feat: scope workspace routes by organization slug
This commit is contained in:
@@ -23,8 +23,18 @@ This document covers backend authentication integration using better-auth, inclu
|
|||||||
- `membership: Membership | null`
|
- `membership: Membership | null`
|
||||||
- `permissions: Permission[]`
|
- `permissions: Permission[]`
|
||||||
- `POST /api/auth/organization` request payload: `{ organizationId: string }`.
|
- `POST /api/auth/organization` request payload: `{ organizationId: string }`.
|
||||||
|
- `POST /api/auth/organization` success response payload includes:
|
||||||
|
- `organization: Organization | null`
|
||||||
|
- `organizations: AccountOrganizationOption[]`
|
||||||
|
- `permissions: Permission[]`
|
||||||
- `PATCH /api/account/profile` request payload: `{ name: string; avatarUrl: string }`.
|
- `PATCH /api/account/profile` request payload: `{ name: string; avatarUrl: string }`.
|
||||||
- All responses use `{ success, reason, ...payload }` and `Cache-Control: no-store`.
|
- All responses use `{ success, reason, ...payload }` and `Cache-Control: no-store`.
|
||||||
|
- `Organization.slug` is the canonical tenant segment for authenticated workspace URLs.
|
||||||
|
Organization create/update and setup slug generation must reject or rewrite reserved
|
||||||
|
workspace segments from `modules/shared/lib/workspace-routing.ts` so `/app/settings` and
|
||||||
|
similar product routes cannot be interpreted as tenants.
|
||||||
|
- Switching organizations should return the selected organization and available organization
|
||||||
|
options so the client can redirect to the same workspace section under the new slug.
|
||||||
|
|
||||||
#### 4. Validation & Error Matrix
|
#### 4. Validation & Error Matrix
|
||||||
- Missing/expired session -> `success: false`, `401`, `未登录或会话已过期`.
|
- Missing/expired session -> `success: false`, `401`, `未登录或会话已过期`.
|
||||||
|
|||||||
@@ -216,6 +216,17 @@ surface.
|
|||||||
implemented, show an honest not-connected state instead of fake provider accounts.
|
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()`;
|
- The sidebar nav selected state belongs in a small client component using `usePathname()`;
|
||||||
keep the rest of `AppShell` server-rendered.
|
keep the rest of `AppShell` server-rendered.
|
||||||
|
- Authenticated workspace links should preserve the active organization slug by using
|
||||||
|
`modules/shared/lib/workspace-routing.ts`. Sidebar links, breadcrumbs, permission
|
||||||
|
redirects, table search/pagination paths, login redirects, and organization switch
|
||||||
|
redirects should call `getWorkspaceHref(activeSlug, workspacePath)` instead of hard-coding
|
||||||
|
`/app/...` paths.
|
||||||
|
- The legacy unscoped `/app/...` paths remain valid fallback paths for accounts without an
|
||||||
|
active organization. When a session has an active organization, redirect into
|
||||||
|
`/app/{organizationSlug}/...` so operators can see which workspace they are using.
|
||||||
|
- Top-level workspace route names such as `dashboard`, `settings`, `elders`, and `beds` are
|
||||||
|
reserved path segments, not tenant identifiers. Keep the frontend reserved list in
|
||||||
|
`workspace-routing.ts` aligned with backend organization slug validation.
|
||||||
|
|
||||||
### Business Form Defaults Contract
|
### Business Form Defaults Contract
|
||||||
|
|
||||||
|
|||||||
@@ -155,6 +155,71 @@ app/(app)/
|
|||||||
└── page.tsx -> modules/orders/ (detail view)
|
└── page.tsx -> modules/orders/ (detail view)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Scenario: Organization-Scoped Workspace Routes
|
||||||
|
|
||||||
|
### 1. Scope / Trigger
|
||||||
|
|
||||||
|
- Trigger: adding or changing protected app workspace routes, app-shell navigation, breadcrumbs, login redirects, organization switching, or settings pagination links.
|
||||||
|
- The current workspace URL must include the active organization slug when an organization is selected.
|
||||||
|
|
||||||
|
### 2. Signatures
|
||||||
|
|
||||||
|
- Canonical workspace route: `/app/[organizationSlug]/<workspacePath>`.
|
||||||
|
- Legacy-compatible route: `/app/<workspacePath>` may remain available while old links are migrated.
|
||||||
|
- Shared helpers live in `modules/shared/lib/workspace-routing.ts`:
|
||||||
|
- `getWorkspaceHref(organizationSlug: string | undefined, path?: string): string`
|
||||||
|
- `getWorkspacePathFromPathname(pathname: string): string`
|
||||||
|
- `getWorkspaceSlugFromPathname(pathname: string): string | undefined`
|
||||||
|
- `isWorkspacePathActive(pathname: string, itemPath: string): boolean`
|
||||||
|
|
||||||
|
### 3. Contracts
|
||||||
|
|
||||||
|
- `navGroups` stores workspace-local paths such as `/dashboard`, `/settings/users`, not full `/app/...` hrefs.
|
||||||
|
- `AppShell` passes the current `organization.slug` into navigation and logo links.
|
||||||
|
- `AppSidebarNav`, `AppBreadcrumbs`, settings search forms, and pagination must generate hrefs with `getWorkspaceHref(...)`.
|
||||||
|
- The `[organizationSlug]` layout must reject slug/session mismatches by redirecting to the active session organization workspace.
|
||||||
|
- Organization slugs cannot use reserved first-level workspace section keys such as `dashboard`, `settings`, `elders`, or `beds`.
|
||||||
|
|
||||||
|
### 4. Validation & Error Matrix
|
||||||
|
|
||||||
|
- Missing active organization slug -> helpers fall back to legacy `/app/<workspacePath>`.
|
||||||
|
- URL slug differs from `getCurrentAuthContext().organization.slug` -> redirect to `getWorkspaceHref(activeSlug, "/dashboard")`.
|
||||||
|
- New or updated organization slug equals a reserved workspace key -> API returns validation failure.
|
||||||
|
- A page-level redirect inside protected routes -> use `getWorkspaceHref(context.organization?.slug, targetPath)`.
|
||||||
|
|
||||||
|
### 5. Good/Base/Bad Cases
|
||||||
|
|
||||||
|
- Good: organization switch calls `POST /api/auth/organization`, then navigates to the same workspace path under the returned active organization slug.
|
||||||
|
- Base: legacy `/app/dashboard` remains renderable for compatibility, but new app-shell links point to `/app/{slug}/dashboard`.
|
||||||
|
- Bad: hard-code `/app/settings/users` in table forms or breadcrumbs; it drops users out of the organization-scoped workspace.
|
||||||
|
- Bad: infer tenant from `localStorage`; active organization remains server-session state.
|
||||||
|
|
||||||
|
### 6. Tests Required
|
||||||
|
|
||||||
|
- `pnpm lint`
|
||||||
|
- `pnpm type-check`
|
||||||
|
- `pnpm build`
|
||||||
|
- Browser assertions:
|
||||||
|
- login/setup lands on `/app/{activeOrg.slug}/dashboard`
|
||||||
|
- sidebar links preserve the active organization slug
|
||||||
|
- settings search and pagination preserve the active organization slug
|
||||||
|
- switching organization moves the URL to the new slug while preserving the workspace path
|
||||||
|
- mismatched `/app/{wrongSlug}/...` redirects to the active organization workspace
|
||||||
|
|
||||||
|
### 7. Wrong vs Correct
|
||||||
|
|
||||||
|
#### Wrong
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Link href="/app/settings/users">用户管理</Link>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Correct
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Link href={getWorkspaceHref(organization.slug, "/settings/users")}>用户管理</Link>
|
||||||
|
```
|
||||||
|
|
||||||
## Import Path Aliases
|
## Import Path Aliases
|
||||||
|
|
||||||
Configure in `tsconfig.json`:
|
Configure in `tsconfig.json`:
|
||||||
|
|||||||
5
app/(app)/app/[organizationSlug]/alerts/page.tsx
Normal file
5
app/(app)/app/[organizationSlug]/alerts/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import AlertsPage from "../../alerts/page";
|
||||||
|
|
||||||
|
export default function ScopedAlertsPage(): React.ReactElement {
|
||||||
|
return AlertsPage();
|
||||||
|
}
|
||||||
5
app/(app)/app/[organizationSlug]/beds/page.tsx
Normal file
5
app/(app)/app/[organizationSlug]/beds/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import BedsPage from "../../beds/page";
|
||||||
|
|
||||||
|
export default async function ScopedBedsPage(): Promise<React.ReactElement> {
|
||||||
|
return BedsPage();
|
||||||
|
}
|
||||||
5
app/(app)/app/[organizationSlug]/care/page.tsx
Normal file
5
app/(app)/app/[organizationSlug]/care/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import CarePage from "../../care/page";
|
||||||
|
|
||||||
|
export default function ScopedCarePage(): React.ReactElement {
|
||||||
|
return CarePage();
|
||||||
|
}
|
||||||
5
app/(app)/app/[organizationSlug]/dashboard/page.tsx
Normal file
5
app/(app)/app/[organizationSlug]/dashboard/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import DashboardPage from "../../dashboard/page";
|
||||||
|
|
||||||
|
export default async function ScopedDashboardPage(): Promise<React.ReactElement> {
|
||||||
|
return DashboardPage();
|
||||||
|
}
|
||||||
5
app/(app)/app/[organizationSlug]/devices/page.tsx
Normal file
5
app/(app)/app/[organizationSlug]/devices/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import DevicesPage from "../../devices/page";
|
||||||
|
|
||||||
|
export default function ScopedDevicesPage(): React.ReactElement {
|
||||||
|
return DevicesPage();
|
||||||
|
}
|
||||||
5
app/(app)/app/[organizationSlug]/elders/page.tsx
Normal file
5
app/(app)/app/[organizationSlug]/elders/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import EldersPage from "../../elders/page";
|
||||||
|
|
||||||
|
export default async function ScopedEldersPage(): Promise<React.ReactElement> {
|
||||||
|
return EldersPage();
|
||||||
|
}
|
||||||
5
app/(app)/app/[organizationSlug]/emergency/page.tsx
Normal file
5
app/(app)/app/[organizationSlug]/emergency/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import EmergencyPage from "../../emergency/page";
|
||||||
|
|
||||||
|
export default function ScopedEmergencyPage(): React.ReactElement {
|
||||||
|
return EmergencyPage();
|
||||||
|
}
|
||||||
5
app/(app)/app/[organizationSlug]/family/page.tsx
Normal file
5
app/(app)/app/[organizationSlug]/family/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import FamilyPage from "../../family/page";
|
||||||
|
|
||||||
|
export default function ScopedFamilyPage(): React.ReactElement {
|
||||||
|
return FamilyPage();
|
||||||
|
}
|
||||||
5
app/(app)/app/[organizationSlug]/health/page.tsx
Normal file
5
app/(app)/app/[organizationSlug]/health/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import HealthPage from "../../health/page";
|
||||||
|
|
||||||
|
export default function ScopedHealthPage(): React.ReactElement {
|
||||||
|
return HealthPage();
|
||||||
|
}
|
||||||
28
app/(app)/app/[organizationSlug]/layout.tsx
Normal file
28
app/(app)/app/[organizationSlug]/layout.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
||||||
|
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
||||||
|
|
||||||
|
type OrganizationWorkspaceLayoutProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: Promise<{
|
||||||
|
organizationSlug: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function OrganizationWorkspaceLayout({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: OrganizationWorkspaceLayoutProps): Promise<React.ReactElement> {
|
||||||
|
const [{ organizationSlug }, context] = await Promise.all([params, getCurrentAuthContext()]);
|
||||||
|
if (!context) {
|
||||||
|
redirect("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeSlug = context.organization?.slug;
|
||||||
|
if (activeSlug && activeSlug !== organizationSlug) {
|
||||||
|
redirect(getWorkspaceHref(activeSlug, "/dashboard"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
5
app/(app)/app/[organizationSlug]/notices/page.tsx
Normal file
5
app/(app)/app/[organizationSlug]/notices/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import NoticesPage from "../../notices/page";
|
||||||
|
|
||||||
|
export default function ScopedNoticesPage(): React.ReactElement {
|
||||||
|
return NoticesPage();
|
||||||
|
}
|
||||||
14
app/(app)/app/[organizationSlug]/page.tsx
Normal file
14
app/(app)/app/[organizationSlug]/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
||||||
|
|
||||||
|
type ScopedAppIndexPageProps = {
|
||||||
|
params: Promise<{
|
||||||
|
organizationSlug: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function ScopedAppIndexPage({ params }: ScopedAppIndexPageProps): Promise<never> {
|
||||||
|
const { organizationSlug } = await params;
|
||||||
|
redirect(getWorkspaceHref(organizationSlug, "/dashboard"));
|
||||||
|
}
|
||||||
10
app/(app)/app/[organizationSlug]/settings/audit/page.tsx
Normal file
10
app/(app)/app/[organizationSlug]/settings/audit/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import AuditPage from "../../../settings/audit/page";
|
||||||
|
import type { SearchParams } from "@/modules/settings/lib/pagination";
|
||||||
|
|
||||||
|
type ScopedAuditPageProps = {
|
||||||
|
searchParams?: Promise<SearchParams>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function ScopedAuditPage({ searchParams }: ScopedAuditPageProps): Promise<React.ReactElement> {
|
||||||
|
return AuditPage({ searchParams });
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import GlobalSettingsPage from "../../../settings/global/page";
|
||||||
|
|
||||||
|
export default async function ScopedGlobalSettingsPage(): Promise<React.ReactElement> {
|
||||||
|
return GlobalSettingsPage();
|
||||||
|
}
|
||||||
9
app/(app)/app/[organizationSlug]/settings/layout.tsx
Normal file
9
app/(app)/app/[organizationSlug]/settings/layout.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import SettingsLayout from "../../settings/layout";
|
||||||
|
|
||||||
|
type ScopedSettingsLayoutProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ScopedSettingsLayout({ children }: ScopedSettingsLayoutProps): React.ReactElement {
|
||||||
|
return <SettingsLayout>{children}</SettingsLayout>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import OrganizationDetailPage from "../../../../settings/organizations/[id]/page";
|
||||||
|
|
||||||
|
type ScopedOrganizationDetailPageProps = {
|
||||||
|
params: Promise<{
|
||||||
|
organizationSlug: string;
|
||||||
|
id: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function ScopedOrganizationDetailPage({
|
||||||
|
params,
|
||||||
|
}: ScopedOrganizationDetailPageProps): Promise<React.ReactElement> {
|
||||||
|
const { id } = await params;
|
||||||
|
return OrganizationDetailPage({ params: Promise.resolve({ id }) });
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import OrganizationsPage from "../../../settings/organizations/page";
|
||||||
|
import type { SearchParams } from "@/modules/settings/lib/pagination";
|
||||||
|
|
||||||
|
type ScopedOrganizationsPageProps = {
|
||||||
|
searchParams?: Promise<SearchParams>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function ScopedOrganizationsPage({
|
||||||
|
searchParams,
|
||||||
|
}: ScopedOrganizationsPageProps): Promise<React.ReactElement> {
|
||||||
|
return OrganizationsPage({ searchParams });
|
||||||
|
}
|
||||||
5
app/(app)/app/[organizationSlug]/settings/page.tsx
Normal file
5
app/(app)/app/[organizationSlug]/settings/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import SettingsPage from "../../settings/page";
|
||||||
|
|
||||||
|
export default async function ScopedSettingsPage(): Promise<React.ReactElement> {
|
||||||
|
return SettingsPage();
|
||||||
|
}
|
||||||
10
app/(app)/app/[organizationSlug]/settings/roles/page.tsx
Normal file
10
app/(app)/app/[organizationSlug]/settings/roles/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import RolesPage from "../../../settings/roles/page";
|
||||||
|
import type { SearchParams } from "@/modules/settings/lib/pagination";
|
||||||
|
|
||||||
|
type ScopedRolesPageProps = {
|
||||||
|
searchParams?: Promise<SearchParams>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function ScopedRolesPage({ searchParams }: ScopedRolesPageProps): Promise<React.ReactElement> {
|
||||||
|
return RolesPage({ searchParams });
|
||||||
|
}
|
||||||
10
app/(app)/app/[organizationSlug]/settings/status/page.tsx
Normal file
10
app/(app)/app/[organizationSlug]/settings/status/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import StatusPage from "../../../settings/status/page";
|
||||||
|
import type { SearchParams } from "@/modules/settings/lib/pagination";
|
||||||
|
|
||||||
|
type ScopedStatusPageProps = {
|
||||||
|
searchParams?: Promise<SearchParams>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function ScopedStatusPage({ searchParams }: ScopedStatusPageProps): Promise<React.ReactElement> {
|
||||||
|
return StatusPage({ searchParams });
|
||||||
|
}
|
||||||
10
app/(app)/app/[organizationSlug]/settings/users/page.tsx
Normal file
10
app/(app)/app/[organizationSlug]/settings/users/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import UsersPage from "../../../settings/users/page";
|
||||||
|
import type { SearchParams } from "@/modules/settings/lib/pagination";
|
||||||
|
|
||||||
|
type ScopedUsersPageProps = {
|
||||||
|
searchParams?: Promise<SearchParams>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function ScopedUsersPage({ searchParams }: ScopedUsersPageProps): Promise<React.ReactElement> {
|
||||||
|
return UsersPage({ searchParams });
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from "@/modules/core/server/operations";
|
} from "@/modules/core/server/operations";
|
||||||
import { hasPermission } from "@/modules/core/server/permissions";
|
import { hasPermission } from "@/modules/core/server/permissions";
|
||||||
import { BedsWorkspace } from "@/modules/facilities/components/BedsWorkspace";
|
import { BedsWorkspace } from "@/modules/facilities/components/BedsWorkspace";
|
||||||
|
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
||||||
|
|
||||||
export default async function BedsPage(): Promise<React.ReactElement> {
|
export default async function BedsPage(): Promise<React.ReactElement> {
|
||||||
const context = await getCurrentAuthContext();
|
const context = await getCurrentAuthContext();
|
||||||
@@ -17,7 +18,7 @@ export default async function BedsPage(): Promise<React.ReactElement> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!hasPermission(context.permissions, "facility:read")) {
|
if (!hasPermission(context.permissions, "facility:read")) {
|
||||||
redirect("/app/dashboard");
|
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const organizationId = context.organization?.id;
|
const organizationId = context.organization?.id;
|
||||||
|
|||||||
@@ -7,10 +7,13 @@ import {
|
|||||||
listOperationalElders,
|
listOperationalElders,
|
||||||
listOperationalIncidents,
|
listOperationalIncidents,
|
||||||
} from "@/modules/core/server/operations";
|
} from "@/modules/core/server/operations";
|
||||||
import type { BedStatus } from "@/modules/core/types";
|
import { hasPermission } from "@/modules/core/server/permissions";
|
||||||
|
import type { BedStatus, Permission } from "@/modules/core/types";
|
||||||
import { DashboardHome, type DashboardMetric } from "@/modules/dashboard/components/DashboardHome";
|
import { DashboardHome, type DashboardMetric } from "@/modules/dashboard/components/DashboardHome";
|
||||||
|
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
||||||
|
|
||||||
const BED_STATUSES: BedStatus[] = ["available", "occupied", "maintenance", "disabled"];
|
const BED_STATUSES: BedStatus[] = ["available", "occupied", "maintenance", "disabled"];
|
||||||
|
const DASHBOARD_PERMISSIONS: Permission[] = ["elder:read", "facility:read", "admission:read", "incident:read"];
|
||||||
|
|
||||||
export default async function DashboardPage(): Promise<React.ReactElement> {
|
export default async function DashboardPage(): Promise<React.ReactElement> {
|
||||||
const context = await getCurrentAuthContext();
|
const context = await getCurrentAuthContext();
|
||||||
@@ -19,6 +22,11 @@ export default async function DashboardPage(): Promise<React.ReactElement> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const organizationId = context.organization?.id;
|
const organizationId = context.organization?.id;
|
||||||
|
const canViewDashboard = DASHBOARD_PERMISSIONS.some((permission) => hasPermission(context.permissions, permission));
|
||||||
|
if (!canViewDashboard) {
|
||||||
|
redirect(getWorkspaceHref(context.organization?.slug, "/family"));
|
||||||
|
}
|
||||||
|
|
||||||
const [elders, beds, admissions, incidents] = await Promise.all([
|
const [elders, beds, admissions, incidents] = await Promise.all([
|
||||||
listOperationalElders(organizationId),
|
listOperationalElders(organizationId),
|
||||||
listOperationalBeds(organizationId),
|
listOperationalBeds(organizationId),
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { redirect } from "next/navigation";
|
|||||||
|
|
||||||
import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
||||||
import { listOperationalElders } from "@/modules/core/server/operations";
|
import { listOperationalElders } from "@/modules/core/server/operations";
|
||||||
|
import { hasPermission } from "@/modules/core/server/permissions";
|
||||||
import { EldersClient } from "@/modules/elders/components/EldersClient";
|
import { EldersClient } from "@/modules/elders/components/EldersClient";
|
||||||
|
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
||||||
|
|
||||||
export default async function EldersPage(): Promise<React.ReactElement> {
|
export default async function EldersPage(): Promise<React.ReactElement> {
|
||||||
const context = await getCurrentAuthContext();
|
const context = await getCurrentAuthContext();
|
||||||
@@ -10,6 +12,10 @@ export default async function EldersPage(): Promise<React.ReactElement> {
|
|||||||
redirect("/login");
|
redirect("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!hasPermission(context.permissions, "elder:read")) {
|
||||||
|
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
|
||||||
|
}
|
||||||
|
|
||||||
const elders = await listOperationalElders(context.organization?.id);
|
const elders = await listOperationalElders(context.organization?.id);
|
||||||
|
|
||||||
return <EldersClient initialElders={elders} permissions={context.permissions} />;
|
return <EldersClient initialElders={elders} permissions={context.permissions} />;
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default function AppIndexPage(): never {
|
import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
||||||
redirect("/app/dashboard");
|
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
||||||
|
|
||||||
|
export default async function AppIndexPage(): Promise<never> {
|
||||||
|
const context = await getCurrentAuthContext();
|
||||||
|
redirect(getWorkspaceHref(context?.organization?.slug, "/dashboard"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
|||||||
import { hasPermission } from "@/modules/core/server/permissions";
|
import { hasPermission } from "@/modules/core/server/permissions";
|
||||||
import { TablePagination, TableToolbar } from "@/modules/settings/components/TableToolbar";
|
import { TablePagination, TableToolbar } from "@/modules/settings/components/TableToolbar";
|
||||||
import { getPage, getPageCount, getSearchParam, paginate, type SearchParams } from "@/modules/settings/lib/pagination";
|
import { getPage, getPageCount, getSearchParam, paginate, type SearchParams } from "@/modules/settings/lib/pagination";
|
||||||
|
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
||||||
|
|
||||||
type AuditPageProps = {
|
type AuditPageProps = {
|
||||||
searchParams?: Promise<SearchParams>;
|
searchParams?: Promise<SearchParams>;
|
||||||
@@ -19,9 +20,10 @@ export default async function AuditPage({ searchParams }: AuditPageProps): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!hasPermission(context.permissions, "audit:read")) {
|
if (!hasPermission(context.permissions, "audit:read")) {
|
||||||
redirect("/app/dashboard");
|
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pagePath = getWorkspaceHref(context.organization?.slug, "/settings/audit");
|
||||||
const params = (await searchParams) ?? {};
|
const params = (await searchParams) ?? {};
|
||||||
const query = getSearchParam(params, "q");
|
const query = getSearchParam(params, "q");
|
||||||
const page = getPage(params);
|
const page = getPage(params);
|
||||||
@@ -47,7 +49,7 @@ export default async function AuditPage({ searchParams }: AuditPageProps): Promi
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<TableToolbar
|
<TableToolbar
|
||||||
pathname="/app/settings/audit"
|
pathname={pagePath}
|
||||||
query={query}
|
query={query}
|
||||||
searchPlaceholder="搜索操作者、动作、对象、结果"
|
searchPlaceholder="搜索操作者、动作、对象、结果"
|
||||||
total={filteredLogs.length}
|
total={filteredLogs.length}
|
||||||
@@ -91,7 +93,7 @@ export default async function AuditPage({ searchParams }: AuditPageProps): Promi
|
|||||||
<TablePagination
|
<TablePagination
|
||||||
page={Math.min(page, pageCount)}
|
page={Math.min(page, pageCount)}
|
||||||
pageCount={pageCount}
|
pageCount={pageCount}
|
||||||
pathname="/app/settings/audit"
|
pathname={pagePath}
|
||||||
query={query}
|
query={query}
|
||||||
total={filteredLogs.length}
|
total={filteredLogs.length}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
|||||||
import { hasPermission } from "@/modules/core/server/permissions";
|
import { hasPermission } from "@/modules/core/server/permissions";
|
||||||
import { getSystemSettings } from "@/modules/core/server/system-settings";
|
import { getSystemSettings } from "@/modules/core/server/system-settings";
|
||||||
import { GlobalSettingsClient } from "@/modules/settings/components/GlobalSettingsClient";
|
import { GlobalSettingsClient } from "@/modules/settings/components/GlobalSettingsClient";
|
||||||
|
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
||||||
|
|
||||||
export default async function GlobalSettingsPage(): Promise<React.ReactElement> {
|
export default async function GlobalSettingsPage(): Promise<React.ReactElement> {
|
||||||
const context = await getCurrentAuthContext();
|
const context = await getCurrentAuthContext();
|
||||||
@@ -12,7 +13,7 @@ export default async function GlobalSettingsPage(): Promise<React.ReactElement>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!hasPermission(context.permissions, "platform:manage")) {
|
if (!hasPermission(context.permissions, "platform:manage")) {
|
||||||
redirect("/app/dashboard");
|
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = await getSystemSettings();
|
const settings = await getSystemSettings();
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
listSettingsOrganizations,
|
listSettingsOrganizations,
|
||||||
} from "@/modules/core/server/settings";
|
} from "@/modules/core/server/settings";
|
||||||
import { OrganizationDetailClient } from "@/modules/settings/components/OrganizationDetailClient";
|
import { OrganizationDetailClient } from "@/modules/settings/components/OrganizationDetailClient";
|
||||||
|
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
||||||
|
|
||||||
type OrganizationDetailPageProps = {
|
type OrganizationDetailPageProps = {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -31,7 +32,7 @@ export default async function OrganizationDetailPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!hasPermission(context.permissions, "organization:read")) {
|
if (!hasPermission(context.permissions, "organization:read")) {
|
||||||
redirect("/app/dashboard");
|
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
@@ -42,7 +43,7 @@ export default async function OrganizationDetailPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!context.account.platformRoleId && context.organization?.id && context.organization.id !== organization.id) {
|
if (!context.account.platformRoleId && context.organization?.id && context.organization.id !== organization.id) {
|
||||||
redirect("/app/settings/organizations");
|
redirect(getWorkspaceHref(context.organization?.slug, "/settings/organizations"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const [roles, organizationMemberships, organizationAccounts, invitations, joinRequests] = await Promise.all([
|
const [roles, organizationMemberships, organizationAccounts, invitations, joinRequests] = await Promise.all([
|
||||||
@@ -72,7 +73,7 @@ export default async function OrganizationDetailPage({
|
|||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
<section className="flex flex-col gap-4 border-b pb-5 lg:flex-row lg:items-end lg:justify-between">
|
<section className="flex flex-col gap-4 border-b pb-5 lg:flex-row lg:items-end lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<LinkButton href="/app/settings/organizations" size="sm" variant="outline">
|
<LinkButton href={getWorkspaceHref(context.organization?.slug, "/settings/organizations")} size="sm" variant="outline">
|
||||||
<ArrowLeft className="size-4" aria-hidden="true" />
|
<ArrowLeft className="size-4" aria-hidden="true" />
|
||||||
返回机构列表
|
返回机构列表
|
||||||
</LinkButton>
|
</LinkButton>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "@/modules/settings/components/OrganizationManagementClient";
|
} from "@/modules/settings/components/OrganizationManagementClient";
|
||||||
import { TablePagination, TableToolbar } from "@/modules/settings/components/TableToolbar";
|
import { TablePagination, TableToolbar } from "@/modules/settings/components/TableToolbar";
|
||||||
import { getPage, getPageCount, getSearchParam, paginate, type SearchParams } from "@/modules/settings/lib/pagination";
|
import { getPage, getPageCount, getSearchParam, paginate, type SearchParams } from "@/modules/settings/lib/pagination";
|
||||||
|
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
||||||
|
|
||||||
type OrganizationsPageProps = {
|
type OrganizationsPageProps = {
|
||||||
searchParams?: Promise<SearchParams>;
|
searchParams?: Promise<SearchParams>;
|
||||||
@@ -24,9 +25,10 @@ export default async function OrganizationsPage({ searchParams }: OrganizationsP
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!hasPermission(context.permissions, "organization:read")) {
|
if (!hasPermission(context.permissions, "organization:read")) {
|
||||||
redirect("/app/dashboard");
|
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pagePath = getWorkspaceHref(context.organization?.slug, "/settings/organizations");
|
||||||
const params = (await searchParams) ?? {};
|
const params = (await searchParams) ?? {};
|
||||||
const query = getSearchParam(params, "q");
|
const query = getSearchParam(params, "q");
|
||||||
const page = getPage(params);
|
const page = getPage(params);
|
||||||
@@ -64,7 +66,7 @@ export default async function OrganizationsPage({ searchParams }: OrganizationsP
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<TableToolbar
|
<TableToolbar
|
||||||
pathname="/app/settings/organizations"
|
pathname={pagePath}
|
||||||
query={query}
|
query={query}
|
||||||
searchPlaceholder="搜索机构名称、标识、状态"
|
searchPlaceholder="搜索机构名称、标识、状态"
|
||||||
total={filteredOrganizations.length}
|
total={filteredOrganizations.length}
|
||||||
@@ -86,7 +88,7 @@ export default async function OrganizationsPage({ searchParams }: OrganizationsP
|
|||||||
<Table.Cell className="px-4 py-3">
|
<Table.Cell className="px-4 py-3">
|
||||||
<Link
|
<Link
|
||||||
className="font-medium text-primary underline-offset-4 hover:underline"
|
className="font-medium text-primary underline-offset-4 hover:underline"
|
||||||
href={`/app/settings/organizations/${organization.id}`}
|
href={getWorkspaceHref(context.organization?.slug, `/settings/organizations/${organization.id}`)}
|
||||||
>
|
>
|
||||||
{organization.name}
|
{organization.name}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -118,7 +120,7 @@ export default async function OrganizationsPage({ searchParams }: OrganizationsP
|
|||||||
<TablePagination
|
<TablePagination
|
||||||
page={Math.min(page, pageCount)}
|
page={Math.min(page, pageCount)}
|
||||||
pageCount={pageCount}
|
pageCount={pageCount}
|
||||||
pathname="/app/settings/organizations"
|
pathname={pagePath}
|
||||||
query={query}
|
query={query}
|
||||||
total={filteredOrganizations.length}
|
total={filteredOrganizations.length}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { getCurrentAuthContext } from "@/modules/core/server/auth";
|
||||||
|
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
||||||
|
|
||||||
export default async function SettingsPage(): Promise<React.ReactElement> {
|
export default async function SettingsPage(): Promise<React.ReactElement> {
|
||||||
redirect("/app/settings/users");
|
const context = await getCurrentAuthContext();
|
||||||
|
redirect(getWorkspaceHref(context?.organization?.slug, "/settings/users"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { getRoleDefinitions, hasPermission, PERMISSION_DEFINITIONS } from "@/mod
|
|||||||
import { RoleManagementClient, RoleRowActions } from "@/modules/settings/components/RoleManagementClient";
|
import { RoleManagementClient, RoleRowActions } from "@/modules/settings/components/RoleManagementClient";
|
||||||
import { TablePagination, TableToolbar } from "@/modules/settings/components/TableToolbar";
|
import { TablePagination, TableToolbar } from "@/modules/settings/components/TableToolbar";
|
||||||
import { getPage, getPageCount, getSearchParam, paginate, type SearchParams } from "@/modules/settings/lib/pagination";
|
import { getPage, getPageCount, getSearchParam, paginate, type SearchParams } from "@/modules/settings/lib/pagination";
|
||||||
|
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
||||||
|
|
||||||
type RolesPageProps = {
|
type RolesPageProps = {
|
||||||
searchParams?: Promise<SearchParams>;
|
searchParams?: Promise<SearchParams>;
|
||||||
@@ -19,9 +20,10 @@ export default async function RolesPage({ searchParams }: RolesPageProps): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!hasPermission(context.permissions, "role:read")) {
|
if (!hasPermission(context.permissions, "role:read")) {
|
||||||
redirect("/app/dashboard");
|
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pagePath = getWorkspaceHref(context.organization?.slug, "/settings/roles");
|
||||||
const params = (await searchParams) ?? {};
|
const params = (await searchParams) ?? {};
|
||||||
const query = getSearchParam(params, "q");
|
const query = getSearchParam(params, "q");
|
||||||
const page = getPage(params);
|
const page = getPage(params);
|
||||||
@@ -48,7 +50,7 @@ export default async function RolesPage({ searchParams }: RolesPageProps): Promi
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<TableToolbar
|
<TableToolbar
|
||||||
pathname="/app/settings/roles"
|
pathname={pagePath}
|
||||||
query={query}
|
query={query}
|
||||||
searchPlaceholder="搜索角色、权限、说明"
|
searchPlaceholder="搜索角色、权限、说明"
|
||||||
total={filteredRoles.length}
|
total={filteredRoles.length}
|
||||||
@@ -94,7 +96,7 @@ export default async function RolesPage({ searchParams }: RolesPageProps): Promi
|
|||||||
<TablePagination
|
<TablePagination
|
||||||
page={Math.min(page, pageCount)}
|
page={Math.min(page, pageCount)}
|
||||||
pageCount={pageCount}
|
pageCount={pageCount}
|
||||||
pathname="/app/settings/roles"
|
pathname={pagePath}
|
||||||
query={query}
|
query={query}
|
||||||
total={filteredRoles.length}
|
total={filteredRoles.length}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { IncidentStatusActions } from "@/modules/settings/components/IncidentStatusActions";
|
import { IncidentStatusActions } from "@/modules/settings/components/IncidentStatusActions";
|
||||||
import { TablePagination, TableToolbar } from "@/modules/settings/components/TableToolbar";
|
import { TablePagination, TableToolbar } from "@/modules/settings/components/TableToolbar";
|
||||||
import { getPage, getPageCount, getSearchParam, paginate, type SearchParams } from "@/modules/settings/lib/pagination";
|
import { getPage, getPageCount, getSearchParam, paginate, type SearchParams } from "@/modules/settings/lib/pagination";
|
||||||
|
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
||||||
|
|
||||||
type StatusPageProps = {
|
type StatusPageProps = {
|
||||||
searchParams?: Promise<SearchParams>;
|
searchParams?: Promise<SearchParams>;
|
||||||
@@ -27,9 +28,10 @@ export default async function StatusPage({ searchParams }: StatusPageProps): Pro
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!hasPermission(context.permissions, "incident:read")) {
|
if (!hasPermission(context.permissions, "incident:read")) {
|
||||||
redirect("/app/dashboard");
|
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pagePath = getWorkspaceHref(context.organization?.slug, "/settings/status");
|
||||||
const params = (await searchParams) ?? {};
|
const params = (await searchParams) ?? {};
|
||||||
const query = getSearchParam(params, "q");
|
const query = getSearchParam(params, "q");
|
||||||
const page = getPage(params);
|
const page = getPage(params);
|
||||||
@@ -93,7 +95,7 @@ export default async function StatusPage({ searchParams }: StatusPageProps): Pro
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<TableToolbar
|
<TableToolbar
|
||||||
pathname="/app/settings/status"
|
pathname={pagePath}
|
||||||
query={query}
|
query={query}
|
||||||
searchPlaceholder="搜索故障标题、来源、状态"
|
searchPlaceholder="搜索故障标题、来源、状态"
|
||||||
total={filteredIncidents.length}
|
total={filteredIncidents.length}
|
||||||
@@ -145,7 +147,7 @@ export default async function StatusPage({ searchParams }: StatusPageProps): Pro
|
|||||||
<TablePagination
|
<TablePagination
|
||||||
page={Math.min(page, pageCount)}
|
page={Math.min(page, pageCount)}
|
||||||
pageCount={pageCount}
|
pageCount={pageCount}
|
||||||
pathname="/app/settings/status"
|
pathname={pagePath}
|
||||||
query={query}
|
query={query}
|
||||||
total={filteredIncidents.length}
|
total={filteredIncidents.length}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { TablePagination, TableToolbar } from "@/modules/settings/components/Tab
|
|||||||
import { UserAccountActions, UserManagementClient } from "@/modules/settings/components/UserManagementClient";
|
import { UserAccountActions, UserManagementClient } from "@/modules/settings/components/UserManagementClient";
|
||||||
import { getPage, getPageCount, getSearchParam, paginate, type SearchParams } from "@/modules/settings/lib/pagination";
|
import { getPage, getPageCount, getSearchParam, paginate, type SearchParams } from "@/modules/settings/lib/pagination";
|
||||||
import { UserAvatar } from "@/modules/shared/components/UserAvatar";
|
import { UserAvatar } from "@/modules/shared/components/UserAvatar";
|
||||||
|
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
||||||
|
|
||||||
type UsersPageProps = {
|
type UsersPageProps = {
|
||||||
searchParams?: Promise<SearchParams>;
|
searchParams?: Promise<SearchParams>;
|
||||||
@@ -37,9 +38,10 @@ export default async function UsersPage({ searchParams }: UsersPageProps): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!hasPermission(context.permissions, "account:read")) {
|
if (!hasPermission(context.permissions, "account:read")) {
|
||||||
redirect("/app/dashboard");
|
redirect(getWorkspaceHref(context.organization?.slug, "/dashboard"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pagePath = getWorkspaceHref(context.organization?.slug, "/settings/users");
|
||||||
const params = (await searchParams) ?? {};
|
const params = (await searchParams) ?? {};
|
||||||
const query = getSearchParam(params, "q");
|
const query = getSearchParam(params, "q");
|
||||||
const page = getPage(params);
|
const page = getPage(params);
|
||||||
@@ -90,7 +92,7 @@ export default async function UsersPage({ searchParams }: UsersPageProps): Promi
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<TableToolbar
|
<TableToolbar
|
||||||
pathname="/app/settings/users"
|
pathname={pagePath}
|
||||||
query={query}
|
query={query}
|
||||||
searchPlaceholder="搜索姓名、邮箱、角色、状态"
|
searchPlaceholder="搜索姓名、邮箱、角色、状态"
|
||||||
total={filteredAccounts.length}
|
total={filteredAccounts.length}
|
||||||
@@ -152,7 +154,7 @@ export default async function UsersPage({ searchParams }: UsersPageProps): Promi
|
|||||||
<TablePagination
|
<TablePagination
|
||||||
page={Math.min(page, pageCount)}
|
page={Math.min(page, pageCount)}
|
||||||
pageCount={pageCount}
|
pageCount={pageCount}
|
||||||
pathname="/app/settings/users"
|
pathname={pagePath}
|
||||||
query={query}
|
query={query}
|
||||||
total={filteredAccounts.length}
|
total={filteredAccounts.length}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { requirePermission } from "@/modules/core/server/auth";
|
|||||||
import { getDatabase } from "@/modules/core/server/db";
|
import { getDatabase } from "@/modules/core/server/db";
|
||||||
import { organizations } from "@/modules/core/server/schema";
|
import { organizations } from "@/modules/core/server/schema";
|
||||||
import type { OrganizationStatus } from "@/modules/core/types";
|
import type { OrganizationStatus } from "@/modules/core/types";
|
||||||
|
import { RESERVED_WORKSPACE_SLUGS } from "@/modules/shared/lib/workspace-routing";
|
||||||
|
|
||||||
type RouteContext = {
|
type RouteContext = {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@@ -38,7 +39,7 @@ function readStatus(value: unknown): OrganizationStatus | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isValidSlug(value: string): boolean {
|
function isValidSlug(value: string): boolean {
|
||||||
return /^[a-z0-9][a-z0-9-]{1,47}$/.test(value) && !value.endsWith("-");
|
return /^[a-z0-9][a-z0-9-]{1,47}$/.test(value) && !value.endsWith("-") && !RESERVED_WORKSPACE_SLUGS.has(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function PATCH(request: Request, context: RouteContext): Promise<Response> {
|
export async function PATCH(request: Request, context: RouteContext): Promise<Response> {
|
||||||
@@ -74,7 +75,7 @@ export async function PATCH(request: Request, context: RouteContext): Promise<Re
|
|||||||
return jsonFailure("机构标识不能为空");
|
return jsonFailure("机构标识不能为空");
|
||||||
}
|
}
|
||||||
if (slug && !isValidSlug(slug)) {
|
if (slug && !isValidSlug(slug)) {
|
||||||
return jsonFailure("机构标识仅支持小写字母、数字和连字符,长度 2-48 位,且不能以连字符结尾");
|
return jsonFailure("机构标识仅支持小写字母、数字和连字符,长度 2-48 位,不能以连字符结尾,且不能使用系统路径保留字");
|
||||||
}
|
}
|
||||||
const registrationEnabled = "registrationEnabled" in body ? readBoolean(body, "registrationEnabled") : null;
|
const registrationEnabled = "registrationEnabled" in body ? readBoolean(body, "registrationEnabled") : null;
|
||||||
const oidcEnabled = "oidcEnabled" in body ? readBoolean(body, "oidcEnabled") : null;
|
const oidcEnabled = "oidcEnabled" in body ? readBoolean(body, "oidcEnabled") : null;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { getDatabase } from "@/modules/core/server/db";
|
|||||||
import { seedOrganizationRoles } from "@/modules/core/server/permissions";
|
import { seedOrganizationRoles } from "@/modules/core/server/permissions";
|
||||||
import { organizations } from "@/modules/core/server/schema";
|
import { organizations } from "@/modules/core/server/schema";
|
||||||
import { getSystemSettings } from "@/modules/core/server/system-settings";
|
import { getSystemSettings } from "@/modules/core/server/system-settings";
|
||||||
|
import { RESERVED_WORKSPACE_SLUGS } from "@/modules/shared/lib/workspace-routing";
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
@@ -18,7 +19,7 @@ function readString(source: Record<string, unknown>, key: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isValidSlug(value: string): boolean {
|
function isValidSlug(value: string): boolean {
|
||||||
return /^[a-z0-9][a-z0-9-]{1,47}$/.test(value) && !value.endsWith("-");
|
return /^[a-z0-9][a-z0-9-]{1,47}$/.test(value) && !value.endsWith("-") && !RESERVED_WORKSPACE_SLUGS.has(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(): Promise<Response> {
|
export async function GET(): Promise<Response> {
|
||||||
@@ -75,7 +76,7 @@ export async function POST(request: Request): Promise<Response> {
|
|||||||
return jsonFailure("机构标识不能为空");
|
return jsonFailure("机构标识不能为空");
|
||||||
}
|
}
|
||||||
if (!isValidSlug(slug)) {
|
if (!isValidSlug(slug)) {
|
||||||
return jsonFailure("机构标识仅支持小写字母、数字和连字符,长度 2-48 位,且不能以连字符结尾");
|
return jsonFailure("机构标识仅支持小写字母、数字和连字符,长度 2-48 位,不能以连字符结尾,且不能使用系统路径保留字");
|
||||||
}
|
}
|
||||||
|
|
||||||
const database = getDatabase();
|
const database = getDatabase();
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import { Card } from "@/components/ui/card";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select } from "@/components/ui/select";
|
import { Select } from "@/components/ui/select";
|
||||||
import type { ApiResult } from "@/modules/core/server/api";
|
import type { ApiResult } from "@/modules/core/server/api";
|
||||||
import type { Organization } from "@/modules/core/types";
|
import type { AccountOrganizationOption, Organization } from "@/modules/core/types";
|
||||||
|
import { getWorkspaceHref, getWorkspacePathFromPathname } from "@/modules/shared/lib/workspace-routing";
|
||||||
|
|
||||||
type AuthMode = "login" | "register" | "setup";
|
type AuthMode = "login" | "register" | "setup";
|
||||||
|
|
||||||
@@ -45,6 +46,27 @@ async function readApiResult<T extends Record<string, unknown>>(response: Respon
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SessionPayload = {
|
||||||
|
account: unknown | null;
|
||||||
|
organization: Organization | null;
|
||||||
|
organizations: AccountOrganizationOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function getSessionWorkspaceHref(session: SessionPayload, preferredHref: string): string {
|
||||||
|
const activeOrganization = session.organization ?? session.organizations.find((organization) => organization.isActive);
|
||||||
|
return getWorkspaceHref(activeOrganization?.slug, getWorkspacePathFromPathname(preferredHref));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveWorkspaceRedirect(preferredHref: string): Promise<string> {
|
||||||
|
const sessionResponse = await fetch("/api/auth/session");
|
||||||
|
const session = await readApiResult<SessionPayload>(sessionResponse);
|
||||||
|
if (!session.success) {
|
||||||
|
return preferredHref;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getSessionWorkspaceHref(session, preferredHref);
|
||||||
|
}
|
||||||
|
|
||||||
function AuthPanelContent({ mode }: AuthPanelProps): React.ReactElement {
|
function AuthPanelContent({ mode }: AuthPanelProps): React.ReactElement {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -69,7 +91,7 @@ function AuthPanelContent({ mode }: AuthPanelProps): React.ReactElement {
|
|||||||
fetch("/api/organizations"),
|
fetch("/api/organizations"),
|
||||||
]);
|
]);
|
||||||
const bootstrap = await readApiResult<{ setupRequired: boolean }>(bootstrapResponse);
|
const bootstrap = await readApiResult<{ setupRequired: boolean }>(bootstrapResponse);
|
||||||
const session = await readApiResult<{ account: unknown | null }>(sessionResponse);
|
const session = await readApiResult<SessionPayload>(sessionResponse);
|
||||||
const organizationsResult = await readApiResult<{ organizations: Organization[] }>(organizationsResponse);
|
const organizationsResult = await readApiResult<{ organizations: Organization[] }>(organizationsResponse);
|
||||||
|
|
||||||
if (!isMounted || !bootstrap.success || !session.success) {
|
if (!isMounted || !bootstrap.success || !session.success) {
|
||||||
@@ -97,7 +119,7 @@ function AuthPanelContent({ mode }: AuthPanelProps): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (session.account) {
|
if (session.account) {
|
||||||
router.replace("/app/dashboard");
|
router.replace(getSessionWorkspaceHref(session, "/app/dashboard"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +181,7 @@ function AuthPanelContent({ mode }: AuthPanelProps): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
router.replace(isLogin ? redirectTo : "/app/dashboard");
|
router.replace(await resolveWorkspaceRedirect(isLogin ? redirectTo : "/app/dashboard"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasAccounts === null) {
|
if (hasAccounts === null) {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import type {
|
|||||||
PublicAccount,
|
PublicAccount,
|
||||||
Session,
|
Session,
|
||||||
} from "@/modules/core/types";
|
} from "@/modules/core/types";
|
||||||
|
import { RESERVED_WORKSPACE_SLUGS } from "@/modules/shared/lib/workspace-routing";
|
||||||
|
|
||||||
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;
|
||||||
@@ -154,7 +155,8 @@ function createSlug(name: string): string {
|
|||||||
.replace(/[^a-z0-9]+/g, "-")
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
.replace(/^-|-$/g, "");
|
.replace(/^-|-$/g, "");
|
||||||
|
|
||||||
return base.length > 0 ? base : `org-${Date.now()}`;
|
const slug = base.length > 0 ? base : `org-${Date.now()}`;
|
||||||
|
return RESERVED_WORKSPACE_SLUGS.has(slug) ? `org-${slug}` : slug;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setSessionCookie(sessionId: string): Promise<void> {
|
export async function setSessionCookie(sessionId: string): Promise<void> {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { and, eq, inArray, isNull, or } from "drizzle-orm";
|
import { and, eq, inArray, isNull, or } from "drizzle-orm";
|
||||||
|
|
||||||
import { getDatabase } from "@/modules/core/server/db";
|
import { getDatabase } from "@/modules/core/server/db";
|
||||||
import { permissions, rolePermissions, roles } from "@/modules/core/server/schema";
|
import { organizations, permissions, rolePermissions, roles } from "@/modules/core/server/schema";
|
||||||
import { ensureSystemSettings } from "@/modules/core/server/system-settings";
|
import { ensureSystemSettings } from "@/modules/core/server/system-settings";
|
||||||
import type { Permission, RoleDefinition, RoleId } from "@/modules/core/types";
|
import type { Permission, RoleDefinition, RoleId } from "@/modules/core/types";
|
||||||
import { PERMISSIONS, ROLE_LABELS } from "@/modules/core/types";
|
import { PERMISSIONS, ROLE_LABELS } from "@/modules/core/types";
|
||||||
@@ -126,9 +126,21 @@ export const SYSTEM_ROLE_DEFINITIONS: SeedRole[] = [
|
|||||||
{
|
{
|
||||||
key: "viewer",
|
key: "viewer",
|
||||||
scope: "organization",
|
scope: "organization",
|
||||||
description: "只读访客,查看老人、床位和入住信息。",
|
description: "普通用户,只读查看老人、床位和入住信息。",
|
||||||
permissions: ["facility:read", "admission:read", "elder:read"],
|
permissions: ["facility:read", "admission:read", "elder:read"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "family",
|
||||||
|
scope: "organization",
|
||||||
|
description: "亲属账号,预留给家属端与授权老人动态访问。",
|
||||||
|
permissions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "resident",
|
||||||
|
scope: "organization",
|
||||||
|
description: "本人账号,预留给老人本人查看个人服务与健康信息。",
|
||||||
|
permissions: [],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export async function ensureSystemDefaults(): Promise<void> {
|
export async function ensureSystemDefaults(): Promise<void> {
|
||||||
@@ -181,6 +193,9 @@ export async function ensureSystemDefaults(): Promise<void> {
|
|||||||
if (inserts.length > 0) {
|
if (inserts.length > 0) {
|
||||||
await database.insert(rolePermissions).values(inserts).onConflictDoNothing();
|
await database.insert(rolePermissions).values(inserts).onConflictDoNothing();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const organizationRows = await database.select({ id: organizations.id }).from(organizations);
|
||||||
|
await Promise.all(organizationRows.map((organization) => seedOrganizationRoles(organization.id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function seedOrganizationRoles(organizationId: string): Promise<void> {
|
export async function seedOrganizationRoles(organizationId: string): Promise<void> {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export const ROLE_IDS = [
|
|||||||
"manager",
|
"manager",
|
||||||
"caregiver",
|
"caregiver",
|
||||||
"viewer",
|
"viewer",
|
||||||
|
"family",
|
||||||
|
"resident",
|
||||||
] as const;
|
] as const;
|
||||||
export type RoleId = (typeof ROLE_IDS)[number];
|
export type RoleId = (typeof ROLE_IDS)[number];
|
||||||
|
|
||||||
@@ -20,7 +22,9 @@ export const ROLE_LABELS: Record<RoleId, string> = {
|
|||||||
org_admin: "机构管理员",
|
org_admin: "机构管理员",
|
||||||
manager: "运营主管",
|
manager: "运营主管",
|
||||||
caregiver: "照护人员",
|
caregiver: "照护人员",
|
||||||
viewer: "只读访客",
|
viewer: "普通用户",
|
||||||
|
family: "亲属",
|
||||||
|
resident: "本人",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PERMISSIONS = [
|
export const PERMISSIONS = [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { Building2, Check, ChevronUp, Ellipsis, Link2, LogOut, Settings } from "lucide-react";
|
import { Building2, Check, ChevronUp, Ellipsis, Link2, LogOut, Settings } from "lucide-react";
|
||||||
import { FormEvent, useEffect, useRef, useState } from "react";
|
import { FormEvent, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import type { ApiResult } from "@/modules/core/server/api";
|
import type { ApiResult } from "@/modules/core/server/api";
|
||||||
import type { AccountOrganizationOption, Organization, PublicAccount } from "@/modules/core/types";
|
import type { AccountOrganizationOption, Organization, PublicAccount } from "@/modules/core/types";
|
||||||
import { UserAvatar } from "@/modules/shared/components/UserAvatar";
|
import { UserAvatar } from "@/modules/shared/components/UserAvatar";
|
||||||
|
import { getWorkspaceHref, getWorkspacePathFromPathname } from "@/modules/shared/lib/workspace-routing";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type AccountMenuProps = {
|
type AccountMenuProps = {
|
||||||
@@ -25,6 +26,7 @@ export function AccountMenu({
|
|||||||
organizations,
|
organizations,
|
||||||
}: AccountMenuProps): React.ReactElement {
|
}: AccountMenuProps): React.ReactElement {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
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 [isOrganizationOpen, setIsOrganizationOpen] = useState(false);
|
||||||
@@ -71,7 +73,10 @@ export function AccountMenu({
|
|||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ organizationId }),
|
body: JSON.stringify({ organizationId }),
|
||||||
});
|
});
|
||||||
const result = (await response.json()) as ApiResult<Record<string, unknown>>;
|
const result = (await response.json()) as ApiResult<{
|
||||||
|
organization: Organization | null;
|
||||||
|
organizations: AccountOrganizationOption[];
|
||||||
|
}>;
|
||||||
setIsPending(false);
|
setIsPending(false);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -80,6 +85,9 @@ export function AccountMenu({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsOrganizationOpen(false);
|
setIsOrganizationOpen(false);
|
||||||
|
const activeOrganization = result.organization ?? result.organizations.find((item) => item.isActive);
|
||||||
|
const workspacePath = getWorkspacePathFromPathname(pathname);
|
||||||
|
router.replace(getWorkspaceHref(activeOrganization?.slug, workspacePath));
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,55 +4,60 @@ import Link from "next/link";
|
|||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
|
import { getWorkspaceHref, getWorkspacePathFromPathname, getWorkspaceSlugFromPathname } from "@/modules/shared/lib/workspace-routing";
|
||||||
|
|
||||||
type BreadcrumbItem = {
|
type BreadcrumbItem = {
|
||||||
href?: string;
|
href?: string;
|
||||||
label: string;
|
label: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const routeLabels: Record<string, string> = {
|
const routeLabels: Record<string, string> = {
|
||||||
"/app/dashboard": "运营看板",
|
"/dashboard": "运营看板",
|
||||||
"/app/elders": "老人档案",
|
"/elders": "老人档案",
|
||||||
"/app/beds": "床位房间",
|
"/beds": "床位房间",
|
||||||
"/app/health": "健康照护",
|
"/health": "健康照护",
|
||||||
"/app/care": "护理服务",
|
"/care": "护理服务",
|
||||||
"/app/emergency": "安全应急",
|
"/emergency": "安全应急",
|
||||||
"/app/devices": "设备运维",
|
"/devices": "设备运维",
|
||||||
"/app/notices": "公告通知",
|
"/notices": "公告通知",
|
||||||
"/app/alerts": "规则预警",
|
"/alerts": "规则预警",
|
||||||
"/app/family": "家属服务",
|
"/family": "家属服务",
|
||||||
"/app/settings/global": "全局配置",
|
"/settings/global": "全局配置",
|
||||||
"/app/settings/organizations": "机构管理",
|
"/settings/organizations": "机构管理",
|
||||||
"/app/settings/users": "用户管理",
|
"/settings/users": "用户管理",
|
||||||
"/app/settings/roles": "角色权限",
|
"/settings/roles": "角色权限",
|
||||||
"/app/settings/audit": "审计日志",
|
"/settings/audit": "审计日志",
|
||||||
"/app/settings/status": "运行状态",
|
"/settings/status": "运行状态",
|
||||||
};
|
};
|
||||||
|
|
||||||
function getBreadcrumbs(pathname: string): BreadcrumbItem[] {
|
function getBreadcrumbs(pathname: string): BreadcrumbItem[] {
|
||||||
if (pathname === "/app" || pathname === "/app/dashboard") {
|
const workspacePath = getWorkspacePathFromPathname(pathname);
|
||||||
|
const organizationSlug = getWorkspaceSlugFromPathname(pathname);
|
||||||
|
|
||||||
|
if (workspacePath === "/dashboard") {
|
||||||
return [{ label: "工作台" }];
|
return [{ label: "工作台" }];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathname.startsWith("/app/settings/organizations/")) {
|
if (workspacePath.startsWith("/settings/organizations/")) {
|
||||||
return [
|
return [
|
||||||
{ href: "/app/dashboard", label: "工作台" },
|
{ href: getWorkspaceHref(organizationSlug, "/dashboard"), label: "工作台" },
|
||||||
{ href: "/app/settings/users", label: "系统设置" },
|
{ href: getWorkspaceHref(organizationSlug, "/settings/users"), label: "管理系统" },
|
||||||
{ href: "/app/settings/organizations", label: "机构管理" },
|
{ href: getWorkspaceHref(organizationSlug, "/settings/organizations"), label: "机构管理" },
|
||||||
{ label: "机构详情" },
|
{ label: "机构详情" },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathname.startsWith("/app/settings")) {
|
if (workspacePath.startsWith("/settings")) {
|
||||||
return [
|
return [
|
||||||
{ href: "/app/dashboard", label: "工作台" },
|
{ href: getWorkspaceHref(organizationSlug, "/dashboard"), label: "工作台" },
|
||||||
{ href: "/app/settings/users", label: "系统设置" },
|
{ href: getWorkspaceHref(organizationSlug, "/settings/users"), label: "管理系统" },
|
||||||
{ label: routeLabels[pathname] ?? "设置" },
|
{ label: routeLabels[workspacePath] ?? "设置" },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ href: "/app/dashboard", label: "工作台" },
|
{ href: getWorkspaceHref(organizationSlug, "/dashboard"), label: "工作台" },
|
||||||
{ label: routeLabels[pathname] ?? "工作区" },
|
{ label: routeLabels[workspacePath] ?? "工作区" },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { AppBreadcrumbs } from "@/modules/shared/components/AppBreadcrumbs";
|
|||||||
import { AppSidebarNav } from "@/modules/shared/components/AppSidebarNav";
|
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";
|
import { navItems } from "@/modules/shared/lib/navigation";
|
||||||
|
import { getWorkspaceHref } from "@/modules/shared/lib/workspace-routing";
|
||||||
|
|
||||||
type AppShellProps = {
|
type AppShellProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -24,11 +25,14 @@ export function AppShell({
|
|||||||
organizations,
|
organizations,
|
||||||
permissions,
|
permissions,
|
||||||
}: AppShellProps): React.ReactElement {
|
}: AppShellProps): React.ReactElement {
|
||||||
|
const organizationSlug = organization?.slug;
|
||||||
|
const dashboardHref = getWorkspaceHref(organizationSlug, "/dashboard");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-svh items-stretch overflow-hidden bg-background text-foreground">
|
<div className="flex h-svh items-stretch overflow-hidden bg-background text-foreground">
|
||||||
<aside className="hidden h-svh 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 h-16 shrink-0 items-center gap-3 border-b px-5">
|
<Link href={dashboardHref} 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>
|
||||||
@@ -37,7 +41,7 @@ export function AppShell({
|
|||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<AppSidebarNav permissions={permissions} />
|
<AppSidebarNav organizationSlug={organizationSlug} permissions={permissions} />
|
||||||
|
|
||||||
<div className="border-t p-3">
|
<div className="border-t p-3">
|
||||||
<AccountMenu
|
<AccountMenu
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ import {
|
|||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
import type { Permission } from "@/modules/core/types";
|
import type { Permission } from "@/modules/core/types";
|
||||||
import { navGroups, type NavIconKey } from "@/modules/shared/lib/navigation";
|
import { navGroups, type NavIconKey, type NavItem } from "@/modules/shared/lib/navigation";
|
||||||
|
import { getWorkspaceHref, isWorkspacePathActive } from "@/modules/shared/lib/workspace-routing";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const iconMap: Record<NavIconKey, LucideIcon> = {
|
const iconMap: Record<NavIconKey, LucideIcon> = {
|
||||||
@@ -42,23 +43,28 @@ const iconMap: Record<NavIconKey, LucideIcon> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type AppSidebarNavProps = {
|
type AppSidebarNavProps = {
|
||||||
|
organizationSlug?: string;
|
||||||
permissions: Permission[];
|
permissions: Permission[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function isActivePath(pathname: string, href: string): boolean {
|
function canViewNavItem(item: NavItem, permissions: readonly Permission[]): boolean {
|
||||||
if (href === "/app/dashboard") {
|
if (item.permission && !permissions.includes(item.permission)) {
|
||||||
return pathname === "/app" || pathname === href;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return pathname === href || pathname.startsWith(`${href}/`);
|
if (item.anyPermissions && !item.anyPermissions.some((permission) => permissions.includes(permission))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppSidebarNav({ permissions }: AppSidebarNavProps): React.ReactElement {
|
export function AppSidebarNav({ organizationSlug, permissions }: AppSidebarNavProps): React.ReactElement {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const visibleNavGroups = navGroups
|
const visibleNavGroups = navGroups
|
||||||
.map((group) => ({
|
.map((group) => ({
|
||||||
...group,
|
...group,
|
||||||
items: group.items.filter((item) => !item.permission || permissions.includes(item.permission)),
|
items: group.items.filter((item) => canViewNavItem(item, permissions)),
|
||||||
}))
|
}))
|
||||||
.filter((group) => group.items.length > 0);
|
.filter((group) => group.items.length > 0);
|
||||||
|
|
||||||
@@ -71,12 +77,13 @@ export function AppSidebarNav({ permissions }: AppSidebarNavProps): React.ReactE
|
|||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
{group.items.map((item) => {
|
{group.items.map((item) => {
|
||||||
const Icon = iconMap[item.icon];
|
const Icon = iconMap[item.icon];
|
||||||
const isActive = isActivePath(pathname, item.href);
|
const href = getWorkspaceHref(organizationSlug, item.path);
|
||||||
|
const isActive = isWorkspacePathActive(pathname, item.path);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={item.href}>
|
<li key={item.path}>
|
||||||
<Link
|
<Link
|
||||||
href={item.href}
|
href={href}
|
||||||
aria-current={isActive ? "page" : undefined}
|
aria-current={isActive ? "page" : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group flex min-h-11 items-center gap-3 rounded-md px-3 text-sm transition-colors",
|
"group flex min-h-11 items-center gap-3 rounded-md px-3 text-sm transition-colors",
|
||||||
|
|||||||
@@ -17,10 +17,11 @@ export type NavIconKey =
|
|||||||
| "users";
|
| "users";
|
||||||
|
|
||||||
export type NavItem = {
|
export type NavItem = {
|
||||||
href: string;
|
path: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: NavIconKey;
|
icon: NavIconKey;
|
||||||
permission?: Permission;
|
permission?: Permission;
|
||||||
|
anyPermissions?: Permission[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NavGroup = {
|
export type NavGroup = {
|
||||||
@@ -33,34 +34,40 @@ export const navGroups: NavGroup[] = [
|
|||||||
label: "运营",
|
label: "运营",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
href: "/app/dashboard",
|
path: "/dashboard",
|
||||||
label: "运营看板",
|
label: "运营看板",
|
||||||
icon: "dashboard",
|
icon: "dashboard",
|
||||||
|
anyPermissions: ["elder:read", "facility:read", "admission:read", "incident:read"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/app/elders",
|
path: "/elders",
|
||||||
label: "老人档案",
|
label: "老人档案",
|
||||||
icon: "users",
|
icon: "users",
|
||||||
|
permission: "elder:read",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/app/beds",
|
path: "/beds",
|
||||||
label: "床位房间",
|
label: "床位房间",
|
||||||
icon: "beds",
|
icon: "beds",
|
||||||
|
anyPermissions: ["facility:read", "admission:read"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/app/health",
|
path: "/health",
|
||||||
label: "健康照护",
|
label: "健康照护",
|
||||||
icon: "health",
|
icon: "health",
|
||||||
|
permission: "elder:read",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/app/care",
|
path: "/care",
|
||||||
label: "护理服务",
|
label: "护理服务",
|
||||||
icon: "care",
|
icon: "care",
|
||||||
|
permission: "elder:update",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/app/emergency",
|
path: "/emergency",
|
||||||
label: "安全应急",
|
label: "安全应急",
|
||||||
icon: "emergency",
|
icon: "emergency",
|
||||||
|
permission: "incident:read",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -68,62 +75,64 @@ export const navGroups: NavGroup[] = [
|
|||||||
label: "协同",
|
label: "协同",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
href: "/app/devices",
|
path: "/devices",
|
||||||
label: "设备运维",
|
label: "设备运维",
|
||||||
icon: "devices",
|
icon: "devices",
|
||||||
|
permission: "facility:read",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/app/notices",
|
path: "/notices",
|
||||||
label: "公告通知",
|
label: "公告通知",
|
||||||
icon: "notices",
|
icon: "notices",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/app/alerts",
|
path: "/alerts",
|
||||||
label: "规则预警",
|
label: "规则预警",
|
||||||
icon: "alerts",
|
icon: "alerts",
|
||||||
|
permission: "incident:read",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/app/family",
|
path: "/family",
|
||||||
label: "家属服务",
|
label: "家属服务",
|
||||||
icon: "devices",
|
icon: "devices",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "系统设置",
|
label: "管理系统",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
href: "/app/settings/global",
|
path: "/settings/global",
|
||||||
label: "全局配置",
|
label: "全局配置",
|
||||||
icon: "global",
|
icon: "global",
|
||||||
permission: "platform:manage",
|
permission: "platform:manage",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/app/settings/organizations",
|
path: "/settings/organizations",
|
||||||
label: "机构管理",
|
label: "机构管理",
|
||||||
icon: "organizations",
|
icon: "organizations",
|
||||||
permission: "organization:read",
|
permission: "organization:read",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/app/settings/users",
|
path: "/settings/users",
|
||||||
label: "用户管理",
|
label: "用户管理",
|
||||||
icon: "users",
|
icon: "users",
|
||||||
permission: "account:read",
|
permission: "account:read",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/app/settings/roles",
|
path: "/settings/roles",
|
||||||
label: "角色权限",
|
label: "角色权限",
|
||||||
icon: "roles",
|
icon: "roles",
|
||||||
permission: "role:read",
|
permission: "role:read",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/app/settings/audit",
|
path: "/settings/audit",
|
||||||
label: "审计日志",
|
label: "审计日志",
|
||||||
icon: "audit",
|
icon: "audit",
|
||||||
permission: "audit:read",
|
permission: "audit:read",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/app/settings/status",
|
path: "/settings/status",
|
||||||
label: "运行状态",
|
label: "运行状态",
|
||||||
icon: "status",
|
icon: "status",
|
||||||
permission: "incident:read",
|
permission: "incident:read",
|
||||||
|
|||||||
92
modules/shared/lib/workspace-routing.ts
Normal file
92
modules/shared/lib/workspace-routing.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
export const APP_ROUTE_PREFIX = "/app";
|
||||||
|
|
||||||
|
export const WORKSPACE_SECTION_KEYS = [
|
||||||
|
"dashboard",
|
||||||
|
"elders",
|
||||||
|
"beds",
|
||||||
|
"health",
|
||||||
|
"care",
|
||||||
|
"emergency",
|
||||||
|
"devices",
|
||||||
|
"notices",
|
||||||
|
"alerts",
|
||||||
|
"family",
|
||||||
|
"settings",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const RESERVED_WORKSPACE_SLUGS = new Set<string>(WORKSPACE_SECTION_KEYS);
|
||||||
|
|
||||||
|
function splitPath(pathname: string): string[] {
|
||||||
|
const pathOnly = pathname.split(/[?#]/, 1)[0] ?? "";
|
||||||
|
return pathOnly.split("/").filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeWorkspacePath(path: string): string {
|
||||||
|
const trimmed = path.trim();
|
||||||
|
if (!trimmed || trimmed === "/" || trimmed === APP_ROUTE_PREFIX) {
|
||||||
|
return "/dashboard";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.startsWith(`${APP_ROUTE_PREFIX}/`) || trimmed === APP_ROUTE_PREFIX) {
|
||||||
|
return getWorkspacePathFromPathname(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||||
|
return withLeadingSlash.replace(/\/{2,}/g, "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWorkspacePathFromPathname(pathname: string): string {
|
||||||
|
const segments = splitPath(pathname);
|
||||||
|
if (segments[0] !== "app") {
|
||||||
|
return "/dashboard";
|
||||||
|
}
|
||||||
|
|
||||||
|
const appSegments = segments.slice(1);
|
||||||
|
if (appSegments.length === 0) {
|
||||||
|
return "/dashboard";
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstSegment = appSegments[0] ?? "";
|
||||||
|
if (RESERVED_WORKSPACE_SLUGS.has(firstSegment)) {
|
||||||
|
return `/${appSegments.join("/")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopedSegments = appSegments.slice(1);
|
||||||
|
return scopedSegments.length > 0 ? `/${scopedSegments.join("/")}` : "/dashboard";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWorkspaceSlugFromPathname(pathname: string): string | undefined {
|
||||||
|
const segments = splitPath(pathname);
|
||||||
|
if (segments[0] !== "app") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = segments[1];
|
||||||
|
if (!candidate || RESERVED_WORKSPACE_SLUGS.has(candidate)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWorkspaceHref(organizationSlug: string | undefined, path = "/dashboard"): string {
|
||||||
|
const workspacePath = normalizeWorkspacePath(path);
|
||||||
|
const slug = organizationSlug?.trim();
|
||||||
|
|
||||||
|
if (!slug) {
|
||||||
|
return `${APP_ROUTE_PREFIX}${workspacePath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${APP_ROUTE_PREFIX}/${encodeURIComponent(slug)}${workspacePath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWorkspacePathActive(pathname: string, itemPath: string): boolean {
|
||||||
|
const currentPath = getWorkspacePathFromPathname(pathname);
|
||||||
|
const normalizedItemPath = normalizeWorkspacePath(itemPath);
|
||||||
|
|
||||||
|
if (normalizedItemPath === "/dashboard") {
|
||||||
|
return currentPath === "/dashboard";
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentPath === normalizedItemPath || currentPath.startsWith(`${normalizedItemPath}/`);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user