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 edc30531be..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, @@ -35,6 +36,7 @@ import ResourceSelector from './resource-selector.svelte'; export type OAuth2Flow = 'authorization' | 'device'; + export type OAuth2Outcome = 'approved' | 'denied'; function hostnameOf(uri: string): string | null { try { @@ -65,10 +67,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 +204,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 +232,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..799ea54d2b 100644 --- a/src/routes/(public)/oauth2/consent/+page.svelte +++ b/src/routes/(public)/oauth2/consent/+page.svelte @@ -7,15 +7,18 @@ 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 { isWebRedirect } from '$lib/helpers/oauth2-redirect'; + 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 +28,11 @@ return Number.isInteger(value) && value >= 0 ? value : undefined; } + 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 +46,7 @@ let cancelled = false; phase = 'loading'; error = null; + completedRedirectUrl = undefined; const currentRelativeUrl = () => window.location.pathname + window.location.search; @@ -124,6 +133,15 @@ 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); + if (cancelled) return; + phase = 'approved'; + } return; } if (result.grantId) { @@ -184,7 +202,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..bd6347cf31 --- /dev/null +++ b/src/routes/(public)/oauth2/outcome-card.svelte @@ -0,0 +1,274 @@ + + + +
+
+
+ {#if app?.logoUri} + {appName} + {:else} +
{appInitial}
+ {/if} + {#if accountLabel} + + + + + + + + + {:else} + + + + {/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} +

+
+
+
+ +