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:
Alex 2025-05-09 16:00:58 -05:00 committed by GitHub
parent 55af925ab3
commit 3f719bd8d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 867 additions and 38 deletions

View file

@ -1,4 +1,16 @@
{
"user_pin_code_settings": "PIN Code",
"user_pin_code_settings_description": "Manage your PIN code",
"current_pin_code": "Current PIN code",
"new_pin_code": "New PIN code",
"setup_pin_code": "Setup a PIN code",
"confirm_new_pin_code": "Confirm new PIN code",
"unable_to_change_pin_code": "Unable to change PIN code",
"unable_to_setup_pin_code": "Unable to setup PIN code",
"pin_code_changed_successfully": "Successfully changed PIN code",
"pin_code_setup_successfully": "Successfully setup a PIN code",
"pin_code_reset_successfully": "Successfully reset PIN code",
"reset_pin_code": "Reset PIN code",
"about": "About",
"account": "Account",
"account_settings": "Account Settings",
@ -53,6 +65,7 @@
"confirm_email_below": "To confirm, type \"{email}\" below",
"confirm_reprocess_all_faces": "Are you sure you want to reprocess all faces? This will also clear named people.",
"confirm_user_password_reset": "Are you sure you want to reset {user}'s password?",
"confirm_user_pin_code_reset": "Are you sure you want to reset {user}'s PIN code?",
"create_job": "Create job",
"cron_expression": "Cron expression",
"cron_expression_description": "Set the scanning interval using the cron format. For more information please refer to e.g. <link>Crontab Guru</link>",
@ -922,6 +935,7 @@
"unable_to_remove_reaction": "Unable to remove reaction",
"unable_to_repair_items": "Unable to repair items",
"unable_to_reset_password": "Unable to reset password",
"unable_to_reset_pin_code": "Unable to reset PIN code",
"unable_to_resolve_duplicate": "Unable to resolve duplicate",
"unable_to_restore_assets": "Unable to restore assets",
"unable_to_restore_trash": "Unable to restore trash",

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -2294,6 +2294,139 @@
]
}
},
"/auth/pin-code": {
"delete": {
"operationId": "resetPinCode",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PinCodeChangeDto"
}
}
},
"required": true
},
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Authentication"
]
},
"post": {
"operationId": "setupPinCode",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PinCodeSetupDto"
}
}
},
"required": true
},
"responses": {
"201": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Authentication"
]
},
"put": {
"operationId": "changePinCode",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PinCodeChangeDto"
}
}
},
"required": true
},
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Authentication"
]
}
},
"/auth/status": {
"get": {
"operationId": "getAuthStatus",
"parameters": [],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AuthStatusResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Authentication"
]
}
},
"/auth/validateToken": {
"post": {
"operationId": "validateAccessToken",
@ -9031,6 +9164,21 @@
],
"type": "string"
},
"AuthStatusResponseDto": {
"properties": {
"password": {
"type": "boolean"
},
"pinCode": {
"type": "boolean"
}
},
"required": [
"password",
"pinCode"
],
"type": "object"
},
"AvatarUpdate": {
"properties": {
"color": {
@ -10964,6 +11112,37 @@
],
"type": "object"
},
"PinCodeChangeDto": {
"properties": {
"newPinCode": {
"example": "123456",
"type": "string"
},
"password": {
"type": "string"
},
"pinCode": {
"example": "123456",
"type": "string"
}
},
"required": [
"newPinCode"
],
"type": "object"
},
"PinCodeSetupDto": {
"properties": {
"pinCode": {
"example": "123456",
"type": "string"
}
},
"required": [
"pinCode"
],
"type": "object"
},
"PlacesResponseDto": {
"properties": {
"admin1name": {
@ -13958,6 +14137,11 @@
"password": {
"type": "string"
},
"pinCode": {
"example": "123456",
"nullable": true,
"type": "string"
},
"quotaSizeInBytes": {
"format": "int64",
"minimum": 0,

View file

@ -123,6 +123,7 @@ export type UserAdminUpdateDto = {
email?: string;
name?: string;
password?: string;
pinCode?: string | null;
quotaSizeInBytes?: number | null;
shouldChangePassword?: boolean;
storageLabel?: string | null;
@ -510,6 +511,18 @@ export type LogoutResponseDto = {
redirectUri: string;
successful: boolean;
};
export type PinCodeChangeDto = {
newPinCode: string;
password?: string;
pinCode?: string;
};
export type PinCodeSetupDto = {
pinCode: string;
};
export type AuthStatusResponseDto = {
password: boolean;
pinCode: boolean;
};
export type ValidateAccessTokenResponseDto = {
authStatus: boolean;
};
@ -2017,6 +2030,41 @@ export function logout(opts?: Oazapfts.RequestOpts) {
method: "POST"
}));
}
export function resetPinCode({ pinCodeChangeDto }: {
pinCodeChangeDto: PinCodeChangeDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/auth/pin-code", oazapfts.json({
...opts,
method: "DELETE",
body: pinCodeChangeDto
})));
}
export function setupPinCode({ pinCodeSetupDto }: {
pinCodeSetupDto: PinCodeSetupDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/auth/pin-code", oazapfts.json({
...opts,
method: "POST",
body: pinCodeSetupDto
})));
}
export function changePinCode({ pinCodeChangeDto }: {
pinCodeChangeDto: PinCodeChangeDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/auth/pin-code", oazapfts.json({
...opts,
method: "PUT",
body: pinCodeChangeDto
})));
}
export function getAuthStatus(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: AuthStatusResponseDto;
}>("/auth/status", {
...opts
}));
}
export function validateAccessToken(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;

View file

@ -142,4 +142,50 @@ describe(AuthController.name, () => {
expect(ctx.authenticate).toHaveBeenCalled();
});
});
describe('POST /auth/pin-code', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '123456' });
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should reject 5 digits', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '12345' });
expect(status).toEqual(400);
expect(body).toEqual(errorDto.badRequest(['pinCode must be a 6-digit numeric string']));
});
it('should reject 7 digits', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: '1234567' });
expect(status).toEqual(400);
expect(body).toEqual(errorDto.badRequest(['pinCode must be a 6-digit numeric string']));
});
it('should reject non-numbers', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/auth/pin-code').send({ pinCode: 'A12345' });
expect(status).toEqual(400);
expect(body).toEqual(errorDto.badRequest(['pinCode must be a 6-digit numeric string']));
});
});
describe('PUT /auth/pin-code', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).put('/auth/pin-code').send({ pinCode: '123456', newPinCode: '654321' });
expect(ctx.authenticate).toHaveBeenCalled();
});
});
describe('DELETE /auth/pin-code', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).delete('/auth/pin-code').send({ pinCode: '123456' });
expect(ctx.authenticate).toHaveBeenCalled();
});
});
describe('GET /auth/status', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get('/auth/status');
expect(ctx.authenticate).toHaveBeenCalled();
});
});
});

View file

@ -1,12 +1,15 @@
import { Body, Controller, HttpCode, HttpStatus, Post, Req, Res } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Post, Put, Req, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express';
import {
AuthDto,
AuthStatusResponseDto,
ChangePasswordDto,
LoginCredentialDto,
LoginResponseDto,
LogoutResponseDto,
PinCodeChangeDto,
PinCodeSetupDto,
SignUpDto,
ValidateAccessTokenResponseDto,
} from 'src/dtos/auth.dto';
@ -74,4 +77,28 @@ export class AuthController {
ImmichCookie.IS_AUTHENTICATED,
]);
}
@Get('status')
@Authenticated()
getAuthStatus(@Auth() auth: AuthDto): Promise<AuthStatusResponseDto> {
return this.service.getAuthStatus(auth);
}
@Post('pin-code')
@Authenticated()
setupPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeSetupDto): Promise<void> {
return this.service.setupPinCode(auth, dto);
}
@Put('pin-code')
@Authenticated()
async changePinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeChangeDto): Promise<void> {
return this.service.changePinCode(auth, dto);
}
@Delete('pin-code')
@Authenticated()
async resetPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeChangeDto): Promise<void> {
return this.service.resetPinCode(auth, dto);
}
}

View file

@ -3,7 +3,7 @@ import { Transform } from 'class-transformer';
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser, UserAdmin } from 'src/database';
import { ImmichCookie } from 'src/enum';
import { Optional, toEmail } from 'src/validation';
import { Optional, PinCode, toEmail } from 'src/validation';
export type CookieResponse = {
isSecure: boolean;
@ -78,6 +78,26 @@ export class ChangePasswordDto {
newPassword!: string;
}
export class PinCodeSetupDto {
@PinCode()
pinCode!: string;
}
export class PinCodeResetDto {
@PinCode({ optional: true })
pinCode?: string;
@Optional()
@IsString()
@IsNotEmpty()
password?: string;
}
export class PinCodeChangeDto extends PinCodeResetDto {
@PinCode()
newPinCode!: string;
}
export class ValidateAccessTokenResponseDto {
authStatus!: boolean;
}
@ -114,3 +134,8 @@ export class OAuthConfigDto {
export class OAuthAuthorizeResponseDto {
url!: string;
}
export class AuthStatusResponseDto {
pinCode!: boolean;
password!: boolean;
}

View file

@ -4,7 +4,7 @@ import { IsBoolean, IsEmail, IsEnum, IsNotEmpty, IsNumber, IsString, Min } from
import { User, UserAdmin } from 'src/database';
import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum';
import { UserMetadataItem } from 'src/types';
import { Optional, ValidateBoolean, toEmail, toSanitized } from 'src/validation';
import { Optional, PinCode, ValidateBoolean, toEmail, toSanitized } from 'src/validation';
export class UserUpdateMeDto {
@Optional()
@ -116,6 +116,9 @@ export class UserAdminUpdateDto {
@IsString()
password?: string;
@PinCode({ optional: true, nullable: true, emptyToNull: true })
pinCode?: string | null;
@Optional()
@IsString()
@IsNotEmpty()

View file

@ -87,6 +87,16 @@ where
"users"."isAdmin" = $1
and "users"."deletedAt" is null
-- UserRepository.getForPinCode
select
"users"."pinCode",
"users"."password"
from
"users"
where
"users"."id" = $1
and "users"."deletedAt" is null
-- UserRepository.getByEmail
select
"id",

View file

@ -89,13 +89,23 @@ export class UserRepository {
return !!admin;
}
@GenerateSql({ params: [DummyValue.UUID] })
getForPinCode(id: string) {
return this.db
.selectFrom('users')
.select(['users.pinCode', 'users.password'])
.where('users.id', '=', id)
.where('users.deletedAt', 'is', null)
.executeTakeFirstOrThrow();
}
@GenerateSql({ params: [DummyValue.EMAIL] })
getByEmail(email: string, withPassword?: boolean) {
getByEmail(email: string, options?: { withPassword?: boolean }) {
return this.db
.selectFrom('users')
.select(columns.userAdmin)
.select(withMetadata)
.$if(!!withPassword, (eb) => eb.select('password'))
.$if(!!options?.withPassword, (eb) => eb.select('password'))
.where('email', '=', email)
.where('users.deletedAt', 'is', null)
.executeTakeFirst();

View file

@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "users" ADD "pinCode" character varying;`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "users" DROP COLUMN "pinCode";`.execute(db);
}

View file

@ -37,6 +37,9 @@ export class UserTable {
@Column({ default: '' })
password!: Generated<string>;
@Column({ nullable: true })
pinCode!: string | null;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;

View file

@ -1,5 +1,6 @@
import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import { DateTime } from 'luxon';
import { SALT_ROUNDS } from 'src/constants';
import { UserAdmin } from 'src/database';
import { AuthDto, SignUpDto } from 'src/dtos/auth.dto';
import { AuthType, Permission } from 'src/enum';
@ -118,7 +119,7 @@ describe(AuthService.name, () => {
await sut.changePassword(auth, dto);
expect(mocks.user.getByEmail).toHaveBeenCalledWith(auth.user.email, true);
expect(mocks.user.getByEmail).toHaveBeenCalledWith(auth.user.email, { withPassword: true });
expect(mocks.crypto.compareBcrypt).toHaveBeenCalledWith('old-password', 'hash-password');
});
@ -859,4 +860,77 @@ describe(AuthService.name, () => {
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: '' });
});
});
describe('setupPinCode', () => {
it('should setup a PIN code', async () => {
const user = factory.userAdmin();
const auth = factory.auth({ user });
const dto = { pinCode: '123456' };
mocks.user.getForPinCode.mockResolvedValue({ pinCode: null, password: '' });
mocks.user.update.mockResolvedValue(user);
await sut.setupPinCode(auth, dto);
expect(mocks.user.getForPinCode).toHaveBeenCalledWith(user.id);
expect(mocks.crypto.hashBcrypt).toHaveBeenCalledWith('123456', SALT_ROUNDS);
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pinCode: expect.any(String) });
});
it('should fail if the user already has a PIN code', async () => {
const user = factory.userAdmin();
const auth = factory.auth({ user });
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
await expect(sut.setupPinCode(auth, { pinCode: '123456' })).rejects.toThrow('User already has a PIN code');
});
});
describe('changePinCode', () => {
it('should change the PIN code', async () => {
const user = factory.userAdmin();
const auth = factory.auth({ user });
const dto = { pinCode: '123456', newPinCode: '012345' };
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
mocks.user.update.mockResolvedValue(user);
mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b);
await sut.changePinCode(auth, dto);
expect(mocks.crypto.compareBcrypt).toHaveBeenCalledWith('123456', '123456 (hashed)');
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pinCode: '012345 (hashed)' });
});
it('should fail if the PIN code does not match', async () => {
const user = factory.userAdmin();
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b);
await expect(
sut.changePinCode(factory.auth({ user }), { pinCode: '000000', newPinCode: '012345' }),
).rejects.toThrow('Wrong PIN code');
});
});
describe('resetPinCode', () => {
it('should reset the PIN code', async () => {
const user = factory.userAdmin();
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b);
await sut.resetPinCode(factory.auth({ user }), { pinCode: '123456' });
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pinCode: null });
});
it('should throw if the PIN code does not match', async () => {
const user = factory.userAdmin();
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b);
await expect(sut.resetPinCode(factory.auth({ user }), { pinCode: '000000' })).rejects.toThrow('Wrong PIN code');
});
});
});

View file

@ -9,11 +9,15 @@ import { StorageCore } from 'src/cores/storage.core';
import { UserAdmin } from 'src/database';
import {
AuthDto,
AuthStatusResponseDto,
ChangePasswordDto,
LoginCredentialDto,
LogoutResponseDto,
OAuthCallbackDto,
OAuthConfigDto,
PinCodeChangeDto,
PinCodeResetDto,
PinCodeSetupDto,
SignUpDto,
mapLoginResponse,
} from 'src/dtos/auth.dto';
@ -56,9 +60,9 @@ export class AuthService extends BaseService {
throw new UnauthorizedException('Password login has been disabled');
}
let user = await this.userRepository.getByEmail(dto.email, true);
let user = await this.userRepository.getByEmail(dto.email, { withPassword: true });
if (user) {
const isAuthenticated = this.validatePassword(dto.password, user);
const isAuthenticated = this.validateSecret(dto.password, user.password);
if (!isAuthenticated) {
user = undefined;
}
@ -86,12 +90,12 @@ export class AuthService extends BaseService {
async changePassword(auth: AuthDto, dto: ChangePasswordDto): Promise<UserAdminResponseDto> {
const { password, newPassword } = dto;
const user = await this.userRepository.getByEmail(auth.user.email, true);
const user = await this.userRepository.getByEmail(auth.user.email, { withPassword: true });
if (!user) {
throw new UnauthorizedException();
}
const valid = this.validatePassword(password, user);
const valid = this.validateSecret(password, user.password);
if (!valid) {
throw new BadRequestException('Wrong password');
}
@ -103,6 +107,56 @@ export class AuthService extends BaseService {
return mapUserAdmin(updatedUser);
}
async setupPinCode(auth: AuthDto, { pinCode }: PinCodeSetupDto) {
const user = await this.userRepository.getForPinCode(auth.user.id);
if (!user) {
throw new UnauthorizedException();
}
if (user.pinCode) {
throw new BadRequestException('User already has a PIN code');
}
const hashed = await this.cryptoRepository.hashBcrypt(pinCode, SALT_ROUNDS);
await this.userRepository.update(auth.user.id, { pinCode: hashed });
}
async resetPinCode(auth: AuthDto, dto: PinCodeResetDto) {
const user = await this.userRepository.getForPinCode(auth.user.id);
this.resetPinChecks(user, dto);
await this.userRepository.update(auth.user.id, { pinCode: null });
}
async changePinCode(auth: AuthDto, dto: PinCodeChangeDto) {
const user = await this.userRepository.getForPinCode(auth.user.id);
this.resetPinChecks(user, dto);
const hashed = await this.cryptoRepository.hashBcrypt(dto.newPinCode, SALT_ROUNDS);
await this.userRepository.update(auth.user.id, { pinCode: hashed });
}
private resetPinChecks(
user: { pinCode: string | null; password: string | null },
dto: { pinCode?: string; password?: string },
) {
if (!user.pinCode) {
throw new BadRequestException('User does not have a PIN code');
}
if (dto.password) {
if (!this.validateSecret(dto.password, user.password)) {
throw new BadRequestException('Wrong password');
}
} else if (dto.pinCode) {
if (!this.validateSecret(dto.pinCode, user.pinCode)) {
throw new BadRequestException('Wrong PIN code');
}
} else {
throw new BadRequestException('Either password or pinCode is required');
}
}
async adminSignUp(dto: SignUpDto): Promise<UserAdminResponseDto> {
const adminUser = await this.userRepository.getAdmin();
if (adminUser) {
@ -371,11 +425,12 @@ export class AuthService extends BaseService {
throw new UnauthorizedException('Invalid API key');
}
private validatePassword(inputPassword: string, user: { password?: string }): boolean {
if (!user || !user.password) {
private validateSecret(inputSecret: string, existingHash?: string | null): boolean {
if (!existingHash) {
return false;
}
return this.cryptoRepository.compareBcrypt(inputPassword, user.password);
return this.cryptoRepository.compareBcrypt(inputSecret, existingHash);
}
private async validateSession(tokenValue: string): Promise<AuthDto> {
@ -428,4 +483,16 @@ export class AuthService extends BaseService {
}
return url;
}
async getAuthStatus(auth: AuthDto): Promise<AuthStatusResponseDto> {
const user = await this.userRepository.getForPinCode(auth.user.id);
if (!user) {
throw new UnauthorizedException();
}
return {
pinCode: !!user.pinCode,
password: !!user.password,
};
}
}

View file

@ -70,6 +70,10 @@ export class UserAdminService extends BaseService {
dto.password = await this.cryptoRepository.hashBcrypt(dto.password, SALT_ROUNDS);
}
if (dto.pinCode) {
dto.pinCode = await this.cryptoRepository.hashBcrypt(dto.pinCode, SALT_ROUNDS);
}
if (dto.storageLabel === '') {
dto.storageLabel = null;
}

View file

@ -18,6 +18,7 @@ import {
IsOptional,
IsString,
IsUUID,
Matches,
Validate,
ValidateBy,
ValidateIf,
@ -70,6 +71,22 @@ export class UUIDParamDto {
id!: string;
}
type PinCodeOptions = { optional?: boolean } & OptionalOptions;
export const PinCode = ({ optional, ...options }: PinCodeOptions = {}) => {
const decorators = [
IsString(),
IsNotEmpty(),
Matches(/^\d{6}$/, { message: ({ property }) => `${property} must be a 6-digit numeric string` }),
ApiProperty({ example: '123456' }),
];
if (optional) {
decorators.push(Optional(options));
}
return applyDecorators(...decorators);
};
export interface OptionalOptions extends ValidationOptions {
nullable?: boolean;
/** convert empty strings to null */

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

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

View file

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

View file

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

View file

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