@percepta/create 3.3.0 → 3.4.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.
Files changed (30) 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/package.json.template +1 -1
  6. package/templates/webapp/AGENTS.md +6 -7
  7. package/templates/webapp/README.md +5 -3
  8. package/templates/webapp/agent-skills/access-control.md +15 -22
  9. package/templates/webapp/eslint.config.mjs +35 -7
  10. package/templates/webapp/package.json.template +4 -1
  11. package/templates/webapp/scripts/seed.ts +71 -24
  12. package/templates/webapp/src/access/access.manifest.ts +14 -1
  13. package/templates/webapp/src/access/schema.zed +1 -5
  14. package/templates/webapp/src/app/(admin)/admin/_components/AdminTabs.tsx +33 -0
  15. package/templates/webapp/src/app/(admin)/admin/_components/PrincipalMultiInput.tsx +248 -0
  16. package/templates/webapp/src/app/(admin)/admin/_lib/accessAdmin.ts +62 -0
  17. package/templates/webapp/src/app/(admin)/admin/page.tsx +683 -0
  18. package/templates/webapp/src/app/(admin)/layout.tsx +43 -0
  19. package/templates/webapp/src/app/(app)/layout.tsx +13 -3
  20. package/templates/webapp/src/app/(auth)/auth/signin/CredentialsSignInForm.tsx +1 -1
  21. package/templates/webapp/src/app/global-error.tsx +1 -2
  22. package/templates/webapp/src/components/Header.tsx +23 -2
  23. package/templates/webapp/src/server/api/routers/access.ts +2 -2
  24. package/templates/webapp/src/services/access/AppAccessControl.ts +49 -0
  25. package/templates/webapp/src/services/inngest/events/AppEvents.ts +2 -2
  26. package/templates/webapp/src/services/inngest/events/payloads/ExampleEventPayload.ts +6 -10
  27. package/templates/webapp/src/app/(app)/admin/_lib/PrincipalRoleTable.tsx +0 -113
  28. package/templates/webapp/src/app/(app)/admin/_lib/accessAdmin.ts +0 -85
  29. package/templates/webapp/src/app/(app)/admin/groups/page.tsx +0 -117
  30. package/templates/webapp/src/app/(app)/admin/users/page.tsx +0 -79
@@ -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
@@ -9,10 +9,15 @@ import {
9
9
  DropdownMenuTrigger,
10
10
  } from "@percepta/design";
11
11
  import { LogOut } from "lucide-react";
12
+ import Link from "next/link";
12
13
  import React, { useCallback } from "react";
13
14
  import { authClient } from "../lib/auth-client";
14
15
 
15
- export default function Header() {
16
+ export default function Header({
17
+ showAdminLink,
18
+ }: {
19
+ readonly showAdminLink: boolean;
20
+ }) {
16
21
  const { data: session } = authClient.useSession();
17
22
  const user = session?.user;
18
23
 
@@ -31,7 +36,23 @@ export default function Header() {
31
36
  }
32
37
 
33
38
  return (
34
- <header className="sticky top-0 z-30 flex h-16 w-full items-center justify-end border-b border-border bg-card px-4 shadow-sm sm:px-6 lg:px-8">
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
+ <Link
42
+ className="rounded-md px-3 py-2 text-sm font-medium text-foreground transition-colors hover:bg-muted"
43
+ href="/"
44
+ >
45
+ __APP_TITLE__
46
+ </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
53
+ </Link>
54
+ ) : null}
55
+ </nav>
35
56
  <DropdownMenu>
36
57
  <DropdownMenuTrigger asChild={true}>
37
58
  <button
@@ -5,8 +5,8 @@ export const accessRouter = router({
5
5
  roleManagementStatus: protectedProcedure
6
6
  .use(
7
7
  requirePermission({
8
- permission: accessManifest.system.manageRolesPermission,
9
- resource: accessManifest.system.ref(),
8
+ permission: accessManifest.application.manageAppRolesPermission,
9
+ resource: accessManifest.application.ref(),
10
10
  }),
11
11
  )
12
12
  .query(() => ({ canManageRoles: true })),
@@ -1,6 +1,9 @@
1
1
  import {
2
2
  type AppAccessRuntime,
3
+ applicationRef,
3
4
  createAppAccessRuntime,
5
+ createCustomerAccessControl,
6
+ type CustomerAccessControl,
4
7
  } from "@percepta/access-control";
5
8
  import { accessManifest } from "../../access/access.manifest";
6
9
  import { getEnvConfig } from "../../config/getEnvConfig";
@@ -30,10 +33,56 @@ export function canAccessApplication(userId: string): Promise<boolean> {
30
33
  return appAccessRuntime.canAccessApplication(userId);
31
34
  }
32
35
 
36
+ export function canManageAppRoles(userId: string): Promise<boolean> {
37
+ const { permissions } = getAccessControl();
38
+ return permissions.can({
39
+ permission: accessManifest.application.manageAppRolesPermission,
40
+ resource: applicationRef(accessManifest.appNamespace),
41
+ subject: toUserSubject(userId),
42
+ });
43
+ }
44
+
45
+ export function canManageAppRolesStrong(userId: string): Promise<boolean> {
46
+ const { permissions } = getAccessControl();
47
+ return permissions.canStrong({
48
+ permission: accessManifest.application.manageAppRolesPermission,
49
+ resource: applicationRef(accessManifest.appNamespace),
50
+ subject: toUserSubject(userId),
51
+ });
52
+ }
53
+
54
+ export function canManageDefaultRoles(userId: string): Promise<boolean> {
55
+ const { permissions } = getAccessControl();
56
+ return permissions.can({
57
+ permission: accessManifest.application.manageDefaultRolesPermission,
58
+ resource: applicationRef(accessManifest.appNamespace),
59
+ subject: toUserSubject(userId),
60
+ });
61
+ }
62
+
63
+ export function canManageDefaultRolesStrong(
64
+ userId: string,
65
+ ): Promise<boolean> {
66
+ const { permissions } = getAccessControl();
67
+ return permissions.canStrong({
68
+ permission: accessManifest.application.manageDefaultRolesPermission,
69
+ resource: applicationRef(accessManifest.appNamespace),
70
+ subject: toUserSubject(userId),
71
+ });
72
+ }
73
+
33
74
  export function getAccessControl(): AppAccessControl {
34
75
  return appAccessRuntime.getAccessControl();
35
76
  }
36
77
 
78
+ export function getCustomerAccessControl(): CustomerAccessControl {
79
+ const { client } = getAccessControl();
80
+ return createCustomerAccessControl({
81
+ applications: [{ appNamespace: accessManifest.appNamespace }],
82
+ client,
83
+ });
84
+ }
85
+
37
86
  export function toUserSubject(userId: string) {
38
87
  return appAccessRuntime.toUserSubject(userId);
39
88
  }
@@ -1,4 +1,4 @@
1
- import { ExampleEventPayload } from "./payloads/ExampleEventPayload";
1
+ import { exampleEventPayloadSchema } from "./payloads/ExampleEventPayload";
2
2
 
3
3
  /**
4
4
  * Define all Inngest events for the application here.
@@ -24,5 +24,5 @@ import { ExampleEventPayload } from "./payloads/ExampleEventPayload";
24
24
  */
25
25
  export const AppEvents = {
26
26
  // Example event - replace with your actual events
27
- "app/example.event": ExampleEventPayload.SCHEMA,
27
+ "app/example.event": exampleEventPayloadSchema,
28
28
  };
@@ -1,12 +1,8 @@
1
1
  import z from "zod";
2
2
 
3
- /**
4
- * Example event payload schema.
5
- * Replace this with your actual event payloads.
6
- */
7
- export type ExampleEventPayload = z.infer<typeof ExampleEventPayload.SCHEMA>;
8
- export namespace ExampleEventPayload {
9
- export const SCHEMA = z.object({
10
- exampleId: z.string(),
11
- });
12
- }
3
+ /** Example event payload schema. Replace this with your actual event payloads. */
4
+ export const exampleEventPayloadSchema = z.object({
5
+ exampleId: z.string(),
6
+ });
7
+
8
+ export type ExampleEventPayload = z.infer<typeof exampleEventPayloadSchema>;
@@ -1,113 +0,0 @@
1
- import type { SubjectRef } from "@percepta/access-control";
2
- import { ShieldCheck, ShieldMinus } from "lucide-react";
3
- import { type AccessAppRole, assignableRoles } from "./accessAdmin";
4
-
5
- type RoleAction = (formData: FormData) => Promise<void>;
6
-
7
- export interface PrincipalRoleRow {
8
- readonly accessLabel: string;
9
- readonly canAssignRoles?: boolean;
10
- readonly detail: string;
11
- readonly displayName: string;
12
- readonly roles: readonly AccessAppRole[];
13
- readonly subject: SubjectRef;
14
- }
15
-
16
- export function PrincipalRoleTable({
17
- assignAction,
18
- principalColumnLabel,
19
- revokeAction,
20
- rows,
21
- }: {
22
- readonly assignAction: RoleAction;
23
- readonly principalColumnLabel: string;
24
- readonly revokeAction: RoleAction;
25
- readonly rows: readonly PrincipalRoleRow[];
26
- }) {
27
- return (
28
- <div className="overflow-x-auto rounded-md border border-border">
29
- <table className="w-full text-left text-sm">
30
- <thead className="border-b border-border bg-muted/50 text-xs text-muted-foreground uppercase">
31
- <tr>
32
- <th className="px-4 py-3 font-medium">{principalColumnLabel}</th>
33
- <th className="px-4 py-3 font-medium">Application Access</th>
34
- <th className="px-4 py-3 font-medium">Roles</th>
35
- <th className="px-4 py-3 font-medium">Actions</th>
36
- </tr>
37
- </thead>
38
- <tbody className="divide-y divide-border">
39
- {rows.map((principal) => (
40
- <tr key={principal.subject} className="align-top">
41
- <td className="px-4 py-4">
42
- <div className="font-medium text-foreground">
43
- {principal.displayName}
44
- </div>
45
- <div className="text-muted-foreground">{principal.detail}</div>
46
- </td>
47
- <td className="px-4 py-4 text-foreground">
48
- {principal.accessLabel}
49
- </td>
50
- <td className="px-4 py-4">
51
- {principal.roles.length > 0 ? (
52
- <div className="flex flex-wrap gap-2">
53
- {principal.roles.map((role) => (
54
- <span
55
- key={role}
56
- className="rounded-md border border-border bg-muted px-2 py-1 text-xs font-medium text-foreground"
57
- >
58
- {role}
59
- </span>
60
- ))}
61
- </div>
62
- ) : (
63
- <span className="text-muted-foreground">None</span>
64
- )}
65
- </td>
66
- <td className="px-4 py-4">
67
- <div className="flex flex-wrap gap-2">
68
- {assignableRoles.map((role) => {
69
- const hasRole = principal.roles.includes(role);
70
- const action = hasRole ? revokeAction : assignAction;
71
- return (
72
- <form action={action} key={role}>
73
- <input
74
- name="subject"
75
- type="hidden"
76
- value={principal.subject}
77
- />
78
- <input name="role" type="hidden" value={role} />
79
- <button
80
- className={
81
- hasRole
82
- ? "inline-flex h-9 items-center gap-2 rounded-md border border-border px-3 text-sm font-medium text-foreground transition-colors hover:bg-muted"
83
- : "inline-flex h-9 items-center gap-2 rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
84
- }
85
- disabled={
86
- !hasRole && principal.canAssignRoles === false
87
- }
88
- type="submit"
89
- >
90
- {hasRole ? (
91
- <>
92
- <ShieldMinus className="h-4 w-4" />
93
- Revoke {role}
94
- </>
95
- ) : (
96
- <>
97
- <ShieldCheck className="h-4 w-4" />
98
- Assign {role}
99
- </>
100
- )}
101
- </button>
102
- </form>
103
- );
104
- })}
105
- </div>
106
- </td>
107
- </tr>
108
- ))}
109
- </tbody>
110
- </table>
111
- </div>
112
- );
113
- }
@@ -1,85 +0,0 @@
1
- import type { AppRole, SubjectRef } from "@percepta/access-control";
2
- import { revalidatePath } from "next/cache";
3
- import { notFound, redirect } from "next/navigation";
4
- import { accessManifest } from "../../../../access/access.manifest";
5
- import { getServerSession } from "../../../../lib/auth";
6
- import {
7
- getAccessControl,
8
- toUserSubject,
9
- } from "../../../../services/access/AppAccessControl";
10
-
11
- export type AccessAppRole = AppRole<typeof accessManifest>;
12
-
13
- export const assignableRoles: readonly AccessAppRole[] =
14
- accessManifest.system.assignableRoles ?? [];
15
-
16
- export async function updatePrincipalRole(
17
- formData: FormData,
18
- operation: "assign" | "revoke",
19
- path: string,
20
- ) {
21
- await requireRoleManager();
22
- const app = getAccessControl().app;
23
- const role = readAssignableRole(formData);
24
- const subject = readPrincipalSubject(formData);
25
-
26
- if (operation === "assign") {
27
- await app.assignAppRole(role, subject);
28
- } else {
29
- await app.revokeAppRole(role, subject);
30
- }
31
-
32
- revalidatePath(path);
33
- }
34
-
35
- export async function requireRoleManager() {
36
- if (!isAdminUiEnabled()) {
37
- notFound();
38
- }
39
-
40
- const session = await getServerSession();
41
-
42
- if (session?.user == null) {
43
- redirect("/auth/signin");
44
- }
45
-
46
- const allowed = await getAccessControl().permissions.canStrong({
47
- permission: accessManifest.system.manageRolesPermission,
48
- resource: accessManifest.system.ref(),
49
- subject: toUserSubject(session.user.id),
50
- });
51
- if (!allowed) {
52
- notFound();
53
- }
54
- }
55
-
56
- export function readAssignableRole(formData: FormData): AccessAppRole {
57
- const role = formData.get("role");
58
- if (
59
- typeof role !== "string" ||
60
- !assignableRoles.includes(role as AccessAppRole)
61
- ) {
62
- throw new Error("Invalid app role.");
63
- }
64
-
65
- return role as AccessAppRole;
66
- }
67
-
68
- function readPrincipalSubject(formData: FormData): SubjectRef {
69
- const subject = formData.get("subject");
70
- if (
71
- typeof subject !== "string" ||
72
- (!subject.startsWith("core/user:") &&
73
- !/^core\/group:[^#]+#member$/.test(subject))
74
- ) {
75
- throw new Error("Invalid principal subject.");
76
- }
77
-
78
- return subject as SubjectRef;
79
- }
80
-
81
- function isAdminUiEnabled(): boolean {
82
- const adminUI: { readonly enabled?: boolean } | undefined =
83
- accessManifest.adminUI;
84
- return adminUI?.enabled !== false;
85
- }
@@ -1,117 +0,0 @@
1
- import type { SubjectRef } from "@percepta/access-control";
2
- import { db as authDb } from "@__REPO_NAME__/auth/db";
3
- import { groups } from "@__REPO_NAME__/auth/schema";
4
- import { inArray } from "drizzle-orm";
5
- import type { Metadata } from "next";
6
- import { accessManifest } from "../../../../access/access.manifest";
7
- import { getAccessControl } from "../../../../services/access/AppAccessControl";
8
- import {
9
- type PrincipalRoleRow,
10
- PrincipalRoleTable,
11
- } from "../_lib/PrincipalRoleTable";
12
- import { requireRoleManager, updatePrincipalRole } from "../_lib/accessAdmin";
13
-
14
- export const metadata: Metadata = {
15
- title: "Groups - __APP_TITLE__",
16
- description: "Groups - __APP_TITLE__",
17
- };
18
-
19
- const GROUP_SUBJECT_PATTERN = /^core\/group:([^#]+)#member$/;
20
-
21
- export default async function AdminGroupsPage() {
22
- await requireRoleManager();
23
-
24
- const rows = await listApplicationGroups();
25
-
26
- return (
27
- <div className="space-y-6">
28
- <div>
29
- <h1 className="text-2xl font-semibold text-foreground">Groups</h1>
30
- </div>
31
-
32
- {rows.length === 0 ? (
33
- <div className="rounded-md border border-border bg-card p-8 text-sm text-muted-foreground">
34
- <p className="font-medium text-foreground">No groups enrolled.</p>
35
- <p className="mt-2">
36
- Groups appear here after the customer identity sync projects them
37
- into SpiceDB and grants this application access.
38
- </p>
39
- </div>
40
- ) : (
41
- <PrincipalRoleTable
42
- assignAction={assignGroupRole}
43
- principalColumnLabel="Group"
44
- revokeAction={revokeGroupRole}
45
- rows={rows}
46
- />
47
- )}
48
- </div>
49
- );
50
- }
51
-
52
- async function assignGroupRole(formData: FormData) {
53
- "use server";
54
-
55
- await updatePrincipalRole(formData, "assign", "/admin/groups");
56
- }
57
-
58
- async function revokeGroupRole(formData: FormData) {
59
- "use server";
60
-
61
- await updatePrincipalRole(formData, "revoke", "/admin/groups");
62
- }
63
-
64
- async function listApplicationGroups(): Promise<readonly PrincipalRoleRow[]> {
65
- const access = getAccessControl();
66
- const subjects = await access.permissions.lookupSubjects({
67
- permission: accessManifest.system.accessPermission,
68
- resource: accessManifest.system.ref(),
69
- subjectRelation: "member",
70
- subjectType: "core/group",
71
- });
72
-
73
- if (subjects.length === 0) {
74
- return [];
75
- }
76
-
77
- const groupRefs = subjects.map((subject) => ({
78
- id: groupIdFromSubject(subject),
79
- subject,
80
- }));
81
- const groupNames = await listGroupNames(groupRefs.map(({ id }) => id));
82
-
83
- const rows = await Promise.all(
84
- groupRefs.map(async ({ id, subject }) => ({
85
- accessLabel: "Allowed",
86
- detail: subject,
87
- displayName: groupNames.get(id) ?? id,
88
- roles: await access.app.listAppRoles(subject),
89
- subject,
90
- })),
91
- );
92
-
93
- return rows.sort((a, b) => a.displayName.localeCompare(b.displayName));
94
- }
95
-
96
- async function listGroupNames(
97
- groupIds: readonly string[],
98
- ): Promise<ReadonlyMap<string, string>> {
99
- const groupRows = await authDb
100
- .select({
101
- id: groups.id,
102
- name: groups.name,
103
- })
104
- .from(groups)
105
- .where(inArray(groups.id, [...new Set(groupIds)]));
106
-
107
- return new Map(groupRows.map((group) => [group.id, group.name]));
108
- }
109
-
110
- function groupIdFromSubject(subject: SubjectRef): string {
111
- const id = GROUP_SUBJECT_PATTERN.exec(subject)?.[1];
112
- if (id == null) {
113
- throw new Error("Invalid group subject.");
114
- }
115
-
116
- return id;
117
- }
@@ -1,79 +0,0 @@
1
- import { db as authDb } from "@__REPO_NAME__/auth/db";
2
- import { users } from "@__REPO_NAME__/auth/schema";
3
- import type { Metadata } from "next";
4
- import { accessManifest } from "../../../../access/access.manifest";
5
- import {
6
- getAccessControl,
7
- toUserSubject,
8
- } from "../../../../services/access/AppAccessControl";
9
- import { PrincipalRoleTable } from "../_lib/PrincipalRoleTable";
10
- import { requireRoleManager, updatePrincipalRole } from "../_lib/accessAdmin";
11
-
12
- export const metadata: Metadata = {
13
- title: "Users - __APP_TITLE__",
14
- description: "Users - __APP_TITLE__",
15
- };
16
-
17
- export default async function AdminUsersPage() {
18
- await requireRoleManager();
19
-
20
- const access = getAccessControl();
21
- const userRows = await authDb
22
- .select({
23
- email: users.email,
24
- id: users.id,
25
- name: users.name,
26
- })
27
- .from(users)
28
- .orderBy(users.email);
29
-
30
- const rows = await Promise.all(
31
- userRows.map(async (user) => {
32
- const subject = toUserSubject(user.id);
33
- const [canAccessApp, roles] = await Promise.all([
34
- access.permissions.can({
35
- permission: accessManifest.system.accessPermission,
36
- resource: accessManifest.system.ref(),
37
- subject,
38
- }),
39
- access.app.listAppRoles(subject),
40
- ]);
41
-
42
- return {
43
- accessLabel: canAccessApp ? "Allowed" : "Not enrolled",
44
- canAssignRoles: canAccessApp,
45
- detail: user.email,
46
- displayName: user.name,
47
- roles,
48
- subject,
49
- };
50
- }),
51
- );
52
-
53
- return (
54
- <div className="space-y-6">
55
- <div>
56
- <h1 className="text-2xl font-semibold text-foreground">Users</h1>
57
- </div>
58
-
59
- <PrincipalRoleTable
60
- assignAction={assignUserRole}
61
- principalColumnLabel="User"
62
- revokeAction={revokeUserRole}
63
- rows={rows}
64
- />
65
- </div>
66
- );
67
- }
68
-
69
- async function assignUserRole(formData: FormData) {
70
- "use server";
71
-
72
- await updatePrincipalRole(formData, "assign", "/admin/users");
73
- }
74
-
75
- async function revokeUserRole(formData: FormData) {
76
- "use server";
77
-
78
- await updatePrincipalRole(formData, "revoke", "/admin/users");
79
- }