feat(web): user profile (#1148)

* fix: allow updateUser for admin account

* feat: update user first/last name

* feat(web): change password
This commit is contained in:
Jason Rasmussen 2022-12-21 09:43:35 -05:00 committed by GitHub
parent 723a7c563f
commit 14db7a09e3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 342 additions and 21 deletions

View file

@ -19,6 +19,7 @@ doc/AssetFileUploadResponseDto.md
doc/AssetResponseDto.md doc/AssetResponseDto.md
doc/AssetTypeEnum.md doc/AssetTypeEnum.md
doc/AuthenticationApi.md doc/AuthenticationApi.md
doc/ChangePasswordDto.md
doc/CheckDuplicateAssetDto.md doc/CheckDuplicateAssetDto.md
doc/CheckDuplicateAssetResponseDto.md doc/CheckDuplicateAssetResponseDto.md
doc/CheckExistingAssetsDto.md doc/CheckExistingAssetsDto.md
@ -114,6 +115,7 @@ lib/model/asset_count_by_user_id_response_dto.dart
lib/model/asset_file_upload_response_dto.dart lib/model/asset_file_upload_response_dto.dart
lib/model/asset_response_dto.dart lib/model/asset_response_dto.dart
lib/model/asset_type_enum.dart lib/model/asset_type_enum.dart
lib/model/change_password_dto.dart
lib/model/check_duplicate_asset_dto.dart lib/model/check_duplicate_asset_dto.dart
lib/model/check_duplicate_asset_response_dto.dart lib/model/check_duplicate_asset_response_dto.dart
lib/model/check_existing_assets_dto.dart lib/model/check_existing_assets_dto.dart
@ -186,6 +188,7 @@ test/asset_file_upload_response_dto_test.dart
test/asset_response_dto_test.dart test/asset_response_dto_test.dart
test/asset_type_enum_test.dart test/asset_type_enum_test.dart
test/authentication_api_test.dart test/authentication_api_test.dart
test/change_password_dto_test.dart
test/check_duplicate_asset_dto_test.dart test/check_duplicate_asset_dto_test.dart
test/check_duplicate_asset_response_dto_test.dart test/check_duplicate_asset_response_dto_test.dart
test/check_existing_assets_dto_test.dart test/check_existing_assets_dto_test.dart

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/doc/ChangePasswordDto.md generated Normal file

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

@ -5,7 +5,9 @@ import { AuthType, IMMICH_AUTH_TYPE_COOKIE } from '../../constants/jwt.constant'
import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator'; import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
import { Authenticated } from '../../decorators/authenticated.decorator'; import { Authenticated } from '../../decorators/authenticated.decorator';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service'; import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { UserResponseDto } from '../user/response-dto/user-response.dto';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { ChangePasswordDto } from './dto/change-password.dto';
import { LoginCredentialDto } from './dto/login-credential.dto'; import { LoginCredentialDto } from './dto/login-credential.dto';
import { SignUpDto } from './dto/sign-up.dto'; import { SignUpDto } from './dto/sign-up.dto';
import { AdminSignupResponseDto } from './response-dto/admin-signup-response.dto'; import { AdminSignupResponseDto } from './response-dto/admin-signup-response.dto';
@ -45,6 +47,13 @@ export class AuthController {
return new ValidateAccessTokenResponseDto(true); return new ValidateAccessTokenResponseDto(true);
} }
@Authenticated()
@ApiBearerAuth()
@Post('change-password')
async changePassword(@GetAuthUser() authUser: AuthUserDto, @Body() dto: ChangePasswordDto): Promise<UserResponseDto> {
return this.authService.changePassword(authUser, dto);
}
@Post('/logout') @Post('/logout')
async logout(@Req() req: Request, @Res({ passthrough: true }) response: Response): Promise<LogoutResponseDto> { async logout(@Req() req: Request, @Res({ passthrough: true }) response: Response): Promise<LogoutResponseDto> {
const authType: AuthType = req.cookies[IMMICH_AUTH_TYPE_COOKIE]; const authType: AuthType = req.cookies[IMMICH_AUTH_TYPE_COOKIE];

View file

@ -1,9 +1,18 @@
import { BadRequestException, Inject, Injectable, InternalServerErrorException, Logger } from '@nestjs/common'; import {
BadRequestException,
Inject,
Injectable,
InternalServerErrorException,
Logger,
UnauthorizedException,
} from '@nestjs/common';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { UserEntity } from '../../../../../libs/database/src/entities/user.entity'; import { UserEntity } from '../../../../../libs/database/src/entities/user.entity';
import { AuthType } from '../../constants/jwt.constant'; import { AuthType } from '../../constants/jwt.constant';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service'; import { ImmichJwtService } from '../../modules/immich-jwt/immich-jwt.service';
import { IUserRepository, USER_REPOSITORY } from '../user/user-repository'; import { IUserRepository, USER_REPOSITORY } from '../user/user-repository';
import { ChangePasswordDto } from './dto/change-password.dto';
import { LoginCredentialDto } from './dto/login-credential.dto'; import { LoginCredentialDto } from './dto/login-credential.dto';
import { SignUpDto } from './dto/sign-up.dto'; import { SignUpDto } from './dto/sign-up.dto';
import { AdminSignupResponseDto, mapAdminSignupResponse } from './response-dto/admin-signup-response.dto'; import { AdminSignupResponseDto, mapAdminSignupResponse } from './response-dto/admin-signup-response.dto';
@ -48,6 +57,23 @@ export class AuthService {
return { successful: true, redirectUri: '/auth/login' }; return { successful: true, redirectUri: '/auth/login' };
} }
public async changePassword(authUser: AuthUserDto, dto: ChangePasswordDto) {
const { password, newPassword } = dto;
const user = await this.userRepository.getByEmail(authUser.email, true);
if (!user) {
throw new UnauthorizedException();
}
const valid = await this.validatePassword(password, user);
if (!valid) {
throw new BadRequestException('Wrong password');
}
user.password = newPassword;
return this.userRepository.update(user.id, user);
}
public async adminSignUp(dto: SignUpDto): Promise<AdminSignupResponseDto> { public async adminSignUp(dto: SignUpDto): Promise<AdminSignupResponseDto> {
const adminUser = await this.userRepository.getAdmin(); const adminUser = await this.userRepository.getAdmin();

View file

@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
export class ChangePasswordDto {
@IsString()
@IsNotEmpty()
@ApiProperty({ example: 'password' })
password!: string;
@IsString()
@IsNotEmpty()
@MinLength(8)
@ApiProperty({ example: 'password' })
newPassword!: string;
}

View file

@ -86,7 +86,7 @@ export class UserRepository implements IUserRepository {
if (user.isAdmin) { if (user.isAdmin) {
const adminUser = await this.userRepository.findOne({ where: { isAdmin: true } }); const adminUser = await this.userRepository.findOne({ where: { isAdmin: true } });
if (adminUser) { if (adminUser && adminUser.id !== id) {
throw new BadRequestException('Admin user exists'); throw new BadRequestException('Admin user exists');
} }

View file

@ -1707,6 +1707,42 @@
] ]
} }
}, },
"/auth/change-password": {
"post": {
"operationId": "changePassword",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ChangePasswordDto"
}
}
}
},
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UserResponseDto"
}
}
}
}
},
"tags": [
"Authentication"
],
"security": [
{
"bearer": []
}
]
}
},
"/auth/logout": { "/auth/logout": {
"post": { "post": {
"operationId": "logout", "operationId": "logout",
@ -3258,6 +3294,23 @@
"authStatus" "authStatus"
] ]
}, },
"ChangePasswordDto": {
"type": "object",
"properties": {
"password": {
"type": "string",
"example": "password"
},
"newPassword": {
"type": "string",
"example": "password"
}
},
"required": [
"password",
"newPassword"
]
},
"LogoutResponseDto": { "LogoutResponseDto": {
"type": "object", "type": "object",
"properties": { "properties": {

View file

@ -4,7 +4,7 @@
* Immich * Immich
* Immich API * Immich API
* *
* The version of the OpenAPI document: 1.38.2 * The version of the OpenAPI document: 1.39.0
* *
* *
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
@ -481,6 +481,25 @@ export const AssetTypeEnum = {
export type AssetTypeEnum = typeof AssetTypeEnum[keyof typeof AssetTypeEnum]; export type AssetTypeEnum = typeof AssetTypeEnum[keyof typeof AssetTypeEnum];
/**
*
* @export
* @interface ChangePasswordDto
*/
export interface ChangePasswordDto {
/**
*
* @type {string}
* @memberof ChangePasswordDto
*/
'password': string;
/**
*
* @type {string}
* @memberof ChangePasswordDto
*/
'newPassword': string;
}
/** /**
* *
* @export * @export
@ -4171,6 +4190,45 @@ export const AuthenticationApiAxiosParamCreator = function (configuration?: Conf
options: localVarRequestOptions, options: localVarRequestOptions,
}; };
}, },
/**
*
* @param {ChangePasswordDto} changePasswordDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
changePassword: async (changePasswordDto: ChangePasswordDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'changePasswordDto' is not null or undefined
assertParamExists('changePassword', 'changePasswordDto', changePasswordDto)
const localVarPath = `/auth/change-password`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(changePasswordDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/** /**
* *
* @param {LoginCredentialDto} loginCredentialDto * @param {LoginCredentialDto} loginCredentialDto
@ -4288,6 +4346,16 @@ export const AuthenticationApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.adminSignUp(signUpDto, options); const localVarAxiosArgs = await localVarAxiosParamCreator.adminSignUp(signUpDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
}, },
/**
*
* @param {ChangePasswordDto} changePasswordDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async changePassword(changePasswordDto: ChangePasswordDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<UserResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.changePassword(changePasswordDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/** /**
* *
* @param {LoginCredentialDto} loginCredentialDto * @param {LoginCredentialDto} loginCredentialDto
@ -4335,6 +4403,15 @@ export const AuthenticationApiFactory = function (configuration?: Configuration,
adminSignUp(signUpDto: SignUpDto, options?: any): AxiosPromise<AdminSignupResponseDto> { adminSignUp(signUpDto: SignUpDto, options?: any): AxiosPromise<AdminSignupResponseDto> {
return localVarFp.adminSignUp(signUpDto, options).then((request) => request(axios, basePath)); return localVarFp.adminSignUp(signUpDto, options).then((request) => request(axios, basePath));
}, },
/**
*
* @param {ChangePasswordDto} changePasswordDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
changePassword(changePasswordDto: ChangePasswordDto, options?: any): AxiosPromise<UserResponseDto> {
return localVarFp.changePassword(changePasswordDto, options).then((request) => request(axios, basePath));
},
/** /**
* *
* @param {LoginCredentialDto} loginCredentialDto * @param {LoginCredentialDto} loginCredentialDto
@ -4381,6 +4458,17 @@ export class AuthenticationApi extends BaseAPI {
return AuthenticationApiFp(this.configuration).adminSignUp(signUpDto, options).then((request) => request(this.axios, this.basePath)); return AuthenticationApiFp(this.configuration).adminSignUp(signUpDto, options).then((request) => request(this.axios, this.basePath));
} }
/**
*
* @param {ChangePasswordDto} changePasswordDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AuthenticationApi
*/
public changePassword(changePasswordDto: ChangePasswordDto, options?: AxiosRequestConfig) {
return AuthenticationApiFp(this.configuration).changePassword(changePasswordDto, options).then((request) => request(this.axios, this.basePath));
}
/** /**
* *
* @param {LoginCredentialDto} loginCredentialDto * @param {LoginCredentialDto} loginCredentialDto

View file

@ -4,7 +4,7 @@
* Immich * Immich
* Immich API * Immich API
* *
* The version of the OpenAPI document: 1.38.2 * The version of the OpenAPI document: 1.39.0
* *
* *
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View file

@ -4,7 +4,7 @@
* Immich * Immich
* Immich API * Immich API
* *
* The version of the OpenAPI document: 1.38.2 * The version of the OpenAPI document: 1.39.0
* *
* *
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View file

@ -4,7 +4,7 @@
* Immich * Immich
* Immich API * Immich API
* *
* The version of the OpenAPI document: 1.38.2 * The version of the OpenAPI document: 1.39.0
* *
* *
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View file

@ -4,7 +4,7 @@
* Immich * Immich
* Immich API * Immich API
* *
* The version of the OpenAPI document: 1.38.2 * The version of the OpenAPI document: 1.39.0
* *
* *
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View file

@ -1,27 +1,154 @@
<script lang="ts"> <script lang="ts">
import { UserResponseDto } from '@api'; import {
notificationController,
NotificationType
} from '$lib/components/shared-components/notification/notification';
import { api, UserResponseDto } from '@api';
import { AxiosError } from 'axios';
import { fade } from 'svelte/transition';
import SettingAccordion from '../admin-page/settings/setting-accordion.svelte'; import SettingAccordion from '../admin-page/settings/setting-accordion.svelte';
import SettingInputField, { import SettingInputField, {
SettingInputFieldType SettingInputFieldType
} from '../admin-page/settings/setting-input-field.svelte'; } from '../admin-page/settings/setting-input-field.svelte';
export let user: UserResponseDto; export let user: UserResponseDto;
const handleSaveProfile = async () => {
try {
const { data } = await api.userApi.updateUser({
id: user.id,
firstName: user.firstName,
lastName: user.lastName
});
Object.assign(user, data);
notificationController.show({
message: 'Saved profile',
type: NotificationType.Info
});
} catch (error) {
console.error('Error [user-profile] [updateProfile]', error);
notificationController.show({
message: 'Unable to save profile',
type: NotificationType.Error
});
}
};
let password = '';
let newPassword = '';
let confirmPassword = '';
const handleChangePassword = async () => {
try {
await api.authenticationApi.changePassword({
password,
newPassword
});
notificationController.show({
message: 'Updated password',
type: NotificationType.Info
});
password = '';
newPassword = '';
confirmPassword = '';
} catch (error: AxiosError | any) {
console.error('Error [user-profile] [changePassword]', error);
notificationController.show({
message: error?.response?.data?.message || 'Unable to change password',
type: NotificationType.Error
});
}
};
</script> </script>
<SettingAccordion title="User profile" subtitle="Manage the user information"> <SettingAccordion title="User Profile" subtitle="View and manage your profile">
<section class="my-4"> <section class="my-4">
<SettingInputField <div in:fade={{ duration: 500 }}>
inputType={SettingInputFieldType.TEXT} <form autocomplete="off" on:submit|preventDefault>
label="First name" <div class="flex flex-col gap-4 ml-4 mt-4">
bind:value={user.firstName} <SettingInputField
required={true} inputType={SettingInputFieldType.TEXT}
/> label="User ID"
bind:value={user.id}
disabled={true}
/>
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.TEXT} inputType={SettingInputFieldType.TEXT}
label="Last name" label="Email"
bind:value={user.lastName} bind:value={user.email}
required={true} disabled={true}
/> />
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="First name"
bind:value={user.firstName}
required={true}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="Last name"
bind:value={user.lastName}
required={true}
/>
<div class="flex justify-end">
<button
type="submit"
on:click={() => handleSaveProfile()}
class="text-sm bg-immich-primary dark:bg-immich-dark-primary hover:bg-immich-primary/75 dark:hover:bg-immich-dark-primary/80 px-4 py-2 text-white dark:text-immich-dark-gray rounded-full shadow-md font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>Save
</button>
</div>
</div>
</form>
</div>
</section>
</SettingAccordion>
<SettingAccordion title="Password" subtitle="Change your password">
<section class="my-4">
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault>
<div class="flex flex-col gap-4 ml-4 mt-4">
<SettingInputField
inputType={SettingInputFieldType.PASSWORD}
label="Password"
bind:value={password}
required={true}
/>
<SettingInputField
inputType={SettingInputFieldType.PASSWORD}
label="New password"
bind:value={newPassword}
required={true}
/>
<SettingInputField
inputType={SettingInputFieldType.PASSWORD}
label="Confirm password"
bind:value={confirmPassword}
required={true}
/>
<div class="flex justify-end">
<button
type="submit"
disabled={!(password && newPassword && newPassword === confirmPassword)}
on:click={() => handleChangePassword()}
class="text-sm bg-immich-primary dark:bg-immich-dark-primary hover:bg-immich-primary/75 dark:hover:bg-immich-dark-primary/80 px-4 py-2 text-white dark:text-immich-dark-gray rounded-full shadow-md font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>Save
</button>
</div>
</div>
</form>
</div>
</section> </section>
</SettingAccordion> </SettingAccordion>