1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-26 10:50:29 +02:00

feat(web): improve /auth pages (#1969)

* feat(web): improve /auth pages

* invalidate load functions after login

* handle login server errors more graceful

* add loading state to oauth button
This commit is contained in:
Michel Heusschen 2023-03-15 22:38:29 +01:00 committed by GitHub
parent 04955a4123
commit 87d84b922f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 299 additions and 276 deletions

View File

@ -1,14 +1,11 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { AppRoute } from '$lib/constants';
import { api } from '@api'; import { api } from '@api';
import ImmichLogo from '../shared-components/immich-logo.svelte';
let error: string; let error: string;
let success: string;
let password = ''; let password = '';
let confirmPassowrd = ''; let confirmPassowrd = '';
let canRegister = false; let canRegister = false;
$: { $: {
@ -21,13 +18,11 @@
} }
} }
async function registerAdmin(event: SubmitEvent) { async function registerAdmin(event: SubmitEvent & { currentTarget: HTMLFormElement }) {
if (canRegister) { if (canRegister) {
error = ''; error = '';
const formElement = event.target as HTMLFormElement; const form = new FormData(event.currentTarget);
const form = new FormData(formElement);
const email = form.get('email'); const email = form.get('email');
const password = form.get('password'); const password = form.get('password');
@ -42,7 +37,7 @@
}); });
if (status === 201) { if (status === 201) {
goto('/auth/login'); goto(AppRoute.AUTH_LOGIN);
return; return;
} else { } else {
error = 'Error create admin account'; error = 'Error create admin account';
@ -52,81 +47,74 @@
} }
</script> </script>
<div <form on:submit|preventDefault={registerAdmin} method="post" class="flex flex-col gap-5 mt-5">
class="border bg-immich-bg dark:bg-immich-dark-gray dark:border-immich-dark-gray p-4 shadow-sm w-[500px] max-w-[95vw] rounded-3xl py-8 dark:text-immich-dark-fg" <div class="flex flex-col gap-2">
> <label class="immich-form-label" for="email">Admin Email</label>
<div class="flex flex-col place-items-center place-content-center gap-4 px-4"> <input
<ImmichLogo class="text-center" height="100" width="100" /> class="immich-form-input"
<h1 class="text-2xl text-immich-primary dark:text-immich-dark-primary font-medium"> id="email"
Admin Registration name="email"
</h1> type="email"
<p autocomplete="email"
class="text-sm border rounded-md p-4 font-mono text-gray-600 dark:border-immich-dark-bg dark:text-gray-300" required
> />
Since you are the first user on the system, you will be assigned as the Admin and are
responsible for administrative tasks, and additional users will be created by you.
</p>
</div> </div>
<form on:submit|preventDefault={registerAdmin} method="post" action="" autocomplete="off"> <div class="flex flex-col gap-2">
<div class="m-4 flex flex-col gap-2"> <label class="immich-form-label" for="password">Admin Password</label>
<label class="immich-form-label" for="email">Admin Email</label> <input
<input class="immich-form-input" id="email" name="email" type="email" required /> class="immich-form-input"
</div> id="password"
name="password"
type="password"
autocomplete="new-password"
required
bind:value={password}
/>
</div>
<div class="m-4 flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label class="immich-form-label" for="password">Admin Password</label> <label class="immich-form-label" for="confirmPassword">Confirm Admin Password</label>
<input <input
class="immich-form-input" class="immich-form-input"
id="password" id="confirmPassword"
name="password" name="password"
type="password" type="password"
required autocomplete="new-password"
bind:value={password} required
/> bind:value={confirmPassowrd}
</div> />
</div>
<div class="m-4 flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label class="immich-form-label" for="confirmPassword">Confirm Admin Password</label> <label class="immich-form-label" for="firstName">First Name</label>
<input <input
class="immich-form-input" class="immich-form-input"
id="confirmPassword" id="firstName"
name="password" name="firstName"
type="password" type="text"
required autocomplete="given-name"
bind:value={confirmPassowrd} required
/> />
</div> </div>
<div class="m-4 flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label class="immich-form-label" for="firstName">First Name</label> <label class="immich-form-label" for="lastName">Last Name</label>
<input class="immich-form-input" id="firstName" name="firstName" type="text" required /> <input
</div> class="immich-form-input"
id="lastName"
name="lastName"
type="text"
autocomplete="family-name"
required
/>
</div>
<div class="m-4 flex flex-col gap-2"> {#if error}
<label class="immich-form-label" for="lastName">Last Name</label> <p class="text-red-400">{error}</p>
<input class="immich-form-input" id="lastName" name="lastName" type="text" required /> {/if}
</div>
{#if error} <div class="my-5 flex w-full">
<p class="text-red-400 ml-4">{error}</p> <button type="submit" class="immich-btn-primary-big">Sign Up</button>
{/if} </div>
</form>
{#if success}
<div>
<p>Admin account has been registered</p>
<p>
<a href="/auth/login">Login</a>
</p>
</div>
{/if}
<div class="flex w-full">
<button
type="submit"
class="m-4 p-2 bg-immich-primary dark:bg-immich-dark-primary hover:bg-immich-primary/75 dark:hover:bg-immich-dark-primary/80 dark:text-immich-dark-gray px-6 py-4 text-white rounded-md shadow-md w-full"
>Sign Up</button
>
</div>
</form>
</div>

View File

@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { api, UserResponseDto } from '@api'; import { api, UserResponseDto } from '@api';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import ImmichLogo from '../shared-components/immich-logo.svelte';
export let user: UserResponseDto; export let user: UserResponseDto;
let error: string; let error: string;
@ -44,61 +43,41 @@
} }
</script> </script>
<div <form on:submit|preventDefault={changePassword} method="post" class="flex flex-col gap-5 mt-5">
class="border bg-gray-50 dark:bg-immich-dark-gray dark:border-immich-dark-gray p-4 shadow-sm w-[500px] max-w-[95vw] rounded-3xl py-8 dark:text-immich-dark-fg" <div class="flex flex-col gap-2">
> <label class="immich-form-label" for="password">New Password</label>
<div class="flex flex-col place-items-center place-content-center gap-4 px-4"> <input
<ImmichLogo class="text-center" height="100" width="100" /> class="immich-form-input"
<h1 class="text-2xl text-immich-primary dark:text-immich-dark-primary font-medium"> id="password"
Change Password name="password"
</h1> type="password"
autocomplete="new-password"
<p required
class="text-sm border rounded-3xl p-6 text-gray-600 dark:border-immich-dark-bg dark:text-gray-300 bg-immich-bg dark:bg-gray-900" bind:value={password}
> />
Hi {user.firstName}
{user.lastName} ({user.email}),
<br />
<br />
This is either the first time you are signing into the system or a request has been made to change
your password. Please enter the new password below.
</p>
</div> </div>
<form on:submit|preventDefault={changePassword} method="post" autocomplete="off"> <div class="flex flex-col gap-2">
<div class="m-4 flex flex-col gap-2"> <label class="immich-form-label" for="confirmPassword">Confirm Password</label>
<label class="immich-form-label" for="password">New Password</label> <input
<input class="immich-form-input"
class="immich-form-input" id="confirmPassword"
id="password" name="password"
name="password" type="password"
type="password" autocomplete="current-password"
required required
bind:value={password} bind:value={confirmPassowrd}
/> />
</div> </div>
<div class="m-4 flex flex-col gap-2"> {#if error}
<label class="immich-form-label" for="confirmPassword">Confirm Password</label> <p class="text-red-400 text-sm">{error}</p>
<input {/if}
class="immich-form-input"
id="confirmPassword"
name="password"
type="password"
required
bind:value={confirmPassowrd}
/>
</div>
{#if error} {#if success}
<p class="text-red-400 ml-4 text-sm">{error}</p> <p class="text-immich-primary text-sm">{success}</p>
{/if} {/if}
<div class="my-5 flex w-full">
{#if success} <button type="submit" class="immich-btn-primary-big">Change Password</button>
<p class="text-immich-primary ml-4 text-sm">{success}</p> </div>
{/if} </form>
<div class="flex w-full">
<button type="submit" class="immich-btn-primary-big m-4">Change Password</button>
</div>
</form>
</div>

View File

@ -1,33 +1,33 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { loginPageMessage } from '$lib/constants'; import { AppRoute } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { api, oauth, OAuthConfigResponseDto } from '@api'; import { api, oauth, OAuthConfigResponseDto } from '@api';
import { createEventDispatcher, onMount } from 'svelte'; import { createEventDispatcher, onMount } from 'svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import ImmichLogo from '../shared-components/immich-logo.svelte';
let error: string; let error: string;
let email = ''; let email = '';
let password = ''; let password = '';
let oauthError: string; let oauthError: string;
let authConfig: OAuthConfigResponseDto = { enabled: false, passwordLoginEnabled: false }; export let authConfig: OAuthConfigResponseDto;
let loading = true; let loading = false;
let oauthLoading = true;
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
onMount(async () => { onMount(async () => {
if (oauth.isCallback(window.location)) { if (oauth.isCallback(window.location)) {
try { try {
loading = true;
await oauth.login(window.location); await oauth.login(window.location);
dispatch('success'); dispatch('success');
return; return;
} catch (e) { } catch (e) {
console.error('Error [login-form] [oauth.callback]', e); console.error('Error [login-form] [oauth.callback]', e);
oauthError = 'Unable to complete OAuth login'; oauthError = 'Unable to complete OAuth login';
loading = false; } finally {
oauthLoading = false;
} }
} }
@ -38,7 +38,7 @@
const { enabled, url, autoLaunch } = authConfig; const { enabled, url, autoLaunch } = authConfig;
if (enabled && url && autoLaunch && !oauth.isAutoLaunchDisabled(window.location)) { if (enabled && url && autoLaunch && !oauth.isAutoLaunchDisabled(window.location)) {
await goto('/auth/login?autoLaunch=0', { replaceState: true }); await goto(`${AppRoute.AUTH_LOGIN}?autoLaunch=0`, { replaceState: true });
await goto(url); await goto(url);
return; return;
} }
@ -47,7 +47,7 @@
handleError(error, 'Unable to connect!'); handleError(error, 'Unable to connect!');
} }
loading = false; oauthLoading = false;
}); });
const login = async () => { const login = async () => {
@ -75,100 +75,89 @@
}; };
</script> </script>
<div {#if authConfig.passwordLoginEnabled}
class="border bg-white dark:bg-immich-dark-gray dark:border-immich-dark-gray p-8 shadow-sm w-full max-w-lg rounded-3xl" <form on:submit|preventDefault={login} class="flex flex-col gap-5 mt-5">
> {#if error}
<div class="flex flex-col place-items-center place-content-center gap-4 py-4"> <p class="text-red-400" transition:fade>
<ImmichLogo class="text-center h-24 w-24" /> {error}
<h1 class="text-2xl text-immich-primary dark:text-immich-dark-primary font-medium">Login</h1> </p>
</div>
{#if loginPageMessage}
<p
class="text-sm border rounded-xl p-4 text-immich-primary dark:text-immich-dark-primary font-medium bg-immich-primary/5 dark:border-immich-dark-bg w-full border-immich-primary border-2"
>
{@html loginPageMessage}
</p>
{/if}
{#if authConfig.passwordLoginEnabled}
<form on:submit|preventDefault={login} class="flex flex-col gap-5 mt-5">
{#if error}
<p class="text-red-400" transition:fade>
{error}
</p>
{/if}
<div class="flex flex-col gap-2">
<label class="immich-form-label" for="email">Email</label>
<input
class="immich-form-input"
id="email"
name="email"
type="email"
bind:value={email}
required
/>
</div>
<div class="flex flex-col gap-2">
<label class="immich-form-label" for="password">Password</label>
<input
class="immich-form-input"
id="password"
name="password"
type="password"
bind:value={password}
required
/>
</div>
<div class="my-5 flex w-full">
<button
type="submit"
class="immich-btn-primary-big inline-flex items-center h-14"
disabled={loading}
>
{#if loading}
<LoadingSpinner />
{:else}
Login
{/if}
</button>
</div>
</form>
{/if}
{#if authConfig.enabled}
{#if authConfig.passwordLoginEnabled}
<div class="inline-flex items-center justify-center w-full">
<hr class="w-3/4 h-px my-4 bg-gray-200 border-0 dark:bg-gray-600" />
<span
class="absolute px-3 font-medium text-gray-900 -translate-x-1/2 left-1/2 dark:text-white bg-white dark:bg-immich-dark-gray"
>
or
</span>
</div>
{/if} {/if}
<div class="my-5 flex flex-col gap-5">
{#if oauthError} <div class="flex flex-col gap-2">
<p class="text-red-400" transition:fade>{oauthError}</p> <label class="immich-form-label" for="email">Email</label>
{/if} <input
<a href={authConfig.url} class="flex w-full"> class="immich-form-input"
<button id="email"
type="button" name="email"
disabled={loading} type="email"
class={authConfig.passwordLoginEnabled autocomplete="email"
? 'immich-btn-secondary-big' bind:value={email}
: 'immich-btn-primary-big'} required
> />
{authConfig.buttonText || 'Login with OAuth'} </div>
</button>
</a> <div class="flex flex-col gap-2">
<label class="immich-form-label" for="password">Password</label>
<input
class="immich-form-input"
id="password"
name="password"
type="password"
autocomplete="current-password"
bind:value={password}
required
/>
</div>
<div class="my-5 flex w-full">
<button
type="submit"
class="immich-btn-primary-big inline-flex items-center h-14"
disabled={loading || oauthLoading}
>
{#if loading}
<LoadingSpinner />
{:else}
Login
{/if}
</button>
</div>
</form>
{/if}
{#if authConfig.enabled}
{#if authConfig.passwordLoginEnabled}
<div class="inline-flex items-center justify-center w-full">
<hr class="w-3/4 h-px my-4 bg-gray-200 border-0 dark:bg-gray-600" />
<span
class="absolute px-3 font-medium text-gray-900 -translate-x-1/2 left-1/2 dark:text-white bg-white dark:bg-immich-dark-gray"
>
or
</span>
</div> </div>
{/if} {/if}
<div class="my-5 flex flex-col gap-5">
{#if oauthError}
<p class="text-red-400" transition:fade>{oauthError}</p>
{/if}
<a href={authConfig.url} class="flex w-full">
<button
type="button"
disabled={loading || oauthLoading}
class={'inline-flex items-center h-14 ' + authConfig.passwordLoginEnabled
? 'immich-btn-secondary-big'
: 'immich-btn-primary-big'}
>
{#if oauthLoading}
<LoadingSpinner />
{:else}
{authConfig.buttonText || 'Login with OAuth'}
{/if}
</button>
</a>
</div>
{/if}
{#if !authConfig.enabled && !authConfig.passwordLoginEnabled} {#if !authConfig.enabled && !authConfig.passwordLoginEnabled}
<p class="text-center dark:text-immich-dark-fg p-4">Login has been disabled.</p> <p class="text-center dark:text-immich-dark-fg p-4">Login has been disabled.</p>
{/if} {/if}
</div>

View File

@ -0,0 +1,29 @@
<script lang="ts">
import ImmichLogo from './immich-logo.svelte';
export let title: string;
export let showMessage = $$slots.message;
</script>
<section class="min-h-screen w-screen flex place-items-center place-content-center p-4">
<div
class="flex flex-col gap-4 border bg-white dark:bg-immich-dark-gray dark:border-immich-dark-gray p-8 shadow-sm w-full max-w-lg rounded-3xl"
>
<div class="flex flex-col place-items-center place-content-center gap-4 py-4">
<ImmichLogo class="h-24 w-24" />
<h1 class="text-2xl text-immich-primary dark:text-immich-dark-primary font-medium">
{title}
</h1>
</div>
{#if showMessage}
<div
class="text-sm border rounded-xl p-4 text-immich-primary dark:text-immich-dark-primary font-medium bg-immich-primary/5 dark:border-immich-dark-bg w-full border-immich-primary border-2"
>
<slot name="message" />
</div>
{/if}
<slot />
</div>
</section>

View File

@ -14,5 +14,8 @@ export enum AppRoute {
SHARING = '/sharing', SHARING = '/sharing',
SEARCH = '/search', SEARCH = '/search',
AUTH_LOGIN = '/auth/login' AUTH_LOGIN = '/auth/login',
AUTH_LOGOUT = '/auth/logout',
AUTH_REGISTER = '/auth/register',
AUTH_CHANGE_PASSWORD = '/auth/change-password'
} }

View File

@ -1,23 +1,18 @@
export const prerender = false; import { AppRoute } from '$lib/constants';
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load = (async ({ locals: { api } }) => { export const load = (async ({ locals: { user } }) => {
try { if (!user) {
const { data: userInfo } = await api.userApi.getMyUserInfo(); throw redirect(302, AppRoute.AUTH_LOGIN);
} else if (!user.shouldChangePassword) {
if (userInfo.shouldChangePassword) { throw redirect(302, AppRoute.PHOTOS);
return {
user: userInfo,
meta: {
title: 'Change Password'
}
};
} else {
throw redirect(302, '/photos');
}
} catch (e) {
throw redirect(302, '/auth/login');
} }
return {
user,
meta: {
title: 'Change Password'
}
};
}) satisfies PageServerLoad; }) satisfies PageServerLoad;

View File

@ -1,21 +1,28 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { fade } from 'svelte/transition';
import ChangePasswordForm from '$lib/components/forms/change-password-form.svelte'; import ChangePasswordForm from '$lib/components/forms/change-password-form.svelte';
import FullscreenContainer from '$lib/components/shared-components/fullscreen-container.svelte';
import { AppRoute } from '$lib/constants';
import type { PageData } from './$types'; import type { PageData } from './$types';
export let data: PageData; export let data: PageData;
const onSuccessHandler = async () => { const onSuccessHandler = async () => {
await fetch('auth/logout', { method: 'POST' }); await fetch(AppRoute.AUTH_LOGOUT, { method: 'POST' });
goto('/auth/login'); goto(AppRoute.AUTH_LOGIN);
}; };
</script> </script>
<section class="h-screen w-screen flex place-items-center place-content-center"> <FullscreenContainer title={data.meta.title}>
<div in:fade={{ duration: 100 }} out:fade={{ duration: 100 }}> <p slot="message">
<ChangePasswordForm user={data.user} on:success={onSuccessHandler} /> Hi {data.user.firstName}
</div> {data.user.lastName} ({data.user.email}),
</section> <br />
<br />
This is either the first time you are signing into the system or a request has been made to change
your password. Please enter the new password below.
</p>
<ChangePasswordForm user={data.user} on:success={onSuccessHandler} />
</FullscreenContainer>

View File

@ -1,14 +1,32 @@
import { AppRoute } from '$lib/constants';
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import type { OAuthConfigResponseDto } from '@api';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load = (async ({ locals: { api } }) => { export const load = (async ({ locals: { api } }) => {
const { data } = await api.userApi.getUserCount(true); const { data } = await api.userApi.getUserCount(true);
if (data.userCount === 0) { if (data.userCount === 0) {
// Admin not registered // Admin not registered
throw redirect(302, '/auth/register'); throw redirect(302, AppRoute.AUTH_REGISTER);
}
let authConfig: OAuthConfigResponseDto = {
passwordLoginEnabled: true,
enabled: false
};
try {
// TODO: Figure out how to get correct redirect URI server-side.
const { data } = await api.oauthApi.generateConfig({ redirectUri: '/' });
data.url = undefined;
authConfig = data;
} catch (err) {
console.error('[ERROR] login/+page.server.ts:', err);
} }
return { return {
authConfig,
meta: { meta: {
title: 'Login' title: 'Login'
} }

View File

@ -1,16 +1,22 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { fade } from 'svelte/transition';
import LoginForm from '$lib/components/forms/login-form.svelte'; import LoginForm from '$lib/components/forms/login-form.svelte';
import FullscreenContainer from '$lib/components/shared-components/fullscreen-container.svelte';
import { AppRoute } from '$lib/constants';
import { loginPageMessage } from '$lib/constants';
import type { PageData } from './$types';
export let data: PageData;
</script> </script>
<section <FullscreenContainer title={data.meta.title} showMessage={!!loginPageMessage}>
class="min-h-screen w-screen flex place-items-center place-content-center p-4" <p slot="message">
transition:fade={{ duration: 100 }} {@html loginPageMessage}
> </p>
<LoginForm <LoginForm
on:success={() => goto('/photos')} authConfig={data.authConfig}
on:first-login={() => goto('/auth/change-password')} on:success={() => goto(AppRoute.PHOTOS, { invalidateAll: true })}
on:first-login={() => goto(AppRoute.AUTH_CHANGE_PASSWORD)}
/> />
</section> </FullscreenContainer>

View File

@ -1,7 +1,16 @@
<script lang="ts"> <script lang="ts">
import AdminRegistrationForm from '$lib/components/forms/admin-registration-form.svelte'; import AdminRegistrationForm from '$lib/components/forms/admin-registration-form.svelte';
import FullscreenContainer from '$lib/components/shared-components/fullscreen-container.svelte';
import type { PageData } from './$types';
export let data: PageData;
</script> </script>
<section class="h-screen w-screen flex place-items-center place-content-center"> <FullscreenContainer title={data.meta.title}>
<p slot="message">
Since you are the first user on the system, you will be assigned as the Admin and are
responsible for administrative tasks, and additional users will be created by you.
</p>
<AdminRegistrationForm /> <AdminRegistrationForm />
</section> </FullscreenContainer>