diff --git a/frontend/src/api.js b/frontend/src/api.js index d4cbd8e..8c5d9bb 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -112,6 +112,62 @@ export async function fetchMe() { return r.json(); } +// ── Admin helpers ───────────────────────────────────────────────────────────── + +export async function listAdminUsers() { + return getJson('/api/admin/users'); +} + +export async function createAdminUser(username, password, email) { + const res = await fetch('/api/admin/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password, email: email || undefined }), + }); + if (!res.ok) { const b = await res.json().catch(() => ({})); throw new Error(b.error || `HTTP ${res.status}`); } + return res.json(); +} + +export async function setUserStatus(userUid, status) { + const res = await fetch(`/api/admin/users/${userUid}/status`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status }), + }); + if (!res.ok) { const b = await res.json().catch(() => ({})); throw new Error(b.error || `HTTP ${res.status}`); } + return res.json(); +} + +export async function assignRole(userUid, roleSlug) { + const res = await fetch(`/api/admin/users/${userUid}/roles`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ role_slug: roleSlug }), + }); + if (!res.ok) { const b = await res.json().catch(() => ({})); throw new Error(b.error || `HTTP ${res.status}`); } +} + +export async function removeRole(userUid, roleSlug) { + const res = await fetch(`/api/admin/users/${userUid}/roles/${encodeURIComponent(roleSlug)}`, { + method: 'DELETE', + }); + if (!res.ok && res.status !== 204) { const b = await res.json().catch(() => ({})); throw new Error(b.error || `HTTP ${res.status}`); } +} + +export async function listRoles() { + return getJson('/api/admin/roles'); +} + +export async function createRole(slug, name) { + const res = await fetch('/api/admin/roles', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slug, name }), + }); + if (!res.ok) { const b = await res.json().catch(() => ({})); throw new Error(b.error || `HTTP ${res.status}`); } + return res.json(); +} + // ── 401 interceptor ─────────────────────────────────────────────────────────── const _origFetch = window.fetch; window.fetch = async (...args) => { diff --git a/frontend/src/components/AdminView.jsx b/frontend/src/components/AdminView.jsx index f9520c2..7abd8b2 100644 --- a/frontend/src/components/AdminView.jsx +++ b/frontend/src/components/AdminView.jsx @@ -1,15 +1,218 @@ +import { useState, useEffect, useContext, useCallback } from 'react' +import { AuthContext } from '../App.jsx' +import { + listAdminUsers, createAdminUser, setUserStatus, assignRole, removeRole, + listRoles, createRole +} from '../api.js' + +const ROLE_ADMIN = 4 + export default function AdminView({ archives }) { + const { currentUser } = useContext(AuthContext) ?? {} + const isAdmin = currentUser && (currentUser.role_bits & ROLE_ADMIN) !== 0 + + const [tab, setTab] = useState('users') + const [users, setUsers] = useState([]) + const [roles, setRoles] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + // Create user form state + const [newUsername, setNewUsername] = useState('') + const [newPassword, setNewPassword] = useState('') + const [newEmail, setNewEmail] = useState('') + const [createError, setCreateError] = useState(null) + const [creating, setCreating] = useState(false) + + // Create role form state + const [newRoleSlug, setNewRoleSlug] = useState('') + const [newRoleName, setNewRoleName] = useState('') + const [roleCreateError, setRoleCreateError] = useState(null) + const [creatingRole, setCreatingRole] = useState(false) + + const refresh = useCallback(async () => { + if (!isAdmin) return + setLoading(true) + setError(null) + try { + const [u, r] = await Promise.all([listAdminUsers(), listRoles()]) + setUsers(u) + setRoles(r) + } catch (e) { + setError(e.message) + } finally { + setLoading(false) + } + }, [isAdmin]) + + useEffect(() => { refresh() }, [refresh]) + + async function handleToggleStatus(user) { + const next = user.status === 'active' ? 'disabled' : 'active' + try { + await setUserStatus(user.user_uid, next) + setUsers(us => us.map(u => u.user_uid === user.user_uid ? { ...u, status: next } : u)) + } catch (e) { + setError(e.message) + } + } + + async function handleCreateUser(e) { + e.preventDefault() + if (!newUsername.trim() || !newPassword) { setCreateError('Username and password required'); return } + setCreating(true) + setCreateError(null) + try { + await createAdminUser(newUsername.trim(), newPassword, newEmail.trim() || undefined) + setNewUsername('') + setNewPassword('') + setNewEmail('') + await refresh() + } catch (e) { + setCreateError(e.message) + } finally { + setCreating(false) + } + } + + async function handleCreateRole(e) { + e.preventDefault() + if (!newRoleSlug.trim() || !newRoleName.trim()) { setRoleCreateError('Slug and name required'); return } + setCreatingRole(true) + setRoleCreateError(null) + try { + await createRole(newRoleSlug.trim(), newRoleName.trim()) + setNewRoleSlug('') + setNewRoleName('') + await refresh() + } catch (e) { + setRoleCreateError(e.message) + } finally { + setCreatingRole(false) + } + } + + if (!isAdmin) { + return ( +
+

Admin

+

You need admin privileges to access this panel.

+

Mounted Archives

+
+ {archives.map(a => ( +
+ {a.label} +
{a.archive_path}
+
+ ))} +
+
+ ) + } + return (
-

Mounted Archives

-
- {archives.map(archive => ( -
- {archive.label} -
{archive.archive_path}
-
- ))} +

Admin

+ +
+ + +
+ + {error &&
{error}
} + + {tab === 'users' && ( +
+

Users

+ {loading ?

Loading…

: ( + + + + + + {users.map(u => ( + + + + + + + + ))} + +
UsernameEmailRolesStatusActions
{u.username}{u.email || '—'}{u.role_slugs.join(', ') || '—'}{u.status} + +
+ )} + +

Create User

+
+ setNewUsername(e.target.value)} required /> + setNewPassword(e.target.value)} required /> + setNewEmail(e.target.value)} /> + {createError &&
{createError}
} + +
+
+ )} + + {tab === 'roles' && ( +
+

Roles

+ {loading ?

Loading…

: ( + + + + + + {roles.map(r => ( + + + + + + + + ))} + +
SlugNameLevelBitBuilt-in
{r.slug}{r.name}{r.level}{r.bit_position}{r.is_builtin ? '✓' : ''}
+ )} + +

Create Custom Role

+
+ setNewRoleSlug(e.target.value)} required /> + setNewRoleName(e.target.value)} required /> + {roleCreateError &&
{roleCreateError}
} + +
+
+ )} + + {tab === 'archives' && ( +
+

Mounted Archives

+
+ {archives.map(a => ( +
+ {a.label} +
{a.archive_path}
+
+ ))} +
+
+ )}
) }