mirror of
https://github.com/samsonjs/immich.git
synced 2026-04-27 15:07:45 +00: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 867 additions and 38 deletions
14
i18n/en.json
14
i18n/en.json
|
|
@ -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",
|
"about": "About",
|
||||||
"account": "Account",
|
"account": "Account",
|
||||||
"account_settings": "Account Settings",
|
"account_settings": "Account Settings",
|
||||||
|
|
@ -53,6 +65,7 @@
|
||||||
"confirm_email_below": "To confirm, type \"{email}\" below",
|
"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_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_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",
|
"create_job": "Create job",
|
||||||
"cron_expression": "Cron expression",
|
"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>",
|
"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_remove_reaction": "Unable to remove reaction",
|
||||||
"unable_to_repair_items": "Unable to repair items",
|
"unable_to_repair_items": "Unable to repair items",
|
||||||
"unable_to_reset_password": "Unable to reset password",
|
"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_resolve_duplicate": "Unable to resolve duplicate",
|
||||||
"unable_to_restore_assets": "Unable to restore assets",
|
"unable_to_restore_assets": "Unable to restore assets",
|
||||||
"unable_to_restore_trash": "Unable to restore trash",
|
"unable_to_restore_trash": "Unable to restore trash",
|
||||||
|
|
|
||||||
BIN
mobile/openapi/README.md
generated
BIN
mobile/openapi/README.md
generated
Binary file not shown.
BIN
mobile/openapi/lib/api.dart
generated
BIN
mobile/openapi/lib/api.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/api/authentication_api.dart
generated
BIN
mobile/openapi/lib/api/authentication_api.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/api_client.dart
generated
BIN
mobile/openapi/lib/api_client.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/auth_status_response_dto.dart
generated
Normal file
BIN
mobile/openapi/lib/model/auth_status_response_dto.dart
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/lib/model/pin_code_change_dto.dart
generated
Normal file
BIN
mobile/openapi/lib/model/pin_code_change_dto.dart
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/lib/model/pin_code_setup_dto.dart
generated
Normal file
BIN
mobile/openapi/lib/model/pin_code_setup_dto.dart
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/lib/model/user_admin_update_dto.dart
generated
BIN
mobile/openapi/lib/model/user_admin_update_dto.dart
generated
Binary file not shown.
|
|
@ -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": {
|
"/auth/validateToken": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "validateAccessToken",
|
"operationId": "validateAccessToken",
|
||||||
|
|
@ -9031,6 +9164,21 @@
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"AuthStatusResponseDto": {
|
||||||
|
"properties": {
|
||||||
|
"password": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"pinCode": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"password",
|
||||||
|
"pinCode"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"AvatarUpdate": {
|
"AvatarUpdate": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"color": {
|
"color": {
|
||||||
|
|
@ -10964,6 +11112,37 @@
|
||||||
],
|
],
|
||||||
"type": "object"
|
"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": {
|
"PlacesResponseDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"admin1name": {
|
"admin1name": {
|
||||||
|
|
@ -13958,6 +14137,11 @@
|
||||||
"password": {
|
"password": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"pinCode": {
|
||||||
|
"example": "123456",
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"quotaSizeInBytes": {
|
"quotaSizeInBytes": {
|
||||||
"format": "int64",
|
"format": "int64",
|
||||||
"minimum": 0,
|
"minimum": 0,
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,7 @@ export type UserAdminUpdateDto = {
|
||||||
email?: string;
|
email?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
pinCode?: string | null;
|
||||||
quotaSizeInBytes?: number | null;
|
quotaSizeInBytes?: number | null;
|
||||||
shouldChangePassword?: boolean;
|
shouldChangePassword?: boolean;
|
||||||
storageLabel?: string | null;
|
storageLabel?: string | null;
|
||||||
|
|
@ -510,6 +511,18 @@ export type LogoutResponseDto = {
|
||||||
redirectUri: string;
|
redirectUri: string;
|
||||||
successful: boolean;
|
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 = {
|
export type ValidateAccessTokenResponseDto = {
|
||||||
authStatus: boolean;
|
authStatus: boolean;
|
||||||
};
|
};
|
||||||
|
|
@ -2017,6 +2030,41 @@ export function logout(opts?: Oazapfts.RequestOpts) {
|
||||||
method: "POST"
|
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) {
|
export function validateAccessToken(opts?: Oazapfts.RequestOpts) {
|
||||||
return oazapfts.ok(oazapfts.fetchJson<{
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
status: 200;
|
status: 200;
|
||||||
|
|
|
||||||
|
|
@ -142,4 +142,50 @@ describe(AuthController.name, () => {
|
||||||
expect(ctx.authenticate).toHaveBeenCalled();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 { ApiTags } from '@nestjs/swagger';
|
||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import {
|
import {
|
||||||
AuthDto,
|
AuthDto,
|
||||||
|
AuthStatusResponseDto,
|
||||||
ChangePasswordDto,
|
ChangePasswordDto,
|
||||||
LoginCredentialDto,
|
LoginCredentialDto,
|
||||||
LoginResponseDto,
|
LoginResponseDto,
|
||||||
LogoutResponseDto,
|
LogoutResponseDto,
|
||||||
|
PinCodeChangeDto,
|
||||||
|
PinCodeSetupDto,
|
||||||
SignUpDto,
|
SignUpDto,
|
||||||
ValidateAccessTokenResponseDto,
|
ValidateAccessTokenResponseDto,
|
||||||
} from 'src/dtos/auth.dto';
|
} from 'src/dtos/auth.dto';
|
||||||
|
|
@ -74,4 +77,28 @@ export class AuthController {
|
||||||
ImmichCookie.IS_AUTHENTICATED,
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { Transform } from 'class-transformer';
|
||||||
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
|
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||||
import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser, UserAdmin } from 'src/database';
|
import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser, UserAdmin } from 'src/database';
|
||||||
import { ImmichCookie } from 'src/enum';
|
import { ImmichCookie } from 'src/enum';
|
||||||
import { Optional, toEmail } from 'src/validation';
|
import { Optional, PinCode, toEmail } from 'src/validation';
|
||||||
|
|
||||||
export type CookieResponse = {
|
export type CookieResponse = {
|
||||||
isSecure: boolean;
|
isSecure: boolean;
|
||||||
|
|
@ -78,6 +78,26 @@ export class ChangePasswordDto {
|
||||||
newPassword!: string;
|
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 {
|
export class ValidateAccessTokenResponseDto {
|
||||||
authStatus!: boolean;
|
authStatus!: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -114,3 +134,8 @@ export class OAuthConfigDto {
|
||||||
export class OAuthAuthorizeResponseDto {
|
export class OAuthAuthorizeResponseDto {
|
||||||
url!: string;
|
url!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class AuthStatusResponseDto {
|
||||||
|
pinCode!: boolean;
|
||||||
|
password!: boolean;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { IsBoolean, IsEmail, IsEnum, IsNotEmpty, IsNumber, IsString, Min } from
|
||||||
import { User, UserAdmin } from 'src/database';
|
import { User, UserAdmin } from 'src/database';
|
||||||
import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum';
|
import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum';
|
||||||
import { UserMetadataItem } from 'src/types';
|
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 {
|
export class UserUpdateMeDto {
|
||||||
@Optional()
|
@Optional()
|
||||||
|
|
@ -116,6 +116,9 @@ export class UserAdminUpdateDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
password?: string;
|
password?: string;
|
||||||
|
|
||||||
|
@PinCode({ optional: true, nullable: true, emptyToNull: true })
|
||||||
|
pinCode?: string | null;
|
||||||
|
|
||||||
@Optional()
|
@Optional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,16 @@ where
|
||||||
"users"."isAdmin" = $1
|
"users"."isAdmin" = $1
|
||||||
and "users"."deletedAt" is null
|
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
|
-- UserRepository.getByEmail
|
||||||
select
|
select
|
||||||
"id",
|
"id",
|
||||||
|
|
|
||||||
|
|
@ -89,13 +89,23 @@ export class UserRepository {
|
||||||
return !!admin;
|
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] })
|
@GenerateSql({ params: [DummyValue.EMAIL] })
|
||||||
getByEmail(email: string, withPassword?: boolean) {
|
getByEmail(email: string, options?: { withPassword?: boolean }) {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('users')
|
.selectFrom('users')
|
||||||
.select(columns.userAdmin)
|
.select(columns.userAdmin)
|
||||||
.select(withMetadata)
|
.select(withMetadata)
|
||||||
.$if(!!withPassword, (eb) => eb.select('password'))
|
.$if(!!options?.withPassword, (eb) => eb.select('password'))
|
||||||
.where('email', '=', email)
|
.where('email', '=', email)
|
||||||
.where('users.deletedAt', 'is', null)
|
.where('users.deletedAt', 'is', null)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -37,6 +37,9 @@ export class UserTable {
|
||||||
@Column({ default: '' })
|
@Column({ default: '' })
|
||||||
password!: Generated<string>;
|
password!: Generated<string>;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
pinCode!: string | null;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
createdAt!: Generated<Timestamp>;
|
createdAt!: Generated<Timestamp>;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
import { SALT_ROUNDS } from 'src/constants';
|
||||||
import { UserAdmin } from 'src/database';
|
import { UserAdmin } from 'src/database';
|
||||||
import { AuthDto, SignUpDto } from 'src/dtos/auth.dto';
|
import { AuthDto, SignUpDto } from 'src/dtos/auth.dto';
|
||||||
import { AuthType, Permission } from 'src/enum';
|
import { AuthType, Permission } from 'src/enum';
|
||||||
|
|
@ -118,7 +119,7 @@ describe(AuthService.name, () => {
|
||||||
|
|
||||||
await sut.changePassword(auth, dto);
|
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');
|
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: '' });
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,15 @@ import { StorageCore } from 'src/cores/storage.core';
|
||||||
import { UserAdmin } from 'src/database';
|
import { UserAdmin } from 'src/database';
|
||||||
import {
|
import {
|
||||||
AuthDto,
|
AuthDto,
|
||||||
|
AuthStatusResponseDto,
|
||||||
ChangePasswordDto,
|
ChangePasswordDto,
|
||||||
LoginCredentialDto,
|
LoginCredentialDto,
|
||||||
LogoutResponseDto,
|
LogoutResponseDto,
|
||||||
OAuthCallbackDto,
|
OAuthCallbackDto,
|
||||||
OAuthConfigDto,
|
OAuthConfigDto,
|
||||||
|
PinCodeChangeDto,
|
||||||
|
PinCodeResetDto,
|
||||||
|
PinCodeSetupDto,
|
||||||
SignUpDto,
|
SignUpDto,
|
||||||
mapLoginResponse,
|
mapLoginResponse,
|
||||||
} from 'src/dtos/auth.dto';
|
} from 'src/dtos/auth.dto';
|
||||||
|
|
@ -56,9 +60,9 @@ export class AuthService extends BaseService {
|
||||||
throw new UnauthorizedException('Password login has been disabled');
|
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) {
|
if (user) {
|
||||||
const isAuthenticated = this.validatePassword(dto.password, user);
|
const isAuthenticated = this.validateSecret(dto.password, user.password);
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
user = undefined;
|
user = undefined;
|
||||||
}
|
}
|
||||||
|
|
@ -86,12 +90,12 @@ export class AuthService extends BaseService {
|
||||||
|
|
||||||
async changePassword(auth: AuthDto, dto: ChangePasswordDto): Promise<UserAdminResponseDto> {
|
async changePassword(auth: AuthDto, dto: ChangePasswordDto): Promise<UserAdminResponseDto> {
|
||||||
const { password, newPassword } = dto;
|
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) {
|
if (!user) {
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
const valid = this.validatePassword(password, user);
|
const valid = this.validateSecret(password, user.password);
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
throw new BadRequestException('Wrong password');
|
throw new BadRequestException('Wrong password');
|
||||||
}
|
}
|
||||||
|
|
@ -103,6 +107,56 @@ export class AuthService extends BaseService {
|
||||||
return mapUserAdmin(updatedUser);
|
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> {
|
async adminSignUp(dto: SignUpDto): Promise<UserAdminResponseDto> {
|
||||||
const adminUser = await this.userRepository.getAdmin();
|
const adminUser = await this.userRepository.getAdmin();
|
||||||
if (adminUser) {
|
if (adminUser) {
|
||||||
|
|
@ -371,11 +425,12 @@ export class AuthService extends BaseService {
|
||||||
throw new UnauthorizedException('Invalid API key');
|
throw new UnauthorizedException('Invalid API key');
|
||||||
}
|
}
|
||||||
|
|
||||||
private validatePassword(inputPassword: string, user: { password?: string }): boolean {
|
private validateSecret(inputSecret: string, existingHash?: string | null): boolean {
|
||||||
if (!user || !user.password) {
|
if (!existingHash) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return this.cryptoRepository.compareBcrypt(inputPassword, user.password);
|
|
||||||
|
return this.cryptoRepository.compareBcrypt(inputSecret, existingHash);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async validateSession(tokenValue: string): Promise<AuthDto> {
|
private async validateSession(tokenValue: string): Promise<AuthDto> {
|
||||||
|
|
@ -428,4 +483,16 @@ export class AuthService extends BaseService {
|
||||||
}
|
}
|
||||||
return url;
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,10 @@ export class UserAdminService extends BaseService {
|
||||||
dto.password = await this.cryptoRepository.hashBcrypt(dto.password, SALT_ROUNDS);
|
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 === '') {
|
if (dto.storageLabel === '') {
|
||||||
dto.storageLabel = null;
|
dto.storageLabel = null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
IsUUID,
|
IsUUID,
|
||||||
|
Matches,
|
||||||
Validate,
|
Validate,
|
||||||
ValidateBy,
|
ValidateBy,
|
||||||
ValidateIf,
|
ValidateIf,
|
||||||
|
|
@ -70,6 +71,22 @@ export class UUIDParamDto {
|
||||||
id!: string;
|
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 {
|
export interface OptionalOptions extends ValidationOptions {
|
||||||
nullable?: boolean;
|
nullable?: boolean;
|
||||||
/** convert empty strings to null */
|
/** convert empty strings to null */
|
||||||
|
|
|
||||||
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">
|
<script lang="ts">
|
||||||
import { page } from '$app/stores';
|
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 { OpenSettingQueryParameterValue, QueryParameter } from '$lib/constants';
|
||||||
import { featureFlags } from '$lib/stores/server-config.store';
|
import { featureFlags } from '$lib/stores/server-config.store';
|
||||||
import { user } from '$lib/stores/user.store';
|
import { user } from '$lib/stores/user.store';
|
||||||
import { oauth } from '$lib/utils';
|
import { oauth } from '$lib/utils';
|
||||||
import { type ApiKeyResponseDto, type SessionResponseDto } from '@immich/sdk';
|
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 {
|
import {
|
||||||
mdiAccountGroupOutline,
|
mdiAccountGroupOutline,
|
||||||
mdiAccountOutline,
|
mdiAccountOutline,
|
||||||
|
|
@ -29,11 +21,21 @@
|
||||||
mdiDownload,
|
mdiDownload,
|
||||||
mdiFeatureSearchOutline,
|
mdiFeatureSearchOutline,
|
||||||
mdiKeyOutline,
|
mdiKeyOutline,
|
||||||
|
mdiLockSmart,
|
||||||
mdiOnepassword,
|
mdiOnepassword,
|
||||||
mdiServerOutline,
|
mdiServerOutline,
|
||||||
mdiTwoFactorAuthentication,
|
mdiTwoFactorAuthentication,
|
||||||
} from '@mdi/js';
|
} 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 {
|
interface Props {
|
||||||
keys?: ApiKeyResponseDto[];
|
keys?: ApiKeyResponseDto[];
|
||||||
|
|
@ -135,6 +137,16 @@
|
||||||
<PartnerSettings user={$user} />
|
<PartnerSettings user={$user} />
|
||||||
</SettingAccordion>
|
</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
|
<SettingAccordion
|
||||||
icon={mdiKeyOutline}
|
icon={mdiKeyOutline}
|
||||||
key="user-purchase-settings"
|
key="user-purchase-settings"
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,17 @@
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { updateUserAdmin, type UserAdminResponseDto } from '@immich/sdk';
|
import { updateUserAdmin, type UserAdminResponseDto } from '@immich/sdk';
|
||||||
import { Button, Modal, ModalBody, ModalFooter } from '@immich/ui';
|
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';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: UserAdminResponseDto;
|
user: UserAdminResponseDto;
|
||||||
canResetPassword?: boolean;
|
canResetPassword?: boolean;
|
||||||
onClose: (
|
onClose: (
|
||||||
data?: { action: 'update'; data: UserAdminResponseDto } | { action: 'resetPassword'; data: string },
|
data?:
|
||||||
|
| { action: 'update'; data: UserAdminResponseDto }
|
||||||
|
| { action: 'resetPassword'; data: string }
|
||||||
|
| { action: 'resetPinCode' },
|
||||||
) => void;
|
) => 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
|
// TODO move password reset server-side
|
||||||
function generatePassword(length: number = 16) {
|
function generatePassword(length: number = 16) {
|
||||||
let generatedPassword = '';
|
let generatedPassword = '';
|
||||||
|
|
@ -151,13 +172,34 @@
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
|
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<div class="flex gap-3 w-full">
|
<div class="w-full">
|
||||||
{#if canResetPassword}
|
<div class="flex gap-3 w-full">
|
||||||
<Button shape="round" color="warning" variant="filled" fullWidth onclick={resetPassword}
|
{#if canResetPassword}
|
||||||
>{$t('reset_password')}</Button
|
<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}
|
</div>
|
||||||
<Button type="submit" shape="round" fullWidth form="edit-user-form">{$t('confirm')}</Button>
|
|
||||||
|
<div class="w-full mt-4">
|
||||||
|
<Button type="submit" shape="round" fullWidth form="edit-user-form">{$t('confirm')}</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,10 @@
|
||||||
await refresh();
|
await refresh();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'resetPinCode': {
|
||||||
|
notificationController.show({ type: NotificationType.Info, message: $t('pin_code_reset_successfully') });
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -137,7 +141,7 @@
|
||||||
{#if !immichUser.deletedAt}
|
{#if !immichUser.deletedAt}
|
||||||
<IconButton
|
<IconButton
|
||||||
shape="round"
|
shape="round"
|
||||||
size="small"
|
size="medium"
|
||||||
icon={mdiPencilOutline}
|
icon={mdiPencilOutline}
|
||||||
title={$t('edit_user')}
|
title={$t('edit_user')}
|
||||||
onclick={() => handleEdit(immichUser)}
|
onclick={() => handleEdit(immichUser)}
|
||||||
|
|
@ -146,7 +150,7 @@
|
||||||
{#if immichUser.id !== $user.id}
|
{#if immichUser.id !== $user.id}
|
||||||
<IconButton
|
<IconButton
|
||||||
shape="round"
|
shape="round"
|
||||||
size="small"
|
size="medium"
|
||||||
icon={mdiTrashCanOutline}
|
icon={mdiTrashCanOutline}
|
||||||
title={$t('delete_user')}
|
title={$t('delete_user')}
|
||||||
onclick={() => handleDelete(immichUser)}
|
onclick={() => handleDelete(immichUser)}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue