Skip to content

Commit 09ec282

Browse files
authored
Merge pull request #3067 from appwrite/feat-oauth2server-settings
feat: add OAuth2 server settings card and update SDK
2 parents f29b4ba + 01273a1 commit 09ec282

6 files changed

Lines changed: 309 additions & 4 deletions

File tree

bun.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
},
2121
"dependencies": {
2222
"@ai-sdk/svelte": "^1.1.24",
23-
"@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@9786d91",
23+
"@appwrite.io/console": "https://pkg.vc/-/@appwrite/@appwrite.io/console@5c7f8e5",
2424
"@appwrite.io/pink-icons": "0.25.0",
2525
"@appwrite.io/pink-icons-svelte": "https://pkg.vc/-/@appwrite/@appwrite.io/pink-icons-svelte@bfe7ce3",
2626
"@appwrite.io/pink-legacy": "^1.0.3",

src/lib/actions/analytics.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,7 @@ export enum Submit {
255255
ProjectUpdateLabels = 'submit_project_update_labels',
256256
ProjectService = 'submit_project_service',
257257
ProjectUpdateSMTP = 'submit_project_update_smtp',
258+
ProjectUpdateOAuth2Server = 'submit_project_update_oauth2_server',
258259
ProjectResume = 'submit_project_resume',
259260
MemberCreate = 'submit_member_create',
260261
MemberDelete = 'submit_member_delete',

src/lib/flags.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,6 @@ function isFlagEnabled(name: string) {
2020

2121
export const flags = {
2222
multiDb: isFlagEnabled('multi-db'),
23-
granularProjectAccess: isFlagEnabled('granular-project-access')
23+
granularProjectAccess: isFlagEnabled('granular-project-access'),
24+
oauth2Server: isFlagEnabled('oauth2-server')
2425
};

src/routes/(console)/project-[region]-[project]/settings/+page.svelte

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,12 @@
1818
import UpdateVariables from '../updateVariables.svelte';
1919
import { page } from '$app/state';
2020
import UpdateLabels from './updateLabels.svelte';
21+
import UpdateOAuth2Server from './updateOAuth2Server.svelte';
2122
import type { PageData } from './$types';
2223
import { Alert } from '@appwrite.io/pink-svelte';
24+
import { flags } from '$lib/flags';
25+
import { user } from '$lib/stores/user';
26+
import { organization } from '$lib/stores/organization';
2327
2428
let { data }: { data: PageData } = $props();
2529
@@ -95,6 +99,9 @@
9599
<UpdateName />
96100
<UpdateLabels />
97101
<UpdateProtocols />
102+
{#if flags.oauth2Server({ account: $user, organization: $organization })}
103+
<UpdateOAuth2Server />
104+
{/if}
98105
<UpdateServices />
99106
<UpdateInstallations {...data.installations} limit={data.limit} offset={data.offset} />
100107
<UpdateVariables
Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
<script lang="ts">
2+
import { invalidate } from '$app/navigation';
3+
import { Submit, trackError, trackEvent } from '$lib/actions/analytics';
4+
import { CardGrid } from '$lib/components';
5+
import { Dependencies } from '$lib/constants';
6+
import { Button, Form, InputNumber, InputTags, InputText } from '$lib/elements/forms';
7+
import InputSelect from '$lib/elements/forms/inputSelect.svelte';
8+
import { addNotification } from '$lib/stores/notifications';
9+
import { canWriteProjects } from '$lib/stores/roles';
10+
import { sdk } from '$lib/stores/sdk';
11+
import { Divider, Icon, Layout, Selector, Tooltip, Typography } from '@appwrite.io/pink-svelte';
12+
import { IconInfo } from '@appwrite.io/pink-icons-svelte';
13+
import deepEqual from 'deep-equal';
14+
import { project } from '../store';
15+
16+
type TimeUnit = 'seconds' | 'minutes' | 'hours' | 'days';
17+
18+
const multipliers: Record<TimeUnit, number> = {
19+
seconds: 1,
20+
minutes: 60,
21+
hours: 3600,
22+
days: 86400
23+
};
24+
25+
const unitOptions: { value: TimeUnit; label: string }[] = [
26+
{ value: 'seconds', label: 'Seconds' },
27+
{ value: 'minutes', label: 'Minutes' },
28+
{ value: 'hours', label: 'Hours' },
29+
{ value: 'days', label: 'Days' }
30+
];
31+
32+
function fromSeconds(
33+
s: number | null,
34+
defaultUnit: TimeUnit = 'hours'
35+
): { value: number | null; unit: TimeUnit } {
36+
if (s === null) return { value: null, unit: defaultUnit };
37+
if (s % 86400 === 0) return { value: s / 86400, unit: 'days' };
38+
if (s % 3600 === 0) return { value: s / 3600, unit: 'hours' };
39+
if (s % 60 === 0) return { value: s / 60, unit: 'minutes' };
40+
return { value: s, unit: 'seconds' };
41+
}
42+
43+
function toSeconds(value: number | null, unit: TimeUnit): number | null {
44+
return value !== null ? value * multipliers[unit] : null;
45+
}
46+
47+
let enabled = $state(false);
48+
let authorizationUrl = $state('');
49+
let scopes = $state<string[]>([]);
50+
51+
let accessTokenValue = $state<number | null>(null);
52+
let accessTokenUnit = $state<TimeUnit>('hours');
53+
let refreshTokenValue = $state<number | null>(null);
54+
let refreshTokenUnit = $state<TimeUnit>('days');
55+
let publicAccessTokenValue = $state<number | null>(null);
56+
let publicAccessTokenUnit = $state<TimeUnit>('hours');
57+
let publicRefreshTokenValue = $state<number | null>(null);
58+
let publicRefreshTokenUnit = $state<TimeUnit>('days');
59+
let confidentialPkce = $state(false);
60+
61+
const accessTokenDuration = $derived(toSeconds(accessTokenValue, accessTokenUnit));
62+
const refreshTokenDuration = $derived(toSeconds(refreshTokenValue, refreshTokenUnit));
63+
const publicAccessTokenDuration = $derived(
64+
toSeconds(publicAccessTokenValue, publicAccessTokenUnit)
65+
);
66+
const publicRefreshTokenDuration = $derived(
67+
toSeconds(publicRefreshTokenValue, publicRefreshTokenUnit)
68+
);
69+
70+
const isButtonDisabled = $derived(
71+
!$canWriteProjects ||
72+
deepEqual(
73+
{
74+
enabled,
75+
authorizationUrl,
76+
scopes,
77+
accessTokenDuration,
78+
refreshTokenDuration,
79+
publicAccessTokenDuration,
80+
publicRefreshTokenDuration,
81+
confidentialPkce
82+
},
83+
{
84+
enabled: $project.oAuth2ServerEnabled ?? false,
85+
authorizationUrl: $project.oAuth2ServerAuthorizationUrl ?? '',
86+
scopes: $project.oAuth2ServerScopes ?? [],
87+
accessTokenDuration: $project.oAuth2ServerAccessTokenDuration ?? null,
88+
refreshTokenDuration: $project.oAuth2ServerRefreshTokenDuration ?? null,
89+
publicAccessTokenDuration:
90+
$project.oAuth2ServerPublicAccessTokenDuration ?? null,
91+
publicRefreshTokenDuration:
92+
$project.oAuth2ServerPublicRefreshTokenDuration ?? null,
93+
confidentialPkce: $project.oAuth2ServerConfidentialPkce ?? false
94+
}
95+
)
96+
);
97+
98+
async function update() {
99+
try {
100+
await sdk.forProject($project.region, $project.$id).project.updateOAuth2Server({
101+
enabled,
102+
authorizationUrl,
103+
scopes,
104+
accessTokenDuration: accessTokenDuration ?? undefined,
105+
refreshTokenDuration: refreshTokenDuration ?? undefined,
106+
publicAccessTokenDuration: publicAccessTokenDuration ?? undefined,
107+
publicRefreshTokenDuration: publicRefreshTokenDuration ?? undefined,
108+
confidentialPkce
109+
});
110+
111+
await invalidate(Dependencies.PROJECT);
112+
113+
addNotification({
114+
type: 'success',
115+
message: 'OAuth2 server settings have been updated.'
116+
});
117+
trackEvent(Submit.ProjectUpdateOAuth2Server);
118+
} catch (error) {
119+
addNotification({ type: 'error', message: error.message });
120+
trackError(error, Submit.ProjectUpdateOAuth2Server);
121+
}
122+
}
123+
124+
$effect(() => {
125+
enabled = $project.oAuth2ServerEnabled ?? false;
126+
authorizationUrl = $project.oAuth2ServerAuthorizationUrl ?? '';
127+
scopes = $project.oAuth2ServerScopes ?? [];
128+
129+
const at = fromSeconds($project.oAuth2ServerAccessTokenDuration ?? null, 'hours');
130+
accessTokenValue = at.value;
131+
accessTokenUnit = at.unit;
132+
133+
const rt = fromSeconds($project.oAuth2ServerRefreshTokenDuration ?? null, 'days');
134+
refreshTokenValue = rt.value;
135+
refreshTokenUnit = rt.unit;
136+
137+
const pat = fromSeconds($project.oAuth2ServerPublicAccessTokenDuration ?? null, 'hours');
138+
publicAccessTokenValue = pat.value;
139+
publicAccessTokenUnit = pat.unit;
140+
141+
const prt = fromSeconds($project.oAuth2ServerPublicRefreshTokenDuration ?? null, 'days');
142+
publicRefreshTokenValue = prt.value;
143+
publicRefreshTokenUnit = prt.unit;
144+
145+
confidentialPkce = $project.oAuth2ServerConfidentialPkce ?? false;
146+
});
147+
</script>
148+
149+
<Form onSubmit={update}>
150+
<CardGrid>
151+
<svelte:fragment slot="title">OAuth2 server</svelte:fragment>
152+
Configure your project as an OAuth2 authorization server. When enabled, external applications
153+
can authenticate users through your project using the OAuth2 protocol.
154+
<svelte:fragment slot="aside">
155+
<Selector.Switch
156+
id="oauth2-server-enabled"
157+
bind:checked={enabled}
158+
label="Enable OAuth2 server"
159+
description="Allow external applications to authenticate users through your project."
160+
disabled={!$canWriteProjects} />
161+
162+
{#if enabled}
163+
<InputText
164+
id="oauth2-authorization-url"
165+
label="Authorization URL"
166+
bind:value={authorizationUrl}
167+
required
168+
placeholder="https://example.com/consent"
169+
disabled={!$canWriteProjects}>
170+
<Tooltip slot="info">
171+
<Icon icon={IconInfo} size="s" />
172+
<span slot="tooltip"
173+
>The consent screen URL shown to users during the OAuth2 authorization
174+
flow.</span>
175+
</Tooltip>
176+
</InputText>
177+
178+
<InputTags
179+
id="oauth2-scopes"
180+
label="Scopes"
181+
bind:tags={scopes}
182+
placeholder="e.g. profile"
183+
max={100}
184+
disabled={!$canWriteProjects}>
185+
<Tooltip slot="info">
186+
<Icon icon={IconInfo} size="s" />
187+
<span slot="tooltip"
188+
>OAuth2 scopes this server will accept. Up to 100 scopes, each up to 128
189+
characters long.</span>
190+
</Tooltip>
191+
</InputTags>
192+
193+
<Divider />
194+
195+
<Layout.Stack gap="xs">
196+
<Typography.Text variant="m-500">Confidential clients</Typography.Text>
197+
<Typography.Caption variant="400">
198+
Server-side apps that authenticate with a client secret.
199+
</Typography.Caption>
200+
</Layout.Stack>
201+
202+
<div class="duration-field">
203+
<InputNumber
204+
id="oauth2-access-token-duration"
205+
label="Access token duration"
206+
bind:value={accessTokenValue}
207+
placeholder="8"
208+
min={1}
209+
disabled={!$canWriteProjects} />
210+
<InputSelect
211+
id="oauth2-access-token-unit"
212+
required
213+
bind:value={accessTokenUnit}
214+
options={unitOptions}
215+
disabled={!$canWriteProjects} />
216+
</div>
217+
218+
<div class="duration-field">
219+
<InputNumber
220+
id="oauth2-refresh-token-duration"
221+
label="Refresh token duration"
222+
bind:value={refreshTokenValue}
223+
placeholder="365"
224+
min={1}
225+
disabled={!$canWriteProjects} />
226+
<InputSelect
227+
id="oauth2-refresh-token-unit"
228+
required
229+
bind:value={refreshTokenUnit}
230+
options={unitOptions}
231+
disabled={!$canWriteProjects} />
232+
</div>
233+
234+
<Selector.Switch
235+
id="oauth2-confidential-pkce"
236+
bind:checked={confidentialPkce}
237+
label="Require PKCE"
238+
description="When enabled, confidential clients must use PKCE in addition to their client secret. Public clients always require PKCE."
239+
disabled={!$canWriteProjects} />
240+
241+
<Divider />
242+
243+
<Layout.Stack gap="xs">
244+
<Typography.Text variant="m-500">Public clients</Typography.Text>
245+
<Typography.Caption variant="400">
246+
SPAs, mobile, and native apps that cannot keep a client secret.
247+
</Typography.Caption>
248+
</Layout.Stack>
249+
250+
<div class="duration-field">
251+
<InputNumber
252+
id="oauth2-public-access-token-duration"
253+
label="Access token duration"
254+
bind:value={publicAccessTokenValue}
255+
placeholder="1"
256+
min={1}
257+
disabled={!$canWriteProjects} />
258+
<InputSelect
259+
id="oauth2-public-access-token-unit"
260+
required
261+
bind:value={publicAccessTokenUnit}
262+
options={unitOptions}
263+
disabled={!$canWriteProjects} />
264+
</div>
265+
266+
<div class="duration-field">
267+
<InputNumber
268+
id="oauth2-public-refresh-token-duration"
269+
label="Refresh token duration"
270+
bind:value={publicRefreshTokenValue}
271+
placeholder="30"
272+
min={1}
273+
disabled={!$canWriteProjects} />
274+
<InputSelect
275+
id="oauth2-public-refresh-token-unit"
276+
required
277+
bind:value={publicRefreshTokenUnit}
278+
options={unitOptions}
279+
disabled={!$canWriteProjects} />
280+
</div>
281+
{/if}
282+
</svelte:fragment>
283+
<svelte:fragment slot="actions">
284+
<Button submit disabled={isButtonDisabled}>Update</Button>
285+
</svelte:fragment>
286+
</CardGrid>
287+
</Form>
288+
289+
<style>
290+
.duration-field {
291+
display: grid;
292+
grid-template-columns: 1fr 8rem;
293+
gap: var(--space-4);
294+
align-items: end;
295+
}
296+
</style>

0 commit comments

Comments
 (0)