diff --git a/docs/user-management-enhancement.md b/docs/user-management-enhancement.md new file mode 100644 index 0000000..61b3175 --- /dev/null +++ b/docs/user-management-enhancement.md @@ -0,0 +1,509 @@ +# 用户管理增强设计 + +## 背景 + +当前后台已经具备基础用户系统: + +- 首次初始化管理员。 +- 后台账号密码登录。 +- `admin` / `user` 两级固定角色。 +- 用户启用、禁用、删除、重置密码。 +- 签名 Cookie 会话和 `session_version` 失效机制。 + +本设计是在 `docs/backend-redesign.md` 的用户系统基础上继续增强,不重做认证入口,不引入外部身份服务,也不把 Steam 密码、Steam Guard code 或 refresh token 写入配置文件。 + +## 目标 + +- 用户资料从“只有账号名”扩展为可运营的后台用户档案。 +- 管理员可以看到用户状态、最近登录和最近活跃信息。 +- 管理员可以审计关键管理动作。 +- 管理员可以踢下线指定用户或使用户所有会话失效。 +- 固定角色继续保留,但后端按权限点执行校验,避免业务逻辑散落判断角色字符串。 +- 普通用户只能访问被授权的 Steam 账户,不能查看或操作未授权 Steam 账户的聊天、历史、好友、群组和素材。 +- 为后续多 Steam 账户切换预留数据结构,但 v1 不实现多个 Steam 账户同时在线。 + +## 非目标 + +- 不支持自定义角色。 +- 不支持头像、邮箱、手机号等用户资料。 +- 不做审计日志导出。 +- 不接入 LDAP、OAuth、OIDC 或外部 SSO。 +- 不实现多个 Steam 会话并发在线;v1 仍保持一个运行时活动 Steam 会话。 +- 不在数据库中保存 Steam 明文密码或 Steam Guard code。 + +## 角色和权限 + +角色保持固定: + +- `admin`:后台管理员。 +- `user`:普通聊天用户。 + +后端引入权限点映射,业务代码调用权限点而不是直接散落判断角色: + +| 权限点 | admin | user | 说明 | +| --- | --- | --- | --- | +| `user.manage` | 是 | 否 | 新增、编辑、禁用、删除用户 | +| `session.manage` | 是 | 否 | 查看和踢下线用户会话 | +| `audit.view` | 是 | 否 | 查看审计日志 | +| `steam.manage` | 是 | 否 | 登录、退出、切换 Steam 账户 | +| `steam.account.manage` | 是 | 否 | 维护 Steam 账户资料和授权关系 | +| `chat.use` | 是 | 是 | 使用聊天能力;普通用户还必须通过 Steam 账户授权 | +| `self.password.change` | 是 | 是 | 修改自己的后台密码 | + +`admin` 对所有 Steam 账户默认有访问权。`user` 只允许访问授权表中的 Steam 账户。 + +## 数据模型 + +继续使用 `${STEAM_CHAT_DATA_DIR}/auth.sqlite`。启动时执行幂等迁移,旧库自动补齐字段和表。 + +### users + +在现有 `users` 表上增加字段: + +```sql +ALTER TABLE users ADD COLUMN display_name TEXT NOT NULL DEFAULT ''; +ALTER TABLE users ADD COLUMN note TEXT NOT NULL DEFAULT ''; +ALTER TABLE users ADD COLUMN created_by INTEGER; +ALTER TABLE users ADD COLUMN last_login_ip TEXT; +ALTER TABLE users ADD COLUMN last_seen_at TEXT; +ALTER TABLE users ADD COLUMN password_changed_at TEXT; +ALTER TABLE users ADD COLUMN force_password_change INTEGER NOT NULL DEFAULT 0; +ALTER TABLE users ADD COLUMN failed_login_count INTEGER NOT NULL DEFAULT 0; +ALTER TABLE users ADD COLUMN locked_until TEXT; +``` + +字段说明: + +- `display_name`:后台展示名,可为空;为空时前端展示 `username`。 +- `note`:管理员备注,仅管理员可见。 +- `created_by`:创建该用户的管理员 ID;初始化管理员为空。 +- `last_login_ip`:最近一次登录成功的客户端 IP,按现有 `trustProxy` 规则解析。 +- `last_seen_at`:最近一次通过认证请求的时间。 +- `password_changed_at`:最近一次密码变更时间。 +- `force_password_change`:管理员重置密码后可要求用户下次登录后先改密。 +- `failed_login_count` / `locked_until`:登录失败和临时锁定状态。 + +### user_sessions + +当前签名 Cookie 是无状态会话,无法列出或单独踢下线。增强后改成“签名 Cookie + 会话表”: + +```sql +CREATE TABLE IF NOT EXISTS user_sessions ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + created_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + revoked_at TEXT, + revoked_by INTEGER, + ip TEXT, + user_agent TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_user_sessions_expires_at ON user_sessions(expires_at); +``` + +Cookie payload 增加 `sid`: + +```json +{ + "sid": "base64url-random-id", + "uid": 1, + "role": "admin", + "sv": 1, + "iat": 1710000000000, + "exp": 1710604800000 +} +``` + +校验规则: + +- Cookie 签名、过期时间、`uid`、`role`、`session_version` 仍然校验。 +- 额外查询 `user_sessions.id = sid`。 +- 会话不存在、已撤销、已过期时返回 401。 +- 每次认证成功节流更新 `user_sessions.last_seen_at` 和 `users.last_seen_at`。 +- 管理员踢下线时设置 `revoked_at` 和 `revoked_by`。 + +### steam_accounts + +新增 Steam 账户资源表: + +```sql +CREATE TABLE IF NOT EXISTS steam_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + steam_id TEXT NOT NULL UNIQUE, + label TEXT NOT NULL DEFAULT '', + account_name_hint TEXT NOT NULL DEFAULT '', + refresh_token TEXT, + refresh_token_updated_at TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + created_by INTEGER, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_login_at TEXT, + last_active_at TEXT, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL +); +``` + +字段说明: + +- `steam_id`:SteamID64,是授权和日志隔离的主键。 +- `label`:管理员维护的显示名称,例如“客服一号”。 +- `account_name_hint`:可选账号提示,只保存脱敏后的账号名或管理员输入的备注,不保存密码。 +- `refresh_token`:该 Steam 账户的 refresh token,用于后续免密码连接;接口响应、审计日志和普通错误日志都不能返回该字段。 +- `refresh_token_updated_at`:最近一次写入 refresh token 的时间。 +- `enabled`:禁用后不能被连接,普通用户也不能访问。 +- `last_login_at` / `last_active_at`:最近连接和活动时间。 + +Steam refresh token 写入 `steam_accounts.refresh_token`。本项目仍不保存 Steam 明文密码和 Steam Guard code;refresh token 只作为 Steam 账户资源的敏感字段保存在本地 SQLite 中。 + +### user_steam_accounts + +用户和 Steam 账户的授权关系: + +```sql +CREATE TABLE IF NOT EXISTS user_steam_accounts ( + user_id INTEGER NOT NULL, + steam_account_id INTEGER NOT NULL, + granted_by INTEGER, + granted_at TEXT NOT NULL, + PRIMARY KEY (user_id, steam_account_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (steam_account_id) REFERENCES steam_accounts(id) ON DELETE CASCADE, + FOREIGN KEY (granted_by) REFERENCES users(id) ON DELETE SET NULL +); +``` + +规则: + +- 新建普通用户默认没有 Steam 账户访问权。 +- 管理员默认可访问所有 Steam 账户,不需要写授权行。 +- 禁用 Steam 账户后,所有普通用户对该账户的访问立即失效。 +- 删除 Steam 账户时同时删除授权关系和该账户记录中的 refresh token。 + +### audit_logs + +新增本地审计日志表: + +```sql +CREATE TABLE IF NOT EXISTS audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor_user_id INTEGER, + action TEXT NOT NULL, + target_type TEXT NOT NULL, + target_id TEXT NOT NULL, + detail_json TEXT NOT NULL DEFAULT '{}', + ip TEXT, + user_agent TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at); +CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit_logs(action); +``` + +记录动作: + +- 用户:创建、更新资料、启用、禁用、删除、角色变更、重置密码、强制改密状态变更。 +- 会话:踢下线单个会话、踢下线用户全部会话、用户主动退出全部会话。 +- Steam 账户:新增、更新、禁用、启用、删除、授权、取消授权、登录、退出、切换。 + +审计日志只记录必要元数据。密码、Steam Guard code、refresh token 必须脱敏或不进入 `detail_json`。 + +## Steam 账户访问控制 + +### 当前运行模型 + +v1 仍只有一个活动 Steam 会话。后端维护当前活动账户: + +```text +app_meta.active_steam_account_id +``` + +所有依赖 Steam 账户上下文的接口都必须先解析当前活动账户: + +1. 校验后台用户会话。 +2. 读取当前 Steam 状态和 `active_steam_account_id`。 +3. 如果接口依赖 Steam 在线,继续要求 Steam 状态为 `online`。 +4. 校验当前用户是否可访问该 Steam 账户。 +5. 执行业务逻辑。 + +普通用户未被授权访问当前活动账户时返回 403: + +```json +{ + "error": "Steam account access denied" +} +``` + +如果当前没有活动 Steam 账户,依赖 Steam 的接口返回 503: + +```json +{ + "error": "Steam account is not connected", + "steamStatus": "logged_out" +} +``` + +### 需要保护的接口 + +以下接口必须校验 Steam 账户访问权: + +- `GET /api/steam/status` +- `GET /api/friends` +- `GET /api/groups` +- `GET /api/emoticons` +- `GET /history` +- `GET /conversations` +- `GET /proxy/sticker/:type` +- `GET /proxy/image` +- `POST /message` +- `POST /image` +- WebSocket 握手后的所有聊天、历史、好友、群组、素材消息类型 + +管理员管理接口仍只校验管理员权限: + +- Steam 登录、退出、切换账户。 +- Steam 账户维护。 +- 用户 Steam 账户授权维护。 + +### 聊天历史隔离 + +现有聊天历史是 JSONL 文件。为了避免用户读取未授权账户历史,新增日志字段: + +```json +{ + "steamAccountId": "7656119...", + "id": "target-steam-id", + "message": "..." +} +``` + +规则: + +- 新写入的历史必须带 `steamAccountId`。 +- 查询历史和会话列表时必须传入当前活动账户,并只返回该账户记录。 +- 旧历史没有 `steamAccountId`,迁移期只允许管理员查看。 +- 当前活动账户首次成功识别后,可以由管理员触发一次“认领旧历史到该 Steam 账户”的维护动作;v1 可以先不做自动认领。 + +## 后端 API + +### 当前用户 + +`GET /api/auth/me` 增加字段: + +```json +{ + "needsSetup": false, + "user": { + "id": 1, + "username": "admin", + "displayName": "管理员", + "role": "admin", + "disabled": false, + "forcePasswordChange": false + }, + "permissions": ["user.manage", "steam.manage"], + "steam": { + "status": "online", + "steamId": "7656119...", + "activeAccount": { + "id": 1, + "steamId": "7656119...", + "label": "客服一号" + }, + "accessAllowed": true + } +} +``` + +### 用户管理 + +新增或调整接口: + +- `GET /api/users?query=&role=&status=` + - 管理员可用。 + - 支持按账号、昵称、备注搜索。 + - `status` 支持 `enabled`、`disabled`、`locked`。 +- `POST /api/users` + - 新增 `displayName`、`note`、`steamAccountIds`。 +- `PATCH /api/users/:id` + - 支持 `displayName`、`note`、`role`、`disabled`、`forcePasswordChange`。 +- `GET /api/users/:id/sessions` + - 返回该用户未过期且未撤销的会话。 +- `DELETE /api/users/:id/sessions/:sessionId` + - 管理员踢下线单个会话。 +- `DELETE /api/users/:id/sessions` + - 管理员踢下线用户全部会话。 +- `GET /api/users/:id/steam-accounts` + - 查看普通用户已授权 Steam 账户。 +- `PUT /api/users/:id/steam-accounts` + - 用完整 `steamAccountIds` 覆盖授权关系。 + +保留现有接口: + +- `POST /api/users/:id/password` +- `DELETE /api/users/:id` +- `POST /api/auth/password` +- `POST /api/auth/logout` + +新增当前用户退出全部会话: + +- `POST /api/auth/logout-all` + - 撤销当前用户除当前请求外的所有会话,随后也可选择清除当前 Cookie。 + +### Steam 账户管理 + +新增接口: + +- `GET /api/steam/accounts` + - 管理员返回全部账户。 + - 普通用户返回自己被授权且启用的账户。 +- `POST /api/steam/accounts/login` + - 管理员用账号密码登录 Steam。 + - 请求包含 `accountName`、`password`、可选 `logonID`、可选 `label`。 + - 登录成功后用 SteamID64 upsert `steam_accounts`,写入 `refresh_token`,并设为当前活动账户。 +- `POST /api/steam/accounts/:id/connect` + - 管理员用该账户 `refresh_token` 连接 Steam。 + - 成功后设为当前活动账户。 +- `PATCH /api/steam/accounts/:id` + - 管理员更新 `label`、`accountNameHint`、`enabled`。 +- `DELETE /api/steam/accounts/:id` + - 管理员删除账户资料、授权关系和表内 refresh token。 + - 如果删除的是当前活动账户,必须先退出 Steam 或由后端自动执行退出。 +- `POST /api/steam/accounts/:id/logout` + - 管理员退出当前活动账户;只允许操作当前活动账户。 + +兼容现有接口: + +- `POST /api/steam/login` 可以保留为 `POST /api/steam/accounts/login` 的兼容入口。 +- `POST /api/steam/logout` 可以保留为退出当前活动账户的兼容入口。 +- `GET /api/steam/status` 返回当前活动账户信息和当前用户访问结果。 + +## 前端设计 + +### 导航 + +管理员导航增加: + +- `用户管理` +- `Steam 账户` +- `审计日志` + +普通用户导航保持: + +- `Steam 连接` 或状态页 +- `聊天` +- `账号` + +普通用户只看到自己可访问的 Steam 账户状态。未授权当前活动账户时,聊天页展示无权限状态,不加载好友、群组、历史和 WebSocket。 + +### 用户管理页 + +用户列表展示: + +- 账号。 +- 昵称。 +- 角色。 +- 启用、禁用、锁定状态。 +- 已授权 Steam 账户数量。 +- 最近登录时间和 IP。 +- 最近活跃时间。 + +用户详情区支持: + +- 修改昵称和备注。 +- 修改角色。 +- 启用、禁用。 +- 重置密码。 +- 要求下次登录改密。 +- 管理 Steam 账户授权。 +- 查看和踢下线会话。 + +### Steam 账户页 + +管理员可查看: + +- SteamID64。 +- 显示名称。 +- 当前是否活动。 +- 是否启用。 +- 最近登录时间。 +- 被授权用户数量。 + +操作: + +- 登录新的 Steam 账户。 +- 用已保存 token 连接已有账户。 +- 编辑显示名称和账号提示。 +- 启用、禁用。 +- 删除账户。 +- 查看已授权用户。 + +### 审计日志页 + +v1 只提供后台查看,不提供导出: + +- 按动作、目标类型、操作者、时间范围筛选。 +- 展示时间、操作者、动作、目标、IP、简要详情。 +- 敏感字段永远不展示。 + +## 迁移策略 + +1. 启动时创建新表并给 `users` 补齐新增字段。 +2. 引入 `schema_version` 或在 `app_meta` 中记录迁移版本,迁移保持幂等。 +3. 现有 Cookie 没有 `sid`,升级后统一要求重新登录。 +4. 现有 `${STEAM_CHAT_DATA_DIR}/refresh.token` 保留为兼容读取来源,首次成功登录并识别 SteamID 后写入 `steam_accounts.refresh_token` 和 `refresh_token_updated_at`。 +5. token 迁移成功后删除旧路径 `refresh.token`,避免同一敏感凭据存在两份。 +6. 首个识别出的 Steam 账户自动创建 `steam_accounts` 记录并设为当前活动账户。 +7. 普通用户默认不自动获得该账户访问权,由管理员显式授权。 +8. 旧聊天历史没有 `steamAccountId`,默认只允许管理员查看;后续可增加管理员手动认领工具。 + +## 安全要求 + +- 所有权限校验必须在后端执行,前端只做体验隐藏。 +- 管理员不能删除或禁用最后一个启用管理员。 +- 管理员不能删除当前登录用户自己。 +- 用户被禁用、角色变更、重置密码、强制改密时,相关旧会话必须失效。 +- 登录失败达到阈值后临时锁定账号;建议默认 5 次失败锁定 15 分钟。 +- 审计日志和错误日志不得包含后台密码、Steam 密码、Steam Guard code、refresh token。 +- `steam_accounts.refresh_token` 不得出现在任何 API 响应、前端状态、审计详情或结构化日志中。 +- Steam 账户授权失败统一返回 403,不暴露未授权账户的详情。 +- 普通用户不能通过历史、会话列表、WebSocket 或媒体代理旁路读取未授权账户数据。 + +## 测试要求 + +新增或调整测试: + +- 用户资料字段迁移和读写。 +- 固定角色到权限点映射。 +- 登录失败计数和锁定。 +- 会话表校验、踢下线、退出全部会话。 +- 用户禁用、角色变更、重置密码后旧会话失效。 +- Steam 账户创建、禁用、删除和表内 refresh token 读写。 +- 普通用户访问未授权 Steam 账户返回 403。 +- 普通用户不能读取未授权账户历史、会话列表、好友、群组、素材和 WebSocket 数据。 +- 管理员默认可访问所有 Steam 账户。 +- 审计日志记录关键管理动作并脱敏敏感字段。 +- 旧库迁移后已有管理员仍可登录。 + +验收命令: + +```bash +npm run typecheck +npm test +``` + +## 建议落地顺序 + +1. 数据迁移和权限点映射。 +2. 会话表和踢下线能力。 +3. 用户资料、登录安全和审计日志。 +4. Steam 账户表、授权关系和当前活动账户校验。 +5. 聊天历史增加 `steamAccountId` 并按账户过滤。 +6. 前端用户详情、Steam 账户页和审计日志页。 +7. 清理兼容接口和补齐测试。 diff --git a/src/auth/session.ts b/src/auth/session.ts index dd1d322..ac471b0 100644 --- a/src/auth/session.ts +++ b/src/auth/session.ts @@ -1,17 +1,22 @@ 'use strict'; import type { IncomingMessage } from 'node:http'; -import type { PublicUser } from './store'; +import type { PublicUser, PublicUserSession } from './store'; import { isRecord } from '../types'; const crypto = require('node:crypto'); type AuthStoreLike = { + createSession: (userId: unknown, input: { expiresAt: string; ip?: string | null; userAgent?: string | null }) => PublicUserSession; getOrCreateSessionSecret: () => string; getUserById: (id: unknown) => (PublicUser & { passwordHash?: string }) | null; + revokeSession: (sessionId: unknown, revokedBy?: unknown) => boolean; + revokeUserSessions: (userId: unknown, revokedBy?: unknown, exceptSessionId?: unknown) => number; + touchSession: (sessionId: unknown, userId: unknown, context?: { ip?: string | null; userAgent?: string | null }) => PublicUserSession | null; }; type SessionPayload = { + sid: string; uid: number; role: 'admin' | 'user'; sv: number; @@ -21,6 +26,7 @@ type SessionPayload = { export type AppSession = { payload: SessionPayload; + sessionId: string; user: PublicUser; }; @@ -28,6 +34,7 @@ type SessionManagerOptions = { store: AuthStoreLike; cookieName?: string; maxAgeMs?: number; + getClientIp?: (req: IncomingMessage) => string; }; const DEFAULT_COOKIE_NAME = 'steam_chat_session'; @@ -60,6 +67,10 @@ function parseCookieHeader(header: unknown): Record { return cookies; } +function headerText(value: string | string[] | undefined): string { + return Array.isArray(value) ? value[0] || '' : value || ''; +} + function secureCookieFor(req?: IncomingMessage): boolean { if (!req) return false; const socket = req.socket as IncomingMessage['socket'] & { encrypted?: boolean }; @@ -90,11 +101,20 @@ function userToPublic(user: PublicUser & { passwordHash?: string }): PublicUser return safeUser; } +function requestContext(req?: IncomingMessage, getClientIp?: (req: IncomingMessage) => string) { + if (!req) return {}; + return { + ip: getClientIp ? getClientIp(req) : req.socket?.remoteAddress || null, + userAgent: headerText(req.headers['user-agent']) || null + }; +} + function createSessionManager(options: SessionManagerOptions) { const store = options.store; const cookieName = options.cookieName || DEFAULT_COOKIE_NAME; const maxAgeMs = options.maxAgeMs || DEFAULT_MAX_AGE_MS; const secret = store.getOrCreateSessionSecret(); + const getRequestClientIp = options.getClientIp; function encode(payload: SessionPayload): string { const encoded = base64urlJson(payload); @@ -112,13 +132,14 @@ function createSessionManager(options: SessionManagerOptions) { const parsed: unknown = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')); if (!isRecord(parsed)) return null; const payload: SessionPayload = { + sid: typeof parsed.sid === 'string' ? parsed.sid : '', uid: Number(parsed.uid || 0), role: parsed.role === 'admin' ? 'admin' : 'user', sv: Number(parsed.sv || 0), iat: Number(parsed.iat || 0), exp: Number(parsed.exp || 0) }; - if (!payload.uid || !payload.sv || !payload.iat || !payload.exp) return null; + if (!payload.sid || !payload.uid || !payload.sv || !payload.iat || !payload.exp) return null; return payload; } catch (_) { return null; @@ -131,17 +152,25 @@ function createSessionManager(options: SessionManagerOptions) { if (!payload || payload.exp <= Date.now()) return null; const user = store.getUserById(payload.uid); if (!user || user.disabled || user.sessionVersion !== payload.sv || user.role !== payload.role) return null; - return { payload, user: userToPublic(user) }; + const session = store.touchSession(payload.sid, user.id, requestContext(req, getRequestClientIp)); + if (!session) return null; + return { payload, sessionId: session.id, user: userToPublic(user) }; } function createSetCookie(user: PublicUser, req?: IncomingMessage) { const iat = Date.now(); + const exp = iat + maxAgeMs; + const session = store.createSession(user.id, { + ...requestContext(req, getRequestClientIp), + expiresAt: new Date(exp).toISOString() + }); const payload: SessionPayload = { + sid: session.id, uid: user.id, role: user.role, sv: user.sessionVersion, iat, - exp: iat + maxAgeMs + exp }; return serializeCookie(cookieName, encode(payload), { maxAge: maxAgeMs / 1000, @@ -150,6 +179,16 @@ function createSessionManager(options: SessionManagerOptions) { }); } + function revokeCurrentSession(req: IncomingMessage, revokedBy?: unknown): boolean { + const raw = parseCookieHeader(req.headers.cookie)[cookieName]; + const payload = decode(raw); + return payload ? store.revokeSession(payload.sid, revokedBy ?? payload.uid) : false; + } + + function revokeUserSessions(userId: unknown, revokedBy?: unknown, exceptSessionId?: unknown): number { + return store.revokeUserSessions(userId, revokedBy, exceptSessionId); + } + function createClearCookie(req?: IncomingMessage) { return serializeCookie(cookieName, '', { maxAge: 0, @@ -179,7 +218,9 @@ function createSessionManager(options: SessionManagerOptions) { getSession, maxAgeMs, requireAdmin, - requireSession + requireSession, + revokeCurrentSession, + revokeUserSessions }; } diff --git a/src/auth/store.ts b/src/auth/store.ts index ff83ee8..7b777e0 100644 --- a/src/auth/store.ts +++ b/src/auth/store.ts @@ -14,29 +14,149 @@ type SQLRow = Record; export type UserRole = 'admin' | 'user'; +export type UserPermission = + | 'user.manage' + | 'session.manage' + | 'audit.view' + | 'steam.manage' + | 'steam.account.manage' + | 'chat.use' + | 'self.password.change'; + export type PublicUser = { id: number; username: string; + displayName: string; + note: string; role: UserRole; disabled: boolean; sessionVersion: number; createdAt: string; updatedAt: string; lastLoginAt: string | null; + lastLoginIp: string | null; + lastSeenAt: string | null; + passwordChangedAt: string | null; + forcePasswordChange: boolean; + failedLoginCount: number; + lockedUntil: string | null; + locked: boolean; + createdBy: number | null; + steamAccountCount: number; }; type UserRow = PublicUser & { passwordHash: string; }; +export type PublicUserSession = { + id: string; + userId: number; + createdAt: string; + lastSeenAt: string; + expiresAt: string; + revokedAt: string | null; + revokedBy: number | null; + ip: string | null; + userAgent: string | null; +}; + +export type PublicSteamAccount = { + id: number; + steamId: string; + label: string; + accountNameHint: string; + enabled: boolean; + createdBy: number | null; + createdAt: string; + updatedAt: string; + lastLoginAt: string | null; + lastActiveAt: string | null; + refreshTokenUpdatedAt: string | null; + authorizedUserCount: number; + active: boolean; +}; + +type SteamAccountRow = PublicSteamAccount & { + refreshToken: string | null; +}; + +export type PublicAuditLog = { + id: number; + actorUserId: number | null; + actorUsername: string | null; + action: string; + targetType: string; + targetId: string; + detail: UnknownRecord; + ip: string | null; + userAgent: string | null; + createdAt: string; +}; + type AuthStoreOptions = { dbPath?: string; database?: DatabaseSync; }; +type RequestContext = { + ip?: string | null; + userAgent?: string | null; +}; + +type SessionCreateInput = RequestContext & { + expiresAt: string; +}; + +type UserListFilters = { + query?: unknown; + role?: unknown; + status?: unknown; +}; + +type AuditListFilters = { + action?: unknown; + targetType?: unknown; + actorUserId?: unknown; + from?: unknown; + to?: unknown; + limit?: unknown; +}; + const USERNAME_PATTERN = /^[A-Za-z0-9_.-]{3,64}$/; const PASSWORD_MIN_LENGTH = 8; const HASH_PREFIX = 'scrypt:v1'; +const FAILED_LOGIN_LOCK_THRESHOLD = 5; +const LOCK_MS = 15 * 60 * 1000; +const TOUCH_THROTTLE_MS = 60 * 1000; + +const ROLE_PERMISSIONS: Record = { + admin: [ + 'user.manage', + 'session.manage', + 'audit.view', + 'steam.manage', + 'steam.account.manage', + 'chat.use', + 'self.password.change' + ], + user: [ + 'chat.use', + 'self.password.change' + ] +}; + +const SENSITIVE_DETAIL_KEYS = new Set([ + 'password', + 'oldPassword', + 'newPassword', + 'refreshToken', + 'refresh_token', + 'steamGuard', + 'steamGuardCode', + 'code', + 'guardCode' +]); function nowIso() { return new Date().toISOString(); @@ -50,10 +170,30 @@ function asNumber(value: unknown): number { return typeof value === 'bigint' ? Number(value) : Number(value || 0); } +function asNullableNumber(value: unknown): number | null { + if (value === null || value === undefined || value === '') return null; + const parsed = asNumber(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : null; +} + function asRole(value: unknown): UserRole { return value === 'admin' ? 'admin' : 'user'; } +function textOrNull(value: unknown): string | null { + return typeof value === 'string' && value ? value : null; +} + +function futureIso(msFromNow: number) { + return new Date(Date.now() + msFromNow).toISOString(); +} + +function isFutureIso(value: unknown): boolean { + if (typeof value !== 'string' || !value) return false; + const parsed = Date.parse(value); + return Number.isFinite(parsed) && parsed > Date.now(); +} + function validateUsername(username: unknown): string { const value = String(username || '').trim(); if (!USERNAME_PATTERN.test(value)) { @@ -75,6 +215,24 @@ function validateRole(role: unknown): UserRole { throw httpError(400, 'Role must be admin or user'); } +function validateShortText(value: unknown, field: string, maxLength: number): string { + const text = String(value || '').trim(); + if (text.length > maxLength) throw httpError(400, `${field} is too long`); + return text; +} + +function sanitizeSteamId(value: unknown): string { + const text = String(value || '').trim(); + if (!/^[0-9]{3,32}$/.test(text)) throw httpError(400, 'steamId must be numeric'); + return text; +} + +function normalizeIdList(value: unknown): number[] { + if (!Array.isArray(value)) return []; + const ids = value.map((item) => Math.floor(Number(item))).filter((item) => Number.isFinite(item) && item > 0); + return [...new Set(ids)]; +} + function hashPassword(password: string) { const salt = crypto.randomBytes(16).toString('base64url'); const hash = crypto.scryptSync(password, salt, 64).toString('base64url'); @@ -92,24 +250,33 @@ function verifyPassword(password: unknown, stored: unknown): boolean { return crypto.timingSafeEqual(left, right); } -function userFromRow(row: SQLRow | undefined): UserRow | null { - if (!row) return null; - return { - id: asNumber(row.id), - username: String(row.username || ''), - passwordHash: String(row.password_hash || ''), - role: asRole(row.role), - disabled: Boolean(asNumber(row.disabled)), - sessionVersion: asNumber(row.session_version), - createdAt: String(row.created_at || ''), - updatedAt: String(row.updated_at || ''), - lastLoginAt: typeof row.last_login_at === 'string' ? row.last_login_at : null - }; +function parseJsonObject(value: unknown): UnknownRecord { + if (typeof value !== 'string' || !value) return {}; + try { + const parsed: unknown = JSON.parse(value); + return isRecord(parsed) ? parsed : {}; + } catch (_) { + return {}; + } } -function publicUser(user: UserRow): PublicUser { - const { passwordHash: _passwordHash, ...safeUser } = user; - return safeUser; +function sanitizeAuditDetail(value: unknown): unknown { + if (Array.isArray(value)) return value.map((item) => sanitizeAuditDetail(item)); + if (!isRecord(value)) return value; + const result: UnknownRecord = {}; + for (const [key, item] of Object.entries(value)) { + result[key] = SENSITIVE_DETAIL_KEYS.has(key) ? '[redacted]' : sanitizeAuditDetail(item); + } + return result; +} + +function permissionsForRole(role: UserRole): UserPermission[] { + return [...ROLE_PERMISSIONS[role]]; +} + +function hasPermission(roleOrUser: UserRole | Pick, permission: UserPermission): boolean { + const role = typeof roleOrUser === 'string' ? roleOrUser : roleOrUser.role; + return ROLE_PERMISSIONS[role].includes(permission); } function createAuthStore(options: AuthStoreOptions = {}) { @@ -138,8 +305,184 @@ function createAuthStore(options: AuthStoreOptions = {}) { value TEXT NOT NULL, updated_at TEXT NOT NULL ); + + CREATE TABLE IF NOT EXISTS user_sessions ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + created_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + revoked_at TEXT, + revoked_by INTEGER, + ip TEXT, + user_agent TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (revoked_by) REFERENCES users(id) ON DELETE SET NULL + ); + + CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id); + CREATE INDEX IF NOT EXISTS idx_user_sessions_expires_at ON user_sessions(expires_at); + + CREATE TABLE IF NOT EXISTS steam_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + steam_id TEXT NOT NULL UNIQUE, + label TEXT NOT NULL DEFAULT '', + account_name_hint TEXT NOT NULL DEFAULT '', + refresh_token TEXT, + refresh_token_updated_at TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + created_by INTEGER, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + last_login_at TEXT, + last_active_at TEXT, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL + ); + + CREATE TABLE IF NOT EXISTS user_steam_accounts ( + user_id INTEGER NOT NULL, + steam_account_id INTEGER NOT NULL, + granted_by INTEGER, + granted_at TEXT NOT NULL, + PRIMARY KEY (user_id, steam_account_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (steam_account_id) REFERENCES steam_accounts(id) ON DELETE CASCADE, + FOREIGN KEY (granted_by) REFERENCES users(id) ON DELETE SET NULL + ); + + CREATE TABLE IF NOT EXISTS audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor_user_id INTEGER, + action TEXT NOT NULL, + target_type TEXT NOT NULL, + target_id TEXT NOT NULL, + detail_json TEXT NOT NULL DEFAULT '{}', + ip TEXT, + user_agent TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL + ); + + CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at); + CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit_logs(action); `); + function columnsFor(table: string): Set { + return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((row: SQLRow) => String(row.name))); + } + + function addColumnIfMissing(table: string, column: string, sql: string) { + if (!columnsFor(table).has(column)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${sql}`); + } + + addColumnIfMissing('users', 'display_name', "display_name TEXT NOT NULL DEFAULT ''"); + addColumnIfMissing('users', 'note', "note TEXT NOT NULL DEFAULT ''"); + addColumnIfMissing('users', 'created_by', 'created_by INTEGER'); + addColumnIfMissing('users', 'last_login_ip', 'last_login_ip TEXT'); + addColumnIfMissing('users', 'last_seen_at', 'last_seen_at TEXT'); + addColumnIfMissing('users', 'password_changed_at', 'password_changed_at TEXT'); + addColumnIfMissing('users', 'force_password_change', 'force_password_change INTEGER NOT NULL DEFAULT 0'); + addColumnIfMissing('users', 'failed_login_count', 'failed_login_count INTEGER NOT NULL DEFAULT 0'); + addColumnIfMissing('users', 'locked_until', 'locked_until TEXT'); + + function steamAccountCountForUser(userId: unknown): number { + const row = db.prepare('SELECT COUNT(*) AS count FROM user_steam_accounts WHERE user_id = ?').get(asNumber(userId)); + return asNumber(row?.count); + } + + function userFromRow(row: SQLRow | undefined): UserRow | null { + if (!row) return null; + const lockedUntil = textOrNull(row.locked_until); + return { + id: asNumber(row.id), + username: String(row.username || ''), + passwordHash: String(row.password_hash || ''), + displayName: String(row.display_name || ''), + note: String(row.note || ''), + role: asRole(row.role), + disabled: Boolean(asNumber(row.disabled)), + sessionVersion: asNumber(row.session_version), + createdAt: String(row.created_at || ''), + updatedAt: String(row.updated_at || ''), + lastLoginAt: textOrNull(row.last_login_at), + lastLoginIp: textOrNull(row.last_login_ip), + lastSeenAt: textOrNull(row.last_seen_at), + passwordChangedAt: textOrNull(row.password_changed_at), + forcePasswordChange: Boolean(asNumber(row.force_password_change)), + failedLoginCount: asNumber(row.failed_login_count), + lockedUntil, + locked: isFutureIso(lockedUntil), + createdBy: asNullableNumber(row.created_by), + steamAccountCount: steamAccountCountForUser(row.id) + }; + } + + function publicUser(user: UserRow): PublicUser { + const { passwordHash: _passwordHash, ...safeUser } = user; + return safeUser; + } + + function sessionFromRow(row: SQLRow | undefined): PublicUserSession | null { + if (!row) return null; + return { + id: String(row.id || ''), + userId: asNumber(row.user_id), + createdAt: String(row.created_at || ''), + lastSeenAt: String(row.last_seen_at || ''), + expiresAt: String(row.expires_at || ''), + revokedAt: textOrNull(row.revoked_at), + revokedBy: asNullableNumber(row.revoked_by), + ip: textOrNull(row.ip), + userAgent: textOrNull(row.user_agent) + }; + } + + function activeSteamAccountId(): number | null { + const value = Number.parseInt(String(getMeta('active_steam_account_id') || ''), 10); + return Number.isFinite(value) && value > 0 ? value : null; + } + + function steamAccountFromRow(row: SQLRow | undefined): SteamAccountRow | null { + if (!row) return null; + const activeId = activeSteamAccountId(); + return { + id: asNumber(row.id), + steamId: String(row.steam_id || ''), + label: String(row.label || ''), + accountNameHint: String(row.account_name_hint || ''), + refreshToken: textOrNull(row.refresh_token), + refreshTokenUpdatedAt: textOrNull(row.refresh_token_updated_at), + enabled: Boolean(asNumber(row.enabled)), + createdBy: asNullableNumber(row.created_by), + createdAt: String(row.created_at || ''), + updatedAt: String(row.updated_at || ''), + lastLoginAt: textOrNull(row.last_login_at), + lastActiveAt: textOrNull(row.last_active_at), + authorizedUserCount: asNumber(row.authorized_user_count), + active: activeId === asNumber(row.id) + }; + } + + function publicSteamAccount(account: SteamAccountRow): PublicSteamAccount { + const { refreshToken: _refreshToken, ...safeAccount } = account; + return safeAccount; + } + + function auditLogFromRow(row: SQLRow): PublicAuditLog { + return { + id: asNumber(row.id), + actorUserId: asNullableNumber(row.actor_user_id), + actorUsername: textOrNull(row.actor_username), + action: String(row.action || ''), + targetType: String(row.target_type || ''), + targetId: String(row.target_id || ''), + detail: parseJsonObject(row.detail_json), + ip: textOrNull(row.ip), + userAgent: textOrNull(row.user_agent), + createdAt: String(row.created_at || '') + }; + } + function getUserById(id: unknown): UserRow | null { return userFromRow(db.prepare('SELECT * FROM users WHERE id = ?').get(asNumber(id))); } @@ -164,19 +507,52 @@ function createAuthStore(options: AuthStoreOptions = {}) { } } + function replaceUserSteamAccounts(userId: unknown, steamAccountIds: unknown, grantedBy?: unknown): PublicSteamAccount[] { + const user = getUserById(userId); + if (!user) throw httpError(404, 'User not found'); + const ids = normalizeIdList(steamAccountIds); + for (const id of ids) { + if (!getSteamAccountById(id)) throw httpError(404, `Steam account not found: ${id}`); + } + const at = nowIso(); + db.exec('BEGIN'); + try { + db.prepare('DELETE FROM user_steam_accounts WHERE user_id = ?').run(user.id); + const insert = db.prepare(` + INSERT INTO user_steam_accounts (user_id, steam_account_id, granted_by, granted_at) + VALUES (?, ?, ?, ?) + `); + for (const id of ids) insert.run(user.id, id, asNullableNumber(grantedBy), at); + db.exec('COMMIT'); + } catch (error) { + db.exec('ROLLBACK'); + throw error; + } + return listUserSteamAccounts(user.id); + } + function createUser(input: UnknownRecord): PublicUser { const username = validateUsername(input.username); const password = validatePassword(input.password); const role = validateRole(input.role || 'user'); + const displayName = validateShortText(input.displayName ?? input.display_name, 'displayName', 80); + const note = validateShortText(input.note, 'note', 500); + const createdBy = asNullableNumber(input.createdBy ?? input.created_by); const at = nowIso(); try { const result = db.prepare(` - INSERT INTO users (username, password_hash, role, disabled, session_version, created_at, updated_at) - VALUES (?, ?, ?, 0, 1, ?, ?) - `).run(username, hashPassword(password), role, at, at); + INSERT INTO users ( + username, password_hash, display_name, note, role, disabled, session_version, + created_by, created_at, updated_at, password_changed_at + ) + VALUES (?, ?, ?, ?, ?, 0, 1, ?, ?, ?, ?) + `).run(username, hashPassword(password), displayName, note, role, createdBy, at, at, at); const user = getUserById(result.lastInsertRowid); if (!user) throw httpError(500, 'Created user cannot be loaded'); - return publicUser(user); + if (Object.prototype.hasOwnProperty.call(input, 'steamAccountIds')) { + replaceUserSteamAccounts(user.id, input.steamAccountIds, createdBy); + } + return publicUser(getUserById(user.id)!); } catch (error) { if (isRecord(error) && String(error.message || '').includes('UNIQUE')) { throw httpError(409, 'Username already exists'); @@ -190,18 +566,54 @@ function createAuthStore(options: AuthStoreOptions = {}) { return createUser({ ...input, role: 'admin' }); } - function authenticate(username: unknown, password: unknown): PublicUser | null { - const user = getUserByUsername(username); - if (!user || user.disabled || !verifyPassword(password, user.passwordHash)) return null; - const at = nowIso(); - db.prepare('UPDATE users SET last_login_at = ?, updated_at = ? WHERE id = ?').run(at, at, user.id); - return publicUser({ ...user, lastLoginAt: at, updatedAt: at }); + function recordLoginFailure(user: UserRow) { + const nextCount = user.failedLoginCount + 1; + const lockedUntil = nextCount >= FAILED_LOGIN_LOCK_THRESHOLD ? futureIso(LOCK_MS) : null; + db.prepare(` + UPDATE users + SET failed_login_count = ?, locked_until = ?, updated_at = ? + WHERE id = ? + `).run(nextCount, lockedUntil, nowIso(), user.id); } - function listUsers(): PublicUser[] { - return db.prepare('SELECT * FROM users ORDER BY id ASC') + function authenticate(username: unknown, password: unknown, context: RequestContext = {}): PublicUser | null { + const user = getUserByUsername(username); + if (!user) return null; + if (user.locked) throw httpError(423, 'User is temporarily locked'); + if (user.disabled) return null; + if (!verifyPassword(password, user.passwordHash)) { + recordLoginFailure(user); + return null; + } + const at = nowIso(); + db.prepare(` + UPDATE users + SET last_login_at = ?, last_login_ip = ?, last_seen_at = ?, + failed_login_count = 0, locked_until = NULL, updated_at = ? + WHERE id = ? + `).run(at, context.ip || null, at, at, user.id); + return publicUser(getUserById(user.id)!); + } + + function listUsers(filters: UserListFilters = {}): PublicUser[] { + let users = db.prepare('SELECT * FROM users ORDER BY id ASC') .all() .map((row: SQLRow) => publicUser(userFromRow(row)!)); + const query = String(filters.query || '').trim().toLowerCase(); + const role = String(filters.role || '').trim(); + const status = String(filters.status || '').trim(); + if (query) { + users = users.filter((user) => [ + user.username, + user.displayName, + user.note + ].some((value) => value.toLowerCase().includes(query))); + } + if (role === 'admin' || role === 'user') users = users.filter((user) => user.role === role); + if (status === 'enabled') users = users.filter((user) => !user.disabled && !user.locked); + if (status === 'disabled') users = users.filter((user) => user.disabled); + if (status === 'locked') users = users.filter((user) => user.locked); + return users; } function updateUser(id: unknown, patch: UnknownRecord): PublicUser { @@ -209,31 +621,65 @@ function createAuthStore(options: AuthStoreOptions = {}) { if (!user) throw httpError(404, 'User not found'); const nextRole = Object.prototype.hasOwnProperty.call(patch, 'role') ? validateRole(patch.role) : user.role; const nextDisabled = Object.prototype.hasOwnProperty.call(patch, 'disabled') ? Boolean(patch.disabled) : user.disabled; + const nextDisplayName = Object.prototype.hasOwnProperty.call(patch, 'displayName') + ? validateShortText(patch.displayName, 'displayName', 80) + : user.displayName; + const nextNote = Object.prototype.hasOwnProperty.call(patch, 'note') + ? validateShortText(patch.note, 'note', 500) + : user.note; + const nextForcePasswordChange = Object.prototype.hasOwnProperty.call(patch, 'forcePasswordChange') + ? Boolean(patch.forcePasswordChange) + : user.forcePasswordChange; if (user.role === 'admin' && !user.disabled && (nextRole !== 'admin' || nextDisabled)) { ensureCanRemoveAdmin(user); } - if (nextRole === user.role && nextDisabled === user.disabled) return publicUser(user); + const sessionBump = nextRole !== user.role + || nextDisabled !== user.disabled + || nextForcePasswordChange !== user.forcePasswordChange; + if ( + nextRole === user.role + && nextDisabled === user.disabled + && nextDisplayName === user.displayName + && nextNote === user.note + && nextForcePasswordChange === user.forcePasswordChange + ) { + return publicUser(user); + } const at = nowIso(); db.prepare(` UPDATE users - SET role = ?, disabled = ?, session_version = session_version + 1, updated_at = ? + SET display_name = ?, note = ?, role = ?, disabled = ?, force_password_change = ?, + session_version = session_version + ?, updated_at = ? WHERE id = ? - `).run(nextRole, nextDisabled ? 1 : 0, at, user.id); + `).run( + nextDisplayName, + nextNote, + nextRole, + nextDisabled ? 1 : 0, + nextForcePasswordChange ? 1 : 0, + sessionBump ? 1 : 0, + at, + user.id + ); const next = getUserById(user.id); if (!next) throw httpError(500, 'Updated user cannot be loaded'); return publicUser(next); } - function setPassword(id: unknown, password: unknown): PublicUser { + function setPassword(id: unknown, password: unknown, options: UnknownRecord = {}): PublicUser { const user = getUserById(id); if (!user) throw httpError(404, 'User not found'); const nextPassword = validatePassword(password); + const forcePasswordChange = Object.prototype.hasOwnProperty.call(options, 'forcePasswordChange') + ? Boolean(options.forcePasswordChange) + : user.forcePasswordChange; const at = nowIso(); db.prepare(` UPDATE users - SET password_hash = ?, session_version = session_version + 1, updated_at = ? + SET password_hash = ?, session_version = session_version + 1, + password_changed_at = ?, force_password_change = ?, updated_at = ? WHERE id = ? - `).run(hashPassword(nextPassword), at, user.id); + `).run(hashPassword(nextPassword), at, forcePasswordChange ? 1 : 0, at, user.id); const next = getUserById(user.id); if (!next) throw httpError(500, 'Updated user cannot be loaded'); return publicUser(next); @@ -243,7 +689,7 @@ function createAuthStore(options: AuthStoreOptions = {}) { const user = getUserById(id); if (!user) throw httpError(404, 'User not found'); if (!verifyPassword(oldPassword, user.passwordHash)) throw httpError(401, 'Old password is incorrect'); - return setPassword(user.id, newPassword); + return setPassword(user.id, newPassword, { forcePasswordChange: false }); } function deleteUser(id: unknown, currentUserId: unknown): void { @@ -254,6 +700,83 @@ function createAuthStore(options: AuthStoreOptions = {}) { db.prepare('DELETE FROM users WHERE id = ?').run(user.id); } + function createSession(userId: unknown, input: SessionCreateInput): PublicUserSession { + const user = getUserById(userId); + if (!user) throw httpError(404, 'User not found'); + const at = nowIso(); + const sessionId = crypto.randomBytes(32).toString('base64url'); + db.prepare(` + INSERT INTO user_sessions (id, user_id, created_at, last_seen_at, expires_at, ip, user_agent) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run( + sessionId, + user.id, + at, + at, + input.expiresAt, + input.ip || null, + input.userAgent || null + ); + return sessionFromRow(db.prepare('SELECT * FROM user_sessions WHERE id = ?').get(sessionId))!; + } + + function getSessionById(sessionId: unknown): PublicUserSession | null { + return sessionFromRow(db.prepare('SELECT * FROM user_sessions WHERE id = ?').get(String(sessionId || ''))); + } + + function validateSession(sessionId: unknown, userId: unknown): PublicUserSession | null { + const session = getSessionById(sessionId); + if (!session || session.userId !== asNumber(userId) || session.revokedAt || isFutureIso(session.expiresAt) === false) { + return null; + } + return session; + } + + function touchSession(sessionId: unknown, userId: unknown, context: RequestContext = {}): PublicUserSession | null { + const session = validateSession(sessionId, userId); + if (!session) return null; + const lastSeenMs = Date.parse(session.lastSeenAt); + if (Number.isFinite(lastSeenMs) && Date.now() - lastSeenMs < TOUCH_THROTTLE_MS) return session; + const at = nowIso(); + db.prepare('UPDATE user_sessions SET last_seen_at = ?, ip = COALESCE(?, ip), user_agent = COALESCE(?, user_agent) WHERE id = ?') + .run(at, context.ip || null, context.userAgent || null, session.id); + db.prepare('UPDATE users SET last_seen_at = ?, updated_at = ? WHERE id = ?').run(at, at, asNumber(userId)); + return getSessionById(session.id); + } + + function listUserSessions(userId: unknown): PublicUserSession[] { + return db.prepare(` + SELECT * FROM user_sessions + WHERE user_id = ? AND revoked_at IS NULL AND expires_at > ? + ORDER BY last_seen_at DESC + `).all(asNumber(userId), nowIso()).map((row: SQLRow) => sessionFromRow(row)!); + } + + function revokeSession(sessionId: unknown, revokedBy?: unknown): boolean { + const session = getSessionById(sessionId); + if (!session || session.revokedAt) return false; + db.prepare('UPDATE user_sessions SET revoked_at = ?, revoked_by = ? WHERE id = ?') + .run(nowIso(), asNullableNumber(revokedBy), session.id); + return true; + } + + function revokeUserSessions(userId: unknown, revokedBy?: unknown, exceptSessionId?: unknown): number { + const at = nowIso(); + const except = String(exceptSessionId || ''); + const result = except + ? db.prepare(` + UPDATE user_sessions + SET revoked_at = ?, revoked_by = ? + WHERE user_id = ? AND id <> ? AND revoked_at IS NULL + `).run(at, asNullableNumber(revokedBy), asNumber(userId), except) + : db.prepare(` + UPDATE user_sessions + SET revoked_at = ?, revoked_by = ? + WHERE user_id = ? AND revoked_at IS NULL + `).run(at, asNullableNumber(revokedBy), asNumber(userId)); + return asNumber(result.changes); + } + function getMeta(key: string): string | null { const row = db.prepare('SELECT value FROM app_meta WHERE key = ?').get(key); return typeof row?.value === 'string' ? row.value : null; @@ -287,38 +810,289 @@ function createAuthStore(options: AuthStoreOptions = {}) { return countUsers() === 0; } + function accountSelectSql(where: string) { + return ` + SELECT steam_accounts.*, + (SELECT COUNT(*) FROM user_steam_accounts WHERE steam_account_id = steam_accounts.id) AS authorized_user_count + FROM steam_accounts + ${where} + `; + } + + function getSteamAccountById(id: unknown, includeToken = false): SteamAccountRow | PublicSteamAccount | null { + const row = steamAccountFromRow(db.prepare(accountSelectSql('WHERE steam_accounts.id = ?')).get(asNumber(id))); + if (!row) return null; + return includeToken ? row : publicSteamAccount(row); + } + + function getSteamAccountBySteamId(steamId: unknown, includeToken = false): SteamAccountRow | PublicSteamAccount | null { + const row = steamAccountFromRow(db.prepare(accountSelectSql('WHERE steam_accounts.steam_id = ?')).get(String(steamId || '').trim())); + if (!row) return null; + return includeToken ? row : publicSteamAccount(row); + } + + function listSteamAccounts(includeDisabled = true): PublicSteamAccount[] { + const where = includeDisabled ? '' : 'WHERE steam_accounts.enabled = 1'; + return db.prepare(`${accountSelectSql(where)} ORDER BY steam_accounts.id ASC`) + .all() + .map((row: SQLRow) => publicSteamAccount(steamAccountFromRow(row)!)); + } + + function listSteamAccountsForUser(userId: unknown): PublicSteamAccount[] { + const user = getUserById(userId); + if (!user) throw httpError(404, 'User not found'); + if (user.role === 'admin') return listSteamAccounts(true); + return db.prepare(` + ${accountSelectSql('JOIN user_steam_accounts ON user_steam_accounts.steam_account_id = steam_accounts.id WHERE user_steam_accounts.user_id = ? AND steam_accounts.enabled = 1')} + ORDER BY steam_accounts.id ASC + `).all(user.id).map((row: SQLRow) => publicSteamAccount(steamAccountFromRow(row)!)); + } + + function listUserSteamAccounts(userId: unknown): PublicSteamAccount[] { + return db.prepare(` + ${accountSelectSql('JOIN user_steam_accounts ON user_steam_accounts.steam_account_id = steam_accounts.id WHERE user_steam_accounts.user_id = ?')} + ORDER BY steam_accounts.id ASC + `).all(asNumber(userId)).map((row: SQLRow) => publicSteamAccount(steamAccountFromRow(row)!)); + } + + function upsertSteamAccount(input: UnknownRecord): PublicSteamAccount { + const steamId = sanitizeSteamId(input.steamId ?? input.steam_id); + const existing = getSteamAccountBySteamId(steamId, true) as SteamAccountRow | null; + const at = nowIso(); + const label = Object.prototype.hasOwnProperty.call(input, 'label') ? validateShortText(input.label, 'label', 80) : undefined; + const accountNameHint = Object.prototype.hasOwnProperty.call(input, 'accountNameHint') + ? validateShortText(input.accountNameHint, 'accountNameHint', 120) + : Object.prototype.hasOwnProperty.call(input, 'account_name_hint') + ? validateShortText(input.account_name_hint, 'accountNameHint', 120) + : undefined; + const refreshToken = typeof input.refreshToken === 'string' && input.refreshToken ? input.refreshToken : undefined; + const createdBy = asNullableNumber(input.createdBy ?? input.created_by); + let account: SteamAccountRow | null; + if (existing) { + db.prepare(` + UPDATE steam_accounts + SET label = ?, account_name_hint = ?, + refresh_token = COALESCE(?, refresh_token), + refresh_token_updated_at = CASE WHEN ? IS NULL THEN refresh_token_updated_at ELSE ? END, + enabled = CASE WHEN enabled IS NULL THEN 1 ELSE enabled END, + updated_at = ?, last_login_at = ? + WHERE id = ? + `).run( + label === undefined ? existing.label : label, + accountNameHint === undefined ? existing.accountNameHint : accountNameHint, + refreshToken || null, + refreshToken || null, + refreshToken ? at : null, + at, + at, + existing.id + ); + account = getSteamAccountById(existing.id, true) as SteamAccountRow | null; + } else { + const result = db.prepare(` + INSERT INTO steam_accounts ( + steam_id, label, account_name_hint, refresh_token, refresh_token_updated_at, + enabled, created_by, created_at, updated_at, last_login_at + ) + VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?) + `).run( + steamId, + label || '', + accountNameHint || '', + refreshToken || null, + refreshToken ? at : null, + createdBy, + at, + at, + at + ); + account = getSteamAccountById(result.lastInsertRowid, true) as SteamAccountRow | null; + } + if (!account) throw httpError(500, 'Steam account cannot be loaded'); + if (input.setActive) setActiveSteamAccount(account.id); + return publicSteamAccount(getSteamAccountById(account.id, true) as SteamAccountRow); + } + + function updateSteamAccount(id: unknown, patch: UnknownRecord): PublicSteamAccount { + const account = getSteamAccountById(id, true) as SteamAccountRow | null; + if (!account) throw httpError(404, 'Steam account not found'); + const label = Object.prototype.hasOwnProperty.call(patch, 'label') + ? validateShortText(patch.label, 'label', 80) + : account.label; + const accountNameHint = Object.prototype.hasOwnProperty.call(patch, 'accountNameHint') + ? validateShortText(patch.accountNameHint, 'accountNameHint', 120) + : account.accountNameHint; + const enabled = Object.prototype.hasOwnProperty.call(patch, 'enabled') ? Boolean(patch.enabled) : account.enabled; + db.prepare(` + UPDATE steam_accounts + SET label = ?, account_name_hint = ?, enabled = ?, updated_at = ? + WHERE id = ? + `).run(label, accountNameHint, enabled ? 1 : 0, nowIso(), account.id); + return publicSteamAccount(getSteamAccountById(account.id, true) as SteamAccountRow); + } + + function deleteSteamAccount(id: unknown): void { + const account = getSteamAccountById(id, true) as SteamAccountRow | null; + if (!account) throw httpError(404, 'Steam account not found'); + db.prepare('DELETE FROM steam_accounts WHERE id = ?').run(account.id); + if (activeSteamAccountId() === account.id) setMeta('active_steam_account_id', ''); + } + + function setActiveSteamAccount(id: unknown): PublicSteamAccount { + const account = getSteamAccountById(id, true) as SteamAccountRow | null; + if (!account) throw httpError(404, 'Steam account not found'); + setMeta('active_steam_account_id', String(account.id)); + return publicSteamAccount(getSteamAccountById(account.id, true) as SteamAccountRow); + } + + function getActiveSteamAccount(includeToken = false): SteamAccountRow | PublicSteamAccount | null { + const id = activeSteamAccountId(); + return id ? getSteamAccountById(id, includeToken) : null; + } + + function markSteamAccountActive(id: unknown): void { + const account = getSteamAccountById(id, true) as SteamAccountRow | null; + if (!account) throw httpError(404, 'Steam account not found'); + db.prepare('UPDATE steam_accounts SET last_active_at = ?, updated_at = ? WHERE id = ?').run(nowIso(), nowIso(), account.id); + } + + function canAccessSteamAccount(userId: unknown, steamAccountId: unknown): boolean { + const user = getUserById(userId); + const account = getSteamAccountById(steamAccountId, true) as SteamAccountRow | null; + if (!user || !account || !account.enabled) return false; + if (user.role === 'admin') return true; + const row = db.prepare(` + SELECT 1 AS ok FROM user_steam_accounts + WHERE user_id = ? AND steam_account_id = ? + `).get(user.id, account.id); + return Boolean(row); + } + + function recordAudit(input: UnknownRecord): PublicAuditLog { + const action = validateShortText(input.action, 'action', 80); + const targetType = validateShortText(input.targetType ?? input.target_type, 'targetType', 80); + const targetId = validateShortText(input.targetId ?? input.target_id, 'targetId', 160); + const detail = isRecord(input.detail) ? sanitizeAuditDetail(input.detail) : {}; + const at = nowIso(); + const result = db.prepare(` + INSERT INTO audit_logs ( + actor_user_id, action, target_type, target_id, detail_json, ip, user_agent, created_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run( + asNullableNumber(input.actorUserId ?? input.actor_user_id), + action, + targetType, + targetId, + JSON.stringify(detail), + textOrNull(input.ip), + textOrNull(input.userAgent ?? input.user_agent), + at + ); + return listAuditLogs({ limit: 1 }).find((item) => item.id === asNumber(result.lastInsertRowid))!; + } + + function listAuditLogs(filters: AuditListFilters = {}): PublicAuditLog[] { + const clauses: string[] = []; + const params: Array = []; + const action = String(filters.action || '').trim(); + const targetType = String(filters.targetType || '').trim(); + const actorUserId = Number(filters.actorUserId || 0); + const from = String(filters.from || '').trim(); + const to = String(filters.to || '').trim(); + if (action) { + clauses.push('audit_logs.action = ?'); + params.push(action); + } + if (targetType) { + clauses.push('audit_logs.target_type = ?'); + params.push(targetType); + } + if (Number.isFinite(actorUserId) && actorUserId > 0) { + clauses.push('audit_logs.actor_user_id = ?'); + params.push(actorUserId); + } + if (from) { + clauses.push('audit_logs.created_at >= ?'); + params.push(from); + } + if (to) { + clauses.push('audit_logs.created_at <= ?'); + params.push(to); + } + const parsedLimit = Number.parseInt(String(filters.limit || ''), 10); + const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 200) : 100; + params.push(limit); + return db.prepare(` + SELECT audit_logs.*, users.username AS actor_username + FROM audit_logs + LEFT JOIN users ON users.id = audit_logs.actor_user_id + ${clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''} + ORDER BY audit_logs.id DESC + LIMIT ? + `).all(...params).map((row: SQLRow) => auditLogFromRow(row)); + } + return { db, dbPath, authenticate, + canAccessSteamAccount, changeOwnPassword, close() { db.close(); }, countUsers, createInitialAdmin, + createSession, createUser, + deleteSteamAccount, deleteUser, enabledAdminCount, + getActiveSteamAccount, getMeta, getOrCreateSessionSecret, getOrCreateSteamLogonID, + getSessionById, + getSteamAccountById, + getSteamAccountBySteamId, getUserById, getUserByUsername, + hasPermission, + listAuditLogs, + listSteamAccounts, + listSteamAccountsForUser, + listUserSessions, + listUserSteamAccounts, listUsers, + markSteamAccountActive, + permissionsForRole, + recordAudit, + replaceUserSteamAccounts, requiresSetup, + revokeSession, + revokeUserSessions, + setActiveSteamAccount, setMeta, setPassword, - updateUser + touchSession, + updateSteamAccount, + updateUser, + upsertSteamAccount, + validateSession }; } module.exports = { + FAILED_LOGIN_LOCK_THRESHOLD, + LOCK_MS, PASSWORD_MIN_LENGTH, + ROLE_PERMISSIONS, USERNAME_PATTERN, createAuthStore, hashPassword, - publicUser, + hasPermission, + permissionsForRole, validatePassword, validateRole, validateUsername, diff --git a/src/index.ts b/src/index.ts index 907d36d..08803ed 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ 'use strict'; import type { CallbackStyleFunction, Persona, UnknownRecord } from './types'; +import type { IncomingMessage } from 'node:http'; import { errorMessage, isRecord } from './types'; const winston = require('winston'); @@ -9,8 +10,9 @@ const { createAuthStore } = require('./auth/store'); const { createSessionManager } = require('./auth/session'); const { loadConfig } = require('./config/load'); const { AUTH_DB_PATH, REFRESH_TOKEN_PATH } = require('./paths'); +const { getClientIp } = require('./server/auth'); const { createChatService } = require('./server/chat-service'); -const { createSteamLoginService } = require('./steam/lifecycle'); +const { createSteamLoginService, readRefreshToken } = require('./steam/lifecycle'); const { steamIdToString } = require('./storage/chat-log'); @@ -69,7 +71,11 @@ const steamUser = new SteamUser({ renewRefreshTokens: true }); const steamCommunity = new SteamCommunity(); const users: Record = {}; const authStore = createAuthStore({ dbPath: AUTH_DB_PATH }); -const sessionManager = createSessionManager({ store: authStore }); +const chatAuthConfig = isRecord(config.chat) && isRecord(config.chat.auth) ? config.chat.auth : {}; +const sessionManager = createSessionManager({ + store: authStore, + getClientIp: (req: IncomingMessage) => getClientIp(req, chatAuthConfig) +}); const logger = winston.createLogger({ level: process.env.LOG_LEVEL || 'info', @@ -135,13 +141,30 @@ const lifecycle = createSteamLoginService({ }, logger, refreshTokenPath: REFRESH_TOKEN_PATH, - getDefaultLogonID: authStore.getOrCreateSteamLogonID + getDefaultLogonID: authStore.getOrCreateSteamLogonID, + onLoggedOn(steamId: string | null) { + if (!steamId) return; + authStore.upsertSteamAccount({ + steamId, + refreshToken: readRefreshToken(REFRESH_TOKEN_PATH) || undefined, + setActive: true + }); + }, + onRefreshToken(refreshToken: string, steamId: string | null) { + if (!steamId) return; + authStore.upsertSteamAccount({ + steamId, + refreshToken, + setActive: true + }); + } }); createSteamMessageLogger({ steamUser, getUserInfo, getSelfName, + getSteamAccountId: () => authStore.getActiveSteamAccount(false)?.steamId || steamIdToString(steamUser.steamID || ''), logger }); diff --git a/src/server/chat-service.ts b/src/server/chat-service.ts index e36e5a7..c9f8199 100644 --- a/src/server/chat-service.ts +++ b/src/server/chat-service.ts @@ -4,7 +4,7 @@ import type { IncomingMessage, Server, ServerResponse } from 'node:http'; import type { Duplex } from 'node:stream'; import type { RawData, WebSocket as WsConnection, WebSocketServer as WsServer } from 'ws'; import type { AppSession } from '../auth/session'; -import type { PublicUser } from '../auth/store'; +import type { PublicAuditLog, PublicSteamAccount, PublicUser, PublicUserSession, UserPermission } from '../auth/store'; import type { AuthConfig, CallbackStyleFunction, @@ -51,7 +51,7 @@ const { stickerUrlForType } = require('../storage/media-cache'); const { WEB_DIR } = require('../paths'); -const { createAuthChecker } = require('./auth'); +const { createAuthChecker, getClientIp } = require('./auth'); const { isLocalOrLanIp, normalizeIp } = require('./network'); type Waiter = Promise | (() => Promise | unknown); @@ -114,16 +114,39 @@ type ChatServiceOptions = { steamLoginService?: SteamLoginServiceLike; }; +type RequestContext = { + ip?: string | null; + userAgent?: string | null; +}; + type AuthStoreLike = { - authenticate: (username: unknown, password: unknown) => PublicUser | null; + authenticate: (username: unknown, password: unknown, context?: RequestContext) => PublicUser | null; + canAccessSteamAccount: (userId: unknown, steamAccountId: unknown) => boolean; changeOwnPassword: (id: unknown, oldPassword: unknown, newPassword: unknown) => PublicUser; createInitialAdmin: (input: UnknownRecord) => PublicUser; createUser: (input: UnknownRecord) => PublicUser; + deleteSteamAccount: (id: unknown) => void; deleteUser: (id: unknown, currentUserId: unknown) => void; - listUsers: () => PublicUser[]; + getActiveSteamAccount: (includeToken?: boolean) => (PublicSteamAccount & { refreshToken?: string | null }) | null; + getSteamAccountById: (id: unknown, includeToken?: boolean) => (PublicSteamAccount & { refreshToken?: string | null }) | null; + hasPermission: (roleOrUser: string | Pick, permission: UserPermission) => boolean; + listAuditLogs: (filters?: UnknownRecord) => PublicAuditLog[]; + listSteamAccountsForUser: (userId: unknown) => PublicSteamAccount[]; + listUserSessions: (userId: unknown) => PublicUserSession[]; + listUserSteamAccounts: (userId: unknown) => PublicSteamAccount[]; + listUsers: (filters?: UnknownRecord) => PublicUser[]; + markSteamAccountActive: (id: unknown) => void; + permissionsForRole: (role: PublicUser['role']) => UserPermission[]; + recordAudit: (input: UnknownRecord) => PublicAuditLog; + replaceUserSteamAccounts: (userId: unknown, steamAccountIds: unknown, grantedBy?: unknown) => PublicSteamAccount[]; requiresSetup: () => boolean; - setPassword: (id: unknown, password: unknown) => PublicUser; + revokeSession: (sessionId: unknown, revokedBy?: unknown) => boolean; + revokeUserSessions: (userId: unknown, revokedBy?: unknown, exceptSessionId?: unknown) => number; + setActiveSteamAccount: (id: unknown) => PublicSteamAccount; + setPassword: (id: unknown, password: unknown, options?: UnknownRecord) => PublicUser; + updateSteamAccount: (id: unknown, patch: UnknownRecord) => PublicSteamAccount; updateUser: (id: unknown, patch: UnknownRecord) => PublicUser; + upsertSteamAccount: (input: UnknownRecord) => PublicSteamAccount; }; type SessionManagerLike = { @@ -132,6 +155,8 @@ type SessionManagerLike = { getSession: (req: IncomingMessage) => AppSession | null; requireAdmin: (req: IncomingMessage) => AppSession; requireSession: (req: IncomingMessage) => AppSession; + revokeCurrentSession: (req: IncomingMessage, revokedBy?: unknown) => boolean; + revokeUserSessions: (userId: unknown, revokedBy?: unknown, exceptSessionId?: unknown) => number; }; type SteamStatusSummary = { @@ -142,11 +167,14 @@ type SteamStatusSummary = { lastCodeWrong?: boolean; error?: string | null; steamId?: string | null; + activeAccount?: Pick | null; + accessAllowed?: boolean; }; type SteamLoginServiceLike = { ensureOnline: () => void; getStatus: () => SteamStatusSummary; + connectWithRefreshToken?: (refreshToken: unknown, steamID?: unknown) => SteamStatusSummary; login: (input: UnknownRecord) => SteamStatusSummary; logout: () => SteamStatusSummary; submitGuard: (code: unknown) => SteamStatusSummary; @@ -162,6 +190,13 @@ type ImageBody = UnknownRecord & { url?: string; }; +type SteamAccessContext = { + session: AppSession | null; + activeAccount: PublicSteamAccount | null; + steamAccountId?: string; + includeLegacy: boolean; +}; + type FriendSummary = { id: string; name: string; @@ -418,6 +453,7 @@ function createChatService(options: ChatServiceOptions = {}) { const steamLoginService = options.steamLoginService; const legacyAuth = createAuthChecker(config.auth); const clients = new Set(); + const wsSessions = new Map(); const recentSentText = new Map(); let disposeSteamEvents = () => {}; @@ -433,9 +469,122 @@ function createChatService(options: ChatServiceOptions = {}) { return map.has(key); } + function requestContext(req: IncomingMessage): RequestContext { + return { + ip: getClientIp(req, config.auth), + userAgent: Array.isArray(req.headers['user-agent']) ? req.headers['user-agent'][0] || null : req.headers['user-agent'] || null + }; + } + + function activeAccountSummary(account: PublicSteamAccount | null) { + return account ? { id: account.id, steamId: account.steamId, label: account.label } : null; + } + + function ensureActiveSteamAccount(status = currentSteamStatus()): PublicSteamAccount | null { + if (!authStore) return null; + const active = authStore.getActiveSteamAccount(false); + const statusSteamId = status.steamId ? String(status.steamId) : ''; + if (statusSteamId && (!active || active.steamId !== statusSteamId)) { + return authStore.upsertSteamAccount({ steamId: statusSteamId, setActive: true }); + } + return active; + } + + function steamStatusForSession(session: AppSession | null): SteamStatusSummary { + const status = currentSteamStatus(); + const active = ensureActiveSteamAccount(status); + const accessAllowed = !session || !active ? false : authStore?.canAccessSteamAccount(session.user.id, active.id) === true; + return { + ...status, + activeAccount: accessAllowed || session?.user.role === 'admin' ? activeAccountSummary(active) : null, + accessAllowed + }; + } + + function requirePermission(req: IncomingMessage, permission: UserPermission): AppSession | null { + if (!sessionManager || !authStore) return requireAdminSession(req); + const session = sessionManager.requireSession(req); + if (!authStore.hasPermission(session.user, permission)) { + throw Object.assign(new Error('Forbidden'), { statusCode: 403 }); + } + return session; + } + + function recordAudit(session: AppSession | null, req: IncomingMessage, action: string, targetType: string, targetId: unknown, detail: UnknownRecord = {}) { + if (!authStore || !session) return; + const context = requestContext(req); + authStore.recordAudit({ + actorUserId: session.user.id, + action, + targetType, + targetId: String(targetId || ''), + detail, + ip: context.ip, + userAgent: context.userAgent + }); + } + + function steamAccountUnavailable(status = currentSteamStatus()) { + return Object.assign(new Error('Steam account is not connected'), { + statusCode: 503, + steamStatus: status.status + }); + } + + function requireSteamAccountAccess(req: IncomingMessage, needsOnline: boolean): SteamAccessContext { + const session = requireLegacyOrSession(req); + if (needsOnline) requireSteamOnline(); + if (!sessionManager || !authStore) { + const active = ensureActiveSteamAccount(); + return { + session, + activeAccount: active, + steamAccountId: active?.steamId, + includeLegacy: true + }; + } + const status = currentSteamStatus(); + const active = ensureActiveSteamAccount(status); + if (!active) throw steamAccountUnavailable(status); + if (!authStore.canAccessSteamAccount(session?.user.id, active.id)) { + throw Object.assign(new Error('Steam account access denied'), { statusCode: 403 }); + } + authStore.markSteamAccountActive(active.id); + return { + session, + activeAccount: active, + steamAccountId: active.steamId, + includeLegacy: session?.user.role === 'admin' + }; + } + + function wsAccessContext(ws: WsConnection, needsOnline: boolean): SteamAccessContext { + const session = wsSessions.get(ws) || null; + if (needsOnline) requireSteamOnline(); + if (!sessionManager || !authStore) { + const active = ensureActiveSteamAccount(); + return { session, activeAccount: active, steamAccountId: active?.steamId, includeLegacy: true }; + } + const status = currentSteamStatus(); + const active = ensureActiveSteamAccount(status); + if (!active) throw steamAccountUnavailable(status); + if (!session || !authStore.canAccessSteamAccount(session.user.id, active.id)) { + throw Object.assign(new Error('Steam account access denied'), { statusCode: 403 }); + } + authStore.markSteamAccountActive(active.id); + return { session, activeAccount: active, steamAccountId: active.steamId, includeLegacy: session.user.role === 'admin' }; + } + + function canWsReceiveActiveAccount(ws: WsConnection): boolean { + if (!sessionManager || !authStore) return true; + const session = wsSessions.get(ws); + const active = ensureActiveSteamAccount(); + return Boolean(session && active && authStore.canAccessSteamAccount(session.user.id, active.id)); + } + function broadcast(payload: unknown, except?: WsConnection) { for (const ws of clients) { - if (ws !== except) sendWs(ws, payload); + if (ws !== except && canWsReceiveActiveAccount(ws)) sendWs(ws, payload); } } @@ -480,7 +629,8 @@ function createChatService(options: ChatServiceOptions = {}) { return { needsSetup: authStore?.requiresSetup?.() ?? false, user: session?.user || null, - steam: currentSteamStatus() + permissions: session && authStore ? authStore.permissionsForRole(session.user.role) : [], + steam: steamStatusForSession(session) }; } @@ -495,7 +645,7 @@ function createChatService(options: ChatServiceOptions = {}) { if (req.method === 'POST' && pathname === '/api/auth/setup') { const body = await readJsonBody(req); const user = authStore.createInitialAdmin(body); - jsonResponse(res, 200, { ok: true, user, steam: currentSteamStatus() }, { + jsonResponse(res, 200, { ok: true, user, permissions: authStore.permissionsForRole(user.role), steam: steamStatusForSession(null) }, { 'Set-Cookie': sessionManager.createSetCookie(user, req) }); return true; @@ -503,15 +653,30 @@ function createChatService(options: ChatServiceOptions = {}) { if (req.method === 'POST' && pathname === '/api/auth/login') { const body = await readJsonBody(req); - const user = authStore.authenticate(body.username, body.password); + const user = authStore.authenticate(body.username, body.password, requestContext(req)); if (!user) throw Object.assign(new Error('Invalid username or password'), { statusCode: 401 }); - jsonResponse(res, 200, { ok: true, user, steam: currentSteamStatus() }, { + jsonResponse(res, 200, { ok: true, user, permissions: authStore.permissionsForRole(user.role), steam: steamStatusForSession(null) }, { 'Set-Cookie': sessionManager.createSetCookie(user, req) }); return true; } if (req.method === 'POST' && pathname === '/api/auth/logout') { + const session = sessionManager.getSession(req); + if (session) { + sessionManager.revokeCurrentSession(req, session.user.id); + recordAudit(session, req, 'session.logout', 'session', session.sessionId); + } + jsonResponse(res, 200, { ok: true }, { + 'Set-Cookie': sessionManager.createClearCookie(req) + }); + return true; + } + + if (req.method === 'POST' && pathname === '/api/auth/logout-all') { + const session = sessionManager.requireSession(req); + sessionManager.revokeUserSessions(session.user.id, session.user.id); + recordAudit(session, req, 'session.logout_all', 'user', session.user.id); jsonResponse(res, 200, { ok: true }, { 'Set-Cookie': sessionManager.createClearCookie(req) }); @@ -522,6 +687,7 @@ function createChatService(options: ChatServiceOptions = {}) { const session = sessionManager.requireSession(req); const body = await readJsonBody(req); const user = authStore.changeOwnPassword(session.user.id, body.oldPassword, body.newPassword); + recordAudit(session, req, 'user.password.change_self', 'user', session.user.id); jsonResponse(res, 200, { ok: true, user }, { 'Set-Cookie': sessionManager.createSetCookie(user, req) }); @@ -531,41 +697,94 @@ function createChatService(options: ChatServiceOptions = {}) { return false; } - async function handleUsersApi(req: IncomingMessage, res: ServerResponse, pathname: string) { + async function handleUsersApi(req: IncomingMessage, res: ServerResponse, url: URL) { if (!authStore || !sessionManager) return false; + const pathname = url.pathname; if (pathname === '/api/users') { - requireAdminSession(req); + const session = requirePermission(req, 'user.manage'); if (req.method === 'GET') { - jsonResponse(res, 200, { users: authStore.listUsers() }); + jsonResponse(res, 200, { + users: authStore.listUsers({ + query: url.searchParams.get('query'), + role: url.searchParams.get('role'), + status: url.searchParams.get('status') + }) + }); return true; } if (req.method === 'POST') { - const user = authStore.createUser(await readJsonBody(req)); + const body = await readJsonBody(req); + const user = authStore.createUser({ ...body, createdBy: session?.user.id }); + recordAudit(session, req, 'user.create', 'user', user.id, { username: user.username, role: user.role, steamAccountIds: body.steamAccountIds }); jsonResponse(res, 201, { ok: true, user }); return true; } return false; } + const sessionsMatch = pathname.match(/^\/api\/users\/(\d+)\/sessions$/); + if (sessionsMatch) { + const session = requirePermission(req, 'session.manage'); + if (req.method === 'GET') { + jsonResponse(res, 200, { sessions: authStore.listUserSessions(sessionsMatch[1]) }); + return true; + } + if (req.method === 'DELETE') { + const revoked = authStore.revokeUserSessions(sessionsMatch[1], session?.user.id); + recordAudit(session, req, 'session.revoke_user', 'user', sessionsMatch[1], { revoked }); + jsonResponse(res, 200, { ok: true, revoked }); + return true; + } + } + + const sessionMatch = pathname.match(/^\/api\/users\/(\d+)\/sessions\/([^/]+)$/); + if (sessionMatch && req.method === 'DELETE') { + const session = requirePermission(req, 'session.manage'); + const revoked = authStore.revokeSession(decodeURIComponent(sessionMatch[2]), session?.user.id); + recordAudit(session, req, 'session.revoke', 'session', sessionMatch[2], { userId: sessionMatch[1], revoked }); + jsonResponse(res, 200, { ok: true, revoked }); + return true; + } + + const steamAccountsMatch = pathname.match(/^\/api\/users\/(\d+)\/steam-accounts$/); + if (steamAccountsMatch) { + const session = requirePermission(req, 'steam.account.manage'); + if (req.method === 'GET') { + jsonResponse(res, 200, { steamAccounts: authStore.listUserSteamAccounts(steamAccountsMatch[1]) }); + return true; + } + if (req.method === 'PUT') { + const body = await readJsonBody(req); + const steamAccounts = authStore.replaceUserSteamAccounts(steamAccountsMatch[1], body.steamAccountIds, session?.user.id); + recordAudit(session, req, 'steam_account.grant_replace', 'user', steamAccountsMatch[1], { steamAccountIds: body.steamAccountIds }); + jsonResponse(res, 200, { ok: true, steamAccounts }); + return true; + } + } + const passwordMatch = pathname.match(/^\/api\/users\/(\d+)\/password$/); if (passwordMatch && req.method === 'POST') { - requireAdminSession(req); + const session = requirePermission(req, 'user.manage'); const body = await readJsonBody(req); - const user = authStore.setPassword(passwordMatch[1], body.password); + const user = authStore.setPassword(passwordMatch[1], body.password, { forcePasswordChange: body.forcePasswordChange }); + recordAudit(session, req, 'user.password.reset', 'user', user.id, { forcePasswordChange: body.forcePasswordChange }); jsonResponse(res, 200, { ok: true, user }); return true; } const userMatch = pathname.match(/^\/api\/users\/(\d+)$/); if (userMatch) { - const session = requireAdminSession(req); + const session = requirePermission(req, 'user.manage'); if (req.method === 'PATCH') { - const user = authStore.updateUser(userMatch[1], await readJsonBody(req)); + const body = await readJsonBody(req); + const user = authStore.updateUser(userMatch[1], body); + recordAudit(session, req, 'user.update', 'user', user.id, body); jsonResponse(res, 200, { ok: true, user }); return true; } if (req.method === 'DELETE') { authStore.deleteUser(userMatch[1], session?.user.id); + recordAudit(session, req, 'user.delete', 'user', userMatch[1]); jsonResponse(res, 200, { ok: true }); return true; } @@ -574,38 +793,126 @@ function createChatService(options: ChatServiceOptions = {}) { return false; } - async function handleSteamApi(req: IncomingMessage, res: ServerResponse, pathname: string) { + async function handleSteamApi(req: IncomingMessage, res: ServerResponse, url: URL) { if (!steamLoginService) return false; + const pathname = url.pathname; if (req.method === 'GET' && pathname === '/api/steam/status') { - requireLegacyOrSession(req); - jsonResponse(res, 200, currentSteamStatus()); + const session = requireLegacyOrSession(req); + jsonResponse(res, 200, steamStatusForSession(session)); return true; } - if (req.method === 'POST' && pathname === '/api/steam/login') { - requireAdminSession(req); - const status = steamLoginService.login(await readJsonBody(req)); - jsonResponse(res, 200, status); + if (req.method === 'GET' && pathname === '/api/steam/accounts') { + const session = requireLegacyOrSession(req); + if (!session || !authStore) throw Object.assign(new Error('Forbidden'), { statusCode: 403 }); + jsonResponse(res, 200, { steamAccounts: authStore.listSteamAccountsForUser(session.user.id) }); + return true; + } + + if (req.method === 'POST' && (pathname === '/api/steam/login' || pathname === '/api/steam/accounts/login')) { + const session = requirePermission(req, 'steam.manage'); + const body = await readJsonBody(req); + const status = steamLoginService.login(body); + if (authStore && status.steamId) { + authStore.upsertSteamAccount({ + steamId: status.steamId, + label: body.label, + accountNameHint: body.accountName, + createdBy: session?.user.id, + setActive: true + }); + } + recordAudit(session, req, 'steam_account.login', 'steam_account', status.steamId || 'pending', { label: body.label, accountName: body.accountName }); + jsonResponse(res, 200, steamStatusForSession(session)); return true; } if (req.method === 'POST' && pathname === '/api/steam/guard') { - requireAdminSession(req); + const session = requirePermission(req, 'steam.manage'); const body = await readJsonBody(req); - jsonResponse(res, 200, steamLoginService.submitGuard(body.code)); + steamLoginService.submitGuard(body.code); + recordAudit(session, req, 'steam_account.guard_submit', 'steam_account', 'pending'); + jsonResponse(res, 200, steamStatusForSession(session)); return true; } if (req.method === 'POST' && pathname === '/api/steam/logout') { - requireAdminSession(req); - jsonResponse(res, 200, steamLoginService.logout()); + const session = requirePermission(req, 'steam.manage'); + const active = authStore?.getActiveSteamAccount(false); + steamLoginService.logout(); + recordAudit(session, req, 'steam_account.logout', 'steam_account', active?.id || 'active'); + jsonResponse(res, 200, steamStatusForSession(session)); + return true; + } + + const accountMatch = pathname.match(/^\/api\/steam\/accounts\/(\d+)$/); + if (accountMatch) { + const session = requirePermission(req, 'steam.account.manage'); + if (req.method === 'PATCH') { + const account = authStore!.updateSteamAccount(accountMatch[1], await readJsonBody(req)); + recordAudit(session, req, 'steam_account.update', 'steam_account', account.id); + jsonResponse(res, 200, { ok: true, steamAccount: account }); + return true; + } + if (req.method === 'DELETE') { + const active = authStore!.getActiveSteamAccount(false); + if (active?.id === Number(accountMatch[1])) steamLoginService.logout(); + authStore!.deleteSteamAccount(accountMatch[1]); + recordAudit(session, req, 'steam_account.delete', 'steam_account', accountMatch[1]); + jsonResponse(res, 200, { ok: true }); + return true; + } + } + + const connectMatch = pathname.match(/^\/api\/steam\/accounts\/(\d+)\/connect$/); + if (connectMatch && req.method === 'POST') { + const session = requirePermission(req, 'steam.manage'); + const account = authStore!.getSteamAccountById(connectMatch[1], true); + if (!account) throw Object.assign(new Error('Steam account not found'), { statusCode: 404 }); + if (!account.enabled) throw Object.assign(new Error('Steam account is disabled'), { statusCode: 409 }); + const status = steamLoginService.connectWithRefreshToken + ? steamLoginService.connectWithRefreshToken(account.refreshToken, account.steamId) + : steamLoginService.login({}); + authStore!.setActiveSteamAccount(account.id); + recordAudit(session, req, 'steam_account.connect', 'steam_account', account.id); + jsonResponse(res, 200, { ...steamStatusForSession(session), ...status }); + return true; + } + + const accountLogoutMatch = pathname.match(/^\/api\/steam\/accounts\/(\d+)\/logout$/); + if (accountLogoutMatch && req.method === 'POST') { + const session = requirePermission(req, 'steam.manage'); + const active = authStore?.getActiveSteamAccount(false); + if (!active || active.id !== Number(accountLogoutMatch[1])) { + throw Object.assign(new Error('Steam account is not active'), { statusCode: 409 }); + } + steamLoginService.logout(); + recordAudit(session, req, 'steam_account.logout', 'steam_account', active.id); + jsonResponse(res, 200, steamStatusForSession(session)); return true; } return false; } + async function handleAuditApi(req: IncomingMessage, res: ServerResponse, url: URL) { + if (!authStore || !sessionManager) return false; + if (req.method !== 'GET' || url.pathname !== '/api/audit-logs') return false; + requirePermission(req, 'audit.view'); + jsonResponse(res, 200, { + auditLogs: authStore.listAuditLogs({ + action: url.searchParams.get('action'), + targetType: url.searchParams.get('targetType'), + actorUserId: url.searchParams.get('actorUserId'), + from: url.searchParams.get('from'), + to: url.searchParams.get('to'), + limit: url.searchParams.get('limit') + }) + }); + return true; + } + async function withSteamRetry(operation: () => Promise | T, needsWebSession = false): Promise { requireSteamOnline(); await resolveWaiter(waitForLogin); @@ -626,7 +933,7 @@ function createChatService(options: ChatServiceOptions = {}) { } } - async function sendTextMessage(id: unknown, msg: unknown): Promise { + async function sendTextMessage(id: unknown, msg: unknown, steamAccountId?: string): Promise { if (!id || !String(msg || '').trim()) { throw Object.assign(new Error('id and msg are required'), { statusCode: 400 }); } @@ -644,6 +951,7 @@ function createChatService(options: ChatServiceOptions = {}) { const record: HistoryRecordInput = { type: 'message', echo: true, + steamAccountId, id, name: await getSelfName(), message @@ -657,7 +965,7 @@ function createChatService(options: ChatServiceOptions = {}) { return item; } - async function sendImageMessage(id: unknown, body: ImageBody): Promise { + async function sendImageMessage(id: unknown, body: ImageBody, steamAccountId?: string): Promise { if (!id) throw Object.assign(new Error('id is required'), { statusCode: 400 }); if (!steamCommunity || typeof steamCommunity.sendImageToUser !== 'function') { throw new Error('Steam image sender is unavailable'); @@ -678,6 +986,7 @@ function createChatService(options: ChatServiceOptions = {}) { const item = normalizeHistoryItem({ type: 'message', echo: true, + steamAccountId, id, name: await getSelfName(), message, @@ -689,8 +998,10 @@ function createChatService(options: ChatServiceOptions = {}) { async function handleSteamIncoming(event: SteamFriendMessageEvent) { const id = event.id; const info = await getUserInfo(event.steamID || id).catch((): Persona => ({ player_name: id })); + const active = ensureActiveSteamAccount(); const item = normalizeHistoryItem({ type: 'message', + steamAccountId: active?.steamId, id, name: info.player_name || info.personaName || id, message: event.message, @@ -703,9 +1014,11 @@ function createChatService(options: ChatServiceOptions = {}) { async function handleSteamEcho(event: SteamFriendMessageEvent) { const id = event.id; if (isRecent(recentSentText, `${id}:${event.message}`) || isRecent(recentSentText, `${id}:${event.compatibilityMessage}`)) return; + const active = ensureActiveSteamAccount(); const item = normalizeHistoryItem({ type: 'message', echo: true, + steamAccountId: active?.steamId, id, name: await getSelfName(), message: event.message, @@ -728,8 +1041,9 @@ function createChatService(options: ChatServiceOptions = {}) { } if (await handleAuthApi(req, res, url.pathname)) return; - if (await handleSteamApi(req, res, url.pathname)) return; - if (await handleUsersApi(req, res, url.pathname)) return; + if (await handleSteamApi(req, res, url)) return; + if (await handleUsersApi(req, res, url)) return; + if (await handleAuditApi(req, res, url)) return; requireLegacyOrSession(req); @@ -739,46 +1053,54 @@ function createChatService(options: ChatServiceOptions = {}) { return; } if (url.pathname === '/api/emoticons') { - requireSteamOnline(); + requireSteamAccountAccess(req, true); const data = await getEmoticons({ steamUser, waitForLogin, waitForWebSession }); jsonResponse(res, 200, data); return; } if (url.pathname === '/api/friends') { - requireSteamOnline(); + requireSteamAccountAccess(req, true); jsonResponse(res, 200, await listFriends(steamUser)); return; } if (url.pathname === '/api/groups') { - requireSteamOnline(); + requireSteamAccountAccess(req, true); jsonResponse(res, 200, await listGroups(steamUser)); return; } if (url.pathname === '/history') { + const access = requireSteamAccountAccess(req, false); jsonResponse(res, 200, await readHistory({ logPath, id: url.searchParams.get('id'), limit: url.searchParams.get('limit'), + steamAccountId: access.steamAccountId, + includeLegacy: access.includeLegacy, logger })); return; } if (url.pathname === '/conversations') { + const access = requireSteamAccountAccess(req, false); jsonResponse(res, 200, await buildConversations({ logPath, limit: url.searchParams.get('limit'), + steamAccountId: access.steamAccountId, + includeLegacy: access.includeLegacy, getUserInfo, logger })); return; } if (url.pathname.startsWith('/proxy/sticker/')) { + requireSteamAccountAccess(req, false); const type = decodeURIComponent(url.pathname.slice('/proxy/sticker/'.length)); const sticker = await loadOrDownloadSticker(type, { fetchImpl }); textResponse(res, 200, sticker.buffer, { 'Content-Type': sticker.contentType, 'Cache-Control': 'public, max-age=86400' }); return; } if (url.pathname === '/proxy/image') { + requireSteamAccountAccess(req, false); const source = url.searchParams.get('url') || ''; const image = await loadOrDownloadRemoteImage(source, { fetchImpl }); textResponse(res, 200, image.buffer, { 'Content-Type': image.contentType, 'Cache-Control': 'public, max-age=86400' }); @@ -791,14 +1113,16 @@ function createChatService(options: ChatServiceOptions = {}) { if (req.method === 'POST' && (url.pathname === '/' || url.pathname === '/message')) { const body = await readJsonBody(req); - const item = await sendTextMessage(body.id, body.msg); + const access = requireSteamAccountAccess(req, true); + const item = await sendTextMessage(body.id, body.msg, access.steamAccountId); jsonResponse(res, 200, { ok: true, item }); return; } if (req.method === 'POST' && (url.pathname === '/image' || url.pathname === '/img')) { const body = await readJsonBody(req); - const item = await sendImageMessage(body.id, body); + const access = requireSteamAccountAccess(req, true); + const item = await sendImageMessage(body.id, body, access.steamAccountId); jsonResponse(res, 200, { ok: true, item }); return; } @@ -828,41 +1152,59 @@ function createChatService(options: ChatServiceOptions = {}) { return; } if (type === 'send_message' || type === 'msg') { - const item = await sendTextMessage(payload.id, payload.msg || payload.message || ''); + const access = wsAccessContext(ws, true); + const item = await sendTextMessage(payload.id, payload.msg || payload.message || '', access.steamAccountId); reply({ type: 'message_sent', item }); return; } if (type === 'send_image' || type === 'img') { - const item = await sendImageMessage(payload.id, payload); + const access = wsAccessContext(ws, true); + const item = await sendImageMessage(payload.id, payload, access.steamAccountId); reply({ type: 'image_sent', item }); return; } if (type === 'get_history' || type === 'history') { + const access = wsAccessContext(ws, false); reply({ type: 'history', - items: await readHistory({ logPath, id: payload.id, limit: payload.limit, logger }) + items: await readHistory({ + logPath, + id: payload.id, + limit: payload.limit, + steamAccountId: access.steamAccountId, + includeLegacy: access.includeLegacy, + logger + }) }); return; } if (type === 'get_conversations' || type === 'conversations') { + const access = wsAccessContext(ws, false); reply({ type: 'conversations', - conversations: await buildConversations({ logPath, limit: payload.limit, getUserInfo, logger }) + conversations: await buildConversations({ + logPath, + limit: payload.limit, + steamAccountId: access.steamAccountId, + includeLegacy: access.includeLegacy, + getUserInfo, + logger + }) }); return; } if (type === 'get_emoticons' || type === 'emoticons') { - requireSteamOnline(); + wsAccessContext(ws, true); reply({ type: 'emoticons', ...(await getEmoticons({ steamUser, waitForLogin, waitForWebSession })) }); return; } if (type === 'get_friends' || type === 'friends') { - requireSteamOnline(); + wsAccessContext(ws, true); reply({ type: 'friends', friends: await listFriends(steamUser) }); return; } if (type === 'get_groups' || type === 'groups') { - requireSteamOnline(); + wsAccessContext(ws, true); reply({ type: 'groups', groups: await listGroups(steamUser) }); return; } @@ -895,7 +1237,9 @@ function createChatService(options: ChatServiceOptions = {}) { legacyAuth.challengeUpgrade(socket); return; } + const session = sessionManager?.getSession(req) || null; wss.handleUpgrade(req, socket, head, (ws: WsConnection) => { + wsSessions.set(ws, session); wss.emit('connection', ws, req); }); }); @@ -908,8 +1252,14 @@ function createChatService(options: ChatServiceOptions = {}) { clients.add(ws); sendWs(ws, { type: 'ready', wsPath: config.wsPath }); ws.on('message', (raw: RawData) => handleWsMessage(ws, raw)); - ws.on('close', () => clients.delete(ws)); - ws.on('error', () => clients.delete(ws)); + ws.on('close', () => { + clients.delete(ws); + wsSessions.delete(ws); + }); + ws.on('error', () => { + clients.delete(ws); + wsSessions.delete(ws); + }); }); if (steamUser) { diff --git a/src/steam/lifecycle.ts b/src/steam/lifecycle.ts index 551bcff..44ed951 100644 --- a/src/steam/lifecycle.ts +++ b/src/steam/lifecycle.ts @@ -104,6 +104,8 @@ type SteamLoginRequest = { type SteamLoginServiceOptions = SteamLifecycleOptions & { getDefaultLogonID?: () => number; + onLoggedOn?: (steamId: string | null) => void; + onRefreshToken?: (refreshToken: string, steamId: string | null) => void; }; function createDeferred(): Deferred { @@ -373,7 +375,9 @@ function createSteamLoginService(options: SteamLoginServiceOptions) { refreshTokenPath = DEFAULT_REFRESH_TOKEN_PATH, fileSystem = fs, timers = { setTimeout, clearTimeout }, - getDefaultLogonID + getDefaultLogonID, + onLoggedOn, + onRefreshToken } = options; if (!steamUser) { @@ -398,6 +402,15 @@ function createSteamLoginService(options: SteamLoginServiceOptions) { method.call(logger, message, meta); } + function callHook(name: 'onLoggedOn' | 'onRefreshToken', callback: (() => void) | undefined) { + if (!callback) return; + try { + callback(); + } catch (error) { + log('warn', `${name} hook failed`, { error: errorMessage(error) }); + } + } + function resetDeferreds() { loginDeferred = createHandledDeferred(); webDeferred = createHandledDeferred(); @@ -509,6 +522,7 @@ function createSteamLoginService(options: SteamLoginServiceOptions) { lastError = null; steamId = steamIdToText(steamUser.steamID); loginDeferred.resolve(true); + callHook('onLoggedOn', () => onLoggedOn?.(steamId)); log('info', 'Steam logged on'); try { steamUser.setPersona?.(steamUser.EPersonaState?.Online || 1); @@ -535,6 +549,7 @@ function createSteamLoginService(options: SteamLoginServiceOptions) { if (!refreshToken) return; fileSystem.mkdirSync(path.dirname(refreshTokenPath), { recursive: true }); fileSystem.writeFileSync(refreshTokenPath, `${refreshToken}\n`, 'utf8'); + callHook('onRefreshToken', () => onRefreshToken?.(refreshToken, steamId)); log('info', 'Steam refresh token saved'); }); @@ -597,6 +612,20 @@ function createSteamLoginService(options: SteamLoginServiceOptions) { beginLogin(optionsForLogin, 'account credentials').catch(() => {}); return getStatus(); }, + connectWithRefreshToken(refreshToken: unknown, steamID?: unknown) { + if (status === 'logging_in' || status === 'waiting_guard' || status === 'reconnecting' || status === 'online') { + throw Object.assign(new Error('Steam login is already active'), { statusCode: 409 }); + } + const token = String(refreshToken || '').trim(); + if (!token) throw Object.assign(new Error('Steam account refresh token is missing'), { statusCode: 409 }); + const logOnOptions: LogOnOptions = { + ...baseLoginOptions(), + refreshToken: token + }; + if (steamID) logOnOptions.steamID = String(steamID); + beginLogin(logOnOptions, 'stored refresh token').catch(() => {}); + return getStatus(); + }, submitGuard(code: unknown) { const value = String(code || '').trim(); if (!value) throw Object.assign(new Error('Steam Guard code is required'), { statusCode: 400 }); diff --git a/src/steam/message-logger.ts b/src/steam/message-logger.ts index 1e5865a..838d0e6 100644 --- a/src/steam/message-logger.ts +++ b/src/steam/message-logger.ts @@ -46,12 +46,13 @@ type SteamMessageLoggerOptions = { steamUser: SteamMessageLoggerUser; getUserInfo?: (steamID: unknown) => Promise; getSelfName?: () => Promise; + getSteamAccountId?: () => string | null | undefined; logPath?: string; logger?: LoggerLike; }; function createSteamMessageLogger(options: SteamMessageLoggerOptions) { - const { steamUser, getSelfName = async () => 'Me', logPath = DEFAULT_LOG_PATH, logger = console } = options; + const { steamUser, getSelfName = async () => 'Me', getSteamAccountId = () => null, logPath = DEFAULT_LOG_PATH, logger = console } = options; const getUserInfo: (steamID: unknown) => Promise = options.getUserInfo || (async () => ({ player_name: 'Unknown' })); if (!steamUser || typeof steamUser.on !== 'function') { throw new Error('steamUser EventEmitter is required'); @@ -71,6 +72,10 @@ function createSteamMessageLogger(options: SteamMessageLoggerOptions) { return true; } + function activeSteamAccountId(): string | undefined { + return getSteamAccountId() || undefined; + } + async function importFriendMessageHistory(id: string): Promise { if (typeof steamUser.chat?.getFriendMessageHistory !== 'function') return false; const response = await new Promise<{ messages?: SteamFriendHistoryMessage[] }>((resolve, reject) => { @@ -86,6 +91,7 @@ function createSteamMessageLogger(options: SteamMessageLoggerOptions) { const echo = Boolean(senderId && senderId !== id); await appendLog({ echo, + steamAccountId: activeSteamAccountId(), id, name: echo ? selfName : (friendInfo.player_name || friendInfo.personaName || id), message: typeof message.message === 'string' ? message.message : '', @@ -106,6 +112,7 @@ function createSteamMessageLogger(options: SteamMessageLoggerOptions) { const echo = Boolean(senderId && senderId !== id); await appendLog({ echo, + steamAccountId: activeSteamAccountId(), id, name: echo ? await getSelfName() : (message.accountid ? String(message.accountid) : 'Unknown'), message: message.message || '', @@ -137,6 +144,7 @@ function createSteamMessageLogger(options: SteamMessageLoggerOptions) { await maybeImportSteamHistory(id); const info = await getUserInfo(event.steamID || id); await appendLog({ + steamAccountId: activeSteamAccountId(), id, name: info.player_name || info.personaName || id, message: event.message, @@ -155,6 +163,7 @@ function createSteamMessageLogger(options: SteamMessageLoggerOptions) { try { await appendLog({ echo: true, + steamAccountId: activeSteamAccountId(), id, name: await getSelfName(), message: event.message, diff --git a/src/storage/chat-log.ts b/src/storage/chat-log.ts index 2f7b60c..73be4c7 100644 --- a/src/storage/chat-log.ts +++ b/src/storage/chat-log.ts @@ -64,6 +64,11 @@ function normalizeHistoryItem(record: HistoryRecordInput): HistoryItem { const legacyImageUrl = typeof record.imageUrl === 'string' ? record.imageUrl : ''; const type = typeof record.type === 'string' ? record.type : (legacyImageUrl ? 'image' : 'message'); const message = typeof record.message === 'string' ? record.message : ''; + const steamAccountId = typeof record.steamAccountId === 'string' + ? record.steamAccountId + : typeof record.steam_account_id === 'string' + ? record.steam_account_id + : ''; const item: HistoryItem = { type, date: typeof record.date === 'string' ? record.date : formatDate(record.sentAt ? new Date(record.sentAt) : new Date()), @@ -73,6 +78,7 @@ function normalizeHistoryItem(record: HistoryRecordInput): HistoryItem { message: type === 'image' && !message && legacyImageUrl ? legacyImageUrl : message, ordinal: typeof record.ordinal === 'string' || typeof record.ordinal === 'number' ? record.ordinal : null }; + if (steamAccountId) item.steamAccountId = steamAccountId; if (typeof record.sentAt === 'string') item.sentAt = record.sentAt; return item; } @@ -83,8 +89,8 @@ function numericOrdinal(value: unknown): number | null { return Number.isFinite(parsed) ? parsed : null; } -function ordinalGroupKey(item: Pick): string { - return `${item.id}\0${parseMessageDate(item)}`; +function ordinalGroupKey(item: Pick): string { + return `${item.steamAccountId || ''}\0${item.id}\0${parseMessageDate(item)}`; } function usedOrdinalsByGroup(items: HistoryItem[]): Map> { @@ -136,6 +142,7 @@ function serializeHistoryLogRecord(source: HistoryRecordInput, item: HistoryItem if (source.type === 'message') record.type = source.type; record.date = item.date; record.echo = item.echo; + if (item.steamAccountId) record.steamAccountId = item.steamAccountId; record.id = item.id; record.name = item.name; record.message = item.message; @@ -195,11 +202,22 @@ function sortHistoryItems(items: HistoryItem[]) { }); } -async function readHistory(options: { logPath?: string; limit?: unknown; id?: unknown; logger?: LoggerLike } = {}): Promise { +async function readHistory(options: { + logPath?: string; + limit?: unknown; + id?: unknown; + steamAccountId?: unknown; + includeLegacy?: boolean; + logger?: LoggerLike; +} = {}): Promise { const logPath = options.logPath || DEFAULT_LOG_PATH; const limit = limitFrom(options.limit); const id = options.id ? steamIdToString(options.id) : ''; + const steamAccountId = typeof options.steamAccountId === 'string' ? options.steamAccountId : ''; let records = await readAllLogLines(logPath, options.logger); + if (steamAccountId) { + records = records.filter((item) => item.steamAccountId === steamAccountId || (options.includeLegacy && !item.steamAccountId)); + } if (id) { records = records.filter((item) => item.id === id); } @@ -250,13 +268,21 @@ function previewForMessage(item: Pick): string async function buildConversations(options: { logPath?: string; limit?: unknown; + steamAccountId?: unknown; + includeLegacy?: boolean; getUserInfo?: (id: string) => Promise<{ player_name?: string; personaName?: string }>; logger?: LoggerLike; } = {}): Promise { const logPath = options.logPath || DEFAULT_LOG_PATH; const limit = limitFrom(options.limit, 100); const getUserInfo = options.getUserInfo; - const records = await readHistory({ logPath, limit, logger: options.logger }); + const records = await readHistory({ + logPath, + limit, + steamAccountId: options.steamAccountId, + includeLegacy: options.includeLegacy, + logger: options.logger + }); const conversations = new Map(); for (const item of records) { diff --git a/src/types.ts b/src/types.ts index 2329094..a6e82d6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -44,6 +44,7 @@ export type HistoryRecordInput = UnknownRecord & { type?: string; date?: string; echo?: boolean; + steamAccountId?: string; id?: string | number | SteamIdLike; steamID?: string | number | SteamIdLike; name?: string; @@ -56,6 +57,7 @@ export type HistoryItem = { type: string; date: string; echo: boolean; + steamAccountId?: string; id: string; name: string; message: string; diff --git a/test/backend-auth.test.ts b/test/backend-auth.test.ts index f981f21..9f555dd 100644 --- a/test/backend-auth.test.ts +++ b/test/backend-auth.test.ts @@ -81,3 +81,81 @@ test('user management protects the current user and the last enabled admin', (t: store.deleteUser(admin.id, admin2.id); assert.equal(store.getUserById(admin.id), null); }); + +test('enhanced users support profile fields, permissions, login locking, sessions, and audit redaction', (t: TestContext) => { + const store = createAuthStore({ dbPath: tempDbPath() }); + t.after(() => store.close()); + const admin = store.createInitialAdmin({ username: 'admin', password: 'password123', displayName: 'Root' }); + const user = store.createUser({ + username: 'worker', + password: 'password123', + role: 'user', + displayName: 'Worker', + note: 'night shift', + createdBy: admin.id + }); + + assert.equal(store.hasPermission(admin, 'user.manage'), true); + assert.equal(store.hasPermission(user, 'user.manage'), false); + assert.equal(store.listUsers({ query: 'night' })[0].username, 'worker'); + assert.equal(store.listUsers({ role: 'user', status: 'enabled' }).length, 1); + + for (let attempt = 0; attempt < 5; attempt += 1) { + assert.equal(store.authenticate('worker', 'wrong'), null); + } + assert.equal(store.listUsers({ status: 'locked' })[0].username, 'worker'); + assert.throws(() => store.authenticate('worker', 'password123'), /temporarily locked/); + + store.setPassword(user.id, 'password456', { forcePasswordChange: true }); + const updated = store.getUserById(user.id); + assert.equal(updated.forcePasswordChange, true); + + const sessions = createSessionManager({ store }); + const cookie = cookiePair(sessions.createSetCookie(admin)); + const session = sessions.getSession(requestWithCookie(cookie)); + assert.ok(session?.sessionId); + assert.equal(store.listUserSessions(admin.id).length, 1); + assert.equal(store.revokeSession(session?.sessionId, admin.id), true); + assert.equal(sessions.getSession(requestWithCookie(cookie)), null); + + store.recordAudit({ + actorUserId: admin.id, + action: 'user.password.reset', + targetType: 'user', + targetId: user.id, + detail: { password: 'secret', nested: { refreshToken: 'token' } }, + ip: '203.0.113.1' + }); + const audit = store.listAuditLogs({ action: 'user.password.reset' })[0]; + assert.equal(audit.detail.password, '[redacted]'); + assert.deepEqual(audit.detail.nested, { refreshToken: '[redacted]' }); +}); + +test('steam accounts keep refresh tokens private and enforce explicit user grants', (t: TestContext) => { + const store = createAuthStore({ dbPath: tempDbPath() }); + t.after(() => store.close()); + const admin = store.createInitialAdmin({ username: 'admin', password: 'password123' }); + const user = store.createUser({ username: 'worker', password: 'password123', role: 'user' }); + const account = store.upsertSteamAccount({ + steamId: '76561198000000000', + label: 'Support', + accountNameHint: 'support***', + refreshToken: 'refresh-secret', + createdBy: admin.id, + setActive: true + }); + + assert.equal(store.getActiveSteamAccount().id, account.id); + assert.equal(store.getSteamAccountById(account.id).refreshToken, undefined); + assert.equal(store.getSteamAccountById(account.id, true).refreshToken, 'refresh-secret'); + assert.equal(store.canAccessSteamAccount(admin.id, account.id), true); + assert.equal(store.canAccessSteamAccount(user.id, account.id), false); + + store.replaceUserSteamAccounts(user.id, [account.id], admin.id); + assert.equal(store.canAccessSteamAccount(user.id, account.id), true); + assert.equal(store.listSteamAccountsForUser(user.id).length, 1); + + store.updateSteamAccount(account.id, { enabled: false }); + assert.equal(store.canAccessSteamAccount(user.id, account.id), false); + assert.equal(store.listSteamAccountsForUser(user.id).length, 0); +}); diff --git a/test/backend-service.test.ts b/test/backend-service.test.ts index fee2860..29e4d48 100644 --- a/test/backend-service.test.ts +++ b/test/backend-service.test.ts @@ -17,6 +17,7 @@ const WebSocket = require('ws'); const { createAuthStore } = require('../src/auth/store'); const { createSessionManager } = require('../src/auth/session'); const { createChatService } = require('../src/server/chat-service'); +const { appendLog } = require('../src/storage/chat-log'); type TestSteamUser = EventEmitterType & { chat: { @@ -183,3 +184,71 @@ test('backend WebSocket rejects missing cookies and accepts authenticated users' t.after(() => ws.close()); assert.equal(ws.readyState, WebSocket.OPEN); }); + +test('backend blocks ordinary users from ungranted active Steam account history', async (t: TestContext) => { + const paths = tempPath('backend-steam-grants'); + const store = createAuthStore({ dbPath: paths.dbPath }); + t.after(() => store.close()); + const sessions = createSessionManager({ store }); + const admin = store.createInitialAdmin({ username: 'admin', password: 'password123' }); + const user = store.createUser({ username: 'worker', password: 'password123', role: 'user' }); + const account = store.upsertSteamAccount({ steamId: '76561198000000000', label: 'Support', setActive: true }); + await appendLog({ + steamAccountId: account.steamId, + id: '42', + name: 'Alice', + message: 'authorized row', + ordinal: 1, + date: '2026-06-23 10:00:00.000' + }, { logPath: paths.logPath }); + await appendLog({ + id: '99', + name: 'Legacy', + message: 'legacy row', + ordinal: 1, + date: '2026-06-23 10:01:00.000' + }, { logPath: paths.logPath }); + + const adminCookie = cookiePair(sessions.createSetCookie(admin)); + const userCookie = cookiePair(sessions.createSetCookie(user)); + const steamUser = new EventEmitter() as TestSteamUser; + steamUser.chat = { + sendFriendMessage(_id: unknown, _msg: unknown, callback: (error: Error | null) => void) { + callback(null); + } + }; + const service = createChatService({ + config: { host: '127.0.0.1', port: 0, wsPath: '/ws' }, + steamUser, + logPath: paths.logPath, + authStore: store, + sessionManager: sessions, + steamLoginService: offlineSteamService(), + logger: { info() {}, warn() {}, error() {} } + }) as ChatServiceRuntime; + t.after(() => service.stop().catch(() => {})); + const port = await listen(service.server); + + const deniedStatus = await fetch(`http://127.0.0.1:${port}/api/steam/status`, { + headers: { Cookie: userCookie } + }); + assert.equal(deniedStatus.status, 200); + assert.equal((await deniedStatus.json()).accessAllowed, false); + + const deniedHistory = await fetch(`http://127.0.0.1:${port}/history?id=42`, { + headers: { Cookie: userCookie } + }); + assert.equal(deniedHistory.status, 403); + + store.replaceUserSteamAccounts(user.id, [account.id], admin.id); + const allowedHistory = await fetch(`http://127.0.0.1:${port}/history?id=42`, { + headers: { Cookie: userCookie } + }); + assert.equal(allowedHistory.status, 200); + assert.deepEqual((await allowedHistory.json()).map((item: { message: string }) => item.message), ['authorized row']); + + const adminHistory = await fetch(`http://127.0.0.1:${port}/history`, { + headers: { Cookie: adminCookie } + }); + assert.deepEqual((await adminHistory.json()).map((item: { message: string }) => item.message), ['authorized row', 'legacy row']); +}); diff --git a/test/logger.test.ts b/test/logger.test.ts index 1bcb737..9ae16fe 100644 --- a/test/logger.test.ts +++ b/test/logger.test.ts @@ -126,3 +126,41 @@ test('appendLog and buildConversations generate previews and newest-first summar assert.equal(conversations[0].preview, '[图片]'); assert.equal(conversations[1].preview, '[贴纸] happy'); }); + +test('readHistory and buildConversations filter by steam account and keep legacy rows admin-only', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'steam-chat-log-')); + const logPath = path.join(dir, 'chat.jsonl'); + await appendLog({ + steamAccountId: '76561198000000000', + id: '1', + name: 'Alice', + message: 'account one', + ordinal: 1, + date: '2026-06-23 10:00:00.000' + }, { logPath }); + await appendLog({ + steamAccountId: '76561198000000001', + id: '1', + name: 'Alice', + message: 'account two', + ordinal: 1, + date: '2026-06-23 10:01:00.000' + }, { logPath }); + await appendLog({ + id: '2', + name: 'Legacy', + message: 'legacy', + ordinal: 1, + date: '2026-06-23 10:02:00.000' + }, { logPath }); + + const accountHistory = await readHistory({ logPath, steamAccountId: '76561198000000000', limit: 10 }); + assert.deepEqual(accountHistory.map((item: { message: string }) => item.message), ['account one']); + + const adminHistory = await readHistory({ logPath, steamAccountId: '76561198000000000', includeLegacy: true, limit: 10 }); + assert.deepEqual(adminHistory.map((item: { message: string }) => item.message), ['account one', 'legacy']); + + const conversations = await buildConversations({ logPath, steamAccountId: '76561198000000001' }); + assert.equal(conversations.length, 1); + assert.equal(conversations[0].preview, 'account two'); +}); diff --git a/web/app.ts b/web/app.ts index 25bd3a3..885d989 100644 --- a/web/app.ts +++ b/web/app.ts @@ -1,16 +1,71 @@ type Role = 'admin' | 'user'; -type View = 'steam' | 'chat' | 'users' | 'account'; +type View = 'steam' | 'chat' | 'users' | 'steamAccounts' | 'audit' | 'account'; type Tone = 'muted' | 'ok' | 'warn' | 'error'; +type Permission = + | 'user.manage' + | 'session.manage' + | 'audit.view' + | 'steam.manage' + | 'steam.account.manage' + | 'chat.use' + | 'self.password.change'; type User = { id: number; username: string; + displayName: string; + note: string; role: Role; disabled: boolean; + forcePasswordChange: boolean; sessionVersion: number; createdAt: string; updatedAt: string; lastLoginAt: string | null; + lastLoginIp: string | null; + lastSeenAt: string | null; + passwordChangedAt: string | null; + failedLoginCount: number; + lockedUntil: string | null; + locked: boolean; + steamAccountCount: number; +}; + +type UserSession = { + id: string; + userId: number; + createdAt: string; + lastSeenAt: string; + expiresAt: string; + ip: string | null; + userAgent: string | null; +}; + +type SteamAccount = { + id: number; + steamId: string; + label: string; + accountNameHint: string; + enabled: boolean; + createdAt: string; + updatedAt: string; + lastLoginAt: string | null; + lastActiveAt: string | null; + refreshTokenUpdatedAt: string | null; + authorizedUserCount: number; + active: boolean; +}; + +type AuditLog = { + id: number; + actorUserId: number | null; + actorUsername: string | null; + action: string; + targetType: string; + targetId: string; + detail: Record; + ip: string | null; + createdAt: string; }; type SteamStatus = { @@ -21,11 +76,14 @@ type SteamStatus = { lastCodeWrong: boolean; error: string | null; steamId: string | null; + activeAccount?: Pick | null; + accessAllowed?: boolean; }; type MeResponse = { needsSetup: boolean; user: User | null; + permissions: Permission[]; steam: SteamStatus; }; @@ -70,9 +128,14 @@ type WsPayload = Record & { type AppState = { me: User | null; needsSetup: boolean; + permissions: Permission[]; steam: SteamStatus; view: View; users: User[]; + userSessions: Record; + userSteamAccounts: Record; + steamAccounts: SteamAccount[]; + auditLogs: AuditLog[]; conversations: ListEntry[]; friends: ListEntry[]; groups: ListEntry[]; @@ -85,6 +148,11 @@ type AppState = { ws: WebSocket | null; reconnectTimer: ReturnType | null; statusTimer: ReturnType | null; + userQuery: string; + userRole: string; + userStatus: string; + auditAction: string; + auditTargetType: string; feedback: string; feedbackTone: Tone; }; @@ -96,7 +164,9 @@ const defaultSteamStatus: SteamStatus = { domain: null, lastCodeWrong: false, error: null, - steamId: null + steamId: null, + activeAccount: null, + accessAllowed: false }; const root = document.querySelector('#app'); @@ -105,9 +175,14 @@ if (!root) throw new Error('Missing app root'); const state: AppState = { me: null, needsSetup: false, + permissions: [], steam: defaultSteamStatus, view: normalizeView(localStorage.getItem('steam-chat.view')), users: [], + userSessions: {}, + userSteamAccounts: {}, + steamAccounts: [], + auditLogs: [], conversations: [], friends: [], groups: [], @@ -120,6 +195,11 @@ const state: AppState = { ws: null, reconnectTimer: null, statusTimer: null, + userQuery: '', + userRole: '', + userStatus: '', + auditAction: '', + auditTargetType: '', feedback: '就绪', feedbackTone: 'muted' }; @@ -151,7 +231,7 @@ function clampLimit(value: unknown): number { function normalizeView(value: unknown): View { const view = String(value || ''); - return view === 'steam' || view === 'chat' || view === 'users' || view === 'account' ? view : 'steam'; + return view === 'steam' || view === 'chat' || view === 'users' || view === 'steamAccounts' || view === 'audit' || view === 'account' ? view : 'steam'; } function asListEntries(value: unknown): ListEntry[] { @@ -192,6 +272,35 @@ function steamOnline() { return state.steam.status === 'online'; } +function steamAccessAllowed() { + return state.steam.accessAllowed !== false; +} + +function hasPermission(permission: Permission) { + return state.permissions.includes(permission); +} + +function displayName(user: User) { + return user.displayName || user.username; +} + +function dateTime(value: unknown): string { + if (!value) return '无'; + const date = new Date(String(value)); + if (Number.isNaN(date.getTime())) return String(value); + return date.toLocaleString('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); +} + +function compactJson(value: unknown): string { + if (!isRecord(value) || !Object.keys(value).length) return '{}'; + return JSON.stringify(value); +} + function steamStatusSignature(status = state.steam) { return [ status.status, @@ -200,7 +309,9 @@ function steamStatusSignature(status = state.steam) { status.domain || '', status.lastCodeWrong, status.error || '', - status.steamId || '' + status.steamId || '', + status.activeAccount?.id || '', + status.accessAllowed ].join('|'); } @@ -232,6 +343,7 @@ async function api(path: string, options: RequestInit = {}): Promise { if (response.status === 401 && !path.startsWith('/api/auth/me') && !path.startsWith('/api/auth/login')) { stopWebSocket(); state.me = null; + state.permissions = []; state.needsSetup = false; renderLogin(); } @@ -322,12 +434,18 @@ function navButton(view: View, label: string) { return button; } +function panel(title: string, className = '') { + const section = create('section', `panel${className ? ` ${className}` : ''}`); + section.append(create('h2', 'panel-title', title)); + return section; +} + function renderShell() { if (!state.me) { renderLogin(); return; } - if (state.me.role !== 'admin' && state.view === 'users') state.view = 'steam'; + if (state.me.role !== 'admin' && ['users', 'steamAccounts', 'audit'].includes(state.view)) state.view = 'steam'; clear(root); const shell = create('div', 'admin-shell'); const sidebar = create('aside', 'admin-sidebar'); @@ -335,7 +453,9 @@ function renderShell() { brand.append(create('strong', '', 'Steam Chat'), create('span', '', state.me.username)); const nav = create('nav'); nav.append(navButton('steam', 'Steam 连接'), navButton('chat', '聊天')); - if (state.me.role === 'admin') nav.append(navButton('users', '用户管理')); + if (hasPermission('user.manage')) nav.append(navButton('users', '用户管理')); + if (hasPermission('steam.account.manage')) nav.append(navButton('steamAccounts', 'Steam 账户')); + if (hasPermission('audit.view')) nav.append(navButton('audit', '审计日志')); nav.append(navButton('account', '账号')); sidebar.append(brand, nav); @@ -366,7 +486,12 @@ function renderShell() { shell.append(sidebar, main); root.append(shell); if (state.view === 'chat') void refreshChatData(); - if (state.view === 'users' && state.me.role === 'admin') void loadUsers(); + if (state.view === 'users' && hasPermission('user.manage')) { + void loadSteamAccounts(); + void loadUsers(); + } + if (state.view === 'steamAccounts') void loadSteamAccounts(); + if (state.view === 'audit') void loadAuditLogs(); ensureWebSocket(); startStatusPolling(); } @@ -374,20 +499,29 @@ function renderShell() { function pageTitle() { if (state.view === 'chat') return '聊天'; if (state.view === 'users') return '用户管理'; + if (state.view === 'steamAccounts') return 'Steam 账户'; + if (state.view === 'audit') return '审计日志'; if (state.view === 'account') return '账号'; return 'Steam 连接'; } function pageSubtitle() { - if (state.view === 'chat') return steamOnline() ? '好友、群组、历史和实时消息' : 'Steam 未在线,聊天操作已禁用'; - if (state.view === 'users') return '后台用户、角色和启用状态'; + if (state.view === 'chat') { + if (!steamAccessAllowed()) return '当前后台用户未被授权访问活动 Steam 账户'; + return steamOnline() ? '好友、群组、历史和实时消息' : 'Steam 未在线,聊天操作已禁用'; + } + if (state.view === 'users') return '后台用户、会话、角色、资料和 Steam 授权'; + if (state.view === 'steamAccounts') return 'Steam 账户资料、连接状态和授权计数'; + if (state.view === 'audit') return '关键管理动作和敏感字段脱敏记录'; if (state.view === 'account') return '修改当前后台账号密码'; return state.me?.role === 'admin' ? '管理员在这里完成 Steam 登录和 Guard 验证' : '等待管理员连接 Steam'; } function renderCurrentView() { if (state.view === 'chat') return renderChatView(); - if (state.view === 'users' && state.me?.role === 'admin') return renderUsersView(); + if (state.view === 'users' && hasPermission('user.manage')) return renderUsersView(); + if (state.view === 'steamAccounts' && hasPermission('steam.account.manage')) return renderSteamAccountsView(); + if (state.view === 'audit' && hasPermission('audit.view')) return renderAuditView(); if (state.view === 'account') return renderAccountView(); return renderSteamView(); } @@ -396,16 +530,22 @@ function renderSteamView() { const view = create('div', 'steam-view'); const summary = create('section', 'status-panel'); const statusText = create('strong', '', steamLabel()); - const detail = create('p', 'muted', state.steam.error || (state.steam.steamId ? `当前 SteamID:${state.steam.steamId}` : '当前没有可用的 Steam 会话')); + const activeLabel = state.steam.activeAccount + ? `${state.steam.activeAccount.label || state.steam.activeAccount.steamId} · ${state.steam.activeAccount.steamId}` + : state.steam.steamId ? `当前 SteamID:${state.steam.steamId}` : '当前没有可用的 Steam 会话'; + const detail = create('p', 'muted', state.steam.error || activeLabel); summary.append(statusText, detail); view.append(summary); if (state.me?.role !== 'admin') { - view.append(create('p', 'muted', '普通用户只能在 Steam 在线后使用聊天。')); + const access = panel('访问状态', 'status-note-panel'); + access.append(create('p', state.steam.accessAllowed === false ? 'warn-text' : 'muted', state.steam.accessAllowed === false ? '当前账号未被授权访问活动 Steam 账户。' : '普通用户只能在 Steam 在线且被授权后使用聊天。')); + view.append(access); return view; } if (state.steam.requiresGuard) { + const guardPanel = panel('Steam Guard', 'form-panel'); const guardForm = create('form', 'inline-form') as HTMLFormElement; const guardText = state.steam.guardType === 'email' ? `邮箱验证码${state.steam.domain ? `:${state.steam.domain}` : ''}` @@ -430,10 +570,13 @@ function renderSteamView() { setFeedback(errorMessage(error), 'error'); } }); - view.append(guardForm); + guardPanel.append(guardForm); + view.append(guardPanel); } else if (!['logging_in', 'online', 'reconnecting'].includes(state.steam.status)) { + const loginPanel = panel('登录 Steam', 'form-panel'); const loginForm = create('form', 'stack-form narrow-form') as HTMLFormElement; loginForm.innerHTML = ` + @@ -446,6 +589,7 @@ function renderSteamView() { const result = await api('/api/steam/login', jsonBody({ accountName: formValue(loginForm, 'accountName'), password: formValue(loginForm, 'password'), + label: formValue(loginForm, 'label'), ...(rawLogonID ? { logonID: Number(rawLogonID) } : {}) })); updateSteamStatus(result as SteamStatus); @@ -454,9 +598,11 @@ function renderSteamView() { setFeedback(errorMessage(error), 'error'); } }); - view.append(loginForm); + loginPanel.append(loginForm); + view.append(loginPanel); } + const actions = create('div', 'page-actions'); const logout = create('button', 'danger-btn', '退出 Steam 并删除 token'); logout.type = 'button'; logout.disabled = state.steam.status === 'logged_out'; @@ -469,17 +615,39 @@ function renderSteamView() { setFeedback(errorMessage(error), 'error'); } }); - view.append(logout); + actions.append(logout); + view.append(actions); return view; } function renderUsersView() { const view = create('div', 'users-view'); + const filterPanel = panel('筛选', 'toolbar-panel'); + const filters = create('form', 'inline-form user-filters') as HTMLFormElement; + filters.innerHTML = ` + + + + + `; + (filters.elements.namedItem('role') as HTMLSelectElement).value = state.userRole; + (filters.elements.namedItem('status') as HTMLSelectElement).value = state.userStatus; + filters.addEventListener('submit', async (event) => { + event.preventDefault(); + state.userQuery = formValue(filters, 'query'); + state.userRole = formValue(filters, 'role'); + state.userStatus = formValue(filters, 'status'); + await loadUsers(); + }); + filterPanel.append(filters); + const createPanel = panel('新增用户', 'toolbar-panel'); const form = create('form', 'inline-form user-create') as HTMLFormElement; form.innerHTML = ` + + `; form.addEventListener('submit', async (event) => { @@ -487,8 +655,10 @@ function renderUsersView() { try { await api('/api/users', jsonBody({ username: formValue(form, 'username'), + displayName: formValue(form, 'displayName'), password: formValue(form, 'password'), - role: formValue(form, 'role') || 'user' + role: formValue(form, 'role') || 'user', + note: formValue(form, 'note') })); form.reset(); await loadUsers(); @@ -496,9 +666,12 @@ function renderUsersView() { setFeedback(errorMessage(error), 'error'); } }); + createPanel.append(form); + const listPanel = panel('用户列表', 'list-panel'); const table = create('div', 'user-table'); table.id = 'userTable'; - view.append(form, table); + listPanel.append(table); + view.append(filterPanel, createPanel, listPanel); renderUserTable(table); return view; } @@ -512,8 +685,17 @@ function renderUserTable(container: HTMLElement) { for (const user of state.users) { const row = create('article', 'user-row'); const info = create('div'); - info.append(create('strong', '', user.username), create('span', 'muted', `${user.role === 'admin' ? '管理员' : '普通用户'} · ${user.disabled ? '已禁用' : '启用'}`)); + const status = user.locked ? '锁定' : user.disabled ? '已禁用' : '启用'; + info.append( + create('strong', '', `${displayName(user)} (${user.username})`), + create('span', 'muted', `${user.role === 'admin' ? '管理员' : '普通用户'} · ${status} · 授权 ${user.steamAccountCount || 0} 个 Steam 账户`), + create('span', 'muted', `最近登录 ${dateTime(user.lastLoginAt)} · IP ${user.lastLoginIp || '无'} · 活跃 ${dateTime(user.lastSeenAt)}`), + create('span', 'muted', user.note ? `备注:${user.note}` : '无备注') + ); const actions = create('div', 'row-actions'); + const profile = create('button', 'ghost-btn', '编辑资料'); + profile.type = 'button'; + profile.addEventListener('click', () => editUserProfile(user)); const role = create('button', 'ghost-btn', user.role === 'admin' ? '降为用户' : '设为管理员'); role.type = 'button'; role.disabled = user.id === state.me?.id; @@ -525,20 +707,85 @@ function renderUserTable(container: HTMLElement) { const password = create('button', 'ghost-btn', '重置密码'); password.type = 'button'; password.addEventListener('click', () => resetUserPassword(user.id)); + const forcePassword = create('button', 'ghost-btn', user.forcePasswordChange ? '取消强制改密' : '要求改密'); + forcePassword.type = 'button'; + forcePassword.addEventListener('click', () => patchUser(user.id, { forcePasswordChange: !user.forcePasswordChange })); + const sessions = create('button', 'ghost-btn', '会话'); + sessions.type = 'button'; + sessions.addEventListener('click', () => loadUserSessions(user.id)); + const grants = create('button', 'ghost-btn', '授权'); + grants.type = 'button'; + grants.addEventListener('click', () => loadUserSteamAccounts(user.id)); const remove = create('button', 'danger-btn', '删除'); remove.type = 'button'; remove.disabled = user.id === state.me?.id; remove.addEventListener('click', () => deleteUser(user.id)); - actions.append(role, disabled, password, remove); + actions.append(profile, role, disabled, password, forcePassword, sessions, grants, remove); row.append(info, actions); + const detail = renderUserDetail(user); + if (detail) row.append(detail); container.append(row); } } +function renderUserDetail(user: User): HTMLElement | null { + const sessions = state.userSessions[user.id]; + const grants = state.userSteamAccounts[user.id]; + if (!sessions && !grants) return null; + const detail = create('div', 'user-detail'); + if (sessions) { + const block = create('section', 'detail-block'); + const revokeAll = create('button', 'danger-btn', '踢下线全部会话'); + revokeAll.type = 'button'; + revokeAll.addEventListener('click', () => revokeUserSessions(user.id)); + block.append(create('h3', '', '会话'), revokeAll); + if (!sessions.length) block.append(create('div', 'empty small', '暂无活动会话')); + for (const session of sessions) { + const row = create('div', 'session-row'); + row.append(create('span', '', `${dateTime(session.lastSeenAt)} · ${session.ip || '未知 IP'}`), create('span', 'muted', session.userAgent || '无 User-Agent')); + const revoke = create('button', 'danger-btn', '踢下线'); + revoke.type = 'button'; + revoke.addEventListener('click', () => revokeSession(user.id, session.id)); + row.append(revoke); + block.append(row); + } + detail.append(block); + } + if (grants) { + const form = create('form', 'grant-grid') as HTMLFormElement; + form.append(create('h3', '', 'Steam 授权')); + if (!state.steamAccounts.length) form.append(create('div', 'empty small', '暂无 Steam 账户')); + for (const account of state.steamAccounts) { + const label = create('label', 'check-row'); + const input = create('input') as HTMLInputElement; + input.type = 'checkbox'; + input.name = 'steamAccountIds'; + input.value = String(account.id); + input.checked = grants.some((item) => item.id === account.id); + label.append(input, create('span', '', `${account.label || account.steamId} · ${account.enabled ? '启用' : '禁用'}`)); + form.append(label); + } + const save = create('button', 'primary-btn', '保存授权'); + save.type = 'submit'; + form.append(save); + form.addEventListener('submit', async (event) => { + event.preventDefault(); + const ids = [...form.querySelectorAll('input[name="steamAccountIds"]:checked')].map((input) => Number(input.value)); + await saveUserSteamAccounts(user.id, ids); + }); + detail.append(form); + } + return detail; +} + async function loadUsers() { - if (state.me?.role !== 'admin') return; + if (!hasPermission('user.manage')) return; try { - const payload = await api('/api/users'); + const params = new URLSearchParams(); + if (state.userQuery) params.set('query', state.userQuery); + if (state.userRole) params.set('role', state.userRole); + if (state.userStatus) params.set('status', state.userStatus); + const payload = await api(`/api/users${params.size ? `?${params}` : ''}`); state.users = isRecord(payload) && Array.isArray(payload.users) ? payload.users as User[] : []; const table = document.querySelector('#userTable'); if (table) renderUserTable(table); @@ -547,6 +794,14 @@ async function loadUsers() { } } +async function editUserProfile(user: User) { + const display = window.prompt('昵称', user.displayName); + if (display === null) return; + const note = window.prompt('备注', user.note); + if (note === null) return; + await patchUser(user.id, { displayName: display, note }); +} + async function patchUser(id: number, patch: Record) { try { await api(`/api/users/${id}`, { @@ -563,9 +818,65 @@ async function patchUser(id: number, patch: Record) { async function resetUserPassword(id: number) { const password = window.prompt('输入新密码,至少 8 位'); if (!password) return; + const forcePasswordChange = window.confirm('要求该用户下次登录后修改密码?'); try { - await api(`/api/users/${id}/password`, jsonBody({ password })); + await api(`/api/users/${id}/password`, jsonBody({ password, forcePasswordChange })); setFeedback('密码已重置', 'ok'); + await loadUsers(); + } catch (error) { + setFeedback(errorMessage(error), 'error'); + } +} + +async function loadUserSessions(id: number) { + try { + const payload = await api(`/api/users/${id}/sessions`); + state.userSessions[id] = isRecord(payload) && Array.isArray(payload.sessions) ? payload.sessions as UserSession[] : []; + renderShell(); + } catch (error) { + setFeedback(errorMessage(error), 'error'); + } +} + +async function revokeSession(userId: number, sessionId: string) { + try { + await api(`/api/users/${userId}/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE' }); + await loadUserSessions(userId); + } catch (error) { + setFeedback(errorMessage(error), 'error'); + } +} + +async function revokeUserSessions(userId: number) { + try { + await api(`/api/users/${userId}/sessions`, { method: 'DELETE' }); + await loadUserSessions(userId); + } catch (error) { + setFeedback(errorMessage(error), 'error'); + } +} + +async function loadUserSteamAccounts(id: number) { + try { + await loadSteamAccounts(); + const payload = await api(`/api/users/${id}/steam-accounts`); + state.userSteamAccounts[id] = isRecord(payload) && Array.isArray(payload.steamAccounts) ? payload.steamAccounts as SteamAccount[] : []; + renderShell(); + } catch (error) { + setFeedback(errorMessage(error), 'error'); + } +} + +async function saveUserSteamAccounts(id: number, steamAccountIds: number[]) { + try { + const payload = await api(`/api/users/${id}/steam-accounts`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ steamAccountIds }) + }); + state.userSteamAccounts[id] = isRecord(payload) && Array.isArray(payload.steamAccounts) ? payload.steamAccounts as SteamAccount[] : []; + setFeedback('授权已保存', 'ok'); + await loadUsers(); } catch (error) { setFeedback(errorMessage(error), 'error'); } @@ -581,7 +892,207 @@ async function deleteUser(id: number) { } } +function renderSteamAccountsView() { + const view = create('div', 'steam-accounts-view'); + if (hasPermission('steam.manage')) { + const loginPanel = panel('登录新账户', 'toolbar-panel'); + const loginForm = create('form', 'inline-form steam-account-login') as HTMLFormElement; + loginForm.innerHTML = ` + + + + + + `; + loginForm.addEventListener('submit', async (event) => { + event.preventDefault(); + const rawLogonID = formValue(loginForm, 'logonID').trim(); + try { + const status = await api('/api/steam/accounts/login', jsonBody({ + label: formValue(loginForm, 'label'), + accountName: formValue(loginForm, 'accountName'), + password: formValue(loginForm, 'password'), + ...(rawLogonID ? { logonID: Number(rawLogonID) } : {}) + })); + updateSteamStatus(status as SteamStatus); + loginForm.reset(); + await loadSteamAccounts(); + } catch (error) { + setFeedback(errorMessage(error), 'error'); + } + }); + loginPanel.append(loginForm); + view.append(loginPanel); + } + const listPanel = panel('账户列表', 'list-panel'); + const list = create('div', 'account-table'); + list.id = 'steamAccountTable'; + listPanel.append(list); + view.append(listPanel); + renderSteamAccountTable(list); + return view; +} + +function renderSteamAccountTable(container: HTMLElement) { + clear(container); + if (!state.steamAccounts.length) { + container.append(create('div', 'empty', '暂无 Steam 账户')); + return; + } + for (const account of state.steamAccounts) { + const row = create('article', 'account-row'); + const info = create('div'); + info.append( + create('strong', '', account.label || account.steamId), + create('span', 'muted', `${account.steamId} · ${account.active ? '当前活动' : '未连接'} · ${account.enabled ? '启用' : '禁用'} · 授权 ${account.authorizedUserCount || 0} 人`), + create('span', 'muted', `最近登录 ${dateTime(account.lastLoginAt)} · 活跃 ${dateTime(account.lastActiveAt)} · token ${dateTime(account.refreshTokenUpdatedAt)}`), + create('span', 'muted', account.accountNameHint ? `账号提示:${account.accountNameHint}` : '无账号提示') + ); + const actions = create('div', 'row-actions'); + const connect = create('button', 'ghost-btn', '连接'); + connect.type = 'button'; + connect.disabled = account.active || !account.enabled; + connect.addEventListener('click', () => connectSteamAccount(account.id)); + const edit = create('button', 'ghost-btn', '编辑'); + edit.type = 'button'; + edit.addEventListener('click', () => editSteamAccount(account)); + const toggle = create('button', 'ghost-btn', account.enabled ? '禁用' : '启用'); + toggle.type = 'button'; + toggle.addEventListener('click', () => patchSteamAccount(account.id, { enabled: !account.enabled })); + const logout = create('button', 'ghost-btn', '退出'); + logout.type = 'button'; + logout.disabled = !account.active; + logout.addEventListener('click', () => logoutSteamAccount(account.id)); + const remove = create('button', 'danger-btn', '删除'); + remove.type = 'button'; + remove.addEventListener('click', () => deleteSteamAccount(account.id)); + actions.append(connect, edit, toggle, logout, remove); + row.append(info, actions); + container.append(row); + } +} + +async function loadSteamAccounts() { + try { + const payload = await api('/api/steam/accounts'); + state.steamAccounts = isRecord(payload) && Array.isArray(payload.steamAccounts) ? payload.steamAccounts as SteamAccount[] : []; + const table = document.querySelector('#steamAccountTable'); + if (table) renderSteamAccountTable(table); + } catch (error) { + setFeedback(errorMessage(error), 'error'); + } +} + +async function connectSteamAccount(id: number) { + try { + const status = await api(`/api/steam/accounts/${id}/connect`, { method: 'POST' }); + updateSteamStatus(status as SteamStatus); + await loadSteamAccounts(); + } catch (error) { + setFeedback(errorMessage(error), 'error'); + } +} + +async function editSteamAccount(account: SteamAccount) { + const label = window.prompt('显示名称', account.label); + if (label === null) return; + const accountNameHint = window.prompt('账号提示', account.accountNameHint); + if (accountNameHint === null) return; + await patchSteamAccount(account.id, { label, accountNameHint }); +} + +async function patchSteamAccount(id: number, patch: Record) { + try { + await api(`/api/steam/accounts/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(patch) + }); + await loadSteamAccounts(); + } catch (error) { + setFeedback(errorMessage(error), 'error'); + } +} + +async function logoutSteamAccount(id: number) { + try { + const status = await api(`/api/steam/accounts/${id}/logout`, { method: 'POST' }); + updateSteamStatus(status as SteamStatus); + await loadSteamAccounts(); + } catch (error) { + setFeedback(errorMessage(error), 'error'); + } +} + +async function deleteSteamAccount(id: number) { + if (!window.confirm('确认删除该 Steam 账户资料和授权关系?')) return; + try { + await api(`/api/steam/accounts/${id}`, { method: 'DELETE' }); + await loadSteamAccounts(); + } catch (error) { + setFeedback(errorMessage(error), 'error'); + } +} + +function renderAuditView() { + const view = create('div', 'audit-view'); + const filterPanel = panel('筛选', 'toolbar-panel'); + const filters = create('form', 'inline-form audit-filters') as HTMLFormElement; + filters.innerHTML = ` + + + + `; + filters.addEventListener('submit', async (event) => { + event.preventDefault(); + state.auditAction = formValue(filters, 'action'); + state.auditTargetType = formValue(filters, 'targetType'); + await loadAuditLogs(); + }); + filterPanel.append(filters); + const listPanel = panel('日志列表', 'list-panel'); + const table = create('div', 'audit-table'); + table.id = 'auditTable'; + listPanel.append(table); + view.append(filterPanel, listPanel); + renderAuditTable(table); + return view; +} + +function renderAuditTable(container: HTMLElement) { + clear(container); + if (!state.auditLogs.length) { + container.append(create('div', 'empty', '暂无审计日志')); + return; + } + for (const item of state.auditLogs) { + const row = create('article', 'audit-row'); + row.append( + create('strong', '', `${dateTime(item.createdAt)} · ${item.action}`), + create('span', 'muted', `操作者 ${item.actorUsername || item.actorUserId || '系统'} · ${item.targetType}:${item.targetId} · ${item.ip || '无 IP'}`), + create('code', '', compactJson(item.detail)) + ); + container.append(row); + } +} + +async function loadAuditLogs() { + try { + const params = new URLSearchParams(); + if (state.auditAction) params.set('action', state.auditAction); + if (state.auditTargetType) params.set('targetType', state.auditTargetType); + const payload = await api(`/api/audit-logs${params.size ? `?${params}` : ''}`); + state.auditLogs = isRecord(payload) && Array.isArray(payload.auditLogs) ? payload.auditLogs as AuditLog[] : []; + const table = document.querySelector('#auditTable'); + if (table) renderAuditTable(table); + } catch (error) { + setFeedback(errorMessage(error), 'error'); + } +} + function renderAccountView() { + const view = create('div', 'account-view'); + const accountPanel = panel('修改密码', 'form-panel'); const form = create('form', 'stack-form narrow-form') as HTMLFormElement; form.innerHTML = ` @@ -601,7 +1112,9 @@ function renderAccountView() { setFeedback(errorMessage(error), 'error'); } }); - return form; + accountPanel.append(form); + view.append(accountPanel); + return view; } function renderChatView() { @@ -739,18 +1252,31 @@ function renderComposer() { } function updateChatAvailability() { - const disabled = !steamOnline(); + const disabled = !steamOnline() || !steamAccessAllowed(); for (const selector of ['#messageInput', '#sendButton', '#pickerToggle', '#fileButton', '.image-url-form input', '.image-url-form button']) { document.querySelectorAll(selector).forEach((node) => { node.disabled = disabled; }); } const note = document.querySelector('#offlineNote'); - if (note) note.hidden = !disabled; + if (note) { + note.textContent = !steamAccessAllowed() ? '当前账号未被授权访问活动 Steam 账户。' : 'Steam 未在线,聊天发送和素材操作不可用。'; + note.hidden = !disabled; + } } async function refreshChatData() { if (!state.me) return; + if (!steamAccessAllowed()) { + state.conversations = []; + state.friends = []; + state.groups = []; + state.emoticons = []; + state.stickers = []; + updateChatLists(); + updateChatAvailability(); + return; + } try { const conversations = await api(`/conversations?limit=${state.historyLimit}`); state.conversations = asListEntries(conversations); @@ -976,7 +1502,7 @@ function renderPicker(container: HTMLElement) { } function ensureWebSocket() { - if (!state.me || state.ws || state.reconnectTimer) return; + if (!state.me || !steamAccessAllowed() || state.ws || state.reconnectTimer) return; const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; const ws = new WebSocket(`${protocol}//${location.host}${state.wsPath}`); state.ws = ws; @@ -1053,6 +1579,7 @@ async function logoutApp() { stopStatusPolling(); stopWebSocket(); state.me = null; + state.permissions = []; renderLogin(); } @@ -1060,6 +1587,7 @@ async function bootstrap() { const mePayload = await api('/api/auth/me') as MeResponse; state.needsSetup = Boolean(mePayload.needsSetup); state.me = mePayload.user || null; + state.permissions = Array.isArray(mePayload.permissions) ? mePayload.permissions : []; updateSteamStatus(mePayload.steam || defaultSteamStatus); if (state.needsSetup) { renderSetup(); diff --git a/web/style.css b/web/style.css index 1a2b40a..48af035 100644 --- a/web/style.css +++ b/web/style.css @@ -7,6 +7,7 @@ --muted: #657283; --brand: #176b87; --brand-soft: #d9ecf2; + --surface-muted: #f8fafc; --ok: #1f7a4d; --warn: #a45d14; --danger: #b33a3a; @@ -293,7 +294,7 @@ nav button.is-active { .content { min-height: 0; overflow: auto; - padding: 20px; + padding: 18px; } .feedback { @@ -316,56 +317,213 @@ nav button.is-active { } .steam-view, -.users-view { +.users-view, +.steam-accounts-view, +.audit-view, +.account-view { display: grid; gap: 16px; } +.users-view { + grid-template-columns: minmax(300px, 0.8fr) minmax(520px, 1.2fr); + align-items: start; +} + +.panel { + display: grid; + gap: 12px; + min-width: 0; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); + padding: 14px; +} + +.panel-title { + margin: 0; + color: var(--text); + font-size: 14px; + line-height: 1.2; +} + +.panel .stack-form { + margin-top: 0; +} + +.toolbar-panel { + align-self: start; +} + +.list-panel { + grid-column: 1 / -1; + gap: 10px; + border: 0; + background: transparent; + padding: 0; +} + +.form-panel, +.status-note-panel { + max-width: 720px; +} + +.page-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.user-filters { + grid-template-columns: minmax(220px, 1.4fr) minmax(120px, 0.65fr) minmax(120px, 0.65fr) auto; +} + +.user-create { + grid-template-columns: minmax(150px, 0.9fr) minmax(150px, 0.9fr) minmax(160px, 0.9fr) minmax(130px, 0.7fr) minmax(210px, 1.2fr) auto; +} + +.steam-account-login { + grid-template-columns: minmax(150px, 1fr) minmax(170px, 1fr) minmax(170px, 1fr) minmax(120px, 0.7fr) auto; +} + +.audit-filters { + grid-template-columns: minmax(220px, 1fr) minmax(180px, 0.8fr) auto; + max-width: 760px; +} + .status-panel { display: grid; gap: 4px; - border-bottom: 1px solid var(--line); - padding-bottom: 16px; + max-width: 720px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); + padding: 14px; } .status-panel strong { font-size: 20px; } -.user-table { +.user-table, +.account-table, +.audit-table { display: grid; gap: 8px; } -.user-row { +.user-row, +.account-row { display: grid; - grid-template-columns: minmax(0, 1fr) auto; + grid-template-columns: minmax(280px, 1fr) minmax(320px, auto); align-items: center; - gap: 12px; + gap: 14px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); - padding: 12px; + padding: 14px 16px; } -.user-row div:first-child { +.user-row > div:first-child, +.account-row > div:first-child { display: grid; gap: 2px; min-width: 0; } .user-row strong, -.user-row span { +.user-row span, +.account-row strong, +.account-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.user-detail { + display: grid; + grid-column: 1 / -1; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 12px; + border-top: 1px solid var(--line); + padding-top: 12px; +} + +.detail-block, +.grant-grid { + display: grid; + gap: 8px; + align-content: start; + border-radius: 6px; + background: var(--surface-muted); + padding: 10px; +} + +.detail-block h3, +.grant-grid h3 { + margin: 0; + font-size: 14px; +} + +.session-row, +.check-row { + display: grid; + grid-template-columns: minmax(140px, 0.9fr) minmax(220px, 1.2fr) auto; + gap: 8px; + align-items: center; +} + +.grant-grid { + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.grant-grid h3, +.grant-grid .empty, +.grant-grid .primary-btn { + grid-column: 1 / -1; +} + +.check-row { + grid-template-columns: auto minmax(0, 1fr); + justify-content: start; + color: var(--text); +} + +.check-row input { + width: auto; +} + +.audit-row { + display: grid; + grid-template-columns: minmax(220px, 0.75fr) minmax(300px, 1fr) minmax(260px, 1fr); + gap: 10px; + align-items: center; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--surface); + padding: 12px; +} + +.audit-row code { + overflow: auto; + border-radius: 6px; + background: var(--soft); + padding: 8px; + white-space: nowrap; +} + .row-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; + max-width: 640px; +} + +.row-actions button { + min-height: 32px; + padding: 0 10px; + font-size: 13px; } .chat-layout { @@ -711,6 +869,46 @@ nav button.is-active { color: #fff; } +@media (max-width: 1500px) { + .users-view { + grid-template-columns: 1fr; + } +} + +@media (max-width: 1200px) { + .top-actions { + flex-wrap: wrap; + } + + .user-row, + .account-row, + .session-row, + .audit-row { + grid-template-columns: 1fr; + } + + .inline-form, + .user-filters, + .user-create, + .steam-account-login, + .audit-filters { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .inline-form button, + .user-filters button, + .user-create button, + .steam-account-login button, + .audit-filters button { + grid-column: auto; + } + + .row-actions { + justify-content: flex-start; + max-width: none; + } +} + @media (max-width: 920px) { .admin-shell { grid-template-columns: 1fr; @@ -760,14 +958,38 @@ nav button.is-active { min-height: 70dvh; } + .bubble { + max-width: 94%; + } +} + +@media (max-width: 640px) { + .content { + padding: 12px; + } + .inline-form, - .user-row, + .user-filters, + .user-create, + .steam-account-login, + .audit-filters, .compose-row, .image-url-form { grid-template-columns: 1fr; } - .bubble { - max-width: 94%; + .topbar { + padding: 14px 12px; + } + + .panel { + padding: 12px; + } + + .user-row, + .account-row, + .audit-row { + grid-template-columns: 1fr; + padding: 12px; } }