diff --git a/src/modules/layout/config/navigation.ts b/src/modules/layout/config/navigation.ts index aca6bf8..98a05aa 100644 --- a/src/modules/layout/config/navigation.ts +++ b/src/modules/layout/config/navigation.ts @@ -95,6 +95,8 @@ export const NAV_CONFIG: Partial> = { items: [ { title: "admin.userList", href: "/admin/users" }, { title: "admin.importUsers", href: "/admin/users/import", permission: Permissions.USER_MANAGE }, + { title: "admin.roles", href: "/admin/roles", permission: Permissions.ROLE_READ }, + { title: "admin.permissions", href: "/admin/permissions", permission: Permissions.PERMISSION_READ }, ] }, { diff --git a/src/modules/users/actions.ts b/src/modules/users/actions.ts index 1c8caa5..57e627d 100644 --- a/src/modules/users/actions.ts +++ b/src/modules/users/actions.ts @@ -2,15 +2,12 @@ import { revalidatePath } from "next/cache" import { z } from "zod" -import { eq } from "drizzle-orm" import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import type { ActionState } from "@/shared/types/action-state" import { parseExcel } from "@/shared/lib/excel" import { formatDateForFile } from "@/shared/lib/utils" -import { db } from "@/shared/db" -import { users } from "@/shared/db/schema" import { batchImportUsers, @@ -19,7 +16,7 @@ import { parseUserImportData, type UserImportResult, } from "./import-export" -import { updateUserProfileById, type UpdateUserProfileInput } from "./data-access" +import { deleteUserById, updateUserProfileById, type UpdateUserProfileInput } from "./data-access" /** Zod schema for self-service profile update (P0-13) */ const UpdateUserProfileSchema = z.object({ @@ -171,41 +168,18 @@ export async function exportUsersAction( } } -/** - * 更新用户角色(管理员) - */ -export async function updateUserRoleAction( - prevState: ActionState, - formData: FormData -): Promise> { - try { - await requirePermission(Permissions.USER_MANAGE) - const userId = formData.get("userId") as string - const role = formData.get("role") as string - // 简化实现:更新用户角色 - void userId - void role - return { success: true, message: "用户角色已更新" } - } catch (e) { - if (e instanceof PermissionDeniedError) { - return { success: false, message: e.message } - } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "更新用户角色失败" } - } -} - /** * 删除用户(管理员) + * + * P0-2 修复:DB 操作下沉到 data-access.deleteUserById,包含最后管理员保护。 + * P0-3 修复:客户端通过 Server Action 调用,不再用 fetch。 */ export async function deleteUserAction( - prevState: ActionState, - formData: FormData -): Promise> { + userId: string +): Promise> { try { await requirePermission(Permissions.USER_MANAGE) - const userId = formData.get("userId") as string - await db.delete(users).where(eq(users.id, userId)) + await deleteUserById(userId) revalidatePath("/admin/users") return { success: true, message: "用户已删除" } } catch (e) { diff --git a/src/modules/users/components/admin-users-view.tsx b/src/modules/users/components/admin-users-view.tsx index 1f99aa3..10b5868 100644 --- a/src/modules/users/components/admin-users-view.tsx +++ b/src/modules/users/components/admin-users-view.tsx @@ -2,10 +2,8 @@ import * as React from "react" import Link from "next/link" -import { useRouter } from "next/navigation" -import { useSearchParams } from "next/navigation" -import { Search, Users, Upload, MoreHorizontal, Trash2, Pencil } from "lucide-react" -import { toast } from "sonner" +import { useRouter, useSearchParams } from "next/navigation" +import { Search, Users, Upload, MoreHorizontal, Trash2, Pencil, UserCog } from "lucide-react" import { Button } from "@/shared/components/ui/button" import { Input } from "@/shared/components/ui/input" @@ -44,6 +42,13 @@ import { } from "@/shared/components/ui/alert-dialog" import { EmptyState } from "@/shared/components/ui/empty-state" import { formatDate } from "@/shared/lib/utils" +import { useActionMutation } from "@/shared/hooks/use-action-mutation" +import { useTranslations } from "next-intl" +import { + UserRoleAssignDialog, + type AssignableRole, +} from "@/modules/rbac/components/user-role-assign-dialog" +import { deleteUserAction } from "../actions" interface AdminUserListItem { id: string @@ -63,6 +68,8 @@ interface AdminUsersViewProps { totalPages: number search: string roleFilter: string + /** All roles (with id/name/isSystem/isEnabled) for the assign dialog. */ + assignableRoles?: AssignableRole[] } export function AdminUsersView({ @@ -74,12 +81,22 @@ export function AdminUsersView({ totalPages, search, roleFilter, + assignableRoles, }: AdminUsersViewProps) { const router = useRouter() const searchParams = useSearchParams() + const t = useTranslations("users") const [searchInput, setSearchInput] = React.useState(search) const [deleteUserId, setDeleteUserId] = React.useState(null) - const [deleting, setDeleting] = React.useState(false) + const [assignTarget, setAssignTarget] = React.useState(null) + + const deleteMutation = useActionMutation({ + successMessage: t("messages.deleted"), + onSuccess: () => { + setDeleteUserId(null) + router.refresh() + }, + }) const updateParams = React.useCallback( (updates: Record) => { @@ -105,22 +122,9 @@ export function AdminUsersView({ updateParams({ search: searchInput, page: undefined }) } - const handleDelete = async () => { + const handleDelete = (): void => { if (!deleteUserId) return - setDeleting(true) - try { - const res = await fetch("/api/admin/users/" + deleteUserId, { - method: "DELETE", - }) - if (!res.ok) throw new Error("删除失败") - toast.success("用户已删除") - setDeleteUserId(null) - router.refresh() - } catch (e) { - toast.error("删除失败:" + (e as Error).message) - } finally { - setDeleting(false) - } + void deleteMutation.mutate(() => deleteUserAction(deleteUserId)) } const start = total === 0 ? 0 : (page - 1) * pageSize + 1 @@ -130,13 +134,13 @@ export function AdminUsersView({
-

用户管理

-

管理所有系统用户,包括查看、搜索、删除。

+

{t("title")}

+

{t("adminDescription")}

@@ -147,7 +151,7 @@ export function AdminUsersView({
setSearchInput(e.target.value)} className="pl-9" @@ -158,10 +162,10 @@ export function AdminUsersView({ onValueChange={(v) => updateParams({ role: v === "all" ? undefined : v, page: undefined })} > - + - 所有角色 + {t("allRoles")} {roleOptions.map((r) => ( {r} @@ -169,7 +173,7 @@ export function AdminUsersView({ ))} - + {(search || roleFilter) && ( )} @@ -191,8 +195,8 @@ export function AdminUsersView({ {users.length === 0 ? ( ) : ( <> @@ -200,12 +204,12 @@ export function AdminUsersView({ - 姓名 - 邮箱 - 角色 - 手机 - 注册时间 - 操作 + {t("columns.name")} + {t("columns.email")} + {t("columns.role")} + {t("columns.phone")} + {t("columns.createdAt")} + {t("columns.actions")} @@ -216,7 +220,7 @@ export function AdminUsersView({
{u.roles.length === 0 ? ( - 未分配 + {t("unassigned")} ) : ( u.roles.map((r) => ( {r} @@ -236,14 +240,20 @@ export function AdminUsersView({ - 编辑 + {t("actions.edit")} + {assignableRoles && ( + setAssignTarget(u)}> + + {t("actions.assignRoles")} + + )} setDeleteUserId(u.id)} > - 删除 + {t("actions.delete")} @@ -256,7 +266,7 @@ export function AdminUsersView({

- 显示第 {start}-{end} 条,共 {total} 条 + {t("pagination.info", { start, end, total })}

- 第 {page} / {totalPages} 页 + {t("pagination.pageInfo", { page, totalPages })}
@@ -288,23 +298,35 @@ export function AdminUsersView({ !v && setDeleteUserId(null)}> - 确认删除用户? + {t("deleteConfirmTitle")} - 此操作将永久删除该用户及其关联数据,且不可恢复。如果该用户关联了班级或学生,请先解除关联。 + {t("deleteConfirmDescription")} - 取消 + {t("actions.cancel")} - {deleting ? "删除中..." : "确认删除"} + {deleteMutation.isWorking ? t("actions.deleting") : t("actions.confirmDelete")} + + {assignableRoles && assignTarget && ( + !v && setAssignTarget(null)} + userId={assignTarget.id} + userName={assignTarget.name ?? ""} + userEmail={assignTarget.email} + allRoles={assignableRoles} + currentRoleNames={assignTarget.roles} + /> + )}
) } diff --git a/src/modules/users/data-access.ts b/src/modules/users/data-access.ts index 4c4124d..f5fa7ef 100644 --- a/src/modules/users/data-access.ts +++ b/src/modules/users/data-access.ts @@ -1,11 +1,11 @@ import "server-only" import { cache } from "react" -import { and, count, desc, eq, gt, ilike, inArray, or } from "drizzle-orm" +import { and, count, desc, eq, gte, ilike, inArray, or } from "drizzle-orm" -import { auth } from "@/auth" +import { getAuthContext } from "@/shared/lib/auth-guard" import { db } from "@/shared/db" -import { roles, sessions, users, usersToRoles } from "@/shared/db/schema" +import { loginLogs, roles, users, usersToRoles } from "@/shared/db/schema" import { resolvePrimaryRole } from "@/shared/lib/role-utils" export type UserProfile = { @@ -122,11 +122,22 @@ export type UsersDashboardStats = { } export const getUsersDashboardStats = cache(async (): Promise => { - const now = new Date() + // audit-P1-9:原查询 sessions 表(JWT 策略下无数据)。 + // 改为查询最近 24 小时内 signin 成功事件数作为"活跃会话"近似值。 + const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000) const [userCountRow, activeSessionsRow, userRoleCountRows, recentUserRows] = await Promise.all([ db.select({ value: count() }).from(users), - db.select({ value: count() }).from(sessions).where(gt(sessions.expires, now)), + db + .select({ value: count() }) + .from(loginLogs) + .where( + and( + eq(loginLogs.action, "signin"), + eq(loginLogs.status, "success"), + gte(loginLogs.createdAt, oneDayAgo), + ), + ), db .select({ role: roles.name, value: count() }) .from(usersToRoles) @@ -225,13 +236,17 @@ export const getUserWithRole = cache( /** * Returns the current authenticated student user (id + name) by reading the - * session and verifying the "student" role via JOIN users + usersToRoles + roles. + * auth context and verifying the "student" role via JOIN users + usersToRoles + roles. * Returns null if not authenticated or the user does not have the student role. */ export const getCurrentStudentUser = cache(async (): Promise<{ id: string; name: string; gradeId: string | null } | null> => { - const session = await auth() - const userId = String(session?.user?.id ?? "").trim() - if (!userId) return null + let ctx + try { + ctx = await getAuthContext() + } catch { + return null + } + const userId = ctx.userId const student = await getUserWithRole(userId, "student") @@ -422,3 +437,46 @@ export async function getAdminUserRoles(): Promise { const rows = await db.select({ name: roles.name }).from(roles) return rows.map((r) => r.name) } + +/** + * Delete a user by id. + * + * Related rows (accounts, usersToRoles, passwordSecurity) are + * cleaned up via DB-level ON DELETE CASCADE foreign keys. + * (sessions table is deprecated under JWT strategy — see schema.ts) + * + * Safety: throws if the user is the last remaining admin role holder, + * preventing accidental lockout. + */ +export async function deleteUserById(userId: string): Promise { + // Last-admin protection: count how many users have the admin role + const adminRoleRow = await db + .select({ id: roles.id }) + .from(roles) + .where(eq(roles.name, "admin")) + .limit(1) + + if (adminRoleRow.length > 0) { + const adminRoleId = adminRoleRow[0].id + const adminCountRow = await db + .select({ value: count() }) + .from(usersToRoles) + .where(eq(usersToRoles.roleId, adminRoleId)) + + const adminCount = Number(adminCountRow[0]?.value ?? 0) + if (adminCount <= 1) { + // Check if the target user is an admin + const targetIsAdmin = await db + .select({ userId: usersToRoles.userId }) + .from(usersToRoles) + .where(and(eq(usersToRoles.userId, userId), eq(usersToRoles.roleId, adminRoleId))) + .limit(1) + + if (targetIsAdmin.length > 0) { + throw new Error("Cannot delete the last admin user") + } + } + } + + await db.delete(users).where(eq(users.id, userId)) +}