feat(admin): manage users and invite-based registration

This commit is contained in:
hectorqin committed 2026-09-24 17:19:48 +08:00
1 parent 316dd72611
commit 06b3d09e22
20 files changed
+358 -52

No files matched your search

+20 -3
View File
@@ -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`
+1 -1
View File
@@ -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` |
+1 -1
View File
@@ -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` |
+16
View File
@@ -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,
+1
View File
@@ -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);
};
}
+29 -3
View File
@@ -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(),
};
});
+8 -2
View File
@@ -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() };
+44
View File
@@ -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<max_uses AND expires_at>?',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);
}
}
+3 -2
View File
@@ -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}`);
+52 -35
View File
@@ -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<PublicUser & {disabled:boolean}> {
return this.db
.all<UserRow>('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<PublicUser> {
}, admission?: () => void): Promise<PublicUser> {
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<void> {
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);
});
}
}
+59
View File
@@ -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');
});
+11 -1
View File
@@ -212,11 +212,12 @@ export class ReaderApi {
return this.get<InstanceInfo>('/api/v1/instance');
}
async register(username: string, password: string, displayName?: string): Promise<Session> {
async register(username: string, password: string, displayName?: string, inviteCode?: string): Promise<Session> {
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<unknown> { return this.call('/api/v1/admin/users','POST',input); }
async adminUpdateUser(id:string,input:{disabled?:boolean;role?:'admin'|'member'}): Promise<unknown> { return this.call('/api/v1/admin/users/'+encodeURIComponent(id),'PATCH',input); }
async adminResetPassword(id:string,password:string): Promise<unknown> { return this.call('/api/v1/admin/users/'+encodeURIComponent(id)+'/password','POST',{password}); }
async registrationSettings(): Promise<import('./types.ts').RegistrationSettings> { return this.get('/api/v1/admin/registration'); }
async setRegistrationMode(mode:import('./types.ts').RegistrationSettings['mode']): Promise<unknown> { 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<unknown> { return this.call('/api/v1/admin/invites/'+encodeURIComponent(id),'DELETE'); }
async changePassword(currentPassword: string, newPassword: string): Promise<void> {
await this.call('/api/v1/auth/password', 'POST', { currentPassword, newPassword });
// The server revokes every session on a password change, including this one.
+6
View File
@@ -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;
+11
View File
@@ -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 {
+9 -4
View File
@@ -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 {
/>
</div>
) : null}
<button type="submit" className="button primary login-submit" disabled={state.busy}>
{state.registering && state.instance?.invitationRequired && <div className="field"><label htmlFor="login-invite">邀请码</label><input id="login-invite" autoComplete="off" value={state.inviteCode} placeholder="填写管理员提供的邀请码" disabled={state.busy} onInput={event=>this.patch({inviteCode:event.currentTarget.value})} /></div>}
{state.registering && state.instance && !state.instance.registrationOpen && <p className="notice">此书房暂未开放注册,请联系管理员创建账号或开放注册。</p>}
<button type="submit" className="button primary login-submit" disabled={state.busy || (state.registering && !!state.instance && !state.instance.registrationOpen)}>
{state.registering ? '注册并登录' : '登录'}
</button>
<p className="login-session-hint">登录状态自动保持,无需每天重新登录</p>
{(state.registering || !state.instance || state.instance.registrationOpen || state.instance.userCount === 0) && <div className="login-switch">
<div className="login-switch">
<span>{state.registering ? '已有账号?' : '还没有账号?'}</span>
<button type="button" disabled={state.busy} onClick={() => this.toggleRegister()}>
{state.registering ? '返回登录' : '注册新账号'}
</button>
</div>}
</div>
</form>
);
}
@@ -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<LoginState> {
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' };
+5
View File
@@ -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 && <UserManagement api={this.options.api} onClose={()=>this.patch({usersOpen:false,settingsOpen:true})} />}
{state.opdsOpen && <OpdsAccess api={this.options.api} onSignedOut={this.options.onSignedOut}
onClose={() => this.patch({ opdsOpen: false, settingsOpen: true })} />}
{state.dialog ? (
+3
View File
@@ -51,6 +51,7 @@ export interface ShelfSettingsOptions {
onPatch(patch: Partial<AppSettings>): 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<HTMLDivElement>(null);
useLayoutEffect(() => {
@@ -112,6 +114,7 @@ export function ShelfSettingsPanel({
<SectionTitle>外部阅读器</SectionTitle>
<div className="notice">通过 OPDS,让其他阅读器只读访问你的书架。</div>
<Button onClick={onOpenExternalReader}>连接外部阅读器</Button>
{onOpenUsers && <><SectionTitle>实例管理</SectionTitle><Button onClick={onOpenUsers}>用户管理</Button></>}
</div>
</div>
);
+44
View File
@@ -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<ManagedUser[]>([]),[settings,setSettings]=useState<RegistrationSettings|null>(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<ManagedUser|null>(null),[confirmation,setConfirmation]=useState<{text:string;run:()=>Promise<unknown>}|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<unknown>,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 <Modal title="用户管理" busy={busy} onClose={onClose}><div className="user-management source-modal-content">
<nav className="extension-tabs" aria-label="用户管理分类"><button aria-pressed={tab==='users'} onClick={()=>setTab('users')}>用户账号</button><button aria-pressed={tab==='registration'} onClick={()=>setTab('registration')}>注册与邀请</button></nav>
{error && <p role="alert" className="notice">{error}</p>}{message && <p role="status">{message}</p>}
{confirmation && <section className="notice" role="alert"><p>{confirmation.text}</p><button className="button primary" disabled={busy} onClick={()=>void work(confirmation.run)}>确认操作</button> <button className="button" disabled={busy} onClick={()=>setConfirmation(null)}>取消操作</button></section>}
{tab==='users' ? <>
<div className="user-manager-actions"><input aria-label="筛选用户" placeholder="搜索用户名或显示名" value={query} onInput={e=>setQuery(e.currentTarget.value)} /><button className="button primary" disabled={busy} onClick={()=>{setForm(!form);setReset(null);setPassword('');}}>新增用户</button></div>
{form && <form className="user-manager-form" onSubmit={e=>{e.preventDefault();void work(async()=>{await api.adminCreateUser({username:username.trim(),password,displayName:displayName.trim(),role});setForm(false);setUsername('');setPassword('');setDisplayName('');setRole('member');},'用户已创建');}}>
<label>用户名<input required minLength={3} maxLength={100} value={username} onInput={e=>setUsername(e.currentTarget.value)} /></label>
<label>初始密码<input required type="password" autoComplete="new-password" minLength={8} maxLength={1024} value={password} onInput={e=>setPassword(e.currentTarget.value)} /></label>
<label>显示名<input maxLength={100} value={displayName} onInput={e=>setDisplayName(e.currentTarget.value)} /></label>
<label>账号角色<select value={role} onChange={e=>setRole(e.currentTarget.value as 'admin'|'member')}><option value="member">普通用户</option><option value="admin">管理员</option></select></label>
<button className="button primary" disabled={busy}>创建账号</button>
</form>}
{reset && <form className="user-manager-form" onSubmit={e=>{e.preventDefault();void work(async()=>{await api.adminResetPassword(reset.id,password);setReset(null);setPassword('');},'密码已重置,旧会话已失效');}}><p>重置 {reset.username} 的密码,所有旧会话将立即失效。</p><label>新密码<input type="password" required minLength={8} maxLength={1024} autoComplete="new-password" value={password} onInput={e=>setPassword(e.currentTarget.value)} /></label><button className="button primary" disabled={busy}>确认重置密码</button><button type="button" className="button" disabled={busy} onClick={()=>{setReset(null);setPassword('');}}>取消重置</button></form>}
<p className="muted">共 {users.length} 个账号</p><ul className="user-manager-list">{users.filter(user=>(user.username+' '+user.displayName).toLowerCase().includes(query.toLowerCase())).map(user=><li key={user.id}>
<div><strong>{user.displayName}</strong><span className="muted"> @{user.username}{user.id===self?' · 当前账号':''}</span></div>
<p>{user.role==='admin'?'管理员':'普通用户'} · {user.disabled?'已停用':'正常'}</p>
{user.id!==self && <div className="user-manager-actions"><button className="button" disabled={busy} onClick={()=>setConfirmation({text:`确定${user.disabled?'启用':'停用'} ${user.username}?${user.disabled?'':'旧会话将立即失效。'}`,run:()=>api.adminUpdateUser(user.id,{disabled:!user.disabled})})}>{user.disabled?'启用':'停用'}</button><button className="button" disabled={busy} onClick={()=>setConfirmation({text:`将 ${user.username} ${user.role==='admin'?'改为普通用户':'设为管理员(可管理全部用户及实例设置)'}?`,run:()=>api.adminUpdateUser(user.id,{role:user.role==='admin'?'member':'admin'})})}>{user.role==='admin'?'移除管理员':'设为管理员'}</button><button className="button" disabled={busy} onClick={()=>{setReset(user);setForm(false);setPassword('');}}>重置密码</button></div>}
</li>)}</ul>
</> : settings && <>
<label className="user-manager-mode">注册方式<select aria-label="注册方式" value={settings.mode} disabled={busy} onChange={e=>{const mode=e.currentTarget.value as RegistrationSettings['mode'];setConfirmation({text:`将注册方式修改为${mode==='closed'?'关闭注册':mode==='open'?'开放注册(任何访问者都可创建账号)':'仅邀请码注册'}?`,run:()=>api.setRegistrationMode(mode)});}}><option value="closed">关闭注册</option><option value="open">开放注册</option><option value="invite">仅邀请码注册</option></select></label>
<p className="muted">关闭注册时仍可由管理员创建账号。设置立即生效并在重启后保留。</p>
<details><summary>创建邀请码</summary><form className="user-manager-form" onSubmit={e=>{e.preventDefault();void work(async()=>{setCode((await api.createInvite({label,maxUses,days})).code);},'邀请码已创建');}}><label>备注<input maxLength={80} value={label} onInput={e=>setLabel(e.currentTarget.value)} /></label><label>可用次数<input type="number" required min={1} max={1000} value={maxUses} onInput={e=>setMaxUses(Number(e.currentTarget.value))} /></label><label>有效天数<input type="number" required min={1} max={365} value={days} onInput={e=>setDays(Number(e.currentTarget.value))} /></label><button className="button primary" disabled={busy}>生成邀请码</button></form></details>
{code && <label className="user-manager-mode">新邀请码(仅本次显示,请复制保存)<input aria-label="新邀请码" readOnly value={code} onFocus={e=>e.currentTarget.select()} /></label>}
<ul className="user-manager-list">{settings.invites.map(invite=><li key={invite.id}><strong>{invite.label||'未备注邀请码'}</strong><p>已用 {invite.usedCount} / {invite.maxUses} 次 · {invite.disabled?'已停用':invite.expiresAt<=Date.now()?'已过期':invite.usedCount>=invite.maxUses?'已用完':'可用'}</p><p className="muted">有效至 {new Date(invite.expiresAt).toLocaleString()}</p>{!invite.disabled && <button className="button" disabled={busy} onClick={()=>setConfirmation({text:'停用此邀请码?已注册账号不受影响。',run:()=>api.disableInvite(invite.id)})}>停用邀请码</button>}</li>)}</ul>
</>}
</div></Modal>;
}
+2
View File
@@ -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);
+33
View File
@@ -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();}
}