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
This commit is contained in:
Joseph Milazzo 2022-01-30 14:45:57 -08:00 committed by GitHub
parent 6e6b72a5b5
commit efb527035d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
109 changed files with 2041 additions and 407 deletions

View file

@ -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;

View file

@ -49,7 +49,7 @@ export class AccountService implements OnDestroy {
return this.httpClient.get<string[]>(this.baseUrl + 'account/roles');
}
login(model: any): Observable<any> {
login(model: {username: string, password: string}): Observable<any> {
return this.httpClient.post<User>(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<User>(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<string>(this.baseUrl + 'account/migrate-email', model, {responseType: 'text' as 'json'});
}
confirmMigrationEmail(model: {email: string, token: string}) {
return this.httpClient.post<User>(this.baseUrl + 'account/confirm-migration-email', model);
}
resendConfirmationEmail(userId: number) {
return this.httpClient.post<string>(this.baseUrl + 'account/resend-confirmation-email?userId=' + userId, {}, {responseType: 'text' as 'json'});
}
inviteUser(model: {email: string, roles: Array<string>, libraries: Array<number>, sendEmail: boolean}) {
return this.httpClient.post<string>(this.baseUrl + 'account/invite', model, {responseType: 'text' as 'json'});
}
confirmEmail(model: {email: string, username: string, password: string, token: string}) {
return this.httpClient.post<User>(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<string>, libraries: Array<number>, userId: number}) {
return this.httpClient.post(this.baseUrl + 'account/update', model);
}
updatePreferences(userPreferences: Preferences) {
return this.httpClient.post<Preferences>(this.baseUrl + 'users/update-preferences', userPreferences).pipe(map(settings => {
if (this.currentUser !== undefined || this.currentUser != null) {

View file

@ -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<Array<Member>>(this.baseUrl + 'users/pending');
}
}

View file

@ -36,4 +36,8 @@ export class ServerService {
getChangelog() {
return this.httpClient.get<UpdateVersionEvent[]>(this.baseUrl + 'server/changelog', {});
}
isServerAccessible() {
return this.httpClient.get<boolean>(this.baseUrl + 'server/accessible');
}
}

View file

@ -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',

View file

@ -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();
}

View file

@ -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,

View file

@ -0,0 +1,58 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Edit {{member.username | sentenceCase}}</h4>
<button type="button" class="close" aria-label="Close" (click)="close()">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<form [formGroup]="userForm">
<div class="row no-gutters">
<div class="col-md-6 col-sm-12 pr-2">
<div class="form-group">
<label for="username">Username</label>
<input id="username" class="form-control" formControlName="username" type="text">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="userForm.dirty || userForm.touched">
<div *ngIf="userForm.get('username')?.errors?.required">
This field is required
</div>
</div>
</div>
</div>
<div class="col-md-6 col-sm-12">
<div class="form-group" style="width:100%">
<label for="email">Email</label>
<input class="form-control" type="email" id="email" formControlName="email" [disabled]="true">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="userForm.dirty || userForm.touched">
<div *ngIf="userForm.get('email')?.errors?.required">
This field is required
</div>
<div *ngIf="userForm.get('email')?.errors?.email">
This must be a valid email address
</div>
</div>
</div>
</div>
</div>
<div class="row no-gutters">
<div class="col-md-6">
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true" [member]="member"></app-role-selector>
</div>
<div class="col-md-6">
<app-library-selector (selected)="updateLibrarySelection($event)" [member]="member"></app-library-selector>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="close()">
Cancel
</button>
<button type="button" class="btn btn-primary" (click)="save()" [disabled]="isSaving || !userForm.valid">
<span *ngIf="isSaving" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span>{{isSaving ? 'Saving...' : 'Update'}}</span>
</button>
</div>

View file

@ -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<string> = [];
selectedLibraries: Array<number> = [];
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<string>) {
this.selectedRoles = roles;
}
updateLibrarySelection(libraries: Array<Library>) {
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);
});
}
}

View file

@ -0,0 +1,56 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Invite User</h4>
<button type="button" class="close" aria-label="Close" (click)="close()">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p>
Invite a user to your server. Enter their email in and we will send them an email to create an account.
</p>
<p *ngIf="!checkedAccessibility">
<span class="spinner-border text-primary" style="width: 1.5rem; height: 1.5rem;" role="status" aria-hidden="true"></span>
&nbsp;Checking accessibility of server...
</p>
<form [formGroup]="inviteForm">
<div class="row no-gutters">
<div class="form-group" style="width:100%">
<label for="email">Email</label>
<input class="form-control" type="email" id="email" formControlName="email" required>
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="inviteForm.dirty || inviteForm.touched">
<div *ngIf="email?.errors?.required">
This field is required
</div>
</div>
</div>
</div>
<ng-container *ngIf="emailLink !== '' && checkedAccessibility && !accessible">
<p>Use this link to finish setting up the user account due to your server not being accessible outside your local network.</p>
<a href="{{emailLink}}" target="_blank">{{emailLink}}</a>
</ng-container>
<div class="row no-gutters">
<div class="col-md-6">
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true"></app-role-selector>
</div>
<div class="col-md-6">
<app-library-selector (selected)="updateLibrarySelection($event)"></app-library-selector>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="close()">
Cancel
</button>
<button type="button" class="btn btn-primary" (click)="invite()" [disabled]="isSending || !inviteForm.valid || !checkedAccessibility || emailLink !== ''">
<span *ngIf="isSending" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span>{{isSending ? 'Inviting...' : 'Invite'}}</span>
</button>
</div>

View file

@ -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<string> = [];
selectedLibraries: Array<number> = [];
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<string>) {
this.selectedRoles = roles;
}
updateLibrarySelection(libraries: Array<Library>) {
this.selectedLibraries = libraries.map(l => l.id);
}
}

View file

@ -0,0 +1,20 @@
<h4>Libraries</h4>
<div class="list-group" *ngIf="!isLoading">
<div class="form-check" *ngIf="allLibraries.length > 0">
<input id="selectall" type="checkbox" class="form-check-input"
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="hasSomeSelected">
<label for="selectall" class="form-check-label">{{selectAll ? 'Deselect' : 'Select'}} All</label>
</div>
<ul>
<li class="list-group-item" *ngFor="let library of allLibraries; let i = index">
<div class="form-check">
<input id="library-{{i}}" type="checkbox" class="form-check-input" attr.aria-label="Library {{library.name}}"
[ngModel]="selections.isSelected(library)" (change)="handleSelection(library)">
<label attr.for="library-{{i}}" class="form-check-label">{{library.name}}</label>
</div>
</li>
<li class="list-group-item" *ngIf="allLibraries.length === 0">
There are no libraries setup yet.
</li>
</ul>
</div>

View file

@ -0,0 +1,3 @@
.list-group-item {
border: none;
}

View file

@ -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<Array<Library>> = new EventEmitter<Array<Library>>();
allLibraries: Library[] = [];
selectedLibraries: Array<{selected: boolean, data: Library}> = [];
selections!: SelectionModel<Library>;
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<Library>(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());
}
}

View file

@ -1,19 +1,53 @@
<div class="container-fluid">
<div class="row mb-2">
<div class="col-8"><h3>Users</h3></div>
<div class="col-4"><button class="btn btn-primary float-right" (click)="createMember()"><i class="fa fa-plus" aria-hidden="true"></i><span class="phone-hidden">&nbsp;Add User</span></button></div>
</div>
<ul class="list-group" *ngIf="!createMemberToggle; else createUser">
<ng-container>
<div class="row mb-2">
<div class="col-8"><h3>Pending Invites</h3></div>
<div class="col-4"><button class="btn btn-primary float-right" (click)="inviteUser()"><i class="fa fa-plus" aria-hidden="true"></i><span class="phone-hidden">&nbsp;Invite</span></button></div>
</div>
<ul class="list-group">
<li class="list-group-item" *ngFor="let invite of pendingInvites; let idx = index;">
<div>
<h4>
<span id="member-name--{{idx}}">{{invite.username | titlecase}} </span>
<div class="float-right">
<button class="btn btn-danger mr-2" (click)="deleteUser(invite)">Cancel</button>
<button class="btn btn-secondary mr-2" (click)="resendEmail(invite)">Resend</button>
</div>
</h4>
<div>Invited: {{invite.created | date: 'short'}}</div>
</div>
</li>
<li *ngIf="loadingMembers" class="list-group-item">
<div class="spinner-border text-secondary" role="status">
<span class="invisible">Loading...</span>
</div>
</li>
<li class="list-group-item" *ngIf="pendingInvites.length === 0 && !loadingMembers">
There are no invited Users
</li>
</ul>
</ng-container>
<h3 class="mt-3">Active Users</h3>
<ul class="list-group">
<li *ngFor="let member of members; let idx = index;" class="list-group-item">
<div>
<h4>
<i class="presence fa fa-circle" title="Active" aria-hidden="true" *ngIf="false && (messageHub.onlineUsers$ | async)?.includes(member.username)"></i><span id="member-name--{{idx}}">{{member.username | titlecase}} </span><span *ngIf="member.username === loggedInUsername">(You)</span>
<i class="presence fa fa-circle" title="Active" aria-hidden="true" *ngIf="false && (messageHub.onlineUsers$ | async)?.includes(member.username)"></i>
<span id="member-name--{{idx}}">{{member.username | titlecase}} </span>
<span *ngIf="member.username === loggedInUsername">
<i class="fas fa-star" aria-hidden="true"></i>
<span class="sr-only">(You)</span>
</span>
<div class="float-right" *ngIf="canEditMember(member)">
<button class="btn btn-danger mr-2" (click)="deleteUser(member)" placement="top" ngbTooltip="Delete User" attr.aria-label="Delete User {{member.username | titlecase}}"><i class="fa fa-trash" aria-hidden="true"></i></button>
<button class="btn btn-secondary mr-2" (click)="updatePassword(member)" placement="top" ngbTooltip="Change Password" attr.aria-label="Change Password for {{member.username | titlecase}}"><i class="fa fa-key" aria-hidden="true"></i></button>
<button class="btn btn-primary" (click)="openEditLibraryAccess(member)" placement="top" ngbTooltip="Edit" attr.aria-label="Edit {{member.username | titlecase}}"><i class="fa fa-pen" aria-hidden="true"></i></button>
<button class="btn btn-primary" (click)="openEditUser(member)" placement="top" ngbTooltip="Edit" attr.aria-label="Edit {{member.username | titlecase}}"><i class="fa fa-pen" aria-hidden="true"></i></button>
</div>
</h4>
<div>Last Active:
@ -44,7 +78,4 @@
There are no other users.
</li>
</ul>
<ng-template #createUser>
<app-register-member (created)="onMemberCreated($event)"></app-register-member>
</ng-template>
</div>

View file

@ -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<void>();
@ -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. <br/> <a href="' + email + '" target="_blank">' + email + '</a>');
});
});
}
updatePassword(member: Member) {

View file

@ -0,0 +1,10 @@
<h4>Roles</h4>
<ul class="list-group">
<li class="list-group-item" *ngFor="let role of selectedRoles; let i = index">
<div class="form-check">
<input id="role-{{i}}" type="checkbox" attr.aria-label="Role {{role.data}}" class="form-check-input"
[(ngModel)]="role.selected" name="role" (ngModelChange)="handleModelUpdate()">
<label attr.for="role-{{i}}" class="form-check-label">{{role.data}}</label>
</div>
</li>
</ul>

View file

@ -0,0 +1,3 @@
.list-group-item {
border: none;
}

View file

@ -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<string[]> = new EventEmitter<string[]>();
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));
}
}

View file

@ -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'}
];

View file

@ -1,7 +1,7 @@
<nav class="navbar navbar-expand-md navbar-dark fixed-top" *ngIf="navService?.navbarVisible$ | async">
<div class="container-fluid">
<a class="sr-only sr-only-focusable focus-visible" href="javascript:void(0);" (click)="moveFocus()">Skip to main content</a>
<a class="navbar-brand" routerLink="/library" routerLinkActive="active"><img class="logo" src="../../assets/images/logo.png" alt="kavita icon" aria-hidden="true"/><span class="phone-hidden"> Kavita</span></a>
<a class="navbar-brand dark-exempt" routerLink="/library" routerLinkActive="active"><img class="logo" src="../../assets/images/logo.png" alt="kavita icon" aria-hidden="true"/><span class="phone-hidden"> Kavita</span></a>
<ul class="navbar-nav col mr-auto">
<div class="nav-item" *ngIf="(accountService.currentUser$ | async) as user">
@ -62,17 +62,25 @@
</button>
</div>
<div class="nav-item" *ngIf="(accountService.currentUser$ | async) as user">
<app-nav-events-toggle [user]="user"></app-nav-events-toggle>
</div>
<ng-container *ngIf="(accountService.currentUser$ | async) as user">
<div class="nav-item">
<app-nav-events-toggle [user]="user"></app-nav-events-toggle>
</div>
<div class="nav-item pr-2">
<a routerLink="/admin/dashboard" *ngIf="user.roles.includes('Admin')" class="dark-exempt" style="padding: 5px">
<i class="fa fa-cogs" aria-hidden="true" style="color: white"></i>
<span class="sr-only">Server Settings</span>
</a>
</div>
</ng-container>
<div ngbDropdown class="nav-item dropdown" display="dynamic" placement="bottom-right" *ngIf="(accountService.currentUser$ | async) as user" dropdown>
<button class="btn btn-outline-secondary primary-text" ngbDropdownToggle>
{{user.username | sentenceCase}}
</button>
<div ngbDropdownMenu>
<a ngbDropdownItem routerLink="/preferences/">User Settings</a>
<a ngbDropdownItem routerLink="/admin/dashboard" *ngIf="user.roles.includes('Admin')">Server Settings</a>
<a ngbDropdownItem routerLink="/preferences/">Settings</a>
<a ngbDropdownItem (click)="logout()">Logout</a>
</div>
</div>

View file

@ -1,32 +0,0 @@
<div class="text-danger" *ngIf="errors.length > 0">
<p>Errors:</p>
<ul>
<li *ngFor="let error of errors">{{error}}</li>
</ul>
</div>
<form [formGroup]="registerForm" (ngSubmit)="register()">
<div class="form-group">
<label for="username">Username</label>
<input id="username" class="form-control" formControlName="username" type="text">
</div>
<div class="form-group" *ngIf="registerForm.get('isAdmin')?.value || !authDisabled">
<label for="password">Password</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="passwordTooltip" role="button" tabindex="0"></i>
<ng-template #passwordTooltip>
Password must be between 6 and 32 characters in length
</ng-template>
<span class="sr-only" id="password-help"><ng-container [ngTemplateOutlet]="passwordTooltip"></ng-container></span>
<input id="password" class="form-control" formControlName="password" type="password" aria-describedby="password-help">
</div>
<div class="form-check" *ngIf="!firstTimeFlow">
<input id="admin" type="checkbox" aria-label="Admin" class="form-check-input" formControlName="isAdmin">
<label for="admin" class="form-check-label">Admin</label>
</div>
<div class="float-right">
<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>

View file

@ -1,18 +0,0 @@
.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%);
}
input {
background-color: #fff !important;
color: black !important;
}

View file

@ -1,54 +0,0 @@
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',
templateUrl: './register-member.component.html',
styleUrls: ['./register-member.component.scss']
})
export class RegisterMemberComponent implements OnInit {
@Input() firstTimeFlow = false;
/**
* 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('', []),
isAdmin: new FormControl(false, [])
});
errors: string[] = [];
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(user => {
this.created.emit(user);
}, err => {
this.errors = err;
});
}
cancel() {
this.created.emit(null);
}
}

View file

@ -0,0 +1,55 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Account Migration</h4>
<button type="button" class="close" aria-label="Close" (click)="close()">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<p>Your account does not have an email on file. This is a one-time migration. Please add your email to the account. A verficiation link will be sent to your email for you
to confirm and will then be allowed to authenticate with this server. This is required.
</p>
<form [formGroup]="registerForm">
<div class="form-group">
<label for="username">Username</label>
<input id="username" class="form-control" formControlName="username" type="text">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="registerForm.dirty || registerForm.touched">
<div *ngIf="registerForm.get('username')?.errors?.required">
This field is required
</div>
</div>
</div>
<div class="form-group" style="width:100%">
<label for="email">Email</label>
<input class="form-control" type="email" id="email" formControlName="email" required>
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="registerForm.dirty || registerForm.touched">
<div *ngIf="registerForm.get('email')?.errors?.required">
This field is required
</div>
<div *ngIf="registerForm.get('email')?.errors?.email">
This must be a valid email address
</div>
</div>
</div>
<div class="form-group">
<label for="password">Password</label>
<input id="password" class="form-control" maxlength="32" minlength="6" formControlName="password" type="password" aria-describedby="password-help">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="registerForm.dirty || registerForm.touched">
<div *ngIf="registerForm.get('password')?.errors?.required">
This field is required
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" (click)="close()">
Cancel
</button>
<button type="button" class="btn btn-primary" (click)="save()" [disabled]="isSaving || !registerForm.valid">
<span *ngIf="isSaving" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span>Submit</span>
</button>
</div>

View file

@ -0,0 +1,60 @@
import { Component, Input, OnInit } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { SafeUrl } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { ConfirmService } from 'src/app/shared/confirm.service';
import { AccountService } from 'src/app/_services/account.service';
import { MemberService } from 'src/app/_services/member.service';
import { ServerService } from 'src/app/_services/server.service';
@Component({
selector: 'app-add-email-to-account-migration-modal',
templateUrl: './add-email-to-account-migration-modal.component.html',
styleUrls: ['./add-email-to-account-migration-modal.component.scss']
})
export class AddEmailToAccountMigrationModalComponent implements OnInit {
@Input() username!: string;
@Input() password!: string;
isSaving: boolean = false;
registerForm: FormGroup = new FormGroup({});
emailLink: string = '';
emailLinkUrl: SafeUrl | undefined;
constructor(private accountService: AccountService, private modal: NgbActiveModal,
private serverService: ServerService, private confirmService: ConfirmService) {
}
ngOnInit(): void {
this.registerForm.addControl('username', new FormControl(this.username, [Validators.required]));
this.registerForm.addControl('email', new FormControl('', [Validators.required, Validators.email]));
this.registerForm.addControl('password', new FormControl(this.password, [Validators.required]));
}
close() {
this.modal.close(false);
}
save() {
this.serverService.isServerAccessible().subscribe(canAccess => {
const model = this.registerForm.getRawValue();
model.sendEmail = canAccess;
this.accountService.migrateUser(model).subscribe(async (email) => {
if (!canAccess) {
// Display the email to the user
this.emailLink = email;
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. <br/> <a href="' + this.emailLink + '" target="_blank">' + this.emailLink + '</a>');
} else {
await this.confirmService.alert('Please check your email for the confirmation link. You must confirm to be able to login.');
}
});
});
}
}

View file

@ -0,0 +1,58 @@
<!--
<div class="text-danger" *ngIf="errors.length > 0">
<p>Errors:</p>
<ul>
<li *ngFor="let error of errors">{{error}}</li>
</ul>
</div> -->
<app-splash-container>
<ng-container title><h2>Register</h2></ng-container>
<ng-container body>
<p>Complete the form to complete your registration</p>
<form [formGroup]="registerForm" (ngSubmit)="submit()">
<div class="form-group">
<label for="username">Username</label>
<input id="username" class="form-control" formControlName="username" type="text">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="registerForm.dirty || registerForm.touched">
<div *ngIf="registerForm.get('username')?.errors?.required">
This field is required
</div>
</div>
</div>
<div class="form-group" style="width:100%">
<label for="email">Email</label>
<input class="form-control" type="email" id="email" formControlName="email" required>
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="registerForm.dirty || registerForm.touched">
<div *ngIf="registerForm.get('email')?.errors?.required">
This field is required
</div>
<div *ngIf="registerForm.get('email')?.errors?.email">
This must be a valid email address
</div>
</div>
</div>
<div class="form-group">
<label for="password">Password</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="passwordTooltip" role="button" tabindex="0"></i>
<ng-template #passwordTooltip>
Password must be between 6 and 32 characters in length
</ng-template>
<span class="sr-only" id="password-help"><ng-container [ngTemplateOutlet]="passwordTooltip"></ng-container></span>
<input id="password" class="form-control" maxlength="32" minlength="6" formControlName="password" type="password" aria-describedby="password-help">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="registerForm.dirty || registerForm.touched">
<div *ngIf="registerForm.get('password')?.errors?.required">
This field is required
</div>
<div *ngIf="registerForm.get('password')?.errors?.minlength || registerForm.get('password')?.errors?.maxLength">
Password must be between 6 and 32 characters in length
</div>
</div>
</div>
<div class="float-right">
<button class="btn btn-secondary alt" type="submit">Register</button>
</div>
</form>
</ng-container>
</app-splash-container>

View file

@ -0,0 +1,4 @@
input {
background-color: #fff !important;
color: black;
}

View file

@ -0,0 +1,60 @@
import { Component, Input, OnInit } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { ToastrService } from 'ngx-toastr';
import { AccountService } from 'src/app/_services/account.service';
@Component({
selector: 'app-confirm-email',
templateUrl: './confirm-email.component.html',
styleUrls: ['./confirm-email.component.scss']
})
export class ConfirmEmailComponent implements OnInit {
/**
* Email token used for validating
*/
token: string = '';
registerForm: FormGroup = new FormGroup({
email: new FormControl('', [Validators.required, Validators.email]),
username: new FormControl('', [Validators.required]),
password: new FormControl('', [Validators.required, Validators.maxLength(32), Validators.minLength(6)]),
});
/**
* Validation errors from API
*/
errors: Array<string> = [];
constructor(private route: ActivatedRoute, private router: Router, private accountService: AccountService, private toastr: ToastrService) {
const token = this.route.snapshot.queryParamMap.get('token');
const email = this.route.snapshot.queryParamMap.get('email');
if (token == undefined || token === '' || token === null) {
// This is not a valid url, redirect to login
this.toastr.error('Invalid confirmation email');
this.router.navigateByUrl('login');
return;
}
this.token = token;
this.registerForm.get('email')?.setValue(email || '');
}
ngOnInit(): void {
}
submit() {
let model = this.registerForm.getRawValue();
model.token = this.token;
this.accountService.confirmEmail(model).subscribe((user) => {
this.toastr.success('Account registration complete');
this.router.navigateByUrl('login');
}, err => {
this.errors = err;
});
}
}

View file

@ -0,0 +1,34 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { ToastrService } from 'ngx-toastr';
import { AccountService } from 'src/app/_services/account.service';
@Component({
selector: 'app-confirm-migration-email',
templateUrl: './confirm-migration-email.component.html',
styleUrls: ['./confirm-migration-email.component.scss']
})
export class ConfirmMigrationEmailComponent implements OnInit {
constructor(private route: ActivatedRoute, private router: Router, private accountService: AccountService, private toastr: ToastrService) {
const token = this.route.snapshot.queryParamMap.get('token');
const email = this.route.snapshot.queryParamMap.get('email');
if (token === undefined || token === '' || token === null || email === undefined || email === '' || email === null) {
// This is not a valid url, redirect to login
this.toastr.error('Invalid confirmation email');
this.router.navigateByUrl('login');
return;
}
this.accountService.confirmMigrationEmail({token: token, email}).subscribe((user) => {
this.toastr.success('Account migration complete');
this.router.navigateByUrl('login');
});
}
ngOnInit(): void {
}
}

View file

@ -0,0 +1,58 @@
<!--
<div class="text-danger" *ngIf="errors.length > 0">
<p>Errors:</p>
<ul>
<li *ngFor="let error of errors">{{error}}</li>
</ul>
</div> -->
<app-splash-container>
<ng-container title><h2>Register</h2></ng-container>
<ng-container body>
<p>Complete the form to register an admin account</p>
<form [formGroup]="registerForm" (ngSubmit)="submit()">
<div class="form-group">
<label for="username">Username</label>
<input id="username" class="form-control" formControlName="username" type="text">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="registerForm.dirty || registerForm.touched">
<div *ngIf="registerForm.get('username')?.errors?.required">
This field is required
</div>
</div>
</div>
<div class="form-group" style="width:100%">
<label for="email">Email</label>
<input class="form-control" type="email" id="email" formControlName="email" required>
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="registerForm.dirty || registerForm.touched">
<div *ngIf="registerForm.get('email')?.errors?.required">
This field is required
</div>
<div *ngIf="registerForm.get('email')?.errors?.email">
This must be a valid email address
</div>
</div>
</div>
<div class="form-group">
<label for="password">Password</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="passwordTooltip" role="button" tabindex="0"></i>
<ng-template #passwordTooltip>
Password must be between 6 and 32 characters in length
</ng-template>
<span class="sr-only" id="password-help"><ng-container [ngTemplateOutlet]="passwordTooltip"></ng-container></span>
<input id="password" class="form-control" maxlength="32" minlength="6" formControlName="password" type="password" aria-describedby="password-help">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="registerForm.dirty || registerForm.touched">
<div *ngIf="registerForm.get('password')?.errors?.required">
This field is required
</div>
<div *ngIf="registerForm.get('password')?.errors?.minlength || registerForm.get('password')?.errors?.maxLength">
Password must be between 6 and 32 characters in length
</div>
</div>
</div>
<div class="float-right">
<button class="btn btn-secondary alt" type="submit">Register</button>
</div>
</form>
</ng-container>
</app-splash-container>

View file

@ -0,0 +1,4 @@
input {
background-color: #fff !important;
color: black;
}

View file

@ -0,0 +1,46 @@
import { Component, OnInit } from '@angular/core';
import { FormGroup, FormControl, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { ToastrService } from 'ngx-toastr';
import { take } from 'rxjs/operators';
import { AccountService } from 'src/app/_services/account.service';
import { MemberService } from 'src/app/_services/member.service';
/**
* This is exclusivly used to register the first user on the server and nothing else
*/
@Component({
selector: 'app-register',
templateUrl: './register.component.html',
styleUrls: ['./register.component.scss']
})
export class RegisterComponent implements OnInit {
registerForm: FormGroup = new FormGroup({
email: new FormControl('', [Validators.required, Validators.email]),
username: new FormControl('', [Validators.required]),
password: new FormControl('', [Validators.required, Validators.maxLength(32), Validators.minLength(6)]),
});
constructor(private route: ActivatedRoute, private router: Router, private accountService: AccountService, private toastr: ToastrService, private memberService: MemberService) {
this.memberService.adminExists().pipe(take(1)).subscribe(adminExists => {
if (adminExists) {
this.router.navigateByUrl('login');
}
});
}
ngOnInit(): void {
}
submit() {
const model = this.registerForm.getRawValue();
this.accountService.register(model).subscribe((user) => {
this.toastr.success('Account registration complete');
this.router.navigateByUrl('login');
}, err => {
// TODO: Handle errors
});
}
}

View file

@ -0,0 +1,29 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ConfirmEmailComponent } from './confirm-email/confirm-email.component';
import { RegistrationRoutingModule } from './registration.router.module';
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { ReactiveFormsModule } from '@angular/forms';
import { SplashContainerComponent } from './splash-container/splash-container.component';
import { RegisterComponent } from './register/register.component';
import { AddEmailToAccountMigrationModalComponent } from './add-email-to-account-migration-modal/add-email-to-account-migration-modal.component';
import { ConfirmMigrationEmailComponent } from './confirm-migration-email/confirm-migration-email.component';
@NgModule({
declarations: [
ConfirmEmailComponent,
SplashContainerComponent,
RegisterComponent,
AddEmailToAccountMigrationModalComponent,
ConfirmMigrationEmailComponent
],
imports: [
CommonModule,
RegistrationRoutingModule,
NgbTooltipModule,
ReactiveFormsModule
]
})
export class RegistrationModule { }

View file

@ -0,0 +1,27 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { ConfirmEmailComponent } from './confirm-email/confirm-email.component';
import { ConfirmMigrationEmailComponent } from './confirm-migration-email/confirm-migration-email.component';
import { RegisterComponent } from './register/register.component';
const routes: Routes = [
{
path: 'confirm-email',
component: ConfirmEmailComponent,
},
{
path: 'confirm-migration-email',
component: ConfirmMigrationEmailComponent,
},
{
path: 'register',
component: RegisterComponent,
}
];
@NgModule({
imports: [RouterModule.forChild(routes), ],
exports: [RouterModule]
})
export class RegistrationRoutingModule { }

View file

@ -0,0 +1,20 @@
<div class="mx-auto login">
<div class="row row-cols-4 row-cols-md-4 row-cols-sm-2 row-cols-xs-2">
<div class="col align-self-center card p-3 m-3" style="max-width: 12rem;">
<span>
<div class="logo-container">
<h3 class="card-title text-center">
<ng-content select="[title]"></ng-content>
</h3>
</div>
</span>
<div class="card-text">
<ng-content select="[body]"></ng-content>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,85 @@
@use "../../../theme/colors";
.login {
display: flex;
align-items: center;
justify-content: center;
margin-top: -61px; // To offset the navbar
height: calc(100vh);
min-height: 289px;
position: relative;
width: 100vw;
max-width: 100vw;
&::before {
content: "";
background-image: url('../../../assets/images/login-bg.jpg');
background-size: cover;
position: absolute;
top: 0;
right: 0;
bottom: 0;
opacity: 0.1;
width: 100%;
}
.logo-container {
.logo {
display:inline-block;
height: 50px;
}
}
.row {
margin-top: 10vh;
}
.card {
background-color: colors.$primary-color;
color: #fff;
min-width: 300px;
&:focus {
border: 2px solid white;
}
.card-title {
font-family: 'Spartan', sans-serif;
font-weight: bold;
display: inline-block;
vertical-align: middle;
width: 280px;
}
.card-text {
font-family: "EBGaramond", "Helvetica Neue", sans-serif;
}
.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%);
}
}
}
.invalid-feedback {
display: inline-block;
color: #343c59;
}
input {
background-color: #fff !important;
color: black;
}

View file

@ -0,0 +1,15 @@
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-splash-container',
templateUrl: './splash-container.component.html',
styleUrls: ['./splash-container.component.scss']
})
export class SplashContainerComponent implements OnInit {
constructor() { }
ngOnInit(): void {
}
}

View file

@ -6,7 +6,7 @@
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body" [innerHtml]="config.content | safeHtml">
<div class="modal-body" style="overflow-x: auto" [innerHtml]="config.content | safeHtml">
</div>
<div class="modal-footer">
<div *ngFor="let btn of config.buttons">

View file

@ -4,7 +4,6 @@ import { ReactiveFormsModule } from '@angular/forms';
import { NgbCollapseModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { ConfirmDialogComponent } from './confirm-dialog/confirm-dialog.component';
import { SafeHtmlPipe } from './safe-html.pipe';
import { RegisterMemberComponent } from '../register-member/register-member.component';
import { ReadMoreComponent } from './read-more/read-more.component';
import { RouterModule } from '@angular/router';
import { DrawerComponent } from './drawer/drawer.component';
@ -21,7 +20,6 @@ import { BadgeExpanderComponent } from './badge-expander/badge-expander.componen
@NgModule({
declarations: [
RegisterMemberComponent,
ConfirmDialogComponent,
SafeHtmlPipe,
ReadMoreComponent,
@ -45,7 +43,6 @@ import { BadgeExpanderComponent } from './badge-expander/badge-expander.componen
NgCircleProgressModule.forRoot(),
],
exports: [
RegisterMemberComponent,
SafeHtmlPipe,
SentenceCasePipe,
ReadMoreComponent,

View file

@ -1,14 +1,6 @@
<div class="mx-auto login">
<ng-container *ngIf="isLoaded">
<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>
<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">

View file

@ -1,9 +1,11 @@
import { Component, OnInit } from '@angular/core';
import { FormGroup, FormControl, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { first, take } from 'rxjs/operators';
import { SettingsService } from '../admin/settings.service';
import { AddEmailToAccountMigrationModalComponent } from '../registration/add-email-to-account-migration-modal/add-email-to-account-migration-modal.component';
import { User } from '../_models/user';
import { AccountService } from '../_services/account.service';
import { MemberService } from '../_services/member.service';
@ -35,7 +37,7 @@ export class UserLoginComponent implements OnInit {
isLoaded: boolean = false;
constructor(private accountService: AccountService, private router: Router, private memberService: MemberService,
private toastr: ToastrService, private navService: NavService, private settingsService: SettingsService) { }
private toastr: ToastrService, private navService: NavService, private settingsService: SettingsService, private modalService: NgbModal) { }
ngOnInit(): void {
this.navService.showNavBar();
@ -62,6 +64,12 @@ export class UserLoginComponent implements OnInit {
} else {
this.memberService.adminExists().pipe(take(1)).subscribe(adminExists => {
this.firstTimeFlow = !adminExists;
if (this.firstTimeFlow) {
this.router.navigateByUrl('registration/register');
return;
}
this.setupAuthenticatedLoginFlow();
this.isLoaded = true;
});
@ -95,7 +103,7 @@ export class UserLoginComponent implements OnInit {
}
login() {
this.model = {username: this.loginForm.get('username')?.value, password: this.loginForm.get('password')?.value};
this.model = this.loginForm.getRawValue();
this.accountService.login(this.model).subscribe(() => {
this.loginForm.reset();
this.navService.showNavBar();
@ -109,9 +117,19 @@ export class UserLoginComponent implements OnInit {
this.router.navigateByUrl('/library');
}
}, err => {
this.toastr.error(err.error);
if (err.error === 'You are missing an email on your account. Please wait while we migrate your account.') {
// TODO: Implement this flow
const modalRef = this.modalService.open(AddEmailToAccountMigrationModalComponent, { scrollable: true, size: 'md' });
modalRef.componentInstance.username = this.model.username;
modalRef.closed.pipe(take(1)).subscribe(() => {
});
} else {
this.toastr.error(err.error);
}
});
// TODO: Move this into account service so it always happens
this.accountService.currentUser$
.pipe(first(x => (x !== null && x !== undefined && typeof x !== 'undefined')))
.subscribe(currentUser => {

View file

@ -9,7 +9,7 @@
color: $dark-text-color;
}
a, .btn-link {
a:not(.dark-exempt), .btn-link {
color: $dark-hover-color;
> i {
color: $dark-hover-color !important;

View file

@ -52,6 +52,21 @@ body {
cursor: pointer;
}
.btn.btn-secondary.alt {
background-color: #424c72;
border-color: #444f75;
&:hover {
background-color: #3b4466;
}
&:focus {
background-color: #343c59;
box-shadow: 0 0 0 0.2rem rgb(68 79 117 / 50%);
}
}
app-root {
background-color: inherit;
}