diff --git a/build.sh b/build.sh index 75e9d97..3d9a7ae 100644 --- a/build.sh +++ b/build.sh @@ -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 . \ No newline at end of file diff --git a/cfc.tar b/cfc.tar deleted file mode 100644 index 89dca53..0000000 Binary files a/cfc.tar and /dev/null differ diff --git a/infra/db/admin_auth.sql b/infra/db/admin_auth.sql new file mode 100644 index 0000000..e177865 --- /dev/null +++ b/infra/db/admin_auth.sql @@ -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); diff --git a/nextjs/.env.example b/nextjs/.env.example index e268349..21b57ec 100644 --- a/nextjs/.env.example +++ b/nextjs/.env.example @@ -3,3 +3,5 @@ DB_ENDPOINT=localhost DB_PORT=3306 DB_USER= DB_PASS= + +# After DB is up, apply admin tables (see infra/db/admin_auth.sql) diff --git a/nextjs/package.json b/nextjs/package.json index 5596895..555eab5 100644 --- a/nextjs/package.json +++ b/nextjs/package.json @@ -9,6 +9,7 @@ "lint": "next lint" }, "dependencies": { + "bcryptjs": "^3.0.3", "dotenv": "^16.4.5", "i18next": "^23.10.1", "mysql2": "^3.11.0", @@ -20,6 +21,7 @@ "semantic-ui-css": "^2.5.0" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", "@types/node": "^20.14.10", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", diff --git a/nextjs/src/app/about/page.tsx b/nextjs/src/app/(site)/about/page.tsx similarity index 100% rename from nextjs/src/app/about/page.tsx rename to nextjs/src/app/(site)/about/page.tsx diff --git a/nextjs/src/app/archive/page.tsx b/nextjs/src/app/(site)/archive/page.tsx similarity index 100% rename from nextjs/src/app/archive/page.tsx rename to nextjs/src/app/(site)/archive/page.tsx diff --git a/nextjs/src/app/contact/page.tsx b/nextjs/src/app/(site)/contact/page.tsx similarity index 100% rename from nextjs/src/app/contact/page.tsx rename to nextjs/src/app/(site)/contact/page.tsx diff --git a/nextjs/src/app/guide/page.tsx b/nextjs/src/app/(site)/guide/page.tsx similarity index 100% rename from nextjs/src/app/guide/page.tsx rename to nextjs/src/app/(site)/guide/page.tsx diff --git a/nextjs/src/app/(site)/layout.tsx b/nextjs/src/app/(site)/layout.tsx new file mode 100644 index 0000000..8070e5a --- /dev/null +++ b/nextjs/src/app/(site)/layout.tsx @@ -0,0 +1,7 @@ +import type { ReactNode } from 'react'; + +import { SiteLayout } from '@/components/SiteLayout'; + +export default function SiteChromeLayout({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/nextjs/src/app/page.tsx b/nextjs/src/app/(site)/page.tsx similarity index 83% rename from nextjs/src/app/page.tsx rename to nextjs/src/app/(site)/page.tsx index 2e3d61d..fe86754 100644 --- a/nextjs/src/app/page.tsx +++ b/nextjs/src/app/(site)/page.tsx @@ -3,18 +3,18 @@ import style from '@/styles/web.module.scss'; export default function HomePage() { return (
-
+
diff --git a/nextjs/src/app/players/page.tsx b/nextjs/src/app/(site)/players/page.tsx similarity index 100% rename from nextjs/src/app/players/page.tsx rename to nextjs/src/app/(site)/players/page.tsx diff --git a/nextjs/src/app/tournaments/[tournament_key]/players/page.tsx b/nextjs/src/app/(site)/tournaments/[tournament_key]/players/page.tsx similarity index 100% rename from nextjs/src/app/tournaments/[tournament_key]/players/page.tsx rename to nextjs/src/app/(site)/tournaments/[tournament_key]/players/page.tsx diff --git a/nextjs/src/app/tournaments/[tournament_key]/scoreboard/page.tsx b/nextjs/src/app/(site)/tournaments/[tournament_key]/scoreboard/page.tsx similarity index 100% rename from nextjs/src/app/tournaments/[tournament_key]/scoreboard/page.tsx rename to nextjs/src/app/(site)/tournaments/[tournament_key]/scoreboard/page.tsx diff --git a/nextjs/src/app/tournaments/page.tsx b/nextjs/src/app/(site)/tournaments/page.tsx similarity index 100% rename from nextjs/src/app/tournaments/page.tsx rename to nextjs/src/app/(site)/tournaments/page.tsx diff --git a/nextjs/src/app/admin/(dashboard)/archive/ArchiveAdminClient.tsx b/nextjs/src/app/admin/(dashboard)/archive/ArchiveAdminClient.tsx new file mode 100644 index 0000000..01d5c53 --- /dev/null +++ b/nextjs/src/app/admin/(dashboard)/archive/ArchiveAdminClient.tsx @@ -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([]); + 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) { + 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 ( +
+

+ ← Dashboard +

+

Archive

+ {error ?

{error}

: null} + {loading ?

Loading…

: null} + {!loading && + items.map((row) => ( +
+ + +
+ + +
+
+ ))} + +
+

+ New entry +

+
+ + + +
+
+ ); +} diff --git a/nextjs/src/app/admin/(dashboard)/archive/page.tsx b/nextjs/src/app/admin/(dashboard)/archive/page.tsx new file mode 100644 index 0000000..162492e --- /dev/null +++ b/nextjs/src/app/admin/(dashboard)/archive/page.tsx @@ -0,0 +1,8 @@ +import { requireAdmin } from '@/lib/auth/requireAdmin'; + +import { ArchiveAdminClient } from './ArchiveAdminClient'; + +export default async function AdminArchivePage() { + await requireAdmin(); + return ; +} diff --git a/nextjs/src/app/admin/(dashboard)/guide/GuideAdminClient.tsx b/nextjs/src/app/admin/(dashboard)/guide/GuideAdminClient.tsx new file mode 100644 index 0000000..ee3262e --- /dev/null +++ b/nextjs/src/app/admin/(dashboard)/guide/GuideAdminClient.tsx @@ -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([]); + 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) { + 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 ( +
+

+ ← Dashboard +

+

Guide

+ {error ?

{error}

: null} + {loading ?

Loading…

: null} + {!loading && + items.map((row) => ( +
+ +