diff --git a/apps/api/src/routes/api/admin/plugins/bulk.ts b/apps/api/src/routes/api/admin/plugins/bulk.ts index 564cfe1..cf0e26e 100644 --- a/apps/api/src/routes/api/admin/plugins/bulk.ts +++ b/apps/api/src/routes/api/admin/plugins/bulk.ts @@ -7,7 +7,7 @@ import { cache } from '$lib/cache' import { latestCacheKey } from '$routes/api/plugins/[slug]/latest' import { recordAudit, actorFromAdmin } from '$lib/audit' -const actionEnum = t.Union([t.Literal('approve'), t.Literal('reject'), t.Literal('delete')]) +const actionEnum = t.Union([t.Literal('approve'), t.Literal('reject'), t.Literal('delete'), t.Literal('transfer')]) export default new Elysia().use(adminMiddleware).post( '/', @@ -21,6 +21,11 @@ export default new Elysia().use(adminMiddleware).post( return { error: 'bulk action limited to 100 ids per request' } } + if (body.action === 'transfer' && !body.ownerId) { + set.status = 400 + return { error: 'ownerId is required for the transfer action' } + } + const targets = await db.query.plugins.findMany({ where: { id: { in: body.ids } } }) const found = new Set(targets.map((p) => p.id)) const missing = body.ids.filter((id) => !found.has(id)) @@ -30,6 +35,17 @@ export default new Elysia().use(adminMiddleware).post( await db.delete(releases).where(inArray(releases.pluginId, body.ids)) await db.delete(plugins).where(inArray(plugins.id, body.ids)) affected = targets.length + } else if (body.action === 'transfer') { + const newOwner = await db.query.users.findFirst({ where: { id: body.ownerId } }) + if (!newOwner) { + set.status = 400 + return { error: 'New owner user not found' } + } + await db + .update(plugins) + .set({ ownerId: body.ownerId, updatedAt: Date.now() }) + .where(inArray(plugins.id, body.ids)) + affected = targets.length } else { const status = body.action === 'approve' ? 'approved' : 'rejected' const patch: Partial = { @@ -47,7 +63,14 @@ export default new Elysia().use(adminMiddleware).post( ...actorFromAdmin(admin, request), action: `plugin.bulk.${body.action}`, target: `plugins:${body.ids.length}`, - meta: { ids: body.ids, missing, affected, rejectionReason: body.rejectionReason ?? null }, + meta: { + ids: body.ids, + missing, + affected, + rejectionReason: body.rejectionReason ?? null, + ownerId: body.ownerId ?? null, + fromOwners: body.action === 'transfer' ? Object.fromEntries(targets.map((p) => [p.id, p.ownerId])) : null, + }, }) return { ok: true, action: body.action, affected, missing } @@ -55,9 +78,10 @@ export default new Elysia().use(adminMiddleware).post( { detail: { tags: ['Admin'], - summary: 'Bulk-approve / reject / delete plugins', + summary: 'Bulk-approve / reject / delete / transfer plugins', description: - 'Apply the same action to up to 100 plugins in one round-trip. `delete` cascades to releases. `reject` accepts an optional reason. Single audit-log entry per call.', + 'Apply the same action to up to 100 plugins in one round-trip. `delete` cascades to releases. `reject` accepts an optional reason. ' + + '`transfer` reassigns ownership to the user given in `ownerId`, bypassing the consent flow. Single audit-log entry per call.', operationId: 'bulkPluginAction', security: [{ bearerAuth: [] }, { cookieAuth: [] }], }, @@ -65,6 +89,7 @@ export default new Elysia().use(adminMiddleware).post( ids: t.Array(t.String(), { maxItems: 100 }), action: actionEnum, rejectionReason: t.Optional(t.String({ maxLength: 500 })), + ownerId: t.Optional(t.String()), }), response: { 200: t.Object({ diff --git a/apps/api/tests/routes/admin-plugins-bulk.test.ts b/apps/api/tests/routes/admin-plugins-bulk.test.ts new file mode 100644 index 0000000..7ca1326 --- /dev/null +++ b/apps/api/tests/routes/admin-plugins-bulk.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, beforeEach } from 'bun:test' +import { clearDb, buildApp, makeUser, makePlugin } from '../helpers' +import { signJwt } from '../../src/lib/jwt' +import { db } from '../../src/db' + +async function adminToken() { + const u = await makeUser({ role: 'admin', username: 'admin' }) + return { + user: u, + token: await signJwt({ sub: u.id, identityId: u.identityId, username: u.username, providerInstanceId: 'github' }), + } +} + +async function bulk(token: string, body: Record) { + const app = await buildApp() + return app.handle( + new Request('http://localhost/api/admin/plugins/bulk/', { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + ) +} + +describe('POST /api/admin/plugins/bulk (transfer)', () => { + beforeEach(clearDb) + + it('reassigns ownership of all selected plugins', async () => { + const { user, token } = await adminToken() + const newOwner = await makeUser({ username: 'newowner' }) + await makePlugin(user.id, { id: 'alpha' }) + await makePlugin(user.id, { id: 'beta' }) + + const res = await bulk(token, { ids: ['alpha', 'beta'], action: 'transfer', ownerId: newOwner.id }) + expect(res.status).toBe(200) + const body = (await res.json()) as { ok: boolean; affected: number; missing: string[] } + expect(body.ok).toBe(true) + expect(body.affected).toBe(2) + expect(body.missing).toEqual([]) + + const rows = await db.query.plugins.findMany({ where: { id: { in: ['alpha', 'beta'] } } }) + expect(rows.map((p) => p.ownerId)).toEqual([newOwner.id, newOwner.id]) + }) + + it('rejects a transfer without ownerId', async () => { + const { user, token } = await adminToken() + await makePlugin(user.id, { id: 'alpha' }) + + const res = await bulk(token, { ids: ['alpha'], action: 'transfer' }) + expect(res.status).toBe(400) + const body = (await res.json()) as { error: string } + expect(body.error).toContain('ownerId') + + const row = await db.query.plugins.findFirst({ where: { id: 'alpha' } }) + expect(row?.ownerId).toBe(user.id) + }) + + it('rejects a transfer to an unknown user', async () => { + const { user, token } = await adminToken() + await makePlugin(user.id, { id: 'alpha' }) + + const res = await bulk(token, { ids: ['alpha'], action: 'transfer', ownerId: 'no-such-user' }) + expect(res.status).toBe(400) + const body = (await res.json()) as { error: string } + expect(body.error).toBe('New owner user not found') + + const row = await db.query.plugins.findFirst({ where: { id: 'alpha' } }) + expect(row?.ownerId).toBe(user.id) + }) + + it('reports plugins that do not exist as missing', async () => { + const { user, token } = await adminToken() + const newOwner = await makeUser({ username: 'newowner' }) + await makePlugin(user.id, { id: 'alpha' }) + + const res = await bulk(token, { ids: ['alpha', 'ghost'], action: 'transfer', ownerId: newOwner.id }) + expect(res.status).toBe(200) + const body = (await res.json()) as { affected: number; missing: string[] } + expect(body.missing).toEqual(['ghost']) + }) +}) diff --git a/apps/frontend/messages/de.json b/apps/frontend/messages/de.json index e77ae74..cf76de2 100644 --- a/apps/frontend/messages/de.json +++ b/apps/frontend/messages/de.json @@ -430,6 +430,7 @@ "admin_plugins_bulk_reject_prompt": "{count} Plugin(s) ablehnen — optionaler Grund:", "admin_plugins_bulk_result": "{action}: {affected} betroffen", "admin_plugins_bulk_result_missing": ", {count} fehlend", + "admin_plugins_bulk_transfer_prompt": "{count} Plugin(s) übertragen — Benutzer-ID des neuen Inhabers:", "admin_plugins_card_subtitle_middle": "vom Webhook-Ingest, bis sie freigegeben sind. Freigabemodus umschalten in", "admin_plugins_card_subtitle_prefix": "Ausstehende Plugins liefern", "admin_plugins_clear": "Leeren", @@ -470,6 +471,7 @@ "admin_plugins_tab_pending": "Ausstehend", "admin_plugins_tab_rejected": "Abgelehnt", "admin_plugins_title": "Plugins", + "admin_plugins_transfer": "Übertragen", "admin_plugins_unpin": "Lösen", "admin_plugins_unpinned": "Gelöst", "admin_plugins_unverified": "{name} entwertet", @@ -777,6 +779,7 @@ "nav_sign_in": "Anmelden", "nav_sign_out": "Abmelden", "nav_submit": "Einreichen", + "nav_toggle_menu": "Menü umschalten", "plugin_detail_back_to_catalog": "Zurück zum Katalog", "plugin_detail_by": "Von", "plugin_detail_clipboard_unavailable": "Zwischenablage nicht verfügbar", diff --git a/apps/frontend/messages/en.json b/apps/frontend/messages/en.json index e010bdc..a40f22f 100644 --- a/apps/frontend/messages/en.json +++ b/apps/frontend/messages/en.json @@ -430,6 +430,7 @@ "admin_plugins_bulk_reject_prompt": "Reject {count} plugin(s) — optional reason:", "admin_plugins_bulk_result": "{action}: {affected} affected", "admin_plugins_bulk_result_missing": ", {count} missing", + "admin_plugins_bulk_transfer_prompt": "Transfer {count} plugin(s) — new owner user ID:", "admin_plugins_card_subtitle_middle": "from the webhook ingest until approved. Toggle approval mode in", "admin_plugins_card_subtitle_prefix": "Pending plugins return", "admin_plugins_clear": "Clear", @@ -470,6 +471,7 @@ "admin_plugins_tab_pending": "Pending", "admin_plugins_tab_rejected": "Rejected", "admin_plugins_title": "Plugins", + "admin_plugins_transfer": "Transfer", "admin_plugins_unpin": "Unpin", "admin_plugins_unpinned": "Unpinned", "admin_plugins_unverified": "{name} unverified", @@ -777,6 +779,7 @@ "nav_sign_in": "Sign in", "nav_sign_out": "Sign out", "nav_submit": "Submit", + "nav_toggle_menu": "Toggle menu", "plugin_detail_back_to_catalog": "Back to catalog", "plugin_detail_by": "By", "plugin_detail_clipboard_unavailable": "Clipboard unavailable", diff --git a/apps/frontend/messages/es.json b/apps/frontend/messages/es.json index 3170427..d3cb94b 100644 --- a/apps/frontend/messages/es.json +++ b/apps/frontend/messages/es.json @@ -430,6 +430,7 @@ "admin_plugins_bulk_reject_prompt": "Rechazar {count} plugin(s) — motivo opcional:", "admin_plugins_bulk_result": "{action}: {affected} afectados", "admin_plugins_bulk_result_missing": ", {count} faltantes", + "admin_plugins_bulk_transfer_prompt": "Transferir {count} plugin(s) — ID de usuario del nuevo propietario:", "admin_plugins_card_subtitle_middle": "desde la ingesta del webhook hasta que se aprueban. Alterna el modo de aprobación en", "admin_plugins_card_subtitle_prefix": "Los plugins pendientes devuelven", "admin_plugins_clear": "Limpiar", @@ -470,6 +471,7 @@ "admin_plugins_tab_pending": "Pendientes", "admin_plugins_tab_rejected": "Rechazados", "admin_plugins_title": "Plugins", + "admin_plugins_transfer": "Transferir", "admin_plugins_unpin": "Desfijar", "admin_plugins_unpinned": "Desfijado", "admin_plugins_unverified": "{name} sin verificar", @@ -777,6 +779,7 @@ "nav_sign_in": "Iniciar sesión", "nav_sign_out": "Cerrar sesión", "nav_submit": "Enviar", + "nav_toggle_menu": "Abrir/cerrar menú", "plugin_detail_back_to_catalog": "Volver al catálogo", "plugin_detail_by": "Por", "plugin_detail_clipboard_unavailable": "Portapapeles no disponible", diff --git a/apps/frontend/messages/fr.json b/apps/frontend/messages/fr.json index 3c0b3cf..a3600bf 100644 --- a/apps/frontend/messages/fr.json +++ b/apps/frontend/messages/fr.json @@ -430,6 +430,7 @@ "admin_plugins_bulk_reject_prompt": "Rejeter {count} plugin(s) — motif optionnel :", "admin_plugins_bulk_result": "{action} : {affected} affectés", "admin_plugins_bulk_result_missing": ", {count} manquants", + "admin_plugins_bulk_transfer_prompt": "Transférer {count} plugin(s) — ID utilisateur du nouveau propriétaire :", "admin_plugins_card_subtitle_middle": "depuis l'ingestion webhook jusqu'à approbation. Bascule le mode d'approbation dans", "admin_plugins_card_subtitle_prefix": "Les plugins en attente renvoient", "admin_plugins_clear": "Effacer", @@ -470,6 +471,7 @@ "admin_plugins_tab_pending": "En attente", "admin_plugins_tab_rejected": "Rejetés", "admin_plugins_title": "Plugins", + "admin_plugins_transfer": "Transférer", "admin_plugins_unpin": "Détacher", "admin_plugins_unpinned": "Détaché", "admin_plugins_unverified": "{name} non vérifié", @@ -777,6 +779,7 @@ "nav_sign_in": "Se connecter", "nav_sign_out": "Se déconnecter", "nav_submit": "Soumettre", + "nav_toggle_menu": "Ouvrir/fermer le menu", "plugin_detail_back_to_catalog": "Retour au catalogue", "plugin_detail_by": "Par", "plugin_detail_clipboard_unavailable": "Presse-papiers indisponible", diff --git a/apps/frontend/messages/it.json b/apps/frontend/messages/it.json index 0695fc4..affd9ab 100644 --- a/apps/frontend/messages/it.json +++ b/apps/frontend/messages/it.json @@ -430,6 +430,7 @@ "admin_plugins_bulk_reject_prompt": "Rifiutare {count} plugin — motivo opzionale:", "admin_plugins_bulk_result": "{action}: {affected} interessati", "admin_plugins_bulk_result_missing": ", {count} mancanti", + "admin_plugins_bulk_transfer_prompt": "Trasferisci {count} plugin — ID utente del nuovo proprietario:", "admin_plugins_card_subtitle_middle": "dall'ingest del webhook finché non sono approvati. Cambia la modalità di approvazione in", "admin_plugins_card_subtitle_prefix": "I plugin in attesa restituiscono", "admin_plugins_clear": "Pulisci", @@ -470,6 +471,7 @@ "admin_plugins_tab_pending": "In attesa", "admin_plugins_tab_rejected": "Rifiutati", "admin_plugins_title": "Plugin", + "admin_plugins_transfer": "Trasferisci", "admin_plugins_unpin": "Rimuovi pin", "admin_plugins_unpinned": "Pin rimosso", "admin_plugins_unverified": "{name} non più verificato", @@ -777,6 +779,7 @@ "nav_sign_in": "Accedi", "nav_sign_out": "Esci", "nav_submit": "Invia", + "nav_toggle_menu": "Apri/chiudi menu", "plugin_detail_back_to_catalog": "Torna al catalogo", "plugin_detail_by": "Di", "plugin_detail_clipboard_unavailable": "Appunti non disponibili", diff --git a/apps/frontend/messages/zh-CN.json b/apps/frontend/messages/zh-CN.json index 291f006..3d0fb12 100644 --- a/apps/frontend/messages/zh-CN.json +++ b/apps/frontend/messages/zh-CN.json @@ -430,6 +430,7 @@ "admin_plugins_bulk_reject_prompt": "拒绝 {count} 个插件 — 可选原因:", "admin_plugins_bulk_result": "{action}:{affected} 个已生效", "admin_plugins_bulk_result_missing": ",{count} 个缺失", + "admin_plugins_bulk_transfer_prompt": "转移 {count} 个插件 — 新所有者的用户 ID:", "admin_plugins_card_subtitle_middle": ",直到被批准。审批模式可在以下位置切换:", "admin_plugins_card_subtitle_prefix": "待审核插件在 webhook 摄取时返回", "admin_plugins_clear": "清空", @@ -470,6 +471,7 @@ "admin_plugins_tab_pending": "待审核", "admin_plugins_tab_rejected": "已拒绝", "admin_plugins_title": "插件", + "admin_plugins_transfer": "转移", "admin_plugins_unpin": "取消置顶", "admin_plugins_unpinned": "已取消置顶", "admin_plugins_unverified": "{name} 已取消认证", @@ -777,6 +779,7 @@ "nav_sign_in": "登录", "nav_sign_out": "登出", "nav_submit": "提交", + "nav_toggle_menu": "切换菜单", "plugin_detail_back_to_catalog": "返回目录", "plugin_detail_by": "作者", "plugin_detail_clipboard_unavailable": "剪贴板不可用", diff --git a/apps/frontend/src/lib/components/Header.svelte b/apps/frontend/src/lib/components/Header.svelte index abe97c6..fc73016 100644 --- a/apps/frontend/src/lib/components/Header.svelte +++ b/apps/frontend/src/lib/components/Header.svelte @@ -6,6 +6,8 @@ import LogIn from '@lucide/svelte/icons/log-in' import LogOut from '@lucide/svelte/icons/log-out' import ShieldCheck from '@lucide/svelte/icons/shield-check' + import Menu from '@lucide/svelte/icons/menu' + import X from '@lucide/svelte/icons/x' import Button from '$components/ui/Button.svelte' import ThemeToggle from '$components/ThemeToggle.svelte' import LanguageSwitcher from '$components/LanguageSwitcher.svelte' @@ -13,8 +15,11 @@ import { branding } from '$lib/stores/branding.svelte' import { features } from '$lib/stores/features.svelte' import { instanceInfo } from '$lib/stores/instance-info.svelte' + import { cn } from '$lib/utils' import { m } from '$lib/paraglide/messages' + let mobileOpen = $state(false) + const navLinks = $derived( [ { href: '/plugins', label: m.nav_plugins(), show: true }, @@ -37,11 +42,23 @@ toast.error(e instanceof Error ? e.message : 'Failed to sign out') } } + + $effect(() => { + // Close the mobile menu on route change. + void page.url.pathname + mobileOpen = false + }) + + const mobileLinkClass = (active: boolean) => + cn( + 'block rounded-md px-3 py-2 text-sm transition-colors', + active ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground', + )
-
- +
+ {#if branding.logoUrl} {branding.name} {:else} @@ -49,10 +66,10 @@ {/if} - {branding.name} + {branding.name} -
+ +
+ + + {#if mobileOpen} + + {/if}
diff --git a/apps/frontend/src/routes/admin/plugins/+page.svelte b/apps/frontend/src/routes/admin/plugins/+page.svelte index 2d789c8..78289fc 100644 --- a/apps/frontend/src/routes/admin/plugins/+page.svelte +++ b/apps/frontend/src/routes/admin/plugins/+page.svelte @@ -4,6 +4,7 @@ import Check from '@lucide/svelte/icons/check' import X from '@lucide/svelte/icons/x' import Trash2 from '@lucide/svelte/icons/trash-2' + import ArrowRightLeft from '@lucide/svelte/icons/arrow-right-left' import RefreshCw from '@lucide/svelte/icons/refresh-cw' import Webhook from '@lucide/svelte/icons/webhook' import Star from '@lucide/svelte/icons/star' @@ -102,9 +103,10 @@ selected = new Set(plugins.map((p) => p.id)) } - async function bulk(action: 'approve' | 'reject' | 'delete') { + async function bulk(action: 'approve' | 'reject' | 'delete' | 'transfer') { if (selected.size === 0) return let rejectionReason: string | undefined + let ownerId: string | undefined if (action === 'reject') { const r = prompt(m.admin_plugins_bulk_reject_prompt({ count: selected.size })) if (r === null) return @@ -112,14 +114,27 @@ } else if (action === 'delete') { bulkDeleteOpen = true return + } else if (action === 'transfer') { + const o = prompt(m.admin_plugins_bulk_transfer_prompt({ count: selected.size })) + if (!o?.trim()) return + ownerId = o.trim() } - await runBulk(action, rejectionReason) + await runBulk(action, rejectionReason, ownerId) } - async function runBulk(action: 'approve' | 'reject' | 'delete', rejectionReason?: string) { + async function runBulk( + action: 'approve' | 'reject' | 'delete' | 'transfer', + rejectionReason?: string, + ownerId?: string, + ) { bulkBusy = true try { - const { data, error } = await eden.api.admin.plugins.bulk.post({ ids: [...selected], action, rejectionReason }) + const { data, error } = await eden.api.admin.plugins.bulk.post({ + ids: [...selected], + action, + rejectionReason, + ownerId, + }) if (error) throw new Error( typeof error.value === 'string' @@ -291,12 +306,14 @@ {#if selected.size > 0} -
- +
+ {selected.size} {m.admin_plugins_selected()} -
+
+