From f143a8510369cc8bf798b302d701d043f0db8313 Mon Sep 17 00:00:00 2001 From: Andrea Debernardi Date: Sat, 8 Aug 2026 07:56:20 +0200 Subject: [PATCH 1/3] feat(admin): bulk ownership transfer for plugins Add a 'transfer' action to POST /api/admin/plugins/bulk that reassigns ownership of up to 100 plugins to a given user, validating the target user exists and recording previous owners in the audit log entry. Expose it in the admin plugins page as a Transfer button in the bulk selection bar, prompting for the new owner's user ID (same pattern as the bulk reject reason prompt). Localized in all six languages. --- apps/api/src/routes/api/admin/plugins/bulk.ts | 33 +++++++- .../tests/routes/admin-plugins-bulk.test.ts | 81 +++++++++++++++++++ apps/frontend/messages/de.json | 2 + apps/frontend/messages/en.json | 2 + apps/frontend/messages/es.json | 2 + apps/frontend/messages/fr.json | 2 + apps/frontend/messages/it.json | 2 + apps/frontend/messages/zh-CN.json | 2 + .../src/routes/admin/plugins/+page.svelte | 27 ++++++- 9 files changed, 145 insertions(+), 8 deletions(-) create mode 100644 apps/api/tests/routes/admin-plugins-bulk.test.ts 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..561438d 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", diff --git a/apps/frontend/messages/en.json b/apps/frontend/messages/en.json index e010bdc..9022fec 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", diff --git a/apps/frontend/messages/es.json b/apps/frontend/messages/es.json index 3170427..0fe574c 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", diff --git a/apps/frontend/messages/fr.json b/apps/frontend/messages/fr.json index 3c0b3cf..8f9d6f7 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é", diff --git a/apps/frontend/messages/it.json b/apps/frontend/messages/it.json index 0695fc4..d0d407e 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", diff --git a/apps/frontend/messages/zh-CN.json b/apps/frontend/messages/zh-CN.json index 291f006..cc8ea32 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} 已取消认证", diff --git a/apps/frontend/src/routes/admin/plugins/+page.svelte b/apps/frontend/src/routes/admin/plugins/+page.svelte index 2d789c8..ca50955 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' @@ -305,6 +320,10 @@ {m.admin_plugins_reject()} + + {/if} + {#if auth.user} + + + {:else} + + {/if} + + + + + + {#if mobileOpen} + + {/if}