auth-ui: rewrite ui
Change-Id: I6f00867015ec77aa7e336e89da4dc1b081e330c6
diff --git a/core/auth/ui/.gitignore b/core/auth/ui/.gitignore
index 984983f..7bd5027 100644
--- a/core/auth/ui/.gitignore
+++ b/core/auth/ui/.gitignore
@@ -1,5 +1,5 @@
server
server_arm64
server_amd64
-e2e/cache
-e2e/artifacts
+e2e/artifacts/
+e2e/cache/
diff --git a/core/auth/ui/Makefile b/core/auth/ui/Makefile
index da8b9c7..126467b 100644
--- a/core/auth/ui/Makefile
+++ b/core/auth/ui/Makefile
@@ -1,8 +1,12 @@
+GO ?= go
+GOFMT ?= gofmt
+GO_FILES := $(wildcard *.go e2e/*.go)
+
repo_name ?= giolekva
podman ?= docker
docker_flags=--provenance=false --sbom=false
-.PHONY: test test-e2e test-e2e-offline clean-e2e-artifacts
+.PHONY: clean format format-check test test-race vet check test-e2e test-e2e-offline clean-e2e-artifacts build build_arm64 build_amd64 push_arm64 push_amd64 push
ifeq ($(podman), podman)
manifest_dest=docker://docker.io/$(repo_name)/auth-ui:latest
endif
@@ -10,35 +14,50 @@
clean:
rm -f server server_*
+format:
+ $(GOFMT) -w $(GO_FILES)
+
+format-check:
+ @files="$$( $(GOFMT) -l $(GO_FILES) )" || { status=$$?; printf 'gofmt check failed\n' >&2; exit $$status; }; \
+ if [ -n "$$files" ]; then printf 'Go files need formatting:\n%s\n' "$$files" >&2; exit 1; fi
+
test:
- go test ./...
- go vet ./...
+ $(GO) test ./...
+
+test-race:
+ $(GO) test -race -count=1 ./...
+
+vet:
+ $(GO) vet ./...
+
+check: format-check test test-race vet
+ $(GO) build ./...
test-e2e:
- go test -tags=e2e -count=1 -timeout=10m -v ./e2e
+ $(GO) test -tags=e2e -count=1 -timeout=10m -v ./e2e
test-e2e-offline:
- AUTH_UI_E2E_OFFLINE=1 go test -tags=e2e -count=1 -timeout=10m -v ./e2e
+ AUTH_UI_E2E_OFFLINE=1 $(GO) test -tags=e2e -count=1 -timeout=10m -v ./e2e
clean-e2e-artifacts:
rm -rf -- e2e/artifacts
build: clean
- go build -o server *.go
+ $(GO) build -o server *.go
build_arm64: export CGO_ENABLED=0
build_arm64: export GO111MODULE=on
build_arm64: export GOOS=linux
build_arm64: export GOARCH=arm64
build_arm64:
- go build -o server_arm64 *.go
+ $(GO) build -o server_arm64 *.go
build_amd64: export CGO_ENABLED=0
build_amd64: export GO111MODULE=on
build_amd64: export GOOS=linux
build_amd64: export GOARCH=amd64
build_amd64:
- go build -o server_amd64 *.go
+ $(GO) build -o server_amd64 *.go
push_arm64: clean build_arm64
$(podman) build --platform linux/arm64 --tag=$(repo_name)/auth-ui:arm64 $(docker_flags) .
diff --git a/core/auth/ui/README.md b/core/auth/ui/README.md
new file mode 100644
index 0000000..d5991e9
--- /dev/null
+++ b/core/auth/ui/README.md
@@ -0,0 +1,74 @@
+# auth-ui
+
+`auth-ui` is the server-rendered login, registration, account, password-change, and OAuth login surface for the dodo stack. It proxies existing Ory Kratos browser flows and integrates Ory Hydra login/consent challenges.
+
+## Local development
+
+Go 1.22 or newer is required. From this directory:
+
+```sh
+make format # rewrite all Go source with gofmt
+make format-check # report formatting drift without changing files
+make test # untagged unit/helper tests
+make test-race # untagged tests with the race detector
+make vet # untagged package vet
+make check # source-only format, unit, race, vet, and build checks
+make build # write ./server
+```
+
+`GO` and `GOFMT` may be overridden, for example `make GO=/path/to/go test`. `make check` does not install or launch Chromium, Kratos, or Hydra and does not download E2E runtimes.
+
+The executable requires reachable Kratos and Hydra services. See `./server -h` for the current listener and upstream flags; browser registration is disabled unless `-enable-registration` is set.
+
+## End-to-end tests
+
+The tagged suite starts isolated native Kratos, Hydra, auth-ui, Playwright, and Chromium processes on loopback ports. Detailed pinned versions, cache locations, and troubleshooting are in [`e2e/README.md`](e2e/README.md).
+
+```sh
+make install-e2e-browser # install the pinned managed Chromium
+make install-e2e-browser-deps # also install Linux host packages; may require privileges
+make test-e2e # online-capable full suite; installs/checks Chromium first
+make test-e2e-offline # full suite using already populated caches only
+```
+
+Use a separate artifact destination when retaining a release run:
+
+```sh
+AUTH_UI_E2E_ARTIFACT_DIR="$(mktemp -d)" make test-e2e
+```
+
+The default Ory cache is `e2e/cache/`; the default retained run location is `e2e/artifacts/`. Screenshots, video, traces, session metadata, and service logs can contain synthetic passwords, cookies, OAuth challenges, authorization codes, or tokens. Treat all E2E artifacts as sensitive, keep them out of Git, restrict access, and delete them after review. `make clean-e2e-artifacts` removes only the default artifact directory, not an override or any cache.
+
+## Browser behavior and assets
+
+The product uses native HTML forms and full-page navigation. It ships no product JavaScript: labels, source order, links, buttons, browser validation, server feedback, and CSS focus styles are the complete interaction model. Login uses the native `username` and `current-password` autocomplete purposes; registration and password change use `new-password`.
+
+All runtime styles are repository-owned in `static/base.css` and `static/main.css`. Pages do not load Pico, a web font, a CDN stylesheet, or another external frontend dependency. The UI remains usable when script execution is unavailable because no interaction depends on script.
+
+## Current password policy
+
+One shared Go validator is used by browser registration, browser password change, and `POST /identities`. A password must:
+
+- contain at least **20 bytes** (this is a UTF-8 byte count, not a character count);
+- contain at least one Unicode digit;
+- contain at least one Unicode lowercase letter;
+- contain at least one Unicode uppercase letter; and
+- contain at least one ASCII space or one character from this exact ASCII punctuation allowlist:
+
+```text
+!"#$%&'()*+,-./:;<=>?@[\]^_{|}~
+```
+
+The forms intentionally have one password field, no confirmation field, and no HTML `minlength`, `maxlength`, or `pattern`, because those native constraints cannot exactly reproduce the shared byte/Unicode policy.
+
+## OAuth consent
+
+OAuth consent is automatic. After successful Hydra login, auth-ui accepts all scopes requested in the existing consent challenge and continues to the client callback; there is no consent page, scope selector, allow button, or reject button. This documents current behavior, not a recommendation or a new policy.
+
+## Unavailable and deferred work
+
+This UI does not provide account recovery/forgot-password, email verification, MFA, passkeys, social or passwordless login, account deletion, session management, current-password reauthentication, or interactive OAuth consent.
+
+Security hardening is deliberately deferred and must not be inferred from the UX or E2E gates. [`FOLLOW_UP_SECURITY_ISSUES.md`](FOLLOW_UP_SECURITY_ISSUES.md) is a backlog covering TLS verification, redirect/`return_to` validation, logout CSRF, identity API authentication/exposure, request and response bounds, sensitive logging, cookie/response headers, and production-like security topology. Those items are not implemented by this release. Loopback E2E proves functional compatibility, not production transport or origin security.
+
+Automated checks cover Chromium keyboard, semantics, responsive viewports, and a 640×360 reflow proxy. They are not evidence of physical-device behavior, a screen-reader pass, native autofill activation, or manual browser zoom at 200%.
diff --git a/core/auth/ui/api.go b/core/auth/ui/api.go
index cced90d..e1b74d1 100644
--- a/core/auth/ui/api.go
+++ b/core/auth/ui/api.go
@@ -7,8 +7,6 @@
"io"
"net/http"
"net/url"
- "strings"
- "unicode"
"github.com/gorilla/mux"
)
@@ -74,51 +72,10 @@
return errors
}
-type ValidationError struct {
- Field string `json:"field"`
- Message string `json:"message"`
-}
-
type CombinedErrors struct {
Errors []ValidationError `json:"errors"`
}
-func validateUsername(username string) []ValidationError {
- var errors []ValidationError
- if len(username) < 3 {
- errors = append(errors, ValidationError{"username", "Username must be at least 3 characters long."})
- }
- // TODO other validations
- return errors
-}
-
-func validatePassword(password string) []ValidationError {
- var errors []ValidationError
- if len(password) < 20 {
- errors = append(errors, ValidationError{"password", "Password must be at least 20 characters long."})
- }
- digit := false
- lowerCase := false
- upperCase := false
- special := false
- for _, c := range password {
- if unicode.IsDigit(c) {
- digit = true
- } else if unicode.IsLower(c) {
- lowerCase = true
- } else if unicode.IsUpper(c) {
- upperCase = true
- } else if strings.Contains(" !\"#$%&'()*+,-./:;<=>?@[\\]^_{|}~", string(c)) {
- special = true
- }
- }
- if !digit || !lowerCase || !upperCase || !special {
- errors = append(errors, ValidationError{"password", "Password must contain at least one digit, lower&upper case and special characters"})
- }
- // TODO other validations
- return errors
-}
-
func replyWithErrors(w http.ResponseWriter, errors []ValidationError) {
response := CombinedErrors{Errors: errors}
w.Header().Set("Content-Type", "application/json")
diff --git a/core/auth/ui/api_test.go b/core/auth/ui/api_test.go
index 90e64be..1f653dd 100644
--- a/core/auth/ui/api_test.go
+++ b/core/auth/ui/api_test.go
@@ -1,19 +1,27 @@
package main
import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
"testing"
)
-func TestPasswordInvalid(t *testing.T) {
- errs := validatePassword("foobar")
- if len(errs) != 2 {
- t.Fatal(errs)
- }
-}
+func TestIdentityCreateSharedValidationResponseRemainsCompatible(t *testing.T) {
+ server := NewAPIServer(0, "http://kratos.invalid")
+ request := httptest.NewRequest(http.MethodPost, "/identities", strings.NewReader(`{"username":"x","password":"short"}`))
+ recorder := httptest.NewRecorder()
+ server.identityCreate(recorder, request)
-func TestPasswordValid(t *testing.T) {
- errs := validatePassword("foBa2r-gdkjS1-SA0120")
- if len(errs) != 0 {
- t.Fatal(errs)
+ const expected = `{"errors":[{"field":"username","message":"Username must be at least 3 characters long."},{"field":"password","message":"Password must be at least 20 characters long."},{"field":"password","message":"Password must contain at least one digit, lower\u0026upper case and special characters"}]}
+`
+ if recorder.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400", recorder.Code)
+ }
+ if got := recorder.Header().Get("Content-Type"); got != "application/json" {
+ t.Fatalf("content type = %q", got)
+ }
+ if got := recorder.Body.String(); got != expected {
+ t.Fatalf("body = %q, want byte-compatible %q", got, expected)
}
}
diff --git a/core/auth/ui/e2e/README.md b/core/auth/ui/e2e/README.md
index 710061d..fb105bd 100644
--- a/core/auth/ui/e2e/README.md
+++ b/core/auth/ui/e2e/README.md
@@ -59,10 +59,13 @@
## Commands
-Fast, untagged tests and vet (no Ory or browser process startup):
+Fast, untagged source gates (no Ory or browser process startup):
```sh
make test
+make vet
+# or run the complete source-only aggregate:
+make check
```
Install/check the pinned browser, then run the complete tagged suite:
@@ -173,4 +176,4 @@
## Intentional non-goals
-This suite does not cover or introduce CI, Docker/Compose, PostgreSQL, Windows, system browsers, Firefox/WebKit, parallel stacks, pixel baselines, accessibility audits, recovery, verification, settings UI, MFA, social login, registration-disabled mode, PKCE, refresh tokens, revocation, introspection, consent rejection, Hydra logout, device/client-credentials flows, or a public password-change API. It does not change product handlers, templates, selectors, styles, or static assets.
+This suite does not cover or introduce CI, Docker/Compose, PostgreSQL, Windows, system browsers, Firefox/WebKit, parallel stacks, pixel baselines, accessibility audits, recovery, verification, MFA, social login, registration-disabled mode, PKCE, refresh tokens, revocation, introspection, consent rejection, Hydra logout, device/client-credentials flows, or a public password-change API. It does not change product handlers, templates, selectors, styles, or static assets.
diff --git a/core/auth/ui/e2e/api_password_test.go b/core/auth/ui/e2e/api_password_test.go
index e62c041..1fbf3df 100644
--- a/core/auth/ui/e2e/api_password_test.go
+++ b/core/auth/ui/e2e/api_password_test.go
@@ -94,7 +94,7 @@
openKratosForm(t, session.Page, testStack.UIURL+"/login", "/login")
checkpoint(t, session, "api-created-identity-login-form")
fillCredentials(t, session.Page, username, password)
- clickButton(t, session.Page, "login")
+ clickButton(t, session.Page, "Sign in")
assertGreeting(t, session.Page, username)
checkpoint(t, session, "api-created-identity-greeting")
whoami := assertAcceptedKratosSession(t, client, session)
@@ -114,7 +114,8 @@
t.Fatal(err)
}
assertKratosForm(t, session.Page, "/login")
- if count, err := session.Page.GetByRole("button", playwright.PageGetByRoleOptions{Name: "change password", Exact: playwright.Bool(true)}).Count(); err != nil || count != 0 {
+ assertAuthStateSemantics(t, session.Page, false)
+ if count, err := session.Page.GetByRole("button", playwright.PageGetByRoleOptions{Name: "Change password", Exact: playwright.Bool(true)}).Count(); err != nil || count != 0 {
t.Fatalf("unauthenticated change-password guard exposed its form: count=%d err=%v", count, err)
}
checkpoint(t, session, "unauthenticated-change-password-guard")
@@ -122,45 +123,99 @@
registerThroughBrowser(t, session, username, oldPassword, "password-change-registration")
original := assertAcceptedKratosSession(t, client, session)
openChangePasswordForm(t, session.Page, username)
+ settingsFlow := currentFlowID(t, session.Page)
- if err := session.Page.Locator(`input[name="password"]`).Fill("short"); err != nil {
+ newPasswordField := session.Page.GetByLabel("New password", playwright.PageGetByLabelOptions{Exact: playwright.Bool(true)})
+ if err := newPasswordField.Focus(); err != nil {
+ t.Fatal("focus new-password field")
+ }
+ assertActiveFocusVisible(t, session.Page)
+ if err := session.Page.Keyboard().Press("Tab"); err != nil {
+ t.Fatal("tab from new password to submit")
+ }
+ assertFocusedElementID(t, session.Page, "change-password-submit")
+ assertActiveFocusVisible(t, session.Page)
+ if err := session.Page.Keyboard().Press("Tab"); err != nil {
+ t.Fatal("tab from password submit to account link")
+ }
+ assertFocusedElementText(t, session.Page, "Back to account")
+ if err := session.Page.Keyboard().Press("Shift+Tab"); err != nil {
+ t.Fatal("shift-tab from account link to password submit")
+ }
+ assertFocusedElementID(t, session.Page, "change-password-submit")
+ if err := session.Page.Keyboard().Press("Shift+Tab"); err != nil {
+ t.Fatal("shift-tab from password submit to new password")
+ }
+ assertFocusedElementID(t, session.Page, "change-password")
+ if err := newPasswordField.Fill("short"); err != nil {
t.Fatal("fill invalid replacement password")
}
- clickButton(t, session.Page, "change password")
+ if err := newPasswordField.Press("Enter"); err != nil {
+ t.Fatal("submit invalid password change with Enter")
+ }
assertChangePasswordForm(t, session.Page, username)
+ assertAuthStateSemantics(t, session.Page, true, "change-password")
+ if got := currentFlowID(t, session.Page); got != settingsFlow {
+ t.Fatalf("local settings validation replaced flow %q with %q", settingsFlow, got)
+ }
+ assertLatestUIResponseStatus(t, session, http.MethodPost, "/settings", http.StatusUnprocessableEntity)
assertVisibleExactText(t, session.Page, passwordLengthMessage)
assertVisibleExactText(t, session.Page, passwordCompositionMessage)
+ assertInvalidFieldDescriptions(t, session.Page, "change-password", passwordLengthMessage, passwordCompositionMessage)
+ if value, err := session.Page.GetByLabel("New password", playwright.PageGetByLabelOptions{Exact: playwright.Bool(true)}).InputValue(); err != nil || value != "" {
+ t.Fatalf("locally rejected settings password was retained: length=%d err=%v", len(value), err)
+ }
checkpoint(t, session, "invalid-password-change-validation")
- logoutThroughBrowser(t, session)
- checkpoint(t, session, "old-password-login-after-invalid-update")
- fillCredentials(t, session.Page, username, oldPassword)
- clickButton(t, session.Page, "login")
- assertGreeting(t, session.Page, username)
- oldLogin := assertAcceptedKratosSession(t, client, session)
- if oldLogin.Identity.ID != original.Identity.ID || oldLogin.Identity.Traits.Username != username {
- t.Fatal("old password after invalid update did not resolve to the original identity")
- }
- checkpoint(t, session, "old-password-accepted-after-invalid-update")
+ t.Run("old password remains valid after local rejection", func(t *testing.T) {
+ verificationSession := newKratosTestSession(t)
+ openKratosForm(t, verificationSession.Page, testStack.UIURL+"/login", "/login")
+ fillCredentials(t, verificationSession.Page, username, oldPassword)
+ clickButton(t, verificationSession.Page, "Sign in")
+ assertGreeting(t, verificationSession.Page, username)
+ oldLogin := assertAcceptedKratosSession(t, client, verificationSession)
+ if oldLogin.Identity.ID != original.Identity.ID || oldLogin.Identity.Traits.Username != username {
+ t.Fatal("old password after invalid update did not resolve to the original identity")
+ }
+ checkpoint(t, verificationSession, "old-password-accepted-after-invalid-update")
+ })
- openChangePasswordForm(t, session.Page, username)
- if err := session.Page.Locator(`input[name="password"]`).Fill(newPassword); err != nil {
+ assertChangePasswordForm(t, session.Page, username)
+ assertAuthStateSemantics(t, session.Page, true, "change-password")
+ if got := currentFlowID(t, session.Page); got != settingsFlow {
+ t.Fatalf("rejected settings form flow changed before correction: got %q, want %q", got, settingsFlow)
+ }
+ newPasswordField = session.Page.GetByLabel("New password", playwright.PageGetByLabelOptions{Exact: playwright.Bool(true)})
+ if err := newPasswordField.Fill(newPassword); err != nil {
t.Fatal("fill valid replacement password")
}
- clickButton(t, session.Page, "change password")
+ if err := newPasswordField.Press("Enter"); err != nil {
+ t.Fatal("submit password change with Enter")
+ }
assertVisibleExactText(t, session.Page, passwordChangedMessage)
+ assertLatestUIResponseStatus(t, session, http.MethodPost, "/settings", http.StatusOK)
+ if count, err := session.Page.GetByRole("status").Count(); err != nil || count != 1 {
+ t.Fatalf("password-change success status count=%d err=%v", count, err)
+ }
+ if count, err := session.Page.GetByRole("heading", playwright.PageGetByRoleOptions{Name: "Password changed", Exact: playwright.Bool(true)}).Count(); err != nil || count != 1 {
+ t.Fatalf("password-change success heading count=%d err=%v", count, err)
+ }
+ assertAuthDocumentSemantics(t, session.Page)
+ assertNoFormOrPasswordControls(t, session.Page, "password-change success")
checkpoint(t, session, "password-change-success")
+ clickLink(t, session.Page, "Back to account")
+ assertGreeting(t, session.Page, username)
logoutThroughBrowser(t, session)
checkpoint(t, session, "old-password-login-after-replacement")
fillCredentials(t, session.Page, username, oldPassword)
- clickButton(t, session.Page, "login")
- assertKratosForm(t, session.Page, "/login")
+ clickButton(t, session.Page, "Sign in")
+ assertInvalidLoginFeedback(t, session)
assertNoAcceptedKratosSession(t, client, session)
checkpoint(t, session, "old-password-rejected")
fillCredentials(t, session.Page, username, newPassword)
- clickButton(t, session.Page, "login")
+ clickButton(t, session.Page, "Sign in")
assertGreeting(t, session.Page, username)
accepted := assertAcceptedKratosSession(t, client, session)
if accepted.Identity.ID != original.Identity.ID || accepted.Identity.Traits.Username != username {
@@ -216,11 +271,11 @@
func openChangePasswordForm(t *testing.T, page playwright.Page, username string) {
t.Helper()
- link := page.Locator(`a[href="/settings"]`)
- if err := link.Click(); err != nil {
+ if err := page.GetByRole("link", playwright.PageGetByRoleOptions{Name: "Change password", Exact: playwright.Bool(true)}).Click(); err != nil {
t.Fatal("open change-password flow from the logged-in user page")
}
assertChangePasswordForm(t, page, username)
+ assertAuthStateSemantics(t, page, false)
}
func assertChangePasswordForm(t *testing.T, page playwright.Page, username string) {
@@ -229,30 +284,40 @@
if err != nil || u.Scheme+"://"+u.Host != testStack.UIURL || u.Path != "/settings" || u.Query().Get("flow") == "" {
t.Fatal("authenticated password form was not rendered through a Kratos settings flow")
}
- password := page.Locator(`input[name="password"]`)
+ assertAuthDocumentSemantics(t, page)
+ assertCurrentAuthFormState(t, page, "/settings")
+ assertFieldContract(t, page, "change-password", "password", "password", "new-password", "New password")
+ password := page.GetByLabel("New password", playwright.PageGetByLabelOptions{Exact: playwright.Bool(true)})
if err := password.WaitFor(playwright.LocatorWaitForOptions{State: playwright.WaitForSelectorStateVisible, Timeout: playwright.Float(10_000)}); err != nil {
t.Fatalf("change-password field did not become visible: %v", err)
}
- usernameInput := page.Locator(`input[name="username"]`)
- if count, err := usernameInput.Count(); err != nil || count != 1 {
- t.Fatalf("change-password form username field count=%d err=%v", count, err)
+ if count, err := password.Count(); err != nil || count != 1 {
+ t.Fatalf("change-password field count=%d err=%v", count, err)
}
- if value, err := usernameInput.InputValue(); err != nil || value != username {
- t.Fatal("change-password form did not retain the authenticated username")
+ if count, err := page.Locator("form").Count(); err != nil || count != 1 {
+ t.Fatalf("change-password form count=%d err=%v, want 1", count, err)
}
+ if count, err := page.Locator(`input[type="password"]`).Count(); err != nil || count != 1 {
+ t.Fatalf("change-password password input count=%d err=%v, want 1", count, err)
+ }
+ for _, name := range []string{"username", "method"} {
+ if count, err := page.Locator(`input[name="` + name + `"]`).Count(); err != nil || count != 0 {
+ t.Fatalf("change-password form input %q count=%d err=%v, want 0", name, count, err)
+ }
+ }
+ assertVisibleExactText(t, page, username)
}
func assertVisibleExactText(t *testing.T, page playwright.Page, text string) {
t.Helper()
- if err := page.GetByText(text, playwright.PageGetByTextOptions{Exact: playwright.Bool(true)}).WaitFor(playwright.LocatorWaitForOptions{State: playwright.WaitForSelectorStateVisible, Timeout: playwright.Float(10_000)}); err != nil {
+ if err := page.GetByText(text, playwright.PageGetByTextOptions{Exact: playwright.Bool(true)}).First().WaitFor(playwright.LocatorWaitForOptions{State: playwright.WaitForSelectorStateVisible, Timeout: playwright.Float(10_000)}); err != nil {
t.Fatalf("expected product message was not visible: %v", err)
}
}
func logoutThroughBrowser(t *testing.T, session *browserSession) {
t.Helper()
- if _, err := session.Page.Goto(testStack.UIURL + "/logout"); err != nil {
- t.Fatal(err)
- }
+ clickLink(t, session.Page, "Log out")
assertKratosForm(t, session.Page, "/login")
+ assertAuthStateSemantics(t, session.Page, false)
}
diff --git a/core/auth/ui/e2e/browser.go b/core/auth/ui/e2e/browser.go
index 19949f5..a21753a 100644
--- a/core/auth/ui/e2e/browser.go
+++ b/core/auth/ui/e2e/browser.go
@@ -99,6 +99,34 @@
Cleanup(func())
}
+type browserSize struct {
+ Width int `json:"width"`
+ Height int `json:"height"`
+}
+
+type browserSessionOptions struct {
+ Viewport browserSize
+ VideoSize browserSize
+ ReducedMotion bool
+}
+
+func defaultBrowserSessionOptions() browserSessionOptions {
+ return browserSessionOptions{
+ Viewport: browserSize{Width: 1280, Height: 720},
+ VideoSize: browserSize{Width: 1280, Height: 720},
+ }
+}
+
+func validateBrowserSessionOptions(options browserSessionOptions) error {
+ if options.Viewport.Width <= 0 || options.Viewport.Height <= 0 {
+ return fmt.Errorf("browser viewport must have positive dimensions")
+ }
+ if options.VideoSize.Width <= 0 || options.VideoSize.Height <= 0 {
+ return fmt.Errorf("browser video must have positive dimensions")
+ }
+ return nil
+}
+
type browserSession struct {
Page playwright.Page
Context playwright.BrowserContext
@@ -106,6 +134,8 @@
dir string
video playwright.Video
started time.Time
+ viewport browserSize
+ videoSize browserSize
screenshots []string
checkpoint int
finalize sync.Once
@@ -117,6 +147,8 @@
blocked []string
requestsMu sync.Mutex
requests []requestMetadata
+ diagnosticsMu sync.Mutex
+ diagnostics []string
tracingStarted bool
screenshotOp func(string) error
stopTraceOp func(string) error
@@ -126,10 +158,18 @@
}
func newBrowserSession(t testReporter, browser playwright.Browser, root string, allowedOrigins []string) (*browserSession, error) {
+ return newBrowserSessionWithOptions(t, browser, root, allowedOrigins, defaultBrowserSessionOptions())
+}
+
+func newBrowserSessionWithOptions(t testReporter, browser playwright.Browser, root string, allowedOrigins []string, options browserSessionOptions) (*browserSession, error) {
+ if err := validateBrowserSessionOptions(options); err != nil {
+ return nil, err
+ }
session, err := newBrowserSessionOwner(t, root, browser.Version())
if err != nil {
return nil, err
}
+ assignBrowserSessionMetadata(session, options)
// Register while holding the construction/finalization lock. A watchdog
// either snapshots this ownership and waits here, or rejects construction
// before any external Playwright context exists.
@@ -144,11 +184,15 @@
}
defer session.lifecycleMu.Unlock()
videoDir := filepath.Join(session.dir, ".video")
- context, err := browser.NewContext(playwright.BrowserNewContextOptions{
- Viewport: &playwright.Size{Width: 1280, Height: 720},
- RecordVideo: &playwright.RecordVideo{Dir: playwright.String(videoDir), Size: &playwright.Size{Width: 1280, Height: 720}},
+ contextOptions := playwright.BrowserNewContextOptions{
+ Viewport: &playwright.Size{Width: options.Viewport.Width, Height: options.Viewport.Height},
+ RecordVideo: &playwright.RecordVideo{Dir: playwright.String(videoDir), Size: &playwright.Size{Width: options.VideoSize.Width, Height: options.VideoSize.Height}},
ServiceWorkers: playwright.ServiceWorkerPolicyBlock,
- })
+ }
+ if options.ReducedMotion {
+ contextOptions.ReducedMotion = playwright.ReducedMotionReduce
+ }
+ context, err := browser.NewContext(contextOptions)
if err != nil {
return nil, err
}
@@ -169,6 +213,14 @@
page.OnResponse(func(response playwright.Response) {
session.recordRequest(response.Request().Method(), response.Status(), response.Request().URL())
})
+ page.OnConsole(func(message playwright.ConsoleMessage) {
+ if message.Type() == "error" && !isExpectedFormStatusConsoleError(message.Text()) {
+ session.recordDiagnostic("console error: " + message.Text())
+ }
+ })
+ page.OnPageError(func(err error) {
+ session.recordDiagnostic("page error: " + err.Error())
+ })
s := session
s.Page = page
s.video = page.Video()
@@ -189,6 +241,11 @@
return s, nil
}
+func assignBrowserSessionMetadata(session *browserSession, options browserSessionOptions) {
+ session.viewport = options.Viewport
+ session.videoSize = options.VideoSize
+}
+
func newBrowserSessionOwner(t testReporter, root, browserVersion string) (*browserSession, error) {
dir := filepath.Join(root, sanitizeName(t.Name()))
if err := os.MkdirAll(filepath.Join(dir, "screenshots"), 0o700); err != nil {
@@ -197,7 +254,8 @@
if err := os.MkdirAll(filepath.Join(dir, ".video"), 0o700); err != nil {
return nil, err
}
- session := &browserSession{t: t, dir: dir, started: time.Now().UTC(), browserVer: browserVersion}
+ defaults := defaultBrowserSessionOptions()
+ session := &browserSession{t: t, dir: dir, started: time.Now().UTC(), browserVer: browserVersion, viewport: defaults.Viewport, videoSize: defaults.VideoSize}
session.installDefaultArtifactOps()
// Own metadata and all available partial artifacts before Playwright context
// construction. If Playwright cannot create a context/page/trace, cleanup
@@ -275,6 +333,28 @@
return sortedStrings(s.blocked)
}
+func isExpectedFormStatusConsoleError(message string) bool {
+ switch message {
+ case "Failed to load resource: the server responded with a status of 409 (Conflict)",
+ "Failed to load resource: the server responded with a status of 422 (Unprocessable Entity)":
+ return true
+ default:
+ return false
+ }
+}
+
+func (s *browserSession) recordDiagnostic(message string) {
+ s.diagnosticsMu.Lock()
+ s.diagnostics = append(s.diagnostics, message)
+ s.diagnosticsMu.Unlock()
+}
+
+func (s *browserSession) BrowserDiagnostics() []string {
+ s.diagnosticsMu.Lock()
+ defer s.diagnosticsMu.Unlock()
+ return append([]string(nil), s.diagnostics...)
+}
+
func (s *browserSession) Checkpoint(name string) error {
s.lifecycleMu.Lock()
defer s.lifecycleMu.Unlock()
@@ -369,7 +449,7 @@
if s.t.Failed() || s.forcedFailure || len(errs) > 0 {
outcome = "failed"
}
- metadata := sessionMetadata{TestName: s.t.Name(), StartedAt: s.started, FinishedAt: time.Now().UTC(), Outcome: outcome, BindingVersion: playwrightVersion, CLIVersion: playwrightCLIVersion, ChromiumRevision: chromiumRevision, BrowserVersion: s.browserVer, Screenshots: s.screenshots, FinalURL: finalURL}
+ metadata := sessionMetadata{TestName: s.t.Name(), StartedAt: s.started, FinishedAt: time.Now().UTC(), Outcome: outcome, BindingVersion: playwrightVersion, CLIVersion: playwrightCLIVersion, ChromiumRevision: chromiumRevision, BrowserVersion: s.browserVer, Viewport: s.viewport, VideoSize: s.videoSize, Screenshots: s.screenshots, FinalURL: finalURL}
data, err := json.MarshalIndent(metadata, "", " ")
if err == nil {
err = os.WriteFile(filepath.Join(s.dir, "session.json"), append(data, '\n'), 0o600)
@@ -394,23 +474,19 @@
return "passed", false
}
-const expectedExternalFontRequest = "https://cdnjs.cloudflare.com/ajax/libs/hack-font/3.3.0/web/hack.min.css"
-
-func isExpectedBlockedBrowserRequest(request string) bool {
- return request == expectedExternalFontRequest
-}
-
type sessionMetadata struct {
- TestName string `json:"test_name"`
- StartedAt time.Time `json:"started_at"`
- FinishedAt time.Time `json:"finished_at"`
- Outcome string `json:"outcome"`
- BindingVersion string `json:"binding_version"`
- CLIVersion string `json:"playwright_cli_version"`
- ChromiumRevision string `json:"chromium_revision"`
- BrowserVersion string `json:"browser_version"`
- Screenshots []string `json:"screenshots"`
- FinalURL string `json:"final_url"`
+ TestName string `json:"test_name"`
+ StartedAt time.Time `json:"started_at"`
+ FinishedAt time.Time `json:"finished_at"`
+ Outcome string `json:"outcome"`
+ BindingVersion string `json:"binding_version"`
+ CLIVersion string `json:"playwright_cli_version"`
+ ChromiumRevision string `json:"chromium_revision"`
+ BrowserVersion string `json:"browser_version"`
+ Viewport browserSize `json:"viewport"`
+ VideoSize browserSize `json:"video_size"`
+ Screenshots []string `json:"screenshots"`
+ FinalURL string `json:"final_url"`
}
var unsafeName = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
diff --git a/core/auth/ui/e2e/browser_artifacts_test.go b/core/auth/ui/e2e/browser_artifacts_test.go
index c51766b..12ef1ec 100644
--- a/core/auth/ui/e2e/browser_artifacts_test.go
+++ b/core/auth/ui/e2e/browser_artifacts_test.go
@@ -72,15 +72,11 @@
if err := session.screenshot("00-login.png"); err != nil {
t.Fatal(err)
}
- blocked := session.BlockedRequests()
- foundCDN := false
- for _, request := range blocked {
- if strings.HasPrefix(request, "https://cdnjs.cloudflare.com/") {
- foundCDN = true
- }
+ if blocked := session.BlockedRequests(); len(blocked) != 0 {
+ t.Fatalf("self-contained login page made blocked requests: %v", blocked)
}
- if !foundCDN {
- t.Fatalf("expected external CDN font request to be aborted, blocked=%v", blocked)
+ if diagnostics := session.BrowserDiagnostics(); len(diagnostics) != 0 {
+ t.Fatalf("login page emitted browser diagnostics: %v", diagnostics)
}
policy := newRoutePolicy(testStack.allowedOrigins())
for _, request := range session.RequestMetadata() {
@@ -109,6 +105,11 @@
func assertArtifactSet(t *testing.T, dir string, failedBeforeFinalize bool) {
t.Helper()
+ assertArtifactSetWithOptions(t, dir, failedBeforeFinalize, defaultBrowserSessionOptions())
+}
+
+func assertArtifactSetWithOptions(t *testing.T, dir string, failedBeforeFinalize bool, expected browserSessionOptions) {
+ t.Helper()
wantOutcome, requireFailureScreenshot := artifactOutcomeExpectation(failedBeforeFinalize, t.Failed())
required := []string{"screenshots/00-initial.png", "screenshots/99-final.png", "video.webm", "trace.zip", "session.json"}
if requireFailureScreenshot {
@@ -140,4 +141,7 @@
if metadata.BindingVersion != playwrightVersion || metadata.CLIVersion != playwrightCLIVersion || metadata.ChromiumRevision != chromiumRevision || metadata.BrowserVersion != chromiumVersion {
t.Fatalf("unexpected Playwright metadata: %+v", metadata)
}
+ if metadata.Viewport != expected.Viewport || metadata.VideoSize != expected.VideoSize {
+ t.Fatalf("session dimensions viewport=%+v video=%+v, want viewport=%+v video=%+v", metadata.Viewport, metadata.VideoSize, expected.Viewport, expected.VideoSize)
+ }
}
diff --git a/core/auth/ui/e2e/browser_test.go b/core/auth/ui/e2e/browser_test.go
index 9422096..4397a48 100644
--- a/core/auth/ui/e2e/browser_test.go
+++ b/core/auth/ui/e2e/browser_test.go
@@ -149,18 +149,45 @@
}
}
-func TestExpectedBlockedBrowserRequest(t *testing.T) {
- if !isExpectedBlockedBrowserRequest(expectedExternalFontRequest) {
- t.Fatal("known external font request was not recognized")
+func TestBrowserSessionOptions(t *testing.T) {
+ defaults := defaultBrowserSessionOptions()
+ if defaults.Viewport != (browserSize{Width: 1280, Height: 720}) || defaults.VideoSize != defaults.Viewport || defaults.ReducedMotion {
+ t.Fatalf("default browser options=%+v", defaults)
}
- for _, request := range []string{
- "https://cdnjs.cloudflare.com/other.css",
- "https://example.test/unexpected.js",
- "http://127.0.0.1:1234/unowned",
- expectedExternalFontRequest + "?token=secret",
+ if err := validateBrowserSessionOptions(defaults); err != nil {
+ t.Fatalf("default browser options rejected: %v", err)
+ }
+ for _, options := range []browserSessionOptions{
+ {Viewport: browserSize{Width: 0, Height: 720}, VideoSize: defaults.VideoSize},
+ {Viewport: defaults.Viewport, VideoSize: browserSize{Width: 1280, Height: -1}},
} {
- if isExpectedBlockedBrowserRequest(request) {
- t.Fatalf("unexpected blocked request was accepted: %s", sanitizeFinalURL(request))
+ if err := validateBrowserSessionOptions(options); err == nil {
+ t.Fatalf("invalid browser options accepted: %+v", options)
+ }
+ }
+}
+
+func TestExpectedFormStatusConsoleError(t *testing.T) {
+ canonical409 := "Failed to load resource: the server responded with a status of 409 (Conflict)"
+ canonical422 := "Failed to load resource: the server responded with a status of 422 (Unprocessable Entity)"
+ for _, message := range []string{canonical409, canonical422} {
+ if !isExpectedFormStatusConsoleError(message) {
+ t.Fatalf("expected form-status diagnostic was not recognized: %q", message)
+ }
+ }
+ for _, message := range []string{
+ "product console error",
+ "prefix " + canonical409,
+ canonical409 + " suffix",
+ " " + canonical422,
+ canonical422 + " ",
+ "Failed to load resource: the server responded with a status of 404 (Not Found)",
+ "Failed to load resource: the server responded with a status of 409 (Unprocessable Entity)",
+ "Failed to load resource: the server responded with a status of 422 (Conflict)",
+ "Failed to load resource: the server responded with a status of 500 (Internal Server Error)",
+ } {
+ if isExpectedFormStatusConsoleError(message) {
+ t.Fatalf("unexpected diagnostic was suppressed: %q", message)
}
}
}
@@ -189,13 +216,13 @@
if got := sanitizeFinalURL("http://127.0.0.1:1234/login?flow=sensitive#fragment"); got != "http://127.0.0.1:1234/login" {
t.Fatalf("sanitized URL=%q", got)
}
- metadata := sessionMetadata{TestName: "TestFailure", StartedAt: time.Unix(1, 0).UTC(), FinishedAt: time.Unix(2, 0).UTC(), Outcome: "failed", BindingVersion: playwrightVersion, CLIVersion: playwrightCLIVersion, ChromiumRevision: chromiumRevision, BrowserVersion: chromiumVersion, Screenshots: []string{"00-initial.png", "99-final.png", "failure.png"}, FinalURL: "http://127.0.0.1:1/login"}
+ metadata := sessionMetadata{TestName: "TestFailure", StartedAt: time.Unix(1, 0).UTC(), FinishedAt: time.Unix(2, 0).UTC(), Outcome: "failed", BindingVersion: playwrightVersion, CLIVersion: playwrightCLIVersion, ChromiumRevision: chromiumRevision, BrowserVersion: chromiumVersion, Viewport: browserSize{Width: 1280, Height: 720}, VideoSize: browserSize{Width: 1280, Height: 720}, Screenshots: []string{"00-initial.png", "99-final.png", "failure.png"}, FinalURL: "http://127.0.0.1:1/login"}
data, err := json.Marshal(metadata)
if err != nil {
t.Fatal(err)
}
text := string(data)
- for _, required := range []string{`"outcome":"failed"`, `"failure.png"`, `"binding_version":"v0.6100.0"`, `"playwright_cli_version":"1.61.1"`, `"chromium_revision":"1228"`, `"browser_version":"149.0.7827.55"`} {
+ for _, required := range []string{`"outcome":"failed"`, `"failure.png"`, `"binding_version":"v0.6100.0"`, `"playwright_cli_version":"1.61.1"`, `"chromium_revision":"1228"`, `"browser_version":"149.0.7827.55"`, `"viewport":{"width":1280,"height":720}`, `"video_size":{"width":1280,"height":720}`} {
if !strings.Contains(text, required) {
t.Errorf("metadata missing %s: %s", required, text)
}
@@ -294,13 +321,18 @@
}
}
-func TestPartialBrowserOwnerRecordsMetadataAndCleanupError(t *testing.T) {
+func TestPartialBrowserOwnerRecordsRequestedMetadataAndCleanupError(t *testing.T) {
reporter := &fakeReporter{name: "TestContextConstructionFailure", failed: true}
root := t.TempDir()
s, err := newBrowserSessionOwner(reporter, root, chromiumVersion)
if err != nil {
t.Fatal(err)
}
+ requested := browserSessionOptions{
+ Viewport: browserSize{Width: 390, Height: 844},
+ VideoSize: browserSize{Width: 640, Height: 360},
+ }
+ assignBrowserSessionMetadata(s, requested)
raw := filepath.Join(s.dir, ".video", "partial.data")
if err := os.WriteFile(raw, []byte("partial Playwright data"), 0o600); err != nil {
t.Fatal(err)
@@ -323,6 +355,9 @@
if metadata.Outcome != "failed" {
t.Fatalf("metadata outcome=%q", metadata.Outcome)
}
+ if metadata.Viewport != requested.Viewport || metadata.VideoSize != requested.VideoSize {
+ t.Fatalf("partial metadata viewport=%+v video=%+v, want viewport=%+v video=%+v", metadata.Viewport, metadata.VideoSize, requested.Viewport, requested.VideoSize)
+ }
for _, unavailable := range []string{"screenshots/99-final.png", "screenshots/failure.png", "trace.zip", "video.webm"} {
if _, err := os.Stat(filepath.Join(s.dir, unavailable)); !os.IsNotExist(err) {
t.Fatalf("unavailable artifact %s was fabricated: %v", unavailable, err)
diff --git a/core/auth/ui/e2e/hydra_test.go b/core/auth/ui/e2e/hydra_test.go
index 1b78c8c..012bdf2 100644
--- a/core/auth/ui/e2e/hydra_test.go
+++ b/core/auth/ui/e2e/hydra_test.go
@@ -11,6 +11,8 @@
"strings"
"testing"
"time"
+
+ playwright "github.com/mxschmitt/playwright-go"
)
type workflowStep struct {
@@ -37,7 +39,7 @@
assertKratosForm(t, session.Page, "/login")
checkpoint(t, session, "authorization-login-form")
fillCredentials(t, session.Page, username, password)
- clickButton(t, session.Page, "login")
+ clickButton(t, session.Page, "Sign in")
checkpoint(t, session, "post-login-automatic-consent")
firstCallback, err := firstAttempt.Wait(context.Background())
@@ -49,6 +51,7 @@
t.Fatal(err)
}
assertCallbackPage(t, session, callbacks)
+ assertConsentUIAbsent(t, session)
checkpoint(t, session, "callback-completion")
if err := firstAttempt.Finish(context.Background()); err != nil {
t.Fatal(err)
@@ -88,7 +91,7 @@
t.Fatal(err)
}
assertCallbackPage(t, session, callbacks)
- if count, err := session.Page.Locator(`input[name="password"]`).Count(); err != nil || count != 0 {
+ if count, err := session.Page.GetByLabel("Password").Count(); err != nil || count != 0 {
t.Fatalf("second authorization rendered another password form: count=%d", count)
}
checkpoint(t, session, "second-authenticated-callback")
@@ -99,6 +102,74 @@
_ = exchangeAndValidateHydraCode(t, client, clientID, clientSecret, callbacks.URI(), secondCode, username, secondNonce)
}
+func TestHydraFailedLoginCorrection(t *testing.T) {
+ tests := []struct {
+ name string
+ credential func(t *testing.T, username, password string) (string, string)
+ }{
+ {
+ name: "wrong password",
+ credential: func(t *testing.T, username, _ string) (string, string) {
+ _, wrongPassword := uniqueKratosCredentials(t)
+ return username, wrongPassword
+ },
+ },
+ {
+ name: "unknown username",
+ credential: func(t *testing.T, _ string, password string) (string, string) {
+ unknownUsername, _ := uniqueKratosCredentials(t)
+ return unknownUsername, password
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ session, callbacks := newHydraTestSession(t)
+ client := newDirectAPIClient(testStack.KratosURL, testStack.KratosAdmin)
+ defer client.close()
+ username, password, clientID, clientSecret := createHydraBrowserUserAndClient(t, client, callbacks.URI())
+ state := uniqueOAuthValue(t, "state")
+ nonce := uniqueOAuthValue(t, "nonce")
+ attempt, err := callbacks.Begin()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := session.Page.Goto(hydraAuthorizationURL(testStack.HydraURL, clientID, callbacks.URI(), state, nonce)); err != nil {
+ t.Fatal("navigate to Hydra authorization request")
+ }
+ assertKratosForm(t, session.Page, "/login")
+ attemptedUsername, attemptedPassword := tt.credential(t, username, password)
+ fillCredentials(t, session.Page, attemptedUsername, attemptedPassword)
+ clickButton(t, session.Page, "Sign in")
+ assertInvalidLoginFeedback(t, session)
+ assertNoAcceptedKratosSession(t, client, session)
+ assertNoOAuthCallbackYet(t, attempt)
+ assertHydraLoginChallengePending(t, client, session)
+ checkpoint(t, session, "challenged-login-rejection")
+
+ fillCredentials(t, session.Page, username, password)
+ clickButton(t, session.Page, "Sign in")
+ callback, err := attempt.Wait(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ code, err := validateOAuthCallback(callback, state)
+ if err != nil {
+ t.Fatal(err)
+ }
+ assertCallbackPage(t, session, callbacks)
+ if err := attempt.Finish(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ assertAcceptedKratosSession(t, client, session)
+ assertLoginChallengeCookieCleared(t, session)
+ assertUIRequestCount(t, session, http.MethodPost, "/login", 2)
+ _ = exchangeAndValidateHydraCode(t, client, clientID, clientSecret, callbacks.URI(), code, username, nonce)
+ checkpoint(t, session, "challenged-login-corrected-callback")
+ })
+ }
+}
+
func TestHydraStaleLoginChallengeDoesNotBreakDirectLogin(t *testing.T) {
session, callbacks := newHydraTestSession(t)
client := newDirectAPIClient(testStack.KratosURL, testStack.KratosAdmin)
@@ -116,7 +187,7 @@
}
assertKratosForm(t, session.Page, "/login")
fillCredentials(t, session.Page, username, password)
- clickButton(t, session.Page, "login")
+ clickButton(t, session.Page, "Sign in")
callback, err := authAttempt.Wait(context.Background())
if err != nil {
t.Fatal(err)
@@ -139,7 +210,7 @@
assertNoAcceptedKratosSession(t, client, session)
fillCredentials(t, session.Page, username, password)
- clickButton(t, session.Page, "login")
+ clickButton(t, session.Page, "Sign in")
assertAcceptedKratosSession(t, client, session)
current, err := url.Parse(session.Page.URL())
@@ -153,6 +224,67 @@
assertGreeting(t, session.Page, username)
}
+func assertNoOAuthCallbackYet(t *testing.T, attempt *callbackAttempt) {
+ t.Helper()
+ attempt.capture.mu.Lock()
+ defer attempt.capture.mu.Unlock()
+ if attempt.count != 0 || attempt.done {
+ t.Fatalf("failed login produced callback count=%d done=%v", attempt.count, attempt.done)
+ }
+}
+
+func loginChallengeCookie(t *testing.T, session *browserSession) string {
+ t.Helper()
+ cookies, err := session.Context.Cookies(testStack.UIURL)
+ if err != nil {
+ t.Fatal("read auth-ui cookies")
+ }
+ for _, cookie := range cookies {
+ if cookie.Name == "login_challenge" && cookie.Value != "" {
+ return cookie.Value
+ }
+ }
+ return ""
+}
+
+func assertHydraLoginChallengePending(t *testing.T, client *directAPIClient, session *browserSession) {
+ t.Helper()
+ challenge := loginChallengeCookie(t, session)
+ if challenge == "" {
+ t.Fatal("failed challenged login did not preserve its challenge cookie")
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ request, err := http.NewRequestWithContext(ctx, http.MethodGet, testStack.HydraAdmin+"/admin/oauth2/auth/requests/login?login_challenge="+url.QueryEscape(challenge), nil)
+ if err != nil {
+ t.Fatal("construct pending Hydra login request check")
+ }
+ status, _, err := client.do(ctx, request)
+ if err != nil || status != http.StatusOK {
+ t.Fatalf("Hydra login challenge was not pending: status=%d err=%v", status, err)
+ }
+}
+
+func assertLoginChallengeCookieCleared(t *testing.T, session *browserSession) {
+ t.Helper()
+ if challenge := loginChallengeCookie(t, session); challenge != "" {
+ t.Fatal("successful challenged retry did not clear the challenge cookie")
+ }
+}
+
+func assertUIRequestCount(t *testing.T, session *browserSession, method, path string, want int) {
+ t.Helper()
+ count := 0
+ for _, request := range session.RequestMetadata() {
+ if request.Status != 0 && request.Origin == testStack.UIURL && request.Method == method && request.Path == path {
+ count++
+ }
+ }
+ if count != want {
+ t.Fatalf("completed UI %s %s request count=%d, want %d", method, path, count, want)
+ }
+}
+
func createHydraBrowserUserAndClient(t *testing.T, client *directAPIClient, callbackURI string) (username, password, clientID, clientSecret string) {
t.Helper()
username, password = uniqueKratosCredentials(t)
@@ -320,6 +452,34 @@
}
}
+func assertConsentUIAbsent(t *testing.T, session *browserSession) {
+ t.Helper()
+ consentRedirects := 0
+ for _, request := range session.RequestMetadata() {
+ if request.Origin != testStack.UIURL || request.Path != "/consent" || request.Status == 0 {
+ continue
+ }
+ if request.Method != http.MethodGet || request.Status < http.StatusMultipleChoices || request.Status >= http.StatusBadRequest {
+ t.Fatalf("automatic consent produced an unexpected browser response: %+v", request)
+ }
+ consentRedirects++
+ }
+ if consentRedirects != 1 {
+ t.Fatalf("automatic consent redirect count=%d, want 1", consentRedirects)
+ }
+ for name, locator := range map[string]playwright.Locator{
+ "consent form": session.Page.Locator(`form[action*="consent"]`),
+ "scope control": session.Page.Locator(`[name="scope"]`),
+ "allow action": session.Page.GetByRole("button", playwright.PageGetByRoleOptions{Name: "Allow", Exact: playwright.Bool(true)}),
+ "reject action": session.Page.GetByRole("button", playwright.PageGetByRoleOptions{Name: "Reject", Exact: playwright.Bool(true)}),
+ "consent heading": session.Page.GetByRole("heading", playwright.PageGetByRoleOptions{Name: "Consent", Exact: playwright.Bool(true)}),
+ } {
+ if count, err := locator.Count(); err != nil || count != 0 {
+ t.Fatalf("%s count=%d err=%v, want 0", name, count, err)
+ }
+ }
+}
+
func assertSecondAuthorizationUsesExistingSession(t *testing.T, metadata []requestMetadata) {
t.Helper()
observedUILoginGET := false
@@ -360,9 +520,10 @@
t.Errorf("browser did not receive an expected successful OAuth workflow response from %s", origin)
}
}
- for _, request := range session.BlockedRequests() {
- if !isExpectedBlockedBrowserRequest(request) {
- t.Errorf("browser blocked an unexpected query-free target %s", request)
- }
+ if blocked := session.BlockedRequests(); len(blocked) != 0 {
+ t.Errorf("browser blocked requests during self-contained OAuth UI: %v", blocked)
+ }
+ if diagnostics := session.BrowserDiagnostics(); len(diagnostics) != 0 {
+ t.Errorf("browser reported page/console errors during OAuth: %v", diagnostics)
}
}
diff --git a/core/auth/ui/e2e/kratos_test.go b/core/auth/ui/e2e/kratos_test.go
index d909b1d..b7d739d 100644
--- a/core/auth/ui/e2e/kratos_test.go
+++ b/core/auth/ui/e2e/kratos_test.go
@@ -46,6 +46,7 @@
client := newDirectAPIClient(testStack.KratosURL, testStack.KratosAdmin)
defer client.close()
username, password := uniqueKratosCredentials(t)
+ correctedUsername, _ := uniqueKratosCredentials(t)
registerThroughBrowser(t, session, username, password, "initial-registration")
first := assertAcceptedKratosSession(t, client, session)
@@ -57,20 +58,93 @@
openKratosForm(t, session.Page, testStack.UIURL+"/register", "/register")
checkpoint(t, session, "duplicate-registration-form")
fillCredentials(t, session.Page, username, password)
- clickButton(t, session.Page, "Create Account")
- assertKratosForm(t, session.Page, "/login")
- checkpoint(t, session, "duplicate-registration-return-login")
+ clickButton(t, session.Page, "Create account")
+ assertKratosForm(t, session.Page, "/register")
+ assertAuthStateSemantics(t, session.Page, true)
+ assertLatestUIResponseStatus(t, session, http.MethodPost, "/register", http.StatusConflict)
+ assertVisibleExactText(t, session.Page, usernameUnavailableMessage)
+ assertCredentialValues(t, session.Page, username, "")
+ checkpoint(t, session, "duplicate-registration-feedback")
assertNoAcceptedKratosSession(t, client, session)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancel()
identities, err := client.kratosIdentitiesByUsername(ctx, username)
+ cancel()
if err != nil {
t.Fatal(err)
}
if len(identities) != 1 || identities[0].ID != first.Identity.ID {
t.Fatalf("duplicate registration identity count=%d, want exactly one unchanged identity", len(identities))
}
+
+ fillCredentials(t, session.Page, correctedUsername, password)
+ clickButton(t, session.Page, "Create account")
+ assertGreeting(t, session.Page, correctedUsername)
+ checkpoint(t, session, "corrected-registration-greeting")
+ corrected := assertAcceptedKratosSession(t, client, session)
+ ctx, cancel = context.WithTimeout(context.Background(), 10*time.Second)
+ identities, err = client.kratosIdentitiesByUsername(ctx, correctedUsername)
+ cancel()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(identities) != 1 || identities[0].ID != corrected.Identity.ID {
+ t.Fatalf("corrected registration identity count=%d, want exactly one new identity", len(identities))
+ }
+}
+
+func TestKratosLocalRegistrationValidationAndCorrection(t *testing.T) {
+ session := newKratosTestSession(t)
+ client := newDirectAPIClient(testStack.KratosURL, testStack.KratosAdmin)
+ defer client.close()
+ username, password := uniqueKratosCredentials(t)
+
+ openKratosForm(t, session.Page, testStack.UIURL+"/register", "/register")
+ originalFlow := currentFlowID(t, session.Page)
+ fillCredentials(t, session.Page, "ab", "short-secret")
+ usernameField := session.Page.GetByLabel("Username", playwright.PageGetByLabelOptions{Exact: playwright.Bool(true)})
+ if err := usernameField.Focus(); err != nil {
+ t.Fatal("focus registration username")
+ }
+ assertActiveFocusVisible(t, session.Page)
+ if err := session.Page.Keyboard().Press("Tab"); err != nil {
+ t.Fatal("tab from registration username to password")
+ }
+ assertFocusedElementID(t, session.Page, "register-password")
+ assertActiveFocusVisible(t, session.Page)
+ if err := session.Page.Keyboard().Press("Tab"); err != nil {
+ t.Fatal("tab from registration password to submit")
+ }
+ assertFocusedElementID(t, session.Page, "register-submit")
+ assertActiveFocusVisible(t, session.Page)
+ if err := session.Page.Keyboard().Press("Shift+Tab"); err != nil {
+ t.Fatal("shift-tab from registration submit to password")
+ }
+ assertFocusedElementID(t, session.Page, "register-password")
+ if err := session.Page.Keyboard().Press("Enter"); err != nil {
+ t.Fatal("submit registration with Enter from password")
+ }
+ assertVisibleExactText(t, session.Page, passwordLengthMessage)
+ assertKratosForm(t, session.Page, "/register")
+ assertAuthStateSemantics(t, session.Page, true, "register-username", "register-password")
+ if got := currentFlowID(t, session.Page); got != originalFlow {
+ t.Fatal("local validation replaced the current registration flow")
+ }
+ assertLatestUIResponseStatus(t, session, http.MethodPost, "/register", http.StatusUnprocessableEntity)
+ assertVisibleExactText(t, session.Page, usernameLengthMessage)
+ assertVisibleExactText(t, session.Page, passwordLengthMessage)
+ assertVisibleExactText(t, session.Page, passwordCompositionMessage)
+ assertInvalidFieldDescriptions(t, session.Page, "register-username", usernameLengthMessage)
+ assertInvalidFieldDescriptions(t, session.Page, "register-password", passwordLengthMessage, passwordCompositionMessage)
+ assertCredentialValues(t, session.Page, "ab", "")
+ assertNoAcceptedKratosSession(t, client, session)
+ checkpoint(t, session, "local-registration-validation")
+
+ fillCredentials(t, session.Page, username, password)
+ clickButton(t, session.Page, "Create account")
+ assertGreeting(t, session.Page, username)
+ assertAcceptedKratosSession(t, client, session)
+ checkpoint(t, session, "corrected-registration-success")
}
func TestKratosLogoutInvalidatesSession(t *testing.T) {
@@ -84,8 +158,23 @@
if _, accepted, err := client.kratosWhoAmI(context.Background(), acceptedCookies); err != nil || !accepted {
t.Fatalf("registered Kratos session was not accepted before logout: accepted=%v err=%v", accepted, err)
}
- clickLink(t, session.Page, "logout")
+ if err := session.Page.Keyboard().Press("Tab"); err != nil {
+ t.Fatal("tab to first account action")
+ }
+ assertFocusedElementText(t, session.Page, "Change password")
+ assertActiveFocusVisible(t, session.Page)
+ if err := session.Page.Keyboard().Press("Tab"); err != nil {
+ t.Fatal("tab to logout action")
+ }
+ assertFocusedElementText(t, session.Page, "Log out")
+ assertActiveFocusVisible(t, session.Page)
+ if err := session.Page.Keyboard().Press("Shift+Tab"); err != nil {
+ t.Fatal("shift-tab to change-password action")
+ }
+ assertFocusedElementText(t, session.Page, "Change password")
+ clickLink(t, session.Page, "Log out")
assertKratosForm(t, session.Page, "/login")
+ assertAuthStateSemantics(t, session.Page, false)
checkpoint(t, session, "logout-return-login")
if _, accepted, err := client.kratosWhoAmI(context.Background(), acceptedCookies); err != nil {
@@ -104,11 +193,38 @@
registerThroughBrowser(t, session, username, password, "registration-before-login")
registered := assertAcceptedKratosSession(t, client, session)
- clickLink(t, session.Page, "logout")
+ clickLink(t, session.Page, "Log out")
assertKratosForm(t, session.Page, "/login")
checkpoint(t, session, "later-login-form")
- fillCredentials(t, session.Page, username, password)
- clickButton(t, session.Page, "login")
+ usernameField := session.Page.GetByLabel("Username", playwright.PageGetByLabelOptions{Exact: playwright.Bool(true)})
+ passwordField := session.Page.GetByLabel("Password", playwright.PageGetByLabelOptions{Exact: playwright.Bool(true)})
+ if err := usernameField.Focus(); err != nil {
+ t.Fatal("focus username for keyboard login")
+ }
+ if err := usernameField.Fill(username); err != nil {
+ t.Fatal("fill username for keyboard login")
+ }
+ if err := session.Page.Keyboard().Press("Tab"); err != nil {
+ t.Fatal("tab from username to password")
+ }
+ assertFocusedElementID(t, session.Page, "login-password")
+ if err := passwordField.Fill(password); err != nil {
+ t.Fatal("fill password for keyboard login")
+ }
+ if err := session.Page.Keyboard().Press("Tab"); err != nil {
+ t.Fatal("tab from password to submit")
+ }
+ assertFocusedElementID(t, session.Page, "login-submit")
+ if err := session.Page.Keyboard().Press("Shift+Tab"); err != nil {
+ t.Fatal("shift-tab from submit to password")
+ }
+ assertFocusedElementID(t, session.Page, "login-password")
+ if err := session.Page.Keyboard().Press("Enter"); err != nil {
+ t.Fatal("submit login with Enter from password")
+ }
+ if err := session.Page.WaitForURL(testStack.UIURL + "/"); err != nil {
+ t.Fatalf("wait for keyboard login navigation: %v", err)
+ }
assertGreeting(t, session.Page, username)
checkpoint(t, session, "later-login-greeting")
loggedIn := assertAcceptedKratosSession(t, client, session)
@@ -125,12 +241,12 @@
_, wrongPassword := uniqueKratosCredentials(t)
registerThroughBrowser(t, session, username, password, "registration-before-wrong-password")
- clickLink(t, session.Page, "logout")
+ clickLink(t, session.Page, "Log out")
assertKratosForm(t, session.Page, "/login")
checkpoint(t, session, "wrong-password-login-form")
fillCredentials(t, session.Page, username, wrongPassword)
- clickButton(t, session.Page, "login")
- assertKratosForm(t, session.Page, "/login")
+ clickButton(t, session.Page, "Sign in")
+ assertInvalidLoginFeedback(t, session)
checkpoint(t, session, "wrong-password-return-login")
assertNoAcceptedKratosSession(t, client, session)
}
@@ -144,8 +260,8 @@
openKratosForm(t, session.Page, testStack.UIURL+"/login", "/login")
checkpoint(t, session, "unknown-username-login-form")
fillCredentials(t, session.Page, username, password)
- clickButton(t, session.Page, "login")
- assertKratosForm(t, session.Page, "/login")
+ clickButton(t, session.Page, "Sign in")
+ assertInvalidLoginFeedback(t, session)
checkpoint(t, session, "unknown-username-return-login")
assertNoAcceptedKratosSession(t, client, session)
@@ -162,10 +278,15 @@
func newKratosTestSession(t *testing.T) *browserSession {
t.Helper()
+ return newKratosTestSessionWithOptions(t, defaultBrowserSessionOptions())
+}
+
+func newKratosTestSessionWithOptions(t *testing.T, options browserSessionOptions) *browserSession {
+ t.Helper()
dir := filepath.Join(testStack.ArtifactDir, sanitizeName(t.Name()))
failedBeforeFinalize := false
- t.Cleanup(func() { assertArtifactSet(t, dir, failedBeforeFinalize) })
- session, err := newBrowserSession(t, testBrowser.Browser, testStack.ArtifactDir, []string{testStack.UIURL, testStack.KratosURL})
+ t.Cleanup(func() { assertArtifactSetWithOptions(t, dir, failedBeforeFinalize, options) })
+ session, err := newBrowserSessionWithOptions(t, testBrowser.Browser, testStack.ArtifactDir, []string{testStack.UIURL, testStack.KratosURL}, options)
if err != nil {
t.Fatal(err)
}
@@ -192,7 +313,7 @@
openKratosForm(t, session.Page, testStack.UIURL+"/register", "/register")
checkpoint(t, session, checkpointPrefix+"-form")
fillCredentials(t, session.Page, username, password)
- clickButton(t, session.Page, "Create Account")
+ clickButton(t, session.Page, "Create account")
assertGreeting(t, session.Page, username)
checkpoint(t, session, checkpointPrefix+"-greeting")
}
@@ -203,6 +324,7 @@
t.Fatal(err)
}
assertKratosForm(t, page, route)
+ assertAuthStateSemantics(t, page, false)
}
func assertKratosForm(t *testing.T, page playwright.Page, route string) {
@@ -211,10 +333,28 @@
if err != nil || u.Scheme+"://"+u.Host != testStack.UIURL || u.Path != route || u.Query().Get("flow") == "" {
t.Fatalf("expected rendered %s flow at the UI origin", route)
}
- for _, selector := range []string{`input[name="username"]`, `input[name="password"]`} {
- if err := page.Locator(selector).WaitFor(playwright.LocatorWaitForOptions{State: playwright.WaitForSelectorStateVisible, Timeout: playwright.Float(10_000)}); err != nil {
- t.Fatalf("required credential field %s did not become visible: %v", selector, err)
+ assertAuthDocumentSemantics(t, page)
+ assertCurrentAuthFormState(t, page, route)
+ prefix, passwordAutocomplete := "login", "current-password"
+ if route == "/register" {
+ prefix, passwordAutocomplete = "register", "new-password"
+ }
+ assertFieldContract(t, page, prefix+"-username", "username", "text", "username", "Username")
+ assertFieldContract(t, page, prefix+"-password", "password", "password", passwordAutocomplete, "Password")
+ for _, label := range []string{"Username", "Password"} {
+ field := page.GetByLabel(label, playwright.PageGetByLabelOptions{Exact: playwright.Bool(true)})
+ if err := field.WaitFor(playwright.LocatorWaitForOptions{State: playwright.WaitForSelectorStateVisible, Timeout: playwright.Float(10_000)}); err != nil {
+ t.Fatalf("required credential field %q did not become visible: %v", label, err)
}
+ if count, err := field.Count(); err != nil || count != 1 {
+ t.Fatalf("credential field %q count=%d err=%v", label, count, err)
+ }
+ }
+ if count, err := page.Locator("form").Count(); err != nil || count != 1 {
+ t.Fatalf("credential form count=%d err=%v, want 1", count, err)
+ }
+ if count, err := page.Locator(`input[type="password"]`).Count(); err != nil || count != 1 {
+ t.Fatalf("password input count=%d err=%v, want 1", count, err)
}
csrf := page.Locator(`input[name="csrf_token"]`)
if err := csrf.WaitFor(playwright.LocatorWaitForOptions{State: playwright.WaitForSelectorStateAttached, Timeout: playwright.Float(10_000)}); err != nil {
@@ -226,12 +366,80 @@
}
}
+func currentFlowID(t *testing.T, page playwright.Page) string {
+ t.Helper()
+ current, err := url.Parse(page.URL())
+ if err != nil || current.Query().Get("flow") == "" {
+ t.Fatal("current browser URL does not contain a flow id")
+ }
+ return current.Query().Get("flow")
+}
+
+func assertCredentialValues(t *testing.T, page playwright.Page, username, password string) {
+ t.Helper()
+ gotUsername, err := page.GetByLabel("Username", playwright.PageGetByLabelOptions{Exact: playwright.Bool(true)}).InputValue()
+ if err != nil || gotUsername != username {
+ t.Fatalf("username value=%q err=%v, want %q", gotUsername, err, username)
+ }
+ gotPassword, err := page.GetByLabel("Password", playwright.PageGetByLabelOptions{Exact: playwright.Bool(true)}).InputValue()
+ if err != nil || gotPassword != password {
+ t.Fatalf("password value length=%d err=%v, want length=%d", len(gotPassword), err, len(password))
+ }
+}
+
+func assertLatestUIResponseStatus(t *testing.T, session *browserSession, method, path string, want int) {
+ t.Helper()
+ metadata := session.RequestMetadata()
+ for i := len(metadata) - 1; i >= 0; i-- {
+ request := metadata[i]
+ if request.Method == method && request.Origin == testStack.UIURL && request.Path == path && request.Status != 0 {
+ if request.Status != want {
+ t.Fatalf("latest %s %s status=%d, want %d", method, path, request.Status, want)
+ }
+ return
+ }
+ }
+ t.Fatalf("no completed UI response for %s %s", method, path)
+}
+
+func assertInvalidLoginFeedback(t *testing.T, session *browserSession) {
+ t.Helper()
+ assertKratosForm(t, session.Page, "/login")
+ assertAuthStateSemantics(t, session.Page, true)
+ assertLatestUIResponseStatus(t, session, http.MethodGet, "/login", http.StatusOK)
+ assertVisibleExactText(t, session.Page, "Username or password is incorrect.")
+ form := session.Page.Locator("form")
+ if count, err := form.Count(); err != nil || count != 1 {
+ t.Fatalf("invalid-login form count=%d err=%v", count, err)
+ }
+ if count, err := form.Locator(`[role="alert"]`).Count(); err != nil || count != 1 {
+ t.Fatalf("invalid-login in-form alert count=%d err=%v", count, err)
+ }
+ formHTML, err := form.InnerHTML()
+ if err != nil {
+ t.Fatalf("read invalid-login form source: %v", err)
+ }
+ alertIndex := strings.Index(formHTML, `role="alert"`)
+ usernameIndex := strings.Index(formHTML, `name="username"`)
+ passwordIndex := strings.Index(formHTML, `name="password"`)
+ if alertIndex < 0 || usernameIndex < 0 || passwordIndex < 0 || !(alertIndex < usernameIndex && usernameIndex < passwordIndex) {
+ t.Fatalf("invalid-login source order alert=%d username=%d password=%d", alertIndex, usernameIndex, passwordIndex)
+ }
+ assertCredentialValues(t, session.Page, "", "")
+ if count, err := session.Page.GetByRole("button", playwright.PageGetByRoleOptions{Name: "Sign in", Exact: playwright.Bool(true)}).Count(); err != nil || count != 1 {
+ t.Fatalf("invalid-login submit action count=%d err=%v", count, err)
+ }
+ if count, err := session.Page.GetByRole("link", playwright.PageGetByRoleOptions{Name: "Create account", Exact: playwright.Bool(true)}).Count(); err != nil || count != 1 {
+ t.Fatalf("invalid-login exact registration link count=%d err=%v", count, err)
+ }
+}
+
func fillCredentials(t *testing.T, page playwright.Page, username, password string) {
t.Helper()
- if err := page.Locator(`input[name="username"]`).Fill(username); err != nil {
+ if err := page.GetByLabel("Username", playwright.PageGetByLabelOptions{Exact: playwright.Bool(true)}).Fill(username); err != nil {
t.Fatal("fill username field")
}
- if err := page.Locator(`input[name="password"]`).Fill(password); err != nil {
+ if err := page.GetByLabel("Password", playwright.PageGetByLabelOptions{Exact: playwright.Bool(true)}).Fill(password); err != nil {
t.Fatal("fill password field")
}
}
@@ -243,9 +451,33 @@
}
}
+func assertFocusedElementID(t *testing.T, page playwright.Page, want string) {
+ t.Helper()
+ value, err := page.Evaluate(`document.activeElement && document.activeElement.id`)
+ if err != nil {
+ t.Fatalf("read focused element: %v", err)
+ }
+ got, _ := value.(string)
+ if got != want {
+ t.Fatalf("focused element id=%q, want %q", got, want)
+ }
+}
+
+func assertFocusedElementText(t *testing.T, page playwright.Page, want string) {
+ t.Helper()
+ value, err := page.Evaluate(`document.activeElement && document.activeElement.textContent.trim()`)
+ if err != nil {
+ t.Fatalf("read focused element text: %v", err)
+ }
+ got, _ := value.(string)
+ if got != want {
+ t.Fatalf("focused element text=%q, want %q", got, want)
+ }
+}
+
func clickLink(t *testing.T, page playwright.Page, name string) {
t.Helper()
- if err := page.GetByText(name, playwright.PageGetByTextOptions{Exact: playwright.Bool(true)}).Click(); err != nil {
+ if err := page.GetByRole("link", playwright.PageGetByRoleOptions{Name: name, Exact: playwright.Bool(true)}).Click(); err != nil {
t.Fatalf("click %s link: %v", name, err)
}
}
@@ -256,10 +488,30 @@
if err != nil || u.Scheme+"://"+u.Host != testStack.UIURL || u.Path != "/" {
t.Fatal("successful authentication did not return to the UI landing route")
}
+ assertAuthDocumentSemantics(t, page)
+ if count, err := page.GetByRole("heading", playwright.PageGetByRoleOptions{Name: "Account", Exact: playwright.Bool(true)}).Count(); err != nil || count != 1 {
+ t.Fatalf("account heading count=%d err=%v", count, err)
+ }
body, err := page.Locator("body").InnerText()
if err != nil || !strings.Contains(body, fmt.Sprintf("Hello %s!", username)) {
t.Fatalf("authenticated greeting is not visible: %v", err)
}
+ for _, name := range []string{"Change password", "Log out"} {
+ if count, err := page.GetByRole("link", playwright.PageGetByRoleOptions{Name: name, Exact: playwright.Bool(true)}).Count(); err != nil || count != 1 {
+ t.Fatalf("account link %q count=%d err=%v", name, count, err)
+ }
+ }
+ assertNoFormOrPasswordControls(t, page, "account")
+}
+
+func assertNoFormOrPasswordControls(t *testing.T, page playwright.Page, context string) {
+ t.Helper()
+ if count, err := page.Locator("form").Count(); err != nil || count != 0 {
+ t.Fatalf("%s form count=%d err=%v, want 0", context, count, err)
+ }
+ if count, err := page.Locator(`input[type="password"]`).Count(); err != nil || count != 0 {
+ t.Fatalf("%s password input count=%d err=%v, want 0", context, count, err)
+ }
}
func checkpoint(t *testing.T, session *browserSession, name string) {
@@ -327,13 +579,10 @@
t.Errorf("browser did not receive an expected successful workflow response from %s", origin)
}
}
- blocked := session.BlockedRequests()
- if len(blocked) == 0 {
- t.Error("browser did not block the expected external CDN font request")
+ if blocked := session.BlockedRequests(); len(blocked) != 0 {
+ t.Errorf("browser blocked requests from self-contained UI: %v", blocked)
}
- for _, request := range blocked {
- if !isExpectedBlockedBrowserRequest(request) {
- t.Errorf("browser blocked an unexpected query-free target %s", request)
- }
+ if diagnostics := session.BrowserDiagnostics(); len(diagnostics) != 0 {
+ t.Errorf("browser reported page/console errors: %v", diagnostics)
}
}
diff --git a/core/auth/ui/e2e/oauth_helpers_test.go b/core/auth/ui/e2e/oauth_helpers_test.go
index 5f62b6f..df64cc0 100644
--- a/core/auth/ui/e2e/oauth_helpers_test.go
+++ b/core/auth/ui/e2e/oauth_helpers_test.go
@@ -9,6 +9,7 @@
"net/http/httptest"
"net/url"
"os"
+ "os/exec"
"path/filepath"
"strings"
"sync"
@@ -362,6 +363,31 @@
}
}
+func TestFormatCheckPropagatesFormatterFailure(t *testing.T) {
+ repo, err := repositoryDir()
+ if err != nil {
+ t.Fatal(err)
+ }
+ failingFormatter := filepath.Join(t.TempDir(), "failing-gofmt")
+ if err := os.WriteFile(failingFormatter, []byte("#!/bin/sh\nexit 23\n"), 0o700); err != nil {
+ t.Fatal(err)
+ }
+ for _, formatter := range []string{
+ filepath.Join(t.TempDir(), "missing-gofmt"),
+ failingFormatter,
+ } {
+ command := exec.Command("make", "format-check", "GOFMT="+formatter)
+ command.Dir = repo
+ output, err := command.CombinedOutput()
+ if err == nil {
+ t.Fatalf("format-check false-passed for unavailable formatter %q: %s", formatter, output)
+ }
+ if !strings.Contains(string(output), "gofmt check failed") {
+ t.Fatalf("format-check failure for %q omitted diagnostic: %s", formatter, output)
+ }
+ }
+}
+
func TestFinalMakeTargetsRemainOptIn(t *testing.T) {
repo, err := repositoryDir()
if err != nil {
@@ -373,16 +399,22 @@
}
makefile := string(data)
for _, required := range []string{
- "test:\n\tgo test ./...\n\tgo vet ./...",
- "test-e2e:\n\tgo test -tags=e2e -count=1 -timeout=10m -v ./e2e",
- "test-e2e-offline:\n\tAUTH_UI_E2E_OFFLINE=1 go test -tags=e2e -count=1 -timeout=10m -v ./e2e",
+ "GO ?= go",
+ "GOFMT ?= gofmt",
+ "format:\n\t$(GOFMT) -w $(GO_FILES)",
+ "test:\n\t$(GO) test ./...",
+ "test-race:\n\t$(GO) test -race -count=1 ./...",
+ "vet:\n\t$(GO) vet ./...",
+ "check: format-check test test-race vet\n\t$(GO) build ./...",
+ "test-e2e:\n\t$(GO) test -tags=e2e -count=1 -timeout=10m -v ./e2e",
+ "test-e2e-offline:\n\tAUTH_UI_E2E_OFFLINE=1 $(GO) test -tags=e2e -count=1 -timeout=10m -v ./e2e",
"clean-e2e-artifacts:\n\trm -rf -- e2e/artifacts",
} {
if !strings.Contains(makefile, required) {
t.Fatalf("Makefile omitted required opt-in target contract %q", required)
}
}
- if strings.Contains(makefile, "test: test-e2e") || strings.Contains(makefile, "clean: clean-e2e-artifacts") {
+ if strings.Contains(makefile, "test-e2e-offline: install-e2e-browser") || strings.Contains(makefile, "test: test-e2e") || strings.Contains(makefile, "clean: clean-e2e-artifacts") {
t.Fatal("Makefile made an ordinary or offline target depend on an online/destructive E2E target")
}
}
diff --git a/core/auth/ui/e2e/ux_test.go b/core/auth/ui/e2e/ux_test.go
new file mode 100644
index 0000000..33c9b99
--- /dev/null
+++ b/core/auth/ui/e2e/ux_test.go
@@ -0,0 +1,511 @@
+//go:build e2e
+
+package e2e
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+
+ playwright "github.com/mxschmitt/playwright-go"
+)
+
+type responsiveMetrics struct {
+ ViewportWidth int `json:"viewportWidth"`
+ ScrollWidth int `json:"scrollWidth"`
+ ScrollX float64 `json:"scrollX"`
+ ScrollY float64 `json:"scrollY"`
+ TerminalTop float64 `json:"terminalTop"`
+ TerminalRight float64 `json:"terminalRight"`
+ TargetCount int `json:"targetCount"`
+ SmallTargets []string `json:"smallTargets"`
+}
+
+type reachabilityMetrics struct {
+ FocusOutlineVisible bool `json:"focusOutlineVisible"`
+ CheckedCount int `json:"checkedCount"`
+ OutsideViewport []string `json:"outsideViewport"`
+}
+
+type semanticMetrics struct {
+ MainCount int `json:"mainCount"`
+ HeadingCount int `json:"headingCount"`
+ EmptyIDs []string `json:"emptyIds"`
+ DuplicateIDs []string `json:"duplicateIds"`
+ UnnamedControls []string `json:"unnamedControls"`
+ BrokenDescriptions []string `json:"brokenDescriptions"`
+ InvalidAriaValues []string `json:"invalidAriaValues"`
+ AlertCount int `json:"alertCount"`
+ AlertBeforeFirstControl bool `json:"alertBeforeFirstControl"`
+ ForbiddenAttributes []string `json:"forbiddenAttributes"`
+}
+
+type formStateMetrics struct {
+ Action string `json:"action"`
+ HiddenCount int `json:"hiddenCount"`
+ HiddenNames []string `json:"hiddenNames"`
+ EmptyHiddenValues []string `json:"emptyHiddenValues"`
+ PasswordCount int `json:"passwordCount"`
+ PasswordValueAttrs int `json:"passwordValueAttrs"`
+}
+
+type fieldContractMetrics struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Type string `json:"type"`
+ Autocomplete string `json:"autocomplete"`
+ Autocapitalize string `json:"autocapitalize"`
+ Spellcheck string `json:"spellcheck"`
+ Required bool `json:"required"`
+ Label string `json:"label"`
+}
+
+type invalidFieldMetrics struct {
+ AriaInvalid string `json:"ariaInvalid"`
+ DescriptionIDs []string `json:"descriptionIds"`
+ DescriptionText []string `json:"descriptionText"`
+}
+
+type authStateMetrics struct {
+ AlertCount int `json:"alertCount"`
+ AlertBeforeFirstControl bool `json:"alertBeforeFirstControl"`
+ InvalidFieldIDs []string `json:"invalidFieldIds"`
+ InvalidWithoutDescription []string `json:"invalidWithoutDescription"`
+}
+
+func TestAuthResponsiveMatrix(t *testing.T) {
+ viewports := []browserSize{
+ {Width: 1280, Height: 720},
+ {Width: 390, Height: 844},
+ {Width: 320, Height: 568},
+ {Width: 844, Height: 390},
+ {Width: 320, Height: 240},
+ {Width: 640, Height: 360},
+ }
+ for _, viewport := range viewports {
+ viewport := viewport
+ t.Run(fmt.Sprintf("%dx%d", viewport.Width, viewport.Height), func(t *testing.T) {
+ options := browserSessionOptions{Viewport: viewport, VideoSize: viewport}
+ session := newKratosTestSessionWithOptions(t, options)
+ openKratosForm(t, session.Page, testStack.UIURL+"/login", "/login")
+ assertResponsiveLayout(t, session.Page)
+ assertFocusedFieldAndActionsReachable(t, session.Page, "Password")
+ assertLocalStylesLoaded(t, session)
+ checkpoint(t, session, fmt.Sprintf("responsive-%dx%d", viewport.Width, viewport.Height))
+ })
+ }
+}
+
+func TestAuthLongUsernameAndValidationWrapping(t *testing.T) {
+ viewport := browserSize{Width: 320, Height: 568}
+ session := newKratosTestSessionWithOptions(t, browserSessionOptions{Viewport: viewport, VideoSize: viewport})
+ client := newDirectAPIClient(testStack.KratosURL, testStack.KratosAdmin)
+ defer client.close()
+ _, password := uniqueKratosCredentials(t)
+ username := "ux-" + strings.Repeat("terminalwrap", 14)
+ status, body := postIdentityJSON(t, client, username, password)
+ if status != http.StatusOK {
+ t.Fatalf("create long-username identity status=%d: %s", status, sanitizedResponseDiagnostic(body))
+ }
+
+ openKratosForm(t, session.Page, testStack.UIURL+"/login", "/login")
+ fillCredentials(t, session.Page, username, password)
+ clickButton(t, session.Page, "Sign in")
+ assertGreeting(t, session.Page, username)
+ assertWrappedWithinTerminal(t, session.Page, "strong")
+ assertResponsiveLayout(t, session.Page)
+ checkpoint(t, session, "long-account-username-wrap")
+
+ if err := session.Context.ClearCookies(); err != nil {
+ t.Fatal(err)
+ }
+ openKratosForm(t, session.Page, testStack.UIURL+"/register", "/register")
+ fillCredentials(t, session.Page, "ab", "short")
+ clickButton(t, session.Page, "Create account")
+ assertVisibleExactText(t, session.Page, passwordCompositionMessage)
+ assertWrappedWithinTerminal(t, session.Page, `[role="alert"]`)
+ assertResponsiveLayout(t, session.Page)
+ checkpoint(t, session, "fixed-validation-message-wrap")
+}
+
+func TestAuthReducedMotionAndLocalAssets(t *testing.T) {
+ viewport := browserSize{Width: 390, Height: 844}
+ session := newKratosTestSessionWithOptions(t, browserSessionOptions{Viewport: viewport, VideoSize: viewport, ReducedMotion: true})
+ openKratosForm(t, session.Page, testStack.UIURL+"/login", "/login")
+ motion := evaluateJSON[struct {
+ Matches bool `json:"matches"`
+ ScrollBehavior string `json:"scrollBehavior"`
+ TransitionDuration string `json:"transitionDuration"`
+ AnimationDuration string `json:"animationDuration"`
+ }](t, session.Page, `() => {
+ const style = getComputedStyle(document.querySelector('input'));
+ return {
+ matches: matchMedia('(prefers-reduced-motion: reduce)').matches,
+ scrollBehavior: getComputedStyle(document.documentElement).scrollBehavior,
+ transitionDuration: style.transitionDuration,
+ animationDuration: style.animationDuration
+ };
+ }`)
+ if !motion.Matches || motion.ScrollBehavior != "auto" || motion.TransitionDuration != "0s" || motion.AnimationDuration != "0s" {
+ t.Fatalf("reduced-motion styles=%+v", motion)
+ }
+ assertLocalStylesLoaded(t, session)
+ if scripts, err := session.Page.Locator("script").Count(); err != nil || scripts != 0 {
+ t.Fatalf("product script count=%d err=%v", scripts, err)
+ }
+ checkpoint(t, session, "reduced-motion-local-assets")
+}
+
+func assertAuthDocumentSemantics(t *testing.T, page playwright.Page) {
+ t.Helper()
+ metrics := evaluateJSON[semanticMetrics](t, page, `() => {
+ const visible = (element) => {
+ const style = getComputedStyle(element);
+ const bounds = element.getBoundingClientRect();
+ return !element.hidden && style.display !== 'none' && style.visibility !== 'hidden' && bounds.width > 0 && bounds.height > 0;
+ };
+ const identify = (element) => element.id || element.getAttribute('name') || element.textContent.trim() || element.tagName.toLowerCase();
+ const ids = [...document.querySelectorAll('[id]')].map((element) => element.id);
+ const seen = new Set();
+ const duplicates = new Set();
+ for (const id of ids) {
+ if (seen.has(id)) duplicates.add(id);
+ seen.add(id);
+ }
+ const controls = [...document.querySelectorAll('input:not([type="hidden"]), button, a')].filter(visible);
+ const unnamedControls = controls.filter((element) => {
+ if (element.matches('input')) {
+ return !element.labels || element.labels.length === 0 || ![...element.labels].some((label) => label.textContent.trim());
+ }
+ return !(element.getAttribute('aria-label') || element.textContent).trim();
+ }).map(identify);
+ const brokenDescriptions = [];
+ for (const element of document.querySelectorAll('[aria-describedby]')) {
+ const references = element.getAttribute('aria-describedby').trim().split(/\s+/).filter(Boolean);
+ if (references.length === 0 || references.some((id) => {
+ const target = document.getElementById(id);
+ return !target || !target.textContent.trim();
+ })) brokenDescriptions.push(identify(element));
+ }
+ const invalidAriaValues = [...document.querySelectorAll('[aria-invalid]')]
+ .filter((element) => element.getAttribute('aria-invalid') !== 'true').map(identify);
+ const alerts = [...document.querySelectorAll('[role="alert"]')];
+ const form = document.querySelector('form');
+ const firstControl = form && form.querySelector('input:not([type="hidden"]), select, textarea, button');
+ const alertBeforeFirstControl = alerts.length === 0 || (alerts.length === 1 && firstControl && Boolean(alerts[0].compareDocumentPosition(firstControl) & Node.DOCUMENT_POSITION_FOLLOWING));
+ const forbiddenAttributes = [];
+ for (const element of document.querySelectorAll('input')) {
+ for (const name of ['autofocus', 'minlength', 'maxlength', 'pattern']) {
+ if (element.hasAttribute(name)) forbiddenAttributes.push(identify(element) + ':' + name);
+ }
+ }
+ return {
+ mainCount: document.querySelectorAll('main').length,
+ headingCount: document.querySelectorAll('h1').length,
+ emptyIds: ids.filter((id) => !id),
+ duplicateIds: [...duplicates],
+ unnamedControls,
+ brokenDescriptions,
+ invalidAriaValues,
+ alertCount: alerts.length,
+ alertBeforeFirstControl,
+ forbiddenAttributes
+ };
+ }`)
+ if metrics.MainCount != 1 || metrics.HeadingCount != 1 || len(metrics.EmptyIDs) != 0 || len(metrics.DuplicateIDs) != 0 || len(metrics.UnnamedControls) != 0 || len(metrics.BrokenDescriptions) != 0 || len(metrics.InvalidAriaValues) != 0 || metrics.AlertCount > 1 || !metrics.AlertBeforeFirstControl || len(metrics.ForbiddenAttributes) != 0 {
+ t.Fatalf("auth document semantic metrics=%+v", metrics)
+ }
+}
+
+func assertAuthStateSemantics(t *testing.T, page playwright.Page, expectAlert bool, invalidFieldIDs ...string) {
+ t.Helper()
+ metrics := evaluateJSON[authStateMetrics](t, page, `() => {
+ const alerts = [...document.querySelectorAll('[role="alert"]')];
+ const form = document.querySelector('form');
+ const firstControl = form && form.querySelector('input:not([type="hidden"]), select, textarea, button');
+ const invalidFields = [...document.querySelectorAll('[aria-invalid]')];
+ const invalidWithoutDescription = invalidFields.filter((element) => {
+ if (element.getAttribute('aria-invalid') !== 'true') return true;
+ const references = (element.getAttribute('aria-describedby') || '').trim().split(/\s+/).filter(Boolean);
+ return references.length === 0 || references.some((id) => {
+ const target = document.getElementById(id);
+ return !target || !target.textContent.trim();
+ });
+ });
+ return {
+ alertCount: alerts.length,
+ alertBeforeFirstControl: alerts.length === 1 && firstControl && Boolean(alerts[0].compareDocumentPosition(firstControl) & Node.DOCUMENT_POSITION_FOLLOWING),
+ invalidFieldIds: invalidFields.map((element) => element.id),
+ invalidWithoutDescription: invalidWithoutDescription.map((element) => element.id || element.getAttribute('name') || element.tagName.toLowerCase())
+ };
+ }`)
+ wantAlerts := 0
+ if expectAlert {
+ wantAlerts = 1
+ }
+ if metrics.AlertCount != wantAlerts || (expectAlert && !metrics.AlertBeforeFirstControl) {
+ t.Fatalf("auth alert state=%+v, want count=%d and source ordering", metrics, wantAlerts)
+ }
+ expectedInvalid := make(map[string]bool, len(invalidFieldIDs))
+ for _, id := range invalidFieldIDs {
+ expectedInvalid[id] = true
+ }
+ if len(metrics.InvalidFieldIDs) != len(expectedInvalid) {
+ t.Fatalf("invalid fields=%v, want exactly %v", metrics.InvalidFieldIDs, invalidFieldIDs)
+ }
+ for _, id := range metrics.InvalidFieldIDs {
+ if id == "" || !expectedInvalid[id] {
+ t.Fatalf("invalid fields=%v, want exactly %v", metrics.InvalidFieldIDs, invalidFieldIDs)
+ }
+ }
+ if len(metrics.InvalidWithoutDescription) != 0 {
+ t.Fatalf("invalid fields lack valid descriptions: %v", metrics.InvalidWithoutDescription)
+ }
+}
+
+func assertCurrentAuthFormState(t *testing.T, page playwright.Page, route string) {
+ t.Helper()
+ current, err := url.Parse(page.URL())
+ if err != nil || current.Scheme+"://"+current.Host != testStack.UIURL || current.Path != route || current.Query().Get("flow") == "" {
+ t.Fatalf("current auth form URL is not a valid %s flow", route)
+ }
+ metrics := evaluateJSON[formStateMetrics](t, page, `() => {
+ const form = document.querySelector('form');
+ const hidden = [...form.querySelectorAll('input[type="hidden"]')];
+ const passwords = [...form.querySelectorAll('input[type="password"]')];
+ return {
+ action: form.getAttribute('action'),
+ hiddenCount: hidden.length,
+ hiddenNames: hidden.map((element) => element.getAttribute('name') || ''),
+ emptyHiddenValues: hidden.filter((element) => !element.value).map((element) => element.getAttribute('name') || ''),
+ passwordCount: passwords.length,
+ passwordValueAttrs: passwords.filter((element) => element.hasAttribute('value')).length
+ };
+ }`)
+ action, err := url.Parse(metrics.Action)
+ if err != nil || action.IsAbs() || action.Path != route || action.Query().Get("flow") != current.Query().Get("flow") || len(action.Query()) != 1 {
+ t.Fatalf("auth form action=%q does not contain only the current %s flow", metrics.Action, route)
+ }
+ if metrics.HiddenCount != 1 || len(metrics.HiddenNames) != 1 || metrics.HiddenNames[0] != "csrf_token" || len(metrics.EmptyHiddenValues) != 0 || metrics.PasswordCount != 1 || metrics.PasswordValueAttrs != 0 {
+ t.Fatalf("auth form protocol/password metrics=%+v", metrics)
+ }
+}
+
+func assertFieldContract(t *testing.T, page playwright.Page, id, name, fieldType, autocomplete, label string) {
+ t.Helper()
+ metrics := evaluateJSON[fieldContractMetrics](t, page, `(id) => {
+ const element = document.getElementById(id);
+ return {
+ id: element && element.id,
+ name: element && element.getAttribute('name'),
+ type: element && element.getAttribute('type'),
+ autocomplete: element && element.getAttribute('autocomplete'),
+ autocapitalize: element && element.getAttribute('autocapitalize'),
+ spellcheck: element && element.getAttribute('spellcheck'),
+ required: Boolean(element && element.required),
+ label: element && element.labels ? [...element.labels].map((item) => item.textContent.trim()).join(' ') : ''
+ };
+ }`, id)
+ if metrics.ID != id || metrics.Name != name || metrics.Type != fieldType || metrics.Autocomplete != autocomplete || !metrics.Required || metrics.Label != label {
+ t.Fatalf("field %s semantic contract=%+v", id, metrics)
+ }
+ if name == "username" && (metrics.Autocapitalize != "none" || metrics.Spellcheck != "false") {
+ t.Fatalf("username field %s input-assistance contract=%+v", id, metrics)
+ }
+}
+
+func assertInvalidFieldDescriptions(t *testing.T, page playwright.Page, id string, expected ...string) {
+ t.Helper()
+ metrics := evaluateJSON[invalidFieldMetrics](t, page, `(id) => {
+ const element = document.getElementById(id);
+ const references = (element.getAttribute('aria-describedby') || '').trim().split(/\s+/).filter(Boolean);
+ return {
+ ariaInvalid: element.getAttribute('aria-invalid') || '',
+ descriptionIds: references,
+ descriptionText: references.map((reference) => document.getElementById(reference).textContent.trim())
+ };
+ }`, id)
+ if metrics.AriaInvalid != "true" || len(metrics.DescriptionIDs) == 0 {
+ t.Fatalf("invalid field %s association metrics=%+v", id, metrics)
+ }
+ for _, want := range expected {
+ found := false
+ for _, text := range metrics.DescriptionText {
+ if text == want {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Fatalf("invalid field %s descriptions=%q, want %q", id, metrics.DescriptionText, want)
+ }
+ }
+}
+
+func assertActiveFocusVisible(t *testing.T, page playwright.Page) {
+ t.Helper()
+ focus := evaluateJSON[struct {
+ ID string `json:"id"`
+ OutlineStyle string `json:"outlineStyle"`
+ OutlineWidth float64 `json:"outlineWidth"`
+ }](t, page, `() => {
+ const element = document.activeElement;
+ const style = getComputedStyle(element);
+ return {id: element && (element.id || element.textContent.trim()), outlineStyle: style.outlineStyle, outlineWidth: parseFloat(style.outlineWidth) || 0};
+ }`)
+ if focus.ID == "" || focus.OutlineStyle == "none" || focus.OutlineWidth < 2 {
+ t.Fatalf("active element does not have visible focus: %+v", focus)
+ }
+}
+
+func assertResponsiveLayout(t *testing.T, page playwright.Page) {
+ t.Helper()
+ metrics := evaluateJSON[responsiveMetrics](t, page, `() => {
+ window.scrollTo({left: 0, top: 0, behavior: 'instant'});
+ const terminal = document.querySelector('.terminal');
+ const rect = terminal.getBoundingClientRect();
+ const visible = (element) => {
+ const style = getComputedStyle(element);
+ const bounds = element.getBoundingClientRect();
+ return !element.hidden && style.display !== 'none' && style.visibility !== 'hidden' && bounds.width > 0 && bounds.height > 0;
+ };
+ const label = (element) => element.id || element.getAttribute('name') || element.textContent.trim() || element.tagName.toLowerCase();
+ const targets = [...terminal.querySelectorAll('input:not([type="hidden"]), select, textarea, button, a')].filter(visible);
+ return {
+ viewportWidth: document.documentElement.clientWidth,
+ scrollWidth: document.documentElement.scrollWidth,
+ scrollX,
+ scrollY,
+ terminalTop: rect.top,
+ terminalRight: rect.right,
+ targetCount: targets.length,
+ smallTargets: targets.filter((element) => {
+ const target = element.getBoundingClientRect();
+ return target.width < 43.5 || target.height < 43.5;
+ }).map(label)
+ };
+ }`)
+ if metrics.ScrollX < -1 || metrics.ScrollX > 1 || metrics.ScrollY < -1 || metrics.ScrollY > 1 {
+ t.Fatalf("page did not reset to the scroll origin: scrollX=%v scrollY=%v", metrics.ScrollX, metrics.ScrollY)
+ }
+ if metrics.ScrollWidth > metrics.ViewportWidth+1 {
+ t.Fatalf("page horizontal overflow: scrollWidth=%d viewport=%d", metrics.ScrollWidth, metrics.ViewportWidth)
+ }
+ if metrics.TerminalTop < -1 || metrics.TerminalRight > float64(metrics.ViewportWidth)+1 {
+ t.Fatalf("terminal outside document origin/viewport: %+v", metrics)
+ }
+ if metrics.TargetCount == 0 || len(metrics.SmallTargets) != 0 {
+ t.Fatalf("activation target metrics=%+v", metrics)
+ }
+}
+
+func assertFocusedFieldAndActionsReachable(t *testing.T, page playwright.Page, label string) {
+ t.Helper()
+ field := page.GetByLabel(label, playwright.PageGetByLabelOptions{Exact: playwright.Bool(true)})
+ if err := field.Focus(); err != nil {
+ t.Fatalf("focus %s: %v", label, err)
+ }
+ metrics := evaluateJSON[reachabilityMetrics](t, page, `() => {
+ const field = document.activeElement;
+ const style = getComputedStyle(field);
+ const visible = (element) => {
+ const computed = getComputedStyle(element);
+ const bounds = element.getBoundingClientRect();
+ return !element.hidden && computed.display !== 'none' && computed.visibility !== 'hidden' && bounds.width > 0 && bounds.height > 0;
+ };
+ const label = (element) => element.id || element.getAttribute('name') || element.textContent.trim() || element.tagName.toLowerCase();
+ const targets = [...document.querySelectorAll('.terminal input:not([type="hidden"]), .terminal select, .terminal textarea, .terminal button, .terminal a')].filter(visible);
+ const candidates = [field, ...targets.filter((element) => element !== field)];
+ const outsideViewport = [];
+ for (const element of candidates) {
+ element.scrollIntoView({block: 'nearest', inline: 'nearest', behavior: 'instant'});
+ const bounds = element.getBoundingClientRect();
+ if (bounds.top < -1 || bounds.left < -1 || bounds.bottom > innerHeight + 1 || bounds.right > innerWidth + 1) {
+ outsideViewport.push(label(element));
+ }
+ }
+ return {
+ focusOutlineVisible: style.outlineStyle !== 'none' && parseFloat(style.outlineWidth) >= 2,
+ checkedCount: candidates.length,
+ outsideViewport
+ };
+ }`)
+ if !metrics.FocusOutlineVisible || metrics.CheckedCount == 0 || len(metrics.OutsideViewport) != 0 {
+ t.Fatalf("focus/reachability metrics=%+v", metrics)
+ }
+}
+
+func assertWrappedWithinTerminal(t *testing.T, page playwright.Page, selector string) {
+ t.Helper()
+ metrics := evaluateJSON[struct {
+ ElementRight float64 `json:"elementRight"`
+ TerminalRight float64 `json:"terminalRight"`
+ ScrollWidth int `json:"scrollWidth"`
+ ClientWidth int `json:"clientWidth"`
+ }](t, page, `(selector) => {
+ const element = document.querySelector(selector);
+ const terminal = document.querySelector('.terminal');
+ return {
+ elementRight: element.getBoundingClientRect().right,
+ terminalRight: terminal.getBoundingClientRect().right,
+ scrollWidth: document.documentElement.scrollWidth,
+ clientWidth: document.documentElement.clientWidth
+ };
+ }`, selector)
+ if metrics.ElementRight > metrics.TerminalRight+1 || metrics.ScrollWidth > metrics.ClientWidth+1 {
+ t.Fatalf("content did not wrap within terminal: %+v", metrics)
+ }
+}
+
+func assertLocalStylesLoaded(t *testing.T, session *browserSession) {
+ t.Helper()
+ styles := map[string]bool{"/static/base.css": false, "/static/main.css": false}
+ for _, request := range session.RequestMetadata() {
+ if request.Status == 0 {
+ continue
+ }
+ if request.Origin != testStack.UIURL && request.Origin != testStack.KratosURL {
+ t.Fatalf("browser received response from external origin: %+v", request)
+ }
+ if _, ok := styles[request.Path]; ok && request.Origin == testStack.UIURL && request.Method == http.MethodGet && request.Status == http.StatusOK {
+ styles[request.Path] = true
+ }
+ }
+ for path, loaded := range styles {
+ if !loaded {
+ t.Fatalf("local stylesheet did not load with 200: %s", path)
+ }
+ }
+ if blocked := session.BlockedRequests(); len(blocked) != 0 {
+ t.Fatalf("self-contained page made blocked requests: %v", blocked)
+ }
+ if diagnostics := session.BrowserDiagnostics(); len(diagnostics) != 0 {
+ t.Fatalf("page emitted browser diagnostics: %v", diagnostics)
+ }
+}
+
+func evaluateJSON[T any](t *testing.T, page playwright.Page, expression string, args ...any) T {
+ t.Helper()
+ var value any
+ var err error
+ if len(args) == 0 {
+ value, err = page.Evaluate(expression)
+ } else {
+ value, err = page.Evaluate(expression, args[0])
+ }
+ if err != nil {
+ t.Fatalf("evaluate browser metrics: %v", err)
+ }
+ encoded, err := json.Marshal(value)
+ if err != nil {
+ t.Fatalf("encode browser metrics: %v", err)
+ }
+ var result T
+ if err := json.Unmarshal(encoded, &result); err != nil {
+ t.Fatalf("decode browser metrics: %v", err)
+ }
+ return result
+}
diff --git a/core/auth/ui/main.go b/core/auth/ui/main.go
index 55dc963..244a9b0 100644
--- a/core/auth/ui/main.go
+++ b/core/auth/ui/main.go
@@ -15,6 +15,7 @@
"net/http"
"net/http/cookiejar"
"net/url"
+ "time"
"github.com/gorilla/mux"
"github.com/itaysk/regogo"
@@ -41,9 +42,9 @@
WhoAmI *template.Template
Register *template.Template
Login *template.Template
- Consent *template.Template
ChangePassword *template.Template
ChangePasswordSuccess *template.Template
+ Error *template.Template
}
func ParseTemplates(fs embed.FS) (*Templates, error) {
@@ -70,10 +71,6 @@
if err != nil {
return nil, err
}
- consent, err := parse("templates/consent.html")
- if err != nil {
- return nil, err
- }
changePassword, err := parse("templates/change-password.html")
if err != nil {
return nil, err
@@ -82,7 +79,83 @@
if err != nil {
return nil, err
}
- return &Templates{whoami, register, login, consent, changePassword, changePasswordSuccess}, nil
+ errorPage, err := parse("templates/error.html")
+ if err != nil {
+ return nil, err
+ }
+ return &Templates{whoami, register, login, changePassword, changePasswordSuccess, errorPage}, nil
+}
+
+const (
+ invalidLoginMessage = "Username or password is incorrect."
+ duplicateRegistrationMessage = "Username is not available."
+ expiredFlowMessage = "This form expired. Please try again."
+ registrationRejectedMessage = "Registration could not be completed. Please review your details and try again."
+ passwordChangeRejectedMessage = "Password could not be changed. Please choose a different password and try again."
+ authenticationUnavailableMessage = "Authentication service is temporarily unavailable. Please try again."
+
+ authNoticeCookieName = "auth_ui_notice"
+ authNoticeLoginInvalid = "login_invalid"
+ authNoticeFlowExpired = "flow_expired"
+)
+
+var errFlowExpired = errors.New("self-service flow expired")
+
+type LoginPageData struct {
+ FormAction string
+ CSRFToken string
+ EnableRegistration bool
+ GeneralNotice string
+}
+
+type RegisterPageData struct {
+ FormAction string
+ CSRFToken string
+ Username string
+ UsernameErrors []ValidationError
+ PasswordErrors []ValidationError
+ GeneralError string
+}
+
+type ChangePasswordPageData struct {
+ Username string
+ CSRFToken string
+ FormAction string
+ PasswordErrors []ValidationError
+ GeneralError string
+}
+
+type AccountPageData struct {
+ Username string
+}
+
+type ErrorPageData struct {
+ Title string
+ Message string
+ Status int
+ RecoveryHref string
+ RecoveryText string
+}
+
+type oryFlowResponse struct {
+ ID string `json:"id"`
+ UI struct {
+ Nodes []struct {
+ Attributes struct {
+ Name string `json:"name"`
+ Value json.RawMessage `json:"value"`
+ } `json:"attributes"`
+ } `json:"nodes"`
+ Messages []struct {
+ ID int64 `json:"id"`
+ } `json:"messages"`
+ } `json:"ui"`
+}
+
+type oryErrorResponse struct {
+ Error struct {
+ ID string `json:"id"`
+ } `json:"error"`
}
type Server struct {
@@ -113,6 +186,153 @@
return &Server{r, serv, kratos, hydra, tmpls, enableRegistration, api, defaultReturnTo}
}
+func executeTemplate(tmpl *template.Template, data any) ([]byte, error) {
+ var page bytes.Buffer
+ if err := tmpl.Execute(&page, data); err != nil {
+ return nil, err
+ }
+ return page.Bytes(), nil
+}
+
+func writeTemplate(w http.ResponseWriter, status int, page []byte) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ _, _ = w.Write(page)
+}
+
+func renderTemplate(w http.ResponseWriter, tmpl *template.Template, status int, data any) {
+ page, err := executeTemplate(tmpl, data)
+ if err != nil {
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+ return
+ }
+ writeTemplate(w, status, page)
+}
+
+func (s *Server) renderDependencyError(w http.ResponseWriter, context string) {
+ data := ErrorPageData{Message: authenticationUnavailableMessage, Status: http.StatusBadGateway}
+ switch context {
+ case "login":
+ data.Title = "Authentication unavailable"
+ data.RecoveryHref = "/login"
+ data.RecoveryText = "Try signing in again"
+ case "registration":
+ data.Title = "Registration unavailable"
+ if s.enableRegistration {
+ data.RecoveryHref = "/register"
+ data.RecoveryText = "Try registration again"
+ } else {
+ data.RecoveryHref = "/login"
+ data.RecoveryText = "Go to sign in"
+ }
+ default:
+ data.Title = "Account unavailable"
+ data.RecoveryHref = "/"
+ data.RecoveryText = "Back to account"
+ }
+ renderTemplate(w, s.tmpls.Error, data.Status, data)
+}
+
+func setAuthNotice(w http.ResponseWriter, code string) {
+ if code != authNoticeLoginInvalid && code != authNoticeFlowExpired {
+ return
+ }
+ http.SetCookie(w, &http.Cookie{
+ Name: authNoticeCookieName,
+ Value: code,
+ Path: "/",
+ MaxAge: 120,
+ Expires: time.Now().Add(2 * time.Minute),
+ HttpOnly: true,
+ SameSite: http.SameSiteLaxMode,
+ })
+}
+
+func clearAuthNotice(w http.ResponseWriter) {
+ http.SetCookie(w, &http.Cookie{
+ Name: authNoticeCookieName,
+ Value: "",
+ Path: "/",
+ MaxAge: -1,
+ HttpOnly: true,
+ SameSite: http.SameSiteLaxMode,
+ })
+}
+
+func pendingAuthNotice(r *http.Request, accepted ...string) (code string, clear bool) {
+ cookie, err := r.Cookie(authNoticeCookieName)
+ if err != nil {
+ return "", false
+ }
+ for _, acceptedCode := range accepted {
+ if cookie.Value == acceptedCode {
+ return cookie.Value, true
+ }
+ }
+ if cookie.Value != authNoticeLoginInvalid && cookie.Value != authNoticeFlowExpired {
+ return "", true
+ }
+ return "", false
+}
+
+func parseOryErrorID(body []byte) string {
+ var response oryErrorResponse
+ if json.Unmarshal(body, &response) != nil {
+ return ""
+ }
+ return response.Error.ID
+}
+
+func parseRetryFlow(body []byte) (flowID, csrfToken string, duplicate bool, err error) {
+ var response oryFlowResponse
+ if json.Unmarshal(body, &response) != nil || response.ID == "" {
+ return "", "", false, errors.New("invalid retry flow")
+ }
+ csrfCount := 0
+ for _, node := range response.UI.Nodes {
+ if node.Attributes.Name != "csrf_token" {
+ continue
+ }
+ csrfCount++
+ if json.Unmarshal(node.Attributes.Value, &csrfToken) != nil || csrfToken == "" {
+ return "", "", false, errors.New("invalid retry csrf token")
+ }
+ }
+ if csrfCount != 1 {
+ return "", "", false, errors.New("invalid retry csrf token count")
+ }
+ for _, message := range response.UI.Messages {
+ if message.ID == 4000007 {
+ duplicate = true
+ }
+ }
+ return response.ID, csrfToken, duplicate, nil
+}
+
+func readResponseBody(resp *http.Response) ([]byte, error) {
+ defer resp.Body.Close()
+ return ioutil.ReadAll(resp.Body)
+}
+
+func localFlowAction(path, flow string) string {
+ return path + "?flow=" + url.QueryEscape(flow)
+}
+
+func (s *Server) restartFlow(w http.ResponseWriter, r *http.Request, flowType string) {
+ setAuthNotice(w, authNoticeFlowExpired)
+ addr := s.kratos + "/self-service/" + flowType + "/browser"
+ if flowType == "login" {
+ returnTo := r.FormValue("return_to")
+ if returnTo == "" && s.defaultReturnTo != "" {
+ returnTo = s.defaultReturnTo
+ }
+ if returnTo != "" {
+ addr += fmt.Sprintf("?return_to=%s", returnTo)
+ }
+ }
+ http.Redirect(w, r, addr, http.StatusSeeOther)
+}
+
func cacheControlWrapper(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// TODO(giolekva): enable caching
@@ -160,37 +380,60 @@
if err != nil {
return "", err
}
- respBody, err := ioutil.ReadAll(resp.Body)
+ respBody, err := readResponseBody(resp)
if err != nil {
return "", err
}
- token, err := regogo.Get(string(respBody), "input.ui.nodes[0].attributes.value")
+ if resp.StatusCode == http.StatusGone && parseOryErrorID(respBody) == "self_service_flow_expired" {
+ return "", errFlowExpired
+ }
+ if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
+ return "", errors.New("flow fetch failed")
+ }
+ _, token, _, err := parseRetryFlow(respBody)
if err != nil {
return "", err
}
- return token.String(), nil
+ return token, nil
}
func (s *Server) registerInitiate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ http.Error(w, "Bad request.", http.StatusBadRequest)
return
}
- flow, ok := r.Form["flow"]
- if !ok {
+ flow := r.FormValue("flow")
+ if flow == "" {
http.Redirect(w, r, s.kratos+"/self-service/registration/browser", http.StatusSeeOther)
return
}
- csrfToken, err := getCSRFToken("registration", flow[0], r.Cookies())
+ csrfToken, err := getCSRFToken("registration", flow, r.Cookies())
+ if errors.Is(err, errFlowExpired) {
+ s.restartFlow(w, r, "registration")
+ return
+ }
if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ s.renderDependencyError(w, "registration")
return
}
- w.Header().Set("Content-Type", "text/html")
- if err := s.tmpls.Register.Execute(w, csrfToken); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ notice := ""
+ noticeCode, clearNotice := pendingAuthNotice(r, authNoticeFlowExpired)
+ if noticeCode == authNoticeFlowExpired {
+ notice = expiredFlowMessage
+ }
+ page, err := executeTemplate(s.tmpls.Register, RegisterPageData{
+ FormAction: localFlowAction(r.URL.Path, flow),
+ CSRFToken: csrfToken,
+ GeneralError: notice,
+ })
+ if err != nil {
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
+ if clearNotice {
+ clearAuthNotice(w)
+ }
+ writeTemplate(w, http.StatusOK, page)
}
type regReq struct {
@@ -206,36 +449,85 @@
func (s *Server) register(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ http.Error(w, "Bad request.", http.StatusBadRequest)
return
}
- flow, ok := r.Form["flow"]
- if !ok {
+ flow := r.FormValue("flow")
+ if flow == "" {
http.Redirect(w, r, s.kratos+"/self-service/registration/browser", http.StatusSeeOther)
return
}
+ username := r.FormValue("username")
+ password := r.FormValue("password")
+ usernameErrors := validateUsername(username)
+ passwordErrors := validatePassword(password)
+ if len(usernameErrors)+len(passwordErrors) > 0 {
+ renderTemplate(w, s.tmpls.Register, http.StatusUnprocessableEntity, RegisterPageData{
+ FormAction: localFlowAction(r.URL.Path, flow),
+ CSRFToken: r.FormValue("csrf_token"),
+ Username: username,
+ UsernameErrors: usernameErrors,
+ PasswordErrors: passwordErrors,
+ })
+ return
+ }
req := regReq{
CSRFToken: r.FormValue("csrf_token"),
Method: "password",
- Password: r.FormValue("password"),
+ Password: password,
Traits: regReqTraits{
- Username: r.FormValue("username"),
+ Username: username,
},
}
var reqBody bytes.Buffer
if err := json.NewEncoder(&reqBody).Encode(req); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ s.renderDependencyError(w, "registration")
return
}
- if resp, err := postToKratos("registration", flow[0], r.Cookies(), &reqBody); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ resp, err := postToKratos("registration", flow, r.Cookies(), &reqBody)
+ if err != nil {
+ s.renderDependencyError(w, "registration")
return
- } else {
- for _, c := range resp.Cookies() {
- http.SetCookie(w, c)
- }
+ }
+ for _, cookie := range resp.Cookies() {
+ http.SetCookie(w, cookie)
+ }
+ if resp.StatusCode < http.StatusBadRequest {
+ resp.Body.Close()
http.Redirect(w, r, "/", http.StatusSeeOther)
+ return
}
+ body, err := readResponseBody(resp)
+ if err != nil {
+ s.renderDependencyError(w, "registration")
+ return
+ }
+ if (resp.StatusCode == http.StatusGone && parseOryErrorID(body) == "self_service_flow_expired") ||
+ (resp.StatusCode == http.StatusForbidden && parseOryErrorID(body) == "security_csrf_violation") {
+ s.restartFlow(w, r, "registration")
+ return
+ }
+ if resp.StatusCode != http.StatusBadRequest {
+ s.renderDependencyError(w, "registration")
+ return
+ }
+ retryFlow, retryCSRF, duplicate, err := parseRetryFlow(body)
+ if err != nil {
+ s.renderDependencyError(w, "registration")
+ return
+ }
+ status := http.StatusUnprocessableEntity
+ generalError := registrationRejectedMessage
+ if duplicate {
+ status = http.StatusConflict
+ generalError = duplicateRegistrationMessage
+ }
+ renderTemplate(w, s.tmpls.Register, status, RegisterPageData{
+ FormAction: localFlowAction(r.URL.Path, retryFlow),
+ CSRFToken: retryCSRF,
+ Username: username,
+ GeneralError: generalError,
+ })
}
// Login flow
@@ -253,7 +545,7 @@
func (s *Server) loginInitiate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ http.Error(w, "Bad request.", http.StatusBadRequest)
return
}
challenge, hasChallenge := r.Form["login_challenge"]
@@ -264,13 +556,13 @@
if hasChallenge {
_, username, err := getWhoAmIFromKratos(r.Cookies())
if err != nil && err != ErrNotLoggedIn {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ s.renderDependencyError(w, "login")
return
}
if err == nil {
redirectTo, err := s.hydra.LoginAcceptChallenge(challenge[0], username)
if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ s.renderDependencyError(w, "login")
return
}
clearLoginChallengeCookie(w)
@@ -299,18 +591,36 @@
return
}
csrfToken, err := getCSRFToken("login", flow[0], r.Cookies())
+ if errors.Is(err, errFlowExpired) {
+ s.restartFlow(w, r, "login")
+ return
+ }
if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ s.renderDependencyError(w, "login")
return
}
- w.Header().Set("Content-Type", "text/html")
- if err := s.tmpls.Login.Execute(w, map[string]any{
- "csrfToken": csrfToken,
- "enableRegistration": s.enableRegistration,
- }); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ notice := ""
+ noticeCode, clearNotice := pendingAuthNotice(r, authNoticeLoginInvalid, authNoticeFlowExpired)
+ switch noticeCode {
+ case authNoticeLoginInvalid:
+ notice = invalidLoginMessage
+ case authNoticeFlowExpired:
+ notice = expiredFlowMessage
+ }
+ page, err := executeTemplate(s.tmpls.Login, LoginPageData{
+ FormAction: localFlowAction(r.URL.Path, flow[0]),
+ CSRFToken: csrfToken,
+ EnableRegistration: s.enableRegistration,
+ GeneralNotice: notice,
+ })
+ if err != nil {
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
+ if clearNotice {
+ clearAuthNotice(w)
+ }
+ writeTemplate(w, http.StatusOK, page)
}
type loginReq struct {
@@ -442,72 +752,65 @@
}
-func extractError(r io.Reader) error {
- respBody, err := ioutil.ReadAll(r)
- if err != nil {
- return err
+func isRejectedLoginRedirect(resp *http.Response) bool {
+ if resp.StatusCode != http.StatusSeeOther {
+ return false
}
- fmt.Printf("++ %s\n", respBody)
- t, err := regogo.Get(string(respBody), "input.ui.messages[0].type")
- if err != nil {
- return err
- }
- if t.String() == "error" {
- message, err := regogo.Get(string(respBody), "input.ui.messages[0].text")
- if err != nil {
- return err
- }
- return errors.New(message.String())
- }
- return nil
+ location, err := url.Parse(resp.Header.Get("Location"))
+ return err == nil && location.Path == "/login" && location.Query().Get("flow") != ""
}
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ http.Error(w, "Bad request.", http.StatusBadRequest)
return
}
- flow, ok := r.Form["flow"]
- if !ok {
+ flow := r.FormValue("flow")
+ if flow == "" {
http.Redirect(w, r, s.kratos+"/self-service/login/browser", http.StatusSeeOther)
return
}
req := url.Values{
- "csrf_token": []string{r.FormValue("csrf_token")},
- "method": []string{"password"},
- "password": []string{r.FormValue("password")},
- "identifier": []string{r.FormValue("username")},
+ "csrf_token": {r.FormValue("csrf_token")},
+ "method": {"password"},
+ "password": {r.FormValue("password")},
+ "identifier": {r.FormValue("username")},
}
- resp, err := postFormToKratos("login", flow[0], r.Cookies(), req)
- var vv bytes.Buffer
- io.Copy(&vv, resp.Body)
- fmt.Println(vv.String())
+ resp, err := postFormToKratos("login", flow, r.Cookies(), req)
if err != nil {
- if challenge, _ := r.Cookie("login_challenge"); challenge != nil {
- redirectTo, err := s.hydra.LoginRejectChallenge(challenge.Value, err.Error())
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- clearLoginChallengeCookie(w)
- http.Redirect(w, r, redirectTo, http.StatusSeeOther)
- return
- }
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ s.renderDependencyError(w, "login")
return
}
- for _, c := range resp.Cookies() {
- http.SetCookie(w, c)
+ defer resp.Body.Close()
+ var responseBody bytes.Buffer
+ _, _ = io.Copy(&responseBody, resp.Body)
+ fmt.Println(responseBody.String())
+ for _, cookie := range resp.Cookies() {
+ http.SetCookie(w, cookie)
+ }
+ if (resp.StatusCode == http.StatusGone && parseOryErrorID(responseBody.Bytes()) == "self_service_flow_expired") ||
+ (resp.StatusCode == http.StatusForbidden && parseOryErrorID(responseBody.Bytes()) == "security_csrf_violation") {
+ s.restartFlow(w, r, "login")
+ return
+ }
+ if isRejectedLoginRedirect(resp) {
+ setAuthNotice(w, authNoticeLoginInvalid)
+ http.Redirect(w, r, resp.Header.Get("Location"), http.StatusSeeOther)
+ return
+ }
+ if resp.StatusCode != http.StatusSeeOther {
+ s.renderDependencyError(w, "login")
+ return
}
if challenge, _ := r.Cookie("login_challenge"); challenge != nil {
_, username, err := getWhoAmIFromKratos(resp.Cookies())
if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ s.renderDependencyError(w, "login")
return
}
redirectTo, err := s.hydra.LoginAcceptChallenge(challenge.Value, username)
if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ s.renderDependencyError(w, "login")
return
}
clearLoginChallengeCookie(w)
@@ -523,7 +826,7 @@
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
if logoutURL, err := getLogoutURLFromKratos(r.Cookies()); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ s.renderDependencyError(w, "account")
return
} else {
http.Redirect(w, r, logoutURL, http.StatusSeeOther)
@@ -536,11 +839,9 @@
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ s.renderDependencyError(w, "account")
} else {
- if err := s.tmpls.WhoAmI.Execute(w, username); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- }
+ renderTemplate(w, s.tmpls.WhoAmI, http.StatusOK, AccountPageData{Username: username})
}
}
@@ -576,11 +877,6 @@
} else {
http.Redirect(w, r, redirectTo, http.StatusSeeOther)
}
- // w.Header().Set("Content-Type", "text/html")
- // if err := s.tmpls.Consent.Execute(w, consent.RequestedScopes); err != nil {
- // http.Error(w, err.Error(), http.StatusInternalServerError)
- // return
- // }
}
func (s *Server) processConsent(w http.ResponseWriter, r *http.Request) {
@@ -610,14 +906,6 @@
}
}
-type changePasswordData struct {
- Username string
- Password string
- CSRFToken string
- FormAction string
- PasswordErrors []ValidationError
-}
-
func (s *Server) changePasswordForm(w http.ResponseWriter, r *http.Request) {
flow := r.FormValue("flow")
if flow == "" {
@@ -626,23 +914,42 @@
}
_, username, err := getWhoAmIFromKratos(r.Cookies())
if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ s.renderDependencyError(w, "account")
return
}
csrfToken, err := getCSRFToken("settings", flow, r.Cookies())
+ if errors.Is(err, errFlowExpired) {
+ s.restartFlow(w, r, "settings")
+ return
+ }
if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ s.renderDependencyError(w, "account")
return
}
- if err := s.tmpls.ChangePassword.Execute(w, changePasswordData{Username: username, CSRFToken: csrfToken, FormAction: r.URL.Path + "?flow=" + url.QueryEscape(flow)}); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ notice := ""
+ noticeCode, clearNotice := pendingAuthNotice(r, authNoticeFlowExpired)
+ if noticeCode == authNoticeFlowExpired {
+ notice = expiredFlowMessage
+ }
+ page, err := executeTemplate(s.tmpls.ChangePassword, ChangePasswordPageData{
+ Username: username,
+ CSRFToken: csrfToken,
+ FormAction: localFlowAction(r.URL.Path, flow),
+ GeneralError: notice,
+ })
+ if err != nil {
+ http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
+ if clearNotice {
+ clearAuthNotice(w)
+ }
+ writeTemplate(w, http.StatusOK, page)
}
func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
- http.Error(w, err.Error(), http.StatusBadRequest)
+ http.Error(w, "Bad request.", http.StatusBadRequest)
return
}
flow := r.FormValue("flow")
@@ -653,13 +960,16 @@
password := r.FormValue("password")
_, username, err := getWhoAmIFromKratos(r.Cookies())
if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ s.renderDependencyError(w, "account")
return
}
- if verr := validatePassword(password); len(verr) > 0 {
- if err := s.tmpls.ChangePassword.Execute(w, changePasswordData{Username: username, Password: password, CSRFToken: r.FormValue("csrf_token"), FormAction: r.URL.Path + "?flow=" + url.QueryEscape(flow), PasswordErrors: verr}); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- }
+ if passwordErrors := validatePassword(password); len(passwordErrors) > 0 {
+ renderTemplate(w, s.tmpls.ChangePassword, http.StatusUnprocessableEntity, ChangePasswordPageData{
+ Username: username,
+ CSRFToken: r.FormValue("csrf_token"),
+ FormAction: localFlowAction(r.URL.Path, flow),
+ PasswordErrors: passwordErrors,
+ })
return
}
resp, err := postFormToKratos("settings", flow, r.Cookies(), url.Values{
@@ -668,24 +978,39 @@
"password": {password},
})
if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ s.renderDependencyError(w, "account")
return
}
- defer resp.Body.Close()
- if resp.StatusCode >= http.StatusBadRequest {
- if err := extractError(resp.Body); err != nil {
- if renderErr := s.tmpls.ChangePassword.Execute(w, changePasswordData{Username: username, Password: password, CSRFToken: r.FormValue("csrf_token"), FormAction: r.URL.Path + "?flow=" + url.QueryEscape(flow), PasswordErrors: []ValidationError{{Field: "password", Message: err.Error()}}}); renderErr != nil {
- http.Error(w, renderErr.Error(), http.StatusInternalServerError)
- }
- return
- }
- http.Error(w, "password change failed", resp.StatusCode)
+ if resp.StatusCode < http.StatusBadRequest {
+ resp.Body.Close()
+ renderTemplate(w, s.tmpls.ChangePasswordSuccess, http.StatusOK, nil)
return
}
- if err := s.tmpls.ChangePasswordSuccess.Execute(w, nil); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
+ body, err := readResponseBody(resp)
+ if err != nil {
+ s.renderDependencyError(w, "account")
return
}
+ if (resp.StatusCode == http.StatusGone && parseOryErrorID(body) == "self_service_flow_expired") ||
+ (resp.StatusCode == http.StatusForbidden && parseOryErrorID(body) == "security_csrf_violation") {
+ s.restartFlow(w, r, "settings")
+ return
+ }
+ if resp.StatusCode != http.StatusBadRequest {
+ s.renderDependencyError(w, "account")
+ return
+ }
+ retryFlow, retryCSRF, _, err := parseRetryFlow(body)
+ if err != nil {
+ s.renderDependencyError(w, "account")
+ return
+ }
+ renderTemplate(w, s.tmpls.ChangePassword, http.StatusUnprocessableEntity, ChangePasswordPageData{
+ Username: username,
+ CSRFToken: retryCSRF,
+ FormAction: localFlowAction(r.URL.Path, retryFlow),
+ GeneralError: passwordChangeRejectedMessage,
+ })
}
func main() {
diff --git a/core/auth/ui/main_test.go b/core/auth/ui/main_test.go
new file mode 100644
index 0000000..904bdd4
--- /dev/null
+++ b/core/auth/ui/main_test.go
@@ -0,0 +1,1191 @@
+package main
+
+import (
+ "encoding/json"
+ "errors"
+ "html"
+ "html/template"
+ "io/fs"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "reflect"
+ "regexp"
+ "strings"
+ "sync/atomic"
+ "testing"
+)
+
+const (
+ sanitizedRetryFlowFixture = `{
+ "id":"retry-flow",
+ "obsolete_state":"obsolete-upstream-state",
+ "ui":{
+ "action":"https://upstream.invalid/do-not-render",
+ "nodes":[
+ {"attributes":{"name":"traits.username","value":"upstream-value-must-not-render"}},
+ {"attributes":{"name":"csrf_token","value":"retry-csrf"}}
+ ],
+ "messages":[{"id":4000007,"text":"upstream prose must not render"}]
+ }
+ }`
+ sanitizedOtherRetryFlowFixture = `{
+ "id":"other-flow",
+ "obsolete_state":"obsolete-upstream-state",
+ "ui":{"action":"https://upstream.invalid/other-action","nodes":[{"attributes":{"name":"csrf_token","value":"other-csrf"}}],"messages":[{"id":1234,"text":"unsafe upstream guidance"}]}
+ }`
+ sanitizedExpiredFlowFixture = `{"error":{"id":"self_service_flow_expired","message":"unsafe expiry prose"}}`
+ sanitizedCSRFFixture = `{"error":{"id":"security_csrf_violation","message":"unsafe CSRF prose"}}`
+
+ obsoleteFlowSentinel = "obsolete-submitted-flow"
+ obsoleteCSRFSentinel = "obsolete-submitted-csrf"
+ submittedPasswordSentinel = "Submitted-Password-Sentinel-9!"
+)
+
+func TestStage3LocalAssetAndPaletteContracts(t *testing.T) {
+ baseTemplate, err := fs.ReadFile(tmpls, "templates/base.html")
+ if err != nil {
+ t.Fatal(err)
+ }
+ markup := string(baseTemplate)
+ for _, expected := range []string{`href="/static/base.css?v=0.0.1"`, `href="/static/main.css?v=0.0.3"`} {
+ if strings.Count(markup, expected) != 1 {
+ t.Fatalf("base template local stylesheet %q count=%d, want 1", expected, strings.Count(markup, expected))
+ }
+ }
+ for _, forbidden := range []string{"pico", "cdnjs", "<script", "http://", "https://"} {
+ if strings.Contains(strings.ToLower(markup), forbidden) {
+ t.Fatalf("base template contains forbidden asset reference %q", forbidden)
+ }
+ }
+ if links := strings.Count(markup, `<link rel="stylesheet"`); links != 2 {
+ t.Fatalf("stylesheet link count=%d, want 2", links)
+ }
+
+ baseCSS, err := fs.ReadFile(static, "static/base.css")
+ if err != nil {
+ t.Fatal(err)
+ }
+ mainCSS, err := fs.ReadFile(static, "static/main.css")
+ if err != nil {
+ t.Fatal(err)
+ }
+ allCSS := string(baseCSS) + "\n" + string(mainCSS)
+ opaqueColor := regexp.MustCompile(`(?i)#[0-9a-f]{3,8}\b|rgba?\(`)
+ colors := opaqueColor.FindAllString(allCSS, -1)
+ wantColors := []string{"#d6d6d6", "#3a3a3a", "#7f9f7f", "#d4888d"}
+ if !reflect.DeepEqual(colors, wantColors) {
+ t.Fatalf("opaque CSS colors=%v, want exact palette once in base.css", colors)
+ }
+ for _, forbidden := range []string{"--pico-", "box-shadow", "@font-face", "url("} {
+ if strings.Contains(strings.ToLower(allCSS), forbidden) {
+ t.Fatalf("CSS contains forbidden presentation primitive %q", forbidden)
+ }
+ }
+ for _, primitive := range []string{"box-sizing: border-box", "border-radius: 0", "min-height: 44px", ":focus-visible", "overflow-wrap: anywhere", "prefers-reduced-motion", "--font-mono"} {
+ if !strings.Contains(string(baseCSS), primitive) {
+ t.Fatalf("base.css omitted owned primitive %q", primitive)
+ }
+ }
+ for _, presentation := range []string{"width: min(100%, 500px)", "min-height: 100vh", "min-height: 100dvh", "align-items: flex-start", "input:-webkit-autofill"} {
+ if !strings.Contains(string(mainCSS), presentation) {
+ t.Fatalf("main.css omitted auth presentation contract %q", presentation)
+ }
+ }
+ removed := []struct {
+ filesystem fs.FS
+ path string
+ }{
+ {static, "static/" + "pico.2.0.6.min.css"},
+ {tmpls, "templates/" + "consent.html"},
+ }
+ for _, asset := range removed {
+ if _, err := fs.Stat(asset.filesystem, asset.path); err == nil || !errors.Is(err, fs.ErrNotExist) {
+ t.Fatalf("removed embedded asset %q still exists", asset.path)
+ }
+ }
+ if _, present := reflect.TypeOf(Templates{}).FieldByName("Consent"); present {
+ t.Fatal("Templates retains dormant consent storage")
+ }
+}
+
+func testTemplates(t *testing.T) *Templates {
+ t.Helper()
+ templates, err := ParseTemplates(tmpls)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return templates
+}
+
+func withKratosServer(t *testing.T, handler http.Handler) *httptest.Server {
+ t.Helper()
+ server := httptest.NewServer(handler)
+ old := *kratos
+ *kratos = server.URL
+ t.Cleanup(func() {
+ *kratos = old
+ server.Close()
+ })
+ return server
+}
+
+func testServer(t *testing.T, kratosURL string) *Server {
+ t.Helper()
+ return NewServer(0, kratosURL, nil, testTemplates(t), true, nil, "")
+}
+
+func formRequest(method, target string, values url.Values) *http.Request {
+ request := httptest.NewRequest(method, target, strings.NewReader(values.Encode()))
+ request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ return request
+}
+
+func setJSONResponse(w http.ResponseWriter, status int, body string) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _, _ = w.Write([]byte(body))
+}
+
+func validFlowFixture(id, csrf string) string {
+ body, _ := json.Marshal(map[string]any{
+ "id": id,
+ "ui": map[string]any{"nodes": []any{
+ map[string]any{"attributes": map[string]any{"name": "identifier", "value": ""}},
+ map[string]any{"attributes": map[string]any{"name": "csrf_token", "value": csrf}},
+ }},
+ })
+ return string(body)
+}
+
+func whoAmIFixture() string {
+ return `{"identity":{"id":"identity-id","traits":{"username":"tester"}}}`
+}
+
+func TestParseRetryFlowPinnedFixtures(t *testing.T) {
+ flow, csrf, duplicate, err := parseRetryFlow([]byte(sanitizedRetryFlowFixture))
+ if err != nil || flow != "retry-flow" || csrf != "retry-csrf" || !duplicate {
+ t.Fatalf("duplicate fixture parsed as flow=%q csrf=%q duplicate=%v err=%v", flow, csrf, duplicate, err)
+ }
+ flow, csrf, duplicate, err = parseRetryFlow([]byte(sanitizedOtherRetryFlowFixture))
+ if err != nil || flow != "other-flow" || csrf != "other-csrf" || duplicate {
+ t.Fatalf("other fixture parsed as flow=%q csrf=%q duplicate=%v err=%v", flow, csrf, duplicate, err)
+ }
+ if got := parseOryErrorID([]byte(sanitizedExpiredFlowFixture)); got != "self_service_flow_expired" {
+ t.Fatalf("expired error id = %q", got)
+ }
+ if got := parseOryErrorID([]byte(sanitizedCSRFFixture)); got != "security_csrf_violation" {
+ t.Fatalf("CSRF error id = %q", got)
+ }
+}
+
+func TestParseRetryFlowRejectsMalformedMinimumState(t *testing.T) {
+ tests := []string{
+ `not-json`,
+ `{"id":"","ui":{"nodes":[{"attributes":{"name":"csrf_token","value":"token"}}]}}`,
+ `{"id":"flow","ui":{"nodes":[]}}`,
+ `{"id":"flow","ui":{"nodes":[{"attributes":{"name":"csrf_token","value":""}}]}}`,
+ `{"id":"flow","ui":{"nodes":[{"attributes":{"name":"csrf_token","value":"one"}},{"attributes":{"name":"csrf_token","value":"two"}}]}}`,
+ }
+ for _, fixture := range tests {
+ if _, _, _, err := parseRetryFlow([]byte(fixture)); err == nil {
+ t.Fatalf("malformed fixture accepted: %s", fixture)
+ }
+ }
+}
+
+func TestAuthNoticeAllowlistAndConsumption(t *testing.T) {
+ invalid := httptest.NewRecorder()
+ setAuthNotice(invalid, "arbitrary")
+ if len(invalid.Result().Cookies()) != 0 {
+ t.Fatal("arbitrary notice code was set")
+ }
+
+ set := httptest.NewRecorder()
+ setAuthNotice(set, authNoticeLoginInvalid)
+ cookies := set.Result().Cookies()
+ if len(cookies) != 1 || cookies[0].Value != authNoticeLoginInvalid || cookies[0].MaxAge != 120 || !cookies[0].HttpOnly {
+ t.Fatalf("notice cookie = %#v", cookies)
+ }
+
+ request := httptest.NewRequest(http.MethodGet, "/login?flow=valid", nil)
+ request.AddCookie(cookies[0])
+ if code, clear := pendingAuthNotice(request, authNoticeLoginInvalid); code != authNoticeLoginInvalid || !clear {
+ t.Fatalf("pending matching notice code=%q clear=%v", code, clear)
+ }
+
+ mismatchRequest := httptest.NewRequest(http.MethodGet, "/register?flow=valid", nil)
+ mismatchRequest.AddCookie(cookies[0])
+ if code, clear := pendingAuthNotice(mismatchRequest, authNoticeFlowExpired); code != "" || clear {
+ t.Fatalf("valid nonmatching notice code=%q clear=%v", code, clear)
+ }
+
+ arbitraryRequest := httptest.NewRequest(http.MethodGet, "/login?flow=valid", nil)
+ arbitraryRequest.AddCookie(&http.Cookie{Name: authNoticeCookieName, Value: "arbitrary-client-value"})
+ if code, clear := pendingAuthNotice(arbitraryRequest, authNoticeLoginInvalid, authNoticeFlowExpired); code != "" || !clear {
+ t.Fatalf("arbitrary notice code=%q clear=%v", code, clear)
+ }
+}
+
+func responseCookieNamed(response *http.Response, name string) *http.Cookie {
+ for _, cookie := range response.Cookies() {
+ if cookie.Name == name {
+ return cookie
+ }
+ }
+ return nil
+}
+
+func TestValidFormClearsArbitraryClientNotice(t *testing.T) {
+ upstream := withKratosServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ setJSONResponse(w, http.StatusOK, validFlowFixture("valid-flow", "valid-csrf"))
+ }))
+ server := testServer(t, upstream.URL)
+ request := httptest.NewRequest(http.MethodGet, "/login?flow=valid-flow", nil)
+ request.AddCookie(&http.Cookie{Name: authNoticeCookieName, Value: "arbitrary-client-value"})
+ recorder := httptest.NewRecorder()
+ server.loginInitiate(recorder, request)
+ if recorder.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", recorder.Code)
+ }
+ cleared := responseCookieNamed(recorder.Result(), authNoticeCookieName)
+ if cleared == nil || cleared.MaxAge != -1 {
+ t.Fatalf("arbitrary notice was not cleared after valid form render: %#v", cleared)
+ }
+}
+
+func TestMatchingNoticeNotConsumedWithoutValidForm(t *testing.T) {
+ tests := []struct {
+ name string
+ upstream http.Handler
+ breakLogin bool
+ status int
+ }{
+ {
+ name: "flow fetch failure",
+ upstream: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ setJSONResponse(w, http.StatusInternalServerError, `{"error":"unavailable"}`)
+ }),
+ status: http.StatusBadGateway,
+ },
+ {
+ name: "template execution failure",
+ upstream: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ setJSONResponse(w, http.StatusOK, validFlowFixture("valid-flow", "valid-csrf"))
+ }),
+ breakLogin: true,
+ status: http.StatusInternalServerError,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ upstream := withKratosServer(t, tt.upstream)
+ server := testServer(t, upstream.URL)
+ if tt.breakLogin {
+ server.tmpls.Login = template.Must(template.New("broken-login").Funcs(template.FuncMap{
+ "fail": func() (string, error) { return "", http.ErrAbortHandler },
+ }).Parse(`{{fail}}`))
+ }
+ request := httptest.NewRequest(http.MethodGet, "/login?flow=valid-flow", nil)
+ request.AddCookie(&http.Cookie{Name: authNoticeCookieName, Value: authNoticeFlowExpired})
+ recorder := httptest.NewRecorder()
+ server.loginInitiate(recorder, request)
+ if recorder.Code != tt.status {
+ t.Fatalf("status = %d, want %d", recorder.Code, tt.status)
+ }
+ if cookie := responseCookieNamed(recorder.Result(), authNoticeCookieName); cookie != nil {
+ t.Fatalf("matching notice was consumed without a valid form: %#v", cookie)
+ }
+ })
+ }
+}
+
+func TestRegistrationLocalValidationUsesSharedErrorsWithoutUpstream(t *testing.T) {
+ var calls atomic.Int32
+ upstream := withKratosServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ calls.Add(1)
+ http.Error(w, "unexpected", http.StatusInternalServerError)
+ }))
+ server := testServer(t, upstream.URL)
+ recorder := httptest.NewRecorder()
+ request := formRequest(http.MethodPost, "/register?flow=current-flow", url.Values{
+ "csrf_token": {"current-csrf"}, "username": {"ab"}, "password": {"short-secret"},
+ })
+ server.register(recorder, request)
+ if recorder.Code != http.StatusUnprocessableEntity {
+ t.Fatalf("status = %d, want 422", recorder.Code)
+ }
+ body := html.UnescapeString(recorder.Body.String())
+ for _, expected := range []string{testUsernameLengthMessage, testPasswordLengthMessage, testPasswordCompositionMessage, `value="ab"`, `action="/register?flow=current-flow"`, `value="current-csrf"`} {
+ if !strings.Contains(body, expected) {
+ t.Fatalf("response omitted %q", expected)
+ }
+ }
+ if strings.Contains(body, "short-secret") {
+ t.Fatal("rejected registration password was rendered")
+ }
+ if calls.Load() != 0 {
+ t.Fatalf("local validation made %d upstream calls", calls.Load())
+ }
+}
+
+func TestSettingsLocalValidationUsesSharedErrorsWithoutSubmission(t *testing.T) {
+ var submissions atomic.Int32
+ upstream := withKratosServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path == "/sessions/whoami" {
+ setJSONResponse(w, http.StatusOK, whoAmIFixture())
+ return
+ }
+ if r.Method == http.MethodPost {
+ submissions.Add(1)
+ }
+ http.Error(w, "unexpected", http.StatusInternalServerError)
+ }))
+ server := testServer(t, upstream.URL)
+ recorder := httptest.NewRecorder()
+ request := formRequest(http.MethodPost, "/settings?flow=current-flow", url.Values{
+ "csrf_token": {"current-csrf"}, "password": {"short-secret"},
+ })
+ server.changePassword(recorder, request)
+ if recorder.Code != http.StatusUnprocessableEntity {
+ t.Fatalf("status = %d, want 422", recorder.Code)
+ }
+ body := html.UnescapeString(recorder.Body.String())
+ for _, expected := range []string{testPasswordLengthMessage, testPasswordCompositionMessage, `action="/settings?flow=current-flow"`, `value="current-csrf"`} {
+ if !strings.Contains(body, expected) {
+ t.Fatalf("response omitted %q", expected)
+ }
+ }
+ if strings.Contains(body, "short-secret") {
+ t.Fatal("rejected settings password was rendered")
+ }
+ if submissions.Load() != 0 {
+ t.Fatalf("local validation made %d settings submissions", submissions.Load())
+ }
+}
+
+func TestRegistrationExpectedRejectionsUseReturnedRetryState(t *testing.T) {
+ tests := []struct {
+ name string
+ fixture string
+ status int
+ message string
+ flow string
+ csrf string
+ }{
+ {"duplicate", sanitizedRetryFlowFixture, http.StatusConflict, duplicateRegistrationMessage, "retry-flow", "retry-csrf"},
+ {"other", sanitizedOtherRetryFlowFixture, http.StatusUnprocessableEntity, registrationRejectedMessage, "other-flow", "other-csrf"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ upstream := withKratosServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ setJSONResponse(w, http.StatusBadRequest, tt.fixture)
+ }))
+ server := testServer(t, upstream.URL)
+ recorder := httptest.NewRecorder()
+ request := formRequest(http.MethodPost, "/register?flow="+obsoleteFlowSentinel, url.Values{
+ "csrf_token": {obsoleteCSRFSentinel}, "username": {"retained-user"}, "password": {submittedPasswordSentinel},
+ })
+ server.register(recorder, request)
+ if recorder.Code != tt.status {
+ t.Fatalf("status = %d, want %d", recorder.Code, tt.status)
+ }
+ body := recorder.Body.String()
+ for _, expected := range []string{tt.message, `action="/register?flow=` + tt.flow + `"`, `value="` + tt.csrf + `"`, `value="retained-user"`} {
+ if !strings.Contains(body, expected) {
+ t.Fatalf("response omitted %q", expected)
+ }
+ }
+ for _, forbidden := range []string{submittedPasswordSentinel, obsoleteFlowSentinel, obsoleteCSRFSentinel, "https://upstream.invalid/do-not-render", "https://upstream.invalid/other-action", "obsolete-upstream-state", "upstream prose", "upstream-value", "unsafe upstream"} {
+ if strings.Contains(body, forbidden) {
+ t.Fatalf("response rendered forbidden upstream/password text %q", forbidden)
+ }
+ }
+ })
+ }
+}
+
+func TestSettingsExpectedRejectionUsesReturnedRetryState(t *testing.T) {
+ upstream := withKratosServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/sessions/whoami":
+ setJSONResponse(w, http.StatusOK, whoAmIFixture())
+ case "/self-service/settings":
+ setJSONResponse(w, http.StatusBadRequest, sanitizedOtherRetryFlowFixture)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ server := testServer(t, upstream.URL)
+ recorder := httptest.NewRecorder()
+ request := formRequest(http.MethodPost, "/settings?flow="+obsoleteFlowSentinel, url.Values{
+ "csrf_token": {obsoleteCSRFSentinel}, "password": {submittedPasswordSentinel},
+ })
+ server.changePassword(recorder, request)
+ if recorder.Code != http.StatusUnprocessableEntity {
+ t.Fatalf("status = %d, want 422", recorder.Code)
+ }
+ body := recorder.Body.String()
+ for _, expected := range []string{passwordChangeRejectedMessage, `action="/settings?flow=other-flow"`, `value="other-csrf"`} {
+ if !strings.Contains(body, expected) {
+ t.Fatalf("response omitted %q", expected)
+ }
+ }
+ for _, forbidden := range []string{submittedPasswordSentinel, obsoleteFlowSentinel, obsoleteCSRFSentinel, "https://upstream.invalid/other-action", "obsolete-upstream-state", "unsafe upstream"} {
+ if strings.Contains(body, forbidden) {
+ t.Fatalf("settings rejection rendered forbidden state %q", forbidden)
+ }
+ }
+}
+
+func TestSettingsResponseCookiesAreNeverForwarded(t *testing.T) {
+ const sentinelCookieName = "kratos_settings_sentinel"
+ tests := []struct {
+ name string
+ status int
+ fixture string
+ wantStatus int
+ wantNotice bool
+ }{
+ {"success", http.StatusOK, `{}`, http.StatusOK, false},
+ {"expected rejection", http.StatusBadRequest, sanitizedOtherRetryFlowFixture, http.StatusUnprocessableEntity, false},
+ {"unexpected failure", http.StatusInternalServerError, `{"obsolete_state":"obsolete-upstream-state","ui":{"action":"https://upstream.invalid/generic-action"}}`, http.StatusBadGateway, false},
+ {"expiry", http.StatusGone, sanitizedExpiredFlowFixture, http.StatusSeeOther, true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ upstream := withKratosServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/sessions/whoami":
+ setJSONResponse(w, http.StatusOK, whoAmIFixture())
+ case "/self-service/settings":
+ http.SetCookie(w, &http.Cookie{Name: sentinelCookieName, Value: "must-not-forward", Path: "/"})
+ setJSONResponse(w, tt.status, tt.fixture)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ server := testServer(t, upstream.URL)
+ recorder := httptest.NewRecorder()
+ request := formRequest(http.MethodPost, "/settings?flow="+obsoleteFlowSentinel, url.Values{
+ "csrf_token": {obsoleteCSRFSentinel}, "password": {submittedPasswordSentinel},
+ })
+ server.changePassword(recorder, request)
+ if recorder.Code != tt.wantStatus {
+ t.Fatalf("status = %d, want %d", recorder.Code, tt.wantStatus)
+ }
+ response := recorder.Result()
+ responseCookies := response.Cookies()
+ if cookie := responseCookieNamed(response, sentinelCookieName); cookie != nil {
+ t.Fatalf("Kratos settings cookie was forwarded: %#v", cookie)
+ }
+ notice := responseCookieNamed(response, authNoticeCookieName)
+ if tt.wantNotice {
+ if len(responseCookies) != 1 || notice == nil || notice.Value != authNoticeFlowExpired {
+ t.Fatalf("expiry response cookies = %#v", responseCookies)
+ }
+ } else if len(responseCookies) != 0 {
+ t.Fatalf("unexpected settings response cookies = %#v", responseCookies)
+ }
+ body := recorder.Body.String()
+ for _, forbidden := range []string{submittedPasswordSentinel, obsoleteFlowSentinel, obsoleteCSRFSentinel, "https://upstream.invalid/generic-action", "obsolete-upstream-state"} {
+ if strings.Contains(body, forbidden) {
+ t.Fatalf("settings output rendered forbidden state %q", forbidden)
+ }
+ }
+ })
+ }
+}
+
+func TestRegistrationExpiredAndCSRFSubmissionsRestartWithNotice(t *testing.T) {
+ tests := []struct {
+ status int
+ fixture string
+ }{{http.StatusGone, sanitizedExpiredFlowFixture}, {http.StatusForbidden, sanitizedCSRFFixture}}
+ for _, tt := range tests {
+ t.Run(http.StatusText(tt.status), func(t *testing.T) {
+ upstream := withKratosServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ setJSONResponse(w, tt.status, tt.fixture)
+ }))
+ server := testServer(t, upstream.URL)
+ recorder := httptest.NewRecorder()
+ request := formRequest(http.MethodPost, "/register?flow=old", url.Values{
+ "csrf_token": {"old"}, "username": {"valid-user"}, "password": {"Correct-Horse-Battery-9!"},
+ })
+ server.register(recorder, request)
+ if recorder.Code != http.StatusSeeOther || recorder.Header().Get("Location") != upstream.URL+"/self-service/registration/browser" {
+ t.Fatalf("restart response status=%d location=%q", recorder.Code, recorder.Header().Get("Location"))
+ }
+ cookies := recorder.Result().Cookies()
+ if len(cookies) != 1 || cookies[0].Name != authNoticeCookieName || cookies[0].Value != authNoticeFlowExpired {
+ t.Fatalf("restart notice cookies = %#v", cookies)
+ }
+ })
+ }
+}
+
+func TestMalformedRetryStateAndUnexpectedFailureRenderGeneric502(t *testing.T) {
+ tests := []struct {
+ name string
+ status int
+ fixture string
+ }{
+ {"malformed expected rejection", http.StatusBadRequest, `{"id":"flow","obsolete_state":"obsolete-upstream-state","ui":{"action":"https://upstream.invalid/generic-action","nodes":[]},"unsafe":"do not render"}`},
+ {"unexpected status", http.StatusInternalServerError, `{"error":"private upstream failure"}`},
+ {"wrong gone id", http.StatusGone, `{"error":{"id":"other","message":"private upstream failure"}}`},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ upstream := withKratosServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ setJSONResponse(w, tt.status, tt.fixture)
+ }))
+ server := testServer(t, upstream.URL)
+ recorder := httptest.NewRecorder()
+ request := formRequest(http.MethodPost, "/register?flow="+obsoleteFlowSentinel, url.Values{
+ "csrf_token": {obsoleteCSRFSentinel}, "username": {"valid-user"}, "password": {submittedPasswordSentinel},
+ })
+ server.register(recorder, request)
+ if recorder.Code != http.StatusBadGateway {
+ t.Fatalf("status = %d, want 502", recorder.Code)
+ }
+ body := recorder.Body.String()
+ for _, expected := range []string{"Registration unavailable", authenticationUnavailableMessage, `href="/register"`, "Try registration again"} {
+ if !strings.Contains(body, expected) {
+ t.Fatalf("generic page omitted %q", expected)
+ }
+ }
+ for _, forbidden := range []string{submittedPasswordSentinel, obsoleteFlowSentinel, obsoleteCSRFSentinel, "https://upstream.invalid/generic-action", "obsolete-upstream-state", "private upstream", "do not render"} {
+ if strings.Contains(body, forbidden) {
+ t.Fatalf("generic page rendered forbidden state %q", forbidden)
+ }
+ }
+ })
+ }
+}
+
+func TestSettingsExpiredAndCSRFSubmissionsRestartMatchingFlow(t *testing.T) {
+ tests := []struct {
+ status int
+ fixture string
+ }{{http.StatusGone, sanitizedExpiredFlowFixture}, {http.StatusForbidden, sanitizedCSRFFixture}}
+ for _, tt := range tests {
+ t.Run(http.StatusText(tt.status), func(t *testing.T) {
+ upstream := withKratosServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/sessions/whoami":
+ setJSONResponse(w, http.StatusOK, whoAmIFixture())
+ case "/self-service/settings":
+ setJSONResponse(w, tt.status, tt.fixture)
+ case "/self-service/settings/flows":
+ setJSONResponse(w, http.StatusOK, validFlowFixture("fresh-settings-flow", "fresh-settings-csrf"))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ server := testServer(t, upstream.URL)
+ recorder := httptest.NewRecorder()
+ request := formRequest(http.MethodPost, "/settings?flow=old", url.Values{
+ "csrf_token": {"old"}, "password": {"Correct-Horse-Battery-9!"},
+ })
+ server.changePassword(recorder, request)
+ if recorder.Code != http.StatusSeeOther || recorder.Header().Get("Location") != upstream.URL+"/self-service/settings/browser" {
+ t.Fatalf("restart response status=%d location=%q", recorder.Code, recorder.Header().Get("Location"))
+ }
+ cookies := recorder.Result().Cookies()
+ if len(cookies) != 1 || cookies[0].Value != authNoticeFlowExpired {
+ t.Fatalf("restart notice cookies = %#v", cookies)
+ }
+
+ freshRequest := httptest.NewRequest(http.MethodGet, "/settings?flow=fresh-settings-flow", nil)
+ freshRequest.AddCookie(cookies[0])
+ fresh := httptest.NewRecorder()
+ server.changePasswordForm(fresh, freshRequest)
+ if fresh.Code != http.StatusOK {
+ t.Fatalf("fresh settings GET status=%d, want 200", fresh.Code)
+ }
+ body := fresh.Body.String()
+ for _, expected := range []string{expiredFlowMessage, `action="/settings?flow=fresh-settings-flow"`, `value="fresh-settings-csrf"`} {
+ if !strings.Contains(body, expected) {
+ t.Fatalf("fresh settings form omitted %q", expected)
+ }
+ }
+ cleared := responseCookieNamed(fresh.Result(), authNoticeCookieName)
+ if cleared == nil || cleared.MaxAge != -1 {
+ t.Fatalf("fresh settings form did not clear notice: %#v", cleared)
+ }
+ })
+ }
+}
+
+func TestDependencyErrorContextContracts(t *testing.T) {
+ tests := []struct {
+ name string
+ context string
+ registration bool
+ title string
+ href string
+ link string
+ }{
+ {"login", "login", true, "Authentication unavailable", "/login", "Try signing in again"},
+ {"registration", "registration", true, "Registration unavailable", "/register", "Try registration again"},
+ {"disabled registration", "registration", false, "Registration unavailable", "/login", "Go to sign in"},
+ {"account", "account", true, "Account unavailable", "/", "Back to account"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ server := NewServer(0, "http://kratos.invalid", nil, testTemplates(t), tt.registration, nil, "")
+ recorder := httptest.NewRecorder()
+ server.renderDependencyError(recorder, tt.context)
+ if recorder.Code != http.StatusBadGateway {
+ t.Fatalf("status = %d, want 502", recorder.Code)
+ }
+ body := recorder.Body.String()
+ for _, expected := range []string{tt.title, authenticationUnavailableMessage, `href="` + tt.href + `"`, tt.link} {
+ if !strings.Contains(body, expected) {
+ t.Fatalf("dependency page omitted %q", expected)
+ }
+ }
+ })
+ }
+}
+
+func TestExpiredFlowFetchRestartsAndValidRenderConsumesNotice(t *testing.T) {
+ var expired atomic.Bool
+ expired.Store(true)
+ upstream := withKratosServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if expired.Load() {
+ setJSONResponse(w, http.StatusGone, sanitizedExpiredFlowFixture)
+ return
+ }
+ setJSONResponse(w, http.StatusOK, validFlowFixture("fresh-flow", "fresh-csrf"))
+ }))
+ server := testServer(t, upstream.URL)
+ first := httptest.NewRecorder()
+ server.registerInitiate(first, httptest.NewRequest(http.MethodGet, "/register?flow=expired", nil))
+ if first.Code != http.StatusSeeOther || first.Header().Get("Location") != upstream.URL+"/self-service/registration/browser" {
+ t.Fatalf("expired fetch response status=%d location=%q", first.Code, first.Header().Get("Location"))
+ }
+ notice := first.Result().Cookies()[0]
+ expired.Store(false)
+ secondRequest := httptest.NewRequest(http.MethodGet, "/register?flow=fresh-flow", nil)
+ secondRequest.AddCookie(notice)
+ second := httptest.NewRecorder()
+ server.registerInitiate(second, secondRequest)
+ if second.Code != http.StatusOK || !strings.Contains(second.Body.String(), expiredFlowMessage) {
+ t.Fatalf("fresh render status=%d body=%q", second.Code, second.Body.String())
+ }
+ if cookies := second.Result().Cookies(); len(cookies) != 1 || cookies[0].MaxAge != -1 {
+ t.Fatalf("fresh render did not clear notice: %#v", cookies)
+ }
+}
+
+func TestInvalidLoginPreservesRedirectChallengeAndShowsFixedNotice(t *testing.T) {
+ location := "http://auth-ui.invalid/login?flow=retry-login"
+ upstream := withKratosServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPost {
+ w.Header().Set("Location", location)
+ w.WriteHeader(http.StatusSeeOther)
+ return
+ }
+ setJSONResponse(w, http.StatusOK, validFlowFixture("retry-login", "retry-csrf"))
+ }))
+ server := testServer(t, upstream.URL)
+ post := httptest.NewRecorder()
+ postRequest := formRequest(http.MethodPost, "/login?flow=old", url.Values{
+ "csrf_token": {"old"}, "username": {"must-not-be-retained"}, "password": {"must-not-be-retained"},
+ })
+ postRequest.AddCookie(&http.Cookie{Name: "login_challenge", Value: "pending-challenge"})
+ server.login(post, postRequest)
+ if post.Code != http.StatusSeeOther || post.Header().Get("Location") != location {
+ t.Fatalf("login rejection status=%d location=%q", post.Code, post.Header().Get("Location"))
+ }
+ cookies := post.Result().Cookies()
+ if len(cookies) != 1 || cookies[0].Name != authNoticeCookieName || cookies[0].Value != authNoticeLoginInvalid {
+ t.Fatalf("login rejection cookies = %#v", cookies)
+ }
+
+ getRequest := httptest.NewRequest(http.MethodGet, "/login?flow=retry-login", nil)
+ getRequest.AddCookie(cookies[0])
+ getRequest.AddCookie(&http.Cookie{Name: "login_challenge", Value: "pending-challenge"})
+ get := httptest.NewRecorder()
+ server.loginInitiate(get, getRequest)
+ if get.Code != http.StatusOK {
+ t.Fatalf("final GET status = %d, want 200", get.Code)
+ }
+ body := get.Body.String()
+ if !strings.Contains(body, invalidLoginMessage) || !strings.Contains(body, `role="alert"`) || !strings.Contains(body, `action="/login?flow=retry-login"`) {
+ t.Fatal("final login GET omitted fixed notice, alert, or retry action")
+ }
+ if strings.Contains(body, "must-not-be-retained") {
+ t.Fatal("rejected login retained credentials")
+ }
+}
+
+func TestExpiredLoginSubmissionRestartsAndPreservesChallenge(t *testing.T) {
+ tests := []struct {
+ name string
+ status int
+ fixture string
+ }{
+ {"expired flow", http.StatusGone, sanitizedExpiredFlowFixture},
+ {"CSRF violation", http.StatusForbidden, sanitizedCSRFFixture},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ upstream := withKratosServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPost {
+ setJSONResponse(w, tt.status, tt.fixture)
+ return
+ }
+ setJSONResponse(w, http.StatusOK, validFlowFixture("fresh-login-flow", "fresh-login-csrf"))
+ }))
+ server := testServer(t, upstream.URL)
+ server.defaultReturnTo = "https://return.example/dashboard"
+ postRequest := formRequest(http.MethodPost, "/login?flow="+obsoleteFlowSentinel, url.Values{
+ "csrf_token": {obsoleteCSRFSentinel}, "username": {"submitted-user"}, "password": {submittedPasswordSentinel},
+ })
+ postRequest.AddCookie(&http.Cookie{Name: "login_challenge", Value: "pending-login-challenge"})
+ post := httptest.NewRecorder()
+ server.login(post, postRequest)
+ if post.Code != http.StatusSeeOther || post.Header().Get("Location") != upstream.URL+"/self-service/login/browser?return_to=https://return.example/dashboard" {
+ t.Fatalf("restart status=%d location=%q", post.Code, post.Header().Get("Location"))
+ }
+ notice := responseCookieNamed(post.Result(), authNoticeCookieName)
+ if notice == nil || notice.Value != authNoticeFlowExpired {
+ t.Fatalf("restart notice = %#v", notice)
+ }
+ if cookie := responseCookieNamed(post.Result(), "login_challenge"); cookie != nil {
+ t.Fatalf("restart mutated pending login challenge: %#v", cookie)
+ }
+
+ getRequest := httptest.NewRequest(http.MethodGet, "/login?flow=fresh-login-flow", nil)
+ getRequest.AddCookie(notice)
+ getRequest.AddCookie(&http.Cookie{Name: "login_challenge", Value: "pending-login-challenge"})
+ get := httptest.NewRecorder()
+ server.loginInitiate(get, getRequest)
+ if get.Code != http.StatusOK {
+ t.Fatalf("fresh GET status=%d, want 200", get.Code)
+ }
+ body := get.Body.String()
+ for _, expected := range []string{expiredFlowMessage, `action="/login?flow=fresh-login-flow"`, `value="fresh-login-csrf"`} {
+ if !strings.Contains(body, expected) {
+ t.Fatalf("fresh login form omitted %q", expected)
+ }
+ }
+ for _, forbidden := range []string{submittedPasswordSentinel, obsoleteFlowSentinel, obsoleteCSRFSentinel, "submitted-user", "pending-login-challenge"} {
+ if strings.Contains(body, forbidden) {
+ t.Fatalf("fresh login form rendered forbidden state %q", forbidden)
+ }
+ }
+ clearedNotice := responseCookieNamed(get.Result(), authNoticeCookieName)
+ if clearedNotice == nil || clearedNotice.MaxAge != -1 {
+ t.Fatalf("fresh login form did not clear notice: %#v", clearedNotice)
+ }
+ if cookie := responseCookieNamed(get.Result(), "login_challenge"); cookie != nil {
+ t.Fatalf("fresh login form mutated pending challenge: %#v", cookie)
+ }
+ })
+ }
+}
+
+func TestUnexpectedLoginOutcomeRendersGeneric502WithoutHydraDecision(t *testing.T) {
+ upstream := withKratosServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ setJSONResponse(w, http.StatusBadRequest, `{"error":"upstream private prose","obsolete_state":"obsolete-upstream-state","ui":{"action":"https://upstream.invalid/generic-action"}}`)
+ }))
+ server := testServer(t, upstream.URL)
+ recorder := httptest.NewRecorder()
+ request := formRequest(http.MethodPost, "/login?flow="+obsoleteFlowSentinel, url.Values{
+ "csrf_token": {obsoleteCSRFSentinel}, "username": {"user"}, "password": {submittedPasswordSentinel},
+ })
+ request.AddCookie(&http.Cookie{Name: "login_challenge", Value: "pending"})
+ server.login(recorder, request)
+ if recorder.Code != http.StatusBadGateway {
+ t.Fatalf("status = %d, want 502", recorder.Code)
+ }
+ body := recorder.Body.String()
+ for _, expected := range []string{"Authentication unavailable", authenticationUnavailableMessage, `href="/login"`} {
+ if !strings.Contains(body, expected) {
+ t.Fatalf("generic login page omitted %q", expected)
+ }
+ }
+ for _, forbidden := range []string{submittedPasswordSentinel, obsoleteFlowSentinel, obsoleteCSRFSentinel, "https://upstream.invalid/generic-action", "obsolete-upstream-state", "upstream private prose"} {
+ if strings.Contains(body, forbidden) {
+ t.Fatalf("generic login page rendered forbidden state %q", forbidden)
+ }
+ }
+}
+
+func TestMalformedLocalFormRequestReturns400(t *testing.T) {
+ server := testServer(t, "http://127.0.0.1:1")
+ request := httptest.NewRequest(http.MethodPost, "/register?flow=flow", strings.NewReader("username=%zz"))
+ request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ recorder := httptest.NewRecorder()
+ server.register(recorder, request)
+ if recorder.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400", recorder.Code)
+ }
+}
+
+func TestRenderTemplateBuffersExecutionFailure(t *testing.T) {
+ tmpl := template.Must(template.New("broken").Funcs(template.FuncMap{
+ "fail": func() (string, error) { return "", http.ErrAbortHandler },
+ }).Parse(`prefix{{fail}}suffix`))
+ recorder := httptest.NewRecorder()
+ renderTemplate(recorder, tmpl, http.StatusUnprocessableEntity, nil)
+ if recorder.Code != http.StatusInternalServerError || strings.Contains(recorder.Body.String(), "prefix") {
+ t.Fatalf("execution failure status=%d body=%q", recorder.Code, recorder.Body.String())
+ }
+}
+
+func renderedElementAttributes(body, element string) []map[string]string {
+ elementPattern := regexp.MustCompile(`(?is)<` + regexp.QuoteMeta(element) + `\b([^>]*)>`)
+ attributePattern := regexp.MustCompile(`(?i)([a-z_:][a-z0-9_:.-]*)(?:\s*=\s*("[^"]*"|'[^']*'|[^\s"'=<>]+))?`)
+ matches := elementPattern.FindAllStringSubmatch(body, -1)
+ result := make([]map[string]string, 0, len(matches))
+ for _, match := range matches {
+ attributes := map[string]string{}
+ for _, attribute := range attributePattern.FindAllStringSubmatch(match[1], -1) {
+ value := attribute[2]
+ if len(value) >= 2 && ((value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '\'' && value[len(value)-1] == '\'')) {
+ value = value[1 : len(value)-1]
+ }
+ attributes[strings.ToLower(attribute[1])] = html.UnescapeString(value)
+ }
+ result = append(result, attributes)
+ }
+ return result
+}
+
+func assertNoHiddenInputsNamed(t *testing.T, body string, forbiddenNames ...string) {
+ t.Helper()
+ forbidden := make(map[string]bool, len(forbiddenNames))
+ for _, name := range forbiddenNames {
+ forbidden[strings.ToLower(name)] = true
+ }
+ for _, attributes := range renderedElementAttributes(body, "input") {
+ if strings.EqualFold(attributes["type"], "hidden") && forbidden[strings.ToLower(attributes["name"])] {
+ t.Fatalf("rendered forbidden hidden input named %q", attributes["name"])
+ }
+ }
+}
+
+func TestRenderedElementAttributesAreOrderIndependent(t *testing.T) {
+ inputs := renderedElementAttributes(`<input value="one" name='username' type="hidden"><input TYPE=hidden disabled NAME=method><input name="alternate" required type='password'>`, "input")
+ if len(inputs) != 3 {
+ t.Fatalf("input count=%d, want 3", len(inputs))
+ }
+ for index, expected := range []map[string]string{
+ {"value": "one", "name": "username", "type": "hidden"},
+ {"type": "hidden", "disabled": "", "name": "method"},
+ {"name": "alternate", "required": "", "type": "password"},
+ } {
+ if !reflect.DeepEqual(inputs[index], expected) {
+ t.Fatalf("input %d attributes=%v, want %v", index, inputs[index], expected)
+ }
+ }
+}
+
+func TestPageModelsDoNotContainPasswordValues(t *testing.T) {
+ models := []any{LoginPageData{}, RegisterPageData{}, ChangePasswordPageData{}, AccountPageData{}, ErrorPageData{}}
+ for _, model := range models {
+ typeOf := reflect.TypeOf(model)
+ for i := 0; i < typeOf.NumField(); i++ {
+ field := typeOf.Field(i)
+ if field.Type.Kind() == reflect.String && strings.Contains(strings.ToLower(field.Name), "password") {
+ t.Fatalf("%s contains password-bearing string field %s", typeOf.Name(), field.Name)
+ }
+ }
+ }
+}
+
+func TestSemanticTemplateContracts(t *testing.T) {
+ templates := testTemplates(t)
+ usernameErrors := []ValidationError{
+ {Field: "username", Message: "First username error."},
+ {Field: "username", Message: "Second username error."},
+ }
+ passwordErrors := []ValidationError{
+ {Field: "password", Message: testPasswordLengthMessage},
+ {Field: "password", Message: testPasswordCompositionMessage},
+ }
+ pages := []struct {
+ name string
+ tmpl *template.Template
+ data any
+ h1 string
+ formCount int
+ passwordCount int
+ }{
+ {"login", templates.Login, LoginPageData{FormAction: "/login?flow=flow", CSRFToken: "csrf", EnableRegistration: true}, "Sign in", 1, 1},
+ {"register", templates.Register, RegisterPageData{FormAction: "/register?flow=flow", CSRFToken: "csrf"}, "Create account", 1, 1},
+ {"register errors", templates.Register, RegisterPageData{FormAction: "/register?flow=flow", CSRFToken: "csrf", Username: "retained", UsernameErrors: usernameErrors, PasswordErrors: passwordErrors, GeneralError: registrationRejectedMessage}, "Create account", 1, 1},
+ {"change password", templates.ChangePassword, ChangePasswordPageData{FormAction: "/settings?flow=flow", CSRFToken: "csrf", Username: "tester"}, "Change password", 1, 1},
+ {"change password errors", templates.ChangePassword, ChangePasswordPageData{FormAction: "/settings?flow=flow", CSRFToken: "csrf", Username: "tester", PasswordErrors: passwordErrors, GeneralError: passwordChangeRejectedMessage}, "Change password", 1, 1},
+ {"account", templates.WhoAmI, AccountPageData{Username: "tester"}, "Account", 0, 0},
+ {"success", templates.ChangePasswordSuccess, nil, "Password changed", 0, 0},
+ {"error", templates.Error, ErrorPageData{Title: "Authentication unavailable", Message: authenticationUnavailableMessage, RecoveryHref: "/login", RecoveryText: "Try signing in again"}, "Authentication unavailable", 0, 0},
+ }
+ idPattern := regexp.MustCompile(`\bid="([^"]+)"`)
+ for _, page := range pages {
+ t.Run(page.name, func(t *testing.T) {
+ rendered, err := executeTemplate(page.tmpl, page.data)
+ if err != nil {
+ t.Fatal(err)
+ }
+ body := string(rendered)
+ if strings.Count(body, "<main") != 1 {
+ t.Fatalf("main count=%d, want 1", strings.Count(body, "<main"))
+ }
+ if strings.Count(body, "<h1") != 1 || !strings.Contains(body, "<h1>"+page.h1+"</h1>") {
+ t.Fatalf("h1 contract missing for %q", page.h1)
+ }
+ seen := map[string]bool{}
+ for _, match := range idPattern.FindAllStringSubmatch(body, -1) {
+ if match[1] == "" || seen[match[1]] {
+ t.Fatalf("empty or duplicate id %q", match[1])
+ }
+ seen[match[1]] = true
+ }
+ for _, forbidden := range []string{"autofocus", "minlength=", "maxlength=", "pattern=", "confirmation", `role="button"`, `aria-invalid="false"`, `aria-invalid="undefined"`} {
+ if strings.Contains(strings.ToLower(body), strings.ToLower(forbidden)) {
+ t.Fatalf("rendered forbidden form contract %q", forbidden)
+ }
+ }
+ forms := renderedElementAttributes(body, "form")
+ if len(forms) != page.formCount {
+ t.Fatalf("form count=%d, want %d", len(forms), page.formCount)
+ }
+ passwordCount := 0
+ for _, attributes := range renderedElementAttributes(body, "input") {
+ if !strings.EqualFold(attributes["type"], "password") {
+ continue
+ }
+ passwordCount++
+ if _, present := attributes["value"]; present {
+ t.Fatal("rendered password input has a value attribute")
+ }
+ }
+ if passwordCount != page.passwordCount {
+ t.Fatalf("password input count=%d, want %d", passwordCount, page.passwordCount)
+ }
+ })
+ }
+}
+
+func TestFormLabelsNativeAttributesAndPersistentPolicy(t *testing.T) {
+ templates := testTemplates(t)
+ login, err := executeTemplate(templates.Login, LoginPageData{FormAction: "/login?flow=flow", CSRFToken: "csrf", EnableRegistration: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ register, err := executeTemplate(templates.Register, RegisterPageData{FormAction: "/register?flow=flow", CSRFToken: "csrf"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ settings, err := executeTemplate(templates.ChangePassword, ChangePasswordPageData{FormAction: "/settings?flow=flow", CSRFToken: "csrf", Username: "tester"})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ contracts := []struct {
+ name string
+ body string
+ fragments []string
+ }{
+ {
+ "login",
+ string(login),
+ []string{
+ `<label for="login-username">Username</label>`,
+ `id="login-username" type="text" name="username" autocomplete="username" autocapitalize="none" spellcheck="false" required`,
+ `<label for="login-password">Password</label>`,
+ `id="login-password" type="password" name="password" autocomplete="current-password" required`,
+ `<button id="login-submit" class="subbmit-button" type="submit">Sign in</button>`,
+ `<a href="/register">Create account</a>`,
+ },
+ },
+ {
+ "register",
+ string(register),
+ []string{
+ `<label for="register-username">Username</label>`,
+ `id="register-username" type="text" name="username" value="" autocomplete="username" autocapitalize="none" spellcheck="false" required`,
+ `<label for="register-password">Password</label>`,
+ `id="register-password" type="password" name="password" autocomplete="new-password" required aria-describedby="register-password-policy register-password-symbols"`,
+ `<button id="register-submit" type="submit">Create account</button>`,
+ `<a href="/login">Sign in</a>`,
+ },
+ },
+ {
+ "settings",
+ string(settings),
+ []string{
+ `<label for="change-password">New password</label>`,
+ `id="change-password" type="password" name="password" autocomplete="new-password" required aria-describedby="change-password-policy change-password-symbols"`,
+ `<button id="change-password-submit" type="submit">Change password</button>`,
+ `<a href="/">Back to account</a>`,
+ },
+ },
+ }
+ for _, contract := range contracts {
+ t.Run(contract.name, func(t *testing.T) {
+ for _, fragment := range contract.fragments {
+ if !strings.Contains(contract.body, fragment) {
+ t.Fatalf("missing semantic fragment %q", fragment)
+ }
+ }
+ if strings.Contains(contract.body, `aria-invalid=`) {
+ t.Fatal("pristine form rendered aria-invalid")
+ }
+ })
+ }
+
+ const policy = "Use at least 20 bytes, including an uppercase letter, lowercase letter, number, and an ASCII symbol or space."
+ const symbols = `!"#$%&'()*+,-./:;<=>?@[\]^_{|}~`
+ for name, body := range map[string]string{"register": string(register), "settings": string(settings)} {
+ unescaped := html.UnescapeString(body)
+ if strings.Count(unescaped, policy) != 1 {
+ t.Fatalf("%s policy guidance count=%d, want 1", name, strings.Count(unescaped, policy))
+ }
+ if !strings.Contains(unescaped, "Accepted ASCII symbols: <code>"+symbols+"</code>. ASCII space is also accepted.") {
+ t.Fatalf("%s omitted exact rendered ASCII symbols", name)
+ }
+ }
+}
+
+func TestValidationSummariesAndFieldAssociations(t *testing.T) {
+ templates := testTemplates(t)
+ usernameErrors := []ValidationError{
+ {Field: "username", Message: "First username error."},
+ {Field: "username", Message: "Second username error."},
+ }
+ passwordErrors := []ValidationError{
+ {Field: "password", Message: testPasswordLengthMessage},
+ {Field: "password", Message: testPasswordCompositionMessage},
+ }
+ register, err := executeTemplate(templates.Register, RegisterPageData{
+ FormAction: "/register?flow=flow",
+ CSRFToken: "csrf",
+ UsernameErrors: usernameErrors,
+ PasswordErrors: passwordErrors,
+ GeneralError: registrationRejectedMessage,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ body := string(register)
+ if strings.Count(body, `role="alert"`) != 1 || strings.Index(body, `role="alert"`) > strings.Index(body, `id="register-username"`) {
+ t.Fatal("registration alert summary is not unique and source-ordered before fields")
+ }
+ unescapedBody := html.UnescapeString(body)
+ for _, message := range []string{"First username error.", "Second username error.", testPasswordLengthMessage, testPasswordCompositionMessage} {
+ if strings.Count(unescapedBody, message) != 2 {
+ t.Fatalf("error %q count=%d, want summary and field error", message, strings.Count(unescapedBody, message))
+ }
+ }
+ for _, fragment := range []string{
+ `aria-invalid="true" aria-describedby="register-username-error-0 register-username-error-1"`,
+ `id="register-username-error-0"`,
+ `id="register-username-error-1"`,
+ `aria-describedby="register-password-policy register-password-symbols register-password-error-0 register-password-error-1" aria-invalid="true"`,
+ `id="register-password-error-0"`,
+ `id="register-password-error-1"`,
+ } {
+ if !strings.Contains(body, fragment) {
+ t.Fatalf("registration errors missing association %q", fragment)
+ }
+ }
+
+ settings, err := executeTemplate(templates.ChangePassword, ChangePasswordPageData{
+ Username: "tester",
+ FormAction: "/settings?flow=flow",
+ CSRFToken: "csrf",
+ PasswordErrors: passwordErrors,
+ GeneralError: passwordChangeRejectedMessage,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ body = string(settings)
+ if strings.Count(body, `role="alert"`) != 1 || strings.Index(body, `role="alert"`) > strings.Index(body, `id="change-password"`) {
+ t.Fatal("settings alert summary is not unique and source-ordered before its field")
+ }
+ for _, fragment := range []string{
+ `aria-describedby="change-password-policy change-password-symbols change-password-error-0 change-password-error-1" aria-invalid="true"`,
+ `id="change-password-error-0"`,
+ `id="change-password-error-1"`,
+ } {
+ if !strings.Contains(body, fragment) {
+ t.Fatalf("settings errors missing association %q", fragment)
+ }
+ }
+}
+
+func TestTemplateHierarchyNavigationAndEscaping(t *testing.T) {
+ templates := testTemplates(t)
+ account, err := executeTemplate(templates.WhoAmI, AccountPageData{Username: `<script>alert("secret")</script>`})
+ if err != nil {
+ t.Fatal(err)
+ }
+ accountBody := string(account)
+ if strings.Contains(accountBody, `<script>`) || !strings.Contains(accountBody, `<script>`) {
+ t.Fatal("account username was not safely escaped")
+ }
+ for _, fragment := range []string{`<h1>Account</h1>`, `<a href="/settings">Change password</a>`, `<a href="/logout">Log out</a>`} {
+ if !strings.Contains(accountBody, fragment) {
+ t.Fatalf("account missing hierarchy/navigation %q", fragment)
+ }
+ }
+
+ success, err := executeTemplate(templates.ChangePasswordSuccess, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, fragment := range []string{`<h1>Password changed</h1>`, `<p role="status">Password changed successfully.</p>`, `<a href="/">Back to account</a>`} {
+ if !strings.Contains(string(success), fragment) {
+ t.Fatalf("success page missing %q", fragment)
+ }
+ }
+
+ errorPage, err := executeTemplate(templates.Error, ErrorPageData{
+ Title: "Authentication unavailable",
+ Message: authenticationUnavailableMessage,
+ RecoveryHref: "/login",
+ RecoveryText: "Try signing in again",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ errorBody := string(errorPage)
+ if strings.Count(errorBody, "<a ") != 1 {
+ t.Fatalf("generic error recovery link count=%d, want 1", strings.Count(errorBody, "<a "))
+ }
+ for _, fragment := range []string{`<h1>Authentication unavailable</h1>`, authenticationUnavailableMessage, `<a href="/login">Try signing in again</a>`} {
+ if !strings.Contains(errorBody, fragment) {
+ t.Fatalf("generic error page missing %q", fragment)
+ }
+ }
+
+ register, err := executeTemplate(templates.Register, RegisterPageData{
+ FormAction: "/register?flow=flow&return=<unsafe>",
+ CSRFToken: `<csrf&secret>`,
+ Username: `<img src=x onerror=secret>`,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ registerBody := string(register)
+ for _, forbidden := range []string{`<unsafe>`, `<csrf&secret>`, `<img src=x onerror=secret>`} {
+ if strings.Contains(registerBody, forbidden) {
+ t.Fatalf("registration rendered unsafe value %q", forbidden)
+ }
+ }
+ assertNoHiddenInputsNamed(t, registerBody, "method", "username")
+
+ settings, err := executeTemplate(templates.ChangePassword, ChangePasswordPageData{Username: "tester", FormAction: "/settings?flow=flow", CSRFToken: "csrf"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ settingsBody := string(settings)
+ assertNoHiddenInputsNamed(t, settingsBody, "method", "username")
+}
diff --git a/core/auth/ui/static/base.css b/core/auth/ui/static/base.css
new file mode 100644
index 0000000..0df1916
--- /dev/null
+++ b/core/auth/ui/static/base.css
@@ -0,0 +1,129 @@
+:root {
+ --color-surface: #d6d6d6;
+ --color-ink: #3a3a3a;
+ --color-action: #7f9f7f;
+ --color-identity: #d4888d;
+ --space-1: 4px;
+ --space-2: 8px;
+ --space-3: 12px;
+ --space-4: 16px;
+ --space-5: 24px;
+ --space-6: 32px;
+ --font-mono: "Hack", ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, monospace;
+ color: var(--color-ink);
+ background: var(--color-surface);
+ font-family: var(--font-mono);
+ font-size: 14px;
+ line-height: 1.5;
+}
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+html {
+ min-width: 0;
+ margin: 0;
+ padding: 0;
+ background: var(--color-surface);
+ color: var(--color-ink);
+ scroll-behavior: smooth;
+}
+
+body {
+ min-width: 0;
+ min-height: 100vh;
+ min-height: 100dvh;
+ margin: 0;
+ background: var(--color-surface);
+ color: var(--color-ink);
+}
+
+button,
+input,
+select,
+textarea {
+ max-width: 100%;
+ border-radius: 0;
+ color: inherit;
+ font: inherit;
+}
+
+button,
+input,
+select,
+textarea,
+a {
+ min-height: 44px;
+}
+
+button,
+a {
+ min-width: 44px;
+}
+
+button {
+ cursor: pointer;
+}
+
+input,
+select,
+textarea {
+ width: 100%;
+}
+
+a {
+ color: inherit;
+ overflow-wrap: anywhere;
+ text-decoration-thickness: 0.12em;
+ text-underline-offset: 0.18em;
+}
+
+a:hover {
+ text-decoration-thickness: 0.2em;
+}
+
+:focus-visible {
+ outline: 2px solid var(--color-ink);
+ outline-offset: 2px;
+}
+
+h1,
+h2,
+p,
+li,
+label,
+strong,
+code {
+ overflow-wrap: anywhere;
+}
+
+code {
+ color: inherit;
+ font-family: inherit;
+}
+
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ scroll-behavior: auto !important;
+ transition-duration: 0s !important;
+ animation-duration: 0s !important;
+ animation-iteration-count: 1 !important;
+ }
+}
diff --git a/core/auth/ui/static/main.css b/core/auth/ui/static/main.css
index a261094..fa297ec 100644
--- a/core/auth/ui/static/main.css
+++ b/core/auth/ui/static/main.css
@@ -1,89 +1,207 @@
-[data-theme="light"],
-:root:not([data-theme="dark"]) {
- --pico-font-family: Hack, monospace;
- --pico-font-size: 14px;
- --pico-background-color: #d6d6d6;
- --pico-border-radius: 0;
- --pico-form-element-border-color: #ffffff;
- --pico-form-element-active-border-color: #7f9f7f;
- --pico-form-element-focus-color: #7f9f7f;
- --pico-form-element-background-color: #3a3a3a;
- --pico-form-element-active-background-color: #3a3a3a;
- --pico-form-element-selected-background-color: #3a3a3a;
- --pico-primary: #7f9f7f;
- --pico-primary-background: #7f9f7f;
- --pico-primary-hover: #d4888d;
- --pico-primary-hover-background: #d4888d;
- --pico-grid-spacing-horizontal: 0;
-}
-
body {
- width: 100%;
- height: 100vh;
- display: flex;
- justify-content: center;
- align-items: center;
+ font-size: 14px;
}
.container {
- max-width: 500px !important;
+ display: flex;
width: 100%;
+ min-width: 0;
+ min-height: 100vh;
+ min-height: 100dvh;
+ padding: var(--space-4);
+ align-items: center;
+ justify-content: center;
+}
+
+.terminal {
+ width: min(100%, 500px);
+ min-width: 0;
+ padding: var(--space-5);
+ border-left: 4px solid var(--color-action);
+ background: var(--color-ink);
+ color: var(--color-surface);
+}
+
+.terminal > :first-child {
+ margin-top: 0;
+}
+
+.terminal > :last-child {
+ margin-bottom: 0;
+}
+
+.logo {
+ display: flex;
+ min-width: 0;
+ margin: 0 0 var(--space-3);
+ font-size: 24px;
+ font-weight: 700;
+ line-height: 1;
+}
+
+.logo span:last-child {
+ color: var(--color-identity);
+}
+
+h1 {
+ margin: 0 0 var(--space-4);
+ padding-bottom: var(--space-2);
+ border-bottom: 1px solid var(--color-action);
+ font-size: 22px;
+ line-height: 1.2;
+}
+
+p,
+ul {
+ margin-top: 0;
+ margin-bottom: var(--space-3);
}
form {
- padding: 10px;
- background-color: var(--pico-form-element-background-color);
- width: 100%;
+ display: grid;
+ min-width: 0;
+ gap: var(--space-2);
+ margin: 0;
}
-input {
- background: var(--pico-form-element-background-color);
- color: white;
- padding: 10px;
- /* border: 1px solid var(--pico-form-element-border-color); */
+label {
+ display: block;
+ min-width: 0;
+ margin-top: var(--space-1);
+ font-weight: 700;
+}
+
+input:not([type="hidden"]) {
+ min-width: 0;
+ padding: var(--space-2) var(--space-3);
+ border: 2px solid var(--color-surface);
+ background: var(--color-surface);
+ color: var(--color-ink);
+ caret-color: var(--color-ink);
text-align: left;
- font-family: var(--pico-font-family) !important;
+}
+
+input[aria-invalid="true"] {
+ border-left: 8px solid var(--color-identity);
+}
+
+button {
+ display: inline-flex;
+ width: fit-content;
+ max-width: 100%;
+ margin-top: var(--space-2);
+ padding: var(--space-2) var(--space-4);
+ align-items: center;
+ justify-content: center;
+ border: 2px solid var(--color-surface);
+ background: var(--color-surface);
+ color: var(--color-ink);
+ font-weight: 700;
+ overflow-wrap: anywhere;
+ text-align: center;
+}
+
+button:hover {
+ border-color: var(--color-action);
+ text-decoration: underline;
+ text-decoration-thickness: 0.15em;
+ text-underline-offset: 0.18em;
+}
+
+.terminal :focus-visible {
+ outline-color: var(--color-surface);
+}
+
+input:focus-visible,
+button:focus-visible {
+ outline-offset: 3px;
+}
+
+[role="alert"],
+[role="status"] {
+ min-width: 0;
+ margin: 0 0 var(--space-2);
+ padding: var(--space-2) 0 var(--space-2) var(--space-3);
+ border-left: 4px solid var(--color-identity);
+}
+
+[role="status"] {
+ border-left-color: var(--color-action);
+}
+
+[role="alert"] > :last-child,
+[role="status"]:last-child {
+ margin-bottom: 0;
+}
+
+form > p[id$="-policy"],
+form > p[id$="-symbols"],
+.error-message {
+ margin: 0;
+ font-size: 0.9rem;
+}
+
+.error-message {
+ padding-left: var(--space-2);
+ border-left: 4px solid var(--color-identity);
+ font-weight: 700;
+}
+
+nav {
+ min-width: 0;
+ margin-top: var(--space-3);
+}
+
+nav ul {
+ display: flex;
+ min-width: 0;
+ margin: 0;
+ padding: 0;
+ flex-wrap: wrap;
+ gap: var(--space-2) var(--space-4);
+ list-style: none;
+}
+
+nav li {
+ min-width: 0;
+}
+
+nav a,
+.terminal > a {
+ display: inline-flex;
+ max-width: 100%;
+ padding: var(--space-2) 0;
+ align-items: center;
+ font-weight: 700;
+}
+
+strong {
+ min-width: 0;
}
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
input:-webkit-autofill:active {
- -webkit-text-fill-color: white !important;
- transition: background-color 5000s ease-in-out 0s;
- background-color: var(--pico-form-element-background-color) !important;
- /* font-size: var(--pico-font-size) !important;
- font-family: var(--pico-font-family) !important; */
+ -webkit-text-fill-color: var(--color-ink);
+ caret-color: var(--color-ink);
+ font: inherit;
}
-&:-webkit-autofill::first-line {
- font-family: var(--pico-font-family) !important;
+@media (max-width: 360px) {
+ .container {
+ padding: var(--space-2);
+ }
+
+ .terminal {
+ padding: var(--space-3);
+ }
}
-p {
- color: white;
-}
-
-.logo span:first-child {
- color: white;
- font-size: 24px;
- padding-left: 10px;
-}
-
-.logo span:nth-child(2) {
- color: var(--pico-primary-hover);
- font-size: 24px;
-}
-
-.logo {
- padding-top: var(--pico-spacing);
- background-color: var(--pico-form-element-background-color);
-}
-
-label {
- color: white;
-}
-
-.error-message {
- color: var(--pico-primary-hover);
+@media (max-height: 480px) {
+ .container {
+ padding-top: var(--space-2);
+ padding-bottom: var(--space-2);
+ align-items: flex-start;
+ }
}
diff --git a/core/auth/ui/static/pico.2.0.6.min.css b/core/auth/ui/static/pico.2.0.6.min.css
deleted file mode 100644
index 5928ed7..0000000
--- a/core/auth/ui/static/pico.2.0.6.min.css
+++ /dev/null
@@ -1,4 +0,0 @@
-@charset "UTF-8";/*!
- * Pico CSS ✨ v2.0.6 (https://picocss.com)
- * Copyright 2019-2024 - Licensed under MIT
- */:root{--pico-font-family-emoji:"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--pico-font-family-sans-serif:system-ui,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,Helvetica,Arial,"Helvetica Neue",sans-serif,var(--pico-font-family-emoji);--pico-font-family-monospace:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace,var(--pico-font-family-emoji);--pico-font-family:var(--pico-font-family-sans-serif);--pico-line-height:1.5;--pico-font-weight:400;--pico-font-size:100%;--pico-text-underline-offset:0.1rem;--pico-border-radius:0.25rem;--pico-border-width:0.0625rem;--pico-outline-width:0.125rem;--pico-transition:0.2s ease-in-out;--pico-spacing:1rem;--pico-typography-spacing-vertical:1rem;--pico-block-spacing-vertical:var(--pico-spacing);--pico-block-spacing-horizontal:var(--pico-spacing);--pico-grid-column-gap:var(--pico-spacing);--pico-grid-row-gap:var(--pico-spacing);--pico-form-element-spacing-vertical:0.75rem;--pico-form-element-spacing-horizontal:1rem;--pico-group-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-primary-focus);--pico-group-box-shadow-focus-with-input:0 0 0 0.0625rem var(--pico-form-element-border-color);--pico-modal-overlay-backdrop-filter:blur(0.375rem);--pico-nav-element-spacing-vertical:1rem;--pico-nav-element-spacing-horizontal:0.5rem;--pico-nav-link-spacing-vertical:0.5rem;--pico-nav-link-spacing-horizontal:0.5rem;--pico-nav-breadcrumb-divider:">";--pico-icon-checkbox:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-minus:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");--pico-icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-date:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");--pico-icon-time:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-search:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E");--pico-icon-close:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E");--pico-icon-loading:url("data:image/svg+xml,%3Csvg fill='none' height='24' width='24' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg' %3E%3Cstyle%3E g %7B animation: rotate 2s linear infinite; transform-origin: center center; %7D circle %7B stroke-dasharray: 75,100; stroke-dashoffset: -5; animation: dash 1.5s ease-in-out infinite; stroke-linecap: round; %7D @keyframes rotate %7B 0%25 %7B transform: rotate(0deg); %7D 100%25 %7B transform: rotate(360deg); %7D %7D @keyframes dash %7B 0%25 %7B stroke-dasharray: 1,100; stroke-dashoffset: 0; %7D 50%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -17.5; %7D 100%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -62; %7D %7D %3C/style%3E%3Cg%3E%3Ccircle cx='12' cy='12' r='10' fill='none' stroke='rgb(136, 145, 164)' stroke-width='4' /%3E%3C/g%3E%3C/svg%3E")}@media (min-width:576px){:root{--pico-font-size:106.25%}}@media (min-width:768px){:root{--pico-font-size:112.5%}}@media (min-width:1024px){:root{--pico-font-size:118.75%}}@media (min-width:1280px){:root{--pico-font-size:125%}}@media (min-width:1536px){:root{--pico-font-size:131.25%}}a{--pico-text-decoration:underline}a.contrast,a.secondary{--pico-text-decoration:underline}small{--pico-font-size:0.875em}h1,h2,h3,h4,h5,h6{--pico-font-weight:700}h1{--pico-font-size:2rem;--pico-line-height:1.125;--pico-typography-spacing-top:3rem}h2{--pico-font-size:1.75rem;--pico-line-height:1.15;--pico-typography-spacing-top:2.625rem}h3{--pico-font-size:1.5rem;--pico-line-height:1.175;--pico-typography-spacing-top:2.25rem}h4{--pico-font-size:1.25rem;--pico-line-height:1.2;--pico-typography-spacing-top:1.874rem}h5{--pico-font-size:1.125rem;--pico-line-height:1.225;--pico-typography-spacing-top:1.6875rem}h6{--pico-font-size:1rem;--pico-line-height:1.25;--pico-typography-spacing-top:1.5rem}tfoot td,tfoot th,thead td,thead th{--pico-font-weight:600;--pico-border-width:0.1875rem}code,kbd,pre,samp{--pico-font-family:var(--pico-font-family-monospace)}kbd{--pico-font-weight:bolder}:where(select,textarea),input:not([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-outline-width:0.0625rem}[type=search]{--pico-border-radius:5rem}[type=checkbox],[type=radio]{--pico-border-width:0.125rem}[type=checkbox][role=switch]{--pico-border-width:0.1875rem}details.dropdown summary:not([role=button]){--pico-outline-width:0.0625rem}nav details.dropdown summary:focus-visible{--pico-outline-width:0.125rem}[role=search]{--pico-border-radius:5rem}[role=group]:has(button.secondary:focus,[type=submit].secondary:focus,[type=button].secondary:focus,[role=button].secondary:focus),[role=search]:has(button.secondary:focus,[type=submit].secondary:focus,[type=button].secondary:focus,[role=button].secondary:focus){--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}[role=group]:has(button.contrast:focus,[type=submit].contrast:focus,[type=button].contrast:focus,[role=button].contrast:focus),[role=search]:has(button.contrast:focus,[type=submit].contrast:focus,[type=button].contrast:focus,[role=button].contrast:focus){--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-contrast-focus)}[role=group] [role=button],[role=group] [type=button],[role=group] [type=submit],[role=group] button,[role=search] [role=button],[role=search] [type=button],[role=search] [type=submit],[role=search] button{--pico-form-element-spacing-horizontal:2rem}details summary[role=button]:not(.outline)::after{filter:brightness(0) invert(1)}[aria-busy=true]:not(input,select,textarea):is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0) invert(1)}:root:not([data-theme=dark]),[data-theme=light]{--pico-background-color:#fff;--pico-color:#373c44;--pico-text-selection-color:rgba(2, 154, 232, 0.25);--pico-muted-color:#646b79;--pico-muted-border-color:#e7eaf0;--pico-primary:#0172ad;--pico-primary-background:#0172ad;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(1, 114, 173, 0.5);--pico-primary-hover:#015887;--pico-primary-hover-background:#02659a;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(2, 154, 232, 0.5);--pico-primary-inverse:#fff;--pico-secondary:#5d6b89;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(93, 107, 137, 0.5);--pico-secondary-hover:#48536b;--pico-secondary-hover-background:#48536b;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(93, 107, 137, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#181c25;--pico-contrast-background:#181c25;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(24, 28, 37, 0.5);--pico-contrast-hover:#000;--pico-contrast-hover-background:#000;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-secondary-hover);--pico-contrast-focus:rgba(93, 107, 137, 0.25);--pico-contrast-inverse:#fff;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(129, 145, 181, 0.01698),0.0335rem 0.067rem 0.402rem rgba(129, 145, 181, 0.024),0.0625rem 0.125rem 0.75rem rgba(129, 145, 181, 0.03),0.1125rem 0.225rem 1.35rem rgba(129, 145, 181, 0.036),0.2085rem 0.417rem 2.502rem rgba(129, 145, 181, 0.04302),0.5rem 1rem 6rem rgba(129, 145, 181, 0.06),0 0 0 0.0625rem rgba(129, 145, 181, 0.015);--pico-h1-color:#2d3138;--pico-h2-color:#373c44;--pico-h3-color:#424751;--pico-h4-color:#4d535e;--pico-h5-color:#5c6370;--pico-h6-color:#646b79;--pico-mark-background-color:#fde7c0;--pico-mark-color:#0f1114;--pico-ins-color:#1d6a54;--pico-del-color:#883935;--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:#f3f5f7;--pico-code-color:#646b79;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:#fbfcfc;--pico-form-element-selected-background-color:#dfe3eb;--pico-form-element-border-color:#cfd5e2;--pico-form-element-color:#23262c;--pico-form-element-placeholder-color:var(--pico-muted-color);--pico-form-element-active-background-color:#fff;--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:#b86a6b;--pico-form-element-invalid-active-border-color:#c84f48;--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:#4c9b8a;--pico-form-element-valid-active-border-color:#279977;--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#bfc7d9;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#dfe3eb;--pico-range-active-border-color:#bfc7d9;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:var(--pico-background-color);--pico-card-border-color:var(--pico-muted-border-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:#fbfcfc;--pico-dropdown-background-color:#fff;--pico-dropdown-border-color:#eff1f4;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#eff1f4;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(232, 234, 237, 0.75);--pico-progress-background-color:#dfe3eb;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(76, 155, 138)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(200, 79, 72)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E");color-scheme:light}:root:not([data-theme=dark]) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),[data-theme=light] input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}@media only screen and (prefers-color-scheme:dark){:root:not([data-theme]){--pico-background-color:#13171f;--pico-color:#c2c7d0;--pico-text-selection-color:rgba(1, 170, 255, 0.1875);--pico-muted-color:#7b8495;--pico-muted-border-color:#202632;--pico-primary:#01aaff;--pico-primary-background:#0172ad;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(1, 170, 255, 0.5);--pico-primary-hover:#79c0ff;--pico-primary-hover-background:#017fc0;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(1, 170, 255, 0.375);--pico-primary-inverse:#fff;--pico-secondary:#969eaf;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(150, 158, 175, 0.5);--pico-secondary-hover:#b3b9c5;--pico-secondary-hover-background:#5d6b89;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(144, 158, 190, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#dfe3eb;--pico-contrast-background:#eff1f4;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(223, 227, 235, 0.5);--pico-contrast-hover:#fff;--pico-contrast-hover-background:#fff;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-contrast-hover);--pico-contrast-focus:rgba(207, 213, 226, 0.25);--pico-contrast-inverse:#000;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(7, 9, 12, 0.01698),0.0335rem 0.067rem 0.402rem rgba(7, 9, 12, 0.024),0.0625rem 0.125rem 0.75rem rgba(7, 9, 12, 0.03),0.1125rem 0.225rem 1.35rem rgba(7, 9, 12, 0.036),0.2085rem 0.417rem 2.502rem rgba(7, 9, 12, 0.04302),0.5rem 1rem 6rem rgba(7, 9, 12, 0.06),0 0 0 0.0625rem rgba(7, 9, 12, 0.015);--pico-h1-color:#f0f1f3;--pico-h2-color:#e0e3e7;--pico-h3-color:#c2c7d0;--pico-h4-color:#b3b9c5;--pico-h5-color:#a4acba;--pico-h6-color:#8891a4;--pico-mark-background-color:#014063;--pico-mark-color:#fff;--pico-ins-color:#62af9a;--pico-del-color:#ce7e7b;--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:#1a1f28;--pico-code-color:#8891a4;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:#1c212c;--pico-form-element-selected-background-color:#2a3140;--pico-form-element-border-color:#2a3140;--pico-form-element-color:#e0e3e7;--pico-form-element-placeholder-color:#8891a4;--pico-form-element-active-background-color:#1a1f28;--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:#964a50;--pico-form-element-invalid-active-border-color:#b7403b;--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:#2a7b6f;--pico-form-element-valid-active-border-color:#16896a;--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#333c4e;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#202632;--pico-range-active-border-color:#2a3140;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:#181c25;--pico-card-border-color:var(--pico-card-background-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:#1a1f28;--pico-dropdown-background-color:#181c25;--pico-dropdown-border-color:#202632;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#202632;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(8, 9, 10, 0.75);--pico-progress-background-color:#202632;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(42, 123, 111)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(150, 74, 80)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E");color-scheme:dark}:root:not([data-theme]) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}:root:not([data-theme]) details summary[role=button].contrast:not(.outline)::after{filter:brightness(0)}:root:not([data-theme]) [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0)}}[data-theme=dark]{--pico-background-color:#13171f;--pico-color:#c2c7d0;--pico-text-selection-color:rgba(1, 170, 255, 0.1875);--pico-muted-color:#7b8495;--pico-muted-border-color:#202632;--pico-primary:#01aaff;--pico-primary-background:#0172ad;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(1, 170, 255, 0.5);--pico-primary-hover:#79c0ff;--pico-primary-hover-background:#017fc0;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(1, 170, 255, 0.375);--pico-primary-inverse:#fff;--pico-secondary:#969eaf;--pico-secondary-background:#525f7a;--pico-secondary-border:var(--pico-secondary-background);--pico-secondary-underline:rgba(150, 158, 175, 0.5);--pico-secondary-hover:#b3b9c5;--pico-secondary-hover-background:#5d6b89;--pico-secondary-hover-border:var(--pico-secondary-hover-background);--pico-secondary-hover-underline:var(--pico-secondary-hover);--pico-secondary-focus:rgba(144, 158, 190, 0.25);--pico-secondary-inverse:#fff;--pico-contrast:#dfe3eb;--pico-contrast-background:#eff1f4;--pico-contrast-border:var(--pico-contrast-background);--pico-contrast-underline:rgba(223, 227, 235, 0.5);--pico-contrast-hover:#fff;--pico-contrast-hover-background:#fff;--pico-contrast-hover-border:var(--pico-contrast-hover-background);--pico-contrast-hover-underline:var(--pico-contrast-hover);--pico-contrast-focus:rgba(207, 213, 226, 0.25);--pico-contrast-inverse:#000;--pico-box-shadow:0.0145rem 0.029rem 0.174rem rgba(7, 9, 12, 0.01698),0.0335rem 0.067rem 0.402rem rgba(7, 9, 12, 0.024),0.0625rem 0.125rem 0.75rem rgba(7, 9, 12, 0.03),0.1125rem 0.225rem 1.35rem rgba(7, 9, 12, 0.036),0.2085rem 0.417rem 2.502rem rgba(7, 9, 12, 0.04302),0.5rem 1rem 6rem rgba(7, 9, 12, 0.06),0 0 0 0.0625rem rgba(7, 9, 12, 0.015);--pico-h1-color:#f0f1f3;--pico-h2-color:#e0e3e7;--pico-h3-color:#c2c7d0;--pico-h4-color:#b3b9c5;--pico-h5-color:#a4acba;--pico-h6-color:#8891a4;--pico-mark-background-color:#014063;--pico-mark-color:#fff;--pico-ins-color:#62af9a;--pico-del-color:#ce7e7b;--pico-blockquote-border-color:var(--pico-muted-border-color);--pico-blockquote-footer-color:var(--pico-muted-color);--pico-button-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-button-hover-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-table-border-color:var(--pico-muted-border-color);--pico-table-row-stripped-background-color:rgba(111, 120, 135, 0.0375);--pico-code-background-color:#1a1f28;--pico-code-color:#8891a4;--pico-code-kbd-background-color:var(--pico-color);--pico-code-kbd-color:var(--pico-background-color);--pico-form-element-background-color:#1c212c;--pico-form-element-selected-background-color:#2a3140;--pico-form-element-border-color:#2a3140;--pico-form-element-color:#e0e3e7;--pico-form-element-placeholder-color:#8891a4;--pico-form-element-active-background-color:#1a1f28;--pico-form-element-active-border-color:var(--pico-primary-border);--pico-form-element-focus-color:var(--pico-primary-border);--pico-form-element-disabled-opacity:0.5;--pico-form-element-invalid-border-color:#964a50;--pico-form-element-invalid-active-border-color:#b7403b;--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:#2a7b6f;--pico-form-element-valid-active-border-color:#16896a;--pico-form-element-valid-focus-color:var(--pico-form-element-valid-active-border-color);--pico-switch-background-color:#333c4e;--pico-switch-checked-background-color:var(--pico-primary-background);--pico-switch-color:#fff;--pico-switch-thumb-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-range-border-color:#202632;--pico-range-active-border-color:#2a3140;--pico-range-thumb-border-color:var(--pico-background-color);--pico-range-thumb-color:var(--pico-secondary-background);--pico-range-thumb-active-color:var(--pico-primary-background);--pico-accordion-border-color:var(--pico-muted-border-color);--pico-accordion-active-summary-color:var(--pico-primary-hover);--pico-accordion-close-summary-color:var(--pico-color);--pico-accordion-open-summary-color:var(--pico-muted-color);--pico-card-background-color:#181c25;--pico-card-border-color:var(--pico-card-background-color);--pico-card-box-shadow:var(--pico-box-shadow);--pico-card-sectioning-background-color:#1a1f28;--pico-dropdown-background-color:#181c25;--pico-dropdown-border-color:#202632;--pico-dropdown-box-shadow:var(--pico-box-shadow);--pico-dropdown-color:var(--pico-color);--pico-dropdown-hover-background-color:#202632;--pico-loading-spinner-opacity:0.5;--pico-modal-overlay-background-color:rgba(8, 9, 10, 0.75);--pico-progress-background-color:#202632;--pico-progress-color:var(--pico-primary-background);--pico-tooltip-background-color:var(--pico-contrast-background);--pico-tooltip-color:var(--pico-contrast-inverse);--pico-icon-valid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(42, 123, 111)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-invalid:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(150, 74, 80)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E");color-scheme:dark}[data-theme=dark] input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]){--pico-form-element-focus-color:var(--pico-primary-focus)}[data-theme=dark] details summary[role=button].contrast:not(.outline)::after{filter:brightness(0)}[data-theme=dark] [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before{filter:brightness(0)}[type=checkbox],[type=radio],[type=range],progress{accent-color:var(--pico-primary)}*,::after,::before{box-sizing:border-box;background-repeat:no-repeat}::after,::before{text-decoration:inherit;vertical-align:inherit}:where(:root){-webkit-tap-highlight-color:transparent;-webkit-text-size-adjust:100%;-moz-text-size-adjust:100%;text-size-adjust:100%;background-color:var(--pico-background-color);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:var(--pico-font-size);line-height:var(--pico-line-height);font-family:var(--pico-font-family);text-underline-offset:var(--pico-text-underline-offset);text-rendering:optimizeLegibility;overflow-wrap:break-word;-moz-tab-size:4;-o-tab-size:4;tab-size:4}body{width:100%;margin:0}main{display:block}body>footer,body>header,body>main{padding-block:var(--pico-block-spacing-vertical)}section{margin-bottom:var(--pico-block-spacing-vertical)}.container,.container-fluid{width:100%;margin-right:auto;margin-left:auto;padding-right:var(--pico-spacing);padding-left:var(--pico-spacing)}@media (min-width:576px){.container{max-width:510px;padding-right:0;padding-left:0}}@media (min-width:768px){.container{max-width:700px}}@media (min-width:1024px){.container{max-width:950px}}@media (min-width:1280px){.container{max-width:1200px}}@media (min-width:1536px){.container{max-width:1450px}}.grid{grid-column-gap:var(--pico-grid-column-gap);grid-row-gap:var(--pico-grid-row-gap);display:grid;grid-template-columns:1fr}@media (min-width:768px){.grid{grid-template-columns:repeat(auto-fit,minmax(0%,1fr))}}.grid>*{min-width:0}.overflow-auto{overflow:auto}b,strong{font-weight:bolder}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}address,blockquote,dl,ol,p,pre,table,ul{margin-top:0;margin-bottom:var(--pico-typography-spacing-vertical);color:var(--pico-color);font-style:normal;font-weight:var(--pico-font-weight)}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:var(--pico-typography-spacing-vertical);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:var(--pico-font-size);line-height:var(--pico-line-height);font-family:var(--pico-font-family)}h1{--pico-color:var(--pico-h1-color)}h2{--pico-color:var(--pico-h2-color)}h3{--pico-color:var(--pico-h3-color)}h4{--pico-color:var(--pico-h4-color)}h5{--pico-color:var(--pico-h5-color)}h6{--pico-color:var(--pico-h6-color)}:where(article,address,blockquote,dl,figure,form,ol,p,pre,table,ul)~:is(h1,h2,h3,h4,h5,h6){margin-top:var(--pico-typography-spacing-top)}p{margin-bottom:var(--pico-typography-spacing-vertical)}hgroup{margin-bottom:var(--pico-typography-spacing-vertical)}hgroup>*{margin-top:0;margin-bottom:0}hgroup>:not(:first-child):last-child{--pico-color:var(--pico-muted-color);--pico-font-weight:unset;font-size:1rem}:where(ol,ul) li{margin-bottom:calc(var(--pico-typography-spacing-vertical) * .25)}:where(dl,ol,ul) :where(dl,ol,ul){margin:0;margin-top:calc(var(--pico-typography-spacing-vertical) * .25)}ul li{list-style:square}mark{padding:.125rem .25rem;background-color:var(--pico-mark-background-color);color:var(--pico-mark-color);vertical-align:baseline}blockquote{display:block;margin:var(--pico-typography-spacing-vertical) 0;padding:var(--pico-spacing);border-right:none;border-left:.25rem solid var(--pico-blockquote-border-color);border-inline-start:0.25rem solid var(--pico-blockquote-border-color);border-inline-end:none}blockquote footer{margin-top:calc(var(--pico-typography-spacing-vertical) * .5);color:var(--pico-blockquote-footer-color)}abbr[title]{border-bottom:1px dotted;text-decoration:none;cursor:help}ins{color:var(--pico-ins-color);text-decoration:none}del{color:var(--pico-del-color)}::-moz-selection{background-color:var(--pico-text-selection-color)}::selection{background-color:var(--pico-text-selection-color)}:where(a:not([role=button])),[role=link]{--pico-color:var(--pico-primary);--pico-background-color:transparent;--pico-underline:var(--pico-primary-underline);outline:0;background-color:var(--pico-background-color);color:var(--pico-color);-webkit-text-decoration:var(--pico-text-decoration);text-decoration:var(--pico-text-decoration);text-decoration-color:var(--pico-underline);text-underline-offset:0.125em;transition:background-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition),-webkit-text-decoration var(--pico-transition);transition:background-color var(--pico-transition),color var(--pico-transition),text-decoration var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),color var(--pico-transition),text-decoration var(--pico-transition),box-shadow var(--pico-transition),-webkit-text-decoration var(--pico-transition)}:where(a:not([role=button])):is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-primary-hover);--pico-underline:var(--pico-primary-hover-underline);--pico-text-decoration:underline}:where(a:not([role=button])):focus-visible,[role=link]:focus-visible{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}:where(a:not([role=button])).secondary,[role=link].secondary{--pico-color:var(--pico-secondary);--pico-underline:var(--pico-secondary-underline)}:where(a:not([role=button])).secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link].secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-secondary-hover);--pico-underline:var(--pico-secondary-hover-underline)}:where(a:not([role=button])).contrast,[role=link].contrast{--pico-color:var(--pico-contrast);--pico-underline:var(--pico-contrast-underline)}:where(a:not([role=button])).contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[role=link].contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-contrast-hover);--pico-underline:var(--pico-contrast-hover-underline)}a[role=button]{display:inline-block}button{margin:0;overflow:visible;font-family:inherit;text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[role=button],[type=button],[type=file]::file-selector-button,[type=reset],[type=submit],button{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);--pico-color:var(--pico-primary-inverse);--pico-box-shadow:var(--pico-button-box-shadow, 0 0 0 rgba(0, 0, 0, 0));padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);border:var(--pico-border-width) solid var(--pico-border-color);border-radius:var(--pico-border-radius);outline:0;background-color:var(--pico-background-color);box-shadow:var(--pico-box-shadow);color:var(--pico-color);font-weight:var(--pico-font-weight);font-size:1rem;line-height:var(--pico-line-height);text-align:center;text-decoration:none;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}[role=button]:is(:hover,:active,:focus),[role=button]:is([aria-current]:not([aria-current=false])),[type=button]:is(:hover,:active,:focus),[type=button]:is([aria-current]:not([aria-current=false])),[type=file]::file-selector-button:is(:hover,:active,:focus),[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])),[type=reset]:is(:hover,:active,:focus),[type=reset]:is([aria-current]:not([aria-current=false])),[type=submit]:is(:hover,:active,:focus),[type=submit]:is([aria-current]:not([aria-current=false])),button:is(:hover,:active,:focus),button:is([aria-current]:not([aria-current=false])){--pico-background-color:var(--pico-primary-hover-background);--pico-border-color:var(--pico-primary-hover-border);--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0));--pico-color:var(--pico-primary-inverse)}[role=button]:focus,[role=button]:is([aria-current]:not([aria-current=false])):focus,[type=button]:focus,[type=button]:is([aria-current]:not([aria-current=false])):focus,[type=file]::file-selector-button:focus,[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])):focus,[type=reset]:focus,[type=reset]:is([aria-current]:not([aria-current=false])):focus,[type=submit]:focus,[type=submit]:is([aria-current]:not([aria-current=false])):focus,button:focus,button:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}[type=button],[type=reset],[type=submit]{margin-bottom:var(--pico-spacing)}:is(button,[type=submit],[type=button],[role=button]).secondary,[type=file]::file-selector-button,[type=reset]{--pico-background-color:var(--pico-secondary-background);--pico-border-color:var(--pico-secondary-border);--pico-color:var(--pico-secondary-inverse);cursor:pointer}:is(button,[type=submit],[type=button],[role=button]).secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=file]::file-selector-button:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:var(--pico-secondary-hover-background);--pico-border-color:var(--pico-secondary-hover-border);--pico-color:var(--pico-secondary-inverse)}:is(button,[type=submit],[type=button],[role=button]).secondary:focus,:is(button,[type=submit],[type=button],[role=button]).secondary:is([aria-current]:not([aria-current=false])):focus,[type=file]::file-selector-button:focus,[type=file]::file-selector-button:is([aria-current]:not([aria-current=false])):focus,[type=reset]:focus,[type=reset]:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}:is(button,[type=submit],[type=button],[role=button]).contrast{--pico-background-color:var(--pico-contrast-background);--pico-border-color:var(--pico-contrast-border);--pico-color:var(--pico-contrast-inverse)}:is(button,[type=submit],[type=button],[role=button]).contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:var(--pico-contrast-hover-background);--pico-border-color:var(--pico-contrast-hover-border);--pico-color:var(--pico-contrast-inverse)}:is(button,[type=submit],[type=button],[role=button]).contrast:focus,:is(button,[type=submit],[type=button],[role=button]).contrast:is([aria-current]:not([aria-current=false])):focus{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-contrast-focus)}:is(button,[type=submit],[type=button],[role=button]).outline,[type=reset].outline{--pico-background-color:transparent;--pico-color:var(--pico-primary);--pico-border-color:var(--pico-primary)}:is(button,[type=submit],[type=button],[role=button]).outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset].outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-background-color:transparent;--pico-color:var(--pico-primary-hover);--pico-border-color:var(--pico-primary-hover)}:is(button,[type=submit],[type=button],[role=button]).outline.secondary,[type=reset].outline{--pico-color:var(--pico-secondary);--pico-border-color:var(--pico-secondary)}:is(button,[type=submit],[type=button],[role=button]).outline.secondary:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),[type=reset].outline:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-secondary-hover);--pico-border-color:var(--pico-secondary-hover)}:is(button,[type=submit],[type=button],[role=button]).outline.contrast{--pico-color:var(--pico-contrast);--pico-border-color:var(--pico-contrast)}:is(button,[type=submit],[type=button],[role=button]).outline.contrast:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){--pico-color:var(--pico-contrast-hover);--pico-border-color:var(--pico-contrast-hover)}:where(button,[type=submit],[type=reset],[type=button],[role=button])[disabled],:where(fieldset[disabled]) :is(button,[type=submit],[type=button],[type=reset],[role=button]){opacity:.5;pointer-events:none}:where(table){width:100%;border-collapse:collapse;border-spacing:0;text-indent:0}td,th{padding:calc(var(--pico-spacing)/ 2) var(--pico-spacing);border-bottom:var(--pico-border-width) solid var(--pico-table-border-color);background-color:var(--pico-background-color);color:var(--pico-color);font-weight:var(--pico-font-weight);text-align:left;text-align:start}tfoot td,tfoot th{border-top:var(--pico-border-width) solid var(--pico-table-border-color);border-bottom:0}table.striped tbody tr:nth-child(odd) td,table.striped tbody tr:nth-child(odd) th{background-color:var(--pico-table-row-stripped-background-color)}:where(audio,canvas,iframe,img,svg,video){vertical-align:middle}audio,video{display:inline-block}audio:not([controls]){display:none;height:0}:where(iframe){border-style:none}img{max-width:100%;height:auto;border-style:none}:where(svg:not([fill])){fill:currentColor}svg:not(:root){overflow:hidden}code,kbd,pre,samp{font-size:.875em;font-family:var(--pico-font-family)}pre code{font-size:inherit;font-family:inherit}pre{-ms-overflow-style:scrollbar;overflow:auto}code,kbd,pre{border-radius:var(--pico-border-radius);background:var(--pico-code-background-color);color:var(--pico-code-color);font-weight:var(--pico-font-weight);line-height:initial}code,kbd{display:inline-block;padding:.375rem}pre{display:block;margin-bottom:var(--pico-spacing);overflow-x:auto}pre>code{display:block;padding:var(--pico-spacing);background:0 0;line-height:var(--pico-line-height)}kbd{background-color:var(--pico-code-kbd-background-color);color:var(--pico-code-kbd-color);vertical-align:baseline}figure{display:block;margin:0;padding:0}figure figcaption{padding:calc(var(--pico-spacing) * .5) 0;color:var(--pico-muted-color)}hr{height:0;margin:var(--pico-typography-spacing-vertical) 0;border:0;border-top:1px solid var(--pico-muted-border-color);color:inherit}[hidden],template{display:none!important}canvas{display:inline-block}input,optgroup,select,textarea{margin:0;font-size:1rem;line-height:var(--pico-line-height);font-family:inherit;letter-spacing:inherit}input{overflow:visible}select{text-transform:none}legend{max-width:100%;padding:0;color:inherit;white-space:normal}textarea{overflow:auto}[type=checkbox],[type=radio]{padding:0}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}::-moz-focus-inner{padding:0;border-style:none}:-moz-focusring{outline:0}:-moz-ui-invalid{box-shadow:none}::-ms-expand{display:none}[type=file],[type=range]{padding:0;border-width:0}input:not([type=checkbox],[type=radio],[type=range]){height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2)}fieldset{width:100%;margin:0;margin-bottom:var(--pico-spacing);padding:0;border:0}fieldset legend,label{display:block;margin-bottom:calc(var(--pico-spacing) * .375);color:var(--pico-color);font-weight:var(--pico-form-label-font-weight,var(--pico-font-weight))}fieldset legend{margin-bottom:calc(var(--pico-spacing) * .5)}button[type=submit],input:not([type=checkbox],[type=radio]),select,textarea{width:100%}input:not([type=checkbox],[type=radio],[type=range],[type=file]),select,textarea{-webkit-appearance:none;-moz-appearance:none;appearance:none;padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal)}input,select,textarea{--pico-background-color:var(--pico-form-element-background-color);--pico-border-color:var(--pico-form-element-border-color);--pico-color:var(--pico-form-element-color);--pico-box-shadow:none;border:var(--pico-border-width) solid var(--pico-border-color);border-radius:var(--pico-border-radius);outline:0;background-color:var(--pico-background-color);box-shadow:var(--pico-box-shadow);color:var(--pico-color);font-weight:var(--pico-font-weight);transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}:where(select,textarea):not([readonly]):is(:active,:focus),input:not([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[readonly]):is(:active,:focus){--pico-background-color:var(--pico-form-element-active-background-color)}:where(select,textarea):not([readonly]):is(:active,:focus),input:not([type=submit],[type=button],[type=reset],[role=switch],[readonly]):is(:active,:focus){--pico-border-color:var(--pico-form-element-active-border-color)}:where(select,textarea):not([readonly]):focus,input:not([type=submit],[type=button],[type=reset],[type=range],[type=file],[readonly]):focus{--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-focus-color)}:where(fieldset[disabled]) :is(input:not([type=submit],[type=button],[type=reset]),select,textarea),input:not([type=submit],[type=button],[type=reset])[disabled],label[aria-disabled=true],select[disabled],textarea[disabled]{opacity:var(--pico-form-element-disabled-opacity);pointer-events:none}label[aria-disabled=true] input[disabled]{opacity:1}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid]{padding-right:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem)!important;padding-left:var(--pico-form-element-spacing-horizontal);padding-inline-start:var(--pico-form-element-spacing-horizontal)!important;padding-inline-end:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem)!important;background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid=false]:not(select){background-image:var(--pico-icon-valid)}:where(input,select,textarea):not([type=checkbox],[type=radio],[type=date],[type=datetime-local],[type=month],[type=time],[type=week],[type=range])[aria-invalid=true]:not(select){background-image:var(--pico-icon-invalid)}:where(input,select,textarea)[aria-invalid=false]{--pico-border-color:var(--pico-form-element-valid-border-color)}:where(input,select,textarea)[aria-invalid=false]:is(:active,:focus){--pico-border-color:var(--pico-form-element-valid-active-border-color)!important}:where(input,select,textarea)[aria-invalid=false]:is(:active,:focus):not([type=checkbox],[type=radio]){--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-valid-focus-color)!important}:where(input,select,textarea)[aria-invalid=true]{--pico-border-color:var(--pico-form-element-invalid-border-color)}:where(input,select,textarea)[aria-invalid=true]:is(:active,:focus){--pico-border-color:var(--pico-form-element-invalid-active-border-color)!important}:where(input,select,textarea)[aria-invalid=true]:is(:active,:focus):not([type=checkbox],[type=radio]){--pico-box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-invalid-focus-color)!important}[dir=rtl] :where(input,select,textarea):not([type=checkbox],[type=radio]):is([aria-invalid],[aria-invalid=true],[aria-invalid=false]){background-position:center left .75rem}input::-webkit-input-placeholder,input::placeholder,select:invalid,textarea::-webkit-input-placeholder,textarea::placeholder{color:var(--pico-form-element-placeholder-color);opacity:1}input:not([type=checkbox],[type=radio]),select,textarea{margin-bottom:var(--pico-spacing)}select::-ms-expand{border:0;background-color:transparent}select:not([multiple],[size]){padding-right:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem);padding-left:var(--pico-form-element-spacing-horizontal);padding-inline-start:var(--pico-form-element-spacing-horizontal);padding-inline-end:calc(var(--pico-form-element-spacing-horizontal) + 1.5rem);background-image:var(--pico-icon-chevron);background-position:center right .75rem;background-size:1rem auto;background-repeat:no-repeat}select[multiple] option:checked{background:var(--pico-form-element-selected-background-color);color:var(--pico-form-element-color)}[dir=rtl] select:not([multiple],[size]){background-position:center left .75rem}textarea{display:block;resize:vertical}textarea[aria-invalid]{--pico-icon-height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2);background-position:top right .75rem!important;background-size:1rem var(--pico-icon-height)!important}:where(input,select,textarea,fieldset,.grid)+small{display:block;width:100%;margin-top:calc(var(--pico-spacing) * -.75);margin-bottom:var(--pico-spacing);color:var(--pico-muted-color)}:where(input,select,textarea,fieldset,.grid)[aria-invalid=false]+small{color:var(--pico-ins-color)}:where(input,select,textarea,fieldset,.grid)[aria-invalid=true]+small{color:var(--pico-del-color)}label>:where(input,select,textarea){margin-top:calc(var(--pico-spacing) * .25)}label:has([type=checkbox],[type=radio]){width:-moz-fit-content;width:fit-content;cursor:pointer}[type=checkbox],[type=radio]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:1.25em;height:1.25em;margin-top:-.125em;margin-inline-end:.5em;border-width:var(--pico-border-width);vertical-align:middle;cursor:pointer}[type=checkbox]::-ms-check,[type=radio]::-ms-check{display:none}[type=checkbox]:checked,[type=checkbox]:checked:active,[type=checkbox]:checked:focus,[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);background-image:var(--pico-icon-checkbox);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=checkbox]~label,[type=radio]~label{display:inline-block;margin-bottom:0;cursor:pointer}[type=checkbox]~label:not(:last-of-type),[type=radio]~label:not(:last-of-type){margin-inline-end:1em}[type=checkbox]:indeterminate{--pico-background-color:var(--pico-primary-background);--pico-border-color:var(--pico-primary-border);background-image:var(--pico-icon-minus);background-position:center;background-size:.75em auto;background-repeat:no-repeat}[type=radio]{border-radius:50%}[type=radio]:checked,[type=radio]:checked:active,[type=radio]:checked:focus{--pico-background-color:var(--pico-primary-inverse);border-width:.35em;background-image:none}[type=checkbox][role=switch]{--pico-background-color:var(--pico-switch-background-color);--pico-color:var(--pico-switch-color);width:2.25em;height:1.25em;border:var(--pico-border-width) solid var(--pico-border-color);border-radius:1.25em;background-color:var(--pico-background-color);line-height:1.25em}[type=checkbox][role=switch]:not([aria-invalid]){--pico-border-color:var(--pico-switch-background-color)}[type=checkbox][role=switch]:before{display:block;aspect-ratio:1;height:100%;border-radius:50%;background-color:var(--pico-color);box-shadow:var(--pico-switch-thumb-box-shadow);content:"";transition:margin .1s ease-in-out}[type=checkbox][role=switch]:focus{--pico-background-color:var(--pico-switch-background-color);--pico-border-color:var(--pico-switch-background-color)}[type=checkbox][role=switch]:checked{--pico-background-color:var(--pico-switch-checked-background-color);--pico-border-color:var(--pico-switch-checked-background-color);background-image:none}[type=checkbox][role=switch]:checked::before{margin-inline-start:calc(2.25em - 1.25em)}[type=checkbox][role=switch][disabled]{--pico-background-color:var(--pico-border-color)}[type=checkbox][aria-invalid=false]:checked,[type=checkbox][aria-invalid=false]:checked:active,[type=checkbox][aria-invalid=false]:checked:focus,[type=checkbox][role=switch][aria-invalid=false]:checked,[type=checkbox][role=switch][aria-invalid=false]:checked:active,[type=checkbox][role=switch][aria-invalid=false]:checked:focus{--pico-background-color:var(--pico-form-element-valid-border-color)}[type=checkbox]:checked:active[aria-invalid=true],[type=checkbox]:checked:focus[aria-invalid=true],[type=checkbox]:checked[aria-invalid=true],[type=checkbox][role=switch]:checked:active[aria-invalid=true],[type=checkbox][role=switch]:checked:focus[aria-invalid=true],[type=checkbox][role=switch]:checked[aria-invalid=true]{--pico-background-color:var(--pico-form-element-invalid-border-color)}[type=checkbox][aria-invalid=false]:checked,[type=checkbox][aria-invalid=false]:checked:active,[type=checkbox][aria-invalid=false]:checked:focus,[type=checkbox][role=switch][aria-invalid=false]:checked,[type=checkbox][role=switch][aria-invalid=false]:checked:active,[type=checkbox][role=switch][aria-invalid=false]:checked:focus,[type=radio][aria-invalid=false]:checked,[type=radio][aria-invalid=false]:checked:active,[type=radio][aria-invalid=false]:checked:focus{--pico-border-color:var(--pico-form-element-valid-border-color)}[type=checkbox]:checked:active[aria-invalid=true],[type=checkbox]:checked:focus[aria-invalid=true],[type=checkbox]:checked[aria-invalid=true],[type=checkbox][role=switch]:checked:active[aria-invalid=true],[type=checkbox][role=switch]:checked:focus[aria-invalid=true],[type=checkbox][role=switch]:checked[aria-invalid=true],[type=radio]:checked:active[aria-invalid=true],[type=radio]:checked:focus[aria-invalid=true],[type=radio]:checked[aria-invalid=true]{--pico-border-color:var(--pico-form-element-invalid-border-color)}[type=color]::-webkit-color-swatch-wrapper{padding:0}[type=color]::-moz-focus-inner{padding:0}[type=color]::-webkit-color-swatch{border:0;border-radius:calc(var(--pico-border-radius) * .5)}[type=color]::-moz-color-swatch{border:0;border-radius:calc(var(--pico-border-radius) * .5)}input:not([type=checkbox],[type=radio],[type=range],[type=file]):is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){--pico-icon-position:0.75rem;--pico-icon-width:1rem;padding-right:calc(var(--pico-icon-width) + var(--pico-icon-position));background-image:var(--pico-icon-date);background-position:center right var(--pico-icon-position);background-size:var(--pico-icon-width) auto;background-repeat:no-repeat}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=time]{background-image:var(--pico-icon-time)}[type=date]::-webkit-calendar-picker-indicator,[type=datetime-local]::-webkit-calendar-picker-indicator,[type=month]::-webkit-calendar-picker-indicator,[type=time]::-webkit-calendar-picker-indicator,[type=week]::-webkit-calendar-picker-indicator{width:var(--pico-icon-width);margin-right:calc(var(--pico-icon-width) * -1);margin-left:var(--pico-icon-position);opacity:0}@-moz-document url-prefix(){[type=date],[type=datetime-local],[type=month],[type=time],[type=week]{padding-right:var(--pico-form-element-spacing-horizontal)!important;background-image:none!important}}[dir=rtl] :is([type=date],[type=datetime-local],[type=month],[type=time],[type=week]){text-align:right}[type=file]{--pico-color:var(--pico-muted-color);margin-left:calc(var(--pico-outline-width) * -1);padding:calc(var(--pico-form-element-spacing-vertical) * .5) 0;padding-left:var(--pico-outline-width);border:0;border-radius:0;background:0 0}[type=file]::file-selector-button{margin-right:calc(var(--pico-spacing)/ 2);padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal)}[type=file]:is(:hover,:active,:focus)::file-selector-button{--pico-background-color:var(--pico-secondary-hover-background);--pico-border-color:var(--pico-secondary-hover-border)}[type=file]:focus::file-selector-button{--pico-box-shadow:var(--pico-button-hover-box-shadow, 0 0 0 rgba(0, 0, 0, 0)),0 0 0 var(--pico-outline-width) var(--pico-secondary-focus)}[type=range]{-webkit-appearance:none;-moz-appearance:none;appearance:none;width:100%;height:1.25rem;background:0 0}[type=range]::-webkit-slider-runnable-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-webkit-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-moz-range-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-moz-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-ms-track{width:100%;height:.375rem;border-radius:var(--pico-border-radius);background-color:var(--pico-range-border-color);-ms-transition:background-color var(--pico-transition),box-shadow var(--pico-transition);transition:background-color var(--pico-transition),box-shadow var(--pico-transition)}[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-webkit-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]::-moz-range-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-moz-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]::-ms-thumb{-webkit-appearance:none;width:1.25rem;height:1.25rem;margin-top:-.4375rem;border:2px solid var(--pico-range-thumb-border-color);border-radius:50%;background-color:var(--pico-range-thumb-color);cursor:pointer;-ms-transition:background-color var(--pico-transition),transform var(--pico-transition);transition:background-color var(--pico-transition),transform var(--pico-transition)}[type=range]:active,[type=range]:focus-within{--pico-range-border-color:var(--pico-range-active-border-color);--pico-range-thumb-color:var(--pico-range-thumb-active-color)}[type=range]:active::-webkit-slider-thumb{transform:scale(1.25)}[type=range]:active::-moz-range-thumb{transform:scale(1.25)}[type=range]:active::-ms-thumb{transform:scale(1.25)}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search]{padding-inline-start:calc(var(--pico-form-element-spacing-horizontal) + 1.75rem);background-image:var(--pico-icon-search);background-position:center left calc(var(--pico-form-element-spacing-horizontal) + .125rem);background-size:1rem auto;background-repeat:no-repeat}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid]{padding-inline-start:calc(var(--pico-form-element-spacing-horizontal) + 1.75rem)!important;background-position:center left 1.125rem,center right .75rem}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid=false]{background-image:var(--pico-icon-search),var(--pico-icon-valid)}input:not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid=true]{background-image:var(--pico-icon-search),var(--pico-icon-invalid)}[dir=rtl] :where(input):not([type=checkbox],[type=radio],[type=range],[type=file])[type=search]{background-position:center right 1.125rem}[dir=rtl] :where(input):not([type=checkbox],[type=radio],[type=range],[type=file])[type=search][aria-invalid]{background-position:center right 1.125rem,center left .75rem}details{display:block;margin-bottom:var(--pico-spacing)}details summary{line-height:1rem;list-style-type:none;cursor:pointer;transition:color var(--pico-transition)}details summary:not([role]){color:var(--pico-accordion-close-summary-color)}details summary::-webkit-details-marker{display:none}details summary::marker{display:none}details summary::-moz-list-bullet{list-style-type:none}details summary::after{display:block;width:1rem;height:1rem;margin-inline-start:calc(var(--pico-spacing,1rem) * .5);float:right;transform:rotate(-90deg);background-image:var(--pico-icon-chevron);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:"";transition:transform var(--pico-transition)}details summary:focus{outline:0}details summary:focus:not([role]){color:var(--pico-accordion-active-summary-color)}details summary:focus-visible:not([role]){outline:var(--pico-outline-width) solid var(--pico-primary-focus);outline-offset:calc(var(--pico-spacing,1rem) * 0.5);color:var(--pico-primary)}details summary[role=button]{width:100%;text-align:left}details summary[role=button]::after{height:calc(1rem * var(--pico-line-height,1.5))}details[open]>summary{margin-bottom:var(--pico-spacing)}details[open]>summary:not([role]):not(:focus){color:var(--pico-accordion-open-summary-color)}details[open]>summary::after{transform:rotate(0)}[dir=rtl] details summary{text-align:right}[dir=rtl] details summary::after{float:left;background-position:left center}article{margin-bottom:var(--pico-block-spacing-vertical);padding:var(--pico-block-spacing-vertical) var(--pico-block-spacing-horizontal);border-radius:var(--pico-border-radius);background:var(--pico-card-background-color);box-shadow:var(--pico-card-box-shadow)}article>footer,article>header{margin-right:calc(var(--pico-block-spacing-horizontal) * -1);margin-left:calc(var(--pico-block-spacing-horizontal) * -1);padding:calc(var(--pico-block-spacing-vertical) * .66) var(--pico-block-spacing-horizontal);background-color:var(--pico-card-sectioning-background-color)}article>header{margin-top:calc(var(--pico-block-spacing-vertical) * -1);margin-bottom:var(--pico-block-spacing-vertical);border-bottom:var(--pico-border-width) solid var(--pico-card-border-color);border-top-right-radius:var(--pico-border-radius);border-top-left-radius:var(--pico-border-radius)}article>footer{margin-top:var(--pico-block-spacing-vertical);margin-bottom:calc(var(--pico-block-spacing-vertical) * -1);border-top:var(--pico-border-width) solid var(--pico-card-border-color);border-bottom-right-radius:var(--pico-border-radius);border-bottom-left-radius:var(--pico-border-radius)}details.dropdown{position:relative;border-bottom:none}details.dropdown summary::after,details.dropdown>a::after,details.dropdown>button::after{display:block;width:1rem;height:calc(1rem * var(--pico-line-height,1.5));margin-inline-start:.25rem;float:right;transform:rotate(0) translateX(.2rem);background-image:var(--pico-icon-chevron);background-position:right center;background-size:1rem auto;background-repeat:no-repeat;content:""}nav details.dropdown{margin-bottom:0}details.dropdown summary:not([role]){height:calc(1rem * var(--pico-line-height) + var(--pico-form-element-spacing-vertical) * 2 + var(--pico-border-width) * 2);padding:var(--pico-form-element-spacing-vertical) var(--pico-form-element-spacing-horizontal);border:var(--pico-border-width) solid var(--pico-form-element-border-color);border-radius:var(--pico-border-radius);background-color:var(--pico-form-element-background-color);color:var(--pico-form-element-placeholder-color);line-height:inherit;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;transition:background-color var(--pico-transition),border-color var(--pico-transition),color var(--pico-transition),box-shadow var(--pico-transition)}details.dropdown summary:not([role]):active,details.dropdown summary:not([role]):focus{border-color:var(--pico-form-element-active-border-color);background-color:var(--pico-form-element-active-background-color)}details.dropdown summary:not([role]):focus{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-form-element-focus-color)}details.dropdown summary:not([role]):focus-visible{outline:0}details.dropdown summary:not([role])[aria-invalid=false]{--pico-form-element-border-color:var(--pico-form-element-valid-border-color);--pico-form-element-active-border-color:var(--pico-form-element-valid-focus-color);--pico-form-element-focus-color:var(--pico-form-element-valid-focus-color)}details.dropdown summary:not([role])[aria-invalid=true]{--pico-form-element-border-color:var(--pico-form-element-invalid-border-color);--pico-form-element-active-border-color:var(--pico-form-element-invalid-focus-color);--pico-form-element-focus-color:var(--pico-form-element-invalid-focus-color)}nav details.dropdown{display:inline;margin:calc(var(--pico-nav-element-spacing-vertical) * -1) 0}nav details.dropdown summary::after{transform:rotate(0) translateX(0)}nav details.dropdown summary:not([role]){height:calc(1rem * var(--pico-line-height) + var(--pico-nav-link-spacing-vertical) * 2);padding:calc(var(--pico-nav-link-spacing-vertical) - var(--pico-border-width) * 2) var(--pico-nav-link-spacing-horizontal)}nav details.dropdown summary:not([role]):focus-visible{box-shadow:0 0 0 var(--pico-outline-width) var(--pico-primary-focus)}details.dropdown summary+ul{display:flex;z-index:99;position:absolute;left:0;flex-direction:column;width:100%;min-width:-moz-fit-content;min-width:fit-content;margin:0;margin-top:var(--pico-outline-width);padding:0;border:var(--pico-border-width) solid var(--pico-dropdown-border-color);border-radius:var(--pico-border-radius);background-color:var(--pico-dropdown-background-color);box-shadow:var(--pico-dropdown-box-shadow);color:var(--pico-dropdown-color);white-space:nowrap;opacity:0;transition:opacity var(--pico-transition),transform 0s ease-in-out 1s}details.dropdown summary+ul[dir=rtl]{right:0;left:auto}details.dropdown summary+ul li{width:100%;margin-bottom:0;padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal);list-style:none}details.dropdown summary+ul li:first-of-type{margin-top:calc(var(--pico-form-element-spacing-vertical) * .5)}details.dropdown summary+ul li:last-of-type{margin-bottom:calc(var(--pico-form-element-spacing-vertical) * .5)}details.dropdown summary+ul li a{display:block;margin:calc(var(--pico-form-element-spacing-vertical) * -.5) calc(var(--pico-form-element-spacing-horizontal) * -1);padding:calc(var(--pico-form-element-spacing-vertical) * .5) var(--pico-form-element-spacing-horizontal);overflow:hidden;border-radius:0;color:var(--pico-dropdown-color);text-decoration:none;text-overflow:ellipsis}details.dropdown summary+ul li a:active,details.dropdown summary+ul li a:focus,details.dropdown summary+ul li a:focus-visible,details.dropdown summary+ul li a:hover,details.dropdown summary+ul li a[aria-current]:not([aria-current=false]){background-color:var(--pico-dropdown-hover-background-color)}details.dropdown summary+ul li label{width:100%}details.dropdown summary+ul li:has(label):hover{background-color:var(--pico-dropdown-hover-background-color)}details.dropdown[open] summary{margin-bottom:0}details.dropdown[open] summary+ul{transform:scaleY(1);opacity:1;transition:opacity var(--pico-transition),transform 0s ease-in-out 0s}details.dropdown[open] summary::before{display:block;z-index:1;position:fixed;width:100vw;height:100vh;inset:0;background:0 0;content:"";cursor:default}label>details.dropdown{margin-top:calc(var(--pico-spacing) * .25)}[role=group],[role=search]{display:inline-flex;position:relative;width:100%;margin-bottom:var(--pico-spacing);border-radius:var(--pico-border-radius);box-shadow:var(--pico-group-box-shadow,0 0 0 transparent);vertical-align:middle;transition:box-shadow var(--pico-transition)}[role=group] input:not([type=checkbox],[type=radio]),[role=group] select,[role=group]>*,[role=search] input:not([type=checkbox],[type=radio]),[role=search] select,[role=search]>*{position:relative;flex:1 1 auto;margin-bottom:0}[role=group] input:not([type=checkbox],[type=radio]):not(:first-child),[role=group] select:not(:first-child),[role=group]>:not(:first-child),[role=search] input:not([type=checkbox],[type=radio]):not(:first-child),[role=search] select:not(:first-child),[role=search]>:not(:first-child){margin-left:0;border-top-left-radius:0;border-bottom-left-radius:0}[role=group] input:not([type=checkbox],[type=radio]):not(:last-child),[role=group] select:not(:last-child),[role=group]>:not(:last-child),[role=search] input:not([type=checkbox],[type=radio]):not(:last-child),[role=search] select:not(:last-child),[role=search]>:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}[role=group] input:not([type=checkbox],[type=radio]):focus,[role=group] select:focus,[role=group]>:focus,[role=search] input:not([type=checkbox],[type=radio]):focus,[role=search] select:focus,[role=search]>:focus{z-index:2}[role=group] [role=button]:not(:first-child),[role=group] [type=button]:not(:first-child),[role=group] [type=reset]:not(:first-child),[role=group] [type=submit]:not(:first-child),[role=group] button:not(:first-child),[role=group] input:not([type=checkbox],[type=radio]):not(:first-child),[role=group] select:not(:first-child),[role=search] [role=button]:not(:first-child),[role=search] [type=button]:not(:first-child),[role=search] [type=reset]:not(:first-child),[role=search] [type=submit]:not(:first-child),[role=search] button:not(:first-child),[role=search] input:not([type=checkbox],[type=radio]):not(:first-child),[role=search] select:not(:first-child){margin-left:calc(var(--pico-border-width) * -1)}[role=group] [role=button],[role=group] [type=button],[role=group] [type=reset],[role=group] [type=submit],[role=group] button,[role=search] [role=button],[role=search] [type=button],[role=search] [type=reset],[role=search] [type=submit],[role=search] button{width:auto}@supports selector(:has(*)){[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus),[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus){--pico-group-box-shadow:var(--pico-group-box-shadow-focus-with-button)}[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) input:not([type=checkbox],[type=radio]),[role=group]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) select,[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) input:not([type=checkbox],[type=radio]),[role=search]:has(button:focus,[type=submit]:focus,[type=button]:focus,[role=button]:focus) select{border-color:transparent}[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus),[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus){--pico-group-box-shadow:var(--pico-group-box-shadow-focus-with-input)}[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [role=button],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=button],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=submit],[role=group]:has(input:not([type=submit],[type=button]):focus,select:focus) button,[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [role=button],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=button],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) [type=submit],[role=search]:has(input:not([type=submit],[type=button]):focus,select:focus) button{--pico-button-box-shadow:0 0 0 var(--pico-border-width) var(--pico-primary-border);--pico-button-hover-box-shadow:0 0 0 var(--pico-border-width) var(--pico-primary-hover-border)}[role=group] [role=button]:focus,[role=group] [type=button]:focus,[role=group] [type=reset]:focus,[role=group] [type=submit]:focus,[role=group] button:focus,[role=search] [role=button]:focus,[role=search] [type=button]:focus,[role=search] [type=reset]:focus,[role=search] [type=submit]:focus,[role=search] button:focus{box-shadow:none}}[role=search]>:first-child{border-top-left-radius:5rem;border-bottom-left-radius:5rem}[role=search]>:last-child{border-top-right-radius:5rem;border-bottom-right-radius:5rem}[aria-busy=true]:not(input,select,textarea,html){white-space:nowrap}[aria-busy=true]:not(input,select,textarea,html)::before{display:inline-block;width:1em;height:1em;background-image:var(--pico-icon-loading);background-size:1em auto;background-repeat:no-repeat;content:"";vertical-align:-.125em}[aria-busy=true]:not(input,select,textarea,html):not(:empty)::before{margin-inline-end:calc(var(--pico-spacing) * .5)}[aria-busy=true]:not(input,select,textarea,html):empty{text-align:center}[role=button][aria-busy=true],[type=button][aria-busy=true],[type=reset][aria-busy=true],[type=submit][aria-busy=true],a[aria-busy=true],button[aria-busy=true]{pointer-events:none}:root{--pico-scrollbar-width:0px}dialog{display:flex;z-index:999;position:fixed;top:0;right:0;bottom:0;left:0;align-items:center;justify-content:center;width:inherit;min-width:100%;height:inherit;min-height:100%;padding:0;border:0;-webkit-backdrop-filter:var(--pico-modal-overlay-backdrop-filter);backdrop-filter:var(--pico-modal-overlay-backdrop-filter);background-color:var(--pico-modal-overlay-background-color);color:var(--pico-color)}dialog article{width:100%;max-height:calc(100vh - var(--pico-spacing) * 2);margin:var(--pico-spacing);overflow:auto}@media (min-width:576px){dialog article{max-width:510px}}@media (min-width:768px){dialog article{max-width:700px}}dialog article>header>*{margin-bottom:0}dialog article>header .close,dialog article>header :is(a,button)[rel=prev]{margin:0;margin-left:var(--pico-spacing);padding:0;float:right}dialog article>footer{text-align:right}dialog article>footer [role=button],dialog article>footer button{margin-bottom:0}dialog article>footer [role=button]:not(:first-of-type),dialog article>footer button:not(:first-of-type){margin-left:calc(var(--pico-spacing) * .5)}dialog article .close,dialog article :is(a,button)[rel=prev]{display:block;width:1rem;height:1rem;margin-top:calc(var(--pico-spacing) * -1);margin-bottom:var(--pico-spacing);margin-left:auto;border:none;background-image:var(--pico-icon-close);background-position:center;background-size:auto 1rem;background-repeat:no-repeat;background-color:transparent;opacity:.5;transition:opacity var(--pico-transition)}dialog article .close:is([aria-current]:not([aria-current=false]),:hover,:active,:focus),dialog article :is(a,button)[rel=prev]:is([aria-current]:not([aria-current=false]),:hover,:active,:focus){opacity:1}dialog:not([open]),dialog[open=false]{display:none}.modal-is-open{padding-right:var(--pico-scrollbar-width,0);overflow:hidden;pointer-events:none;touch-action:none}.modal-is-open dialog{pointer-events:auto;touch-action:auto}:where(.modal-is-opening,.modal-is-closing) dialog,:where(.modal-is-opening,.modal-is-closing) dialog>article{animation-duration:.2s;animation-timing-function:ease-in-out;animation-fill-mode:both}:where(.modal-is-opening,.modal-is-closing) dialog{animation-duration:.8s;animation-name:modal-overlay}:where(.modal-is-opening,.modal-is-closing) dialog>article{animation-delay:.2s;animation-name:modal}.modal-is-closing dialog,.modal-is-closing dialog>article{animation-delay:0s;animation-direction:reverse}@keyframes modal-overlay{from{-webkit-backdrop-filter:none;backdrop-filter:none;background-color:transparent}}@keyframes modal{from{transform:translateY(-100%);opacity:0}}:where(nav li)::before{float:left;content:""}nav,nav ul{display:flex}nav{justify-content:space-between;overflow:visible}nav ol,nav ul{align-items:center;margin-bottom:0;padding:0;list-style:none}nav ol:first-of-type,nav ul:first-of-type{margin-left:calc(var(--pico-nav-element-spacing-horizontal) * -1)}nav ol:last-of-type,nav ul:last-of-type{margin-right:calc(var(--pico-nav-element-spacing-horizontal) * -1)}nav li{display:inline-block;margin:0;padding:var(--pico-nav-element-spacing-vertical) var(--pico-nav-element-spacing-horizontal)}nav li :where(a,[role=link]){display:inline-block;margin:calc(var(--pico-nav-link-spacing-vertical) * -1) calc(var(--pico-nav-link-spacing-horizontal) * -1);padding:var(--pico-nav-link-spacing-vertical) var(--pico-nav-link-spacing-horizontal);border-radius:var(--pico-border-radius)}nav li :where(a,[role=link]):not(:hover){text-decoration:none}nav li [role=button],nav li [type=button],nav li button,nav li input:not([type=checkbox],[type=radio],[type=range],[type=file]),nav li select{height:auto;margin-right:inherit;margin-bottom:0;margin-left:inherit;padding:calc(var(--pico-nav-link-spacing-vertical) - var(--pico-border-width) * 2) var(--pico-nav-link-spacing-horizontal)}nav[aria-label=breadcrumb]{align-items:center;justify-content:start}nav[aria-label=breadcrumb] ul li:not(:first-child){margin-inline-start:var(--pico-nav-link-spacing-horizontal)}nav[aria-label=breadcrumb] ul li a{margin:calc(var(--pico-nav-link-spacing-vertical) * -1) 0;margin-inline-start:calc(var(--pico-nav-link-spacing-horizontal) * -1)}nav[aria-label=breadcrumb] ul li:not(:last-child)::after{display:inline-block;position:absolute;width:calc(var(--pico-nav-link-spacing-horizontal) * 4);margin:0 calc(var(--pico-nav-link-spacing-horizontal) * -1);content:var(--pico-nav-breadcrumb-divider);color:var(--pico-muted-color);text-align:center;text-decoration:none;white-space:nowrap}nav[aria-label=breadcrumb] a[aria-current]:not([aria-current=false]){background-color:transparent;color:inherit;text-decoration:none;pointer-events:none}aside li,aside nav,aside ol,aside ul{display:block}aside li{padding:calc(var(--pico-nav-element-spacing-vertical) * .5) var(--pico-nav-element-spacing-horizontal)}aside li a{display:block}aside li [role=button]{margin:inherit}[dir=rtl] nav[aria-label=breadcrumb] ul li:not(:last-child) ::after{content:"\\"}progress{display:inline-block;vertical-align:baseline}progress{-webkit-appearance:none;-moz-appearance:none;display:inline-block;appearance:none;width:100%;height:.5rem;margin-bottom:calc(var(--pico-spacing) * .5);overflow:hidden;border:0;border-radius:var(--pico-border-radius);background-color:var(--pico-progress-background-color);color:var(--pico-progress-color)}progress::-webkit-progress-bar{border-radius:var(--pico-border-radius);background:0 0}progress[value]::-webkit-progress-value{background-color:var(--pico-progress-color);-webkit-transition:inline-size var(--pico-transition);transition:inline-size var(--pico-transition)}progress::-moz-progress-bar{background-color:var(--pico-progress-color)}@media (prefers-reduced-motion:no-preference){progress:indeterminate{background:var(--pico-progress-background-color) linear-gradient(to right,var(--pico-progress-color) 30%,var(--pico-progress-background-color) 30%) top left/150% 150% no-repeat;animation:progress-indeterminate 1s linear infinite}progress:indeterminate[value]::-webkit-progress-value{background-color:transparent}progress:indeterminate::-moz-progress-bar{background-color:transparent}}@media (prefers-reduced-motion:no-preference){[dir=rtl] progress:indeterminate{animation-direction:reverse}}@keyframes progress-indeterminate{0%{background-position:200% 0}100%{background-position:-200% 0}}[data-tooltip]{position:relative}[data-tooltip]:not(a,button,input){border-bottom:1px dotted;text-decoration:none;cursor:help}[data-tooltip]::after,[data-tooltip]::before,[data-tooltip][data-placement=top]::after,[data-tooltip][data-placement=top]::before{display:block;z-index:99;position:absolute;bottom:100%;left:50%;padding:.25rem .5rem;overflow:hidden;transform:translate(-50%,-.25rem);border-radius:var(--pico-border-radius);background:var(--pico-tooltip-background-color);content:attr(data-tooltip);color:var(--pico-tooltip-color);font-style:normal;font-weight:var(--pico-font-weight);font-size:.875rem;text-decoration:none;text-overflow:ellipsis;white-space:nowrap;opacity:0;pointer-events:none}[data-tooltip]::after,[data-tooltip][data-placement=top]::after{padding:0;transform:translate(-50%,0);border-top:.3rem solid;border-right:.3rem solid transparent;border-left:.3rem solid transparent;border-radius:0;background-color:transparent;content:"";color:var(--pico-tooltip-background-color)}[data-tooltip][data-placement=bottom]::after,[data-tooltip][data-placement=bottom]::before{top:100%;bottom:auto;transform:translate(-50%,.25rem)}[data-tooltip][data-placement=bottom]:after{transform:translate(-50%,-.3rem);border:.3rem solid transparent;border-bottom:.3rem solid}[data-tooltip][data-placement=left]::after,[data-tooltip][data-placement=left]::before{top:50%;right:100%;bottom:auto;left:auto;transform:translate(-.25rem,-50%)}[data-tooltip][data-placement=left]:after{transform:translate(.3rem,-50%);border:.3rem solid transparent;border-left:.3rem solid}[data-tooltip][data-placement=right]::after,[data-tooltip][data-placement=right]::before{top:50%;right:auto;bottom:auto;left:100%;transform:translate(.25rem,-50%)}[data-tooltip][data-placement=right]:after{transform:translate(-.3rem,-50%);border:.3rem solid transparent;border-right:.3rem solid}[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{opacity:1}@media (hover:hover) and (pointer:fine){[data-tooltip]:focus::after,[data-tooltip]:focus::before,[data-tooltip]:hover::after,[data-tooltip]:hover::before{--pico-tooltip-slide-to:translate(-50%, -0.25rem);transform:translate(-50%,.75rem);animation-duration:.2s;animation-fill-mode:forwards;animation-name:tooltip-slide;opacity:0}[data-tooltip]:focus::after,[data-tooltip]:hover::after{--pico-tooltip-caret-slide-to:translate(-50%, 0rem);transform:translate(-50%,-.25rem);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:focus::before,[data-tooltip][data-placement=bottom]:hover::after,[data-tooltip][data-placement=bottom]:hover::before{--pico-tooltip-slide-to:translate(-50%, 0.25rem);transform:translate(-50%,-.75rem);animation-name:tooltip-slide}[data-tooltip][data-placement=bottom]:focus::after,[data-tooltip][data-placement=bottom]:hover::after{--pico-tooltip-caret-slide-to:translate(-50%, -0.3rem);transform:translate(-50%,-.5rem);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=left]:focus::after,[data-tooltip][data-placement=left]:focus::before,[data-tooltip][data-placement=left]:hover::after,[data-tooltip][data-placement=left]:hover::before{--pico-tooltip-slide-to:translate(-0.25rem, -50%);transform:translate(.75rem,-50%);animation-name:tooltip-slide}[data-tooltip][data-placement=left]:focus::after,[data-tooltip][data-placement=left]:hover::after{--pico-tooltip-caret-slide-to:translate(0.3rem, -50%);transform:translate(.05rem,-50%);animation-name:tooltip-caret-slide}[data-tooltip][data-placement=right]:focus::after,[data-tooltip][data-placement=right]:focus::before,[data-tooltip][data-placement=right]:hover::after,[data-tooltip][data-placement=right]:hover::before{--pico-tooltip-slide-to:translate(0.25rem, -50%);transform:translate(-.75rem,-50%);animation-name:tooltip-slide}[data-tooltip][data-placement=right]:focus::after,[data-tooltip][data-placement=right]:hover::after{--pico-tooltip-caret-slide-to:translate(-0.3rem, -50%);transform:translate(-.05rem,-50%);animation-name:tooltip-caret-slide}}@keyframes tooltip-slide{to{transform:var(--pico-tooltip-slide-to);opacity:1}}@keyframes tooltip-caret-slide{50%{opacity:0}to{transform:var(--pico-tooltip-caret-slide-to);opacity:1}}[aria-controls]{cursor:pointer}[aria-disabled=true],[disabled]{cursor:not-allowed}[aria-hidden=false][hidden]{display:initial}[aria-hidden=false][hidden]:not(:focus){clip:rect(0,0,0,0);position:absolute}[tabindex],a,area,button,input,label,select,summary,textarea{-ms-touch-action:manipulation}[dir=rtl]{direction:rtl}@media (prefers-reduced-motion:reduce){:not([aria-busy=true]),:not([aria-busy=true])::after,:not([aria-busy=true])::before{background-attachment:initial!important;animation-duration:1ms!important;animation-delay:-1ms!important;animation-iteration-count:1!important;scroll-behavior:auto!important;transition-delay:0s!important;transition-duration:0s!important}}
\ No newline at end of file
diff --git a/core/auth/ui/templates/base.html b/core/auth/ui/templates/base.html
index 624f908..37326ec 100644
--- a/core/auth/ui/templates/base.html
+++ b/core/auth/ui/templates/base.html
@@ -1,16 +1,15 @@
<!DOCTYPE html>
-<html lang="en" data-theme="light">
+<html lang="en">
<head>
- <link rel="stylesheet" href="/static/pico.2.0.6.min.css">
- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/hack-font/3.3.0/web/hack.min.css">
- <link rel="stylesheet" href="/static/main.css?v=0.0.2">
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ block "title" . }}Title{{ end }}</title>
+ <link rel="stylesheet" href="/static/base.css?v=0.0.1" />
+ <link rel="stylesheet" href="/static/main.css?v=0.0.3" />
</head>
-<body>
- <main class="container">
- {{ block "main" . }}{{ end }}
- </main>
-</body>
+ <body>
+ <main class="container">
+ {{ block "main" . }}{{ end }}
+ </main>
+ </body>
</html>
diff --git a/core/auth/ui/templates/change-password-success.html b/core/auth/ui/templates/change-password-success.html
index 61c8e74..eb784ef 100644
--- a/core/auth/ui/templates/change-password-success.html
+++ b/core/auth/ui/templates/change-password-success.html
@@ -1,6 +1,8 @@
{{ define "title" }}dodo: password changed{{ end }}
{{ define "main" }}
-<div>
- <p>Password changed successfully.</p>
+<div class="terminal">
+ <h1>Password changed</h1>
+ <p role="status">Password changed successfully.</p>
+ <a href="/">Back to account</a>
</div>
{{ end }}
diff --git a/core/auth/ui/templates/change-password.html b/core/auth/ui/templates/change-password.html
index ca5ea0b..ff9b7a5 100644
--- a/core/auth/ui/templates/change-password.html
+++ b/core/auth/ui/templates/change-password.html
@@ -1,25 +1,37 @@
{{ define "title" }}dodo: change password{{ end }}
{{ define "main" }}
-<div class="form-container">
- <div class="logo">
+<div class="terminal">
+ <div class="logo" aria-hidden="true">
<span>do</span><span>do:</span>
</div>
- <form action="{{ .FormAction }}" method="POST">
- <label>
- new password
- <input type="password" name="password" aria-label="Password" value="{{ .Password }}" aria-invalid="{{ if .PasswordErrors }}true{{ else }}undefined{{ end }}" required/>
- </label>
- {{ if .PasswordErrors }}
- {{ range .PasswordErrors }}
- <small class="error-message" aria-live="assertive">
- {{ .Message }}
- </small>
+ <h1>Change password</h1>
+ <p>Signed in as <strong>{{ .Username }}</strong>.</p>
+ <form id="change-password-form" action="{{ .FormAction }}" method="POST">
+ {{ if or .GeneralError .PasswordErrors }}
+ <div role="alert">
+ {{ if .GeneralError }}<p>{{ .GeneralError }}</p>{{ end }}
+ {{ if .PasswordErrors }}
+ <p>Please correct the following errors:</p>
+ <ul>
+ {{ range .PasswordErrors }}<li>{{ .Message }}</li>{{ end }}
+ </ul>
+ {{ end }}
+ </div>
{{ end }}
+
+ <label for="change-password">New password</label>
+ <input id="change-password" type="password" name="password" autocomplete="new-password" required aria-describedby="change-password-policy change-password-symbols{{ range $index, $_ := .PasswordErrors }} change-password-error-{{ $index }}{{ end }}"{{ if .PasswordErrors }} aria-invalid="true"{{ end }} />
+ <p id="change-password-policy">Use at least 20 bytes, including an uppercase letter, lowercase letter, number, and an ASCII symbol or space.</p>
+ <p id="change-password-symbols">Accepted ASCII symbols: <code>!"#$%&'()*+,-./:;<=>?@[\]^_{|}~</code>. ASCII space is also accepted.</p>
+ {{ range $index, $error := .PasswordErrors }}
+ <p id="change-password-error-{{ $index }}" class="error-message">{{ $error.Message }}</p>
{{ end }}
- <button type="submit">change password</button>
- <input type="hidden" name="csrf_token" value="{{ .CSRFToken }}" />
- <input type="hidden" name="method" value="password" />
- <input type="hidden" name="username" value="{{ .Username }}" />
+
+ <input id="change-password-csrf-token" type="hidden" name="csrf_token" value="{{ .CSRFToken }}" />
+ <button id="change-password-submit" type="submit">Change password</button>
</form>
+ <nav aria-label="Account">
+ <a href="/">Back to account</a>
+ </nav>
</div>
{{ end }}
diff --git a/core/auth/ui/templates/consent.html b/core/auth/ui/templates/consent.html
deleted file mode 100644
index 703434b..0000000
--- a/core/auth/ui/templates/consent.html
+++ /dev/null
@@ -1,19 +0,0 @@
-{{ define "title" }}dodo: consent{{ end }}
-{{ define "main" }}
-<form action="" method="POST">
- {{ range . }}
- <label for="{{ . }}">
- <input type="checkbox" role="switch" id="{{ . }}" name="scope" value="{{ . }}" checked />{{ . }}
- </label>
- {{ end }}
- <button type="submit" name="allow">Allow</button>
- <button type="submit" name="reject" class="secondary outline">Reject</button>
-</form>
-<nav>
- <ul>
- <li>
- <a href="/logout">Log Out</a>
- </li>
- </ul>
-</nav>
-{{ end }}
diff --git a/core/auth/ui/templates/error.html b/core/auth/ui/templates/error.html
new file mode 100644
index 0000000..0975bda
--- /dev/null
+++ b/core/auth/ui/templates/error.html
@@ -0,0 +1,8 @@
+{{ define "title" }}dodo: {{ .Title }}{{ end }}
+{{ define "main" }}
+<div class="terminal">
+ <h1>{{ .Title }}</h1>
+ <p>{{ .Message }}</p>
+ <a href="{{ .RecoveryHref }}">{{ .RecoveryText }}</a>
+</div>
+{{ end }}
diff --git a/core/auth/ui/templates/login.html b/core/auth/ui/templates/login.html
index 0d961b5..a79b400 100644
--- a/core/auth/ui/templates/login.html
+++ b/core/auth/ui/templates/login.html
@@ -1,29 +1,27 @@
{{ define "title" }}dodo: sign in{{ end }}
{{ define "main" }}
-<div>
- <div class="logo">
+<div class="terminal">
+ <div class="logo" aria-hidden="true">
<span>do</span><span>do:</span>
</div>
- <form action="" method="POST">
- <label>
- username
- <input type="text" name="username" autofocus required />
- </label>
- <label>
- password
- <input type="password" name="password" required />
- </label>
- <input type="hidden" name="csrf_token" value="{{ .csrfToken }}" />
- <button class="subbmit-button" type="submit">login</button>
+ <h1>Sign in</h1>
+ <form id="login-form" action="{{ .FormAction }}" method="POST">
+ {{ if .GeneralNotice }}
+ <div role="alert">
+ <p>{{ .GeneralNotice }}</p>
+ </div>
+ {{ end }}
+ <label for="login-username">Username</label>
+ <input id="login-username" type="text" name="username" autocomplete="username" autocapitalize="none" spellcheck="false" required />
+ <label for="login-password">Password</label>
+ <input id="login-password" type="password" name="password" autocomplete="current-password" required />
+ <input id="login-csrf-token" type="hidden" name="csrf_token" value="{{ .CSRFToken }}" />
+ <button id="login-submit" class="subbmit-button" type="submit">Sign in</button>
</form>
- {{- if .enableRegistration -}}
- <nav>
- <ul>
- <li>
- <a href="/register">Create Account</a>
- </li>
- </ul>
+ {{- if .EnableRegistration }}
+ <nav aria-label="Registration">
+ <a href="/register">Create account</a>
</nav>
+ {{- end }}
</div>
-{{- end -}}
{{ end }}
diff --git a/core/auth/ui/templates/register.html b/core/auth/ui/templates/register.html
index 626dc52..54657e7 100644
--- a/core/auth/ui/templates/register.html
+++ b/core/auth/ui/templates/register.html
@@ -1,16 +1,43 @@
{{ define "title" }}dodo: create account{{ end }}
{{ define "main" }}
-<form action="" method="POST">
- <input type="text" name="username" placeholder="Username" autofocus required />
- <input type="password" name="password" placeholder="Password" required />
- <input type="hidden" name="csrf_token" value="{{ . }}" />
- <button type="submit">Create Account</button>
-</form>
-<nav>
- <ul>
- <li>
- <a href="/login">Sign In</a>
- </li>
- </ul>
-</nav>
+<div class="terminal">
+ <div class="logo" aria-hidden="true">
+ <span>do</span><span>do:</span>
+ </div>
+ <h1>Create account</h1>
+ <form id="register-form" action="{{ .FormAction }}" method="POST">
+ {{ if or .GeneralError .UsernameErrors .PasswordErrors }}
+ <div role="alert">
+ {{ if .GeneralError }}<p>{{ .GeneralError }}</p>{{ end }}
+ {{ if or .UsernameErrors .PasswordErrors }}
+ <p>Please correct the following errors:</p>
+ <ul>
+ {{ range .UsernameErrors }}<li>{{ .Message }}</li>{{ end }}
+ {{ range .PasswordErrors }}<li>{{ .Message }}</li>{{ end }}
+ </ul>
+ {{ end }}
+ </div>
+ {{ end }}
+
+ <label for="register-username">Username</label>
+ <input id="register-username" type="text" name="username" value="{{ .Username }}" autocomplete="username" autocapitalize="none" spellcheck="false" required{{ if .UsernameErrors }} aria-invalid="true" aria-describedby="{{ range $index, $_ := .UsernameErrors }}{{ if $index }} {{ end }}register-username-error-{{ $index }}{{ end }}"{{ end }} />
+ {{ range $index, $error := .UsernameErrors }}
+ <p id="register-username-error-{{ $index }}" class="error-message">{{ $error.Message }}</p>
+ {{ end }}
+
+ <label for="register-password">Password</label>
+ <input id="register-password" type="password" name="password" autocomplete="new-password" required aria-describedby="register-password-policy register-password-symbols{{ range $index, $_ := .PasswordErrors }} register-password-error-{{ $index }}{{ end }}"{{ if .PasswordErrors }} aria-invalid="true"{{ end }} />
+ <p id="register-password-policy">Use at least 20 bytes, including an uppercase letter, lowercase letter, number, and an ASCII symbol or space.</p>
+ <p id="register-password-symbols">Accepted ASCII symbols: <code>!"#$%&'()*+,-./:;<=>?@[\]^_{|}~</code>. ASCII space is also accepted.</p>
+ {{ range $index, $error := .PasswordErrors }}
+ <p id="register-password-error-{{ $index }}" class="error-message">{{ $error.Message }}</p>
+ {{ end }}
+
+ <input id="register-csrf-token" type="hidden" name="csrf_token" value="{{ .CSRFToken }}" />
+ <button id="register-submit" type="submit">Create account</button>
+ </form>
+ <nav aria-label="Sign in">
+ <a href="/login">Sign in</a>
+ </nav>
+</div>
{{ end }}
diff --git a/core/auth/ui/templates/whoami.html b/core/auth/ui/templates/whoami.html
index 01be060..d67d715 100644
--- a/core/auth/ui/templates/whoami.html
+++ b/core/auth/ui/templates/whoami.html
@@ -1,6 +1,13 @@
-{{ define "title" }}dodo: who am i{{ end }}
+{{ define "title" }}dodo: account{{ end }}
{{ define "main" }}
-Hello {{.}}!
-<a href="/settings" role="button">change password</a>
-<a href="/logout" role="button">logout</a>
+<div class="terminal">
+ <h1>Account</h1>
+ <p>Hello <strong>{{ .Username }}</strong>!</p>
+ <nav aria-label="Account actions">
+ <ul>
+ <li><a href="/settings">Change password</a></li>
+ <li><a href="/logout">Log out</a></li>
+ </ul>
+ </nav>
+</div>
{{ end }}
diff --git a/core/auth/ui/validation.go b/core/auth/ui/validation.go
new file mode 100644
index 0000000..2e861d5
--- /dev/null
+++ b/core/auth/ui/validation.go
@@ -0,0 +1,47 @@
+package main
+
+import (
+ "strings"
+ "unicode"
+)
+
+type ValidationError struct {
+ Field string `json:"field"`
+ Message string `json:"message"`
+}
+
+func validateUsername(username string) []ValidationError {
+ var errors []ValidationError
+ if len(username) < 3 {
+ errors = append(errors, ValidationError{"username", "Username must be at least 3 characters long."})
+ }
+ // TODO other validations
+ return errors
+}
+
+func validatePassword(password string) []ValidationError {
+ var errors []ValidationError
+ if len(password) < 20 {
+ errors = append(errors, ValidationError{"password", "Password must be at least 20 characters long."})
+ }
+ digit := false
+ lowerCase := false
+ upperCase := false
+ special := false
+ for _, c := range password {
+ if unicode.IsDigit(c) {
+ digit = true
+ } else if unicode.IsLower(c) {
+ lowerCase = true
+ } else if unicode.IsUpper(c) {
+ upperCase = true
+ } else if strings.Contains(" !\"#$%&'()*+,-./:;<=>?@[\\]^_{|}~", string(c)) {
+ special = true
+ }
+ }
+ if !digit || !lowerCase || !upperCase || !special {
+ errors = append(errors, ValidationError{"password", "Password must contain at least one digit, lower&upper case and special characters"})
+ }
+ // TODO other validations
+ return errors
+}
diff --git a/core/auth/ui/validation_test.go b/core/auth/ui/validation_test.go
new file mode 100644
index 0000000..437376b
--- /dev/null
+++ b/core/auth/ui/validation_test.go
@@ -0,0 +1,75 @@
+package main
+
+import (
+ "reflect"
+ "strings"
+ "testing"
+)
+
+const (
+ testUsernameLengthMessage = "Username must be at least 3 characters long."
+ testPasswordLengthMessage = "Password must be at least 20 characters long."
+ testPasswordCompositionMessage = "Password must contain at least one digit, lower&upper case and special characters"
+)
+
+func TestValidateUsernameBoundaries(t *testing.T) {
+ tests := []struct {
+ name string
+ username string
+ want []ValidationError
+ }{
+ {"empty", "", []ValidationError{{Field: "username", Message: testUsernameLengthMessage}}},
+ {"two ASCII bytes", "ab", []ValidationError{{Field: "username", Message: testUsernameLengthMessage}}},
+ {"three ASCII bytes", "abc", nil},
+ {"existing byte semantics", "é", []ValidationError{{Field: "username", Message: testUsernameLengthMessage}}},
+ {"four UTF-8 bytes", "éé", nil},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := validateUsername(tt.username); !reflect.DeepEqual(got, tt.want) {
+ t.Fatalf("validateUsername() = %#v, want %#v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestValidatePasswordLengthAndDeterministicOrdering(t *testing.T) {
+ if got := validatePassword("short"); !reflect.DeepEqual(got, []ValidationError{
+ {Field: "password", Message: testPasswordLengthMessage},
+ {Field: "password", Message: testPasswordCompositionMessage},
+ }) {
+ t.Fatalf("short password errors = %#v", got)
+ }
+ if got := validatePassword("Aa1!" + strings.Repeat("x", 15)); len(got) != 1 || got[0].Message != testPasswordLengthMessage {
+ t.Fatalf("19-byte password errors = %#v", got)
+ }
+ if got := validatePassword("Aa1!" + strings.Repeat("x", 16)); len(got) != 0 {
+ t.Fatalf("20-byte password errors = %#v", got)
+ }
+}
+
+func TestValidatePasswordUnicodeCategories(t *testing.T) {
+ valid := "Éé١ " + strings.Repeat("x", 16)
+ if got := validatePassword(valid); len(got) != 0 {
+ t.Fatalf("Unicode category password errors = %#v", got)
+ }
+}
+
+func TestValidatePasswordAcceptsEveryConfiguredASCIISpecial(t *testing.T) {
+ for _, special := range " !\"#$%&'()*+,-./:;<=>?@[\\]^_{|}~" {
+ t.Run(string(special), func(t *testing.T) {
+ password := "Aa1" + strings.Repeat("x", 16) + string(special)
+ if got := validatePassword(password); len(got) != 0 {
+ t.Fatalf("configured special %q rejected: %#v", special, got)
+ }
+ })
+ }
+}
+
+func TestValidatePasswordRejectsUnconfiguredPunctuation(t *testing.T) {
+ password := "Aa1" + strings.Repeat("x", 16) + "§"
+ got := validatePassword(password)
+ if !reflect.DeepEqual(got, []ValidationError{{Field: "password", Message: testPasswordCompositionMessage}}) {
+ t.Fatalf("unconfigured punctuation errors = %#v", got)
+ }
+}