@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 @@
|
|
|
1
|
+
import { mountAppAuth, type AuthDeps } from "./auth.js";
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { expectNoPageScroll, expectNoViolations, THEMES, VIEWPORTS } from "./axe.js";
|
|
3
|
+
// inbis:wire example.e2e.imports
|
|
4
|
+
|
|
5
|
+
// EXAMPLE CODE (example module): the notes screen (web/src/example-notes.tsx), as someone
|
|
6
|
+
// allowed to read and add notes. Delete it with the example.
|
|
7
|
+
|
|
8
|
+
// inbis:wire example.e2e.setup
|
|
9
|
+
|
|
10
|
+
test("someone adds a note and finds it at the top of the latest notes", async ({ page }) => {
|
|
11
|
+
const title = `Note ${String(Date.now())}`;
|
|
12
|
+
await page.goto("/notes");
|
|
13
|
+
await expect(page.getByRole("heading", { name: "Notes", level: 1 })).toBeVisible();
|
|
14
|
+
await page.getByLabel("Title").fill(title);
|
|
15
|
+
await page.getByLabel("Note", { exact: true }).fill("Written by the end-to-end test.");
|
|
16
|
+
await page.getByRole("button", { name: "Add note" }).click();
|
|
17
|
+
await expect(page.getByRole("status")).toHaveText(`Added “${title}”.`);
|
|
18
|
+
await expect(page.getByLabel("Title")).toHaveValue("");
|
|
19
|
+
const latest = page.getByRole("list", { name: "Latest notes" }).getByRole("listitem").first();
|
|
20
|
+
await expect(latest).toContainText(title);
|
|
21
|
+
|
|
22
|
+
await page.reload();
|
|
23
|
+
await expect(page.getByRole("list", { name: "Latest notes" }).getByRole("listitem").first()).toContainText(title);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
for (const theme of THEMES) {
|
|
27
|
+
for (const [viewport, size] of Object.entries(VIEWPORTS)) {
|
|
28
|
+
test(`Notes (${theme}, ${viewport}) has no accessibility violations`, async ({ page }) => {
|
|
29
|
+
await page.emulateMedia({ colorScheme: theme });
|
|
30
|
+
await page.setViewportSize(size);
|
|
31
|
+
await page.goto("/notes");
|
|
32
|
+
await expect(page.getByRole("heading", { name: "Notes", level: 1 })).toBeVisible();
|
|
33
|
+
await expect(page.getByText("Loading notes…")).toBeHidden();
|
|
34
|
+
await expect(page.getByRole("alert")).toHaveCount(0);
|
|
35
|
+
await expectNoPageScroll(page);
|
|
36
|
+
await expectNoViolations(page);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
-- EXAMPLE CODE (example module): the notes table. To remove the example from an app that
|
|
2
|
+
-- has applied this migration, add a new migration that drops the table; never edit or
|
|
3
|
+
-- delete an applied one.
|
|
4
|
+
CREATE TABLE `example_note` (
|
|
5
|
+
`id` text PRIMARY KEY NOT NULL,
|
|
6
|
+
`title` text NOT NULL,
|
|
7
|
+
`body` text NOT NULL,
|
|
8
|
+
`author_id` text,
|
|
9
|
+
`created_at` integer NOT NULL,
|
|
10
|
+
`updated_at` integer NOT NULL
|
|
11
|
+
);
|
|
12
|
+
CREATE INDEX `example_note_created_at_idx` ON `example_note` (`created_at`);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// EXAMPLE CODE (example module): delete it with the rest of the notes example.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The audit actions the notes example records (src/example-notes.ts). With the audit module
|
|
5
|
+
* they are registered with the app's audit trail (src/audit-catalogues.ts); without it
|
|
6
|
+
* nothing is recorded. Kept in a file of its own, with no imports, so the audit trail can
|
|
7
|
+
* register it without an import cycle.
|
|
8
|
+
*/
|
|
9
|
+
export const EXAMPLE_AUDIT_ACTIONS = {
|
|
10
|
+
noteCreated: "example.note.created",
|
|
11
|
+
noteDeleted: "example.note.deleted",
|
|
12
|
+
} as const;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { createRoute, z, type OpenAPIHono } from "@hono/zod-openapi";
|
|
2
|
+
import type { ApiEnv } from "@awsmbiz/inbis-api";
|
|
3
|
+
import { requireBinding } from "@awsmbiz/inbis-cloudflare";
|
|
4
|
+
import { createDatabase, idColumn, timestampColumns } from "@awsmbiz/inbis-db";
|
|
5
|
+
import { desc } from "drizzle-orm";
|
|
6
|
+
import { sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
7
|
+
import type { Context, MiddlewareHandler } from "hono";
|
|
8
|
+
import type { AppDeps } from "./app.js";
|
|
9
|
+
import { auditSinkFor } from "./audit-sink.js";
|
|
10
|
+
import { EXAMPLE_AUDIT_ACTIONS } from "./example-audit-actions.js";
|
|
11
|
+
// inbis:wire example.imports
|
|
12
|
+
|
|
13
|
+
// EXAMPLE CODE (example module). A small business feature, notes, showing the pattern for
|
|
14
|
+
// your own: a table in the app's migrations (migrations/0002_example_notes.sql), validated
|
|
15
|
+
// routes documented in /openapi.json, a screen (web/src/example-notes.tsx), and, when the
|
|
16
|
+
// app has them, a permission check, audit events and an admin page. Copy it for a feature
|
|
17
|
+
// of your own, or delete it: these files, the `mountExampleNotes` call in src/app.ts, the
|
|
18
|
+
// `/notes` route in web/src/routes.tsx and the `notes.*` messages (plus a migration that
|
|
19
|
+
// drops the table if one was applied).
|
|
20
|
+
|
|
21
|
+
/** The notes table (migrations/0002_example_notes.sql). */
|
|
22
|
+
export const exampleNote = sqliteTable("example_note", {
|
|
23
|
+
id: idColumn(),
|
|
24
|
+
title: text("title").notNull(),
|
|
25
|
+
body: text("body").notNull(),
|
|
26
|
+
/** Who added it: the signed-in user's ID, or null where the routes do not need sign-in. */
|
|
27
|
+
authorId: text("author_id"),
|
|
28
|
+
...timestampColumns(),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export const NoteSchema = z
|
|
32
|
+
.object({
|
|
33
|
+
id: z.string(),
|
|
34
|
+
title: z.string(),
|
|
35
|
+
body: z.string(),
|
|
36
|
+
authorId: z.string().nullable(),
|
|
37
|
+
createdAt: z.string().openapi({ format: "date-time" }),
|
|
38
|
+
})
|
|
39
|
+
.openapi("ExampleNote");
|
|
40
|
+
|
|
41
|
+
const NewNoteSchema = z
|
|
42
|
+
.object({
|
|
43
|
+
title: z.string().trim().min(1).max(120),
|
|
44
|
+
body: z.string().trim().max(2000).default(""),
|
|
45
|
+
})
|
|
46
|
+
.openapi("NewExampleNote");
|
|
47
|
+
|
|
48
|
+
const NoteListSchema = z.object({ items: z.array(NoteSchema) }).openapi("ExampleNoteList");
|
|
49
|
+
|
|
50
|
+
/** The newest notes a list returns. A real feature would paginate (`parsePageRequest` in @awsmbiz/inbis-db). */
|
|
51
|
+
const LIST_LIMIT = 50;
|
|
52
|
+
|
|
53
|
+
type AppContext = Context<ApiEnv<Env>>;
|
|
54
|
+
|
|
55
|
+
/** A row as the API returns it. */
|
|
56
|
+
export function toNote(row: typeof exampleNote.$inferSelect): z.infer<typeof NoteSchema> {
|
|
57
|
+
return { id: row.id, title: row.title, body: row.body, authorId: row.authorId, createdAt: row.createdAt.toISOString() };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The app's database (binding DB). */
|
|
61
|
+
export const notesDatabase = (c: AppContext) => createDatabase(requireBinding(c.env, "DB"));
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Records an event in the app's audit trail when it has one (the audit module); without
|
|
65
|
+
* one, `auditSinkFor` is `undefined` and nothing is recorded.
|
|
66
|
+
*/
|
|
67
|
+
export async function recordNoteEvent(c: AppContext, action: string, actorId: string | null, noteId: string): Promise<void> {
|
|
68
|
+
await auditSinkFor(c.env, { logger: c.var.logger })?.record({
|
|
69
|
+
action,
|
|
70
|
+
actorId,
|
|
71
|
+
subjectId: `example_note:${noteId}`,
|
|
72
|
+
occurredAt: new Date(),
|
|
73
|
+
requestId: c.var.requestId,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Who may call the notes routes: middleware before reading and before adding, and who the caller is. */
|
|
78
|
+
interface NotesPolicy {
|
|
79
|
+
read: MiddlewareHandler[];
|
|
80
|
+
write: MiddlewareHandler[];
|
|
81
|
+
/** The signed-in user's ID, recorded as the note's author and the audit event's actor. */
|
|
82
|
+
actorOf: (c: AppContext) => string | null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Rules that tighten the policy, in module order (each may use the app's deps, e.g. `deps.auth`). */
|
|
86
|
+
const policyRules: ((policy: NotesPolicy, deps: AppDeps) => void)[] = [
|
|
87
|
+
// inbis:wire example.policy
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Mounts `GET /v1/example/notes` (the newest notes) and `POST /v1/example/notes` (add one;
|
|
92
|
+
* `422 VALIDATION_FAILED` on an invalid body). Without access control anyone may call
|
|
93
|
+
* them; with it, the caller must be signed in and hold `example.notes.read` /
|
|
94
|
+
* `example.notes.write` (src/access-control.ts).
|
|
95
|
+
*/
|
|
96
|
+
export function mountExampleNotes(app: OpenAPIHono<ApiEnv<Env>>, deps: AppDeps): void {
|
|
97
|
+
const policy: NotesPolicy = { read: [], write: [], actorOf: () => null };
|
|
98
|
+
for (const rule of policyRules) rule(policy, deps);
|
|
99
|
+
|
|
100
|
+
const list = createRoute({
|
|
101
|
+
method: "get",
|
|
102
|
+
path: "/v1/example/notes",
|
|
103
|
+
tags: ["example"],
|
|
104
|
+
summary: "The newest example notes (example code: replace it with your own)",
|
|
105
|
+
middleware: policy.read,
|
|
106
|
+
responses: {
|
|
107
|
+
200: { description: "Up to 50 notes, newest first", content: { "application/json": { schema: NoteListSchema } } },
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const add = createRoute({
|
|
112
|
+
method: "post",
|
|
113
|
+
path: "/v1/example/notes",
|
|
114
|
+
tags: ["example"],
|
|
115
|
+
summary: "Add an example note (example code: replace it with your own)",
|
|
116
|
+
middleware: policy.write,
|
|
117
|
+
request: { body: { required: true, content: { "application/json": { schema: NewNoteSchema } } } },
|
|
118
|
+
responses: {
|
|
119
|
+
201: { description: "The note added", content: { "application/json": { schema: NoteSchema } } },
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
app.openapi(list, async (c) => {
|
|
124
|
+
const rows = await notesDatabase(c).select().from(exampleNote).orderBy(desc(exampleNote.createdAt), desc(exampleNote.id)).limit(LIST_LIMIT);
|
|
125
|
+
return c.json({ items: rows.map(toNote) }, 200);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
app.openapi(add, async (c) => {
|
|
129
|
+
const { title, body } = c.req.valid("json");
|
|
130
|
+
const authorId = policy.actorOf(c);
|
|
131
|
+
const [row] = await notesDatabase(c).insert(exampleNote).values({ title, body, authorId }).returning();
|
|
132
|
+
if (row === undefined) throw new Error("The note was not stored.");
|
|
133
|
+
await recordNoteEvent(c, EXAMPLE_AUDIT_ACTIONS.noteCreated, authorId, row.id);
|
|
134
|
+
return c.json(toNote(row), 201);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { exports } from "cloudflare:workers";
|
|
2
|
+
// inbis:wire example.test.imports
|
|
3
|
+
|
|
4
|
+
// EXAMPLE CODE (example module): delete it with the rest of the notes example, or copy it.
|
|
5
|
+
|
|
6
|
+
/** The app's origin in tests (also sent as `Origin` on a signed-in caller's changes). */
|
|
7
|
+
const ORIGIN = "https://app.example.test";
|
|
8
|
+
|
|
9
|
+
/** Calls the app over HTTP. */
|
|
10
|
+
export type Call = (path: string, init?: RequestInit) => Promise<Response>;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Steps that turn the anonymous caller into one allowed to read and add notes, in order
|
|
14
|
+
* (with access control: a signed-in user holding the `notes-editor` role).
|
|
15
|
+
*/
|
|
16
|
+
const steps: ((caller: { call: Call }) => Promise<void>)[] = [
|
|
17
|
+
// inbis:wire example.test.caller
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/** A caller allowed to read and add notes. Without access control anyone may. */
|
|
21
|
+
export async function notesCaller(): Promise<Call> {
|
|
22
|
+
const caller: { call: Call } = { call: (path, init) => exports.default.fetch(new Request(`${ORIGIN}${path}`, init)) };
|
|
23
|
+
for (const step of steps) await step(caller);
|
|
24
|
+
return caller.call;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A JSON POST body. */
|
|
28
|
+
export function postJson(body: unknown): RequestInit {
|
|
29
|
+
return { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) };
|
|
30
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { env } from "cloudflare:workers";
|
|
2
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { notesCaller, postJson } from "./example-notes-fixtures.js";
|
|
4
|
+
import { migrate } from "./migrate.js";
|
|
5
|
+
|
|
6
|
+
// EXAMPLE CODE (example module): the notes API over HTTP. Delete it with the example.
|
|
7
|
+
|
|
8
|
+
const NOTES = "/v1/example/notes";
|
|
9
|
+
|
|
10
|
+
interface Note {
|
|
11
|
+
id: string;
|
|
12
|
+
title: string;
|
|
13
|
+
body: string;
|
|
14
|
+
authorId: string | null;
|
|
15
|
+
createdAt: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
beforeEach(async () => {
|
|
19
|
+
await migrate();
|
|
20
|
+
await env.DB.prepare("DELETE FROM example_note").run();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("the example notes API", () => {
|
|
24
|
+
it("adds a note and lists it, newest first", async () => {
|
|
25
|
+
const call = await notesCaller();
|
|
26
|
+
const first = await call(NOTES, postJson({ title: "First", body: "One" }));
|
|
27
|
+
expect(first.status).toBe(201);
|
|
28
|
+
const created = await first.json<Note>();
|
|
29
|
+
expect(created).toMatchObject({ title: "First", body: "One" });
|
|
30
|
+
expect(Number.isNaN(Date.parse(created.createdAt))).toBe(false);
|
|
31
|
+
await call(NOTES, postJson({ title: "Second" }));
|
|
32
|
+
|
|
33
|
+
const list = await call(NOTES);
|
|
34
|
+
expect(list.status).toBe(200);
|
|
35
|
+
const { items } = await list.json<{ items: Note[] }>();
|
|
36
|
+
expect(items.map((note) => note.title)).toEqual(["Second", "First"]);
|
|
37
|
+
expect(items[0]?.body).toBe("");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("refuses a note without a title", async () => {
|
|
41
|
+
const call = await notesCaller();
|
|
42
|
+
const response = await call(NOTES, postJson({ title: " ", body: "No title" }));
|
|
43
|
+
expect(response.status).toBe(422);
|
|
44
|
+
expect(await response.json()).toMatchObject({ error: { code: "VALIDATION_FAILED" } });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("is documented in the OpenAPI description", async () => {
|
|
48
|
+
const call = await notesCaller();
|
|
49
|
+
const document = await (await call("/openapi.json")).json<{ paths: Record<string, Record<string, unknown>> }>();
|
|
50
|
+
expect(Object.keys(document.paths[NOTES] ?? {}).sort()).toEqual(["get", "post"]);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { Alert, AlertDescription, AlertTitle, Button, Card, CardContent, CardHeader, CardTitle, Input, Label } from "@awsmbiz/inbis-ui";
|
|
2
|
+
import { useEffect, useId, useState, type SyntheticEvent } from "react";
|
|
3
|
+
import { t } from "./messages.js";
|
|
4
|
+
|
|
5
|
+
// EXAMPLE CODE (example module): the notes screen, on GET|POST /v1/example/notes
|
|
6
|
+
// (src/example-notes.ts). Copy it for a screen of your own, or delete it with the example.
|
|
7
|
+
|
|
8
|
+
/** A note as the API returns it. */
|
|
9
|
+
interface Note {
|
|
10
|
+
id: string;
|
|
11
|
+
title: string;
|
|
12
|
+
body: string;
|
|
13
|
+
createdAt: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type ListState = { kind: "loading" } | { kind: "error" } | { kind: "forbidden" } | { kind: "ready"; notes: Note[] };
|
|
17
|
+
|
|
18
|
+
/** 401 (not signed in) and 403 (no permission): only with access control. */
|
|
19
|
+
const refused = (status: number) => status === 401 || status === 403;
|
|
20
|
+
|
|
21
|
+
const dateFormat = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" });
|
|
22
|
+
|
|
23
|
+
const textareaClass =
|
|
24
|
+
"min-h-24 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30";
|
|
25
|
+
|
|
26
|
+
/** The notes page: a form to add a note, then the newest notes. */
|
|
27
|
+
export function ExampleNotes() {
|
|
28
|
+
const [list, setList] = useState<ListState>({ kind: "loading" });
|
|
29
|
+
const [attempt, setAttempt] = useState(0);
|
|
30
|
+
const [title, setTitle] = useState("");
|
|
31
|
+
const [body, setBody] = useState("");
|
|
32
|
+
const [saving, setSaving] = useState(false);
|
|
33
|
+
const [saveError, setSaveError] = useState<"failed" | "forbidden" | null>(null);
|
|
34
|
+
const [saved, setSaved] = useState("");
|
|
35
|
+
const titleId = useId();
|
|
36
|
+
const bodyId = useId();
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
let active = true;
|
|
40
|
+
fetch("/v1/example/notes", { credentials: "include" })
|
|
41
|
+
.then(async (response) => {
|
|
42
|
+
if (!active) return;
|
|
43
|
+
if (refused(response.status)) setList({ kind: "forbidden" });
|
|
44
|
+
else if (!response.ok) setList({ kind: "error" });
|
|
45
|
+
else setList({ kind: "ready", notes: ((await response.json()) as { items: Note[] }).items });
|
|
46
|
+
})
|
|
47
|
+
.catch(() => {
|
|
48
|
+
if (active) setList({ kind: "error" });
|
|
49
|
+
});
|
|
50
|
+
return () => {
|
|
51
|
+
active = false;
|
|
52
|
+
};
|
|
53
|
+
}, [attempt]);
|
|
54
|
+
|
|
55
|
+
const submit = async (event: SyntheticEvent) => {
|
|
56
|
+
event.preventDefault();
|
|
57
|
+
setSaving(true);
|
|
58
|
+
setSaveError(null);
|
|
59
|
+
setSaved("");
|
|
60
|
+
const response = await fetch("/v1/example/notes", {
|
|
61
|
+
method: "POST",
|
|
62
|
+
credentials: "include",
|
|
63
|
+
headers: { "content-type": "application/json" },
|
|
64
|
+
body: JSON.stringify({ title, body }),
|
|
65
|
+
}).catch(() => null);
|
|
66
|
+
setSaving(false);
|
|
67
|
+
if (response?.ok !== true) {
|
|
68
|
+
setSaveError(response !== null && refused(response.status) ? "forbidden" : "failed");
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const note = (await response.json()) as Note;
|
|
72
|
+
setTitle("");
|
|
73
|
+
setBody("");
|
|
74
|
+
setSaved(t("notes.saved", { title: note.title }));
|
|
75
|
+
setList((current) => (current.kind === "ready" ? { kind: "ready", notes: [note, ...current.notes] } : current));
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div className="flex flex-col gap-6">
|
|
80
|
+
<div className="flex flex-col gap-2">
|
|
81
|
+
<h1 className="text-2xl font-semibold tracking-tight">{t("notes.title")}</h1>
|
|
82
|
+
<p className="text-muted-foreground">{t("notes.description")}</p>
|
|
83
|
+
</div>
|
|
84
|
+
<Card>
|
|
85
|
+
<CardHeader>
|
|
86
|
+
<CardTitle>
|
|
87
|
+
<h2>{t("notes.add")}</h2>
|
|
88
|
+
</CardTitle>
|
|
89
|
+
</CardHeader>
|
|
90
|
+
<CardContent>
|
|
91
|
+
<form onSubmit={(event) => void submit(event)} className="flex flex-col gap-4">
|
|
92
|
+
<div className="flex flex-col gap-2">
|
|
93
|
+
<Label htmlFor={titleId}>{t("notes.noteTitle")}</Label>
|
|
94
|
+
<Input
|
|
95
|
+
id={titleId}
|
|
96
|
+
required
|
|
97
|
+
maxLength={120}
|
|
98
|
+
value={title}
|
|
99
|
+
onChange={(event) => {
|
|
100
|
+
setTitle(event.target.value);
|
|
101
|
+
}}
|
|
102
|
+
/>
|
|
103
|
+
</div>
|
|
104
|
+
<div className="flex flex-col gap-2">
|
|
105
|
+
<Label htmlFor={bodyId}>{t("notes.noteBody")}</Label>
|
|
106
|
+
<textarea
|
|
107
|
+
id={bodyId}
|
|
108
|
+
maxLength={2000}
|
|
109
|
+
className={textareaClass}
|
|
110
|
+
value={body}
|
|
111
|
+
onChange={(event) => {
|
|
112
|
+
setBody(event.target.value);
|
|
113
|
+
}}
|
|
114
|
+
/>
|
|
115
|
+
</div>
|
|
116
|
+
{saveError !== null ? (
|
|
117
|
+
<Alert variant="destructive" role="alert">
|
|
118
|
+
<AlertDescription>{t(saveError === "forbidden" ? "notes.saveForbidden" : "notes.saveFailed")}</AlertDescription>
|
|
119
|
+
</Alert>
|
|
120
|
+
) : null}
|
|
121
|
+
<Button type="submit" className="self-start" disabled={saving}>
|
|
122
|
+
{saving ? t("notes.saving") : t("notes.save")}
|
|
123
|
+
</Button>
|
|
124
|
+
{/* Always in the page, so screen readers announce the note added. */}
|
|
125
|
+
<p role="status" className="text-sm text-muted-foreground">
|
|
126
|
+
{saved}
|
|
127
|
+
</p>
|
|
128
|
+
</form>
|
|
129
|
+
</CardContent>
|
|
130
|
+
</Card>
|
|
131
|
+
<section aria-labelledby="notes-list-heading" className="flex flex-col gap-3">
|
|
132
|
+
<h2 id="notes-list-heading" className="text-lg font-semibold">
|
|
133
|
+
{t("notes.listHeading")}
|
|
134
|
+
</h2>
|
|
135
|
+
{list.kind === "loading" ? <p className="text-sm text-muted-foreground">{t("notes.loading")}</p> : null}
|
|
136
|
+
{list.kind === "forbidden" ? (
|
|
137
|
+
<Alert role="alert">
|
|
138
|
+
<AlertTitle>{t("notes.forbidden")}</AlertTitle>
|
|
139
|
+
<AlertDescription>{t("notes.forbiddenHint")}</AlertDescription>
|
|
140
|
+
</Alert>
|
|
141
|
+
) : null}
|
|
142
|
+
{list.kind === "error" ? (
|
|
143
|
+
<>
|
|
144
|
+
<Alert variant="destructive" role="alert">
|
|
145
|
+
<AlertTitle>{t("notes.failed")}</AlertTitle>
|
|
146
|
+
<AlertDescription>{t("notes.failedHint")}</AlertDescription>
|
|
147
|
+
</Alert>
|
|
148
|
+
<Button
|
|
149
|
+
className="self-start"
|
|
150
|
+
onClick={() => {
|
|
151
|
+
setList({ kind: "loading" });
|
|
152
|
+
setAttempt((n) => n + 1);
|
|
153
|
+
}}
|
|
154
|
+
>
|
|
155
|
+
{t("notes.tryAgain")}
|
|
156
|
+
</Button>
|
|
157
|
+
</>
|
|
158
|
+
) : null}
|
|
159
|
+
{list.kind === "ready" && list.notes.length === 0 ? <p>{t("notes.empty")}</p> : null}
|
|
160
|
+
{list.kind === "ready" && list.notes.length > 0 ? (
|
|
161
|
+
<ul aria-labelledby="notes-list-heading" className="flex flex-col divide-y">
|
|
162
|
+
{list.notes.map((note) => (
|
|
163
|
+
<li key={note.id} className="flex flex-col gap-1 py-3 first:pt-0">
|
|
164
|
+
<span className="font-medium break-words">{note.title}</span>
|
|
165
|
+
{note.body === "" ? null : <span className="text-sm break-words whitespace-pre-line">{note.body}</span>}
|
|
166
|
+
<time dateTime={note.createdAt} className="text-sm text-muted-foreground">
|
|
167
|
+
{dateFormat.format(new Date(note.createdAt))}
|
|
168
|
+
</time>
|
|
169
|
+
</li>
|
|
170
|
+
))}
|
|
171
|
+
</ul>
|
|
172
|
+
) : null}
|
|
173
|
+
</section>
|
|
174
|
+
</div>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "example",
|
|
3
|
+
"description": "Example code to copy or delete: a small business feature (notes) with an app migration, validated /v1 routes in the OpenAPI description and a screen; with access-control, permissions and a role on the routes; with audit, audit events; with admin, an admin page.",
|
|
4
|
+
"order": 50,
|
|
5
|
+
"requires": [],
|
|
6
|
+
"points": {
|
|
7
|
+
"example.imports": { "file": "src/example-notes.ts", "description": "Imports for the notes routes' policy rules." },
|
|
8
|
+
"example.policy": { "file": "src/example-notes.ts", "description": "Policy rules `(policy, deps) => { ... }` (comma-terminated): add middleware to `policy.read` / `policy.write`, set `policy.actorOf`." },
|
|
9
|
+
"example.test.imports": { "file": "test/example-notes-fixtures.ts", "description": "Imports for the notes test caller." },
|
|
10
|
+
"example.test.caller": { "file": "test/example-notes-fixtures.ts", "description": "Steps `async (caller) => { ... }` (comma-terminated) that replace `caller.call` with one allowed to read and add notes; `ORIGIN` in scope." },
|
|
11
|
+
"example.e2e.imports": { "file": "e2e/example-notes.spec.ts", "description": "Imports for the notes e2e spec." },
|
|
12
|
+
"example.e2e.setup": { "file": "e2e/example-notes.spec.ts", "description": "Top-level setup of the notes e2e spec: sign in as someone allowed to read and add notes." }
|
|
13
|
+
},
|
|
14
|
+
"wiring": {
|
|
15
|
+
"worker.imports": "wiring/worker.imports.ts",
|
|
16
|
+
"worker.routes": "wiring/worker.routes.ts",
|
|
17
|
+
"web.routes.imports": "wiring/web.routes.imports.tsx",
|
|
18
|
+
"web.routes": "wiring/web.routes.tsx",
|
|
19
|
+
"web.messages": "wiring/web.messages.ts",
|
|
20
|
+
"readme.modules": "wiring/readme.modules.md"
|
|
21
|
+
},
|
|
22
|
+
"when": [
|
|
23
|
+
{
|
|
24
|
+
"modules": ["access-control"],
|
|
25
|
+
"files": "with-access-control",
|
|
26
|
+
"wiring": {
|
|
27
|
+
"access.permissions": "wiring/access-control/access.permissions.ts",
|
|
28
|
+
"access.roles": "wiring/access-control/access.roles.ts",
|
|
29
|
+
"example.imports": "wiring/access-control/example.imports.ts",
|
|
30
|
+
"example.policy": "wiring/access-control/example.policy.ts",
|
|
31
|
+
"example.test.imports": "wiring/access-control/example.test.imports.ts",
|
|
32
|
+
"example.test.caller": "wiring/access-control/example.test.caller.ts",
|
|
33
|
+
"example.e2e.imports": "wiring/access-control/example.e2e.imports.ts",
|
|
34
|
+
"example.e2e.setup": "wiring/access-control/example.e2e.setup.ts",
|
|
35
|
+
"readme.modules": "wiring/access-control/readme.modules.md"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"modules": ["audit"],
|
|
40
|
+
"files": "with-audit",
|
|
41
|
+
"wiring": {
|
|
42
|
+
"audit.catalogues.imports": "wiring/audit/audit.catalogues.imports.ts",
|
|
43
|
+
"audit.catalogues": "wiring/audit/audit.catalogues.ts",
|
|
44
|
+
"readme.modules": "wiring/audit/readme.modules.md"
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"modules": ["admin"],
|
|
49
|
+
"files": "with-admin",
|
|
50
|
+
"wiring": {
|
|
51
|
+
"worker.imports": "wiring/admin/worker.imports.ts",
|
|
52
|
+
"worker.routes": "wiring/admin/worker.routes.ts",
|
|
53
|
+
"admin.imports": "wiring/admin/admin.imports.tsx",
|
|
54
|
+
"admin.pages": "wiring/admin/admin.pages.tsx",
|
|
55
|
+
"admin.nav": "wiring/admin/admin.nav.tsx",
|
|
56
|
+
"web.messages": "wiring/admin/web.messages.ts",
|
|
57
|
+
"readme.modules": "wiring/admin/readme.modules.md"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Access control (access-control module): the notes need sign-in and the notes permissions.
|
|
2
|
+
// The seeded administrator grants itself notes-editor through the role admin API (signed in
|
|
3
|
+
// once, through the API: one authenticator code for the whole file), then browses with that session.
|
|
4
|
+
test.skip(!adminsCanSignIn, "the spec signs in as the seeded administrator: email + password and 2FA (src/auth-choices.ts)");
|
|
5
|
+
|
|
6
|
+
let session: Awaited<ReturnType<Awaited<ReturnType<typeof adminApi>>["storageState"]>>;
|
|
7
|
+
|
|
8
|
+
test.beforeAll(async ({ playwright, baseURL }) => {
|
|
9
|
+
const admin = await adminApi(playwright, baseURL);
|
|
10
|
+
const { user } = (await (await admin.get("/api/me")).json()) as { user: { id: string } };
|
|
11
|
+
expect((await admin.put(`/api/admin/users/${user.id}/roles/notes-editor`)).status()).toBe(200);
|
|
12
|
+
session = await admin.storageState();
|
|
13
|
+
await admin.dispose();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test.beforeEach(async ({ context }) => {
|
|
17
|
+
await context.addCookies(session.cookies);
|
|
18
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Access control (access-control module): signed in, holding example.notes.read to read and
|
|
2
|
+
// example.notes.write to add (src/access-control.ts). /v1 has no session of its own, so the
|
|
3
|
+
// rule adds auth's session middleware first.
|
|
4
|
+
(policy, deps) => {
|
|
5
|
+
if (deps.auth === undefined) throw new Error("createApp needs deps.auth (see src/index.ts).");
|
|
6
|
+
const session = sessionMiddleware(authResolver(deps.auth));
|
|
7
|
+
policy.read.push(session, requireAuth(), requirePermission("example.notes.read"));
|
|
8
|
+
policy.write.push(session, requireAuth(), requirePermission("example.notes.write"));
|
|
9
|
+
policy.actorOf = (c) => (c as unknown as Context<AuthEnv>).var.user?.id ?? null;
|
|
10
|
+
},
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Access control (access-control module): a signed-in user an administrator granted notes-editor.
|
|
2
|
+
async (caller) => {
|
|
3
|
+
const app = testApp(choicesWith("invite-only"));
|
|
4
|
+
const user = newUser();
|
|
5
|
+
await seedUser(user);
|
|
6
|
+
const cookie = await signIn(app, user);
|
|
7
|
+
const { id } = (await (await app.call("/api/me", { headers: { cookie } })).json<{ user: { id: string } }>()).user;
|
|
8
|
+
const admin = await seedAdmin();
|
|
9
|
+
const granted = await app.call(`/api/admin/users/${id}/roles/notes-editor`, { ...json({}, { cookie: admin }), method: "PUT" });
|
|
10
|
+
if (granted.status !== 200) throw new Error(`Granting notes-editor failed: ${String(granted.status)}`);
|
|
11
|
+
caller.call = (path, init = {}) => {
|
|
12
|
+
const headers = new Headers(init.headers);
|
|
13
|
+
headers.set("cookie", cookie);
|
|
14
|
+
headers.set("origin", ORIGIN);
|
|
15
|
+
return app.call(path, { ...init, headers });
|
|
16
|
+
};
|
|
17
|
+
},
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import { choicesWith, json, newUser, seedAdmin, seedUser, signIn, testApp } from "./auth-fixtures.js";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
With access control (the `access-control` module), the notes routes need a signed-in user
|
|
2
|
+
holding `example.notes.read` (to read) or `example.notes.write` (to add), declared in
|
|
3
|
+
`src/access-control.ts` with the `notes-editor` system role that grants both: an
|
|
4
|
+
administrator assigns it to people from the role admin API (or the admin area's Roles pages).
|
|
5
|
+
The rule lives in `policyRules` in `src/example-notes.ts`.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import { NotesAdminPage, NotesIcon } from "./admin-notes.js";
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
With the admin area (the `admin` module), administrators get a **Notes** page at
|
|
2
|
+
`/admin/notes` (`web/src/admin-notes.tsx`) to delete notes, on `GET /api/admin/example/notes`
|
|
3
|
+
and `DELETE /api/admin/example/notes/:noteId` (`src/example-notes-admin.ts`, guarded by
|
|
4
|
+
`requireAdmin()` and, for the deletion, `requireRecentAdminSignIn()`).
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Notes admin page (example module: example code, copy or delete).
|
|
2
|
+
"admin.notes.title": "Notes",
|
|
3
|
+
"admin.notes.description": "The newest notes people added. Delete one that should not be there.",
|
|
4
|
+
"admin.notes.loading": "Loading notes…",
|
|
5
|
+
"admin.notes.failed": "Notes could not be loaded.",
|
|
6
|
+
"admin.notes.empty": "No notes yet.",
|
|
7
|
+
"admin.notes.listLabel": "Notes",
|
|
8
|
+
"admin.notes.delete": "Delete",
|
|
9
|
+
"admin.notes.deleteNote": "Delete “{title}”",
|
|
10
|
+
"admin.notes.confirmTitle": "Delete this note?",
|
|
11
|
+
"admin.notes.confirmDescription": "“{title}” will be deleted for everyone. This can't be undone.",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import { mountExampleNotesAdmin } from "./example-notes-admin.js";
|