Files
teatea-pension/modules/settings/components/UserManagementClient.tsx

482 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { FormEvent, useMemo, useState } from "react";
import { Check, Pencil, Plus, Power, X } from "lucide-react";
import { useRouter } from "next/navigation";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import type { ApiResult } from "@/modules/core/server/api";
import type { JoinRequest, Organization, PublicAccount, RoleDefinition } from "@/modules/core/types";
type UserManagementClientProps = {
canManageAccounts?: boolean;
organizations: Organization[];
roles: RoleDefinition[];
joinRequests: JoinRequest[];
};
function getOrganizationRoles(roles: RoleDefinition[], organizationId: string): RoleDefinition[] {
return roles.filter((role) => role.scope === "organization" && role.organizationId === organizationId && role.isEnabled);
}
export function UserManagementClient({
canManageAccounts = false,
organizations,
roles,
joinRequests,
}: UserManagementClientProps): React.ReactElement {
const router = useRouter();
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [selectedOrganizationId, setSelectedOrganizationId] = useState("");
const [reviewRoleByRequestId, setReviewRoleByRequestId] = useState<Record<string, string>>({});
const [message, setMessage] = useState("");
const [isPending, setIsPending] = useState(false);
const organizationRoles = useMemo(
() => getOrganizationRoles(roles, selectedOrganizationId),
[roles, selectedOrganizationId],
);
const pendingJoinRequests = joinRequests.filter((request) => request.status === "pending");
function openCreateDialog(): void {
setMessage("");
setIsCreateOpen(true);
}
function closeCreateDialog(): void {
if (isPending) {
return;
}
setIsCreateOpen(false);
}
async function handleCreateAccount(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
if (!canManageAccounts) {
setMessage("当前角色无权创建用户");
return;
}
setMessage("");
const formData = new FormData(event.currentTarget);
const organizationId = String(formData.get("organizationId") ?? "");
const roleId = String(formData.get("roleId") ?? "");
if (!organizationId || !roleId) {
setMessage("请选择机构和角色");
return;
}
setIsPending(true);
const response = await fetch("/api/settings/accounts", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
organizationId,
roleId,
name: String(formData.get("name") ?? ""),
email: String(formData.get("email") ?? ""),
avatarUrl: String(formData.get("avatarUrl") ?? ""),
password: String(formData.get("password") ?? ""),
}),
});
const result = (await response.json()) as ApiResult<{ account: unknown }>;
setIsPending(false);
if (!result.success) {
setMessage(result.reason);
return;
}
setMessage(result.reason);
setIsCreateOpen(false);
event.currentTarget.reset();
router.refresh();
}
async function reviewJoinRequest(requestId: string, decision: "approved" | "rejected", roleId: string): Promise<void> {
if (!canManageAccounts) {
setMessage("当前角色无权审批加入申请");
return;
}
setMessage("");
setIsPending(true);
const response = await fetch(`/api/settings/join-requests/${requestId}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ decision, roleId }),
});
const result = (await response.json()) as ApiResult<{ joinRequest: unknown }>;
setIsPending(false);
if (!result.success) {
setMessage(result.reason);
return;
}
setMessage(result.reason);
router.refresh();
}
return (
<section className="grid gap-4 xl:grid-cols-[0.9fr_1.1fr]">
<Card>
<CardHeader className="gap-4 md:flex-row md:items-start md:justify-between">
<div>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</div>
<Button
disabled={!canManageAccounts || organizations.length === 0}
onClick={openCreateDialog}
type="button"
>
<Plus className="size-4" aria-hidden="true" />
</Button>
</CardHeader>
<CardContent className="grid gap-3">
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-md border bg-secondary/20 p-3">
<p className="text-xs text-muted-foreground"></p>
<p className="mt-1 text-2xl font-semibold">{organizations.length}</p>
</div>
<div className="rounded-md border bg-secondary/20 p-3">
<p className="text-xs text-muted-foreground"></p>
<p className="mt-1 text-2xl font-semibold">{organizationRoles.length}</p>
</div>
<div className="rounded-md border bg-secondary/20 p-3">
<p className="text-xs text-muted-foreground"></p>
<p className="mt-1 text-2xl font-semibold">{pendingJoinRequests.length}</p>
</div>
</div>
{message ? (
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
{message}
</p>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{pendingJoinRequests.map((request) => {
const requestRoles = getOrganizationRoles(roles, request.organizationId);
const selectedRoleId = reviewRoleByRequestId[request.id] ?? "";
return (
<div key={request.id} className="rounded-md border p-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="font-medium">{request.accountName}</p>
<p className="text-xs text-muted-foreground">{request.accountEmail}</p>
<p className="mt-2 text-sm text-muted-foreground">{request.organizationName}</p>
</div>
<Badge variant="warning"></Badge>
</div>
<div className="mt-3 grid gap-2 sm:grid-cols-[1fr_auto_auto]">
<Select
aria-label={`${request.accountName} 审批角色`}
disabled={requestRoles.length === 0}
onValueChange={(roleId) =>
setReviewRoleByRequestId((current) => ({ ...current, [request.id]: roleId }))
}
options={[
{ value: "", label: "选择审批角色", disabled: true },
...requestRoles.map((role) => ({ value: role.id, label: role.label })),
]}
value={selectedRoleId}
/>
<Button
disabled={isPending || !selectedRoleId || !canManageAccounts}
onClick={() => void reviewJoinRequest(request.id, "approved", selectedRoleId)}
size="sm"
type="button"
>
<Check className="size-4" aria-hidden="true" />
</Button>
<Button
disabled={isPending || !canManageAccounts}
onClick={() => void reviewJoinRequest(request.id, "rejected", "")}
size="sm"
type="button"
variant="outline"
>
<X className="size-4" aria-hidden="true" />
</Button>
</div>
</div>
);
})}
{pendingJoinRequests.length === 0 ? <p className="text-sm text-muted-foreground"></p> : null}
</CardContent>
</Card>
<Dialog
description="管理员创建账号后立即分配到机构和角色。"
onClose={closeCreateDialog}
open={isCreateOpen}
title="创建机构用户"
>
<form className="grid gap-3" onSubmit={handleCreateAccount}>
<label className="grid gap-2 text-sm">
<span className="font-medium"></span>
<Select
aria-label="机构"
name="organizationId"
onValueChange={setSelectedOrganizationId}
options={[
{ value: "", label: "选择机构", disabled: true },
...organizations.map((organization) => ({ value: organization.id, label: organization.name })),
]}
required
value={selectedOrganizationId}
/>
</label>
<label className="grid gap-2 text-sm">
<span className="font-medium"></span>
<Select
aria-label="角色"
defaultValue=""
disabled={organizationRoles.length === 0}
key={selectedOrganizationId}
name="roleId"
options={[
{ value: "", label: selectedOrganizationId ? "选择角色" : "先选择机构", disabled: true },
...organizationRoles.map((role) => ({ value: role.id, label: role.label })),
]}
required
/>
</label>
<label className="grid gap-2 text-sm">
<span className="font-medium"></span>
<Input name="name" required />
</label>
<label className="grid gap-2 text-sm">
<span className="font-medium"></span>
<Input name="email" required type="email" />
</label>
<label className="grid gap-2 text-sm">
<span className="font-medium"> URL</span>
<Input name="avatarUrl" placeholder="https://example.com/avatar.png" type="url" />
</label>
<label className="grid gap-2 text-sm">
<span className="font-medium"></span>
<Input minLength={6} name="password" required type="password" />
</label>
<div className="flex flex-col-reverse gap-2 pt-1 sm:flex-row sm:justify-end">
<Button disabled={isPending} onClick={closeCreateDialog} type="button" variant="outline">
</Button>
<Button disabled={isPending || organizationRoles.length === 0} type="submit">
<Plus className="size-4" aria-hidden="true" />
</Button>
</div>
</form>
{message ? (
<p className="mt-3 rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
{message}
</p>
) : null}
</Dialog>
</section>
);
}
type UserAccountActionsProps = {
account: PublicAccount;
currentAccountId: string;
organizations: Organization[];
roles: RoleDefinition[];
};
export function UserAccountActions({
account,
currentAccountId,
organizations,
roles,
}: UserAccountActionsProps): React.ReactElement {
const router = useRouter();
const [isEditOpen, setIsEditOpen] = useState(false);
const [selectedOrganizationId, setSelectedOrganizationId] = useState(() => account.organizationId ?? "");
const [message, setMessage] = useState("");
const [isPending, setIsPending] = useState(false);
const organizationRoles = useMemo(
() => getOrganizationRoles(roles, selectedOrganizationId),
[roles, selectedOrganizationId],
);
const defaultRoleId = organizationRoles.find((role) => role.key === account.role || role.id === account.role)?.id ?? "";
const isCurrentAccount = account.id === currentAccountId;
async function updateAccount(payload: Record<string, string>): Promise<void> {
setMessage("");
setIsPending(true);
const response = await fetch(`/api/settings/accounts/${account.id}`, {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
const result = (await response.json()) as ApiResult<{ account: PublicAccount }>;
setIsPending(false);
if (!result.success) {
setMessage(result.reason);
return;
}
setMessage(result.reason);
setIsEditOpen(false);
router.refresh();
}
async function disableAccount(): Promise<void> {
setMessage("");
setIsPending(true);
const response = await fetch(`/api/settings/accounts/${account.id}`, {
method: "DELETE",
});
const result = (await response.json()) as ApiResult<{ account: PublicAccount }>;
setIsPending(false);
if (!result.success) {
setMessage(result.reason);
return;
}
router.refresh();
}
async function handleSubmit(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
const formData = new FormData(event.currentTarget);
await updateAccount({
name: String(formData.get("name") ?? ""),
avatarUrl: String(formData.get("avatarUrl") ?? ""),
status: String(formData.get("status") ?? ""),
organizationId: String(formData.get("organizationId") ?? ""),
roleId: String(formData.get("roleId") ?? ""),
});
}
return (
<div className="flex items-center justify-end gap-2">
<Button onClick={() => setIsEditOpen(true)} size="sm" type="button" variant="outline">
<Pencil className="size-4" aria-hidden="true" />
</Button>
{account.status === "disabled" ? (
<Button
disabled={isPending}
onClick={() => void updateAccount({ status: "active" })}
size="sm"
type="button"
variant="outline"
>
<Power className="size-4" aria-hidden="true" />
</Button>
) : (
<Button
disabled={isPending || isCurrentAccount}
onClick={() => void disableAccount()}
size="sm"
type="button"
variant="destructive"
>
<Power className="size-4" aria-hidden="true" />
</Button>
)}
<Dialog
description="调整账号基础信息、状态和机构角色。"
onClose={() => setIsEditOpen(false)}
open={isEditOpen}
title="编辑用户"
>
<form className="grid gap-4" onSubmit={handleSubmit}>
<label className="grid gap-2 text-sm">
<span className="font-medium"></span>
<Input defaultValue={account.name} name="name" required />
</label>
<label className="grid gap-2 text-sm">
<span className="font-medium"> URL</span>
<Input defaultValue={account.avatarUrl} name="avatarUrl" placeholder="https://example.com/avatar.png" type="url" />
</label>
<label className="grid gap-2 text-sm">
<span className="font-medium"></span>
<Select
aria-label="账号状态"
defaultValue={account.status}
disabled={isCurrentAccount}
name="status"
options={[
{ value: "active", label: "启用" },
{ value: "disabled", label: "停用" },
]}
/>
</label>
<div className="grid gap-3 md:grid-cols-2">
<label className="grid gap-2 text-sm">
<span className="font-medium"></span>
<Select
aria-label="账号机构"
name="organizationId"
onValueChange={setSelectedOrganizationId}
options={[
{ value: "", label: "选择机构", disabled: true },
...organizations.map((organization) => ({ value: organization.id, label: organization.name })),
]}
value={selectedOrganizationId}
/>
</label>
<label className="grid gap-2 text-sm">
<span className="font-medium"></span>
<Select
aria-label="账号角色"
defaultValue={defaultRoleId}
disabled={organizationRoles.length === 0}
key={selectedOrganizationId}
name="roleId"
options={[
{ value: "", label: selectedOrganizationId ? "选择角色" : "先选择机构", disabled: true },
...organizationRoles.map((role) => ({ value: role.id, label: role.label })),
]}
/>
</label>
</div>
{message ? (
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
{message}
</p>
) : null}
<div className="flex justify-end gap-2">
<Button onClick={() => setIsEditOpen(false)} type="button" variant="outline">
</Button>
<Button disabled={isPending} type="submit">
</Button>
</div>
</form>
</Dialog>
</div>
);
}