From adec93e331f8b3fb3ccc4b9d6b534bf17581e9af Mon Sep 17 00:00:00 2001 From: hailey Date: Mon, 28 Sep 2026 23:52:50 +0000 Subject: [PATCH] Refresh account pages, speed up the account page, and fix 2FA sign-in from OAuth Account page - Redesigned account, sign-in, consent, and two-factor pages with a single light/dark stylesheet (drops Pico). - Sessions are grouped by app, most recently used first, with per-session and per-app sign out. Revoking uses the session's row ID instead of putting the access token in the page, and requires a same-origin POST. - "Add another account" works while signed in. Account page speed - Only live sessions are loaded: the query filtered on created_at < now+2y (every token ever issued), and the page then showed all of them anyway. - App names are looked up once per app, in parallel, with a 2s cap and a cache, using metadata only (no JWKS fetch). - Tokens past every lifetime or from an old session version are pruned hourly. - The client JWKS cache was never written; it now is. Sign-in and OAuth - A second factor is now its own step. After the password checks out, the pending account and OAuth return are kept in the session, so the code page asks only for the code and always returns to /oauth/authorize. - Viewing the consent page keeps the OAuth request alive during slow 2FA. - Signed-out consent POSTs return to sign-in with the request. - Reject redirects to the client with error=access_denied. - Accept only succeeds once; expired/used requests get a readable page. - Scopes are described in plain language on the consent page. --- oauth/client/manager.go | 7 + server/account_dashboard.go | 277 +++++ server/handle_account.go | 77 +- server/handle_account_revoke.go | 40 +- server/handle_account_signin.go | 413 ++++++-- server/handle_oauth_authorize.go | 124 ++- server/scope_descriptions.go | 116 +++ server/server.go | 32 +- server/signin_oauth_redirect_test.go | 443 ++++++++ server/static/pico.css | 4 - server/static/style.css | 946 +++++++++++++++++- server/templates/account.html | 181 +++- server/templates/alert.html | 4 - server/templates/authorize.html | 136 ++- server/templates/message.html | 17 + server/templates/partials.html | 31 + server/templates/signin.html | 112 ++- server/templates/signin_verify.html | 61 ++ server/templates/two_factor.html | 144 ++- server/templates/two_factor_backup_codes.html | 35 +- server/templates/two_factor_totp.html | 99 +- server/templates/two_factor_yubikey.html | 163 +-- server/templates_xss_test.go | 38 +- server/two_factor_strong_test.go | 8 +- 24 files changed, 2921 insertions(+), 587 deletions(-) create mode 100644 server/account_dashboard.go create mode 100644 server/scope_descriptions.go create mode 100644 server/signin_oauth_redirect_test.go delete mode 100644 server/static/pico.css delete mode 100644 server/templates/alert.html create mode 100644 server/templates/message.html create mode 100644 server/templates/partials.html create mode 100644 server/templates/signin_verify.html diff --git a/oauth/client/manager.go b/oauth/client/manager.go index e202356..f8b8fb9 100644 --- a/oauth/client/manager.go +++ b/oauth/client/manager.go @@ -91,6 +91,12 @@ func (cm *Manager) GetClient(ctx context.Context, clientId string) (*Client, err }, nil } +// GetMetadata returns a client's metadata without resolving its JWKS. Use it +// when only display details (name, URI, logo) are needed. +func (cm *Manager) GetMetadata(ctx context.Context, clientId string) (*Metadata, error) { + return cm.getClientMetadata(ctx, clientId) +} + func (cm *Manager) getClientMetadata(ctx context.Context, clientId string) (*Metadata, error) { if isLocalhostClientID(clientId) { return buildLocalhostVirtualMetadata(clientId) @@ -161,6 +167,7 @@ func (cm *Manager) getClientJwks(ctx context.Context, clientId, jwksUri string) } jwks = s + cm.jwksCache.Set(clientId, jwks, 5*time.Minute) } return jwks, nil diff --git a/server/account_dashboard.go b/server/account_dashboard.go new file mode 100644 index 0000000..6aa6003 --- /dev/null +++ b/server/account_dashboard.go @@ -0,0 +1,277 @@ +package server + +import ( + "context" + "net/url" + "sort" + "strings" + "sync" + "time" + + cache "github.com/go-pkgz/expirable-cache/v3" + "github.com/haileyok/cocoon/models" + "github.com/haileyok/cocoon/oauth/constants" + "github.com/haileyok/cocoon/oauth/provider" + "github.com/hako/durafmt" +) + +const ( + // clientLookupTimeout bounds how long the account page waits on client + // metadata. Apps that don't answer in time are shown by their hostname. + clientLookupTimeout = 2 * time.Second + + clientDisplayTTL = 30 * time.Minute + clientDisplayFailedTTL = 2 * time.Minute + + // maxOauthSessionLifetime and maxOauthRefreshLifetime are the longest + // lifetimes any client gets. Tokens older than these can never be used. + maxOauthSessionLifetime = constants.ConfidentialClientSessionLifetime + maxOauthRefreshLifetime = constants.ConfidentialClientRefreshLifetime + + oauthTokenCleanupInterval = time.Hour +) + +// accountSession is one signed-in OAuth session shown on the account page. +type accountSession struct { + ID uint + IP string + Confidential bool + CreatedAt time.Time + LastActive time.Time + SignedInAgo string + LastActiveAgo string + ExpiresIn string +} + +// accountApp groups the sessions a single OAuth client holds. +type accountApp struct { + ClientID string + Name string + Host string + URI string + Initial string + Sessions []accountSession + LastActive time.Time + LastActiveAgo string +} + +type clientDisplay struct { + Name string + URI string +} + +// oauthSessionDeadline is when a token stops being refreshable: the earlier +// of the absolute session lifetime and the sliding refresh window. +func oauthSessionDeadline(t provider.OauthToken) time.Time { + sessionLifetime := constants.PublicClientSessionLifetime + refreshLifetime := constants.PublicClientRefreshLifetime + if t.ClientAuth.Method != "none" { + sessionLifetime = constants.ConfidentialClientSessionLifetime + refreshLifetime = constants.ConfidentialClientRefreshLifetime + } + deadline := t.CreatedAt.Add(sessionLifetime) + if refresh := t.UpdatedAt.Add(refreshLifetime); refresh.Before(deadline) { + deadline = refresh + } + return deadline +} + +// liveOauthTokens returns the account's OAuth tokens that can still be +// refreshed. The query only reads rows that could possibly be live, so the +// cost tracks active sessions rather than every session ever issued. +func (s *Server) liveOauthTokens(ctx context.Context, did string, sessionVersion int64, now time.Time) ([]provider.OauthToken, error) { + var tokens []provider.OauthToken + if err := s.db.Raw(ctx, + "SELECT * FROM oauth_tokens WHERE sub = ? AND session_version = ? AND created_at > ? AND updated_at > ? ORDER BY updated_at DESC", + nil, did, sessionVersion, now.Add(-maxOauthSessionLifetime), now.Add(-maxOauthRefreshLifetime), + ).Scan(&tokens).Error; err != nil { + return nil, err + } + + live := tokens[:0] + for _, t := range tokens { + if now.Before(oauthSessionDeadline(t)) { + live = append(live, t) + } + } + return live, nil +} + +func clientHost(clientID string) string { + u, err := url.Parse(clientID) + if err != nil || u.Host == "" { + return clientID + } + return u.Hostname() +} + +func (s *Server) clientDisplayCache() cache.Cache[string, clientDisplay] { + s.clientDisplayOnce.Do(func() { + s.clientDisplays = cache.NewCache[string, clientDisplay]().WithLRU().WithMaxKeys(1000) + }) + return s.clientDisplays +} + +// describeClients resolves display details for each client ID at most once, +// in parallel, and never waits longer than clientLookupTimeout overall. +func (s *Server) describeClients(ctx context.Context, clientIDs []string) map[string]clientDisplay { + out := make(map[string]clientDisplay, len(clientIDs)) + c := s.clientDisplayCache() + + var missing []string + for _, id := range clientIDs { + if d, ok := c.Get(id); ok { + out[id] = d + } else { + missing = append(missing, id) + } + } + if len(missing) == 0 || s.oauthProvider == nil { + for _, id := range missing { + out[id] = clientDisplay{} + } + return out + } + + ctx, cancel := context.WithTimeout(ctx, clientLookupTimeout) + defer cancel() + + var mu sync.Mutex + var wg sync.WaitGroup + for _, id := range missing { + wg.Add(1) + go func(id string) { + defer wg.Done() + d := clientDisplay{} + ttl := clientDisplayFailedTTL + if md, err := s.oauthProvider.ClientManager.GetMetadata(ctx, id); err == nil { + d = clientDisplay{Name: strings.TrimSpace(md.ClientName), URI: md.ClientURI} + ttl = clientDisplayTTL + } + c.Set(id, d, ttl) + mu.Lock() + out[id] = d + mu.Unlock() + }(id) + } + wg.Wait() + return out +} + +func shortDuration(d time.Duration) string { + if d < time.Minute { + return "just now" + } + return durafmt.Parse(d).LimitFirstN(1).String() +} + +// safeClientURI only links to web pages, so a client can't smuggle a +// javascript: URL onto the account page. +func safeClientURI(raw string) string { + u, err := url.Parse(raw) + if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" { + return "" + } + return u.String() +} + +// loadAccountApps returns the account's live OAuth sessions grouped by app, +// most recently used first. +func (s *Server) loadAccountApps(ctx context.Context, repo *models.RepoActor, now time.Time) ([]accountApp, error) { + tokens, err := s.liveOauthTokens(ctx, repo.Repo.Did, repo.SessionVersion, now) + if err != nil { + return nil, err + } + + byClient := map[string]*accountApp{} + var order []string + for _, t := range tokens { + app, ok := byClient[t.ClientId] + if !ok { + app = &accountApp{ClientID: t.ClientId, Host: clientHost(t.ClientId)} + byClient[t.ClientId] = app + order = append(order, t.ClientId) + } + app.Sessions = append(app.Sessions, accountSession{ + ID: t.ID, + IP: t.Ip, + Confidential: t.ClientAuth.Method != "none", + CreatedAt: t.CreatedAt, + LastActive: t.UpdatedAt, + SignedInAgo: shortDuration(now.Sub(t.CreatedAt)), + LastActiveAgo: shortDuration(now.Sub(t.UpdatedAt)), + ExpiresIn: durafmt.Parse(oauthSessionDeadline(t).Sub(now)).LimitFirstN(1).String(), + }) + if t.UpdatedAt.After(app.LastActive) { + app.LastActive = t.UpdatedAt + } + } + + displays := s.describeClients(ctx, order) + apps := make([]accountApp, 0, len(order)) + for _, id := range order { + app := byClient[id] + d := displays[id] + app.Name = d.Name + if app.Name == "" { + app.Name = app.Host + } + app.URI = safeClientURI(d.URI) + for _, r := range app.Name { + app.Initial = strings.ToUpper(string(r)) + break + } + app.LastActiveAgo = shortDuration(now.Sub(app.LastActive)) + apps = append(apps, *app) + } + sort.SliceStable(apps, func(i, j int) bool { return apps[i].LastActive.After(apps[j].LastActive) }) + return apps, nil +} + +// pruneOauthTokens deletes OAuth tokens that can never be used again: +// past every lifetime, or issued before the account's current session +// version (a password reset or "sign out everywhere"). +func (s *Server) pruneOauthTokens(ctx context.Context, now time.Time) (int64, error) { + res := s.db.Exec(ctx, + "DELETE FROM oauth_tokens WHERE created_at < ? OR updated_at < ?", + nil, now.Add(-maxOauthSessionLifetime), now.Add(-maxOauthRefreshLifetime), + ) + if res.Error != nil { + return 0, res.Error + } + n := res.RowsAffected + + res = s.db.Exec(ctx, + "DELETE FROM oauth_tokens WHERE session_version < (SELECT r.session_version FROM repos r WHERE r.did = oauth_tokens.sub)", + nil, + ) + if res.Error != nil { + return n, res.Error + } + return n + res.RowsAffected, nil +} + +func (s *Server) oauthTokenCleanupRoutine(ctx context.Context) { + run := func() { + n, err := s.pruneOauthTokens(ctx, time.Now()) + if err != nil { + s.logger.Error("pruning expired oauth tokens", "error", err) + return + } + if n > 0 { + s.logger.Info("pruned expired oauth tokens", "count", n) + } + } + + run() + ticker := time.NewTicker(oauthTokenCleanupInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + run() + } + } +} diff --git a/server/handle_account.go b/server/handle_account.go index 8a26ef4..5c3153e 100644 --- a/server/handle_account.go +++ b/server/handle_account.go @@ -5,16 +5,13 @@ import ( "time" "github.com/haileyok/cocoon/internal/helpers" - "github.com/haileyok/cocoon/oauth" - "github.com/haileyok/cocoon/oauth/constants" - "github.com/haileyok/cocoon/oauth/provider" - "github.com/hako/durafmt" + "github.com/haileyok/cocoon/models" "github.com/labstack/echo/v4" ) func (s *Server) handleAccount(e echo.Context) error { ctx := e.Request().Context() - logger := s.logger.With("name", "handleAuth") + logger := s.logger.With("name", "handleAccount") repo, sess, accounts, err := s.getSessionRepoAndAccountsOrErr(e) if err != nil { @@ -24,64 +21,32 @@ func (s *Server) handleAccount(e echo.Context) error { return e.Redirect(303, "/account/signin") } - oldestPossibleSession := time.Now().Add(constants.ConfidentialClientSessionLifetime) - - var tokens []provider.OauthToken - if err := s.db.Raw(ctx, "SELECT * FROM oauth_tokens WHERE sub = ? AND created_at < ? ORDER BY created_at ASC", nil, repo.Repo.Did, oldestPossibleSession).Scan(&tokens).Error; err != nil { + apps, err := s.loadAccountApps(ctx, repo, time.Now()) + if err != nil { logger.Error("couldnt fetch oauth sessions for account", "did", repo.Repo.Did, "error", err) - sess.AddFlash("Unable to fetch sessions. See server logs for more details.", "error") - sess.Save(e.Request(), e.Response()) - return e.Render(200, "account.html", map[string]any{ - "Repo": repo, - "Tokens": []map[string]string{}, - "flashes": getFlashesFromSession(e, sess), - "Accounts": accounts, - "ActiveDid": repo.Repo.Did, - }) + sess.AddFlash("Unable to load your app sessions. See server logs for more details.", "error") + apps = nil } - var filtered []provider.OauthToken - for _, t := range tokens { - ageRes := oauth.GetSessionAgeFromToken(t) - if ageRes.SessionExpired { - continue - } - filtered = append(filtered, t) + sessionCount := 0 + for _, a := range apps { + sessionCount += len(a.Sessions) } - now := time.Now() - - tokenInfo := []map[string]string{} - for _, t := range tokens { - ageRes := oauth.GetSessionAgeFromToken(t) - maxTime := constants.PublicClientSessionLifetime - if t.ClientAuth.Method != "none" { - maxTime = constants.ConfidentialClientSessionLifetime - } - - var clientName string - metadata, err := s.oauthProvider.ClientManager.GetClient(ctx, t.ClientId) - if err != nil { - clientName = t.ClientId - } else { - clientName = metadata.Metadata.ClientName - } - - tokenInfo = append(tokenInfo, map[string]string{ - "ClientName": clientName, - "Age": durafmt.Parse(ageRes.SessionAge).LimitFirstN(2).String(), - "LastUpdated": durafmt.Parse(now.Sub(t.UpdatedAt)).LimitFirstN(2).String(), - "ExpiresIn": durafmt.Parse(now.Add(maxTime).Sub(now)).LimitFirstN(2).String(), - "Token": t.Token, - "Ip": t.Ip, - }) + var twoFactorMethods int64 + if err := s.db.Raw(ctx, "SELECT COUNT(*) FROM two_factor_credentials WHERE did = ?", nil, repo.Repo.Did).Scan(&twoFactorMethods).Error; err != nil { + logger.Error("counting two factor methods", "did", repo.Repo.Did, "error", err) } return e.Render(200, "account.html", map[string]any{ - "Repo": repo, - "Tokens": tokenInfo, - "flashes": getFlashesFromSession(e, sess), - "Accounts": accounts, - "ActiveDid": repo.Repo.Did, + "Repo": repo, + "Apps": apps, + "SessionCount": sessionCount, + "TwoFactorMethods": twoFactorMethods, + "EmailTwoFactor": repo.TwoFactorType != models.TwoFactorTypeNone, + "flashes": getFlashesFromSession(e, sess), + "Accounts": accounts, + "ActiveDid": repo.Repo.Did, + "Hostname": s.config.Hostname, }) } diff --git a/server/handle_account_revoke.go b/server/handle_account_revoke.go index c855306..a2634c1 100644 --- a/server/handle_account_revoke.go +++ b/server/handle_account_revoke.go @@ -2,23 +2,32 @@ package server import ( "errors" + "net/http" "github.com/haileyok/cocoon/internal/helpers" "github.com/labstack/echo/v4" + "gorm.io/gorm" ) +// AccountRevokeInput names either one session (ID) or every session held by +// one app (ClientID). type AccountRevokeInput struct { - Token string `form:"token"` + ID uint `form:"id"` + ClientID string `form:"client_id"` } func (s *Server) handleAccountRevoke(e echo.Context) error { ctx := e.Request().Context() - logger := s.logger.With("name", "handleAcocuntRevoke") + logger := s.logger.With("name", "handleAccountRevoke") + + if !isSameOriginRequest(e) { + return e.JSON(http.StatusForbidden, map[string]string{"error": "Forbidden"}) + } var req AccountRevokeInput if err := e.Bind(&req); err != nil { logger.Error("could not bind account revoke request", "error", err) - return helpers.ServerError(e, nil) + return helpers.InputError(e, nil) } repo, sess, err := s.getSessionRepoOrErr(e) @@ -29,14 +38,27 @@ func (s *Server) handleAccountRevoke(e echo.Context) error { return e.Redirect(303, "/account/signin") } - if err := s.db.Exec(ctx, "DELETE FROM oauth_tokens WHERE sub = ? AND token = ?", nil, repo.Repo.Did, req.Token).Error; err != nil { - logger.Error("couldnt delete oauth session for account", "did", repo.Repo.Did, "token", req.Token, "error", err) - sess.AddFlash("Unable to revoke session. See server logs for more details.", "error") - sess.Save(e.Request(), e.Response()) - return e.Redirect(303, "/account") + var r *gorm.DB + switch { + case req.ID != 0: + r = s.db.Exec(ctx, "DELETE FROM oauth_tokens WHERE sub = ? AND id = ?", nil, repo.Repo.Did, req.ID) + case req.ClientID != "": + r = s.db.Exec(ctx, "DELETE FROM oauth_tokens WHERE sub = ? AND client_id = ?", nil, repo.Repo.Did, req.ClientID) + default: + return helpers.InputError(e, nil) } + affected := r.RowsAffected - sess.AddFlash("Session successfully revoked!", "success") + if r.Error != nil { + logger.Error("couldnt delete oauth session for account", "did", repo.Repo.Did, "error", r.Error) + sess.AddFlash("Unable to sign that app out. See server logs for more details.", "error") + } else if affected == 0 { + sess.AddFlash("That session was already signed out.", "success") + } else if req.ClientID != "" { + sess.AddFlash("Signed the app out everywhere.", "success") + } else { + sess.AddFlash("Session signed out.", "success") + } sess.Save(e.Request(), e.Response()) return e.Redirect(303, "/account") } diff --git a/server/handle_account_signin.go b/server/handle_account_signin.go index 37b3307..1485b76 100644 --- a/server/handle_account_signin.go +++ b/server/handle_account_signin.go @@ -4,12 +4,16 @@ import ( "context" "errors" "fmt" + "net/url" "strings" + "time" "github.com/bluesky-social/indigo/atproto/syntax" "github.com/gorilla/sessions" "github.com/haileyok/cocoon/internal/helpers" "github.com/haileyok/cocoon/models" + "github.com/haileyok/cocoon/oauth" + "github.com/haileyok/cocoon/oauth/provider" "github.com/labstack/echo-contrib/session" "github.com/labstack/echo/v4" "golang.org/x/crypto/bcrypt" @@ -23,6 +27,95 @@ type OauthSigninInput struct { QueryParams string `form:"query_params"` } +type signinVerifyInput struct { + Token string `form:"token"` +} + +// Signing in with a second factor happens in two steps. Once the password +// checks out, the account and where to go afterwards are kept in the browser +// session, so the code page only asks for the code and the OAuth request +// can't be lost between the two steps. +const ( + pendingSigninDidKey = "signin_pending_did" + pendingSigninVersionKey = "signin_pending_version" + pendingSigninAtKey = "signin_pending_at" + pendingSigninReturnKey = "signin_pending_return" + + // pendingSigninLifetime matches how long an emailed code is valid. + pendingSigninLifetime = 10 * time.Minute +) + +type pendingSignin struct { + Did string + SessionVersion int64 + Return string // OAuth authorize query, empty for the account page +} + +func setPendingSignin(sess *sessions.Session, p pendingSignin, now time.Time) { + sess.Values[pendingSigninDidKey] = p.Did + sess.Values[pendingSigninVersionKey] = p.SessionVersion + sess.Values[pendingSigninAtKey] = now.Unix() + sess.Values[pendingSigninReturnKey] = p.Return +} + +func clearPendingSignin(sess *sessions.Session) { + delete(sess.Values, pendingSigninDidKey) + delete(sess.Values, pendingSigninVersionKey) + delete(sess.Values, pendingSigninAtKey) + delete(sess.Values, pendingSigninReturnKey) +} + +// getPendingSignin returns the password-verified sign-in waiting for a code. +// ok is false when there is none or it is too old; the OAuth return is still +// reported so the caller can send the user back to the start of that flow. +func getPendingSignin(sess *sessions.Session, now time.Time) (p pendingSignin, ok bool) { + if sess == nil { + return p, false + } + p.Did, _ = sess.Values[pendingSigninDidKey].(string) + p.SessionVersion, _ = sess.Values[pendingSigninVersionKey].(int64) + p.Return, _ = sess.Values[pendingSigninReturnKey].(string) + at, _ := sess.Values[pendingSigninAtKey].(int64) + if p.Did == "" || at == 0 { + return p, false + } + started := time.Unix(at, 0) + if now.Before(started.Add(-time.Minute)) || now.After(started.Add(pendingSigninLifetime)) { + return p, false + } + return p, true +} + +// oauthReturnQuery keeps a sign-in query only when it carries an OAuth +// request. Other query strings (like "?add=1" for adding an account) must not +// send the user to /oauth/authorize afterwards. +func oauthReturnQuery(query string) string { + query = strings.TrimPrefix(strings.TrimSpace(query), "?") + q, err := url.ParseQuery(query) + if err != nil || (q.Get("client_id") == "" && q.Get("request_uri") == "") { + return "" + } + return q.Encode() +} + +// signinReturnPath is where a finished sign-in goes: back into the OAuth +// flow when it started there, otherwise the account page. +func signinReturnPath(query string) string { + query = strings.TrimPrefix(strings.TrimSpace(query), "?") + if query == "" { + return "/account" + } + return "/oauth/authorize?" + query +} + +func signinPath(query string) string { + query = strings.TrimPrefix(strings.TrimSpace(query), "?") + if query == "" { + return "/account/signin" + } + return "/account/signin?" + query +} + var ErrSessionUnauthenticated = errors.New("session is unauthenticated") func (s *Server) getSessionRepoAndAccountsOrErr(e echo.Context) (*models.RepoActor, *sessions.Session, []models.RepoActor, error) { @@ -79,7 +172,40 @@ func getFlashesFromSession(e echo.Context, sess *sessions.Session) map[string]an } } +// oauthRequestAppName names the app behind an in-progress OAuth request, so +// the sign-in pages can say where the user is headed. It returns "" when the +// query isn't an OAuth request or the app can't be identified quickly. +func (s *Server) oauthRequestAppName(ctx context.Context, query string) (string, *provider.OauthAuthorizationRequest) { + q, err := url.ParseQuery(strings.TrimPrefix(query, "?")) + if err != nil { + return "", nil + } + var clientID string + var authReq *provider.OauthAuthorizationRequest + if reqURI := q.Get("request_uri"); reqURI != "" { + if id, err := oauth.DecodeRequestUri(reqURI); err == nil { + var r provider.OauthAuthorizationRequest + if err := s.db.Raw(ctx, "SELECT * FROM oauth_authorization_requests WHERE request_id = ?", nil, id).Scan(&r).Error; err == nil && r.RequestId != "" { + authReq = &r + clientID = r.ClientId + } + } + } + if clientID == "" { + clientID = q.Get("client_id") + } + if clientID == "" { + return "", authReq + } + d := s.describeClients(ctx, []string{clientID})[clientID] + if d.Name != "" { + return d.Name, authReq + } + return clientHost(clientID), authReq +} + func (s *Server) handleAccountSigninGet(e echo.Context) error { + ctx := e.Request().Context() repo, sess, accounts, err := s.getSessionRepoAndAccountsOrErr(e) if err != nil && !errors.Is(err, ErrSessionUnauthenticated) { return helpers.ServerError(e, nil) @@ -97,14 +223,56 @@ func (s *Server) handleAccountSigninGet(e echo.Context) error { activeDid = repo.Repo.Did } + queryParams := oauthReturnQuery(e.QueryParams().Encode()) + appName, authReq := s.oauthRequestAppName(ctx, queryParams) + username := e.QueryParam("login_hint") + if username == "" && authReq != nil && authReq.Parameters.LoginHint != nil { + username = *authReq.Parameters.LoginHint + } + return e.Render(200, "signin.html", map[string]any{ "flashes": getFlashesFromSession(e, sess), - "QueryParams": e.QueryParams().Encode(), + "QueryParams": queryParams, "Accounts": accounts, "ActiveDid": activeDid, + "AppName": appName, + "Username": username, + "Hostname": s.config.Hostname, }) } +func (s *Server) lookupSigninRepo(ctx context.Context, username string) (*models.RepoActor, error) { + username = strings.ToLower(strings.TrimSpace(username)) + var query string + if _, err := syntax.ParseDID(username); err == nil { + query = "SELECT r.*, a.* FROM repos r LEFT JOIN actors a ON r.did = a.did WHERE r.did = ?" + } else if _, err := syntax.ParseHandle(username); err == nil { + query = "SELECT r.*, a.* FROM actors a LEFT JOIN repos r ON a.did = r.did WHERE a.handle = ?" + } else { + query = "SELECT r.*, a.* FROM repos r LEFT JOIN actors a ON r.did = a.did WHERE r.email = ?" + } + var repo models.RepoActor + if err := s.db.Raw(ctx, query, nil, username).Scan(&repo).Error; err != nil { + return nil, err + } + if repo.Repo.Did == "" { + return nil, gorm.ErrRecordNotFound + } + return &repo, nil +} + +// finishSignin adds the account to the browser session and sends the user on. +func (s *Server) finishSignin(e echo.Context, sess *sessions.Session, repo *models.RepoActor, returnQuery string) error { + clearPendingSignin(sess) + s.applyAccountSessionOptions(sess, int(AccountSessionMaxAge.Seconds())) + setActiveSessionDid(sess, repo.Repo.Did) + sess.Values["version:"+repo.Repo.Did] = repo.SessionVersion + if err := sess.Save(e.Request(), e.Response()); err != nil { + return err + } + return e.Redirect(303, signinReturnPath(returnQuery)) +} + func (s *Server) handleAccountSigninPost(e echo.Context) error { ctx := e.Request().Context() logger := s.logger.With("name", "handleAccountSigninPost") @@ -114,96 +282,207 @@ func (s *Server) handleAccountSigninPost(e echo.Context) error { logger.Error("error binding sign in req", "error", err) return helpers.ServerError(e, nil) } + req.QueryParams = oauthReturnQuery(req.QueryParams) - sess, _ := session.Get(s.config.SessionCookieKey, e) - - req.Username = strings.ToLower(req.Username) - var idtype string - if _, err := syntax.ParseDID(req.Username); err == nil { - idtype = "did" - } else if _, err := syntax.ParseHandle(req.Username); err == nil { - idtype = "handle" - } else { - idtype = "email" + sess, err := session.Get(s.config.SessionCookieKey, e) + if err != nil { + return helpers.ServerError(e, nil) } + // A new password attempt replaces any sign-in that was waiting on a code. + clearPendingSignin(sess) - queryParams := "" - if req.QueryParams != "" { - queryParams = fmt.Sprintf("?%s", req.QueryParams) + back := func(msg string) error { + sess.AddFlash(msg, "error") + sess.Save(e.Request(), e.Response()) + return e.Redirect(303, signinPath(req.QueryParams)) } - // TODO: we should make this a helper since we do it for the base create_session as well - var repo models.RepoActor - var err error - switch idtype { - case "did": - err = s.db.Raw(ctx, "SELECT r.*, a.* FROM repos r LEFT JOIN actors a ON r.did = a.did WHERE r.did = ?", nil, req.Username).Scan(&repo).Error - case "handle": - err = s.db.Raw(ctx, "SELECT r.*, a.* FROM actors a LEFT JOIN repos r ON a.did = r.did WHERE a.handle = ?", nil, req.Username).Scan(&repo).Error - case "email": - err = s.db.Raw(ctx, "SELECT r.*, a.* FROM repos r LEFT JOIN actors a ON r.did = a.did WHERE r.email = ?", nil, req.Username).Scan(&repo).Error - } + repo, err := s.lookupSigninRepo(ctx, req.Username) if err != nil { - if err == gorm.ErrRecordNotFound { - sess.AddFlash("Handle or password is incorrect", "error") - } else { - sess.AddFlash("Something went wrong!", "error") + if errors.Is(err, gorm.ErrRecordNotFound) { + return back("Handle or password is incorrect.") } - sess.Save(e.Request(), e.Response()) - return e.Redirect(303, "/account/signin"+queryParams) + logger.Error("looking up account for sign in", "error", err) + return back("Something went wrong. Please try again.") } if err := bcrypt.CompareHashAndPassword([]byte(repo.Password), []byte(req.Password)); err != nil { - if err == bcrypt.ErrMismatchedHashAndPassword { - sess.AddFlash("Handle or password is incorrect", "error") - } else { - sess.AddFlash("Something went wrong!", "error") + if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) { + return back("Handle or password is incorrect.") } - sess.Save(e.Request(), e.Response()) - return e.Redirect(303, "/account/signin"+queryParams) + return back("Something went wrong. Please try again.") } - res, err := s.checkSecondFactor(ctx, &repo, req.AuthFactorToken) - if errors.Is(err, errYubiCloudUnavailable) { - sess.AddFlash("Couldn't check your YubiKey with Yubico right now. Try again shortly, or use another sign-in method.", "error") - sess.AddFlash("requires 2FA token", "tokenrequired") + res, err := s.checkSecondFactor(ctx, repo, req.AuthFactorToken) + pending := pendingSignin{Did: repo.Repo.Did, SessionVersion: repo.SessionVersion, Return: req.QueryParams} + toVerify := func(msg string) error { + setPendingSignin(sess, pending, time.Now()) + if msg != "" { + sess.AddFlash(msg, "error") + } sess.Save(e.Request(), e.Response()) - return e.Redirect(303, "/account/signin"+queryParams) + return e.Redirect(303, "/account/signin/verify") + } + if errors.Is(err, errYubiCloudUnavailable) { + return toVerify("Couldn't check your YubiKey with Yubico right now. Try again shortly, or use another sign-in method.") } if err != nil { logger.Error("checking second factor", "error", err) - sess.AddFlash("Something went wrong!", "error") - sess.Save(e.Request(), e.Response()) - return e.Redirect(303, "/account/signin"+queryParams) - } - if res != secondFactorOK { - switch res { - case secondFactorInvalid: - sess.AddFlash("That code is incorrect.", "error") - case secondFactorExpired: - sess.AddFlash("That code has expired. Sign in again to get a new one.", "error") - case secondFactorLocked: - sess.AddFlash("Too many incorrect codes. Try again in a few minutes.", "error") + return back("Something went wrong. Please try again.") + } + + switch res { + case secondFactorOK: + return s.finishSignin(e, sess, repo, req.QueryParams) + case secondFactorRequired: + return toVerify("") + case secondFactorInvalid: + return toVerify("That code is incorrect.") + case secondFactorLocked: + return back("Too many incorrect codes. Try again in a few minutes.") + default: // secondFactorExpired: only emailed codes expire + if err := s.createAndSendTwoFactorCode(ctx, *repo); err != nil { + logger.Error("sending two factor code", "error", err) + return back("Something went wrong. Please try again.") } - if res != secondFactorExpired { - sess.AddFlash("requires 2FA token", "tokenrequired") + return toVerify("That code has expired. We've emailed you a new one.") + } +} + +func (s *Server) hasStrongSecondFactor(ctx context.Context, did string) (bool, error) { + var n int64 + if err := s.db.Raw(ctx, "SELECT COUNT(*) FROM two_factor_credentials WHERE did = ?", nil, did).Scan(&n).Error; err != nil { + return false, err + } + return n > 0, nil +} + +func (s *Server) signinVerifyState(e echo.Context) (*sessions.Session, *models.RepoActor, pendingSignin, error) { + sess, err := session.Get(s.config.SessionCookieKey, e) + if err != nil { + return nil, nil, pendingSignin{}, err + } + p, ok := getPendingSignin(sess, time.Now()) + if !ok { + return sess, nil, p, nil + } + repo, err := s.getRepoActorByDid(e.Request().Context(), p.Did) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return sess, nil, p, nil } - sess.Save(e.Request(), e.Response()) - return e.Redirect(303, "/account/signin"+queryParams) + return sess, nil, p, err + } + // A password reset or "sign out everywhere" since the password step + // cancels it. + if repo.SessionVersion != p.SessionVersion { + return sess, nil, p, nil } + return sess, repo, p, nil +} - s.applyAccountSessionOptions(sess, int(AccountSessionMaxAge.Seconds())) +// restartSignin sends the user back to the password step, keeping the OAuth +// request they came with. +func (s *Server) restartSignin(e echo.Context, sess *sessions.Session, p pendingSignin, msg string) error { + clearPendingSignin(sess) + if msg != "" { + sess.AddFlash(msg, "error") + } + sess.Save(e.Request(), e.Response()) + return e.Redirect(303, signinPath(p.Return)) +} - setActiveSessionDid(sess, repo.Repo.Did) - sess.Values["version:"+repo.Repo.Did] = repo.SessionVersion +func (s *Server) handleAccountSigninVerifyGet(e echo.Context) error { + ctx := e.Request().Context() + sess, repo, p, err := s.signinVerifyState(e) + if err != nil { + s.logger.Error("loading pending sign in", "error", err) + return helpers.ServerError(e, nil) + } + if repo == nil { + return s.restartSignin(e, sess, p, "Your sign-in timed out. Please enter your password again.") + } - if err := sess.Save(e.Request(), e.Response()); err != nil { - return err + creds, err := s.getTwoFactorCredentials(ctx, repo.Repo.Did) + if err != nil { + s.logger.Error("loading two factor methods", "error", err) + return helpers.ServerError(e, nil) + } + hasTOTP, hasYubiKey := false, false + for _, c := range creds { + switch c.Type { + case models.TwoFactorCredentialTOTP: + hasTOTP = true + case models.TwoFactorCredentialYubicoOTP: + hasYubiKey = true + } } - if queryParams != "" { - return e.Redirect(303, "/oauth/authorize"+queryParams) - } else { - return e.Redirect(303, "/account") + appName, _ := s.oauthRequestAppName(ctx, p.Return) + return e.Render(200, "signin_verify.html", map[string]any{ + "flashes": getFlashesFromSession(e, sess), + "Handle": repo.Handle, + "EmailCode": len(creds) == 0, + "HasTOTP": hasTOTP, + "HasYubiKey": hasYubiKey, + "AppName": appName, + "RestartURL": signinPath(p.Return), + "Hostname": s.config.Hostname, + }) +} + +func (s *Server) handleAccountSigninVerifyPost(e echo.Context) error { + ctx := e.Request().Context() + logger := s.logger.With("name", "handleAccountSigninVerifyPost") + + var req signinVerifyInput + if err := e.Bind(&req); err != nil { + return helpers.InputError(e, nil) + } + + sess, repo, p, err := s.signinVerifyState(e) + if err != nil { + logger.Error("loading pending sign in", "error", err) + return helpers.ServerError(e, nil) + } + if repo == nil { + return s.restartSignin(e, sess, p, "Your sign-in timed out. Please enter your password again.") + } + + again := func(msg, kind string) error { + sess.AddFlash(msg, kind) + sess.Save(e.Request(), e.Response()) + return e.Redirect(303, "/account/signin/verify") + } + + res, err := s.checkSecondFactor(ctx, repo, req.Token) + if errors.Is(err, errYubiCloudUnavailable) { + return again("Couldn't check your YubiKey with Yubico right now. Try again shortly, or use another sign-in method.", "error") + } + if err != nil { + logger.Error("checking second factor", "error", err) + return again("Something went wrong. Please try again.", "error") + } + + switch res { + case secondFactorOK: + return s.finishSignin(e, sess, repo, p.Return) + case secondFactorRequired: + // With emailed codes, checkSecondFactor has just sent a fresh one + // (the page's "Send a new code" button submits an empty code). + if has, err := s.hasStrongSecondFactor(ctx, repo.Repo.Did); err == nil && has { + return again("Enter a code to continue.", "error") + } + return again("We've emailed you a new code.", "success") + case secondFactorInvalid: + return again("That code is incorrect.", "error") + case secondFactorLocked: + return s.restartSignin(e, sess, p, "Too many incorrect codes. Try again in a few minutes.") + default: // secondFactorExpired: only emailed codes expire + if err := s.createAndSendTwoFactorCode(ctx, *repo); err != nil { + logger.Error("sending two factor code", "error", err) + return again("Something went wrong. Please try again.", "error") + } + return again("That code has expired. We've emailed you a new one.", "error") } } diff --git a/server/handle_oauth_authorize.go b/server/handle_oauth_authorize.go index a7313ee..425c599 100644 --- a/server/handle_oauth_authorize.go +++ b/server/handle_oauth_authorize.go @@ -52,6 +52,7 @@ func (s *Server) handleOauthAuthorizeGet(e echo.Context) error { if s.config.Version == "dev" && parRequest.ClientID == "" { return e.Render(200, "authorize.html", map[string]any{ "Scopes": []string{"atproto", "transition:generic"}, + "Permissions": describeScopes("atproto transition:generic"), "AppName": "DEV MODE AUTHORIZATION PAGE", "Handle": "paula.cocoon.social", "RequestUri": "", @@ -157,23 +158,68 @@ func (s *Server) handleOauthAuthorizeGet(e echo.Context) error { return e.Redirect(303, "/account/signin?"+e.QueryParams().Encode()) } - scopes := strings.Split(req.Parameters.Scope, " ") - appName := client.Metadata.ClientName + appName := strings.TrimSpace(client.Metadata.ClientName) + if appName == "" { + appName = clientHost(client.Metadata.ClientID) + } + + if req.Sub != nil || req.Code != nil { + return s.renderMessage(e, 400, "Already signed in", "This sign-in request has already been used. Go back to "+appName+" and start again.") + } + now := time.Now() + if now.After(req.ExpiresAt) { + return s.renderMessage(e, 400, "Sign-in request expired", "This sign-in request timed out. Go back to "+appName+" and start again.") + } + // The request stays valid while someone is working through sign-in and + // consent, however long a second factor takes. + if err := s.db.Exec(ctx, "UPDATE oauth_authorization_requests SET expires_at = ? WHERE request_id = ? AND sub IS NULL", nil, now.Add(constants.ParExpiresIn), reqId).Error; err != nil { + logger.Error("extending authorization request", "error", err) + } data := map[string]any{ - "Scopes": scopes, + "Scopes": strings.Fields(req.Parameters.Scope), + "Permissions": describeScopes(req.Parameters.Scope), "AppName": appName, + "AppHost": clientHost(client.Metadata.ClientID), + "AppURI": safeClientURI(client.Metadata.ClientURI), "RequestUri": input.RequestUri, "QueryParams": e.QueryParams().Encode(), "Handle": repo.Actor.Handle, "Accounts": accounts, "ActiveDid": repo.Repo.Did, "HasLoginHint": hasLoginHint, + "Hostname": s.config.Hostname, } return e.Render(200, "authorize.html", data) } +// authorizeRedirectURL builds the redirect back to the client, using the +// response mode the client asked for. +func (s *Server) authorizeRedirectURL(authReq *provider.OauthAuthorizationRequest, q url.Values) string { + q.Set("state", authReq.Parameters.State) + q.Set("iss", "https://"+s.config.Hostname) + + hashOrQuestion := "?" + if authReq.Parameters.ResponseMode != nil { + switch *authReq.Parameters.ResponseMode { + case "fragment": + hashOrQuestion = "#" + case "query": + default: + if authReq.Parameters.ResponseType != "code" { + hashOrQuestion = "#" + } + } + } else if authReq.Parameters.ResponseType != "code" { + hashOrQuestion = "#" + } + if hashOrQuestion == "?" && strings.Contains(authReq.Parameters.RedirectURI, "?") { + hashOrQuestion = "&" + } + return authReq.Parameters.RedirectURI + hashOrQuestion + q.Encode() +} + type OauthAuthorizePostRequest struct { RequestUri string `form:"request_uri"` AcceptOrRejct string `form:"accept_or_reject"` @@ -183,14 +229,6 @@ func (s *Server) handleOauthAuthorizePost(e echo.Context) error { ctx := e.Request().Context() logger := s.logger.With("name", "handleOauthAuthorizePost") - repo, _, err := s.getSessionRepoOrErr(e) - if err != nil { - if !errors.Is(err, ErrSessionUnauthenticated) { - return helpers.ServerError(e, to.StringPtr(err.Error())) - } - return e.Redirect(303, "/account/signin") - } - var req OauthAuthorizePostRequest if err := e.Bind(&req); err != nil { logger.Error("error binding authorize post request", "error", err) @@ -206,55 +244,51 @@ func (s *Server) handleOauthAuthorizePost(e echo.Context) error { if err := s.db.Raw(ctx, "SELECT * FROM oauth_authorization_requests WHERE request_id = ?", nil, reqId).Scan(&authReq).Error; err != nil { return helpers.ServerError(e, to.StringPtr(err.Error())) } + if authReq.RequestId == "" { + return s.renderMessage(e, 400, "Sign-in request not found", "This sign-in request doesn't exist or has already been used. Go back to the app and start again.") + } - client, err := s.oauthProvider.ClientManager.GetClient(e.Request().Context(), authReq.ClientId) + repo, _, err := s.getSessionRepoOrErr(e) if err != nil { - return helpers.ServerError(e, to.StringPtr(err.Error())) + if !errors.Is(err, ErrSessionUnauthenticated) { + return helpers.ServerError(e, to.StringPtr(err.Error())) + } + // Keep the OAuth request so signing in again returns to it. + q := url.Values{"client_id": {authReq.ClientId}, "request_uri": {req.RequestUri}} + return e.Redirect(303, "/account/signin?"+q.Encode()) } - // TODO: figure out how im supposed to actually redirect - if req.AcceptOrRejct == "reject" { - return e.Redirect(303, client.Metadata.ClientURI) + if authReq.Sub != nil || authReq.Code != nil { + return s.renderMessage(e, 400, "Already signed in", "This sign-in request has already been used. Go back to the app and start again.") } - if time.Now().After(authReq.ExpiresAt) { - return helpers.InputError(e, to.StringPtr("the request has expired")) + if req.AcceptOrRejct == "reject" { + if err := s.db.Exec(ctx, "DELETE FROM oauth_authorization_requests WHERE request_id = ?", nil, reqId).Error; err != nil { + logger.Error("error deleting rejected authorization request", "error", err) + } + q := url.Values{} + q.Set("error", "access_denied") + q.Set("error_description", "The user denied the request") + return e.Redirect(303, s.authorizeRedirectURL(&authReq, q)) } - if authReq.Sub != nil || authReq.Code != nil { - return helpers.InputError(e, to.StringPtr("this request was already authorized")) + if time.Now().After(authReq.ExpiresAt) { + return s.renderMessage(e, 400, "Sign-in request expired", "This sign-in request timed out. Go back to the app and start again.") } code := oauth.GenerateCode() - if err := s.db.Exec(ctx, "UPDATE oauth_authorization_requests SET sub = ?, session_version = ?, code = ?, accepted = ?, ip = ? WHERE request_id = ?", nil, repo.Repo.Did, repo.SessionVersion, code, true, e.RealIP(), reqId).Error; err != nil { - logger.Error("error updating authorization request", "error", err) + // Only the first accept wins, even if the form is submitted twice. + res := s.db.Exec(ctx, "UPDATE oauth_authorization_requests SET sub = ?, session_version = ?, code = ?, accepted = ?, ip = ? WHERE request_id = ? AND sub IS NULL AND code IS NULL", nil, repo.Repo.Did, repo.SessionVersion, code, true, e.RealIP(), reqId) + if res.Error != nil { + logger.Error("error updating authorization request", "error", res.Error) return helpers.ServerError(e, nil) } + if res.RowsAffected == 0 { + return s.renderMessage(e, 400, "Already signed in", "This sign-in request has already been used. Go back to the app and start again.") + } q := url.Values{} - q.Set("state", authReq.Parameters.State) - q.Set("iss", "https://"+s.config.Hostname) q.Set("code", code) - - hashOrQuestion := "?" - if authReq.Parameters.ResponseMode != nil { - switch *authReq.Parameters.ResponseMode { - case "fragment": - hashOrQuestion = "#" - case "query": - // do nothing - break - default: - if authReq.Parameters.ResponseType != "code" { - hashOrQuestion = "#" - } - } - } else { - if authReq.Parameters.ResponseType != "code" { - hashOrQuestion = "#" - } - } - - return e.Redirect(303, authReq.Parameters.RedirectURI+hashOrQuestion+q.Encode()) + return e.Redirect(303, s.authorizeRedirectURL(&authReq, q)) } diff --git a/server/scope_descriptions.go b/server/scope_descriptions.go new file mode 100644 index 0000000..01ac468 --- /dev/null +++ b/server/scope_descriptions.go @@ -0,0 +1,116 @@ +package server + +import ( + "net/http" + "strings" + + "github.com/haileyok/cocoon/oauth/scopes" + "github.com/labstack/echo/v4" +) + +// scopePermission is one line on the consent page: a plain-language summary +// of what a group of requested scopes allows, with the raw scopes kept for +// anyone who wants to check. +type scopePermission struct { + Title string + Detail string + Sensitive bool + Raw []string +} + +func describeScopes(scope string) []scopePermission { + var ( + out []scopePermission + repo []string + repoAll bool + rpc []string + blob []string + account []string + ident []string + include []string + other []string + ) + add := func(p scopePermission) { out = append(out, p) } + + for _, raw := range strings.Fields(scope) { + sc, err := scopes.Parse(raw) + if err != nil { + other = append(other, raw) + continue + } + switch sc.Resource { + case scopes.ResourceAtproto: + add(scopePermission{Title: "Know who you are", Detail: "See your handle and DID.", Raw: []string{raw}}) + case scopes.ResourceTransition: + switch sc.Transition { + case "generic": + add(scopePermission{Title: "Full access to your account", Detail: "Read and write posts, likes, follows, and other data, and act on your behalf with other services. Does not include direct messages.", Sensitive: true, Raw: []string{raw}}) + case "chat.bsky": + add(scopePermission{Title: "Your direct messages", Detail: "Read and send Bluesky direct messages.", Sensitive: true, Raw: []string{raw}}) + case "email": + add(scopePermission{Title: "Your email address", Detail: "See the email address on your account.", Raw: []string{raw}}) + default: + other = append(other, raw) + } + case scopes.ResourceRepo: + repo = append(repo, raw) + for _, c := range sc.Collections { + if c == "*" { + repoAll = true + } + } + case scopes.ResourceRPC: + rpc = append(rpc, raw) + case scopes.ResourceBlob: + blob = append(blob, raw) + case scopes.ResourceAccount: + account = append(account, raw) + case scopes.ResourceIdentity: + ident = append(ident, raw) + case scopes.ResourceInclude: + include = append(include, raw) + default: + other = append(other, raw) + } + } + + if len(include) > 0 { + add(scopePermission{Title: "A bundle of permissions", Detail: "Permissions defined by the app's publisher.", Raw: include}) + } + if len(repo) > 0 { + detail := "Create, change, or delete specific kinds of records in your repository." + if repoAll { + detail = "Create, change, or delete any record in your repository." + } + add(scopePermission{Title: "Change your data", Detail: detail, Sensitive: repoAll, Raw: repo}) + } + if len(blob) > 0 { + add(scopePermission{Title: "Upload media", Detail: "Upload images, video, or other files to your account.", Raw: blob}) + } + if len(rpc) > 0 { + add(scopePermission{Title: "Talk to other services as you", Detail: "Make requests on your behalf to other atproto services.", Raw: rpc}) + } + if len(account) > 0 { + add(scopePermission{Title: "Manage account settings", Detail: "Access account-level settings such as email or status.", Sensitive: true, Raw: account}) + } + if len(ident) > 0 { + add(scopePermission{Title: "Change your identity", Detail: "Change your handle or DID document.", Sensitive: true, Raw: ident}) + } + if len(other) > 0 { + add(scopePermission{Title: "Other permissions", Raw: other}) + } + return out +} + +// renderMessage shows a simple page for errors a person can act on, such as +// an expired sign-in request. +func (s *Server) renderMessage(e echo.Context, status int, title, message string) error { + if status == 0 { + status = http.StatusOK + } + return e.Render(status, "message.html", map[string]any{ + "Title": title, + "Message": message, + "Hostname": s.config.Hostname, + }) +} diff --git a/server/server.go b/server/server.go index 3d2d372..2eb43e2 100644 --- a/server/server.go +++ b/server/server.go @@ -14,6 +14,7 @@ import ( "net/smtp" "os" "path/filepath" + "strings" "sync" "time" @@ -27,6 +28,7 @@ import ( "github.com/bluesky-social/indigo/util" "github.com/bluesky-social/indigo/xrpc" "github.com/domodwyer/mailyak/v3" + cache "github.com/go-pkgz/expirable-cache/v3" "github.com/go-playground/validator" "github.com/gorilla/sessions" "github.com/haileyok/cocoon/identity" @@ -101,6 +103,10 @@ type Server struct { lastRequestCrawl time.Time requestCrawlMu sync.Mutex + // Display names of OAuth clients for the account page. + clientDisplays cache.Cache[string, clientDisplay] + clientDisplayOnce sync.Once + dbName string dbType string s3Config *S3Config @@ -206,29 +212,43 @@ type TemplateRenderer struct { templates *template.Template isDev bool templatePath string + hostname string +} + +var templateFuncs = template.FuncMap{ + // initial is the first letter of a name, for avatar placeholders. + "initial": func(s string) string { + s = strings.TrimLeft(s, "@ ") + for _, r := range s { + return strings.ToUpper(string(r)) + } + return "?" + }, } func (s *Server) loadTemplates() { absPath, _ := filepath.Abs("server/templates/*.html") if s.config.Version == "dev" { - tmpl := template.Must(template.ParseGlob(absPath)) + tmpl := template.Must(template.New("").Funcs(templateFuncs).ParseGlob(absPath)) s.echo.Renderer = &TemplateRenderer{ templates: tmpl, isDev: true, templatePath: absPath, + hostname: s.config.Hostname, } } else { - tmpl := template.Must(template.ParseFS(templateFS, "templates/*.html")) + tmpl := template.Must(template.New("").Funcs(templateFuncs).ParseFS(templateFS, "templates/*.html")) s.echo.Renderer = &TemplateRenderer{ templates: tmpl, isDev: false, + hostname: s.config.Hostname, } } } func (t *TemplateRenderer) Render(w io.Writer, name string, data any, c echo.Context) error { if t.isDev { - tmpl, err := template.ParseGlob(t.templatePath) + tmpl, err := template.New("").Funcs(templateFuncs).ParseGlob(t.templatePath) if err != nil { return err } @@ -237,6 +257,9 @@ func (t *TemplateRenderer) Render(w io.Writer, name string, data any, c echo.Con if viewContext, isMap := data.(map[string]any); isMap { viewContext["reverse"] = c.Echo().Reverse + if _, ok := viewContext["Hostname"]; !ok { + viewContext["Hostname"] = t.hostname + } } return t.templates.ExecuteTemplate(w, name, data) @@ -577,6 +600,8 @@ func (s *Server) addRoutes() { s.echo.POST("/account/switch", s.handleAccountSwitchPost) s.echo.GET("/account/signin", s.handleAccountSigninGet) s.echo.POST("/account/signin", s.handleAccountSigninPost) + s.echo.GET("/account/signin/verify", s.handleAccountSigninVerifyGet) + s.echo.POST("/account/signin/verify", s.handleAccountSigninVerifyPost) s.echo.GET("/account/signout", s.handleAccountSignout) s.echo.GET("/account/2fa", s.handleAccountTwoFactor) s.echo.GET("/account/2fa/totp", s.handleAccountTwoFactorTOTPGet) @@ -675,6 +700,7 @@ func (s *Server) Serve(ctx context.Context) error { }() go s.backupRoutine() + go s.oauthTokenCleanupRoutine(ctx) go func() { if err := s.requestCrawl(ctx); err != nil { diff --git a/server/signin_oauth_redirect_test.go b/server/signin_oauth_redirect_test.go new file mode 100644 index 0000000..167c14e --- /dev/null +++ b/server/signin_oauth_redirect_test.go @@ -0,0 +1,443 @@ +package server + +import ( + "context" + "html" + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "strconv" + "strings" + "testing" + "time" + + "github.com/gorilla/sessions" + "github.com/haileyok/cocoon/internal/totp" + "github.com/haileyok/cocoon/models" + "github.com/haileyok/cocoon/oauth" + "github.com/haileyok/cocoon/oauth/provider" + "gorm.io/gorm" +) + +var queryParamsField = regexp.MustCompile(`name="query_params"[^>]*value="([^"]*)"`) + +func sessionCookieFrom(s *Server, w *httptest.ResponseRecorder, fallback *http.Cookie) *http.Cookie { + for _, c := range w.Result().Cookies() { + if c.Name == s.config.SessionCookieKey { + return c + } + } + return fallback +} + +func sameOriginPost(s *Server, cookie *http.Cookie, path string, form url.Values) *httptest.ResponseRecorder { + r := httptest.NewRequest("POST", path, strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r.Header.Set("Origin", "http://"+r.Host) + if cookie != nil { + r.AddCookie(cookie) + } + w := httptest.NewRecorder() + s.echo.ServeHTTP(w, r) + return w +} + +func addTestTOTP(t *testing.T, s *Server, did string) []byte { + t.Helper() + secret, err := totp.GenerateSecret() + if err != nil { + t.Fatal(err) + } + cred := models.TwoFactorCredential{Did: did, Type: models.TwoFactorCredentialTOTP, Name: "phone", Secret: secret} + if err := s.db.Create(context.Background(), &cred, nil).Error; err != nil { + t.Fatal(err) + } + return secret +} + +var testOAuthQuery = url.Values{ + "client_id": {"https://app.example/client-metadata.json"}, + "request_uri": {"urn:ietf:params:oauth:request_uri:req-abc123"}, +}.Encode() + +// passwordStep runs the first half of a 2FA sign-in from the OAuth flow and +// returns the cookie holding the pending sign-in. +func passwordStep(t *testing.T, s *Server, acct *testAccount) *http.Cookie { + t.Helper() + w := browserDo(s, nil, "GET", "/account/signin?"+testOAuthQuery, nil) + if w.Code != 200 { + t.Fatalf("signin page: %d", w.Code) + } + cookie := sessionCookieFrom(s, w, nil) + m := queryParamsField.FindStringSubmatch(w.Body.String()) + if m == nil || html.UnescapeString(m[1]) != testOAuthQuery { + t.Fatalf("signin page lost the oauth query: %v", m) + } + + w = browserDo(s, cookie, "POST", "/account/signin", url.Values{ + "username": {acct.Handle}, + "password": {acct.Password}, + "query_params": {html.UnescapeString(m[1])}, + }) + if w.Code != 303 || w.Header().Get("Location") != "/account/signin/verify" { + t.Fatalf("password step: %d %q", w.Code, w.Header().Get("Location")) + } + return sessionCookieFrom(s, w, cookie) +} + +// Signing in from the OAuth flow with a second factor must end up back on +// /oauth/authorize, without asking for the password again on the code page. +func TestSigninWithSecondFactorKeepsOAuthRequest(t *testing.T) { + s, acct := recoveryServer(t) + s.loadTemplates() + secret := addTestTOTP(t, s, acct.Did) + + cookie := passwordStep(t, s, acct) + + w := browserDo(s, cookie, "GET", "/account/signin/verify", nil) + if w.Code != 200 { + t.Fatalf("code page: %d %s", w.Code, w.Header().Get("Location")) + } + cookie = sessionCookieFrom(s, w, cookie) + body := w.Body.String() + if !strings.Contains(body, `name="token"`) || strings.Contains(body, `name="password"`) { + t.Fatalf("code page should ask only for a code:\n%s", body) + } + if !strings.Contains(body, acct.Handle) { + t.Fatalf("code page should say which account is signing in") + } + + // A wrong code stays on the code page and keeps the pending sign-in. + w = browserDo(s, cookie, "POST", "/account/signin/verify", url.Values{"token": {"000000"}}) + if w.Code != 303 || w.Header().Get("Location") != "/account/signin/verify" { + t.Fatalf("wrong code: %d %q", w.Code, w.Header().Get("Location")) + } + cookie = sessionCookieFrom(s, w, cookie) + + w = browserDo(s, cookie, "POST", "/account/signin/verify", url.Values{"token": {totp.Code(secret, time.Now())}}) + if w.Code != 303 { + t.Fatalf("code step: %d %s", w.Code, w.Body.String()) + } + if got, want := w.Header().Get("Location"), "/oauth/authorize?"+testOAuthQuery; got != want { + t.Fatalf("after the code, Location = %q, want %q", got, want) + } + cookie = sessionCookieFrom(s, w, cookie) + + // Now signed in: the account page loads, and the code page is done. + if w := browserDo(s, cookie, "GET", "/account", nil); w.Code != 200 { + t.Fatalf("account page after sign-in: %d %q", w.Code, w.Header().Get("Location")) + } + if w := browserDo(s, cookie, "GET", "/account/signin/verify", nil); w.Code != 303 || !strings.HasPrefix(w.Header().Get("Location"), "/account/signin") { + t.Fatalf("code page after sign-in should restart: %d %q", w.Code, w.Header().Get("Location")) + } +} + +func TestSigninVerifyWithoutPendingSigninRestarts(t *testing.T) { + s, _ := recoveryServer(t) + s.loadTemplates() + w := browserDo(s, nil, "POST", "/account/signin/verify", url.Values{"token": {"123456"}}) + if w.Code != 303 || w.Header().Get("Location") != "/account/signin" { + t.Fatalf("got %d %q", w.Code, w.Header().Get("Location")) + } +} + +// A password reset between the two steps cancels the pending sign-in, and +// the user goes back to the start of the same OAuth request. +func TestSigninVerifyCancelledBySessionVersionChange(t *testing.T) { + s, acct := recoveryServer(t) + s.loadTemplates() + secret := addTestTOTP(t, s, acct.Did) + cookie := passwordStep(t, s, acct) + + if err := s.db.Exec(context.Background(), "UPDATE repos SET session_version = session_version + 1 WHERE did = ?", nil, acct.Did).Error; err != nil { + t.Fatal(err) + } + + w := browserDo(s, cookie, "POST", "/account/signin/verify", url.Values{"token": {totp.Code(secret, time.Now())}}) + if want := "/account/signin?" + testOAuthQuery; w.Code != 303 || w.Header().Get("Location") != want { + t.Fatalf("got %d %q, want %q", w.Code, w.Header().Get("Location"), want) + } + cookie = sessionCookieFrom(s, w, cookie) + if w := browserDo(s, cookie, "GET", "/account", nil); w.Code != 303 { + t.Fatalf("should not be signed in, got %d", w.Code) + } +} + +func TestPendingSigninExpires(t *testing.T) { + sess := sessions.NewSession(nil, "test") + now := time.Now() + setPendingSignin(sess, pendingSignin{Did: "did:plc:abc", SessionVersion: 2, Return: "request_uri=x"}, now) + + if p, ok := getPendingSignin(sess, now.Add(pendingSigninLifetime-time.Second)); !ok || p.Did != "did:plc:abc" || p.SessionVersion != 2 { + t.Fatalf("pending sign-in should still be valid: %+v %v", p, ok) + } + p, ok := getPendingSignin(sess, now.Add(pendingSigninLifetime+time.Second)) + if ok { + t.Fatal("pending sign-in should have expired") + } + if p.Return != "request_uri=x" { + t.Fatal("an expired sign-in should still report where it was headed") + } + + clearPendingSignin(sess) + if _, ok := getPendingSignin(sess, now); ok { + t.Fatal("cleared sign-in is still pending") + } +} + +func TestOauthReturnQuery(t *testing.T) { + for in, want := range map[string]string{ + "": "", + "add=1": "", + "?request_uri=abc": "request_uri=abc", + "client_id=x&state=%3": "", + "client_id=x&state=y": "client_id=x&state=y", + } { + if got := oauthReturnQuery(in); got != want { + t.Errorf("oauthReturnQuery(%q) = %q, want %q", in, got, want) + } + } +} + +// "Add another account" from the dashboard must reach the sign-in form while +// signed in, and land back on the dashboard rather than /oauth/authorize. +func TestSigninAddAccountFromDashboard(t *testing.T) { + s, acct, cookie := manageServer(t) + other := s.createTestAccount(t, "second.pds.test") + + w := browserDo(s, cookie, "GET", "/account/signin?add=1", nil) + if w.Code != 200 { + t.Fatalf("add account page: %d %q", w.Code, w.Header().Get("Location")) + } + w = browserDo(s, cookie, "POST", "/account/signin", url.Values{ + "username": {other.Handle}, "password": {other.Password}, "query_params": {"add=1"}, + }) + if w.Code != 303 || w.Header().Get("Location") != "/account" { + t.Fatalf("got %d %q", w.Code, w.Header().Get("Location")) + } + cookie = sessionCookieFrom(s, w, cookie) + body := browserDo(s, cookie, "GET", "/account", nil).Body.String() + if !strings.Contains(body, acct.Handle) || !strings.Contains(body, other.Handle) { + t.Fatal("both accounts should be in the switcher") + } +} + +func insertOauthToken(t *testing.T, s *Server, tok provider.OauthToken) provider.OauthToken { + t.Helper() + if tok.Token == "" { + tok.Token = "at-" + oauth.GenerateTokenId() + } + if tok.RefreshToken == "" { + tok.RefreshToken = oauth.GenerateRefreshToken() + } + if tok.ClientAuth.Method == "" { + tok.ClientAuth.Method = "private_key_jwt" + } + if err := s.db.Create(context.Background(), &tok, nil).Error; err != nil { + t.Fatal(err) + } + return tok +} + +func TestLiveOauthTokensSkipsDeadSessions(t *testing.T) { + s := newTestServer(t) + acct := s.createTestAccount(t, "alice.pds.test") + now := time.Now() + day := 24 * time.Hour + + live := insertOauthToken(t, s, provider.OauthToken{Sub: acct.Did, ClientId: "https://a.example/c.json", Model: gorm.Model{CreatedAt: now.Add(-30 * day), UpdatedAt: now.Add(-day)}}) + livePublic := insertOauthToken(t, s, provider.OauthToken{Sub: acct.Did, ClientId: "https://b.example/c.json", ClientAuth: provider.ClientAuth{Method: "none"}, Model: gorm.Model{CreatedAt: now.Add(-3 * day), UpdatedAt: now.Add(-time.Hour)}}) + // Public client past its two-week lifetime. + insertOauthToken(t, s, provider.OauthToken{Sub: acct.Did, ClientId: "https://b.example/c.json", ClientAuth: provider.ClientAuth{Method: "none"}, Model: gorm.Model{CreatedAt: now.Add(-20 * day), UpdatedAt: now.Add(-time.Hour)}}) + // Not refreshed in over three months. + insertOauthToken(t, s, provider.OauthToken{Sub: acct.Did, ClientId: "https://a.example/c.json", Model: gorm.Model{CreatedAt: now.Add(-200 * day), UpdatedAt: now.Add(-100 * day)}}) + // From before a password reset. + insertOauthToken(t, s, provider.OauthToken{Sub: acct.Did, ClientId: "https://a.example/c.json", SessionVersion: -1, Model: gorm.Model{CreatedAt: now.Add(-day), UpdatedAt: now}}) + // Someone else's. + insertOauthToken(t, s, provider.OauthToken{Sub: "did:plc:someoneelse", ClientId: "https://a.example/c.json", Model: gorm.Model{CreatedAt: now.Add(-day), UpdatedAt: now}}) + + got, err := s.liveOauthTokens(context.Background(), acct.Did, 0, now) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 || got[0].ID != livePublic.ID || got[1].ID != live.ID { + ids := []uint{} + for _, g := range got { + ids = append(ids, g.ID) + } + t.Fatalf("live tokens = %v, want [%d %d]", ids, livePublic.ID, live.ID) + } +} + +func TestLoadAccountAppsGroupsByClient(t *testing.T) { + s := newTestServer(t) + acct := s.createTestAccount(t, "alice.pds.test") + repo, err := s.getRepoActorByDid(context.Background(), acct.Did) + if err != nil { + t.Fatal(err) + } + now := time.Now() + insertOauthToken(t, s, provider.OauthToken{Sub: acct.Did, ClientId: "https://a.example/c.json", Model: gorm.Model{CreatedAt: now.Add(-time.Hour), UpdatedAt: now.Add(-time.Hour)}}) + insertOauthToken(t, s, provider.OauthToken{Sub: acct.Did, ClientId: "https://b.example/c.json", Model: gorm.Model{CreatedAt: now.Add(-time.Hour), UpdatedAt: now.Add(-time.Minute)}}) + insertOauthToken(t, s, provider.OauthToken{Sub: acct.Did, ClientId: "https://a.example/c.json", Model: gorm.Model{CreatedAt: now.Add(-2 * time.Hour), UpdatedAt: now.Add(-2 * time.Hour)}}) + + // No client manager: names fall back to hostnames without any lookups. + apps, err := s.loadAccountApps(context.Background(), repo, now) + if err != nil { + t.Fatal(err) + } + if len(apps) != 2 { + t.Fatalf("got %d apps, want 2", len(apps)) + } + if apps[0].Name != "b.example" || len(apps[0].Sessions) != 1 { + t.Fatalf("most recently used app should be first: %+v", apps[0]) + } + if apps[1].Name != "a.example" || len(apps[1].Sessions) != 2 || apps[1].Initial != "A" { + t.Fatalf("a.example should hold both of its sessions: %+v", apps[1]) + } +} + +func TestPruneOauthTokens(t *testing.T) { + s := newTestServer(t) + acct := s.createTestAccount(t, "alice.pds.test") + now := time.Now() + day := 24 * time.Hour + keep := insertOauthToken(t, s, provider.OauthToken{Sub: acct.Did, ClientId: "c", Model: gorm.Model{CreatedAt: now.Add(-day), UpdatedAt: now}}) + insertOauthToken(t, s, provider.OauthToken{Sub: acct.Did, ClientId: "c", Model: gorm.Model{CreatedAt: now.Add(-800 * day), UpdatedAt: now}}) + insertOauthToken(t, s, provider.OauthToken{Sub: acct.Did, ClientId: "c", Model: gorm.Model{CreatedAt: now.Add(-100 * day), UpdatedAt: now.Add(-95 * day)}}) + insertOauthToken(t, s, provider.OauthToken{Sub: acct.Did, ClientId: "c", SessionVersion: -1, Model: gorm.Model{CreatedAt: now.Add(-day), UpdatedAt: now}}) + + n, err := s.pruneOauthTokens(context.Background(), now) + if err != nil { + t.Fatal(err) + } + if n != 3 { + t.Fatalf("pruned %d, want 3", n) + } + var ids []uint + if err := s.db.Raw(context.Background(), "SELECT id FROM oauth_tokens", nil).Scan(&ids).Error; err != nil { + t.Fatal(err) + } + if len(ids) != 1 || ids[0] != keep.ID { + t.Fatalf("remaining tokens = %v, want [%d]", ids, keep.ID) + } +} + +func TestAccountRevokeOnlyTouchesOwnSessions(t *testing.T) { + s, acct, cookie := manageServer(t) + now := time.Now() + mine := insertOauthToken(t, s, provider.OauthToken{Sub: acct.Did, ClientId: "https://a.example/c.json", Model: gorm.Model{CreatedAt: now, UpdatedAt: now}}) + mine2 := insertOauthToken(t, s, provider.OauthToken{Sub: acct.Did, ClientId: "https://a.example/c.json", Model: gorm.Model{CreatedAt: now, UpdatedAt: now}}) + theirs := insertOauthToken(t, s, provider.OauthToken{Sub: "did:plc:someoneelse", ClientId: "https://a.example/c.json", Model: gorm.Model{CreatedAt: now, UpdatedAt: now}}) + + count := func(id uint) int64 { + var n int64 + s.db.Raw(context.Background(), "SELECT COUNT(*) FROM oauth_tokens WHERE id = ?", nil, id).Scan(&n) + return n + } + + // Cross-site posts are refused. + if w := browserDo(s, cookie, "POST", "/account/revoke", url.Values{"id": {itoa(mine.ID)}}); w.Code != 403 || count(mine.ID) != 1 { + t.Fatalf("cross-site revoke: %d", w.Code) + } + + if w := sameOriginPost(s, cookie, "/account/revoke", url.Values{"id": {itoa(theirs.ID)}}); w.Code != 303 || count(theirs.ID) != 1 { + t.Fatal("revoking another account's session must do nothing") + } + if w := sameOriginPost(s, cookie, "/account/revoke", url.Values{"id": {itoa(mine.ID)}}); w.Code != 303 || count(mine.ID) != 0 || count(mine2.ID) != 1 { + t.Fatal("revoke by id should remove exactly that session") + } + if w := sameOriginPost(s, cookie, "/account/revoke", url.Values{"client_id": {"https://a.example/c.json"}}); w.Code != 303 || count(mine2.ID) != 0 || count(theirs.ID) != 1 { + t.Fatal("revoke by app should remove only this account's sessions for it") + } +} + +func itoa(n uint) string { return strconv.FormatUint(uint64(n), 10) } + +func insertAuthRequest(t *testing.T, s *Server, id string) { + t.Helper() + req := provider.OauthAuthorizationRequest{ + RequestId: id, + ClientId: "https://app.example/client-metadata.json", + Parameters: provider.ParRequest{ + ResponseType: "code", + RedirectURI: "https://app.example/callback", + State: "state-123", + Scope: "atproto", + }, + ExpiresAt: time.Now().Add(time.Minute), + } + if err := s.db.Create(context.Background(), &req, nil).Error; err != nil { + t.Fatal(err) + } +} + +func TestAuthorizeRejectRedirectsToAppWithAccessDenied(t *testing.T) { + s, _, cookie := manageServer(t) + insertAuthRequest(t, s, "req-reject") + + w := browserDo(s, cookie, "POST", "/oauth/authorize", url.Values{ + "request_uri": {oauth.EncodeRequestUri("req-reject")}, + "accept_or_reject": {"reject"}, + }) + if w.Code != 303 { + t.Fatalf("reject: %d %s", w.Code, w.Body.String()) + } + loc, err := url.Parse(w.Header().Get("Location")) + if err != nil { + t.Fatal(err) + } + q := loc.Query() + if loc.Host != "app.example" || loc.Path != "/callback" || q.Get("error") != "access_denied" || q.Get("state") != "state-123" || q.Get("iss") != "https://"+testHostname { + t.Fatalf("reject redirect = %s", loc) + } + var n int64 + s.db.Raw(context.Background(), "SELECT COUNT(*) FROM oauth_authorization_requests WHERE request_id = ?", nil, "req-reject").Scan(&n) + if n != 0 { + t.Fatal("a rejected request should not be usable afterwards") + } +} + +func TestAuthorizePostWhenSignedOutKeepsRequest(t *testing.T) { + s, _ := recoveryServer(t) + s.loadTemplates() + insertAuthRequest(t, s, "req-signed-out") + reqURI := oauth.EncodeRequestUri("req-signed-out") + + w := browserDo(s, nil, "POST", "/oauth/authorize", url.Values{"request_uri": {reqURI}, "accept_or_reject": {"accept"}}) + want := "/account/signin?" + url.Values{"client_id": {"https://app.example/client-metadata.json"}, "request_uri": {reqURI}}.Encode() + if w.Code != 303 || w.Header().Get("Location") != want { + t.Fatalf("got %d %q, want %q", w.Code, w.Header().Get("Location"), want) + } +} + +func TestAuthorizeAcceptOnlyOnce(t *testing.T) { + s, _, cookie := manageServer(t) + insertAuthRequest(t, s, "req-once") + form := url.Values{"request_uri": {oauth.EncodeRequestUri("req-once")}, "accept_or_reject": {"accept"}} + + w := browserDo(s, cookie, "POST", "/oauth/authorize", form) + if w.Code != 303 || !strings.HasPrefix(w.Header().Get("Location"), "https://app.example/callback?") { + t.Fatalf("accept: %d %q", w.Code, w.Header().Get("Location")) + } + if w := browserDo(s, cookie, "POST", "/oauth/authorize", form); w.Code != 400 { + t.Fatalf("second accept should fail, got %d %q", w.Code, w.Header().Get("Location")) + } +} + +func TestDescribeScopes(t *testing.T) { + perms := describeScopes("atproto transition:generic repo:app.bsky.feed.post bogus::") + var titles []string + for _, p := range perms { + titles = append(titles, p.Title) + } + got := strings.Join(titles, "|") + want := "Know who you are|Full access to your account|Change your data|Other permissions" + if got != want { + t.Fatalf("titles = %q, want %q", got, want) + } + if !perms[1].Sensitive || perms[0].Sensitive { + t.Fatal("full access should be marked sensitive, identity should not") + } +} diff --git a/server/static/pico.css b/server/static/pico.css deleted file mode 100644 index cbbe2c3..0000000 --- a/server/static/pico.css +++ /dev/null @@ -1,4 +0,0 @@ -@charset "UTF-8";/*! - * Pico CSS ✨ v2.1.1 (https://picocss.com) - * Copyright 2019-2025 - Licensed under MIT - */:host,: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){:host,:root{--pico-font-size:106.25%}}@media (min-width:768px){:host,:root{--pico-font-size:112.5%}}@media (min-width:1024px){:host,:root{--pico-font-size:118.75%}}@media (min-width:1280px){:host,:root{--pico-font-size:125%}}@media (min-width:1536px){:host,: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)}:host(:not([data-theme=dark])),:root:not([data-theme=dark]),[data-theme=light]{color-scheme:light;--pico-background-color:#fff;--pico-color:#373c44;--pico-text-selection-color:rgba(116, 139, 248, 0.25);--pico-muted-color:#646b79;--pico-muted-border-color:rgb(231, 234, 239.5);--pico-primary:#2060df;--pico-primary-background:#2060df;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(32, 96, 223, 0.5);--pico-primary-hover:#184eb8;--pico-primary-hover-background:#1d59d0;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(116, 139, 248, 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:rgb(252.5, 230.5, 191.5);--pico-mark-color:#0f1114;--pico-ins-color:rgb(28.5, 105.5, 84);--pico-del-color:rgb(136, 56.5, 53);--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:rgb(243, 244.5, 246.75);--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:rgb(251, 251.5, 252.25);--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:rgb(183.5, 105.5, 106.5);--pico-form-element-invalid-active-border-color:rgb(200.25, 79.25, 72.25);--pico-form-element-invalid-focus-color:var(--pico-form-element-invalid-active-border-color);--pico-form-element-valid-border-color:rgb(76, 154.5, 137.5);--pico-form-element-valid-active-border-color:rgb(39, 152.75, 118.75);--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:rgb(251, 251.5, 252.25);--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, 154.5, 137.5)' 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.25, 79.25, 72.25)' 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")}:host(:not([data-theme=dark])) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),: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){:host(:not([data-theme])),:root:not([data-theme]){color-scheme:dark;--pico-background-color:rgb(19, 22.5, 30.5);--pico-color:#c2c7d0;--pico-text-selection-color:rgba(137, 153, 249, 0.1875);--pico-muted-color:#7b8495;--pico-muted-border-color:#202632;--pico-primary:#8999f9;--pico-primary-background:#2060df;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(137, 153, 249, 0.5);--pico-primary-hover:#aeb5fb;--pico-primary-hover-background:#3c71f7;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(137, 153, 249, 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, 8.5, 12, 0.01698),0.0335rem 0.067rem 0.402rem rgba(7, 8.5, 12, 0.024),0.0625rem 0.125rem 0.75rem rgba(7, 8.5, 12, 0.03),0.1125rem 0.225rem 1.35rem rgba(7, 8.5, 12, 0.036),0.2085rem 0.417rem 2.502rem rgba(7, 8.5, 12, 0.04302),0.5rem 1rem 6rem rgba(7, 8.5, 12, 0.06),0 0 0 0.0625rem rgba(7, 8.5, 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:rgb(205.5, 126, 123);--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:rgb(26, 30.5, 40.25);--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:rgb(28, 33, 43.5);--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:rgb(26, 30.5, 40.25);--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:rgb(149.5, 74, 80);--pico-form-element-invalid-active-border-color:rgb(183.25, 63.5, 59);--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:rgb(22, 137, 105.5);--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:rgb(26, 30.5, 40.25);--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(7.5, 8.5, 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(149.5, 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")}:host(:not([data-theme])) input:is([type=submit],[type=button],[type=reset],[type=checkbox],[type=radio],[type=file]),: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)}:host(:not([data-theme])) details summary[role=button].contrast:not(.outline)::after,:root:not([data-theme]) details summary[role=button].contrast:not(.outline)::after{filter:brightness(0)}:host(:not([data-theme])) [aria-busy=true]:not(input,select,textarea).contrast:is(button,[type=submit],[type=button],[type=reset],[role=button]):not(.outline)::before,: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]{color-scheme:dark;--pico-background-color:rgb(19, 22.5, 30.5);--pico-color:#c2c7d0;--pico-text-selection-color:rgba(137, 153, 249, 0.1875);--pico-muted-color:#7b8495;--pico-muted-border-color:#202632;--pico-primary:#8999f9;--pico-primary-background:#2060df;--pico-primary-border:var(--pico-primary-background);--pico-primary-underline:rgba(137, 153, 249, 0.5);--pico-primary-hover:#aeb5fb;--pico-primary-hover-background:#3c71f7;--pico-primary-hover-border:var(--pico-primary-hover-background);--pico-primary-hover-underline:var(--pico-primary-hover);--pico-primary-focus:rgba(137, 153, 249, 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, 8.5, 12, 0.01698),0.0335rem 0.067rem 0.402rem rgba(7, 8.5, 12, 0.024),0.0625rem 0.125rem 0.75rem rgba(7, 8.5, 12, 0.03),0.1125rem 0.225rem 1.35rem rgba(7, 8.5, 12, 0.036),0.2085rem 0.417rem 2.502rem rgba(7, 8.5, 12, 0.04302),0.5rem 1rem 6rem rgba(7, 8.5, 12, 0.06),0 0 0 0.0625rem rgba(7, 8.5, 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:rgb(205.5, 126, 123);--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:rgb(26, 30.5, 40.25);--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:rgb(28, 33, 43.5);--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:rgb(26, 30.5, 40.25);--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:rgb(149.5, 74, 80);--pico-form-element-invalid-active-border-color:rgb(183.25, 63.5, 59);--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:rgb(22, 137, 105.5);--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:rgb(26, 30.5, 40.25);--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(7.5, 8.5, 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(149.5, 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")}[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(:host),: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(:host),svg:not(:root){overflow:hidden}code,kbd,pre,samp{font-size:.875em;font-family:var(--pico-font-family)}pre code,pre samp{font-size:inherit;font-family:inherit}pre{-ms-overflow-style:scrollbar;overflow:auto}code,kbd,pre,samp{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,samp{display:inline-block;padding:.375rem}pre{display:block;margin-bottom:var(--pico-spacing);overflow-x:auto}pre>code,pre>samp{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>a::after,details.dropdown>button::after,details.dropdown>summary::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,form){white-space:nowrap}[aria-busy=true]:not(input,select,textarea,html,form)::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,form):not(:empty)::before{margin-inline-end:calc(var(--pico-spacing) * .5)}[aria-busy=true]:not(input,select,textarea,html,form):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}:host,: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,[role=button]){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/server/static/style.css b/server/static/style.css index 57b9828..10ac455 100644 --- a/server/static/style.css +++ b/server/static/style.css @@ -1,83 +1,941 @@ +/* cocoon account pages */ + :root { - --zinc-700: rgb(66, 71, 81); - --success: rgb(0, 166, 110); - --danger: rgb(155, 35, 24); + color-scheme: light dark; + --bg: #f4f6fa; + --surface: #ffffff; + --surface-2: #f0f3f8; + --border: #e1e6ee; + --text: #0f1729; + --muted: #5b6678; + --faint: #8a94a6; + --accent: #1a73ff; + --accent-hover: #0b5fe0; + --accent-soft: rgba(26, 115, 255, 0.1); + --on-accent: #ffffff; + --danger: #d9322a; + --danger-soft: rgba(217, 50, 42, 0.1); + --success: #138a52; + --success-soft: rgba(19, 138, 82, 0.1); + --warning: #b86e00; + --warning-soft: rgba(184, 110, 0, 0.12); + --shadow: 0 1px 2px rgba(15, 23, 41, 0.05), 0 8px 24px rgba(15, 23, 41, 0.06); + --radius: 14px; + --radius-sm: 9px; + --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0b0f17; + --surface: #131a26; + --surface-2: #1a2231; + --border: #253043; + --text: #e8edf5; + --muted: #9aa6b8; + --faint: #6c788b; + --accent: #4b93ff; + --accent-hover: #6aa6ff; + --accent-soft: rgba(75, 147, 255, 0.14); + --danger: #ff6b61; + --danger-soft: rgba(255, 107, 97, 0.13); + --success: #3ccf8e; + --success-soft: rgba(60, 207, 142, 0.13); + --warning: #f0a93b; + --warning-soft: rgba(240, 169, 59, 0.14); + --shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 8px 24px rgba(0, 0, 0, 0.25); + } +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + -webkit-text-size-adjust: 100%; } body { - display: flex; - flex-direction: column; + margin: 0; + min-height: 100vh; + background: var(--bg); + color: var(--text); + font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + -webkit-font-smoothing: antialiased; } -main { +a { + color: var(--accent); + text-decoration: none; +} +a:hover { + text-decoration: underline; } -.margin-top-sm { - margin-top: 2em; +h1, +h2, +h3, +h4 { + margin: 0; + line-height: 1.25; + letter-spacing: -0.01em; +} +h1 { + font-size: 1.6rem; +} +h2 { + font-size: 1.15rem; +} +h3 { + font-size: 1rem; +} +p { + margin: 0 0 0.75rem; +} +p:last-child { + margin-bottom: 0; } -.margin-top-md { - margin-top: 2.5em; +code, +pre, +.mono { + font-family: var(--mono); + font-size: 0.86em; +} +pre { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.9rem 1rem; + overflow-x: auto; + margin: 0 0 1rem; +} +code { + background: var(--surface-2); + padding: 0.1em 0.35em; + border-radius: 5px; + word-break: break-all; +} +pre code { + background: none; + padding: 0; } -.margin-bottom-xs { - margin-bottom: 1.5em; +.muted { + color: var(--muted); +} +.faint { + color: var(--faint); +} +.small { + font-size: 0.86rem; } -.centered-body { - min-height: 100vh; +/* ---------- top bar ---------- */ + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + max-width: 880px; + margin: 0 auto; + padding: 1.1rem 1.25rem; +} +.brand { + display: inline-flex; + align-items: center; + gap: 0.6rem; + color: var(--text); + font-weight: 650; +} +.brand:hover { + text-decoration: none; +} +.brand-mark { + display: grid; + place-items: center; + width: 30px; + height: 30px; + border-radius: 9px; + background: linear-gradient(135deg, #3b8cff, #7a5cff); + color: #fff; + font-size: 0.95rem; +} +.brand-lg { + display: flex; justify-content: center; } +.brand-lg .brand-mark { + width: 52px; + height: 52px; + border-radius: 15px; +} +.brand-lg svg { + width: 26px; + height: 29px; +} +.brand-host { + color: var(--muted); + font-weight: 500; +} + +/* ---------- layout ---------- */ -.base-container { - border: 1px solid var(--zinc-700); - border-radius: 10px; - padding: 1.75em 1.2em; +.page { + max-width: 880px; + margin: 0 auto; + padding: 0 1.25rem 4rem; +} +.page-narrow { + max-width: 460px; + margin: 0 auto; + padding: 3vh 1.25rem 4rem; +} +.stack > * + * { + margin-top: 1.25rem; } -.box-shadow-container { - box-shadow: 1px 1px 52px 2px rgba(0, 0, 0, 0.42); +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 1.4rem; +} +.card-flush { + padding: 0; + overflow: hidden; +} +.card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} +.card-header p { + margin: 0.2rem 0 0; +} +.section-title { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + margin: 2rem 0 0.75rem; +} +.section-title h2 { + font-size: 1.05rem; } -.login-container { - max-width: 50ch; - form :last-child { - margin-bottom: 0; +/* ---------- auth pages ---------- */ + +.auth-card { + padding: 2rem 1.75rem; +} +.auth-head { + text-align: center; + margin-bottom: 1.5rem; +} +.auth-head h1 { + font-size: 1.4rem; + margin-top: 0.9rem; +} +.auth-head p { + margin-top: 0.4rem; + color: var(--muted); +} +.auth-foot { + text-align: center; + margin-top: 1.25rem; + color: var(--muted); + font-size: 0.9rem; +} + +/* ---------- avatars ---------- */ + +.avatar { + flex: none; + display: grid; + place-items: center; + width: 44px; + height: 44px; + border-radius: 50%; + background: var(--accent-soft); + color: var(--accent); + font-weight: 700; + font-size: 1.05rem; + text-transform: uppercase; +} +.avatar-lg { + width: 64px; + height: 64px; + font-size: 1.5rem; +} +.avatar-sm { + width: 30px; + height: 30px; + font-size: 0.8rem; +} +.avatar-app { + border-radius: 12px; + background: var(--surface-2); + color: var(--text); + border: 1px solid var(--border); +} +.avatar-center { + margin: 0 auto; +} + +/* ---------- profile ---------- */ + +.profile { + display: flex; + align-items: center; + gap: 1rem; +} +.profile-main { + min-width: 0; +} +.profile-main h1 { + font-size: 1.45rem; + overflow-wrap: anywhere; +} +.did { + display: inline-block; + margin-top: 0.25rem; + color: var(--muted); + font-family: var(--mono); + font-size: 0.8rem; + overflow-wrap: anywhere; +} + +.stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.75rem; + margin-top: 1.4rem; +} +.stat { + display: block; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.85rem 1rem; + color: var(--text); +} +a.stat:hover { + text-decoration: none; + border-color: var(--accent); +} +.stat-value { + display: block; + font-size: 1.35rem; + font-weight: 700; + line-height: 1.2; +} +.stat-label { + display: block; + color: var(--muted); + font-size: 0.82rem; +} +@media (max-width: 560px) { + .stats { + grid-template-columns: 1fr; } - form button { - float: right; +} + +/* ---------- lists ---------- */ + +.list { + list-style: none; + margin: 0; + padding: 0; +} +.list > li + li { + border-top: 1px solid var(--border); +} +.row { + display: flex; + align-items: center; + gap: 0.9rem; + padding: 1rem 1.25rem; +} +.row-main { + flex: 1; + min-width: 0; +} +.row-title { + font-weight: 600; + overflow-wrap: anywhere; +} +.row-sub { + color: var(--muted); + font-size: 0.86rem; + overflow-wrap: anywhere; +} +.row-actions { + display: flex; + gap: 0.5rem; + flex: none; +} + +/* app cards with expandable sessions */ + +.app > summary { + list-style: none; + cursor: pointer; +} +.app > summary::-webkit-details-marker { + display: none; +} +.app > summary:hover { + background: var(--surface-2); +} +.chevron { + flex: none; + width: 9px; + height: 9px; + border-right: 2px solid var(--faint); + border-bottom: 2px solid var(--faint); + transform: rotate(-45deg); + transition: transform 0.15s ease; + margin-right: 0.2rem; +} +.app[open] .chevron { + transform: rotate(45deg); +} +.app-body { + background: var(--surface-2); + border-top: 1px solid var(--border); + padding: 0.25rem 0; +} +.session { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem 1.25rem 0.75rem 4.4rem; +} +.session + .session { + border-top: 1px dashed var(--border); +} +.session-meta { + flex: 1; + min-width: 0; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); + gap: 0.15rem 1rem; + font-size: 0.86rem; +} +.session-meta dt { + color: var(--faint); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.session-meta dd { + margin: 0; + overflow-wrap: anywhere; +} +.app-footer { + display: flex; + justify-content: flex-end; + padding: 0.75rem 1.25rem; + border-top: 1px solid var(--border); +} +@media (max-width: 560px) { + .session { + padding-left: 1.25rem; + flex-direction: column; + align-items: stretch; } } -.authorize-container { - max-width: 100ch; +.empty { + text-align: center; + padding: 2.5rem 1.5rem; + color: var(--muted); +} +.empty h3 { + color: var(--text); + margin-bottom: 0.3rem; +} + +/* ---------- badges ---------- */ + +.badge { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.15rem 0.6rem; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 600; + background: var(--surface-2); + color: var(--muted); + border: 1px solid var(--border); + white-space: nowrap; +} +.badge-success { + background: var(--success-soft); + color: var(--success); + border-color: transparent; +} +.badge-warning { + background: var(--warning-soft); + color: var(--warning); + border-color: transparent; +} +.badge-danger { + background: var(--danger-soft); + color: var(--danger); + border-color: transparent; +} +.dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; } -button { - width: unset; - min-width: 16ch; +/* ---------- forms ---------- */ + +form { + margin: 0; +} +.field { + margin-bottom: 0.9rem; +} +label, +.label { + display: block; + font-weight: 600; + font-size: 0.88rem; + margin-bottom: 0.35rem; +} +input, +select, +textarea { + width: 100%; + font: inherit; + color: var(--text); + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.7rem 0.85rem; + margin: 0 0 0.75rem; + transition: border-color 0.12s ease, box-shadow 0.12s ease; +} +.field input, +.field select { + margin-bottom: 0; +} +input:focus, +select:focus, +textarea:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} +input::placeholder { + color: var(--faint); +} +.input-code { + text-align: center; + font-family: var(--mono); + font-size: 1.35rem; + letter-spacing: 0.18em; + padding: 0.8rem; +} +.hint { + color: var(--muted); + font-size: 0.84rem; + margin: -0.4rem 0 0.9rem; } +button, +.button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + font: inherit; + font-weight: 600; + font-size: 0.92rem; + padding: 0.6rem 1.1rem; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); + cursor: pointer; + text-decoration: none; + white-space: nowrap; + transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease; +} +button:hover, +.button:hover { + text-decoration: none; + background: var(--surface-2); +} +button.primary, +.button.primary { + background: var(--accent); + border-color: var(--accent); + color: var(--on-accent); +} +button.primary:hover, +.button.primary:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); +} +button.danger, +.button.danger { + color: var(--danger); +} +button.danger:hover, +.button.danger:hover { + background: var(--danger-soft); + border-color: var(--danger); +} +button.ghost, +.button.ghost { + background: transparent; + border-color: transparent; + color: var(--muted); +} +button.ghost:hover, +.button.ghost:hover { + color: var(--text); + background: var(--surface-2); +} +button.small, +.button.small { + padding: 0.35rem 0.75rem; + font-size: 0.84rem; +} +button.block, +.button.block { + width: 100%; + padding: 0.75rem 1rem; +} .button-row { display: flex; - gap: 1ch; - justify-content: end; + gap: 0.75rem; + justify-content: flex-end; + flex-wrap: wrap; +} +.button-row.split > * { + flex: 1; } +.link-button { + background: none; + border: none; + padding: 0; + color: var(--accent); + font-weight: 500; + font-size: inherit; +} +.link-button:hover { + background: none; + text-decoration: underline; +} + +/* ---------- alerts ---------- */ .alert { - border: 1px solid var(--zinc-700); - border-radius: 10px; - padding: 1em 1em; - p { - color: white; - margin-bottom: unset; - } + display: flex; + gap: 0.6rem; + align-items: flex-start; + border-radius: var(--radius-sm); + padding: 0.75rem 0.95rem; + margin-bottom: 1.1rem; + font-size: 0.92rem; + border: 1px solid transparent; +} +.alert p { + margin: 0; +} +.alert-danger { + background: var(--danger-soft); + color: var(--danger); } - .alert-success { - background-color: var(--success); + background: var(--success-soft); + color: var(--success); +} +.alert-info { + background: var(--accent-soft); + color: var(--accent); +} +.alert-warning { + background: var(--warning-soft); + color: var(--warning); } -.alert-danger { - background-color: var(--danger); +/* ---------- account switcher ---------- */ + +.menu { + position: relative; +} +.menu > summary { + list-style: none; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.3rem 0.6rem 0.3rem 0.3rem; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--surface); + font-weight: 600; + font-size: 0.9rem; + max-width: 260px; +} +.menu > summary::-webkit-details-marker { + display: none; +} +.menu > summary .truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.menu-panel { + position: absolute; + right: 0; + top: calc(100% + 0.5rem); + z-index: 10; + width: 300px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 0.4rem; +} +.menu-label { + padding: 0.5rem 0.7rem 0.3rem; + color: var(--faint); + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; +} +.menu-item { + display: flex; + align-items: center; + gap: 0.65rem; + width: 100%; + padding: 0.55rem 0.7rem; + border-radius: var(--radius-sm); + border: none; + background: none; + color: var(--text); + font-weight: 500; + font-size: 0.92rem; + text-align: left; + justify-content: flex-start; +} +.menu-item:hover { + background: var(--surface-2); + text-decoration: none; +} +.menu-item .truncate { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.menu-sep { + height: 1px; + background: var(--border); + margin: 0.35rem 0; +} +.check { + color: var(--accent); + font-weight: 700; +} + +/* ---------- consent ---------- */ + +.consent-apps { + display: flex; + align-items: center; + justify-content: center; + gap: 0.6rem; +} +.consent-link { + width: 28px; + border-top: 2px dotted var(--faint); +} +.account-chip { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.7rem 0.85rem; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-2); + margin-bottom: 1.25rem; +} +.account-chip .row-main { + min-width: 0; +} +.perms { + list-style: none; + margin: 0 0 1.25rem; + padding: 0; + border: 1px solid var(--border); + border-radius: var(--radius-sm); +} +.perms li { + display: flex; + gap: 0.75rem; + padding: 0.8rem 0.95rem; +} +.perms li + li { + border-top: 1px solid var(--border); +} +.perm-icon { + flex: none; + display: grid; + place-items: center; + width: 26px; + height: 26px; + border-radius: 50%; + background: var(--accent-soft); + color: var(--accent); + font-size: 0.8rem; + font-weight: 700; +} +.perm-sensitive .perm-icon { + background: var(--warning-soft); + color: var(--warning); +} +.perm-title { + font-weight: 600; + font-size: 0.93rem; +} +.perm-detail { + color: var(--muted); + font-size: 0.86rem; +} +.disclosure { + margin-bottom: 1.25rem; + font-size: 0.86rem; +} +.disclosure summary { + cursor: pointer; + color: var(--muted); +} +.disclosure[open] summary { + margin-bottom: 0.5rem; +} +.scope-list { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +/* ---------- 2FA management ---------- */ + +.method-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 0.75rem; +} +.method { + display: flex; + gap: 0.8rem; + align-items: center; + padding: 1rem; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--text); +} +.method:hover { + text-decoration: none; + border-color: var(--accent); +} +.method .row-sub { + margin-top: 0.1rem; +} +.inline-form { + margin-top: 0.9rem; + padding: 1rem; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface-2); +} +details.inline > summary { + cursor: pointer; + list-style: none; + display: inline-flex; +} +details.inline > summary::-webkit-details-marker { + display: none; +} +.qr { + display: flex; + justify-content: center; + padding: 1rem; + background: #fff; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + margin-bottom: 1rem; +} +.qr img { + display: block; + width: 220px; + height: 220px; + image-rendering: pixelated; +} +.codes { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.5rem; + margin: 0 0 1.25rem; + padding: 1rem; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-family: var(--mono); + font-size: 1rem; + text-align: center; +} +.steps { + counter-reset: step; + list-style: none; + padding: 0; + margin: 0 0 1.25rem; +} +.steps li { + counter-increment: step; + position: relative; + padding-left: 2.1rem; + margin-bottom: 0.6rem; +} +.steps li::before { + content: counter(step); + position: absolute; + left: 0; + top: 0; + width: 1.5rem; + height: 1.5rem; + display: grid; + place-items: center; + border-radius: 50%; + background: var(--accent-soft); + color: var(--accent); + font-size: 0.8rem; + font-weight: 700; +} +.back { + display: inline-flex; + align-items: center; + gap: 0.35rem; + color: var(--muted); + font-size: 0.9rem; + margin-bottom: 1rem; } diff --git a/server/templates/account.html b/server/templates/account.html index fe677ce..8d86c04 100644 --- a/server/templates/account.html +++ b/server/templates/account.html @@ -1,56 +1,147 @@ - - - - - - Your Account + {{ template "meta" }} + Your account · {{ .Repo.Handle }} - -
-

Welcome, {{ .Repo.Handle }}

- - {{ if gt (len .Accounts) 1 }} -
- - - + + +
{{ end }} - - - + {{ end }} + + + Add another account + Sign out of {{ .Repo.Handle }} + + + + +
+ {{ template "flashes" .flashes }} + +
+
+ +
+

@{{ .Repo.Handle }}

+ {{ .Repo.Repo.Did }} + {{ if .Repo.Repo.Email }}
{{ .Repo.Repo.Email }}
{{ end }} +
+
+ +
+ + {{ if and (eq .TwoFactorMethods 0) (not .EmailTwoFactor) }} +

Security

+
+
+
+

Protect your account with two-factor authentication

+

Require a code from an authenticator app or a YubiKey when you sign in.

+
+ Set up +
+
{{ end }} - {{ if .flashes.successes }} -
-

{{ index .flashes.successes 0 }}

-
- {{ end }} {{ if eq (len .Tokens) 0 }} -
diff --git a/server/templates/alert.html b/server/templates/alert.html deleted file mode 100644 index 0621f74..0000000 --- a/server/templates/alert.html +++ /dev/null @@ -1,4 +0,0 @@ - - diff --git a/server/templates/authorize.html b/server/templates/authorize.html index cf28211..8a96894 100644 --- a/server/templates/authorize.html +++ b/server/templates/authorize.html @@ -1,62 +1,92 @@ - - - - - - Application Authorization + {{ template "meta" }} + Allow {{ .AppName }} to access your account? - -
-

Authorizing with {{ .AppName }}

-

- You are signed in as {{ .Handle }}. - Sign out this account -

- {{ if and (not .HasLoginHint) (gt (len .Accounts) 1) }} -
- - - - + + + +
+ {{ end }} + {{ end }} + + {{ end }} + + Sign in to another account + Sign out of {{ .Handle }} + + {{ end }} - - - - {{ end }} -

- Need a different account? Sign in another account. -

-

{{ .AppName }} is asking for you to grant it these scopes:

-
    - {{ range .Scopes }} -
  • {{.}}
  • - {{ end }} -
-

- If you press Accept, the application will be granted permissions for - these scopes with your account {{ .Handle }}. If you reject, you - will be sent back to the application. -

-
-
- - -
-
+ +
This will allow {{ .AppName }} to:
+
    + {{ range .Permissions }} +
  • + +
    +
    {{ .Title }}
    + {{ if .Detail }}
    {{ .Detail }}
    {{ end }} +
    +
  • + {{ end }} +
+ +
+ Show requested scopes +
+ {{ range .Scopes }}{{ . }}{{ end }} +
+
+ +
+ +
+ + +
+
+

+ You can sign {{ .AppName }} out at any time from your account page. +

+
diff --git a/server/templates/message.html b/server/templates/message.html new file mode 100644 index 0000000..36e1144 --- /dev/null +++ b/server/templates/message.html @@ -0,0 +1,17 @@ + + + + {{ template "meta" }} + {{ .Title }} + + +
{{ template "brand" .Hostname }}
+
+
+

{{ .Title }}

+

{{ .Message }}

+

Go to your account

+
+
+ + diff --git a/server/templates/partials.html b/server/templates/partials.html new file mode 100644 index 0000000..28320f6 --- /dev/null +++ b/server/templates/partials.html @@ -0,0 +1,31 @@ +{{ define "meta" }} + + + + + +{{ end }} + +{{ define "brand" }} + + {{ template "logo" }} + {{ if . }}{{ . }}{{ else }}cocoon{{ end }} + +{{ end }} + +{{ define "logo" }}{{ end }} + +{{ define "icon-phone" }}{{ end }} + +{{ define "icon-key" }}{{ end }} + +{{ define "flashes" }} +{{ if . }} +{{ range .errors }} + +{{ end }} +{{ range .successes }} +

{{ . }}

+{{ end }} +{{ end }} +{{ end }} diff --git a/server/templates/signin.html b/server/templates/signin.html index ab5dbaa..6df1172 100644 --- a/server/templates/signin.html +++ b/server/templates/signin.html @@ -1,47 +1,81 @@ - - - - - - PDS Authentication + {{ template "meta" }} + Sign in{{ if .AppName }} to continue to {{ .AppName }}{{ end }} - -
-

Sign into your account

-

Enter your handle and password below.

- {{ if gt (len .Accounts) 0 }} -

You currently have {{ len .Accounts }} signed-in account(s).

- {{ end }} - {{ if .flashes.errors }} -
-

{{ index .flashes.errors 0 }}

-
- {{ end }} -
- -
- - {{ if .flashes.tokenrequired }} -
- + +
{{ template "brand" .Hostname }}
+
+
+
+ {{ if .AppName }} + +

Sign in to continue

+

to {{ .AppName }}

+ {{ else }} +
{{ template "logo" }}
+

Sign in

+

Use your {{ if .Hostname }}{{ .Hostname }} {{ end }}handle or email.

+ {{ end }} +
+ + {{ template "flashes" .flashes }} + + {{ if and .QueryParams .Accounts }} +
Continue as
+
    + {{ range .Accounts }} +
  • + + + + + +
  • + + {{ end }} +
+
Or sign in to another account
{{ end }} - - - + +
+
+ + +
+
+ + +
+ + +
+
+ {{ if and (not .QueryParams) .Accounts }} +

Back to your account

+ {{ end }}
diff --git a/server/templates/signin_verify.html b/server/templates/signin_verify.html new file mode 100644 index 0000000..a86a4a9 --- /dev/null +++ b/server/templates/signin_verify.html @@ -0,0 +1,61 @@ + + + + {{ template "meta" }} + Enter your code + + +
{{ template "brand" .Hostname }}
+
+
+
+ +

Two-factor authentication

+

+ Signing in as {{ .Handle }}{{ if .AppName }} to continue to {{ .AppName }}{{ end }}. +

+
+ + {{ template "flashes" .flashes }} + +
+
+ + +
+

+ {{ if .EmailCode }} + We sent a code to the email address on your account. It expires in ten minutes. + {{ else if .HasYubiKey }} + Click the box and touch your YubiKey{{ if .HasTOTP }}, or type a code from your authenticator app{{ end }}. + You can also use a backup code. + {{ else }} + Open your authenticator app and enter the 6-digit code. You can also use a backup code. + {{ end }} +

+ +
+ + {{ if .EmailCode }} +
+ +
+ {{ end }} +
+

Not you? Sign in with a different account

+
+ + diff --git a/server/templates/two_factor.html b/server/templates/two_factor.html index 76d017a..b32cf74 100644 --- a/server/templates/two_factor.html +++ b/server/templates/two_factor.html @@ -1,71 +1,111 @@ - - - - - + {{ template "meta" }} Two-factor authentication - -
-

← Back to your account

-

Two-factor authentication

- {{ if .flashes.errors }} -
-

{{ index .flashes.errors 0 }}

+ +
{{ template "brand" .Hostname }}
+
+ ← Back to your account +
+
+

Two-factor authentication

+

Require a second step when {{ .Repo.Handle }} signs in.

+
+ {{ if .HasCredentials }} + On + {{ else if .EmailTwoFactor }} + Email codes + {{ else }} + Off + {{ end }}
- {{ end }} - {{ if .flashes.successes }} -
-

{{ index .flashes.successes 0 }}

-
- {{ end }} + + {{ template "flashes" .flashes }} {{ if .HasCredentials }} -

- Signing in to {{ .Repo.Handle }} needs a code from one of the methods - below. In the Bluesky app, enter it in the box that asks for an emailed - code. No emailed code is sent. -

- {{ range .Credentials }} -
-

{{ .Name }}

-

{{ .Type }} · added {{ .CreatedAt }} · last used {{ .LastUsedAt }}

-
- Remove -
- +
+

+ Signing in needs a code from one of the methods below. In the Bluesky + app, enter it in the box that asks for an emailed code. No email is sent. +

+
+ +

Your sign-in methods

+
+
    + {{ range .Credentials }} +
  • + +
    +
    {{ .Name }}
    +
    {{ .Type }} · added {{ .CreatedAt }} · last used {{ .LastUsedAt }}
    +
    +
    + Remove + + + + + + +
    + +
    +
  • + {{ end }} +
+
+ +

Backup codes

+
+
+
+

{{ .BackupCodesRemaining }} unused backup {{ if eq .BackupCodesRemaining 1 }}code{{ else }}codes{{ end }} left

+

Each works once, in place of a code, if you lose your authenticator or YubiKey.

+
+ {{ if le .BackupCodesRemaining 3 }}Running low{{ end }} +
+
+ Create new backup codes +
+

This replaces every existing backup code.

+ + - +
-
- {{ end }} - -

Backup codes

-

{{ .BackupCodesRemaining }} unused backup code(s) left. Each works once, in place of a code.

-
- Create new backup codes -
- - - -
-
+ {{ else if .EmailTwoFactor }} -

Signing in currently needs a code sent to your email. Adding an authenticator app or YubiKey replaces emailed codes.

+
+

Signing in currently needs a code sent to your email. Adding an authenticator app or YubiKey replaces emailed codes.

+
{{ else }} -

Two-factor authentication is off.

+
+

Two-factor authentication is off. Anyone with your password can sign in. Add a method below to turn it on.

+
{{ end }} -

Add a sign-in method

- +

Add a sign-in method

+
diff --git a/server/templates/two_factor_backup_codes.html b/server/templates/two_factor_backup_codes.html index 53e8442..ac3f241 100644 --- a/server/templates/two_factor_backup_codes.html +++ b/server/templates/two_factor_backup_codes.html @@ -1,27 +1,24 @@ - - - - - + {{ template "meta" }} Backup codes - -
-

Save your backup codes

-
-

{{ .Message }}

-
-

- If you lose your authenticator or YubiKey, each of these codes lets you - sign in once. They won't be shown again, and emailed codes can't be - used instead, so store them somewhere safe. -

-
{{ range .Codes }}{{ . }}
-{{ end }}
-

I've saved them

+ +
{{ template "brand" .Hostname }}
+
+
+

Save your backup codes

+

{{ .Message }}

+

+ If you lose your authenticator or YubiKey, each of these codes lets you + sign in once. They won't be shown again, and emailed codes can't be + used instead, so store them somewhere safe. +

+
{{ range .Codes }}{{ . }} +{{ end }}
+ I've saved them +
diff --git a/server/templates/two_factor_totp.html b/server/templates/two_factor_totp.html index 0c1d7cd..3419dae 100644 --- a/server/templates/two_factor_totp.html +++ b/server/templates/two_factor_totp.html @@ -1,57 +1,62 @@ - - - - - + {{ template "meta" }} Add an authenticator app - -
-

← Back

-

Add an authenticator app

- {{ if .Error }} -
-

{{ .Error }}

-
- {{ end }} - {{ if .flashes.errors }} -
-

{{ index .flashes.errors 0 }}

-
- {{ end }} - {{ if .flashes.successes }} -
-

{{ index .flashes.successes 0 }}

-
- {{ end }} - {{ if .EmailOnly }} -

- Your account currently uses emailed sign-in codes. Adding this replaces - them, so first confirm you can read your email. -

-
- - -
- {{ end }} -

Scan this QR code with your authenticator app, then enter the 6-digit code it shows.

-

QR code for your authenticator app

-

Can't scan it? Enter this key instead: {{ .Secret }}

-
- - - - + +
{{ template "brand" .Hostname }}
+
+ ← Back +
+

Add an authenticator app

+ {{ if .Error }} + + {{ end }} + {{ template "flashes" .flashes }} + {{ if .EmailOnly }} - - {{ else if .HasCredentials }} - +
+
+

+ Your account currently uses emailed sign-in codes. Adding this + replaces them, so first confirm you can read your email. +

+ + + + +
+
{{ end }} - - + +
    +
  1. Scan this QR code with your authenticator app.
  2. +
+
QR code for your authenticator app
+

Can't scan it? Enter this key instead: {{ .Secret }}

+ +
    +
  1. Enter the 6-digit code it shows, and confirm it's you.
  2. +
+
+ + + + + + + + {{ if .EmailOnly }} + + + {{ else if .HasCredentials }} + + + {{ end }} + +
+
diff --git a/server/templates/two_factor_yubikey.html b/server/templates/two_factor_yubikey.html index 28206e4..a1b189b 100644 --- a/server/templates/two_factor_yubikey.html +++ b/server/templates/two_factor_yubikey.html @@ -1,90 +1,95 @@ - - - - - + {{ template "meta" }} Add a YubiKey - -
-

← Back

-

Add a YubiKey

- {{ if .Error }} -
-

{{ .Error }}

-
- {{ end }} - {{ if .flashes.errors }} -
-

{{ index .flashes.errors 0 }}

-
- {{ end }} - {{ if .flashes.successes }} -
-

{{ index .flashes.successes 0 }}

-
- {{ end }} - {{ if .EmailOnly }} -

- Your account currently uses emailed sign-in codes. Adding this replaces - them, so first confirm you can read your email. -

-
- - -
- {{ end }} - {{ if .YubiCloud }} -

- Fill in the fields below, then click in the YubiKey box and touch your - key. It types a code and submits the form. Signing in works the same - way: touch your key in the box that asks for a code. -

-
- - - {{ if .EmailOnly }} - - {{ else if .HasCredentials }} - + +
{{ template "brand" .Hostname }}
+
+ ← Back +
+

Add a YubiKey

+ {{ if .Error }} + {{ end }} - - - - {{ else }} -

- This server isn't set up to check YubiKeys with Yubico, so your key - needs a slot programmed with a secret key only you and this server - know. With - YubiKey Manager - installed, run: -

-
ykman otp yubiotp --serial-public-id --generate-private-id --generate-key 2
-

- This programs slot 2 (used with a long touch) and leaves slot 1 as it - is. Copy the printed secret key below. Don't use --upload. -

-

- Server operators: set COCOON_YUBICO_CLIENT_ID and - COCOON_YUBICO_API_KEY to let people add a YubiKey just by - touching it. -

-
- - - + {{ template "flashes" .flashes }} + {{ if .EmailOnly }} - - {{ else if .HasCredentials }} - +
+
+

+ Your account currently uses emailed sign-in codes. Adding this + replaces them, so first confirm you can read your email. +

+ + + + +
+
+ {{ end }} + + {{ if .YubiCloud }} +

+ Fill in the fields below, then click in the YubiKey box and touch your + key. It types a code and submits the form. Signing in works the same + way: touch your key in the box that asks for a code. +

+
+ + + + + {{ if .EmailOnly }} + + + {{ else if .HasCredentials }} + + + {{ end }} + + + +
+ {{ else }} +

+ This server isn't set up to check YubiKeys with Yubico, so your key + needs a slot programmed with a secret key only you and this server + know. With + YubiKey Manager + installed, run: +

+
ykman otp yubiotp --serial-public-id --generate-private-id --generate-key 2
+

+ This programs slot 2 (used with a long touch) and leaves slot 1 as it + is. Copy the printed secret key below. Don't use --upload. +

+

+ Server operators: set COCOON_YUBICO_CLIENT_ID and + COCOON_YUBICO_API_KEY to let people add a YubiKey just by + touching it. +

+
+ + + + + + + {{ if .EmailOnly }} + + + {{ else if .HasCredentials }} + + + {{ end }} + + + +
{{ end }} - - - - {{ end }} +
diff --git a/server/templates_xss_test.go b/server/templates_xss_test.go index 178c7a5..0fd5368 100644 --- a/server/templates_xss_test.go +++ b/server/templates_xss_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + "github.com/haileyok/cocoon/models" "github.com/labstack/echo/v4" ) @@ -81,29 +82,32 @@ func TestAuthorizePageEscapesUserHandle(t *testing.T) { // The account management page renders the client_name of every authorized // OAuth client (persisted XSS surface), plus token and IP data. func TestAccountPageEscapesClientName(t *testing.T) { + repo := &models.RepoActor{Repo: models.Repo{Did: "did:plc:abc"}, Actor: models.Actor{Did: "did:plc:abc", Handle: "alice.pds.test"}} + xss := `` out := renderWithProductionRenderer(t, "account.html", map[string]any{ - "Repo": map[string]any{ - "Actor": map[string]any{"Handle": "alice.pds.test"}, - "Repo": map[string]any{"Did": "did:plc:abc"}, - }, - "Tokens": []map[string]string{ - { - "ClientName": ``, - "Age": "1 minute", - "LastUpdated": "1 minute", - "ExpiresIn": "1 hour", - "Token": "secret-token", - "Ip": "127.0.0.1", - }, - }, - "flashes": map[string]any{"errors": []string{}, "successes": []string{}, "tokenrequired": []string{}}, - "Accounts": []any{}, - "ActiveDid": "did:plc:abc", + "Repo": repo, + "Apps": []accountApp{{ + ClientID: `https://evil.example/">`, + Name: xss, + Host: "evil.example", + Initial: "<", + URI: safeClientURI("javascript:alert(1)"), + Sessions: []accountSession{{ID: 1, IP: xss, LastActiveAgo: "1 minute", SignedInAgo: "1 minute", ExpiresIn: "1 hour"}}, + }}, + "SessionCount": 1, + "TwoFactorMethods": int64(0), + "EmailTwoFactor": false, + "flashes": map[string]any{"errors": []any{}, "successes": []any{}}, + "Accounts": []models.RepoActor{*repo}, + "ActiveDid": "did:plc:abc", }) if strings.Contains(out, "