Disable Authentication & Login Page Rework (#619)

* Implemented the ability to disable authentication on a server instance. Admins will require authentication, but non-admin accounts can be setup without any password requirements.

* WIP for new login page.

* Reworked code to handle disabled auth better. First time user flow is moved into the user login component.

* Removed debug code

* Removed home component, shakeout testing is complete.

* remove a file accidently committed

* Fixed a code smell from last PR

* Code smells
This commit is contained in:
Joseph Milazzo 2021-10-02 09:23:58 -07:00 committed by GitHub
parent 83d76982f4
commit a5b6bf1b52
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 376 additions and 174 deletions

View File

@ -7,10 +7,10 @@ using API.Constants;
using API.DTOs;
using API.DTOs.Account;
using API.Entities;
using API.Errors;
using API.Extensions;
using API.Interfaces;
using API.Interfaces.Services;
using API.Services;
using AutoMapper;
using Kavita.Common;
using Microsoft.AspNetCore.Identity;
@ -31,13 +31,14 @@ namespace API.Controllers
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<AccountController> _logger;
private readonly IMapper _mapper;
private readonly IAccountService _accountService;
/// <inheritdoc />
public AccountController(UserManager<AppUser> userManager,
SignInManager<AppUser> signInManager,
ITokenService tokenService, IUnitOfWork unitOfWork,
ILogger<AccountController> logger,
IMapper mapper)
IMapper mapper, IAccountService accountService)
{
_userManager = userManager;
_signInManager = signInManager;
@ -45,6 +46,7 @@ namespace API.Controllers
_unitOfWork = unitOfWork;
_logger = logger;
_mapper = mapper;
_accountService = accountService;
}
/// <summary>
@ -61,30 +63,10 @@ namespace API.Controllers
if (resetPasswordDto.UserName != User.GetUsername() && !User.IsInRole(PolicyConstants.AdminRole))
return Unauthorized("You are not permitted to this operation.");
// Validate Password
foreach (var validator in _userManager.PasswordValidators)
var errors = await _accountService.ChangeUserPassword(user, resetPasswordDto.Password);
if (errors.Any())
{
var validationResult = await validator.ValidateAsync(_userManager, user, resetPasswordDto.Password);
if (!validationResult.Succeeded)
{
return BadRequest(
validationResult.Errors.Select(e => new ApiException(400, e.Code, e.Description)));
}
}
var result = await _userManager.RemovePasswordAsync(user);
if (!result.Succeeded)
{
_logger.LogError("Could not update password");
return BadRequest(result.Errors.Select(e => new ApiException(400, e.Code, e.Description)));
}
result = await _userManager.AddPasswordAsync(user, resetPasswordDto.Password);
if (!result.Succeeded)
{
_logger.LogError("Could not update password");
return BadRequest(result.Errors.Select(e => new ApiException(400, e.Code, e.Description)));
return BadRequest(errors);
}
_logger.LogInformation("{User}'s Password has been reset", resetPasswordDto.UserName);
@ -110,6 +92,13 @@ namespace API.Controllers
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;
}
var result = await _userManager.CreateAsync(user, registerDto.Password);
if (!result.Succeeded) return BadRequest(result.Errors);
@ -166,6 +155,14 @@ namespace API.Controllers
if (user == null) return Unauthorized("Invalid username");
var isAdmin = await _unitOfWork.UserRepository.IsUserAdmin(user);
var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
if (!settings.EnableAuthentication && !isAdmin)
{
_logger.LogDebug("User {UserName} is logging in with authentication disabled", loginDto.Username);
loginDto.Password = AccountService.DefaultPassword;
}
var result = await _signInManager
.CheckPasswordSignInAsync(user, loginDto.Password, false);

View File

@ -2,13 +2,11 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Constants;
using API.DTOs;
using API.Entities;
using API.Extensions;
using API.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace API.Controllers
@ -19,13 +17,11 @@ namespace API.Controllers
public class CollectionController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly UserManager<AppUser> _userManager;
/// <inheritdoc />
public CollectionController(IUnitOfWork unitOfWork, UserManager<AppUser> userManager)
public CollectionController(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
_userManager = userManager;
}
/// <summary>
@ -36,7 +32,7 @@ namespace API.Controllers
public async Task<IEnumerable<CollectionTagDto>> GetAllTags()
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
var isAdmin = await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole);
var isAdmin = await _unitOfWork.UserRepository.IsUserAdmin(user);
if (isAdmin)
{
return await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync();

View File

@ -26,7 +26,6 @@ namespace API.Controllers
private readonly IUnitOfWork _unitOfWork;
private readonly IDownloadService _downloadService;
private readonly IDirectoryService _directoryService;
private readonly UserManager<AppUser> _userManager;
private readonly ICacheService _cacheService;
private readonly IReaderService _readerService;
@ -41,13 +40,12 @@ namespace API.Controllers
private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer();
public OpdsController(IUnitOfWork unitOfWork, IDownloadService downloadService,
IDirectoryService directoryService, UserManager<AppUser> userManager,
ICacheService cacheService, IReaderService readerService)
IDirectoryService directoryService, ICacheService cacheService,
IReaderService readerService)
{
_unitOfWork = unitOfWork;
_downloadService = downloadService;
_directoryService = directoryService;
_userManager = userManager;
_cacheService = cacheService;
_readerService = readerService;
@ -170,7 +168,7 @@ namespace API.Controllers
return BadRequest("OPDS is not enabled on this server");
var userId = await GetUser(apiKey);
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
var isAdmin = await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole);
var isAdmin = await _unitOfWork.UserRepository.IsUserAdmin(user);
IEnumerable <CollectionTagDto> tags;
if (isAdmin)
@ -213,7 +211,7 @@ namespace API.Controllers
return BadRequest("OPDS is not enabled on this server");
var userId = await GetUser(apiKey);
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
var isAdmin = await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole);
var isAdmin = await _unitOfWork.UserRepository.IsUserAdmin(user);
IEnumerable <CollectionTagDto> tags;
if (isAdmin)

View File

@ -8,6 +8,8 @@ using API.Entities.Enums;
using API.Extensions;
using API.Helpers.Converters;
using API.Interfaces;
using API.Interfaces.Services;
using API.Services;
using Kavita.Common;
using Kavita.Common.Extensions;
using Microsoft.AspNetCore.Authorization;
@ -21,12 +23,14 @@ namespace API.Controllers
private readonly ILogger<SettingsController> _logger;
private readonly IUnitOfWork _unitOfWork;
private readonly ITaskScheduler _taskScheduler;
private readonly IAccountService _accountService;
public SettingsController(ILogger<SettingsController> logger, IUnitOfWork unitOfWork, ITaskScheduler taskScheduler)
public SettingsController(ILogger<SettingsController> logger, IUnitOfWork unitOfWork, ITaskScheduler taskScheduler, IAccountService accountService)
{
_logger = logger;
_unitOfWork = unitOfWork;
_taskScheduler = taskScheduler;
_accountService = accountService;
}
[Authorize(Policy = "RequireAdminRole")]
@ -57,6 +61,7 @@ namespace API.Controllers
// We do not allow CacheDirectory changes, so we will ignore.
var currentSettings = await _unitOfWork.SettingsRepository.GetSettingsAsync();
var updateAuthentication = false;
foreach (var setting in currentSettings)
{
@ -93,6 +98,13 @@ namespace API.Controllers
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.EnableAuthentication && updateSettingsDto.EnableAuthentication + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.EnableAuthentication + string.Empty;
_unitOfWork.SettingsRepository.Update(setting);
updateAuthentication = true;
}
if (setting.Key == ServerSettingKey.AllowStatCollection && updateSettingsDto.AllowStatCollection + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.AllowStatCollection + string.Empty;
@ -110,12 +122,33 @@ namespace API.Controllers
if (!_unitOfWork.HasChanges()) return Ok("Nothing was updated");
if (!_unitOfWork.HasChanges() || !await _unitOfWork.CommitAsync())
try
{
await _unitOfWork.CommitAsync();
if (updateAuthentication)
{
var users = await _unitOfWork.UserRepository.GetNonAdminUsersAsync();
foreach (var user in users)
{
var errors = await _accountService.ChangeUserPassword(user, AccountService.DefaultPassword);
if (!errors.Any()) continue;
await _unitOfWork.RollbackAsync();
return BadRequest(errors);
}
_logger.LogInformation("Server authentication changed. Updated all non-admins to default password");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an exception when updating server settings");
await _unitOfWork.RollbackAsync();
return BadRequest("There was a critical issue. Please try again.");
}
_logger.LogInformation("Server Settings updated");
_taskScheduler.ScheduleTasks();
return Ok(updateSettingsDto);
@ -148,5 +181,12 @@ namespace API.Controllers
var settingsDto = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
return Ok(settingsDto.EnableOpds);
}
[HttpGet("authentication-enabled")]
public async Task<ActionResult<bool>> GetAuthenticationEnabled()
{
var settingsDto = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync();
return Ok(settingsDto.EnableAuthentication);
}
}
}

View File

@ -10,7 +10,6 @@ using Microsoft.AspNetCore.Mvc;
namespace API.Controllers
{
[Authorize]
public class UsersController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
@ -39,6 +38,15 @@ namespace API.Controllers
return Ok(await _unitOfWork.UserRepository.GetMembersAsync());
}
[AllowAnonymous]
[HttpGet("names")]
public async Task<ActionResult<IEnumerable<MemberDto>>> GetUserNames()
{
var members = await _unitOfWork.UserRepository.GetMembersAsync();
return Ok(members.Select(m => m.Username));
}
[Authorize]
[HttpGet("has-reading-progress")]
public async Task<ActionResult<bool>> HasReadingProgress(int libraryId)
{
@ -47,6 +55,7 @@ namespace API.Controllers
return Ok(await _unitOfWork.AppUserProgressRepository.UserHasProgress(library.Type, userId));
}
[Authorize]
[HttpGet("has-library-access")]
public async Task<ActionResult<bool>> HasLibraryAccess(int libraryId)
{
@ -54,6 +63,7 @@ namespace API.Controllers
return Ok(libs.Any(x => x.Id == libraryId));
}
[Authorize]
[HttpPost("update-preferences")]
public async Task<ActionResult<UserPreferencesDto>> UpdatePreferences(UserPreferencesDto preferencesDto)
{

View File

@ -1,8 +1,8 @@
namespace API.DTOs
namespace API.DTOs.Account
{
public class LoginDto
{
public string Username { get; init; }
public string Password { get; init; }
public string Password { get; set; }
}
}
}

View File

@ -8,7 +8,7 @@ namespace API.DTOs
public string Username { get; init; }
[Required]
[StringLength(32, MinimumLength = 6)]
public string Password { get; init; }
public string Password { get; set; }
public bool IsAdmin { get; init; }
}
}
}

View File

@ -21,5 +21,10 @@
/// Enables OPDS connections to be made to the server.
/// </summary>
public bool EnableOpds { get; set; }
/// <summary>
/// Enables Authentication on the server. Defaults to true.
/// </summary>
public bool EnableAuthentication { get; set; }
}
}

View File

@ -153,6 +153,16 @@ namespace API.Data.Repositories
return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole);
}
public async Task<IEnumerable<AppUser>> GetNonAdminUsersAsync()
{
return await _userManager.GetUsersInRoleAsync(PolicyConstants.PlebRole);
}
public async Task<bool> IsUserAdmin(AppUser user)
{
return await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole);
}
public async Task<AppUserRating> GetUserRating(int seriesId, int userId)
{
return await _context.AppUserRating.Where(r => r.SeriesId == seriesId && r.AppUserId == userId)

View File

@ -49,6 +49,7 @@ namespace API.Data
new () {Key = ServerSettingKey.Port, Value = "5000"}, // Not used from DB, but DB is sync with appSettings.json
new () {Key = ServerSettingKey.AllowStatCollection, Value = "true"},
new () {Key = ServerSettingKey.EnableOpds, Value = "false"},
new () {Key = ServerSettingKey.EnableAuthentication, Value = "true"},
};
foreach (var defaultSetting in defaultSettings)

View File

@ -20,6 +20,8 @@ namespace API.Entities.Enums
AllowStatCollection = 6,
[Description("EnableOpds")]
EnableOpds = 7,
[Description("EnableAuthentication")]
EnableAuthentication = 8
}
}

View File

@ -36,6 +36,7 @@ namespace API.Extensions
services.AddScoped<IVersionUpdaterService, VersionUpdaterService>();
services.AddScoped<IDownloadService, DownloadService>();
services.AddScoped<IReaderService, ReaderService>();
services.AddScoped<IAccountService, AccountService>();
services.AddScoped<IPresenceTracker, PresenceTracker>();

View File

@ -36,6 +36,9 @@ namespace API.Helpers.Converters
case ServerSettingKey.EnableOpds:
destination.EnableOpds = bool.Parse(row.Value);
break;
case ServerSettingKey.EnableAuthentication:
destination.EnableAuthentication = bool.Parse(row.Value);
break;
}
}

View File

@ -15,6 +15,8 @@ namespace API.Interfaces.Repositories
public void Delete(AppUser user);
Task<IEnumerable<MemberDto>> GetMembersAsync();
Task<IEnumerable<AppUser>> GetAdminUsersAsync();
Task<IEnumerable<AppUser>> GetNonAdminUsersAsync();
Task<bool> IsUserAdmin(AppUser user);
Task<AppUserRating> GetUserRating(int seriesId, int userId);
Task<AppUserPreferences> GetPreferencesAsync(string username);
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForSeries(int userId, int seriesId);

View File

@ -0,0 +1,12 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using API.Entities;
using API.Errors;
namespace API.Interfaces.Services
{
public interface IAccountService
{
Task<IEnumerable<ApiException>> ChangeUserPassword(AppUser user, string newPassword);
}
}

View File

@ -0,0 +1,53 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Entities;
using API.Errors;
using API.Interfaces.Services;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
namespace API.Services
{
public class AccountService : IAccountService
{
private readonly UserManager<AppUser> _userManager;
private readonly ILogger<AccountService> _logger;
public const string DefaultPassword = "[k.2@RZ!mxCQkJzE";
public AccountService(UserManager<AppUser> userManager, ILogger<AccountService> logger)
{
_userManager = userManager;
_logger = logger;
}
public async Task<IEnumerable<ApiException>> 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 result = await _userManager.RemovePasswordAsync(user);
if (!result.Succeeded)
{
_logger.LogError("Could not update password");
return result.Errors.Select(e => new ApiException(400, e.Code, e.Description));
}
result = await _userManager.AddPasswordAsync(user, newPassword);
if (!result.Succeeded)
{
_logger.LogError("Could not update password");
return result.Errors.Select(e => new ApiException(400, e.Code, e.Description));
}
return new List<ApiException>();
}
}
}

View File

@ -78,7 +78,7 @@ namespace API
Id = "Bearer"
}
},
new string[] { }
Array.Empty<string>()
}
});

View File

@ -16,6 +16,10 @@ export class MemberService {
return this.httpClient.get<Member[]>(this.baseUrl + 'users');
}
getMemberNames() {
return this.httpClient.get<string[]>(this.baseUrl + 'users/names');
}
adminExists() {
return this.httpClient.get<boolean>(this.baseUrl + 'admin/exists');
}

View File

@ -131,7 +131,9 @@ export class MessageHubService {
}
stopHubConnection() {
this.hubConnection.stop().catch(err => console.error(err));
if (this.hubConnection) {
this.hubConnection.stop().catch(err => console.error(err));
}
}
sendMessage(methodName: string, body?: any) {

View File

@ -6,4 +6,5 @@ export interface ServerSettings {
port: number;
allowStatCollection: boolean;
enableOpds: boolean;
enableAuthentication: boolean;
}

View File

@ -42,6 +42,15 @@
</div>
</div>
<div class="form-group">
<label for="authentication" aria-describedby="authentication-info">Authentication</label>
<p class="accent" id="authentication-info">By disabling authentication, all non-admin users will be able to login by just their username. No password will be required to authenticate.</p>
<div class="form-check">
<input id="authentication" type="checkbox" aria-label="User Authentication" class="form-check-input" formControlName="enableAuthentication">
<label for="authentication" class="form-check-label">Enable Authentication</label>
</div>
</div>
<h4>Reoccuring Tasks</h4>
<div class="form-group">
<label for="settings-tasks-scan">Library Scan</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="taskScanTooltip" role="button" tabindex="0"></i>

View File

@ -2,6 +2,7 @@ import { Component, OnInit } from '@angular/core';
import { FormGroup, FormControl, Validators } from '@angular/forms';
import { ToastrService } from 'ngx-toastr';
import { take } from 'rxjs/operators';
import { ConfirmService } from 'src/app/shared/confirm.service';
import { SettingsService } from '../settings.service';
import { ServerSettings } from '../_models/server-settings';
@ -17,7 +18,7 @@ export class ManageSettingsComponent implements OnInit {
taskFrequencies: Array<string> = [];
logLevels: Array<string> = [];
constructor(private settingsService: SettingsService, private toastr: ToastrService) { }
constructor(private settingsService: SettingsService, private toastr: ToastrService, private confirmService: ConfirmService) { }
ngOnInit(): void {
this.settingsService.getTaskFrequencies().pipe(take(1)).subscribe(frequencies => {
@ -35,6 +36,7 @@ export class ManageSettingsComponent implements OnInit {
this.settingsForm.addControl('loggingLevel', new FormControl(this.serverSettings.loggingLevel, [Validators.required]));
this.settingsForm.addControl('allowStatCollection', new FormControl(this.serverSettings.allowStatCollection, [Validators.required]));
this.settingsForm.addControl('enableOpds', new FormControl(this.serverSettings.enableOpds, [Validators.required]));
this.settingsForm.addControl('enableAuthentication', new FormControl(this.serverSettings.enableAuthentication, [Validators.required]));
});
}
@ -46,15 +48,28 @@ export class ManageSettingsComponent implements OnInit {
this.settingsForm.get('loggingLevel')?.setValue(this.serverSettings.loggingLevel);
this.settingsForm.get('allowStatCollection')?.setValue(this.serverSettings.allowStatCollection);
this.settingsForm.get('enableOpds')?.setValue(this.serverSettings.enableOpds);
this.settingsForm.get('enableAuthentication')?.setValue(this.serverSettings.enableAuthentication);
}
saveSettings() {
async saveSettings() {
const modelSettings = this.settingsForm.value;
this.settingsService.updateServerSettings(modelSettings).pipe(take(1)).subscribe((settings: ServerSettings) => {
if (this.settingsForm.get('enableAuthentication')?.value === false) {
if (!await this.confirmService.confirm('Disabling Authentication opens your server up to unauthorized access and possible hacking. Are you sure you want to continue with this?')) {
return;
}
}
const informUserAfterAuthenticationEnabled = this.settingsForm.get('enableAuthentication')?.value && !this.serverSettings.enableAuthentication;
this.settingsService.updateServerSettings(modelSettings).pipe(take(1)).subscribe(async (settings: ServerSettings) => {
this.serverSettings = settings;
this.resetForm();
this.toastr.success('Server settings updated');
if (informUserAfterAuthenticationEnabled) {
await this.confirmService.alert('You have just re-enabled authentication. All non-admin users have been re-assigned a password of "[k.2@RZ!mxCQkJzE". This is a publicly known password. Please change their users passwords or request them to.');
}
}, (err: any) => {
console.error('error: ', err);
});

View File

@ -77,7 +77,7 @@ export class ManageUsersComponent implements OnInit, OnDestroy {
this.createMemberToggle = true;
}
onMemberCreated(success: boolean) {
onMemberCreated(createdUser: User | null) {
this.createMemberToggle = false;
this.loadMembers();
}

View File

@ -35,4 +35,8 @@ export class SettingsService {
getOpdsEnabled() {
return this.http.get<boolean>(this.baseUrl + 'settings/opds-enabled', {responseType: 'text' as 'json'});
}
getAuthenticationEnabled() {
return this.http.get<boolean>(this.baseUrl + 'settings/authentication-enabled', {responseType: 'text' as 'json'});
}
}

View File

@ -1,8 +1,6 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { HomeComponent } from './home/home.component';
import { LibraryDetailComponent } from './library-detail/library-detail.component';
import { LibraryComponent } from './library/library.component';
import { NotConnectedComponent } from './not-connected/not-connected.component';
import { SeriesDetailComponent } from './series-detail/series-detail.component';
import { RecentlyAddedComponent } from './recently-added/recently-added.component';
@ -10,13 +8,12 @@ import { UserLoginComponent } from './user-login/user-login.component';
import { AuthGuard } from './_guards/auth.guard';
import { LibraryAccessGuard } from './_guards/library-access.guard';
import { InProgressComponent } from './in-progress/in-progress.component';
import { DashboardComponent as AdminDashboardComponent } from './admin/dashboard/dashboard.component';
import { DashboardComponent } from './dashboard/dashboard.component';
// TODO: Once we modularize the components, use this and measure performance impact: https://angular.io/guide/lazy-loading-ngmodules#preloading-modules
const routes: Routes = [
{path: '', component: HomeComponent},
{path: '', component: UserLoginComponent},
{
path: 'admin',
loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule)
@ -62,7 +59,7 @@ const routes: Routes = [
},
{path: 'login', component: UserLoginComponent},
{path: 'no-connection', component: NotConnectedComponent},
{path: '**', component: HomeComponent, pathMatch: 'full'}
{path: '**', component: UserLoginComponent, pathMatch: 'full'}
];
@NgModule({

View File

@ -4,10 +4,9 @@ import { APP_INITIALIZER, ErrorHandler, NgModule } from '@angular/core';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { HomeComponent } from './home/home.component';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { NgbDropdownModule, NgbNavModule, NgbPaginationModule, NgbRatingModule } from '@ng-bootstrap/ng-bootstrap';
import { NgbCollapseModule, NgbDropdownModule, NgbNavModule, NgbPaginationModule, NgbRatingModule } from '@ng-bootstrap/ng-bootstrap';
import { NavHeaderComponent } from './nav-header/nav-header.component';
import { JwtInterceptor } from './_interceptors/jwt.interceptor';
import { UserLoginComponent } from './user-login/user-login.component';
@ -87,7 +86,6 @@ if (environment.production) {
@NgModule({
declarations: [
AppComponent,
HomeComponent,
NavHeaderComponent,
UserLoginComponent,
LibraryComponent,
@ -114,6 +112,8 @@ if (environment.production) {
NgbNavModule,
NgbPaginationModule,
NgbCollapseModule, // Login
SharedModule,
CarouselModule,
TypeaheadModule,

View File

@ -1,11 +0,0 @@
<div class="container">
<ng-container *ngIf="firstTimeFlow">
<p>Please create an admin account for yourself to start your reading journey.</p>
<app-register-member (created)="onAdminCreated($event)" [firstTimeFlow]="firstTimeFlow"></app-register-member>
</ng-container>
</div>

View File

@ -1,52 +0,0 @@
import { Component, OnInit } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { take } from 'rxjs/operators';
import { MemberService } from '../_services/member.service';
import { AccountService } from '../_services/account.service';
import { Title } from '@angular/platform-browser';
@Component({
selector: 'app-home',
templateUrl: './home.component.html',
styleUrls: ['./home.component.scss']
})
export class HomeComponent implements OnInit {
firstTimeFlow = false;
model: any = {};
registerForm: FormGroup = new FormGroup({
username: new FormControl('', [Validators.required]),
password: new FormControl('', [Validators.required])
});
constructor(public accountService: AccountService, private memberService: MemberService, private router: Router, private titleService: Title) {
}
ngOnInit(): void {
this.memberService.adminExists().subscribe(adminExists => {
this.firstTimeFlow = !adminExists;
if (this.firstTimeFlow) {
return;
}
this.titleService.setTitle('Kavita');
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (user) {
this.router.navigateByUrl('/library');
} else {
this.router.navigateByUrl('/login');
}
});
});
}
onAdminCreated(success: boolean) {
if (success) {
this.router.navigateByUrl('/login');
}
}
}

View File

@ -325,7 +325,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
});
} else {
// If no user, we can't render
this.router.navigateByUrl('/home');
this.router.navigateByUrl('/login');
}
});

View File

@ -1,3 +1,4 @@
<div class="text-danger" *ngIf="errors.length > 0">
<p>Errors:</p>
<ul>
@ -10,7 +11,7 @@
<input id="username" class="form-control" formControlName="username" type="text">
</div>
<div class="form-group">
<div class="form-group" *ngIf="registerForm.get('isAdmin')?.value || !authDisabled">
<label for="password">Password</label>
<input id="password" class="form-control" formControlName="password" type="password">
</div>
@ -21,7 +22,7 @@
</div>
<div class="float-right">
<button class="btn btn-secondary mr-2" type="button" (click)="cancel()">Cancel</button>
<button class="btn btn-primary" type="submit">Register</button>
<button class="btn btn-secondary mr-2" type="button" (click)="cancel()" *ngIf="!firstTimeFlow">Cancel</button>
<button class="btn btn-primary {{firstTimeFlow ? 'alt' : ''}}" type="submit">Register</button>
</div>
</form>
</form>

View File

@ -0,0 +1,13 @@
.alt {
background-color: #424c72;
border-color: #444f75;
}
.alt:hover {
background-color: #3b4466;
}
.alt:focus {
background-color: #343c59;
box-shadow: 0 0 0 0.2rem rgb(68 79 117 / 50%);
}

View File

@ -1,6 +1,9 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { FormGroup, FormControl, Validators } from '@angular/forms';
import { take } from 'rxjs/operators';
import { AccountService } from 'src/app/_services/account.service';
import { SettingsService } from '../admin/settings.service';
import { User } from '../_models/user';
@Component({
selector: 'app-register-member',
@ -10,35 +13,42 @@ import { AccountService } from 'src/app/_services/account.service';
export class RegisterMemberComponent implements OnInit {
@Input() firstTimeFlow = false;
@Output() created = new EventEmitter<boolean>();
/**
* Emits the new user created.
*/
@Output() created = new EventEmitter<User | null>();
adminExists = false;
authDisabled: boolean = false;
registerForm: FormGroup = new FormGroup({
username: new FormControl('', [Validators.required]),
password: new FormControl('', [Validators.required]),
password: new FormControl('', []),
isAdmin: new FormControl(false, [])
});
errors: string[] = [];
constructor(private accountService: AccountService) {
constructor(private accountService: AccountService, private settingsService: SettingsService) {
}
ngOnInit(): void {
this.settingsService.getAuthenticationEnabled().pipe(take(1)).subscribe(authEnabled => {
this.authDisabled = !authEnabled;
});
if (this.firstTimeFlow) {
this.registerForm.get('isAdmin')?.setValue(true);
}
}
register() {
this.accountService.register(this.registerForm.value).subscribe(resp => {
this.created.emit(true);
this.accountService.register(this.registerForm.value).subscribe(user => {
this.created.emit(user);
}, err => {
this.errors = err;
});
}
cancel() {
this.created.emit(false);
this.created.emit(null);
}
}

View File

@ -1,28 +1,44 @@
<div class="mx-auto login">
<div class="card p-3" style="width: 18rem;">
<div class="logo-container">
<img class="logo" src="assets/images/kavita-book-cropped.png" alt="Kavita logo"/>
<h3 class="card-title text-center">Kavita</h3>
<div class="display: inline-block" *ngIf="firstTimeFlow">
<h3 class="card-title text-center">Create an Admin Account</h3>
<div class="card p-3">
<p>Please create an admin account for yourself to start your reading journey.</p>
<app-register-member (created)="onAdminCreated($event)" [firstTimeFlow]="firstTimeFlow"></app-register-member>
</div>
<div class="card-text">
<form [formGroup]="loginForm" (ngSubmit)="login()">
<div class="form-group">
<label for="username">Username</label>
<input class="form-control" formControlName="username" id="username" type="text" autofocus>
</div>
<div class="form-group">
<label for="password">Password</label>
<input class="form-control" formControlName="password" id="password" type="password">
</div>
<div class="float-right">
<button class="btn btn-primary alt" type="submit">Login</button>
</div>
</form>
</div>
</div>
<form [formGroup]="loginForm" (ngSubmit)="login()" novalidate class="needs-validation" *ngIf="!firstTimeFlow">
<div class="row row-cols-4 row-cols-md-4 row-cols-sm-2 row-cols-xs-2">
<ng-container *ngFor="let member of memberNames">
<div class="col align-self-center card p-3 m-3" style="width: 12rem;">
<span tabindex="0" (click)="select(member)" a11y-click="13,32">
<div class="logo-container">
<h3 class="card-title text-center">{{member | titlecase}}</h3>
</div>
</span>
<div class="card-text" #collapse="ngbCollapse" [(ngbCollapse)]="isCollapsed[member]" (keyup.enter)="$event.stopPropagation()">
<div class="form-group" style="display: none;">
<label for="username--{{member}}">Username</label>
<input class="form-control" formControlName="username" id="username--{{member}}" type="text" [readonly]="true">
</div>
<div class="form-group">
<label for="password--{{member}}">Password</label>
<input class="form-control" formControlName="password" id="password--{{member}}" type="password" autofocus>
<div *ngIf="authDisabled" class="invalid-feedback">
Authentication is disabled. Only type password if this is an admin account.
</div>
</div>
<div class="float-right">
<button class="btn btn-primary alt" type="submit--{{member}}">Login</button>
</div>
</div>
</div>
</ng-container>
</div>
</form>
</div>

View File

@ -4,6 +4,7 @@
display: flex;
align-items: center;
justify-content: center;
margin-top: -61px; // To offset the navbar
height: calc(100vh);
min-height: 289px;
position: relative;
@ -22,24 +23,34 @@
}
.logo-container {
margin: 0 auto 15px;
.logo {
display:inline-block;
height: 50px;
}
}
.row {
margin-top: 10vh;
}
.card {
background-color: $primary-color;
color: #fff;
cursor: pointer;
min-width: 300px;
&:focus {
border: 2px solid white;
}
.card-title {
font-family: 'Spartan', sans-serif;
font-weight: bold;
display: inline-block;
margin: 0 10px;
vertical-align: middle;
width: 280px;
}
.card-text {
@ -66,3 +77,7 @@
}
}
.invalid-feedback {
display: inline-block;
color: #343c59;
}

View File

@ -2,7 +2,9 @@ import { Component, OnInit } from '@angular/core';
import { FormGroup, FormControl, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { ToastrService } from 'ngx-toastr';
import { first } from 'rxjs/operators';
import { first, take } from 'rxjs/operators';
import { SettingsService } from '../admin/settings.service';
import { User } from '../_models/user';
import { AccountService } from '../_services/account.service';
import { MemberService } from '../_services/member.service';
import { NavService } from '../_services/nav.service';
@ -17,27 +19,57 @@ export class UserLoginComponent implements OnInit {
model: any = {username: '', password: ''};
loginForm: FormGroup = new FormGroup({
username: new FormControl('', [Validators.required]),
password: new FormControl('', [Validators.required])
password: new FormControl('', [Validators.required])
});
constructor(private accountService: AccountService, private router: Router, private memberService: MemberService, private toastr: ToastrService, private navService: NavService) { }
memberNames: Array<string> = [];
isCollapsed: {[key: string]: boolean} = {};
authDisabled: boolean = false;
/**
* If there are no admins on the server, this will enable the registration to kick in.
*/
firstTimeFlow: boolean = true;
constructor(private accountService: AccountService, private router: Router, private memberService: MemberService,
private toastr: ToastrService, private navService: NavService, private settingsService: SettingsService) { }
ngOnInit(): void {
// Validate that there are users so you can refresh to home. This is important for first installs
this.validateAdmin();
}
validateAdmin() {
this.navService.hideNavBar();
this.memberService.adminExists().subscribe(res => {
if (!res) {
this.router.navigateByUrl('/home');
this.navService.showNavBar();
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
if (user) {
this.router.navigateByUrl('/library');
}
});
this.settingsService.getAuthenticationEnabled().pipe(take(1)).subscribe((enabled: boolean) => {
// There is a bug where this is coming back as a string not a boolean.
this.authDisabled = enabled + '' === 'false';
if (this.authDisabled) {
this.loginForm.get('password')?.setValidators([]);
}
});
this.memberService.getMemberNames().pipe(take(1)).subscribe(members => {
this.memberNames = members;
const isOnlyOne = this.memberNames.length === 1;
this.memberNames.forEach(name => this.isCollapsed[name] = !isOnlyOne);
this.firstTimeFlow = members.length === 0;
});
}
onAdminCreated(user: User | null) {
if (user != null) {
this.firstTimeFlow = false;
this.isCollapsed[user.username] = true;
this.select(user.username);
this.memberNames.push(user.username);
} else {
this.toastr.error('There was an issue creating the new user. Please refresh and try again.');
}
}
login() {
if (!this.loginForm.dirty || !this.loginForm.valid) { return; }
this.model = {username: this.loginForm.get('username')?.value, password: this.loginForm.get('password')?.value};
this.accountService.login(this.model).subscribe(() => {
this.loginForm.reset();
@ -45,7 +77,7 @@ export class UserLoginComponent implements OnInit {
// Check if user came here from another url, else send to library route
const pageResume = localStorage.getItem('kavita--auth-intersection-url');
if (pageResume && pageResume !== '/no-connection') {
if (pageResume && pageResume !== '/no-connection' && pageResume !== '/login') {
localStorage.setItem('kavita--auth-intersection-url', '');
this.router.navigateByUrl(pageResume);
} else {
@ -62,4 +94,20 @@ export class UserLoginComponent implements OnInit {
});
}
select(member: string) {
this.loginForm.get('username')?.setValue(member);
this.isCollapsed[member] = !this.isCollapsed[member];
this.collapseAllButName(member);
// ?! Scroll to the newly opened element?
}
collapseAllButName(name: string) {
Object.keys(this.isCollapsed).forEach(key => {
if (key !== name) {
this.isCollapsed[key] = true;
}
});
}
}