From aca74f577d543893a654903efaf9f41c4727cdc5 Mon Sep 17 00:00:00 2001 From: Furkan Cevik Date: Sat, 29 Nov 2025 20:27:18 +0100 Subject: [PATCH 01/52] feat: Implement OIDC user sync and update Profile page - Added User entity and UserRepository - Implemented OIDC user synchronization service - Added UserController with /api/me endpoint - Updated SecurityConfig to enable OIDC login and user sync - Updated Project and File controllers to enforce ownership - Rewrote Frontend Profile page to display real user data - Updated backend tests to use OIDC mocks --- .../edu/kit/quak/files/FileController.java | 72 +++++++- .../edu/kit/quak/files/ProjectController.java | 48 ++++-- .../edu/kit/quak/files/model/FileElement.java | 11 ++ .../files/model/FileElementContainer.java | 2 +- .../edu/kit/quak/files/model/Project.java | 12 ++ .../files/repository/ProjectRepository.java | 4 + .../quak/security/OidcUserSyncService.java | 56 ++++++ .../edu/kit/quak/security/SecurityConfig.java | 26 ++- .../edu/kit/quak/security/UserController.java | 45 +++++ .../edu/kit/quak/security/model/User.java | 157 +++++++++++++++++ .../edu/kit/quak/security/model/UserDto.java | 11 ++ .../security/repository/UserRepository.java | 13 ++ .../src/main/resources/application.properties | 11 +- .../kit/quak/files/FileControllerTest.java | 125 +++++++++----- .../kit/quak/files/ProjectControllerTest.java | 56 +++++- .../src/test/resources/application.properties | 14 ++ frontend/src/pages/Profile.tsx | 162 +++++++----------- 17 files changed, 645 insertions(+), 180 deletions(-) create mode 100644 backend/src/main/java/edu/kit/quak/security/OidcUserSyncService.java create mode 100644 backend/src/main/java/edu/kit/quak/security/UserController.java create mode 100644 backend/src/main/java/edu/kit/quak/security/model/User.java create mode 100644 backend/src/main/java/edu/kit/quak/security/model/UserDto.java create mode 100644 backend/src/main/java/edu/kit/quak/security/repository/UserRepository.java create mode 100644 backend/src/test/resources/application.properties diff --git a/backend/src/main/java/edu/kit/quak/files/FileController.java b/backend/src/main/java/edu/kit/quak/files/FileController.java index 2b751632..c9207d49 100644 --- a/backend/src/main/java/edu/kit/quak/files/FileController.java +++ b/backend/src/main/java/edu/kit/quak/files/FileController.java @@ -4,13 +4,19 @@ import edu.kit.quak.files.model.Directory; import edu.kit.quak.files.model.File; import edu.kit.quak.files.model.FileElement; +import edu.kit.quak.files.model.Project; import edu.kit.quak.files.repository.FileRepository; import edu.kit.quak.files.repository.RepoMonad; import edu.kit.quak.files.repository.savers.FileElementSaver; import edu.kit.quak.files.repository.savers.FileElementSaversRepository; +import edu.kit.quak.security.model.User; +import edu.kit.quak.security.repository.UserRepository; import jakarta.servlet.http.HttpServletResponse; import jakarta.transaction.Transactional; import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; @@ -28,6 +34,7 @@ import static edu.kit.quak.files.model.FileElement.TYPE_FIELD; import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.FORBIDDEN; /** * This controller handles all the calls to the {@code /file/} endpoint. @@ -44,16 +51,46 @@ public class FileController { private final FileRepository files; private final ObjectMapper objectMapper; private final FileElementSaversRepository savers; + private final UserRepository users; - public FileController(FileRepository files, ObjectMapper objectMapper, FileElementSaversRepository savers) { + public FileController(FileRepository files, ObjectMapper objectMapper, FileElementSaversRepository savers, UserRepository users) { this.files = files; this.objectMapper = objectMapper; this.savers = savers; + this.users = users; + } + + private User getUser(Authentication authentication) { + if (authentication instanceof OAuth2AuthenticationToken oauthToken && + authentication.getPrincipal() instanceof OidcUser oidcUser) { + String registrationId = oauthToken.getAuthorizedClientRegistrationId(); + String sub = oidcUser.getSubject(); + return users.findByIssuerAndSub(registrationId, sub) + .orElseThrow(() -> new ResponseStatusException(FORBIDDEN, "User not found")); + } + throw new ResponseStatusException(FORBIDDEN, "Not authenticated"); + } + + private void checkOwnership(FileElement element, User user) { + Project project = element.getProject(); + if (project == null || project.getOwner() == null || !project.getOwner().getId().equals(user.getId())) { + throw new ResponseStatusException(FORBIDDEN, "You do not own this file"); + } } @ResponseStatus(HttpStatus.CREATED) @PostMapping("/") - public FileElement newFile(@RequestBody Map obj, @RequestHeader(name = "parent-id") String parent) { + public FileElement newFile(@RequestBody Map obj, @RequestHeader(name = "parent-id") String parent, Authentication authentication) { + User user = getUser(authentication); + + savers.getSaverForElementId(parent) + .map(FileElementSaver::getRepository) + .flatMap(repo -> repo.findById(parent)) + .ifPresentOrElse( + element -> checkOwnership(element, user), + () -> { throw new ResponseStatusException(BAD_REQUEST, "No matching parent found"); } + ); + final RepoMonad dest = savers.getSaverForElementId(parent) .flatMap(FileElementSaver::getRepoMonad) .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "No matching parent found")); @@ -69,16 +106,25 @@ public FileElement newFile(@RequestBody Map obj, @RequestHead } @GetMapping("/{fId}") - public FileElement retrieveFile(@PathVariable String fId) { - return savers.getSaverForElementId(fId, FILTER) + public FileElement retrieveFile(@PathVariable String fId, Authentication authentication) { + User user = getUser(authentication); + FileElement element = savers.getSaverForElementId(fId, FILTER) .map(FileElementSaver::getRepository) .flatMap(repo -> repo.findById(fId)) .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "No matching FileElement found for id")); + checkOwnership(element, user); + return element; } @DeleteMapping("/{fId}") @Transactional - public void deleteFile(@PathVariable String fId) { + public void deleteFile(@PathVariable String fId, Authentication authentication) { + User user = getUser(authentication); + savers.getSaverForElementId(fId, FILTER) + .map(FileElementSaver::getRepository) + .flatMap(repo -> repo.findById(fId)) + .ifPresent(element -> checkOwnership(element, user)); + try { savers.delete(fId, FILTER); } catch (IllegalArgumentException e) { @@ -87,7 +133,13 @@ public void deleteFile(@PathVariable String fId) { } @PatchMapping("/{fId}") - public void patchFileElement(@PathVariable String fId, @RequestBody Map body) { + public void patchFileElement(@PathVariable String fId, @RequestBody Map body, Authentication authentication) { + User user = getUser(authentication); + savers.getSaverForElementId(fId, FILTER) + .map(FileElementSaver::getRepository) + .flatMap(repo -> repo.findById(fId)) + .ifPresent(element -> checkOwnership(element, user)); + try { savers.getSaverForElementId(fId, FILTER) .ifPresent(sav -> sav.patch(fId, (toPatch, clazz) -> { @@ -100,20 +152,24 @@ public void patchFileElement(@PathVariable String fId, @RequestBody Map new ResponseStatusException(BAD_REQUEST, "Given file-ID does not resolve to an existing file.") ); + checkOwnership(file, user); response.setContentType(file.getContentType()); return file.getContent(); } @PutMapping("/{fId}/content") - public void setFileContent(@PathVariable String fId, @RequestBody byte[] content, @RequestHeader("Content-Type") String contentType) { + public void setFileContent(@PathVariable String fId, @RequestBody byte[] content, @RequestHeader("Content-Type") String contentType, Authentication authentication) { + User user = getUser(authentication); File file = files.findById(fId).orElseThrow( () -> new ResponseStatusException(BAD_REQUEST, "Given file-ID does not resolve to an existing file.") ); + checkOwnership(file, user); file.setContent(content); file.setContentType(contentType); diff --git a/backend/src/main/java/edu/kit/quak/files/ProjectController.java b/backend/src/main/java/edu/kit/quak/files/ProjectController.java index 7c0fe0c6..78fa35a2 100644 --- a/backend/src/main/java/edu/kit/quak/files/ProjectController.java +++ b/backend/src/main/java/edu/kit/quak/files/ProjectController.java @@ -3,8 +3,13 @@ import edu.kit.quak.files.model.Project; import edu.kit.quak.files.repository.ProjectRepository; import edu.kit.quak.files.repository.savers.FileElementSaversRepository; +import edu.kit.quak.security.model.User; +import edu.kit.quak.security.repository.UserRepository; import jakarta.transaction.Transactional; import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; @@ -20,6 +25,7 @@ import java.util.List; import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.FORBIDDEN; /** * This controller handles all the calls to the {@code /project/} endpoint. @@ -33,23 +39,37 @@ public class ProjectController { private final ProjectRepository projects; private final FileElementSaversRepository savers; + private final UserRepository users; - public ProjectController(ProjectRepository projects, FileElementSaversRepository savers) { + public ProjectController(ProjectRepository projects, FileElementSaversRepository savers, UserRepository users) { this.projects = projects; this.savers = savers; + this.users = users; + } + + private User getUser(Authentication authentication) { + if (authentication instanceof OAuth2AuthenticationToken oauthToken && + authentication.getPrincipal() instanceof OidcUser oidcUser) { + String registrationId = oauthToken.getAuthorizedClientRegistrationId(); + String sub = oidcUser.getSubject(); + return users.findByIssuerAndSub(registrationId, sub) + .orElseThrow(() -> new ResponseStatusException(FORBIDDEN, "User not found")); + } + throw new ResponseStatusException(FORBIDDEN, "Not authenticated"); } @GetMapping({"", "/"}) - public List getProjects() { - List list = new LinkedList<>(); - projects.findAll().forEach(list::add); - return list; + public List getProjects(Authentication authentication) { + User user = getUser(authentication); + return projects.findAllByOwner(user); } @PostMapping({"", "/"}) @ResponseStatus(HttpStatus.CREATED) - public Project createProject(@RequestBody Project project) { + public Project createProject(@RequestBody Project project, Authentication authentication) { + User user = getUser(authentication); project.setId(null); + project.setOwner(user); if (!project.getElements().isEmpty()) { throw new ResponseStatusException(BAD_REQUEST, "New Projects cannot already contain files"); } @@ -57,15 +77,20 @@ public Project createProject(@RequestBody Project project) { } @GetMapping("/{pId}") - public Project getProject(@PathVariable String pId) { - return projects.findById(pId).orElseThrow( + public Project getProject(@PathVariable String pId, Authentication authentication) { + User user = getUser(authentication); + Project project = projects.findById(pId).orElseThrow( () -> new ResponseStatusException(BAD_REQUEST, "Given id does not map to a project") ); + if (project.getOwner() == null || !project.getOwner().getId().equals(user.getId())) { + throw new ResponseStatusException(FORBIDDEN, "You do not own this project"); + } + return project; } @PatchMapping("/{pId}") - public Project patchProject(@PathVariable String pId, @RequestBody Project modified) { - Project original = getProject(pId); + public Project patchProject(@PathVariable String pId, @RequestBody Project modified, Authentication authentication) { + Project original = getProject(pId, authentication); try { original.patch(modified); } catch (IllegalArgumentException e) { @@ -77,7 +102,8 @@ public Project patchProject(@PathVariable String pId, @RequestBody Project modif @DeleteMapping("/{pId}") @Transactional - public void deleteProject(@PathVariable String pId) { + public void deleteProject(@PathVariable String pId, Authentication authentication) { + getProject(pId, authentication); // Check ownership savers.delete(pId, Project.class); } } diff --git a/backend/src/main/java/edu/kit/quak/files/model/FileElement.java b/backend/src/main/java/edu/kit/quak/files/model/FileElement.java index 0f378520..e8bee5d3 100644 --- a/backend/src/main/java/edu/kit/quak/files/model/FileElement.java +++ b/backend/src/main/java/edu/kit/quak/files/model/FileElement.java @@ -110,4 +110,15 @@ public boolean equals(Object o) { public int hashCode() { return Objects.hash(name, parent); } + + @JsonIgnore + public Project getProject() { + if (this instanceof Project) { + return (Project) this; + } + if (getParent().isPresent()) { + return getParent().get().getProject(); + } + return null; + } } diff --git a/backend/src/main/java/edu/kit/quak/files/model/FileElementContainer.java b/backend/src/main/java/edu/kit/quak/files/model/FileElementContainer.java index 1f240f1f..8f604aea 100644 --- a/backend/src/main/java/edu/kit/quak/files/model/FileElementContainer.java +++ b/backend/src/main/java/edu/kit/quak/files/model/FileElementContainer.java @@ -23,7 +23,7 @@ public abstract class FileElementContainer> extends FileElement { @JsonIgnore - @OneToMany(orphanRemoval = true, cascade = CascadeType.ALL, fetch = FetchType.EAGER) + @OneToMany(orphanRemoval = true, cascade = CascadeType.ALL, fetch = FetchType.EAGER, mappedBy = "parent") protected Set> contents = new HashSet<>(); public FileElementContainer(String name, FileElementContainer parent) { diff --git a/backend/src/main/java/edu/kit/quak/files/model/Project.java b/backend/src/main/java/edu/kit/quak/files/model/Project.java index 17d85a00..93634043 100644 --- a/backend/src/main/java/edu/kit/quak/files/model/Project.java +++ b/backend/src/main/java/edu/kit/quak/files/model/Project.java @@ -48,4 +48,16 @@ public String getTypeIdentifier() { public String generateId(Object base) { return ID_PREFIX + base.toString(); } + + @com.fasterxml.jackson.annotation.JsonIgnore + @jakarta.persistence.ManyToOne + private edu.kit.quak.security.model.User owner; + + public edu.kit.quak.security.model.User getOwner() { + return owner; + } + + public void setOwner(edu.kit.quak.security.model.User owner) { + this.owner = owner; + } } diff --git a/backend/src/main/java/edu/kit/quak/files/repository/ProjectRepository.java b/backend/src/main/java/edu/kit/quak/files/repository/ProjectRepository.java index 1cddcc6e..4449e9c8 100644 --- a/backend/src/main/java/edu/kit/quak/files/repository/ProjectRepository.java +++ b/backend/src/main/java/edu/kit/quak/files/repository/ProjectRepository.java @@ -3,5 +3,9 @@ import edu.kit.quak.files.model.Project; import org.springframework.data.repository.CrudRepository; +import edu.kit.quak.security.model.User; +import java.util.List; + public interface ProjectRepository extends CrudRepository { + List findAllByOwner(User owner); } diff --git a/backend/src/main/java/edu/kit/quak/security/OidcUserSyncService.java b/backend/src/main/java/edu/kit/quak/security/OidcUserSyncService.java new file mode 100644 index 00000000..3222fd20 --- /dev/null +++ b/backend/src/main/java/edu/kit/quak/security/OidcUserSyncService.java @@ -0,0 +1,56 @@ +package edu.kit.quak.security; + +import edu.kit.quak.security.model.User; +import edu.kit.quak.security.repository.UserRepository; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; + +@Service +public class OidcUserSyncService { + + private final UserRepository userRepository; + + public OidcUserSyncService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Transactional + public User syncUser(String issuer, OidcUser oidcUser) { + String sub = oidcUser.getSubject(); + if (sub == null) { + throw new IllegalArgumentException("Subject (sub) claim is missing"); + } + + return userRepository.findByIssuerAndSub(issuer, sub) + .map(existingUser -> updateUser(existingUser, oidcUser)) + .orElseGet(() -> createUser(issuer, sub, oidcUser)); + } + + private User updateUser(User user, OidcUser oidcUser) { + user.setEmail(oidcUser.getEmail()); + user.setEmailVerified(oidcUser.getEmailVerified()); + user.setName(oidcUser.getFullName()); + user.setGivenName(oidcUser.getGivenName()); + user.setFamilyName(oidcUser.getFamilyName()); + user.setAvatarUrl(oidcUser.getPicture()); + user.setLastLoginAt(Instant.now()); + return userRepository.save(user); + } + + private User createUser(String issuer, String sub, OidcUser oidcUser) { + User user = new User(); + user.setIssuer(issuer); + user.setSub(sub); + user.setEmail(oidcUser.getEmail()); + user.setEmailVerified(oidcUser.getEmailVerified()); + user.setName(oidcUser.getFullName()); + user.setGivenName(oidcUser.getGivenName()); + user.setFamilyName(oidcUser.getFamilyName()); + user.setAvatarUrl(oidcUser.getPicture()); + user.setLastLoginAt(Instant.now()); + return userRepository.save(user); + } +} diff --git a/backend/src/main/java/edu/kit/quak/security/SecurityConfig.java b/backend/src/main/java/edu/kit/quak/security/SecurityConfig.java index 9993356e..4bdedf7b 100644 --- a/backend/src/main/java/edu/kit/quak/security/SecurityConfig.java +++ b/backend/src/main/java/edu/kit/quak/security/SecurityConfig.java @@ -42,7 +42,8 @@ public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http, - OAuth2AuthorizationRequestResolver authorizationRequestResolver) throws Exception { + OAuth2AuthorizationRequestResolver authorizationRequestResolver, + AuthenticationSuccessHandler authenticationSuccessHandler) throws Exception { http .cors(cors -> cors.configurationSource(corsConfigurationSource())) .csrf(csrf -> csrf @@ -72,7 +73,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, .authorizationEndpoint(authorization -> authorization .authorizationRequestResolver(authorizationRequestResolver) ) - .successHandler(authenticationSuccessHandler()) + .successHandler(authenticationSuccessHandler) ) .logout(logout -> logout .logoutUrl("/api/auth/logout") @@ -127,6 +128,7 @@ private OAuth2AuthorizationRequest customizeAuthorizationRequest( .additionalParameters(params -> { params.put("code_challenge", codeChallenge); params.put("code_challenge_method", "S256"); + params.put("prompt", "select_account"); }) .attributes(attrs -> { attrs.put("code_verifier", codeVerifier); @@ -153,11 +155,21 @@ private String generateCodeChallenge(String codeVerifier) { } @Bean - public AuthenticationSuccessHandler authenticationSuccessHandler() { - SimpleUrlAuthenticationSuccessHandler handler = new SimpleUrlAuthenticationSuccessHandler(); - handler.setDefaultTargetUrl(frontendUrl + "/"); - handler.setAlwaysUseDefaultTargetUrl(true); - return handler; + public AuthenticationSuccessHandler authenticationSuccessHandler(OidcUserSyncService oidcUserSyncService) { + SimpleUrlAuthenticationSuccessHandler delegate = new SimpleUrlAuthenticationSuccessHandler(); + delegate.setDefaultTargetUrl(frontendUrl + "/"); + delegate.setAlwaysUseDefaultTargetUrl(true); + + return (request, response, authentication) -> { + if (authentication.getPrincipal() instanceof org.springframework.security.oauth2.core.oidc.user.OidcUser oidcUser) { + if (authentication instanceof org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken oauthToken) { + String registrationId = oauthToken.getAuthorizedClientRegistrationId(); + edu.kit.quak.security.model.User user = oidcUserSyncService.syncUser(registrationId, oidcUser); + request.getSession().setAttribute("userId", user.getId()); + } + } + delegate.onAuthenticationSuccess(request, response, authentication); + }; } @Bean diff --git a/backend/src/main/java/edu/kit/quak/security/UserController.java b/backend/src/main/java/edu/kit/quak/security/UserController.java new file mode 100644 index 00000000..ba020c81 --- /dev/null +++ b/backend/src/main/java/edu/kit/quak/security/UserController.java @@ -0,0 +1,45 @@ +package edu.kit.quak.security; + +import edu.kit.quak.security.model.User; +import edu.kit.quak.security.model.UserDto; +import edu.kit.quak.security.repository.UserRepository; +import jakarta.servlet.http.HttpSession; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.util.UUID; + +@RestController +@RequestMapping("/api") +public class UserController { + + private final UserRepository userRepository; + + public UserController(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @GetMapping("/me") + public ResponseEntity getCurrentUser(HttpSession session) { + UUID userId = (UUID) session.getAttribute("userId"); + if (userId == null) { + // Should be handled by SecurityConfig, but double check + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + User user = userRepository.findById(userId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found")); + + return ResponseEntity.ok(new UserDto( + user.getId(), + user.getEmail(), + user.getName(), + user.getAvatarUrl(), + user.getEmailVerified() + )); + } +} diff --git a/backend/src/main/java/edu/kit/quak/security/model/User.java b/backend/src/main/java/edu/kit/quak/security/model/User.java new file mode 100644 index 00000000..fb326168 --- /dev/null +++ b/backend/src/main/java/edu/kit/quak/security/model/User.java @@ -0,0 +1,157 @@ +package edu.kit.quak.security.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "users", uniqueConstraints = { + @UniqueConstraint(columnNames = {"issuer", "sub"}) +}) +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(nullable = false) + private String issuer; + + @Column(nullable = false) + private String sub; + + private String email; + + @Column(name = "email_verified") + private Boolean emailVerified; + + private String name; + + @Column(name = "given_name") + private String givenName; + + @Column(name = "family_name") + private String familyName; + + @Column(name = "avatar_url") + private String avatarUrl; + + @CreationTimestamp + @Column(name = "created_at", updatable = false, columnDefinition = "TIMESTAMP(6)") + private Instant createdAt; + + @UpdateTimestamp + @Column(name = "updated_at", columnDefinition = "TIMESTAMP(6)") + private Instant updatedAt; + + @Column(name = "last_login_at", columnDefinition = "TIMESTAMP(6)") + private Instant lastLoginAt; + + public User() { + } + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getIssuer() { + return issuer; + } + + public void setIssuer(String issuer) { + this.issuer = issuer; + } + + public String getSub() { + return sub; + } + + public void setSub(String sub) { + this.sub = sub; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public Boolean getEmailVerified() { + return emailVerified; + } + + public void setEmailVerified(Boolean emailVerified) { + this.emailVerified = emailVerified; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getGivenName() { + return givenName; + } + + public void setGivenName(String givenName) { + this.givenName = givenName; + } + + public String getFamilyName() { + return familyName; + } + + public void setFamilyName(String familyName) { + this.familyName = familyName; + } + + public String getAvatarUrl() { + return avatarUrl; + } + + public void setAvatarUrl(String avatarUrl) { + this.avatarUrl = avatarUrl; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(Instant updatedAt) { + this.updatedAt = updatedAt; + } + + public Instant getLastLoginAt() { + return lastLoginAt; + } + + public void setLastLoginAt(Instant lastLoginAt) { + this.lastLoginAt = lastLoginAt; + } +} diff --git a/backend/src/main/java/edu/kit/quak/security/model/UserDto.java b/backend/src/main/java/edu/kit/quak/security/model/UserDto.java new file mode 100644 index 00000000..885f84d8 --- /dev/null +++ b/backend/src/main/java/edu/kit/quak/security/model/UserDto.java @@ -0,0 +1,11 @@ +package edu.kit.quak.security.model; + +import java.util.UUID; + +public record UserDto( + UUID userId, + String email, + String name, + String avatarUrl, + Boolean emailVerified +) {} diff --git a/backend/src/main/java/edu/kit/quak/security/repository/UserRepository.java b/backend/src/main/java/edu/kit/quak/security/repository/UserRepository.java new file mode 100644 index 00000000..f34a5cad --- /dev/null +++ b/backend/src/main/java/edu/kit/quak/security/repository/UserRepository.java @@ -0,0 +1,13 @@ +package edu.kit.quak.security.repository; + +import edu.kit.quak.security.model.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface UserRepository extends JpaRepository { + Optional findByIssuerAndSub(String issuer, String sub); +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 241ede67..bd93cdd0 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -1,6 +1,10 @@ spring.application.name=QuaK -spring.datasource.driver-class-name=org.h2.Driver -spring.datasource.url=jdbc:h2:mem:localhost +spring.datasource.driver-class-name=org.mariadb.jdbc.Driver +spring.datasource.url=${DB_URL:jdbc:mariadb://localhost:3306/quak} +spring.datasource.username=${DB_USERNAME:root} +spring.datasource.password=${DB_PASSWORD:password} +spring.jpa.hibernate.ddl-auto=update +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MariaDBDialect # OAuth2 / OIDC Configuration spring.security.oauth2.client.registration.google.client-id=${OIDC_CLIENT_ID:your-client-id-here} @@ -21,7 +25,8 @@ spring.security.oauth2.client.provider.google.jwk-set-uri=https://www.googleapis server.servlet.session.cookie.http-only=true server.servlet.session.cookie.secure=${COOKIE_SECURE:false} server.servlet.session.cookie.same-site=lax -server.servlet.session.timeout=30m +server.servlet.session.timeout=30d +server.servlet.session.cookie.max-age=30d # Frontend URL (for redirects after login) app.frontend.url=${FRONTEND_URL:http://localhost:5173} diff --git a/backend/src/test/java/edu/kit/quak/files/FileControllerTest.java b/backend/src/test/java/edu/kit/quak/files/FileControllerTest.java index b7310f25..c0dc0632 100644 --- a/backend/src/test/java/edu/kit/quak/files/FileControllerTest.java +++ b/backend/src/test/java/edu/kit/quak/files/FileControllerTest.java @@ -37,11 +37,12 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import org.springframework.security.test.context.support.WithMockUser; +import edu.kit.quak.security.model.User; +import edu.kit.quak.security.repository.UserRepository; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin; @SpringBootTest @AutoConfigureMockMvc -@WithMockUser class FileControllerTest extends QuaKApplicationTests { public static final String JSON_CONTENT_TYPE = "application/json"; @@ -50,12 +51,33 @@ class FileControllerTest extends QuaKApplicationTests { @Autowired private MockMvc mockMvc; + + @Autowired + private UserRepository users; private Project parent; + private User testUser; @BeforeEach void setUp() { - parent = projects.save(new Project("Main")); + testUser = new User(); + testUser.setIssuer("test"); + testUser.setSub("test-sub"); + testUser.setEmail("test@example.com"); + testUser.setName("Test User"); + if (users.findByIssuerAndSub("test", "test-sub").isEmpty()) { + testUser = users.save(testUser); + } else { + testUser = users.findByIssuerAndSub("test", "test-sub").get(); + } + + parent = new Project("Main"); + parent.setOwner(testUser); + parent = projects.save(parent); + } + + private org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.OidcLoginRequestPostProcessor auth() { + return oidcLogin().idToken(token -> token.claim("sub", "test-sub")); } @AfterEach @@ -74,6 +96,7 @@ void newFile() throws Exception { .contentType(JSON_CONTENT_TYPE) .header("parent-id", parent.getId()) .with(csrf()) + .with(auth()) ).andExpectAll( status().isCreated(), content().contentType(JSON_CONTENT_TYPE), @@ -102,6 +125,7 @@ void newDirectory() throws Exception { .contentType(JSON_CONTENT_TYPE) .header("parent-id", parent.getId()) .with(csrf()) + .with(auth()) ).andExpectAll( status().isCreated(), content().contentType(JSON_CONTENT_TYPE), @@ -115,9 +139,9 @@ void newDirectory() throws Exception { @Test void retrieveFile() throws Exception { - //Creating without a parent because the file shouldn't even know its parent - File query = files.save(new File("Neu", null)); - mockMvc.perform(get("/file/"+query.getId())) + //Creating with parent so it has an owner + File query = files.save(new File("Neu", parent)); + mockMvc.perform(get("/file/"+query.getId()).with(auth())) .andExpectAll( status().isOk(), jsonPath("$.id", is(query.getId())), @@ -128,9 +152,9 @@ void retrieveFile() throws Exception { @Test void retrieveDirectory() throws Exception { - //Creating without a parent because the directory shouldn't even know its parent - Directory query = directories.save(new Directory("Hi", null)); - mockMvc.perform(get("/file/"+query.getId())) + //Creating with parent so it has an owner + Directory query = directories.save(new Directory("Hi", parent)); + mockMvc.perform(get("/file/"+query.getId()).with(auth())) .andExpectAll( status().isOk(), jsonPath("$.id", is(query.getId())), @@ -142,27 +166,27 @@ void retrieveDirectory() throws Exception { @Test void deleteFile() throws Exception { - File toDelete = files.save(new File("Fi", null)); - mockMvc.perform(delete("/file/" + toDelete.getId()).with(csrf())) + File toDelete = files.save(new File("Fi", parent)); + mockMvc.perform(delete("/file/" + toDelete.getId()).with(csrf()).with(auth())) .andExpect(status().isOk()); Assertions.assertTrue(files.findById(toDelete.getId()).isEmpty()); - mockMvc.perform(get("/file/"+toDelete.getId())) + mockMvc.perform(get("/file/"+toDelete.getId()).with(auth())) .andExpect(status().is4xxClientError()); } @Test void deleteDirectory() throws Exception { - Directory toDelete = directories.save(new Directory("toDelete", null)); - mockMvc.perform(delete("/file/" + toDelete.getId()).with(csrf())) - .andExpect(status().isOk()); + Directory toDelete = directories.save(new Directory("toDelete", parent)); + mockMvc.perform(delete("/file/" + toDelete.getId()).with(csrf()).with(auth())) + .andExpect(status().isOk()); Assertions.assertTrue(directories.findById(toDelete.getId()).isEmpty()); - mockMvc.perform(get("/file/"+toDelete.getId())) - .andExpect(status().is4xxClientError()); + mockMvc.perform(get("/file/"+toDelete.getId()).with(auth())) + .andExpect(status().is4xxClientError()); } @Test void patchFile() throws Exception { - File toPatch = files.save(new File("Hi", null)); + File toPatch = files.save(new File("Hi", parent)); final String name = UUID.randomUUID().toString(); ObjectNode patch = mapper.createObjectNode(); @@ -175,6 +199,7 @@ void patchFile() throws Exception { .contentType(JSON_CONTENT_TYPE) .content(patch.toString()) .with(csrf()) + .with(auth()) ).andExpect(status().isOk()); File patched = files.findById(toPatch.getId()).orElseThrow(); @@ -187,7 +212,7 @@ void patchFile() throws Exception { @Test @Transactional void patchDirectory() throws Exception { - Directory toPatch = directories.save(new Directory("toPatch", null)); + Directory toPatch = directories.save(new Directory("toPatch", parent)); final String name = UUID.randomUUID().toString(); ObjectNode patch = mapper.createObjectNode(); @@ -199,6 +224,7 @@ void patchDirectory() throws Exception { .contentType(JSON_CONTENT_TYPE) .content(patch.toString()) .with(csrf()) + .with(auth()) ).andExpect(status().isOk()); Directory patched = directories.findById(toPatch.getId()).orElseThrow(); @@ -209,7 +235,7 @@ void patchDirectory() throws Exception { @Test @Transactional void notPatchingDirectoryContent() throws Exception { - Directory toPatch = directories.save(new Directory("toPatch", null)); + Directory toPatch = directories.save(new Directory("toPatch", parent)); ObjectNode patch = mapper.createObjectNode(); ArrayNode contents = mapper.createArrayNode(); contents.add(getResource("file.json")); @@ -221,6 +247,7 @@ void notPatchingDirectoryContent() throws Exception { .contentType(JSON_CONTENT_TYPE) .content(patch.toString()) .with(csrf()) + .with(auth()) ).andExpect(status().isBadRequest()); Directory patched = directories.findById(toPatch.getId()).orElseThrow(); @@ -230,7 +257,7 @@ void notPatchingDirectoryContent() throws Exception { @ParameterizedTest @ValueSource(strings = {MediaType.APPLICATION_JSON_VALUE, MediaType.TEXT_HTML_VALUE, MediaType.TEXT_PLAIN_VALUE, MediaType.TEXT_XML_VALUE}) void postAndGetFileContent(String contentType) throws Exception { - File file = files.save(new File("Hi", null)); + File file = files.save(new File("Hi", parent)); byte[] content = new byte[200]; new Random().nextBytes(content); @@ -244,12 +271,13 @@ void postAndGetFileContent(String contentType) throws Exception { .content(content) .header("Content-Type", contentType) .with(csrf()) + .with(auth()) ).andExpect( status().isOk() ).andReturn().getRequest().getHeader("Content-Type"); mockMvc.perform( - get(String.format("/file/%s/content", file.getId())) + get(String.format("/file/%s/content", file.getId())).with(auth()) ).andExpectAll( status().isOk(), content().contentType(contentHeader), @@ -257,7 +285,7 @@ void postAndGetFileContent(String contentType) throws Exception { ); mockMvc.perform( - get("/file/"+file.getId()) + get("/file/"+file.getId()).with(auth()) ).andExpectAll( status().isOk(), jsonPath("$.contentType", startsWith(contentType)) @@ -270,19 +298,20 @@ void postAndGetFileContent(String contentType) throws Exception { void fileContentOver1MB() throws Exception { byte[] bytes = new byte[1500000]; new Random().nextBytes(bytes); - File file = files.save(new File("Hi", null)); + File file = files.save(new File("Hi", parent)); mockMvc.perform( put(String.format("/file/%s/content", file.getId())) .content(bytes) .content(bytes) .contentType("*/*") .with(csrf()) + .with(auth()) ).andExpect( status().isOk() ); mockMvc.perform( - get(String.format("/file/%s/content", file.getId())) + get(String.format("/file/%s/content", file.getId())).with(auth()) ).andExpectAll( status().isOk(), content().bytes(bytes) @@ -292,9 +321,9 @@ void fileContentOver1MB() throws Exception { @Test void fileContentEmptyAfterCreation() throws Exception { - File file = files.save(new File("Hi", null)); + File file = files.save(new File("Hi", parent)); mockMvc.perform( - get(String.format("/file/%s/content", file.getId())) + get(String.format("/file/%s/content", file.getId())).with(auth()) ).andExpectAll( status().isOk(), jsonPath("$").doesNotExist() @@ -304,9 +333,9 @@ void fileContentEmptyAfterCreation() throws Exception { @Test void failOnDirectoryContentEndpoint() throws Exception { - Directory dir = directories.save(new Directory("dir", null)); + Directory dir = directories.save(new Directory("dir", parent)); mockMvc.perform( - get(String.format("/file/%s/content", dir.getElements())) + get(String.format("/file/%s/content", dir.getElements())).with(auth()) ).andExpect( status().is4xxClientError() ); @@ -314,13 +343,14 @@ void failOnDirectoryContentEndpoint() throws Exception { @Test void failOnSetDirectoryContent() throws Exception { - Directory dir = directories.save(new Directory("dir", null)); + Directory dir = directories.save(new Directory("dir", parent)); mockMvc.perform( put(String.format("/file/%s/content", dir.getElements())) .contentType("text/plain") .contentType("text/plain") .content("Hello World") .with(csrf()) + .with(auth()) ).andExpect( status().is4xxClientError() ); @@ -328,10 +358,11 @@ void failOnSetDirectoryContent() throws Exception { @Test void failOnAddFileToFile() throws Exception { - File parent = files.save(new File("parent", null)); + File parent = files.save(new File("parent", this.parent)); mockMvc.perform( get("/file/") .header("parent-id", parent.getId()) + .with(auth()) ).andExpect( status().is4xxClientError() ); @@ -341,14 +372,14 @@ void failOnAddFileToFile() throws Exception { @Transactional //We don't allow the content of a contained directory to be displayed void noRecursiveDirectoryContent() throws Exception { - Directory main = new Directory("main", null); + Directory main = new Directory("main", parent); Directory lower = new Directory("lower", main); File file = files.save(new File("Hi", lower)); lower = directories.save(lower); main = directories.save(main); mockMvc.perform( - get("/file/" + main.getId()) + get("/file/" + main.getId()).with(auth()) ).andExpectAll( status().isOk(), jsonPath("$.name", is(main.getName())), @@ -360,7 +391,7 @@ void noRecursiveDirectoryContent() throws Exception { //Make sure `lower` has contents mockMvc.perform( - get("/file/" + lower.getId()) + get("/file/" + lower.getId()).with(auth()) ).andExpectAll( status().isOk(), jsonPath("$.contents[0].name", is(file.getName())) @@ -369,9 +400,11 @@ void noRecursiveDirectoryContent() throws Exception { @Test void cantRequestProjectInFileEndpoint() throws Exception { - Project project = projects.save(new Project("Test")); + Project project = new Project("Test"); + project.setOwner(testUser); + project = projects.save(project); mockMvc.perform( - get("/file/"+project.getId()) + get("/file/"+project.getId()).with(auth()) ).andExpectAll( status().isBadRequest() ); @@ -388,6 +421,7 @@ void failOnCreatedOnMissingInFile() throws Exception { .contentType(JSON_CONTENT_TYPE) .content(toSend.toString()) .with(csrf()) + .with(auth()) ).andExpectAll( status().isBadRequest() ); @@ -414,6 +448,7 @@ void failNotOnLastAccessMissingInFile() throws Exception { .contentType(JSON_CONTENT_TYPE) .content(toSend.toString()) .with(csrf()) + .with(auth()) ).andExpectAll( status().isCreated() ).andReturn(); @@ -421,7 +456,7 @@ void failNotOnLastAccessMissingInFile() throws Exception { JsonNode response = mapper.readTree(result.getResponse().getContentAsString()); mockMvc.perform( - get("/file/"+response.get("id").asText()) + get("/file/"+response.get("id").asText()).with(auth()) ).andExpectAll( status().isOk(), jsonPath("$.lastAccess", notNullValue()) @@ -431,21 +466,23 @@ void failNotOnLastAccessMissingInFile() throws Exception { @Test @Transactional void successfulDeletionOfContentInProject() throws Exception { - Project project = projects.save(new Project("New")); + Project project = new Project("New"); + project.setOwner(testUser); + project = projects.save(project); Directory dir = directories.save(new Directory("Hi", project)); File file = files.save(new File("", dir)); directories.save(dir); projects.save(project); - mockMvc.perform(delete("/file/" + dir.getId()).with(csrf())) + mockMvc.perform(delete("/file/" + dir.getId()).with(csrf()).with(auth())) .andExpectAll( status().isOk() ); - mockMvc.perform(get("/file/" + dir.getId())) + mockMvc.perform(get("/file/" + dir.getId()).with(auth())) .andExpectAll(status().is4xxClientError()); - mockMvc.perform(get("/file/" + file.getId())) + mockMvc.perform(get("/file/" + file.getId()).with(auth())) .andExpectAll(status().is4xxClientError()); assertFalse(projects.findById(project.getId()).orElseThrow().getElements().contains(dir)); @@ -460,7 +497,7 @@ void successfulDeletionOfContentInProject() throws Exception { void deleteFileInProject() throws Exception { File file = files.save(new File("", parent)); - mockMvc.perform(delete("/file/" + file.getId()).with(csrf())) + mockMvc.perform(delete("/file/" + file.getId()).with(csrf()).with(auth())) .andExpectAll(status().isOk()); assertTrue(files.findById(file.getId()).isEmpty()); @@ -476,8 +513,8 @@ void deleteFileInProject() throws Exception { void deleteDirectoryInProject() throws Exception { Directory dir = directories.save(new Directory("", parent)); - mockMvc.perform(delete("/file/" + dir.getId()).with(csrf())) - .andExpectAll(status().isOk()); + mockMvc.perform(delete("/file/" + dir.getId()).with(csrf()).with(auth())) + .andExpectAll(status().isOk()); assertTrue(directories.findById(dir.getId()).isEmpty()); assertTrue(projects.findById(parent.getId()) diff --git a/backend/src/test/java/edu/kit/quak/files/ProjectControllerTest.java b/backend/src/test/java/edu/kit/quak/files/ProjectControllerTest.java index a35ca0b5..16dbb00c 100644 --- a/backend/src/test/java/edu/kit/quak/files/ProjectControllerTest.java +++ b/backend/src/test/java/edu/kit/quak/files/ProjectControllerTest.java @@ -29,25 +29,47 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; -import org.springframework.security.test.context.support.WithMockUser; +import edu.kit.quak.security.model.User; +import edu.kit.quak.security.repository.UserRepository; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin; @SpringBootTest @AutoConfigureMockMvc -@WithMockUser class ProjectControllerTest extends QuaKApplicationTests { @Autowired private MockMvc mockMvc; + + @Autowired + private UserRepository users; private final ObjectMapper mapper = new ObjectMapper(); + + private User testUser; @BeforeEach void setUp() { + testUser = new User(); + testUser.setIssuer("test"); + testUser.setSub("test-sub"); + testUser.setEmail("test@example.com"); + testUser.setName("Test User"); + // Ensure unique user for each test run if DB is not reset + if (users.findByIssuerAndSub("test", "test-sub").isEmpty()) { + testUser = users.save(testUser); + } else { + testUser = users.findByIssuerAndSub("test", "test-sub").get(); + } + for (Project project : projects.findAll()) { savers.delete(project.getId()); } } + private org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.OidcLoginRequestPostProcessor auth() { + return oidcLogin().idToken(token -> token.claim("sub", "test-sub")); + } + @Test void createAndGetProject() throws Exception { final String name = UUID.randomUUID().toString(); @@ -60,6 +82,7 @@ void createAndGetProject() throws Exception { .contentType(JSON_CONTENT_TYPE) .content(project.toString()) .with(csrf()) + .with(auth()) ).andExpectAll( status().isCreated(), content().contentType(JSON_CONTENT_TYPE), @@ -80,16 +103,20 @@ void createAndGetProject() throws Exception { mockMvc.perform( get("/project") + .with(auth()) ).andExpectAll(matchers.apply("$[0]")); mockMvc.perform( get("/project/" + id) + .with(auth()) ).andExpectAll(matchers.apply("$")); } @Test @Transactional void patchProject() throws Exception { - Project toPatch = projects.save(new Project("ToPatch")); + Project toPatch = new Project("ToPatch"); + toPatch.setOwner(testUser); + toPatch = projects.save(toPatch); final String name = UUID.randomUUID().toString(); ObjectNode patch = mapper.createObjectNode(); @@ -101,6 +128,7 @@ void patchProject() throws Exception { .contentType(JSON_CONTENT_TYPE) .content(patch.toString()) .with(csrf()) + .with(auth()) ).andExpectAll( status().isOk() ); @@ -111,7 +139,9 @@ void patchProject() throws Exception { @Test void failPatchWithContent() throws Exception { - Project toPatch = projects.save(new Project("toPatch")); + Project toPatch = new Project("toPatch"); + toPatch.setOwner(testUser); + toPatch = projects.save(toPatch); ObjectNode patch = mapper.createObjectNode(); ArrayNode contents = mapper.createArrayNode(); @@ -124,6 +154,7 @@ void failPatchWithContent() throws Exception { .contentType(JSON_CONTENT_TYPE) .content(patch.toString()) .with(csrf()) + .with(auth()) ).andExpectAll( status().isBadRequest() ); @@ -131,9 +162,11 @@ void failPatchWithContent() throws Exception { @Test void deleteProject() throws Exception { - Project toDelete = projects.save(new Project("toDelete")); + Project toDelete = new Project("toDelete"); + toDelete.setOwner(testUser); + toDelete = projects.save(toDelete); mockMvc.perform( - delete("/project/" + toDelete.getId()).with(csrf()) + delete("/project/" + toDelete.getId()).with(csrf()).with(auth()) ).andExpect(status().isOk()); assertTrue(projects.findById(toDelete.getId()).isEmpty()); } @@ -141,12 +174,14 @@ void deleteProject() throws Exception { @Test @Transactional void deleteProjectContent() throws Exception { - Project toDelete = projects.save(new Project("toDelete")); + Project toDelete = new Project("toDelete"); + toDelete.setOwner(testUser); + toDelete = projects.save(toDelete); File inner = files.save(new File("Hello", toDelete)); projects.save(toDelete); mockMvc.perform( - delete("/project/" + toDelete.getId()).with(csrf()) + delete("/project/" + toDelete.getId()).with(csrf()).with(auth()) ).andExpect(status().isOk()); assertEmpty(files.findById(inner.getId())); assertEmpty(projects.findById(toDelete.getId())); @@ -157,6 +192,7 @@ void deleteProjectContent() throws Exception { //We don't allow the content of a contained directory to be displayed void noRecursiveDirectoryContent() throws Exception { Project main = new Project("main"); + main.setOwner(testUser); Directory lower = new Directory("lower", main); File file = files.save(new File("Hi", lower)); lower = directories.save(lower); @@ -164,6 +200,7 @@ void noRecursiveDirectoryContent() throws Exception { mockMvc.perform( get("/project/" + main.getId()) + .with(auth()) ).andExpectAll( status().isOk(), jsonPath("$.name", is(main.getName())), @@ -178,6 +215,7 @@ void noRecursiveDirectoryContent() throws Exception { @Transactional void projectOverviewContainsOnlyFirstLevelOfProjectContent() throws Exception { Project main = new Project("main"); + main.setOwner(testUser); Directory lower = new Directory("lower", main); File inner = files.save(new File("inner", lower)); lower = directories.save(lower); @@ -185,6 +223,7 @@ void projectOverviewContainsOnlyFirstLevelOfProjectContent() throws Exception { mockMvc.perform( get("/project/") + .with(auth()) ).andExpectAll( status().isOk(), jsonPath("$[0].id", is(main.getId())), @@ -197,6 +236,7 @@ void projectOverviewContainsOnlyFirstLevelOfProjectContent() throws Exception { //Ensure that the normal view is correct mockMvc.perform( get("/project/" + main.getId()) + .with(auth()) ).andExpectAll( status().isOk(), jsonPath("$.name", is(main.getName())), diff --git a/backend/src/test/resources/application.properties b/backend/src/test/resources/application.properties new file mode 100644 index 00000000..018ab410 --- /dev/null +++ b/backend/src/test/resources/application.properties @@ -0,0 +1,14 @@ +spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1 +spring.datasource.driverClassName=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password=password +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect +spring.jpa.hibernate.ddl-auto=create-drop + +app.frontend.url=http://localhost:5173 + +# OIDC placeholders to avoid resolution errors +spring.security.oauth2.client.registration.google.client-id=test-client-id +spring.security.oauth2.client.registration.google.client-secret=test-client-secret +spring.security.oauth2.client.provider.google.issuer-uri=https://accounts.google.com + diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx index 28ff0a47..01f3a73f 100644 --- a/frontend/src/pages/Profile.tsx +++ b/frontend/src/pages/Profile.tsx @@ -1,24 +1,54 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; -import { User } from 'lucide-react'; +import { User as UserIcon, Loader2 } from 'lucide-react'; +import { api } from '@/utils/api'; + +interface UserDto { + userId: string; + email: string; + name: string; + avatarUrl: string | null; + emailVerified: boolean; +} export const Profile: React.FC = () => { - // Mock profile data - const [profileData] = useState({ - username: 'quantum_researcher', - email: 'alice.quantum@example.com', - fullName: 'Dr. Alice Quantum', - bio: 'Quantum computing researcher specializing in quantum algorithms and error correction. Passionate about making quantum computing accessible to everyone.', - institution: 'Quantum Research Institute', - role: 'Senior Researcher', - joinDate: 'January 2024', - projectsCount: 12, - collaborations: 5 - }); + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchProfile = async () => { + try { + const data = await api.get('/api/me'); + setUser(data); + } catch (err) { + console.error('Failed to fetch profile:', err); + setError('Failed to load profile data'); + } finally { + setLoading(false); + } + }; + + fetchProfile(); + }, []); + + if (loading) { + return ( +
+ +
+ ); + } + + if (error || !user) { + return ( +
+

Error

+

{error || 'User not found'}

+
+ ); + } return (
@@ -29,97 +59,33 @@ export const Profile: React.FC = () => {
-
- +
+ {user.avatarUrl ? ( + {user.name} + ) : ( + + )}
- {profileData.fullName} + {user.name} - @{profileData.username} • {profileData.email} + {user.email}
- {profileData.role} - {profileData.institution} - Joined {profileData.joinDate} + {user.emailVerified && ( + + Verified Email + + )} + User ID: {user.userId}
-

{profileData.bio}

-
- - - {/* Statistics Card */} - - - Activity Statistics - - -
-
-
{profileData.projectsCount}
-
Projects
-
-
-
{profileData.collaborations}
-
Collaborations
-
-
-
24
-
Circuits Created
-
-
-
-
- - {/* Edit Profile Card */} - - - Edit Profile - Update your profile information - - -
-
- - -
-
- - -
-
- -
- - -
- -
-
- - -
-
- - -
-
- -
- -