Skip to content

Add Switchify account sign-in with an emailed code - #990

Merged
enaboapps merged 6 commits into
mainfrom
claude/984-account-sign-in
Oct 5, 2026
Merged

enaboapps merged 6 commits into
mainfrom
claude/984-account-sign-in

Conversation

@enaboapps

@enaboapps enaboapps commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Closes #984
Part of #988.

What

  • src-tauri/src/account.rs: sign-in with an emailed 6-digit code against the shared Supabase project, using the same accounts as Switchify on Android. Requests are made from Rust with reqwest, so the webview CSP is unchanged.
    • POST /auth/v1/otp with create_user: true. Production's sign-up and login templates both send {{ .Token }}, so new and existing users get a code.
    • POST /auth/v1/verify with type: email.
    • The refresh-token grant. The async mutex is held during a refresh, because refresh tokens rotate and two concurrent refreshes would revoke the session.
    • POST /auth/v1/logout?scope=local (best effort) on sign-out.
    • POST /rest/v1/rpc/delete_user_account to delete the account. This is the same RPC Android uses, and it cascades to user_preferences and desktop_preferences.
  • Secrets: the refresh token, user id and email are kept in the OS keychain under the new service com.enaboapps.switchify.pc.account. The access token stays in memory only. Tokens and codes are never logged or sent to the webview, which only receives {available, signedIn, email, pendingEmail}.
  • Failure handling:
    • A refresh rejected with 400/401/403 signs this install out.
    • Network errors and 5xx keep the session.
    • A keychain write failure doesn't sign in.
    • A refresh for a different user id is rejected.
  • Publishable key: only the apikey header carries it. Authorization is sent only with a user access token, because Supabase's sb_publishable_ keys aren't JWTs.
  • Commands: get_account, request_sign_in_code, verify_sign_in_code, cancel_sign_in, sign_out and delete_account. All are main-window-only via the capability, and an account-changed event is emitted on changes.
  • UI: a new Settings → Account tab:
    • email field, then "Email me a code"
    • code field (one-time-code, focused automatically), then "Sign in" or "Use a different email"
    • signed-in state with "Sign out" and "Delete account…", which asks for confirmation and explains that deletion covers every device, including Android
    • errors announced with role=alert
  • Release config: SWITCHIFY_SUPABASE_URL and SWITCHIFY_SUPABASE_PUBLISHABLE_KEY are baked in at build time with option_env!, and the macOS and Windows release jobs now require them. Both are set as repository variables; these are public values that the Android app embeds too. Builds without them show "Accounts are unavailable in this build".

Review fixes

  • Keychain write failure during refresh: a refresh token the server has already rotated is kept in memory even if the keychain write fails, so the revoked token is never sent again (which would trigger reuse detection).

  • Sign-out order: sign-out, delete and a rejected refresh clear the in-memory session first, so a keychain error never leaves "Signed in as …" showing for a deleted account.

  • Locked keychain at startup: a keychain that can't be read (e.g. locked at login) is retried on the next account call. The UI says so, and sign-in is blocked so it can't silently replace a hidden session. A corrupt entry counts as signed out.

  • Focus in the Account tab: controls stay enabled while a request is running (aria-disabled, readOnly, extra presses ignored), so focus is never dropped.

    • Focus moves to the next control after each step: code field, Sign out, Keep account, Delete account… or the email field.
    • Opening the tab doesn't steal focus.
    • A repeated error is announced again.
  • Release check: both release jobs require an https:// Supabase URL.

  • Re-review round 2:

    • Account commands emit the current state even when they fail, so a session rejected mid-action never leaves a stale "Signed in as …".
    • Only user actions re-read an unreadable keychain, so background calls never trigger keychain prompts.
    • Undecodable or ambiguous keychain entries are removed and treated as signed out instead of blocking sign-in.
    • aria-disabled buttons now look disabled.
    • The local-stack test reads the code after "enter the code:", because the magic-link token can contain digit runs.
  • Final review round:

    • When the backend changes screens (e.g. a session rejected during delete), any delete confirmation and code are cleared, so the next sign-in never opens one click from deletion.
    • If the focused control disappears, focus moves to the new screen; focus elsewhere is left alone.
    • Post-action events don't re-read the keychain, so it prompts at most once per action.
    • aria-disabled buttons show as GrayText in forced colours.

Validation

  • npm run lint, npm test (269 passed, 15 new AccountSection tests) and npm run build
  • cargo fmt --check and cargo clippy --all-targets -D warnings: clean
  • cargo test: 658 passed, 4 ignored, including 19 new account tests that use a fake transport and an in-memory store, with no network and no real keychain. They cover:
    • config validation and the stable keychain identity
    • email and code validation with no request sent
    • the request and verify payloads
    • only the refresh session being stored
    • wrong codes, rate limits, network errors and keychain failure
    • restart, refresh and caching, and an expiring token being refreshed
    • rejected vs failed refresh, and a mismatched user
    • offline sign-out
    • delete success and failure, and a second sign-in being blocked
  • End to end against a real local Supabase stack (switchify-supabase, supabase start): the ignored test account::tests::local_stack_sign_in_refresh_sign_out_and_delete passed. It requests a code, reads it from the stack's Mailpit inbox, verifies it, refreshes after a simulated restart and deletes the account through the RPC.
  • Production was checked read-only: the publishable key is accepted with apikey only (GET /auth/v1/settings), and a bogus refresh token gets HTTP 400, which the code maps to signed out. No real code emails were sent.

🤖 Generated with Claude Code

Sign in with the same Supabase account as the Android app. Rust calls the
auth REST API (request code, verify, refresh, sign out) so the webview CSP
stays closed. The refresh token is kept in the OS keychain under
com.enaboapps.switchify.pc.account; access tokens stay in memory and no
token, code or email is logged or sent to the webview beyond the address
shown to the user. A rejected refresh token signs out; outages do not.

Settings gains an Account tab to request a code, sign in, sign out and
delete the account through the shared delete_user_account RPC, with a
confirmation step. Release builds now require SWITCHIFY_SUPABASE_URL and
SWITCHIFY_SUPABASE_PUBLISHABLE_KEY; builds without them show accounts as
unavailable.

Refs #984

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@enaboapps enaboapps added this to the v1.0.0-rc.22 milestone Oct 5, 2026
OwenMcGirr and others added 3 commits October 5, 2026 13:09
Address review on account sign-in:
- Keep a server-rotated refresh token in memory even if the keychain write
  fails, so the revoked token is never reused.
- Clear the in-memory session before deleting from the keychain, so a
  keychain failure never leaves a deleted or signed-out account showing.
- Retry a keychain that could not be read at startup (e.g. locked at login)
  and tell the user, instead of silently appearing signed out.
- Account tab: controls stay enabled while busy so focus is never dropped,
  focus moves to the next control after each step, opening the tab no
  longer steals focus, and repeated errors are announced again.
- Release jobs require an https Supabase URL.
- Tests cover rotated-token save failure, locked and corrupt keychain
  entries, keychain delete failure and the unchanged store on a mismatched
  refresh.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Address re-review on account sign-in:
- Account commands emit the current account state even when they fail,
  so a session rejected mid-action never leaves a stale signed-in view.
- Only user actions re-read an unreadable keychain; background calls do
  not, so they cannot trigger keychain prompts.
- Undecodable or ambiguous keychain entries are removed and treated as
  signed out instead of blocking sign-in forever.
- Buttons marked aria-disabled look disabled.
- Correct the comment on a failed keychain delete, and make the local
  stack test read the code after "enter the code:" (the magic-link token
  can contain digit runs).
- Tests cover background no-retry, sign-out with a failing keychain delete,
  and repeat presses while a request is pending.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Address final review on account sign-in:
- A screen change pushed by the backend (e.g. a session rejected during
  delete) clears any pending delete confirmation and code, so the next
  sign-in never opens one click from deletion.
- If that change removes the focused control, focus moves to the new
  screen's first control or the keychain notice; focus elsewhere is kept.
- Post-action events report state without re-reading the keychain, so a
  locked keychain prompts at most once per action.
- aria-disabled buttons show as GrayText in forced colours.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@enaboapps
enaboapps marked this pull request as ready for review October 5, 2026 12:23
@enaboapps
enaboapps merged commit a1c254e into main Oct 5, 2026
6 checks passed
@enaboapps
enaboapps deleted the claude/984-account-sign-in branch October 5, 2026 13:12
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.

Settings sync: email OTP account sign-in for Switchify PC

2 participants