feat(dashboard): logout button + admin user-management window

Logout: new sidebar link 'Log out (username)' that POSTs /api/logout
(clears session cookie) and navigates to /login. Visible to everyone.
Replaces 'no logout functionality' state where users could only get
out by deleting cookies manually.

Admin window: new 'Admin · Users' window (only shown when current
user.is_admin) lists all users in a table with:
  - Add user (username + password + admin checkbox)
  - Reset password inline per row
  - Toggle admin per row
  - Delete user per row (blocked for self)
Wraps the existing /api-admin/users CRUD endpoints in main.py.

Plumbing: useCurrentUser hook fetches /me on mount; apiPatch+apiDelete
helpers added to api/client.ts; new endpoint wrappers exported from
api/endpoints.ts; AdminUsersWindow.tsx registered in WindowRenderer
under id prefix 'adminusers'; CSS for admin table/form/buttons and
the muted-red logout link.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-15 20:10:10 +02:00
parent 88e9e88f46
commit 1c1c43d28b
23 changed files with 521 additions and 50 deletions

View file

@ -29,6 +29,40 @@ export async function apiPost<T>(path: string, body: unknown): Promise<T> {
return res.json();
}
/**
* PATCH JSON to an authenticated API endpoint. Same shape as apiPost.
*/
export async function apiPatch<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body ?? {}),
});
if (!res.ok) {
let detail = '';
try { detail = (await res.json())?.detail ?? ''; } catch { /* ignore */ }
throw new Error(`API ${path}: ${res.status}${detail ? ` (${detail})` : ''}`);
}
return res.json();
}
/**
* DELETE an authenticated API endpoint. No body. Returns parsed JSON.
*/
export async function apiDelete<T>(path: string): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
method: 'DELETE',
credentials: 'include',
});
if (!res.ok) {
let detail = '';
try { detail = (await res.json())?.detail ?? ''; } catch { /* ignore */ }
throw new Error(`API ${path}: ${res.status}${detail ? ` (${detail})` : ''}`);
}
return res.json();
}
export function wsUrl(): string {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${proto}//${location.host}/api/ws/live`;

View file

@ -1,4 +1,4 @@
import { apiFetch, apiPost } from './client';
import { apiFetch, apiPost, apiPatch, apiDelete } from './client';
import type { TelemetrySnapshot, CombatStatsMessage, ServerHealth } from '../types';
interface LiveResponse {
@ -46,3 +46,51 @@ export const agentSessionHistory = (sessionId: string) =>
apiFetch<{ messages: AgentHistoryMessage[] }>(
`/agent/sessions/${encodeURIComponent(sessionId)}/history`,
);
// ─── Auth / current user ───────────────────────────────────────────────────
export interface CurrentUser {
username: string;
is_admin: boolean;
}
export const getCurrentUser = () => apiFetch<CurrentUser>('/me');
/**
* Log out by hitting /logout (which clears the cookie server-side and 302s
* to /login). We follow the redirect explicitly so the browser ends up on
* the login page with a fresh state.
*/
export async function logout(): Promise<void> {
// /logout is a GET that returns a redirect. apiFetch would throw because
// the redirect target /login returns HTML, not JSON. Use a bare fetch.
await fetch('/api/logout', { credentials: 'include', redirect: 'manual' });
// Force navigation regardless — the cookie is gone either way.
window.location.href = '/login';
}
// ─── Admin user CRUD ───────────────────────────────────────────────────────
export interface AdminUser {
id: number;
username: string;
is_admin: boolean;
created_at: string;
}
export const listAdminUsers = () =>
apiFetch<{ users: AdminUser[] }>('/api-admin/users');
export const createAdminUser = (username: string, password: string, isAdmin: boolean) =>
apiPost<{ ok: boolean; username: string }>('/api-admin/users', {
username, password, is_admin: isAdmin,
});
export const updateAdminUser = (
id: number,
body: { password?: string; is_admin?: boolean },
) =>
apiPatch<{ ok: boolean }>(`/api-admin/users/${id}`, body);
export const deleteAdminUser = (id: number) =>
apiDelete<{ ok: boolean }>(`/api-admin/users/${id}`);

View file

@ -1,8 +1,17 @@
import React from 'react';
import React, { useCallback } from 'react';
import { useWindowManager } from '../../contexts/WindowManagerContext';
import { useCurrentUser } from '../../hooks/useCurrentUser';
import { logout } from '../../api/endpoints';
export const SidebarWindowButtons: React.FC = () => {
const { openWindow } = useWindowManager();
const { user } = useCurrentUser();
const isAdmin = !!user?.is_admin;
const onLogout = useCallback(async () => {
if (!confirm('Log out?')) return;
try { await logout(); } catch { window.location.href = '/login'; }
}, []);
return (
<div className="ml-tool-links">
@ -18,6 +27,18 @@ export const SidebarWindowButtons: React.FC = () => {
onClick={() => openWindow('vitalsharing', 'Vital Sharing')}>🤝 Vitals</span>
<span className="ml-tool-link" style={{ cursor: 'pointer' }}
onClick={() => openWindow('combatpicker', 'Combat Stats')}> Combat</span>
{isAdmin && (
<span className="ml-tool-link" style={{ cursor: 'pointer' }}
onClick={() => openWindow('adminusers', 'Admin · Users')}>🛡 Admin</span>
)}
<span
className="ml-tool-link ml-tool-link-logout"
style={{ cursor: 'pointer' }}
onClick={onLogout}
title={user ? `Logged in as ${user.username}` : 'Log out'}
>
🚪 Log out{user ? ` (${user.username})` : ''}
</span>
</div>
);
};

View file

@ -0,0 +1,223 @@
import React, { useCallback, useEffect, useState } from 'react';
import { DraggableWindow } from './DraggableWindow';
import {
listAdminUsers,
createAdminUser,
updateAdminUser,
deleteAdminUser,
type AdminUser,
} from '../../api/endpoints';
import { useCurrentUser } from '../../hooks/useCurrentUser';
interface Props {
id: string;
zIndex: number;
}
function fmtCreated(iso: string): string {
try {
const d = new Date(iso);
return d.toISOString().slice(0, 10);
} catch {
return iso;
}
}
export const AdminUsersWindow: React.FC<Props> = ({ id, zIndex }) => {
const { user: me } = useCurrentUser();
const [users, setUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Add-user form state
const [newUsername, setNewUsername] = useState('');
const [newPassword, setNewPassword] = useState('');
const [newIsAdmin, setNewIsAdmin] = useState(false);
const [creating, setCreating] = useState(false);
// Per-row "reset password" state
const [pwEditingId, setPwEditingId] = useState<number | null>(null);
const [pwValue, setPwValue] = useState('');
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await listAdminUsers();
setUsers(res.users ?? []);
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => { void refresh(); }, [refresh]);
const onCreate = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
if (!newUsername.trim() || newPassword.length < 4) {
setError('Username required and password must be at least 4 chars');
return;
}
setCreating(true);
setError(null);
try {
await createAdminUser(newUsername.trim(), newPassword, newIsAdmin);
setNewUsername(''); setNewPassword(''); setNewIsAdmin(false);
await refresh();
} catch (e) {
setError(String(e));
} finally {
setCreating(false);
}
}, [newUsername, newPassword, newIsAdmin, refresh]);
const onToggleAdmin = useCallback(async (u: AdminUser) => {
setError(null);
try {
await updateAdminUser(u.id, { is_admin: !u.is_admin });
await refresh();
} catch (e) {
setError(String(e));
}
}, [refresh]);
const onSavePassword = useCallback(async (id: number) => {
if (pwValue.length < 4) {
setError('Password must be at least 4 characters');
return;
}
setError(null);
try {
await updateAdminUser(id, { password: pwValue });
setPwEditingId(null);
setPwValue('');
} catch (e) {
setError(String(e));
}
}, [pwValue]);
const onDelete = useCallback(async (u: AdminUser) => {
if (!confirm(`Delete user "${u.username}"? This cannot be undone.`)) return;
setError(null);
try {
await deleteAdminUser(u.id);
await refresh();
} catch (e) {
setError(String(e));
}
}, [refresh]);
return (
<DraggableWindow id={id} title="🛡️ Admin · Users" zIndex={zIndex} width={620} height={540}>
<div className="ml-admin">
{error && <div className="ml-admin-error">{error}</div>}
<section className="ml-admin-section">
<h3>Add user</h3>
<form onSubmit={onCreate} className="ml-admin-create">
<input
type="text"
placeholder="Username"
value={newUsername}
onChange={e => setNewUsername(e.target.value)}
disabled={creating}
autoComplete="off"
/>
<input
type="password"
placeholder="Password (min 4)"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
disabled={creating}
autoComplete="new-password"
/>
<label>
<input
type="checkbox"
checked={newIsAdmin}
onChange={e => setNewIsAdmin(e.target.checked)}
disabled={creating}
/>
admin
</label>
<button type="submit" disabled={creating || !newUsername.trim() || newPassword.length < 4}>
{creating ? 'Adding…' : 'Add'}
</button>
</form>
</section>
<section className="ml-admin-section">
<h3>Users {loading && <span className="ml-admin-muted">(loading)</span>}</h3>
<table className="ml-admin-table">
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Admin</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{users.map(u => {
const isMe = me != null && me.username.toLowerCase() === u.username.toLowerCase();
return (
<tr key={u.id}>
<td>{u.id}</td>
<td>
{u.username}
{isMe && <span className="ml-admin-muted"> (you)</span>}
</td>
<td>
<button
className="ml-admin-toggle"
onClick={() => onToggleAdmin(u)}
title="Click to toggle admin"
>
{u.is_admin ? '✓' : ''}
</button>
</td>
<td>{fmtCreated(u.created_at)}</td>
<td>
{pwEditingId === u.id ? (
<span className="ml-admin-pw-edit">
<input
type="text"
placeholder="New password"
value={pwValue}
onChange={e => setPwValue(e.target.value)}
autoFocus
/>
<button onClick={() => onSavePassword(u.id)}>Save</button>
<button onClick={() => { setPwEditingId(null); setPwValue(''); }}>
Cancel
</button>
</span>
) : (
<>
<button onClick={() => { setPwEditingId(u.id); setPwValue(''); }}>
Reset PW
</button>
{!isMe && (
<button className="ml-admin-danger" onClick={() => onDelete(u)}>
Delete
</button>
)}
</>
)}
</td>
</tr>
);
})}
{users.length === 0 && !loading && (
<tr><td colSpan={5} className="ml-admin-muted">No users.</td></tr>
)}
</tbody>
</table>
</section>
</div>
</DraggableWindow>
);
};

View file

@ -12,6 +12,7 @@ const VitalSharingWindow = lazy(() => import('./VitalSharingWindow').then(m => (
const QuestStatusWindow = lazy(() => import('./QuestStatusWindow').then(m => ({ default: m.QuestStatusWindow })));
const PlayerDashboardWindow = lazy(() => import('./PlayerDashboardWindow').then(m => ({ default: m.PlayerDashboardWindow })));
const AgentWindow = lazy(() => import('./AgentWindow').then(m => ({ default: m.AgentWindow })));
const AdminUsersWindow = lazy(() => import('./AdminUsersWindow').then(m => ({ default: m.AdminUsersWindow })));
import type { CharacterState } from '../../types';
interface Props {
@ -65,6 +66,8 @@ export const WindowRenderer: React.FC<Props> = React.memo(({ characters, chatMes
return <PlayerDashboardWindow key={w.id} id={w.id} zIndex={w.zIndex} characters={characters} />;
case 'agent':
return <AgentWindow key={w.id} id={w.id} zIndex={w.zIndex} />;
case 'adminusers':
return <AdminUsersWindow key={w.id} id={w.id} zIndex={w.zIndex} />;
default:
return null;
}

View file

@ -0,0 +1,25 @@
import { useEffect, useState } from 'react';
import { getCurrentUser, type CurrentUser } from '../api/endpoints';
/**
* Returns the currently-logged-in dashboard user, or null if not logged in /
* not yet loaded. Useful for conditionally showing admin-only UI bits.
*
* Fetches `/me` once on mount. Cheap the endpoint just decodes the
* session cookie and returns {username, is_admin}.
*/
export function useCurrentUser(): { user: CurrentUser | null; loading: boolean } {
const [user, setUser] = useState<CurrentUser | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
getCurrentUser()
.then(u => { if (!cancelled) setUser(u); })
.catch(() => { if (!cancelled) setUser(null); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, []);
return { user, loading };
}

View file

@ -810,6 +810,122 @@
100% { transform: translate(var(--dx), var(--dy)) scale(0); opacity: 0; }
}
/* ── Sidebar logout link — visually distinct from window-opener links ── */
.ml-tool-link-logout {
margin-top: 4px;
color: #d88;
border-top: 1px dashed #444;
padding-top: 4px;
font-size: 0.78rem;
}
.ml-tool-link-logout:hover { color: #f88; background: rgba(150, 60, 60, 0.15); }
/* ── Admin · Users window ──────────────────────────────── */
.ml-admin {
display: flex;
flex-direction: column;
height: 100%;
padding: 8px 10px;
gap: 10px;
font-size: 0.85rem;
overflow-y: auto;
}
.ml-admin-section {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 4px;
padding: 8px 10px;
}
.ml-admin-section h3 {
margin: 0 0 6px;
font-size: 0.9rem;
color: #cfcfff;
font-weight: 600;
}
.ml-admin-error {
background: #3a1c1c;
border: 1px solid #803333;
color: #ffaaaa;
padding: 6px 9px;
border-radius: 4px;
font-family: monospace;
font-size: 0.78rem;
white-space: pre-wrap;
}
.ml-admin-muted { color: #888; font-style: italic; }
.ml-admin-create {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.ml-admin-create input[type=text],
.ml-admin-create input[type=password] {
background: #111;
color: #eee;
border: 1px solid #444;
border-radius: 3px;
padding: 4px 7px;
font-size: 0.82rem;
flex: 1 1 140px;
min-width: 100px;
}
.ml-admin-create label {
display: inline-flex;
align-items: center;
gap: 4px;
color: #ccc;
font-size: 0.8rem;
}
.ml-admin button {
background: #2a2a3a;
color: #ddd;
border: 1px solid #444;
border-radius: 3px;
padding: 3px 9px;
font-size: 0.78rem;
cursor: pointer;
margin-right: 4px;
}
.ml-admin button:hover:not(:disabled) { background: #353550; border-color: #88f; }
.ml-admin button:disabled { opacity: 0.45; cursor: not-allowed; }
.ml-admin-danger { color: #ffaaaa; border-color: #803333 !important; }
.ml-admin-danger:hover:not(:disabled) { background: #3a1c1c !important; }
.ml-admin-table {
width: 100%;
border-collapse: collapse;
font-size: 0.78rem;
}
.ml-admin-table th, .ml-admin-table td {
text-align: left;
padding: 4px 6px;
border-bottom: 1px solid #2a2a2a;
vertical-align: middle;
}
.ml-admin-table th { color: #aaa; font-weight: 600; text-transform: uppercase; font-size: 0.68rem; letter-spacing: 0.04em; }
.ml-admin-table tbody tr:hover { background: rgba(255, 255, 255, 0.025); }
.ml-admin-toggle {
font-family: monospace;
font-weight: bold;
min-width: 28px;
text-align: center;
}
.ml-admin-pw-edit {
display: inline-flex;
gap: 4px;
align-items: center;
}
.ml-admin-pw-edit input {
background: #111;
color: #eee;
border: 1px solid #88f;
border-radius: 3px;
padding: 3px 6px;
font-family: monospace;
font-size: 0.78rem;
width: 160px;
}
/* ── Mobile ───────────────────────────────────────────── */
@media (max-width: 768px) {
.ml-layout { flex-direction: column; }