open-legal-products makes Google sign-in onboarding less brittle

The Mike team has closed the gaps that could leave users stranded during onboarding, account recovery, or a deployment.

securityworkflow

The changes keep password recovery available even when a user has not finished onboarding, while retaining the onboarding requirement elsewhere. They also make profile personalisation more forgiving: incomplete answers no longer block unrelated edits, and fast tab changes no longer lose a pending save.

  • Existing accounts retain their settings when application updates arrive before the matching database update.
  • Account fields avoid overwriting a user's in-progress changes.
  • The Word add-in keeps sign-in usable while Google authentication runs and avoids keeping session credentials in persistent browser storage.

The work was tested across the application and add-in, including browser-based checks.

So what Legal teams adopting Mike should care because authentication and onboarding failures are most damaging precisely when a user needs to recover access or complete urgent work.

View this fork on GitHub →

Spotted something wrong? Or know the PR text has fresher detail than the writeup above?

Commits in this thread

10 commits from open-legal-products/mike, oldest first. Source extracted verbatim from the harvested git log.

SHA Subject Author Date
16d85463 fix(auth): exempt credential-recovery routes from the onboarding gate Amal 2026-08-21 ↗ GitHub
commit body
WHY THIS MATTERS
A user who signs up, reaches onboarding, and closes the tab has
onboarding_version = NULL. If that user later forgets their password,
the recovery email link logs them in and lands on /reset-password -
but OnboardingGate saw an authenticated, un-onboarded user on a
non-exempt route and immediately replaced the URL with
/onboarding/profile. The reset form never rendered, the password never
changed, and the next login failed again: a permanent lockout loop.

Replicated live during the PR #365 review: with onboarding_version set
to NULL, navigating to /reset-password with a session redirects to
/onboarding/profile before the form paints.

WHAT IS A ROUTE-GATE EXEMPTION LIST
OnboardingGate wraps every page and decides "does this user belong in
onboarding right now?". Pages that are themselves part of an auth
transition (login, signup, the OAuth callback) are listed as exempt so
the gate never fights the auth flow. The bug was that the list only
covered the *entry* flows; the *recovery* flows (/reset-password,
/forgot-password, /verify-mfa) also run with a session that must not
be interrupted.

HOW THE FIX WORKS
The three credential-recovery routes join the exemption list, so a
recovery session can finish its job before onboarding resumes on the
next normal navigation. A parameterised test pins each route as
reachable for an un-onboarded user.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RuPCULDYgVsiCanRgbEy6W
44adf584 fix(settings): make personalisation autosave lossless around "Other" boxes Amal 2026-08-21 ↗ GitHub
commit body
WHY THIS MATTERS
Two silent data-loss paths were replicated live during the PR #365
review of the new Personalisation settings page:

1. Tick "Other" under Practice areas and leave the text box empty,
   then change any OTHER field (e.g. Title -> Partner). Nothing is
   saved - no request fires, no status appears, and navigating away
   discards the change. The page-wide validationError guard paused
   the entire autosave effect, and the error string itself was never
   rendered, so the user had no way to know saving had stopped.

2. Pick a value (or finish typing) and click another Settings tab
   within the 400 ms debounce window. The effect cleanup cancelled
   the scheduled save on unmount, so the edit vanished.

WHAT IS A DEBOUNCED AUTOSAVE EFFECT
Each edit re-runs a React effect that schedules the PATCH 400 ms out;
another edit inside that window cancels and reschedules. The cleanup
that makes the rescheduling work is the same cleanup that runs on
unmount - so without an explicit flush, leaving the page always
cancels the last pending save.

HOW THE FIX WORKS
- The hook now reports WHICH field groups are mid-edit-invalid
  (invalidGroups) instead of a single page-wide error. The autosave
  effect only skips a save when the edited field's own group is
  invalid; for other fields it saves a payload in which the invalid
  groups fall back to their last persisted values, so a half-finished
  "Other" box can neither block unrelated saves nor overwrite its own
  stored value with a transient empty state.
- The validation message is now rendered inline (aria-live), so a
  paused save is visible instead of silent.
- The scheduled save closure is kept in a ref; the unmount cleanup
  fires it immediately instead of dropping it.

Tests pin all three behaviours: unrelated-field saves proceed with the
persisted fallback, the message renders, and unmount flushes the
pending save.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RuPCULDYgVsiCanRgbEy6W
64f2e625 fix(settings): stop profile refreshes from wiping in-progress account edits Amal 2026-08-21 ↗ GitHub
commit body
WHY THIS MATTERS
Replicated live during the PR #365 review: type a new Display Name,
tab into Organisation (blur fires the name autosave), and keep typing.
When the name PATCH resolves, the Organisation input snaps back to the
stored server value mid-keystroke and the typed text is gone - while
the field is still focused.

WHAT IS A HYDRATION-EFFECT CLOBBER
The page keeps local input state and "hydrates" it from the profile
context in an effect. The old effect wrote BOTH fields whenever EITHER
profile value changed. Saving one field on blur refreshes the whole
profile object, so the effect re-ran and overwrote the sibling input
with the (stale) server value. This was latent before this PR - with
click-to-save, a profile refresh never landed while the user was
typing in the other field; blur-autosave made the race the common
case, and this PR also removed the truthiness guard that used to
soften it.

HOW THE FIX WORKS
The single combined effect becomes two effects, each keyed only to its
own profile value, and each skips syncing while its input is the
active element - the profile can never overwrite what the user is
currently typing. When the field blurs, its own save handler reads the
input state directly, so nothing is lost by skipping the sync.

A regression test drives the real sequence (type name -> focus
organisation -> type -> name save resolves with a refreshed profile)
and asserts both inputs keep their typed values.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RuPCULDYgVsiCanRgbEy6W
3f9b9b9d fix(backend): survive un-migrated databases and cap prompt-bound fields Amal 2026-08-21 ↗ GitHub
commit body
Two backend hardenings from the PR #365 review, both in user.ts.

── 1. Profile reads on databases without the 20260821 migrations ──

WHY THIS MATTERS
Replicated live by dropping the six new user_profiles columns (the
state of any deployment that updates the backend before running
migrations): GET /user/profile returned 200 but the user's saved
legal_research_us=false and quick_actions_visible=false came back as
true, model preferences fell back to defaults, and PATCH /user/profile
returned a raw 500 leaking PostgREST internals. Settings appeared to
reset themselves.

WHAT IS THE FALLBACK-TIER CASCADE
selectProfile tolerates older databases by retrying with smaller
column lists. Each tier is entered by matching the NAME of its newly
added column inside the Postgres 42703 (undefined column) error.
Postgres reports only the FIRST unknown column - after this PR that is
"jurisdiction" - so none of the existing tiers matched and the code
fell through to a select that predates the model/research/quick-action
columns, then papered over the gap with defaults.

HOW THE FIX WORKS
A new first tier keys on any of the six 20260821 columns and retries
with PROFILE_SELECT_NO_ONBOARDING - the exact pre-PR column list - so
every previously saved preference survives. serializeProfile already
treats the absent onboarding fields as "legacy exempt" (version 0),
which matches what the migration's backfill would write, so existing
users are not funneled into onboarding by a missing migration either.
getUserModelSettings gets the same treatment: on 42703 it retries with
the pre-migration three-column select instead of silently switching
every chat to default models and re-enabling US legal research.

The test harness's per-table stub now accepts a queue of results so a
test can express "first select fails with 42703, the retry succeeds",
and a regression test pins the preserved preferences.

── 2. 200-character cap on displayName / organisation ──

WHY THIS MATTERS
This PR starts injecting both fields into the system prompt of every
chat, project chat, and Word chat turn. Every other personalisation
field is bounded (jurisdiction <= 100, practice areas <= 20 x 100,
titles allowlisted), but these two only had a cap inside the signup
trigger - PATCH /user/profile accepted arbitrary sizes, letting one
oversized value inflate token cost on every message thereafter.

HOW THE FIX WORKS
validateProfilePayload rejects values over 200 characters (the same
limit handle_new_user applies via left(..., 200)), with tests for both
fields.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RuPCULDYgVsiCanRgbEy6W
40488660 fix(word-addin): keep the login page interactive during Google sign-in Amal 2026-08-21 ↗ GitHub
commit body
WHY THIS MATTERS
Clicking "Continue with Google" replaced the entire task pane with the
bootstrap "Loading..." spinner for the lifetime of the OAuth dialog -
no explanation, no cancel, no way back to password login. On Word on
the web the dialog opens as a real browser popup that can land behind
the window or be blocked, leaving the pane indistinguishable from a
hung startup. The login page's own "Continuing..." button state was
dead code: it could never render because the page unmounted first.

WHAT WENT WRONG MECHANICALLY
signInWithGoogle set the store-wide `_loading` flag - the same flag
App.tsx uses for the one-time token bootstrap, checked BEFORE the
"no token -> LoginPage" branch. Setting it synchronously unmounted
LoginPage in the same React batch that tried to show the button
spinner.

HOW THE FIX WORKS
signInWithGoogle no longer touches `_loading`. The login page stays
mounted; its local googleLoading state (already wired) shows
"Continuing..." on the button and re-enables everything when the flow
resolves either way. Success still lands in writeSession, which
broadcasts the new token and swaps the pane to the app shell exactly
as before. This also removes the stuck-spinner path where a
superseded writeSession left `_loading` true forever with no error.

All 30 add-in auth e2e tests (chromium + webkit), including the five
Google OAuth scenarios, pass against this change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RuPCULDYgVsiCanRgbEy6W
6cb5260f fix(word-addin): keep OAuth tokens out of localStorage in the dialog Amal 2026-08-21 ↗ GitHub
commit body
WHY THIS MATTERS
The Google OAuth dialog created a Supabase client with
persistSession + storage: window.localStorage. After the PKCE code
exchange, the user's full session - access AND refresh token - was
written to localStorage on the add-in's origin. The cleanup that
removed it ran only on the happy path, and even then the client's
auto-refresh timer stayed alive and could re-persist the session
after the key was deleted. Any crash or close between the exchange
and cleanup left long-lived credentials on disk, readable by any
script on the origin. This contradicts the invariant the add-in
itself enforces elsewhere: tokens live exclusively in
OfficeRuntime.storage, never browser storage (office-mock.ts fails
tests that violate it).

WHAT IS THE PKCE PERSISTENCE ACTUALLY FOR
The dialog's Supabase client needs persistence for exactly one thing:
the PKCE code verifier must survive the same-tab redirect to Google
and back. That is a session-scoped, single-tab lifetime -
sessionStorage's exact semantics. Nothing about the flow needs the
minted session itself to be persisted at all.

HOW THE FIX WORKS
- storage: window.sessionStorage (+ autoRefreshToken: false), so
  nothing outlives the dialog window even on a crash.
- After a successful exchange the dialog signs the temporary client
  out with scope "local" - clearing its persisted copy and refresh
  timer without revoking the session the task pane is adopting -
  before handing the tokens over.
- clearTemporaryAuthStorage also sweeps the old localStorage keys so
  sessions persisted by earlier builds are removed.
- messageParent is now guarded: if the host rejects the
  DialogOrigin-1.1 messageOptions overload it retries the legacy
  same-domain form, and on total failure it shows an actionable
  message instead of claiming "Signed in" while the task pane hangs.
  The success status is only shown after the handoff call returns.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RuPCULDYgVsiCanRgbEy6W
8b5f3aef fix(settings): drop the focused-input hydration guards, keep the effect split Amal 2026-08-21 ↗ GitHub
commit body
WHY THIS MATTERS
An independent verifier measured that the focused-input guards added
with the hydration-effect split introduced their own data-loss bug:
if the profile finishes loading while the Display Name input is
focused, the guard skips the sync, the input stays empty, and merely
blurring the untouched field fires the autosave - comparing local ""
against the freshly loaded stored name and overwriting it with an
empty string. The same verifier proved empirically that the effect
SPLIT alone fixes the original sibling-clobber bug: a name save never
changes profile.organisation, so the organisation effect simply does
not run when the name save's profile refresh lands.

THE TEST WAS ALSO NOT PINNING ANYTHING
The previous regression test resolved the mocked save inside
user.click()'s microtask flush, so the race window never opened and
the test passed even against the unfixed combined effect. It now
holds the save in flight with a deferred promise, types into the
sibling field, and only then lets the profile refresh land - it fails
on the pre-fix code and passes on this one.

HOW THE FIX WORKS
Keep the two per-field effects (each keyed only to its own profile
value); remove the refs and document.activeElement checks. Simpler,
and strictly fewer failure modes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RuPCULDYgVsiCanRgbEy6W
c39f01ef fix(word-addin): remove the session-revoking signOut from the OAuth dialog Amal 2026-08-21 ↗ GitHub
commit body
WHY THIS MATTERS
Two independent verification agents auditing the previous commit both
proved the same severe regression: the cleanup call added there -
`supabase.auth.signOut({ scope: "local" })` - is NOT local. In the
pinned @supabase/auth-js, _signOut always calls
admin.signOut(accessToken, scope), which POSTs `/logout?scope=local`
to GoTrue with the session's JWT. GoTrue's handler deletes that
session row and revokes its refresh token server-side - the exact
refresh token the dialog hands to the task pane two lines later.
Google sign-in would have appeared to work, then silently logged the
user out at the first token refresh (~1 hour), with no test able to
catch it because the e2e suite stubs the dialog file out entirely.

WHAT "LOCAL SIGN-OUT" ACTUALLY MEANS IN GOTRUE
scope=local revokes the CURRENT session only (vs. "global" = all of
the user's sessions, "others" = all but this one). "Local" refers to
which sessions are revoked, not to whether a network call happens -
a subtlety the removed comment got exactly backwards.

HOW THE FIX WORKS
Delete the call. It was also redundant: clearTemporaryAuthStorage()
on the next line removes the persisted session keys, and the client
is created with autoRefreshToken: false, so no refresh timer can
re-persist anything. Also removed: the option-less messageParent
retry, which both verifiers flagged as speculative (every supported
host has the DialogOrigin 1.1 set) and mildly harmful - dropping
targetOrigin feeds the task pane's fail-open origin check. A visible
failure message beats a weaker handoff.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RuPCULDYgVsiCanRgbEy6W
2a0168d0 fix(settings): save earlier pending personalisation edits past an invalid Other box Amal 2026-08-21 ↗ GitHub
commit body
WHY THIS MATTERS
An independent audit of the previous personalisation-autosave commit
found a residual hole in the same class as the bug it fixed: change
Title (save armed, inside the 400 ms debounce), then tick an empty
"Other" practice area. The effect re-ran, the guard keyed on the LAST
edited field's group (now invalid) returned early, and the still
unsaved Title change was silently dropped - never re-armed.

HOW THE FIX WORKS
The per-field group guard is removed entirely; it was redundant. The
payload substitution already replaces invalid groups with their last
persisted values, so when only the invalid group changed, the payload
equals the persisted snapshot and no save fires - exactly what the
guard achieved. When OTHER fields also changed, the snapshot differs
and the save now fires, carrying the earlier edits with the invalid
group held at its persisted value. One comparison does both jobs, and
the FIELD_GROUP map is deleted. A deterministic test pins the exact
sequence (Title edit -> empty Other tick -> flush) and asserts the
Title change survives with the stored practice areas untouched.

KNOWN LIMIT (deliberate)
A valid area ticked while the Other box is empty stays visible in the
UI but is excluded from payloads until the Other text is filled - the
inline "Enter your other practice area" message shows why saving is
partially held. Saving half-finished groups would be worse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RuPCULDYgVsiCanRgbEy6W
88092ec1 fix(backend): per-migration fallback tiers, truncating caps, tested retry Amal 2026-08-21 ↗ GitHub
commit body
Three corrections to the earlier "survive un-migrated databases"
commit, all raised by independent verification agents.

── 1. A database missing ONLY migration 02 kept too little ──

The single fallback tier keyed on all six 20260821 columns, so a
database with migration 01 applied but not 02 (password_set_at) was
retried WITHOUT its five live onboarding columns: personalisation
read back empty and - worse - serializeProfile's legacy-exempt
default made genuinely new users report onboardingComplete: true and
skip onboarding. Postgres names the FIRST unknown column in a 42703,
so the two states are distinguishable: an 02-only gap names
password_set_at, an 01 gap names jurisdiction. There are now two
tiers, most-migrated first, with a regression test for the 02-only
shape asserting the live columns and NULL onboarding state survive.

── 2. The 200-character cap now truncates instead of rejecting ──

Rejection contradicted the signup trigger (handle_new_user applies
left(..., 200) silently) and would have permanently 400'd any
over-long value written before the cap existed - with the server's
detail string discarded by the settings UI, appearing as an
unexplained failure. Truncation matches the trigger exactly. The test
harness now records update payloads so the tests assert the actual
truncated write, not just a status code.

── 3. getUserModelSettings retry is now explicit and tested ──

The retry's second failure was silently swallowed (data stayed null
by accident); that fallback-to-defaults is now an explicit branch,
and the previously untested severe half of the un-migrated-DB bug -
saved models and legal_research_us=false surviving the retry - is
pinned by two new unit tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RuPCULDYgVsiCanRgbEy6W

Capture this thread into my fork

Download a single Markdown prompt that tells Claude how to port every commit above into your working tree — adapting paths and structure to match your repo. Run it via claude -p < capture-thread-1270.md from inside the repo you want the changes in.

⬇ Download capture-thread-1270.md