Files

321 lines
12 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 { usePathname, useRouter } from "next/navigation";
import { Building2, Check, ChevronUp, Ellipsis, Link2, LogOut, Settings } from "lucide-react";
import { FormEvent, useEffect, useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import type { ApiResult } from "@/modules/core/server/api";
import type { AccountOrganizationOption, Organization, PublicAccount } from "@/modules/core/types";
import { UserAvatar } from "@/modules/shared/components/UserAvatar";
import { getWorkspaceHref, getWorkspacePathFromPathname } from "@/modules/shared/lib/workspace-routing";
import { cn } from "@/lib/utils";
type AccountMenuProps = {
account: PublicAccount;
organization?: Organization;
organizations: AccountOrganizationOption[];
};
export function AccountMenu({
account,
organization,
organizations,
}: AccountMenuProps): React.ReactElement {
const router = useRouter();
const pathname = usePathname();
const menuRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
const [isOrganizationOpen, setIsOrganizationOpen] = useState(false);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isSignOutConfirmOpen, setIsSignOutConfirmOpen] = useState(false);
const [isPending, setIsPending] = useState(false);
const [message, setMessage] = useState("");
const currentOrganization = organizations.find((item) => item.isActive) ?? organizations[0];
useEffect(() => {
if (!isOpen && !isOrganizationOpen) {
return;
}
function handlePointerDown(event: PointerEvent): void {
const target = event.target;
if (target instanceof Node && !menuRef.current?.contains(target)) {
setIsOpen(false);
setIsOrganizationOpen(false);
}
}
function handleKeyDown(event: KeyboardEvent): void {
if (event.key === "Escape") {
setIsOpen(false);
setIsOrganizationOpen(false);
}
}
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [isOpen, isOrganizationOpen]);
async function handleOrganizationSwitch(organizationId: string): Promise<void> {
setMessage("");
setIsPending(true);
const response = await fetch("/api/auth/organization", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ organizationId }),
});
const result = (await response.json()) as ApiResult<{
organization: Organization | null;
organizations: AccountOrganizationOption[];
}>;
setIsPending(false);
if (!result.success) {
setMessage(result.reason);
return;
}
setIsOrganizationOpen(false);
const activeOrganization = result.organization ?? result.organizations.find((item) => item.isActive);
const workspacePath = getWorkspacePathFromPathname(pathname);
router.replace(getWorkspaceHref(activeOrganization?.slug, workspacePath));
router.refresh();
}
async function handleSignOut(): Promise<void> {
setIsPending(true);
await fetch("/api/auth/logout", { method: "POST" });
router.refresh();
router.replace("/login");
}
async function handleProfileSubmit(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
setMessage("");
setIsPending(true);
const formData = new FormData(event.currentTarget);
const response = await fetch("/api/account/profile", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({
name: String(formData.get("name") ?? ""),
avatarUrl: String(formData.get("avatarUrl") ?? ""),
}),
});
const result = (await response.json()) as ApiResult<{ account: PublicAccount }>;
setIsPending(false);
if (!result.success) {
setMessage(result.reason);
return;
}
setMessage(result.reason);
router.refresh();
}
return (
<div className="relative grid gap-2" ref={menuRef}>
<button
aria-expanded={isOrganizationOpen}
aria-haspopup="menu"
className="flex min-h-10 w-full min-w-0 items-center gap-2 rounded-md border bg-card px-3 text-left text-sm transition-colors hover:bg-secondary"
disabled={isPending || organizations.length === 0}
onClick={() => {
setMessage("");
setIsOrganizationOpen((current) => !current);
setIsOpen(false);
}}
type="button"
>
<Building2 className="size-4 shrink-0 text-primary" aria-hidden="true" />
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{currentOrganization?.name ?? organization?.name ?? "未选择机构"}</span>
<span className="block truncate text-xs text-muted-foreground">
{currentOrganization?.slug ? `/${currentOrganization.slug}` : "机构标识未配置"}
</span>
</span>
<ChevronUp className={cn("size-4 shrink-0 text-muted-foreground transition-transform", isOrganizationOpen && "rotate-180")} />
</button>
{isOrganizationOpen ? (
<div
className="absolute bottom-full left-0 right-0 z-30 mb-2 max-h-80 overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-lg"
role="menu"
>
{organizations.map((item) => (
<button
className="flex min-h-11 w-full min-w-0 items-center gap-2 rounded-sm px-3 text-left text-sm hover:bg-secondary disabled:opacity-55"
disabled={isPending || item.isActive}
key={item.id}
onClick={() => void handleOrganizationSwitch(item.id)}
role="menuitem"
type="button"
>
<Building2 className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{item.name}</span>
<span className="block truncate text-xs text-muted-foreground">
/{item.slug} · {item.roleLabel}
</span>
</span>
{item.isActive ? <Check className="size-4 shrink-0 text-primary" aria-hidden="true" /> : null}
</button>
))}
{message ? <p className="px-3 py-2 text-xs text-destructive">{message}</p> : null}
</div>
) : null}
<div className="flex min-w-0 items-center gap-3 rounded-md bg-secondary/55 p-2">
<UserAvatar avatarUrl={account.avatarUrl} name={account.name} size="sm" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium leading-tight">{account.name}</p>
<p className="truncate text-xs leading-tight text-muted-foreground">{account.email}</p>
</div>
<Button
aria-expanded={isOpen}
aria-haspopup="menu"
aria-label="打开账号菜单"
onClick={() => {
setMessage("");
setIsOpen((current) => !current);
setIsOrganizationOpen(false);
}}
size="icon"
type="button"
variant="ghost"
className="size-9 shrink-0"
>
<Ellipsis className="size-4" aria-hidden="true" />
</Button>
</div>
{isOpen ? (
<div
className="absolute bottom-full right-0 z-30 mb-2 w-52 rounded-md border bg-popover p-1 text-popover-foreground shadow-lg"
role="menu"
>
<button
className="flex min-h-10 w-full items-center gap-2 rounded-sm px-3 text-left text-sm font-medium hover:bg-secondary"
onClick={() => {
setIsOpen(false);
setIsSettingsOpen(true);
setMessage("");
}}
role="menuitem"
type="button"
>
<Settings className="size-4 text-muted-foreground" aria-hidden="true" />
</button>
<button
className="flex min-h-10 w-full items-center gap-2 rounded-sm px-3 text-left text-sm font-medium text-destructive hover:bg-destructive/10 disabled:opacity-55"
disabled={isPending}
onClick={() => {
setIsOpen(false);
setIsSignOutConfirmOpen(true);
}}
role="menuitem"
type="button"
>
<LogOut className="size-4 text-destructive" aria-hidden="true" />
退
</button>
</div>
) : null}
<Dialog
description="管理你的个人资料和第三方登录绑定状态。"
onClose={() => {
if (!isPending) {
setIsSettingsOpen(false);
setMessage("");
}
}}
open={isSettingsOpen}
title="用户设置"
width="lg"
>
<form className="grid gap-5" onSubmit={handleProfileSubmit}>
<div className="grid gap-4 md:grid-cols-[auto_1fr]">
<UserAvatar avatarUrl={account.avatarUrl} name={account.name} size="lg" />
<div className="grid gap-3">
<Input defaultValue={account.name} label="用户名称" name="name" required />
<Input defaultValue={account.avatarUrl} label="头像 URL" name="avatarUrl" placeholder="https://..." />
</div>
</div>
<section className="rounded-md border p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Link2 className="size-4 text-primary" aria-hidden="true" />
<h3 className="text-sm font-semibold"> OIDC </h3>
</div>
<p className="mt-1 text-sm text-muted-foreground">
{organization?.oidcEnabled ? "当前机构已配置 OIDC 登录入口。" : "当前机构暂未启用 OIDC 登录入口。"}
</p>
</div>
<Badge variant={organization?.oidcEnabled ? "success" : "secondary"}>
{organization?.oidcEnabled ? "可接入" : "未启用"}
</Badge>
</div>
<div className="mt-4 rounded-md bg-secondary/55 px-3 py-2 text-sm text-muted-foreground">
OIDC
</div>
</section>
{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 disabled={isPending} onClick={() => setIsSettingsOpen(false)} type="button" variant="outline">
</Button>
<Button disabled={isPending} type="submit">
</Button>
</div>
</form>
</Dialog>
<Dialog
description="退出后需要重新登录才能继续访问工作台。"
onClose={() => {
if (!isPending) {
setIsSignOutConfirmOpen(false);
}
}}
open={isSignOutConfirmOpen}
title="确认退出登录"
width="sm"
>
<div className="grid gap-4">
<p className="text-sm text-muted-foreground">退</p>
<div className="flex justify-end gap-2">
<Button disabled={isPending} onClick={() => setIsSignOutConfirmOpen(false)} type="button" variant="outline">
</Button>
<Button disabled={isPending} onClick={() => void handleSignOut()} type="button" variant="destructive">
<LogOut className="size-4" aria-hidden="true" />
退
</Button>
</div>
</div>
</Dialog>
</div>
);
}