0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-21 00:52:43 -05:00

refactor: migrate create user form to immich ui (#15350)

* refactor: migrate create user form to immich ui

* minor styling tweak

* remove unintentional commit

* revert formating diff

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Jason Rasmussen 2025-01-15 15:09:19 -05:00 committed by GitHub
parent 2d2966caa0
commit a60da1ccab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 121 additions and 148 deletions

View file

@ -5,10 +5,8 @@
import { ByteUnit, convertToBytes } from '$lib/utils/byte-units';
import { handleError } from '$lib/utils/handle-error';
import { createUserAdmin } from '@immich/sdk';
import { Alert, Button, Field, HelperText, Input, PasswordInput, Stack, Switch } from '@immich/ui';
import { t } from 'svelte-i18n';
import Button from '../elements/buttons/button.svelte';
import Slider from '../elements/slider.svelte';
import PasswordField from '../shared-components/password-field.svelte';
interface Props {
onClose: () => void;
@ -17,137 +15,114 @@
oauthEnabled?: boolean;
}
let { onClose, onSubmit, onCancel, oauthEnabled = false }: Props = $props();
let { onClose, onSubmit: onDone, onCancel, oauthEnabled = false }: Props = $props();
let error = $state('');
let success = $state('');
let success = $state(false);
let email = $state('');
let password = $state('');
let confirmPassword = $state('');
let passwordConfirm = $state('');
let name = $state('');
let shouldChangePassword = $state(true);
let notify = $state(true);
let canCreateUser = $state(false);
let quotaSize: number | undefined = $state();
let quotaSize: string | undefined = $state();
let isCreatingUser = $state(false);
let quotaSizeInBytes = $derived(quotaSize ? convertToBytes(quotaSize, ByteUnit.GiB) : null);
let quotaSizeInBytes = $derived(quotaSize ? convertToBytes(Number(quotaSize), ByteUnit.GiB) : null);
let quotaSizeWarning = $derived(
quotaSizeInBytes && userInteraction.serverInfo && quotaSizeInBytes > userInteraction.serverInfo.diskSizeRaw,
);
$effect(() => {
if (password !== confirmPassword && confirmPassword.length > 0) {
error = $t('password_does_not_match');
canCreateUser = false;
} else {
error = '';
canCreateUser = true;
}
});
const passwordMismatch = $derived(password !== passwordConfirm && passwordConfirm.length > 0);
const passwordMismatchMessage = $derived(passwordMismatch ? $t('password_does_not_match') : '');
const valid = $derived(!passwordMismatch && !isCreatingUser);
async function registerUser() {
if (canCreateUser && !isCreatingUser) {
isCreatingUser = true;
error = '';
try {
await createUserAdmin({
userAdminCreateDto: {
email,
password,
shouldChangePassword,
name,
quotaSizeInBytes,
notify,
},
});
success = $t('new_user_created');
onSubmit();
return;
} catch (error) {
handleError(error, $t('errors.unable_to_create_user'));
} finally {
isCreatingUser = false;
}
}
}
const onsubmit = async (event: Event) => {
const onSubmit = async (event: Event) => {
event.preventDefault();
await registerUser();
if (!valid) {
return;
}
isCreatingUser = true;
error = '';
try {
await createUserAdmin({
userAdminCreateDto: {
email,
password,
shouldChangePassword,
name,
quotaSizeInBytes,
notify,
},
});
success = true;
onDone();
return;
} catch (error) {
handleError(error, $t('errors.unable_to_create_user'));
} finally {
isCreatingUser = false;
}
};
</script>
<FullScreenModal title={$t('create_new_user')} showLogo {onClose}>
<form {onsubmit} autocomplete="off" id="create-new-user-form">
<div class="my-4 flex flex-col gap-2">
<label class="immich-form-label" for="email">{$t('email')}</label>
<input class="immich-form-input" id="email" bind:value={email} type="email" required />
</div>
{#if $featureFlags.email}
<div class="my-4 flex place-items-center justify-between gap-2">
<label class="text-sm dark:text-immich-dark-fg" for="send-welcome-email">
{$t('admin.send_welcome_email')}
</label>
<Slider id="send-welcome-email" bind:checked={notify} />
</div>
{/if}
<div class="my-4 flex flex-col gap-2">
<label class="immich-form-label" for="password">{$t('password')}</label>
<PasswordField id="password" bind:password autocomplete="new-password" required={!oauthEnabled} />
</div>
<div class="my-4 flex flex-col gap-2">
<label class="immich-form-label" for="confirmPassword">{$t('confirm_password')}</label>
<PasswordField
id="confirmPassword"
bind:password={confirmPassword}
autocomplete="new-password"
required={!oauthEnabled}
/>
</div>
<div class="my-4 flex place-items-center justify-between gap-2">
<label class="text-sm dark:text-immich-dark-fg" for="require-password-change">
{$t('admin.require_password_change_on_login')}
</label>
<Slider id="require-password-change" bind:checked={shouldChangePassword} />
</div>
<div class="my-4 flex flex-col gap-2">
<label class="immich-form-label" for="name">{$t('name')}</label>
<input class="immich-form-input" id="name" bind:value={name} type="text" required />
</div>
<div class="my-4 flex flex-col gap-2">
<label class="flex items-center gap-2 immich-form-label" for="quotaSize">
{$t('admin.quota_size_gib')}
{#if quotaSizeWarning}
<p class="text-red-400 text-sm">{$t('errors.quota_higher_than_disk_size')}</p>
{/if}
</label>
<input class="immich-form-input" id="quotaSize" type="number" min="0" bind:value={quotaSize} />
</div>
<form onsubmit={onSubmit} autocomplete="off" id="create-new-user-form">
<FullScreenModal title={$t('create_new_user')} showLogo {onClose}>
{#if error}
<p class="text-sm text-red-400">{error}</p>
<Alert color="danger" size="small" title={error} closable />
{/if}
{#if success}
<p class="text-sm text-immich-primary">{success}</p>
<p class="text-sm text-immich-primary">{$t('new_user_created')}</p>
{/if}
</form>
{#snippet stickyBottom()}
<Button color="gray" fullwidth onclick={onCancel}>{$t('cancel')}</Button>
<Button type="submit" disabled={isCreatingUser} fullwidth form="create-new-user-form">{$t('create')}</Button>
{/snippet}
</FullScreenModal>
<Stack gap={4}>
<Field label={$t('email')} required>
<Input bind:value={email} type="email" />
</Field>
{#if $featureFlags.email}
<Field label={$t('admin.send_welcome_email')}>
<Switch id="send-welcome-email" bind:checked={notify} class="flex justify-between text-sm" />
</Field>
{/if}
<Field label={$t('password')} required={!oauthEnabled}>
<PasswordInput id="password" bind:value={password} autocomplete="new-password" />
</Field>
<Field label={$t('confirm_password')} required={!oauthEnabled}>
<PasswordInput id="confirmPassword" bind:value={passwordConfirm} autocomplete="new-password" />
<HelperText color="danger">{passwordMismatchMessage}</HelperText>
</Field>
<Field label={$t('admin.require_password_change_on_login')}>
<Switch id="require-password-change" bind:checked={shouldChangePassword} class="flex justify-between text-sm" />
</Field>
<Field label={$t('name')} required>
<Input bind:value={name} />
</Field>
<Field label={$t('admin.quota_size_gib')}>
<Input bind:value={quotaSize} type="number" min="0" />
{#if quotaSizeWarning}
<HelperText color="danger">{$t('errors.quota_higher_than_disk_size')}</HelperText>
{/if}
</Field>
</Stack>
{#snippet stickyBottom()}
<Button color="secondary" fullWidth onclick={onCancel} shape="round">{$t('cancel')}</Button>
<Button type="submit" disabled={!valid} fullWidth shape="round">{$t('create')}</Button>
{/snippet}
</FullScreenModal>
</form>

View file

@ -11,7 +11,7 @@
</script>
<section class="min-w-screen flex min-h-screen items-center justify-center">
<Card color="secondary" class="w-full max-w-xl border m-2">
<Card color="secondary" class="w-full max-w-lg border m-2">
<CardHeader class="mt-6">
<VStack>
<Logo variant="icon" size="giant" />

View file

@ -17,7 +17,7 @@
</script>
<script lang="ts">
import Button from '$lib/components/elements/buttons/button.svelte';
import { Button } from '@immich/ui';
import { AssetTypeEnum, type SmartSearchDto, type MetadataSearchDto } from '@immich/sdk';
import SearchPeopleSection from './search-people-section.svelte';
import SearchLocationSection from './search-location-section.svelte';
@ -163,7 +163,7 @@
</form>
{#snippet stickyBottom()}
<Button type="reset" color="gray" fullwidth form={formId}>{$t('clear_all')}</Button>
<Button type="submit" fullwidth form={formId}>{$t('search')}</Button>
<Button shape="round" size="large" type="reset" color="secondary" fullWidth form={formId}>{$t('clear_all')}</Button>
<Button shape="round" size="large" type="submit" fullWidth form={formId}>{$t('search')}</Button>
{/snippet}
</FullScreenModal>

View file

@ -2,9 +2,6 @@
import { page } from '$app/stores';
import DeleteConfirmDialog from '$lib/components/admin-page/delete-confirm-dialogue.svelte';
import RestoreDialogue from '$lib/components/admin-page/restore-dialogue.svelte';
import Button from '$lib/components/elements/buttons/button.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import CreateUserForm from '$lib/components/forms/create-user-form.svelte';
import EditUserForm from '$lib/components/forms/edit-user-form.svelte';
@ -15,12 +12,13 @@
notificationController,
} from '$lib/components/shared-components/notification/notification';
import { locale } from '$lib/stores/preferences.store';
import { serverConfig, featureFlags } from '$lib/stores/server-config.store';
import { featureFlags, serverConfig } from '$lib/stores/server-config.store';
import { user } from '$lib/stores/user.store';
import { websocketEvents } from '$lib/stores/websocket';
import { copyToClipboard } from '$lib/utils';
import { getByteUnitString } from '$lib/utils/byte-units';
import { UserStatus, searchUsersAdmin, type UserAdminResponseDto } from '@immich/sdk';
import { Button, Code, IconButton, Text } from '@immich/ui';
import { mdiContentCopy, mdiDeleteRestore, mdiInfinity, mdiPencilOutline, mdiTrashCanOutline } from '@mdi/js';
import { DateTime } from 'luxon';
import { onMount } from 'svelte';
@ -161,22 +159,21 @@
>
{#snippet promptSnippet()}
<div class="flex flex-col gap-4">
<p>{$t('admin.user_password_has_been_reset')}</p>
<Text>{$t('admin.user_password_has_been_reset')}</Text>
<div class="flex justify-center gap-2">
<code
class="rounded-md bg-gray-200 px-2 py-1 font-bold text-immich-primary dark:text-immich-dark-primary dark:bg-gray-700"
>
{newPassword}
</code>
<LinkButton onclick={() => copyToClipboard(newPassword)} title={$t('copy_password')}>
<div class="flex place-items-center gap-2 text-sm">
<Icon path={mdiContentCopy} size="18" />
</div>
</LinkButton>
<div class="flex justify-center gap-2 items-center">
<Code color="primary">{newPassword}</Code>
<IconButton
icon={mdiContentCopy}
shape="round"
color="secondary"
variant="ghost"
onclick={() => copyToClipboard(newPassword)}
title={$t('copy_password')}
/>
</div>
<p>{$t('admin.user_password_reset_description')}</p>
<Text>{$t('admin.user_password_reset_description')}</Text>
</div>
{/snippet}
</ConfirmDialog>
@ -222,31 +219,31 @@
class="flex flex-row flex-wrap justify-center gap-x-2 gap-y-1 w-4/12 lg:w-3/12 xl:w-2/12 text-ellipsis break-all text-sm"
>
{#if !immichUser.deletedAt}
<CircleIconButton
<IconButton
shape="round"
size="large"
icon={mdiPencilOutline}
title={$t('edit_user')}
color="primary"
size="16"
onclick={() => editUserHandler(immichUser)}
/>
{#if immichUser.id !== $user.id}
<CircleIconButton
<IconButton
shape="round"
size="large"
icon={mdiTrashCanOutline}
title={$t('delete_user')}
color="primary"
size="16"
onclick={() => deleteUserHandler(immichUser)}
/>
{/if}
{/if}
{#if immichUser.deletedAt && immichUser.status === UserStatus.Deleted}
<CircleIconButton
<IconButton
shape="round"
size="large"
icon={mdiDeleteRestore}
title={$t('admin.user_restore_scheduled_removal', {
values: { date: getDeleteDate(immichUser.deletedAt) },
})}
color="primary"
size="16"
onclick={() => restoreUserHandler(immichUser)}
/>
{/if}
@ -256,8 +253,7 @@
{/if}
</tbody>
</table>
<Button size="sm" onclick={() => (shouldShowCreateUserForm = true)}>{$t('create_user')}</Button>
<Button shape="round" onclick={() => (shouldShowCreateUserForm = true)}>{$t('create_user')}</Button>
</section>
</section>
</UserPageLayout>

View file

@ -51,9 +51,7 @@
<Field label={$t('confirm_password')} required>
<PasswordInput bind:value={passwordConfirm} autocomplete="new-password" />
{#if errorMessage}
<HelperText color="danger">{errorMessage}</HelperText>
{/if}
<HelperText color="danger">{errorMessage}</HelperText>
</Field>
<div class="my-5 flex w-full">

View file

@ -131,7 +131,7 @@
<div class="inline-flex w-full items-center justify-center mt-4">
<hr class="my-4 h-px w-3/4 border-0 bg-gray-200 dark:bg-gray-600" />
<span
class="absolute left-1/2 -translate-x-1/2 bg-white px-3 font-medium text-gray-900 dark:bg-immich-dark-gray dark:text-white"
class="absolute left-1/2 -translate-x-1/2 bg-gray-50 px-3 font-medium text-gray-900 dark:bg-neutral-900 dark:text-white"
>
{$t('or').toUpperCase()}
</span>

View file

@ -2,7 +2,11 @@ import plugin from 'tailwindcss/plugin';
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{html,js,svelte,ts}', './node_modules/@immich/ui/dist/**/*.{svelte,js}'],
content: [
'./src/**/*.{html,js,svelte,ts}',
'./node_modules/@immich/ui/dist/**/*.{svelte,js}',
'../../ui/src/**/*.{html,js,svelte,ts}',
],
darkMode: 'class',
theme: {
extend: {