Merge pull request 'feat(licenses): web UI for license management' (#270) from feat/inline-visibility-settings into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped

This commit was merged in pull request #270.
This commit is contained in:
2026-05-31 02:27:36 +00:00
4 changed files with 233 additions and 11 deletions
+12
View File
@@ -2632,6 +2632,18 @@
"repo.licenses.licensee": "Licensee",
"repo.licenses.expires": "Expires",
"repo.licenses.never": "Never",
"repo.licenses.new_package": "New Package",
"repo.licenses.description": "Description",
"repo.licenses.max_sites": "Max Sites",
"repo.licenses.channels_help": "Comma-separated channel names (e.g. stable,release-candidate). Leave empty for all channels.",
"repo.licenses.create_package": "Create Package",
"repo.licenses.package_created": "License package created successfully.",
"repo.licenses.generate_key": "Generate Key",
"repo.licenses.key_created": "License Key Created",
"repo.licenses.key_created_copy": "Copy this key now. It will not be shown again.",
"repo.licenses.revoke": "Revoke",
"repo.licenses.key_revoked": "License key revoked.",
"repo.licenses.update_feeds": "Update Feed URLs",
"repo.release.draft": "Draft",
"repo.release.prerelease": "Pre-Release",
"repo.release.stable": "Stable",
+112 -2
View File
@@ -5,10 +5,12 @@ package repo
import (
"net/http"
"strconv"
"time"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
)
@@ -26,6 +28,7 @@ func Licenses(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.licenses")
ctx.Data["PageIsLicenses"] = true
ctx.Data["IsLicensesPage"] = true
ctx.Data["IsRepoAdmin"] = ctx.Repo.IsAdmin()
ownerID := ctx.Repo.Repository.OwnerID
@@ -35,7 +38,6 @@ func Licenses(ctx *context.Context) {
return
}
// Build display list with key counts.
var display []LicensePackageDisplay
for _, pkg := range pkgs {
count, _ := licenses.CountKeysByPackage(ctx, pkg.ID)
@@ -47,7 +49,6 @@ func Licenses(ctx *context.Context) {
}
ctx.Data["LicensePackages"] = display
// List all keys for the owner.
keys, err := licenses.ListLicenseKeys(ctx, ownerID)
if err != nil {
ctx.ServerError("ListLicenseKeys", err)
@@ -57,3 +58,112 @@ func Licenses(ctx *context.Context) {
ctx.HTML(http.StatusOK, tplLicenses)
}
// LicensesCreatePackage handles POST to create a new license package.
func LicensesCreatePackage(ctx *context.Context) {
name := ctx.FormString("name")
if name == "" {
ctx.Flash.Error("Package name is required")
ctx.Redirect(ctx.Repo.RepoLink + "/licenses")
return
}
durationDays, _ := strconv.Atoi(ctx.FormString("duration_days"))
maxSites, _ := strconv.Atoi(ctx.FormString("max_sites"))
pkg := &licenses.LicensePackage{
OwnerID: ctx.Repo.Repository.OwnerID,
Name: name,
Description: ctx.FormString("description"),
DurationDays: durationDays,
MaxSites: maxSites,
AllowedChannels: ctx.FormString("allowed_channels"),
RepoScope: "all",
IsActive: true,
}
if err := licenses.CreateLicensePackage(ctx, pkg); err != nil {
ctx.ServerError("CreateLicensePackage", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.licenses.package_created"))
ctx.Redirect(ctx.Repo.RepoLink + "/licenses")
}
// LicensesGenerateKey handles POST to generate a new key from a package.
func LicensesGenerateKey(ctx *context.Context) {
packageID, _ := strconv.ParseInt(ctx.FormString("package_id"), 10, 64)
if packageID == 0 {
ctx.Flash.Error("Invalid package")
ctx.Redirect(ctx.Repo.RepoLink + "/licenses")
return
}
pkg, err := licenses.GetLicensePackageByID(ctx, packageID)
if err != nil {
ctx.ServerError("GetLicensePackageByID", err)
return
}
key := &licenses.LicenseKey{
PackageID: packageID,
OwnerID: ctx.Repo.Repository.OwnerID,
IsActive: true,
}
// Auto-calculate expiry from package duration.
if pkg.DurationDays > 0 {
expires := time.Now().AddDate(0, 0, pkg.DurationDays)
key.ExpiresUnix = timeutil.TimeStamp(expires.Unix())
}
rawKey, err := licenses.CreateLicenseKey(ctx, key)
if err != nil {
ctx.ServerError("CreateLicenseKey", err)
return
}
ctx.Data["Title"] = ctx.Tr("repo.licenses")
ctx.Data["PageIsLicenses"] = true
ctx.Data["IsLicensesPage"] = true
ctx.Data["IsRepoAdmin"] = ctx.Repo.IsAdmin()
ctx.Data["NewKeyCreated"] = rawKey
// Re-render the page with the new key displayed.
ownerID := ctx.Repo.Repository.OwnerID
pkgs, _ := licenses.ListLicensePackages(ctx, ownerID)
var display []LicensePackageDisplay
for _, p := range pkgs {
count, _ := licenses.CountKeysByPackage(ctx, p.ID)
display = append(display, LicensePackageDisplay{
LicensePackage: p,
KeyCount: count,
Created: time.Unix(int64(p.CreatedUnix), 0),
})
}
ctx.Data["LicensePackages"] = display
keys, _ := licenses.ListLicenseKeys(ctx, ownerID)
ctx.Data["LicenseKeys"] = keys
ctx.HTML(http.StatusOK, tplLicenses)
}
// LicensesRevokeKey handles POST to revoke a license key.
func LicensesRevokeKey(ctx *context.Context) {
keyID := ctx.PathParamInt64("id")
key, err := licenses.GetLicenseKeyByID(ctx, keyID)
if err != nil {
ctx.ServerError("GetLicenseKeyByID", err)
return
}
key.IsActive = false
if err := licenses.UpdateLicenseKey(ctx, key); err != nil {
ctx.ServerError("UpdateLicenseKey", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.licenses.key_revoked"))
ctx.Redirect(ctx.Repo.RepoLink + "/licenses")
}
+5 -2
View File
@@ -1502,8 +1502,11 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
// end "/{username}/{reponame}": update server
// "/{username}/{reponame}": licenses page
m.Group("/{username}/{reponame}", func() {
m.Get("/licenses", repo.Licenses)
m.Group("/{username}/{reponame}/licenses", func() {
m.Get("", repo.Licenses)
m.Post("/packages", repo.LicensesCreatePackage)
m.Post("/keys/generate", repo.LicensesGenerateKey)
m.Post("/keys/{id}/revoke", repo.LicensesRevokeKey)
}, optSignIn, context.RepoAssignment)
// end "/{username}/{reponame}": licenses
+104 -7
View File
@@ -2,12 +2,61 @@
<div role="main" aria-label="{{.Title}}" class="page-content repository">
{{template "repo/header" .}}
<div class="ui container">
<h4 class="ui top attached header">
{{svg "octicon-key" 16}} {{ctx.Locale.Tr "repo.licenses"}}
{{if .NewKeyCreated}}
<div class="ui success message">
<div class="header">{{ctx.Locale.Tr "repo.licenses.key_created"}}</div>
<p>{{ctx.Locale.Tr "repo.licenses.key_created_copy"}}</p>
<code class="tw-text-lg tw-select-all">{{.NewKeyCreated}}</code>
</div>
{{end}}
{{/* License Packages */}}
<h4 class="ui top attached header tw-flex tw-justify-between tw-items-center">
<span>{{svg "octicon-key" 16}} {{ctx.Locale.Tr "repo.licenses.packages"}}</span>
{{if .IsRepoAdmin}}
<button class="ui small primary button" onclick="document.getElementById('new-package-form').classList.toggle('tw-hidden')">
{{ctx.Locale.Tr "repo.licenses.new_package"}}
</button>
{{end}}
</h4>
<div class="ui attached segment">
{{if .IsRepoAdmin}}
<div id="new-package-form" class="tw-hidden tw-mb-4">
<form class="ui form" method="post" action="{{.RepoLink}}/licenses/packages">
{{.CsrfTokenHtml}}
<div class="two fields">
<div class="required field">
<label>{{ctx.Locale.Tr "repo.licenses.package_name"}}</label>
<input name="name" required placeholder="Pro Annual">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.licenses.description"}}</label>
<input name="description" placeholder="Annual pro subscription">
</div>
</div>
<div class="three fields">
<div class="field">
<label>{{ctx.Locale.Tr "repo.licenses.duration"}} ({{ctx.Locale.Tr "repo.licenses.days"}})</label>
<input name="duration_days" type="number" value="0" min="0" placeholder="0 = lifetime">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.licenses.max_sites"}}</label>
<input name="max_sites" type="number" value="0" min="0" placeholder="0 = unlimited">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.licenses.channels"}}</label>
<input name="allowed_channels" placeholder='stable,release-candidate'>
<p class="help">{{ctx.Locale.Tr "repo.licenses.channels_help"}}</p>
</div>
</div>
<button class="ui primary button" type="submit">{{ctx.Locale.Tr "repo.licenses.create_package"}}</button>
</form>
<div class="divider"></div>
</div>
{{end}}
{{if .LicensePackages}}
<h5>{{ctx.Locale.Tr "repo.licenses.packages"}}</h5>
<table class="ui table">
<thead>
<tr>
@@ -16,6 +65,7 @@
<th>{{ctx.Locale.Tr "repo.licenses.channels"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.keys_issued"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.status"}}</th>
{{if .IsRepoAdmin}}<th></th>{{end}}
</tr>
</thead>
<tbody>
@@ -26,6 +76,17 @@
<td>{{if .AllowedChannels}}<code>{{.AllowedChannels}}</code>{{else}}{{ctx.Locale.Tr "repo.licenses.all_channels"}}{{end}}</td>
<td>{{.KeyCount}}</td>
<td>{{if .IsActive}}<span class="ui green label">{{ctx.Locale.Tr "repo.licenses.active"}}</span>{{else}}<span class="ui grey label">{{ctx.Locale.Tr "repo.licenses.inactive"}}</span>{{end}}</td>
{{if $.IsRepoAdmin}}
<td class="tw-text-right">
<form method="post" action="{{$.RepoLink}}/licenses/keys/generate" class="tw-inline">
{{$.CsrfTokenHtml}}
<input type="hidden" name="package_id" value="{{.ID}}">
<button class="ui tiny primary button" type="submit" title="{{ctx.Locale.Tr "repo.licenses.generate_key"}}">
{{svg "octicon-plus" 14}} {{ctx.Locale.Tr "repo.licenses.generate_key"}}
</button>
</form>
</td>
{{end}}
</tr>
{{end}}
</tbody>
@@ -37,10 +98,14 @@
<p>{{ctx.Locale.Tr "repo.licenses.none_desc"}}</p>
</div>
{{end}}
</div>
{{if .LicenseKeys}}
<div class="divider"></div>
<h5>{{ctx.Locale.Tr "repo.licenses.issued_keys"}}</h5>
{{/* Issued Keys */}}
{{if .LicenseKeys}}
<h4 class="ui top attached header tw-mt-4">
{{svg "octicon-lock" 16}} {{ctx.Locale.Tr "repo.licenses.issued_keys"}}
</h4>
<div class="ui attached segment">
<table class="ui table">
<thead>
<tr>
@@ -48,6 +113,7 @@
<th>{{ctx.Locale.Tr "repo.licenses.licensee"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.expires"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.status"}}</th>
{{if .IsRepoAdmin}}<th></th>{{end}}
</tr>
</thead>
<tbody>
@@ -57,11 +123,42 @@
<td>{{.LicenseeName}}{{if .LicenseeEmail}} <small>({{.LicenseeEmail}})</small>{{end}}</td>
<td>{{if eq .ExpiresUnix 0}}{{ctx.Locale.Tr "repo.licenses.never"}}{{else}}{{DateUtils.TimeSince .ExpiresUnix}}{{end}}</td>
<td>{{if .IsActive}}<span class="ui green label">{{ctx.Locale.Tr "repo.licenses.active"}}</span>{{else}}<span class="ui grey label">{{ctx.Locale.Tr "repo.licenses.inactive"}}</span>{{end}}</td>
{{if $.IsRepoAdmin}}
<td class="tw-text-right">
<form method="post" action="{{$.RepoLink}}/licenses/keys/{{.ID}}/revoke" class="tw-inline">
{{$.CsrfTokenHtml}}
<button class="ui tiny red button" type="submit" title="{{ctx.Locale.Tr "repo.licenses.revoke"}}">
{{svg "octicon-x" 14}}
</button>
</form>
</td>
{{end}}
</tr>
{{end}}
</tbody>
</table>
{{end}}
</div>
{{end}}
{{/* Update Feed Info */}}
<h4 class="ui top attached header tw-mt-4">
{{svg "octicon-rss" 16}} {{ctx.Locale.Tr "repo.licenses.update_feeds"}}
</h4>
<div class="ui attached segment">
<div class="field">
<label>Joomla updates.xml</label>
<div class="ui action input tw-w-full">
<input type="text" readonly value="{{AppUrl}}{{.Repository.Owner.Name}}/{{.Repository.Name}}/updates.xml" onclick="this.select()">
<button class="ui button" onclick="navigator.clipboard.writeText(this.previousElementSibling.value)">{{svg "octicon-copy" 14}}</button>
</div>
</div>
<div class="field tw-mt-2">
<label>Dolibarr JSON</label>
<div class="ui action input tw-w-full">
<input type="text" readonly value="{{AppUrl}}{{.Repository.Owner.Name}}/{{.Repository.Name}}/updates/dolibarr.json" onclick="this.select()">
<button class="ui button" onclick="navigator.clipboard.writeText(this.previousElementSibling.value)">{{svg "octicon-copy" 14}}</button>
</div>
</div>
</div>
</div>
</div>