Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 29 additions & 4 deletions apps/api/src/routes/api/admin/plugins/bulk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
'/',
Expand All @@ -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))
Expand All @@ -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<typeof plugins.$inferInsert> = {
Expand All @@ -47,24 +63,33 @@ 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 }
},
{
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: [] }],
},
body: t.Object({
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({
Expand Down
81 changes: 81 additions & 0 deletions apps/api/tests/routes/admin-plugins-bulk.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
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'])
})
})
3 changes: 3 additions & 0 deletions apps/frontend/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions apps/frontend/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions apps/frontend/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions apps/frontend/messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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é",
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions apps/frontend/messages/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions apps/frontend/messages/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "清空",
Expand Down Expand Up @@ -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} 已取消认证",
Expand Down Expand Up @@ -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": "剪贴板不可用",
Expand Down
Loading
Loading