Skip to content

Commit 000977b

Browse files
authored
Enhance SSH dialog user experience (#4260)
1 parent 491f78a commit 000977b

File tree

9 files changed

+192
-37
lines changed

9 files changed

+192
-37
lines changed

packages/playground/src/components/profile_manager/ConnectWallet.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -336,12 +336,12 @@ async function storeAndLogin() {
336336
const grid = await getGrid({ mnemonic: mnemonic.value, keypairType: keypairType.value });
337337
storeEmail(grid!, email.value);
338338
setCredentials(md5(password.value), mnemonicHash, keypairTypeHash, md5(email.value));
339-
await handlePostLogin(grid!, password.value, email.value);
340339
const profile = await loadProfile(grid!);
341340
if (email.value && profile.email !== email.value) {
342341
profile.email = email.value;
343342
}
344343
profileManager.set({ ...profile, mnemonic: mnemonic.value });
344+
await handlePostLogin(grid!, password.value, email.value);
345345
} catch (e) {
346346
if (e instanceof TwinNotExistError) {
347347
isNonActiveMnemonic.value = true;

packages/playground/src/components/profile_manager/LoginWallet.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,8 @@ async function login() {
7474
: KeypairType.sr25519;
7575
7676
const grid = await getGrid({ mnemonic: mnemonic, keypairType: keypairType as KeypairType });
77-
await handlePostLogin(grid!, password.value);
7877
profileManager.set({ ...(await loadProfile(grid!)), mnemonic });
78+
await handlePostLogin(grid!, password.value);
7979
emit("closeDialog");
8080
}
8181
} else {

packages/playground/src/components/ssh_keys/SshDataDialog.vue

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
@keydown.esc="() => $emit('close')"
88
>
99
<template #default>
10+
<v-form v-model="isValidForm">
1011
<v-card>
1112
<v-toolbar color="primary" class="custom-toolbar">
1213
<p class="mb-5">SSH-Key Details</p>
@@ -60,24 +61,26 @@
6061
<v-card-actions class="justify-end mb-1 mr-2">
6162
<v-btn color="anchor" text @click="$emit('close')">Close</v-btn>
6263

63-
<v-tooltip v-model="showTooltip" text="No changes have been made" bottom>
64+
<v-tooltip :disabled="hasChanges" text="No changes have been made" bottom>
6465
<template #activator="{ props }">
65-
<div v-on="props">
66-
<v-btn text :loading="loading" :disabled="!hasChanges" v-bind="props" @click="updateKey"> Save </v-btn>
66+
<div v-bind="props">
67+
<v-btn text :loading="loading" :disabled="!hasChanges || !isValidForm" v-bind="props" @click="updateKey"> Save </v-btn>
6768
</div>
6869
</template>
6970
</v-tooltip>
7071
</v-card-actions>
7172
</v-card>
73+
</v-form>
7274
</template>
7375
</v-dialog>
7476
</template>
7577

7678
<script lang="ts">
77-
import { capitalize, defineComponent, computed, type PropType, ref, watch } from "vue";
79+
import { capitalize, defineComponent, type PropType, ref, watch } from "vue";
7880
7981
import type { SSHKeyData } from "@/types";
8082
import SSHKeysManagement from "@/utils/ssh";
83+
import { isAlphanumericWithSpace } from "@/utils/validators";
8184
8285
export default defineComponent({
8386
name: "SSHDataDialog",
@@ -101,7 +104,8 @@ export default defineComponent({
101104
const loading = ref<boolean>(false);
102105
const hasChanges = ref<boolean>(false);
103106
const originalKey = ref<SSHKeyData>({ ...props.selectedKey });
104-
const showTooltip = computed(() => !hasChanges.value);
107+
const isValidForm = ref<boolean>(false);
108+
105109
watch(
106110
() => props.open,
107111
newValue => {
@@ -150,6 +154,12 @@ export default defineComponent({
150154
if (name === props.selectedKey.name) {
151155
return true;
152156
}
157+
if (isAlphanumericWithSpace("Invalid name")(name) !== true) {
158+
return "Key name must only contain letters, numbers, and spaces within the name.";
159+
}
160+
if (name.length > 30) {
161+
return "Please enter a key name with fewer than 30 characters.";
162+
}
153163
const found = props.allKeys.find(key => key.name === name);
154164
return found ? "You have another key with the same name." : true;
155165
}
@@ -161,9 +171,9 @@ export default defineComponent({
161171
currentKey,
162172
sshRules,
163173
loading,
174+
isValidForm,
164175
validateName,
165176
hasChanges,
166-
showTooltip,
167177
};
168178
},
169179
});

packages/playground/src/components/ssh_keys/SshFormDialog.vue

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,16 @@
33
v-model="$props.open"
44
max-width="800"
55
attach="#modals"
6-
@click:outside="() => $emit('close')"
7-
@keydown.esc="() => $emit('close')"
6+
@click:outside="
7+
() => {
8+
if (!generating && !savingKey) $emit('close');
9+
}
10+
"
11+
@keydown.esc="
12+
() => {
13+
if (!generating && !savingKey) $emit('close');
14+
}
15+
"
816
>
917
<template #default>
1018
<v-form v-model="isValidForm">
@@ -23,7 +31,7 @@
2331
>
2432
<v-text-field
2533
v-model="keyName"
26-
hint="Leave this field empty to generate a name automatically, or enter a custom name to save it with your key."
34+
hint="Enter a unique name (letters, numbers, and spaces only, less than 30 characters) to identify your SSH key."
2735
class="mb-4"
2836
hide-details="auto"
2937
label="Name"
@@ -68,7 +76,7 @@
6876
</v-card-text>
6977

7078
<v-card-actions class="justify-end mb-1 mr-2">
71-
<v-btn color="anchor" text="Close" @click="$emit('close')" />
79+
<v-btn color="anchor" :disabled="generating || savingKey" text="Close" @click="$emit('close')" />
7280

7381
<v-btn
7482
v-if="$props.dialogType === SSHCreationMethod.Generate"
@@ -103,6 +111,7 @@ import { type Profile, useProfileManager } from "@/stores/profile_manager";
103111
import { SSHCreationMethod, type SSHKeyData } from "@/types";
104112
import { type Balance, loadBalance } from "@/utils/grid";
105113
import SSHKeysManagement from "@/utils/ssh";
114+
import { isAlphanumericWithSpace } from "@/utils/validators";
106115
107116
const props = defineProps({
108117
open: {
@@ -156,7 +165,7 @@ function generateSSHKey() {
156165
id: keyId,
157166
publicKey: "",
158167
createdAt: sshKeysManagement.formatDate(now),
159-
name: keyName.value,
168+
name: keyName.value.trimEnd(),
160169
isActive: true,
161170
};
162171
@@ -171,7 +180,7 @@ function createNewSSHKey() {
171180
id: keyId,
172181
publicKey: sshKey.value,
173182
createdAt: sshKeysManagement.formatDate(now),
174-
name: keyName.value,
183+
name: keyName.value.trimEnd(),
175184
isActive: true,
176185
};
177186
@@ -185,7 +194,7 @@ function createNewSSHKey() {
185194
}
186195
}
187196
188-
createdKey.value.name = keyName.value;
197+
createdKey.value.name = keyName.value.trimEnd();
189198
190199
emits("save", createdKey.value);
191200
}
@@ -256,8 +265,9 @@ function sshRules(value: any) {
256265
257266
function sshNameRules(value: any) {
258267
return [
268+
(v: string) => !!v || "Key name is required.",
269+
isAlphanumericWithSpace("Key name must only contain letters, numbers, and spaces within the name."),
259270
(v: string) => v.length < 30 || "Please enter a key name with fewer than 30 characters.",
260-
(v: string) => !v.includes(" ") || "Key names cannot include spaces. Please use a name without spaces.",
261271
(v: string) => sshKeysManagement.availableName(v) || "You have another key with the same name.",
262272
];
263273
}

packages/playground/src/utils/profile_manager.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { GridClient } from "@threefold/grid_client";
22

33
import router from "@/router";
4+
import type { SSHKeyData } from "@/types";
45

56
import { createCustomToast, ToastType } from "./custom_toast";
67
import { readEmail } from "./grid";
@@ -33,8 +34,15 @@ export async function handlePostLogin(grid: GridClient, password: string, email?
3334

3435
// Migrate the ssh-key
3536
const sshKeysManagement = new SSHKeysManagement();
37+
let newKeys: SSHKeyData[] = [];
3638
if (!sshKeysManagement.migrated()) {
37-
const newKeys = sshKeysManagement.migrate();
39+
newKeys = sshKeysManagement.migrate();
40+
}
41+
if (sshKeysManagement.needsDefaultNameAssignment()) {
42+
newKeys = sshKeysManagement.assignDefaultNames();
43+
}
44+
if (newKeys.length > 0) {
3845
await sshKeysManagement.update(newKeys);
46+
createCustomToast("SSH keys have been recovered successfully.", ToastType.success);
3947
}
4048
}

packages/playground/src/utils/ssh.ts

Lines changed: 77 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import crypto from "crypto";
33
import { useProfileManager } from "@/stores/profile_manager";
44
import type { SSHKeyData } from "@/types";
55

6+
import { isAlphanumericWithSpace } from "../utils/validators";
67
import { createCustomToast, ToastType } from "./custom_toast";
78
import { getGrid, storeSSH } from "./grid";
89
import { downloadAsJson } from "./helpers";
@@ -11,7 +12,6 @@ import { downloadAsJson } from "./helpers";
1112
* Manages SSH key operations including migration, updating, exporting, deleting, and listing.
1213
*/
1314
class SSHKeysManagement {
14-
private oldKey = "";
1515
updateCost = 0.01;
1616
private words = [
1717
"moon",
@@ -36,20 +36,29 @@ class SSHKeysManagement {
3636
"cosmos",
3737
];
3838

39+
/**
40+
* Can be a string (legacy), SSHKeyData[] (current), or undefined.
41+
* The string type is only for migration and should be removed after migration is complete.
42+
*/
43+
private oldKeys: string | SSHKeyData[] | undefined;
44+
3945
constructor() {
4046
const profileManager = useProfileManager();
41-
this.oldKey = profileManager.profile?.ssh as unknown as string;
47+
this.oldKeys = profileManager.profile?.ssh;
4248
}
4349

4450
/**
4551
* Migrates an old SSH key string to the new SSHKeyData format.
4652
* @returns An array containing the migrated SSHKeyData.
4753
*/
4854
migrate(): SSHKeyData[] {
55+
if (this.migrated()) {
56+
return this.oldKeys as SSHKeyData[];
57+
}
4958
const userKeys: SSHKeyData[] = [];
5059

5160
let keyName = "";
52-
const parts = this.oldKey.split(" ");
61+
const parts = (this.oldKeys as string).split(" ");
5362

5463
if (parts.length < 3) {
5564
keyName = this.generateName()!;
@@ -61,7 +70,7 @@ class SSHKeysManagement {
6170
name: keyName,
6271
id: 1,
6372
isActive: true,
64-
publicKey: this.oldKey,
73+
publicKey: this.oldKeys as string,
6574
};
6675
userKeys.push(newKey);
6776
return userKeys;
@@ -72,7 +81,7 @@ class SSHKeysManagement {
7281
* @returns A boolean indicating whether the key has not been migrated.
7382
*/
7483
migrated(): boolean {
75-
return typeof this.oldKey !== "string";
84+
return typeof this.oldKeys !== "string";
7685
}
7786

7887
/**
@@ -94,24 +103,48 @@ class SSHKeysManagement {
94103

95104
await storeSSH(grid!, copiedKeys);
96105
profileManager.updateSSH(copiedKeys);
106+
this.oldKeys = profileManager.profile?.ssh;
97107
}
98108

99109
/**
100110
* Generates a random name for an SSH key that is not included in the blocked names<user keys>.
101111
* @returns The generated SSH key name.
102112
* @throws Error if all names are blocked.
103113
*/
104-
generateName(): string | null {
105-
// Filter out names that are already used
106-
const blockedNames = this.list().map(key => key.name);
107-
const availableNames = this.words.filter(name => !blockedNames.includes(name));
108-
109-
if (availableNames.length === 0) {
110-
return null;
114+
/**
115+
* Generates a unique name for an SSH key.
116+
* Tries to use the words list for random names, falls back to 'default', 'default1', ...
117+
* @param existingNames Set of already used names
118+
* @param useWords Whether to use the words list (default: true)
119+
*/
120+
private getUniqueName(existingNames: Set<string>, useWords: boolean = true): string {
121+
if (useWords) {
122+
const availableNames = this.words.filter(name => !existingNames.has(name));
123+
if (availableNames.length > 0) {
124+
const name = availableNames[Math.floor(Math.random() * availableNames.length)];
125+
existingNames.add(name);
126+
return name;
127+
}
111128
}
129+
// Fallback to default naming
130+
let i = 0;
131+
let name = "default";
132+
while (existingNames.has(name)) {
133+
i++;
134+
name = `default${i}`;
135+
}
136+
existingNames.add(name);
137+
return name;
138+
}
112139

113-
// Generate a random name from the available names
114-
return availableNames[Math.floor(Math.random() * availableNames.length)];
140+
/**
141+
* Generates a random name for an SSH key that is not included in the blocked names<user keys>.
142+
* @returns The generated SSH key name.
143+
*/
144+
generateName(): string {
145+
const blockedNames = new Set(this.list().map(key => key.name));
146+
// Try to use words, fallback to default naming if all words are used
147+
return this.getUniqueName(blockedNames, true);
115148
}
116149

117150
/**
@@ -166,7 +199,7 @@ class SSHKeysManagement {
166199
let keys: SSHKeyData[] = [];
167200

168201
if (this.migrated()) {
169-
keys = this.oldKey as unknown as SSHKeyData[];
202+
keys = this.oldKeys as unknown as SSHKeyData[];
170203
}
171204

172205
// Profile created for the first time.
@@ -218,6 +251,35 @@ class SSHKeysManagement {
218251
availablePublicKey(publicKey: string): boolean {
219252
return !this.list().some(key => key.publicKey === publicKey);
220253
}
254+
needsDefaultNameAssignment(keys?: SSHKeyData[]): boolean {
255+
if (!keys) {
256+
keys = this.oldKeys as SSHKeyData[];
257+
}
258+
return keys.some(key => isAlphanumericWithSpace("Invalid name")(key.name) !== true);
259+
}
260+
261+
/**
262+
* Checks all user keys for empty names and assigns a unique default name (default, default1, ...).
263+
* Updates the keys in-place and returns the updated array.
264+
* @param keys The SSH keys to check and update.
265+
* @returns The updated array of SSH keys.
266+
*/
267+
assignDefaultNames(keys?: SSHKeyData[]): SSHKeyData[] {
268+
if (!keys) {
269+
keys = this.oldKeys as SSHKeyData[];
270+
}
271+
const existingValidNames = new Set(
272+
keys.map(k => k.name).filter(name => name && isAlphanumericWithSpace("Invalid name")(name) === true),
273+
);
274+
for (const key of keys) {
275+
if (!key.name || isAlphanumericWithSpace("Invalid name")(key.name) !== true) {
276+
key.name = this.getUniqueName(existingValidNames, false);
277+
} else {
278+
existingValidNames.add(key.name);
279+
}
280+
}
281+
return keys;
282+
}
221283
}
222284

223285
export default SSHKeysManagement;

packages/playground/src/utils/validators.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,24 @@ export interface IsAlphanumeric {
158158
locale?: validator.AlphanumericLocale;
159159
options?: validator.IsAlphanumericOptions;
160160
}
161+
/**
162+
* Validates that a string contains only alphanumeric characters and spaces.
163+
* - No leading spaces allowed
164+
* - No consecutive spaces allowed
165+
* - Must contain at least one alphanumeric character
166+
* - Spaces can only appear between alphanumeric characters
167+
* @param msg - The error message to return if validation fails
168+
* @returns A validation function that checks if a string meets the alphanumeric with space criteria
169+
*/
170+
export function isAlphanumericWithSpace(msg: string) {
171+
return (value: string) => {
172+
if (value.endsWith(" ") || !/^[a-zA-Z0-9]+( [a-zA-Z0-9]+)*$/.test(value.trimEnd())) {
173+
return msg;
174+
}
175+
return true;
176+
};
177+
}
178+
161179
export function isAlphanumeric(msg: string, config: IsAlphanumeric = {}) {
162180
const { locale, options } = config;
163181
return (value: string) => {

0 commit comments

Comments
 (0)