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

@@ -40,9 +40,14 @@ public class SecurityUtil {
return user != null && user.getPermissions().isCanDownload();
}
public boolean canManipulateLibrary() {
public boolean canManageLibrary() {
var user = getCurrentUser();
return user != null && user.getPermissions().isCanManipulateLibrary();
return user != null && user.getPermissions().isCanManageLibrary();
}
public boolean canManageIcons() {
var user = getCurrentUser();
return user != null && user.getPermissions().isCanManageIcons();
}
public boolean canSyncKoReader() {
@@ -89,4 +94,19 @@ public class SecurityUtil {
}
return false;
}
public boolean canAccessBookdrop() {
var user = getCurrentUser();
return user != null && user.getPermissions().isCanAccessBookdrop();
}
public boolean canAccessUserStats() {
var user = getCurrentUser();
return user != null && user.getPermissions().isCanAccessUserStats();
}
public boolean canAccessTaskManager() {
var user = getCurrentUser();
return user != null && user.getPermissions().isCanAccessTaskManager();
}
}

View File

@@ -71,8 +71,8 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
if (permissions.isPermissionEditMetadata()) {
authorities.add(new SimpleGrantedAuthority("ROLE_EDIT_METADATA"));
}
if (permissions.isPermissionManipulateLibrary()) {
authorities.add(new SimpleGrantedAuthority("ROLE_MANIPULATE_LIBRARY"));
if (permissions.isPermissionManageLibrary()) {
authorities.add(new SimpleGrantedAuthority("ROLE_MANAGE_LIBRARY"));
}
if (permissions.isPermissionAdmin()) {
authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN"));

View File

@@ -89,7 +89,7 @@ public class KoboAuthFilter extends OncePerRequestFilter {
addAuthorityIfPermissionGranted(authorities, "ROLE_UPLOAD", permissions.isPermissionUpload());
addAuthorityIfPermissionGranted(authorities, "ROLE_DOWNLOAD", permissions.isPermissionDownload());
addAuthorityIfPermissionGranted(authorities, "ROLE_EDIT_METADATA", permissions.isPermissionEditMetadata());
addAuthorityIfPermissionGranted(authorities, "ROLE_MANIPULATE_LIBRARY", permissions.isPermissionManipulateLibrary());
addAuthorityIfPermissionGranted(authorities, "ROLE_MANAGE_LIBRARY", permissions.isPermissionManageLibrary());
addAuthorityIfPermissionGranted(authorities, "ROLE_ADMIN", permissions.isPermissionAdmin());
addAuthorityIfPermissionGranted(authorities, "ROLE_SYNC_KOBO", permissions.isPermissionSyncKobo());
}

View File

@@ -64,7 +64,7 @@ public class AuthenticationService {
permissions.setCanUpload(true);
permissions.setCanDownload(true);
permissions.setCanEditMetadata(true);
permissions.setCanManipulateLibrary(true);
permissions.setCanManageLibrary(true);
permissions.setCanSyncKoReader(true);
permissions.setCanSyncKobo(true);
permissions.setCanEmailBook(true);

View File

@@ -36,8 +36,7 @@ public class AppSettingController {
@ApiResponse(responseCode = "400", description = "Invalid request")
})
@PutMapping
public void updateSettings(
@Parameter(description = "List of settings to update") @RequestBody List<SettingRequest> settingRequests) throws JsonProcessingException {
public void updateSettings(@Parameter(description = "List of settings to update") @RequestBody List<SettingRequest> settingRequests) throws JsonProcessingException {
for (SettingRequest settingRequest : settingRequests) {
AppSettingKey key = AppSettingKey.valueOf(settingRequest.getName());
appSettingService.updateSetting(key, settingRequest.getValue());

View File

@@ -26,8 +26,7 @@ public class BackgroundUploadController {
@Operation(summary = "Upload background image file", description = "Upload a new background image file for the authenticated user.")
@ApiResponse(responseCode = "200", description = "Background image uploaded successfully")
@PostMapping("/upload")
public ResponseEntity<UploadResponse> uploadFile(
@Parameter(description = "Background image file") @RequestParam("file") MultipartFile file) {
public ResponseEntity<UploadResponse> uploadFile(@Parameter(description = "Background image file") @RequestParam("file") MultipartFile file) {
try {
BookLoreUser authenticatedUser = authenticationService.getAuthenticatedUser();
UploadResponse response = backgroundUploadService.uploadBackgroundFile(file, authenticatedUser.getId());

View File

@@ -23,6 +23,7 @@ import lombok.AllArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
@Tag(name = "Bookdrop", description = "Endpoints for managing bookdrop files and imports")
@@ -39,6 +40,7 @@ public class BookdropFileController {
@Operation(summary = "Get bookdrop notification summary", description = "Retrieve a summary of bookdrop file notifications.")
@ApiResponse(responseCode = "200", description = "Notification summary returned successfully")
@GetMapping("/notification")
@PreAuthorize("@securityUtil.canAccessBookdrop() or @securityUtil.isAdmin()")
public BookdropFileNotification getSummary() {
return bookDropService.getFileNotificationSummary();
}
@@ -46,6 +48,7 @@ public class BookdropFileController {
@Operation(summary = "Get bookdrop files by status", description = "Retrieve a paginated list of bookdrop files filtered by status.")
@ApiResponse(responseCode = "200", description = "Bookdrop files returned successfully")
@GetMapping("/files")
@PreAuthorize("@securityUtil.canAccessBookdrop() or @securityUtil.isAdmin()")
public Page<BookdropFile> getFilesByStatus(
@Parameter(description = "Status to filter files by") @RequestParam(required = false) String status,
Pageable pageable) {
@@ -55,6 +58,7 @@ public class BookdropFileController {
@Operation(summary = "Discard selected bookdrop files", description = "Discard selected bookdrop files based on selection criteria.")
@ApiResponse(responseCode = "200", description = "Files discarded successfully")
@PostMapping("/files/discard")
@PreAuthorize("@securityUtil.canAccessBookdrop() or @securityUtil.isAdmin()")
public ResponseEntity<Void> discardSelectedFiles(
@Parameter(description = "Selection request for files to discard") @RequestBody BookdropSelectionRequest request) {
bookDropService.discardSelectedFiles(request.isSelectAll(), request.getExcludedIds(), request.getSelectedIds());
@@ -64,6 +68,7 @@ public class BookdropFileController {
@Operation(summary = "Finalize bookdrop import", description = "Finalize the import of selected bookdrop files.")
@ApiResponse(responseCode = "200", description = "Import finalized successfully")
@PostMapping("/imports/finalize")
@PreAuthorize("@securityUtil.canAccessBookdrop() or @securityUtil.isAdmin()")
public ResponseEntity<BookdropFinalizeResult> finalizeImport(
@Parameter(description = "Finalize import request") @RequestBody BookdropFinalizeRequest request) {
BookdropFinalizeResult result = bookDropService.finalizeImport(request);
@@ -73,6 +78,7 @@ public class BookdropFileController {
@Operation(summary = "Rescan bookdrop folder", description = "Trigger a rescan of the bookdrop folder for new files.")
@ApiResponse(responseCode = "200", description = "Bookdrop folder rescanned successfully")
@PostMapping("/rescan")
@PreAuthorize("@securityUtil.canAccessBookdrop() or @securityUtil.isAdmin()")
public ResponseEntity<Void> rescanBookdrop() {
monitoringService.rescanBookdropFolder();
return ResponseEntity.ok().build();
@@ -81,6 +87,7 @@ public class BookdropFileController {
@Operation(summary = "Extract metadata from filenames using pattern", description = "Parse filenames of selected files using a pattern to extract metadata fields.")
@ApiResponse(responseCode = "200", description = "Pattern extraction completed")
@PostMapping("/files/extract-pattern")
@PreAuthorize("@securityUtil.canAccessBookdrop() or @securityUtil.isAdmin()")
public ResponseEntity<BookdropPatternExtractResult> extractFromPattern(
@Parameter(description = "Pattern extraction request") @Valid @RequestBody BookdropPatternExtractRequest request) {
BookdropPatternExtractResult result = filenamePatternExtractor.bulkExtract(request);
@@ -90,6 +97,7 @@ public class BookdropFileController {
@Operation(summary = "Bulk edit metadata for selected files", description = "Apply metadata changes to multiple selected files at once.")
@ApiResponse(responseCode = "200", description = "Bulk edit completed")
@PostMapping("/files/bulk-edit")
@PreAuthorize("@securityUtil.canAccessBookdrop() or @securityUtil.isAdmin()")
public ResponseEntity<BookdropBulkEditResult> bulkEditMetadata(
@Parameter(description = "Bulk edit request") @Valid @RequestBody BookdropBulkEditRequest request) {
BookdropBulkEditResult result = bookdropBulkEditService.bulkEdit(request);

View File

@@ -8,6 +8,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -24,8 +25,8 @@ public class FileMoveController {
@Operation(summary = "Move files", description = "Bulk move files to a different location within the library.")
@ApiResponse(responseCode = "200", description = "Files moved successfully")
@PostMapping("/move")
public ResponseEntity<?> moveFiles(
@Parameter(description = "File move request") @RequestBody FileMoveRequest request) {
@PreAuthorize("@securityUtil.canManageLibrary() or @securityUtil.isAdmin()")
public ResponseEntity<?> moveFiles(@Parameter(description = "File move request") @RequestBody FileMoveRequest request) {
fileMoveService.bulkMoveFiles(request);
return ResponseEntity.ok().build();
}

View File

@@ -12,6 +12,7 @@ import jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
@Tag(name = "Icons", description = "Endpoints for managing SVG icons")
@@ -25,6 +26,7 @@ public class IconController {
@Operation(summary = "Save an SVG icon", description = "Saves an SVG icon to the system.")
@ApiResponse(responseCode = "200", description = "SVG icon saved successfully")
@PostMapping
@PreAuthorize("@securityUtil.canManageIcons() or @securityUtil.isAdmin()")
public ResponseEntity<?> saveSvgIcon(@Valid @RequestBody SvgIconCreateRequest svgIconCreateRequest) {
iconService.saveSvgIcon(svgIconCreateRequest);
return ResponseEntity.ok().build();
@@ -33,6 +35,7 @@ public class IconController {
@Operation(summary = "Save multiple SVG icons", description = "Saves multiple SVG icons to the system in batch.")
@ApiResponse(responseCode = "200", description = "Batch save completed with detailed results")
@PostMapping("/batch")
@PreAuthorize("@securityUtil.canManageIcons() or @securityUtil.isAdmin()")
public ResponseEntity<SvgIconBatchResponse> saveBatchSvgIcons(@Valid @RequestBody SvgIconBatchRequest request) {
SvgIconBatchResponse response = iconService.saveBatchSvgIcons(request.getIcons());
return ResponseEntity.ok(response);
@@ -61,6 +64,7 @@ public class IconController {
@Operation(summary = "Delete an SVG icon", description = "Deletes an SVG icon by its name.")
@ApiResponse(responseCode = "200", description = "SVG icon deleted successfully")
@DeleteMapping("/{svgName}")
@PreAuthorize("@securityUtil.canManageIcons() or @securityUtil.isAdmin()")
public ResponseEntity<?> deleteSvgIcon(@Parameter(description = "SVG icon name") @PathVariable String svgName) {
iconService.deleteSvgIcon(svgName);
return ResponseEntity.ok().build();

View File

@@ -29,15 +29,13 @@ public class KoreaderController {
@ApiResponse(responseCode = "200", description = "User authorized successfully")
@GetMapping("/users/auth")
public ResponseEntity<Map<String, String>> authorizeUser() {
return koreaderService
.authorizeUser();
return koreaderService.authorizeUser();
}
@Operation(summary = "Create KoReader user (disabled)", description = "Attempt to register a user via KoReader (always forbidden).")
@ApiResponse(responseCode = "403", description = "User registration forbidden")
@PostMapping("/users/create")
public ResponseEntity<?> createUser(
@Parameter(description = "User data") @RequestBody Map<String, Object> userData) {
public ResponseEntity<?> createUser(@Parameter(description = "User data") @RequestBody Map<String, Object> userData) {
log.warn("Attempt to register user via Koreader blocked: {}", userData);
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(Map.of("error", "User registration via Koreader is disabled"));
}
@@ -45,8 +43,7 @@ public class KoreaderController {
@Operation(summary = "Get KoReader progress", description = "Retrieve reading progress for a book by its hash.")
@ApiResponse(responseCode = "200", description = "Progress returned successfully")
@GetMapping("/syncs/progress/{bookHash}")
public ResponseEntity<KoreaderProgress> getProgress(
@Parameter(description = "Book hash") @PathVariable String bookHash) {
public ResponseEntity<KoreaderProgress> getProgress(@Parameter(description = "Book hash") @PathVariable String bookHash) {
KoreaderProgress progress = koreaderService.getProgress(bookHash);
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_JSON)
@@ -56,8 +53,7 @@ public class KoreaderController {
@Operation(summary = "Update KoReader progress", description = "Update reading progress for a book.")
@ApiResponse(responseCode = "200", description = "Progress updated successfully")
@PutMapping("/syncs/progress")
public ResponseEntity<?> updateProgress(
@Parameter(description = "KoReader progress object") @Valid @RequestBody KoreaderProgress koreaderProgress) {
public ResponseEntity<?> updateProgress(@Parameter(description = "KoReader progress object") @Valid @RequestBody KoreaderProgress koreaderProgress) {
koreaderService.saveProgress(koreaderProgress.getDocument(), koreaderProgress);
return ResponseEntity.ok(Map.of("status", "progress updated"));
}

View File

@@ -49,7 +49,7 @@ public class LibraryController {
@Operation(summary = "Create a library", description = "Create a new library. Requires admin or manipulation permission.")
@ApiResponse(responseCode = "200", description = "Library created successfully")
@PostMapping
@PreAuthorize("@securityUtil.canManipulateLibrary() or @securityUtil.isAdmin()")
@PreAuthorize("@securityUtil.canManageLibrary() or @securityUtil.isAdmin()")
public ResponseEntity<Library> createLibrary(
@Parameter(description = "Library creation request") @Validated @RequestBody CreateLibraryRequest request) {
return ResponseEntity.ok(libraryService.createLibrary(request));
@@ -59,7 +59,7 @@ public class LibraryController {
@ApiResponse(responseCode = "200", description = "Library updated successfully")
@PutMapping("/{libraryId}")
@CheckLibraryAccess(libraryIdParam = "libraryId")
@PreAuthorize("@securityUtil.canManipulateLibrary() or @securityUtil.isAdmin()")
@PreAuthorize("@securityUtil.canManageLibrary() or @securityUtil.isAdmin()")
public ResponseEntity<Library> updateLibrary(
@Parameter(description = "Library update request") @Validated @RequestBody CreateLibraryRequest request,
@Parameter(description = "ID of the library") @PathVariable Long libraryId) {
@@ -70,7 +70,7 @@ public class LibraryController {
@ApiResponse(responseCode = "204", description = "Library deleted successfully")
@DeleteMapping("/{libraryId}")
@CheckLibraryAccess(libraryIdParam = "libraryId")
@PreAuthorize("@securityUtil.canManipulateLibrary() or @securityUtil.isAdmin()")
@PreAuthorize("@securityUtil.canManageLibrary() or @securityUtil.isAdmin()")
public ResponseEntity<?> deleteLibrary(
@Parameter(description = "ID of the library") @PathVariable long libraryId) {
libraryService.deleteLibrary(libraryId);
@@ -101,9 +101,8 @@ public class LibraryController {
@ApiResponse(responseCode = "204", description = "Library rescanned successfully")
@PutMapping("/{libraryId}/refresh")
@CheckLibraryAccess(libraryIdParam = "libraryId")
@PreAuthorize("@securityUtil.canManipulateLibrary() or @securityUtil.isAdmin()")
public ResponseEntity<?> rescanLibrary(
@Parameter(description = "ID of the library") @PathVariable long libraryId) {
@PreAuthorize("@securityUtil.canManageLibrary() or @securityUtil.isAdmin()")
public ResponseEntity<?> rescanLibrary(@Parameter(description = "ID of the library") @PathVariable long libraryId) {
libraryService.rescanLibrary(libraryId);
return ResponseEntity.noContent().build();
}
@@ -112,7 +111,7 @@ public class LibraryController {
@ApiResponse(responseCode = "200", description = "File naming pattern updated successfully")
@PatchMapping("/{libraryId}/file-naming-pattern")
@CheckLibraryAccess(libraryIdParam = "libraryId")
@PreAuthorize("@securityUtil.canManipulateLibrary() or @securityUtil.isAdmin()")
@PreAuthorize("@securityUtil.canManageLibrary() or @securityUtil.isAdmin()")
public ResponseEntity<Library> setFileNamingPattern(
@Parameter(description = "ID of the library") @PathVariable long libraryId,
@Parameter(description = "File naming pattern body") @RequestBody Map<String, String> body) {

View File

@@ -33,24 +33,21 @@ public class MagicShelfController {
@Operation(summary = "Get a magic shelf by ID", description = "Retrieve a specific magic shelf by its ID.")
@ApiResponse(responseCode = "200", description = "Magic shelf returned successfully")
@GetMapping("/{id}")
public ResponseEntity<MagicShelf> getShelf(
@Parameter(description = "ID of the magic shelf") @PathVariable Long id) {
public ResponseEntity<MagicShelf> getShelf(@Parameter(description = "ID of the magic shelf") @PathVariable Long id) {
return ResponseEntity.ok(magicShelfService.getShelf(id));
}
@Operation(summary = "Create or update a magic shelf", description = "Create or update a magic shelf for the user.")
@ApiResponse(responseCode = "200", description = "Magic shelf created/updated successfully")
@PostMapping
public ResponseEntity<MagicShelf> createUpdateShelf(
@Parameter(description = "Magic shelf object") @Valid @RequestBody MagicShelf shelf) {
public ResponseEntity<MagicShelf> createUpdateShelf(@Parameter(description = "Magic shelf object") @Valid @RequestBody MagicShelf shelf) {
return ResponseEntity.ok(magicShelfService.createOrUpdateShelf(shelf));
}
@Operation(summary = "Delete a magic shelf", description = "Delete a specific magic shelf by its ID.")
@ApiResponse(responseCode = "204", description = "Magic shelf deleted successfully")
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteShelf(
@Parameter(description = "ID of the magic shelf") @PathVariable Long id) {
public ResponseEntity<Void> deleteShelf(@Parameter(description = "ID of the magic shelf") @PathVariable Long id) {
magicShelfService.deleteShelf(id);
return ResponseEntity.noContent().build();
}

View File

@@ -1,6 +1,5 @@
package com.adityachandel.booklore.controller;
import com.adityachandel.booklore.config.security.service.AuthenticationService;
import com.adityachandel.booklore.config.security.annotation.CheckBookAccess;
import com.adityachandel.booklore.exception.ApiError;
import com.adityachandel.booklore.mapper.BookMetadataMapper;
@@ -13,6 +12,10 @@ import com.adityachandel.booklore.model.entity.BookEntity;
import com.adityachandel.booklore.model.enums.MetadataReplaceMode;
import com.adityachandel.booklore.repository.BookRepository;
import com.adityachandel.booklore.service.metadata.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -20,12 +23,6 @@ import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.tags.Tag;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.Parameter;
import java.util.List;
import java.util.Map;
@@ -37,7 +34,6 @@ public class MetadataController {
private final BookMetadataService bookMetadataService;
private final BookMetadataUpdater bookMetadataUpdater;
private final AuthenticationService authenticationService;
private final BookMetadataMapper bookMetadataMapper;
private final MetadataMatchService metadataMatchService;
private final DuckDuckGoCoverService duckDuckGoCoverService;

View File

@@ -59,7 +59,6 @@ public class MobileOidcController {
private final UserRepository userRepository;
private final UserProvisioningService userProvisioningService;
private final AuthenticationService authenticationService;
private final BookLoreUserTransformer bookLoreUserTransformer;
private final ObjectMapper objectMapper;
private static final ConcurrentMap<String, Object> userLocks = new ConcurrentHashMap<>();

View File

@@ -26,9 +26,8 @@ public class PathController {
@Operation(summary = "Get folders at a path", description = "Retrieve a list of folders at a given path. Requires admin or library manipulation permission.")
@ApiResponse(responseCode = "200", description = "Folders returned successfully")
@GetMapping
@PreAuthorize("@securityUtil.canManipulateLibrary() or @securityUtil.isAdmin()")
public List<String> getFolders(
@Parameter(description = "Path to list folders at") @RequestParam String path) {
@PreAuthorize("@securityUtil.canManageLibrary() or @securityUtil.isAdmin()")
public List<String> getFolders(@Parameter(description = "Path to list folders at") @RequestParam String path) {
return pathService.getFoldersAtPath(path);
}
}

View File

@@ -11,6 +11,7 @@ import jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
@@ -40,6 +41,7 @@ public class ReadingSessionController {
@ApiResponse(responseCode = "401", description = "Unauthorized")
})
@GetMapping("/heatmap/year/{year}")
@PreAuthorize("@securityUtil.canAccessUserStats() or @securityUtil.isAdmin()")
public ResponseEntity<List<ReadingSessionHeatmapResponse>> getHeatmapForYear(@PathVariable int year) {
List<ReadingSessionHeatmapResponse> heatmapData = readingSessionService.getSessionHeatmapForYear(year);
return ResponseEntity.ok(heatmapData);
@@ -52,6 +54,7 @@ public class ReadingSessionController {
@ApiResponse(responseCode = "401", description = "Unauthorized")
})
@GetMapping("/timeline/week/{year}/{week}")
@PreAuthorize("@securityUtil.canAccessUserStats() or @securityUtil.isAdmin()")
public ResponseEntity<List<ReadingSessionTimelineResponse>> getTimelineForWeek(
@PathVariable int year,
@PathVariable int week) {

View File

@@ -30,14 +30,14 @@ public class TaskController {
private final TaskCronService taskCronService;
@GetMapping
@PreAuthorize("@securityUtil.isAdmin()")
@PreAuthorize("@securityUtil.canAccessTaskManager() or @securityUtil.isAdmin()")
public ResponseEntity<List<TaskInfo>> getAvailableTasks() {
List<TaskInfo> taskInfos = service.getAvailableTasks();
return ResponseEntity.ok(taskInfos);
}
@PostMapping("/start")
@PreAuthorize("@securityUtil.isAdmin()")
@PreAuthorize("@securityUtil.canAccessTaskManager() or @securityUtil.isAdmin()")
public ResponseEntity<TaskCreateResponse> startTask(@RequestBody TaskCreateRequest request) {
TaskCreateResponse response = service.runAsUser(request);
if (response.getStatus() == TaskStatus.ACCEPTED) {
@@ -47,21 +47,21 @@ public class TaskController {
}
@DeleteMapping("/{taskId}/cancel")
@PreAuthorize("@securityUtil.isAdmin()")
@PreAuthorize("@securityUtil.canAccessTaskManager() or @securityUtil.isAdmin()")
public ResponseEntity<TaskCancelResponse> cancelTask(@PathVariable String taskId) {
TaskCancelResponse response = service.cancelTask(taskId);
return ResponseEntity.ok(response);
}
@GetMapping("/last")
@PreAuthorize("@securityUtil.isAdmin()")
@PreAuthorize("@securityUtil.canAccessTaskManager() or @securityUtil.isAdmin()")
public ResponseEntity<TasksHistoryResponse> getLatestTasksForEachType() {
TasksHistoryResponse response = taskHistoryService.getLatestTasksForEachType();
return ResponseEntity.ok(response);
}
@PatchMapping("/{taskType}/cron")
@PreAuthorize("@securityUtil.isAdmin()")
@PreAuthorize("@securityUtil.canAccessTaskManager() or @securityUtil.isAdmin()")
public ResponseEntity<CronConfig> patchCronConfig(@PathVariable TaskType taskType, @RequestBody TaskCronConfigRequest request) {
CronConfig response = taskCronService.patchCronConfig(taskType, request);
service.rescheduleTask(taskType);

View File

@@ -41,8 +41,7 @@ public class UserController {
})
@GetMapping("/{id}")
@PreAuthorize("@securityUtil.canViewUserProfile(#id)")
public ResponseEntity<BookLoreUser> getUser(
@Parameter(description = "ID of the user") @PathVariable Long id) {
public ResponseEntity<BookLoreUser> getUser(@Parameter(description = "ID of the user") @PathVariable Long id) {
return ResponseEntity.ok(userService.getBookLoreUser(id));
}
@@ -69,16 +68,14 @@ public class UserController {
@ApiResponse(responseCode = "204", description = "User deleted successfully")
@DeleteMapping("/{id}")
@PreAuthorize("@securityUtil.isAdmin()")
public void deleteUser(
@Parameter(description = "ID of the user") @PathVariable Long id) {
public void deleteUser(@Parameter(description = "ID of the user") @PathVariable Long id) {
userService.deleteUser(id);
}
@Operation(summary = "Change password", description = "Change the password for the current user.")
@ApiResponse(responseCode = "200", description = "Password changed successfully")
@PutMapping("/change-password")
public ResponseEntity<?> changePassword(
@Parameter(description = "Change password request") @RequestBody ChangePasswordRequest request) {
public ResponseEntity<?> changePassword(@Parameter(description = "Change password request") @RequestBody ChangePasswordRequest request) {
userService.changePassword(request);
return ResponseEntity.ok().build();
}
@@ -87,8 +84,7 @@ public class UserController {
@ApiResponse(responseCode = "200", description = "Password changed successfully")
@PutMapping("/change-user-password")
@PreAuthorize("@securityUtil.isAdmin()")
public ResponseEntity<?> changeUserPassword(
@Parameter(description = "Change user password request") @RequestBody ChangeUserPasswordRequest request) {
public ResponseEntity<?> changeUserPassword(@Parameter(description = "Change user password request") @RequestBody ChangeUserPasswordRequest request) {
userService.changeUserPassword(request);
return ResponseEntity.ok().build();
}

View File

@@ -52,10 +52,11 @@ public enum ApiError {
UNSUPPORTED_FILE_TYPE(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "%s"),
CONFLICT(HttpStatus.CONFLICT, "%s"),
FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "File not found: %s"),
SHELF_CANNOT_BE_DELETED(HttpStatus.FORBIDDEN, "'%s' shelf can't be deleted" ),
SHELF_CANNOT_BE_DELETED(HttpStatus.FORBIDDEN, "'%s' shelf can't be deleted"),
TASK_NOT_FOUND(HttpStatus.NOT_FOUND, "Scheduled task not found: %s"),
TASK_ALREADY_RUNNING(HttpStatus.CONFLICT, "Task is already running: %s"),
ICON_ALREADY_EXISTS(HttpStatus.CONFLICT, "SVG icon with name '%s' already exists");
ICON_ALREADY_EXISTS(HttpStatus.CONFLICT, "SVG icon with name '%s' already exists"),
DEMO_USER_PASSWORD_CHANGE_NOT_ALLOWED(HttpStatus.FORBIDDEN, "Demo user password change not allowed.");
private final HttpStatus status;
private final String message;

View File

@@ -31,10 +31,18 @@ public class BookLoreUserTransformer {
permissions.setCanEditMetadata(userEntity.getPermissions().isPermissionEditMetadata());
permissions.setCanEmailBook(userEntity.getPermissions().isPermissionEmailBook());
permissions.setCanDeleteBook(userEntity.getPermissions().isPermissionDeleteBook());
permissions.setCanManipulateLibrary(userEntity.getPermissions().isPermissionManipulateLibrary());
permissions.setCanManageLibrary(userEntity.getPermissions().isPermissionManageLibrary());
permissions.setCanAccessOpds(userEntity.getPermissions().isPermissionAccessOpds());
permissions.setCanSyncKoReader(userEntity.getPermissions().isPermissionSyncKoreader());
permissions.setCanSyncKobo(userEntity.getPermissions().isPermissionSyncKobo());
permissions.setCanManageMetadataConfig(userEntity.getPermissions().isPermissionManageMetadataConfig());
permissions.setCanAccessBookdrop(userEntity.getPermissions().isPermissionAccessBookdrop());
permissions.setCanAccessLibraryStats(userEntity.getPermissions().isPermissionAccessLibraryStats());
permissions.setCanAccessUserStats(userEntity.getPermissions().isPermissionAccessUserStats());
permissions.setCanAccessTaskManager(userEntity.getPermissions().isPermissionAccessTaskManager());
permissions.setCanManageGlobalPreferences(userEntity.getPermissions().isPermissionManageGlobalPreferences());
permissions.setCanManageIcons(userEntity.getPermissions().isPermissionManageIcons());
permissions.setDemoUser(userEntity.getPermissions().isPermissionDemoUser());
BookLoreUser bookLoreUser = new BookLoreUser();
bookLoreUser.setId(userEntity.getId());
@@ -63,7 +71,8 @@ public class BookLoreUserTransformer {
case SIDEBAR_SHELF_SORTING -> userSettings.setSidebarShelfSorting(objectMapper.readValue(value, SidebarSortOption.class));
case SIDEBAR_MAGIC_SHELF_SORTING -> userSettings.setSidebarMagicShelfSorting(objectMapper.readValue(value, SidebarSortOption.class));
case ENTITY_VIEW_PREFERENCES -> userSettings.setEntityViewPreferences(objectMapper.readValue(value, BookLoreUser.UserSettings.EntityViewPreferences.class));
case TABLE_COLUMN_PREFERENCE -> userSettings.setTableColumnPreference(objectMapper.readValue(value, new TypeReference<>() {}));
case TABLE_COLUMN_PREFERENCE -> userSettings.setTableColumnPreference(objectMapper.readValue(value, new TypeReference<>() {
}));
case DASHBOARD_CONFIG -> userSettings.setDashboardConfig(objectMapper.readValue(value, BookLoreUser.UserSettings.DashboardConfig.class));
}
} else {

View File

@@ -30,12 +30,20 @@ public class BookLoreUser {
private boolean canUpload;
private boolean canDownload;
private boolean canEditMetadata;
private boolean canManipulateLibrary;
private boolean canManageLibrary;
private boolean canSyncKoReader;
private boolean canSyncKobo;
private boolean canEmailBook;
private boolean canDeleteBook;
private boolean canAccessOpds;
private boolean canManageMetadataConfig;
private boolean canAccessBookdrop;
private boolean canAccessLibraryStats;
private boolean canAccessUserStats;
private boolean canAccessTaskManager;
private boolean canManageGlobalPreferences;
private boolean canManageIcons;
private boolean isDemoUser;
}
@Data

View File

@@ -14,13 +14,20 @@ public class UserCreateRequest {
private boolean permissionUpload;
private boolean permissionDownload;
private boolean permissionEditMetadata;
private boolean permissionManipulateLibrary;
private boolean permissionManageLibrary;
private boolean permissionEmailBook;
private boolean permissionDeleteBook;
private boolean permissionAccessOpds;
private boolean permissionSyncKoreader;
private boolean permissionSyncKobo;
private boolean permissionAdmin;
private boolean permissionManageMetadataConfig;
private boolean permissionAccessBookdrop;
private boolean permissionAccessLibraryStats;
private boolean permissionAccessUserStats;
private boolean permissionAccessTaskManager;
private boolean permissionManageGlobalPreferences;
private boolean permissionManageIcons;
private Set<Long> selectedLibraries;
}

View File

@@ -17,11 +17,18 @@ public class UserUpdateRequest {
private boolean canUpload;
private boolean canDownload;
private boolean canEditMetadata;
private boolean canManipulateLibrary;
private boolean canManageLibrary;
private boolean canEmailBook;
private boolean canDeleteBook;
private boolean canAccessOpds;
private boolean canSyncKoReader;
private boolean canSyncKobo;
private boolean canManageMetadataConfig;
private boolean canAccessBookdrop;
private boolean canAccessLibraryStats;
private boolean canAccessUserStats;
private boolean canAccessTaskManager;
private boolean canManageGlobalPreferences;
private boolean canManageIcons;
}
}

View File

@@ -1,44 +1,54 @@
package com.adityachandel.booklore.model.dto.settings;
import com.adityachandel.booklore.model.enums.PermissionType;
import lombok.Getter;
import java.util.List;
@Getter
public enum AppSettingKey {
OIDC_PROVIDER_DETAILS("oidc_provider_details", true, true),
// @formatter:off
// ADMIN only (public settings)
OIDC_PROVIDER_DETAILS ("oidc_provider_details", true, true, List.of(PermissionType.ADMIN)),
OIDC_ENABLED ("oidc_enabled", false, true, List.of(PermissionType.ADMIN)),
OIDC_AUTO_PROVISION_DETAILS ("oidc_auto_provision_details", true, false, List.of(PermissionType.ADMIN)),
KOBO_SETTINGS ("kobo_settings", true, false, List.of(PermissionType.ADMIN)),
OPDS_SERVER_ENABLED ("opds_server_enabled", false, false, List.of(PermissionType.ADMIN)),
QUICK_BOOK_MATCH("quick_book_match", true, false),
LIBRARY_METADATA_REFRESH_OPTIONS("library_metadata_refresh_options", true, false),
OIDC_AUTO_PROVISION_DETAILS("oidc_auto_provision_details", true, false),
SIDEBAR_LIBRARY_SORTING("sidebar_library_sorting", true, false),
SIDEBAR_SHELF_SORTING("sidebar_shelf_sorting", true, false),
METADATA_PROVIDER_SETTINGS("metadata_provider_settings", true, false),
METADATA_MATCH_WEIGHTS("metadata_match_weights", true, false),
METADATA_PERSISTENCE_SETTINGS("metadata_persistence_settings", true, false),
METADATA_PUBLIC_REVIEWS_SETTINGS("metadata_public_reviews_settings", true, false),
KOBO_SETTINGS("kobo_settings", true, false),
COVER_CROPPING_SETTINGS("cover_cropping_settings", true, false),
// ADMIN + MANAGE_METADATA_CONFIG
QUICK_BOOK_MATCH ("quick_book_match", true, false, List.of(PermissionType.ADMIN, PermissionType.MANAGE_METADATA_CONFIG)),
LIBRARY_METADATA_REFRESH_OPTIONS ("library_metadata_refresh_options", true, false, List.of(PermissionType.ADMIN, PermissionType.MANAGE_METADATA_CONFIG)),
METADATA_PROVIDER_SETTINGS ("metadata_provider_settings", true, false, List.of(PermissionType.ADMIN, PermissionType.MANAGE_METADATA_CONFIG)),
METADATA_MATCH_WEIGHTS ("metadata_match_weights", true, false, List.of(PermissionType.ADMIN, PermissionType.MANAGE_METADATA_CONFIG)),
METADATA_PERSISTENCE_SETTINGS ("metadata_persistence_settings", true, false, List.of(PermissionType.ADMIN, PermissionType.MANAGE_METADATA_CONFIG)),
METADATA_PUBLIC_REVIEWS_SETTINGS ("metadata_public_reviews_settings", true, false, List.of(PermissionType.ADMIN, PermissionType.MANAGE_METADATA_CONFIG)),
UPLOAD_FILE_PATTERN ("upload_file_pattern", false, false, List.of(PermissionType.ADMIN, PermissionType.MANAGE_METADATA_CONFIG)),
MOVE_FILE_PATTERN ("move_file_pattern", false, false, List.of(PermissionType.ADMIN, PermissionType.MANAGE_METADATA_CONFIG)),
METADATA_DOWNLOAD_ON_BOOKDROP ("metadata_download_on_bookdrop", false, false, List.of(PermissionType.ADMIN, PermissionType.MANAGE_METADATA_CONFIG)),
AUTO_BOOK_SEARCH("auto_book_search", false, false),
COVER_IMAGE_RESOLUTION("cover_image_resolution", false, false),
SIMILAR_BOOK_RECOMMENDATION("similar_book_recommendation", false, false),
UPLOAD_FILE_PATTERN("upload_file_pattern", false, false),
MOVE_FILE_PATTERN("move_file_pattern", false, false),
OPDS_SERVER_ENABLED("opds_server_enabled", false, false),
OIDC_ENABLED("oidc_enabled", false, true),
CBX_CACHE_SIZE_IN_MB("cbx_cache_size_in_mb", false, false),
PDF_CACHE_SIZE_IN_MB("pdf_cache_size_in_mb", false, false),
BOOK_DELETION_ENABLED("book_deletion_enabled", false, false),
METADATA_DOWNLOAD_ON_BOOKDROP("metadata_download_on_bookdrop", false, false),
MAX_FILE_UPLOAD_SIZE_IN_MB("max_file_upload_size_in_mb", false, false);
// ADMIN + MANAGE_GLOBAL_PREFERENCES
COVER_CROPPING_SETTINGS ("cover_cropping_settings", true, false, List.of(PermissionType.ADMIN, PermissionType.MANAGE_GLOBAL_PREFERENCES)),
AUTO_BOOK_SEARCH ("auto_book_search", false, false, List.of(PermissionType.ADMIN, PermissionType.MANAGE_GLOBAL_PREFERENCES)),
SIMILAR_BOOK_RECOMMENDATION ("similar_book_recommendation", false, false, List.of(PermissionType.ADMIN, PermissionType.MANAGE_GLOBAL_PREFERENCES)),
CBX_CACHE_SIZE_IN_MB ("cbx_cache_size_in_mb", false, false, List.of(PermissionType.ADMIN, PermissionType.MANAGE_GLOBAL_PREFERENCES)),
PDF_CACHE_SIZE_IN_MB ("pdf_cache_size_in_mb", false, false, List.of(PermissionType.ADMIN, PermissionType.MANAGE_GLOBAL_PREFERENCES)),
MAX_FILE_UPLOAD_SIZE_IN_MB ("max_file_upload_size_in_mb", false, false, List.of(PermissionType.ADMIN, PermissionType.MANAGE_GLOBAL_PREFERENCES)),
// No specific permissions required
SIDEBAR_LIBRARY_SORTING ("sidebar_library_sorting", true, false, List.of()),
SIDEBAR_SHELF_SORTING ("sidebar_shelf_sorting", true, false, List.of());
// @formatter:on
private final String dbKey;
private final boolean isJson;
private final boolean isPublic;
private final List<PermissionType> requiredPermissions;
AppSettingKey(String dbKey, boolean isJson, boolean isPublic) {
AppSettingKey(String dbKey, boolean isJson, boolean isPublic, List<PermissionType> requiredPermissions) {
this.dbKey = dbKey;
this.isJson = isJson;
this.isPublic = isPublic;
this.requiredPermissions = requiredPermissions;
}
@Override

View File

@@ -23,7 +23,6 @@ public class AppSettings {
private Integer pdfCacheSizeInMb;
private Integer maxFileUploadSizeInMb;
private boolean remoteAuthEnabled;
private boolean bookDeletionEnabled;
private boolean metadataDownloadOnBookdrop;
private boolean oidcEnabled;
private OidcProviderDetails oidcProviderDetails;

View File

@@ -20,6 +20,9 @@ public class UserPermissionsEntity {
@JoinColumn(name = "user_id", nullable = false, unique = true)
private BookLoreUserEntity user;
@Column(name = "permission_admin", nullable = false)
private boolean permissionAdmin;
@Column(name = "permission_upload", nullable = false)
@Builder.Default
private boolean permissionUpload = false;
@@ -34,7 +37,7 @@ public class UserPermissionsEntity {
@Column(name = "permission_manipulate_library", nullable = false)
@Builder.Default
private boolean permissionManipulateLibrary = false;
private boolean permissionManageLibrary = false;
@Column(name = "permission_email_book", nullable = false)
@Builder.Default
@@ -56,6 +59,35 @@ public class UserPermissionsEntity {
@Builder.Default
private boolean permissionSyncKobo = false;
@Column(name = "permission_admin", nullable = false)
private boolean permissionAdmin;
@Column(name = "permission_manage_metadata_config", nullable = false)
@Builder.Default
private boolean permissionManageMetadataConfig = false;
@Column(name = "permission_access_bookdrop", nullable = false)
@Builder.Default
private boolean permissionAccessBookdrop = false;
@Column(name = "permission_access_library_stats", nullable = false)
@Builder.Default
private boolean permissionAccessLibraryStats = false;
@Column(name = "permission_access_user_stats", nullable = false)
@Builder.Default
private boolean permissionAccessUserStats = false;
@Column(name = "permission_access_task_manager", nullable = false)
@Builder.Default
private boolean permissionAccessTaskManager = false;
@Column(name = "permission_manage_global_preferences", nullable = false)
@Builder.Default
private boolean permissionManageGlobalPreferences = false;
@Column(name = "permission_manage_icons", nullable = false)
@Builder.Default
private boolean permissionManageIcons = false;
@Column(name = "permission_demo_user", nullable = false)
@Builder.Default
private boolean permissionDemoUser = false;
}

View File

@@ -1,14 +1,22 @@
package com.adityachandel.booklore.model.enums;
public enum PermissionType {
ADMIN,
UPLOAD,
DOWNLOAD,
EDIT_METADATA,
MANIPULATE_LIBRARY,
MANAGE_LIBRARY,
EMAIL_BOOK,
DELETE_BOOK,
SYNC_KOREADER,
SYNC_KOBO,
ACCESS_OPDS,
ADMIN
MANAGE_METADATA_CONFIG,
ACCESS_BOOKDROP,
ACCESS_LIBRARY_STATS,
ACCESS_USER_STATS,
ACCESS_TASK_MANAGER,
MANAGE_GLOBAL_PREFERENCES,
MANAGE_ICONS,
DEMO_USER
}

View File

@@ -1,13 +1,18 @@
package com.adityachandel.booklore.service.appsettings;
import com.adityachandel.booklore.config.AppProperties;
import com.adityachandel.booklore.config.security.service.AuthenticationService;
import com.adityachandel.booklore.model.dto.BookLoreUser;
import com.adityachandel.booklore.model.dto.request.MetadataRefreshOptions;
import com.adityachandel.booklore.model.dto.settings.*;
import com.adityachandel.booklore.model.entity.AppSettingEntity;
import com.adityachandel.booklore.model.enums.PermissionType;
import com.adityachandel.booklore.util.UserPermissionUtils;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Lazy;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
import java.util.List;
@@ -16,15 +21,21 @@ import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class AppSettingService {
private final AppProperties appProperties;
private final SettingPersistenceHelper settingPersistenceHelper;
private final AuthenticationService authenticationService;
private volatile AppSettings appSettings;
private final ReentrantLock lock = new ReentrantLock();
public AppSettingService(AppProperties appProperties, SettingPersistenceHelper settingPersistenceHelper, @Lazy AuthenticationService authenticationService) {
this.appProperties = appProperties;
this.settingPersistenceHelper = settingPersistenceHelper;
this.authenticationService = authenticationService;
}
public AppSettings getAppSettings() {
if (appSettings == null) {
lock.lock();
@@ -41,6 +52,10 @@ public class AppSettingService {
@Transactional
public void updateSetting(AppSettingKey key, Object val) throws JsonProcessingException {
BookLoreUser user = authenticationService.getAuthenticatedUser();
validatePermission(key, user);
var setting = settingPersistenceHelper.appSettingsRepository.findByName(key.toString());
if (setting == null) {
setting = new AppSettingEntity();
@@ -51,6 +66,21 @@ public class AppSettingService {
refreshCache();
}
private void validatePermission(AppSettingKey key, BookLoreUser user) {
List<PermissionType> requiredPermissions = key.getRequiredPermissions();
if (requiredPermissions.isEmpty()) {
return;
}
boolean hasPermission = requiredPermissions.stream().anyMatch(permission ->
UserPermissionUtils.hasPermission(user.getPermissions(), permission)
);
if (!hasPermission) {
throw new AccessDeniedException("User does not have permission to update " + key.getDbKey());
}
}
public PublicAppSetting getPublicSettings() {
return buildPublicSetting();
}
@@ -104,7 +134,6 @@ public class AppSettingService {
builder.cbxCacheSizeInMb(Integer.parseInt(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.CBX_CACHE_SIZE_IN_MB, "5120")));
builder.pdfCacheSizeInMb(Integer.parseInt(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.PDF_CACHE_SIZE_IN_MB, "5120")));
builder.maxFileUploadSizeInMb(Integer.parseInt(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.MAX_FILE_UPLOAD_SIZE_IN_MB, "100")));
builder.bookDeletionEnabled(Boolean.parseBoolean(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.BOOK_DELETION_ENABLED, "false")));
builder.metadataDownloadOnBookdrop(Boolean.parseBoolean(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.METADATA_DOWNLOAD_ON_BOOKDROP, "true")));
boolean settingEnabled = Boolean.parseBoolean(settingPersistenceHelper.getOrCreateSetting(AppSettingKey.OIDC_ENABLED, "false"));

View File

@@ -52,7 +52,7 @@ public class BookReviewService {
// Check user permissions for auto-download
BookLoreUser currentUser = authenticationService.getAuthenticatedUser();
boolean hasPermission = currentUser.getPermissions().isAdmin() || currentUser.getPermissions().isCanManipulateLibrary();
boolean hasPermission = currentUser.getPermissions().isAdmin() || currentUser.getPermissions().isCanManageLibrary();
if (!hasPermission || !reviewSettings.isAutoDownloadEnabled()) {
return existingReviews;

View File

@@ -106,7 +106,7 @@ public class BookdropEventHandlerService {
notificationService.sendMessageToPermissions(
Topic.LOG,
LogNotification.info("Processing bookdrop file: " + fileName + " (" + queueSize + " files remaining)"),
Set.of(PermissionType.ADMIN, PermissionType.MANIPULATE_LIBRARY)
Set.of(PermissionType.ADMIN, PermissionType.MANAGE_LIBRARY)
);
BookdropFileEntity bookdropFileEntity = BookdropFileEntity.builder()
@@ -134,13 +134,13 @@ public class BookdropEventHandlerService {
notificationService.sendMessageToPermissions(
Topic.LOG,
LogNotification.info("All bookdrop files have finished processing"),
Set.of(PermissionType.ADMIN, PermissionType.MANIPULATE_LIBRARY)
Set.of(PermissionType.ADMIN, PermissionType.MANAGE_LIBRARY)
);
} else {
notificationService.sendMessageToPermissions(
Topic.LOG,
LogNotification.info("Finished processing bookdrop file: " + fileName + " (" + fileQueue.size() + " files remaining)"),
Set.of(PermissionType.ADMIN, PermissionType.MANIPULATE_LIBRARY)
Set.of(PermissionType.ADMIN, PermissionType.MANAGE_LIBRARY)
);
}

View File

@@ -30,6 +30,6 @@ public class BookdropNotificationService {
Instant.now().toString()
);
notificationService.sendMessageToPermissions(Topic.BOOKDROP_FILE, summaryNotification, Set.of(PermissionType.ADMIN, PermissionType.MANIPULATE_LIBRARY));
notificationService.sendMessageToPermissions(Topic.BOOKDROP_FILE, summaryNotification, Set.of(PermissionType.ADMIN, PermissionType.MANAGE_LIBRARY));
}
}

View File

@@ -51,12 +51,19 @@ public class UserProvisioningService {
perms.setPermissionUpload(true);
perms.setPermissionDownload(true);
perms.setPermissionEditMetadata(true);
perms.setPermissionManipulateLibrary(true);
perms.setPermissionManageLibrary(true);
perms.setPermissionEmailBook(true);
perms.setPermissionDeleteBook(true);
perms.setPermissionAccessOpds(true);
perms.setPermissionSyncKoreader(true);
perms.setPermissionSyncKobo(true);
perms.setPermissionManageMetadataConfig(true);
perms.setPermissionAccessBookdrop(true);
perms.setPermissionAccessLibraryStats(true);
perms.setPermissionAccessUserStats(true);
perms.setPermissionAccessTaskManager(true);
perms.setPermissionManageGlobalPreferences(true);
perms.setPermissionManageIcons(true);
user.setPermissions(perms);
createUser(user);
@@ -82,13 +89,20 @@ public class UserProvisioningService {
permissions.setPermissionUpload(request.isPermissionUpload());
permissions.setPermissionDownload(request.isPermissionDownload());
permissions.setPermissionEditMetadata(request.isPermissionEditMetadata());
permissions.setPermissionManipulateLibrary(request.isPermissionManipulateLibrary());
permissions.setPermissionManageLibrary(request.isPermissionManageLibrary());
permissions.setPermissionEmailBook(request.isPermissionEmailBook());
permissions.setPermissionDeleteBook(request.isPermissionDeleteBook());
permissions.setPermissionAccessOpds(request.isPermissionAccessOpds());
permissions.setPermissionSyncKoreader(request.isPermissionSyncKoreader());
permissions.setPermissionSyncKobo(request.isPermissionSyncKobo());
permissions.setPermissionAdmin(request.isPermissionAdmin());
permissions.setPermissionManageMetadataConfig(request.isPermissionManageMetadataConfig());
permissions.setPermissionAccessBookdrop(request.isPermissionAccessBookdrop());
permissions.setPermissionAccessLibraryStats(request.isPermissionAccessLibraryStats());
permissions.setPermissionAccessUserStats(request.isPermissionAccessUserStats());
permissions.setPermissionAccessTaskManager(request.isPermissionAccessTaskManager());
permissions.setPermissionManageGlobalPreferences(request.isPermissionManageGlobalPreferences());
permissions.setPermissionManageIcons(request.isPermissionManageIcons());
user.setPermissions(permissions);
if (request.getSelectedLibraries() != null && !request.getSelectedLibraries().isEmpty()) {
@@ -115,12 +129,19 @@ public class UserProvisioningService {
perms.setPermissionUpload(defaultPermissions.contains("permissionUpload"));
perms.setPermissionDownload(defaultPermissions.contains("permissionDownload"));
perms.setPermissionEditMetadata(defaultPermissions.contains("permissionEditMetadata"));
perms.setPermissionManipulateLibrary(defaultPermissions.contains("permissionManipulateLibrary"));
perms.setPermissionManageLibrary(defaultPermissions.contains("permissionManageLibrary"));
perms.setPermissionEmailBook(defaultPermissions.contains("permissionEmailBook"));
perms.setPermissionDeleteBook(defaultPermissions.contains("permissionDeleteBook"));
perms.setPermissionAccessOpds(defaultPermissions.contains("permissionAccessOpds"));
perms.setPermissionSyncKoreader(defaultPermissions.contains("permissionSyncKoreader"));
perms.setPermissionSyncKobo(defaultPermissions.contains("permissionSyncKobo"));
perms.setPermissionManageMetadataConfig(defaultPermissions.contains("permissionManageMetadataConfig"));
perms.setPermissionAccessBookdrop(defaultPermissions.contains("permissionAccessBookdrop"));
perms.setPermissionAccessLibraryStats(defaultPermissions.contains("permissionAccessLibraryStats"));
perms.setPermissionAccessUserStats(defaultPermissions.contains("permissionAccessUserStats"));
perms.setPermissionAccessTaskManager(defaultPermissions.contains("permissionAccessTaskManager"));
perms.setPermissionManageGlobalPreferences(defaultPermissions.contains("permissionManageGlobalPreferences"));
perms.setPermissionManageIcons(defaultPermissions.contains("permissionManageIcons"));
}
user.setPermissions(perms);
@@ -170,22 +191,36 @@ public class UserProvisioningService {
permissions.setPermissionUpload(defaultPermissions.contains("permissionUpload"));
permissions.setPermissionDownload(defaultPermissions.contains("permissionDownload"));
permissions.setPermissionEditMetadata(defaultPermissions.contains("permissionEditMetadata"));
permissions.setPermissionManipulateLibrary(defaultPermissions.contains("permissionManipulateLibrary"));
permissions.setPermissionManageLibrary(defaultPermissions.contains("permissionManageLibrary"));
permissions.setPermissionEmailBook(defaultPermissions.contains("permissionEmailBook"));
permissions.setPermissionDeleteBook(defaultPermissions.contains("permissionDeleteBook"));
permissions.setPermissionAccessOpds(defaultPermissions.contains("permissionAccessOpds"));
permissions.setPermissionSyncKoreader(defaultPermissions.contains("permissionSyncKoreader"));
permissions.setPermissionSyncKobo(defaultPermissions.contains("permissionSyncKobo"));
permissions.setPermissionManageMetadataConfig(defaultPermissions.contains("permissionManageMetadataConfig"));
permissions.setPermissionAccessBookdrop(defaultPermissions.contains("permissionAccessBookdrop"));
permissions.setPermissionAccessLibraryStats(defaultPermissions.contains("permissionAccessLibraryStats"));
permissions.setPermissionAccessUserStats(defaultPermissions.contains("permissionAccessUserStats"));
permissions.setPermissionAccessTaskManager(defaultPermissions.contains("permissionAccessTaskManager"));
permissions.setPermissionManageGlobalPreferences(defaultPermissions.contains("permissionManageGlobalPreferences"));
permissions.setPermissionManageIcons(defaultPermissions.contains("permissionManageIcons"));
} else {
permissions.setPermissionUpload(false);
permissions.setPermissionDownload(false);
permissions.setPermissionEditMetadata(false);
permissions.setPermissionManipulateLibrary(false);
permissions.setPermissionManageLibrary(false);
permissions.setPermissionEmailBook(false);
permissions.setPermissionAccessOpds(false);
permissions.setPermissionDeleteBook(false);
permissions.setPermissionSyncKoreader(false);
permissions.setPermissionSyncKobo(false);
permissions.setPermissionManageMetadataConfig(false);
permissions.setPermissionAccessBookdrop(false);
permissions.setPermissionAccessLibraryStats(false);
permissions.setPermissionAccessUserStats(false);
permissions.setPermissionAccessTaskManager(false);
permissions.setPermissionManageGlobalPreferences(false);
permissions.setPermissionManageIcons(false);
}
permissions.setPermissionAdmin(isAdmin);

View File

@@ -50,12 +50,19 @@ public class UserService {
user.getPermissions().setPermissionUpload(updateRequest.getPermissions().isCanUpload());
user.getPermissions().setPermissionDownload(updateRequest.getPermissions().isCanDownload());
user.getPermissions().setPermissionEditMetadata(updateRequest.getPermissions().isCanEditMetadata());
user.getPermissions().setPermissionManipulateLibrary(updateRequest.getPermissions().isCanManipulateLibrary());
user.getPermissions().setPermissionManageLibrary(updateRequest.getPermissions().isCanManageLibrary());
user.getPermissions().setPermissionEmailBook(updateRequest.getPermissions().isCanEmailBook());
user.getPermissions().setPermissionDeleteBook(updateRequest.getPermissions().isCanDeleteBook());
user.getPermissions().setPermissionAccessOpds(updateRequest.getPermissions().isCanAccessOpds());
user.getPermissions().setPermissionSyncKoreader(updateRequest.getPermissions().isCanSyncKoReader());
user.getPermissions().setPermissionSyncKobo(updateRequest.getPermissions().isCanSyncKobo());
user.getPermissions().setPermissionManageMetadataConfig(updateRequest.getPermissions().isCanManageMetadataConfig());
user.getPermissions().setPermissionAccessBookdrop(updateRequest.getPermissions().isCanAccessBookdrop());
user.getPermissions().setPermissionAccessLibraryStats(updateRequest.getPermissions().isCanAccessLibraryStats());
user.getPermissions().setPermissionAccessUserStats(updateRequest.getPermissions().isCanAccessUserStats());
user.getPermissions().setPermissionAccessTaskManager(updateRequest.getPermissions().isCanAccessTaskManager());
user.getPermissions().setPermissionManageGlobalPreferences(updateRequest.getPermissions().isCanManageGlobalPreferences());
user.getPermissions().setPermissionManageIcons(updateRequest.getPermissions().isCanManageIcons());
}
if (updateRequest.getAssignedLibraries() != null && getMyself().getPermissions().isAdmin()) {
@@ -92,9 +99,14 @@ public class UserService {
public void changePassword(ChangePasswordRequest changePasswordRequest) {
BookLoreUser bookLoreUser = authenticationService.getAuthenticatedUser();
BookLoreUserEntity bookLoreUserEntity = userRepository.findById(bookLoreUser.getId())
.orElseThrow(() -> ApiError.USER_NOT_FOUND.createException(bookLoreUser.getId()));
if(bookLoreUserEntity.getPermissions().isPermissionDemoUser()) {
throw ApiError.DEMO_USER_PASSWORD_CHANGE_NOT_ALLOWED.createException();
}
if (!passwordEncoder.matches(changePasswordRequest.getCurrentPassword(), bookLoreUserEntity.getPasswordHash())) {
throw ApiError.PASSWORD_INCORRECT.createException();
}

View File

@@ -22,7 +22,7 @@ import java.time.Instant;
import java.util.*;
import static com.adityachandel.booklore.model.enums.PermissionType.ADMIN;
import static com.adityachandel.booklore.model.enums.PermissionType.MANIPULATE_LIBRARY;
import static com.adityachandel.booklore.model.enums.PermissionType.MANAGE_LIBRARY;
@Slf4j
@Service
@@ -52,7 +52,7 @@ public class BookFilePersistenceService {
} else {
log.info("[FILE_CREATE] Book with hash '{}' already exists at same path. Skipping update.", currentHash);
}
notificationService.sendMessageToPermissions(Topic.BOOK_ADD, bookMapper.toBookWithDescription(book, false), Set.of(ADMIN, MANIPULATE_LIBRARY));
notificationService.sendMessageToPermissions(Topic.BOOK_ADD, bookMapper.toBookWithDescription(book, false), Set.of(ADMIN, MANAGE_LIBRARY));
}
String findMatchingLibraryPath(LibraryEntity libraryEntity, Path filePath) {

View File

@@ -23,7 +23,7 @@ import java.util.Optional;
import java.util.Set;
import static com.adityachandel.booklore.model.enums.PermissionType.ADMIN;
import static com.adityachandel.booklore.model.enums.PermissionType.MANIPULATE_LIBRARY;
import static com.adityachandel.booklore.model.enums.PermissionType.MANAGE_LIBRARY;
@Slf4j
@Service
@@ -43,7 +43,7 @@ public class BookFileTransactionalHandler {
String fileName = path.getFileName().toString();
String libraryPath = bookFilePersistenceService.findMatchingLibraryPath(libraryEntity, path);
notificationService.sendMessageToPermissions(Topic.LOG, LogNotification.info("Started processing file: " + filePath), Set.of(ADMIN, MANIPULATE_LIBRARY));
notificationService.sendMessageToPermissions(Topic.LOG, LogNotification.info("Started processing file: " + filePath), Set.of(ADMIN, MANAGE_LIBRARY));
LibraryPathEntity libraryPathEntity = bookFilePersistenceService.getLibraryPathEntityForFile(libraryEntity, libraryPath);
@@ -59,7 +59,7 @@ public class BookFileTransactionalHandler {
libraryProcessingService.processLibraryFiles(List.of(libraryFile), libraryEntity);
notificationService.sendMessageToPermissions(Topic.LOG, LogNotification.info("Finished processing file: " + filePath), Set.of(ADMIN, MANIPULATE_LIBRARY));
notificationService.sendMessageToPermissions(Topic.LOG, LogNotification.info("Finished processing file: " + filePath), Set.of(ADMIN, MANAGE_LIBRARY));
log.info("[CREATE] Completed processing for file '{}'", filePath);
}
}

View File

@@ -136,7 +136,7 @@ public class LibraryFileEventProcessor {
book.setDeleted(true);
bookFilePersistenceService.save(book);
notificationService.sendMessageToPermissions(Topic.BOOKS_REMOVE, Set.of(book.getId()),
Set.of(PermissionType.ADMIN, PermissionType.MANIPULATE_LIBRARY));
Set.of(PermissionType.ADMIN, PermissionType.MANAGE_LIBRARY));
log.info("[MARKED_DELETED] Book '{}' marked as deleted", fileName);
}, () -> log.warn("[NOT_FOUND] Book for deleted path '{}' not found", path));

View File

@@ -1,5 +1,6 @@
package com.adityachandel.booklore.util;
import com.adityachandel.booklore.model.dto.BookLoreUser;
import com.adityachandel.booklore.model.entity.UserPermissionsEntity;
import com.adityachandel.booklore.model.enums.PermissionType;
import lombok.experimental.UtilityClass;
@@ -9,16 +10,47 @@ public class UserPermissionUtils {
public static boolean hasPermission(UserPermissionsEntity perms, PermissionType type) {
return switch (type) {
case ADMIN -> perms.isPermissionAdmin();
case UPLOAD -> perms.isPermissionUpload();
case DOWNLOAD -> perms.isPermissionDownload();
case EDIT_METADATA -> perms.isPermissionEditMetadata();
case MANIPULATE_LIBRARY -> perms.isPermissionManipulateLibrary();
case MANAGE_LIBRARY -> perms.isPermissionManageLibrary();
case EMAIL_BOOK -> perms.isPermissionEmailBook();
case DELETE_BOOK -> perms.isPermissionDeleteBook();
case ACCESS_OPDS -> perms.isPermissionAccessOpds();
case SYNC_KOREADER -> perms.isPermissionSyncKoreader();
case SYNC_KOBO -> perms.isPermissionSyncKobo();
case ADMIN -> perms.isPermissionAdmin();
case MANAGE_METADATA_CONFIG -> perms.isPermissionManageMetadataConfig();
case ACCESS_BOOKDROP -> perms.isPermissionAccessBookdrop();
case ACCESS_LIBRARY_STATS -> perms.isPermissionAccessLibraryStats();
case ACCESS_USER_STATS -> perms.isPermissionAccessUserStats();
case ACCESS_TASK_MANAGER -> perms.isPermissionAccessTaskManager();
case MANAGE_ICONS -> perms.isPermissionManageIcons();
case MANAGE_GLOBAL_PREFERENCES -> perms.isPermissionManageGlobalPreferences();
case DEMO_USER -> perms.isPermissionDemoUser();
};
}
public static boolean hasPermission(BookLoreUser.UserPermissions perms, PermissionType type) {
return switch (type) {
case ADMIN -> perms.isAdmin();
case UPLOAD -> perms.isCanUpload();
case DOWNLOAD -> perms.isCanDownload();
case EDIT_METADATA -> perms.isCanEditMetadata();
case MANAGE_LIBRARY -> perms.isCanManageLibrary();
case EMAIL_BOOK -> perms.isCanEmailBook();
case DELETE_BOOK -> perms.isCanDeleteBook();
case ACCESS_OPDS -> perms.isCanAccessOpds();
case SYNC_KOREADER -> perms.isCanSyncKoReader();
case SYNC_KOBO -> perms.isCanSyncKobo();
case MANAGE_METADATA_CONFIG -> perms.isCanManageMetadataConfig();
case ACCESS_BOOKDROP -> perms.isCanAccessBookdrop();
case ACCESS_LIBRARY_STATS -> perms.isCanAccessLibraryStats();
case ACCESS_USER_STATS -> perms.isCanAccessUserStats();
case ACCESS_TASK_MANAGER -> perms.isCanAccessTaskManager();
case MANAGE_ICONS -> perms.isCanManageIcons();
case MANAGE_GLOBAL_PREFERENCES -> perms.isCanManageGlobalPreferences();
case DEMO_USER -> perms.isDemoUser();
};
}
}

View File

@@ -0,0 +1,38 @@
ALTER TABLE user_permissions
ADD COLUMN IF NOT EXISTS permission_manage_metadata_config BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS permission_access_bookdrop BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS permission_access_library_stats BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS permission_access_user_stats BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS permission_access_task_manager BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS permission_manage_global_preferences BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS permission_manage_icons BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS permission_demo_user BOOLEAN NOT NULL DEFAULT FALSE;
-- Set all new permissions to TRUE for admin users
UPDATE user_permissions up
SET up.permission_manage_metadata_config = TRUE
WHERE up.permission_admin = TRUE;
UPDATE user_permissions up
SET up.permission_access_bookdrop = TRUE
WHERE up.permission_admin = TRUE;
UPDATE user_permissions up
SET up.permission_access_library_stats = TRUE
WHERE up.permission_admin = TRUE;
UPDATE user_permissions up
SET up.permission_access_user_stats = TRUE
WHERE up.permission_admin = TRUE;
UPDATE user_permissions up
SET up.permission_access_task_manager = TRUE
WHERE up.permission_admin = TRUE;
UPDATE user_permissions up
SET up.permission_manage_global_preferences = TRUE
WHERE up.permission_admin = TRUE;
UPDATE user_permissions up
SET up.permission_manage_icons = TRUE
WHERE up.permission_admin = TRUE;

View File

@@ -86,10 +86,10 @@ class BookReviewServiceTest {
.build();
}
private BookLoreUser createUser(boolean isAdmin, boolean canManipulateLibrary) {
private BookLoreUser createUser(boolean isAdmin, boolean canManageLibrary) {
BookLoreUser.UserPermissions permissions = new BookLoreUser.UserPermissions();
permissions.setAdmin(isAdmin);
permissions.setCanManipulateLibrary(canManipulateLibrary);
permissions.setCanManageLibrary(canManageLibrary);
BookLoreUser user = new BookLoreUser();
user.setPermissions(permissions);

View File

@@ -29,12 +29,20 @@ class UserPermissionUtilsTest {
.permissionUpload(false)
.permissionDownload(false)
.permissionEditMetadata(false)
.permissionManipulateLibrary(false)
.permissionManageLibrary(false)
.permissionEmailBook(false)
.permissionDeleteBook(false)
.permissionAccessOpds(false)
.permissionSyncKoreader(false)
.permissionSyncKobo(false)
.permissionManageMetadataConfig(false)
.permissionAccessBookdrop(false)
.permissionAccessLibraryStats(false)
.permissionAccessUserStats(false)
.permissionAccessTaskManager(false)
.permissionManageGlobalPreferences(false)
.permissionManageIcons(false)
.permissionDemoUser(false)
.permissionAdmin(false)
.build();
@@ -49,12 +57,20 @@ class UserPermissionUtilsTest {
.permissionUpload(true)
.permissionDownload(true)
.permissionEditMetadata(true)
.permissionManipulateLibrary(true)
.permissionManageLibrary(true)
.permissionEmailBook(true)
.permissionDeleteBook(true)
.permissionAccessOpds(true)
.permissionSyncKoreader(true)
.permissionSyncKobo(true)
.permissionManageMetadataConfig(true)
.permissionAccessBookdrop(true)
.permissionAccessLibraryStats(true)
.permissionAccessUserStats(true)
.permissionAccessTaskManager(true)
.permissionManageGlobalPreferences(true)
.permissionManageIcons(true)
.permissionDemoUser(true)
.permissionAdmin(true)
.build();
@@ -68,24 +84,40 @@ class UserPermissionUtilsTest {
.permissionUpload(false)
.permissionDownload(false)
.permissionEditMetadata(false)
.permissionManipulateLibrary(false)
.permissionManageLibrary(false)
.permissionEmailBook(false)
.permissionDeleteBook(false)
.permissionAccessOpds(false)
.permissionSyncKoreader(false)
.permissionSyncKobo(false)
.permissionManageMetadataConfig(false)
.permissionAccessBookdrop(false)
.permissionAccessLibraryStats(false)
.permissionAccessUserStats(false)
.permissionAccessTaskManager(false)
.permissionManageGlobalPreferences(false)
.permissionManageIcons(false)
.permissionDemoUser(false)
.permissionAdmin(false);
switch (permissionType) {
case UPLOAD -> builder.permissionUpload(value);
case DOWNLOAD -> builder.permissionDownload(value);
case EDIT_METADATA -> builder.permissionEditMetadata(value);
case MANIPULATE_LIBRARY -> builder.permissionManipulateLibrary(value);
case MANAGE_LIBRARY -> builder.permissionManageLibrary(value);
case EMAIL_BOOK -> builder.permissionEmailBook(value);
case DELETE_BOOK -> builder.permissionDeleteBook(value);
case ACCESS_OPDS -> builder.permissionAccessOpds(value);
case SYNC_KOREADER -> builder.permissionSyncKoreader(value);
case SYNC_KOBO -> builder.permissionSyncKobo(value);
case MANAGE_METADATA_CONFIG -> builder.permissionManageMetadataConfig(value);
case ACCESS_BOOKDROP -> builder.permissionAccessBookdrop(value);
case ACCESS_LIBRARY_STATS -> builder.permissionAccessLibraryStats(value);
case ACCESS_USER_STATS -> builder.permissionAccessUserStats(value);
case ACCESS_TASK_MANAGER -> builder.permissionAccessTaskManager(value);
case MANAGE_GLOBAL_PREFERENCES -> builder.permissionManageGlobalPreferences(value);
case MANAGE_ICONS -> builder.permissionManageIcons(value);
case DEMO_USER -> builder.permissionDemoUser(value);
case ADMIN -> builder.permissionAdmin(value);
}

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',
});
}