diff --git a/CHANGELOG.md b/CHANGELOG.md
index 55b6ff33..e7432aaf 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,12 @@ written while it was being built. See [RELEASING.md](RELEASING.md).
### Added
+- You can now change your own password. Your name in the top right opens a new
+ Account page, whose Change password button takes you to the sign-in service
+ and brings you back. It asks you to sign in once more on the way, which is
+ what proves it is you. Forgetting a password is still not self-service, so the
+ page says to ask a platform administrator instead.
+
### Changed
- Following an invitation link into a private hackathon now admits you straight
diff --git a/components/frontend/src/lib/components/layout/NavBar.svelte b/components/frontend/src/lib/components/layout/NavBar.svelte
index 02d91dd2..9c148f8b 100644
--- a/components/frontend/src/lib/components/layout/NavBar.svelte
+++ b/components/frontend/src/lib/components/layout/NavBar.svelte
@@ -10,7 +10,11 @@
import { safeReturnTo } from '$lib/utils/returnTo';
// The header carries identity, theme and sign-out — no administration entry.
- // That moved to the dashboard's Manage platform section, which is the single
+ // Identity doubles as the way to the account page: the monogram and name link
+ // there rather than the bar growing a third control. Clicking your own name is
+ // where people already look for it, and it is what keeps the bar inside 320px
+ // and keeps a rare action from sitting beside a common one.
+ // Administration moved to the dashboard's Manage platform section, the single
// place the platform pages are offered from. The trade is deliberate: from
// inside a hackathon an admin now returns to the dashboard first, via the
// wordmark, rather than jumping straight there from the header — on a phone
@@ -65,6 +69,8 @@
$page.url.pathname === '/' || $page.url.pathname.startsWith('/dashboard')
);
+ const onAccount = $derived($page.url.pathname.startsWith('/account'));
+
// The row vocabulary is SidebarNavSection's, so the two navigations read as
// one system rather than drifting into separate dialects of the same idea.
const ROW =
@@ -161,18 +167,26 @@
-
+
+
+ Account
+
signOut({ callbackUrl: '/' })}
class="btn btn-sm btn-quiet mt-1 self-start"
diff --git a/components/frontend/src/lib/components/layout/NavBar.test.ts b/components/frontend/src/lib/components/layout/NavBar.test.ts
index 63fee596..ff466954 100644
--- a/components/frontend/src/lib/components/layout/NavBar.test.ts
+++ b/components/frontend/src/lib/components/layout/NavBar.test.ts
@@ -36,6 +36,30 @@ describe("NavBar", () => {
expect(screen.queryByRole("button", { name: "Log in" })).toBeNull()
})
+ // Identity is the only way to the account page from the bar — there is no
+ // third control — so the name being a link is the whole affordance rather than
+ // a decoration on it.
+ it("makes identity the link to the account page when signed in", () => {
+ render(NavBar, { session: signedIn })
+
+ const links = screen
+ .getAllByRole("link")
+ .filter((a) => a.getAttribute("href") === "/account")
+
+ expect(links.length).toBeGreaterThan(0)
+ expect(links[0]).toHaveAccessibleName(/Your account/)
+ })
+
+ it("offers no account link when signed out", () => {
+ render(NavBar, { session: null })
+
+ expect(
+ screen
+ .queryAllByRole("link")
+ .filter((a) => a.getAttribute("href") === "/account"),
+ ).toHaveLength(0)
+ })
+
it("offers Log in and names nobody when signed out", () => {
render(NavBar, { session: null })
diff --git a/components/frontend/src/lib/utils/account.test.ts b/components/frontend/src/lib/utils/account.test.ts
new file mode 100644
index 00000000..9e09fb26
--- /dev/null
+++ b/components/frontend/src/lib/utils/account.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it, vi, beforeEach } from "vitest"
+
+const signIn = vi.fn()
+vi.mock("@auth/sveltekit/client", () => ({
+ signIn: (...a: unknown[]) => signIn(...a),
+}))
+
+import { startPasswordChange, UPDATE_PASSWORD_ACTION } from "./account"
+
+/*
+ * The shape of the call is the whole mechanism, and every part of it is load
+ * bearing in a way that is invisible at the call site: `kc_action` has to be the
+ * *third* argument, because that is the one @auth/core merges into the
+ * authorization URL. Passed as part of the options object instead it would be
+ * posted as a form field, Keycloak would never see it, and the user would land
+ * on an ordinary sign-in that quietly did nothing.
+ */
+describe("startPasswordChange", () => {
+ beforeEach(() => signIn.mockClear())
+
+ it("asks Keycloak for the update-password action, in the third argument", () => {
+ startPasswordChange("/account")
+
+ expect(signIn).toHaveBeenCalledTimes(1)
+ expect(signIn).toHaveBeenCalledWith(
+ "keycloak",
+ { callbackUrl: "/account" },
+ { kc_action: "UPDATE_PASSWORD" },
+ )
+ })
+
+ it("comes back where it was told to", () => {
+ startPasswordChange("/somewhere/else?tab=2")
+
+ expect(signIn).toHaveBeenCalledWith(
+ "keycloak",
+ { callbackUrl: "/somewhere/else?tab=2" },
+ expect.anything(),
+ )
+ })
+
+ // Spelled exactly as Keycloak's required action is, or Keycloak ignores the
+ // parameter and the redirect degrades to a plain sign-in.
+ it("names the action Keycloak actually registers", () => {
+ expect(UPDATE_PASSWORD_ACTION).toBe("UPDATE_PASSWORD")
+ })
+})
diff --git a/components/frontend/src/lib/utils/account.ts b/components/frontend/src/lib/utils/account.ts
new file mode 100644
index 00000000..d2d136bb
--- /dev/null
+++ b/components/frontend/src/lib/utils/account.ts
@@ -0,0 +1,49 @@
+// Changing a password is an OIDC round trip, not a form we post.
+//
+// The Go backend has no password surface at all — nothing in `api/proto` names
+// a credential, and it holds no Keycloak admin client — so there is no RPC to
+// call here and never will be. Keycloak owns the credential, and the way to ask
+// it for its own update-password screen is `kc_action` on the authorization
+// endpoint.
+//
+// Auth.js's third `signIn` argument is what carries that: `@auth/core` merges
+// the signin request's query into the authorization URL's parameters, so
+// `kc_action` reaches Keycloak while state, nonce and PKCE stay Auth.js's
+// business rather than ours. Hand-building the authorize URL instead would put
+// us in charge of all three and fail Auth.js's own callback checks.
+//
+// The trip ends back at `returnTo` either way. On success Keycloak returns to
+// the callback with a fresh code and `kc_action_status=success`; on Cancel it
+// returns the same way with `cancelled`. Both complete the flow and mint a new
+// session, so there is no error path — a cancelled password change is
+// indistinguishable from a normal sign-in, which is also why the caller has
+// nothing honest to show as a confirmation.
+
+import { signIn } from "@auth/sveltekit/client"
+
+/**
+ * Keycloak's application-initiated action for setting a new password. Enabled
+ * as a required action on the realm already, which is the precondition Keycloak
+ * checks before honouring it — with it disabled, Keycloak logs a warning and
+ * ignores the parameter, and the user would land on a plain sign-in instead.
+ *
+ * That it is already enabled is why this page needs no Keycloak change at all.
+ */
+export const UPDATE_PASSWORD_ACTION = "UPDATE_PASSWORD"
+
+/**
+ * Send the user to Keycloak to set a new password, and back to `returnTo`.
+ *
+ * They are asked to sign in again on the way: the provider sets
+ * `prompt: "login"` (see `src/auth.ts`), so this re-authenticates before the
+ * form appears. That is deliberate rather than incidental — Keycloak's form
+ * asks for the new password twice and never for the old one, so the fresh login
+ * is the only thing proving it is really them.
+ */
+export function startPasswordChange(returnTo: string): void {
+ signIn(
+ "keycloak",
+ { callbackUrl: returnTo },
+ { kc_action: UPDATE_PASSWORD_ACTION },
+ )
+}
diff --git a/components/frontend/src/routes/(app)/account/+page.svelte b/components/frontend/src/routes/(app)/account/+page.svelte
new file mode 100644
index 00000000..ca3868cd
--- /dev/null
+++ b/components/frontend/src/routes/(app)/account/+page.svelte
@@ -0,0 +1,96 @@
+
+
+
+ Account · Hackagon
+
+
+
+
+
+
+
+
+ {initial}
+
+
+ Signed in as
+ {userName}
+ {#if user?.email && user.email !== userName}
+ {user.email}
+ {/if}
+
+
+
+
+
+
+
+
+
Password
+
+
+
+
+ Your password is held by the sign-in service, not by Hackagon. Changing it takes
+ you there and asks you to sign in once more first; you will come back here when
+ you are done, or if you cancel.
+
+
+
+ Change password
+
+
+
+
+ Forgotten your password? There is no self-service reset yet — ask a platform
+ administrator to set a new one for you.
+
+
+