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:
parent
3fdc8cb489
commit
c7c81fb65c
43 changed files with 2248 additions and 298 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue