mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-05-11 07:26:53 +00:00
feat(identity): add identity screen to getting started.
This commit is contained in:
+33
-6
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
<div class="flex flex-col sm:flex-row gap-2 justify-stretch sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium disabled:opacity-50"
|
||||
class="tutorial-action-btn tutorial-action-btn-primary"
|
||||
:disabled="migrationBusy"
|
||||
@click="migrationMigrate"
|
||||
>
|
||||
@@ -85,7 +85,7 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 rounded-lg border border-gray-300 dark:border-zinc-600 bg-white dark:bg-zinc-900 text-sm font-medium disabled:opacity-50"
|
||||
class="tutorial-action-btn tutorial-action-btn-secondary"
|
||||
:disabled="migrationBusy"
|
||||
@click="migrationFresh"
|
||||
>
|
||||
@@ -208,8 +208,105 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Choose Connection Mode -->
|
||||
<div v-else-if="currentStep === 2" key="step2-mode" class="space-y-6">
|
||||
<!-- Step 2: Identity Setup -->
|
||||
<div v-else-if="currentStep === 2" key="step2-identity" class="space-y-6">
|
||||
<div class="text-center space-y-2">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{{ $t("tutorial.identity_title") }}
|
||||
</h2>
|
||||
<p class="text-gray-600 dark:text-zinc-400 text-base">
|
||||
{{ $t("tutorial.identity_desc") }}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
ref="identityImportFileInput"
|
||||
type="file"
|
||||
accept=".identity,.bin,.key"
|
||||
class="hidden"
|
||||
@change="onIdentityImportFileChange"
|
||||
/>
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="text-left flex items-start gap-4 p-5 rounded-2xl border-2 transition-all"
|
||||
:class="
|
||||
identityMode === 'new'
|
||||
? 'border-blue-500 bg-blue-500/5'
|
||||
: 'border-gray-200 dark:border-zinc-700 hover:border-blue-400'
|
||||
"
|
||||
@click="identityMode = 'new'"
|
||||
>
|
||||
<v-icon icon="mdi-account-plus-outline" color="blue" size="34"></v-icon>
|
||||
<div>
|
||||
<div class="font-bold text-gray-900 dark:text-white">
|
||||
{{ $t("tutorial.identity_new") }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-zinc-400">
|
||||
{{ $t("tutorial.identity_new_desc") }}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-left flex items-start gap-4 p-5 rounded-2xl border-2 transition-all"
|
||||
:class="
|
||||
identityMode === 'import'
|
||||
? 'border-blue-500 bg-blue-500/5'
|
||||
: 'border-gray-200 dark:border-zinc-700 hover:border-blue-400'
|
||||
"
|
||||
@click="identityMode = 'import'"
|
||||
>
|
||||
<v-icon icon="mdi-file-import-outline" color="indigo" size="34"></v-icon>
|
||||
<div>
|
||||
<div class="font-bold text-gray-900 dark:text-white">
|
||||
{{ $t("tutorial.identity_import") }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-zinc-400">
|
||||
{{ $t("tutorial.identity_import_desc") }}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-gray-200 dark:border-zinc-700 p-4 space-y-3">
|
||||
<label class="block text-sm font-semibold text-gray-700 dark:text-zinc-200">
|
||||
{{ $t("tutorial.identity_set_name") }}
|
||||
</label>
|
||||
<input
|
||||
v-model="identityName"
|
||||
type="text"
|
||||
:placeholder="defaultUsername"
|
||||
class="w-full rounded-xl border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-3 py-2 text-sm text-gray-900 dark:text-zinc-100"
|
||||
/>
|
||||
<div
|
||||
v-if="identityMode === 'import'"
|
||||
class="space-y-3 pt-2 border-t border-gray-200 dark:border-zinc-800"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="tutorial-action-btn tutorial-action-btn-secondary w-full justify-center"
|
||||
@click="$refs.identityImportFileInput?.click()"
|
||||
>
|
||||
{{
|
||||
identityImportFile
|
||||
? identityImportFile.name
|
||||
: $t("tutorial.identity_upload_file")
|
||||
}}
|
||||
</button>
|
||||
<textarea
|
||||
v-model="identityImportBase32"
|
||||
rows="3"
|
||||
class="w-full rounded-xl border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-3 py-2 text-xs font-mono text-gray-900 dark:text-zinc-100"
|
||||
:placeholder="$t('tutorial.identity_base32_placeholder')"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="identityImportError" class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ identityImportError }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Choose Connection Mode -->
|
||||
<div v-else-if="currentStep === 3" key="step3-mode" class="space-y-6">
|
||||
<div class="text-center space-y-2">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{{ $t("tutorial.connect") }}
|
||||
@@ -303,8 +400,8 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Bootstrap Selection -->
|
||||
<div v-else-if="currentStep === 3" key="step3-bootstrap" class="space-y-6">
|
||||
<!-- Step 4: Bootstrap Selection -->
|
||||
<div v-else-if="currentStep === 4" key="step4-bootstrap" class="space-y-6">
|
||||
<div class="text-center space-y-2">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{{ $t("tutorial.bootstrap_title") }}
|
||||
@@ -576,14 +673,14 @@
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 text-xs rounded-xl border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 font-semibold transition-all hover:bg-gray-50 dark:hover:bg-zinc-700"
|
||||
class="tutorial-action-btn tutorial-action-btn-secondary"
|
||||
@click="skipBootstraps"
|
||||
>
|
||||
{{ $t("tutorial.bootstrap_skip") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-5 py-2 text-xs rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-bold shadow-sm transition-all"
|
||||
class="tutorial-action-btn tutorial-action-btn-success"
|
||||
:disabled="
|
||||
addingBootstraps || reloadingReticulum || selectedBootstrapCount === 0
|
||||
"
|
||||
@@ -603,8 +700,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: Propagation Mode -->
|
||||
<div v-else-if="currentStep === 4" key="step4-prop" class="space-y-6">
|
||||
<!-- Step 5: Propagation Mode -->
|
||||
<div v-else-if="currentStep === 5" key="step5-prop" class="space-y-6">
|
||||
<div class="text-center space-y-2">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{{ $t("tutorial.propagation") }}
|
||||
@@ -628,7 +725,7 @@
|
||||
<div class="flex flex-col gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-6 py-3 rounded-2xl bg-blue-600 hover:bg-blue-500 text-white font-bold shadow-lg transition-all transform hover:scale-[1.02]"
|
||||
class="tutorial-action-btn tutorial-action-btn-primary w-full"
|
||||
:disabled="savingPropagation"
|
||||
@click="enableAutoPropagation"
|
||||
>
|
||||
@@ -643,7 +740,7 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-6 py-3 rounded-2xl border-2 border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 font-bold shadow-xs transition-all transform hover:scale-[1.02]"
|
||||
class="tutorial-action-btn tutorial-action-btn-secondary w-full"
|
||||
@click="nextStep"
|
||||
>
|
||||
{{ $t("tutorial.propagation_skip_auto") }}
|
||||
@@ -661,8 +758,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 5: Learn & Create -->
|
||||
<div v-else-if="currentStep === 5" key="step5-tools" class="space-y-6">
|
||||
<!-- Step 6: Learn & Create -->
|
||||
<div v-else-if="currentStep === 6" key="step6-tools" class="space-y-6">
|
||||
<div class="text-center space-y-2">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{{ $t("tutorial.learn_create") }}
|
||||
@@ -871,10 +968,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 6: Finish -->
|
||||
<!-- Step 7: Finish -->
|
||||
<div
|
||||
v-else-if="currentStep === 6"
|
||||
key="step6-finish"
|
||||
v-else-if="currentStep === 7"
|
||||
key="step7-finish"
|
||||
class="flex flex-col items-center text-center space-y-8 py-10"
|
||||
>
|
||||
<div class="w-32 h-32 bg-green-500/10 rounded-full flex items-center justify-center relative">
|
||||
@@ -908,7 +1005,7 @@
|
||||
<button
|
||||
v-if="currentStep > 1 && currentStep < totalSteps"
|
||||
type="button"
|
||||
class="px-6 py-2.5 rounded-xl border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 font-semibold text-sm shadow-xs transition-all hover:bg-gray-50 dark:hover:bg-zinc-700 hover:border-blue-400 dark:hover:border-blue-500"
|
||||
class="tutorial-action-btn tutorial-action-btn-secondary"
|
||||
@click="previousStep"
|
||||
>
|
||||
{{ $t("tutorial.back") }}
|
||||
@@ -919,7 +1016,7 @@
|
||||
<button
|
||||
v-if="currentStep < totalSteps"
|
||||
type="button"
|
||||
class="px-6 py-2.5 rounded-xl border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 font-semibold text-sm shadow-xs transition-all opacity-50 hover:opacity-100 hover:bg-gray-50 dark:hover:bg-zinc-700"
|
||||
class="tutorial-action-btn tutorial-action-btn-secondary"
|
||||
@click="skipTutorial"
|
||||
>
|
||||
{{ $t("tutorial.skip") }}
|
||||
@@ -928,8 +1025,9 @@
|
||||
<button
|
||||
v-if="currentStep < totalSteps"
|
||||
type="button"
|
||||
class="px-8 h-12 rounded-xl bg-blue-600 hover:bg-blue-500 text-white font-semibold text-sm shadow-xs transition-all"
|
||||
@click="nextStep"
|
||||
class="tutorial-action-btn tutorial-action-btn-primary"
|
||||
:disabled="currentStep === 2 && identityImportInProgress"
|
||||
@click="handlePrimaryAction"
|
||||
>
|
||||
{{ $t("tutorial.next") }}
|
||||
</button>
|
||||
@@ -937,7 +1035,7 @@
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="px-8 h-12 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold text-sm shadow-xs transition-all"
|
||||
class="tutorial-action-btn tutorial-action-btn-success"
|
||||
@click="finishTutorial"
|
||||
>
|
||||
{{ $t("tutorial.finish_setup") }}
|
||||
@@ -1012,7 +1110,7 @@
|
||||
<div class="flex flex-col sm:flex-row gap-2 justify-stretch sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium disabled:opacity-50"
|
||||
class="tutorial-action-btn tutorial-action-btn-primary"
|
||||
:disabled="migrationBusy"
|
||||
@click="migrationMigrate"
|
||||
>
|
||||
@@ -1020,7 +1118,7 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 rounded-lg border border-gray-300 dark:border-zinc-600 bg-white dark:bg-zinc-900 text-sm font-medium disabled:opacity-50"
|
||||
class="tutorial-action-btn tutorial-action-btn-secondary"
|
||||
:disabled="migrationBusy"
|
||||
@click="migrationFresh"
|
||||
>
|
||||
@@ -1147,8 +1245,107 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Choose Connection Mode -->
|
||||
<div v-else-if="currentStep === 2" key="page-step2-mode" class="space-y-8 py-8">
|
||||
<!-- Step 2: Identity Setup -->
|
||||
<div v-else-if="currentStep === 2" key="page-step2-identity" class="space-y-8 py-8">
|
||||
<div class="text-center space-y-3">
|
||||
<h2 class="text-3xl font-black text-gray-900 dark:text-white">
|
||||
{{ $t("tutorial.identity_title") }}
|
||||
</h2>
|
||||
<p class="text-lg text-gray-600 dark:text-zinc-400 max-w-3xl mx-auto">
|
||||
{{ $t("tutorial.identity_desc_page") }}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
ref="identityImportFileInput"
|
||||
type="file"
|
||||
accept=".identity,.bin,.key"
|
||||
class="hidden"
|
||||
@change="onIdentityImportFileChange"
|
||||
/>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 max-w-5xl mx-auto">
|
||||
<button
|
||||
type="button"
|
||||
class="text-left flex items-start gap-5 p-7 rounded-3xl border-2 transition-all"
|
||||
:class="
|
||||
identityMode === 'new'
|
||||
? 'border-blue-500 bg-blue-500/5'
|
||||
: 'border-gray-200 dark:border-zinc-700 hover:border-blue-400'
|
||||
"
|
||||
@click="identityMode = 'new'"
|
||||
>
|
||||
<v-icon icon="mdi-account-plus-outline" color="blue" size="52"></v-icon>
|
||||
<div>
|
||||
<div class="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{{ $t("tutorial.identity_new") }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-zinc-400 mt-1">
|
||||
{{ $t("tutorial.identity_new_desc") }}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-left flex items-start gap-5 p-7 rounded-3xl border-2 transition-all"
|
||||
:class="
|
||||
identityMode === 'import'
|
||||
? 'border-blue-500 bg-blue-500/5'
|
||||
: 'border-gray-200 dark:border-zinc-700 hover:border-blue-400'
|
||||
"
|
||||
@click="identityMode = 'import'"
|
||||
>
|
||||
<v-icon icon="mdi-file-import-outline" color="indigo" size="52"></v-icon>
|
||||
<div>
|
||||
<div class="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{{ $t("tutorial.identity_import") }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-zinc-400 mt-1">
|
||||
{{ $t("tutorial.identity_import_desc") }}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="max-w-4xl mx-auto rounded-3xl border border-gray-200 dark:border-zinc-700 p-6 space-y-4"
|
||||
>
|
||||
<label class="block text-base font-semibold text-gray-800 dark:text-zinc-100">
|
||||
{{ $t("tutorial.identity_set_name") }}
|
||||
</label>
|
||||
<input
|
||||
v-model="identityName"
|
||||
type="text"
|
||||
:placeholder="defaultUsername"
|
||||
class="w-full rounded-xl border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-4 py-3 text-base text-gray-900 dark:text-zinc-100"
|
||||
/>
|
||||
<div
|
||||
v-if="identityMode === 'import'"
|
||||
class="space-y-4 pt-3 border-t border-gray-200 dark:border-zinc-800"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="tutorial-action-btn tutorial-action-btn-secondary w-full justify-center"
|
||||
@click="$refs.identityImportFileInput?.click()"
|
||||
>
|
||||
{{
|
||||
identityImportFile
|
||||
? identityImportFile.name
|
||||
: $t("tutorial.identity_upload_file")
|
||||
}}
|
||||
</button>
|
||||
<textarea
|
||||
v-model="identityImportBase32"
|
||||
rows="4"
|
||||
class="w-full rounded-xl border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-4 py-3 text-sm font-mono text-gray-900 dark:text-zinc-100"
|
||||
:placeholder="$t('tutorial.identity_base32_placeholder')"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="identityImportError" class="text-sm text-red-600 dark:text-red-400">
|
||||
{{ identityImportError }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Choose Connection Mode -->
|
||||
<div v-else-if="currentStep === 3" key="page-step3-mode" class="space-y-8 py-8">
|
||||
<div class="text-center space-y-2">
|
||||
<h2 class="text-3xl font-black text-gray-900 dark:text-white">
|
||||
{{ $t("tutorial.connect") }}
|
||||
@@ -1236,8 +1433,8 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Bootstrap Selection -->
|
||||
<div v-else-if="currentStep === 3" key="page-step3-bootstrap" class="space-y-6 py-8">
|
||||
<!-- Step 4: Bootstrap Selection -->
|
||||
<div v-else-if="currentStep === 4" key="page-step4-bootstrap" class="space-y-6 py-8">
|
||||
<div class="text-center space-y-2">
|
||||
<h2 class="text-3xl font-black text-gray-900 dark:text-white">
|
||||
{{ $t("tutorial.bootstrap_title") }}
|
||||
@@ -1517,14 +1714,14 @@
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="px-6 py-3 text-sm rounded-xl border-2 border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 font-bold transition-all hover:bg-gray-50 dark:hover:bg-zinc-700"
|
||||
class="tutorial-action-btn tutorial-action-btn-secondary"
|
||||
@click="skipBootstraps"
|
||||
>
|
||||
{{ $t("tutorial.bootstrap_skip") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-8 py-3 text-sm rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-bold shadow-lg transition-all"
|
||||
class="tutorial-action-btn tutorial-action-btn-success"
|
||||
:disabled="addingBootstraps || reloadingReticulum || selectedBootstrapCount === 0"
|
||||
@click="confirmBootstraps"
|
||||
>
|
||||
@@ -1541,8 +1738,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: Propagation Mode -->
|
||||
<div v-else-if="currentStep === 4" key="page-step4-prop" class="space-y-8 py-12">
|
||||
<!-- Step 5: Propagation Mode -->
|
||||
<div v-else-if="currentStep === 5" key="page-step5-prop" class="space-y-8 py-12">
|
||||
<div class="text-center space-y-4">
|
||||
<h2 class="text-4xl font-black text-gray-900 dark:text-white">
|
||||
{{ $t("tutorial.propagation") }}
|
||||
@@ -1566,7 +1763,7 @@
|
||||
<div class="flex flex-col gap-4 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
class="px-10 py-4 text-xl rounded-2xl bg-blue-600 hover:bg-blue-500 text-white font-black shadow-xl transition-all transform hover:scale-105"
|
||||
class="tutorial-action-btn tutorial-action-btn-primary"
|
||||
:disabled="savingPropagation"
|
||||
@click="enableAutoPropagation"
|
||||
>
|
||||
@@ -1581,7 +1778,7 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-10 py-4 text-xl rounded-2xl border-2 border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 font-black shadow-lg transition-all transform hover:scale-105"
|
||||
class="tutorial-action-btn tutorial-action-btn-secondary"
|
||||
@click="nextStep"
|
||||
>
|
||||
{{ $t("tutorial.propagation_skip_auto") }}
|
||||
@@ -1599,8 +1796,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 5: Learn & Create -->
|
||||
<div v-else-if="currentStep === 5" key="page-step5-tools" class="space-y-8 py-10">
|
||||
<!-- Step 6: Learn & Create -->
|
||||
<div v-else-if="currentStep === 6" key="page-step6-tools" class="space-y-8 py-10">
|
||||
<div class="text-center space-y-4">
|
||||
<h2 class="text-4xl font-black text-gray-900 dark:text-white">
|
||||
{{ $t("tutorial.learn_create") }}
|
||||
@@ -1853,10 +2050,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 6: Finish -->
|
||||
<!-- Step 7: Finish -->
|
||||
<div
|
||||
v-else-if="currentStep === 6"
|
||||
key="page-step6-finish"
|
||||
v-else-if="currentStep === 7"
|
||||
key="page-step7-finish"
|
||||
class="flex flex-col items-center text-center space-y-10 py-20"
|
||||
>
|
||||
<div class="w-48 h-48 bg-green-500/10 rounded-full flex items-center justify-center relative">
|
||||
@@ -1890,7 +2087,7 @@
|
||||
<button
|
||||
v-if="currentStep > 1 && currentStep < totalSteps"
|
||||
type="button"
|
||||
class="px-8 h-12 rounded-xl border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 font-semibold text-sm shadow-xs transition-all hover:bg-gray-50 dark:hover:bg-zinc-700 hover:border-blue-400 dark:hover:border-blue-500"
|
||||
class="tutorial-action-btn tutorial-action-btn-secondary"
|
||||
@click="previousStep"
|
||||
>
|
||||
{{ $t("tutorial.back") }}
|
||||
@@ -1901,7 +2098,7 @@
|
||||
<button
|
||||
v-if="currentStep < totalSteps"
|
||||
type="button"
|
||||
class="px-8 h-12 rounded-xl border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 font-semibold text-sm shadow-xs transition-all opacity-50 hover:opacity-100 hover:bg-gray-50 dark:hover:bg-zinc-700"
|
||||
class="tutorial-action-btn tutorial-action-btn-secondary"
|
||||
@click="skipTutorial"
|
||||
>
|
||||
{{ $t("tutorial.skip_setup") }}
|
||||
@@ -1910,8 +2107,9 @@
|
||||
<button
|
||||
v-if="currentStep < totalSteps"
|
||||
type="button"
|
||||
class="px-12 h-14 text-lg rounded-xl bg-blue-600 hover:bg-blue-500 text-white font-semibold shadow-xs transition-all"
|
||||
@click="nextStep"
|
||||
class="tutorial-action-btn tutorial-action-btn-primary"
|
||||
:disabled="currentStep === 2 && identityImportInProgress"
|
||||
@click="handlePrimaryAction"
|
||||
>
|
||||
{{ $t("tutorial.continue") }}
|
||||
</button>
|
||||
@@ -1919,7 +2117,7 @@
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="px-12 h-14 text-lg rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold shadow-xs transition-all"
|
||||
class="tutorial-action-btn tutorial-action-btn-success"
|
||||
@click="finishTutorial"
|
||||
>
|
||||
{{ $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);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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: "<div/>" } }],
|
||||
});
|
||||
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: "<div/>" } }],
|
||||
});
|
||||
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: "<div/>" } }],
|
||||
});
|
||||
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: "<div/>" } }],
|
||||
});
|
||||
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: "<div/>" } }],
|
||||
});
|
||||
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: "<div/>" } }],
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user