diff --git a/packages/playground/src/components/node_details.vue b/packages/playground/src/components/node_details.vue index 15166c48cf..6f47332144 100644 --- a/packages/playground/src/components/node_details.vue +++ b/packages/playground/src/components/node_details.vue @@ -11,14 +11,8 @@ > - $emit('close-dialog', false)" - > - - mdi-close - + $emit('close-dialog', false)"> + mdi-close @@ -33,87 +27,37 @@ - + {{ errorMessage }} - - closeDialog(val)" - /> + + closeDialog(val)" /> - - - + + + - - + + - + - + - - - + + + @@ -143,7 +87,6 @@ import { getNode, getNodeStatusColor } from "@/utils/get_nodes"; import IPerfCard from "./node_details_cards/iperf_details_card.vue"; import NodeResourcesCharts from "./node_resources_charts.vue"; export default { - components: { NodeResourcesCharts, NodeDetailsCard, diff --git a/packages/playground/src/components/profile_manager/ConnectWallet.vue b/packages/playground/src/components/profile_manager/ConnectWallet.vue index c8c4d40a52..99be7d3d75 100644 --- a/packages/playground/src/components/profile_manager/ConnectWallet.vue +++ b/packages/playground/src/components/profile_manager/ConnectWallet.vue @@ -336,12 +336,12 @@ async function storeAndLogin() { const grid = await getGrid({ mnemonic: mnemonic.value, keypairType: keypairType.value }); storeEmail(grid!, email.value); setCredentials(md5(password.value), mnemonicHash, keypairTypeHash, md5(email.value)); - await handlePostLogin(grid!, password.value, email.value); const profile = await loadProfile(grid!); if (email.value && profile.email !== email.value) { profile.email = email.value; } profileManager.set({ ...profile, mnemonic: mnemonic.value }); + await handlePostLogin(grid!, password.value, email.value); } catch (e) { if (e instanceof TwinNotExistError) { isNonActiveMnemonic.value = true; diff --git a/packages/playground/src/components/profile_manager/LoginWallet.vue b/packages/playground/src/components/profile_manager/LoginWallet.vue index 9c25e4eb6f..5bf448e620 100644 --- a/packages/playground/src/components/profile_manager/LoginWallet.vue +++ b/packages/playground/src/components/profile_manager/LoginWallet.vue @@ -74,8 +74,8 @@ async function login() { : KeypairType.sr25519; const grid = await getGrid({ mnemonic: mnemonic, keypairType: keypairType as KeypairType }); - await handlePostLogin(grid!, password.value); profileManager.set({ ...(await loadProfile(grid!)), mnemonic }); + await handlePostLogin(grid!, password.value); emit("closeDialog"); } } else { diff --git a/packages/playground/src/components/ssh_keys/SshDataDialog.vue b/packages/playground/src/components/ssh_keys/SshDataDialog.vue index ebeb442b37..6b47f1fedd 100644 --- a/packages/playground/src/components/ssh_keys/SshDataDialog.vue +++ b/packages/playground/src/components/ssh_keys/SshDataDialog.vue @@ -9,9 +9,7 @@ - - SSH-Key Details - + SSH-Key Details @@ -22,7 +20,10 @@ v-model="currentKey[_key as keyof SSHKeyData]" :label="_key" :readonly="_key === 'fingerPrint'" - :rules="[(value: string) => !!value || `${_key} is required.`, _key === 'name' ? validateName(currentKey.name): true]" + :rules="[ + (value: string) => !!value || `${_key} is required.`, + _key === 'name' ? validateName(currentKey.name) : true, + ]" /> @@ -41,12 +42,8 @@ - - Active - - - Inactive - + Active + Inactive @@ -74,6 +71,7 @@ import { capitalize, defineComponent, type PropType, ref, watch } from "vue"; import type { SSHKeyData } from "@/types"; import SSHKeysManagement from "@/utils/ssh"; +import { isAlphanumericWithSpace } from "@/utils/validators"; export default defineComponent({ name: "SSHDataDialog", @@ -133,6 +131,12 @@ export default defineComponent({ if (name === props.selectedKey.name) { return true; } + if (isAlphanumericWithSpace("Invalid name")(name) !== true) { + return "Key name must only contain letters, numbers, and spaces within the name."; + } + if (name.length > 30) { + return "Please enter a key name with fewer than 30 characters."; + } const found = props.allKeys.find(key => key.name === name); return found ? "You have another key with the same name." : true; } diff --git a/packages/playground/src/components/ssh_keys/SshFormDialog.vue b/packages/playground/src/components/ssh_keys/SshFormDialog.vue index 872c826c98..755fb7b6bd 100644 --- a/packages/playground/src/components/ssh_keys/SshFormDialog.vue +++ b/packages/playground/src/components/ssh_keys/SshFormDialog.vue @@ -3,8 +3,16 @@ v-model="$props.open" max-width="800" attach="#modals" - @click:outside="() => $emit('close')" - @keydown.esc="() => $emit('close')" + @click:outside=" + () => { + if (!generating && !savingKey) $emit('close'); + } + " + @keydown.esc=" + () => { + if (!generating && !savingKey) $emit('close'); + } + " > @@ -23,7 +31,7 @@ > - + !!v || "Key name is required.", + isAlphanumericWithSpace("Key name must only contain letters, numbers, and spaces within the name."), (v: string) => v.length < 30 || "Please enter a key name with fewer than 30 characters.", - (v: string) => !v.includes(" ") || "Key names cannot include spaces. Please use a name without spaces.", (v: string) => sshKeysManagement.availableName(v) || "You have another key with the same name.", ]; } diff --git a/packages/playground/src/utils/profile_manager.ts b/packages/playground/src/utils/profile_manager.ts index 98e487ccbe..97a69efb96 100644 --- a/packages/playground/src/utils/profile_manager.ts +++ b/packages/playground/src/utils/profile_manager.ts @@ -1,6 +1,7 @@ import type { GridClient } from "@threefold/grid_client"; import router from "@/router"; +import type { SSHKeyData } from "@/types"; import { createCustomToast, ToastType } from "./custom_toast"; import { readEmail } from "./grid"; @@ -33,8 +34,15 @@ export async function handlePostLogin(grid: GridClient, password: string, email? // Migrate the ssh-key const sshKeysManagement = new SSHKeysManagement(); + let newKeys: SSHKeyData[] = []; if (!sshKeysManagement.migrated()) { - const newKeys = sshKeysManagement.migrate(); + newKeys = sshKeysManagement.migrate(); + } + if (sshKeysManagement.needsDefaultNameAssignment()) { + newKeys = sshKeysManagement.assignDefaultNames(); + } + if (newKeys.length > 0) { await sshKeysManagement.update(newKeys); + createCustomToast("SSH keys have been recovered successfully.", ToastType.success); } } diff --git a/packages/playground/src/utils/ssh.ts b/packages/playground/src/utils/ssh.ts index 5646ecd24a..4292fd3851 100644 --- a/packages/playground/src/utils/ssh.ts +++ b/packages/playground/src/utils/ssh.ts @@ -3,6 +3,7 @@ import crypto from "crypto"; import { useProfileManager } from "@/stores/profile_manager"; import type { SSHKeyData } from "@/types"; +import { isAlphanumericWithSpace } from "../utils/validators"; import { createCustomToast, ToastType } from "./custom_toast"; import { getGrid, storeSSH } from "./grid"; import { downloadAsJson } from "./helpers"; @@ -11,7 +12,6 @@ import { downloadAsJson } from "./helpers"; * Manages SSH key operations including migration, updating, exporting, deleting, and listing. */ class SSHKeysManagement { - private oldKey = ""; updateCost = 0.01; private words = [ "moon", @@ -36,9 +36,15 @@ class SSHKeysManagement { "cosmos", ]; + /** + * Can be a string (legacy), SSHKeyData[] (current), or undefined. + * The string type is only for migration and should be removed after migration is complete. + */ + private oldKeys: string | SSHKeyData[] | undefined; + constructor() { const profileManager = useProfileManager(); - this.oldKey = profileManager.profile?.ssh as unknown as string; + this.oldKeys = profileManager.profile?.ssh; } /** @@ -46,10 +52,13 @@ class SSHKeysManagement { * @returns An array containing the migrated SSHKeyData. */ migrate(): SSHKeyData[] { + if (this.migrated()) { + return this.oldKeys as SSHKeyData[]; + } const userKeys: SSHKeyData[] = []; let keyName = ""; - const parts = this.oldKey.split(" "); + const parts = (this.oldKeys as string).split(" "); if (parts.length < 3) { keyName = this.generateName()!; @@ -61,7 +70,7 @@ class SSHKeysManagement { name: keyName, id: 1, isActive: true, - publicKey: this.oldKey, + publicKey: this.oldKeys as string, }; userKeys.push(newKey); return userKeys; @@ -72,7 +81,7 @@ class SSHKeysManagement { * @returns A boolean indicating whether the key has not been migrated. */ migrated(): boolean { - return typeof this.oldKey !== "string"; + return typeof this.oldKeys !== "string"; } /** @@ -94,6 +103,7 @@ class SSHKeysManagement { await storeSSH(grid!, copiedKeys); profileManager.updateSSH(copiedKeys); + this.oldKeys = profileManager.profile?.ssh; } /** @@ -101,17 +111,40 @@ class SSHKeysManagement { * @returns The generated SSH key name. * @throws Error if all names are blocked. */ - generateName(): string | null { - // Filter out names that are already used - const blockedNames = this.list().map(key => key.name); - const availableNames = this.words.filter(name => !blockedNames.includes(name)); - - if (availableNames.length === 0) { - return null; + /** + * Generates a unique name for an SSH key. + * Tries to use the words list for random names, falls back to 'default', 'default1', ... + * @param existingNames Set of already used names + * @param useWords Whether to use the words list (default: true) + */ + private getUniqueName(existingNames: Set, useWords: boolean = true): string { + if (useWords) { + const availableNames = this.words.filter(name => !existingNames.has(name)); + if (availableNames.length > 0) { + const name = availableNames[Math.floor(Math.random() * availableNames.length)]; + existingNames.add(name); + return name; + } } + // Fallback to default naming + let i = 0; + let name = "default"; + while (existingNames.has(name)) { + i++; + name = `default${i}`; + } + existingNames.add(name); + return name; + } - // Generate a random name from the available names - return availableNames[Math.floor(Math.random() * availableNames.length)]; + /** + * Generates a random name for an SSH key that is not included in the blocked names. + * @returns The generated SSH key name. + */ + generateName(): string { + const blockedNames = new Set(this.list().map(key => key.name)); + // Try to use words, fallback to default naming if all words are used + return this.getUniqueName(blockedNames, true); } /** @@ -166,7 +199,7 @@ class SSHKeysManagement { let keys: SSHKeyData[] = []; if (this.migrated()) { - keys = this.oldKey as unknown as SSHKeyData[]; + keys = this.oldKeys as unknown as SSHKeyData[]; } // Profile created for the first time. @@ -218,6 +251,31 @@ class SSHKeysManagement { availablePublicKey(publicKey: string): boolean { return !this.list().some(key => key.publicKey === publicKey); } + needsDefaultNameAssignment(keys?: SSHKeyData[]): boolean { + if (!keys) { + keys = this.oldKeys as SSHKeyData[]; + } + return keys.some(key => isAlphanumericWithSpace("Invalid name")(key.name) !== true); + } + + /** + * Checks all user keys for empty names and assigns a unique default name (default, default1, ...). + * Updates the keys in-place and returns the updated array. + * @param keys The SSH keys to check and update. + * @returns The updated array of SSH keys. + */ + assignDefaultNames(keys?: SSHKeyData[]): SSHKeyData[] { + if (!keys) { + keys = this.oldKeys as SSHKeyData[]; + } + const existingNames = new Set(keys.map(k => k.name).filter(Boolean)); + for (const key of keys) { + if (isAlphanumericWithSpace("Invalid name")(key.name) !== true) { + key.name = this.getUniqueName(existingNames, false); + } + } + return keys; + } } export default SSHKeysManagement; diff --git a/packages/playground/src/utils/validators.ts b/packages/playground/src/utils/validators.ts index 506cd657ea..d9f95d5ca9 100644 --- a/packages/playground/src/utils/validators.ts +++ b/packages/playground/src/utils/validators.ts @@ -158,6 +158,28 @@ export interface IsAlphanumeric { locale?: validator.AlphanumericLocale; options?: validator.IsAlphanumericOptions; } +/** + * Validates that a string contains only alphanumeric characters and spaces. + * - No leading spaces allowed + * - No consecutive spaces allowed + * - Must contain at least one alphanumeric character + * - Spaces can only appear between alphanumeric characters + * @param msg - The error message to return if validation fails + * @returns A validation function that checks if a string meets the alphanumeric with space criteria + */ +export function isAlphanumericWithSpace(msg: string) { + return (value: string) => { + if (value.endsWith(" ")) { + return msg; + } + value = value.trimEnd(); + if (!/^[a-zA-Z0-9]+( [a-zA-Z0-9]+)*$/.test(value)) { + return msg; + } + return true; + }; +} + export function isAlphanumeric(msg: string, config: IsAlphanumeric = {}) { const { locale, options } = config; return (value: string) => { diff --git a/packages/playground/src/views/sshkey_view.vue b/packages/playground/src/views/sshkey_view.vue index 950add6fd9..7045eec763 100644 --- a/packages/playground/src/views/sshkey_view.vue +++ b/packages/playground/src/views/sshkey_view.vue @@ -139,21 +139,23 @@ const sshKeysManagement = new SSHKeysManagement(); onMounted(async () => { loading.value = true; - if (!sshKeysManagement.migrated()) { - tableLoadingMessage.value = "Migrating your old key..."; + if (!sshKeysManagement.migrated() || sshKeysManagement.needsDefaultNameAssignment()) { + tableLoadingMessage.value = "Recovering your old key..."; const migrationInterval = setInterval(async () => { - const migrated = !sshKeysManagement.migrated(); - if (migrated) { + const migrated = sshKeysManagement.migrated(); + const nameUpdated = !sshKeysManagement.needsDefaultNameAssignment(); + if (migrated && nameUpdated) { clearInterval(migrationInterval); allKeys.value = sshKeysManagement.list(); tableLoadingMessage.value = ""; + loading.value = false; } }, 1000); } else { allKeys.value = sshKeysManagement.list(); tableLoadingMessage.value = ""; + loading.value = false; } - loading.value = false; }); const openDialog = (type: SSHCreationMethod) => { diff --git a/packages/playground/tests/utils/isAlphanumericWithSpace.test.ts b/packages/playground/tests/utils/isAlphanumericWithSpace.test.ts new file mode 100644 index 0000000000..f8f6895719 --- /dev/null +++ b/packages/playground/tests/utils/isAlphanumericWithSpace.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import { isAlphanumericWithSpace } from "../../src/utils/validators"; + +describe("isAlphanumericWithSpace", () => { + const validator = isAlphanumericWithSpace("Invalid input"); + + it("should allow alphanumeric characters and spaces inside only", () => { + expect(validator("Hello World 123")).toBe(true); + expect(validator("A1 B2 C3")).toBe(true); + expect(validator("abcXYZ 789")).toBe(true); + expect(validator("abc 123 xyz")).toBe(true); + }); + + it("Should allow single trailing space", () => { + expect(validator("Hello World ")).toBe(true); + }); + + it("should not allow leading or multiple trailing spaces, or only spaces", () => { + expect(validator(" HelloWorld")).toBe("Invalid input"); + expect(validator("HelloWorld ")).toBe("Invalid input"); + expect(validator("Hello World")).toBe("Invalid input"); + expect(validator(" ")).toBe("Invalid input"); + expect(validator("")).toBe("Invalid input"); + expect(validator(" ")).toBe("Invalid input"); + }); + + it("should not allow special characters", () => { + expect(validator("Hello@World")).toBe("Invalid input"); + expect(validator("Test!123")).toBe("Invalid input"); + expect(validator("Name_123")).toBe("Invalid input"); + expect(validator("Dash-Here")).toBe("Invalid input"); + }); + + it("should not allow empty string", () => { + expect(validator("")).toBe("Invalid input"); + }); +});
{{ errorMessage }}
- SSH-Key Details -
SSH-Key Details