mirror of
https://github.com/booklore-app/booklore.git
synced 2026-02-18 00:17:53 +01:00
Lots of permission related stuffs
This commit is contained in:
@@ -10,6 +10,7 @@ import org.mapstruct.Mapping;
|
||||
public interface BookLoreUserMapper {
|
||||
|
||||
@Mapping(source = "permissions", target = "permissions")
|
||||
@Mapping(source = "libraries", target = "assignedLibraries")
|
||||
BookLoreUser toDto(BookLoreUserEntity entity);
|
||||
|
||||
default BookLoreUser.UserPermissions mapPermissions(UserPermissionsEntity permissions) {
|
||||
@@ -22,7 +23,6 @@ public interface BookLoreUserMapper {
|
||||
dto.setCanDownload(permissions.isPermissionDownload());
|
||||
dto.setCanManipulateLibrary(permissions.isPermissionManipulateLibrary());
|
||||
dto.setCanEditMetadata(permissions.isPermissionEditMetadata());
|
||||
dto.setCanEditMetadata(permissions.isPermissionEditMetadata());
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,15 @@ package com.adityachandel.booklore.model.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class BookLoreUser {
|
||||
private Long id;
|
||||
private String username;
|
||||
private String name;
|
||||
private String email;
|
||||
private List<Library> assignedLibraries;
|
||||
private UserPermissions permissions;
|
||||
|
||||
@Data
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.adityachandel.booklore.model.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
@Data
|
||||
public class UserCreateRequest {
|
||||
private String username;
|
||||
@@ -13,4 +15,6 @@ public class UserCreateRequest {
|
||||
private boolean permissionDownload;
|
||||
private boolean permissionEditMetadata;
|
||||
private boolean permissionManipulateLibrary;
|
||||
|
||||
private Set<Long> selectedLibraries;
|
||||
}
|
||||
@@ -2,11 +2,14 @@ package com.adityachandel.booklore.model.dto.request;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
public class UserUpdateRequest {
|
||||
private String name;
|
||||
private String email;
|
||||
private Permissions permissions;
|
||||
private List<Long> assignedLibraries;
|
||||
|
||||
@Data
|
||||
public static class Permissions {
|
||||
|
||||
@@ -5,6 +5,7 @@ import lombok.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@Getter
|
||||
@@ -44,6 +45,14 @@ public class BookLoreUserEntity {
|
||||
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
|
||||
private Set<ShelfEntity> shelves = new HashSet<>();
|
||||
|
||||
@ManyToMany
|
||||
@JoinTable(
|
||||
name = "user_library_mapping",
|
||||
joinColumns = @JoinColumn(name = "user_id"),
|
||||
inverseJoinColumns = @JoinColumn(name = "library_id")
|
||||
)
|
||||
private List<LibraryEntity> libraries;
|
||||
|
||||
@PrePersist
|
||||
public void prePersist() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
@@ -31,6 +31,9 @@ public class LibraryEntity {
|
||||
@OneToMany(mappedBy = "library", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER)
|
||||
private List<LibraryPathEntity> libraryPaths;
|
||||
|
||||
@ManyToMany(mappedBy = "libraries")
|
||||
private List<BookLoreUserEntity> users;
|
||||
|
||||
private boolean watch;
|
||||
|
||||
private String icon;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.adityachandel.booklore.repository;
|
||||
|
||||
import com.adityachandel.booklore.model.entity.BookEntity;
|
||||
import com.adityachandel.booklore.model.entity.LibraryEntity;
|
||||
import com.adityachandel.booklore.model.entity.LibraryPathEntity;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
@@ -14,6 +15,7 @@ import org.springframework.stereotype.Repository;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
@Repository
|
||||
public interface BookRepository extends JpaRepository<BookEntity, Long>, JpaSpecificationExecutor<BookEntity> {
|
||||
@@ -29,14 +31,16 @@ public interface BookRepository extends JpaRepository<BookEntity, Long>, JpaSpec
|
||||
|
||||
Optional<BookEntity> findBookByFileNameAndLibraryId(String fileName, long libraryId);
|
||||
|
||||
@Query("SELECT b FROM BookEntity b JOIN b.metadata bm WHERE LOWER(bm.title) LIKE LOWER(CONCAT('%', :title, '%'))")
|
||||
List<BookEntity> findByTitleContainingIgnoreCase(@Param("title") String title);
|
||||
|
||||
@Query("SELECT b FROM BookEntity b JOIN b.shelves s WHERE s.id = :shelfId")
|
||||
List<BookEntity> findByShelfId(@Param("shelfId") Long shelfId);
|
||||
|
||||
@Modifying
|
||||
@Query("DELETE FROM BookEntity b WHERE b.id IN (:ids)")
|
||||
void deleteByIdIn(Collection<Long> ids);
|
||||
|
||||
@Query("SELECT b FROM BookEntity b WHERE b.library IN (SELECT l FROM LibraryEntity l WHERE l IN :userLibraries)")
|
||||
List<BookEntity> findBooksByUserLibraries(@Param("userLibraries") List<LibraryEntity> userLibraries);
|
||||
|
||||
List<BookEntity> findByLibraryIdIn(Set<Long> userLibraryIds);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,14 @@ package com.adityachandel.booklore.repository;
|
||||
import com.adityachandel.booklore.model.entity.LibraryEntity;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface LibraryRepository extends JpaRepository<LibraryEntity, Long>, JpaSpecificationExecutor<LibraryEntity> {
|
||||
|
||||
List<LibraryEntity> findByIdIn(List<Long> ids);
|
||||
}
|
||||
|
||||
@@ -97,8 +97,29 @@ public class BooksService {
|
||||
}
|
||||
|
||||
public List<Book> getBooks(boolean withDescription) {
|
||||
return bookRepository.findAll().stream()
|
||||
.map(bookEntity -> bookMapper.toBookWithDescription(bookEntity, withDescription))
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
BookLoreUserEntity userEntity = userRepository.findById(user.getId()).orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
||||
|
||||
List<BookEntity> books;
|
||||
if (userEntity.getPermissions().isPermissionAdmin()) {
|
||||
books = bookRepository.findAll();
|
||||
} else {
|
||||
Set<Long> userLibraryIds = userEntity.getLibraries().stream()
|
||||
.map(LibraryEntity::getId)
|
||||
.collect(Collectors.toSet());
|
||||
books = bookRepository.findByLibraryIdIn(userLibraryIds);
|
||||
}
|
||||
|
||||
return books.stream()
|
||||
.map(bookEntity -> {
|
||||
UserBookProgressEntity userProgress = userBookProgressRepository.findByUserIdAndBookId(user.getId(), bookEntity.getId())
|
||||
.orElse(new UserBookProgressEntity());
|
||||
Book book = bookMapper.toBookWithDescription(bookEntity, withDescription);
|
||||
book.setLastReadTime(userProgress.getLastReadTime());
|
||||
book.setPdfProgress(userProgress.getPdfProgress());
|
||||
book.setEpubProgress(userProgress.getEpubProgress());
|
||||
return book;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
|
||||
@@ -4,16 +4,19 @@ import com.adityachandel.booklore.exception.ApiError;
|
||||
import com.adityachandel.booklore.mapper.BookMapper;
|
||||
import com.adityachandel.booklore.mapper.LibraryMapper;
|
||||
import com.adityachandel.booklore.model.dto.Book;
|
||||
import com.adityachandel.booklore.model.dto.BookLoreUser;
|
||||
import com.adityachandel.booklore.model.dto.Library;
|
||||
import com.adityachandel.booklore.model.dto.LibraryPath;
|
||||
import com.adityachandel.booklore.model.dto.request.CreateLibraryRequest;
|
||||
import com.adityachandel.booklore.model.entity.BookEntity;
|
||||
import com.adityachandel.booklore.model.entity.BookLoreUserEntity;
|
||||
import com.adityachandel.booklore.model.entity.LibraryEntity;
|
||||
import com.adityachandel.booklore.model.entity.LibraryPathEntity;
|
||||
import com.adityachandel.booklore.model.websocket.Topic;
|
||||
import com.adityachandel.booklore.repository.BookRepository;
|
||||
import com.adityachandel.booklore.repository.LibraryPathRepository;
|
||||
import com.adityachandel.booklore.repository.LibraryRepository;
|
||||
import com.adityachandel.booklore.repository.UserRepository;
|
||||
import com.adityachandel.booklore.service.fileprocessor.FileProcessingUtils;
|
||||
import com.adityachandel.booklore.service.monitoring.MonitoringService;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
@@ -21,6 +24,7 @@ import jakarta.transaction.Transactional;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.dao.InvalidDataAccessApiUsageException;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -45,6 +49,8 @@ public class LibraryService {
|
||||
private final NotificationService notificationService;
|
||||
private final FileProcessingUtils fileProcessingUtils;
|
||||
private final MonitoringService monitoringService;
|
||||
private final AuthenticationService authenticationService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@Transactional
|
||||
@PostConstruct
|
||||
@@ -188,7 +194,15 @@ public class LibraryService {
|
||||
}
|
||||
|
||||
public List<Library> getLibraries() {
|
||||
List<LibraryEntity> libraries = libraryRepository.findAll();
|
||||
BookLoreUser user = authenticationService.getAuthenticatedUser();
|
||||
BookLoreUserEntity userEntity = userRepository.findById(user.getId()).orElseThrow(() -> new UsernameNotFoundException("User not found"));
|
||||
List<LibraryEntity> libraries;
|
||||
if (userEntity.getPermissions().isPermissionAdmin()) {
|
||||
libraries = libraryRepository.findAll();
|
||||
} else {
|
||||
List<Long> libraryIds = userEntity.getLibraries().stream().map(LibraryEntity::getId).toList();
|
||||
libraries = libraryRepository.findByIdIn(libraryIds);
|
||||
}
|
||||
return libraries.stream().map(libraryMapper::toLibrary).toList();
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,11 @@ import com.adityachandel.booklore.model.dto.UserCreateRequest;
|
||||
import com.adityachandel.booklore.model.dto.request.UserLoginRequest;
|
||||
import com.adityachandel.booklore.model.dto.request.UserUpdateRequest;
|
||||
import com.adityachandel.booklore.model.entity.BookLoreUserEntity;
|
||||
import com.adityachandel.booklore.model.entity.LibraryEntity;
|
||||
import com.adityachandel.booklore.model.entity.UserPermissionsEntity;
|
||||
import com.adityachandel.booklore.repository.LibraryRepository;
|
||||
import com.adityachandel.booklore.repository.UserRepository;
|
||||
import jakarta.transaction.Transactional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -18,10 +21,7 @@ import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
@@ -31,8 +31,10 @@ public class UserService {
|
||||
private final UserRepository userRepository;
|
||||
private final BookLoreUserMapper bookLoreUserMapper;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final LibraryRepository libraryRepository;
|
||||
private final JwtUtils jwtUtils;
|
||||
|
||||
@Transactional
|
||||
public ResponseEntity<Map<String, String>> registerUser(UserCreateRequest request) {
|
||||
Optional<BookLoreUserEntity> existingUser = userRepository.findByUsername(request.getUsername());
|
||||
if (existingUser.isPresent()) {
|
||||
@@ -50,10 +52,14 @@ public class UserService {
|
||||
permissions.setPermissionUpload(request.isPermissionUpload());
|
||||
permissions.setPermissionDownload(request.isPermissionDownload());
|
||||
permissions.setPermissionEditMetadata(request.isPermissionEditMetadata());
|
||||
permissions.setPermissionEditMetadata(request.isPermissionManipulateLibrary());
|
||||
|
||||
permissions.setPermissionManipulateLibrary(request.isPermissionManipulateLibrary());
|
||||
bookLoreUserEntity.setPermissions(permissions);
|
||||
|
||||
if (request.getSelectedLibraries() != null && !request.getSelectedLibraries().isEmpty()) {
|
||||
List<LibraryEntity> libraries = libraryRepository.findAllById(request.getSelectedLibraries());
|
||||
bookLoreUserEntity.setLibraries(new ArrayList<>(libraries));
|
||||
}
|
||||
|
||||
userRepository.save(bookLoreUserEntity);
|
||||
|
||||
String token = jwtUtils.generateToken(bookLoreUserEntity);
|
||||
@@ -84,11 +90,19 @@ public class UserService {
|
||||
|
||||
public BookLoreUser updateUser(Long id, UserUpdateRequest updateRequest) {
|
||||
BookLoreUserEntity user = userRepository.findById(id).orElseThrow(() -> ApiError.USER_NOT_FOUND.createException(id));
|
||||
|
||||
user.setName(updateRequest.getName());
|
||||
user.setEmail(updateRequest.getEmail());
|
||||
user.getPermissions().setPermissionUpload(updateRequest.getPermissions().isCanUpload());
|
||||
user.getPermissions().setPermissionDownload(updateRequest.getPermissions().isCanDownload());
|
||||
user.getPermissions().setPermissionEditMetadata(updateRequest.getPermissions().isCanEditMetadata());
|
||||
|
||||
List<Long> libraryIds = updateRequest.getAssignedLibraries();
|
||||
if (libraryIds != null) {
|
||||
List<LibraryEntity> updatedLibraries = libraryRepository.findAllById(libraryIds);
|
||||
user.setLibraries(updatedLibraries);
|
||||
}
|
||||
|
||||
userRepository.save(user);
|
||||
return bookLoreUserMapper.toDto(user);
|
||||
}
|
||||
|
||||
@@ -206,4 +206,14 @@ CREATE TABLE IF NOT EXISTS user_book_progress
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_book_progress_user ON user_book_progress (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_book_progress_book ON user_book_progress (book_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_book_progress_book ON user_book_progress (book_id);
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_library_mapping
|
||||
(
|
||||
user_id BIGINT NOT NULL,
|
||||
library_id BIGINT NOT NULL,
|
||||
PRIMARY KEY (user_id, library_id),
|
||||
CONSTRAINT fk_user_library_mapping_user FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_user_library_mapping_library FOREIGN KEY (library_id) REFERENCES library (id) ON DELETE CASCADE
|
||||
);
|
||||
@@ -7,6 +7,8 @@ import {BookService} from './book.service';
|
||||
import {SortOption} from '../model/sort.model';
|
||||
import {LibraryState} from '../model/state/library-state.model';
|
||||
import {API_CONFIG} from '../../config/api-config';
|
||||
import {FetchMetadataRequest} from '../../metadata/model/request/fetch-metadata-request.model';
|
||||
import {BookMetadata} from '../model/book.model';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
@@ -138,4 +140,8 @@ export class LibraryService {
|
||||
getLibrariesFromState(): Library[] {
|
||||
return this.libraryStateSubject.value.libraries || [];
|
||||
}
|
||||
|
||||
getAllLibrariesFromAPI(): Observable<Library[]> {
|
||||
return this.http.get <Library[]>(`${this.url}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<th>Username</th>
|
||||
<th>Full Name</th>
|
||||
<th>Email</th>
|
||||
<th>Assigned Libraries</th>
|
||||
<th style="width: 100px;">Can Upload</th>
|
||||
<th style="width: 100px;">Can Download</th>
|
||||
<th style="width: 100px;">Can Edit Metadata</th>
|
||||
@@ -26,6 +27,20 @@
|
||||
<input *ngIf="user.isEditing" type="email" [(ngModel)]="user.email" class="p-inputtext w-full"/>
|
||||
<span *ngIf="!user.isEditing">{{ user.email }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<p-multiSelect
|
||||
*ngIf="user.isEditing"
|
||||
[options]="allLibraries"
|
||||
optionLabel="name"
|
||||
optionValue="id"
|
||||
[(ngModel)]="editingLibraryIds"
|
||||
placeholder="Select Libraries"
|
||||
appendTo="body">
|
||||
</p-multiSelect>
|
||||
<span *ngIf="!user.isEditing">
|
||||
{{ user.libraryNames }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canUpload" *ngIf="user.isEditing"></p-checkbox>
|
||||
<p-checkbox [binary]="true" [(ngModel)]="user.permissions.canUpload" disabled *ngIf="!user.isEditing"></p-checkbox>
|
||||
|
||||
@@ -8,23 +8,37 @@ import {NgIf, NgStyle} from '@angular/common';
|
||||
import {User, UserService} from '../../../../user.service';
|
||||
import {MessageService} from 'primeng/api';
|
||||
import {Checkbox} from 'primeng/checkbox';
|
||||
import {MultiSelect} from 'primeng/multiselect';
|
||||
import {Library} from '../../../../book/model/library.model';
|
||||
import {LibraryService} from '../../../../book/service/library.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin',
|
||||
imports: [FormsModule, Button, TableModule, NgIf, Checkbox, NgStyle],
|
||||
imports: [FormsModule, Button, TableModule, NgIf, Checkbox, NgStyle, MultiSelect],
|
||||
templateUrl: './admin.component.html',
|
||||
styleUrl: './admin.component.scss',
|
||||
styleUrls: ['./admin.component.scss'],
|
||||
})
|
||||
export class AdminComponent implements OnInit {
|
||||
ref: DynamicDialogRef | undefined;
|
||||
private dialogService = inject(DialogService);
|
||||
private userService = inject(UserService);
|
||||
private libraryService = inject(LibraryService);
|
||||
private messageService = inject(MessageService);
|
||||
|
||||
users: any[] = [];
|
||||
users: User[] = [];
|
||||
editingLibraryIds: number[] = [];
|
||||
allLibraries: Library[] = [];
|
||||
|
||||
ngOnInit() {
|
||||
this.loadUsers();
|
||||
this.libraryService.getAllLibrariesFromAPI().subscribe({
|
||||
next: (libraries) => {
|
||||
this.allLibraries = libraries;
|
||||
},
|
||||
error: () => {
|
||||
this.messageService.add({severity: 'error', summary: 'Error', detail: 'Failed to load libraries'});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadUsers() {
|
||||
@@ -33,6 +47,8 @@ export class AdminComponent implements OnInit {
|
||||
this.users = data.map(user => ({
|
||||
...user,
|
||||
isEditing: false,
|
||||
selectedLibraryIds: user.assignedLibraries?.map(lib => lib.id) || [],
|
||||
libraryNames: user.assignedLibraries?.map(lib => lib.name).join(', ') || ''
|
||||
}));
|
||||
},
|
||||
error: () => {
|
||||
@@ -57,19 +73,24 @@ export class AdminComponent implements OnInit {
|
||||
|
||||
toggleEdit(user: any) {
|
||||
user.isEditing = !user.isEditing;
|
||||
if (!user.isEditing) {
|
||||
this.loadUsers();
|
||||
if (user.isEditing) {
|
||||
this.editingLibraryIds = [...user.selectedLibraryIds];
|
||||
} else {
|
||||
user.libraryNames = user.assignedLibraries?.map((lib: Library) => lib.name).join(', ') || '';
|
||||
}
|
||||
}
|
||||
|
||||
saveUser(user: any) {
|
||||
user.selectedLibraryIds = [...this.editingLibraryIds];
|
||||
this.userService.updateUser(user.id, {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
permissions: user.permissions,
|
||||
assignedLibraries: user.selectedLibraryIds
|
||||
}).subscribe({
|
||||
next: () => {
|
||||
user.isEditing = false;
|
||||
this.loadUsers();
|
||||
this.messageService.add({severity: 'success', summary: 'Success', detail: 'User updated successfully'});
|
||||
},
|
||||
error: () => {
|
||||
|
||||
@@ -61,7 +61,10 @@ export class CreateUserDialogComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
const userData = this.userForm.value;
|
||||
const userData = {
|
||||
...this.userForm.value,
|
||||
selectedLibraries: this.userForm.value.selectedLibraries.map((lib: Library) => lib.id)
|
||||
};
|
||||
|
||||
this.userService.createUser(userData).subscribe({
|
||||
next: response => {
|
||||
|
||||
@@ -4,12 +4,14 @@ import {BehaviorSubject, Observable} from 'rxjs';
|
||||
import {API_CONFIG} from './config/api-config';
|
||||
import {jwtDecode} from 'jwt-decode';
|
||||
import {RxStompService} from './shared/websocket/rx-stomp.service';
|
||||
import {Library} from './book/model/library.model';
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
name: string;
|
||||
email: string;
|
||||
assignedLibraries: Library[];
|
||||
permissions: {
|
||||
admin: boolean;
|
||||
canUpload: boolean;
|
||||
|
||||
Reference in New Issue
Block a user