feat: add real account workspace controls

This commit is contained in:
2026-07-02 19:52:29 -07:00
parent 1b8fae0116
commit 1cdf89c608
12 changed files with 759 additions and 203 deletions

View File

@@ -4,6 +4,60 @@ This document covers backend authentication integration using better-auth, inclu
## 1. Overview ## 1. Overview
### Current Project Auth Contract: Session Organization and Profile APIs
#### 1. Scope / Trigger
- Trigger: app shell and account settings need authenticated current-tenant switching and current-account profile updates.
- This repository currently uses the custom `teatea_session` HTTP-only cookie plus Drizzle tables (`sessions`, `accounts`, `organizations`, `memberships`, `roles`), not a better-auth runtime.
#### 2. Signatures
- `GET /api/auth/session`: returns current account, active organization, available organization options, membership, and permissions.
- `POST /api/auth/organization`: switches `sessions.activeOrganizationId` for the current session.
- `PATCH /api/account/profile`: updates the authenticated account's `name` and `avatarUrl`.
#### 3. Contracts
- `GET /api/auth/session` response payload:
- `account: PublicAccount | null`
- `organization: Organization | null`
- `organizations: AccountOrganizationOption[]`
- `membership: Membership | null`
- `permissions: Permission[]`
- `POST /api/auth/organization` request payload: `{ organizationId: string }`.
- `PATCH /api/account/profile` request payload: `{ name: string; avatarUrl: string }`.
- All responses use `{ success, reason, ...payload }` and `Cache-Control: no-store`.
#### 4. Validation & Error Matrix
- Missing/expired session -> `success: false`, `401`, `未登录或会话已过期`.
- Empty `organizationId` -> `success: false`, `400`, `机构不能为空`.
- Organization not in authenticated account's available organization list -> `success: false`, `403`, `无权切换到该机构`.
- Empty profile `name` -> `success: false`, `400`, `用户名称不能为空`.
#### 5. Good/Base/Bad Cases
- Good: platform account with organization-read permission can switch among active organizations.
- Base: organization user can switch only among active memberships.
- Bad: never trust a client-provided organization ID without comparing against server-computed organization options.
#### 6. Tests Required
- Session API asserts `organizations` includes `isActive`, `slug`, and `roleLabel`.
- Organization switch asserts session row changes and forbidden org IDs are rejected.
- Profile update asserts current account only is updated and audit log is recorded.
#### 7. Wrong vs Correct
Wrong:
```typescript
await database.update(sessions).set({ activeOrganizationId: body.organizationId });
```
Correct:
```typescript
const target = context.organizations.find((organization) => organization.id === organizationId);
if (!target) {
throw new Error("无权切换到该机构");
}
await database.update(sessions).set({ activeOrganizationId: organizationId }).where(eq(sessions.id, context.session.id));
```
### What is better-auth ### What is better-auth
better-auth is a modern authentication library for TypeScript applications that provides: better-auth is a modern authentication library for TypeScript applications that provides:

View File

@@ -200,6 +200,23 @@ When a module becomes real, replace the placeholder with a Server Component that
data from the server boundary and pass only persisted, permission-filtered data into data from the server boundary and pass only persisted, permission-filtered data into
client components. client components.
### App Shell Tenant and Account Menu Contract
The desktop app shell footer is the tenant/account workspace control, not only a sign-out
surface.
- Show a compact organization switcher above the account card.
- Display both organization name and non-empty `slug`; do not hide or fabricate missing
tenant identifiers.
- Switch organizations by calling `POST /api/auth/organization` and then `router.refresh()`;
do not store the active organization in localStorage.
- The account card menu opens a user settings dialog for the current account. Profile edits
call `PATCH /api/account/profile`.
- OIDC binding UI must reflect real backend capability. If binding records/callbacks are not
implemented, show an honest not-connected state instead of fake provider accounts.
- The sidebar nav selected state belongs in a small client component using `usePathname()`;
keep the rest of `AppShell` server-rendered.
### Business Form Defaults Contract ### Business Form Defaults Contract
Create forms for persisted business records must not prefill required domain fields with Create forms for persisted business records must not prefill required domain fields with

View File

@@ -26,7 +26,12 @@ export default async function AppLayout({ children }: AppLayoutProps): Promise<R
} }
return ( return (
<AppShell account={context.account} permissions={context.permissions}> <AppShell
account={context.account}
organization={context.organization}
organizations={context.organizations}
permissions={context.permissions}
>
{children} {children}
</AppShell> </AppShell>
); );

View File

@@ -0,0 +1,66 @@
import { eq } from "drizzle-orm";
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
import { recordAuditLog } from "@/modules/core/server/audit";
import { getCurrentAuthContext, toPublicAccount } from "@/modules/core/server/auth";
import { getDatabase } from "@/modules/core/server/db";
import { accounts } from "@/modules/core/server/schema";
export const dynamic = "force-dynamic";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readString(source: Record<string, unknown>, key: string): string {
const value = source[key];
return typeof value === "string" ? value.trim() : "";
}
export async function PATCH(request: Request): Promise<Response> {
const context = await getCurrentAuthContext();
if (!context) {
return jsonFailure("未登录或会话已过期", 401);
}
const body = await readJsonBody(request);
if (!isRecord(body)) {
return jsonFailure("请求数据格式无效");
}
const name = readString(body, "name");
const avatarUrl = readString(body, "avatarUrl");
if (!name) {
return jsonFailure("用户名称不能为空");
}
const database = getDatabase();
const rows = await database
.update(accounts)
.set({
name,
avatarUrl,
updatedAt: new Date(),
})
.where(eq(accounts.id, context.account.id))
.returning();
const account = rows[0];
if (!account) {
return jsonFailure("账号不存在", 404);
}
const publicAccount = toPublicAccount(account, context.account.role, context.organization);
await recordAuditLog({
actor: publicAccount,
organizationId: context.organization?.id,
action: "account.profile.update",
targetType: "account",
targetId: publicAccount.id,
result: "success",
reason: "更新个人资料",
});
return jsonSuccess("用户资料已保存", { account: publicAccount });
}

View File

@@ -0,0 +1,29 @@
import { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
import { switchActiveOrganization } from "@/modules/core/server/auth";
export const dynamic = "force-dynamic";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export async function POST(request: Request): Promise<Response> {
const body = await readJsonBody(request);
if (!isRecord(body) || typeof body.organizationId !== "string" || !body.organizationId.trim()) {
return jsonFailure("机构不能为空");
}
try {
const context = await switchActiveOrganization(body.organizationId.trim());
return jsonSuccess("机构已切换", {
account: context.account,
organization: context.organization ?? null,
organizations: context.organizations,
membership: context.membership ?? null,
permissions: context.permissions,
});
} catch (error) {
return jsonFailure(error instanceof Error ? error.message : "机构切换失败", 403);
}
}

View File

@@ -9,6 +9,7 @@ export async function GET(): Promise<Response> {
return jsonSuccess("Session loaded", { return jsonSuccess("Session loaded", {
account: context?.account ?? null, account: context?.account ?? null,
organization: context?.organization ?? null, organization: context?.organization ?? null,
organizations: context?.organizations ?? [],
membership: context?.membership ?? null, membership: context?.membership ?? null,
permissions: context?.permissions ?? [], permissions: context?.permissions ?? [],
}); });

View File

@@ -17,7 +17,16 @@ import {
sessions, sessions,
} from "@/modules/core/server/schema"; } from "@/modules/core/server/schema";
import { getSystemSettings } from "@/modules/core/server/system-settings"; import { getSystemSettings } from "@/modules/core/server/system-settings";
import type { Account, AuthContext, Membership, Organization, Permission, PublicAccount, Session } from "@/modules/core/types"; import type {
Account,
AccountOrganizationOption,
AuthContext,
Membership,
Organization,
Permission,
PublicAccount,
Session,
} from "@/modules/core/types";
const SESSION_COOKIE = "teatea_session"; const SESSION_COOKIE = "teatea_session";
const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 7; const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 7;
@@ -80,6 +89,23 @@ function toMembership(row: MembershipRow, role: RoleRow): Membership {
}; };
} }
function toOrganizationOption(
organization: OrganizationRow,
roleLabel: string,
activeOrganizationId: string | null,
): AccountOrganizationOption {
return {
id: organization.id,
name: organization.name,
slug: organization.slug,
status: organization.status,
registrationEnabled: organization.registrationEnabled,
oidcEnabled: organization.oidcEnabled,
roleLabel,
isActive: organization.id === activeOrganizationId,
};
}
export function toPublicAccount(account: AccountRow | Account, roleKey?: string, organization?: Organization): PublicAccount { export function toPublicAccount(account: AccountRow | Account, roleKey?: string, organization?: Organization): PublicAccount {
const fallbackRole = "role" in account ? account.role : "viewer"; const fallbackRole = "role" in account ? account.role : "viewer";
const fallbackOrganization = "organization" in account ? account.organization : undefined; const fallbackOrganization = "organization" in account ? account.organization : undefined;
@@ -505,16 +531,73 @@ export async function getCurrentAuthContext(): Promise<AuthContext | null> {
const permissions = [...new Set([...platformPermissions, ...organizationPermissions])]; const permissions = [...new Set([...platformPermissions, ...organizationPermissions])];
const organization = organizationRow ? toOrganization(organizationRow) : undefined; const organization = organizationRow ? toOrganization(organizationRow) : undefined;
const membership = membershipRow ? toMembership(membershipRow.membership, membershipRow.role) : undefined; const membership = membershipRow ? toMembership(membershipRow.membership, membershipRow.role) : undefined;
const canAccessAllOrganizations =
platformPermissions.includes("platform:manage") || platformPermissions.includes("organization:read");
const organizationOptions = canAccessAllOrganizations
? (
await database
.select()
.from(organizations)
.where(eq(organizations.status, "active"))
).map((item) => toOrganizationOption(item, platformRole?.label ?? "平台账号", row.session.activeOrganizationId))
: (
await database
.select({ membership: memberships, organization: organizations, role: roles })
.from(memberships)
.innerJoin(organizations, eq(organizations.id, memberships.organizationId))
.innerJoin(roles, eq(roles.id, memberships.roleId))
.where(
and(
eq(memberships.accountId, row.account.id),
eq(memberships.status, "active"),
eq(organizations.status, "active"),
eq(roles.isEnabled, true),
),
)
).map((item) => toOrganizationOption(item.organization, item.role.label, row.session.activeOrganizationId));
return { return {
account: toPublicAccount(row.account, platformRole?.key ?? membership?.roleKey, organization), account: toPublicAccount(row.account, platformRole?.key ?? membership?.roleKey, organization),
organization, organization,
organizations: organizationOptions,
membership, membership,
permissions, permissions,
session, session,
}; };
} }
export async function switchActiveOrganization(organizationId: string): Promise<AuthContext> {
const context = await getCurrentAuthContext();
if (!context) {
throw new Error("未登录或会话已过期");
}
const target = context.organizations.find((organization) => organization.id === organizationId);
if (!target) {
throw new Error("无权切换到该机构");
}
const database = getDatabase();
await database.update(sessions).set({ activeOrganizationId: organizationId }).where(eq(sessions.id, context.session.id));
await recordAuditLog({
actor: context.account,
organizationId,
action: "session.organization.switch",
targetType: "organization",
targetId: organizationId,
result: "success",
reason: `切换机构:${target.name}`,
});
const nextContext = await getCurrentAuthContext();
if (!nextContext) {
throw new Error("机构已切换,请重新登录");
}
return nextContext;
}
export async function removeCurrentSession(): Promise<PublicAccount | null> { export async function removeCurrentSession(): Promise<PublicAccount | null> {
const context = await getCurrentAuthContext(); const context = await getCurrentAuthContext();
const cookieStore = await cookies(); const cookieStore = await cookies();

View File

@@ -162,6 +162,14 @@ export type Account = {
export type PublicAccount = Omit<Account, "passwordHash" | "passwordSalt">; export type PublicAccount = Omit<Account, "passwordHash" | "passwordSalt">;
export type AccountOrganizationOption = Pick<
Organization,
"id" | "name" | "slug" | "status" | "registrationEnabled" | "oidcEnabled"
> & {
roleLabel: string;
isActive: boolean;
};
export type Session = { export type Session = {
id: string; id: string;
accountId: string; accountId: string;
@@ -263,6 +271,7 @@ export type AppData = {
export type AuthContext = { export type AuthContext = {
account: PublicAccount; account: PublicAccount;
organization?: Organization; organization?: Organization;
organizations: AccountOrganizationOption[];
membership?: Membership; membership?: Membership;
permissions: Permission[]; permissions: Permission[];
session: Session; session: Session;

View File

@@ -1,29 +1,41 @@
"use client"; "use client";
import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Ellipsis, LogOut, Settings } from "lucide-react"; import { Building2, Check, ChevronUp, Ellipsis, Link2, LogOut, Settings } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { FormEvent, useEffect, useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import type { Permission, PublicAccount } from "@/modules/core/types"; 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 { UserAvatar } from "@/modules/shared/components/UserAvatar";
import { cn } from "@/lib/utils";
type AccountMenuProps = { type AccountMenuProps = {
account: PublicAccount; account: PublicAccount;
permissions: Permission[]; organization?: Organization;
organizations: AccountOrganizationOption[];
}; };
export function AccountMenu({ account, permissions }: AccountMenuProps): React.ReactElement { export function AccountMenu({
account,
organization,
organizations,
}: AccountMenuProps): React.ReactElement {
const router = useRouter(); const router = useRouter();
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false); 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 [isPending, setIsPending] = useState(false);
const canReadAccounts = permissions.includes("account:read"); const [message, setMessage] = useState("");
const settingsHref = canReadAccounts ? `/app/settings/users?q=${encodeURIComponent(account.email)}` : "/app/settings/global"; const currentOrganization = organizations.find((item) => item.isActive) ?? organizations[0];
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen && !isOrganizationOpen) {
return; return;
} }
@@ -31,12 +43,14 @@ export function AccountMenu({ account, permissions }: AccountMenuProps): React.R
const target = event.target; const target = event.target;
if (target instanceof Node && !menuRef.current?.contains(target)) { if (target instanceof Node && !menuRef.current?.contains(target)) {
setIsOpen(false); setIsOpen(false);
setIsOrganizationOpen(false);
} }
} }
function handleKeyDown(event: KeyboardEvent): void { function handleKeyDown(event: KeyboardEvent): void {
if (event.key === "Escape") { if (event.key === "Escape") {
setIsOpen(false); setIsOpen(false);
setIsOrganizationOpen(false);
} }
} }
@@ -47,7 +61,27 @@ export function AccountMenu({ account, permissions }: AccountMenuProps): React.R
document.removeEventListener("pointerdown", handlePointerDown); document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown); document.removeEventListener("keydown", handleKeyDown);
}; };
}, [isOpen]); }, [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<Record<string, unknown>>;
setIsPending(false);
if (!result.success) {
setMessage(result.reason);
return;
}
setIsOrganizationOpen(false);
router.refresh();
}
async function handleSignOut(): Promise<void> { async function handleSignOut(): Promise<void> {
setIsPending(true); setIsPending(true);
@@ -56,8 +90,83 @@ export function AccountMenu({ account, permissions }: AccountMenuProps): React.R
router.replace("/login"); 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 ( return (
<div className="relative" ref={menuRef}> <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"> <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" /> <UserAvatar avatarUrl={account.avatarUrl} name={account.name} size="sm" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@@ -68,7 +177,11 @@ export function AccountMenu({ account, permissions }: AccountMenuProps): React.R
aria-expanded={isOpen} aria-expanded={isOpen}
aria-haspopup="menu" aria-haspopup="menu"
aria-label="打开账号菜单" aria-label="打开账号菜单"
onClick={() => setIsOpen((current) => !current)} onClick={() => {
setMessage("");
setIsOpen((current) => !current);
setIsOrganizationOpen(false);
}}
size="icon" size="icon"
type="button" type="button"
variant="ghost" variant="ghost"
@@ -83,27 +196,117 @@ export function AccountMenu({ account, permissions }: AccountMenuProps): React.R
className="absolute bottom-full right-0 z-30 mb-2 w-52 rounded-md border bg-popover p-1 text-popover-foreground shadow-lg" 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" role="menu"
> >
<Link
className="flex min-h-10 items-center gap-2 rounded-sm px-3 text-sm font-medium hover:bg-secondary"
href={settingsHref}
onClick={() => setIsOpen(false)}
role="menuitem"
>
<Settings className="size-4 text-muted-foreground" aria-hidden="true" />
</Link>
<button <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 disabled:opacity-55" className="flex min-h-10 w-full items-center gap-2 rounded-sm px-3 text-left text-sm font-medium hover:bg-secondary"
disabled={isPending} onClick={() => {
onClick={handleSignOut} setIsOpen(false);
setIsSettingsOpen(true);
setMessage("");
}}
role="menuitem" role="menuitem"
type="button" type="button"
> >
<LogOut className="size-4 text-muted-foreground" aria-hidden="true" /> <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> </button>
</div> </div>
) : null} ) : null}
<Dialog
className="max-w-2xl"
description="管理你的个人资料和第三方登录绑定状态。"
onClose={() => {
if (!isPending) {
setIsSettingsOpen(false);
setMessage("");
}
}}
open={isSettingsOpen}
title="用户设置"
>
<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
className="max-w-md"
description="退出后需要重新登录才能继续访问工作台。"
onClose={() => {
if (!isPending) {
setIsSignOutConfirmOpen(false);
}
}}
open={isSignOutConfirmOpen}
title="确认退出登录"
>
<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> </div>
); );
} }

View File

@@ -1,166 +1,34 @@
import Link from "next/link"; import Link from "next/link";
import { import { Stethoscope } from "lucide-react";
Bell,
BedDouble,
Building2,
ClipboardCheck,
Globe2,
HeartPulse,
LayoutDashboard,
ListChecks,
Radio,
Settings,
ShieldAlert,
Stethoscope,
TabletSmartphone,
Users,
Wrench,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { Permission, PublicAccount } from "@/modules/core/types"; import type { AccountOrganizationOption, Organization, Permission, PublicAccount } from "@/modules/core/types";
import { SignOutButton } from "@/modules/auth/components/SignOutButton"; import { SignOutButton } from "@/modules/auth/components/SignOutButton";
import { AccountMenu } from "@/modules/shared/components/AccountMenu"; import { AccountMenu } from "@/modules/shared/components/AccountMenu";
import { AppBreadcrumbs } from "@/modules/shared/components/AppBreadcrumbs"; import { AppBreadcrumbs } from "@/modules/shared/components/AppBreadcrumbs";
import { AppSidebarNav } from "@/modules/shared/components/AppSidebarNav";
import { UserAvatar } from "@/modules/shared/components/UserAvatar"; import { UserAvatar } from "@/modules/shared/components/UserAvatar";
import { navItems } from "@/modules/shared/lib/navigation";
type NavItem = {
href: string;
label: string;
icon: LucideIcon;
permission?: Permission;
};
type NavGroup = {
label: string;
items: NavItem[];
};
const navGroups: NavGroup[] = [
{
label: "运营",
items: [
{
href: "/app/dashboard",
label: "运营看板",
icon: LayoutDashboard,
},
{
href: "/app/elders",
label: "老人档案",
icon: Users,
},
{
href: "/app/beds",
label: "床位房间",
icon: BedDouble,
},
{
href: "/app/health",
label: "健康照护",
icon: HeartPulse,
},
{
href: "/app/care",
label: "护理服务",
icon: ClipboardCheck,
},
{
href: "/app/emergency",
label: "安全应急",
icon: ShieldAlert,
},
],
},
{
label: "协同",
items: [
{
href: "/app/devices",
label: "设备运维",
icon: Wrench,
},
{
href: "/app/notices",
label: "公告通知",
icon: Bell,
},
{
href: "/app/alerts",
label: "规则预警",
icon: Radio,
},
{
href: "/app/family",
label: "家属服务",
icon: TabletSmartphone,
},
],
},
{
label: "系统设置",
items: [
{
href: "/app/settings/global",
label: "全局配置",
icon: Globe2,
permission: "platform:manage",
},
{
href: "/app/settings/organizations",
label: "机构管理",
icon: Building2,
permission: "organization:read",
},
{
href: "/app/settings/users",
label: "用户管理",
icon: Users,
permission: "account:read",
},
{
href: "/app/settings/roles",
label: "角色权限",
icon: Settings,
permission: "role:read",
},
{
href: "/app/settings/audit",
label: "审计日志",
icon: ListChecks,
permission: "audit:read",
},
{
href: "/app/settings/status",
label: "运行状态",
icon: Wrench,
permission: "incident:read",
},
],
},
];
const navItems: NavItem[] = navGroups.flatMap((group) => group.items);
type AppShellProps = { type AppShellProps = {
children: React.ReactNode; children: React.ReactNode;
account: PublicAccount; account: PublicAccount;
organization?: Organization;
organizations: AccountOrganizationOption[];
permissions: Permission[]; permissions: Permission[];
}; };
export function AppShell({ children, account, permissions }: AppShellProps): React.ReactElement { export function AppShell({
const visibleNavGroups = navGroups children,
.map((group) => ({ account,
...group, organization,
items: group.items.filter((item) => !item.permission || permissions.includes(item.permission)), organizations,
})) permissions,
.filter((group) => group.items.length > 0); }: AppShellProps): React.ReactElement {
return ( return (
<div className="flex min-h-svh items-stretch bg-background text-foreground"> <div className="flex h-svh items-stretch overflow-hidden bg-background text-foreground">
<aside className="hidden w-64 shrink-0 border-r bg-white/78 backdrop-blur xl:block"> <aside className="hidden h-svh w-64 shrink-0 border-r bg-white/78 backdrop-blur xl:block">
<div className="flex h-full min-h-0 flex-col"> <div className="flex h-full min-h-0 flex-col">
<Link href="/app/dashboard" className="flex min-h-16 items-center gap-3 border-b px-5"> <Link href="/app/dashboard" className="flex h-16 shrink-0 items-center gap-3 border-b px-5">
<span className="inline-flex size-10 items-center justify-center rounded-md bg-primary text-primary-foreground"> <span className="inline-flex size-10 items-center justify-center rounded-md bg-primary text-primary-foreground">
<Stethoscope className="size-5" aria-hidden="true" /> <Stethoscope className="size-5" aria-hidden="true" />
</span> </span>
@@ -169,40 +37,20 @@ export function AppShell({ children, account, permissions }: AppShellProps): Rea
</span> </span>
</Link> </Link>
<nav className="min-h-0 flex-1 overflow-y-auto px-3 py-4" aria-label="主导航"> <AppSidebarNav permissions={permissions} />
<div className="space-y-5">
{visibleNavGroups.map((group) => (
<section key={group.label}>
<p className="px-3 pb-2 text-xs font-semibold text-muted-foreground">{group.label}</p>
<ul className="space-y-1">
{group.items.map((item) => (
<li key={item.href}>
<Link
href={item.href}
className="group flex min-h-11 items-center gap-3 rounded-md px-3 text-sm transition-colors hover:bg-secondary"
>
<item.icon
className="size-4 shrink-0 text-muted-foreground group-hover:text-primary"
aria-hidden="true"
/>
<span className="min-w-0 truncate font-medium">{item.label}</span>
</Link>
</li>
))}
</ul>
</section>
))}
</div>
</nav>
<div className="border-t p-3"> <div className="border-t p-3">
<AccountMenu account={account} permissions={permissions} /> <AccountMenu
account={account}
organization={organization}
organizations={organizations}
/>
</div> </div>
</div> </div>
</aside> </aside>
<div className="flex min-w-0 flex-1 flex-col"> <div className="flex min-h-0 min-w-0 flex-1 flex-col">
<header className="sticky top-0 z-20 flex min-h-14 items-center justify-between border-b bg-background/88 px-4 backdrop-blur md:px-8"> <header className="sticky top-0 z-20 flex h-16 shrink-0 items-center justify-between border-b bg-background/88 px-4 backdrop-blur md:px-8">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="inline-flex size-10 items-center justify-center rounded-md bg-primary text-primary-foreground xl:hidden"> <span className="inline-flex size-10 items-center justify-center rounded-md bg-primary text-primary-foreground xl:hidden">
<Stethoscope className="size-5" aria-hidden="true" /> <Stethoscope className="size-5" aria-hidden="true" />

View File

@@ -0,0 +1,106 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
BedDouble,
Bell,
Building2,
ClipboardCheck,
Globe2,
HeartPulse,
LayoutDashboard,
ListChecks,
Radio,
Settings,
ShieldAlert,
TabletSmartphone,
Users,
Wrench,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { Permission } from "@/modules/core/types";
import { navGroups, type NavIconKey } from "@/modules/shared/lib/navigation";
import { cn } from "@/lib/utils";
const iconMap: Record<NavIconKey, LucideIcon> = {
alerts: Radio,
audit: ListChecks,
beds: BedDouble,
care: ClipboardCheck,
dashboard: LayoutDashboard,
devices: TabletSmartphone,
emergency: ShieldAlert,
global: Globe2,
health: HeartPulse,
notices: Bell,
organizations: Building2,
roles: Settings,
status: Wrench,
users: Users,
};
type AppSidebarNavProps = {
permissions: Permission[];
};
function isActivePath(pathname: string, href: string): boolean {
if (href === "/app/dashboard") {
return pathname === "/app" || pathname === href;
}
return pathname === href || pathname.startsWith(`${href}/`);
}
export function AppSidebarNav({ permissions }: AppSidebarNavProps): React.ReactElement {
const pathname = usePathname();
const visibleNavGroups = navGroups
.map((group) => ({
...group,
items: group.items.filter((item) => !item.permission || permissions.includes(item.permission)),
}))
.filter((group) => group.items.length > 0);
return (
<nav className="min-h-0 flex-1 overflow-y-auto px-3 py-4" aria-label="主导航">
<div className="space-y-5">
{visibleNavGroups.map((group) => (
<section key={group.label}>
<p className="px-3 pb-2 text-xs font-semibold text-muted-foreground">{group.label}</p>
<ul className="space-y-1">
{group.items.map((item) => {
const Icon = iconMap[item.icon];
const isActive = isActivePath(pathname, item.href);
return (
<li key={item.href}>
<Link
href={item.href}
aria-current={isActive ? "page" : undefined}
className={cn(
"group flex min-h-11 items-center gap-3 rounded-md px-3 text-sm transition-colors",
isActive
? "bg-primary text-primary-foreground shadow-sm"
: "text-foreground hover:bg-secondary hover:text-primary",
)}
>
<Icon
className={cn(
"size-4 shrink-0 transition-colors",
isActive ? "text-primary-foreground" : "text-muted-foreground group-hover:text-primary",
)}
aria-hidden="true"
/>
<span className="min-w-0 truncate font-medium">{item.label}</span>
</Link>
</li>
);
})}
</ul>
</section>
))}
</div>
</nav>
);
}

View File

@@ -0,0 +1,135 @@
import type { Permission } from "@/modules/core/types";
export type NavIconKey =
| "alerts"
| "audit"
| "beds"
| "care"
| "dashboard"
| "devices"
| "emergency"
| "global"
| "health"
| "notices"
| "organizations"
| "roles"
| "status"
| "users";
export type NavItem = {
href: string;
label: string;
icon: NavIconKey;
permission?: Permission;
};
export type NavGroup = {
label: string;
items: NavItem[];
};
export const navGroups: NavGroup[] = [
{
label: "运营",
items: [
{
href: "/app/dashboard",
label: "运营看板",
icon: "dashboard",
},
{
href: "/app/elders",
label: "老人档案",
icon: "users",
},
{
href: "/app/beds",
label: "床位房间",
icon: "beds",
},
{
href: "/app/health",
label: "健康照护",
icon: "health",
},
{
href: "/app/care",
label: "护理服务",
icon: "care",
},
{
href: "/app/emergency",
label: "安全应急",
icon: "emergency",
},
],
},
{
label: "协同",
items: [
{
href: "/app/devices",
label: "设备运维",
icon: "devices",
},
{
href: "/app/notices",
label: "公告通知",
icon: "notices",
},
{
href: "/app/alerts",
label: "规则预警",
icon: "alerts",
},
{
href: "/app/family",
label: "家属服务",
icon: "devices",
},
],
},
{
label: "系统设置",
items: [
{
href: "/app/settings/global",
label: "全局配置",
icon: "global",
permission: "platform:manage",
},
{
href: "/app/settings/organizations",
label: "机构管理",
icon: "organizations",
permission: "organization:read",
},
{
href: "/app/settings/users",
label: "用户管理",
icon: "users",
permission: "account:read",
},
{
href: "/app/settings/roles",
label: "角色权限",
icon: "roles",
permission: "role:read",
},
{
href: "/app/settings/audit",
label: "审计日志",
icon: "audit",
permission: "audit:read",
},
{
href: "/app/settings/status",
label: "运行状态",
icon: "status",
permission: "incident:read",
},
],
},
];
export const navItems: NavItem[] = navGroups.flatMap((group) => group.items);