Initial import of Forge app
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user