Public Access
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:
1 parent
62af83ce53
commit
c64cdcd9e3
46 files changed
+2227
-1125
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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('')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user