@percepta/create 3.4.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.
- package/package.json +1 -1
- package/templates/monorepo/auth/package.json +2 -1
- package/templates/monorepo/auth/src/principals.ts +11 -0
- package/templates/monorepo/package.json.template +1 -1
- package/templates/webapp/README.md +26 -0
- package/templates/webapp/e2e/rbac.spec.ts +136 -0
- package/templates/webapp/eslint.config.mjs +6 -0
- package/templates/webapp/gitignore.template +2 -0
- package/templates/webapp/package.json.template +5 -1
- package/templates/webapp/playwright.config.ts +33 -0
- package/templates/webapp/src/access/access.manifest.ts +5 -11
- package/templates/webapp/src/app/(admin)/admin/page.tsx +64 -149
- package/templates/webapp/src/services/access/AppAccessControl.ts +5 -31
- package/templates/webapp/src/app/(admin)/admin/_components/PrincipalMultiInput.tsx +0 -248
package/package.json
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./src/index.ts",
|
|
9
9
|
"./db": "./src/drizzle/db.ts",
|
|
10
|
+
"./principals": "./src/principals.ts",
|
|
10
11
|
"./schema": "./src/drizzle/schema/index.ts"
|
|
11
12
|
},
|
|
12
13
|
"scripts": {
|
|
@@ -17,7 +18,7 @@
|
|
|
17
18
|
"db:setup-and-migrate": "pnpm db:setup && pnpm db:migrate"
|
|
18
19
|
},
|
|
19
20
|
"dependencies": {
|
|
20
|
-
"@percepta/auth": "0.1.
|
|
21
|
+
"@percepta/auth": "0.1.1",
|
|
21
22
|
"drizzle-orm": "^0.45.2"
|
|
22
23
|
},
|
|
23
24
|
"devDependencies": {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { listAuthPrincipals } from "@percepta/auth/drizzle";
|
|
2
|
+
import { db } from "./drizzle/db";
|
|
3
|
+
import { groups, users } from "./drizzle/schema";
|
|
4
|
+
|
|
5
|
+
export function listPrincipals() {
|
|
6
|
+
return listAuthPrincipals({
|
|
7
|
+
db,
|
|
8
|
+
groupsTable: groups,
|
|
9
|
+
usersTable: users,
|
|
10
|
+
});
|
|
11
|
+
}
|
|
@@ -98,6 +98,32 @@ src/
|
|
|
98
98
|
| `pnpm db:setup-and-migrate` | Setup and migrate database |
|
|
99
99
|
| `pnpm db:studio` | Setup/migrate the database, then open Drizzle Studio |
|
|
100
100
|
| `pnpm db:seed` | Seed default shared-auth dev users and local access grants |
|
|
101
|
+
| `pnpm test:e2e:install` | Install the Chromium browser used by Playwright |
|
|
102
|
+
| `pnpm test:e2e` | Run Playwright e2e tests after local setup |
|
|
103
|
+
| `pnpm test:e2e:ui` | Run Playwright e2e tests in UI mode after local setup |
|
|
104
|
+
|
|
105
|
+
## End-to-End Tests
|
|
106
|
+
|
|
107
|
+
This template includes focused Playwright coverage for seeded RBAC flows. Before
|
|
108
|
+
the first run, install the browser binary:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
pnpm test:e2e:install
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Then run:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
pnpm test:e2e
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The e2e script runs local setup first, then starts the Next.js dev server via
|
|
121
|
+
Playwright. To point the tests at an already-running app, set
|
|
122
|
+
`PLAYWRIGHT_BASE_URL`, for example:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
PLAYWRIGHT_BASE_URL=http://localhost:3000 pnpm test:e2e
|
|
126
|
+
```
|
|
101
127
|
|
|
102
128
|
## Logging
|
|
103
129
|
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { expect, test, type Page } from "@playwright/test";
|
|
4
|
+
|
|
5
|
+
const execFileAsync = promisify(execFile);
|
|
6
|
+
const password = "password";
|
|
7
|
+
|
|
8
|
+
const users = {
|
|
9
|
+
appAdmin: "app-admin@example.com",
|
|
10
|
+
appUser: "app-user@example.com",
|
|
11
|
+
customerAdmin: "customer-admin@example.com",
|
|
12
|
+
nonAppUser: "non-user@example.com",
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
test.describe("RBAC access", () => {
|
|
16
|
+
test("customer admin can manage role mappings but cannot enter the main app", async ({
|
|
17
|
+
page,
|
|
18
|
+
}) => {
|
|
19
|
+
test.setTimeout(60_000);
|
|
20
|
+
await signIn(page, users.customerAdmin, "/admin?tab=assignments");
|
|
21
|
+
|
|
22
|
+
await expect(page.getByRole("heading", { name: "RBAC" })).toBeVisible();
|
|
23
|
+
await expect(page.getByRole("link", { name: "Admin" })).toBeVisible();
|
|
24
|
+
|
|
25
|
+
const userAssignments = page.getByRole("button", {
|
|
26
|
+
name: "User assignments",
|
|
27
|
+
});
|
|
28
|
+
const adminAssignments = page.getByRole("button", {
|
|
29
|
+
name: "Admin assignments",
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
await expect(userAssignments).toHaveAttribute("aria-disabled", "false");
|
|
33
|
+
await expect(adminAssignments).toHaveAttribute("aria-disabled", "false");
|
|
34
|
+
|
|
35
|
+
await userAssignments.click();
|
|
36
|
+
await expect(page.getByPlaceholder("Search users or groups")).toBeVisible();
|
|
37
|
+
|
|
38
|
+
let shouldResetSeedData = false;
|
|
39
|
+
try {
|
|
40
|
+
await page.getByRole("option", { name: /App Non User/ }).click();
|
|
41
|
+
shouldResetSeedData = true;
|
|
42
|
+
await expect(userAssignments).toContainText("App Non User");
|
|
43
|
+
await expect(userAssignments).toHaveAttribute("aria-busy", "false");
|
|
44
|
+
|
|
45
|
+
await page.reload();
|
|
46
|
+
await expect(
|
|
47
|
+
page.getByRole("button", { name: "User assignments" }),
|
|
48
|
+
).toContainText("App Non User");
|
|
49
|
+
} finally {
|
|
50
|
+
if (shouldResetSeedData) {
|
|
51
|
+
await resetSeedData();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
await page.reload();
|
|
56
|
+
await expect(
|
|
57
|
+
page.getByRole("button", { name: "User assignments" }),
|
|
58
|
+
).not.toContainText("App Non User");
|
|
59
|
+
|
|
60
|
+
await page.goto("/");
|
|
61
|
+
await expect(page.getByRole("heading", { name: /Welcome to/ })).toHaveCount(
|
|
62
|
+
0,
|
|
63
|
+
);
|
|
64
|
+
await expectNotFound(page);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("app admin can see RBAC and main app but cannot edit default role mappings", async ({
|
|
68
|
+
page,
|
|
69
|
+
}) => {
|
|
70
|
+
await signIn(page, users.appAdmin, "/admin?tab=assignments");
|
|
71
|
+
|
|
72
|
+
await expect(page.getByRole("heading", { name: "RBAC" })).toBeVisible();
|
|
73
|
+
await expect(
|
|
74
|
+
page
|
|
75
|
+
.getByText("Only customer admins can edit default application roles.")
|
|
76
|
+
.first(),
|
|
77
|
+
).toBeVisible();
|
|
78
|
+
|
|
79
|
+
await expect(
|
|
80
|
+
page.getByRole("button", { name: "User assignments" }),
|
|
81
|
+
).toHaveAttribute("aria-disabled", "true");
|
|
82
|
+
await expect(
|
|
83
|
+
page.getByRole("button", { name: "Admin assignments" }),
|
|
84
|
+
).toHaveAttribute("aria-disabled", "true");
|
|
85
|
+
|
|
86
|
+
await page.goto("/");
|
|
87
|
+
await expect(
|
|
88
|
+
page.getByRole("heading", { name: /Welcome to/ }),
|
|
89
|
+
).toBeVisible();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("app user can enter the main app but cannot reach admin", async ({
|
|
93
|
+
page,
|
|
94
|
+
}) => {
|
|
95
|
+
await signIn(page, users.appUser, "/");
|
|
96
|
+
|
|
97
|
+
await expect(
|
|
98
|
+
page.getByRole("heading", { name: /Welcome to/ }),
|
|
99
|
+
).toBeVisible();
|
|
100
|
+
await expect(page.getByRole("link", { name: "Admin" })).toHaveCount(0);
|
|
101
|
+
|
|
102
|
+
await page.goto("/admin");
|
|
103
|
+
await expect(page.getByRole("heading", { name: "RBAC" })).toHaveCount(0);
|
|
104
|
+
await expectNotFound(page);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("non app user cannot enter the app", async ({ page }) => {
|
|
108
|
+
await signIn(page, users.nonAppUser, "/");
|
|
109
|
+
|
|
110
|
+
await expect(page.getByRole("heading", { name: /Welcome to/ })).toHaveCount(
|
|
111
|
+
0,
|
|
112
|
+
);
|
|
113
|
+
await expect(page.getByRole("heading", { name: "RBAC" })).toHaveCount(0);
|
|
114
|
+
await expectNotFound(page);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
async function signIn(page: Page, email: string, callbackUrl: string) {
|
|
119
|
+
await page.goto(
|
|
120
|
+
`/auth/signin?callbackUrl=${encodeURIComponent(callbackUrl)}`,
|
|
121
|
+
);
|
|
122
|
+
await page.getByLabel("Email").fill(email);
|
|
123
|
+
await page.getByLabel("Password").fill(password);
|
|
124
|
+
await page.getByRole("button", { name: "Sign In" }).click();
|
|
125
|
+
await page.waitForURL((url) => url.pathname !== "/auth/signin");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function expectNotFound(page: Page) {
|
|
129
|
+
await expect(
|
|
130
|
+
page.getByText(/This page could not be found|404/i).first(),
|
|
131
|
+
).toBeVisible();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function resetSeedData() {
|
|
135
|
+
await execFileAsync("pnpm", ["db:seed"], { cwd: process.cwd() });
|
|
136
|
+
}
|
|
@@ -27,6 +27,9 @@
|
|
|
27
27
|
"deploy:percepta-test": "tsx ./scripts/deploy-percepta-test.ts",
|
|
28
28
|
"deploy:percepta-test:pr": "tsx ./scripts/open-ryvn-deploy-pr.ts",
|
|
29
29
|
"test": "vitest run",
|
|
30
|
+
"test:e2e": "pnpm run setup && playwright test",
|
|
31
|
+
"test:e2e:install": "playwright install chromium",
|
|
32
|
+
"test:e2e:ui": "pnpm run setup && playwright test --ui",
|
|
30
33
|
"test:watch": "vitest"
|
|
31
34
|
},
|
|
32
35
|
"dependencies": {
|
|
@@ -55,7 +58,7 @@
|
|
|
55
58
|
"@opentelemetry/exporter-trace-otlp-proto": "^0.203.0",
|
|
56
59
|
"@opentelemetry/sdk-node": "^0.203.0",
|
|
57
60
|
"@__REPO_NAME__/auth": "workspace:*",
|
|
58
|
-
"@percepta/access-control": "0.6.
|
|
61
|
+
"@percepta/access-control": "0.6.1",
|
|
59
62
|
"@percepta/design": "0.3.2",
|
|
60
63
|
"@percepta/logger": "0.0.6",
|
|
61
64
|
"@percepta/next-utils": "0.1.0",
|
|
@@ -104,6 +107,7 @@
|
|
|
104
107
|
"devDependencies": {
|
|
105
108
|
"@eslint/js": "^9.18.0",
|
|
106
109
|
"@next/eslint-plugin-next": "^15.3.5",
|
|
110
|
+
"@playwright/test": "^1.58.2",
|
|
107
111
|
"@percepta/build": "0.4.0",
|
|
108
112
|
"@tailwindcss/postcss": "^4.1.11",
|
|
109
113
|
"@types/formidable": "^3.4.5",
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { defineConfig, devices } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
const port = Number(process.env.PLAYWRIGHT_PORT ?? 3000);
|
|
4
|
+
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${port}`;
|
|
5
|
+
|
|
6
|
+
export default defineConfig({
|
|
7
|
+
testDir: "./e2e",
|
|
8
|
+
fullyParallel: false,
|
|
9
|
+
forbidOnly: Boolean(process.env.CI),
|
|
10
|
+
retries: process.env.CI ? 2 : 0,
|
|
11
|
+
reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : "list",
|
|
12
|
+
use: {
|
|
13
|
+
baseURL,
|
|
14
|
+
trace: "on-first-retry",
|
|
15
|
+
},
|
|
16
|
+
webServer: process.env.PLAYWRIGHT_BASE_URL
|
|
17
|
+
? undefined
|
|
18
|
+
: {
|
|
19
|
+
command: `pnpm dev -- --hostname 127.0.0.1 --port ${port}`,
|
|
20
|
+
env: {
|
|
21
|
+
SKIP_INNGEST_SYNC: "true",
|
|
22
|
+
},
|
|
23
|
+
reuseExistingServer: !process.env.CI,
|
|
24
|
+
timeout: 120_000,
|
|
25
|
+
url: baseURL,
|
|
26
|
+
},
|
|
27
|
+
projects: [
|
|
28
|
+
{
|
|
29
|
+
name: "chromium",
|
|
30
|
+
use: { ...devices["Desktop Chrome"] },
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
});
|
|
@@ -1,17 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
defineAccessManifest,
|
|
3
|
+
defineAccessRoleDefinitions,
|
|
4
|
+
} from "@percepta/access-control";
|
|
2
5
|
|
|
3
6
|
const appRoles = [] as const;
|
|
4
7
|
|
|
5
|
-
export const accessRoleDefinitions = []
|
|
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
|
-
}>;
|
|
8
|
+
export const accessRoleDefinitions = defineAccessRoleDefinitions(appRoles, []);
|
|
15
9
|
|
|
16
10
|
export const accessManifest = defineAccessManifest({
|
|
17
11
|
adminUI: {
|
|
@@ -1,7 +1,14 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
AccessRoleDefinition,
|
|
3
|
+
ApplicationGrant,
|
|
4
|
+
SubjectRef,
|
|
5
|
+
} from "@percepta/access-control";
|
|
2
6
|
import { groupSubjectRef } from "@percepta/access-control";
|
|
3
|
-
import {
|
|
4
|
-
|
|
7
|
+
import {
|
|
8
|
+
PrincipalMultiInput,
|
|
9
|
+
type PrincipalOption,
|
|
10
|
+
} from "@percepta/access-control/react";
|
|
11
|
+
import { listPrincipals } from "@__REPO_NAME__/auth/principals";
|
|
5
12
|
import {
|
|
6
13
|
Badge,
|
|
7
14
|
Table,
|
|
@@ -11,7 +18,6 @@ import {
|
|
|
11
18
|
TableHeader,
|
|
12
19
|
TableRow,
|
|
13
20
|
} from "@percepta/design";
|
|
14
|
-
import { isNull } from "drizzle-orm";
|
|
15
21
|
import type { Metadata } from "next";
|
|
16
22
|
import { revalidatePath } from "next/cache";
|
|
17
23
|
import { notFound } from "next/navigation";
|
|
@@ -31,10 +37,6 @@ import {
|
|
|
31
37
|
readAssignableRole,
|
|
32
38
|
requireAnyAdminPermission,
|
|
33
39
|
} from "./_lib/accessAdmin";
|
|
34
|
-
import {
|
|
35
|
-
PrincipalMultiInput,
|
|
36
|
-
type PrincipalOption,
|
|
37
|
-
} from "./_components/PrincipalMultiInput";
|
|
38
40
|
import { AdminTabs, type AdminTab } from "./_components/AdminTabs";
|
|
39
41
|
|
|
40
42
|
export const metadata: Metadata = {
|
|
@@ -50,22 +52,10 @@ interface AdminPageProps {
|
|
|
50
52
|
}>;
|
|
51
53
|
}
|
|
52
54
|
|
|
53
|
-
interface
|
|
54
|
-
readonly description: string;
|
|
55
|
-
readonly permission: string;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
interface RoleDefinition {
|
|
59
|
-
readonly description: string;
|
|
60
|
-
readonly editableBy: "app_admin" | "customer_admin";
|
|
61
|
-
readonly label: string;
|
|
62
|
-
readonly permissions: readonly PermissionDefinition[];
|
|
63
|
-
readonly role: string;
|
|
55
|
+
interface RoleDefinition extends AccessRoleDefinition {
|
|
64
56
|
readonly source: "Application access" | "App RBAC";
|
|
65
57
|
}
|
|
66
58
|
|
|
67
|
-
type AppRoleDefinition = Omit<RoleDefinition, "source">;
|
|
68
|
-
|
|
69
59
|
interface PrincipalAssignmentOption extends PrincipalOption {
|
|
70
60
|
readonly effectiveAccess: boolean;
|
|
71
61
|
readonly subject: SubjectRef;
|
|
@@ -98,7 +88,6 @@ interface PrincipalAccessRow {
|
|
|
98
88
|
const roleDefinitions = [
|
|
99
89
|
{
|
|
100
90
|
description: "Can sign in to this application.",
|
|
101
|
-
editableBy: "customer_admin",
|
|
102
91
|
label: "User",
|
|
103
92
|
permissions: [
|
|
104
93
|
{
|
|
@@ -111,7 +100,6 @@ const roleDefinitions = [
|
|
|
111
100
|
},
|
|
112
101
|
{
|
|
113
102
|
description: "Can sign in and manage application-specific roles.",
|
|
114
|
-
editableBy: "customer_admin",
|
|
115
103
|
label: "Admin",
|
|
116
104
|
permissions: [
|
|
117
105
|
{
|
|
@@ -126,7 +114,9 @@ const roleDefinitions = [
|
|
|
126
114
|
role: "admin",
|
|
127
115
|
source: "Application access",
|
|
128
116
|
},
|
|
129
|
-
...(
|
|
117
|
+
...(
|
|
118
|
+
accessRoleDefinitions as readonly AccessRoleDefinition<AccessAppRole>[]
|
|
119
|
+
).map(
|
|
130
120
|
(definition): RoleDefinition => ({
|
|
131
121
|
...definition,
|
|
132
122
|
role: definition.role,
|
|
@@ -250,6 +240,7 @@ async function AssignmentsTab({
|
|
|
250
240
|
<TableCell className="py-4 whitespace-normal">
|
|
251
241
|
<PrincipalMultiInput
|
|
252
242
|
action={row.updateAction}
|
|
243
|
+
ariaLabel={`${row.definition.label} assignments`}
|
|
253
244
|
disabled={row.disabled}
|
|
254
245
|
hiddenFields={row.hiddenFields}
|
|
255
246
|
key={row.selectedSubjects.join("|")}
|
|
@@ -302,7 +293,11 @@ async function updateAssignableRoleAssignments(formData: FormData) {
|
|
|
302
293
|
.filter((principal) => principal.roles.includes(role))
|
|
303
294
|
.map((principal) => principal.subject);
|
|
304
295
|
|
|
305
|
-
await
|
|
296
|
+
await getAccessControl().app.setAppRoleSubjects(
|
|
297
|
+
role,
|
|
298
|
+
selectedSubjects,
|
|
299
|
+
permissions.canManageDefaultRoles ? undefined : { currentSubjects },
|
|
300
|
+
);
|
|
306
301
|
|
|
307
302
|
revalidatePath("/admin");
|
|
308
303
|
}
|
|
@@ -322,17 +317,18 @@ async function updateApplicationAccessAssignments(
|
|
|
322
317
|
requireEffectiveAccess: false,
|
|
323
318
|
});
|
|
324
319
|
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
+
}
|
|
336
332
|
|
|
337
333
|
revalidatePath("/admin");
|
|
338
334
|
}
|
|
@@ -413,59 +409,24 @@ async function listRbacRoleAssignmentRows(
|
|
|
413
409
|
async function listPrincipalAccessRows(
|
|
414
410
|
permissions: AdminPermissions,
|
|
415
411
|
): Promise<readonly PrincipalAccessRow[]> {
|
|
416
|
-
const [
|
|
417
|
-
|
|
418
|
-
.select({
|
|
419
|
-
email: users.email,
|
|
420
|
-
id: users.id,
|
|
421
|
-
name: users.name,
|
|
422
|
-
})
|
|
423
|
-
.from(users)
|
|
424
|
-
.orderBy(users.email),
|
|
425
|
-
authDb
|
|
426
|
-
.select({
|
|
427
|
-
id: groups.id,
|
|
428
|
-
name: groups.name,
|
|
429
|
-
source: groups.source,
|
|
430
|
-
})
|
|
431
|
-
.from(groups)
|
|
432
|
-
.where(isNull(groups.deletedAt))
|
|
433
|
-
.orderBy(groups.name),
|
|
412
|
+
const [principals, grants, roleSubjects] = await Promise.all([
|
|
413
|
+
listPrincipals(),
|
|
434
414
|
getCustomerAccessControl().listApplicationGrants(
|
|
435
415
|
accessManifest.appNamespace,
|
|
436
416
|
),
|
|
417
|
+
listAssignableRoleSubjects(),
|
|
437
418
|
]);
|
|
438
419
|
|
|
439
420
|
const grantMap = buildGrantMap(grants);
|
|
440
421
|
const access = getAccessControl();
|
|
441
422
|
const application = accessManifest.application.ref();
|
|
442
423
|
|
|
443
|
-
const
|
|
444
|
-
|
|
445
|
-
const subject =
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
resource: application,
|
|
450
|
-
subject,
|
|
451
|
-
});
|
|
452
|
-
|
|
453
|
-
return {
|
|
454
|
-
detail: user.email,
|
|
455
|
-
displayName: user.name,
|
|
456
|
-
effectiveAccess,
|
|
457
|
-
isAdmin: grantState?.has("admin") === true,
|
|
458
|
-
isUser: grantState?.has("user") === true,
|
|
459
|
-
principalType: "User" as const,
|
|
460
|
-
roles: await access.app.listAppRoles(subject),
|
|
461
|
-
subject,
|
|
462
|
-
};
|
|
463
|
-
}),
|
|
464
|
-
);
|
|
465
|
-
|
|
466
|
-
const groupAccessRows = await Promise.all(
|
|
467
|
-
groupRows.map(async (group) => {
|
|
468
|
-
const subject = groupSubjectRef(group.id);
|
|
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);
|
|
469
430
|
const grantState = grantMap.get(subject);
|
|
470
431
|
const effectiveAccess = await access.permissions.can({
|
|
471
432
|
permission: accessManifest.application.accessPermission,
|
|
@@ -474,30 +435,39 @@ async function listPrincipalAccessRows(
|
|
|
474
435
|
});
|
|
475
436
|
|
|
476
437
|
return {
|
|
477
|
-
detail:
|
|
478
|
-
displayName:
|
|
438
|
+
detail: principal.detail,
|
|
439
|
+
displayName: principal.displayName,
|
|
479
440
|
effectiveAccess,
|
|
480
441
|
isAdmin: grantState?.has("admin") === true,
|
|
481
442
|
isUser: grantState?.has("user") === true,
|
|
482
|
-
principalType:
|
|
483
|
-
roles:
|
|
443
|
+
principalType: principal.type,
|
|
444
|
+
roles: assignableRoles.filter(
|
|
445
|
+
(role) => roleSubjects.get(role)?.has(subject) === true,
|
|
446
|
+
),
|
|
484
447
|
subject,
|
|
485
448
|
};
|
|
486
449
|
}),
|
|
487
450
|
);
|
|
488
451
|
|
|
489
|
-
const rows = [...userAccessRows, ...groupAccessRows].sort((a, b) => {
|
|
490
|
-
const typeCompare = a.principalType.localeCompare(b.principalType);
|
|
491
|
-
return typeCompare === 0
|
|
492
|
-
? a.displayName.localeCompare(b.displayName)
|
|
493
|
-
: typeCompare;
|
|
494
|
-
});
|
|
495
|
-
|
|
496
452
|
return permissions.canManageDefaultRoles
|
|
497
453
|
? rows
|
|
498
454
|
: rows.filter((row) => row.effectiveAccess);
|
|
499
455
|
}
|
|
500
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
|
+
|
|
501
471
|
function buildGrantMap(
|
|
502
472
|
grants: readonly ApplicationGrant[],
|
|
503
473
|
): Map<SubjectRef, Set<ApplicationGrant["relation"]>> {
|
|
@@ -516,14 +486,9 @@ function canEditAppRole(
|
|
|
516
486
|
role: AccessAppRole,
|
|
517
487
|
permissions: AdminPermissions,
|
|
518
488
|
): boolean {
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
return roleDefinition.editableBy === "customer_admin"
|
|
525
|
-
? permissions.canManageDefaultRoles
|
|
526
|
-
: permissions.canManageAppRoles;
|
|
489
|
+
return (
|
|
490
|
+
appRoleDefinitionMap.has(role) === true && permissions.canManageAppRoles
|
|
491
|
+
);
|
|
527
492
|
}
|
|
528
493
|
|
|
529
494
|
function getApplicationAccessRoleDefinition(
|
|
@@ -611,56 +576,6 @@ function validateSelectedPrincipals(
|
|
|
611
576
|
}
|
|
612
577
|
}
|
|
613
578
|
|
|
614
|
-
async function syncApplicationAccessAssignments(
|
|
615
|
-
relation: ApplicationAccessRole,
|
|
616
|
-
currentSubjects: readonly SubjectRef[],
|
|
617
|
-
selectedSubjects: readonly SubjectRef[],
|
|
618
|
-
) {
|
|
619
|
-
const currentSet = new Set(currentSubjects);
|
|
620
|
-
const selectedSet = new Set(selectedSubjects);
|
|
621
|
-
const customerAccess = getCustomerAccessControl();
|
|
622
|
-
const appNamespace = accessManifest.appNamespace;
|
|
623
|
-
|
|
624
|
-
await Promise.all([
|
|
625
|
-
...subjectsMinus(selectedSet, currentSet).map((subject) =>
|
|
626
|
-
relation === "user"
|
|
627
|
-
? customerAccess.assignApplicationUser(appNamespace, subject)
|
|
628
|
-
: customerAccess.assignApplicationAdmin(appNamespace, subject),
|
|
629
|
-
),
|
|
630
|
-
...subjectsMinus(currentSet, selectedSet).map((subject) =>
|
|
631
|
-
relation === "user"
|
|
632
|
-
? customerAccess.revokeApplicationUser(appNamespace, subject)
|
|
633
|
-
: customerAccess.revokeApplicationAdmin(appNamespace, subject),
|
|
634
|
-
),
|
|
635
|
-
]);
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
async function syncAppRoleAssignments(
|
|
639
|
-
role: AccessAppRole,
|
|
640
|
-
currentSubjects: readonly SubjectRef[],
|
|
641
|
-
selectedSubjects: readonly SubjectRef[],
|
|
642
|
-
) {
|
|
643
|
-
const currentSet = new Set(currentSubjects);
|
|
644
|
-
const selectedSet = new Set(selectedSubjects);
|
|
645
|
-
const app = getAccessControl().app;
|
|
646
|
-
|
|
647
|
-
await Promise.all([
|
|
648
|
-
...subjectsMinus(selectedSet, currentSet).map((subject) =>
|
|
649
|
-
app.assignAppRole(role, subject),
|
|
650
|
-
),
|
|
651
|
-
...subjectsMinus(currentSet, selectedSet).map((subject) =>
|
|
652
|
-
app.revokeAppRole(role, subject),
|
|
653
|
-
),
|
|
654
|
-
]);
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
function subjectsMinus(
|
|
658
|
-
left: ReadonlySet<SubjectRef>,
|
|
659
|
-
right: ReadonlySet<SubjectRef>,
|
|
660
|
-
): SubjectRef[] {
|
|
661
|
-
return Array.from(left).filter((subject) => !right.has(subject));
|
|
662
|
-
}
|
|
663
|
-
|
|
664
579
|
function sortSubjectsByPrincipal(
|
|
665
580
|
subjects: readonly SubjectRef[],
|
|
666
581
|
principals: readonly PrincipalAccessRow[],
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
type AppAccessRuntime,
|
|
3
|
-
applicationRef,
|
|
4
3
|
createAppAccessRuntime,
|
|
5
|
-
createCustomerAccessControl,
|
|
6
4
|
type CustomerAccessControl,
|
|
7
5
|
} from "@percepta/access-control";
|
|
8
6
|
import { accessManifest } from "../../access/access.manifest";
|
|
@@ -34,41 +32,21 @@ export function canAccessApplication(userId: string): Promise<boolean> {
|
|
|
34
32
|
}
|
|
35
33
|
|
|
36
34
|
export function canManageAppRoles(userId: string): Promise<boolean> {
|
|
37
|
-
|
|
38
|
-
return permissions.can({
|
|
39
|
-
permission: accessManifest.application.manageAppRolesPermission,
|
|
40
|
-
resource: applicationRef(accessManifest.appNamespace),
|
|
41
|
-
subject: toUserSubject(userId),
|
|
42
|
-
});
|
|
35
|
+
return appAccessRuntime.canManageAppRoles(userId);
|
|
43
36
|
}
|
|
44
37
|
|
|
45
38
|
export function canManageAppRolesStrong(userId: string): Promise<boolean> {
|
|
46
|
-
|
|
47
|
-
return permissions.canStrong({
|
|
48
|
-
permission: accessManifest.application.manageAppRolesPermission,
|
|
49
|
-
resource: applicationRef(accessManifest.appNamespace),
|
|
50
|
-
subject: toUserSubject(userId),
|
|
51
|
-
});
|
|
39
|
+
return appAccessRuntime.canManageAppRolesStrong(userId);
|
|
52
40
|
}
|
|
53
41
|
|
|
54
42
|
export function canManageDefaultRoles(userId: string): Promise<boolean> {
|
|
55
|
-
|
|
56
|
-
return permissions.can({
|
|
57
|
-
permission: accessManifest.application.manageDefaultRolesPermission,
|
|
58
|
-
resource: applicationRef(accessManifest.appNamespace),
|
|
59
|
-
subject: toUserSubject(userId),
|
|
60
|
-
});
|
|
43
|
+
return appAccessRuntime.canManageDefaultRoles(userId);
|
|
61
44
|
}
|
|
62
45
|
|
|
63
46
|
export function canManageDefaultRolesStrong(
|
|
64
47
|
userId: string,
|
|
65
48
|
): Promise<boolean> {
|
|
66
|
-
|
|
67
|
-
return permissions.canStrong({
|
|
68
|
-
permission: accessManifest.application.manageDefaultRolesPermission,
|
|
69
|
-
resource: applicationRef(accessManifest.appNamespace),
|
|
70
|
-
subject: toUserSubject(userId),
|
|
71
|
-
});
|
|
49
|
+
return appAccessRuntime.canManageDefaultRolesStrong(userId);
|
|
72
50
|
}
|
|
73
51
|
|
|
74
52
|
export function getAccessControl(): AppAccessControl {
|
|
@@ -76,11 +54,7 @@ export function getAccessControl(): AppAccessControl {
|
|
|
76
54
|
}
|
|
77
55
|
|
|
78
56
|
export function getCustomerAccessControl(): CustomerAccessControl {
|
|
79
|
-
|
|
80
|
-
return createCustomerAccessControl({
|
|
81
|
-
applications: [{ appNamespace: accessManifest.appNamespace }],
|
|
82
|
-
client,
|
|
83
|
-
});
|
|
57
|
+
return appAccessRuntime.getCustomerAccessControl();
|
|
84
58
|
}
|
|
85
59
|
|
|
86
60
|
export function toUserSubject(userId: string) {
|
|
@@ -1,248 +0,0 @@
|
|
|
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
|
-
}
|