diff --git a/src/main/java/de/tum/cit/aet/artemis/core/domain/User.java b/src/main/java/de/tum/cit/aet/artemis/core/domain/User.java index 7fa995658af1..6498340f3bc2 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/domain/User.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/domain/User.java @@ -561,4 +561,9 @@ public void hasAcceptedIrisElseThrow() { public String getSshPublicKey() { return sshPublicKey; } + + @Nullable + public @Size(max = 100) String getSshPublicKeyHash() { + return sshPublicKeyHash; + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/core/dto/UserDTO.java b/src/main/java/de/tum/cit/aet/artemis/core/dto/UserDTO.java index 1ac72940f919..3d627425a8f7 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/dto/UserDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/dto/UserDTO.java @@ -78,6 +78,8 @@ public class UserDTO extends AuditingEntityDTO { private String sshPublicKey; + private String sshKeyHash; + private ZonedDateTime irisAccepted; public UserDTO() { @@ -291,4 +293,12 @@ public ZonedDateTime getIrisAccepted() { public void setIrisAccepted(ZonedDateTime irisAccepted) { this.irisAccepted = irisAccepted; } + + public String getSshKeyHash() { + return sshKeyHash; + } + + public void setSshKeyHash(String sshKeyHash) { + this.sshKeyHash = sshKeyHash; + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/open/PublicAccountResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/open/PublicAccountResource.java index 992b340359a1..bd673ece51d4 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/web/open/PublicAccountResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/web/open/PublicAccountResource.java @@ -167,6 +167,7 @@ public ResponseEntity getAccount() { userDTO.setVcsAccessToken(user.getVcsAccessToken()); userDTO.setVcsAccessTokenExpiryDate(user.getVcsAccessTokenExpiryDate()); userDTO.setSshPublicKey(user.getSshPublicKey()); + userDTO.setSshKeyHash(user.getSshPublicKeyHash()); log.info("GET /account {} took {}ms", user.getLogin(), System.currentTimeMillis() - start); return ResponseEntity.ok(userDTO); } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/domain/AuthenticationMechanism.java b/src/main/java/de/tum/cit/aet/artemis/programming/domain/AuthenticationMechanism.java index 3caec801ed53..239ef3674d44 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/domain/AuthenticationMechanism.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/domain/AuthenticationMechanism.java @@ -20,5 +20,5 @@ public enum AuthenticationMechanism { /** * The user used the artemis client code editor to authenticate to the LocalVC */ - CODE_EDITOR + CODE_EDITOR, } diff --git a/src/main/webapp/app/core/user/user.model.ts b/src/main/webapp/app/core/user/user.model.ts index b55ff839fd54..f793581b21a3 100644 --- a/src/main/webapp/app/core/user/user.model.ts +++ b/src/main/webapp/app/core/user/user.model.ts @@ -16,6 +16,7 @@ export class User extends Account { public vcsAccessToken?: string; public vcsAccessTokenExpiryDate?: string; public sshPublicKey?: string; + public sshKeyHash?: string; public irisAccepted?: dayjs.Dayjs; constructor( diff --git a/src/main/webapp/app/shared/components/documentation-button/documentation-button.component.ts b/src/main/webapp/app/shared/components/documentation-button/documentation-button.component.ts index f598865bb6a6..7724361d798e 100644 --- a/src/main/webapp/app/shared/components/documentation-button/documentation-button.component.ts +++ b/src/main/webapp/app/shared/components/documentation-button/documentation-button.component.ts @@ -12,7 +12,7 @@ const DocumentationLinks = { Quiz: 'exercises/quiz/', Model: 'exercises/modeling/', Programming: 'exercises/programming/', - SshSetup: 'exercises/programming.html#repository-access', + SshSetup: 'icl/ssh-intro', Text: 'exercises/textual/', FileUpload: 'exercises/file-upload/', Notifications: 'notifications/', diff --git a/src/main/webapp/app/shared/components/documentation-link/documentation-link.component.html b/src/main/webapp/app/shared/components/documentation-link/documentation-link.component.html new file mode 100644 index 000000000000..b3e990001b2f --- /dev/null +++ b/src/main/webapp/app/shared/components/documentation-link/documentation-link.component.html @@ -0,0 +1,5 @@ +@if (displayString() && documentationType()) { + + + +} diff --git a/src/main/webapp/app/shared/components/documentation-link/documentation-link.component.ts b/src/main/webapp/app/shared/components/documentation-link/documentation-link.component.ts new file mode 100644 index 000000000000..86f5d19aafb6 --- /dev/null +++ b/src/main/webapp/app/shared/components/documentation-link/documentation-link.component.ts @@ -0,0 +1,25 @@ +import { Component, input } from '@angular/core'; +import { TranslateDirective } from 'app/shared/language/translate.directive'; + +// The routes here are used to build the link to the documentation. +// Therefore, it's important that they exactly match the url to the subpage of the documentation. +// Additionally, the case names must match the keys in documentationLinks.json for the tooltip. +const DocumentationLinks: { [key: string]: string } = { + SshSetup: 'icl/ssh-intro', +}; + +export type DocumentationType = keyof typeof DocumentationLinks; + +@Component({ + selector: 'jhi-documentation-link', + standalone: true, + templateUrl: './documentation-link.component.html', + imports: [TranslateDirective], +}) +export class DocumentationLinkComponent { + readonly BASE_URL = 'https://docs.artemis.cit.tum.de/user/'; + readonly DocumentationLinks = DocumentationLinks; + + documentationType = input(); + displayString = input(); +} diff --git a/src/main/webapp/app/shared/user-settings/ssh-settings/ssh-user-settings.component.html b/src/main/webapp/app/shared/user-settings/ssh-settings/ssh-user-settings.component.html index 8899797a2e8c..7372a07ac241 100644 --- a/src/main/webapp/app/shared/user-settings/ssh-settings/ssh-user-settings.component.html +++ b/src/main/webapp/app/shared/user-settings/ssh-settings/ssh-user-settings.component.html @@ -3,80 +3,153 @@

@if (currentUser) {
-
-
- - -
-
- @if (storedSshKey === '' && !editSshKey) { -
- -
- } - @if (storedSshKey !== '' && !editSshKey) { -
-
- {{ sshKey }} -
-
-
+ + @if (keyCount === 0 && !showSshKey) { +
+
+

+
+

+ + + +

+
+ +
-
- -
} - @if (editSshKey) { -
-
+ + + @if (keyCount > 0 && !showSshKey) { +
+

+ +
+

+ + + +

+
+ + + + + + + + + + + + + + + +
+
+
+ {{ sshKeyHash }} +
+
+
+ +
+
+ } + + + @if (showSshKey) {
+ @if (isKeyReadonly) { +

+ } @else { +

+ } + +
+

+ + + +

+
+
- +

+
-
-
- + +
+

+ + {{ copyInstructions }} +

+
+ + @if (!isKeyReadonly) { +
+
+ +
+
+ +
-
- + } @else { +
+
+ +
-
+ }
}
diff --git a/src/main/webapp/app/shared/user-settings/ssh-settings/ssh-user-settings.component.scss b/src/main/webapp/app/shared/user-settings/ssh-settings/ssh-user-settings.component.scss new file mode 100644 index 000000000000..8255e6ade0f4 --- /dev/null +++ b/src/main/webapp/app/shared/user-settings/ssh-settings/ssh-user-settings.component.scss @@ -0,0 +1,134 @@ +textarea { + width: 600px; + height: 150px; + font-size: small; + font-family: + Bitstream Vera Sans Mono, + Courier New, + monospace; +} + +table { + width: 100%; + border-collapse: collapse; + margin: 20px 0; + font-size: 16px; + text-align: left; +} + +th, +td { + padding: 15px 15px; +} + +/* Button styling */ + +.btn { + border-radius: 0; +} + +.container { + position: relative; +} + +.narrower-box { + max-width: 500px; + margin: 0 auto; +} + +.font-medium { + font-size: medium; +} + +.icon-column { + width: auto; + margin-right: 15px; +} + +.vertical-center { + margin: 0; + position: absolute; + top: 50%; + left: 50%; + -ms-transform: translateY(-50%); + transform: translateY(-50%); +} + +tbody tr:hover { + background-color: var(--ssh-key-table-hover-background); +} + +/* dd container */ +.dropdown { + display: inline-block; + position: relative; + outline: none; + margin: 0; +} + +/* button */ +.dropbtn { + padding: 0; + color: grey; + cursor: pointer; + transition: 0.35s ease-out; +} + +/* dd content */ +.dropdown .dropdown-content { + position: absolute; + top: 50%; + min-width: 120%; + box-shadow: 0 8px 16px var(--ssh-key-settings-shadow); + z-index: 100000; // makes sure the drop down menu is always shown at the highest view plane + visibility: hidden; + opacity: 1; + transition: 0.35s ease-out; + width: 80px; +} + +/* show dd content */ +.dropdown:focus .dropdown-content { + outline: none; + transform: translateY(20px); + visibility: visible; + opacity: 1; + background-color: var(--light); +} + +/* mask to close menu by clicking on the button */ +.dropdown .db2 { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + opacity: 0; + cursor: pointer; + z-index: 10; + display: none; +} + +.dropdown:focus .db2 { + display: inline-block; +} + +.dropdown .db2:focus .dropdown-content { + outline: none; + visibility: hidden; + margin-right: 15px; + opacity: 0; +} + +.dropdown-button { + margin: auto; + color: var(--ssh-key-settings-text-color); + background-color: var(--dropdown-bg); + border-color: var(--dropdown-bg); + width: 80px; +} + +.dropdown-button:hover { + background-color: var(--ssh-key-settings-dropdown-buttons-hover); + border-color: var(--ssh-key-settings-dropdown-buttons-hover); +} diff --git a/src/main/webapp/app/shared/user-settings/ssh-settings/ssh-user-settings.component.ts b/src/main/webapp/app/shared/user-settings/ssh-settings/ssh-user-settings.component.ts index 808fe99b016c..6fa6fbc9336e 100644 --- a/src/main/webapp/app/shared/user-settings/ssh-settings/ssh-user-settings.component.ts +++ b/src/main/webapp/app/shared/user-settings/ssh-settings/ssh-user-settings.component.ts @@ -4,27 +4,34 @@ import { AccountService } from 'app/core/auth/account.service'; import { Subject, Subscription, tap } from 'rxjs'; import { ProfileService } from 'app/shared/layouts/profiles/profile.service'; import { PROFILE_LOCALVC } from 'app/app.constants'; -import { faEdit, faSave, faTrash } from '@fortawesome/free-solid-svg-icons'; +import { faEdit, faEllipsis, faSave, faTrash } from '@fortawesome/free-solid-svg-icons'; import { DocumentationType } from 'app/shared/components/documentation-button/documentation-button.component'; import { ButtonSize, ButtonType } from 'app/shared/components/button.component'; import { AlertService } from 'app/core/util/alert.service'; +import { getOS } from 'app/shared/util/os-detector.util'; @Component({ selector: 'jhi-account-information', templateUrl: './ssh-user-settings.component.html', - styleUrls: ['../user-settings.scss'], + styleUrls: ['../user-settings.scss', './ssh-user-settings.component.scss'], }) export class SshUserSettingsComponent implements OnInit { readonly documentationType: DocumentationType = 'SshSetup'; currentUser?: User; localVCEnabled = false; sshKey = ''; + sshKeyHash = ''; storedSshKey = ''; - editSshKey = false; + showSshKey = false; + keyCount = 0; + isKeyReadonly = true; + copyInstructions = ''; readonly faEdit = faEdit; readonly faSave = faSave; readonly faTrash = faTrash; + readonly faEllipsis = faEllipsis; + private authStateSubscription: Subscription; private dialogErrorSource = new Subject(); @@ -40,14 +47,18 @@ export class SshUserSettingsComponent implements OnInit { this.profileService.getProfileInfo().subscribe((profileInfo) => { this.localVCEnabled = profileInfo.activeProfiles.includes(PROFILE_LOCALVC); }); - + this.setMessageBasedOnOS(getOS()); this.authStateSubscription = this.accountService .getAuthenticationState() .pipe( tap((user: User) => { this.storedSshKey = user.sshPublicKey || ''; this.sshKey = this.storedSshKey; + this.sshKeyHash = user.sshKeyHash || ''; this.currentUser = user; + // currently only 0 or 1 key are supported + this.keyCount = this.sshKey ? 1 : 0; + this.isKeyReadonly = !!this.sshKey; return this.currentUser; }), ) @@ -58,8 +69,10 @@ export class SshUserSettingsComponent implements OnInit { this.accountService.addSshPublicKey(this.sshKey).subscribe({ next: () => { this.alertService.success('artemisApp.userSettings.sshSettingsPage.saveSuccess'); - this.editSshKey = false; + this.showSshKey = false; this.storedSshKey = this.sshKey; + this.keyCount = this.keyCount + 1; + this.isKeyReadonly = true; }, error: () => { this.alertService.error('artemisApp.userSettings.sshSettingsPage.saveFailure'); @@ -68,12 +81,14 @@ export class SshUserSettingsComponent implements OnInit { } deleteSshKey() { - this.editSshKey = false; + this.showSshKey = false; this.accountService.deleteSshPublicKey().subscribe({ next: () => { this.alertService.success('artemisApp.userSettings.sshSettingsPage.deleteSuccess'); this.sshKey = ''; this.storedSshKey = ''; + this.keyCount = this.keyCount - 1; + this.isKeyReadonly = false; }, error: () => { this.alertService.error('artemisApp.userSettings.sshSettingsPage.deleteFailure'); @@ -83,10 +98,29 @@ export class SshUserSettingsComponent implements OnInit { } cancelEditingSshKey() { - this.editSshKey = !this.editSshKey; + this.showSshKey = !this.showSshKey; this.sshKey = this.storedSshKey; } protected readonly ButtonType = ButtonType; protected readonly ButtonSize = ButtonSize; + + private setMessageBasedOnOS(os: string): void { + switch (os) { + case 'Windows': + this.copyInstructions = 'cat ~/.ssh/id_ed25519.pub | clip'; + break; + case 'MacOS': + this.copyInstructions = 'pbcopy < ~/.ssh/id_ed25519.pub'; + break; + case 'Linux': + this.copyInstructions = 'xclip -selection clipboard < ~/.ssh/id_ed25519.pub'; + break; + case 'Android': + this.copyInstructions = 'termux-clipboard-set < ~/.ssh/id_ed25519.pub'; + break; + default: + this.copyInstructions = 'Ctrl + C'; + } + } } 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 bc91e586ecb2..fe912bdef56a 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 @@ -12,9 +12,10 @@ import { VcsAccessTokensSettingsComponent } from 'app/shared/user-settings/vcs-a import { ClipboardModule } from '@angular/cdk/clipboard'; import { FormDateTimePickerModule } from 'app/shared/date-time-picker/date-time-picker.module'; import { IdeSettingsComponent } from 'app/shared/user-settings/ide-preferences/ide-settings.component'; +import { DocumentationLinkComponent } from 'app/shared/components/documentation-link/documentation-link.component'; @NgModule({ - imports: [RouterModule.forChild(userSettingsState), ArtemisSharedModule, ArtemisSharedComponentModule, ClipboardModule, FormDateTimePickerModule], + imports: [RouterModule.forChild(userSettingsState), ArtemisSharedModule, ArtemisSharedComponentModule, ClipboardModule, FormDateTimePickerModule, DocumentationLinkComponent], declarations: [ UserSettingsContainerComponent, AccountInformationComponent, diff --git a/src/main/webapp/app/shared/user-settings/user-settings.scss b/src/main/webapp/app/shared/user-settings/user-settings.scss index 02a29cc3ae86..15bc60da27ed 100644 --- a/src/main/webapp/app/shared/user-settings/user-settings.scss +++ b/src/main/webapp/app/shared/user-settings/user-settings.scss @@ -19,11 +19,6 @@ dd { font-size: larger; } -span { - color: gray; - font-size: smaller; -} - .userSettings-info { span { font-style: italic; diff --git a/src/main/webapp/app/shared/util/os-detector.util.ts b/src/main/webapp/app/shared/util/os-detector.util.ts new file mode 100644 index 000000000000..59ef5027b69b --- /dev/null +++ b/src/main/webapp/app/shared/util/os-detector.util.ts @@ -0,0 +1,17 @@ +export function getOS(): string { + const userAgent = window.navigator.userAgent; + + if (userAgent.indexOf('Win') !== -1) { + return 'Windows'; + } else if (userAgent.indexOf('Mac') !== -1) { + return 'MacOS'; + } else if (/Android/.test(userAgent)) { + return 'Android'; + } else if (userAgent.indexOf('Linux') !== -1) { + return 'Linux'; + } else if (/iPhone|iPad|iPod/.test(userAgent)) { + return 'iOS'; + } else { + return 'Unknown'; + } +} diff --git a/src/main/webapp/content/scss/themes/_dark-variables.scss b/src/main/webapp/content/scss/themes/_dark-variables.scss index 40789ff78781..daa039c0e167 100644 --- a/src/main/webapp/content/scss/themes/_dark-variables.scss +++ b/src/main/webapp/content/scss/themes/_dark-variables.scss @@ -662,3 +662,10 @@ $iris-rate-background: var(--neutral-dark-l-15); // Image Cropper $cropper-overlay-color: transparent; + +// Settings +$ssh-key-table-hover-background: $gray-900; +$ssh-key-settings-dropdown-buttons: $gray-800; +$ssh-key-settings-dropdown-buttons-hover: $gray-700; +$ssh-key-settings-text-color: $white; +$ssh-key-settings-shadow: rgba(0, 0, 0, 0.5); diff --git a/src/main/webapp/content/scss/themes/_default-variables.scss b/src/main/webapp/content/scss/themes/_default-variables.scss index 92eca3165ea0..337a58ed3316 100644 --- a/src/main/webapp/content/scss/themes/_default-variables.scss +++ b/src/main/webapp/content/scss/themes/_default-variables.scss @@ -590,3 +590,10 @@ $iris-rate-background: var(--gray-300); // Image Cropper $cropper-overlay-color: transparent; + +// Settings +$ssh-key-settings-table-hover-background: $gray-200; +$ssh-key-settings-dropdown-buttons: $light; +$ssh-key-settings-dropdown-buttons-hover: $gray-200; +$ssh-key-settings-text-color: $black; +$ssh-key-settings-shadow: rgba(0, 0, 0, 0.2); diff --git a/src/main/webapp/i18n/de/userSettings.json b/src/main/webapp/i18n/de/userSettings.json index 1d5cbdacc854..6a2ebecc1763 100644 --- a/src/main/webapp/i18n/de/userSettings.json +++ b/src/main/webapp/i18n/de/userSettings.json @@ -27,11 +27,22 @@ "deleteFailure": "SSH-Schlüssel konnte nicht gelöscht werden.", "deleteSuccess": "SSH-Schlüssel erfolgreich gelöscht.", "cancelSavingSshKey": "Abbrechen", - "editExistingSshKey": "SSH-Schlüssel bearbeiten", - "addNewSshKey": "Neuer SSH-Schlüssel", + "editExistingSshKey": "Bearbeiten", + "viewExistingSshKey": "Ansehen", + "addNewSshKey": "SSH-Schlüssel hinzufügen", + "sshKeyDetails": "SSH-Schlüssel Informationen", "deleteSshKey": "Löschen", "sshKeyDisplayedInformation": "Das ist dein aktuell konfigurierter SSH-Schlüssel:", - "key": "SSH Schlüssel" + "key": "Schlüssel", + "noKeysHaveBeenAdded": "Es wurden noch keine SSH-Schlüssel hinzugefügt", + "whatToUseSSHForInfo": "Verwende SSH-Schlüssel, um einfach und sicher eine Verbindung zu Repositories herzustellen.", + "learnMore": "Erfahre mehr über SSH-Schlüssel", + "alreadyHaveKey": "Du hast bereits einen Schlüssel? Kopiere deinen Schlüssel in die Zwischenablage:", + "back": "Zurück", + "keysTablePageTitle": "SSH-Schlüssel", + "keys": "Schlüssel", + "actions": "Aktionen", + "keyName": "Key 1 (Aktuell wird nur ein Schlüssel unterstützt)" }, "vcsAccessTokensSettingsPage": { "addTokenTitle": "Neues Zugriffstoken erzeugen", @@ -56,7 +67,7 @@ "joinedArtemis": "Artemis beigetreten am", "profilePicture": "Profilbild", "addProfilePicture": "Profilbild hinzufügen", - "sshKey": "Öffentlicher SSH Schlüssel" + "sshKey": "Öffentlicher SSH-Schlüssel" }, "categories": { "NOTIFICATION_SETTINGS": "Benachrichtigungseinstellungen", diff --git a/src/main/webapp/i18n/en/userSettings.json b/src/main/webapp/i18n/en/userSettings.json index 328be05e823a..29afe83a0c3c 100644 --- a/src/main/webapp/i18n/en/userSettings.json +++ b/src/main/webapp/i18n/en/userSettings.json @@ -27,11 +27,22 @@ "deleteFailure": "Failed to delete SSH key.", "deleteSuccess": "Successfully deleted SSH key.", "cancelSavingSshKey": "Cancel", - "editExistingSshKey": "Edit SSH key", - "addNewSshKey": "New SSH key", + "editExistingSshKey": "Edit", + "viewExistingSshKey": "View", + "addNewSshKey": "Add SSH key", + "sshKeyDetails": "SSH key details", "deleteSshKey": "Delete", "sshKeyDisplayedInformation": "This is your currently configured SSH key:", - "key": "SSH Key" + "key": "Key", + "noKeysHaveBeenAdded": "No SSH keys have been added", + "whatToUseSSHForInfo": "Use SSH keys to connect simply and securely to repositories.", + "learnMore": "Learn more about SSH keys", + "alreadyHaveKey": "Already have a key? Copy your key to the clipboard:", + "back": "Back", + "keysTablePageTitle": "SSH keys", + "keys": "Keys", + "actions": "Actions", + "keyName": "Key 1 (at the moment you can only have one key)" }, "vcsAccessTokensSettingsPage": { "addTokenTitle": "Add personal access token", diff --git a/src/test/javascript/spec/component/account/ssh-user-settings.component.spec.ts b/src/test/javascript/spec/component/account/ssh-user-settings.component.spec.ts index c7b44b4f9e97..29b647dfb946 100644 --- a/src/test/javascript/spec/component/account/ssh-user-settings.component.spec.ts +++ b/src/test/javascript/spec/component/account/ssh-user-settings.component.spec.ts @@ -76,10 +76,10 @@ describe('SshUserSettingsComponent', () => { accountServiceMock.addSshPublicKey.mockReturnValue(of(new HttpResponse({ status: 200 }))); comp.ngOnInit(); comp.sshKey = 'new-key'; - comp.editSshKey = true; + comp.showSshKey = true; comp.saveSshKey(); expect(accountServiceMock.addSshPublicKey).toHaveBeenCalledWith('new-key'); - expect(comp.editSshKey).toBeFalse(); + expect(comp.showSshKey).toBeFalse(); }); it('should delete SSH key and disable edit mode', () => { @@ -87,10 +87,10 @@ describe('SshUserSettingsComponent', () => { comp.ngOnInit(); const empty = ''; comp.sshKey = 'new-key'; - comp.editSshKey = true; + comp.showSshKey = true; comp.deleteSshKey(); expect(accountServiceMock.deleteSshPublicKey).toHaveBeenCalled(); - expect(comp.editSshKey).toBeFalse(); + expect(comp.showSshKey).toBeFalse(); expect(comp.storedSshKey).toEqual(empty); }); @@ -113,12 +113,48 @@ describe('SshUserSettingsComponent', () => { const oldKey = 'old-key'; const newKey = 'new-key'; comp.sshKey = oldKey; - comp.editSshKey = true; + comp.showSshKey = true; comp.saveSshKey(); expect(comp.storedSshKey).toEqual(oldKey); - comp.editSshKey = true; + comp.showSshKey = true; comp.sshKey = newKey; comp.cancelEditingSshKey(); expect(comp.storedSshKey).toEqual(oldKey); }); + + it('should detect Windows', () => { + jest.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Windows NT 10.0; Win64; x64)'); + comp.ngOnInit(); + expect(comp.copyInstructions).toBe('cat ~/.ssh/id_ed25519.pub | clip'); + }); + + it('should detect MacOS', () => { + jest.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)'); + comp.ngOnInit(); + expect(comp.copyInstructions).toBe('pbcopy < ~/.ssh/id_ed25519.pub'); + }); + + it('should detect Linux', () => { + jest.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (X11; Linux x86_64)'); + comp.ngOnInit(); + expect(comp.copyInstructions).toBe('xclip -selection clipboard < ~/.ssh/id_ed25519.pub'); + }); + + it('should detect Android', () => { + jest.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Linux; Android 10; Pixel 3)'); + comp.ngOnInit(); + expect(comp.copyInstructions).toBe('termux-clipboard-set < ~/.ssh/id_ed25519.pub'); + }); + + it('should detect iOS', () => { + jest.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (iPhone; CPU iPhone OS 13_5)'); + comp.ngOnInit(); + expect(comp.copyInstructions).toBe('Ctrl + C'); + }); + + it('should return Unknown for unrecognized OS', () => { + jest.spyOn(window.navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Unknown OS)'); + comp.ngOnInit(); + expect(comp.copyInstructions).toBe('Ctrl + C'); + }); });