feat(users,layout): update users module and navigation config

- Update users/actions.ts with new server actions

- Update users/data-access.ts with new data access functions

- Update users/components/admin-users-view.tsx

- Update layout/config/navigation.ts
This commit is contained in:
SpecialX
2026-07-03 10:31:57 +08:00
parent 2dd8c2197c
commit 93eacccdbf
4 changed files with 146 additions and 90 deletions

View File

@@ -95,6 +95,8 @@ export const NAV_CONFIG: Partial<Record<Role, NavItem[]>> = {
items: [ items: [
{ title: "admin.userList", href: "/admin/users" }, { title: "admin.userList", href: "/admin/users" },
{ title: "admin.importUsers", href: "/admin/users/import", permission: Permissions.USER_MANAGE }, { 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 },
] ]
}, },
{ {

View File

@@ -2,15 +2,12 @@
import { revalidatePath } from "next/cache" import { revalidatePath } from "next/cache"
import { z } from "zod" import { z } from "zod"
import { eq } from "drizzle-orm"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions" import { Permissions } from "@/shared/types/permissions"
import type { ActionState } from "@/shared/types/action-state" import type { ActionState } from "@/shared/types/action-state"
import { parseExcel } from "@/shared/lib/excel" import { parseExcel } from "@/shared/lib/excel"
import { formatDateForFile } from "@/shared/lib/utils" import { formatDateForFile } from "@/shared/lib/utils"
import { db } from "@/shared/db"
import { users } from "@/shared/db/schema"
import { import {
batchImportUsers, batchImportUsers,
@@ -19,7 +16,7 @@ import {
parseUserImportData, parseUserImportData,
type UserImportResult, type UserImportResult,
} from "./import-export" } 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) */ /** Zod schema for self-service profile update (P0-13) */
const UpdateUserProfileSchema = z.object({ const UpdateUserProfileSchema = z.object({
@@ -171,41 +168,18 @@ export async function exportUsersAction(
} }
} }
/**
* 更新用户角色(管理员)
*/
export async function updateUserRoleAction(
prevState: ActionState<unknown>,
formData: FormData
): Promise<ActionState<unknown>> {
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( export async function deleteUserAction(
prevState: ActionState<unknown>, userId: string
formData: FormData ): Promise<ActionState<void>> {
): Promise<ActionState<unknown>> {
try { try {
await requirePermission(Permissions.USER_MANAGE) await requirePermission(Permissions.USER_MANAGE)
const userId = formData.get("userId") as string await deleteUserById(userId)
await db.delete(users).where(eq(users.id, userId))
revalidatePath("/admin/users") revalidatePath("/admin/users")
return { success: true, message: "用户已删除" } return { success: true, message: "用户已删除" }
} catch (e) { } catch (e) {

View File

@@ -2,10 +2,8 @@
import * as React from "react" import * as React from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter, useSearchParams } from "next/navigation"
import { useSearchParams } from "next/navigation" import { Search, Users, Upload, MoreHorizontal, Trash2, Pencil, UserCog } from "lucide-react"
import { Search, Users, Upload, MoreHorizontal, Trash2, Pencil } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input" import { Input } from "@/shared/components/ui/input"
@@ -44,6 +42,13 @@ import {
} from "@/shared/components/ui/alert-dialog" } from "@/shared/components/ui/alert-dialog"
import { EmptyState } from "@/shared/components/ui/empty-state" import { EmptyState } from "@/shared/components/ui/empty-state"
import { formatDate } from "@/shared/lib/utils" 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 { interface AdminUserListItem {
id: string id: string
@@ -63,6 +68,8 @@ interface AdminUsersViewProps {
totalPages: number totalPages: number
search: string search: string
roleFilter: string roleFilter: string
/** All roles (with id/name/isSystem/isEnabled) for the assign dialog. */
assignableRoles?: AssignableRole[]
} }
export function AdminUsersView({ export function AdminUsersView({
@@ -74,12 +81,22 @@ export function AdminUsersView({
totalPages, totalPages,
search, search,
roleFilter, roleFilter,
assignableRoles,
}: AdminUsersViewProps) { }: AdminUsersViewProps) {
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const t = useTranslations("users")
const [searchInput, setSearchInput] = React.useState(search) const [searchInput, setSearchInput] = React.useState(search)
const [deleteUserId, setDeleteUserId] = React.useState<string | null>(null) const [deleteUserId, setDeleteUserId] = React.useState<string | null>(null)
const [deleting, setDeleting] = React.useState(false) const [assignTarget, setAssignTarget] = React.useState<AdminUserListItem | null>(null)
const deleteMutation = useActionMutation({
successMessage: t("messages.deleted"),
onSuccess: () => {
setDeleteUserId(null)
router.refresh()
},
})
const updateParams = React.useCallback( const updateParams = React.useCallback(
(updates: Record<string, string | undefined>) => { (updates: Record<string, string | undefined>) => {
@@ -105,22 +122,9 @@ export function AdminUsersView({
updateParams({ search: searchInput, page: undefined }) updateParams({ search: searchInput, page: undefined })
} }
const handleDelete = async () => { const handleDelete = (): void => {
if (!deleteUserId) return if (!deleteUserId) return
setDeleting(true) void deleteMutation.mutate(() => deleteUserAction(deleteUserId))
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)
}
} }
const start = total === 0 ? 0 : (page - 1) * pageSize + 1 const start = total === 0 ? 0 : (page - 1) * pageSize + 1
@@ -130,13 +134,13 @@ export function AdminUsersView({
<div className="flex h-full flex-col space-y-6"> <div className="flex h-full flex-col space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h2 className="text-2xl font-bold tracking-tight"></h2> <h2 className="text-2xl font-bold tracking-tight">{t("title")}</h2>
<p className="text-muted-foreground"></p> <p className="text-muted-foreground">{t("adminDescription")}</p>
</div> </div>
<Button asChild> <Button asChild>
<Link href="/admin/users/import"> <Link href="/admin/users/import">
<Upload className="mr-2 h-4 w-4" /> <Upload className="mr-2 h-4 w-4" />
{t("importButton")}
</Link> </Link>
</Button> </Button>
</div> </div>
@@ -147,7 +151,7 @@ export function AdminUsersView({
<div className="relative flex-1"> <div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input
placeholder="搜索姓名或邮箱..." placeholder={t("searchPlaceholder")}
value={searchInput} value={searchInput}
onChange={(e) => setSearchInput(e.target.value)} onChange={(e) => setSearchInput(e.target.value)}
className="pl-9" className="pl-9"
@@ -158,10 +162,10 @@ export function AdminUsersView({
onValueChange={(v) => updateParams({ role: v === "all" ? undefined : v, page: undefined })} onValueChange={(v) => updateParams({ role: v === "all" ? undefined : v, page: undefined })}
> >
<SelectTrigger className="w-full md:w-48"> <SelectTrigger className="w-full md:w-48">
<SelectValue placeholder="所有角色" /> <SelectValue placeholder={t("allRoles")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all"></SelectItem> <SelectItem value="all">{t("allRoles")}</SelectItem>
{roleOptions.map((r) => ( {roleOptions.map((r) => (
<SelectItem key={r} value={r}> <SelectItem key={r} value={r}>
{r} {r}
@@ -169,7 +173,7 @@ export function AdminUsersView({
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<Button type="submit"></Button> <Button type="submit">{t("search")}</Button>
{(search || roleFilter) && ( {(search || roleFilter) && (
<Button <Button
type="button" type="button"
@@ -179,7 +183,7 @@ export function AdminUsersView({
updateParams({ search: undefined, role: undefined, page: undefined }) updateParams({ search: undefined, role: undefined, page: undefined })
}} }}
> >
{t("reset")}
</Button> </Button>
)} )}
</form> </form>
@@ -191,8 +195,8 @@ export function AdminUsersView({
{users.length === 0 ? ( {users.length === 0 ? (
<EmptyState <EmptyState
icon={Users} icon={Users}
title="暂无用户" title={t("emptyTitle")}
description={search || roleFilter ? "没有匹配的用户,请调整搜索条件。" : "系统中还没有用户,点击批量导入创建。"} description={search || roleFilter ? t("emptyFilteredDescription") : t("emptyDescription")}
/> />
) : ( ) : (
<> <>
@@ -200,12 +204,12 @@ export function AdminUsersView({
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead></TableHead> <TableHead>{t("columns.name")}</TableHead>
<TableHead></TableHead> <TableHead>{t("columns.email")}</TableHead>
<TableHead></TableHead> <TableHead>{t("columns.role")}</TableHead>
<TableHead></TableHead> <TableHead>{t("columns.phone")}</TableHead>
<TableHead></TableHead> <TableHead>{t("columns.createdAt")}</TableHead>
<TableHead className="w-[60px]"></TableHead> <TableHead className="w-[60px]">{t("columns.actions")}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -216,7 +220,7 @@ export function AdminUsersView({
<TableCell> <TableCell>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{u.roles.length === 0 ? ( {u.roles.length === 0 ? (
<Badge variant="secondary"></Badge> <Badge variant="secondary">{t("unassigned")}</Badge>
) : ( ) : (
u.roles.map((r) => ( u.roles.map((r) => (
<Badge key={r} variant="secondary">{r}</Badge> <Badge key={r} variant="secondary">{r}</Badge>
@@ -236,14 +240,20 @@ export function AdminUsersView({
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem> <DropdownMenuItem>
<Pencil className="mr-2 h-4 w-4" /> <Pencil className="mr-2 h-4 w-4" />
{t("actions.edit")}
</DropdownMenuItem> </DropdownMenuItem>
{assignableRoles && (
<DropdownMenuItem onClick={() => setAssignTarget(u)}>
<UserCog className="mr-2 h-4 w-4" />
{t("actions.assignRoles")}
</DropdownMenuItem>
)}
<DropdownMenuItem <DropdownMenuItem
className="text-destructive" className="text-destructive"
onClick={() => setDeleteUserId(u.id)} onClick={() => setDeleteUserId(u.id)}
> >
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
{t("actions.delete")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -256,7 +266,7 @@ export function AdminUsersView({
<div className="flex items-center justify-between pt-4"> <div className="flex items-center justify-between pt-4">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{start}-{end} {total} {t("pagination.info", { start, end, total })}
</p> </p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
@@ -265,10 +275,10 @@ export function AdminUsersView({
disabled={page <= 1} disabled={page <= 1}
onClick={() => updateParams({ page: String(page - 1) })} onClick={() => updateParams({ page: String(page - 1) })}
> >
{t("pagination.prev")}
</Button> </Button>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{page} / {totalPages} {t("pagination.pageInfo", { page, totalPages })}
</span> </span>
<Button <Button
variant="outline" variant="outline"
@@ -276,7 +286,7 @@ export function AdminUsersView({
disabled={page >= totalPages} disabled={page >= totalPages}
onClick={() => updateParams({ page: String(page + 1) })} onClick={() => updateParams({ page: String(page + 1) })}
> >
{t("pagination.next")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -288,23 +298,35 @@ export function AdminUsersView({
<AlertDialog open={!!deleteUserId} onOpenChange={(v) => !v && setDeleteUserId(null)}> <AlertDialog open={!!deleteUserId} onOpenChange={(v) => !v && setDeleteUserId(null)}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle> <AlertDialogTitle>{t("deleteConfirmTitle")}</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
{t("deleteConfirmDescription")}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel disabled={deleting}></AlertDialogCancel> <AlertDialogCancel disabled={deleteMutation.isWorking}>{t("actions.cancel")}</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={handleDelete} onClick={handleDelete}
disabled={deleting} disabled={deleteMutation.isWorking}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90" className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
> >
{deleting ? "删除中..." : "确认删除"} {deleteMutation.isWorking ? t("actions.deleting") : t("actions.confirmDelete")}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
{assignableRoles && assignTarget && (
<UserRoleAssignDialog
open={true}
onOpenChange={(v) => !v && setAssignTarget(null)}
userId={assignTarget.id}
userName={assignTarget.name ?? ""}
userEmail={assignTarget.email}
allRoles={assignableRoles}
currentRoleNames={assignTarget.roles}
/>
)}
</div> </div>
) )
} }

View File

@@ -1,11 +1,11 @@
import "server-only" import "server-only"
import { cache } from "react" 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 { 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" import { resolvePrimaryRole } from "@/shared/lib/role-utils"
export type UserProfile = { export type UserProfile = {
@@ -122,11 +122,22 @@ export type UsersDashboardStats = {
} }
export const getUsersDashboardStats = cache(async (): Promise<UsersDashboardStats> => { export const getUsersDashboardStats = cache(async (): Promise<UsersDashboardStats> => {
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([ const [userCountRow, activeSessionsRow, userRoleCountRows, recentUserRows] = await Promise.all([
db.select({ value: count() }).from(users), 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 db
.select({ role: roles.name, value: count() }) .select({ role: roles.name, value: count() })
.from(usersToRoles) .from(usersToRoles)
@@ -225,13 +236,17 @@ export const getUserWithRole = cache(
/** /**
* Returns the current authenticated student user (id + name) by reading the * 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. * 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> => { export const getCurrentStudentUser = cache(async (): Promise<{ id: string; name: string; gradeId: string | null } | null> => {
const session = await auth() let ctx
const userId = String(session?.user?.id ?? "").trim() try {
if (!userId) return null ctx = await getAuthContext()
} catch {
return null
}
const userId = ctx.userId
const student = await getUserWithRole(userId, "student") const student = await getUserWithRole(userId, "student")
@@ -422,3 +437,46 @@ export async function getAdminUserRoles(): Promise<string[]> {
const rows = await db.select({ name: roles.name }).from(roles) const rows = await db.select({ name: roles.name }).from(roles)
return rows.map((r) => r.name) 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<void> {
// 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))
}