From efb527035de8fd2fb91c700b1de0d6600154633e Mon Sep 17 00:00:00 2001 From: Joseph Milazzo Date: Sun, 30 Jan 2022 14:45:57 -0800 Subject: [PATCH] Account Email Support (#1000) * Moved the Server Settings out into a button on nav header * Refactored Mange Users page to the new design (skeleton). Implemented skeleton code for Invite User. * Hashed out more of the code, but need to move all the email code to a Kavita controlled API server due to password credentials. * Cleaned up some warnings * When no user exists for an api key in Plugin controller, throw 401. * Hooked in the ability to check if the Kavita instance can be accessed externally so we can determine if the user can invite or not. * Hooked up some logic if the user's server isn't accessible, then default to old flow * Basic flow is working for confirm email. Needs validation, error handling, etc. * Refactored Password validation to account service * Cleaned up the code in confirm-email to work much better. * Refactored the login page to have a container functionality, so we can reuse the styles on multiple pages (registration pages). Hooked up the code for confirm email. * Messy code, but making progress. Refactored Register to be used only for first time user registration. Added a new register component to handle first time flow only. * Invite works much better, still needs a bit of work for non-accessible server setup. Started work on underlying manage users page to meet new design. * Changed (you) to a star to indicate who you're logged in as. * Inviting a user is now working and tested fully. * Removed the register member component as we now have invite and confirm components. * Editing a user is now working. Username change and Role/Library access from within one screen. Email changing is on hold. * Cleaned up code for edit user and disabled email field for now. * Cleaned up the code to indicate changing a user's email is not possible. * Implemented a migration for existing accounts so they can validate their emails and still login. * Change url for email server * Implemented the ability to resend an email confirmation code (or regenerate for non accessible servers). Fixed an overflow on the confirm dialog. * Took care of some code cleanup * Removed 3 db calls from cover refresh and some misc cleanup * Fixed a broken test --- .gitignore | 3 + API.Benchmark/TestBenchmark.cs | 1 - API.Tests/Extensions/SeriesExtensionsTests.cs | 1 - API.Tests/Helpers/CacheHelperTests.cs | 2 - API.Tests/Helpers/EntityFactory.cs | 3 +- API.Tests/Parser/ComicParserTests.cs | 2 - API.Tests/Parser/MangaParserTests.cs | 2 - API.Tests/Parser/ParserTest.cs | 1 - API.Tests/Services/DirectoryServiceTests.cs | 1 - API.Tests/Services/ParseScannedFilesTests.cs | 2 - API.Tests/Services/ReaderServiceTests.cs | 104 ++-- API.Tests/Services/ScannerServiceTests.cs | 19 +- API/Constants/PolicyConstants.cs | 10 +- API/Controllers/AccountController.cs | 475 ++++++++++++++++-- API/Controllers/BookController.cs | 1 - API/Controllers/DownloadController.cs | 1 - API/Controllers/SeriesController.cs | 1 - API/Controllers/ServerController.cs | 16 +- API/Controllers/UsersController.cs | 15 +- API/DTOs/Account/ConfirmEmailDto.cs | 16 + API/DTOs/Account/ConfirmMigrationEmailDto.cs | 7 + API/DTOs/Account/InviteUserDto.cs | 21 + API/DTOs/Account/MigrateUserEmailDto.cs | 11 + API/DTOs/Account/UpdateUserDto.cs | 23 + API/DTOs/ChapterDto.cs | 1 - API/DTOs/Email/ConfirmationEmailDto.cs | 8 + API/DTOs/Email/EmailMigrationDto.cs | 8 + API/DTOs/Filtering/ReadStatus.cs | 4 +- API/DTOs/MemberDto.cs | 5 +- API/DTOs/Reader/ChapterInfoDto.cs | 3 +- API/DTOs/RegisterDto.cs | 2 + API/DTOs/UserDto.cs | 1 + API/Data/MigrateConfigFiles.cs | 1 - API/Data/Repositories/LibraryRepository.cs | 9 + API/Data/Repositories/TagRepository.cs | 4 +- API/Data/Repositories/UserRepository.cs | 62 ++- API/Data/Repositories/VolumeRepository.cs | 1 - API/Entities/Enums/AgeRating.cs | 1 + API/Entities/Metadata/SeriesMetadata.cs | 3 +- .../ApplicationServiceExtensions.cs | 1 + API/Extensions/HttpExtensions.cs | 1 - API/Extensions/IdentityServiceExtensions.cs | 8 +- API/Extensions/ParserInfoListExtensions.cs | 1 - API/Helpers/ParserInfoHelpers.cs | 1 - API/Helpers/SQLHelper.cs | 1 - API/Parser/Parser.cs | 1 - API/Program.cs | 1 - API/Services/AccountService.cs | 59 ++- API/Services/ArchiveService.cs | 1 - API/Services/BookService.cs | 4 +- API/Services/CacheService.cs | 4 +- API/Services/DirectoryService.cs | 7 +- API/Services/EmailService.cs | 103 ++++ API/Services/MetadataService.cs | 24 +- API/Services/ReaderService.cs | 6 +- API/Services/TaskScheduler.cs | 5 +- API/Services/Tasks/ScannerService.cs | 4 +- API/Startup.cs | 3 - Kavita.Common/AppSettingsConfig.cs | 7 - Kavita.Common/Configuration.cs | 2 +- UI/Web/src/app/_models/member.ts | 2 + UI/Web/src/app/_services/account.service.ts | 50 +- UI/Web/src/app/_services/member.service.ts | 4 + UI/Web/src/app/_services/server.service.ts | 4 + .../edit-rbs-modal.component.ts | 2 + .../library-access-modal.component.ts | 1 - UI/Web/src/app/admin/admin.module.ts | 8 + .../admin/edit-user/edit-user.component.html | 58 +++ .../admin/edit-user/edit-user.component.scss | 0 .../admin/edit-user/edit-user.component.ts | 62 +++ .../invite-user/invite-user.component.html | 56 +++ .../invite-user/invite-user.component.scss | 0 .../invite-user/invite-user.component.ts | 80 +++ .../library-selector.component.html | 20 + .../library-selector.component.scss | 3 + .../library-selector.component.ts | 71 +++ .../manage-users/manage-users.component.html | 51 +- .../manage-users/manage-users.component.ts | 72 ++- .../role-selector.component.html | 10 + .../role-selector.component.scss | 3 + .../role-selector/role-selector.component.ts | 57 +++ UI/Web/src/app/app-routing.module.ts | 6 +- .../app/nav-header/nav-header.component.html | 20 +- .../register-member.component.html | 32 -- .../register-member.component.scss | 18 - .../register-member.component.ts | 54 -- ...-to-account-migration-modal.component.html | 55 ++ ...-to-account-migration-modal.component.scss | 0 ...il-to-account-migration-modal.component.ts | 60 +++ .../confirm-email.component.html | 58 +++ .../confirm-email.component.scss | 4 + .../confirm-email/confirm-email.component.ts | 60 +++ .../confirm-migration-email.component.html | 0 .../confirm-migration-email.component.scss | 0 .../confirm-migration-email.component.ts | 34 ++ .../register/register.component.html | 58 +++ .../register/register.component.scss | 4 + .../register/register.component.ts | 46 ++ .../app/registration/registration.module.ts | 29 ++ .../registration.router.module.ts | 27 + .../splash-container.component.html | 20 + .../splash-container.component.scss | 85 ++++ .../splash-container.component.ts | 15 + .../confirm-dialog.component.html | 2 +- UI/Web/src/app/shared/shared.module.ts | 3 - .../app/user-login/user-login.component.html | 8 - .../app/user-login/user-login.component.ts | 24 +- UI/Web/src/assets/themes/dark.scss | 2 +- UI/Web/src/styles.scss | 15 + 109 files changed, 2041 insertions(+), 407 deletions(-) create mode 100644 API/DTOs/Account/ConfirmEmailDto.cs create mode 100644 API/DTOs/Account/ConfirmMigrationEmailDto.cs create mode 100644 API/DTOs/Account/InviteUserDto.cs create mode 100644 API/DTOs/Account/MigrateUserEmailDto.cs create mode 100644 API/DTOs/Account/UpdateUserDto.cs create mode 100644 API/DTOs/Email/ConfirmationEmailDto.cs create mode 100644 API/DTOs/Email/EmailMigrationDto.cs create mode 100644 API/Services/EmailService.cs delete mode 100644 Kavita.Common/AppSettingsConfig.cs create mode 100644 UI/Web/src/app/admin/edit-user/edit-user.component.html create mode 100644 UI/Web/src/app/admin/edit-user/edit-user.component.scss create mode 100644 UI/Web/src/app/admin/edit-user/edit-user.component.ts create mode 100644 UI/Web/src/app/admin/invite-user/invite-user.component.html create mode 100644 UI/Web/src/app/admin/invite-user/invite-user.component.scss create mode 100644 UI/Web/src/app/admin/invite-user/invite-user.component.ts create mode 100644 UI/Web/src/app/admin/library-selector/library-selector.component.html create mode 100644 UI/Web/src/app/admin/library-selector/library-selector.component.scss create mode 100644 UI/Web/src/app/admin/library-selector/library-selector.component.ts create mode 100644 UI/Web/src/app/admin/role-selector/role-selector.component.html create mode 100644 UI/Web/src/app/admin/role-selector/role-selector.component.scss create mode 100644 UI/Web/src/app/admin/role-selector/role-selector.component.ts delete mode 100644 UI/Web/src/app/register-member/register-member.component.html delete mode 100644 UI/Web/src/app/register-member/register-member.component.scss delete mode 100644 UI/Web/src/app/register-member/register-member.component.ts create mode 100644 UI/Web/src/app/registration/add-email-to-account-migration-modal/add-email-to-account-migration-modal.component.html create mode 100644 UI/Web/src/app/registration/add-email-to-account-migration-modal/add-email-to-account-migration-modal.component.scss create mode 100644 UI/Web/src/app/registration/add-email-to-account-migration-modal/add-email-to-account-migration-modal.component.ts create mode 100644 UI/Web/src/app/registration/confirm-email/confirm-email.component.html create mode 100644 UI/Web/src/app/registration/confirm-email/confirm-email.component.scss create mode 100644 UI/Web/src/app/registration/confirm-email/confirm-email.component.ts create mode 100644 UI/Web/src/app/registration/confirm-migration-email/confirm-migration-email.component.html create mode 100644 UI/Web/src/app/registration/confirm-migration-email/confirm-migration-email.component.scss create mode 100644 UI/Web/src/app/registration/confirm-migration-email/confirm-migration-email.component.ts create mode 100644 UI/Web/src/app/registration/register/register.component.html create mode 100644 UI/Web/src/app/registration/register/register.component.scss create mode 100644 UI/Web/src/app/registration/register/register.component.ts create mode 100644 UI/Web/src/app/registration/registration.module.ts create mode 100644 UI/Web/src/app/registration/registration.router.module.ts create mode 100644 UI/Web/src/app/registration/splash-container/splash-container.component.html create mode 100644 UI/Web/src/app/registration/splash-container/splash-container.component.scss create mode 100644 UI/Web/src/app/registration/splash-container/splash-container.component.ts diff --git a/.gitignore b/.gitignore index 1ee566816..bc03f54c7 100644 --- a/.gitignore +++ b/.gitignore @@ -502,6 +502,9 @@ UI/Web/dist/ /API.Tests/Extensions/Test Data/modified on run.txt # All config files/folders in config except appsettings.json +/API/config-bak/ +/API/config-bak/*.* +/API/config-bak/**/ /API/config/covers/ /API/config/logs/ /API/config/backups/ diff --git a/API.Benchmark/TestBenchmark.cs b/API.Benchmark/TestBenchmark.cs index 618a8b93c..c5d2d18e1 100644 --- a/API.Benchmark/TestBenchmark.cs +++ b/API.Benchmark/TestBenchmark.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using API.Comparators; using API.DTOs; using API.Extensions; using BenchmarkDotNet.Attributes; diff --git a/API.Tests/Extensions/SeriesExtensionsTests.cs b/API.Tests/Extensions/SeriesExtensionsTests.cs index fc5b5b8ca..c00ade1e8 100644 --- a/API.Tests/Extensions/SeriesExtensionsTests.cs +++ b/API.Tests/Extensions/SeriesExtensionsTests.cs @@ -5,7 +5,6 @@ using API.Entities.Metadata; using API.Extensions; using API.Parser; using API.Services.Tasks.Scanner; -using API.Tests.Helpers; using Xunit; namespace API.Tests.Extensions diff --git a/API.Tests/Helpers/CacheHelperTests.cs b/API.Tests/Helpers/CacheHelperTests.cs index 9d1024ff0..723742bc6 100644 --- a/API.Tests/Helpers/CacheHelperTests.cs +++ b/API.Tests/Helpers/CacheHelperTests.cs @@ -5,8 +5,6 @@ using System.IO.Abstractions.TestingHelpers; using API.Entities; using API.Helpers; using API.Services; -using Microsoft.Extensions.Logging; -using NSubstitute; using Xunit; namespace API.Tests.Helpers; diff --git a/API.Tests/Helpers/EntityFactory.cs b/API.Tests/Helpers/EntityFactory.cs index 25b807c32..7a9ed79be 100644 --- a/API.Tests/Helpers/EntityFactory.cs +++ b/API.Tests/Helpers/EntityFactory.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; diff --git a/API.Tests/Parser/ComicParserTests.cs b/API.Tests/Parser/ComicParserTests.cs index c2ebc80f2..73f7cede4 100644 --- a/API.Tests/Parser/ComicParserTests.cs +++ b/API.Tests/Parser/ComicParserTests.cs @@ -1,6 +1,4 @@ -using System.Collections.Generic; using System.IO.Abstractions.TestingHelpers; -using API.Entities.Enums; using API.Parser; using API.Services; using Microsoft.Extensions.Logging; diff --git a/API.Tests/Parser/MangaParserTests.cs b/API.Tests/Parser/MangaParserTests.cs index fe4dd5e42..171e582cb 100644 --- a/API.Tests/Parser/MangaParserTests.cs +++ b/API.Tests/Parser/MangaParserTests.cs @@ -1,6 +1,4 @@ -using System.Collections.Generic; using API.Entities.Enums; -using API.Parser; using Xunit; using Xunit.Abstractions; diff --git a/API.Tests/Parser/ParserTest.cs b/API.Tests/Parser/ParserTest.cs index 38637c10d..3ac183937 100644 --- a/API.Tests/Parser/ParserTest.cs +++ b/API.Tests/Parser/ParserTest.cs @@ -1,5 +1,4 @@ using System.Linq; -using API.Entities.Enums; using Xunit; using static API.Parser.Parser; diff --git a/API.Tests/Services/DirectoryServiceTests.cs b/API.Tests/Services/DirectoryServiceTests.cs index bdbb7a238..391b4eac4 100644 --- a/API.Tests/Services/DirectoryServiceTests.cs +++ b/API.Tests/Services/DirectoryServiceTests.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.IO; -using System.IO.Abstractions; using System.IO.Abstractions.TestingHelpers; using System.Linq; using System.Text; diff --git a/API.Tests/Services/ParseScannedFilesTests.cs b/API.Tests/Services/ParseScannedFilesTests.cs index fd55143a1..bd1174416 100644 --- a/API.Tests/Services/ParseScannedFilesTests.cs +++ b/API.Tests/Services/ParseScannedFilesTests.cs @@ -10,10 +10,8 @@ using API.Entities.Enums; using API.Parser; using API.Services; using API.Services.Tasks.Scanner; -using API.SignalR; using API.Tests.Helpers; using AutoMapper; -using Microsoft.AspNetCore.SignalR; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; diff --git a/API.Tests/Services/ReaderServiceTests.cs b/API.Tests/Services/ReaderServiceTests.cs index 232a93c4e..75cfaf73f 100644 --- a/API.Tests/Services/ReaderServiceTests.cs +++ b/API.Tests/Services/ReaderServiceTests.cs @@ -1,6 +1,5 @@ using System.Collections.Generic; using System.Data.Common; -using System.IO; using System.IO.Abstractions.TestingHelpers; using System.Linq; using System.Threading.Tasks; @@ -153,9 +152,8 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); var fileSystem = new MockFileSystem(); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); Assert.Equal(0, await readerService.CapPageToChapter(1, -1)); Assert.Equal(1, await readerService.CapPageToChapter(1, 10)); @@ -200,9 +198,8 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); var fileSystem = new MockFileSystem(); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); var successful = await readerService.SaveReadingProgress(new ProgressDto() { @@ -252,9 +249,8 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); var fileSystem = new MockFileSystem(); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); var successful = await readerService.SaveReadingProgress(new ProgressDto() { @@ -325,9 +321,8 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); var fileSystem = new MockFileSystem(); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); var volumes = await _unitOfWork.VolumeRepository.GetVolumes(1); readerService.MarkChaptersAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters); @@ -379,8 +374,7 @@ public class ReaderServiceTests var fileSystem = new MockFileSystem(); var ds = new DirectoryService(Substitute.For>(), fileSystem); - var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); var volumes = (await _unitOfWork.VolumeRepository.GetVolumes(1)).ToList(); readerService.MarkChaptersAsRead(await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress), 1, volumes.First().Chapters); @@ -441,9 +435,8 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); var fileSystem = new MockFileSystem(); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); var nextChapter = await readerService.GetNextChapterIdAsync(1, 1, 1, 1); var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(nextChapter); @@ -490,9 +483,8 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); var fileSystem = new MockFileSystem(); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); var nextChapter = await readerService.GetNextChapterIdAsync(1, 1, 2, 1); @@ -535,9 +527,8 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); var fileSystem = new MockFileSystem(); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); var nextChapter = await readerService.GetNextChapterIdAsync(1, 1, 2, 1); @@ -579,9 +570,8 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); var fileSystem = new MockFileSystem(); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); var nextChapter = await readerService.GetNextChapterIdAsync(1, 2, 3, 1); @@ -635,9 +625,8 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); var fileSystem = new MockFileSystem(); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); var prevChapter = await readerService.GetPrevChapterIdAsync(1, 1, 2, 1); var actualChapter = await _unitOfWork.ChapterRepository.GetChapterAsync(prevChapter); @@ -684,9 +673,8 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); var fileSystem = new MockFileSystem(); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); var prevChapter = await readerService.GetPrevChapterIdAsync(1, 2, 3, 1); @@ -729,9 +717,8 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); var fileSystem = new MockFileSystem(); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); var prevChapter = await readerService.GetPrevChapterIdAsync(1, 1, 1, 1); @@ -775,9 +762,8 @@ public class ReaderServiceTests await _context.SaveChangesAsync(); var fileSystem = new MockFileSystem(); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); var prevChapter = await readerService.GetPrevChapterIdAsync(1, 2, 4, 1); @@ -830,9 +816,8 @@ public class ReaderServiceTests var fileSystem = new MockFileSystem(); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); // Save progress on first volume chapters and 1st of second volume await readerService.SaveReadingProgress(new ProgressDto() @@ -905,9 +890,8 @@ public class ReaderServiceTests var fileSystem = new MockFileSystem(); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); // Save progress on first volume chapters and 1st of second volume await readerService.SaveReadingProgress(new ProgressDto() @@ -973,9 +957,8 @@ public class ReaderServiceTests var fileSystem = new MockFileSystem(); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); // Save progress on first volume chapters and 1st of second volume await readerService.SaveReadingProgress(new ProgressDto() @@ -1038,9 +1021,8 @@ public class ReaderServiceTests var fileSystem = new MockFileSystem(); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); // Save progress on first volume chapters and 1st of second volume await readerService.SaveReadingProgress(new ProgressDto() @@ -1104,9 +1086,8 @@ public class ReaderServiceTests var fileSystem = new MockFileSystem(); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); // Save progress on first volume chapters and 1st of second volume await readerService.SaveReadingProgress(new ProgressDto() @@ -1174,9 +1155,8 @@ public class ReaderServiceTests var fileSystem = new MockFileSystem(); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); await readerService.MarkChaptersUntilAsRead(user, 1, 5); @@ -1222,9 +1202,8 @@ public class ReaderServiceTests var fileSystem = new MockFileSystem(); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); await readerService.MarkChaptersUntilAsRead(user, 1, 2.5f); @@ -1271,9 +1250,8 @@ public class ReaderServiceTests var fileSystem = new MockFileSystem(); - var ds = new DirectoryService(Substitute.For>(), fileSystem); - var cs = new CacheService(_logger, _unitOfWork, ds, new MockReadingItemServiceForCacheService(ds)); - var readerService = new ReaderService(_unitOfWork, Substitute.For>(), ds, cs); + + var readerService = new ReaderService(_unitOfWork, Substitute.For>()); var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress); await readerService.MarkChaptersUntilAsRead(user, 1, 2); diff --git a/API.Tests/Services/ScannerServiceTests.cs b/API.Tests/Services/ScannerServiceTests.cs index b78c6be35..280fe5c10 100644 --- a/API.Tests/Services/ScannerServiceTests.cs +++ b/API.Tests/Services/ScannerServiceTests.cs @@ -1,29 +1,12 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Data.Common; -using System.IO; -using System.IO.Abstractions.TestingHelpers; +using System.Collections.Generic; using System.Linq; -using System.Threading.Tasks; -using API.Data; using API.Entities; using API.Entities.Enums; using API.Entities.Metadata; -using API.Helpers; using API.Parser; -using API.Services; using API.Services.Tasks; using API.Services.Tasks.Scanner; -using API.SignalR; using API.Tests.Helpers; -using AutoMapper; -using Microsoft.AspNetCore.SignalR; -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.Extensions.Logging; -using NSubstitute; using Xunit; namespace API.Tests.Services diff --git a/API/Constants/PolicyConstants.cs b/API/Constants/PolicyConstants.cs index 389a87eee..cbcd05872 100644 --- a/API/Constants/PolicyConstants.cs +++ b/API/Constants/PolicyConstants.cs @@ -1,4 +1,7 @@ -namespace API.Constants +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace API.Constants { /// /// Role-based Security @@ -17,5 +20,10 @@ /// Used to give a user ability to download files from the server /// public const string DownloadRole = "Download"; + + public static readonly ImmutableArray ValidRoles = new ImmutableArray() + { + AdminRole, PlebRole, DownloadRole + }; } } diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index cfc3a1c7a..ada167f1a 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -1,20 +1,30 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net; +using System.Net.Sockets; using System.Reflection; using System.Threading.Tasks; +using System.Web; using API.Constants; using API.Data; +using API.Data.Repositories; using API.DTOs; using API.DTOs.Account; +using API.DTOs.Email; using API.Entities; +using API.Errors; using API.Extensions; using API.Services; using AutoMapper; +using AutoMapper.QueryableExtensions; +using Flurl.Util; using Kavita.Common; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; namespace API.Controllers @@ -31,13 +41,15 @@ namespace API.Controllers private readonly ILogger _logger; private readonly IMapper _mapper; private readonly IAccountService _accountService; + private readonly IEmailService _emailService; + private readonly IHostEnvironment _environment; /// public AccountController(UserManager userManager, SignInManager signInManager, ITokenService tokenService, IUnitOfWork unitOfWork, ILogger logger, - IMapper mapper, IAccountService accountService) + IMapper mapper, IAccountService accountService, IEmailService emailService, IHostEnvironment environment) { _userManager = userManager; _signInManager = signInManager; @@ -46,6 +58,8 @@ namespace API.Controllers _logger = logger; _mapper = mapper; _accountService = accountService; + _emailService = emailService; + _environment = environment; } /// @@ -73,71 +87,71 @@ namespace API.Controllers } /// - /// Register a new user on the server + /// Register the first user (admin) on the server. Will not do anything if an admin is already confirmed /// /// /// [HttpPost("register")] - public async Task> Register(RegisterDto registerDto) + public async Task> RegisterFirstUser(RegisterDto registerDto) { + var admins = await _userManager.GetUsersInRoleAsync("Admin"); + if (admins.Count > 0) return BadRequest("Not allowed"); + try { - if (await _userManager.Users.AnyAsync(x => x.NormalizedUserName == registerDto.Username.ToUpper())) + var usernameValidation = await _accountService.ValidateUsername(registerDto.Username); + if (usernameValidation.Any()) { - return BadRequest("Username is taken."); + return BadRequest(usernameValidation); } - // If we are registering an admin account, ensure there are no existing admins or user registering is an admin - if (registerDto.IsAdmin) + var user = new AppUser() { - var firstTimeFlow = !(await _userManager.GetUsersInRoleAsync("Admin")).Any(); - if (!firstTimeFlow && !await _unitOfWork.UserRepository.IsUserAdminAsync( - await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()))) - { - return BadRequest("You are not permitted to create an admin account"); - } - } + UserName = registerDto.Username, + Email = registerDto.Email, + UserPreferences = new AppUserPreferences(), + ApiKey = HashUtil.ApiKey() + }; - var user = _mapper.Map(registerDto); - user.UserPreferences ??= new AppUserPreferences(); - user.ApiKey = HashUtil.ApiKey(); - - var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); - if (!settings.EnableAuthentication && !registerDto.IsAdmin) - { - _logger.LogInformation("User {UserName} is being registered as non-admin with no server authentication. Using default password", registerDto.Username); - registerDto.Password = AccountService.DefaultPassword; - } + // I am removing Authentication disabled code + // var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + // if (!settings.EnableAuthentication && !registerDto.IsAdmin) + // { + // _logger.LogInformation("User {UserName} is being registered as non-admin with no server authentication. Using default password", registerDto.Username); + // registerDto.Password = AccountService.DefaultPassword; + // } var result = await _userManager.CreateAsync(user, registerDto.Password); - if (!result.Succeeded) return BadRequest(result.Errors); + var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); + if (string.IsNullOrEmpty(token)) return BadRequest("There was an issue generating a confirmation token."); + if (!await ConfirmEmailToken(token, user)) return BadRequest($"There was an issue validating your email: {token}"); - var role = registerDto.IsAdmin ? PolicyConstants.AdminRole : PolicyConstants.PlebRole; - var roleResult = await _userManager.AddToRoleAsync(user, role); + var roleResult = await _userManager.AddToRoleAsync(user, PolicyConstants.AdminRole); if (!roleResult.Succeeded) return BadRequest(result.Errors); - // When we register an admin, we need to grant them access to all Libraries. - if (registerDto.IsAdmin) - { - _logger.LogInformation("{UserName} is being registered as admin. Granting access to all libraries", - user.UserName); - var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); - foreach (var lib in libraries) - { - lib.AppUsers ??= new List(); - lib.AppUsers.Add(user); - } - - if (libraries.Any() && !await _unitOfWork.CommitAsync()) - _logger.LogError("There was an issue granting library access. Please do this manually"); - } + // // When we register an admin, we need to grant them access to all Libraries. + // if (registerDto.IsAdmin) + // { + // _logger.LogInformation("{UserName} is being registered as admin. Granting access to all libraries", + // user.UserName); + // var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); + // foreach (var lib in libraries) + // { + // lib.AppUsers ??= new List(); + // lib.AppUsers.Add(user); + // } + // + // if (libraries.Any() && !await _unitOfWork.CommitAsync()) + // _logger.LogError("There was an issue granting library access. Please do this manually"); + // } return new UserDto { Username = user.UserName, + Email = user.Email, Token = await _tokenService.CreateToken(user), RefreshToken = await _tokenService.CreateRefreshToken(user), ApiKey = user.ApiKey, @@ -153,6 +167,7 @@ namespace API.Controllers return BadRequest("Something went wrong when registering user"); } + /// /// Perform a login. Will send JWT Token of the logged in user back. /// @@ -167,6 +182,15 @@ namespace API.Controllers if (user == null) return Unauthorized("Invalid username"); + // Check if the user has an email, if not, inform them so they can migrate + var validPassword = await _signInManager.UserManager.CheckPasswordAsync(user, loginDto.Password); + if (string.IsNullOrEmpty(user.Email) && !user.EmailConfirmed && validPassword) + { + _logger.LogCritical("User {UserName} does not have an email. Providing a one time migration", user.UserName); + return Unauthorized( + "You are missing an email on your account. Please wait while we migrate your account."); + } + var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user); var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); if (!settings.EnableAuthentication && !isAdmin) @@ -178,7 +202,10 @@ namespace API.Controllers var result = await _signInManager .CheckPasswordSignInAsync(user, loginDto.Password, false); - if (!result.Succeeded) return Unauthorized("Your credentials are not correct."); + if (!result.Succeeded) + { + return Unauthorized(result.IsNotAllowed ? "You must confirm your email first" : "Your credentials are not correct."); + } // Update LastActive on account user.LastActive = DateTime.Now; @@ -192,6 +219,7 @@ namespace API.Controllers return new UserDto { Username = user.UserName, + Email = user.Email, Token = await _tokenService.CreateToken(user), RefreshToken = await _tokenService.CreateRefreshToken(user), ApiKey = user.ApiKey, @@ -285,5 +313,366 @@ namespace API.Controllers return BadRequest("Something went wrong, unable to reset key"); } + + /// + /// Update the user account. This can only affect Username, Email (will require confirming), Roles, and Library access. + /// + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("update")] + public async Task UpdateAccount(UpdateUserDto dto) + { + var adminUser = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + if (!await _unitOfWork.UserRepository.IsUserAdminAsync(adminUser)) return Unauthorized("You do not have permission"); + + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(dto.UserId); + if (user == null) return BadRequest("User does not exist"); + + // Check if username is changing + if (!user.UserName.Equals(dto.Username)) + { + // Validate username change + var errors = await _accountService.ValidateUsername(dto.Username); + if (errors.Any()) return BadRequest("Username already taken"); + user.UserName = dto.Username; + _unitOfWork.UserRepository.Update(user); + } + + if (!user.Email.Equals(dto.Email)) + { + // Validate username change + var errors = await _accountService.ValidateEmail(dto.Email); + if (errors.Any()) return BadRequest("Email already registered"); + // NOTE: This needs to be handled differently, like save it in a temp variable in DB until email is validated. For now, I wont allow it + } + + // Update roles + var existingRoles = await _userManager.GetRolesAsync(user); + var hasAdminRole = dto.Roles.Contains(PolicyConstants.AdminRole); + if (!hasAdminRole) + { + dto.Roles.Add(PolicyConstants.PlebRole); + } + if (existingRoles.Except(dto.Roles).Any()) + { + var roles = dto.Roles; + + var roleResult = await _userManager.RemoveFromRolesAsync(user, existingRoles); + if (!roleResult.Succeeded) return BadRequest(roleResult.Errors); + roleResult = await _userManager.AddToRolesAsync(user, roles); + if (!roleResult.Succeeded) return BadRequest(roleResult.Errors); + } + + + var allLibraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); + List libraries; + if (hasAdminRole) + { + _logger.LogInformation("{UserName} is being registered as admin. Granting access to all libraries", + user.UserName); + libraries = allLibraries; + } + else + { + // Remove user from all libraries + foreach (var lib in allLibraries) + { + lib.AppUsers ??= new List(); + lib.AppUsers.Remove(user); + } + + libraries = (await _unitOfWork.LibraryRepository.GetLibraryForIdsAsync(dto.Libraries)).ToList(); + } + + foreach (var lib in libraries) + { + lib.AppUsers ??= new List(); + lib.AppUsers.Add(user); + } + + if (!_unitOfWork.HasChanges()) return Ok(); + if (await _unitOfWork.CommitAsync()) + { + return Ok(); + } + + await _unitOfWork.RollbackAsync(); + return BadRequest("There was an exception when updating the user"); + } + + + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("invite")] + public async Task> InviteUser(InviteUserDto dto) + { + var adminUser = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); + _logger.LogInformation("{User} is inviting {Email} to the server", adminUser.UserName, dto.Email); + + // Check if there is an existing invite + var emailValidationErrors = await _accountService.ValidateEmail(dto.Email); + if (emailValidationErrors.Any()) + { + var invitedUser = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); + if (await _userManager.IsEmailConfirmedAsync(invitedUser)) + return BadRequest($"User is already registered as {invitedUser.UserName}"); + return BadRequest("User is already invited under this email and has yet to accepted invite."); + } + + // Create a new user + var user = new AppUser() + { + UserName = dto.Email, + Email = dto.Email, + ApiKey = HashUtil.ApiKey(), + UserPreferences = new AppUserPreferences() + }; + + try + { + var result = await _userManager.CreateAsync(user, AccountService.DefaultPassword); + if (!result.Succeeded) return BadRequest(result.Errors); + + // Assign Roles + var roles = dto.Roles; + var hasAdminRole = dto.Roles.Contains(PolicyConstants.AdminRole); + if (!hasAdminRole) + { + roles.Add(PolicyConstants.PlebRole); + } + + foreach (var role in roles) + { + if (!PolicyConstants.ValidRoles.Contains(role)) continue; + var roleResult = await _userManager.AddToRoleAsync(user, role); + if (!roleResult.Succeeded) + return + BadRequest(roleResult.Errors); // TODO: Combine all these return BadRequest into one big thing + } + + // Grant access to libraries + List libraries; + if (hasAdminRole) + { + _logger.LogInformation("{UserName} is being registered as admin. Granting access to all libraries", + user.UserName); + libraries = (await _unitOfWork.LibraryRepository.GetLibrariesAsync()).ToList(); + } + else + { + libraries = (await _unitOfWork.LibraryRepository.GetLibraryForIdsAsync(dto.Libraries)).ToList(); + } + + foreach (var lib in libraries) + { + lib.AppUsers ??= new List(); + lib.AppUsers.Add(user); + } + + await _unitOfWork.CommitAsync(); + + + var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); + if (string.IsNullOrEmpty(token)) return BadRequest("There was an issue sending email"); + + var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString(); + var emailLink = + $"{Request.Scheme}://{host}{Request.PathBase}/registration/confirm-email?token={HttpUtility.UrlEncode(token)}&email={HttpUtility.UrlEncode(dto.Email)}"; + _logger.LogInformation("[Invite User]: Email Link: {Link}", emailLink); + if (dto.SendEmail) + { + await _emailService.SendConfirmationEmail(new ConfirmationEmailDto() + { + EmailAddress = dto.Email, + InvitingUser = adminUser.UserName, + ServerConfirmationLink = emailLink + }); + } + return Ok(emailLink); + } + catch (Exception ex) + { + _unitOfWork.UserRepository.Delete(user); + await _unitOfWork.CommitAsync(); + } + + return BadRequest("There was an error setting up your account. Please check the logs"); + } + + [HttpPost("confirm-email")] + public async Task> ConfirmEmail(ConfirmEmailDto dto) + { + var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); + + // Validate Password and Username + var validationErrors = new List(); + validationErrors.AddRange(await _accountService.ValidateUsername(dto.Username)); + validationErrors.AddRange(await _accountService.ValidatePassword(user, dto.Password)); + + if (validationErrors.Any()) + { + return BadRequest(validationErrors); + } + + + if (!await ConfirmEmailToken(dto.Token, user)) return BadRequest("Invalid Email Token"); + + user.UserName = dto.Username; + var errors = await _accountService.ChangeUserPassword(user, dto.Password); + if (errors.Any()) + { + return BadRequest(errors); + } + await _unitOfWork.CommitAsync(); + + + user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(user.UserName, + AppUserIncludes.UserPreferences); + + // Perform Login code + return new UserDto + { + Username = user.UserName, + Email = user.Email, + Token = await _tokenService.CreateToken(user), + RefreshToken = await _tokenService.CreateRefreshToken(user), + ApiKey = user.ApiKey, + Preferences = _mapper.Map(user.UserPreferences) + }; + } + + [AllowAnonymous] + [HttpPost("confirm-migration-email")] + public async Task> ConfirmMigrationEmail(ConfirmMigrationEmailDto dto) + { + var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); + if (user == null) return Unauthorized("This email is not on system"); + + if (!await ConfirmEmailToken(dto.Token, user)) return BadRequest("Invalid Email Token"); + + await _unitOfWork.CommitAsync(); + + user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(user.UserName, + AppUserIncludes.UserPreferences); + + // Perform Login code + return new UserDto + { + Username = user.UserName, + Email = user.Email, + Token = await _tokenService.CreateToken(user), + RefreshToken = await _tokenService.CreateRefreshToken(user), + ApiKey = user.ApiKey, + Preferences = _mapper.Map(user.UserPreferences) + }; + } + + [HttpPost("resend-confirmation-email")] + public async Task> ResendConfirmationSendEmail([FromQuery] int userId) + { + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); + if (user == null) return BadRequest("User does not exist"); + + if (string.IsNullOrEmpty(user.Email)) + return BadRequest( + "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)}"; + _logger.LogInformation("[Email Migration]: Email Link: {Link}", emailLink); + await _emailService.SendMigrationEmail(new EmailMigrationDto() + { + EmailAddress = user.Email, + Username = user.UserName, + ServerConfirmationLink = emailLink + }); + + + return Ok(emailLink); + } + + /// + /// This is similar to invite. Essentially we authenticate the user's password then go through invite email flow + /// + /// + /// + [AllowAnonymous] + [HttpPost("migrate-email")] + public async Task> MigrateEmail(MigrateUserEmailDto dto) + { + // Check if there is an existing invite + var emailValidationErrors = await _accountService.ValidateEmail(dto.Email); + if (emailValidationErrors.Any()) + { + var invitedUser = await _unitOfWork.UserRepository.GetUserByEmailAsync(dto.Email); + if (await _userManager.IsEmailConfirmedAsync(invitedUser)) + return BadRequest($"User is already registered as {invitedUser.UserName}"); + return BadRequest("User is already invited under this email and has yet to accepted invite."); + } + + + var user = await _userManager.Users + .Include(u => u.UserPreferences) + .SingleOrDefaultAsync(x => x.NormalizedUserName == dto.Username.ToUpper()); + if (user == null) return Unauthorized("Invalid username"); + + var validPassword = await _signInManager.UserManager.CheckPasswordAsync(user, dto.Password); + if (!validPassword) return Unauthorized("Your credentials are not correct"); + + try + { + var token = await _userManager.GenerateEmailConfirmationTokenAsync(user); + if (string.IsNullOrEmpty(token)) return BadRequest("There was an issue sending email"); + user.Email = dto.Email; + _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)}"; + _logger.LogInformation("[Email Migration]: Email Link: {Link}", emailLink); + if (dto.SendEmail) + { + await _emailService.SendMigrationEmail(new EmailMigrationDto() + { + EmailAddress = dto.Email, + Username = user.UserName, + ServerConfirmationLink = emailLink + }); + } + return Ok(emailLink); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an issue during email migration. Contact support"); + _unitOfWork.UserRepository.Delete(user); + await _unitOfWork.CommitAsync(); + } + + return BadRequest("There was an error setting up your account. Please check the logs"); + } + + private async Task ConfirmEmailToken(string token, AppUser user) + { + var result = await _userManager.ConfirmEmailAsync(user, token); + if (!result.Succeeded) + { + _logger.LogCritical("Email validation failed"); + if (result.Errors.Any()) + { + foreach (var error in result.Errors) + { + _logger.LogCritical("Email validation error: {Message}", error.Description); + } + } + + return false; + } + + return true; + } } } diff --git a/API/Controllers/BookController.cs b/API/Controllers/BookController.cs index 473640df7..2bca8bb2f 100644 --- a/API/Controllers/BookController.cs +++ b/API/Controllers/BookController.cs @@ -10,7 +10,6 @@ using API.Extensions; using API.Services; using HtmlAgilityPack; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using VersOne.Epub; diff --git a/API/Controllers/DownloadController.cs b/API/Controllers/DownloadController.cs index 09388d3af..b840c0cbb 100644 --- a/API/Controllers/DownloadController.cs +++ b/API/Controllers/DownloadController.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; -using API.Comparators; using API.Constants; using API.Data; using API.DTOs.Downloads; diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index e3f75332c..e39ea1fd2 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -3,7 +3,6 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.Data; -using API.Data.Metadata; using API.Data.Repositories; using API.DTOs; using API.DTOs.Filtering; diff --git a/API/Controllers/ServerController.cs b/API/Controllers/ServerController.cs index 45fb22ce5..0f2124d06 100644 --- a/API/Controllers/ServerController.cs +++ b/API/Controllers/ServerController.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Net.NetworkInformation; using System.Threading.Tasks; using API.DTOs.Stats; using API.DTOs.Update; @@ -28,10 +29,11 @@ namespace API.Controllers private readonly IVersionUpdaterService _versionUpdaterService; private readonly IStatsService _statsService; private readonly ICleanupService _cleanupService; + private readonly IEmailService _emailService; public ServerController(IHostApplicationLifetime applicationLifetime, ILogger logger, IConfiguration config, IBackupService backupService, IArchiveService archiveService, ICacheService cacheService, - IVersionUpdaterService versionUpdaterService, IStatsService statsService, ICleanupService cleanupService) + IVersionUpdaterService versionUpdaterService, IStatsService statsService, ICleanupService cleanupService, IEmailService emailService) { _applicationLifetime = applicationLifetime; _logger = logger; @@ -42,6 +44,7 @@ namespace API.Controllers _versionUpdaterService = versionUpdaterService; _statsService = statsService; _cleanupService = cleanupService; + _emailService = emailService; } /// @@ -119,5 +122,16 @@ namespace API.Controllers { return Ok(await _versionUpdaterService.GetAllReleases()); } + + /// + /// Is this server accessible to the outside net + /// + /// + [HttpGet("accessible")] + [AllowAnonymous] + public async Task> IsServerAccessible() + { + return await _emailService.CheckIfAccessible(Request.Host.ToString()); + } } } diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs index 7662fdf95..2f469309e 100644 --- a/API/Controllers/UsersController.cs +++ b/API/Controllers/UsersController.cs @@ -36,19 +36,29 @@ namespace API.Controllers [HttpGet] public async Task>> GetUsers() { - return Ok(await _unitOfWork.UserRepository.GetMembersAsync()); + return Ok(await _unitOfWork.UserRepository.GetEmailConfirmedMemberDtosAsync()); } + [Authorize(Policy = "RequireAdminRole")] + [HttpGet("pending")] + public async Task>> GetPendingUsers() + { + return Ok(await _unitOfWork.UserRepository.GetPendingMemberDtosAsync()); + } + + + [AllowAnonymous] [HttpGet("names")] public async Task>> GetUserNames() { + // This is only for disabled auth flow - being removed var setting = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); if (setting.EnableAuthentication) { return Unauthorized("This API cannot be used given your server's configuration"); } - var members = await _unitOfWork.UserRepository.GetMembersAsync(); + var members = await _unitOfWork.UserRepository.GetEmailConfirmedMemberDtosAsync(); return Ok(members.Select(m => m.Username)); } @@ -94,5 +104,6 @@ namespace API.Controllers return BadRequest("There was an issue saving preferences."); } + } } diff --git a/API/DTOs/Account/ConfirmEmailDto.cs b/API/DTOs/Account/ConfirmEmailDto.cs new file mode 100644 index 000000000..225835796 --- /dev/null +++ b/API/DTOs/Account/ConfirmEmailDto.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace API.DTOs.Account; + +public class ConfirmEmailDto +{ + [Required] + public string Email { get; set; } + [Required] + public string Token { get; set; } + [Required] + [StringLength(32, MinimumLength = 6)] + public string Password { get; set; } + [Required] + public string Username { get; set; } +} diff --git a/API/DTOs/Account/ConfirmMigrationEmailDto.cs b/API/DTOs/Account/ConfirmMigrationEmailDto.cs new file mode 100644 index 000000000..07e0aa1ca --- /dev/null +++ b/API/DTOs/Account/ConfirmMigrationEmailDto.cs @@ -0,0 +1,7 @@ +namespace API.DTOs.Account; + +public class ConfirmMigrationEmailDto +{ + public string Email { get; set; } + public string Token { get; set; } +} diff --git a/API/DTOs/Account/InviteUserDto.cs b/API/DTOs/Account/InviteUserDto.cs new file mode 100644 index 000000000..04c9c1103 --- /dev/null +++ b/API/DTOs/Account/InviteUserDto.cs @@ -0,0 +1,21 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace API.DTOs.Account; + +public class InviteUserDto +{ + [Required] + public string Email { get; init; } + /// + /// List of Roles to assign to user. If admin not present, Pleb will be applied. + /// If admin present, all libraries will be granted access and will ignore those from DTO. + /// + public ICollection Roles { get; init; } + /// + /// A list of libraries to grant access to + /// + public IList Libraries { get; init; } + + public bool SendEmail { get; init; } = true; +} diff --git a/API/DTOs/Account/MigrateUserEmailDto.cs b/API/DTOs/Account/MigrateUserEmailDto.cs new file mode 100644 index 000000000..ec684a9be --- /dev/null +++ b/API/DTOs/Account/MigrateUserEmailDto.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace API.DTOs.Account; + +public class MigrateUserEmailDto +{ + public string Email { get; set; } + public string Username { get; set; } + public string Password { get; set; } + public bool SendEmail { get; set; } +} diff --git a/API/DTOs/Account/UpdateUserDto.cs b/API/DTOs/Account/UpdateUserDto.cs new file mode 100644 index 000000000..f3afb98a5 --- /dev/null +++ b/API/DTOs/Account/UpdateUserDto.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; + +namespace API.DTOs.Account; + +public record UpdateUserDto +{ + public int UserId { get; set; } + public string Username { get; set; } + /// + /// This field will not result in any change to the User model. Changing email is not supported. + /// + public string Email { get; set; } + /// + /// List of Roles to assign to user. If admin not present, Pleb will be applied. + /// If admin present, all libraries will be granted access and will ignore those from DTO. + /// + public IList Roles { get; init; } + /// + /// A list of libraries to grant access to + /// + public IList Libraries { get; init; } + +} diff --git a/API/DTOs/ChapterDto.cs b/API/DTOs/ChapterDto.cs index 6a4effe16..10956b529 100644 --- a/API/DTOs/ChapterDto.cs +++ b/API/DTOs/ChapterDto.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using API.DTOs.Metadata; -using API.Entities; namespace API.DTOs { diff --git a/API/DTOs/Email/ConfirmationEmailDto.cs b/API/DTOs/Email/ConfirmationEmailDto.cs new file mode 100644 index 000000000..1754ece22 --- /dev/null +++ b/API/DTOs/Email/ConfirmationEmailDto.cs @@ -0,0 +1,8 @@ +namespace API.DTOs.Email; + +public class ConfirmationEmailDto +{ + public string InvitingUser { get; init; } + public string EmailAddress { get; init; } + public string ServerConfirmationLink { get; init; } +} diff --git a/API/DTOs/Email/EmailMigrationDto.cs b/API/DTOs/Email/EmailMigrationDto.cs new file mode 100644 index 000000000..0da3bd5eb --- /dev/null +++ b/API/DTOs/Email/EmailMigrationDto.cs @@ -0,0 +1,8 @@ +namespace API.DTOs.Email; + +public class EmailMigrationDto +{ + public string EmailAddress { get; init; } + public string Username { get; init; } + public string ServerConfirmationLink { get; init; } +} diff --git a/API/DTOs/Filtering/ReadStatus.cs b/API/DTOs/Filtering/ReadStatus.cs index e2452fdc1..eeb786714 100644 --- a/API/DTOs/Filtering/ReadStatus.cs +++ b/API/DTOs/Filtering/ReadStatus.cs @@ -1,6 +1,4 @@ -using System; - -namespace API.DTOs.Filtering; +namespace API.DTOs.Filtering; /// /// Represents the Reading Status. This is a flag and allows multiple statues diff --git a/API/DTOs/MemberDto.cs b/API/DTOs/MemberDto.cs index 88a16aa7c..8215cebc2 100644 --- a/API/DTOs/MemberDto.cs +++ b/API/DTOs/MemberDto.cs @@ -4,15 +4,16 @@ using System.Collections.Generic; namespace API.DTOs { /// - /// Represents a member of a Kavita server. + /// Represents a member of a Kavita server. /// public class MemberDto { public int Id { get; init; } public string Username { get; init; } + public string Email { get; init; } public DateTime Created { get; init; } public DateTime LastActive { get; init; } public IEnumerable Libraries { get; init; } public IEnumerable Roles { get; init; } } -} \ No newline at end of file +} diff --git a/API/DTOs/Reader/ChapterInfoDto.cs b/API/DTOs/Reader/ChapterInfoDto.cs index e29f3798c..7c847d926 100644 --- a/API/DTOs/Reader/ChapterInfoDto.cs +++ b/API/DTOs/Reader/ChapterInfoDto.cs @@ -1,5 +1,4 @@ -using System; -using API.Entities.Enums; +using API.Entities.Enums; namespace API.DTOs.Reader { diff --git a/API/DTOs/RegisterDto.cs b/API/DTOs/RegisterDto.cs index 1bf598f5d..33dc62212 100644 --- a/API/DTOs/RegisterDto.cs +++ b/API/DTOs/RegisterDto.cs @@ -7,6 +7,8 @@ namespace API.DTOs [Required] public string Username { get; init; } [Required] + public string Email { get; init; } + [Required] [StringLength(32, MinimumLength = 6)] public string Password { get; set; } public bool IsAdmin { get; init; } diff --git a/API/DTOs/UserDto.cs b/API/DTOs/UserDto.cs index d7d6be2e9..7a7a234e7 100644 --- a/API/DTOs/UserDto.cs +++ b/API/DTOs/UserDto.cs @@ -4,6 +4,7 @@ namespace API.DTOs public class UserDto { public string Username { get; init; } + public string Email { get; init; } public string Token { get; init; } public string RefreshToken { get; init; } public string ApiKey { get; init; } diff --git a/API/Data/MigrateConfigFiles.cs b/API/Data/MigrateConfigFiles.cs index 51ee37167..1acc81b13 100644 --- a/API/Data/MigrateConfigFiles.cs +++ b/API/Data/MigrateConfigFiles.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.IO; -using System.IO.Abstractions; using System.Linq; using API.Services; using Kavita.Common; diff --git a/API/Data/Repositories/LibraryRepository.cs b/API/Data/Repositories/LibraryRepository.cs index 26fc517a2..a97826c75 100644 --- a/API/Data/Repositories/LibraryRepository.cs +++ b/API/Data/Repositories/LibraryRepository.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -36,6 +37,7 @@ public interface ILibraryRepository Task DeleteLibrary(int libraryId); Task> GetLibrariesForUserIdAsync(int userId); Task GetLibraryTypeAsync(int libraryId); + Task> GetLibraryForIdsAsync(IList libraryIds); } public class LibraryRepository : ILibraryRepository @@ -108,6 +110,13 @@ public class LibraryRepository : ILibraryRepository .SingleAsync(); } + public async Task> GetLibraryForIdsAsync(IList libraryIds) + { + return await _context.Library + .Where(x => libraryIds.Contains(x.Id)) + .ToListAsync(); + } + public async Task> GetLibraryDtosAsync() { return await _context.Library diff --git a/API/Data/Repositories/TagRepository.cs b/API/Data/Repositories/TagRepository.cs index 772957aa9..50d566aae 100644 --- a/API/Data/Repositories/TagRepository.cs +++ b/API/Data/Repositories/TagRepository.cs @@ -50,13 +50,13 @@ public class TagRepository : ITagRepository public async Task RemoveAllTagNoLongerAssociated(bool removeExternal = false) { - var TagsWithNoConnections = await _context.Tag + var tagsWithNoConnections = await _context.Tag .Include(p => p.SeriesMetadatas) .Include(p => p.Chapters) .Where(p => p.SeriesMetadatas.Count == 0 && p.Chapters.Count == 0 && p.ExternalTag == removeExternal) .ToListAsync(); - _context.Tag.RemoveRange(TagsWithNoConnections); + _context.Tag.RemoveRange(tagsWithNoConnections); await _context.SaveChangesAsync(); } diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index 138ef15b8..b4e8a97f7 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.Linq; using System.Threading.Tasks; using API.Constants; @@ -20,7 +21,8 @@ public enum AppUserIncludes Progress = 2, Bookmarks = 4, ReadingLists = 8, - Ratings = 16 + Ratings = 16, + UserPreferences = 32 } public interface IUserRepository @@ -29,7 +31,8 @@ public interface IUserRepository void Update(AppUserPreferences preferences); void Update(AppUserBookmark bookmark); public void Delete(AppUser user); - Task> GetMembersAsync(); + Task> GetEmailConfirmedMemberDtosAsync(); + Task> GetPendingMemberDtosAsync(); Task> GetAdminUsersAsync(); Task> GetNonAdminUsersAsync(); Task IsUserAdminAsync(AppUser user); @@ -48,6 +51,7 @@ public interface IUserRepository Task GetUserIdByUsernameAsync(string username); Task GetUserWithReadingListsByUsernameAsync(string username); Task> GetAllBookmarksByIds(IList bookmarkIds); + Task GetUserByEmailAsync(string email); } public class UserRepository : IUserRepository @@ -156,6 +160,13 @@ public class UserRepository : IUserRepository query = query.Include(u => u.Ratings); } + if (includeFlags.HasFlag(AppUserIncludes.UserPreferences)) + { + query = query.Include(u => u.UserPreferences); + } + + + return query; } @@ -198,6 +209,11 @@ public class UserRepository : IUserRepository .ToListAsync(); } + public async Task GetUserByEmailAsync(string email) + { + return await _context.AppUser.SingleOrDefaultAsync(u => u.Email.Equals(email)); + } + public async Task> GetAdminUsersAsync() { return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole); @@ -280,9 +296,10 @@ public class UserRepository : IUserRepository } - public async Task> GetMembersAsync() + public async Task> GetEmailConfirmedMemberDtosAsync() { return await _context.Users + .Where(u => u.EmailConfirmed) .Include(x => x.Libraries) .Include(r => r.UserRoles) .ThenInclude(r => r.Role) @@ -291,6 +308,7 @@ public class UserRepository : IUserRepository { Id = u.Id, Username = u.UserName, + Email = u.Email, Created = u.Created, LastActive = u.LastActive, Roles = u.UserRoles.Select(r => r.Role.Name).ToList(), @@ -305,4 +323,42 @@ public class UserRepository : IUserRepository .AsNoTracking() .ToListAsync(); } + + public async Task> GetPendingMemberDtosAsync() + { + return await _context.Users + .Where(u => !u.EmailConfirmed) + .Include(x => x.Libraries) + .Include(r => r.UserRoles) + .ThenInclude(r => r.Role) + .OrderBy(u => u.UserName) + .Select(u => new MemberDto + { + Id = u.Id, + Username = u.UserName, + Email = u.Email, + Created = u.Created, + LastActive = u.LastActive, + Roles = u.UserRoles.Select(r => r.Role.Name).ToList(), + Libraries = u.Libraries.Select(l => new LibraryDto + { + Name = l.Name, + Type = l.Type, + LastScanned = l.LastScanned, + Folders = l.Folders.Select(x => x.Path).ToList() + }).ToList() + }) + .AsNoTracking() + .ToListAsync(); + } + + public async Task ValidateUserExists(string username) + { + if (await _userManager.Users.AnyAsync(x => x.NormalizedUserName == username.ToUpper())) + { + throw new ValidationException("Username is taken."); + } + + return true; + } } diff --git a/API/Data/Repositories/VolumeRepository.cs b/API/Data/Repositories/VolumeRepository.cs index e63b469e6..6ab2ca113 100644 --- a/API/Data/Repositories/VolumeRepository.cs +++ b/API/Data/Repositories/VolumeRepository.cs @@ -1,7 +1,6 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using API.Comparators; using API.DTOs; using API.Entities; using API.Extensions; diff --git a/API/Entities/Enums/AgeRating.cs b/API/Entities/Enums/AgeRating.cs index ddb288ee1..ed9deac25 100644 --- a/API/Entities/Enums/AgeRating.cs +++ b/API/Entities/Enums/AgeRating.cs @@ -21,6 +21,7 @@ public enum AgeRating [Description("Everyone 10+")] Everyone10Plus = 5, [Description("PG")] + // ReSharper disable once InconsistentNaming PG = 6, [Description("Kids to Adults")] KidsToAdults = 7, diff --git a/API/Entities/Metadata/SeriesMetadata.cs b/API/Entities/Metadata/SeriesMetadata.cs index 54ea8ccc0..81fcba090 100644 --- a/API/Entities/Metadata/SeriesMetadata.cs +++ b/API/Entities/Metadata/SeriesMetadata.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using API.Entities.Enums; using API.Entities.Interfaces; diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index fd0c5f5ca..edc3cf4e9 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -37,6 +37,7 @@ namespace API.Extensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); diff --git a/API/Extensions/HttpExtensions.cs b/API/Extensions/HttpExtensions.cs index 68655f43d..f0b3d5399 100644 --- a/API/Extensions/HttpExtensions.cs +++ b/API/Extensions/HttpExtensions.cs @@ -1,6 +1,5 @@ using System.IO; using System.Linq; -using System.Runtime.Intrinsics.Arm; using System.Security.Cryptography; using System.Text; using System.Text.Json; diff --git a/API/Extensions/IdentityServiceExtensions.cs b/API/Extensions/IdentityServiceExtensions.cs index bdf177586..763f3a5a4 100644 --- a/API/Extensions/IdentityServiceExtensions.cs +++ b/API/Extensions/IdentityServiceExtensions.cs @@ -1,12 +1,9 @@ -using System; -using System.Text; +using System.Text; using System.Threading.Tasks; using API.Constants; using API.Data; using API.Entities; -using ExCSS; using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.AspNetCore.Authorization.Infrastructure; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -27,6 +24,8 @@ namespace API.Extensions opt.Password.RequireUppercase = false; opt.Password.RequireNonAlphanumeric = false; opt.Password.RequiredLength = 6; + + opt.SignIn.RequireConfirmedEmail = true; }) .AddTokenProvider>(TokenOptions.DefaultProvider) .AddRoles() @@ -35,6 +34,7 @@ namespace API.Extensions .AddRoleValidator>() .AddEntityFrameworkStores(); + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { diff --git a/API/Extensions/ParserInfoListExtensions.cs b/API/Extensions/ParserInfoListExtensions.cs index 31a65c819..262a8012a 100644 --- a/API/Extensions/ParserInfoListExtensions.cs +++ b/API/Extensions/ParserInfoListExtensions.cs @@ -1,7 +1,6 @@ using System.Collections.Generic; using System.Linq; using API.Entities; -using API.Entities.Enums; using API.Parser; namespace API.Extensions diff --git a/API/Helpers/ParserInfoHelpers.cs b/API/Helpers/ParserInfoHelpers.cs index 48421cd70..a97601a43 100644 --- a/API/Helpers/ParserInfoHelpers.cs +++ b/API/Helpers/ParserInfoHelpers.cs @@ -1,7 +1,6 @@ using System.Collections.Generic; using API.Entities; using API.Entities.Enums; -using API.Extensions; using API.Parser; using API.Services.Tasks.Scanner; diff --git a/API/Helpers/SQLHelper.cs b/API/Helpers/SQLHelper.cs index d06d246ef..fcd44e7da 100644 --- a/API/Helpers/SQLHelper.cs +++ b/API/Helpers/SQLHelper.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.Data; using System.Data.Common; -using API.DTOs; using Microsoft.EntityFrameworkCore; namespace API.Helpers diff --git a/API/Parser/Parser.cs b/API/Parser/Parser.cs index 847b0b62b..be9d8e8af 100644 --- a/API/Parser/Parser.cs +++ b/API/Parser/Parser.cs @@ -3,7 +3,6 @@ using System.IO; using System.Linq; using System.Text.RegularExpressions; using API.Entities.Enums; -using API.Services; namespace API.Parser { diff --git a/API/Program.cs b/API/Program.cs index 286a0f0cd..c6cb7ed69 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -8,7 +8,6 @@ using API.Data; using API.Entities; using API.Entities.Enums; using API.Services; -using API.Services.Tasks; using Kavita.Common; using Kavita.Common.EnvironmentInfo; using Microsoft.AspNetCore.Hosting; diff --git a/API/Services/AccountService.cs b/API/Services/AccountService.cs index 0591770ec..62f5386fb 100644 --- a/API/Services/AccountService.cs +++ b/API/Services/AccountService.cs @@ -1,9 +1,12 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using API.Data; using API.Entities; using API.Errors; using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace API.Services @@ -11,30 +14,29 @@ namespace API.Services public interface IAccountService { Task> ChangeUserPassword(AppUser user, string newPassword); + Task> ValidatePassword(AppUser user, string password); + Task> ValidateUsername(string username); + Task> ValidateEmail(string email); } public class AccountService : IAccountService { private readonly UserManager _userManager; private readonly ILogger _logger; + private readonly IUnitOfWork _unitOfWork; public const string DefaultPassword = "[k.2@RZ!mxCQkJzE"; - public AccountService(UserManager userManager, ILogger logger) + public AccountService(UserManager userManager, ILogger logger, IUnitOfWork unitOfWork) { _userManager = userManager; _logger = logger; + _unitOfWork = unitOfWork; } public async Task> ChangeUserPassword(AppUser user, string newPassword) { - foreach (var validator in _userManager.PasswordValidators) - { - var validationResult = await validator.ValidateAsync(_userManager, user, newPassword); - if (!validationResult.Succeeded) - { - return validationResult.Errors.Select(e => new ApiException(400, e.Code, e.Description)); - } - } + var passwordValidationIssues = (await ValidatePassword(user, newPassword)).ToList(); + if (passwordValidationIssues.Any()) return passwordValidationIssues; var result = await _userManager.RemovePasswordAsync(user); if (!result.Succeeded) @@ -53,5 +55,42 @@ namespace API.Services return new List(); } + + public async Task> ValidatePassword(AppUser user, string password) + { + foreach (var validator in _userManager.PasswordValidators) + { + var validationResult = await validator.ValidateAsync(_userManager, user, password); + if (!validationResult.Succeeded) + { + return validationResult.Errors.Select(e => new ApiException(400, e.Code, e.Description)); + } + } + + return Array.Empty(); + } + public async Task> ValidateUsername(string username) + { + if (await _userManager.Users.AnyAsync(x => x.NormalizedUserName == username.ToUpper())) + { + return new List() + { + new ApiException(400, "Username is already taken") + }; + } + + return Array.Empty(); + } + + public async Task> ValidateEmail(string email) + { + var user = await _unitOfWork.UserRepository.GetUserByEmailAsync(email); + if (user == null) return Array.Empty(); + + return new List() + { + new ApiException(400, "Email is already registered") + }; + } } } diff --git a/API/Services/ArchiveService.cs b/API/Services/ArchiveService.cs index cdf4df4db..db098aa0f 100644 --- a/API/Services/ArchiveService.cs +++ b/API/Services/ArchiveService.cs @@ -7,7 +7,6 @@ using System.Linq; using System.Threading.Tasks; using System.Xml.Serialization; using API.Archive; -using API.Comparators; using API.Data.Metadata; using API.Extensions; using API.Services.Tasks; diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index d4fb303f0..5d40437d3 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -171,7 +171,7 @@ namespace API.Services stylesheetHtml = stylesheetHtml.Insert(0, importBuilder.ToString()); - EscapeCSSImportReferences(ref stylesheetHtml, apiBase, prepend); + EscapeCssImportReferences(ref stylesheetHtml, apiBase, prepend); EscapeFontFamilyReferences(ref stylesheetHtml, apiBase, prepend); @@ -200,7 +200,7 @@ namespace API.Services return RemoveWhiteSpaceFromStylesheets(stylesheet.ToCss()); } - private static void EscapeCSSImportReferences(ref string stylesheetHtml, string apiBase, string prepend) + private static void EscapeCssImportReferences(ref string stylesheetHtml, string apiBase, string prepend) { foreach (Match match in Parser.Parser.CssImportUrlRegex.Matches(stylesheetHtml)) { diff --git a/API/Services/CacheService.cs b/API/Services/CacheService.cs index 0b6c4aa0d..c5396f4ed 100644 --- a/API/Services/CacheService.cs +++ b/API/Services/CacheService.cs @@ -1,6 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Collections.Immutable; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; diff --git a/API/Services/DirectoryService.cs b/API/Services/DirectoryService.cs index bf3c01d25..4862e40d8 100644 --- a/API/Services/DirectoryService.cs +++ b/API/Services/DirectoryService.cs @@ -1,5 +1,4 @@ using System; -using System.Collections; using System.Collections.Generic; using System.Collections.Immutable; using System.IO; @@ -7,7 +6,7 @@ using System.IO.Abstractions; using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; -using API.Comparators; +using API.Entities.Enums; using API.Extensions; using Microsoft.Extensions.Logging; @@ -22,7 +21,7 @@ namespace API.Services string TempDirectory { get; } string ConfigDirectory { get; } /// - /// Original BookmarkDirectory. Only used for resetting directory. Use for actual path. + /// Original BookmarkDirectory. Only used for resetting directory. Use for actual path. /// string BookmarkDirectory { get; } /// @@ -682,7 +681,7 @@ namespace API.Services FileSystem.Path.Join(directoryName, "test.txt"), string.Empty); } - catch (Exception ex) + catch (Exception) { ClearAndDeleteDirectory(directoryName); return false; diff --git a/API/Services/EmailService.cs b/API/Services/EmailService.cs new file mode 100644 index 000000000..4e2ba162e --- /dev/null +++ b/API/Services/EmailService.cs @@ -0,0 +1,103 @@ +using System; +using System.Net.Http; +using System.Threading.Tasks; +using API.DTOs.Email; +using API.Services.Tasks; +using Flurl.Http; +using Kavita.Common.EnvironmentInfo; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; + +namespace API.Services; + +public interface IEmailService +{ + Task SendConfirmationEmail(ConfirmationEmailDto data); + Task CheckIfAccessible(string host); + Task SendMigrationEmail(EmailMigrationDto data); +} + +public class EmailService : IEmailService +{ + private readonly ILogger _logger; + private const string ApiUrl = "https://email.kavitareader.com"; + + public EmailService(ILogger logger) + { + _logger = logger; + + FlurlHttp.ConfigureClient(ApiUrl, cli => + cli.Settings.HttpClientFactory = new UntrustedCertClientFactory()); + } + + public async Task SendConfirmationEmail(ConfirmationEmailDto data) + { + + var success = await SendEmailWithPost(ApiUrl + "/api/email/confirm", data); + if (!success) + { + _logger.LogError("There was a critical error sending Confirmation email"); + } + } + + public async Task CheckIfAccessible(string host) + { + return await SendEmailWithGet(ApiUrl + "/api/email/reachable?host=" + host); + } + + public async Task SendMigrationEmail(EmailMigrationDto data) + { + await SendEmailWithPost(ApiUrl + "/api/email/email-migration", data); + } + + private static async Task SendEmailWithGet(string url) + { + try + { + var response = await (url) + .WithHeader("Accept", "application/json") + .WithHeader("User-Agent", "Kavita") + .WithHeader("x-api-key", "MsnvA2DfQqxSK5jh") + .WithHeader("x-kavita-version", BuildInfo.Version) + .WithHeader("Content-Type", "application/json") + .WithTimeout(TimeSpan.FromSeconds(30)) + .GetStringAsync(); + + if (!string.IsNullOrEmpty(response) && bool.Parse(response)) + { + return true; + } + } + catch (Exception) + { + return false; + } + return false; + } + + + private static async Task SendEmailWithPost(string url, object data) + { + try + { + var response = await (url) + .WithHeader("Accept", "application/json") + .WithHeader("User-Agent", "Kavita") + .WithHeader("x-api-key", "MsnvA2DfQqxSK5jh") + .WithHeader("x-kavita-version", BuildInfo.Version) + .WithHeader("Content-Type", "application/json") + .WithTimeout(TimeSpan.FromSeconds(30)) + .PostJsonAsync(data); + + if (response.StatusCode != StatusCodes.Status200OK) + { + return false; + } + } + catch (Exception) + { + return false; + } + return true; + } +} diff --git a/API/Services/MetadataService.cs b/API/Services/MetadataService.cs index b6d45c77e..ed446ec19 100644 --- a/API/Services/MetadataService.cs +++ b/API/Services/MetadataService.cs @@ -1,16 +1,13 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.IO; using System.Linq; using System.Threading.Tasks; using API.Comparators; using API.Data; -using API.Data.Metadata; using API.Data.Repositories; using API.Data.Scanner; using API.Entities; -using API.Entities.Enums; using API.Extensions; using API.Helpers; using API.SignalR; @@ -70,7 +67,7 @@ public class MetadataService : IMetadataService if (firstFile == null) return false; - _logger.LogDebug("[MetadataService] Generating cover image for {File}", firstFile?.FilePath); + _logger.LogDebug("[MetadataService] Generating cover image for {File}", firstFile.FilePath); chapter.CoverImage = _readingItemService.GetCoverImage(firstFile.FilePath, ImageService.GetChapterFormat(chapter.Id, chapter.VolumeId), firstFile.Format); return true; @@ -144,7 +141,7 @@ public class MetadataService : IMetadataService /// /// /// - private void ProcessSeriesMetadataUpdate(Series series, ICollection allPeople, ICollection allGenres, ICollection allTags, bool forceUpdate) + private void ProcessSeriesMetadataUpdate(Series series, bool forceUpdate) { _logger.LogDebug("[MetadataService] Processing series {SeriesName}", series.OriginalName); try @@ -220,17 +217,12 @@ public class MetadataService : IMetadataService }); _logger.LogDebug("[MetadataService] Fetched {SeriesCount} series for refresh", nonLibrarySeries.Count); - var allPeople = await _unitOfWork.PersonRepository.GetAllPeople(); - var allGenres = await _unitOfWork.GenreRepository.GetAllGenresAsync(); - var allTags = await _unitOfWork.TagRepository.GetAllTagsAsync(); - - var seriesIndex = 0; foreach (var series in nonLibrarySeries) { try { - ProcessSeriesMetadataUpdate(series, allPeople, allGenres, allTags, forceUpdate); + ProcessSeriesMetadataUpdate(series, forceUpdate); } catch (Exception ex) { @@ -270,12 +262,11 @@ public class MetadataService : IMetadataService await _unitOfWork.GenreRepository.RemoveAllGenreNoLongerAssociated(); } - // TODO: I can probably refactor RefreshMetadata and RefreshMetadataForSeries to be the same by utilizing chunk size of 1, so most of the code can be the same. + // TODO: Write out a single piece of code that can iterate over a collection/chunk and perform custom actions private async Task PerformScan(Library library, bool forceUpdate, Action action) { var chunkInfo = await _unitOfWork.SeriesRepository.GetChunkInfo(library.Id); var stopwatch = Stopwatch.StartNew(); - var totalTime = 0L; _logger.LogInformation("[MetadataService] Refreshing Library {LibraryName}. Total Items: {TotalSize}. Total Chunks: {TotalChunks} with {ChunkSize} size", library.Name, chunkInfo.TotalSize, chunkInfo.TotalChunks, chunkInfo.ChunkSize); await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress, MessageFactory.RefreshMetadataProgressEvent(library.Id, 0F)); @@ -283,7 +274,6 @@ public class MetadataService : IMetadataService for (var chunk = 1; chunk <= chunkInfo.TotalChunks; chunk++) { if (chunkInfo.TotalChunks == 0) continue; - totalTime += stopwatch.ElapsedMilliseconds; stopwatch.Restart(); action(chunk, chunkInfo); @@ -346,11 +336,7 @@ public class MetadataService : IMetadataService await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress, MessageFactory.RefreshMetadataProgressEvent(libraryId, 0F)); - var allPeople = await _unitOfWork.PersonRepository.GetAllPeople(); - var allGenres = await _unitOfWork.GenreRepository.GetAllGenresAsync(); - var allTags = await _unitOfWork.TagRepository.GetAllTagsAsync(); - - ProcessSeriesMetadataUpdate(series, allPeople, allGenres, allTags, forceUpdate); + ProcessSeriesMetadataUpdate(series, forceUpdate); await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress, MessageFactory.RefreshMetadataProgressEvent(libraryId, 1F)); diff --git a/API/Services/ReaderService.cs b/API/Services/ReaderService.cs index be43c78cf..c59cd8549 100644 --- a/API/Services/ReaderService.cs +++ b/API/Services/ReaderService.cs @@ -30,17 +30,13 @@ public class ReaderService : IReaderService { private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; - private readonly IDirectoryService _directoryService; - private readonly ICacheService _cacheService; private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer(); private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst(); - public ReaderService(IUnitOfWork unitOfWork, ILogger logger, IDirectoryService directoryService, ICacheService cacheService) + public ReaderService(IUnitOfWork unitOfWork, ILogger logger) { _unitOfWork = unitOfWork; _logger = logger; - _directoryService = directoryService; - _cacheService = cacheService; } public static string FormatBookmarkFolderPath(string baseDirectory, int userId, int seriesId, int chapterId) diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index 9f91bf75c..6c1d914cf 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -35,7 +35,6 @@ public class TaskScheduler : ITaskScheduler private readonly IStatsService _statsService; private readonly IVersionUpdaterService _versionUpdaterService; - private readonly IDirectoryService _directoryService; public static BackgroundJobServer Client => new BackgroundJobServer(); private static readonly Random Rnd = new Random(); @@ -43,8 +42,7 @@ public class TaskScheduler : ITaskScheduler public TaskScheduler(ICacheService cacheService, ILogger logger, IScannerService scannerService, IUnitOfWork unitOfWork, IMetadataService metadataService, IBackupService backupService, - ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService, - IDirectoryService directoryService) + ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService) { _cacheService = cacheService; _logger = logger; @@ -55,7 +53,6 @@ public class TaskScheduler : ITaskScheduler _cleanupService = cleanupService; _statsService = statsService; _versionUpdaterService = versionUpdaterService; - _directoryService = directoryService; } public async Task ScheduleTasks() diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index 7c6a51f2c..374bffb3d 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -569,7 +569,7 @@ public class ScannerService : IScannerService PersonHelper.UpdatePeople(allPeople, chapter.People.Where(p => p.Role == PersonRole.Translator).Select(p => p.Name), PersonRole.Translator, person => PersonHelper.AddPersonIfNotExists(series.Metadata.People, person)); - TagHelper.UpdateTag(allTags, chapter.Tags.Select(t => t.Title), false, (tag, added) => + TagHelper.UpdateTag(allTags, chapter.Tags.Select(t => t.Title), false, (tag, _) => TagHelper.AddTagIfNotExists(series.Metadata.Tags, tag)); GenreHelper.UpdateGenre(allGenres, chapter.Genres.Select(t => t.Title), false, genre => @@ -821,7 +821,7 @@ public class ScannerService : IScannerService // Remove all tags that aren't matching between chapter tags and metadata TagHelper.KeepOnlySameTagBetweenLists(chapter.Tags, tags.Select(t => DbFactory.Tag(t, false)).ToList()); TagHelper.UpdateTag(allTags, tags, false, - (tag, added) => + (tag, _) => { chapter.Tags.Add(tag); }); diff --git a/API/Startup.cs b/API/Startup.cs index 00bfdd589..6fd0d2ff0 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -6,7 +6,6 @@ using System.Net; using System.Net.Sockets; using System.Threading.Tasks; using API.Data; -using API.Entities; using API.Extensions; using API.Middleware; using API.Services; @@ -21,10 +20,8 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.HttpOverrides; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.ResponseCompression; using Microsoft.AspNetCore.StaticFiles; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; diff --git a/Kavita.Common/AppSettingsConfig.cs b/Kavita.Common/AppSettingsConfig.cs deleted file mode 100644 index c7718b230..000000000 --- a/Kavita.Common/AppSettingsConfig.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Kavita.Common -{ - public class AppSettingsConfig - { - - } -} diff --git a/Kavita.Common/Configuration.cs b/Kavita.Common/Configuration.cs index 7593ae84a..55aa99598 100644 --- a/Kavita.Common/Configuration.cs +++ b/Kavita.Common/Configuration.cs @@ -329,7 +329,7 @@ namespace Kavita.Common } /// - /// This should NEVER be called except by + /// This should NEVER be called except by MigrateConfigFiles /// /// /// diff --git a/UI/Web/src/app/_models/member.ts b/UI/Web/src/app/_models/member.ts index 0b8f17423..e1ef5ea77 100644 --- a/UI/Web/src/app/_models/member.ts +++ b/UI/Web/src/app/_models/member.ts @@ -1,7 +1,9 @@ import { Library } from './library'; export interface Member { + id: number; username: string; + email: string; lastActive: string; // datetime created: string; // datetime isAdmin: boolean; diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index 7b6159b51..e19bcacf4 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -49,7 +49,7 @@ export class AccountService implements OnDestroy { return this.httpClient.get(this.baseUrl + 'account/roles'); } - login(model: any): Observable { + login(model: {username: string, password: string}): Observable { return this.httpClient.post(this.baseUrl + 'account/login', model).pipe( map((response: User) => { const user = response; @@ -91,25 +91,13 @@ export class AccountService implements OnDestroy { this.messageHub.stopHubConnection(); } - // setCurrentUser() { - // // TODO: Refactor this to setCurentUser in accoutnService - // const user = this.getUserFromLocalStorage(); - - - // if (user) { - // this.navService.setDarkMode(user.preferences.siteDarkMode); - // this.messageHub.createHubConnection(user, this.accountService.hasAdminRole(user)); - // this.libraryService.getLibraryNames().pipe(take(1)).subscribe(() => {/* No Operation */}); - // } else { - // this.navService.setDarkMode(true); - // } - // } - - register(model: {username: string, password: string, isAdmin?: boolean}) { - if (!model.hasOwnProperty('isAdmin')) { - model.isAdmin = false; - } + /** + * Registers the first admin on the account. Only used for that. All other registrations must occur through invite + * @param model + * @returns + */ + register(model: {username: string, password: string, email: string}) { return this.httpClient.post(this.baseUrl + 'account/register', model).pipe( map((user: User) => { return user; @@ -118,6 +106,26 @@ export class AccountService implements OnDestroy { ); } + migrateUser(model: {email: string, username: string, password: string, sendEmail: boolean}) { + return this.httpClient.post(this.baseUrl + 'account/migrate-email', model, {responseType: 'text' as 'json'}); + } + + confirmMigrationEmail(model: {email: string, token: string}) { + return this.httpClient.post(this.baseUrl + 'account/confirm-migration-email', model); + } + + resendConfirmationEmail(userId: number) { + return this.httpClient.post(this.baseUrl + 'account/resend-confirmation-email?userId=' + userId, {}, {responseType: 'text' as 'json'}); + } + + inviteUser(model: {email: string, roles: Array, libraries: Array, sendEmail: boolean}) { + return this.httpClient.post(this.baseUrl + 'account/invite', model, {responseType: 'text' as 'json'}); + } + + confirmEmail(model: {email: string, username: string, password: string, token: string}) { + return this.httpClient.post(this.baseUrl + 'account/confirm-email', model); + } + getDecodedToken(token: string) { return JSON.parse(atob(token.split('.')[1])); } @@ -126,6 +134,10 @@ export class AccountService implements OnDestroy { return this.httpClient.post(this.baseUrl + 'account/reset-password', {username, password}, {responseType: 'json' as 'text'}); } + update(model: {email: string, roles: Array, libraries: Array, userId: number}) { + return this.httpClient.post(this.baseUrl + 'account/update', model); + } + updatePreferences(userPreferences: Preferences) { return this.httpClient.post(this.baseUrl + 'users/update-preferences', userPreferences).pipe(map(settings => { if (this.currentUser !== undefined || this.currentUser != null) { diff --git a/UI/Web/src/app/_services/member.service.ts b/UI/Web/src/app/_services/member.service.ts index 3e59347f7..1c7820e8a 100644 --- a/UI/Web/src/app/_services/member.service.ts +++ b/UI/Web/src/app/_services/member.service.ts @@ -39,4 +39,8 @@ export class MemberService { updateMemberRoles(username: string, roles: string[]) { return this.httpClient.post(this.baseUrl + 'account/update-rbs', {username, roles}); } + + getPendingInvites() { + return this.httpClient.get>(this.baseUrl + 'users/pending'); + } } diff --git a/UI/Web/src/app/_services/server.service.ts b/UI/Web/src/app/_services/server.service.ts index 4538d17db..fcc94435c 100644 --- a/UI/Web/src/app/_services/server.service.ts +++ b/UI/Web/src/app/_services/server.service.ts @@ -36,4 +36,8 @@ export class ServerService { getChangelog() { return this.httpClient.get(this.baseUrl + 'server/changelog', {}); } + + isServerAccessible() { + return this.httpClient.get(this.baseUrl + 'server/accessible'); + } } diff --git a/UI/Web/src/app/admin/_modals/edit-rbs-modal/edit-rbs-modal.component.ts b/UI/Web/src/app/admin/_modals/edit-rbs-modal/edit-rbs-modal.component.ts index ff6346152..54f27f926 100644 --- a/UI/Web/src/app/admin/_modals/edit-rbs-modal/edit-rbs-modal.component.ts +++ b/UI/Web/src/app/admin/_modals/edit-rbs-modal/edit-rbs-modal.component.ts @@ -4,6 +4,8 @@ import { Member } from 'src/app/_models/member'; import { AccountService } from 'src/app/_services/account.service'; import { MemberService } from 'src/app/_services/member.service'; +// TODO: Remove this component, edit-user will take over + @Component({ selector: 'app-edit-rbs-modal', templateUrl: './edit-rbs-modal.component.html', diff --git a/UI/Web/src/app/admin/_modals/library-access-modal/library-access-modal.component.ts b/UI/Web/src/app/admin/_modals/library-access-modal/library-access-modal.component.ts index 4c7791b4d..50023b3e7 100644 --- a/UI/Web/src/app/admin/_modals/library-access-modal/library-access-modal.component.ts +++ b/UI/Web/src/app/admin/_modals/library-access-modal/library-access-modal.component.ts @@ -21,7 +21,6 @@ export class LibraryAccessModalComponent implements OnInit { isLoading: boolean = false; get hasSomeSelected() { - console.log(this.selections != null && this.selections.hasSomeSelected()); return this.selections != null && this.selections.hasSomeSelected(); } diff --git a/UI/Web/src/app/admin/admin.module.ts b/UI/Web/src/app/admin/admin.module.ts index 5ffadf3a1..49bbf1153 100644 --- a/UI/Web/src/app/admin/admin.module.ts +++ b/UI/Web/src/app/admin/admin.module.ts @@ -16,6 +16,10 @@ import { EditRbsModalComponent } from './_modals/edit-rbs-modal/edit-rbs-modal.c import { ManageSystemComponent } from './manage-system/manage-system.component'; import { ChangelogComponent } from './changelog/changelog.component'; import { PipeModule } from '../pipe/pipe.module'; +import { InviteUserComponent } from './invite-user/invite-user.component'; +import { RoleSelectorComponent } from './role-selector/role-selector.component'; +import { LibrarySelectorComponent } from './library-selector/library-selector.component'; +import { EditUserComponent } from './edit-user/edit-user.component'; @@ -33,6 +37,10 @@ import { PipeModule } from '../pipe/pipe.module'; EditRbsModalComponent, ManageSystemComponent, ChangelogComponent, + InviteUserComponent, + RoleSelectorComponent, + LibrarySelectorComponent, + EditUserComponent, ], imports: [ CommonModule, diff --git a/UI/Web/src/app/admin/edit-user/edit-user.component.html b/UI/Web/src/app/admin/edit-user/edit-user.component.html new file mode 100644 index 000000000..db7af4508 --- /dev/null +++ b/UI/Web/src/app/admin/edit-user/edit-user.component.html @@ -0,0 +1,58 @@ + + + diff --git a/UI/Web/src/app/admin/edit-user/edit-user.component.scss b/UI/Web/src/app/admin/edit-user/edit-user.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/UI/Web/src/app/admin/edit-user/edit-user.component.ts b/UI/Web/src/app/admin/edit-user/edit-user.component.ts new file mode 100644 index 000000000..ceec62cae --- /dev/null +++ b/UI/Web/src/app/admin/edit-user/edit-user.component.ts @@ -0,0 +1,62 @@ +import { Component, Input, OnInit } from '@angular/core'; +import { FormGroup, FormControl, Validators } from '@angular/forms'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { ConfirmService } from 'src/app/shared/confirm.service'; +import { Library } from 'src/app/_models/library'; +import { Member } from 'src/app/_models/member'; +import { AccountService } from 'src/app/_services/account.service'; +import { ServerService } from 'src/app/_services/server.service'; + +// TODO: Rename this to EditUserModal +@Component({ + selector: 'app-edit-user', + templateUrl: './edit-user.component.html', + styleUrls: ['./edit-user.component.scss'] +}) +export class EditUserComponent implements OnInit { + + @Input() member!: Member; + + selectedRoles: Array = []; + selectedLibraries: Array = []; + isSaving: boolean = false; + + userForm: FormGroup = new FormGroup({}); + + public get email() { return this.userForm.get('email'); } + public get username() { return this.userForm.get('username'); } + public get password() { return this.userForm.get('password'); } + + constructor(public modal: NgbActiveModal, private accountService: AccountService, private serverService: ServerService, + private confirmService: ConfirmService) { } + + ngOnInit(): void { + this.userForm.addControl('email', new FormControl(this.member.email, [Validators.required, Validators.email])); + this.userForm.addControl('username', new FormControl(this.member.username, [Validators.required])); + + this.userForm.get('email')?.disable(); + } + + updateRoleSelection(roles: Array) { + this.selectedRoles = roles; + } + + updateLibrarySelection(libraries: Array) { + this.selectedLibraries = libraries.map(l => l.id); + } + + close() { + this.modal.close(false); + } + + save() { + const model = this.userForm.getRawValue(); + model.userId = this.member.id; + model.roles = this.selectedRoles; + model.libraries = this.selectedLibraries; + this.accountService.update(model).subscribe(() => { + this.modal.close(true); + }); + } + +} diff --git a/UI/Web/src/app/admin/invite-user/invite-user.component.html b/UI/Web/src/app/admin/invite-user/invite-user.component.html new file mode 100644 index 000000000..41954f2b7 --- /dev/null +++ b/UI/Web/src/app/admin/invite-user/invite-user.component.html @@ -0,0 +1,56 @@ + + + diff --git a/UI/Web/src/app/admin/invite-user/invite-user.component.scss b/UI/Web/src/app/admin/invite-user/invite-user.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/UI/Web/src/app/admin/invite-user/invite-user.component.ts b/UI/Web/src/app/admin/invite-user/invite-user.component.ts new file mode 100644 index 000000000..e8cebcb19 --- /dev/null +++ b/UI/Web/src/app/admin/invite-user/invite-user.component.ts @@ -0,0 +1,80 @@ +import { Component, OnInit } from '@angular/core'; +import { FormControl, FormGroup, Validators } from '@angular/forms'; +import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { ConfirmService } from 'src/app/shared/confirm.service'; +import { Library } from 'src/app/_models/library'; +import { AccountService } from 'src/app/_services/account.service'; +import { ServerService } from 'src/app/_services/server.service'; + +@Component({ + selector: 'app-invite-user', + templateUrl: './invite-user.component.html', + styleUrls: ['./invite-user.component.scss'] +}) +export class InviteUserComponent implements OnInit { + + /** + * Maintains if the backend is sending an email + */ + isSending: boolean = false; + inviteForm: FormGroup = new FormGroup({}); + /** + * If a user would be able to load this server up externally + */ + accessible: boolean = true; + checkedAccessibility: boolean = false; + selectedRoles: Array = []; + selectedLibraries: Array = []; + emailLink: string = ''; + + public get email() { return this.inviteForm.get('email'); } + + constructor(public modal: NgbActiveModal, private accountService: AccountService, private serverService: ServerService, + private confirmService: ConfirmService) { } + + ngOnInit(): void { + this.inviteForm.addControl('email', new FormControl('', [Validators.required])); + + this.serverService.isServerAccessible().subscribe(async (accessibile) => { + if (!accessibile) { + await this.confirmService.alert('This server is not accessible outside the network. You cannot invite via Email. You wil be given a link to finish registration with instead.'); + this.accessible = accessibile; + } + this.checkedAccessibility = true; + }); + } + + close() { + this.modal.close(false); + } + + invite() { + + this.isSending = true; + const email = this.inviteForm.get('email')?.value; + this.accountService.inviteUser({ + email, + libraries: this.selectedLibraries, + roles: this.selectedRoles, + sendEmail: this.accessible + }).subscribe(email => { + this.emailLink = email; + this.isSending = false; + if (this.accessible) { + this.modal.close(true); + } + }, err => { + this.isSending = false; + }); + } + + updateRoleSelection(roles: Array) { + this.selectedRoles = roles; + } + + updateLibrarySelection(libraries: Array) { + this.selectedLibraries = libraries.map(l => l.id); + } + +} diff --git a/UI/Web/src/app/admin/library-selector/library-selector.component.html b/UI/Web/src/app/admin/library-selector/library-selector.component.html new file mode 100644 index 000000000..132ac1750 --- /dev/null +++ b/UI/Web/src/app/admin/library-selector/library-selector.component.html @@ -0,0 +1,20 @@ +

Libraries

+
+
+ + +
+
    +
  • +
    + + +
    +
  • +
  • + There are no libraries setup yet. +
  • +
+
\ No newline at end of file diff --git a/UI/Web/src/app/admin/library-selector/library-selector.component.scss b/UI/Web/src/app/admin/library-selector/library-selector.component.scss new file mode 100644 index 000000000..3f2adc8d1 --- /dev/null +++ b/UI/Web/src/app/admin/library-selector/library-selector.component.scss @@ -0,0 +1,3 @@ +.list-group-item { + border: none; +} \ No newline at end of file diff --git a/UI/Web/src/app/admin/library-selector/library-selector.component.ts b/UI/Web/src/app/admin/library-selector/library-selector.component.ts new file mode 100644 index 000000000..c7172ced6 --- /dev/null +++ b/UI/Web/src/app/admin/library-selector/library-selector.component.ts @@ -0,0 +1,71 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { FormBuilder } from '@angular/forms'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { SelectionModel } from 'src/app/typeahead/typeahead.component'; +import { Library } from 'src/app/_models/library'; +import { Member } from 'src/app/_models/member'; +import { LibraryService } from 'src/app/_services/library.service'; + +@Component({ + selector: 'app-library-selector', + templateUrl: './library-selector.component.html', + styleUrls: ['./library-selector.component.scss'] +}) +export class LibrarySelectorComponent implements OnInit { + + @Input() member: Member | undefined; + @Output() selected: EventEmitter> = new EventEmitter>(); + + allLibraries: Library[] = []; + selectedLibraries: Array<{selected: boolean, data: Library}> = []; + selections!: SelectionModel; + selectAll: boolean = false; + isLoading: boolean = false; + + get hasSomeSelected() { + return this.selections != null && this.selections.hasSomeSelected(); + } + + constructor(private libraryService: LibraryService, private fb: FormBuilder) { } + + ngOnInit(): void { + this.libraryService.getLibraries().subscribe(libs => { + this.allLibraries = libs; + this.setupSelections(); + }); + } + + + setupSelections() { + this.selections = new SelectionModel(false, this.allLibraries); + this.isLoading = false; + + // If a member is passed in, then auto-select their libraries + if (this.member !== undefined) { + this.member.libraries.forEach(lib => { + this.selections.toggle(lib, true, (a, b) => a.name === b.name); + }); + this.selectAll = this.selections.selected().length === this.allLibraries.length; + this.selected.emit(this.selections.selected()); + } + } + + toggleAll() { + this.selectAll = !this.selectAll; + this.allLibraries.forEach(s => this.selections.toggle(s, this.selectAll)); + this.selected.emit(this.selections.selected()); + } + + handleSelection(item: Library) { + this.selections.toggle(item); + const numberOfSelected = this.selections.selected().length; + if (numberOfSelected == 0) { + this.selectAll = false; + } else if (numberOfSelected == this.selectedLibraries.length) { + this.selectAll = true; + } + + this.selected.emit(this.selections.selected()); + } + +} 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 5b6ce57b9..76cdd8747 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 @@ -1,19 +1,53 @@
-
-

Users

-
-
-
    + +
    +

    Pending Invites

    +
    +
    +
      +
    • +
      +

      + {{invite.username | titlecase}} +
      + + +
      +

      + +
      Invited: {{invite.created | date: 'short'}}
      +
      +
    • +
    • +
      + +
      +
    • +
    • + There are no invited Users +
    • +
    +
    + + + +

    Active Users

    +
    • - {{member.username | titlecase}} (You) + + {{member.username | titlecase}} + + + (You) +
      - +

      Last Active: @@ -44,7 +78,4 @@ There are no other users.
    - - -
\ No newline at end of file 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 c1982f130..1dc25ce53 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 @@ -5,13 +5,15 @@ import { MemberService } from 'src/app/_services/member.service'; import { Member } from 'src/app/_models/member'; import { User } from 'src/app/_models/user'; import { AccountService } from 'src/app/_services/account.service'; -import { LibraryAccessModalComponent } from '../_modals/library-access-modal/library-access-modal.component'; import { ToastrService } from 'ngx-toastr'; import { ResetPasswordModalComponent } from '../_modals/reset-password-modal/reset-password-modal.component'; import { ConfirmService } from 'src/app/shared/confirm.service'; import { EditRbsModalComponent } from '../_modals/edit-rbs-modal/edit-rbs-modal.component'; import { Subject } from 'rxjs'; import { MessageHubService } from 'src/app/_services/message-hub.service'; +import { InviteUserComponent } from '../invite-user/invite-user.component'; +import { EditUserComponent } from '../edit-user/edit-user.component'; +import { ServerService } from 'src/app/_services/server.service'; @Component({ selector: 'app-manage-users', @@ -21,10 +23,8 @@ import { MessageHubService } from 'src/app/_services/message-hub.service'; export class ManageUsersComponent implements OnInit, OnDestroy { members: Member[] = []; + pendingInvites: Member[] = []; loggedInUsername = ''; - - // Create User functionality - createMemberToggle = false; loadingMembers = false; private onDestroy = new Subject(); @@ -34,7 +34,8 @@ export class ManageUsersComponent implements OnInit, OnDestroy { private modalService: NgbModal, private toastr: ToastrService, private confirmService: ConfirmService, - public messageHub: MessageHubService) { + public messageHub: MessageHubService, + private serverService: ServerService) { this.accountService.currentUser$.pipe(take(1)).subscribe((user: User) => { this.loggedInUsername = user.username; }); @@ -43,6 +44,8 @@ export class ManageUsersComponent implements OnInit, OnDestroy { ngOnInit(): void { this.loadMembers(); + + this.loadPendingInvites(); } ngOnDestroy() { @@ -69,31 +72,41 @@ export class ManageUsersComponent implements OnInit, OnDestroy { }); } + loadPendingInvites() { + this.memberService.getPendingInvites().subscribe(members => { + this.pendingInvites = members; + // Show logged in user at the top of the list + this.pendingInvites.sort((a: Member, b: Member) => { + if (a.username === this.loggedInUsername) return 1; + if (b.username === this.loggedInUsername) return 1; + + const nameA = a.username.toUpperCase(); + const nameB = b.username.toUpperCase(); + if (nameA < nameB) return -1; + if (nameA > nameB) return 1; + return 0; + }) + }); + } + canEditMember(member: Member): boolean { return this.loggedInUsername !== member.username; } - createMember() { - this.createMemberToggle = true; - } - - onMemberCreated(createdUser: User | null) { - this.createMemberToggle = false; - this.loadMembers(); - } - - openEditLibraryAccess(member: Member) { - const modalRef = this.modalService.open(LibraryAccessModalComponent); + openEditUser(member: Member) { + const modalRef = this.modalService.open(EditUserComponent, {size: 'lg'}); modalRef.componentInstance.member = member; modalRef.closed.subscribe(() => { this.loadMembers(); }); } + async deleteUser(member: Member) { if (await this.confirmService.confirm('Are you sure you want to delete this user?')) { this.memberService.deleteMember(member.username).subscribe(() => { this.loadMembers(); + this.loadPendingInvites(); this.toastr.success(member.username + ' has been deleted.'); }); } @@ -106,7 +119,32 @@ export class ManageUsersComponent implements OnInit, OnDestroy { if (updatedMember !== undefined) { member = updatedMember; } - }) + }); + } + + inviteUser() { + const modalRef = this.modalService.open(InviteUserComponent, {size: 'lg'}); + modalRef.closed.subscribe((successful: boolean) => { + if (successful) { + this.loadPendingInvites(); + } + }); + } + + resendEmail(member: Member) { + + this.serverService.isServerAccessible().subscribe(canAccess => { + this.accountService.resendConfirmationEmail(member.id).subscribe(async (email) => { + if (canAccess) { + this.toastr.info('Email sent to ' + member.username); + return; + } + await this.confirmService.alert( + 'Please click this link to confirm your email. You must confirm to be able to login. You may need to log out of the current account before clicking.
' + email + ''); + + }); + }); + } updatePassword(member: Member) { diff --git a/UI/Web/src/app/admin/role-selector/role-selector.component.html b/UI/Web/src/app/admin/role-selector/role-selector.component.html new file mode 100644 index 000000000..1eb806aab --- /dev/null +++ b/UI/Web/src/app/admin/role-selector/role-selector.component.html @@ -0,0 +1,10 @@ +

Roles

+
    +
  • +
    + + +
    +
  • +
\ No newline at end of file diff --git a/UI/Web/src/app/admin/role-selector/role-selector.component.scss b/UI/Web/src/app/admin/role-selector/role-selector.component.scss new file mode 100644 index 000000000..3f2adc8d1 --- /dev/null +++ b/UI/Web/src/app/admin/role-selector/role-selector.component.scss @@ -0,0 +1,3 @@ +.list-group-item { + border: none; +} \ No newline at end of file diff --git a/UI/Web/src/app/admin/role-selector/role-selector.component.ts b/UI/Web/src/app/admin/role-selector/role-selector.component.ts new file mode 100644 index 000000000..79b04f70d --- /dev/null +++ b/UI/Web/src/app/admin/role-selector/role-selector.component.ts @@ -0,0 +1,57 @@ +import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { Member } from 'src/app/_models/member'; +import { AccountService } from 'src/app/_services/account.service'; +import { MemberService } from 'src/app/_services/member.service'; + +@Component({ + selector: 'app-role-selector', + templateUrl: './role-selector.component.html', + styleUrls: ['./role-selector.component.scss'] +}) +export class RoleSelectorComponent implements OnInit { + + @Input() member: Member | undefined; + /** + * Allows the selection of Admin role + */ + @Input() allowAdmin: boolean = false; + @Output() selected: EventEmitter = new EventEmitter(); + + allRoles: string[] = []; + selectedRoles: Array<{selected: boolean, data: string}> = []; + + constructor(public modal: NgbActiveModal, private accountService: AccountService, private memberService: MemberService) { } + + ngOnInit(): void { + this.accountService.getRoles().subscribe(roles => { + let bannedRoles = ['Pleb']; + if (!this.allowAdmin) { + bannedRoles.push('Admin'); + } + roles = roles.filter(item => !bannedRoles.includes(item)); + this.allRoles = roles; + this.selectedRoles = roles.map(item => { + return {selected: false, data: item}; + }); + this.selected.emit(this.selectedRoles.filter(item => item.selected).map(item => item.data)); + this.preselect(); + }); + } + + preselect() { + if (this.member !== undefined) { + this.member.roles.forEach(role => { + const foundRole = this.selectedRoles.filter(item => item.data === role); + if (foundRole.length > 0) { + foundRole[0].selected = true; + } + }); + } + } + + handleModelUpdate() { + this.selected.emit(this.selectedRoles.filter(item => item.selected).map(item => item.data)); + } + +} diff --git a/UI/Web/src/app/app-routing.module.ts b/UI/Web/src/app/app-routing.module.ts index 7f8bd88f7..87eb0cfba 100644 --- a/UI/Web/src/app/app-routing.module.ts +++ b/UI/Web/src/app/app-routing.module.ts @@ -65,7 +65,11 @@ const routes: Routes = [ ] }, - {path: 'login', component: UserLoginComponent}, + { + path: 'registration', + loadChildren: () => import('../app/registration/registration.module').then(m => m.RegistrationModule) + }, + {path: 'login', component: UserLoginComponent}, // TODO: move this to registration module {path: 'no-connection', component: NotConnectedComponent}, {path: '**', component: UserLoginComponent, pathMatch: 'full'} ]; diff --git a/UI/Web/src/app/nav-header/nav-header.component.html b/UI/Web/src/app/nav-header/nav-header.component.html index d2e99de03..f12c58494 100644 --- a/UI/Web/src/app/nav-header/nav-header.component.html +++ b/UI/Web/src/app/nav-header/nav-header.component.html @@ -1,7 +1,7 @@