Merge pull request 'feat(licenses): UI/UX cleanup, permissions system, and key management improvements' (#306) from dev into main
Deploy MokoGitea / deploy (push) Failing after 3m35s

This commit was merged in pull request #306.
This commit is contained in:
2026-05-31 14:22:04 +00:00
36 changed files with 1345 additions and 154 deletions
+142 -1
View File
@@ -30,10 +30,12 @@ type LicenseKey struct {
LicenseeEmail string `xorm:""` // customer email
DomainRestriction string `xorm:"TEXT"` // comma-separated allowed domains
MaxSites int `xorm:"NOT NULL DEFAULT 0"` // 0 = use package default
PaymentRef string `xorm:"UNIQUE"` // idempotency key from payment system
IsInternal bool `xorm:"NOT NULL DEFAULT false"` // true = base org/repo key
IsActive bool `xorm:"NOT NULL DEFAULT true"`
StartsUnix timeutil.TimeStamp `xorm:"NOT NULL DEFAULT 0"` // custom start, 0 = creation
ExpiresUnix timeutil.TimeStamp `xorm:"NOT NULL DEFAULT 0"` // 0 = never
LastHeartbeatUnix timeutil.TimeStamp `xorm:"NOT NULL DEFAULT 0"` // last successful validation
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"`
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED"`
}
@@ -75,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)
@@ -113,6 +128,22 @@ func ListLicenseKeysByPackage(ctx context.Context, packageID int64) ([]*LicenseK
return keys, db.GetEngine(ctx).Where("package_id = ?", packageID).Find(&keys)
}
// GetLicenseKeyByPaymentRef looks up a key by its payment reference (idempotency).
func GetLicenseKeyByPaymentRef(ctx context.Context, paymentRef string) (*LicenseKey, error) {
if paymentRef == "" {
return nil, db.ErrNotExist{Resource: "LicenseKey"}
}
key := new(LicenseKey)
has, err := db.GetEngine(ctx).Where("payment_ref = ?", paymentRef).Get(key)
if err != nil {
return nil, err
}
if !has {
return nil, db.ErrNotExist{Resource: "LicenseKey"}
}
return key, nil
}
// CountKeysByPackage returns the number of keys for a package.
func CountKeysByPackage(ctx context.Context, packageID int64) (int64, error) {
return db.GetEngine(ctx).Where("package_id = ?", packageID).Count(new(LicenseKey))
@@ -124,6 +155,14 @@ func UpdateLicenseKey(ctx context.Context, key *LicenseKey) error {
return err
}
// TouchHeartbeat updates the last heartbeat timestamp for a key.
func TouchHeartbeat(ctx context.Context, keyID int64) error {
_, err := db.GetEngine(ctx).ID(keyID).
Cols("last_heartbeat_unix").
Update(&LicenseKey{LastHeartbeatUnix: timeutil.TimeStampNow()})
return err
}
// DeleteLicenseKey deletes a license key by ID.
func DeleteLicenseKey(ctx context.Context, id int64) error {
_, err := db.GetEngine(ctx).ID(id).Delete(new(LicenseKey))
@@ -132,7 +171,11 @@ func DeleteLicenseKey(ctx context.Context, id int64) error {
// ValidateLicenseKey validates a raw key string against the database.
// Returns the key record and its associated package, or an error.
func ValidateLicenseKey(ctx context.Context, rawKey string) (*LicenseKey, *LicensePackage, 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)
if err != nil {
@@ -160,5 +203,103 @@ func ValidateLicenseKey(ctx context.Context, rawKey string) (*LicenseKey, *Licen
return nil, nil, fmt.Errorf("license package is deactivated")
}
// Domain restriction check — skip for internal/master keys.
if domain != "" && !key.IsInternal {
if key.DomainRestriction != "" {
allowed := false
for _, d := range strings.Split(key.DomainRestriction, ",") {
if strings.EqualFold(strings.TrimSpace(d), domain) {
allowed = true
break
}
}
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.
maxSites := key.MaxSites
if maxSites == 0 {
maxSites = pkg.MaxSites
}
if maxSites > 0 {
uniqueDomains, err := CountUniqueDomainsByKey(ctx, key.ID)
if err != nil {
return nil, nil, fmt.Errorf("failed to count domains: %w", err)
}
// Allow if this domain is already recorded, or if under the limit.
domainKnown, _ := IsDomainKnownForKey(ctx, key.ID, domain)
if !domainKnown && uniqueDomains >= int64(maxSites) {
return nil, nil, fmt.Errorf("site limit reached (%d/%d)", uniqueDomains, maxSites)
}
}
}
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
}
+16
View File
@@ -47,3 +47,19 @@ func GetRecentUsage(ctx context.Context, keyID int64, limit int) ([]*LicenseKeyU
func CountUsageByKey(ctx context.Context, keyID int64) (int64, error) {
return db.GetEngine(ctx).Where("key_id = ?", keyID).Count(new(LicenseKeyUsage))
}
// CountUniqueDomainsByKey returns the number of distinct domains that have used a key.
func CountUniqueDomainsByKey(ctx context.Context, keyID int64) (int64, error) {
count, err := db.GetEngine(ctx).
Where("key_id = ? AND domain != ''", keyID).
Distinct("domain").
Count(new(LicenseKeyUsage))
return count, err
}
// IsDomainKnownForKey checks whether a specific domain has already been recorded for a key.
func IsDomainKnownForKey(ctx context.Context, keyID int64, domain string) (bool, error) {
return db.GetEngine(ctx).
Where("key_id = ? AND domain = ?", keyID, domain).
Exist(new(LicenseKeyUsage))
}
+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))
+1
View File
@@ -25,6 +25,7 @@ type UpdateStreamConfig struct {
RepoID int64 `xorm:"INDEX NOT NULL DEFAULT 0"` // 0 = org-level default
StreamMode string `xorm:"NOT NULL DEFAULT 'joomla'"` // joomla, custom
Platform string `xorm:"NOT NULL DEFAULT 'joomla'"` // joomla, dolibarr, both
LicensingEnabled bool `xorm:"NOT NULL DEFAULT false"` // master toggle for licensing system
RequireKey bool `xorm:"NOT NULL DEFAULT false"` // require license key for update feed
// CustomStreams is a JSON array of stream definitions.
// Each entry: {"name":"lts","suffix":"-lts","description":"Long-term support"}
+5
View File
@@ -31,6 +31,11 @@ func (t TeamList) UnitMaxAccess(tp unit.Type) perm.AccessMode {
if team.IsOwnerTeam() {
return perm.AccessModeOwner
}
// Admin-level teams implicitly have admin access to all units,
// even units added after the team was created (no TeamUnit record needed).
if team.HasAdminAccess() && maxAccess < perm.AccessModeAdmin {
maxAccess = perm.AccessModeAdmin
}
for _, teamUnit := range team.Units {
if teamUnit.Type != tp {
continue
+1 -1
View File
@@ -52,7 +52,7 @@ func RemoveTeamRepo(ctx context.Context, teamID, repoID int64) error {
// GetTeamsWithAccessToAnyRepoUnit returns all teams in an organization that have given access level to the repository special unit.
// This function is only used for finding some teams that can be used as branch protection allowlist or reviewers, it isn't really used for access control.
// FIXME: TEAM-UNIT-PERMISSION this logic is not complete, search the fixme keyword to see more details
// Note: admin-level teams (authorize >= Admin) implicitly have access to all units.
func GetTeamsWithAccessToAnyRepoUnit(ctx context.Context, orgID, repoID int64, mode perm.AccessMode, unitType unit.Type, unitTypesMore ...unit.Type) (teams []*Team, err error) {
teamIDs, err := getTeamIDsWithAccessToAnyRepoUnit(ctx, orgID, repoID, mode, unitType, unitTypesMore...)
if err != nil {
+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,
}
)
+28
View File
@@ -60,6 +60,8 @@ type LicenseKey struct {
// swagger:strfmt date-time
ExpiresAt *time.Time `json:"expires_at"`
// swagger:strfmt date-time
LastHeartbeat *time.Time `json:"last_heartbeat,omitempty"`
// swagger:strfmt date-time
Created time.Time `json:"created_at"`
}
@@ -93,6 +95,32 @@ type EditLicenseKeyOption struct {
ExpiresAt *time.Time `json:"expires_at"`
}
// PurchaseLicenseKeyOption options for purchasing a license key via webhook.
type PurchaseLicenseKeyOption struct {
PackageID int64 `json:"package_id" binding:"Required"`
LicenseeName string `json:"licensee_name"`
LicenseeEmail string `json:"licensee_email"`
Domain string `json:"domain"`
PaymentRef string `json:"payment_ref"`
}
// ValidateLicenseKeyOption options for validating a license key.
type ValidateLicenseKeyOption struct {
Key string `json:"key" binding:"Required"`
Domain string `json:"domain"`
}
// ValidateLicenseKeyResponse is the response from license key validation.
type ValidateLicenseKeyResponse struct {
Valid bool `json:"valid"`
Message string `json:"message,omitempty"`
PackageName string `json:"package_name,omitempty"`
Channels string `json:"channels,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
SitesUsed int64 `json:"sites_used"`
MaxSites int `json:"max_sites"`
}
// LicenseKeyUsage represents a usage tracking entry.
type LicenseKeyUsage struct {
ID int64 `json:"id"`
+50 -10
View File
@@ -2148,9 +2148,15 @@
"repo.settings.unit_visibility_private": "Private (follow repo visibility)",
"repo.settings.unit_visibility_public": "Public (anyone can read)",
"repo.settings.unit_visibility_releases_help": "Controls whether the releases page is visible to anonymous visitors.",
"repo.settings.update_platform": "Update Server Platform",
"repo.settings.licensing_section": "Licensing & Updates",
"repo.settings.licensing_section_desc": "Manage commercial license keys and gated update feeds for this repository. When enabled, the Licenses tab appears and release tags must follow update stream naming.",
"repo.settings.update_platform": "Update Feed Format",
"repo.settings.update_platform_both": "Both (Joomla + Dolibarr)",
"repo.settings.require_update_key": "Require license key for update feed access",
"repo.settings.update_platform_help": "Choose which update feed format to generate. All formats support license key validation.",
"repo.settings.require_update_key": "Require license key for update feeds",
"repo.settings.require_update_key_help": "When enabled, update feeds return empty results unless a valid license key is provided. Joomla clients will see a Download Key field in Update Sites.",
"repo.settings.enable_licensing": "Enable licensing for this repository",
"repo.settings.enable_licensing_help": "Show the Licenses tab and enable license key management for this repository.",
"repo.settings.packages_desc": "Enable Repository Packages Registry",
"repo.settings.projects_desc": "Enable Projects",
"repo.settings.projects_mode_desc": "Projects Mode (which kinds of projects to show)",
@@ -2629,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",
@@ -2638,13 +2644,13 @@
"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.channels_help": "Select which update channels this package grants access to. Leave all unchecked for all channels.",
"repo.licenses.create_package": "Create License Package",
"repo.licenses.create_new_package": "Create New License 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.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",
@@ -2654,6 +2660,32 @@
"repo.licenses.master_key_created": "Master License Key Created",
"repo.licenses.master_key_created_copy": "This is your organization master key with unlimited access to all update channels. Copy it now — it will not be shown again.",
"repo.licenses.update_feeds": "Update Feed URLs",
"repo.licenses.edit_key": "Edit License Key",
"repo.licenses.licensee_name": "Licensee Name",
"repo.licenses.licensee_email": "Licensee Email",
"repo.licenses.domain_restriction": "Domain Restriction",
"repo.licenses.domain_restriction_help": "Comma-separated list of allowed domains. Leave empty for no restriction.",
"repo.licenses.use_package_default": "use package default",
"repo.licenses.expires_at": "Expires At",
"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",
@@ -2795,18 +2827,26 @@
"org.form.create_org_not_allowed": "You are not allowed to create an organization.",
"org.settings": "Settings",
"org.settings.options": "Organization",
"org.settings.update_streams": "Licenses & Update Streams",
"org.settings.update_streams_desc": "Configure the default update streams for all repositories in this organization. Repos can override with their own settings.",
"org.settings.update_streams": "Licensing & Update Streams",
"org.settings.licensing": "Licensing",
"org.settings.licensing_desc": "Control commercial license key management and gated update feeds across all repositories in this organization.",
"org.settings.enable_licensing": "Enable licensing for this organization",
"org.settings.enable_licensing_help": "Show the Licenses page in the org menu and enable license key management. Individual repos can also enable licensing independently.",
"org.settings.require_key": "Require license key for all update feeds",
"org.settings.require_key_help": "Update feeds return empty results unless a valid key is provided. Joomla clients will see a Download Key field. Individual repos can override this.",
"org.settings.update_streams_heading": "Update Streams",
"org.settings.update_streams_desc": "Configure the default update streams for all repositories. Release tags are matched to streams by their suffix. Repos can override with per-repo settings.",
"org.settings.stream_mode": "Stream Mode",
"org.settings.stream_mode_joomla": "Standard Joomla streams (stable, release-candidate, beta, alpha, development)",
"org.settings.stream_mode_custom": "Custom streams (define your own channels and tag patterns)",
"org.settings.default_streams": "Active Streams",
"org.settings.default_streams_joomla": "These are the currently active update streams. Release tags are matched to streams by their suffix.",
"org.settings.stream_name": "Stream Name",
"org.settings.stream_name": "Channel",
"org.settings.stream_suffix": "Tag Suffix",
"org.settings.no_suffix": "none (stable)",
"org.settings.streams_tag_help": "When licensing is active, release tags with prerelease suffixes must match one of the streams above (e.g. v1.0.0-rc1 matches the -rc stream).",
"org.settings.custom_streams": "Custom Stream Definitions (JSON)",
"org.settings.custom_streams_help": "JSON array of stream objects. Each needs: name, suffix, description. Example: [{\"name\":\"lts\",\"suffix\":\"-lts\",\"description\":\"Long-term support\"}]",
"org.settings.update_streams_saved": "Update stream settings saved.",
"org.settings.update_streams_saved": "Settings saved.",
"org.settings.full_name": "Full Name",
"org.settings.email": "Contact Email Address",
"org.settings.website": "Website",
+3
View File
@@ -1351,11 +1351,14 @@ func Routes() *web.Router {
m.Combo("").Get(repo.ListLicensePackages).
Post(bind(api.CreateLicensePackageOption{}), repo.CreateLicensePackage)
}, reqToken(), reqAdmin())
m.Post("/license-keys/validate", bind(api.ValidateLicenseKeyOption{}), repo.ValidateLicenseKey)
m.Group("/license-keys", func() {
m.Combo("").Get(repo.ListLicenseKeys).
Post(bind(api.CreateLicenseKeyOption{}), repo.CreateLicenseKey)
m.Post("/purchase", bind(api.PurchaseLicenseKeyOption{}), repo.PurchaseLicenseKey)
m.Group("/{id}", func() {
m.Delete("", repo.DeleteLicenseKey)
m.Patch("", bind(api.EditLicenseKeyOption{}), repo.EditLicenseKey)
m.Get("/usage", repo.GetLicenseKeyUsage)
})
}, reqToken(), reqAdmin())
+136
View File
@@ -52,6 +52,10 @@ func toLicenseKeyAPI(key *licenses.LicenseKey) *structs.LicenseKey {
t := time.Unix(int64(key.ExpiresUnix), 0)
lk.ExpiresAt = &t
}
if key.LastHeartbeatUnix > 0 {
t := time.Unix(int64(key.LastHeartbeatUnix), 0)
lk.LastHeartbeat = &t
}
return lk
}
@@ -161,6 +165,100 @@ func CreateLicenseKey(ctx *context.APIContext) {
ctx.JSON(http.StatusCreated, resp)
}
// EditLicenseKey edits a license key via API.
func EditLicenseKey(ctx *context.APIContext) {
form := web.GetForm(ctx).(*structs.EditLicenseKeyOption)
keyID := ctx.PathParamInt64("id")
key, err := licenses.GetLicenseKeyByID(ctx, keyID)
if err != nil {
ctx.APIErrorNotFound(err)
return
}
if key.IsInternal {
ctx.APIError(http.StatusForbidden, "master keys cannot be edited")
return
}
if form.LicenseeName != nil {
key.LicenseeName = *form.LicenseeName
}
if form.LicenseeEmail != nil {
key.LicenseeEmail = *form.LicenseeEmail
}
if form.DomainRestriction != nil {
key.DomainRestriction = *form.DomainRestriction
}
if form.MaxSites != nil {
key.MaxSites = *form.MaxSites
}
if form.IsActive != nil {
key.IsActive = *form.IsActive
}
if form.ExpiresAt != nil {
key.ExpiresUnix = timeutil.TimeStamp(form.ExpiresAt.Unix())
}
if err := licenses.UpdateLicenseKey(ctx, key); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusOK, toLicenseKeyAPI(key))
}
// PurchaseLicenseKey handles purchase webhook — creates a key from a payment event.
func PurchaseLicenseKey(ctx *context.APIContext) {
form := web.GetForm(ctx).(*structs.PurchaseLicenseKeyOption)
// Idempotency check: if payment_ref already exists, return existing key.
if form.PaymentRef != "" {
existing, err := licenses.GetLicenseKeyByPaymentRef(ctx, form.PaymentRef)
if err == nil {
resp := &structs.LicenseKeyCreated{
LicenseKey: *toLicenseKeyAPI(existing),
RawKey: "", // raw key not available after creation
}
ctx.JSON(http.StatusOK, resp)
return
}
}
pkg, err := licenses.GetLicensePackageByID(ctx, form.PackageID)
if err != nil {
ctx.APIErrorNotFound(err)
return
}
key := &licenses.LicenseKey{
PackageID: form.PackageID,
OwnerID: ctx.Repo.Repository.OwnerID,
LicenseeName: form.LicenseeName,
LicenseeEmail: form.LicenseeEmail,
DomainRestriction: form.Domain,
PaymentRef: form.PaymentRef,
IsActive: true,
}
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.APIErrorInternal(err)
return
}
resp := &structs.LicenseKeyCreated{
LicenseKey: *toLicenseKeyAPI(key),
RawKey: rawKey,
}
ctx.JSON(http.StatusCreated, resp)
}
// DeleteLicenseKey deletes a license key.
func DeleteLicenseKey(ctx *context.APIContext) {
if err := licenses.DeleteLicenseKey(ctx, ctx.PathParamInt64("id")); err != nil {
@@ -170,6 +268,44 @@ func DeleteLicenseKey(ctx *context.APIContext) {
ctx.Status(http.StatusNoContent)
}
// ValidateLicenseKey validates a license key — public endpoint (no auth required).
func ValidateLicenseKey(ctx *context.APIContext) {
form := web.GetForm(ctx).(*structs.ValidateLicenseKeyOption)
key, pkg, err := licenses.ValidateLicenseKey(ctx, form.Key, form.Domain)
if err != nil {
ctx.JSON(http.StatusOK, &structs.ValidateLicenseKeyResponse{
Valid: false,
Message: err.Error(),
})
return
}
_ = licenses.TouchHeartbeat(ctx, key.ID)
var expiresAt *time.Time
if key.ExpiresUnix > 0 {
t := time.Unix(int64(key.ExpiresUnix), 0)
expiresAt = &t
}
maxSites := key.MaxSites
if maxSites == 0 {
maxSites = pkg.MaxSites
}
sitesUsed, _ := licenses.CountUniqueDomainsByKey(ctx, key.ID)
ctx.JSON(http.StatusOK, &structs.ValidateLicenseKeyResponse{
Valid: true,
PackageName: pkg.Name,
Channels: pkg.AllowedChannels,
ExpiresAt: expiresAt,
SitesUsed: sitesUsed,
MaxSites: maxSites,
})
}
// GetLicenseKeyUsage returns usage logs for a license key.
func GetLicenseKeyUsage(ctx *context.APIContext) {
usages, err := licenses.GetRecentUsage(ctx, ctx.PathParamInt64("id"), 100)
+8
View File
@@ -9,6 +9,7 @@ import (
"strings"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
licenses_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/organization"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/renderhelper"
repo_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
@@ -107,6 +108,13 @@ func home(ctx *context.Context, viewRepositories bool) {
ctx.Data["Teams"] = ctx.Org.Teams
ctx.Data["IsOrganizationMember"] = ctx.Org.IsMember
ctx.Data["IsOrganizationOwner"] = ctx.Org.IsOwner
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]
}
+267 -8
View File
@@ -6,9 +6,13 @@ package org
import (
"net/http"
"strconv"
"strings"
"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"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
@@ -16,6 +20,27 @@ import (
const tplOrgLicenses templates.TplName = "org/licenses"
// parseOrgAllowedChannels splits an AllowedChannels string (CSV or JSON array) into a slice.
func parseOrgAllowedChannels(s string) []string {
if s == "" {
return nil
}
if strings.HasPrefix(s, "[") {
var parsed []string
if err := json.Unmarshal([]byte(s), &parsed); err == nil {
return parsed
}
}
parts := strings.Split(s, ",")
result := make([]string, 0, len(parts))
for _, p := range parts {
if t := strings.TrimSpace(p); t != "" {
result = append(result, t)
}
}
return result
}
// LicensePackageDisplay is used in templates.
type LicensePackageDisplay struct {
*licenses.LicensePackage
@@ -31,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)
@@ -66,8 +93,17 @@ 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["IsOrganizationOwner"] = ctx.Org.IsOwner
ctx.Data["OrgLicensingEnabled"] = true
orgCfg, _ := licenses.GetOrgConfig(ctx, ownerID)
if orgCfg != nil {
ctx.Data["AvailableStreams"] = orgCfg.GetActiveStreams()
} else {
ctx.Data["AvailableStreams"] = licenses.DefaultJoomlaStreams()
}
ctx.HTML(http.StatusOK, tplOrgLicenses)
}
@@ -84,13 +120,20 @@ func LicensesCreatePackage(ctx *context.Context) {
durationDays, _ := strconv.Atoi(ctx.FormString("duration_days"))
maxSites, _ := strconv.Atoi(ctx.FormString("max_sites"))
channels := ctx.Req.Form["allowed_channels"]
var allowedChannels string
if len(channels) > 0 {
data, _ := json.Marshal(channels)
allowedChannels = string(data)
}
pkg := &licenses.LicensePackage{
OwnerID: ctx.Org.Organization.ID,
Name: name,
Description: ctx.FormString("description"),
DurationDays: durationDays,
MaxSites: maxSites,
AllowedChannels: ctx.FormString("allowed_channels"),
AllowedChannels: allowedChannels,
RepoScope: "all",
IsActive: true,
}
@@ -130,10 +173,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.
@@ -157,9 +211,185 @@ func LicensesGenerateKey(ctx *context.Context) {
keys, _ := licenses.ListLicenseKeys(ctx, ownerID)
ctx.Data["LicenseKeys"] = keys
orgCfg, _ := licenses.GetOrgConfig(ctx, ownerID)
if orgCfg != nil {
ctx.Data["AvailableStreams"] = orgCfg.GetActiveStreams()
} else {
ctx.Data["AvailableStreams"] = licenses.DefaultJoomlaStreams()
}
ctx.HTML(http.StatusOK, tplOrgLicenses)
}
const tplOrgLicensesEditPackage templates.TplName = "org/licenses_edit_package"
const tplOrgLicensesEditKey templates.TplName = "repo/licenses_edit_key"
// LicensesEditPackage shows the edit form for an org license package.
func LicensesEditPackage(ctx *context.Context) {
pkgID := ctx.PathParamInt64("id")
pkg, err := licenses.GetLicensePackageByID(ctx, pkgID)
if err != nil {
ctx.ServerError("GetLicensePackageByID", err)
return
}
if pkg.Name == licenses.MasterPackageName {
ctx.Flash.Error("Master package cannot be edited")
ctx.Redirect(ctx.Org.OrgLink + "/-/licenses")
return
}
ctx.Data["Title"] = ctx.Tr("repo.licenses.edit_package")
ctx.Data["IsLicensesPage"] = true
ctx.Data["Package"] = pkg
ctx.Data["SelectedChannels"] = parseOrgAllowedChannels(pkg.AllowedChannels)
orgCfg, _ := licenses.GetOrgConfig(ctx, ctx.Org.Organization.ID)
if orgCfg != nil {
ctx.Data["AvailableStreams"] = orgCfg.GetActiveStreams()
} else {
ctx.Data["AvailableStreams"] = licenses.DefaultJoomlaStreams()
}
ctx.HTML(http.StatusOK, tplOrgLicensesEditPackage)
}
// LicensesEditPackagePost saves edits to an org license package.
func LicensesEditPackagePost(ctx *context.Context) {
pkgID := ctx.PathParamInt64("id")
pkg, err := licenses.GetLicensePackageByID(ctx, pkgID)
if err != nil {
ctx.ServerError("GetLicensePackageByID", err)
return
}
if pkg.Name == licenses.MasterPackageName {
ctx.Flash.Error("Master package cannot be edited")
ctx.Redirect(ctx.Org.OrgLink + "/-/licenses")
return
}
pkg.Name = ctx.FormString("name")
pkg.Description = ctx.FormString("description")
durationDays, _ := strconv.Atoi(ctx.FormString("duration_days"))
pkg.DurationDays = durationDays
maxSites, _ := strconv.Atoi(ctx.FormString("max_sites"))
pkg.MaxSites = maxSites
channels := ctx.Req.Form["allowed_channels"]
if len(channels) > 0 {
data, _ := json.Marshal(channels)
pkg.AllowedChannels = string(data)
} else {
pkg.AllowedChannels = ""
}
pkg.IsActive = ctx.FormString("is_active") == "on"
if err := licenses.UpdateLicensePackage(ctx, pkg); err != nil {
ctx.ServerError("UpdateLicensePackage", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.licenses.package_updated"))
ctx.Redirect(ctx.Org.OrgLink + "/-/licenses")
}
// LicensesDeletePackage deletes an org license package. Site admin only.
func LicensesDeletePackage(ctx *context.Context) {
if !ctx.IsUserSiteAdmin() {
ctx.NotFound(nil)
return
}
pkgID := ctx.PathParamInt64("id")
pkg, err := licenses.GetLicensePackageByID(ctx, pkgID)
if err != nil {
ctx.ServerError("GetLicensePackageByID", err)
return
}
if pkg.Name == licenses.MasterPackageName {
ctx.Flash.Error("Master package cannot be deleted")
ctx.Redirect(ctx.Org.OrgLink + "/-/licenses")
return
}
if err := licenses.DeleteLicensePackage(ctx, pkgID); err != nil {
ctx.ServerError("DeleteLicensePackage", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.licenses.package_deleted"))
ctx.Redirect(ctx.Org.OrgLink + "/-/licenses")
}
// LicensesEditKey shows the edit form for an org license key.
func LicensesEditKey(ctx *context.Context) {
keyID := ctx.PathParamInt64("id")
key, err := licenses.GetLicenseKeyByID(ctx, keyID)
if err != nil {
ctx.ServerError("GetLicenseKeyByID", err)
return
}
if key.IsInternal {
ctx.Flash.Error("Master keys cannot be edited")
ctx.Redirect(ctx.Org.OrgLink + "/-/licenses")
return
}
ctx.Data["Title"] = ctx.Tr("repo.licenses.edit_key")
ctx.Data["IsLicensesPage"] = true
ctx.Data["Key"] = key
ctx.Data["FormAction"] = ctx.Org.OrgLink + "/-/licenses/keys/" + strconv.FormatInt(key.ID, 10) + "/edit"
ctx.Data["BackLink"] = ctx.Org.OrgLink + "/-/licenses"
if key.ExpiresUnix > 0 {
ctx.Data["ExpiresDate"] = time.Unix(int64(key.ExpiresUnix), 0).Format("2006-01-02")
}
ctx.HTML(http.StatusOK, tplOrgLicensesEditKey)
}
// LicensesEditKeyPost saves edits to an org license key.
func LicensesEditKeyPost(ctx *context.Context) {
keyID := ctx.PathParamInt64("id")
key, err := licenses.GetLicenseKeyByID(ctx, keyID)
if err != nil {
ctx.ServerError("GetLicenseKeyByID", err)
return
}
if key.IsInternal {
ctx.Flash.Error("Master keys cannot be edited")
ctx.Redirect(ctx.Org.OrgLink + "/-/licenses")
return
}
key.LicenseeName = ctx.FormString("licensee_name")
key.LicenseeEmail = ctx.FormString("licensee_email")
key.DomainRestriction = ctx.FormString("domain_restriction")
maxSites, _ := strconv.Atoi(ctx.FormString("max_sites"))
key.MaxSites = maxSites
key.IsActive = ctx.FormString("is_active") == "on"
expiresStr := ctx.FormString("expires_at")
if expiresStr != "" {
t, err := time.Parse("2006-01-02", expiresStr)
if err == nil {
key.ExpiresUnix = timeutil.TimeStamp(t.Unix())
}
} else {
key.ExpiresUnix = 0
}
if err := licenses.UpdateLicenseKey(ctx, key); err != nil {
ctx.ServerError("UpdateLicenseKey", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.licenses.key_updated"))
ctx.Redirect(ctx.Org.OrgLink + "/-/licenses")
}
// LicensesRevokeKey handles POST to revoke an org license key.
func LicensesRevokeKey(ctx *context.Context) {
keyID := ctx.PathParamInt64("id")
@@ -178,3 +408,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")
}
+3 -13
View File
@@ -324,19 +324,9 @@ func NewTeam(ctx *context.Context) {
ctx.HTML(http.StatusOK, tplTeamNew)
}
// FIXME: TEAM-UNIT-PERMISSION: this design is not right, when a new unit is added in the future,
// The existing teams won't inherit the correct admin permission for the new unit.
// The full history is like this:
// 1. There was only "team", no "team unit", so "team.authorize" was used to determine the team permission.
// 2. Later, "team unit" was introduced, then the usage of "team.authorize" became inconsistent, and causes various bugs.
// - Sometimes, "team.authorize" is used to determine the team permission, e.g. admin, owner
// - Sometimes, "team unit" is used not really used and "team unit" is used.
// - Some functions like `GetTeamsWithAccessToAnyRepoUnit` use both.
//
// 3. After introducing "team unit" and more unclear changes, it becomes difficult to maintain team permissions.
// - Org owner need to click the permission for each unit, but can't just set a common "write" permission for all units.
//
// Ideally, "team.authorize=write" should mean the team has write access to all units including newly (future) added ones.
// getUnitPerms parses the unit permission form values for a team.
// Note: admin teams (team.authorize >= Admin) implicitly have admin access to
// all units via UnitMaxAccess(), so explicit TeamUnit records are supplementary.
func getUnitPerms(forms url.Values, teamPermission perm.AccessMode) map[unit_model.Type]perm.AccessMode {
unitPerms := make(map[unit_model.Type]perm.AccessMode)
for _, ut := range unit_model.AllRepoUnitTypes {
+6 -4
View File
@@ -37,10 +37,12 @@ func SettingsUpdateStreamsPost(ctx *context.Context) {
orgID := ctx.Org.Organization.ID
cfg := &licenses.UpdateStreamConfig{
OwnerID: orgID,
RepoID: 0,
StreamMode: ctx.FormString("stream_mode"),
CustomStreams: ctx.FormString("custom_streams"),
OwnerID: orgID,
RepoID: 0,
StreamMode: ctx.FormString("stream_mode"),
CustomStreams: ctx.FormString("custom_streams"),
LicensingEnabled: ctx.FormString("licensing_enabled") == "on",
RequireKey: ctx.FormString("require_key") == "on",
}
if cfg.StreamMode == "" {
+209 -9
View File
@@ -6,9 +6,12 @@ package repo
import (
"net/http"
"strconv"
"strings"
"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"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
@@ -16,6 +19,27 @@ import (
const tplLicenses templates.TplName = "repo/licenses"
// parseAllowedChannels splits an AllowedChannels string (CSV or JSON array) into a slice.
func parseAllowedChannels(s string) []string {
if s == "" {
return nil
}
if strings.HasPrefix(s, "[") {
var parsed []string
if err := json.Unmarshal([]byte(s), &parsed); err == nil {
return parsed
}
}
parts := strings.Split(s, ",")
result := make([]string, 0, len(parts))
for _, p := range parts {
if t := strings.TrimSpace(p); t != "" {
result = append(result, t)
}
}
return result
}
// LicensePackageDisplay is used in templates.
type LicensePackageDisplay struct {
*licenses.LicensePackage
@@ -28,12 +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)
@@ -68,6 +94,14 @@ func Licenses(ctx *context.Context) {
}
ctx.Data["LicenseKeys"] = keys
// Load available streams for the channels multiselect.
orgCfg, _ := licenses.GetOrgConfig(ctx, ownerID)
if orgCfg != nil {
ctx.Data["AvailableStreams"] = orgCfg.GetActiveStreams()
} else {
ctx.Data["AvailableStreams"] = licenses.DefaultJoomlaStreams()
}
ctx.HTML(http.StatusOK, tplLicenses)
}
@@ -83,13 +117,20 @@ func LicensesCreatePackage(ctx *context.Context) {
durationDays, _ := strconv.Atoi(ctx.FormString("duration_days"))
maxSites, _ := strconv.Atoi(ctx.FormString("max_sites"))
channels := ctx.Req.Form["allowed_channels"]
var allowedChannels string
if len(channels) > 0 {
data, _ := json.Marshal(channels)
allowedChannels = string(data)
}
pkg := &licenses.LicensePackage{
OwnerID: ctx.Repo.Repository.OwnerID,
Name: name,
Description: ctx.FormString("description"),
DurationDays: durationDays,
MaxSites: maxSites,
AllowedChannels: ctx.FormString("allowed_channels"),
AllowedChannels: allowedChannels,
RepoScope: "all",
IsActive: true,
}
@@ -130,16 +171,28 @@ 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
// Re-render the page with the new key displayed.
@@ -158,6 +211,13 @@ func LicensesGenerateKey(ctx *context.Context) {
keys, _ := licenses.ListLicenseKeys(ctx, ownerID)
ctx.Data["LicenseKeys"] = keys
orgCfg, _ := licenses.GetOrgConfig(ctx, ownerID)
if orgCfg != nil {
ctx.Data["AvailableStreams"] = orgCfg.GetActiveStreams()
} else {
ctx.Data["AvailableStreams"] = licenses.DefaultJoomlaStreams()
}
ctx.HTML(http.StatusOK, tplLicenses)
}
@@ -181,6 +241,77 @@ func LicensesRevokeKey(ctx *context.Context) {
}
const tplLicensesEditPackage templates.TplName = "repo/licenses_edit_package"
const tplLicensesEditKey templates.TplName = "repo/licenses_edit_key"
// LicensesEditKey shows the edit form for a license key.
func LicensesEditKey(ctx *context.Context) {
keyID := ctx.PathParamInt64("id")
key, err := licenses.GetLicenseKeyByID(ctx, keyID)
if err != nil {
ctx.ServerError("GetLicenseKeyByID", err)
return
}
if key.IsInternal {
ctx.Flash.Error("Master keys cannot be edited")
ctx.Redirect(ctx.Repo.RepoLink + "/licenses")
return
}
ctx.Data["Title"] = ctx.Tr("repo.licenses.edit_key")
ctx.Data["PageIsLicenses"] = true
ctx.Data["IsLicensesPage"] = true
ctx.Data["Key"] = key
ctx.Data["FormAction"] = ctx.Repo.RepoLink + "/licenses/keys/" + strconv.FormatInt(key.ID, 10) + "/edit"
ctx.Data["BackLink"] = ctx.Repo.RepoLink + "/licenses"
if key.ExpiresUnix > 0 {
ctx.Data["ExpiresDate"] = time.Unix(int64(key.ExpiresUnix), 0).Format("2006-01-02")
}
ctx.HTML(http.StatusOK, tplLicensesEditKey)
}
// LicensesEditKeyPost saves edits to a license key.
func LicensesEditKeyPost(ctx *context.Context) {
keyID := ctx.PathParamInt64("id")
key, err := licenses.GetLicenseKeyByID(ctx, keyID)
if err != nil {
ctx.ServerError("GetLicenseKeyByID", err)
return
}
if key.IsInternal {
ctx.Flash.Error("Master keys cannot be edited")
ctx.Redirect(ctx.Repo.RepoLink + "/licenses")
return
}
key.LicenseeName = ctx.FormString("licensee_name")
key.LicenseeEmail = ctx.FormString("licensee_email")
key.DomainRestriction = ctx.FormString("domain_restriction")
maxSites, _ := strconv.Atoi(ctx.FormString("max_sites"))
key.MaxSites = maxSites
key.IsActive = ctx.FormString("is_active") == "on"
expiresStr := ctx.FormString("expires_at")
if expiresStr != "" {
t, err := time.Parse("2006-01-02", expiresStr)
if err == nil {
key.ExpiresUnix = timeutil.TimeStamp(t.Unix())
}
} else {
key.ExpiresUnix = 0
}
if err := licenses.UpdateLicenseKey(ctx, key); err != nil {
ctx.ServerError("UpdateLicenseKey", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.licenses.key_updated"))
ctx.Redirect(ctx.Repo.RepoLink + "/licenses")
}
// LicensesEditPackage shows the edit form for a license package.
func LicensesEditPackage(ctx *context.Context) {
@@ -191,10 +322,26 @@ func LicensesEditPackage(ctx *context.Context) {
return
}
if pkg.Name == licenses.MasterPackageName {
ctx.Flash.Error("Master package cannot be edited")
ctx.Redirect(ctx.Repo.RepoLink + "/licenses")
return
}
ctx.Data["Title"] = ctx.Tr("repo.licenses.edit_package")
ctx.Data["PageIsLicenses"] = true
ctx.Data["IsLicensesPage"] = true
ctx.Data["Package"] = pkg
ctx.Data["SelectedChannels"] = parseAllowedChannels(pkg.AllowedChannels)
ownerID := ctx.Repo.Repository.OwnerID
orgCfg, _ := licenses.GetOrgConfig(ctx, ownerID)
if orgCfg != nil {
ctx.Data["AvailableStreams"] = orgCfg.GetActiveStreams()
} else {
ctx.Data["AvailableStreams"] = licenses.DefaultJoomlaStreams()
}
ctx.HTML(http.StatusOK, tplLicensesEditPackage)
}
@@ -207,13 +354,27 @@ func LicensesEditPackagePost(ctx *context.Context) {
return
}
if pkg.Name == licenses.MasterPackageName {
ctx.Flash.Error("Master package cannot be edited")
ctx.Redirect(ctx.Repo.RepoLink + "/licenses")
return
}
pkg.Name = ctx.FormString("name")
pkg.Description = ctx.FormString("description")
durationDays, _ := strconv.Atoi(ctx.FormString("duration_days"))
pkg.DurationDays = durationDays
maxSites, _ := strconv.Atoi(ctx.FormString("max_sites"))
pkg.MaxSites = maxSites
pkg.AllowedChannels = ctx.FormString("allowed_channels")
channels := ctx.Req.Form["allowed_channels"]
if len(channels) > 0 {
data, _ := json.Marshal(channels)
pkg.AllowedChannels = string(data)
} else {
pkg.AllowedChannels = ""
}
pkg.IsActive = ctx.FormString("is_active") == "on"
if err := licenses.UpdateLicensePackage(ctx, pkg); err != nil {
@@ -232,6 +393,16 @@ func LicensesDeletePackage(ctx *context.Context) {
return
}
pkgID := ctx.PathParamInt64("id")
pkg, err := licenses.GetLicensePackageByID(ctx, pkgID)
if err != nil {
ctx.ServerError("GetLicensePackageByID", err)
return
}
if pkg.Name == licenses.MasterPackageName {
ctx.Flash.Error("Master package cannot be deleted")
ctx.Redirect(ctx.Repo.RepoLink + "/licenses")
return
}
if err := licenses.DeleteLicensePackage(ctx, pkgID); err != nil {
ctx.ServerError("DeleteLicensePackage", err)
return
@@ -240,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")
}
+6 -5
View File
@@ -681,11 +681,12 @@ func handleSettingsPostAdvanced(ctx *context.Context) {
updatePlatform = "joomla"
}
updateCfg := &licenses_model.UpdateStreamConfig{
OwnerID: repo.OwnerID,
RepoID: repo.ID,
Platform: updatePlatform,
RequireKey: form.RequireUpdateKey,
StreamMode: "joomla", // inherit org default
OwnerID: repo.OwnerID,
RepoID: repo.ID,
Platform: updatePlatform,
LicensingEnabled: form.EnableLicensing,
RequireKey: form.RequireUpdateKey,
StreamMode: "joomla", // inherit org default
}
if err := licenses_model.SaveConfig(ctx, updateCfg); err != nil {
log.Error("SaveConfig: %v", err)
+25 -6
View File
@@ -21,6 +21,9 @@ func validateUpdateKey(ctx *context.Context) (allowedChannels []string, ok bool)
if rawKey == "" {
rawKey = ctx.FormString("download_key")
}
if rawKey == "" {
rawKey = ctx.FormString("dlid")
}
if rawKey == "" {
// Check if this repo requires a key for update feed access.
@@ -33,17 +36,19 @@ func validateUpdateKey(ctx *context.Context) (allowedChannels []string, ok bool)
return nil, true
}
key, pkg, err := licenses.ValidateLicenseKey(ctx, rawKey)
domain := ctx.FormString("domain")
key, pkg, err := licenses.ValidateLicenseKey(ctx, rawKey, domain)
if err != nil {
log.Debug("License key validation failed: %v", err)
return nil, false
}
// Record usage.
// Update heartbeat and record usage.
_ = licenses.TouchHeartbeat(ctx, key.ID)
_ = licenses.RecordUsage(ctx, &licenses.LicenseKeyUsage{
KeyID: key.ID,
RepoID: ctx.Repo.Repository.ID,
Domain: ctx.FormString("domain"),
Domain: domain,
IPAddress: ctx.RemoteAddr(),
UserAgent: ctx.Req.UserAgent(),
VersionFrom: ctx.FormString("version"),
@@ -85,7 +90,11 @@ func ServeUpdatesXML(ctx *context.Context) {
return
}
xmlData, err := updateserver.GenerateJoomlaXML(ctx, ctx.Repo.Repository, allowedChannels...)
// Check if this repo requires a license key for update feed access.
repoCfg, _ := licenses.GetRepoConfig(ctx, ctx.Repo.Repository.ID)
requireKey := repoCfg != nil && repoCfg.RequireKey
xmlData, err := updateserver.GenerateJoomlaXML(ctx, ctx.Repo.Repository, requireKey, allowedChannels...)
if err != nil {
ctx.ServerError("GenerateJoomlaXML", err)
return
@@ -97,9 +106,19 @@ func ServeUpdatesXML(ctx *context.Context) {
}
// ServeDolibarrJSON generates and serves a Dolibarr-compatible update feed
// from the repository's releases.
// from the repository's releases. Uses the same license key validation as the
// Joomla XML feed — all platforms share the same licensing system.
func ServeDolibarrJSON(ctx *context.Context) {
data, err := updateserver.GenerateDolibarrJSON(ctx, ctx.Repo.Repository)
allowedChannels, ok := validateUpdateKey(ctx)
if !ok {
// Return empty updates for invalid keys.
ctx.Resp.Header().Set("Content-Type", "application/json; charset=utf-8")
ctx.Resp.WriteHeader(http.StatusOK)
_, _ = ctx.Resp.Write([]byte(`{"module":"","updates":[]}`))
return
}
data, err := updateserver.GenerateDolibarrJSON(ctx, ctx.Repo.Repository, allowedChannels...)
if err != nil {
ctx.ServerError("GenerateDolibarrJSON", err)
return
+24 -11
View File
@@ -1106,10 +1106,18 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Group("/licenses", func() {
m.Get("", org.Licenses)
m.Post("/packages", org.LicensesCreatePackage)
m.Post("/keys/generate", org.LicensesGenerateKey)
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)
@@ -1516,13 +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.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
+8 -3
View File
@@ -606,17 +606,22 @@ func repoAssignmentPrepareTemplateData(ctx *Context, data *repoAssignmentPrepare
return
}
// Check if license packages exist for this repo's owner (enables Licenses tab).
// Check if licensing is enabled for this repo/org.
orgCfg, _ := licenses_model.GetOrgConfig(ctx, repo.OwnerID)
repoUpdateCfg, _ := licenses_model.GetRepoConfig(ctx, repo.ID)
licensingEnabled := (orgCfg != nil && orgCfg.LicensingEnabled) ||
(repoUpdateCfg != nil && repoUpdateCfg.LicensingEnabled)
numLicensePackages, _ := db.Count[licenses_model.LicensePackage](ctx, licenses_model.FindLicensePackageOptions{
OwnerID: repo.OwnerID,
})
ctx.Data["NumLicensePackages"] = numLicensePackages
ctx.Data["EnableLicenses"] = numLicensePackages > 0
ctx.Data["EnableLicenses"] = licensingEnabled || numLicensePackages > 0
ctx.Data["LicensingEnabled"] = licensingEnabled
ctx.Data["IsRepoAdmin"] = ctx.Repo.Permission.IsAdmin()
ctx.Data["IsSiteAdmin"] = ctx.IsUserSiteAdmin()
// Load repo update config for platform-aware UI.
repoUpdateCfg, _ := licenses_model.GetRepoConfig(ctx, repo.ID)
if repoUpdateCfg != nil {
ctx.Data["RepoUpdatePlatform"] = repoUpdateCfg.Platform
} else {
+1
View File
@@ -135,6 +135,7 @@ type RepoSettingForm struct {
ReleasesVisibility string
UpdatePlatform string
RequireUpdateKey bool
EnableLicensing bool
EnablePackages bool
+64
View File
@@ -11,6 +11,7 @@ import (
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
git_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git"
licenses_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
repo_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
user_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/container"
@@ -166,6 +167,64 @@ func createTag(ctx context.Context, gitRepo *git.Repository, rel *repo_model.Rel
}
// CreateRelease creates a new release of repository.
// ErrTagDoesNotMatchStream indicates a tag doesn't match any configured update stream.
type ErrTagDoesNotMatchStream struct {
TagName string
}
func (e ErrTagDoesNotMatchStream) Error() string {
return fmt.Sprintf("tag %q does not match any configured update stream", e.TagName)
}
// validateTagAgainstStreams checks that a release tag follows the update stream
// naming convention when licensing is active. Tags must start with a version
// prefix (v1.0.0) and any suffix must match a configured stream (e.g. -rc, -beta).
// When licensing is disabled, any tag is allowed.
func validateTagAgainstStreams(ctx context.Context, rel *repo_model.Release) error {
if rel.IsDraft || rel.IsTag {
return nil // drafts and lightweight tags are not validated
}
// Load the repo to get the owner ID.
repo, err := repo_model.GetRepositoryByID(ctx, rel.RepoID)
if err != nil {
return nil // non-fatal, skip validation
}
// Check if licensing is enabled at org or repo level.
orgCfg, _ := licenses_model.GetOrgConfig(ctx, repo.OwnerID)
repoCfg, _ := licenses_model.GetRepoConfig(ctx, repo.ID)
licensingEnabled := (orgCfg != nil && orgCfg.LicensingEnabled) ||
(repoCfg != nil && repoCfg.LicensingEnabled)
if !licensingEnabled {
return nil // licensing off — any tag is fine
}
// Check that the tag contains a stream-compatible suffix.
// Any prerelease suffix in the tag must match a configured stream suffix.
streams := licenses_model.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID)
lower := strings.ToLower(rel.TagName)
for _, s := range streams {
if s.Suffix == "" {
continue // stable stream matches everything
}
if strings.Contains(lower, s.Suffix) {
return nil // matches a configured stream
}
}
// If the tag has a prerelease-looking suffix but it doesn't match any stream, reject.
for _, indicator := range []string{"-rc", "-beta", "-alpha", "-dev"} {
if strings.Contains(lower, indicator) {
return ErrTagDoesNotMatchStream{TagName: rel.TagName}
}
}
// No prerelease suffix — this is a stable release, always allowed.
return nil
}
func CreateRelease(gitRepo *git.Repository, rel *repo_model.Release, attachmentUUIDs []string, msg string) error {
has, err := repo_model.IsReleaseExist(gitRepo.Ctx, rel.RepoID, rel.TagName)
if err != nil {
@@ -176,6 +235,11 @@ func CreateRelease(gitRepo *git.Repository, rel *repo_model.Release, attachmentU
}
}
// When licensing is enabled, validate that the tag matches a configured update stream.
if err := validateTagAgainstStreams(gitRepo.Ctx, rel); err != nil {
return err
}
if _, err = createTag(gitRepo.Ctx, gitRepo, rel, msg); err != nil {
return err
}
+13 -1
View File
@@ -35,7 +35,8 @@ type DolibarrUpdates struct {
}
// GenerateDolibarrJSON builds a Dolibarr-compatible update feed from releases.
func GenerateDolibarrJSON(ctx context.Context, repo *repo_model.Repository) (*DolibarrUpdates, error) {
// allowedChannels optionally restricts output to specific channels (nil = all).
func GenerateDolibarrJSON(ctx context.Context, repo *repo_model.Repository, allowedChannels ...string) (*DolibarrUpdates, error) {
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
RepoID: repo.ID,
ListOptions: db.ListOptionsAll,
@@ -73,8 +74,19 @@ func GenerateDolibarrJSON(ctx context.Context, repo *repo_model.Repository) (*Do
}
}
// Build allowed channel set for filtering.
channelAllowed := make(map[string]bool)
if len(allowedChannels) > 0 {
for _, c := range allowedChannels {
channelAllowed[NormalizeChannel(c)] = true
}
}
for _, stream := range streams {
ch := stream.Name
if len(channelAllowed) > 0 && !channelAllowed[ch] {
continue
}
rel, ok := bestByChannel[ch]
if !ok {
continue
+26 -16
View File
@@ -24,21 +24,27 @@ type xmlUpdates struct {
}
type xmlUpdate struct {
Name string `xml:"name"`
Description string `xml:"description"`
Element string `xml:"element"`
Type string `xml:"type"`
Client string `xml:"client"`
Version string `xml:"version"`
CreationDate string `xml:"creationDate"`
InfoURL xmlInfoURL `xml:"infourl"`
Downloads xmlDownloads `xml:"downloads"`
SHA256 string `xml:"sha256,omitempty"`
Tags xmlTags `xml:"tags"`
ChangelogURL string `xml:"changelogurl,omitempty"`
Maintainer string `xml:"maintainer,omitempty"`
MaintainerURL string `xml:"maintainerurl,omitempty"`
TargetPlatform xmlTargetPlat `xml:"targetplatform"`
Name string `xml:"name"`
Description string `xml:"description"`
Element string `xml:"element"`
Type string `xml:"type"`
Client string `xml:"client"`
Version string `xml:"version"`
CreationDate string `xml:"creationDate"`
InfoURL xmlInfoURL `xml:"infourl"`
Downloads xmlDownloads `xml:"downloads"`
SHA256 string `xml:"sha256,omitempty"`
Tags xmlTags `xml:"tags"`
ChangelogURL string `xml:"changelogurl,omitempty"`
Maintainer string `xml:"maintainer,omitempty"`
MaintainerURL string `xml:"maintainerurl,omitempty"`
TargetPlatform xmlTargetPlat `xml:"targetplatform"`
DownloadKey *xmlDownloadKey `xml:"downloadkey,omitempty"`
}
type xmlDownloadKey struct {
Prefix string `xml:"prefix,attr"`
Suffix string `xml:"suffix,attr"`
}
type xmlInfoURL struct {
@@ -120,7 +126,7 @@ func NormalizeChannel(ch string) string {
// It returns the raw XML bytes. The element, maintainer, and target platform
// are derived from the repo name and owner.
// allowedChannels optionally restricts output to specific channels (nil = all).
func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, allowedChannels ...string) ([]byte, error) {
func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, requireKey bool, allowedChannels ...string) ([]byte, error) {
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
RepoID: repo.ID,
ListOptions: db.ListOptionsAll,
@@ -234,6 +240,10 @@ func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, allowed
},
}
if requireKey {
u.DownloadKey = &xmlDownloadKey{Prefix: "&dlid=", Suffix: ""}
}
updates.Updates = append(updates.Updates, u)
}
+53 -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,11 +54,18 @@
<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>
<input name="allowed_channels" placeholder="stable,release-candidate">
{{if $.AvailableStreams}}
{{range $.AvailableStreams}}
<div class="ui checkbox tw-mr-4 tw-mb-2">
<input name="allowed_channels" type="checkbox" value="{{.Name}}">
<label>{{.Name}}{{if .Description}} <small class="text grey">({{.Description}})</small>{{end}}</label>
</div>
{{end}}
{{end}}
<p class="help">{{ctx.Locale.Tr "repo.licenses.channels_help"}}</p>
</div>
</div>
@@ -62,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>
@@ -76,20 +89,33 @@
<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">
<form method="post" action="{{$.Org.HomeLink}}/-/licenses/keys/generate" class="tw-inline">
<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 tw-flex tw-gap-1 tw-items-center">
{{$.CsrfTokenHtml}}
<input type="hidden" name="package_id" value="{{.ID}}">
<button class="ui tiny primary button" type="submit">
{{svg "octicon-plus" 14}} {{ctx.Locale.Tr "repo.licenses.generate_key"}}
{{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>
</form>
{{if ne .Name "Master (Internal)"}}
<a class="ui tiny button" href="{{$.Org.HomeLink}}/-/licenses/packages/{{.ID}}/edit" title="{{ctx.Locale.Tr "repo.licenses.edit_package"}}">
{{svg "octicon-pencil" 14}}
</a>
{{if $.IsSiteAdmin}}
<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>
{{end}}
</tr>
@@ -110,12 +136,13 @@
{{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>
<th>{{ctx.Locale.Tr "repo.licenses.licensee"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.expires"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.last_seen"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.status"}}</th>
{{if .IsRepoAdmin}}<th></th>{{end}}
</tr>
@@ -123,18 +150,24 @@
<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.TimeSince .ExpiresUnix}}{{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>
<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="{{$.Org.HomeLink}}/-/licenses/keys/{{.ID}}/revoke" class="tw-inline">
{{$.CsrfTokenHtml}}
<button class="ui tiny red button" type="submit">
{{svg "octicon-x" 14}}
</button>
</form>
<td class="tw-text-right tw-flex tw-gap-1 tw-justify-end">
{{if not .IsInternal}}
<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}}
<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>
+60
View File
@@ -0,0 +1,60 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content organization">
{{template "org/header" .}}
<div class="ui container">
<h4 class="ui top attached header">
{{svg "octicon-pencil" 16}} {{ctx.Locale.Tr "repo.licenses.edit_package"}}
</h4>
<div class="ui attached segment">
<form class="ui form" method="post" action="{{$.Org.HomeLink}}/-/licenses/packages/{{.Package.ID}}/edit">
{{.CsrfTokenHtml}}
<div class="two fields">
<div class="required field">
<label>{{ctx.Locale.Tr "repo.licenses.package_name"}}</label>
<input name="name" required value="{{.Package.Name}}">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.licenses.description"}}</label>
<input name="description" value="{{.Package.Description}}">
</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="{{.Package.DurationDays}}" min="0">
<p class="help">0 = {{ctx.Locale.Tr "repo.licenses.lifetime"}}</p>
</div>
<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 = {{ctx.Locale.Tr "repo.licenses.unlimited"}}</p>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.licenses.channels"}}</label>
{{if .AvailableStreams}}
{{range .AvailableStreams}}
<div class="ui checkbox tw-mr-4 tw-mb-2">
<input name="allowed_channels" type="checkbox" value="{{.Name}}" {{if SliceUtils.Contains $.SelectedChannels .Name}}checked{{end}}>
<label>{{.Name}}{{if .Description}} <small class="text grey">({{.Description}})</small>{{end}}</label>
</div>
{{end}}
{{end}}
<p class="help">{{ctx.Locale.Tr "repo.licenses.channels_help"}}</p>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<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>
<a class="ui button" href="{{$.Org.HomeLink}}/-/licenses">{{ctx.Locale.Tr "cancel"}}</a>
</div>
</form>
</div>
</div>
</div>
{{template "base/footer" .}}
+4 -1
View File
@@ -25,9 +25,12 @@
{{svg "octicon-package"}} {{ctx.Locale.Tr "packages.title"}}
</a>
{{end}}
{{if .IsOrganizationMember}}
{{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}}>
+30 -5
View File
@@ -1,13 +1,38 @@
{{template "org/settings/layout_head" (dict "pageClass" "organization settings")}}
<div class="org-setting-content">
{{/* Section 1: Licensing */}}
<h4 class="ui top attached header">
{{ctx.Locale.Tr "org.settings.update_streams"}}
{{svg "octicon-key" 16}} {{ctx.Locale.Tr "org.settings.licensing"}}
</h4>
<div class="ui attached segment">
<p>{{ctx.Locale.Tr "org.settings.update_streams_desc"}}</p>
<form class="ui form" method="post" action="{{.OrgLink}}/settings/update-streams">
{{.CsrfTokenHtml}}
<p>{{ctx.Locale.Tr "org.settings.licensing_desc"}}</p>
<div class="inline field">
<div class="ui checkbox">
<input name="licensing_enabled" type="checkbox" {{if .StreamConfig.LicensingEnabled}}checked{{end}}>
<label><strong>{{ctx.Locale.Tr "org.settings.enable_licensing"}}</strong></label>
</div>
<p class="help">{{ctx.Locale.Tr "org.settings.enable_licensing_help"}}</p>
</div>
<div class="inline field">
<div class="ui checkbox">
<input name="require_key" type="checkbox" {{if .StreamConfig.RequireKey}}checked{{end}}>
<label><strong>{{ctx.Locale.Tr "org.settings.require_key"}}</strong></label>
</div>
<p class="help">{{ctx.Locale.Tr "org.settings.require_key_help"}}</p>
</div>
<div class="ui divider"></div>
{{/* Section 2: Update Streams */}}
<h5>{{svg "octicon-rss" 14}} {{ctx.Locale.Tr "org.settings.update_streams_heading"}}</h5>
<p>{{ctx.Locale.Tr "org.settings.update_streams_desc"}}</p>
<div class="grouped fields">
<label>{{ctx.Locale.Tr "org.settings.stream_mode"}}</label>
<div class="field">
@@ -26,8 +51,7 @@
<div class="field">
<label>{{ctx.Locale.Tr "org.settings.default_streams"}}</label>
<p class="help">{{ctx.Locale.Tr "org.settings.default_streams_joomla"}}</p>
<table class="ui small table">
<table class="ui small compact table">
<thead>
<tr>
<th>{{ctx.Locale.Tr "org.settings.stream_name"}}</th>
@@ -39,12 +63,13 @@
{{range .EffectiveStreams}}
<tr>
<td><code>{{.Name}}</code></td>
<td>{{if .Suffix}}<code>{{.Suffix}}</code>{{else}}<em>(no suffix)</em>{{end}}</td>
<td>{{if .Suffix}}<code>{{.Suffix}}</code>{{else}}<span class="text grey">{{ctx.Locale.Tr "org.settings.no_suffix"}}</span>{{end}}</td>
<td>{{.Description}}</td>
</tr>
{{end}}
</tbody>
</table>
<p class="help">{{ctx.Locale.Tr "org.settings.streams_tag_help"}}</p>
</div>
<div class="field">
+1 -1
View File
@@ -47,7 +47,7 @@
<h3>{{ctx.Locale.Tr "org.settings.permission"}}</h3>
{{ctx.Locale.Tr "org.teams.write_permission_desc"}}
{{else if (eq .Team.AccessMode 3)}}
{{/* FIXME: here might not right, see "FIXME: TEAM-UNIT-PERMISSION", new units might not have correct admin permission*/}}
{{/* Admin teams implicitly have admin access to all units (including newly added ones) */}}
<h3>{{ctx.Locale.Tr "org.settings.permission"}}</h3>
{{ctx.Locale.Tr "org.teams.admin_permission_desc"}}
{{else}}
+51 -29
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,30 +45,32 @@
<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>
</form>
{{if ne .Name "Master (Internal)"}}
<a class="ui tiny button" href="{{$.RepoLink}}/licenses/packages/{{.ID}}/edit" title="{{ctx.Locale.Tr "repo.licenses.edit_package"}}">
{{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>
{{end}}
@@ -106,11 +114,18 @@
<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>
<input name="allowed_channels" placeholder="stable,release-candidate">
{{if .AvailableStreams}}
{{range .AvailableStreams}}
<div class="ui checkbox tw-mr-4 tw-mb-2">
<input name="allowed_channels" type="checkbox" value="{{.Name}}">
<label>{{.Name}}{{if .Description}} <small class="text grey">({{.Description}})</small>{{end}}</label>
</div>
{{end}}
{{end}}
<p class="help">{{ctx.Locale.Tr "repo.licenses.channels_help"}}</p>
</div>
</div>
@@ -127,12 +142,13 @@
{{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>
<th>{{ctx.Locale.Tr "repo.licenses.licensee"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.expires"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.last_seen"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.status"}}</th>
{{if .IsRepoAdmin}}<th></th>{{end}}
</tr>
@@ -140,18 +156,24 @@
<tbody>
{{range .LicenseKeys}}
<tr>
<td><code>{{.KeyPrefix}}</code></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.TimeSince .ExpiresUnix}}{{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>
<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 class="tw-text-right tw-flex tw-gap-1 tw-justify-end">
{{if not .IsInternal}}
<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}}
<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>
@@ -167,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>
+56
View File
@@ -0,0 +1,56 @@
{{template "base/head" .}}
<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-pencil" 16}} {{ctx.Locale.Tr "repo.licenses.edit_key"}}
</h4>
<div class="ui attached segment">
<div class="tw-mb-4">
<strong>{{ctx.Locale.Tr "repo.licenses.key_prefix"}}:</strong> <code>{{.Key.KeyPrefix}}</code>
</div>
<form class="ui form" method="post" action="{{.FormAction}}">
{{.CsrfTokenHtml}}
<div class="two fields">
<div class="field">
<label>{{ctx.Locale.Tr "repo.licenses.licensee_name"}}</label>
<input name="licensee_name" value="{{.Key.LicenseeName}}" placeholder="Customer name">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.licenses.licensee_email"}}</label>
<input name="licensee_email" type="email" value="{{.Key.LicenseeEmail}}" placeholder="customer@example.com">
</div>
</div>
<div class="two fields">
<div class="field">
<label>{{ctx.Locale.Tr "repo.licenses.domain_restriction"}}</label>
<input name="domain_restriction" value="{{.Key.DomainRestriction}}" placeholder="example.com,example.org">
<p class="help">{{ctx.Locale.Tr "repo.licenses.domain_restriction_help"}}</p>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.licenses.max_sites"}}</label>
<input name="max_sites" type="number" value="{{.Key.MaxSites}}" min="0">
<p class="help">0 = {{ctx.Locale.Tr "repo.licenses.use_package_default"}}</p>
</div>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.licenses.expires_at"}}</label>
<input name="expires_at" type="date" value="{{.ExpiresDate}}">
<p class="help">{{ctx.Locale.Tr "repo.licenses.expires_at_help"}}</p>
</div>
<div class="field">
<div class="ui checkbox">
<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>
<a class="ui button" href="{{.BackLink}}">{{ctx.Locale.Tr "cancel"}}</a>
</div>
</form>
</div>
</div>
</div>
{{template "base/footer" .}}
+10 -2
View File
@@ -27,11 +27,18 @@
<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>
<input name="allowed_channels" value="{{.Package.AllowedChannels}}">
{{if .AvailableStreams}}
{{range .AvailableStreams}}
<div class="ui checkbox tw-mr-4 tw-mb-2">
<input name="allowed_channels" type="checkbox" value="{{.Name}}" {{if SliceUtils.Contains $.SelectedChannels .Name}}checked{{end}}>
<label>{{.Name}}{{if .Description}} <small class="text grey">({{.Description}})</small>{{end}}</label>
</div>
{{end}}
{{end}}
<p class="help">{{ctx.Locale.Tr "repo.licenses.channels_help"}}</p>
</div>
</div>
@@ -40,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}}
+1 -1
View File
@@ -62,7 +62,7 @@
{{.Name}}
</a>
<div class="item-body flex-text-block">
{{/*FIXME: TEAM-UNIT-PERMISSION this display is not right, search the fixme keyword to see more details */}}
{{/* Team access mode: 0=per-unit, 1=read, 2=write, 3=admin (all units), 4=owner */}}
{{svg "octicon-shield-lock"}}
{{if eq .AccessMode 0}}
{{ctx.Locale.Tr "repo.settings.collaboration.per_unit"}}
+16
View File
@@ -514,6 +514,20 @@
</select>
<p class="help">{{ctx.Locale.Tr "repo.settings.unit_visibility_releases_help"}}</p>
</div>
</div>
<div class="divider"></div>
{{/* Licensing & Update Feeds */}}
<div class="inline field">
<label>{{ctx.Locale.Tr "repo.settings.licensing_section"}}</label>
<div class="ui checkbox">
<input class="enable-system" name="enable_licensing" type="checkbox" data-target="#licensing_options_box" {{if and .RepoUpdateConfig .RepoUpdateConfig.LicensingEnabled}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.enable_licensing"}}</label>
</div>
</div>
<div class="field tw-pl-4{{if not (and .RepoUpdateConfig .RepoUpdateConfig.LicensingEnabled)}} disabled{{end}}" id="licensing_options_box">
<p class="help tw-mb-4">{{ctx.Locale.Tr "repo.settings.licensing_section_desc"}}</p>
<div class="inline field">
<label>{{ctx.Locale.Tr "repo.settings.update_platform"}}</label>
<select name="update_platform" class="ui dropdown">
@@ -521,12 +535,14 @@
<option value="dolibarr" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.Platform "dolibarr")}}selected{{end}}>Dolibarr (JSON)</option>
<option value="both" {{if and .RepoUpdateConfig (eq .RepoUpdateConfig.Platform "both")}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.update_platform_both"}}</option>
</select>
<p class="help">{{ctx.Locale.Tr "repo.settings.update_platform_help"}}</p>
</div>
<div class="inline field">
<div class="ui checkbox">
<input name="require_update_key" type="checkbox" {{if and .RepoUpdateConfig .RepoUpdateConfig.RequireKey}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.require_update_key"}}</label>
</div>
<p class="help">{{ctx.Locale.Tr "repo.settings.require_update_key_help"}}</p>
</div>
</div>