diff --git a/apps/api/src/routes/api/admin/plugins/[id].ts b/apps/api/src/routes/api/admin/plugins/[id].ts index dd343a7..bac1b30 100644 --- a/apps/api/src/routes/api/admin/plugins/[id].ts +++ b/apps/api/src/routes/api/admin/plugins/[id].ts @@ -54,6 +54,7 @@ export default new Elysia() return { error: 'New owner user not found' } } patch.ownerId = body.ownerId + if (ownerChange) patch.author = `${target.displayName} <${existing.repoUrl}>` } await db.update(plugins).set(patch).where(eq(plugins.id, params.id)) await cache().del(latestCacheKey(params.id)) diff --git a/apps/api/src/routes/api/admin/plugins/bulk.ts b/apps/api/src/routes/api/admin/plugins/bulk.ts index cf0e26e..b7a0e79 100644 --- a/apps/api/src/routes/api/admin/plugins/bulk.ts +++ b/apps/api/src/routes/api/admin/plugins/bulk.ts @@ -1,5 +1,5 @@ import { Elysia, t } from 'elysia' -import { inArray } from 'drizzle-orm' +import { eq, inArray } from 'drizzle-orm' import { adminMiddleware } from '$middleware/admin' import { db } from '$db' import { plugins, releases } from '$db/schema' @@ -41,10 +41,15 @@ export default new Elysia().use(adminMiddleware).post( 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)) + const now = Date.now() + // author is a per-plugin string (it embeds the repo URL), so the + // transfer can't be a single bulk UPDATE. + for (const p of targets) { + await db + .update(plugins) + .set({ ownerId: body.ownerId, author: `${newOwner.displayName} <${p.repoUrl}>`, updatedAt: now }) + .where(eq(plugins.id, p.id)) + } affected = targets.length } else { const status = body.action === 'approve' ? 'approved' : 'rejected' diff --git a/apps/api/src/routes/auth/me/transfers/[id].ts b/apps/api/src/routes/auth/me/transfers/[id].ts index e62fbb0..fca12bd 100644 --- a/apps/api/src/routes/auth/me/transfers/[id].ts +++ b/apps/api/src/routes/auth/me/transfers/[id].ts @@ -51,9 +51,17 @@ export default new Elysia().use(authMiddleware).post( } if (status === 'accepted') { + const [newOwner, plugin] = await Promise.all([ + db.query.users.findFirst({ where: { id: transfer.toUserId } }), + db.query.plugins.findFirst({ where: { id: transfer.pluginId } }), + ]) await db .update(plugins) - .set({ ownerId: transfer.toUserId, updatedAt: now }) + .set({ + ownerId: transfer.toUserId, + ...(newOwner && plugin ? { author: `${newOwner.displayName} <${plugin.repoUrl}>` } : {}), + updatedAt: now, + }) .where(eq(plugins.id, transfer.pluginId)) } diff --git a/apps/api/tests/routes/admin-plugins-bulk.test.ts b/apps/api/tests/routes/admin-plugins-bulk.test.ts index 7ca1326..edf0932 100644 --- a/apps/api/tests/routes/admin-plugins-bulk.test.ts +++ b/apps/api/tests/routes/admin-plugins-bulk.test.ts @@ -40,6 +40,9 @@ describe('POST /api/admin/plugins/bulk (transfer)', () => { const rows = await db.query.plugins.findMany({ where: { id: { in: ['alpha', 'beta'] } } }) expect(rows.map((p) => p.ownerId)).toEqual([newOwner.id, newOwner.id]) + for (const row of rows) { + expect(row.author).toBe(`newowner <${row.repoUrl}>`) + } }) it('rejects a transfer without ownerId', async () => { diff --git a/apps/frontend/messages/de.json b/apps/frontend/messages/de.json index cf76de2..b92f72c 100644 --- a/apps/frontend/messages/de.json +++ b/apps/frontend/messages/de.json @@ -430,7 +430,10 @@ "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_bulk_transfer_description": "Die ausgewählten Plugins werden diesem Benutzer zugewiesen, der ihr Publisher wird.", + "admin_plugins_bulk_transfer_owner_label": "Neuer Inhaber", + "admin_plugins_bulk_transfer_placeholder": "Benutzer auswählen…", + "admin_plugins_bulk_transfer_title": "{count} Plugin(s) übertragen", "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", diff --git a/apps/frontend/messages/en.json b/apps/frontend/messages/en.json index a40f22f..fb21343 100644 --- a/apps/frontend/messages/en.json +++ b/apps/frontend/messages/en.json @@ -430,7 +430,10 @@ "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_bulk_transfer_description": "The selected plugins will be reassigned to this user, who becomes their publisher.", + "admin_plugins_bulk_transfer_owner_label": "New owner", + "admin_plugins_bulk_transfer_placeholder": "Select a user…", + "admin_plugins_bulk_transfer_title": "Transfer {count} plugin(s)", "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", diff --git a/apps/frontend/messages/es.json b/apps/frontend/messages/es.json index d3cb94b..4725a69 100644 --- a/apps/frontend/messages/es.json +++ b/apps/frontend/messages/es.json @@ -430,7 +430,10 @@ "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_bulk_transfer_description": "Los plugins seleccionados se reasignarán a este usuario, que pasará a ser su publicador.", + "admin_plugins_bulk_transfer_owner_label": "Nuevo propietario", + "admin_plugins_bulk_transfer_placeholder": "Selecciona un usuario…", + "admin_plugins_bulk_transfer_title": "Transferir {count} plugin(s)", "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", diff --git a/apps/frontend/messages/fr.json b/apps/frontend/messages/fr.json index a3600bf..7e90506 100644 --- a/apps/frontend/messages/fr.json +++ b/apps/frontend/messages/fr.json @@ -430,7 +430,10 @@ "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_bulk_transfer_description": "Les plugins sélectionnés seront réattribués à cet utilisateur, qui en devient l’éditeur.", + "admin_plugins_bulk_transfer_owner_label": "Nouveau propriétaire", + "admin_plugins_bulk_transfer_placeholder": "Sélectionner un utilisateur…", + "admin_plugins_bulk_transfer_title": "Transférer {count} plugin(s)", "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", diff --git a/apps/frontend/messages/it.json b/apps/frontend/messages/it.json index affd9ab..67b083f 100644 --- a/apps/frontend/messages/it.json +++ b/apps/frontend/messages/it.json @@ -430,7 +430,10 @@ "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_bulk_transfer_description": "I plugin selezionati verranno riassegnati a questo utente, che ne diventa il publisher.", + "admin_plugins_bulk_transfer_owner_label": "Nuovo proprietario", + "admin_plugins_bulk_transfer_placeholder": "Seleziona un utente…", + "admin_plugins_bulk_transfer_title": "Trasferisci {count} plugin", "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", diff --git a/apps/frontend/messages/zh-CN.json b/apps/frontend/messages/zh-CN.json index 3d0fb12..2465cc7 100644 --- a/apps/frontend/messages/zh-CN.json +++ b/apps/frontend/messages/zh-CN.json @@ -430,7 +430,10 @@ "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_bulk_transfer_description": "所选插件将被重新分配给该用户,其将成为插件的发布者。", + "admin_plugins_bulk_transfer_owner_label": "新所有者", + "admin_plugins_bulk_transfer_placeholder": "选择用户…", + "admin_plugins_bulk_transfer_title": "转移 {count} 个插件", "admin_plugins_card_subtitle_middle": ",直到被批准。审批模式可在以下位置切换:", "admin_plugins_card_subtitle_prefix": "待审核插件在 webhook 摄取时返回", "admin_plugins_clear": "清空", diff --git a/apps/frontend/src/lib/components/ui/Modal.svelte b/apps/frontend/src/lib/components/ui/Modal.svelte new file mode 100644 index 0000000..32a75c4 --- /dev/null +++ b/apps/frontend/src/lib/components/ui/Modal.svelte @@ -0,0 +1,50 @@ + + + + + + +
+
+ {title} + {#if description} + {description} + {/if} +
+ + + +
+ +
+ {@render children()} +
+ + {#if footer} +
+ {@render footer()} +
+ {/if} +
+
+
diff --git a/apps/frontend/src/routes/admin/plugins/+page.svelte b/apps/frontend/src/routes/admin/plugins/+page.svelte index 78289fc..f304d53 100644 --- a/apps/frontend/src/routes/admin/plugins/+page.svelte +++ b/apps/frontend/src/routes/admin/plugins/+page.svelte @@ -22,6 +22,9 @@ import Button from '$components/ui/Button.svelte' import Input from '$components/ui/Input.svelte' import ConfirmDialog from '$components/ui/ConfirmDialog.svelte' + import Modal from '$components/ui/Modal.svelte' + import Select from '$components/ui/Select.svelte' + import Label from '$components/ui/Label.svelte' import { eden } from '$lib/eden' import { m } from '$lib/paraglide/messages' import AdminPageHeader from '$components/admin/AdminPageHeader.svelte' @@ -44,6 +47,13 @@ updatedAt: number } + type AdminUser = { + id: string + displayName: string + email: string | null + role: 'user' | 'admin' + } + let allPlugins = $state([]) let loading = $state(true) let filter = $state<'all' | 'approved' | 'pending' | 'rejected'>('all') @@ -53,6 +63,10 @@ let bulkBusy = $state(false) let deleteTarget = $state(null) let bulkDeleteOpen = $state(false) + let transferOpen = $state(false) + let transferOwnerId = $state('') + let users = $state([]) + let usersLoaded = $state(false) const counts = $derived.by(() => { const c = { all: allPlugins.length, approved: 0, pending: 0, rejected: 0 } @@ -106,7 +120,6 @@ 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 @@ -115,18 +128,42 @@ 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() + transferOwnerId = '' + transferOpen = true + void loadUsers() + return } - await runBulk(action, rejectionReason, ownerId) + await runBulk(action, rejectionReason) + } + + async function loadUsers() { + if (usersLoaded) return + try { + const { data, error } = await eden.api.admin.users.get({ query: { limit: '200' } }) + if (error) + throw new Error( + typeof error.value === 'string' + ? error.value + : ((error.value as { error?: string })?.error ?? `Request failed (${error.status})`), + ) + users = (data as { users: AdminUser[] }).users.sort((a, b) => a.displayName.localeCompare(b.displayName)) + usersLoaded = true + } catch (e) { + toast.error(e instanceof Error ? e.message : m.admin_users_load_failed()) + } + } + + async function confirmTransfer() { + if (!transferOwnerId) return + const ok = await runBulk('transfer', undefined, transferOwnerId) + if (ok) transferOpen = false } async function runBulk( action: 'approve' | 'reject' | 'delete' | 'transfer', rejectionReason?: string, ownerId?: string, - ) { + ): Promise { bulkBusy = true try { const { data, error } = await eden.api.admin.plugins.bulk.post({ @@ -147,8 +184,10 @@ toast.success(base + extra) selected = new Set() await load() + return true } catch (e) { toast.error(e instanceof Error ? e.message : m.admin_plugins_bulk_failed()) + return false } finally { bulkBusy = false } @@ -505,6 +544,33 @@ /> {/if} + +
+ + +
+ {#snippet footer()} + + + {/snippet} +
+