@awsmbiz/create-inbis-app 0.0.0-stage → 0.1.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 +28 -2
- package/dist/cli.mjs +850 -0
- package/dist/cli.mjs.map +1 -0
- package/package.json +43 -3
- package/template/.dev.vars.example +4 -0
- package/template/README.md +115 -0
- package/template/e2e/accessibility.spec.ts +33 -0
- package/template/e2e/axe.ts +47 -0
- package/template/e2e/shell.spec.ts +33 -0
- package/template/e2e/tsconfig.json +8 -0
- package/template/eslint.config.js +7 -0
- package/template/migrations/0001_app_initial.sql +8 -0
- package/template/modules/README.md +6 -0
- package/template/modules/access-control/files/src/access-control-routes.ts +36 -0
- package/template/modules/access-control/files/src/access-control.ts +46 -0
- package/template/modules/access-control/files/test/access-control.test.ts +78 -0
- package/template/modules/access-control/module.json +54 -0
- package/template/modules/access-control/wiring/admin/admin.imports.tsx +1 -0
- package/template/modules/access-control/wiring/admin/admin.nav.tsx +2 -0
- package/template/modules/access-control/wiring/admin/admin.pages.tsx +4 -0
- package/template/modules/access-control/wiring/admin/admin.userSections.tsx +1 -0
- package/template/modules/access-control/wiring/admin/readme.modules.md +3 -0
- package/template/modules/access-control/wiring/admin/web.messages.ts +4 -0
- package/template/modules/access-control/wiring/audit/audit.catalogues.imports.ts +1 -0
- package/template/modules/access-control/wiring/audit/audit.catalogues.ts +1 -0
- package/template/modules/access-control/wiring/audit/readme.modules.md +4 -0
- package/template/modules/access-control/wiring/audit/web.messages.ts +6 -0
- package/template/modules/access-control/wiring/d1.jsonc +8 -0
- package/template/modules/access-control/wiring/env.bindings.ts +2 -0
- package/template/modules/access-control/wiring/readme.modules.md +20 -0
- package/template/modules/access-control/wiring/test.env.ts +1 -0
- package/template/modules/access-control/wiring/test.migrate.ts +1 -0
- package/template/modules/access-control/wiring/vitest.bindings.ts +1 -0
- package/template/modules/access-control/wiring/worker.imports.ts +1 -0
- package/template/modules/access-control/wiring/worker.routes.ts +2 -0
- package/template/modules/access-control/with-admin/e2e/access-roles.spec.ts +51 -0
- package/template/modules/access-control/with-admin/web/src/admin-roles.tsx +38 -0
- package/template/modules/access-control/with-audit/test/access-control-audit.test.ts +25 -0
- package/template/modules/admin/files/e2e/admin-accessibility.spec.ts +86 -0
- package/template/modules/admin/files/e2e/admin-area.spec.ts +109 -0
- package/template/modules/admin/files/e2e/admin-helpers.ts +39 -0
- package/template/modules/admin/files/src/admin.ts +46 -0
- package/template/modules/admin/files/test/admin.test.ts +47 -0
- package/template/modules/admin/files/web/src/admin-area.tsx +140 -0
- package/template/modules/admin/files/web/src/admin-client.ts +49 -0
- package/template/modules/admin/files/web/src/admin-example.tsx +62 -0
- package/template/modules/admin/files/web/src/admin-gate.tsx +112 -0
- package/template/modules/admin/module.json +50 -0
- package/template/modules/admin/wiring/account.sections.tsx +16 -0
- package/template/modules/admin/wiring/audit/admin.authAudit.ts +2 -0
- package/template/modules/admin/wiring/audit/admin.imports.tsx +1 -0
- package/template/modules/admin/wiring/audit/admin.nav.tsx +1 -0
- package/template/modules/admin/wiring/audit/admin.pages.tsx +1 -0
- package/template/modules/admin/wiring/audit/admin.server.imports.ts +3 -0
- package/template/modules/admin/wiring/audit/admin.stats.ts +3 -0
- package/template/modules/admin/wiring/audit/admin.userSections.tsx +1 -0
- package/template/modules/admin/wiring/audit/readme.modules.md +4 -0
- package/template/modules/admin/wiring/audit/web.messages.ts +1 -0
- package/template/modules/admin/wiring/readme.modules.md +26 -0
- package/template/modules/admin/wiring/web.messages.ts +22 -0
- package/template/modules/admin/wiring/web.routes.imports.tsx +1 -0
- package/template/modules/admin/wiring/web.routes.tsx +2 -0
- package/template/modules/admin/wiring/web.styles.css +1 -0
- package/template/modules/admin/wiring/worker.imports.ts +1 -0
- package/template/modules/admin/wiring/worker.routes.ts +2 -0
- package/template/modules/admin/with-audit/e2e/admin-activity.spec.ts +37 -0
- package/template/modules/admin/with-audit/test/admin-audit.test.ts +37 -0
- package/template/modules/admin/with-audit/web/src/admin-activity.tsx +21 -0
- package/template/modules/audit/files/src/audit-actions.ts +20 -0
- package/template/modules/audit/files/src/audit-log.ts +33 -0
- package/template/modules/audit/files/test/audit.test.ts +47 -0
- package/template/modules/audit/module.json +36 -0
- package/template/modules/audit/wiring/audit.imports.ts +1 -0
- package/template/modules/audit/wiring/audit.sink.ts +2 -0
- package/template/modules/audit/wiring/auth/audit.catalogues.imports.ts +1 -0
- package/template/modules/audit/wiring/auth/audit.catalogues.ts +1 -0
- package/template/modules/audit/wiring/auth/readme.modules.md +7 -0
- package/template/modules/audit/wiring/auth/web.messages.ts +26 -0
- package/template/modules/audit/wiring/auth/web.routes.imports.tsx +1 -0
- package/template/modules/audit/wiring/auth/web.routes.tsx +1 -0
- package/template/modules/audit/wiring/auth/worker.imports.ts +1 -0
- package/template/modules/audit/wiring/auth/worker.routes.ts +2 -0
- package/template/modules/audit/wiring/d1.jsonc +8 -0
- package/template/modules/audit/wiring/env.bindings.ts +4 -0
- package/template/modules/audit/wiring/index.imports.ts +1 -0
- package/template/modules/audit/wiring/index.setup.ts +2 -0
- package/template/modules/audit/wiring/readme.modules.md +16 -0
- package/template/modules/audit/wiring/test.env.ts +1 -0
- package/template/modules/audit/wiring/test.migrate.ts +1 -0
- package/template/modules/audit/wiring/vars.deployed.jsonc +2 -0
- package/template/modules/audit/wiring/vars.dev.jsonc +2 -0
- package/template/modules/audit/wiring/vitest.bindings.ts +1 -0
- package/template/modules/audit/with-auth/e2e/my-activity.spec.ts +89 -0
- package/template/modules/audit/with-auth/src/audit-routes.ts +21 -0
- package/template/modules/audit/with-auth/test/audit-routes.test.ts +17 -0
- package/template/modules/audit/with-auth/web/src/my-activity.tsx +168 -0
- package/template/modules/auth/files/e2e/auth-accessibility.spec.ts +68 -0
- package/template/modules/auth/files/e2e/auth-bootstrap-admin.spec.ts +58 -0
- package/template/modules/auth/files/e2e/auth-helpers.ts +74 -0
- package/template/modules/auth/files/e2e/auth-invitations.spec.ts +51 -0
- package/template/modules/auth/files/e2e/auth-phone.spec.ts +41 -0
- package/template/modules/auth/files/e2e/auth-seeded.ts +97 -0
- package/template/modules/auth/files/e2e/auth-sign-in.spec.ts +57 -0
- package/template/modules/auth/files/e2e/auth-two-factor.spec.ts +44 -0
- package/template/modules/auth/files/scripts/seed-user.mjs +64 -0
- package/template/modules/auth/files/src/auth-choices.ts +33 -0
- package/template/modules/auth/files/src/auth-config.ts +55 -0
- package/template/modules/auth/files/src/auth.ts +68 -0
- package/template/modules/auth/files/src/local-test-mode.ts +49 -0
- package/template/modules/auth/files/src/notifications.ts +94 -0
- package/template/modules/auth/files/test/auth-fixtures.ts +135 -0
- package/template/modules/auth/files/test/auth.test.ts +210 -0
- package/template/modules/auth/files/test/totp.ts +49 -0
- package/template/modules/auth/files/test/turnstile-fake.ts +42 -0
- package/template/modules/auth/files/web/src/account-menu.tsx +25 -0
- package/template/modules/auth/files/web/src/account.tsx +87 -0
- package/template/modules/auth/files/web/src/auth-client.ts +60 -0
- package/template/modules/auth/files/web/src/auth-pages.tsx +152 -0
- package/template/modules/auth/module.json +56 -0
- package/template/modules/auth/wiring/bindings.deployed.jsonc +2 -0
- package/template/modules/auth/wiring/d1.jsonc +7 -0
- package/template/modules/auth/wiring/devvars.txt +12 -0
- package/template/modules/auth/wiring/env.bindings.ts +26 -0
- package/template/modules/auth/wiring/index.deps.ts +1 -0
- package/template/modules/auth/wiring/index.imports.ts +4 -0
- package/template/modules/auth/wiring/index.setup.ts +7 -0
- package/template/modules/auth/wiring/playwright.imports.ts +1 -0
- package/template/modules/auth/wiring/playwright.servers.ts +12 -0
- package/template/modules/auth/wiring/playwright.setup.ts +2 -0
- package/template/modules/auth/wiring/playwright.vars.ts +2 -0
- package/template/modules/auth/wiring/readme.modules.md +30 -0
- package/template/modules/auth/wiring/readme.provisioning.md +11 -0
- package/template/modules/auth/wiring/secrets.jsonc +2 -0
- package/template/modules/auth/wiring/test.env.ts +1 -0
- package/template/modules/auth/wiring/test.migrate.ts +1 -0
- package/template/modules/auth/wiring/vars.dev.jsonc +19 -0
- package/template/modules/auth/wiring/vars.production.jsonc +13 -0
- package/template/modules/auth/wiring/vars.staging.jsonc +13 -0
- package/template/modules/auth/wiring/vitest.bindings.ts +6 -0
- package/template/modules/auth/wiring/web.header.tsx +1 -0
- package/template/modules/auth/wiring/web.layout.imports.tsx +1 -0
- package/template/modules/auth/wiring/web.messages.ts +17 -0
- package/template/modules/auth/wiring/web.routes.imports.tsx +12 -0
- package/template/modules/auth/wiring/web.routes.tsx +11 -0
- package/template/modules/auth/wiring/web.styles.css +1 -0
- package/template/modules/auth/wiring/worker.deps.ts +2 -0
- package/template/modules/auth/wiring/worker.imports.ts +1 -0
- package/template/modules/auth/wiring/worker.routes.ts +2 -0
- package/template/modules/example/files/e2e/example-notes.spec.ts +39 -0
- package/template/modules/example/files/migrations/0002_example_notes.sql +12 -0
- package/template/modules/example/files/src/example-audit-actions.ts +12 -0
- package/template/modules/example/files/src/example-notes.ts +136 -0
- package/template/modules/example/files/test/example-notes-fixtures.ts +30 -0
- package/template/modules/example/files/test/example-notes.test.ts +52 -0
- package/template/modules/example/files/web/src/example-notes.tsx +176 -0
- package/template/modules/example/module.json +61 -0
- package/template/modules/example/wiring/access-control/access.permissions.ts +3 -0
- package/template/modules/example/wiring/access-control/access.roles.ts +2 -0
- package/template/modules/example/wiring/access-control/example.e2e.imports.ts +2 -0
- package/template/modules/example/wiring/access-control/example.e2e.setup.ts +18 -0
- package/template/modules/example/wiring/access-control/example.imports.ts +3 -0
- package/template/modules/example/wiring/access-control/example.policy.ts +10 -0
- package/template/modules/example/wiring/access-control/example.test.caller.ts +17 -0
- package/template/modules/example/wiring/access-control/example.test.imports.ts +1 -0
- package/template/modules/example/wiring/access-control/readme.modules.md +5 -0
- package/template/modules/example/wiring/admin/admin.imports.tsx +1 -0
- package/template/modules/example/wiring/admin/admin.nav.tsx +2 -0
- package/template/modules/example/wiring/admin/admin.pages.tsx +2 -0
- package/template/modules/example/wiring/admin/readme.modules.md +4 -0
- package/template/modules/example/wiring/admin/web.messages.ts +11 -0
- package/template/modules/example/wiring/admin/worker.imports.ts +1 -0
- package/template/modules/example/wiring/admin/worker.routes.ts +2 -0
- package/template/modules/example/wiring/audit/audit.catalogues.imports.ts +1 -0
- package/template/modules/example/wiring/audit/audit.catalogues.ts +2 -0
- package/template/modules/example/wiring/audit/readme.modules.md +4 -0
- package/template/modules/example/wiring/readme.modules.md +16 -0
- package/template/modules/example/wiring/web.messages.ts +19 -0
- package/template/modules/example/wiring/web.routes.imports.tsx +1 -0
- package/template/modules/example/wiring/web.routes.tsx +2 -0
- package/template/modules/example/wiring/worker.imports.ts +1 -0
- package/template/modules/example/wiring/worker.routes.ts +2 -0
- package/template/modules/example/with-access-control/test/example-notes-access.test.ts +51 -0
- package/template/modules/example/with-admin/e2e/example-notes-admin.spec.ts +55 -0
- package/template/modules/example/with-admin/src/example-notes-admin.ts +62 -0
- package/template/modules/example/with-admin/test/example-notes-admin.test.ts +42 -0
- package/template/modules/example/with-admin/web/src/admin-notes.tsx +99 -0
- package/template/modules/example/with-audit/test/example-notes-audit.test.ts +22 -0
- package/template/package.json +57 -0
- package/template/playwright.config.ts +38 -0
- package/template/renovate.json +12 -0
- package/template/src/app.ts +46 -0
- package/template/src/audit-catalogues.ts +9 -0
- package/template/src/audit-sink.ts +21 -0
- package/template/src/env.d.ts +21 -0
- package/template/src/index.ts +18 -0
- package/template/src/v1.ts +24 -0
- package/template/standalone/.github/workflows/ci.yml +21 -0
- package/template/standalone/.github/workflows/deploy.yml +32 -0
- package/template/standalone/.nvmrc +1 -0
- package/template/standalone/_gitignore +9 -0
- package/template/standalone/pnpm-workspace.yaml +11 -0
- package/template/template.json +56 -0
- package/template/test/env.d.ts +11 -0
- package/template/test/migrate.ts +11 -0
- package/template/test/migrations.test.ts +22 -0
- package/template/test/worker.test.ts +65 -0
- package/template/tsconfig.json +7 -0
- package/template/vitest.config.ts +24 -0
- package/template/web/index.html +12 -0
- package/template/web/src/app.tsx +44 -0
- package/template/web/src/home.tsx +51 -0
- package/template/web/src/layout.tsx +48 -0
- package/template/web/src/main.tsx +16 -0
- package/template/web/src/messages.ts +38 -0
- package/template/web/src/navigation.tsx +34 -0
- package/template/web/src/route-focus.ts +49 -0
- package/template/web/src/routes.tsx +38 -0
- package/template/web/src/styles.css +11 -0
- package/template/web/src/system-theme.ts +16 -0
- package/template/web/tsconfig.json +7 -0
- package/template/web/vite.config.ts +15 -0
- package/template/wrangler.jsonc +96 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { choicesWith, json, migrate, newUser, seedAdmin, seedUser, signIn, testApp, type TestApp } from "./auth-fixtures.js";
|
|
3
|
+
|
|
4
|
+
// Access control wired into the app, over HTTP: the example permission protects
|
|
5
|
+
// GET /v1/example/protected, and administrators grant it through the role admin API.
|
|
6
|
+
// @awsmbiz/inbis-access-control's own tests cover the rules in detail; these prove the wiring.
|
|
7
|
+
|
|
8
|
+
const PROTECTED = "/v1/example/protected";
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
await migrate();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
async function signedInUser(app: TestApp): Promise<{ cookie: string; id: string }> {
|
|
15
|
+
const user = newUser();
|
|
16
|
+
await seedUser(user);
|
|
17
|
+
const cookie = await signIn(app, user);
|
|
18
|
+
const me = await app.call("/api/me", { headers: { cookie } });
|
|
19
|
+
return { cookie, id: (await me.json<{ user: { id: string } }>()).user.id };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("a route protected by a permission", () => {
|
|
23
|
+
it("refuses a visitor who is not signed in", async () => {
|
|
24
|
+
const response = await testApp().call(PROTECTED);
|
|
25
|
+
expect(response.status).toBe(401);
|
|
26
|
+
expect(await response.json()).toMatchObject({ error: { code: "UNAUTHENTICATED" } });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("refuses a signed-in user without the permission, and admits them once an administrator grants a role with it", async () => {
|
|
30
|
+
const app = testApp(choicesWith("invite-only"));
|
|
31
|
+
const user = await signedInUser(app);
|
|
32
|
+
|
|
33
|
+
const refused = await app.call(PROTECTED, { headers: { cookie: user.cookie } });
|
|
34
|
+
expect(refused.status).toBe(403);
|
|
35
|
+
expect(await refused.json()).toMatchObject({ error: { code: "FORBIDDEN", details: { permission: "example.read" } } });
|
|
36
|
+
|
|
37
|
+
const admin = await seedAdmin();
|
|
38
|
+
const granted = await app.call(`/api/admin/users/${user.id}/roles/viewer`, { ...json({}, { cookie: admin }), method: "PUT" });
|
|
39
|
+
expect(granted.status).toBe(200);
|
|
40
|
+
expect(await granted.json()).toMatchObject({ items: [{ id: "viewer", system: true }] });
|
|
41
|
+
|
|
42
|
+
const admitted = await app.call(PROTECTED, { headers: { cookie: user.cookie } });
|
|
43
|
+
expect(admitted.status).toBe(200);
|
|
44
|
+
expect(await admitted.json()).toEqual({ message: "You hold the example.read permission." });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("is documented in the OpenAPI description", async () => {
|
|
48
|
+
const response = await testApp().call("/openapi.json");
|
|
49
|
+
const document = await response.json<{ paths: Record<string, unknown> }>();
|
|
50
|
+
expect(Object.keys(document.paths)).toContain(PROTECTED);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("the role admin API", () => {
|
|
55
|
+
it("lists the app's catalogue and seeded system roles to administrators", async () => {
|
|
56
|
+
const app = testApp(choicesWith("invite-only"));
|
|
57
|
+
const admin = await seedAdmin();
|
|
58
|
+
|
|
59
|
+
const permissions = await app.call("/api/admin/permissions", { headers: { cookie: admin } });
|
|
60
|
+
expect(permissions.status).toBe(200);
|
|
61
|
+
const { items: declared } = await permissions.json<{ items: { name: string }[] }>();
|
|
62
|
+
expect(declared.map((permission) => permission.name)).toContain("example.read");
|
|
63
|
+
|
|
64
|
+
const roles = await app.call("/api/admin/roles", { headers: { cookie: admin } });
|
|
65
|
+
const { items: seeded } = await roles.json<{ items: { id: string; name: string; system: boolean }[] }>();
|
|
66
|
+
expect(seeded.find((role) => role.id === "viewer")).toMatchObject({ name: "Viewer", system: true });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("is for administrators only", async () => {
|
|
70
|
+
const app = testApp(choicesWith("invite-only"));
|
|
71
|
+
const anonymous = await app.call("/api/admin/roles");
|
|
72
|
+
expect(anonymous.status).toBe(401);
|
|
73
|
+
|
|
74
|
+
const user = await signedInUser(app);
|
|
75
|
+
const refused = await app.call("/api/admin/roles", { headers: { cookie: user.cookie } });
|
|
76
|
+
expect(refused.status).toBe(403);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "access-control",
|
|
3
|
+
"description": "Business permissions (@awsmbiz/inbis-access-control): a typed permission catalogue stub with a system role, requirePermission() on an example route and the role admin API; with admin, the roles pages and the user-roles section; with audit, role changes in the trail.",
|
|
4
|
+
"order": 20,
|
|
5
|
+
"requires": ["auth"],
|
|
6
|
+
"bindings": {
|
|
7
|
+
"ACCESS_CONTROL_DB": "the access-control tables (@awsmbiz/inbis-access-control migrations, same database)"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@awsmbiz/inbis-access-control": "workspace:*"
|
|
11
|
+
},
|
|
12
|
+
"points": {
|
|
13
|
+
"access.permissions": { "file": "src/access-control.ts", "description": "Entries of APP_ACCESS.permissions: `\"name.action\": \"Description\",`." },
|
|
14
|
+
"access.roles": { "file": "src/access-control.ts", "description": "System roles of APP_ACCESS.roles: `id: { name, description?, permissions, inherits? },`." }
|
|
15
|
+
},
|
|
16
|
+
"wiring": {
|
|
17
|
+
"worker.imports": "wiring/worker.imports.ts",
|
|
18
|
+
"worker.routes": "wiring/worker.routes.ts",
|
|
19
|
+
"env.bindings": "wiring/env.bindings.ts",
|
|
20
|
+
"wrangler.d1": "wiring/d1.jsonc",
|
|
21
|
+
"test.env": "wiring/test.env.ts",
|
|
22
|
+
"test.migrate": "wiring/test.migrate.ts",
|
|
23
|
+
"vitest.bindings": "wiring/vitest.bindings.ts",
|
|
24
|
+
"readme.modules": "wiring/readme.modules.md"
|
|
25
|
+
},
|
|
26
|
+
"when": [
|
|
27
|
+
{
|
|
28
|
+
"modules": ["audit"],
|
|
29
|
+
"files": "with-audit",
|
|
30
|
+
"wiring": {
|
|
31
|
+
"audit.catalogues.imports": "wiring/audit/audit.catalogues.imports.ts",
|
|
32
|
+
"audit.catalogues": "wiring/audit/audit.catalogues.ts",
|
|
33
|
+
"web.messages": "wiring/audit/web.messages.ts",
|
|
34
|
+
"readme.modules": "wiring/audit/readme.modules.md"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"modules": ["admin"],
|
|
39
|
+
"files": "with-admin",
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@awsmbiz/inbis-admin-ui": "workspace:*",
|
|
42
|
+
"@awsmbiz/inbis-api-client": "workspace:*"
|
|
43
|
+
},
|
|
44
|
+
"wiring": {
|
|
45
|
+
"admin.imports": "wiring/admin/admin.imports.tsx",
|
|
46
|
+
"admin.nav": "wiring/admin/admin.nav.tsx",
|
|
47
|
+
"admin.pages": "wiring/admin/admin.pages.tsx",
|
|
48
|
+
"admin.userSections": "wiring/admin/admin.userSections.tsx",
|
|
49
|
+
"web.messages": "wiring/admin/web.messages.ts",
|
|
50
|
+
"readme.modules": "wiring/admin/readme.modules.md"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
]
|
|
54
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import { NewRolePage, RolePage, RolesIcon, RolesPage, UserRoles } from "./admin-roles.js";
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// Roles and permissions (access-control module).
|
|
2
|
+
{ path: "roles", title: t("accessControl.roles.title"), component: RolesPage },
|
|
3
|
+
{ path: "roles/new", title: t("accessControl.newRole.title"), component: NewRolePage },
|
|
4
|
+
{ path: "roles/:roleId", title: t("accessControl.role.title"), component: RolePage },
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<UserRoles user={user} />
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import { ACCESS_CONTROL_AUDIT_ACTIONS } from "@awsmbiz/inbis-access-control";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ACCESS_CONTROL_AUDIT_ACTIONS,
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Role changes an administrator makes (access-control module), in their own activity.
|
|
2
|
+
"activity.action.access.role.assigned": "Assigned a role",
|
|
3
|
+
"activity.action.access.role.unassigned": "Removed a role",
|
|
4
|
+
"activity.action.access.role.created": "Created a role",
|
|
5
|
+
"activity.action.access.role.updated": "Changed a role",
|
|
6
|
+
"activity.action.access.role.deleted": "Deleted a role",
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
// Access control (@awsmbiz/inbis-access-control): after auth's (user_role references user), before audit's and DB.
|
|
3
|
+
"binding": "ACCESS_CONTROL_DB",
|
|
4
|
+
"database_name": "{{database_name}}",
|
|
5
|
+
"database_id": "{{database_id}}",
|
|
6
|
+
"migrations_dir": "node_modules/@awsmbiz/inbis-access-control/migrations",
|
|
7
|
+
"migrations_table": "_inbis_access_control_migrations"
|
|
8
|
+
},
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
## Access control
|
|
2
|
+
|
|
3
|
+
`@awsmbiz/inbis-access-control`: business permissions and roles, in the app's database
|
|
4
|
+
through their own binding (`ACCESS_CONTROL_DB`, migrations shipped in the package, applied
|
|
5
|
+
after auth's). They are separate from the platform administrator role: an administrator
|
|
6
|
+
gets no business permission implicitly.
|
|
7
|
+
|
|
8
|
+
- **Catalogue** — `src/access-control.ts` (`APP_ACCESS`) declares every permission the app
|
|
9
|
+
checks and the system roles that grant them. It starts as a stub (`example.read`, role
|
|
10
|
+
`viewer`): replace both with your own. Names are typed, so a typo is a compile error. The
|
|
11
|
+
catalogue is seeded into D1 on the first permission check after a deploy.
|
|
12
|
+
- **Protecting a route** — `requireAuth()` (from `@awsmbiz/inbis-auth/hono`), then
|
|
13
|
+
`requirePermission("<permission>")` (from `src/access-control.ts`): `401` signed out,
|
|
14
|
+
`403 FORBIDDEN` without the permission. `src/access-control-routes.ts` shows it on
|
|
15
|
+
`GET /v1/example/protected`; `/v1` routes add auth's `sessionMiddleware` first (`/api/*`
|
|
16
|
+
already has it). In code: `(await accessControlOf(c)).authorize({ userId, permission })`.
|
|
17
|
+
- **Roles** — administrators assign roles and create custom ones through the role admin
|
|
18
|
+
API under `/api/admin` (`/permissions`, `/roles`, `/users/:userId/roles`; changes need a
|
|
19
|
+
recent sign-in).
|
|
20
|
+
- **Tests** — `test/access-control.test.ts` (Workers).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ACCESS_CONTROL_MIGRATIONS: D1Migration[];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
await applyD1Migrations(env.ACCESS_CONTROL_DB, env.ACCESS_CONTROL_MIGRATIONS, "_inbis_access_control_migrations");
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ACCESS_CONTROL_MIGRATIONS: await readD1Migrations("./node_modules/@awsmbiz/inbis-access-control/migrations"),
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import { mountAccessControl } from "./access-control-routes.js";
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { expect, test, type BrowserContext } from "@playwright/test";
|
|
2
|
+
import { adminApi } from "./auth-helpers.js";
|
|
3
|
+
import { adminsCanSignIn } from "./auth-seeded.js";
|
|
4
|
+
import { expectNoPageScroll, expectNoViolations, THEMES, VIEWPORTS } from "./axe.js";
|
|
5
|
+
|
|
6
|
+
// The roles pages in the admin area, as the seeded administrator (signed in once, through
|
|
7
|
+
// the API: one authenticator code for the whole file). The role admin API itself is
|
|
8
|
+
// covered by test/access-control.test.ts.
|
|
9
|
+
|
|
10
|
+
test.skip(!adminsCanSignIn, "administrators need email + password and 2FA (src/auth-choices.ts)");
|
|
11
|
+
test.describe.configure({ mode: "serial" });
|
|
12
|
+
|
|
13
|
+
let session: Awaited<ReturnType<BrowserContext["storageState"]>>;
|
|
14
|
+
|
|
15
|
+
test.beforeAll(async ({ playwright, baseURL }) => {
|
|
16
|
+
const admin = await adminApi(playwright, baseURL);
|
|
17
|
+
session = await admin.storageState();
|
|
18
|
+
await admin.dispose();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test.beforeEach(async ({ context }) => {
|
|
22
|
+
await context.addCookies(session.cookies);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("an administrator finds the app's system role in the admin area and opens it", async ({ page }) => {
|
|
26
|
+
await page.goto("/admin/roles");
|
|
27
|
+
await expect(page.getByRole("heading", { name: "Roles", level: 1 })).toBeVisible();
|
|
28
|
+
await page.getByRole("link", { name: "Viewer" }).click();
|
|
29
|
+
await expect(page).toHaveURL(/\/admin\/roles\/viewer$/);
|
|
30
|
+
await expect(page.getByRole("heading", { name: "Viewer", level: 1 })).toBeVisible();
|
|
31
|
+
await expect(page.getByText("example.read")).toBeVisible();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
for (const [name, path, heading] of [
|
|
35
|
+
["Roles", "/admin/roles", "Roles"],
|
|
36
|
+
["A system role", "/admin/roles/viewer", "Viewer"],
|
|
37
|
+
["New role", "/admin/roles/new", "New role"],
|
|
38
|
+
] as const) {
|
|
39
|
+
for (const theme of THEMES) {
|
|
40
|
+
for (const [viewport, size] of Object.entries(VIEWPORTS)) {
|
|
41
|
+
test(`${name} (${theme}, ${viewport}) has no accessibility violations`, async ({ page }) => {
|
|
42
|
+
await page.emulateMedia({ colorScheme: theme });
|
|
43
|
+
await page.setViewportSize(size);
|
|
44
|
+
await page.goto(path);
|
|
45
|
+
await expect(page.getByRole("heading", { name: heading, level: 1 })).toBeVisible();
|
|
46
|
+
await expectNoPageScroll(page);
|
|
47
|
+
await expectNoViolations(page);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { AccessRolePage, AccessRolesPage, UserRolesSection, type AdminPageProps } from "@awsmbiz/inbis-admin-ui";
|
|
2
|
+
import { createAuthAdminClient } from "@awsmbiz/inbis-api-client";
|
|
3
|
+
|
|
4
|
+
// Roles and permissions in the admin area (access-control module), over the role admin
|
|
5
|
+
// API that src/access-control-routes.ts mounts: the roles list, a role's page (system
|
|
6
|
+
// roles read-only, custom roles editable) and the roles section of a user's page.
|
|
7
|
+
|
|
8
|
+
const client = createAuthAdminClient();
|
|
9
|
+
|
|
10
|
+
/** `roles`: every role, system roles first. */
|
|
11
|
+
export function RolesPage() {
|
|
12
|
+
return <AccessRolesPage client={client} />;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** `roles/new`: a custom role from the app's permissions. */
|
|
16
|
+
export function NewRolePage() {
|
|
17
|
+
return <AccessRolePage client={client} />;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** `roles/:roleId`: one role. */
|
|
21
|
+
export function RolePage({ params }: AdminPageProps) {
|
|
22
|
+
return <AccessRolePage client={client} roleId={params["roleId"] ?? ""} />;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The roles section of a user's page: assign and remove roles. */
|
|
26
|
+
export function UserRoles({ user }: { user: { id: string; name: string } }) {
|
|
27
|
+
return <UserRolesSection client={client} userId={user.id} userName={user.name} />;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The roles nav item's icon (a shield). */
|
|
31
|
+
export function RolesIcon(props: { className?: string; "aria-hidden"?: boolean | "true" }) {
|
|
32
|
+
return (
|
|
33
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" {...props}>
|
|
34
|
+
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
|
35
|
+
<path d="m9 12 2 2 4-4" />
|
|
36
|
+
</svg>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { env } from "cloudflare:workers";
|
|
2
|
+
import { beforeEach, expect, it } from "vitest";
|
|
3
|
+
import { auditLog } from "../src/audit-log.js";
|
|
4
|
+
import { choicesWith, json, migrate, newUser, seedAdmin, seedUser, signIn, testApp } from "./auth-fixtures.js";
|
|
5
|
+
|
|
6
|
+
beforeEach(async () => {
|
|
7
|
+
await migrate();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("an administrator's role assignment is recorded in the audit trail", async () => {
|
|
11
|
+
const app = testApp(choicesWith("invite-only"));
|
|
12
|
+
const user = newUser();
|
|
13
|
+
await seedUser(user);
|
|
14
|
+
const cookie = await signIn(app, user);
|
|
15
|
+
const userId = (await (await app.call("/api/me", { headers: { cookie } })).json<{ user: { id: string } }>()).user.id;
|
|
16
|
+
const admin = await seedAdmin();
|
|
17
|
+
|
|
18
|
+
const granted = await app.call(`/api/admin/users/${userId}/roles/viewer`, { ...json({}, { cookie: admin }), method: "PUT" });
|
|
19
|
+
expect(granted.status).toBe(200);
|
|
20
|
+
|
|
21
|
+
const { items } = await auditLog(env).list({ action: "access.role.assigned", subjectId: `user:${userId}` });
|
|
22
|
+
expect(items).toHaveLength(1);
|
|
23
|
+
expect(items[0]).toMatchObject({ action: "access.role.assigned", metadata: { roleId: "viewer" } });
|
|
24
|
+
expect(items[0]?.actorId).not.toBeNull();
|
|
25
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { expect, test, type Page } from "@playwright/test";
|
|
2
|
+
import { signInAsAdmin } from "./admin-helpers.js";
|
|
3
|
+
import { adminsCanSignIn, invitationsAllowed, USERS } from "./auth-seeded.js";
|
|
4
|
+
import { expectNoPageScroll, expectNoViolations, THEMES, VIEWPORTS } from "./axe.js";
|
|
5
|
+
|
|
6
|
+
// Zero axe violations (WCAG 2.2 AA, plan Q56) on every admin page, the refusal and the
|
|
7
|
+
// phone navigation, in light and dark at desktop and phone sizes. Read-only.
|
|
8
|
+
|
|
9
|
+
test.skip(!adminsCanSignIn, "administrators need email + password and 2FA (src/auth-choices.ts)");
|
|
10
|
+
|
|
11
|
+
interface Screen {
|
|
12
|
+
path: string | ((page: Page) => Promise<void>);
|
|
13
|
+
heading: string;
|
|
14
|
+
on?: boolean;
|
|
15
|
+
/** Something that shows the page has its data. */
|
|
16
|
+
ready?: (page: Page) => Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const usersTable = (page: Page) => page.getByRole("table", { name: "Users" });
|
|
20
|
+
|
|
21
|
+
const SCREENS: Screen[] = [
|
|
22
|
+
// The account page, with its link to the admin area.
|
|
23
|
+
{ path: "/account", heading: "Your account", ready: (p) => expect(p.getByRole("link", { name: "Open the admin area" })).toBeVisible() },
|
|
24
|
+
{ path: "/admin", heading: "Dashboard", ready: (p) => expect(p.getByText("Total users")).toBeVisible() },
|
|
25
|
+
{ path: "/admin/users", heading: "Users", ready: (p) => expect(usersTable(p).getByRole("rowheader").first()).toBeVisible() },
|
|
26
|
+
{
|
|
27
|
+
path: async (page) => {
|
|
28
|
+
await page.goto("/admin/users");
|
|
29
|
+
await usersTable(page).getByRole("link", { name: USERS.admin.name }).click();
|
|
30
|
+
},
|
|
31
|
+
heading: USERS.admin.name,
|
|
32
|
+
ready: (p) => expect(p.getByRole("region", { name: "Two-factor authentication" })).toContainText("Enrolled"),
|
|
33
|
+
},
|
|
34
|
+
{ path: "/admin/invitations", heading: "Invitations", on: invitationsAllowed },
|
|
35
|
+
{ path: "/admin/settings", heading: "Auth settings", ready: (p) => expect(p.getByRole("table", { name: "Methods and providers" })).toBeVisible() },
|
|
36
|
+
{ path: "/admin/example", heading: "Example page", ready: (p) => expect(p.getByLabel("Name")).toBeVisible() },
|
|
37
|
+
{ path: "/admin/nowhere", heading: "Page not found" },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/** The page's heading, with nothing left loading. */
|
|
41
|
+
async function settled(page: Page, heading: string) {
|
|
42
|
+
await expect(page.getByRole("heading", { level: 1, name: heading })).toBeVisible();
|
|
43
|
+
await expect(page.getByRole("status").filter({ hasText: /^Loading/ })).toHaveCount(0);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
for (const theme of THEMES) {
|
|
47
|
+
for (const [viewport, size] of Object.entries(VIEWPORTS)) {
|
|
48
|
+
test.describe(`${theme}, ${viewport}`, () => {
|
|
49
|
+
test.use({ viewport: size, colorScheme: theme });
|
|
50
|
+
|
|
51
|
+
test("every admin page has no accessibility violations", async ({ page, baseURL }) => {
|
|
52
|
+
test.setTimeout(120_000);
|
|
53
|
+
await signInAsAdmin(page, baseURL);
|
|
54
|
+
for (const screen of SCREENS.filter((s) => s.on !== false)) {
|
|
55
|
+
await test.step(typeof screen.path === "string" ? screen.path : screen.heading, async () => {
|
|
56
|
+
if (typeof screen.path === "string") await page.goto(screen.path);
|
|
57
|
+
else await screen.path(page);
|
|
58
|
+
await screen.ready?.(page);
|
|
59
|
+
await settled(page, screen.heading);
|
|
60
|
+
await expectNoPageScroll(page);
|
|
61
|
+
await expectNoViolations(page);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
if (viewport === "phone") {
|
|
65
|
+
await test.step("navigation menu", async () => {
|
|
66
|
+
await page.goto("/admin");
|
|
67
|
+
await page.getByRole("button", { name: "Open navigation" }).click();
|
|
68
|
+
const sheet = page.getByRole("dialog", { name: "Navigation" });
|
|
69
|
+
await expect(sheet.getByRole("link", { name: "Example" })).toBeVisible();
|
|
70
|
+
await expectNoViolations(page);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("the refusal has no accessibility violations", async ({ page }) => {
|
|
76
|
+
await page.goto("/sign-in?next=%2Fadmin");
|
|
77
|
+
await page.getByLabel("Email").fill(USERS.axe.email);
|
|
78
|
+
await page.getByLabel("Password").fill(USERS.axe.password);
|
|
79
|
+
await page.getByRole("button", { name: "Sign in" }).click();
|
|
80
|
+
await expect(page.getByRole("heading", { level: 1, name: "Not allowed" })).toBeVisible();
|
|
81
|
+
await expectNoPageScroll(page);
|
|
82
|
+
await expectNoViolations(page);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { expect, test, type Page } from "@playwright/test";
|
|
2
|
+
import { totpCode } from "../test/totp.js";
|
|
3
|
+
import { adminsCreateUsers, newUser, patchAuthSettings, signInAsAdmin } from "./admin-helpers.js";
|
|
4
|
+
import { adminApi, signInWithPassword } from "./auth-helpers.js";
|
|
5
|
+
import { ADMIN_TOTP_SECRET, adminsCanSignIn, USERS } from "./auth-seeded.js";
|
|
6
|
+
|
|
7
|
+
// The admin area (/admin): gated on the administrator role before any admin code loads
|
|
8
|
+
// (plan Q16), the auth administration pages, the app's own example page, and a user
|
|
9
|
+
// action that needs a recent sign-in, refused and run again after signing in.
|
|
10
|
+
|
|
11
|
+
test.skip(!adminsCanSignIn, "administrators need email + password and 2FA (src/auth-choices.ts)");
|
|
12
|
+
test.describe.configure({ mode: "serial" });
|
|
13
|
+
|
|
14
|
+
const dashboard = (page: Page) => page.getByRole("heading", { level: 1, name: "Dashboard" });
|
|
15
|
+
|
|
16
|
+
test("a signed-out visitor is sent to sign in, and an administrator comes back to the admin area", async ({ page }) => {
|
|
17
|
+
await page.goto("/admin");
|
|
18
|
+
await expect(page).toHaveURL(/\/sign-in\?next=%2Fadmin$/);
|
|
19
|
+
await signInWithPassword(page, USERS.admin, "/admin");
|
|
20
|
+
await expect(page.getByRole("heading", { name: "Two-factor authentication" })).toBeVisible();
|
|
21
|
+
await page.getByLabel("6-digit code").fill(await totpCode(ADMIN_TOTP_SECRET));
|
|
22
|
+
await page.getByRole("button", { name: "Verify" }).click();
|
|
23
|
+
|
|
24
|
+
await expect(page).toHaveURL(/\/admin$/);
|
|
25
|
+
await expect(dashboard(page)).toBeVisible();
|
|
26
|
+
await expect(page.getByText("Total users")).toBeVisible();
|
|
27
|
+
// Without the audit module, recent activity is auth's own history; it loads either way.
|
|
28
|
+
await expect(page.getByRole("list", { name: "Recent activity" }).or(page.getByText("No activity yet"))).toBeVisible();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("a regular user is refused the admin area and never downloads its code", async ({ page }) => {
|
|
32
|
+
const adminCode: string[] = [];
|
|
33
|
+
page.on("request", (request) => {
|
|
34
|
+
if (/admin-area|inbis-admin-ui/.test(request.url())) adminCode.push(request.url());
|
|
35
|
+
});
|
|
36
|
+
await signInWithPassword(page, USERS.axe, "/account");
|
|
37
|
+
await expect(page.getByRole("heading", { level: 1, name: "Your account" })).toBeVisible();
|
|
38
|
+
await expect(page.getByRole("link", { name: "Open the admin area" })).toHaveCount(0);
|
|
39
|
+
|
|
40
|
+
await page.goto("/admin");
|
|
41
|
+
await expect(page.getByRole("heading", { level: 1, name: "Not allowed" })).toBeVisible();
|
|
42
|
+
await expect(page.getByText("The admin area is for administrators.")).toBeVisible();
|
|
43
|
+
await expect(dashboard(page)).toHaveCount(0);
|
|
44
|
+
expect(adminCode).toEqual([]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("an administrator goes from the account page to the admin area, its pages and the app's own page", async ({ page, baseURL }) => {
|
|
48
|
+
await signInAsAdmin(page, baseURL);
|
|
49
|
+
await page.goto("/account");
|
|
50
|
+
await page.getByRole("link", { name: "Open the admin area" }).click();
|
|
51
|
+
await expect(dashboard(page)).toBeVisible();
|
|
52
|
+
|
|
53
|
+
const nav = page.getByRole("navigation", { name: "Administration" });
|
|
54
|
+
await nav.getByRole("link", { name: "Users" }).click();
|
|
55
|
+
await expect(page.getByRole("heading", { level: 1, name: "Users" })).toBeVisible();
|
|
56
|
+
await expect(page.getByRole("table", { name: "Users" }).getByRole("link", { name: USERS.admin.name })).toBeVisible();
|
|
57
|
+
|
|
58
|
+
await nav.getByRole("link", { name: "Auth settings" }).click();
|
|
59
|
+
await expect(page.getByRole("heading", { level: 1, name: "Auth settings" })).toBeVisible();
|
|
60
|
+
|
|
61
|
+
// The app's own admin page (web/src/admin-example.tsx), on the app's own API.
|
|
62
|
+
await nav.getByRole("link", { name: "Example" }).click();
|
|
63
|
+
await expect(page.getByRole("heading", { level: 1, name: "Example page" })).toBeVisible();
|
|
64
|
+
await page.getByLabel("Name").fill("Ada");
|
|
65
|
+
await page.getByRole("button", { name: "Greet" }).click();
|
|
66
|
+
await expect(page.getByRole("status").filter({ hasText: "Hello, Ada" })).toBeVisible();
|
|
67
|
+
|
|
68
|
+
await page.getByRole("link", { name: "Back to the app" }).click();
|
|
69
|
+
await expect(page).toHaveURL(/\/$/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("a ban refused for a stale sign-in runs again once the administrator has signed in again", async ({ page, baseURL, playwright }) => {
|
|
73
|
+
test.skip(!adminsCreateUsers, "registration is closed: administrators create no accounts");
|
|
74
|
+
test.setTimeout(300_000);
|
|
75
|
+
const target = await newUser(playwright, baseURL, "Stale Ban");
|
|
76
|
+
// The shortest re-authentication window for administrators (60 s), so the page's sign-in goes stale.
|
|
77
|
+
const setup = await adminApi(playwright, baseURL);
|
|
78
|
+
await patchAuthSettings(setup, { sessions: { admin: { freshAge: 60 } } });
|
|
79
|
+
await setup.dispose();
|
|
80
|
+
try {
|
|
81
|
+
await signInAsAdmin(page, baseURL);
|
|
82
|
+
await page.goto(`/admin/users?q=${encodeURIComponent(target.name)}`);
|
|
83
|
+
await page.getByRole("table", { name: "Users" }).getByRole("link", { name: target.name }).click();
|
|
84
|
+
await expect(page.getByRole("heading", { level: 1, name: target.name })).toBeVisible();
|
|
85
|
+
// Waited in the test runner: page.waitForTimeout runs on the page's (throttled) timers.
|
|
86
|
+
await new Promise((resolve) => setTimeout(resolve, 61_000));
|
|
87
|
+
|
|
88
|
+
await page.getByRole("button", { name: "Ban user" }).click();
|
|
89
|
+
const dialog = page.getByRole("dialog", { name: `Ban ${target.name}?` });
|
|
90
|
+
await dialog.getByRole("button", { name: "Ban user" }).click();
|
|
91
|
+
|
|
92
|
+
// SESSION_NOT_FRESH: the dialog steps aside and the administrator signs in again right here.
|
|
93
|
+
await expect(page.getByRole("heading", { name: "Sign in" })).toBeVisible();
|
|
94
|
+
const signIn = page.locator("form").filter({ has: page.getByRole("button", { name: "Sign in" }) });
|
|
95
|
+
await signIn.getByLabel("Email", { exact: true }).fill(USERS.admin.email);
|
|
96
|
+
await signIn.getByLabel("Password", { exact: true }).fill(USERS.admin.password);
|
|
97
|
+
await signIn.getByRole("button", { name: "Sign in" }).click();
|
|
98
|
+
await page.getByLabel("6-digit code").fill(await totpCode(ADMIN_TOTP_SECRET));
|
|
99
|
+
await page.getByRole("button", { name: "Verify", exact: true }).click();
|
|
100
|
+
|
|
101
|
+
// The refused ban ran again.
|
|
102
|
+
await expect(page.getByRole("region", { name: /Notifications/ })).toContainText(`${target.name} is banned`);
|
|
103
|
+
await expect(page.getByRole("button", { name: "Unban user" })).toBeVisible();
|
|
104
|
+
} finally {
|
|
105
|
+
const restore = await adminApi(playwright, baseURL);
|
|
106
|
+
await patchAuthSettings(restore, { sessions: { admin: { freshAge: null } } });
|
|
107
|
+
await restore.dispose();
|
|
108
|
+
}
|
|
109
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { expect, type APIRequestContext, type Page, type PlaywrightWorkerArgs } from "@playwright/test";
|
|
2
|
+
import { totpCode } from "../test/totp.js";
|
|
3
|
+
import { adminApi } from "./auth-helpers.js";
|
|
4
|
+
import { ADMIN_TOTP_SECRET, AUTH_CHOICES, TURNSTILE_TEST_TOKEN, USERS } from "./auth-seeded.js";
|
|
5
|
+
|
|
6
|
+
/** Whether administrators may create accounts (every registration mode but `closed`). */
|
|
7
|
+
export const adminsCreateUsers = AUTH_CHOICES.registration !== "closed";
|
|
8
|
+
|
|
9
|
+
/** Signs the page in as the seeded administrator (password, then the authenticator code), without the UI. */
|
|
10
|
+
export async function signInAsAdmin(page: Page, baseURL: string | undefined): Promise<void> {
|
|
11
|
+
const headers = { origin: baseURL ?? "", "x-captcha-response": TURNSTILE_TEST_TOKEN };
|
|
12
|
+
const signIn = await page.request.post("/api/auth/sign-in/email", { data: { email: USERS.admin.email, password: USERS.admin.password }, headers });
|
|
13
|
+
expect(signIn.status()).toBe(200);
|
|
14
|
+
const verified = await page.request.post("/api/auth/two-factor/verify-totp", { data: { code: await totpCode(ADMIN_TOTP_SECRET) }, headers });
|
|
15
|
+
expect(verified.status()).toBe(200);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let counter = 0;
|
|
19
|
+
|
|
20
|
+
/** A new user (unique name and email), created by the seeded administrator through the API. */
|
|
21
|
+
export async function newUser(playwright: PlaywrightWorkerArgs["playwright"], baseURL: string | undefined, label: string) {
|
|
22
|
+
counter += 1;
|
|
23
|
+
const stamp = `${String(Date.now())}${String(counter)}`;
|
|
24
|
+
const user = { name: `${label} ${stamp}`, email: `${label.toLowerCase().replace(/\s+/g, "-")}-${stamp}@example.test` };
|
|
25
|
+
const admin = await adminApi(playwright, baseURL);
|
|
26
|
+
const created = await admin.post("/api/auth/admin/create-user", {
|
|
27
|
+
data: { ...user, password: "a long enough password", data: { emailVerified: true } },
|
|
28
|
+
});
|
|
29
|
+
expect(created.status(), await created.text()).toBe(200);
|
|
30
|
+
await admin.dispose();
|
|
31
|
+
return user;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Changes runtime auth settings (a JSON merge patch of the overrides) as the administrator. */
|
|
35
|
+
export async function patchAuthSettings(admin: APIRequestContext, settings: unknown): Promise<void> {
|
|
36
|
+
const { version } = (await (await admin.get("/api/admin/auth-settings")).json()) as { version: number };
|
|
37
|
+
const response = await admin.patch("/api/admin/auth-settings", { data: { version, settings } });
|
|
38
|
+
expect(response.status(), await response.text()).toBe(200);
|
|
39
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ApiEnv } from "@awsmbiz/inbis-api";
|
|
2
|
+
import {
|
|
3
|
+
mountAuthAudit,
|
|
4
|
+
mountAuthSettings,
|
|
5
|
+
mountAuthStats,
|
|
6
|
+
mountAuthUsers,
|
|
7
|
+
type MountAuthAuditOptions,
|
|
8
|
+
type MountAuthStatsOptions,
|
|
9
|
+
} from "@awsmbiz/inbis-auth/hono";
|
|
10
|
+
import type { Hono } from "hono";
|
|
11
|
+
// inbis:wire admin.server.imports
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Dashboard counts (`GET /api/admin/auth-stats`). Without an audit trail, auth counts failed
|
|
15
|
+
* sign-ins in its interim history and active users from sessions; with one, the trail counts.
|
|
16
|
+
*/
|
|
17
|
+
const stats: MountAuthStatsOptions<ApiEnv<Env>> = {
|
|
18
|
+
// inbis:wire admin.stats
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** The auth audit trail (`GET /api/admin/auth-audit-events`): auth's interim history, or the app's audit trail. */
|
|
22
|
+
const authAudit: MountAuthAuditOptions<ApiEnv<Env>> = {
|
|
23
|
+
// inbis:wire admin.authAudit
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Mounts the admin API the admin area (`/admin`) reads, after `mountAppAuth` (its session
|
|
28
|
+
* middleware covers these paths). Every route applies the admin policy (an administrator
|
|
29
|
+
* with 2FA, ADR-3); changes also need a recent sign-in (`403 SESSION_NOT_FRESH`).
|
|
30
|
+
*
|
|
31
|
+
* - `GET /api/admin/users[/:userId]`, `POST /api/admin/users/:userId/two-factor/reset`
|
|
32
|
+
* - `GET /api/admin/auth-stats` (dashboard counts)
|
|
33
|
+
* - `GET|PATCH /api/admin/auth-settings`, `GET /api/admin/auth-providers/status`
|
|
34
|
+
* - `GET /api/admin/auth-audit-events`
|
|
35
|
+
*
|
|
36
|
+
* Better Auth's admin routes (`/api/auth/admin/*`: create user, ban, set role, delete,
|
|
37
|
+
* sessions) and the invitation routes come with `mountAppAuth`. Your own admin routes go
|
|
38
|
+
* next to this call in `src/app.ts`, guarded with `requireAdmin()` (and
|
|
39
|
+
* `requireRecentAdminSignIn()` for changes) from `@awsmbiz/inbis-auth/hono`.
|
|
40
|
+
*/
|
|
41
|
+
export function mountAppAdmin(app: Hono<ApiEnv<Env>>): void {
|
|
42
|
+
mountAuthUsers(app);
|
|
43
|
+
mountAuthStats(app, stats);
|
|
44
|
+
mountAuthSettings(app);
|
|
45
|
+
mountAuthAudit(app, authAudit);
|
|
46
|
+
}
|