feat: some changes and admin

This commit is contained in:
2026-03-31 16:09:03 +09:00
parent b5a1c08024
commit fd0f451e7c
54 changed files with 2156 additions and 8 deletions

View File

@@ -0,0 +1,147 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import style from '@/styles/admin.module.scss';
type ArchiveRow = {
id: number;
title: string;
youtube_id: string;
};
export function ArchiveAdminClient() {
const [items, setItems] = useState<ArchiveRow[]>([]);
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const [title, setTitle] = useState('');
const [youtubeId, setYoutubeId] = useState('');
const load = useCallback(async () => {
setError('');
const res = await fetch('/api/admin/archive', { credentials: 'include' });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError((data as { error?: string }).error ?? 'Failed to load');
setLoading(false);
return;
}
setItems((data as { items: ArchiveRow[] }).items ?? []);
setLoading(false);
}, []);
useEffect(() => {
load();
}, [load]);
function updateLocal(id: number, patch: Partial<ArchiveRow>) {
setItems((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)));
}
async function save(row: ArchiveRow) {
setError('');
const res = await fetch(`/api/admin/archive/${row.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ title: row.title, youtube_id: row.youtube_id }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError((data as { error?: string }).error ?? 'Save failed');
return;
}
await load();
}
async function remove(id: number) {
if (!window.confirm('Delete?')) return;
setError('');
const res = await fetch(`/api/admin/archive/${id}`, { method: 'DELETE', credentials: 'include' });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError((data as { error?: string }).error ?? 'Delete failed');
return;
}
await load();
}
async function add(e: React.FormEvent) {
e.preventDefault();
setError('');
const res = await fetch('/api/admin/archive', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ title, youtube_id: youtubeId }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError((data as { error?: string }).error ?? 'Create failed');
return;
}
setTitle('');
setYoutubeId('');
await load();
}
return (
<div className={style.card}>
<p>
<Link href="/admin"> Dashboard</Link>
</p>
<h1 className={style.title}>Archive</h1>
{error ? <p className={style.error}>{error}</p> : null}
{loading ? <p>Loading</p> : null}
{!loading &&
items.map((row) => (
<div key={row.id} style={{ marginBottom: 24, paddingBottom: 16, borderBottom: '1px solid #eee' }}>
<label className={style.label}>
title
<input
className={style.input}
value={row.title}
onChange={(e) => updateLocal(row.id, { title: e.target.value })}
/>
</label>
<label className={style.label}>
youtube_id
<input
className={style.input}
value={row.youtube_id}
onChange={(e) => updateLocal(row.id, { youtube_id: e.target.value })}
/>
</label>
<div className={style.rowActions}>
<button type="button" className={style.btn} onClick={() => save(row)}>
Save
</button>
<button type="button" className={style.btnDanger} onClick={() => remove(row.id)}>
Delete
</button>
</div>
</div>
))}
<hr className={style.hr} />
<h2 className={style.title} style={{ fontSize: '1.1rem' }}>
New entry
</h2>
<form className={style.form} style={{ maxWidth: 480 }} onSubmit={add}>
<label className={style.label}>
title
<input className={style.input} value={title} onChange={(e) => setTitle(e.target.value)} required />
</label>
<label className={style.label}>
youtube_id
<input className={style.input} value={youtubeId} onChange={(e) => setYoutubeId(e.target.value)} required />
</label>
<button className={style.btn} type="submit">
Add
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,8 @@
import { requireAdmin } from '@/lib/auth/requireAdmin';
import { ArchiveAdminClient } from './ArchiveAdminClient';
export default async function AdminArchivePage() {
await requireAdmin();
return <ArchiveAdminClient />;
}

View File

@@ -0,0 +1,170 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import style from '@/styles/admin.module.scss';
type GuideRow = {
id: number;
title: string;
description: string | null;
youtube_id: string;
};
export function GuideAdminClient() {
const [items, setItems] = useState<GuideRow[]>([]);
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [youtubeId, setYoutubeId] = useState('');
const load = useCallback(async () => {
setError('');
const res = await fetch('/api/admin/guide', { credentials: 'include' });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError((data as { error?: string }).error ?? 'Failed to load');
setLoading(false);
return;
}
setItems((data as { items: GuideRow[] }).items ?? []);
setLoading(false);
}, []);
useEffect(() => {
load();
}, [load]);
function updateLocal(id: number, patch: Partial<GuideRow>) {
setItems((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)));
}
async function save(row: GuideRow) {
setError('');
const res = await fetch(`/api/admin/guide/${row.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
title: row.title,
description: row.description ?? '',
youtube_id: row.youtube_id,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError((data as { error?: string }).error ?? 'Save failed');
return;
}
await load();
}
async function remove(id: number) {
if (!window.confirm('Delete this entry?')) return;
setError('');
const res = await fetch(`/api/admin/guide/${id}`, { method: 'DELETE', credentials: 'include' });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError((data as { error?: string }).error ?? 'Delete failed');
return;
}
await load();
}
async function add(e: React.FormEvent) {
e.preventDefault();
setError('');
const res = await fetch('/api/admin/guide', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ title, description, youtube_id: youtubeId }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError((data as { error?: string }).error ?? 'Create failed');
return;
}
setTitle('');
setDescription('');
setYoutubeId('');
await load();
}
return (
<div className={style.card}>
<p>
<Link href="/admin"> Dashboard</Link>
</p>
<h1 className={style.title}>Guide</h1>
{error ? <p className={style.error}>{error}</p> : null}
{loading ? <p>Loading</p> : null}
{!loading &&
items.map((row) => (
<div key={row.id} style={{ marginBottom: 24, paddingBottom: 16, borderBottom: '1px solid #eee' }}>
<label className={style.label}>
title
<input
className={style.input}
value={row.title}
onChange={(e) => updateLocal(row.id, { title: e.target.value })}
/>
</label>
<label className={style.label}>
description
<textarea
className={`${style.input} ${style.textarea}`}
value={row.description ?? ''}
onChange={(e) => updateLocal(row.id, { description: e.target.value })}
/>
</label>
<label className={style.label}>
youtube_id
<input
className={style.input}
value={row.youtube_id}
onChange={(e) => updateLocal(row.id, { youtube_id: e.target.value })}
/>
</label>
<div className={style.rowActions}>
<button type="button" className={style.btn} onClick={() => save(row)}>
Save
</button>
<button type="button" className={style.btnDanger} onClick={() => remove(row.id)}>
Delete
</button>
</div>
</div>
))}
<hr className={style.hr} />
<h2 className={style.title} style={{ fontSize: '1.1rem' }}>
New entry
</h2>
<form className={style.form} style={{ maxWidth: 480 }} onSubmit={add}>
<label className={style.label}>
title
<input className={style.input} value={title} onChange={(e) => setTitle(e.target.value)} required />
</label>
<label className={style.label}>
description
<textarea
className={`${style.input} ${style.textarea}`}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</label>
<label className={style.label}>
youtube_id
<input className={style.input} value={youtubeId} onChange={(e) => setYoutubeId(e.target.value)} required />
</label>
<button className={style.btn} type="submit">
Add
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,8 @@
import { requireAdmin } from '@/lib/auth/requireAdmin';
import { GuideAdminClient } from './GuideAdminClient';
export default async function AdminGuidePage() {
await requireAdmin();
return <GuideAdminClient />;
}

View File

@@ -0,0 +1,8 @@
import type { ReactNode } from 'react';
import { requireLogin } from '@/lib/auth/requireAdmin';
export default async function AdminDashboardLayout({ children }: { children: ReactNode }) {
await requireLogin();
return <>{children}</>;
}

View File

@@ -0,0 +1,49 @@
import Link from 'next/link';
import { LogoutButton } from '@/components/admin/LogoutButton';
import { getSessionUser } from '@/lib/auth/session';
import style from '@/styles/admin.module.scss';
export default async function AdminDashboardPage() {
const user = await getSessionUser();
if (!user) {
return null;
}
return (
<div className={style.card}>
<h1 className={style.title}>Admin</h1>
<p className={style.sub}>
Signed in as <strong>{user.email}</strong>
{user.is_admin ? ' (administrator)' : ''}
</p>
{!user.is_admin && (
<p className={style.error}>
You do not have permission to edit content. Ask an administrator to grant admin access.
</p>
)}
{user.is_admin && (
<ul style={{ lineHeight: 1.8 }}>
<li>
<Link href="/admin/users">Users (approval &amp; roles)</Link>
</li>
<li>
<Link href="/admin/players">Players</Link>
</li>
<li>
<Link href="/admin/guide">Guide</Link>
</li>
<li>
<Link href="/admin/archive">Archive</Link>
</li>
<li>
<Link href="/admin/qa">Q&amp;A</Link>
</li>
</ul>
)}
<div style={{ marginTop: 16 }}>
<LogoutButton />
</div>
</div>
);
}

View File

@@ -0,0 +1,184 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import style from '@/styles/admin.module.scss';
type Char = { id: number; name_id: string; name: string; name_jp: string };
type PlayerRow = {
id: number;
player_key: string;
player_name: string;
description: string | null;
image: string | null;
character_id: string | null;
name_id?: string;
name_jp?: string;
};
export function PlayersAdminClient() {
const [players, setPlayers] = useState<PlayerRow[]>([]);
const [characters, setCharacters] = useState<Char[]>([]);
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const [playerKey, setPlayerKey] = useState('');
const [playerName, setPlayerName] = useState('');
const [description, setDescription] = useState('');
const [image, setImage] = useState('');
const [characterId, setCharacterId] = useState('');
const load = useCallback(async () => {
setError('');
const [pr, cr] = await Promise.all([
fetch('/api/admin/players', { credentials: 'include' }),
fetch('/api/admin/characters', { credentials: 'include' }),
]);
const pj = await pr.json().catch(() => ({}));
const cj = await cr.json().catch(() => ({}));
if (!pr.ok) {
setError((pj as { error?: string }).error ?? 'Failed to load players');
setLoading(false);
return;
}
if (!cr.ok) {
setError((cj as { error?: string }).error ?? 'Failed to load characters');
setLoading(false);
return;
}
setPlayers((pj as { players: PlayerRow[] }).players ?? []);
setCharacters((cj as { characters: Char[] }).characters ?? []);
setLoading(false);
}, []);
useEffect(() => {
load();
}, [load]);
async function addPlayer(e: React.FormEvent) {
e.preventDefault();
setError('');
const res = await fetch('/api/admin/players', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
player_key: playerKey,
player_name: playerName,
description,
image: image || null,
character_id: characterId,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError((data as { error?: string }).error ?? 'Create failed');
return;
}
setPlayerKey('');
setPlayerName('');
setDescription('');
setImage('');
setCharacterId('');
await load();
}
async function remove(id: number) {
if (!window.confirm('Delete this player?')) return;
setError('');
const res = await fetch(`/api/admin/players/${id}`, { method: 'DELETE', credentials: 'include' });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError((data as { error?: string }).error ?? 'Delete failed');
return;
}
await load();
}
return (
<div className={style.card}>
<p>
<Link href="/admin"> Dashboard</Link>
</p>
<h1 className={style.title}>Players</h1>
{error ? <p className={style.error}>{error}</p> : null}
{loading ? <p>Loading</p> : null}
{!loading && (
<>
<table className={style.table}>
<thead>
<tr>
<th>Key</th>
<th>Name</th>
<th>Character</th>
<th />
</tr>
</thead>
<tbody>
{players.map((p) => (
<tr key={p.id}>
<td>{p.player_key}</td>
<td>{p.player_name}</td>
<td>{p.name_jp ?? p.character_id}</td>
<td>
<button type="button" className={style.btnDanger} onClick={() => remove(p.id)}>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
<hr className={style.hr} />
<h2 className={style.title} style={{ fontSize: '1.1rem' }}>
Add player
</h2>
<form className={style.form} style={{ maxWidth: 480 }} onSubmit={addPlayer}>
<label className={style.label}>
player_key
<input className={style.input} value={playerKey} onChange={(e) => setPlayerKey(e.target.value)} required />
</label>
<label className={style.label}>
player_name
<input className={style.input} value={playerName} onChange={(e) => setPlayerName(e.target.value)} required />
</label>
<label className={style.label}>
description
<textarea
className={`${style.input} ${style.textarea}`}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</label>
<label className={style.label}>
image URL (optional)
<input className={style.input} value={image} onChange={(e) => setImage(e.target.value)} />
</label>
<label className={style.label}>
character
<select
className={style.input}
value={characterId}
onChange={(e) => setCharacterId(e.target.value)}
required
>
<option value=""></option>
{characters.map((c) => (
<option key={c.id} value={c.id}>
{c.name_jp} ({c.name_id})
</option>
))}
</select>
</label>
<button className={style.btn} type="submit">
Add
</button>
</form>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,8 @@
import { requireAdmin } from '@/lib/auth/requireAdmin';
import { PlayersAdminClient } from './PlayersAdminClient';
export default async function AdminPlayersPage() {
await requireAdmin();
return <PlayersAdminClient />;
}

View File

@@ -0,0 +1,157 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import style from '@/styles/admin.module.scss';
type ContactRow = {
id: number;
question: string;
answer: string;
};
export function QaAdminClient() {
const [items, setItems] = useState<ContactRow[]>([]);
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const [question, setQuestion] = useState('');
const [answer, setAnswer] = useState('');
const load = useCallback(async () => {
setError('');
const res = await fetch('/api/admin/contact', { credentials: 'include' });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError((data as { error?: string }).error ?? 'Failed to load');
setLoading(false);
return;
}
setItems((data as { items: ContactRow[] }).items ?? []);
setLoading(false);
}, []);
useEffect(() => {
load();
}, [load]);
function updateLocal(id: number, patch: Partial<ContactRow>) {
setItems((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r)));
}
async function save(row: ContactRow) {
setError('');
const res = await fetch(`/api/admin/contact/${row.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ question: row.question, answer: row.answer }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError((data as { error?: string }).error ?? 'Save failed');
return;
}
await load();
}
async function remove(id: number) {
if (!window.confirm('Delete?')) return;
setError('');
const res = await fetch(`/api/admin/contact/${id}`, { method: 'DELETE', credentials: 'include' });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setError((data as { error?: string }).error ?? 'Delete failed');
return;
}
await load();
}
async function add(e: React.FormEvent) {
e.preventDefault();
setError('');
const res = await fetch('/api/admin/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ question, answer }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError((data as { error?: string }).error ?? 'Create failed');
return;
}
setQuestion('');
setAnswer('');
await load();
}
return (
<div className={style.card}>
<p>
<Link href="/admin"> Dashboard</Link>
</p>
<h1 className={style.title}>Q&amp;A</h1>
{error ? <p className={style.error}>{error}</p> : null}
{loading ? <p>Loading</p> : null}
{!loading &&
items.map((row) => (
<div key={row.id} style={{ marginBottom: 24, paddingBottom: 16, borderBottom: '1px solid #eee' }}>
<label className={style.label}>
question
<textarea
className={`${style.input} ${style.textarea}`}
value={row.question}
onChange={(e) => updateLocal(row.id, { question: e.target.value })}
/>
</label>
<label className={style.label}>
answer
<textarea
className={`${style.input} ${style.textarea}`}
value={row.answer}
onChange={(e) => updateLocal(row.id, { answer: e.target.value })}
/>
</label>
<div className={style.rowActions}>
<button type="button" className={style.btn} onClick={() => save(row)}>
Save
</button>
<button type="button" className={style.btnDanger} onClick={() => remove(row.id)}>
Delete
</button>
</div>
</div>
))}
<hr className={style.hr} />
<h2 className={style.title} style={{ fontSize: '1.1rem' }}>
New Q&amp;A
</h2>
<form className={style.form} style={{ maxWidth: 480 }} onSubmit={add}>
<label className={style.label}>
question
<textarea
className={`${style.input} ${style.textarea}`}
value={question}
onChange={(e) => setQuestion(e.target.value)}
required
/>
</label>
<label className={style.label}>
answer
<textarea
className={`${style.input} ${style.textarea}`}
value={answer}
onChange={(e) => setAnswer(e.target.value)}
required
/>
</label>
<button className={style.btn} type="submit">
Add
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,8 @@
import { requireAdmin } from '@/lib/auth/requireAdmin';
import { QaAdminClient } from './QaAdminClient';
export default async function AdminQaPage() {
await requireAdmin();
return <QaAdminClient />;
}

View File

@@ -0,0 +1,100 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import style from '@/styles/admin.module.scss';
type UserRow = {
id: number;
email: string;
is_approved: number | boolean;
is_admin: number | boolean;
created_at: string;
};
export function UsersAdminClient() {
const [users, setUsers] = useState<UserRow[]>([]);
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
setError('');
const res = await fetch('/api/admin/users', { credentials: 'include' });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError((data as { error?: string }).error ?? 'Failed to load');
setLoading(false);
return;
}
setUsers((data as { users: UserRow[] }).users ?? []);
setLoading(false);
}, []);
useEffect(() => {
load();
}, [load]);
async function patch(id: number, body: { is_approved?: boolean; is_admin?: boolean }) {
setError('');
const res = await fetch(`/api/admin/users/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(body),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError((data as { error?: string }).error ?? 'Update failed');
return;
}
await load();
}
return (
<div className={style.card}>
<p>
<Link href="/admin"> Dashboard</Link>
</p>
<h1 className={style.title}>Users</h1>
<p className={style.sub}>Approve new accounts and grant admin role.</p>
{error ? <p className={style.error}>{error}</p> : null}
{loading ? <p>Loading</p> : null}
{!loading && (
<table className={style.table}>
<thead>
<tr>
<th>Email</th>
<th>Approved</th>
<th>Admin</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td>{u.email}</td>
<td>
<input
type="checkbox"
checked={Boolean(u.is_approved)}
onChange={(e) => patch(u.id, { is_approved: e.target.checked })}
/>
</td>
<td>
<input
type="checkbox"
checked={Boolean(u.is_admin)}
onChange={(e) => patch(u.id, { is_admin: e.target.checked })}
/>
</td>
<td>{String(u.created_at)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}

View File

@@ -0,0 +1,8 @@
import { requireAdmin } from '@/lib/auth/requireAdmin';
import { UsersAdminClient } from './UsersAdminClient';
export default async function AdminUsersPage() {
await requireAdmin();
return <UsersAdminClient />;
}

View File

@@ -0,0 +1,25 @@
import type { Metadata } from 'next';
import type { ReactNode } from 'react';
import Link from 'next/link';
import style from '@/styles/admin.module.scss';
export const metadata: Metadata = {
title: 'Admin — Catherine League',
robots: 'noindex, nofollow',
};
export default function AdminRootLayout({ children }: { children: ReactNode }) {
return (
<div className={style.wrap}>
<nav className={style.nav}>
<Link href="/"> Site</Link>
<Link href="/admin/login">Login</Link>
<Link href="/admin/register">Register</Link>
<Link href="/admin">Dashboard</Link>
</nav>
{children}
</div>
);
}

View File

@@ -0,0 +1,78 @@
'use client';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import Link from 'next/link';
import style from '@/styles/admin.module.scss';
export default function AdminLoginPage() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
setLoading(true);
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
credentials: 'include',
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError((data as { error?: string }).error ?? 'Login failed');
setLoading(false);
return;
}
router.push('/admin');
router.refresh();
} catch {
setError('Network error');
}
setLoading(false);
}
return (
<div className={style.card}>
<h1 className={style.title}>Admin login</h1>
<p className={style.sub}>
No account? <Link href="/admin/register">Register</Link> an administrator must approve you before you can sign in.
</p>
<form className={style.form} onSubmit={onSubmit}>
<label className={style.label}>
Email
<input
className={style.input}
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</label>
<label className={style.label}>
Password
<input
className={style.input}
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</label>
{error ? <p className={style.error}>{error}</p> : null}
<button className={style.btn} type="submit" disabled={loading}>
{loading ? 'Signing in…' : 'Sign in'}
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,18 @@
import Link from 'next/link';
import style from '@/styles/admin.module.scss';
export default function AdminNoAccessPage() {
return (
<div className={style.card}>
<h1 className={style.title}>No admin access</h1>
<p className={style.sub}>
Your account is approved, but you do not have administrator privileges for content management. Ask an existing admin to
grant you the admin role.
</p>
<p>
<Link href="/admin">Dashboard</Link>
</p>
</div>
);
}

View File

@@ -0,0 +1,18 @@
import Link from 'next/link';
import style from '@/styles/admin.module.scss';
export default function AdminPendingPage() {
return (
<div className={style.card}>
<h1 className={style.title}>Account pending</h1>
<p className={style.sub}>
Your account has not been approved yet, or approval was revoked. You cannot use the admin area until an administrator
approves you.
</p>
<p>
<Link href="/admin/login">Back to login</Link>
</p>
</div>
);
}

View File

@@ -0,0 +1,81 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import style from '@/styles/admin.module.scss';
export default function AdminRegisterPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [loading, setLoading] = useState(false);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
setSuccess('');
setLoading(true);
try {
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError((data as { error?: string }).error ?? 'Registration failed');
setLoading(false);
return;
}
setSuccess((data as { message?: string }).message ?? 'Registered.');
setEmail('');
setPassword('');
} catch {
setError('Network error');
}
setLoading(false);
}
return (
<div className={style.card}>
<h1 className={style.title}>Register</h1>
<p className={style.sub}>
After registering, wait for an administrator to approve your account. Then you can{' '}
<Link href="/admin/login">sign in</Link>.
</p>
<form className={style.form} onSubmit={onSubmit}>
<label className={style.label}>
Email
<input
className={style.input}
type="email"
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</label>
<label className={style.label}>
Password (min. 8 characters)
<input
className={style.input}
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
minLength={8}
required
/>
</label>
{error ? <p className={style.error}>{error}</p> : null}
{success ? <p className={style.success}>{success}</p> : null}
<button className={style.btn} type="submit" disabled={loading}>
{loading ? 'Submitting…' : 'Register'}
</button>
</form>
</div>
);
}