Files
forge/client/src/pages/Admin/index.tsx
T

155 lines
7.0 KiB
TypeScript

import { useEffect, useState, useCallback } from 'react';
import PageHeader from '../../components/PageHeader';
import { listUsers, createUser, deleteUser, listTokens, createToken, revokeToken, type AppUser, type TokenInfo } from '../../services/admin.service';
import { ROLE_LABELS, roleAtLeast, type Role, type CurrentUser } from '../../services/auth.service';
import styles from './Admin.module.scss';
const fmt = (v: string | null) => (v ? new Date(v).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) : '—');
export default function Admin({ user }: { user: CurrentUser }) {
const isAdmin = user.role === 'admin';
const [tab, setTab] = useState<'users' | 'tokens'>('users');
useEffect(() => { if (!isAdmin) setTab('users'); }, [isAdmin]);
return (
<div className={styles.page}>
<PageHeader title="Administration" subtitle="Users and API access" />
<div className={styles.tabs}>
<button className={tab === 'users' ? styles.on : ''} onClick={() => setTab('users')}>Users</button>
{isAdmin && <button className={tab === 'tokens' ? styles.on : ''} onClick={() => setTab('tokens')}>API Tokens</button>}
</div>
{tab === 'users' ? <UsersPanel me={user} /> : <TokensPanel />}
</div>
);
}
function UsersPanel({ me }: { me: CurrentUser }) {
const [users, setUsers] = useState<AppUser[]>([]);
const [error, setError] = useState<string | null>(null);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [role, setRole] = useState<Role>('viewer');
const [busy, setBusy] = useState(false);
const load = useCallback(() => { listUsers().then(setUsers).catch(() => setError('Failed to load users')); }, []);
useEffect(() => { load(); }, [load]);
// Leadership can grant up to 'lead'; only admin can grant 'admin'.
const grantable: Role[] = me.role === 'admin' ? ['viewer', 'pm', 'lead', 'admin'] : ['viewer', 'pm', 'lead'];
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setBusy(true); setError(null);
try {
const res = await createUser(username.trim(), password, role);
if (res.user) { setUsername(''); setPassword(''); setRole('viewer'); load(); }
else setError(res.message || 'Could not create user');
} catch { setError('Could not create user'); }
finally { setBusy(false); }
};
const remove = async (u: string) => {
if (!confirm(`Delete user "${u}"?`)) return;
await deleteUser(u).catch(() => {});
load();
};
return (
<div className={styles.grid}>
<form className={styles.card} onSubmit={submit}>
<h3 className={styles.cardTitle}>Add user</h3>
<label className={styles.label}>Username</label>
<input className={styles.input} value={username} onChange={e => setUsername(e.target.value)} autoComplete="off" />
<label className={styles.label}>Password</label>
<input className={styles.input} type="password" value={password} onChange={e => setPassword(e.target.value)} autoComplete="new-password" placeholder="min 6 chars" />
<label className={styles.label}>Role</label>
<select className={styles.input} value={role} onChange={e => setRole(e.target.value as Role)}>
{grantable.map(r => <option key={r} value={r}>{ROLE_LABELS[r]}</option>)}
</select>
{error && <div className={styles.err}>{error}</div>}
<button className={styles.btn} type="submit" disabled={busy || username.trim().length < 2 || password.length < 6}>
{busy ? 'Creating…' : 'Create user'}
</button>
</form>
<div className={styles.card}>
<h3 className={styles.cardTitle}>Users ({users.length})</h3>
<table className={styles.table}>
<thead><tr><th>Username</th><th>Role</th><th>Last login</th><th></th></tr></thead>
<tbody>
{users.map(u => (
<tr key={u.id}>
<td>{u.username}</td>
<td><span className={styles.roleBadge} data-role={u.role}>{ROLE_LABELS[u.role]}</span></td>
<td className={styles.muted}>{fmt(u.lastLoginAt)}</td>
<td className={styles.right}>
{u.username !== me.username && <button className={styles.danger} onClick={() => remove(u.username)}>Delete</button>}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
function TokensPanel() {
const [tokens, setTokens] = useState<TokenInfo[]>([]);
const [label, setLabel] = useState('');
const [minted, setMinted] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const load = useCallback(() => { listTokens().then(setTokens).catch(() => {}); }, []);
useEffect(() => { load(); }, [load]);
const create = async (e: React.FormEvent) => {
e.preventDefault();
setBusy(true);
try {
const res = await createToken(label.trim() || 'sync-extension');
setMinted(res.token); setLabel(''); load();
} finally { setBusy(false); }
};
const revoke = async (id: number) => { if (confirm('Revoke this token?')) { await revokeToken(id).catch(() => {}); load(); } };
return (
<div className={styles.grid}>
<form className={styles.card} onSubmit={create}>
<h3 className={styles.cardTitle}>Create API token</h3>
<p className={styles.hint}>For the FORGE Snow Sync Chrome extension. The raw token is shown once.</p>
<label className={styles.label}>Label</label>
<input className={styles.input} value={label} onChange={e => setLabel(e.target.value)} placeholder="e.g. my laptop" />
<button className={styles.btn} type="submit" disabled={busy}>{busy ? 'Creating…' : 'Create token'}</button>
{minted && (
<div className={styles.minted}>
<div className={styles.mintedLabel}>Copy now shown once:</div>
<code className={styles.mintedToken} onClick={() => navigator.clipboard?.writeText(minted)}>{minted}</code>
<button type="button" className={styles.copy} onClick={() => navigator.clipboard?.writeText(minted)}>Copy</button>
</div>
)}
</form>
<div className={styles.card}>
<h3 className={styles.cardTitle}>Tokens ({tokens.length})</h3>
<table className={styles.table}>
<thead><tr><th>Label</th><th>ID</th><th>Last used</th><th>Expires</th><th></th></tr></thead>
<tbody>
{tokens.map(t => (
<tr key={t.id} className={t.revoked ? styles.revokedRow : ''}>
<td>{t.label}</td>
<td className={styles.mono}>{t.tokenId}</td>
<td className={styles.muted}>{fmt(t.lastUsedAt)}</td>
<td className={styles.muted}>{fmt(t.expiresAt)}</td>
<td className={styles.right}>
{t.revoked ? <span className={styles.revoked}>revoked</span> : <button className={styles.danger} onClick={() => revoke(t.id)}>Revoke</button>}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}