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

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

View File

@@ -40,9 +40,14 @@ public class SecurityUtil {
return user != null && user.getPermissions().isCanDownload(); return user != null && user.getPermissions().isCanDownload();
} }
public boolean canManipulateLibrary() { public boolean canManageLibrary() {
var user = getCurrentUser(); 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() { public boolean canSyncKoReader() {
@@ -89,4 +94,19 @@ public class SecurityUtil {
} }
return false; return false;
} }
public boolean canAccessBookdrop() {
var user = getCurrentUser();
return user != null && user.getPermissions().isCanAccessBookdrop();
}
public boolean canAccessUserStats() {
var user = getCurrentUser();
return user != null && user.getPermissions().isCanAccessUserStats();
}
public boolean canAccessTaskManager() {
var user = getCurrentUser();
return user != null && user.getPermissions().isCanAccessTaskManager();
}
} }

View File

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

View File

@@ -89,7 +89,7 @@ public class KoboAuthFilter extends OncePerRequestFilter {
addAuthorityIfPermissionGranted(authorities, "ROLE_UPLOAD", permissions.isPermissionUpload()); addAuthorityIfPermissionGranted(authorities, "ROLE_UPLOAD", permissions.isPermissionUpload());
addAuthorityIfPermissionGranted(authorities, "ROLE_DOWNLOAD", permissions.isPermissionDownload()); addAuthorityIfPermissionGranted(authorities, "ROLE_DOWNLOAD", permissions.isPermissionDownload());
addAuthorityIfPermissionGranted(authorities, "ROLE_EDIT_METADATA", permissions.isPermissionEditMetadata()); 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_ADMIN", permissions.isPermissionAdmin());
addAuthorityIfPermissionGranted(authorities, "ROLE_SYNC_KOBO", permissions.isPermissionSyncKobo()); addAuthorityIfPermissionGranted(authorities, "ROLE_SYNC_KOBO", permissions.isPermissionSyncKobo());
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
package com.adityachandel.booklore.controller; 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.config.security.annotation.CheckBookAccess;
import com.adityachandel.booklore.exception.ApiError; import com.adityachandel.booklore.exception.ApiError;
import com.adityachandel.booklore.mapper.BookMetadataMapper; 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.model.enums.MetadataReplaceMode;
import com.adityachandel.booklore.repository.BookRepository; import com.adityachandel.booklore.repository.BookRepository;
import com.adityachandel.booklore.service.metadata.*; 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 lombok.AllArgsConstructor;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize; 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.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; 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.List;
import java.util.Map; import java.util.Map;
@@ -37,7 +34,6 @@ public class MetadataController {
private final BookMetadataService bookMetadataService; private final BookMetadataService bookMetadataService;
private final BookMetadataUpdater bookMetadataUpdater; private final BookMetadataUpdater bookMetadataUpdater;
private final AuthenticationService authenticationService;
private final BookMetadataMapper bookMetadataMapper; private final BookMetadataMapper bookMetadataMapper;
private final MetadataMatchService metadataMatchService; private final MetadataMatchService metadataMatchService;
private final DuckDuckGoCoverService duckDuckGoCoverService; private final DuckDuckGoCoverService duckDuckGoCoverService;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -55,7 +55,8 @@ public enum ApiError {
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_NOT_FOUND(HttpStatus.NOT_FOUND, "Scheduled task not found: %s"),
TASK_ALREADY_RUNNING(HttpStatus.CONFLICT, "Task is already running: %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 HttpStatus status;
private final String message; private final String message;

View File

@@ -31,10 +31,18 @@ public class BookLoreUserTransformer {
permissions.setCanEditMetadata(userEntity.getPermissions().isPermissionEditMetadata()); permissions.setCanEditMetadata(userEntity.getPermissions().isPermissionEditMetadata());
permissions.setCanEmailBook(userEntity.getPermissions().isPermissionEmailBook()); permissions.setCanEmailBook(userEntity.getPermissions().isPermissionEmailBook());
permissions.setCanDeleteBook(userEntity.getPermissions().isPermissionDeleteBook()); permissions.setCanDeleteBook(userEntity.getPermissions().isPermissionDeleteBook());
permissions.setCanManipulateLibrary(userEntity.getPermissions().isPermissionManipulateLibrary()); permissions.setCanManageLibrary(userEntity.getPermissions().isPermissionManageLibrary());
permissions.setCanAccessOpds(userEntity.getPermissions().isPermissionAccessOpds()); permissions.setCanAccessOpds(userEntity.getPermissions().isPermissionAccessOpds());
permissions.setCanSyncKoReader(userEntity.getPermissions().isPermissionSyncKoreader()); permissions.setCanSyncKoReader(userEntity.getPermissions().isPermissionSyncKoreader());
permissions.setCanSyncKobo(userEntity.getPermissions().isPermissionSyncKobo()); 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 bookLoreUser = new BookLoreUser();
bookLoreUser.setId(userEntity.getId()); bookLoreUser.setId(userEntity.getId());
@@ -63,7 +71,8 @@ public class BookLoreUserTransformer {
case SIDEBAR_SHELF_SORTING -> userSettings.setSidebarShelfSorting(objectMapper.readValue(value, SidebarSortOption.class)); case SIDEBAR_SHELF_SORTING -> userSettings.setSidebarShelfSorting(objectMapper.readValue(value, SidebarSortOption.class));
case SIDEBAR_MAGIC_SHELF_SORTING -> userSettings.setSidebarMagicShelfSorting(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 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)); case DASHBOARD_CONFIG -> userSettings.setDashboardConfig(objectMapper.readValue(value, BookLoreUser.UserSettings.DashboardConfig.class));
} }
} else { } else {

View File

@@ -30,12 +30,20 @@ public class BookLoreUser {
private boolean canUpload; private boolean canUpload;
private boolean canDownload; private boolean canDownload;
private boolean canEditMetadata; private boolean canEditMetadata;
private boolean canManipulateLibrary; private boolean canManageLibrary;
private boolean canSyncKoReader; private boolean canSyncKoReader;
private boolean canSyncKobo; private boolean canSyncKobo;
private boolean canEmailBook; private boolean canEmailBook;
private boolean canDeleteBook; private boolean canDeleteBook;
private boolean canAccessOpds; 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 @Data

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -51,12 +51,19 @@ public class UserProvisioningService {
perms.setPermissionUpload(true); perms.setPermissionUpload(true);
perms.setPermissionDownload(true); perms.setPermissionDownload(true);
perms.setPermissionEditMetadata(true); perms.setPermissionEditMetadata(true);
perms.setPermissionManipulateLibrary(true); perms.setPermissionManageLibrary(true);
perms.setPermissionEmailBook(true); perms.setPermissionEmailBook(true);
perms.setPermissionDeleteBook(true); perms.setPermissionDeleteBook(true);
perms.setPermissionAccessOpds(true); perms.setPermissionAccessOpds(true);
perms.setPermissionSyncKoreader(true); perms.setPermissionSyncKoreader(true);
perms.setPermissionSyncKobo(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); user.setPermissions(perms);
createUser(user); createUser(user);
@@ -82,13 +89,20 @@ public class UserProvisioningService {
permissions.setPermissionUpload(request.isPermissionUpload()); permissions.setPermissionUpload(request.isPermissionUpload());
permissions.setPermissionDownload(request.isPermissionDownload()); permissions.setPermissionDownload(request.isPermissionDownload());
permissions.setPermissionEditMetadata(request.isPermissionEditMetadata()); permissions.setPermissionEditMetadata(request.isPermissionEditMetadata());
permissions.setPermissionManipulateLibrary(request.isPermissionManipulateLibrary()); permissions.setPermissionManageLibrary(request.isPermissionManageLibrary());
permissions.setPermissionEmailBook(request.isPermissionEmailBook()); permissions.setPermissionEmailBook(request.isPermissionEmailBook());
permissions.setPermissionDeleteBook(request.isPermissionDeleteBook()); permissions.setPermissionDeleteBook(request.isPermissionDeleteBook());
permissions.setPermissionAccessOpds(request.isPermissionAccessOpds()); permissions.setPermissionAccessOpds(request.isPermissionAccessOpds());
permissions.setPermissionSyncKoreader(request.isPermissionSyncKoreader()); permissions.setPermissionSyncKoreader(request.isPermissionSyncKoreader());
permissions.setPermissionSyncKobo(request.isPermissionSyncKobo()); permissions.setPermissionSyncKobo(request.isPermissionSyncKobo());
permissions.setPermissionAdmin(request.isPermissionAdmin()); 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); user.setPermissions(permissions);
if (request.getSelectedLibraries() != null && !request.getSelectedLibraries().isEmpty()) { if (request.getSelectedLibraries() != null && !request.getSelectedLibraries().isEmpty()) {
@@ -115,12 +129,19 @@ public class UserProvisioningService {
perms.setPermissionUpload(defaultPermissions.contains("permissionUpload")); perms.setPermissionUpload(defaultPermissions.contains("permissionUpload"));
perms.setPermissionDownload(defaultPermissions.contains("permissionDownload")); perms.setPermissionDownload(defaultPermissions.contains("permissionDownload"));
perms.setPermissionEditMetadata(defaultPermissions.contains("permissionEditMetadata")); perms.setPermissionEditMetadata(defaultPermissions.contains("permissionEditMetadata"));
perms.setPermissionManipulateLibrary(defaultPermissions.contains("permissionManipulateLibrary")); perms.setPermissionManageLibrary(defaultPermissions.contains("permissionManageLibrary"));
perms.setPermissionEmailBook(defaultPermissions.contains("permissionEmailBook")); perms.setPermissionEmailBook(defaultPermissions.contains("permissionEmailBook"));
perms.setPermissionDeleteBook(defaultPermissions.contains("permissionDeleteBook")); perms.setPermissionDeleteBook(defaultPermissions.contains("permissionDeleteBook"));
perms.setPermissionAccessOpds(defaultPermissions.contains("permissionAccessOpds")); perms.setPermissionAccessOpds(defaultPermissions.contains("permissionAccessOpds"));
perms.setPermissionSyncKoreader(defaultPermissions.contains("permissionSyncKoreader")); perms.setPermissionSyncKoreader(defaultPermissions.contains("permissionSyncKoreader"));
perms.setPermissionSyncKobo(defaultPermissions.contains("permissionSyncKobo")); 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); user.setPermissions(perms);
@@ -170,22 +191,36 @@ public class UserProvisioningService {
permissions.setPermissionUpload(defaultPermissions.contains("permissionUpload")); permissions.setPermissionUpload(defaultPermissions.contains("permissionUpload"));
permissions.setPermissionDownload(defaultPermissions.contains("permissionDownload")); permissions.setPermissionDownload(defaultPermissions.contains("permissionDownload"));
permissions.setPermissionEditMetadata(defaultPermissions.contains("permissionEditMetadata")); permissions.setPermissionEditMetadata(defaultPermissions.contains("permissionEditMetadata"));
permissions.setPermissionManipulateLibrary(defaultPermissions.contains("permissionManipulateLibrary")); permissions.setPermissionManageLibrary(defaultPermissions.contains("permissionManageLibrary"));
permissions.setPermissionEmailBook(defaultPermissions.contains("permissionEmailBook")); permissions.setPermissionEmailBook(defaultPermissions.contains("permissionEmailBook"));
permissions.setPermissionDeleteBook(defaultPermissions.contains("permissionDeleteBook")); permissions.setPermissionDeleteBook(defaultPermissions.contains("permissionDeleteBook"));
permissions.setPermissionAccessOpds(defaultPermissions.contains("permissionAccessOpds")); permissions.setPermissionAccessOpds(defaultPermissions.contains("permissionAccessOpds"));
permissions.setPermissionSyncKoreader(defaultPermissions.contains("permissionSyncKoreader")); permissions.setPermissionSyncKoreader(defaultPermissions.contains("permissionSyncKoreader"));
permissions.setPermissionSyncKobo(defaultPermissions.contains("permissionSyncKobo")); 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 { } else {
permissions.setPermissionUpload(false); permissions.setPermissionUpload(false);
permissions.setPermissionDownload(false); permissions.setPermissionDownload(false);
permissions.setPermissionEditMetadata(false); permissions.setPermissionEditMetadata(false);
permissions.setPermissionManipulateLibrary(false); permissions.setPermissionManageLibrary(false);
permissions.setPermissionEmailBook(false); permissions.setPermissionEmailBook(false);
permissions.setPermissionAccessOpds(false); permissions.setPermissionAccessOpds(false);
permissions.setPermissionDeleteBook(false); permissions.setPermissionDeleteBook(false);
permissions.setPermissionSyncKoreader(false); permissions.setPermissionSyncKoreader(false);
permissions.setPermissionSyncKobo(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); permissions.setPermissionAdmin(isAdmin);

View File

@@ -50,12 +50,19 @@ public class UserService {
user.getPermissions().setPermissionUpload(updateRequest.getPermissions().isCanUpload()); user.getPermissions().setPermissionUpload(updateRequest.getPermissions().isCanUpload());
user.getPermissions().setPermissionDownload(updateRequest.getPermissions().isCanDownload()); user.getPermissions().setPermissionDownload(updateRequest.getPermissions().isCanDownload());
user.getPermissions().setPermissionEditMetadata(updateRequest.getPermissions().isCanEditMetadata()); 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().setPermissionEmailBook(updateRequest.getPermissions().isCanEmailBook());
user.getPermissions().setPermissionDeleteBook(updateRequest.getPermissions().isCanDeleteBook()); user.getPermissions().setPermissionDeleteBook(updateRequest.getPermissions().isCanDeleteBook());
user.getPermissions().setPermissionAccessOpds(updateRequest.getPermissions().isCanAccessOpds()); user.getPermissions().setPermissionAccessOpds(updateRequest.getPermissions().isCanAccessOpds());
user.getPermissions().setPermissionSyncKoreader(updateRequest.getPermissions().isCanSyncKoReader()); user.getPermissions().setPermissionSyncKoreader(updateRequest.getPermissions().isCanSyncKoReader());
user.getPermissions().setPermissionSyncKobo(updateRequest.getPermissions().isCanSyncKobo()); 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()) { if (updateRequest.getAssignedLibraries() != null && getMyself().getPermissions().isAdmin()) {
@@ -92,9 +99,14 @@ public class UserService {
public void changePassword(ChangePasswordRequest changePasswordRequest) { public void changePassword(ChangePasswordRequest changePasswordRequest) {
BookLoreUser bookLoreUser = authenticationService.getAuthenticatedUser(); BookLoreUser bookLoreUser = authenticationService.getAuthenticatedUser();
BookLoreUserEntity bookLoreUserEntity = userRepository.findById(bookLoreUser.getId()) BookLoreUserEntity bookLoreUserEntity = userRepository.findById(bookLoreUser.getId())
.orElseThrow(() -> ApiError.USER_NOT_FOUND.createException(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())) { if (!passwordEncoder.matches(changePasswordRequest.getCurrentPassword(), bookLoreUserEntity.getPasswordHash())) {
throw ApiError.PASSWORD_INCORRECT.createException(); throw ApiError.PASSWORD_INCORRECT.createException();
} }

View File

@@ -22,7 +22,7 @@ import java.time.Instant;
import java.util.*; import java.util.*;
import static com.adityachandel.booklore.model.enums.PermissionType.ADMIN; 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 @Slf4j
@Service @Service
@@ -52,7 +52,7 @@ public class BookFilePersistenceService {
} else { } else {
log.info("[FILE_CREATE] Book with hash '{}' already exists at same path. Skipping update.", currentHash); 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) { String findMatchingLibraryPath(LibraryEntity libraryEntity, Path filePath) {

View File

@@ -23,7 +23,7 @@ import java.util.Optional;
import java.util.Set; import java.util.Set;
import static com.adityachandel.booklore.model.enums.PermissionType.ADMIN; 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 @Slf4j
@Service @Service
@@ -43,7 +43,7 @@ public class BookFileTransactionalHandler {
String fileName = path.getFileName().toString(); String fileName = path.getFileName().toString();
String libraryPath = bookFilePersistenceService.findMatchingLibraryPath(libraryEntity, path); 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); LibraryPathEntity libraryPathEntity = bookFilePersistenceService.getLibraryPathEntityForFile(libraryEntity, libraryPath);
@@ -59,7 +59,7 @@ public class BookFileTransactionalHandler {
libraryProcessingService.processLibraryFiles(List.of(libraryFile), libraryEntity); 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); log.info("[CREATE] Completed processing for file '{}'", filePath);
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -29,12 +29,20 @@ class UserPermissionUtilsTest {
.permissionUpload(false) .permissionUpload(false)
.permissionDownload(false) .permissionDownload(false)
.permissionEditMetadata(false) .permissionEditMetadata(false)
.permissionManipulateLibrary(false) .permissionManageLibrary(false)
.permissionEmailBook(false) .permissionEmailBook(false)
.permissionDeleteBook(false) .permissionDeleteBook(false)
.permissionAccessOpds(false) .permissionAccessOpds(false)
.permissionSyncKoreader(false) .permissionSyncKoreader(false)
.permissionSyncKobo(false) .permissionSyncKobo(false)
.permissionManageMetadataConfig(false)
.permissionAccessBookdrop(false)
.permissionAccessLibraryStats(false)
.permissionAccessUserStats(false)
.permissionAccessTaskManager(false)
.permissionManageGlobalPreferences(false)
.permissionManageIcons(false)
.permissionDemoUser(false)
.permissionAdmin(false) .permissionAdmin(false)
.build(); .build();
@@ -49,12 +57,20 @@ class UserPermissionUtilsTest {
.permissionUpload(true) .permissionUpload(true)
.permissionDownload(true) .permissionDownload(true)
.permissionEditMetadata(true) .permissionEditMetadata(true)
.permissionManipulateLibrary(true) .permissionManageLibrary(true)
.permissionEmailBook(true) .permissionEmailBook(true)
.permissionDeleteBook(true) .permissionDeleteBook(true)
.permissionAccessOpds(true) .permissionAccessOpds(true)
.permissionSyncKoreader(true) .permissionSyncKoreader(true)
.permissionSyncKobo(true) .permissionSyncKobo(true)
.permissionManageMetadataConfig(true)
.permissionAccessBookdrop(true)
.permissionAccessLibraryStats(true)
.permissionAccessUserStats(true)
.permissionAccessTaskManager(true)
.permissionManageGlobalPreferences(true)
.permissionManageIcons(true)
.permissionDemoUser(true)
.permissionAdmin(true) .permissionAdmin(true)
.build(); .build();
@@ -68,24 +84,40 @@ class UserPermissionUtilsTest {
.permissionUpload(false) .permissionUpload(false)
.permissionDownload(false) .permissionDownload(false)
.permissionEditMetadata(false) .permissionEditMetadata(false)
.permissionManipulateLibrary(false) .permissionManageLibrary(false)
.permissionEmailBook(false) .permissionEmailBook(false)
.permissionDeleteBook(false) .permissionDeleteBook(false)
.permissionAccessOpds(false) .permissionAccessOpds(false)
.permissionSyncKoreader(false) .permissionSyncKoreader(false)
.permissionSyncKobo(false) .permissionSyncKobo(false)
.permissionManageMetadataConfig(false)
.permissionAccessBookdrop(false)
.permissionAccessLibraryStats(false)
.permissionAccessUserStats(false)
.permissionAccessTaskManager(false)
.permissionManageGlobalPreferences(false)
.permissionManageIcons(false)
.permissionDemoUser(false)
.permissionAdmin(false); .permissionAdmin(false);
switch (permissionType) { switch (permissionType) {
case UPLOAD -> builder.permissionUpload(value); case UPLOAD -> builder.permissionUpload(value);
case DOWNLOAD -> builder.permissionDownload(value); case DOWNLOAD -> builder.permissionDownload(value);
case EDIT_METADATA -> builder.permissionEditMetadata(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 EMAIL_BOOK -> builder.permissionEmailBook(value);
case DELETE_BOOK -> builder.permissionDeleteBook(value); case DELETE_BOOK -> builder.permissionDeleteBook(value);
case ACCESS_OPDS -> builder.permissionAccessOpds(value); case ACCESS_OPDS -> builder.permissionAccessOpds(value);
case SYNC_KOREADER -> builder.permissionSyncKoreader(value); case SYNC_KOREADER -> builder.permissionSyncKoreader(value);
case SYNC_KOBO -> builder.permissionSyncKobo(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); case ADMIN -> builder.permissionAdmin(value);
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -137,12 +137,12 @@
.user-info { .user-info {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.75rem;
} }
.user-avatar { .user-avatar {
width: 32px; width: 36px;
height: 32px; height: 36px;
border-radius: 50%; border-radius: 50%;
background: var(--p-primary-color); background: var(--p-primary-color);
color: white; color: white;
@@ -151,93 +151,292 @@
justify-content: center; justify-content: center;
font-weight: 600; font-weight: 600;
font-size: 0.875rem; font-size: 0.875rem;
flex-shrink: 0;
}
.user-details {
display: flex;
flex-direction: column;
gap: 0.25rem;
} }
.username { .username {
font-weight: 500; font-weight: 600;
color: var(--p-text-color);
} }
.user-type-badge { .user-type-badge {
display: inline-block; display: inline-block;
padding: 0.25rem 0.5rem; padding: 0.2rem 0.5rem;
border: 1px solid var(--p-primary-color); border: 1px solid;
border-radius: 4px; border-radius: 12px;
font-size: 0.75rem; font-size: 0.6875rem;
font-weight: 500; font-weight: 700;
color: var(--p-text-color); 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;
} }
.library-names { &[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;
}
}
.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-size: 0.875rem;
font-weight: 600;
color: var(--p-text-color); color: var(--p-text-color);
transition: color 0.2s;
} }
.actions-cell { .actions-group {
text-align: center; display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
} }
.user-dialog { .expanded-content {
.p-dialog-content {
padding: 1.5rem; padding: 1.5rem;
} background: var(--overlay-background);
border-radius: 8px;
.p-dialog-footer { margin: 0.5rem;
padding: 1rem 1.5rem;
}
}
.dialog-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1.5rem; 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; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
label { label {
font-size: 0.8125rem;
font-weight: 600; font-weight: 600;
color: var(--p-text-color); color: var(--text-color-secondary);
font-size: 0.875rem; text-transform: uppercase;
letter-spacing: 0.025em;
}
span {
font-size: 0.9375rem;
color: var(--text-color);
}
input {
width: 100%;
} }
} }
.error-message { .permissions-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
}
.permission-group {
h5 {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
padding: 0.75rem; font-size: 0.8125rem;
background: var(--p-red-50); font-weight: 600;
border: 1px solid var(--p-red-200); color: var(--text-color);
border-radius: 4px; margin: 0 0 0.75rem 0;
color: var(--p-red-700); text-transform: uppercase;
font-size: 0.875rem; letter-spacing: 0.025em;
.pi { .pi {
color: var(--p-red-500); color: var(--primary-color);
font-size: 0.875rem;
}
} }
} }
.dialog-actions { .permission-items {
display: flex; display: flex;
justify-content: flex-end; flex-direction: column;
gap: 0.75rem; gap: 0.75rem;
} }
.full-name-column { .permission-item {
min-width: 150px; display: flex;
width: 150px; align-items: center;
gap: 0.75rem;
label {
font-size: 0.875rem;
color: var(--text-color);
cursor: pointer;
user-select: none;
} }
.email-column { p-checkbox {
min-width: 150px; flex-shrink: 0;
width: 200px; }
} }
.libraries-column { .library-names {
min-width: 200px; font-size: 0.875rem;
width: 250px; color: var(--text-color);
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.text-center {
text-align: center;
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,9 @@
<p-tablist> <p-tablist>
<p-tab value="0">Prime Icons</p-tab> <p-tab value="0">Prime Icons</p-tab>
<p-tab value="1">SVG Icons</p-tab> <p-tab value="1">SVG Icons</p-tab>
@if (canManageIcons) {
<p-tab value="2">Add SVG Icon(s)</p-tab> <p-tab value="2">Add SVG Icon(s)</p-tab>
}
</p-tablist> </p-tablist>
<p-tabpanels> <p-tabpanels>
<p-tabpanel value="0"> <p-tabpanel value="0">
@@ -71,21 +73,23 @@
</div> </div>
} }
@if (canManageIcons) {
<div style="display: flex; align-items: center; position: fixed; right: 32px; bottom: 32px; z-index: 101;"> <div style="display: flex; align-items: center; position: fixed; right: 32px; bottom: 32px; z-index: 101;">
<div <div
class="svg-trash-area" class="svg-trash-area"
[class.trash-hover]="isTrashHover" [class.trash-hover]="isTrashHover"
(dragover)="onTrashDragOver($event)" (dragover)="onTrashDragOver($event)"
(dragleave)="onTrashDragLeave($event)" (dragleave)="onTrashDragLeave($event)"
(drop)="onTrashDrop($event)" (drop)="onTrashDrop($event)">
>
<i class="pi pi-trash"></i> <i class="pi pi-trash"></i>
<span>Drag here to delete icon</span> <span>Drag here to delete icon</span>
</div> </div>
</div> </div>
} }
}
</div> </div>
</p-tabpanel> </p-tabpanel>
@if (canManageIcons) {
<p-tabpanel value="2"> <p-tabpanel value="2">
<div class="svg-paste-container"> <div class="svg-paste-container">
<div class="svg-input-section"> <div class="svg-input-section">
@@ -179,5 +183,6 @@
} }
</div> </div>
</p-tabpanel> </p-tabpanel>
}
</p-tabpanels> </p-tabpanels>
</p-tabs> </p-tabs>

View File

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

View File

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

View File

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

View File

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

View File

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