feat: enhance user management

This commit is contained in:
2026-06-25 11:05:28 +08:00
parent c8c57c5eb8
commit fec21290ec
14 changed files with 2836 additions and 138 deletions

View File

@@ -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 coderefresh 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. 清理兼容接口和补齐测试。

View File

@@ -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<string, string> {
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
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<string, Persona> = {};
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
});

View File

@@ -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<unknown> | (() => Promise<unknown> | 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<PublicUser, 'role'>, 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<PublicSteamAccount, 'id' | 'steamId' | 'label'> | 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<WsConnection>();
const wsSessions = new Map<WsConnection, AppSession | null>();
const recentSentText = new Map<string, number>();
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<T>(operation: () => Promise<T> | T, needsWebSession = false): Promise<T> {
requireSteamOnline();
await resolveWaiter(waitForLogin);
@@ -626,7 +933,7 @@ function createChatService(options: ChatServiceOptions = {}) {
}
}
async function sendTextMessage(id: unknown, msg: unknown): Promise<HistoryItem> {
async function sendTextMessage(id: unknown, msg: unknown, steamAccountId?: string): Promise<HistoryItem> {
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<HistoryItem> {
async function sendImageMessage(id: unknown, body: ImageBody, steamAccountId?: string): Promise<HistoryItem> {
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) {

View File

@@ -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<T = unknown>(): Deferred<T> {
@@ -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<boolean>();
webDeferred = createHandledDeferred<WebSession>();
@@ -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 });

View File

@@ -46,12 +46,13 @@ type SteamMessageLoggerOptions = {
steamUser: SteamMessageLoggerUser;
getUserInfo?: (steamID: unknown) => Promise<Persona>;
getSelfName?: () => Promise<string>;
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<Persona> = 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<boolean> {
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,

View File

@@ -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<HistoryItem, 'id' | 'date' | 'sentAt' | 'ordinal'>): string {
return `${item.id}\0${parseMessageDate(item)}`;
function ordinalGroupKey(item: Pick<HistoryItem, 'steamAccountId' | 'id' | 'date' | 'sentAt' | 'ordinal'>): string {
return `${item.steamAccountId || ''}\0${item.id}\0${parseMessageDate(item)}`;
}
function usedOrdinalsByGroup(items: HistoryItem[]): Map<string, Set<number>> {
@@ -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<HistoryItem[]> {
async function readHistory(options: {
logPath?: string;
limit?: unknown;
id?: unknown;
steamAccountId?: unknown;
includeLegacy?: boolean;
logger?: LoggerLike;
} = {}): Promise<HistoryItem[]> {
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<HistoryItem, 'type' | 'message'>): 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<ConversationSummary[]> {
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<string, ConversationSummary & { updatedAtMs: number }>();
for (const item of records) {

View File

@@ -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;

View File

@@ -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);
});

View File

@@ -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']);
});

View File

@@ -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');
});

View File

@@ -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<string, unknown>;
ip: string | null;
createdAt: string;
};
type SteamStatus = {
@@ -21,11 +76,14 @@ type SteamStatus = {
lastCodeWrong: boolean;
error: string | null;
steamId: string | null;
activeAccount?: Pick<SteamAccount, 'id' | 'steamId' | 'label'> | null;
accessAllowed?: boolean;
};
type MeResponse = {
needsSetup: boolean;
user: User | null;
permissions: Permission[];
steam: SteamStatus;
};
@@ -70,9 +128,14 @@ type WsPayload = Record<string, unknown> & {
type AppState = {
me: User | null;
needsSetup: boolean;
permissions: Permission[];
steam: SteamStatus;
view: View;
users: User[];
userSessions: Record<number, UserSession[]>;
userSteamAccounts: Record<number, SteamAccount[]>;
steamAccounts: SteamAccount[];
auditLogs: AuditLog[];
conversations: ListEntry[];
friends: ListEntry[];
groups: ListEntry[];
@@ -85,6 +148,11 @@ type AppState = {
ws: WebSocket | null;
reconnectTimer: ReturnType<typeof setTimeout> | null;
statusTimer: ReturnType<typeof setInterval> | 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<HTMLElement>('#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<unknown> {
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 = `
<label>显示名称<input name="label" autocomplete="off" placeholder="例如:客服一号"></label>
<label>Steam 账号<input name="accountName" autocomplete="username" required></label>
<label>Steam 密码<input name="password" type="password" autocomplete="current-password" required></label>
<label>Logon ID<input name="logonID" inputmode="numeric" placeholder="留空使用后台固定值"></label>
@@ -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 = `
<label>搜索<input name="query" value="${state.userQuery}" placeholder="账号、昵称、备注"></label>
<label>角色<select name="role"><option value="">全部</option><option value="admin">管理员</option><option value="user">普通用户</option></select></label>
<label>状态<select name="status"><option value="">全部</option><option value="enabled">启用</option><option value="disabled">禁用</option><option value="locked">锁定</option></select></label>
<button type="submit">筛选</button>
`;
(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 = `
<label>账号<input name="username" minlength="3" maxlength="64" required></label>
<label>昵称<input name="displayName" maxlength="80"></label>
<label>密码<input name="password" type="password" minlength="8" required></label>
<label>角色<select name="role"><option value="user">普通用户</option><option value="admin">管理员</option></select></label>
<label>备注<input name="note" maxlength="500"></label>
<button type="submit">新增用户</button>
`;
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<HTMLInputElement>('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<HTMLElement>('#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<string, unknown>) {
try {
await api(`/api/users/${id}`, {
@@ -563,9 +818,65 @@ async function patchUser(id: number, patch: Record<string, unknown>) {
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 = `
<label>显示名称<input name="label" maxlength="80"></label>
<label>Steam 账号<input name="accountName" autocomplete="username" required></label>
<label>Steam 密码<input name="password" type="password" autocomplete="current-password" required></label>
<label>Logon ID<input name="logonID" inputmode="numeric"></label>
<button type="submit">登录新账户</button>
`;
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<HTMLElement>('#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<string, unknown>) {
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 = `
<label>动作<input name="action" value="${state.auditAction}" placeholder="例如 user.update"></label>
<label>目标类型<input name="targetType" value="${state.auditTargetType}" placeholder="user / steam_account"></label>
<button type="submit">筛选</button>
`;
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<HTMLElement>('#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 = `
<label>当前密码<input name="oldPassword" type="password" autocomplete="current-password" required></label>
@@ -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<HTMLInputElement | HTMLButtonElement | HTMLTextAreaElement>(selector).forEach((node) => {
node.disabled = disabled;
});
}
const note = document.querySelector<HTMLElement>('#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();

View File

@@ -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;
}
}