This commit is contained in:
2026-04-18 23:11:35 +02:00
commit f7cfd2a86e
196 changed files with 35538 additions and 0 deletions
+438
View File
@@ -0,0 +1,438 @@
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 {
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<AdminSetting[]>([]);
const [invites, setInvites] = useState<InviteRecord[]>([]);
const [users, setUsers] = useState<AdminProfileRow[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="mx-auto flex max-w-4xl flex-col gap-6 px-6 py-8">
<header>
<h1 className="font-display text-2xl font-semibold tracking-tight text-white">
{t('app:admin.title')}
</h1>
</header>
{error && (
<div
role="alert"
className="flex items-start gap-3 rounded-lg border border-rose-500/20 bg-rose-500/10 p-3 text-sm text-rose-100"
>
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-400" />
<p className="min-w-0 flex-1 break-words">{error}</p>
</div>
)}
{loading ? (
<div className="flex items-center gap-2 text-sm text-neutral-500">
<SpinnerIcon className="h-4 w-4 text-brand-400" />
</div>
) : (
<>
<SettingsSection settings={settings} onRefresh={refresh} />
<InvitesSection invites={invites} onRefresh={refresh} />
<UsersSection users={users} onRefresh={refresh} />
</>
)}
</div>
);
}
function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
return (
<section className="rounded-2xl border border-white/10 bg-ink-900/60 p-5 backdrop-blur-xl">
<header className="mb-4 flex items-center justify-between">
<h2 className="text-xs font-semibold uppercase tracking-wide text-neutral-400">{title}</h2>
{action}
</header>
{children}
</section>
);
}
// --- Settings --------------------------------------------------------------
function SettingsSection({ settings, onRefresh }: { settings: AdminSetting[]; onRefresh: () => Promise<void> }) {
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 (
<Section title={t('app:admin.settings_title')}>
<Toggle
label={t('app:admin.invites_enabled')}
hint={t('app:admin.invites_enabled_hint')}
checked={invitesEnabled}
disabled={busy}
onChange={(v) => void handleToggleInvites(v)}
/>
</Section>
);
}
// --- Invites ---------------------------------------------------------------
function InvitesSection({ invites, onRefresh }: { invites: InviteRecord[]; onRefresh: () => Promise<void> }) {
const { t } = useTranslation(['app']);
const [busy, setBusy] = useState(false);
const [copiedCode, setCopiedCode] = useState<string | null>(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 (
<Section
title={t('app:admin.invites_title')}
action={
<button
type="button"
onClick={() => void handleCreate()}
disabled={busy}
className="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-brand-500/90 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-brand-400 disabled:opacity-60"
>
{busy ? <SpinnerIcon className="h-3.5 w-3.5" /> : <PlusIcon className="h-3.5 w-3.5" />}
<span>{t('app:admin.invites_create')}</span>
</button>
}
>
{invites.length === 0 ? (
<p className="text-sm text-neutral-500">{t('app:admin.invites_empty')}</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead className="text-[10px] uppercase tracking-wide text-neutral-500">
<tr>
<th className="py-2 pr-3">{t('app:admin.invite_col_code')}</th>
<th className="py-2 pr-3">{t('app:admin.invite_col_uses')}</th>
<th className="py-2 pr-3">{t('app:admin.invite_col_expires')}</th>
<th className="py-2 pr-3">{t('app:admin.invite_col_status')}</th>
<th className="py-2" />
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{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-300'
: expired
? 'text-amber-300'
: '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 (
<tr key={inv.code} className="py-2">
<td className="py-2 pr-3 font-mono text-xs">{inv.code}</td>
<td className="py-2 pr-3 text-xs text-neutral-400">{usesText}</td>
<td className="py-2 pr-3 text-xs text-neutral-400">{expiresText}</td>
<td className={'py-2 pr-3 text-xs font-medium ' + statusColor}>{status}</td>
<td className="py-2 text-right">
<div className="inline-flex gap-1">
<IconButton
label={
copiedCode === inv.code
? t('app:admin.invites_copied')
: t('app:admin.invites_copy')
}
onClick={() => void handleCopy(inv.code)}
>
<CopyIcon className="h-3.5 w-3.5" />
</IconButton>
<button
type="button"
onClick={() => void handleToggle(inv.code, inv.disabled)}
className="rounded-md border border-white/10 bg-white/5 px-2 py-1 text-[11px] font-medium text-neutral-300 transition hover:bg-white/10"
>
{inv.disabled ? t('app:admin.invites_enable') : t('app:admin.invites_disable')}
</button>
<IconButton
label={t('app:admin.invites_delete')}
tone="danger"
onClick={() => void handleDelete(inv.code)}
>
<TrashIcon className="h-3.5 w-3.5" />
</IconButton>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</Section>
);
}
// --- Users -----------------------------------------------------------------
function UsersSection({ users, onRefresh }: { users: AdminProfileRow[]; onRefresh: () => Promise<void> }) {
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 (
<Section title={t('app:admin.users_title')}>
{users.length === 0 ? (
<p className="text-sm text-neutral-500">{t('app:admin.users_empty')}</p>
) : (
<ul className="divide-y divide-white/5">
{users.map((u) => {
const letter =
(u.displayName ?? u.username ?? '?').trim().charAt(0).toUpperCase() || '?';
return (
<li key={u.userId} className="flex items-center gap-3 py-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-500/30 text-sm font-semibold text-white ring-1 ring-brand-400/30">
{letter}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-white">{u.displayName}</p>
<p className="truncate text-xs text-neutral-500">@{u.username}</p>
</div>
<div className="flex gap-2">
<FlagChip
label={t('app:admin.users_flag_admin')}
active={u.isAdmin}
onToggle={(v) => void toggle(u.userId, 'is_admin', v)}
/>
<FlagChip
label={t('app:admin.users_flag_blocked_inviting')}
active={u.blockedFromInviting}
onToggle={(v) => void toggle(u.userId, 'blocked_from_inviting', v)}
/>
<FlagChip
label={t('app:admin.users_flag_banned')}
tone="danger"
active={u.banned}
onToggle={(v) => void toggle(u.userId, 'banned', v)}
/>
</div>
</li>
);
})}
</ul>
)}
</Section>
);
}
// --- Bits ------------------------------------------------------------------
function Toggle({
label,
hint,
checked,
disabled,
onChange,
}: {
label: string;
hint?: string;
checked: boolean;
disabled?: boolean;
onChange: (next: boolean) => void;
}) {
return (
<label className="flex cursor-pointer items-start justify-between gap-4">
<span className="min-w-0 flex-1">
<span className="block text-sm text-neutral-200">{label}</span>
{hint && <span className="mt-1 block text-xs text-neutral-500">{hint}</span>}
</span>
<span className="relative mt-0.5 inline-flex h-6 w-11 shrink-0">
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
className="peer sr-only"
/>
<span className="inline-block h-6 w-11 rounded-full bg-neutral-700 transition peer-checked:bg-brand-500/70 peer-disabled:opacity-50" />
<span className="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white transition peer-checked:translate-x-5" />
</span>
</label>
);
}
function IconButton({
label,
children,
onClick,
tone,
}: {
label: string;
children: React.ReactNode;
onClick: () => void;
tone?: 'danger';
}) {
return (
<button
type="button"
onClick={onClick}
aria-label={label}
title={label}
className={
'flex h-7 w-7 cursor-pointer items-center justify-center rounded-md border transition focus:outline-none focus-visible:ring-2 ' +
(tone === 'danger'
? 'border-rose-500/30 bg-rose-500/10 text-rose-200 hover:bg-rose-500/20 focus-visible:ring-rose-400/40'
: 'border-white/10 bg-white/5 text-neutral-300 hover:bg-white/10 focus-visible:ring-brand-400/40')
}
>
{children}
</button>
);
}
function FlagChip({
label,
active,
onToggle,
tone,
}: {
label: string;
active: boolean;
onToggle: (next: boolean) => void;
tone?: 'danger';
}) {
const activeClass =
tone === 'danger'
? 'border-rose-400/40 bg-rose-500/20 text-rose-100'
: 'border-brand-400/40 bg-brand-500/20 text-brand-100';
return (
<button
type="button"
onClick={() => onToggle(!active)}
className={
'cursor-pointer rounded-full border px-2.5 py-0.5 text-[11px] font-medium transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-400/40 ' +
(active ? activeClass : 'border-white/10 bg-white/5 text-neutral-400 hover:bg-white/10')
}
>
{label}
</button>
);
}