mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
fix: require users to authenticate existing Immich account before OAuth linking
This commit is contained in:
@@ -51,6 +51,7 @@ export const Docs = {
|
||||
export const Route = {
|
||||
// auth
|
||||
login: (params?: { continue?: string; autoLaunch?: 0 | 1 }) => '/auth/login' + asQueryString(params),
|
||||
authLink: (params?: { linkToken?: string; email?: string }) => '/auth/link' + asQueryString(params),
|
||||
logout: (params?: { continue?: string }) => '/auth/logout' + asQueryString(params),
|
||||
register: () => '/auth/register',
|
||||
changePassword: () => '/auth/change-password',
|
||||
|
||||
@@ -294,7 +294,7 @@ export const oauth = {
|
||||
return finishOAuth({ oAuthCallbackDto: { url: location.href } });
|
||||
},
|
||||
link: (location: Location) => {
|
||||
return linkOAuthAccount({ oAuthCallbackDto: { url: location.href } });
|
||||
return linkOAuthAccount({ oAuthLinkDto: { url: location.href } });
|
||||
},
|
||||
unlink: () => {
|
||||
return unlinkOAuthAccount();
|
||||
|
||||
@@ -2,32 +2,13 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { oauth } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { Button, LoadingSpinner, toastManager } from '@immich/ui';
|
||||
import { onMount } from 'svelte';
|
||||
import { Button, toastManager } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
let loading = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
if (oauth.isCallback(globalThis.location)) {
|
||||
try {
|
||||
loading = true;
|
||||
const response = await oauth.link(globalThis.location);
|
||||
authManager.setUser(response);
|
||||
toastManager.primary($t('linked_oauth_account'));
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_link_oauth_account'));
|
||||
} finally {
|
||||
await goto('?open=oauth');
|
||||
}
|
||||
}
|
||||
|
||||
loading = false;
|
||||
});
|
||||
|
||||
const handleUnlink = async () => {
|
||||
try {
|
||||
const response = await oauth.unlink();
|
||||
@@ -42,15 +23,11 @@
|
||||
<section class="my-4">
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<div class="sm:ms-8 flex justify-end">
|
||||
{#if loading}
|
||||
<div class="flex place-content-center place-items-center">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
{:else if featureFlagsManager.value.oauth}
|
||||
{#if featureFlagsManager.value.oauth}
|
||||
{#if authManager.user.oauthId}
|
||||
<Button shape="round" size="small" onclick={() => handleUnlink()}>{$t('unlink_oauth')}</Button>
|
||||
{:else}
|
||||
<Button shape="round" size="small" onclick={() => oauth.authorize(globalThis.location)}
|
||||
<Button shape="round" size="small" onclick={() => goto(Route.login({ autoLaunch: 1 }))}
|
||||
>{$t('link_to_oauth')}</Button
|
||||
>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import AuthPageLayout from '$lib/components/layouts/AuthPageLayout.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { getServerErrorMessage } from '$lib/utils/handle-error';
|
||||
import { linkOAuthAccount, login } from '@immich/sdk';
|
||||
import { Alert, Button, Field, Input, PasswordInput, Stack, toastManager } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
interface Props {
|
||||
data: PageData;
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
let email = $state(data.email || authManager.user?.email || '');
|
||||
let password = $state('');
|
||||
let errorMessage = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
// Strip sensitive params from URL after reading — keeps linkToken out of browser history
|
||||
history.replaceState(null, '', Route.authLink());
|
||||
|
||||
const handleSubmit = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
errorMessage = '';
|
||||
loading = true;
|
||||
const user = await login({ loginCredentialDto: { email, password } });
|
||||
eventManager.emit('AuthLogin', user);
|
||||
|
||||
const response = await linkOAuthAccount({ oAuthLinkDto: { linkToken: data.linkToken } });
|
||||
authManager.setUser(response);
|
||||
toastManager.primary($t('linked_oauth_account'));
|
||||
await goto(Route.photos(), { invalidateAll: true });
|
||||
} catch (error) {
|
||||
errorMessage = getServerErrorMessage(error) || $t('errors.incorrect_email_or_password');
|
||||
loading = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<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}
|
||||
</Stack>
|
||||
</AuthPageLayout>
|
||||
@@ -0,0 +1,16 @@
|
||||
import { getFormatter } from '$lib/utils/i18n';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load = (async ({ url }) => {
|
||||
const linkToken = url.searchParams.get('linkToken') || '';
|
||||
const email = url.searchParams.get('email') || '';
|
||||
|
||||
const $t = await getFormatter();
|
||||
return {
|
||||
meta: {
|
||||
title: $t('link_to_oauth'),
|
||||
},
|
||||
linkToken,
|
||||
email,
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
@@ -1,14 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import AuthPageLayout from '$lib/components/layouts/AuthPageLayout.svelte';
|
||||
import { OpenQueryParam } from '$lib/constants';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { oauth } from '$lib/utils';
|
||||
import { getServerErrorMessage, handleError } from '$lib/utils/handle-error';
|
||||
import { login, type LoginResponseDto } from '@immich/sdk';
|
||||
import { Alert, Button, Field, Input, PasswordInput, Stack } from '@immich/ui';
|
||||
import { isHttpError, login, type LoginResponseDto } 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';
|
||||
@@ -43,6 +45,20 @@
|
||||
}
|
||||
|
||||
if (oauth.isCallback(globalThis.location)) {
|
||||
const params = new URLSearchParams(globalThis.location.search);
|
||||
if (params.has('error')) {
|
||||
if (authManager.authenticated) {
|
||||
const message = params.get('error_description') || $t('errors.unable_to_link_oauth_account');
|
||||
await goto(Route.userSettings({ isOpen: OpenQueryParam.OAUTH }));
|
||||
toastManager.warning(message);
|
||||
} else {
|
||||
oauthError =
|
||||
params.get('error_description') || params.get('error') || $t('errors.unable_to_complete_oauth_login');
|
||||
oauthLoading = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await oauth.login(globalThis.location);
|
||||
|
||||
@@ -54,6 +70,11 @@
|
||||
await onSuccess(user);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (isHttpError(error) && error.data?.message === 'oauth_account_link_required') {
|
||||
const errorData = error.data as unknown as Record<string, string>;
|
||||
await goto(Route.authLink({ linkToken: errorData.linkToken, email: errorData.userEmail }));
|
||||
return;
|
||||
}
|
||||
console.error('Error [login-form] [oauth.callback]', error);
|
||||
oauthError = getServerErrorMessage(error) || $t('errors.unable_to_complete_oauth_login');
|
||||
oauthLoading = false;
|
||||
|
||||
@@ -9,7 +9,9 @@ export const load = (async ({ parent, url }) => {
|
||||
await parent();
|
||||
|
||||
const continueUrl = url.searchParams.get('continue') || Route.photos();
|
||||
if (authManager.authenticated) {
|
||||
const isOAuthCallback = url.searchParams.has('code') || url.searchParams.has('error');
|
||||
const isOAuthAutoLaunch = url.searchParams.has('autoLaunch');
|
||||
if (authManager.authenticated && !isOAuthCallback && !isOAuthAutoLaunch) {
|
||||
redirect(307, continueUrl);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user