mirror of
https://github.com/immich-app/immich.git
synced 2025-07-15 20:38:26 +02:00
feat: user pin-code (#18138)
* feat: user pincode * pr feedback * chore: cleanup --------- Co-authored-by: Jason Rasmussen <jason@rasm.me>
This commit is contained in:
parent
55af925ab3
commit
3f719bd8d7
28 changed files with 1392 additions and 39 deletions
web/src
lib
components/user-settings-page
modals
routes/admin/user-management
114
web/src/lib/components/user-settings-page/PinCodeInput.svelte
Normal file
114
web/src/lib/components/user-settings-page/PinCodeInput.svelte
Normal file
|
@ -0,0 +1,114 @@
|
|||
<script lang="ts">
|
||||
interface Props {
|
||||
label: string;
|
||||
value?: string;
|
||||
pinLength?: number;
|
||||
tabindexStart?: number;
|
||||
}
|
||||
|
||||
let { label, value = $bindable(''), pinLength = 6, tabindexStart = 0 }: Props = $props();
|
||||
|
||||
let pinValues = $state(Array.from({ length: pinLength }).fill(''));
|
||||
let pinCodeInputElements: HTMLInputElement[] = $state([]);
|
||||
|
||||
$effect(() => {
|
||||
if (value === '') {
|
||||
pinValues = Array.from({ length: pinLength }).fill('');
|
||||
}
|
||||
});
|
||||
|
||||
const focusNext = (index: number) => {
|
||||
pinCodeInputElements[Math.min(index + 1, pinLength - 1)]?.focus();
|
||||
};
|
||||
|
||||
const focusPrev = (index: number) => {
|
||||
if (index > 0) {
|
||||
pinCodeInputElements[index - 1]?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleInput = (event: Event, index: number) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
let currentPinValue = target.value;
|
||||
|
||||
if (target.value.length > 1) {
|
||||
currentPinValue = value.slice(0, 1);
|
||||
}
|
||||
|
||||
if (Number.isNaN(Number(value))) {
|
||||
pinValues[index] = '';
|
||||
target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
pinValues[index] = currentPinValue;
|
||||
|
||||
value = pinValues.join('').trim();
|
||||
|
||||
if (value && index < pinLength - 1) {
|
||||
focusNext(index);
|
||||
}
|
||||
};
|
||||
|
||||
function handleKeydown(event: KeyboardEvent & { currentTarget: EventTarget & HTMLInputElement }) {
|
||||
const target = event.currentTarget as HTMLInputElement;
|
||||
const index = pinCodeInputElements.indexOf(target);
|
||||
|
||||
switch (event.key) {
|
||||
case 'Tab': {
|
||||
return;
|
||||
}
|
||||
case 'Backspace': {
|
||||
if (target.value === '' && index > 0) {
|
||||
focusPrev(index);
|
||||
pinValues[index - 1] = '';
|
||||
} else if (target.value !== '') {
|
||||
pinValues[index] = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'ArrowLeft': {
|
||||
if (index > 0) {
|
||||
focusPrev(index);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'ArrowRight': {
|
||||
if (index < pinLength - 1) {
|
||||
focusNext(index);
|
||||
}
|
||||
return;
|
||||
}
|
||||
default: {
|
||||
if (Number.isNaN(Number(event.key))) {
|
||||
event.preventDefault();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
{#if label}
|
||||
<label class="text-xs text-dark" for={pinCodeInputElements[0]?.id}>{label.toUpperCase()}</label>
|
||||
{/if}
|
||||
<div class="flex gap-2">
|
||||
{#each { length: pinLength } as _, index (index)}
|
||||
<input
|
||||
tabindex={tabindexStart + index}
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
maxlength="1"
|
||||
bind:this={pinCodeInputElements[index]}
|
||||
id="pin-code-{index}"
|
||||
class="h-12 w-10 rounded-xl border-2 border-suble dark:border-gray-700 bg-transparent text-center text-lg font-medium focus:border-immich-primary focus:ring-primary dark:focus:border-primary font-mono"
|
||||
bind:value={pinValues[index]}
|
||||
onkeydown={handleKeydown}
|
||||
oninput={(event) => handleInput(event, index)}
|
||||
aria-label={`PIN digit ${index + 1} of ${pinLength}${label ? ` for ${label}` : ''}`}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
116
web/src/lib/components/user-settings-page/PinCodeSettings.svelte
Normal file
116
web/src/lib/components/user-settings-page/PinCodeSettings.svelte
Normal file
|
@ -0,0 +1,116 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
notificationController,
|
||||
NotificationType,
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
import PinCodeInput from '$lib/components/user-settings-page/PinCodeInput.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { changePinCode, getAuthStatus, setupPinCode } from '@immich/sdk';
|
||||
import { Button } from '@immich/ui';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
let hasPinCode = $state(false);
|
||||
let currentPinCode = $state('');
|
||||
let newPinCode = $state('');
|
||||
let confirmPinCode = $state('');
|
||||
let isLoading = $state(false);
|
||||
let canSubmit = $derived(
|
||||
(hasPinCode ? currentPinCode.length === 6 : true) && confirmPinCode.length === 6 && newPinCode === confirmPinCode,
|
||||
);
|
||||
|
||||
onMount(async () => {
|
||||
const authStatus = await getAuthStatus();
|
||||
hasPinCode = authStatus.pinCode;
|
||||
});
|
||||
|
||||
const handleSubmit = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
await (hasPinCode ? handleChange() : handleSetup());
|
||||
};
|
||||
|
||||
const handleSetup = async () => {
|
||||
isLoading = true;
|
||||
try {
|
||||
await setupPinCode({ pinCodeSetupDto: { pinCode: newPinCode } });
|
||||
|
||||
resetForm();
|
||||
|
||||
notificationController.show({
|
||||
message: $t('pin_code_setup_successfully'),
|
||||
type: NotificationType.Info,
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, $t('unable_to_setup_pin_code'));
|
||||
} finally {
|
||||
isLoading = false;
|
||||
hasPinCode = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = async () => {
|
||||
isLoading = true;
|
||||
try {
|
||||
await changePinCode({ pinCodeChangeDto: { pinCode: currentPinCode, newPinCode } });
|
||||
|
||||
resetForm();
|
||||
|
||||
notificationController.show({
|
||||
message: $t('pin_code_changed_successfully'),
|
||||
type: NotificationType.Info,
|
||||
});
|
||||
} catch (error) {
|
||||
handleError(error, $t('unable_to_change_pin_code'));
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
currentPinCode = '';
|
||||
newPinCode = '';
|
||||
confirmPinCode = '';
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="my-4">
|
||||
<div in:fade={{ duration: 200 }}>
|
||||
<form autocomplete="off" onsubmit={handleSubmit} class="mt-6">
|
||||
<div class="flex flex-col gap-6 place-items-center place-content-center">
|
||||
{#if hasPinCode}
|
||||
<p class="text-dark">Change PIN code</p>
|
||||
<PinCodeInput label={$t('current_pin_code')} bind:value={currentPinCode} tabindexStart={1} pinLength={6} />
|
||||
|
||||
<PinCodeInput label={$t('new_pin_code')} bind:value={newPinCode} tabindexStart={7} pinLength={6} />
|
||||
|
||||
<PinCodeInput
|
||||
label={$t('confirm_new_pin_code')}
|
||||
bind:value={confirmPinCode}
|
||||
tabindexStart={13}
|
||||
pinLength={6}
|
||||
/>
|
||||
{:else}
|
||||
<p class="text-dark">{$t('setup_pin_code')}</p>
|
||||
<PinCodeInput label={$t('new_pin_code')} bind:value={newPinCode} tabindexStart={1} pinLength={6} />
|
||||
|
||||
<PinCodeInput
|
||||
label={$t('confirm_new_pin_code')}
|
||||
bind:value={confirmPinCode}
|
||||
tabindexStart={7}
|
||||
pinLength={6}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 mt-4">
|
||||
<Button shape="round" color="secondary" type="button" size="small" onclick={resetForm}>
|
||||
{$t('clear')}
|
||||
</Button>
|
||||
<Button shape="round" type="submit" size="small" loading={isLoading} disabled={!canSubmit}>
|
||||
{hasPinCode ? $t('save') : $t('create')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
|
@ -1,24 +1,16 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import ChangePinCodeSettings from '$lib/components/user-settings-page/PinCodeSettings.svelte';
|
||||
import DownloadSettings from '$lib/components/user-settings-page/download-settings.svelte';
|
||||
import FeatureSettings from '$lib/components/user-settings-page/feature-settings.svelte';
|
||||
import NotificationsSettings from '$lib/components/user-settings-page/notifications-settings.svelte';
|
||||
import UserPurchaseSettings from '$lib/components/user-settings-page/user-purchase-settings.svelte';
|
||||
import UserUsageStatistic from '$lib/components/user-settings-page/user-usage-statistic.svelte';
|
||||
import { OpenSettingQueryParameterValue, QueryParameter } from '$lib/constants';
|
||||
import { featureFlags } from '$lib/stores/server-config.store';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { oauth } from '$lib/utils';
|
||||
import { type ApiKeyResponseDto, type SessionResponseDto } from '@immich/sdk';
|
||||
import SettingAccordionState from '../shared-components/settings/setting-accordion-state.svelte';
|
||||
import SettingAccordion from '../shared-components/settings/setting-accordion.svelte';
|
||||
import AppSettings from './app-settings.svelte';
|
||||
import ChangePasswordSettings from './change-password-settings.svelte';
|
||||
import DeviceList from './device-list.svelte';
|
||||
import OAuthSettings from './oauth-settings.svelte';
|
||||
import PartnerSettings from './partner-settings.svelte';
|
||||
import UserAPIKeyList from './user-api-key-list.svelte';
|
||||
import UserProfileSettings from './user-profile-settings.svelte';
|
||||
import NotificationsSettings from '$lib/components/user-settings-page/notifications-settings.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import DownloadSettings from '$lib/components/user-settings-page/download-settings.svelte';
|
||||
import UserPurchaseSettings from '$lib/components/user-settings-page/user-purchase-settings.svelte';
|
||||
import FeatureSettings from '$lib/components/user-settings-page/feature-settings.svelte';
|
||||
import {
|
||||
mdiAccountGroupOutline,
|
||||
mdiAccountOutline,
|
||||
|
@ -29,11 +21,21 @@
|
|||
mdiDownload,
|
||||
mdiFeatureSearchOutline,
|
||||
mdiKeyOutline,
|
||||
mdiLockSmart,
|
||||
mdiOnepassword,
|
||||
mdiServerOutline,
|
||||
mdiTwoFactorAuthentication,
|
||||
} from '@mdi/js';
|
||||
import UserUsageStatistic from '$lib/components/user-settings-page/user-usage-statistic.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import SettingAccordionState from '../shared-components/settings/setting-accordion-state.svelte';
|
||||
import SettingAccordion from '../shared-components/settings/setting-accordion.svelte';
|
||||
import AppSettings from './app-settings.svelte';
|
||||
import ChangePasswordSettings from './change-password-settings.svelte';
|
||||
import DeviceList from './device-list.svelte';
|
||||
import OAuthSettings from './oauth-settings.svelte';
|
||||
import PartnerSettings from './partner-settings.svelte';
|
||||
import UserAPIKeyList from './user-api-key-list.svelte';
|
||||
import UserProfileSettings from './user-profile-settings.svelte';
|
||||
|
||||
interface Props {
|
||||
keys?: ApiKeyResponseDto[];
|
||||
|
@ -135,6 +137,16 @@
|
|||
<PartnerSettings user={$user} />
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
icon={mdiLockSmart}
|
||||
key="user-pin-code-settings"
|
||||
title={$t('user_pin_code_settings')}
|
||||
subtitle={$t('user_pin_code_settings_description')}
|
||||
autoScrollTo={true}
|
||||
>
|
||||
<ChangePinCodeSettings />
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
icon={mdiKeyOutline}
|
||||
key="user-purchase-settings"
|
||||
|
|
|
@ -6,14 +6,17 @@
|
|||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updateUserAdmin, type UserAdminResponseDto } from '@immich/sdk';
|
||||
import { Button, Modal, ModalBody, ModalFooter } from '@immich/ui';
|
||||
import { mdiAccountEditOutline } from '@mdi/js';
|
||||
import { mdiAccountEditOutline, mdiLockSmart, mdiOnepassword } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
user: UserAdminResponseDto;
|
||||
canResetPassword?: boolean;
|
||||
onClose: (
|
||||
data?: { action: 'update'; data: UserAdminResponseDto } | { action: 'resetPassword'; data: string },
|
||||
data?:
|
||||
| { action: 'update'; data: UserAdminResponseDto }
|
||||
| { action: 'resetPassword'; data: string }
|
||||
| { action: 'resetPinCode' },
|
||||
) => void;
|
||||
}
|
||||
|
||||
|
@ -76,6 +79,24 @@
|
|||
}
|
||||
};
|
||||
|
||||
const resetUserPincode = async () => {
|
||||
const isConfirmed = await modalManager.openDialog({
|
||||
prompt: $t('admin.confirm_user_pin_code_reset', { values: { user: user.name } }),
|
||||
});
|
||||
|
||||
if (!isConfirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await updateUserAdmin({ id: user.id, userAdminUpdateDto: { pinCode: null } });
|
||||
|
||||
onClose({ action: 'resetPinCode' });
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_reset_pin_code'));
|
||||
}
|
||||
};
|
||||
|
||||
// TODO move password reset server-side
|
||||
function generatePassword(length: number = 16) {
|
||||
let generatedPassword = '';
|
||||
|
@ -151,13 +172,34 @@
|
|||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<div class="flex gap-3 w-full">
|
||||
{#if canResetPassword}
|
||||
<Button shape="round" color="warning" variant="filled" fullWidth onclick={resetPassword}
|
||||
>{$t('reset_password')}</Button
|
||||
<div class="w-full">
|
||||
<div class="flex gap-3 w-full">
|
||||
{#if canResetPassword}
|
||||
<Button
|
||||
shape="round"
|
||||
color="warning"
|
||||
variant="filled"
|
||||
fullWidth
|
||||
onclick={resetPassword}
|
||||
leadingIcon={mdiOnepassword}
|
||||
>
|
||||
{$t('reset_password')}</Button
|
||||
>
|
||||
{/if}
|
||||
|
||||
<Button
|
||||
shape="round"
|
||||
color="warning"
|
||||
variant="filled"
|
||||
fullWidth
|
||||
onclick={resetUserPincode}
|
||||
leadingIcon={mdiLockSmart}>{$t('reset_pin_code')}</Button
|
||||
>
|
||||
{/if}
|
||||
<Button type="submit" shape="round" fullWidth form="edit-user-form">{$t('confirm')}</Button>
|
||||
</div>
|
||||
|
||||
<div class="w-full mt-4">
|
||||
<Button type="submit" shape="round" fullWidth form="edit-user-form">{$t('confirm')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
|
|
@ -74,6 +74,10 @@
|
|||
await refresh();
|
||||
break;
|
||||
}
|
||||
case 'resetPinCode': {
|
||||
notificationController.show({ type: NotificationType.Info, message: $t('pin_code_reset_successfully') });
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -137,7 +141,7 @@
|
|||
{#if !immichUser.deletedAt}
|
||||
<IconButton
|
||||
shape="round"
|
||||
size="small"
|
||||
size="medium"
|
||||
icon={mdiPencilOutline}
|
||||
title={$t('edit_user')}
|
||||
onclick={() => handleEdit(immichUser)}
|
||||
|
@ -146,7 +150,7 @@
|
|||
{#if immichUser.id !== $user.id}
|
||||
<IconButton
|
||||
shape="round"
|
||||
size="small"
|
||||
size="medium"
|
||||
icon={mdiTrashCanOutline}
|
||||
title={$t('delete_user')}
|
||||
onclick={() => handleDelete(immichUser)}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue