From f93ff4f6560eb5da2c94216830f2c810dbca1da4 Mon Sep 17 00:00:00 2001 From: EJClaw Date: Thu, 8 Oct 2026 11:13:42 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EB=AA=A9=EB=A1=9D:=20=EA=B2=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=82=AD=EC=A0=9C=20=EC=98=88=EC=A0=95=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=81=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 게스트는 사이트를 나가고 하루 지나면 자동 삭제된다. 목록과 상세에 마지막 접속 + 24시간을 보여 주고, 접속 중이거나 방에 자리가 있으면 삭제 보류로 표시한다. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/admin.test.ts | 17 +++++++++++++++++ apps/server/src/http/admin.ts | 4 ++++ apps/server/src/server.ts | 6 ++++-- apps/web/src/pages/admin/AdminApp.tsx | 5 +++++ docs/14-admin.md | 1 + 5 files changed, 31 insertions(+), 2 deletions(-) diff --git a/apps/server/src/admin.test.ts b/apps/server/src/admin.test.ts index 939bbba..92a0230 100644 --- a/apps/server/src/admin.test.ts +++ b/apps/server/src/admin.test.ts @@ -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; @@ -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); + }); }); diff --git a/apps/server/src/http/admin.ts b/apps/server/src/http/admin.ts index 0f05581..8eea99d 100644 --- a/apps/server/src/http/admin.ts +++ b/apps/server/src/http/admin.ts @@ -18,6 +18,8 @@ export interface AdminDeps { settings: SettingsStore; games: Record; 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; } @@ -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), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 7bd4641..74fb18e 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -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; diff --git a/apps/web/src/pages/admin/AdminApp.tsx b/apps/web/src/pages/admin/AdminApp.tsx index 97c4488..6da8d54 100644 --- a/apps/web/src/pages/admin/AdminApp.tsx +++ b/apps/web/src/pages/admin/AdminApp.tsx @@ -30,6 +30,9 @@ function useLoad(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() { setOpen(u.id)} tabIndex={0} onKeyDown={(e) => e.key === 'Enter' && setOpen(u.id)}> {u.nickname} + {guestDeletion(u) &&
{guestDeletion(u)}
} {u.kind === 'member' ? `디스코드 ${u.discordName ?? ''} (${u.discordId})` : '게스트'} {u.role === 'superadmin' ? '슈퍼어드민' : u.role === 'admin' ? '어드민' : '-'} @@ -498,6 +502,7 @@ function UserSheet({ id, onClose }: { id: string; onClose: () => void }) {
{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)}}

{d.stats.length ? d.stats.map((s) => `${s.gameId} ${s.played}판 ${s.wins}승`).join(' · ') : '기록 없음'}

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