From 1cdf89c6089caf9b96b88d14cbf4b977c944ffc8 Mon Sep 17 00:00:00 2001 From: TalexDreamSoul Date: Thu, 2 Jul 2026 19:52:29 -0700 Subject: [PATCH] feat: add real account workspace controls --- .trellis/spec/backend/authentication.md | 54 +++++ .trellis/spec/frontend/components.md | 17 ++ app/(app)/app/layout.tsx | 7 +- app/api/account/profile/route.ts | 66 +++++ app/api/auth/organization/route.ts | 29 +++ app/api/auth/session/route.ts | 1 + modules/core/server/auth.ts | 85 ++++++- modules/core/types.ts | 9 + modules/shared/components/AccountMenu.tsx | 253 ++++++++++++++++++-- modules/shared/components/AppShell.tsx | 200 ++-------------- modules/shared/components/AppSidebarNav.tsx | 106 ++++++++ modules/shared/lib/navigation.ts | 135 +++++++++++ 12 files changed, 759 insertions(+), 203 deletions(-) create mode 100644 app/api/account/profile/route.ts create mode 100644 app/api/auth/organization/route.ts create mode 100644 modules/shared/components/AppSidebarNav.tsx create mode 100644 modules/shared/lib/navigation.ts diff --git a/.trellis/spec/backend/authentication.md b/.trellis/spec/backend/authentication.md index b08247e..8f7f89e 100644 --- a/.trellis/spec/backend/authentication.md +++ b/.trellis/spec/backend/authentication.md @@ -4,6 +4,60 @@ This document covers backend authentication integration using better-auth, inclu ## 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 better-auth is a modern authentication library for TypeScript applications that provides: diff --git a/.trellis/spec/frontend/components.md b/.trellis/spec/frontend/components.md index b79aea0..f8296a6 100644 --- a/.trellis/spec/frontend/components.md +++ b/.trellis/spec/frontend/components.md @@ -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 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 Create forms for persisted business records must not prefill required domain fields with diff --git a/app/(app)/app/layout.tsx b/app/(app)/app/layout.tsx index 6f71ec6..e9a7f15 100644 --- a/app/(app)/app/layout.tsx +++ b/app/(app)/app/layout.tsx @@ -26,7 +26,12 @@ export default async function AppLayout({ children }: AppLayoutProps): Promise + {children} ); diff --git a/app/api/account/profile/route.ts b/app/api/account/profile/route.ts new file mode 100644 index 0000000..24bde1d --- /dev/null +++ b/app/api/account/profile/route.ts @@ -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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readString(source: Record, key: string): string { + const value = source[key]; + return typeof value === "string" ? value.trim() : ""; +} + +export async function PATCH(request: Request): Promise { + 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 }); +} diff --git a/app/api/auth/organization/route.ts b/app/api/auth/organization/route.ts new file mode 100644 index 0000000..193f13e --- /dev/null +++ b/app/api/auth/organization/route.ts @@ -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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export async function POST(request: Request): Promise { + 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); + } +} diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts index 41d7e1d..ce31d0c 100644 --- a/app/api/auth/session/route.ts +++ b/app/api/auth/session/route.ts @@ -9,6 +9,7 @@ export async function GET(): Promise { return jsonSuccess("Session loaded", { account: context?.account ?? null, organization: context?.organization ?? null, + organizations: context?.organizations ?? [], membership: context?.membership ?? null, permissions: context?.permissions ?? [], }); diff --git a/modules/core/server/auth.ts b/modules/core/server/auth.ts index f6fa636..7e847da 100644 --- a/modules/core/server/auth.ts +++ b/modules/core/server/auth.ts @@ -17,7 +17,16 @@ import { sessions, } from "@/modules/core/server/schema"; 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_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 { const fallbackRole = "role" in account ? account.role : "viewer"; const fallbackOrganization = "organization" in account ? account.organization : undefined; @@ -505,16 +531,73 @@ export async function getCurrentAuthContext(): Promise { const permissions = [...new Set([...platformPermissions, ...organizationPermissions])]; const organization = organizationRow ? toOrganization(organizationRow) : 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 { account: toPublicAccount(row.account, platformRole?.key ?? membership?.roleKey, organization), organization, + organizations: organizationOptions, membership, permissions, session, }; } +export async function switchActiveOrganization(organizationId: string): Promise { + 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 { const context = await getCurrentAuthContext(); const cookieStore = await cookies(); diff --git a/modules/core/types.ts b/modules/core/types.ts index 47693f8..a944072 100644 --- a/modules/core/types.ts +++ b/modules/core/types.ts @@ -162,6 +162,14 @@ export type Account = { export type PublicAccount = Omit; +export type AccountOrganizationOption = Pick< + Organization, + "id" | "name" | "slug" | "status" | "registrationEnabled" | "oidcEnabled" +> & { + roleLabel: string; + isActive: boolean; +}; + export type Session = { id: string; accountId: string; @@ -263,6 +271,7 @@ export type AppData = { export type AuthContext = { account: PublicAccount; organization?: Organization; + organizations: AccountOrganizationOption[]; membership?: Membership; permissions: Permission[]; session: Session; diff --git a/modules/shared/components/AccountMenu.tsx b/modules/shared/components/AccountMenu.tsx index b65aa0a..e58bc1f 100644 --- a/modules/shared/components/AccountMenu.tsx +++ b/modules/shared/components/AccountMenu.tsx @@ -1,29 +1,41 @@ "use client"; -import Link from "next/link"; import { useRouter } from "next/navigation"; -import { Ellipsis, LogOut, Settings } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +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 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 { cn } from "@/lib/utils"; type AccountMenuProps = { 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 menuRef = useRef(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 canReadAccounts = permissions.includes("account:read"); - const settingsHref = canReadAccounts ? `/app/settings/users?q=${encodeURIComponent(account.email)}` : "/app/settings/global"; + const [message, setMessage] = useState(""); + const currentOrganization = organizations.find((item) => item.isActive) ?? organizations[0]; useEffect(() => { - if (!isOpen) { + if (!isOpen && !isOrganizationOpen) { return; } @@ -31,12 +43,14 @@ export function AccountMenu({ account, permissions }: AccountMenuProps): React.R 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); } } @@ -47,7 +61,27 @@ export function AccountMenu({ account, permissions }: AccountMenuProps): React.R document.removeEventListener("pointerdown", handlePointerDown); document.removeEventListener("keydown", handleKeyDown); }; - }, [isOpen]); + }, [isOpen, isOrganizationOpen]); + + async function handleOrganizationSwitch(organizationId: string): Promise { + 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>; + setIsPending(false); + + if (!result.success) { + setMessage(result.reason); + return; + } + + setIsOrganizationOpen(false); + router.refresh(); + } async function handleSignOut(): Promise { setIsPending(true); @@ -56,8 +90,83 @@ export function AccountMenu({ account, permissions }: AccountMenuProps): React.R router.replace("/login"); } + async function handleProfileSubmit(event: FormEvent): Promise { + 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 ( -
+
+ + + {isOrganizationOpen ? ( +
+ {organizations.map((item) => ( + + ))} + {message ?

{message}

: null} +
+ ) : null} +
@@ -68,7 +177,11 @@ export function AccountMenu({ account, permissions }: AccountMenuProps): React.R aria-expanded={isOpen} aria-haspopup="menu" aria-label="打开账号菜单" - onClick={() => setIsOpen((current) => !current)} + onClick={() => { + setMessage(""); + setIsOpen((current) => !current); + setIsOrganizationOpen(false); + }} size="icon" type="button" 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" role="menu" > - setIsOpen(false)} - role="menuitem" - > -
) : null} + + { + if (!isPending) { + setIsSettingsOpen(false); + setMessage(""); + } + }} + open={isSettingsOpen} + title="用户设置" + > +
+
+ +
+ + +
+
+ +
+
+
+
+
+

+ {organization?.oidcEnabled ? "当前机构已配置 OIDC 登录入口。" : "当前机构暂未启用 OIDC 登录入口。"} +

+
+ + {organization?.oidcEnabled ? "可接入" : "未启用"} + +
+
+ 绑定、解绑和重新授权需要接入 OIDC 回调与绑定记录后启用;当前不会展示虚假的绑定账号。 +
+
+ + {message ? ( +

+ {message} +

+ ) : null} + +
+ + +
+
+
+ + { + if (!isPending) { + setIsSignOutConfirmOpen(false); + } + }} + open={isSignOutConfirmOpen} + title="确认退出登录" + > +
+

确定要退出当前账号吗?

+
+ + +
+
+
); } diff --git a/modules/shared/components/AppShell.tsx b/modules/shared/components/AppShell.tsx index 609b219..dfba69b 100644 --- a/modules/shared/components/AppShell.tsx +++ b/modules/shared/components/AppShell.tsx @@ -1,166 +1,34 @@ import Link from "next/link"; -import { - 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 { Stethoscope } 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 { AccountMenu } from "@/modules/shared/components/AccountMenu"; import { AppBreadcrumbs } from "@/modules/shared/components/AppBreadcrumbs"; +import { AppSidebarNav } from "@/modules/shared/components/AppSidebarNav"; import { UserAvatar } from "@/modules/shared/components/UserAvatar"; - -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); +import { navItems } from "@/modules/shared/lib/navigation"; type AppShellProps = { children: React.ReactNode; account: PublicAccount; + organization?: Organization; + organizations: AccountOrganizationOption[]; permissions: Permission[]; }; -export function AppShell({ children, account, permissions }: AppShellProps): React.ReactElement { - const visibleNavGroups = navGroups - .map((group) => ({ - ...group, - items: group.items.filter((item) => !item.permission || permissions.includes(item.permission)), - })) - .filter((group) => group.items.length > 0); - +export function AppShell({ + children, + account, + organization, + organizations, + permissions, +}: AppShellProps): React.ReactElement { return ( -
-