Files
Immich/web/src/routes/auth/login/+page.svelte
T
Jason RasmussenCodexcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>jrasm91
5d1167a36c refactor: apikeys, passkeys, and sessions (#32670)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: jrasm91 <4334196+jrasm91@users.noreply.github.com>
2026-10-24 11:51:43 +03:00

236 lines
7.5 KiB
Svelte

<script lang="ts">
import { goto } from '$app/navigation';
import AuthPageLayout from '$lib/components/layouts/AuthPageLayout.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { createPreloadedQuery, MutationCancelledError } from '$lib/queries/client';
import { getPublicConfigQuery } from '$lib/queries/config-public.query';
import { loginWithPasskeyMutation } from '$lib/queries/passkey.query';
import { getServerConfigQuery } from '$lib/queries/server.query';
import { Route } from '$lib/route';
import { browserSupportsWebAuthn } from '@simplewebauthn/browser';
import { createMutation } from '@tanstack/svelte-query';
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 { mdiShieldKeyOutline } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
interface Props {
data: PageData;
}
let { data }: Props = $props();
let errorMessage: string = $state('');
let email = $state('');
let password = $state('');
let oauthError = $state('');
let loading = $state(false);
let oauthLoading = $state(true);
const queries = {
publicConfig: createPreloadedQuery(getPublicConfigQuery),
serverConfig: createPreloadedQuery(getServerConfigQuery),
};
const publicConfig = $derived(queries.publicConfig.data);
const serverConfig = $derived(queries.serverConfig.data);
const passkeySupported = browserSupportsWebAuthn();
const passkeyEnabled = $derived(publicConfig.passkey.enabled && passkeySupported);
const passkeyLogin = createMutation(() => ({ ...loginWithPasskeyMutation(), onSuccess: (user) => onLogin(user) }));
const passkeyLoading = $derived(passkeyLogin.isPending || passkeyLogin.isSuccess);
const passkeyError = $derived(
passkeyLogin.error && !(passkeyLogin.error instanceof MutationCancelledError)
? getServerErrorMessage(passkeyLogin.error) || $t('errors.something_went_wrong')
: '',
);
const onSuccess = async (user: LoginResponseDto) => {
await goto(data.continueUrl, { invalidateAll: true });
eventManager.emit('AuthLogin', user);
};
const onFirstLogin = () => goto(Route.changePassword());
const onOnboarding = () => goto(Route.onboarding());
const onLogin = async (user: LoginResponseDto) => {
if (user.isAdmin && !serverConfig.isOnboarded) {
await onOnboarding();
return;
}
// change the user password before we onboard them
if (!user.isAdmin && user.shouldChangePassword && publicConfig.passwordLogin.enabled) {
await onFirstLogin();
return;
}
// We want to onboard after the first login since their password will change
// and handleLogin will be called again (relogin). We then do onboarding on that next call.
if (!user.isOnboarded) {
await onOnboarding();
return;
}
await onSuccess(user);
};
onMount(async () => {
if (!publicConfig.oauth.enabled) {
oauthLoading = false;
return;
}
if (oauth.isCallback(location)) {
try {
const user = await oauth.login(location);
if (!user.isOnboarded) {
await onOnboarding();
return;
}
await onSuccess(user);
return;
} catch (error) {
console.error('Error [login-form] [oauth.callback]', error);
oauthError = getServerErrorMessage(error) || $t('errors.unable_to_complete_oauth_login');
oauthLoading = false;
return;
}
}
try {
if (
(publicConfig.oauth.autoLaunch && !oauth.isAutoLaunchDisabled(location)) ||
oauth.isAutoLaunchEnabled(location)
) {
await goto(Route.login({ autoLaunch: 0 }), { replaceState: true });
await oauth.authorize(location);
return;
}
} catch (error) {
handleError(error, $t('errors.unable_to_connect'));
}
oauthLoading = false;
});
const handleLogin = async () => {
try {
errorMessage = '';
loading = true;
const user = await login({ loginCredentialDto: { email, password } });
await onLogin(user);
} catch (error) {
errorMessage = getServerErrorMessage(error) || $t('errors.incorrect_email_or_password');
loading = false;
}
};
const handleOAuthLogin = async () => {
oauthLoading = true;
oauthError = '';
const success = await oauth.authorize(location);
if (!success) {
oauthLoading = false;
oauthError = $t('errors.unable_to_login_with_oauth');
}
};
const onsubmit = async (event: Event) => {
event.preventDefault();
await handleLogin();
};
</script>
{#snippet divider()}
<div class="my-4 inline-flex w-full items-center justify-center">
<hr class="my-4 h-px w-3/4 border-0 bg-gray-200 dark:bg-gray-600" />
<span
class="absolute inset-s-1/2 -translate-x-1/2 bg-gray-50 px-3 font-medium text-gray-900 uppercase dark:bg-neutral-900 dark:text-white"
>
{$t('or')}
</span>
</div>
{/snippet}
<AuthPageLayout title={data.meta.title}>
<Stack gap={4}>
{#if publicConfig.server.loginPageMessage}
<Alert color="primary" class="mb-6">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html publicConfig.server.loginPageMessage}
</Alert>
{/if}
{#if !oauthLoading && publicConfig.passwordLogin.enabled}
<form {onsubmit} class="flex flex-col gap-4">
{#if errorMessage}
<Alert color="danger" title={errorMessage} closable />
{/if}
<Field label={$t('email')} required="indicator">
<Input id="email" name="email" type="email" autocomplete="email" bind:value={email} />
</Field>
<Field label={$t('password')} required="indicator">
<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>
{/if}
{#if !oauthLoading && passkeyEnabled}
{#if publicConfig.passwordLogin.enabled}
{@render divider()}
{/if}
{#if passkeyError}
<Alert color="danger" title={passkeyError} closable />
{/if}
<Button
shape="round"
leadingIcon={mdiShieldKeyOutline}
loading={passkeyLoading}
disabled={loading || passkeyLoading}
size="large"
fullWidth
color={publicConfig.passwordLogin.enabled ? 'secondary' : 'primary'}
onclick={() => passkeyLogin.mutate()}
>
{$t('login_with_passkey')}
</Button>
{/if}
{#if publicConfig.oauth.enabled}
{#if publicConfig.passwordLogin.enabled || passkeyEnabled}
{@render divider()}
{/if}
{#if oauthError}
<Alert color="danger" title={oauthError} closable />
{/if}
<Button
shape="round"
loading={loading || oauthLoading}
disabled={loading || oauthLoading}
size="large"
fullWidth
color={publicConfig.passwordLogin.enabled ? 'secondary' : 'primary'}
onclick={handleOAuthLogin}
>
{publicConfig.oauth.buttonText}
</Button>
{/if}
{#if !publicConfig.passwordLogin.enabled && !passkeyEnabled && !publicConfig.oauth.enabled}
<Alert color="warning" title={$t('login_has_been_disabled')} />
{/if}
</Stack>
</AuthPageLayout>