diff --git a/Pyro.Api/Pyro.ApiTests/Clients/IdentityClient.cs b/Pyro.Api/Pyro.ApiTests/Clients/IdentityClient.cs index 8b44e01b..5c23e13d 100644 --- a/Pyro.Api/Pyro.ApiTests/Clients/IdentityClient.cs +++ b/Pyro.Api/Pyro.ApiTests/Clients/IdentityClient.cs @@ -38,6 +38,9 @@ public async Task UnlockUser(string login) public async Task ActivateUser(ActivateUserRequest request) => await Post($"/api/users/activate", request); + public async Task ChangePassword(ChangePasswordRequest request) + => await Post("/api/users/change-password", request); + public async Task?> GetAccessTokens() => await Get>("/api/users/access-tokens"); diff --git a/Pyro.Api/Pyro.ApiTests/Tests/UserTests.cs b/Pyro.Api/Pyro.ApiTests/Tests/UserTests.cs index 623ffd4a..f427b41c 100644 --- a/Pyro.Api/Pyro.ApiTests/Tests/UserTests.cs +++ b/Pyro.Api/Pyro.ApiTests/Tests/UserTests.cs @@ -91,6 +91,36 @@ public async Task CreateGetUpdateUser() }); } + [Test] + public async Task ChangePassword() + { + var login = faker.Internet.Email(); + var createRequest = new CreateUserRequest( + login, + ["Admin"]); + await client.CreateUser(createRequest); + + var message = Api.Smtp.WaitForMessage(x => x.To == login) ?? + throw new InvalidOperationException("The message was not found."); + var token = message.GetToken(); + var password = faker.Random.Hash(); + var activateUserRequest = new ActivateUserRequest(token, password); + await client.ActivateUser(activateUserRequest); + + using var identityClient = new IdentityClient(Api.BaseAddress); + await identityClient.Login(login, password); + + var newPassword = faker.Random.Hash(); + var changePasswordRequest = new ChangePasswordRequest(password, newPassword); + await identityClient.ChangePassword(changePasswordRequest); + await identityClient.Logout(); + + await identityClient.Login(login, newPassword); + var user = identityClient.GetUser(login); + + Assert.That(user, Is.Not.Null); + } + [Test] public async Task CreateGetDeleteAccessToken() { diff --git a/Pyro.Api/Pyro.Contracts/Requests/Identity/ChangePasswordRequest.cs b/Pyro.Api/Pyro.Contracts/Requests/Identity/ChangePasswordRequest.cs new file mode 100644 index 00000000..f97cae43 --- /dev/null +++ b/Pyro.Api/Pyro.Contracts/Requests/Identity/ChangePasswordRequest.cs @@ -0,0 +1,6 @@ +// Copyright (c) Dmytro Kyshchenko. All rights reserved. +// Licensed under the GPL-3.0 license. See LICENSE file in the project root for full license information. + +namespace Pyro.Contracts.Requests.Identity; + +public record ChangePasswordRequest(string OldPassword, string NewPassword); \ No newline at end of file diff --git a/Pyro.Api/Pyro.Domain.Identity.UnitTests/Models/UserTests.cs b/Pyro.Api/Pyro.Domain.Identity.UnitTests/Models/UserTests.cs index 336182d4..c5ffc1d8 100644 --- a/Pyro.Api/Pyro.Domain.Identity.UnitTests/Models/UserTests.cs +++ b/Pyro.Api/Pyro.Domain.Identity.UnitTests/Models/UserTests.cs @@ -270,4 +270,44 @@ public void ActivateUser() Assert.That(user.OneTimePasswords, Is.Empty); }); } + + [Test] + public void ChangeIncorrectPassword() + { + const string oldPassword = "password"; + + var passwordService = new PasswordService(TimeProvider.System); + var (passwordHash, salt) = passwordService.GeneratePasswordHash(oldPassword); + var user = new User + { + Login = "test@localhost.local", + Password = passwordHash, + Salt = salt, + }; + + Assert.Throws(() => user.ChangePassword(passwordService, "incorrect", "newPassword")); + } + + [Test] + public void ChangePassword() + { + const string oldPassword = "password"; + + var passwordService = new PasswordService(TimeProvider.System); + var (passwordHash, salt) = passwordService.GeneratePasswordHash(oldPassword); + var user = new User + { + Login = "test@localhost.local", + Password = passwordHash, + Salt = salt, + }; + + user.ChangePassword(passwordService, oldPassword, "newPassword"); + + Assert.Multiple(() => + { + Assert.That(user.Password, Is.Not.EqualTo(passwordHash)); + Assert.That(user.Salt, Is.Not.EqualTo(salt)); + }); + } } \ No newline at end of file diff --git a/Pyro.Api/Pyro.Domain.Identity/Commands/ChangePassword.cs b/Pyro.Api/Pyro.Domain.Identity/Commands/ChangePassword.cs new file mode 100644 index 00000000..4ca50b0b --- /dev/null +++ b/Pyro.Api/Pyro.Domain.Identity/Commands/ChangePassword.cs @@ -0,0 +1,50 @@ +// Copyright (c) Dmytro Kyshchenko. All rights reserved. +// Licensed under the GPL-3.0 license. See LICENSE file in the project root for full license information. + +using FluentValidation; +using MediatR; +using Pyro.Domain.Shared.CurrentUserProvider; +using Pyro.Domain.Shared.Exceptions; + +namespace Pyro.Domain.Identity.Commands; + +public record ChangePassword(string OldPassword, string NewPassword) : IRequest; + +public class ChangePasswordValidator : AbstractValidator +{ + public ChangePasswordValidator() + { + RuleFor(x => x.OldPassword) + .NotEmpty(); + + RuleFor(x => x.NewPassword) + .NotEmpty() + .MinimumLength(8); + } +} + +public class ChangePasswordHandler : IRequestHandler +{ + private readonly ICurrentUserProvider currentUserProvider; + private readonly IUserRepository userRepository; + private readonly IPasswordService passwordService; + + public ChangePasswordHandler( + ICurrentUserProvider currentUserProvider, + IUserRepository userRepository, + IPasswordService passwordService) + { + this.currentUserProvider = currentUserProvider; + this.userRepository = userRepository; + this.passwordService = passwordService; + } + + public async Task Handle(ChangePassword request, CancellationToken cancellationToken) + { + var currentUser = currentUserProvider.GetCurrentUser(); + var user = await userRepository.GetUserById(currentUser.Id, cancellationToken) ?? + throw new NotFoundException($"The user (Id: {currentUser.Id}) not found"); + + user.ChangePassword(passwordService, request.OldPassword, request.NewPassword); + } +} \ No newline at end of file diff --git a/Pyro.Api/Pyro.Domain.Identity/Models/User.cs b/Pyro.Api/Pyro.Domain.Identity/Models/User.cs index 2c6df8a6..958b9b8f 100644 --- a/Pyro.Api/Pyro.Domain.Identity/Models/User.cs +++ b/Pyro.Api/Pyro.Domain.Identity/Models/User.cs @@ -40,7 +40,7 @@ public IReadOnlyList Password get => password; [MemberNotNull(nameof(password))] - init + set { if (value is null) throw new DomainValidationException("Password cannot be null."); @@ -57,7 +57,7 @@ public IReadOnlyList Salt get => salt; [MemberNotNull(nameof(salt))] - init + set { if (value is null) throw new DomainValidationException("Salt cannot be null."); @@ -143,6 +143,16 @@ public void DeleteAccessToken(string name) accessTokens.Remove(accessToken); } + public void ChangePassword(IPasswordService passwordService, string oldPassword, string newPassword) + { + if (!passwordService.VerifyPassword(oldPassword, password, salt)) + throw new DomainException("The old password is incorrect."); + + var (newPasswordHash, newSalt) = passwordService.GeneratePasswordHash(newPassword); + Password = newPasswordHash; + Salt = newSalt; + } + public void AddOneTimePassword(OneTimePassword oneTimePassword) { if (oneTimePasswords.Any(x => x.Token == oneTimePassword.Token)) diff --git a/Pyro.Api/Pyro/DtoMappings/IdentityMapper.cs b/Pyro.Api/Pyro/DtoMappings/IdentityMapper.cs index f538c2c3..6f425545 100644 --- a/Pyro.Api/Pyro/DtoMappings/IdentityMapper.cs +++ b/Pyro.Api/Pyro/DtoMappings/IdentityMapper.cs @@ -40,6 +40,8 @@ public static partial class IdentityMapper public static partial ActivateUser ToCommand(this ActivateUserRequest request); + public static partial ChangePassword ToCommand(this ChangePasswordRequest request); + [MapProperty([nameof(JwtTokenPair.AccessToken), nameof(Token.Value)], nameof(TokenPairResponse.AccessToken))] [MapProperty([nameof(JwtTokenPair.RefreshToken), nameof(Token.Value)], nameof(TokenPairResponse.RefreshToken))] public static partial TokenPairResponse ToResponse(this JwtTokenPair jwtTokenPair); diff --git a/Pyro.Api/Pyro/Endpoints/IdentityEndpoints.cs b/Pyro.Api/Pyro/Endpoints/IdentityEndpoints.cs index 5a554190..ce5ff9fa 100644 --- a/Pyro.Api/Pyro/Endpoints/IdentityEndpoints.cs +++ b/Pyro.Api/Pyro/Endpoints/IdentityEndpoints.cs @@ -182,6 +182,26 @@ private static IEndpointRouteBuilder MapUserEndpoints(this IEndpointRouteBuilder .WithName("Activate User") .WithOpenApi(); + usersBuilder.MapPost("/change-password", async ( + IMediator mediator, + UnitOfWork unitOfWork, + ChangePasswordRequest request, + CancellationToken cancellationToken) => + { + var command = request.ToCommand(); + await mediator.Send(command, cancellationToken); + await unitOfWork.SaveChangesAsync(cancellationToken); + + return Results.Ok(); + }) + .Produces(200) + .ProducesValidationProblem() + .Produces(401) + .Produces(403) + .ProducesProblem(500) + .WithName("Change Password") + .WithOpenApi(); + var accessTokenBuilder = usersBuilder.MapGroup("/access-tokens") .WithTags("Access Tokens"); diff --git a/Pyro.Api/Pyro/Extensions/SpaExtensions.cs b/Pyro.Api/Pyro/Extensions/SpaExtensions.cs index da4fa760..478756ee 100644 --- a/Pyro.Api/Pyro/Extensions/SpaExtensions.cs +++ b/Pyro.Api/Pyro/Extensions/SpaExtensions.cs @@ -7,23 +7,26 @@ namespace Pyro.Extensions; internal static class SpaExtensions { - public static IServiceCollection AddSpa(this IServiceCollection services) + public static IHostApplicationBuilder AddSpa(this IHostApplicationBuilder builder) { - var fileServerOptions = new FileServerOptions + if (!builder.Environment.IsDevelopment()) { - EnableDefaultFiles = true, - EnableDirectoryBrowsing = false, - FileProvider = new PhysicalFileProvider(Path.Combine(Directory.GetCurrentDirectory(), "wwwroot")), - DefaultFilesOptions = + var fileServerOptions = new FileServerOptions { - DefaultFileNames = ["index.html"], - }, - }; - services.AddSingleton(fileServerOptions); - services.AddSingleton(fileServerOptions.DefaultFilesOptions); - services.AddSingleton(fileServerOptions.StaticFileOptions); - - return services; + EnableDefaultFiles = true, + EnableDirectoryBrowsing = false, + FileProvider = new PhysicalFileProvider(Path.Combine(Directory.GetCurrentDirectory(), "wwwroot")), + DefaultFilesOptions = + { + DefaultFileNames = ["index.html"], + }, + }; + builder.Services.AddSingleton(fileServerOptions); + builder.Services.AddSingleton(fileServerOptions.DefaultFilesOptions); + builder.Services.AddSingleton(fileServerOptions.StaticFileOptions); + } + + return builder; } public static IApplicationBuilder UseSpa(this IApplicationBuilder app) diff --git a/Pyro.Api/Pyro/Program.cs b/Pyro.Api/Pyro/Program.cs index c03e5c2e..dcc7821e 100644 --- a/Pyro.Api/Pyro/Program.cs +++ b/Pyro.Api/Pyro/Program.cs @@ -55,7 +55,7 @@ .AddOpenBehavior(typeof(ValidatorPipeline<,>))); builder.Services.AddAuth(); -builder.Services.AddSpa(); +builder.AddSpa(); var app = builder.Build(); diff --git a/Pyro.UI/src/app/actions/users.actions.ts b/Pyro.UI/src/app/actions/users.actions.ts index 3663a30e..9deb724a 100644 --- a/Pyro.UI/src/app/actions/users.actions.ts +++ b/Pyro.UI/src/app/actions/users.actions.ts @@ -51,3 +51,10 @@ export const updateUserFailure = createAction('[Users] Update User Failure'); export const loadUser = createAction('[Users] Load User', props<{ login: string }>()); export const loadUserSuccess = createAction('[Users] Load User Success', props<{ user: User }>()); export const loadUserFailure = createAction('[Users] Load User Failure'); + +export const changePassword = createAction( + '[Users] Change Password', + props<{ oldPassword: string; newPassword: string }>(), +); +export const changePasswordSuccess = createAction('[Users] Change Password Success'); +export const changePasswordFailure = createAction('[Users] Change Password Failure'); diff --git a/Pyro.UI/src/app/app.routes.ts b/Pyro.UI/src/app/app.routes.ts index 7900e44b..2f23452a 100644 --- a/Pyro.UI/src/app/app.routes.ts +++ b/Pyro.UI/src/app/app.routes.ts @@ -29,6 +29,7 @@ import { import { AccessTokenListComponent, AccessTokenNewComponent, + ChangePasswordComponent, ProfileEditComponent, SettingsComponent, } from './components/settings'; @@ -161,7 +162,11 @@ export const routes: Routes = [ path: 'access-tokens', component: AccessTokenListComponent, canActivate: [authGuard()], - children: [], + }, + { + path: 'change-password', + component: ChangePasswordComponent, + canActivate: [authGuard()], }, { path: '', redirectTo: 'profile', pathMatch: 'full' }, ], diff --git a/Pyro.UI/src/app/components/settings/change-password/change-password.component.css b/Pyro.UI/src/app/components/settings/change-password/change-password.component.css new file mode 100644 index 00000000..e69de29b diff --git a/Pyro.UI/src/app/components/settings/change-password/change-password.component.html b/Pyro.UI/src/app/components/settings/change-password/change-password.component.html new file mode 100644 index 00000000..616e346b --- /dev/null +++ b/Pyro.UI/src/app/components/settings/change-password/change-password.component.html @@ -0,0 +1,48 @@ +
+
+ + +
+ + + +
+ +
+ + + +
+ +
+ + + +
+ +
+ +
+ +
diff --git a/Pyro.UI/src/app/components/settings/change-password/change-password.component.ts b/Pyro.UI/src/app/components/settings/change-password/change-password.component.ts new file mode 100644 index 00000000..edf0a014 --- /dev/null +++ b/Pyro.UI/src/app/components/settings/change-password/change-password.component.ts @@ -0,0 +1,54 @@ +import { changePassword } from '@actions/users.actions'; +import { Component } from '@angular/core'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { ValidationSummaryComponent, Validators } from '@controls/validation-summary'; +import { Store } from '@ngrx/store'; +import { AppState } from '@states/app.state'; +import { ButtonModule } from 'primeng/button'; +import { InputTextModule } from 'primeng/inputtext'; + +@Component({ + selector: 'change-password', + standalone: true, + imports: [ButtonModule, InputTextModule, ReactiveFormsModule, ValidationSummaryComponent], + templateUrl: './change-password.component.html', + styleUrl: './change-password.component.css', +}) +export class ChangePasswordComponent { + public form = this.formBuilder.group( + { + oldPassword: ['', [Validators.required('Old Password')]], + newPassword: [ + '', + [Validators.required('New Password'), Validators.minLength('New Password', 8)], + ], + confirmPassword: [ + '', + [Validators.required('Confirm Password'), Validators.minLength('New Password', 8)], + ], + }, + { + validators: Validators.sameAs( + ['newPassword', 'New Password'], + ['confirmPassword', 'Confirm Password'], + ), + }, + ); + + public constructor( + private readonly formBuilder: FormBuilder, + private readonly store: Store, + ) {} + + public onSubmit(): void { + if (this.form.invalid) { + return; + } + + let oldPassword = this.form.value.oldPassword!; + let newPassword = this.form.value.newPassword!; + this.store.dispatch(changePassword({ oldPassword, newPassword })); + + this.form.reset(); + } +} diff --git a/Pyro.UI/src/app/components/settings/index.ts b/Pyro.UI/src/app/components/settings/index.ts index 89c9b00e..5287adbf 100644 --- a/Pyro.UI/src/app/components/settings/index.ts +++ b/Pyro.UI/src/app/components/settings/index.ts @@ -1,4 +1,5 @@ export { AccessTokenListComponent } from './access-token-list/access-token-list.component'; export { AccessTokenNewComponent } from './access-token-new/access-token-new.component'; +export { ChangePasswordComponent } from './change-password/change-password.component'; export { ProfileEditComponent } from './profile-edit/profile-edit.component'; export { SettingsComponent } from './settings.component'; diff --git a/Pyro.UI/src/app/components/settings/settings.component.html b/Pyro.UI/src/app/components/settings/settings.component.html index 9ebfa83e..e6881ef2 100644 --- a/Pyro.UI/src/app/components/settings/settings.component.html +++ b/Pyro.UI/src/app/components/settings/settings.component.html @@ -4,10 +4,10 @@

Settings

-
+
-
+
diff --git a/Pyro.UI/src/app/components/settings/settings.component.ts b/Pyro.UI/src/app/components/settings/settings.component.ts index ecee557a..530b662c 100644 --- a/Pyro.UI/src/app/components/settings/settings.component.ts +++ b/Pyro.UI/src/app/components/settings/settings.component.ts @@ -13,5 +13,6 @@ export class SettingsComponent { public readonly menuItems: MenuItem[] = [ { label: 'Profile', icon: 'pi pi-cog', routerLink: ['profile'] }, { label: 'Access Tokens', icon: 'pi pi-key', routerLink: ['access-tokens'] }, + { label: 'Change Password', icon: 'pi pi-key', routerLink: ['change-password'] }, ]; } diff --git a/Pyro.UI/src/app/controls/validation-summary/validators.ts b/Pyro.UI/src/app/controls/validation-summary/validators.ts index e09423ce..722a89e4 100644 --- a/Pyro.UI/src/app/controls/validation-summary/validators.ts +++ b/Pyro.UI/src/app/controls/validation-summary/validators.ts @@ -118,8 +118,8 @@ export class Validators { controlName2: [string, string], ): ValidatorFn { return form => { - let control1 = form.get(controlName1); - let control2 = form.get(controlName2); + let control1 = form.get(controlName1[0]); + let control2 = form.get(controlName2[0]); if (!control1 || !control2) { return null; @@ -129,7 +129,7 @@ export class Validators { return null; } - return { sameAs: `The '${controlName1}' and the '${controlName2}' fields must match` }; + return { sameAs: `The '${controlName1[1]}' and the '${controlName2[1]}' fields must match` }; }; } diff --git a/Pyro.UI/src/app/effects/errors.effects.ts b/Pyro.UI/src/app/effects/errors.effects.ts index ff9f9225..731e4b62 100644 --- a/Pyro.UI/src/app/effects/errors.effects.ts +++ b/Pyro.UI/src/app/effects/errors.effects.ts @@ -37,6 +37,7 @@ import { } from '@actions/repository.actions'; import { loadRolesFailure } from '@actions/roles.actions'; import { + changePasswordFailure, createUserFailure, loadUserFailure, loadUsersFailure, @@ -91,6 +92,7 @@ export const errorsEffect = createEffect( createIssueFailure, editIssueFailure, loadIssueFailure, + changePasswordFailure, ), tap(() => messageService.add({ diff --git a/Pyro.UI/src/app/effects/users.effects.ts b/Pyro.UI/src/app/effects/users.effects.ts index 03f72b84..53f1acf5 100644 --- a/Pyro.UI/src/app/effects/users.effects.ts +++ b/Pyro.UI/src/app/effects/users.effects.ts @@ -1,6 +1,9 @@ import { notifyAction } from '@actions/notification.actions'; import { loadRoles } from '@actions/roles.actions'; import { + changePassword, + changePasswordFailure, + changePasswordSuccess, createUser, createUserFailure, createUserSuccess, @@ -29,7 +32,7 @@ import { Router } from '@angular/router'; import { Actions, createEffect, ofType } from '@ngrx/effects'; import { concatLatestFrom } from '@ngrx/operators'; import { Store } from '@ngrx/store'; -import { UserService } from '@services/user.service'; +import { ChangePassword, UserService } from '@services/user.service'; import { AppState } from '@states/app.state'; import { selectCurrentPage } from '@states/paged.state'; import { selectUsers } from '@states/users.state'; @@ -206,3 +209,35 @@ export const loadUserEffect = createEffect( }, { functional: true }, ); + +export const changePasswordEffect = createEffect( + (actions$ = inject(Actions), userService = inject(UserService)) => { + return actions$.pipe( + ofType(changePassword), + switchMap(({ oldPassword, newPassword }) => { + let command: ChangePassword = { oldPassword, newPassword }; + + return userService.changePassword(command); + }), + map(() => changePasswordSuccess()), + catchError(() => of(changePasswordFailure())), + ); + }, + { functional: true }, +); + +export const changePasswordSuccessEffect = createEffect( + (actions$ = inject(Actions)) => { + return actions$.pipe( + ofType(changePasswordSuccess), + map(() => + notifyAction({ + title: 'Password changed', + message: 'Password has been changed', + severity: 'success', + }), + ), + ); + }, + { functional: true }, +); diff --git a/Pyro.UI/src/app/reducers/auth.reducers.ts b/Pyro.UI/src/app/reducers/auth.reducers.ts index d8227661..ecf48b3a 100644 --- a/Pyro.UI/src/app/reducers/auth.reducers.ts +++ b/Pyro.UI/src/app/reducers/auth.reducers.ts @@ -1,4 +1,9 @@ -import { loggedInAction, loggedOutAction, refreshedAction } from '@actions/auth.actions'; +import { + loggedInAction, + loggedOutAction, + logoutFailedAction, + refreshedAction, +} from '@actions/auth.actions'; import { CurrentUser } from '@models/current-user'; import { ActionReducer, createReducer, INIT, on } from '@ngrx/store'; import { AppState } from '@states/app.state'; @@ -19,6 +24,7 @@ export const authReducer = createReducer( ), on( loggedOutAction, + logoutFailedAction, (state): AuthState => ({ ...state, @@ -68,7 +74,10 @@ export function saveStateReducer(reducer: ActionReducer): ActionReduce } } else if (action.type === loggedInAction.type || action.type === refreshedAction.type) { localStorage.setItem(localStorageKey, JSON.stringify(result.auth)); - } else if (action.type === loggedOutAction.type) { + } else if ( + action.type === loggedOutAction.type || + action.type === logoutFailedAction.type + ) { localStorage.removeItem(localStorageKey); } diff --git a/Pyro.UI/src/app/services/auth.interceptor.ts b/Pyro.UI/src/app/services/auth.interceptor.ts index efdb2bdd..478d2a22 100644 --- a/Pyro.UI/src/app/services/auth.interceptor.ts +++ b/Pyro.UI/src/app/services/auth.interceptor.ts @@ -1,18 +1,16 @@ -import { refreshAction, refreshedAction } from '@actions/auth.actions'; +import { refreshAction, refreshedAction, refreshFailedAction } from '@actions/auth.actions'; import { HttpContextToken, HttpInterceptorFn } from '@angular/common/http'; import { inject } from '@angular/core'; -import { Router } from '@angular/router'; import { Actions, ofType } from '@ngrx/effects'; import { Store } from '@ngrx/store'; import { AppState } from '@states/app.state'; import { selectCurrentUser } from '@states/auth.state'; -import { catchError, map, of, switchMap, take, throwError } from 'rxjs'; +import { map, of, race, switchMap, take } from 'rxjs'; import { Endpoints } from '../endpoints'; export const ALLOW_ANONYMOUS = new HttpContextToken(() => false); export const authInterceptor: HttpInterceptorFn = (req, next) => { - let router = inject(Router); let store: Store = inject(Store); let actions$ = inject(Actions); @@ -30,15 +28,15 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => { // TODO: catch errors // TODO: handle multiple requests - return actions$.pipe( - ofType(refreshedAction), - take(1), - map(({ currentUser }) => currentUser), - catchError(error => { - router.navigate(['/login']); - - return throwError(() => error); - }), + return race( + actions$.pipe( + ofType(refreshedAction), + map(({ currentUser }) => currentUser), + ), + actions$.pipe( + ofType(refreshFailedAction), + map(() => null), + ), ); } diff --git a/Pyro.UI/src/app/services/user.service.ts b/Pyro.UI/src/app/services/user.service.ts index 39e8d437..624c5644 100644 --- a/Pyro.UI/src/app/services/user.service.ts +++ b/Pyro.UI/src/app/services/user.service.ts @@ -63,6 +63,10 @@ export class UserService { public activate(command: ActivateUser): Observable { return this.httpClient.post(`${Endpoints.Users}/activate`, command); } + + public changePassword(command: ChangePassword): Observable { + return this.httpClient.post(`${Endpoints.Users}/change-password`, command); + } } export interface Permission { @@ -100,3 +104,8 @@ export interface ActivateUser { get token(): string; get password(): string; } + +export interface ChangePassword { + get oldPassword(): string; + get newPassword(): string; +}