2
0

feat(packages): add bulk visibility management for packages
Some checks failed
Build and Release / Create Release (push) Successful in 0s
Build and Release / Unit Tests (push) Successful in 3m12s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 5m8s
Build and Release / Lint (push) Successful in 5m19s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Successful in 3m11s
Build and Release / Build Binaries (amd64, darwin, macos) (push) Successful in 5m38s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Successful in 9h5m9s
Build and Release / Build Binary (linux/arm64) (push) Successful in 8m29s
Build and Release / Build Binaries (arm64, darwin, macos) (push) Failing after 10m38s

Add ability to bulk set packages as private or public in both admin and repository package views. Includes new bulk action buttons, visibility grouping in repository view, and corresponding backend handlers for processing visibility changes. Admin can manage all packages while repository owners can manage their own packages.
This commit is contained in:
2026-02-07 15:02:16 -05:00
parent fb2d53ba7a
commit b2adcdf969
6 changed files with 307 additions and 31 deletions

View File

@@ -3322,6 +3322,13 @@
"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.bulk.make_private": "Make Private",
"admin.packages.bulk.make_public": "Make Public",
"admin.packages.bulk.private.enabled": "Made %d package(s) private",
"admin.packages.bulk.private.disabled": "Made %d package(s) public",
"admin.packages.visibility": "Visibility",
"admin.packages.visibility.private": "Private",
"admin.packages.visibility.public": "Public",
"admin.packages.automatch.button": "Find matching repository",
"admin.packages.automatch.match": "Match",
"admin.packages.automatch.success": "Package linked to matching repository",
@@ -3726,6 +3733,14 @@
"packages.no_metadata": "No metadata.",
"packages.empty.documentation": "For more information on the package registry, see <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"%s\">the documentation</a>.",
"packages.empty.repo": "Did you upload a package, but it's not shown here? Go to <a href=\"%[1]s\">package settings</a> and link it to this repo.",
"packages.visibility.public": "Public Packages",
"packages.visibility.private": "Private Packages",
"packages.bulk.actions": "Bulk Actions",
"packages.bulk.make_private": "Make Private",
"packages.bulk.make_public": "Make Public",
"packages.bulk.selected": "Selected:",
"packages.bulk.select_all": "Select all",
"packages.bulk.no_selection": "Please select at least one package.",
"packages.registry.documentation": "For more information on the %s registry, see <a target=\"_blank\" rel=\"noopener noreferrer\" href=\"%s\">the documentation</a>.",
"packages.filter.type": "Type",
"packages.filter.type.all": "All",

View File

@@ -177,6 +177,41 @@ func BulkAutoMatch(ctx *context.Context) {
ctx.JSONRedirect(setting.AppSubURL + "/-/admin/packages")
}
// BulkSetPrivate sets/unsets private flag on multiple packages
func BulkSetPrivate(ctx *context.Context) {
packageIDs := ctx.FormStrings("ids[]")
isPrivate := ctx.FormBool("is_private")
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 := 0
for _, id := range ids {
if err := packages_model.SetPackageIsPrivate(ctx, id, isPrivate); err == nil {
succeeded++
}
}
if isPrivate {
ctx.Flash.Success(ctx.Tr("admin.packages.bulk.private.enabled", succeeded))
} else {
ctx.Flash.Success(ctx.Tr("admin.packages.bulk.private.disabled", succeeded))
}
ctx.JSONRedirect(setting.AppSubURL + "/-/admin/packages")
}
// SingleAutoMatch automatically matches a single package to a repository
func SingleAutoMatch(ctx *context.Context) {
packageID := ctx.FormInt64("id")

View File

@@ -5,6 +5,7 @@ package repo
import (
"net/http"
"strconv"
"code.gitcaddy.com/server/v3/models/db"
"code.gitcaddy.com/server/v3/models/packages"
@@ -19,7 +20,7 @@ const (
tplPackagesList templates.TplName = "repo/packages"
)
// Packages displays a list of all packages in the repository
// Packages displays a list of all packages in the repository, grouped by visibility
func Packages(ctx *context.Context) {
page := max(ctx.FormInt("page"), 1)
query := ctx.FormTrim("q")
@@ -47,6 +48,16 @@ func Packages(ctx *context.Context) {
return
}
// Group packages by visibility
var publicPackages, privatePackages []*packages.PackageDescriptor
for _, pd := range pds {
if pd.Package.IsPrivate {
privatePackages = append(privatePackages, pd)
} else {
publicPackages = append(publicPackages, pd)
}
}
hasPackages, err := packages.HasRepositoryPackages(ctx, ctx.Repo.Repository.ID)
if err != nil {
ctx.ServerError("HasRepositoryPackages", err)
@@ -61,6 +72,8 @@ func Packages(ctx *context.Context) {
ctx.Data["HasPackages"] = hasPackages
ctx.Data["CanWritePackages"] = ctx.Repo.CanWrite(unit.TypePackages) || ctx.IsUserSiteAdmin()
ctx.Data["PackageDescriptors"] = pds
ctx.Data["PublicPackages"] = publicPackages
ctx.Data["PrivatePackages"] = privatePackages
ctx.Data["Total"] = total
ctx.Data["RepositoryAccessMap"] = map[int64]bool{ctx.Repo.Repository.ID: true} // There is only the current repository
@@ -70,3 +83,34 @@ func Packages(ctx *context.Context) {
ctx.HTML(http.StatusOK, tplPackagesList)
}
// BulkSetPackageVisibility changes visibility for multiple packages
func BulkSetPackageVisibility(ctx *context.Context) {
if !ctx.Repo.CanWrite(unit.TypePackages) && !ctx.IsUserSiteAdmin() {
ctx.JSONError(ctx.Tr("packages.settings.visibility.no_permission"))
return
}
isPrivate := ctx.FormString("is_private") == "true"
ids := ctx.Req.Form["ids[]"]
for _, idStr := range ids {
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
continue
}
// Verify the package belongs to this repository
pkg, err := packages.GetPackageByID(ctx, id)
if err != nil || pkg.RepoID != ctx.Repo.Repository.ID {
continue
}
if err := packages.SetPackageIsPrivate(ctx, id, isPrivate); err != nil {
ctx.ServerError("SetPackageIsPrivate", err)
return
}
}
ctx.JSONOK()
}

View File

@@ -851,6 +851,7 @@ func registerWebRoutes(m *web.Router) {
m.Post("/delete", admin.DeletePackageVersion)
m.Post("/cleanup", admin.CleanupExpiredData)
m.Post("/bulk-global", admin.BulkSetGlobal)
m.Post("/bulk-private", admin.BulkSetPrivate)
m.Post("/bulk-automatch", admin.BulkAutoMatch)
m.Post("/automatch", admin.SingleAutoMatch)
}, packagesEnabled)
@@ -1638,6 +1639,7 @@ func registerWebRoutes(m *web.Router) {
m.Group("/{username}/{reponame}", func() {
if setting.Packages.Enabled {
m.Get("/packages", repo.Packages)
m.Post("/packages/bulk-visibility", reqSignIn, repo.BulkSetPackageVisibility)
}
}, optSignIn, context.RepoAssignment)

View File

@@ -34,6 +34,9 @@
<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="make-private">{{svg "octicon-lock" 14}} {{ctx.Locale.Tr "admin.packages.bulk.make_private"}}</div>
<div class="item" data-action="make-public">{{svg "octicon-eye" 14}} {{ctx.Locale.Tr "admin.packages.bulk.make_public"}}</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>
@@ -58,6 +61,7 @@
</th>
<th>{{ctx.Locale.Tr "admin.packages.creator"}}</th>
<th>{{ctx.Locale.Tr "admin.packages.repository"}}</th>
<th>{{ctx.Locale.Tr "admin.packages.visibility"}}</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">
@@ -91,6 +95,13 @@
</button>
{{end}}
</td>
<td>
{{if .Package.IsPrivate}}
<span class="ui tiny orange label">{{svg "octicon-lock" 12}} {{ctx.Locale.Tr "admin.packages.visibility.private"}}</span>
{{else}}
<span class="ui tiny label">{{svg "octicon-eye" 12}} {{ctx.Locale.Tr "admin.packages.visibility.public"}}</span>
{{end}}
</td>
<td>
{{if .Package.IsGlobal}}
<span class="ui tiny green label">{{svg "octicon-globe" 12}} {{ctx.Locale.Tr "admin.packages.global.yes"}}</span>
@@ -108,7 +119,7 @@
</td>
</tr>
{{else}}
<tr><td class="tw-text-center" colspan="12">{{ctx.Locale.Tr "no_results_found"}}</td></tr>
<tr><td class="tw-text-center" colspan="13">{{ctx.Locale.Tr "no_results_found"}}</td></tr>
{{end}}
</tbody>
</table>
@@ -177,6 +188,14 @@ document.addEventListener('DOMContentLoaded', function() {
url = '{{AppSubUrl}}/-/admin/packages/bulk-global';
formData.append('is_global', 'false');
break;
case 'make-private':
url = '{{AppSubUrl}}/-/admin/packages/bulk-private';
formData.append('is_private', 'true');
break;
case 'make-public':
url = '{{AppSubUrl}}/-/admin/packages/bulk-private';
formData.append('is_private', 'false');
break;
case 'automatch':
url = '{{AppSubUrl}}/-/admin/packages/bulk-automatch';
break;

View File

@@ -14,37 +14,125 @@
</div>
</form>
{{end}}
<div>
{{range .PackageDescriptors}}
<div class="flex-list">
<div class="flex-item">
<div class="flex-item-main">
<div class="flex-item-title">
<a href="{{.VersionWebLink}}">{{.Package.Name}}</a>
<span class="ui label">{{svg .Package.Type.SVGName 16}} {{.Package.Type.Name}}</span>
{{if .Package.IsPrivate}}
<span class="ui basic orange label">{{ctx.Locale.Tr "repo.visibility.private"}}</span>
{{end}}
</div>
<div class="flex-item-body">
{{$timeStr := DateUtils.TimeSince .Version.CreatedUnix}}
{{$hasRepositoryAccess := false}}
{{if .Repository}}
{{$hasRepositoryAccess = index $.RepositoryAccessMap .Repository.ID}}
{{end}}
{{if $hasRepositoryAccess}}
{{ctx.Locale.Tr "packages.published_by_in" $timeStr .Creator.HomeLink .Creator.GetDisplayName .Repository.Link .Repository.FullName}}
{{else}}
{{ctx.Locale.Tr "packages.published_by" $timeStr .Creator.HomeLink .Creator.GetDisplayName}}
{{end}}
</div>
</div>
<div class="flex-item-trailing">
<span class="text grey">{{DateUtils.TimeSince .Version.CreatedUnix}}</span>
</div>
{{if and .CanWritePackages (or .PublicPackages .PrivatePackages)}}
<div class="tw-flex tw-gap-2 tw-my-3">
<div class="ui small dropdown button" id="pkg-bulk-actions">
<span class="text">{{ctx.Locale.Tr "packages.bulk.actions"}}</span>
{{svg "octicon-triangle-down" 14}}
<div class="menu">
<div class="item" data-action="make-private">{{svg "octicon-lock" 14}} {{ctx.Locale.Tr "packages.bulk.make_private"}}</div>
<div class="item" data-action="make-public">{{svg "octicon-globe" 14}} {{ctx.Locale.Tr "packages.bulk.make_public"}}</div>
</div>
</div>
{{else}}
<span class="tw-text-text-light-2 tw-self-center" id="pkg-selected-count"></span>
</div>
{{end}}
<div>
{{/* Public Packages Section */}}
{{if .PublicPackages}}
<div class="tw-mb-6">
<h4 class="ui top attached header tw-flex tw-items-center tw-gap-2">
{{svg "octicon-globe" 16}}
{{ctx.Locale.Tr "packages.visibility.public"}}
<span class="ui small label">{{len .PublicPackages}}</span>
{{if .CanWritePackages}}
<div class="tw-ml-auto">
<input type="checkbox" class="select-section-packages" data-section="public" title="{{ctx.Locale.Tr "packages.bulk.select_all"}}">
</div>
{{end}}
</h4>
<div class="ui attached segment">
{{range .PublicPackages}}
<div class="flex-list">
<div class="flex-item">
{{if $.CanWritePackages}}
<div class="flex-item-leading">
<input type="checkbox" class="package-checkbox" data-package-id="{{.Package.ID}}" data-section="public">
</div>
{{end}}
<div class="flex-item-main">
<div class="flex-item-title">
<a href="{{.VersionWebLink}}">{{.Package.Name}}</a>
<span class="ui label">{{svg .Package.Type.SVGName 16}} {{.Package.Type.Name}}</span>
</div>
<div class="flex-item-body">
{{$timeStr := DateUtils.TimeSince .Version.CreatedUnix}}
{{$hasRepositoryAccess := false}}
{{if .Repository}}
{{$hasRepositoryAccess = index $.RepositoryAccessMap .Repository.ID}}
{{end}}
{{if $hasRepositoryAccess}}
{{ctx.Locale.Tr "packages.published_by_in" $timeStr .Creator.HomeLink .Creator.GetDisplayName .Repository.Link .Repository.FullName}}
{{else}}
{{ctx.Locale.Tr "packages.published_by" $timeStr .Creator.HomeLink .Creator.GetDisplayName}}
{{end}}
</div>
</div>
<div class="flex-item-trailing">
<span class="text grey">{{DateUtils.TimeSince .Version.CreatedUnix}}</span>
</div>
</div>
</div>
{{end}}
</div>
</div>
{{end}}
{{/* Private Packages Section */}}
{{if .PrivatePackages}}
<div class="tw-mb-6">
<h4 class="ui top attached header tw-flex tw-items-center tw-gap-2">
{{svg "octicon-lock" 16}}
{{ctx.Locale.Tr "packages.visibility.private"}}
<span class="ui small orange label">{{len .PrivatePackages}}</span>
{{if .CanWritePackages}}
<div class="tw-ml-auto">
<input type="checkbox" class="select-section-packages" data-section="private" title="{{ctx.Locale.Tr "packages.bulk.select_all"}}">
</div>
{{end}}
</h4>
<div class="ui attached segment">
{{range .PrivatePackages}}
<div class="flex-list">
<div class="flex-item">
{{if $.CanWritePackages}}
<div class="flex-item-leading">
<input type="checkbox" class="package-checkbox" data-package-id="{{.Package.ID}}" data-section="private">
</div>
{{end}}
<div class="flex-item-main">
<div class="flex-item-title">
<a href="{{.VersionWebLink}}">{{.Package.Name}}</a>
<span class="ui label">{{svg .Package.Type.SVGName 16}} {{.Package.Type.Name}}</span>
<span class="ui basic orange label">{{ctx.Locale.Tr "repo.visibility.private"}}</span>
</div>
<div class="flex-item-body">
{{$timeStr := DateUtils.TimeSince .Version.CreatedUnix}}
{{$hasRepositoryAccess := false}}
{{if .Repository}}
{{$hasRepositoryAccess = index $.RepositoryAccessMap .Repository.ID}}
{{end}}
{{if $hasRepositoryAccess}}
{{ctx.Locale.Tr "packages.published_by_in" $timeStr .Creator.HomeLink .Creator.GetDisplayName .Repository.Link .Repository.FullName}}
{{else}}
{{ctx.Locale.Tr "packages.published_by" $timeStr .Creator.HomeLink .Creator.GetDisplayName}}
{{end}}
</div>
</div>
<div class="flex-item-trailing">
<span class="text grey">{{DateUtils.TimeSince .Version.CreatedUnix}}</span>
</div>
</div>
</div>
{{end}}
</div>
</div>
{{end}}
{{/* Empty state - no packages at all */}}
{{if and (not .PublicPackages) (not .PrivatePackages)}}
{{if not .HasPackages}}
<div class="empty-placeholder">
{{svg "octicon-package" 48}}
@@ -61,3 +149,76 @@
{{end}}
{{template "base/paginate" .}}
</div>
{{if .CanWritePackages}}
<script>
document.addEventListener('DOMContentLoaded', function() {
const sectionCheckboxes = document.querySelectorAll('.select-section-packages');
const packageCheckboxes = document.querySelectorAll('.package-checkbox');
const selectedCountEl = document.getElementById('pkg-selected-count');
const bulkDropdown = document.getElementById('pkg-bulk-actions');
function updateSelectedCount() {
const selected = document.querySelectorAll('.package-checkbox:checked').length;
if (selected > 0) {
selectedCountEl.textContent = '{{ctx.Locale.Tr "packages.bulk.selected"}} ' + selected;
} else {
selectedCountEl.textContent = '';
}
}
// Section checkbox toggles all packages in that section
sectionCheckboxes.forEach(cb => {
cb.addEventListener('change', function() {
const section = this.dataset.section;
document.querySelectorAll(`.package-checkbox[data-section="${section}"]`).forEach(pkg => {
pkg.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 "packages.bulk.no_selection"}}');
return;
}
const formData = new FormData();
ids.forEach(id => formData.append('ids[]', id));
if (action === 'make-private') {
formData.append('is_private', 'true');
} else if (action === 'make-public') {
formData.append('is_private', 'false');
}
fetch(window.location.pathname + '/bulk-visibility', {
method: 'POST',
body: formData,
headers: {
'X-Csrf-Token': document.querySelector('meta[name=_csrf]')?.content || ''
}
}).then(response => response.json())
.then(data => {
if (data.ok) {
window.location.reload();
}
});
});
});
});
</script>
{{end}}