Skip to content

Commit 8a0b7c8

Browse files
committed
feat: 普通用户可在设置中注销账号
在「设置 → 危险」新增注销账号入口,输入当前密码后永久删除账号及名下全部数据(由数据库级联删除),完成后自动退出登录。管理员账号不能自助注销,避免实例失去管理员。满足应用商店对可注册账号须支持应用内删除的要求。
1 parent e5ac60c commit 8a0b7c8

13 files changed

Lines changed: 302 additions & 7 deletions

File tree

‎README.en.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ Practice statistics show total practice, accuracy (correct / attempted) and acti
183183
- **🕒 Application time zone**: Beijing time by default and changeable by administrators; dates and statistics follow the application time zone. See [TIME_ZONE.md](doc/TIME_ZONE.md) (Chinese).
184184
- **🔑 One-time setup link**: fresh installs no longer ship an admin with a fixed password. On first start the server generates a setup link that works only once; open it to choose the admin email and password. The desktop and standalone Android apps open it automatically; on fnOS you enter the admin email and password in the install wizard instead; Docker, npm and source runs print it in the startup log (the token is also in `config/setup-token` in the data directory). Instances upgraded from earlier versions keep their accounts.
185185
- **🧭 Setup wizard**: on first sign-in the admin goes through a wizard: change the default password (only for the default admin of earlier versions) → set up an AI provider (free MiniMax Code recommended) → create the first notebook. The last two steps can be skipped, and the wizard can be run again from **Settings → User Management**. Instances already in use are not interrupted after upgrading.
186-
- **🔐 Multiple users and admin console**: sign-up and sign-in, with data isolated per user. New sign-ups are off by default (the sign-in page then hides the sign-up link and asks visitors to contact the administrator) and can be turned on under **Settings → User Management**, where admins can also cap daily AI use per user and see today's usage. Administrators open the admin console from **Settings → User Management** to view usage statistics and enable, disable or delete users. Sign-in and sign-up limit repeated failures to block password guessing.
186+
- **🔐 Multiple users and admin console**: sign-up and sign-in, with data isolated per user. New sign-ups are off by default (the sign-in page then hides the sign-up link and asks visitors to contact the administrator) and can be turned on under **Settings → User Management**, where admins can also cap daily AI use per user and see today's usage. Administrators open the admin console from **Settings → User Management** to view usage statistics and enable, disable or delete users. Regular users can delete their own account under **Settings → Danger** by entering their password, which permanently deletes the account and all of its data (admins cannot delete themselves). Sign-in and sign-up limit repeated failures to block password guessing.
187187
- **🐞 Debug logs**: when something goes wrong, an admin turns on debug mode under "Settings → User Management", reproduces the problem, then exports the debug log (JSON) and sends it to the author. Debug mode records every server log, including `debug` level, plus frontend errors; API keys, passwords and tokens are removed on export. The log lives in memory only, debug mode turns off after 24 hours, and a restart clears it.
188188
- **🔌 Openclaw integration**: an external Openclaw agent can upload question images in bulk via `POST /api/openclaw/batch-upload`; see [Environment variables](#-environment-variables).
189189

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@
183183
- **🕒 应用时区**:默认北京时间,管理员可修改,日期显示与统计都按应用时区计算,详见 [时区说明](doc/TIME_ZONE.md)。
184184
- **🔑 一次性初始化链接**:全新安装不再预置固定密码的管理员。服务首次启动时生成只能使用一次的初始化链接,打开后自行设置管理员邮箱和密码:桌面版、安卓单机版自动打开;飞牛OS 直接在安装向导中填写管理员邮箱和密码;Docker、npm、源码运行打印在启动日志中(也可在数据目录的 `config/setup-token` 中找到令牌)。从旧版本升级的实例保留原有账号。
185185
- **🧭 安装向导**:管理员首次登录时进入向导:修改默认密码(仅旧版本的默认管理员需要)→ 配置 AI 渠道(推荐免费的 MiniMax Code)→ 创建第一个错题本,后两步可跳过;可在「设置 → 用户管理」中重新运行。已在使用的实例升级后不会自动弹出。
186-
- **🔐 多用户与管理后台**:支持注册、登录,数据按用户隔离;新用户注册默认关闭(关闭时登录页不显示注册入口,改为提示联系管理员),管理员可在「设置 → 用户管理」中开启,并可限制每人每天的 AI 使用次数、查看今日用量;管理员可在「设置 → 用户管理」进入管理后台,查看使用统计,启用/禁用或删除用户。登录和注册带失败次数限制,防止暴力破解密码。
186+
- **🔐 多用户与管理后台**:支持注册、登录,数据按用户隔离;新用户注册默认关闭(关闭时登录页不显示注册入口,改为提示联系管理员),管理员可在「设置 → 用户管理」中开启,并可限制每人每天的 AI 使用次数、查看今日用量;管理员可在「设置 → 用户管理」进入管理后台,查看使用统计,启用/禁用或删除用户;普通用户可在「设置 → 危险」中输入密码注销自己的账号,账号及全部数据随之永久删除(管理员不能自助注销)。登录和注册带失败次数限制,防止暴力破解密码。
187187
- **🐞 调试日志**:遇到问题时,管理员在「设置 → 用户管理」中打开调试模式,复现问题后导出调试日志(JSON)发给作者。调试模式下记录包括 debug 级别在内的全部服务端日志和前端错误,导出时自动去除 API Key、密码和令牌;日志只保存在内存中,24 小时后自动关闭,服务重启后清空。
188188
- **🔌 Openclaw 集成**:外部 Openclaw agent 可通过 `POST /api/openclaw/batch-upload` 批量上传题目图片,配置见[环境变量](#-环境变量)。
189189

‎doc/PRIVACY.en.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,6 @@ Request retention and processing are governed by the selected service's terms. C
2222

2323
## Data management and contact
2424

25-
Users can delete questions and study records and export their personal data. Instance administrators manage accounts, databases, uploads, logs and backups. Uninstalling a desktop app does not automatically delete its data directory; uninstalling standalone Android deletes its private data. Deleting instance data does not delete exported backups or requests already received by third parties; manage those separately with their respective operators.
25+
Users can delete questions and study records and export their personal data. Regular users can also delete their account under Settings → Danger Zone by entering their password, which permanently deletes the account and all of its data (administrator accounts cannot delete themselves). Instance administrators manage accounts, databases, uploads, logs and backups. Uninstalling a desktop app does not automatically delete its data directory; uninstalling standalone Android deletes its private data. Deleting instance data does not delete exported backups or requests already received by third parties; manage those separately with their respective operators.
2626

2727
Do not enter personal study material into the shared demo. Contact your instance administrator for privacy requests about a self-hosted deployment. Contact the software maintainer through project [Issues](https://github.com/margrop/wrong-notebook/issues); use the [security reporting channel](../SECURITY.md) for sensitive account, credential or vulnerability information instead of posting it publicly.

‎doc/PRIVACY.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,6 @@ AI API Key、服务地址,以及用户主动连接 MiniMax Code 后的登录
2222

2323
## 数据管理与联系
2424

25-
用户可在实例中删除题目和学习记录、导出个人数据;实例管理员负责账号、数据库、上传文件、日志及备份的保管和清理。卸载桌面版不会自动删除数据目录;安卓单机版卸载会删除其私有数据目录。删除实例中的数据不等于删除已导出的备份或第三方已收到的请求,应分别向相应运营者处理。
25+
用户可在实例中删除题目和学习记录、导出个人数据,普通用户还可在「设置 → 危险」中输入密码注销账号,账号及名下全部数据随之永久删除(管理员账号不能自助注销);实例管理员负责账号、数据库、上传文件、日志及备份的保管和清理。卸载桌面版不会自动删除数据目录;安卓单机版卸载会删除其私有数据目录。删除实例中的数据不等于删除已导出的备份或第三方已收到的请求,应分别向相应运营者处理。
2626

2727
使用共享演示站时,不应录入个人学习资料。自托管实例的隐私请求请联系该实例管理员。软件本身的问题可通过项目 [Issues](https://github.com/margrop/wrong-notebook/issues) 联系维护者;涉及密钥、账号或漏洞的内容请使用[安全报告渠道](../SECURITY.md),不要公开个人资料或凭据。

‎doc/wiki/Settings-and-Users.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@
5252

5353
管理员从“设置 → 用户管理”进入后台,可查看使用统计,启用、禁用或删除用户。删除前应确认对象并[备份数据](Backup-and-Restore)。
5454

55+
普通用户可在“设置 → 危险 → 注销账号”中输入当前密码,永久删除自己的账号及名下全部数据(错题、错题本、标签、练习记录、试卷等),完成后自动退出登录,无法恢复,需要保留的数据请先导出。管理员账号不能自助注销,避免实例失去管理员;需要清空数据时使用同一页的系统初始化。
56+
5557
新用户注册默认关闭,避免服务暴露到公网时被陌生人注册并消耗 AI 额度。需要为家人创建账号时,管理员在“设置 → 用户管理”中打开“允许新用户注册”,账号注册完成后再关闭;也可以用环境变量 `ALLOW_REGISTRATION=true` 改变默认值,网页中保存过的开关优先。升级前已在网页中保存过设置的实例,保持原来的注册开关。注册关闭时,登录页不显示“立即注册”,改为提示联系管理员开启注册。
5658

5759
### 每日 AI 次数

‎doc/wiki/en/Settings-and-Users-en.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ See the [time zone notes](https://github.com/margrop/wrong-notebook/blob/main/do
5252

5353
Admins open the dashboard from "Settings → User Management" to see usage statistics and enable, disable or delete users. Confirm the target and [back up your data](Backup-and-Restore-en) before deleting.
5454

55+
Regular users can delete their own account under "Settings → Danger → Delete Account" by entering their current password. The account and all of its data (questions, notebooks, tags, practice records, exam papers, etc.) are permanently deleted and the user is signed out; this cannot be undone, so export anything you want to keep first. Admin accounts cannot delete themselves, so the instance never loses its administrator; to clear data, use System Initialization on the same page.
56+
5557
New sign-ups are off by default, so strangers cannot register and use up your AI quota when the service is reachable from the internet. To create accounts for family members, an admin turns on "Allow New Registrations" under "Settings → User Management" and turns it off again once the accounts exist. The environment variable `ALLOW_REGISTRATION=true` changes the default; a switch saved on the web page takes precedence. Instances that saved settings on the web page before upgrading keep their existing switch. While sign-ups are off, the sign-in page hides "Register now" and asks visitors to contact the administrator.
5658

5759
### Daily AI limit

‎src/__tests__/integration/user.test.ts‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({
99
mockPrismaUser: {
1010
findUnique: vi.fn(),
1111
update: vi.fn(),
12+
delete: vi.fn(),
1213
},
1314
mockPrismaErrorItem: {
1415
updateMany: vi.fn(async () => ({ count: 0 })),
@@ -41,10 +42,11 @@ vi.mock('@/lib/auth', () => ({
4142
// Mock bcryptjs
4243
vi.mock('bcryptjs', () => ({
4344
hash: vi.fn((password: string) => Promise.resolve(`hashed_${password}`)),
45+
compare: vi.fn((password: string, hashed: string) => Promise.resolve(hashed === `hashed_${password}`)),
4446
}));
4547

4648
// Import after mocks
47-
import { GET, PATCH } from '@/app/api/user/route';
49+
import { DELETE, GET, PATCH } from '@/app/api/user/route';
4850
import type { Session } from 'next-auth';
4951

5052
describe('/api/user', () => {
@@ -668,4 +670,50 @@ describe('/api/user', () => {
668670
expect(updateCall.where.email).toBe('normaluser@example.com');
669671
});
670672
});
673+
describe('DELETE /api/user', () => {
674+
const deleteRequest = (body: unknown) => new Request('http://localhost/api/user', {
675+
method: 'DELETE',
676+
body: JSON.stringify(body),
677+
headers: { 'Content-Type': 'application/json' },
678+
});
679+
680+
it('普通用户输入正确密码后删除自己的账号', async () => {
681+
mocks.mockPrismaUser.findUnique.mockResolvedValue({ ...defaultDbUser, password: 'hashed_secret1' });
682+
mocks.mockPrismaUser.delete.mockResolvedValue({ id: 'user-1' });
683+
684+
const response = await DELETE(deleteRequest({ password: 'secret1' }));
685+
686+
expect(response.status).toBe(200);
687+
expect(await response.json()).toEqual({ success: true });
688+
expect(mocks.mockPrismaUser.delete).toHaveBeenCalledWith({ where: { id: 'user-1' } });
689+
});
690+
691+
it('密码错误时返回 400,不删除账号', async () => {
692+
mocks.mockPrismaUser.findUnique.mockResolvedValue({ ...defaultDbUser, password: 'hashed_secret1' });
693+
694+
const response = await DELETE(deleteRequest({ password: 'wrong' }));
695+
696+
expect(response.status).toBe(400);
697+
expect((await response.json()).message).toBe('Incorrect password');
698+
expect(mocks.mockPrismaUser.delete).not.toHaveBeenCalled();
699+
});
700+
701+
it('未提供密码时返回校验错误', async () => {
702+
mocks.mockPrismaUser.findUnique.mockResolvedValue({ ...defaultDbUser, password: 'hashed_secret1' });
703+
704+
const response = await DELETE(deleteRequest({}));
705+
706+
expect(response.status).toBe(400);
707+
expect(mocks.mockPrismaUser.delete).not.toHaveBeenCalled();
708+
});
709+
710+
it('管理员不能注销自己的账号', async () => {
711+
mocks.mockPrismaUser.findUnique.mockResolvedValue({ ...defaultDbUser, role: 'admin', password: 'hashed_secret1' });
712+
713+
const response = await DELETE(deleteRequest({ password: 'secret1' }));
714+
715+
expect(response.status).toBe(403);
716+
expect(mocks.mockPrismaUser.delete).not.toHaveBeenCalled();
717+
});
718+
});
671719
});
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { act } from 'react';
2+
import { createRoot } from 'react-dom/client';
3+
import type { Root } from 'react-dom/client';
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5+
import { signOut } from 'next-auth/react';
6+
import { DeleteAccountSettings } from '@/components/settings/delete-account-settings';
7+
import { LanguageProvider } from '@/contexts/LanguageContext';
8+
import { apiClient, ApiError } from '@/lib/api-client';
9+
import { toast } from '@/lib/feedback';
10+
11+
vi.mock('next-auth/react', () => ({ signOut: vi.fn() }));
12+
vi.mock('@/lib/api-client', async (importOriginal) => {
13+
const actual = await importOriginal<typeof import('@/lib/api-client')>();
14+
return { ...actual, apiClient: { delete: vi.fn() } };
15+
});
16+
vi.mock('@/lib/frontend-logger', () => ({ frontendLogger: { error: vi.fn() } }));
17+
vi.mock('@/lib/feedback', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
18+
19+
let container: HTMLDivElement;
20+
let root: Root;
21+
22+
beforeEach(() => {
23+
vi.clearAllMocks();
24+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
25+
container = document.createElement('div');
26+
document.body.appendChild(container);
27+
root = createRoot(container);
28+
});
29+
30+
afterEach(async () => {
31+
await act(async () => root.unmount());
32+
container.remove();
33+
vi.unstubAllGlobals();
34+
});
35+
36+
async function render(isAdmin: boolean) {
37+
await act(async () => root.render(<LanguageProvider><DeleteAccountSettings isAdmin={isAdmin} /></LanguageProvider>));
38+
}
39+
40+
/** 打开确认弹窗、输入密码并提交 */
41+
async function submitWithPassword(password: string) {
42+
await act(async () => container.querySelector<HTMLButtonElement>('button')!.click());
43+
const input = document.querySelector<HTMLInputElement>('#delete-account-password')!;
44+
const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!;
45+
await act(async () => {
46+
setValue.call(input, password);
47+
input.dispatchEvent(new Event('input', { bubbles: true }));
48+
});
49+
await act(async () => {
50+
input.form!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
51+
});
52+
}
53+
54+
describe('注销账号设置', () => {
55+
it('管理员的注销按钮不可用,并说明原因', async () => {
56+
await render(true);
57+
58+
expect(container.querySelector<HTMLButtonElement>('button')!.disabled).toBe(true);
59+
expect(container.textContent).toContain('管理员账号不能自助注销');
60+
});
61+
62+
it('普通用户输入密码后注销并退出登录', async () => {
63+
vi.mocked(apiClient.delete).mockResolvedValue({ success: true });
64+
await render(false);
65+
66+
await submitWithPassword('secret1');
67+
68+
expect(apiClient.delete).toHaveBeenCalledWith('/api/user', { body: JSON.stringify({ password: 'secret1' }) });
69+
expect(signOut).toHaveBeenCalledWith({ callbackUrl: '/login' });
70+
});
71+
72+
it('密码错误时提示并保持登录', async () => {
73+
vi.mocked(apiClient.delete).mockRejectedValue(new ApiError(400, 'Bad Request', { message: 'Incorrect password' }));
74+
await render(false);
75+
76+
await submitWithPassword('wrong');
77+
78+
expect(toast.error).toHaveBeenCalledWith('密码错误');
79+
expect(signOut).not.toHaveBeenCalled();
80+
});
81+
});

‎src/app/api/user/route.ts‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ import { prisma } from "@/lib/prisma";
55
import { withUser } from "@/lib/api-handler";
66
import type { AuthenticatedContext } from "@/lib/api-handler";
77
import { z } from "zod";
8-
import { hash } from "bcryptjs";
8+
import { compare, hash } from "bcryptjs";
99
import type { Prisma } from "@prisma/client";
10-
import { unauthorized, notFound, badRequest, validationError, internalError } from "@/lib/api-errors";
10+
import { unauthorized, notFound, badRequest, forbidden, validationError, internalError } from "@/lib/api-errors";
1111
import { createLogger } from "@/lib/logger";
1212
import { isDefaultAdminPassword } from "@/lib/default-admin";
1313
import { syncMasteryLevels } from "@/lib/mastery";
@@ -173,3 +173,34 @@ async function handlePatch(req: Request, { session }: AuthenticatedContext) {
173173
}
174174

175175
export const PATCH = withUser(handlePatch);
176+
177+
const deleteAccountSchema = z.object({
178+
password: z.string().min(1),
179+
});
180+
181+
/**
182+
* DELETE /api/user
183+
* 普通用户注销自己的账号:校验密码后删除用户,名下数据由数据库级联删除。
184+
* 管理员不能自助注销,避免实例失去管理员;清空数据请使用系统初始化。
185+
*/
186+
async function handleDelete(req: Request, { user }: AuthenticatedContext) {
187+
if (user.role === 'admin') {
188+
return forbidden("Administrators cannot delete their own account");
189+
}
190+
191+
const parsed = deleteAccountSchema.safeParse(await req.json().catch(() => null));
192+
if (!parsed.success) {
193+
return validationError("Invalid input", parsed.error.issues);
194+
}
195+
196+
if (!(await compare(parsed.data.password, user.password))) {
197+
return badRequest("Incorrect password");
198+
}
199+
200+
await prisma.user.delete({ where: { id: user.id } });
201+
logger.info({ userId: user.id }, 'User deleted own account');
202+
203+
return NextResponse.json({ success: true });
204+
}
205+
206+
export const DELETE = withUser(handleDelete);

0 commit comments

Comments
 (0)