Compare commits

...

6 Commits

Author SHA1 Message Date
Marcel
37f5c3d005 feat(db): add migration to grant ANNOTATE_ALL to existing admin groups
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 2m27s
CI / Backend Unit Tests (pull_request) Successful in 2m12s
CI / E2E Tests (pull_request) Successful in 23m43s
CI / Unit & Component Tests (push) Successful in 2m28s
CI / Backend Unit Tests (push) Successful in 2m15s
CI / E2E Tests (push) Successful in 22m17s
Covers existing deployments where the Administrators group was created
before DataInitializer started including ANNOTATE_ALL.

Refs #40
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 08:52:32 +01:00
Marcel
eb8bcdb426 fix(frontend): make annotation delete button fully opaque
Some checks failed
CI / Backend Unit Tests (pull_request) Successful in 2m15s
CI / E2E Tests (pull_request) Successful in 22m58s
CI / Unit & Component Tests (push) Has been cancelled
CI / Backend Unit Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Successful in 2m29s
Replace opacity: 0.3 on the annotation container with an rgba
background so child elements (the × button) are not affected by
the parent's opacity and render at full opacity.

Refs #40
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 08:49:52 +01:00
Marcel
05f3ce687f test(e2e): rewrite PDF viewer and annotation beforeAll to use API calls
Some checks failed
CI / E2E Tests (pull_request) Waiting to run
CI / Unit & Component Tests (push) Successful in 2m27s
CI / Backend Unit Tests (push) Successful in 2m17s
CI / Unit & Component Tests (pull_request) Has been cancelled
CI / Backend Unit Tests (pull_request) Has been cancelled
CI / E2E Tests (push) Has started running
- Replace UI-based document setup in beforeAll hooks with direct API
  calls via Playwright's request fixture — avoids the 90s timeout from
  navigating + uploading through the Docker dev server
- Fix non-PDF test: create a file-less document in beforeAll instead of
  relying on seed data that may not exist
- Share annotationDocId across describe blocks so the read-only user
  test can navigate to a known PDF document
- Add annotation visibility check before enabling annotate mode in the
  delete test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 08:26:59 +01:00
Marcel
06e846f2f8 fix(frontend): use closest() to skip pointer capture on annotation children
When a child element inside an annotation div (e.g. the delete button)
was clicked, the AnnotationLayer's pointerdown handler would call
setPointerCapture, preventing the child's click event from firing.
Using closest('[data-annotation]') instead of checking dataset.annotation
on the target directly fixes delete buttons inside annotation elements.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 08:26:26 +01:00
Marcel
ea1c097ae0 fix(e2e): activate e2e profile in dev mode and create reader user idempotently
- Add e2e to the dev Maven profile's spring.profiles.active so
  DataInitializer always runs when developing/testing locally
- Create the reader test user independently of the person-seed guard
  so it survives restarts where seed data already exists
- Set SPRING_PROFILES_ACTIVE=dev,e2e in docker-compose backend service

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 08:25:54 +01:00
Marcel
b45ec744b2 feat: add PDF annotation feature (#40)
Backend:
- Add ANNOTATE_ALL permission
- Add ANNOTATION_NOT_FOUND and ANNOTATION_OVERLAP error codes
- V10 migration: document_annotations table with page/rect/color/owner
- DocumentAnnotation entity, AnnotationRepository, CreateAnnotationDTO
- AnnotationService: overlap detection (rectangle intersection), ownership enforcement on delete
- AnnotationController: GET (authenticated), POST/DELETE (ANNOTATE_ALL)
- 15 new tests (AnnotationServiceTest, AnnotationControllerTest) — TDD red/green

Frontend:
- AnnotationLayer.svelte: pointer-event drawing, colored rect overlays, delete buttons
- PdfViewer.svelte: annotate toggle, color picker, loads/saves/deletes annotations via API
- Disabled annotate button with tooltip for users without ANNOTATE_ALL
- canAnnotate exposed from layout server, passed to PdfViewer
- errors.ts + de/en/es translations for new error codes
- 3 new unit tests for AnnotationLayer — TDD red/green

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 23:27:21 +01:00
33 changed files with 1123 additions and 56 deletions

View File

@@ -148,7 +148,7 @@
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<spring.profiles.active>dev</spring.profiles.active>
<spring.profiles.active>dev,e2e</spring.profiles.active>
</properties>
</profile>
<profile>

View File

@@ -49,7 +49,7 @@ public class DataInitializer {
// 1. Admin Gruppe erstellen
UserGroup adminGroup = UserGroup.builder()
.name("Administrators")
.permissions(Set.of("ADMIN", "READ_ALL", "WRITE_ALL", "ADMIN_USER", "ADMIN_TAG", "ADMIN_PERMISSION"))
.permissions(Set.of("ADMIN", "READ_ALL", "WRITE_ALL", "ANNOTATE_ALL", "ADMIN_USER", "ADMIN_TAG", "ADMIN_PERMISSION"))
.build();
groupRepository.save(adminGroup);
@@ -84,8 +84,24 @@ public class DataInitializer {
TagRepository tagRepo,
PasswordEncoder passwordEncoder) {
return args -> {
// Always ensure the read-only test user exists, even when seed data was already loaded.
if (userRepository.findByUsername("reader").isEmpty()) {
log.info("E2E seed: Erstelle 'reader'-Testbenutzer...");
UserGroup leserGroup = groupRepository.findByName("Leser").orElseGet(() ->
groupRepository.save(UserGroup.builder()
.name("Leser")
.permissions(Set.of("READ_ALL"))
.build()));
userRepository.save(AppUser.builder()
.username("reader")
.password(passwordEncoder.encode("reader123"))
.groups(Set.of(leserGroup))
.build());
log.info("E2E seed: 'reader'-Testbenutzer erstellt.");
}
if (personRepo.count() > 0) {
log.info("E2E seed: Daten bereits vorhanden, überspringe.");
log.info("E2E seed: Personendaten bereits vorhanden, überspringe Dokument-Seed.");
return;
}
@@ -166,19 +182,6 @@ public class DataInitializer {
.receivers(Set.of(otto))
.build());
// ── Read-only user (for permissions E2E tests) ───────────────────
// Username: reader / Password: reader123
// Has only READ_ALL — used to assert write controls are absent.
UserGroup leserGroup = groupRepository.save(UserGroup.builder()
.name("Leser")
.permissions(Set.of("READ_ALL"))
.build());
userRepository.save(AppUser.builder()
.username("reader")
.password(passwordEncoder.encode("reader123"))
.groups(Set.of(leserGroup))
.build());
log.info("E2E seed: {} Personen, {} Tags, {} Dokumente, {} Benutzer erstellt.",
personRepo.count(), tagRepo.count(), docRepo.count(), userRepository.count());
};

View File

@@ -0,0 +1,67 @@
package org.raddatz.familienarchiv.controller;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.raddatz.familienarchiv.dto.CreateAnnotationDTO;
import org.raddatz.familienarchiv.model.AppUser;
import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.raddatz.familienarchiv.security.Permission;
import org.raddatz.familienarchiv.security.RequirePermission;
import org.raddatz.familienarchiv.service.AnnotationService;
import org.raddatz.familienarchiv.service.UserService;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/documents/{documentId}/annotations")
@RequiredArgsConstructor
@Slf4j
public class AnnotationController {
private final AnnotationService annotationService;
private final UserService userService;
@GetMapping
public List<DocumentAnnotation> listAnnotations(@PathVariable UUID documentId) {
return annotationService.listAnnotations(documentId);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
@RequirePermission(Permission.ANNOTATE_ALL)
public DocumentAnnotation createAnnotation(
@PathVariable UUID documentId,
@RequestBody CreateAnnotationDTO dto,
Authentication authentication) {
UUID userId = resolveUserId(authentication);
return annotationService.createAnnotation(documentId, dto, userId);
}
@DeleteMapping("/{annotationId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
@RequirePermission(Permission.ANNOTATE_ALL)
public void deleteAnnotation(
@PathVariable UUID documentId,
@PathVariable UUID annotationId,
Authentication authentication) {
UUID userId = resolveUserId(authentication);
annotationService.deleteAnnotation(documentId, annotationId, userId);
}
// ─── private helpers ──────────────────────────────────────────────────────
private UUID resolveUserId(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) return null;
try {
AppUser user = userService.findByUsername(authentication.getName());
return user != null ? user.getId() : null;
} catch (Exception e) {
log.warn("Could not resolve user for annotation: {}", e.getMessage());
return null;
}
}
}

View File

@@ -0,0 +1,17 @@
package org.raddatz.familienarchiv.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class CreateAnnotationDTO {
private int pageNumber;
private double x;
private double y;
private double width;
private double height;
private String color;
}

View File

@@ -38,6 +38,12 @@ public enum ErrorCode {
/** The password-reset token is missing, expired, or already used. 400 */
INVALID_RESET_TOKEN,
// --- Annotations ---
/** The annotation with the given ID does not exist. 404 */
ANNOTATION_NOT_FOUND,
/** The new annotation overlaps an existing one on the same page. 409 */
ANNOTATION_OVERLAP,
// --- Generic ---
/** Request validation failed (missing or malformed fields). 400 */
VALIDATION_ERROR,

View File

@@ -0,0 +1,59 @@
package org.raddatz.familienarchiv.model;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "document_annotations")
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class DocumentAnnotation {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID id;
@Column(name = "document_id", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private UUID documentId;
@Column(name = "page_number", nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private int pageNumber;
@Column(nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private double x;
@Column(nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private double y;
@Column(nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private double width;
@Column(nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private double height;
@Column(nullable = false)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private String color;
@Column(name = "created_by")
private UUID createdBy;
@Column(name = "created_at", nullable = false, updatable = false)
@CreationTimestamp
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,17 @@
package org.raddatz.familienarchiv.repository;
import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface AnnotationRepository extends JpaRepository<DocumentAnnotation, UUID> {
List<DocumentAnnotation> findByDocumentId(UUID documentId);
List<DocumentAnnotation> findByDocumentIdAndPageNumber(UUID documentId, int pageNumber);
Optional<DocumentAnnotation> findByIdAndDocumentId(UUID id, UUID documentId);
}

View File

@@ -3,6 +3,7 @@ package org.raddatz.familienarchiv.security;
public enum Permission {
READ_ALL,
WRITE_ALL,
ANNOTATE_ALL,
ADMIN,
ADMIN_USER,
ADMIN_TAG,

View File

@@ -0,0 +1,74 @@
package org.raddatz.familienarchiv.service;
import lombok.RequiredArgsConstructor;
import org.raddatz.familienarchiv.dto.CreateAnnotationDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.raddatz.familienarchiv.repository.AnnotationRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class AnnotationService {
private final AnnotationRepository annotationRepository;
public List<DocumentAnnotation> listAnnotations(UUID documentId) {
return annotationRepository.findByDocumentId(documentId);
}
@Transactional
public DocumentAnnotation createAnnotation(UUID documentId, CreateAnnotationDTO dto, UUID userId) {
List<DocumentAnnotation> existing =
annotationRepository.findByDocumentIdAndPageNumber(documentId, dto.getPageNumber());
boolean overlaps = existing.stream().anyMatch(a -> overlaps(a, dto));
if (overlaps) {
throw DomainException.conflict(
ErrorCode.ANNOTATION_OVERLAP, "Annotation overlaps an existing one on this page");
}
DocumentAnnotation annotation = DocumentAnnotation.builder()
.documentId(documentId)
.pageNumber(dto.getPageNumber())
.x(dto.getX())
.y(dto.getY())
.width(dto.getWidth())
.height(dto.getHeight())
.color(dto.getColor())
.createdBy(userId)
.build();
return annotationRepository.save(annotation);
}
@Transactional
public void deleteAnnotation(UUID documentId, UUID annotationId, UUID userId) {
DocumentAnnotation annotation = annotationRepository
.findByIdAndDocumentId(annotationId, documentId)
.orElseThrow(() -> DomainException.notFound(
ErrorCode.ANNOTATION_NOT_FOUND, "Annotation not found: " + annotationId));
if (userId == null || !userId.equals(annotation.getCreatedBy())) {
throw DomainException.forbidden("Only the annotation author can delete it");
}
annotationRepository.delete(annotation);
}
// ─── private helpers ──────────────────────────────────────────────────────
private boolean overlaps(DocumentAnnotation existing, CreateAnnotationDTO dto) {
double ex2 = existing.getX() + existing.getWidth();
double ey2 = existing.getY() + existing.getHeight();
double dx2 = dto.getX() + dto.getWidth();
double dy2 = dto.getY() + dto.getHeight();
return existing.getX() < dx2 && ex2 > dto.getX()
&& existing.getY() < dy2 && ey2 > dto.getY();
}
}

View File

@@ -0,0 +1,14 @@
CREATE TABLE document_annotations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
page_number INTEGER NOT NULL,
x DOUBLE PRECISION NOT NULL,
y DOUBLE PRECISION NOT NULL,
width DOUBLE PRECISION NOT NULL,
height DOUBLE PRECISION NOT NULL,
color VARCHAR(20) NOT NULL,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP NOT NULL DEFAULT now()
);
CREATE INDEX ON document_annotations (document_id, page_number);

View File

@@ -0,0 +1,7 @@
-- Grant ANNOTATE_ALL to every group that already has ADMIN.
-- New installs get it via DataInitializer; this covers existing deployments.
INSERT INTO group_permissions (group_id, permission)
SELECT g.id, 'ANNOTATE_ALL'
FROM user_groups g
WHERE g.id IN (SELECT group_id FROM group_permissions WHERE permission = 'ADMIN')
AND g.id NOT IN (SELECT group_id FROM group_permissions WHERE permission = 'ANNOTATE_ALL');

View File

@@ -0,0 +1,130 @@
package org.raddatz.familienarchiv.controller;
import org.junit.jupiter.api.Test;
import org.raddatz.familienarchiv.config.SecurityConfig;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.raddatz.familienarchiv.security.PermissionAspect;
import org.raddatz.familienarchiv.service.AnnotationService;
import org.raddatz.familienarchiv.service.CustomUserDetailsService;
import org.raddatz.familienarchiv.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(AnnotationController.class)
@Import({SecurityConfig.class, PermissionAspect.class, AopAutoConfiguration.class})
class AnnotationControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean AnnotationService annotationService;
@MockitoBean UserService userService;
@MockitoBean CustomUserDetailsService customUserDetailsService;
private static final String ANNOTATION_JSON =
"{\"pageNumber\":1,\"x\":0.1,\"y\":0.1,\"width\":0.2,\"height\":0.2,\"color\":\"#ff0000\"}";
// ─── GET /api/documents/{documentId}/annotations ──────────────────────────
@Test
void listAnnotations_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(get("/api/documents/" + UUID.randomUUID() + "/annotations"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void listAnnotations_returns200_whenAuthenticated() throws Exception {
when(annotationService.listAnnotations(any())).thenReturn(List.of());
mockMvc.perform(get("/api/documents/" + UUID.randomUUID() + "/annotations"))
.andExpect(status().isOk());
}
// ─── POST /api/documents/{documentId}/annotations ─────────────────────────
@Test
void createAnnotation_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
.contentType(MediaType.APPLICATION_JSON)
.content(ANNOTATION_JSON))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void createAnnotation_returns403_whenMissingAnnotatePermission() throws Exception {
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
.contentType(MediaType.APPLICATION_JSON)
.content(ANNOTATION_JSON))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void createAnnotation_returns201_whenHasAnnotatePermission() throws Exception {
UUID docId = UUID.randomUUID();
DocumentAnnotation saved = DocumentAnnotation.builder()
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
.x(0.1).y(0.1).width(0.2).height(0.2).color("#ff0000").build();
when(annotationService.createAnnotation(any(), any(), any())).thenReturn(saved);
mockMvc.perform(post("/api/documents/" + docId + "/annotations")
.contentType(MediaType.APPLICATION_JSON)
.content(ANNOTATION_JSON))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.pageNumber").value(1));
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void createAnnotation_returns409_whenOverlap() throws Exception {
when(annotationService.createAnnotation(any(), any(), any()))
.thenThrow(DomainException.conflict(ErrorCode.ANNOTATION_OVERLAP, "Overlap"));
mockMvc.perform(post("/api/documents/" + UUID.randomUUID() + "/annotations")
.contentType(MediaType.APPLICATION_JSON)
.content(ANNOTATION_JSON))
.andExpect(status().isConflict());
}
// ─── DELETE /api/documents/{documentId}/annotations/{annotationId} ─────────
@Test
void deleteAnnotation_returns401_whenUnauthenticated() throws Exception {
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser
void deleteAnnotation_returns403_whenMissingAnnotatePermission() throws Exception {
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(authorities = "ANNOTATE_ALL")
void deleteAnnotation_returns204_whenHasAnnotatePermission() throws Exception {
mockMvc.perform(delete("/api/documents/" + UUID.randomUUID() + "/annotations/" + UUID.randomUUID()))
.andExpect(status().isNoContent());
}
}

View File

@@ -0,0 +1,131 @@
package org.raddatz.familienarchiv.service;
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.raddatz.familienarchiv.dto.CreateAnnotationDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.model.DocumentAnnotation;
import org.raddatz.familienarchiv.repository.AnnotationRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.http.HttpStatus.CONFLICT;
import static org.springframework.http.HttpStatus.FORBIDDEN;
import static org.springframework.http.HttpStatus.NOT_FOUND;
@ExtendWith(MockitoExtension.class)
class AnnotationServiceTest {
@Mock AnnotationRepository annotationRepository;
@InjectMocks AnnotationService annotationService;
// ─── createAnnotation ─────────────────────────────────────────────────────
@Test
void createAnnotation_throwsConflict_whenAnnotationOverlapsExisting() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.1, 0.1, 0.3, 0.3, "#ff0000");
DocumentAnnotation existing = DocumentAnnotation.builder()
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
.x(0.2).y(0.2).width(0.3).height(0.3).color("#00ff00").build();
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1))
.thenReturn(List.of(existing));
assertThatThrownBy(() -> annotationService.createAnnotation(docId, dto, userId))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(CONFLICT));
verify(annotationRepository, never()).save(any());
}
@Test
void createAnnotation_savesAndReturns_whenNoOverlap() {
UUID docId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
CreateAnnotationDTO dto = new CreateAnnotationDTO(1, 0.0, 0.0, 0.05, 0.05, "#ff0000");
when(annotationRepository.findByDocumentIdAndPageNumber(docId, 1)).thenReturn(List.of());
DocumentAnnotation saved = DocumentAnnotation.builder()
.id(UUID.randomUUID()).documentId(docId).pageNumber(1)
.x(0.0).y(0.0).width(0.05).height(0.05).color("#ff0000").createdBy(userId).build();
when(annotationRepository.save(any())).thenReturn(saved);
DocumentAnnotation result = annotationService.createAnnotation(docId, dto, userId);
assertThat(result).isEqualTo(saved);
verify(annotationRepository).save(any());
}
// ─── deleteAnnotation ─────────────────────────────────────────────────────
@Test
void deleteAnnotation_throwsNotFound_whenMissing() {
UUID docId = UUID.randomUUID();
UUID annotId = UUID.randomUUID();
when(annotationRepository.findByIdAndDocumentId(annotId, docId)).thenReturn(Optional.empty());
assertThatThrownBy(() -> annotationService.deleteAnnotation(docId, annotId, UUID.randomUUID()))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(NOT_FOUND));
}
@Test
void deleteAnnotation_throwsForbidden_whenNotOwner() {
UUID docId = UUID.randomUUID();
UUID annotId = UUID.randomUUID();
UUID ownerId = UUID.randomUUID();
UUID otherId = UUID.randomUUID();
DocumentAnnotation annotation = DocumentAnnotation.builder()
.id(annotId).documentId(docId).createdBy(ownerId).build();
when(annotationRepository.findByIdAndDocumentId(annotId, docId))
.thenReturn(Optional.of(annotation));
assertThatThrownBy(() -> annotationService.deleteAnnotation(docId, annotId, otherId))
.isInstanceOf(DomainException.class)
.satisfies(e -> assertThat(((DomainException) e).getStatus()).isEqualTo(FORBIDDEN));
verify(annotationRepository, never()).delete(any());
}
@Test
void deleteAnnotation_succeeds_whenOwner() {
UUID docId = UUID.randomUUID();
UUID annotId = UUID.randomUUID();
UUID ownerId = UUID.randomUUID();
DocumentAnnotation annotation = DocumentAnnotation.builder()
.id(annotId).documentId(docId).createdBy(ownerId).build();
when(annotationRepository.findByIdAndDocumentId(annotId, docId))
.thenReturn(Optional.of(annotation));
annotationService.deleteAnnotation(docId, annotId, ownerId);
verify(annotationRepository).delete(annotation);
}
// ─── listAnnotations ──────────────────────────────────────────────────────
@Test
void listAnnotations_returnsAllForDocument() {
UUID docId = UUID.randomUUID();
DocumentAnnotation a = DocumentAnnotation.builder()
.id(UUID.randomUUID()).documentId(docId).build();
when(annotationRepository.findByDocumentId(docId)).thenReturn(List.of(a));
assertThat(annotationService.listAnnotations(docId)).containsExactly(a);
}
}

View File

@@ -98,6 +98,7 @@ services:
S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
S3_BUCKET_NAME: ${MINIO_DEFAULT_BUCKETS}
S3_REGION: us-east-1
SPRING_PROFILES_ACTIVE: dev,e2e
APP_BASE_URL: ${APP_BASE_URL:-http://localhost:3000}
# Defaults to the local Mailpit catcher — override in .env for production SMTP
MAIL_HOST: ${MAIL_HOST:-mailpit}

View File

@@ -5,7 +5,7 @@
"value": "de",
"domain": "localhost",
"path": "/",
"expires": 1808565334.192108,
"expires": 1808896929.897686,
"httpOnly": false,
"secure": false,
"sameSite": "Lax"
@@ -15,7 +15,7 @@
"value": "Basic%20YWRtaW46YWRtaW4xMjM%3D",
"domain": "localhost",
"path": "/",
"expires": 1774091734.449243,
"expires": 1774423330.233039,
"httpOnly": true,
"secure": false,
"sameSite": "Strict"

View File

@@ -1,5 +1,9 @@
import { test, expect } from '@playwright/test';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/**
* Document management E2E tests.
@@ -150,29 +154,38 @@ const PDF_FIXTURE = path.resolve(__dirname, 'fixtures/minimal.pdf');
test.describe('PDF viewer', () => {
let pdfDocHref: string;
let noFileDocHref: string;
test.beforeAll(async ({ browser }) => {
// Create a document and upload the PDF fixture so later tests have a
// real file attached. Runs once for the whole describe block.
const ctx = await browser.newContext();
const p = await ctx.newPage();
test.beforeAll(async ({ request }) => {
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
await p.goto('/documents/new');
await p.waitForSelector('[data-hydrated]');
await p.getByLabel('Titel').fill('E2E PDF Viewer Test');
await p.getByRole('button', { name: /Speichern/i }).click();
await p.waitForURL(/\/documents\/[^/]+$/);
// Create a document with a PDF file.
const createRes = await request.post('/api/documents', {
multipart: { title: 'E2E PDF Viewer Test' }
});
if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`);
const doc = await createRes.json();
// Upload the PDF on the edit page
const href = p.url().replace(/\/$/, '');
pdfDocHref = href;
await p.goto(`${href}/edit`);
await p.waitForSelector('[data-hydrated]');
await p.locator('input[type="file"][name="file"]').setInputFiles(PDF_FIXTURE);
await p.getByRole('button', { name: /Speichern/i }).click();
await p.waitForURL(/\/documents\/[^/]+$/);
const uploadRes = await request.put(`/api/documents/${doc.id}`, {
multipart: {
title: doc.title,
file: {
name: 'minimal.pdf',
mimeType: 'application/pdf',
buffer: fs.readFileSync(PDF_FIXTURE)
}
}
});
if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`);
pdfDocHref = `${baseURL}/documents/${doc.id}`;
await ctx.close();
// Create a document WITHOUT a file — used to verify no canvas is rendered.
const noFileRes = await request.post('/api/documents', {
multipart: { title: 'E2E No-File Test' }
});
if (!noFileRes.ok()) throw new Error(`Create no-file document failed: ${noFileRes.status()}`);
const noFileDoc = await noFileRes.json();
noFileDocHref = `${baseURL}/documents/${noFileDoc.id}`;
});
test('PDF renders in the custom viewer — canvas is present instead of iframe', async ({
@@ -201,20 +214,164 @@ test.describe('PDF viewer', () => {
await page.screenshot({ path: 'test-results/e2e/pdf-viewer-nav.png' });
});
test('non-PDF attachment renders as an img element, not canvas', async ({ page }) => {
// The seed document "Urlaubspostkarte Ostsee" has a .jpg original filename.
// Navigate to it and confirm an <img> is used (no canvas, no iframe).
await page.goto('/');
await page.waitForSelector('[data-hydrated]');
await page.goto('/?q=Urlaubspostkarte');
const link = page.getByRole('link', { name: /Urlaubspostkarte/i }).first();
const href = await link.getAttribute('href');
await page.goto(href!);
test('document without a file has no canvas', async ({ page }) => {
// A document with no file attached must not render a PDF canvas.
await page.goto(noFileDocHref);
await page.waitForSelector('[data-hydrated]');
// No canvas — this is an image document
// No canvas — this document has no file
await expect(page.locator('canvas')).not.toBeAttached();
await page.screenshot({ path: 'test-results/e2e/pdf-viewer-image-fallback.png' });
});
});
// ─── PDF Annotations (admin) ──────────────────────────────────────────────────
// Shared with the read-only user describe block below
let sharedAnnotationDocId: string;
test.describe('PDF annotations — admin', () => {
let annotationDocHref: string;
test.beforeAll(async ({ request }) => {
// Create a document with a PDF via API — much faster than UI automation.
const createRes = await request.post('/api/documents', {
multipart: { title: 'E2E Annotations Test' }
});
if (!createRes.ok()) throw new Error(`Create document failed: ${createRes.status()}`);
const doc = await createRes.json();
const uploadRes = await request.put(`/api/documents/${doc.id}`, {
multipart: {
title: doc.title,
file: {
name: 'minimal.pdf',
mimeType: 'application/pdf',
buffer: fs.readFileSync(PDF_FIXTURE)
}
}
});
if (!uploadRes.ok()) throw new Error(`Upload PDF failed: ${uploadRes.status()}`);
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
annotationDocHref = `${baseURL}/documents/${doc.id}`;
sharedAnnotationDocId = doc.id;
});
test('admin user sees an active Annotieren button on a PDF', async ({ page }) => {
test.setTimeout(60_000);
await page.goto(annotationDocHref);
await page.waitForSelector('[data-hydrated]');
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
// Admin has ANNOTATE_ALL — button must be enabled
const annotateBtn = page.getByRole('button', { name: /^annotieren$/i });
await expect(annotateBtn).toBeVisible();
await expect(annotateBtn).not.toBeDisabled();
await page.screenshot({ path: 'test-results/e2e/annotations-button-admin.png' });
});
test('admin can draw an annotation and it appears on the page', async ({ page }) => {
test.setTimeout(60_000);
await page.goto(annotationDocHref);
await page.waitForSelector('[data-hydrated]');
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
// Enable annotate mode
await page.getByRole('button', { name: /^annotieren$/i }).click();
// Color picker must appear
await expect(page.getByLabel(/farbe/i)).toBeVisible();
// Draw on the annotation layer overlay
const annotationLayer = page.locator('[role="presentation"]').last();
const box = await annotationLayer.boundingBox();
if (!box) throw new Error('Annotation layer not found');
const startX = box.x + box.width * 0.3;
const startY = box.y + box.height * 0.3;
const endX = box.x + box.width * 0.55;
const endY = box.y + box.height * 0.55;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(endX, endY);
await page.mouse.up();
await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({
timeout: 8000
});
await page.screenshot({ path: 'test-results/e2e/annotation-drawn.png' });
});
test('annotation persists after page reload', async ({ page }) => {
test.setTimeout(60_000);
await page.goto(annotationDocHref);
await page.waitForSelector('[data-hydrated]');
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
// Annotation from the previous test must be loaded from the API
await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({
timeout: 8000
});
await page.screenshot({ path: 'test-results/e2e/annotation-persisted.png' });
});
test('admin can delete an annotation', async ({ page }) => {
test.setTimeout(60_000);
await page.goto(annotationDocHref);
await page.waitForSelector('[data-hydrated]');
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 20000 });
// Ensure annotation is visible before enabling annotate mode
await expect(page.locator('[data-testid^="annotation-"]').first()).toBeVisible({
timeout: 8000
});
// Enable annotate mode to show delete buttons
await page.getByRole('button', { name: /^annotieren$/i }).click();
const deleteBtn = page.getByRole('button', { name: /annotation löschen/i }).first();
await expect(deleteBtn).toBeVisible({ timeout: 8000 });
await deleteBtn.click();
await expect(page.locator('[data-testid^="annotation-"]')).toHaveCount(0, {
timeout: 8000
});
await page.screenshot({ path: 'test-results/e2e/annotation-deleted.png' });
});
});
// ─── PDF Annotations (read-only user) ─────────────────────────────────────────
test.describe('PDF annotations — read-only user', () => {
// Isolated session — does not share the admin storage state
test.use({ storageState: { cookies: [], origins: [] } });
test('read-only user sees a disabled Annotieren button', async ({ page }) => {
test.setTimeout(60_000);
await page.goto('/login');
await page.getByLabel('Benutzername').fill('reader');
await page.getByLabel('Passwort').fill('reader123');
await page.getByRole('button', { name: 'Anmelden' }).click();
await page.waitForURL('/');
// Navigate directly to the PDF document created by the admin beforeAll.
const baseURL = process.env.E2E_BASE_URL ?? 'http://localhost:3000';
await page.goto(`${baseURL}/documents/${sharedAnnotationDocId}`);
await page.waitForSelector('[data-hydrated]');
// Wait for the PDF canvas — once rendered, the controls bar (with disabled button) is shown.
await page.locator('canvas').first().waitFor({ state: 'visible', timeout: 30000 });
const disabledBtn = page.getByRole('button', { name: /annotieren/i });
await expect(disabledBtn).toBeVisible({ timeout: 5000 });
await expect(disabledBtn).toBeDisabled();
await page.screenshot({ path: 'test-results/e2e/annotations-button-reader.png' });
});
});

View File

@@ -1,5 +1,7 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"error_annotation_not_found": "Die Annotation wurde nicht gefunden.",
"error_annotation_overlap": "Die Annotation überschneidet sich mit einer vorhandenen.",
"error_document_not_found": "Das Dokument wurde nicht gefunden.",
"error_document_no_file": "Diesem Dokument ist noch keine Datei zugeordnet.",
"error_file_not_found": "Die Datei konnte im Speicher nicht gefunden werden.",

View File

@@ -1,5 +1,7 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"error_annotation_not_found": "Annotation not found.",
"error_annotation_overlap": "The annotation overlaps an existing one.",
"error_document_not_found": "Document not found.",
"error_document_no_file": "No file is associated with this document.",
"error_file_not_found": "The file could not be found in storage.",

View File

@@ -1,5 +1,7 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"error_annotation_not_found": "Anotación no encontrada.",
"error_annotation_overlap": "La anotación se superpone con una existente.",
"error_document_not_found": "Documento no encontrado.",
"error_document_no_file": "No hay ningún archivo asociado a este documento.",
"error_file_not_found": "El archivo no pudo encontrarse en el almacenamiento.",

View File

@@ -0,0 +1,170 @@
<script lang="ts">
type Annotation = {
id: string;
documentId: string;
pageNumber: number;
x: number;
y: number;
width: number;
height: number;
color: string;
createdAt: string;
};
type DrawRect = {
x: number;
y: number;
width: number;
height: number;
};
let {
annotations = [],
canAnnotate,
color,
onDraw,
onDelete
}: {
annotations: Annotation[];
canAnnotate: boolean;
color: string;
onDraw: (rect: { x: number; y: number; width: number; height: number }) => void;
onDelete: (id: string) => void;
} = $props();
let drawStart = $state<{ x: number; y: number } | null>(null);
let drawRect = $state<DrawRect | null>(null);
function hexToRgba(hex: string, alpha: number): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
function getNormalizedCoords(event: PointerEvent, element: HTMLElement): { x: number; y: number } {
const rect = element.getBoundingClientRect();
return {
x: (event.clientX - rect.left) / rect.width,
y: (event.clientY - rect.top) / rect.height
};
}
function handlePointerDown(event: PointerEvent) {
if (!canAnnotate) return;
if ((event.target as HTMLElement).closest('[data-annotation]')) return;
const container = event.currentTarget as HTMLElement;
container.setPointerCapture(event.pointerId);
const coords = getNormalizedCoords(event, container);
drawStart = coords;
drawRect = { x: coords.x, y: coords.y, width: 0, height: 0 };
}
function handlePointerMove(event: PointerEvent) {
if (!canAnnotate || !drawStart) return;
const container = event.currentTarget as HTMLElement;
const coords = getNormalizedCoords(event, container);
const x = Math.min(drawStart.x, coords.x);
const y = Math.min(drawStart.y, coords.y);
const width = Math.abs(coords.x - drawStart.x);
const height = Math.abs(coords.y - drawStart.y);
drawRect = { x, y, width, height };
}
function handlePointerUp(event: PointerEvent) {
if (!canAnnotate || !drawStart || !drawRect) return;
const container = event.currentTarget as HTMLElement;
const coords = getNormalizedCoords(event, container);
const x = Math.min(drawStart.x, coords.x);
const y = Math.min(drawStart.y, coords.y);
const width = Math.abs(coords.x - drawStart.x);
const height = Math.abs(coords.y - drawStart.y);
if (width > 0.01 && height > 0.01) {
onDraw({ x, y, width, height });
}
drawStart = null;
drawRect = null;
}
const containerStyle = $derived(
`position: absolute; top: 0; left: 0; width: 100%; height: 100%;${canAnnotate ? ' cursor: crosshair;' : ''}`
);
</script>
<div
style={containerStyle}
role="presentation"
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
>
{#each annotations as annotation (annotation.id)}
<div
data-testid="annotation-{annotation.id}"
data-annotation
style="
position: absolute;
left: {annotation.x * 100}%;
top: {annotation.y * 100}%;
width: {annotation.width * 100}%;
height: {annotation.height * 100}%;
background-color: {hexToRgba(annotation.color, 0.3)};
pointer-events: {canAnnotate ? 'auto' : 'none'};
"
>
{#if canAnnotate}
<button
aria-label="Annotation löschen"
onclick={(e) => {
e.stopPropagation();
onDelete(annotation.id);
}}
style="
position: absolute;
top: -8px;
right: -8px;
width: 16px;
height: 16px;
background-color: #ef4444;
color: white;
border: none;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
line-height: 1;
padding: 0;
pointer-events: auto;
">×</button
>
{/if}
</div>
{/each}
{#if drawRect && drawRect.width > 0}
<div
style="
position: absolute;
left: {drawRect.x * 100}%;
top: {drawRect.y * 100}%;
width: {drawRect.width * 100}%;
height: {drawRect.height * 100}%;
border: 2px dashed {color};
opacity: 0.3;
pointer-events: none;
"
></div>
{/if}
</div>

View File

@@ -0,0 +1,74 @@
import { describe, it, expect, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import AnnotationLayer from './AnnotationLayer.svelte';
afterEach(cleanup);
type Annotation = {
id: string;
documentId: string;
pageNumber: number;
x: number;
y: number;
width: number;
height: number;
color: string;
createdAt: string;
};
function makeAnnotation(id = 'ann-1'): Annotation {
return {
id,
documentId: 'doc-1',
pageNumber: 1,
x: 0.1,
y: 0.1,
width: 0.3,
height: 0.2,
color: '#ff0000',
createdAt: new Date().toISOString()
};
}
describe('AnnotationLayer', () => {
it('renders a colored element for each annotation', async () => {
render(AnnotationLayer, {
annotations: [makeAnnotation('ann-1'), makeAnnotation('ann-2')],
canAnnotate: false,
color: '#ff0000',
onDraw: () => {},
onDelete: () => {}
});
await expect.element(page.getByTestId('annotation-ann-1')).toBeInTheDocument();
await expect.element(page.getByTestId('annotation-ann-2')).toBeInTheDocument();
});
it('shows a delete button for each annotation when canAnnotate is true', async () => {
render(AnnotationLayer, {
annotations: [makeAnnotation('ann-1')],
canAnnotate: true,
color: '#ff0000',
onDraw: () => {},
onDelete: () => {}
});
await expect
.element(page.getByRole('button', { name: /annotation löschen/i }))
.toBeInTheDocument();
});
it('does not show delete buttons when canAnnotate is false', async () => {
render(AnnotationLayer, {
annotations: [makeAnnotation('ann-1')],
canAnnotate: false,
color: '#ff0000',
onDraw: () => {},
onDelete: () => {}
});
expect(page.getByRole('button', { name: /annotation löschen/i }).query()).toBeNull();
});
});

View File

@@ -1,8 +1,17 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
import AnnotationLayer from './AnnotationLayer.svelte';
let { url }: { url: string } = $props();
let {
url,
documentId = '',
canAnnotate = false
}: {
url: string;
documentId?: string;
canAnnotate?: boolean;
} = $props();
let pdfDoc = $state<PDFDocumentProxy | null>(null);
let currentPage = $state(1);
@@ -24,6 +33,22 @@ let textLayerInstance: { cancel: () => void } | null = null;
let pdfjsLib: typeof import('pdfjs-dist') | null = null;
let pdfjsReady = $state(false);
type Annotation = {
id: string;
documentId: string;
pageNumber: number;
x: number;
y: number;
width: number;
height: number;
color: string;
createdAt: string;
};
let annotations = $state<Annotation[]>([]);
let annotateMode = $state(false);
let annotateColor = $state('#ffff00');
onMount(async () => {
// Dynamic import keeps pdfjs out of the SSR bundle entirely
const [lib, { default: workerUrl }] = await Promise.all([
@@ -134,6 +159,54 @@ async function prerender(doc: PDFDocumentProxy, pageNum: number) {
}
}
async function loadAnnotations(docId: string) {
if (!docId) return;
try {
const res = await fetch(`/api/documents/${docId}/annotations`);
if (res.ok) annotations = await res.json();
} catch {
// ignore
}
}
async function handleAnnotationDraw(rect: { x: number; y: number; width: number; height: number }) {
if (!documentId) return;
try {
const res = await fetch(`/api/documents/${documentId}/annotations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pageNumber: currentPage,
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
color: annotateColor
})
});
if (res.ok) {
const created: Annotation = await res.json();
annotations = [...annotations, created];
}
} catch {
// ignore
}
}
async function handleAnnotationDelete(annotationId: string) {
if (!documentId) return;
try {
const res = await fetch(`/api/documents/${documentId}/annotations/${annotationId}`, {
method: 'DELETE'
});
if (res.ok) {
annotations = annotations.filter((a) => a.id !== annotationId);
}
} catch {
// ignore
}
}
$effect(() => {
if (pdfjsReady && url) {
loadDocument(url);
@@ -151,6 +224,12 @@ $effect(() => {
}
});
$effect(() => {
if (documentId) {
loadAnnotations(documentId);
}
});
function prevPage() {
if (currentPage > 1) currentPage -= 1;
}
@@ -274,6 +353,37 @@ function zoomOut() {
</svg>
</button>
</div>
<!-- Annotate controls -->
{#if canAnnotate}
<div class="flex items-center gap-1">
<button
onclick={() => (annotateMode = !annotateMode)}
aria-label={annotateMode ? 'Annotieren beenden' : 'Annotieren'}
class="rounded px-2 py-1 font-sans text-xs text-gray-300 transition hover:bg-white/10 {annotateMode ? 'bg-white/20' : ''}"
>
{annotateMode ? 'Fertig' : 'Annotieren'}
</button>
{#if annotateMode}
<input
type="color"
bind:value={annotateColor}
aria-label="Farbe wählen"
class="h-6 w-6 cursor-pointer rounded border-0 bg-transparent p-0"
title="Farbe wählen"
/>
{/if}
</div>
{:else}
<button
disabled
title="Sie benötigen die Berechtigung ANNOTATE_ALL zum Annotieren"
class="cursor-not-allowed rounded px-2 py-1 font-sans text-xs text-gray-500"
aria-label="Annotieren (keine Berechtigung)"
>
Annotieren
</button>
{/if}
</div>
<!-- PDF canvas area -->
@@ -297,6 +407,13 @@ function zoomOut() {
class="textLayer"
style="position: absolute; top: 0; left: 0; overflow: hidden; pointer-events: none; line-height: 1;"
></div>
<AnnotationLayer
annotations={annotations.filter((a) => a.pageNumber === currentPage)}
canAnnotate={annotateMode}
color={annotateColor}
onDraw={handleAnnotationDraw}
onDelete={handleAnnotationDelete}
/>
</div>
</div>
{/if}

View File

@@ -14,6 +14,8 @@ export type ErrorCode =
| 'WRONG_CURRENT_PASSWORD'
| 'IMPORT_ALREADY_RUNNING'
| 'INVALID_RESET_TOKEN'
| 'ANNOTATION_NOT_FOUND'
| 'ANNOTATION_OVERLAP'
| 'UNAUTHORIZED'
| 'FORBIDDEN'
| 'VALIDATION_ERROR'
@@ -61,6 +63,10 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
return m.error_import_already_running();
case 'INVALID_RESET_TOKEN':
return m.error_invalid_reset_token();
case 'ANNOTATION_NOT_FOUND':
return m.error_annotation_not_found();
case 'ANNOTATION_OVERLAP':
return m.error_annotation_overlap();
case 'UNAUTHORIZED':
return m.error_unauthorized();
case 'FORBIDDEN':

View File

@@ -1,11 +1,10 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
const groups: { permissions: string[] }[] = locals.user?.groups ?? [];
return {
user: locals.user,
canWrite:
locals.user?.groups?.some((g: { permissions: string[] }) =>
g.permissions.includes('WRITE_ALL')
) ?? false
canWrite: groups.some((g) => g.permissions.includes('WRITE_ALL')),
canAnnotate: groups.some((g) => g.permissions.includes('ANNOTATE_ALL'))
};
};

View File

@@ -29,6 +29,7 @@ const makeUser = (overrides = {}) => ({
const baseData = {
user: undefined,
canWrite: true,
canAnnotate: false,
users: [makeUser()],
groups: [makeGroup()],
tags: []

View File

@@ -24,7 +24,13 @@ const makeUser = (overrides = {}) => ({
...overrides
});
const baseData = { user: undefined, canWrite: true, editUser: makeUser(), groups };
const baseData = {
user: undefined,
canWrite: true,
canAnnotate: false,
editUser: makeUser(),
groups
};
afterEach(cleanup);

View File

@@ -10,7 +10,7 @@ const groups = [
{ id: 'g2', name: 'Admins', permissions: ['ADMIN'] }
];
const baseData = { user: undefined, canWrite: true, groups };
const baseData = { user: undefined, canWrite: true, canAnnotate: false, groups };
afterEach(cleanup);

View File

@@ -12,6 +12,7 @@ afterEach(cleanup);
const baseData = {
user: undefined,
canWrite: true,
canAnnotate: false,
documents: [],
initialValues: { senderName: '', receiverName: '' },
filters: { senderId: '', receiverId: '', from: '', to: '', dir: 'DESC' as const }

View File

@@ -875,7 +875,7 @@ function versionLabel(v: VersionSummary, index: number): string {
<p class="font-sans text-sm tracking-wide uppercase">{m.doc_no_scan()}</p>
</div>
{:else if fileUrl && doc.contentType?.startsWith('application/pdf')}
<PdfViewer url={fileUrl} />
<PdfViewer url={fileUrl} documentId={doc.id} canAnnotate={data.canAnnotate} />
{:else if fileUrl}
<div class="flex h-full w-full items-center justify-center overflow-auto p-8">
<img

View File

@@ -10,6 +10,7 @@ afterEach(cleanup);
const baseData = {
user: undefined,
canWrite: true,
canAnnotate: false,
persons: [],
initialSenderId: '',
initialSenderName: '',

View File

@@ -22,6 +22,7 @@ const makeData = (overrides = {}) => ({
createdAt: ''
},
canWrite: true,
canAnnotate: false,
...overrides
});

View File

@@ -20,6 +20,7 @@ afterEach(cleanup);
const emptyData = {
user: undefined,
canWrite: true,
canAnnotate: false,
filters: { q: '', from: '', to: '', senderId: '', receiverId: '', tags: [] },
documents: [],
initialValues: { senderName: '', receiverName: '' },

View File

@@ -14,7 +14,7 @@ const makePerson = (overrides = {}) => ({
...overrides
});
const emptyData = { user: undefined, canWrite: true, q: '', persons: [] };
const emptyData = { user: undefined, canWrite: true, canAnnotate: false, q: '', persons: [] };
const dataWithPersons = { ...emptyData, persons: [makePerson()] };
afterEach(cleanup);