Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/lib/helpers/oauth2-redirect.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
16 changes: 12 additions & 4 deletions src/routes/(public)/oauth2/consent-card.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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<string | null>(null);
let approving = $state(false);
Expand Down Expand Up @@ -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';
Expand All @@ -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';
Expand Down
32 changes: 29 additions & 3 deletions src/routes/(public)/oauth2/consent/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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<Phase>('loading');
let grant = $state<Models.Oauth2Grant | null>(null);
let app = $state<Models.App | null>(null);
let account = $state<Models.User<Models.Preferences> | null>(null);
let error = $state<string | null>(null);
let completedRedirectUrl = $state<string | undefined>(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.
Expand All @@ -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.
Expand All @@ -38,6 +46,7 @@
let cancelled = false;
phase = 'loading';
error = null;
completedRedirectUrl = undefined;

const currentRelativeUrl = () => window.location.pathname + window.location.search;

Expand Down Expand Up @@ -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;
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
if (result.grantId) {
Expand Down Expand Up @@ -184,7 +202,15 @@
{grant}
{app}
accountLabel={account?.email || account?.name || undefined}
flow="authorization" />
flow="authorization"
{onDone} />
{:else if phase === 'approved' || phase === 'denied'}
<OAuth2OutcomeCard
outcome={phase}
flow="authorization"
{app}
accountLabel={account?.email || account?.name || undefined}
redirectUrl={completedRedirectUrl} />
{/if}
</div>
</div>
Expand Down
64 changes: 11 additions & 53 deletions src/routes/(public)/oauth2/device/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -127,7 +124,7 @@
}
}

function onDeviceDone(outcome: 'approved' | 'denied') {
function onDone(outcome: OAuth2Outcome) {
phase = outcome === 'approved' ? 'approved' : 'denied';
}
</script>
Expand Down Expand Up @@ -209,32 +206,13 @@
{app}
accountLabel={account?.email || account?.name || undefined}
flow={DEVICE_FLOW}
{onDeviceDone} />
{:else if phase === 'approved'}
<Card.Base padding="l" radius="l" style="width: 100%;">
<Layout.Stack gap="l" alignItems="center" alignContent="center">
<div class="success-icon-wrap">
<Icon icon={IconCheckCircle} size="l" color="--fgcolor-success" />
</div>
<Typography.Title size="m" align="center">Device connected</Typography.Title>
<Typography.Text variant="m-400" align="center">
You've authorized {app?.name ?? 'the application'}. You can return to your
device — it will continue automatically.
</Typography.Text>
</Layout.Stack>
</Card.Base>
{:else if phase === 'denied'}
<Card.Base padding="l" radius="l" style="width: 100%;">
<Layout.Stack gap="l" alignItems="center" alignContent="center">
<div class="denied-icon-wrap">
<Icon icon={IconXCircle} size="l" color="--fgcolor-neutral-secondary" />
</div>
<Typography.Title size="m" align="center">Request cancelled</Typography.Title>
<Typography.Text variant="m-400" align="center">
No access was granted. You can close this page.
</Typography.Text>
</Layout.Stack>
</Card.Base>
{onDone} />
{:else if phase === 'approved' || phase === 'denied'}
<OAuth2OutcomeCard
outcome={phase}
flow={DEVICE_FLOW}
{app}
accountLabel={account?.email || account?.name || undefined} />
{/if}
</div>
</div>
Expand Down Expand Up @@ -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;
}
Expand Down
Loading