feat(licenses): UI/UX cleanup, permissions, renew, auto-domain, custom keys

- Replace confirm() with Gitea modal system (link-action + data-modal-confirm)
- Add confirmation modal to revoke key action
- Fix clipboard copy to use data-clipboard-target with tooltip feedback
- Localize all hardcoded English strings (feed labels, "unlimited", "Master")
- Improve key creation flash with security-focused message + copy button
- Add count badge to org licenses nav tab
- Add icon to org settings navbar for update streams
- Add help text to "Active" checkboxes explaining deactivation impact
- Fix empty state message to reference UI creation (not just API)
- Compact tables for denser license data display
- Add orange "Master" label to master package rows
- Conditional feed buttons on release page (only when licensing enabled)
- Add TypeLicenses unit type with Read/Write/Admin team permissions
- Route-level permission enforcement via RequireUnitReader/Writer
- Add "Renew" action for license keys (extends by package duration)
- Auto-associate domain on first heartbeat (lock-on-first-use)
- Enforce max_sites limit during domain auto-association
- Allow site admins and org owners to set custom license key values

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Miller
2026-05-31 08:54:29 -05:00
parent b77da17f38
commit ed79a48119
16 changed files with 313 additions and 89 deletions
+80
View File
@@ -77,6 +77,19 @@ func CreateLicenseKey(ctx context.Context, key *LicenseKey) (rawKey string, err
return rawKey, nil
}
// CreateLicenseKeyCustom stores a key with a user-provided raw key string.
// The raw key is hashed and stored — it will not be recoverable after this.
func CreateLicenseKeyCustom(ctx context.Context, key *LicenseKey, rawKey string) error {
key.KeyHash = HashKey(rawKey)
if len(rawKey) > 12 {
key.KeyPrefix = rawKey[:12] + "..."
} else {
key.KeyPrefix = rawKey
}
_, err := db.GetEngine(ctx).Insert(key)
return err
}
// GetLicenseKeyByHash looks up a key by its SHA-256 hash.
func GetLicenseKeyByHash(ctx context.Context, hash string) (*LicenseKey, error) {
key := new(LicenseKey)
@@ -160,6 +173,8 @@ func DeleteLicenseKey(ctx context.Context, id int64) error {
// Returns the key record and its associated package, or an error.
// The domain parameter is optional — when provided, it is checked against
// the key's DomainRestriction list and the MaxSites limit.
// On first heartbeat with a domain, if no DomainRestriction is set, the domain
// is automatically associated as the key's restriction (lock-on-first-use).
func ValidateLicenseKey(ctx context.Context, rawKey, domain string) (*LicenseKey, *LicensePackage, error) {
hash := HashKey(rawKey)
key, err := GetLicenseKeyByHash(ctx, hash)
@@ -201,6 +216,32 @@ func ValidateLicenseKey(ctx context.Context, rawKey, domain string) (*LicenseKey
if !allowed {
return nil, nil, fmt.Errorf("domain not allowed for this license key")
}
} else {
// No domain restriction set — auto-associate on first heartbeat.
// Append this domain to the restriction list, enforcing max_sites.
maxSites := key.MaxSites
if maxSites == 0 {
maxSites = pkg.MaxSites
}
domainKnown, _ := IsDomainKnownForKey(ctx, key.ID, domain)
if !domainKnown {
if maxSites > 0 {
uniqueDomains, err := CountUniqueDomainsByKey(ctx, key.ID)
if err != nil {
return nil, nil, fmt.Errorf("failed to count domains: %w", err)
}
if uniqueDomains >= int64(maxSites) {
return nil, nil, fmt.Errorf("site limit reached (%d/%d)", uniqueDomains, maxSites)
}
}
// Append this domain to the key's restriction list.
_ = updateDomainRestriction(ctx, key.ID, domain)
if key.DomainRestriction == "" {
key.DomainRestriction = domain
} else {
key.DomainRestriction = key.DomainRestriction + "," + domain
}
}
}
// Site limit check: use key's MaxSites, fall back to package default.
@@ -223,3 +264,42 @@ func ValidateLicenseKey(ctx context.Context, rawKey, domain string) (*LicenseKey
return key, pkg, nil
}
// updateDomainRestriction appends a domain to a key's DomainRestriction field in the DB.
func updateDomainRestriction(ctx context.Context, keyID int64, domain string) error {
key, err := GetLicenseKeyByID(ctx, keyID)
if err != nil {
return err
}
if key.DomainRestriction == "" {
key.DomainRestriction = domain
} else {
key.DomainRestriction = key.DomainRestriction + "," + domain
}
_, err = db.GetEngine(ctx).ID(keyID).Cols("domain_restriction").Update(key)
return err
}
// RenewLicenseKey extends the expiration of a key by the given number of days
// from the current expiry (or from now if already expired/no expiry set).
func RenewLicenseKey(ctx context.Context, keyID int64, days int) error {
key, err := GetLicenseKeyByID(ctx, keyID)
if err != nil {
return err
}
now := timeutil.TimeStampNow()
var base timeutil.TimeStamp
if key.ExpiresUnix > 0 && key.ExpiresUnix > now {
// Key still valid — extend from current expiry.
base = key.ExpiresUnix
} else {
// Key expired or has no expiry — extend from now.
base = now
}
key.ExpiresUnix = base + timeutil.TimeStamp(int64(days)*86400)
key.IsActive = true
_, err = db.GetEngine(ctx).ID(keyID).Cols("expires_unix", "is_active").Update(key)
return err
}
+5
View File
@@ -83,6 +83,11 @@ func UpdateLicensePackage(ctx context.Context, pkg *LicensePackage) error {
return err
}
// CountOrgPackages returns the number of license packages for an organization.
func CountOrgPackages(ctx context.Context, orgID int64) (int64, error) {
return db.GetEngine(ctx).Where("owner_id = ?", orgID).Count(new(LicensePackage))
}
// DeleteLicensePackage deletes a license package by ID.
func DeleteLicensePackage(ctx context.Context, id int64) error {
_, err := db.GetEngine(ctx).ID(id).Delete(new(LicensePackage))
+12 -3
View File
@@ -33,9 +33,7 @@ const (
TypeProjects // 8 Projects
TypePackages // 9 Packages
TypeActions // 10 Actions
// FIXME: TEAM-UNIT-PERMISSION: the team unit "admin" permission's design is not right, when a new unit is added in the future,
// admin team won't inherit the correct admin permission for the new unit, need to have a complete fix before adding any new unit.
TypeLicenses // 11 Licenses
)
// Value returns integer value for unit type (used by template)
@@ -65,6 +63,7 @@ var (
TypeProjects,
TypePackages,
TypeActions,
TypeLicenses,
}
// DefaultRepoUnits contains the default unit types
@@ -328,6 +327,15 @@ var (
perm.AccessModeOwner,
}
UnitLicenses = Unit{
TypeLicenses,
"repo.licenses",
"/licenses",
"repo.licenses.desc",
8,
perm.AccessModeOwner,
}
// Units contains all the units
Units = map[Type]Unit{
TypeCode: UnitCode,
@@ -340,6 +348,7 @@ var (
TypeProjects: UnitProjects,
TypePackages: UnitPackages,
TypeActions: UnitActions,
TypeLicenses: UnitLicenses,
}
)
+18 -2
View File
@@ -2635,7 +2635,7 @@
"repo.licenses.active": "Active",
"repo.licenses.inactive": "Inactive",
"repo.licenses.none": "No License Packages",
"repo.licenses.none_desc": "License packages can be created via the API to gate access to update streams.",
"repo.licenses.none_desc": "Create a license package to start managing keys and gating update feeds.",
"repo.licenses.issued_keys": "Issued Keys",
"repo.licenses.key_prefix": "Key",
"repo.licenses.licensee": "Licensee",
@@ -2650,7 +2650,7 @@
"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.key_created_copy": "This key is hashed before storage and cannot be retrieved later. Copy and store it securely now.",
"repo.licenses.revoke": "Revoke",
"repo.licenses.edit_package": "Edit License Package",
"repo.licenses.delete_package": "Delete Package",
@@ -2670,6 +2670,22 @@
"repo.licenses.expires_at_help": "Leave empty for no expiry (lifetime).",
"repo.licenses.key_updated": "License key updated.",
"repo.licenses.last_seen": "Last Seen",
"repo.licenses.confirm_delete_package": "Delete this package? This action cannot be undone.",
"repo.licenses.confirm_revoke_key": "Revoke this license key? The licensee will immediately lose access to update feeds.",
"repo.licenses.feed_joomla_xml": "Joomla XML",
"repo.licenses.feed_dolibarr_json": "Dolibarr JSON",
"repo.licenses.feed_joomla_updates": "Joomla updates.xml",
"repo.licenses.feed_dolibarr_updates": "Dolibarr JSON",
"repo.licenses.master_label": "Master",
"repo.licenses.unlimited": "unlimited",
"repo.licenses.active_help_package": "Deactivating blocks new key creation and disables all issued keys.",
"repo.licenses.active_help_key": "Deactivating immediately blocks update feed access for this licensee.",
"repo.licenses.renew": "Renew",
"repo.licenses.key_renewed": "License key renewed for %d days.",
"repo.licenses.confirm_renew_key": "Renew this license key? The expiration will be extended by the package duration.",
"repo.licenses.desc": "License packages and keys for gating update feeds.",
"repo.licenses.custom_key_placeholder": "Custom key (optional)",
"repo.licenses.custom_key_help": "Leave empty to auto-generate. Site admins and org owners can set a custom key value.",
"repo.release.draft": "Draft",
"repo.release.prerelease": "Pre-Release",
"repo.release.stable": "Stable",
+4
View File
@@ -111,6 +111,10 @@ func home(ctx *context.Context, viewRepositories bool) {
orgCfg, _ := licenses_model.GetOrgConfig(ctx, ctx.Org.Organization.ID)
ctx.Data["OrgLicensingEnabled"] = orgCfg != nil && orgCfg.LicensingEnabled
if orgCfg != nil && orgCfg.LicensingEnabled {
numPkgs, _ := licenses_model.CountOrgPackages(ctx, ctx.Org.Organization.ID)
ctx.Data["NumOrgLicensePackages"] = numPkgs
}
ctx.Data["IsPublicMember"] = func(uid int64) bool {
return membersIsPublic[uid]
}
+51 -7
View File
@@ -10,6 +10,8 @@ import (
"time"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/perm"
unit_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/unit"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/json"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
@@ -54,8 +56,10 @@ func Licenses(ctx *context.Context) {
org := ctx.Org.Organization
ownerID := org.ID
// Auto-create master key if org owner.
if ctx.Org.IsOwner {
canWriteLicenses := ctx.Org.Organization.UnitPermission(ctx, ctx.Doer, unit_model.TypeLicenses) >= perm.AccessModeWrite || ctx.IsUserSiteAdmin()
// Auto-create master key if has write access.
if canWriteLicenses {
newMasterKey, err := licenses.EnsureMasterKey(ctx, ownerID)
if err != nil {
ctx.ServerError("EnsureMasterKey", err)
@@ -89,7 +93,7 @@ func Licenses(ctx *context.Context) {
return
}
ctx.Data["LicenseKeys"] = keys
ctx.Data["IsRepoAdmin"] = ctx.Org.IsOwner
ctx.Data["IsRepoAdmin"] = canWriteLicenses
ctx.Data["IsSiteAdmin"] = ctx.IsUserSiteAdmin()
ctx.Data["OrgLicensingEnabled"] = true
@@ -168,10 +172,21 @@ func LicensesGenerateKey(ctx *context.Context) {
key.ExpiresUnix = timeutil.TimeStamp(expires.Unix())
}
rawKey, err := licenses.CreateLicenseKey(ctx, key)
if err != nil {
ctx.ServerError("CreateLicenseKey", err)
return
// Site admins and org owners can manually set a custom key.
var rawKey string
customKey := strings.TrimSpace(ctx.FormString("custom_key"))
if customKey != "" && (ctx.IsUserSiteAdmin() || ctx.Org.IsOwner) {
if err := licenses.CreateLicenseKeyCustom(ctx, key, customKey); err != nil {
ctx.ServerError("CreateLicenseKeyCustom", err)
return
}
rawKey = customKey
} else {
rawKey, err = licenses.CreateLicenseKey(ctx, key)
if err != nil {
ctx.ServerError("CreateLicenseKey", err)
return
}
}
// Re-render with the new key shown.
@@ -392,3 +407,32 @@ func LicensesRevokeKey(ctx *context.Context) {
ctx.Flash.Success(ctx.Tr("repo.licenses.key_revoked"))
ctx.Redirect(ctx.Org.OrgLink + "/-/licenses")
}
// LicensesRenewKey extends a license key's expiration by the package's duration.
func LicensesRenewKey(ctx *context.Context) {
keyID := ctx.PathParamInt64("id")
key, err := licenses.GetLicenseKeyByID(ctx, keyID)
if err != nil {
ctx.ServerError("GetLicenseKeyByID", err)
return
}
pkg, err := licenses.GetLicensePackageByID(ctx, key.PackageID)
if err != nil {
ctx.ServerError("GetLicensePackageByID", err)
return
}
days := pkg.DurationDays
if days == 0 {
days = 365 // default to 1 year for lifetime packages
}
if err := licenses.RenewLicenseKey(ctx, keyID, days); err != nil {
ctx.ServerError("RenewLicenseKey", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.licenses.key_renewed", days))
ctx.Redirect(ctx.Org.OrgLink + "/-/licenses")
}
+49 -7
View File
@@ -10,6 +10,7 @@ import (
"time"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
unit_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/unit"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/json"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
@@ -51,13 +52,14 @@ 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.Permission.IsAdmin()
canWriteLicenses := ctx.Repo.Permission.CanWrite(unit_model.TypeLicenses) || ctx.IsUserSiteAdmin()
ctx.Data["IsRepoAdmin"] = canWriteLicenses
ctx.Data["IsSiteAdmin"] = ctx.IsUserSiteAdmin()
ownerID := ctx.Repo.Repository.OwnerID
// Auto-create master package + key if admin and none exist.
if ctx.Repo.Permission.IsAdmin() {
if canWriteLicenses {
newMasterKey, err := licenses.EnsureMasterKey(ctx, ownerID)
if err != nil {
ctx.ServerError("EnsureMasterKey", err)
@@ -169,16 +171,27 @@ func LicensesGenerateKey(ctx *context.Context) {
key.ExpiresUnix = timeutil.TimeStamp(expires.Unix())
}
rawKey, err := licenses.CreateLicenseKey(ctx, key)
if err != nil {
ctx.ServerError("CreateLicenseKey", err)
return
// Site admins and org owners can manually set a custom key.
var rawKey string
customKey := strings.TrimSpace(ctx.FormString("custom_key"))
if customKey != "" && (ctx.IsUserSiteAdmin() || ctx.Repo.Permission.IsOwner()) {
if err := licenses.CreateLicenseKeyCustom(ctx, key, customKey); err != nil {
ctx.ServerError("CreateLicenseKeyCustom", err)
return
}
rawKey = customKey
} else {
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.Permission.IsAdmin()
ctx.Data["IsRepoAdmin"] = ctx.Repo.Permission.CanWrite(unit_model.TypeLicenses) || ctx.IsUserSiteAdmin()
ctx.Data["IsSiteAdmin"] = ctx.IsUserSiteAdmin()
ctx.Data["NewKeyCreated"] = rawKey
@@ -398,3 +411,32 @@ func LicensesDeletePackage(ctx *context.Context) {
ctx.Flash.Success(ctx.Tr("repo.licenses.package_deleted"))
ctx.Redirect(ctx.Repo.RepoLink + "/licenses")
}
// LicensesRenewKey extends a license key's expiration by the package's duration.
func LicensesRenewKey(ctx *context.Context) {
keyID := ctx.PathParamInt64("id")
key, err := licenses.GetLicenseKeyByID(ctx, keyID)
if err != nil {
ctx.ServerError("GetLicenseKeyByID", err)
return
}
pkg, err := licenses.GetLicensePackageByID(ctx, key.PackageID)
if err != nil {
ctx.ServerError("GetLicensePackageByID", err)
return
}
days := pkg.DurationDays
if days == 0 {
days = 365 // default to 1 year for lifetime packages
}
if err := licenses.RenewLicenseKey(ctx, keyID, days); err != nil {
ctx.ServerError("RenewLicenseKey", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.licenses.key_renewed", days))
ctx.Redirect(ctx.Repo.RepoLink + "/licenses")
}
+24 -18
View File
@@ -1106,15 +1106,18 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Group("/licenses", func() {
m.Get("", org.Licenses)
m.Post("/packages", org.LicensesCreatePackage)
m.Get("/packages/{id}/edit", org.LicensesEditPackage)
m.Post("/packages/{id}/edit", org.LicensesEditPackagePost)
m.Post("/packages/{id}/delete", org.LicensesDeletePackage)
m.Post("/keys/generate", org.LicensesGenerateKey)
m.Get("/keys/{id}/edit", org.LicensesEditKey)
m.Post("/keys/{id}/edit", org.LicensesEditKeyPost)
m.Post("/keys/{id}/revoke", org.LicensesRevokeKey)
})
m.Group("", func() {
m.Post("/packages", org.LicensesCreatePackage)
m.Get("/packages/{id}/edit", org.LicensesEditPackage)
m.Post("/packages/{id}/edit", org.LicensesEditPackagePost)
m.Post("/packages/{id}/delete", org.LicensesDeletePackage)
m.Post("/keys/generate", org.LicensesGenerateKey)
m.Get("/keys/{id}/edit", org.LicensesEditKey)
m.Post("/keys/{id}/edit", org.LicensesEditKeyPost)
m.Post("/keys/{id}/revoke", org.LicensesRevokeKey)
m.Post("/keys/{id}/renew", org.LicensesRenewKey)
}, reqUnitAccess(unit.TypeLicenses, perm.AccessModeWrite, true))
}, reqUnitAccess(unit.TypeLicenses, perm.AccessModeRead, true))
m.Get("/repositories", org.Repositories)
m.Get("/heatmap", user.DashboardHeatmap)
@@ -1521,15 +1524,18 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
// "/{username}/{reponame}": licenses page
m.Group("/{username}/{reponame}/licenses", func() {
m.Get("", repo.Licenses)
m.Post("/packages", repo.LicensesCreatePackage)
m.Get("/packages/{id}/edit", repo.LicensesEditPackage)
m.Post("/packages/{id}/edit", repo.LicensesEditPackagePost)
m.Post("/packages/{id}/delete", repo.LicensesDeletePackage)
m.Post("/keys/generate", repo.LicensesGenerateKey)
m.Get("/keys/{id}/edit", repo.LicensesEditKey)
m.Post("/keys/{id}/edit", repo.LicensesEditKeyPost)
m.Post("/keys/{id}/revoke", repo.LicensesRevokeKey)
}, optSignIn, context.RepoAssignment)
m.Group("", func() {
m.Post("/packages", repo.LicensesCreatePackage)
m.Get("/packages/{id}/edit", repo.LicensesEditPackage)
m.Post("/packages/{id}/edit", repo.LicensesEditPackagePost)
m.Post("/packages/{id}/delete", repo.LicensesDeletePackage)
m.Post("/keys/generate", repo.LicensesGenerateKey)
m.Get("/keys/{id}/edit", repo.LicensesEditKey)
m.Post("/keys/{id}/edit", repo.LicensesEditKeyPost)
m.Post("/keys/{id}/revoke", repo.LicensesRevokeKey)
m.Post("/keys/{id}/renew", repo.LicensesRenewKey)
}, context.RequireUnitWriter(unit.TypeLicenses))
}, optSignIn, context.RepoAssignment, context.RequireUnitReader(unit.TypeLicenses))
// end "/{username}/{reponame}": licenses
m.Group("/{username}/{reponame}", func() { // to maintain compatibility with old attachments
+26 -20
View File
@@ -7,7 +7,10 @@
<div class="ui info message">
<div class="header">{{ctx.Locale.Tr "repo.licenses.master_key_created"}}</div>
<p>{{ctx.Locale.Tr "repo.licenses.master_key_created_copy"}}</p>
<code class="tw-text-lg tw-select-all">{{.NewMasterKey}}</code>
<div class="ui action input tw-w-full tw-mt-2">
<input class="js-new-master-key" type="text" readonly value="{{.NewMasterKey}}" onclick="this.select()">
<button class="ui button" data-clipboard-target=".js-new-master-key" data-tooltip-content="{{ctx.Locale.Tr "copy_url"}}">{{svg "octicon-copy" 14}}</button>
</div>
</div>
{{end}}
@@ -15,7 +18,10 @@
<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 class="ui action input tw-w-full tw-mt-2">
<input class="js-new-license-key" type="text" readonly value="{{.NewKeyCreated}}" onclick="this.select()">
<button class="ui button" data-clipboard-target=".js-new-license-key" data-tooltip-content="{{ctx.Locale.Tr "copy_url"}}">{{svg "octicon-copy" 14}}</button>
</div>
</div>
{{end}}
@@ -48,7 +54,7 @@
<div class="field">
<label>{{ctx.Locale.Tr "repo.licenses.max_sites"}}</label>
<input name="max_sites" type="number" value="0" min="0">
<p class="help">0 = unlimited</p>
<p class="help">0 = {{ctx.Locale.Tr "repo.licenses.unlimited"}}</p>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.licenses.channels"}}</label>
@@ -69,7 +75,7 @@
</details>
{{end}}
{{if .LicensePackages}}
<table class="ui table">
<table class="ui compact table">
<thead>
<tr>
<th>{{ctx.Locale.Tr "repo.licenses.package_name"}}</th>
@@ -83,16 +89,19 @@
<tbody>
{{range .LicensePackages}}
<tr>
<td><strong>{{.Name}}</strong>{{if .Description}}<br><small class="text grey">{{.Description}}</small>{{end}}</td>
<td><strong>{{.Name}}</strong>{{if eq .Name "Master (Internal)"}} <span class="ui tiny orange label">{{ctx.Locale.Tr "repo.licenses.master_label"}}</span>{{end}}{{if .Description}}<br><small class="text grey">{{.Description}}</small>{{end}}</td>
<td>{{if eq .DurationDays 0}}{{ctx.Locale.Tr "repo.licenses.lifetime"}}{{else}}{{.DurationDays}} {{ctx.Locale.Tr "repo.licenses.days"}}{{end}}</td>
<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 tw-flex tw-gap-1 tw-justify-end">
<form method="post" action="{{$.Org.HomeLink}}/-/licenses/keys/generate" class="tw-inline">
<form method="post" action="{{$.Org.HomeLink}}/-/licenses/keys/generate" class="tw-inline tw-flex tw-gap-1 tw-items-center">
{{$.CsrfTokenHtml}}
<input type="hidden" name="package_id" value="{{.ID}}">
{{if or $.IsSiteAdmin $.IsOrganizationOwner}}
<input type="text" name="custom_key" placeholder="{{ctx.Locale.Tr "repo.licenses.custom_key_placeholder"}}" class="tw-w-32 tw-text-xs" title="{{ctx.Locale.Tr "repo.licenses.custom_key_help"}}">
{{end}}
<button class="ui tiny primary button" type="submit" title="{{ctx.Locale.Tr "repo.licenses.generate_key"}}">
{{svg "octicon-plus" 14}}
</button>
@@ -102,12 +111,9 @@
{{svg "octicon-pencil" 14}}
</a>
{{if $.IsSiteAdmin}}
<form method="post" action="{{$.Org.HomeLink}}/-/licenses/packages/{{.ID}}/delete" class="tw-inline" onsubmit="return confirm('Delete this package? This action cannot be undone.')">
{{$.CsrfTokenHtml}}
<button class="ui tiny red button" type="submit" title="{{ctx.Locale.Tr "repo.licenses.delete_package"}}">
{{svg "octicon-trash" 14}}
</button>
</form>
<button class="ui tiny red button link-action" data-url="{{$.Org.HomeLink}}/-/licenses/packages/{{.ID}}/delete" data-modal-confirm="{{ctx.Locale.Tr "repo.licenses.confirm_delete_package"}}" title="{{ctx.Locale.Tr "repo.licenses.delete_package"}}">
{{svg "octicon-trash" 14}}
</button>
{{end}}
{{end}}
</td>
@@ -130,7 +136,7 @@
{{svg "octicon-lock" 16}} {{ctx.Locale.Tr "repo.licenses.issued_keys"}}
</h4>
<div class="ui attached segment">
<table class="ui table">
<table class="ui compact table">
<thead>
<tr>
<th>{{ctx.Locale.Tr "repo.licenses.key_prefix"}}</th>
@@ -144,7 +150,7 @@
<tbody>
{{range .LicenseKeys}}
<tr>
<td><code>{{.KeyPrefix}}</code>{{if .IsInternal}} <span class="ui tiny orange label">Master</span>{{end}}</td>
<td><code>{{.KeyPrefix}}</code>{{if .IsInternal}} <span class="ui tiny orange label">{{ctx.Locale.Tr "repo.licenses.master_label"}}</span>{{end}}</td>
<td>{{.LicenseeName}}{{if .LicenseeEmail}} <small>({{.LicenseeEmail}})</small>{{end}}</td>
<td>{{if eq .ExpiresUnix 0}}{{ctx.Locale.Tr "repo.licenses.never"}}{{else}}{{DateUtils.AbsoluteShort .ExpiresUnix}}{{end}}</td>
<td>{{if eq .LastHeartbeatUnix 0}}{{ctx.Locale.Tr "repo.licenses.never"}}{{else}}{{DateUtils.AbsoluteShort .LastHeartbeatUnix}}{{end}}</td>
@@ -155,13 +161,13 @@
<a class="ui tiny button" href="{{$.Org.HomeLink}}/-/licenses/keys/{{.ID}}/edit" title="{{ctx.Locale.Tr "repo.licenses.edit_key"}}">
{{svg "octicon-pencil" 14}}
</a>
<button class="ui tiny green button link-action" data-url="{{$.Org.HomeLink}}/-/licenses/keys/{{.ID}}/renew" data-modal-confirm="{{ctx.Locale.Tr "repo.licenses.confirm_renew_key"}}" title="{{ctx.Locale.Tr "repo.licenses.renew"}}">
{{svg "octicon-sync" 14}}
</button>
{{end}}
<form method="post" action="{{$.Org.HomeLink}}/-/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>
<button class="ui tiny red button link-action" data-url="{{$.Org.HomeLink}}/-/licenses/keys/{{.ID}}/revoke" data-modal-confirm="{{ctx.Locale.Tr "repo.licenses.confirm_revoke_key"}}" title="{{ctx.Locale.Tr "repo.licenses.revoke"}}">
{{svg "octicon-x" 14}}
</button>
</td>
{{end}}
</tr>
+2 -1
View File
@@ -27,7 +27,7 @@
<div class="field">
<label>{{ctx.Locale.Tr "repo.licenses.max_sites"}}</label>
<input name="max_sites" type="number" value="{{.Package.MaxSites}}" min="0">
<p class="help">0 = unlimited</p>
<p class="help">0 = {{ctx.Locale.Tr "repo.licenses.unlimited"}}</p>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.licenses.channels"}}</label>
@@ -47,6 +47,7 @@
<input name="is_active" type="checkbox" {{if .Package.IsActive}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.licenses.active"}}</label>
</div>
<p class="help">{{ctx.Locale.Tr "repo.licenses.active_help_package"}}</p>
</div>
<div class="field tw-mt-4">
<button class="ui primary button" type="submit">{{ctx.Locale.Tr "save"}}</button>
+3
View File
@@ -28,6 +28,9 @@
{{if and .IsOrganizationMember (or .OrgLicensingEnabled .IsLicensesPage)}}
<a class="{{if .IsLicensesPage}}active {{end}}item" href="{{$.Org.HomeLink}}/-/licenses">
{{svg "octicon-key"}} {{ctx.Locale.Tr "repo.licenses"}}
{{if .NumOrgLicensePackages}}
<div class="ui small label">{{.NumOrgLicensePackages}}</div>
{{end}}
</a>
{{end}}
{{if and .IsRepoIndexerEnabled .CanReadCode}}
+1 -1
View File
@@ -26,7 +26,7 @@
</a>
{{end}}
<a class="{{if .PageIsSettingsUpdateStreams}}active {{end}}item" href="{{.OrgLink}}/settings/update-streams">
{{ctx.Locale.Tr "org.settings.update_streams"}}
{{svg "octicon-key"}} {{ctx.Locale.Tr "org.settings.update_streams"}}
</a>
{{if .EnableActions}}
<details class="item toggleable-item" {{if or .PageIsOrgSettingsActionsGeneral .PageIsSharedSettingsRunners .PageIsSharedSettingsSecrets .PageIsSharedSettingsVariables}}open{{end}}>
+32 -26
View File
@@ -7,7 +7,10 @@
<div class="ui info message">
<div class="header">{{ctx.Locale.Tr "repo.licenses.master_key_created"}}</div>
<p>{{ctx.Locale.Tr "repo.licenses.master_key_created_copy"}}</p>
<code class="tw-text-lg tw-select-all">{{.NewMasterKey}}</code>
<div class="ui action input tw-w-full tw-mt-2">
<input class="js-new-master-key" type="text" readonly value="{{.NewMasterKey}}" onclick="this.select()">
<button class="ui button" data-clipboard-target=".js-new-master-key" data-tooltip-content="{{ctx.Locale.Tr "copy_url"}}">{{svg "octicon-copy" 14}}</button>
</div>
</div>
{{end}}
@@ -15,7 +18,10 @@
<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 class="ui action input tw-w-full tw-mt-2">
<input class="js-new-license-key" type="text" readonly value="{{.NewKeyCreated}}" onclick="this.select()">
<button class="ui button" data-clipboard-target=".js-new-license-key" data-tooltip-content="{{ctx.Locale.Tr "copy_url"}}">{{svg "octicon-copy" 14}}</button>
</div>
</div>
{{end}}
@@ -25,7 +31,7 @@
</h4>
<div class="ui attached segment">
{{if .LicensePackages}}
<table class="ui table">
<table class="ui compact table">
<thead>
<tr>
<th>{{ctx.Locale.Tr "repo.licenses.package_name"}}</th>
@@ -39,16 +45,19 @@
<tbody>
{{range .LicensePackages}}
<tr>
<td><strong>{{.Name}}</strong>{{if .Description}}<br><small class="text grey">{{.Description}}</small>{{end}}</td>
<td><strong>{{.Name}}</strong>{{if eq .Name "Master (Internal)"}} <span class="ui tiny orange label">{{ctx.Locale.Tr "repo.licenses.master_label"}}</span>{{end}}{{if .Description}}<br><small class="text grey">{{.Description}}</small>{{end}}</td>
<td>{{if eq .DurationDays 0}}{{ctx.Locale.Tr "repo.licenses.lifetime"}}{{else}}{{.DurationDays}} {{ctx.Locale.Tr "repo.licenses.days"}}{{end}}</td>
<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 tw-flex tw-gap-1 tw-justify-end">
<form method="post" action="{{$.RepoLink}}/licenses/keys/generate" class="tw-inline">
<form method="post" action="{{$.RepoLink}}/licenses/keys/generate" class="tw-inline tw-flex tw-gap-1 tw-items-center">
{{$.CsrfTokenHtml}}
<input type="hidden" name="package_id" value="{{.ID}}">
{{if $.IsSiteAdmin}}
<input type="text" name="custom_key" placeholder="{{ctx.Locale.Tr "repo.licenses.custom_key_placeholder"}}" class="tw-w-32 tw-text-xs" title="{{ctx.Locale.Tr "repo.licenses.custom_key_help"}}">
{{end}}
<button class="ui tiny primary button" type="submit" title="{{ctx.Locale.Tr "repo.licenses.generate_key"}}">
{{svg "octicon-plus" 14}}
</button>
@@ -58,12 +67,9 @@
{{svg "octicon-pencil" 14}}
</a>
{{if $.IsSiteAdmin}}
<form method="post" action="{{$.RepoLink}}/licenses/packages/{{.ID}}/delete" class="tw-inline" onsubmit="return confirm('Delete this package? This action cannot be undone.')">
{{$.CsrfTokenHtml}}
<button class="ui tiny red button" type="submit" title="{{ctx.Locale.Tr "repo.licenses.delete_package"}}">
{{svg "octicon-trash" 14}}
</button>
</form>
<button class="ui tiny red button link-action" data-url="{{$.RepoLink}}/licenses/packages/{{.ID}}/delete" data-modal-confirm="{{ctx.Locale.Tr "repo.licenses.confirm_delete_package"}}" title="{{ctx.Locale.Tr "repo.licenses.delete_package"}}">
{{svg "octicon-trash" 14}}
</button>
{{end}}
{{end}}
</td>
@@ -108,7 +114,7 @@
<div class="field">
<label>{{ctx.Locale.Tr "repo.licenses.max_sites"}}</label>
<input name="max_sites" type="number" value="0" min="0">
<p class="help">0 = unlimited</p>
<p class="help">0 = {{ctx.Locale.Tr "repo.licenses.unlimited"}}</p>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.licenses.channels"}}</label>
@@ -136,7 +142,7 @@
{{svg "octicon-lock" 16}} {{ctx.Locale.Tr "repo.licenses.issued_keys"}}
</h4>
<div class="ui attached segment">
<table class="ui table">
<table class="ui compact table">
<thead>
<tr>
<th>{{ctx.Locale.Tr "repo.licenses.key_prefix"}}</th>
@@ -150,7 +156,7 @@
<tbody>
{{range .LicenseKeys}}
<tr>
<td><code>{{.KeyPrefix}}</code>{{if .IsInternal}} <span class="ui tiny orange label">Master</span>{{end}}</td>
<td><code>{{.KeyPrefix}}</code>{{if .IsInternal}} <span class="ui tiny orange label">{{ctx.Locale.Tr "repo.licenses.master_label"}}</span>{{end}}</td>
<td>{{.LicenseeName}}{{if .LicenseeEmail}} <small>({{.LicenseeEmail}})</small>{{end}}</td>
<td>{{if eq .ExpiresUnix 0}}{{ctx.Locale.Tr "repo.licenses.never"}}{{else}}{{DateUtils.AbsoluteShort .ExpiresUnix}}{{end}}</td>
<td>{{if eq .LastHeartbeatUnix 0}}{{ctx.Locale.Tr "repo.licenses.never"}}{{else}}{{DateUtils.AbsoluteShort .LastHeartbeatUnix}}{{end}}</td>
@@ -161,13 +167,13 @@
<a class="ui tiny button" href="{{$.RepoLink}}/licenses/keys/{{.ID}}/edit" title="{{ctx.Locale.Tr "repo.licenses.edit_key"}}">
{{svg "octicon-pencil" 14}}
</a>
<button class="ui tiny green button link-action" data-url="{{$.RepoLink}}/licenses/keys/{{.ID}}/renew" data-modal-confirm="{{ctx.Locale.Tr "repo.licenses.confirm_renew_key"}}" title="{{ctx.Locale.Tr "repo.licenses.renew"}}">
{{svg "octicon-sync" 14}}
</button>
{{end}}
<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>
<button class="ui tiny red button link-action" data-url="{{$.RepoLink}}/licenses/keys/{{.ID}}/revoke" data-modal-confirm="{{ctx.Locale.Tr "repo.licenses.confirm_revoke_key"}}" title="{{ctx.Locale.Tr "repo.licenses.revoke"}}">
{{svg "octicon-x" 14}}
</button>
</td>
{{end}}
</tr>
@@ -183,17 +189,17 @@
</h4>
<div class="ui attached segment">
<div class="field">
<label>Joomla updates.xml</label>
<label>{{ctx.Locale.Tr "repo.licenses.feed_joomla_updates"}}</label>
<div class="ui action input tw-w-full">
<input type="text" readonly value="{{.Repository.HTMLURL ctx}}/updates.xml" onclick="this.select()">
<button class="ui button" onclick="navigator.clipboard.writeText(this.previousElementSibling.value)">{{svg "octicon-copy" 14}}</button>
<input class="js-feed-url-joomla" type="text" readonly value="{{.Repository.HTMLURL ctx}}/updates.xml" onclick="this.select()">
<button class="ui button" data-clipboard-target=".js-feed-url-joomla" data-tooltip-content="{{ctx.Locale.Tr "copy_url"}}">{{svg "octicon-copy" 14}}</button>
</div>
</div>
<div class="field tw-mt-2">
<label>Dolibarr JSON</label>
<label>{{ctx.Locale.Tr "repo.licenses.feed_dolibarr_updates"}}</label>
<div class="ui action input tw-w-full">
<input type="text" readonly value="{{.Repository.HTMLURL ctx}}/updates/dolibarr.json" onclick="this.select()">
<button class="ui button" onclick="navigator.clipboard.writeText(this.previousElementSibling.value)">{{svg "octicon-copy" 14}}</button>
<input class="js-feed-url-dolibarr" type="text" readonly value="{{.Repository.HTMLURL ctx}}/updates/dolibarr.json" onclick="this.select()">
<button class="ui button" data-clipboard-target=".js-feed-url-dolibarr" data-tooltip-content="{{ctx.Locale.Tr "copy_url"}}">{{svg "octicon-copy" 14}}</button>
</div>
</div>
</div>
+1
View File
@@ -43,6 +43,7 @@
<input name="is_active" type="checkbox" {{if .Key.IsActive}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.licenses.active"}}</label>
</div>
<p class="help">{{ctx.Locale.Tr "repo.licenses.active_help_key"}}</p>
</div>
<div class="field tw-mt-4">
<button class="ui primary button" type="submit">{{ctx.Locale.Tr "save"}}</button>
+2 -1
View File
@@ -27,7 +27,7 @@
<div class="field">
<label>{{ctx.Locale.Tr "repo.licenses.max_sites"}}</label>
<input name="max_sites" type="number" value="{{.Package.MaxSites}}" min="0">
<p class="help">0 = unlimited</p>
<p class="help">0 = {{ctx.Locale.Tr "repo.licenses.unlimited"}}</p>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.licenses.channels"}}</label>
@@ -47,6 +47,7 @@
<input name="is_active" type="checkbox" {{if .Package.IsActive}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.licenses.active"}}</label>
</div>
<p class="help">{{ctx.Locale.Tr "repo.licenses.active_help_package"}}</p>
</div>
<div class="field tw-mt-4">
<button class="ui primary button" type="submit">{{ctx.Locale.Tr "save"}}</button>
+3 -3
View File
@@ -16,15 +16,15 @@
{{svg "octicon-rss" 16}} {{ctx.Locale.Tr "rss_feed"}}
</a>
{{end}}
{{if not .PageIsTagList}}
{{if and (not .PageIsTagList) .LicensingEnabled}}
{{if or (eq .RepoUpdatePlatform "joomla") (eq .RepoUpdatePlatform "both") (eq .RepoUpdatePlatform "")}}
<a class="ui small button" href="{{.RepoLink}}/updates.xml" target="_blank">
{{svg "octicon-download" 16}} Joomla XML
{{svg "octicon-download" 16}} {{ctx.Locale.Tr "repo.licenses.feed_joomla_xml"}}
</a>
{{end}}
{{if or (eq .RepoUpdatePlatform "dolibarr") (eq .RepoUpdatePlatform "both")}}
<a class="ui small button" href="{{.RepoLink}}/updates/dolibarr.json" target="_blank">
{{svg "octicon-download" 16}} Dolibarr JSON
{{svg "octicon-download" 16}} {{ctx.Locale.Tr "repo.licenses.feed_dolibarr_json"}}
</a>
{{end}}
{{end}}