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

@ -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;
}