mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
Implement OPDS v2 to return user-specific book feeds
This commit is contained in:
committed by
Aditya Chandel
parent
4cff107a67
commit
443dcad59a
@@ -5,7 +5,7 @@ import com.adityachandel.booklore.config.security.filter.CoverJwtFilter;
|
||||
import com.adityachandel.booklore.config.security.filter.DualJwtAuthenticationFilter;
|
||||
import com.adityachandel.booklore.config.security.filter.KoboAuthFilter;
|
||||
import com.adityachandel.booklore.config.security.filter.KoreaderAuthFilter;
|
||||
import com.adityachandel.booklore.config.security.service.CustomOpdsUserDetailsService;
|
||||
import com.adityachandel.booklore.config.security.service.OpdsUserDetailsService;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@@ -37,7 +37,7 @@ import jakarta.servlet.http.HttpServletResponse;
|
||||
@Configuration
|
||||
public class SecurityConfig {
|
||||
|
||||
private final CustomOpdsUserDetailsService customOpdsUserDetailsService;
|
||||
private final OpdsUserDetailsService opdsUserDetailsService;
|
||||
private final DualJwtAuthenticationFilter dualJwtAuthenticationFilter;
|
||||
private final AppProperties appProperties;
|
||||
|
||||
@@ -157,7 +157,7 @@ public class SecurityConfig {
|
||||
@Bean
|
||||
public AuthenticationProvider authenticationProvider() {
|
||||
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
|
||||
provider.setUserDetailsService(customOpdsUserDetailsService);
|
||||
provider.setUserDetailsService(opdsUserDetailsService);
|
||||
provider.setPasswordEncoder(passwordEncoder());
|
||||
return provider;
|
||||
}
|
||||
|
||||
@@ -69,6 +69,11 @@ public class SecurityUtil {
|
||||
var user = getCurrentUser();
|
||||
return user != null && user.getPermissions().isCanDeleteBook();
|
||||
}
|
||||
public boolean canAccessOpds() {
|
||||
var user = getCurrentUser();
|
||||
return user != null && user.getPermissions().isCanAccessOpds();
|
||||
}
|
||||
|
||||
|
||||
public boolean canViewUserProfile(Long userId) {
|
||||
var user = getCurrentUser();
|
||||
|
||||
@@ -2,11 +2,14 @@ package com.adityachandel.booklore.config.security.service;
|
||||
|
||||
import com.adityachandel.booklore.config.AppProperties;
|
||||
import com.adityachandel.booklore.config.security.JwtUtils;
|
||||
import com.adityachandel.booklore.config.security.userdetails.OpdsUserDetails;
|
||||
import com.adityachandel.booklore.exception.ApiError;
|
||||
import com.adityachandel.booklore.model.dto.BookLoreUser;
|
||||
import com.adityachandel.booklore.model.dto.request.UserLoginRequest;
|
||||
import com.adityachandel.booklore.model.entity.BookLoreUserEntity;
|
||||
import com.adityachandel.booklore.model.entity.OpdsUserEntity;
|
||||
import com.adityachandel.booklore.model.entity.RefreshTokenEntity;
|
||||
import com.adityachandel.booklore.repository.OpdsUserRepository;
|
||||
import com.adityachandel.booklore.repository.RefreshTokenRepository;
|
||||
import com.adityachandel.booklore.repository.UserRepository;
|
||||
import com.adityachandel.booklore.service.user.DefaultSettingInitializer;
|
||||
@@ -16,6 +19,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@@ -35,6 +39,7 @@ public class AuthenticationService {
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final JwtUtils jwtUtils;
|
||||
private final DefaultSettingInitializer defaultSettingInitializer;
|
||||
private final OpdsUserRepository opdsUserRepository;
|
||||
|
||||
public BookLoreUser getAuthenticatedUser() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
@@ -46,6 +51,15 @@ public class AuthenticationService {
|
||||
throw new IllegalStateException("Authenticated principal is not of type BookLoreUser");
|
||||
}
|
||||
|
||||
|
||||
public OpdsUserDetails getOpdsUser() {
|
||||
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (authentication != null && authentication.getPrincipal() instanceof OpdsUserDetails opdsUser) {
|
||||
return opdsUser;
|
||||
}
|
||||
throw new IllegalStateException("No OPDS user authenticated");
|
||||
}
|
||||
|
||||
public ResponseEntity<Map<String, String>> loginUser(UserLoginRequest loginRequest) {
|
||||
BookLoreUserEntity user = userRepository.findByUsername(loginRequest.getUsername()).orElseThrow(() -> ApiError.USER_NOT_FOUND.createException(loginRequest.getUsername()));
|
||||
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
package com.adityachandel.booklore.config.security.service;
|
||||
|
||||
import com.adityachandel.booklore.exception.ApiError;
|
||||
import com.adityachandel.booklore.model.entity.OpdsUserEntity;
|
||||
import com.adityachandel.booklore.repository.OpdsUserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.userdetails.User;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CustomOpdsUserDetailsService implements UserDetailsService {
|
||||
|
||||
private final OpdsUserRepository opdsUserRepository;
|
||||
|
||||
@Override
|
||||
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
||||
OpdsUserEntity user = opdsUserRepository.findByUsername(username).orElseThrow(() -> ApiError.USER_NOT_FOUND.createException(username));
|
||||
return User.builder()
|
||||
.username(user.getUsername())
|
||||
.password(user.getPassword())
|
||||
.roles("USER")
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.adityachandel.booklore.config.security.service;
|
||||
|
||||
import com.adityachandel.booklore.config.security.userdetails.OpdsUserDetails;
|
||||
import com.adityachandel.booklore.exception.ApiError;
|
||||
import com.adityachandel.booklore.mapper.OpdsUserMapper;
|
||||
import com.adityachandel.booklore.mapper.OpdsUserV2Mapper;
|
||||
import com.adityachandel.booklore.repository.OpdsUserV2Repository;
|
||||
import com.adityachandel.booklore.repository.OpdsUserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class OpdsUserDetailsService implements UserDetailsService {
|
||||
|
||||
private final OpdsUserRepository opdsUserRepository;
|
||||
private final OpdsUserV2Repository opdsUserV2Repository;
|
||||
private final OpdsUserMapper opdsUserMapper;
|
||||
private final OpdsUserV2Mapper opdsUserV2Mapper;
|
||||
|
||||
@Override
|
||||
public OpdsUserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
||||
return opdsUserRepository.findByUsername(username)
|
||||
.map(user -> {
|
||||
var mappedUser = opdsUserMapper.toOpdsUser(user);
|
||||
return new OpdsUserDetails(null, mappedUser);
|
||||
})
|
||||
.orElseGet(() -> {
|
||||
var userV2 = opdsUserV2Repository.findByUsername(username)
|
||||
.orElseThrow(() -> ApiError.USER_NOT_FOUND.createException(username));
|
||||
var mappedCredential = opdsUserV2Mapper.toDto(userV2);
|
||||
return new OpdsUserDetails(mappedCredential, null);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.adityachandel.booklore.config.security.userdetails;
|
||||
|
||||
import com.adityachandel.booklore.model.dto.OpdsUser;
|
||||
import com.adityachandel.booklore.model.dto.OpdsUserV2;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
@Getter
|
||||
@RequiredArgsConstructor
|
||||
public class OpdsUserDetails implements UserDetails {
|
||||
|
||||
private final OpdsUserV2 opdsUserV2;
|
||||
private final OpdsUser opdsUser;
|
||||
|
||||
@Override
|
||||
public String getUsername() {
|
||||
if (opdsUserV2 != null) return opdsUserV2.getUsername();
|
||||
if (opdsUser != null) return opdsUser.getUsername();
|
||||
throw new IllegalStateException("No username available");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPassword() {
|
||||
if (opdsUserV2 != null) return opdsUserV2.getPasswordHash();
|
||||
if (opdsUser != null) return opdsUser.getPassword();
|
||||
throw new IllegalStateException("No password available");
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<? extends GrantedAuthority> getAuthorities() {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.adityachandel.booklore.controller;
|
||||
|
||||
|
||||
import com.adityachandel.booklore.model.dto.OpdsUserV2;
|
||||
import com.adityachandel.booklore.model.dto.request.OpdsUserV2CreateRequest;
|
||||
import com.adityachandel.booklore.service.OpdsUserV2Service;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v2/opds-users")
|
||||
@RequiredArgsConstructor
|
||||
public class OpdsUserV2Controller {
|
||||
|
||||
private final OpdsUserV2Service service;
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("@securityUtil.isAdmin() or @securityUtil.canAccessOpds()")
|
||||
public List<OpdsUserV2> getUsers() {
|
||||
return service.getOpdsUsers();
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("@securityUtil.isAdmin() or @securityUtil.canAccessOpds()")
|
||||
public OpdsUserV2 createUser(@RequestBody OpdsUserV2CreateRequest createRequest) {
|
||||
return service.createOpdsUser(createRequest);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@PreAuthorize("@securityUtil.isAdmin() or @securityUtil.canAccessOpds()")
|
||||
public void deleteUser(@PathVariable Long id) {
|
||||
service.deleteOpdsUser(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.adityachandel.booklore.mapper;
|
||||
|
||||
import com.adityachandel.booklore.model.entity.OpdsUserV2Entity;
|
||||
import com.adityachandel.booklore.model.dto.OpdsUserV2;
|
||||
import org.mapstruct.Mapper;
|
||||
import org.mapstruct.Mapping;
|
||||
import org.mapstruct.factory.Mappers;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper(componentModel = "spring")
|
||||
public interface OpdsUserV2Mapper {
|
||||
|
||||
OpdsUserV2Mapper INSTANCE = Mappers.getMapper(OpdsUserV2Mapper.class);
|
||||
|
||||
@Mapping(source = "user.id", target = "userId")
|
||||
OpdsUserV2 toDto(OpdsUserV2Entity entity);
|
||||
|
||||
List<OpdsUserV2> toDto(List<OpdsUserV2Entity> entities);
|
||||
|
||||
@Mapping(target = "user.id", source = "userId")
|
||||
OpdsUserV2Entity toEntity(OpdsUserV2 dto);
|
||||
}
|
||||
@@ -32,6 +32,7 @@ public class BookLoreUserTransformer {
|
||||
permissions.setCanEmailBook(userEntity.getPermissions().isPermissionEmailBook());
|
||||
permissions.setCanDeleteBook(userEntity.getPermissions().isPermissionDeleteBook());
|
||||
permissions.setCanManipulateLibrary(userEntity.getPermissions().isPermissionManipulateLibrary());
|
||||
permissions.setCanAccessOpds(userEntity.getPermissions().isPermissionAccessOpds());
|
||||
permissions.setCanSyncKoReader(userEntity.getPermissions().isPermissionSyncKoreader());
|
||||
permissions.setCanSyncKobo(userEntity.getPermissions().isPermissionSyncKobo());
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ public class BookLoreUser {
|
||||
private boolean canSyncKobo;
|
||||
private boolean canEmailBook;
|
||||
private boolean canDeleteBook;
|
||||
private boolean canAccessOpds;
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package com.adityachandel.booklore.model.dto;
|
||||
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class OpdsUser {
|
||||
private Long id;
|
||||
private String username;
|
||||
@JsonIgnore
|
||||
private String password;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.adityachandel.booklore.model.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import lombok.*;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class OpdsUserV2 {
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private String username;
|
||||
@JsonIgnore
|
||||
private String passwordHash;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ public class UserCreateRequest {
|
||||
private boolean permissionManipulateLibrary;
|
||||
private boolean permissionEmailBook;
|
||||
private boolean permissionDeleteBook;
|
||||
private boolean permissionAccessOpds;
|
||||
private boolean permissionSyncKoreader;
|
||||
private boolean permissionSyncKobo;
|
||||
private boolean permissionAdmin;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.adityachandel.booklore.model.dto.request;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class OpdsUserV2CreateRequest {
|
||||
private String username;
|
||||
private String password;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ public class UserUpdateRequest {
|
||||
private boolean canManipulateLibrary;
|
||||
private boolean canEmailBook;
|
||||
private boolean canDeleteBook;
|
||||
private boolean canAccessOpds;
|
||||
private boolean canSyncKoReader;
|
||||
private boolean canSyncKobo;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.adityachandel.booklore.model.entity;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "opds_user_v2")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class OpdsUserV2Entity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "user_id", nullable = false)
|
||||
private BookLoreUserEntity user;
|
||||
|
||||
@Column(nullable = false, length = 100)
|
||||
private String username;
|
||||
|
||||
@Column(name = "password_hash", nullable = false)
|
||||
private String passwordHash;
|
||||
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private Instant updatedAt;
|
||||
|
||||
@PrePersist
|
||||
public void prePersist() {
|
||||
Instant now = Instant.now();
|
||||
this.createdAt = now;
|
||||
this.updatedAt = now;
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
public void preUpdate() {
|
||||
this.updatedAt = Instant.now();
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,9 @@ public class UserPermissionsEntity {
|
||||
@Column(name = "permission_sync_koreader", nullable = false)
|
||||
private boolean permissionSyncKoreader = false;
|
||||
|
||||
@Column(name = "permission_access_opds", nullable = false)
|
||||
private boolean permissionAccessOpds = false;
|
||||
|
||||
@Column(name = "permission_sync_kobo", nullable = false)
|
||||
private boolean permissionSyncKobo = false;
|
||||
|
||||
|
||||
@@ -9,5 +9,6 @@ public enum PermissionType {
|
||||
DELETE_BOOK,
|
||||
SYNC_KOREADER,
|
||||
SYNC_KOBO,
|
||||
ACCESS_OPDS,
|
||||
ADMIN
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.adityachandel.booklore.repository;
|
||||
|
||||
import com.adityachandel.booklore.model.entity.BookEntity;
|
||||
import jakarta.transaction.Transactional;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.*;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
@@ -76,13 +75,28 @@ public interface BookRepository extends JpaRepository<BookEntity, Long>, JpaSpec
|
||||
WHERE (b.deleted IS NULL OR b.deleted = false) AND (
|
||||
LOWER(m.title) LIKE LOWER(CONCAT('%', :text, '%'))
|
||||
OR LOWER(m.subtitle) LIKE LOWER(CONCAT('%', :text, '%'))
|
||||
OR LOWER(m.description) LIKE LOWER(CONCAT('%', :text, '%'))
|
||||
OR LOWER(m.seriesName) LIKE LOWER(CONCAT('%', :text, '%'))
|
||||
OR LOWER(a.name) LIKE LOWER(CONCAT('%', :text, '%'))
|
||||
)
|
||||
ORDER BY m.title ASC
|
||||
""")
|
||||
List<BookEntity> findBooksContainingMetadata(@Param("text") String text);
|
||||
List<BookEntity> searchByMetadata(@Param("text") String text);
|
||||
|
||||
@Query("""
|
||||
SELECT DISTINCT b FROM BookEntity b
|
||||
LEFT JOIN FETCH b.metadata m
|
||||
LEFT JOIN FETCH m.authors a
|
||||
LEFT JOIN FETCH m.categories
|
||||
WHERE (b.deleted IS NULL OR b.deleted = false)
|
||||
AND b.library.id IN :libraryIds
|
||||
AND (
|
||||
LOWER(m.title) LIKE LOWER(CONCAT('%', :text, '%'))
|
||||
OR LOWER(m.seriesName) LIKE LOWER(CONCAT('%', :text, '%'))
|
||||
OR LOWER(a.name) LIKE LOWER(CONCAT('%', :text, '%'))
|
||||
)
|
||||
ORDER BY m.title ASC
|
||||
""")
|
||||
List<BookEntity> searchByMetadataAndLibraryIds(@Param("text") String text, @Param("libraryIds") Collection<Long> libraryIds);
|
||||
|
||||
@Modifying
|
||||
@Transactional
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.adityachandel.booklore.repository;
|
||||
|
||||
import com.adityachandel.booklore.model.entity.OpdsUserV2Entity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface OpdsUserV2Repository extends JpaRepository<OpdsUserV2Entity, Long> {
|
||||
|
||||
Optional<OpdsUserV2Entity> findByUsername(String username);
|
||||
|
||||
List<OpdsUserV2Entity> findByUserId(Long userId);
|
||||
}
|
||||
@@ -52,8 +52,18 @@ public class BookQueryService {
|
||||
return bookRepository.findAllFullBooks();
|
||||
}
|
||||
|
||||
public List<BookEntity> getBooksContainingMetadata(String text) {
|
||||
return bookRepository.findBooksContainingMetadata(text);
|
||||
public List<Book> searchBooksByMetadata(String text) {
|
||||
List<BookEntity> bookEntities = bookRepository.searchByMetadata(text);
|
||||
return bookEntities.stream()
|
||||
.map(bookMapperV2::toDTO)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public List<Book> searchBooksByMetadataInLibraries(String text, Set<Long> libraryIds) {
|
||||
List<BookEntity> bookEntities = bookRepository.searchByMetadataAndLibraryIds(text, libraryIds);
|
||||
return bookEntities.stream()
|
||||
.map(bookMapperV2::toDTO)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public void saveAll(List<BookEntity> books) {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.adityachandel.booklore.service;
|
||||
|
||||
import com.adityachandel.booklore.config.security.service.AuthenticationService;
|
||||
import com.adityachandel.booklore.mapper.OpdsUserV2Mapper;
|
||||
import com.adityachandel.booklore.model.dto.BookLoreUser;
|
||||
import com.adityachandel.booklore.model.dto.OpdsUserV2;
|
||||
import com.adityachandel.booklore.model.dto.request.OpdsUserV2CreateRequest;
|
||||
import com.adityachandel.booklore.model.entity.BookLoreUserEntity;
|
||||
import com.adityachandel.booklore.model.entity.OpdsUserV2Entity;
|
||||
import com.adityachandel.booklore.repository.OpdsUserV2Repository;
|
||||
import com.adityachandel.booklore.repository.UserRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class OpdsUserV2Service {
|
||||
|
||||
private final OpdsUserV2Repository opdsUserV2Repository;
|
||||
private final AuthenticationService authenticationService;
|
||||
private final UserRepository userRepository;
|
||||
private final OpdsUserV2Mapper mapper;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
|
||||
public List<OpdsUserV2> getOpdsUsers() {
|
||||
BookLoreUser bookLoreUser = authenticationService.getAuthenticatedUser();
|
||||
List<OpdsUserV2Entity> users = opdsUserV2Repository.findByUserId(bookLoreUser.getId());
|
||||
return mapper.toDto(users);
|
||||
}
|
||||
|
||||
public OpdsUserV2 createOpdsUser(OpdsUserV2CreateRequest request) {
|
||||
BookLoreUser bookLoreUser = authenticationService.getAuthenticatedUser();
|
||||
BookLoreUserEntity userEntity = userRepository.findById(bookLoreUser.getId()).orElseThrow(() -> new UsernameNotFoundException("User not found with ID: " + bookLoreUser.getId()));
|
||||
OpdsUserV2Entity opdsUserV2 = OpdsUserV2Entity.builder()
|
||||
.user(userEntity)
|
||||
.username(request.getUsername())
|
||||
.passwordHash(passwordEncoder.encode(request.getPassword()))
|
||||
.build();
|
||||
return mapper.toDto(opdsUserV2Repository.save(opdsUserV2));
|
||||
}
|
||||
|
||||
public void deleteOpdsUser(Long userId) {
|
||||
BookLoreUser bookLoreUser = authenticationService.getAuthenticatedUser();
|
||||
OpdsUserV2Entity user = opdsUserV2Repository.findById(userId).orElseThrow(() -> new RuntimeException("User not found with ID: " + userId));
|
||||
if (!user.getUser().getId().equals(bookLoreUser.getId())) {
|
||||
throw new AccessDeniedException("You are not allowed to delete this user");
|
||||
}
|
||||
opdsUserV2Repository.delete(user);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,22 @@
|
||||
package com.adityachandel.booklore.service.opds;
|
||||
|
||||
import com.adityachandel.booklore.model.entity.BookEntity;
|
||||
import com.adityachandel.booklore.config.security.service.AuthenticationService;
|
||||
import com.adityachandel.booklore.config.security.userdetails.OpdsUserDetails;
|
||||
import com.adityachandel.booklore.mapper.custom.BookLoreUserTransformer;
|
||||
import com.adityachandel.booklore.model.dto.*;
|
||||
import com.adityachandel.booklore.model.entity.BookLoreUserEntity;
|
||||
import com.adityachandel.booklore.repository.UserRepository;
|
||||
import com.adityachandel.booklore.service.BookQueryService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -16,11 +24,13 @@ import java.util.List;
|
||||
public class OpdsService {
|
||||
|
||||
private final BookQueryService bookQueryService;
|
||||
private final AuthenticationService authenticationService;
|
||||
private final UserRepository userRepository;
|
||||
private final BookLoreUserTransformer bookLoreUserTransformer;
|
||||
|
||||
public String generateCatalogFeed(HttpServletRequest request) {
|
||||
var books = bookQueryService.getAllFullBookEntities();
|
||||
var feedVersion = extractVersionFromAcceptHeader(request);
|
||||
|
||||
List<Book> books = getAllowedBooks(null);
|
||||
String feedVersion = extractVersionFromAcceptHeader(request);
|
||||
return switch (feedVersion) {
|
||||
case "2.0" -> generateOpdsV2Feed(books);
|
||||
default -> generateOpdsV1Feed(books);
|
||||
@@ -28,20 +38,49 @@ public class OpdsService {
|
||||
}
|
||||
|
||||
public String generateSearchResults(HttpServletRequest request, String queryParam) {
|
||||
List<BookEntity> books = List.of();
|
||||
if (queryParam != null) {
|
||||
books = bookQueryService.getBooksContainingMetadata(queryParam);
|
||||
} else {
|
||||
books = bookQueryService.getAllFullBookEntities();
|
||||
}
|
||||
var feedVersion = extractVersionFromAcceptHeader(request);
|
||||
|
||||
List<Book> books = getAllowedBooks(queryParam);
|
||||
String feedVersion = extractVersionFromAcceptHeader(request);
|
||||
return switch (feedVersion) {
|
||||
case "2.0" -> generateOpdsV2Feed(books);
|
||||
default -> generateOpdsV1Feed(books);
|
||||
};
|
||||
}
|
||||
|
||||
private List<Book> getAllowedBooks(String queryParam) {
|
||||
OpdsUserDetails opdsUserDetails = authenticationService.getOpdsUser();
|
||||
OpdsUser opdsUser = opdsUserDetails.getOpdsUser();
|
||||
|
||||
if (opdsUser != null) {
|
||||
return (queryParam != null)
|
||||
? bookQueryService.searchBooksByMetadata(queryParam)
|
||||
: bookQueryService.getAllBooks(true);
|
||||
}
|
||||
|
||||
OpdsUserV2 opdsUserV2 = opdsUserDetails.getOpdsUserV2();
|
||||
BookLoreUserEntity entity = userRepository.findById(opdsUserV2.getUserId())
|
||||
.orElseThrow(() -> new AccessDeniedException("User not found"));
|
||||
|
||||
if (!entity.getPermissions().isPermissionAccessOpds() && !entity.getPermissions().isPermissionAdmin()) {
|
||||
throw new AccessDeniedException("You are not allowed to access this resource");
|
||||
}
|
||||
|
||||
BookLoreUser user = bookLoreUserTransformer.toDTO(entity);
|
||||
boolean isAdmin = user.getPermissions().isAdmin();
|
||||
Set<Long> libraryIds = user.getAssignedLibraries().stream()
|
||||
.map(Library::getId)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
if (isAdmin) {
|
||||
return (queryParam != null)
|
||||
? bookQueryService.searchBooksByMetadata(queryParam)
|
||||
: bookQueryService.getAllBooks(true);
|
||||
} else {
|
||||
return (queryParam != null)
|
||||
? bookQueryService.searchBooksByMetadataInLibraries(queryParam, libraryIds)
|
||||
: bookQueryService.getAllBooksByLibraryIds(libraryIds, true);
|
||||
}
|
||||
}
|
||||
|
||||
public String generateSearchDescription(HttpServletRequest request) {
|
||||
var feedVersion = extractVersionFromAcceptHeader(request);
|
||||
|
||||
@@ -70,7 +109,7 @@ public class OpdsService {
|
||||
""";
|
||||
}
|
||||
|
||||
private String generateOpdsV1Feed(List<BookEntity> books) {
|
||||
private String generateOpdsV1Feed(List<Book> books) {
|
||||
var feed = new StringBuilder("""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/terms/">
|
||||
@@ -86,7 +125,7 @@ public class OpdsService {
|
||||
return feed.toString();
|
||||
}
|
||||
|
||||
private void appendBookEntryV1(StringBuilder feed, BookEntity book) {
|
||||
private void appendBookEntryV1(StringBuilder feed, Book book) {
|
||||
feed.append("""
|
||||
<entry>
|
||||
<title>%s</title>
|
||||
@@ -98,13 +137,13 @@ public class OpdsService {
|
||||
<author>
|
||||
<name>%s</name>
|
||||
</author>
|
||||
""".formatted(escapeXml(author.getName()))));
|
||||
""".formatted(escapeXml(author))));
|
||||
|
||||
appendOptionalTags(feed, book);
|
||||
feed.append(" </entry>");
|
||||
}
|
||||
|
||||
private void appendOptionalTags(StringBuilder feed, BookEntity book) {
|
||||
private void appendOptionalTags(StringBuilder feed, Book book) {
|
||||
if (book.getMetadata().getPublisher() != null) {
|
||||
feed.append("<dc:publisher>").append(escapeXml(book.getMetadata().getPublisher())).append("</dc:publisher>");
|
||||
}
|
||||
@@ -115,7 +154,7 @@ public class OpdsService {
|
||||
|
||||
if (book.getMetadata().getCategories() != null) {
|
||||
book.getMetadata().getCategories().forEach(category ->
|
||||
feed.append("<dc:subject>").append(escapeXml(category.getName())).append("</dc:subject>")
|
||||
feed.append("<dc:subject>").append(escapeXml(category)).append("</dc:subject>")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -144,11 +183,11 @@ public class OpdsService {
|
||||
}
|
||||
}
|
||||
|
||||
private String generateOpdsV2Feed(List<BookEntity> books) {
|
||||
private String generateOpdsV2Feed(List<Book> books) {
|
||||
// Placeholder for OPDS v2.0 feed implementation (similar structure as v1)
|
||||
return "OPDS v2.0 Feed is under construction";
|
||||
}
|
||||
|
||||
|
||||
private String generateOpdsV2SearchDescription() {
|
||||
// Placeholder for OPDS v2.0 feed implementation (similar structure as v1)
|
||||
return "OPDS v2.0 Feed is under construction";
|
||||
@@ -159,7 +198,10 @@ public class OpdsService {
|
||||
return DateTimeFormatter.ISO_INSTANT.format(java.time.Instant.now());
|
||||
}
|
||||
|
||||
private String fileMimeType(BookEntity book) {
|
||||
private String fileMimeType(Book book) {
|
||||
if (book == null || book.getBookType() == null) {
|
||||
return "octet-stream";
|
||||
}
|
||||
return switch (book.getBookType()) {
|
||||
case PDF -> "pdf";
|
||||
case EPUB -> "epub+zip";
|
||||
|
||||
@@ -53,6 +53,7 @@ public class UserProvisioningService {
|
||||
perms.setPermissionManipulateLibrary(true);
|
||||
perms.setPermissionEmailBook(true);
|
||||
perms.setPermissionDeleteBook(true);
|
||||
perms.setPermissionAccessOpds(true);
|
||||
perms.setPermissionSyncKoreader(true);
|
||||
perms.setPermissionSyncKobo(true);
|
||||
|
||||
@@ -83,6 +84,7 @@ public class UserProvisioningService {
|
||||
permissions.setPermissionManipulateLibrary(request.isPermissionManipulateLibrary());
|
||||
permissions.setPermissionEmailBook(request.isPermissionEmailBook());
|
||||
permissions.setPermissionDeleteBook(request.isPermissionDeleteBook());
|
||||
permissions.setPermissionAccessOpds(request.isPermissionAccessOpds());
|
||||
permissions.setPermissionSyncKoreader(request.isPermissionSyncKoreader());
|
||||
permissions.setPermissionSyncKobo(request.isPermissionSyncKobo());
|
||||
permissions.setPermissionAdmin(request.isPermissionAdmin());
|
||||
@@ -115,6 +117,7 @@ public class UserProvisioningService {
|
||||
perms.setPermissionManipulateLibrary(defaultPermissions.contains("permissionManipulateLibrary"));
|
||||
perms.setPermissionEmailBook(defaultPermissions.contains("permissionEmailBook"));
|
||||
perms.setPermissionDeleteBook(defaultPermissions.contains("permissionDeleteBook"));
|
||||
perms.setPermissionAccessOpds(defaultPermissions.contains("permissionAccessOpds"));
|
||||
perms.setPermissionSyncKoreader(defaultPermissions.contains("permissionSyncKoreader"));
|
||||
perms.setPermissionSyncKobo(defaultPermissions.contains("permissionSyncKobo"));
|
||||
}
|
||||
@@ -164,6 +167,7 @@ public class UserProvisioningService {
|
||||
permissions.setPermissionManipulateLibrary(defaultPermissions.contains("permissionManipulateLibrary"));
|
||||
permissions.setPermissionEmailBook(defaultPermissions.contains("permissionEmailBook"));
|
||||
permissions.setPermissionDeleteBook(defaultPermissions.contains("permissionDeleteBook"));
|
||||
permissions.setPermissionAccessOpds(defaultPermissions.contains("permissionAccessOpds"));
|
||||
permissions.setPermissionSyncKoreader(defaultPermissions.contains("permissionSyncKoreader"));
|
||||
permissions.setPermissionSyncKobo(defaultPermissions.contains("permissionSyncKobo"));
|
||||
} else {
|
||||
@@ -172,6 +176,7 @@ public class UserProvisioningService {
|
||||
permissions.setPermissionEditMetadata(false);
|
||||
permissions.setPermissionManipulateLibrary(false);
|
||||
permissions.setPermissionEmailBook(false);
|
||||
permissions.setPermissionAccessOpds(false);
|
||||
permissions.setPermissionDeleteBook(false);
|
||||
permissions.setPermissionSyncKoreader(false);
|
||||
permissions.setPermissionSyncKobo(false);
|
||||
|
||||
@@ -53,6 +53,7 @@ public class UserService {
|
||||
user.getPermissions().setPermissionManipulateLibrary(updateRequest.getPermissions().isCanManipulateLibrary());
|
||||
user.getPermissions().setPermissionEmailBook(updateRequest.getPermissions().isCanEmailBook());
|
||||
user.getPermissions().setPermissionDeleteBook(updateRequest.getPermissions().isCanDeleteBook());
|
||||
user.getPermissions().setPermissionAccessOpds(updateRequest.getPermissions().isCanAccessOpds());
|
||||
user.getPermissions().setPermissionSyncKoreader(updateRequest.getPermissions().isCanSyncKoReader());
|
||||
user.getPermissions().setPermissionSyncKobo(updateRequest.getPermissions().isCanSyncKobo());
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ public class UserPermissionUtils {
|
||||
case MANIPULATE_LIBRARY -> perms.isPermissionManipulateLibrary();
|
||||
case EMAIL_BOOK -> perms.isPermissionEmailBook();
|
||||
case DELETE_BOOK -> perms.isPermissionDeleteBook();
|
||||
case ACCESS_OPDS -> perms.isPermissionAccessOpds();
|
||||
case SYNC_KOREADER -> perms.isPermissionSyncKoreader();
|
||||
case SYNC_KOBO -> perms.isPermissionSyncKobo();
|
||||
case ADMIN -> perms.isPermissionAdmin();
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE IF NOT EXISTS opds_user_v2
|
||||
(
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
username VARCHAR(100) NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT fk_opds_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
CONSTRAINT uq_userid_username UNIQUE (user_id, username)
|
||||
);
|
||||
|
||||
ALTER TABLE user_permissions
|
||||
ADD COLUMN IF NOT EXISTS permission_access_opds BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
UPDATE user_permissions
|
||||
SET permission_access_opds = TRUE
|
||||
WHERE permission_admin = TRUE;
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.adityachandel.booklore.config.security.service;
|
||||
|
||||
import com.adityachandel.booklore.config.security.userdetails.OpdsUserDetails;
|
||||
import com.adityachandel.booklore.mapper.OpdsUserMapper;
|
||||
import com.adityachandel.booklore.mapper.OpdsUserV2Mapper;
|
||||
import com.adityachandel.booklore.model.entity.OpdsUserEntity;
|
||||
import com.adityachandel.booklore.model.entity.OpdsUserV2Entity;
|
||||
import com.adityachandel.booklore.repository.OpdsUserRepository;
|
||||
import com.adityachandel.booklore.repository.OpdsUserV2Repository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class OpdsUserDetailsServiceTest {
|
||||
|
||||
private OpdsUserRepository opdsUserRepository;
|
||||
private OpdsUserV2Repository opdsUserV2Repository;
|
||||
private OpdsUserMapper opdsUserMapper;
|
||||
private OpdsUserV2Mapper opdsUserV2Mapper;
|
||||
private OpdsUserDetailsService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
opdsUserRepository = mock(OpdsUserRepository.class);
|
||||
opdsUserV2Repository = mock(OpdsUserV2Repository.class);
|
||||
opdsUserMapper = mock(OpdsUserMapper.class);
|
||||
opdsUserV2Mapper = mock(OpdsUserV2Mapper.class);
|
||||
|
||||
service = new OpdsUserDetailsService(opdsUserRepository, opdsUserV2Repository, opdsUserMapper, opdsUserV2Mapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadUserByUsername_primaryRepositoryHit_usesPrimaryMapperAndDoesNotCallV2() {
|
||||
String username = "primaryUser";
|
||||
|
||||
Optional<OpdsUserEntity> primaryEntity = Optional.of(mock(OpdsUserEntity.class));
|
||||
when(opdsUserRepository.findByUsername(username)).thenReturn(primaryEntity);
|
||||
|
||||
OpdsUserDetails result = service.loadUserByUsername(username);
|
||||
|
||||
assertNotNull(result, "Expected non-null OpdsUserDetails when primary repo returns a user");
|
||||
verify(opdsUserMapper, times(1)).toOpdsUser(any(OpdsUserEntity.class));
|
||||
verify(opdsUserV2Repository, never()).findByUsername(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadUserByUsername_primaryEmpty_v2RepositoryHit_usesV2Mapper() {
|
||||
String username = "v2User";
|
||||
|
||||
when(opdsUserRepository.findByUsername(username)).thenReturn(Optional.empty());
|
||||
|
||||
Optional<OpdsUserV2Entity> v2Entity = Optional.of(mock(OpdsUserV2Entity.class));
|
||||
when(opdsUserV2Repository.findByUsername(username)).thenReturn(v2Entity);
|
||||
|
||||
OpdsUserDetails result = service.loadUserByUsername(username);
|
||||
|
||||
assertNotNull(result, "Expected non-null OpdsUserDetails when v2 repo returns a user");
|
||||
verify(opdsUserRepository, times(1)).findByUsername(username);
|
||||
verify(opdsUserV2Repository, times(1)).findByUsername(username);
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadUserByUsername_bothRepositoriesEmpty_throwsException() {
|
||||
String username = "missingUser";
|
||||
|
||||
when(opdsUserRepository.findByUsername(username)).thenReturn(Optional.empty());
|
||||
when(opdsUserV2Repository.findByUsername(username)).thenReturn(Optional.empty());
|
||||
|
||||
assertThrows(Exception.class, () -> service.loadUserByUsername(username), "Expected an exception when user not found in either repository");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package com.adityachandel.booklore.service;
|
||||
|
||||
import com.adityachandel.booklore.config.security.service.AuthenticationService;
|
||||
import com.adityachandel.booklore.mapper.OpdsUserV2Mapper;
|
||||
import com.adityachandel.booklore.model.dto.BookLoreUser;
|
||||
import com.adityachandel.booklore.model.dto.OpdsUserV2;
|
||||
import com.adityachandel.booklore.model.dto.request.OpdsUserV2CreateRequest;
|
||||
import com.adityachandel.booklore.model.entity.BookLoreUserEntity;
|
||||
import com.adityachandel.booklore.model.entity.OpdsUserV2Entity;
|
||||
import com.adityachandel.booklore.repository.OpdsUserV2Repository;
|
||||
import com.adityachandel.booklore.repository.UserRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Captor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class OpdsUserV2ServiceTest {
|
||||
|
||||
@Mock
|
||||
private OpdsUserV2Repository opdsUserV2Repository;
|
||||
@Mock
|
||||
private AuthenticationService authenticationService;
|
||||
@Mock
|
||||
private UserRepository userRepository;
|
||||
@Mock
|
||||
private OpdsUserV2Mapper mapper;
|
||||
@Mock
|
||||
private PasswordEncoder passwordEncoder;
|
||||
|
||||
@InjectMocks
|
||||
private OpdsUserV2Service service;
|
||||
|
||||
@Captor
|
||||
private ArgumentCaptor<OpdsUserV2Entity> entityCaptor;
|
||||
|
||||
@Test
|
||||
void getOpdsUsers_returnsMappedDtos() {
|
||||
BookLoreUser authUser = mock(BookLoreUser.class);
|
||||
when(authUser.getId()).thenReturn(1L);
|
||||
when(authenticationService.getAuthenticatedUser()).thenReturn(authUser);
|
||||
|
||||
OpdsUserV2Entity entity = mock(OpdsUserV2Entity.class);
|
||||
List<OpdsUserV2Entity> entities = List.of(entity);
|
||||
when(opdsUserV2Repository.findByUserId(1L)).thenReturn(entities);
|
||||
|
||||
OpdsUserV2 dto = mock(OpdsUserV2.class);
|
||||
List<OpdsUserV2> dtos = List.of(dto);
|
||||
when(mapper.toDto(entities)).thenReturn(dtos);
|
||||
|
||||
List<OpdsUserV2> result = service.getOpdsUsers();
|
||||
|
||||
assertSame(dtos, result);
|
||||
verify(opdsUserV2Repository).findByUserId(1L);
|
||||
verify(mapper).toDto(entities);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createOpdsUser_success_savesWithEncodedPasswordAndReturnsDto() {
|
||||
BookLoreUser authUser = mock(BookLoreUser.class);
|
||||
when(authUser.getId()).thenReturn(1L);
|
||||
when(authenticationService.getAuthenticatedUser()).thenReturn(authUser);
|
||||
|
||||
BookLoreUserEntity userEntity = mock(BookLoreUserEntity.class);
|
||||
when(userRepository.findById(1L)).thenReturn(Optional.of(userEntity));
|
||||
|
||||
OpdsUserV2CreateRequest request = mock(OpdsUserV2CreateRequest.class);
|
||||
when(request.getUsername()).thenReturn("alice");
|
||||
when(request.getPassword()).thenReturn("plaintext");
|
||||
|
||||
when(passwordEncoder.encode("plaintext")).thenReturn("encoded-pass");
|
||||
|
||||
OpdsUserV2Entity savedEntity = mock(OpdsUserV2Entity.class);
|
||||
OpdsUserV2 dto = mock(OpdsUserV2.class);
|
||||
when(opdsUserV2Repository.save(any())).thenReturn(savedEntity);
|
||||
when(mapper.toDto(savedEntity)).thenReturn(dto);
|
||||
|
||||
OpdsUserV2 result = service.createOpdsUser(request);
|
||||
|
||||
assertSame(dto, result);
|
||||
verify(passwordEncoder).encode("plaintext");
|
||||
verify(opdsUserV2Repository).save(entityCaptor.capture());
|
||||
OpdsUserV2Entity captured = entityCaptor.getValue();
|
||||
// we cannot assume concrete builder internals here; assert via getters that exist on entity
|
||||
assertEquals("alice", captured.getUsername());
|
||||
assertEquals("encoded-pass", captured.getPasswordHash());
|
||||
assertSame(userEntity, captured.getUser());
|
||||
verify(mapper).toDto(savedEntity);
|
||||
}
|
||||
|
||||
@Test
|
||||
void createOpdsUser_userNotFound_throwsUsernameNotFoundException() {
|
||||
BookLoreUser authUser = mock(BookLoreUser.class);
|
||||
when(authUser.getId()).thenReturn(2L);
|
||||
when(authenticationService.getAuthenticatedUser()).thenReturn(authUser);
|
||||
when(userRepository.findById(2L)).thenReturn(Optional.empty());
|
||||
|
||||
OpdsUserV2CreateRequest request = mock(OpdsUserV2CreateRequest.class);
|
||||
|
||||
assertThrows(UsernameNotFoundException.class, () -> service.createOpdsUser(request));
|
||||
verify(opdsUserV2Repository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteOpdsUser_deletesWhenOwner() {
|
||||
BookLoreUser authUser = mock(BookLoreUser.class);
|
||||
when(authUser.getId()).thenReturn(10L);
|
||||
when(authenticationService.getAuthenticatedUser()).thenReturn(authUser);
|
||||
|
||||
BookLoreUserEntity ownerEntity = mock(BookLoreUserEntity.class);
|
||||
when(ownerEntity.getId()).thenReturn(10L);
|
||||
|
||||
OpdsUserV2Entity target = mock(OpdsUserV2Entity.class);
|
||||
when(target.getUser()).thenReturn(ownerEntity);
|
||||
|
||||
when(opdsUserV2Repository.findById(100L)).thenReturn(Optional.of(target));
|
||||
|
||||
service.deleteOpdsUser(100L);
|
||||
|
||||
verify(opdsUserV2Repository).delete(target);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteOpdsUser_throwsAccessDeniedWhenNotOwner() {
|
||||
BookLoreUser authUser = mock(BookLoreUser.class);
|
||||
when(authUser.getId()).thenReturn(11L);
|
||||
when(authenticationService.getAuthenticatedUser()).thenReturn(authUser);
|
||||
|
||||
BookLoreUserEntity ownerEntity = mock(BookLoreUserEntity.class);
|
||||
when(ownerEntity.getId()).thenReturn(9L);
|
||||
|
||||
OpdsUserV2Entity target = mock(OpdsUserV2Entity.class);
|
||||
when(target.getUser()).thenReturn(ownerEntity);
|
||||
|
||||
when(opdsUserV2Repository.findById(200L)).thenReturn(Optional.of(target));
|
||||
|
||||
assertThrows(AccessDeniedException.class, () -> service.deleteOpdsUser(200L));
|
||||
verify(opdsUserV2Repository, never()).delete(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getOpdsUsers_returnsEmptyListWhenNoUsers() {
|
||||
BookLoreUser authUser = mock(BookLoreUser.class);
|
||||
when(authUser.getId()).thenReturn(5L);
|
||||
when(authenticationService.getAuthenticatedUser()).thenReturn(authUser);
|
||||
|
||||
List<OpdsUserV2Entity> emptyEntities = List.of();
|
||||
when(opdsUserV2Repository.findByUserId(5L)).thenReturn(emptyEntities);
|
||||
|
||||
List<OpdsUserV2> emptyDtos = List.of();
|
||||
when(mapper.toDto(emptyEntities)).thenReturn(emptyDtos);
|
||||
|
||||
List<OpdsUserV2> result = service.getOpdsUsers();
|
||||
|
||||
assertNotNull(result);
|
||||
assertTrue(result.isEmpty());
|
||||
verify(opdsUserV2Repository).findByUserId(5L);
|
||||
verify(mapper).toDto(emptyEntities);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteOpdsUser_userNotFound_throwsRuntimeException() {
|
||||
BookLoreUser authUser = mock(BookLoreUser.class);
|
||||
when(authenticationService.getAuthenticatedUser()).thenReturn(authUser);
|
||||
|
||||
when(opdsUserV2Repository.findById(300L)).thenReturn(Optional.empty());
|
||||
|
||||
RuntimeException ex = assertThrows(RuntimeException.class, () -> service.deleteOpdsUser(300L));
|
||||
assertTrue(ex.getMessage().contains("User not found with ID: 300"));
|
||||
verify(opdsUserV2Repository, never()).delete(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createOpdsUser_passwordEncoderThrows_propagatesException() {
|
||||
BookLoreUser authUser = mock(BookLoreUser.class);
|
||||
when(authUser.getId()).thenReturn(6L);
|
||||
when(authenticationService.getAuthenticatedUser()).thenReturn(authUser);
|
||||
|
||||
BookLoreUserEntity userEntity = mock(BookLoreUserEntity.class);
|
||||
when(userRepository.findById(6L)).thenReturn(Optional.of(userEntity));
|
||||
|
||||
OpdsUserV2CreateRequest request = mock(OpdsUserV2CreateRequest.class);
|
||||
when(request.getUsername()).thenReturn("bob");
|
||||
when(request.getPassword()).thenReturn("plaintext");
|
||||
|
||||
when(passwordEncoder.encode("plaintext")).thenThrow(new IllegalArgumentException("encoding failed"));
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> service.createOpdsUser(request));
|
||||
verify(opdsUserV2Repository, never()).save(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createOpdsUser_usernameNull_savedWithNullUsername() {
|
||||
BookLoreUser authUser = mock(BookLoreUser.class);
|
||||
when(authUser.getId()).thenReturn(7L);
|
||||
when(authenticationService.getAuthenticatedUser()).thenReturn(authUser);
|
||||
|
||||
BookLoreUserEntity userEntity = mock(BookLoreUserEntity.class);
|
||||
when(userRepository.findById(7L)).thenReturn(Optional.of(userEntity));
|
||||
|
||||
OpdsUserV2CreateRequest request = mock(OpdsUserV2CreateRequest.class);
|
||||
when(request.getUsername()).thenReturn(null);
|
||||
when(request.getPassword()).thenReturn("secret");
|
||||
when(passwordEncoder.encode("secret")).thenReturn("encoded-secret");
|
||||
|
||||
OpdsUserV2Entity savedEntity = mock(OpdsUserV2Entity.class);
|
||||
OpdsUserV2 dto = mock(OpdsUserV2.class);
|
||||
when(opdsUserV2Repository.save(any())).thenReturn(savedEntity);
|
||||
when(mapper.toDto(savedEntity)).thenReturn(dto);
|
||||
|
||||
OpdsUserV2 result = service.createOpdsUser(request);
|
||||
|
||||
assertSame(dto, result);
|
||||
verify(opdsUserV2Repository).save(entityCaptor.capture());
|
||||
OpdsUserV2Entity captured = entityCaptor.getValue();
|
||||
assertNull(captured.getUsername());
|
||||
assertEquals("encoded-secret", captured.getPasswordHash());
|
||||
assertSame(userEntity, captured.getUser());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package com.adityachandel.booklore.service.opds;
|
||||
|
||||
import com.adityachandel.booklore.config.security.service.AuthenticationService;
|
||||
import com.adityachandel.booklore.config.security.userdetails.OpdsUserDetails;
|
||||
import com.adityachandel.booklore.mapper.custom.BookLoreUserTransformer;
|
||||
import com.adityachandel.booklore.model.dto.*;
|
||||
import com.adityachandel.booklore.model.entity.BookLoreUserEntity;
|
||||
import com.adityachandel.booklore.repository.UserRepository;
|
||||
import com.adityachandel.booklore.service.BookQueryService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.Answers.RETURNS_DEEP_STUBS;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class OpdsServiceTest {
|
||||
|
||||
@Mock
|
||||
private BookQueryService bookQueryService;
|
||||
@Mock
|
||||
private AuthenticationService authenticationService;
|
||||
@Mock
|
||||
private UserRepository userRepository;
|
||||
@Mock
|
||||
private BookLoreUserTransformer bookLoreUserTransformer;
|
||||
@Mock
|
||||
private HttpServletRequest request;
|
||||
|
||||
@InjectMocks
|
||||
private OpdsService service;
|
||||
|
||||
@Test
|
||||
void generateCatalogFeed_defaultsToV1_callsGetAllBooksAndReturnsFeed() {
|
||||
OpdsUserDetails details = mock(OpdsUserDetails.class);
|
||||
when(authenticationService.getOpdsUser()).thenReturn(details);
|
||||
when(details.getOpdsUser()).thenReturn(mock(OpdsUser.class));
|
||||
|
||||
when(request.getHeader("Accept")).thenReturn(null);
|
||||
when(bookQueryService.getAllBooks(true)).thenReturn(List.of()); // minimal stub
|
||||
|
||||
String feed = service.generateCatalogFeed(request);
|
||||
|
||||
assertNotNull(feed);
|
||||
assertTrue(feed.contains("<title>Booklore Catalog</title>"));
|
||||
verify(bookQueryService).getAllBooks(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateSearchResults_withQuery_usesSearchBooksByMetadata_and_producesFeed() {
|
||||
OpdsUserDetails details = mock(OpdsUserDetails.class);
|
||||
when(authenticationService.getOpdsUser()).thenReturn(details);
|
||||
when(details.getOpdsUser()).thenReturn(mock(OpdsUser.class));
|
||||
|
||||
// build a minimal Book mock so feed generation won't NPE
|
||||
Book book = mock(Book.class);
|
||||
BookMetadata metadata = mock(BookMetadata.class);
|
||||
when(book.getMetadata()).thenReturn(metadata);
|
||||
when(metadata.getTitle()).thenReturn("Searchable Title");
|
||||
when(metadata.getAuthors()).thenReturn(Set.of("Author One"));
|
||||
when(book.getId()).thenReturn(11L);
|
||||
when(book.getAddedOn()).thenReturn(null);
|
||||
when(book.getBookType()).thenReturn(null); // will map to default mime type
|
||||
|
||||
when(bookQueryService.searchBooksByMetadata("query")).thenReturn(List.of(book));
|
||||
when(request.getHeader("Accept")).thenReturn(null);
|
||||
|
||||
String feed = service.generateSearchResults(request, "query");
|
||||
|
||||
assertNotNull(feed);
|
||||
assertTrue(feed.contains("Searchable Title"));
|
||||
verify(bookQueryService).searchBooksByMetadata("query");
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateCatalogFeed_opdsV2Admin_callsGetAllBooks_and_returnsV2Placeholder() {
|
||||
OpdsUserDetails details = mock(OpdsUserDetails.class);
|
||||
when(authenticationService.getOpdsUser()).thenReturn(details);
|
||||
when(details.getOpdsUser()).thenReturn(null);
|
||||
|
||||
OpdsUserV2 opdsUserV2 = mock(OpdsUserV2.class);
|
||||
when(details.getOpdsUserV2()).thenReturn(opdsUserV2);
|
||||
when(opdsUserV2.getUserId()).thenReturn(9L);
|
||||
|
||||
// entity with deep-stubbed permissions for the initial permission check
|
||||
BookLoreUserEntity entity = mock(BookLoreUserEntity.class, RETURNS_DEEP_STUBS);
|
||||
when(entity.getPermissions().isPermissionAccessOpds()).thenReturn(true);
|
||||
when(userRepository.findById(9L)).thenReturn(Optional.of(entity));
|
||||
|
||||
// transformer returns DTO indicating admin user
|
||||
BookLoreUser userDto = mock(BookLoreUser.class);
|
||||
BookLoreUser.UserPermissions dtoPerms = mock(BookLoreUser.UserPermissions.class);
|
||||
when(dtoPerms.isAdmin()).thenReturn(true);
|
||||
when(userDto.getPermissions()).thenReturn(dtoPerms);
|
||||
when(bookLoreUserTransformer.toDTO(entity)).thenReturn(userDto);
|
||||
|
||||
when(request.getHeader("Accept")).thenReturn("application/opds+json;version=2.0");
|
||||
when(bookQueryService.getAllBooks(true)).thenReturn(List.of());
|
||||
|
||||
String feed = service.generateCatalogFeed(request);
|
||||
|
||||
assertEquals("OPDS v2.0 Feed is under construction", feed);
|
||||
verify(bookQueryService).getAllBooks(true);
|
||||
verify(userRepository).findById(9L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateCatalogFeed_opdsV2NonAdmin_callsLibraryScopedQueryMethods() {
|
||||
OpdsUserDetails details = mock(OpdsUserDetails.class);
|
||||
when(authenticationService.getOpdsUser()).thenReturn(details);
|
||||
when(details.getOpdsUser()).thenReturn(null);
|
||||
|
||||
OpdsUserV2 opdsUserV2 = mock(OpdsUserV2.class);
|
||||
when(details.getOpdsUserV2()).thenReturn(opdsUserV2);
|
||||
when(opdsUserV2.getUserId()).thenReturn(21L);
|
||||
|
||||
BookLoreUserEntity entity = mock(BookLoreUserEntity.class, RETURNS_DEEP_STUBS);
|
||||
when(entity.getPermissions().isPermissionAccessOpds()).thenReturn(true); // allow access
|
||||
when(userRepository.findById(21L)).thenReturn(Optional.of(entity));
|
||||
|
||||
// DTO: non-admin with assigned libraries
|
||||
BookLoreUser userDto = mock(BookLoreUser.class);
|
||||
BookLoreUser.UserPermissions dtoPerms = mock(BookLoreUser.UserPermissions.class);
|
||||
when(dtoPerms.isAdmin()).thenReturn(false);
|
||||
when(userDto.getPermissions()).thenReturn(dtoPerms);
|
||||
|
||||
Library lib = mock(Library.class);
|
||||
when(lib.getId()).thenReturn(7L);
|
||||
when(userDto.getAssignedLibraries()).thenReturn(List.of(lib));
|
||||
|
||||
when(bookLoreUserTransformer.toDTO(entity)).thenReturn(userDto);
|
||||
|
||||
when(request.getHeader("Accept")).thenReturn(null); // v1 default; feed version not relevant for allowed-books logic
|
||||
when(bookQueryService.getAllBooksByLibraryIds(Set.of(7L), true)).thenReturn(List.of());
|
||||
|
||||
String feed = service.generateCatalogFeed(request);
|
||||
|
||||
assertNotNull(feed);
|
||||
verify(bookQueryService).getAllBooksByLibraryIds(Set.of(7L), true);
|
||||
verify(userRepository).findById(21L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateCatalogFeed_opdsV2UserMissingBookLoreUser_throwsAccessDenied() {
|
||||
OpdsUserDetails details = mock(OpdsUserDetails.class);
|
||||
when(authenticationService.getOpdsUser()).thenReturn(details);
|
||||
when(details.getOpdsUser()).thenReturn(null);
|
||||
|
||||
OpdsUserV2 opdsUserV2 = mock(OpdsUserV2.class);
|
||||
when(details.getOpdsUserV2()).thenReturn(opdsUserV2);
|
||||
when(opdsUserV2.getUserId()).thenReturn(42L);
|
||||
|
||||
when(userRepository.findById(42L)).thenReturn(Optional.empty());
|
||||
|
||||
AccessDeniedException ex = assertThrows(AccessDeniedException.class, () -> service.generateCatalogFeed(request));
|
||||
assertTrue(ex.getMessage().contains("User not found"));
|
||||
verify(userRepository).findById(42L);
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,8 @@ export class AuthenticationSettingsComponent implements OnInit {
|
||||
{label: 'Email Book', value: 'permissionEmailBook', selected: false},
|
||||
{label: 'Delete Book', value: 'permissionDeleteBook', selected: false},
|
||||
{label: 'KOReader Sync', value: 'permissionSyncKoreader', selected: false},
|
||||
{label: 'Kobo Sync', value: 'permissionSyncKobo', selected: false}
|
||||
{label: 'Kobo Sync', value: 'permissionSyncKobo', selected: false},
|
||||
{label: 'Access OPDS', value: 'permissionAccessOpds', selected: false}
|
||||
];
|
||||
|
||||
internalAuthEnabled = true;
|
||||
|
||||
@@ -3,7 +3,7 @@ import {DialogService, DynamicDialogRef} from 'primeng/dynamicdialog';
|
||||
import {GithubSupportDialog} from './utilities/component/github-support-dialog/github-support-dialog';
|
||||
import {LibraryCreatorComponent} from './book/components/library-creator/library-creator.component';
|
||||
import {BookUploaderComponent} from './utilities/component/book-uploader/book-uploader.component';
|
||||
import {UserProfileDialogComponent} from './settings/global-preferences/user-profile-dialog/user-profile-dialog.component';
|
||||
import {UserProfileDialogComponent} from './settings/user-profile-dialog/user-profile-dialog.component';
|
||||
import {MagicShelfComponent} from './magic-shelf-component/magic-shelf-component';
|
||||
|
||||
@Injectable({
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
<div class="main-container enclosing-container">
|
||||
<div class="settings-header">
|
||||
<h2 class="settings-title">
|
||||
<i class="pi pi-server"></i>
|
||||
OPDS Settings
|
||||
</h2>
|
||||
<p class="settings-description">
|
||||
Manage your OPDS credentials and control how your book collection is shared with reading apps.
|
||||
By default, all libraries you have access to will be included in the OPDS feed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if (hasPermission) {
|
||||
<div class="settings-content">
|
||||
<div class="endpoint-section">
|
||||
<h3 class="section-title">
|
||||
<i class="pi pi-link"></i>
|
||||
OPDS Endpoint
|
||||
</h3>
|
||||
<div class="endpoint-form">
|
||||
<div class="endpoint-field">
|
||||
<input
|
||||
id="endpoint-url"
|
||||
fluid
|
||||
class="endpoint-input"
|
||||
type="text"
|
||||
pInputText
|
||||
[value]="opdsEndpoint"
|
||||
readonly/>
|
||||
<p-button
|
||||
icon="pi pi-copy"
|
||||
severity="info"
|
||||
outlined
|
||||
size="small"
|
||||
(onClick)="copyEndpoint()">
|
||||
</p-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="users-section">
|
||||
<div class="section-header">
|
||||
<div class="section-title-group">
|
||||
<h3 class="section-title">
|
||||
<i class="pi pi-users"></i>
|
||||
OPDS Users
|
||||
</h3>
|
||||
<p-button
|
||||
icon="pi pi-plus"
|
||||
label="Add User"
|
||||
severity="success"
|
||||
size="small"
|
||||
(onClick)="showCreateUserDialog = true">
|
||||
</p-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-card">
|
||||
<p-table
|
||||
[value]="users"
|
||||
[paginator]="users.length > 10"
|
||||
[rows]="10"
|
||||
[showCurrentPageReport]="true"
|
||||
currentPageReportTemplate="Showing {first} to {last} of {totalRecords} users">
|
||||
<ng-template pTemplate="header">
|
||||
<tr>
|
||||
<th>
|
||||
<div class="header-content">
|
||||
<i class="pi pi-user"></i>
|
||||
<span>Username</span>
|
||||
</div>
|
||||
</th>
|
||||
<th>
|
||||
<div class="header-content">
|
||||
<i class="pi pi-key"></i>
|
||||
<span>Password</span>
|
||||
</div>
|
||||
</th>
|
||||
<th class="actions-header">
|
||||
<div class="header-content">
|
||||
<i class="pi pi-cog"></i>
|
||||
<span>Actions</span>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</ng-template>
|
||||
<ng-template pTemplate="body" let-user let-rowIndex="rowIndex">
|
||||
<tr>
|
||||
<td>
|
||||
<div class="user-info">
|
||||
<div class="user-avatar">
|
||||
{{ user.username.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<span class="username">{{ user.username }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<p-password
|
||||
fluid
|
||||
class="w-32 md:w-56"
|
||||
[(ngModel)]="dummyPassword"
|
||||
[feedback]="false"
|
||||
size="small"
|
||||
[disabled]="true"
|
||||
[toggleMask]="false">
|
||||
</p-password>
|
||||
<i
|
||||
class="pi pi-info-circle text-gray-400"
|
||||
pTooltip="Passwords are hidden for security reasons. To change, delete the user and create a new one with a new password."
|
||||
tooltipPosition="right"
|
||||
style="cursor: pointer;">
|
||||
</i>
|
||||
</div>
|
||||
</td>
|
||||
<td class="actions-cell">
|
||||
<p-button
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
size="small"
|
||||
[outlined]="true"
|
||||
[rounded]="true"
|
||||
(onClick)="confirmDelete(user)"
|
||||
pTooltip="Delete user">
|
||||
</p-button>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
<ng-template pTemplate="emptymessage">
|
||||
<tr>
|
||||
<td colspan="3">
|
||||
<div class="empty-message">
|
||||
<i class="pi pi-users"></i>
|
||||
<p class="empty-title">No users found</p>
|
||||
<p class="empty-subtitle">Create your first OPDS user to get started</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</ng-template>
|
||||
</p-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p-dialog
|
||||
header="Create New User"
|
||||
[(visible)]="showCreateUserDialog"
|
||||
[modal]="true"
|
||||
styleClass="user-dialog"
|
||||
[style]="{width: '400px'}">
|
||||
<div class="dialog-form">
|
||||
<div class="form-field">
|
||||
<label for="username">Username</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
pInputText
|
||||
[(ngModel)]="newUser.username"
|
||||
placeholder="Enter username"/>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
pInputText
|
||||
[(ngModel)]="newUser.password"
|
||||
placeholder="Enter password"/>
|
||||
</div>
|
||||
</div>
|
||||
<ng-template pTemplate="footer">
|
||||
<div class="dialog-actions">
|
||||
<p-button
|
||||
label="Cancel"
|
||||
severity="secondary"
|
||||
outlined="true"
|
||||
(onClick)="cancelCreateUser()">
|
||||
</p-button>
|
||||
<p-button
|
||||
label="Create"
|
||||
severity="success"
|
||||
[disabled]="!newUser.username || !newUser.password"
|
||||
(onClick)="createUser()">
|
||||
</p-button>
|
||||
</div>
|
||||
</ng-template>
|
||||
</p-dialog>
|
||||
|
||||
<p-confirmDialog></p-confirmDialog>
|
||||
} @else {
|
||||
<div class="px-4 py-4 rounded-lg bg-red-700/30 border border-red-600 text-red-200 flex items-center gap-2 max-w-lg">
|
||||
<i class="pi pi-lock text-red-400 text-xl"></i>
|
||||
<span>
|
||||
Access to OPDS is restricted.
|
||||
<br>
|
||||
Please contact your administrator to request permission.
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,268 @@
|
||||
.main-container {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
height: calc(100dvh - 10.5rem);
|
||||
overflow-y: auto;
|
||||
border-width: 1px;
|
||||
border-radius: 0.5rem;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
height: calc(100dvh - 11.65rem);
|
||||
}
|
||||
}
|
||||
|
||||
.enclosing-container {
|
||||
border-color: var(--p-content-border-color);
|
||||
background: var(--p-content-background);
|
||||
}
|
||||
|
||||
.p-datatable th .header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.p-datatable td {
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
border: 1px solid var(--p-content-border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--p-content-background);
|
||||
}
|
||||
|
||||
.p-datatable {
|
||||
.p-datatable-table {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
.p-datatable-thead > tr > th {
|
||||
background: var(--p-surface-100);
|
||||
border-bottom: 2px solid var(--p-content-border-color);
|
||||
padding: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--p-text-color);
|
||||
}
|
||||
|
||||
.p-datatable-tbody > tr {
|
||||
border-bottom: 1px solid var(--p-surface-border);
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: var(--p-surface-50);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.p-datatable-tbody > tr > td {
|
||||
padding: 1rem;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--p-primary-color);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.password-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.password-hidden, .password-visible {
|
||||
font-family: monospace;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
color: var(--p-text-muted-color);
|
||||
|
||||
.pi {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--p-surface-400);
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-subtitle {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.user-dialog {
|
||||
.p-dialog-header {
|
||||
border-bottom: 1px solid var(--p-surface-border);
|
||||
}
|
||||
|
||||
.p-dialog-content {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.p-dialog-footer {
|
||||
border-top: 1px solid var(--p-surface-border);
|
||||
padding: 1rem 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
label {
|
||||
font-weight: 600;
|
||||
color: var(--p-text-color);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--p-primary-color);
|
||||
box-shadow: 0 0 0 2px var(--p-primary-color-alpha-20);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--p-text-muted-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
border-bottom: 1px solid var(--p-surface-border);
|
||||
}
|
||||
|
||||
.settings-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--p-text-color);
|
||||
margin: 0 0 0.75rem 0;
|
||||
|
||||
.pi {
|
||||
color: var(--p-primary-color);
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-description {
|
||||
color: var(--p-text-muted-color);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.endpoint-section {
|
||||
background: var(--p-content-background);
|
||||
border: 1px solid var(--p-surface-border);
|
||||
border-radius: 8px;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--p-text-color);
|
||||
|
||||
.pi {
|
||||
color: var(--p-primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.endpoint-form {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.endpoint-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.endpoint-input {
|
||||
min-width: 9rem;
|
||||
max-width: 50rem;
|
||||
}
|
||||
|
||||
.users-section {
|
||||
@media (min-width: 768px) {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.section-title-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import {Component, inject, OnDestroy, OnInit} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {Button} from 'primeng/button';
|
||||
import {InputText} from 'primeng/inputtext';
|
||||
import {API_CONFIG} from '../../config/api-config';
|
||||
import {Tooltip} from 'primeng/tooltip';
|
||||
import {TableModule} from 'primeng/table';
|
||||
import {Dialog} from 'primeng/dialog';
|
||||
import {FormsModule} from '@angular/forms';
|
||||
import {ConfirmDialog} from 'primeng/confirmdialog';
|
||||
import {ConfirmationService, MessageService} from 'primeng/api';
|
||||
import {OpdsUserV2, OpdsUserV2CreateRequest, OpdsV2Service} from './opds-v2.service';
|
||||
import {catchError, filter, takeUntil, tap} from 'rxjs/operators';
|
||||
import {UserService} from '../user-management/user.service';
|
||||
import {of, Subject} from 'rxjs';
|
||||
import {Password} from 'primeng/password';
|
||||
|
||||
@Component({
|
||||
selector: 'app-opds-settings-v2',
|
||||
imports: [
|
||||
CommonModule,
|
||||
Button,
|
||||
InputText,
|
||||
Tooltip,
|
||||
Dialog,
|
||||
FormsModule,
|
||||
ConfirmDialog,
|
||||
TableModule,
|
||||
Password
|
||||
],
|
||||
providers: [ConfirmationService, MessageService],
|
||||
templateUrl: './opds-settings-v2.html',
|
||||
styleUrl: './opds-settings-v2.scss'
|
||||
})
|
||||
export class OpdsSettingsV2 implements OnInit, OnDestroy {
|
||||
|
||||
opdsEndpoint = `${API_CONFIG.BASE_URL}/api/v1/opds/catalog`;
|
||||
|
||||
private opdsService = inject(OpdsV2Service);
|
||||
private confirmationService = inject(ConfirmationService);
|
||||
private messageService = inject(MessageService);
|
||||
private userService = inject(UserService);
|
||||
|
||||
users: OpdsUserV2[] = [];
|
||||
loading = false;
|
||||
showCreateUserDialog = false;
|
||||
newUser: OpdsUserV2CreateRequest = {username: '', password: ''};
|
||||
passwordVisibility: boolean[] = [];
|
||||
hasPermission = false;
|
||||
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
dummyPassword: string = "***********************";
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loading = true;
|
||||
|
||||
this.userService.userState$.pipe(
|
||||
filter(state => !!state?.user && state.loaded),
|
||||
takeUntil(this.destroy$),
|
||||
tap(state => {
|
||||
this.hasPermission = !!(state.user?.permissions.canAccessOpds || state.user?.permissions.admin);
|
||||
}),
|
||||
filter(() => this.hasPermission),
|
||||
tap(() => this.loadUsers())
|
||||
).subscribe();
|
||||
}
|
||||
|
||||
private loadUsers(): void {
|
||||
this.opdsService.getUser().pipe(
|
||||
takeUntil(this.destroy$),
|
||||
catchError(err => {
|
||||
console.error('Error loading users:', err);
|
||||
this.showMessage('error', 'Error', 'Failed to load users');
|
||||
return of([]);
|
||||
})
|
||||
).subscribe(users => {
|
||||
this.users = users;
|
||||
this.passwordVisibility = new Array(users.length).fill(false);
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
createUser(): void {
|
||||
if (!this.newUser.username || !this.newUser.password) return;
|
||||
|
||||
this.opdsService.createUser(this.newUser).pipe(
|
||||
takeUntil(this.destroy$),
|
||||
catchError(err => {
|
||||
console.error('Error creating user:', err);
|
||||
this.showMessage('error', 'Error', 'Failed to create user');
|
||||
return of(null);
|
||||
})
|
||||
).subscribe(user => {
|
||||
if (!user) return;
|
||||
this.users.push(user);
|
||||
this.resetCreateUserDialog();
|
||||
this.showMessage('success', 'Success', 'User created successfully');
|
||||
});
|
||||
}
|
||||
|
||||
confirmDelete(user: OpdsUserV2): void {
|
||||
this.confirmationService.confirm({
|
||||
message: `Are you sure you want to delete user "${user.username}"?`,
|
||||
header: 'Delete Confirmation',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptButtonStyleClass: 'p-button-danger',
|
||||
accept: () => this.deleteUser(user)
|
||||
});
|
||||
}
|
||||
|
||||
deleteUser(user: OpdsUserV2): void {
|
||||
if (!user.id) return;
|
||||
|
||||
this.opdsService.deleteCredential(user.id).pipe(
|
||||
takeUntil(this.destroy$),
|
||||
catchError(err => {
|
||||
console.error('Error deleting user:', err);
|
||||
this.showMessage('error', 'Error', 'Failed to delete user');
|
||||
return of(null);
|
||||
})
|
||||
).subscribe(() => {
|
||||
this.users = this.users.filter(u => u.id !== user.id);
|
||||
this.showMessage('success', 'Success', 'User deleted successfully');
|
||||
});
|
||||
}
|
||||
|
||||
cancelCreateUser(): void {
|
||||
this.resetCreateUserDialog();
|
||||
}
|
||||
|
||||
copyEndpoint(): void {
|
||||
navigator.clipboard.writeText(this.opdsEndpoint).then(() => {
|
||||
this.showMessage('success', 'Copied', 'OPDS endpoint copied to clipboard');
|
||||
});
|
||||
}
|
||||
|
||||
private resetCreateUserDialog(): void {
|
||||
this.showCreateUserDialog = false;
|
||||
this.newUser = {username: '', password: ''};
|
||||
}
|
||||
|
||||
private showMessage(severity: string, summary: string, detail: string): void {
|
||||
this.messageService.add({severity, summary, detail});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {Observable} from 'rxjs';
|
||||
import {API_CONFIG} from '../../config/api-config';
|
||||
|
||||
export interface OpdsUserV2CreateRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface OpdsUserV2 {
|
||||
id: number;
|
||||
userId: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class OpdsV2Service {
|
||||
|
||||
private readonly baseUrl = `${API_CONFIG.BASE_URL}/api/v2/opds-users`;
|
||||
private http = inject(HttpClient);
|
||||
|
||||
getUser(): Observable<OpdsUserV2[]> {
|
||||
return this.http.get<OpdsUserV2[]>(this.baseUrl);
|
||||
}
|
||||
|
||||
createUser(user: OpdsUserV2CreateRequest): Observable<OpdsUserV2> {
|
||||
return this.http.post<OpdsUserV2>(this.baseUrl, user);
|
||||
}
|
||||
|
||||
deleteCredential(id: number): Observable<void> {
|
||||
return this.http.delete<void>(`${this.baseUrl}/${id}`);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,17 @@
|
||||
style="cursor: pointer;">
|
||||
</i>
|
||||
</p>
|
||||
|
||||
<div class="px-4 mb-4">
|
||||
<div class="border-l-8 border border-red-500 text-red-400 p-3 rounded-md flex items-center gap-2">
|
||||
<i class="pi pi-exclamation-triangle mt-1"></i>
|
||||
<p class="text-sm">
|
||||
<b class="text-red-500">Deprecated:</b> OPDS (v1) support will be removed in a future release.
|
||||
Please migrate to <b>OPDS v2</b> for continued support and improvements.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col space-y-6 p-4 m-4 custom-border">
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
@@ -53,7 +64,7 @@
|
||||
</p-button>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<p-table [value]="users" [responsiveLayout]="'scroll'">
|
||||
<p-table [value]="users">
|
||||
<ng-template pTemplate="header">
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
@@ -10,10 +10,10 @@ import {MessageService} from 'primeng/api';
|
||||
import {filter, take} from 'rxjs/operators';
|
||||
|
||||
import {OpdsUser, OpdsUserService} from './opds-user.service';
|
||||
import {AppSettingsService} from '../../../core/service/app-settings.service';
|
||||
import {AppSettingKey, AppSettings} from '../../../core/model/app-settings.model';
|
||||
import {AppSettingsService} from '../../core/service/app-settings.service';
|
||||
import {AppSettingKey, AppSettings} from '../../core/model/app-settings.model';
|
||||
import {Password} from 'primeng/password';
|
||||
import {API_CONFIG} from '../../../config/api-config';
|
||||
import {API_CONFIG} from '../../config/api-config';
|
||||
import {ToggleSwitch} from 'primeng/toggleswitch';
|
||||
|
||||
@Component({
|
||||
@@ -1,5 +1,5 @@
|
||||
import {inject, Injectable} from '@angular/core';
|
||||
import {API_CONFIG} from '../../../config/api-config';
|
||||
import {API_CONFIG} from '../../config/api-config';
|
||||
import {HttpClient} from '@angular/common/http';
|
||||
import {Observable} from 'rxjs';
|
||||
|
||||
@@ -29,9 +29,12 @@
|
||||
<i class="pi pi-lock"></i> Authentication
|
||||
</p-tab>
|
||||
<p-tab [value]="SettingsTab.Opds">
|
||||
<i class="pi pi-globe"></i> OPDS
|
||||
<i class="pi pi-globe"></i> OPDS (Deprecated)
|
||||
</p-tab>
|
||||
}
|
||||
<p-tab [value]="SettingsTab.OpdsV2">
|
||||
<i class="pi pi-globe"></i> OPDS V2
|
||||
</p-tab>
|
||||
<p-tab [value]="SettingsTab.DeviceSettings">
|
||||
<i class="pi pi-mobile"></i> Devices
|
||||
</p-tab>
|
||||
@@ -66,6 +69,9 @@
|
||||
<app-opds-settings></app-opds-settings>
|
||||
</p-tabpanel>
|
||||
}
|
||||
<p-tabpanel [value]="SettingsTab.OpdsV2">
|
||||
<app-opds-settings-v2></app-opds-settings-v2>
|
||||
</p-tabpanel>
|
||||
<p-tabpanel [value]="SettingsTab.DeviceSettings">
|
||||
<app-device-settings-component></app-device-settings-component>
|
||||
</p-tabpanel>
|
||||
|
||||
@@ -11,9 +11,10 @@ import {AuthenticationSettingsComponent} from '../core/security/oauth2-managemen
|
||||
import {ViewPreferencesParentComponent} from './view-preferences-parent/view-preferences-parent.component';
|
||||
import {ReaderPreferences} from './reader-preferences/reader-preferences.component';
|
||||
import {MetadataSettingsComponent} from './metadata-settings-component/metadata-settings-component';
|
||||
import {OpdsSettingsComponent} from './global-preferences/opds-settings/opds-settings.component';
|
||||
import {OpdsSettingsComponent} from './opds-settings/opds-settings.component';
|
||||
import {DeviceSettingsComponent} from './device-settings-component/device-settings-component';
|
||||
import {FileNamingPatternComponent} from './file-naming-pattern/file-naming-pattern.component';
|
||||
import {OpdsSettingsV2} from './opds-settings-v2/opds-settings-v2';
|
||||
|
||||
export enum SettingsTab {
|
||||
ReaderSettings = 'reader',
|
||||
@@ -25,6 +26,7 @@ export enum SettingsTab {
|
||||
MetadataSettings = 'metadata',
|
||||
ApplicationSettings = 'application',
|
||||
AuthenticationSettings = 'authentication',
|
||||
OpdsV2 = 'opds-v2',
|
||||
Opds = 'opds'
|
||||
}
|
||||
|
||||
@@ -46,7 +48,8 @@ export enum SettingsTab {
|
||||
MetadataSettingsComponent,
|
||||
OpdsSettingsComponent,
|
||||
DeviceSettingsComponent,
|
||||
FileNamingPatternComponent
|
||||
FileNamingPatternComponent,
|
||||
OpdsSettingsV2
|
||||
],
|
||||
templateUrl: './settings.component.html',
|
||||
styleUrl: './settings.component.scss'
|
||||
|
||||
@@ -91,6 +91,11 @@
|
||||
<label>Delete Book</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<p-checkbox formControlName="permissionAccessOpds" [binary]="true"></p-checkbox>
|
||||
<label>Access OPDS</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<p-checkbox formControlName="permissionSyncKoreader" [binary]="true"></p-checkbox>
|
||||
<label>KOReader Sync</label>
|
||||
|
||||
@@ -50,6 +50,7 @@ export class CreateUserDialogComponent implements OnInit {
|
||||
permissionManipulateLibrary: [false],
|
||||
permissionEmailBook: [false],
|
||||
permissionDeleteBook: [false],
|
||||
permissionAccessOpds: [false],
|
||||
permissionSyncKoreader: [false],
|
||||
permissionSyncKobo: [false],
|
||||
permissionAdmin: [false],
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<th style="width: 80px;">Manage Library</th>
|
||||
<th style="width: 80px;">Email Books</th>
|
||||
<th style="width: 80px;">Delete Books</th>
|
||||
<th style="width: 80px;">Access OPDS</th>
|
||||
<th style="width: 80px;">KOReader Sync</th>
|
||||
<th style="width: 80px;">Kobo Sync</th>
|
||||
<th style="width: 120px;">Edit</th>
|
||||
@@ -127,6 +128,14 @@
|
||||
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canDeleteBook" disabled></p-checkbox>
|
||||
}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
@if (user.isEditing) {
|
||||
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canAccessOpds"></p-checkbox>
|
||||
}
|
||||
@if (!user.isEditing) {
|
||||
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canAccessOpds" disabled></p-checkbox>
|
||||
}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
@if (user.isEditing) {
|
||||
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canSyncKoReader"></p-checkbox>
|
||||
|
||||
@@ -107,6 +107,7 @@ export interface User {
|
||||
canManipulateLibrary: boolean;
|
||||
canSyncKoReader: boolean;
|
||||
canSyncKobo: boolean;
|
||||
canAccessOpds: boolean;
|
||||
};
|
||||
userSettings: UserSettings;
|
||||
provisioningMethod?: 'LOCAL' | 'OIDC' | 'REMOTE';
|
||||
|
||||
@@ -5,7 +5,7 @@ import {FormBuilder, FormGroup, FormsModule, ReactiveFormsModule, Validators} fr
|
||||
import {InputText} from 'primeng/inputtext';
|
||||
|
||||
import {Password} from 'primeng/password';
|
||||
import {User, UserService} from '../../user-management/user.service';
|
||||
import {User, UserService} from '../user-management/user.service';
|
||||
import {MessageService} from 'primeng/api';
|
||||
import {Subject} from 'rxjs';
|
||||
import {Message} from 'primeng/message';
|
||||
Reference in New Issue
Block a user