@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,47 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { choicesWith, migrate, newUser, seedAdmin, seedUser, signIn, testApp } from "./auth-fixtures.js";
|
|
3
|
+
|
|
4
|
+
// The admin API the admin area (/admin) reads, over HTTP: every route applies the admin
|
|
5
|
+
// policy (an administrator with 2FA). @awsmbiz/inbis-auth's own tests cover each route in
|
|
6
|
+
// detail; these prove the wiring.
|
|
7
|
+
|
|
8
|
+
const ADMIN_ROUTES = ["/api/admin/users", "/api/admin/auth-stats", "/api/admin/auth-settings", "/api/admin/auth-providers/status", "/api/admin/auth-audit-events"];
|
|
9
|
+
|
|
10
|
+
beforeEach(async () => {
|
|
11
|
+
await migrate();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe("the admin API", () => {
|
|
15
|
+
for (const path of ADMIN_ROUTES) {
|
|
16
|
+
it(`GET ${path} needs a signed-in administrator`, async () => {
|
|
17
|
+
const app = testApp(choicesWith("invite-only"));
|
|
18
|
+
expect((await app.call(path)).status).toBe(401);
|
|
19
|
+
|
|
20
|
+
const user = newUser();
|
|
21
|
+
await seedUser(user);
|
|
22
|
+
const cookie = await signIn(app, user);
|
|
23
|
+
const refused = await app.call(path, { headers: { cookie } });
|
|
24
|
+
expect(refused.status).toBe(403);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
it("an administrator with 2FA reads the users, the dashboard counts and the auth settings", async () => {
|
|
29
|
+
const app = testApp(choicesWith("invite-only"));
|
|
30
|
+
const admin = newUser("Grace Admin");
|
|
31
|
+
const cookie = await seedAdmin(admin);
|
|
32
|
+
|
|
33
|
+
const users = await app.call("/api/admin/users?role=admin&limit=100", { headers: { cookie } });
|
|
34
|
+
expect(users.status).toBe(200);
|
|
35
|
+
const { users: admins } = await users.json<{ users: { email: string; role: string }[] }>();
|
|
36
|
+
expect(admins).toContainEqual(expect.objectContaining({ email: admin.email, role: "admin" }));
|
|
37
|
+
|
|
38
|
+
const stats = await app.call("/api/admin/auth-stats", { headers: { cookie } });
|
|
39
|
+
expect(stats.status).toBe(200);
|
|
40
|
+
const counts = await stats.json<{ totalUsers: number; failedSignIns24h: number }>();
|
|
41
|
+
expect(counts.totalUsers).toBeGreaterThanOrEqual(1);
|
|
42
|
+
|
|
43
|
+
const settings = await app.call("/api/admin/auth-settings", { headers: { cookie } });
|
|
44
|
+
expect(settings.status).toBe(200);
|
|
45
|
+
expect(await settings.json()).toHaveProperty("effective");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AdminLink,
|
|
3
|
+
AdminShell,
|
|
4
|
+
AuthDashboardPage,
|
|
5
|
+
AuthInvitationsPage,
|
|
6
|
+
AuthSettingsPage,
|
|
7
|
+
AuthUserActions,
|
|
8
|
+
AuthUserDetailPage,
|
|
9
|
+
AuthUsersPage,
|
|
10
|
+
CreateUserButton,
|
|
11
|
+
type AdminNavItem,
|
|
12
|
+
type AdminPage,
|
|
13
|
+
type AdminPageProps,
|
|
14
|
+
type AdminViewer,
|
|
15
|
+
type StepUpRequest,
|
|
16
|
+
} from "@awsmbiz/inbis-admin-ui";
|
|
17
|
+
import { SignInScreen, StepUpScreen, usePublicAuthConfig } from "@awsmbiz/inbis-auth-ui";
|
|
18
|
+
import { FlaskConical, KeyRound, LayoutDashboard, MailPlus, Users } from "lucide-react";
|
|
19
|
+
import { Fragment, useCallback, useMemo } from "react";
|
|
20
|
+
import { auth, dashboard, getJson } from "./admin-client.js";
|
|
21
|
+
import { ExamplePage } from "./admin-example.js";
|
|
22
|
+
import { client } from "./auth-client.js";
|
|
23
|
+
import { t } from "./messages.js";
|
|
24
|
+
import type { RouteContext } from "./routes.js";
|
|
25
|
+
// inbis:wire admin.imports
|
|
26
|
+
|
|
27
|
+
// The admin area on @awsmbiz/inbis-admin-ui, loaded lazily by AdminGate (admin-gate.tsx)
|
|
28
|
+
// for administrators only. It mounts the platform's auth administration pages and the
|
|
29
|
+
// app's own (admin-example.tsx). To add a page of your own: write a component (see
|
|
30
|
+
// admin-example.tsx), then add it to `pages` and, to list it, to `navItems`.
|
|
31
|
+
|
|
32
|
+
/** A user's page: the platform's sections (profile, sign-in methods, 2FA, sessions), then other modules' (`user` in scope). */
|
|
33
|
+
function UserPage({ params }: AdminPageProps) {
|
|
34
|
+
return (
|
|
35
|
+
<AuthUserDetailPage
|
|
36
|
+
client={auth}
|
|
37
|
+
userId={params["userId"] ?? ""}
|
|
38
|
+
actions={(user) => <AuthUserActions client={auth} user={user} />}
|
|
39
|
+
revokeSessions={auth}
|
|
40
|
+
sections={(user) => (
|
|
41
|
+
// Keyed by the user, so other modules' sections start afresh on another user's page.
|
|
42
|
+
<Fragment key={user.id}>
|
|
43
|
+
{/* inbis:wire admin.userSections */}
|
|
44
|
+
</Fragment>
|
|
45
|
+
)}
|
|
46
|
+
/>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Every admin page, by path under /admin (`:name` is a parameter). The first match wins. */
|
|
51
|
+
const pages: AdminPage[] = [
|
|
52
|
+
{ path: "", title: t("admin.nav.dashboard"), component: () => <AuthDashboardPage client={dashboard} /> },
|
|
53
|
+
{ path: "users", title: t("admin.nav.users"), component: () => <AuthUsersPage client={auth} actions={<CreateUserButton client={auth} />} /> },
|
|
54
|
+
{ path: "users/:userId", title: t("admin.page.user"), component: UserPage },
|
|
55
|
+
{ path: "invitations", title: t("admin.nav.invitations"), component: () => <AuthInvitationsPage client={auth} /> },
|
|
56
|
+
{ path: "settings", title: t("admin.nav.settings"), component: () => <AuthSettingsPage client={auth} /> },
|
|
57
|
+
// inbis:wire admin.pages
|
|
58
|
+
// The app's own pages. `component` may be React.lazy(() => import("./my-page.js")).
|
|
59
|
+
{ path: "example", title: t("admin.nav.example"), component: ExamplePage },
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
/** Whether the app's registration mode lets administrators invite. */
|
|
63
|
+
const invites = (mode: string) => mode === "open" || mode === "invite-only";
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The navigation, in order. `visible(viewer)` hides an item the administrator may not use
|
|
67
|
+
* (e.g. `(viewer) => viewer.permissions.includes("billing.read")`); the server still enforces access.
|
|
68
|
+
*/
|
|
69
|
+
function navItems(invitations: boolean): AdminNavItem[] {
|
|
70
|
+
return [
|
|
71
|
+
{ to: "", label: t("admin.nav.dashboard"), icon: LayoutDashboard },
|
|
72
|
+
{ to: "users", label: t("admin.nav.users"), icon: Users },
|
|
73
|
+
...(invitations ? [{ to: "invitations", label: t("admin.nav.invitations"), icon: MailPlus }] : []),
|
|
74
|
+
{ to: "settings", label: t("admin.nav.settings"), icon: KeyRound },
|
|
75
|
+
// inbis:wire admin.nav
|
|
76
|
+
{ to: "example", label: t("admin.nav.example"), icon: FlaskConical },
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The signed-in administrator, from routes that apply the admin policy: an admin-only call
|
|
82
|
+
* first (refused with TWO_FACTOR_REQUIRED or STEP_UP_REQUIRED until the session meets the
|
|
83
|
+
* policy, which the shell turns into the step-up screen), then GET /api/me.
|
|
84
|
+
*/
|
|
85
|
+
async function loadViewer(): Promise<AdminViewer> {
|
|
86
|
+
await auth.admin.listUsers({ query: { limit: 1 } });
|
|
87
|
+
const { user } = await getJson<{ user: { id: string; name: string; email: string; role: string } }>("/api/me");
|
|
88
|
+
/** Business permissions, for `visible` predicates; none until the app grants some. */
|
|
89
|
+
const permissions: string[] = [];
|
|
90
|
+
// inbis:wire admin.viewer
|
|
91
|
+
return { id: user.id, name: user.name, email: user.email, role: user.role, permissions };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function AdminArea({ path, search, navigate }: RouteContext) {
|
|
95
|
+
const router = useMemo(() => ({ path, search, navigate }), [path, search, navigate]);
|
|
96
|
+
const publicConfig = usePublicAuthConfig(client);
|
|
97
|
+
const invitations = publicConfig.status === "ready" && invites(publicConfig.config.registration.mode);
|
|
98
|
+
const nav = useMemo(() => navItems(invitations), [invitations]);
|
|
99
|
+
const toSignIn = useCallback(() => {
|
|
100
|
+
navigate(`/sign-in?next=${encodeURIComponent(window.location.pathname)}`);
|
|
101
|
+
}, [navigate]);
|
|
102
|
+
const stepUp = useCallback(
|
|
103
|
+
({ reason, retry }: StepUpRequest) =>
|
|
104
|
+
reason.kind === "recent-sign-in" ? (
|
|
105
|
+
// A stale sign-in: sign in again right here (password, then the 2FA code). The new
|
|
106
|
+
// session is fresh, and the refused change runs again with the form as it was.
|
|
107
|
+
<SignInScreen client={client} onSignedIn={retry} />
|
|
108
|
+
) : (
|
|
109
|
+
<StepUpScreen
|
|
110
|
+
client={client}
|
|
111
|
+
requirement={reason}
|
|
112
|
+
onSatisfied={retry}
|
|
113
|
+
onSignInAgain={() => {
|
|
114
|
+
const next = window.location.pathname;
|
|
115
|
+
void client.signOut().then(() => {
|
|
116
|
+
navigate(`/sign-in?next=${encodeURIComponent(next)}`);
|
|
117
|
+
});
|
|
118
|
+
}}
|
|
119
|
+
/>
|
|
120
|
+
),
|
|
121
|
+
[navigate],
|
|
122
|
+
);
|
|
123
|
+
return (
|
|
124
|
+
<AdminShell
|
|
125
|
+
title={t("admin.title")}
|
|
126
|
+
basePath="/admin"
|
|
127
|
+
router={router}
|
|
128
|
+
loadViewer={loadViewer}
|
|
129
|
+
navItems={nav}
|
|
130
|
+
pages={pages}
|
|
131
|
+
stepUp={stepUp}
|
|
132
|
+
onSignInRequired={toSignIn}
|
|
133
|
+
headerEnd={
|
|
134
|
+
<AdminLink to="/" className="rounded-md px-2 py-1 text-sm font-medium underline-offset-4 outline-none hover:underline focus-visible:ring-3 focus-visible:ring-ring/50">
|
|
135
|
+
{t("admin.backToApp")}
|
|
136
|
+
</AdminLink>
|
|
137
|
+
}
|
|
138
|
+
/>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { AuthDashboardClient } from "@awsmbiz/inbis-admin-ui";
|
|
2
|
+
import { ApiError, createAuthAdminClient } from "@awsmbiz/inbis-api-client";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One client for every auth administration page: the admin API (src/admin.ts) and Better
|
|
6
|
+
* Auth's admin routes. Admin pages of other modules import it from here.
|
|
7
|
+
*/
|
|
8
|
+
export const auth = createAuthAdminClient();
|
|
9
|
+
|
|
10
|
+
/** A same-origin JSON GET; failures reject with an `ApiError` (what the admin pages' error states show). */
|
|
11
|
+
export async function getJson<T>(url: string, init: { signal?: AbortSignal } = {}): Promise<T> {
|
|
12
|
+
let response: Response;
|
|
13
|
+
try {
|
|
14
|
+
response = await fetch(url, { credentials: "include", ...(init.signal === undefined ? {} : { signal: init.signal }) });
|
|
15
|
+
} catch (cause) {
|
|
16
|
+
throw ApiError.network(cause);
|
|
17
|
+
}
|
|
18
|
+
if (!response.ok) throw await ApiError.fromResponse(response);
|
|
19
|
+
return (await response.json()) as T;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface AuthHistoryEvent {
|
|
23
|
+
id: string;
|
|
24
|
+
action: string;
|
|
25
|
+
actorId: string | null;
|
|
26
|
+
resource: string | null;
|
|
27
|
+
occurredAt: string;
|
|
28
|
+
requestId: string | null;
|
|
29
|
+
metadata: Record<string, unknown> | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The dashboard's data. Its recent activity reads GET /api/admin/auth-audit-events: auth's
|
|
34
|
+
* own history, or with the audit module the whole audit trail.
|
|
35
|
+
*/
|
|
36
|
+
export const dashboard: AuthDashboardClient = {
|
|
37
|
+
stats: (init) => auth.stats(init),
|
|
38
|
+
auditEvents: {
|
|
39
|
+
list: async (query = {}, init = {}) => {
|
|
40
|
+
const search = new URLSearchParams({ limit: String(query.limit ?? 10) });
|
|
41
|
+
if (query.cursor !== undefined) search.set("cursor", query.cursor);
|
|
42
|
+
const body = await getJson<{ items: AuthHistoryEvent[]; nextCursor: string | null }>(`/api/admin/auth-audit-events?${search.toString()}`, init);
|
|
43
|
+
return {
|
|
44
|
+
items: body.items.map(({ resource, occurredAt, ...event }) => ({ ...event, subjectId: resource, occurredAt: new Date(occurredAt) })),
|
|
45
|
+
nextCursor: body.nextCursor,
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { ErrorState, LoadingState, PageHeader, useAdmin } from "@awsmbiz/inbis-admin-ui";
|
|
2
|
+
import { Button, Card, CardContent, Input, Label } from "@awsmbiz/inbis-ui";
|
|
3
|
+
import { useQuery } from "@tanstack/react-query";
|
|
4
|
+
import { useId, useState, type SyntheticEvent } from "react";
|
|
5
|
+
import { getJson } from "./admin-client.js";
|
|
6
|
+
import { t } from "./messages.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* An admin page of the app's own, next to the platform's: replace it with your product's
|
|
10
|
+
* administration. It uses what the platform's pages use: `useAdmin()` (step-up, navigation),
|
|
11
|
+
* `PageHeader` for the page's one <h1>, the page states, and TanStack Query (the admin shell
|
|
12
|
+
* provides the QueryClient). Its data comes from the app's own API (`GET /v1/hello`, src/v1.ts);
|
|
13
|
+
* guard your own admin routes with `requireAdmin()` from `@awsmbiz/inbis-auth/hono`.
|
|
14
|
+
* It is listed in `pages` and `navItems` in admin-area.tsx.
|
|
15
|
+
*/
|
|
16
|
+
export function ExamplePage() {
|
|
17
|
+
const { withStepUp } = useAdmin();
|
|
18
|
+
const nameId = useId();
|
|
19
|
+
const [draft, setDraft] = useState("");
|
|
20
|
+
const [name, setName] = useState<string | null>(null);
|
|
21
|
+
const greeting = useQuery({
|
|
22
|
+
queryKey: ["example", "hello", name],
|
|
23
|
+
enabled: name !== null,
|
|
24
|
+
// withStepUp: a call refused because the session must verify first re-runs once it has.
|
|
25
|
+
queryFn: ({ signal }) => withStepUp(() => getJson<{ message: string }>(`/v1/hello?name=${encodeURIComponent(name ?? "")}`, { signal })),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const submit = (event: SyntheticEvent) => {
|
|
29
|
+
event.preventDefault();
|
|
30
|
+
if (draft.trim() !== "") setName(draft.trim());
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<>
|
|
35
|
+
<PageHeader title={t("admin.example.title")} description={t("admin.example.description")} />
|
|
36
|
+
<Card>
|
|
37
|
+
<CardContent className="flex flex-col gap-4">
|
|
38
|
+
<form onSubmit={submit} className="flex flex-col gap-2 sm:flex-row sm:items-end">
|
|
39
|
+
<div className="flex flex-1 flex-col gap-2">
|
|
40
|
+
<Label htmlFor={nameId}>{t("admin.example.name")}</Label>
|
|
41
|
+
<Input
|
|
42
|
+
id={nameId}
|
|
43
|
+
value={draft}
|
|
44
|
+
maxLength={100}
|
|
45
|
+
onChange={(event) => {
|
|
46
|
+
setDraft(event.target.value);
|
|
47
|
+
}}
|
|
48
|
+
/>
|
|
49
|
+
</div>
|
|
50
|
+
<Button type="submit">{t("admin.example.submit")}</Button>
|
|
51
|
+
</form>
|
|
52
|
+
{name !== null && greeting.isPending ? <LoadingState label={t("admin.example.loading")} className="py-2" /> : null}
|
|
53
|
+
{greeting.isError ? <ErrorState title={t("admin.example.failed")} error={greeting.error} onRetry={() => void greeting.refetch()} /> : null}
|
|
54
|
+
{/* Always in the page, so screen readers announce the answer when it arrives. */}
|
|
55
|
+
<p role="status" className="text-sm">
|
|
56
|
+
{greeting.isSuccess ? greeting.data.message : ""}
|
|
57
|
+
</p>
|
|
58
|
+
</CardContent>
|
|
59
|
+
</Card>
|
|
60
|
+
</>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { accessRequirementOf, type AccessRequirement } from "@awsmbiz/inbis-auth/client";
|
|
2
|
+
import { StepUpScreen } from "@awsmbiz/inbis-auth-ui";
|
|
3
|
+
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@awsmbiz/inbis-ui";
|
|
4
|
+
import { lazy, Suspense, useEffect, useState } from "react";
|
|
5
|
+
import { client } from "./auth-client.js";
|
|
6
|
+
import { t } from "./messages.js";
|
|
7
|
+
import type { RouteContext } from "./routes.js";
|
|
8
|
+
|
|
9
|
+
// The admin area (@awsmbiz/inbis-admin-ui and its pages) is its own chunk, downloaded only
|
|
10
|
+
// once GET /api/me says the visitor is an administrator (plan Q16).
|
|
11
|
+
const AdminArea = lazy(() => import("./admin-area.js").then((module) => ({ default: module.AdminArea })));
|
|
12
|
+
|
|
13
|
+
type Gate = { kind: "checking" } | { kind: "admin" } | { kind: "refused" } | { kind: "failed" } | { kind: "challenge"; requirement: AccessRequirement };
|
|
14
|
+
|
|
15
|
+
function Checking() {
|
|
16
|
+
return (
|
|
17
|
+
<main className="p-4">
|
|
18
|
+
<h1 className="sr-only">{t("admin.title")}</h1>
|
|
19
|
+
<p role="status" className="text-sm text-muted-foreground">
|
|
20
|
+
{t("common.loading")}
|
|
21
|
+
</p>
|
|
22
|
+
</main>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `/admin` and below. Checks the signed-in user's role before loading any admin code:
|
|
28
|
+
* a signed-out visitor goes to sign-in (and comes back), a session that needs a second
|
|
29
|
+
* factor completes it here, and anyone else but an administrator is refused without
|
|
30
|
+
* downloading the admin bundle. The admin API enforces access itself (src/admin.ts);
|
|
31
|
+
* this only decides what to load.
|
|
32
|
+
*/
|
|
33
|
+
export function AdminGate({ path, search, navigate }: RouteContext) {
|
|
34
|
+
const [gate, setGate] = useState<Gate>({ kind: "checking" });
|
|
35
|
+
const [attempt, setAttempt] = useState(0);
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
let active = true;
|
|
39
|
+
fetch("/api/me", { credentials: "include" }).then(
|
|
40
|
+
async (response) => {
|
|
41
|
+
if (!active) return;
|
|
42
|
+
if (response.status === 401) {
|
|
43
|
+
navigate(`/sign-in?next=${encodeURIComponent(window.location.pathname)}`, { replace: true });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const requirement = await accessRequirementOf(response.clone());
|
|
47
|
+
if (requirement !== null) setGate({ kind: "challenge", requirement });
|
|
48
|
+
else if (!response.ok) setGate({ kind: "failed" });
|
|
49
|
+
else {
|
|
50
|
+
const { user } = (await response.json()) as { user: { role: string } };
|
|
51
|
+
setGate({ kind: user.role === "admin" ? "admin" : "refused" });
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
() => {
|
|
55
|
+
if (active) setGate({ kind: "failed" });
|
|
56
|
+
},
|
|
57
|
+
);
|
|
58
|
+
return () => {
|
|
59
|
+
active = false;
|
|
60
|
+
};
|
|
61
|
+
// The role is checked once per visit to the admin area, not on every admin page.
|
|
62
|
+
}, [navigate, attempt]);
|
|
63
|
+
|
|
64
|
+
if (gate.kind === "checking") return <Checking />;
|
|
65
|
+
if (gate.kind === "admin") {
|
|
66
|
+
return (
|
|
67
|
+
<Suspense fallback={<Checking />}>
|
|
68
|
+
<AdminArea path={path} search={search} navigate={navigate} />
|
|
69
|
+
</Suspense>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
if (gate.kind === "challenge") {
|
|
73
|
+
return (
|
|
74
|
+
<StepUpScreen
|
|
75
|
+
client={client}
|
|
76
|
+
requirement={gate.requirement}
|
|
77
|
+
onSatisfied={() => {
|
|
78
|
+
setGate({ kind: "checking" });
|
|
79
|
+
setAttempt((n) => n + 1);
|
|
80
|
+
}}
|
|
81
|
+
onSignInAgain={() => {
|
|
82
|
+
const next = window.location.pathname;
|
|
83
|
+
void client.signOut().then(() => {
|
|
84
|
+
navigate(`/sign-in?next=${encodeURIComponent(next)}`);
|
|
85
|
+
});
|
|
86
|
+
}}
|
|
87
|
+
/>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return (
|
|
91
|
+
<main className="flex min-h-svh items-center justify-center bg-muted/40 p-4">
|
|
92
|
+
<Card className="w-full max-w-sm">
|
|
93
|
+
<CardHeader>
|
|
94
|
+
<CardTitle>
|
|
95
|
+
<h1>{t("admin.notAllowed")}</h1>
|
|
96
|
+
</CardTitle>
|
|
97
|
+
<CardDescription>{gate.kind === "refused" ? t("admin.refused") : t("admin.failed")}</CardDescription>
|
|
98
|
+
</CardHeader>
|
|
99
|
+
<CardContent>
|
|
100
|
+
<Button
|
|
101
|
+
variant="outline"
|
|
102
|
+
onClick={() => {
|
|
103
|
+
navigate("/");
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
{t("admin.home")}
|
|
107
|
+
</Button>
|
|
108
|
+
</CardContent>
|
|
109
|
+
</Card>
|
|
110
|
+
</main>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "admin",
|
|
3
|
+
"description": "The admin area: a lazily loaded, admin-gated /admin on @awsmbiz/inbis-admin-ui with the auth administration pages (dashboard, users, invitations, settings; activity with audit) and an example page of the app's own.",
|
|
4
|
+
"order": 40,
|
|
5
|
+
"requires": ["auth"],
|
|
6
|
+
"dependencies": {
|
|
7
|
+
"@awsmbiz/inbis-admin-ui": "workspace:*",
|
|
8
|
+
"@awsmbiz/inbis-api-client": "workspace:*",
|
|
9
|
+
"@awsmbiz/inbis-ui": "workspace:*",
|
|
10
|
+
"@tanstack/react-query": "5.104.3",
|
|
11
|
+
"lucide-react": "1.65.0"
|
|
12
|
+
},
|
|
13
|
+
"points": {
|
|
14
|
+
"admin.server.imports": { "file": "src/admin.ts", "description": "Imports for the admin API wiring." },
|
|
15
|
+
"admin.stats": { "file": "src/admin.ts", "description": "Properties of mountAuthStats' options (failedSignIns, activeUsers), comma-terminated." },
|
|
16
|
+
"admin.authAudit": { "file": "src/admin.ts", "description": "Properties of mountAuthAudit's options (list), comma-terminated." },
|
|
17
|
+
"admin.imports": { "file": "web/src/admin-area.tsx", "description": "Imports for the admin area. Import from a file of the module's own (not from packages admin-area.tsx already imports)." },
|
|
18
|
+
"admin.pages": { "file": "web/src/admin-area.tsx", "description": "AdminPage entries (path relative to /admin, title, component), comma-terminated; before the app's own pages." },
|
|
19
|
+
"admin.nav": { "file": "web/src/admin-area.tsx", "description": "AdminNavItem entries (to, label, icon, visible), comma-terminated; before the app's own items." },
|
|
20
|
+
"admin.userSections": { "file": "web/src/admin-area.tsx", "description": "JSX sections of a user's admin page, inside the `sections={(user) => …}` fragment (`user`: AdminUserDetail in scope); wrap each in UserDetailSection." },
|
|
21
|
+
"admin.viewer": { "file": "web/src/admin-area.tsx", "description": "Statements in loadViewer that add the administrator's business permissions to `permissions` (for `visible` predicates); `user` is in scope." }
|
|
22
|
+
},
|
|
23
|
+
"wiring": {
|
|
24
|
+
"worker.imports": "wiring/worker.imports.ts",
|
|
25
|
+
"worker.routes": "wiring/worker.routes.ts",
|
|
26
|
+
"web.routes.imports": "wiring/web.routes.imports.tsx",
|
|
27
|
+
"web.routes": "wiring/web.routes.tsx",
|
|
28
|
+
"web.messages": "wiring/web.messages.ts",
|
|
29
|
+
"web.styles": "wiring/web.styles.css",
|
|
30
|
+
"account.sections": "wiring/account.sections.tsx",
|
|
31
|
+
"readme.modules": "wiring/readme.modules.md"
|
|
32
|
+
},
|
|
33
|
+
"when": [
|
|
34
|
+
{
|
|
35
|
+
"modules": ["audit"],
|
|
36
|
+
"files": "with-audit",
|
|
37
|
+
"wiring": {
|
|
38
|
+
"admin.server.imports": "wiring/audit/admin.server.imports.ts",
|
|
39
|
+
"admin.stats": "wiring/audit/admin.stats.ts",
|
|
40
|
+
"admin.authAudit": "wiring/audit/admin.authAudit.ts",
|
|
41
|
+
"admin.imports": "wiring/audit/admin.imports.tsx",
|
|
42
|
+
"admin.pages": "wiring/audit/admin.pages.tsx",
|
|
43
|
+
"admin.nav": "wiring/audit/admin.nav.tsx",
|
|
44
|
+
"admin.userSections": "wiring/audit/admin.userSections.tsx",
|
|
45
|
+
"web.messages": "wiring/audit/web.messages.ts",
|
|
46
|
+
"readme.modules": "wiring/audit/readme.modules.md"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{/* The admin area (admin module), for administrators. */}
|
|
2
|
+
{me.user.role === "admin" ? (
|
|
3
|
+
<Card>
|
|
4
|
+
<CardHeader>
|
|
5
|
+
<CardTitle>
|
|
6
|
+
<h2>{t("account.administration")}</h2>
|
|
7
|
+
</CardTitle>
|
|
8
|
+
<CardDescription>{t("account.administrationDescription")}</CardDescription>
|
|
9
|
+
</CardHeader>
|
|
10
|
+
<CardContent>
|
|
11
|
+
<Link href="/admin" navigate={navigate} className={linkClass}>
|
|
12
|
+
{t("account.openAdministration")}
|
|
13
|
+
</Link>
|
|
14
|
+
</CardContent>
|
|
15
|
+
</Card>
|
|
16
|
+
) : null}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import { activityNavItem, activityPage, UserActivity } from "./admin-activity.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
activityNavItem,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
activityPage,
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
// The audit trail (audit module) counts: failed sign-ins, and distinct users who signed in.
|
|
2
|
+
failedSignIns: (c, since) => auditLog(c.env, { logger: c.var.logger }).count({ action: AUTH_AUDIT_ACTIONS.signInFailed, from: since }),
|
|
3
|
+
activeUsers: (c, since) => auditLog(c.env, { logger: c.var.logger }).countActors({ action: AUTH_AUDIT_ACTIONS.signInSucceeded, from: since }),
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<UserActivity user={user} />
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
With the audit trail (the `audit` module), the admin area adds an **Activity** page (every
|
|
2
|
+
event, `GET /api/admin/audit-events`) and each user's recent activity on their page
|
|
3
|
+
(`web/src/admin-activity.tsx`), and the dashboard's counts and recent activity read the
|
|
4
|
+
trail (`src/admin.ts`).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"admin.nav.activity": "Activity",
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
## Admin area (admin module)
|
|
2
|
+
|
|
3
|
+
`@awsmbiz/inbis-admin-ui` (the admin shell and the auth administration pages) on
|
|
4
|
+
`@awsmbiz/inbis-api-client`, at `/admin`.
|
|
5
|
+
|
|
6
|
+
- **Who gets in** — administrators (`role` `admin`) with 2FA, signed in with an allowed
|
|
7
|
+
admin method (the admin policy). `web/src/admin-gate.tsx` checks `GET /api/me` before
|
|
8
|
+
loading anything: visitors go to sign-in and come back, a session that needs a second
|
|
9
|
+
factor completes it in place, and other users are refused without downloading the admin
|
|
10
|
+
code (`web/src/admin-area.tsx` is its own chunk). Administrators find the area on their
|
|
11
|
+
account page.
|
|
12
|
+
- **API** — `src/admin.ts` mounts the admin API (`/api/admin/users`, `/api/admin/auth-stats`,
|
|
13
|
+
`/api/admin/auth-settings`, `/api/admin/auth-providers/status`,
|
|
14
|
+
`/api/admin/auth-audit-events`); Better Auth's admin routes (`/api/auth/admin/*`) and the
|
|
15
|
+
invitation routes come with sign-in. Every route applies the admin policy; changes also
|
|
16
|
+
need a recent sign-in, which the area asks for in place and then runs the change again.
|
|
17
|
+
- **Pages** — dashboard, users (create, change role, ban, revoke sessions, reset 2FA,
|
|
18
|
+
delete), invitations (when the registration mode allows them) and auth settings.
|
|
19
|
+
- **Your own pages** — `web/src/admin-example.tsx` is an example page on the app's own API.
|
|
20
|
+
Copy it, add the component to `pages` and an item to `navItems` in
|
|
21
|
+
`web/src/admin-area.tsx`, and guard its API routes with `requireAdmin()` (plus
|
|
22
|
+
`requireRecentAdminSignIn()` for changes) from `@awsmbiz/inbis-auth/hono`. Delete the
|
|
23
|
+
example when you have pages of your own.
|
|
24
|
+
- **Tests** — `test/admin.test.ts` (Workers) and `e2e/admin-*.spec.ts` (Playwright + axe:
|
|
25
|
+
the gate, a change that needs a recent sign-in, every page in both themes at desktop and
|
|
26
|
+
phone sizes).
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// The admin area (admin module). The platform's admin pages bring their own text (@awsmbiz/inbis-admin-ui).
|
|
2
|
+
"admin.title": "Administration",
|
|
3
|
+
"admin.notAllowed": "Not allowed",
|
|
4
|
+
"admin.refused": "The admin area is for administrators.",
|
|
5
|
+
"admin.failed": "The admin area could not be loaded. Check your connection and try again.",
|
|
6
|
+
"admin.home": "Go to the home page",
|
|
7
|
+
"admin.backToApp": "Back to the app",
|
|
8
|
+
"admin.nav.dashboard": "Dashboard",
|
|
9
|
+
"admin.nav.users": "Users",
|
|
10
|
+
"admin.nav.invitations": "Invitations",
|
|
11
|
+
"admin.nav.settings": "Auth settings",
|
|
12
|
+
"admin.nav.example": "Example",
|
|
13
|
+
"admin.page.user": "User",
|
|
14
|
+
"admin.example.title": "Example page",
|
|
15
|
+
"admin.example.description": "A page of the app's own, next to the platform's. Replace it with your product's administration (web/src/admin-example.tsx).",
|
|
16
|
+
"admin.example.name": "Name",
|
|
17
|
+
"admin.example.submit": "Greet",
|
|
18
|
+
"admin.example.loading": "Asking the API…",
|
|
19
|
+
"admin.example.failed": "The API did not answer",
|
|
20
|
+
"account.administration": "Administration",
|
|
21
|
+
"account.administrationDescription": "Manage users, invitations and sign-in settings.",
|
|
22
|
+
"account.openAdministration": "Open the admin area",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import { AdminGate } from "./admin-gate.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@import "@awsmbiz/inbis-admin-ui/styles.css";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import { mountAppAdmin } from "./admin.js";
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { expect, test } from "@playwright/test";
|
|
2
|
+
import { signInAsAdmin } from "./admin-helpers.js";
|
|
3
|
+
import { adminsCanSignIn, USERS } from "./auth-seeded.js";
|
|
4
|
+
import { expectNoPageScroll, expectNoViolations, THEMES, VIEWPORTS } from "./axe.js";
|
|
5
|
+
|
|
6
|
+
// With the audit trail (audit module), the admin area has an Activity page over every
|
|
7
|
+
// event, and each user's page lists their recent activity.
|
|
8
|
+
|
|
9
|
+
test.skip(!adminsCanSignIn, "administrators need email + password and 2FA (src/auth-choices.ts)");
|
|
10
|
+
|
|
11
|
+
test("an administrator finds their own sign-in in the activity, and on their user page", async ({ page, baseURL }) => {
|
|
12
|
+
await signInAsAdmin(page, baseURL);
|
|
13
|
+
await page.goto("/admin");
|
|
14
|
+
await page.getByRole("navigation", { name: "Administration" }).getByRole("link", { name: "Activity" }).click();
|
|
15
|
+
await expect(page.getByRole("heading", { level: 1, name: "Activity" })).toBeVisible();
|
|
16
|
+
await expect(page.getByRole("table", { name: "Activity" }).getByText("auth.sign_in.succeeded").first()).toBeVisible();
|
|
17
|
+
|
|
18
|
+
await page.goto("/admin/users");
|
|
19
|
+
await page.getByRole("table", { name: "Users" }).getByRole("link", { name: USERS.admin.name }).click();
|
|
20
|
+
await expect(page.getByRole("heading", { level: 1, name: USERS.admin.name })).toBeVisible();
|
|
21
|
+
await expect(page.getByRole("region", { name: "Recent activity" })).toContainText("auth.sign_in.succeeded");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
for (const theme of THEMES) {
|
|
25
|
+
for (const [viewport, size] of Object.entries(VIEWPORTS)) {
|
|
26
|
+
test(`the activity page (${theme}, ${viewport}) has no accessibility violations`, async ({ page, baseURL }) => {
|
|
27
|
+
await page.emulateMedia({ colorScheme: theme });
|
|
28
|
+
await page.setViewportSize(size);
|
|
29
|
+
await signInAsAdmin(page, baseURL);
|
|
30
|
+
await page.goto("/admin/activity");
|
|
31
|
+
await expect(page.getByRole("heading", { level: 1, name: "Activity" })).toBeVisible();
|
|
32
|
+
await expect(page.getByRole("table", { name: "Activity" }).getByRole("row").nth(1)).toBeVisible();
|
|
33
|
+
await expectNoPageScroll(page);
|
|
34
|
+
await expectNoViolations(page);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|