Skip to content
Open
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
1 change: 1 addition & 0 deletions apps/api/src/routes/api/admin/plugins/[id].ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
15 changes: 10 additions & 5 deletions apps/api/src/routes/api/admin/plugins/bulk.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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'
Expand Down
10 changes: 9 additions & 1 deletion apps/api/src/routes/auth/me/transfers/[id].ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}

Expand Down
3 changes: 3 additions & 0 deletions apps/api/tests/routes/admin-plugins-bulk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
5 changes: 4 additions & 1 deletion apps/frontend/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion apps/frontend/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion apps/frontend/messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion apps/frontend/messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion apps/frontend/messages/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 4 additions & 1 deletion apps/frontend/messages/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "清空",
Expand Down
50 changes: 50 additions & 0 deletions apps/frontend/src/lib/components/ui/Modal.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<script lang="ts">
import { Dialog } from 'bits-ui'
import X from '@lucide/svelte/icons/x'
import type { Snippet } from 'svelte'
import { m } from '$lib/paraglide/messages'

type Props = {
open: boolean
title: string
description?: string
children: Snippet
footer?: Snippet
}

let { open = $bindable(), title, description, children, footer }: Props = $props()
</script>

<Dialog.Root bind:open>
<Dialog.Portal>
<Dialog.Overlay class="fixed inset-0 z-40 bg-black/40 backdrop-blur-sm" />
<Dialog.Content
class="fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-card p-6 shadow-2xl focus:outline-none"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<Dialog.Title class="text-base font-semibold tracking-tight">{title}</Dialog.Title>
{#if description}
<Dialog.Description class="text-sm text-muted-foreground mt-1.5">{description}</Dialog.Description>
{/if}
</div>
<Dialog.Close
class="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md hover:bg-accent"
aria-label={m.common_close()}
>
<X class="h-4 w-4" />
</Dialog.Close>
</div>

<div class="mt-5">
{@render children()}
</div>

{#if footer}
<div class="mt-5 flex items-center justify-end gap-2">
{@render footer()}
</div>
{/if}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
78 changes: 72 additions & 6 deletions apps/frontend/src/routes/admin/plugins/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -44,6 +47,13 @@
updatedAt: number
}

type AdminUser = {
id: string
displayName: string
email: string | null
role: 'user' | 'admin'
}

let allPlugins = $state<AdminPlugin[]>([])
let loading = $state(true)
let filter = $state<'all' | 'approved' | 'pending' | 'rejected'>('all')
Expand All @@ -53,6 +63,10 @@
let bulkBusy = $state(false)
let deleteTarget = $state<AdminPlugin | null>(null)
let bulkDeleteOpen = $state(false)
let transferOpen = $state(false)
let transferOwnerId = $state('')
let users = $state<AdminUser[]>([])
let usersLoaded = $state(false)

const counts = $derived.by(() => {
const c = { all: allPlugins.length, approved: 0, pending: 0, rejected: 0 }
Expand Down Expand Up @@ -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
Expand All @@ -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<boolean> {
bulkBusy = true
try {
const { data, error } = await eden.api.admin.plugins.bulk.post({
Expand All @@ -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
}
Expand Down Expand Up @@ -505,6 +544,33 @@
/>
{/if}

<Modal
bind:open={transferOpen}
title={m.admin_plugins_bulk_transfer_title({ count: selected.size })}
description={m.admin_plugins_bulk_transfer_description()}
>
<div class="space-y-2">
<Label for="transfer-owner">{m.admin_plugins_bulk_transfer_owner_label()}</Label>
<Select id="transfer-owner" bind:value={transferOwnerId} disabled={bulkBusy}>
<option value="" disabled>{m.admin_plugins_bulk_transfer_placeholder()}</option>
{#each users as u (u.id)}
<option value={u.id}
>{u.displayName}{u.email ? ` — ${u.email}` : ''}{u.role === 'admin' ? ' (admin)' : ''}</option
>
{/each}
</Select>
</div>
{#snippet footer()}
<Button variant="ghost" size="sm" onclick={() => (transferOpen = false)} disabled={bulkBusy}>
{m.confirm_dialog_cancel()}
</Button>
<Button size="sm" onclick={confirmTransfer} disabled={!transferOwnerId || bulkBusy}>
<ArrowRightLeft class="h-3.5 w-3.5" />
{bulkBusy ? m.confirm_dialog_busy() : m.admin_plugins_transfer()}
</Button>
{/snippet}
</Modal>

<ConfirmDialog
bind:open={bulkDeleteOpen}
title={m.admin_plugins_bulk_delete_title({ count: selected.size })}
Expand Down
Loading