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:
parent
88e9e88f46
commit
1c1c43d28b
23 changed files with 521 additions and 50 deletions
|
|
@ -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`;
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
223
frontend/src/components/windows/AdminUsersWindow.tsx
Normal file
223
frontend/src/components/windows/AdminUsersWindow.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
25
frontend/src/hooks/useCurrentUser.ts
Normal file
25
frontend/src/hooks/useCurrentUser.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue