fix(账号): 固化超级管理员身份

This commit is contained in:
Script Generator
2026-06-26 11:18:00 +08:00
parent 56e0fe562d
commit 4cda624dc7
13 changed files with 419 additions and 80 deletions

View File

@@ -3,8 +3,10 @@
import { useEffect, useMemo, useState } from 'react';
import { Plus, Pencil, Trash2, X, ChevronRight, FolderOpen, Settings } from 'lucide-react';
import { useMemberStore } from '@/stores/useMemberStore';
import { useAuthStore } from '@/stores/useAuthStore';
import { maskPhone, generatePassword } from '@/lib/members';
import type { Member, Department, PasswordRule } from '@/lib/members';
import { isSystemAdminMember } from '@/lib/member-system';
import { RouteGuard } from '@/components/auth/Guard';
export default function MembersPage() {
@@ -17,6 +19,8 @@ export default function MembersPage() {
function MembersPageContent() {
const { departments, members, roles, passwordRule, fetchMembers, updatePasswordRule, createDepartment, updateDepartment, deleteDepartment, createMember, updateMember, deleteMember } = useMemberStore();
const currentUser = useAuthStore((s) => s.user);
const refreshUser = useAuthStore((s) => s.refreshUser);
const [activeDeptId, setActiveDeptId] = useState<string | null>(null);
const [showMemberModal, setShowMemberModal] = useState(false);
const [editingMember, setEditingMember] = useState<Member | null>(null);
@@ -169,7 +173,12 @@ function MembersPageContent() {
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
<button onClick={() => { setEditingMember(m); setShowMemberModal(true); }} className="h-6 px-2 rounded text-[11px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]"></button>
<button onClick={() => deleteMember(m.id)} className="h-6 px-2 rounded text-[11px] font-medium text-red-500 hover:bg-red-50"></button>
{!isSystemAdminMember(m) && (
<button onClick={() => deleteMember(m.id)} className="h-6 px-2 rounded text-[11px] font-medium text-red-500 hover:bg-red-50"></button>
)}
{isSystemAdminMember(m) && (
<span className="h-6 px-2 inline-flex items-center rounded text-[11px] font-medium text-amber-700 bg-amber-50"></span>
)}
</div>
</td>
</tr>
@@ -191,8 +200,12 @@ function MembersPageContent() {
passwordRule={passwordRule}
onClose={() => setShowMemberModal(false)}
onSubmit={(data) => {
if (editingMember) updateMember(editingMember.id, data);
else createMember(data as any);
if (editingMember) {
updateMember(editingMember.id, data);
if (editingMember.id === currentUser?.id) void refreshUser();
} else {
createMember(data as any);
}
setShowMemberModal(false);
}}
/>
@@ -239,6 +252,7 @@ function MemberModal({ initial, departments, roles, defaultDeptId, passwordRule,
const [phone, setPhone] = useState(initial?.phone ?? '');
const [email, setEmail] = useState(initial?.email ?? '');
const [password] = useState(() => initial?.password ?? generatePassword(passwordRule));
const isSystemMember = initial ? isSystemAdminMember(initial) : false;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@@ -254,21 +268,26 @@ function MemberModal({ initial, departments, roles, defaultDeptId, passwordRule,
<button onClick={onClose} className="p-1 rounded hover:bg-[var(--bg-subtle)] text-[var(--ink-muted)]"><X className="h-4 w-4" /></button>
</div>
<form onSubmit={handleSubmit} className="space-y-3">
{isSystemMember && (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-[11px] text-amber-700">
</div>
)}
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"> *</label>
<input value={name} onChange={(e) => setName(e.target.value)} required placeholder="请输入姓名" className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none" />
<input value={name} onChange={(e) => setName(e.target.value)} required disabled={isSystemMember} placeholder="请输入姓名" className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none disabled:bg-zinc-50 disabled:text-[var(--ink-soft)]" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"> *</label>
<select value={departmentId} onChange={(e) => setDepartmentId(e.target.value)} required className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none">
<select value={departmentId} onChange={(e) => setDepartmentId(e.target.value)} required disabled={isSystemMember} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none disabled:bg-zinc-50 disabled:text-[var(--ink-soft)]">
<option value=""></option>
{departments.map((d) => <option key={d.id} value={d.id}>{d.parentId ? ' ' : ''}{d.name}</option>)}
</select>
</div>
<div>
<label className="text-[12px] font-medium text-[var(--ink-soft)] mb-1 block"> *</label>
<select value={roleId} onChange={(e) => setRoleId(e.target.value)} required className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none">
<select value={roleId} onChange={(e) => setRoleId(e.target.value)} required disabled={isSystemMember} className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg-card)] px-3 text-[13px] focus:border-[var(--accent)] focus:outline-none disabled:bg-zinc-50 disabled:text-[var(--ink-soft)]">
<option value=""></option>
{roles.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>

View File

@@ -8,6 +8,7 @@ import { useDevTaskStore } from '@/stores/useDevTaskStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { useMemberStore } from '@/stores/useMemberStore';
import { useAuthStore } from '@/stores/useAuthStore';
import { resolveMemberDisplayName } from '@/lib/member-system';
import type { Priority } from '@/lib/derive';
import type { BugSeverity } from '@/lib/bug';
@@ -60,6 +61,7 @@ export function BugCreateModal({ testCaseId, onClose }: Props) {
const handleSubmit = () => {
if (!canSubmit) return;
const operator = user ? resolveMemberDisplayName(user.id, members) : '系统';
createBug({
versionId: tc?.versionId || '',
testCaseId,
@@ -68,10 +70,10 @@ export function BugCreateModal({ testCaseId, onClose }: Props) {
description: description.trim(),
severity,
priority,
reportedBy: user?.name || '系统',
reportedBy: operator,
assigneeId,
images: images.length > 0 ? images : undefined,
}, user?.name || '系统');
}, operator);
onClose();
};

View File

@@ -10,6 +10,7 @@ import { useMemberStore } from '@/stores/useMemberStore';
import { useAuthStore } from '@/stores/useAuthStore';
import { BUG_ALLOWED_TRANSITIONS, BUG_STATUS_LABEL, BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR } from '@/lib/bug';
import { formatDateTime } from '@/lib/format';
import { isMemberReference, resolveMemberDisplayName } from '@/lib/member-system';
import type { BugStatus } from '@/lib/bug';
const LOG_ACTION_LABEL: Record<string, string> = {
@@ -31,7 +32,7 @@ export function BugDetailDrawer({ bugId, onClose, contextLabel }: Props) {
const { requirements } = useRequirementStore();
const { members } = useMemberStore();
const user = useAuthStore((s) => s.user);
const operator = user?.name || '系统';
const operator = user ? resolveMemberDisplayName(user.id, members) : '系统';
const bug = bugs.find((b) => b.id === bugId);
if (!bug) return null;
@@ -39,6 +40,12 @@ export function BugDetailDrawer({ bugId, onClose, contextLabel }: Props) {
const tc = testCases.find((c) => c.id === bug.testCaseId);
const requirement = tc ? requirements.find((r) => r.id === tc.requirementId) : null;
const nextStatuses = BUG_ALLOWED_TRANSITIONS[bug.status];
const currentMember = user ? members.find((m) => m.id === user.id) : undefined;
const assigneeName = resolveMemberDisplayName(bug.assigneeId, members);
const reporterName = resolveMemberDisplayName(bug.reportedBy, members);
const isCurrentAssignee = currentMember
? isMemberReference(bug.assigneeId, currentMember)
: operator === assigneeName;
const [resolution, setResolution] = useState(bug.resolution || '');
const [showResolutionInput, setShowResolutionInput] = useState(false);
@@ -110,7 +117,7 @@ export function BugDetailDrawer({ bugId, onClose, contextLabel }: Props) {
<span className="text-[11px] text-[var(--ink-muted)]">{bug.priority}</span>
</div>
{nextStatuses.length > 0 && !showResolutionInput && operator === bug.assigneeId && (
{nextStatuses.length > 0 && !showResolutionInput && isCurrentAssignee && (
<div className="flex items-center gap-2 pt-1 flex-wrap">
<ChevronRight className="h-3.5 w-3.5 text-[var(--ink-muted)]" />
{nextStatuses.map((s) => (
@@ -125,8 +132,8 @@ export function BugDetailDrawer({ bugId, onClose, contextLabel }: Props) {
)}
</div>
)}
{nextStatuses.length > 0 && !showResolutionInput && operator !== bug.assigneeId && (
<div className="text-[11px] text-[var(--ink-muted)] pt-1"> {bug.assigneeId}</div>
{nextStatuses.length > 0 && !showResolutionInput && !isCurrentAssignee && (
<div className="text-[11px] text-[var(--ink-muted)] pt-1"> {assigneeName}</div>
)}
{showResolutionInput && (
@@ -144,7 +151,7 @@ export function BugDetailDrawer({ bugId, onClose, contextLabel }: Props) {
<div className="text-[11px] text-[var(--ink-muted)]"></div>
<select value={transferTo} onChange={(e) => setTransferTo(e.target.value)} className="h-8 w-full rounded-lg border border-[var(--line)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none">
<option value=""></option>
{members.filter((m) => m.name !== bug.assigneeId).map((m) => <option key={m.id} value={m.name}>{m.name}</option>)}
{members.filter((m) => !isMemberReference(bug.assigneeId, m)).map((m) => <option key={m.id} value={m.name}>{m.name}</option>)}
</select>
<input value={transferRemark} onChange={(e) => setTransferRemark(e.target.value)} placeholder="转交备注(可选)" className="h-8 w-full rounded-lg border border-[var(--line)] px-3 text-[12px] focus:border-[var(--accent)] focus:outline-none" />
<div className="flex gap-2">
@@ -159,8 +166,8 @@ export function BugDetailDrawer({ bugId, onClose, contextLabel }: Props) {
<div className="rounded-xl border border-[var(--line)] bg-[var(--bg-card)] p-4">
<div className="text-[10px] text-[var(--ink-muted)] uppercase tracking-wide mb-3"></div>
<div className="grid grid-cols-2 gap-y-3 gap-x-4 text-[12px]">
<div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)]">{bug.reportedBy}</span></div>
<div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)] font-medium">{bug.assigneeId}</span></div>
<div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)]">{reporterName}</span></div>
<div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)] font-medium">{assigneeName}</span></div>
<div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)]">{formatDateTime(bug.createdAt)}</span></div>
{bug.resolvedAt && <div><span className="text-[var(--ink-muted)]"></span><span className="text-[var(--ink)]">{formatDateTime(bug.resolvedAt)}</span></div>}
{bug.closedAt && <div><span className="text-[var(--ink-muted)]"></span><span className="text-emerald-600">{formatDateTime(bug.closedAt)}</span></div>}
@@ -202,10 +209,10 @@ export function BugDetailDrawer({ bugId, onClose, contextLabel }: Props) {
<div key={log.id} className="flex gap-2.5 text-[11px]">
<span className="text-[var(--ink-muted)] tabular-nums shrink-0 w-[110px]">{formatDateTime(log.createdAt)}</span>
<div className="flex-1">
<span className="font-medium text-[var(--ink)]">{log.operator}</span>
<span className="font-medium text-[var(--ink)]">{resolveMemberDisplayName(log.operator, members)}</span>
<span className="text-[var(--ink-soft)]"> {LOG_ACTION_LABEL[log.action] || log.action}</span>
{log.fromValue && log.toValue && (
<span className="text-[var(--ink-muted)]"> {log.fromValue} {log.toValue}</span>
<span className="text-[var(--ink-muted)]"> {resolveMemberDisplayName(log.fromValue, members)} {resolveMemberDisplayName(log.toValue, members)}</span>
)}
{log.remark && <span className="text-[var(--ink-muted)]"> ({log.remark})</span>}
</div>

View File

@@ -4,6 +4,8 @@ import { memo } from 'react';
import { BugStatusBadge } from './BugStatusBadge';
import { BUG_SEVERITY_LABEL, BUG_SEVERITY_COLOR, getBugActualHours } from '@/lib/bug';
import { formatWorkHours } from '@/lib/work-hours';
import { resolveMemberDisplayName } from '@/lib/member-system';
import { useMemberStore } from '@/stores/useMemberStore';
import type { Bug } from '@/lib/bug';
interface Props {
@@ -21,6 +23,8 @@ const PRIORITY_DOT: Record<string, string> = {
function BugRowImpl({ bug, testCaseNo, onClick }: Props) {
const actualHours = getBugActualHours(bug);
const members = useMemberStore((s) => s.members);
const assigneeName = resolveMemberDisplayName(bug.assigneeId, members);
return (
<div onClick={onClick} className="flex items-center gap-3 px-4 py-2.5 border-b border-[var(--line)] hover:bg-[var(--bg-subtle)] cursor-pointer transition-colors last:border-b-0">
<span className={`h-2 w-2 rounded-full shrink-0 ${PRIORITY_DOT[bug.priority] || 'bg-zinc-300'}`} />
@@ -32,7 +36,7 @@ function BugRowImpl({ bug, testCaseNo, onClick }: Props) {
<span className="text-[11px] text-[var(--ink-muted)] tabular-nums w-28 text-right shrink-0 whitespace-nowrap">{formatWorkHours(actualHours)}</span>
)}
{testCaseNo && <span className="text-[10px] font-mono text-[var(--ink-muted)] w-14 text-right shrink-0">{testCaseNo}</span>}
<span className="text-[11px] text-[var(--ink-soft)] w-14 text-right truncate shrink-0">{bug.assigneeId}</span>
<span className="text-[11px] text-[var(--ink-soft)] w-14 text-right truncate shrink-0">{assigneeName}</span>
</div>
);
}

View File

@@ -6,6 +6,7 @@ import { useBugStore } from '@/stores/useBugStore';
import { useTestCaseStore } from '@/stores/useTestCaseStore';
import { useRequirementStore } from '@/stores/useRequirementStore';
import { useAuthStore } from '@/stores/useAuthStore';
import { useMemberStore } from '@/stores/useMemberStore';
import { BugRow } from './BugRow';
import { BugDetailDrawer } from './BugDetailDrawer';
import { Pagination, usePagination } from '@/components/Pagination';
@@ -13,6 +14,7 @@ import { SearchInput, matchTitleOrNo } from '@/components/SearchInput';
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
import { BUG_STATUS_LABEL, BUG_SEVERITY_LABEL, bugIntervals } from '@/lib/bug';
import { formatWorkHours, calcTwoMetrics } from '@/lib/work-hours';
import { isMemberReference, resolveMemberDisplayName } from '@/lib/member-system';
import type { BugStatus, BugSeverity } from '@/lib/bug';
interface Props {
@@ -25,6 +27,7 @@ export function BugTab({ versionId, requirementIds }: Props) {
const { testCases, fetchTestCases } = useTestCaseStore();
const { requirements } = useRequirementStore();
const user = useAuthStore((s) => s.user);
const members = useMemberStore((s) => s.members);
useEffect(() => { fetchBugs(); }, [fetchBugs]);
useEffect(() => { fetchTestCases(); }, [fetchTestCases]);
@@ -48,26 +51,36 @@ export function BugTab({ versionId, requirementIds }: Props) {
const [keyword, setKeyword] = useState('');
const debouncedKeyword = useDebouncedValue(keyword, 300);
const [selectedBugId, setSelectedBugId] = useState<string | null>(null);
const testCaseMap = useMemo(() => new Map(testCases.map((c) => [c.id, c])), [testCases]);
const currentMember = useMemo(() => members.find((m) => m.id === user?.id), [members, user?.id]);
const currentUserName = currentMember?.name ?? user?.name;
const assignees = useMemo(() => {
const names = new Set(versionBugs.map((b) => resolveMemberDisplayName(b.assigneeId, members)));
return Array.from(names);
}, [versionBugs, members]);
const filteredBugs = useMemo(() => {
let result = versionBugs;
if (filterAssignee) result = result.filter((b) => b.assigneeId === filterAssignee);
if (filterAssignee === '__me') {
if (currentMember) {
result = result.filter((b) => isMemberReference(b.assigneeId, currentMember));
} else {
result = [];
}
} else if (filterAssignee) {
result = result.filter((b) => resolveMemberDisplayName(b.assigneeId, members) === filterAssignee);
}
if (filterStatus) result = result.filter((b) => b.status === filterStatus);
if (filterSeverity) result = result.filter((b) => b.severity === filterSeverity);
if (debouncedKeyword) result = result.filter((b) => matchTitleOrNo({ title: b.title, no: b.bugNo }, debouncedKeyword));
return result;
}, [versionBugs, filterAssignee, filterStatus, filterSeverity, debouncedKeyword]);
}, [versionBugs, filterAssignee, filterStatus, filterSeverity, debouncedKeyword, currentMember, members]);
const { paged, page, setPage, total, pageSize, setPageSize } = usePagination(filteredBugs, 20);
const [selectedBugId, setSelectedBugId] = useState<string | null>(null);
const testCaseMap = useMemo(() => new Map(testCases.map((c) => [c.id, c])), [testCases]);
const assignees = useMemo(() => {
const names = new Set(versionBugs.map((b) => b.assigneeId));
return Array.from(names);
}, [versionBugs]);
const hasFilter = !!(filterAssignee || filterStatus || filterSeverity || debouncedKeyword);
return (
@@ -94,8 +107,8 @@ export function BugTab({ versionId, requirementIds }: Props) {
<SearchInput value={keyword} onChange={(v) => { setKeyword(v); setPage(1); }} placeholder="搜索标题/编号" />
<select value={filterAssignee} onChange={(e) => { setFilterAssignee(e.target.value); setPage(1); }} className="h-7 rounded-md border border-[var(--line)] bg-[var(--bg-card)] px-2 text-[11px] text-[var(--ink-soft)] focus:border-[var(--accent)] focus:outline-none">
<option value=""></option>
{user?.name && <option value={user.name}>Bug</option>}
{assignees.filter((a) => a !== user?.name).map((a) => <option key={a} value={a}>{a}</option>)}
{currentMember && <option value="__me">Bug</option>}
{assignees.filter((a) => a !== currentUserName).map((a) => <option key={a} value={a}>{a}</option>)}
</select>
<select value={filterStatus} onChange={(e) => { setFilterStatus(e.target.value); setPage(1); }} className="h-7 rounded-md border border-[var(--line)] bg-[var(--bg-card)] px-2 text-[11px] text-[var(--ink-soft)] focus:border-[var(--accent)] focus:outline-none">
<option value=""></option>

View File

@@ -0,0 +1,57 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { resolveAuthUserFromMembers, toAuthUser, type AuthUser } from './auth-user';
import type { Member } from './members';
const member: Member = {
id: 'm-8',
name: '超级管理员',
departmentId: 'dept-1',
roleId: 'role-admin',
phone: '13200132008',
email: 'admin@company.com',
password: 'Ftb@2024',
createdAt: '2024-01-01',
};
test('toAuthUser removes password and keeps stable member identity', () => {
assert.deepEqual(toAuthUser(member), {
id: 'm-8',
name: '超级管理员',
departmentId: 'dept-1',
roleId: 'role-admin',
phone: '13200132008',
email: 'admin@company.com',
});
});
test('resolveAuthUserFromMembers refreshes stale display names by member id', () => {
const staleUser: AuthUser = {
id: 'm-8',
name: '陈十',
departmentId: 'dept-1',
roleId: 'role-admin',
phone: '13200132008',
email: 'old@company.com',
};
const resolved = resolveAuthUserFromMembers(staleUser, [member]);
assert.equal(resolved?.id, 'm-8');
assert.equal(resolved?.name, '超级管理员');
assert.equal(resolved?.email, 'admin@company.com');
});
test('resolveAuthUserFromMembers returns null when the account no longer exists', () => {
const staleUser: AuthUser = {
id: 'm-9',
name: '离职用户',
departmentId: 'dept-2',
roleId: 'role-dev',
phone: '13900139000',
email: 'old@company.com',
};
assert.equal(resolveAuthUserFromMembers(staleUser, [member]), null);
});

26
apps/web/lib/auth-user.ts Normal file
View File

@@ -0,0 +1,26 @@
import type { Member } from './members';
export interface AuthUser {
id: string;
name: string;
roleId: string;
departmentId: string;
phone: string;
email: string;
}
export function toAuthUser(member: Member): AuthUser {
return {
id: member.id,
name: member.name,
roleId: member.roleId,
departmentId: member.departmentId,
phone: member.phone,
email: member.email,
};
}
export function resolveAuthUserFromMembers(user: AuthUser, members: Member[]): AuthUser | null {
const member = members.find((m) => m.id === user.id);
return member ? toAuthUser(member) : null;
}

View File

@@ -0,0 +1,84 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
SYSTEM_ADMIN_MEMBER_ID,
SYSTEM_ADMIN_MEMBER_NAME,
SYSTEM_ADMIN_ROLE_ID,
ensureSystemAdminMember,
sanitizeSystemAdminPatch,
canDeleteSystemMember,
resolveMemberDisplayName,
isMemberReference,
} from './member-system';
import type { Member } from './members';
const oldAdmin: Member = {
id: SYSTEM_ADMIN_MEMBER_ID,
name: '陈十',
departmentId: 'dept-2',
roleId: 'role-dev',
phone: '13200132008',
email: 'chenshi@company.com',
password: 'Ftb@2024',
createdAt: '2024-01-01',
};
test('normalizes the built-in admin account when old data named it as a regular user', () => {
const result = ensureSystemAdminMember([oldAdmin]);
assert.equal(result.changed, true);
assert.equal(result.members.length, 1);
assert.equal(result.members[0].id, SYSTEM_ADMIN_MEMBER_ID);
assert.equal(result.members[0].name, SYSTEM_ADMIN_MEMBER_NAME);
assert.equal(result.members[0].roleId, SYSTEM_ADMIN_ROLE_ID);
assert.equal(result.members[0].isSystem, true);
assert.equal(result.members[0].phone, oldAdmin.phone);
});
test('adds a built-in admin account when member data has none', () => {
const result = ensureSystemAdminMember([]);
assert.equal(result.changed, true);
assert.equal(result.members.length, 1);
assert.equal(result.members[0].id, SYSTEM_ADMIN_MEMBER_ID);
assert.equal(result.members[0].name, SYSTEM_ADMIN_MEMBER_NAME);
assert.equal(result.members[0].roleId, SYSTEM_ADMIN_ROLE_ID);
});
test('protects the built-in admin account from identity and role edits', () => {
const patch = sanitizeSystemAdminPatch(SYSTEM_ADMIN_MEMBER_ID, {
name: '陈十',
roleId: 'role-dev',
departmentId: 'dept-3',
phone: '13900139000',
});
assert.equal(patch.name, undefined);
assert.equal(patch.roleId, undefined);
assert.equal(patch.departmentId, undefined);
assert.equal(patch.phone, '13900139000');
});
test('blocks deleting the built-in admin account', () => {
assert.equal(canDeleteSystemMember(SYSTEM_ADMIN_MEMBER_ID), false);
assert.equal(canDeleteSystemMember('m-9'), true);
});
test('resolves legacy Chen Shi labels to the built-in admin display name', () => {
const result = ensureSystemAdminMember([oldAdmin]);
assert.equal(resolveMemberDisplayName('陈十', result.members), SYSTEM_ADMIN_MEMBER_NAME);
assert.equal(resolveMemberDisplayName(SYSTEM_ADMIN_MEMBER_ID, result.members), SYSTEM_ADMIN_MEMBER_NAME);
assert.equal(resolveMemberDisplayName('张三', result.members), '张三');
});
test('matches legacy admin names as the built-in admin member', () => {
const result = ensureSystemAdminMember([oldAdmin]);
const admin = result.members[0];
assert.equal(isMemberReference('陈十', admin), true);
assert.equal(isMemberReference(SYSTEM_ADMIN_MEMBER_ID, admin), true);
assert.equal(isMemberReference(SYSTEM_ADMIN_MEMBER_NAME, admin), true);
assert.equal(isMemberReference('张三', admin), false);
});

View File

@@ -0,0 +1,92 @@
import type { Member } from './members';
export const SYSTEM_ADMIN_MEMBER_ID = 'm-8';
export const SYSTEM_ADMIN_MEMBER_NAME = '超级管理员';
export const SYSTEM_ADMIN_ROLE_ID = 'role-admin';
export const SYSTEM_ADMIN_DEPARTMENT_ID = 'dept-1';
const LEGACY_SYSTEM_ADMIN_NAMES = ['陈十'];
export const SYSTEM_ADMIN_MEMBER: Member = {
id: SYSTEM_ADMIN_MEMBER_ID,
name: SYSTEM_ADMIN_MEMBER_NAME,
departmentId: SYSTEM_ADMIN_DEPARTMENT_ID,
roleId: SYSTEM_ADMIN_ROLE_ID,
phone: '13200132008',
email: 'admin@company.com',
password: 'Ftb@2024',
createdAt: '2024-01-01',
isSystem: true,
};
function normalizeSystemAdminMember(member: Partial<Member>): Member {
return {
...SYSTEM_ADMIN_MEMBER,
...member,
id: SYSTEM_ADMIN_MEMBER_ID,
name: SYSTEM_ADMIN_MEMBER_NAME,
departmentId: SYSTEM_ADMIN_DEPARTMENT_ID,
roleId: SYSTEM_ADMIN_ROLE_ID,
phone: member.phone || SYSTEM_ADMIN_MEMBER.phone,
email: member.email || SYSTEM_ADMIN_MEMBER.email,
password: member.password || SYSTEM_ADMIN_MEMBER.password,
createdAt: member.createdAt || SYSTEM_ADMIN_MEMBER.createdAt,
isSystem: true,
};
}
export function ensureSystemAdminMember(members: Member[]): { members: Member[]; changed: boolean } {
const index = members.findIndex((m) => m.id === SYSTEM_ADMIN_MEMBER_ID);
if (index === -1) {
return { members: [SYSTEM_ADMIN_MEMBER, ...members], changed: true };
}
const current = members[index];
const normalized = normalizeSystemAdminMember(current);
const changed =
current.name !== normalized.name ||
current.departmentId !== normalized.departmentId ||
current.roleId !== normalized.roleId ||
current.phone !== normalized.phone ||
current.email !== normalized.email ||
current.password !== normalized.password ||
current.createdAt !== normalized.createdAt ||
current.isSystem !== true;
if (!changed) return { members, changed: false };
return {
members: members.map((m, i) => (i === index ? normalized : m)),
changed: true,
};
}
export function sanitizeSystemAdminPatch(id: string, data: Partial<Member>): Partial<Member> {
if (id !== SYSTEM_ADMIN_MEMBER_ID) return data;
const { name, departmentId, roleId, isSystem, ...allowed } = data;
return { ...allowed, isSystem: true };
}
export function canDeleteSystemMember(id: string): boolean {
return id !== SYSTEM_ADMIN_MEMBER_ID;
}
export function isSystemAdminMember(member: Pick<Member, 'id'>): boolean {
return member.id === SYSTEM_ADMIN_MEMBER_ID;
}
export function resolveMemberDisplayName(ref: string | undefined | null, members: Member[]): string {
if (!ref) return '-';
const member = members.find((m) => m.id === ref || m.name === ref);
if (member) return member.name;
if (LEGACY_SYSTEM_ADMIN_NAMES.includes(ref)) {
const admin = members.find((m) => m.id === SYSTEM_ADMIN_MEMBER_ID);
return admin?.name ?? SYSTEM_ADMIN_MEMBER_NAME;
}
return ref;
}
export function isMemberReference(ref: string | undefined | null, member: Pick<Member, 'id' | 'name'>): boolean {
if (!ref) return false;
if (ref === member.id || ref === member.name) return true;
return member.id === SYSTEM_ADMIN_MEMBER_ID && LEGACY_SYSTEM_ADMIN_NAMES.includes(ref);
}

View File

@@ -15,6 +15,7 @@ export interface Member {
email: string;
password: string;
createdAt: string;
isSystem?: boolean;
}
export interface RoleItem {

View File

@@ -3,17 +3,10 @@
import { create } from 'zustand';
import { loadServerData } from '@/lib/server-data';
import type { Member } from '@/lib/members';
import { SYSTEM_ADMIN_MEMBER, ensureSystemAdminMember } from '@/lib/member-system';
import { resolveAuthUserFromMembers, toAuthUser, type AuthUser } from '@/lib/auth-user';
import { useMemberStore } from './useMemberStore';
interface AuthUser {
id: string;
name: string;
roleId: string;
departmentId: string;
phone: string;
email: string;
}
interface AuthState {
user: AuthUser | null;
isAuthenticated: boolean;
@@ -30,38 +23,42 @@ interface StoredMembersData {
const SESSION_KEY = 'ftb_auth_session';
const PERSIST_KEY = 'ftb_auth_persist';
const FALLBACK_MEMBERS: Member[] = [
{
id: 'm-8',
name: '\u8d85\u7ea7\u7ba1\u7406\u5458',
departmentId: 'dept-1',
roleId: 'role-admin',
phone: '13200132008',
email: 'chenshi@company.com',
password: 'Ftb@2024',
createdAt: '2024-01-01',
},
];
const FALLBACK_MEMBERS: Member[] = [SYSTEM_ADMIN_MEMBER];
function writeAuthStorage(user: AuthUser, remember: boolean) {
sessionStorage.setItem(SESSION_KEY, JSON.stringify(user));
if (remember) {
localStorage.setItem(PERSIST_KEY, JSON.stringify(user));
}
}
function clearAuthStorage() {
sessionStorage.removeItem(SESSION_KEY);
localStorage.removeItem(PERSIST_KEY);
}
async function loadMembersForAuth(): Promise<Member[]> {
try {
const stored = await loadServerData<StoredMembersData>('members');
if (stored?.members?.length) return stored.members;
if (stored?.members?.length) return ensureSystemAdminMember(stored.members).members;
} catch {}
const inMemoryMembers = useMemberStore.getState().members;
return inMemoryMembers.length > 0 ? inMemoryMembers : FALLBACK_MEMBERS;
return inMemoryMembers.length > 0 ? ensureSystemAdminMember(inMemoryMembers).members : FALLBACK_MEMBERS;
}
function toAuthUser(member: Member): AuthUser {
return {
id: member.id,
name: member.name,
roleId: member.roleId,
departmentId: member.departmentId,
phone: member.phone,
email: member.email,
};
async function refreshStoredAuthUser(user: AuthUser, remember: boolean, set: (state: Partial<AuthState>) => void) {
try {
const members = await loadMembersForAuth();
const next = resolveAuthUserFromMembers(user, members);
if (!next) {
clearAuthStorage();
set({ user: null, isAuthenticated: false });
return;
}
set({ user: next, isAuthenticated: true });
writeAuthStorage(next, remember);
} catch {}
}
export const useAuthStore = create<AuthState>((set) => ({
@@ -75,30 +72,29 @@ export const useAuthStore = create<AuthState>((set) => ({
const user = toAuthUser(member);
set({ user, isAuthenticated: true });
sessionStorage.setItem(SESSION_KEY, JSON.stringify(user));
if (remember) {
localStorage.setItem(PERSIST_KEY, JSON.stringify(user));
}
writeAuthStorage(user, remember);
return true;
},
logout: () => {
set({ user: null, isAuthenticated: false });
sessionStorage.removeItem(SESSION_KEY);
localStorage.removeItem(PERSIST_KEY);
clearAuthStorage();
},
checkAuth: () => {
const session = sessionStorage.getItem(SESSION_KEY);
if (session) {
set({ user: JSON.parse(session), isAuthenticated: true });
const user = JSON.parse(session);
set({ user, isAuthenticated: true });
void refreshStoredAuthUser(user, !!localStorage.getItem(PERSIST_KEY), set);
return;
}
const persist = localStorage.getItem(PERSIST_KEY);
if (persist) {
const user = JSON.parse(persist);
set({ user, isAuthenticated: true });
sessionStorage.setItem(SESSION_KEY, JSON.stringify(user));
writeAuthStorage(user, true);
void refreshStoredAuthUser(user, true, set);
return;
}
},
@@ -114,10 +110,7 @@ export const useAuthStore = create<AuthState>((set) => ({
const next = toAuthUser(member);
useAuthStore.setState({ user: next });
sessionStorage.setItem(SESSION_KEY, JSON.stringify(next));
if (localStorage.getItem(PERSIST_KEY)) {
localStorage.setItem(PERSIST_KEY, JSON.stringify(next));
}
writeAuthStorage(next, !!localStorage.getItem(PERSIST_KEY));
} catch {}
},
}));

View File

@@ -2,6 +2,13 @@
import { create } from 'zustand';
import type { Department, Member, RoleItem, PasswordRule } from '@/lib/members';
import { DEFAULT_PASSWORD_RULE, generatePassword } from '@/lib/members';
import {
SYSTEM_ADMIN_MEMBER,
SYSTEM_ADMIN_ROLE_ID,
canDeleteSystemMember,
ensureSystemAdminMember,
sanitizeSystemAdminPatch,
} from '@/lib/member-system';
import { DEFAULT_ROLE_PERMISSIONS } from '@/lib/permissions';
import { loadServerData, saveServerData } from '@/lib/server-data';
@@ -24,7 +31,7 @@ const PRESET_ROLES: RoleItem[] = [
];
const MOCK_MEMBERS: Member[] = [
{ id: 'm-8', name: '超级管理员', departmentId: 'dept-1', roleId: 'role-admin', phone: '13200132008', email: 'chenshi@company.com', password: 'Ftb@2024', createdAt: '2024-01-01' },
SYSTEM_ADMIN_MEMBER,
];
interface MemberState {
@@ -74,7 +81,22 @@ export const useMemberStore = create<MemberState>((set, get) => ({
}
return r;
});
set({ departments: cached.departments, members: cached.members, roles: migratedRoles, passwordRule: cached.passwordRule || DEFAULT_PASSWORD_RULE });
const adminRole = PRESET_ROLES.find((r) => r.id === SYSTEM_ADMIN_ROLE_ID)!;
const roles = migratedRoles.some((r) => r.id === SYSTEM_ADMIN_ROLE_ID)
? migratedRoles.map((r) => r.id === SYSTEM_ADMIN_ROLE_ID ? { ...adminRole, ...r, isSystem: true, permissions: DEFAULT_ROLE_PERMISSIONS[SYSTEM_ADMIN_ROLE_ID] } : r)
: [adminRole, ...migratedRoles];
const rolesChanged =
roles.length !== migratedRoles.length ||
roles.some((role, index) => JSON.stringify(role) !== JSON.stringify(migratedRoles[index]));
const ensured = ensureSystemAdminMember(cached.members);
const next = {
departments: cached.departments,
members: ensured.members,
roles,
passwordRule: cached.passwordRule || DEFAULT_PASSWORD_RULE,
};
set(next);
if (ensured.changed || rolesChanged) saveStored(next);
}
},
@@ -107,11 +129,14 @@ export const useMemberStore = create<MemberState>((set, get) => ({
saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule });
},
updateMember: (id, data) => {
const members = get().members.map((m) => m.id === id ? { ...m, ...data } : m);
set({ members });
saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule });
const safeData = sanitizeSystemAdminPatch(id, data);
const updatedMembers = get().members.map((m) => m.id === id ? { ...m, ...safeData } : m);
const ensured = ensureSystemAdminMember(updatedMembers);
set({ members: ensured.members });
saveStored({ departments: get().departments, members: ensured.members, roles: get().roles, passwordRule: get().passwordRule });
},
deleteMember: (id) => {
if (!canDeleteSystemMember(id)) return;
const members = get().members.filter((m) => m.id !== id);
set({ members });
saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule });
@@ -142,6 +167,7 @@ export const useMemberStore = create<MemberState>((set, get) => ({
saveStored({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule });
},
deleteRole: (id) => {
if (id === SYSTEM_ADMIN_ROLE_ID) return;
const roles = get().roles.filter((r) => r.id !== id);
set({ roles });
saveStored({ departments: get().departments, members: get().members, roles, passwordRule: get().passwordRule });

View File

@@ -300,3 +300,18 @@
**理由**
- 去重不能只依赖模型提示,必须有系统规则兜底。
- 标题也参与签名,避免同一需求/QY 下不同真实工作项被误过滤。
## 26. 系统内置超级管理员账号不可删除
**问题**:全新系统需要一个稳定可登录的超级管理员账号。早期默认成员曾以普通姓名展示,用户手动改名后,登录态和 Bug 单据中的人员姓名可能仍保留旧快照,造成“提交人是旧姓名、修复人是新姓名”的错位。
**决策**
- 固定 `m-8` 为系统内置超级管理员账号,姓名、部门、角色由系统保护,不允许删除。
- 读取历史成员数据时自动迁移旧默认账号为“超级管理员”,保留手机号、邮箱、密码。
- 登录态以成员 `id` 为稳定身份,页面启动和编辑当前用户后都按成员表刷新姓名、角色和联系方式。
- Bug 展示和筛选对历史旧名做兼容解析,旧单据中的默认账号旧姓名显示为当前超级管理员。
**理由**
- 账号身份不能依赖可编辑姓名;姓名只是展示字段。
- 内置超管账号避免新系统初始化后被误删或降权导致无法管理。
- 历史单据兼容能修复已有数据的显示错位,同时不需要批量改写业务记录。