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.
2026-10-05 19:18:07 +02:00
|
|
|
package server
|
|
|
|
|
|
|
|
|
|
import (
|
2026-10-05 21:48:05 +02:00
|
|
|
"fmt"
|
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.
2026-10-05 19:18:07 +02:00
|
|
|
"strings"
|
|
|
|
|
"testing"
|
2026-10-05 21:48:05 +02:00
|
|
|
|
|
|
|
|
"github.com/danyel/go-loose/web"
|
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.
2026-10-05 19:18:07 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// The theme origin is substituted into every HTML page. These tests cover the two
|
|
|
|
|
// branches and the failure mode in between, because a wrong result here is invisible
|
|
|
|
|
// until a user reports that the theme stopped applying.
|
|
|
|
|
|
|
|
|
|
const pageWithThemeLines = `<!doctype html>
|
|
|
|
|
<html data-theme="tokyo" data-mode="dark">
|
|
|
|
|
<head>
|
|
|
|
|
<link rel="stylesheet" href="/assets/style.css">
|
|
|
|
|
<link rel="stylesheet" href="__GO_LOOSE_THEME_ORIGIN__/rt/v1/contract.css" data-theme-contract>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
<bananas-theme-selector id="theme"></bananas-theme-selector>
|
|
|
|
|
<script type="module" src="__GO_LOOSE_THEME_ORIGIN__/rt/v1/components.js" data-bananas-components></script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>`
|
|
|
|
|
|
|
|
|
|
func TestThemeOriginIsSubstituted(t *testing.T) {
|
|
|
|
|
got := string(applyThemeOrigin([]byte(pageWithThemeLines), "https://themes.example.com"))
|
|
|
|
|
|
|
|
|
|
if strings.Contains(got, themeOriginPlaceholder) {
|
|
|
|
|
t.Error("the placeholder survived substitution")
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(got, `href="https://themes.example.com/rt/v1/contract.css"`) {
|
|
|
|
|
t.Errorf("the contract stylesheet was not rewritten:\n%s", got)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(got, `src="https://themes.example.com/rt/v1/components.js"`) {
|
|
|
|
|
t.Errorf("the component module was not rewritten:\n%s", got)
|
|
|
|
|
}
|
|
|
|
|
// The application's own stylesheet must be untouched.
|
|
|
|
|
if !strings.Contains(got, `href="/assets/style.css"`) {
|
|
|
|
|
t.Error("the local stylesheet was rewritten")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestThemeOriginTrailingSlashIsTrimmed(t *testing.T) {
|
|
|
|
|
// A trailing slash would produce a double slash, and the service answers 404 for
|
|
|
|
|
// "//rt/v1/contract.css" because the route is matched on the clean path.
|
|
|
|
|
for _, base := range []string{
|
|
|
|
|
"https://themes.example.com/",
|
|
|
|
|
"https://themes.example.com///",
|
|
|
|
|
} {
|
|
|
|
|
got := string(applyThemeOrigin([]byte(pageWithThemeLines), base))
|
|
|
|
|
if strings.Contains(got, "com//") {
|
|
|
|
|
t.Errorf("base %q produced a doubled slash:\n%s", base, got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestThemeLinesAreDroppedWhenNoOriginIsConfigured(t *testing.T) {
|
|
|
|
|
for _, base := range []string{"", " ", "/"} {
|
|
|
|
|
got := string(applyThemeOrigin([]byte(pageWithThemeLines), base))
|
|
|
|
|
|
|
|
|
|
if strings.Contains(got, themeOriginPlaceholder) {
|
|
|
|
|
t.Errorf("base %q left the placeholder in place:\n%s", base, got)
|
|
|
|
|
}
|
|
|
|
|
// An empty href would be a same-origin request to this application, which 404s
|
|
|
|
|
// and looks like a styling bug in the wrong service.
|
|
|
|
|
if strings.Contains(got, `href="/rt/v1/contract.css"`) {
|
|
|
|
|
t.Errorf("base %q emitted an empty contract href:\n%s", base, got)
|
|
|
|
|
}
|
|
|
|
|
if strings.Contains(got, themeMarkerContract) || strings.Contains(got, themeMarkerScript) {
|
|
|
|
|
t.Errorf("base %q left a theme line behind:\n%s", base, got)
|
|
|
|
|
}
|
|
|
|
|
// The rest of the page must survive.
|
|
|
|
|
if !strings.Contains(got, `href="/assets/style.css"`) {
|
|
|
|
|
t.Errorf("base %q removed the local stylesheet:\n%s", base, got)
|
|
|
|
|
}
|
|
|
|
|
if !strings.Contains(got, "<body>") || !strings.Contains(got, "</html>") {
|
|
|
|
|
t.Errorf("base %q damaged the page structure:\n%s", base, got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestThemeOriginLeavesNonThemePagesAlone(t *testing.T) {
|
|
|
|
|
// A page with no theme lines must come back byte-identical, so that a deployment
|
|
|
|
|
// without the contract serving its login and install pages is a no-op.
|
|
|
|
|
plain := []byte("<!doctype html>\n<html>\n<body>no theme here</body>\n</html>\n")
|
|
|
|
|
if got := applyThemeOrigin(plain, "https://themes.example.com"); string(got) != string(plain) {
|
|
|
|
|
t.Errorf("a page without theme lines was modified:\n%s", got)
|
|
|
|
|
}
|
|
|
|
|
if got := applyThemeOrigin(plain, ""); string(got) != string(plain) {
|
|
|
|
|
t.Errorf("a page without theme lines was modified:\n%s", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-10-05 21:48:05 +02:00
|
|
|
|
|
|
|
|
// contractBackedTokens maps this application's own colour token onto the shared
|
|
|
|
|
// contract role that carries the same meaning. The right-hand side is a bare role
|
|
|
|
|
// name because the variable prefix belongs to the contract.
|
|
|
|
|
var contractBackedTokens = map[string]string{
|
|
|
|
|
"--bg": "bg",
|
|
|
|
|
"--surface": "surface",
|
|
|
|
|
// go-loose's second surface step is the contract's raised surface.
|
|
|
|
|
"--surface2": "surface-raised",
|
|
|
|
|
"--text": "text",
|
|
|
|
|
"--muted": "muted",
|
|
|
|
|
"--accent": "accent",
|
|
|
|
|
// go-loose's secondary accent is the contract's secondary role: for every
|
|
|
|
|
// palette the two agree, tokyo's #bb9af7 and catppuccin's #cba6f7 among them.
|
|
|
|
|
"--accent2": "secondary",
|
|
|
|
|
"--good": "good",
|
|
|
|
|
"--danger": "danger",
|
|
|
|
|
"--border": "border",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestLocalTokensAdoptTheContractWhereItIsServed covers the difference between a
|
|
|
|
|
// theme selector that is present in the page and one that works.
|
|
|
|
|
//
|
|
|
|
|
// Every palette this application names has to resolve through the contract's
|
|
|
|
|
// variables, with this application's own value as the fallback. The fallback is
|
|
|
|
|
// what keeps the install and login pages styled as they always were: they never load
|
|
|
|
|
// the contract, so the variable is undefined there and the literal applies. Loading
|
|
|
|
|
// the contract on a console page therefore adopts the shared palette without
|
|
|
|
|
// changing anything for the pages that do not ask for it.
|
|
|
|
|
//
|
|
|
|
|
// Both directions are asserted. Adopting the role is not enough on its own: a single
|
|
|
|
|
// declaration left holding a bare literal overrides the contract for whichever
|
|
|
|
|
// palette that block names, and the console then renders half shared and half local.
|
|
|
|
|
func TestLocalTokensAdoptTheContractWhereItIsServed(t *testing.T) {
|
|
|
|
|
css, err := web.Files.ReadFile("style.css")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("style.css must be embedded: %v", err)
|
|
|
|
|
}
|
|
|
|
|
stylesheet := string(css)
|
|
|
|
|
|
|
|
|
|
for token, role := range contractBackedTokens {
|
|
|
|
|
if !strings.Contains(stylesheet, fmt.Sprintf("%s:var(--bn-%s,", token, role)) {
|
|
|
|
|
t.Errorf("token %s does not adopt --bn-%s, so selecting a palette outside this application's own four changes nothing:\n%s",
|
|
|
|
|
token, role, firstLines(css, 8))
|
|
|
|
|
}
|
|
|
|
|
// A literal assignment anywhere would win for the palette it sits under, and
|
|
|
|
|
// the contract's value for that one palette would be discarded.
|
|
|
|
|
if literal := token + ":#"; strings.Contains(stylesheet, literal) {
|
|
|
|
|
t.Errorf("token %s is still assigned a bare literal, which overrides the contract for the palette that block names:\n%s",
|
|
|
|
|
token, firstLines(css, 8))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestTheShadowTokenIsNotMapped guards the one token deliberately left alone.
|
|
|
|
|
// The contract's shadow family holds complete box-shadow values, and this token
|
|
|
|
|
// holds a colour, so wiring them together would substitute a shadow for a colour.
|
|
|
|
|
func TestTheShadowTokenIsNotMapped(t *testing.T) {
|
|
|
|
|
css, err := web.Files.ReadFile("style.css")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("style.css must be embedded: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if strings.Contains(string(css), "--shadow:var(--bn-") {
|
|
|
|
|
t.Error("--shadow is a colour here and the contract's shadow roles are box-shadow values; mapping them would break every elevation")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// firstLines trims a stylesheet for an error message, since the token blocks sit at
|
|
|
|
|
// the top of the file and the whole of it is noise in a test failure.
|
|
|
|
|
func firstLines(css []byte, n int) string {
|
|
|
|
|
lines := strings.SplitN(string(css), "\n", n+1)
|
|
|
|
|
if len(lines) > n {
|
|
|
|
|
lines = lines[:n]
|
|
|
|
|
}
|
|
|
|
|
return strings.Join(lines, "\n")
|
|
|
|
|
}
|