From 462d5e531dcfeded325d2a1df61acc3f2f6c8a65 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 6 Jul 2026 12:42:28 +0530 Subject: [PATCH 1/2] (feat): show success screen when OAuth redirect targets a native app When an OAuth2 client redirects to a custom protocol (e.g. cursor://), the browser hands off to the OS and the tab is left hanging with no feedback. Detect non-web redirect URIs after approve/reject and render an outcome screen instead. The shared outcome card completes the consent card's identity row (dashed pending connector resolves to a check when approved, X when cancelled) and offers an Open-app button to retry the deep link. The device flow's approved/denied screens now reuse the same card. --- .../(public)/oauth2/consent-card.svelte | 24 +- .../(public)/oauth2/consent/+page.svelte | 39 ++- .../(public)/oauth2/device/+page.svelte | 64 +---- .../(public)/oauth2/outcome-card.svelte | 244 ++++++++++++++++++ 4 files changed, 311 insertions(+), 60 deletions(-) create mode 100644 src/routes/(public)/oauth2/outcome-card.svelte diff --git a/src/routes/(public)/oauth2/consent-card.svelte b/src/routes/(public)/oauth2/consent-card.svelte index edc30531be..1c372d1bf0 100644 --- a/src/routes/(public)/oauth2/consent-card.svelte +++ b/src/routes/(public)/oauth2/consent-card.svelte @@ -35,6 +35,7 @@ import ResourceSelector from './resource-selector.svelte'; export type OAuth2Flow = 'authorization' | 'device'; + export type OAuth2Outcome = 'approved' | 'denied'; function hostnameOf(uri: string): string | null { try { @@ -44,6 +45,15 @@ } } + function isWebRedirect(uri: string): boolean { + try { + const { protocol } = new URL(uri); + return protocol === 'http:' || protocol === 'https:'; + } catch { + return false; + } + } + // Scope tokens are never rendered inline — they add noise. Instead each row // exposes a hover copy button that yields the raw token, exactly as issued // (prefix included), for anyone who needs the precise string. @@ -65,10 +75,10 @@ app: Models.App; accountLabel?: string; flow: OAuth2Flow; - onDeviceDone?: (outcome: 'approved' | 'denied') => void; + onDone?: (outcome: OAuth2Outcome, redirectUrl?: string) => void; } - let { grant, app, accountLabel = undefined, flow, onDeviceDone }: Props = $props(); + let { grant, app, accountLabel = undefined, flow, onDone }: Props = $props(); let error = $state(null); let approving = $state(false); @@ -202,10 +212,13 @@ if (grant.$id !== grantId) return; trackEvent(Submit.AccountOAuth2ConsentApprove, { app_id: grant.appId, flow }); if (flow === 'device' || !result.redirectUrl) { - onDeviceDone?.('approved'); + onDone?.('approved', result.redirectUrl); return; } window.location.href = result.redirectUrl; + if (!isWebRedirect(result.redirectUrl)) { + onDone?.('approved', result.redirectUrl); + } } catch (e: unknown) { if (grant.$id !== grantId) return; const message = (e as Error)?.message ?? 'Failed to authorize the application'; @@ -227,10 +240,13 @@ if (grant.$id !== grantId) return; trackEvent(Submit.AccountOAuth2ConsentDeny, { app_id: grant.appId, flow }); if (flow === 'device' || !result.redirectUrl) { - onDeviceDone?.('denied'); + onDone?.('denied', result.redirectUrl); return; } window.location.href = result.redirectUrl; + if (!isWebRedirect(result.redirectUrl)) { + onDone?.('denied', result.redirectUrl); + } } catch (e: unknown) { if (grant.$id !== grantId) return; const message = (e as Error)?.message ?? 'Failed to cancel the request'; diff --git a/src/routes/(public)/oauth2/consent/+page.svelte b/src/routes/(public)/oauth2/consent/+page.svelte index bec5acd84a..cf3fa1435a 100644 --- a/src/routes/(public)/oauth2/consent/+page.svelte +++ b/src/routes/(public)/oauth2/consent/+page.svelte @@ -7,15 +7,17 @@ import { IconExclamation } from '@appwrite.io/pink-icons-svelte'; import { Button } from '$lib/elements/forms'; import { sdk } from '$lib/stores/sdk'; - import OAuth2ConsentCard from '../consent-card.svelte'; + import OAuth2ConsentCard, { type OAuth2Outcome } from '../consent-card.svelte'; + import OAuth2OutcomeCard from '../outcome-card.svelte'; - type Phase = 'loading' | 'ready' | 'error'; + type Phase = 'loading' | 'ready' | 'approved' | 'denied' | 'error'; let phase = $state('loading'); let grant = $state(null); let app = $state(null); let account = $state | null>(null); let error = $state(null); + let completedRedirectUrl = $state(undefined); // OIDC `max_age` is a non-negative integer count of seconds. Reject anything // else (e.g. `max_age=abc`) so we omit the param rather than forwarding NaN. @@ -25,6 +27,20 @@ return Number.isInteger(value) && value >= 0 ? value : undefined; } + function isWebRedirect(uri: string): boolean { + try { + const { protocol } = new URL(uri); + return protocol === 'http:' || protocol === 'https:'; + } catch { + return false; + } + } + + function onDone(outcome: OAuth2Outcome, redirectUrl?: string) { + completedRedirectUrl = redirectUrl; + phase = outcome === 'approved' ? 'approved' : 'denied'; + } + // Re-runs when the authorize params change (this route can stay mounted as // the router moves between requests). Reset to loading so a previously // loaded grant can never be approved against a different request. @@ -38,6 +54,7 @@ let cancelled = false; phase = 'loading'; error = null; + completedRedirectUrl = undefined; const currentRelativeUrl = () => window.location.pathname + window.location.search; @@ -124,6 +141,14 @@ if (result.redirectUrl) { // Already consented — go straight back to the client. window.location.href = result.redirectUrl; + if (!isWebRedirect(result.redirectUrl)) { + completedRedirectUrl = result.redirectUrl; + account = loggedInAccount; + app = await sdk.forConsole.apps + .get({ appId: clientId }) + .catch(() => null); + phase = 'approved'; + } return; } if (result.grantId) { @@ -184,7 +209,15 @@ {grant} {app} accountLabel={account?.email || account?.name || undefined} - flow="authorization" /> + flow="authorization" + {onDone} /> + {:else if phase === 'approved' || phase === 'denied'} + {/if} diff --git a/src/routes/(public)/oauth2/device/+page.svelte b/src/routes/(public)/oauth2/device/+page.svelte index cc06f4bad9..15f37b0d4c 100644 --- a/src/routes/(public)/oauth2/device/+page.svelte +++ b/src/routes/(public)/oauth2/device/+page.svelte @@ -5,16 +5,13 @@ import { page } from '$app/state'; import { AppwriteException, type Models } from '@appwrite.io/console'; import { Card, Layout, Typography, Icon, Spinner } from '@appwrite.io/pink-svelte'; - import { - IconDesktopComputer, - IconCheckCircle, - IconXCircle - } from '@appwrite.io/pink-icons-svelte'; + import { IconDesktopComputer } from '@appwrite.io/pink-icons-svelte'; import { Button, Form, Label } from '$lib/elements/forms'; import { addNotification } from '$lib/stores/notifications'; import { sdk } from '$lib/stores/sdk'; import { Submit, trackError, trackEvent } from '$lib/actions/analytics'; - import OAuth2ConsentCard, { type OAuth2Flow } from '../consent-card.svelte'; + import OAuth2ConsentCard, { type OAuth2Flow, type OAuth2Outcome } from '../consent-card.svelte'; + import OAuth2OutcomeCard from '../outcome-card.svelte'; type Phase = 'loading' | 'enter-code' | 'consent' | 'approved' | 'denied'; @@ -127,7 +124,7 @@ } } - function onDeviceDone(outcome: 'approved' | 'denied') { + function onDone(outcome: OAuth2Outcome) { phase = outcome === 'approved' ? 'approved' : 'denied'; } @@ -209,32 +206,13 @@ {app} accountLabel={account?.email || account?.name || undefined} flow={DEVICE_FLOW} - {onDeviceDone} /> - {:else if phase === 'approved'} - - -
- -
- Device connected - - You've authorized {app?.name ?? 'the application'}. You can return to your - device — it will continue automatically. - -
-
- {:else if phase === 'denied'} - - -
- -
- Request cancelled - - No access was granted. You can close this page. - -
-
+ {onDone} /> + {:else if phase === 'approved' || phase === 'denied'} + {/if} @@ -300,26 +278,6 @@ opacity: 0.6; } - .success-icon-wrap { - width: 3rem; - height: 3rem; - border-radius: 50%; - background: var(--bgcolor-success-secondary, rgba(16, 185, 129, 0.1)); - display: flex; - align-items: center; - justify-content: center; - } - - .denied-icon-wrap { - width: 3rem; - height: 3rem; - border-radius: 50%; - background: var(--bgcolor-neutral-secondary, #f4f4f4); - display: flex; - align-items: center; - justify-content: center; - } - .bold { font-weight: 500; } diff --git a/src/routes/(public)/oauth2/outcome-card.svelte b/src/routes/(public)/oauth2/outcome-card.svelte new file mode 100644 index 0000000000..b70ee24710 --- /dev/null +++ b/src/routes/(public)/oauth2/outcome-card.svelte @@ -0,0 +1,244 @@ + + + +
+
+
+ {#if app?.logoUri} + {appName} + {:else} +
{appInitial}
+ {/if} + + + + + + + + +
+ +
+ {title} + + {message} + +
+
+ +
+ {#if approved && redirectUrl} + +

It's safe to close this tab.

+ {/if} + +

+ + {#if approved} + You can revoke access anytime in your account settings + {:else} + {appName} was not given access to your account + {/if} +

+
+
+
+ + From 5396441676b88fa156aa8fde4496c70773c6217b Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 6 Jul 2026 15:27:39 +0530 Subject: [PATCH 2/2] (fix): address review comments on OAuth outcome screen Guard the already-consented fast path against a cancelled effect, hide the account avatar and move the status onto the app avatar when no account label is known, and extract the duplicated isWebRedirect helper to $lib/helpers/oauth2-redirect. --- src/lib/helpers/oauth2-redirect.ts | 14 +++++++ .../(public)/oauth2/consent-card.svelte | 10 +---- .../(public)/oauth2/consent/+page.svelte | 11 +---- .../(public)/oauth2/outcome-card.svelte | 42 ++++++++++++++++--- 4 files changed, 53 insertions(+), 24 deletions(-) create mode 100644 src/lib/helpers/oauth2-redirect.ts diff --git a/src/lib/helpers/oauth2-redirect.ts b/src/lib/helpers/oauth2-redirect.ts new file mode 100644 index 0000000000..061d9046dc --- /dev/null +++ b/src/lib/helpers/oauth2-redirect.ts @@ -0,0 +1,14 @@ +/** + * Whether an OAuth2 redirect URI points back to the web (http/https) rather + * than a native application deep link (e.g. cursor://). Native redirects hand + * off to the OS and leave the tab open, so the console shows an outcome + * screen instead of relying on the navigation. + */ +export function isWebRedirect(uri: string): boolean { + try { + const { protocol } = new URL(uri); + return protocol === 'http:' || protocol === 'https:'; + } catch { + return false; + } +} diff --git a/src/routes/(public)/oauth2/consent-card.svelte b/src/routes/(public)/oauth2/consent-card.svelte index 1c372d1bf0..20a52ea58b 100644 --- a/src/routes/(public)/oauth2/consent-card.svelte +++ b/src/routes/(public)/oauth2/consent-card.svelte @@ -18,6 +18,7 @@ import { sdk } from '$lib/stores/sdk'; import { Submit, trackError, trackEvent } from '$lib/actions/analytics'; import { splitConsentScopes, buildConsentPermissions } from '$lib/helpers/oauth2-scopes'; + import { isWebRedirect } from '$lib/helpers/oauth2-redirect'; import { parseAuthorizationDetails, mergeIdentifiers, @@ -45,15 +46,6 @@ } } - function isWebRedirect(uri: string): boolean { - try { - const { protocol } = new URL(uri); - return protocol === 'http:' || protocol === 'https:'; - } catch { - return false; - } - } - // Scope tokens are never rendered inline — they add noise. Instead each row // exposes a hover copy button that yields the raw token, exactly as issued // (prefix included), for anyone who needs the precise string. diff --git a/src/routes/(public)/oauth2/consent/+page.svelte b/src/routes/(public)/oauth2/consent/+page.svelte index cf3fa1435a..799ea54d2b 100644 --- a/src/routes/(public)/oauth2/consent/+page.svelte +++ b/src/routes/(public)/oauth2/consent/+page.svelte @@ -7,6 +7,7 @@ import { IconExclamation } from '@appwrite.io/pink-icons-svelte'; import { Button } from '$lib/elements/forms'; import { sdk } from '$lib/stores/sdk'; + import { isWebRedirect } from '$lib/helpers/oauth2-redirect'; import OAuth2ConsentCard, { type OAuth2Outcome } from '../consent-card.svelte'; import OAuth2OutcomeCard from '../outcome-card.svelte'; @@ -27,15 +28,6 @@ return Number.isInteger(value) && value >= 0 ? value : undefined; } - function isWebRedirect(uri: string): boolean { - try { - const { protocol } = new URL(uri); - return protocol === 'http:' || protocol === 'https:'; - } catch { - return false; - } - } - function onDone(outcome: OAuth2Outcome, redirectUrl?: string) { completedRedirectUrl = redirectUrl; phase = outcome === 'approved' ? 'approved' : 'denied'; @@ -147,6 +139,7 @@ app = await sdk.forConsole.apps .get({ appId: clientId }) .catch(() => null); + if (cancelled) return; phase = 'approved'; } return; diff --git a/src/routes/(public)/oauth2/outcome-card.svelte b/src/routes/(public)/oauth2/outcome-card.svelte index b70ee24710..bd6347cf31 100644 --- a/src/routes/(public)/oauth2/outcome-card.svelte +++ b/src/routes/(public)/oauth2/outcome-card.svelte @@ -58,14 +58,20 @@ {:else}
{appInitial}
{/if} - - - + {#if accountLabel} + + + + + + + + + {:else} + - - - + {/if}
@@ -119,11 +125,35 @@ connector becomes a solid line with a status node when approved; stays dashed with an X when the request was cancelled. */ .identity { + position: relative; display: flex; align-items: center; justify-content: center; } + /* Fallback when no account is known: the status sits on the app avatar's + corner instead of in the connector. */ + .status-badge { + position: absolute; + right: -0.5rem; + bottom: -0.35rem; + display: flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + border-radius: 50%; + border: 1px solid var(--border-neutral); + background: var(--bgcolor-neutral-primary); + color: var(--fgcolor-neutral-secondary); + } + + .status-badge.approved { + border-color: var(--border-success, rgba(16, 185, 129, 0.45)); + background: var(--bgcolor-success-weaker, rgba(16, 185, 129, 0.12)); + color: var(--fgcolor-success); + } + .avatar { width: 3.5rem; height: 3.5rem;