diff --git a/README.md b/README.md index f101f38..26fae0f 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ bun run typecheck bun test # 규칙·서버 통합·방 단위 테스트 (cd apps/web && bunx vite build) # 웹 빌드 bun e2e/omok.e2e.ts # 브라우저 두 개로 오목 한 판 (Chrome 필요) +bun e2e/admin.e2e.ts # 관리자: 슈퍼어드민 로그인 → 규칙 변경·잠금 → 새 방 반영 ``` ## 배포 @@ -26,6 +27,9 @@ cd deploy && docker compose up -d --build ``` 자세한 절차는 [docs/11-deployment-ops.md](docs/11-deployment-ops.md). +## 관리자 +`/admin`. 슈퍼어드민은 디스코드 ID `293719842274541579`(환경 변수 `SUPERADMIN_DISCORD_IDS`), 어드민은 관리자 화면에서 디스코드 ID로 추가한다. 자세한 내용은 [docs/14-admin.md](docs/14-admin.md). + ## 구조 - `packages/engine` — 게임 공통 타입, 시드 난수, 테스트 도구 - `packages/shared` — 프로토콜, 닉네임·방 코드 규칙, 게임 목록 (`/lite`는 브라우저용) diff --git a/apps/server/src/admin.test.ts b/apps/server/src/admin.test.ts new file mode 100644 index 0000000..e29b31e --- /dev/null +++ b/apps/server/src/admin.test.ts @@ -0,0 +1,208 @@ +/** Admin site tests (docs/14-admin.md §9). */ +import { afterEach, describe, expect, test } from 'bun:test'; +import { Client, api, boot, guest, sleep } from './test-utils'; + +const SUPER = '293719842274541579'; +type Booted = ReturnType; +const running: Booted[] = []; +const clients: Client[] = []; + +afterEach(async () => { + for (const c of clients.splice(0)) c.close(); + for (const s of running.splice(0)) await s.stop(); +}); + +function start(): Booted { + const s = boot(undefined, { + discordExchange: async (code) => ({ id: code, username: `user${code.slice(-4)}`, global_name: `디코${code.slice(-4)}`, avatar: null }), + }); + s.config.discord = { clientId: 'cid', clientSecret: 'sec' }; + running.push(s); + return s; +} + +/** Logs in through the (mocked) Discord flow; returns the session cookie and user id. */ +async function discordLogin(s: Booted, discordId: string): Promise<{ cookie: string; id: string }> { + const st = await api(s.base, '/api/auth/discord/start?next=/'); + const state = new URL(st.res.headers.get('location')!).searchParams.get('state')!; + const oauth = st.setCookie!.split(';')[0]!; + const cb = await api(s.base, `/api/auth/discord/callback?code=${discordId}&state=${state}`, { cookie: oauth }); + const loc = cb.res.headers.get('location'); + if (loc !== '/') throw new Error(`login redirect ${loc}`); + const sid = cb.setCookie!.split(/,(?=\s*\w+=)/).find((c) => c.trim().startsWith('sid='))!.split(';')[0]!.trim(); + const me = await api(s.base, '/api/me', { cookie: sid }); + return { cookie: sid, id: me.body.me.id }; +} + +describe('roles', () => { + test('guest and plain discord users get 401/403; superadmin by fixed Discord ID', async () => { + const s = start(); + const g = await guest(s.base, '손님'); + expect((await api(s.base, '/api/admin/me', { cookie: g.cookie })).res.status).toBe(403); + expect((await api(s.base, '/api/admin/me')).res.status).toBe(401); + const plain = await discordLogin(s, '111111111111111111'); + expect((await api(s.base, '/api/admin/overview', { cookie: plain.cookie })).res.status).toBe(403); + expect((await api(s.base, '/api/me', { cookie: plain.cookie })).body.role).toBe('user'); + const sup = await discordLogin(s, SUPER); + const me = await api(s.base, '/api/admin/me', { cookie: sup.cookie }); + expect(me.body.role).toBe('superadmin'); + expect((await api(s.base, '/api/me', { cookie: sup.cookie })).body.role).toBe('superadmin'); + }); + + test('superadmin adds/removes an admin by Discord ID; takes effect immediately; admin cannot manage users', async () => { + const s = start(); + const sup = await discordLogin(s, SUPER); + const adminDiscord = '222222222222222222'; + const bad = await api(s.base, '/api/admin/admins', { method: 'POST', cookie: sup.cookie, body: JSON.stringify({ discordId: '12ab' }) }); + expect(bad.res.status).toBe(400); + // Pre-register before the person ever logged in. + const add = await api(s.base, '/api/admin/admins', { method: 'POST', cookie: sup.cookie, body: JSON.stringify({ discordId: adminDiscord, note: '친구1' }) }); + expect(add.res.status).toBe(200); + expect(add.body.admins.map((a: any) => a.discordId)).toEqual([SUPER, adminDiscord]); + const adm = await discordLogin(s, adminDiscord); + expect((await api(s.base, '/api/admin/me', { cookie: adm.cookie })).body.role).toBe('admin'); + expect((await api(s.base, '/api/admin/users', { cookie: adm.cookie })).res.status).toBe(403); + expect((await api(s.base, '/api/admin/admins', { cookie: adm.cookie })).res.status).toBe(403); + expect((await api(s.base, '/api/admin/games', { cookie: adm.cookie })).res.status).toBe(200); + // Fixed superadmin cannot be removed. + expect((await api(s.base, `/api/admin/admins/${SUPER}`, { method: 'DELETE', cookie: sup.cookie })).res.status).toBe(400); + expect((await api(s.base, `/api/admin/admins/${adminDiscord}`, { method: 'DELETE', cookie: sup.cookie })).res.status).toBe(200); + expect((await api(s.base, '/api/admin/me', { cookie: adm.cookie })).res.status).toBe(403); + const audit = await api(s.base, '/api/admin/audit', { cookie: sup.cookie }); + expect(audit.body.entries.map((e: any) => e.action)).toEqual(['admin.remove', 'admin.add']); + }); + + test('admin API rejects foreign-origin writes', async () => { + const s = start(); + const sup = await discordLogin(s, SUPER); + const res = await fetch(s.base + '/api/admin/site', { + method: 'PUT', + headers: { origin: 'https://evil.example', cookie: sup.cookie, 'content-type': 'application/json' }, + body: JSON.stringify({ maintenance: true }), + }); + expect(res.status).toBe(403); + }); +}); + +describe('game settings', () => { + test('schema exposed with Korean titles; defaults apply to new rooms; locked options win; invalid values rejected', async () => { + const s = start(); + const sup = await discordLogin(s, SUPER); + const games = await api(s.base, '/api/admin/games', { cookie: sup.cookie }); + const omok = games.body.games.find((g: any) => g.id === 'omok'); + expect(omok.schema.properties.ruleSet.title).toBe('규칙'); + expect(omok.schema.properties.autoMoveLimit.title).toContain('자동 착수'); + const bad = await api(s.base, '/api/admin/games/omok', { + method: 'PUT', + cookie: sup.cookie, + body: JSON.stringify({ defaultOptions: { ruleSet: 'nope' } }), + }); + expect(bad.res.status).toBe(400); + const tooMany = await api(s.base, '/api/admin/games/omok', { method: 'PUT', cookie: sup.cookie, body: JSON.stringify({ maxPlayers: 3 }) }); + expect(tooMany.res.status).toBe(400); + const put = await api(s.base, '/api/admin/games/omok', { + method: 'PUT', + cookie: sup.cookie, + body: JSON.stringify({ + nameKo: '오목(친구룰)', + notice: '렌주룰로만 해요', + defaultOptions: { ruleSet: 'renju', timeControl: { kind: 'perMove', perMoveSec: 45 } }, + lockedOptions: ['ruleSet'], + }), + }); + expect(put.res.status).toBe(200); + expect(put.body.effective.defaultOptions.timeControl.perMoveSec).toBe(45); + const cfg = await api(s.base, '/api/config'); + expect(cfg.body.catalog.find((g: any) => g.id === 'omok')).toMatchObject({ nameKo: '오목(친구룰)', notice: '렌주룰로만 해요' }); + + // A host creates a room → admin defaults; trying to change the locked rule is overridden. + const host = await guest(s.base, '방장'); + const room = await api(s.base, '/api/rooms', { method: 'POST', cookie: host.cookie, body: JSON.stringify({ gameId: 'omok' }) }); + const c = await new Client(s.base, host.cookie, host.id).connect(); + clients.push(c); + c.send({ t: 'join', code: room.body.code, as: 'player' }); + const first = await c.next('room'); + expect((first.room.options as any).ruleSet).toBe('renju'); + expect(first.room.lockedOptions).toEqual(['ruleSet']); + c.send({ t: 'config', options: { ...(first.room.options as any), ruleSet: 'free', boardSize: 19 } }); + const after = await c.next('room', (m) => (m.room.options as any).boardSize === 19); + expect((after.room.options as any).ruleSet).toBe('renju'); + + // Disable the game → creation refused and hidden from catalog. + await api(s.base, '/api/admin/games/omok', { method: 'PUT', cookie: sup.cookie, body: JSON.stringify({ enabled: false }) }); + const refused = await api(s.base, '/api/rooms', { method: 'POST', cookie: host.cookie, body: JSON.stringify({ gameId: 'omok' }) }); + expect(refused.res.status).toBe(400); + expect((await api(s.base, '/api/config')).body.catalog.find((g: any) => g.id === 'omok').available).toBe(false); + // Reset restores code defaults. + await api(s.base, '/api/admin/games/omok/reset', { method: 'POST', cookie: sup.cookie }); + expect((await api(s.base, '/api/config')).body.catalog.find((g: any) => g.id === 'omok')).toMatchObject({ nameKo: '오목', available: true }); + }); +}); + +describe('site settings, rooms, users', () => { + test('maintenance blocks room creation; banned words masked in chat and rejected in nicknames', async () => { + const s = start(); + const sup = await discordLogin(s, SUPER); + const site = (await api(s.base, '/api/admin/site', { cookie: sup.cookie })).body.site; + await api(s.base, '/api/admin/site', { method: 'PUT', cookie: sup.cookie, body: JSON.stringify({ ...site, maintenance: true, announcement: '점검 중이에요' }) }); + const g = await guest(s.base, '방장님'); + const r = await api(s.base, '/api/rooms', { method: 'POST', cookie: g.cookie, body: JSON.stringify({ gameId: 'omok' }) }); + expect(r.res.status).toBe(429); + expect(r.body.error).toBe('점검 중이에요'); + expect((await api(s.base, '/api/config')).body).toMatchObject({ maintenance: true, announcement: '점검 중이에요' }); + await api(s.base, '/api/admin/site', { method: 'PUT', cookie: sup.cookie, body: JSON.stringify({ ...site, bannedWords: ['바보'] }) }); + expect((await api(s.base, '/api/auth/guest', { method: 'POST', body: JSON.stringify({ nickname: '왕바보' }) })).res.status).toBe(400); + const room = await api(s.base, '/api/rooms', { method: 'POST', cookie: g.cookie, body: JSON.stringify({ gameId: 'omok' }) }); + const c = await new Client(s.base, g.cookie, g.id).connect(); + clients.push(c); + c.send({ t: 'join', code: room.body.code, as: 'player' }); + await c.next('room'); + const chat = c.next('chat'); + c.send({ t: 'chat', text: '너 바보야' }); + expect((await chat).text).toBe('너 **야'); + }); + + test('admin closes a room: everyone is told and disconnected from it', async () => { + const s = start(); + const sup = await discordLogin(s, SUPER); + const g = await guest(s.base, '방주인'); + const room = await api(s.base, '/api/rooms', { method: 'POST', cookie: g.cookie, body: JSON.stringify({ gameId: 'omok' }) }); + const c = await new Client(s.base, g.cookie, g.id).connect(); + clients.push(c); + c.send({ t: 'join', code: room.body.code, as: 'player' }); + await c.next('room'); + const list = await api(s.base, '/api/admin/rooms', { cookie: sup.cookie }); + expect(list.body.rooms.map((r: any) => r.code)).toContain(room.body.code); + const bye = c.next('bye'); + expect((await api(s.base, `/api/admin/rooms/${room.body.code}/close`, { method: 'POST', cookie: sup.cookie })).res.status).toBe(200); + expect((await bye).reason).toBe('closed'); + expect((await api(s.base, `/api/rooms/${room.body.code}`)).res.status).toBe(404); + }); + + test('superadmin: search, rename, ban (sessions revoked, socket closed, discord re-login refused), unban', async () => { + const s = start(); + const sup = await discordLogin(s, SUPER); + const victim = await discordLogin(s, '333333333333333333'); + const c = await new Client(s.base, victim.cookie, victim.id).connect(); + clients.push(c); + const found = await api(s.base, '/api/admin/users?q=333333333333333333', { cookie: sup.cookie }); + expect(found.body.users.map((u: any) => u.id)).toEqual([victim.id]); + const ren = await api(s.base, `/api/admin/users/${victim.id}`, { method: 'PATCH', cookie: sup.cookie, body: JSON.stringify({ nickname: '바뀐 이름' }) }); + expect(ren.res.status).toBe(200); + expect((await api(s.base, '/api/me', { cookie: victim.cookie })).body.me.nickname).toBe('바뀐 이름'); + const ban = await api(s.base, `/api/admin/users/${victim.id}`, { method: 'PATCH', cookie: sup.cookie, body: JSON.stringify({ ban: { reason: '도배' } }) }); + expect(ban.res.status).toBe(200); + for (let i = 0; i < 20 && !c.closed; i++) await sleep(25); + expect(c.closed?.code).toBe(4403); + expect((await api(s.base, '/api/me', { cookie: victim.cookie })).body.me).toBeNull(); + await expect(discordLogin(s, '333333333333333333')).rejects.toThrow('/?login=banned'); + // Superadmin cannot ban themselves. + const self = await api(s.base, `/api/admin/users/${sup.id}`, { method: 'PATCH', cookie: sup.cookie, body: JSON.stringify({ ban: { reason: 'x' } }) }); + expect(self.res.status).toBe(400); + await api(s.base, `/api/admin/users/${victim.id}`, { method: 'PATCH', cookie: sup.cookie, body: JSON.stringify({ ban: null }) }); + const again = await discordLogin(s, '333333333333333333'); + expect(again.id).toBe(victim.id); + const actions = (await api(s.base, '/api/admin/audit', { cookie: sup.cookie })).body.entries.map((e: any) => e.action); + expect(actions).toEqual(['user.unban', 'user.ban', 'user.rename']); + }); +}); diff --git a/apps/server/src/admin/roles.ts b/apps/server/src/admin/roles.ts new file mode 100644 index 0000000..dec5d60 --- /dev/null +++ b/apps/server/src/admin/roles.ts @@ -0,0 +1,90 @@ +/** Role resolution by linked Discord ID (docs/14-admin.md §2). Computed per request. */ +import type { Database } from 'bun:sqlite'; + +export type Role = 'user' | 'admin' | 'superadmin'; + +export class Roles { + constructor( + private db: Database, + private superadminDiscordIds: string[], + ) {} + + discordIdOf(userId: string): string | null { + return ( + this.db + .query<{ provider_user_id: string }, [string]>("SELECT provider_user_id FROM oauth_accounts WHERE user_id = ? AND provider = 'discord'") + .get(userId)?.provider_user_id ?? null + ); + } + + roleOfDiscord(discordId: string | null): Role { + if (!discordId) return 'user'; + if (this.superadminDiscordIds.includes(discordId)) return 'superadmin'; + const row = this.db.query<{ n: number }, [string]>('SELECT COUNT(*) AS n FROM admins WHERE discord_id = ?').get(discordId); + return (row?.n ?? 0) > 0 ? 'admin' : 'user'; + } + + roleOf(userId: string | null): Role { + return userId ? this.roleOfDiscord(this.discordIdOf(userId)) : 'user'; + } + + isSuperadminDiscord(discordId: string): boolean { + return this.superadminDiscordIds.includes(discordId); + } + + listAdmins(): { discordId: string; note: string | null; addedBy: string | null; addedAt: number; userId: string | null; nickname: string | null; fixed: boolean }[] { + const rows = this.db + .query<{ discord_id: string; note: string | null; added_by: string | null; added_at: number; user_id: string | null; nickname: string | null }, []>( + `SELECT a.discord_id, a.note, a.added_by, a.added_at, o.user_id, u.nickname FROM admins a + LEFT JOIN oauth_accounts o ON o.provider = 'discord' AND o.provider_user_id = a.discord_id + LEFT JOIN users u ON u.id = o.user_id ORDER BY a.added_at`, + ) + .all() + .map((r) => ({ discordId: r.discord_id, note: r.note, addedBy: r.added_by, addedAt: r.added_at, userId: r.user_id, nickname: r.nickname, fixed: false })); + const fixed = this.superadminDiscordIds.map((d) => { + const u = this.db + .query<{ user_id: string; nickname: string }, [string]>( + "SELECT o.user_id, u.nickname FROM oauth_accounts o JOIN users u ON u.id = o.user_id WHERE o.provider = 'discord' AND o.provider_user_id = ?", + ) + .get(d); + return { discordId: d, note: '슈퍼어드민(설정 고정)', addedBy: null, addedAt: 0, userId: u?.user_id ?? null, nickname: u?.nickname ?? null, fixed: true }; + }); + return [...fixed, ...rows]; + } + + addAdmin(discordId: string, note: string | null, by: string, now = Date.now()): boolean { + return this.db.query('INSERT OR IGNORE INTO admins (discord_id, note, added_by, added_at) VALUES (?, ?, ?, ?)').run(discordId, note, by, now).changes > 0; + } + + removeAdmin(discordId: string): boolean { + return this.db.query('DELETE FROM admins WHERE discord_id = ?').run(discordId).changes > 0; + } +} + +export class Audit { + constructor(private db: Database) {} + + log(actorId: string, action: string, target: string | null, before: unknown, after: unknown, now = Date.now()): void { + this.db + .query('INSERT INTO admin_audit (actor_id, action, target, before_json, after_json, at) VALUES (?, ?, ?, ?, ?, ?)') + .run(actorId, action, target, before === undefined ? null : JSON.stringify(before), after === undefined ? null : JSON.stringify(after), now); + } + + list(limit: number, offset: number) { + return this.db + .query<{ id: number; actor_id: string; nickname: string | null; action: string; target: string | null; before_json: string | null; after_json: string | null; at: number }, [number, number]>( + 'SELECT a.*, u.nickname FROM admin_audit a LEFT JOIN users u ON u.id = a.actor_id ORDER BY a.id DESC LIMIT ? OFFSET ?', + ) + .all(limit, offset) + .map((r) => ({ + id: r.id, + actorId: r.actor_id, + actor: r.nickname, + action: r.action, + target: r.target, + before: r.before_json ? JSON.parse(r.before_json) : null, + after: r.after_json ? JSON.parse(r.after_json) : null, + at: r.at, + })); + } +} diff --git a/apps/server/src/admin/settings.ts b/apps/server/src/admin/settings.ts new file mode 100644 index 0000000..b53133d --- /dev/null +++ b/apps/server/src/admin/settings.ts @@ -0,0 +1,159 @@ +/** + * Admin-editable site and game settings with an in-memory cache (docs/14-admin.md §4–§5). + * Changes apply to new rooms / new games only. + */ +import type { Database } from 'bun:sqlite'; +import { z } from 'zod'; +import type { AnyGameDefinition } from '@bg/engine'; +import { CATALOG, type CatalogEntry } from '@bg/shared'; + +export const SiteSettingsSchema = z.object({ + siteName: z.string().trim().min(1).max(40).default('같이 놀자 보드게임'), + announcement: z.string().trim().max(300).default(''), + maintenance: z.boolean().default(false), + bannedWords: z.array(z.string().trim().min(1).max(30)).max(500).default([]), + maxRoomsPerUser: z.number().int().min(1).max(50).default(3), + maxRooms: z.number().int().min(10).max(20000).default(2000), + graceSec: z.number().int().min(10).max(600).default(60), + chatEnabledDefault: z.boolean().default(true), + chatFilterDefault: z.boolean().default(true), +}); +export type SiteSettings = z.infer; + +export const GameSettingsSchema = z.object({ + enabled: z.boolean().optional(), + nameKo: z.string().trim().min(1).max(30).optional(), + blurb: z.string().trim().max(80).optional(), + notice: z.string().trim().max(500).optional(), + minPlayers: z.number().int().min(1).max(32).optional(), + maxPlayers: z.number().int().min(1).max(32).optional(), + defaultOptions: z.unknown().optional(), + lockedOptions: z.array(z.string().max(60)).max(100).optional(), +}); +export type GameSettings = z.infer; + +export interface EffectiveGame { + id: string; + enabled: boolean; + nameKo: string; + blurb: string; + notice: string; + minPlayers: number; + maxPlayers: number; + defaultOptions: unknown; + lockedOptions: string[]; +} + +export class SettingsStore { + private site: SiteSettings; + private games = new Map(); + + constructor( + private db: Database, + private defs: Record, + ) { + const row = db.query<{ json: string }, [string]>("SELECT json FROM site_settings WHERE key = ?").get('site'); + this.site = SiteSettingsSchema.parse(row ? JSON.parse(row.json) : {}); + for (const r of db.query<{ game_id: string; json: string }, []>('SELECT game_id, json FROM game_settings').all()) { + const parsed = GameSettingsSchema.safeParse(JSON.parse(r.json)); + if (parsed.success) this.games.set(r.game_id, parsed.data); + } + } + + getSite(): SiteSettings { + return this.site; + } + + setSite(next: SiteSettings, by: string, now = Date.now()): void { + this.db + .query('INSERT INTO site_settings (key, json, updated_by, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(key) DO UPDATE SET json = excluded.json, updated_by = excluded.updated_by, updated_at = excluded.updated_at') + .run('site', JSON.stringify(next), by, now); + this.site = next; + } + + rawGame(id: string): GameSettings { + return this.games.get(id) ?? {}; + } + + /** Validates against the game's own schemas. Returns an error message or null. */ + validateGame(id: string, s: GameSettings): string | null { + const def = this.defs[id]; + if (!def) return '없는 게임이에요.'; + if (s.defaultOptions !== undefined) { + const r = def.optionsSchema.safeParse(s.defaultOptions); + if (!r.success) return `기본 규칙 값이 올바르지 않아요: ${r.error.issues.map((i) => i.path.join('.') + ' ' + i.message).join(', ')}`; + } + const min = s.minPlayers ?? def.minPlayers; + const max = s.maxPlayers ?? def.maxPlayers; + if (min < def.minPlayers || max > def.maxPlayers || min > max) { + return `인원은 원작 범위(${def.minPlayers}~${def.maxPlayers}명) 안에서만 정할 수 있어요.`; + } + return null; + } + + setGame(id: string, s: GameSettings, by: string, now = Date.now()): void { + const def = this.defs[id]!; + const clean: GameSettings = { ...s }; + if (clean.defaultOptions !== undefined) clean.defaultOptions = def.optionsSchema.parse(clean.defaultOptions); + this.db + .query('INSERT INTO game_settings (game_id, json, updated_by, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT(game_id) DO UPDATE SET json = excluded.json, updated_by = excluded.updated_by, updated_at = excluded.updated_at') + .run(id, JSON.stringify(clean), by, now); + this.games.set(id, clean); + } + + resetGame(id: string): void { + this.db.query('DELETE FROM game_settings WHERE game_id = ?').run(id); + this.games.delete(id); + } + + game(id: string): EffectiveGame | null { + const def = this.defs[id]; + if (!def) return null; + const meta = CATALOG.find((c) => c.id === id); + const s = this.rawGame(id); + let defaults: unknown = def.defaultOptions; + if (s.defaultOptions !== undefined) { + const r = def.optionsSchema.safeParse(s.defaultOptions); + if (r.success) defaults = r.data; + } + return { + id, + enabled: s.enabled ?? meta?.available ?? true, + nameKo: s.nameKo ?? meta?.nameKo ?? def.nameKo, + blurb: s.blurb ?? meta?.blurb ?? '', + notice: s.notice ?? '', + minPlayers: s.minPlayers ?? def.minPlayers, + maxPlayers: s.maxPlayers ?? def.maxPlayers, + defaultOptions: defaults, + lockedOptions: s.lockedOptions ?? [], + }; + } + + /** Catalog for /api/config: code metadata merged with admin overrides. */ + catalog(): (CatalogEntry & { notice: string })[] { + return CATALOG.map((c) => { + const g = this.game(c.id); + if (!g) return { ...c, available: false, notice: '' }; + return { ...c, nameKo: g.nameKo, blurb: g.blurb, minPlayers: g.minPlayers, maxPlayers: g.maxPlayers, available: g.enabled, notice: g.notice }; + }); + } +} + +/** Forces locked option paths (dot-separated) to the admin default values. */ +export function applyLocks(options: unknown, defaults: unknown, locked: string[]): unknown { + if (!locked.length || typeof options !== 'object' || options === null) return options; + const out = structuredClone(options) as Record; + for (const path of locked) { + const keys = path.split('.'); + let src: unknown = defaults; + for (const k of keys) src = (src as Record | undefined)?.[k]; + if (src === undefined) continue; + let dst = out; + for (const k of keys.slice(0, -1)) { + if (typeof dst[k] !== 'object' || dst[k] === null) dst[k] = {}; + dst = dst[k] as Record; + } + dst[keys[keys.length - 1]!] = structuredClone(src); + } + return out; +} diff --git a/apps/server/src/auth/sessions.ts b/apps/server/src/auth/sessions.ts index 865f0cc..cf17767 100644 --- a/apps/server/src/auth/sessions.ts +++ b/apps/server/src/auth/sessions.ts @@ -32,7 +32,7 @@ export class Sessions { const row = this.db .query<{ user_id: string; expires_at: number; last_used_at: number; kind: 'guest' | 'member' }, [string]>( `SELECT s.user_id, s.expires_at, s.last_used_at, u.kind FROM sessions s JOIN users u ON u.id = s.user_id - WHERE s.token_hash = ? AND u.deleted_at IS NULL`, + WHERE s.token_hash = ? AND u.deleted_at IS NULL AND u.banned_at IS NULL`, ) .get(hash); if (!row) return null; diff --git a/apps/server/src/auth/users.ts b/apps/server/src/auth/users.ts index 900b061..d7742b1 100644 --- a/apps/server/src/auth/users.ts +++ b/apps/server/src/auth/users.ts @@ -14,6 +14,8 @@ export interface UserRow { created_at: number; last_seen_at: number; deleted_at: number | null; + banned_at: number | null; + banned_reason: string | null; } export const NICK_CHANGES_PER_DAY = 10; @@ -111,6 +113,39 @@ export class Users { } } + /** Admin rename: ignores the daily limit. */ + forceNickname(userId: string, nickname: string): void { + this.db.query('UPDATE users SET nickname = ? WHERE id = ?').run(nickname, userId); + } + + setBan(userId: string, reason: string | null, now = Date.now()): void { + if (reason === null) this.db.query('UPDATE users SET banned_at = NULL, banned_reason = NULL WHERE id = ?').run(userId); + else this.db.query('UPDATE users SET banned_at = ?, banned_reason = ? WHERE id = ?').run(now, reason, userId); + } + + search(q: string, limit: number, offset: number) { + const like = `%${q.replace(/[%_]/g, (m) => '\\' + m)}%`; + const rows = this.db + .query( + `SELECT u.*, o.provider_user_id AS discord_id, o.username AS discord_name FROM users u + LEFT JOIN oauth_accounts o ON o.user_id = u.id AND o.provider = 'discord' + WHERE u.deleted_at IS NULL AND (? = '' OR u.nickname LIKE ? ESCAPE '\\' OR u.id = ? OR o.provider_user_id = ?) + ORDER BY u.last_seen_at DESC LIMIT ? OFFSET ?`, + ) + .all(q, like, q, q, limit, offset); + const total = this.db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM users WHERE deleted_at IS NULL').get()!.n; + return { rows, total }; + } + + counts(): { total: number; guests: number; members: number; banned: number } { + return this.db + .query<{ total: number; guests: number; members: number; banned: number }, []>( + `SELECT COUNT(*) AS total, SUM(kind = 'guest') AS guests, SUM(kind = 'member') AS members, SUM(banned_at IS NOT NULL) AS banned + FROM users WHERE deleted_at IS NULL`, + ) + .get()!; + } + touch(userId: string, now = Date.now()): void { this.db.query('UPDATE users SET last_seen_at = ? WHERE id = ?').run(now, userId); } diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index a8dd278..12a4e86 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -9,8 +9,12 @@ export interface Config { secureCookies: boolean; /** Trust X-Forwarded-For (only when running behind our reverse proxy). */ trustProxy: boolean; + superadminDiscordIds: string[]; } +/** The site owner's Discord ID (docs/14-admin.md §2). */ +export const DEFAULT_SUPERADMIN = '293719842274541579'; + export function loadConfig(env: Record = process.env): Config { const publicOrigin = (env.PUBLIC_ORIGIN ?? 'http://localhost:5173').replace(/\/$/, ''); const extra = (env.ALLOWED_ORIGINS ?? '').split(',').map((s) => s.trim()).filter(Boolean); @@ -29,5 +33,6 @@ export function loadConfig(env: Record = process.env sessionSecret: sessionSecret || 'dev-only-insecure-session-secret-change-me', secureCookies: publicOrigin.startsWith('https://'), trustProxy: env.TRUST_PROXY === '1', + superadminDiscordIds: (env.SUPERADMIN_DISCORD_IDS ?? DEFAULT_SUPERADMIN).split(',').map((s) => s.trim()).filter(Boolean), }; } diff --git a/apps/server/src/db/migrations/002_admin.sql b/apps/server/src/db/migrations/002_admin.sql new file mode 100644 index 0000000..5ad9c24 --- /dev/null +++ b/apps/server/src/db/migrations/002_admin.sql @@ -0,0 +1,34 @@ +CREATE TABLE admins ( + discord_id TEXT PRIMARY KEY, + note TEXT, + added_by TEXT, + added_at INTEGER NOT NULL +); + +CREATE TABLE game_settings ( + game_id TEXT PRIMARY KEY, + json TEXT NOT NULL, + updated_by TEXT, + updated_at INTEGER NOT NULL +); + +CREATE TABLE site_settings ( + key TEXT PRIMARY KEY, + json TEXT NOT NULL, + updated_by TEXT, + updated_at INTEGER NOT NULL +); + +CREATE TABLE admin_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor_id TEXT NOT NULL, + action TEXT NOT NULL, + target TEXT, + before_json TEXT, + after_json TEXT, + at INTEGER NOT NULL +); +CREATE INDEX admin_audit_at ON admin_audit(at); + +ALTER TABLE users ADD COLUMN banned_at INTEGER; +ALTER TABLE users ADD COLUMN banned_reason TEXT; diff --git a/apps/server/src/http/admin.ts b/apps/server/src/http/admin.ts new file mode 100644 index 0000000..6f1fda9 --- /dev/null +++ b/apps/server/src/http/admin.ts @@ -0,0 +1,239 @@ +/** Admin API (docs/14-admin.md §8). Mounted under /api/admin by createHttpApp. */ +import { Hono, type Context } from 'hono'; +import { z } from 'zod'; +import type { AnyGameDefinition } from '@bg/engine'; +import { nicknameError, normalizeNickname } from '@bg/shared'; +import type { Audit, Role, Roles } from '../admin/roles'; +import { GameSettingsSchema, SiteSettingsSchema, type SettingsStore } from '../admin/settings'; +import type { Sessions } from '../auth/sessions'; +import { toPublicUser, type Users } from '../auth/users'; +import type { RoomManager } from '../rooms/manager'; + +export interface AdminDeps { + users: Users; + sessions: Sessions; + rooms: RoomManager; + roles: Roles; + audit: Audit; + settings: SettingsStore; + games: Record; + disconnectUser: (userId: string) => void; + stats: () => Record; +} + +type Env = { Variables: { userId: string | null; role: Role } }; + +const DISCORD_ID = /^\d{17,20}$/; + +export function createAdminApp(d: AdminDeps) { + const app = new Hono(); + + app.use('*', async (c, next) => { + const role = d.roles.roleOf(c.get('userId')); + if (role === 'user') return c.json({ error: '권한이 없어요.' }, c.get('userId') ? 403 : 401); + c.set('role', role); + await next(); + }); + + const superOnly = async (c: Context, next: () => Promise) => { + if (c.get('role') !== 'superadmin') return c.json({ error: '슈퍼어드민만 할 수 있어요.' }, 403); + await next(); + }; + const actor = (c: Context) => c.get('userId')!; + const body = async (c: Context) => c.req.json().catch(() => null); + + app.get('/me', (c) => c.json({ role: c.get('role'), me: toPublicUser(d.users.get(actor(c))!) })); + + app.get('/overview', (c) => c.json({ users: d.users.counts(), ...d.stats() })); + + // ------------------------------------------------------------ games + app.get('/games', (c) => + c.json({ + games: Object.values(d.games).map((def) => ({ + id: def.id, + codeDefaults: { nameKo: def.nameKo, minPlayers: def.minPlayers, maxPlayers: def.maxPlayers, defaultOptions: def.defaultOptions }, + raw: d.settings.rawGame(def.id), + effective: d.settings.game(def.id), + schema: z.toJSONSchema(def.optionsSchema, { io: 'input', unrepresentable: 'any' }), + })), + }), + ); + + app.put('/games/:id', async (c) => { + const id = c.req.param('id'); + if (!d.games[id]) return c.json({ error: '없는 게임이에요.' }, 404); + const parsed = GameSettingsSchema.safeParse(await body(c)); + if (!parsed.success) return c.json({ error: '입력 값이 올바르지 않아요.' }, 400); + const err = d.settings.validateGame(id, parsed.data); + if (err) return c.json({ error: err }, 400); + const before = d.settings.rawGame(id); + d.settings.setGame(id, parsed.data, actor(c)); + d.audit.log(actor(c), 'game.update', id, before, d.settings.rawGame(id)); + return c.json({ effective: d.settings.game(id), raw: d.settings.rawGame(id) }); + }); + + app.post('/games/:id/reset', (c) => { + const id = c.req.param('id'); + if (!d.games[id]) return c.json({ error: '없는 게임이에요.' }, 404); + const before = d.settings.rawGame(id); + d.settings.resetGame(id); + d.audit.log(actor(c), 'game.reset', id, before, null); + return c.json({ effective: d.settings.game(id), raw: {} }); + }); + + // ------------------------------------------------------------ site + app.get('/site', (c) => c.json({ site: d.settings.getSite() })); + app.put('/site', async (c) => { + const parsed = SiteSettingsSchema.safeParse(await body(c)); + if (!parsed.success) return c.json({ error: `입력 값이 올바르지 않아요: ${parsed.error.issues.map((i) => i.path.join('.')).join(', ')}` }, 400); + const before = d.settings.getSite(); + d.settings.setSite(parsed.data, actor(c)); + d.audit.log(actor(c), 'site.update', null, before, parsed.data); + return c.json({ site: parsed.data }); + }); + + // ------------------------------------------------------------ rooms + app.get('/rooms', (c) => + c.json({ + rooms: d.rooms.all().map((r) => ({ + code: r.code, + gameId: r.config.gameId, + status: r.status, + host: d.users.get(r.hostId)?.nickname ?? null, + seats: r.seats.map((s) => (s ? { id: s.userId, nickname: d.users.get(s.userId)?.nickname ?? '?', connected: !!r.presence.get(s.userId)?.connected } : null)), + spectators: r.spectators.size, + connections: r.conns.size, + visibility: r.config.visibility, + gameNo: r.gameNo, + lastActiveAt: r.lastActiveAt, + })), + }), + ); + app.post('/rooms/:code/close', (c) => { + const code = c.req.param('code'); + if (!d.rooms.closeByAdmin(code)) return c.json({ error: '방을 찾을 수 없어요.' }, 404); + d.audit.log(actor(c), 'room.close', code, null, null); + return c.json({ ok: true }); + }); + + // ------------------------------------------------------------ audit + app.get('/audit', (c) => { + const page = Math.max(0, Number(c.req.query('page') ?? 0) || 0); + return c.json({ entries: d.audit.list(50, page * 50) }); + }); + + // ------------------------------------------------------------ users (superadmin) + app.use('/users/*', superOnly); + app.use('/users', superOnly); + app.get('/users', (c) => { + const q = (c.req.query('q') ?? '').trim().slice(0, 60); + const page = Math.max(0, Number(c.req.query('page') ?? 0) || 0); + const { rows, total } = d.users.search(q, 30, page * 30); + return c.json({ + total, + users: rows.map((u) => ({ + id: u.id, + nickname: u.nickname, + kind: u.kind, + discordId: u.discord_id, + discordName: u.discord_name, + role: d.roles.roleOfDiscord(u.discord_id), + banned: u.banned_at ? { at: u.banned_at, reason: u.banned_reason } : null, + createdAt: u.created_at, + lastSeenAt: u.last_seen_at, + })), + }); + }); + + app.get('/users/:id', (c) => { + const u = d.users.get(c.req.param('id')); + if (!u) return c.json({ error: '사용자를 찾을 수 없어요.' }, 404); + const discordId = d.roles.discordIdOf(u.id); + return c.json({ + user: { + ...toPublicUser(u), + discordId, + role: d.roles.roleOfDiscord(discordId), + banned: u.banned_at ? { at: u.banned_at, reason: u.banned_reason } : null, + createdAt: u.created_at, + lastSeenAt: u.last_seen_at, + activeRoom: d.rooms.activeRoomFor(u.id), + }, + stats: d.users.stats(u.id), + }); + }); + + app.patch('/users/:id', async (c) => { + const id = c.req.param('id'); + const u = d.users.get(id); + if (!u) return c.json({ error: '사용자를 찾을 수 없어요.' }, 404); + const parsed = z + .object({ nickname: z.string().max(100).optional(), ban: z.object({ reason: z.string().trim().min(1).max(200) }).nullable().optional() }) + .safeParse(await body(c)); + if (!parsed.success) return c.json({ error: '입력 값이 올바르지 않아요.' }, 400); + const discordId = d.roles.discordIdOf(id); + if (parsed.data.ban && discordId && d.roles.isSuperadminDiscord(discordId)) return c.json({ error: '슈퍼어드민은 제한할 수 없어요.' }, 400); + if (parsed.data.nickname !== undefined) { + const nick = normalizeNickname(parsed.data.nickname); + const e = nicknameError(nick, d.settings.getSite().bannedWords); + if (e) return c.json({ error: e }, 400); + d.users.forceNickname(id, nick); + d.audit.log(actor(c), 'user.rename', id, u.nickname, nick); + } + if (parsed.data.ban !== undefined) { + if (parsed.data.ban) { + d.users.setBan(id, parsed.data.ban.reason); + d.sessions.revokeAll(id); + d.disconnectUser(id); + d.audit.log(actor(c), 'user.ban', id, null, parsed.data.ban); + } else { + d.users.setBan(id, null); + d.audit.log(actor(c), 'user.unban', id, { reason: u.banned_reason }, null); + } + } + return c.json({ ok: true }); + }); + + app.post('/users/:id/logout', (c) => { + const id = c.req.param('id'); + if (!d.users.get(id)) return c.json({ error: '사용자를 찾을 수 없어요.' }, 404); + d.sessions.revokeAll(id); + d.disconnectUser(id); + d.audit.log(actor(c), 'user.logout', id, null, null); + return c.json({ ok: true }); + }); + + app.delete('/users/:id', (c) => { + const id = c.req.param('id'); + const u = d.users.get(id); + if (!u) return c.json({ error: '사용자를 찾을 수 없어요.' }, 404); + const discordId = d.roles.discordIdOf(id); + if (discordId && d.roles.isSuperadminDiscord(discordId)) return c.json({ error: '슈퍼어드민은 삭제할 수 없어요.' }, 400); + d.disconnectUser(id); + d.users.softDelete(id); + d.audit.log(actor(c), 'user.delete', id, { nickname: u.nickname, discordId }, null); + return c.json({ ok: true }); + }); + + // ------------------------------------------------------------ admins (superadmin) + app.use('/admins/*', superOnly); + app.use('/admins', superOnly); + app.get('/admins', (c) => c.json({ admins: d.roles.listAdmins() })); + app.post('/admins', async (c) => { + const parsed = z.object({ discordId: z.string().trim(), note: z.string().trim().max(100).optional() }).safeParse(await body(c)); + if (!parsed.success || !DISCORD_ID.test(parsed.data.discordId)) return c.json({ error: '디스코드 ID는 숫자 17~20자리예요.' }, 400); + if (d.roles.isSuperadminDiscord(parsed.data.discordId)) return c.json({ error: '이미 슈퍼어드민이에요.' }, 400); + if (!d.roles.addAdmin(parsed.data.discordId, parsed.data.note ?? null, actor(c))) return c.json({ error: '이미 등록된 어드민이에요.' }, 400); + d.audit.log(actor(c), 'admin.add', parsed.data.discordId, null, { note: parsed.data.note ?? null }); + return c.json({ admins: d.roles.listAdmins() }); + }); + app.delete('/admins/:discordId', (c) => { + const id = c.req.param('discordId'); + if (d.roles.isSuperadminDiscord(id)) return c.json({ error: '설정 파일의 슈퍼어드민은 지울 수 없어요.' }, 400); + if (!d.roles.removeAdmin(id)) return c.json({ error: '등록되지 않은 디스코드 ID예요.' }, 404); + d.audit.log(actor(c), 'admin.remove', id, null, null); + return c.json({ admins: d.roles.listAdmins() }); + }); + + return app; +} diff --git a/apps/server/src/http/app.ts b/apps/server/src/http/app.ts index 6dd7f27..95e85e9 100644 --- a/apps/server/src/http/app.ts +++ b/apps/server/src/http/app.ts @@ -2,13 +2,16 @@ import { Hono, type Context } from 'hono'; import { deleteCookie, getCookie, setCookie } from 'hono/cookie'; import { z } from 'zod'; -import { CATALOG, catalogById, nicknameError, normalizeNickname, normalizeRoomCode, formatRoomCode } from '@bg/shared'; +import { nicknameError, normalizeNickname, normalizeRoomCode, formatRoomCode } from '@bg/shared'; import type { Config } from '../config'; import { authorizeUrl, avatarUrl, exchangeCode, safeNext, type DiscordUser } from '../auth/discord'; import { SESSION_COOKIE, SESSION_TTL, randomToken, signValue, unsignValue, type Sessions } from '../auth/sessions'; import { toPublicUser, type Users } from '../auth/users'; import type { RoomManager } from '../rooms/manager'; import { WindowLimiter } from '../ws/rate-limit'; +import { createAdminApp, type AdminDeps } from './admin'; +import type { Roles } from '../admin/roles'; +import type { SettingsStore } from '../admin/settings'; export interface HttpDeps { config: Config; @@ -19,6 +22,9 @@ export interface HttpDeps { discordExchange?: (code: string, redirectUri: string) => Promise; ipOf: (req: Request) => string; health: () => { ok: boolean; [k: string]: unknown }; + roles: Roles; + settings: SettingsStore; + admin: Omit; } type Env = { Variables: { userId: string | null } }; @@ -68,14 +74,23 @@ export function createHttpApp(d: HttpDeps) { return c.json(h, h.ok ? 200 : 503); }); - app.get('/api/config', (c) => c.json({ discord: d.config.discord !== null, catalog: CATALOG })); + app.get('/api/config', (c) => { + const site = d.settings.getSite(); + return c.json({ + discord: d.config.discord !== null, + catalog: d.settings.catalog(), + siteName: site.siteName, + announcement: site.announcement, + maintenance: site.maintenance, + }); + }); app.post('/api/auth/guest', async (c) => { if (!guestLimiter.allow(d.ipOf(c.req.raw))) return c.json({ error: '잠시 후 다시 시도해 주세요.' }, 429); const body = z.object({ nickname: z.string().max(100) }).safeParse(await c.req.json().catch(() => null)); if (!body.success) return c.json({ error: '닉네임을 입력해 주세요.' }, 400); const nick = normalizeNickname(body.data.nickname); - const e = nicknameError(nick); + const e = nicknameError(nick, d.settings.getSite().bannedWords); if (e) return c.json({ error: e }, 400); const current = requireUser(c); if (current) { @@ -119,6 +134,7 @@ export function createHttpApp(d: HttpDeps) { } const avatar = avatarUrl(du); const existing = d.users.findByOauth('discord', du.id); + if (existing?.banned_at) return c.redirect('/?login=banned'); const current = requireUser(c) ? d.users.get(requireUser(c)!) : null; let userId: string; let merged = false; @@ -170,6 +186,7 @@ export function createHttpApp(d: HttpDeps) { me: toPublicUser(row), settings: JSON.parse(row.settings_json), useAvatar: !!row.use_avatar, + role: d.roles.roleOf(row.id), hasDiscordAvatar: !!row.avatar_url, activeRoom: d.rooms.activeRoomFor(row.id), }); @@ -197,7 +214,7 @@ export function createHttpApp(d: HttpDeps) { if (!body.success) return c.json({ error: '잘못된 요청이에요.' }, 400); if (body.data.nickname !== undefined) { const nick = normalizeNickname(body.data.nickname); - const e = nicknameError(nick) ?? d.users.changeNickname(u, nick); + const e = nicknameError(nick, d.settings.getSite().bannedWords) ?? d.users.changeNickname(u, nick); if (e) return c.json({ error: e }, 400); } d.users.updateSettings(u, { useAvatar: body.data.useAvatar, settings: body.data.settings }); @@ -227,7 +244,7 @@ export function createHttpApp(d: HttpDeps) { const body = z .object({ gameId: z.string().max(40), visibility: z.enum(['private', 'public']).optional() }) .safeParse(await c.req.json().catch(() => null)); - if (!body.success || !catalogById(body.data.gameId)?.available) return c.json({ error: '없는 게임이에요.' }, 400); + if (!body.success || !d.settings.game(body.data.gameId)?.enabled) return c.json({ error: '없는 게임이에요.' }, 400); const r = d.rooms.create(u, body.data.gameId, body.data.visibility); if (typeof r === 'string') return c.json({ error: r }, 429); return c.json({ code: r.code }); @@ -237,7 +254,7 @@ export function createHttpApp(d: HttpDeps) { const u = requireUser(c); if (!u) return c.json({ error: '로그인이 필요해요.' }, 401); const gameId = c.req.param('gameId'); - if (!catalogById(gameId)?.available) return c.json({ error: '없는 게임이에요.' }, 400); + if (!d.settings.game(gameId)?.enabled) return c.json({ error: '없는 게임이에요.' }, 400); if (!createLimiter.allow(d.ipOf(c.req.raw))) return c.json({ error: '잠시 후 다시 시도해 주세요.' }, 429); const r = d.rooms.quickPlay(u, gameId); if (typeof r === 'string') return c.json({ error: r }, 429); @@ -264,5 +281,10 @@ export function createHttpApp(d: HttpDeps) { app.get('/api/games/:gameId/rooms', (c) => c.json({ rooms: d.rooms.publicRooms(c.req.param('gameId')) })); + app.route( + '/api/admin', + createAdminApp({ ...d.admin, users: d.users, sessions: d.sessions, rooms: d.rooms, roles: d.roles, settings: d.settings }), + ); + return app; } diff --git a/apps/server/src/rooms/manager.ts b/apps/server/src/rooms/manager.ts index 205b9a5..98b5621 100644 --- a/apps/server/src/rooms/manager.ts +++ b/apps/server/src/rooms/manager.ts @@ -4,6 +4,7 @@ import type { AnyGameDefinition } from '@bg/engine'; import { isAllowedRoomCode, type PublicUser } from '@bg/shared'; import { Room, type RoomConfig, type RoomDeps, type RoomSnapshot } from './room'; import type { RoomStore } from './store'; +import type { SettingsStore } from '../admin/settings'; export const MAX_ROOMS = 2000; export const MAX_ROOMS_PER_USER = 3; @@ -17,6 +18,7 @@ export interface ManagerDeps { now?: () => number; log?: RoomDeps['log']; random?: () => number; + settings?: SettingsStore; } export class RoomManager { @@ -37,6 +39,7 @@ export class RoomManager { users: this.deps.users, now: this.now, log: this.deps.log, + settings: this.deps.settings, onSeatChange: (room, userId, seated) => { const set = this.seatIndex.get(userId) ?? new Set(); if (seated) set.add(room.code); @@ -69,19 +72,26 @@ export class RoomManager { /** Returns the room or a Korean error message. */ create(hostId: string, gameId: string, visibility: 'private' | 'public' = 'private'): Room | string { const def = this.deps.games[gameId]; - if (!def) return '없는 게임이에요.'; - if (this.byCode.size >= MAX_ROOMS) return '지금은 방이 너무 많아요. 잠시 후 다시 시도해 주세요.'; + const eff = this.deps.settings?.game(gameId); + const site = this.deps.settings?.getSite(); + if (!def || (eff && !eff.enabled)) return '없는 게임이에요.'; + if (site?.maintenance) return site.announcement || '지금은 점검 중이라 새 방을 만들 수 없어요.'; + const maxRooms = site?.maxRooms ?? MAX_ROOMS; + const perUser = site?.maxRoomsPerUser ?? MAX_ROOMS_PER_USER; + if (this.byCode.size >= maxRooms) return '지금은 방이 너무 많아요. 잠시 후 다시 시도해 주세요.'; const mine = [...(this.createdBy.get(hostId) ?? [])].filter((c) => this.byCode.has(c)); - if (mine.length >= MAX_ROOMS_PER_USER) return `방은 한 번에 ${MAX_ROOMS_PER_USER}개까지 만들 수 있어요.`; + if (mine.length >= perUser) return `방은 한 번에 ${perUser}개까지 만들 수 있어요.`; + const options = eff?.defaultOptions ?? def.defaultOptions; + const base = def.playersFor ? def.playersFor(options).max : def.maxPlayers; const config: RoomConfig = { gameId, - options: def.defaultOptions, - maxPlayers: def.playersFor ? def.playersFor(def.defaultOptions).max : def.maxPlayers, + options, + maxPlayers: Math.min(base, eff?.maxPlayers ?? base), visibility, allowSpectators: true, - chatEnabled: true, - chatFilter: true, - graceSec: 60, + chatEnabled: site?.chatEnabledDefault ?? true, + chatFilter: site?.chatFilterDefault ?? true, + graceSec: site?.graceSec ?? 60, }; const code = this.newCode(); const id = ulid(this.now()); @@ -174,6 +184,14 @@ export class RoomManager { } } + closeByAdmin(code: string): boolean { + const room = this.byCode.get(code); + if (!room) return false; + room.closeByAdmin(); + this.close(room); + return true; + } + close(room: Room): void { room.shutdown(); this.deps.store.close(room.id, this.now()); diff --git a/apps/server/src/rooms/room.ts b/apps/server/src/rooms/room.ts index 338ab6b..79ff59c 100644 --- a/apps/server/src/rooms/room.ts +++ b/apps/server/src/rooms/room.ts @@ -7,6 +7,7 @@ import { SeededRng, createSeed, seedToHex, type AnyGameDefinition, type GameResu import { maskProfanity, type PublicUser, type RoomView, type ServerMessage, type SeatView } from '@bg/shared'; import { sha256Hex } from '../auth/sessions'; import type { RoomStore } from './store'; +import { applyLocks, type EffectiveGame, type SiteSettings } from '../admin/settings'; export interface Conn { userId: string; @@ -76,6 +77,8 @@ export interface RoomDeps { log?: (level: 'info' | 'warn' | 'error', msg: string, extra?: Record) => void; onEmpty?: (room: Room) => void; onSeatChange?: (room: Room, userId: string, seated: boolean) => void; + /** Admin settings (docs/14-admin.md). Optional so unit tests can run without them. */ + settings?: { game(id: string): EffectiveGame | null; getSite(): SiteSettings }; } const CHAT_HISTORY = 30; @@ -126,8 +129,23 @@ export class Room { } playerRange(): { min: number; max: number } { - const d = this.def; - return d.playersFor ? d.playersFor(this.config.options) : { min: d.minPlayers, max: d.maxPlayers }; + return this.rangeFor(this.config.gameId, this.config.options); + } + + /** Game's own range (per options) intersected with the admin-configured range. */ + private rangeFor(gameId: string, options: unknown): { min: number; max: number } { + const d = this.deps.games[gameId]!; + const base = d.playersFor ? d.playersFor(options) : { min: d.minPlayers, max: d.maxPlayers }; + const eff = this.deps.settings?.game(gameId); + if (!eff) return base; + const min = Math.max(base.min, eff.minPlayers); + const max = Math.min(base.max, eff.maxPlayers); + return min <= max ? { min, max } : base; + } + + private locked(gameId: string): { defaults: unknown; paths: string[] } { + const eff = this.deps.settings?.game(gameId); + return { defaults: eff?.defaultOptions, paths: eff?.lockedOptions ?? [] }; } seatOf(userId: string): number { @@ -239,6 +257,7 @@ export class Room { rematchVotes: [...this.rematchVotes], sessionStats: this.sessionStats, seedHash: this.game?.seedHash ?? null, + lockedOptions: this.locked(this.config.gameId).paths, lastSeed: this.lastSeed, }; } @@ -446,10 +465,11 @@ export class Room { const next: RoomConfig = { ...this.config }; if (patch.gameId !== undefined && patch.gameId !== this.config.gameId) { const def = this.deps.games[patch.gameId]; - if (!def) return '없는 게임이에요.'; + const eff = this.deps.settings?.game(patch.gameId); + if (!def || (eff && !eff.enabled)) return '없는 게임이에요.'; next.gameId = patch.gameId; - next.options = def.defaultOptions; - next.maxPlayers = def.maxPlayers; + next.options = eff?.defaultOptions ?? def.defaultOptions; + next.maxPlayers = eff?.maxPlayers ?? def.maxPlayers; } const def = this.deps.games[next.gameId]!; if (patch.options !== undefined) { @@ -457,7 +477,10 @@ export class Room { if (!parsed.success) return '설정 값이 올바르지 않아요.'; next.options = parsed.data; } - const range = def.playersFor ? def.playersFor(next.options) : { min: def.minPlayers, max: def.maxPlayers }; + // Admin-locked options always keep the admin value (docs/14-admin.md §4). + const lk = this.locked(next.gameId); + if (lk.paths.length) next.options = def.optionsSchema.parse(applyLocks(next.options, lk.defaults, lk.paths)); + const range = this.rangeFor(next.gameId, next.options); if (patch.maxPlayers !== undefined) next.maxPlayers = patch.maxPlayers; next.maxPlayers = Math.min(Math.max(next.maxPlayers, range.min), range.max); if (next.maxPlayers < this.seatedIds().length) return `이미 ${this.seatedIds().length}명이 앉아 있어요.`; @@ -517,7 +540,7 @@ export class Room { const msg: Extract = { t: 'chat', from: this.deps.users.publicUser(userId), - text: this.config.chatFilter ? maskProfanity(clean) : clean, + text: this.config.chatFilter ? maskProfanity(clean, this.deps.settings?.getSite().bannedWords ?? []) : clean, at: now, }; this.chatLog.push(msg); @@ -570,8 +593,12 @@ export class Room { private startGame(players: string[]): string | null { const def = this.def; - const parsed = def.optionsSchema.safeParse(this.config.options); + const lk = this.locked(def.id); + const parsed = def.optionsSchema.safeParse(lk.paths.length ? applyLocks(this.config.options, lk.defaults, lk.paths) : this.config.options); if (!parsed.success) return '설정 값이 올바르지 않아요.'; + const eff = this.deps.settings?.game(def.id); + if (eff && !eff.enabled) return '관리자가 이 게임을 잠시 막아 두었어요.'; + this.config = { ...this.config, options: parsed.data }; const seed = createSeed(); const seedHex = seedToHex(seed); const rng = SeededRng.fromSeed(seed); @@ -800,6 +827,20 @@ export class Room { if (st) conn.send(st); } + /** Admin closes the room: void any running game, tell everyone, disconnect. */ + closeByAdmin(): void { + if (this.game && !this.game.finished) { + this.finishGame({ ranking: [this.game.players], summary: '관리자가 방을 닫아 무효 처리됐어요.', reason: 'abandoned' }, true); + } + this.notice('closed', '관리자가 방을 닫았어요.'); + this.clearTimer(); + for (const c of this.conns.values()) { + c.send({ t: 'bye', reason: 'closed' }); + c.close(4002, 'closed'); + } + this.conns.clear(); + } + shutdown(): void { this.clearTimer(); for (const c of this.conns.values()) { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 4953756..337f250 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -11,6 +11,8 @@ import { createHttpApp, type HttpDeps } from './http/app'; import { RoomManager } from './rooms/manager'; import { RoomStore } from './rooms/store'; import { Gateway, type WsData } from './ws/gateway'; +import { Audit, Roles } from './admin/roles'; +import { SettingsStore } from './admin/settings'; export type LogFn = (level: 'info' | 'warn' | 'error', msg: string, extra?: Record) => void; @@ -38,7 +40,11 @@ export function startServer(opts: StartOptions) { const u = users.get(id); return u ? toPublicUser(u) : null; }; - const rooms = new RoomManager({ store, games: GAMES, users: { publicUser }, log }); + const settings = new SettingsStore(db, GAMES); + const roles = new Roles(db, config.superadminDiscordIds); + const audit = new Audit(db); + const startedAt = Date.now(); + const rooms = new RoomManager({ store, games: GAMES, users: { publicUser }, log, settings }); const restored = rooms.restoreAll(); log('info', 'rooms restored', restored); const gateway = new Gateway({ rooms, me: publicUser, log }); @@ -57,6 +63,24 @@ export function startServer(opts: StartOptions) { ipOf, discordExchange: opts.discordExchange, health: () => ({ ok: ready, rooms: rooms.all().length, connections: gateway.connectionCount }), + roles, + settings, + admin: { + audit, + games: GAMES, + disconnectUser: (id) => gateway.disconnectUser(id), + stats: () => { + const all = rooms.all(); + const day = Date.now() - 24 * 3600_000; + return { + connections: gateway.connectionCount, + rooms: { total: all.length, lobby: all.filter((r) => r.status === 'lobby').length, playing: all.filter((r) => r.status === 'playing').length }, + games: db.query<{ total: number; today: number }, [number]>('SELECT COUNT(*) AS total, SUM(started_at > ?) AS today FROM games').get(day), + uptimeSec: Math.round((Date.now() - startedAt) / 1000), + memoryMb: Math.round(process.memoryUsage().rss / 1048576), + }; + }, + }, }); const staticDir = opts.staticDir === undefined ? join(import.meta.dir, '../../web/dist') : opts.staticDir; @@ -114,7 +138,7 @@ export function startServer(opts: StartOptions) { db.close(); }; - return { server, stop, rooms, users, sessions, db, gateway }; + return { server, stop, rooms, users, sessions, db, gateway, settings, roles }; } async function serveFile(root: string, pathname: string): Promise { diff --git a/apps/server/src/ws/gateway.ts b/apps/server/src/ws/gateway.ts index b0201fc..42ab45a 100644 --- a/apps/server/src/ws/gateway.ts +++ b/apps/server/src/ws/gateway.ts @@ -195,6 +195,14 @@ export class Gateway { if (e) err(e); } + /** Drop every connection of a user (ban / forced logout). */ + disconnectUser(userId: string): void { + for (const ws of [...(this.byUser.get(userId) ?? [])]) { + this.send(ws, { t: 'bye', reason: 'kicked' }); + ws.close(4403, 'banned'); + } + } + /** Graceful shutdown: tell everyone to reconnect shortly (docs/03 §10). */ shutdown(): void { this.closing = true; diff --git a/apps/web/src/components/GameNotice.tsx b/apps/web/src/components/GameNotice.tsx new file mode 100644 index 0000000..789cadf --- /dev/null +++ b/apps/web/src/components/GameNotice.tsx @@ -0,0 +1,18 @@ +import { catalogById } from '@bg/shared/lite'; +import { useSession } from '../store/session'; + +/** Display name with admin overrides applied. */ +export function gameName(id: string): string { + return useSession.getState().catalog.find((c) => c.id === id)?.nameKo ?? catalogById(id)?.nameKo ?? '게임'; +} + +/** Admin notice for a game (docs/14-admin.md §4). */ +export function GameNotice({ gameId }: { gameId: string }) { + const notice = useSession((s) => s.catalog.find((c) => c.id === gameId)?.notice); + if (!notice) return null; + return ( +

+ 📢 {notice} +

+ ); +} diff --git a/apps/web/src/games/omok/index.tsx b/apps/web/src/games/omok/index.tsx index dd76bdd..bf2a60f 100644 --- a/apps/web/src/games/omok/index.tsx +++ b/apps/web/src/games/omok/index.tsx @@ -335,8 +335,14 @@ function Options({ value: o, onChange }: OptionsProps) { 시간 [k, l] as [string, string])} - onChange={(k) => set({ timeControl: { ...o.timeControl, ...TIME_PRESETS.find((p) => p[0] === k)![2] } })} + options={[ + ...TIME_PRESETS.map(([k, l]) => [k, l] as [string, string]), + ...(TIME_PRESETS.some((p) => p[0] === timeKey(o.timeControl)) ? [] : [[timeKey(o.timeControl), `${timeLabel(o.timeControl)}(관리자 설정)`] as [string, string]]), + ]} + onChange={(k) => { + const preset = TIME_PRESETS.find((p) => p[0] === k); + if (preset) set({ timeControl: { ...o.timeControl, ...preset[2] } }); + }} />
@@ -388,8 +394,16 @@ function Options({ value: o, onChange }: OptionsProps) { ); } +function timeLabel(tc: OmokOptions['timeControl']): string { + if (tc.kind === 'none') return '시간 제한 없음'; + if (tc.kind === 'perMove') return `한 수 ${tc.perMoveSec}초`; + const m = Math.floor(tc.baseSec / 60); + const sec = tc.baseSec % 60; + return `${m}분${sec ? ` ${sec}초` : ''} + ${tc.incSec}초`; +} + function optionsSummary(o: OmokOptions): string { - const t = TIME_PRESETS.find((p) => p[0] === timeKey(o.timeControl))?.[1] ?? ''; + const t = timeLabel(o.timeControl); return [RULE_KO[o.ruleSet], `${o.boardSize}줄`, t].join(' · '); } diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index ff196a9..5679446 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -14,6 +14,19 @@ const GamePage = lazy(() => import('./pages/GamePage')); const MePage = lazy(() => import('./pages/MePage')); const RulesPage = lazy(() => import('./pages/RulesPage')); const NotFound = lazy(() => import('./pages/NotFound')); +const AdminApp = lazy(() => import('./pages/admin/AdminApp')); + +function Announcement() { + const text = useSession((s) => s.announcement); + const maintenance = useSession((s) => s.maintenance); + if (!text && !maintenance) return null; + return ( +
+ {maintenance && '🛠 점검 중 · '} + {text || '새 방을 잠시 만들 수 없어요.'} +
+ ); +} function Shell() { const loaded = useSession((s) => s.loaded); @@ -29,6 +42,7 @@ function Shell() { const q = new URLSearchParams(loc.search); if (q.get('linked') === '1') toast('디스코드 계정과 연결했어요.'); if (q.get('login') === 'failed') toast('디스코드 로그인에 실패했어요. 다시 시도해 주세요.', 'error'); + if (q.get('login') === 'banned') toast('이용이 제한된 계정이에요.', 'error', 6000); if (q.has('linked') || q.has('login')) { q.delete('linked'); q.delete('login'); @@ -39,6 +53,7 @@ function Shell() { return ( <> + 불러오는 중…
}> @@ -48,6 +63,7 @@ function Shell() { } /> } /> } /> + } /> } /> diff --git a/apps/web/src/net/api.ts b/apps/web/src/net/api.ts index 3048be2..a4311d3 100644 --- a/apps/web/src/net/api.ts +++ b/apps/web/src/net/api.ts @@ -35,8 +35,11 @@ export interface Settings { tapConfirm?: boolean; } +export type Role = 'user' | 'admin' | 'superadmin'; + export interface MeResponse { me: PublicUser | null; + role?: Role; settings?: Settings; useAvatar?: boolean; hasDiscordAvatar?: boolean; @@ -54,7 +57,8 @@ export interface RoomPreview { } export const api = { - config: () => call<{ discord: boolean; catalog: CatalogEntry[] }>('GET', '/api/config'), + config: () => + call<{ discord: boolean; catalog: (CatalogEntry & { notice?: string })[]; siteName: string; announcement: string; maintenance: boolean }>('GET', '/api/config'), me: () => call('GET', '/api/me'), guest: (nickname: string) => call<{ me: PublicUser }>('POST', '/api/auth/guest', { nickname }), updateMe: (patch: { nickname?: string; useAvatar?: boolean; settings?: Settings }) => call<{ me: PublicUser }>('PATCH', '/api/me', patch), @@ -69,4 +73,6 @@ export const api = { call<{ rooms: { code: string; host: string; seated: number; max: number }[] }>('GET', `/api/games/${gameId}/rooms`), }; +export { call as apiCall }; + export const discordLoginUrl = (next: string) => `/api/auth/discord/start?next=${encodeURIComponent(next)}`; diff --git a/apps/web/src/pages/GamePage.tsx b/apps/web/src/pages/GamePage.tsx index da460c9..91f5148 100644 --- a/apps/web/src/pages/GamePage.tsx +++ b/apps/web/src/pages/GamePage.tsx @@ -11,7 +11,8 @@ import { GAME_ICON } from '../lib/catalog-ui'; export default function GamePage() { const { gameId = '' } = useParams(); - const g = catalogById(gameId); + const catalog = useSession((s) => s.catalog); + const g = catalog.find((c) => c.id === gameId) ?? catalogById(gameId); const nav = useNavigate(); const me = useSession((s) => s.me); const [rooms, setRooms] = useState<{ code: string; host: string; seated: number; max: number }[]>([]); diff --git a/apps/web/src/pages/Home.tsx b/apps/web/src/pages/Home.tsx index 4bde710..c42abaf 100644 --- a/apps/web/src/pages/Home.tsx +++ b/apps/web/src/pages/Home.tsx @@ -11,7 +11,7 @@ import { formatRoomCode } from '@bg/shared/lite'; import { GAME_ICON, UPCOMING } from '../lib/catalog-ui'; export function Home() { - const { me, activeRoom } = useSession(); + const { me, activeRoom, siteName } = useSession(); const catalog = useSession((s) => (s.catalog.length ? s.catalog : CATALOG)); const nav = useNavigate(); const [picker, setPicker] = useState(false); @@ -37,7 +37,7 @@ export function Home() { return (
-

같이 놀자 보드게임

+

{siteName}

{me ? : '👤'} {me ? me.nickname : '내 정보'} diff --git a/apps/web/src/pages/MePage.tsx b/apps/web/src/pages/MePage.tsx index d6ca8e0..b495e2f 100644 --- a/apps/web/src/pages/MePage.tsx +++ b/apps/web/src/pages/MePage.tsx @@ -25,7 +25,7 @@ function Seg({ value, options, onChange, label }: { value: T; export default function MePage() { const nav = useNavigate(); - const { me, settings, discordEnabled, useAvatar, hasDiscordAvatar, updateSettings } = useSession(); + const { me, settings, discordEnabled, useAvatar, hasDiscordAvatar, updateSettings, role } = useSession(); const [editing, setEditing] = useState(false); const [nick, setNick] = useState(me?.nickname ?? ''); const [error, setError] = useState(null); @@ -128,6 +128,12 @@ export default function MePage() { )} + {role !== 'user' && ( + + )} + {display}
diff --git a/apps/web/src/pages/RoomPage.tsx b/apps/web/src/pages/RoomPage.tsx index 981e669..1e802ae 100644 --- a/apps/web/src/pages/RoomPage.tsx +++ b/apps/web/src/pages/RoomPage.tsx @@ -2,9 +2,10 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { create } from 'zustand'; import { useNavigate, useParams } from 'react-router'; -import { CHAT_MAX, EMOTE_LABEL, EMOTES, catalogById, formatRoomCode, type PublicUser, type RoomView } from '@bg/shared/lite'; +import { CHAT_MAX, EMOTE_LABEL, EMOTES, formatRoomCode, type PublicUser, type RoomView } from '@bg/shared/lite'; import { Avatar, CodeLabel, ConfirmSheet, CopyButton, ShareButton, Sheet, TopBar } from '../components/ui'; import { NicknameForm } from '../components/NicknameForm'; +import { GameNotice, gameName } from '../components/GameNotice'; import { QrModal } from '../components/QrModal'; import { api, type RoomPreview } from '../net/api'; import { socket, useRoom } from '../net/socket'; @@ -44,7 +45,7 @@ export default function RoomPage() { {preview ? ( <>

- {catalogById(preview.gameId)?.nameKo ?? '게임'} 방에 초대받았어요 + {gameName(preview.gameId)} 방에 초대받았어요

방장 {preview.host} · {preview.seated}/{preview.maxPlayers}명 · 코드 {preview.codeLabel} @@ -132,7 +133,7 @@ function Room({ code, me }: { code: string; me: PublicUser }) { ); } - const g = catalogById(room.gameId); + const g = { nameKo: gameName(room.gameId) }; const inGame = room.status === 'playing' || room.status === 'paused' || (room.status === 'finished' && !!game); const isHost = room.hostId === me.id; const doLeave = () => { @@ -241,9 +242,9 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser }) const seated = room.seats.filter(Boolean).length; const need = Math.max(0, room.minPlayers - seated); const url = `${location.origin}/r/${room.code}`; - const gameName = catalogById(room.gameId)?.nameKo ?? '게임'; + const gName = gameName(room.gameId); const host = room.seats.find((s) => s?.user.id === room.hostId)?.user.nickname ?? me.nickname; - const shareText = `${host}님이 ${gameName} 방에 초대했어요! 코드 ${formatRoomCode(room.code)}`; + const shareText = `${host}님이 ${gName} 방에 초대했어요! 코드 ${formatRoomCode(room.code)}`; const notReady = room.seats.filter((s) => s && !s.ready && s.user.id !== room.hostId).length; const [askStart, setAskStart] = useState(false); @@ -257,7 +258,7 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser })

- + @@ -315,6 +316,8 @@ function Lobby({ room, ui, me }: { room: RoomView; ui: GameUI; me: PublicUser })

{room.visibility === 'public' ? '공개 방 (누구나 들어올 수 있어요)' : '친구만 (코드·링크로만 들어와요)'}

+ {room.lockedOptions.length > 0 &&

🔒 일부 규칙은 관리자가 정해 두어서 바꿀 수 없어요.

} +
@@ -406,6 +409,7 @@ function SettingsSheet({ room, ui, onClose }: { room: RoomView; ui: GameUI; onCl return (

게임 설정

+ {room.lockedOptions.length > 0 &&

🔒 관리자가 고정한 규칙은 바꿔도 저장되지 않아요.

}
방 공개 diff --git a/apps/web/src/pages/RulesPage.tsx b/apps/web/src/pages/RulesPage.tsx index eec4b1e..3418466 100644 --- a/apps/web/src/pages/RulesPage.tsx +++ b/apps/web/src/pages/RulesPage.tsx @@ -1,9 +1,9 @@ import { useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router'; -import { catalogById } from '@bg/shared/lite'; import { TopBar } from '../components/ui'; import { loadGameUI } from '../games/registry'; import type { GameUI } from '../games/types'; +import { GameNotice, gameName } from '../components/GameNotice'; export default function RulesPage() { const { gameId = '' } = useParams(); @@ -12,11 +12,12 @@ export default function RulesPage() { useEffect(() => { loadGameUI(gameId).then(setUi); }, [gameId]); - const g = catalogById(gameId); + const g = { nameKo: gameName(gameId) }; return ( <> nav(-1)} />
+ {ui === undefined ?

불러오는 중…

: ui ? :

아직 준비 중인 게임이에요.

}
diff --git a/apps/web/src/pages/admin/AdminApp.tsx b/apps/web/src/pages/admin/AdminApp.tsx new file mode 100644 index 0000000..70e64a9 --- /dev/null +++ b/apps/web/src/pages/admin/AdminApp.tsx @@ -0,0 +1,657 @@ +/** 관리자 사이트 (docs/14-admin.md). Downloaded only when /admin is opened. */ +import { useCallback, useEffect, useState, type ReactNode } from 'react'; +import { NavLink, Route, Routes, useNavigate, useParams } from 'react-router'; +import { formatRoomCode } from '@bg/shared/lite'; +import { ConfirmSheet, Sheet, TopBar } from '../../components/ui'; +import { apiCall, discordLoginUrl, type Role } from '../../net/api'; +import { useSession } from '../../store/session'; +import { toast } from '../../store/toast'; +import { SchemaForm, type JsonSchema } from './SchemaForm'; + +const a = { + get: (p: string) => apiCall('GET', `/api/admin${p}`), + put: (p: string, b: unknown) => apiCall('PUT', `/api/admin${p}`, b), + post: (p: string, b: unknown = {}) => apiCall('POST', `/api/admin${p}`, b), + patch: (p: string, b: unknown) => apiCall('PATCH', `/api/admin${p}`, b), + del: (p: string) => apiCall('DELETE', `/api/admin${p}`), +}; + +function useLoad(path: string): [T | null, () => void, string | null] { + const [data, setData] = useState(null); + const [err, setErr] = useState(null); + const load = useCallback(() => { + a.get(path).then( + (d) => (setData(d), setErr(null)), + (e) => setErr((e as Error).message), + ); + }, [path]); + useEffect(load, [load]); + return [data, load, err]; +} + +const time = (ms: number) => (ms ? new Date(ms).toLocaleString('ko-KR', { dateStyle: 'short', timeStyle: 'short' }) : '-'); + +export default function AdminApp() { + const nav = useNavigate(); + const [role, setRole] = useState(null); + useEffect(() => { + a.get<{ role: Role }>('/me').then( + (r) => setRole(r.role), + () => setRole('denied'), + ); + }, []); + if (role === null) return
확인 중…
; + if (role === 'denied' || role === 'user') return ; + const sup = role === 'superadmin'; + const tabs: [string, string, boolean][] = [ + ['', '대시보드', true], + ['games', '게임', true], + ['rooms', '방', true], + ['site', '사이트', true], + ['users', '사용자', sup], + ['admins', '관리자', sup], + ['audit', '기록', true], + ]; + return ( + <> + nav('/me')} /> + +
+ + } /> + } /> + } /> + } /> + } /> + {sup && } />} + {sup && } />} + } /> + +
+ + ); +} + +function Denied() { + const me = useSession((s) => s.me); + const discord = useSession((s) => s.discordEnabled); + return ( +
+
+

권한이 없어요

+

관리자 페이지는 관리자로 등록된 디스코드 계정만 볼 수 있어요.

+ {discord && me?.kind !== 'member' && ( + + 디스코드로 로그인 + + )} + + 홈으로 + +
+
+ ); +} + +function Stat({ label, value }: { label: string; value: ReactNode }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +// ------------------------------------------------------------------ dashboard + +function Dashboard() { + const [d] = useLoad('/overview'); + if (!d) return

불러오는 중…

; + return ( +
+ + + + + + + + + + +
+ ); +} + +// ------------------------------------------------------------------ games + +interface AdminGame { + id: string; + codeDefaults: { nameKo: string; minPlayers: number; maxPlayers: number; defaultOptions: unknown }; + raw: Record; + effective: { enabled: boolean; nameKo: string; blurb: string; notice: string; minPlayers: number; maxPlayers: number; defaultOptions: unknown; lockedOptions: string[] }; + schema: JsonSchema; +} + +function Games() { + const [d] = useLoad<{ games: AdminGame[] }>('/games'); + const nav = useNavigate(); + if (!d) return

불러오는 중…

; + return ( +
+

설정을 바꾸면 새로 만드는 방과 새로 시작하는 판부터 적용돼요. 진행 중인 판은 그대로예요.

+ {d.games.map((g) => ( + + ))} +
+ ); +} + +function GameEditor() { + const { id = '' } = useParams(); + const nav = useNavigate(); + const [d, reload] = useLoad<{ games: AdminGame[] }>('/games'); + const g = d?.games.find((x) => x.id === id); + const [form, setForm] = useState<{ enabled: boolean; nameKo: string; blurb: string; notice: string; minPlayers: number; maxPlayers: number; defaultOptions: unknown; locked: Set } | null>(null); + const [ask, setAsk] = useState(false); + useEffect(() => { + if (g) setForm({ ...g.effective, locked: new Set(g.effective.lockedOptions) }); + }, [g?.id, d]); // eslint-disable-line react-hooks/exhaustive-deps + if (!d) return

불러오는 중…

; + if (!g || !form) return

없는 게임이에요.

; + const cd = g.codeDefaults; + const save = async () => { + try { + await a.put(`/games/${id}`, { + enabled: form.enabled, + nameKo: form.nameKo, + blurb: form.blurb, + notice: form.notice, + minPlayers: form.minPlayers, + maxPlayers: form.maxPlayers, + defaultOptions: form.defaultOptions, + lockedOptions: [...form.locked], + }); + toast('저장했어요.'); + reload(); + void useSession.getState().refresh(); + } catch (e) { + toast((e as Error).message, 'error', 6000); + } + }; + return ( +
+ +

{g.effective.nameKo} 설정

+
+

기본 정보

+
+ 사용 +
+ + +
+
+ + +