diff --git a/.gitignore b/.gitignore index c2065bc2..5bf99b48 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ out/ ### VS Code ### .vscode/ + +Projects/ +test-credentials.properties diff --git a/docs/architecture/start_analysis_flow.puml b/docs/architecture/start_analysis_flow.puml new file mode 100644 index 00000000..d9cb73c4 --- /dev/null +++ b/docs/architecture/start_analysis_flow.puml @@ -0,0 +1,55 @@ +@startuml StartAnalysisFlow +title Start Analysis Flow + +actor User +participant "Client (React)" as Client +participant "AuthController" as Auth +participant "RequestResource" as Controller +participant "RequestService" as ReqService +participant "RepositoryFetchingService" as RepoService +participant "ArtemisClientService" as ArtemisClient +participant "GitOperationsService" as GitService +participant "Artemis Server" as Artemis +participant "Git Server" as Git + +== Authentication Phase == +User -> Client: Enter Credentials & Click "Start" +Client -> Auth: POST /api/auth/login +note right of Client: Sends username, password, serverUrl +Auth -> ArtemisClient: authenticate(url, user, pass) +ArtemisClient -> Artemis: POST /api/core/public/authenticate +Artemis --> ArtemisClient: 200 OK (Set-Cookie: jwt) +ArtemisClient --> Auth: jwtToken +Auth -> Client: 200 OK (Set-Cookie: jwt, username, password) +Client -> Client: Navigate to /teams + +== Data Loading Phase == +Client -> Controller: GET /api/requestResource/fetchAndCloneRepositories +note right of Client: Cookies included automatically +Controller -> Controller: Extract & Decrypt Credentials +Controller -> ReqService: fetchAndCloneRepositories(url, jwt, user, pass) +ReqService -> RepoService: fetchAndCloneRepositories(url, jwt, user, pass) + +== Fetching Participations == +RepoService -> ArtemisClient: fetchParticipations(url, jwt) +ArtemisClient -> Artemis: GET /api/exercise/exercises/{id}/participations +Artemis --> ArtemisClient: List +ArtemisClient --> RepoService: List + +== Cloning Repositories == +loop For each Participation + RepoService -> GitService: cloneOrPullRepository(uri, teamName, user, pass) + GitService -> Git: git clone / git pull + note right of GitService: Uses Instructor Username/Password directly + Git --> GitService: Repository Content + GitService --> RepoService: Local Path +end + +== Response Phase == +RepoService --> ReqService: List +ReqService --> Controller: List +Controller --> Client: 200 OK (JSON List) +Client -> Client: Transform Data (Mock Analysis) +Client -> User: Display Team Cards + +@enduml diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 2c456c69..498c73ec 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -3,10 +3,38 @@ info: {title: OpenAPI definition, version: v0} servers: - {url: 'http://localhost:8080', description: Generated server url} paths: + /api/auth/login: + post: + tags: [auth-resource] + operationId: login + requestBody: + content: + application/json: + schema: {$ref: '#/components/schemas/LoginRequestDTO'} + required: true + responses: + '200': {description: OK} /api/requestResource/fetchAndCloneRepositories: get: tags: [request-resource] operationId: fetchAndCloneRepositories + parameters: + - name: jwt + in: cookie + required: false + schema: {type: string} + - name: artemis_server_url + in: cookie + required: false + schema: {type: string} + - name: artemis_username + in: cookie + required: false + schema: {type: string} + - name: artemis_password + in: cookie + required: false + schema: {type: string} responses: '200': description: OK @@ -17,6 +45,13 @@ paths: items: {$ref: '#/components/schemas/TeamRepositoryDTO'} components: schemas: + LoginRequestDTO: + type: object + properties: + password: {type: string, minLength: 1} + serverUrl: {type: string, minLength: 1} + username: {type: string, minLength: 1} + required: [password, serverUrl, username] ParticipantDTO: type: object properties: diff --git a/scripts/docker_start.sh b/scripts/docker_start.sh index 46c7fc06..58676666 100755 --- a/scripts/docker_start.sh +++ b/scripts/docker_start.sh @@ -110,10 +110,4 @@ echo -e "\n${YELLOW}Useful commands:${NC}" echo -e "${YELLOW}• View logs:${NC} docker compose -f docker/docker-compose.yml logs -f" echo -e "${YELLOW}• Stop services:${NC} docker compose -f docker/docker-compose.yml down" echo -e "${YELLOW}• Restart services:${NC} docker compose -f docker/docker-compose.yml restart" -echo -e "\n${YELLOW}Press Ctrl+C to stop all services${NC}\n" -# Keep script running and handle Ctrl+C -trap "echo -e '\n${RED}Shutting down all services...${NC}'; docker compose -f docker/docker-compose.yml down; exit" INT - -# Follow logs to keep script running -docker compose -f docker/docker-compose.yml logs -f diff --git a/src/main/java/de/tum/cit/aet/core/config/ArtemisConfig.java b/src/main/java/de/tum/cit/aet/core/config/ArtemisConfig.java index 57365cba..25abd4db 100644 --- a/src/main/java/de/tum/cit/aet/core/config/ArtemisConfig.java +++ b/src/main/java/de/tum/cit/aet/core/config/ArtemisConfig.java @@ -11,10 +11,6 @@ @Setter public class ArtemisConfig { - private String baseUrl; - private String username; - private String password; - private String jwtToken; private Long exerciseId; private String gitRepoPath; private Integer numThreads; diff --git a/src/main/java/de/tum/cit/aet/core/config/SecurityConfig.java b/src/main/java/de/tum/cit/aet/core/config/SecurityConfig.java index 745b3f53..7dd0ccd7 100644 --- a/src/main/java/de/tum/cit/aet/core/config/SecurityConfig.java +++ b/src/main/java/de/tum/cit/aet/core/config/SecurityConfig.java @@ -39,11 +39,11 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, .cors(cors -> cors.configurationSource(corsConfigurationSource)) .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth -> auth - .requestMatchers("/api-docs", "/api-docs.yaml", "/actuator/health") + .requestMatchers("/api-docs", "/api-docs.yaml", "/actuator/health", "/api/auth/**") .permitAll() .anyRequest().authenticated() ) - .httpBasic(_ -> {}); + .httpBasic(basic -> {}); return http.build(); } diff --git a/src/main/java/de/tum/cit/aet/core/dto/ArtemisCredentials.java b/src/main/java/de/tum/cit/aet/core/dto/ArtemisCredentials.java new file mode 100644 index 00000000..7f2d59e5 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/core/dto/ArtemisCredentials.java @@ -0,0 +1,35 @@ +package de.tum.cit.aet.core.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * DTO holding Artemis authentication credentials. + * Used to pass credentials through the application layers cleanly. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record ArtemisCredentials( + String serverUrl, + String jwtToken, + String username, + String password +) { + /** + * Checks if the credentials contain valid authentication data. + * + * @return true if serverUrl and jwtToken are present + */ + public boolean isValid() { + return serverUrl != null && !serverUrl.isBlank() + && jwtToken != null && !jwtToken.isBlank(); + } + + /** + * Checks if username/password credentials are available for Git operations. + * + * @return true if both username and password are present + */ + public boolean hasGitCredentials() { + return username != null && !username.isBlank() + && password != null && !password.isBlank(); + } +} diff --git a/src/main/java/de/tum/cit/aet/core/exceptions/GitOperationException.java b/src/main/java/de/tum/cit/aet/core/exceptions/GitOperationException.java new file mode 100644 index 00000000..8f858d19 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/core/exceptions/GitOperationException.java @@ -0,0 +1,28 @@ +package de.tum.cit.aet.core.exceptions; + +/** + * Custom exception for errors occurring during Git operations. + * This class provides a more specific way to catch and handle failures + * related to cloning, pulling, or other Git functionality. + */ +public class GitOperationException extends RuntimeException { + + /** + * Constructs a new GitOperationException with the specified detail message. + * + * @param message the detail message. + */ + public GitOperationException(String message) { + super(message); + } + + /** + * Constructs a new GitOperationException with the specified detail message and cause. + * + * @param message the detail message. + * @param cause the cause (which is saved for later retrieval by the {@link #getCause()} method). + */ + public GitOperationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/de/tum/cit/aet/core/security/CryptoService.java b/src/main/java/de/tum/cit/aet/core/security/CryptoService.java new file mode 100644 index 00000000..0c3035a1 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/core/security/CryptoService.java @@ -0,0 +1,82 @@ +package de.tum.cit.aet.core.security; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Base64; + +@Service +public class CryptoService { + + private static final String ALGORITHM = "AES/GCM/NoPadding"; + private static final int GCM_TAG_LENGTH = 128; + private static final int GCM_IV_LENGTH = 12; + + private final SecretKeySpec secretKey; + + public CryptoService(@Value("${harmonia.security.secret-key:default-secret-key-change-me-in-prod}") String secret) { + this.secretKey = generateKey(secret); + } + + private SecretKeySpec generateKey(String myKey) { + try { + byte[] key = myKey.getBytes(StandardCharsets.UTF_8); + MessageDigest sha = MessageDigest.getInstance("SHA-256"); + key = sha.digest(key); + key = Arrays.copyOf(key, 16); // Use only first 128 bit + return new SecretKeySpec(key, "AES"); + } catch (Exception e) { + throw new RuntimeException("Error generating security key", e); + } + } + + /** + * Encrypts a string using AES encryption. + * + * @param strToEncrypt The string to encrypt + * @return The encrypted string (Base64 encoded) + */ + public String encrypt(String strToEncrypt) { + try { + byte[] iv = new byte[GCM_IV_LENGTH]; + new SecureRandom().nextBytes(iv); + GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.ENCRYPT_MODE, secretKey, spec); + byte[] cipherText = cipher.doFinal(strToEncrypt.getBytes(StandardCharsets.UTF_8)); + byte[] encrypted = new byte[iv.length + cipherText.length]; + System.arraycopy(iv, 0, encrypted, 0, iv.length); + System.arraycopy(cipherText, 0, encrypted, iv.length, cipherText.length); + return Base64.getEncoder().encodeToString(encrypted); + } catch (Exception e) { + throw new RuntimeException("Error while encrypting", e); + } + } + + /** + * Decrypts a string using AES encryption. + * + * @param strToDecrypt The string to decrypt (Base64 encoded) + * @return The decrypted string + */ + public String decrypt(String strToDecrypt) { + try { + byte[] decoded = Base64.getDecoder().decode(strToDecrypt); + byte[] iv = new byte[GCM_IV_LENGTH]; + System.arraycopy(decoded, 0, iv, 0, iv.length); + GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.DECRYPT_MODE, secretKey, spec); + return new String(cipher.doFinal(decoded, GCM_IV_LENGTH, decoded.length - GCM_IV_LENGTH)); + } catch (Exception e) { + throw new RuntimeException("Error while decrypting", e); + } + } +} diff --git a/src/main/java/de/tum/cit/aet/dataProcessing/service/RequestService.java b/src/main/java/de/tum/cit/aet/dataProcessing/service/RequestService.java index d6a9f93c..396d10d9 100644 --- a/src/main/java/de/tum/cit/aet/dataProcessing/service/RequestService.java +++ b/src/main/java/de/tum/cit/aet/dataProcessing/service/RequestService.java @@ -1,5 +1,6 @@ package de.tum.cit.aet.dataProcessing.service; +import de.tum.cit.aet.core.dto.ArtemisCredentials; import de.tum.cit.aet.repositoryProcessing.dto.TeamRepositoryDTO; import de.tum.cit.aet.repositoryProcessing.service.RepositoryFetchingService; import lombok.extern.slf4j.Slf4j; @@ -20,12 +21,13 @@ public RequestService(RepositoryFetchingService repositoryFetchingService) { } /** - * Fetches and clones all repositories from Artemis. + * Fetches and clones all repositories from Artemis using dynamic credentials. * + * @param credentials The Artemis credentials * @return List of TeamRepositoryDTO containing repository information */ - public List fetchAndCloneRepositories() { + public List fetchAndCloneRepositories(ArtemisCredentials credentials) { log.info("RequestService: Initiating repository fetch and clone process"); - return repositoryFetchingService.fetchAndCloneRepositories(); + return repositoryFetchingService.fetchAndCloneRepositories(credentials); } } diff --git a/src/main/java/de/tum/cit/aet/dataProcessing/web/RequestResource.java b/src/main/java/de/tum/cit/aet/dataProcessing/web/RequestResource.java index 6da61eab..8c50839e 100644 --- a/src/main/java/de/tum/cit/aet/dataProcessing/web/RequestResource.java +++ b/src/main/java/de/tum/cit/aet/dataProcessing/web/RequestResource.java @@ -1,10 +1,13 @@ package de.tum.cit.aet.dataProcessing.web; +import de.tum.cit.aet.core.dto.ArtemisCredentials; +import de.tum.cit.aet.core.security.CryptoService; import de.tum.cit.aet.dataProcessing.service.RequestService; import de.tum.cit.aet.repositoryProcessing.dto.TeamRepositoryDTO; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -17,23 +20,55 @@ public class RequestResource { private final RequestService requestService; + private final CryptoService cryptoService; @Autowired - public RequestResource(RequestService requestService) { + public RequestResource(RequestService requestService, CryptoService cryptoService) { this.requestService = requestService; + this.cryptoService = cryptoService; } /** * GET endpoint to fetch and clone all repositories. * Triggers the fetching of participations from Artemis and clones/pulls all repositories. * + * @param jwtToken The JWT token from the cookie + * @param serverUrl The Artemis server URL from the cookie + * @param username The Artemis username from the cookie + * @param encryptedPassword The encrypted Artemis password from the cookie * @return ResponseEntity containing the list of TeamRepositoryDTO */ @GetMapping("fetchAndCloneRepositories") - public ResponseEntity> fetchAndCloneRepositories() { + public ResponseEntity> fetchAndCloneRepositories( + @CookieValue(value = "jwt", required = false) String jwtToken, + @CookieValue(value = "artemis_server_url", required = false) String serverUrl, + @CookieValue(value = "artemis_username", required = false) String username, + @CookieValue(value = "artemis_password", required = false) String encryptedPassword + ) { log.info("GET request received: fetchAndCloneRepositories"); - List repositories = requestService.fetchAndCloneRepositories(); + + String password = decryptPassword(encryptedPassword); + ArtemisCredentials credentials = new ArtemisCredentials(serverUrl, jwtToken, username, password); + + if (!credentials.isValid()) { + log.warn("No credentials found in cookies. Authentication required."); + return ResponseEntity.status(401).build(); + } + + List repositories = requestService.fetchAndCloneRepositories(credentials); log.info("Successfully fetched and cloned {} repositories", repositories.size()); return ResponseEntity.ok(repositories); } + + private String decryptPassword(String encryptedPassword) { + if (encryptedPassword == null) { + return null; + } + try { + return cryptoService.decrypt(encryptedPassword); + } catch (Exception e) { + log.error("Failed to decrypt password from cookie", e); + return null; + } + } } diff --git a/src/main/java/de/tum/cit/aet/repositoryProcessing/service/ArtemisClientService.java b/src/main/java/de/tum/cit/aet/repositoryProcessing/service/ArtemisClientService.java index 66c287a4..8eb6ae99 100644 --- a/src/main/java/de/tum/cit/aet/repositoryProcessing/service/ArtemisClientService.java +++ b/src/main/java/de/tum/cit/aet/repositoryProcessing/service/ArtemisClientService.java @@ -6,10 +6,14 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.web.client.RestClient; import java.util.List; +import java.util.Map; /** * Service responsible for communicating with the Artemis API. @@ -20,30 +24,117 @@ public class ArtemisClientService { private final ArtemisConfig artemisConfig; - private final RestClient restClient; @Autowired public ArtemisClientService(ArtemisConfig artemisConfig) { this.artemisConfig = artemisConfig; - restClient = RestClient.builder() - .baseUrl(artemisConfig.getBaseUrl()) - .defaultHeader("Cookie", "jwt=" + artemisConfig.getJwtToken()) - .build(); } /** - * Fetches all participations for the configured exercise from Artemis. + * Authenticates with the Artemis server and retrieves the JWT cookie. * + * @param serverUrl The Artemis server URL + * @param username The username + * @param password The password + * @return The JWT cookie string + */ + public String authenticate(String serverUrl, String username, String password) { + return authenticateInternal(serverUrl, username, password, 0); + } + + private String authenticateInternal(String serverUrl, String username, String password, int retryCount) { + if (retryCount > 3) { + throw new ArtemisConnectionException("Too many redirects during authentication"); + } + + // Strip /api suffix if present + if (serverUrl.endsWith("/api")) { + serverUrl = serverUrl.substring(0, serverUrl.length() - 4); + } else if (serverUrl.endsWith("/api/")) { + serverUrl = serverUrl.substring(0, serverUrl.length() - 5); + } + + log.info("Authenticating with Artemis at {} (attempt {})", serverUrl, retryCount + 1); + + Map authRequest = Map.of( + "username", username, + "password", password, + "rememberMe", true + ); + + try { + // Ensure we hit the correct endpoint structure + // Modern Artemis uses /api/core/public/authenticate + // We try to construct the path carefully + String authPath = "/api/core/public/authenticate"; + + // If the serverUrl already ends with /api, we should avoid doubling it if we were using /api/... + // But here we assume serverUrl is the root (e.g. https://artemis.tum.de) + + ResponseEntity response = RestClient.create(serverUrl).post() + .uri(authPath) + .contentType(MediaType.APPLICATION_JSON) + .body(authRequest) + .retrieve() + .toEntity(String.class); + + if (response.getStatusCode().is3xxRedirection()) { + String newLocation = response.getHeaders().getFirst(HttpHeaders.LOCATION); + if (newLocation != null) { + // Normalize new location to be a base URL + String newBaseUrl = newLocation; + if (newBaseUrl.endsWith(authPath)) { + newBaseUrl = newBaseUrl.substring(0, newBaseUrl.length() - authPath.length()); + } + if (newBaseUrl.endsWith("/")) { + newBaseUrl = newBaseUrl.substring(0, newBaseUrl.length() - 1); + } + + return authenticateInternal(newBaseUrl, username, password, retryCount + 1); + } + } + + List cookies = response.getHeaders().get(HttpHeaders.SET_COOKIE); + if (cookies != null) { + for (String cookie : cookies) { + if (cookie.startsWith("jwt=")) { + int start = 4; + int end = cookie.indexOf(';'); + if (end == -1) { + end = cookie.length(); + } + return cookie.substring(start, end); + } + } + } + + throw new ArtemisConnectionException("No JWT cookie received from Artemis"); + } catch (Exception e) { + log.error("Authentication failed", e); + throw new ArtemisConnectionException("Authentication failed", e); + } + } + + /** + * Fetches all participations for the configured exercise from Artemis using provided credentials. + * + * @param serverUrl The Artemis server URL + * @param jwtToken The JWT token for authentication * @return List of participation DTOs containing team and repository information */ - public List fetchParticipations() { - log.info("Fetching participations for exercise ID: {}", artemisConfig.getExerciseId()); + public List fetchParticipations(String serverUrl, String jwtToken) { + log.info("Fetching participations for exercise ID: {} from {}", artemisConfig.getExerciseId(), serverUrl); - String uri = String.format("/exercise/exercises/%d/participations?withLatestResults=false", + String uri = String.format("/api/exercise/exercises/%d/participations?withLatestResults=false", artemisConfig.getExerciseId()); try { - List participations = restClient.get() + RestClient dynamicClient = RestClient.builder() + .baseUrl(serverUrl) + .defaultHeader("Cookie", "jwt=" + jwtToken) + .build(); + + List participations = dynamicClient.get() .uri(uri) .retrieve() .body(new ParameterizedTypeReference<>() { @@ -59,4 +150,35 @@ public List fetchParticipations() { throw new ArtemisConnectionException("Failed to fetch participations from Artemis", e); } } + + /** + * Fetches a VCS access token for a specific participation. + * + * @param serverUrl The Artemis server URL + * @param jwtToken The JWT token for authentication + * @param participationId The ID of the participation + * @return The VCS access token + */ + public String getVcsAccessToken(String serverUrl, String jwtToken, Long participationId) { + String uri = "/api/core/account/participation-vcs-access-token?participationId=" + participationId; + + try { + RestClient dynamicClient = RestClient.builder() + .baseUrl(serverUrl) + .defaultHeader("Cookie", "jwt=" + jwtToken) + .build(); + + // Assuming GET as POST failed with 405 + String vcsToken = dynamicClient.get() + .uri(uri) + .retrieve() + .body(String.class); + + return vcsToken; + + } catch (Exception e) { + log.error("Error fetching VCS access token for participation {}", participationId, e); + throw new ArtemisConnectionException("Failed to fetch VCS access token", e); + } + } } diff --git a/src/main/java/de/tum/cit/aet/repositoryProcessing/service/GitOperationsService.java b/src/main/java/de/tum/cit/aet/repositoryProcessing/service/GitOperationsService.java index 9afc3acf..b48fc806 100644 --- a/src/main/java/de/tum/cit/aet/repositoryProcessing/service/GitOperationsService.java +++ b/src/main/java/de/tum/cit/aet/repositoryProcessing/service/GitOperationsService.java @@ -1,6 +1,7 @@ package de.tum.cit.aet.repositoryProcessing.service; import de.tum.cit.aet.core.config.ArtemisConfig; +import de.tum.cit.aet.core.exceptions.GitOperationException; import lombok.extern.slf4j.Slf4j; import org.eclipse.jgit.api.Git; import org.eclipse.jgit.api.errors.GitAPIException; @@ -31,14 +32,19 @@ public GitOperationsService(ArtemisConfig artemisConfig) { } /** - * Clones or pulls a repository. If the repository already exists locally, it performs a pull. - * Otherwise, it clones the repository. + * Clones or pulls a repository using provided credentials (Dynamic). * * @param repositoryUri The URI of the Git repository - * @param teamName The name of the team (used for directory naming) + * @param teamName The name of the team + * @param username The username for authentication + * @param password The password (or token) for authentication * @return The local path where the repository is stored */ - public String cloneOrPullRepository(String repositoryUri, String teamName) { + public String cloneOrPullRepository(String repositoryUri, String teamName, String username, String password) { + return cloneOrPullRepository(repositoryUri, teamName, new UsernamePasswordCredentialsProvider(username, password)); + } + + private String cloneOrPullRepository(String repositoryUri, String teamName, UsernamePasswordCredentialsProvider credentialsProvider) { // Extracts the repository name from the URI String[] parts = repositoryUri.split("/"); String repoName = parts[parts.length - 1].replace(".git", ""); @@ -48,10 +54,10 @@ public String cloneOrPullRepository(String repositoryUri, String teamName) { if (repoDir.exists() && new File(repoDir, ".git").exists()) { log.info("Repository {} already exists, performing git pull", repoName); - pullRepository(localPath); + pullRepository(localPath, credentialsProvider); } else { log.info("Cloning repository {} for team {}", repoName, teamName); - cloneRepository(repositoryUri, localPath); + cloneRepository(repositoryUri, localPath, credentialsProvider); } return localPath.toString(); @@ -60,27 +66,30 @@ public String cloneOrPullRepository(String repositoryUri, String teamName) { /** * Clones a repository from the given URI to the local path. * - * @param repositoryUri The URI of the Git repository - * @param localPath The local path where the repository should be cloned + * @param repositoryUri The URI of the Git repository + * @param localPath The local path where the repository should be cloned + * @param credentialsProvider The credentials provider */ - private void cloneRepository(String repositoryUri, Path localPath) { - try (Git _ = Git.cloneRepository() + private void cloneRepository(String repositoryUri, Path localPath, UsernamePasswordCredentialsProvider credentialsProvider) { + try (Git git = Git.cloneRepository() .setURI(repositoryUri) .setDirectory(localPath.toFile()) - .setCredentialsProvider(createCredentialsProvider()) + .setCredentialsProvider(credentialsProvider) .call()) { log.info("Successfully cloned repository to {}", localPath); } catch (GitAPIException e) { log.error("Failed to clone repository from {} to {}. Error: {}", repositoryUri, localPath, e.getMessage(), e); + throw new GitOperationException("Failed to clone repository: " + e.getMessage(), e); } } /** * Pulls the latest changes for an existing repository. * - * @param localPath The local path of the repository + * @param localPath The local path of the repository + * @param credentialsProvider The credentials provider */ - private void pullRepository(Path localPath) { + private void pullRepository(Path localPath, UsernamePasswordCredentialsProvider credentialsProvider) { try { FileRepositoryBuilder builder = new FileRepositoryBuilder(); Repository repository = builder @@ -91,7 +100,7 @@ private void pullRepository(Path localPath) { try (Git git = new Git(repository)) { git.pull() - .setCredentialsProvider(createCredentialsProvider()) + .setCredentialsProvider(credentialsProvider) .call(); log.info("Successfully pulled latest changes for {}", localPath); @@ -99,21 +108,11 @@ private void pullRepository(Path localPath) { } catch (IOException e) { // Handle IOException (e.g., .git directory not found or inaccessible) log.error("Failed to open repository at {}. Ensure it is a valid Git repository. Error: {}", localPath, e.getMessage(), e); + throw new GitOperationException("Failed to open repository: " + e.getMessage(), e); } catch (GitAPIException e) { // Handle GitAPIException (e.g., authentication failure, network issue during pull) log.error("Failed to pull latest changes for {}. Error: {}", localPath, e.getMessage(), e); + throw new GitOperationException("Failed to pull repository: " + e.getMessage(), e); } } - - /** - * Creates credentials provider for Git authentication. - * - * @return UsernamePasswordCredentialsProvider with configured credentials - */ - private UsernamePasswordCredentialsProvider createCredentialsProvider() { - return new UsernamePasswordCredentialsProvider( - artemisConfig.getUsername(), - artemisConfig.getPassword() - ); - } } diff --git a/src/main/java/de/tum/cit/aet/repositoryProcessing/service/RepositoryFetchingService.java b/src/main/java/de/tum/cit/aet/repositoryProcessing/service/RepositoryFetchingService.java index 440db57d..86c90120 100644 --- a/src/main/java/de/tum/cit/aet/repositoryProcessing/service/RepositoryFetchingService.java +++ b/src/main/java/de/tum/cit/aet/repositoryProcessing/service/RepositoryFetchingService.java @@ -1,5 +1,6 @@ package de.tum.cit.aet.repositoryProcessing.service; +import de.tum.cit.aet.core.dto.ArtemisCredentials; import de.tum.cit.aet.repositoryProcessing.dto.ParticipationDTO; import de.tum.cit.aet.repositoryProcessing.dto.TeamRepositoryDTO; import de.tum.cit.aet.repositoryProcessing.dto.TeamRepositoryDTOBuilder; @@ -29,31 +30,27 @@ public RepositoryFetchingService(ArtemisClientService artemisClientService, GitO /** * Fetches all team repositories from Artemis and clones/pulls them locally. * + * @param credentials The Artemis credentials * @return List of TeamRepositoryDTO containing repository information */ - public List fetchAndCloneRepositories() { + public List fetchAndCloneRepositories(ArtemisCredentials credentials) { log.info("Starting repository fetching process"); // Step 1: Fetch participations from Artemis - List participations = artemisClientService.fetchParticipations(); + List participations = artemisClientService.fetchParticipations( + credentials.serverUrl(), credentials.jwtToken()); // Step 2: Filter participations with repositories and clone them List teamRepositories = participations.stream() .filter(p -> p.repositoryUri() != null && !p.repositoryUri().isEmpty()) - .map(this::cloneTeamRepository) + .map(p -> cloneTeamRepository(p, credentials)) .toList(); log.info("Completed repository fetching. Total repositories: {}", teamRepositories.size()); return teamRepositories; } - /** - * Clones a single team repository and creates a TeamRepositoryDTO. - * - * @param participation The participation containing repository information - * @return TeamRepositoryDTO with clone status and information - */ - private TeamRepositoryDTO cloneTeamRepository(ParticipationDTO participation) { + private TeamRepositoryDTO cloneTeamRepository(ParticipationDTO participation, ArtemisCredentials credentials) { String teamName = participation.team() != null ? participation.team().name() : "Unknown Team"; @@ -61,8 +58,14 @@ private TeamRepositoryDTO cloneTeamRepository(ParticipationDTO participation) { TeamRepositoryDTOBuilder builder = TeamRepositoryDTO.builder() .participation(participation); + try { - String localPath = gitOperationsService.cloneOrPullRepository(repositoryUri, teamName); + if (!credentials.hasGitCredentials()) { + throw new IllegalStateException("No credentials provided for cloning. Username and password are required."); + } + + String localPath = gitOperationsService.cloneOrPullRepository( + repositoryUri, teamName, credentials.username(), credentials.password()); builder.localPath(localPath) .isCloned(true); diff --git a/src/main/java/de/tum/cit/aet/usermanagement/dto/LoginRequestDTO.java b/src/main/java/de/tum/cit/aet/usermanagement/dto/LoginRequestDTO.java new file mode 100644 index 00000000..47a218e4 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/usermanagement/dto/LoginRequestDTO.java @@ -0,0 +1,11 @@ +package de.tum.cit.aet.usermanagement.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.validation.constraints.NotBlank; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record LoginRequestDTO( + @NotBlank(message = "Username must not be blank") String username, + @NotBlank(message = "Password must not be blank") String password, + @NotBlank(message = "Server URL must not be blank") String serverUrl) { +} diff --git a/src/main/java/de/tum/cit/aet/usermanagement/web/AuthResource.java b/src/main/java/de/tum/cit/aet/usermanagement/web/AuthResource.java new file mode 100644 index 00000000..96890b20 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/usermanagement/web/AuthResource.java @@ -0,0 +1,90 @@ +package de.tum.cit.aet.usermanagement.web; + +import de.tum.cit.aet.core.security.CryptoService; +import de.tum.cit.aet.repositoryProcessing.service.ArtemisClientService; +import de.tum.cit.aet.usermanagement.dto.LoginRequestDTO; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.validation.Valid; +import java.time.Duration; + +@Slf4j +@RestController +@RequestMapping("/api/auth") +public class AuthResource { + + private final ArtemisClientService artemisClientService; + private final CryptoService cryptoService; + + public AuthResource(ArtemisClientService artemisClientService, CryptoService cryptoService) { + this.artemisClientService = artemisClientService; + this.cryptoService = cryptoService; + } + + /** + * Authenticates the user against Artemis and sets the necessary cookies. + * + * @param loginRequest The login request containing username, password, and server URL + * @return ResponseEntity with the cookies set + */ + @PostMapping("/login") + public ResponseEntity login(@Valid @RequestBody LoginRequestDTO loginRequest) { + log.info("POST request received: /api/auth/login for server {}", loginRequest.serverUrl()); + String jwtToken = artemisClientService.authenticate( + loginRequest.serverUrl(), + loginRequest.username(), + loginRequest.password() + ); + + ResponseCookie jwtCookie = ResponseCookie.from("jwt", jwtToken) + .httpOnly(true) + .secure(true) // Set to true for production + .path("/") + .maxAge(Duration.ofDays(1)) + .sameSite("Strict") + .build(); + + ResponseCookie serverUrlCookie = ResponseCookie.from("artemis_server_url", loginRequest.serverUrl()) + .httpOnly(true) + .secure(true) + .path("/") + .maxAge(Duration.ofDays(1)) + .sameSite("Strict") + .build(); + + ResponseCookie usernameCookie = ResponseCookie.from("artemis_username", loginRequest.username()) + .httpOnly(true) + .secure(true) + .path("/") + .maxAge(Duration.ofDays(1)) + .sameSite("Strict") + .build(); + + // Encrypt password before storing in cookie + String encryptedPassword = cryptoService.encrypt(loginRequest.password()); + ResponseCookie passwordCookie = ResponseCookie.from("artemis_password", encryptedPassword) + .httpOnly(true) + .secure(true) + .path("/") + .maxAge(Duration.ofDays(1)) + .sameSite("Strict") + .build(); + + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.SET_COOKIE, jwtCookie.toString()); + headers.add(HttpHeaders.SET_COOKIE, serverUrlCookie.toString()); + headers.add(HttpHeaders.SET_COOKIE, usernameCookie.toString()); + headers.add(HttpHeaders.SET_COOKIE, passwordCookie.toString()); + + return ResponseEntity.ok() + .headers(headers) + .build(); + } +} diff --git a/src/main/resources/config/application.yml b/src/main/resources/config/application.yml index b2609854..c9fa3c62 100644 --- a/src/main/resources/config/application.yml +++ b/src/main/resources/config/application.yml @@ -58,7 +58,7 @@ harmonia: - "http://localhost:8080" artemis: - baseUrl: https://artemis-test2.artemis.cit.tum.de/api + baseUrl: username: password: jwtToken: diff --git a/src/main/webapp/src/app/generated/.openapi-generator/FILES b/src/main/webapp/src/app/generated/.openapi-generator/FILES index 6a1e582a..d9f533db 100644 --- a/src/main/webapp/src/app/generated/.openapi-generator/FILES +++ b/src/main/webapp/src/app/generated/.openapi-generator/FILES @@ -1,10 +1,13 @@ .gitignore .npmignore api.ts +apis/auth-resource-api.ts apis/request-resource-api.ts base.ts common.ts configuration.ts +docs/AuthResourceApi.md +docs/LoginRequestDTO.md docs/ParticipantDTO.md docs/ParticipationDTO.md docs/RequestResourceApi.md @@ -13,6 +16,7 @@ docs/TeamRepositoryDTO.md git_push.sh index.ts models/index.ts +models/login-request-dto.ts models/participant-dto.ts models/participation-dto.ts models/team-dto.ts diff --git a/src/main/webapp/src/app/generated/api.ts b/src/main/webapp/src/app/generated/api.ts index 30ac8dc7..229c9497 100644 --- a/src/main/webapp/src/app/generated/api.ts +++ b/src/main/webapp/src/app/generated/api.ts @@ -12,4 +12,5 @@ * Do not edit the class manually. */ +export * from './apis/auth-resource-api'; export * from './apis/request-resource-api'; diff --git a/src/main/webapp/src/app/generated/apis/auth-resource-api.ts b/src/main/webapp/src/app/generated/apis/auth-resource-api.ts new file mode 100644 index 00000000..33488461 --- /dev/null +++ b/src/main/webapp/src/app/generated/apis/auth-resource-api.ts @@ -0,0 +1,135 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * OpenAPI definition + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: v0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import type { Configuration } from '../configuration'; +import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios'; +import globalAxios from 'axios'; +// Some imports not used depending on template conditions +// @ts-ignore +import { + DUMMY_BASE_URL, + assertParamExists, + setApiKeyToObject, + setBasicAuthToObject, + setBearerAuthToObject, + setOAuthToObject, + setSearchParams, + serializeDataIfNeeded, + toPathString, + createRequestFunction, +} from '../common'; +// @ts-ignore +import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base'; +// @ts-ignore +import type { LoginRequestDTO } from '../models'; +/** + * AuthResourceApi - axios parameter creator + */ +export const AuthResourceApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * + * @param {LoginRequestDTO} loginRequestDTO + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + login: async (loginRequestDTO: LoginRequestDTO, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'loginRequestDTO' is not null or undefined + assertParamExists('login', 'loginRequestDTO', loginRequestDTO); + const localVarPath = `/api/auth/login`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { ...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers }; + localVarRequestOptions.data = serializeDataIfNeeded(loginRequestDTO, localVarRequestOptions, configuration); + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + }; +}; + +/** + * AuthResourceApi - functional programming interface + */ +export const AuthResourceApiFp = function (configuration?: Configuration) { + const localVarAxiosParamCreator = AuthResourceApiAxiosParamCreator(configuration); + return { + /** + * + * @param {LoginRequestDTO} loginRequestDTO + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async login( + loginRequestDTO: LoginRequestDTO, + options?: RawAxiosRequestConfig, + ): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.login(loginRequestDTO, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['AuthResourceApi.login']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => + createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + }; +}; + +/** + * AuthResourceApi - factory interface + */ +export const AuthResourceApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = AuthResourceApiFp(configuration); + return { + /** + * + * @param {LoginRequestDTO} loginRequestDTO + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + login(loginRequestDTO: LoginRequestDTO, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.login(loginRequestDTO, options).then(request => request(axios, basePath)); + }, + }; +}; + +/** + * AuthResourceApi - object-oriented interface + */ +export class AuthResourceApi extends BaseAPI { + /** + * + * @param {LoginRequestDTO} loginRequestDTO + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public login(loginRequestDTO: LoginRequestDTO, options?: RawAxiosRequestConfig) { + return AuthResourceApiFp(this.configuration) + .login(loginRequestDTO, options) + .then(request => request(this.axios, this.basePath)); + } +} diff --git a/src/main/webapp/src/app/generated/apis/request-resource-api.ts b/src/main/webapp/src/app/generated/apis/request-resource-api.ts index da4184bb..36a60cfa 100644 --- a/src/main/webapp/src/app/generated/apis/request-resource-api.ts +++ b/src/main/webapp/src/app/generated/apis/request-resource-api.ts @@ -40,10 +40,20 @@ export const RequestResourceApiAxiosParamCreator = function (configuration?: Con return { /** * + * @param {string} [jwt] + * @param {string} [artemisServerUrl] + * @param {string} [artemisUsername] + * @param {string} [artemisPassword] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - fetchAndCloneRepositories: async (options: RawAxiosRequestConfig = {}): Promise => { + fetchAndCloneRepositories: async ( + jwt?: string, + artemisServerUrl?: string, + artemisUsername?: string, + artemisPassword?: string, + options: RawAxiosRequestConfig = {}, + ): Promise => { const localVarPath = `/api/requestResource/fetchAndCloneRepositories`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -76,13 +86,27 @@ export const RequestResourceApiFp = function (configuration?: Configuration) { return { /** * + * @param {string} [jwt] + * @param {string} [artemisServerUrl] + * @param {string} [artemisUsername] + * @param {string} [artemisPassword] * @param {*} [options] Override http request option. * @throws {RequiredError} */ async fetchAndCloneRepositories( + jwt?: string, + artemisServerUrl?: string, + artemisUsername?: string, + artemisPassword?: string, options?: RawAxiosRequestConfig, ): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.fetchAndCloneRepositories(options); + const localVarAxiosArgs = await localVarAxiosParamCreator.fetchAndCloneRepositories( + jwt, + artemisServerUrl, + artemisUsername, + artemisPassword, + options, + ); const localVarOperationServerIndex = configuration?.serverIndex ?? 0; const localVarOperationServerBasePath = operationServerMap['RequestResourceApi.fetchAndCloneRepositories']?.[localVarOperationServerIndex]?.url; @@ -100,11 +124,23 @@ export const RequestResourceApiFactory = function (configuration?: Configuration return { /** * + * @param {string} [jwt] + * @param {string} [artemisServerUrl] + * @param {string} [artemisUsername] + * @param {string} [artemisPassword] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - fetchAndCloneRepositories(options?: RawAxiosRequestConfig): AxiosPromise> { - return localVarFp.fetchAndCloneRepositories(options).then(request => request(axios, basePath)); + fetchAndCloneRepositories( + jwt?: string, + artemisServerUrl?: string, + artemisUsername?: string, + artemisPassword?: string, + options?: RawAxiosRequestConfig, + ): AxiosPromise> { + return localVarFp + .fetchAndCloneRepositories(jwt, artemisServerUrl, artemisUsername, artemisPassword, options) + .then(request => request(axios, basePath)); }, }; }; @@ -115,12 +151,22 @@ export const RequestResourceApiFactory = function (configuration?: Configuration export class RequestResourceApi extends BaseAPI { /** * + * @param {string} [jwt] + * @param {string} [artemisServerUrl] + * @param {string} [artemisUsername] + * @param {string} [artemisPassword] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - public fetchAndCloneRepositories(options?: RawAxiosRequestConfig) { + public fetchAndCloneRepositories( + jwt?: string, + artemisServerUrl?: string, + artemisUsername?: string, + artemisPassword?: string, + options?: RawAxiosRequestConfig, + ) { return RequestResourceApiFp(this.configuration) - .fetchAndCloneRepositories(options) + .fetchAndCloneRepositories(jwt, artemisServerUrl, artemisUsername, artemisPassword, options) .then(request => request(this.axios, this.basePath)); } } diff --git a/src/main/webapp/src/app/generated/docs/AuthResourceApi.md b/src/main/webapp/src/app/generated/docs/AuthResourceApi.md new file mode 100644 index 00000000..7d0f9f78 --- /dev/null +++ b/src/main/webapp/src/app/generated/docs/AuthResourceApi.md @@ -0,0 +1,51 @@ +# AuthResourceApi + +All URIs are relative to _http://localhost:8080_ + +| Method | HTTP request | Description | +| ------------------- | ------------------------ | ----------- | +| [**login**](#login) | **POST** /api/auth/login | | + +# **login** + +> login(loginRequestDTO) + +### Example + +```typescript +import { AuthResourceApi, Configuration, LoginRequestDTO } from './api'; + +const configuration = new Configuration(); +const apiInstance = new AuthResourceApi(configuration); + +let loginRequestDTO: LoginRequestDTO; // + +const { status, data } = await apiInstance.login(loginRequestDTO); +``` + +### Parameters + +| Name | Type | Description | Notes | +| ------------------- | ------------------- | ----------- | ----- | +| **loginRequestDTO** | **LoginRequestDTO** | | | + +### Return type + +void (empty response body) + +### Authorization + +No authorization required + +### HTTP request headers + +- **Content-Type**: application/json +- **Accept**: Not defined + +### HTTP response details + +| Status code | Description | Response headers | +| ----------- | ----------- | ---------------- | +| **200** | OK | - | + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) diff --git a/src/main/webapp/src/app/generated/docs/LoginRequestDTO.md b/src/main/webapp/src/app/generated/docs/LoginRequestDTO.md new file mode 100644 index 00000000..508d9fa5 --- /dev/null +++ b/src/main/webapp/src/app/generated/docs/LoginRequestDTO.md @@ -0,0 +1,23 @@ +# LoginRequestDTO + +## Properties + +| Name | Type | Description | Notes | +| ------------- | ---------- | ----------- | ---------------------- | +| **password** | **string** | | [default to undefined] | +| **serverUrl** | **string** | | [default to undefined] | +| **username** | **string** | | [default to undefined] | + +## Example + +```typescript +import { LoginRequestDTO } from './api'; + +const instance: LoginRequestDTO = { + password, + serverUrl, + username, +}; +``` + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/src/main/webapp/src/app/generated/docs/RequestResourceApi.md b/src/main/webapp/src/app/generated/docs/RequestResourceApi.md index 960e83df..af24f838 100644 --- a/src/main/webapp/src/app/generated/docs/RequestResourceApi.md +++ b/src/main/webapp/src/app/generated/docs/RequestResourceApi.md @@ -18,12 +18,22 @@ import { RequestResourceApi, Configuration } from './api'; const configuration = new Configuration(); const apiInstance = new RequestResourceApi(configuration); -const { status, data } = await apiInstance.fetchAndCloneRepositories(); +let jwt: string; // (optional) (default to undefined) +let artemisServerUrl: string; // (optional) (default to undefined) +let artemisUsername: string; // (optional) (default to undefined) +let artemisPassword: string; // (optional) (default to undefined) + +const { status, data } = await apiInstance.fetchAndCloneRepositories(jwt, artemisServerUrl, artemisUsername, artemisPassword); ``` ### Parameters -This endpoint does not have any parameters. +| Name | Type | Description | Notes | +| -------------------- | ------------ | ----------- | -------------------------------- | +| **jwt** | [**string**] | | (optional) defaults to undefined | +| **artemisServerUrl** | [**string**] | | (optional) defaults to undefined | +| **artemisUsername** | [**string**] | | (optional) defaults to undefined | +| **artemisPassword** | [**string**] | | (optional) defaults to undefined | ### Return type diff --git a/src/main/webapp/src/app/generated/models/index.ts b/src/main/webapp/src/app/generated/models/index.ts index f5c52a7b..c5c6d44a 100644 --- a/src/main/webapp/src/app/generated/models/index.ts +++ b/src/main/webapp/src/app/generated/models/index.ts @@ -1,3 +1,4 @@ +export * from './login-request-dto'; export * from './participant-dto'; export * from './participation-dto'; export * from './team-dto'; diff --git a/src/main/webapp/src/app/generated/models/login-request-dto.ts b/src/main/webapp/src/app/generated/models/login-request-dto.ts new file mode 100644 index 00000000..cc4a95d2 --- /dev/null +++ b/src/main/webapp/src/app/generated/models/login-request-dto.ts @@ -0,0 +1,19 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * OpenAPI definition + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: v0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +export interface LoginRequestDTO { + password: string; + serverUrl: string; + username: string; +} diff --git a/src/main/webapp/src/components/StartAnalysis.tsx b/src/main/webapp/src/components/StartAnalysis.tsx index 5dd2b3ce..6d23af3c 100644 --- a/src/main/webapp/src/components/StartAnalysis.tsx +++ b/src/main/webapp/src/components/StartAnalysis.tsx @@ -1,36 +1,92 @@ import { useState } from 'react'; import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { PlayCircle } from 'lucide-react'; +import { PlayCircle, Loader2 } from 'lucide-react'; +import { toast } from '@/hooks/use-toast'; interface StartAnalysisProps { - onStart: (course: string, exercise: string) => void; + onStart: (course: string, exercise: string, username: string, password: string) => void; } const StartAnalysis = ({ onStart }: StartAnalysisProps) => { const [course, setCourse] = useState('ITP'); const [exercise, setExercise] = useState('Final Project'); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [serverUrl, setServerUrl] = useState('https://artemis.tum.de'); + const [isLoading, setIsLoading] = useState(false); const handleCourseChange = (value: string) => { setCourse(value); setExercise(''); // Reset exercise when course changes }; - const handleStart = () => { - if (course && exercise) { - onStart(course, exercise); + const handleStart = async () => { + if (course && exercise && username && password && serverUrl) { + setIsLoading(true); + try { + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ username, password, serverUrl }), + }); + + if (response.ok) { + onStart(course, exercise, username, password); + } else { + toast({ + variant: 'destructive', + title: 'Login failed', + description: 'Please check your credentials and server URL.', + }); + } + } catch { + toast({ + variant: 'destructive', + title: 'Error', + description: 'An error occurred during login.', + }); + } finally { + setIsLoading(false); + } } }; return ( -
+

Welcome to Harmonia!

Analyze student team projects to assess collaboration quality

+
+ + setServerUrl(e.target.value)} /> +
+ +
+ + setUsername(e.target.value)} /> +
+ +
+ + setPassword(e.target.value)} + /> +
+ +
+