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
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue