@percepta/create 3.2.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 (41) hide show
  1. package/README.md +3 -3
  2. package/package.json +2 -2
  3. package/templates/monorepo/README.md +9 -6
  4. package/templates/monorepo/access/dev-grants.yaml.example +3 -8
  5. package/templates/monorepo/auth/README.md +4 -3
  6. package/templates/monorepo/auth/package.json +2 -5
  7. package/templates/monorepo/auth/scripts/setup-database.ts +2 -48
  8. package/templates/monorepo/auth/src/auth.ts +10 -40
  9. package/templates/monorepo/auth/src/config/database.ts +9 -25
  10. package/templates/monorepo/auth/src/drizzle/db.ts +3 -4
  11. package/templates/monorepo/auth/src/drizzle/schema/auth/accounts.ts +2 -23
  12. package/templates/monorepo/auth/src/drizzle/schema/auth/sessions.ts +2 -16
  13. package/templates/monorepo/auth/src/drizzle/schema/auth/verifications.ts +2 -11
  14. package/templates/monorepo/auth/src/drizzle/schema/groups.ts +1 -1
  15. package/templates/monorepo/auth/src/drizzle/schema/users.ts +1 -1
  16. package/templates/monorepo/package.json.template +1 -1
  17. package/templates/webapp/AGENTS.md +6 -7
  18. package/templates/webapp/README.md +5 -3
  19. package/templates/webapp/agent-skills/access-control.md +15 -22
  20. package/templates/webapp/eslint.config.mjs +35 -7
  21. package/templates/webapp/package.json.template +4 -1
  22. package/templates/webapp/scripts/seed.ts +71 -24
  23. package/templates/webapp/src/access/access.manifest.ts +14 -1
  24. package/templates/webapp/src/access/schema.zed +1 -5
  25. package/templates/webapp/src/app/(admin)/admin/_components/AdminTabs.tsx +33 -0
  26. package/templates/webapp/src/app/(admin)/admin/_components/PrincipalMultiInput.tsx +248 -0
  27. package/templates/webapp/src/app/(admin)/admin/_lib/accessAdmin.ts +62 -0
  28. package/templates/webapp/src/app/(admin)/admin/page.tsx +683 -0
  29. package/templates/webapp/src/app/(admin)/layout.tsx +43 -0
  30. package/templates/webapp/src/app/(app)/layout.tsx +13 -3
  31. package/templates/webapp/src/app/(auth)/auth/signin/CredentialsSignInForm.tsx +1 -1
  32. package/templates/webapp/src/app/global-error.tsx +1 -2
  33. package/templates/webapp/src/components/Header.tsx +23 -2
  34. package/templates/webapp/src/server/api/routers/access.ts +2 -2
  35. package/templates/webapp/src/services/access/AppAccessControl.ts +49 -0
  36. package/templates/webapp/src/services/inngest/events/AppEvents.ts +2 -2
  37. package/templates/webapp/src/services/inngest/events/payloads/ExampleEventPayload.ts +6 -10
  38. package/templates/webapp/src/app/(app)/admin/_lib/PrincipalRoleTable.tsx +0 -113
  39. package/templates/webapp/src/app/(app)/admin/_lib/accessAdmin.ts +0 -85
  40. package/templates/webapp/src/app/(app)/admin/groups/page.tsx +0 -117
  41. package/templates/webapp/src/app/(app)/admin/users/page.tsx +0 -79
@@ -55,7 +55,7 @@
55
55
  "@opentelemetry/exporter-trace-otlp-proto": "^0.203.0",
56
56
  "@opentelemetry/sdk-node": "^0.203.0",
57
57
  "@__REPO_NAME__/auth": "workspace:*",
58
- "@percepta/access-control": "0.3.2",
58
+ "@percepta/access-control": "0.6.0",
59
59
  "@percepta/design": "0.3.2",
60
60
  "@percepta/logger": "0.0.6",
61
61
  "@percepta/next-utils": "0.1.0",
@@ -102,6 +102,7 @@
102
102
  "zod": "^4.1.5"
103
103
  },
104
104
  "devDependencies": {
105
+ "@eslint/js": "^9.18.0",
105
106
  "@next/eslint-plugin-next": "^15.3.5",
106
107
  "@percepta/build": "0.4.0",
107
108
  "@tailwindcss/postcss": "^4.1.11",
@@ -121,9 +122,11 @@
121
122
  "eslint-plugin-n": "^17.23.1",
122
123
  "eslint-plugin-react": "^7.37.4",
123
124
  "eslint-plugin-react-hooks": "^5.2.0",
125
+ "globals": "^15.14.0",
124
126
  "husky": "^9.1.7",
125
127
  "tailwindcss": "^4.0.12",
126
128
  "typescript": "^5.7.3",
129
+ "typescript-eslint": "^8.33.0",
127
130
  "vitest": "^3.2.1",
128
131
  "yargs": "^17.7.2"
129
132
  }
@@ -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,5 +1,18 @@
1
1
  import { defineAccessManifest } from "@percepta/access-control";
2
2
 
3
+ const appRoles = [] as const;
4
+
5
+ export const accessRoleDefinitions = [] as const satisfies ReadonlyArray<{
6
+ readonly description: string;
7
+ readonly editableBy: "app_admin" | "customer_admin";
8
+ readonly label: string;
9
+ readonly permissions: ReadonlyArray<{
10
+ readonly description: string;
11
+ readonly permission: string;
12
+ }>;
13
+ readonly role: (typeof appRoles)[number];
14
+ }>;
15
+
3
16
  export const accessManifest = defineAccessManifest({
4
17
  adminUI: {
5
18
  enabled: true,
@@ -10,6 +23,6 @@ export const accessManifest = defineAccessManifest({
10
23
  urlEnv: "APP_BASE_URL",
11
24
  },
12
25
  system: {
13
- assignableRoles: ["admin"],
26
+ assignableRoles: appRoles,
14
27
  },
15
28
  } 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,248 @@
1
+ "use client";
2
+
3
+ import {
4
+ Badge,
5
+ Command,
6
+ CommandEmpty,
7
+ CommandGroup,
8
+ CommandInput,
9
+ CommandItem,
10
+ CommandList,
11
+ Popover,
12
+ PopoverContent,
13
+ PopoverTrigger,
14
+ } from "@percepta/design";
15
+ import { useCallback, useMemo, useState, useTransition } from "react";
16
+
17
+ export interface PrincipalOption {
18
+ readonly detail: string;
19
+ readonly disabled?: boolean;
20
+ readonly disabledReason?: string;
21
+ readonly label: string;
22
+ readonly subject: string;
23
+ readonly type: "Group" | "Principal" | "User";
24
+ }
25
+
26
+ interface HiddenField {
27
+ readonly name: string;
28
+ readonly value: string;
29
+ }
30
+
31
+ interface PrincipalMultiInputProps {
32
+ readonly action: (formData: FormData) => Promise<void>;
33
+ readonly disabled: boolean;
34
+ readonly hiddenFields?: readonly HiddenField[];
35
+ readonly options: readonly PrincipalOption[];
36
+ readonly selectedSubjects: readonly string[];
37
+ }
38
+
39
+ export function PrincipalMultiInput({
40
+ action,
41
+ disabled,
42
+ hiddenFields = [],
43
+ options,
44
+ selectedSubjects,
45
+ }: PrincipalMultiInputProps) {
46
+ const [isPickerOpen, setIsPickerOpen] = useState(false);
47
+ const [isPending, startTransition] = useTransition();
48
+ const [query, setQuery] = useState("");
49
+ const [selected, setSelected] = useState<readonly string[]>(
50
+ selectedSubjects,
51
+ );
52
+ const optionBySubject = useMemo(
53
+ () => new Map(options.map((option) => [option.subject, option])),
54
+ [options],
55
+ );
56
+ const selectedSet = useMemo(() => new Set(selected), [selected]);
57
+ const normalizedQuery = query.trim().toLowerCase();
58
+
59
+ const selectedOptions = useMemo(
60
+ () =>
61
+ selected.map(
62
+ (subject): PrincipalOption =>
63
+ optionBySubject.get(subject) ?? {
64
+ detail: "Unknown principal",
65
+ label: subject,
66
+ subject,
67
+ type: "Principal",
68
+ },
69
+ ),
70
+ [optionBySubject, selected],
71
+ );
72
+
73
+ const filteredOptions = useMemo(
74
+ () =>
75
+ options.filter((option) => {
76
+ if (selectedSet.has(option.subject)) {
77
+ return false;
78
+ }
79
+
80
+ if (normalizedQuery.length === 0) {
81
+ return true;
82
+ }
83
+
84
+ return `${option.label} ${option.detail} ${option.type}`
85
+ .toLowerCase()
86
+ .includes(normalizedQuery);
87
+ }),
88
+ [normalizedQuery, options, selectedSet],
89
+ );
90
+
91
+ const submitSelection = useCallback(
92
+ (nextSelected: readonly string[]) => {
93
+ const formData = new FormData();
94
+ for (const field of hiddenFields) {
95
+ formData.append(field.name, field.value);
96
+ }
97
+ for (const subject of nextSelected) {
98
+ formData.append("subjects", subject);
99
+ }
100
+
101
+ startTransition(async () => {
102
+ await action(formData);
103
+ });
104
+ },
105
+ [action, hiddenFields],
106
+ );
107
+
108
+ const isDisabled = disabled || isPending;
109
+
110
+ const handleAddOption = useCallback(
111
+ (subject: string) => {
112
+ const option = optionBySubject.get(subject);
113
+ if (
114
+ isDisabled ||
115
+ option?.disabled === true ||
116
+ selected.includes(subject)
117
+ ) {
118
+ return;
119
+ }
120
+
121
+ const nextSelected = [...selected, subject];
122
+ setSelected(nextSelected);
123
+ submitSelection(nextSelected);
124
+ setQuery("");
125
+ setIsPickerOpen(false);
126
+ },
127
+ [isDisabled, optionBySubject, selected, submitSelection],
128
+ );
129
+
130
+ const handleRemoveOption = useCallback(
131
+ (subject: string) => {
132
+ if (isDisabled) {
133
+ return;
134
+ }
135
+
136
+ const nextSelected = selected.filter(
137
+ (currentSubject) => currentSubject !== subject,
138
+ );
139
+ setSelected(nextSelected);
140
+ submitSelection(nextSelected);
141
+ },
142
+ [isDisabled, selected, submitSelection],
143
+ );
144
+
145
+ return (
146
+ <Popover
147
+ open={isPickerOpen}
148
+ onOpenChange={(open) => {
149
+ if (!isDisabled) {
150
+ setIsPickerOpen(open);
151
+ }
152
+ }}
153
+ >
154
+ <PopoverTrigger asChild={true}>
155
+ <div
156
+ aria-busy={isPending}
157
+ aria-disabled={isDisabled}
158
+ className="flex min-h-12 cursor-text flex-wrap items-center gap-2 rounded-md border border-border bg-background p-2 data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-60"
159
+ data-disabled={isDisabled}
160
+ role="button"
161
+ tabIndex={isDisabled ? -1 : 0}
162
+ >
163
+ {selectedOptions.map((option) => (
164
+ <SelectedPrincipalBadge
165
+ disabled={isDisabled}
166
+ key={option.subject}
167
+ onRemove={handleRemoveOption}
168
+ option={option}
169
+ />
170
+ ))}
171
+ {selectedOptions.length === 0 ? (
172
+ <span className="min-w-40 flex-1 text-sm text-muted-foreground">
173
+ {isDisabled ? "No users or groups assigned" : "Search users or groups"}
174
+ </span>
175
+ ) : (
176
+ <span aria-hidden={true} className="min-w-8 flex-1" />
177
+ )}
178
+ </div>
179
+ </PopoverTrigger>
180
+ <PopoverContent align="start" className="w-96 p-0">
181
+ <Command shouldFilter={false}>
182
+ <CommandInput
183
+ onValueChange={setQuery}
184
+ placeholder="Search users or groups"
185
+ value={query}
186
+ />
187
+ <CommandList>
188
+ <CommandEmpty>No matches</CommandEmpty>
189
+ <CommandGroup>
190
+ {filteredOptions.map((option) => (
191
+ <CommandItem
192
+ disabled={isDisabled || option.disabled === true}
193
+ key={option.subject}
194
+ onSelect={handleAddOption}
195
+ title={option.disabledReason}
196
+ value={option.subject}
197
+ >
198
+ <span className="min-w-0 flex-1">
199
+ <span className="block truncate font-medium text-foreground">
200
+ {option.label}
201
+ </span>
202
+ <span className="block truncate text-xs text-muted-foreground">
203
+ {option.detail} / {option.type}
204
+ </span>
205
+ </span>
206
+ </CommandItem>
207
+ ))}
208
+ </CommandGroup>
209
+ </CommandList>
210
+ </Command>
211
+ </PopoverContent>
212
+ </Popover>
213
+ );
214
+ }
215
+
216
+ function SelectedPrincipalBadge({
217
+ disabled,
218
+ onRemove,
219
+ option,
220
+ }: {
221
+ readonly disabled: boolean;
222
+ readonly onRemove: (subject: string) => void;
223
+ readonly option: PrincipalOption;
224
+ }) {
225
+ const handleRemove = useCallback(() => {
226
+ onRemove(option.subject);
227
+ }, [onRemove, option.subject]);
228
+
229
+ return (
230
+ <span
231
+ className="max-w-full"
232
+ onClick={(event) => event.stopPropagation()}
233
+ onKeyDown={(event) => event.stopPropagation()}
234
+ >
235
+ <Badge
236
+ className="max-w-full gap-2 py-1 text-sm"
237
+ disabled={disabled}
238
+ onRemove={handleRemove}
239
+ variant="secondary"
240
+ >
241
+ <span className="truncate">{option.label}</span>
242
+ <span className="text-xs font-normal text-muted-foreground">
243
+ {option.type}
244
+ </span>
245
+ </Badge>
246
+ </span>
247
+ );
248
+ }
@@ -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
+ }