From b8fb843b7a7645fe5fd93746374a30ae7ef929f2 Mon Sep 17 00:00:00 2001 From: ACX <8075870+acx10@users.noreply.github.com> Date: Mon, 22 Dec 2025 11:24:54 -0700 Subject: [PATCH] Introduce more granular permission controls and update the user management UI (#1965) Co-authored-by: acx10 --- .../config/security/SecurityUtil.java | 24 +- .../filter/JwtAuthenticationFilter.java | 4 +- .../security/filter/KoboAuthFilter.java | 2 +- .../service/AuthenticationService.java | 2 +- .../controller/AppSettingController.java | 3 +- .../BackgroundUploadController.java | 3 +- .../controller/BookdropFileController.java | 8 + .../controller/FileMoveController.java | 5 +- .../booklore/controller/IconController.java | 4 + .../controller/KoreaderController.java | 12 +- .../controller/LibraryController.java | 13 +- .../controller/MagicShelfController.java | 9 +- .../controller/MetadataController.java | 12 +- .../controller/MobileOidcController.java | 1 - .../booklore/controller/PathController.java | 5 +- .../controller/ReadingSessionController.java | 3 + .../booklore/controller/TaskController.java | 10 +- .../booklore/controller/UserController.java | 12 +- .../booklore/exception/ApiError.java | 5 +- .../custom/BookLoreUserTransformer.java | 13 +- .../booklore/model/dto/BookLoreUser.java | 10 +- .../booklore/model/dto/UserCreateRequest.java | 9 +- .../model/dto/request/UserUpdateRequest.java | 9 +- .../model/dto/settings/AppSettingKey.java | 60 +-- .../model/dto/settings/AppSettings.java | 1 - .../model/entity/UserPermissionsEntity.java | 38 +- .../booklore/model/enums/PermissionType.java | 12 +- .../appsettings/AppSettingService.java | 35 +- .../service/book/BookReviewService.java | 2 +- .../bookdrop/BookdropEventHandlerService.java | 6 +- .../bookdrop/BookdropNotificationService.java | 2 +- .../service/user/UserProvisioningService.java | 45 +- .../booklore/service/user/UserService.java | 14 +- .../watcher/BookFilePersistenceService.java | 4 +- .../watcher/BookFileTransactionalHandler.java | 6 +- .../watcher/LibraryFileEventProcessor.java | 2 +- .../booklore/util/UserPermissionUtils.java | 36 +- .../db/migration/V79__Add_New_Permissions.sql | 38 ++ .../service/BookReviewServiceTest.java | 4 +- .../util/UserPermissionUtilsTest.java | 40 +- booklore-ui/src/app/app.routes.ts | 12 +- .../core/security/guards/bookdrop.guard.ts | 20 + .../security/guards/edit-metdata.guard.ts | 20 + .../security/guards/library-stats.guard.ts | 20 + .../security/guards/manage-library.guard.ts | 2 +- .../core/security/guards/user-stats.guard.ts | 20 + .../book-browser/book-browser.component.html | 2 +- .../bookdrop/service/bookdrop-file.service.ts | 2 +- .../main-dashboard.component.html | 2 +- .../features/settings/settings.component.html | 25 +- .../user-management.component.html | 387 ++++++++++++------ .../user-management.component.scss | 301 +++++++++++--- .../user-management.component.ts | 84 +++- .../settings/user-management/user.service.ts | 11 +- .../user-stats/user-stats.component.html | 1 - .../user-stats/user-stats.component.scss | 20 - .../icon-picker/icon-picker-component.html | 203 ++++----- .../icon-picker/icon-picker-component.ts | 7 + .../layout-menu/app.menuitem.component.ts | 2 +- .../layout-topbar/app.topbar.component.html | 152 ++++--- .../layout-topbar/app.topbar.component.ts | 57 ++- .../services/dialog-launcher.service.ts | 2 +- 62 files changed, 1365 insertions(+), 510 deletions(-) create mode 100644 booklore-api/src/main/resources/db/migration/V79__Add_New_Permissions.sql create mode 100644 booklore-ui/src/app/core/security/guards/bookdrop.guard.ts create mode 100644 booklore-ui/src/app/core/security/guards/edit-metdata.guard.ts create mode 100644 booklore-ui/src/app/core/security/guards/library-stats.guard.ts create mode 100644 booklore-ui/src/app/core/security/guards/user-stats.guard.ts diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityUtil.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityUtil.java index 85d0a679..2aa5f6a0 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityUtil.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/SecurityUtil.java @@ -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(); + } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/filter/JwtAuthenticationFilter.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/filter/JwtAuthenticationFilter.java index e408748d..cfff739d 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/filter/JwtAuthenticationFilter.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/filter/JwtAuthenticationFilter.java @@ -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")); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/filter/KoboAuthFilter.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/filter/KoboAuthFilter.java index 0f6b98e2..50bc85ed 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/filter/KoboAuthFilter.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/filter/KoboAuthFilter.java @@ -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()); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/service/AuthenticationService.java b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/service/AuthenticationService.java index e1b6c84e..e4e1fae3 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/config/security/service/AuthenticationService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/config/security/service/AuthenticationService.java @@ -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); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/AppSettingController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/AppSettingController.java index 0925e7da..636251a1 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/AppSettingController.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/AppSettingController.java @@ -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 settingRequests) throws JsonProcessingException { + public void updateSettings(@Parameter(description = "List of settings to update") @RequestBody List settingRequests) throws JsonProcessingException { for (SettingRequest settingRequest : settingRequests) { AppSettingKey key = AppSettingKey.valueOf(settingRequest.getName()); appSettingService.updateSetting(key, settingRequest.getValue()); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/BackgroundUploadController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/BackgroundUploadController.java index 547bffee..572be54c 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/BackgroundUploadController.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/BackgroundUploadController.java @@ -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 uploadFile( - @Parameter(description = "Background image file") @RequestParam("file") MultipartFile file) { + public ResponseEntity uploadFile(@Parameter(description = "Background image file") @RequestParam("file") MultipartFile file) { try { BookLoreUser authenticatedUser = authenticationService.getAuthenticatedUser(); UploadResponse response = backgroundUploadService.uploadBackgroundFile(file, authenticatedUser.getId()); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookdropFileController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookdropFileController.java index c9395942..b2a49c8a 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookdropFileController.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/BookdropFileController.java @@ -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 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 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 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 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 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 bulkEditMetadata( @Parameter(description = "Bulk edit request") @Valid @RequestBody BookdropBulkEditRequest request) { BookdropBulkEditResult result = bookdropBulkEditService.bulkEdit(request); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/FileMoveController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/FileMoveController.java index a2823567..d40390d8 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/FileMoveController.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/FileMoveController.java @@ -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(); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/IconController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/IconController.java index fee758a9..bbf2d4a7 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/IconController.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/IconController.java @@ -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 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(); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/KoreaderController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/KoreaderController.java index e70758cc..e316e871 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/KoreaderController.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/KoreaderController.java @@ -29,15 +29,13 @@ public class KoreaderController { @ApiResponse(responseCode = "200", description = "User authorized successfully") @GetMapping("/users/auth") public ResponseEntity> 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 userData) { + public ResponseEntity createUser(@Parameter(description = "User data") @RequestBody Map 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 getProgress( - @Parameter(description = "Book hash") @PathVariable String bookHash) { + public ResponseEntity 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")); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/LibraryController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/LibraryController.java index 35b8eb5c..ab2d4426 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/LibraryController.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/LibraryController.java @@ -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 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 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 setFileNamingPattern( @Parameter(description = "ID of the library") @PathVariable long libraryId, @Parameter(description = "File naming pattern body") @RequestBody Map body) { diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/MagicShelfController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/MagicShelfController.java index 8a55e6f9..c2a097e5 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/MagicShelfController.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/MagicShelfController.java @@ -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 getShelf( - @Parameter(description = "ID of the magic shelf") @PathVariable Long id) { + public ResponseEntity 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 createUpdateShelf( - @Parameter(description = "Magic shelf object") @Valid @RequestBody MagicShelf shelf) { + public ResponseEntity 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 deleteShelf( - @Parameter(description = "ID of the magic shelf") @PathVariable Long id) { + public ResponseEntity deleteShelf(@Parameter(description = "ID of the magic shelf") @PathVariable Long id) { magicShelfService.deleteShelf(id); return ResponseEntity.noContent().build(); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/MetadataController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/MetadataController.java index 589d027f..333457e1 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/MetadataController.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/MetadataController.java @@ -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; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/MobileOidcController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/MobileOidcController.java index 7c6b7482..b32fde38 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/MobileOidcController.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/MobileOidcController.java @@ -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 userLocks = new ConcurrentHashMap<>(); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/PathController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/PathController.java index b22f2485..b927a6d7 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/PathController.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/PathController.java @@ -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 getFolders( - @Parameter(description = "Path to list folders at") @RequestParam String path) { + @PreAuthorize("@securityUtil.canManageLibrary() or @securityUtil.isAdmin()") + public List getFolders(@Parameter(description = "Path to list folders at") @RequestParam String path) { return pathService.getFoldersAtPath(path); } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/ReadingSessionController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/ReadingSessionController.java index 02e8311c..19c33b34 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/ReadingSessionController.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/ReadingSessionController.java @@ -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> getHeatmapForYear(@PathVariable int year) { List 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> getTimelineForWeek( @PathVariable int year, @PathVariable int week) { diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/TaskController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/TaskController.java index f89a9b37..3f97f6b6 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/TaskController.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/TaskController.java @@ -30,14 +30,14 @@ public class TaskController { private final TaskCronService taskCronService; @GetMapping - @PreAuthorize("@securityUtil.isAdmin()") + @PreAuthorize("@securityUtil.canAccessTaskManager() or @securityUtil.isAdmin()") public ResponseEntity> getAvailableTasks() { List taskInfos = service.getAvailableTasks(); return ResponseEntity.ok(taskInfos); } @PostMapping("/start") - @PreAuthorize("@securityUtil.isAdmin()") + @PreAuthorize("@securityUtil.canAccessTaskManager() or @securityUtil.isAdmin()") public ResponseEntity 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 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 getLatestTasksForEachType() { TasksHistoryResponse response = taskHistoryService.getLatestTasksForEachType(); return ResponseEntity.ok(response); } @PatchMapping("/{taskType}/cron") - @PreAuthorize("@securityUtil.isAdmin()") + @PreAuthorize("@securityUtil.canAccessTaskManager() or @securityUtil.isAdmin()") public ResponseEntity patchCronConfig(@PathVariable TaskType taskType, @RequestBody TaskCronConfigRequest request) { CronConfig response = taskCronService.patchCronConfig(taskType, request); service.rescheduleTask(taskType); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/controller/UserController.java b/booklore-api/src/main/java/com/adityachandel/booklore/controller/UserController.java index 528633dc..bacb98a5 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/controller/UserController.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/controller/UserController.java @@ -41,8 +41,7 @@ public class UserController { }) @GetMapping("/{id}") @PreAuthorize("@securityUtil.canViewUserProfile(#id)") - public ResponseEntity getUser( - @Parameter(description = "ID of the user") @PathVariable Long id) { + public ResponseEntity 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(); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/exception/ApiError.java b/booklore-api/src/main/java/com/adityachandel/booklore/exception/ApiError.java index f4ed7862..f1042ffe 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/exception/ApiError.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/exception/ApiError.java @@ -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; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/custom/BookLoreUserTransformer.java b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/custom/BookLoreUserTransformer.java index 275d4f01..9743c6b1 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/mapper/custom/BookLoreUserTransformer.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/mapper/custom/BookLoreUserTransformer.java @@ -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 { diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookLoreUser.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookLoreUser.java index e98c0df8..e04074e7 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookLoreUser.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/BookLoreUser.java @@ -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 diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/UserCreateRequest.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/UserCreateRequest.java index a5ce4311..95c09ddf 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/UserCreateRequest.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/UserCreateRequest.java @@ -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 selectedLibraries; } \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/UserUpdateRequest.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/UserUpdateRequest.java index a23726bc..a6442769 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/UserUpdateRequest.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/request/UserUpdateRequest.java @@ -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; } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettingKey.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettingKey.java index 7add6c81..bd998be9 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettingKey.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettingKey.java @@ -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 requiredPermissions; - AppSettingKey(String dbKey, boolean isJson, boolean isPublic) { + AppSettingKey(String dbKey, boolean isJson, boolean isPublic, List requiredPermissions) { this.dbKey = dbKey; this.isJson = isJson; this.isPublic = isPublic; + this.requiredPermissions = requiredPermissions; } @Override diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettings.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettings.java index 1e2c650a..12a6e4b0 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettings.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/dto/settings/AppSettings.java @@ -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; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/UserPermissionsEntity.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/UserPermissionsEntity.java index bd123218..f09ee951 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/UserPermissionsEntity.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/entity/UserPermissionsEntity.java @@ -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; } \ No newline at end of file diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/model/enums/PermissionType.java b/booklore-api/src/main/java/com/adityachandel/booklore/model/enums/PermissionType.java index 9c4e2874..fa6b171c 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/model/enums/PermissionType.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/model/enums/PermissionType.java @@ -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 } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/AppSettingService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/AppSettingService.java index 67415c22..97575392 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/AppSettingService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/appsettings/AppSettingService.java @@ -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 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")); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/book/BookReviewService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/book/BookReviewService.java index 150ebb06..d6076364 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/book/BookReviewService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/book/BookReviewService.java @@ -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; diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookdropEventHandlerService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookdropEventHandlerService.java index 95555f9b..74e6d623 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookdropEventHandlerService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookdropEventHandlerService.java @@ -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) ); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookdropNotificationService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookdropNotificationService.java index a86ea6a7..965e878a 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookdropNotificationService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/bookdrop/BookdropNotificationService.java @@ -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)); } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/user/UserProvisioningService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/user/UserProvisioningService.java index 3a4a9655..dbd6d2be 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/user/UserProvisioningService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/user/UserProvisioningService.java @@ -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); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/user/UserService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/user/UserService.java index 60430e58..a21a75f7 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/user/UserService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/user/UserService.java @@ -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(); } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/watcher/BookFilePersistenceService.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/watcher/BookFilePersistenceService.java index cd3f8181..96e77352 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/watcher/BookFilePersistenceService.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/watcher/BookFilePersistenceService.java @@ -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) { diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/watcher/BookFileTransactionalHandler.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/watcher/BookFileTransactionalHandler.java index d48b94e5..3ec5b864 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/watcher/BookFileTransactionalHandler.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/watcher/BookFileTransactionalHandler.java @@ -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); } } diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/service/watcher/LibraryFileEventProcessor.java b/booklore-api/src/main/java/com/adityachandel/booklore/service/watcher/LibraryFileEventProcessor.java index db0809fe..8b5cac56 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/service/watcher/LibraryFileEventProcessor.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/service/watcher/LibraryFileEventProcessor.java @@ -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)); diff --git a/booklore-api/src/main/java/com/adityachandel/booklore/util/UserPermissionUtils.java b/booklore-api/src/main/java/com/adityachandel/booklore/util/UserPermissionUtils.java index a51bd41f..bd3e69f8 100644 --- a/booklore-api/src/main/java/com/adityachandel/booklore/util/UserPermissionUtils.java +++ b/booklore-api/src/main/java/com/adityachandel/booklore/util/UserPermissionUtils.java @@ -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(); }; } } diff --git a/booklore-api/src/main/resources/db/migration/V79__Add_New_Permissions.sql b/booklore-api/src/main/resources/db/migration/V79__Add_New_Permissions.sql new file mode 100644 index 00000000..a5bfca81 --- /dev/null +++ b/booklore-api/src/main/resources/db/migration/V79__Add_New_Permissions.sql @@ -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; diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/service/BookReviewServiceTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/service/BookReviewServiceTest.java index b253a460..59e6adb2 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/service/BookReviewServiceTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/service/BookReviewServiceTest.java @@ -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); diff --git a/booklore-api/src/test/java/com/adityachandel/booklore/util/UserPermissionUtilsTest.java b/booklore-api/src/test/java/com/adityachandel/booklore/util/UserPermissionUtilsTest.java index cb47d47e..3ca591f8 100644 --- a/booklore-api/src/test/java/com/adityachandel/booklore/util/UserPermissionUtilsTest.java +++ b/booklore-api/src/test/java/com/adityachandel/booklore/util/UserPermissionUtilsTest.java @@ -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); } diff --git a/booklore-ui/src/app/app.routes.ts b/booklore-ui/src/app/app.routes.ts index 624e4035..cea4ffad 100644 --- a/booklore-ui/src/app/app.routes.ts +++ b/booklore-ui/src/app/app.routes.ts @@ -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]}, ] }, { diff --git a/booklore-ui/src/app/core/security/guards/bookdrop.guard.ts b/booklore-ui/src/app/core/security/guards/bookdrop.guard.ts new file mode 100644 index 00000000..fd6b00db --- /dev/null +++ b/booklore-ui/src/app/core/security/guards/bookdrop.guard.ts @@ -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; + }) + ); +}; diff --git a/booklore-ui/src/app/core/security/guards/edit-metdata.guard.ts b/booklore-ui/src/app/core/security/guards/edit-metdata.guard.ts new file mode 100644 index 00000000..6866e942 --- /dev/null +++ b/booklore-ui/src/app/core/security/guards/edit-metdata.guard.ts @@ -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; + }) + ); +}; diff --git a/booklore-ui/src/app/core/security/guards/library-stats.guard.ts b/booklore-ui/src/app/core/security/guards/library-stats.guard.ts new file mode 100644 index 00000000..6e8361b4 --- /dev/null +++ b/booklore-ui/src/app/core/security/guards/library-stats.guard.ts @@ -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; + }) + ); +}; diff --git a/booklore-ui/src/app/core/security/guards/manage-library.guard.ts b/booklore-ui/src/app/core/security/guards/manage-library.guard.ts index 2426eb44..ef72bd27 100644 --- a/booklore-ui/src/app/core/security/guards/manage-library.guard.ts +++ b/booklore-ui/src/app/core/security/guards/manage-library.guard.ts @@ -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']); diff --git a/booklore-ui/src/app/core/security/guards/user-stats.guard.ts b/booklore-ui/src/app/core/security/guards/user-stats.guard.ts new file mode 100644 index 00000000..7fc3d4dc --- /dev/null +++ b/booklore-ui/src/app/core/security/guards/user-stats.guard.ts @@ -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; + }) + ); +}; diff --git a/booklore-ui/src/app/features/book/components/book-browser/book-browser.component.html b/booklore-ui/src/app/features/book/components/book-browser/book-browser.component.html index b62c3eb7..178e551f 100644 --- a/booklore-ui/src/app/features/book/components/book-browser/book-browser.component.html +++ b/booklore-ui/src/app/features/book/components/book-browser/book-browser.component.html @@ -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)) {
{ 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()); diff --git a/booklore-ui/src/app/features/dashboard/components/main-dashboard/main-dashboard.component.html b/booklore-ui/src/app/features/dashboard/components/main-dashboard/main-dashboard.component.html index f65682cf..b5196443 100644 --- a/booklore-ui/src/app/features/dashboard/components/main-dashboard/main-dashboard.component.html +++ b/booklore-ui/src/app/features/dashboard/components/main-dashboard/main-dashboard.component.html @@ -17,7 +17,7 @@
@if ((userService.userState$ | async)?.user?.permissions; as permissions) {
- @if (permissions.admin || permissions.canManipulateLibrary) { + @if (permissions.admin || permissions.canManageLibrary) {

Welcome to BookLore!
diff --git a/booklore-ui/src/app/features/settings/settings.component.html b/booklore-ui/src/app/features/settings/settings.component.html index 7a5222cb..1e854b04 100644 --- a/booklore-ui/src/app/features/settings/settings.component.html +++ b/booklore-ui/src/app/features/settings/settings.component.html @@ -9,16 +9,20 @@ View - @if (userState.user.permissions.admin) { + @if (userState.user.permissions.admin || userState.user.permissions.canManageMetadataConfig) { Metadata 1 Metadata 2 + } + @if (userState.user.permissions.admin || userState.user.permissions.canManageGlobalPreferences) { Application + } + @if (userState.user.permissions.admin) { Users @@ -26,14 +30,17 @@ Email - - @if (userState.user.permissions.admin) { + @if (userState.user.permissions.admin || userState.user.permissions.canManageMetadataConfig) { Patterns + } + @if (userState.user.permissions.admin) { Authentication + } + @if (userState.user.permissions.admin || userState.user.permissions.canAccessTaskManager) { Tasks @@ -52,16 +59,20 @@ - @if (userState.user.permissions.admin) { + @if (userState.user.permissions.admin || userState.user.permissions.canManageMetadataConfig) { + } + @if (userState.user.permissions.admin || userState.user.permissions.canManageGlobalPreferences) { + } + @if (userState.user.permissions.admin) { @@ -69,13 +80,17 @@ - @if (userState.user.permissions.admin) { + @if (userState.user.permissions.admin || userState.user.permissions.canManageMetadataConfig) { + } + @if (userState.user.permissions.admin) { + } + @if (userState.user.permissions.admin || userState.user.permissions.canAccessTaskManager) { diff --git a/booklore-ui/src/app/features/settings/user-management/user-management.component.html b/booklore-ui/src/app/features/settings/user-management/user-management.component.html index 2eb487e5..bb1d35e2 100644 --- a/booklore-ui/src/app/features/settings/user-management/user-management.component.html +++ b/booklore-ui/src/app/features/settings/user-management/user-management.component.html @@ -29,65 +29,51 @@

- + - -
- - Username -
- - -
+ + +
Type
- +
- - Full Name + + User
- -
- - Email -
- - +
- Assigned Libraries + Libraries
- Admin - Upload - Download - Manage Metadata - Manage Library - Email Books - Delete Books - Access OPDS - KOReader Sync - Kobo Sync - -
- - Edit -
+ + - -
- - Password -
+ + - -
- - Delete + + + + + + + + + + +
+ + Actions
@@ -95,36 +81,31 @@ + + + + + + {{ (user.provisioningMethod || 'LOCAL') | lowercase | titlecase }} + + - - {{ (user.provisioningMethod || 'LOCAL') | lowercase | titlecase }} - - - - @if (user.isEditing) { - - } - @if (!user.isEditing) { - {{ user.name }} - } - - - @if (user.isEditing) { - - } - @if (!user.isEditing) { - {{ user.email }} - } - - @if (user.isEditing) { - } - @if (!user.isEditing) { - {{ user.libraryNames }} + } @else { + {{ user.libraryNames || 'None' }} } - +
+ @if (user.permissions.admin) { + + } @else { + + } +
- +
+ + {{ getBookManagementPermissionsCount(user) }}/6 + +
- +
+ + {{ getDeviceSyncPermissionsCount(user) }}/3 + +
- +
+ + {{ getSystemAccessPermissionsCount(user) }}/3 + +
- +
+ + {{ getSystemConfigPermissionsCount(user) }}/4 + +
- - - - - - - - - - - - - - - - - @if (!user.isEditing) { - - - } - @if (user.isEditing) { -
+ +
+ @if (!user.isEditing) { + + + } + @if (user.isEditing) { -
- } - - - - - - - - + } + @if (!user.isEditing) { + + + + + } +
+ @if (isRowExpanded(user)) { + + +
+
+

+ + User Information +

+
+
+ + @if (user.isEditing) { + + } @else { + {{ user.name || 'N/A' }} + } +
+
+ + @if (user.isEditing) { + + } @else { + {{ user.email || 'N/A' }} + } +
+
+
+ +
+

+ + Permissions +

+
+
+
Book Management
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
Device Sync
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
System Access
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
System Configuration
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
Administration
+
+
+ + +
+
+
+
+
+
+ + + }
diff --git a/booklore-ui/src/app/features/settings/user-management/user-management.component.scss b/booklore-ui/src/app/features/settings/user-management/user-management.component.scss index 5db62d45..13991e2c 100644 --- a/booklore-ui/src/app/features/settings/user-management/user-management.component.scss +++ b/booklore-ui/src/app/features/settings/user-management/user-management.component.scss @@ -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; } diff --git a/booklore-ui/src/app/features/settings/user-management/user-management.component.ts b/booklore-ui/src/app/features/settings/user-management/user-management.component.ts index 835e8ff9..69534bc7 100644 --- a/booklore-ui/src/app/features/settings/user-management/user-management.component.ts +++ b/booklore-ui/src/app/features/settings/user-management/user-management.component.ts @@ -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; + } } diff --git a/booklore-ui/src/app/features/settings/user-management/user.service.ts b/booklore-ui/src/app/features/settings/user-management/user.service.ts index 97fd73b5..2be4b81e 100644 --- a/booklore-ui/src/app/features/settings/user-management/user.service.ts +++ b/booklore-ui/src/app/features/settings/user-management/user.service.ts @@ -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'; diff --git a/booklore-ui/src/app/features/stats/component/user-stats/user-stats.component.html b/booklore-ui/src/app/features/stats/component/user-stats/user-stats.component.html index 8f1cc8de..40e8a438 100644 --- a/booklore-ui/src/app/features/stats/component/user-stats/user-stats.component.html +++ b/booklore-ui/src/app/features/stats/component/user-stats/user-stats.component.html @@ -5,7 +5,6 @@

{{ userName ? userName + "'s Reading Statistics" : "Your Reading Statistics" }}

-

Track your reading habits and progress

diff --git a/booklore-ui/src/app/features/stats/component/user-stats/user-stats.component.scss b/booklore-ui/src/app/features/stats/component/user-stats/user-stats.component.scss index ed40df58..432d5ae9 100644 --- a/booklore-ui/src/app/features/stats/component/user-stats/user-stats.component.scss +++ b/booklore-ui/src/app/features/stats/component/user-stats/user-stats.component.scss @@ -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 { diff --git a/booklore-ui/src/app/shared/components/icon-picker/icon-picker-component.html b/booklore-ui/src/app/shared/components/icon-picker/icon-picker-component.html index e4a3e09d..a14fe63e 100644 --- a/booklore-ui/src/app/shared/components/icon-picker/icon-picker-component.html +++ b/booklore-ui/src/app/shared/components/icon-picker/icon-picker-component.html @@ -2,7 +2,9 @@ Prime Icons SVG Icons - Add SVG Icon(s) + @if (canManageIcons) { + Add SVG Icon(s) + } @@ -58,8 +60,8 @@ outlined> - Page {{ currentSvgPage + 1 }} of {{ totalSvgPages }} - + Page {{ currentSvgPage + 1 }} of {{ totalSvgPages }} + } -
-
- - Drag here to delete icon + @if (canManageIcons) { +
+
+ + Drag here to delete icon +
-
+ } }
- -
-
- + @if (canManageIcons) { + +
+
+ - + - @if (svgContent && svgPreview) { -
-

Preview

-
-
- } - - @if (errorMessage) { -
{{ errorMessage }}
- } - -
- - -
-
- - @if (svgEntries.length > 0) { -
-
-

- Queued Icons ({{ svgEntries.length }}) -

- - -
- -
- @for (entry of svgEntries; track entry.name; let i = $index) { -
-
- {{ entry.name }} - -
-
- @if (entry.error) { -
{{ entry.error }}
- } -
- } -
- - @if (batchErrorMessage) { -
{{ batchErrorMessage }}
+ @if (svgContent && svgPreview) { +
+

Preview

+
+
} -
+ @if (errorMessage) { +
{{ errorMessage }}
+ } + +
+ (onClick)="addSvgEntry()" + [disabled]="!svgContent || !svgName" + label="Add to Queue" + severity="primary" + outlined + icon="pi pi-plus">
- } -
- + + @if (svgEntries.length > 0) { +
+
+

+ Queued Icons ({{ svgEntries.length }}) +

+ + +
+ +
+ @for (entry of svgEntries; track entry.name; let i = $index) { +
+
+ {{ entry.name }} + +
+
+ @if (entry.error) { +
{{ entry.error }}
+ } +
+ } +
+ + @if (batchErrorMessage) { +
{{ batchErrorMessage }}
+ } + +
+ + +
+
+ } +
+
+ } diff --git a/booklore-ui/src/app/shared/components/icon-picker/icon-picker-component.ts b/booklore-ui/src/app/shared/components/icon-picker/icon-picker-component.ts index 4b378896..56b5e3c2 100644 --- a/booklore-ui/src/app/shared/components/icon-picker/icon-picker-component.ts +++ b/booklore-ui/src/app/shared/components/icon-picker/icon-picker-component.ts @@ -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; + } } diff --git a/booklore-ui/src/app/shared/layout/component/layout-menu/app.menuitem.component.ts b/booklore-ui/src/app/shared/layout/component/layout-menu/app.menuitem.component.ts index 235a0a94..25b7c139 100644 --- a/booklore-ui/src/app/shared/layout/component/layout-menu/app.menuitem.component.ts +++ b/booklore-ui/src/app/shared/layout/component/layout-menu/app.menuitem.component.ts @@ -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; } }); diff --git a/booklore-ui/src/app/shared/layout/component/layout-topbar/app.topbar.component.html b/booklore-ui/src/app/shared/layout/component/layout-topbar/app.topbar.component.html index fa12038f..4bfc5644 100644 --- a/booklore-ui/src/app/shared/layout/component/layout-topbar/app.topbar.component.html +++ b/booklore-ui/src/app/shared/layout/component/layout-topbar/app.topbar.component.html @@ -30,46 +30,50 @@