diff --git a/src/main/java/de/tum/in/www1/artemis/repository/UserRepository.java b/src/main/java/de/tum/in/www1/artemis/repository/UserRepository.java index e659f82db28d..04dd3b8732f2 100644 --- a/src/main/java/de/tum/in/www1/artemis/repository/UserRepository.java +++ b/src/main/java/de/tum/in/www1/artemis/repository/UserRepository.java @@ -765,6 +765,17 @@ default Page findAllWithGroupsByIsDeletedIsFalse(Pageable pageable) { """) void updateUserSshPublicKeyHash(@Param("userId") long userId, @Param("sshPublicKeyHash") String sshPublicKeyHash, @Param("sshPublicKey") String sshPublicKey); + @Modifying + @Transactional // ok because of modifying query + @Query(""" + UPDATE User user + SET user.vcsAccessToken = :vcsAccessToken, + user.vcsAccessTokenExpiryDate = :vcsAccessTokenExpiryDate + WHERE user.id = :userId + """) + void updateUserVcsAccessToken(@Param("userId") long userId, @Param("vcsAccessToken") String vcsAccessToken, + @Param("vcsAccessTokenExpiryDate") ZonedDateTime vcsAccessTokenExpiryDate); + @Modifying @Transactional // ok because of modifying query @Query(""" diff --git a/src/main/java/de/tum/in/www1/artemis/service/dto/UserDTO.java b/src/main/java/de/tum/in/www1/artemis/service/dto/UserDTO.java index 97d02ca20df7..22c2cccd6546 100644 --- a/src/main/java/de/tum/in/www1/artemis/service/dto/UserDTO.java +++ b/src/main/java/de/tum/in/www1/artemis/service/dto/UserDTO.java @@ -74,6 +74,8 @@ public class UserDTO extends AuditingEntityDTO { private String vcsAccessToken; + private ZonedDateTime vcsAccessTokenExpiryDate; + private String sshPublicKey; private ZonedDateTime irisAccepted; @@ -250,6 +252,14 @@ public void setVcsAccessToken(String vcsAccessToken) { this.vcsAccessToken = vcsAccessToken; } + public void setVcsAccessTokenExpiryDate(ZonedDateTime zoneDateTime) { + this.vcsAccessTokenExpiryDate = zoneDateTime; + } + + public ZonedDateTime getVcsAccessTokenExpiryDate() { + return vcsAccessTokenExpiryDate; + } + public String getSshPublicKey() { return sshPublicKey; } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/AccountResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/AccountResource.java index 29ba05bcd377..5734c258da9a 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/AccountResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/AccountResource.java @@ -2,27 +2,42 @@ import static de.tum.in.www1.artemis.config.Constants.PROFILE_CORE; +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.PublicKey; +import java.time.ZonedDateTime; import java.util.Optional; import jakarta.validation.Valid; +import jakarta.ws.rs.BadRequestException; +import org.apache.sshd.common.config.keys.AuthorizedKeyEntry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import de.tum.in.www1.artemis.config.icl.ssh.HashUtils; import de.tum.in.www1.artemis.domain.User; import de.tum.in.www1.artemis.repository.UserRepository; import de.tum.in.www1.artemis.security.annotations.EnforceAtLeastStudent; import de.tum.in.www1.artemis.service.AccountService; +import de.tum.in.www1.artemis.service.connectors.localvc.LocalVCPersonalAccessTokenManagementService; import de.tum.in.www1.artemis.service.dto.PasswordChangeDTO; import de.tum.in.www1.artemis.service.dto.UserDTO; import de.tum.in.www1.artemis.service.user.UserCreationService; import de.tum.in.www1.artemis.service.user.UserService; import de.tum.in.www1.artemis.web.rest.errors.AccessForbiddenException; +import de.tum.in.www1.artemis.web.rest.errors.BadRequestAlertException; import de.tum.in.www1.artemis.web.rest.errors.EmailAlreadyUsedException; import de.tum.in.www1.artemis.web.rest.errors.PasswordViolatesRequirementsException; @@ -34,6 +49,11 @@ @RequestMapping("api/") public class AccountResource { + @Value("${jhipster.clientApp.name}") + private String applicationName; + + private static final Logger log = LoggerFactory.getLogger(AccountResource.class); + private final UserRepository userRepository; private final UserService userService; @@ -96,4 +116,120 @@ public ResponseEntity changePassword(@RequestBody PasswordChangeDTO passwo return ResponseEntity.ok().build(); } + + /** + * PUT account/ssh-public-key : sets the ssh public key + * + * @param sshPublicKey the ssh public key to set + * + * @return the ResponseEntity with status 200 (OK), with status 404 (Not Found), or with status 400 (Bad Request) + */ + @PutMapping("account/ssh-public-key") + @EnforceAtLeastStudent + public ResponseEntity addSshPublicKey(@RequestBody String sshPublicKey) throws GeneralSecurityException, IOException { + + User user = userRepository.getUser(); + log.debug("REST request to add SSH key to user {}", user.getLogin()); + // Parse the public key string + AuthorizedKeyEntry keyEntry; + try { + keyEntry = AuthorizedKeyEntry.parseAuthorizedKeyEntry(sshPublicKey); + } + catch (IllegalArgumentException e) { + throw new BadRequestAlertException("Invalid SSH key format", "SSH key", "invalidKeyFormat", true); + } + // Extract the PublicKey object + PublicKey publicKey = keyEntry.resolvePublicKey(null, null, null); + String keyHash = HashUtils.getSha512Fingerprint(publicKey); + userRepository.updateUserSshPublicKeyHash(user.getId(), keyHash, sshPublicKey); + return ResponseEntity.ok().build(); + } + + /** + * PUT account/ssh-public-key : sets the ssh public key + * + * @return the ResponseEntity with status 200 (OK), with status 404 (Not Found), or with status 400 (Bad Request) + */ + @DeleteMapping("account/ssh-public-key") + @EnforceAtLeastStudent + public ResponseEntity deleteSshPublicKey() { + User user = userRepository.getUser(); + log.debug("REST request to remove SSH key of user {}", user.getLogin()); + userRepository.updateUserSshPublicKeyHash(user.getId(), null, null); + + log.debug("Successfully deleted SSH key of user {}", user.getLogin()); + return ResponseEntity.ok().build(); + } + + /** + * PUT account/user-vcs-access-token : creates a vcsAccessToken for a user + * + * @param expiryDate The expiry date which should be set for the token + * @return the ResponseEntity with a userDTO containing the token: with status 200 (OK), with status 404 (Not Found), or with status 400 (Bad Request) + */ + @PutMapping("account/user-vcs-access-token") + @EnforceAtLeastStudent + public ResponseEntity createVcsAccessToken(@RequestParam("expiryDate") ZonedDateTime expiryDate) { + User user = userRepository.getUser(); + log.debug("REST request to create a new VCS access token for user {}", user.getLogin()); + if (expiryDate.isBefore(ZonedDateTime.now()) || expiryDate.isAfter(ZonedDateTime.now().plusYears(1))) { + throw new BadRequestException("Invalid expiry date provided"); + } + + userRepository.updateUserVcsAccessToken(user.getId(), LocalVCPersonalAccessTokenManagementService.generateSecureVCSAccessToken(), expiryDate); + log.debug("Successfully created a VCS access token for user {}", user.getLogin()); + user = userRepository.getUser(); + UserDTO userDTO = new UserDTO(); + userDTO.setLogin(user.getLogin()); + userDTO.setVcsAccessToken(user.getVcsAccessToken()); + userDTO.setVcsAccessTokenExpiryDate(user.getVcsAccessTokenExpiryDate()); + return ResponseEntity.ok(userDTO); + } + + /** + * DELETE account/user-vcs-access-token : deletes the vcsAccessToken of a user + * + * @return the ResponseEntity with status 200 (OK), with status 404 (Not Found), or with status 400 (Bad Request) + */ + @DeleteMapping("account/user-vcs-access-token") + @EnforceAtLeastStudent + public ResponseEntity deleteVcsAccessToken() { + User user = userRepository.getUser(); + log.debug("REST request to remove VCS access token key of user {}", user.getLogin()); + userRepository.updateUserVcsAccessToken(user.getId(), null, null); + log.debug("Successfully deleted VCS access token of user {}", user.getLogin()); + return ResponseEntity.ok().build(); + } + + /** + * GET account/participation-vcs-access-token : get the vcsToken for of a user for a participation + * + * @param participationId the participation for which the access token should be fetched + * + * @return the versionControlAccessToken belonging to the provided participation and user + */ + @GetMapping("account/participation-vcs-access-token") + @EnforceAtLeastStudent + public ResponseEntity getVcsAccessToken(@RequestParam("participationId") Long participationId) { + User user = userRepository.getUser(); + + log.debug("REST request to get VCS access token of user {} for participation {}", user.getLogin(), participationId); + return ResponseEntity.ok(userService.getParticipationVcsAccessTokenForUserAndParticipationIdOrElseThrow(user, participationId).getVcsAccessToken()); + } + + /** + * PUT account/participation-vcs-access-token : get the vcsToken for of a user for a participation + * + * @param participationId the participation for which the access token should be fetched + * + * @return the versionControlAccessToken belonging to the provided participation and user + */ + @PutMapping("account/participation-vcs-access-token") + @EnforceAtLeastStudent + public ResponseEntity createVcsAccessToken(@RequestParam("participationId") Long participationId) { + User user = userRepository.getUser(); + + log.debug("REST request to create a new VCS access token for user {} for participation {}", user.getLogin(), participationId); + return ResponseEntity.ok(userService.createParticipationVcsAccessTokenForUserAndParticipationIdOrElseThrow(user, participationId).getVcsAccessToken()); + } } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/UserResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/UserResource.java index 747208042837..8415cbc6f104 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/UserResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/UserResource.java @@ -2,24 +2,18 @@ import static de.tum.in.www1.artemis.config.Constants.PROFILE_CORE; -import java.io.IOException; -import java.security.GeneralSecurityException; -import java.security.PublicKey; import java.time.ZonedDateTime; import java.util.List; import java.util.Optional; -import org.apache.sshd.common.config.keys.AuthorizedKeyEntry; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -29,7 +23,6 @@ import org.springframework.web.server.ResponseStatusException; import org.springframework.web.servlet.support.ServletUriComponentsBuilder; -import de.tum.in.www1.artemis.config.icl.ssh.HashUtils; import de.tum.in.www1.artemis.domain.User; import de.tum.in.www1.artemis.repository.UserRepository; import de.tum.in.www1.artemis.security.annotations.EnforceAtLeastInstructor; @@ -39,7 +32,6 @@ import de.tum.in.www1.artemis.service.dto.UserInitializationDTO; import de.tum.in.www1.artemis.service.user.UserCreationService; import de.tum.in.www1.artemis.service.user.UserService; -import de.tum.in.www1.artemis.web.rest.util.HeaderUtil; import tech.jhipster.web.util.PaginationUtil; /** @@ -67,9 +59,6 @@ @RequestMapping("api/") public class UserResource { - @Value("${jhipster.clientApp.name}") - private String applicationName; - private static final Logger log = LoggerFactory.getLogger(UserResource.class); private final UserService userService; @@ -180,81 +169,4 @@ public ResponseEntity setIrisAcceptedToTimestamp() { userRepository.updateIrisAcceptedToDate(user.getId(), ZonedDateTime.now()); return ResponseEntity.ok().build(); } - - /** - * PUT users/sshpublickey : sets the ssh public key - * - * @param sshPublicKey the ssh public key to set - * - * @return the ResponseEntity with status 200 (OK), with status 404 (Not Found), or with status 400 (Bad Request) - */ - @PutMapping("users/sshpublickey") - @EnforceAtLeastStudent - public ResponseEntity addSshPublicKey(@RequestBody String sshPublicKey) throws GeneralSecurityException, IOException { - - User user = userRepository.getUser(); - log.debug("REST request to add SSH key to user {}", user.getLogin()); - // Parse the public key string - AuthorizedKeyEntry keyEntry; - try { - keyEntry = AuthorizedKeyEntry.parseAuthorizedKeyEntry(sshPublicKey); - } - catch (IllegalArgumentException e) { - return ResponseEntity.badRequest().headers(HeaderUtil.createFailureAlert(applicationName, true, "sshUserSettings", "saveSshKeyError", "Invalid SSH key format")) - .body(null); - } - // Extract the PublicKey object - PublicKey publicKey = keyEntry.resolvePublicKey(null, null, null); - String keyHash = HashUtils.getSha512Fingerprint(publicKey); - userRepository.updateUserSshPublicKeyHash(user.getId(), keyHash, sshPublicKey); - return ResponseEntity.ok().build(); - } - - /** - * PUT users/sshpublickey : sets the ssh public key - * - * @return the ResponseEntity with status 200 (OK), with status 404 (Not Found), or with status 400 (Bad Request) - */ - @DeleteMapping("users/sshpublickey") - @EnforceAtLeastStudent - public ResponseEntity deleteSshPublicKey() { - User user = userRepository.getUser(); - log.debug("REST request to remove SSH key of user {}", user.getLogin()); - userRepository.updateUserSshPublicKeyHash(user.getId(), null, null); - - log.debug("Successfully deleted SSH key of user {}", user.getLogin()); - return ResponseEntity.ok().build(); - } - - /** - * GET users/vcsToken : get the vcsToken for of a user for a participation - * - * @param participationId the participation for which the access token should be fetched - * - * @return the versionControlAccessToken belonging to the provided participation and user - */ - @GetMapping("users/vcsToken") - @EnforceAtLeastStudent - public ResponseEntity getVcsAccessToken(@RequestParam("participationId") Long participationId) { - User user = userRepository.getUser(); - - log.debug("REST request to get VCS access token of user {} for participation {}", user.getLogin(), participationId); - return ResponseEntity.ok(userService.getParticipationVcsAccessTokenForUserAndParticipationIdOrElseThrow(user, participationId).getVcsAccessToken()); - } - - /** - * PUT users/vcsToken : get the vcsToken for of a user for a participation - * - * @param participationId the participation for which the access token should be fetched - * - * @return the versionControlAccessToken belonging to the provided participation and user - */ - @PutMapping("users/vcsToken") - @EnforceAtLeastStudent - public ResponseEntity createVcsAccessToken(@RequestParam("participationId") Long participationId) { - User user = userRepository.getUser(); - - log.debug("REST request to create a new VCS access token for user {} for participation {}", user.getLogin(), participationId); - return ResponseEntity.ok(userService.createParticipationVcsAccessTokenForUserAndParticipationIdOrElseThrow(user, participationId).getVcsAccessToken()); - } } diff --git a/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicAccountResource.java b/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicAccountResource.java index 9480b6e77d79..7950c09cb773 100644 --- a/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicAccountResource.java +++ b/src/main/java/de/tum/in/www1/artemis/web/rest/open/PublicAccountResource.java @@ -165,6 +165,7 @@ public ResponseEntity getAccount() { UserDTO userDTO = new UserDTO(user); // we set this value on purpose here: the user can only fetch their own information, make the token available for constructing the token-based clone-URL userDTO.setVcsAccessToken(user.getVcsAccessToken()); + userDTO.setVcsAccessTokenExpiryDate(user.getVcsAccessTokenExpiryDate()); userDTO.setSshPublicKey(user.getSshPublicKey()); log.info("GET /account {} took {}ms", user.getLogin(), System.currentTimeMillis() - start); return ResponseEntity.ok(userDTO); diff --git a/src/main/webapp/app/core/auth/account.service.ts b/src/main/webapp/app/core/auth/account.service.ts index 81e0c074557b..38c854af7006 100644 --- a/src/main/webapp/app/core/auth/account.service.ts +++ b/src/main/webapp/app/core/auth/account.service.ts @@ -13,6 +13,7 @@ import { Exercise, getCourseFromExercise } from 'app/entities/exercise.model'; import { Authority } from 'app/shared/constants/authority.constants'; import { TranslateService } from '@ngx-translate/core'; import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; +import { EntityResponseType } from 'app/complaints/complaint.service'; export interface IAccountService { save: (account: any) => Observable>; @@ -336,7 +337,7 @@ export class AccountService implements IAccountService { if (this.userIdentity) { this.userIdentity.sshPublicKey = sshPublicKey; } - return this.http.put('api/users/sshpublickey', sshPublicKey); + return this.http.put('api/account/ssh-public-key', sshPublicKey); } /** @@ -346,7 +347,24 @@ export class AccountService implements IAccountService { if (this.userIdentity) { this.userIdentity.sshPublicKey = undefined; } - return this.http.delete('api/users/sshpublickey'); + return this.http.delete('api/account/ssh-public-key'); + } + + /** + * Sends a request to the server to delete the user's current vcsAccessToken + */ + deleteUserVcsAccessToken(): Observable { + return this.http.delete('api/account/user-vcs-access-token'); + } + + /** + * Sends a request to the server to create a new vcsAccessToken for the user + * + * @param expiryDate The expiry date which should get set for the vcsAccessToken + */ + addNewVcsAccessToken(expiryDate: string): Observable { + const params = new HttpParams().set('expiryDate', expiryDate); + return this.http.put('api/account/user-vcs-access-token', null, { observe: 'response', params }); } /** @@ -357,7 +375,7 @@ export class AccountService implements IAccountService { */ getVcsAccessToken(participationId: number): Observable> { const params = new HttpParams().set('participationId', participationId); - return this.http.get('api/users/vcsToken', { observe: 'response', params, responseType: 'text' as 'json' }); + return this.http.get('api/account/participation-vcs-access-token', { observe: 'response', params, responseType: 'text' as 'json' }); } /** @@ -368,6 +386,6 @@ export class AccountService implements IAccountService { */ createVcsAccessToken(participationId: number): Observable> { const params = new HttpParams().set('participationId', participationId); - return this.http.put('api/users/vcsToken', null, { observe: 'response', params, responseType: 'text' as 'json' }); + return this.http.put('api/account/participation-vcs-access-token', null, { observe: 'response', params, responseType: 'text' as 'json' }); } } diff --git a/src/main/webapp/app/core/user/user.model.ts b/src/main/webapp/app/core/user/user.model.ts index 0c30062efbd0..816cf4fc9a9c 100644 --- a/src/main/webapp/app/core/user/user.model.ts +++ b/src/main/webapp/app/core/user/user.model.ts @@ -14,6 +14,7 @@ export class User extends Account { public visibleRegistrationNumber?: string; public password?: string; public vcsAccessToken?: string; + public vcsAccessTokenExpiryDate?: string; public sshPublicKey?: string; public irisAccepted?: dayjs.Dayjs; @@ -35,6 +36,7 @@ export class User extends Account { password?: string, imageUrl?: string, vcsAccessToken?: string, + vcsAccessTokenExpiryDate?: string, sshPublicKey?: string, irisAccepted?: dayjs.Dayjs, ) { @@ -48,6 +50,7 @@ export class User extends Account { this.lastNotificationRead = lastNotificationRead; this.password = password; this.vcsAccessToken = vcsAccessToken; + this.vcsAccessTokenExpiryDate = vcsAccessTokenExpiryDate; this.sshPublicKey = sshPublicKey; this.irisAccepted = irisAccepted; } diff --git a/src/main/webapp/app/overview/exercise-details/exercise-details-student-actions.component.html b/src/main/webapp/app/overview/exercise-details/exercise-details-student-actions.component.html index ea54cf40b8c2..b2d119d0cd2a 100644 --- a/src/main/webapp/app/overview/exercise-details/exercise-details-student-actions.component.html +++ b/src/main/webapp/app/overview/exercise-details/exercise-details-student-actions.component.html @@ -126,6 +126,7 @@ [participations]="exercise.studentParticipations!" [exercise]="exercise" [routerLinkForRepositoryView]="repositoryLink + '/repository/' + exercise.studentParticipations![0].id" + [useParticipationVcsAccessToken]="true" /> } @if (theiaEnabled) { diff --git a/src/main/webapp/app/shared/components/code-button/code-button.component.html b/src/main/webapp/app/shared/components/code-button/code-button.component.html index 89d1b5aa952f..c9d791edac00 100644 --- a/src/main/webapp/app/shared/components/code-button/code-button.component.html +++ b/src/main/webapp/app/shared/components/code-button/code-button.component.html @@ -14,8 +14,14 @@ container="body" > - @if (useSsh && (!user.sshPublicKey || gitlabVCEnabled)) { -
+ @if (useSsh && !copyEnabled) { +
+ } + @if (useToken && !copyEnabled && tokenMissing) { +
+ } + @if (useToken && !copyEnabled && tokenExpired) { +
} @if (participations && participations.length > 1) {
@@ -34,7 +40,7 @@
{{ cloneHeadline | artemisTranslate }}
@if (showCloneUrlWithoutToken) { HTTPS } - @if (useVersionControlAccessToken && participations) { + @if (accessTokensEnabled && (!useParticipationVcsAccessToken || participations)) { Token } SSH @@ -50,7 +56,7 @@
{{ cloneHeadline | artemisTranslate }}
'url-box-remove-line-right': !localVCEnabled || !!routerLinkForRepositoryView, }" [cdkCopyToClipboard]="getHttpOrSshRepositoryUri(false)" - (cdkCopyToClipboardCopied)="onCopyFinished($event)" + (cdkCopyToClipboardCopied)="copyEnabled ? onCopyFinished($event) : null" >{{ getHttpOrSshRepositoryUri() }}
@@ -86,6 +92,7 @@
{{ cloneHeadline | artemisTranslate }}
diff --git a/src/main/webapp/app/shared/user-settings/user-settings.module.ts b/src/main/webapp/app/shared/user-settings/user-settings.module.ts index 98f82ef20bef..c006c7f5e904 100644 --- a/src/main/webapp/app/shared/user-settings/user-settings.module.ts +++ b/src/main/webapp/app/shared/user-settings/user-settings.module.ts @@ -8,9 +8,19 @@ import { userSettingsState } from 'app/shared/user-settings/user-settings.route' import { ScienceSettingsComponent } from 'app/shared/user-settings/science-settings/science-settings.component'; import { SshUserSettingsComponent } from 'app/shared/user-settings/ssh-settings/ssh-user-settings.component'; import { ArtemisSharedComponentModule } from 'app/shared/components/shared-component.module'; +import { VcsAccessTokensSettingsComponent } from 'app/shared/user-settings/vcs-access-tokens-settings/vcs-access-tokens-settings.component'; +import { ClipboardModule } from '@angular/cdk/clipboard'; +import { FormDateTimePickerModule } from 'app/shared/date-time-picker/date-time-picker.module'; @NgModule({ - imports: [RouterModule.forChild(userSettingsState), ArtemisSharedModule, ArtemisSharedComponentModule], - declarations: [UserSettingsContainerComponent, AccountInformationComponent, NotificationSettingsComponent, ScienceSettingsComponent, SshUserSettingsComponent], + imports: [RouterModule.forChild(userSettingsState), ArtemisSharedModule, ArtemisSharedComponentModule, ClipboardModule, FormDateTimePickerModule], + declarations: [ + UserSettingsContainerComponent, + AccountInformationComponent, + NotificationSettingsComponent, + ScienceSettingsComponent, + SshUserSettingsComponent, + VcsAccessTokensSettingsComponent, + ], }) export class UserSettingsModule {} diff --git a/src/main/webapp/app/shared/user-settings/user-settings.route.ts b/src/main/webapp/app/shared/user-settings/user-settings.route.ts index 1e60f8d264c0..f27183c487cf 100644 --- a/src/main/webapp/app/shared/user-settings/user-settings.route.ts +++ b/src/main/webapp/app/shared/user-settings/user-settings.route.ts @@ -6,6 +6,7 @@ import { UserRouteAccessService } from 'app/core/auth/user-route-access-service' import { Authority } from 'app/shared/constants/authority.constants'; import { ScienceSettingsComponent } from 'app/shared/user-settings/science-settings/science-settings.component'; import { SshUserSettingsComponent } from 'app/shared/user-settings/ssh-settings/ssh-user-settings.component'; +import { VcsAccessTokensSettingsComponent } from 'app/shared/user-settings/vcs-access-tokens-settings/vcs-access-tokens-settings.component'; export const userSettingsState: Routes = [ { @@ -44,12 +45,19 @@ export const userSettingsState: Routes = [ }, }, { - path: 'sshSettings', + path: 'ssh', component: SshUserSettingsComponent, data: { pageTitle: 'artemisApp.userSettings.categories.SSH_SETTINGS', }, }, + { + path: 'vcs-token', + component: VcsAccessTokensSettingsComponent, + data: { + pageTitle: 'artemisApp.userSettings.categories.VCS_TOKEN_SETTINGS', + }, + }, ], }, ]; diff --git a/src/main/webapp/app/shared/user-settings/vcs-access-tokens-settings/vcs-access-tokens-settings.component.html b/src/main/webapp/app/shared/user-settings/vcs-access-tokens-settings/vcs-access-tokens-settings.component.html new file mode 100644 index 000000000000..5dc7ee5c55a7 --- /dev/null +++ b/src/main/webapp/app/shared/user-settings/vcs-access-tokens-settings/vcs-access-tokens-settings.component.html @@ -0,0 +1,130 @@ +

+ +

+@if (currentUser) { +
+
+
+ +
+
+ @if (!edit) { +
+
+ @if (this.currentUser?.vcsAccessToken) { + + + + + + + + + + + + + + + + + + +
+ + + + + +
*************** + + {{ this.currentUser?.vcsAccessTokenExpiryDate | artemisDate }} + +
+ } @else { +
+ } +
+
+ @if (!this.currentUser?.vcsAccessToken) { +
+
+
+ +
+
+
+ } + } + @if (edit) { +
+
+

+ +
+
+ +
+
+ +
+ + +
+
+
+ } +
+} diff --git a/src/main/webapp/app/shared/user-settings/vcs-access-tokens-settings/vcs-access-tokens-settings.component.ts b/src/main/webapp/app/shared/user-settings/vcs-access-tokens-settings/vcs-access-tokens-settings.component.ts new file mode 100644 index 000000000000..9214784ea8f6 --- /dev/null +++ b/src/main/webapp/app/shared/user-settings/vcs-access-tokens-settings/vcs-access-tokens-settings.component.ts @@ -0,0 +1,125 @@ +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { User } from 'app/core/user/user.model'; +import { AccountService } from 'app/core/auth/account.service'; +import { Subject, Subscription, tap } from 'rxjs'; +import dayjs from 'dayjs/esm'; +import { faBan, faCopy, faEdit, faSave, faTrash } from '@fortawesome/free-solid-svg-icons'; +import { ButtonSize, ButtonType } from 'app/shared/components/button.component'; +import { AlertService } from 'app/core/util/alert.service'; + +@Component({ + selector: 'jhi-account-information', + templateUrl: './vcs-access-tokens-settings.component.html', + styleUrls: ['../user-settings.scss'], +}) +export class VcsAccessTokensSettingsComponent implements OnInit, OnDestroy { + currentUser?: User; + + readonly faEdit = faEdit; + readonly faSave = faSave; + readonly faTrash = faTrash; + readonly faCopy = faCopy; + readonly faBan = faBan; + private authStateSubscription: Subscription; + expiryDate?: dayjs.Dayjs; + validExpiryDate = false; + wasCopied = false; + edit = false; + + private dialogErrorSource = new Subject(); + + dialogError$ = this.dialogErrorSource.asObservable(); + + protected readonly ButtonType = ButtonType; + protected readonly ButtonSize = ButtonSize; + + constructor( + private accountService: AccountService, + private alertService: AlertService, + ) {} + + ngOnInit() { + this.authStateSubscription = this.accountService + .getAuthenticationState() + .pipe( + tap((user: User) => { + this.currentUser = user; + return this.currentUser; + }), + ) + .subscribe(); + } + + ngOnDestroy(): void { + this.authStateSubscription.unsubscribe(); + } + + deleteVcsAccessToken() { + this.accountService.deleteUserVcsAccessToken().subscribe({ + next: () => { + if (this.currentUser) { + this.currentUser.vcsAccessTokenExpiryDate = undefined; + this.currentUser.vcsAccessToken = undefined; + } + this.alertService.success('artemisApp.userSettings.vcsAccessTokensSettingsPage.deleteSuccess'); + }, + error: () => { + this.alertService.error('artemisApp.userSettings.vcsAccessTokensSettingsPage.deleteFailure'); + }, + }); + this.dialogErrorSource.next(''); + } + + addNewVcsAccessToken() { + this.edit = true; + } + + sendTokenCreationRequest() { + if (!this.expiryDate || this.expiryDate.isBefore(dayjs()) || this.expiryDate.isAfter(dayjs().add(1, 'year'))) { + this.alertService.error('artemisApp.userSettings.vcsAccessTokensSettingsPage.addFailure'); + return; + } + this.accountService.addNewVcsAccessToken(this.expiryDate.toISOString()).subscribe({ + next: (res) => { + if (this.currentUser) { + const user = res.body as User; + this.currentUser.vcsAccessToken = user.vcsAccessToken; + this.currentUser.vcsAccessTokenExpiryDate = user.vcsAccessTokenExpiryDate; + this.edit = false; + } + this.alertService.success('artemisApp.userSettings.vcsAccessTokensSettingsPage.addSuccess'); + }, + error: () => { + this.alertService.error('artemisApp.userSettings.vcsAccessTokensSettingsPage.addFailure'); + }, + }); + } + + /** + * set wasCopied for 3 seconds on success + */ + onCopyFinished(successful: boolean) { + if (successful) { + this.wasCopied = true; + setTimeout(() => { + this.wasCopied = false; + }, 3000); + } + } + + /** + * Validates if the expiry date is after current time + */ + validateDate() { + this.validExpiryDate = !!this.expiryDate?.isAfter(dayjs()) && !!this.expiryDate?.isBefore(dayjs().add(1, 'year')); + } + + /** + * Cancel creation of a new token + */ + cancelTokenCreation() { + this.edit = false; + this.expiryDate = undefined; + this.validExpiryDate = false; + } +} diff --git a/src/main/webapp/i18n/de/exercise-actions.json b/src/main/webapp/i18n/de/exercise-actions.json index f45e3097bf3e..928496b308b8 100644 --- a/src/main/webapp/i18n/de/exercise-actions.json +++ b/src/main/webapp/i18n/de/exercise-actions.json @@ -69,6 +69,8 @@ "uploadFile": "Datei hochladen", "viewTeam": "Team", "sshKeyTip": "Um SSH zu nutzen, musst du {link:hier} einen SSH Schlüssel zu deinem Konto hinzufügen.", + "vcsTokenTip": "Um mit einem VCS-Zugriffstoken auf das Repository zuzugreifen, musst du {link:hier} ein neues hinzufügen.", + "vcsTokenExpiredTip": "Dein VCS-Zugriffstoken ist abgelaufen. Erneuere es {link:hier}.", "startExerciseBeforeStartDate": "Du kannst vor dem Startdatum nicht an der Aufgabe teilnehmen.", "deleteMultipleExercisesQuestion": "Sollen die ausgewählten Aufgaben wirklich dauerhaft gelöscht werden?" } diff --git a/src/main/webapp/i18n/de/userSettings.json b/src/main/webapp/i18n/de/userSettings.json index 4898166be418..d7e1c3de5180 100644 --- a/src/main/webapp/i18n/de/userSettings.json +++ b/src/main/webapp/i18n/de/userSettings.json @@ -5,7 +5,8 @@ "saveChanges": "Änderungen speichern", "saveSettingsSuccessAlert": "Die Einstellungen wurden erfolgreich gespeichert.", "userSettings": "Benutzereinstellungen", - "sshSettings": "SSH Einstellungen", + "sshSettings": "SSH", + "vcsAccessTokenSettings": "VCS Token", "accountInformation": "Account Informationen", "notificationSettings": "Benachrichtigungen", "notificationSettingsFilterInfo": "Diese Einstellungen filtern auch die Seitenleiste für Mitteilungen", @@ -27,6 +28,21 @@ "sshKeyDisplayedInformation": "Das ist dein aktuell konfigurierter SSH-Schlüssel:", "key": "SSH Schlüssel" }, + "vcsAccessTokensSettingsPage": { + "addTokenTitle": "Neues Zugriffstoken erzeugen", + "infoText": "Du kannst ein persönliches Zugriffstoken generieren, um mit dem Artemis Local Version Control System zu interagieren. Verwende es um dich über HTTP bei Git zu authentifizieren.", + "deleteVcsAccessTokenQuestion": "Möchtest du dein Zugriffstoken für das Versionskontrollsystem wirklich löschen? Du kannst dich nicht mehr bei lokalen Repositories authentifizieren, die mit diesem Token geklont wurden.", + "createAccessToken": "Neues VCS Zugriffstoken generieren", + "noTokenSet": "Du hast kein Zugriffstoken", + "addToken": "Neues Token hinzufügen", + "deleteFailure": "Das Löschen des VCS-Zugriffstokens ist fehlgeschlagen", + "deleteSuccess": "Das VCS-Zugriffstoken wurde erfolgreich gelöscht", + "addFailure": "Neues VCS-Zugriffstoken konnte nicht gespeichert werden", + "addSuccess": "Neues VCS-Zugriffstoken erfolgreich erstellt", + "vcsAccessToken": "VCS-Zugriffstoken", + "expiryDate": "Ablaufdatum", + "actions": "Aktionen" + }, "accountInformationPage": { "registrationNumber": "Matrikelnummer", "fullName": "Vollständiger Name", @@ -38,7 +54,8 @@ "categories": { "NOTIFICATION_SETTINGS": "Benachrichtigungseinstellungen", "SCIENCE_SETTINGS": "Forschungseinstellungen", - "SSH_SETTINGS": "SSH" + "SSH_SETTINGS": "SSH Einstellungen", + "VCS_TOKEN_SETTINGS": "VCS Tokeneinstellungen" }, "settingGroupNames": { "weeklySummary": "Wöchentliche Zusammenfassung", diff --git a/src/main/webapp/i18n/en/exercise-actions.json b/src/main/webapp/i18n/en/exercise-actions.json index b32d1e5be4d1..ba4ac935244d 100644 --- a/src/main/webapp/i18n/en/exercise-actions.json +++ b/src/main/webapp/i18n/en/exercise-actions.json @@ -70,6 +70,8 @@ "uploadFile": "Upload a file", "viewTeam": "Team", "sshKeyTip": "To use ssh, you need to add an ssh key to your account {link:here}.", + "vcsTokenTip": "To access the repository with a VCS access token, you need to add a new one to your account {link:here}.", + "vcsTokenExpiredTip": "Your VCS access token has expired. Renew it {link:here}.", "startExerciseBeforeStartDate": "You cannot participate before the start date of the exercise.", "deleteMultipleExercisesQuestion": "Are you sure you want to delete the selected exercises?" } diff --git a/src/main/webapp/i18n/en/userSettings.json b/src/main/webapp/i18n/en/userSettings.json index 839dd249b260..4230ddab9506 100644 --- a/src/main/webapp/i18n/en/userSettings.json +++ b/src/main/webapp/i18n/en/userSettings.json @@ -5,7 +5,8 @@ "saveChanges": "Save Changes", "saveSettingsSuccessAlert": "Your Settings have been successfully saved.", "userSettings": "User Settings", - "sshSettings": "SSH Settings", + "sshSettings": "SSH", + "vcsAccessTokenSettings": "VCS Token", "accountInformation": "Account Information", "notificationSettings": "Notifications", "notificationSettingsFilterInfo": "These settings also filter the notification sidebar", @@ -27,6 +28,21 @@ "sshKeyDisplayedInformation": "This is your currently configured SSH key:", "key": "SSH Key" }, + "vcsAccessTokensSettingsPage": { + "addTokenTitle": "Add personal access token", + "infoText": "You can generate a personal access token to interact with the Artemis Local Version Control System. You can use it to authenticate to Git over HTTP.", + "deleteVcsAccessTokenQuestion": "Do you really want to delete your version control system access token? You will not be able to authenticate to local repositories cloned with this token any more.", + "createAccessToken": "Create new VCS access token", + "noTokenSet": "You do not have any user tokens", + "addToken": "Add new token", + "deleteFailure": "Deleting the VCS access token failed", + "deleteSuccess": "Successfully deleted the VCS access token", + "addFailure": "Failed to set new VCS access token", + "addSuccess": "Successfully created a new VCS access token", + "vcsAccessToken": "VCS Access Token", + "expiryDate": "Expiry Date", + "actions": "Actions" + }, "accountInformationPage": { "registrationNumber": "Registration Number", "fullName": "Full Name", @@ -38,7 +54,8 @@ "categories": { "NOTIFICATION_SETTINGS": "Notification Settings", "SCIENCE_SETTINGS": "Science Settings", - "SSH_SETTINGS": "SSH" + "SSH_SETTINGS": "SSH Settings", + "VCS_TOKEN_SETTINGS": "VCS Token Settings" }, "settingGroupNames": { "weeklySummary": "Weekly Summary", diff --git a/src/test/java/de/tum/in/www1/artemis/authentication/UserLocalVcIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/authentication/UserAccountLocalVcsIntegrationTest.java similarity index 64% rename from src/test/java/de/tum/in/www1/artemis/authentication/UserLocalVcIntegrationTest.java rename to src/test/java/de/tum/in/www1/artemis/authentication/UserAccountLocalVcsIntegrationTest.java index b419cbf57260..29630b917676 100644 --- a/src/test/java/de/tum/in/www1/artemis/authentication/UserLocalVcIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/authentication/UserAccountLocalVcsIntegrationTest.java @@ -9,7 +9,7 @@ import de.tum.in.www1.artemis.AbstractSpringIntegrationLocalCILocalVCTest; import de.tum.in.www1.artemis.user.UserTestService; -class UserLocalVcIntegrationTest extends AbstractSpringIntegrationLocalCILocalVCTest { +class UserAccountLocalVcsIntegrationTest extends AbstractSpringIntegrationLocalCILocalVCTest { private static final String TEST_PREFIX = "userlvc"; // shorter prefix as user's name is limited to 50 chars @@ -31,4 +31,16 @@ void teardown() throws Exception { void addAndDeleteSshPublicKeyByUser() throws Exception { userTestService.addAndDeleteSshPublicKey(); } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void getAndCreateParticipationVcsAccessTokenByUser() throws Exception { + userTestService.getAndCreateParticipationVcsAccessToken(); + } + + @Test + @WithMockUser(username = TEST_PREFIX + "student1", roles = "USER") + void createAndDeleteUserVcsAccessTokenByUser() throws Exception { + userTestService.createAndDeleteUserVcsAccessToken(); + } } diff --git a/src/test/java/de/tum/in/www1/artemis/localvcci/LocalVCLocalCIParticipationIntegrationTest.java b/src/test/java/de/tum/in/www1/artemis/localvcci/LocalVCLocalCIParticipationIntegrationTest.java index 190d02485212..cbd957d51dd1 100644 --- a/src/test/java/de/tum/in/www1/artemis/localvcci/LocalVCLocalCIParticipationIntegrationTest.java +++ b/src/test/java/de/tum/in/www1/artemis/localvcci/LocalVCLocalCIParticipationIntegrationTest.java @@ -57,7 +57,7 @@ void testStartParticipation() throws Exception { LocalVCRepositoryUri studentAssignmentRepositoryUri = new LocalVCRepositoryUri(projectKey, projectKey.toLowerCase() + "-" + TEST_PREFIX + "student1", localVCBaseUrl); assertThat(studentAssignmentRepositoryUri.getLocalRepositoryPath(localVCBasePath)).exists(); - var vcsAccessToken = request.get("/api/users/vcsToken?participationId=" + participation.getId(), HttpStatus.OK, String.class); + var vcsAccessToken = request.get("/api/account/participation-vcs-access-token?participationId=" + participation.getId(), HttpStatus.OK, String.class); assertThat(vcsAccessToken).isNotNull(); assertThat(vcsAccessToken).startsWith("vcpat"); diff --git a/src/test/java/de/tum/in/www1/artemis/user/UserTestService.java b/src/test/java/de/tum/in/www1/artemis/user/UserTestService.java index 394200342127..7902d3348d51 100644 --- a/src/test/java/de/tum/in/www1/artemis/user/UserTestService.java +++ b/src/test/java/de/tum/in/www1/artemis/user/UserTestService.java @@ -27,13 +27,18 @@ import de.tum.in.www1.artemis.course.CourseUtilService; import de.tum.in.www1.artemis.domain.Authority; import de.tum.in.www1.artemis.domain.Course; +import de.tum.in.www1.artemis.domain.ProgrammingSubmission; import de.tum.in.www1.artemis.domain.User; +import de.tum.in.www1.artemis.domain.enumeration.SubmissionType; import de.tum.in.www1.artemis.domain.science.ScienceEvent; import de.tum.in.www1.artemis.domain.science.ScienceEventType; import de.tum.in.www1.artemis.exercise.programming.MockDelegate; import de.tum.in.www1.artemis.exercise.programming.ProgrammingExerciseUtilService; import de.tum.in.www1.artemis.repository.AuthorityRepository; import de.tum.in.www1.artemis.repository.CourseRepository; +import de.tum.in.www1.artemis.repository.ParticipationRepository; +import de.tum.in.www1.artemis.repository.ParticipationVCSAccessTokenRepository; +import de.tum.in.www1.artemis.repository.SubmissionRepository; import de.tum.in.www1.artemis.repository.UserRepository; import de.tum.in.www1.artemis.repository.science.ScienceEventRepository; import de.tum.in.www1.artemis.security.Role; @@ -106,6 +111,15 @@ public class UserTestService { private static final int NUMBER_OF_INSTRUCTORS = 1; + @Autowired + private ParticipationVCSAccessTokenRepository participationVCSAccessTokenRepository; + + @Autowired + private ParticipationRepository participationRepository; + + @Autowired + private SubmissionRepository submissionRepository; + public void setup(String testPrefix, MockDelegate mockDelegate) throws Exception { this.TEST_PREFIX = testPrefix; this.mockDelegate = mockDelegate; @@ -817,18 +831,73 @@ public void addAndDeleteSshPublicKey() throws Exception { // adding invalid key should fail String invalidSshKey = "invalid key"; - request.putWithResponseBody("/api/users/sshpublickey", invalidSshKey, String.class, HttpStatus.BAD_REQUEST, true); + request.putWithResponseBody("/api/account/ssh-public-key", invalidSshKey, String.class, HttpStatus.BAD_REQUEST, true); // adding valid key should work correctly String validSshKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEbgjoSpKnry5yuMiWh/uwhMG2Jq5Sh8Uw9vz+39or2i email@abc.de"; - request.putWithResponseBody("/api/users/sshpublickey", validSshKey, String.class, HttpStatus.OK, true); + request.putWithResponseBody("/api/account/ssh-public-key", validSshKey, String.class, HttpStatus.OK, true); assertThat(userRepository.getUser().getSshPublicKey()).isEqualTo(validSshKey); // deleting the key shoul work correctly - request.delete("/api/users/sshpublickey", HttpStatus.OK); + request.delete("/api/account/ssh-public-key", HttpStatus.OK); assertThat(userRepository.getUser().getSshPublicKey()).isEqualTo(null); } + // Test + public void getAndCreateParticipationVcsAccessToken() throws Exception { + User user = userUtilService.getUserByLogin(TEST_PREFIX + "student1"); + + // try to get token for non existent participation + request.get("/api/account/participation-vcs-access-token?participationId=11", HttpStatus.NOT_FOUND, String.class); + + var course = courseUtilService.addEmptyCourse(); + var exercise = programmingExerciseUtilService.addProgrammingExerciseToCourse(course); + courseRepository.save(course); + + var submission = (ProgrammingSubmission) new ProgrammingSubmission().commitHash("abc").type(SubmissionType.MANUAL).submitted(true); + submission = programmingExerciseUtilService.addProgrammingSubmission(exercise, submission, user.getLogin()); + // request existing token + var token = request.get("/api/account/participation-vcs-access-token?participationId=" + submission.getParticipation().getId(), HttpStatus.OK, String.class); + assertThat(token).isNotNull(); + + // delete all tokens + participationVCSAccessTokenRepository.deleteAll(); + + // check that token was deleted + request.get("/api/account/participation-vcs-access-token?participationId=" + submission.getParticipation().getId(), HttpStatus.NOT_FOUND, String.class); + var newToken = request.putWithResponseBody("/api/account/participation-vcs-access-token?participationId=" + submission.getParticipation().getId(), null, String.class, + HttpStatus.OK); + assertThat(newToken).isNotEqualTo(token); + + submissionRepository.delete(submission); + participationVCSAccessTokenRepository.deleteAll(); + participationRepository.deleteById(submission.getParticipation().getId()); + } + + // Test + public void createAndDeleteUserVcsAccessToken() throws Exception { + User user = userUtilService.getUserByLogin(TEST_PREFIX + "student1"); + assertThat(user.getVcsAccessToken()).isNull(); + + // Set expiry date to already past date -> Bad Request + ZonedDateTime expiryDate = ZonedDateTime.now().minusMonths(1); + var userDTO = request.putWithResponseBody("/api/account/user-vcs-access-token?expiryDate=" + expiryDate, null, UserDTO.class, HttpStatus.BAD_REQUEST); + assertThat(userDTO).isNull(); + + // Correct expiry date -> OK + expiryDate = ZonedDateTime.now().plusMonths(1); + userDTO = request.putWithResponseBody("/api/account/user-vcs-access-token?expiryDate=" + expiryDate, null, UserDTO.class, HttpStatus.OK); + user = userUtilService.getUserByLogin(TEST_PREFIX + "student1"); + assertThat(user.getVcsAccessToken()).isEqualTo(userDTO.getVcsAccessToken()); + assertThat(user.getVcsAccessTokenExpiryDate()).isEqualTo(userDTO.getVcsAccessTokenExpiryDate()); + + // Delete token + request.delete("/api/account/user-vcs-access-token", HttpStatus.OK); + user = userUtilService.getUserByLogin(TEST_PREFIX + "student1"); + assertThat(user.getVcsAccessToken()).isNull(); + assertThat(user.getVcsAccessTokenExpiryDate()).isNull(); + } + public UserRepository getUserRepository() { return userRepository; } diff --git a/src/test/javascript/spec/component/account/vcs-access-token-settings.component.spec.ts b/src/test/javascript/spec/component/account/vcs-access-token-settings.component.spec.ts new file mode 100644 index 000000000000..71439b28cfba --- /dev/null +++ b/src/test/javascript/spec/component/account/vcs-access-token-settings.component.spec.ts @@ -0,0 +1,187 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { AccountService } from 'app/core/auth/account.service'; +import { of, throwError } from 'rxjs'; +import { By } from '@angular/platform-browser'; +import { MockComponent, MockPipe } from 'ng-mocks'; +import { ButtonComponent } from 'app/shared/components/button.component'; +import { FormDateTimePickerComponent } from 'app/shared/date-time-picker/date-time-picker.component'; +import { User } from 'app/core/user/user.model'; +import { ArtemisTestModule } from '../../test.module'; +import { MockNgbModalService } from '../../helpers/mocks/service/mock-ngb-modal.service'; +import { MockTranslateService, TranslatePipeMock } from '../../helpers/mocks/service/mock-translate.service'; +import { TranslateService } from '@ngx-translate/core'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; +import { VcsAccessTokensSettingsComponent } from 'app/shared/user-settings/vcs-access-tokens-settings/vcs-access-tokens-settings.component'; +import dayjs from 'dayjs/esm'; +import { ArtemisDatePipe } from 'app/shared/pipes/artemis-date.pipe'; +import { AlertService } from 'app/core/util/alert.service'; + +describe('VcsAccessTokensSettingsComponent', () => { + let fixture: ComponentFixture; + let comp: VcsAccessTokensSettingsComponent; + + let accountServiceMock: { getAuthenticationState: jest.Mock; deleteUserVcsAccessToken: jest.Mock; addNewVcsAccessToken: jest.Mock }; + const alertServiceMock = { error: jest.fn(), addAlert: jest.fn() }; + let translateService: TranslateService; + + const token = 'initial-token'; + + beforeEach(async () => { + accountServiceMock = { + getAuthenticationState: jest.fn(), + deleteUserVcsAccessToken: jest.fn(), + addNewVcsAccessToken: jest.fn(), + }; + + await TestBed.configureTestingModule({ + imports: [ArtemisTestModule], + declarations: [ + VcsAccessTokensSettingsComponent, + TranslatePipeMock, + MockPipe(ArtemisDatePipe), + MockComponent(ButtonComponent), + MockComponent(FormDateTimePickerComponent), + ], + providers: [ + { provide: AccountService, useValue: accountServiceMock }, + { provide: TranslateService, useClass: MockTranslateService }, + { provide: NgbModal, useClass: MockNgbModalService }, + { provide: AlertService, useValue: alertServiceMock }, + ], + }).compileComponents(); + fixture = TestBed.createComponent(VcsAccessTokensSettingsComponent); + comp = fixture.componentInstance; + + translateService = TestBed.inject(TranslateService); + translateService.currentLang = 'en'; + + accountServiceMock.getAuthenticationState.mockReturnValue(of({ id: 1, vcsAccessToken: token, vcsAccessTokenExpiryDate: '11:20' } as User)); + accountServiceMock.addNewVcsAccessToken.mockReturnValue(of({ id: 1, vcsAccessToken: token, vcsAccessTokenExpiryDate: '11:20' } as User)); + accountServiceMock.deleteUserVcsAccessToken.mockReturnValue(of({})); + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('should cancel token creation', () => { + accountServiceMock.getAuthenticationState.mockReturnValue(of({ id: 1 } as User)); + + startTokenCreation(); + + // click button to send expiry date to server, to create the new token + const createTokenButton = fixture.debugElement.query(By.css('#cancel-vcs-token-creation-button')); + createTokenButton.triggerEventHandler('onClick', null); + fixture.detectChanges(); + expect(comp.edit).toBeFalsy(); + }); + + it('should fail token creation with invalid date', () => { + accountServiceMock.getAuthenticationState.mockReturnValue(of({ id: 1 } as User)); + startTokenCreation(); + + // add an invalid expiry date + comp.expiryDate = dayjs().subtract(7, 'day'); + comp.validateDate(); + fixture.detectChanges(); + + // click button to send expiry date to server, to create the new token + const createTokenButton = fixture.debugElement.query(By.css('#create-vcs-token-button')); + createTokenButton.triggerEventHandler('onClick', null); + fixture.detectChanges(); + expect(comp.edit).toBeTruthy(); + expect(comp.currentUser?.vcsAccessToken).toBeUndefined(); + expect(alertServiceMock.error).toHaveBeenCalled(); + }); + + it('should handle failed token creation', () => { + accountServiceMock.addNewVcsAccessToken.mockImplementation(() => { + return throwError(() => new Error('Internal Server error')); + }); + + accountServiceMock.getAuthenticationState.mockReturnValue(of({ id: 1 } as User)); + startTokenCreation(); + + // add an invalid expiry date + comp.expiryDate = dayjs().add(7, 'day'); + comp.validExpiryDate = true; + + // click button to send expiry date to server, to create the new token + const createTokenButton = fixture.debugElement.query(By.css('#create-vcs-token-button')); + createTokenButton.triggerEventHandler('onClick', null); + fixture.detectChanges(); + expect(comp.edit).toBeTruthy(); + expect(alertServiceMock.error).toHaveBeenCalled(); + }); + + it('should create new vcs access token', () => { + const newToken = 'new-token'; + const tokenExpiryDate = dayjs().add(7, 'day'); + + accountServiceMock.getAuthenticationState.mockReturnValue(of({ id: 1 } as User)); + accountServiceMock.addNewVcsAccessToken.mockReturnValue(of({ body: { id: 1, vcsAccessToken: newToken, vcsAccessTokenExpiryDate: tokenExpiryDate.toISOString() } as User })); + startTokenCreation(); + + // add an expiry date + comp.expiryDate = tokenExpiryDate; + comp.validateDate(); + fixture.detectChanges(); + + // click button to send expiry date to server, to create the new token + const createTokenButton = fixture.debugElement.query(By.css('#create-vcs-token-button')); + createTokenButton.triggerEventHandler('onClick', null); + fixture.detectChanges(); + + expect(comp.edit).toBeFalsy(); + expect(accountServiceMock.addNewVcsAccessToken).toHaveBeenCalled(); + expect(comp.currentUser!.vcsAccessToken).toEqual(newToken); + }); + + it('should delete vcs access token', () => { + accountServiceMock.deleteUserVcsAccessToken.mockImplementation(() => { + return throwError(() => new Error('Internal Server error')); + }); + comp.ngOnInit(); + comp.deleteVcsAccessToken(); + expect(accountServiceMock.deleteUserVcsAccessToken).toHaveBeenCalled(); + expect(alertServiceMock.error).toHaveBeenCalled(); + }); + + it('should handle error when delete vcs access token fails', () => { + const newToken = 'new-token'; + accountServiceMock.addNewVcsAccessToken.mockReturnValue(of({ id: 1, vcsAccessToken: newToken, vcsAccessTokenExpiryDate: '11:20' } as User)); + comp.ngOnInit(); + expect(comp.currentUser!.vcsAccessToken).toEqual(token); + comp.deleteVcsAccessToken(); + expect(accountServiceMock.deleteUserVcsAccessToken).toHaveBeenCalled(); + expect(comp.currentUser!.vcsAccessToken).toBeUndefined(); + }); + + it('should set wasCopied to true and back to false after 3 seconds on successful copy', () => { + comp.ngOnInit(); + + jest.useFakeTimers(); + comp.onCopyFinished(true); + expect(comp.wasCopied).toBeTruthy(); + jest.advanceTimersByTime(3000); + expect(comp.wasCopied).toBeFalsy(); + jest.useRealTimers(); + }); + + it('should not change wasCopied if copy is unsuccessful', () => { + comp.ngOnInit(); + comp.onCopyFinished(false); + + // Verify that wasCopied remains false + expect(comp.wasCopied).toBeFalsy(); + }); + + function startTokenCreation() { + comp.ngOnInit(); + fixture.detectChanges(); + expect(comp.currentUser!.vcsAccessToken).toBeUndefined(); + + // click on new token button + const addTokenButton = fixture.debugElement.query(By.css('#add-new-token-button')); + addTokenButton.triggerEventHandler('onClick', null); + fixture.detectChanges(); + expect(comp.edit).toBeTruthy(); + } +}); diff --git a/src/test/javascript/spec/component/shared/code-button.component.spec.ts b/src/test/javascript/spec/component/shared/code-button.component.spec.ts index 499a4e36dc9b..967f51a4e333 100644 --- a/src/test/javascript/spec/component/shared/code-button.component.spec.ts +++ b/src/test/javascript/spec/component/shared/code-button.component.spec.ts @@ -147,7 +147,7 @@ describe('CodeButtonComponent', () => { component.ngOnInit(); tick(); - expect(component.setupSshKeysUrl).toBe(`${window.location.origin}/user-settings/sshSettings`); + expect(component.sshSettingsUrl).toBe(`${window.location.origin}/user-settings/ssh`); expect(component.sshTemplateUrl).toBe(info.sshCloneURLTemplate); expect(component.sshEnabled).toBe(!!info.sshCloneURLTemplate); expect(component.versionControlUrl).toBe(info.versionControlUrl); @@ -158,13 +158,14 @@ describe('CodeButtonComponent', () => { getVcsAccessTokenSpy = jest.spyOn(accountService, 'getVcsAccessToken').mockReturnValue(throwError(() => new HttpErrorResponse({ status: 404, statusText: 'Not found' }))); stubServices(); participation.id = 1; + component.useParticipationVcsAccessToken = true; component.participations = [participation]; component.ngOnChanges(); tick(); component.ngOnInit(); tick(); - expect(component.useVersionControlAccessToken).toBeTrue(); + expect(component.accessTokensEnabled).toBeTrue(); expect(component.user.vcsAccessToken).toEqual(vcsToken); expect(getVcsAccessTokenSpy).toHaveBeenCalled(); expect(createVcsAccessTokenSpy).toHaveBeenCalled(); @@ -173,13 +174,14 @@ describe('CodeButtonComponent', () => { it('should not create new vcsAccessToken when it exists', fakeAsync(() => { participation.id = 1; component.participations = [participation]; + component.useParticipationVcsAccessToken = true; stubServices(); component.ngOnChanges(); tick(); component.ngOnInit(); tick(); - expect(component.useVersionControlAccessToken).toBeTrue(); + expect(component.accessTokensEnabled).toBeTrue(); expect(component.user.vcsAccessToken).toEqual(vcsToken); expect(getVcsAccessTokenSpy).toHaveBeenCalled(); expect(createVcsAccessTokenSpy).not.toHaveBeenCalled(); @@ -207,12 +209,13 @@ describe('CodeButtonComponent', () => { it('should not use ssh when ssh is not enabled (even if useSsh is set)', () => { participation.repositoryUri = `https://gitlab.ase.in.tum.de/scm/ITCPLEASE1/itcplease1-exercise-team1.git`; component.participations = [participation]; - component.useSsh = true; + component.useParticipationVcsAccessToken = true; + component.useSsh = false; component.isTeamParticipation = false; - component.useVersionControlAccessToken = true; - component.useToken = true; + component.accessTokensEnabled = true; component.ngOnInit(); component.ngOnChanges(); + component.useToken = true; const url = component.getHttpOrSshRepositoryUri(); expect(url).toBe(`https://${component.user.login}:**********@gitlab.ase.in.tum.de/scm/ITCPLEASE1/itcplease1-exercise-team1.git`); @@ -258,12 +261,13 @@ describe('CodeButtonComponent', () => { it('should insert the correct token in the repository uri', () => { participation.repositoryUri = `https://${component.user.login}@gitlab.ase.in.tum.de/scm/ITCPLEASE1/itcplease1-exercise-team1.git`; component.participations = [participation]; + component.useParticipationVcsAccessToken = true; component.useSsh = false; - component.useToken = true; component.isTeamParticipation = false; - component.useVersionControlAccessToken = true; + component.accessTokensEnabled = true; component.ngOnInit(); component.ngOnChanges(); + component.useToken = true; // Placeholder is shown let url = component.getHttpOrSshRepositoryUri(); @@ -288,12 +292,13 @@ describe('CodeButtonComponent', () => { it('should add the user login and token to the URL', () => { participation.repositoryUri = `https://gitlab.ase.in.tum.de/scm/ITCPLEASE1/itcplease1-exercise-team1.git`; component.participations = [participation]; + component.useParticipationVcsAccessToken = true; component.useSsh = false; - component.useToken = true; component.isTeamParticipation = false; - component.useVersionControlAccessToken = true; + component.accessTokensEnabled = true; component.ngOnInit(); component.ngOnChanges(); + component.useToken = true; const url = component.getHttpOrSshRepositoryUri(); expect(url).toBe(`https://${component.user.login}:**********@gitlab.ase.in.tum.de/scm/ITCPLEASE1/itcplease1-exercise-team1.git`); @@ -334,6 +339,22 @@ describe('CodeButtonComponent', () => { expect(component.getHttpOrSshRepositoryUri()).toBe('https://user1@gitlab.ase.in.tum.de/scm/ITCPLEASE1/itcplease1-exercise.solution.git'); }); + it('should set wasCopied to true and back to false after 3 seconds on successful copy', () => { + component.ngOnInit(); + jest.useFakeTimers(); + component.onCopyFinished(true); + expect(component.wasCopied).toBeTrue(); + jest.advanceTimersByTime(3000); + expect(component.wasCopied).toBeFalse(); + jest.useRealTimers(); + }); + + it('should not change wasCopied if copy is unsuccessful', () => { + component.ngOnInit(); + component.onCopyFinished(false); + expect(component.wasCopied).toBeFalse(); + }); + it('should fetch and store ssh preference', fakeAsync(() => { stubServices(); @@ -347,33 +368,33 @@ describe('CodeButtonComponent', () => { expect(localStorageUseSshRetrieveStub).toHaveBeenNthCalledWith(1, 'useSsh'); expect(localStorageUseSshObserveStub).toHaveBeenNthCalledWith(1, 'useSsh'); - expect(component.useSsh).toBeFalsy(); + expect(component.useSsh).toBeFalse(); fixture.debugElement.query(By.css('.code-button')).nativeElement.click(); tick(); fixture.debugElement.query(By.css('#useSSHButton')).nativeElement.click(); tick(); expect(localStorageUseSshStoreStub).toHaveBeenNthCalledWith(1, 'useSsh', true); - expect(component.useSsh).toBeTruthy(); + expect(component.useSsh).toBeTrue(); fixture.debugElement.query(By.css('#useHTTPSButton')).nativeElement.click(); tick(); expect(localStorageUseSshStoreStub).toHaveBeenCalledWith('useSsh', false); - expect(component.useSsh).toBeFalsy(); + expect(component.useSsh).toBeFalse(); fixture.debugElement.query(By.css('#useHTTPSWithTokenButton')).nativeElement.click(); tick(); expect(localStorageUseSshStoreStub).toHaveBeenCalledWith('useSsh', false); - expect(component.useSsh).toBeFalsy(); - expect(component.useToken).toBeTruthy(); + expect(component.useSsh).toBeFalse(); + expect(component.useToken).toBeTrue(); localStorageUseSshObserveStubSubject.next(true); tick(); - expect(component.useSsh).toBeTruthy(); + expect(component.useSsh).toBeTrue(); localStorageUseSshObserveStubSubject.next(false); tick(); - expect(component.useSsh).toBeFalsy(); + expect(component.useSsh).toBeFalse(); })); it.each([ @@ -420,7 +441,7 @@ describe('CodeButtonComponent', () => { guidedTourSettings: [], login: 'edx_userLogin', internal: true, - vcsAccessToken: 'token', + vcsAccessToken: vcsToken, }), );