diff --git a/API/API.csproj b/API/API.csproj index 69978c55f..86e62986f 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -14,6 +14,7 @@ bin\Debug\API.xml + 1701;1702;1591 diff --git a/API/Constants/PolicyConstants.cs b/API/Constants/PolicyConstants.cs index cbcd05872..a57b279ec 100644 --- a/API/Constants/PolicyConstants.cs +++ b/API/Constants/PolicyConstants.cs @@ -21,9 +21,7 @@ namespace API.Constants /// public const string DownloadRole = "Download"; - public static readonly ImmutableArray ValidRoles = new ImmutableArray() - { - AdminRole, PlebRole, DownloadRole - }; + public static readonly ImmutableArray ValidRoles = + ImmutableArray.Create(AdminRole, PlebRole, DownloadRole); } } diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index 0905fe2f2..57dcf6b38 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -394,6 +394,7 @@ namespace API.Controllers } + [Authorize(Policy = "RequireAdminRole")] [HttpPost("invite")] public async Task> InviteUser(InviteUserDto dto) @@ -439,7 +440,7 @@ namespace API.Controllers var roleResult = await _userManager.AddToRoleAsync(user, role); if (!roleResult.Succeeded) return - BadRequest(roleResult.Errors); // TODO: Combine all these return BadRequest into one big thing + BadRequest(roleResult.Errors); } // Grant access to libraries @@ -482,7 +483,7 @@ namespace API.Controllers } return Ok(emailLink); } - catch (Exception ex) + catch (Exception) { _unitOfWork.UserRepository.Delete(user); await _unitOfWork.CommitAsync(); @@ -533,6 +534,56 @@ namespace API.Controllers }; } + [AllowAnonymous] + [HttpPost("confirm-password-reset")] + public async Task> ConfirmForgotPassword(ConfirmPasswordResetDto dto) + { + var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); + if (user == null) + { + return BadRequest("Invalid Details"); + } + + var result = await _userManager.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, "ResetPassword", dto.Token); + if (!result) return BadRequest("Unable to reset password"); + + var errors = await _accountService.ChangeUserPassword(user, dto.Password); + return errors.Any() ? BadRequest(errors) : BadRequest("Unable to reset password"); + } + + + /// + /// Will send user a link to update their password to their email or prompt them if not accessible + /// + /// + /// + [AllowAnonymous] + [HttpPost("forgot-password")] + public async Task> ForgotPassword([FromQuery] string email) + { + var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(email); + if (user == null) + { + _logger.LogError("There are no users with email: {Email} but user is requesting password reset", email); + return Ok("An email will be sent to the email if it exists in our database"); + } + + var emailLink = GenerateEmailLink(await _userManager.GeneratePasswordResetTokenAsync(user), "confirm-reset-password", user.Email); + _logger.LogInformation("[Forgot Password]: Email Link: {Link}", emailLink); + var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString(); + if (await _emailService.CheckIfAccessible(host)) + { + await _emailService.SendPasswordResetEmail(new PasswordResetEmailDto() + { + EmailAddress = user.Email, + ServerConfirmationLink = emailLink + }); + return Ok("Email sent"); + } + + return Ok("Your server is not accessible. The Link to reset your password is in the logs."); + } + [AllowAnonymous] [HttpPost("confirm-migration-email")] public async Task> ConfirmMigrationEmail(ConfirmMigrationEmailDto dto) @@ -570,10 +621,7 @@ namespace API.Controllers "This user needs to migrate. Have them log out and login to trigger a migration flow"); if (user.EmailConfirmed) return BadRequest("User already confirmed"); - var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); - var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString(); - var emailLink = - $"{Request.Scheme}://{host}{Request.PathBase}/registration/confirm-migration-email?token={HttpUtility.UrlEncode(token)}&email={HttpUtility.UrlEncode(user.Email)}"; + var emailLink = GenerateEmailLink(await _userManager.GenerateEmailConfirmationTokenAsync(user), "confirm-migration-email", user.Email); _logger.LogInformation("[Email Migration]: Email Link: {Link}", emailLink); await _emailService.SendMigrationEmail(new EmailMigrationDto() { @@ -586,6 +634,14 @@ namespace API.Controllers return Ok(emailLink); } + private string GenerateEmailLink(string token, string routePart, string email) + { + var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString(); + var emailLink = + $"{Request.Scheme}://{host}{Request.PathBase}/registration/{routePart}?token={HttpUtility.UrlEncode(token)}&email={HttpUtility.UrlEncode(email)}"; + return emailLink; + } + /// /// This is similar to invite. Essentially we authenticate the user's password then go through invite email flow /// @@ -622,9 +678,7 @@ namespace API.Controllers _unitOfWork.UserRepository.Update(user); await _unitOfWork.CommitAsync(); - var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString(); - var emailLink = - $"{Request.Scheme}://{host}{Request.PathBase}/registration/confirm-migration-email?token={HttpUtility.UrlEncode(token)}&email={HttpUtility.UrlEncode(dto.Email)}"; + var emailLink = GenerateEmailLink(await _userManager.GenerateEmailConfirmationTokenAsync(user), "confirm-migration-email", user.Email); _logger.LogInformation("[Email Migration]: Email Link: {Link}", emailLink); if (dto.SendEmail) { diff --git a/API/DTOs/Account/ConfirmPasswordResetDto.cs b/API/DTOs/Account/ConfirmPasswordResetDto.cs new file mode 100644 index 000000000..603508ac4 --- /dev/null +++ b/API/DTOs/Account/ConfirmPasswordResetDto.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace API.DTOs.Account; + +public class ConfirmPasswordResetDto +{ + [Required] + public string Email { get; set; } + [Required] + public string Token { get; set; } + [Required] + [StringLength(32, MinimumLength = 6)] + public string Password { get; set; } +} diff --git a/API/DTOs/Email/PasswordResetEmailDto.cs b/API/DTOs/Email/PasswordResetEmailDto.cs new file mode 100644 index 000000000..c52060583 --- /dev/null +++ b/API/DTOs/Email/PasswordResetEmailDto.cs @@ -0,0 +1,7 @@ +namespace API.DTOs.Email; + +public class PasswordResetEmailDto +{ + public string EmailAddress { get; init; } + public string ServerConfirmationLink { get; init; } +} diff --git a/API/DTOs/RegisterDto.cs b/API/DTOs/RegisterDto.cs index 33dc62212..95814b88f 100644 --- a/API/DTOs/RegisterDto.cs +++ b/API/DTOs/RegisterDto.cs @@ -11,6 +11,5 @@ namespace API.DTOs [Required] [StringLength(32, MinimumLength = 6)] public string Password { get; set; } - public bool IsAdmin { get; init; } } } diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index b4e8a97f7..120acfc53 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -211,7 +211,7 @@ public class UserRepository : IUserRepository public async Task GetUserByEmailAsync(string email) { - return await _context.AppUser.SingleOrDefaultAsync(u => u.Email.Equals(email)); + return await _context.AppUser.SingleOrDefaultAsync(u => u.Email.ToLower().Equals(email.ToLower())); } public async Task> GetAdminUsersAsync() diff --git a/API/Services/EmailService.cs b/API/Services/EmailService.cs index 4e2ba162e..a9412671c 100644 --- a/API/Services/EmailService.cs +++ b/API/Services/EmailService.cs @@ -15,6 +15,7 @@ public interface IEmailService Task SendConfirmationEmail(ConfirmationEmailDto data); Task CheckIfAccessible(string host); Task SendMigrationEmail(EmailMigrationDto data); + Task SendPasswordResetEmail(PasswordResetEmailDto data); } public class EmailService : IEmailService @@ -50,6 +51,11 @@ public class EmailService : IEmailService await SendEmailWithPost(ApiUrl + "/api/email/email-migration", data); } + public async Task SendPasswordResetEmail(PasswordResetEmailDto data) + { + await SendEmailWithPost(ApiUrl + "/api/email/email-password-reset", data); + } + private static async Task SendEmailWithGet(string url) { try diff --git a/API/Services/ReaderService.cs b/API/Services/ReaderService.cs index 847df6004..05c7860f2 100644 --- a/API/Services/ReaderService.cs +++ b/API/Services/ReaderService.cs @@ -325,12 +325,8 @@ public class ReaderService : IReaderService if (volume == null) return nonSpecialChapters.First(); var chapters = volume.Chapters.OrderBy(c => float.Parse(c.Number)).ToList(); - foreach (var chapter in chapters.Where(chapter => chapter.PagesRead < chapter.Pages)) - { - return chapter; - } - return chapters.First(); + return chapters.FirstOrDefault(chapter => chapter.PagesRead < chapter.Pages) ?? chapters.First(); } diff --git a/API/Services/Tasks/CleanupService.cs b/API/Services/Tasks/CleanupService.cs index 6f17f2917..19583b979 100644 --- a/API/Services/Tasks/CleanupService.cs +++ b/API/Services/Tasks/CleanupService.cs @@ -183,7 +183,7 @@ namespace API.Services.Tasks b.FileName))); - var filesToDelete = allBookmarkFiles.ToList().Except(bookmarks).ToList(); + var filesToDelete = allBookmarkFiles.AsEnumerable().Except(bookmarks).ToList(); _logger.LogDebug("[Bookmarks] Bookmark cleanup wants to delete {Count} files", filesToDelete.Count); if (filesToDelete.Count == 0) return; diff --git a/UI/Web/src/app/_models/member.ts b/UI/Web/src/app/_models/member.ts index e1ef5ea77..874dba535 100644 --- a/UI/Web/src/app/_models/member.ts +++ b/UI/Web/src/app/_models/member.ts @@ -6,7 +6,7 @@ export interface Member { email: string; lastActive: string; // datetime created: string; // datetime - isAdmin: boolean; + //isAdmin: boolean; roles: string[]; libraries: Library[]; } \ No newline at end of file diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index e19bcacf4..c78eaaec6 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -130,6 +130,14 @@ export class AccountService implements OnDestroy { return JSON.parse(atob(token.split('.')[1])); } + requestResetPasswordEmail(email: string) { + return this.httpClient.post(this.baseUrl + 'account/forgot-password?email=' + encodeURIComponent(email), {}, {responseType: 'text' as 'json'}); + } + + confirmResetPasswordEmail(model: {email: string, token: string, password: string}) { + return this.httpClient.post(this.baseUrl + 'account/confirm-password-reset', model); + } + resetPassword(username: string, password: string) { return this.httpClient.post(this.baseUrl + 'account/reset-password', {username, password}, {responseType: 'json' as 'text'}); } diff --git a/UI/Web/src/app/_services/member.service.ts b/UI/Web/src/app/_services/member.service.ts index 1c7820e8a..5ba08dca4 100644 --- a/UI/Web/src/app/_services/member.service.ts +++ b/UI/Web/src/app/_services/member.service.ts @@ -25,7 +25,7 @@ export class MemberService { } deleteMember(username: string) { - return this.httpClient.delete(this.baseUrl + 'users/delete-user?username=' + username); + return this.httpClient.delete(this.baseUrl + 'users/delete-user?username=' + encodeURIComponent(username)); } hasLibraryAccess(libraryId: number) { diff --git a/UI/Web/src/app/admin/manage-users/manage-users.component.html b/UI/Web/src/app/admin/manage-users/manage-users.component.html index 76cdd8747..4a591b850 100644 --- a/UI/Web/src/app/admin/manage-users/manage-users.component.html +++ b/UI/Web/src/app/admin/manage-users/manage-users.component.html @@ -56,7 +56,7 @@ {{member.lastActive | date: 'short'}} -
Sharing: {{formatLibraries(member)}}
+
Sharing: {{formatLibraries(member)}}
Roles: None diff --git a/UI/Web/src/app/admin/manage-users/manage-users.component.ts b/UI/Web/src/app/admin/manage-users/manage-users.component.ts index 1dc25ce53..bfb534670 100644 --- a/UI/Web/src/app/admin/manage-users/manage-users.component.ts +++ b/UI/Web/src/app/admin/manage-users/manage-users.component.ts @@ -167,4 +167,5 @@ export class ManageUsersComponent implements OnInit, OnDestroy { getRoles(member: Member) { return member.roles.filter(item => item != 'Pleb'); } + } diff --git a/UI/Web/src/app/registration/confirm-reset-password/confirm-reset-password.component.html b/UI/Web/src/app/registration/confirm-reset-password/confirm-reset-password.component.html new file mode 100644 index 000000000..9046a1fbe --- /dev/null +++ b/UI/Web/src/app/registration/confirm-reset-password/confirm-reset-password.component.html @@ -0,0 +1,28 @@ + +

Password Reset

+ +

Enter the email of your account. We will send you an email

+
+
+   + + Password must be between 6 and 32 characters in length + + + +
+
+ This field is required +
+
+ Password must be between 6 and 32 characters in length +
+
+
+ +
+ +
+
+
+
diff --git a/UI/Web/src/app/registration/confirm-reset-password/confirm-reset-password.component.scss b/UI/Web/src/app/registration/confirm-reset-password/confirm-reset-password.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/UI/Web/src/app/registration/confirm-reset-password/confirm-reset-password.component.ts b/UI/Web/src/app/registration/confirm-reset-password/confirm-reset-password.component.ts new file mode 100644 index 000000000..2fdcbc910 --- /dev/null +++ b/UI/Web/src/app/registration/confirm-reset-password/confirm-reset-password.component.ts @@ -0,0 +1,50 @@ +import { Component, OnInit } from '@angular/core'; +import { FormGroup, FormControl, Validators } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ToastrService } from 'ngx-toastr'; +import { AccountService } from 'src/app/_services/account.service'; + +@Component({ + selector: 'app-confirm-reset-password', + templateUrl: './confirm-reset-password.component.html', + styleUrls: ['./confirm-reset-password.component.scss'] +}) +export class ConfirmResetPasswordComponent implements OnInit { + + token: string = ''; + registerForm: FormGroup = new FormGroup({ + email: new FormControl('', [Validators.required, Validators.email]), + password: new FormControl('', [Validators.required, Validators.maxLength(32), Validators.minLength(6)]), + }); + + constructor(private route: ActivatedRoute, private router: Router, private accountService: AccountService, private toastr: ToastrService) { + const token = this.route.snapshot.queryParamMap.get('token'); + const email = this.route.snapshot.queryParamMap.get('email'); + if (token == undefined || token === '' || token === null) { + // This is not a valid url, redirect to login + this.toastr.error('Invalid reset password url'); + this.router.navigateByUrl('login'); + return; + } + + this.token = token; + this.registerForm.get('email')?.setValue(email); + + } + + ngOnInit(): void { + } + + submit() { + const model = this.registerForm.getRawValue(); + model.token = this.token; + this.accountService.confirmResetPasswordEmail(model).subscribe(() => { + this.toastr.success("Password reset"); + this.router.navigateByUrl('login'); + }, err => { + console.log(err); + }); + } + + +} diff --git a/UI/Web/src/app/registration/register/register.component.html b/UI/Web/src/app/registration/register/register.component.html index d6e4c987d..c87826e68 100644 --- a/UI/Web/src/app/registration/register/register.component.html +++ b/UI/Web/src/app/registration/register/register.component.html @@ -1,10 +1,3 @@ -

Register

diff --git a/UI/Web/src/app/registration/register/register.component.ts b/UI/Web/src/app/registration/register/register.component.ts index 9cf3f4443..76ac3d87d 100644 --- a/UI/Web/src/app/registration/register/register.component.ts +++ b/UI/Web/src/app/registration/register/register.component.ts @@ -38,8 +38,6 @@ export class RegisterComponent implements OnInit { this.accountService.register(model).subscribe((user) => { this.toastr.success('Account registration complete'); this.router.navigateByUrl('login'); - }, err => { - // TODO: Handle errors }); } diff --git a/UI/Web/src/app/registration/registration.module.ts b/UI/Web/src/app/registration/registration.module.ts index 1e1f89c38..873c512f7 100644 --- a/UI/Web/src/app/registration/registration.module.ts +++ b/UI/Web/src/app/registration/registration.module.ts @@ -8,6 +8,8 @@ import { SplashContainerComponent } from './splash-container/splash-container.co import { RegisterComponent } from './register/register.component'; import { AddEmailToAccountMigrationModalComponent } from './add-email-to-account-migration-modal/add-email-to-account-migration-modal.component'; import { ConfirmMigrationEmailComponent } from './confirm-migration-email/confirm-migration-email.component'; +import { ResetPasswordComponent } from './reset-password/reset-password.component'; +import { ConfirmResetPasswordComponent } from './confirm-reset-password/confirm-reset-password.component'; @@ -17,7 +19,9 @@ import { ConfirmMigrationEmailComponent } from './confirm-migration-email/confir SplashContainerComponent, RegisterComponent, AddEmailToAccountMigrationModalComponent, - ConfirmMigrationEmailComponent + ConfirmMigrationEmailComponent, + ResetPasswordComponent, + ConfirmResetPasswordComponent ], imports: [ CommonModule, diff --git a/UI/Web/src/app/registration/registration.router.module.ts b/UI/Web/src/app/registration/registration.router.module.ts index 688b43d70..f87951c25 100644 --- a/UI/Web/src/app/registration/registration.router.module.ts +++ b/UI/Web/src/app/registration/registration.router.module.ts @@ -2,7 +2,9 @@ import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; import { ConfirmEmailComponent } from './confirm-email/confirm-email.component'; import { ConfirmMigrationEmailComponent } from './confirm-migration-email/confirm-migration-email.component'; +import { ConfirmResetPasswordComponent } from './confirm-reset-password/confirm-reset-password.component'; import { RegisterComponent } from './register/register.component'; +import { ResetPasswordComponent } from './reset-password/reset-password.component'; const routes: Routes = [ { @@ -16,6 +18,14 @@ const routes: Routes = [ { path: 'register', component: RegisterComponent, + }, + { + path: 'reset-password', + component: ResetPasswordComponent + }, + { + path: 'confirm-reset-password', + component: ConfirmResetPasswordComponent } ]; diff --git a/UI/Web/src/app/registration/reset-password/reset-password.component.html b/UI/Web/src/app/registration/reset-password/reset-password.component.html new file mode 100644 index 000000000..ed3eaedc8 --- /dev/null +++ b/UI/Web/src/app/registration/reset-password/reset-password.component.html @@ -0,0 +1,24 @@ + +

Password Reset

+ +

Enter the email of your account. We will send you an email

+
+
+ + +
+
+ This field is required +
+
+ This must be a valid email address +
+
+
+ +
+ +
+
+
+
diff --git a/UI/Web/src/app/registration/reset-password/reset-password.component.scss b/UI/Web/src/app/registration/reset-password/reset-password.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/UI/Web/src/app/registration/reset-password/reset-password.component.ts b/UI/Web/src/app/registration/reset-password/reset-password.component.ts new file mode 100644 index 000000000..4080b4f63 --- /dev/null +++ b/UI/Web/src/app/registration/reset-password/reset-password.component.ts @@ -0,0 +1,31 @@ +import { Component, OnInit } from '@angular/core'; +import { FormGroup, FormControl, Validators } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { ToastrService } from 'ngx-toastr'; +import { AccountService } from 'src/app/_services/account.service'; + +@Component({ + selector: 'app-reset-password', + templateUrl: './reset-password.component.html', + styleUrls: ['./reset-password.component.scss'] +}) +export class ResetPasswordComponent implements OnInit { + + registerForm: FormGroup = new FormGroup({ + email: new FormControl('', [Validators.required, Validators.email]), + }); + + constructor(private route: ActivatedRoute, private router: Router, private accountService: AccountService, private toastr: ToastrService) {} + + ngOnInit(): void { + } + + submit() { + const model = this.registerForm.get('email')?.value; + this.accountService.requestResetPasswordEmail(model).subscribe((resp: string) => { + this.toastr.info(resp); + this.router.navigateByUrl('login'); + }); + } + +} diff --git a/UI/Web/src/app/registration/splash-container/splash-container.component.scss b/UI/Web/src/app/registration/splash-container/splash-container.component.scss index ac14abbc4..40255e600 100644 --- a/UI/Web/src/app/registration/splash-container/splash-container.component.scss +++ b/UI/Web/src/app/registration/splash-container/splash-container.component.scss @@ -1,5 +1,7 @@ @use "../../../theme/colors"; + + .login { display: flex; align-items: center; @@ -72,6 +74,15 @@ box-shadow: 0 0 0 0.2rem rgb(68 79 117 / 50%); } } + + ::ng-deep input { + background-color: #fff !important; + color: black; + } + + ::ng-deep a { + color: white; + } } .invalid-feedback { @@ -79,7 +90,3 @@ color: #343c59; } -input { - background-color: #fff !important; - color: black; -} \ No newline at end of file diff --git a/UI/Web/src/app/user-login/user-login.component.html b/UI/Web/src/app/user-login/user-login.component.html index c595203c1..badfde742 100644 --- a/UI/Web/src/app/user-login/user-login.component.html +++ b/UI/Web/src/app/user-login/user-login.component.html @@ -13,6 +13,10 @@
+ +