From 06b3d09e22ab06d30b3fd485e34ecdf9094f2788 Mon Sep 17 00:00:00 2001 From: hectorqin Date: Thu, 24 Sep 2026 17:19:48 +0800 Subject: [PATCH] feat(admin): manage users and invite-based registration --- docs/api.md | 23 ++++++- docs/configuration.en.md | 2 +- docs/configuration.zh-CN.md | 2 +- server/src/db/schema.ts | 16 +++++ server/src/http/auth.ts | 1 + server/src/http/routes/auth.ts | 32 ++++++++- server/src/http/routes/library.ts | 10 ++- server/src/services/registration.ts | 44 +++++++++++++ server/src/services/tokens.ts | 5 +- server/src/services/users.ts | 87 +++++++++++++++---------- server/test/user-management.test.ts | 59 +++++++++++++++++ web/src/api/client.ts | 12 +++- web/src/api/types.ts | 6 ++ web/src/styles/reader.css | 11 ++++ web/src/ui/login-screen.tsx | 13 ++-- web/src/ui/shelf-screen.tsx | 5 ++ web/src/ui/shelf-settings.tsx | 3 + web/src/ui/user-management.tsx | 44 +++++++++++++ web/tools/ui-review/p1-reading.mjs | 2 + web/tools/ui-review/user-management.mjs | 33 ++++++++++ 20 files changed, 358 insertions(+), 52 deletions(-) create mode 100644 server/src/services/registration.ts create mode 100644 server/test/user-management.test.ts create mode 100644 web/src/ui/user-management.tsx create mode 100644 web/tools/ui-review/user-management.mjs diff --git a/docs/api.md b/docs/api.md index 767c448..4e0eadb 100644 --- a/docs/api.md +++ b/docs/api.md @@ -72,7 +72,8 @@ ``` - 首个账号恒可注册,且自动成为 `admin` -- 之后仅在 `ALLOW_REGISTRATION=true` 时开放 +- 之后按管理员设置的注册策略开放;未设置时兼容 `ALLOW_REGISTRATION`。 +- 邀请模式需要提交 `inviteCode`,无效、过期、停用或用尽的邀请码返回 `400 INVITE_INVALID`,未填写返回 `400 INVITE_REQUIRED`。普通注册只创建普通用户。 - 关闭时返回 `400 REGISTRATION_DISABLED` 返回 `201`: @@ -1008,7 +1009,7 @@ Invoke-RestMethod -Method Post -Uri "$readerApi/books/$readerBookId/refresh" -He ### `GET /admin/users` → `{ "users": [ { "id": "...", "username": "...", "displayName": "...", - "role": "member", "createdAt": 0 } ] }` + "role": "member", "disabled": false, "createdAt": 0 } ] }` ### `POST /admin/users` @@ -1023,7 +1024,23 @@ Invoke-RestMethod -Method Post -Uri "$readerApi/books/$readerBookId/refresh" -He `{ "disabled": true }` 或 `{ "role": "admin" }`。 -不能停用自己(`400 SELF_LOCKOUT`),否则会把自己锁在外面。 +不能停用自己或移除自己的管理员角色(`400 SELF_LOCKOUT`),并须保留至少一名启用的管理员。停用立即撤销旧会话,重新启用仍需重新登录。 + +### `POST /admin/users/:id/password` + +`{ "password": "至少8位的新密码" }` → `{ "ok": true }`。重置其他用户的密码,同时撤销其访问令牌和续期令牌。本人请使用 `/auth/password`。 + +### `GET /admin/registration`、`PATCH /admin/registration` + +GET 返回 `{ "mode": "closed", "invites": [...] }`;PATCH 接受 `{ "mode": "closed" | "open" | "invite" }`,对应关闭、开放、仅邀请码注册。数据库设置优先于环境变量,重启后保留。首个账号仍可直接注册为管理员。 + +`GET /instance` 额外返回 `registrationMode` 和 `invitationRequired`,不暴露邀请码;`registrationOpen` 为开放或邀请模式时均为 true。 + +### `POST /admin/invites`、`DELETE /admin/invites/:id` + +创建参数 `{ "label": "家人", "maxUses": 1, "days": 7 }`,备注最多 80 字、次数 1–1000、有效期 1–365 天。返回 `201 { "code": "新邀请码", "invite": {...} }`。明文仅创建时返回,数据库只保存摘要。 + +列表条目含 `id/label/maxUses/usedCount/expiresAt/disabled/createdAt`。DELETE 停用邀请码,不删除已注册用户。邀请码消费与账号创建在同一事务内,失败不扣次数,并发不超额。上述管理接口均仅管理员可用。 ### `GET /providers` diff --git a/docs/configuration.en.md b/docs/configuration.en.md index 0fdb264..85ea6fd 100644 --- a/docs/configuration.en.md +++ b/docs/configuration.en.md @@ -26,7 +26,7 @@ These are defaults when running the server directly. The Docker image additional | `PUBLIC_URL` | Empty | Public-facing URL; set when using a reverse proxy | | `SCAN_INTERVAL` | `1800` | Scheduled scan interval in seconds; `0` disables it | | `WATCH_INTERVAL` | `60` | Change-check interval in seconds; `0` disables it | -| `ALLOW_REGISTRATION` | `false` | Allow public registration after the first account | +| `ALLOW_REGISTRATION` | `false` | Default registration policy; saved admin settings take precedence (closed, open, or invite-only) | | `ACCESS_TOKEN_TTL` | `604800` | Access-token lifetime in seconds (7 days); renewed near expiry on requests or returning to the foreground | | `REFRESH_TOKEN_TTL` | `31536000` | Refresh-token lifetime in seconds | | `READER_TOKEN_SECRET` | Generated | Signing secret, at least 16 characters; otherwise stored in `DATA_DIR/token.secret` | diff --git a/docs/configuration.zh-CN.md b/docs/configuration.zh-CN.md index 0896496..d1c9634 100644 --- a/docs/configuration.zh-CN.md +++ b/docs/configuration.zh-CN.md @@ -27,7 +27,7 @@ | `PUBLIC_URL` | 空 | 对外访问地址,反向代理后设置 | | `SCAN_INTERVAL` | `1800` | 定时扫描间隔,秒;`0` 关闭 | | `WATCH_INTERVAL` | `60` | 变化检查间隔,秒;`0` 关闭 | -| `ALLOW_REGISTRATION` | `false` | 首个账号之后是否允许公开注册 | +| `ALLOW_REGISTRATION` | `false` | 首个账号之后的默认注册策略;管理员在「书架设置 → 用户管理 → 注册与邀请」保存的策略优先,支持关闭、开放、邀请码注册 | | `ACCESS_TOKEN_TTL` | `604800` | 访问令牌有效期,秒;默认 7 天,客户端在临近过期请求或返回前台时自动续期 | | `REFRESH_TOKEN_TTL` | `31536000` | 刷新令牌有效期,秒 | | `READER_TOKEN_SECRET` | 自动生成 | 至少 16 字符的签名密钥;默认保存在 `DATA_DIR/token.secret` | diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index e2497ef..be65015 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -15,6 +15,7 @@ * that an instance running an older build against a newer database still works. */ export const MIGRATIONS_SQL = ` +ALTER TABLE users ADD COLUMN auth_version INTEGER NOT NULL DEFAULT 0; ALTER TABLE source_instances ADD COLUMN is_default INTEGER NOT NULL DEFAULT 0; CREATE UNIQUE INDEX IF NOT EXISTS source_single_default ON source_instances(is_default) WHERE is_default = 1; ALTER TABLE book_files ADD COLUMN parse_version INTEGER NOT NULL DEFAULT 0; @@ -37,6 +38,21 @@ CREATE TABLE IF NOT EXISTS users ( updated_at INTEGER NOT NULL ); +CREATE TABLE IF NOT EXISTS registration_settings ( + id INTEGER PRIMARY KEY CHECK(id = 1), + mode TEXT NOT NULL CHECK(mode IN ('closed','open','invite')) +); +CREATE TABLE IF NOT EXISTS registration_invites ( + id TEXT PRIMARY KEY, + code_hash TEXT NOT NULL UNIQUE, + label TEXT NOT NULL, + max_uses INTEGER NOT NULL, + used_count INTEGER NOT NULL DEFAULT 0, + expires_at INTEGER NOT NULL, + disabled INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL +); + CREATE TABLE IF NOT EXISTS refresh_tokens ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, diff --git a/server/src/http/auth.ts b/server/src/http/auth.ts index cbfa090..a93063f 100644 --- a/server/src/http/auth.ts +++ b/server/src/http/auth.ts @@ -19,6 +19,7 @@ export function authenticate(ctx: AppContext) { const row = ctx.users.byId(payload.sub); if (!row) throw unauthorized('account no longer exists', 'TOKEN_INVALID'); if (row.disabled === 1) throw forbidden('account disabled', 'ACCOUNT_DISABLED'); + if ((payload.ver ?? 0) !== (row.auth_version ?? 0)) throw unauthorized('session revoked', 'TOKEN_INVALID'); request.currentUser = toPublicUser(row); }; } diff --git a/server/src/http/routes/auth.ts b/server/src/http/routes/auth.ts index ff1925c..a5333c6 100644 --- a/server/src/http/routes/auth.ts +++ b/server/src/http/routes/auth.ts @@ -1,20 +1,44 @@ import type { FastifyInstance } from 'fastify'; import type { AppContext } from '../context.ts'; -import { authenticate, currentUser } from '../auth.ts'; +import { authenticate, currentUser, requireAdmin } from '../auth.ts'; +import { RegistrationService } from '../../services/registration.ts'; import { badRequest } from '../../lib/errors.ts'; interface Credentials { username?: string; password?: string; displayName?: string; + inviteCode?: string; } export function registerAuthRoutes(app: FastifyInstance, ctx: AppContext): void { const authenticateFn = authenticate(ctx); + const registration = new RegistrationService(ctx.db); + + app.get('/api/v1/admin/registration', {preHandler:authenticateFn}, async request => { + requireAdmin(request); return {mode:registration.mode(),invites:registration.invites()}; + }); + app.patch('/api/v1/admin/registration', {preHandler:authenticateFn}, async request => { + requireAdmin(request); registration.setMode((request.body as {mode?:unknown})?.mode); return {mode:registration.mode()}; + }); + app.post('/api/v1/admin/invites', {preHandler:authenticateFn}, async (request,reply) => { + requireAdmin(request); const body=(request.body ?? {}) as {label?:unknown;maxUses?:unknown;days?:unknown}; + const result=registration.create(body.label ?? '',body.maxUses ?? 1,body.days ?? 7); reply.status(201); return result; + }); + app.delete('/api/v1/admin/invites/:id', {preHandler:authenticateFn}, async request => { + requireAdmin(request); registration.disable((request.params as {id:string}).id); return {ok:true}; + }); + app.post('/api/v1/admin/users/:id/password', {preHandler:authenticateFn}, async request => { + requireAdmin(request); const {id}=request.params as {id:string}; + if (id===currentUser(request).id) throw badRequest('请使用个人修改密码功能','SELF_LOCKOUT'); + const password=(request.body as {password?:unknown})?.password; + if (typeof password!=='string') throw badRequest('请填写新密码'); + await ctx.users.resetPassword(id,password); return {ok:true}; + }); app.post('/api/v1/auth/register', async (request, reply) => { const body = (request.body ?? {}) as Credentials; - if (!body.username || !body.password) throw badRequest('username and password are required'); + if (typeof body.username!=='string' || typeof body.password!=='string' || !body.username || !body.password || body.username.length>100 || body.password.length>1024 || (body.displayName!==undefined && (typeof body.displayName!=='string' || body.displayName.length>100))) throw badRequest('请填写有效的用户名、密码和显示名'); if (!ctx.users.canRegisterPublicly()) { throw badRequest('public registration is disabled on this instance', 'REGISTRATION_DISABLED'); } @@ -22,7 +46,7 @@ export function registerAuthRoutes(app: FastifyInstance, ctx: AppContext): void username: body.username, password: body.password, ...(body.displayName !== undefined ? { displayName: body.displayName } : {}), - }); + }, () => registration.admit(body.inviteCode)); const session = await ctx.users.login(body.username, body.password, request.headers['user-agent'] ?? ''); reply.status(201); return { user, session }; @@ -67,6 +91,8 @@ export function registerAuthRoutes(app: FastifyInstance, ctx: AppContext): void name: 'reader', apiVersion: 1, registrationOpen: ctx.users.canRegisterPublicly(), + registrationMode: ctx.users.count() === 0 ? 'open' : registration.mode(), + invitationRequired: ctx.users.count() > 0 && registration.mode() === 'invite', userCount: ctx.users.count(), }; }); diff --git a/server/src/http/routes/library.ts b/server/src/http/routes/library.ts index 4f78006..4bfb3f9 100644 --- a/server/src/http/routes/library.ts +++ b/server/src/http/routes/library.ts @@ -776,7 +776,8 @@ export function registerLibraryRoutes(app: FastifyInstance, ctx: AppContext): vo app.post('/api/v1/admin/users', { preHandler: auth }, async (request, reply) => { requireAdmin(request); const body = (request.body ?? {}) as { username?: string; password?: string; displayName?: string; role?: 'admin' | 'member' }; - if (!body.username || !body.password) throw badRequest('username and password are required'); + if (typeof body.username!=='string' || typeof body.password!=='string' || !body.username || !body.password || body.username.length>100 || body.password.length>1024 || (body.displayName!==undefined && (typeof body.displayName!=='string' || body.displayName.length>100))) throw badRequest('请填写有效的用户名、密码和显示名'); + if (body.role!==undefined && body.role!=='admin' && body.role!=='member') throw badRequest('无效的用户角色'); const user = await ctx.users.createAsAdmin({ username: body.username, password: body.password, @@ -792,7 +793,12 @@ export function registerLibraryRoutes(app: FastifyInstance, ctx: AppContext): vo requireAdmin(request); const { id } = request.params as { id: string }; const body = (request.body ?? {}) as { disabled?: boolean; role?: 'admin' | 'member' }; - if (id === actor.id && body.disabled) throw badRequest('cannot disable your own account', 'SELF_LOCKOUT'); + const target = ctx.users.byId(id); + if (!target) throw notFound('用户不存在'); + if (body.disabled!==undefined && typeof body.disabled!=='boolean') throw badRequest('无效的账号状态'); + if (body.role!==undefined && body.role!=='admin' && body.role!=='member') throw badRequest('无效的用户角色'); + if (id === actor.id && (body.disabled || body.role==='member')) throw badRequest('不能停用自己或移除自己的管理员权限', 'SELF_LOCKOUT'); + if (target.role==='admin' && !target.disabled && (body.disabled || body.role==='member') && ctx.db.get<{n:number}>("SELECT count(*) AS n FROM users WHERE role='admin' AND disabled=0")!.n<=1) throw badRequest('必须保留至少一名启用的管理员','LAST_ADMIN'); if (body.disabled !== undefined) ctx.users.setDisabled(id, body.disabled); if (body.role !== undefined) ctx.users.setRole(id, body.role); return { users: ctx.users.list() }; diff --git a/server/src/services/registration.ts b/server/src/services/registration.ts new file mode 100644 index 0000000..d8f2b60 --- /dev/null +++ b/server/src/services/registration.ts @@ -0,0 +1,44 @@ +import { randomBytes, randomUUID, createHash } from 'node:crypto'; +import type { Db } from '../db/index.ts'; +import { badRequest, notFound } from '../lib/errors.ts'; + +export type RegistrationMode = 'closed' | 'open' | 'invite'; +const digest = (code: string) => createHash('sha256').update(code.trim()).digest('hex'); + +export class RegistrationService { + constructor(private readonly db: Db) {} + mode(): RegistrationMode { + return this.db.get<{mode:RegistrationMode}>('SELECT mode FROM registration_settings WHERE id = 1')?.mode + ?? (process.env.ALLOW_REGISTRATION === 'true' ? 'open' : 'closed'); + } + setMode(mode: unknown): void { + if (mode !== 'closed' && mode !== 'open' && mode !== 'invite') throw badRequest('无效的注册模式'); + this.db.run('INSERT INTO registration_settings (id,mode) VALUES (1,?) ON CONFLICT(id) DO UPDATE SET mode=excluded.mode',mode); + } + invites() { + return this.db.all<{id:string;label:string;maxUses:number;usedCount:number;expiresAt:number;disabled:number;createdAt:number}>( + 'SELECT id,label,max_uses AS maxUses,used_count AS usedCount,expires_at AS expiresAt,disabled,created_at AS createdAt FROM registration_invites ORDER BY created_at DESC', + ).map(row=>({...row,disabled:!!row.disabled})); + } + create(label: unknown, maxUses: unknown, days: unknown) { + if (typeof label !== 'string' || label.trim().length > 80 || !Number.isInteger(maxUses) || (maxUses as number) < 1 || (maxUses as number) > 1000 || !Number.isInteger(days) || (days as number) < 1 || (days as number) > 365) throw badRequest('备注限 80 字,次数须为 1–1000,有效期须为 1–365 天'); + const id=randomUUID(),code=randomBytes(18).toString('base64url'),now=Date.now(); + this.db.run('INSERT INTO registration_invites(id,code_hash,label,max_uses,expires_at,created_at) VALUES(?,?,?,?,?,?)',id,digest(code),label.trim(),maxUses as number,now+(days as number)*86400000,now); + return {code,invite:this.invites().find(invite=>invite.id===id)!}; + } + disable(id:string): void { + if (!this.db.get('SELECT id FROM registration_invites WHERE id=?',id)) throw notFound('邀请码不存在'); + this.db.run('UPDATE registration_invites SET disabled=1 WHERE id=?',id); + } + /** Called inside the same transaction as account creation, after password hashing. */ + admit(code: unknown): void { + if (this.db.get<{n:number}>('SELECT count(*) AS n FROM users')!.n === 0) return; + const mode=this.mode(); + if (mode==='closed') throw badRequest('此实例已关闭公开注册,请联系管理员创建账号','REGISTRATION_DISABLED'); + if (mode==='open') return; + if (typeof code!=='string' || !code.trim() || code.length>200) throw badRequest('请填写有效的邀请码','INVITE_REQUIRED'); + const row=this.db.get<{id:string}>('SELECT id FROM registration_invites WHERE code_hash=? AND disabled=0 AND used_count?',digest(code),Date.now()); + if (!row) throw badRequest('邀请码无效、已过期或已用完','INVITE_INVALID'); + this.db.run('UPDATE registration_invites SET used_count=used_count+1 WHERE id=?',row.id); + } +} diff --git a/server/src/services/tokens.ts b/server/src/services/tokens.ts index 61e0701..31043ca 100644 --- a/server/src/services/tokens.ts +++ b/server/src/services/tokens.ts @@ -8,6 +8,7 @@ export interface TokenPayload { type: 'access'; iat: number; exp: number; + ver?: number; } /** @@ -18,11 +19,11 @@ export interface TokenPayload { */ export function signAccessToken( config: AppConfig, - user: { id: string; role: 'admin' | 'member' }, + user: { id: string; role: 'admin' | 'member'; authVersion?: number }, ): { token: string; expiresAt: number } { const issuedAt = Math.floor(Date.now() / 1000); const expiresAt = issuedAt + config.accessTokenTtl; - const payload: TokenPayload = { sub: user.id, role: user.role, type: 'access', iat: issuedAt, exp: expiresAt }; + const payload: TokenPayload = { sub: user.id, role: user.role, type: 'access', iat: issuedAt, exp: expiresAt, ver: user.authVersion ?? 0 }; const header = base64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); const body = base64url(JSON.stringify(payload)); const signature = sign(config.jwtSecret, `${header}.${body}`); diff --git a/server/src/services/users.ts b/server/src/services/users.ts index d2e18e4..ecdc4f0 100644 --- a/server/src/services/users.ts +++ b/server/src/services/users.ts @@ -4,6 +4,7 @@ import type { AppConfig } from '../config/index.ts'; import { hashPassword, verifyPassword } from './passwords.ts'; import { hashToken, newRefreshToken, signAccessToken } from './tokens.ts'; import { conflict, forbidden, notFound, unauthorized } from '../lib/errors.ts'; +import { RegistrationService } from './registration.ts'; export type Role = 'admin' | 'member'; @@ -16,6 +17,7 @@ export interface UserRow { disabled: number; created_at: number; updated_at: number; + auth_version?: number; } export interface PublicUser { @@ -47,10 +49,10 @@ export class UserService { return row?.n ?? 0; } - list(): PublicUser[] { + list(): Array { return this.db .all('SELECT * FROM users ORDER BY created_at ASC') - .map(toPublicUser); + .map(row=>({...toPublicUser(row),disabled:!!row.disabled})); } byId(id: string): UserRow | undefined { @@ -64,7 +66,7 @@ export class UserService { */ canRegisterPublicly(): boolean { if (this.count() === 0) return true; - return process.env.ALLOW_REGISTRATION === 'true'; + return new RegistrationService(this.db).mode() !== 'closed'; } async create(input: { @@ -72,40 +74,45 @@ export class UserService { password: string; displayName?: string; role?: Role; - }): Promise { + }, admission?: () => void): Promise { const username = input.username.trim(); if (username.length < 3) throw conflict('username must be at least 3 characters', 'USERNAME_TOO_SHORT'); if (input.password.length < 8) throw conflict('password must be at least 8 characters', 'PASSWORD_TOO_SHORT'); const existing = this.db.get<{ id: string }>('SELECT id FROM users WHERE username = ? COLLATE NOCASE', username); if (existing) throw conflict('username already taken', 'USERNAME_TAKEN'); - const now = Date.now(); - const row: UserRow = { - id: randomUUID(), - username, - display_name: input.displayName?.trim() ?? '', - password_hash: await hashPassword(input.password), - // The first user to register owns the instance. - role: input.role ?? (this.count() === 0 ? 'admin' : 'member'), - disabled: 0, - created_at: now, - updated_at: now, - }; - this.db.run( - `INSERT INTO users (id, username, display_name, password_hash, role, disabled, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?)`, - row.id, row.username, row.display_name, row.password_hash, row.role, row.disabled, row.created_at, row.updated_at, - ); - // A new account must see the whole shared library straight away. Without - // this, a book scanned before the account existed would stay invisible - // until its file happened to change. - this.db.run( - `INSERT OR IGNORE INTO user_books (user_id, book_id, added_at) - SELECT ?, b.id, ? FROM books b - WHERE EXISTS (SELECT 1 FROM book_files f WHERE f.book_id = b.id AND f.missing = 0)`, - row.id, now, - ); - return toPublicUser(row); + const passwordHash = await hashPassword(input.password); + return this.db.transaction(() => { + admission?.(); + if (this.db.get('SELECT id FROM users WHERE username = ? COLLATE NOCASE', username)) throw conflict('username already taken', 'USERNAME_TAKEN'); + const now = Date.now(); + const row: UserRow = { + id: randomUUID(), + username, + display_name: input.displayName?.trim() ?? '', + password_hash: passwordHash, + // The first user to register owns the instance. + role: input.role ?? (this.count() === 0 ? 'admin' : 'member'), + disabled: 0, + created_at: now, + updated_at: now, + }; + this.db.run( + `INSERT INTO users (id, username, display_name, password_hash, role, disabled, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?)`, + row.id, row.username, row.display_name, row.password_hash, row.role, row.disabled, row.created_at, row.updated_at, + ); + // A new account must see the whole shared library straight away. Without + // this, a book scanned before the account existed would stay invisible + // until its file happened to change. + this.db.run( + `INSERT OR IGNORE INTO user_books (user_id, book_id, added_at) + SELECT ?, b.id, ? FROM books b + WHERE EXISTS (SELECT 1 FROM book_files f WHERE f.book_id = b.id AND f.missing = 0)`, + row.id, now, + ); + return toPublicUser(row); + }); } /** @@ -139,7 +146,7 @@ export class UserService { if (!row || !ok) throw unauthorized('invalid username or password', 'BAD_CREDENTIALS'); if (row.disabled === 1) throw forbidden('account disabled', 'ACCOUNT_DISABLED'); - const access = signAccessToken(this.config, { id: row.id, role: row.role }); + const access = signAccessToken(this.config, { id: row.id, role: row.role, authVersion: row.auth_version ?? 0 }); const refresh = newRefreshToken(); const refreshExpiresAt = Date.now() + this.config.refreshTokenTtl * 1000; this.db.run( @@ -177,7 +184,7 @@ export class UserService { this.db.run('UPDATE refresh_tokens SET revoked_at = ? WHERE id = ?', Date.now(), row.id); - const access = signAccessToken(this.config, { id: user.id, role: user.role }); + const access = signAccessToken(this.config, { id: user.id, role: user.role, authVersion: user.auth_version ?? 0 }); const next = newRefreshToken(); const expiresAt = Date.now() + this.config.refreshTokenTtl * 1000; this.db.run( @@ -214,7 +221,7 @@ export class UserService { } if (nextPassword.length < 8) throw conflict('password must be at least 8 characters', 'PASSWORD_TOO_SHORT'); this.db.run( - 'UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?', + 'UPDATE users SET password_hash = ?, auth_version = auth_version + 1, updated_at = ? WHERE id = ?', await hashPassword(nextPassword), Date.now(), userId, ); // A password change must invalidate every other device's session. @@ -223,10 +230,20 @@ export class UserService { setDisabled(userId: string, disabled: boolean): void { this.db.run('UPDATE users SET disabled = ?, updated_at = ? WHERE id = ?', disabled ? 1 : 0, Date.now(), userId); - if (disabled) this.revokeAll(userId); + if (disabled) { this.revokeAll(userId); this.db.run('UPDATE users SET auth_version=auth_version+1 WHERE id=?',userId); } } setRole(userId: string, role: Role): void { this.db.run('UPDATE users SET role = ?, updated_at = ? WHERE id = ?', role, Date.now(), userId); } + + async resetPassword(userId: string, password: string): Promise { + if (!this.byId(userId)) throw notFound('用户不存在'); + if (password.length < 8 || password.length > 1024) throw conflict('密码须为 8–1024 位','PASSWORD_TOO_SHORT'); + const hash = await hashPassword(password); + this.db.transaction(()=>{ + this.db.run('UPDATE users SET password_hash=?,auth_version=auth_version+1,updated_at=? WHERE id=?',hash,Date.now(),userId); + this.revokeAll(userId); + }); + } } diff --git a/server/test/user-management.test.ts b/server/test/user-management.test.ts new file mode 100644 index 0000000..5e02e82 --- /dev/null +++ b/server/test/user-management.test.ts @@ -0,0 +1,59 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp,mkdir,rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Db } from '../src/db/index.ts'; +import { buildApp } from '../src/http/app.ts'; +import { UserService } from '../src/services/users.ts'; +import { RegistrationService } from '../src/services/registration.ts'; +import type { AppContext } from '../src/http/context.ts'; +import type { AppConfig } from '../src/config/index.ts'; + +test('admin users, persisted registration policy, bounded invites and immediate session revocation',async t=>{ + const root=await mkdtemp(join(tmpdir(),'reader-users-'));await mkdir(join(root,'books'));await mkdir(join(root,'data')); + const config={booksDir:join(root,'books'),dataDir:join(root,'data'),jwtSecret:'isolated-admin-test-secret',accessTokenTtl:604800,refreshTokenTtl:31536000,logLevel:'silent',corsOrigins:[],webDir:'',publicUrl:''} as unknown as AppConfig; + const db=new Db(join(root,'data','reader.db')),users=new UserService(db,config); + const app=buildApp({db,config,users} as AppContext); + t.after(async()=>{await app.close();db.close();await rm(root,{recursive:true,force:true});}); + const register=(username:string,inviteCode?:string)=>app.inject({method:'POST',url:'/api/v1/auth/register',payload:{username,password:'password123',inviteCode}}); + const first=await register('owner');assert.equal(first.statusCode,201); + const owner=first.json().session,headers={authorization:'Bearer '+owner.accessToken}; + const setting=(mode:string)=>app.inject({method:'PATCH',url:'/api/v1/admin/registration',headers,payload:{mode}}); + assert.equal((await setting('closed')).statusCode,200);assert.equal((await register('blocked')).statusCode,400); + assert.equal((await setting('open')).statusCode,200);assert.equal((await register('public-member')).statusCode,201); + assert.equal((await setting('invite')).statusCode,200); + assert.equal(new RegistrationService(db).mode(),'invite'); + const reopened=new Db(join(root,'data','reader.db'));assert.equal(new RegistrationService(reopened).mode(),'invite');reopened.close(); + const instance=(await app.inject({url:'/api/v1/instance'})).json();assert.equal(instance.invitationRequired,true);assert.equal(instance.registrationOpen,true); + assert.equal((await register('missing-invite')).json().error.code,'INVITE_REQUIRED'); + const minted=await app.inject({method:'POST',url:'/api/v1/admin/invites',headers,payload:{label:'single',days:1,maxUses:1}}); + assert.equal(minted.statusCode,201);const {code,invite}=minted.json(); + assert.equal((await register('owner',code)).statusCode,409); + assert.equal(new RegistrationService(db).invites()[0]!.usedCount,0); + const raced=await Promise.all([register('invite-one',code),register('invite-two',code)]); + assert.deepEqual(raced.map(r=>r.statusCode).sort(),[201,400]); + const member=raced.find(r=>r.statusCode===201)!.json().session,memberHeaders={authorization:'Bearer '+member.accessToken}; + assert.equal(member.user.role,'member'); + const invites=(await app.inject({url:'/api/v1/admin/registration',headers})).json(); + assert.equal(invites.invites.find((i:{id:string})=>i.id===invite.id).usedCount,1); + assert.ok(!JSON.stringify(invites).includes(code));assert.ok(!JSON.stringify(invites).includes('code_hash')); + for (const [method,url,payload] of [['GET','/api/v1/admin/users',undefined],['POST','/api/v1/admin/users',{username:'attacker',password:'password123'}],['PATCH','/api/v1/admin/registration',{mode:'open'}],['POST','/api/v1/admin/invites',{}],['POST',`/api/v1/admin/users/${owner.user.id}/password`,{password:'password456'}]] as const) { + const result=await app.inject({method,url,headers:memberHeaders,...(payload?{payload}:{})});assert.equal(result.statusCode,403); + } + assert.equal((await app.inject({method:'PATCH',url:`/api/v1/admin/users/${owner.user.id}`,headers,payload:{role:'member'}})).json().error.code,'SELF_LOCKOUT'); + assert.equal((await app.inject({method:'PATCH',url:`/api/v1/admin/users/${member.user.id}`,headers,payload:{disabled:'false'}})).statusCode,400); + const reset=await app.inject({method:'POST',url:`/api/v1/admin/users/${member.user.id}/password`,headers,payload:{password:'newpassword123'}});assert.equal(reset.statusCode,200); + assert.equal((await app.inject({url:'/api/v1/auth/me',headers:memberHeaders})).statusCode,401); + assert.equal((await app.inject({method:'POST',url:'/api/v1/auth/refresh',payload:{refreshToken:member.refreshToken}})).statusCode,401); + const login=await app.inject({method:'POST',url:'/api/v1/auth/login',payload:{username:member.user.username,password:'newpassword123'}});assert.equal(login.statusCode,200); + const newHeaders={authorization:'Bearer '+login.json().accessToken}; + assert.equal((await app.inject({method:'PATCH',url:`/api/v1/admin/users/${member.user.id}`,headers,payload:{disabled:true}})).statusCode,200); + assert.equal((await app.inject({url:'/api/v1/auth/me',headers:newHeaders})).statusCode,403); + assert.equal((await app.inject({method:'PATCH',url:`/api/v1/admin/users/${member.user.id}`,headers,payload:{disabled:false}})).statusCode,200); + assert.equal((await app.inject({url:'/api/v1/auth/me',headers:newHeaders})).statusCode,401); + const disabled=new RegistrationService(db).create('disabled',1,1);new RegistrationService(db).disable(disabled.invite.id); + assert.equal((await register('disabled-invite',disabled.code)).json().error.code,'INVITE_INVALID'); + const expired=new RegistrationService(db).create('expired',1,1);db.run('UPDATE registration_invites SET expires_at=0 WHERE id=?',expired.invite.id); + assert.equal((await register('expired-invite',expired.code)).json().error.code,'INVITE_INVALID'); +}); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index c31296e..c7d43ef 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -212,11 +212,12 @@ export class ReaderApi { return this.get('/api/v1/instance'); } - async register(username: string, password: string, displayName?: string): Promise { + async register(username: string, password: string, displayName?: string, inviteCode?: string): Promise { const { session } = await this.call<{ user: User; session: Session }>('/api/v1/auth/register', 'POST', { username, password, ...(displayName ? { displayName } : {}), + ...(inviteCode ? { inviteCode } : {}), }); await this.setSession(session); return session; @@ -233,6 +234,15 @@ export class ReaderApi { return result.user; } + async adminUsers(): Promise<{users:import('./types.ts').ManagedUser[]}> { return this.get('/api/v1/admin/users'); } + async adminCreateUser(input:{username:string;password:string;displayName:string;role:'admin'|'member'}): Promise { return this.call('/api/v1/admin/users','POST',input); } + async adminUpdateUser(id:string,input:{disabled?:boolean;role?:'admin'|'member'}): Promise { return this.call('/api/v1/admin/users/'+encodeURIComponent(id),'PATCH',input); } + async adminResetPassword(id:string,password:string): Promise { return this.call('/api/v1/admin/users/'+encodeURIComponent(id)+'/password','POST',{password}); } + async registrationSettings(): Promise { return this.get('/api/v1/admin/registration'); } + async setRegistrationMode(mode:import('./types.ts').RegistrationSettings['mode']): Promise { return this.call('/api/v1/admin/registration','PATCH',{mode}); } + async createInvite(input:{label:string;maxUses:number;days:number}): Promise<{code:string}> { return this.call('/api/v1/admin/invites','POST',input); } + async disableInvite(id:string): Promise { return this.call('/api/v1/admin/invites/'+encodeURIComponent(id),'DELETE'); } + async changePassword(currentPassword: string, newPassword: string): Promise { await this.call('/api/v1/auth/password', 'POST', { currentPassword, newPassword }); // The server revokes every session on a password change, including this one. diff --git a/web/src/api/types.ts b/web/src/api/types.ts index d1b7f75..dafefde 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -24,6 +24,8 @@ export interface User { } export interface InstanceInfo { + registrationMode?: 'closed' | 'open' | 'invite'; + invitationRequired?: boolean; name: string; apiVersion: number; registrationOpen: boolean; @@ -337,6 +339,10 @@ export interface ApiErrorBody { error: { code: string; message: string }; } +export interface ManagedUser extends User { disabled: boolean } +export interface RegistrationInvite { id:string; label:string; maxUses:number; usedCount:number; expiresAt:number; disabled:boolean; createdAt:number } +export interface RegistrationSettings { mode:'closed'|'open'|'invite'; invites:RegistrationInvite[] } + export interface BookSearchPage { hits: Array<{title: string; excerpt: string; anchor: {sectionId: string; start: number; end: number; quote: string; prefix: string; suffix: string}}>; scanned: number; total: number; limited: boolean; nextCursor?: string; diff --git a/web/src/styles/reader.css b/web/src/styles/reader.css index 4bfd93b..b2f3b40 100644 --- a/web/src/styles/reader.css +++ b/web/src/styles/reader.css @@ -2750,6 +2750,17 @@ book-content { .login-session-hint { text-align: center; font-size: .72rem; color: var(--reader-muted); line-height: 1.6; margin: .85rem 0 1.4rem; } .login-switch { display: flex; align-items: center; justify-content: center; gap: .25rem; font-size: .85rem; color: var(--reader-muted); } .login-switch button { min-height: 44px; border: 0; padding: .5rem; background: transparent; color: var(--reader-accent); font: inherit; cursor: pointer; } +.user-management { display: grid; gap: 1rem; } +.user-manager-actions { display: flex; flex-wrap: wrap; gap: .5rem; } +.user-manager-actions input { flex: 1; min-width: 120px; } +.user-manager-form { display: grid; gap: .85rem; padding: 1rem; border: 1px solid var(--reader-line); border-radius: .75rem; } +.user-manager-form label, .user-manager-mode { display: grid; gap: .4rem; } +.user-management input, .user-management select { min-width: 0; width: 100%; min-height: 44px; font-size: 16px; padding: .65rem .75rem; border: 1px solid var(--reader-line); border-radius: .65rem; background: var(--reader-paper); color: var(--reader-ink); } +.user-management .extension-tabs button[aria-pressed=true] { color: var(--reader-accent); border-bottom-color: var(--reader-accent); } +.user-manager-list { list-style: none; padding: 0; margin: 0; display: grid; gap: .75rem; } +.user-manager-list li { padding: 1rem; border: 1px solid var(--reader-line); border-radius: .75rem; overflow-wrap: anywhere; } +.user-manager-list p { margin: .5rem 0; font-size: .85rem; } +.user-management summary { cursor: pointer; min-height: 44px; padding-block: .65rem; } @media (max-width: 480px) { .login-screen { padding: 1.5rem 1rem; } .login-card { padding: 1.5rem; } } .notice { diff --git a/web/src/ui/login-screen.tsx b/web/src/ui/login-screen.tsx index 6043754..c5ae6c0 100644 --- a/web/src/ui/login-screen.tsx +++ b/web/src/ui/login-screen.tsx @@ -17,6 +17,7 @@ interface LoginState { username: string; password: string; displayName: string; + inviteCode: string; registering: boolean; busy: boolean; error: string; @@ -55,6 +56,7 @@ export class LoginScreen { username: '', password: '', displayName: '', + inviteCode: '', registering: false, busy: false, error: '', @@ -153,16 +155,18 @@ export class LoginScreen { /> ) : null} -

登录状态自动保持,无需每天重新登录

- {(state.registering || !state.instance || state.instance.registrationOpen || state.instance.userCount === 0) &&
+
{state.registering ? '已有账号?' : '还没有账号?'} -
} +
); } @@ -246,7 +250,7 @@ export class LoginScreen { this.patch({ error: '', busy: true }); try { if (this.state.registering) { - await this.options.api.register(username, password, this.state.displayName.trim() || undefined); + await this.options.api.register(username, password, this.state.displayName.trim() || undefined, this.state.inviteCode.trim() || undefined); } else { await this.options.api.login(username, password); } @@ -277,6 +281,7 @@ function noticeForInstance(instance: InstanceInfo): Partial { return { notice: '这是一个全新的实例,第一个注册的账号会成为管理员。', noticeKind: 'info', registering: true }; } if (instance.registrationOpen) { + if (instance.invitationRequired) return { notice:'此书房需要邀请码注册,请向管理员获取。',noticeKind:'info' }; return { notice: `已有 ${instance.userCount} 个账号,此实例开放注册。`, noticeKind: 'info' }; } return { notice: '', noticeKind: 'info' }; diff --git a/web/src/ui/shelf-screen.tsx b/web/src/ui/shelf-screen.tsx index 6871beb..0a41d6a 100644 --- a/web/src/ui/shelf-screen.tsx +++ b/web/src/ui/shelf-screen.tsx @@ -9,6 +9,7 @@ import { mountUI } from './mount.ts'; import { DialogView, type Dialog, type DialogAnswer } from './dialog.tsx'; import { DENSITY_LABELS, SHELF_SORTS, ShelfSettingsPanel } from './shelf-settings.tsx'; import { OpdsAccess } from './opds-access.tsx'; +import { UserManagement } from './user-management.tsx'; import { isLocalSort, shelfOrder, shelfServerSort, sortBooks, type ReadingTimes } from './shelf-order.ts'; import { describeShelfAction } from './shelf-membership.ts'; import { Icon, IconButton, IconTextButton } from './toolkit.tsx'; @@ -173,6 +174,7 @@ interface ShelfState { status: string; settingsOpen: boolean; opdsOpen: boolean; + usersOpen: boolean; refreshing: boolean; /** * True until the first frame that has *anything* to show. @@ -307,6 +309,7 @@ export class ShelfScreen { status: '', settingsOpen: false, opdsOpen: false, + usersOpen: false, refreshing: false, bootstrapping: true, revision: 0, @@ -992,7 +995,9 @@ export class ShelfScreen { onPatch={(patch) => this.applySettings(patch)} onClose={() => this.patch({ settingsOpen: false })} onOpenExternalReader={() => this.patch({ settingsOpen: false, opdsOpen: true })} + {...(this.options.api.currentSession()?.user.role === 'admin' ? {onOpenUsers:()=>this.patch({settingsOpen:false,usersOpen:true})} : {})} /> + {state.usersOpen && this.patch({usersOpen:false,settingsOpen:true})} />} {state.opdsOpen && this.patch({ opdsOpen: false, settingsOpen: true })} />} {state.dialog ? ( diff --git a/web/src/ui/shelf-settings.tsx b/web/src/ui/shelf-settings.tsx index 2cbc21f..233a469 100644 --- a/web/src/ui/shelf-settings.tsx +++ b/web/src/ui/shelf-settings.tsx @@ -51,6 +51,7 @@ export interface ShelfSettingsOptions { onPatch(patch: Partial): void; onClose(): void; onOpenExternalReader(): void; + onOpenUsers?: () => void; } export function ShelfSettingsPanel({ @@ -59,6 +60,7 @@ export function ShelfSettingsPanel({ onPatch, onClose, onOpenExternalReader, + onOpenUsers, }: ShelfSettingsOptions & { open: boolean }): JSX.Element { const panel = useRef(null); useLayoutEffect(() => { @@ -112,6 +114,7 @@ export function ShelfSettingsPanel({ 外部阅读器
通过 OPDS,让其他阅读器只读访问你的书架。
+ {onOpenUsers && <>实例管理} ); diff --git a/web/src/ui/user-management.tsx b/web/src/ui/user-management.tsx new file mode 100644 index 0000000..728ea6f --- /dev/null +++ b/web/src/ui/user-management.tsx @@ -0,0 +1,44 @@ +import { useEffect, useState } from './vendor/preact.ts'; +import type { ReaderApi } from '../api/client.ts'; +import type { ManagedUser, RegistrationSettings } from '../api/types.ts'; +import { Modal } from './modal.tsx'; + +export function UserManagement({api,onClose}:{api:ReaderApi;onClose():void}) { + const [users,setUsers]=useState([]),[settings,setSettings]=useState(null); + const [tab,setTab]=useState<'users'|'registration'>('users'),[busy,setBusy]=useState(false),[error,setError]=useState(''),[message,setMessage]=useState(''); + const [form,setForm]=useState(false),[username,setUsername]=useState(''),[password,setPassword]=useState(''),[displayName,setDisplayName]=useState(''),[role,setRole]=useState<'admin'|'member'>('member'); + const [reset,setReset]=useState(null),[confirmation,setConfirmation]=useState<{text:string;run:()=>Promise}|null>(null); + const [label,setLabel]=useState(''),[maxUses,setMaxUses]=useState(1),[days,setDays]=useState(7),[code,setCode]=useState(''); + const [query,setQuery]=useState(''); + async function load() { const [list,config]=await Promise.all([api.adminUsers(),api.registrationSettings()]);setUsers(list.users);setSettings(config); } + async function work(action:()=>Promise,success='已保存') { if(busy)return;setBusy(true);setError('');setMessage('');try{await action();await load();setConfirmation(null);setMessage(success);}catch(e){setError(e instanceof Error?e.message:'操作失败');}finally{setBusy(false);} } + useEffect(()=>{void work(async()=>{},'');},[]); + const self=api.currentSession()?.user.id; + return
+ + {error &&

{error}

}{message &&

{message}

} + {confirmation &&

{confirmation.text}

} + {tab==='users' ? <> +
setQuery(e.currentTarget.value)} />
+ {form &&
{e.preventDefault();void work(async()=>{await api.adminCreateUser({username:username.trim(),password,displayName:displayName.trim(),role});setForm(false);setUsername('');setPassword('');setDisplayName('');setRole('member');},'用户已创建');}}> + + + + + +
} + {reset &&
{e.preventDefault();void work(async()=>{await api.adminResetPassword(reset.id,password);setReset(null);setPassword('');},'密码已重置,旧会话已失效');}}>

重置 {reset.username} 的密码,所有旧会话将立即失效。

} +

共 {users.length} 个账号

    {users.filter(user=>(user.username+' '+user.displayName).toLowerCase().includes(query.toLowerCase())).map(user=>
  • +
    {user.displayName} @{user.username}{user.id===self?' · 当前账号':''}
    +

    {user.role==='admin'?'管理员':'普通用户'} · {user.disabled?'已停用':'正常'}

    + {user.id!==self &&
    } +
  • )}
+ : settings && <> + +

关闭注册时仍可由管理员创建账号。设置立即生效并在重启后保留。

+
创建邀请码
{e.preventDefault();void work(async()=>{setCode((await api.createInvite({label,maxUses,days})).code);},'邀请码已创建');}}>
+ {code && } +
    {settings.invites.map(invite=>
  • {invite.label||'未备注邀请码'}

    已用 {invite.usedCount} / {invite.maxUses} 次 · {invite.disabled?'已停用':invite.expiresAt<=Date.now()?'已过期':invite.usedCount>=invite.maxUses?'已用完':'可用'}

    有效至 {new Date(invite.expiresAt).toLocaleString()}

    {!invite.disabled && }
  • )}
+ } +
; +} diff --git a/web/tools/ui-review/p1-reading.mjs b/web/tools/ui-review/p1-reading.mjs index 41b6ee4..304aa0c 100644 --- a/web/tools/ui-review/p1-reading.mjs +++ b/web/tools/ui-review/p1-reading.mjs @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'; import { join, resolve, relative, isAbsolute } from 'node:path'; import { chromium } from 'playwright'; import JSZip from 'jszip'; +import { reviewUserManagement } from './user-management.mjs'; import { openDatabase } from '../../../server/src/db/index.ts'; import { buildApp } from '../../../server/src/http/app.ts'; import { Scanner } from '../../../server/src/indexer/scanner.ts'; @@ -41,6 +42,7 @@ try { await context.route('**/api/v1/auth/me',route=>route.fulfill({status:503,contentType:'application/json',body:JSON.stringify({error:{code:'TEMPORARY',message:'retry later'}})})); await page.reload();await page.locator('.shelf-screen').waitFor();await context.unroute('**/api/v1/auth/me'); await page.reload();await page.locator('.shelf-screen').waitFor(); + await reviewUserManagement(context,page,base,shots); await page.goto(base+'/#/book/'+epub.id);await page.locator('book-content h1').waitFor();assert.ok(await button('设置').evaluate(el=>el.getBoundingClientRect().bottom<=innerHeight));await button('工具').click(); for(const width of [320,390,1280]){await page.setViewportSize({width,height:844});await page.screenshot({path:join(shots,'tools-'+width+'.png')});assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false);} await button('笔记').click();assert.equal(await page.locator('.reading-note-editor').count(),0); diff --git a/web/tools/ui-review/user-management.mjs b/web/tools/ui-review/user-management.mjs new file mode 100644 index 0000000..ab38c93 --- /dev/null +++ b/web/tools/ui-review/user-management.mjs @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import { join } from 'node:path'; + +export async function reviewUserManagement(context,page,base,shots) { + const button=name=>page.getByRole('button',{name,exact:true}); + await page.getByRole('button',{name:/书架设置/}).click();await button('用户管理').click(); + const dialog=page.getByRole('dialog',{name:'用户管理'}); + await page.getByText('共 1 个账号',{exact:true}).waitFor(); + await button('新增用户').click();await dialog.getByLabel('用户名',{exact:true}).fill('family-ui');await dialog.getByLabel('初始密码').fill('password123');await dialog.getByLabel('显示名',{exact:true}).fill('家人');await button('创建账号').click(); + const member=dialog.locator('li').filter({hasText:'@family-ui'});await member.waitFor(); + await member.getByRole('button',{name:'设为管理员',exact:true}).click();await button('确认操作').click();await member.getByRole('button',{name:'移除管理员',exact:true}).waitFor(); + await member.getByRole('button',{name:'移除管理员',exact:true}).click();await button('确认操作').click();await member.getByRole('button',{name:'设为管理员',exact:true}).waitFor(); + await member.getByRole('button',{name:'停用',exact:true}).click();await button('确认操作').click();await member.getByRole('button',{name:'启用',exact:true}).waitFor(); + await member.getByRole('button',{name:'启用',exact:true}).click();await button('确认操作').click();await member.getByRole('button',{name:'停用',exact:true}).waitFor(); + await member.getByRole('button',{name:'重置密码',exact:true}).click();await dialog.getByLabel('新密码',{exact:true}).fill('password456');await button('确认重置密码').click();await page.getByText('密码已重置,旧会话已失效',{exact:true}).waitFor(); + await page.screenshot({path:join(shots,'admin-users-390.png')}); + await button('注册与邀请').click();await dialog.getByLabel('注册方式').selectOption('invite');await button('确认操作').click();await page.getByText('已保存',{exact:true}).waitFor(); + await page.getByText('创建邀请码',{exact:true}).click();await dialog.getByLabel('备注',{exact:true}).fill('家人注册');await button('生成邀请码').click();await dialog.getByLabel('新邀请码',{exact:true}).waitFor(); + const code=await dialog.getByLabel('新邀请码',{exact:true}).inputValue();assert.ok(code.length>=20); + await page.screenshot({path:join(shots,'admin-registration-390.png')}); + assert.equal(await dialog.evaluate(el=>el.scrollWidth>el.clientWidth),false); + const newcomer=await context.browser().newContext({viewport:{width:390,height:844}}), visitor=await newcomer.newPage(); + try { + await visitor.goto(base);await visitor.getByRole('button',{name:'注册新账号',exact:true}).click();await visitor.getByLabel('邀请码',{exact:true}).waitFor(); + await visitor.getByLabel('用户名',{exact:true}).fill('invited-ui');await visitor.getByLabel('密码',{exact:true}).fill('password123');await visitor.getByLabel('邀请码',{exact:true}).fill(code); + await visitor.getByRole('button',{name:'注册并登录',exact:true}).click();await visitor.locator('.shelf-screen').waitFor(); + await visitor.getByRole('button',{name:/书架设置/}).click();assert.equal(await visitor.getByRole('button',{name:'用户管理',exact:true}).count(),0); + } finally {await newcomer.close();} + await dialog.getByLabel('注册方式').selectOption('closed');await button('确认操作').click();await page.getByText('已保存',{exact:true}).waitFor(); + await button('关闭弹窗').click();await page.getByRole('dialog',{name:'书架设置'}).getByRole('button',{name:'关闭',exact:true}).click(); + const anonymous=await context.browser().newContext(), login=await anonymous.newPage(); + try {await login.goto(base);await login.getByRole('button',{name:'注册新账号',exact:true}).click();await login.getByText('此书房暂未开放注册,请联系管理员创建账号或开放注册。',{exact:true}).waitFor();assert.equal(await login.getByRole('button',{name:'注册并登录',exact:true}).isDisabled(),true);}finally{await anonymous.close();} +}