Read Only Account Changes + Fixes from last PR (#3453)
This commit is contained in:
parent
41c346d5e6
commit
a8144a1d3e
28 changed files with 193 additions and 38 deletions
1
UI/Web/.gitignore
vendored
1
UI/Web/.gitignore
vendored
|
@ -2,3 +2,4 @@ node_modules/
|
|||
test-results/
|
||||
playwright-report/
|
||||
i18n-cache-busting.json
|
||||
e2e-tests/environments/environment.local.ts
|
||||
|
|
|
@ -6,21 +6,30 @@ import {Directive, EventEmitter, HostListener, Output} from '@angular/core';
|
|||
})
|
||||
export class DblClickDirective {
|
||||
|
||||
@Output() singleClick = new EventEmitter<Event>();
|
||||
@Output() doubleClick = new EventEmitter<Event>();
|
||||
|
||||
private lastTapTime = 0;
|
||||
private tapTimeout = 300; // Time threshold for a double tap (in milliseconds)
|
||||
private singleClickTimeout: any;
|
||||
|
||||
@HostListener('click', ['$event'])
|
||||
handleClick(event: Event): void {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
const currentTime = new Date().getTime();
|
||||
|
||||
if (currentTime - this.lastTapTime < this.tapTimeout) {
|
||||
// Detected a double click/tap
|
||||
clearTimeout(this.singleClickTimeout); // Prevent single-click emission
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
this.doubleClick.emit(event);
|
||||
} else {
|
||||
// Delay single-click emission to check if a double-click occurs
|
||||
this.singleClickTimeout = setTimeout(() => {
|
||||
this.singleClick.emit(event); // Optional: emit single-click if no double-click follows
|
||||
}, this.tapTimeout);
|
||||
}
|
||||
|
||||
this.lastTapTime = currentTime;
|
||||
}
|
||||
|
||||
|
|
|
@ -15,6 +15,7 @@ import { AgeRestriction } from '../_models/metadata/age-restriction';
|
|||
import { TextResonse } from '../_types/text-response';
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {Action} from "./action-factory.service";
|
||||
import {CoverImageSize} from "../admin/_models/cover-image-size";
|
||||
|
||||
export enum Role {
|
||||
Admin = 'Admin',
|
||||
|
@ -27,6 +28,17 @@ export enum Role {
|
|||
Promote = 'Promote',
|
||||
}
|
||||
|
||||
export const allRoles = [
|
||||
Role.Admin,
|
||||
Role.ChangePassword,
|
||||
Role.Bookmark,
|
||||
Role.Download,
|
||||
Role.ChangeRestriction,
|
||||
Role.ReadOnly,
|
||||
Role.Login,
|
||||
Role.Promote,
|
||||
]
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
|
@ -91,14 +103,22 @@ export class AccountService {
|
|||
return true;
|
||||
}
|
||||
|
||||
hasAnyRole(user: User, roles: Array<Role>) {
|
||||
hasAnyRole(user: User, roles: Array<Role>, restrictedRoles: Array<Role> = []) {
|
||||
if (!user || !user.roles) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If restricted roles are provided and the user has any of them, deny access
|
||||
if (restrictedRoles.length > 0 && restrictedRoles.some(role => user.roles.includes(role))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If roles are empty, allow access (no restrictions by roles)
|
||||
if (roles.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Allow access if the user has any of the allowed roles
|
||||
return roles.some(role => user.roles.includes(role));
|
||||
}
|
||||
|
||||
|
|
|
@ -123,6 +123,7 @@
|
|||
filter: blur(20px);
|
||||
object-fit: contain;
|
||||
transform: scale(1.1);
|
||||
mix-blend-mode: color;
|
||||
|
||||
.background-area {
|
||||
position: absolute;
|
||||
|
|
|
@ -55,7 +55,7 @@
|
|||
[ngStyle]="{'background-color': backgroundColor, 'height': readerMode === ReaderMode.Webtoon ? 'inherit' : '100dvh'}" #readingArea>
|
||||
|
||||
@if (readerMode !== ReaderMode.Webtoon) {
|
||||
<div (dblclick)="bookmarkPage($event)">
|
||||
<div appDblClick (dblclick)="bookmarkPage($event)" (singleClick)="toggleMenu()">
|
||||
<app-canvas-renderer
|
||||
[readerSettings$]="readerSettings$"
|
||||
[image$]="currentImage$"
|
||||
|
@ -89,7 +89,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div appDblClick (doubleClick)="bookmarkPage($event)">
|
||||
<div appDblClick (doubleClick)="bookmarkPage($event)" (singleClick)="toggleMenu()">
|
||||
<app-single-renderer [image$]="currentImage$"
|
||||
[readerSettings$]="readerSettings$"
|
||||
[bookmark$]="showBookmarkEffect$"
|
||||
|
@ -123,7 +123,7 @@
|
|||
</div>
|
||||
} @else {
|
||||
@if (!isLoading && !inSetup) {
|
||||
<div class="webtoon-images">
|
||||
<div class="webtoon-images" appDblClick (doubleClick)="bookmarkPage($event)" (singleClick)="toggleMenu()">
|
||||
<app-infinite-scroller [pageNum]="pageNum"
|
||||
[bufferPages]="5"
|
||||
[goToPage]="goToPageEvent"
|
||||
|
|
|
@ -623,10 +623,10 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
||||
fromEvent(this.readingArea.nativeElement, 'click').pipe(debounceTime(200), takeUntilDestroyed(this.destroyRef)).subscribe((event: MouseEvent | any) => {
|
||||
if (event.detail > 1) return;
|
||||
this.toggleMenu();
|
||||
});
|
||||
// fromEvent(this.readingArea.nativeElement, 'click').pipe(debounceTime(200), takeUntilDestroyed(this.destroyRef)).subscribe((event: MouseEvent | any) => {
|
||||
// if (event.detail > 1) return;
|
||||
// this.toggleMenu();
|
||||
// });
|
||||
|
||||
fromEvent(this.readingArea.nativeElement, 'scroll').pipe(debounceTime(200), takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
||||
this.prevScrollLeft = this.readingArea?.nativeElement?.scrollLeft || 0;
|
||||
|
@ -1663,6 +1663,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy {
|
|||
event.preventDefault();
|
||||
}
|
||||
if (this.bookmarkMode) return;
|
||||
if (!(this.accountService.hasBookmarkRole(this.user) || this.accountService.hasAdminRole(this.user))) return;
|
||||
|
||||
const pageNum = this.pageNum;
|
||||
// if canvasRenderer and doubleRenderer is undefined, then we are in webtoon mode
|
||||
|
|
|
@ -11,7 +11,9 @@
|
|||
@if (navStreams$ | async; as streams) {
|
||||
@if (showAll) {
|
||||
<app-side-nav-item icon="fa fa-chevron-left" [title]="t('back')" (click)="showLess()"></app-side-nav-item>
|
||||
<app-side-nav-item icon="fa-cogs" [title]="t('customize')" link="/settings" [fragment]="SettingsTabId.Customize"></app-side-nav-item>
|
||||
@if (!isReadOnly) {
|
||||
<app-side-nav-item icon="fa-cogs" [title]="t('customize')" link="/settings" [fragment]="SettingsTabId.Customize"></app-side-nav-item>
|
||||
}
|
||||
@if (streams.length > ItemLimit && (navService.sideNavCollapsed$ | async) === false) {
|
||||
<div class="mb-2 mt-3 ms-2 me-2">
|
||||
<label for="filter" class="form-label visually-hidden">{{t('filter-label')}}</label>
|
||||
|
|
|
@ -66,6 +66,7 @@ export class SideNavComponent implements OnInit {
|
|||
}
|
||||
showAll: boolean = false;
|
||||
totalSize = 0;
|
||||
isReadOnly = false;
|
||||
|
||||
private showAllSubject = new BehaviorSubject<boolean>(false);
|
||||
showAll$ = this.showAllSubject.asObservable();
|
||||
|
@ -146,6 +147,8 @@ export class SideNavComponent implements OnInit {
|
|||
ngOnInit(): void {
|
||||
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
|
||||
if (!user) return;
|
||||
this.isReadOnly = this.accountService.hasReadOnlyRole(user!);
|
||||
this.cdRef.markForCheck();
|
||||
this.loadDataSubject.next();
|
||||
});
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
@if (hasAnyChildren(user, section)) {
|
||||
<h5 class="side-nav-header mb-2 ms-3" [ngClass]="{'mt-4': idx > 0}">{{t(section.title)}}</h5>
|
||||
@for(item of section.children; track item.fragment) {
|
||||
@if (accountService.hasAnyRole(user, item.roles)) {
|
||||
@if (accountService.hasAnyRole(user, item.roles, item.restrictRoles)) {
|
||||
<app-side-nav-item [id]="'nav-item-' + item.fragment" [noIcon]="true" link="/settings" [fragment]="item.fragment" [title]="item.fragment | settingFragment" [badgeCount]="item.badgeCount$ | async"></app-side-nav-item>
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@ import {AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, De
|
|||
import {TranslocoDirective} from "@jsverse/transloco";
|
||||
import {AsyncPipe, DOCUMENT, NgClass} from "@angular/common";
|
||||
import {NavService} from "../../_services/nav.service";
|
||||
import {AccountService, Role} from "../../_services/account.service";
|
||||
import {AccountService, allRoles, Role} from "../../_services/account.service";
|
||||
import {SideNavItemComponent} from "../_components/side-nav-item/side-nav-item.component";
|
||||
import {ActivatedRoute, NavigationEnd, Router, RouterLink} from "@angular/router";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
|
@ -51,11 +51,16 @@ interface PrefSection {
|
|||
class SideNavItem {
|
||||
fragment: SettingsTabId;
|
||||
roles: Array<Role> = [];
|
||||
/**
|
||||
* If you have any of these, the item will be restricted
|
||||
*/
|
||||
restrictRoles: Array<Role> = [];
|
||||
badgeCount$?: Observable<number> | undefined;
|
||||
|
||||
constructor(fragment: SettingsTabId, roles: Array<Role> = [], badgeCount$: Observable<number> | undefined = undefined) {
|
||||
constructor(fragment: SettingsTabId, roles: Array<Role> = [], badgeCount$: Observable<number> | undefined = undefined, restrictRoles: Array<Role> = []) {
|
||||
this.fragment = fragment;
|
||||
this.roles = roles;
|
||||
this.restrictRoles = restrictRoles;
|
||||
this.badgeCount$ = badgeCount$;
|
||||
}
|
||||
}
|
||||
|
@ -68,7 +73,6 @@ class SideNavItem {
|
|||
NgClass,
|
||||
AsyncPipe,
|
||||
SideNavItemComponent,
|
||||
RouterLink,
|
||||
SettingFragmentPipe
|
||||
],
|
||||
templateUrl: './preference-nav.component.html',
|
||||
|
@ -98,7 +102,7 @@ export class PreferenceNavComponent implements AfterViewInit {
|
|||
children: [
|
||||
new SideNavItem(SettingsTabId.Account, []),
|
||||
new SideNavItem(SettingsTabId.Preferences),
|
||||
new SideNavItem(SettingsTabId.Customize),
|
||||
new SideNavItem(SettingsTabId.Customize, [], undefined, [Role.ReadOnly]),
|
||||
new SideNavItem(SettingsTabId.Clients),
|
||||
new SideNavItem(SettingsTabId.Theme),
|
||||
new SideNavItem(SettingsTabId.Devices),
|
||||
|
@ -119,7 +123,7 @@ export class PreferenceNavComponent implements AfterViewInit {
|
|||
{
|
||||
title: 'import-section-title',
|
||||
children: [
|
||||
new SideNavItem(SettingsTabId.CBLImport, []),
|
||||
new SideNavItem(SettingsTabId.CBLImport, [], undefined, [Role.ReadOnly]),
|
||||
]
|
||||
},
|
||||
{
|
||||
|
@ -175,7 +179,6 @@ export class PreferenceNavComponent implements AfterViewInit {
|
|||
this.navService.collapseSideNav(true);
|
||||
}
|
||||
|
||||
|
||||
this.accountService.hasValidLicense$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(res => {
|
||||
if (res) {
|
||||
this.hasActiveLicense = true;
|
||||
|
@ -203,7 +206,6 @@ export class PreferenceNavComponent implements AfterViewInit {
|
|||
if (this.sections[2].children.length === 1) {
|
||||
this.sections[2].children.push(new SideNavItem(SettingsTabId.MALStackImport, []));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
this.scrollToActiveItem();
|
||||
|
@ -227,7 +229,15 @@ export class PreferenceNavComponent implements AfterViewInit {
|
|||
}
|
||||
|
||||
hasAnyChildren(user: User, section: PrefSection) {
|
||||
return section.children.filter(item => this.accountService.hasAnyRole(user, item.roles)).length > 0;
|
||||
// Filter out items where the user has a restricted role
|
||||
const visibleItems = section.children.filter(item =>
|
||||
item.restrictRoles.length === 0 || !this.accountService.hasAnyRole(user, item.restrictRoles)
|
||||
);
|
||||
|
||||
// Check if the user has any allowed roles in the remaining items
|
||||
return visibleItems.some(item =>
|
||||
this.accountService.hasAnyRole(user, item.roles)
|
||||
);
|
||||
}
|
||||
|
||||
collapse() {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<ng-container *transloco="let t; read:'change-email'">
|
||||
|
||||
<app-setting-item [title]="t('email-title')">
|
||||
<app-setting-item [title]="t('email-title')" [canEdit]="canEdit">
|
||||
<ng-template #extra>
|
||||
@if(emailConfirmed) {
|
||||
<i class="fa-solid fa-circle-check ms-1 confirm-icon" aria-hidden="true" [ngbTooltip]="t('email-confirmed')"></i>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<ng-container *transloco="let t; read:'change-password'">
|
||||
<app-setting-item [title]="t('password-label')">
|
||||
<app-setting-item [title]="t('password-label')" [canEdit]="canEdit">
|
||||
<ng-template #view>
|
||||
<span class="col-12">***************</span>
|
||||
</ng-template>
|
||||
|
|
|
@ -41,6 +41,7 @@ export class ChangePasswordComponent implements OnInit, OnDestroy {
|
|||
passwordsMatch = false;
|
||||
resetPasswordErrors: string[] = [];
|
||||
isViewMode: boolean = true;
|
||||
canEdit: boolean = false;
|
||||
|
||||
|
||||
public get password() { return this.passwordChangeForm.get('password'); }
|
||||
|
@ -50,6 +51,7 @@ export class ChangePasswordComponent implements OnInit, OnDestroy {
|
|||
|
||||
this.accountService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef), shareReplay()).subscribe(user => {
|
||||
this.user = user;
|
||||
this.canEdit = !this.accountService.hasReadOnlyRole(user!);
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<ng-container *transloco="let t; read:'manage-devices'">
|
||||
|
||||
<div class="position-relative">
|
||||
<button class="btn btn-primary-outline position-absolute custom-position" (click)="addDevice()">
|
||||
<button class="btn btn-primary-outline position-absolute custom-position" [disabled]="isReadOnly$" (click)="addDevice()">
|
||||
<i class="fa fa-plus" aria-hidden="true"></i><span class="phone-hidden ms-1">{{t('add')}}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
Component, DestroyRef,
|
||||
inject,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
|
@ -20,6 +20,10 @@ import {SortableHeader} from "../../_single-module/table/_directives/sortable-he
|
|||
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
|
||||
import {EditDeviceModalComponent} from "../_modals/edit-device-modal/edit-device-modal.component";
|
||||
import {DefaultModalOptions} from "../../_models/default-modal-options";
|
||||
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
|
||||
import {map} from "rxjs";
|
||||
import {shareReplay} from "rxjs/operators";
|
||||
import {AccountService} from "../../_services/account.service";
|
||||
|
||||
@Component({
|
||||
selector: 'app-manage-devices',
|
||||
|
@ -33,16 +37,24 @@ import {DefaultModalOptions} from "../../_models/default-modal-options";
|
|||
export class ManageDevicesComponent implements OnInit {
|
||||
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly deviceService = inject(DeviceService);
|
||||
private readonly settingsService = inject(SettingsService);
|
||||
private readonly confirmService = inject(ConfirmService);
|
||||
private readonly modalService = inject(NgbModal);
|
||||
private readonly accountService = inject(AccountService);
|
||||
|
||||
devices: Array<Device> = [];
|
||||
isEditingDevice: boolean = false;
|
||||
device: Device | undefined;
|
||||
hasEmailSetup = false;
|
||||
|
||||
isReadOnly$ = this.accountService.currentUser$.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
map(c => c && this.accountService.hasReadOnlyRole(c)),
|
||||
shareReplay({refCount: true, bufferSize: 1}),
|
||||
);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.settingsService.isEmailSetup().subscribe(res => {
|
||||
this.hasEmailSetup = res;
|
||||
|
|
|
@ -87,14 +87,14 @@
|
|||
{{selectedTheme.name | sentenceCase}}
|
||||
<div class="float-end">
|
||||
@if (selectedTheme.isSiteTheme) {
|
||||
@if (selectedTheme.name !== 'Dark') {
|
||||
@if (selectedTheme.name !== 'Dark' && (canUseThemes$ | async)) {
|
||||
<button class="btn btn-danger me-1" (click)="deleteTheme(selectedTheme.site!)">{{t('delete')}}</button>
|
||||
}
|
||||
@if (hasAdmin$ | async) {
|
||||
<button class="btn btn-secondary me-1" [disabled]="selectedTheme.site?.isDefault" (click)="updateDefault(selectedTheme.site!)">{{t('set-default')}}</button>
|
||||
}
|
||||
<button class="btn btn-primary me-1" [disabled]="currentTheme && selectedTheme.name === currentTheme.name" (click)="applyTheme(selectedTheme.site!)">{{t('apply')}}</button>
|
||||
} @else {
|
||||
} @else if (canUseThemes$ | async) {
|
||||
<button class="btn btn-primary" [disabled]="selectedTheme.downloadable?.alreadyDownloaded" (click)="downloadTheme(selectedTheme.downloadable!)">{{t('download')}}</button>
|
||||
}
|
||||
</div>
|
||||
|
|
|
@ -73,6 +73,12 @@ export class ThemeManagerComponent {
|
|||
shareReplay({refCount: true, bufferSize: 1}),
|
||||
);
|
||||
|
||||
canUseThemes$ = this.accountService.currentUser$.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
map(c => c && !this.accountService.hasReadOnlyRole(c)),
|
||||
shareReplay({refCount: true, bufferSize: 1}),
|
||||
);
|
||||
|
||||
files: NgxFileDropEntry[] = [];
|
||||
acceptableExtensions = ['.css'].join(',');
|
||||
isUploadingTheme: boolean = false;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue