diff --git a/CHANGELOG.md b/CHANGELOG.md index f67b2b9..c8e9f7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,12 +15,19 @@ follows [Semantic Versioning](https://semver.org). returns `taxonomies` (name → terms in use, most used first). Terms are case-sensitive in core, so the control matches what you type case-insensitively against the page's terms and the site's existing ones and reuses the existing spelling instead of creating "Foo" next to "foo"; a pasted "a, b" becomes two terms. -- **Save no longer misses a click that follows typing in the editor.** The "Unsaved" badge now keeps its place in the - toolbar, so the blur that makes the page dirty cannot move Save out from under the click, and Save always sends the - latest frontmatter. +- **Account page with session management (duneorg/dune#155).** `/admin/account` (the user name in the top bar links to it) lists + your signed-in sessions with when and from where, marks the current one, and lets you sign out any other session or + all of them at once. It only ever touches your own sessions and logs `auth.sessions_revoked` to the audit log. Session + ids never appear in the page: it uses a short derived handle. Needs a core with session listing + (`SessionManager.listForUser`/`revokeOthers`, plugin-api 1.1); on an older core the page says so instead of listing. + Since signing in no longer ends other sessions (#30), "sign out other sessions" here is how you end a session you + suspect is stolen or left open on another device. ### Fixed +- **Save no longer misses a click that follows typing in the editor.** The "Unsaved" badge now keeps its place in the + toolbar, so the blur that makes the page dirty cannot move Save out from under the click, and Save always sends the + latest frontmatter. - **Signing in no longer ends the account's other admin sessions (#30).** Login revoked every existing session of the user, so using the admin in two browsers or on two devices logged you out of the first one, with no hint why. Sessions now end on logout, password change and expiry only. diff --git a/deno.json b/deno.json index 6f573a3..839d0c5 100644 --- a/deno.json +++ b/deno.json @@ -6,6 +6,7 @@ "age": "P1D", "exclude": [ "jsr:@dune/core", + "jsr:@dune/plugin-api", "jsr:@dune/testing" ] }, diff --git a/src/admin/auth/sessions.ts b/src/admin/auth/sessions.ts index aec5b23..af9f0c0 100644 --- a/src/admin/auth/sessions.ts +++ b/src/admin/auth/sessions.ts @@ -35,6 +35,15 @@ export interface SessionManager { revoke(sessionId: string): Promise; /** Revoke all sessions for a user */ revokeAll(userId: string): Promise; + /** + * The user's live sessions, newest first. Empty when the running core + * predates session listing (see {@link SessionManager.canList}). + */ + listForUser(userId: string): Promise; + /** Revoke all of a user's sessions except `keepSessionId`. No-op when `canList` is false. */ + revokeOthers(userId: string, keepSessionId: string): Promise; + /** False when core does not offer session listing yet; the account page then hides the section. */ + readonly canList: boolean; /** Clean up expired sessions */ cleanup(): Promise; } @@ -49,6 +58,11 @@ export async function createSessionManager(config: SessionManagerConfig): Promis lifetimeMs: config.lifetime * 1000, }); + const extended = core as typeof core & { + listForUser?: (userId: string) => Promise; + revokeOthers?: (userId: string, keepSessionId: string) => Promise; + }; + return { // Admin sessions never carry an embeddedUser — the underlying factory's // third param exists only for public auth's userStore: "session" mode. @@ -56,6 +70,22 @@ export async function createSessionManager(config: SessionManagerConfig): Promis get: core.get, revoke: core.revoke, revokeAll: core.revokeAll, + // Session listing arrived in a later core than the first plugin-api 1.0 + // contract; against an older core the section is simply unavailable. + canList: typeof extended.listForUser === "function" && + typeof extended.revokeOthers === "function", + listForUser: (userId) => extended.listForUser?.(userId) ?? Promise.resolve([]), + revokeOthers: (userId, keepSessionId) => extended.revokeOthers?.(userId, keepSessionId) ?? Promise.resolve(), cleanup: core.cleanup, }; } + +/** + * A short, non-reversible handle for a session. The session id is the bearer + * credential in the cookie, so pages never contain it: they carry this handle, + * and the server re-finds the session among the signed-in user's own sessions. + */ +export async function sessionHandle(sessionId: string): Promise { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(sessionId)); + return [...new Uint8Array(digest)].slice(0, 8).map((b) => b.toString(16).padStart(2, "0")).join(""); +} diff --git a/src/admin/manifest.gen.ts b/src/admin/manifest.gen.ts index 8ed48e6..efb517a 100644 --- a/src/admin/manifest.gen.ts +++ b/src/admin/manifest.gen.ts @@ -4,99 +4,100 @@ import * as middlewareMod from "./routes/_middleware.ts"; import * as layoutMod from "./routes/_layout.tsx"; import * as r0 from "./routes/index.tsx"; -import * as r1 from "./routes/api/audit.ts"; -import * as r2 from "./routes/api/comments/mentions/index.ts"; -import * as r3 from "./routes/api/comments/mentions/read.ts"; -import * as r4 from "./routes/api/config/index.ts"; -import * as r5 from "./routes/api/config/theme.ts"; -import * as r6 from "./routes/api/config/theme-config.ts"; -import * as r7 from "./routes/api/config/themes.ts"; -import * as r8 from "./routes/api/content-editor/ws.ts"; -import * as r9 from "./routes/api/csrf.ts"; -import * as r10 from "./routes/api/dashboard.ts"; -import * as r11 from "./routes/api/dev/apply.ts"; -import * as r12 from "./routes/api/editor/parse.ts"; -import * as r13 from "./routes/api/editor/serialize.ts"; -import * as r14 from "./routes/api/email-preview.ts"; -import * as r15 from "./routes/api/email-preview/[id].ts"; -import * as r16 from "./routes/api/flex/[type]/index.ts"; -import * as r17 from "./routes/api/flex/[type]/[id]/index.ts"; -import * as r18 from "./routes/api/history/[...rest].ts"; -import * as r19 from "./routes/api/i18n/memory/index.ts"; -import * as r20 from "./routes/api/i18n/memory/rebuild.ts"; -import * as r21 from "./routes/api/i18n/mt-status.ts"; -import * as r22 from "./routes/api/i18n/translate-page.ts"; -import * as r23 from "./routes/api/i18n/translate-segment.ts"; -import * as r24 from "./routes/api/inline-edit/presence.ts"; -import * as r25 from "./routes/api/introspect.ts"; -import * as r26 from "./routes/api/jobs.ts"; -import * as r27 from "./routes/api/jobs/[name]/run.ts"; -import * as r28 from "./routes/api/media/index.ts"; -import * as r29 from "./routes/api/media/meta.ts"; -import * as r30 from "./routes/api/media/upload.ts"; -import * as r31 from "./routes/api/metrics.ts"; -import * as r32 from "./routes/api/page-source.ts"; -import * as r33 from "./routes/api/pages/index.ts"; -import * as r34 from "./routes/api/pages/reorder.ts"; -import * as r35 from "./routes/api/pages/translate.ts"; -import * as r36 from "./routes/api/pages/[...path]/comments/[id]/resolve.ts"; -import * as r37 from "./routes/api/pages/[...path]/comments/[id]/index.ts"; -import * as r38 from "./routes/api/pages/[...path]/comments/index.ts"; -import * as r39 from "./routes/api/pages/[...path]/index.ts"; -import * as r40 from "./routes/api/plugins/index.ts"; -import * as r41 from "./routes/api/plugins/install.ts"; -import * as r42 from "./routes/api/plugins/[name]/config.ts"; -import * as r43 from "./routes/api/preview.ts"; -import * as r44 from "./routes/api/registry/plugins.ts"; -import * as r45 from "./routes/api/registry/themes.ts"; -import * as r46 from "./routes/api/render-markdown.ts"; -import * as r47 from "./routes/api/search/analytics.ts"; -import * as r48 from "./routes/api/search/engines.ts"; -import * as r49 from "./routes/api/sections.ts"; -import * as r50 from "./routes/api/staging/[...path]/publish.ts"; -import * as r51 from "./routes/api/staging/[...path]/index.ts"; -import * as r52 from "./routes/api/theme-preview.ts"; -import * as r53 from "./routes/api/themes/install.ts"; -import * as r54 from "./routes/api/users/index.ts"; -import * as r55 from "./routes/api/users/[id]/index.ts"; -import * as r56 from "./routes/api/users/[id]/password.ts"; -import * as r57 from "./routes/api/webhooks/deliveries.ts"; -import * as r58 from "./routes/api/workflow/schedule/index.ts"; -import * as r59 from "./routes/api/workflow/schedule/[id].ts"; -import * as r60 from "./routes/api/workflow/scheduled/[...path].ts"; -import * as r61 from "./routes/api/workflow/stages.ts"; -import * as r62 from "./routes/api/workflow/status/[...path].ts"; -import * as r63 from "./routes/api/workflow/transition.ts"; -import * as r64 from "./routes/audit/index.tsx"; -import * as r65 from "./routes/collab/edit-ws.ts"; -import * as r66 from "./routes/collab/ws.ts"; -import * as r67 from "./routes/config/index.tsx"; -import * as r68 from "./routes/email-preview/index.tsx"; -import * as r69 from "./routes/flex/index.tsx"; -import * as r70 from "./routes/flex/[type]/index.tsx"; -import * as r71 from "./routes/flex/[type]/[id].tsx"; -import * as r72 from "./routes/i18n/index.tsx"; -import * as r73 from "./routes/i18n/memory.tsx"; -import * as r74 from "./routes/jobs/index.tsx"; -import * as r75 from "./routes/login.tsx"; -import * as r76 from "./routes/login/logout.ts"; -import * as r77 from "./routes/marketplace/index.tsx"; -import * as r78 from "./routes/media/index.tsx"; -import * as r79 from "./routes/metrics/index.tsx"; -import * as r80 from "./routes/pages/index.tsx"; -import * as r81 from "./routes/pages/builder.tsx"; -import * as r82 from "./routes/pages/edit.tsx"; -import * as r83 from "./routes/pages/history.tsx"; -import * as r84 from "./routes/plugins/index.tsx"; -import * as r85 from "./routes/search/index.tsx"; -import * as r86 from "./routes/submissions/index.tsx"; -import * as r87 from "./routes/submissions/[form]/index.tsx"; -import * as r88 from "./routes/submissions/[form]/[id]/index.tsx"; -import * as r89 from "./routes/submissions/[form]/[id]/delete.ts"; -import * as r90 from "./routes/submissions/[form]/[id]/files/[filename].ts"; -import * as r91 from "./routes/submissions/[form]/[id]/status.ts"; -import * as r92 from "./routes/themes/index.tsx"; -import * as r93 from "./routes/users/index.tsx"; +import * as r1 from "./routes/account.tsx"; +import * as r2 from "./routes/api/audit.ts"; +import * as r3 from "./routes/api/comments/mentions/index.ts"; +import * as r4 from "./routes/api/comments/mentions/read.ts"; +import * as r5 from "./routes/api/config/index.ts"; +import * as r6 from "./routes/api/config/theme.ts"; +import * as r7 from "./routes/api/config/theme-config.ts"; +import * as r8 from "./routes/api/config/themes.ts"; +import * as r9 from "./routes/api/content-editor/ws.ts"; +import * as r10 from "./routes/api/csrf.ts"; +import * as r11 from "./routes/api/dashboard.ts"; +import * as r12 from "./routes/api/dev/apply.ts"; +import * as r13 from "./routes/api/editor/parse.ts"; +import * as r14 from "./routes/api/editor/serialize.ts"; +import * as r15 from "./routes/api/email-preview.ts"; +import * as r16 from "./routes/api/email-preview/[id].ts"; +import * as r17 from "./routes/api/flex/[type]/index.ts"; +import * as r18 from "./routes/api/flex/[type]/[id]/index.ts"; +import * as r19 from "./routes/api/history/[...rest].ts"; +import * as r20 from "./routes/api/i18n/memory/index.ts"; +import * as r21 from "./routes/api/i18n/memory/rebuild.ts"; +import * as r22 from "./routes/api/i18n/mt-status.ts"; +import * as r23 from "./routes/api/i18n/translate-page.ts"; +import * as r24 from "./routes/api/i18n/translate-segment.ts"; +import * as r25 from "./routes/api/inline-edit/presence.ts"; +import * as r26 from "./routes/api/introspect.ts"; +import * as r27 from "./routes/api/jobs.ts"; +import * as r28 from "./routes/api/jobs/[name]/run.ts"; +import * as r29 from "./routes/api/media/index.ts"; +import * as r30 from "./routes/api/media/meta.ts"; +import * as r31 from "./routes/api/media/upload.ts"; +import * as r32 from "./routes/api/metrics.ts"; +import * as r33 from "./routes/api/page-source.ts"; +import * as r34 from "./routes/api/pages/index.ts"; +import * as r35 from "./routes/api/pages/reorder.ts"; +import * as r36 from "./routes/api/pages/translate.ts"; +import * as r37 from "./routes/api/pages/[...path]/comments/[id]/resolve.ts"; +import * as r38 from "./routes/api/pages/[...path]/comments/[id]/index.ts"; +import * as r39 from "./routes/api/pages/[...path]/comments/index.ts"; +import * as r40 from "./routes/api/pages/[...path]/index.ts"; +import * as r41 from "./routes/api/plugins/index.ts"; +import * as r42 from "./routes/api/plugins/install.ts"; +import * as r43 from "./routes/api/plugins/[name]/config.ts"; +import * as r44 from "./routes/api/preview.ts"; +import * as r45 from "./routes/api/registry/plugins.ts"; +import * as r46 from "./routes/api/registry/themes.ts"; +import * as r47 from "./routes/api/render-markdown.ts"; +import * as r48 from "./routes/api/search/analytics.ts"; +import * as r49 from "./routes/api/search/engines.ts"; +import * as r50 from "./routes/api/sections.ts"; +import * as r51 from "./routes/api/staging/[...path]/publish.ts"; +import * as r52 from "./routes/api/staging/[...path]/index.ts"; +import * as r53 from "./routes/api/theme-preview.ts"; +import * as r54 from "./routes/api/themes/install.ts"; +import * as r55 from "./routes/api/users/index.ts"; +import * as r56 from "./routes/api/users/[id]/index.ts"; +import * as r57 from "./routes/api/users/[id]/password.ts"; +import * as r58 from "./routes/api/webhooks/deliveries.ts"; +import * as r59 from "./routes/api/workflow/schedule/index.ts"; +import * as r60 from "./routes/api/workflow/schedule/[id].ts"; +import * as r61 from "./routes/api/workflow/scheduled/[...path].ts"; +import * as r62 from "./routes/api/workflow/stages.ts"; +import * as r63 from "./routes/api/workflow/status/[...path].ts"; +import * as r64 from "./routes/api/workflow/transition.ts"; +import * as r65 from "./routes/audit/index.tsx"; +import * as r66 from "./routes/collab/edit-ws.ts"; +import * as r67 from "./routes/collab/ws.ts"; +import * as r68 from "./routes/config/index.tsx"; +import * as r69 from "./routes/email-preview/index.tsx"; +import * as r70 from "./routes/flex/index.tsx"; +import * as r71 from "./routes/flex/[type]/index.tsx"; +import * as r72 from "./routes/flex/[type]/[id].tsx"; +import * as r73 from "./routes/i18n/index.tsx"; +import * as r74 from "./routes/i18n/memory.tsx"; +import * as r75 from "./routes/jobs/index.tsx"; +import * as r76 from "./routes/login.tsx"; +import * as r77 from "./routes/login/logout.ts"; +import * as r78 from "./routes/marketplace/index.tsx"; +import * as r79 from "./routes/media/index.tsx"; +import * as r80 from "./routes/metrics/index.tsx"; +import * as r81 from "./routes/pages/index.tsx"; +import * as r82 from "./routes/pages/builder.tsx"; +import * as r83 from "./routes/pages/edit.tsx"; +import * as r84 from "./routes/pages/history.tsx"; +import * as r85 from "./routes/plugins/index.tsx"; +import * as r86 from "./routes/search/index.tsx"; +import * as r87 from "./routes/submissions/index.tsx"; +import * as r88 from "./routes/submissions/[form]/index.tsx"; +import * as r89 from "./routes/submissions/[form]/[id]/index.tsx"; +import * as r90 from "./routes/submissions/[form]/[id]/delete.ts"; +import * as r91 from "./routes/submissions/[form]/[id]/files/[filename].ts"; +import * as r92 from "./routes/submissions/[form]/[id]/status.ts"; +import * as r93 from "./routes/themes/index.tsx"; +import * as r94 from "./routes/users/index.tsx"; /** Shape of an fs-convention route module (subset Fresh cares about). */ export interface AdminRouteModule { @@ -117,99 +118,100 @@ export const adminLayout = layoutMod; export const adminRoutes: AdminRouteEntry[] = [ { pattern: "/", mod: r0 }, - { pattern: "/api/audit", mod: r1 }, - { pattern: "/api/comments/mentions", mod: r2 }, - { pattern: "/api/comments/mentions/read", mod: r3 }, - { pattern: "/api/config", mod: r4 }, - { pattern: "/api/config/theme", mod: r5 }, - { pattern: "/api/config/theme-config", mod: r6 }, - { pattern: "/api/config/themes", mod: r7 }, - { pattern: "/api/content-editor/ws", mod: r8 }, - { pattern: "/api/csrf", mod: r9 }, - { pattern: "/api/dashboard", mod: r10 }, - { pattern: "/api/dev/apply", mod: r11 }, - { pattern: "/api/editor/parse", mod: r12 }, - { pattern: "/api/editor/serialize", mod: r13 }, - { pattern: "/api/email-preview", mod: r14 }, - { pattern: "/api/email-preview/:id", mod: r15 }, - { pattern: "/api/flex/:type", mod: r16 }, - { pattern: "/api/flex/:type/:id", mod: r17 }, - { pattern: "/api/history/:rest*", mod: r18 }, - { pattern: "/api/i18n/memory", mod: r19 }, - { pattern: "/api/i18n/memory/rebuild", mod: r20 }, - { pattern: "/api/i18n/mt-status", mod: r21 }, - { pattern: "/api/i18n/translate-page", mod: r22 }, - { pattern: "/api/i18n/translate-segment", mod: r23 }, - { pattern: "/api/inline-edit/presence", mod: r24 }, - { pattern: "/api/introspect", mod: r25 }, - { pattern: "/api/jobs", mod: r26 }, - { pattern: "/api/jobs/:name/run", mod: r27 }, - { pattern: "/api/media", mod: r28 }, - { pattern: "/api/media/meta", mod: r29 }, - { pattern: "/api/media/upload", mod: r30 }, - { pattern: "/api/metrics", mod: r31 }, - { pattern: "/api/page-source", mod: r32 }, - { pattern: "/api/pages", mod: r33 }, - { pattern: "/api/pages/reorder", mod: r34 }, - { pattern: "/api/pages/translate", mod: r35 }, - { pattern: "/api/pages/:path*/comments/:id/resolve", mod: r36 }, - { pattern: "/api/pages/:path*/comments/:id", mod: r37 }, - { pattern: "/api/pages/:path*/comments", mod: r38 }, - { pattern: "/api/pages/:path*", mod: r39 }, - { pattern: "/api/plugins", mod: r40 }, - { pattern: "/api/plugins/install", mod: r41 }, - { pattern: "/api/plugins/:name/config", mod: r42 }, - { pattern: "/api/preview", mod: r43 }, - { pattern: "/api/registry/plugins", mod: r44 }, - { pattern: "/api/registry/themes", mod: r45 }, - { pattern: "/api/render-markdown", mod: r46 }, - { pattern: "/api/search/analytics", mod: r47 }, - { pattern: "/api/search/engines", mod: r48 }, - { pattern: "/api/sections", mod: r49 }, - { pattern: "/api/staging/:path*/publish", mod: r50 }, - { pattern: "/api/staging/:path*", mod: r51 }, - { pattern: "/api/theme-preview", mod: r52 }, - { pattern: "/api/themes/install", mod: r53 }, - { pattern: "/api/users", mod: r54 }, - { pattern: "/api/users/:id", mod: r55 }, - { pattern: "/api/users/:id/password", mod: r56 }, - { pattern: "/api/webhooks/deliveries", mod: r57 }, - { pattern: "/api/workflow/schedule", mod: r58 }, - { pattern: "/api/workflow/schedule/:id", mod: r59 }, - { pattern: "/api/workflow/scheduled/:path*", mod: r60 }, - { pattern: "/api/workflow/stages", mod: r61 }, - { pattern: "/api/workflow/status/:path*", mod: r62 }, - { pattern: "/api/workflow/transition", mod: r63 }, - { pattern: "/audit", mod: r64 }, - { pattern: "/collab/edit-ws", mod: r65 }, - { pattern: "/collab/ws", mod: r66 }, - { pattern: "/config", mod: r67 }, - { pattern: "/email-preview", mod: r68 }, - { pattern: "/flex", mod: r69 }, - { pattern: "/flex/:type", mod: r70 }, - { pattern: "/flex/:type/:id", mod: r71 }, - { pattern: "/i18n", mod: r72 }, - { pattern: "/i18n/memory", mod: r73 }, - { pattern: "/jobs", mod: r74 }, - { pattern: "/login", mod: r75 }, - { pattern: "/login/logout", mod: r76 }, - { pattern: "/marketplace", mod: r77 }, - { pattern: "/media", mod: r78 }, - { pattern: "/metrics", mod: r79 }, - { pattern: "/pages", mod: r80 }, - { pattern: "/pages/builder", mod: r81 }, - { pattern: "/pages/edit", mod: r82 }, - { pattern: "/pages/history", mod: r83 }, - { pattern: "/plugins", mod: r84 }, - { pattern: "/search", mod: r85 }, - { pattern: "/submissions", mod: r86 }, - { pattern: "/submissions/:form", mod: r87 }, - { pattern: "/submissions/:form/:id", mod: r88 }, - { pattern: "/submissions/:form/:id/delete", mod: r89 }, - { pattern: "/submissions/:form/:id/files/:filename", mod: r90 }, - { pattern: "/submissions/:form/:id/status", mod: r91 }, - { pattern: "/themes", mod: r92 }, - { pattern: "/users", mod: r93 }, + { pattern: "/account", mod: r1 }, + { pattern: "/api/audit", mod: r2 }, + { pattern: "/api/comments/mentions", mod: r3 }, + { pattern: "/api/comments/mentions/read", mod: r4 }, + { pattern: "/api/config", mod: r5 }, + { pattern: "/api/config/theme", mod: r6 }, + { pattern: "/api/config/theme-config", mod: r7 }, + { pattern: "/api/config/themes", mod: r8 }, + { pattern: "/api/content-editor/ws", mod: r9 }, + { pattern: "/api/csrf", mod: r10 }, + { pattern: "/api/dashboard", mod: r11 }, + { pattern: "/api/dev/apply", mod: r12 }, + { pattern: "/api/editor/parse", mod: r13 }, + { pattern: "/api/editor/serialize", mod: r14 }, + { pattern: "/api/email-preview", mod: r15 }, + { pattern: "/api/email-preview/:id", mod: r16 }, + { pattern: "/api/flex/:type", mod: r17 }, + { pattern: "/api/flex/:type/:id", mod: r18 }, + { pattern: "/api/history/:rest*", mod: r19 }, + { pattern: "/api/i18n/memory", mod: r20 }, + { pattern: "/api/i18n/memory/rebuild", mod: r21 }, + { pattern: "/api/i18n/mt-status", mod: r22 }, + { pattern: "/api/i18n/translate-page", mod: r23 }, + { pattern: "/api/i18n/translate-segment", mod: r24 }, + { pattern: "/api/inline-edit/presence", mod: r25 }, + { pattern: "/api/introspect", mod: r26 }, + { pattern: "/api/jobs", mod: r27 }, + { pattern: "/api/jobs/:name/run", mod: r28 }, + { pattern: "/api/media", mod: r29 }, + { pattern: "/api/media/meta", mod: r30 }, + { pattern: "/api/media/upload", mod: r31 }, + { pattern: "/api/metrics", mod: r32 }, + { pattern: "/api/page-source", mod: r33 }, + { pattern: "/api/pages", mod: r34 }, + { pattern: "/api/pages/reorder", mod: r35 }, + { pattern: "/api/pages/translate", mod: r36 }, + { pattern: "/api/pages/:path*/comments/:id/resolve", mod: r37 }, + { pattern: "/api/pages/:path*/comments/:id", mod: r38 }, + { pattern: "/api/pages/:path*/comments", mod: r39 }, + { pattern: "/api/pages/:path*", mod: r40 }, + { pattern: "/api/plugins", mod: r41 }, + { pattern: "/api/plugins/install", mod: r42 }, + { pattern: "/api/plugins/:name/config", mod: r43 }, + { pattern: "/api/preview", mod: r44 }, + { pattern: "/api/registry/plugins", mod: r45 }, + { pattern: "/api/registry/themes", mod: r46 }, + { pattern: "/api/render-markdown", mod: r47 }, + { pattern: "/api/search/analytics", mod: r48 }, + { pattern: "/api/search/engines", mod: r49 }, + { pattern: "/api/sections", mod: r50 }, + { pattern: "/api/staging/:path*/publish", mod: r51 }, + { pattern: "/api/staging/:path*", mod: r52 }, + { pattern: "/api/theme-preview", mod: r53 }, + { pattern: "/api/themes/install", mod: r54 }, + { pattern: "/api/users", mod: r55 }, + { pattern: "/api/users/:id", mod: r56 }, + { pattern: "/api/users/:id/password", mod: r57 }, + { pattern: "/api/webhooks/deliveries", mod: r58 }, + { pattern: "/api/workflow/schedule", mod: r59 }, + { pattern: "/api/workflow/schedule/:id", mod: r60 }, + { pattern: "/api/workflow/scheduled/:path*", mod: r61 }, + { pattern: "/api/workflow/stages", mod: r62 }, + { pattern: "/api/workflow/status/:path*", mod: r63 }, + { pattern: "/api/workflow/transition", mod: r64 }, + { pattern: "/audit", mod: r65 }, + { pattern: "/collab/edit-ws", mod: r66 }, + { pattern: "/collab/ws", mod: r67 }, + { pattern: "/config", mod: r68 }, + { pattern: "/email-preview", mod: r69 }, + { pattern: "/flex", mod: r70 }, + { pattern: "/flex/:type", mod: r71 }, + { pattern: "/flex/:type/:id", mod: r72 }, + { pattern: "/i18n", mod: r73 }, + { pattern: "/i18n/memory", mod: r74 }, + { pattern: "/jobs", mod: r75 }, + { pattern: "/login", mod: r76 }, + { pattern: "/login/logout", mod: r77 }, + { pattern: "/marketplace", mod: r78 }, + { pattern: "/media", mod: r79 }, + { pattern: "/metrics", mod: r80 }, + { pattern: "/pages", mod: r81 }, + { pattern: "/pages/builder", mod: r82 }, + { pattern: "/pages/edit", mod: r83 }, + { pattern: "/pages/history", mod: r84 }, + { pattern: "/plugins", mod: r85 }, + { pattern: "/search", mod: r86 }, + { pattern: "/submissions", mod: r87 }, + { pattern: "/submissions/:form", mod: r88 }, + { pattern: "/submissions/:form/:id", mod: r89 }, + { pattern: "/submissions/:form/:id/delete", mod: r90 }, + { pattern: "/submissions/:form/:id/files/:filename", mod: r91 }, + { pattern: "/submissions/:form/:id/status", mod: r92 }, + { pattern: "/themes", mod: r93 }, + { pattern: "/users", mod: r94 }, ]; /** Island files under src/admin/islands/, for Builder.registerIsland(). */ diff --git a/src/admin/routes/_layout.tsx b/src/admin/routes/_layout.tsx index a8c55a2..7147d3e 100644 --- a/src/admin/routes/_layout.tsx +++ b/src/admin/routes/_layout.tsx @@ -207,7 +207,7 @@ export default function AdminLayout( ☰
- {userName} + {userName}
= { + one: "That session was signed out.", + others: "All other sessions were signed out.", + gone: "That session was already gone.", +}; + +async function loadRows(ctx: FreshContext): Promise { + const { sessions } = ctx.state.adminContext; + const userId = ctx.state.auth.user!.id; + const currentId = ctx.state.auth.session?.id; + const list = await sessions.listForUser(userId); + return await Promise.all(list.map(async (s) => ({ + handle: await sessionHandle(s.id), + createdAt: s.createdAt, + expiresAt: s.expiresAt, + ip: s.ip ?? null, + current: s.id === currentId, + }))); +} + +export const handler = { + async GET(ctx: FreshContext) { + if (!ctx.state.auth?.user) return new Response("Forbidden", { status: 403 }); + const { prefix, sessions } = ctx.state.adminContext; + const notice = NOTICES[ctx.url.searchParams.get("done") ?? ""] ?? null; + const rows = sessions.canList ? await loadRows(ctx) : []; + return ctx.render( + , + ); + }, + + async POST(ctx: FreshContext) { + const csrfDenied = csrfCheck(ctx); + if (csrfDenied) return csrfDenied; + const authResult = ctx.state.auth; + const user = authResult?.user; + const currentId = authResult?.session?.id; + if (!user || !currentId) return new Response("Forbidden", { status: 403 }); + + const { prefix, sessions, auditLogger } = ctx.state.adminContext; + if (!sessions.canList) return new Response("Not supported by this core version", { status: 501 }); + + const form = await ctx.req.formData(); + const action = String(form.get("action") ?? ""); + let done: string; + let revoked = 0; + + if (action === "revoke-others") { + revoked = (await sessions.listForUser(user.id)).filter((s) => s.id !== currentId).length; + await sessions.revokeOthers(user.id, currentId); + done = "others"; + } else if (action === "revoke") { + // Re-find the session among this user's own sessions by its handle; a + // handle that matches nothing (or the current session) revokes nothing. + const handle = String(form.get("handle") ?? ""); + let target: string | null = null; + for (const s of await sessions.listForUser(user.id)) { + if (s.id !== currentId && await sessionHandle(s.id) === handle) target = s.id; + } + if (target) { + await sessions.revoke(target); + revoked = 1; + } + done = target ? "one" : "gone"; + } else { + return new Response("Bad request", { status: 400 }); + } + + if (revoked > 0) { + void auditLogger?.log({ + event: "auth.sessions_revoked", + actor: actorFromAuth(authResult), + ip: getClientIp(ctx), + userAgent: ctx.req.headers.get("user-agent") ?? null, + target: { type: "user", id: user.id }, + detail: { scope: action === "revoke" ? "one" : "others", count: revoked }, + outcome: "success", + }).catch(() => {}); + } + + return new Response(null, { + status: 303, + headers: { Location: `${prefix}/account?done=${done}` }, + }); + }, +}; + +function when(ms: number): string { + return new Date(ms).toISOString().replace("T", " ").slice(0, 16) + " UTC"; +} + +export default function AccountRoute({ data }: { data: AccountData }) { + const { prefix, canList, sessions, notice } = data; + const others = sessions.filter((s) => !s.current).length; + return ( +
+
+

Account

+
+

Signed-in sessions

+ {notice &&

{notice}

} + {!canList + ?

This version of Dune does not list sessions yet. Update @dune/core to manage them here.

+ : ( + <> + + + + + + + + + + + {sessions.map((s) => ( + + + + + + + ))} + +
Signed inExpiresIP address
{when(s.createdAt)}{when(s.expiresAt)}{s.ip ?? "unknown"} + {s.current ? This session : ( + + + + + + )} +
+ {others > 0 && ( +
+ + +
+ )} + + )} +
+ ); +} diff --git a/src/audit/types.ts b/src/audit/types.ts index 3da1282..8bab6b5 100644 --- a/src/audit/types.ts +++ b/src/audit/types.ts @@ -35,7 +35,7 @@ export interface AuditTarget { /** All audit event types */ export type AuditEventType = // Auth - | "auth.login" | "auth.logout" | "auth.login_failed" + | "auth.login" | "auth.logout" | "auth.login_failed" | "auth.sessions_revoked" | "auth.csrf_denied" | "auth.permission_denied" // Pages | "page.create" | "page.update" | "page.delete" | "page.publish" | "page.workflow" diff --git a/tests/admin/account_sessions_test.tsx b/tests/admin/account_sessions_test.tsx new file mode 100644 index 0000000..ee68a6d --- /dev/null +++ b/tests/admin/account_sessions_test.tsx @@ -0,0 +1,162 @@ +/** @jsxImportSource preact */ +/** + * /admin/account: a user lists and signs out their own sessions. The page and + * forms carry a derived handle, never the session id (the cookie credential). + */ + +import { assertEquals, assertStringIncludes } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { render } from "preact-render-to-string"; +import AccountRoute, { handler } from "../../src/admin/routes/account.tsx"; +import { sessionHandle } from "../../src/admin/auth/sessions.ts"; +import type { Session } from "../../src/admin/types.ts"; + +function fakeSessions(canList = true) { + const store = new Map(); + let n = 0; + return { + store, + canList, + add(userId: string, ip = "10.0.0.1"): Session { + const s: Session = { id: `secret-session-id-${++n}`, userId, createdAt: 1_700_000_000_000 + n, expiresAt: 1_800_000_000_000, ip }; + store.set(s.id, s); + return s; + }, + create: (userId: string) => Promise.resolve(fakeSessions().add(userId)), + get: (id: string) => Promise.resolve(store.get(id) ?? null), + revoke: (id: string) => { + store.delete(id); + return Promise.resolve(); + }, + revokeAll: (userId: string) => { + for (const [id, s] of store) if (s.userId === userId) store.delete(id); + return Promise.resolve(); + }, + listForUser: (userId: string) => + Promise.resolve([...store.values()].filter((s) => s.userId === userId).sort((a, b) => b.createdAt - a.createdAt)), + revokeOthers: (userId: string, keep: string) => { + for (const [id, s] of store) if (s.userId === userId && id !== keep) store.delete(id); + return Promise.resolve(); + }, + cleanup: () => Promise.resolve(0), + }; +} + +// deno-lint-ignore no-explicit-any +function ctxFor(sessions: any, current: Session, method: string, body?: Record): any { + const audit: unknown[] = []; + let rendered: unknown = null; + return { + audit, + get rendered() { + return rendered; + }, + req: new Request("https://cms.example.com/admin/account", { + method, + headers: { origin: "https://cms.example.com", "content-type": "application/x-www-form-urlencoded" }, + body: body ? new URLSearchParams(body) : undefined, + }), + url: new URL("https://cms.example.com/admin/account"), + render: (vnode: { props: { data: unknown } }) => { + rendered = vnode.props.data; + return new Response("ok"); + }, + state: { + adminContext: { + prefix: "/admin", + sessions, + auditLogger: { log: (e: unknown) => (audit.push(e), Promise.resolve()) }, + csrfSecret: "s", + services: { security: { clientIp: () => "10.0.0.9" } }, + }, + auth: { authenticated: true, user: { id: current.userId, username: "polo" }, session: current }, + }, + }; +} + +Deno.test("account GET: lists only the user's own sessions and marks the current one", async () => { + const sessions = fakeSessions(); + const mine = sessions.add("u1"); + sessions.add("u1"); + sessions.add("someone-else"); + const ctx = ctxFor(sessions, mine, "GET"); + await handler.GET(ctx); + const data = ctx.rendered as { sessions: { current: boolean; handle: string }[] }; + assertEquals(data.sessions.length, 2); + assertEquals(data.sessions.filter((s) => s.current).length, 1); +}); + +Deno.test("account page never contains a session id, only handles", async () => { + const sessions = fakeSessions(); + const mine = sessions.add("u1"); + const other = sessions.add("u1"); + const ctx = ctxFor(sessions, mine, "GET"); + await handler.GET(ctx); + const html = render(); + assertEquals(html.includes(mine.id), false); + assertEquals(html.includes(other.id), false); + assertStringIncludes(html, await sessionHandle(other.id)); + assertStringIncludes(html, "This session"); + assertStringIncludes(html, "Sign out all other sessions"); +}); + +Deno.test("account POST revoke: signs out the session with that handle, audits it", async () => { + const sessions = fakeSessions(); + const mine = sessions.add("u1"); + const other = sessions.add("u1"); + const ctx = ctxFor(sessions, mine, "POST", { action: "revoke", handle: await sessionHandle(other.id) }); + const res = await handler.POST(ctx); + assertEquals(res.status, 303); + assertEquals(res.headers.get("location"), "/admin/account?done=one"); + assertEquals(sessions.store.has(other.id), false); + assertEquals(sessions.store.has(mine.id), true); + assertEquals((ctx.audit[0] as { event: string }).event, "auth.sessions_revoked"); +}); + +Deno.test("account POST revoke: cannot touch another user's session or the current one", async () => { + const sessions = fakeSessions(); + const mine = sessions.add("u1"); + const theirs = sessions.add("u2"); + + const a = ctxFor(sessions, mine, "POST", { action: "revoke", handle: await sessionHandle(theirs.id) }); + assertEquals((await handler.POST(a)).headers.get("location"), "/admin/account?done=gone"); + assertEquals(sessions.store.has(theirs.id), true); + + const b = ctxFor(sessions, mine, "POST", { action: "revoke", handle: await sessionHandle(mine.id) }); + await handler.POST(b); + assertEquals(sessions.store.has(mine.id), true); + assertEquals(b.audit.length, 0); +}); + +Deno.test("account POST revoke-others: keeps the current session and other users'", async () => { + const sessions = fakeSessions(); + const mine = sessions.add("u1"); + const o1 = sessions.add("u1"); + const o2 = sessions.add("u1"); + const theirs = sessions.add("u2"); + const ctx = ctxFor(sessions, mine, "POST", { action: "revoke-others" }); + const res = await handler.POST(ctx); + assertEquals(res.headers.get("location"), "/admin/account?done=others"); + assertEquals([o1, o2].some((s) => sessions.store.has(s.id)), false); + assertEquals(sessions.store.has(mine.id), true); + assertEquals(sessions.store.has(theirs.id), true); + assertEquals((ctx.audit[0] as { detail: { count: number } }).detail.count, 2); +}); + +Deno.test("account POST: cross-origin is rejected", async () => { + const sessions = fakeSessions(); + const mine = sessions.add("u1"); + const other = sessions.add("u1"); + const ctx = ctxFor(sessions, mine, "POST", { action: "revoke-others" }); + ctx.req = new Request("https://cms.example.com/admin/account", { + method: "POST", + headers: { origin: "https://evil.example.com", "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ action: "revoke-others" }), + }); + assertEquals((await handler.POST(ctx)).status, 403); + assertEquals(sessions.store.has(other.id), true); +}); + +Deno.test("account page: explains itself when core cannot list sessions", () => { + const html = render(); + assertStringIncludes(html, "does not list sessions yet"); +});