SPB Git

Repository Public

Admin.tsx 18.0 KB 254 lines tsx

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
Files main
Menu

Theme