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:
@@ -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 },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user