feat: enhance user management
This commit is contained in:
509
docs/user-management-enhancement.md
Normal file
509
docs/user-management-enhancement.md
Normal 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 code;refresh token 只作为 Steam 账户资源的敏感字段保存在本地 SQLite 中。
|
||||
|
||||
### user_steam_accounts
|
||||
|
||||
用户和 Steam 账户的授权关系:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS user_steam_accounts (
|
||||
user_id INTEGER NOT NULL,
|
||||
steam_account_id INTEGER NOT NULL,
|
||||
granted_by INTEGER,
|
||||
granted_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, steam_account_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (steam_account_id) REFERENCES steam_accounts(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (granted_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- 新建普通用户默认没有 Steam 账户访问权。
|
||||
- 管理员默认可访问所有 Steam 账户,不需要写授权行。
|
||||
- 禁用 Steam 账户后,所有普通用户对该账户的访问立即失效。
|
||||
- 删除 Steam 账户时同时删除授权关系和该账户记录中的 refresh token。
|
||||
|
||||
### audit_logs
|
||||
|
||||
新增本地审计日志表:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
actor_user_id INTEGER,
|
||||
action TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
detail_json TEXT NOT NULL DEFAULT '{}',
|
||||
ip TEXT,
|
||||
user_agent TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit_logs(action);
|
||||
```
|
||||
|
||||
记录动作:
|
||||
|
||||
- 用户:创建、更新资料、启用、禁用、删除、角色变更、重置密码、强制改密状态变更。
|
||||
- 会话:踢下线单个会话、踢下线用户全部会话、用户主动退出全部会话。
|
||||
- Steam 账户:新增、更新、禁用、启用、删除、授权、取消授权、登录、退出、切换。
|
||||
|
||||
审计日志只记录必要元数据。密码、Steam Guard code、refresh token 必须脱敏或不进入 `detail_json`。
|
||||
|
||||
## Steam 账户访问控制
|
||||
|
||||
### 当前运行模型
|
||||
|
||||
v1 仍只有一个活动 Steam 会话。后端维护当前活动账户:
|
||||
|
||||
```text
|
||||
app_meta.active_steam_account_id
|
||||
```
|
||||
|
||||
所有依赖 Steam 账户上下文的接口都必须先解析当前活动账户:
|
||||
|
||||
1. 校验后台用户会话。
|
||||
2. 读取当前 Steam 状态和 `active_steam_account_id`。
|
||||
3. 如果接口依赖 Steam 在线,继续要求 Steam 状态为 `online`。
|
||||
4. 校验当前用户是否可访问该 Steam 账户。
|
||||
5. 执行业务逻辑。
|
||||
|
||||
普通用户未被授权访问当前活动账户时返回 403:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Steam account access denied"
|
||||
}
|
||||
```
|
||||
|
||||
如果当前没有活动 Steam 账户,依赖 Steam 的接口返回 503:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Steam account is not connected",
|
||||
"steamStatus": "logged_out"
|
||||
}
|
||||
```
|
||||
|
||||
### 需要保护的接口
|
||||
|
||||
以下接口必须校验 Steam 账户访问权:
|
||||
|
||||
- `GET /api/steam/status`
|
||||
- `GET /api/friends`
|
||||
- `GET /api/groups`
|
||||
- `GET /api/emoticons`
|
||||
- `GET /history`
|
||||
- `GET /conversations`
|
||||
- `GET /proxy/sticker/:type`
|
||||
- `GET /proxy/image`
|
||||
- `POST /message`
|
||||
- `POST /image`
|
||||
- WebSocket 握手后的所有聊天、历史、好友、群组、素材消息类型
|
||||
|
||||
管理员管理接口仍只校验管理员权限:
|
||||
|
||||
- Steam 登录、退出、切换账户。
|
||||
- Steam 账户维护。
|
||||
- 用户 Steam 账户授权维护。
|
||||
|
||||
### 聊天历史隔离
|
||||
|
||||
现有聊天历史是 JSONL 文件。为了避免用户读取未授权账户历史,新增日志字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"steamAccountId": "7656119...",
|
||||
"id": "target-steam-id",
|
||||
"message": "..."
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- 新写入的历史必须带 `steamAccountId`。
|
||||
- 查询历史和会话列表时必须传入当前活动账户,并只返回该账户记录。
|
||||
- 旧历史没有 `steamAccountId`,迁移期只允许管理员查看。
|
||||
- 当前活动账户首次成功识别后,可以由管理员触发一次“认领旧历史到该 Steam 账户”的维护动作;v1 可以先不做自动认领。
|
||||
|
||||
## 后端 API
|
||||
|
||||
### 当前用户
|
||||
|
||||
`GET /api/auth/me` 增加字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"needsSetup": false,
|
||||
"user": {
|
||||
"id": 1,
|
||||
"username": "admin",
|
||||
"displayName": "管理员",
|
||||
"role": "admin",
|
||||
"disabled": false,
|
||||
"forcePasswordChange": false
|
||||
},
|
||||
"permissions": ["user.manage", "steam.manage"],
|
||||
"steam": {
|
||||
"status": "online",
|
||||
"steamId": "7656119...",
|
||||
"activeAccount": {
|
||||
"id": 1,
|
||||
"steamId": "7656119...",
|
||||
"label": "客服一号"
|
||||
},
|
||||
"accessAllowed": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 用户管理
|
||||
|
||||
新增或调整接口:
|
||||
|
||||
- `GET /api/users?query=&role=&status=`
|
||||
- 管理员可用。
|
||||
- 支持按账号、昵称、备注搜索。
|
||||
- `status` 支持 `enabled`、`disabled`、`locked`。
|
||||
- `POST /api/users`
|
||||
- 新增 `displayName`、`note`、`steamAccountIds`。
|
||||
- `PATCH /api/users/:id`
|
||||
- 支持 `displayName`、`note`、`role`、`disabled`、`forcePasswordChange`。
|
||||
- `GET /api/users/:id/sessions`
|
||||
- 返回该用户未过期且未撤销的会话。
|
||||
- `DELETE /api/users/:id/sessions/:sessionId`
|
||||
- 管理员踢下线单个会话。
|
||||
- `DELETE /api/users/:id/sessions`
|
||||
- 管理员踢下线用户全部会话。
|
||||
- `GET /api/users/:id/steam-accounts`
|
||||
- 查看普通用户已授权 Steam 账户。
|
||||
- `PUT /api/users/:id/steam-accounts`
|
||||
- 用完整 `steamAccountIds` 覆盖授权关系。
|
||||
|
||||
保留现有接口:
|
||||
|
||||
- `POST /api/users/:id/password`
|
||||
- `DELETE /api/users/:id`
|
||||
- `POST /api/auth/password`
|
||||
- `POST /api/auth/logout`
|
||||
|
||||
新增当前用户退出全部会话:
|
||||
|
||||
- `POST /api/auth/logout-all`
|
||||
- 撤销当前用户除当前请求外的所有会话,随后也可选择清除当前 Cookie。
|
||||
|
||||
### Steam 账户管理
|
||||
|
||||
新增接口:
|
||||
|
||||
- `GET /api/steam/accounts`
|
||||
- 管理员返回全部账户。
|
||||
- 普通用户返回自己被授权且启用的账户。
|
||||
- `POST /api/steam/accounts/login`
|
||||
- 管理员用账号密码登录 Steam。
|
||||
- 请求包含 `accountName`、`password`、可选 `logonID`、可选 `label`。
|
||||
- 登录成功后用 SteamID64 upsert `steam_accounts`,写入 `refresh_token`,并设为当前活动账户。
|
||||
- `POST /api/steam/accounts/:id/connect`
|
||||
- 管理员用该账户 `refresh_token` 连接 Steam。
|
||||
- 成功后设为当前活动账户。
|
||||
- `PATCH /api/steam/accounts/:id`
|
||||
- 管理员更新 `label`、`accountNameHint`、`enabled`。
|
||||
- `DELETE /api/steam/accounts/:id`
|
||||
- 管理员删除账户资料、授权关系和表内 refresh token。
|
||||
- 如果删除的是当前活动账户,必须先退出 Steam 或由后端自动执行退出。
|
||||
- `POST /api/steam/accounts/:id/logout`
|
||||
- 管理员退出当前活动账户;只允许操作当前活动账户。
|
||||
|
||||
兼容现有接口:
|
||||
|
||||
- `POST /api/steam/login` 可以保留为 `POST /api/steam/accounts/login` 的兼容入口。
|
||||
- `POST /api/steam/logout` 可以保留为退出当前活动账户的兼容入口。
|
||||
- `GET /api/steam/status` 返回当前活动账户信息和当前用户访问结果。
|
||||
|
||||
## 前端设计
|
||||
|
||||
### 导航
|
||||
|
||||
管理员导航增加:
|
||||
|
||||
- `用户管理`
|
||||
- `Steam 账户`
|
||||
- `审计日志`
|
||||
|
||||
普通用户导航保持:
|
||||
|
||||
- `Steam 连接` 或状态页
|
||||
- `聊天`
|
||||
- `账号`
|
||||
|
||||
普通用户只看到自己可访问的 Steam 账户状态。未授权当前活动账户时,聊天页展示无权限状态,不加载好友、群组、历史和 WebSocket。
|
||||
|
||||
### 用户管理页
|
||||
|
||||
用户列表展示:
|
||||
|
||||
- 账号。
|
||||
- 昵称。
|
||||
- 角色。
|
||||
- 启用、禁用、锁定状态。
|
||||
- 已授权 Steam 账户数量。
|
||||
- 最近登录时间和 IP。
|
||||
- 最近活跃时间。
|
||||
|
||||
用户详情区支持:
|
||||
|
||||
- 修改昵称和备注。
|
||||
- 修改角色。
|
||||
- 启用、禁用。
|
||||
- 重置密码。
|
||||
- 要求下次登录改密。
|
||||
- 管理 Steam 账户授权。
|
||||
- 查看和踢下线会话。
|
||||
|
||||
### Steam 账户页
|
||||
|
||||
管理员可查看:
|
||||
|
||||
- SteamID64。
|
||||
- 显示名称。
|
||||
- 当前是否活动。
|
||||
- 是否启用。
|
||||
- 最近登录时间。
|
||||
- 被授权用户数量。
|
||||
|
||||
操作:
|
||||
|
||||
- 登录新的 Steam 账户。
|
||||
- 用已保存 token 连接已有账户。
|
||||
- 编辑显示名称和账号提示。
|
||||
- 启用、禁用。
|
||||
- 删除账户。
|
||||
- 查看已授权用户。
|
||||
|
||||
### 审计日志页
|
||||
|
||||
v1 只提供后台查看,不提供导出:
|
||||
|
||||
- 按动作、目标类型、操作者、时间范围筛选。
|
||||
- 展示时间、操作者、动作、目标、IP、简要详情。
|
||||
- 敏感字段永远不展示。
|
||||
|
||||
## 迁移策略
|
||||
|
||||
1. 启动时创建新表并给 `users` 补齐新增字段。
|
||||
2. 引入 `schema_version` 或在 `app_meta` 中记录迁移版本,迁移保持幂等。
|
||||
3. 现有 Cookie 没有 `sid`,升级后统一要求重新登录。
|
||||
4. 现有 `${STEAM_CHAT_DATA_DIR}/refresh.token` 保留为兼容读取来源,首次成功登录并识别 SteamID 后写入 `steam_accounts.refresh_token` 和 `refresh_token_updated_at`。
|
||||
5. token 迁移成功后删除旧路径 `refresh.token`,避免同一敏感凭据存在两份。
|
||||
6. 首个识别出的 Steam 账户自动创建 `steam_accounts` 记录并设为当前活动账户。
|
||||
7. 普通用户默认不自动获得该账户访问权,由管理员显式授权。
|
||||
8. 旧聊天历史没有 `steamAccountId`,默认只允许管理员查看;后续可增加管理员手动认领工具。
|
||||
|
||||
## 安全要求
|
||||
|
||||
- 所有权限校验必须在后端执行,前端只做体验隐藏。
|
||||
- 管理员不能删除或禁用最后一个启用管理员。
|
||||
- 管理员不能删除当前登录用户自己。
|
||||
- 用户被禁用、角色变更、重置密码、强制改密时,相关旧会话必须失效。
|
||||
- 登录失败达到阈值后临时锁定账号;建议默认 5 次失败锁定 15 分钟。
|
||||
- 审计日志和错误日志不得包含后台密码、Steam 密码、Steam Guard code、refresh token。
|
||||
- `steam_accounts.refresh_token` 不得出现在任何 API 响应、前端状态、审计详情或结构化日志中。
|
||||
- Steam 账户授权失败统一返回 403,不暴露未授权账户的详情。
|
||||
- 普通用户不能通过历史、会话列表、WebSocket 或媒体代理旁路读取未授权账户数据。
|
||||
|
||||
## 测试要求
|
||||
|
||||
新增或调整测试:
|
||||
|
||||
- 用户资料字段迁移和读写。
|
||||
- 固定角色到权限点映射。
|
||||
- 登录失败计数和锁定。
|
||||
- 会话表校验、踢下线、退出全部会话。
|
||||
- 用户禁用、角色变更、重置密码后旧会话失效。
|
||||
- Steam 账户创建、禁用、删除和表内 refresh token 读写。
|
||||
- 普通用户访问未授权 Steam 账户返回 403。
|
||||
- 普通用户不能读取未授权账户历史、会话列表、好友、群组、素材和 WebSocket 数据。
|
||||
- 管理员默认可访问所有 Steam 账户。
|
||||
- 审计日志记录关键管理动作并脱敏敏感字段。
|
||||
- 旧库迁移后已有管理员仍可登录。
|
||||
|
||||
验收命令:
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npm test
|
||||
```
|
||||
|
||||
## 建议落地顺序
|
||||
|
||||
1. 数据迁移和权限点映射。
|
||||
2. 会话表和踢下线能力。
|
||||
3. 用户资料、登录安全和审计日志。
|
||||
4. Steam 账户表、授权关系和当前活动账户校验。
|
||||
5. 聊天历史增加 `steamAccountId` 并按账户过滤。
|
||||
6. 前端用户详情、Steam 账户页和审计日志页。
|
||||
7. 清理兼容接口和补齐测试。
|
||||
@@ -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
29
src/index.ts
29
src/index.ts
@@ -1,6 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
import type { CallbackStyleFunction, Persona, UnknownRecord } from './types';
|
||||
import type { IncomingMessage } from 'node:http';
|
||||
import { errorMessage, isRecord } from './types';
|
||||
|
||||
const winston = require('winston');
|
||||
@@ -9,8 +10,9 @@ const { createAuthStore } = require('./auth/store');
|
||||
const { createSessionManager } = require('./auth/session');
|
||||
const { loadConfig } = require('./config/load');
|
||||
const { AUTH_DB_PATH, REFRESH_TOKEN_PATH } = require('./paths');
|
||||
const { getClientIp } = require('./server/auth');
|
||||
const { createChatService } = require('./server/chat-service');
|
||||
const { createSteamLoginService } = require('./steam/lifecycle');
|
||||
const { createSteamLoginService, readRefreshToken } = require('./steam/lifecycle');
|
||||
const {
|
||||
steamIdToString
|
||||
} = require('./storage/chat-log');
|
||||
@@ -69,7 +71,11 @@ const steamUser = new SteamUser({ renewRefreshTokens: true });
|
||||
const steamCommunity = new SteamCommunity();
|
||||
const users: Record<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
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
580
web/app.ts
580
web/app.ts
@@ -1,16 +1,71 @@
|
||||
type Role = 'admin' | 'user';
|
||||
type View = 'steam' | 'chat' | 'users' | 'account';
|
||||
type View = 'steam' | 'chat' | 'users' | 'steamAccounts' | 'audit' | 'account';
|
||||
type Tone = 'muted' | 'ok' | 'warn' | 'error';
|
||||
type Permission =
|
||||
| 'user.manage'
|
||||
| 'session.manage'
|
||||
| 'audit.view'
|
||||
| 'steam.manage'
|
||||
| 'steam.account.manage'
|
||||
| 'chat.use'
|
||||
| 'self.password.change';
|
||||
|
||||
type User = {
|
||||
id: number;
|
||||
username: string;
|
||||
displayName: string;
|
||||
note: string;
|
||||
role: Role;
|
||||
disabled: boolean;
|
||||
forcePasswordChange: boolean;
|
||||
sessionVersion: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastLoginAt: string | null;
|
||||
lastLoginIp: string | null;
|
||||
lastSeenAt: string | null;
|
||||
passwordChangedAt: string | null;
|
||||
failedLoginCount: number;
|
||||
lockedUntil: string | null;
|
||||
locked: boolean;
|
||||
steamAccountCount: number;
|
||||
};
|
||||
|
||||
type UserSession = {
|
||||
id: string;
|
||||
userId: number;
|
||||
createdAt: string;
|
||||
lastSeenAt: string;
|
||||
expiresAt: string;
|
||||
ip: string | null;
|
||||
userAgent: string | null;
|
||||
};
|
||||
|
||||
type SteamAccount = {
|
||||
id: number;
|
||||
steamId: string;
|
||||
label: string;
|
||||
accountNameHint: string;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastLoginAt: string | null;
|
||||
lastActiveAt: string | null;
|
||||
refreshTokenUpdatedAt: string | null;
|
||||
authorizedUserCount: number;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
type AuditLog = {
|
||||
id: number;
|
||||
actorUserId: number | null;
|
||||
actorUsername: string | null;
|
||||
action: string;
|
||||
targetType: string;
|
||||
targetId: string;
|
||||
detail: Record<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();
|
||||
|
||||
250
web/style.css
250
web/style.css
@@ -7,6 +7,7 @@
|
||||
--muted: #657283;
|
||||
--brand: #176b87;
|
||||
--brand-soft: #d9ecf2;
|
||||
--surface-muted: #f8fafc;
|
||||
--ok: #1f7a4d;
|
||||
--warn: #a45d14;
|
||||
--danger: #b33a3a;
|
||||
@@ -293,7 +294,7 @@ nav button.is-active {
|
||||
.content {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 20px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.feedback {
|
||||
@@ -316,56 +317,213 @@ nav button.is-active {
|
||||
}
|
||||
|
||||
.steam-view,
|
||||
.users-view {
|
||||
.users-view,
|
||||
.steam-accounts-view,
|
||||
.audit-view,
|
||||
.account-view {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.users-view {
|
||||
grid-template-columns: minmax(300px, 0.8fr) minmax(520px, 1.2fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.panel .stack-form {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.toolbar-panel {
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
.list-panel {
|
||||
grid-column: 1 / -1;
|
||||
gap: 10px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.form-panel,
|
||||
.status-note-panel {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.page-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.user-filters {
|
||||
grid-template-columns: minmax(220px, 1.4fr) minmax(120px, 0.65fr) minmax(120px, 0.65fr) auto;
|
||||
}
|
||||
|
||||
.user-create {
|
||||
grid-template-columns: minmax(150px, 0.9fr) minmax(150px, 0.9fr) minmax(160px, 0.9fr) minmax(130px, 0.7fr) minmax(210px, 1.2fr) auto;
|
||||
}
|
||||
|
||||
.steam-account-login {
|
||||
grid-template-columns: minmax(150px, 1fr) minmax(170px, 1fr) minmax(170px, 1fr) minmax(120px, 0.7fr) auto;
|
||||
}
|
||||
|
||||
.audit-filters {
|
||||
grid-template-columns: minmax(220px, 1fr) minmax(180px, 0.8fr) auto;
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.status-panel {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding-bottom: 16px;
|
||||
max-width: 720px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.status-panel strong {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.user-table {
|
||||
.user-table,
|
||||
.account-table,
|
||||
.audit-table {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.user-row {
|
||||
.user-row,
|
||||
.account-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-columns: minmax(280px, 1fr) minmax(320px, auto);
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
padding: 12px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.user-row div:first-child {
|
||||
.user-row > div:first-child,
|
||||
.account-row > div:first-child {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-row strong,
|
||||
.user-row span {
|
||||
.user-row span,
|
||||
.account-row strong,
|
||||
.account-row span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-detail {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.detail-block,
|
||||
.grant-grid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
align-content: start;
|
||||
border-radius: 6px;
|
||||
background: var(--surface-muted);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.detail-block h3,
|
||||
.grant-grid h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.session-row,
|
||||
.check-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(140px, 0.9fr) minmax(220px, 1.2fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.grant-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.grant-grid h3,
|
||||
.grant-grid .empty,
|
||||
.grant-grid .primary-btn {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.check-row {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
justify-content: start;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.check-row input {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.audit-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 0.75fr) minmax(300px, 1fr) minmax(260px, 1fr);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.audit-row code {
|
||||
overflow: auto;
|
||||
border-radius: 6px;
|
||||
background: var(--soft);
|
||||
padding: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.row-actions button {
|
||||
min-height: 32px;
|
||||
padding: 0 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chat-layout {
|
||||
@@ -711,6 +869,46 @@ nav button.is-active {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@media (max-width: 1500px) {
|
||||
.users-view {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.top-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.user-row,
|
||||
.account-row,
|
||||
.session-row,
|
||||
.audit-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.inline-form,
|
||||
.user-filters,
|
||||
.user-create,
|
||||
.steam-account-login,
|
||||
.audit-filters {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.inline-form button,
|
||||
.user-filters button,
|
||||
.user-create button,
|
||||
.steam-account-login button,
|
||||
.audit-filters button {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
justify-content: flex-start;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.admin-shell {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -760,14 +958,38 @@ nav button.is-active {
|
||||
min-height: 70dvh;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
max-width: 94%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.content {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.inline-form,
|
||||
.user-row,
|
||||
.user-filters,
|
||||
.user-create,
|
||||
.steam-account-login,
|
||||
.audit-filters,
|
||||
.compose-row,
|
||||
.image-url-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
max-width: 94%;
|
||||
.topbar {
|
||||
padding: 14px 12px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.user-row,
|
||||
.account-row,
|
||||
.audit-row {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user