feat: account menu, database roles, and single logout

Bundles the console work from the current branch: the header account
control, database-backed roles and permissions, and signing out across
the applications that share this sign-in.

The account control replaces a separate identity chip and a separate
sign-out button, which meant the header carried two controls describing
one fact. web/account.js is mounted into every page by a placeholder and
owns the keyboard handling, the outside-click behaviour and the sign-out
form once. It also fills the trigger's name, which the previous markup
left empty.

Signing out revokes the hosted-login bearer sessions this installation
issued, then redirects to the provider's end_session_endpoint when the
discovery document advertises one. Applications that authenticate
against this installation therefore lose their session too; one pointed
straight at an identity provider is out of its reach and is unchanged.

Roles and permissions become rows rather than a closed enum, so a tenant
can extend the catalog without a migration, and a role can no longer be
granted a capability that is not in it. requireSameOrigin now compares
against the origin the request arrived on instead of the configured base
URL, which defaulted to http://localhost:8080 and so rejected every
write in a deployment that never set it.

Two bugs found while verifying in a browser:

  - A profile picture never rendered. avatarURL built an absolute URL
    from the configured base URL, and the page's own img-src 'self'
    blocked it. The console's own pages now take a root-relative path;
    only /connect/userinfo, read from another origin, needs an absolute
    one, and it follows the request.
  - absoluteURL preferred the configuration over the request while its
    comment claimed the opposite, which also misdirected the logout
    redirect.

Card layout: the footer was one two-column row shared between a label
and a forty-character identifier, so the identifier took the width and
crushed its label into three stacked words. Each pair is a line now, the
value truncates with the full text in the title, and cards align their
footers across a row.
This commit is contained in:
Daniel Noulet 2026-10-05 19:18:07 +02:00
parent 3fdc8cb489
commit c7c81fb65c
43 changed files with 2248 additions and 298 deletions

View file

@ -9,7 +9,6 @@ import (
"image/png"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strings"
"testing"
@ -32,23 +31,44 @@ func profileRequest(t *testing.T, body string) *http.Request {
return request.WithContext(context.WithValue(request.Context(), claimsKey, claims))
}
func TestAvatarURLIsAbsoluteAndOmittedWithoutKey(t *testing.T) {
// The console reads its own picture through avatarPath, so it must be a relative
// path: the configured base URL is absent or stale often enough that building an
// absolute link from it yields an address the browser refuses to load.
func TestAvatarPathIsRelativeAndOmittedWithoutKey(t *testing.T) {
server := profileTestServer()
if got := server.avatarURL(nil); got != "" {
t.Fatalf("avatarURL(nil) = %q, want empty", got)
if got := server.avatarPath(nil); got != "" {
t.Fatalf("avatarPath(nil) = %q, want empty", got)
}
empty := ""
if got := server.avatarURL(&empty); got != "" {
t.Fatalf("avatarURL(empty) = %q, want empty", got)
if got := server.avatarPath(&empty); got != "" {
t.Fatalf("avatarPath(empty) = %q, want empty", got)
}
key := "k"
got := server.avatarURL(&key)
if want := "https://auth.example.test/api/v1/avatars/k"; got != want {
if got, want := server.avatarPath(&key), "/api/v1/avatars/k"; got != want {
t.Fatalf("avatarPath() = %q, want %q", got, want)
}
// A base URL left at its default must not leak into a page's markup.
server.cfg.BaseURL = "http://localhost:8080"
if got := server.avatarPath(&key); got != "/api/v1/avatars/k" {
t.Fatalf("avatarPath() with default base URL = %q", got)
}
}
// Another origin, such as go-tell reading /connect/userinfo, cannot resolve a
// relative path, so that form follows the request rather than the configuration.
func TestAvatarURLFollowsTheRequestNotTheConfiguredBase(t *testing.T) {
server := profileTestServer()
key := "k"
request := httptest.NewRequest(http.MethodGet, "https://nmbs.auth.example.test/connect/userinfo", nil)
if got, want := server.avatarURL(request, &key), "https://nmbs.auth.example.test/api/v1/avatars/k"; got != want {
t.Fatalf("avatarURL() = %q, want %q", got, want)
}
parsed, err := url.Parse(got)
if err != nil || parsed.Path != "/api/v1/avatars/k" {
t.Fatalf("avatar url = %q, err = %v", got, err)
if got := server.avatarURL(request, nil); got != "" {
t.Fatalf("avatarURL(nil) = %q, want empty", got)
}
// Without a host to follow, the configured base is the only thing left.
if got, want := server.avatarURL(&http.Request{}, &key), "https://auth.example.test/api/v1/avatars/k"; got != want {
t.Fatalf("avatarURL() without a request host = %q, want %q", got, want)
}
}
@ -58,7 +78,8 @@ func TestAvatarURLIsAbsoluteAndOmittedWithoutKey(t *testing.T) {
func TestClientUserExposesRoleCapabilitiesAndPicture(t *testing.T) {
server := profileTestServer()
key := "avatar-key"
payload := server.clientUser(store.ClientUser{
request := httptest.NewRequest(http.MethodGet, "https://auth.example.test/connect/userinfo", nil)
payload := server.clientUser(request, store.ClientUser{
ID: "user-1", Email: "operator@example.test", DisplayName: "Operator",
TenantSlug: "nmbs", Application: "guess", Role: "operator",
Permissions: []string{string(role.ManageKeys), string(role.EditProfile)},
@ -84,7 +105,7 @@ func TestClientUserExposesRoleCapabilitiesAndPicture(t *testing.T) {
// rather than an error.
func TestClientUserWithoutMembershipIsLeastPrivileged(t *testing.T) {
server := profileTestServer()
payload := server.clientUser(store.ClientUser{Role: "user", Permissions: []string{string(role.EditProfile)}})
payload := server.clientUser(nil, store.ClientUser{Role: "user", Permissions: []string{string(role.EditProfile)}})
if payload.AvatarURL != "" {
t.Fatalf("avatar url = %q, want empty", payload.AvatarURL)
}