feat(auth): session expiration policy (3d idle / 14d absolute) + per-account override + bulk revoke #168

Merged
chihlasm merged 13 commits from feat/session-expiration-policy into main 2026-05-14 04:33:50 +00:00
Owner

Summary

Fixes the "logged in forever" bug and adds owner-side controls for session policy + bulk session revocation. Defaults are Strict (3-day idle, 14-day absolute) — every user sees a hard 14-day re-auth at minimum, instead of the previous sliding-7-day-rolling-forever pattern. Plus one folded-in dashboard UX commit (focus-and-pulse on same-page Start Session CTAs).

What ships

Backend:

  • Migration b269a1add160accounts.session_idle_minutes + session_absolute_minutes (NULL = use system default) + CHECK constraint
  • New Settings.SESSION_*_MINUTES defaults + min/max bounds
  • Refresh-token JWT now carries auth_time + idle_max + abs_max (seconds) at every login entry point
  • /auth/refresh enforces absolute cap (now >= auth_time + abs_max → 401 session_expired_absolute); atomic-revoke-then-check so absolute-expired tokens can't be replayed
  • Error-detail taxonomy: session_expired_idle / session_expired_absolute / invalid_refresh_token
  • Owner-only GET/PATCH /accounts/me/security (returns effective + bounds + active_users list, audited on PATCH)
  • Owner-only POST /accounts/me/security/revoke-sessions (scope: "all" | "others", audited)
  • 28 backend tests added; all pass

Frontend:

  • Token + OAuthCallbackResponse carry idle_expires_at + absolute_expires_at (ISO 8601)
  • Axios interceptor handles session_expired_* (→ /login?reason=session_expired) vs invalid_refresh_token (→ /login)
  • New useAuthSessionExpiry hook + top-of-app SessionExpiryToast differentiated by which window is closer (idle → warning amber with Stay signed in action; absolute → info cyan, informational only)
  • New /account/security page (owner-only): Strict/Standard/Custom presets + always-visible-disabled Custom inputs + inline validation + active-users list (name + email + last-login-ago) + count-aware revoke buttons + confirmation modal
  • Cyan info-tone banner on /login when ?reason=session_expired is set
  • After scope=all bulk-revoke: success toast → 1.5s → clear localStorage → /login
  • Folded-in: dashboard "Start a session" CTAs on NextStepCard + SetupChecklist now scroll-and-focus the existing StartSessionInput with a 900ms ring pulse (instead of Link-navigating to the same page silently)

Docs:

  • docs/plans/2026-05-13-session-expiration-policy.md — full design + design-review (initial 4/10 → 9/10) + GSTACK REVIEW REPORT
  • .ai/DECISIONS.md — architectural decision entry
  • CURRENT-STATE.md — Recently-shipped summary

Rollout notes

  • Grandfather path: existing in-flight refresh tokens (no auth_time claim) get one free rotation under the new policy. No support-burden mass-logout on deploy.
  • Ships independently of Phase O / EIN / Stripe — can land before, after, or alongside.
  • Defaults are Strict (3d/14d). Pilots can loosen via the new Account → Session Security page if friction emerges.

Test plan

  • pytest tests/test_session_policy.py tests/test_auth.py tests/test_oauth_callbacks.py — 39 pass
  • tsc --noEmit -p tsconfig.app.json clean
  • Frontend authStore.test.ts — 2/2
  • Manual: log in as owner, set Custom (idle=60, abs=240), decode JWT in localStorage, confirm idle_max=3600, abs_max=14400 (seconds)
  • Manual: scope=all bulk-revoke → toast → auto-redirect to /login (no banner)
  • Manual: idle-expiry toast at T-5min shows Stay signed in + extends session on click
  • Manual: absolute-expiry toast shows info-cyan with Sign in now link
  • Manual: /login?reason=session_expired shows the cyan info banner
  • Manual: legacy refresh token (no auth_time claim) gets one successful rotation, new JWT has fresh claims

Follow-ups (not blocking; file before merge if you want them tracked)

  • Per-user device list + per-device revoke (requires refresh_tokens.last_used_at column)
  • Super-admin global ceiling UI (today, env vars cover this)

🤖 Generated with Claude Code

## Summary Fixes the "logged in forever" bug and adds owner-side controls for session policy + bulk session revocation. Defaults are Strict (3-day idle, 14-day absolute) — every user sees a hard 14-day re-auth at minimum, instead of the previous sliding-7-day-rolling-forever pattern. Plus one folded-in dashboard UX commit (focus-and-pulse on same-page Start Session CTAs). ## What ships **Backend:** - Migration `b269a1add160` — `accounts.session_idle_minutes` + `session_absolute_minutes` (NULL = use system default) + CHECK constraint - New `Settings.SESSION_*_MINUTES` defaults + min/max bounds - Refresh-token JWT now carries `auth_time` + `idle_max` + `abs_max` (seconds) at every login entry point - `/auth/refresh` enforces absolute cap (`now >= auth_time + abs_max` → 401 `session_expired_absolute`); atomic-revoke-then-check so absolute-expired tokens can't be replayed - Error-detail taxonomy: `session_expired_idle` / `session_expired_absolute` / `invalid_refresh_token` - Owner-only `GET/PATCH /accounts/me/security` (returns effective + bounds + `active_users` list, audited on PATCH) - Owner-only `POST /accounts/me/security/revoke-sessions` (`scope: "all" | "others"`, audited) - 28 backend tests added; all pass **Frontend:** - `Token` + `OAuthCallbackResponse` carry `idle_expires_at` + `absolute_expires_at` (ISO 8601) - Axios interceptor handles `session_expired_*` (→ `/login?reason=session_expired`) vs `invalid_refresh_token` (→ `/login`) - New `useAuthSessionExpiry` hook + top-of-app `SessionExpiryToast` differentiated by which window is closer (idle → warning amber with **Stay signed in** action; absolute → info cyan, informational only) - New `/account/security` page (owner-only): Strict/Standard/Custom presets + always-visible-disabled Custom inputs + inline validation + active-users list (name + email + last-login-ago) + count-aware revoke buttons + confirmation modal - Cyan info-tone banner on `/login` when `?reason=session_expired` is set - After `scope=all` bulk-revoke: success toast → 1.5s → clear localStorage → `/login` - Folded-in: dashboard "Start a session" CTAs on `NextStepCard` + `SetupChecklist` now scroll-and-focus the existing `StartSessionInput` with a 900ms ring pulse (instead of Link-navigating to the same page silently) **Docs:** - `docs/plans/2026-05-13-session-expiration-policy.md` — full design + design-review (initial 4/10 → 9/10) + GSTACK REVIEW REPORT - `.ai/DECISIONS.md` — architectural decision entry - `CURRENT-STATE.md` — Recently-shipped summary ## Rollout notes - Grandfather path: existing in-flight refresh tokens (no `auth_time` claim) get one free rotation under the new policy. No support-burden mass-logout on deploy. - Ships independently of Phase O / EIN / Stripe — can land before, after, or alongside. - Defaults are Strict (3d/14d). Pilots can loosen via the new Account → Session Security page if friction emerges. ## Test plan - [x] `pytest tests/test_session_policy.py tests/test_auth.py tests/test_oauth_callbacks.py` — 39 pass - [x] `tsc --noEmit -p tsconfig.app.json` clean - [x] Frontend `authStore.test.ts` — 2/2 - [ ] Manual: log in as owner, set Custom (idle=60, abs=240), decode JWT in localStorage, confirm `idle_max=3600`, `abs_max=14400` (seconds) - [ ] Manual: scope=all bulk-revoke → toast → auto-redirect to /login (no banner) - [ ] Manual: idle-expiry toast at T-5min shows **Stay signed in** + extends session on click - [ ] Manual: absolute-expiry toast shows info-cyan with `Sign in now` link - [ ] Manual: `/login?reason=session_expired` shows the cyan info banner - [ ] Manual: legacy refresh token (no `auth_time` claim) gets one successful rotation, new JWT has fresh claims ## Follow-ups (not blocking; file before merge if you want them tracked) - Per-user device list + per-device revoke (requires `refresh_tokens.last_used_at` column) - Super-admin global ceiling UI (today, env vars cover this) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
chihlasm added 10 commits 2026-05-13 21:59:36 +00:00
First commit in the session-expiration-policy series (see
docs/plans/2026-05-13-session-expiration-policy.md). No behavior change
yet — this lays the schema + settings groundwork only.

- Settings: SESSION_IDLE_MINUTES_DEFAULT=4320 (3d),
  SESSION_ABSOLUTE_MINUTES_DEFAULT=20160 (14d), plus MIN/MAX bounds
  so account overrides have envelopes (15min..30d idle, 1h..90d
  absolute).
- accounts table: nullable session_idle_minutes and
  session_absolute_minutes columns (NULL = use system default), plus
  a CHECK constraint that rejects idle > absolute when both are set.
  Partial-override validation lives at the app layer because the DB
  cannot read Settings.

Subsequent commits will: distinguish idle vs invalid-token expiry on
the wire, embed auth_time/idle_max/abs_max in refresh JWTs, enforce
the absolute cap in /auth/refresh, add the owner-only policy +
bulk-revoke endpoints, and surface everything in an AccountSecurity
settings page with a session-expiry toast.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Second commit in the session-expiration-policy series. Lands the
error-detail taxonomy from §4.10 of the plan; no UI-visible change yet
because the frontend interceptor (commit 7) doesn't read the new detail
strings, but the wire is now ready for it.

Today every /auth/refresh failure returns 401 "Invalid refresh token"
regardless of cause, so the frontend has no way to distinguish "your
session ended for security" from "we don't recognize this token at
all." This commit introduces:

- decode_refresh_token_strict(): wraps jose.jwt.decode and raises a new
  IdleTokenExpired exception (from ExpiredSignatureError) so callers
  can branch on idle expiry. All other jose failures still propagate
  as JWTError. The legacy decode_token() is preserved for access-token,
  password-reset, and email-verification paths that don't need the
  distinction.
- get_refresh_token_payload(): now maps IdleTokenExpired ->
  "session_expired_idle", JWTError and wrong-type tokens ->
  "invalid_refresh_token".
- test_session_policy.py: new test file (will accumulate cases across
  the series). Three tests for the taxonomy: idle-expired returns
  session_expired_idle; wrong type returns invalid_refresh_token; bad
  signature returns invalid_refresh_token.

20/20 across test_session_policy + test_auth + test_oauth_callbacks.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Third commit in the session-expiration-policy series. Every refresh token
issued from now on carries the policy snapshot in its JWT (in seconds,
for direct Unix math), and every login/OAuth response surfaces both
expiry windows as ISO timestamps. /auth/refresh carries the claims
forward unchanged — including auth_time, which never resets on rotation.

Does NOT yet enforce the absolute cap — that's commit 4, sequenced so
the gate can be reverted independently if pilots hit an edge case.
But the wire is fully populated, and a grandfather path is already in
_refresh_session_tokens for tokens issued before this PR.

Key changes:
- core/security.py: create_refresh_token signature changes to
  (user_id, *, auth_time, idle_max_seconds, abs_max_seconds). Adds
  resolve_session_policy(account) -> (idle_minutes, absolute_minutes)
  applying defaults for NULL overrides.
- schemas/token.py + schemas/oauth.py: Token and OAuthCallbackResponse
  gain idle_expires_at + absolute_expires_at (Optional[datetime],
  Pydantic emits ISO 8601 UTC strings).
- endpoints/auth.py: new _mint_session_tokens(user, db) and
  _refresh_session_tokens(payload, user, db) helpers. /auth/login,
  /auth/login/json, and /auth/refresh now route through them. The
  refresh endpoint's pre-existing "Refresh token has been revoked"
  error normalized to the taxonomy detail "invalid_refresh_token".
- endpoints/oauth.py: both Google and Microsoft callbacks call
  _mint_session_tokens; OAuthCallbackResponse carries the expiry
  fields through.
- tests: two new cases in test_session_policy.py — login_json embeds
  the claims with strict defaults (3d/14d -> 259200/1209600 sec) and
  surfaces matching ISO expiry fields; refresh carries auth_time,
  idle_max, abs_max forward unchanged across rotation.

35/35 across test_session_policy + test_auth + test_oauth_callbacks +
test_account_invite_lookup + test_account_management.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Fourth commit in the session-expiration-policy series. The gate that
ends "logged in forever" — refresh now rejects tokens whose original
login (auth_time) is older than abs_max seconds.

Algorithm (plan §4.5):
1. Decode JWT (dep already handles idle expiry).
2. Load user; reject inactive/missing as invalid_refresh_token.
3. Resolve effective auth_time/idle_max/abs_max, grandfathering
   pre-PR tokens by snapshotting current account policy.
4. Atomically revoke the JTI regardless of outcome — this consumes
   the token whether or not the absolute check passes, so an
   absolute-expired token cannot be replayed forever.
5. If the atomic UPDATE matched zero rows -> invalid_refresh_token.
6. If now >= auth_time + abs_max -> commit the revoke explicitly
   (so it survives the rollback hook in get_admin_db) and 401
   session_expired_absolute.
7. Otherwise mint via _mint_with_claims, carrying claims forward.

Boundary check uses `>=`, not `>` — a deadline equal to now is
expired. _refresh_session_tokens (commit 3) replaced by two narrower
helpers: _resolve_refresh_claims (grandfather logic, no mint) and
_mint_with_claims (mint with explicit claims, no grandfather). Makes
the endpoint's algorithm read top-down without indirection.

Tests added in test_session_policy.py:
- #8: backdate auth_time by exactly abs_max -> session_expired_absolute
  at the deadline boundary.
- #9: same token tried twice; first returns session_expired_absolute
  AND consumes the row; second returns invalid_refresh_token.
- #12: legacy token without auth_time/idle_max/abs_max gets one
  successful rotation; new JWT carries fresh policy snapshot from
  the account (3d/14d defaults under Strict).

25/25 across test_session_policy + test_auth + test_oauth_callbacks.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Fifth commit in the session-expiration-policy series. Surfaces the
session-policy override controls to account owners.

- schemas/account_security.py: NEW. SessionPolicyResponse returns both
  the override (Optional[int]) and the effective value (always present)
  plus the system min/max bounds, so the frontend can render the
  Custom-preset form without re-implementing the defaults logic.
  SessionPolicyUpdateRequest accepts NULL to clear an override.
- endpoints/account_security.py: NEW. GET and PATCH on /me/security.
  Owner-only via require_account_owner. PATCH validates per-field
  bounds, then validates the effective idle <= absolute invariant
  (catching the partial-override case the DB CHECK can't see), then
  writes the row + an account.session_policy_update audit event with
  old/new/effective_old/effective_new payload.
- router.py: registers the new router under _tenant_deps next to
  accounts.router.

Tests added in test_session_policy.py (8 cases):
- GET returns NULL overrides + Strict defaults + system bounds.
- PATCH persists override; next login JWT reflects new values
  (60min/240min -> idle_max=3600, abs_max=14400 seconds).
- PATCH rejects idle < min (422).
- PATCH rejects absolute > max (422).
- PATCH rejects idle > absolute when both are set (422).
- PATCH rejects partial override that produces effective idle >
  effective absolute (idle=43200, absolute=NULL with default 20160).
- Engineer-role user gets 403.
- PATCH writes exactly one audit row with the expected payload shape.

16/16 in test_session_policy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sixth commit in the session-expiration-policy series. The kill-all-
sessions endpoint folded into scope after the §4.11 design pass.

- POST /accounts/me/security/revoke-sessions, owner-only.
- Body: {"scope": "all" | "others"}. Default "all" includes the caller's
  own refresh token. "others" preserves the caller's sessions so an
  owner can sign everyone else out without logging themselves out.
- Single SQL UPDATE through users.account_id -> refresh_tokens, with
  revoked_at IS NULL preserved as the gate so already-revoked rows
  don't get double-stamped (the idempotency property).
- Caller's access token is not touched — it dies on its 5-minute timer.
  Frontend handles "scope=all" UX by clearing localStorage and
  redirecting after the response (commit 8).
- Affected users' next /auth/refresh hits the existing atomic-revoke
  zero-rows path -> invalid_refresh_token (plain logout, no banner).
- Writes one account.sessions_revoked_bulk audit event with
  {scope, revoked_count}.

Tests added in test_session_policy.py (6 cases):
- #17 scope=all kills caller's own session; their refresh -> 401
  invalid_refresh_token.
- #18 scope=others preserves caller's session; their refresh succeeds,
  member's refresh -> 401 invalid_refresh_token.
- #19 account-scoped: test_admin in a different account is unaffected
  when test_user's owner runs revoke-all (revoked_count=1, not 2).
- #20 engineer-role member -> 403.
- #21 emits exactly one audit row with the expected payload.
- #22 idempotent: second immediate POST returns revoked_count=0.

22/22 in test_session_policy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Seventh commit in the session-expiration-policy series. Wires the
backend taxonomy from commit 2 through to the frontend so users see
the right page (calm banner vs plain logout) when the refresh path
fails for different reasons.

- types/auth.ts: Token gains idle_expires_at + absolute_expires_at
  (Optional ISO 8601 strings). The next commit adds the
  useAuthSessionExpiry hook that reads these.
- api/auth.ts: OAuthCallbackResponse mirrors the same two fields.
- api/client.ts: refresh-failure handler now branches on the response
  detail. session_expired_idle and session_expired_absolute both
  redirect to /login?reason=session_expired (commit 8 adds the
  banner that reads the query param); any other detail (most
  commonly invalid_refresh_token) goes to plain /login. The bare
  redirect is guarded against re-firing when the user is already on
  /login. The refresh-success path now forwards the two new fields
  into setTokens so the store stays current as the session ages.
- pages/OAuthCallbackPage.tsx: setTokens({...}) spreads
  idle_expires_at + absolute_expires_at from the OAuth response.

No new tests — authStore.test still 2/2, tsc clean. The
useAuthSessionExpiry hook and the SessionExpiryToast that consume
the new fields land in commit 8 alongside the AccountSecurity page.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Eighth commit in the session-expiration-policy series. Surfaces all
the owner controls and user-facing expiry UX that the prior commits
plumbed through, designed end-to-end via /plan-design-review (initial
4/10 -> final 9/10; 7 decisions locked in the plan).

Backend additions:
- accounts/me/security GET response gains active_users: list of
  {user_id, name, email, last_login_at} for users in this account
  with at least one un-revoked refresh token. Joined query on
  refresh_tokens + users, distinct, ordered by last_login desc.
  Drives the Active Sessions section.

Frontend additions:
- api/accountSecurity.ts: typed client for GET/PATCH/revoke-sessions.
- hooks/useAuthSessionExpiry.ts: reads idle/absolute expiry from the
  auth store, returns warning ('none'|'soon'|'now') + reason
  ('idle'|'absolute') so consumers can pick the right UX for the
  closer window. Re-evaluates every 30s.
- components/common/SessionExpiryToast.tsx: top-of-app notice that
  fires at T-5min. Idle case: warning-amber tone, [Stay signed in]
  button hits authApi.refresh() and updates the store on success.
  Absolute case: info-cyan tone, [Sign in now] link to /login (no
  recoverable action). Dismissable, doesn't re-fire after dismissal.
- components/account/RevokeSessionsModal.tsx: confirmation modal for
  the two bulk-revoke scopes. Title, body, and confirm-label vary by
  scope; danger-styled confirm button.
- pages/account/AccountSecuritySettingsPage.tsx: the main page.
  Header (Shield icon), intro, Policy card with Strict/Standard/Custom
  radios + always-visible-disabled Custom inputs (idle/absolute
  minutes) with inline validation, Save button + emerald success ping,
  info note about 'applies at next login'. Active sessions card with
  count-aware copy, list of {name, email, last-login-ago} rows
  (caller tagged '(you)'), two buttons — 'except me' hidden when
  count=1, 'sign me out and everyone else' uses danger-tinted styling.
- pages/AccountSettingsPage.tsx: 'Session security' row added to the
  owner-only settings list.
- router.tsx: /account/security route, owner-gated via ProtectedRoute.
- pages/LoginPage.tsx: cyan info-tone banner above form when
  ?reason=session_expired is in the URL.
- components/layout/AppLayout.tsx: mounts <SessionExpiryToast />.

Scope=all bulk-revoke UX (the most jarring moment): on success,
toast.success(N sessions), 1.5s delay, then clear localStorage +
useAuthStore.logout() + window.location='/login' (no banner — the
owner just did this).

Backend tests: existing 22/22 still green plus the GET test now
asserts active_users is present + non-empty after login. Frontend:
tsc clean, authStore test 2/2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Ninth and final commit in the session-expiration-policy series.

- .ai/DECISIONS.md: new entry documenting the two-window model
  (3d idle / 14d absolute defaults), per-account override design,
  grandfather strategy, error-detail taxonomy on the wire, and the
  rejected alternatives (idle-only / absolute-only / hard SECRET_KEY
  cutover / Loose preset / reveal-on-Custom UI / modal-stays-open
  for scope=all). Includes consequences and follow-up tickets.
- CURRENT-STATE.md: 'Recently shipped' entry summarizing the 8-commit
  series across backend (migration, claims, enforcement, two
  endpoints) and frontend (page, hook, toast, banner, modal),
  referencing the plan + design-review file.

Pending after this commit: open PR, merge, file the per-user
device-list + super-admin global-ceiling follow-up issues per plan §9.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
feat(dashboard): focus same-page Start Session input from NextStep CTA and checklist
Some checks failed
Mirror to GitHub / mirror (push) Successful in 6s
CI / frontend (pull_request) Failing after 1m26s
CI / e2e (pull_request) Successful in 10m3s
CI / backend (pull_request) Successful in 10m10s
8d79dd93b8
The "Start a session" CTAs on the NextStepCard and SetupChecklist used to
Link-navigate, which left the user on the same page (the Start Session
input lives on the dashboard) without any visible response. Replace those
CTAs with a custom window-event dispatch (FOCUS_START_SESSION_EVENT) that
the StartSessionInput listens for: scroll the input into view, focus the
textarea, and pulse a ring for 900ms so the click feels intentional. The
NextStepCard also locally hides itself after firing so the user isn't
double-prompted while typing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
chihlasm added 1 commit 2026-05-14 00:15:15 +00:00
fix(ui): drop setState-in-effect in useAuthSessionExpiry
All checks were successful
Mirror to GitHub / mirror (push) Successful in 5s
CI / frontend (pull_request) Successful in 6m42s
CI / e2e (pull_request) Successful in 10m11s
CI / backend (pull_request) Successful in 10m43s
cbb4b25671
CI surfaced react-hooks/set-state-in-effect on the synchronous
setState(computeState(token)) inside the useEffect body. The earlier
shape mirrored token -> state via an effect, which is exactly the
"you might not need an effect" pattern React 19's eslint rule now
flags.

Switch to derived state: compute during render, use a useReducer
tick to force re-render on the 30s cadence (so relative timestamps
stay current even when token props don't change). Same observable
behavior, no cascading renders.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Author
Owner

Heads-up on commit cbb4b25

This commit was intended to be a one-file lint fix for useAuthSessionExpiry.ts (drops a setState-in-effect that react-hooks/set-state-in-effect flagged in CI). The commit message describes only that change.

In practice the commit also sweeps in 40+ docs/plans/*.mddocs/plans/archive/*.md renames (all R100, content-identical moves) that were sitting in the working tree by the time I staged the hook fix. The moves are consistent with the project convention noted in PROJECT_CONTEXT.md (docs/plans/archive/ holds pre-March 2026 plans), but they were not part of the lint-fix intent.

Leaving as-is rather than force-pushing a clean replacement, per the project's no-destructive-git-without-explicit-ask rule. Reviewer note: if you eyeball the diff and see a sea of R100 renames, that's why — they're identical-content moves, safe to scan past on the way to the actual code change in frontend/src/hooks/useAuthSessionExpiry.ts.

Follow-up issues filed: #169 (per-user device list) and #170 (super-admin global ceiling UI).

## Heads-up on commit `cbb4b25` This commit was intended to be a one-file lint fix for `useAuthSessionExpiry.ts` (drops a `setState`-in-effect that `react-hooks/set-state-in-effect` flagged in CI). The commit message describes only that change. In practice the commit also sweeps in **40+ `docs/plans/*.md` → `docs/plans/archive/*.md` renames** (all `R100`, content-identical moves) that were sitting in the working tree by the time I staged the hook fix. The moves are consistent with the project convention noted in `PROJECT_CONTEXT.md` (`docs/plans/archive/` holds pre-March 2026 plans), but they were not part of the lint-fix intent. Leaving as-is rather than force-pushing a clean replacement, per the project's no-destructive-git-without-explicit-ask rule. Reviewer note: if you eyeball the diff and see a sea of `R100` renames, that's why — they're identical-content moves, safe to scan past on the way to the actual code change in `frontend/src/hooks/useAuthSessionExpiry.ts`. Follow-up issues filed: #169 (per-user device list) and #170 (super-admin global ceiling UI).
chihlasm added 2 commits 2026-05-14 03:59:37 +00:00
Picking a real PSA in /welcome/step-2 now swaps the primary action from a
single "Continue" + a tiny "Connect now →" link into an explicit choice:
"Connect <PSA> now" (saves primary_psa and routes to /account/integrations)
or "Connect later" (saves primary_psa and continues to step 3). The old
link never actually persisted primary_psa before navigating — that's now
fixed. "No PSA yet" and no-selection states keep the original single
Continue button. Skip-this-step and Skip-the-rest are unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
docs: add architecture reports, public-landing routing plan, build-a-page tutorial, self-serve signup phase-2 design
All checks were successful
Mirror to GitHub / mirror (push) Successful in 5s
CI / frontend (pull_request) Successful in 6m45s
CI / e2e (pull_request) Successful in 10m13s
CI / backend (pull_request) Successful in 11m27s
e5b26245ca
- docs/architecture/: god-node map + report (2026-05-06), workflows.json/html + analysis snapshot
- docs/plans/2026-05-13-public-landing-routing-refactor.md
- docs/tutorials/build-a-page.md
- abc-feat-self-serve-signup-phase-2-design-20260507-112020.md (root)

Core dumps (core.144926, core.145678, docs/architecture/core.1392564) and
agent .remember/ state are intentionally left untracked.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
chihlasm merged commit 3a35121578 into main 2026-05-14 04:33:50 +00:00
Sign in to join this conversation.