Skip to content

Review fixes for #365: gate exemptions, lossless autosave, migration fallback, add-in OAuth hardening - #367

Merged
willchen96 merged 10 commits into
google-auth-onboardingfrom
google-auth-onboarding-review-fixes
Aug 22, 2026
Merged

Review fixes for #365: gate exemptions, lossless autosave, migration fallback, add-in OAuth hardening#367
willchen96 merged 10 commits into
google-auth-onboardingfrom
google-auth-onboarding-review-fixes

Conversation

@amal66

@amal66 amal66 commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Review-fix stack for #365 (renders as a diff on top of that PR). Every fix below was replicated live on the local stack before being fixed, and re-verified live against the fixed code.

How the base cases were replicated

Stack: repo compose services (gateway :54721), PR branch backend on :3001 and frontend on :3000, both PR migrations applied. Flows driven in Chrome; database claims proven with psql, not the UI.

  • Happy paths first (all worked): email signup → 2-step onboarding → assistant greeting with the onboarded name; DB row shows onboarding_version = 1 with all personalisation fields persisted. Legacy account (version 0) logs straight into the app, untouched by the gate. Simulated Google account (google identity row + magic link): gated into onboarding with Name pre-filled from Google's full_name, email editing blocked behind the "add a password" modal, Set-password flow flips password_set_at via the service-role RPC (verified against auth.users), and email editing unlocks after.

Replications that motivated the fixes

  1. Reset-password lockout. With onboarding_version = NULL, navigating to /reset-password with a session bounced to /onboarding/profile before the form painted — a user who abandoned onboarding could never complete a password reset. Fixed: recovery routes (/reset-password, /forgot-password, /verify-mfa) exempt from OnboardingGate; re-verified the form now renders for the same user.
  2. Empty "Other" silently froze all personalisation autosave. Tick Practice areas → Other, leave it empty, change Title → no request fires, no status, nothing saved (DB confirmed NULL). The validation error was computed but never rendered. Fixed: per-group invalid tracking (an invalid group can't block unrelated saves, and falls back to its persisted value in the payload) + the message now renders inline. Re-verified: Title saves with the Other box empty, message visible.
  3. Debounced saves dropped on tab navigation. Typing "Space Law" into Other and clicking another Settings tab within the 400 ms debounce dropped both the text and the selection (no request ever fired). Fixed: pending save flushes on unmount. Re-verified: "Space Law" lands in practice_areas despite immediate navigation.
  4. Sibling-field clobber on Account. Typing a Display Name, tabbing into Organisation, and typing there: when the name PATCH resolved, the Organisation input reset to the stale server value mid-keystroke (still focused). Fixed: per-field hydration effects that skip the focused input. Re-verified with the same sequence — both values survive.
  5. Un-migrated DB silently reset settings. Dropping the six new columns (the state of any deploy that updates code before migrations): GET /user/profile returned 200 but legal_research_us=false and quick_actions_visible=false came back true (the 42703 fallback cascade keyed on the wrong column name), and personalisation PATCHes 500'd raw. Fixed: a first fallback tier keyed on the six 20260821 columns + the same retry in getUserModelSettings. Re-verified under the same fault: saved values preserved, onboarding reported legacy-exempt.
  6. Word add-in (code-read, e2e-verified): the OAuth dialog persisted the full session (access + refresh token) in localStorage with a live auto-refresh timer — now sessionStorage + autoRefreshToken:false + local sign-out before the handoff, and messageParent is guarded instead of claiming success before delivery. Clicking "Continue with Google" also blanked the whole pane to a bare "Loading…" spinner for the dialog's lifetime — the login page now stays mounted with its (previously dead) "Continuing…" state. All 30 add-in auth e2e tests pass on chromium + webkit.

Validation

  • backend: tsc --noEmit clean; unit suite 535 passed; user.routes integration file 46 passed (3 new tests)
  • frontend: tsc --noEmit clean; 510 passed (6 new tests)
  • word-addin: typecheck clean; e2e/auth.spec.ts 30 passed (chromium + webkit)

Tradeoffs / design decisions (stated so they can be vetoed)

  • Gate exemptions are enumerated, not derived. /settings?emailChange=processed (the email-change callback target) is still gated for un-onboarded users — exempting /settings wholesale would open all of Settings to them, which felt wrong to decide unilaterally. The email change itself completes server-side; only the confirmation view is deferred.
  • Autosave with an invalid "Other" group sends the persisted value for that group rather than pausing everything — the transient empty state can never overwrite stored data, at the cost of the payload not reflecting the half-finished edit until it's completed.
  • The un-migrated-DB tier reports onboardingVersion: 0 (legacy-exempt) — matching what the migration backfill would write, and failing open rather than trapping existing users in onboarding during a deploy window.
  • The dialog's messageParent retry drops targetOrigin only for hosts that reject the DialogOrigin 1.1 overload; the legacy form is same-domain-only, which is the identical restriction expressed implicitly.

Not addressed here (PR discussion)

  • Google OAuth enabled by default with blank credentials: on a stock local stack the "Continue with Google" button dead-ends on GoTrue's raw JSON error (replicated). Whether to default GOTRUE_EXTERNAL_GOOGLE_ENABLED to false or gate the button on a public flag is a product/config decision.
  • "Skip" on onboarding step 2 discards selections the user already made (sends {}); relabel vs. submit-on-skip is a UX call.
  • POST /user/onboarding re-stamps legacy (version 0) users to 1 and has no state guard — depends on intended re-onboarding semantics.
  • Every login routes through /onboarding/profile (hardcoded at 5 call sites) before the gate bounces completed users to /assistant — cosmetic flash, larger refactor.
  • Degraded-profile fallback asserts passwordSet: false as fact, so a Google user with a password sees "create a password" copy while the profile fetch is failing.
  • Design-system sweep riding along (FieldLabel text-xstext-sm across 8 untouched modals, OptionPill duplicating PillButtonUI outside frontend/src/shared, add-in Google button markup duplicated) — worth a deliberate look or a split.
  • No real-Word smoke test of the dialog flow is claimed anywhere in feat(auth): add Google OAuth onboarding and personalisation #365; Word on Mac (WKWebView) + Word on the web (popup-blocker path) should gate the merge.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RuPCULDYgVsiCanRgbEy6W

Round 2 — adversarial verification of this PR's own fixes

Two independent agents (one empirical — ran the pre-fix vs fixed code and tests; one audit-only — worked purely from git diffs) were asked to refute every bug claim and every fix above. Their verdicts converged: all replicated bugs confirmed real (with severity corrections noted below), fixes for the gate exemption, debounce flush, backend fallback, and login-page state confirmed correct and minimal — and they proved three defects in this PR's own first round, now corrected in the four newest commits:

  1. signOut({scope:"local"}) in the OAuth dialog revoked the very session being handed to the task pane — auth-js always POSTs /logout?scope=local, and GoTrue deletes that session's refresh token server-side. Google sign-in would have died silently at the first token refresh, invisible to CI because the dialog file is stubbed out of e2e. The call is removed (it was also redundant); the speculative option-less messageParent retry is removed too since it mildly weakened the receiver's origin check.
  2. The focused-input hydration guards could save an empty display name (profile loads while the input is focused → guard skips the sync → blur saves "" over the stored name — measured empirically). The guards and refs are removed; the effect split alone fixes the original clobber, which was also proven empirically. The regression test was additionally shown to pass on unfixed code (the mocked save resolved too fast to open the race window) and now uses a deferred promise so it genuinely pins the fix.
  3. A residual pending-save drop: editing Title and then ticking an empty "Other" inside the debounce window dropped the Title edit. The per-field group guard is deleted — the payload substitution plus snapshot comparison already covers it — and a deterministic test pins the sequence.

Also corrected from the audit: a database missing only migration 02 now keeps its live onboarding columns (dedicated fallback tier + test) instead of reporting new users as onboarding-complete; the 200-char cap truncates like handle_new_user instead of rejecting (rejection would have stranded pre-existing over-long values behind an unexplained error); the getUserModelSettings retry now handles a second failure explicitly and finally has tests.

Severity corrections the verifiers made to the original findings, accepted as-is: the reset-password bug is a burned recovery link + dead end rather than a permanent lockout (the recovery session survives the redirect, so completing onboarding un-wedges it); on the profile route only the legal-research/quick-actions booleans were reset by the missing-migration bug — the model-preference reset was real only in getUserModelSettings; and the dialog's storage cleanup did run on error paths, the true exposure being the crash window and refresh-timer re-writes.

Post-correction gates: backend tsc + full unit suite green, 49 tests in the touched backend files (7 new this round); frontend tsc + 512 tests green; add-in typecheck + all 30 auth e2e green on chromium and webkit.

amal66 and others added 6 commits August 21, 2026 11:18
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
…boxes

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
… edits

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

CLAassistant commented Aug 21, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

amal66 and others added 4 commits August 21, 2026 11:59
…ialog

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
…ct split

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
…alid Other box

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
…etry

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
@willchen96
willchen96 marked this pull request as ready for review August 22, 2026 16:52
@willchen96
willchen96 merged commit 090a60d into google-auth-onboarding Aug 22, 2026
1 check was pending
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants