@percepta/create 3.5.1 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,6 +20,7 @@ import {
20
20
  } from "@percepta/design";
21
21
  import type { Metadata } from "next";
22
22
  import { revalidatePath } from "next/cache";
23
+ import Link from "next/link";
23
24
  import { notFound } from "next/navigation";
24
25
  import {
25
26
  accessManifest,
@@ -32,28 +33,34 @@ import {
32
33
  } from "../../../services/access/AppAccessControl";
33
34
  import {
34
35
  type AccessAppRole,
35
- type AdminPermissions,
36
+ type SettingsPermissions,
36
37
  assignableRoles,
37
38
  readAssignableRole,
38
- requireAnyAdminPermission,
39
- } from "./_lib/accessAdmin";
40
- import { AdminTabs, type AdminTab } from "./_components/AdminTabs";
39
+ requireAnySettingsPermission,
40
+ } from "./_lib/accessSettings";
41
+ import {
42
+ AccessControlTabs,
43
+ type AccessControlTab,
44
+ } from "./_components/AccessControlTabs";
41
45
 
42
46
  export const metadata: Metadata = {
43
- title: "RBAC - __APP_TITLE__",
44
- description: "RBAC - __APP_TITLE__",
47
+ title: "Settings - __APP_TITLE__",
48
+ description: "Settings - __APP_TITLE__",
45
49
  };
46
50
 
47
51
  type ApplicationAccessRole = ApplicationGrant["relation"];
48
52
 
49
- interface AdminPageProps {
53
+ interface SettingsPageProps {
50
54
  readonly searchParams?: Promise<{
55
+ readonly section?: string | string[];
51
56
  readonly tab?: string | string[];
52
57
  }>;
53
58
  }
54
59
 
60
+ type SettingsSection = "access-control" | "audit-log";
61
+
55
62
  interface RoleDefinition extends AccessRoleDefinition {
56
- readonly source: "Application access" | "App RBAC";
63
+ readonly source: "Application access" | "App roles";
57
64
  }
58
65
 
59
66
  interface PrincipalAssignmentOption extends PrincipalOption {
@@ -61,7 +68,7 @@ interface PrincipalAssignmentOption extends PrincipalOption {
61
68
  readonly subject: SubjectRef;
62
69
  }
63
70
 
64
- interface RbacRoleAssignmentRow {
71
+ interface RoleAssignmentRow {
65
72
  readonly definition: RoleDefinition;
66
73
  readonly disabled: boolean;
67
74
  readonly disabledReason?: string;
@@ -120,48 +127,73 @@ const roleDefinitions = [
120
127
  (definition): RoleDefinition => ({
121
128
  ...definition,
122
129
  role: definition.role,
123
- source: "App RBAC",
130
+ source: "App roles",
124
131
  }),
125
132
  ),
126
133
  ] as const;
127
134
 
128
135
  const appRoleDefinitionMap = new Map<AccessAppRole, RoleDefinition>(
129
136
  roleDefinitions
130
- .filter((definition) => definition.source === "App RBAC")
137
+ .filter((definition) => definition.source === "App roles")
131
138
  .map((definition) => [definition.role as AccessAppRole, definition]),
132
139
  );
133
140
 
134
- export default async function AdminPage({ searchParams }: AdminPageProps) {
135
- const permissions = await requireAnyAdminPermission();
141
+ export default async function SettingsPage({ searchParams }: SettingsPageProps) {
142
+ const permissions = await requireAnySettingsPermission();
136
143
  const params = await searchParams;
137
- const tab = readAdminTab(params?.tab);
144
+ const section = readSettingsSection(params?.section);
145
+ const tab = readAccessControlTab(params?.tab);
138
146
 
139
147
  return (
140
- <div className="space-y-6">
141
- <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
142
- <div>
143
- <h1 className="text-2xl font-semibold text-foreground">RBAC</h1>
144
- <p className="mt-1 text-sm text-muted-foreground">
145
- {accessManifest.application.displayName}
146
- </p>
147
- </div>
148
- <div className="rounded-md border border-border bg-muted px-3 py-2 text-sm">
149
- <div className="font-medium text-foreground">
150
- {accessManifest.application.displayName}
151
- </div>
152
- <div className="text-muted-foreground">
153
- {accessManifest.appNamespace}
154
- </div>
155
- </div>
148
+ <div className="app-settings">
149
+ <aside className="app-settings-sidebar" aria-label="Settings navigation">
150
+ <div className="app-settings-sidebar-title">Settings</div>
151
+ <Link
152
+ className={getSettingsNavClassName(section === "access-control")}
153
+ href="/settings"
154
+ >
155
+ Access control
156
+ </Link>
157
+ <Link
158
+ className={getSettingsNavClassName(section === "audit-log")}
159
+ href="/settings?section=audit-log"
160
+ >
161
+ Audit log
162
+ </Link>
163
+ </aside>
164
+
165
+ <div className="app-settings-content">
166
+ {section === "access-control" ? (
167
+ <section className="app-settings-section">
168
+ <div className="app-settings-header">
169
+ <p className="app-kicker">Settings</p>
170
+ <h1 className="app-title">Access control</h1>
171
+ <p className="app-subtitle">
172
+ {accessManifest.application.displayName}
173
+ </p>
174
+ </div>
175
+
176
+ <AccessControlTabs activeTab={tab} />
177
+
178
+ {tab === "roles" ? (
179
+ <RolesTab />
180
+ ) : (
181
+ <AssignmentsTab permissions={permissions} />
182
+ )}
183
+ </section>
184
+ ) : (
185
+ <section className="app-settings-section app-settings-placeholder">
186
+ <div className="app-settings-header">
187
+ <p className="app-kicker">Settings</p>
188
+ <h1 className="app-title">Audit log</h1>
189
+ <p className="app-subtitle">
190
+ {accessManifest.application.displayName}
191
+ </p>
192
+ </div>
193
+ <p className="app-review-meta">Coming soon</p>
194
+ </section>
195
+ )}
156
196
  </div>
157
-
158
- <AdminTabs activeTab={tab} />
159
-
160
- {tab === "roles" ? (
161
- <RolesTab />
162
- ) : (
163
- <AssignmentsTab permissions={permissions} />
164
- )}
165
197
  </div>
166
198
  );
167
199
  }
@@ -210,9 +242,9 @@ function RolesTab() {
210
242
  async function AssignmentsTab({
211
243
  permissions,
212
244
  }: {
213
- readonly permissions: AdminPermissions;
245
+ readonly permissions: SettingsPermissions;
214
246
  }) {
215
- const rows = await listRbacRoleAssignmentRows(permissions);
247
+ const rows = await listRoleAssignmentRows(permissions);
216
248
 
217
249
  return (
218
250
  <div className="rounded-md border border-border">
@@ -276,7 +308,7 @@ async function updateAdminAssignments(formData: FormData) {
276
308
  async function updateAssignableRoleAssignments(formData: FormData) {
277
309
  "use server";
278
310
 
279
- const permissions = await requireAnyAdminPermission();
311
+ const permissions = await requireAnySettingsPermission();
280
312
  const role = readAssignableRole(formData);
281
313
 
282
314
  if (!canEditAppRole(role, permissions)) {
@@ -299,14 +331,14 @@ async function updateAssignableRoleAssignments(formData: FormData) {
299
331
  permissions.canManageDefaultRoles ? undefined : { currentSubjects },
300
332
  );
301
333
 
302
- revalidatePath("/admin");
334
+ revalidatePath("/settings");
303
335
  }
304
336
 
305
337
  async function updateApplicationAccessAssignments(
306
338
  formData: FormData,
307
339
  relation: ApplicationGrant["relation"],
308
340
  ) {
309
- const permissions = await requireAnyAdminPermission();
341
+ const permissions = await requireAnySettingsPermission();
310
342
  if (!permissions.canManageDefaultRoles) {
311
343
  notFound();
312
344
  }
@@ -330,16 +362,16 @@ async function updateApplicationAccessAssignments(
330
362
  );
331
363
  }
332
364
 
333
- revalidatePath("/admin");
365
+ revalidatePath("/settings");
334
366
  }
335
367
 
336
- async function listRbacRoleAssignmentRows(
337
- permissions: AdminPermissions,
338
- ): Promise<readonly RbacRoleAssignmentRow[]> {
368
+ async function listRoleAssignmentRows(
369
+ permissions: SettingsPermissions,
370
+ ): Promise<readonly RoleAssignmentRow[]> {
339
371
  const principals = await listPrincipalAccessRows(permissions);
340
372
  const principalOptions = principals.map(toPrincipalOption);
341
373
 
342
- const applicationRows: readonly RbacRoleAssignmentRow[] = [
374
+ const applicationRows: readonly RoleAssignmentRow[] = [
343
375
  {
344
376
  definition: getApplicationAccessRoleDefinition("user"),
345
377
  disabled: !permissions.canManageDefaultRoles,
@@ -372,7 +404,7 @@ async function listRbacRoleAssignmentRows(
372
404
  },
373
405
  ];
374
406
 
375
- const appRoleRows = assignableRoles.map((role): RbacRoleAssignmentRow => {
407
+ const appRoleRows = assignableRoles.map((role): RoleAssignmentRow => {
376
408
  const canEditRole = canEditAppRole(role, permissions);
377
409
  const selectedSubjects = sortSubjectsByPrincipal(
378
410
  principals
@@ -407,7 +439,7 @@ async function listRbacRoleAssignmentRows(
407
439
  }
408
440
 
409
441
  async function listPrincipalAccessRows(
410
- permissions: AdminPermissions,
442
+ permissions: SettingsPermissions,
411
443
  ): Promise<readonly PrincipalAccessRow[]> {
412
444
  const [principals, grants, roleSubjects] = await Promise.all([
413
445
  listPrincipals(),
@@ -484,7 +516,7 @@ function buildGrantMap(
484
516
 
485
517
  function canEditAppRole(
486
518
  role: AccessAppRole,
487
- permissions: AdminPermissions,
519
+ permissions: SettingsPermissions,
488
520
  ): boolean {
489
521
  return (
490
522
  appRoleDefinitionMap.has(role) === true && permissions.canManageAppRoles
@@ -592,7 +624,22 @@ function sortSubjectsByPrincipal(
592
624
  );
593
625
  }
594
626
 
595
- function readAdminTab(tab: string | string[] | undefined): AdminTab {
627
+ function readAccessControlTab(
628
+ tab: string | string[] | undefined,
629
+ ): AccessControlTab {
596
630
  const value = Array.isArray(tab) ? tab[0] : tab;
597
631
  return value === "assignments" ? "assignments" : "roles";
598
632
  }
633
+
634
+ function readSettingsSection(
635
+ section: string | string[] | undefined,
636
+ ): SettingsSection {
637
+ const value = Array.isArray(section) ? section[0] : section;
638
+ return value === "audit-log" ? "audit-log" : "access-control";
639
+ }
640
+
641
+ function getSettingsNavClassName(active: boolean): string {
642
+ return active
643
+ ? "app-settings-nav-item app-settings-nav-active"
644
+ : "app-settings-nav-item";
645
+ }
@@ -9,7 +9,7 @@ export default function RootLayout({
9
9
  children: React.ReactNode;
10
10
  }) {
11
11
  return (
12
- <html lang="en">
12
+ <html lang="en" data-pcd-theme="__MOSAIC_DESIGN_THEME__">
13
13
  <body className="antialiased" suppressHydrationWarning={true}>
14
14
  <Providers>{children}</Providers>
15
15
  </body>
@@ -8,18 +8,20 @@ import {
8
8
  DropdownMenuSeparator,
9
9
  DropdownMenuTrigger,
10
10
  } from "@percepta/design";
11
- import { LogOut } from "lucide-react";
11
+ import { LogOut, Search } from "lucide-react";
12
12
  import Link from "next/link";
13
13
  import React, { useCallback } from "react";
14
14
  import { authClient } from "../lib/auth-client";
15
15
 
16
16
  export default function Header({
17
- showAdminLink,
17
+ showSettingsLink,
18
18
  }: {
19
- readonly showAdminLink: boolean;
19
+ readonly showSettingsLink: boolean;
20
20
  }) {
21
21
  const { data: session } = authClient.useSession();
22
22
  const user = session?.user;
23
+ const appTitle = "__APP_TITLE__";
24
+ const appInitial = appTitle.slice(0, 1).toUpperCase();
23
25
 
24
26
  const handleSignOut = useCallback(() => {
25
27
  void authClient.signOut({
@@ -36,36 +38,45 @@ export default function Header({
36
38
  }
37
39
 
38
40
  return (
39
- <header className="sticky top-0 z-30 flex h-16 w-full items-center justify-between border-b border-border bg-card px-4 shadow-sm sm:px-6 lg:px-8">
40
- <nav aria-label="Primary navigation" className="flex items-center gap-2">
41
+ <header className="app-header">
42
+ <nav aria-label="Primary navigation" className="app-header-nav">
41
43
  <Link
42
- className="rounded-md px-3 py-2 text-sm font-medium text-foreground transition-colors hover:bg-muted"
44
+ className="app-header-brand"
43
45
  href="/"
44
46
  >
45
- __APP_TITLE__
47
+ <span className="app-header-mark" aria-hidden={true}>
48
+ {appInitial}
49
+ </span>
50
+ <span>{appTitle}</span>
46
51
  </Link>
47
- {showAdminLink ? (
48
- <Link
49
- className="rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
50
- href="/admin"
51
- >
52
- Admin
52
+ {showSettingsLink ? (
53
+ <Link className="app-nav-link" href="/settings">
54
+ Settings
53
55
  </Link>
54
56
  ) : null}
55
57
  </nav>
58
+ <div className="app-header-search" aria-hidden={true}>
59
+ <div className="flex items-center justify-between gap-3">
60
+ <span className="inline-flex items-center gap-2">
61
+ <Search className="size-4" />
62
+ Search
63
+ </span>
64
+ <span className="text-xs">Cmd K</span>
65
+ </div>
66
+ </div>
56
67
  <DropdownMenu>
57
68
  <DropdownMenuTrigger asChild={true}>
58
69
  <button
59
- className="-mx-2 flex items-center gap-3 rounded-lg px-2 py-1 transition-colors hover:bg-muted"
70
+ className="app-account-button"
60
71
  aria-label="Open account menu"
61
72
  >
62
- <div className="hidden text-right sm:block">
73
+ <div className="app-account-text">
63
74
  <p className="text-sm font-medium text-foreground">
64
75
  {user.name || "User"}
65
76
  </p>
66
77
  <p className="text-xs text-muted-foreground">{user.email}</p>
67
78
  </div>
68
- <div className="flex h-9 w-9 items-center justify-center rounded-full border border-border bg-primary/10 text-sm font-semibold text-primary">
79
+ <div className="app-avatar">
69
80
  {(user.name || user.email || "U").slice(0, 1).toUpperCase()}
70
81
  </div>
71
82
  </button>