de431386ea
Messages: - Reply-to: hover action, composer chip with cancel, quote bubble inside the replying message with tap-to-jump + amber highlight ring - Search: header search button toggles in-conversation search bar with prev/next + match counter, auto-jump to active match - Forward: multi-select conversation picker. Attachments are now carried over: download + decrypt source, re-encrypt under each target conv-key, re-upload with fresh per-attachment keys, insert new attachment rows Conversations: - Archive + mute per member. New migration 20260420000001 adds `archived` + `muted_until` on conversation_members. Shared helpers: setConversationArchived / setConversationMutedUntil / isConversationMuted - ChatsPage: archive toggle in header with unread badge for archived bucket, split active/archived lists, muted indicator (BellOff icon, dimmed unread badge) - ConversationRowMenu via createPortal (escapes sidebar overflow clip), forwardRef-based MenuItem so submenu positioning refs survive React 18 - ConversationsContext: suppresses notification sound + OS notif when target conversation is muted - Refresh on `profiles UPDATE` realtime so peer avatar / displayName changes flow to conversation.members without manual refresh Resilience: - ErrorBoundary (Discord-style): centred spinner + escalating copy, no manual reload button. Backoff retry schedule [2s, 4s, 8s, 15s, 30s]. Uses a keyed Fragment (not a div wrapper) so flex h-full chains survive - App wrapped root + per-route RouteBoundary, conversation-level boundary - AuthContext: flip `ready` immediately on cached session read; validate getUser in background so a stalled/offline Supabase doesn't freeze the app on the loading spinner Crypto: - Swap libsodium-wrappers -> libsodium-wrappers-sumo (compact build was missing crypto_pwhash so Argon2id vault KDF threw, falling back to plaintext localStorage on every launch) - Shim d.ts for sumo types (sumo is API superset, no official types ship) - vite optimizeDeps includes sumo with the "require" condition - secureFileStore: exists(dir) check before mkdir; surface genuine permission errors instead of silent catch Tauri: - fs scope adds `$APPLOCALDATA` direct entry (not just /**) so the app data directory itself can be mkdir'd on first launch Chat layout: - Skip call_event messages when computing avatar run boundaries so a regular bubble followed by a call event from the same sender still shows its avatar
441 lines
14 KiB
TypeScript
441 lines
14 KiB
TypeScript
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<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="min-h-full bg-surface-3 text-fg">
|
|
<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-fg">
|
|
{t('app:admin.title')}
|
|
</h1>
|
|
</header>
|
|
|
|
{error && (
|
|
<div
|
|
role="alert"
|
|
className="flex items-start gap-3 rounded-lg border border-rose-500/30 bg-rose-500/10 p-3 text-sm text-rose-700 dark:text-rose-100"
|
|
>
|
|
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-500 dark: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-fg-muted">
|
|
<SpinnerIcon className="h-4 w-4 text-accent" />
|
|
</div>
|
|
) : (
|
|
<>
|
|
<SettingsSection settings={settings} onRefresh={refresh} />
|
|
<InvitesSection invites={invites} onRefresh={refresh} />
|
|
<UsersSection users={users} onRefresh={refresh} />
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
|
|
return (
|
|
<section className="rounded-2xl border border-line bg-surface-2 p-5">
|
|
<header className="mb-4 flex items-center justify-between">
|
|
<h2 className="text-xs font-semibold uppercase tracking-[0.1em] text-fg-muted">{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-accent px-3 py-1.5 text-xs font-semibold text-accent-fg transition hover:brightness-110 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-fg-muted">{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-[0.1em] text-fg-muted">
|
|
<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-line">
|
|
{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 (
|
|
<tr key={inv.code} className="py-2">
|
|
<td className="py-2 pr-3 font-mono text-xs text-fg">{inv.code}</td>
|
|
<td className="py-2 pr-3 text-xs text-fg-muted">{usesText}</td>
|
|
<td className="py-2 pr-3 text-xs text-fg-muted">{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-line bg-surface-3 px-2 py-1 text-[11px] font-medium text-fg transition hover:brightness-95"
|
|
>
|
|
{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-fg-muted">{t('app:admin.users_empty')}</p>
|
|
) : (
|
|
<ul className="divide-y divide-line">
|
|
{users.map((u) => {
|
|
return (
|
|
<li key={u.userId} className="flex items-center gap-3 py-3">
|
|
<Avatar
|
|
displayName={u.displayName ?? u.username}
|
|
className="h-9 w-9 text-sm"
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-sm font-semibold text-fg">{u.displayName}</p>
|
|
<p className="truncate text-xs text-fg-muted">@{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-fg">{label}</span>
|
|
{hint && <span className="mt-1 block text-xs text-fg-muted">{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-surface transition peer-checked:bg-accent peer-disabled:opacity-50" />
|
|
<span className="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow 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/40 bg-rose-500/10 text-rose-600 hover:bg-rose-500/20 focus-visible:ring-rose-400/40 dark:text-rose-300'
|
|
: 'border-line bg-surface-3 text-fg hover:brightness-95 focus-visible:ring-accent/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-500/40 bg-rose-500/20 text-rose-700 dark:text-rose-100'
|
|
: 'border-accent/40 bg-accent/20 text-accent';
|
|
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-accent/40 ' +
|
|
(active ? activeClass : 'border-line bg-surface-3 text-fg-muted hover:brightness-95')
|
|
}
|
|
>
|
|
{label}
|
|
</button>
|
|
);
|
|
}
|