diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index 3dfe137..4b875a2 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -1772,11 +1772,25 @@ class ReticulumMeshChat:
)
return self.identity_manager.delete_identity(identity_hash, current_hash)
- def restore_identity_from_bytes(self, identity_bytes: bytes):
- return self.identity_manager.restore_identity_from_bytes(identity_bytes)
+ def restore_identity_from_bytes(
+ self,
+ identity_bytes: bytes,
+ display_name: str | None = None,
+ ):
+ return self.identity_manager.restore_identity_from_bytes(
+ identity_bytes,
+ display_name=display_name,
+ )
- def restore_identity_from_base32(self, base32_value: str):
- return self.identity_manager.restore_identity_from_base32(base32_value)
+ def restore_identity_from_base32(
+ self,
+ base32_value: str,
+ display_name: str | None = None,
+ ):
+ return self.identity_manager.restore_identity_from_base32(
+ base32_value,
+ display_name=display_name,
+ )
def update_identity_metadata_cache(self):
if not hasattr(self, "identity") or not self.identity:
@@ -6086,7 +6100,17 @@ class ReticulumMeshChat:
with open(temp_path, "rb") as f:
identity_bytes = f.read()
os.remove(temp_path)
- result = self.restore_identity_from_bytes(identity_bytes)
+ display_name = None
+ next_field = await reader.next()
+ while next_field is not None:
+ if next_field.name == "display_name":
+ display_name = (await next_field.text()).strip()
+ break
+ next_field = await reader.next()
+ result = self.restore_identity_from_bytes(
+ identity_bytes,
+ display_name=display_name,
+ )
else:
data = await request.json()
base32_value = data.get("base32")
@@ -6095,7 +6119,10 @@ class ReticulumMeshChat:
{"message": "base32 value is required"},
status=400,
)
- result = self.restore_identity_from_base32(base32_value)
+ result = self.restore_identity_from_base32(
+ base32_value,
+ display_name=data.get("display_name"),
+ )
return web.json_response(
{
diff --git a/meshchatx/src/backend/identity_manager.py b/meshchatx/src/backend/identity_manager.py
index f3826a2..3998d05 100644
--- a/meshchatx/src/backend/identity_manager.py
+++ b/meshchatx/src/backend/identity_manager.py
@@ -225,21 +225,32 @@ class IdentityManager:
return True
return False
- def restore_identity_from_bytes(self, identity_bytes: bytes) -> dict:
+ def restore_identity_from_bytes(
+ self,
+ identity_bytes: bytes,
+ display_name: str | None = None,
+ ) -> dict:
try:
# We use RNS.Identity.from_bytes to validate and get the hash
identity = RNS.Identity.from_bytes(identity_bytes)
if not identity:
raise ValueError("Could not load identity from bytes")
- return self._save_new_identity(identity, "Restored Identity")
+ name = (display_name or "").strip() or "Restored Identity"
+ return self._save_new_identity(identity, name)
except Exception as exc:
raise ValueError(f"Failed to restore identity: {exc}") from exc
- def restore_identity_from_base32(self, base32_value: str) -> dict:
+ def restore_identity_from_base32(
+ self,
+ base32_value: str,
+ display_name: str | None = None,
+ ) -> dict:
try:
identity_bytes = base64.b32decode(base32_value, casefold=True)
- return self.restore_identity_from_bytes(identity_bytes)
+ return self.restore_identity_from_bytes(
+ identity_bytes, display_name=display_name
+ )
except Exception as exc:
msg = f"Invalid base32 identity: {exc}"
raise ValueError(msg) from exc
diff --git a/meshchatx/src/frontend/components/TutorialModal.vue b/meshchatx/src/frontend/components/TutorialModal.vue
index 3fd08f6..916a566 100644
--- a/meshchatx/src/frontend/components/TutorialModal.vue
+++ b/meshchatx/src/frontend/components/TutorialModal.vue
@@ -77,7 +77,7 @@
-
-
+
+
+
+
+ {{ $t("tutorial.identity_title") }}
+
+
+ {{ $t("tutorial.identity_desc") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ identityImportError }}
+
+
+
+
+
+
{{ $t("tutorial.connect") }}
@@ -303,8 +400,8 @@
-
-
+
+
{{ $t("tutorial.bootstrap_title") }}
@@ -576,14 +673,14 @@
-
-
+
+
{{ $t("tutorial.propagation") }}
@@ -628,7 +725,7 @@
-
-
+
+
{{ $t("tutorial.learn_create") }}
@@ -871,10 +968,10 @@
-
+
@@ -908,7 +1005,7 @@
-
-
+
+
+
+
+ {{ $t("tutorial.identity_title") }}
+
+
+ {{ $t("tutorial.identity_desc_page") }}
+
+
+
+
+
+
+
+
+ {{ $t("tutorial.identity_new") }}
+
+
+ {{ $t("tutorial.identity_new_desc") }}
+
+
+
+
+
+
+
+ {{ $t("tutorial.identity_import") }}
+
+
+ {{ $t("tutorial.identity_import_desc") }}
+
+
+
+
+
+
+
+
+
+ {{
+ identityImportFile
+ ? identityImportFile.name
+ : $t("tutorial.identity_upload_file")
+ }}
+
+
+
+
+ {{ identityImportError }}
+
+
+
+
+
+
{{ $t("tutorial.connect") }}
@@ -1236,8 +1433,8 @@
-
-
+
+
{{ $t("tutorial.bootstrap_title") }}
@@ -1517,14 +1714,14 @@
{{ $t("tutorial.bootstrap_skip") }}
@@ -1541,8 +1738,8 @@
-
-
+
+
{{ $t("tutorial.propagation") }}
@@ -1566,7 +1763,7 @@
@@ -1581,7 +1778,7 @@
{{ $t("tutorial.propagation_skip_auto") }}
@@ -1599,8 +1796,8 @@
-
-
+
+
{{ $t("tutorial.learn_create") }}
@@ -1853,10 +2050,10 @@
-
+
@@ -1890,7 +2087,7 @@
{{ $t("tutorial.back") }}
@@ -1901,7 +2098,7 @@
{{ $t("tutorial.skip_setup") }}
@@ -1910,8 +2107,9 @@
{{ $t("tutorial.continue") }}
@@ -1919,7 +2117,7 @@
{{ $t("tutorial.finish_setup") }}
@@ -1952,8 +2150,16 @@ export default {
return {
visible: false,
currentStep: 1,
- totalSteps: 6,
+ totalSteps: 7,
logoUrl,
+ identityMode: "new",
+ identityName: "",
+ identityImportBase32: "",
+ identityImportFile: null,
+ identityImportInProgress: false,
+ identityImportError: "",
+ identityImportedHash: null,
+ originalIdentityHash: null,
communityInterfaces: [],
loadingInterfaces: false,
interfaceAddedViaTutorial: false,
@@ -2044,6 +2250,12 @@ export default {
reticulumBundledDocsUrl() {
return bundledReticulumDocsUrl(this.$i18n.locale);
},
+ defaultUsername() {
+ return "Anonymous Peer";
+ },
+ hasIdentityImportInput() {
+ return Boolean(this.identityImportFile || this.identityImportBase32.trim());
+ },
bootstrapSelectedLabels() {
return this.selectedBootstrapKeys.map((k) => this.bootstrapDisplayLabelForKey(k)).filter(Boolean);
},
@@ -2056,7 +2268,7 @@ export default {
this.$nextTick(() => void this.maybeAutoPickBootstrapTcp());
},
currentStep(val) {
- if (val === 3) {
+ if (val === 4) {
this.$nextTick(() => void this.maybeAutoPickBootstrapTcp());
}
},
@@ -2075,6 +2287,7 @@ export default {
};
window.addEventListener("resize", this.onWindowResize, { passive: true });
if (this.isPage) {
+ this.loadIdentitySetupDefaults();
this.loadDiscoveryBootstrapDefaults();
this.loadCommunityInterfaces();
this.loadDiscoveredInterfaces();
@@ -2085,6 +2298,103 @@ export default {
}
},
methods: {
+ resetIdentitySetupState() {
+ this.identityMode = "new";
+ this.identityName = "";
+ this.identityImportBase32 = "";
+ this.identityImportFile = null;
+ this.identityImportError = "";
+ this.identityImportInProgress = false;
+ this.identityImportedHash = null;
+ this.originalIdentityHash = null;
+ },
+ async loadIdentitySetupDefaults() {
+ try {
+ const [identitiesRes, configRes] = await Promise.all([
+ window.api.get("/api/v1/identities"),
+ window.api.get("/api/v1/config"),
+ ]);
+ const identities = identitiesRes.data?.identities ?? [];
+ const currentIdentity = identities.find((item) => item.is_current);
+ this.originalIdentityHash = currentIdentity?.hash || null;
+ this.identityName = configRes.data?.config?.display_name || this.defaultUsername;
+ } catch (e) {
+ console.error("Failed to load identity setup defaults:", e);
+ this.identityName = this.defaultUsername;
+ }
+ },
+ onIdentityImportFileChange(event) {
+ const files = event?.target?.files;
+ this.identityImportFile = files?.[0] || null;
+ this.identityImportError = "";
+ if (event?.target) {
+ event.target.value = "";
+ }
+ },
+ async importIdentityFromFile(file, displayName) {
+ const formData = new FormData();
+ formData.append("file", file);
+ if (displayName) {
+ formData.append("display_name", displayName);
+ }
+ const response = await window.api.post("/api/v1/identity/restore", formData, {
+ headers: { "Content-Type": "multipart/form-data" },
+ });
+ return response.data?.identity?.hash || null;
+ },
+ async importIdentityFromBase32(base32, displayName) {
+ const payload = { base32 };
+ if (displayName) {
+ payload.display_name = displayName;
+ }
+ const response = await window.api.post("/api/v1/identity/restore", payload);
+ return response.data?.identity?.hash || null;
+ },
+ async handleIdentityContinue() {
+ if (this.identityImportInProgress) {
+ return;
+ }
+ const trimmedName = this.identityName.trim() || this.defaultUsername;
+ this.identityImportError = "";
+ if (this.identityMode === "new") {
+ try {
+ await window.api.patch("/api/v1/config", {
+ display_name: trimmedName,
+ });
+ GlobalState.config.display_name = trimmedName;
+ this.identityImportedHash = null;
+ this.currentStep = 3;
+ } catch (e) {
+ this.identityImportError =
+ e.response?.data?.message || this.$t("tutorial.identity_name_update_failed");
+ }
+ return;
+ }
+ if (!this.hasIdentityImportInput) {
+ this.identityImportError = this.$t("tutorial.identity_import_required");
+ return;
+ }
+ this.identityImportInProgress = true;
+ try {
+ let importedHash = null;
+ if (this.identityImportFile) {
+ importedHash = await this.importIdentityFromFile(this.identityImportFile, trimmedName);
+ this.identityImportFile = null;
+ } else {
+ importedHash = await this.importIdentityFromBase32(this.identityImportBase32.trim(), trimmedName);
+ this.identityImportBase32 = "";
+ }
+ if (!importedHash) {
+ throw new Error("Missing imported identity hash");
+ }
+ this.identityImportedHash = importedHash;
+ this.currentStep = 3;
+ } catch (e) {
+ this.identityImportError = e.response?.data?.message || this.$t("tutorial.identity_import_failed");
+ } finally {
+ this.identityImportInProgress = false;
+ }
+ },
async toggleTheme() {
const newTheme = this.config.theme === "dark" ? "light" : "dark";
try {
@@ -2110,6 +2420,7 @@ export default {
async show() {
this.visible = true;
this.currentStep = 1;
+ this.resetIdentitySetupState();
this.interfaceAddedViaTutorial = false;
this.connectionMode = null;
this.selectedBootstrapKeys = [];
@@ -2119,6 +2430,7 @@ export default {
this.bootstrapCommunitySectionOpen = true;
this.bootstrapAutoPickDone = false;
await this.refreshMigrationOffer();
+ await this.loadIdentitySetupDefaults();
await this.loadDiscoveryBootstrapDefaults();
await this.loadCommunityInterfaces();
await this.loadDiscoveredInterfaces();
@@ -2241,7 +2553,7 @@ export default {
this.defaultBootstrapOnly = true;
ToastUtils.success(this.$t("tutorial.discovery_enabled"));
this.connectionMode = "discovery";
- this.currentStep = 3;
+ this.currentStep = 4;
this.bootstrapListSearch = "";
this.bootstrapDiscoveredSectionOpen = true;
this.bootstrapCommunitySectionOpen = true;
@@ -2270,7 +2582,7 @@ export default {
ToastUtils.success(this.$t("tutorial.local_added"));
await this.reloadReticulum();
this.connectionMode = "local";
- this.currentStep = 4;
+ this.currentStep = 5;
} catch (e) {
console.error("Failed to add AutoInterface:", e);
ToastUtils.error(e.response?.data?.message || this.$t("tutorial.failed_add_local"));
@@ -2280,7 +2592,7 @@ export default {
},
useManualMode() {
this.connectionMode = "manual";
- this.currentStep = 4;
+ this.currentStep = 5;
},
isBootstrapSelected(key) {
return this.selectedBootstrapKeys.includes(key);
@@ -2450,7 +2762,7 @@ export default {
if (this.bootstrapAutoPickDone) {
return;
}
- if (this.currentStep !== 3 || this.connectionMode !== "discovery") {
+ if (this.currentStep !== 4 || this.connectionMode !== "discovery") {
return;
}
if (this.selectedBootstrapKeys.length > 0) {
@@ -2557,10 +2869,10 @@ export default {
await this.reloadReticulum();
}
this.addingBootstraps = false;
- this.currentStep = 4;
+ this.currentStep = 5;
},
skipBootstraps() {
- this.currentStep = 4;
+ this.currentStep = 5;
},
async enableAutoPropagation() {
this.savingPropagation = true;
@@ -2665,14 +2977,21 @@ export default {
this.$router.push({ name: routeName });
}
},
+ async handlePrimaryAction() {
+ if (this.currentStep === 2) {
+ await this.handleIdentityContinue();
+ return;
+ }
+ this.nextStep();
+ },
nextStep() {
if (this.currentStep >= this.totalSteps) return;
- if (this.currentStep === 2 && this.connectionMode !== "discovery") {
- this.currentStep = 4;
+ if (this.currentStep === 3 && this.connectionMode !== "discovery") {
+ this.currentStep = 5;
return;
}
this.currentStep++;
- if (this.currentStep === 3) {
+ if (this.currentStep === 4) {
this.bootstrapListSearch = "";
this.bootstrapDiscoveredSectionOpen = true;
this.bootstrapCommunitySectionOpen = true;
@@ -2680,8 +2999,8 @@ export default {
},
previousStep() {
if (this.currentStep <= 1) return;
- if (this.currentStep === 4 && this.connectionMode !== "discovery") {
- this.currentStep = 2;
+ if (this.currentStep === 5 && this.connectionMode !== "discovery") {
+ this.currentStep = 3;
return;
}
this.currentStep--;
@@ -2707,6 +3026,19 @@ export default {
if (GlobalState.hasPendingInterfaceChanges) {
await this.reloadReticulum();
}
+ if (this.identityImportedHash && this.identityImportedHash !== this.originalIdentityHash) {
+ try {
+ await window.api.post("/api/v1/identities/switch", {
+ identity_hash: this.identityImportedHash,
+ });
+ if (this.originalIdentityHash) {
+ await window.api.delete(`/api/v1/identities/${this.originalIdentityHash}`);
+ }
+ } catch (e) {
+ ToastUtils.error(e.response?.data?.message || this.$t("tutorial.identity_switch_failed"));
+ return;
+ }
+ }
this.visible = false;
this.markSeen();
if (this.interfaceAddedViaTutorial) {
@@ -2806,4 +3138,63 @@ export default {
opacity: 0;
transform: translateX(-30px);
}
+
+.tutorial-action-btn {
+ min-height: 2.75rem;
+ padding: 0.625rem 1rem;
+ border-radius: 0.75rem;
+ font-size: 0.875rem;
+ font-weight: 700;
+ line-height: 1.1;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ transition: all 0.15s ease;
+}
+
+.tutorial-action-btn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.tutorial-action-btn-primary {
+ background: rgb(37 99 235);
+ color: white;
+}
+
+.tutorial-action-btn-primary:hover:not(:disabled) {
+ background: rgb(59 130 246);
+}
+
+.tutorial-action-btn-secondary {
+ border: 1px solid rgb(209 213 219);
+ background: white;
+ color: rgb(55 65 81);
+}
+
+.tutorial-action-btn-secondary:hover:not(:disabled) {
+ background: rgb(249 250 251);
+}
+
+.tutorial-action-btn-success {
+ background: rgb(5 150 105);
+ color: white;
+}
+
+.tutorial-action-btn-success:hover:not(:disabled) {
+ background: rgb(16 185 129);
+}
+
+.tutorial-dialog :deep(.dark) .tutorial-action-btn-secondary,
+:deep(.dark) .tutorial-action-btn-secondary {
+ border-color: rgb(63 63 70);
+ background: rgb(39 39 42);
+ color: rgb(212 212 216);
+}
+
+.tutorial-dialog :deep(.dark) .tutorial-action-btn-secondary:hover:not(:disabled),
+:deep(.dark) .tutorial-action-btn-secondary:hover:not(:disabled) {
+ background: rgb(63 63 70);
+}
diff --git a/tests/backend/test_identity_restore.py b/tests/backend/test_identity_restore.py
index 2a523fa..6f469be 100644
--- a/tests/backend/test_identity_restore.py
+++ b/tests/backend/test_identity_restore.py
@@ -76,6 +76,52 @@ class TestIdentityRestore(unittest.TestCase):
# Verify from_bytes was called with the decoded bytes
mock_rns_identity.from_bytes.assert_called_with(identity_bytes)
+ @patch("RNS.Identity")
+ @patch("meshchatx.src.backend.identity_manager.DatabaseProvider")
+ @patch("meshchatx.src.backend.identity_manager.DatabaseSchema")
+ def test_restore_identity_from_base32_with_display_name(
+ self,
+ mock_schema,
+ mock_provider,
+ mock_rns_identity,
+ ):
+ mock_id_instance = MagicMock()
+ mock_id_instance.hash = b"test_hash_32_bytes_long_01234567"
+ mock_id_instance.get_private_key.return_value = b"test_private_key"
+ mock_rns_identity.from_bytes.return_value = mock_id_instance
+
+ identity_bytes = b"some_identity_bytes"
+ base32_value = base64.b32encode(identity_bytes).decode("utf-8")
+ result = self.identity_manager.restore_identity_from_base32(
+ base32_value,
+ display_name="Imported Name",
+ )
+
+ self.assertEqual(result["display_name"], "Imported Name")
+
+ @patch("RNS.Identity")
+ @patch("meshchatx.src.backend.identity_manager.DatabaseProvider")
+ @patch("meshchatx.src.backend.identity_manager.DatabaseSchema")
+ def test_restore_identity_from_base32_blank_display_name_uses_default(
+ self,
+ mock_schema,
+ mock_provider,
+ mock_rns_identity,
+ ):
+ mock_id_instance = MagicMock()
+ mock_id_instance.hash = b"test_hash_32_bytes_long_01234567"
+ mock_id_instance.get_private_key.return_value = b"test_private_key"
+ mock_rns_identity.from_bytes.return_value = mock_id_instance
+
+ identity_bytes = b"some_identity_bytes"
+ base32_value = base64.b32encode(identity_bytes).decode("utf-8")
+ result = self.identity_manager.restore_identity_from_base32(
+ base32_value,
+ display_name=" ",
+ )
+
+ self.assertEqual(result["display_name"], "Restored Identity")
+
@patch("RNS.Identity")
def test_restore_identity_invalid_bytes(self, mock_rns_identity):
mock_rns_identity.from_bytes.return_value = None
diff --git a/tests/backend/test_identity_restore_http_api.py b/tests/backend/test_identity_restore_http_api.py
new file mode 100644
index 0000000..fc5143b
--- /dev/null
+++ b/tests/backend/test_identity_restore_http_api.py
@@ -0,0 +1,99 @@
+# SPDX-License-Identifier: 0BSD
+
+"""HTTP tests for POST /api/v1/identity/restore."""
+
+from __future__ import annotations
+
+from unittest.mock import MagicMock
+
+import pytest
+from aiohttp import FormData, web
+from aiohttp.test_utils import TestClient, TestServer
+
+pytestmark = pytest.mark.usefixtures("require_loopback_tcp")
+
+
+def _build_aio_app(app):
+ routes = web.RouteTableDef()
+ auth_mw, mime_mw, sec_mw = app._define_routes(routes)
+ aio_app = web.Application(middlewares=[auth_mw, mime_mw, sec_mw])
+ aio_app.add_routes(routes)
+ return aio_app
+
+
+@pytest.fixture
+def web_identity_app(mock_app):
+ mock_app.current_context.running = True
+ mock_app.config.auth_enabled.set(False)
+ return mock_app
+
+
+@pytest.mark.asyncio
+async def test_post_identity_restore_base32_passes_display_name(web_identity_app):
+ web_identity_app.restore_identity_from_base32 = MagicMock(
+ return_value={"hash": "abc123", "display_name": "Imported Name"}
+ )
+ aio_app = _build_aio_app(web_identity_app)
+
+ async with TestClient(TestServer(aio_app)) as client:
+ response = await client.post(
+ "/api/v1/identity/restore",
+ json={
+ "base32": "AAAA",
+ "display_name": "Imported Name",
+ },
+ )
+ assert response.status == 200
+ data = await response.json()
+
+ assert data["identity"]["hash"] == "abc123"
+ web_identity_app.restore_identity_from_base32.assert_called_once_with(
+ "AAAA",
+ display_name="Imported Name",
+ )
+
+
+@pytest.mark.asyncio
+async def test_post_identity_restore_returns_400_when_base32_missing(web_identity_app):
+ web_identity_app.restore_identity_from_base32 = MagicMock()
+ aio_app = _build_aio_app(web_identity_app)
+
+ async with TestClient(TestServer(aio_app)) as client:
+ response = await client.post(
+ "/api/v1/identity/restore", json={"display_name": "Any"}
+ )
+ assert response.status == 400
+ data = await response.json()
+
+ assert data["message"] == "base32 value is required"
+ web_identity_app.restore_identity_from_base32.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_post_identity_restore_multipart_file_passes_display_name(
+ web_identity_app,
+):
+ web_identity_app.restore_identity_from_bytes = MagicMock(
+ return_value={"hash": "filehash", "display_name": "From File"}
+ )
+ aio_app = _build_aio_app(web_identity_app)
+
+ form = FormData()
+ form.add_field(
+ "file",
+ b"file-bytes",
+ filename="identity.key",
+ content_type="application/octet-stream",
+ )
+ form.add_field("display_name", "From File")
+
+ async with TestClient(TestServer(aio_app)) as client:
+ response = await client.post("/api/v1/identity/restore", data=form)
+ assert response.status == 200
+ data = await response.json()
+
+ assert data["identity"]["hash"] == "filehash"
+ web_identity_app.restore_identity_from_bytes.assert_called_once()
+ call_args, call_kwargs = web_identity_app.restore_identity_from_bytes.call_args
+ assert call_args[0] == b"file-bytes"
+ assert call_kwargs["display_name"] == "From File"
diff --git a/tests/frontend/TutorialModalMigration.test.js b/tests/frontend/TutorialModalMigration.test.js
index 7ef1ae7..e6273df 100644
--- a/tests/frontend/TutorialModalMigration.test.js
+++ b/tests/frontend/TutorialModalMigration.test.js
@@ -53,6 +53,16 @@ function discoveryApiHandlers(migrationPayload) {
if (url === "/api/v1/reticulum/discovered-interfaces") {
return Promise.resolve({ data: { interfaces: [], active: [] } });
}
+ if (url === "/api/v1/config") {
+ return Promise.resolve({ data: { config: { display_name: "Anonymous Peer" } } });
+ }
+ if (url === "/api/v1/identities") {
+ return Promise.resolve({
+ data: {
+ identities: [{ hash: "default_identity", display_name: "Anonymous Peer", is_current: true }],
+ },
+ });
+ }
return Promise.resolve({ data: {} });
};
}
@@ -370,4 +380,231 @@ describe("TutorialModal getting started migration", () => {
wrapper.unmount();
});
+
+ it("identity step new mode applies display name and continues", async () => {
+ axiosMock.get.mockImplementation(discoveryApiHandlers({ show_choice: false }));
+ axiosMock.patch.mockResolvedValue({ data: {} });
+
+ const router = createRouter({
+ history: createWebHashHistory(),
+ routes: [{ path: "/", name: "home", component: { template: "" } }],
+ });
+ await router.push("/");
+ await router.isReady();
+
+ const wrapper = mount(TutorialModal, {
+ attachTo: document.body,
+ global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
+ });
+
+ await wrapper.vm.show();
+ await flushPromises();
+ wrapper.vm.currentStep = 2;
+ wrapper.vm.identityMode = "new";
+ wrapper.vm.identityName = "Mesh User";
+ await wrapper.vm.handlePrimaryAction();
+
+ expect(axiosMock.patch).toHaveBeenCalledWith("/api/v1/config", { display_name: "Mesh User" });
+ expect(wrapper.vm.currentStep).toBe(3);
+ wrapper.unmount();
+ });
+
+ it("identity step import base32 switches to imported and deletes default on finish", async () => {
+ axiosMock.get.mockImplementation(discoveryApiHandlers({ show_choice: false }));
+ axiosMock.post.mockImplementation((url, body) => {
+ if (url === "/api/v1/identity/restore") {
+ expect(body).toEqual({
+ base32: "ABCD1234",
+ display_name: "Imported User",
+ });
+ return Promise.resolve({
+ data: { identity: { hash: "imported_hash" }, message: "ok" },
+ });
+ }
+ if (url === "/api/v1/identities/switch") {
+ expect(body).toEqual({ identity_hash: "imported_hash" });
+ return Promise.resolve({ data: { hotswapped: true } });
+ }
+ if (url === "/api/v1/app/tutorial/seen") {
+ return Promise.resolve({ data: {} });
+ }
+ return Promise.resolve({ data: {} });
+ });
+ axiosMock.delete = vi.fn().mockResolvedValue({ data: {} });
+
+ const router = createRouter({
+ history: createWebHashHistory(),
+ routes: [{ path: "/", name: "home", component: { template: "" } }],
+ });
+ await router.push("/");
+ await router.isReady();
+
+ const wrapper = mount(TutorialModal, {
+ attachTo: document.body,
+ global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
+ });
+
+ await wrapper.vm.show();
+ await flushPromises();
+
+ wrapper.vm.currentStep = 2;
+ wrapper.vm.identityMode = "import";
+ wrapper.vm.identityName = "Imported User";
+ wrapper.vm.identityImportBase32 = "ABCD1234";
+ await wrapper.vm.handlePrimaryAction();
+
+ expect(wrapper.vm.identityImportedHash).toBe("imported_hash");
+ expect(wrapper.vm.currentStep).toBe(3);
+
+ wrapper.vm.currentStep = wrapper.vm.totalSteps;
+ await wrapper.vm.finishTutorial();
+ expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/identities/switch", {
+ identity_hash: "imported_hash",
+ });
+ expect(axiosMock.delete).toHaveBeenCalledWith("/api/v1/identities/default_identity");
+ wrapper.unmount();
+ });
+
+ it("identity import mode requires file or base32 input", async () => {
+ axiosMock.get.mockImplementation(discoveryApiHandlers({ show_choice: false }));
+
+ const router = createRouter({
+ history: createWebHashHistory(),
+ routes: [{ path: "/", name: "home", component: { template: "" } }],
+ });
+ await router.push("/");
+ await router.isReady();
+
+ const wrapper = mount(TutorialModal, {
+ attachTo: document.body,
+ global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
+ });
+
+ await wrapper.vm.show();
+ await flushPromises();
+
+ wrapper.vm.currentStep = 2;
+ wrapper.vm.identityMode = "import";
+ wrapper.vm.identityImportBase32 = " ";
+ await wrapper.vm.handlePrimaryAction();
+
+ expect(wrapper.vm.currentStep).toBe(2);
+ expect(wrapper.vm.identityImportError).toBe(en.tutorial.identity_import_required);
+ expect(axiosMock.post).not.toHaveBeenCalled();
+ wrapper.unmount();
+ });
+
+ it("identity step new mode falls back to default username for blank input", async () => {
+ axiosMock.get.mockImplementation(discoveryApiHandlers({ show_choice: false }));
+ axiosMock.patch.mockResolvedValue({ data: {} });
+
+ const router = createRouter({
+ history: createWebHashHistory(),
+ routes: [{ path: "/", name: "home", component: { template: "" } }],
+ });
+ await router.push("/");
+ await router.isReady();
+
+ const wrapper = mount(TutorialModal, {
+ attachTo: document.body,
+ global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
+ });
+
+ await wrapper.vm.show();
+ await flushPromises();
+ wrapper.vm.currentStep = 2;
+ wrapper.vm.identityMode = "new";
+ wrapper.vm.identityName = " ";
+ await wrapper.vm.handlePrimaryAction();
+
+ expect(axiosMock.patch).toHaveBeenCalledWith("/api/v1/config", { display_name: "Anonymous Peer" });
+ expect(wrapper.vm.currentStep).toBe(3);
+ wrapper.unmount();
+ });
+
+ it("identity import continue is race-safe and only submits one restore request", async () => {
+ axiosMock.get.mockImplementation(discoveryApiHandlers({ show_choice: false }));
+ let resolveRestore;
+ const restorePromise = new Promise((resolve) => {
+ resolveRestore = resolve;
+ });
+ axiosMock.post.mockImplementation((url) => {
+ if (url === "/api/v1/identity/restore") {
+ return restorePromise;
+ }
+ return Promise.resolve({ data: {} });
+ });
+
+ const router = createRouter({
+ history: createWebHashHistory(),
+ routes: [{ path: "/", name: "home", component: { template: "" } }],
+ });
+ await router.push("/");
+ await router.isReady();
+
+ const wrapper = mount(TutorialModal, {
+ attachTo: document.body,
+ global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
+ });
+
+ await wrapper.vm.show();
+ await flushPromises();
+ wrapper.vm.currentStep = 2;
+ wrapper.vm.identityMode = "import";
+ wrapper.vm.identityName = "Race User";
+ wrapper.vm.identityImportBase32 = "RACEKEY";
+
+ const p1 = wrapper.vm.handlePrimaryAction();
+ const p2 = wrapper.vm.handlePrimaryAction();
+ await flushPromises();
+
+ expect(axiosMock.post).toHaveBeenCalledTimes(1);
+ resolveRestore({
+ data: {
+ identity: { hash: "race_hash" },
+ },
+ });
+ await Promise.all([p1, p2]);
+
+ expect(wrapper.vm.identityImportedHash).toBe("race_hash");
+ expect(wrapper.vm.currentStep).toBe(3);
+ wrapper.unmount();
+ });
+
+ it("finishTutorial keeps modal open and reports error when identity switch fails", async () => {
+ axiosMock.get.mockImplementation(discoveryApiHandlers({ show_choice: false }));
+ axiosMock.post.mockImplementation((url) => {
+ if (url === "/api/v1/identities/switch") {
+ return Promise.reject({ response: { data: { message: "switch failed" } } });
+ }
+ return Promise.resolve({ data: {} });
+ });
+ axiosMock.delete = vi.fn().mockResolvedValue({ data: {} });
+
+ const router = createRouter({
+ history: createWebHashHistory(),
+ routes: [{ path: "/", name: "home", component: { template: "" } }],
+ });
+ await router.push("/");
+ await router.isReady();
+
+ const wrapper = mount(TutorialModal, {
+ attachTo: document.body,
+ global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
+ });
+
+ await wrapper.vm.show();
+ await flushPromises();
+ wrapper.vm.visible = true;
+ wrapper.vm.currentStep = wrapper.vm.totalSteps;
+ wrapper.vm.identityImportedHash = "imported_hash";
+ wrapper.vm.originalIdentityHash = "default_identity";
+
+ await wrapper.vm.finishTutorial();
+
+ expect(wrapper.vm.visible).toBe(true);
+ expect(axiosMock.delete).not.toHaveBeenCalled();
+ expect(ToastUtils.error).toHaveBeenCalledWith("switch failed");
+ wrapper.unmount();
+ });
});