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">
import { goto } from '$app/navigation';
import { AppRoute } from '$lib/constants';
import { api } from '@api';
import ImmichLogo from '../shared-components/immich-logo.svelte';
let error: string;
let success: string;
let password = '';
let confirmPassowrd = '';
let canRegister = false;
$: {
@ -21,13 +18,11 @@
}
}
async function registerAdmin(event: SubmitEvent) {
async function registerAdmin(event: SubmitEvent & { currentTarget: HTMLFormElement }) {
if (canRegister) {
error = '';
const formElement = event.target as HTMLFormElement;
const form = new FormData(formElement);
const form = new FormData(event.currentTarget);
const email = form.get('email');
const password = form.get('password');
@ -42,7 +37,7 @@
});
if (status === 201) {
goto('/auth/login');
goto(AppRoute.AUTH_LOGIN);
return;
} else {
error = 'Error create admin account';
@ -52,81 +47,74 @@
}
</script>
<div
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 place-items-center place-content-center gap-4 px-4">
<ImmichLogo class="text-center" height="100" width="100" />
<h1 class="text-2xl text-immich-primary dark:text-immich-dark-primary font-medium">
Admin Registration
</h1>
<p
class="text-sm border rounded-md p-4 font-mono text-gray-600 dark:border-immich-dark-bg dark:text-gray-300"
>
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>
<form on:submit|preventDefault={registerAdmin} method="post" class="flex flex-col gap-5 mt-5">
<div class="flex flex-col gap-2">
<label class="immich-form-label" for="email">Admin Email</label>
<input
class="immich-form-input"
id="email"
name="email"
type="email"
autocomplete="email"
required
/>
</div>
<form on:submit|preventDefault={registerAdmin} method="post" action="" autocomplete="off">
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="email">Admin Email</label>
<input class="immich-form-input" id="email" name="email" type="email" required />
</div>
<div class="flex flex-col gap-2">
<label class="immich-form-label" for="password">Admin Password</label>
<input
class="immich-form-input"
id="password"
name="password"
type="password"
autocomplete="new-password"
required
bind:value={password}
/>
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="password">Admin Password</label>
<input
class="immich-form-input"
id="password"
name="password"
type="password"
required
bind:value={password}
/>
</div>
<div class="flex flex-col gap-2">
<label class="immich-form-label" for="confirmPassword">Confirm Admin Password</label>
<input
class="immich-form-input"
id="confirmPassword"
name="password"
type="password"
autocomplete="new-password"
required
bind:value={confirmPassowrd}
/>
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="confirmPassword">Confirm Admin Password</label>
<input
class="immich-form-input"
id="confirmPassword"
name="password"
type="password"
required
bind:value={confirmPassowrd}
/>
</div>
<div class="flex flex-col gap-2">
<label class="immich-form-label" for="firstName">First Name</label>
<input
class="immich-form-input"
id="firstName"
name="firstName"
type="text"
autocomplete="given-name"
required
/>
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="firstName">First Name</label>
<input class="immich-form-input" id="firstName" name="firstName" type="text" required />
</div>
<div class="flex flex-col gap-2">
<label class="immich-form-label" for="lastName">Last Name</label>
<input
class="immich-form-input"
id="lastName"
name="lastName"
type="text"
autocomplete="family-name"
required
/>
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="lastName">Last Name</label>
<input class="immich-form-input" id="lastName" name="lastName" type="text" required />
</div>
{#if error}
<p class="text-red-400">{error}</p>
{/if}
{#if error}
<p class="text-red-400 ml-4">{error}</p>
{/if}
{#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>
<div class="my-5 flex w-full">
<button type="submit" class="immich-btn-primary-big">Sign Up</button>
</div>
</form>

View File

@ -1,7 +1,6 @@
<script lang="ts">
import { api, UserResponseDto } from '@api';
import { createEventDispatcher } from 'svelte';
import ImmichLogo from '../shared-components/immich-logo.svelte';
export let user: UserResponseDto;
let error: string;
@ -44,61 +43,41 @@
}
</script>
<div
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 place-items-center place-content-center gap-4 px-4">
<ImmichLogo class="text-center" height="100" width="100" />
<h1 class="text-2xl text-immich-primary dark:text-immich-dark-primary font-medium">
Change Password
</h1>
<p
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"
>
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>
<form on:submit|preventDefault={changePassword} method="post" class="flex flex-col gap-5 mt-5">
<div class="flex flex-col gap-2">
<label class="immich-form-label" for="password">New Password</label>
<input
class="immich-form-input"
id="password"
name="password"
type="password"
autocomplete="new-password"
required
bind:value={password}
/>
</div>
<form on:submit|preventDefault={changePassword} method="post" autocomplete="off">
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="password">New Password</label>
<input
class="immich-form-input"
id="password"
name="password"
type="password"
required
bind:value={password}
/>
</div>
<div class="flex flex-col gap-2">
<label class="immich-form-label" for="confirmPassword">Confirm Password</label>
<input
class="immich-form-input"
id="confirmPassword"
name="password"
type="password"
autocomplete="current-password"
required
bind:value={confirmPassowrd}
/>
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="confirmPassword">Confirm Password</label>
<input
class="immich-form-input"
id="confirmPassword"
name="password"
type="password"
required
bind:value={confirmPassowrd}
/>
</div>
{#if error}
<p class="text-red-400 text-sm">{error}</p>
{/if}
{#if error}
<p class="text-red-400 ml-4 text-sm">{error}</p>
{/if}
{#if success}
<p class="text-immich-primary ml-4 text-sm">{success}</p>
{/if}
<div class="flex w-full">
<button type="submit" class="immich-btn-primary-big m-4">Change Password</button>
</div>
</form>
</div>
{#if success}
<p class="text-immich-primary text-sm">{success}</p>
{/if}
<div class="my-5 flex w-full">
<button type="submit" class="immich-btn-primary-big">Change Password</button>
</div>
</form>

View File

@ -1,33 +1,33 @@
<script lang="ts">
import { goto } from '$app/navigation';
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 { api, oauth, OAuthConfigResponseDto } from '@api';
import { createEventDispatcher, onMount } from 'svelte';
import { fade } from 'svelte/transition';
import ImmichLogo from '../shared-components/immich-logo.svelte';
let error: string;
let email = '';
let password = '';
let oauthError: string;
let authConfig: OAuthConfigResponseDto = { enabled: false, passwordLoginEnabled: false };
let loading = true;
export let authConfig: OAuthConfigResponseDto;
let loading = false;
let oauthLoading = true;
const dispatch = createEventDispatcher();
onMount(async () => {
if (oauth.isCallback(window.location)) {
try {
loading = true;
await oauth.login(window.location);
dispatch('success');
return;
} catch (e) {
console.error('Error [login-form] [oauth.callback]', e);
oauthError = 'Unable to complete OAuth login';
loading = false;
} finally {
oauthLoading = false;
}
}
@ -38,7 +38,7 @@
const { enabled, url, autoLaunch } = authConfig;
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);
return;
}
@ -47,7 +47,7 @@
handleError(error, 'Unable to connect!');
}
loading = false;
oauthLoading = false;
});
const login = async () => {
@ -75,100 +75,89 @@
};
</script>
<div
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"
>
<div class="flex flex-col place-items-center place-content-center gap-4 py-4">
<ImmichLogo class="text-center h-24 w-24" />
<h1 class="text-2xl text-immich-primary dark:text-immich-dark-primary font-medium">Login</h1>
</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 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="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}
class={authConfig.passwordLoginEnabled
? 'immich-btn-secondary-big'
: 'immich-btn-primary-big'}
>
{authConfig.buttonText || 'Login with OAuth'}
</button>
</a>
<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"
autocomplete="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"
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>
{/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}
<p class="text-center dark:text-immich-dark-fg p-4">Login has been disabled.</p>
{/if}
</div>
{#if !authConfig.enabled && !authConfig.passwordLoginEnabled}
<p class="text-center dark:text-immich-dark-fg p-4">Login has been disabled.</p>
{/if}

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',
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 type { PageServerLoad } from './$types';
export const load = (async ({ locals: { api } }) => {
try {
const { data: userInfo } = await api.userApi.getMyUserInfo();
if (userInfo.shouldChangePassword) {
return {
user: userInfo,
meta: {
title: 'Change Password'
}
};
} else {
throw redirect(302, '/photos');
}
} catch (e) {
throw redirect(302, '/auth/login');
export const load = (async ({ locals: { user } }) => {
if (!user) {
throw redirect(302, AppRoute.AUTH_LOGIN);
} else if (!user.shouldChangePassword) {
throw redirect(302, AppRoute.PHOTOS);
}
return {
user,
meta: {
title: 'Change Password'
}
};
}) satisfies PageServerLoad;

View File

@ -1,21 +1,28 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { fade } from 'svelte/transition';
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';
export let data: PageData;
const onSuccessHandler = async () => {
await fetch('auth/logout', { method: 'POST' });
await fetch(AppRoute.AUTH_LOGOUT, { method: 'POST' });
goto('/auth/login');
goto(AppRoute.AUTH_LOGIN);
};
</script>
<section class="h-screen w-screen flex place-items-center place-content-center">
<div in:fade={{ duration: 100 }} out:fade={{ duration: 100 }}>
<ChangePasswordForm user={data.user} on:success={onSuccessHandler} />
</div>
</section>
<FullscreenContainer title={data.meta.title}>
<p slot="message">
Hi {data.user.firstName}
{data.user.lastName} ({data.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>
<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 type { OAuthConfigResponseDto } from '@api';
import type { PageServerLoad } from './$types';
export const load = (async ({ locals: { api } }) => {
const { data } = await api.userApi.getUserCount(true);
if (data.userCount === 0) {
// 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 {
authConfig,
meta: {
title: 'Login'
}

View File

@ -1,16 +1,22 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { fade } from 'svelte/transition';
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>
<section
class="min-h-screen w-screen flex place-items-center place-content-center p-4"
transition:fade={{ duration: 100 }}
>
<FullscreenContainer title={data.meta.title} showMessage={!!loginPageMessage}>
<p slot="message">
{@html loginPageMessage}
</p>
<LoginForm
on:success={() => goto('/photos')}
on:first-login={() => goto('/auth/change-password')}
authConfig={data.authConfig}
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">
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>
<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 />
</section>
</FullscreenContainer>