Commit Graph

1226 Commits

Author SHA1 Message Date
d3fd9143d7 feat(l1): adhoc walker variant with debounced notes autosave
The session variant that Phase 1 L1 users actually hit (intake creates
adhoc sessions when no flow_id is provided). Single-pane note-taking
surface with 300ms-debounced autosave to walk_notes. Shares header
shape + Resolve/Escalate modals with the tree variant. Splits the
notes textarea by paragraph and persists each as a structured
AdhocNote entry. Stops saving once status leaves 'active'.

L1WalkPage now dispatches both variants.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:22:15 -04:00
c0bddc289e feat(l1): L1WalkPage tree variant with Resolve/Escalate modals
Replaces the T20 stub. WalkPage dispatches by session_kind:
- 'flow' / 'proposal' → L1WalkTreeVariant (this commit)
- 'adhoc' → placeholder until T23

L1WalkTreeVariant: sticky header with back link + AI-built badge +
persistent Escalate/Resolve buttons; two-pane body (current step
yes/no card on left, walked-path transcript on right). ResolveModal
and EscalateModal extracted to shared WalkModals.tsx (T23 reuses).

Phase 1 caveat: this surface isn't reached by user-driven intake
(which creates adhoc sessions only). It's exercised via direct URL
or integration tests until Phase 2 wires match_or_build.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:17:02 -04:00
4e9610c252 feat(l1): real L1 dashboard with empty-state + resume widget
Replaces the T20 stub. L1 dashboard renders greeting, "Describe the
problem" intake card (autofocus textarea, optional customer fields,
primary "Start walk" CTA), open-tickets queue (Phase 1: display-only),
and a "Resume in progress" widget listing the L1's active sessions
ordered by last_step_at DESC. Empty-state card shows on accounts with
no queue + no active sessions (first-run nudge to upload KB or auth flows).

Adds /api/l1.ts (full L1 API client surface) and /types/l1.ts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:09:34 -04:00
d0561be6a1 feat(l1): register /l1/* routes + L1RouteGuard + page stubs
L1RouteGuard wraps the new routes and redirects users without
canUseL1Surface back to /. Page components are stubs in this task
(real UI in T21-T24): L1Dashboard, L1WalkPage, L1DraftsPage,
L1TicketsPage.

Routes: /l1, /l1/walk/:sessionId, /l1/drafts, /l1/tickets — all gated.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:03:26 -04:00
fbe25b3d68 feat(l1): role-based sidebar nav + L1 post-login redirect
L1 users see a focused sidebar with only their L1 surfaces (Workspace,
Tickets, My Drafts, Guides, Account). Engineers with can_cover_l1
(plus owners/super_admins) get an appended "L1 Workspace" entry in
their existing sidebar. ProtectedRoute redirects L1 users from / to /l1
on login.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:58:34 -04:00
4586010b87 feat(l1): usePermissions extensions for l1_tech + coverage flag
Adds 'l1_tech' to the AccountRole union, includes can_cover_l1 on the User
type, and exposes isL1Tech / canCoverL1 / canUseL1Surface /
canUseEngineerSurface from usePermissions. Existing isEngineer/isOwner/
etc. flags unchanged in semantics.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:54:52 -04:00
465b8ff880 test(l1): RLS regression tests for internal_tickets + l1_walk_sessions
Adds 8 synchronous psycopg2-based tests that connect as resolutionflow_app
and verify the tenant_isolation RLS policies (USING + WITH CHECK) on the two
new L1 Phase 1 tables block cross-tenant reads and reject cross-tenant INSERTs.

Uses psycopg2 (not asyncpg) to avoid the conftest pytest_runtest_teardown hook
that closes the asyncio event loop after every test — incompatible with
module-scoped asyncpg fixtures in pytest-asyncio 0.24.

conftest.py: extends _RLS_TEST_FILES set to include test_l1_rls.py so it is
excluded from the default create_all test suite (requires RUN_RLS_TESTS=1).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:49:39 -04:00
e5bcf3b28e feat(l1): APScheduler hourly cleanup job for abandoned L1 sessions
flip_stale_sessions flips L1WalkSession.status from 'active' to
'abandoned' for rows where last_step_at is older than 24h. Preserves the
row for audit; removes it from the L1 dashboard's 'Resume in progress'
widget. Runs hourly via APScheduler with max_instances=1 (Lesson 1).
Uses the admin session factory (no RLS context at startup).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:37:55 -04:00
96973c7968 feat(l1): L1 endpoint surface (intake/queue/step/notes/resolve/escalate)
Mounts /api/v1/l1/* with require_l1_or_coverage on every route. Intake
creates an internal ticket and starts a flow OR adhoc session (PSA queue
merge follows in Phase 2). Step/notes/resolve/escalate delegate to
l1_session_service. escalate-without-walk creates an immediately-
escalated session for the BuildAbortedNoKB path.

ValueError from services → 400. Cross-account session access → 404.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:33:18 -04:00
054e9da49b feat(l1): l1_session_service resolve / escalate / escalate_without_walk
resolve: sets status=resolved, helpful, resolution_notes, resolved_at;
flips FlowProposal.validated_by_outcome on helpful=True proposal walks;
closes linked internal ticket. PSA close is a Phase 2 stub.

escalate: marks session + internal ticket as escalated. PSA reassign
deferred to Phase 2.

escalate_without_walk: creates an immediately-escalated adhoc session
with no walked_path, used by the BuildAbortedNoKB → Escalate path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:25:17 -04:00
e803a78ded feat(l1): l1_session_service record_step + update_notes
record_step appends to walked_path JSONB and advances current_node_id
on flow/proposal walks; refuses adhoc sessions. update_notes replaces
walk_notes (used by adhoc walks for debounced autosave); 256KB size cap
to prevent unbounded JSONB growth. Both reject non-active sessions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:20:20 -04:00
6e7c4afc7d feat(l1): l1_session_service start_flow/proposal/adhoc
Three start_* functions creating L1WalkSession rows with appropriate
session_kind and target id. Engineers acting in L1 mode get
acting_as='l1_coverage' for audit; native l1_tech users get acting_as=None.

step/notes (T13) and resolve/escalate (T14) extend this file next.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:16:37 -04:00
44a000a723 fix(l1): make get_ticket keyword-only for consistency
T11 review caught that get_ticket was the one function without the *, marker
all other functions in the module use. One-line fix, no caller impact.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:13:55 -04:00
7a36aeb410 feat(l1): internal_ticket_service with CRUD + status transitions
create_ticket, update_status (sets resolved_at on resolve), get_ticket,
list_tickets_for_account (status filter, account-scoped), promote_to_psa.
Used by L1 intake when account has no PSA integration configured.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:11:21 -04:00
e15897c76f feat(l1): PATCH /accounts/me/members/{id}/coverage for engineer L1-coverage flag
Owner-only endpoint to toggle can_cover_l1 on an engineer user. 422 if target
role is not engineer (owners/super_admins already see L1 surface; viewers/
l1_techs don't need this flag). 404 for cross-account targets.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:07:09 -04:00
7056ed9e6d feat(l1): GET /accounts/me/seats endpoint for seat counter widget
Returns {engineer: SeatCheckResult, l1_tech: SeatCheckResult} for the
authenticated engineer's account. Powers the SeatCounterWidget UI in the
admin/users + account/users surfaces. Engineer+ access only.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 13:02:20 -04:00
8010da8745 fix(l1): T8 review fixes — oauth status const + bulk-invite structured error
- oauth.py: use status.HTTP_402_PAYMENT_REQUIRED constant (was raw 402)
- accounts.py bulk-invite: catch HTTPException separately to preserve
  structured detail dict in failed-row error (was stringified repr,
  unparseable by clients)
- Add bulk-invite per-row 402 test verifying structured error preserved

T8 code review identified these as Important issues. Functional change is
the bulk-invite fix; clients can now parse seat-limit errors from bulk
responses. 13/13 seat-enforcement tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:58:35 -04:00
47ff8ad2b5 feat(l1): enforce seat limits on invite, accept-invite, role-change
For engineer + l1_tech roles, check_seat_available is called at each
mutation point. Returns 402 Payment Required with structured detail
{code: 'seat_limit_exceeded', role, current, limit, upgrade_url} when
seats are full. Grandfathering: existing over-seated accounts keep
existing users; only new mutations are blocked.

Also updates AccountInviteCreate and AccountRoleUpdate schemas to
accept l1_tech as a valid role value.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:49:59 -04:00
02fc47c832 feat(l1): seat_enforcement service for engineer + L1 seat limits
Shared helper used by invite, accept-invite, and role-change endpoints
(integrated in T8). Counts active users by role against role-specific
seat limit on subscription (engineer → seat_limit, l1_tech → l1_seat_limit).
None limit = unlimited.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:40:48 -04:00
874dee7263 fix(l1): add index=True to L1WalkSession.last_step_at model column
Aligns the model with the migration (T6 review caught: migration creates
ix_l1_walk_sessions_last_step_at but model annotation was missing, causing
schema drift if Base.metadata.create_all is used in tests).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:37:39 -04:00
960ea71a20 feat(l1): create l1_walk_sessions table with target-consistency check + RLS
Per-session state for L1 walking a ticket. Supports flow/proposal/adhoc
session kinds; check constraint enforces target-consistency (flow_id set
iff kind=flow; flow_proposal_id set iff kind=proposal; both null iff
kind=adhoc). walked_path + walk_notes JSONB columns track step-by-step
progress; resolved/escalated/abandoned terminal statuses captured.
Account-scoped RLS matches the internal_tickets precedent (FORCE RLS +
tenant_isolation policy with COALESCE/NULLIF guard).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:35:24 -04:00
394f729595 feat(l1): create internal_tickets table with RLS
Tenant-scoped fallback ticket model for accounts without PSA integration.
Tracks customer-name, problem-statement, status lifecycle (open/walking/
resolved/escalated), and optional links to flow/proposal/ai_session/
assigned engineer + PSA promotion ID. Account-scoped RLS policy uses
app.current_account_id session setting.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:30:51 -04:00
c576c6609e feat(l1): extend FlowProposal with source/linked_ticket/validated_by_outcome
Adds source (NOT NULL, backfilled to 'manual_draft'), linked_ticket_id,
linked_ticket_kind, validated_by_outcome columns. CHECK constraints on
source values and linked_ticket_kind values. walked_path lives on the
new l1_walk_sessions table (Task 6) — NOT on FlowProposal.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:27:07 -04:00
8bad2fe945 feat(l1): add require_l1, require_l1_or_coverage, require_l1_or_above deps
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:23:16 -04:00
c977196206 feat(l1): add L1 columns + extend account_role CHECK constraint
Adds users.can_cover_l1, accounts.l1_seats_purchased, subscriptions.l1_seat_limit,
audit_logs.acting_as. Rotates the users.account_role CHECK constraint to include
'l1_tech' (was: 'owner', 'admin', 'engineer', 'viewer').

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:19:38 -04:00
8cf6a66154 feat(l1): add l1_tech role to permissions docstring
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:09:27 -04:00
d40cb834b1 docs(plan): L1 workspace Phase 1 implementation plan
26 bite-sized TDD tasks covering: l1_tech role + perms, seat enforcement
(L1 + engineer together), 5 migrations (role/columns, FlowProposal,
internal_tickets, l1_walk_sessions), seat_enforcement/internal_ticket/
l1_session services, full L1 endpoint surface (intake/queue/step/notes/
resolve/escalate/escalate-without-walk), APScheduler cleanup for 24h
abandoned sessions, frontend usePermissions/Sidebar/router updates,
L1Dashboard (active + empty state + resume widget), L1WalkPage with tree
and adhoc variants, coverage banner, seat counter widget, RLS regression
tests, E2E Playwright suite, acceptance walkthrough.

Phase 2 (AI build + KB documents) and Phase 3 (KB connectors) get
their own plan files. Phase 1 ships with adhoc walks as the default
intake; user-facing flow selection ships in Phase 2 alongside the AI
matcher. PSA close/reassign is a Phase 1 stub (deferred to Phase 2).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 11:58:41 -04:00
07a29f630a docs(design): revise L1 spec after review (sessions, adhoc, OAuth, seat enforcement)
Restructure walked_path off FlowProposal onto new l1_walk_sessions table
(each L1 walk has its own path; proposal carries only the validation bit).
Add adhoc walk variant for live calls when no KB content exists, with a
dedicated BuildAbortedNoKB screen offering ad-hoc/escalate/near-miss
options. Introduce SUGGEST_THRESHOLD below MATCH_THRESHOLD so near-miss
flows surface as suggestions instead of triggering a 10s build. Define
empty-state dashboard mode for first-run accounts. Spec the Microsoft
Graph OAuth flow concretely (multi-tenant app, redirect callback, token
refresh). Add seat enforcement for both L1 and engineer tracks via shared
helper (engineer enforcement was missing in current code). Make audit
policy explicit (resolve/escalate only, not per-step). Add session
lifecycle (concurrent sessions, browser-close recovery, 24h abandonment).
Clarify KB doc visibility is owner/engineer only (L1s see citations in
walker, not /account/kb directly). Acknowledge escalation notification
noise as v1 limitation with targeted notification deferred to v2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 10:51:57 -04:00
d1cf77cd41 docs(design): L1 workspace feature spec
New seat tier between engineer and viewer. Dedicated /l1 surface
(dashboard + walker + drafts) for first-call helpdesk staff. Walk-in
intake + PSA queue both produce tickets. Match-or-build pipeline
prefers authored flows, then outcome-validated AI drafts, then builds
fresh from KB. Three KB connectors: IT Glue, Hudu, SharePoint/OneDrive.
Escalation via package + PSA reassign, picked up in chat. Engineer
coverage via per-user can_cover_l1 flag with audit-log tagging.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 03:33:32 -04:00
93ce0490e0 Merge pull request 'feat(routing): serve public landing at / and move authed index to /home' (#174) from feat/public-landing-routing-refactor into main
All checks were successful
CI / frontend (push) Successful in 6m45s
Mirror to GitHub / mirror (push) Successful in 5s
CI / e2e (push) Successful in 10m14s
CI / backend (push) Successful in 10m52s
2026-05-15 05:18:37 +00:00
f9f98b1a65 fix(routing): finish /home migration in WelcomeStep3 + VerifyEmailPage
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 10m12s
CI / backend (pull_request) Successful in 10m46s
The original public-landing routing refactor migrated WelcomeRouter,
WelcomeStep1, and WelcomeStep2 post-onboarding redirects to /home, but
left four sites still pointing at the old / + query-string destinations:

  - WelcomeStep3 `completeWizardAndExit` (Send invites)
  - WelcomeStep3 `handleSkipStep` (Skip)
  - VerifyEmailPage post-verify auto-redirect (`setTimeout`)
  - VerifyEmailPage success-state "Go to dashboard" Link

These all worked by accident because PublicLanding redirects authed
users from / to /home — so users still landed on the dashboard, but
through an unnecessary mount-and-redirect flicker, and the
`?welcome=true` / `?verified=1` query markers got dropped on the way.

Drop both query markers — neither is read anywhere in the codebase
(grepped frontend/src; the dashboard's onboarding UX is driven by
`getOnboardingStatus`, not URL state). Carrying dead URL params
just invites future "is this load-bearing?" investigations.

Test stubs in WelcomeStep3.test.tsx and VerifyEmailPage.test.tsx
moved from `<Route path="/">` to `<Route path="/home">` so the
assertions verify the new destination instead of accidentally matching
the old one (the previous stubs masked the partial migration).

Out of scope: AcceptInvitePage and OAuthCallbackPage still use
`?welcome=teammate`, but that one carries an explicit "decoded by the
dashboard in Task 41" annotation and may be wired up later, so left
untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 00:34:23 -04:00
86163a69aa test(welcome): align Router/Step1/Step2 stub routes with /home destination
Some checks failed
Mirror to GitHub / mirror (push) Failing after 5m5s
CI / frontend (pull_request) Successful in 6m24s
CI / backend (pull_request) Successful in 10m19s
CI / e2e (pull_request) Successful in 9m51s
Post-refactor, WelcomeRouter and the Step1/Step2 "Skip-the-rest" handlers
navigate to /home, but the MemoryRouter test stubs still mounted the
"dashboard" marker at /. Update the stub routes (and matching it() titles)
so the assertions resolve.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:25:50 -04:00
13f527c4ad test(e2e): align auth + public smoke tests with new / and /home routing
Some checks failed
Mirror to GitHub / mirror (push) Successful in 5s
CI / frontend (pull_request) Failing after 2m4s
CI / e2e (pull_request) Successful in 10m8s
CI / backend (pull_request) Successful in 10m27s
Playwright specs still asserted the pre-refactor URLs and failed on CI:
- auth.spec.ts expected post-login to land at `/`; now `/home`.
- public.spec.ts expected unauth redirect to `/landing`; now `/`.
- public.spec.ts's landing-loads test navigated to `/landing` (a stale-
  bookmark redirect); point it directly at `/`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:35:44 -04:00
41f5519916 docs(legal): add baseline legal documents (privacy, ToS, DPA, subprocessors, cookies)
All checks were successful
Mirror to GitHub / mirror (push) Successful in 6s
Generated by the resolutionflow-legal skill from a code scan of the FastAPI
backend + React frontend on commit 0564646. Each document is a starting
point for attorney review, not legal advice.

Includes:
- privacy-policy.md, terms-of-service.md, cookie-policy.md (public-facing)
- dpa.md (contractual; signed with MSP customers)
- subprocessor-list.md (Railway, Anthropic, Voyage, Stripe, Resend, Sentry,
  PostHog, Google Fonts — confirmed live as of scan)
- data-inventory.md + classification.md (Phase 1/2 working files)
- attorney-review-checklist.md (consolidated [LEGAL REVIEW] punch list)
- implementation-verification.md (claim-by-claim audit vs. actual code)

Three blocking issues filed before public publication:
- #175 deletion-on-offboarding (or rewrite retention claims)
- #176 narrow Sentry send_default_pii + Session Replay config
- #177 EU/UK consent for PostHog + Google Fonts

Public-facing documents intentionally route physical-mail requests through
support@ rather than publishing the LLC's registered address.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:51:19 -04:00
05646465b8 feat(routing): serve public landing at / and move authed index to /home
Some checks failed
Mirror to GitHub / mirror (push) Successful in 5s
CI / e2e (pull_request) Failing after 5m32s
CI / frontend (pull_request) Failing after 5m34s
CI / backend (pull_request) Successful in 10m19s
Stripe's compliance crawler fetches the apex URL without executing JS and
declined live-mode review when `https://resolutionflow.com/` returned the
empty SPA shell that redirected to /landing client-side. Restructure the
router so / serves LandingPage directly:

- `/` → new `PublicLanding` wrapper (LandingPage for anon; Navigate to
  /home for authed users so there's no marketing-frame flicker).
- Authed tree converted to a path-less layout route with absolute child
  paths. QuickStartPage moves to `/home`; all other children
  (`/trees`, `/pilot`, `/admin/*`, `/account/*`, etc.) keep their URLs.
- `/landing` kept as a one-release stale-bookmark redirect to /.
- `ProtectedRoute` unauth redirect flipped /landing → /; `state.from`
  preserved for post-login return.

Reference updates:
- Post-login / post-onboarding destinations → /home: OAuthCallbackPage
  (incl. `?welcome=teammate` query), WelcomeStep1/2/3 dismiss-rest,
  AssistantChatPage post-escalate, WelcomeRouter completion/dismiss
  redirects, VerifyEmailPage's three "Go to dashboard" links.
- Authed chrome → /home: TopBar logo, AppLayout mobile nav + drawer
  logo, CommandPalette Dashboard entry.
- Dashboard onboarding → /home: NextStepCard `ran_session.ctaPath`,
  SetupChecklist `ran_session.path`, SessionHistoryPage empty-state CTA.
- Public back-links → /: TermsPage, PrivacyPage, PoliciesPage,
  ContactPage, PromotionsPage, PublicTemplatesPage (header + footer).
  SharedSessionPage's `to="/"` left as-is — now correctly lands anon
  visitors on the public landing.

Crawlability:
- New `frontend/public/robots.txt` allowlisting public pages and
  disallowing the authed app.
- New `frontend/public/sitemap.xml` for /, /pricing, /contact-sales,
  /contact, /templates, /terms, /privacy, /policies, /promotions.
- `PageMeta` gains an `og:url` (defaults to `window.location.href`) and
  flips `twitter:card` to `summary_large_image` when an `ogImage` is
  passed.

Tests:
- `AppLayout.test.tsx` updated to mount at `/home`.
- New `ProtectedRoute.test.tsx` asserts unauthenticated `/home`
  redirects to `/` (not `/landing`) and preserves origin in `state.from`.

If Stripe's crawler still cannot see the site after this (zero-JS
crawler), the documented next escalation is server-side prerendering of
public routes via `vite-plugin-ssg`. Out of scope here.

Plan: docs/plans/2026-05-13-public-landing-routing-refactor.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 01:58:10 -04:00
b1ee46656e Merge pull request 'docs(handoff): record PR #166/#168 merges + issues #171/#172' (#173) from docs/handoff-pr-168-merge into main
All checks were successful
CI / frontend (push) Successful in 7m8s
Mirror to GitHub / mirror (push) Successful in 5s
CI / backend (push) Successful in 11m23s
CI / e2e (push) Successful in 9m52s
2026-05-14 05:02:08 +00:00
3cea0f23ee docs(handoff): record PR #166/#168 merges, dashboard CTA + welcome step-2 fixes, issues #171/#172
All checks were successful
Mirror to GitHub / mirror (push) Successful in 5s
CI / frontend (pull_request) Successful in 6m44s
CI / e2e (pull_request) Successful in 10m25s
CI / backend (pull_request) Successful in 11m25s
- HANDOFF.md: refreshed for 2026-05-14. PR #166 + #168 merged. Bug-pending-capture
  item from 2026-05-12 likely resolved by PR #168 (dashboard CTA dead-link +
  welcome step-2 PSA confusion); confirm with user next session. Stripe/EIN
  blocker carried forward. Issues #171 (WelcomeStep2 connect-now test coverage)
  and #172 (gitignore core dumps + agent .remember/ state) noted.
- CURRENT_TASK.md: added entries for PR #166, #167, #168 to "Recently shipped"
  with full narrative of the three bundled threads on #168 (session expiration,
  dashboard CTA fix, welcome step-2 reshape).
- SESSION_LOG.md: appended detailed 2026-05-14 entry covering the bug-fix design
  conversation, the FOCUS_START_SESSION_EVENT pattern, the welcome step-2
  Connect-now-bug catch (link never persisted primary_psa), CI gating on PR #168,
  and the two filed issues.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 00:57:46 -04:00
3a35121578 Merge pull request 'feat(auth): session expiration policy (3d idle / 14d absolute) + per-account override + bulk revoke' (#168) from feat/session-expiration-policy into main
All checks were successful
CI / frontend (push) Successful in 6m46s
Mirror to GitHub / mirror (push) Successful in 5s
CI / e2e (push) Successful in 10m6s
CI / backend (push) Successful in 10m53s
2026-05-14 04:33:49 +00:00
fe0e6923d5 Merge pull request 'docs(handoff): record PR #164/#165 merges; flag Stripe activation as current blocker' (#166) from docs/handoff-pr-165-merge into main
All checks were successful
CI / backend (push) Successful in 10m33s
Mirror to GitHub / mirror (push) Successful in 4s
CI / e2e (push) Successful in 9m29s
CI / frontend (push) Successful in 21m24s
2026-05-14 03:59:59 +00:00
e5b26245ca 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
- 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>
2026-05-13 23:59:29 -04:00
dc88797469 feat(welcome): two-button PSA CTA in step-2 — Connect now / Connect later
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>
2026-05-13 23:59:18 -04:00
cbb4b25671 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
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>
2026-05-13 20:15:11 -04:00
8d79dd93b8 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
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>
2026-05-13 17:56:30 -04:00
1106f79611 docs: add session-expiration-policy decision entry + CURRENT-STATE summary
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>
2026-05-13 17:09:09 -04:00
c7cd711859 feat: AccountSecuritySettingsPage + active-users list + toast + login banner
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>
2026-05-13 17:07:14 -04:00
aad554bb9c feat(ui): handle session_expired_{idle,absolute} in axios interceptor
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>
2026-05-13 16:33:56 -04:00
cabd745a2b feat(api): add POST /accounts/me/security/revoke-sessions
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>
2026-05-13 16:31:10 -04:00
8cfaef6a9d feat(api): add GET/PATCH /accounts/me/security endpoint
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>
2026-05-13 16:28:51 -04:00
b21d2fc234 feat(auth): enforce absolute session cap in /auth/refresh
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>
2026-05-13 16:26:00 -04:00
d6a02ee8da feat(auth): embed auth_time/idle_max/abs_max in refresh tokens at every login
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>
2026-05-13 16:22:53 -04:00