@percepta/create 3.3.0 → 3.4.1

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.
Files changed (34) hide show
  1. package/README.md +3 -3
  2. package/package.json +2 -2
  3. package/templates/monorepo/README.md +5 -3
  4. package/templates/monorepo/access/dev-grants.yaml.example +3 -8
  5. package/templates/monorepo/auth/package.json +2 -1
  6. package/templates/monorepo/auth/src/principals.ts +11 -0
  7. package/templates/monorepo/package.json.template +1 -1
  8. package/templates/webapp/AGENTS.md +6 -7
  9. package/templates/webapp/README.md +31 -3
  10. package/templates/webapp/agent-skills/access-control.md +15 -22
  11. package/templates/webapp/e2e/rbac.spec.ts +136 -0
  12. package/templates/webapp/eslint.config.mjs +41 -7
  13. package/templates/webapp/gitignore.template +2 -0
  14. package/templates/webapp/package.json.template +8 -1
  15. package/templates/webapp/playwright.config.ts +33 -0
  16. package/templates/webapp/scripts/seed.ts +71 -24
  17. package/templates/webapp/src/access/access.manifest.ts +9 -2
  18. package/templates/webapp/src/access/schema.zed +1 -5
  19. package/templates/webapp/src/app/(admin)/admin/_components/AdminTabs.tsx +33 -0
  20. package/templates/webapp/src/app/(admin)/admin/_lib/accessAdmin.ts +62 -0
  21. package/templates/webapp/src/app/(admin)/admin/page.tsx +598 -0
  22. package/templates/webapp/src/app/(admin)/layout.tsx +43 -0
  23. package/templates/webapp/src/app/(app)/layout.tsx +13 -3
  24. package/templates/webapp/src/app/(auth)/auth/signin/CredentialsSignInForm.tsx +1 -1
  25. package/templates/webapp/src/app/global-error.tsx +1 -2
  26. package/templates/webapp/src/components/Header.tsx +23 -2
  27. package/templates/webapp/src/server/api/routers/access.ts +2 -2
  28. package/templates/webapp/src/services/access/AppAccessControl.ts +23 -0
  29. package/templates/webapp/src/services/inngest/events/AppEvents.ts +2 -2
  30. package/templates/webapp/src/services/inngest/events/payloads/ExampleEventPayload.ts +6 -10
  31. package/templates/webapp/src/app/(app)/admin/_lib/PrincipalRoleTable.tsx +0 -113
  32. package/templates/webapp/src/app/(app)/admin/_lib/accessAdmin.ts +0 -85
  33. package/templates/webapp/src/app/(app)/admin/groups/page.tsx +0 -117
  34. package/templates/webapp/src/app/(app)/admin/users/page.tsx +0 -79
@@ -0,0 +1,598 @@
1
+ import type {
2
+ AccessRoleDefinition,
3
+ ApplicationGrant,
4
+ SubjectRef,
5
+ } from "@percepta/access-control";
6
+ import { groupSubjectRef } from "@percepta/access-control";
7
+ import {
8
+ PrincipalMultiInput,
9
+ type PrincipalOption,
10
+ } from "@percepta/access-control/react";
11
+ import { listPrincipals } from "@__REPO_NAME__/auth/principals";
12
+ import {
13
+ Badge,
14
+ Table,
15
+ TableBody,
16
+ TableCell,
17
+ TableHead,
18
+ TableHeader,
19
+ TableRow,
20
+ } from "@percepta/design";
21
+ import type { Metadata } from "next";
22
+ import { revalidatePath } from "next/cache";
23
+ import { notFound } from "next/navigation";
24
+ import {
25
+ accessManifest,
26
+ accessRoleDefinitions,
27
+ } from "../../../access/access.manifest";
28
+ import {
29
+ getAccessControl,
30
+ getCustomerAccessControl,
31
+ toUserSubject,
32
+ } from "../../../services/access/AppAccessControl";
33
+ import {
34
+ type AccessAppRole,
35
+ type AdminPermissions,
36
+ assignableRoles,
37
+ readAssignableRole,
38
+ requireAnyAdminPermission,
39
+ } from "./_lib/accessAdmin";
40
+ import { AdminTabs, type AdminTab } from "./_components/AdminTabs";
41
+
42
+ export const metadata: Metadata = {
43
+ title: "RBAC - __APP_TITLE__",
44
+ description: "RBAC - __APP_TITLE__",
45
+ };
46
+
47
+ type ApplicationAccessRole = ApplicationGrant["relation"];
48
+
49
+ interface AdminPageProps {
50
+ readonly searchParams?: Promise<{
51
+ readonly tab?: string | string[];
52
+ }>;
53
+ }
54
+
55
+ interface RoleDefinition extends AccessRoleDefinition {
56
+ readonly source: "Application access" | "App RBAC";
57
+ }
58
+
59
+ interface PrincipalAssignmentOption extends PrincipalOption {
60
+ readonly effectiveAccess: boolean;
61
+ readonly subject: SubjectRef;
62
+ }
63
+
64
+ interface RbacRoleAssignmentRow {
65
+ readonly definition: RoleDefinition;
66
+ readonly disabled: boolean;
67
+ readonly disabledReason?: string;
68
+ readonly hiddenFields?: readonly {
69
+ readonly name: string;
70
+ readonly value: string;
71
+ }[];
72
+ readonly options: readonly PrincipalAssignmentOption[];
73
+ readonly selectedSubjects: readonly SubjectRef[];
74
+ readonly updateAction: (formData: FormData) => Promise<void>;
75
+ }
76
+
77
+ interface PrincipalAccessRow {
78
+ readonly detail: string;
79
+ readonly displayName: string;
80
+ readonly effectiveAccess: boolean;
81
+ readonly isAdmin: boolean;
82
+ readonly isUser: boolean;
83
+ readonly principalType: "Group" | "User";
84
+ readonly roles: readonly AccessAppRole[];
85
+ readonly subject: SubjectRef;
86
+ }
87
+
88
+ const roleDefinitions = [
89
+ {
90
+ description: "Can sign in to this application.",
91
+ label: "User",
92
+ permissions: [
93
+ {
94
+ description: "Enter the application.",
95
+ permission: accessManifest.application.accessPermission,
96
+ },
97
+ ],
98
+ role: "user",
99
+ source: "Application access",
100
+ },
101
+ {
102
+ description: "Can sign in and manage application-specific roles.",
103
+ label: "Admin",
104
+ permissions: [
105
+ {
106
+ description: "Enter the application.",
107
+ permission: accessManifest.application.accessPermission,
108
+ },
109
+ {
110
+ description: "Assign and revoke app-defined roles.",
111
+ permission: accessManifest.application.manageAppRolesPermission,
112
+ },
113
+ ],
114
+ role: "admin",
115
+ source: "Application access",
116
+ },
117
+ ...(
118
+ accessRoleDefinitions as readonly AccessRoleDefinition<AccessAppRole>[]
119
+ ).map(
120
+ (definition): RoleDefinition => ({
121
+ ...definition,
122
+ role: definition.role,
123
+ source: "App RBAC",
124
+ }),
125
+ ),
126
+ ] as const;
127
+
128
+ const appRoleDefinitionMap = new Map<AccessAppRole, RoleDefinition>(
129
+ roleDefinitions
130
+ .filter((definition) => definition.source === "App RBAC")
131
+ .map((definition) => [definition.role as AccessAppRole, definition]),
132
+ );
133
+
134
+ export default async function AdminPage({ searchParams }: AdminPageProps) {
135
+ const permissions = await requireAnyAdminPermission();
136
+ const params = await searchParams;
137
+ const tab = readAdminTab(params?.tab);
138
+
139
+ 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>
156
+ </div>
157
+
158
+ <AdminTabs activeTab={tab} />
159
+
160
+ {tab === "roles" ? (
161
+ <RolesTab />
162
+ ) : (
163
+ <AssignmentsTab permissions={permissions} />
164
+ )}
165
+ </div>
166
+ );
167
+ }
168
+
169
+ function RolesTab() {
170
+ return (
171
+ <div className="rounded-md border border-border">
172
+ <Table>
173
+ <TableHeader>
174
+ <TableRow>
175
+ <TableHead>Role</TableHead>
176
+ <TableHead>Permissions</TableHead>
177
+ </TableRow>
178
+ </TableHeader>
179
+ <TableBody>
180
+ {roleDefinitions.map((role) => (
181
+ <TableRow
182
+ key={`${role.source}:${role.role}`}
183
+ className="align-top"
184
+ >
185
+ <TableCell className="py-4 whitespace-normal">
186
+ <div className="font-medium text-foreground">{role.label}</div>
187
+ <div className="text-muted-foreground">{role.description}</div>
188
+ </TableCell>
189
+ <TableCell className="py-4 whitespace-normal">
190
+ <div className="flex flex-wrap gap-2">
191
+ {role.permissions.map((permission) => (
192
+ <Badge
193
+ key={permission.permission}
194
+ title={permission.description}
195
+ variant="outline"
196
+ >
197
+ {permission.permission}
198
+ </Badge>
199
+ ))}
200
+ </div>
201
+ </TableCell>
202
+ </TableRow>
203
+ ))}
204
+ </TableBody>
205
+ </Table>
206
+ </div>
207
+ );
208
+ }
209
+
210
+ async function AssignmentsTab({
211
+ permissions,
212
+ }: {
213
+ readonly permissions: AdminPermissions;
214
+ }) {
215
+ const rows = await listRbacRoleAssignmentRows(permissions);
216
+
217
+ return (
218
+ <div className="rounded-md border border-border">
219
+ <Table>
220
+ <TableHeader>
221
+ <TableRow>
222
+ <TableHead className="w-72">Role</TableHead>
223
+ <TableHead>Users and Groups</TableHead>
224
+ </TableRow>
225
+ </TableHeader>
226
+ <TableBody>
227
+ {rows.map((row) => (
228
+ <TableRow
229
+ key={`${row.definition.source}:${row.definition.role}`}
230
+ className="align-top"
231
+ >
232
+ <TableCell className="py-4 whitespace-normal">
233
+ <div className="font-medium text-foreground">
234
+ {row.definition.label}
235
+ </div>
236
+ <div className="text-muted-foreground">
237
+ {row.definition.description}
238
+ </div>
239
+ </TableCell>
240
+ <TableCell className="py-4 whitespace-normal">
241
+ <PrincipalMultiInput
242
+ action={row.updateAction}
243
+ ariaLabel={`${row.definition.label} assignments`}
244
+ disabled={row.disabled}
245
+ hiddenFields={row.hiddenFields}
246
+ key={row.selectedSubjects.join("|")}
247
+ options={row.options}
248
+ selectedSubjects={row.selectedSubjects}
249
+ />
250
+ {row.disabledReason == null ? null : (
251
+ <div className="mt-2 text-xs text-muted-foreground">
252
+ {row.disabledReason}
253
+ </div>
254
+ )}
255
+ </TableCell>
256
+ </TableRow>
257
+ ))}
258
+ </TableBody>
259
+ </Table>
260
+ </div>
261
+ );
262
+ }
263
+
264
+ async function updateUserAssignments(formData: FormData) {
265
+ "use server";
266
+
267
+ await updateApplicationAccessAssignments(formData, "user");
268
+ }
269
+
270
+ async function updateAdminAssignments(formData: FormData) {
271
+ "use server";
272
+
273
+ await updateApplicationAccessAssignments(formData, "admin");
274
+ }
275
+
276
+ async function updateAssignableRoleAssignments(formData: FormData) {
277
+ "use server";
278
+
279
+ const permissions = await requireAnyAdminPermission();
280
+ const role = readAssignableRole(formData);
281
+
282
+ if (!canEditAppRole(role, permissions)) {
283
+ notFound();
284
+ }
285
+
286
+ const principals = await listPrincipalAccessRows(permissions);
287
+ const selectedSubjects = readPrincipalSubjects(formData);
288
+ validateSelectedPrincipals(selectedSubjects, principals, {
289
+ requireEffectiveAccess: true,
290
+ });
291
+
292
+ const currentSubjects = principals
293
+ .filter((principal) => principal.roles.includes(role))
294
+ .map((principal) => principal.subject);
295
+
296
+ await getAccessControl().app.setAppRoleSubjects(
297
+ role,
298
+ selectedSubjects,
299
+ permissions.canManageDefaultRoles ? undefined : { currentSubjects },
300
+ );
301
+
302
+ revalidatePath("/admin");
303
+ }
304
+
305
+ async function updateApplicationAccessAssignments(
306
+ formData: FormData,
307
+ relation: ApplicationGrant["relation"],
308
+ ) {
309
+ const permissions = await requireAnyAdminPermission();
310
+ if (!permissions.canManageDefaultRoles) {
311
+ notFound();
312
+ }
313
+
314
+ const principals = await listPrincipalAccessRows(permissions);
315
+ const selectedSubjects = readPrincipalSubjects(formData);
316
+ validateSelectedPrincipals(selectedSubjects, principals, {
317
+ requireEffectiveAccess: false,
318
+ });
319
+
320
+ const customerAccess = getCustomerAccessControl();
321
+ if (relation === "user") {
322
+ await customerAccess.setApplicationUsers(
323
+ accessManifest.appNamespace,
324
+ selectedSubjects,
325
+ );
326
+ } else {
327
+ await customerAccess.setApplicationAdmins(
328
+ accessManifest.appNamespace,
329
+ selectedSubjects,
330
+ );
331
+ }
332
+
333
+ revalidatePath("/admin");
334
+ }
335
+
336
+ async function listRbacRoleAssignmentRows(
337
+ permissions: AdminPermissions,
338
+ ): Promise<readonly RbacRoleAssignmentRow[]> {
339
+ const principals = await listPrincipalAccessRows(permissions);
340
+ const principalOptions = principals.map(toPrincipalOption);
341
+
342
+ const applicationRows: readonly RbacRoleAssignmentRow[] = [
343
+ {
344
+ definition: getApplicationAccessRoleDefinition("user"),
345
+ disabled: !permissions.canManageDefaultRoles,
346
+ disabledReason: permissions.canManageDefaultRoles
347
+ ? undefined
348
+ : "Only customer admins can edit default application roles.",
349
+ options: principalOptions,
350
+ selectedSubjects: sortSubjectsByPrincipal(
351
+ principals
352
+ .filter((principal) => principal.isUser)
353
+ .map((principal) => principal.subject),
354
+ principals,
355
+ ),
356
+ updateAction: updateUserAssignments,
357
+ },
358
+ {
359
+ definition: getApplicationAccessRoleDefinition("admin"),
360
+ disabled: !permissions.canManageDefaultRoles,
361
+ disabledReason: permissions.canManageDefaultRoles
362
+ ? undefined
363
+ : "Only customer admins can edit default application roles.",
364
+ options: principalOptions,
365
+ selectedSubjects: sortSubjectsByPrincipal(
366
+ principals
367
+ .filter((principal) => principal.isAdmin)
368
+ .map((principal) => principal.subject),
369
+ principals,
370
+ ),
371
+ updateAction: updateAdminAssignments,
372
+ },
373
+ ];
374
+
375
+ const appRoleRows = assignableRoles.map((role): RbacRoleAssignmentRow => {
376
+ const canEditRole = canEditAppRole(role, permissions);
377
+ const selectedSubjects = sortSubjectsByPrincipal(
378
+ principals
379
+ .filter((principal) => principal.roles.includes(role))
380
+ .map((principal) => principal.subject),
381
+ principals,
382
+ );
383
+
384
+ return {
385
+ definition: getAppRoleDefinition(role),
386
+ disabled: !canEditRole,
387
+ disabledReason: canEditRole
388
+ ? undefined
389
+ : "You do not have permission to edit this role.",
390
+ hiddenFields: [{ name: "role", value: role }],
391
+ options: principalOptions.map((option) =>
392
+ option.effectiveAccess
393
+ ? option
394
+ : {
395
+ ...option,
396
+ disabled: true,
397
+ disabledReason:
398
+ "Grant application access before assigning app roles.",
399
+ },
400
+ ),
401
+ selectedSubjects,
402
+ updateAction: updateAssignableRoleAssignments,
403
+ };
404
+ });
405
+
406
+ return [...applicationRows, ...appRoleRows];
407
+ }
408
+
409
+ async function listPrincipalAccessRows(
410
+ permissions: AdminPermissions,
411
+ ): Promise<readonly PrincipalAccessRow[]> {
412
+ const [principals, grants, roleSubjects] = await Promise.all([
413
+ listPrincipals(),
414
+ getCustomerAccessControl().listApplicationGrants(
415
+ accessManifest.appNamespace,
416
+ ),
417
+ listAssignableRoleSubjects(),
418
+ ]);
419
+
420
+ const grantMap = buildGrantMap(grants);
421
+ const access = getAccessControl();
422
+ const application = accessManifest.application.ref();
423
+
424
+ const rows = await Promise.all(
425
+ principals.map(async (principal) => {
426
+ const subject =
427
+ principal.type === "User"
428
+ ? toUserSubject(principal.id)
429
+ : groupSubjectRef(principal.id);
430
+ const grantState = grantMap.get(subject);
431
+ const effectiveAccess = await access.permissions.can({
432
+ permission: accessManifest.application.accessPermission,
433
+ resource: application,
434
+ subject,
435
+ });
436
+
437
+ return {
438
+ detail: principal.detail,
439
+ displayName: principal.displayName,
440
+ effectiveAccess,
441
+ isAdmin: grantState?.has("admin") === true,
442
+ isUser: grantState?.has("user") === true,
443
+ principalType: principal.type,
444
+ roles: assignableRoles.filter(
445
+ (role) => roleSubjects.get(role)?.has(subject) === true,
446
+ ),
447
+ subject,
448
+ };
449
+ }),
450
+ );
451
+
452
+ return permissions.canManageDefaultRoles
453
+ ? rows
454
+ : rows.filter((row) => row.effectiveAccess);
455
+ }
456
+
457
+ async function listAssignableRoleSubjects(): Promise<
458
+ ReadonlyMap<AccessAppRole, ReadonlySet<SubjectRef>>
459
+ > {
460
+ const app = getAccessControl().app;
461
+ const entries = await Promise.all(
462
+ assignableRoles.map(async (role) => [
463
+ role,
464
+ new Set(await app.listAppRoleSubjects(role)),
465
+ ] as const),
466
+ );
467
+
468
+ return new Map(entries);
469
+ }
470
+
471
+ function buildGrantMap(
472
+ grants: readonly ApplicationGrant[],
473
+ ): Map<SubjectRef, Set<ApplicationGrant["relation"]>> {
474
+ const grantMap = new Map<SubjectRef, Set<ApplicationGrant["relation"]>>();
475
+
476
+ for (const grant of grants) {
477
+ const subjectGrants = grantMap.get(grant.subject) ?? new Set();
478
+ subjectGrants.add(grant.relation);
479
+ grantMap.set(grant.subject, subjectGrants);
480
+ }
481
+
482
+ return grantMap;
483
+ }
484
+
485
+ function canEditAppRole(
486
+ role: AccessAppRole,
487
+ permissions: AdminPermissions,
488
+ ): boolean {
489
+ return (
490
+ appRoleDefinitionMap.has(role) === true && permissions.canManageAppRoles
491
+ );
492
+ }
493
+
494
+ function getApplicationAccessRoleDefinition(
495
+ role: ApplicationAccessRole,
496
+ ): RoleDefinition {
497
+ const definition = roleDefinitions.find(
498
+ (candidate) =>
499
+ candidate.source === "Application access" && candidate.role === role,
500
+ );
501
+ if (definition == null) {
502
+ throw new Error(`Missing application access role definition: ${role}`);
503
+ }
504
+
505
+ return definition;
506
+ }
507
+
508
+ function getAppRoleDefinition(role: AccessAppRole): RoleDefinition {
509
+ const definition = appRoleDefinitionMap.get(role);
510
+ if (definition == null) {
511
+ throw new Error(`Missing app role definition: ${role}`);
512
+ }
513
+
514
+ return definition;
515
+ }
516
+
517
+ function toPrincipalOption(
518
+ principal: PrincipalAccessRow,
519
+ ): PrincipalAssignmentOption {
520
+ return {
521
+ detail: principal.detail,
522
+ effectiveAccess: principal.effectiveAccess,
523
+ label: principal.displayName,
524
+ subject: principal.subject,
525
+ type: principal.principalType,
526
+ };
527
+ }
528
+
529
+ function readPrincipalSubjects(formData: FormData): readonly SubjectRef[] {
530
+ const subjects: SubjectRef[] = [];
531
+ const seenSubjects = new Set<string>();
532
+
533
+ for (const value of formData.getAll("subjects")) {
534
+ if (typeof value !== "string" || !isPrincipalSubject(value)) {
535
+ throw new Error("Invalid principal subject.");
536
+ }
537
+
538
+ if (!seenSubjects.has(value)) {
539
+ subjects.push(value);
540
+ seenSubjects.add(value);
541
+ }
542
+ }
543
+
544
+ return subjects;
545
+ }
546
+
547
+ function isPrincipalSubject(subject: string): subject is SubjectRef {
548
+ return (
549
+ subject.startsWith("core/user:") ||
550
+ /^core\/group:[^#]+#member$/.test(subject)
551
+ );
552
+ }
553
+
554
+ function validateSelectedPrincipals(
555
+ selectedSubjects: readonly SubjectRef[],
556
+ principals: readonly PrincipalAccessRow[],
557
+ {
558
+ requireEffectiveAccess,
559
+ }: {
560
+ readonly requireEffectiveAccess: boolean;
561
+ },
562
+ ) {
563
+ const principalBySubject = new Map(
564
+ principals.map((principal) => [principal.subject, principal]),
565
+ );
566
+
567
+ for (const subject of selectedSubjects) {
568
+ const principal = principalBySubject.get(subject);
569
+ if (principal == null) {
570
+ throw new Error("Unknown principal subject.");
571
+ }
572
+
573
+ if (requireEffectiveAccess && !principal.effectiveAccess) {
574
+ throw new Error("Principal must have application access.");
575
+ }
576
+ }
577
+ }
578
+
579
+ function sortSubjectsByPrincipal(
580
+ subjects: readonly SubjectRef[],
581
+ principals: readonly PrincipalAccessRow[],
582
+ ): readonly SubjectRef[] {
583
+ const sortKeyBySubject = new Map(
584
+ principals.map((principal) => [
585
+ principal.subject,
586
+ `${principal.principalType}:${principal.displayName}:${principal.detail}`,
587
+ ]),
588
+ );
589
+
590
+ return [...subjects].sort((a, b) =>
591
+ (sortKeyBySubject.get(a) ?? a).localeCompare(sortKeyBySubject.get(b) ?? b),
592
+ );
593
+ }
594
+
595
+ function readAdminTab(tab: string | string[] | undefined): AdminTab {
596
+ const value = Array.isArray(tab) ? tab[0] : tab;
597
+ return value === "assignments" ? "assignments" : "roles";
598
+ }
@@ -0,0 +1,43 @@
1
+ import { notFound, redirect } from "next/navigation";
2
+ import type { ReactNode } from "react";
3
+ import Header from "../../components/Header";
4
+ import { getServerSession } from "../../lib/auth";
5
+ import {
6
+ canManageAppRoles,
7
+ canManageDefaultRoles,
8
+ } from "../../services/access/AppAccessControl";
9
+
10
+ export default async function AdminLayout({
11
+ children,
12
+ }: {
13
+ children: ReactNode;
14
+ }) {
15
+ const session = await getServerSession();
16
+
17
+ if (session?.user == null) {
18
+ redirect("/auth/signin");
19
+ }
20
+
21
+ const [canManageDefaults, canManageRoles] = await Promise.all([
22
+ canManageDefaultRoles(session.user.id),
23
+ canManageAppRoles(session.user.id),
24
+ ]);
25
+ const showAdminLink = canManageDefaults || canManageRoles;
26
+
27
+ if (!showAdminLink) {
28
+ notFound();
29
+ }
30
+
31
+ return (
32
+ <>
33
+ <Header showAdminLink={showAdminLink} />
34
+ <main>
35
+ <div className="py-8">
36
+ <div className="mx-auto max-w-6xl">
37
+ <div className="rounded-lg bg-white p-8">{children}</div>
38
+ </div>
39
+ </div>
40
+ </main>
41
+ </>
42
+ );
43
+ }
@@ -2,7 +2,11 @@ import { notFound, redirect } from "next/navigation";
2
2
  import type { ReactNode } from "react";
3
3
  import Header from "../../components/Header";
4
4
  import { getServerSession } from "../../lib/auth";
5
- import { canAccessApplication } from "../../services/access/AppAccessControl";
5
+ import {
6
+ canAccessApplication,
7
+ canManageAppRoles,
8
+ canManageDefaultRoles,
9
+ } from "../../services/access/AppAccessControl";
6
10
 
7
11
  export default async function AppLayout({ children }: { children: ReactNode }) {
8
12
  const session = await getServerSession();
@@ -11,14 +15,20 @@ export default async function AppLayout({ children }: { children: ReactNode }) {
11
15
  redirect("/auth/signin");
12
16
  }
13
17
 
14
- const canAccessApp = await canAccessApplication(session.user.id);
18
+ const [canAccessApp, canManageDefaults, canManageRoles] = await Promise.all([
19
+ canAccessApplication(session.user.id),
20
+ canManageDefaultRoles(session.user.id),
21
+ canManageAppRoles(session.user.id),
22
+ ]);
23
+ const showAdminLink = canManageDefaults || canManageRoles;
24
+
15
25
  if (!canAccessApp) {
16
26
  notFound();
17
27
  }
18
28
 
19
29
  return (
20
30
  <>
21
- <Header />
31
+ <Header showAdminLink={showAdminLink} />
22
32
  <main>
23
33
  <div className="py-8">
24
34
  <div className="mx-auto max-w-6xl">
@@ -23,7 +23,7 @@ type Credentials = z.infer<typeof CREDENTIALS_SCHEMA>;
23
23
  // credentials. In dev we prefill with the seeded user from `pnpm db:seed` so a
24
24
  // fresh scaffold is one click away from authed.
25
25
  const DEFAULTS: Credentials = IS_DEV
26
- ? { email: "admin@example.com", password: "password" }
26
+ ? { email: "app-admin@example.com", password: "password" }
27
27
  : { email: "", password: "" };
28
28
 
29
29
  export function CredentialsSignInForm() {
@@ -8,9 +8,8 @@ export default function GlobalError({
8
8
  reset: () => void;
9
9
  }) {
10
10
  try {
11
- // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
11
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
12
12
  const { faro } = require("@grafana/faro-web-sdk");
13
- // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
14
13
  faro.api?.pushError(error);
15
14
  } catch {
16
15
  // Faro may not be initialized yet — don't let reporting break the error page