Introduce more granular permission controls and update the user management UI (#1965)

Co-authored-by: acx10 <acx10@users.noreply.github.com>
This commit is contained in:
ACX
2025-12-22 11:24:54 -07:00
committed by GitHub
parent b5ada2fff0
commit b8fb843b7a
62 changed files with 1365 additions and 510 deletions

View File

@@ -22,6 +22,10 @@ import {BookdropFileReviewComponent} from './features/bookdrop/component/bookdro
import {ManageLibraryGuard} from './core/security/guards/manage-library.guard';
import {LoginGuard} from './shared/components/setup/login.guard';
import {UserStatsComponent} from './features/stats/component/user-stats/user-stats.component';
import {BookdropGuard} from './core/security/guards/bookdrop.guard';
import {LibraryStatsGuard} from './core/security/guards/library-stats.guard';
import {UserStatsGuard} from './core/security/guards/user-stats.guard';
import {EditMetadataGuard} from './core/security/guards/edit-metdata.guard';
export const routes: Routes = [
{
@@ -49,10 +53,10 @@ export const routes: Routes = [
{path: 'series/:seriesName', component: SeriesPageComponent, canActivate: [AuthGuard]},
{path: 'magic-shelf/:magicShelfId/books', component: BookBrowserComponent, canActivate: [AuthGuard]},
{path: 'book/:bookId', component: BookMetadataCenterComponent, canActivate: [AuthGuard]},
{path: 'bookdrop', component: BookdropFileReviewComponent, canActivate: [ManageLibraryGuard]},
{path: 'metadata-manager', component: MetadataManagerComponent, canActivate: [ManageLibraryGuard]},
{path: 'library-stats', component: StatsComponent, canActivate: [AuthGuard]},
{path: 'reading-stats', component: UserStatsComponent, canActivate: [AuthGuard]},
{path: 'bookdrop', component: BookdropFileReviewComponent, canActivate: [BookdropGuard]},
{path: 'metadata-manager', component: MetadataManagerComponent, canActivate: [EditMetadataGuard]},
{path: 'library-stats', component: StatsComponent, canActivate: [LibraryStatsGuard]},
{path: 'reading-stats', component: UserStatsComponent, canActivate: [UserStatsGuard]},
]
},
{

View File

@@ -0,0 +1,20 @@
import {inject} from '@angular/core';
import {CanActivateFn, Router} from '@angular/router';
import {UserService} from '../../../features/settings/user-management/user.service';
import {map} from 'rxjs/operators';
export const BookdropGuard: CanActivateFn = () => {
const userService = inject(UserService);
const router = inject(Router);
return userService.userState$.pipe(
map(state => {
const user = state.user;
if (user && (user.permissions.admin || user.permissions.canAccessBookdrop)) {
return true;
}
router.navigate(['/dashboard']);
return false;
})
);
};

View File

@@ -0,0 +1,20 @@
import {inject} from '@angular/core';
import {CanActivateFn, Router} from '@angular/router';
import {UserService} from '../../../features/settings/user-management/user.service';
import {map} from 'rxjs/operators';
export const EditMetadataGuard: CanActivateFn = () => {
const userService = inject(UserService);
const router = inject(Router);
return userService.userState$.pipe(
map(state => {
const user = state.user;
if (user && (user.permissions.admin || user.permissions.canEditMetadata)) {
return true;
}
router.navigate(['/dashboard']);
return false;
})
);
};

View File

@@ -0,0 +1,20 @@
import {inject} from '@angular/core';
import {CanActivateFn, Router} from '@angular/router';
import {UserService} from '../../../features/settings/user-management/user.service';
import {map} from 'rxjs/operators';
export const LibraryStatsGuard: CanActivateFn = () => {
const userService = inject(UserService);
const router = inject(Router);
return userService.userState$.pipe(
map(state => {
const user = state.user;
if (user && (user.permissions.admin || user.permissions.canAccessLibraryStats)) {
return true;
}
router.navigate(['/dashboard']);
return false;
})
);
};

View File

@@ -10,7 +10,7 @@ export const ManageLibraryGuard: CanActivateFn = () => {
return userService.userState$.pipe(
map(state => {
const user = state.user;
if (user && (user.permissions.admin || user.permissions.canManipulateLibrary)) {
if (user && (user.permissions.admin || user.permissions.canManageLibrary)) {
return true;
}
router.navigate(['/dashboard']);

View File

@@ -0,0 +1,20 @@
import {inject} from '@angular/core';
import {CanActivateFn, Router} from '@angular/router';
import {UserService} from '../../../features/settings/user-management/user.service';
import {map} from 'rxjs/operators';
export const UserStatsGuard: CanActivateFn = () => {
const userService = inject(UserService);
const router = inject(Router);
return userService.userState$.pipe(
map(state => {
const user = state.user;
if (user && (user.permissions.admin || user.permissions.canAccessUserStats)) {
return true;
}
router.navigate(['/dashboard']);
return false;
})
);
};

View File

@@ -29,7 +29,7 @@
@if (userService.userState$ | async; as userState) {
@if (entityType !== EntityType.ALL_BOOKS && entityType !== EntityType.UNSHELVED &&
(userState.user!.permissions.admin || userState.user!.permissions.canManipulateLibrary)) {
(userState.user!.permissions.admin || userState.user!.permissions.canManageLibrary)) {
<div class="ml-2 flex-shrink-0">
<p-button
icon="pi pi-ellipsis-v"

View File

@@ -39,7 +39,7 @@ export class BookdropFileService implements OnDestroy {
)
.subscribe(state => {
const user = state.user!;
if (user.permissions.admin || user.permissions.canManipulateLibrary) {
if (user.permissions.admin || user.permissions.canAccessBookdrop) {
this.authService.token$
.pipe(filter(t => !!t), take(1))
.subscribe(() => this.refresh());

View File

@@ -17,7 +17,7 @@
<div class="dashboard-no-library">
@if ((userService.userState$ | async)?.user?.permissions; as permissions) {
<div>
@if (permissions.admin || permissions.canManipulateLibrary) {
@if (permissions.admin || permissions.canManageLibrary) {
<div>
<h1 class="no-library-header">
Welcome to BookLore!<br>

View File

@@ -9,16 +9,20 @@
<p-tab [value]="SettingsTab.ViewPreferences">
<i class="pi pi-desktop"></i> View
</p-tab>
@if (userState.user.permissions.admin) {
@if (userState.user.permissions.admin || userState.user.permissions.canManageMetadataConfig) {
<p-tab [value]="SettingsTab.MetadataSettings">
<i class="pi pi-sliders-h"></i> Metadata 1
</p-tab>
<p-tab [value]="SettingsTab.LibraryMetadataSettings">
<i class="pi pi-database"></i> Metadata 2
</p-tab>
}
@if (userState.user.permissions.admin || userState.user.permissions.canManageGlobalPreferences) {
<p-tab [value]="SettingsTab.ApplicationSettings">
<i class="pi pi-cog"></i> Application
</p-tab>
}
@if (userState.user.permissions.admin) {
<p-tab [value]="SettingsTab.UserManagement">
<i class="pi pi-users"></i> Users
</p-tab>
@@ -26,14 +30,17 @@
<p-tab [value]="SettingsTab.EmailSettingsV2">
<i class="pi pi-envelope"></i> Email
</p-tab>
@if (userState.user.permissions.admin) {
@if (userState.user.permissions.admin || userState.user.permissions.canManageMetadataConfig) {
<p-tab [value]="SettingsTab.NamingPattern">
<i class="pi pi-sitemap"></i> Patterns
</p-tab>
}
@if (userState.user.permissions.admin) {
<p-tab [value]="SettingsTab.AuthenticationSettings">
<i class="pi pi-lock"></i> Authentication
</p-tab>
}
@if (userState.user.permissions.admin || userState.user.permissions.canAccessTaskManager) {
<p-tab [value]="SettingsTab.Tasks">
<i class="pi pi-list-check"></i> Tasks
</p-tab>
@@ -52,16 +59,20 @@
<p-tabpanel [value]="SettingsTab.ViewPreferences">
<app-view-preferences-parent></app-view-preferences-parent>
</p-tabpanel>
@if (userState.user.permissions.admin) {
@if (userState.user.permissions.admin || userState.user.permissions.canManageMetadataConfig) {
<p-tabpanel [value]="SettingsTab.MetadataSettings">
<app-metadata-settings-component></app-metadata-settings-component>
</p-tabpanel>
<p-tabpanel [value]="SettingsTab.LibraryMetadataSettings">
<app-library-metadata-settings-component></app-library-metadata-settings-component>
</p-tabpanel>
}
@if (userState.user.permissions.admin || userState.user.permissions.canManageGlobalPreferences) {
<p-tabpanel [value]="SettingsTab.ApplicationSettings">
<app-global-preferences></app-global-preferences>
</p-tabpanel>
}
@if (userState.user.permissions.admin) {
<p-tabpanel [value]="SettingsTab.UserManagement">
<app-user-management></app-user-management>
</p-tabpanel>
@@ -69,13 +80,17 @@
<p-tabpanel [value]="SettingsTab.EmailSettingsV2">
<app-email-v2></app-email-v2>
</p-tabpanel>
@if (userState.user.permissions.admin) {
@if (userState.user.permissions.admin || userState.user.permissions.canManageMetadataConfig) {
<p-tabpanel [value]="SettingsTab.NamingPattern">
<app-file-naming-pattern></app-file-naming-pattern>
</p-tabpanel>
}
@if (userState.user.permissions.admin) {
<p-tabpanel [value]="SettingsTab.AuthenticationSettings">
<app-authentication-settings></app-authentication-settings>
</p-tabpanel>
}
@if (userState.user.permissions.admin || userState.user.permissions.canAccessTaskManager) {
<p-tabpanel [value]="SettingsTab.Tasks">
<app-task-management></app-task-management>
</p-tabpanel>

View File

@@ -29,65 +29,51 @@
</div>
<div class="table-card">
<p-table [value]="users" [scrollable]="true" scrollHeight="flex">
<p-table
[value]="users"
[scrollable]="true"
scrollHeight="flex"
dataKey="id">
<ng-template pTemplate="header">
<tr>
<th>
<div class="header-content">
<i class="pi pi-user"></i>
<span>Username</span>
</div>
</th>
<th>
<div class="header-content">
<th style="width: 40px"></th>
<th style="width: 100px; text-align: center">
<div class="header-content" style="justify-content: center">
<i class="pi pi-tag"></i>
<span>Type</span>
</div>
</th>
<th class="full-name-column">
<th style="min-width: 180px">
<div class="header-content">
<i class="pi pi-id-card"></i>
<span>Full Name</span>
<i class="pi pi-user"></i>
<span>User</span>
</div>
</th>
<th class="email-column">
<div class="header-content">
<i class="pi pi-envelope"></i>
<span>Email</span>
</div>
</th>
<th class="libraries-column">
<th style="min-width: 200px">
<div class="header-content">
<i class="pi pi-book"></i>
<span>Assigned Libraries</span>
<span>Libraries</span>
</div>
</th>
<th class="permission-header">Admin</th>
<th class="permission-header">Upload</th>
<th class="permission-header">Download</th>
<th class="permission-header">Manage Metadata</th>
<th class="permission-header">Manage Library</th>
<th class="permission-header">Email Books</th>
<th class="permission-header">Delete Books</th>
<th class="permission-header">Access OPDS</th>
<th class="permission-header">KOReader Sync</th>
<th class="permission-header">Kobo Sync</th>
<th class="actions-header">
<div class="header-content">
<i class="pi pi-cog"></i>
<span>Edit</span>
</div>
<th style="width: 120px; text-align: center" pTooltip="Admin permissions" tooltipPosition="top">
<i class="pi pi-shield"></i>
</th>
<th class="actions-header">
<div class="header-content">
<i class="pi pi-key"></i>
<span>Password</span>
</div>
<th style="width: 120px; text-align: center" pTooltip="Book Management permissions" tooltipPosition="top">
<i class="pi pi-book"></i>
</th>
<th class="actions-header">
<div class="header-content">
<i class="pi pi-trash"></i>
<span>Delete</span>
<th style="width: 120px; text-align: center" pTooltip="Device Sync permissions" tooltipPosition="top">
<i class="pi pi-mobile"></i>
</th>
<th style="width: 120px; text-align: center" pTooltip="System Access permissions" tooltipPosition="top">
<i class="pi pi-eye"></i>
</th>
<th style="width: 120px; text-align: center" pTooltip="System Configuration permissions" tooltipPosition="top">
<i class="pi pi-cog"></i>
</th>
<th style="width: 180px; text-align: center">
<div class="header-content" style="justify-content: center">
<i class="pi pi-ellipsis-h"></i>
<span>Actions</span>
</div>
</th>
</tr>
@@ -95,36 +81,31 @@
<ng-template pTemplate="body" let-user>
<tr>
<td>
<button
type="button"
pButton
class="p-button-text p-button-rounded p-button-plain"
(click)="toggleRowExpansion(user)">
<i [class]="isRowExpanded(user) ? 'pi pi-chevron-down' : 'pi pi-chevron-right'"></i>
</button>
</td>
<td class="text-center">
<span class="user-type-badge" [attr.data-type]="user.provisioningMethod || 'LOCAL'">
{{ (user.provisioningMethod || 'LOCAL') | lowercase | titlecase }}
</span>
</td>
<td>
<div class="user-info">
<div class="user-avatar">
{{ user.username.charAt(0).toUpperCase() }}
</div>
<span class="username">{{ user.username }}</span>
<div class="user-details">
<span class="username">{{ user.username }}</span>
</div>
</div>
</td>
<td>
<span class="user-type-badge">
{{ (user.provisioningMethod || 'LOCAL') | lowercase | titlecase }}
</span>
</td>
<td class="full-name-column">
@if (user.isEditing) {
<input type="text" [(ngModel)]="user.name" class="p-inputtext w-full" size="small"/>
}
@if (!user.isEditing) {
<span>{{ user.name }}</span>
}
</td>
<td class="email-column">
@if (user.isEditing) {
<input type="email" [(ngModel)]="user.email" class="p-inputtext w-full" size="small"/>
}
@if (!user.isEditing) {
<span>{{ user.email }}</span>
}
</td>
<td class="libraries-column">
@if (user.isEditing) {
<p-multiSelect
[options]="allLibraries"
@@ -133,102 +114,244 @@
[(ngModel)]="editingLibraryIds"
placeholder="Select Libraries"
appendTo="body"
[style]="{'width': '100%'}"
size="small">
</p-multiSelect>
}
@if (!user.isEditing) {
<span class="library-names">{{ user.libraryNames }}</span>
} @else {
<span class="library-names">{{ user.libraryNames || 'None' }}</span>
}
</td>
<td class="text-center">
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.admin" [disabled]="!user.isEditing"></p-checkbox>
<div class="permission-indicator admin-indicator" [class.active]="user.permissions.admin">
@if (user.permissions.admin) {
<i class="pi pi-shield" pTooltip="Administrator"></i>
} @else {
<i class="pi pi-minus" pTooltip="Not Administrator"></i>
}
</div>
</td>
<td class="text-center">
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canUpload" [disabled]="!user.isEditing"></p-checkbox>
<div class="permission-summary" [attr.data-count]="getBookManagementPermissionsCount(user)" [attr.data-total]="6">
<span class="permission-count">
{{ getBookManagementPermissionsCount(user) }}/6
</span>
</div>
</td>
<td class="text-center">
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canDownload" [disabled]="!user.isEditing"></p-checkbox>
<div class="permission-summary" [attr.data-count]="getDeviceSyncPermissionsCount(user)" [attr.data-total]="3">
<span class="permission-count">
{{ getDeviceSyncPermissionsCount(user) }}/3
</span>
</div>
</td>
<td class="text-center">
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canEditMetadata" [disabled]="!user.isEditing"></p-checkbox>
<div class="permission-summary" [attr.data-count]="getSystemAccessPermissionsCount(user)" [attr.data-total]="3">
<span class="permission-count">
{{ getSystemAccessPermissionsCount(user) }}/3
</span>
</div>
</td>
<td class="text-center">
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canManipulateLibrary" [disabled]="!user.isEditing"></p-checkbox>
<div class="permission-summary" [attr.data-count]="getSystemConfigPermissionsCount(user)" [attr.data-total]="4">
<span class="permission-count">
{{ getSystemConfigPermissionsCount(user) }}/4
</span>
</div>
</td>
<td class="text-center">
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canEmailBook" [disabled]="!user.isEditing"></p-checkbox>
</td>
<td class="text-center">
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canDeleteBook" [disabled]="!user.isEditing"></p-checkbox>
</td>
<td class="text-center">
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canAccessOpds" [disabled]="!user.isEditing"></p-checkbox>
</td>
<td class="text-center">
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canSyncKoReader" [disabled]="!user.isEditing"></p-checkbox>
</td>
<td class="text-center">
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canSyncKobo" [disabled]="!user.isEditing"></p-checkbox>
</td>
<td class="actions-cell">
@if (!user.isEditing) {
<p-button
icon="pi pi-pencil"
severity="info"
size="small"
[outlined]="true"
[rounded]="true"
(onClick)="toggleEdit(user)"
pTooltip="Edit user">
</p-button>
}
@if (user.isEditing) {
<div class="flex gap-1">
<td>
<div class="actions-group">
@if (!user.isEditing) {
<p-button
icon="pi pi-pencil"
severity="info"
[text]="true"
[rounded]="true"
(onClick)="toggleEdit(user)"
tooltipPosition="top"
pTooltip="Edit user">
</p-button>
}
@if (user.isEditing) {
<p-button
icon="pi pi-check"
severity="success"
size="small"
[outlined]="true"
[text]="true"
[rounded]="true"
(onClick)="saveUser(user)"
tooltipPosition="top"
pTooltip="Save changes">
</p-button>
<p-button
icon="pi pi-times"
severity="danger"
size="small"
[outlined]="true"
[rounded]="true"
(onClick)="toggleEdit(user)"
tooltipPosition="top"
pTooltip="Cancel">
</p-button>
</div>
}
</td>
<td class="actions-cell">
<p-button
icon="pi pi-key"
severity="warn"
size="small"
[outlined]="true"
[rounded]="true"
(onClick)="openChangePasswordDialog(user)"
pTooltip="Change password">
</p-button>
</td>
<td class="actions-cell">
<p-button
[disabled]="user.id === currentUser?.id"
icon="pi pi-trash"
severity="danger"
size="small"
[outlined]="true"
[rounded]="true"
(onClick)="deleteUser(user)"
pTooltip="Delete user">
</p-button>
}
@if (!user.isEditing) {
<p-button
icon="pi pi-key"
severity="warn"
[text]="true"
[rounded]="true"
(onClick)="openChangePasswordDialog(user)"
tooltipPosition="top"
pTooltip="Change password">
</p-button>
<p-button
[disabled]="user.id === currentUser?.id"
icon="pi pi-trash"
severity="danger"
[text]="true"
[rounded]="true"
(onClick)="deleteUser(user)"
tooltipPosition="top"
pTooltip="Delete user">
</p-button>
}
</div>
</td>
</tr>
@if (isRowExpanded(user)) {
<tr>
<td colspan="10">
<div class="expanded-content">
<div class="expanded-section">
<h4 class="expanded-title">
<i class="pi pi-id-card"></i>
User Information
</h4>
<div class="info-grid">
<div class="info-item">
<label>Full Name</label>
@if (user.isEditing) {
<input type="text" [(ngModel)]="user.name" class="p-inputtext p-component p-inputtext-sm"/>
} @else {
<span>{{ user.name || 'N/A' }}</span>
}
</div>
<div class="info-item">
<label>Email</label>
@if (user.isEditing) {
<input type="email" [(ngModel)]="user.email" class="p-inputtext p-component p-inputtext-sm"/>
} @else {
<span>{{ user.email || 'N/A' }}</span>
}
</div>
</div>
</div>
<div class="expanded-section">
<h4 class="expanded-title">
<i class="pi pi-lock"></i>
Permissions
</h4>
<div class="permissions-grid">
<div class="permission-group">
<h5><i class="pi pi-book"></i> Book Management</h5>
<div class="permission-items">
<div class="permission-item">
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canUpload" [disabled]="isPermissionDisabled(user)" inputId="upload-{{user.id}}"></p-checkbox>
<label [for]="'upload-'+user.id">Upload Books</label>
</div>
<div class="permission-item">
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canDownload" [disabled]="isPermissionDisabled(user)" inputId="download-{{user.id}}"></p-checkbox>
<label [for]="'download-'+user.id">Download Books</label>
</div>
<div class="permission-item">
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canDeleteBook" [disabled]="isPermissionDisabled(user)" inputId="delete-{{user.id}}"></p-checkbox>
<label [for]="'delete-'+user.id">Delete Books</label>
</div>
<div class="permission-item">
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canEditMetadata" [disabled]="isPermissionDisabled(user)" inputId="metadata-{{user.id}}"></p-checkbox>
<label [for]="'metadata-'+user.id">Edit Metadata</label>
</div>
<div class="permission-item">
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canManageLibrary" [disabled]="isPermissionDisabled(user)" inputId="library-{{user.id}}"></p-checkbox>
<label [for]="'library-'+user.id">Manage Library</label>
</div>
<div class="permission-item">
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canEmailBook" [disabled]="isPermissionDisabled(user)" inputId="email-{{user.id}}"></p-checkbox>
<label [for]="'email-'+user.id">Email Books</label>
</div>
</div>
</div>
<div class="permission-group">
<h5><i class="pi pi-mobile"></i> Device Sync</h5>
<div class="permission-items">
<div class="permission-item">
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canSyncKoReader" [disabled]="isPermissionDisabled(user)" inputId="koreader-{{user.id}}"></p-checkbox>
<label [for]="'koreader-'+user.id">KOReader Sync</label>
</div>
<div class="permission-item">
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canSyncKobo" [disabled]="isPermissionDisabled(user)" inputId="kobo-{{user.id}}"></p-checkbox>
<label [for]="'kobo-'+user.id">Kobo Sync</label>
</div>
<div class="permission-item">
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canAccessOpds" [disabled]="isPermissionDisabled(user)" inputId="opds-{{user.id}}"></p-checkbox>
<label [for]="'opds-'+user.id">OPDS Feed Access</label>
</div>
</div>
</div>
<div class="permission-group">
<h5><i class="pi pi-eye"></i> System Access</h5>
<div class="permission-items">
<div class="permission-item">
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canAccessBookdrop" [disabled]="isPermissionDisabled(user)" inputId="bookdrop-{{user.id}}"></p-checkbox>
<label [for]="'bookdrop-'+user.id">Access Bookdrop</label>
</div>
<div class="permission-item">
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canAccessLibraryStats" [disabled]="isPermissionDisabled(user)" inputId="stats-{{user.id}}"></p-checkbox>
<label [for]="'stats-'+user.id">View Library Statistics</label>
</div>
<div class="permission-item">
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canAccessUserStats" [disabled]="isPermissionDisabled(user)" inputId="userstats-{{user.id}}"></p-checkbox>
<label [for]="'userstats-'+user.id">View User Reading Statistics</label>
</div>
</div>
</div>
<div class="permission-group">
<h5><i class="pi pi-cog"></i> System Configuration</h5>
<div class="permission-items">
<div class="permission-item">
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canManageMetadataConfig" [disabled]="isPermissionDisabled(user)" inputId="metadataconfig-{{user.id}}"></p-checkbox>
<label [for]="'metadataconfig-'+user.id">Manage Metadata Configuration</label>
</div>
<div class="permission-item">
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canManageGlobalPreferences" [disabled]="isPermissionDisabled(user)" inputId="preferences-{{user.id}}"></p-checkbox>
<label [for]="'preferences-'+user.id">Manage Application Preferences</label>
</div>
<div class="permission-item">
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canAccessTaskManager" [disabled]="isPermissionDisabled(user)" inputId="taskmanager-{{user.id}}"></p-checkbox>
<label [for]="'taskmanager-'+user.id">Access Task Manager</label>
</div>
<div class="permission-item">
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canManageIcons" [disabled]="isPermissionDisabled(user)" inputId="icons-{{user.id}}"></p-checkbox>
<label [for]="'icons-'+user.id">Manage Icons</label>
</div>
</div>
</div>
<div class="permission-group">
<h5><i class="pi pi-shield"></i> Administration</h5>
<div class="permission-items">
<div class="permission-item">
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.admin" (onChange)="onAdminCheckboxChange(user)" [disabled]="!user.isEditing" inputId="admin-{{user.id}}"></p-checkbox>
<label [for]="'admin-'+user.id">Full Administrator Access</label>
</div>
</div>
</div>
</div>
</div>
</div>
</td>
</tr>
}
</ng-template>
</p-table>
</div>

View File

@@ -137,12 +137,12 @@
.user-info {
display: flex;
align-items: center;
gap: 0.5rem;
gap: 0.75rem;
}
.user-avatar {
width: 32px;
height: 32px;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--p-primary-color);
color: white;
@@ -151,93 +151,292 @@
justify-content: center;
font-weight: 600;
font-size: 0.875rem;
flex-shrink: 0;
}
.user-details {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.username {
font-weight: 500;
font-weight: 600;
color: var(--p-text-color);
}
.user-type-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border: 1px solid var(--p-primary-color);
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
color: var(--p-text-color);
padding: 0.2rem 0.5rem;
border: 1px solid;
border-radius: 12px;
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.025em;
width: fit-content;
white-space: nowrap;
&[data-type="LOCAL"] {
color: #3b82f6;
background: color-mix(in srgb, #3b82f6 15%, transparent);
border-color: #3b82f6;
}
&[data-type="OIDC"] {
color: #8b5cf6;
background: color-mix(in srgb, #8b5cf6 15%, transparent);
border-color: #8b5cf6;
}
&:not([data-type="LOCAL"]):not([data-type="OIDC"]) {
color: #6b7280;
background: color-mix(in srgb, #6b7280 15%, transparent);
border-color: #6b7280;
}
}
.library-names {
.permission-indicator {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--card-background);
color: var(--text-color-secondary);
transition: all 0.2s;
border: 1px solid var(--border-color);
&.active {
background: color-mix(in srgb, var(--primary-color) 20%, var(--card-background));
color: var(--primary-color);
border-color: var(--primary-color);
.pi {
font-weight: 600;
}
}
}
.admin-indicator {
&.active {
background: color-mix(in srgb, #10b981 20%, var(--card-background));
color: #10b981;
border-color: #10b981;
}
&:not(.active) {
background: color-mix(in srgb, #6b7280 15%, var(--card-background));
color: #6b7280;
border-color: #6b7280;
}
}
.permission-summary {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.375rem 0.875rem;
border-radius: 16px;
background: var(--card-background);
border: 1px solid var(--border-color);
transition: all 0.2s;
&[data-count="0"] {
background: color-mix(in srgb, #6b7280 20%, var(--card-background));
border-color: #6b7280;
.permission-count {
color: #6b7280;
}
}
&[data-total="6"][data-count="1"],
&[data-total="6"][data-count="2"],
&[data-total="3"][data-count="1"],
&[data-total="2"][data-count="1"],
&[data-total="4"][data-count="1"] {
background: color-mix(in srgb, #ef4444 20%, var(--card-background));
border-color: #ef4444;
.permission-count {
color: #ef4444;
font-weight: 700;
}
}
&[data-total="6"][data-count="3"],
&[data-total="6"][data-count="4"],
&[data-total="3"][data-count="2"],
&[data-total="4"][data-count="2"] {
background: color-mix(in srgb, #f59e0b 20%, var(--card-background));
border-color: #f59e0b;
.permission-count {
color: #f59e0b;
font-weight: 700;
}
}
&[data-total="6"][data-count="5"],
&[data-total="4"][data-count="3"] {
background: color-mix(in srgb, #3b82f6 20%, var(--card-background));
border-color: #3b82f6;
.permission-count {
color: #3b82f6;
font-weight: 700;
}
}
&[data-total="6"][data-count="6"],
&[data-total="3"][data-count="3"],
&[data-total="2"][data-count="2"],
&[data-total="4"][data-count="4"] {
background: color-mix(in srgb, #10b981 20%, var(--card-background));
border-color: #10b981;
.permission-count {
color: #10b981;
font-weight: 700;
}
}
}
.permission-count {
font-size: 0.875rem;
font-weight: 600;
color: var(--p-text-color);
transition: color 0.2s;
}
.actions-cell {
text-align: center;
.actions-group {
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
}
.user-dialog {
.p-dialog-content {
padding: 1.5rem;
}
.p-dialog-footer {
padding: 1rem 1.5rem;
}
}
.dialog-form {
.expanded-content {
padding: 1.5rem;
background: var(--overlay-background);
border-radius: 8px;
margin: 0.5rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
border: 1px solid var(--border-color);
}
.form-field {
.expanded-section {
display: flex;
flex-direction: column;
gap: 1rem;
}
.expanded-title {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9375rem;
font-weight: 600;
color: var(--text-color);
margin: 0;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border-color);
.pi {
color: var(--primary-color);
}
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
}
.info-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
label {
font-size: 0.8125rem;
font-weight: 600;
color: var(--p-text-color);
font-size: 0.875rem;
color: var(--text-color-secondary);
text-transform: uppercase;
letter-spacing: 0.025em;
}
span {
font-size: 0.9375rem;
color: var(--text-color);
}
input {
width: 100%;
}
}
.error-message {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
background: var(--p-red-50);
border: 1px solid var(--p-red-200);
border-radius: 4px;
color: var(--p-red-700);
font-size: 0.875rem;
.permissions-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
}
.pi {
color: var(--p-red-500);
.permission-group {
h5 {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-color);
margin: 0 0 0.75rem 0;
text-transform: uppercase;
letter-spacing: 0.025em;
.pi {
color: var(--primary-color);
font-size: 0.875rem;
}
}
}
.dialog-actions {
.permission-items {
display: flex;
justify-content: flex-end;
flex-direction: column;
gap: 0.75rem;
}
.full-name-column {
min-width: 150px;
width: 150px;
.permission-item {
display: flex;
align-items: center;
gap: 0.75rem;
label {
font-size: 0.875rem;
color: var(--text-color);
cursor: pointer;
user-select: none;
}
p-checkbox {
flex-shrink: 0;
}
}
.email-column {
min-width: 150px;
width: 200px;
.library-names {
font-size: 0.875rem;
color: var(--text-color);
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.libraries-column {
min-width: 200px;
width: 250px;
.text-center {
text-align: center;
}

View File

@@ -1,6 +1,6 @@
import {Component, inject, OnDestroy, OnInit} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {Button} from 'primeng/button';
import {Button, ButtonDirective} from 'primeng/button';
import {DynamicDialogRef} from 'primeng/dynamicdialog';
import {TableModule} from 'primeng/table';
import {LowerCasePipe, TitleCasePipe} from '@angular/common';
@@ -29,7 +29,8 @@ import {DialogLauncherService} from '../../../shared/services/dialog-launcher.se
Password,
LowerCasePipe,
TitleCasePipe,
Tooltip
Tooltip,
ButtonDirective
],
templateUrl: './user-management.component.html',
styleUrls: ['./user-management.component.scss'],
@@ -46,6 +47,7 @@ export class UserManagementComponent implements OnInit, OnDestroy {
currentUser: User | null = null;
editingLibraryIds: number[] = [];
allLibraries: Library[] = [];
expandedRows: { [key: string]: boolean } = {};
isPasswordDialogVisible = false;
selectedUser: User | null = null;
@@ -215,4 +217,82 @@ export class UserManagementComponent implements OnInit, OnDestroy {
});
}
}
getBookManagementPermissionsCount(user: User): number {
const permissions = user.permissions;
let count = 0;
if (permissions.canUpload) count++;
if (permissions.canDownload) count++;
if (permissions.canDeleteBook) count++;
if (permissions.canEditMetadata) count++;
if (permissions.canManageLibrary) count++;
if (permissions.canEmailBook) count++;
return count;
}
getDeviceSyncPermissionsCount(user: User): number {
const permissions = user.permissions;
let count = 0;
if (permissions.canSyncKoReader) count++;
if (permissions.canSyncKobo) count++;
if (permissions.canAccessOpds) count++;
return count;
}
getSystemAccessPermissionsCount(user: User): number {
const permissions = user.permissions;
let count = 0;
if (permissions.canAccessBookdrop) count++;
if (permissions.canAccessLibraryStats) count++;
if (permissions.canAccessUserStats) count++;
return count;
}
getSystemConfigPermissionsCount(user: User): number {
const permissions = user.permissions;
let count = 0;
if (permissions.canAccessTaskManager) count++;
if (permissions.canManageGlobalPreferences) count++;
if (permissions.canManageMetadataConfig) count++;
if (permissions.canManageIcons) count++;
return count;
}
toggleRowExpansion(user: User) {
if (this.expandedRows[user.id]) {
delete this.expandedRows[user.id];
} else {
this.expandedRows[user.id] = true;
}
}
isRowExpanded(user: User): boolean {
return this.expandedRows[user.id];
}
onAdminCheckboxChange(user: any) {
if (user.permissions.admin) {
user.permissions.canUpload = true;
user.permissions.canDownload = true;
user.permissions.canDeleteBook = true;
user.permissions.canEditMetadata = true;
user.permissions.canManageLibrary = true;
user.permissions.canEmailBook = true;
user.permissions.canSyncKoReader = true;
user.permissions.canSyncKobo = true;
user.permissions.canAccessOpds = true;
user.permissions.canAccessBookdrop = true;
user.permissions.canAccessLibraryStats = true;
user.permissions.canAccessUserStats = true;
user.permissions.canManageMetadataConfig = true;
user.permissions.canManageGlobalPreferences = true;
user.permissions.canAccessTaskManager = true;
user.permissions.canManageEmailConfig = true;
user.permissions.canManageIcons = true;
}
}
isPermissionDisabled(user: any): boolean {
return !user.isEditing || user.permissions.admin;
}
}

View File

@@ -158,10 +158,19 @@ export interface User {
canEmailBook: boolean;
canDeleteBook: boolean;
canEditMetadata: boolean;
canManipulateLibrary: boolean;
canManageLibrary: boolean;
canManageMetadataConfig: boolean;
canSyncKoReader: boolean;
canSyncKobo: boolean;
canAccessOpds: boolean;
canAccessBookdrop: boolean;
canAccessLibraryStats: boolean;
canAccessUserStats: boolean;
canAccessTaskManager: boolean;
canManageEmailConfig: boolean;
canManageGlobalPreferences: boolean;
canManageIcons: boolean;
demoUser: boolean;
};
userSettings: UserSettings;
provisioningMethod?: 'LOCAL' | 'OIDC' | 'REMOTE';

View File

@@ -5,7 +5,6 @@
<i class="pi pi-chart-line"></i>
<h2>{{ userName ? userName + "'s Reading Statistics" : "Your Reading Statistics" }}</h2>
</div>
<p class="subtitle">Track your reading habits and progress</p>
</div>
</div>

View File

@@ -51,14 +51,6 @@
white-space: nowrap;
}
}
.subtitle {
color: var(--text-secondary-color);
font-size: 0.95rem;
margin: 0;
padding-left: 0;
white-space: nowrap;
}
}
.charts-container {
@@ -100,12 +92,6 @@
white-space: normal;
}
}
.subtitle {
font-size: 0.875rem;
padding-left: 0;
white-space: normal;
}
}
.chart-card {
@@ -133,12 +119,6 @@
white-space: normal;
}
}
.subtitle {
font-size: 0.8rem;
padding-left: 0;
white-space: normal;
}
}
.chart-card {

View File

@@ -2,7 +2,9 @@
<p-tablist>
<p-tab value="0">Prime Icons</p-tab>
<p-tab value="1">SVG Icons</p-tab>
<p-tab value="2">Add SVG Icon(s)</p-tab>
@if (canManageIcons) {
<p-tab value="2">Add SVG Icon(s)</p-tab>
}
</p-tablist>
<p-tabpanels>
<p-tabpanel value="0">
@@ -58,8 +60,8 @@
outlined>
</p-button>
<span class="pagination-info">
Page {{ currentSvgPage + 1 }} of {{ totalSvgPages }}
</span>
Page {{ currentSvgPage + 1 }} of {{ totalSvgPages }}
</span>
<p-button
(onClick)="loadSvgIcons(currentSvgPage + 1)"
[disabled]="currentSvgPage >= totalSvgPages - 1"
@@ -71,113 +73,116 @@
</div>
}
<div style="display: flex; align-items: center; position: fixed; right: 32px; bottom: 32px; z-index: 101;">
<div
class="svg-trash-area"
[class.trash-hover]="isTrashHover"
(dragover)="onTrashDragOver($event)"
(dragleave)="onTrashDragLeave($event)"
(drop)="onTrashDrop($event)"
>
<i class="pi pi-trash"></i>
<span>Drag here to delete icon</span>
@if (canManageIcons) {
<div style="display: flex; align-items: center; position: fixed; right: 32px; bottom: 32px; z-index: 101;">
<div
class="svg-trash-area"
[class.trash-hover]="isTrashHover"
(dragover)="onTrashDragOver($event)"
(dragleave)="onTrashDragLeave($event)"
(drop)="onTrashDrop($event)">
<i class="pi pi-trash"></i>
<span>Drag here to delete icon</span>
</div>
</div>
</div>
}
}
</div>
</p-tabpanel>
<p-tabpanel value="2">
<div class="svg-paste-container">
<div class="svg-input-section">
<input
type="text"
[(ngModel)]="svgName"
placeholder="Enter icon name for saving"
class="svg-name-input"
/>
@if (canManageIcons) {
<p-tabpanel value="2">
<div class="svg-paste-container">
<div class="svg-input-section">
<input
type="text"
[(ngModel)]="svgName"
placeholder="Enter icon name for saving"
class="svg-name-input"
/>
<textarea
[(ngModel)]="svgContent"
(ngModelChange)="onSvgContentChange()"
placeholder="Paste your SVG code here..."
class="svg-textarea"
rows="8"></textarea>
<textarea
[(ngModel)]="svgContent"
(ngModelChange)="onSvgContentChange()"
placeholder="Paste your SVG code here..."
class="svg-textarea"
rows="8"></textarea>
@if (svgContent && svgPreview) {
<div class="svg-preview-section">
<h4 class="preview-title">Preview</h4>
<div class="svg-preview" [innerHTML]="svgPreview"></div>
</div>
}
@if (errorMessage) {
<div class="error-message">{{ errorMessage }}</div>
}
<div class="button-container">
<p-button
(onClick)="addSvgEntry()"
[disabled]="!svgContent || !svgName"
label="Add to Queue"
severity="primary"
outlined
icon="pi pi-plus">
</p-button>
</div>
</div>
@if (svgEntries.length > 0) {
<div class="svg-entries-section">
<div class="entries-header">
<h4 class="entries-title">
Queued Icons ({{ svgEntries.length }})
</h4>
<p-button
(onClick)="clearAllEntries()"
label="Clear All"
severity="danger"
[text]="true"
icon="pi pi-times">
</p-button>
</div>
<div class="entries-grid">
@for (entry of svgEntries; track entry.name; let i = $index) {
<div class="entry-card" [class.has-error]="entry.error">
<div class="entry-header">
<span class="entry-name">{{ entry.name }}</span>
<button
class="entry-remove"
(click)="removeSvgEntry(i)"
title="Remove">
<i class="pi pi-times"></i>
</button>
</div>
<div class="entry-preview" [innerHTML]="entry.preview"></div>
@if (entry.error) {
<div class="entry-error">{{ entry.error }}</div>
}
</div>
}
</div>
@if (batchErrorMessage) {
<div class="error-message">{{ batchErrorMessage }}</div>
@if (svgContent && svgPreview) {
<div class="svg-preview-section">
<h4 class="preview-title">Preview</h4>
<div class="svg-preview" [innerHTML]="svgPreview"></div>
</div>
}
<div class="batch-actions">
@if (errorMessage) {
<div class="error-message">{{ errorMessage }}</div>
}
<div class="button-container">
<p-button
(onClick)="saveAllSvgs()"
[loading]="isSavingBatch"
[disabled]="svgEntries.length === 0"
label="Save All Icons"
severity="success"
icon="pi pi-save">
(onClick)="addSvgEntry()"
[disabled]="!svgContent || !svgName"
label="Add to Queue"
severity="primary"
outlined
icon="pi pi-plus">
</p-button>
</div>
</div>
}
</div>
</p-tabpanel>
@if (svgEntries.length > 0) {
<div class="svg-entries-section">
<div class="entries-header">
<h4 class="entries-title">
Queued Icons ({{ svgEntries.length }})
</h4>
<p-button
(onClick)="clearAllEntries()"
label="Clear All"
severity="danger"
[text]="true"
icon="pi pi-times">
</p-button>
</div>
<div class="entries-grid">
@for (entry of svgEntries; track entry.name; let i = $index) {
<div class="entry-card" [class.has-error]="entry.error">
<div class="entry-header">
<span class="entry-name">{{ entry.name }}</span>
<button
class="entry-remove"
(click)="removeSvgEntry(i)"
title="Remove">
<i class="pi pi-times"></i>
</button>
</div>
<div class="entry-preview" [innerHTML]="entry.preview"></div>
@if (entry.error) {
<div class="entry-error">{{ entry.error }}</div>
}
</div>
}
</div>
@if (batchErrorMessage) {
<div class="error-message">{{ batchErrorMessage }}</div>
}
<div class="batch-actions">
<p-button
(onClick)="saveAllSvgs()"
[loading]="isSavingBatch"
[disabled]="svgEntries.length === 0"
label="Save All Icons"
severity="success"
icon="pi pi-save">
</p-button>
</div>
</div>
}
</div>
</p-tabpanel>
}
</p-tabpanels>
</p-tabs>

View File

@@ -9,6 +9,7 @@ import {MessageService} from 'primeng/api';
import {IconCategoriesHelper} from '../../helpers/icon-categories.helper';
import {Button} from 'primeng/button';
import {TabsModule} from 'primeng/tabs';
import {UserService} from '../../../features/settings/user-management/user.service';
interface SvgEntry {
name: string;
@@ -65,6 +66,7 @@ export class IconPickerComponent implements OnInit {
sanitizer = inject(DomSanitizer);
urlHelper = inject(UrlHelperService);
messageService = inject(MessageService);
userService = inject(UserService);
searchText: string = '';
selectedIcon: string | null = null;
@@ -399,4 +401,9 @@ export class IconPickerComponent implements OnInit {
}
});
}
get canManageIcons(): boolean {
const user = this.userService.getCurrentUser();
return user?.permissions.canManageIcons || user?.permissions.admin || false;
}
}

View File

@@ -66,7 +66,7 @@ export class AppMenuitemComponent implements OnInit, OnDestroy {
) {
this.userService.userState$.subscribe(userState => {
if (userState?.user) {
this.canManipulateLibrary = userState.user.permissions.canManipulateLibrary;
this.canManipulateLibrary = userState.user.permissions.canManageLibrary;
this.admin = userState.user.permissions.admin;
}
});

View File

@@ -30,46 +30,50 @@
<ul class="topbar-items hidden md:flex items-center gap-3 ml-auto pl-4">
<div class="flex gap-4">
@if (userService.userState$ | async; as userState) {
<li>
@if (userState.user?.permissions?.canManipulateLibrary || userState.user?.permissions?.admin) {
@if (userState.user?.permissions?.canAccessBookdrop || userState.user?.permissions?.admin) {
<li>
<a class="topbar-item" (click)="navigateToBookdrop()" pTooltip="Bookdrop" tooltipPosition="bottom">
<i class="pi pi-inbox text-surface-100"></i>
</a>
}
</li>
<li>
@if (userState.user?.permissions?.canManipulateLibrary || userState.user?.permissions?.admin) {
</li>
}
@if (userState.user?.permissions?.canManageLibrary || userState.user?.permissions?.admin) {
<li>
<a class="topbar-item" (click)="openLibraryCreatorDialog()" pTooltip="Create New Library" tooltipPosition="bottom">
<i class="pi pi-plus-circle text-surface-100"></i>
</a>
}
</li>
<li>
@if (userState.user?.permissions?.canUpload || userState.user?.permissions?.admin) {
</li>
}
@if (userState.user?.permissions?.canUpload || userState.user?.permissions?.admin) {
<li>
<a class="topbar-item" (click)="openFileUploadDialog()" pTooltip="Upload Book" tooltipPosition="bottom">
<i class="pi pi-upload text-surface-100"></i>
</a>
</li>
}
}
@if (hasStatsAccess) {
<li>
<button
class="topbar-item"
(click)="shouldShowStatsMenu ? statsMenu.toggle($event) : handleStatsButtonClick($event)"
[pTooltip]="statsTooltip"
tooltipPosition="bottom">
<i class="pi pi-chart-bar text-surface-100"></i>
</button>
@if (shouldShowStatsMenu) {
<p-menu #statsMenu [model]="statsMenuItems" [popup]="true" appendTo="body" />
}
</li>
}
<li>
<button
class="topbar-item"
(click)="statsMenu.toggle($event)"
pTooltip="Stats"
tooltipPosition="bottom">
<i class="pi pi-chart-bar text-surface-100"></i>
</button>
<p-menu #statsMenu [model]="statsMenuItems" [popup]="true" appendTo="body" />
</li>
@if (userService.userState$ | async; as userState) {
<li>
@if (userState.user?.permissions?.canManipulateLibrary || userState.user?.permissions?.admin) {
@if (userState.user?.permissions?.canManageLibrary || userState.user?.permissions?.admin) {
<li>
<a class="topbar-item" (click)="navigateToMetadataManager()" pTooltip="Metadata Manager" tooltipPosition="bottom">
<i class="pi pi-sparkles text-surface-100"></i>
</a>
}
</li>
</li>
}
}
<li>
<a class="topbar-item" (click)="navigateToSettings()" pTooltip="Settings" tooltipPosition="bottom">
@@ -140,11 +144,18 @@
<i class="pi pi-info-circle text-surface-100"></i>
</a>
</li>
<li>
<button class="topbar-item" (click)="openUserProfileDialog()" pTooltip="Profile" tooltipPosition="bottom">
<i class="pi pi-user text-surface-100"></i>
</button>
</li>
<!-- Show Profile only to demo users -->
@if (userService.userState$ | async; as userState) {
@if (!userState.user?.permissions?.demoUser) {
<li>
<button class="topbar-item" (click)="openUserProfileDialog()" pTooltip="Profile" tooltipPosition="bottom">
<i class="pi pi-user text-surface-100"></i>
</button>
</li>
}
}
<li>
<button class="topbar-item" (click)="logout()" pTooltip="Logout" tooltipPosition="left">
<i class="pi pi-sign-out text-surface-100"></i>
@@ -162,16 +173,7 @@
<p-popover #mobileMenu>
<ul class="flex flex-col gap-1 w-48">
@if (userService.userState$ | async; as userState) {
@if (userState.user?.permissions?.canManipulateLibrary || userState.user?.permissions?.admin) {
<li>
<button
class="flex items-center gap-2 w-full text-left p-2 hover:bg-surface-200 dark:hover:bg-surface-700 rounded"
(click)="openLibraryCreatorDialog(); mobileMenu.hide()"
>
<i class="pi pi-plus-circle text-surface-100"></i>
Create Library
</button>
</li>
@if (userState.user?.permissions?.canAccessBookdrop || userState.user?.permissions?.admin) {
<li>
<button
class="flex items-center gap-2 w-full text-left p-2 hover:bg-surface-200 dark:hover:bg-surface-700 rounded"
@@ -182,6 +184,17 @@
</button>
</li>
}
@if (userState.user?.permissions?.canManageLibrary || userState.user?.permissions?.admin) {
<li>
<button
class="flex items-center gap-2 w-full text-left p-2 hover:bg-surface-200 dark:hover:bg-surface-700 rounded"
(click)="openLibraryCreatorDialog(); mobileMenu.hide()"
>
<i class="pi pi-plus-circle text-surface-100"></i>
Create Library
</button>
</li>
}
@if (userState.user?.permissions?.canUpload || userState.user?.permissions?.admin) {
<li>
<button
@@ -194,16 +207,33 @@
</li>
}
}
<li>
<button
class="flex items-center gap-2 w-full text-left p-2 hover:bg-surface-200 dark:hover:bg-surface-700 rounded"
(click)="statsMenu.toggle($event)"
>
<i class="pi pi-chart-bar text-surface-100"></i>
Charts
</button>
<p-menu #statsMenuMobile [model]="statsMenuItems" [popup]="true" />
</li>
@if (hasStatsAccess) {
<li>
<button
class="flex items-center gap-2 w-full text-left p-2 hover:bg-surface-200 dark:hover:bg-surface-700 rounded"
(click)="shouldShowStatsMenu ? statsMenuMobile.toggle($event) : handleStatsButtonClick($event)"
>
<i class="pi pi-chart-bar text-surface-100"></i>
Charts
</button>
@if (shouldShowStatsMenu) {
<p-menu #statsMenuMobile [model]="statsMenuItems" [popup]="true" />
}
</li>
}
@if (userService.userState$ | async; as userState) {
@if (userState.user?.permissions?.canManageLibrary || userState.user?.permissions?.admin) {
<li>
<button
class="flex items-center gap-2 w-full text-left p-2 hover:bg-surface-200 dark:hover:bg-surface-700 rounded"
(click)="navigateToMetadataManager(); mobileMenu.hide()"
>
<i class="pi pi-sparkles text-surface-100"></i>
Metadata Manager
</button>
</li>
}
}
<li>
<button
class="flex items-center gap-2 w-full text-left p-2 hover:bg-surface-200 dark:hover:bg-surface-700 rounded"
@@ -232,15 +262,21 @@
Support BookLore
</button>
</li>
<li>
<button
class="flex items-center gap-2 w-full text-left p-2 hover:bg-surface-200 dark:hover:bg-surface-700 rounded"
(click)="openUserProfileDialog(); mobileMenu.hide()"
>
<i class="pi pi-user text-surface-100"></i>
Profile
</button>
</li>
@if (userService.userState$ | async; as userState) {
@if (!userState.user?.permissions?.demoUser) {
<li>
<button
class="flex items-center gap-2 w-full text-left p-2 hover:bg-surface-200 dark:hover:bg-surface-700 rounded"
(click)="openUserProfileDialog(); mobileMenu.hide()"
>
<i class="pi pi-user text-surface-100"></i>
Profile
</button>
</li>
}
}
<li>
<button
class="flex items-center gap-2 w-full text-left p-2 hover:bg-surface-200 dark:hover:bg-surface-700 rounded"

View File

@@ -57,6 +57,8 @@ export class AppTopBarComponent implements OnDestroy {
@ViewChild('menubutton') menuButton!: ElementRef;
@ViewChild('topbarmenubutton') topbarMenuButton!: ElementRef;
@ViewChild('topbarmenu') menu!: ElementRef;
@ViewChild('statsMenu') statsMenu: any;
@ViewChild('statsMenuMobile') statsMenuMobile: any;
isMenuVisible = true;
progressHighlight = false;
@@ -83,7 +85,6 @@ export class AppTopBarComponent implements OnDestroy {
private bookdropFileService: BookdropFileService,
private dialogLauncher: DialogLauncherService
) {
this.initializeStatsMenu();
this.subscribeToMetadataProgress();
this.subscribeToNotifications();
@@ -104,6 +105,12 @@ export class AppTopBarComponent implements OnDestroy {
this.updateCompletedTaskCount();
this.updateTaskVisibilityWithBookdrop();
});
this.userService.userState$
.pipe(takeUntil(this.destroy$))
.subscribe(() => {
this.initializeStatsMenu();
});
}
ngOnDestroy(): void {
@@ -158,6 +165,16 @@ export class AppTopBarComponent implements OnDestroy {
this.authService.logout();
}
handleStatsButtonClick(event: Event) {
if (this.statsMenuItems.length === 0) {
return;
}
if (this.statsMenuItems.length === 1) {
this.statsMenuItems[0].command?.({originalEvent: event, item: this.statsMenuItems[0]});
}
}
private subscribeToMetadataProgress() {
this.metadataProgressService.progressUpdates$
.pipe(takeUntil(this.destroy$))
@@ -200,18 +217,44 @@ export class AppTopBarComponent implements OnDestroy {
}
private initializeStatsMenu() {
this.statsMenuItems = [
{
const userState = this.userService.userStateSubject.value;
const user = userState.user;
this.statsMenuItems = [];
if (user?.permissions?.canAccessLibraryStats || user?.permissions?.admin) {
this.statsMenuItems.push({
label: 'Library Stats',
icon: 'pi pi-chart-line',
command: () => this.navigateToStats()
},
{
});
}
if (user?.permissions?.canAccessUserStats || user?.permissions?.admin) {
this.statsMenuItems.push({
label: 'Reading Stats',
icon: 'pi pi-users',
command: () => this.navigateToUserStats()
}
];
});
}
}
get hasStatsAccess(): boolean {
return this.statsMenuItems.length > 0;
}
get shouldShowStatsMenu(): boolean {
return this.statsMenuItems.length > 1;
}
get statsTooltip(): string {
if (this.statsMenuItems.length === 0) {
return 'Stats';
}
if (this.statsMenuItems.length === 1) {
return this.statsMenuItems[0].label || 'Stats';
}
return 'Stats';
}
get iconClass(): string {

View File

@@ -184,7 +184,7 @@ export class DialogLauncherService {
openIconPickerDialog(): DynamicDialogRef | null {
return this.openDialog(IconPickerComponent, {
header: 'Choose an Icon',
styleClass: 'dialog-maximal',
styleClass: 'dialog-medium',
});
}