diff --git a/client/src/index.css b/client/src/index.css index eb06664..5afdf0c 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -5,7 +5,7 @@ } :root { - @apply bg-surface text-on-surface mx-auto max-w-2xl overscroll-none; + @apply bg-surface text-on-surface mx-auto overscroll-none; color-scheme: light dark; } diff --git a/server/src/main/java/com/yealch/yealch/OrganizationsController.java b/server/src/main/java/com/yealch/yealch/OrganizationsController.java index d68c6dc..d9b0e0d 100644 --- a/server/src/main/java/com/yealch/yealch/OrganizationsController.java +++ b/server/src/main/java/com/yealch/yealch/OrganizationsController.java @@ -2,6 +2,7 @@ package com.yealch.yealch; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -29,7 +30,13 @@ public class OrganizationsController { } @PostMapping - public ResponseEntity createOrganization(@RequestBody CreateOrganizationRequest request) { + public ResponseEntity createOrganization(@RequestBody CreateOrganizationRequest request, + Authentication authentication) { + Long authenticatedUserId = UsersController.getUserId(authentication).orElse(null); + if (authenticatedUserId == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + if (request == null || request.name() == null || request.name().isBlank()) { return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Map.of("error", "name is required")); } @@ -42,9 +49,19 @@ public class OrganizationsController { } @GetMapping("/{organizationId}") - public ResponseEntity getOrganization(@PathVariable Long organizationId) { + public ResponseEntity getOrganization(@PathVariable Long organizationId, Authentication authentication) { + Long authenticatedUserId = UsersController.getUserId(authentication).orElse(null); + if (authenticatedUserId == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + return organizationRepository.findById(organizationId) - .>map(organization -> ResponseEntity.ok(toResponse(organization))) + .>map(organization -> { + if (!isMember(organization, authenticatedUserId)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + return ResponseEntity.ok(toResponse(organization)); + }) .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND) .body(Map.of("error", "organization not found"))); } @@ -53,9 +70,13 @@ public class OrganizationsController { * Creates a registration of a user as a member of an organization */ @PostMapping("/{organizationId}/members/registrations") - public ResponseEntity addMemberToOrganization(@PathVariable Long organizationId, @PathVariable Long userId) { - // TODO implement roles permissions to disallow any organization member to add - // or remove members + public ResponseEntity addMemberToOrganization(@PathVariable Long organizationId, @PathVariable Long userId, + Authentication authentication) { + Long authenticatedUserId = UsersController.getUserId(authentication).orElse(null); + if (authenticatedUserId == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + Optional organizationOptional = organizationRepository.findById(organizationId); Optional userOptional = userRepository.findById(userId); @@ -64,6 +85,10 @@ public class OrganizationsController { } Organization organization = organizationOptional.get(); + if (!isMember(organization, authenticatedUserId)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + User user = userOptional.get(); organization.addMember(user); organizationRepository.save(organization); @@ -73,7 +98,12 @@ public class OrganizationsController { @DeleteMapping("/{organizationId}/members/{userId}") public ResponseEntity removeMemberFromOrganization(@PathVariable Long organizationId, - @PathVariable Long userId) { + @PathVariable Long userId, Authentication authentication) { + Long authenticatedUserId = UsersController.getUserId(authentication).orElse(null); + if (authenticatedUserId == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + Optional organizationOptional = organizationRepository.findById(organizationId); Optional userOptional = userRepository.findById(userId); @@ -82,6 +112,10 @@ public class OrganizationsController { } Organization organization = organizationOptional.get(); + if (!isMember(organization, authenticatedUserId)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + User user = userOptional.get(); organization.removeMember(user); organizationRepository.save(organization); @@ -90,12 +124,23 @@ public class OrganizationsController { } @DeleteMapping("/{organizationId}") - public ResponseEntity deleteOrganization(@PathVariable Long organizationId) { + public ResponseEntity deleteOrganization(@PathVariable Long organizationId, Authentication authentication) { + Long authenticatedUserId = UsersController.getUserId(authentication).orElse(null); + if (authenticatedUserId == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + Optional organizationOptional = organizationRepository.findById(organizationId); if (organizationOptional.isEmpty()) { return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", "organization not found")); } - organizationRepository.delete(organizationOptional.get()); + + Organization organization = organizationOptional.get(); + if (!isMember(organization, authenticatedUserId)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + + organizationRepository.delete(organization); return ResponseEntity.noContent().build(); } @@ -107,18 +152,31 @@ public class OrganizationsController { } @GetMapping("/{organizationId}/users") - public ResponseEntity getOrganizationUsers(@PathVariable Long organizationId) { + public ResponseEntity getOrganizationUsers(@PathVariable Long organizationId, Authentication authentication) { + Long authenticatedUserId = UsersController.getUserId(authentication).orElse(null); + if (authenticatedUserId == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + return organizationRepository.findById(organizationId) - .>map(organization -> ResponseEntity - .ok(organization.getMembers().stream().map(this::toUserResponse).toList())) + .>map(organization -> { + if (!isMember(organization, authenticatedUserId)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + return ResponseEntity.ok(organization.getMembers().stream().map(this::toUserResponse).toList()); + }) .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND) .body(Map.of("error", "organization not found"))); } + private boolean isMember(Organization organization, Long userId) { + return organization.getMembers().stream().anyMatch(member -> userId.equals(member.getId())); + } + private Map toResponse(Organization organization) { return Map.of( "id", organization.getId(), "name", organization.getName(), "users", organization.getMembers().stream().map(this::toUserResponse).toList()); } -} \ No newline at end of file +} diff --git a/server/src/main/java/com/yealch/yealch/ProjectsController.java b/server/src/main/java/com/yealch/yealch/ProjectsController.java index 7efcc2a..be314c1 100644 --- a/server/src/main/java/com/yealch/yealch/ProjectsController.java +++ b/server/src/main/java/com/yealch/yealch/ProjectsController.java @@ -1,12 +1,17 @@ package com.yealch.yealch; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.Map; + @RestController -@RequestMapping("/api/organizations") +@RequestMapping("/api/projects") public class ProjectsController { private final ProjectRepository projectRepository; @@ -16,8 +21,25 @@ public class ProjectsController { } @DeleteMapping("/{projectId}") - public void deleteProject(@PathVariable Long projectId) { - // TODO validate user is authorized to delete project - projectRepository.deleteById(projectId); + public ResponseEntity deleteProject(@PathVariable Long projectId, Authentication authentication) { + Long authenticatedUserId = UsersController.getUserId(authentication).orElse(null); + if (authenticatedUserId == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + var foundProject = projectRepository.findById(projectId); + if (foundProject.isEmpty()) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", "project not found")); + } + + Project project = foundProject.get(); + Organization organization = project.getOrganization(); + if (organization == null + || organization.getMembers().stream().noneMatch(member -> authenticatedUserId.equals(member.getId()))) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + + projectRepository.delete(project); + return ResponseEntity.noContent().build(); } } diff --git a/server/src/main/java/com/yealch/yealch/UsersController.java b/server/src/main/java/com/yealch/yealch/UsersController.java index 2f6c8f4..b1316a9 100644 --- a/server/src/main/java/com/yealch/yealch/UsersController.java +++ b/server/src/main/java/com/yealch/yealch/UsersController.java @@ -69,7 +69,12 @@ public class UsersController { } @GetMapping("/api/users/{userId}/organizations") - public ResponseEntity getUserOrganizations(@PathVariable Long userId) { + public ResponseEntity getUserOrganizations(@PathVariable Long userId, Authentication authentication) { + Optional authenticatedUserId = getUserId(authentication); + if (authenticatedUserId.isEmpty() || !authenticatedUserId.get().equals(userId)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + // Controversal opinions: // - The concept of controllers makes little sense when you // start cross-cutting concerns and @@ -90,7 +95,7 @@ public class UsersController { } return ResponseEntity.ok(user.getOrganizations().stream() - .map(org -> new GetUserOrganizationsResponse(org.getId(), org.getName())) + .map(organization -> new GetUserOrganizationsResponse(organization.getId(), organization.getName())) .toList()); }) .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", "user not found"))); @@ -104,7 +109,12 @@ public class UsersController { * project is created for them and assigned to the organization. */ @GetMapping("/api/users/{userId}/projects") - public ResponseEntity getUserProjects(@PathVariable Long userId) { + public ResponseEntity getUserProjects(@PathVariable Long userId, Authentication authentication) { + Optional authenticatedUserId = getUserId(authentication); + if (authenticatedUserId.isEmpty() || !authenticatedUserId.get().equals(userId)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + return userRepository.findById(userId) .>map(user -> { if (user.getOrganizations().isEmpty()) { @@ -140,7 +150,12 @@ public class UsersController { */ @PostMapping("/api/users/{userId}/organizations/{organizationId}/projects") public ResponseEntity createProjectForUser(@PathVariable Long userId, @PathVariable Long organizationId, - @RequestBody Map request) { + @RequestBody Map request, Authentication authentication) { + Optional authenticatedUserId = getUserId(authentication); + if (authenticatedUserId.isEmpty() || !authenticatedUserId.get().equals(userId)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + String projectName = request.get("name"); if (projectName == null) { return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Map.of("error", "project name is required")); @@ -167,7 +182,12 @@ public class UsersController { /** Creates a new time entry for a user on a project */ @PostMapping("/api/users/{userId}/projects/{projectId}/times") public ResponseEntity createTimeEntry(@PathVariable Long userId, @PathVariable Long projectId, - @RequestBody Map request) { + @RequestBody Map request, Authentication authentication) { + + Optional authenticatedUserId = getUserId(authentication); + if (authenticatedUserId.isEmpty() || !authenticatedUserId.get().equals(userId)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } if (request == null || request.get("start") == null || request.get("end") == null) { return ResponseEntity.status(HttpStatus.BAD_REQUEST) @@ -225,7 +245,12 @@ public class UsersController { } @GetMapping("/api/users/{userId}/times") - public ResponseEntity getUserTimeEntries(@PathVariable Long userId) { + public ResponseEntity getUserTimeEntries(@PathVariable Long userId, Authentication authentication) { + Optional authenticatedUserId = getUserId(authentication); + if (authenticatedUserId.isEmpty() || !authenticatedUserId.get().equals(userId)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + return userRepository.findById(userId) .>map(user -> ResponseEntity.ok( StreamSupport.stream(timeRepository.findAll().spliterator(), false) @@ -258,9 +283,11 @@ public class UsersController { /** Creates a new organization with this user in it */ @PostMapping("/api/users/{userId}/organizations") public ResponseEntity createOrganizationForUser(@PathVariable Long userId, - @RequestBody String organizationName) { - // TODO check on authorization if the user id is the same user that sends the - // request + @RequestBody String organizationName, Authentication authentication) { + Optional authenticatedUserId = getUserId(authentication); + if (authenticatedUserId.isEmpty() || !authenticatedUserId.get().equals(userId)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } if (organizationName == null) { return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Map.of("error", "organization name is required")); @@ -280,7 +307,12 @@ public class UsersController { @PutMapping("/api/users/{userId}/organizations/{organizationId}/name") public ResponseEntity updateOrganizationName(@PathVariable Long userId, @PathVariable Long organizationId, - @RequestBody String newName) { + @RequestBody String newName, Authentication authentication) { + + Optional authenticatedUserId = getUserId(authentication); + if (authenticatedUserId.isEmpty() || !authenticatedUserId.get().equals(userId)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } if (newName == null) { return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Map.of("error", "organization name is required")); @@ -309,9 +341,13 @@ public class UsersController { @PutMapping("/api/users/{userId}/projects/{projectId}") public ResponseEntity updateProject(@PathVariable Long userId, @PathVariable Long projectId, - @RequestBody UpdateProjectRequest request) { + @RequestBody UpdateProjectRequest request, Authentication authentication) { + + Optional authenticatedUserId = getUserId(authentication); + if (authenticatedUserId.isEmpty() || !authenticatedUserId.get().equals(userId)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } - // if (request == null || request.name() == null || request.organizationId() == null) { return ResponseEntity.status(HttpStatus.BAD_REQUEST) .body(Map.of("error", "name and organizationId are required")); @@ -341,7 +377,12 @@ public class UsersController { @PutMapping("/api/users/{userId}/times") public ResponseEntity updateUserTimes(@PathVariable Long userId, - @RequestBody java.util.List request) { + @RequestBody java.util.List request, Authentication authentication) { + + Optional authenticatedUserId = getUserId(authentication); + if (authenticatedUserId.isEmpty() || !authenticatedUserId.get().equals(userId)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } if (request == null) { return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Map.of("error", "request body is required")); @@ -379,13 +420,13 @@ public class UsersController { .body(Map.of("error", "start must be before end")); } - var maybeTime = timeRepository.findById(timeId); - if (maybeTime.isEmpty()) { + var foundTime = timeRepository.findById(timeId); + if (foundTime.isEmpty()) { return ResponseEntity.status(HttpStatus.NOT_FOUND) .body(Map.of("error", "time not found: " + timeId)); } - Time time = maybeTime.get(); + Time time = foundTime.get(); Project project = time.getProject(); if (project == null || project.getOrganization() == null || !project.getOrganization().getMembers().contains(user)) { @@ -405,7 +446,12 @@ public class UsersController { @DeleteMapping("/api/users/{userId}/times") public ResponseEntity deleteUserTimes(@PathVariable Long userId, - @RequestBody java.util.List request) { + @RequestBody java.util.List request, Authentication authentication) { + + Optional authenticatedUserId = getUserId(authentication); + if (authenticatedUserId.isEmpty() || !authenticatedUserId.get().equals(userId)) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } if (request == null) { return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Map.of("error", "request body is required")); @@ -413,27 +459,27 @@ public class UsersController { return userRepository.findById(userId) .>map(user -> { - for (String idStr : request) { - if (idStr == null) { + for (String rawTimeId : request) { + if (rawTimeId == null) { return ResponseEntity.status(HttpStatus.BAD_REQUEST) .body(Map.of("error", "time id must be provided")); } Long timeId; try { - timeId = Long.parseLong(idStr); + timeId = Long.parseLong(rawTimeId); } catch (NumberFormatException e) { return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(Map.of("error", "invalid time id: " + idStr)); + .body(Map.of("error", "invalid time id: " + rawTimeId)); } - var maybeTime = timeRepository.findById(timeId); - if (maybeTime.isEmpty()) { + var foundTime = timeRepository.findById(timeId); + if (foundTime.isEmpty()) { return ResponseEntity.status(HttpStatus.NOT_FOUND) .body(Map.of("error", "time not found: " + timeId)); } - Time time = maybeTime.get(); + Time time = foundTime.get(); Project project = time.getProject(); if (project == null || project.getOrganization() == null || !project.getOrganization().getMembers().contains(user)) {