From d1e4b6c49e838121f4f9e33940d81192b18d8fab Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 18:41:38 +0200 Subject: [PATCH 01/23] feat(members): implement DELETE/PATCH member + GET invites backend endpoints - Add V006 migration: invalidated_at column + partial unique index on household_invite - Add findByHouseholdIdAndInvalidatedAtIsNull, findByHouseholdIdAndUserId, countByHouseholdIdAndRole - Add ChangeRoleRequest DTO - HouseholdService: getActiveInvite, createInvite (regenerate), removeMember, changeMemberRole - HouseholdController: GET /v1/households/mine/invites, DELETE/PATCH /v1/households/mine/members/{userId} Co-Authored-By: Claude Sonnet 4.6 --- .../household/HouseholdController.java | 25 +++ .../household/HouseholdInviteRepository.java | 1 + .../household/HouseholdMemberRepository.java | 2 + .../recipeapp/household/HouseholdService.java | 69 ++++++- .../household/dto/ChangeRoleRequest.java | 10 + .../household/entity/HouseholdInvite.java | 5 + .../V006__add_invite_invalidated_at.sql | 6 + .../household/HouseholdControllerTest.java | 54 ++++++ .../household/HouseholdServiceTest.java | 181 ++++++++++++++++++ 9 files changed, 349 insertions(+), 4 deletions(-) create mode 100644 backend/src/main/java/com/recipeapp/household/dto/ChangeRoleRequest.java create mode 100644 backend/src/main/resources/db/migration/V006__add_invite_invalidated_at.sql diff --git a/backend/src/main/java/com/recipeapp/household/HouseholdController.java b/backend/src/main/java/com/recipeapp/household/HouseholdController.java index 7f704d7..46c8556 100644 --- a/backend/src/main/java/com/recipeapp/household/HouseholdController.java +++ b/backend/src/main/java/com/recipeapp/household/HouseholdController.java @@ -9,6 +9,8 @@ import org.springframework.web.bind.annotation.*; import java.security.Principal; import java.util.List; +import java.util.Optional; +import java.util.UUID; @RestController @RequestMapping("/v1") @@ -40,12 +42,35 @@ public class HouseholdController { return ResponseEntity.ok(members); } + @GetMapping("/households/mine/invites") + public ResponseEntity> getActiveInvite(Principal principal) { + Optional invite = householdService.getActiveInvite(principal.getName()); + return invite + .map(r -> ResponseEntity.ok(ApiResponse.success(r))) + .orElse(ResponseEntity.noContent().build()); + } + @PostMapping("/households/mine/invites") public ResponseEntity> createInvite(Principal principal) { InviteResponse response = householdService.createInvite(principal.getName()); return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(response)); } + @DeleteMapping("/households/mine/members/{userId}") + public ResponseEntity removeMember(Principal principal, @PathVariable UUID userId) { + householdService.removeMember(principal.getName(), userId); + return ResponseEntity.noContent().build(); + } + + @PatchMapping("/households/mine/members/{userId}") + public ResponseEntity> changeMemberRole( + Principal principal, + @PathVariable UUID userId, + @Valid @RequestBody ChangeRoleRequest request) { + MemberResponse response = householdService.changeMemberRole(principal.getName(), userId, request.role()); + return ResponseEntity.ok(ApiResponse.success(response)); + } + @PostMapping("/invites/{code}/accept") public ResponseEntity> acceptInvite( Principal principal, diff --git a/backend/src/main/java/com/recipeapp/household/HouseholdInviteRepository.java b/backend/src/main/java/com/recipeapp/household/HouseholdInviteRepository.java index 7486172..03dd511 100644 --- a/backend/src/main/java/com/recipeapp/household/HouseholdInviteRepository.java +++ b/backend/src/main/java/com/recipeapp/household/HouseholdInviteRepository.java @@ -8,4 +8,5 @@ import java.util.UUID; public interface HouseholdInviteRepository extends JpaRepository { Optional findByInviteCode(String inviteCode); + Optional findByHouseholdIdAndInvalidatedAtIsNull(UUID householdId); } diff --git a/backend/src/main/java/com/recipeapp/household/HouseholdMemberRepository.java b/backend/src/main/java/com/recipeapp/household/HouseholdMemberRepository.java index 80853b1..32a24de 100644 --- a/backend/src/main/java/com/recipeapp/household/HouseholdMemberRepository.java +++ b/backend/src/main/java/com/recipeapp/household/HouseholdMemberRepository.java @@ -10,4 +10,6 @@ import java.util.UUID; public interface HouseholdMemberRepository extends JpaRepository { Optional findByUserEmailIgnoreCase(String email); List findByHouseholdId(UUID householdId); + Optional findByHouseholdIdAndUserId(UUID householdId, UUID userId); + long countByHouseholdIdAndRole(UUID householdId, String role); } diff --git a/backend/src/main/java/com/recipeapp/household/HouseholdService.java b/backend/src/main/java/com/recipeapp/household/HouseholdService.java index 242f1d5..678bf67 100644 --- a/backend/src/main/java/com/recipeapp/household/HouseholdService.java +++ b/backend/src/main/java/com/recipeapp/household/HouseholdService.java @@ -23,6 +23,8 @@ import org.springframework.transaction.annotation.Transactional; import java.security.SecureRandom; import java.time.Instant; import java.util.List; +import java.util.Optional; +import java.util.UUID; @Service public class HouseholdService { @@ -91,21 +93,73 @@ public class HouseholdService { .toList(); } + @Transactional + public MemberResponse changeMemberRole(String requesterEmail, UUID targetUserId, String newRole) { + HouseholdMember requester = findMembership(requesterEmail); + UUID householdId = requester.getHousehold().getId(); + + HouseholdMember target = householdMemberRepository + .findByHouseholdIdAndUserId(householdId, targetUserId) + .orElseThrow(() -> new ResourceNotFoundException("Member not found in this household")); + + if (target.getRole().equals(newRole)) { + return toMemberResponse(target); + } + + if ("member".equals(newRole) && "planner".equals(target.getRole())) { + long plannerCount = householdMemberRepository.countByHouseholdIdAndRole(householdId, "planner"); + if (plannerCount <= 1) { + throw new ConflictException("Cannot degrade the last planner"); + } + } + + target.setRole(newRole); + return toMemberResponse(householdMemberRepository.save(target)); + } + + @Transactional + public void removeMember(String requesterEmail, UUID targetUserId) { + HouseholdMember requester = findMembership(requesterEmail); + UUID householdId = requester.getHousehold().getId(); + + HouseholdMember target = householdMemberRepository + .findByHouseholdIdAndUserId(householdId, targetUserId) + .orElseThrow(() -> new ResourceNotFoundException("Member not found in this household")); + + if (target.getUser().getEmail().equalsIgnoreCase(requesterEmail)) { + throw new ConflictException("Planner cannot remove yourself"); + } + + householdMemberRepository.delete(target); + } + + @Transactional(readOnly = true) + public Optional getActiveInvite(String userEmail) { + HouseholdMember member = findMembership(userEmail); + return householdInviteRepository + .findByHouseholdIdAndInvalidatedAtIsNull(member.getHousehold().getId()) + .filter(invite -> invite.getExpiresAt().isAfter(Instant.now())) + .map(this::toInviteResponse); + } + @Transactional public InviteResponse createInvite(String userEmail) { HouseholdMember member = findMembership(userEmail); Household household = member.getHousehold(); + householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(household.getId()) + .ifPresent(existing -> { + existing.setInvalidatedAt(Instant.now()); + householdInviteRepository.save(existing); + }); + String code = generateInviteCode(); Instant expiresAt = Instant.now().plusSeconds(48 * 3600); HouseholdInvite invite = householdInviteRepository.save( new HouseholdInvite(household, code, expiresAt)); - return new InviteResponse( - invite.getInviteCode(), - "https://yourapp.com/join/" + invite.getInviteCode(), - invite.getExpiresAt()); + return toInviteResponse(invite); } @Transactional @@ -204,4 +258,11 @@ public class HouseholdService { member.getRole(), member.getJoinedAt()); } + + private InviteResponse toInviteResponse(HouseholdInvite invite) { + return new InviteResponse( + invite.getInviteCode(), + "https://yourapp.com/join/" + invite.getInviteCode(), + invite.getExpiresAt()); + } } diff --git a/backend/src/main/java/com/recipeapp/household/dto/ChangeRoleRequest.java b/backend/src/main/java/com/recipeapp/household/dto/ChangeRoleRequest.java new file mode 100644 index 0000000..80e9c95 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/household/dto/ChangeRoleRequest.java @@ -0,0 +1,10 @@ +package com.recipeapp.household.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record ChangeRoleRequest( + @NotBlank + @Pattern(regexp = "planner|member", message = "role must be 'planner' or 'member'") + String role +) {} diff --git a/backend/src/main/java/com/recipeapp/household/entity/HouseholdInvite.java b/backend/src/main/java/com/recipeapp/household/entity/HouseholdInvite.java index 916fac1..04246e6 100644 --- a/backend/src/main/java/com/recipeapp/household/entity/HouseholdInvite.java +++ b/backend/src/main/java/com/recipeapp/household/entity/HouseholdInvite.java @@ -25,6 +25,9 @@ public class HouseholdInvite { @Column(name = "expires_at", nullable = false) private Instant expiresAt; + @Column(name = "invalidated_at") + private Instant invalidatedAt; + protected HouseholdInvite() {} public HouseholdInvite(Household household, String inviteCode, Instant expiresAt) { @@ -39,4 +42,6 @@ public class HouseholdInvite { public String getStatus() { return status; } public void setStatus(String status) { this.status = status; } public Instant getExpiresAt() { return expiresAt; } + public Instant getInvalidatedAt() { return invalidatedAt; } + public void setInvalidatedAt(Instant invalidatedAt) { this.invalidatedAt = invalidatedAt; } } diff --git a/backend/src/main/resources/db/migration/V006__add_invite_invalidated_at.sql b/backend/src/main/resources/db/migration/V006__add_invite_invalidated_at.sql new file mode 100644 index 0000000..c9a5511 --- /dev/null +++ b/backend/src/main/resources/db/migration/V006__add_invite_invalidated_at.sql @@ -0,0 +1,6 @@ +ALTER TABLE household_invite + ADD COLUMN invalidated_at timestamptz; + +CREATE UNIQUE INDEX uq_household_invite_active + ON household_invite (household_id) + WHERE invalidated_at IS NULL; diff --git a/backend/src/test/java/com/recipeapp/household/HouseholdControllerTest.java b/backend/src/test/java/com/recipeapp/household/HouseholdControllerTest.java index ceee167..1a33db3 100644 --- a/backend/src/test/java/com/recipeapp/household/HouseholdControllerTest.java +++ b/backend/src/test/java/com/recipeapp/household/HouseholdControllerTest.java @@ -15,10 +15,12 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import java.time.Instant; import java.util.List; +import java.util.Optional; import java.util.UUID; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -103,6 +105,58 @@ class HouseholdControllerTest { .andExpect(jsonPath("$.data.inviteCode").value("ABC12XYZ")); } + @Test + void getActiveInviteShouldReturn200WithInvite() throws Exception { + var response = new InviteResponse("ACTIVE12", "https://yourapp.com/join/ACTIVE12", + Instant.now().plusSeconds(172800)); + + when(householdService.getActiveInvite("sarah@example.com")).thenReturn(Optional.of(response)); + + mockMvc.perform(get("/v1/households/mine/invites") + .principal(() -> "sarah@example.com")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("success")) + .andExpect(jsonPath("$.data.inviteCode").value("ACTIVE12")); + } + + @Test + void getActiveInviteShouldReturn204WhenNoActiveInvite() throws Exception { + when(householdService.getActiveInvite("sarah@example.com")).thenReturn(Optional.empty()); + + mockMvc.perform(get("/v1/households/mine/invites") + .principal(() -> "sarah@example.com")) + .andExpect(status().isNoContent()); + } + + @Test + void deleteMemberShouldReturn204() throws Exception { + var memberId = UUID.randomUUID(); + + mockMvc.perform(delete("/v1/households/mine/members/" + memberId) + .principal(() -> "sarah@example.com")) + .andExpect(status().isNoContent()); + + verify(householdService).removeMember("sarah@example.com", memberId); + } + + @Test + void patchMemberRoleShouldReturn200() throws Exception { + var memberId = UUID.randomUUID(); + var memberResponse = new MemberResponse(memberId, "Tom", "planner", Instant.now()); + var request = new ChangeRoleRequest("planner"); + + when(householdService.changeMemberRole("sarah@example.com", memberId, "planner")) + .thenReturn(memberResponse); + + mockMvc.perform(patch("/v1/households/mine/members/" + memberId) + .principal(() -> "sarah@example.com") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("success")) + .andExpect(jsonPath("$.data.role").value("planner")); + } + @Test void acceptInviteShouldReturn200() throws Exception { var response = new AcceptInviteResponse(UUID.randomUUID(), "Smith family", "member"); diff --git a/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java b/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java index baf3c32..9b00fbb 100644 --- a/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java +++ b/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java @@ -22,6 +22,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.time.Instant; import java.util.List; import java.util.Optional; +import java.util.UUID; import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; @@ -223,6 +224,169 @@ class HouseholdServiceTest { .isInstanceOf(ResourceNotFoundException.class); } + // ── changeMemberRole ────────────────────────────────────────────────────── + + @Test + void changeMemberRoleShouldUpdateRole() { + var planner = testUser(); + var target = new UserAccount("tom@example.com", "Tom", "hashed"); + var household = new Household("Smith family", planner); + var plannerMembership = new HouseholdMember(household, planner, "planner"); + var targetMembership = new HouseholdMember(household, target, "member"); + var targetId = UUID.randomUUID(); + + when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership)); + when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(targetId))).thenReturn(Optional.of(targetMembership)); + when(householdMemberRepository.save(any(HouseholdMember.class))).thenAnswer(i -> i.getArgument(0)); + + MemberResponse result = householdService.changeMemberRole("sarah@example.com", targetId, "planner"); + + assertThat(result.role()).isEqualTo("planner"); + } + + @Test + void changeMemberRoleShouldBeIdempotentWhenRoleUnchanged() { + var planner = testUser(); + var target = new UserAccount("tom@example.com", "Tom", "hashed"); + var household = new Household("Smith family", planner); + var plannerMembership = new HouseholdMember(household, planner, "planner"); + var targetMembership = new HouseholdMember(household, target, "member"); + var targetId = UUID.randomUUID(); + + when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership)); + when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(targetId))).thenReturn(Optional.of(targetMembership)); + + MemberResponse result = householdService.changeMemberRole("sarah@example.com", targetId, "member"); + + assertThat(result.role()).isEqualTo("member"); + verify(householdMemberRepository, never()).save(any()); + } + + @Test + void changeMemberRoleShouldThrow409WhenDegradingLastPlanner() { + var planner = testUser(); + var household = new Household("Smith family", planner); + var plannerMembership = new HouseholdMember(household, planner, "planner"); + var targetId = UUID.randomUUID(); + + when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership)); + when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(targetId))).thenReturn(Optional.of(plannerMembership)); + when(householdMemberRepository.countByHouseholdIdAndRole(any(), eq("planner"))).thenReturn(1L); + + assertThatThrownBy(() -> householdService.changeMemberRole("sarah@example.com", targetId, "member")) + .isInstanceOf(ConflictException.class) + .hasMessageContaining("last planner"); + } + + @Test + void changeMemberRoleShouldThrow404WhenTargetNotInHousehold() { + var planner = testUser(); + var household = new Household("Smith family", planner); + var plannerMembership = new HouseholdMember(household, planner, "planner"); + var unknownId = UUID.randomUUID(); + + when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership)); + when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(unknownId))).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> householdService.changeMemberRole("sarah@example.com", unknownId, "planner")) + .isInstanceOf(ResourceNotFoundException.class); + } + + // ── removeMember ────────────────────────────────────────────────────────── + + @Test + void removeMemberShouldDeleteMember() { + var planner = testUser(); + var target = new UserAccount("tom@example.com", "Tom", "hashed"); + var household = new Household("Smith family", planner); + var plannerMembership = new HouseholdMember(household, planner, "planner"); + var targetMembership = new HouseholdMember(household, target, "member"); + var targetId = UUID.randomUUID(); + + when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership)); + when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(targetId))).thenReturn(Optional.of(targetMembership)); + + householdService.removeMember("sarah@example.com", targetId); + + verify(householdMemberRepository).delete(targetMembership); + } + + @Test + void removeMemberShouldThrow409WhenPlannerTriesToRemoveSelf() { + var planner = testUser(); + var household = new Household("Smith family", planner); + var plannerMembership = new HouseholdMember(household, planner, "planner"); + var plannerId = UUID.randomUUID(); + + when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership)); + when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(plannerId))).thenReturn(Optional.of(plannerMembership)); + + assertThatThrownBy(() -> householdService.removeMember("sarah@example.com", plannerId)) + .isInstanceOf(ConflictException.class) + .hasMessageContaining("cannot remove yourself"); + } + + @Test + void removeMemberShouldThrow404WhenTargetNotInHousehold() { + var planner = testUser(); + var household = new Household("Smith family", planner); + var plannerMembership = new HouseholdMember(household, planner, "planner"); + var unknownId = UUID.randomUUID(); + + when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(plannerMembership)); + when(householdMemberRepository.findByHouseholdIdAndUserId(any(), eq(unknownId))).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> householdService.removeMember("sarah@example.com", unknownId)) + .isInstanceOf(ResourceNotFoundException.class); + } + + // ── getActiveInvite ─────────────────────────────────────────────────────── + + @Test + void getActiveInviteShouldReturnActiveInviteResponse() { + var user = testUser(); + var household = new Household("Smith family", user); + var member = new HouseholdMember(household, user, "planner"); + var invite = new HouseholdInvite(household, "ACTIVE123", Instant.now().plusSeconds(86400)); + + when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member)); + when(householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(any())).thenReturn(Optional.of(invite)); + + Optional result = householdService.getActiveInvite("sarah@example.com"); + + assertThat(result).isPresent(); + assertThat(result.get().inviteCode()).isEqualTo("ACTIVE123"); + } + + @Test + void getActiveInviteShouldReturnEmptyWhenNoActiveInvite() { + var user = testUser(); + var household = new Household("Smith family", user); + var member = new HouseholdMember(household, user, "planner"); + + when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member)); + when(householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(any())).thenReturn(Optional.empty()); + + Optional result = householdService.getActiveInvite("sarah@example.com"); + + assertThat(result).isEmpty(); + } + + @Test + void getActiveInviteShouldReturnEmptyWhenExpired() { + var user = testUser(); + var household = new Household("Smith family", user); + var member = new HouseholdMember(household, user, "planner"); + var invite = new HouseholdInvite(household, "EXPIRED1", Instant.now().minusSeconds(3600)); + + when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member)); + when(householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(any())).thenReturn(Optional.of(invite)); + + Optional result = householdService.getActiveInvite("sarah@example.com"); + + assertThat(result).isEmpty(); + } + @Test void getMembersShouldReturnAllMembers() { var user1 = testUser(); @@ -256,4 +420,21 @@ class HouseholdServiceTest { assertThatThrownBy(() -> householdService.createInvite("orphan@example.com")) .isInstanceOf(ResourceNotFoundException.class); } + + @Test + void createInviteShouldInvalidatePreviousActiveInvite() { + var user = testUser(); + var household = new Household("Smith family", user); + var member = new HouseholdMember(household, user, "planner"); + var existingInvite = new HouseholdInvite(household, "OLD12345", Instant.now().plusSeconds(86400)); + + when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member)); + when(householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(any())).thenReturn(Optional.of(existingInvite)); + when(householdInviteRepository.save(any(HouseholdInvite.class))).thenAnswer(i -> i.getArgument(0)); + + householdService.createInvite("sarah@example.com"); + + assertThat(existingInvite.getInvalidatedAt()).isNotNull(); + verify(householdInviteRepository, times(2)).save(any(HouseholdInvite.class)); + } } -- 2.49.1 From b04f2c51d2106624e2a4c18e67abf0b1a7002cbd Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 18:42:52 +0200 Subject: [PATCH 02/23] feat(members): update schema.d.ts with GET invites, DELETE/PATCH member types Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/api/schema.d.ts | 119 ++++++++++++++++++++++++++++++- 1 file changed, 118 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/api/schema.d.ts b/frontend/src/lib/api/schema.d.ts index aadfed1..a6cc337 100644 --- a/frontend/src/lib/api/schema.d.ts +++ b/frontend/src/lib/api/schema.d.ts @@ -203,7 +203,7 @@ export interface paths { path?: never; cookie?: never; }; - get?: never; + get: operations["getActiveInvite"]; put?: never; post: operations["createInvite"]; delete?: never; @@ -212,6 +212,24 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/households/mine/members/{userId}": { + parameters: { + query?: never; + header?: never; + path: { + userId: string; + }; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete: operations["removeMember"]; + options?: never; + head?: never; + patch: operations["changeMemberRole"]; + trace?: never; + }; "/v1/cooking-logs": { parameters: { query?: never; @@ -763,6 +781,14 @@ export interface components { /** Format: date-time */ joinedAt?: string; }; + ChangeRoleRequest: { + role: string; + }; + ApiResponseMemberResponse: { + status?: string; + data?: components["schemas"]["MemberResponse"]; + meta?: components["schemas"]["Meta"]; + }; ApiResponseInviteResponse: { status?: string; data?: components["schemas"]["InviteResponse"]; @@ -2010,6 +2036,97 @@ export interface operations { }; }; }; + getActiveInvite: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseInviteResponse"]; + }; + }; + /** @description No active invite */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + removeMember: { + parameters: { + query?: never; + header?: never; + path: { + userId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiError"]; + }; + }; + }; + }; + changeMemberRole: { + parameters: { + query?: never; + header?: never; + path: { + userId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ChangeRoleRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseMemberResponse"]; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiError"]; + }; + }; + }; + }; listAuditLog: { parameters: { query?: { -- 2.49.1 From 1b5704c8b5a919a311d051e73b0167139dce412b Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 19:01:08 +0200 Subject: [PATCH 03/23] =?UTF-8?q?feat(members):=20implement=20/members=20p?= =?UTF-8?q?age=20=E2=80=94=20Kachel-Ansicht=20(E2,=20issue=20#48)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Rename V006 migration to V026 (avoid conflict with existing V006) - Migration adds invalidated_at + partial unique index on household_invite Frontend: - Toast.svelte — new system component (message + dismiss) - SegmentedControl.svelte — new system component (options, value, onchange) - members/+page.server.ts — loads members + active invite - members/[userId]/+server.ts — DELETE/PATCH proxy - members/invites/+server.ts — POST (regenerate) proxy - MemberCard.svelte — tile with avatar, kebab, inline role edit - RemoveDialog.svelte — confirmation dialog (desktop modal + BottomSheet mobile) - InviteCard.svelte + InvitePanel.svelte — invite management UI - MemberGrid.svelte — responsive 4/2-col grid with sorted members - members/+page.svelte — page composing all components with optimistic updates Co-Authored-By: Claude Sonnet 4.6 --- ...ql => V026__add_invite_invalidated_at.sql} | 0 .../lib/components/SegmentedControl.svelte | 50 ++++ .../lib/components/SegmentedControl.test.ts | 30 +++ frontend/src/lib/components/Toast.svelte | 31 +++ frontend/src/lib/components/Toast.test.ts | 23 ++ .../src/routes/(app)/members/+page.server.ts | 17 ++ .../src/routes/(app)/members/+page.svelte | 98 +++++++- .../routes/(app)/members/InviteCard.svelte | 17 ++ .../routes/(app)/members/InviteCard.test.ts | 23 ++ .../routes/(app)/members/InvitePanel.svelte | 50 ++++ .../routes/(app)/members/InvitePanel.test.ts | 34 +++ .../routes/(app)/members/MemberCard.svelte | 215 ++++++++++++++++++ .../routes/(app)/members/MemberCard.test.ts | 147 ++++++++++++ .../routes/(app)/members/MemberGrid.svelte | 67 ++++++ .../routes/(app)/members/MemberGrid.test.ts | 73 ++++++ .../routes/(app)/members/RemoveDialog.svelte | 86 +++++++ .../routes/(app)/members/RemoveDialog.test.ts | 56 +++++ .../routes/(app)/members/[userId]/+server.ts | 21 ++ .../(app)/members/[userId]/server.test.ts | 50 ++++ .../routes/(app)/members/invites/+server.ts | 9 + .../routes/(app)/members/page.server.test.ts | 74 ++++++ 21 files changed, 1170 insertions(+), 1 deletion(-) rename backend/src/main/resources/db/migration/{V006__add_invite_invalidated_at.sql => V026__add_invite_invalidated_at.sql} (100%) create mode 100644 frontend/src/lib/components/SegmentedControl.svelte create mode 100644 frontend/src/lib/components/SegmentedControl.test.ts create mode 100644 frontend/src/lib/components/Toast.svelte create mode 100644 frontend/src/lib/components/Toast.test.ts create mode 100644 frontend/src/routes/(app)/members/+page.server.ts create mode 100644 frontend/src/routes/(app)/members/InviteCard.svelte create mode 100644 frontend/src/routes/(app)/members/InviteCard.test.ts create mode 100644 frontend/src/routes/(app)/members/InvitePanel.svelte create mode 100644 frontend/src/routes/(app)/members/InvitePanel.test.ts create mode 100644 frontend/src/routes/(app)/members/MemberCard.svelte create mode 100644 frontend/src/routes/(app)/members/MemberCard.test.ts create mode 100644 frontend/src/routes/(app)/members/MemberGrid.svelte create mode 100644 frontend/src/routes/(app)/members/MemberGrid.test.ts create mode 100644 frontend/src/routes/(app)/members/RemoveDialog.svelte create mode 100644 frontend/src/routes/(app)/members/RemoveDialog.test.ts create mode 100644 frontend/src/routes/(app)/members/[userId]/+server.ts create mode 100644 frontend/src/routes/(app)/members/[userId]/server.test.ts create mode 100644 frontend/src/routes/(app)/members/invites/+server.ts create mode 100644 frontend/src/routes/(app)/members/page.server.test.ts diff --git a/backend/src/main/resources/db/migration/V006__add_invite_invalidated_at.sql b/backend/src/main/resources/db/migration/V026__add_invite_invalidated_at.sql similarity index 100% rename from backend/src/main/resources/db/migration/V006__add_invite_invalidated_at.sql rename to backend/src/main/resources/db/migration/V026__add_invite_invalidated_at.sql diff --git a/frontend/src/lib/components/SegmentedControl.svelte b/frontend/src/lib/components/SegmentedControl.svelte new file mode 100644 index 0000000..a83104b --- /dev/null +++ b/frontend/src/lib/components/SegmentedControl.svelte @@ -0,0 +1,50 @@ + + +
+ {#each options as option (option.value)} + + {/each} +
+ + diff --git a/frontend/src/lib/components/SegmentedControl.test.ts b/frontend/src/lib/components/SegmentedControl.test.ts new file mode 100644 index 0000000..708e21c --- /dev/null +++ b/frontend/src/lib/components/SegmentedControl.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import SegmentedControl from './SegmentedControl.svelte'; + +const options = [ + { value: 'planner', label: 'Planer' }, + { value: 'member', label: 'Mitglied' } +]; + +describe('SegmentedControl', () => { + it('renders all option labels', () => { + render(SegmentedControl, { props: { options, value: 'planner', onchange: vi.fn() } }); + expect(screen.getByRole('button', { name: 'Planer' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Mitglied' })).toBeInTheDocument(); + }); + + it('marks the active option with aria-pressed', () => { + render(SegmentedControl, { props: { options, value: 'planner', onchange: vi.fn() } }); + expect(screen.getByRole('button', { name: 'Planer' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('button', { name: 'Mitglied' })).toHaveAttribute('aria-pressed', 'false'); + }); + + it('calls onchange with the new value when an option is clicked', async () => { + const onchange = vi.fn(); + render(SegmentedControl, { props: { options, value: 'planner', onchange } }); + await userEvent.click(screen.getByRole('button', { name: 'Mitglied' })); + expect(onchange).toHaveBeenCalledWith('member'); + }); +}); diff --git a/frontend/src/lib/components/Toast.svelte b/frontend/src/lib/components/Toast.svelte new file mode 100644 index 0000000..47fb71a --- /dev/null +++ b/frontend/src/lib/components/Toast.svelte @@ -0,0 +1,31 @@ + + +{#if visible} +
+ {message} + +
+{/if} diff --git a/frontend/src/lib/components/Toast.test.ts b/frontend/src/lib/components/Toast.test.ts new file mode 100644 index 0000000..114b29b --- /dev/null +++ b/frontend/src/lib/components/Toast.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import Toast from './Toast.svelte'; + +describe('Toast', () => { + it('is not mounted when visible is false', () => { + render(Toast, { props: { message: 'Hallo', visible: false } }); + expect(screen.queryByRole('status')).toBeNull(); + }); + + it('shows the message when visible is true', () => { + render(Toast, { props: { message: 'Gespeichert', visible: true } }); + expect(screen.getByRole('status')).toHaveTextContent('Gespeichert'); + }); + + it('calls ondismiss when close button is clicked', async () => { + const ondismiss = vi.fn(); + render(Toast, { props: { message: 'Fehler', visible: true, ondismiss } }); + await userEvent.click(screen.getByRole('button', { name: /schließen/i })); + expect(ondismiss).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/src/routes/(app)/members/+page.server.ts b/frontend/src/routes/(app)/members/+page.server.ts new file mode 100644 index 0000000..6e63458 --- /dev/null +++ b/frontend/src/routes/(app)/members/+page.server.ts @@ -0,0 +1,17 @@ +import type { PageServerLoad } from './$types'; +import { apiClient } from '$lib/server/api'; + +export const load: PageServerLoad = async ({ fetch, locals }) => { + const api = apiClient(fetch); + + const [membersRes, inviteRes] = await Promise.all([ + api.GET('/v1/households/mine/members'), + api.GET('/v1/households/mine/invites') + ]); + + return { + members: membersRes.data ?? [], + currentUserId: locals.benutzer!.id, + activeInvite: inviteRes.data?.data ?? null + }; +}; diff --git a/frontend/src/routes/(app)/members/+page.svelte b/frontend/src/routes/(app)/members/+page.svelte index a4722af..5e54c8f 100644 --- a/frontend/src/routes/(app)/members/+page.svelte +++ b/frontend/src/routes/(app)/members/+page.svelte @@ -1 +1,97 @@ -

Mitglieder

+ + +Mitglieder — Mealprep + +
+

Mitglieder

+ + (showInvitePanel = !showInvitePanel)} + /> + + {#if showInvitePanel && isPlanner && activeInvite} + + {/if} + + (removeTarget = null)} + /> + + (toastVisible = false)} /> +
diff --git a/frontend/src/routes/(app)/members/InviteCard.svelte b/frontend/src/routes/(app)/members/InviteCard.svelte new file mode 100644 index 0000000..d5d0e55 --- /dev/null +++ b/frontend/src/routes/(app)/members/InviteCard.svelte @@ -0,0 +1,17 @@ + + + diff --git a/frontend/src/routes/(app)/members/InviteCard.test.ts b/frontend/src/routes/(app)/members/InviteCard.test.ts new file mode 100644 index 0000000..5974e5d --- /dev/null +++ b/frontend/src/routes/(app)/members/InviteCard.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import InviteCard from './InviteCard.svelte'; + +describe('InviteCard', () => { + it('renders the invite tile', () => { + render(InviteCard, { props: { onclick: vi.fn() } }); + expect(screen.getByTestId('invite-card')).toBeInTheDocument(); + }); + + it('shows a descriptive label', () => { + render(InviteCard, { props: { onclick: vi.fn() } }); + expect(screen.getByText(/einladen/i)).toBeInTheDocument(); + }); + + it('calls onclick when tile is clicked', async () => { + const onclick = vi.fn(); + render(InviteCard, { props: { onclick } }); + await userEvent.click(screen.getByTestId('invite-card')); + expect(onclick).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/src/routes/(app)/members/InvitePanel.svelte b/frontend/src/routes/(app)/members/InvitePanel.svelte new file mode 100644 index 0000000..0eb9075 --- /dev/null +++ b/frontend/src/routes/(app)/members/InvitePanel.svelte @@ -0,0 +1,50 @@ + + +
+

+ {invite.shareUrl || invite.inviteCode} +

+ +
+ + + +
+ +

+ Läuft ab: {formatExpiry(invite.expiresAt)} +

+
diff --git a/frontend/src/routes/(app)/members/InvitePanel.test.ts b/frontend/src/routes/(app)/members/InvitePanel.test.ts new file mode 100644 index 0000000..77ba4f3 --- /dev/null +++ b/frontend/src/routes/(app)/members/InvitePanel.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import InvitePanel from './InvitePanel.svelte'; + +const invite = { + inviteCode: 'ABC123XY', + shareUrl: 'https://example.com/join/ABC123XY', + expiresAt: '2026-12-01T00:00:00Z' +}; + +describe('InvitePanel', () => { + it('shows the invite URL', () => { + render(InvitePanel, { props: { invite, onregenerate: vi.fn() } }); + expect(screen.getByText(/ABC123XY/)).toBeInTheDocument(); + }); + + it('has a copy button', () => { + render(InvitePanel, { props: { invite, onregenerate: vi.fn() } }); + expect(screen.getByTestId('copy-btn')).toBeInTheDocument(); + }); + + it('has a regenerate button', () => { + render(InvitePanel, { props: { invite, onregenerate: vi.fn() } }); + expect(screen.getByTestId('regenerate-btn')).toBeInTheDocument(); + }); + + it('calls onregenerate when regenerate button is clicked', async () => { + const onregenerate = vi.fn(); + render(InvitePanel, { props: { invite, onregenerate } }); + await userEvent.click(screen.getByTestId('regenerate-btn')); + expect(onregenerate).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/src/routes/(app)/members/MemberCard.svelte b/frontend/src/routes/(app)/members/MemberCard.svelte new file mode 100644 index 0000000..cabcd40 --- /dev/null +++ b/frontend/src/routes/(app)/members/MemberCard.svelte @@ -0,0 +1,215 @@ + + +
+ +
+ {initials} +
+ + +
+ {member.displayName} + {#if isCurrentUser} + Du + {/if} +
+ + + {#if editingRole} + { + onrolechange(member, newValue); + editingRole = false; + }} + /> + {:else} + + {member.role === 'planner' ? 'Planer' : 'Mitglied'} + + {/if} + + + {#if isPlanner && !isCurrentUser} + + + + {#if menuOpen} +
+ + +
+ {/if} + {/if} +
+ + diff --git a/frontend/src/routes/(app)/members/MemberCard.test.ts b/frontend/src/routes/(app)/members/MemberCard.test.ts new file mode 100644 index 0000000..a63bf41 --- /dev/null +++ b/frontend/src/routes/(app)/members/MemberCard.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import MemberCard from './MemberCard.svelte'; + +const plannerMember = { + userId: 'u1', + displayName: 'Sarah', + role: 'planner', + joinedAt: '2024-01-01T00:00:00Z' +}; + +const regularMember = { + userId: 'u2', + displayName: 'Tom', + role: 'member', + joinedAt: '2024-02-01T00:00:00Z' +}; + +describe('MemberCard', () => { + it('shows the member display name', () => { + render(MemberCard, { + props: { + member: plannerMember, + isCurrentUser: false, + isPlanner: false, + onremove: vi.fn(), + onrolechange: vi.fn() + } + }); + expect(screen.getByText('Sarah')).toBeInTheDocument(); + }); + + it('shows "Du"-badge when isCurrentUser is true', () => { + render(MemberCard, { + props: { + member: plannerMember, + isCurrentUser: true, + isPlanner: false, + onremove: vi.fn(), + onrolechange: vi.fn() + } + }); + expect(screen.getByText('Du')).toBeInTheDocument(); + }); + + it('does not show kebab button when isCurrentUser is true', () => { + render(MemberCard, { + props: { + member: plannerMember, + isCurrentUser: true, + isPlanner: true, + onremove: vi.fn(), + onrolechange: vi.fn() + } + }); + expect(screen.queryByTestId('kebab-btn')).toBeNull(); + }); + + it('does not show kebab button when viewer is not a planner', () => { + render(MemberCard, { + props: { + member: regularMember, + isCurrentUser: false, + isPlanner: false, + onremove: vi.fn(), + onrolechange: vi.fn() + } + }); + expect(screen.queryByTestId('kebab-btn')).toBeNull(); + }); + + it('shows kebab button for other members when viewer is planner', () => { + render(MemberCard, { + props: { + member: regularMember, + isCurrentUser: false, + isPlanner: true, + onremove: vi.fn(), + onrolechange: vi.fn() + } + }); + expect(screen.getByTestId('kebab-btn')).toBeInTheDocument(); + }); + + it('opens dropdown when kebab is clicked', async () => { + render(MemberCard, { + props: { + member: regularMember, + isCurrentUser: false, + isPlanner: true, + onremove: vi.fn(), + onrolechange: vi.fn() + } + }); + await userEvent.click(screen.getByTestId('kebab-btn')); + expect(screen.getByText('Rolle ändern')).toBeInTheDocument(); + expect(screen.getByText('Entfernen')).toBeInTheDocument(); + }); + + it('calls onremove when "Entfernen" is clicked in dropdown', async () => { + const onremove = vi.fn(); + render(MemberCard, { + props: { + member: regularMember, + isCurrentUser: false, + isPlanner: true, + onremove, + onrolechange: vi.fn() + } + }); + await userEvent.click(screen.getByTestId('kebab-btn')); + await userEvent.click(screen.getByText('Entfernen')); + expect(onremove).toHaveBeenCalledWith(regularMember); + }); + + it('shows SegmentedControl when "Rolle ändern" is clicked', async () => { + render(MemberCard, { + props: { + member: regularMember, + isCurrentUser: false, + isPlanner: true, + onremove: vi.fn(), + onrolechange: vi.fn() + } + }); + await userEvent.click(screen.getByTestId('kebab-btn')); + await userEvent.click(screen.getByText('Rolle ändern')); + expect(screen.getByRole('group')).toBeInTheDocument(); + }); + + it('closes dropdown on Escape key', async () => { + render(MemberCard, { + props: { + member: regularMember, + isCurrentUser: false, + isPlanner: true, + onremove: vi.fn(), + onrolechange: vi.fn() + } + }); + await userEvent.click(screen.getByTestId('kebab-btn')); + expect(screen.getByText('Entfernen')).toBeInTheDocument(); + await userEvent.keyboard('{Escape}'); + expect(screen.queryByText('Entfernen')).toBeNull(); + }); +}); diff --git a/frontend/src/routes/(app)/members/MemberGrid.svelte b/frontend/src/routes/(app)/members/MemberGrid.svelte new file mode 100644 index 0000000..9e994ca --- /dev/null +++ b/frontend/src/routes/(app)/members/MemberGrid.svelte @@ -0,0 +1,67 @@ + + +
+ {#each sortedMembers as m (m.userId)} + onrolechange(m, role)} + /> + {/each} + {#if isPlanner && showInviteCard} + + {/if} +
+ + diff --git a/frontend/src/routes/(app)/members/MemberGrid.test.ts b/frontend/src/routes/(app)/members/MemberGrid.test.ts new file mode 100644 index 0000000..1745123 --- /dev/null +++ b/frontend/src/routes/(app)/members/MemberGrid.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import MemberGrid from './MemberGrid.svelte'; + +const members = [ + { userId: 'u1', displayName: 'Sarah', role: 'planner', joinedAt: '2024-01-01T00:00:00Z' }, + { userId: 'u2', displayName: 'Tom', role: 'member', joinedAt: '2024-02-01T00:00:00Z' }, + { userId: 'u3', displayName: 'Anna', role: 'member', joinedAt: '2024-03-01T00:00:00Z' } +]; + +describe('MemberGrid', () => { + it('renders all member cards', () => { + render(MemberGrid, { + props: { + members, + currentUserId: 'u1', + isPlanner: true, + showInviteCard: true, + onremove: vi.fn(), + onrolechange: vi.fn(), + oninviteclick: vi.fn() + } + }); + expect(screen.getByText('Sarah')).toBeInTheDocument(); + expect(screen.getByText('Tom')).toBeInTheDocument(); + expect(screen.getByText('Anna')).toBeInTheDocument(); + }); + + it('shows invite card when showInviteCard is true and isPlanner is true', () => { + render(MemberGrid, { + props: { + members, + currentUserId: 'u1', + isPlanner: true, + showInviteCard: true, + onremove: vi.fn(), + onrolechange: vi.fn(), + oninviteclick: vi.fn() + } + }); + expect(screen.getByTestId('invite-card')).toBeInTheDocument(); + }); + + it('hides invite card when isPlanner is false', () => { + render(MemberGrid, { + props: { + members, + currentUserId: 'u2', + isPlanner: false, + showInviteCard: true, + onremove: vi.fn(), + onrolechange: vi.fn(), + oninviteclick: vi.fn() + } + }); + expect(screen.queryByTestId('invite-card')).toBeNull(); + }); + + it('shows "Du"-badge on the current user card', () => { + render(MemberGrid, { + props: { + members, + currentUserId: 'u1', + isPlanner: true, + showInviteCard: false, + onremove: vi.fn(), + onrolechange: vi.fn(), + oninviteclick: vi.fn() + } + }); + expect(screen.getByText('Du')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/routes/(app)/members/RemoveDialog.svelte b/frontend/src/routes/(app)/members/RemoveDialog.svelte new file mode 100644 index 0000000..996db2f --- /dev/null +++ b/frontend/src/routes/(app)/members/RemoveDialog.svelte @@ -0,0 +1,86 @@ + + +{#if show} + {#if isMobile()} + +
+

Mitglied entfernen

+

+ Soll {member.displayName} wirklich entfernt werden? +

+
+ + +
+
+
+ {:else} +
+
e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + > +

Mitglied entfernen

+

+ Soll {member.displayName} wirklich entfernt werden? +

+
+ + +
+
+
+ {/if} +{/if} diff --git a/frontend/src/routes/(app)/members/RemoveDialog.test.ts b/frontend/src/routes/(app)/members/RemoveDialog.test.ts new file mode 100644 index 0000000..9da8f9b --- /dev/null +++ b/frontend/src/routes/(app)/members/RemoveDialog.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import userEvent from '@testing-library/user-event'; +import RemoveDialog from './RemoveDialog.svelte'; + +const member = { + userId: 'u2', + displayName: 'Tom', + role: 'member', + joinedAt: '2024-02-01T00:00:00Z' +}; + +describe('RemoveDialog', () => { + it('is not rendered when show is false', () => { + render(RemoveDialog, { + props: { show: false, member, onconfirm: vi.fn(), oncancel: vi.fn() } + }); + expect(screen.queryByTestId('remove-dialog')).toBeNull(); + }); + + it('shows the member displayName in dialog', () => { + render(RemoveDialog, { + props: { show: true, member, onconfirm: vi.fn(), oncancel: vi.fn() } + }); + expect(screen.getByTestId('remove-dialog')).toBeInTheDocument(); + expect(screen.getByText(/Tom/)).toBeInTheDocument(); + }); + + it('calls onconfirm when confirm button is clicked', async () => { + const onconfirm = vi.fn(); + render(RemoveDialog, { + props: { show: true, member, onconfirm, oncancel: vi.fn() } + }); + await userEvent.click(screen.getByTestId('confirm-remove-btn')); + expect(onconfirm).toHaveBeenCalledOnce(); + }); + + it('calls oncancel when cancel button is clicked', async () => { + const oncancel = vi.fn(); + render(RemoveDialog, { + props: { show: true, member, onconfirm: vi.fn(), oncancel } + }); + await userEvent.click(screen.getByRole('button', { name: /abbrechen/i })); + expect(oncancel).toHaveBeenCalledOnce(); + }); + + it('does NOT call oncancel when backdrop is clicked', async () => { + const oncancel = vi.fn(); + render(RemoveDialog, { + props: { show: true, member, onconfirm: vi.fn(), oncancel } + }); + const backdrop = screen.getByTestId('dialog-backdrop'); + await userEvent.click(backdrop); + expect(oncancel).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/routes/(app)/members/[userId]/+server.ts b/frontend/src/routes/(app)/members/[userId]/+server.ts new file mode 100644 index 0000000..d53d711 --- /dev/null +++ b/frontend/src/routes/(app)/members/[userId]/+server.ts @@ -0,0 +1,21 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { apiClient } from '$lib/server/api'; + +export const DELETE: RequestHandler = async ({ fetch, params }) => { + const api = apiClient(fetch); + const { response } = await api.DELETE('/v1/households/mine/members/{userId}', { + params: { path: { userId: params.userId } } + }); + return new Response(null, { status: response?.status ?? 204 }); +}; + +export const PATCH: RequestHandler = async ({ fetch, params, request }) => { + const body = await request.json(); + const api = apiClient(fetch); + const { data, response } = await api.PATCH('/v1/households/mine/members/{userId}', { + params: { path: { userId: params.userId } }, + body + }); + return json(data, { status: response?.status ?? 200 }); +}; diff --git a/frontend/src/routes/(app)/members/[userId]/server.test.ts b/frontend/src/routes/(app)/members/[userId]/server.test.ts new file mode 100644 index 0000000..6bc2b06 --- /dev/null +++ b/frontend/src/routes/(app)/members/[userId]/server.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('$env/dynamic/private', () => ({ env: { BACKEND_URL: 'http://localhost:8080' } })); + +const mockDelete = vi.fn(); +const mockPatch = vi.fn(); +vi.mock('$lib/server/api', () => ({ + apiClient: () => ({ DELETE: mockDelete, PATCH: mockPatch }) +})); + +const USER_UUID = '22222222-2222-2222-2222-222222222222'; + +describe('members server routes', () => { + let DELETE: any; + let PATCH: any; + + beforeEach(async () => { + mockDelete.mockReset(); + mockPatch.mockReset(); + vi.resetModules(); + const mod = await import('./+server'); + DELETE = mod.DELETE; + PATCH = mod.PATCH; + }); + + it('DELETE proxies to backend and returns 204', async () => { + mockDelete.mockResolvedValue({ response: { status: 204 } }); + const event = { + fetch: vi.fn(), + params: { userId: USER_UUID }, + request: { json: vi.fn() } + } as any; + const res = await DELETE(event); + expect(res.status).toBe(204); + }); + + it('PATCH proxies to backend and returns member response', async () => { + mockPatch.mockResolvedValue({ + data: { status: 'success', data: { userId: USER_UUID, displayName: 'Tom', role: 'planner', joinedAt: '' } }, + response: { status: 200 } + }); + const event = { + fetch: vi.fn(), + params: { userId: USER_UUID }, + request: { json: async () => ({ role: 'planner' }) } + } as any; + const res = await PATCH(event); + expect(res.status).toBe(200); + }); +}); diff --git a/frontend/src/routes/(app)/members/invites/+server.ts b/frontend/src/routes/(app)/members/invites/+server.ts new file mode 100644 index 0000000..76d1f44 --- /dev/null +++ b/frontend/src/routes/(app)/members/invites/+server.ts @@ -0,0 +1,9 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { apiClient } from '$lib/server/api'; + +export const POST: RequestHandler = async ({ fetch }) => { + const api = apiClient(fetch); + const { data, response } = await api.POST('/v1/households/mine/invites'); + return json(data, { status: response?.status ?? 201 }); +}; diff --git a/frontend/src/routes/(app)/members/page.server.test.ts b/frontend/src/routes/(app)/members/page.server.test.ts new file mode 100644 index 0000000..55f2bc5 --- /dev/null +++ b/frontend/src/routes/(app)/members/page.server.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('$lib/server/api', () => ({ + apiClient: vi.fn(() => ({ + GET: vi.fn() + })) +})); + +vi.mock('$env/dynamic/private', () => ({ env: { BACKEND_URL: 'http://localhost:8080' } })); + +describe('members page.server load', () => { + let load: any; + + beforeEach(async () => { + vi.resetModules(); + const mod = await import('./+page.server'); + load = mod.load; + }); + + it('returns members and currentUserId', async () => { + const mockGet = vi.fn().mockImplementation((path: string) => { + if (path === '/v1/households/mine/members') { + return { + data: [ + { userId: 'u1', displayName: 'Sarah', role: 'planner', joinedAt: '2024-01-01T00:00:00Z' }, + { userId: 'u2', displayName: 'Tom', role: 'member', joinedAt: '2024-02-01T00:00:00Z' } + ] + }; + } + if (path === '/v1/households/mine/invites') { + return { + data: { + data: { + inviteCode: 'ABC123', + shareUrl: 'https://x.com/join/ABC123', + expiresAt: '2024-12-01T00:00:00Z' + } + } + }; + } + return { data: null }; + }); + + const { apiClient } = await import('$lib/server/api'); + (apiClient as ReturnType).mockReturnValue({ GET: mockGet }); + + const result = await load({ + fetch: vi.fn(), + locals: { benutzer: { id: 'u1', name: 'Sarah', email: 'sarah@example.com' }, haushalt: {} } + } as any); + + expect(result.members).toHaveLength(2); + expect(result.currentUserId).toBe('u1'); + expect(result.activeInvite).toBeDefined(); + }); + + it('returns null activeInvite when no active invite exists', async () => { + const mockGet = vi.fn().mockImplementation((path: string) => { + if (path === '/v1/households/mine/members') return { data: [] }; + if (path === '/v1/households/mine/invites') return { data: null }; + return { data: null }; + }); + + const { apiClient } = await import('$lib/server/api'); + (apiClient as ReturnType).mockReturnValue({ GET: mockGet }); + + const result = await load({ + fetch: vi.fn(), + locals: { benutzer: { id: 'u1', name: 'Sarah', email: 'sarah@example.com' }, haushalt: {} } + } as any); + + expect(result.activeInvite).toBeNull(); + }); +}); -- 2.49.1 From df3b774f0ca34a63ffd2b91aae18c3759d59cf15 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 19:45:32 +0200 Subject: [PATCH 04/23] fix(members): match settings page padding and h1 typography Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/(app)/members/+page.svelte | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/routes/(app)/members/+page.svelte b/frontend/src/routes/(app)/members/+page.svelte index 5e54c8f..0f1591e 100644 --- a/frontend/src/routes/(app)/members/+page.svelte +++ b/frontend/src/routes/(app)/members/+page.svelte @@ -69,8 +69,8 @@ Mitglieder — Mealprep -
-

Mitglieder

+
+

Mitglieder

(toastVisible = false)} /> -
+ -- 2.49.1 From 4e67ff4258e2c336e2fa1411d221ad28fb8f76e9 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 19:54:26 +0200 Subject: [PATCH 05/23] =?UTF-8?q?feat(members):=20align=20grid=20UI=20to?= =?UTF-8?q?=20spec=20=E2=80=94=20avatar=20colors,=20badges,=20join=20date,?= =?UTF-8?q?=20invite=20panel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MemberCard: white bg, 1px border + shadow-card, centered column layout, avatar color by role (green-dark/blue), role badge with role-specific colors, join date "seit DD.MM.YYYY", Du-badge below join date, ⋯ kebab with icons and divider, inline role-control with Abbrechen, blue editing border #B5D4F4 - InviteCard: white bg, 1.5px dashed border, min-height 180px, plus circle, label "Mitglied einladen", full hover state (green border/bg/icon/label) - InvitePanel: white bg, title "Einladelink teilen", description, mono link box, yellow expiry pill when ≤ 24h, text-link "Neuen Link generieren" - RemoveDialog: white bg, padding 28px 32px, "?" in title, updated body text - +page.server.ts: expose householdName from locals.haushalt - +page.svelte: subtitle "{n} Mitglieder · {householdName}" - Tests: add join date format test, Abbrechen test, InvitePanel title test Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/(app)/members/+page.server.ts | 3 +- .../src/routes/(app)/members/+page.svelte | 3 +- .../routes/(app)/members/InviteCard.svelte | 73 +++- .../routes/(app)/members/InvitePanel.svelte | 123 +++++- .../routes/(app)/members/InvitePanel.test.ts | 5 + .../routes/(app)/members/MemberCard.svelte | 383 +++++++++++++----- .../routes/(app)/members/MemberCard.test.ts | 28 ++ .../routes/(app)/members/RemoveDialog.svelte | 30 +- 8 files changed, 505 insertions(+), 143 deletions(-) diff --git a/frontend/src/routes/(app)/members/+page.server.ts b/frontend/src/routes/(app)/members/+page.server.ts index 6e63458..7324498 100644 --- a/frontend/src/routes/(app)/members/+page.server.ts +++ b/frontend/src/routes/(app)/members/+page.server.ts @@ -12,6 +12,7 @@ export const load: PageServerLoad = async ({ fetch, locals }) => { return { members: membersRes.data ?? [], currentUserId: locals.benutzer!.id, - activeInvite: inviteRes.data?.data ?? null + activeInvite: inviteRes.data?.data ?? null, + householdName: locals.haushalt?.name ?? '' }; }; diff --git a/frontend/src/routes/(app)/members/+page.svelte b/frontend/src/routes/(app)/members/+page.svelte index 0f1591e..5c2dd64 100644 --- a/frontend/src/routes/(app)/members/+page.svelte +++ b/frontend/src/routes/(app)/members/+page.svelte @@ -70,7 +70,8 @@ Mitglieder — Mealprep
-

Mitglieder

+

Mitglieder

+

{members.length} Mitglieder{data.householdName ? ` · ${data.householdName}` : ''}

- - Einladen +
+
+
Mitglied einladen
+ + diff --git a/frontend/src/routes/(app)/members/InvitePanel.svelte b/frontend/src/routes/(app)/members/InvitePanel.svelte index 0eb9075..e7bd100 100644 --- a/frontend/src/routes/(app)/members/InvitePanel.svelte +++ b/frontend/src/routes/(app)/members/InvitePanel.svelte @@ -23,28 +23,117 @@ const date = new Date(dateStr); return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); } + + const isExpiringSoon = $derived( + new Date(invite.expiresAt).getTime() - Date.now() <= 24 * 60 * 60 * 1000 + ); -
-

- {invite.shareUrl || invite.inviteCode} -

+
+
Einladelink teilen
+
Wer diesen Link öffnet, kann dem Haushalt als Mitglied beitreten.
-
- - -
-

- Läuft ab: {formatExpiry(invite.expiresAt)} -

+
+ Läuft ab: {formatExpiry(invite.expiresAt)} +
+ +
+ + diff --git a/frontend/src/routes/(app)/members/InvitePanel.test.ts b/frontend/src/routes/(app)/members/InvitePanel.test.ts index 77ba4f3..5212215 100644 --- a/frontend/src/routes/(app)/members/InvitePanel.test.ts +++ b/frontend/src/routes/(app)/members/InvitePanel.test.ts @@ -31,4 +31,9 @@ describe('InvitePanel', () => { await userEvent.click(screen.getByTestId('regenerate-btn')); expect(onregenerate).toHaveBeenCalledOnce(); }); + + it('shows the panel title', () => { + render(InvitePanel, { props: { invite, onregenerate: vi.fn() } }); + expect(screen.getByText('Einladelink teilen')).toBeInTheDocument(); + }); }); diff --git a/frontend/src/routes/(app)/members/MemberCard.svelte b/frontend/src/routes/(app)/members/MemberCard.svelte index cabcd40..7e2cfa3 100644 --- a/frontend/src/routes/(app)/members/MemberCard.svelte +++ b/frontend/src/routes/(app)/members/MemberCard.svelte @@ -1,6 +1,5 @@ -- 2.49.1 From 92f25e56fc4eee536097753741c4b038642f0995 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 21:24:26 +0200 Subject: [PATCH 11/23] feat(invite): add GET /v1/invites/{code} + rework POST accept as signup+join MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - V027 migration: add invited_by FK column on household_invite - HouseholdInvite entity: add invitedBy field, set on createInvite - New DTOs: InviteInfoResponse, AcceptInviteRequest - HouseholdService: add getInviteInfo(), rewrite acceptInvite(code, name, email, password) — creates UserAccount + joins household in one transaction - HouseholdController: GET /v1/invites/{code} (unauthenticated), POST /v1/invites/{code}/accept creates session after join - SecurityConfig: permitAll() for /v1/invites/*, sessionFixation().changeSessionId() Co-Authored-By: Claude Sonnet 4.6 --- .../com/recipeapp/auth/SecurityConfig.java | 2 + .../household/HouseholdController.java | 40 +++++- .../recipeapp/household/HouseholdService.java | 47 ++++--- .../household/dto/AcceptInviteRequest.java | 11 ++ .../household/dto/InviteInfoResponse.java | 6 + .../household/entity/HouseholdInvite.java | 7 ++ .../migration/V027__add_invite_invited_by.sql | 2 + .../recipeapp/auth/SecurityConfigTest.java | 41 ++++++ .../household/HouseholdControllerTest.java | 61 ++++++++- .../household/HouseholdServiceTest.java | 117 ++++++++++++------ 10 files changed, 271 insertions(+), 63 deletions(-) create mode 100644 backend/src/main/java/com/recipeapp/household/dto/AcceptInviteRequest.java create mode 100644 backend/src/main/java/com/recipeapp/household/dto/InviteInfoResponse.java create mode 100644 backend/src/main/resources/db/migration/V027__add_invite_invited_by.sql create mode 100644 backend/src/test/java/com/recipeapp/auth/SecurityConfigTest.java diff --git a/backend/src/main/java/com/recipeapp/auth/SecurityConfig.java b/backend/src/main/java/com/recipeapp/auth/SecurityConfig.java index 27c35dc..eb98a48 100644 --- a/backend/src/main/java/com/recipeapp/auth/SecurityConfig.java +++ b/backend/src/main/java/com/recipeapp/auth/SecurityConfig.java @@ -24,11 +24,13 @@ public class SecurityConfig { .authorizeHttpRequests(auth -> auth .requestMatchers("/v1/auth/signup", "/v1/auth/login").permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() + .requestMatchers("/v1/invites/*").permitAll() .requestMatchers("/v1/admin/**").hasAuthority("ROLE_ADMIN") .anyRequest().authenticated()) .exceptionHandling(ex -> ex .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))) .sessionManagement(session -> session + .sessionFixation().changeSessionId() .maximumSessions(1)); return http.build(); diff --git a/backend/src/main/java/com/recipeapp/household/HouseholdController.java b/backend/src/main/java/com/recipeapp/household/HouseholdController.java index 46c8556..d009393 100644 --- a/backend/src/main/java/com/recipeapp/household/HouseholdController.java +++ b/backend/src/main/java/com/recipeapp/household/HouseholdController.java @@ -1,10 +1,17 @@ package com.recipeapp.household; +import com.recipeapp.auth.entity.UserAccount; import com.recipeapp.common.ApiResponse; import com.recipeapp.household.dto.*; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.web.bind.annotation.*; import java.security.Principal; @@ -71,11 +78,34 @@ public class HouseholdController { return ResponseEntity.ok(ApiResponse.success(response)); } - @PostMapping("/invites/{code}/accept") - public ResponseEntity> acceptInvite( - Principal principal, - @PathVariable String code) { - AcceptInviteResponse response = householdService.acceptInvite(principal.getName(), code); + @GetMapping("/invites/{code}") + public ResponseEntity> getInviteInfo(@PathVariable String code) { + InviteInfoResponse response = householdService.getInviteInfo(code); return ResponseEntity.ok(ApiResponse.success(response)); } + + @PostMapping("/invites/{code}/accept") + public ResponseEntity> acceptInvite( + @PathVariable String code, + @Valid @RequestBody AcceptInviteRequest request, + HttpServletRequest httpRequest) { + AcceptInviteResponse response = householdService.acceptInvite( + code, request.name(), request.email(), request.password()); + authenticateInSession(request.email(), httpRequest); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + private void authenticateInSession(String email, HttpServletRequest request) { + var oldSession = request.getSession(false); + if (oldSession != null) { + oldSession.invalidate(); + } + var auth = UsernamePasswordAuthenticationToken.authenticated( + email, null, List.of(new SimpleGrantedAuthority("ROLE_USER"))); + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(auth); + SecurityContextHolder.setContext(context); + request.getSession(true).setAttribute( + HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context); + } } diff --git a/backend/src/main/java/com/recipeapp/household/HouseholdService.java b/backend/src/main/java/com/recipeapp/household/HouseholdService.java index bcf207c..cb26329 100644 --- a/backend/src/main/java/com/recipeapp/household/HouseholdService.java +++ b/backend/src/main/java/com/recipeapp/household/HouseholdService.java @@ -6,6 +6,7 @@ import com.recipeapp.common.ConflictException; import com.recipeapp.common.ResourceNotFoundException; import com.recipeapp.common.ValidationException; import com.recipeapp.household.dto.*; +import org.springframework.security.crypto.password.PasswordEncoder; import com.recipeapp.household.entity.Household; import com.recipeapp.household.entity.HouseholdInvite; import com.recipeapp.household.entity.HouseholdMember; @@ -38,6 +39,7 @@ public class HouseholdService { private final IngredientCategoryRepository ingredientCategoryRepository; private final TagRepository tagRepository; private final VarietyScoreConfigRepository varietyScoreConfigRepository; + private final PasswordEncoder passwordEncoder; @Value("${app.base-url}") private String baseUrl; @@ -52,7 +54,8 @@ public class HouseholdService { IngredientRepository ingredientRepository, IngredientCategoryRepository ingredientCategoryRepository, TagRepository tagRepository, - VarietyScoreConfigRepository varietyScoreConfigRepository) { + VarietyScoreConfigRepository varietyScoreConfigRepository, + PasswordEncoder passwordEncoder) { this.userAccountRepository = userAccountRepository; this.householdRepository = householdRepository; this.householdMemberRepository = householdMemberRepository; @@ -61,6 +64,7 @@ public class HouseholdService { this.ingredientCategoryRepository = ingredientCategoryRepository; this.tagRepository = tagRepository; this.varietyScoreConfigRepository = varietyScoreConfigRepository; + this.passwordEncoder = passwordEncoder; } @Transactional @@ -167,30 +171,45 @@ public class HouseholdService { String code = generateInviteCode(); Instant expiresAt = Instant.now().plusSeconds(48 * 3600); - HouseholdInvite invite = householdInviteRepository.save( - new HouseholdInvite(household, code, expiresAt)); + HouseholdInvite invite = new HouseholdInvite(household, code, expiresAt); + invite.setInvitedBy(member.getUser()); + householdInviteRepository.save(invite); return toInviteResponse(invite); } - @Transactional - public AcceptInviteResponse acceptInvite(String userEmail, String code) { - UserAccount user = findUser(userEmail); + @Transactional(readOnly = true) + public InviteInfoResponse getInviteInfo(String code) { + HouseholdInvite invite = householdInviteRepository.findByInviteCode(code) + .orElseThrow(() -> new ResourceNotFoundException("Invite not found or invalid")); - if (householdMemberRepository.findByUserEmailIgnoreCase(userEmail).isPresent()) { - throw new ConflictException("User is already in a household"); + if ("used".equals(invite.getStatus()) || invite.getExpiresAt().isBefore(Instant.now())) { + throw new ResourceNotFoundException("Invite not found or invalid"); + } + + String inviterName = invite.getInvitedBy() != null + ? invite.getInvitedBy().getDisplayName() + : invite.getHousehold().getCreatedBy().getDisplayName(); + + return new InviteInfoResponse(invite.getHousehold().getName(), inviterName); + } + + @Transactional + public AcceptInviteResponse acceptInvite(String code, String name, String email, String rawPassword) { + if (userAccountRepository.existsByEmailIgnoreCase(email)) { + throw new ConflictException("Email already registered"); } HouseholdInvite invite = householdInviteRepository.findByInviteCode(code) - .orElseThrow(() -> new ResourceNotFoundException("Invite not found")); + .orElseThrow(() -> new ResourceNotFoundException("Invite not found or invalid")); - if ("used".equals(invite.getStatus())) { - throw new ConflictException("Invite code already used"); - } - if (invite.getExpiresAt().isBefore(Instant.now())) { - throw new ValidationException("Invite code has expired"); + if ("used".equals(invite.getStatus()) || invite.getExpiresAt().isBefore(Instant.now())) { + throw new ResourceNotFoundException("Invite not found or invalid"); } + UserAccount user = userAccountRepository.save( + new UserAccount(email, name, passwordEncoder.encode(rawPassword))); + invite.setStatus("used"); householdInviteRepository.save(invite); diff --git a/backend/src/main/java/com/recipeapp/household/dto/AcceptInviteRequest.java b/backend/src/main/java/com/recipeapp/household/dto/AcceptInviteRequest.java new file mode 100644 index 0000000..cc132f4 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/household/dto/AcceptInviteRequest.java @@ -0,0 +1,11 @@ +package com.recipeapp.household.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record AcceptInviteRequest( + @NotBlank String name, + @NotBlank @Email String email, + @NotBlank @Size(min = 8) String password +) {} diff --git a/backend/src/main/java/com/recipeapp/household/dto/InviteInfoResponse.java b/backend/src/main/java/com/recipeapp/household/dto/InviteInfoResponse.java new file mode 100644 index 0000000..97d31c6 --- /dev/null +++ b/backend/src/main/java/com/recipeapp/household/dto/InviteInfoResponse.java @@ -0,0 +1,6 @@ +package com.recipeapp.household.dto; + +public record InviteInfoResponse( + String householdName, + String inviterName +) {} diff --git a/backend/src/main/java/com/recipeapp/household/entity/HouseholdInvite.java b/backend/src/main/java/com/recipeapp/household/entity/HouseholdInvite.java index 04246e6..56e7a9f 100644 --- a/backend/src/main/java/com/recipeapp/household/entity/HouseholdInvite.java +++ b/backend/src/main/java/com/recipeapp/household/entity/HouseholdInvite.java @@ -1,5 +1,6 @@ package com.recipeapp.household.entity; +import com.recipeapp.auth.entity.UserAccount; import jakarta.persistence.*; import java.time.Instant; import java.util.UUID; @@ -16,6 +17,10 @@ public class HouseholdInvite { @JoinColumn(name = "household_id", nullable = false) private Household household; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "invited_by") + private UserAccount invitedBy; + @Column(name = "invite_code", nullable = false, unique = true, length = 20) private String inviteCode; @@ -38,6 +43,8 @@ public class HouseholdInvite { public UUID getId() { return id; } public Household getHousehold() { return household; } + public UserAccount getInvitedBy() { return invitedBy; } + public void setInvitedBy(UserAccount invitedBy) { this.invitedBy = invitedBy; } public String getInviteCode() { return inviteCode; } public String getStatus() { return status; } public void setStatus(String status) { this.status = status; } diff --git a/backend/src/main/resources/db/migration/V027__add_invite_invited_by.sql b/backend/src/main/resources/db/migration/V027__add_invite_invited_by.sql new file mode 100644 index 0000000..f1519c8 --- /dev/null +++ b/backend/src/main/resources/db/migration/V027__add_invite_invited_by.sql @@ -0,0 +1,2 @@ +ALTER TABLE household_invite + ADD COLUMN invited_by uuid REFERENCES user_account (id) ON DELETE SET NULL; diff --git a/backend/src/test/java/com/recipeapp/auth/SecurityConfigTest.java b/backend/src/test/java/com/recipeapp/auth/SecurityConfigTest.java new file mode 100644 index 0000000..96b7c17 --- /dev/null +++ b/backend/src/test/java/com/recipeapp/auth/SecurityConfigTest.java @@ -0,0 +1,41 @@ +package com.recipeapp.auth; + +import com.recipeapp.AbstractIntegrationTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class SecurityConfigTest extends AbstractIntegrationTest { + + @Autowired + private WebApplicationContext context; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.webAppContextSetup(context) + .apply(springSecurity()) + .build(); + } + + @Test + void inviteInfoEndpointIsAccessibleWithoutAuthentication() throws Exception { + // 404 = unauthenticated request reached the service (ResourceNotFoundException), not 401 + mockMvc.perform(get("/v1/invites/ANYCODE")) + .andExpect(status().isNotFound()); + } + + @Test + void protectedEndpointRequiresAuthentication() throws Exception { + mockMvc.perform(get("/v1/households/mine")) + .andExpect(status().isUnauthorized()); + } +} diff --git a/backend/src/test/java/com/recipeapp/household/HouseholdControllerTest.java b/backend/src/test/java/com/recipeapp/household/HouseholdControllerTest.java index 1a33db3..c889963 100644 --- a/backend/src/test/java/com/recipeapp/household/HouseholdControllerTest.java +++ b/backend/src/test/java/com/recipeapp/household/HouseholdControllerTest.java @@ -2,6 +2,8 @@ package com.recipeapp.household; import com.fasterxml.jackson.databind.ObjectMapper; import com.recipeapp.common.GlobalExceptionHandler; +import com.recipeapp.common.ResourceNotFoundException; +import com.recipeapp.common.ConflictException; import com.recipeapp.household.dto.*; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -158,16 +160,67 @@ class HouseholdControllerTest { } @Test - void acceptInviteShouldReturn200() throws Exception { - var response = new AcceptInviteResponse(UUID.randomUUID(), "Smith family", "member"); + void getInviteInfoShouldReturn200WithHouseholdAndInviterName() throws Exception { + var response = new InviteInfoResponse("Smith family", "Sarah"); - when(householdService.acceptInvite("tom@example.com", "ABC12XYZ")).thenReturn(response); + when(householdService.getInviteInfo("ABC12XYZ")).thenReturn(response); + + mockMvc.perform(get("/v1/invites/ABC12XYZ")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("success")) + .andExpect(jsonPath("$.data.householdName").value("Smith family")) + .andExpect(jsonPath("$.data.inviterName").value("Sarah")); + } + + @Test + void getInviteInfoShouldReturn404WhenInvalid() throws Exception { + when(householdService.getInviteInfo("BADTOKEN")) + .thenThrow(new ResourceNotFoundException("Invite not found or invalid")); + + mockMvc.perform(get("/v1/invites/BADTOKEN")) + .andExpect(status().isNotFound()); + } + + @Test + void acceptInviteShouldReturn200AndCreateSession() throws Exception { + var response = new AcceptInviteResponse(UUID.randomUUID(), "Smith family", "member"); + var request = new AcceptInviteRequest("Tom", "tom@example.com", "secret123"); + + when(householdService.acceptInvite("ABC12XYZ", "Tom", "tom@example.com", "secret123")) + .thenReturn(response); mockMvc.perform(post("/v1/invites/ABC12XYZ/accept") - .principal(() -> "tom@example.com")) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()) .andExpect(jsonPath("$.status").value("success")) .andExpect(jsonPath("$.data.householdName").value("Smith family")) .andExpect(jsonPath("$.data.role").value("member")); } + + @Test + void acceptInviteShouldReturn409WhenEmailAlreadyRegistered() throws Exception { + var request = new AcceptInviteRequest("Tom", "tom@example.com", "secret123"); + + when(householdService.acceptInvite("ABC12XYZ", "Tom", "tom@example.com", "secret123")) + .thenThrow(new ConflictException("Email already registered")); + + mockMvc.perform(post("/v1/invites/ABC12XYZ/accept") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()); + } + + @Test + void acceptInviteShouldReturn404WhenTokenInvalid() throws Exception { + var request = new AcceptInviteRequest("Tom", "tom@example.com", "secret123"); + + when(householdService.acceptInvite("BADTOKEN", "Tom", "tom@example.com", "secret123")) + .thenThrow(new ResourceNotFoundException("Invite not found or invalid")); + + mockMvc.perform(post("/v1/invites/BADTOKEN/accept") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()); + } } diff --git a/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java b/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java index 6545566..650f517 100644 --- a/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java +++ b/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java @@ -41,6 +41,7 @@ class HouseholdServiceTest { @Mock private IngredientCategoryRepository ingredientCategoryRepository; @Mock private TagRepository tagRepository; @Mock private VarietyScoreConfigRepository varietyScoreConfigRepository; + @Mock private org.springframework.security.crypto.password.PasswordEncoder passwordEncoder; @InjectMocks private HouseholdService householdService; @@ -154,86 +155,122 @@ class HouseholdServiceTest { assertThat(result.shareUrl()).endsWith(result.inviteCode()); } + // ── getInviteInfo ───────────────────────────────────────────────────────── + @Test - void acceptInviteShouldAddUserAsMember() { - var user = new UserAccount("tom@example.com", "Tom", "hashed"); + void getInviteInfoShouldReturnHouseholdNameAndInviterName() { var owner = testUser(); var household = new Household("Smith family", owner); var invite = new HouseholdInvite(household, "ABC12XYZ", Instant.now().plusSeconds(86400)); + invite.setInvitedBy(owner); - when(userAccountRepository.findByEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(user)); - when(householdMemberRepository.findByUserEmailIgnoreCase("tom@example.com")).thenReturn(Optional.empty()); when(householdInviteRepository.findByInviteCode("ABC12XYZ")).thenReturn(Optional.of(invite)); - when(householdMemberRepository.save(any(HouseholdMember.class))).thenAnswer(i -> i.getArgument(0)); - AcceptInviteResponse result = householdService.acceptInvite("tom@example.com", "ABC12XYZ"); + InviteInfoResponse result = householdService.getInviteInfo("ABC12XYZ"); assertThat(result.householdName()).isEqualTo("Smith family"); - assertThat(result.role()).isEqualTo("member"); + assertThat(result.inviterName()).isEqualTo("Sarah"); } @Test - void acceptInviteShouldThrowWhenAlreadyInHousehold() { - var user = new UserAccount("tom@example.com", "Tom", "hashed"); - var household = new Household("Other", user); - var member = new HouseholdMember(household, user, "member"); + void getInviteInfoShouldThrow404WhenCodeNotFound() { + when(householdInviteRepository.findByInviteCode("INVALID")).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> householdService.getInviteInfo("INVALID")) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void getInviteInfoShouldThrow404WhenCodeExpired() { + var owner = testUser(); + var household = new Household("Smith family", owner); + var invite = new HouseholdInvite(household, "EXPIRED", Instant.now().minusSeconds(3600)); + invite.setInvitedBy(owner); + + when(householdInviteRepository.findByInviteCode("EXPIRED")).thenReturn(Optional.of(invite)); + + assertThatThrownBy(() -> householdService.getInviteInfo("EXPIRED")) + .isInstanceOf(ResourceNotFoundException.class); + } + + @Test + void getInviteInfoShouldThrow404WhenCodeAlreadyUsed() { + var owner = testUser(); + var household = new Household("Smith family", owner); + var invite = new HouseholdInvite(household, "USED123", Instant.now().plusSeconds(86400)); + invite.setStatus("used"); + invite.setInvitedBy(owner); + + when(householdInviteRepository.findByInviteCode("USED123")).thenReturn(Optional.of(invite)); + + assertThatThrownBy(() -> householdService.getInviteInfo("USED123")) + .isInstanceOf(ResourceNotFoundException.class); + } + + // ── acceptInvite (new: creates account + joins) ─────────────────────────── + + @Test + void acceptInviteShouldCreateAccountAndAddAsMember() { + var owner = testUser(); + var household = new Household("Smith family", owner); var invite = new HouseholdInvite(household, "ABC12XYZ", Instant.now().plusSeconds(86400)); + invite.setInvitedBy(owner); - when(userAccountRepository.findByEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(user)); - when(householdMemberRepository.findByUserEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(member)); + when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(false); + when(householdInviteRepository.findByInviteCode("ABC12XYZ")).thenReturn(Optional.of(invite)); + when(userAccountRepository.save(any(UserAccount.class))).thenAnswer(i -> i.getArgument(0)); + when(householdMemberRepository.save(any(HouseholdMember.class))).thenAnswer(i -> i.getArgument(0)); + when(passwordEncoder.encode("secret123")).thenReturn("hashed"); - assertThatThrownBy(() -> householdService.acceptInvite("tom@example.com", "ABC12XYZ")) + AcceptInviteResponse result = householdService.acceptInvite("ABC12XYZ", "Tom", "tom@example.com", "secret123"); + + assertThat(result.householdName()).isEqualTo("Smith family"); + assertThat(result.role()).isEqualTo("member"); + verify(userAccountRepository).save(any(UserAccount.class)); + verify(householdMemberRepository).save(any(HouseholdMember.class)); + } + + @Test + void acceptInviteShouldThrow409WhenEmailAlreadyRegistered() { + when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(true); + + assertThatThrownBy(() -> householdService.acceptInvite("ABC12XYZ", "Tom", "tom@example.com", "secret123")) .isInstanceOf(ConflictException.class); } @Test - void acceptInviteShouldThrowWhenCodeExpired() { - var user = new UserAccount("tom@example.com", "Tom", "hashed"); + void acceptInviteShouldThrow404WhenCodeExpired() { var owner = testUser(); var household = new Household("Smith family", owner); var invite = new HouseholdInvite(household, "EXPIRED", Instant.now().minusSeconds(3600)); - when(userAccountRepository.findByEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(user)); - when(householdMemberRepository.findByUserEmailIgnoreCase("tom@example.com")).thenReturn(Optional.empty()); + when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(false); when(householdInviteRepository.findByInviteCode("EXPIRED")).thenReturn(Optional.of(invite)); - assertThatThrownBy(() -> householdService.acceptInvite("tom@example.com", "EXPIRED")) - .isInstanceOf(ValidationException.class); + assertThatThrownBy(() -> householdService.acceptInvite("EXPIRED", "Tom", "tom@example.com", "secret123")) + .isInstanceOf(ResourceNotFoundException.class); } @Test - void acceptInviteShouldThrowWhenCodeAlreadyUsed() { - var user = new UserAccount("tom@example.com", "Tom", "hashed"); + void acceptInviteShouldThrow404WhenCodeAlreadyUsed() { var owner = testUser(); var household = new Household("Smith family", owner); var invite = new HouseholdInvite(household, "USED123", Instant.now().plusSeconds(86400)); invite.setStatus("used"); - when(userAccountRepository.findByEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(user)); - when(householdMemberRepository.findByUserEmailIgnoreCase("tom@example.com")).thenReturn(Optional.empty()); + when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(false); when(householdInviteRepository.findByInviteCode("USED123")).thenReturn(Optional.of(invite)); - assertThatThrownBy(() -> householdService.acceptInvite("tom@example.com", "USED123")) - .isInstanceOf(ConflictException.class); - } - - @Test - void acceptInviteShouldThrowWhenInviteNotFound() { - var user = new UserAccount("tom@example.com", "Tom", "hashed"); - - when(userAccountRepository.findByEmailIgnoreCase("tom@example.com")).thenReturn(Optional.of(user)); - when(householdMemberRepository.findByUserEmailIgnoreCase("tom@example.com")).thenReturn(Optional.empty()); - when(householdInviteRepository.findByInviteCode("INVALID")).thenReturn(Optional.empty()); - - assertThatThrownBy(() -> householdService.acceptInvite("tom@example.com", "INVALID")) + assertThatThrownBy(() -> householdService.acceptInvite("USED123", "Tom", "tom@example.com", "secret123")) .isInstanceOf(ResourceNotFoundException.class); } @Test - void acceptInviteShouldThrowWhenUserNotFound() { - when(userAccountRepository.findByEmailIgnoreCase("unknown@example.com")).thenReturn(Optional.empty()); + void acceptInviteShouldThrow404WhenInviteNotFound() { + when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(false); + when(householdInviteRepository.findByInviteCode("INVALID")).thenReturn(Optional.empty()); - assertThatThrownBy(() -> householdService.acceptInvite("unknown@example.com", "ABC12XYZ")) + assertThatThrownBy(() -> householdService.acceptInvite("INVALID", "Tom", "tom@example.com", "secret123")) .isInstanceOf(ResourceNotFoundException.class); } -- 2.49.1 From 6950b3d8dbdd631a88efbcba6ce02e7ffab3dfbe Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 21:31:03 +0200 Subject: [PATCH 12/23] feat(join): implement A4 join household page (/join/[token]) - schema.d.ts: add GET /v1/invites/{code}, InviteInfoResponse, AcceptInviteRequest; update acceptInvite operation - hooks.server.ts: add /join to PUBLIC_ROUTES; redirect authenticated users on /join/* to / - +page.server.ts: load validates token (invalid:true on 404); action creates account + joins + sets session cookie - HouseholdIdentityPanel.svelte: logo, household name (Fraunces), inviter text, static permissions list - JoinForm.svelte: name/email/password + show/hide toggle, "Haushalt beitreten" CTA, field errors, pre-fill - +page.svelte: no-chrome layout, mobile (banner+form stacked) / desktop (400px panel + flex:1) split, invalid-token inline state Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/hooks.server.test.ts | 13 +- frontend/src/hooks.server.ts | 6 +- frontend/src/lib/api/schema.d.ts | 73 ++++++- .../(public)/join/[token]/+page.server.ts | 83 ++++++++ .../routes/(public)/join/[token]/+page.svelte | 46 ++++ .../[token]/HouseholdIdentityPanel.svelte | 41 ++++ .../[token]/HouseholdIdentityPanel.test.ts | 34 +++ .../(public)/join/[token]/JoinForm.svelte | 113 ++++++++++ .../(public)/join/[token]/JoinForm.test.ts | 74 +++++++ .../(public)/join/[token]/page.server.test.ts | 200 ++++++++++++++++++ 10 files changed, 680 insertions(+), 3 deletions(-) create mode 100644 frontend/src/routes/(public)/join/[token]/+page.server.ts create mode 100644 frontend/src/routes/(public)/join/[token]/+page.svelte create mode 100644 frontend/src/routes/(public)/join/[token]/HouseholdIdentityPanel.svelte create mode 100644 frontend/src/routes/(public)/join/[token]/HouseholdIdentityPanel.test.ts create mode 100644 frontend/src/routes/(public)/join/[token]/JoinForm.svelte create mode 100644 frontend/src/routes/(public)/join/[token]/JoinForm.test.ts create mode 100644 frontend/src/routes/(public)/join/[token]/page.server.test.ts diff --git a/frontend/src/hooks.server.test.ts b/frontend/src/hooks.server.test.ts index 4a59d78..4da0791 100644 --- a/frontend/src/hooks.server.test.ts +++ b/frontend/src/hooks.server.test.ts @@ -42,7 +42,7 @@ describe('auth guard (hooks.server.ts handle)', () => { expect(resolve).toHaveBeenCalledWith(event); }); - it.each(['/login', '/login/', '/register', '/signup', '/signup/', '/invite/abc123'])( + it.each(['/login', '/login/', '/register', '/signup', '/signup/', '/invite/abc123', '/join/ABC12XYZ'])( 'allows public route %s without auth', async (path) => { const { event, resolve } = createEvent(path); @@ -51,6 +51,17 @@ describe('auth guard (hooks.server.ts handle)', () => { } ); + it('redirects authenticated user on /join/[token] to /', async () => { + const { event, resolve } = createEvent('/join/ABC12XYZ', 'valid-session'); + try { + await handle({ event, resolve }); + expect.unreachable(); + } catch (e: any) { + expect(e.status).toBe(302); + expect(e.location).toBe('/'); + } + }); + it.each(['/_app/immutable/chunks/app.js', '/favicon.ico'])( 'allows static asset %s without auth', async (path) => { diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts index 6f2ea43..705b340 100644 --- a/frontend/src/hooks.server.ts +++ b/frontend/src/hooks.server.ts @@ -2,7 +2,7 @@ import type { Handle } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit'; import { apiClient } from '$lib/server/api'; -const PUBLIC_ROUTES = ['/login', '/register', '/signup', '/invite']; +const PUBLIC_ROUTES = ['/login', '/register', '/signup', '/invite', '/join']; const STATIC_PREFIXES = ['/_app/', '/favicon']; @@ -20,6 +20,10 @@ function loginRedirect(pathname: string): never { export const handle: Handle = async ({ event, resolve }) => { if (isPublicRoute(event.url.pathname)) { + const isJoinRoute = event.url.pathname.startsWith('/join/'); + if (isJoinRoute && event.cookies.get('JSESSIONID')) { + throw redirect(302, '/'); + } return resolve(event); } diff --git a/frontend/src/lib/api/schema.d.ts b/frontend/src/lib/api/schema.d.ts index a6cc337..d80b106 100644 --- a/frontend/src/lib/api/schema.d.ts +++ b/frontend/src/lib/api/schema.d.ts @@ -148,6 +148,22 @@ export interface paths { patch?: never; trace?: never; }; + "/v1/invites/{code}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getInviteInfo"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/v1/invites/{code}/accept": { parameters: { query?: never; @@ -739,6 +755,20 @@ export interface components { data?: components["schemas"]["AcceptInviteResponse"]; meta?: components["schemas"]["Meta"]; }; + InviteInfoResponse: { + householdName?: string; + inviterName?: string; + }; + ApiResponseInviteInfoResponse: { + status?: string; + data?: components["schemas"]["InviteInfoResponse"]; + meta?: components["schemas"]["Meta"]; + }; + AcceptInviteRequest: { + name: string; + email: string; + password: string; + }; Meta: { pagination?: components["schemas"]["Pagination"]; }; @@ -1345,7 +1375,7 @@ export interface operations { }; }; }; - acceptInvite: { + getInviteInfo: { parameters: { query?: never; header?: never; @@ -1355,6 +1385,37 @@ export interface operations { cookie?: never; }; requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseInviteInfoResponse"]; + }; + }; + /** @description Not found */ + 404: { + headers: { [name: string]: unknown }; + content: { "*/*": components["schemas"]["ApiError"] }; + }; + }; + }; + acceptInvite: { + parameters: { + query?: never; + header?: never; + path: { + code: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AcceptInviteRequest"]; + }; + }; responses: { /** @description OK */ 200: { @@ -1365,6 +1426,16 @@ export interface operations { "*/*": components["schemas"]["ApiResponseAcceptInviteResponse"]; }; }; + /** @description Email already registered */ + 409: { + headers: { [name: string]: unknown }; + content: { "*/*": components["schemas"]["ApiError"] }; + }; + /** @description Invite not found or invalid */ + 404: { + headers: { [name: string]: unknown }; + content: { "*/*": components["schemas"]["ApiError"] }; + }; }; }; listCategories: { diff --git a/frontend/src/routes/(public)/join/[token]/+page.server.ts b/frontend/src/routes/(public)/join/[token]/+page.server.ts new file mode 100644 index 0000000..5ba5970 --- /dev/null +++ b/frontend/src/routes/(public)/join/[token]/+page.server.ts @@ -0,0 +1,83 @@ +import { fail, redirect } from '@sveltejs/kit'; +import { apiClient } from '$lib/server/api'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ params, fetch }) => { + const api = apiClient(fetch); + const { data, error } = await api.GET('/v1/invites/{code}', { + params: { path: { code: params.token } } + }); + + if (error || !data?.data) { + return { invalid: true }; + } + + return { + invalid: false, + householdName: data.data.householdName ?? '', + inviterName: data.data.inviterName ?? '' + }; +}; + +export const actions = { + default: async ({ params, request, fetch, cookies }) => { + const formData = await request.formData(); + const name = (formData.get('name') ?? '').toString().trim(); + const email = (formData.get('email') ?? '').toString().trim(); + const password = (formData.get('password') ?? '').toString(); + + const errors: Record = {}; + + if (!name) { + errors.name = 'Name ist erforderlich'; + } + + const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailPattern.test(email)) { + errors.email = 'Ungültige E-Mail-Adresse'; + } + + if (password.length < 8) { + errors.password = 'Mindestens 8 Zeichen'; + } + + if (Object.keys(errors).length > 0) { + return fail(400, { errors, name, email }); + } + + const api = apiClient(fetch); + const { error, response } = await api.POST('/v1/invites/{code}/accept', { + params: { path: { code: params.token } }, + body: { name, email, password } + }); + + if (error) { + if (error.status === 409) { + return fail(409, { + errors: { + email: 'Diese E-Mail-Adresse ist bereits registriert. Anmelden →' + }, + name, + email + }); + } + return fail(400, { + errors: { form: 'Einladung ungültig oder abgelaufen.' }, + name, + email + }); + } + + const sessionId = response?.headers.get('set-cookie')?.match(/JSESSIONID=([^;]+)/i)?.[1]; + if (sessionId) { + cookies.set('JSESSIONID', sessionId, { + path: '/', + httpOnly: true, + sameSite: 'lax', + secure: true + }); + } + + redirect(303, '/'); + } +} satisfies Actions; diff --git a/frontend/src/routes/(public)/join/[token]/+page.svelte b/frontend/src/routes/(public)/join/[token]/+page.svelte new file mode 100644 index 0000000..9b52121 --- /dev/null +++ b/frontend/src/routes/(public)/join/[token]/+page.svelte @@ -0,0 +1,46 @@ + + + + Haushalt beitreten — Mealplan + + +{#if data.invalid} +
+
+

+ Einladung ungültig oder abgelaufen +

+

+ Bitte bitte den Einladenden, einen neuen Link zu senden. +

+
+
+{:else} + + +
+ +
+ +
+ + +
+
+

+ Konto erstellen & beitreten +

+ +
+
+
+{/if} diff --git a/frontend/src/routes/(public)/join/[token]/HouseholdIdentityPanel.svelte b/frontend/src/routes/(public)/join/[token]/HouseholdIdentityPanel.svelte new file mode 100644 index 0000000..a2027dd --- /dev/null +++ b/frontend/src/routes/(public)/join/[token]/HouseholdIdentityPanel.svelte @@ -0,0 +1,41 @@ + + +
+ + + + +
+

+ {householdName} +

+

+ Eingeladen von {inviterName} +

+
+ + +
+

+ Als Mitglied kannst du +

+
    +
  • + + Wochenplan einsehen +
  • +
  • + + Einkaufsliste abhaken +
  • +
  • + + Artikel zur Liste hinzufügen +
  • +
+
+
diff --git a/frontend/src/routes/(public)/join/[token]/HouseholdIdentityPanel.test.ts b/frontend/src/routes/(public)/join/[token]/HouseholdIdentityPanel.test.ts new file mode 100644 index 0000000..f114729 --- /dev/null +++ b/frontend/src/routes/(public)/join/[token]/HouseholdIdentityPanel.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import HouseholdIdentityPanel from './HouseholdIdentityPanel.svelte'; + +describe('HouseholdIdentityPanel', () => { + it('renders household name', () => { + render(HouseholdIdentityPanel, { + props: { householdName: 'Smith family', inviterName: 'Sarah' } + }); + expect(screen.getByText('Smith family')).toBeInTheDocument(); + }); + + it('renders inviter name', () => { + render(HouseholdIdentityPanel, { + props: { householdName: 'Smith family', inviterName: 'Sarah' } + }); + expect(screen.getByText(/Sarah/)).toBeInTheDocument(); + }); + + it('renders all three member permissions', () => { + render(HouseholdIdentityPanel, { + props: { householdName: 'Smith family', inviterName: 'Sarah' } + }); + expect(screen.getByText(/Wochenplan/i)).toBeInTheDocument(); + expect(screen.getByText(/Einkaufsliste/i)).toBeInTheDocument(); + }); + + it('renders app logo', () => { + render(HouseholdIdentityPanel, { + props: { householdName: 'Smith family', inviterName: 'Sarah' } + }); + expect(screen.getByText('🥗')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/routes/(public)/join/[token]/JoinForm.svelte b/frontend/src/routes/(public)/join/[token]/JoinForm.svelte new file mode 100644 index 0000000..059602c --- /dev/null +++ b/frontend/src/routes/(public)/join/[token]/JoinForm.svelte @@ -0,0 +1,113 @@ + + +
+ + {#if form?.errors?.form} +

+ {form.errors.form} +

+ {/if} + + +
+ + + {#if form?.errors?.name} +

+ {form.errors.name} +

+ {/if} +
+ + +
+ + + {#if form?.errors?.email} +

+ {form.errors.email} +

+ {/if} +
+ + +
+ +
+ + +
+ {#if form?.errors?.password} +

+ {form.errors.password} +

+ {/if} +
+ + + +
diff --git a/frontend/src/routes/(public)/join/[token]/JoinForm.test.ts b/frontend/src/routes/(public)/join/[token]/JoinForm.test.ts new file mode 100644 index 0000000..7fbae6c --- /dev/null +++ b/frontend/src/routes/(public)/join/[token]/JoinForm.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { userEvent } from '@testing-library/user-event'; +import JoinForm from './JoinForm.svelte'; + +vi.mock('$app/forms', () => ({ + enhance: () => ({ destroy: () => {} }) +})); + +describe('JoinForm', () => { + it('renders name, email and password fields', () => { + render(JoinForm); + expect(screen.getByLabelText('Name')).toBeInTheDocument(); + expect(screen.getByLabelText('E-Mail')).toBeInTheDocument(); + expect(screen.getByLabelText('Passwort')).toBeInTheDocument(); + }); + + it('renders "Haushalt beitreten" submit button', () => { + render(JoinForm); + expect(screen.getByRole('button', { name: /Haushalt beitreten/i })).toBeInTheDocument(); + }); + + it('password field is initially of type password', () => { + render(JoinForm); + expect(screen.getByLabelText('Passwort')).toHaveAttribute('type', 'password'); + }); + + it('password toggle switches type to text', async () => { + const user = userEvent.setup(); + render(JoinForm); + + const toggle = screen.getByRole('button', { name: /passwort anzeigen/i }); + await user.click(toggle); + expect(screen.getByLabelText('Passwort')).toHaveAttribute('type', 'text'); + }); + + it('shows form-level error from form prop', () => { + render(JoinForm, { + props: { + form: { + errors: { form: 'Einladung ungültig oder abgelaufen.' } + } + } + }); + expect(screen.getByText('Einladung ungültig oder abgelaufen.')).toBeInTheDocument(); + }); + + it('shows email-taken error with login link', () => { + render(JoinForm, { + props: { + form: { + errors: { + email: 'Diese E-Mail-Adresse ist bereits registriert. Anmelden →' + } + } + } + }); + expect(screen.getByText(/bereits registriert/)).toBeInTheDocument(); + }); + + it('pre-fills name and email from form prop', () => { + render(JoinForm, { + props: { + form: { + errors: {}, + name: 'Tom', + email: 'tom@example.com' + } + } + }); + expect(screen.getByLabelText('Name')).toHaveValue('Tom'); + expect(screen.getByLabelText('E-Mail')).toHaveValue('tom@example.com'); + }); +}); diff --git a/frontend/src/routes/(public)/join/[token]/page.server.test.ts b/frontend/src/routes/(public)/join/[token]/page.server.test.ts new file mode 100644 index 0000000..db0ff2f --- /dev/null +++ b/frontend/src/routes/(public)/join/[token]/page.server.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('$env/dynamic/private', () => ({ + env: { BACKEND_URL: 'http://localhost:8080' } +})); + +const mockGet = vi.fn(); +const mockPost = vi.fn(); +vi.mock('$lib/server/api', () => ({ + apiClient: () => ({ GET: mockGet, POST: mockPost }) +})); + +describe('join page load function', () => { + let load: any; + + beforeEach(async () => { + mockGet.mockReset(); + vi.resetModules(); + const mod = await import('./+page.server'); + load = mod.load; + }); + + function createLoadEvent(token: string) { + return { + params: { token }, + fetch: vi.fn() + } as any; + } + + it('returns householdName and inviterName for valid token', async () => { + mockGet.mockResolvedValue({ + data: { data: { householdName: 'Smith family', inviterName: 'Sarah' } }, + error: undefined + }); + + const result = await load(createLoadEvent('ABC12XYZ')); + + expect(result.invalid).toBeFalsy(); + expect(result.householdName).toBe('Smith family'); + expect(result.inviterName).toBe('Sarah'); + }); + + it('returns invalid:true on 404 (expired/used/unknown token)', async () => { + mockGet.mockResolvedValue({ + data: undefined, + error: { status: 404 } + }); + + const result = await load(createLoadEvent('BADTOKEN')); + + expect(result.invalid).toBe(true); + }); +}); + +describe('join page form action', () => { + let actions: any; + + beforeEach(async () => { + mockPost.mockReset(); + vi.resetModules(); + const mod = await import('./+page.server'); + actions = mod.actions; + }); + + function createRequest(token: string, formData: Record) { + const fd = new FormData(); + for (const [key, value] of Object.entries(formData)) { + fd.append(key, value); + } + return { + params: { token }, + request: { formData: () => Promise.resolve(fd) }, + fetch: vi.fn(), + cookies: { get: vi.fn(), set: vi.fn() } + } as any; + } + + it('calls POST /v1/invites/{token}/accept with form data', async () => { + mockPost.mockResolvedValue({ + data: { data: { householdName: 'Smith family', role: 'member' } }, + error: undefined, + response: { headers: { get: vi.fn().mockReturnValue(null) } } + }); + + try { + await actions.default(createRequest('ABC12XYZ', { + name: 'Tom', + email: 'tom@example.com', + password: 'secret123' + })); + } catch { + // redirect throws + } + + expect(mockPost).toHaveBeenCalledWith('/v1/invites/{code}/accept', { + params: { path: { code: 'ABC12XYZ' } }, + body: { name: 'Tom', email: 'tom@example.com', password: 'secret123' } + }); + }); + + it('sets JSESSIONID cookie and redirects to / on success', async () => { + mockPost.mockResolvedValue({ + data: { data: { householdName: 'Smith family', role: 'member' } }, + error: undefined, + response: { + headers: { get: vi.fn().mockReturnValue('JSESSIONID=abc123; Path=/; HttpOnly') } + } + }); + + const event = createRequest('ABC12XYZ', { + name: 'Tom', + email: 'tom@example.com', + password: 'secret123' + }); + + try { + await actions.default(event); + expect.unreachable(); + } catch (e: any) { + expect(e.status).toBe(303); + expect(e.location).toBe('/'); + } + + expect(event.cookies.set).toHaveBeenCalledWith( + 'JSESSIONID', + 'abc123', + expect.objectContaining({ path: '/', secure: true }) + ); + }); + + it('returns 409 fail with email-taken message on conflict', async () => { + mockPost.mockResolvedValue({ + data: undefined, + error: { status: 409 }, + response: { headers: { get: vi.fn().mockReturnValue(null) } } + }); + + const result = await actions.default(createRequest('ABC12XYZ', { + name: 'Tom', + email: 'tom@example.com', + password: 'secret123' + })); + + expect(result.status).toBe(409); + expect(result.data.errors.email).toContain('registriert'); + }); + + it('returns 400 fail on invalid token (404 from backend)', async () => { + mockPost.mockResolvedValue({ + data: undefined, + error: { status: 404 }, + response: { headers: { get: vi.fn().mockReturnValue(null) } } + }); + + const result = await actions.default(createRequest('BADTOKEN', { + name: 'Tom', + email: 'tom@example.com', + password: 'secret123' + })); + + expect(result.status).toBe(400); + expect(result.data.errors.form).toBeTruthy(); + }); + + it('rejects empty name with validation error', async () => { + const result = await actions.default(createRequest('ABC12XYZ', { + name: '', + email: 'tom@example.com', + password: 'secret123' + })); + + expect(result.status).toBe(400); + expect(result.data.errors.name).toBeTruthy(); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it('rejects invalid email with validation error', async () => { + const result = await actions.default(createRequest('ABC12XYZ', { + name: 'Tom', + email: 'notanemail', + password: 'secret123' + })); + + expect(result.status).toBe(400); + expect(result.data.errors.email).toBeTruthy(); + expect(mockPost).not.toHaveBeenCalled(); + }); + + it('rejects short password with validation error', async () => { + const result = await actions.default(createRequest('ABC12XYZ', { + name: 'Tom', + email: 'tom@example.com', + password: 'short' + })); + + expect(result.status).toBe(400); + expect(result.data.errors.password).toBeTruthy(); + expect(mockPost).not.toHaveBeenCalled(); + }); +}); -- 2.49.1 From c5ec3396b28749df94c79ec121b0981faa2ccabf Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 21:51:19 +0200 Subject: [PATCH 13/23] fix(migration): deduplicate active invites before creating unique index in V026 Dev databases that accumulated multiple pending invites before V026 was written would fail to create uq_household_invite_active. Added a cleanup UPDATE that marks all-but-the-latest invite per household as invalidated before the index is created. Co-Authored-By: Claude Sonnet 4.6 --- .../db/migration/V026__add_invite_invalidated_at.sql | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/backend/src/main/resources/db/migration/V026__add_invite_invalidated_at.sql b/backend/src/main/resources/db/migration/V026__add_invite_invalidated_at.sql index c9a5511..f210870 100644 --- a/backend/src/main/resources/db/migration/V026__add_invite_invalidated_at.sql +++ b/backend/src/main/resources/db/migration/V026__add_invite_invalidated_at.sql @@ -1,6 +1,17 @@ ALTER TABLE household_invite ADD COLUMN invalidated_at timestamptz; +-- Mark all but the most-recent invite per household as invalidated, +-- so the unique partial index below can be created on dev databases +-- that accumulated multiple pending invites before this migration was added. +UPDATE household_invite +SET invalidated_at = NOW() +WHERE id NOT IN ( + SELECT DISTINCT ON (household_id) id + FROM household_invite + ORDER BY household_id, expires_at DESC +); + CREATE UNIQUE INDEX uq_household_invite_active ON household_invite (household_id) WHERE invalidated_at IS NULL; -- 2.49.1 From 6aed30362773bcfda0cbe91f5db7993acac50921 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 22:00:47 +0200 Subject: [PATCH 14/23] fix(join): permit /v1/invites/** (not just /*) + match panel color to login - SecurityConfig: /** covers /v1/invites/{code}/accept (two path segments); /* only matched one segment so the accept endpoint was returning 401 - HouseholdIdentityPanel + page: use --green-dark bg (matching BrandPanel on login) instead of --green-tint; text updated to white/--green-light Co-Authored-By: Claude Sonnet 4.6 --- .../com/recipeapp/auth/SecurityConfig.java | 2 +- .../recipeapp/auth/SecurityConfigTest.java | 10 +++++++++ .../routes/(public)/join/[token]/+page.svelte | 2 +- .../[token]/HouseholdIdentityPanel.svelte | 22 +++++++++---------- 4 files changed, 23 insertions(+), 13 deletions(-) diff --git a/backend/src/main/java/com/recipeapp/auth/SecurityConfig.java b/backend/src/main/java/com/recipeapp/auth/SecurityConfig.java index eb98a48..7de26fe 100644 --- a/backend/src/main/java/com/recipeapp/auth/SecurityConfig.java +++ b/backend/src/main/java/com/recipeapp/auth/SecurityConfig.java @@ -24,7 +24,7 @@ public class SecurityConfig { .authorizeHttpRequests(auth -> auth .requestMatchers("/v1/auth/signup", "/v1/auth/login").permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() - .requestMatchers("/v1/invites/*").permitAll() + .requestMatchers("/v1/invites/**").permitAll() .requestMatchers("/v1/admin/**").hasAuthority("ROLE_ADMIN") .anyRequest().authenticated()) .exceptionHandling(ex -> ex diff --git a/backend/src/test/java/com/recipeapp/auth/SecurityConfigTest.java b/backend/src/test/java/com/recipeapp/auth/SecurityConfigTest.java index 96b7c17..7bfe67b 100644 --- a/backend/src/test/java/com/recipeapp/auth/SecurityConfigTest.java +++ b/backend/src/test/java/com/recipeapp/auth/SecurityConfigTest.java @@ -10,6 +10,7 @@ import org.springframework.web.context.WebApplicationContext; import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; 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.status; class SecurityConfigTest extends AbstractIntegrationTest { @@ -33,6 +34,15 @@ class SecurityConfigTest extends AbstractIntegrationTest { .andExpect(status().isNotFound()); } + @Test + void inviteAcceptEndpointIsAccessibleWithoutAuthentication() throws Exception { + // 400 = validation error (empty body), but NOT 401 — proves the path is permitted + mockMvc.perform(post("/v1/invites/ANYCODE/accept") + .contentType(org.springframework.http.MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()); + } + @Test void protectedEndpointRequiresAuthentication() throws Exception { mockMvc.perform(get("/v1/households/mine")) diff --git a/frontend/src/routes/(public)/join/[token]/+page.svelte b/frontend/src/routes/(public)/join/[token]/+page.svelte index 9b52121..c271495 100644 --- a/frontend/src/routes/(public)/join/[token]/+page.svelte +++ b/frontend/src/routes/(public)/join/[token]/+page.svelte @@ -26,7 +26,7 @@
-
+
-
+

{householdName}

-

+

Eingeladen von {inviterName}

-
-

+

+

Als Mitglied kannst du

    -
  • - +
  • + Wochenplan einsehen
  • -
  • - +
  • + Einkaufsliste abhaken
  • -
  • - +
  • + Artikel zur Liste hinzufügen
-- 2.49.1 From 44fd39870109575dd9fc8c48a4c1bb6c011b4ccf Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 22:04:24 +0200 Subject: [PATCH 15/23] fix(invite): saveAndFlush invalidation before INSERT + set invalidated_at on accept - createInvite: use saveAndFlush when invalidating existing invite so the UPDATE is guaranteed to hit the DB before the new INSERT, preventing duplicate key violation on uq_household_invite_active - acceptInvite: also set invalidated_at when marking invite as used, so accepted invites are fully removed from the partial unique index and cannot block future invite creation Co-Authored-By: Claude Sonnet 4.6 --- backend/Dockerfile | 1 + .../main/java/com/recipeapp/household/HouseholdService.java | 3 ++- .../java/com/recipeapp/household/HouseholdServiceTest.java | 4 +++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 0b4c221..e62a95d 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -7,6 +7,7 @@ COPY src src RUN ./mvnw package -DskipTests -B FROM eclipse-temurin:21-jre-alpine +RUN apk add --no-cache libwebp WORKDIR /app COPY --from=build /app/target/*.jar app.jar EXPOSE 8080 diff --git a/backend/src/main/java/com/recipeapp/household/HouseholdService.java b/backend/src/main/java/com/recipeapp/household/HouseholdService.java index cb26329..1fa75dd 100644 --- a/backend/src/main/java/com/recipeapp/household/HouseholdService.java +++ b/backend/src/main/java/com/recipeapp/household/HouseholdService.java @@ -165,7 +165,7 @@ public class HouseholdService { householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(household.getId()) .ifPresent(existing -> { existing.setInvalidatedAt(Instant.now()); - householdInviteRepository.save(existing); + householdInviteRepository.saveAndFlush(existing); }); String code = generateInviteCode(); @@ -211,6 +211,7 @@ public class HouseholdService { new UserAccount(email, name, passwordEncoder.encode(rawPassword))); invite.setStatus("used"); + invite.setInvalidatedAt(Instant.now()); householdInviteRepository.save(invite); Household household = invite.getHousehold(); diff --git a/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java b/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java index 650f517..5e36228 100644 --- a/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java +++ b/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java @@ -507,11 +507,13 @@ class HouseholdServiceTest { when(householdMemberRepository.findByUserEmailIgnoreCase("sarah@example.com")).thenReturn(Optional.of(member)); when(householdInviteRepository.findByHouseholdIdAndInvalidatedAtIsNull(any())).thenReturn(Optional.of(existingInvite)); + when(householdInviteRepository.saveAndFlush(any(HouseholdInvite.class))).thenAnswer(i -> i.getArgument(0)); when(householdInviteRepository.save(any(HouseholdInvite.class))).thenAnswer(i -> i.getArgument(0)); householdService.createInvite("sarah@example.com"); assertThat(existingInvite.getInvalidatedAt()).isNotNull(); - verify(householdInviteRepository, times(2)).save(any(HouseholdInvite.class)); + verify(householdInviteRepository).saveAndFlush(existingInvite); + verify(householdInviteRepository).save(any(HouseholdInvite.class)); } } -- 2.49.1 From 0ab1ba0b1b194a04fc966640356be01ea2289a74 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 22:22:07 +0200 Subject: [PATCH 16/23] fix(invite): reject invalidated invites in getInviteInfo Superseded invites had invalidatedAt set but status stayed 'pending', so they passed the validity check and could still be viewed and accepted. Add invalidatedAt != null guard to getInviteInfo. Co-Authored-By: Claude Sonnet 4.6 --- .../com/recipeapp/household/HouseholdService.java | 4 +++- .../recipeapp/household/HouseholdServiceTest.java | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/backend/src/main/java/com/recipeapp/household/HouseholdService.java b/backend/src/main/java/com/recipeapp/household/HouseholdService.java index 1fa75dd..74ce768 100644 --- a/backend/src/main/java/com/recipeapp/household/HouseholdService.java +++ b/backend/src/main/java/com/recipeapp/household/HouseholdService.java @@ -183,7 +183,9 @@ public class HouseholdService { HouseholdInvite invite = householdInviteRepository.findByInviteCode(code) .orElseThrow(() -> new ResourceNotFoundException("Invite not found or invalid")); - if ("used".equals(invite.getStatus()) || invite.getExpiresAt().isBefore(Instant.now())) { + if ("used".equals(invite.getStatus()) + || invite.getInvalidatedAt() != null + || invite.getExpiresAt().isBefore(Instant.now())) { throw new ResourceNotFoundException("Invite not found or invalid"); } diff --git a/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java b/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java index 5e36228..1c9cc96 100644 --- a/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java +++ b/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java @@ -207,6 +207,20 @@ class HouseholdServiceTest { .isInstanceOf(ResourceNotFoundException.class); } + @Test + void getInviteInfoShouldThrow404WhenInviteIsInvalidated() { + var owner = testUser(); + var household = new Household("Smith family", owner); + var invite = new HouseholdInvite(household, "SUPERSEDED", Instant.now().plusSeconds(86400)); + invite.setInvitedBy(owner); + invite.setInvalidatedAt(Instant.now()); // superseded by a new invite + + when(householdInviteRepository.findByInviteCode("SUPERSEDED")).thenReturn(Optional.of(invite)); + + assertThatThrownBy(() -> householdService.getInviteInfo("SUPERSEDED")) + .isInstanceOf(ResourceNotFoundException.class); + } + // ── acceptInvite (new: creates account + joins) ─────────────────────────── @Test -- 2.49.1 From 73af11e84bcfed61e2b35249573cc264568fbea2 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 22:22:54 +0200 Subject: [PATCH 17/23] fix(invite): reject invalidated invites in acceptInvite Same invalidatedAt gap as getInviteInfo: a superseded invite (status still 'pending', invalidatedAt set) could still be used to create an account and join the household. Co-Authored-By: Claude Sonnet 4.6 --- .../com/recipeapp/household/HouseholdService.java | 4 +++- .../recipeapp/household/HouseholdServiceTest.java | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/backend/src/main/java/com/recipeapp/household/HouseholdService.java b/backend/src/main/java/com/recipeapp/household/HouseholdService.java index 74ce768..e72a9d4 100644 --- a/backend/src/main/java/com/recipeapp/household/HouseholdService.java +++ b/backend/src/main/java/com/recipeapp/household/HouseholdService.java @@ -205,7 +205,9 @@ public class HouseholdService { HouseholdInvite invite = householdInviteRepository.findByInviteCode(code) .orElseThrow(() -> new ResourceNotFoundException("Invite not found or invalid")); - if ("used".equals(invite.getStatus()) || invite.getExpiresAt().isBefore(Instant.now())) { + if ("used".equals(invite.getStatus()) + || invite.getInvalidatedAt() != null + || invite.getExpiresAt().isBefore(Instant.now())) { throw new ResourceNotFoundException("Invite not found or invalid"); } diff --git a/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java b/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java index 1c9cc96..223d5cd 100644 --- a/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java +++ b/backend/src/test/java/com/recipeapp/household/HouseholdServiceTest.java @@ -288,6 +288,20 @@ class HouseholdServiceTest { .isInstanceOf(ResourceNotFoundException.class); } + @Test + void acceptInviteShouldThrow404WhenInviteIsInvalidated() { + var owner = testUser(); + var household = new Household("Smith family", owner); + var invite = new HouseholdInvite(household, "SUPERSEDED", Instant.now().plusSeconds(86400)); + invite.setInvalidatedAt(Instant.now()); // superseded by a new invite + + when(userAccountRepository.existsByEmailIgnoreCase("tom@example.com")).thenReturn(false); + when(householdInviteRepository.findByInviteCode("SUPERSEDED")).thenReturn(Optional.of(invite)); + + assertThatThrownBy(() -> householdService.acceptInvite("SUPERSEDED", "Tom", "tom@example.com", "secret123")) + .isInstanceOf(ResourceNotFoundException.class); + } + @Test void createHouseholdShouldThrowWhenUserNotFound() { when(userAccountRepository.findByEmailIgnoreCase("unknown@example.com")).thenReturn(Optional.empty()); -- 2.49.1 From 0b182a33fd94662b885e4531cc9a21748c20199e Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 22:24:58 +0200 Subject: [PATCH 18/23] refactor(auth): extract authenticateInSession to AuthService Remove duplicated private authenticateInSession from AuthController and HouseholdController. Add a single public implementation on AuthService with session fixation protection built in. HouseholdController now injects AuthService and passes role "user" for invite-accepted accounts. Co-Authored-By: Claude Sonnet 4.6 --- .../com/recipeapp/auth/AuthController.java | 29 ++----------------- .../java/com/recipeapp/auth/AuthService.java | 26 +++++++++++++++++ .../household/HouseholdController.java | 27 ++++------------- .../household/HouseholdControllerTest.java | 4 +++ 4 files changed, 37 insertions(+), 49 deletions(-) diff --git a/backend/src/main/java/com/recipeapp/auth/AuthController.java b/backend/src/main/java/com/recipeapp/auth/AuthController.java index d0f6605..93abcb1 100644 --- a/backend/src/main/java/com/recipeapp/auth/AuthController.java +++ b/backend/src/main/java/com/recipeapp/auth/AuthController.java @@ -7,15 +7,10 @@ import jakarta.servlet.http.HttpSession; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.web.bind.annotation.*; import java.security.Principal; -import java.util.List; @RestController @RequestMapping("/v1/auth") @@ -32,7 +27,7 @@ public class AuthController { @Valid @RequestBody SignupRequest request, HttpServletRequest httpRequest) { UserResponse user = authService.signup(request); - authenticateInSession(user.email(), "user", httpRequest); + authService.authenticateInSession(user.email(), "user", httpRequest); return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(user)); } @@ -41,30 +36,10 @@ public class AuthController { @Valid @RequestBody LoginRequest request, HttpServletRequest httpRequest) { UserResponse user = authService.login(request); - // Session fixation protection: invalidate old session before creating new one - var oldSession = httpRequest.getSession(false); - if (oldSession != null) { - oldSession.invalidate(); - } - authenticateInSession(user.email(), user.systemRole() != null ? user.systemRole() : "user", httpRequest); + authService.authenticateInSession(user.email(), user.systemRole() != null ? user.systemRole() : "user", httpRequest); return ResponseEntity.ok(ApiResponse.success(user)); } - /** - * Creates an authenticated Spring Security context and stores it in the HTTP session - * so that subsequent requests from the same session are recognised as authenticated. - * We do this manually because we are not using Spring Security's built-in form login. - */ - private void authenticateInSession(String email, String role, HttpServletRequest request) { - var auth = UsernamePasswordAuthenticationToken.authenticated( - email, null, List.of(new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))); - SecurityContext context = SecurityContextHolder.createEmptyContext(); - context.setAuthentication(auth); - SecurityContextHolder.setContext(context); - request.getSession(true).setAttribute( - HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context); - } - @PostMapping("/logout") public ResponseEntity logout(HttpServletRequest httpRequest) { HttpSession session = httpRequest.getSession(false); diff --git a/backend/src/main/java/com/recipeapp/auth/AuthService.java b/backend/src/main/java/com/recipeapp/auth/AuthService.java index 1f007c6..feaee08 100644 --- a/backend/src/main/java/com/recipeapp/auth/AuthService.java +++ b/backend/src/main/java/com/recipeapp/auth/AuthService.java @@ -7,10 +7,18 @@ import com.recipeapp.common.ResourceNotFoundException; import com.recipeapp.common.ValidationException; import com.recipeapp.household.HouseholdMemberRepository; import com.recipeapp.household.entity.HouseholdMember; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @Service public class AuthService { @@ -82,6 +90,24 @@ public class AuthService { return UserResponse.basic(user.getId(), user.getEmail(), user.getDisplayName()); } + /** + * Establishes an authenticated Spring Security session for the given user. + * Invalidates any existing session first (session fixation protection). + */ + public void authenticateInSession(String email, String role, HttpServletRequest request) { + var oldSession = request.getSession(false); + if (oldSession != null) { + oldSession.invalidate(); + } + var auth = UsernamePasswordAuthenticationToken.authenticated( + email, null, List.of(new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))); + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(auth); + SecurityContextHolder.setContext(context); + request.getSession(true).setAttribute( + HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context); + } + private UserResponse toUserResponse(UserAccount user) { return householdMemberRepository.findByUserEmailIgnoreCase(user.getEmail()) .map(member -> UserResponse.withHousehold( diff --git a/backend/src/main/java/com/recipeapp/household/HouseholdController.java b/backend/src/main/java/com/recipeapp/household/HouseholdController.java index d009393..c3eb59d 100644 --- a/backend/src/main/java/com/recipeapp/household/HouseholdController.java +++ b/backend/src/main/java/com/recipeapp/household/HouseholdController.java @@ -1,17 +1,12 @@ package com.recipeapp.household; -import com.recipeapp.auth.entity.UserAccount; +import com.recipeapp.auth.AuthService; import com.recipeapp.common.ApiResponse; import com.recipeapp.household.dto.*; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.context.SecurityContext; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.web.bind.annotation.*; import java.security.Principal; @@ -24,9 +19,11 @@ import java.util.UUID; public class HouseholdController { private final HouseholdService householdService; + private final AuthService authService; - public HouseholdController(HouseholdService householdService) { + public HouseholdController(HouseholdService householdService, AuthService authService) { this.householdService = householdService; + this.authService = authService; } @PostMapping("/households") @@ -91,21 +88,7 @@ public class HouseholdController { HttpServletRequest httpRequest) { AcceptInviteResponse response = householdService.acceptInvite( code, request.name(), request.email(), request.password()); - authenticateInSession(request.email(), httpRequest); + authService.authenticateInSession(request.email(), "user", httpRequest); return ResponseEntity.ok(ApiResponse.success(response)); } - - private void authenticateInSession(String email, HttpServletRequest request) { - var oldSession = request.getSession(false); - if (oldSession != null) { - oldSession.invalidate(); - } - var auth = UsernamePasswordAuthenticationToken.authenticated( - email, null, List.of(new SimpleGrantedAuthority("ROLE_USER"))); - SecurityContext context = SecurityContextHolder.createEmptyContext(); - context.setAuthentication(auth); - SecurityContextHolder.setContext(context); - request.getSession(true).setAttribute( - HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context); - } } diff --git a/backend/src/test/java/com/recipeapp/household/HouseholdControllerTest.java b/backend/src/test/java/com/recipeapp/household/HouseholdControllerTest.java index c889963..b7bb1c8 100644 --- a/backend/src/test/java/com/recipeapp/household/HouseholdControllerTest.java +++ b/backend/src/test/java/com/recipeapp/household/HouseholdControllerTest.java @@ -1,6 +1,7 @@ package com.recipeapp.household; import com.fasterxml.jackson.databind.ObjectMapper; +import com.recipeapp.auth.AuthService; import com.recipeapp.common.GlobalExceptionHandler; import com.recipeapp.common.ResourceNotFoundException; import com.recipeapp.common.ConflictException; @@ -36,6 +37,9 @@ class HouseholdControllerTest { @Mock private HouseholdService householdService; + @Mock + private AuthService authService; + @InjectMocks private HouseholdController householdController; -- 2.49.1 From 230ee5a067cd9285d7fb917843a94d08719b4b11 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 22:25:48 +0200 Subject: [PATCH 19/23] fix(join): use secure: !dev for JSESSIONID cookie to work in local dev Hardcoded secure: true silently drops the cookie on HTTP (localhost), causing the post-join redirect to bounce back to /login. Use $app/environment dev flag so the cookie works in development while remaining Secure in production. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/routes/(public)/join/[token]/+page.server.ts | 3 ++- frontend/src/routes/(public)/join/[token]/page.server.test.ts | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/src/routes/(public)/join/[token]/+page.server.ts b/frontend/src/routes/(public)/join/[token]/+page.server.ts index 5ba5970..deea5ae 100644 --- a/frontend/src/routes/(public)/join/[token]/+page.server.ts +++ b/frontend/src/routes/(public)/join/[token]/+page.server.ts @@ -1,4 +1,5 @@ import { fail, redirect } from '@sveltejs/kit'; +import { dev } from '$app/environment'; import { apiClient } from '$lib/server/api'; import type { Actions, PageServerLoad } from './$types'; @@ -74,7 +75,7 @@ export const actions = { path: '/', httpOnly: true, sameSite: 'lax', - secure: true + secure: !dev }); } diff --git a/frontend/src/routes/(public)/join/[token]/page.server.test.ts b/frontend/src/routes/(public)/join/[token]/page.server.test.ts index db0ff2f..ae9f9b8 100644 --- a/frontend/src/routes/(public)/join/[token]/page.server.test.ts +++ b/frontend/src/routes/(public)/join/[token]/page.server.test.ts @@ -4,6 +4,8 @@ vi.mock('$env/dynamic/private', () => ({ env: { BACKEND_URL: 'http://localhost:8080' } })); +vi.mock('$app/environment', () => ({ dev: false })); + const mockGet = vi.fn(); const mockPost = vi.fn(); vi.mock('$lib/server/api', () => ({ -- 2.49.1 From ccfc72ab38c94d68c8b7b75358a658469357d606 Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 10 Apr 2026 22:26:16 +0200 Subject: [PATCH 20/23] fix(join): update password toggle aria-label with state Static aria-label "Passwort anzeigen" stayed unchanged after the password became visible, giving screen readers wrong information. Bind label to showPassword state: "Passwort anzeigen" / "Passwort verbergen". Co-Authored-By: Claude Sonnet 4.6 --- .../src/routes/(public)/join/[token]/JoinForm.svelte | 2 +- .../src/routes/(public)/join/[token]/JoinForm.test.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/frontend/src/routes/(public)/join/[token]/JoinForm.svelte b/frontend/src/routes/(public)/join/[token]/JoinForm.svelte index 059602c..57f5c2c 100644 --- a/frontend/src/routes/(public)/join/[token]/JoinForm.svelte +++ b/frontend/src/routes/(public)/join/[token]/JoinForm.svelte @@ -90,7 +90,7 @@