@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.
- package/README.md +3 -3
- package/package.json +2 -2
- package/templates/monorepo/README.md +9 -6
- package/templates/monorepo/access/dev-grants.yaml.example +3 -8
- package/templates/monorepo/auth/README.md +4 -3
- package/templates/monorepo/auth/package.json +2 -5
- package/templates/monorepo/auth/scripts/setup-database.ts +2 -48
- package/templates/monorepo/auth/src/auth.ts +10 -40
- package/templates/monorepo/auth/src/config/database.ts +9 -25
- package/templates/monorepo/auth/src/drizzle/db.ts +3 -4
- package/templates/monorepo/auth/src/drizzle/schema/auth/accounts.ts +2 -23
- package/templates/monorepo/auth/src/drizzle/schema/auth/sessions.ts +2 -16
- package/templates/monorepo/auth/src/drizzle/schema/auth/verifications.ts +2 -11
- package/templates/monorepo/auth/src/drizzle/schema/groups.ts +1 -1
- package/templates/monorepo/auth/src/drizzle/schema/users.ts +1 -1
- package/templates/monorepo/package.json.template +1 -1
- package/templates/webapp/AGENTS.md +6 -7
- package/templates/webapp/README.md +5 -3
- package/templates/webapp/agent-skills/access-control.md +15 -22
- package/templates/webapp/eslint.config.mjs +35 -7
- package/templates/webapp/package.json.template +4 -1
- package/templates/webapp/scripts/seed.ts +71 -24
- package/templates/webapp/src/access/access.manifest.ts +14 -1
- package/templates/webapp/src/access/schema.zed +1 -5
- package/templates/webapp/src/app/(admin)/admin/_components/AdminTabs.tsx +33 -0
- package/templates/webapp/src/app/(admin)/admin/_components/PrincipalMultiInput.tsx +248 -0
- package/templates/webapp/src/app/(admin)/admin/_lib/accessAdmin.ts +62 -0
- package/templates/webapp/src/app/(admin)/admin/page.tsx +683 -0
- package/templates/webapp/src/app/(admin)/layout.tsx +43 -0
- package/templates/webapp/src/app/(app)/layout.tsx +13 -3
- package/templates/webapp/src/app/(auth)/auth/signin/CredentialsSignInForm.tsx +1 -1
- package/templates/webapp/src/app/global-error.tsx +1 -2
- package/templates/webapp/src/components/Header.tsx +23 -2
- package/templates/webapp/src/server/api/routers/access.ts +2 -2
- package/templates/webapp/src/services/access/AppAccessControl.ts +49 -0
- package/templates/webapp/src/services/inngest/events/AppEvents.ts +2 -2
- package/templates/webapp/src/services/inngest/events/payloads/ExampleEventPayload.ts +6 -10
- package/templates/webapp/src/app/(app)/admin/_lib/PrincipalRoleTable.tsx +0 -113
- package/templates/webapp/src/app/(app)/admin/_lib/accessAdmin.ts +0 -85
- package/templates/webapp/src/app/(app)/admin/groups/page.tsx +0 -117
- 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.
|
|
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: "
|
|
16
|
-
|
|
17
|
-
|
|
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: "
|
|
24
|
-
email: "
|
|
25
|
-
name: "
|
|
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
|
|
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
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
subject
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
subject
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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:
|
|
26
|
+
assignableRoles: appRoles,
|
|
14
27
|
},
|
|
15
28
|
} as const);
|
|
@@ -1,7 +1,3 @@
|
|
|
1
1
|
definition __APP_NAME_SNAKE__/system {
|
|
2
|
-
|
|
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
|
+
}
|