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

게스트는 사이트를 나가고 하루 지나면 자동 삭제된다. 목록과 상세에
마지막 접속 + 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;