fix: IDEAS.md quickfixes — idle-timeout app config, id_token out of Claims, FormEvent import

session.idle_timeout (minutes, default 15) joins the app-config registry
and drives the onboarding Garmin session eviction, distinct from
session.duration (the login cookie lifetime in hours). The raw Keycloak
ID token no longer rides in auth.Claims through every request context:
it's minted into the session cookie separately and read back only by the
logout handler via IDTokenFromSessionCookie. OnboardingWizard uses the
type-imported FormEvent<HTMLFormElement> instead of the React.FormEvent
namespace alias.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 17:44:37 +02:00
parent dcbb1d8bb0
commit 8c9285f33c
15 changed files with 160 additions and 61 deletions

View File

@@ -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})
}

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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) {

View File

@@ -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 != "" {

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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.

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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)
}
})
}
}

View File

@@ -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)

View File

@@ -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<HTMLFormElement>) {
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<HTMLFormElement>) {
e.preventDefault();
if (!email.trim() || !password) {
setValidationError("Please enter your Garmin email and password.");