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

@@ -1,2 +1,2 @@
APP_VERSION=1.0.0 APP_VERSION=1.0.1
docker buildx build --platform linux/arm64 -f nextjs/Dockerfile -t cfc-web:$APP_VERSION . docker buildx build --platform linux/arm64 -f nextjs/Dockerfile -t cfc-web:$APP_VERSION .

42
infra/db/admin_auth.sql Normal file
View File

@@ -0,0 +1,42 @@
-- Admin users and sessions for the Next.js admin area.
-- Run after catherine_league schema exists: mysql ... < admin_auth.sql
USE `catherine_league`;
-- Passwords stored as bcrypt hashes (e.g. from bcryptjs), never plaintext.
CREATE TABLE IF NOT EXISTS `admin_users` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`email` VARCHAR(255) NOT NULL,
`password_hash` VARCHAR(255) NOT NULL,
`is_approved` TINYINT(1) NOT NULL DEFAULT 0,
`is_admin` TINYINT(1) NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_admin_users_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Opaque session tokens: store SHA-256 hex of the cookie value; never store raw tokens.
CREATE TABLE IF NOT EXISTS `admin_sessions` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` INT UNSIGNED NOT NULL,
`token_hash` CHAR(64) NOT NULL,
`expires_at` DATETIME NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_admin_sessions_token_hash` (`token_hash`),
KEY `idx_admin_sessions_user_id` (`user_id`),
KEY `idx_admin_sessions_expires_at` (`expires_at`),
CONSTRAINT `fk_admin_sessions_user`
FOREIGN KEY (`user_id`) REFERENCES `admin_users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Seed: first admin (change password immediately in production).
-- Default password: ChangeMeOnFirstLogin!
INSERT INTO `admin_users` (`email`, `password_hash`, `is_approved`, `is_admin`)
SELECT
'admin@localhost',
'$2b$12$H6Y71zX/nmefp33e0MaMaOOBGSiVwxVE3L.Ie3Pfq1/6QZdLR7bTa',
1,
1
WHERE NOT EXISTS (SELECT 1 FROM `admin_users` WHERE `email` = 'admin@localhost' LIMIT 1);

View File

@@ -3,3 +3,5 @@ DB_ENDPOINT=localhost
DB_PORT=3306 DB_PORT=3306
DB_USER= DB_USER=
DB_PASS= DB_PASS=
# After DB is up, apply admin tables (see infra/db/admin_auth.sql)

View File

@@ -9,6 +9,7 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"bcryptjs": "^3.0.3",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"i18next": "^23.10.1", "i18next": "^23.10.1",
"mysql2": "^3.11.0", "mysql2": "^3.11.0",
@@ -20,6 +21,7 @@
"semantic-ui-css": "^2.5.0" "semantic-ui-css": "^2.5.0"
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20.14.10", "@types/node": "^20.14.10",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",

View File

@@ -0,0 +1,7 @@
import type { ReactNode } from 'react';
import { SiteLayout } from '@/components/SiteLayout';
export default function SiteChromeLayout({ children }: { children: ReactNode }) {
return <SiteLayout>{children}</SiteLayout>;
}

View File

@@ -3,18 +3,18 @@ import style from '@/styles/web.module.scss';
export default function HomePage() { export default function HomePage() {
return ( return (
<div className={style.mainBody}> <div className={style.mainBody}>
<div className={style.evojapan2025} /> <div className={style.evojapan2026} />
<div className={style.padding} /> <div className={style.padding} />
<div className={style.evojapan2023kaisaikettei} /> <div className={style.evojapan2023kaisaikettei} />
<div className={style.padding} /> <div className={style.padding} />
<div className={style.group}> <div className={style.group}>
<div className={style.evojapan2023catherinetonamel}> <div className={style.evojapan2023catherinetonamel}>
<a href="https://tonamel.com/competition/VD6y8" rel="noopener noreferrer" target="_blank"> <a href="https://tonamel.com/competition/u2GRP" rel="noopener noreferrer" target="_blank">
&nbsp; &nbsp;
</a> </a>
</div> </div>
<div className={style.evojapan2023catherinefullbodytonamel}> <div className={style.evojapan2023catherinefullbodytonamel}>
<a href="https://tonamel.com/competition/dgdQ9" rel="noopener noreferrer" target="_blank"> <a href="https://tonamel.com/competition/BxuNE" rel="noopener noreferrer" target="_blank">
&nbsp; &nbsp;
</a> </a>
</div> </div>

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>
);
}

View File

@@ -0,0 +1,63 @@
import { NextResponse } from 'next/server';
import type { RowDataPacket } from 'mysql2';
import { requireAdminApi } from '@/lib/auth/apiAuth';
import { getPool } from '@/lib/db';
export const dynamic = 'force-dynamic';
type Ctx = { params: Promise<{ id: string }> };
export async function PATCH(request: Request, context: Ctx) {
const auth = await requireAdminApi();
if (!auth.ok) {
return auth.response;
}
const { id: idParam } = await context.params;
const id = parseInt(idParam, 10);
if (Number.isNaN(id)) {
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
}
const body = await request.json();
const updates: string[] = [];
const values: unknown[] = [];
if (body.title !== undefined) {
updates.push('title = ?');
values.push(String(body.title).trim());
}
if (body.youtube_id !== undefined) {
updates.push('youtube_id = ?');
values.push(String(body.youtube_id).trim());
}
if (updates.length === 0) {
return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
}
values.push(id);
const pool = getPool();
await pool.query(`UPDATE archive SET ${updates.join(', ')} WHERE id = ?`, values);
const [rows] = await pool.query<RowDataPacket[]>('SELECT * FROM archive WHERE id = ?', [id]);
return NextResponse.json({ item: rows[0] });
}
export async function DELETE(_request: Request, context: Ctx) {
const auth = await requireAdminApi();
if (!auth.ok) {
return auth.response;
}
const { id: idParam } = await context.params;
const id = parseInt(idParam, 10);
if (Number.isNaN(id)) {
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
}
const pool = getPool();
await pool.query('DELETE FROM archive WHERE id = ?', [id]);
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,37 @@
import { NextResponse } from 'next/server';
import type { RowDataPacket } from 'mysql2';
import { requireAdminApi } from '@/lib/auth/apiAuth';
import { getPool } from '@/lib/db';
export const dynamic = 'force-dynamic';
export async function GET() {
const auth = await requireAdminApi();
if (!auth.ok) {
return auth.response;
}
const pool = getPool();
const [rows] = await pool.query<RowDataPacket[]>('SELECT * FROM archive ORDER BY id ASC');
return NextResponse.json({ items: rows });
}
export async function POST(request: Request) {
const auth = await requireAdminApi();
if (!auth.ok) {
return auth.response;
}
const body = await request.json();
const title = String(body.title ?? '').trim();
const youtubeId = String(body.youtube_id ?? '').trim();
if (!title || !youtubeId) {
return NextResponse.json({ error: 'title and youtube_id required' }, { status: 400 });
}
const pool = getPool();
await pool.query('INSERT INTO archive (title, youtube_id) VALUES (?, ?)', [title, youtubeId]);
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,20 @@
import { NextResponse } from 'next/server';
import type { RowDataPacket } from 'mysql2';
import { requireAdminApi } from '@/lib/auth/apiAuth';
import { getPool } from '@/lib/db';
export const dynamic = 'force-dynamic';
export async function GET() {
const auth = await requireAdminApi();
if (!auth.ok) {
return auth.response;
}
const pool = getPool();
const [rows] = await pool.query<RowDataPacket[]>(
'SELECT id, name_id, name, name_jp FROM characters ORDER BY id ASC'
);
return NextResponse.json({ characters: rows });
}

View File

@@ -0,0 +1,63 @@
import { NextResponse } from 'next/server';
import type { RowDataPacket } from 'mysql2';
import { requireAdminApi } from '@/lib/auth/apiAuth';
import { getPool } from '@/lib/db';
export const dynamic = 'force-dynamic';
type Ctx = { params: Promise<{ id: string }> };
export async function PATCH(request: Request, context: Ctx) {
const auth = await requireAdminApi();
if (!auth.ok) {
return auth.response;
}
const { id: idParam } = await context.params;
const id = parseInt(idParam, 10);
if (Number.isNaN(id)) {
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
}
const body = await request.json();
const updates: string[] = [];
const values: unknown[] = [];
if (body.question !== undefined) {
updates.push('question = ?');
values.push(String(body.question).trim());
}
if (body.answer !== undefined) {
updates.push('answer = ?');
values.push(String(body.answer).trim());
}
if (updates.length === 0) {
return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
}
values.push(id);
const pool = getPool();
await pool.query(`UPDATE contact SET ${updates.join(', ')} WHERE id = ?`, values);
const [rows] = await pool.query<RowDataPacket[]>('SELECT * FROM contact WHERE id = ?', [id]);
return NextResponse.json({ item: rows[0] });
}
export async function DELETE(_request: Request, context: Ctx) {
const auth = await requireAdminApi();
if (!auth.ok) {
return auth.response;
}
const { id: idParam } = await context.params;
const id = parseInt(idParam, 10);
if (Number.isNaN(id)) {
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
}
const pool = getPool();
await pool.query('DELETE FROM contact WHERE id = ?', [id]);
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,37 @@
import { NextResponse } from 'next/server';
import type { RowDataPacket } from 'mysql2';
import { requireAdminApi } from '@/lib/auth/apiAuth';
import { getPool } from '@/lib/db';
export const dynamic = 'force-dynamic';
export async function GET() {
const auth = await requireAdminApi();
if (!auth.ok) {
return auth.response;
}
const pool = getPool();
const [rows] = await pool.query<RowDataPacket[]>('SELECT * FROM contact ORDER BY id ASC');
return NextResponse.json({ items: rows });
}
export async function POST(request: Request) {
const auth = await requireAdminApi();
if (!auth.ok) {
return auth.response;
}
const body = await request.json();
const question = String(body.question ?? '').trim();
const answer = String(body.answer ?? '').trim();
if (!question || !answer) {
return NextResponse.json({ error: 'question and answer required' }, { status: 400 });
}
const pool = getPool();
await pool.query('INSERT INTO contact (question, answer) VALUES (?, ?)', [question, answer]);
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,67 @@
import { NextResponse } from 'next/server';
import type { RowDataPacket } from 'mysql2';
import { requireAdminApi } from '@/lib/auth/apiAuth';
import { getPool } from '@/lib/db';
export const dynamic = 'force-dynamic';
type Ctx = { params: Promise<{ id: string }> };
export async function PATCH(request: Request, context: Ctx) {
const auth = await requireAdminApi();
if (!auth.ok) {
return auth.response;
}
const { id: idParam } = await context.params;
const id = parseInt(idParam, 10);
if (Number.isNaN(id)) {
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
}
const body = await request.json();
const updates: string[] = [];
const values: unknown[] = [];
if (body.title !== undefined) {
updates.push('title = ?');
values.push(String(body.title).trim());
}
if (body.description !== undefined) {
updates.push('description = ?');
values.push(String(body.description));
}
if (body.youtube_id !== undefined) {
updates.push('youtube_id = ?');
values.push(String(body.youtube_id).trim());
}
if (updates.length === 0) {
return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
}
values.push(id);
const pool = getPool();
await pool.query(`UPDATE guide SET ${updates.join(', ')} WHERE id = ?`, values);
const [rows] = await pool.query<RowDataPacket[]>('SELECT * FROM guide WHERE id = ?', [id]);
return NextResponse.json({ item: rows[0] });
}
export async function DELETE(_request: Request, context: Ctx) {
const auth = await requireAdminApi();
if (!auth.ok) {
return auth.response;
}
const { id: idParam } = await context.params;
const id = parseInt(idParam, 10);
if (Number.isNaN(id)) {
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
}
const pool = getPool();
await pool.query('DELETE FROM guide WHERE id = ?', [id]);
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,41 @@
import { NextResponse } from 'next/server';
import type { RowDataPacket } from 'mysql2';
import { requireAdminApi } from '@/lib/auth/apiAuth';
import { getPool } from '@/lib/db';
export const dynamic = 'force-dynamic';
export async function GET() {
const auth = await requireAdminApi();
if (!auth.ok) {
return auth.response;
}
const pool = getPool();
const [rows] = await pool.query<RowDataPacket[]>('SELECT * FROM guide ORDER BY id ASC');
return NextResponse.json({ items: rows });
}
export async function POST(request: Request) {
const auth = await requireAdminApi();
if (!auth.ok) {
return auth.response;
}
const body = await request.json();
const title = String(body.title ?? '').trim();
const description = String(body.description ?? '');
const youtubeId = String(body.youtube_id ?? '').trim();
if (!title || !youtubeId) {
return NextResponse.json({ error: 'title and youtube_id required' }, { status: 400 });
}
const pool = getPool();
await pool.query(
'INSERT INTO guide (title, description, youtube_id) VALUES (?, ?, ?)',
[title, description, youtubeId]
);
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,124 @@
import { NextResponse } from 'next/server';
import type { RowDataPacket } from 'mysql2';
import { requireAdminApi } from '@/lib/auth/apiAuth';
import { getPool } from '@/lib/db';
export const dynamic = 'force-dynamic';
type Ctx = { params: Promise<{ id: string }> };
export async function GET(_request: Request, context: Ctx) {
const auth = await requireAdminApi();
if (!auth.ok) {
return auth.response;
}
const { id: idParam } = await context.params;
const id = parseInt(idParam, 10);
if (Number.isNaN(id)) {
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
}
const pool = getPool();
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT p.id, p.player_key, p.player_name, p.description, p.image, p.character_id,
c.name_id, c.name, c.name_jp
FROM players p
LEFT JOIN characters c ON c.id = p.character_id
WHERE p.id = ?`,
[id]
);
if (rows.length === 0) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
return NextResponse.json({ player: rows[0] });
}
export async function PATCH(request: Request, context: Ctx) {
const auth = await requireAdminApi();
if (!auth.ok) {
return auth.response;
}
const { id: idParam } = await context.params;
const id = parseInt(idParam, 10);
if (Number.isNaN(id)) {
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
}
const body = await request.json();
const updates: string[] = [];
const values: unknown[] = [];
if (body.player_key !== undefined) {
updates.push('player_key = ?');
values.push(String(body.player_key).trim());
}
if (body.player_name !== undefined) {
updates.push('player_name = ?');
values.push(String(body.player_name).trim());
}
if (body.description !== undefined) {
updates.push('description = ?');
values.push(String(body.description));
}
if (body.image !== undefined) {
updates.push('image = ?');
values.push(body.image == null ? null : String(body.image));
}
if (body.character_id !== undefined) {
updates.push('character_id = ?');
values.push(String(parseInt(String(body.character_id), 10)));
}
if (updates.length === 0) {
return NextResponse.json({ error: 'No fields to update' }, { status: 400 });
}
values.push(id);
const pool = getPool();
await pool.query(`UPDATE players SET ${updates.join(', ')} WHERE id = ?`, values);
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT p.id, p.player_key, p.player_name, p.description, p.image, p.character_id,
c.name_id, c.name, c.name_jp
FROM players p
LEFT JOIN characters c ON c.id = p.character_id
WHERE p.id = ?`,
[id]
);
return NextResponse.json({ player: rows[0] });
}
export async function DELETE(_request: Request, context: Ctx) {
const auth = await requireAdminApi();
if (!auth.ok) {
return auth.response;
}
const { id: idParam } = await context.params;
const id = parseInt(idParam, 10);
if (Number.isNaN(id)) {
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
}
const pool = getPool();
try {
await pool.query('DELETE FROM players WHERE id = ?', [id]);
} catch (e: unknown) {
const code = (e as { errno?: number })?.errno;
if (code === 1451) {
return NextResponse.json(
{ error: 'Cannot delete player: referenced by tournament or other records' },
{ status: 409 }
);
}
throw e;
}
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,52 @@
import { NextResponse } from 'next/server';
import type { RowDataPacket } from 'mysql2';
import { requireAdminApi } from '@/lib/auth/apiAuth';
import { getPool } from '@/lib/db';
export const dynamic = 'force-dynamic';
export async function GET() {
const auth = await requireAdminApi();
if (!auth.ok) {
return auth.response;
}
const pool = getPool();
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT p.id, p.player_key, p.player_name, p.description, p.image, p.character_id,
c.name_id, c.name, c.name_jp
FROM players p
LEFT JOIN characters c ON c.id = p.character_id
ORDER BY p.id ASC`
);
return NextResponse.json({ players: rows });
}
export async function POST(request: Request) {
const auth = await requireAdminApi();
if (!auth.ok) {
return auth.response;
}
const body = await request.json();
const playerKey = String(body.player_key ?? '').trim();
const playerName = String(body.player_name ?? '').trim();
const description = String(body.description ?? '');
const image = body.image != null ? String(body.image) : null;
const characterId = parseInt(String(body.character_id), 10);
if (!playerKey || !playerName || Number.isNaN(characterId)) {
return NextResponse.json({ error: 'player_key, player_name, character_id required' }, { status: 400 });
}
const pool = getPool();
await pool.query(
`INSERT INTO players (player_key, player_name, description, image, character_id)
VALUES (?, ?, ?, ?, ?)`,
[playerKey, playerName, description, image, String(characterId)]
);
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,63 @@
import { NextResponse } from 'next/server';
import type { RowDataPacket } from 'mysql2';
import { requireAdminApi } from '@/lib/auth/apiAuth';
import { getPool } from '@/lib/db';
export const dynamic = 'force-dynamic';
type Ctx = { params: Promise<{ id: string }> };
export async function PATCH(request: Request, context: Ctx) {
const auth = await requireAdminApi();
if (!auth.ok) {
return auth.response;
}
const { id: idParam } = await context.params;
const id = parseInt(idParam, 10);
if (Number.isNaN(id)) {
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
}
const body = await request.json();
const isApproved = typeof body.is_approved === 'boolean' ? body.is_approved : undefined;
const isAdmin = typeof body.is_admin === 'boolean' ? body.is_admin : undefined;
if (isApproved === undefined && isAdmin === undefined) {
return NextResponse.json({ error: 'No changes' }, { status: 400 });
}
const pool = getPool();
if (id === auth.user.id && isApproved === false) {
return NextResponse.json({ error: 'Cannot revoke your own approval' }, { status: 400 });
}
if (id === auth.user.id && isAdmin === false) {
return NextResponse.json({ error: 'Cannot remove your own admin role' }, { status: 400 });
}
const updates: string[] = [];
const values: unknown[] = [];
if (isApproved !== undefined) {
updates.push('is_approved = ?');
values.push(isApproved ? 1 : 0);
}
if (isAdmin !== undefined) {
updates.push('is_admin = ?');
values.push(isAdmin ? 1 : 0);
}
values.push(id);
await pool.query(
`UPDATE admin_users SET ${updates.join(', ')} WHERE id = ?`,
values
);
const [rows] = await pool.query<RowDataPacket[]>(
'SELECT id, email, is_approved, is_admin, created_at FROM admin_users WHERE id = ?',
[id]
);
return NextResponse.json({ user: rows[0] ?? null });
}

View File

@@ -0,0 +1,23 @@
import { NextResponse } from 'next/server';
import type { RowDataPacket } from 'mysql2';
import { requireAdminApi } from '@/lib/auth/apiAuth';
import { getPool } from '@/lib/db';
export const dynamic = 'force-dynamic';
export async function GET() {
const auth = await requireAdminApi();
if (!auth.ok) {
return auth.response;
}
const pool = getPool();
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT id, email, is_approved, is_admin, created_at
FROM admin_users
ORDER BY created_at DESC`
);
return NextResponse.json({ users: rows });
}

View File

@@ -0,0 +1,58 @@
import { NextResponse } from 'next/server';
import { verifyPassword } from '@/lib/auth/password';
import { createSession } from '@/lib/auth/session';
import { getPool } from '@/lib/db';
import type { RowDataPacket } from 'mysql2';
export const dynamic = 'force-dynamic';
export async function POST(request: Request) {
try {
const body = await request.json();
const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : '';
const password = typeof body.password === 'string' ? body.password : '';
if (!email || !password) {
return NextResponse.json({ error: 'Email and password required' }, { status: 400 });
}
const pool = getPool();
const [rows] = await pool.query<RowDataPacket[]>(
'SELECT id, email, password_hash, is_approved, is_admin FROM admin_users WHERE email = ?',
[email]
);
if (rows.length === 0) {
return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 });
}
const row = rows[0];
const ok = verifyPassword(password, row.password_hash as string);
if (!ok) {
return NextResponse.json({ error: 'Invalid email or password' }, { status: 401 });
}
if (!row.is_approved) {
return NextResponse.json(
{ error: 'Your account is not approved yet.', code: 'PENDING_APPROVAL' },
{ status: 403 }
);
}
await createSession(row.id as number);
return NextResponse.json({
ok: true,
user: {
id: row.id,
email: row.email,
is_approved: Boolean(row.is_approved),
is_admin: Boolean(row.is_admin),
},
});
} catch (e) {
console.error(e);
return NextResponse.json({ error: 'Login failed' }, { status: 500 });
}
}

View File

@@ -0,0 +1,10 @@
import { NextResponse } from 'next/server';
import { destroySession } from '@/lib/auth/session';
export const dynamic = 'force-dynamic';
export async function POST() {
await destroySession();
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,20 @@
import { NextResponse } from 'next/server';
import { getSessionUser } from '@/lib/auth/session';
export const dynamic = 'force-dynamic';
export async function GET() {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ user: null }, { status: 401 });
}
return NextResponse.json({
user: {
id: user.id,
email: user.email,
is_approved: user.is_approved,
is_admin: user.is_admin,
},
});
}

View File

@@ -0,0 +1,37 @@
import { NextResponse } from 'next/server';
import { hashPassword } from '@/lib/auth/password';
import { getPool } from '@/lib/db';
export const dynamic = 'force-dynamic';
export async function POST(request: Request) {
try {
const body = await request.json();
const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : '';
const password = typeof body.password === 'string' ? body.password : '';
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return NextResponse.json({ error: 'Valid email is required' }, { status: 400 });
}
if (password.length < 8) {
return NextResponse.json({ error: 'Password must be at least 8 characters' }, { status: 400 });
}
const passwordHash = hashPassword(password);
const pool = getPool();
await pool.query(
`INSERT INTO admin_users (email, password_hash, is_approved, is_admin) VALUES (?, ?, 0, 0)`,
[email, passwordHash]
);
return NextResponse.json({ ok: true, message: 'Registration submitted. Wait for an administrator to approve your account.' });
} catch (e: unknown) {
const code = (e as { code?: string })?.code;
if (code === 'ER_DUP_ENTRY') {
return NextResponse.json({ error: 'Email already registered' }, { status: 409 });
}
console.error(e);
return NextResponse.json({ error: 'Registration failed' }, { status: 500 });
}
}

View File

@@ -3,7 +3,6 @@ import { Indie_Flower, M_PLUS_Rounded_1c } from 'next/font/google';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { I18nProvider } from '@/components/I18nProvider'; import { I18nProvider } from '@/components/I18nProvider';
import { SiteLayout } from '@/components/SiteLayout';
import 'semantic-ui-css/semantic.min.css'; import 'semantic-ui-css/semantic.min.css';
import './globals.css'; import './globals.css';
@@ -37,9 +36,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
return ( return (
<html lang="ja" className={`${indieFlower.variable} ${mPlusRounded.variable}`}> <html lang="ja" className={`${indieFlower.variable} ${mPlusRounded.variable}`}>
<body> <body>
<I18nProvider> <I18nProvider>{children}</I18nProvider>
<SiteLayout>{children}</SiteLayout>
</I18nProvider>
</body> </body>
</html> </html>
); );

View File

@@ -0,0 +1,18 @@
'use client';
import style from '@/styles/admin.module.scss';
export function LogoutButton() {
return (
<button
type="button"
className={style.btnGhost}
onClick={async () => {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
window.location.href = '/admin/login';
}}
>
Log out
</button>
);
}

View File

@@ -0,0 +1,20 @@
import { NextResponse } from 'next/server';
import type { AdminUserRow } from '@/lib/auth/session';
import { getSessionUser } from '@/lib/auth/session';
export async function requireAdminApi(): Promise<
{ ok: true; user: AdminUserRow } | { ok: false; response: NextResponse }
> {
const user = await getSessionUser();
if (!user) {
return { ok: false, response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) };
}
if (!user.is_approved) {
return { ok: false, response: NextResponse.json({ error: 'Pending approval' }, { status: 403 }) };
}
if (!user.is_admin) {
return { ok: false, response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) };
}
return { ok: true, user };
}

View File

@@ -0,0 +1,11 @@
import bcrypt from 'bcryptjs';
const SALT_ROUNDS = 12;
export function hashPassword(plain: string): string {
return bcrypt.hashSync(plain, SALT_ROUNDS);
}
export function verifyPassword(plain: string, passwordHash: string): boolean {
return bcrypt.compareSync(plain, passwordHash);
}

View File

@@ -0,0 +1,22 @@
import { redirect } from 'next/navigation';
import { getSessionUser } from '@/lib/auth/session';
export async function requireLogin(): Promise<NonNullable<Awaited<ReturnType<typeof getSessionUser>>>> {
const user = await getSessionUser();
if (!user) {
redirect('/admin/login');
}
if (!user.is_approved) {
redirect('/admin/pending');
}
return user;
}
export async function requireAdmin(): Promise<NonNullable<Awaited<ReturnType<typeof getSessionUser>>>> {
const user = await requireLogin();
if (!user.is_admin) {
redirect('/admin/no-access');
}
return user;
}

View File

@@ -0,0 +1,88 @@
import { createHash, randomBytes } from 'crypto';
import type { RowDataPacket } from 'mysql2';
import { cookies } from 'next/headers';
import { getPool } from '@/lib/db';
export const SESSION_COOKIE = 'admin_session';
const SESSION_MAX_AGE_SEC = 60 * 60 * 24 * 7; // 7 days
export type AdminUserRow = {
id: number;
email: string;
password_hash: string;
is_approved: boolean;
is_admin: boolean;
};
function hashToken(token: string): string {
return createHash('sha256').update(token, 'utf8').digest('hex');
}
export async function createSession(userId: number): Promise<string> {
const token = randomBytes(32).toString('hex');
const tokenHash = hashToken(token);
const expiresAt = new Date(Date.now() + SESSION_MAX_AGE_SEC * 1000);
const pool = getPool();
await pool.query(
`INSERT INTO admin_sessions (user_id, token_hash, expires_at) VALUES (?, ?, ?)`,
[userId, tokenHash, expiresAt]
);
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: SESSION_MAX_AGE_SEC,
path: '/',
});
return token;
}
export async function destroySession(): Promise<void> {
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE)?.value;
if (token) {
const pool = getPool();
await pool.query(
'DELETE FROM admin_sessions WHERE token_hash = ?',
[hashToken(token)]
);
}
cookieStore.delete(SESSION_COOKIE);
}
export async function getSessionUser(): Promise<AdminUserRow | null> {
const cookieStore = await cookies();
const token = cookieStore.get(SESSION_COOKIE)?.value;
if (!token) {
return null;
}
const tokenHash = hashToken(token);
const pool = getPool();
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT u.id, u.email, u.password_hash, u.is_approved, u.is_admin
FROM admin_sessions s
JOIN admin_users u ON u.id = s.user_id
WHERE s.token_hash = ? AND s.expires_at > NOW()`,
[tokenHash]
);
if (rows.length === 0) {
cookieStore.delete(SESSION_COOKIE);
return null;
}
const row = rows[0] as Record<string, unknown>;
return {
id: row.id as number,
email: row.email as string,
password_hash: row.password_hash as string,
is_approved: Boolean(row.is_approved),
is_admin: Boolean(row.is_admin),
};
}

View File

@@ -0,0 +1,141 @@
.wrap {
max-width: 960px;
margin: 0 auto;
padding: 24px 16px 48px;
font-family: system-ui, sans-serif;
color: #1a1a1a;
}
.card {
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.title {
margin: 0 0 8px;
font-size: 1.5rem;
}
.sub {
color: #666;
margin: 0 0 16px;
font-size: 0.95rem;
}
.form {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 360px;
}
.label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.9rem;
}
.input {
padding: 8px 10px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 1rem;
}
.textarea {
min-height: 100px;
font-family: inherit;
}
.btn {
padding: 10px 16px;
border: none;
border-radius: 4px;
background: #2563eb;
color: #fff;
font-size: 1rem;
cursor: pointer;
&:hover {
background: #1d4ed8;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.btnDanger {
background: #dc2626;
&:hover {
background: #b91c1c;
}
}
.btnGhost {
background: #f3f4f6;
color: #111;
&:hover {
background: #e5e7eb;
}
}
.error {
color: #b91c1c;
font-size: 0.9rem;
}
.success {
color: #15803d;
font-size: 0.9rem;
}
.nav {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid #e5e7eb;
}
.nav a {
color: #2563eb;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.table th,
.table td {
border: 1px solid #e5e7eb;
padding: 8px;
text-align: left;
vertical-align: top;
}
.table th {
background: #f9fafb;
}
.rowActions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.hr {
border: none;
border-top: 1px solid #e5e7eb;
margin: 16px 0;
}

View File

@@ -460,6 +460,14 @@
background-size: contain; background-size: contain;
background-repeat: none; background-repeat: none;
} }
.evojapan2026 {
width: 500px;
height: 471px;
margin: auto;
background-image: url('https://static.catherine-fc.com/media/evo_logo_2026_shadow_drop.png');
background-size: contain;
background-repeat: none;
}
.evojapan2023kaisaikettei { .evojapan2023kaisaikettei {
width: 846px; width: 846px;
height: 285px; height: 285px;