Restricted Profiles (#1581)

* Added ReadingList age rating from all series and started on some unit tests for the new flows.

* Wrote more unit tests for Reading Lists

* Added ability to restrict user accounts to a given age rating via admin edit user modal and invite user. This commit contains all basic code, but no query modifications.

* When updating a reading list's title via UI, explicitly check if there is an existing RL with the same title.

* Refactored Reading List calculation to work properly in the flows it's invoked from.

* Cleaned up an unused method

* Promoted Collections no longer show tags where a Series exists within them that is above the user's age rating.

* Collection search now respects age restrictions

* Series Detail page now checks if the user has explicit access (as a user might bypass with direct url access)

* Hooked up age restriction for dashboard activity streams.

* Refactored some methods from Series Controller and Library Controller to a new Search Controller to keep things organized

* Updated Search to respect age restrictions

* Refactored all the Age Restriction queries to extensions

* Related Series no longer show up if they are out of the age restriction

* Fixed a bad mapping for the update age restriction api

* Fixed a UI state change after updating age restriction

* Fixed unit test

* Added a migration for reading lists

* Code cleanup
This commit is contained in:
Joe Milazzo 2022-10-10 12:59:20 -05:00 committed by GitHub
parent 0ad1638ec0
commit 442af965c6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
63 changed files with 4638 additions and 262 deletions

View file

@ -1,4 +1,5 @@
import { Library } from './library';
import { AgeRating } from './metadata/age-rating';
export interface Member {
id: number;
@ -6,7 +7,10 @@ export interface Member {
email: string;
lastActive: string; // datetime
created: string; // datetime
//isAdmin: boolean;
roles: string[];
libraries: Library[];
/**
* If not applicable, will store a -1
*/
ageRestriction: AgeRating;
}

View file

@ -1,4 +1,8 @@
export enum AgeRating {
/**
* This is not a valid state for Series/Chapters, but used for Restricted Profiles
*/
NotApplicable = -1,
Unknown = 0,
AdultsOnly = 1,
EarlyChildhood = 2,

View file

@ -1,3 +1,4 @@
import { AgeRating } from './metadata/age-rating';
import { Preferences } from './preferences/preferences';
// This interface is only used for login and storing/retreiving JWT from local storage
@ -9,4 +10,5 @@ export interface User {
preferences: Preferences;
apiKey: string;
email: string;
ageRestriction: AgeRating;
}

View file

@ -10,8 +10,16 @@ import { EVENTS, MessageHubService } from './message-hub.service';
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';
import { AgeRating } from '../_models/metadata/age-rating';
export enum Role {
Admin = 'Admin',
ChangePassword = 'Change Password',
Bookmark = 'Bookmark',
Download = 'Download',
ChangeRestriction = 'Change Restriction'
}
@Injectable({
providedIn: 'root'
@ -49,19 +57,23 @@ export class AccountService implements OnDestroy {
}
hasAdminRole(user: User) {
return user && user.roles.includes('Admin');
return user && user.roles.includes(Role.Admin);
}
hasChangePasswordRole(user: User) {
return user && user.roles.includes('Change Password');
return user && user.roles.includes(Role.ChangePassword);
}
hasChangeAgeRestrictionRole(user: User) {
return user && user.roles.includes(Role.ChangeRestriction);
}
hasDownloadRole(user: User) {
return user && user.roles.includes('Download');
return user && user.roles.includes(Role.Download);
}
hasBookmarkRole(user: User) {
return user && user.roles.includes('Bookmark');
return user && user.roles.includes(Role.Bookmark);
}
getRoles() {
@ -149,7 +161,7 @@ export class AccountService implements OnDestroy {
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>}) {
inviteUser(model: {email: string, roles: Array<string>, libraries: Array<number>, ageRestriction: AgeRating}) {
return this.httpClient.post<InviteUserResponse>(this.baseUrl + 'account/invite', model);
}
@ -186,7 +198,7 @@ export class AccountService implements OnDestroy {
return this.httpClient.post(this.baseUrl + 'account/reset-password', {username, password, oldPassword}, {responseType: 'json' as 'text'});
}
update(model: {email: string, roles: Array<string>, libraries: Array<number>, userId: number}) {
update(model: {email: string, roles: Array<string>, libraries: Array<number>, userId: number, ageRestriction: AgeRating}) {
return this.httpClient.post(this.baseUrl + 'account/update', model);
}
@ -194,6 +206,10 @@ export class AccountService implements OnDestroy {
return this.httpClient.post<UpdateEmailResponse>(this.baseUrl + 'account/update/email', {email});
}
updateAgeRestriction(ageRating: AgeRating) {
return this.httpClient.post(this.baseUrl + 'account/update/age-restriction', {ageRating});
}
/**
* This will get latest preferences for a user and cache them into user store
* @returns

View file

@ -113,12 +113,4 @@ export class LibraryService {
return this.libraryTypes[libraryId];
}));
}
search(term: string) {
if (term === '') {
return of(new SearchResultGroup());
}
return this.httpClient.get<SearchResultGroup>(this.baseUrl + 'library/search?queryString=' + encodeURIComponent(term));
}
}

View file

@ -0,0 +1,31 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { of } from 'rxjs';
import { environment } from 'src/environments/environment';
import { SearchResultGroup } from '../_models/search/search-result-group';
import { Series } from '../_models/series';
@Injectable({
providedIn: 'root'
})
export class SearchService {
baseUrl = environment.apiUrl;
constructor(private httpClient: HttpClient) { }
search(term: string) {
if (term === '') {
return of(new SearchResultGroup());
}
return this.httpClient.get<SearchResultGroup>(this.baseUrl + 'search/search?queryString=' + encodeURIComponent(term));
}
getSeriesForMangaFile(mangaFileId: number) {
return this.httpClient.get<Series | null>(this.baseUrl + 'search/series-for-mangafile?mangaFileId=' + mangaFileId);
}
getSeriesForChapter(chapterId: number) {
return this.httpClient.get<Series | null>(this.baseUrl + 'search/series-for-chapter?chapterId=' + chapterId);
}
}

View file

@ -78,14 +78,6 @@ export class SeriesService {
return this.httpClient.get<ChapterMetadata>(this.baseUrl + 'series/chapter-metadata?chapterId=' + chapterId);
}
getSeriesForMangaFile(mangaFileId: number) {
return this.httpClient.get<Series | null>(this.baseUrl + 'series/series-for-mangafile?mangaFileId=' + mangaFileId);
}
getSeriesForChapter(chapterId: number) {
return this.httpClient.get<Series | null>(this.baseUrl + 'series/series-for-chapter?chapterId=' + chapterId);
}
delete(seriesId: number) {
return this.httpClient.delete<boolean>(this.baseUrl + 'series/' + seriesId);
}

View file

@ -1,58 +1,66 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Edit {{member.username | sentenceCase}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
</button>
</div>
<div class="modal-body">
<div class="modal-container">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Edit {{member.username | sentenceCase}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
</button>
</div>
<div class="modal-body scrollable-modal">
<form [formGroup]="userForm">
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input id="username" class="form-control" formControlName="username" type="text" [class.is-invalid]="userForm.get('username')?.invalid && userForm.get('username')?.touched">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="userForm.dirty || userForm.touched">
<div *ngIf="userForm.get('username')?.errors?.required">
This field is required
<form [formGroup]="userForm">
<div class="row g-0">
<div class="col-md-6 col-sm-12 pe-2">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input id="username" class="form-control" formControlName="username" type="text" [class.is-invalid]="userForm.get('username')?.invalid && userForm.get('username')?.touched">
<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="mb-3" style="width:100%">
<label for="email" class="form-label">Email</label>
<input class="form-control" type="email" id="email" formControlName="email">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="userForm.dirty || userForm.touched" [class.is-invalid]="userForm.get('email')?.invalid && userForm.get('email')?.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="col-md-6 col-sm-12">
<div class="mb-3" style="width:100%">
<label for="email" class="form-label">Email</label>
<input class="form-control" type="email" id="email" formControlName="email">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="userForm.dirty || userForm.touched" [class.is-invalid]="userForm.get('email')?.invalid && userForm.get('email')?.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 class="row g-0">
<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>
</div>
<div class="row g-0">
<div class="col-md-6">
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true" [member]="member"></app-role-selector>
<div class="row g-0">
<div class="col-md-12">
<app-restriction-selector (selected)="updateRestrictionSelection($event)" [isAdmin]="hasAdminRoleSelected" [member]="member"></app-restriction-selector>
</div>
</div>
</form>
<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>
<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>
</div>

View file

@ -3,6 +3,7 @@ import { FormGroup, FormControl, Validators } from '@angular/forms';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { Library } from 'src/app/_models/library';
import { Member } from 'src/app/_models/member';
import { AgeRating } from 'src/app/_models/metadata/age-rating';
import { AccountService } from 'src/app/_services/account.service';
// TODO: Rename this to EditUserModal
@ -17,6 +18,7 @@ export class EditUserComponent implements OnInit {
selectedRoles: Array<string> = [];
selectedLibraries: Array<number> = [];
selectedRating: AgeRating = AgeRating.NotApplicable;
isSaving: boolean = false;
userForm: FormGroup = new FormGroup({});
@ -24,6 +26,7 @@ export class EditUserComponent implements OnInit {
public get email() { return this.userForm.get('email'); }
public get username() { return this.userForm.get('username'); }
public get password() { return this.userForm.get('password'); }
public get hasAdminRoleSelected() { return this.selectedRoles.includes('Admin'); };
constructor(public modal: NgbActiveModal, private accountService: AccountService) { }
@ -38,6 +41,10 @@ export class EditUserComponent implements OnInit {
this.selectedRoles = roles;
}
updateRestrictionSelection(rating: AgeRating) {
this.selectedRating = rating;
}
updateLibrarySelection(libraries: Array<Library>) {
this.selectedLibraries = libraries.map(l => l.id);
}
@ -51,6 +58,8 @@ export class EditUserComponent implements OnInit {
model.userId = this.member.id;
model.roles = this.selectedRoles;
model.libraries = this.selectedLibraries;
model.ageRestriction = this.selectedRating || AgeRating.NotApplicable;
console.log('rating: ', this.selectedRating);
this.accountService.update(model).subscribe(() => {
this.modal.close(true);
});

View file

@ -1,55 +1,61 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Invite User</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()">
<div class="modal-container">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">Invite User</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div>
<div class="modal-body scrollable-modal">
<p>
Invite a user to your server. Enter their email in and we will send them an email to create an account. If you do not want to use our email service, you can <a href="https://wiki.kavitareader.com/en/guides/misc/email" rel="noopener noreferrer" target="_blank" rel="noopener noreferrer">host your own</a>
email service or use a fake email (Forgot User will not work). A link will be presented regardless and can be used to setup the email account manually.
</p>
</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. If you do not want to use our email service, you can <a href="https://wiki.kavitareader.com/en/guides/misc/email" rel="noopener noreferrer" target="_blank" rel="noopener noreferrer">host your own</a>
email service or use a fake email (Forgot User will not work). A link will be presented regardless and can be used to setup the email account manually.
</p>
<form [formGroup]="inviteForm" *ngIf="emailLink === ''">
<div class="row g-0">
<div class="mb-3" style="width:100%">
<label for="email" class="form-label">Email</label>
<input class="form-control" type="email" id="email" formControlName="email" required [class.is-invalid]="inviteForm.get('email')?.invalid && inviteForm.get('email')?.touched">
<div id="inviteForm-validations" class="invalid-feedback" *ngIf="inviteForm.dirty || inviteForm.touched">
<div *ngIf="email?.errors?.required">
This field is required
<form [formGroup]="inviteForm" *ngIf="emailLink === ''">
<div class="row g-0">
<div class="mb-3" style="width:100%">
<label for="email" class="form-label">Email</label>
<input class="form-control" type="email" id="email" formControlName="email" required [class.is-invalid]="inviteForm.get('email')?.invalid && inviteForm.get('email')?.touched">
<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>
</div>
<div class="row g-0">
<div class="col-md-6">
<app-role-selector (selected)="updateRoleSelection($event)" [allowAdmin]="true"></app-role-selector>
<div class="row g-0">
<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>
<div class="col-md-6">
<app-library-selector (selected)="updateLibrarySelection($event)"></app-library-selector>
<div class="row g-0">
<div class="col-md-12">
<app-restriction-selector (selected)="updateRestrictionSelection($event)" [isAdmin]="hasAdminRoleSelected"></app-restriction-selector>
</div>
</div>
</div>
</form>
<ng-container *ngIf="emailLink !== ''">
<h4>User invited</h4>
<p>You can use the following link below to setup the account for your user or use the copy button. You may need to log out before using the link to register a new user.
If your server is externally accessible, an email will have been sent to the user and the links can be used by them to finish setting up their account.
</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>
</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 || emailLink !== ''">
<span *ngIf="isSending" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span>{{isSending ? 'Inviting...' : 'Invite'}}</span>
</button>
</div>
</form>
<ng-container *ngIf="emailLink !== ''">
<h4>User invited</h4>
<p>You can use the following link below to setup the account for your user or use the copy button. You may need to log out before using the link to register a new user.
If your server is externally accessible, an email will have been sent to the user and the links can be used by them to finish setting up their account.
</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>
</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 || emailLink !== ''">
<span *ngIf="isSending" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span>{{isSending ? 'Inviting...' : 'Invite'}}</span>
</button>
</div>
</div>

View file

@ -2,11 +2,10 @@ import { Component, OnInit } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { ToastrService } from 'ngx-toastr';
import { ConfirmService } from 'src/app/shared/confirm.service';
import { InviteUserResponse } from 'src/app/_models/invite-user-response';
import { Library } from 'src/app/_models/library';
import { AgeRating } from 'src/app/_models/metadata/age-rating';
import { AccountService } from 'src/app/_services/account.service';
import { ServerService } from 'src/app/_services/server.service';
@Component({
selector: 'app-invite-user',
@ -22,14 +21,16 @@ export class InviteUserComponent implements OnInit {
inviteForm: FormGroup = new FormGroup({});
selectedRoles: Array<string> = [];
selectedLibraries: Array<number> = [];
selectedRating: AgeRating = AgeRating.NotApplicable;
emailLink: string = '';
makeLink: (val: string) => string = (val: string) => {return this.emailLink};
public get hasAdminRoleSelected() { return this.selectedRoles.includes('Admin'); };
public get email() { return this.inviteForm.get('email'); }
constructor(public modal: NgbActiveModal, private accountService: AccountService, private serverService: ServerService,
private confirmService: ConfirmService, private toastr: ToastrService) { }
constructor(public modal: NgbActiveModal, private accountService: AccountService, private toastr: ToastrService) { }
ngOnInit(): void {
this.inviteForm.addControl('email', new FormControl('', [Validators.required]));
@ -47,6 +48,7 @@ export class InviteUserComponent implements OnInit {
email,
libraries: this.selectedLibraries,
roles: this.selectedRoles,
ageRestriction: this.selectedRating
}).subscribe((data: InviteUserResponse) => {
this.emailLink = data.emailLink;
this.isSending = false;
@ -67,4 +69,8 @@ export class InviteUserComponent implements OnInit {
this.selectedLibraries = libraries.map(l => l.id);
}
updateRestrictionSelection(rating: AgeRating) {
this.selectedRating = rating;
}
}

View file

@ -1,6 +1,7 @@
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { Member } from 'src/app/_models/member';
import { User } from 'src/app/_models/user';
import { AccountService } from 'src/app/_services/account.service';
import { MemberService } from 'src/app/_services/member.service';
@ -11,7 +12,10 @@ import { MemberService } from 'src/app/_services/member.service';
})
export class RoleSelectorComponent implements OnInit {
@Input() member: Member | undefined;
/**
* This must have roles
*/
@Input() member: Member | undefined | User;
/**
* Allows the selection of Admin role
*/
@ -25,7 +29,7 @@ export class RoleSelectorComponent implements OnInit {
ngOnInit(): void {
this.accountService.getRoles().subscribe(roles => {
let bannedRoles = ['Pleb'];
const bannedRoles = ['Pleb'];
if (!this.allowAdmin) {
bannedRoles.push('Admin');
}

View file

@ -1,8 +1,3 @@
.scrollable-modal {
max-height: calc(var(--vh) * 100 - 198px);
overflow: auto;
}
.lock-active {
> .input-group-text {
background-color: var(--primary-color);

View file

@ -8,6 +8,7 @@ import { Series } from 'src/app/_models/series';
import { RelationKind, RelationKinds } from 'src/app/_models/series-detail/relation-kind';
import { ImageService } from 'src/app/_services/image.service';
import { LibraryService } from 'src/app/_services/library.service';
import { SearchService } from 'src/app/_services/search.service';
import { SeriesService } from 'src/app/_services/series.service';
interface RelationControl {
@ -47,7 +48,7 @@ export class EditSeriesRelationComponent implements OnInit, OnDestroy {
private onDestroy: Subject<void> = new Subject<void>();
constructor(private seriesService: SeriesService, private utilityService: UtilityService,
public imageService: ImageService, private libraryService: LibraryService,
public imageService: ImageService, private libraryService: LibraryService, private searchService: SearchService,
private readonly cdRef: ChangeDetectorRef) {}
ngOnInit(): void {
@ -127,7 +128,7 @@ export class EditSeriesRelationComponent implements OnInit, OnDestroy {
seriesSettings.id = 'relation--' + index;
seriesSettings.unique = true;
seriesSettings.addIfNonExisting = false;
seriesSettings.fetchFn = (searchFilter: string) => this.libraryService.search(searchFilter).pipe(
seriesSettings.fetchFn = (searchFilter: string) => this.searchService.search(searchFilter).pipe(
map(group => group.series),
map(items => seriesSettings.compareFn(items, searchFilter)),
map(series => series.filter(s => s.seriesId !== this.series.id)),
@ -142,7 +143,7 @@ export class EditSeriesRelationComponent implements OnInit, OnDestroy {
}
if (series !== undefined) {
return this.libraryService.search(series.name).pipe(
return this.searchService.search(series.name).pipe(
map(group => group.series), map(results => {
seriesSettings.savedData = results.filter(s => s.seriesId === series.id);
return seriesSettings;

View file

@ -6,7 +6,7 @@ import { debounceTime, distinctUntilChanged, filter, takeUntil, tap } from 'rxjs
import { Chapter } from 'src/app/_models/chapter';
import { MangaFile } from 'src/app/_models/manga-file';
import { ScrollService } from 'src/app/_services/scroll.service';
import { SeriesService } from 'src/app/_services/series.service';
import { SearchService } from 'src/app/_services/search.service';
import { FilterQueryParam } from '../../shared/_services/filter-utilities.service';
import { CollectionTag } from '../../_models/collection-tag';
import { Library } from '../../_models/library';
@ -52,8 +52,8 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
private readonly onDestroy = new Subject<void>();
constructor(public accountService: AccountService, private router: Router, public navService: NavService,
private libraryService: LibraryService, public imageService: ImageService, @Inject(DOCUMENT) private document: Document,
private scrollService: ScrollService, private seriesService: SeriesService, private readonly cdRef: ChangeDetectorRef) {
public imageService: ImageService, @Inject(DOCUMENT) private document: Document,
private scrollService: ScrollService, private searchService: SearchService, private readonly cdRef: ChangeDetectorRef) {
this.scrollElem = this.document.body;
}
@ -110,7 +110,7 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
this.searchTerm = val.trim();
this.cdRef.markForCheck();
this.libraryService.search(val.trim()).pipe(takeUntil(this.onDestroy)).subscribe(results => {
this.searchService.search(val.trim()).pipe(takeUntil(this.onDestroy)).subscribe(results => {
this.searchResults = results;
this.isLoading = false;
this.cdRef.markForCheck();
@ -185,7 +185,7 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
clickFileSearchResult(item: MangaFile) {
this.clearSearch();
this.seriesService.getSeriesForMangaFile(item.id).subscribe(series => {
this.searchService.getSeriesForMangaFile(item.id).subscribe(series => {
if (series !== undefined && series !== null) {
this.router.navigate(['library', series.libraryId, 'series', series.id]);
}
@ -194,7 +194,7 @@ export class NavHeaderComponent implements OnInit, OnDestroy {
clickChapterSearchResult(item: Chapter) {
this.clearSearch();
this.seriesService.getSeriesForChapter(item.id).subscribe(series => {
this.searchService.getSeriesForChapter(item.id).subscribe(series => {
if (series !== undefined && series !== null) {
this.router.navigate(['library', series.libraryId, 'series', series.id]);
}

View file

@ -11,7 +11,9 @@ export class AgeRatingPipe implements PipeTransform {
constructor(private metadataService: MetadataService) {}
transform(value: AgeRating | AgeRatingDto): Observable<string> {
transform(value: AgeRating | AgeRatingDto | undefined): Observable<string> {
if (value === undefined || value === null) return of('undefined');
if (value.hasOwnProperty('title')) {
return of((value as AgeRatingDto).title);
}

View file

@ -6,7 +6,7 @@ import { Pipe, PipeTransform } from '@angular/core';
export class DefaultValuePipe implements PipeTransform {
transform(value: any, replacementString = '—'): string {
if (value === null || value === undefined || value === '' || value === Infinity || value === NaN || value === {}) return replacementString;
if (value === null || value === undefined || value === '' || value === Infinity || value === NaN) return replacementString;
return value;
}

View file

@ -0,0 +1,32 @@
<div class="card mt-2">
<div class="card-body">
<div class="card-title">
<div class="row mb-2">
<div class="col-11"><h4 id="age-restriction">Age Restriction</h4></div>
<div class="col-1">
<button class="btn btn-primary btn-sm" (click)="toggleViewMode()" *ngIf="(hasChangeAgeRestrictionAbility | async)">{{isViewMode ? 'Edit' : 'Cancel'}}</button>
</div>
</div>
</div>
<ng-container *ngIf="isViewMode">
<span >{{user?.ageRestriction | ageRating | async}}</span>
</ng-container>
<div #collapse="ngbCollapse" [(ngbCollapse)]="isViewMode">
<ng-container *ngIf="user">
<app-restriction-selector (selected)="updateRestrictionSelection($event)" [showContext]="false" [member]="user" [reset]="reset"></app-restriction-selector>
<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="age-restriction" (click)="resetForm()">Reset</button>
<button type="submit" class="flex-fill btn btn-primary" aria-describedby="age-restriction" (click)="saveForm()">Save</button>
</div>
</ng-container>
</div>
</div>
</div>

View file

@ -0,0 +1,79 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, OnInit } from '@angular/core';
import { ToastrService } from 'ngx-toastr';
import { Observable, of, Subject, takeUntil, shareReplay, map, take } from 'rxjs';
import { AgeRating } from 'src/app/_models/metadata/age-rating';
import { User } from 'src/app/_models/user';
import { AccountService } from 'src/app/_services/account.service';
@Component({
selector: 'app-change-age-restriction',
templateUrl: './change-age-restriction.component.html',
styleUrls: ['./change-age-restriction.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ChangeAgeRestrictionComponent implements OnInit {
user: User | undefined = undefined;
hasChangeAgeRestrictionAbility: Observable<boolean> = of(false);
isViewMode: boolean = true;
selectedRating: AgeRating = AgeRating.NotApplicable;
originalRating!: AgeRating;
reset: EventEmitter<AgeRating> = new EventEmitter();
get AgeRating() { return AgeRating; }
private onDestroy = new Subject<void>();
constructor(private 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.originalRating = this.user?.ageRestriction || AgeRating.NotApplicable;
this.cdRef.markForCheck();
});
this.hasChangeAgeRestrictionAbility = this.accountService.currentUser$.pipe(takeUntil(this.onDestroy), shareReplay(), map(user => {
return user !== undefined && (!this.accountService.hasAdminRole(user) && this.accountService.hasChangeAgeRestrictionRole(user));
}));
this.cdRef.markForCheck();
}
updateRestrictionSelection(rating: AgeRating) {
this.selectedRating = rating;
}
ngOnDestroy() {
this.onDestroy.next();
this.onDestroy.complete();
}
resetForm() {
if (!this.user) return;
console.log('resetting to ', this.originalRating)
this.reset.emit(this.originalRating);
this.cdRef.markForCheck();
}
saveForm() {
if (this.user === undefined) { return; }
this.accountService.updateAgeRestriction(this.selectedRating).subscribe(() => {
this.toastr.success('Age Restriction has been updated');
this.originalRating = this.selectedRating;
if (this.user) {
this.user.ageRestriction = this.selectedRating;
}
this.resetForm();
this.isViewMode = true;
}, err => {
});
}
toggleViewMode() {
this.isViewMode = !this.isViewMode;
this.resetForm();
}
}

View file

@ -1,7 +1,7 @@
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 { map, Observable, of, shareReplay, Subject, take, takeUntil } from 'rxjs';
import { User } from 'src/app/_models/user';
import { AccountService } from 'src/app/_services/account.service';
@ -29,6 +29,12 @@ export class ChangePasswordComponent implements OnInit, OnDestroy {
constructor(private 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.cdRef.markForCheck();
});
this.hasChangePasswordAbility = this.accountService.currentUser$.pipe(takeUntil(this.onDestroy), shareReplay(), map(user => {
return user !== undefined && (this.accountService.hasAdminRole(user) || this.accountService.hasChangePasswordRole(user));
}));
@ -38,8 +44,6 @@ export class ChangePasswordComponent implements OnInit, OnDestroy {
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;

View file

@ -0,0 +1,19 @@
<ng-container *ngIf="restrictionForm">
<ng-container *ngIf="showContext">
<h4>Age Rating Restriction</h4>
<p>When selected, all series and reading lists that have at least one item that is greater than the selected restriction will be pruned from results.
<ng-container *ngIf="isAdmin">This is not applicable for admins.</ng-container>
</p>
</ng-container>
<form [formGroup]="restrictionForm">
<div class="mb-3">
<label for="age-rating" class="form-label visually-hidden">Age Rating</label>
<div class="input-group">
<select class="form-select"id="age-rating" formControlName="ageRating">
<option value="-1">No Restriction</option>
<option *ngFor="let opt of ageRatings" [value]="opt.value">{{opt.title | titlecase}}</option>
</select>
</div>
</div>
</form>
</ng-container>

View file

@ -0,0 +1,68 @@
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
import { Member } from 'src/app/_models/member';
import { AgeRating } from 'src/app/_models/metadata/age-rating';
import { AgeRatingDto } from 'src/app/_models/metadata/age-rating-dto';
import { User } from 'src/app/_models/user';
import { MetadataService } from 'src/app/_services/metadata.service';
@Component({
selector: 'app-restriction-selector',
templateUrl: './restriction-selector.component.html',
styleUrls: ['./restriction-selector.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class RestrictionSelectorComponent implements OnInit, OnChanges {
@Input() member: Member | undefined | User;
@Input() isAdmin: boolean = false;
/**
* Show labels and description around the form
*/
@Input() showContext: boolean = true;
@Input() reset: EventEmitter<AgeRating> | undefined;
@Output() selected: EventEmitter<AgeRating> = new EventEmitter<AgeRating>();
ageRatings: Array<AgeRatingDto> = [];
restrictionForm: FormGroup | undefined;
constructor(private metadataService: MetadataService, private readonly cdRef: ChangeDetectorRef) { }
ngOnInit(): void {
this.restrictionForm = new FormGroup({
'ageRating': new FormControl(this.member?.ageRestriction || AgeRating.NotApplicable, [])
});
if (this.isAdmin) {
this.restrictionForm.get('ageRating')?.disable();
}
if (this.reset) {
this.reset.subscribe(e => {
this.restrictionForm?.get('ageRating')?.setValue(e);
this.cdRef.markForCheck();
});
}
this.restrictionForm.get('ageRating')?.valueChanges.subscribe(e => {
this.selected.emit(parseInt(e, 10));
});
this.metadataService.getAllAgeRatings().subscribe(ratings => {
this.ageRatings = ratings;
this.cdRef.markForCheck();
});
}
ngOnChanges() {
if (!this.member) return;
console.log('changes: ');
this.restrictionForm?.get('ageRating')?.setValue(this.member?.ageRestriction || AgeRating.NotApplicable);
this.cdRef.markForCheck();
}
}

View file

@ -11,6 +11,7 @@
<ng-container *ngIf="tab.fragment === FragmentID.Account">
<app-change-email></app-change-email>
<app-change-password></app-change-password>
<app-change-age-restriction></app-change-age-restriction>
</ng-container>
<ng-container *ngIf="tab.fragment === FragmentID.Prefernces">
<p>

View file

@ -15,6 +15,8 @@ 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';
import { ChangeAgeRestrictionComponent } from './change-age-restriction/change-age-restriction.component';
import { RestrictionSelectorComponent } from './restriction-selector/restriction-selector.component';
@NgModule({
@ -28,6 +30,8 @@ import { ChangeEmailComponent } from './change-email/change-email.component';
EditDeviceComponent,
ChangePasswordComponent,
ChangeEmailComponent,
RestrictionSelectorComponent,
ChangeAgeRestrictionComponent,
],
imports: [
CommonModule,
@ -47,7 +51,8 @@ import { ChangeEmailComponent } from './change-email/change-email.component';
],
exports: [
SiteThemeProviderPipe,
ApiKeyComponent
ApiKeyComponent,
RestrictionSelectorComponent
]
})
export class UserSettingsModule { }

View file

@ -6,3 +6,8 @@
.modal-title {
word-break: break-all;
}
.scrollable-modal {
max-height: calc(var(--vh) * 100 - 198px);
overflow: auto;
}