Переглянути джерело

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
Michel Heusschen 2 роки тому
батько
коміт
87d84b922f

+ 68 - 80
web/src/lib/components/forms/admin-registration-form.svelte

@@ -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="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="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="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="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="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="lastName">Last Name</label>
-			<input class="immich-form-input" id="lastName" name="lastName" type="text" required />
-		</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>
 
-		{#if error}
-			<p class="text-red-400 ml-4">{error}</p>
-		{/if}
+	<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>
 
-		{#if success}
-			<div>
-				<p>Admin account has been registered</p>
-				<p>
-					<a href="/auth/login">Login</a>
-				</p>
-			</div>
-		{/if}
+	{#if error}
+		<p class="text-red-400">{error}</p>
+	{/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>

+ 34 - 55
web/src/lib/components/forms/change-password-form.svelte

@@ -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="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>
+	<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>
 
-		{#if error}
-			<p class="text-red-400 ml-4 text-sm">{error}</p>
-		{/if}
+	{#if error}
+		<p class="text-red-400 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>

+ 87 - 98
web/src/lib/components/forms/login-form.svelte

@@ -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 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}
 
-	{#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}
+		<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}
-		<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>
+		<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 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 class="my-5 flex flex-col gap-5">
+		{#if oauthError}
+			<p class="text-red-400" transition:fade>{oauthError}</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'}
-				>
+		<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'}
-				</button>
-			</a>
-		</div>
-	{/if}
+				{/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}

+ 29 - 0
web/src/lib/components/shared-components/fullscreen-container.svelte

@@ -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>

+ 4 - 1
web/src/lib/constants.ts

@@ -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'
 }

+ 12 - 17
web/src/routes/auth/change-password/+page.server.ts

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

+ 16 - 9
web/src/routes/auth/change-password/+page.svelte

@@ -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>

+ 19 - 1
web/src/routes/auth/login/+page.server.ts

@@ -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'
 		}

+ 15 - 9
web/src/routes/auth/login/+page.svelte

@@ -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>

+ 11 - 2
web/src/routes/auth/register/+page.svelte

@@ -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>