@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
@@ -8,21 +8,36 @@
8
8
  */
9
9
 
10
10
  import { AsyncLocalStorage } from "node:async_hooks";
11
+ import { execFileSync } from "node:child_process";
11
12
  import { loadEnvConfig } from "@next/env";
13
+ import type { SubjectRef } from "@percepta/access-control";
12
14
 
13
15
  const SEEDED_USERS = [
14
16
  {
15
- access: "owner",
16
- appRole: "admin",
17
- email: "admin@example.com",
18
- name: "Admin User",
17
+ access: "customer_admin",
18
+ email: "customer-admin@example.com",
19
+ name: "Customer Admin",
19
20
  password: "password",
20
21
  role: "admin",
21
22
  },
22
23
  {
23
- access: "member",
24
- email: "user@example.com",
25
- name: "Regular User",
24
+ access: "app_admin",
25
+ email: "app-admin@example.com",
26
+ name: "App Admin",
27
+ password: "password",
28
+ role: "admin",
29
+ },
30
+ {
31
+ access: "app_user",
32
+ email: "app-user@example.com",
33
+ name: "App User",
34
+ password: "password",
35
+ role: "user",
36
+ },
37
+ {
38
+ access: "none",
39
+ email: "non-user@example.com",
40
+ name: "App Non User",
26
41
  password: "password",
27
42
  role: "user",
28
43
  },
@@ -30,7 +45,7 @@ const SEEDED_USERS = [
30
45
 
31
46
  async function main(): Promise<void> {
32
47
  loadEnvConfig(process.cwd());
33
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
48
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
34
49
  (globalThis as any).AsyncLocalStorage = AsyncLocalStorage;
35
50
 
36
51
  const { auth } = await import("@__REPO_NAME__/auth");
@@ -38,13 +53,16 @@ async function main(): Promise<void> {
38
53
  const { users } = await import("@__REPO_NAME__/auth/schema");
39
54
  const { getAccessControl, toUserSubject } =
40
55
  await import("../src/services/access/AppAccessControl");
56
+ const { getEnvConfig } = await import("../src/config/getEnvConfig");
41
57
  const { createCustomerAccessControl } =
42
58
  await import("@percepta/access-control");
43
59
  const { eq, sql } = await import("drizzle-orm");
44
60
 
61
+ const envConfig = getEnvConfig();
45
62
  const access = getAccessControl();
63
+ const appNamespace = access.manifest.appNamespace;
46
64
  const customerAccess = createCustomerAccessControl({
47
- applications: [{ appNamespace: access.manifest.appNamespace }],
65
+ applications: [{ appNamespace }],
48
66
  client: access.client,
49
67
  });
50
68
 
@@ -89,25 +107,54 @@ async function main(): Promise<void> {
89
107
  }
90
108
 
91
109
  const subject = toUserSubject(userId);
92
- if (seededUser.access === "owner") {
93
- await customerAccess.assignApplicationOwner(
94
- access.manifest.appNamespace,
95
- subject,
96
- );
97
- } else {
98
- await customerAccess.assignApplicationMember(
99
- access.manifest.appNamespace,
100
- subject,
101
- );
102
- }
103
-
104
- if ("appRole" in seededUser) {
105
- await access.app.assignAppRole(seededUser.appRole, subject);
110
+ switch (seededUser.access) {
111
+ case "customer_admin":
112
+ bootstrapCustomerAdmin(subject, envConfig);
113
+ await customerAccess.revokeApplicationAdmin(appNamespace, subject);
114
+ await customerAccess.revokeApplicationUser(appNamespace, subject);
115
+ break;
116
+ case "app_admin":
117
+ await customerAccess.assignApplicationAdmin(appNamespace, subject);
118
+ await customerAccess.revokeApplicationUser(appNamespace, subject);
119
+ break;
120
+ case "app_user":
121
+ await customerAccess.assignApplicationUser(appNamespace, subject);
122
+ await customerAccess.revokeApplicationAdmin(appNamespace, subject);
123
+ break;
124
+ case "none":
125
+ await customerAccess.revokeApplicationAdmin(appNamespace, subject);
126
+ await customerAccess.revokeApplicationUser(appNamespace, subject);
127
+ break;
106
128
  }
107
129
  }
108
130
 
109
- console.log("Ensured local app access grants.");
131
+ console.log("Ensured local customer and app access grants.");
110
132
  process.exit(0);
111
133
  }
112
134
 
113
135
  void main();
136
+
137
+ function bootstrapCustomerAdmin(
138
+ subject: SubjectRef,
139
+ envConfig: {
140
+ readonly SPICEDB_ENDPOINT: string;
141
+ readonly SPICEDB_INSECURE: boolean;
142
+ readonly SPICEDB_PRESHARED_KEY: string;
143
+ },
144
+ ): void {
145
+ const args = [
146
+ "bootstrap-customer-admin",
147
+ "--subject",
148
+ subject,
149
+ "--endpoint",
150
+ envConfig.SPICEDB_ENDPOINT,
151
+ "--key",
152
+ envConfig.SPICEDB_PRESHARED_KEY,
153
+ ];
154
+
155
+ if (envConfig.SPICEDB_INSECURE) {
156
+ args.push("--insecure");
157
+ }
158
+
159
+ execFileSync("percepta-access-control", args, { stdio: "inherit" });
160
+ }
@@ -1,4 +1,11 @@
1
- import { defineAccessManifest } from "@percepta/access-control";
1
+ import {
2
+ defineAccessManifest,
3
+ defineAccessRoleDefinitions,
4
+ } from "@percepta/access-control";
5
+
6
+ const appRoles = [] as const;
7
+
8
+ export const accessRoleDefinitions = defineAccessRoleDefinitions(appRoles, []);
2
9
 
3
10
  export const accessManifest = defineAccessManifest({
4
11
  adminUI: {
@@ -10,6 +17,6 @@ export const accessManifest = defineAccessManifest({
10
17
  urlEnv: "APP_BASE_URL",
11
18
  },
12
19
  system: {
13
- assignableRoles: ["admin"],
20
+ assignableRoles: appRoles,
14
21
  },
15
22
  } as const);
@@ -1,7 +1,3 @@
1
1
  definition __APP_NAME_SNAKE__/system {
2
- relation application: core/application
3
- relation admin: core/user | core/group#member
4
-
5
- permission access_app = application->access
6
- permission manage_roles = access_app & (application->owner + admin)
2
+ // Add application-specific roles and permissions here.
7
3
  }
@@ -0,0 +1,33 @@
1
+ "use client";
2
+
3
+ import { Tabs, TabsList, TabsTrigger } from "@percepta/design";
4
+ import Link from "next/link";
5
+
6
+ export type AdminTab = "assignments" | "roles";
7
+
8
+ const tabs = [
9
+ { href: "/admin?tab=roles", label: "Roles", tab: "roles" },
10
+ {
11
+ href: "/admin?tab=assignments",
12
+ label: "Assignments",
13
+ tab: "assignments",
14
+ },
15
+ ] as const satisfies ReadonlyArray<{
16
+ readonly href: string;
17
+ readonly label: string;
18
+ readonly tab: AdminTab;
19
+ }>;
20
+
21
+ export function AdminTabs({ activeTab }: { readonly activeTab: AdminTab }) {
22
+ return (
23
+ <Tabs value={activeTab}>
24
+ <TabsList aria-label="RBAC views">
25
+ {tabs.map((tab) => (
26
+ <TabsTrigger asChild={true} key={tab.tab} value={tab.tab}>
27
+ <Link href={tab.href}>{tab.label}</Link>
28
+ </TabsTrigger>
29
+ ))}
30
+ </TabsList>
31
+ </Tabs>
32
+ );
33
+ }
@@ -0,0 +1,62 @@
1
+ import type { AppRole } from "@percepta/access-control";
2
+ import { notFound, redirect } from "next/navigation";
3
+ import { accessManifest } from "../../../../access/access.manifest";
4
+ import { getServerSession } from "../../../../lib/auth";
5
+ import {
6
+ canManageAppRolesStrong as checkCanManageAppRoles,
7
+ canManageDefaultRolesStrong as checkCanManageDefaultRoles,
8
+ } from "../../../../services/access/AppAccessControl";
9
+
10
+ export type AccessAppRole = AppRole<typeof accessManifest>;
11
+
12
+ export const assignableRoles: readonly AccessAppRole[] =
13
+ accessManifest.system.assignableRoles ?? [];
14
+
15
+ export interface AdminPermissions {
16
+ readonly canManageAppRoles: boolean;
17
+ readonly canManageDefaultRoles: boolean;
18
+ }
19
+
20
+ export async function requireAnyAdminPermission(): Promise<AdminPermissions> {
21
+ if (!isAdminUiEnabled()) {
22
+ notFound();
23
+ }
24
+
25
+ const session = await getServerSession();
26
+
27
+ if (session?.user == null) {
28
+ redirect("/auth/signin");
29
+ }
30
+
31
+ const [canManageDefaultRoles, canManageAppRoles] = await Promise.all([
32
+ checkCanManageDefaultRoles(session.user.id),
33
+ checkCanManageAppRoles(session.user.id),
34
+ ]);
35
+
36
+ if (!canManageDefaultRoles && !canManageAppRoles) {
37
+ notFound();
38
+ }
39
+
40
+ return {
41
+ canManageAppRoles,
42
+ canManageDefaultRoles,
43
+ };
44
+ }
45
+
46
+ export function readAssignableRole(formData: FormData): AccessAppRole {
47
+ const role = formData.get("role");
48
+ if (
49
+ typeof role !== "string" ||
50
+ !assignableRoles.includes(role as AccessAppRole)
51
+ ) {
52
+ throw new Error("Invalid app role.");
53
+ }
54
+
55
+ return role as AccessAppRole;
56
+ }
57
+
58
+ function isAdminUiEnabled(): boolean {
59
+ const adminUI: { readonly enabled?: boolean } | undefined =
60
+ accessManifest.adminUI;
61
+ return adminUI?.enabled !== false;
62
+ }