Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"age": "P1D",
"exclude": [
"jsr:@dune/core",
"jsr:@dune/plugin-api",
"jsr:@dune/testing"
]
},
Expand Down
30 changes: 30 additions & 0 deletions src/admin/auth/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ export interface SessionManager {
revoke(sessionId: string): Promise<void>;
/** Revoke all sessions for a user */
revokeAll(userId: string): Promise<void>;
/**
* The user's live sessions, newest first. Empty when the running core
* predates session listing (see {@link SessionManager.canList}).
*/
listForUser(userId: string): Promise<Session[]>;
/** Revoke all of a user's sessions except `keepSessionId`. No-op when `canList` is false. */
revokeOthers(userId: string, keepSessionId: string): Promise<void>;
/** 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<number>;
}
Expand All @@ -49,13 +58,34 @@ export async function createSessionManager(config: SessionManagerConfig): Promis
lifetimeMs: config.lifetime * 1000,
});

const extended = core as typeof core & {
listForUser?: (userId: string) => Promise<Session[]>;
revokeOthers?: (userId: string, keepSessionId: string) => Promise<void>;
};

return {
// Admin sessions never carry an embeddedUser — the underlying factory's
// third param exists only for public auth's userStore: "session" mode.
create: (userId, ip) => core.create(userId, ip),
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<string> {
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("");
}
Loading
Loading