Files
joke-app/apps/server/src/admin/roles.ts
EJClaw 242ab3ad71 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>
2026-10-04 04:52:38 +09:00

91 lines
4.0 KiB
TypeScript

/** 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,
}));
}
}