M1.5 관리자 사이트: 슈퍼어드민/어드민 권한, 게임·사이트 설정, 방·사용자 관리, 기록
- 권한: 디스코드 ID로 요청마다 계산. 슈퍼어드민 293719842274541579(SUPERADMIN_DISCORD_IDS), 어드민은 슈퍼어드민이 디스코드 ID로 추가(로그인 전 미리 등록 가능), 고정 슈퍼어드민은 삭제 불가 - 게임 설정: 사용 여부, 표시 이름·설명·안내문, 원작 범위 안 인원, 모든 규칙 옵션 기본값, 옵션별 방장 변경 금지(잠금), 초기화. 입력 화면은 옵션 스키마(zod→JSON Schema)로 자동 생성 - 오목 옵션 전체에 한국어 제목·선택지 메타, 엔진 고정값(자동 착수 허용 횟수, 무승부 재제안 간격, 한 수 제한 초)을 옵션으로 꺼냄 - 사이트 설정: 이름, 공지, 점검 모드, 금지어, 방 수 제한, 연결 유예, 채팅 기본값 - 방 관리(목록·닫기), 사용자 관리(검색·닉네임 변경·이용 제한·강제 로그아웃·삭제, 슈퍼어드민 전용), 관리자 작업 기록(전/후 값) - 이용 제한: 세션 삭제 + 접속 종료 + 디스코드 재로그인 거부 - 테스트: 관리자 API 7개(권한·설정 반영·잠금·점검·금지어·방 닫기·이용 제한·기록), 오목 옵션 2개, e2e/admin.e2e.ts(슈퍼어드민 로그인→규칙 변경·잠금→어드민 추가→게스트 새 방 반영) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
90
apps/server/src/admin/roles.ts
Normal file
90
apps/server/src/admin/roles.ts
Normal file
@@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
159
apps/server/src/admin/settings.ts
Normal file
159
apps/server/src/admin/settings.ts
Normal file
@@ -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<typeof SiteSettingsSchema>;
|
||||
|
||||
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<typeof GameSettingsSchema>;
|
||||
|
||||
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<string, GameSettings>();
|
||||
|
||||
constructor(
|
||||
private db: Database,
|
||||
private defs: Record<string, AnyGameDefinition>,
|
||||
) {
|
||||
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<string, unknown>;
|
||||
for (const path of locked) {
|
||||
const keys = path.split('.');
|
||||
let src: unknown = defaults;
|
||||
for (const k of keys) src = (src as Record<string, unknown> | 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<string, unknown>;
|
||||
}
|
||||
dst[keys[keys.length - 1]!] = structuredClone(src);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user