2
0

feat(packages): add global package access support

Add is_global flag to packages allowing them to be accessible at root URLs without owner prefix. Include database migration, package settings UI, admin bulk operations, and automatic repository matching. This enables cleaner package URLs for organization-wide packages.
This commit is contained in:
2026-01-21 20:05:36 -05:00
parent 4ae82201dc
commit 1f512924de
12 changed files with 520 additions and 11 deletions

View File

@@ -409,6 +409,7 @@ func prepareMigrationTasks() []*migration {
newMigration(332, "Add display_title and license_type to repository", v1_26.AddDisplayTitleAndLicenseTypeToRepository),
newMigration(333, "Add group_header to repository", v1_26.AddGroupHeaderToRepository),
newMigration(334, "Add group_header to user for organization grouping", v1_26.AddGroupHeaderToUser),
newMigration(335, "Add is_global to package for global package access", v1_26.AddIsGlobalToPackage),
}
return preparedMigrations
}

View File

@@ -0,0 +1,17 @@
// Copyright 2024 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package v1_26
import (
"xorm.io/xorm"
)
// AddIsGlobalToPackage adds is_global column to the package table
func AddIsGlobalToPackage(x *xorm.Engine) error {
type Package struct {
IsGlobal bool `xorm:"NOT NULL DEFAULT false INDEX"`
}
return x.Sync(new(Package))
}

View File

@@ -191,6 +191,7 @@ type Package struct {
LowerName string `xorm:"UNIQUE(s) INDEX NOT NULL"`
SemverCompatible bool `xorm:"NOT NULL DEFAULT false"`
IsInternal bool `xorm:"NOT NULL DEFAULT false"`
IsGlobal bool `xorm:"NOT NULL DEFAULT false INDEX"` // Global packages accessible at root URL
}
// TryInsertPackage inserts a package. If a package exists already, ErrDuplicatePackage is returned
@@ -349,3 +350,142 @@ func HasOwnerPackages(ctx context.Context, ownerID int64) (bool, error) {
func HasRepositoryPackages(ctx context.Context, repositoryID int64) (bool, error) {
return db.GetEngine(ctx).Where("repo_id = ?", repositoryID).Exist(&Package{})
}
// GetGlobalPackageByName gets a global package by type and name (accessible at root URL)
func GetGlobalPackageByName(ctx context.Context, packageType Type, name string) (*Package, error) {
var cond builder.Cond = builder.Eq{
"package.type": packageType,
"package.lower_name": strings.ToLower(name),
"package.is_internal": false,
"package.is_global": true,
}
p := &Package{}
has, err := db.GetEngine(ctx).
Where(cond).
Get(p)
if err != nil {
return nil, err
}
if !has {
return nil, ErrPackageNotExist
}
return p, nil
}
// ErrGlobalPackageAlreadyExists indicates a global package with the same type+name already exists
var ErrGlobalPackageAlreadyExists = util.NewAlreadyExistErrorf("a global package with this type and name already exists")
// SetPackageIsGlobal sets the global flag on a package with validation
func SetPackageIsGlobal(ctx context.Context, packageID int64, isGlobal bool) error {
if isGlobal {
// Get the package to check its type and name
p, err := GetPackageByID(ctx, packageID)
if err != nil {
return err
}
// Check if another global package with the same type+name exists
existing, err := GetGlobalPackageByName(ctx, p.Type, p.Name)
if err == nil && existing.ID != packageID {
return ErrGlobalPackageAlreadyExists
}
// ErrPackageNotExist is expected and means we can proceed
}
_, err := db.GetEngine(ctx).ID(packageID).Cols("is_global").Update(&Package{IsGlobal: isGlobal})
return err
}
// SetPackagesIsGlobal sets the global flag on multiple packages (for bulk operations)
func SetPackagesIsGlobal(ctx context.Context, packageIDs []int64, isGlobal bool) (succeeded, failed int, err error) {
for _, id := range packageIDs {
if err := SetPackageIsGlobal(ctx, id, isGlobal); err != nil {
failed++
} else {
succeeded++
}
}
return succeeded, failed, nil
}
// FindMatchingRepository tries to find a repository that matches the package name
func FindMatchingRepository(ctx context.Context, pkg *Package) (int64, error) {
// Try exact match first (case-insensitive)
type Repository struct {
ID int64
LowerName string
}
repo := &Repository{}
has, err := db.GetEngine(ctx).
Table("repository").
Where("owner_id = ? AND lower_name = ?", pkg.OwnerID, strings.ToLower(pkg.Name)).
Get(repo)
if err != nil {
return 0, err
}
if has {
return repo.ID, nil
}
// Try partial match - package name contains repo name or vice versa
repos := make([]*Repository, 0)
err = db.GetEngine(ctx).
Table("repository").
Where("owner_id = ?", pkg.OwnerID).
Find(&repos)
if err != nil {
return 0, err
}
pkgLower := strings.ToLower(pkg.Name)
for _, r := range repos {
if strings.Contains(pkgLower, r.LowerName) || strings.Contains(r.LowerName, pkgLower) {
return r.ID, nil
}
}
return 0, nil
}
// AutoMatchPackageToRepository automatically links a package to a matching repository
func AutoMatchPackageToRepository(ctx context.Context, packageID int64) (repoID int64, err error) {
pkg, err := GetPackageByID(ctx, packageID)
if err != nil {
return 0, err
}
// Already linked
if pkg.RepoID > 0 {
return pkg.RepoID, nil
}
repoID, err = FindMatchingRepository(ctx, pkg)
if err != nil {
return 0, err
}
if repoID > 0 {
err = SetRepositoryLink(ctx, packageID, repoID)
if err != nil {
return 0, err
}
}
return repoID, nil
}
// GetAllGlobalPackages gets all packages marked as global
func GetAllGlobalPackages(ctx context.Context) ([]*Package, error) {
var cond builder.Cond = builder.Eq{
"package.is_internal": false,
"package.is_global": true,
}
ps := make([]*Package, 0, 10)
return ps, db.GetEngine(ctx).
Where(cond).
Find(&ps)
}

View File

@@ -3127,6 +3127,26 @@
"admin.packages.repository": "Repository",
"admin.packages.size": "Size",
"admin.packages.published": "Published",
"admin.packages.global": "Global",
"admin.packages.global.yes": "Global",
"admin.packages.global.no": "No",
"admin.packages.bulk.actions": "Bulk Actions",
"admin.packages.bulk.enable_global": "Enable Global Access",
"admin.packages.bulk.disable_global": "Disable Global Access",
"admin.packages.bulk.automatch": "Auto-Match to Repositories",
"admin.packages.bulk.selected": "Selected:",
"admin.packages.bulk.no_selection": "Please select at least one package",
"admin.packages.bulk.global.enabled": "Enabled global access for %d package(s)",
"admin.packages.bulk.global.disabled": "Disabled global access for %d package(s)",
"admin.packages.bulk.global.partial": "Enabled global access for %d package(s), %d failed (may already exist as global)",
"admin.packages.bulk.automatch.success": "Auto-matched %d package(s) to repositories",
"admin.packages.bulk.automatch.none": "No matching repositories found for selected packages",
"admin.packages.automatch.button": "Find matching repository",
"admin.packages.automatch.match": "Match",
"admin.packages.automatch.success": "Package linked to matching repository",
"admin.packages.automatch.no_match": "No matching repository found",
"admin.packages.automatch.error": "Error matching package to repository",
"admin.packages.automatch.invalid": "Invalid package ID",
"admin.defaulthooks": "Default Webhooks",
"admin.defaulthooks.desc": "Webhooks automatically make HTTP POST requests to a server when certain GitCaddy events trigger. Webhooks defined here are defaults and will be copied into all new repositories. Read more in the <a target=\"_blank\" rel=\"noopener\" href=\"%s\">webhooks guide</a>.",
"admin.defaulthooks.add_webhook": "Add Default Webhook",
@@ -3649,6 +3669,13 @@
"packages.settings.delete.notice": "You are about to delete %s (%s). This operation is irreversible, are you sure?",
"packages.settings.delete.success": "The package has been deleted.",
"packages.settings.delete.error": "Failed to delete the package.",
"packages.settings.global_access": "Global Access",
"packages.settings.global_access.description": "Global packages can be accessed at the root URL without specifying an owner (e.g., /api/packages/_/npm/...).",
"packages.settings.global_access.enable": "Make this package globally accessible",
"packages.settings.global_access.help": "When enabled, this package can be accessed via /api/packages/_/{type}/{name} in addition to the owner-specific URL.",
"packages.settings.global_access.enabled": "Package is now globally accessible.",
"packages.settings.global_access.disabled": "Package global access has been disabled.",
"packages.settings.global_access.error": "Failed to update global access setting.",
"packages.owner.settings.cargo.title": "Cargo Registry Index",
"packages.owner.settings.cargo.initialize": "Initialize Index",
"packages.owner.settings.cargo.initialize.description": "A special index Git repository is needed to use the Cargo registry. Using this option will (re-)create the repository and configure it automatically.",

View File

@@ -4,6 +4,7 @@
package admin
import (
"fmt"
"net/http"
"net/url"
"time"
@@ -106,3 +107,97 @@ func CleanupExpiredData(ctx *context.Context) {
ctx.Flash.Success(ctx.Tr("admin.packages.cleanup.success"))
ctx.Redirect(setting.AppSubURL + "/-/admin/packages")
}
// BulkSetGlobal sets/unsets global flag on multiple packages
func BulkSetGlobal(ctx *context.Context) {
packageIDs := ctx.FormStrings("ids[]")
isGlobal := ctx.FormBool("is_global")
ids := make([]int64, 0, len(packageIDs))
for _, idStr := range packageIDs {
var id int64
if _, err := fmt.Sscanf(idStr, "%d", &id); err == nil && id > 0 {
ids = append(ids, id)
}
}
if len(ids) == 0 {
ctx.Flash.Error(ctx.Tr("admin.packages.bulk.no_selection"))
ctx.JSONRedirect(setting.AppSubURL + "/-/admin/packages")
return
}
succeeded, failed, _ := packages_model.SetPackagesIsGlobal(ctx, ids, isGlobal)
if failed > 0 {
ctx.Flash.Warning(ctx.Tr("admin.packages.bulk.global.partial", succeeded, failed))
} else {
if isGlobal {
ctx.Flash.Success(ctx.Tr("admin.packages.bulk.global.enabled", succeeded))
} else {
ctx.Flash.Success(ctx.Tr("admin.packages.bulk.global.disabled", succeeded))
}
}
ctx.JSONRedirect(setting.AppSubURL + "/-/admin/packages")
}
// BulkAutoMatch automatically matches packages to repositories
func BulkAutoMatch(ctx *context.Context) {
packageIDs := ctx.FormStrings("ids[]")
ids := make([]int64, 0, len(packageIDs))
for _, idStr := range packageIDs {
var id int64
if _, err := fmt.Sscanf(idStr, "%d", &id); err == nil && id > 0 {
ids = append(ids, id)
}
}
if len(ids) == 0 {
ctx.Flash.Error(ctx.Tr("admin.packages.bulk.no_selection"))
ctx.JSONRedirect(setting.AppSubURL + "/-/admin/packages")
return
}
matched := 0
for _, id := range ids {
repoID, err := packages_model.AutoMatchPackageToRepository(ctx, id)
if err == nil && repoID > 0 {
matched++
}
}
if matched > 0 {
ctx.Flash.Success(ctx.Tr("admin.packages.bulk.automatch.success", matched))
} else {
ctx.Flash.Warning(ctx.Tr("admin.packages.bulk.automatch.none"))
}
ctx.JSONRedirect(setting.AppSubURL + "/-/admin/packages")
}
// SingleAutoMatch automatically matches a single package to a repository
func SingleAutoMatch(ctx *context.Context) {
packageID := ctx.FormInt64("id")
if packageID == 0 {
ctx.Flash.Error(ctx.Tr("admin.packages.automatch.invalid"))
ctx.JSONRedirect(setting.AppSubURL + "/-/admin/packages")
return
}
repoID, err := packages_model.AutoMatchPackageToRepository(ctx, packageID)
if err != nil {
ctx.Flash.Error(ctx.Tr("admin.packages.automatch.error"))
ctx.JSONRedirect(setting.AppSubURL + "/-/admin/packages")
return
}
if repoID > 0 {
ctx.Flash.Success(ctx.Tr("admin.packages.automatch.success"))
} else {
ctx.Flash.Warning(ctx.Tr("admin.packages.automatch.no_match"))
}
ctx.JSONRedirect(setting.AppSubURL + "/-/admin/packages")
}

View File

@@ -235,11 +235,14 @@ func home(ctx *context.Context, viewRepositories bool) {
// Always show overview by default for organizations
isViewOverview := !viewRepositories
// Load profile readme if available
prepareOrgProfileReadme(ctx, prepareResult)
ctx.Data["PageIsViewRepositories"] = !isViewOverview
ctx.Data["PageIsViewOverview"] = isViewOverview
ctx.Data["ShowOrgProfileReadmeSelector"] = isViewOverview && prepareResult.ProfilePublicReadmeBlob != nil && prepareResult.ProfilePrivateReadmeBlob != nil
// Load profile readme only on overview page (not repositories page)
if isViewOverview {
prepareOrgProfileReadme(ctx, prepareResult)
ctx.Data["ShowOrgProfileReadmeSelector"] = prepareResult.ProfilePublicReadmeBlob != nil && prepareResult.ProfilePrivateReadmeBlob != nil
}
// When grouping is enabled, order by group_header first to keep groups together
finalOrderBy := orderBy

View File

@@ -453,6 +453,8 @@ func PackageSettingsPost(ctx *context.Context) {
packageSettingsPostActionLink(ctx, form)
case "delete":
packageSettingsPostActionDelete(ctx)
case "global":
packageSettingsPostActionGlobal(ctx)
default:
ctx.NotFound(nil)
}
@@ -508,6 +510,30 @@ func packageSettingsPostActionDelete(ctx *context.Context) {
ctx.Redirect(redirectURL)
}
func packageSettingsPostActionGlobal(ctx *context.Context) {
// Only admins can set global flag
if !ctx.IsUserSiteAdmin() {
ctx.NotFound(nil)
return
}
pd := ctx.Package.Descriptor
isGlobal := ctx.FormBool("is_global")
if err := packages_model.SetPackageIsGlobal(ctx, pd.Package.ID, isGlobal); err != nil {
ctx.Flash.Error(ctx.Tr("packages.settings.global_access.error"))
ctx.Redirect(ctx.Package.Descriptor.VersionWebLink() + "/settings")
return
}
if isGlobal {
ctx.Flash.Success(ctx.Tr("packages.settings.global_access.enabled"))
} else {
ctx.Flash.Success(ctx.Tr("packages.settings.global_access.disabled"))
}
ctx.Redirect(ctx.Package.Descriptor.VersionWebLink() + "/settings")
}
// DownloadPackageFile serves the content of a package file
func DownloadPackageFile(ctx *context.Context) {
pf, err := packages_model.GetFileForVersionByID(ctx, ctx.Package.Descriptor.Version.ID, ctx.PathParamInt64("fileid"))

View File

@@ -831,6 +831,9 @@ func registerWebRoutes(m *web.Router) {
m.Get("", admin.Packages)
m.Post("/delete", admin.DeletePackageVersion)
m.Post("/cleanup", admin.CleanupExpiredData)
m.Post("/bulk-global", admin.BulkSetGlobal)
m.Post("/bulk-automatch", admin.BulkAutoMatch)
m.Post("/automatch", admin.SingleAutoMatch)
}, packagesEnabled)
m.Group("/hooks", func() {

View File

@@ -61,6 +61,10 @@ func packageAssignment(ctx *packageAssignmentCtx, errCb func(int, any)) *Package
pkg := &Package{
Owner: ctx.ContextUser,
}
// Handle global packages (when owner is nil, i.e., username was "_")
isGlobalRequest := ctx.ContextUser == nil
var err error
pkg.AccessMode, err = determineAccessMode(ctx.Base, pkg, ctx.Doer)
if err != nil {
@@ -72,14 +76,47 @@ func packageAssignment(ctx *packageAssignmentCtx, errCb func(int, any)) *Package
name := ctx.PathParam("name")
version := ctx.PathParam("version")
if packageType != "" && name != "" && version != "" {
pv, err := packages_model.GetVersionByNameAndVersion(ctx, pkg.Owner.ID, packages_model.Type(packageType), name, version)
if err != nil {
if err == packages_model.ErrPackageNotExist {
errCb(http.StatusNotFound, fmt.Errorf("GetVersionByNameAndVersion: %w", err))
} else {
errCb(http.StatusInternalServerError, fmt.Errorf("GetVersionByNameAndVersion: %w", err))
var pv *packages_model.PackageVersion
if isGlobalRequest {
// Look up global package by name (no owner)
p, err := packages_model.GetGlobalPackageByName(ctx, packages_model.Type(packageType), name)
if err != nil {
if err == packages_model.ErrPackageNotExist {
errCb(http.StatusNotFound, fmt.Errorf("GetGlobalPackageByName: %w", err))
} else {
errCb(http.StatusInternalServerError, fmt.Errorf("GetGlobalPackageByName: %w", err))
}
return pkg
}
// Get the actual owner for the package
owner, err := user_model.GetUserByID(ctx, p.OwnerID)
if err != nil {
errCb(http.StatusInternalServerError, fmt.Errorf("GetUserByID: %w", err))
return pkg
}
pkg.Owner = owner
// Get the version
pv, err = packages_model.GetVersionByNameAndVersion(ctx, p.OwnerID, packages_model.Type(packageType), name, version)
if err != nil {
if err == packages_model.ErrPackageNotExist {
errCb(http.StatusNotFound, fmt.Errorf("GetVersionByNameAndVersion: %w", err))
} else {
errCb(http.StatusInternalServerError, fmt.Errorf("GetVersionByNameAndVersion: %w", err))
}
return pkg
}
} else {
pv, err = packages_model.GetVersionByNameAndVersion(ctx, pkg.Owner.ID, packages_model.Type(packageType), name, version)
if err != nil {
if err == packages_model.ErrPackageNotExist {
errCb(http.StatusNotFound, fmt.Errorf("GetVersionByNameAndVersion: %w", err))
} else {
errCb(http.StatusInternalServerError, fmt.Errorf("GetVersionByNameAndVersion: %w", err))
}
return pkg
}
return pkg
}
pkg.Descriptor, err = packages_model.GetPackageDescriptor(ctx, pv)
@@ -101,6 +138,14 @@ func determineAccessMode(ctx *Base, pkg *Package, doer *user_model.User) (perm.A
return perm.AccessModeNone, nil
}
// Global packages (no owner) - allow read access to everyone, write only to admins
if pkg.Owner == nil {
if doer != nil && doer.IsAdmin {
return perm.AccessModeAdmin, nil
}
return perm.AccessModeRead, nil
}
// TODO: ActionUser permission check
accessMode := perm.AccessModeNone
if pkg.Owner.IsOrganization() {

View File

@@ -61,6 +61,11 @@ func UserAssignmentAPI() func(ctx *APIContext) {
func userAssignment(ctx *Base, doer *user_model.User, errCb func(int, any)) (contextUser *user_model.User) {
username := ctx.PathParam("username")
// Special case: "_" means global packages (no specific owner)
if username == "_" {
return nil
}
if doer != nil && strings.EqualFold(doer.LowerName, username) {
contextUser = doer
} else {

View File

@@ -25,10 +25,26 @@
</div>
</form>
</div>
<div class="ui attached segment">
<div class="tw-flex tw-gap-2">
<div class="ui small dropdown button" id="bulk-actions-dropdown">
<span class="text">{{ctx.Locale.Tr "admin.packages.bulk.actions"}}</span>
{{svg "octicon-triangle-down" 14}}
<div class="menu">
<div class="item" data-action="enable-global">{{svg "octicon-globe" 14}} {{ctx.Locale.Tr "admin.packages.bulk.enable_global"}}</div>
<div class="item" data-action="disable-global">{{svg "octicon-lock" 14}} {{ctx.Locale.Tr "admin.packages.bulk.disable_global"}}</div>
<div class="divider"></div>
<div class="item" data-action="automatch">{{svg "octicon-link" 14}} {{ctx.Locale.Tr "admin.packages.bulk.automatch"}}</div>
</div>
</div>
<span class="tw-text-text-light-2 tw-self-center" id="selected-count"></span>
</div>
</div>
<div class="ui attached table segment">
<table class="ui very basic striped table unstackable">
<thead>
<tr>
<th class="tw-w-8"><input type="checkbox" id="select-all-packages"></th>
<th>ID</th>
<th>{{ctx.Locale.Tr "admin.packages.owner"}}</th>
<th>{{ctx.Locale.Tr "admin.packages.type"}}</th>
@@ -42,6 +58,7 @@
</th>
<th>{{ctx.Locale.Tr "admin.packages.creator"}}</th>
<th>{{ctx.Locale.Tr "admin.packages.repository"}}</th>
<th>{{ctx.Locale.Tr "admin.packages.global"}}</th>
<th>{{ctx.Locale.Tr "admin.packages.size"}}</th>
<th data-sortt-asc="created_asc" data-sortt-desc="created_desc">
{{ctx.Locale.Tr "admin.packages.published"}}
@@ -53,6 +70,7 @@
<tbody>
{{range .PackageDescriptors}}
<tr>
<td><input type="checkbox" class="package-checkbox" data-package-id="{{.Package.ID}}"></td>
<td>{{.Version.ID}}</td>
<td>
<a href="{{.Owner.HomeLink}}">{{.Owner.Name}}</a>
@@ -67,8 +85,19 @@
<td>
{{if .Repository}}
<a href="{{.Repository.Link}}">{{.Repository.Name}}</a>
{{else}}
<button class="ui tiny basic button automatch-btn" data-package-id="{{.Package.ID}}" title="{{ctx.Locale.Tr "admin.packages.automatch.button"}}">
{{svg "octicon-link" 12}} {{ctx.Locale.Tr "admin.packages.automatch.match"}}
</button>
{{end}}
</td>
<td>
{{if .Package.IsGlobal}}
<span class="ui tiny green label">{{svg "octicon-globe" 12}} {{ctx.Locale.Tr "admin.packages.global.yes"}}</span>
{{else}}
<span class="text grey">{{ctx.Locale.Tr "admin.packages.global.no"}}</span>
{{end}}
</td>
<td>{{FileSize .CalculateBlobSize}}</td>
<td>{{DateUtils.AbsoluteShort .Version.CreatedUnix}}</td>
<td>
@@ -79,7 +108,7 @@
</td>
</tr>
{{else}}
<tr><td class="tw-text-center" colspan="10">{{ctx.Locale.Tr "no_results_found"}}</td></tr>
<tr><td class="tw-text-center" colspan="12">{{ctx.Locale.Tr "no_results_found"}}</td></tr>
{{end}}
</tbody>
</table>
@@ -96,4 +125,103 @@
{{template "base/modal_actions_confirm" .}}
</form>
<script>
document.addEventListener('DOMContentLoaded', function() {
const selectAllCheckbox = document.getElementById('select-all-packages');
const packageCheckboxes = document.querySelectorAll('.package-checkbox');
const selectedCountEl = document.getElementById('selected-count');
const bulkDropdown = document.getElementById('bulk-actions-dropdown');
function updateSelectedCount() {
const selected = document.querySelectorAll('.package-checkbox:checked').length;
if (selected > 0) {
selectedCountEl.textContent = '{{ctx.Locale.Tr "admin.packages.bulk.selected"}} ' + selected;
} else {
selectedCountEl.textContent = '';
}
}
selectAllCheckbox?.addEventListener('change', function() {
packageCheckboxes.forEach(cb => cb.checked = this.checked);
updateSelectedCount();
});
packageCheckboxes.forEach(cb => {
cb.addEventListener('change', updateSelectedCount);
});
function getSelectedIds() {
return Array.from(document.querySelectorAll('.package-checkbox:checked'))
.map(cb => cb.dataset.packageId);
}
bulkDropdown?.querySelectorAll('.item').forEach(item => {
item.addEventListener('click', function() {
const action = this.dataset.action;
const ids = getSelectedIds();
if (ids.length === 0) {
alert('{{ctx.Locale.Tr "admin.packages.bulk.no_selection"}}');
return;
}
let url, formData;
formData = new FormData();
ids.forEach(id => formData.append('ids[]', id));
switch(action) {
case 'enable-global':
url = '{{AppSubUrl}}/-/admin/packages/bulk-global';
formData.append('is_global', 'true');
break;
case 'disable-global':
url = '{{AppSubUrl}}/-/admin/packages/bulk-global';
formData.append('is_global', 'false');
break;
case 'automatch':
url = '{{AppSubUrl}}/-/admin/packages/bulk-automatch';
break;
}
fetch(url, {
method: 'POST',
body: formData,
headers: {
'X-Csrf-Token': document.querySelector('meta[name=_csrf]')?.content || ''
}
}).then(response => response.json())
.then(data => {
if (data.redirect) {
window.location.href = data.redirect;
} else {
window.location.reload();
}
});
});
});
document.querySelectorAll('.automatch-btn').forEach(btn => {
btn.addEventListener('click', function() {
const packageId = this.dataset.packageId;
const formData = new FormData();
formData.append('id', packageId);
fetch('{{AppSubUrl}}/-/admin/packages/automatch', {
method: 'POST',
body: formData,
headers: {
'X-Csrf-Token': document.querySelector('meta[name=_csrf]')?.content || ''
}
}).then(response => response.json())
.then(data => {
if (data.redirect) {
window.location.href = data.redirect;
} else {
window.location.reload();
}
});
});
});
});
</script>
{{template "admin/layout_footer" .}}

View File

@@ -26,6 +26,25 @@
<button class="ui primary button">{{ctx.Locale.Tr "packages.settings.link.button"}}</button>
</form>
</div>
{{if .IsAdmin}}
<h4 class="ui top attached header">
{{ctx.Locale.Tr "packages.settings.global_access"}}
</h4>
<div class="ui attached segment">
<p>{{ctx.Locale.Tr "packages.settings.global_access.description"}}</p>
<form class="ui form" action="{{.Link}}" method="post">
<input type="hidden" name="action" value="global">
<div class="field">
<div class="ui checkbox">
<input type="checkbox" name="is_global" {{if .PackageDescriptor.Package.IsGlobal}}checked{{end}}>
<label>{{ctx.Locale.Tr "packages.settings.global_access.enable"}}</label>
</div>
</div>
<p class="help">{{ctx.Locale.Tr "packages.settings.global_access.help"}}</p>
<button class="ui primary button">{{ctx.Locale.Tr "save"}}</button>
</form>
</div>
{{end}}
<h4 class="ui top attached error header">
{{ctx.Locale.Tr "repo.settings.danger_zone"}}
</h4>