diff --git a/backend/cmd/geniusrund/main.go b/backend/cmd/geniusrund/main.go index a043762..345e0e7 100644 --- a/backend/cmd/geniusrund/main.go +++ b/backend/cmd/geniusrund/main.go @@ -82,6 +82,8 @@ func main() { FrontendURL: envCfg.FrontendURL, }) + server.SetupSessionIdleTimeout = appCfg.SetupSessionIdleTimeout + for _, e := range envCfg.DisplayEnv() { server.EnvVars = append(server.EnvVars, api.EnvVar{Name: e.Name, Value: e.Value}) } diff --git a/backend/internal/api/api_test.go b/backend/internal/api/api_test.go index f5bced1..b5d72cc 100644 --- a/backend/internal/api/api_test.go +++ b/backend/internal/api/api_test.go @@ -65,7 +65,7 @@ func doJSON(t *testing.T, handler http.Handler, method, path string, body any) * } req := httptest.NewRequest(method, path, reader) req.Header.Set("Content-Type", "application/json") - cookie, err := auth.MintSessionCookie(auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com"}, testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure) + cookie, err := auth.MintSessionCookie(auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com"}, "", testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure) if err != nil { t.Fatalf("mint test session cookie: %v", err) } @@ -1081,7 +1081,8 @@ func TestSessionLogout_PassesIDTokenHintFromSessionCookie(t *testing.T) { s, _ := newTestServerWithAuth(t, verifier) cookie, err := auth.MintSessionCookie( - auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com", IDToken: "raw-id-token-jwt"}, + auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com"}, + "raw-id-token-jwt", // travels in the cookie apart from Claims testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure, ) if err != nil { @@ -1107,8 +1108,9 @@ func TestSessionLogout_PassesIDTokenHintFromSessionCookie(t *testing.T) { func TestSessionCallback_MintsSessionCookieCarryingIDToken(t *testing.T) { verifier := &authmock.Verifier{ CallbackResult: auth.LoginResult{ - Claims: auth.Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com", IDToken: "raw-id-token-jwt"}, + Claims: auth.Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com"}, Authorized: true, + IDToken: "raw-id-token-jwt", }, } s, _ := newTestServerWithAuth(t, verifier) @@ -1131,12 +1133,12 @@ func TestSessionCallback_MintsSessionCookieCarryingIDToken(t *testing.T) { if sessionCookie == nil { t.Fatal("expected a session cookie to be set") } - claims, err := auth.ParseSessionCookie(sessionCookie, testSessionConfig.Secret) + idToken, err := auth.IDTokenFromSessionCookie(sessionCookie, testSessionConfig.Secret) if err != nil { - t.Fatalf("parse session cookie: %v", err) + t.Fatalf("read id token from session cookie: %v", err) } - if claims.IDToken != "raw-id-token-jwt" { - t.Errorf("claims.IDToken = %q, want %q", claims.IDToken, "raw-id-token-jwt") + if idToken != "raw-id-token-jwt" { + t.Errorf("cookie id token = %q, want %q", idToken, "raw-id-token-jwt") } } @@ -1177,7 +1179,7 @@ func TestRequestLoggingMiddleware_5xxLogsAtWarnLevel(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/profile", nil) req = req.WithContext(applog.WithLogger(req.Context(), logger)) - cookie, err := auth.MintSessionCookie(auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com"}, testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure) + cookie, err := auth.MintSessionCookie(auth.Claims{Sub: "test-user", Name: "Test User", Email: "test@example.com"}, "", testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure) if err != nil { t.Fatalf("mint session cookie: %v", err) } diff --git a/backend/internal/api/config_test.go b/backend/internal/api/config_test.go index ee01e76..2985110 100644 --- a/backend/internal/api/config_test.go +++ b/backend/internal/api/config_test.go @@ -36,12 +36,27 @@ func TestConfig_GetDefaultsAndEnvSnapshot(t *testing.T) { if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal: %v", err) } - if len(resp.Application) != 1 { - t.Fatalf("expected 1 app-config entry, got %d", len(resp.Application)) + if len(resp.Application) != 2 { + t.Fatalf("expected 2 app-config entries, got %d: %+v", len(resp.Application), resp.Application) } - e := resp.Application[0] - if e.Key != "session.duration" || e.Value != "720" || e.Default != "720" || e.Overridden || e.Description == "" { - t.Fatalf("unexpected default entry: %+v", e) + byKey := map[string]struct { + value, def string + overridden bool + }{} + for _, e := range resp.Application { + if e.Description == "" { + t.Errorf("entry %q has no description", e.Key) + } + byKey[e.Key] = struct { + value, def string + overridden bool + }{e.Value, e.Default, e.Overridden} + } + if e := byKey["session.duration"]; e.value != "720" || e.def != "720" || e.overridden { + t.Fatalf("session.duration default entry = %+v", e) + } + if e := byKey["session.idle_timeout"]; e.value != "15" || e.def != "15" || e.overridden { + t.Fatalf("session.idle_timeout default entry = %+v", e) } if len(resp.Environment) != 1 || resp.Environment[0].Value != "•••• (set)" { t.Fatalf("env snapshot not passed through: %+v", resp.Environment) diff --git a/backend/internal/api/isolation_test.go b/backend/internal/api/isolation_test.go index 110d5e7..6608564 100644 --- a/backend/internal/api/isolation_test.go +++ b/backend/internal/api/isolation_test.go @@ -31,7 +31,7 @@ func doJSONAs(t *testing.T, handler http.Handler, sub, method, path string, body } req := httptest.NewRequest(method, path, reader) req.Header.Set("Content-Type", "application/json") - cookie, err := auth.MintSessionCookie(auth.Claims{Sub: sub, Name: sub, Email: sub + "@example.com"}, testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure) + cookie, err := auth.MintSessionCookie(auth.Claims{Sub: sub, Name: sub, Email: sub + "@example.com"}, "", testSessionConfig.Secret, testSessionConfig.Duration, testSessionConfig.Secure) if err != nil { t.Fatalf("mint test session cookie: %v", err) } diff --git a/backend/internal/api/server.go b/backend/internal/api/server.go index a05ece1..4e6f43e 100644 --- a/backend/internal/api/server.go +++ b/backend/internal/api/server.go @@ -53,6 +53,11 @@ type Server struct { // never call os.Getenv. EnvVars []EnvVar + // SetupSessionIdleTimeout is the app-config session.idle_timeout value + // (see internal/config); zero falls back to + // defaultSetupSessionIdleTimeout. + SetupSessionIdleTimeout time.Duration + mu sync.Mutex userClient map[int64]garmin.Client userSync map[int64]*garmin.Sync diff --git a/backend/internal/api/session.go b/backend/internal/api/session.go index 4b0e24c..6b9035a 100644 --- a/backend/internal/api/session.go +++ b/backend/internal/api/session.go @@ -82,7 +82,7 @@ func (s *Server) handleSessionCallback(w http.ResponseWriter, r *http.Request) { return } - sessionCookie, err := auth.MintSessionCookie(result.Claims, s.SessionConfig.Secret, s.SessionConfig.Duration, s.SessionConfig.Secure) + sessionCookie, err := auth.MintSessionCookie(result.Claims, result.IDToken, s.SessionConfig.Secret, s.SessionConfig.Duration, s.SessionConfig.Secure) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return @@ -93,15 +93,23 @@ func (s *Server) handleSessionCallback(w http.ResponseWriter, r *http.Request) { // handleSessionLogout clears geniusrun's own session cookie and redirects // through Keycloak's end-session endpoint, passing the session's ID token -// as id_token_hint (see auth.Claims.IDToken) so Keycloak can skip its own -// logout-confirmation prompt -- otherwise a user could cancel out of it and -// land back on the app with a Keycloak SSO session but no geniusrun profile -// (already deleted, in the profile-deletion case this exists for). +// as id_token_hint (read back from the cookie via +// auth.IDTokenFromSessionCookie -- it deliberately doesn't ride in Claims) +// so Keycloak can skip its own logout-confirmation prompt -- otherwise a +// user could cancel out of it and land back on the app with a Keycloak SSO +// session but no geniusrun profile (already deleted, in the +// profile-deletion case this exists for). func (s *Server) handleSessionLogout(w http.ResponseWriter, r *http.Request) { claims, _ := auth.ClaimsFromContext(r.Context()) s.removeSetupSession(claims.Sub) + // Best-effort: an unreadable cookie just means logging out without the + // hint, at worst showing Keycloak's own confirmation screen. + var idToken string + if cookie, err := r.Cookie(auth.SessionCookieName); err == nil { + idToken, _ = auth.IDTokenFromSessionCookie(cookie, s.SessionConfig.Secret) + } http.SetCookie(w, auth.ClearCookie(auth.SessionCookieName, s.SessionConfig.Secure)) - http.Redirect(w, r, s.Auth.EndSessionURL(s.SessionConfig.FrontendURL+"/", claims.IDToken), http.StatusFound) + http.Redirect(w, r, s.Auth.EndSessionURL(s.SessionConfig.FrontendURL+"/", idToken), http.StatusFound) } func (s *Server) handleSessionMe(w http.ResponseWriter, r *http.Request) { diff --git a/backend/internal/api/setup.go b/backend/internal/api/setup.go index 464b176..1fd80c8 100644 --- a/backend/internal/api/setup.go +++ b/backend/internal/api/setup.go @@ -13,11 +13,23 @@ import ( applog "geniusrun/backend/internal/log" ) -// setupSessionIdleTimeout bounds how long an onboarding Garmin session -// survives without being touched (login, MFA, or complete) before it's -// evicted -- long enough to check email for an MFA code, short enough that -// an abandoned attempt doesn't leave a subprocess running indefinitely. -const setupSessionIdleTimeout = 15 * time.Minute +// defaultSetupSessionIdleTimeout bounds how long an onboarding Garmin +// session survives without being touched (login, MFA, or complete) before +// it's evicted -- long enough to check email for an MFA code, short enough +// that an abandoned attempt doesn't leave a subprocess running +// indefinitely. Tunable via the session.idle_timeout application-config +// key (minutes -- distinct from session.duration, the login cookie +// lifetime); this constant is the fallback when the Server field was never +// wired (tests building a bare NewServer). +const defaultSetupSessionIdleTimeout = 15 * time.Minute + +// setupIdleTimeout returns the configured onboarding-session idle timeout. +func (s *Server) setupIdleTimeout() time.Duration { + if s.SetupSessionIdleTimeout > 0 { + return s.SetupSessionIdleTimeout + } + return defaultSetupSessionIdleTimeout +} // setupSession is a temporary, not-yet-persisted Garmin authentication // attempt made during onboarding, before any users/profile row exists -- @@ -30,7 +42,7 @@ const setupSessionIdleTimeout = 15 * time.Minute // the next time anything closes and restarts its subprocess; a later // garminFor(ctx, userID) call builds a fresh client with the correct path // instead. Otherwise evicted lazily (the next setup-endpoint touch for that -// subject checks staleness first) once idle past setupSessionIdleTimeout. +// subject checks staleness first) once idle past setupIdleTimeout(). type setupSession struct { Client garmin.Client Email, Password string @@ -213,7 +225,7 @@ func (s *Server) setupSessionFor(sub string) (*setupSession, bool) { if !ok { return nil, false } - if time.Since(sess.LastUsed) > setupSessionIdleTimeout { + if time.Since(sess.LastUsed) > s.setupIdleTimeout() { sess.Client.Close() delete(s.setupSession, sub) if s.ClientConfig.TokenStorePath != "" { diff --git a/backend/internal/auth/middleware_test.go b/backend/internal/auth/middleware_test.go index 2a4de0d..f090416 100644 --- a/backend/internal/auth/middleware_test.go +++ b/backend/internal/auth/middleware_test.go @@ -28,7 +28,7 @@ func TestRequireSession_NoCookie(t *testing.T) { } func TestRequireSession_ValidCookie(t *testing.T) { - cookie, err := MintSessionCookie(Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com"}, []byte(testSecret), time.Hour, false) + cookie, err := MintSessionCookie(Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com"}, "", []byte(testSecret), time.Hour, false) if err != nil { t.Fatalf("mint: %v", err) } @@ -45,7 +45,7 @@ func TestRequireSession_ValidCookie(t *testing.T) { } func TestRequireSession_ExpiredCookie(t *testing.T) { - cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), -time.Hour, false) + cookie, err := MintSessionCookie(Claims{Sub: "u1"}, "", []byte(testSecret), -time.Hour, false) if err != nil { t.Fatalf("mint: %v", err) } @@ -59,7 +59,7 @@ func TestRequireSession_ExpiredCookie(t *testing.T) { } func TestRequireSession_TamperedCookie(t *testing.T) { - cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), time.Hour, false) + cookie, err := MintSessionCookie(Claims{Sub: "u1"}, "", []byte(testSecret), time.Hour, false) if err != nil { t.Fatalf("mint: %v", err) } diff --git a/backend/internal/auth/oidc.go b/backend/internal/auth/oidc.go index 50c389a..e74c16f 100644 --- a/backend/internal/auth/oidc.go +++ b/backend/internal/auth/oidc.go @@ -36,6 +36,10 @@ type Verifier interface { type LoginResult struct { Claims Claims Authorized bool + // IDToken is the raw Keycloak ID token JWT, kept apart from Claims: + // it's only ever needed once more, at logout (id_token_hint), so it + // rides in the session cookie but never in the per-request Claims. + IDToken string } // OIDCConfig configures NewOIDCVerifier. RequiredRole is checked against the @@ -135,8 +139,9 @@ func (v *oidcVerifier) HandleCallback(ctx context.Context, txn TxnState, query u return LoginResult{}, fmt.Errorf("decode id_token claims: %w", err) } return LoginResult{ - Claims: Claims{Sub: claims.Sub, Name: claims.Name, Email: claims.Email, IDToken: rawIDToken}, + Claims: Claims{Sub: claims.Sub, Name: claims.Name, Email: claims.Email}, Authorized: claims.hasRole(v.requiredRole), + IDToken: rawIDToken, }, nil } diff --git a/backend/internal/auth/session.go b/backend/internal/auth/session.go index c251341..c14f079 100644 --- a/backend/internal/auth/session.go +++ b/backend/internal/auth/session.go @@ -5,8 +5,10 @@ // tokens are never stored or refreshed -- once HandleCallback verifies the // ID token and role, only this package's own cookie matters for subsequent // requests. The one exception is the raw ID token itself, carried opaquely -// inside the signed session cookie (Claims.IDToken) solely so a later -// logout can pass it back to Keycloak as id_token_hint -- letting Keycloak +// inside the signed session cookie (apart from Claims -- see +// MintSessionCookie's idToken parameter and IDTokenFromSessionCookie) +// solely so a later logout can pass it back to Keycloak as +// id_token_hint -- letting Keycloak // skip its own logout-confirmation prompt for a session it can positively // identify, rather than leaving the user a chance to cancel out of it after // their geniusrun account (and profile) is already gone. @@ -32,14 +34,15 @@ const ( ) // Claims identifies the authenticated user, carried in the signed session -// cookie. IDToken is the raw Keycloak ID token JWT from the OIDC callback, -// carried opaquely so a later logout can pass it back to Keycloak as -// id_token_hint (see EndSessionURL) -- geniusrun never inspects it itself. +// cookie and stashed in every request's context. Deliberately does NOT +// carry the raw Keycloak ID token: that JWT lives in the cookie payload +// separately (see MintSessionCookie/IDTokenFromSessionCookie) because it's +// only ever needed once more, at logout, and has no business riding +// through every handler's context. type Claims struct { - Sub string - Name string - Email string - IDToken string + Sub string + Name string + Email string } type sessionClaims struct { @@ -65,13 +68,15 @@ type txnClaims struct { // MintSessionCookie signs claims into a JWT valid for duration and wraps it // in a cookie. secure should be true whenever the app is served over HTTPS. -func MintSessionCookie(claims Claims, secret []byte, duration time.Duration, secure bool) (*http.Cookie, error) { +// idToken is the raw Keycloak ID token to carry for the eventual logout's +// id_token_hint (empty is fine, e.g. in tests -- the hint is optional). +func MintSessionCookie(claims Claims, idToken string, secret []byte, duration time.Duration, secure bool) (*http.Cookie, error) { now := time.Now() token := jwt.NewWithClaims(jwt.SigningMethodHS256, sessionClaims{ Sub: claims.Sub, Name: claims.Name, Email: claims.Email, - IDToken: claims.IDToken, + IDToken: idToken, RegisteredClaims: jwt.RegisteredClaims{ IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(duration)), @@ -102,7 +107,21 @@ func ParseSessionCookie(cookie *http.Cookie, secret []byte) (Claims, error) { if _, err := jwt.ParseWithClaims(cookie.Value, &sc, keyfunc(secret), jwt.WithValidMethods([]string{"HS256"})); err != nil { return Claims{}, fmt.Errorf("parse session token: %w", err) } - return Claims{Sub: sc.Sub, Name: sc.Name, Email: sc.Email, IDToken: sc.IDToken}, nil + return Claims{Sub: sc.Sub, Name: sc.Name, Email: sc.Email}, nil +} + +// IDTokenFromSessionCookie verifies the cookie and returns the raw Keycloak +// ID token it carries, for logout's id_token_hint. Only the logout handler +// needs this -- everything else uses ParseSessionCookie's Claims. +func IDTokenFromSessionCookie(cookie *http.Cookie, secret []byte) (string, error) { + if cookie == nil { + return "", fmt.Errorf("no session cookie") + } + var sc sessionClaims + if _, err := jwt.ParseWithClaims(cookie.Value, &sc, keyfunc(secret), jwt.WithValidMethods([]string{"HS256"})); err != nil { + return "", fmt.Errorf("parse session token: %w", err) + } + return sc.IDToken, nil } // MintTxnCookie signs an OIDC login transaction into a short-lived cookie. diff --git a/backend/internal/auth/session_test.go b/backend/internal/auth/session_test.go index 702e963..71ed7f7 100644 --- a/backend/internal/auth/session_test.go +++ b/backend/internal/auth/session_test.go @@ -8,8 +8,8 @@ import ( const testSecret = "test-secret-at-least-32-bytes-long!" func TestMintAndParseSessionCookie(t *testing.T) { - claims := Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com", IDToken: "raw-id-token-jwt"} - cookie, err := MintSessionCookie(claims, []byte(testSecret), time.Hour, true) + claims := Claims{Sub: "u1", Name: "Alice", Email: "alice@example.com"} + cookie, err := MintSessionCookie(claims, "raw-id-token-jwt", []byte(testSecret), time.Hour, true) if err != nil { t.Fatalf("mint: %v", err) } @@ -24,10 +24,20 @@ func TestMintAndParseSessionCookie(t *testing.T) { if got != claims { t.Fatalf("got %+v, want %+v", got, claims) } + + // The ID token rides in the cookie apart from Claims, retrievable only + // through the dedicated logout-path helper. + idToken, err := IDTokenFromSessionCookie(cookie, []byte(testSecret)) + if err != nil { + t.Fatalf("IDTokenFromSessionCookie: %v", err) + } + if idToken != "raw-id-token-jwt" { + t.Fatalf("idToken = %q, want raw-id-token-jwt", idToken) + } } func TestParseSessionCookie_Expired(t *testing.T) { - cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), -time.Hour, false) + cookie, err := MintSessionCookie(Claims{Sub: "u1"}, "", []byte(testSecret), -time.Hour, false) if err != nil { t.Fatalf("mint: %v", err) } @@ -37,7 +47,7 @@ func TestParseSessionCookie_Expired(t *testing.T) { } func TestParseSessionCookie_Tampered(t *testing.T) { - cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), time.Hour, false) + cookie, err := MintSessionCookie(Claims{Sub: "u1"}, "", []byte(testSecret), time.Hour, false) if err != nil { t.Fatalf("mint: %v", err) } @@ -76,7 +86,7 @@ func flipSignatureChar(s string) string { } func TestParseSessionCookie_WrongSecret(t *testing.T) { - cookie, err := MintSessionCookie(Claims{Sub: "u1"}, []byte(testSecret), time.Hour, false) + cookie, err := MintSessionCookie(Claims{Sub: "u1"}, "", []byte(testSecret), time.Hour, false) if err != nil { t.Fatalf("mint: %v", err) } diff --git a/backend/internal/config/appconfig.go b/backend/internal/config/appconfig.go index 08a8305..0cc63db 100644 --- a/backend/internal/config/appconfig.go +++ b/backend/internal/config/appconfig.go @@ -17,7 +17,16 @@ type AppKey struct { } // KeySessionDuration is the session cookie lifetime, in integer hours. -const KeySessionDuration = "session.duration" +const ( + KeySessionDuration = "session.duration" + // KeySessionIdleTimeout bounds an *onboarding Garmin session* (the + // ephemeral, pre-account login attempt), not the login cookie -- + // session.duration is how long a signed-in user stays signed in + // (hours); session.idle_timeout is how long an unfinished setup + // attempt survives untouched before its subprocess is torn down + // (minutes). + KeySessionIdleTimeout = "session.idle_timeout" +) var appRegistry = []AppKey{ { @@ -26,6 +35,12 @@ var appRegistry = []AppKey{ Description: "Session cookie lifetime in hours", Validate: validatePositiveInt, }, + { + Key: KeySessionIdleTimeout, + Default: "15", + Description: "Idle timeout in minutes for an unfinished onboarding Garmin session", + Validate: validatePositiveInt, + }, } // AppRegistry returns every known application-configuration key, in @@ -57,7 +72,8 @@ func ValidateAppValue(key, value string) error { // AppConfig is the typed result of merging DB overrides over registry // defaults. type AppConfig struct { - SessionDuration time.Duration + SessionDuration time.Duration + SetupSessionIdleTimeout time.Duration } // LoadApp merges overrides (store.ConfigValues' map) over defaults. Pure @@ -76,5 +92,9 @@ func LoadApp(overrides map[string]string) (AppConfig, error) { merged[key] = value } hours, _ := strconv.Atoi(merged[KeySessionDuration]) - return AppConfig{SessionDuration: time.Duration(hours) * time.Hour}, nil + idleMinutes, _ := strconv.Atoi(merged[KeySessionIdleTimeout]) + return AppConfig{ + SessionDuration: time.Duration(hours) * time.Hour, + SetupSessionIdleTimeout: time.Duration(idleMinutes) * time.Minute, + }, nil } diff --git a/backend/internal/config/appconfig_test.go b/backend/internal/config/appconfig_test.go index 6694103..a19f24e 100644 --- a/backend/internal/config/appconfig_test.go +++ b/backend/internal/config/appconfig_test.go @@ -11,11 +11,14 @@ func TestLoadApp(t *testing.T) { name string overrides map[string]string want time.Duration + wantIdle time.Duration wantErr string }{ - {name: "defaults when no overrides", overrides: nil, want: 720 * time.Hour}, - {name: "override applied", overrides: map[string]string{"session.duration": "168"}, want: 168 * time.Hour}, + {name: "defaults when no overrides", overrides: nil, want: 720 * time.Hour, wantIdle: 15 * time.Minute}, + {name: "override applied", overrides: map[string]string{"session.duration": "168"}, want: 168 * time.Hour, wantIdle: 15 * time.Minute}, + {name: "idle timeout override applied", overrides: map[string]string{"session.idle_timeout": "30"}, want: 720 * time.Hour, wantIdle: 30 * time.Minute}, {name: "invalid stored value", overrides: map[string]string{"session.duration": "zero"}, wantErr: "session.duration"}, + {name: "invalid idle timeout", overrides: map[string]string{"session.idle_timeout": "0"}, wantErr: "session.idle_timeout"}, {name: "unknown stored key", overrides: map[string]string{"bogus.key": "1"}, wantErr: "unknown configuration key"}, } for _, tt := range tests { @@ -33,6 +36,9 @@ func TestLoadApp(t *testing.T) { if got.SessionDuration != tt.want { t.Fatalf("SessionDuration = %v, want %v", got.SessionDuration, tt.want) } + if got.SetupSessionIdleTimeout != tt.wantIdle { + t.Fatalf("SetupSessionIdleTimeout = %v, want %v", got.SetupSessionIdleTimeout, tt.wantIdle) + } }) } } diff --git a/docs/IDEAS.md b/docs/IDEAS.md index 34b9198..94629f3 100644 --- a/docs/IDEAS.md +++ b/docs/IDEAS.md @@ -7,11 +7,6 @@ writing-plans flow (see `docs/superpowers/specs/` and `docs/superpowers/plans/` for that history) and remove it from here once a spec exists. ## Backlog -- quickfixes - - make setupSessionIdleTimeout an application configuration (key session.idle_timeout), check difference with appCfg.SessionDuration - - don't put id_token in Claim, store it in the cookie apart from Claim - - find alternative to deprecated React.FormEvent - - replace all standard go log calls by our application logger, - new workout kinds - add "Recovery", "Quick", and "Sprint" workout kinds - order to follow (in activities page filter buttons, in analysis page combobox, in profile page workout kinds cards) is Recovery -> Easy -> Long -> Tempo -> 60' Threshold -> 30' Threshold -> Quick -> Intervals -> MAS Test -> Sprint -> Race @@ -77,4 +72,3 @@ for that history) and remove it from here once a spec exists. ## Someday / maybe - training programs with phases (tapering, easier weeks, post-race recovery) -- improve logs (add non-REST logs, make python generate JSON logs through stdout, adapt go<->python protocol) diff --git a/frontend/src/OnboardingWizard.tsx b/frontend/src/OnboardingWizard.tsx index 3d4fab7..423976d 100644 --- a/frontend/src/OnboardingWizard.tsx +++ b/frontend/src/OnboardingWizard.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from "react"; +import type { FormEvent } from "react"; import { api } from "./api/client"; import { showError } from "./banner"; import { BannerStack } from "./components/BannerStack"; @@ -38,7 +39,7 @@ export function OnboardingWizard({ const [completeFailed, setCompleteFailed] = useState(false); const completeTriggered = useRef(false); - function submitName(e: React.FormEvent) { + function submitName(e: FormEvent) { e.preventDefault(); if (!displayName.trim()) { setValidationError("Please enter a display name."); @@ -48,7 +49,7 @@ export function OnboardingWizard({ setStep("garmin"); } - async function submitGarmin(e: React.FormEvent) { + async function submitGarmin(e: FormEvent) { e.preventDefault(); if (!email.trim() || !password) { setValidationError("Please enter your Garmin email and password.");