From 0ab6d3ed10f9ec29a47eb3b6f79be136f653f06c Mon Sep 17 00:00:00 2001 From: xarmian Date: Mon, 27 Apr 2026 23:11:35 -0400 Subject: [PATCH] feat(web): signed-in account chip on CLI auth approval + switch-accounts (TASK-836) (#271) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): show signed-in account chip on CLI auth approval page (TASK-836) The CLI auth approval page (/auth/cli/{code}) previously showed only an "Approve" button with no indication of WHICH account was about to grant the CLI access. For OAuth users on Pad Cloud — most of whom have multiple GitHub/Google accounts — wrong-account approval was a silent footgun, recoverable only by revoking the CLI token after the fact. This change renders an account chip above the Approve button when the session is pending, showing: - The user's avatar (when avatar_url is present) - Display name (or username fallback if name is empty) - Email Below the chip, a "I'm not — switch accounts" link button calls api.auth.logout() and navigates to /login?redirect=/auth/cli/{code}, so after re-login the user lands back on this same approval page (the login page already validates relative-only redirects to prevent open redirects). Graceful degradation: api.auth.me() is wrapped in its own try/catch. If it fails, currentUser stays null and the chip simply doesn't render — the Approve flow still works. The Approve button is also disabled while a switch-accounts call is in flight to avoid double-action races. Works for both email/password (self-hosted) and OAuth (Cloud) sessions because api.auth.me() and api.auth.logout() operate on the unified pad session regardless of how it was established. Parent: PLAN-833. Source: IDEA-831 issue #3. * fix(web): plumb redirect through OAuth login + surface logout failures Codex round-1 findings on TASK-836: - MEDIUM: The login page already preserved ?redirect= for password and 2FA login but the GitHub/Google OAuth buttons were plain anchors with hardcoded hrefs. A user clicking "Switch accounts" on the CLI auth approval page and then signing in via OAuth would land at /console instead of back at /auth/cli/{code}. Added a $derived oauthRedirectQuery rune that reuses the existing getRedirectTarget() validation and appends ?redirect= to both OAuth links when the redirect is non-default. Whether pad-cloud's /auth/github and /auth/google handlers honor the redirect param is an out-of-tree concern and tracked separately if needed; the client side now consistently passes it. - LOW: handleSwitchAccount silently swallowed logout failures and then navigated to /login. If the server didn't actually invalidate the session cookie (network/CSRF), login's onMount would see an authenticated session and bounce the user right back to the approval page — making "switch accounts" appear to be a no-op. The handler now surfaces the error in the page error slot and stays on the approval page, giving the user a clear next step (retry or close the tab) and also resets switchingAccount so the UI isn't stuck in a "Switching..." state. A defensive code check was also added to handleSwitchAccount to mirror handleApprove's "Missing CLI session code" guard, even though the button only renders when status === 'pending'. Parent: PLAN-833. * fix(web): tighten redirect validation + cover OAuth banner buttons Codex round-2 findings on TASK-836: - MEDIUM: getRedirectTarget() accepted protocol-relative URLs (`//host` and `/\host`) because the bare `startsWith('/')` check passes for both. Browsers and most server-side redirect handlers treat those as cross-origin destinations, so a crafted `?redirect=//evil.example` could become an open redirect once forwarded through the OAuth handler. Now also rejects strings that start with `//` or `/\`. This was a pre-existing bug in the password/2FA redirect path; the OAuth link change made the surface area worth tightening. - LOW: The "Use a different GitHub/Google account" banner buttons that appear on `oauth_provider_not_linked` errors hardcoded `?force=1` and dropped the redirect target. Added a sibling `oauthRedirectAmpQuery` derived value (`&redirect=...`) so those links compose properly with `?force=1`. When the redirect is the default `/console` it stays empty so we don't add redundant query noise. Both changes live in cmd/pad/... no, in web/src/routes/login/+page.svelte and don't affect the password / 2FA paths beyond the validation tightening (which they were already passing through silently). Parent: PLAN-833. --- web/src/routes/auth/cli/[code]/+page.svelte | 144 +++++++++++++++++++- web/src/routes/login/+page.svelte | 37 ++++- 2 files changed, 174 insertions(+), 7 deletions(-) diff --git a/web/src/routes/auth/cli/[code]/+page.svelte b/web/src/routes/auth/cli/[code]/+page.svelte index da00a7f4..12116f4a 100644 --- a/web/src/routes/auth/cli/[code]/+page.svelte +++ b/web/src/routes/auth/cli/[code]/+page.svelte @@ -3,10 +3,13 @@ import { page } from '$app/stores'; import { goto } from '$app/navigation'; import { api } from '$lib/api/client'; + import type { User } from '$lib/types'; let status = $state<'loading' | 'pending' | 'approved' | 'expired' | 'error' | 'success' | 'already_approved'>('loading'); let error = $state(''); let approving = $state(false); + let switchingAccount = $state(false); + let currentUser = $state(null); onMount(async () => { const code = $page.params.code; @@ -36,6 +39,15 @@ return; } + // Best-effort load of the current user so we can show which account + // the CLI session is about to be linked to. If this fails, the chip + // just won't render but the Approve flow still works. + try { + currentUser = await api.auth.me(); + } catch { + currentUser = null; + } + status = 'pending'; } catch { status = 'error'; @@ -65,6 +77,32 @@ approving = false; } } + + async function handleSwitchAccount() { + const code = $page.params.code; + if (!code) { + error = 'Missing CLI session code.'; + return; + } + switchingAccount = true; + error = ''; + try { + await api.auth.logout(); + } catch (err: unknown) { + // Surface the failure rather than swallowing it: if the server + // did not invalidate the session cookie, navigating to /login + // would bounce straight back here authenticated and "switch + // accounts" would appear to be a no-op. Showing the error + // gives the user a clear next step (retry / close the tab). + switchingAccount = false; + error = + err instanceof Error && err.message + ? `Couldn't sign you out: ${err.message}` + : "Couldn't sign you out. Please try again or close this tab."; + return; + } + goto(`/login?redirect=/auth/cli/${code}`, { replaceState: true }); + }