게스트는 사이트를 나가고 하루 지나면 자동 삭제된다. 목록과 상세에 마지막 접속 + 24시간을 보여 주고, 접속 중이거나 방에 자리가 있으면 삭제 보류로 표시한다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
677 lines
28 KiB
TypeScript
677 lines
28 KiB
TypeScript
/** 관리자 사이트 (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 } 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: <T,>(p: string) => apiCall<T>('GET', `/api/admin${p}`),
|
|
put: <T,>(p: string, b: unknown) => apiCall<T>('PUT', `/api/admin${p}`, b),
|
|
post: <T,>(p: string, b: unknown = {}) => apiCall<T>('POST', `/api/admin${p}`, b),
|
|
patch: <T,>(p: string, b: unknown) => apiCall<T>('PATCH', `/api/admin${p}`, b),
|
|
del: <T,>(p: string) => apiCall<T>('DELETE', `/api/admin${p}`),
|
|
};
|
|
|
|
function useLoad<T>(path: string): [T | null, () => void, string | null] {
|
|
const [data, setData] = useState<T | null>(null);
|
|
const [err, setErr] = useState<string | null>(null);
|
|
const load = useCallback(() => {
|
|
a.get<T>(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' }) : '-');
|
|
/** Idle guests are deleted a day after they leave the site (docs/05 §5). */
|
|
const guestDeletion = (u: { kind: string; deleteAt: number | null }) =>
|
|
u.kind !== 'guest' ? null : u.deleteAt ? `삭제 예정 ${time(u.deleteAt)}` : '사이트나 방에 남아 있어 삭제 보류';
|
|
|
|
export default function AdminApp() {
|
|
const nav = useNavigate();
|
|
const [role, setRole] = useState<Role | null | 'denied'>(null);
|
|
useEffect(() => {
|
|
a.get<{ role: Role }>('/me').then(
|
|
(r) => setRole(r.role),
|
|
() => setRole('denied'),
|
|
);
|
|
}, []);
|
|
if (role === null) return <main className="page muted">확인 중…</main>;
|
|
if (role === 'denied' || role === 'user') return <Denied />;
|
|
const sup = role === 'superadmin';
|
|
const tabs: [string, string, boolean][] = [
|
|
['', '대시보드', true],
|
|
['games', '게임', true],
|
|
['rooms', '방', true],
|
|
['site', '사이트', true],
|
|
['users', '사용자', sup],
|
|
['admins', '관리자', sup],
|
|
['audit', '기록', true],
|
|
];
|
|
return (
|
|
<div className="admin-layout">
|
|
<nav className="admin-side" aria-label="관리자 메뉴">
|
|
<div className="muted small" style={{ padding: '0 16px 8px', fontWeight: 700 }}>
|
|
관리자 · {sup ? '슈퍼어드민' : '어드민'}
|
|
</div>
|
|
{tabs
|
|
.filter((t) => t[2])
|
|
.map(([to, label]) => (
|
|
<NavLink key={to} to={to ? `/admin/${to}` : '/admin'} end={to === ''}>
|
|
{label}
|
|
</NavLink>
|
|
))}
|
|
<button className="btn btn-ghost btn-sm" style={{ justifyContent: 'flex-start', marginTop: 8 }} onClick={() => nav('/')}>
|
|
← 사이트로
|
|
</button>
|
|
</nav>
|
|
<main className="stack" style={{ gap: 20, minWidth: 0, paddingBottom: 96 }}>
|
|
<Routes>
|
|
<Route index element={<Dashboard />} />
|
|
<Route path="games" element={<Games />} />
|
|
<Route path="games/:id" element={<GameEditor />} />
|
|
<Route path="rooms" element={<Rooms />} />
|
|
<Route path="site" element={<Site />} />
|
|
{sup && <Route path="users" element={<Users />} />}
|
|
{sup && <Route path="admins" element={<Admins />} />}
|
|
<Route path="audit" element={<Audit />} />
|
|
</Routes>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Denied() {
|
|
const me = useSession((s) => s.me);
|
|
const discord = useSession((s) => s.discordEnabled);
|
|
return (
|
|
<main className="page">
|
|
<section className="card stack">
|
|
<h1>권한이 없어요</h1>
|
|
<p className="muted">관리자 페이지는 관리자로 등록된 디스코드 계정만 볼 수 있어요.</p>
|
|
{discord && me?.kind !== 'member' && (
|
|
<a className="btn btn-primary btn-block" href={discordLoginUrl('/admin')}>
|
|
디스코드로 로그인
|
|
</a>
|
|
)}
|
|
<a className="btn btn-secondary btn-block" href="/">
|
|
홈으로
|
|
</a>
|
|
</section>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
function Stat({ label, value }: { label: string; value: ReactNode }) {
|
|
return (
|
|
<div className="card" style={{ padding: 14 }}>
|
|
<div className="muted small">{label}</div>
|
|
<div style={{ fontSize: '1.6rem', fontWeight: 800 }}>{value}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ------------------------------------------------------------------ dashboard
|
|
|
|
function Dashboard() {
|
|
const [d] = useLoad<any>('/overview');
|
|
if (!d) return <p className="muted">불러오는 중…</p>;
|
|
return (
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 12 }}>
|
|
<Stat label="지금 접속" value={d.connections} />
|
|
<Stat label="열린 방" value={d.rooms.total} />
|
|
<Stat label="게임 중인 방" value={d.rooms.playing} />
|
|
<Stat label="오늘 시작한 판" value={d.games?.today ?? 0} />
|
|
<Stat label="전체 판" value={d.games?.total ?? 0} />
|
|
<Stat label="사용자" value={d.users.total} />
|
|
<Stat label="게스트 / 디스코드" value={`${d.users.guests ?? 0} / ${d.users.members ?? 0}`} />
|
|
<Stat label="이용 제한" value={d.users.banned ?? 0} />
|
|
<Stat label="가동 시간" value={`${Math.floor(d.uptimeSec / 3600)}시간 ${Math.floor((d.uptimeSec % 3600) / 60)}분`} />
|
|
<Stat label="메모리" value={`${d.memoryMb}MB`} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ------------------------------------------------------------------ games
|
|
|
|
interface AdminGame {
|
|
id: string;
|
|
codeDefaults: { nameKo: string; minPlayers: number; maxPlayers: number; defaultOptions: unknown };
|
|
raw: Record<string, unknown>;
|
|
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 <p className="muted">불러오는 중…</p>;
|
|
return (
|
|
<div className="stack">
|
|
<p className="muted">설정을 바꾸면 새로 만드는 방과 새로 시작하는 판부터 적용돼요. 진행 중인 판은 그대로예요.</p>
|
|
{d.games.map((g) => (
|
|
<button key={g.id} className="card row" style={{ border: 0, cursor: 'pointer', textAlign: 'left', justifyContent: 'space-between' }} onClick={() => nav(`/admin/games/${g.id}`)}>
|
|
<div>
|
|
<strong>{g.effective.nameKo}</strong> <span className="muted small">({g.id})</span>
|
|
<div className="muted small">
|
|
{g.effective.minPlayers}~{g.effective.maxPlayers}명 · 잠긴 옵션 {g.effective.lockedOptions.length}개
|
|
</div>
|
|
</div>
|
|
{g.effective.enabled ? <span className="badge success">사용 중</span> : <span className="badge warning">꺼짐</span>}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<string> } | 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 <p className="muted">불러오는 중…</p>;
|
|
if (!g || !form) return <p>없는 게임이에요.</p>;
|
|
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 (
|
|
<div className="stack" style={{ gap: 16 }}>
|
|
<button className="btn btn-ghost" style={{ alignSelf: 'flex-start' }} onClick={() => nav('/admin/games')}>
|
|
← 게임 목록
|
|
</button>
|
|
<h1>{g.effective.nameKo} 설정</h1>
|
|
<section className="card stack">
|
|
<h2>기본 정보</h2>
|
|
<div className="stack" style={{ gap: 6 }}>
|
|
<strong>사용</strong>
|
|
<div className="segmented">
|
|
<button type="button" aria-pressed={form.enabled} onClick={() => setForm({ ...form, enabled: true })}>
|
|
사용
|
|
</button>
|
|
<button type="button" aria-pressed={!form.enabled} onClick={() => setForm({ ...form, enabled: false })}>
|
|
끄기(새 방 막기)
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<label className="field">
|
|
표시 이름 <span className="muted small">(원래: {cd.nameKo})</span>
|
|
<input className="input" value={form.nameKo} maxLength={30} onChange={(e) => setForm({ ...form, nameKo: e.target.value })} />
|
|
</label>
|
|
<label className="field">
|
|
한 줄 설명
|
|
<input className="input" value={form.blurb} maxLength={80} onChange={(e) => setForm({ ...form, blurb: e.target.value })} />
|
|
</label>
|
|
<label className="field">
|
|
안내문(규칙 화면 위에 표시)
|
|
<textarea className="input" style={{ minHeight: 90, padding: 12 }} value={form.notice} maxLength={500} onChange={(e) => setForm({ ...form, notice: e.target.value })} />
|
|
</label>
|
|
<div className="row">
|
|
<label className="field" style={{ flex: 1 }}>
|
|
최소 인원 ({cd.minPlayers}~{cd.maxPlayers})
|
|
<input className="input" type="number" min={cd.minPlayers} max={cd.maxPlayers} value={form.minPlayers} onChange={(e) => setForm({ ...form, minPlayers: Number(e.target.value) })} />
|
|
</label>
|
|
<label className="field" style={{ flex: 1 }}>
|
|
최대 인원 ({cd.minPlayers}~{cd.maxPlayers})
|
|
<input className="input" type="number" min={cd.minPlayers} max={cd.maxPlayers} value={form.maxPlayers} onChange={(e) => setForm({ ...form, maxPlayers: Number(e.target.value) })} />
|
|
</label>
|
|
</div>
|
|
</section>
|
|
<section className="card stack">
|
|
<h2>기본 규칙</h2>
|
|
<p className="muted small">방을 만들 때 처음 들어가는 값이에요. 🔒을 켜면 방장이 바꿀 수 없어요.</p>
|
|
<SchemaForm
|
|
schema={g.schema}
|
|
value={form.defaultOptions}
|
|
onChange={(v) => setForm({ ...form, defaultOptions: v })}
|
|
locked={form.locked}
|
|
onToggleLock={(p) => {
|
|
const next = new Set(form.locked);
|
|
if (next.has(p)) next.delete(p);
|
|
else next.add(p);
|
|
setForm({ ...form, locked: next });
|
|
}}
|
|
/>
|
|
</section>
|
|
<div className="bottom-bar sticky-save">
|
|
<div className="bottom-bar-inner">
|
|
<button className="btn btn-primary" onClick={save}>
|
|
저장
|
|
</button>
|
|
<button className="btn btn-secondary" onClick={() => setAsk(true)}>
|
|
처음 값으로
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{ask && (
|
|
<ConfirmSheet
|
|
title="코드 기본값으로 되돌릴까요?"
|
|
body="이 게임의 관리자 설정이 모두 지워져요."
|
|
confirm="되돌리기"
|
|
danger
|
|
onConfirm={async () => {
|
|
await a.post(`/games/${id}/reset`);
|
|
toast('처음 값으로 되돌렸어요.');
|
|
reload();
|
|
void useSession.getState().refresh();
|
|
}}
|
|
onClose={() => setAsk(false)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ------------------------------------------------------------------ rooms
|
|
|
|
function Rooms() {
|
|
const [d, reload] = useLoad<{ rooms: any[] }>('/rooms');
|
|
const [closing, setClosing] = useState<string | null>(null);
|
|
useEffect(() => {
|
|
const t = setInterval(reload, 5000);
|
|
return () => clearInterval(t);
|
|
}, [reload]);
|
|
const catalog = useSession((s) => s.catalog);
|
|
if (!d) return <p className="muted">불러오는 중…</p>;
|
|
const STATUS: Record<string, string> = { lobby: '대기 중', playing: '게임 중', finished: '끝남', paused: '멈춤', broken: '복구 실패' };
|
|
return (
|
|
<div className="stack">
|
|
{d.rooms.length === 0 && <p className="muted">열린 방이 없어요.</p>}
|
|
{d.rooms.map((r) => (
|
|
<div key={r.code} className="card stack" style={{ gap: 6 }}>
|
|
<div className="row" style={{ justifyContent: 'space-between' }}>
|
|
<strong>
|
|
{catalog.find((c) => c.id === r.gameId)?.nameKo ?? r.gameId} · {formatRoomCode(r.code)}
|
|
</strong>
|
|
<span className="badge">{STATUS[r.status] ?? r.status}</span>
|
|
</div>
|
|
<div className="small">
|
|
{r.seats.map((s: any, i: number) => (s ? `${s.nickname}${s.connected ? '' : '(끊김)'}` : `빈자리${i + 1}`)).join(', ')}
|
|
</div>
|
|
<div className="muted small">
|
|
방장 {r.host ?? '-'} · 관전 {r.spectators} · 접속 {r.connections} · {r.visibility === 'public' ? '공개' : '비공개'} · {r.gameNo}판 · 마지막 활동 {time(r.lastActiveAt)}
|
|
</div>
|
|
<div className="row">
|
|
<a className="btn btn-secondary btn-sm" href={`/r/${r.code}`}>
|
|
들어가 보기
|
|
</a>
|
|
<button className="btn btn-danger btn-sm" onClick={() => setClosing(r.code)}>
|
|
방 닫기
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{closing && (
|
|
<ConfirmSheet
|
|
title={`방 ${formatRoomCode(closing)}을 닫을까요?`}
|
|
body="진행 중인 판은 무효가 되고 모두 방에서 나가요."
|
|
confirm="닫기"
|
|
danger
|
|
onConfirm={async () => {
|
|
await a.post(`/rooms/${closing}/close`).catch((e) => toast((e as Error).message, 'error'));
|
|
reload();
|
|
}}
|
|
onClose={() => setClosing(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ------------------------------------------------------------------ site
|
|
|
|
function Site() {
|
|
const [d] = useLoad<{ site: any }>('/site');
|
|
const [s, setS] = useState<any>(null);
|
|
useEffect(() => {
|
|
if (d) setS({ ...d.site, bannedText: d.site.bannedWords.join('\n') });
|
|
}, [d]);
|
|
if (!s) return <p className="muted">불러오는 중…</p>;
|
|
const num = (k: string, label: string, min: number, max: number) => (
|
|
<label className="field">
|
|
{label} <span className="muted small">({min}~{max})</span>
|
|
<input className="input" type="number" min={min} max={max} value={s[k]} onChange={(e) => setS({ ...s, [k]: Number(e.target.value) })} />
|
|
</label>
|
|
);
|
|
const bool = (k: string, label: string, on = '켜기', off = '끄기') => (
|
|
<div className="stack" style={{ gap: 6 }}>
|
|
<strong>{label}</strong>
|
|
<div className="segmented">
|
|
<button type="button" aria-pressed={s[k]} onClick={() => setS({ ...s, [k]: true })}>
|
|
{on}
|
|
</button>
|
|
<button type="button" aria-pressed={!s[k]} onClick={() => setS({ ...s, [k]: false })}>
|
|
{off}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
const save = async () => {
|
|
const { bannedText, ...rest } = s;
|
|
try {
|
|
await a.put('/site', { ...rest, bannedWords: String(bannedText).split('\n').map((w: string) => w.trim()).filter(Boolean) });
|
|
toast('저장했어요.');
|
|
void useSession.getState().refresh();
|
|
} catch (e) {
|
|
toast((e as Error).message, 'error', 6000);
|
|
}
|
|
};
|
|
return (
|
|
<div className="stack" style={{ gap: 16 }}>
|
|
<section className="card stack">
|
|
<label className="field">
|
|
사이트 이름
|
|
<input className="input" value={s.siteName} maxLength={40} onChange={(e) => setS({ ...s, siteName: e.target.value })} />
|
|
</label>
|
|
<label className="field">
|
|
공지(모든 화면 위에 표시, 비우면 숨김)
|
|
<textarea className="input" style={{ minHeight: 80, padding: 12 }} maxLength={300} value={s.announcement} onChange={(e) => setS({ ...s, announcement: e.target.value })} />
|
|
</label>
|
|
{bool('maintenance', '점검 모드(새 방 만들기 막기)', '점검 중', '정상')}
|
|
</section>
|
|
<section className="card stack">
|
|
{num('maxRoomsPerUser', '사용자당 동시에 만들 수 있는 방', 1, 50)}
|
|
{num('maxRooms', '서버 전체 방 수 제한', 10, 20000)}
|
|
{num('graceSec', '연결 끊김 유예(초, 새 방 기본값)', 10, 600)}
|
|
{bool('chatEnabledDefault', '새 방 채팅 기본값', '켜기', '끄기')}
|
|
{bool('chatFilterDefault', '새 방 욕설 필터 기본값', '켜기', '끄기')}
|
|
</section>
|
|
<section className="card stack">
|
|
<label className="field">
|
|
금지어(한 줄에 하나, 채팅에서 가리고 닉네임에 못 씀)
|
|
<textarea className="input" style={{ minHeight: 140, padding: 12 }} value={s.bannedText} onChange={(e) => setS({ ...s, bannedText: e.target.value })} />
|
|
</label>
|
|
</section>
|
|
<div className="bottom-bar sticky-save">
|
|
<div className="bottom-bar-inner">
|
|
<button className="btn btn-primary" onClick={save}>
|
|
저장
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ------------------------------------------------------------------ users (superadmin)
|
|
|
|
function Users() {
|
|
const [q, setQ] = useState('');
|
|
const [query, setQuery] = useState('');
|
|
const [d, reload] = useLoad<{ total: number; users: any[] }>(`/users?q=${encodeURIComponent(query)}`);
|
|
const [open, setOpen] = useState<string | null>(null);
|
|
return (
|
|
<div className="stack">
|
|
<form
|
|
className="row"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
setQuery(q.trim());
|
|
}}
|
|
>
|
|
<input className="input" style={{ flex: 1 }} placeholder="닉네임, 사용자 ID, 디스코드 ID" value={q} onChange={(e) => setQ(e.target.value)} />
|
|
<button className="btn btn-primary">검색</button>
|
|
</form>
|
|
{d && <p className="muted small">전체 {d.total}명</p>}
|
|
{d && (
|
|
<table className="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th>닉네임</th>
|
|
<th className="wide-only">로그인</th>
|
|
<th>권한</th>
|
|
<th className="wide-only">마지막 접속</th>
|
|
<th>상태</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{d.users.map((u) => (
|
|
<tr key={u.id} className="clickable" onClick={() => setOpen(u.id)} tabIndex={0} onKeyDown={(e) => e.key === 'Enter' && setOpen(u.id)}>
|
|
<td>
|
|
<strong>{u.nickname}</strong>
|
|
{guestDeletion(u) && <div className="muted small">{guestDeletion(u)}</div>}
|
|
</td>
|
|
<td className="wide-only small">{u.kind === 'member' ? `디스코드 ${u.discordName ?? ''} (${u.discordId})` : '게스트'}</td>
|
|
<td>{u.role === 'superadmin' ? '슈퍼어드민' : u.role === 'admin' ? '어드민' : '-'}</td>
|
|
<td className="wide-only small">{time(u.lastSeenAt)}</td>
|
|
<td>{u.banned ? <span className="badge warning">제한</span> : <span className="badge success">정상</span>}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
{open && <UserSheet id={open} onClose={() => (setOpen(null), reload())} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function UserSheet({ id, onClose }: { id: string; onClose: () => void }) {
|
|
const [d, reload] = useLoad<{ user: any; stats: any[] }>(`/users/${id}`);
|
|
const [nick, setNick] = useState('');
|
|
const [reason, setReason] = useState('');
|
|
const [ask, setAsk] = useState<null | 'delete' | 'logout'>(null);
|
|
useEffect(() => {
|
|
if (d) setNick(d.user.nickname);
|
|
}, [d]);
|
|
const run = async (p: Promise<unknown>, msg: string) => {
|
|
try {
|
|
await p;
|
|
toast(msg);
|
|
reload();
|
|
} catch (e) {
|
|
toast((e as Error).message, 'error');
|
|
}
|
|
};
|
|
return (
|
|
<Sheet onClose={onClose} label="사용자 관리">
|
|
{!d ? (
|
|
<p className="muted">불러오는 중…</p>
|
|
) : (
|
|
<>
|
|
<h2>{d.user.nickname}</h2>
|
|
<p className="muted small">
|
|
ID {d.user.id}
|
|
<br />
|
|
{d.user.discordId ? `디스코드 ID ${d.user.discordId}` : '게스트'} · 가입 {time(d.user.createdAt)} · 마지막 접속 {time(d.user.lastSeenAt)}
|
|
{d.user.activeRoom && <> · 지금 방 {formatRoomCode(d.user.activeRoom)}</>}
|
|
{guestDeletion(d.user) && <> · {guestDeletion(d.user)}</>}
|
|
</p>
|
|
<p className="small">{d.stats.length ? d.stats.map((s) => `${s.gameId} ${s.played}판 ${s.wins}승`).join(' · ') : '기록 없음'}</p>
|
|
<div className="row">
|
|
<input className="input" style={{ flex: 1 }} value={nick} onChange={(e) => setNick(e.target.value)} maxLength={20} aria-label="닉네임" />
|
|
<button className="btn btn-secondary" onClick={() => run(a.patch(`/users/${id}`, { nickname: nick }), '닉네임을 바꿨어요.')}>
|
|
닉네임 변경
|
|
</button>
|
|
</div>
|
|
{d.user.banned ? (
|
|
<div className="card stack" style={{ background: 'var(--warning-soft)' }}>
|
|
<p>
|
|
이용 제한 중: {d.user.banned.reason} ({time(d.user.banned.at)})
|
|
</p>
|
|
<button className="btn btn-primary" onClick={() => run(a.patch(`/users/${id}`, { ban: null }), '제한을 풀었어요.')}>
|
|
제한 해제
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="row">
|
|
<input className="input" style={{ flex: 1 }} placeholder="제한 사유" value={reason} onChange={(e) => setReason(e.target.value)} maxLength={200} />
|
|
<button className="btn btn-danger" disabled={!reason.trim()} onClick={() => run(a.patch(`/users/${id}`, { ban: { reason } }), '이용을 제한했어요.')}>
|
|
이용 제한
|
|
</button>
|
|
</div>
|
|
)}
|
|
<button className="btn btn-secondary btn-block" onClick={() => setAsk('logout')}>
|
|
모든 기기에서 로그아웃시키기
|
|
</button>
|
|
<button className="btn btn-danger btn-block" onClick={() => setAsk('delete')}>
|
|
계정 삭제
|
|
</button>
|
|
<button className="btn btn-ghost btn-block" onClick={onClose}>
|
|
닫기
|
|
</button>
|
|
{ask === 'logout' && <ConfirmSheet title="로그아웃시킬까요?" confirm="로그아웃" onConfirm={() => run(a.post(`/users/${id}/logout`), '로그아웃시켰어요.')} onClose={() => setAsk(null)} />}
|
|
{ask === 'delete' && (
|
|
<ConfirmSheet
|
|
title="계정을 삭제할까요?"
|
|
body="되돌릴 수 없어요."
|
|
confirm="삭제"
|
|
danger
|
|
onConfirm={async () => {
|
|
await run(a.del(`/users/${id}`), '삭제했어요.');
|
|
onClose();
|
|
}}
|
|
onClose={() => setAsk(null)}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</Sheet>
|
|
);
|
|
}
|
|
|
|
// ------------------------------------------------------------------ admins (superadmin)
|
|
|
|
function Admins() {
|
|
const [d, reload] = useLoad<{ admins: any[] }>('/admins');
|
|
const [discordId, setDiscordId] = useState('');
|
|
const [note, setNote] = useState('');
|
|
const [removing, setRemoving] = useState<string | null>(null);
|
|
const add = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
try {
|
|
await a.post('/admins', { discordId: discordId.trim(), note: note.trim() || undefined });
|
|
toast('어드민을 추가했어요.');
|
|
setDiscordId('');
|
|
setNote('');
|
|
reload();
|
|
} catch (ex) {
|
|
toast((ex as Error).message, 'error');
|
|
}
|
|
};
|
|
return (
|
|
<div className="stack">
|
|
<form className="card stack" onSubmit={add}>
|
|
<h2>어드민 추가</h2>
|
|
<label className="field">
|
|
디스코드 ID(숫자)
|
|
<input className="input" inputMode="numeric" value={discordId} onChange={(e) => setDiscordId(e.target.value)} placeholder="디스코드 ID (숫자 17~20자리)" />
|
|
</label>
|
|
<label className="field">
|
|
메모(선택)
|
|
<input className="input" value={note} maxLength={100} onChange={(e) => setNote(e.target.value)} />
|
|
</label>
|
|
<p className="muted small">디스코드 설정 → 고급 → 개발자 모드를 켠 뒤, 프로필을 우클릭해 "사용자 ID 복사"로 얻을 수 있어요. 아직 사이트에 들어온 적 없는 사람도 미리 추가할 수 있어요.</p>
|
|
<button className="btn btn-primary btn-block" disabled={!/^\d{17,20}$/.test(discordId.trim())}>
|
|
추가
|
|
</button>
|
|
</form>
|
|
{d?.admins.map((x) => (
|
|
<div key={x.discordId} className="card row" style={{ justifyContent: 'space-between' }}>
|
|
<div>
|
|
<strong>{x.nickname ?? '(아직 로그인 안 함)'}</strong> <span className="badge">{x.fixed ? '슈퍼어드민' : '어드민'}</span>
|
|
<div className="muted small">
|
|
{x.discordId}
|
|
{x.note ? ` · ${x.note}` : ''}
|
|
{x.addedAt ? ` · ${time(x.addedAt)}` : ''}
|
|
</div>
|
|
</div>
|
|
{!x.fixed && (
|
|
<button className="btn btn-danger btn-sm" onClick={() => setRemoving(x.discordId)}>
|
|
삭제
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
{removing && (
|
|
<ConfirmSheet
|
|
title="어드민에서 뺄까요?"
|
|
body={`디스코드 ID ${removing}`}
|
|
confirm="빼기"
|
|
danger
|
|
onConfirm={async () => {
|
|
await a.del(`/admins/${removing}`).catch((e) => toast((e as Error).message, 'error'));
|
|
reload();
|
|
}}
|
|
onClose={() => setRemoving(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ------------------------------------------------------------------ audit
|
|
|
|
const ACTION_KO: Record<string, string> = {
|
|
'game.update': '게임 설정 변경',
|
|
'game.reset': '게임 설정 초기화',
|
|
'site.update': '사이트 설정 변경',
|
|
'room.close': '방 닫기',
|
|
'user.rename': '닉네임 변경',
|
|
'user.ban': '이용 제한',
|
|
'user.unban': '제한 해제',
|
|
'user.logout': '강제 로그아웃',
|
|
'user.delete': '계정 삭제',
|
|
'admin.add': '어드민 추가',
|
|
'admin.remove': '어드민 삭제',
|
|
};
|
|
|
|
function Audit() {
|
|
const [page, setPage] = useState(0);
|
|
const [d] = useLoad<{ entries: any[] }>(`/audit?page=${page}`);
|
|
return (
|
|
<div className="stack">
|
|
{d?.entries.length === 0 && <p className="muted">기록이 없어요.</p>}
|
|
{d?.entries.map((e) => (
|
|
<details key={e.id} className="card">
|
|
<summary style={{ cursor: 'pointer' }}>
|
|
<strong>{ACTION_KO[e.action] ?? e.action}</strong> {e.target && <span className="muted">· {e.target}</span>}
|
|
<div className="muted small">
|
|
{time(e.at)} · {e.actor ?? e.actorId}
|
|
</div>
|
|
</summary>
|
|
<pre className="small" style={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>
|
|
전: {JSON.stringify(e.before, null, 1)}
|
|
{'\n'}후: {JSON.stringify(e.after, null, 1)}
|
|
</pre>
|
|
</details>
|
|
))}
|
|
<div className="row">
|
|
<button className="btn btn-secondary btn-sm" disabled={page === 0} onClick={() => setPage(page - 1)}>
|
|
← 최근
|
|
</button>
|
|
<button className="btn btn-secondary btn-sm" disabled={(d?.entries.length ?? 0) < 50} onClick={() => setPage(page + 1)}>
|
|
이전 기록 →
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|