Repository Public
1// Administration: accounts and quotas, invitations, and the state of the system.2import { formatBytes } from '@spd/shared';3import { useQuery, useQueryClient } from '@tanstack/react-query';4import { type FormEvent, type ReactNode, useState } from 'react';5import { del, get, patch, post } from '../api.ts';6import { Glyph } from '../glyphs.tsx';7import { errorText, fmtDate, fmtRelative, getLocale, useT } from '../i18n.ts';8import { useAuth } from '../session.ts';9import { Alert, Page, Section, sensitive } from '../shell/account-ui.tsx';10import { Badge, Button, ConfirmDialog, CopyField, cx, Dialog, EmptyState, ErrorState, IconButton, Loading, ProgressBar, Select, Switch, TextField, useToast } from '../ui.tsx';1112interface AdminUser { id: string; email: string; displayName: string; role: 'admin' | 'user'; disabled: boolean; createdAt: string; quota: number; used: number; passkeys: number }13interface InviteRow { id: string; email: string; role: 'admin' | 'user'; expiresAt: string; usedAt: string | null; createdAt: string }14interface System {15 db: boolean; storage: { ok: boolean; freeBytes: number; totalBytes: number }; antivirus: boolean; queueLagS: number; failedJobs: number; avMode: 'required' | 'optional'; commit?: string | null;16 counts: { users: number; files: number; objects: number; bytes: number; shares: number; requests: number; quarantined: number };17}18const GIB = 1024 ** 3;19const toGb = (bytes: number) => String(Math.round((bytes / GIB) * 100) / 100);20const fromGb = (s: string): number | null => { const n = Number(s.replace(',', '.')); return s.trim() !== '' && Number.isFinite(n) && n >= 0 && n <= 900_000 ? Math.round(n * GIB) : null; };2122// --- users ---------------------------------------------------------------------------------------------------23function EditUser({ user, self, onClose, onSaved }: { user: AdminUser; self: boolean; onClose: () => void; onSaved: () => void }) {24 const t = useT();25 const [gb, setGb] = useState(toGb(user.quota));26 const [role, setRole] = useState(user.role);27 const [disabled, setDisabled] = useState(user.disabled);28 const [busy, setBusy] = useState(false);29 const [failure, setFailure] = useState<string | null>(null);30 const bytes = fromGb(gb);31 const body = { ...(bytes !== null && bytes !== user.quota ? { quotaBytes: bytes } : {}), ...(role !== user.role ? { role } : {}), ...(disabled !== user.disabled ? { disabled } : {}) };32 const dirty = Object.keys(body).length > 0;33 const submit = async (e: FormEvent) => {34 e.preventDefault();35 if (bytes === null || !dirty) return;36 setBusy(true); setFailure(null);37 try { await sensitive(() => patch(`/admin/users/${user.id}`, body), t('account.reauth.cancelled')); onSaved(); onClose(); } catch (err) { setFailure(errorText(err)); setBusy(false); }38 };39 return (40 <Dialog title={t('account.admin.editTitle', { name: user.displayName })} onClose={onClose}>41 <form onSubmit={submit} className="flex flex-col gap-4" noValidate>42 <p className="text-sm break-all text-ink-2">{user.email}</p>43 {failure ? <Alert>{failure}</Alert> : null}44 <TextField label={t('account.admin.quotaGb')} hint={t('account.admin.quotaHint', { used: formatBytes(user.used, getLocale()) })} value={gb} onChange={(e) => setGb(e.currentTarget.value)} inputMode="decimal" autoComplete="off" data-autofocus45 error={bytes === null ? t('account.admin.quotaInvalid') : null} />46 <Select label={t('account.admin.role')} value={role} disabled={self} hint={self ? t('account.admin.selfHint') : undefined} onChange={(e) => setRole(e.currentTarget.value as AdminUser['role'])}>47 <option value="user">{t('account.admin.role.user')}</option>48 <option value="admin">{t('account.admin.role.admin')}</option>49 </Select>50 <Switch label={t('account.admin.disable')} hint={t('account.admin.disableHint')} checked={disabled} disabled={self} onChange={setDisabled} />51 <div className="flex justify-end gap-2"><Button onClick={onClose}>{t('common.cancel')}</Button><Button type="submit" variant="primary" busy={busy} disabled={!dirty || bytes === null}>{t('common.save')}</Button></div>52 </form>53 </Dialog>54 );55}5657function Usage({ u }: { u: AdminUser }) {58 const l = getLocale();59 return (60 <div className="flex min-w-0 flex-col gap-1">61 <span className="text-xs whitespace-nowrap text-ink-2">{formatBytes(u.used, l)} / {formatBytes(u.quota, l)}</span>62 <ProgressBar value={u.quota > 0 ? u.used / u.quota : 0} />63 </div>64 );65}66function Users() {67 const t = useT();68 const toast = useToast();69 const qc = useQueryClient();70 const { state } = useAuth();71 const q = useQuery({ queryKey: ['admin', 'users'], queryFn: () => get<AdminUser[]>('/admin/users') });72 const [editing, setEditing] = useState<AdminUser | null>(null);73 const badges = (u: AdminUser) => <>{u.role === 'admin' ? <Badge tone="accent">{t('account.admin.role.admin')}</Badge> : <Badge>{t('account.admin.role.user')}</Badge>}{u.disabled ? <Badge tone="danger">{t('account.admin.disabled')}</Badge> : null}</>;74 const th = 'px-3 py-2 text-left text-xs font-medium tracking-wide text-ink-3 uppercase';75 return (76 <Section title={t('account.admin.users')} text={q.data ? t('account.admin.usersCount', { n: q.data.length }) : undefined}>77 {q.isLoading ? <Loading /> : q.error || !q.data ? <ErrorState error={q.error} onRetry={() => void q.refetch()} /> : !q.data.length ? <EmptyState icon="users" title={t('account.admin.noUsers')} /> : (78 <>79 <div className="-mx-2 hidden md:block">80 <table className="w-full table-fixed border-collapse text-sm">81 <thead><tr className="border-b border-line">82 <th scope="col" className={cx(th, 'w-[36%]')}>{t('account.admin.col.user')}</th><th scope="col" className={cx(th, 'w-[20%]')}>{t('account.admin.col.role')}</th>83 <th scope="col" className={cx(th, 'w-[24%]')}>{t('account.admin.col.storage')}</th><th scope="col" className={cx(th, 'w-[10%]')}>{t('account.admin.col.passkeys')}</th><th scope="col" className="w-[10%]"><span className="sr-only">{t('common.edit')}</span></th>84 </tr></thead>85 <tbody className="divide-y divide-line">86 {q.data.map((u) => (87 <tr key={u.id} data-testid="admin-user" data-email={u.email} className={cx(u.disabled && 'opacity-70')}>88 <td className="px-3 py-2.5"><p className="truncate font-medium text-ink">{u.displayName}</p><p className="truncate text-xs text-ink-3">{u.email}</p></td>89 <td className="px-3 py-2.5"><div className="flex flex-wrap gap-1">{badges(u)}</div></td>90 <td className="px-3 py-2.5"><Usage u={u} /></td>91 <td className="px-3 py-2.5 text-ink-2">{u.passkeys}</td>92 <td className="px-1 py-1 text-right"><IconButton icon="pencil" label={t('account.admin.edit', { name: u.displayName })} onClick={() => setEditing(u)} /></td>93 </tr>94 ))}95 </tbody>96 </table>97 </div>98 <ul className="divide-y divide-line md:hidden">99 {q.data.map((u) => (100 <li key={u.id} data-testid="admin-user" data-email={u.email} className="flex items-start gap-3 py-3">101 <div className="flex min-w-0 flex-1 flex-col gap-1.5">102 <div><p className="truncate text-sm font-medium text-ink">{u.displayName}</p><p className="truncate text-xs text-ink-3">{u.email}</p></div>103 <div className="flex flex-wrap items-center gap-1">{badges(u)}<span className="ml-1 text-xs text-ink-3">{t('account.admin.passkeysN', { n: u.passkeys })}</span></div>104 <Usage u={u} />105 </div>106 <IconButton icon="pencil" label={t('account.admin.edit', { name: u.displayName })} onClick={() => setEditing(u)} />107 </li>108 ))}109 </ul>110 </>111 )}112 {editing ? <EditUser user={editing} self={editing.id === state?.user?.id} onClose={() => setEditing(null)}113 onSaved={() => { void qc.invalidateQueries({ queryKey: ['admin', 'users'] }); void qc.invalidateQueries({ queryKey: ['auth'] }); toast(t('account.admin.saved'), { tone: 'ok' }); }} /> : null}114 </Section>115 );116}117118// --- invitations ---------------------------------------------------------------------------------------------119function NewInvite({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {120 const t = useT();121 const [email, setEmail] = useState('');122 const [role, setRole] = useState<'user' | 'admin'>('user');123 const [gb, setGb] = useState('');124 const [busy, setBusy] = useState(false);125 const [failure, setFailure] = useState<string | null>(null);126 const [url, setUrl] = useState<string | null>(null);127 const bytes = gb.trim() === '' ? undefined : fromGb(gb);128 const valid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim()) && bytes !== null;129 const submit = async (e: FormEvent) => {130 e.preventDefault();131 if (!valid) return;132 setBusy(true); setFailure(null);133 try {134 const r = await sensitive(() => post<{ id: string; url: string }>('/admin/invites', { email: email.trim(), role, ...(bytes !== undefined && bytes !== null ? { quotaBytes: bytes } : {}) }), t('account.reauth.cancelled'));135 setUrl(r.url); onCreated();136 } catch (err) { setFailure(errorText(err)); }137 setBusy(false);138 };139 if (url) return (140 // its own key: a new dialog instance, so that focus moves into it (the form that held the focus is gone)141 <Dialog key="ready" title={t('account.admin.inviteReady')} onClose={onClose} footer={<Button variant="primary" onClick={onClose}>{t('account.admin.inviteDone')}</Button>}>142 <div className="flex flex-col gap-4">143 <p className="text-sm leading-relaxed text-ink-2">{t('account.admin.inviteReadyText', { email: email.trim() })}</p>144 <CopyField value={url} label={t('account.admin.inviteLink')} />145 <Alert tone="warn">{t('account.admin.inviteOnce')}</Alert>146 </div>147 </Dialog>148 );149 return (150 <Dialog key="form" title={t('account.admin.inviteNew')} onClose={onClose}>151 <form onSubmit={submit} className="flex flex-col gap-4" noValidate>152 {failure ? <Alert>{failure}</Alert> : null}153 <TextField label={t('account.admin.inviteEmail')} type="email" value={email} onChange={(e) => setEmail(e.currentTarget.value)} autoComplete="off" inputMode="email" autoCapitalize="off" maxLength={200} required data-autofocus />154 <Select label={t('account.admin.role')} value={role} onChange={(e) => setRole(e.currentTarget.value as 'user' | 'admin')}>155 <option value="user">{t('account.admin.role.user')}</option>156 <option value="admin">{t('account.admin.role.admin')}</option>157 </Select>158 <TextField label={t('account.admin.quotaGb')} hint={t('account.admin.inviteQuotaHint')} value={gb} onChange={(e) => setGb(e.currentTarget.value)} inputMode="decimal" autoComplete="off" error={bytes === null ? t('account.admin.quotaInvalid') : null} />159 <div className="flex justify-end gap-2"><Button onClick={onClose}>{t('common.cancel')}</Button><Button type="submit" variant="primary" busy={busy} disabled={!valid}>{t('account.admin.inviteCreate')}</Button></div>160 </form>161 </Dialog>162 );163}164function Invites() {165 const t = useT();166 const toast = useToast();167 const qc = useQueryClient();168 const q = useQuery({ queryKey: ['admin', 'invites'], queryFn: () => get<InviteRow[]>('/admin/invites') });169 const [creating, setCreating] = useState(false);170 const [removing, setRemoving] = useState<InviteRow | null>(null);171 const reload = () => qc.invalidateQueries({ queryKey: ['admin', 'invites'] });172 return (173 <Section title={t('account.admin.invites')} text={t('account.admin.invitesText')} action={<Button variant="primary" icon="plus" onClick={() => setCreating(true)}>{t('account.admin.inviteNew')}</Button>}>174 {q.isLoading ? <Loading /> : q.error || !q.data ? <ErrorState error={q.error} onRetry={() => void q.refetch()} /> : !q.data.length ? <EmptyState icon="link" title={t('account.admin.noInvites')} text={t('account.admin.noInvitesText')} /> : (175 <ul className="divide-y divide-line" aria-label={t('account.admin.invites')}>176 {q.data.map((i) => {177 const expired = !i.usedAt && new Date(i.expiresAt).getTime() < Date.now();178 return (179 <li key={i.id} data-testid="admin-invite" data-email={i.email} className="flex items-center gap-3 py-2.5">180 <div className="min-w-0 flex-1">181 <p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm font-medium text-ink"><span className="min-w-0 truncate">{i.email}</span>182 {i.usedAt ? <Badge tone="ok">{t('account.admin.inviteUsed')}</Badge> : expired ? <Badge tone="danger">{t('account.admin.inviteExpired')}</Badge> : <Badge tone="accent">{t('account.admin.invitePending')}</Badge>}183 {i.role === 'admin' ? <Badge>{t('account.admin.role.admin')}</Badge> : null}</p>184 <p className="mt-0.5 text-xs text-ink-3">{i.usedAt ? t('account.admin.inviteUsedOn', { date: fmtDate(i.usedAt) }) : expired ? t('account.admin.inviteExpiredOn', { date: fmtDate(i.expiresAt) }) : t('account.admin.inviteExpires', { when: fmtRelative(i.expiresAt) })}</p>185 </div>186 {i.usedAt ? <span className="w-10" /> : <IconButton icon="trash" label={t('account.admin.inviteDelete', { email: i.email })} onClick={() => setRemoving(i)} />}187 </li>188 );189 })}190 </ul>191 )}192 {creating ? <NewInvite onClose={() => setCreating(false)} onCreated={() => void reload()} /> : null}193 {removing ? <ConfirmDialog danger title={t('account.admin.inviteDeleteTitle')} text={t('account.admin.inviteDeleteText', { email: removing.email })} confirmLabel={t('common.delete')} onClose={() => setRemoving(null)}194 onConfirm={async () => { await del(`/admin/invites/${removing.id}`); await reload(); toast(t('account.admin.inviteDeleted'), { tone: 'ok' }); }} /> : null}195 </Section>196 );197}198199// --- system --------------------------------------------------------------------------------------------------200function Stat({ label, value, tone, detail }: { label: string; value: ReactNode; tone?: 'ok' | 'warn' | 'danger'; detail?: ReactNode }) {201 return (202 <div className="flex min-w-0 flex-col gap-1 rounded-2xl border border-line bg-bg px-3.5 py-3">203 <dt className="text-xs font-medium text-ink-3">{label}</dt>204 <dd className="flex min-w-0 flex-col gap-1">205 <span className="flex items-center gap-2 text-sm font-semibold text-ink">{tone ? <span aria-hidden="true" className={cx('h-2 w-2 shrink-0 rounded-full', tone === 'ok' ? 'bg-ok' : tone === 'warn' ? 'bg-warn' : 'bg-danger')} /> : null}<span className="min-w-0 truncate">{value}</span></span>206 {detail ? <span className="text-xs break-words text-ink-3">{detail}</span> : null}207 </dd>208 </div>209 );210}211function SystemCard() {212 const t = useT();213 const q = useQuery({ queryKey: ['admin', 'system'], queryFn: () => get<System>('/admin/system'), refetchInterval: 30_000 });214 const l = getLocale();215 const s = q.data;216 const n = (v: number) => new Intl.NumberFormat(l).format(v);217 return (218 <Section title={t('account.system.title')} text={s?.commit ? t('account.system.version', { commit: s.commit.slice(0, 12) }) : undefined}219 action={<IconButton icon="refresh" label={t('account.system.refresh')} disabled={q.isFetching} onClick={() => void q.refetch()} />}>220 {q.isLoading ? <Loading /> : q.error || !s ? <ErrorState error={q.error} onRetry={() => void q.refetch()} /> : (221 <div className="flex flex-col gap-4" data-testid="system">222 <dl className="grid grid-cols-2 gap-2.5 lg:grid-cols-3">223 <Stat label={t('account.system.db')} tone={s.db ? 'ok' : 'danger'} value={s.db ? t('account.system.up') : t('account.system.down')} />224 <Stat label={t('account.system.storage')} tone={!s.storage.ok ? 'danger' : s.storage.totalBytes && s.storage.freeBytes / s.storage.totalBytes < 0.1 ? 'warn' : 'ok'}225 value={s.storage.ok ? t('account.system.free', { free: formatBytes(s.storage.freeBytes, l) }) : t('account.system.down')} detail={s.storage.ok ? t('account.system.total', { total: formatBytes(s.storage.totalBytes, l) }) : undefined} />226 <Stat label={t('account.system.antivirus')} tone={s.antivirus ? 'ok' : s.avMode === 'optional' ? 'warn' : 'danger'} value={s.antivirus ? t('account.system.up') : t('account.system.down')}227 detail={s.avMode === 'required' ? t('account.system.avRequired') : t('account.system.avOptional')} />228 <Stat label={t('account.system.queue')} tone={s.queueLagS > 120 ? 'warn' : 'ok'} value={s.queueLagS > 0 ? t('account.system.lag', { n: s.queueLagS }) : t('account.system.noLag')} />229 <Stat label={t('account.system.failed')} tone={s.failedJobs > 0 ? 'warn' : 'ok'} value={n(s.failedJobs)} />230 <Stat label={t('account.system.quarantined')} tone={s.counts.quarantined > 0 ? 'warn' : 'ok'} value={n(s.counts.quarantined)} />231 </dl>232 <dl className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm sm:grid-cols-3">233 {([['users', s.counts.users], ['files', s.counts.files], ['objects', s.counts.objects], ['shares', s.counts.shares], ['requests', s.counts.requests]] as const).map(([k, v]) => (234 <div key={k} className="flex items-baseline justify-between gap-2 border-b border-line pb-2"><dt className="min-w-0 truncate text-ink-2">{t(`account.system.count.${k}`)}</dt><dd className="font-semibold text-ink">{n(v)}</dd></div>235 ))}236 <div className="flex items-baseline justify-between gap-2 border-b border-line pb-2"><dt className="min-w-0 truncate text-ink-2">{t('account.system.count.bytes')}</dt><dd className="font-semibold whitespace-nowrap text-ink">{formatBytes(s.counts.bytes, l)}</dd></div>237 </dl>238 </div>239 )}240 </Section>241 );242}243244export function Admin() {245 const t = useT();246 return (247 <Page wide title={t('account.admin.title')} text={t('account.admin.subtitle')}>248 <SystemCard />249 <Users />250 <Invites />251 </Page>252 );253}254