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
91
internal/server/theme_test.go
Normal file
91
internal/server/theme_test.go
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
package server
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue