feat: oauth re-link via admin-provided token

This commit is contained in:
bo0tzz
2026-04-23 13:11:34 +02:00
parent 2da2bef777
commit 00f83e7c66
17 changed files with 615 additions and 85 deletions
@@ -5,7 +5,7 @@
import { Route } from '$lib/route';
import { oauth } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { Button, toastManager } from '@immich/ui';
import { Button, Stack, Text, toastManager } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
@@ -20,18 +20,28 @@
};
</script>
<section class="my-4">
<div in:fade={{ duration: 500 }}>
<div class="sm:ms-8 flex justify-end">
{#if featureFlagsManager.value.oauth}
{#if featureFlagsManager.value.oauth}
<section class="my-4">
<div in:fade={{ duration: 500 }}>
<Stack gap={3}>
{#if authManager.user.oauthId}
<Button shape="round" size="small" onclick={() => handleUnlink()}>{$t('unlink_oauth')}</Button>
<Text>{$t('oauth_account_is_linked')}</Text>
{#if featureFlagsManager.value.passwordLogin}
<div class="sm:ms-8 flex justify-end">
<Button shape="round" size="small" color="danger" onclick={() => handleUnlink()}>
{$t('unlink_oauth')}
</Button>
</div>
{/if}
{:else}
<Button shape="round" size="small" onclick={() => goto(Route.login({ autoLaunch: 1 }))}
>{$t('link_to_oauth')}</Button
>
<Text>{$t('oauth_account_not_linked')}</Text>
<div class="sm:ms-8 flex justify-end">
<Button shape="round" size="small" onclick={() => goto(Route.login({ autoLaunch: 1 }))}>
{$t('link_to_oauth')}
</Button>
</div>
{/if}
{/if}
</Stack>
</div>
</div>
</section>
</section>
{/if}
+93 -46
View File
@@ -5,9 +5,11 @@
import { eventManager } from '$lib/managers/event-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { Route } from '$lib/route';
import { oauth } from '$lib/utils';
import { getServerErrorMessage, handleError } from '$lib/utils/handle-error';
import { login, register } from '@immich/sdk';
import { isHttpError, login, register, startOAuthReLink } from '@immich/sdk';
import { Alert, Button, Field, Input, PasswordInput, Stack, toastManager } from '@immich/ui';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
@@ -22,6 +24,41 @@
let errorMessage = $state('');
let loading = $state(false);
let registering = $state(false);
let reLinkMode = $state(!!data.reLinkToken);
let reLinkLoading = $state(!!data.reLinkToken);
let reLinkError = $state('');
onMount(async () => {
if (oauth.isCallback(globalThis.location)) {
reLinkLoading = true;
try {
const user = await oauth.login(globalThis.location);
eventManager.emit('AuthLogin', user);
await authManager.refresh();
toastManager.primary($t('linked_oauth_account'));
await goto(Route.photos(), { invalidateAll: true });
} catch (error) {
reLinkLoading = false;
reLinkMode = false;
reLinkError =
getServerErrorMessage(error) ||
(isHttpError(error) ? error.message : undefined) ||
$t('errors.unable_to_complete_oauth_login');
}
return;
}
if (data.reLinkToken) {
try {
await startOAuthReLink({ oAuthReLinkStartDto: { token: data.reLinkToken } });
await oauth.authorize(globalThis.location);
} catch (error) {
reLinkLoading = false;
reLinkMode = false;
reLinkError = getServerErrorMessage(error) || $t('errors.invalid_oauth_relink_token');
}
}
});
const handleSubmit = async (event: Event) => {
event.preventDefault();
@@ -55,56 +92,66 @@
<AuthPageLayout title={data.meta.title}>
<Stack gap={4}>
{#if featureFlagsManager.value.passwordLogin}
<Alert color="primary">
{$t('oauth_link_existing_account')}
</Alert>
<form onsubmit={handleSubmit} class="flex flex-col gap-4">
{#if errorMessage}
<Alert color="danger" title={errorMessage} closable />
{/if}
<Field label={$t('email')}>
<Input id="email" name="email" type="email" autocomplete="email" bind:value={email} />
</Field>
<Field label={$t('password')}>
<PasswordInput id="password" bind:value={password} autocomplete="current-password" />
</Field>
<Button type="submit" size="large" shape="round" fullWidth {loading} class="mt-6">
{$t('to_login')}
</Button>
</form>
{:else}
<Alert color="warning">
{$t('oauth_link_password_login_required')}
</Alert>
{#if reLinkError}
<Alert color="danger" title={reLinkError} closable />
{/if}
{#if featureFlagsManager.value.oauthAutoRegister}
{#if reLinkMode && reLinkLoading}
<Alert color="primary">
{$t('oauth_relink_in_progress')}
</Alert>
{:else}
{#if featureFlagsManager.value.passwordLogin}
<div class="inline-flex w-full items-center justify-center my-4">
<hr class="my-4 h-px w-3/4 border-0 bg-gray-200 dark:bg-gray-600" />
<span
class="absolute start-1/2 -translate-x-1/2 bg-gray-50 px-3 font-medium text-gray-900 dark:bg-neutral-900 dark:text-white uppercase"
>
{$t('or')}
</span>
</div>
<Alert color="primary">
{$t('oauth_link_existing_account')}
</Alert>
<form onsubmit={handleSubmit} class="flex flex-col gap-4">
{#if errorMessage}
<Alert color="danger" title={errorMessage} closable />
{/if}
<Field label={$t('email')}>
<Input id="email" name="email" type="email" autocomplete="email" bind:value={email} />
</Field>
<Field label={$t('password')}>
<PasswordInput id="password" bind:value={password} autocomplete="current-password" />
</Field>
<Button type="submit" size="large" shape="round" fullWidth {loading} class="mt-6">
{$t('to_login')}
</Button>
</form>
{:else}
<Alert color="warning">
{$t('oauth_link_password_login_required')}
</Alert>
{/if}
<Button
shape="round"
size="large"
fullWidth
color={featureFlagsManager.value.passwordLogin ? 'secondary' : 'primary'}
loading={registering}
onclick={handleRegister}
>
{$t('create_new_account')}
</Button>
{#if featureFlagsManager.value.oauthAutoRegister}
{#if featureFlagsManager.value.passwordLogin}
<div class="inline-flex w-full items-center justify-center my-4">
<hr class="my-4 h-px w-3/4 border-0 bg-gray-200 dark:bg-gray-600" />
<span
class="absolute start-1/2 -translate-x-1/2 bg-gray-50 px-3 font-medium text-gray-900 dark:bg-neutral-900 dark:text-white uppercase"
>
{$t('or')}
</span>
</div>
{/if}
<Button
shape="round"
size="large"
fullWidth
color={featureFlagsManager.value.passwordLogin ? 'secondary' : 'primary'}
loading={registering}
onclick={handleRegister}
>
{$t('create_new_account')}
</Button>
{/if}
{/if}
</Stack>
</AuthPageLayout>
+2
View File
@@ -3,6 +3,7 @@ import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
const email = url.searchParams.get('email') || '';
const reLinkToken = url.searchParams.get('token') || '';
const $t = await getFormatter();
return {
@@ -10,5 +11,6 @@ export const load = (async ({ url }) => {
title: $t('link_to_oauth'),
},
email,
reLinkToken,
};
}) satisfies PageLoad;