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
@@ -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 授權;已發佈的共享圖標保留。請先由該賬號用戶保存需要的備份。",
|
||||
"必须保留另一位已完成改密且未封禁的管理员。不能删除当前登录账号。": "必須保留另一位已完成改密且未封禁的管理員。不能刪除當前登錄賬號。",
|
||||
"输入要删除的账号名称": "輸入要刪除的賬號名稱",
|
||||
"验证当前管理员密码": "驗證當前管理員密碼",
|
||||
"永久删除账号": "永久刪除賬號",
|
||||
"不能删除当前登录的管理员账号": "不能刪除當前登錄的管理員賬號",
|
||||
"仅支持删除管理员账号": "僅支持刪除管理員賬號",
|
||||
"确认账号名称不一致,请重新核对": "確認賬號名稱不一致,請重新核對",
|
||||
"请先设置另一位已完成改密且未封禁的管理员": "請先設置另一位已完成改密且未封禁的管理員"
|
||||
}
|
||||
Reference in new issue
Block a user