import { type AdminProfileFlag, type AdminProfileRow, type AdminSetting, createInvite, deleteInvite, type InviteRecord, listAdminSettings, listAllProfiles, listInvites, setInviteDisabled, setUserFlag, updateAdminSetting, } from '@chat-app/shared/admin'; import { extractErrorCode } from '@chat-app/shared/i18n'; import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Avatar } from '../components/Avatar'; import { AlertIcon, CopyIcon, PlusIcon, SpinnerIcon, TrashIcon, } from '../components/icons'; import { supabase } from '../lib/supabase'; export function AdminPage() { const { t } = useTranslation(['app', 'errors']); const [settings, setSettings] = useState([]); const [invites, setInvites] = useState([]); const [users, setUsers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const refresh = useCallback(async () => { try { setLoading(true); const [s, i, u] = await Promise.all([ listAdminSettings(supabase), listInvites(supabase), listAllProfiles(supabase), ]); setSettings(s); setInvites(i); setUsers(u); setError(null); } catch (err: unknown) { const code = extractErrorCode(err); setError( code ? t('errors:' + code, { defaultValue: t('errors:generic') }) : err instanceof Error ? err.message : t('errors:generic'), ); } finally { setLoading(false); } }, [t]); useEffect(() => { void refresh(); }, [refresh]); return (

{t('app:admin.title')}

{error && (

{error}

)} {loading ? (
) : ( <> )}
); } function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) { return (

{title}

{action}
{children}
); } // --- Settings -------------------------------------------------------------- function SettingsSection({ settings, onRefresh }: { settings: AdminSetting[]; onRefresh: () => Promise }) { const { t } = useTranslation(['app']); const [busy, setBusy] = useState(false); const invitesEnabled = Boolean(settings.find((s) => s.key === 'invites_enabled')?.value); async function handleToggleInvites(next: boolean) { if (busy) return; setBusy(true); try { await updateAdminSetting(supabase, 'invites_enabled', next); await onRefresh(); } catch (err: unknown) { console.error(err); } finally { setBusy(false); } } return (
void handleToggleInvites(v)} />
); } // --- Invites --------------------------------------------------------------- function InvitesSection({ invites, onRefresh }: { invites: InviteRecord[]; onRefresh: () => Promise }) { const { t } = useTranslation(['app']); const [busy, setBusy] = useState(false); const [copiedCode, setCopiedCode] = useState(null); async function handleCreate() { if (busy) return; setBusy(true); try { await createInvite(supabase, {}); await onRefresh(); } catch (err: unknown) { console.error(err); } finally { setBusy(false); } } async function handleToggle(code: string, disabled: boolean) { try { await setInviteDisabled(supabase, code, !disabled); await onRefresh(); } catch (err: unknown) { console.error(err); } } async function handleDelete(code: string) { if (!window.confirm(code + ' ?')) return; try { await deleteInvite(supabase, code); await onRefresh(); } catch (err: unknown) { console.error(err); } } async function handleCopy(code: string) { try { await navigator.clipboard.writeText(code); setCopiedCode(code); window.setTimeout(() => setCopiedCode((c) => (c === code ? null : c)), 1400); } catch { /* ignore */ } } return (
void handleCreate()} disabled={busy} className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 disabled:opacity-60" > {busy ? : } {t('app:admin.invites_create')} } > {invites.length === 0 ? (

{t('app:admin.invites_empty')}

) : (
{invites.map((inv) => { const expired = inv.expiresAt ? new Date(inv.expiresAt) < new Date() : false; const status = inv.disabled ? t('app:admin.invite_status_disabled') : expired ? t('app:admin.invite_status_expired') : t('app:admin.invite_status_active'); const statusColor = inv.disabled ? 'text-rose-600 dark:text-rose-300' : expired ? 'text-amber-600 dark:text-amber-300' : 'text-emerald-600 dark:text-emerald-300'; const usesText = inv.usesLimit ? inv.usesCount + '/' + inv.usesLimit : inv.usesCount + '/∞'; const expiresText = inv.expiresAt ? new Date(inv.expiresAt).toLocaleDateString() : t('app:admin.invite_expires_never'); return ( ); })}
{t('app:admin.invite_col_code')} {t('app:admin.invite_col_uses')} {t('app:admin.invite_col_expires')} {t('app:admin.invite_col_status')}
{inv.code} {usesText} {expiresText} {status}
void handleCopy(inv.code)} > void handleDelete(inv.code)} >
)}
); } // --- Users ----------------------------------------------------------------- function UsersSection({ users, onRefresh }: { users: AdminProfileRow[]; onRefresh: () => Promise }) { const { t } = useTranslation(['app']); async function toggle(userId: string, flag: AdminProfileFlag, value: boolean) { try { await setUserFlag(supabase, userId, flag, value); await onRefresh(); } catch (err: unknown) { console.error(err); } } return (
{users.length === 0 ? (

{t('app:admin.users_empty')}

) : (
    {users.map((u) => { return (
  • {u.displayName}

    @{u.username}

    void toggle(u.userId, 'is_admin', v)} /> void toggle(u.userId, 'blocked_from_inviting', v)} /> void toggle(u.userId, 'banned', v)} />
  • ); })}
)}
); } // --- Bits ------------------------------------------------------------------ function Toggle({ label, hint, checked, disabled, onChange, }: { label: string; hint?: string; checked: boolean; disabled?: boolean; onChange: (next: boolean) => void; }) { return ( ); } function IconButton({ label, children, onClick, tone, }: { label: string; children: React.ReactNode; onClick: () => void; tone?: 'danger'; }) { return ( ); } function FlagChip({ label, active, onToggle, tone, }: { label: string; active: boolean; onToggle: (next: boolean) => void; tone?: 'danger'; }) { const activeClass = tone === 'danger' ? 'border-rose-500/40 bg-rose-500/20 text-rose-700 dark:text-rose-100' : 'border-accent/40 bg-accent/20 text-accent'; return ( ); }