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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 28 additions & 4 deletions components/frontend/src/lib/components/layout/NavBar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -161,18 +167,26 @@
<!-- A quiet outlined tile, not an accent-filled disc: a monogram is
identity, not an action to be drawn toward. The header's accent
is spent on the active-nav underline instead. -->
<div class="flex min-w-0 items-center gap-2">
<a
href={resolve('/(app)/account')}
title={userName}
class="flex min-w-0 items-center gap-2 no-underline hover:text-accent-ink"
>
<!-- The name is hidden below sm, which would leave the link
announcing a single letter, so the purpose is stated for
a screen reader either way. -->
<span class="sr-only">Your account</span>
<span
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-field
border border-line-strong bg-raised text-sm font-semibold text-ink-2"
title={userName}
aria-hidden="true"
>
{initial}
</span>
<span class="hidden max-w-40 truncate text-sm font-medium sm:inline">
{userName}
</span>
</div>
</a>
<!-- Desktop only. Below md it moves into the panel, and the width
that frees is what keeps the bar from overflowing at 320px. -->
<button
Expand Down Expand Up @@ -247,6 +261,16 @@
{/if}
{/if}
{#if session?.user}
<!-- The monogram links here as well, but it is a 32px target next
to the panel trigger, so the panel names the destination
rather than relying on anyone aiming for it. -->
<a
href={resolve('/(app)/account')}
aria-current={onAccount ? 'page' : undefined}
class="{ROW} {onAccount ? ROW_ACTIVE : ROW_IDLE}"
>
Account
</a>
<button
onclick={() => signOut({ callbackUrl: '/' })}
class="btn btn-sm btn-quiet mt-1 self-start"
Expand Down
24 changes: 24 additions & 0 deletions components/frontend/src/lib/components/layout/NavBar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })

Expand Down
47 changes: 47 additions & 0 deletions components/frontend/src/lib/utils/account.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
49 changes: 49 additions & 0 deletions components/frontend/src/lib/utils/account.ts
Original file line number Diff line number Diff line change
@@ -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 },
)
}
96 changes: 96 additions & 0 deletions components/frontend/src/routes/(app)/account/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<script lang="ts">
import { resolve } from '$app/paths';
import KeyRound from 'lucide-svelte/icons/key-round';
import { startPasswordChange } from '$lib/utils/account';
import type { PageData } from './$types';

// No loader of its own: everything shown here is already on the session that
// the root layout hands every route. An RPC would only re-fetch what the
// cookie already says.
let { data }: { data: PageData } = $props();

const user = $derived(data.session?.user);
const userName = $derived(user?.name ?? user?.email ?? 'You');
const initial = $derived(userName.charAt(0).toUpperCase());

// The mechanism, and why it is a redirect rather than a form, lives in
// $lib/utils/account. It comes back to this page whether the user sets a new
// password or cancels — which is also why there is no confirmation banner
// below: nothing here can tell those two apart.
const changePassword = () => startPasswordChange(resolve('/(app)/account'));
</script>

<svelte:head>
<title>Account · Hackagon</title>
</svelte:head>

<div class="mx-auto flex w-full max-w-3xl flex-col gap-6 px-4 py-8 sm:px-10 md:px-20">
<div class="flex min-w-0 flex-col gap-1">
<a
href={resolve('/(app)/dashboard')}
class="w-fit text-xs font-semibold text-accent-ink no-underline hover:underline"
>
&larr; Back to dashboard
</a>
<h1 class="m-0 text-title text-ink">Account</h1>
</div>

<!-- Which account, before the one control that changes it. The monogram is
the header's, at the header's size, so the row reads as the same
identity rather than a second one. -->
<section class="card flex items-center gap-3 p-4">
<span
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-field
border border-line-strong bg-raised text-sm font-semibold text-ink-2"
aria-hidden="true"
>
{initial}
</span>
<div class="flex min-w-0 flex-col">
<span class="meta">Signed in as</span>
<span class="truncate text-sm font-medium text-ink">{userName}</span>
{#if user?.email && user.email !== userName}
<span class="truncate text-xs text-ink-3">{user.email}</span>
{/if}
</div>
</section>

<section class="card flex flex-col gap-3 p-4">
<div class="flex items-center gap-3">
<span
class="flex size-9 shrink-0 items-center justify-center rounded-field
bg-info/10 text-info-ink"
aria-hidden="true"
>
<KeyRound class="h-4 w-4" />
</span>
<h2 class="m-0 text-section">Password</h2>
</div>

<!-- Says what is about to happen, because it is a redirect off the app
onto a page that looks nothing like it, and it asks for the current
password on the way. Without the warning that re-prompt reads as
having been signed out mid-task. -->
<p class="prose m-0 text-sm text-ink-2">
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.
</p>

<div>
<button onclick={changePassword} class="btn btn-solid">Change password</button>
</div>

<!-- The gap named rather than hidden. There is no self-service reset:
the realm sets resetPasswordAllowed false because it has no mail
server, and Keycloak only ever delivers a reset link by email. So
somebody locked out cannot be sent anywhere — they have to be told
who can set a new password for them, which is a platform
administrator and deliberately nobody else, since a credential is
platform-wide and a hackathon role is not. -->
<p class="prose m-0 text-xs text-ink-3">
Forgotten your password? There is no self-service reset yet — ask a platform
administrator to set a new one for you.
</p>
</section>
</div>
Loading