feat: Implement API modules for admin, auth, exam, result, and setup functionalities

- Created adminApi.ts for admin-related operations including user management and site settings.
- Developed authApi.ts for authentication processes such as registration, login, and profile management.
- Added examApi.ts for handling exam CRUD operations.
- Introduced resultApi.ts for managing results submission and retrieval.
- Established setupApi.ts for initial setup processes and status checks.
- Created ErrorBoundary component to handle rendering errors gracefully in the frontend.
- Re-exported shared API utilities in index.ts for easier access.
This commit is contained in:
LaurensBartelds committed 2026-04-26 20:10:46 +02:00
1 parent 62af83ce53
commit c64cdcd9e3
46 files changed
+2227 -1125

No files matched your search

+13 -71
View File
@@ -1,49 +1,6 @@
import { useState, useEffect, useCallback } from "react";
import { apiFetch } from "../utils/api";
interface SiteSettings {
site_name: string;
max_upload_mb: string;
allow_guest_mode: string;
registration_enabled: string;
default_theme: string;
maintenance_mode: string;
maintenance_message: string;
smtp_host: string;
smtp_port: string;
smtp_user: string;
smtp_pass: string;
smtp_from: string;
smtp_secure: string;
app_url: string;
}
interface DashboardStats {
totalUsers: number;
totalExams: number;
totalResults: number;
averageScore: number;
recentResults: {
id: number;
title: string;
score_points: number;
total_points: number;
completed_at: number;
display_name: string;
email: string;
}[];
}
interface AdminUser {
id: number;
email: string;
display_name: string;
role: "user" | "admin";
email_verified: number;
created_at: number;
results_count: number;
exams_count: number;
}
import * as adminApi from "../api/adminApi";
import type { DashboardStats, SiteSettings, AdminUser } from "../api/adminApi";
type AdminTab = "overview" | "settings" | "users";
@@ -72,7 +29,7 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
const loadStats = useCallback(async () => {
try {
const data = await apiFetch<DashboardStats>("/api/admin/stats");
const data = await adminApi.getStats();
setStats(data);
} catch (e) {
setError((e as Error).message);
@@ -81,9 +38,7 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
const loadSettings = useCallback(async () => {
try {
const data = await apiFetch<{ settings: SiteSettings }>(
"/api/admin/settings",
);
const data = await adminApi.getSettings();
setSettings(data.settings);
setSettingsDraft(data.settings);
} catch (e) {
@@ -93,10 +48,7 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
const loadUsers = useCallback(async (search = "") => {
try {
const q = search ? `?search=${encodeURIComponent(search)}` : "";
const data = await apiFetch<{ users: AdminUser[]; total: number }>(
`/api/admin/users${q}`,
);
const data = await adminApi.listUsers(search);
setUsers(data.users);
setUsersTotal(data.total);
} catch (e) {
@@ -115,10 +67,7 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
setSaving(true);
setSaveMsg(null);
try {
await apiFetch("/api/admin/settings", {
method: "PUT",
body: JSON.stringify({ settings: settingsDraft }),
});
await adminApi.updateSettings(settingsDraft);
setSettings(settingsDraft);
setSaveMsg("Settings saved successfully");
setTimeout(() => setSaveMsg(null), 3000);
@@ -129,21 +78,18 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
}
};
const changeRole = async (userId: number, newRole: "user" | "admin") => {
const handleChangeRole = async (userId: number, newRole: "user" | "admin") => {
try {
await apiFetch(`/api/admin/users/${userId}/role`, {
method: "PATCH",
body: JSON.stringify({ role: newRole }),
});
await adminApi.changeRole(userId, newRole);
await loadUsers(userSearch);
} catch (e) {
setError((e as Error).message);
}
};
const deleteUser = async (userId: number) => {
const handleDeleteUser = async (userId: number) => {
try {
await apiFetch(`/api/admin/users/${userId}`, { method: "DELETE" });
await adminApi.deleteUser(userId);
setDeleteConfirm(null);
await loadUsers(userSearch);
await loadStats();
@@ -157,17 +103,13 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
setTestEmailSending(true);
setTestEmailMsg(null);
try {
const smtp = {
await adminApi.sendTestEmail(testEmailTo || undefined, {
host: settingsDraft.smtp_host,
port: settingsDraft.smtp_port,
user: settingsDraft.smtp_user,
pass: settingsDraft.smtp_pass,
from: settingsDraft.smtp_from,
secure: settingsDraft.smtp_secure,
};
await apiFetch<{ message: string }>("/api/admin/test-email", {
method: "POST",
body: JSON.stringify({ to: testEmailTo || undefined, smtp }),
});
setTestEmailMsg({
ok: true,
@@ -784,7 +726,7 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
className="admin-role-select"
value={u.role}
onChange={(e) =>
void changeRole(
void handleChangeRole(
u.id,
e.target.value as "user" | "admin",
)
@@ -797,7 +739,7 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<div className="admin-delete-confirm">
<button
className="btn btn-sm admin-btn-danger"
onClick={() => void deleteUser(u.id)}
onClick={() => void handleDeleteUser(u.id)}
>
Confirm
</button>
+3 -2
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from 'react'
import { apiFetch, ApiError } from '../utils/api'
import { ApiError } from '../utils/api'
import * as authApi from '../api/authApi'
interface Props {
onDone: () => void
@@ -23,7 +24,7 @@ export default function EmailVerify({ onDone }: Readonly<Props>) {
return
}
apiFetch<{ message: string }>(`/api/auth/verify-email?token=${encodeURIComponent(token)}`)
authApi.verifyEmailToken(token)
.then((data) => {
setStatus('success')
setMessage(data.message)
+56
View File
@@ -0,0 +1,56 @@
import { Component, type ReactNode, type ErrorInfo } from 'react'
interface Props {
children: ReactNode
fallback?: ReactNode
}
interface State {
hasError: boolean
error?: Error
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
override componentDidCatch(error: Error, info: ErrorInfo) {
console.error('ErrorBoundary caught error:', error, info)
}
override render() {
if (this.state.hasError) {
return (
this.props.fallback ?? (
<div style={{ padding: 40, textAlign: 'center' }}>
<h2>Something went wrong</h2>
<p style={{ color: '#666' }}>
{this.state.error?.message ?? 'An unexpected error occurred'}
</p>
<button
onClick={() => window.location.reload()}
style={{
marginTop: 16,
padding: '10px 20px',
background: '#1a1a2e',
color: '#fff',
border: 'none',
borderRadius: 8,
cursor: 'pointer',
}}
>
Reload page
</button>
</div>
)
)
}
return this.props.children
}
}
+6 -12
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { apiFetch } from '../utils/api'
import type { Exam, Question, QuestionType, Choice, MatchItem } from '../types'
import * as examApi from '../api/examApi'
import type { Exam, Question, QuestionType, Choice } from '../types'
const QUESTION_TYPES: { value: QuestionType; label: string }[] = [
{ value: 'multiple-choice', label: 'Multiple Choice' },
@@ -61,9 +61,9 @@ export default function ExamEditor() {
useEffect(() => {
if (isNew) return
setLoading(true)
apiFetch<{ exam: Exam }>(`/api/exams/${examId}`)
examApi.getExam(examId!)
.then((data) => {
setExam(data.exam)
setExam(data)
setHasChanges(false)
})
.catch((e) => setError((e as Error).message))
@@ -111,18 +111,12 @@ export default function ExamEditor() {
setError(null)
try {
if (isNew) {
const data = await apiFetch<{ id: number }>('/api/exams', {
method: 'POST',
body: JSON.stringify({ exam }),
})
const data = await examApi.createExam(exam)
setSaveMsg('Exam created successfully')
setHasChanges(false)
navigate(`/exam-editor/${data.id}`, { replace: true })
} else {
await apiFetch(`/api/exams/${examId}`, {
method: 'PUT',
body: JSON.stringify({ exam }),
})
await examApi.updateExam(examId!, exam)
setSaveMsg('Exam saved')
setHasChanges(false)
}
+3 -4
View File
@@ -1,7 +1,6 @@
import { useState, useEffect, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { apiFetch } from '../utils/api'
import type { Exam } from '../types'
import * as examApi from '../api/examApi'
interface SavedExam {
id: number
@@ -23,7 +22,7 @@ export default function MyExams() {
setLoading(true)
setError(null)
try {
const data = await apiFetch<SavedExam[]>('/api/exams')
const data = await examApi.listExams()
setExams(data)
} catch (e) {
setError((e as Error).message)
@@ -38,7 +37,7 @@ export default function MyExams() {
const deleteExam = async (id: number) => {
try {
await apiFetch(`/api/exams/${id}`, { method: 'DELETE' })
await examApi.deleteExam(id)
setDeleteConfirm(null)
void loadExams()
} catch (e) {
+9 -19
View File
@@ -1,8 +1,7 @@
import { useState, useEffect, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { apiFetch } from '../utils/api'
import * as resultApi from '../api/resultApi'
import { getExamUrlId } from '../utils/slug'
import type { Exam, ExamState } from '../types'
interface ResultItem {
id: number
@@ -29,7 +28,7 @@ export default function MyResults() {
setLoading(true)
setError(null)
try {
const data = await apiFetch<{ results: ResultItem[]; total: number }>('/api/results')
const data = await resultApi.listResults()
setResults(data.results)
setTotal(data.total)
} catch (e) {
@@ -45,7 +44,7 @@ export default function MyResults() {
const deleteResult = async (id: number) => {
try {
await apiFetch(`/api/results/${id}`, { method: 'DELETE' })
await resultApi.deleteResult(id)
setDeleteConfirm(null)
void loadResults()
} catch (e) {
@@ -55,26 +54,17 @@ export default function MyResults() {
const reviewResult = async (item: ResultItem) => {
try {
const data = await apiFetch<{
exam: Exam
state: ExamState
title: string
scorePoints: number
totalPoints: number
correctCount: number
gradedCount: number
passingScore: number
elapsedMs: number
completedAt: number
}>(`/api/results/${item.id}`)
const data = await resultApi.getResult(item.id)
// Store in session for review - we'll navigate to review route
// Use localStorage as a bridge since review expects exam/examState in App state
localStorage.setItem('brainwave.review.exam', JSON.stringify(data.exam))
const completedAt = (data as unknown as Record<string, unknown>).completed_at
const state = data.state as Record<string, unknown> | undefined
localStorage.setItem('brainwave.review.state', JSON.stringify({
...data.state,
completedAt: data.completedAt * 1000,
...(state || {}),
completedAt: typeof completedAt === 'number' ? completedAt * 1000 : Date.now(),
}))
navigate(`/review/${getExamUrlId(data.exam)}`)
navigate(`/review/${getExamUrlId(data.exam as unknown as { title: string; id?: string })}`)
} catch (e) {
setError((e as Error).message)
}
+4 -9
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'
import { apiFetch, ApiError } from '../utils/api'
import { ApiError } from '../utils/api'
import * as authApi from '../api/authApi'
interface Props {
onDone: () => void
@@ -32,10 +33,7 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
setError(null)
setSubmitting(true)
try {
const data = await apiFetch<{ message: string }>('/api/auth/forgot-password', {
method: 'POST',
body: JSON.stringify({ email }),
})
const data = await authApi.forgotPassword(email)
setMessage(data.message)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Request failed')
@@ -53,10 +51,7 @@ export default function ResetPassword({ onDone }: Readonly<Props>) {
}
setSubmitting(true)
try {
await apiFetch('/api/auth/reset-password', {
method: 'POST',
body: JSON.stringify({ token, newPassword }),
})
await authApi.resetPassword({ token: token!, newPassword })
setStep('success')
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Password reset failed')
+3 -9
View File
@@ -1,6 +1,6 @@
import { useState, useCallback } from 'react'
import { useAuth } from '../contexts/AuthContext'
import { apiFetch } from '../utils/api'
import * as authApi from '../api/authApi'
export default function Settings() {
const { user, refreshUser } = useAuth()
@@ -23,10 +23,7 @@ export default function Settings() {
setSavingName(true)
setNameMsg(null)
try {
await apiFetch('/api/auth/me', {
method: 'PATCH',
body: JSON.stringify({ displayName: trimmed }),
})
await authApi.updateProfile({ displayName: trimmed })
setNameMsg({ ok: true, text: 'Display name updated' })
await refreshUser()
} catch (e) {
@@ -48,10 +45,7 @@ export default function Settings() {
setSavingPassword(true)
setPasswordMsg(null)
try {
await apiFetch('/api/auth/change-password', {
method: 'POST',
body: JSON.stringify({ currentPassword, newPassword }),
})
await authApi.changePassword({ currentPassword, newPassword })
setPasswordMsg({ ok: true, text: 'Password changed successfully' })
setCurrentPassword('')
setNewPassword('')
+13 -24
View File
@@ -1,5 +1,6 @@
import { useState } from 'react'
import { apiFetch, setToken } from '../utils/api'
import { setToken } from '../utils/api'
import * as setupApi from '../api/setupApi'
interface Props {
onComplete: () => void
@@ -51,29 +52,17 @@ export default function SetupWizard({ onComplete }: Readonly<Props>) {
setError(null)
setSubmitting(true)
try {
const data = await apiFetch<{
message: string
token: string
user: {
id: number
email: string
displayName: string
role: 'user' | 'admin'
}
}>('/api/setup', {
method: 'POST',
body: JSON.stringify({
email,
password,
displayName: displayName.trim(),
smtpHost,
smtpPort,
smtpUser,
smtpPass,
smtpFrom: smtpFrom || 'BrainWave <noreply@brainwave.local>',
smtpSecure,
appUrl,
}),
const data = await setupApi.completeSetup({
email,
password,
displayName: displayName.trim(),
smtpHost,
smtpPort,
smtpUser,
smtpPass,
smtpFrom: smtpFrom || 'BrainWave <noreply@brainwave.local>',
smtpSecure,
appUrl,
})
setToken(data.token)
onComplete()
+4 -3
View File
@@ -1,7 +1,8 @@
import { useState, useEffect, useCallback } from 'react'
import { useNavigate, useParams, Navigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import { apiFetch } from '../utils/api'
import * as examApi from '../api/examApi'
import * as resultApi from '../api/resultApi'
import MyExams from './MyExams'
import MyResults from './MyResults'
import Settings from './Settings'
@@ -24,9 +25,9 @@ export default function Workspace() {
const loadCounts = useCallback(async () => {
try {
const exams = await apiFetch<{ id: number }[]>('/api/exams')
const exams = await examApi.listExams()
setExamCount(exams.length)
const results = await apiFetch<{ results: unknown[]; total: number }>('/api/results')
const results = await resultApi.listResults()
setResultCount(results.total)
} catch {
// silently ignore