관리자 사용자 목록: 게스트 삭제 예정 시각 표시

게스트는 사이트를 나가고 하루 지나면 자동 삭제된다. 목록과 상세에
마지막 접속 + 24시간을 보여 주고, 접속 중이거나 방에 자리가 있으면 삭제 보류로 표시한다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-08 11:13:42 +09:00
parent 2fbd956cb5
commit f93ff4f656
5 changed files with 31 additions and 2 deletions

View File

@@ -1,6 +1,7 @@
/** Admin site tests (docs/14-admin.md §9). */
import { afterEach, describe, expect, test } from 'bun:test';
import { Client, TEST_SUPERADMINS, api, boot, guest, sleep } from './test-utils';
import { GUEST_IDLE_MS } from './server';
const [SUPER, SUPER2] = TEST_SUPERADMINS;
type Booted = ReturnType<typeof boot>;
@@ -238,4 +239,20 @@ describe('site settings, rooms, users', () => {
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']);
});
test('superadmin sees when an idle guest will be deleted; none for members or guests still online', async () => {
const s = start();
const sup = await discordLogin(s, SUPER);
const away = await guest(s.base, '떠난손님');
const here = await guest(s.base, '있는손님');
const c = await new Client(s.base, here.cookie, here.id).connect();
clients.push(c);
const seen = (s.db.query('SELECT last_seen_at AS t FROM users WHERE id = ?').get(away.id) as { t: number }).t;
const list = (await api(s.base, '/api/admin/users', { cookie: sup.cookie })).body.users;
const byId = (id: string) => list.find((u: any) => u.id === id);
expect(byId(away.id).deleteAt).toBe(seen + GUEST_IDLE_MS);
expect(byId(here.id).deleteAt).toBeNull();
expect(byId(sup.id).deleteAt).toBeNull();
expect((await api(s.base, `/api/admin/users/${away.id}`, { cookie: sup.cookie })).body.user.deleteAt).toBe(seen + GUEST_IDLE_MS);
});
});

View File

@@ -18,6 +18,8 @@ export interface AdminDeps {
settings: SettingsStore;
games: Record<string, AnyGameDefinition>;
disconnectUser: (userId: string) => void;
/** When an idle guest will be deleted; null for members and for guests still online or seated. */
guestDeleteAt: (u: { id: string; kind: 'guest' | 'member'; last_seen_at: number }) => number | null;
stats: () => Record<string, unknown>;
}
@@ -145,6 +147,7 @@ export function createAdminApp(d: AdminDeps) {
banned: u.banned_at ? { at: u.banned_at, reason: u.banned_reason } : null,
createdAt: u.created_at,
lastSeenAt: u.last_seen_at,
deleteAt: d.guestDeleteAt(u),
})),
});
});
@@ -161,6 +164,7 @@ export function createAdminApp(d: AdminDeps) {
banned: u.banned_at ? { at: u.banned_at, reason: u.banned_reason } : null,
createdAt: u.created_at,
lastSeenAt: u.last_seen_at,
deleteAt: d.guestDeleteAt(u),
activeRoom: d.rooms.activeRoomFor(u.id),
},
stats: d.users.stats(u.id),

View File

@@ -64,11 +64,12 @@ export function startServer(opts: StartOptions) {
gateway.closeUser(id);
if (users.deleteGuest(id)) log('info', 'guest removed', { user: id });
};
/** Still on the site (socket open) or still holding a seat: not idle. */
const guestKept = (id: string) => gateway.isOnline(id) || !!rooms.activeRoomFor(id);
/** Guests who closed the site and stayed away a day are deleted (docs/05 §4). */
const purgeIdleGuests = (now = Date.now()) => {
for (const id of users.idleGuests(now - GUEST_IDLE_MS)) {
// Still on the site (socket open) or still holding a seat: not idle.
if (gateway.isOnline(id) || rooms.activeRoomFor(id)) continue;
if (guestKept(id)) continue;
removeGuest(id);
}
};
@@ -95,6 +96,7 @@ export function startServer(opts: StartOptions) {
audit,
games: GAMES,
disconnectUser: (id) => gateway.disconnectUser(id),
guestDeleteAt: (u) => (u.kind !== 'guest' || guestKept(u.id) ? null : u.last_seen_at + GUEST_IDLE_MS),
stats: () => {
const all = rooms.all();
const day = Date.now() - 24 * 3600_000;

View File

@@ -30,6 +30,9 @@ function useLoad<T>(path: string): [T | null, () => void, string | null] {
}
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();
@@ -454,6 +457,7 @@ function Users() {
<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>
@@ -498,6 +502,7 @@ function UserSheet({ id, onClose }: { id: string; onClose: () => void }) {
<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">

View File

@@ -62,6 +62,7 @@
- **이용 제한**: 사유와 함께 설정. 즉시 그 사용자의 모든 세션 삭제와 접속 종료. 제한된 디스코드 계정은 다시 로그인할 수 없다("이용이 제한된 계정이에요"). 해제하면 다시 로그인 가능.
- **닉네임 변경**: 관리자가 바꾸면 하루 변경 횟수 제한을 무시.
- **삭제**: `05-accounts-auth.md`의 계정 삭제와 같음(전적 익명화).
- **게스트 삭제 예정**: 게스트는 사이트를 나가고 하루 지나면 자동 삭제되므로(`05-accounts-auth.md` §5), 목록과 상세에 삭제 예정 시각(마지막 접속 + 24시간)을 보여 준다. 아직 사이트에 접속해 있거나 방에 자리가 있으면 "삭제 보류"로 표시한다.
- 슈퍼어드민 자신은 제한·삭제할 수 없다.
## 7. 저장 (`08-data-model.md`에 추가)