feat: allow administrator deletion and remove icon source field
This commit is contained in:
1 parent
a5f1236d44
commit
2db0c75498
18 files changed
+367
-27
No files matched your search
@@ -40,6 +40,8 @@ pnpm check:staged # git add 后,提交前检查本地凭证和禁止提交的
|
||||
|
||||
管理员登录并改密后,侧栏显示“管理员后台”,可添加账号、设置管理员/普通用户/只读用户权限、封禁和解除封禁。新建账号也需要首次改密。现有账号保留普通用户权限;管理员账号管理不会授予查看其他用户财务数据的能力。权限或封禁状态变化会撤销该用户全部登录会话和 MCP 授权;只读账号的业务写入、MCP 写入和草稿均由服务端拒绝,仍可改自己的登录密码、授权只读连接。详细验证见 [管理员功能验证](docs/admin-accounts.md)。
|
||||
|
||||
设置并登录另一位已完成改密的管理员后,可以在后台删除原 `admin`:输入原账号名称、验证当前管理员密码并确认。不能删除当前登录账号,且必须保留一位未封禁、已完成改密的管理员。删除会清理被删除账号的全部私人数据和授权,请先由该用户保存需要的备份;共享图标保留。仍有其他管理员时,重启不会重新创建默认账号。
|
||||
|
||||
金额原币保留,金额/汇率使用十进制字符串和 MySQL Decimal。归档只停止金额编辑,仍参与统计;需要归零时先更新余额。每次金额更新新增独立历史,同日按记录顺序保留;每日趋势使用当日最后余额;已有错误记录可单独更正,转账及其之前的历史需通过新增余额调整修正,避免破坏双方一致性。币种与资产负债属性创建后固定。详情同时显示原币和本位币金额。
|
||||
|
||||
汇率使用 [Frankfurter 公共日汇率 API](https://frankfurter.dev/)。请求使用固定币种表,不发送用户选择、身份或金额。进程运行时每小时检查,每天尝试一次;添加项目、修改本位币会重新触发检查。失败保留原币和历史汇率,显示缺失或实际使用日期,可以点击重试。自动更新保留已有同日历史导入汇率。首次使用不会自动补齐早期历史汇率,缺失日期不绘制完整总额;原币和已有汇率始终保留。
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `Icon` DROP COLUMN `source`;
|
||||
@@ -325,8 +325,6 @@ model Icon {
|
||||
hash String @db.Char(64)
|
||||
/// 规范化静态 PNG 图片二进制
|
||||
data Bytes @db.MediumBlob
|
||||
/// 可选公开来源网址
|
||||
source String? @db.VarChar(500)
|
||||
/// 记录创建时间,UTC
|
||||
createdAt DateTime @default(now())
|
||||
positions Position[]
|
||||
|
||||
@@ -26,10 +26,9 @@ async function main() {
|
||||
name: item.name,
|
||||
shared: true,
|
||||
data,
|
||||
source: item.source,
|
||||
hash: createHash('sha256').update(data).digest('hex'),
|
||||
},
|
||||
update: { data, source: item.source, hash: createHash('sha256').update(data).digest('hex') },
|
||||
update: { data, hash: createHash('sha256').update(data).digest('hex') },
|
||||
});
|
||||
}
|
||||
console.log(`Shared icon catalog ready: ${sources.length} icons.`);
|
||||
|
||||
@@ -74,6 +74,17 @@ async function main() {
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
// Verify the icon column removal migration against its former shape too.
|
||||
await connection.query('ALTER TABLE `Icon` ADD COLUMN `source` VARCHAR(500) NULL');
|
||||
await connection.query(
|
||||
fs.readFileSync(
|
||||
path.join(
|
||||
__dirname,
|
||||
'../prisma/migrations/20261005150000_remove_icon_source/migration.sql',
|
||||
),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
const build = run([require.resolve('typescript/bin/tsc')]);
|
||||
if (build.status !== 0) {
|
||||
console.log(build.stdout);
|
||||
|
||||
+50
-4
@@ -4,19 +4,21 @@ import {
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Req,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { hash } from 'bcryptjs';
|
||||
import { hash, compare } from 'bcryptjs';
|
||||
import { z } from 'zod';
|
||||
import { Database } from './database';
|
||||
import { UserRequest } from './auth';
|
||||
import { adminCreateInput, adminUpdateInput } from './user-access';
|
||||
import { UserRequest, AuthService } from './auth';
|
||||
import { adminCreateInput, adminUpdateInput, adminDeleteInput } from './user-access';
|
||||
|
||||
const summary = {
|
||||
id: true,
|
||||
@@ -28,7 +30,10 @@ const summary = {
|
||||
} as const;
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
constructor(private db: Database) {}
|
||||
constructor(
|
||||
private db: Database,
|
||||
private auth: AuthService,
|
||||
) {}
|
||||
private async requireAdmin(userId: string) {
|
||||
const u = await this.db.user.findUnique({ where: { id: userId } });
|
||||
if (!u || u.role !== 'admin' || u.banned || u.mustChangePassword)
|
||||
@@ -107,6 +112,44 @@ export class AdminService {
|
||||
return result;
|
||||
});
|
||||
}
|
||||
async remove(r: UserRequest, id: string, body: unknown) {
|
||||
z.string().uuid().parse(id);
|
||||
const v = adminDeleteInput.parse(body);
|
||||
await this.requireAdmin(r.userId);
|
||||
this.auth.limit(r);
|
||||
if (id === r.userId) throw new ForbiddenException('不能删除当前登录的管理员账号');
|
||||
const verified = await this.db.user.findUniqueOrThrow({ where: { id: r.userId } });
|
||||
if (!(await compare(v.currentPassword, verified.passwordHash)))
|
||||
throw new ForbiddenException('当前密码错误');
|
||||
return this.db.serial(async (tx) => {
|
||||
// Share the lock order with role/ban changes to avoid concurrent loss of administrators.
|
||||
await tx.$queryRaw(Prisma.sql`SELECT id FROM User WHERE role='admin' ORDER BY id FOR UPDATE`);
|
||||
const actor = await tx.user.findUnique({ where: { id: r.userId } });
|
||||
if (
|
||||
!actor ||
|
||||
actor.role !== 'admin' ||
|
||||
actor.banned ||
|
||||
actor.mustChangePassword ||
|
||||
actor.passwordHash !== verified.passwordHash
|
||||
)
|
||||
throw new ForbiddenException('管理员权限已变更');
|
||||
const target = await tx.user.findUnique({ where: { id } });
|
||||
if (!target) throw new NotFoundException('账号不存在');
|
||||
if (target.role !== 'admin') throw new BadRequestException('仅支持删除管理员账号');
|
||||
if (v.confirmationUsername !== target.username)
|
||||
throw new BadRequestException('确认账号名称不一致,请重新核对');
|
||||
if (
|
||||
!(await tx.user.count({
|
||||
where: { id: { not: id }, role: 'admin', banned: false, mustChangePassword: false },
|
||||
}))
|
||||
)
|
||||
throw new ForbiddenException('请先设置另一位已完成改密且未封禁的管理员');
|
||||
// Private images must not become ownerless; published shared images remain available.
|
||||
await tx.icon.deleteMany({ where: { ownerId: id, shared: false } });
|
||||
await tx.user.delete({ where: { id } });
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
}
|
||||
@Controller('api/admin/users')
|
||||
export class AdminController {
|
||||
@@ -120,4 +163,7 @@ export class AdminController {
|
||||
@Patch(':id') update(@Req() r: UserRequest, @Param('id') id: string, @Body() b: unknown) {
|
||||
return this.service.update(r, id, b);
|
||||
}
|
||||
@Delete(':id') remove(@Req() r: UserRequest, @Param('id') id: string, @Body() b: unknown) {
|
||||
return this.service.remove(r, id, b);
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,7 @@ export class IconsBusinessService {
|
||||
const [items, total] = await this.db.$transaction([
|
||||
this.db.icon.findMany({
|
||||
where,
|
||||
select: { id: true, name: true, shared: true, source: true },
|
||||
select: { id: true, name: true, shared: true },
|
||||
orderBy: [{ name: 'asc' }, { id: 'asc' }],
|
||||
skip: (index - 1) * 60,
|
||||
take: 60,
|
||||
@@ -127,7 +127,7 @@ export class IconsBusinessService {
|
||||
where: { ownerId_hash_shared: { ownerId: r.userId, hash, shared } },
|
||||
create: { name: v.name, ownerId: r.userId, shared, hash, data },
|
||||
update: {},
|
||||
select: { id: true, name: true, shared: true, source: true },
|
||||
select: { id: true, name: true, shared: true },
|
||||
});
|
||||
return icon;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { metalConfig, metalHoldingInput } from './metals';
|
||||
import { scheduleInput } from './schedules';
|
||||
import { z } from 'zod';
|
||||
import { loginInput, adminCreateInput, adminUpdateInput } from './user-access';
|
||||
import { loginInput, adminCreateInput, adminUpdateInput, adminDeleteInput } from './user-access';
|
||||
import {
|
||||
credentials,
|
||||
credentialChange,
|
||||
@@ -36,6 +36,7 @@ export function setupOpenApi(app: INestApplication) {
|
||||
'POST /api/auth/login': loginInput,
|
||||
'POST /api/admin/users': adminCreateInput,
|
||||
'PATCH /api/admin/users/{id}': adminUpdateInput,
|
||||
'DELETE /api/admin/users/{id}': adminDeleteInput,
|
||||
'PATCH /api/auth/credentials': credentialChange,
|
||||
'POST /api/auth/reveal': z.object({ password: credentials.shape.password.optional() }).strict(),
|
||||
'POST /api/positions': positionInput,
|
||||
|
||||
@@ -9,6 +9,12 @@ export const loginInput = credentials.extend({
|
||||
.max(72)
|
||||
.refine((s) => Buffer.byteLength(s, 'utf8') <= 72, '密码最多 72 字节'),
|
||||
});
|
||||
export const adminDeleteInput = z
|
||||
.object({
|
||||
confirmationUsername: credentials.shape.username,
|
||||
currentPassword: loginInput.shape.password,
|
||||
})
|
||||
.strict();
|
||||
export const adminCreateInput = credentials.extend({ role: roles.default('user') }).strict();
|
||||
export const adminUpdateInput = z
|
||||
.object({ role: roles.optional(), banned: z.boolean().optional() })
|
||||
|
||||
@@ -228,6 +228,156 @@ test(
|
||||
const unbanned = await call('/auth/login', 'POST', { username, password: regularPassword });
|
||||
assert.equal(unbanned.status, 201);
|
||||
assert.equal((await call('/overview', 'GET', undefined, unbanned.cookie)).status, 200);
|
||||
const originalDelete = { confirmationUsername: 'admin', currentPassword: password };
|
||||
assert.equal(
|
||||
(await call('/admin/users/' + initial.id, 'DELETE', originalDelete, cookie)).status,
|
||||
403,
|
||||
);
|
||||
assert.equal(
|
||||
(await call('/admin/users/' + initial.id, 'DELETE', originalDelete, unbanned.cookie))
|
||||
.status,
|
||||
403,
|
||||
);
|
||||
const replacementName = 'replacement_' + randomUUID().slice(0, 10);
|
||||
const replacementInitial = 'Initial-' + randomUUID();
|
||||
const replacement = await call(
|
||||
'/admin/users',
|
||||
'POST',
|
||||
{ username: replacementName, password: replacementInitial, role: 'admin' },
|
||||
cookie,
|
||||
);
|
||||
assert.equal(replacement.status, 201);
|
||||
const firstLogin = await call('/auth/login', 'POST', {
|
||||
username: replacementName,
|
||||
password: replacementInitial,
|
||||
});
|
||||
assert.equal(
|
||||
(
|
||||
await call(
|
||||
'/admin/users/' + initial.id,
|
||||
'DELETE',
|
||||
{ confirmationUsername: 'admin', currentPassword: replacementInitial },
|
||||
firstLogin.cookie,
|
||||
)
|
||||
).status,
|
||||
403,
|
||||
);
|
||||
const replacementPassword = 'Changed-' + randomUUID();
|
||||
const replacementChanged = await call(
|
||||
'/auth/credentials',
|
||||
'PATCH',
|
||||
{ currentPassword: replacementInitial, newPassword: replacementPassword },
|
||||
firstLogin.cookie,
|
||||
);
|
||||
assert.equal(replacementChanged.status, 200);
|
||||
const replacementCookie = replacementChanged.cookie;
|
||||
assert.equal(
|
||||
(
|
||||
await call(
|
||||
'/admin/users/' + initial.id,
|
||||
'DELETE',
|
||||
{ confirmationUsername: 'admin', currentPassword: 'wrong' },
|
||||
replacementCookie,
|
||||
)
|
||||
).status,
|
||||
403,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await call(
|
||||
'/admin/users/' + initial.id,
|
||||
'DELETE',
|
||||
{ confirmationUsername: 'wrong-name', currentPassword: replacementPassword },
|
||||
replacementCookie,
|
||||
)
|
||||
).status,
|
||||
400,
|
||||
);
|
||||
assert.ok(await db.user.findUnique({ where: { id: initial.id } }));
|
||||
const originalToken = await call(
|
||||
'/agent/tokens',
|
||||
'POST',
|
||||
{ name: 'original-admin', scopes: ['read'], days: 1, password },
|
||||
cookie,
|
||||
);
|
||||
assert.equal(originalToken.status, 201);
|
||||
const privateIcon = await db.icon.create({
|
||||
data: {
|
||||
name: 'private-delete',
|
||||
ownerId: initial.id,
|
||||
shared: false,
|
||||
hash: 'a'.repeat(64),
|
||||
data: Buffer.from('test'),
|
||||
},
|
||||
});
|
||||
const sharedIcon = await db.icon.create({
|
||||
data: {
|
||||
name: 'shared-keep',
|
||||
ownerId: initial.id,
|
||||
shared: true,
|
||||
hash: 'b'.repeat(64),
|
||||
data: Buffer.from('test'),
|
||||
},
|
||||
});
|
||||
const position = await db.position.create({
|
||||
data: {
|
||||
userId: initial.id,
|
||||
name: 'delete-fixture',
|
||||
kind: 'account',
|
||||
side: 'asset',
|
||||
category: 'bank',
|
||||
currency: 'CNY',
|
||||
notes: '',
|
||||
},
|
||||
});
|
||||
await db.revision.create({
|
||||
data: {
|
||||
positionId: position.id,
|
||||
amount: '10',
|
||||
effectiveDate: new Date(),
|
||||
notes: '',
|
||||
reason: 'initial',
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
(
|
||||
await call(
|
||||
'/admin/users/' + initial.id,
|
||||
'DELETE',
|
||||
{ confirmationUsername: 'admin', currentPassword: replacementPassword },
|
||||
replacementCookie,
|
||||
)
|
||||
).status,
|
||||
200,
|
||||
);
|
||||
assert.equal(await db.user.findUnique({ where: { id: initial.id } }), null);
|
||||
assert.equal(await db.position.findUnique({ where: { id: position.id } }), null);
|
||||
assert.equal(await db.revision.count({ where: { positionId: position.id } }), 0);
|
||||
assert.equal(await db.icon.findUnique({ where: { id: privateIcon.id } }), null);
|
||||
assert.equal(
|
||||
(await db.icon.findUniqueOrThrow({ where: { id: sharedIcon.id } })).ownerId,
|
||||
null,
|
||||
);
|
||||
assert.equal((await call('/auth/me', 'GET', undefined, cookie)).status, 401);
|
||||
assert.equal(
|
||||
(await call('/auth/login', 'POST', { username: 'admin', password })).status,
|
||||
401,
|
||||
);
|
||||
await assert.rejects(oauth.verifyAccessToken(originalToken.data.token));
|
||||
assert.equal(
|
||||
(
|
||||
await call(
|
||||
'/admin/users/' + replacement.data.id,
|
||||
'DELETE',
|
||||
{ confirmationUsername: replacementName, currentPassword: replacementPassword },
|
||||
replacementCookie,
|
||||
)
|
||||
).status,
|
||||
403,
|
||||
);
|
||||
await new AuthService(db as Database).onModuleInit();
|
||||
assert.equal(await db.user.count({ where: { username: 'admin' } }), 0);
|
||||
assert.equal((await call('/admin/users', 'GET', undefined, replacementCookie)).status, 200);
|
||||
} finally {
|
||||
await db.$disconnect();
|
||||
}
|
||||
|
||||
@@ -74,6 +74,13 @@ test('private and shared icons, account reuse and complete ZIP restoration prese
|
||||
assert.equal((await call('/icons')).status, 401);
|
||||
const own = await upload(a.cookie, '我的银行');
|
||||
assert.equal(own.status, 201);
|
||||
assert.equal('source' in own.data, false);
|
||||
const ownList = await call('/icons?q=' + encodeURIComponent('我的银行'), a.cookie);
|
||||
assert.equal('source' in ownList.data.items.find((i: any) => i.id === own.data.id), false);
|
||||
const sourceColumns: any[] = await db.$queryRawUnsafe(
|
||||
"SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'Icon' AND COLUMN_NAME = 'source'",
|
||||
);
|
||||
assert.equal(sourceColumns.length, 0);
|
||||
assert.equal((await upload(a.cookie, '同一图片')).data.id, own.data.id);
|
||||
assert.equal((await call('/icons/' + own.data.id + '/image', b.cookie)).status, 404);
|
||||
assert.equal(
|
||||
|
||||
@@ -19,6 +19,8 @@ export function AdminPanel({ user, report }: { user: User; report: (e: unknown)
|
||||
[offset, setOffset] = useState(0);
|
||||
const [busy, setBusy] = useState(false),
|
||||
[version, setVersion] = useState(0);
|
||||
const [deleting, setDeleting] = useState<Account | null>(null);
|
||||
const [confirmation, setConfirmation] = useState('');
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setBusy(true);
|
||||
@@ -69,6 +71,26 @@ export function AdminPanel({ user, report }: { user: User; report: (e: unknown)
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function remove(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (!deleting) return;
|
||||
const f = new FormData(e.currentTarget);
|
||||
setBusy(true);
|
||||
try {
|
||||
await api('/admin/users/' + deleting.id, 'DELETE', {
|
||||
confirmationUsername: confirmation,
|
||||
currentPassword: f.get('currentPassword'),
|
||||
});
|
||||
setDeleting(null);
|
||||
setConfirmation('');
|
||||
setOffset(0);
|
||||
setVersion((v) => v + 1);
|
||||
} catch (e) {
|
||||
report(e);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="admin-panel">
|
||||
<section className="panel">
|
||||
@@ -161,6 +183,18 @@ export function AdminPanel({ user, report }: { user: User; report: (e: unknown)
|
||||
>
|
||||
{account.banned ? tr('解除封禁') : tr('封禁账号')}
|
||||
</button>
|
||||
{account.role === 'admin' && account.id !== user.id && (
|
||||
<button
|
||||
className="danger"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setDeleting(account);
|
||||
setConfirmation('');
|
||||
}}
|
||||
>
|
||||
{tr('删除管理员')}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -184,6 +218,57 @@ export function AdminPanel({ user, report }: { user: User; report: (e: unknown)
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
{deleting && (
|
||||
<div className="veil">
|
||||
<section className="modal" role="dialog" aria-modal="true" aria-label={tr('删除管理员')}>
|
||||
<h2>
|
||||
{tr('删除管理员')} · {deleting.username}
|
||||
</h2>
|
||||
<p className="danger-text">
|
||||
{tr(
|
||||
'此操作不可撤销,将删除该账号及其全部财务数据、私有图标、登录会话和 MCP 授权;已发布的共享图标保留。请先由该账号用户保存需要的备份。',
|
||||
)}
|
||||
</p>
|
||||
<p className="muted">
|
||||
{tr('必须保留另一位已完成改密且未封禁的管理员。不能删除当前登录账号。')}
|
||||
</p>
|
||||
<form onSubmit={remove}>
|
||||
<label className="field">
|
||||
<span>{tr('输入要删除的账号名称')}</span>
|
||||
<input
|
||||
required
|
||||
autoComplete="off"
|
||||
value={confirmation}
|
||||
onChange={(e) => setConfirmation(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{tr('验证当前管理员密码')}</span>
|
||||
<input
|
||||
name="currentPassword"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
maxLength={72}
|
||||
/>
|
||||
</label>
|
||||
<div className="actions">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
disabled={busy}
|
||||
onClick={() => setDeleting(null)}
|
||||
>
|
||||
{tr('取消')}
|
||||
</button>
|
||||
<button className="danger" disabled={busy || confirmation !== deleting.username}>
|
||||
{tr('永久删除账号')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { t as tr } from './i18n';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { api } from './api';
|
||||
import { useToast } from './Toast';
|
||||
type Icon = { id: string; name: string; shared: boolean; source: string | null };
|
||||
type Icon = { id: string; name: string; shared: boolean };
|
||||
export const iconUrl = (id: string) => '/api/icons/' + encodeURIComponent(id) + '/image';
|
||||
export function IconLibrary({
|
||||
initialId,
|
||||
|
||||
@@ -656,5 +656,15 @@
|
||||
"不能修改自己的系统权限或封禁自己": "You cannot change your own role or ban yourself.",
|
||||
"必须保留至少一个可用管理员": "At least one active administrator must remain.",
|
||||
"管理员权限已变更": "Administrator permissions have changed.",
|
||||
"账号不存在": "User not found."
|
||||
"账号不存在": "User not found.",
|
||||
"删除管理员": "Delete administrator",
|
||||
"此操作不可撤销,将删除该账号及其全部财务数据、私有图标、登录会话和 MCP 授权;已发布的共享图标保留。请先由该账号用户保存需要的备份。": "This cannot be undone. The account, its financial data, private icons, login sessions and MCP grants will be deleted. Published shared icons remain. Ask the account owner to save any required backup first.",
|
||||
"必须保留另一位已完成改密且未封禁的管理员。不能删除当前登录账号。": "Another unbanned administrator who has changed their initial password must remain. You cannot delete your current account.",
|
||||
"输入要删除的账号名称": "Enter the username to delete",
|
||||
"验证当前管理员密码": "Verify your current administrator password",
|
||||
"永久删除账号": "Permanently delete account",
|
||||
"不能删除当前登录的管理员账号": "You cannot delete the currently signed-in administrator.",
|
||||
"仅支持删除管理员账号": "Only administrator accounts can be deleted.",
|
||||
"确认账号名称不一致,请重新核对": "The confirmation username does not match. Check it again.",
|
||||
"请先设置另一位已完成改密且未封禁的管理员": "First set up another unbanned administrator who has changed their initial password."
|
||||
}
|
||||
@@ -656,5 +656,15 @@
|
||||
"不能修改自己的系统权限或封禁自己": "不能修改自己的系統權限或封禁自己",
|
||||
"必须保留至少一个可用管理员": "必須保留至少一個可用管理員",
|
||||
"管理员权限已变更": "管理員權限已變更",
|
||||
"账号不存在": "賬號不存在"
|
||||
"账号不存在": "賬號不存在",
|
||||
"删除管理员": "刪除管理員",
|
||||
"此操作不可撤销,将删除该账号及其全部财务数据、私有图标、登录会话和 MCP 授权;已发布的共享图标保留。请先由该账号用户保存需要的备份。": "此操作不可撤銷,將刪除該賬號及其全部財務數據、私有圖標、登錄會話和 MCP 授權;已發佈的共享圖標保留。請先由該賬號用戶保存需要的備份。",
|
||||
"必须保留另一位已完成改密且未封禁的管理员。不能删除当前登录账号。": "必須保留另一位已完成改密且未封禁的管理員。不能刪除當前登錄賬號。",
|
||||
"输入要删除的账号名称": "輸入要刪除的賬號名稱",
|
||||
"验证当前管理员密码": "驗證當前管理員密碼",
|
||||
"永久删除账号": "永久刪除賬號",
|
||||
"不能删除当前登录的管理员账号": "不能刪除當前登錄的管理員賬號",
|
||||
"仅支持删除管理员账号": "僅支持刪除管理員賬號",
|
||||
"确认账号名称不一致,请重新核对": "確認賬號名稱不一致,請重新核對",
|
||||
"请先设置另一位已完成改密且未封禁的管理员": "請先設置另一位已完成改密且未封禁的管理員"
|
||||
}
|
||||
@@ -13,10 +13,14 @@ ADMIN_PASSWORD=admin
|
||||
|
||||
后台接口:`GET /api/admin/users?limit=50&offset=0`、`POST /api/admin/users`、`PATCH /api/admin/users/:id`。列表完整分页,仅返回账号身份、角色、状态和创建时间;不返回哈希、会话令牌或财务数据。角色为 `admin`、`user`、`readonly`。管理动作在事务内重查管理员权限;禁止修改自身角色或封禁自己,并保留可用管理员。
|
||||
|
||||
删除原管理员:先创建或设置另一位管理员,使用新管理员登录并完成首次改密,然后在后台原管理员所在行点击“删除管理员”。输入被删除账号名称和当前管理员密码后确认。`DELETE /api/admin/users/:id` 仅支持删除其他管理员;后端在事务中重查操作人权限、密码是否变化、被删除用户名和剩余管理员状态。至少保留另一位未封禁且完成改密的管理员,不允许删除自己。删除不可撤销,会级联清理该账号财务数据、会话和 MCP 授权,并删除私有图标;已发布共享图标保留。仍有其他管理员时,重启不会重新创建默认 admin。
|
||||
|
||||
角色/封禁状态变化会删除该用户全部会话、撤销 MCP/PAT/OAuth 授权、取消待确认草稿和已批准待兑换授权。解封不会恢复旧令牌。密码修改也会撤销既有 MCP 授权。HTTP 与 MCP 后端都会检查封禁、首次改密和只读权限;只读用户可管理自己的登录密码及只读连接,无法提交财务写入、草稿或确认业务操作。管理员仍遵循原有财务数据用户隔离。
|
||||
|
||||
验证:前后端类型检查、网页生产构建、53 项单元测试,以及使用临时 MySQL 库和独立 API 的管理员与现有业务/MCP/OAuth 回归。当前数据库已应用新增迁移;本机网页验证默认管理员首次登录展示强制改密页,未替用户设置新密码。
|
||||
|
||||
追加验证:新管理员首次改密后删除原 admin 成功,用户名确认或密码错误时拒绝,普通用户和未改密管理员无权删除,禁止删除当前管理员。被删除用户的持仓、历史、私有图标和授权被清理,共享图标保留,新管理员仍可使用后台;再次初始化不会重建原账号。图标表通过 `20261005150000_remove_icon_source` 移除 source,接口、前端类型和图标初始化脚本同步调整,现有 ZIP v9 图标格式保持一致。53 项单元测试与 29 项隔离回归通过。
|
||||
|
||||
运行隔离回归:在仓库根目录执行 `pnpm --filter @worthpath/api test:isolated`。测试库名使用随机 `wp_test_` 前缀,API 使用随机本机端口;完成或失败后清理自己创建的服务和数据库。管理员测试只在该隔离流程中运行,避免修改真实管理员。
|
||||
|
||||
已有迁移目录 `005_account_icons`、`006_navigation_transfers`、`007_notes_display` 排在初始迁移之前,因此原有 `migrate deploy` 从空库初始化会失败。本次未重命名历史迁移。隔离测试用当前 Prisma 模型建立临时库,再移除本次字段并执行新增 SQL 来验证增量迁移;现有库迁移正常。全新部署仍需要单独修复历史迁移顺序。
|
||||
@@ -32,7 +32,7 @@ Revision 保存按业务日期生效的绝对金额,每次金额更新新增
|
||||
|
||||
## 账户图标模块
|
||||
|
||||
Icon 存储 name、ownerId、shared、SHA-256、规范静态 PNG 的 MediumBlob 和可选公开来源;Position.iconId 外键 SetNull,多个账户共享同一图片。所有图标接口使用已验证身份,读取/检索/赋值均限定 shared=true 或 ownerId=当前用户,响应不返回 ownerId。个人上传默认私有;共享发布须中文名称和明确公开确认。拒绝 SVG、动图、损坏图片、超限像素/文件,重编码移除元数据。内置图标固定 ID 追加初始化;在线请求不会发送用户财务数据。
|
||||
Icon 存储 name、ownerId、shared、SHA-256 和规范静态 PNG 的 MediumBlob,不存储 source;内置图标来源保留在资源清单中。Position.iconId 外键 SetNull,多个账户共享同一图片。所有图标接口使用已验证身份,读取/检索/赋值均限定 shared=true 或 ownerId=当前用户,响应不返回 ownerId。个人上传默认私有;共享发布须中文名称和明确公开确认。拒绝 SVG、动图、损坏图片、超限像素/文件,重编码移除元数据。内置图标固定 ID 追加初始化;在线请求不会发送用户财务数据。
|
||||
|
||||
当前备份要求完整 icons 数组,ZIP v9 将图标内容放入 icons.json;内容校验和解码在导入事务前完成,图标在事务内以当前用户私有范围重建,账户关联重映射。清空删除私有图标,公开共享图标不因发布者清空而消失。用户删除时图标 ownerId SetNull,不影响他人已引用的公共图标。
|
||||
|
||||
|
||||
+22
-12
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"tableCount": 17,
|
||||
"columnCount": 161,
|
||||
"columnCount": 163,
|
||||
"tables": [
|
||||
{
|
||||
"TABLE_NAME": "_prisma_migrations",
|
||||
@@ -397,11 +397,6 @@
|
||||
"COLUMN_NAME": "data",
|
||||
"COLUMN_COMMENT": "规范化静态 PNG 图片二进制"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "icon",
|
||||
"COLUMN_NAME": "source",
|
||||
"COLUMN_COMMENT": "可选公开来源网址"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "icon",
|
||||
"COLUMN_NAME": "createdAt",
|
||||
@@ -552,6 +547,16 @@
|
||||
"COLUMN_NAME": "metalCostPerGram",
|
||||
"COLUMN_COMMENT": "买入时每克实物成本,原币;空值表示不计算盈亏"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "position",
|
||||
"COLUMN_NAME": "lastRepaymentMonth",
|
||||
"COLUMN_COMMENT": "最后手动标记已完成还款的业务月份,UTC+8 YYYY-MM;空表示未标记"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "position",
|
||||
"COLUMN_NAME": "lastBookedDate",
|
||||
"COLUMN_COMMENT": "最后手动标记已完成记账的业务日期,UTC+8 YYYY-MM-DD;空表示未标记"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "positionlink",
|
||||
"COLUMN_NAME": "id",
|
||||
@@ -868,14 +873,19 @@
|
||||
"COLUMN_COMMENT": "独立资产是否参与资产总览和净资产轨迹"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "position",
|
||||
"COLUMN_NAME": "lastRepaymentMonth",
|
||||
"COLUMN_COMMENT": "最后手动标记已完成还款的业务月份,UTC+8 YYYY-MM;空表示未标记"
|
||||
"TABLE_NAME": "user",
|
||||
"COLUMN_NAME": "role",
|
||||
"COLUMN_COMMENT": "系统权限:admin、user 或 readonly;管理员不能查看其他用户财务数据"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "position",
|
||||
"COLUMN_NAME": "lastBookedDate",
|
||||
"COLUMN_COMMENT": "最后手动标记已完成记账的业务日期,UTC+8 YYYY-MM-DD;空表示未标记"
|
||||
"TABLE_NAME": "user",
|
||||
"COLUMN_NAME": "banned",
|
||||
"COLUMN_COMMENT": "封禁后禁止登录和使用已有会话、MCP 授权"
|
||||
},
|
||||
{
|
||||
"TABLE_NAME": "user",
|
||||
"COLUMN_NAME": "mustChangePassword",
|
||||
"COLUMN_COMMENT": "首次登录必须设置新的强密码"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user