mirror of
https://github.com/thegeneralist01/archivr
synced 2026-07-22 03:05:32 +02:00
Merge branch 'task/user-mgmt-frontend'
This commit is contained in:
commit
6357cc518e
2 changed files with 267 additions and 8 deletions
|
|
@ -112,6 +112,62 @@ export async function fetchMe() {
|
||||||
return r.json();
|
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 ───────────────────────────────────────────────────────────
|
// ── 401 interceptor ───────────────────────────────────────────────────────────
|
||||||
const _origFetch = window.fetch;
|
const _origFetch = window.fetch;
|
||||||
window.fetch = async (...args) => {
|
window.fetch = async (...args) => {
|
||||||
|
|
|
||||||
|
|
@ -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 }) {
|
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 (
|
return (
|
||||||
<section id="admin-view" className="view admin-view is-active">
|
<section id="admin-view" className="view admin-view is-active">
|
||||||
<h1>Mounted Archives</h1>
|
<h1>Admin</h1>
|
||||||
|
<p className="muted">You need admin privileges to access this panel.</p>
|
||||||
|
<h2>Mounted Archives</h2>
|
||||||
<div className="admin-list">
|
<div className="admin-list">
|
||||||
{archives.map(archive => (
|
{archives.map(a => (
|
||||||
<div key={archive.id} className="admin-archive">
|
<div key={a.id} className="admin-archive">
|
||||||
<strong>{archive.label}</strong>
|
<strong>{a.label}</strong>
|
||||||
<div className="muted">{archive.archive_path}</div>
|
<div className="muted">{a.archive_path}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="admin-view" className="view admin-view is-active">
|
||||||
|
<h1>Admin</h1>
|
||||||
|
|
||||||
|
<div className="admin-tabs">
|
||||||
|
<button className={`admin-tab${tab === 'users' ? ' is-active' : ''}`} onClick={() => setTab('users')}>Users</button>
|
||||||
|
<button className={`admin-tab${tab === 'roles' ? ' is-active' : ''}`} onClick={() => setTab('roles')}>Roles</button>
|
||||||
|
<button className={`admin-tab${tab === 'archives' ? ' is-active' : ''}`} onClick={() => setTab('archives')}>Archives</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="capture-error">{error}</div>}
|
||||||
|
|
||||||
|
{tab === 'users' && (
|
||||||
|
<div className="admin-section">
|
||||||
|
<h2>Users</h2>
|
||||||
|
{loading ? <p className="muted">Loading…</p> : (
|
||||||
|
<table className="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Username</th><th>Email</th><th>Roles</th><th>Status</th><th>Actions</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map(u => (
|
||||||
|
<tr key={u.user_uid} className={u.status === 'disabled' ? 'admin-row-disabled' : ''}>
|
||||||
|
<td>{u.username}</td>
|
||||||
|
<td className="muted">{u.email || '—'}</td>
|
||||||
|
<td>{u.role_slugs.join(', ') || '—'}</td>
|
||||||
|
<td><span className={`status-badge status-${u.status}`}>{u.status}</span></td>
|
||||||
|
<td>
|
||||||
|
<button className="admin-action-btn" onClick={() => handleToggleStatus(u)}>
|
||||||
|
{u.status === 'active' ? 'Ban' : 'Unban'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h3>Create User</h3>
|
||||||
|
<form className="admin-form" onSubmit={handleCreateUser}>
|
||||||
|
<input className="admin-input" placeholder="Username" value={newUsername}
|
||||||
|
onChange={e => setNewUsername(e.target.value)} required />
|
||||||
|
<input className="admin-input" type="password" placeholder="Password (min 8 chars)"
|
||||||
|
value={newPassword} onChange={e => setNewPassword(e.target.value)} required />
|
||||||
|
<input className="admin-input" type="email" placeholder="Email (optional)"
|
||||||
|
value={newEmail} onChange={e => setNewEmail(e.target.value)} />
|
||||||
|
{createError && <div className="capture-error">{createError}</div>}
|
||||||
|
<button className="capture-submit" type="submit" disabled={creating}>
|
||||||
|
{creating ? 'Creating…' : 'Create User'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'roles' && (
|
||||||
|
<div className="admin-section">
|
||||||
|
<h2>Roles</h2>
|
||||||
|
{loading ? <p className="muted">Loading…</p> : (
|
||||||
|
<table className="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Slug</th><th>Name</th><th>Level</th><th>Bit</th><th>Built-in</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{roles.map(r => (
|
||||||
|
<tr key={r.role_uid}>
|
||||||
|
<td><code>{r.slug}</code></td>
|
||||||
|
<td>{r.name}</td>
|
||||||
|
<td>{r.level}</td>
|
||||||
|
<td>{r.bit_position}</td>
|
||||||
|
<td>{r.is_builtin ? '✓' : ''}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h3>Create Custom Role</h3>
|
||||||
|
<form className="admin-form" onSubmit={handleCreateRole}>
|
||||||
|
<input className="admin-input" placeholder="Slug (e.g. moderator)" value={newRoleSlug}
|
||||||
|
onChange={e => setNewRoleSlug(e.target.value)} required />
|
||||||
|
<input className="admin-input" placeholder="Display Name (e.g. Moderator)"
|
||||||
|
value={newRoleName} onChange={e => setNewRoleName(e.target.value)} required />
|
||||||
|
{roleCreateError && <div className="capture-error">{roleCreateError}</div>}
|
||||||
|
<button className="capture-submit" type="submit" disabled={creatingRole}>
|
||||||
|
{creatingRole ? 'Creating…' : 'Create Role'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'archives' && (
|
||||||
|
<div className="admin-section">
|
||||||
|
<h2>Mounted Archives</h2>
|
||||||
|
<div className="admin-list">
|
||||||
|
{archives.map(a => (
|
||||||
|
<div key={a.id} className="admin-archive">
|
||||||
|
<strong>{a.label}</strong>
|
||||||
|
<div className="muted">{a.archive_path}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue