Add candidate account archiving and password reset
This commit is contained in:
@@ -111,6 +111,9 @@ export function createAdminRoutes(context) {
|
||||
const user = await requireUser(request, response, 'admin');
|
||||
if (!user) return true;
|
||||
const db = await readDb();
|
||||
if (request.method === 'DELETE' && /^\/api\/admin\/(?:admins|candidates|candidate-accounts)(?:\/|$)/.test(pathname)) {
|
||||
return sendError(response, 405, '账户不得删除;考生账户请由校方归档,管理员账户可停用');
|
||||
}
|
||||
|
||||
if (request.method === 'GET' && pathname === '/api/admin/context') {
|
||||
return sendJson(response, 200, {
|
||||
@@ -579,10 +582,63 @@ export function createAdminRoutes(context) {
|
||||
const candidates = db.candidateProfiles.filter(profile => profileInScope(user, profile)).map(profile => {
|
||||
const instance = pendingWorkflow(db, 'profile_change', profile.id) || db.workflowInstances.filter(item => item.businessType === 'profile_change' && item.businessId === profile.id)[0];
|
||||
const account = db.users.find(item => item.id === profile.userId);
|
||||
return { ...profile, idNumberMasked: profile.idNumber.startsWith('PENDING-') ? '待考生补充' : maskId(profile.idNumber), username: account?.username, candidateNumber: account?.candidateNumber || '', mustChangePassword: Boolean(account?.mustChangePassword), workflow: workflowView(db, instance) };
|
||||
return {
|
||||
...profile,
|
||||
idNumberMasked: profile.idNumber.startsWith('PENDING-') ? '待考生补充' : maskId(profile.idNumber),
|
||||
username: account?.username,
|
||||
candidateNumber: account?.candidateNumber || '',
|
||||
mustChangePassword: Boolean(account?.mustChangePassword),
|
||||
accountArchived: Boolean(account?.archivedAt),
|
||||
archivedAt: account?.archivedAt || null,
|
||||
archivedByName: db.users.find(item => item.id === account?.archivedBy)?.displayName || '',
|
||||
workflow: workflowView(db, instance)
|
||||
};
|
||||
});
|
||||
return sendJson(response, 200, { ok: true, candidates, schools: user.adminLevel === 'super' ? db.schools.filter(item => item.active) : db.schools.filter(item => item.id === user.schoolId && item.active), classes: db.classes.filter(item => item.active && (user.adminLevel === 'super' || item.schoolId === user.schoolId)) });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/admin/candidate-accounts/archive') {
|
||||
if (user.adminLevel !== 'school') return sendError(response, 403, '考生账户归档由校级管理员负责');
|
||||
const body = await readJson(request);
|
||||
const scopeType = cleanText(body.scopeType, 20);
|
||||
const scopeValue = cleanText(body.scopeValue, 100);
|
||||
const archived = Boolean(body.archived);
|
||||
if (!['class', 'grade'].includes(scopeType) || !scopeValue) return sendError(response, 400, '请选择要归档的班级或年级');
|
||||
const scopedClasses = db.classes.filter(item => item.schoolId === user.schoolId);
|
||||
const targetClassIds = scopeType === 'class'
|
||||
? scopedClasses.filter(item => item.id === scopeValue).map(item => item.id)
|
||||
: scopedClasses.filter(item => item.grade === scopeValue).map(item => item.id);
|
||||
if (!targetClassIds.length) return sendError(response, 404, '所选班级或年级不在本校范围内');
|
||||
const targetIds = new Set(db.candidateProfiles.filter(profile => profile.schoolId === user.schoolId && targetClassIds.includes(profile.classId)).map(profile => profile.userId));
|
||||
const targets = db.users.filter(item => item.role === 'candidate' && targetIds.has(item.id) && Boolean(item.archivedAt) !== archived);
|
||||
const changedAt = archived ? nowIso() : null;
|
||||
for (const target of targets) {
|
||||
target.archivedAt = changedAt;
|
||||
target.archivedBy = archived ? user.id : null;
|
||||
}
|
||||
const scopeLabel = scopeType === 'class'
|
||||
? db.classes.find(item => item.id === scopeValue)?.name
|
||||
: scopeValue;
|
||||
await database.updateCandidateArchives(targets, logAction(db, user, archived ? '批量归档考生账户' : '批量恢复考生账户', `${scopeLabel} · ${targets.length} 个账户`));
|
||||
if (archived && targets.length) {
|
||||
const targetUserIds = new Set(targets.map(item => item.id));
|
||||
for (const [token, session] of sessions) if (targetUserIds.has(session.userId)) sessions.delete(token);
|
||||
}
|
||||
return sendJson(response, 200, { ok: true, archived, count: targets.length, scopeLabel });
|
||||
}
|
||||
const candidatePasswordResetMatch = pathname.match(/^\/api\/admin\/candidates\/([^/]+)\/reset-password$/);
|
||||
if (request.method === 'POST' && candidatePasswordResetMatch) {
|
||||
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以重置考生密码');
|
||||
const profile = db.candidateProfiles.find(item => item.id === candidatePasswordResetMatch[1]);
|
||||
const target = db.users.find(item => item.id === profile?.userId && item.role === 'candidate');
|
||||
if (!profile || !target) return sendError(response, 404, '考生账户不存在');
|
||||
if (target.archivedAt) return sendError(response, 409, '归档账户需由校方恢复后才能重置密码');
|
||||
const temporaryPassword = `Reset-${randomBytes(7).toString('base64url')}`;
|
||||
target.passwordHash = hashPassword(temporaryPassword);
|
||||
target.mustChangePassword = true;
|
||||
await database.changePassword(target, logAction(db, user, '重置考生密码', `${target.candidateNumber} · ${profile.name}`));
|
||||
for (const [token, session] of sessions) if (session.userId === target.id) sessions.delete(token);
|
||||
return sendJson(response, 200, { ok: true, candidateNumber: target.candidateNumber, temporaryPassword });
|
||||
}
|
||||
const candidateMatch = pathname.match(/^\/api\/admin\/candidates\/([^/]+)$/);
|
||||
if (request.method === 'PATCH' && candidateMatch) {
|
||||
if (!requirePermission(user, response, 'candidates.review')) return true;
|
||||
|
||||
@@ -85,7 +85,7 @@ export function createAuthRoutes(context) {
|
||||
const db = await readDb();
|
||||
const account = cleanText(body.username, 120).toLowerCase();
|
||||
const user = db.users.find(item => item.username.toLowerCase() === account || String(item.candidateNumber || '').toLowerCase() === account);
|
||||
if (!user || user.active === false || !verifyPassword(String(body.password || ''), user.passwordHash)) return sendError(response, 401, '账号或密码不正确');
|
||||
if (!user || user.active === false || user.archivedAt || !verifyPassword(String(body.password || ''), user.passwordHash)) return sendError(response, 401, '账号或密码不正确');
|
||||
const token = randomBytes(32).toString('hex');
|
||||
sessions.set(token, { userId: user.id, expiresAt: Date.now() + 8 * 60 * 60 * 1000 });
|
||||
return sendJson(response, 200, { ok: true, user: safeUser(user) }, { 'Set-Cookie': `hz_session=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=28800` });
|
||||
@@ -98,7 +98,7 @@ export function createAuthRoutes(context) {
|
||||
const newPassword = String(body.newPassword || '');
|
||||
if (!verifyPassword(currentPassword, user.passwordHash)) return sendError(response, 400, '当前密码不正确');
|
||||
if (newPassword.length < 8) return sendError(response, 400, '新密码至少需要 8 位');
|
||||
if (newPassword === currentPassword) return sendError(response, 400, '新密码不能与初始密码相同');
|
||||
if (newPassword === currentPassword) return sendError(response, 400, '新密码不能与当前密码相同');
|
||||
user.passwordHash = hashPassword(newPassword);
|
||||
user.mustChangePassword = false;
|
||||
const db = await readDb();
|
||||
|
||||
Reference in New Issue
Block a user