Auth Email Rework (#1567)

* Hooked up Send to for Series and volumes and fixed a bug where Email Service errors weren't propagating to the UI layer.

When performing actions on series detail, don't disable the button anymore.

* Added send to action to volumes

* Fixed a bug where .kavitaignore wasn't being applied at library root level

* Added a notification for when a device is being sent a file.

* Added a check in forgot password for users that do not have an email set or aren't confirmed.

* Added a new api for change email and moved change password directly into new Account tab (styling and logic needs testing)

* Save approx scroll position like with jump key, but on normal click of card.

* Implemented the ability to change your email address or set one. This requires a 2 step process using a confirmation token. This needs polishing and css.

* Removed an unused directive from codebase

* Fixed up some typos on publicly

* Updated query for Pending Invites to also check if the user account has not logged in at least once.

* Cleaned up the css for validate email change

* Hooked in an indicator to tell user that a user has an unconfirmed email

* Cleaned up code smells
This commit is contained in:
Joe Milazzo 2022-10-01 08:23:35 -05:00 committed by GitHub
parent 3792ac3421
commit 5f17c2fb73
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
49 changed files with 816 additions and 274 deletions

View file

@ -0,0 +1,10 @@
export interface UpdateEmailResponse {
/**
* Did the user not have an existing email
*/
hadNoExistingEmail: boolean;
/**
* Was an email sent (ie is this server accessible)
*/
emailSent: boolean;
}

View file

@ -8,4 +8,5 @@ export interface User {
roles: string[];
preferences: Preferences;
apiKey: string;
email: string;
}

View file

@ -11,6 +11,7 @@ import { ThemeService } from './theme.service';
import { InviteUserResponse } from '../_models/invite-user-response';
import { UserUpdateEvent } from '../_models/events/user-update-event';
import { DeviceService } from './device.service';
import { UpdateEmailResponse } from '../_models/email/update-email-response';
@Injectable({
providedIn: 'root'
@ -132,6 +133,10 @@ export class AccountService implements OnDestroy {
);
}
isEmailConfirmed() {
return this.httpClient.get<boolean>(this.baseUrl + 'account/email-confirmed');
}
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'});
}
@ -152,6 +157,10 @@ export class AccountService implements OnDestroy {
return this.httpClient.post<User>(this.baseUrl + 'account/confirm-email', model);
}
confirmEmailUpdate(model: {email: string, token: string}) {
return this.httpClient.post<User>(this.baseUrl + 'account/confirm-email-update', model);
}
/**
* Given a user id, returns a full url for setting up the user account
* @param userId
@ -181,6 +190,10 @@ export class AccountService implements OnDestroy {
return this.httpClient.post(this.baseUrl + 'account/update', model);
}
updateEmail(email: string) {
return this.httpClient.post<UpdateEmailResponse>(this.baseUrl + 'account/update/email', {email});
}
/**
* This will get latest preferences for a user and cache them into user store
* @returns

View file

@ -383,6 +383,24 @@ export class ActionFactoryService {
}
]
},
{
action: Action.Submenu,
title: 'Send To',
callback: this.dummyCallback,
requiresAdmin: false,
children: [
{
action: Action.SendTo,
title: '',
callback: this.dummyCallback,
requiresAdmin: false,
dynamicList: this.deviceService.devices$.pipe(map((devices: Array<Device>) => devices.map(d => {
return {'title': d.name, 'data': d};
}), shareReplay())),
children: []
}
],
},
{
action: Action.Download,
title: 'Download',

View file

@ -8,10 +8,12 @@ import { AddToListModalComponent, ADD_FLOW } from '../reading-list/_modals/add-t
import { EditReadingListModalComponent } from '../reading-list/_modals/edit-reading-list-modal/edit-reading-list-modal.component';
import { ConfirmService } from '../shared/confirm.service';
import { Chapter } from '../_models/chapter';
import { Device } from '../_models/device/device';
import { Library } from '../_models/library';
import { ReadingList } from '../_models/reading-list';
import { Series } from '../_models/series';
import { Volume } from '../_models/volume';
import { DeviceService } from './device.service';
import { LibraryService } from './library.service';
import { MemberService } from './member.service';
import { ReaderService } from './reader.service';
@ -39,7 +41,7 @@ export class ActionService implements OnDestroy {
constructor(private libraryService: LibraryService, private seriesService: SeriesService,
private readerService: ReaderService, private toastr: ToastrService, private modalService: NgbModal,
private confirmService: ConfirmService, private memberService: MemberService) { }
private confirmService: ConfirmService, private memberService: MemberService, private deviceSerivce: DeviceService) { }
ngOnDestroy() {
this.onDestroy.next();
@ -552,6 +554,15 @@ export class ActionService implements OnDestroy {
});
}
sendToDevice(chapterIds: Array<number>, device: Device, callback?: VoidActionCallback) {
this.deviceSerivce.sendTo(chapterIds, device.id).subscribe(() => {
this.toastr.success('File emailed to ' + device.name);
if (callback) {
callback();
}
});
}
private async promptIfForce(extraContent: string = '') {
// Prompt user if we should do a force or not
const config = this.confirmService.defaultConfirm;

View file

@ -40,8 +40,8 @@ export class DeviceService {
}));
}
sendTo(chapterId: number, deviceId: number) {
return this.httpClient.post(this.baseUrl + 'device/send-to', {deviceId, chapterId}, {responseType: 'text' as 'json'});
sendTo(chapterIds: Array<number>, deviceId: number) {
return this.httpClient.post(this.baseUrl + 'device/send-to', {deviceId, chapterIds}, {responseType: 'text' as 'json'});
}

View file

@ -51,31 +51,35 @@ export enum EVENTS {
/**
* A subtype of NotificationProgress that represents a file being processed for cover image extraction
*/
CoverUpdateProgress = 'CoverUpdateProgress',
CoverUpdateProgress = 'CoverUpdateProgress',
/**
* A library is created or removed from the instance
*/
LibraryModified = 'LibraryModified',
LibraryModified = 'LibraryModified',
/**
* A user updates an entities read progress
*/
UserProgressUpdate = 'UserProgressUpdate',
UserProgressUpdate = 'UserProgressUpdate',
/**
* A user updates account or preferences
*/
UserUpdate = 'UserUpdate',
UserUpdate = 'UserUpdate',
/**
* When bulk bookmarks are being converted
*/
ConvertBookmarksProgress = 'ConvertBookmarksProgress',
ConvertBookmarksProgress = 'ConvertBookmarksProgress',
/**
* When files are being scanned to calculate word count
*/
WordCountAnalyzerProgress = 'WordCountAnalyzerProgress',
WordCountAnalyzerProgress = 'WordCountAnalyzerProgress',
/**
* When the user needs to be informed, but it's not a big deal
*/
Info = 'Info',
Info = 'Info',
/**
* A user is sending files to their device
*/
SendingToDevice = 'SendingToDevice',
}
export interface Message<T> {
@ -261,6 +265,13 @@ export class MessageHubService {
payload: resp.body
});
});
this.hubConnection.on(EVENTS.SendingToDevice, resp => {
this.messagesSource.next({
event: EVENTS.SendingToDevice,
payload: resp.body
});
});
}
stopHubConnection() {

View file

@ -45,10 +45,6 @@
</div>
<div class="modal-footer">
<!-- <div class="form-check form-switch">
<input id="stat-collection" type="checkbox" aria-label="Stat Collection" class="form-check-input" formControlName="allowStatCollection" role="switch">
<label for="stat-collection" class="form-check-label">Send Data</label>
</div> -->
<button type="button" class="btn btn-secondary" (click)="close()">
Cancel
</button>

View file

@ -5,7 +5,7 @@
email service, by setting up <a href="https://github.com/Kareadita/KavitaEmail" target="_blank" rel="noopener noreferrer">Kavita Email</a> service. Set the url of the email service and use the Test button to ensure it works.
At any time you can reset to the default. There is no way to disable emails for authentication, although you are not required to use a
valid email address for users. Confirmation links will always be saved to logs and presented in the UI.
Registration/Confirmation emails will not be sent if you are not accessing Kavita via a publically reachable url.
Registration/Confirmation emails will not be sent if you are not accessing Kavita via a publicly reachable url.
<span class="text-warning">If you want Send To device to work, you must host your own email service.</span>
</p>
<div class="mb-3">

View file

@ -13,9 +13,7 @@ import { SharedSideNavCardsModule } from '../shared-side-nav-cards/shared-side-n
imports: [
CommonModule,
AllSeriesRoutingModule,
SharedSideNavCardsModule
]
})
export class AllSeriesModule { }

View file

@ -228,9 +228,7 @@ export class CardDetailDrawerComponent implements OnInit, OnDestroy {
case (Action.SendTo):
{
const device = (action._extra!.data as Device);
this.deviceSerivce.sendTo(chapter.id, device.id).subscribe(() => {
this.toastr.success('File emailed to ' + device.name);
});
this.actionService.sendToDevice([chapter.id], device);
break;
}
default:

View file

@ -21,7 +21,7 @@
</p>
<virtual-scroller [ngClass]="{'empty': items.length === 0 && !isLoading}" #scroll [items]="items" [bufferAmount]="1" [parentScroll]="parentScroll">
<div class="grid row g-0" #container>
<div class="card col-auto mt-2 mb-2" *ngFor="let item of scroll.viewPortItems; trackBy:trackByIdentity; index as i" id="jumpbar-index--{{i}}" [attr.jumpbar-index]="i">
<div class="card col-auto mt-2 mb-2" (click)="tryToSaveJumpKey(item)" *ngFor="let item of scroll.viewPortItems; trackBy:trackByIdentity; index as i" id="jumpbar-index--{{i}}" [attr.jumpbar-index]="i">
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: scroll.viewPortInfo.startIndexWithBuffer + i }"></ng-container>
</div>
</div>
@ -34,7 +34,7 @@
<ng-template #cardTemplate>
<virtual-scroller #scroll [items]="items" [bufferAmount]="1">
<div class="grid row g-0" #container>
<div class="card col-auto mt-2 mb-2" *ngFor="let item of scroll.viewPortItems; trackBy:trackByIdentity; index as i" id="jumpbar-index--{{i}}" [attr.jumpbar-index]="i">
<div class="card col-auto mt-2 mb-2" (click)="tryToSaveJumpKey(item)" *ngFor="let item of scroll.viewPortItems; trackBy:trackByIdentity; index as i" id="jumpbar-index--{{i}}" [attr.jumpbar-index]="i">
<ng-container [ngTemplateOutlet]="itemTemplate" [ngTemplateOutletContext]="{ $implicit: item, idx: i }"></ng-container>
</div>
</div>

View file

@ -1,6 +1,7 @@
import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
import { DOCUMENT } from '@angular/common';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChild, ElementRef, EventEmitter, HostListener, Inject, Input, OnChanges, OnDestroy, OnInit, Output, TemplateRef, TrackByFunction, ViewChild } from '@angular/core';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChild, ElementRef, EventEmitter, HostListener,
Inject, Input, OnChanges, OnDestroy, OnInit, Output, TemplateRef, TrackByFunction, ViewChild } from '@angular/core';
import { Router } from '@angular/router';
import { VirtualScrollerComponent } from '@iharbeck/ngx-virtual-scroller';
import { Subject } from 'rxjs';
@ -13,7 +14,6 @@ import { Pagination } from 'src/app/_models/pagination';
import { FilterEvent, FilterItem, SeriesFilter } from 'src/app/_models/series-filter';
import { ActionItem } from 'src/app/_services/action-factory.service';
import { JumpbarService } from 'src/app/_services/jumpbar.service';
import { SeriesService } from 'src/app/_services/series.service';
@Component({
selector: 'app-card-detail-layout',
@ -157,4 +157,14 @@ export class CardDetailLayoutComponent implements OnInit, OnDestroy, OnChanges {
this.jumpbarService.saveResumeKey(this.router.url, jumpKey.key);
this.changeDetectionRef.markForCheck();
}
tryToSaveJumpKey(item: any) {
let name = '';
if (item.hasOwnProperty('name')) {
name = item.name;
} else if (item.hasOwnProperty('title')) {
name = item.title;
}
this.jumpbarService.saveResumeKey(this.router.url, name.charAt(0));
}
}

View file

@ -0,0 +1,18 @@
<app-splash-container>
<ng-container title><h2>Validate Email Change</h2></ng-container>
<ng-container body>
<p *ngIf="!confirmed; else confirmedMessage">Please wait while your email update is validated.</p>
<ng-template #confirmedMessage>
<div class="card">
<div class="card-body">
<div class="card-title">
<h3><i class="fa-regular fa-circle-check me-2" style="font-size: 1.8rem" aria-hidden="true"></i>Success!</h3>
</div>
<p>Your email has been validated and is now changed within Kavita. You will be redirected to login.</p>
</div>
</div>
</ng-template>
</ng-container>
</app-splash-container>

View file

@ -0,0 +1,7 @@
.card-body {
padding: 0px 0px;
}
.card {
background-color: var(--primary-color);
}

View file

@ -0,0 +1,55 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { ToastrService } from 'ngx-toastr';
import { AccountService } from 'src/app/_services/account.service';
import { NavService } from 'src/app/_services/nav.service';
import { ThemeService } from 'src/app/_services/theme.service';
/**
* This component just validates the email via API then redirects to login
*/
@Component({
selector: 'app-confirm-email-change',
templateUrl: './confirm-email-change.component.html',
styleUrls: ['./confirm-email-change.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ConfirmEmailChangeComponent implements OnInit {
email: string = '';
token: string = '';
confirmed: boolean = false;
constructor(private route: ActivatedRoute, private router: Router, private accountService: AccountService,
private toastr: ToastrService, private themeService: ThemeService, private navService: NavService,
private readonly cdRef: ChangeDetectorRef) {
this.navService.hideSideNav();
this.themeService.setTheme(this.themeService.defaultTheme);
const token = this.route.snapshot.queryParamMap.get('token');
const email = this.route.snapshot.queryParamMap.get('email');
if (this.isNullOrEmpty(token) || this.isNullOrEmpty(email)) {
// This is not a valid url, redirect to login
this.toastr.error('Invalid confirmation url');
this.router.navigateByUrl('login');
return;
}
this.token = token!;
this.email = email!;
}
ngOnInit(): void {
this.accountService.confirmEmailUpdate({email: this.email, token: this.token}).subscribe((errors) => {
this.confirmed = true;
this.cdRef.markForCheck();
setTimeout(() => this.router.navigateByUrl('login'), 2000);
});
}
isNullOrEmpty(v: string | null | undefined) {
return v == undefined || v === '' || v === null;
}
}

View file

@ -38,19 +38,23 @@ export class ConfirmEmailComponent {
const token = this.route.snapshot.queryParamMap.get('token');
const email = this.route.snapshot.queryParamMap.get('email');
this.cdRef.markForCheck();
if (token == undefined || token === '' || token === null) {
if (this.isNullOrEmpty(token) || this.isNullOrEmpty(email)) {
// This is not a valid url, redirect to login
this.toastr.error('Invalid confirmation email');
this.toastr.error('Invalid confirmation url');
this.router.navigateByUrl('login');
return;
}
this.token = token;
this.token = token!;
this.registerForm.get('email')?.setValue(email || '');
this.cdRef.markForCheck();
}
isNullOrEmpty(v: string | null | undefined) {
return v == undefined || v === '' || v === null;
}
submit() {
let model = this.registerForm.getRawValue();
const model = this.registerForm.getRawValue();
model.token = this.token;
this.accountService.confirmEmail(model).subscribe((user) => {
this.toastr.success('Account registration complete');

View file

@ -16,7 +16,7 @@
<div class="mb-3" style="width:100%">
<label for="email" class="form-label">Email</label>&nbsp;<i class="fa fa-info-circle" placement="right" [ngbTooltip]="emailTooltip" role="button" tabindex="0"></i>
<ng-template #emailTooltip>Email does not have to be valid, it is used for forgot password flow. It is not sent outside the server unless forgot password is used without a custom email service host.</ng-template>
<ng-template #emailTooltip>Email is optional and provides acccess to forgot password. It is not sent outside the server unless forgot password is used without a custom email service host.</ng-template>
<span class="visually-hidden" id="email-help">
<ng-container [ngTemplateOutlet]="emailTooltip"></ng-container>
</span>

View file

@ -18,7 +18,7 @@ import { MemberService } from 'src/app/_services/member.service';
export class RegisterComponent implements OnInit {
registerForm: FormGroup = new FormGroup({
email: new FormControl('', [Validators.required, Validators.email]),
email: new FormControl('', [Validators.email]),
username: new FormControl('', [Validators.required]),
password: new FormControl('', [Validators.required, Validators.maxLength(32), Validators.minLength(6)]),
});

View file

@ -11,6 +11,7 @@ import { ConfirmMigrationEmailComponent } from './confirm-migration-email/confir
import { ResetPasswordComponent } from './reset-password/reset-password.component';
import { ConfirmResetPasswordComponent } from './confirm-reset-password/confirm-reset-password.component';
import { UserLoginComponent } from './user-login/user-login.component';
import { ConfirmEmailChangeComponent } from './confirm-email-change/confirm-email-change.component';
@ -23,7 +24,8 @@ import { UserLoginComponent } from './user-login/user-login.component';
ConfirmMigrationEmailComponent,
ResetPasswordComponent,
ConfirmResetPasswordComponent,
UserLoginComponent
UserLoginComponent,
ConfirmEmailChangeComponent
],
imports: [
CommonModule,

View file

@ -1,5 +1,6 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { ConfirmEmailChangeComponent } from './confirm-email-change/confirm-email-change.component';
import { ConfirmEmailComponent } from './confirm-email/confirm-email.component';
import { ConfirmMigrationEmailComponent } from './confirm-migration-email/confirm-migration-email.component';
import { ConfirmResetPasswordComponent } from './confirm-reset-password/confirm-reset-password.component';
@ -24,6 +25,10 @@ const routes: Routes = [
path: 'confirm-migration-email',
component: ConfirmMigrationEmailComponent,
},
{
path: 'confirm-email-update',
component: ConfirmEmailChangeComponent,
},
{
path: 'register',
component: RegisterComponent,

View file

@ -2,7 +2,7 @@
<app-side-nav-companion-bar *ngIf="series !== undefined" [hasExtras]="true" [extraDrawer]="extrasDrawer">
<ng-container title>
<h2 class="title text-break">
<app-card-actionables [disabled]="actionInProgress" (actionHandler)="performAction($event)" [actions]="seriesActions" [labelBy]="series.name" iconClass="fa-ellipsis-v"></app-card-actionables>
<app-card-actionables (actionHandler)="performAction($event)" [actions]="seriesActions" [labelBy]="series.name" iconClass="fa-ellipsis-v"></app-card-actionables>
<span>{{series.name}}</span>
</h2>
</ng-container>
@ -81,7 +81,7 @@
</div>
<div class="col-auto ms-2 d-none d-sm-block">
<div class="card-actions">
<app-card-actionables [disabled]="actionInProgress" (actionHandler)="performAction($event)" [actions]="seriesActions" [labelBy]="series.name" iconClass="fa-ellipsis-h" btnClass="btn-secondary"></app-card-actionables>
<app-card-actionables (actionHandler)="performAction($event)" [actions]="seriesActions" [labelBy]="series.name" iconClass="fa-ellipsis-h" btnClass="btn-secondary"></app-card-actionables>
</div>
</div>

View file

@ -113,11 +113,6 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
seriesImage: string = '';
downloadInProgress: boolean = false;
/**
* If an action is currently being done, don't let the user kick off another action
*/
actionInProgress: boolean = false;
itemSize: number = 10; // when 10 done, 16 loads
/**
@ -182,7 +177,6 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
switch (action.action) {
case Action.AddToReadingList:
this.actionService.addMultipleToReadingList(seriesId, selectedVolumeIds, chapters, (success) => {
this.actionInProgress = false;
if (success) this.bulkSelectionService.deselectAll();
this.changeDetectionRef.markForCheck();
});
@ -190,7 +184,6 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
case Action.MarkAsRead:
this.actionService.markMultipleAsRead(seriesId, selectedVolumeIds, chapters, () => {
this.setContinuePoint();
this.actionInProgress = false;
this.bulkSelectionService.deselectAll();
this.changeDetectionRef.markForCheck();
});
@ -199,7 +192,6 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
case Action.MarkAsUnread:
this.actionService.markMultipleAsUnread(seriesId, selectedVolumeIds, chapters, () => {
this.setContinuePoint();
this.actionInProgress = false;
this.bulkSelectionService.deselectAll();
this.changeDetectionRef.markForCheck();
});
@ -334,70 +326,53 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
}
handleSeriesActionCallback(action: ActionItem<Series>, series: Series) {
this.actionInProgress = true;
this.changeDetectionRef.markForCheck();
switch(action.action) {
case(Action.MarkAsRead):
this.actionService.markSeriesAsRead(series, (series: Series) => {
this.actionInProgress = false;
this.loadSeries(series.id);
});
break;
case(Action.MarkAsUnread):
this.actionService.markSeriesAsUnread(series, (series: Series) => {
this.actionInProgress = false;
this.loadSeries(series.id);
});
break;
case(Action.Scan):
this.actionService.scanSeries(series, () => {
this.actionInProgress = false;
this.changeDetectionRef.markForCheck();
});
this.actionService.scanSeries(series);
break;
case(Action.RefreshMetadata):
this.actionService.refreshMetdata(series, () => {
this.actionInProgress = false;
this.changeDetectionRef.markForCheck();
});
this.actionService.refreshMetdata(series);
break;
case(Action.Delete):
this.deleteSeries(series);
break;
case(Action.AddToReadingList):
this.actionService.addSeriesToReadingList(series, () => {
this.actionInProgress = false;
this.changeDetectionRef.markForCheck();
});
this.actionService.addSeriesToReadingList(series);
break;
case(Action.AddToCollection):
this.actionService.addMultipleSeriesToCollectionTag([series], () => {
this.actionInProgress = false;
this.changeDetectionRef.markForCheck();
});
this.actionService.addMultipleSeriesToCollectionTag([series]);
break;
case (Action.AnalyzeFiles):
this.actionService.analyzeFilesForSeries(series, () => {
this.actionInProgress = false;
this.changeDetectionRef.markForCheck();
});
this.actionService.analyzeFilesForSeries(series);
break;
case Action.AddToWantToReadList:
this.actionService.addMultipleSeriesToWantToReadList([series.id], () => {
this.actionInProgress = false;
this.changeDetectionRef.markForCheck();
});
this.actionService.addMultipleSeriesToWantToReadList([series.id]);
break;
case Action.RemoveFromWantToReadList:
this.actionService.removeMultipleSeriesFromWantToReadList([series.id], () => {
this.actionInProgress = false;
this.changeDetectionRef.markForCheck();
});
this.actionService.removeMultipleSeriesFromWantToReadList([series.id]);
break;
case (Action.Download):
case Action.Download:
if (this.downloadInProgress) return;
this.downloadSeries();
break;
case Action.SendTo:
{
const chapterIds = this.volumes.map(v => v.chapters.map(c => c.id)).flat()
const device = (action._extra!.data as Device);
this.actionService.sendToDevice(chapterIds, device);
break;
}
default:
break;
}
@ -422,6 +397,12 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
this.openChapter(volume.chapters.sort(this.utilityService.sortChapters)[0], true);
}
break;
case (Action.SendTo):
{
const device = (action._extra!.data as Device);
this.actionService.sendToDevice(volume.chapters.map(c => c.id), device);
break;
}
default:
break;
}
@ -447,7 +428,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
case (Action.SendTo):
{
const device = (action._extra!.data as Device);
this.deviceSerivce.sendTo(chapter.id, device.id).subscribe(() => {
this.deviceSerivce.sendTo([chapter.id], device.id).subscribe(() => {
this.toastr.success('File emailed to ' + device.name);
});
break;
@ -460,7 +441,6 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
async deleteSeries(series: Series) {
this.actionService.deleteSeries(series, (result: boolean) => {
this.actionInProgress = false;
this.changeDetectionRef.markForCheck();
if (result) {
this.router.navigate(['library', this.libraryId]);
@ -604,8 +584,6 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
this.actionService.markVolumeAsRead(this.seriesId, vol, () => {
this.setContinuePoint();
this.actionInProgress = false;
this.changeDetectionRef.markForCheck();
});
}
@ -616,8 +594,6 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
this.actionService.markVolumeAsUnread(this.seriesId, vol, () => {
this.setContinuePoint();
this.actionInProgress = false;
this.changeDetectionRef.markForCheck();
});
}
@ -628,8 +604,6 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
this.actionService.markChapterAsRead(this.seriesId, chapter, () => {
this.setContinuePoint();
this.actionInProgress = false;
this.changeDetectionRef.markForCheck();
});
}
@ -640,8 +614,6 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe
this.actionService.markChapterAsUnread(this.seriesId, chapter, () => {
this.setContinuePoint();
this.actionInProgress = false;
this.changeDetectionRef.markForCheck();
});
}

View file

@ -11,7 +11,6 @@ import { SidenavModule } from '../sidenav/sidenav.module';
declarations: [],
imports: [
CommonModule,
CardsModule,
SidenavModule,
],

View file

@ -7,7 +7,6 @@ import { ReadMoreComponent } from './read-more/read-more.component';
import { RouterModule } from '@angular/router';
import { DrawerComponent } from './drawer/drawer.component';
import { TagBadgeComponent } from './tag-badge/tag-badge.component';
import { ShowIfScrollbarDirective } from './show-if-scrollbar.directive';
import { A11yClickDirective } from './a11y-click.directive';
import { SeriesFormatComponent } from './series-format/series-format.component';
import { UpdateNotificationModalComponent } from './update-notification/update-notification-modal.component';
@ -25,7 +24,6 @@ import { IconAndTitleComponent } from './icon-and-title/icon-and-title.component
ReadMoreComponent,
DrawerComponent,
TagBadgeComponent,
ShowIfScrollbarDirective,
A11yClickDirective,
SeriesFormatComponent,
UpdateNotificationModalComponent,
@ -50,16 +48,12 @@ import { IconAndTitleComponent } from './icon-and-title/icon-and-title.component
A11yClickDirective, // Used globally
SeriesFormatComponent, // Used globally
TagBadgeComponent, // Used globally
CircularLoaderComponent, // Used in Cards only
CircularLoaderComponent, // Used in Cards and Series Detail
ImageComponent, // Used globally
ShowIfScrollbarDirective, // Used book reader only?
PersonBadgeComponent, // Used Series Detail
BadgeExpanderComponent, // Used Series Detail/Metadata
IconAndTitleComponent, // Used in Series Detail/Metadata
],
})
export class SharedModule { }

View file

@ -1,23 +0,0 @@
import { AfterViewInit, Directive, ElementRef, TemplateRef, ViewContainerRef } from '@angular/core';
// TODO: Fix this code or remove it
@Directive({
selector: '[appShowIfScrollbar]'
})
export class ShowIfScrollbarDirective implements AfterViewInit {
constructor(private el: ElementRef, private templateRef: TemplateRef<any>, private viewContainer: ViewContainerRef) {
}
ngAfterViewInit(): void {
// NOTE: This doesn't work!
if (this.el.nativeElement.scrollHeight > this.el.nativeElement.clientHeight) {
// If condition is true add template to DOM
this.viewContainer.createEmbeddedView(this.templateRef);
} else {
// Else remove template from DOM
this.viewContainer.clear();
}
}
}

View file

@ -0,0 +1,66 @@
<div class="card mt-2">
<div class="card-body">
<div class="card-title">
<div class="row mb-2">
<div class="col-11">
<h4 id="email-card">Email
<ng-container *ngIf="!emailConfirmed">
<i class="fa-solid fa-circle ms-1 confirm-icon" aria-hidden="true" ngbTooltip="This email is not confirmed"></i>
<span class="visually-hidden">This email is not confirmed</span>
</ng-container>
</h4>
</div>
<div class="col-1">
<button class="btn btn-primary btn-sm" (click)="toggleViewMode()">{{isViewMode ? 'Edit' : 'Cancel'}}</button>
</div>
</div>
</div>
<ng-container *ngIf="isViewMode">
<span>{{user?.email}}</span>
</ng-container>
<div #collapse="ngbCollapse" [(ngbCollapse)]="isViewMode">
<ng-container>
<div class="alert alert-danger" role="alert" *ngIf="errors.length > 0">
<div *ngFor="let error of errors">{{error}}</div>
</div>
<form [formGroup]="form">
<div class="mb-3">
<label for="email" class="form-label visually-hidden">Email</label>
<input class="form-control custom-input" type="email" id="email" formControlName="email"
[class.is-invalid]="form.get('email')?.invalid && form.get('email')?.touched">
<div id="email-validations" class="invalid-feedback" *ngIf="form.dirty || form.touched">
<div *ngIf="form.get('email')?.errors?.required">
This field is required
</div>
</div>
</div>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
<button type="button" class="flex-fill btn btn-secondary me-2" aria-describedby="email-card" (click)="resetForm()">Reset</button>
<button type="submit" class="flex-fill btn btn-primary" aria-describedby="email-card" (click)="saveForm()" [disabled]="!form.valid || !(form.dirty || form.touched)">Save</button>
</div>
</form>
</ng-container>
<ng-container *ngIf="emailLink !== ''">
<h4>Email Updated</h4>
<p>You can use the following link below to confirm the email for your account.
If your server is externally accessible, an email will have been sent to the email and the link can be used to confirm the email.
</p>
<a class="email-link" href="{{emailLink}}" target="_blank" rel="noopener noreferrer">Setup user's account</a>
<app-api-key title="Invite Url" tooltipText="Copy this and paste in a new tab. You may need to log out." [showRefresh]="false" [transform]="makeLink"></app-api-key>
</ng-container>
<ng-template #noPermission>
<p>You do not have permission to change your password. Reach out to the admin of the server.</p>
</ng-template>
</div>
</div>
</div>

View file

@ -0,0 +1,5 @@
.confirm-icon {
color: var(--primary-color);
font-size: 14px;
margin-bottom: 10px;
}

View file

@ -0,0 +1,86 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { FormGroup, FormControl, Validators } from '@angular/forms';
import { ToastrService } from 'ngx-toastr';
import { Observable, of, Subject, takeUntil, shareReplay, map, tap, take } from 'rxjs';
import { UpdateEmailResponse } from 'src/app/_models/email/update-email-response';
import { User } from 'src/app/_models/user';
import { AccountService } from 'src/app/_services/account.service';
@Component({
selector: 'app-change-email',
templateUrl: './change-email.component.html',
styleUrls: ['./change-email.component.scss']
})
export class ChangeEmailComponent implements OnInit, OnDestroy {
form: FormGroup = new FormGroup({});
user: User | undefined = undefined;
hasChangePasswordAbility: Observable<boolean> = of(false);
passwordsMatch = false;
errors: string[] = [];
isViewMode: boolean = true;
emailLink: string = '';
emailConfirmed: boolean = true;
public get email() { return this.form.get('email'); }
private onDestroy = new Subject<void>();
makeLink: (val: string) => string = (val: string) => {return this.emailLink};
constructor(public accountService: AccountService, private toastr: ToastrService, private readonly cdRef: ChangeDetectorRef) { }
ngOnInit(): void {
this.accountService.currentUser$.pipe(takeUntil(this.onDestroy), shareReplay(), take(1)).subscribe(user => {
this.user = user;
this.form.addControl('email', new FormControl(user?.email, [Validators.required, Validators.email]));
this.cdRef.markForCheck();
this.accountService.isEmailConfirmed().subscribe((confirmed) => {
this.emailConfirmed = confirmed;
this.cdRef.markForCheck();
});
});
}
ngOnDestroy() {
this.onDestroy.next();
this.onDestroy.complete();
}
resetForm() {
this.form.get('email')?.setValue(this.user?.email);
this.errors = [];
this.cdRef.markForCheck();
}
saveForm() {
if (this.user === undefined) { return; }
const model = this.form.value;
this.errors = [];
this.accountService.updateEmail(model.email).subscribe((updateEmailResponse: UpdateEmailResponse) => {
if (updateEmailResponse.emailSent) {
if (updateEmailResponse.hadNoExistingEmail) {
this.toastr.success('An email has been sent to ' + model.email + ' for confirmation.');
} else {
this.toastr.success('An email has been sent to your old email address for confirmation');
}
} else {
this.toastr.success('The server is not publicly accessible. Ask the admin to fetch your confirmation link from the logs');
}
this.resetForm();
this.isViewMode = true;
}, err => {
this.errors = err;
})
}
toggleViewMode() {
this.isViewMode = !this.isViewMode;
this.resetForm();
}
}

View file

@ -0,0 +1,73 @@
<div class="card mt-2">
<div class="card-body">
<div class="card-title">
<div class="row mb-2">
<div class="col-11"><h4>Password</h4></div>
<div class="col-1">
<button class="btn btn-primary btn-sm" (click)="toggleViewMode()" *ngIf="(hasChangePasswordAbility | async)">{{isViewMode ? 'Edit' : 'Cancel'}}</button>
</div>
</div>
</div>
<ng-container *ngIf="isViewMode">
<span>***************</span>
</ng-container>
<div #collapse="ngbCollapse" [(ngbCollapse)]="isViewMode">
<ng-container>
<div class="alert alert-danger" role="alert" *ngIf="resetPasswordErrors.length > 0">
<div *ngFor="let error of resetPasswordErrors">{{error}}</div>
</div>
<form [formGroup]="passwordChangeForm">
<div class="mb-3">
<label for="oldpass" class="form-label">Current Password</label>
<input class="form-control custom-input" type="password" id="oldpass" formControlName="oldPassword"
[class.is-invalid]="passwordChangeForm.get('oldPassword')?.invalid && passwordChangeForm.get('oldPassword')?.touched">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="passwordChangeForm.dirty || passwordChangeForm.touched">
<div *ngIf="passwordChangeForm.get('oldPassword')?.errors?.required">
This field is required
</div>
</div>
</div>
<div class="mb-3">
<label for="new-password">New Password</label>
<input class="form-control" type="password" id="new-password" formControlName="password"
[class.is-invalid]="passwordChangeForm.get('password')?.invalid && passwordChangeForm.get('password')?.touched">
<div id="password-validations" class="invalid-feedback" *ngIf="passwordChangeForm.dirty || passwordChangeForm.touched">
<div *ngIf="password?.errors?.required">
This field is required
</div>
</div>
</div>
<div class="mb-3">
<label for="confirm-password">Confirm Password</label>
<input class="form-control" type="password" id="confirm-password" formControlName="confirmPassword" aria-describedby="password-validations"
[class.is-invalid]="passwordChangeForm.get('confirmPassword')?.invalid && passwordChangeForm.get('confirmPassword')?.touched">
<div id="password-validations" class="invalid-feedback" *ngIf="passwordChangeForm.dirty || passwordChangeForm.touched">
<div *ngIf="!passwordsMatch">
Passwords must match
</div>
<div *ngIf="confirmPassword?.errors?.required">
This field is required
</div>
</div>
</div>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
<button type="button" class="flex-fill btn btn-secondary me-2" aria-describedby="password-panel" (click)="resetPasswordForm()">Reset</button>
<button type="submit" class="flex-fill btn btn-primary" aria-describedby="password-panel" (click)="savePasswordForm()" [disabled]="!passwordChangeForm.valid || !(passwordChangeForm.dirty || passwordChangeForm.touched)">Save</button>
</div>
</form>
</ng-container>
<ng-template #noPermission>
<p>You do not have permission to change your password. Reach out to the admin of the server.</p>
</ng-template>
</div>
</div>
</div>

View file

@ -0,0 +1,82 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { ToastrService } from 'ngx-toastr';
import { map, Observable, of, shareReplay, Subject, takeUntil } from 'rxjs';
import { User } from 'src/app/_models/user';
import { AccountService } from 'src/app/_services/account.service';
@Component({
selector: 'app-change-password',
templateUrl: './change-password.component.html',
styleUrls: ['./change-password.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ChangePasswordComponent implements OnInit, OnDestroy {
passwordChangeForm: FormGroup = new FormGroup({});
user: User | undefined = undefined;
hasChangePasswordAbility: Observable<boolean> = of(false);
observableHandles: Array<any> = [];
passwordsMatch = false;
resetPasswordErrors: string[] = [];
isViewMode: boolean = true;
public get password() { return this.passwordChangeForm.get('password'); }
public get confirmPassword() { return this.passwordChangeForm.get('confirmPassword'); }
private onDestroy = new Subject<void>();
constructor(private accountService: AccountService, private toastr: ToastrService, private readonly cdRef: ChangeDetectorRef) { }
ngOnInit(): void {
this.hasChangePasswordAbility = this.accountService.currentUser$.pipe(takeUntil(this.onDestroy), shareReplay(), map(user => {
return user !== undefined && (this.accountService.hasAdminRole(user) || this.accountService.hasChangePasswordRole(user));
}));
this.cdRef.markForCheck();
this.passwordChangeForm.addControl('password', new FormControl('', [Validators.required]));
this.passwordChangeForm.addControl('confirmPassword', new FormControl('', [Validators.required]));
this.passwordChangeForm.addControl('oldPassword', new FormControl('', [Validators.required]));
this.observableHandles.push(this.passwordChangeForm.valueChanges.subscribe(() => {
const values = this.passwordChangeForm.value;
this.passwordsMatch = values.password === values.confirmPassword;
this.cdRef.markForCheck();
}));
}
ngOnDestroy() {
this.observableHandles.forEach(o => o.unsubscribe());
this.onDestroy.next();
this.onDestroy.complete();
}
resetPasswordForm() {
this.passwordChangeForm.get('password')?.setValue('');
this.passwordChangeForm.get('confirmPassword')?.setValue('');
this.passwordChangeForm.get('oldPassword')?.setValue('');
this.resetPasswordErrors = [];
this.cdRef.markForCheck();
}
savePasswordForm() {
if (this.user === undefined) { return; }
const model = this.passwordChangeForm.value;
this.resetPasswordErrors = [];
this.observableHandles.push(this.accountService.resetPassword(this.user?.username, model.confirmPassword, model.oldPassword).subscribe(() => {
this.toastr.success('Password has been updated');
this.resetPasswordForm();
this.isViewMode = true;
}, err => {
this.resetPasswordErrors = err;
}));
}
toggleViewMode() {
this.isViewMode = !this.isViewMode;
this.resetPasswordForm();
}
}

View file

@ -8,7 +8,11 @@
<li *ngFor="let tab of tabs" [ngbNavItem]="tab">
<a ngbNavLink routerLink="." [fragment]="tab.fragment">{{ tab.title | sentenceCase }}</a>
<ng-template ngbNavContent>
<ng-container *ngIf="tab.fragment === ''">
<ng-container *ngIf="tab.fragment === FragmentID.Account">
<app-change-email></app-change-email>
<app-change-password></app-change-password>
</ng-container>
<ng-container *ngIf="tab.fragment === FragmentID.Prefernces">
<p>
These are global settings that are bound to your account.
</p>
@ -289,68 +293,18 @@
</ngb-accordion>
</form>
</ng-container>
<ng-container *ngIf="tab.fragment === 'password'">
<ng-container *ngIf="(hasChangePasswordAbility | async); else noPermission">
<p>Change your Password</p>
<div class="alert alert-danger" role="alert" *ngIf="resetPasswordErrors.length > 0">
<div *ngFor="let error of resetPasswordErrors">{{error}}</div>
</div>
<form [formGroup]="passwordChangeForm">
<div class="mb-3">
<label for="oldpass" class="form-label">Current Password</label>
<input class="form-control custom-input" type="password" id="oldpass" formControlName="oldPassword"
[class.is-invalid]="passwordChangeForm.get('oldPassword')?.invalid && passwordChangeForm.get('oldPassword')?.touched">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="passwordChangeForm.dirty || passwordChangeForm.touched">
<div *ngIf="passwordChangeForm.get('oldPassword')?.errors?.required">
This field is required
</div>
</div>
</div>
<div class="mb-3">
<label for="new-password">New Password</label>
<input class="form-control" type="password" id="new-password" formControlName="password"
[class.is-invalid]="passwordChangeForm.get('password')?.invalid && passwordChangeForm.get('password')?.touched">
<div id="password-validations" class="invalid-feedback" *ngIf="passwordChangeForm.dirty || passwordChangeForm.touched">
<div *ngIf="password?.errors?.required">
This field is required
</div>
</div>
</div>
<div class="mb-3">
<label for="confirm-password">Confirm Password</label>
<input class="form-control" type="password" id="confirm-password" formControlName="confirmPassword" aria-describedby="password-validations"
[class.is-invalid]="passwordChangeForm.get('confirmPassword')?.invalid && passwordChangeForm.get('confirmPassword')?.touched">
<div id="password-validations" class="invalid-feedback" *ngIf="passwordChangeForm.dirty || passwordChangeForm.touched">
<div *ngIf="!passwordsMatch">
Passwords must match
</div>
<div *ngIf="confirmPassword?.errors?.required">
This field is required
</div>
</div>
</div>
<div class="col-auto d-flex d-md-block justify-content-sm-center text-md-end mb-3">
<button type="button" class="flex-fill btn btn-secondary me-2" aria-describedby="password-panel" (click)="resetPasswordForm()">Reset</button>
<button type="submit" class="flex-fill btn btn-primary" aria-describedby="password-panel" (click)="savePasswordForm()" [disabled]="!passwordChangeForm.valid || !(passwordChangeForm.dirty || passwordChangeForm.touched)">Save</button>
</div>
</form>
</ng-container>
<ng-template #noPermission>
<p>You do not have permission to change your password. Reach out to the admin of the server.</p>
</ng-template>
</ng-container>
<ng-container *ngIf="tab.fragment === 'clients'">
<ng-container *ngIf="tab.fragment === FragmentID.Clients">
<p>All 3rd Party clients will either use the API key or the Connection Url below. These are like passwords, keep it private.</p>
<p class="alert alert-warning" role="alert" *ngIf="!opdsEnabled">OPDS is not enabled on this server.</p>
<app-api-key tooltipText="The API key is like a password. Keep it secret, Keep it safe."></app-api-key>
<app-api-key title="OPDS URL" [showRefresh]="false" [transform]="makeUrl"></app-api-key>
</ng-container>
<ng-container *ngIf="tab.fragment === 'theme'">
<ng-container *ngIf="tab.fragment === FragmentID.Theme">
<app-theme-manager></app-theme-manager>
</ng-container>
<ng-container *ngIf="tab.fragment === 'devices'">
<ng-container *ngIf="tab.fragment === FragmentID.Devices">
<app-manage-devices></app-manage-devices>
</ng-container>
</ng-template>

View file

@ -1,7 +1,7 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { FormControl, FormGroup } from '@angular/forms';
import { ToastrService } from 'ngx-toastr';
import { map, shareReplay, take, takeUntil } from 'rxjs/operators';
import { take, takeUntil } from 'rxjs/operators';
import { Title } from '@angular/platform-browser';
import { BookService } from 'src/app/book-reader/book.service';
import { readingDirections, scalingOptions, pageSplitOptions, readingModes, Preferences, bookLayoutModes, layoutModes, pageLayoutModes } from 'src/app/_models/preferences/preferences';
@ -11,7 +11,7 @@ import { ActivatedRoute, Router } from '@angular/router';
import { SettingsService } from 'src/app/admin/settings.service';
import { bookColorThemes } from 'src/app/book-reader/reader-settings/reader-settings.component';
import { BookPageLayoutMode } from 'src/app/_models/book-page-layout-mode';
import { forkJoin, Observable, of, Subject } from 'rxjs';
import { forkJoin, Subject } from 'rxjs';
enum AccordionPanelID {
ImageReader = 'image-reader',
@ -19,6 +19,15 @@ enum AccordionPanelID {
GlobalSettings = 'global-settings'
}
enum FragmentID {
Account = 'account',
Prefernces = '',
Clients = 'clients',
Theme = 'theme',
Devices = 'devices',
}
@Component({
selector: 'app-user-preferences',
templateUrl: './user-preferences.component.html',
@ -37,9 +46,7 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
pageLayoutModes = pageLayoutModes;
settingsForm: FormGroup = new FormGroup({});
passwordChangeForm: FormGroup = new FormGroup({});
user: User | undefined = undefined;
hasChangePasswordAbility: Observable<boolean> = of(false);
passwordsMatch = false;
resetPasswordErrors: string[] = [];
@ -48,11 +55,11 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
fontFamilies: Array<string> = [];
tabs: Array<{title: string, fragment: string}> = [
{title: 'Preferences', fragment: ''},
{title: 'Password', fragment: 'password'},
{title: '3rd Party Clients', fragment: 'clients'},
{title: 'Theme', fragment: 'theme'},
{title: 'Devices', fragment: 'devices'},
{title: 'Account', fragment: FragmentID.Account},
{title: 'Preferences', fragment: FragmentID.Prefernces},
{title: '3rd Party Clients', fragment: FragmentID.Clients},
{title: 'Theme', fragment: FragmentID.Theme},
{title: 'Devices', fragment: FragmentID.Devices},
];
active = this.tabs[0];
opdsEnabled: boolean = false;
@ -64,8 +71,10 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
return AccordionPanelID;
}
public get password() { return this.passwordChangeForm.get('password'); }
public get confirmPassword() { return this.passwordChangeForm.get('confirmPassword'); }
get FragmentID() {
return FragmentID;
}
constructor(private accountService: AccountService, private toastr: ToastrService, private bookService: BookService,
private titleService: Title, private route: ActivatedRoute, private settingsService: SettingsService,
@ -92,11 +101,6 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
ngOnInit(): void {
this.titleService.setTitle('Kavita - User Preferences');
this.hasChangePasswordAbility = this.accountService.currentUser$.pipe(takeUntil(this.onDestroy), shareReplay(), map(user => {
return user !== undefined && (this.accountService.hasAdminRole(user) || this.accountService.hasChangePasswordRole(user));
}));
this.cdRef.markForCheck();
forkJoin({
user: this.accountService.currentUser$.pipe(take(1)),
pref: this.accountService.getPreferences()
@ -139,18 +143,6 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
this.cdRef.markForCheck();
});
this.passwordChangeForm.addControl('password', new FormControl('', [Validators.required]));
this.passwordChangeForm.addControl('confirmPassword', new FormControl('', [Validators.required]));
this.passwordChangeForm.addControl('oldPassword', new FormControl('', [Validators.required]));
this.observableHandles.push(this.passwordChangeForm.valueChanges.subscribe(() => {
const values = this.passwordChangeForm.value;
this.passwordsMatch = values.password === values.confirmPassword;
this.cdRef.markForCheck();
}));
this.settingsForm.get('bookReaderImmersiveMode')?.valueChanges.pipe(takeUntil(this.onDestroy)).subscribe(mode => {
if (mode) {
this.settingsForm.get('bookReaderTapToPaginate')?.setValue(true);
@ -194,14 +186,6 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
this.settingsForm.markAsPristine();
}
resetPasswordForm() {
this.passwordChangeForm.get('password')?.setValue('');
this.passwordChangeForm.get('confirmPassword')?.setValue('');
this.passwordChangeForm.get('oldPassword')?.setValue('');
this.resetPasswordErrors = [];
this.cdRef.markForCheck();
}
save() {
if (this.user === undefined) return;
const modelSettings = this.settingsForm.value;
@ -240,18 +224,6 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
}));
}
savePasswordForm() {
if (this.user === undefined) { return; }
const model = this.passwordChangeForm.value;
this.resetPasswordErrors = [];
this.observableHandles.push(this.accountService.resetPassword(this.user?.username, model.confirmPassword, model.oldPassword).subscribe(() => {
this.toastr.success('Password has been updated');
this.resetPasswordForm();
}, err => {
this.resetPasswordErrors = err;
}));
}
transformKeyToOpdsUrl(key: string) {
return `${location.origin}/api/opds/${key}`;

View file

@ -13,6 +13,8 @@ import { SidenavModule } from '../sidenav/sidenav.module';
import { ManageDevicesComponent } from './manage-devices/manage-devices.component';
import { DevicePlatformPipe } from './_pipes/device-platform.pipe';
import { EditDeviceComponent } from './edit-device/edit-device.component';
import { ChangePasswordComponent } from './change-password/change-password.component';
import { ChangeEmailComponent } from './change-email/change-email.component';
@NgModule({
@ -24,6 +26,8 @@ import { EditDeviceComponent } from './edit-device/edit-device.component';
ManageDevicesComponent,
DevicePlatformPipe,
EditDeviceComponent,
ChangePasswordComponent,
ChangeEmailComponent,
],
imports: [
CommonModule,