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:
parent
0ad1638ec0
commit
442af965c6
63 changed files with 4638 additions and 262 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
31
UI/Web/src/app/_services/search.service.ts
Normal file
31
UI/Web/src/app/_services/search.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 { }
|
||||
|
|
|
|||
|
|
@ -6,3 +6,8 @@
|
|||
.modal-title {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.scrollable-modal {
|
||||
max-height: calc(var(--vh) * 100 - 198px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue