|
| 1 | +<script lang="ts"> |
| 2 | +import { goto } from "$app/navigation"; |
| 3 | +import type { GlobalState } from "$lib/global"; |
| 4 | +import { runtime } from "$lib/global/runtime.svelte"; |
| 5 | +import { ButtonAction, Drawer } from "$lib/ui"; |
| 6 | +import { ShieldKeyIcon } from "@hugeicons/core-free-icons"; |
| 7 | +import { HugeiconsIcon } from "@hugeicons/svelte"; |
| 8 | +import { getContext, onMount } from "svelte"; |
| 9 | +
|
| 10 | +let globalState: GlobalState | undefined = $state(undefined); |
| 11 | +
|
| 12 | +let passphrase = $state(""); |
| 13 | +let confirmPassphrase = $state(""); |
| 14 | +let isLoading = $state(false); |
| 15 | +let showSuccessDrawer = $state(false); |
| 16 | +let errorMessage = $state<string | null>(null); |
| 17 | +let hasExistingPassphrase = $state(false); |
| 18 | +
|
| 19 | +const REQUIREMENTS = [ |
| 20 | + { label: "At least 12 characters", test: (p: string) => p.length >= 12 }, |
| 21 | + { label: "Uppercase letter (A–Z)", test: (p: string) => /[A-Z]/.test(p) }, |
| 22 | + { label: "Lowercase letter (a–z)", test: (p: string) => /[a-z]/.test(p) }, |
| 23 | + { label: "Number (0–9)", test: (p: string) => /[0-9]/.test(p) }, |
| 24 | + { |
| 25 | + label: "Special character (!@#$…)", |
| 26 | + test: (p: string) => /[^A-Za-z0-9]/.test(p), |
| 27 | + }, |
| 28 | +]; |
| 29 | +
|
| 30 | +$effect(() => { |
| 31 | + runtime.header.title = "Recovery Passphrase"; |
| 32 | +}); |
| 33 | +
|
| 34 | +onMount(async () => { |
| 35 | + globalState = getContext<() => GlobalState>("globalState")(); |
| 36 | + if (!globalState) throw new Error("Global state is not defined"); |
| 37 | + try { |
| 38 | + hasExistingPassphrase = |
| 39 | + await globalState.vaultController.hasRecoveryPassphrase(); |
| 40 | + } catch { |
| 41 | + // non-critical |
| 42 | + } |
| 43 | +}); |
| 44 | +
|
| 45 | +async function handleSave() { |
| 46 | + errorMessage = null; |
| 47 | +
|
| 48 | + if (!passphrase) { |
| 49 | + errorMessage = "Please enter a passphrase."; |
| 50 | + return; |
| 51 | + } |
| 52 | +
|
| 53 | + const unmet = REQUIREMENTS.filter((r) => !r.test(passphrase)); |
| 54 | + if (unmet.length > 0) { |
| 55 | + errorMessage = "Passphrase does not meet all requirements."; |
| 56 | + return; |
| 57 | + } |
| 58 | +
|
| 59 | + if (passphrase !== confirmPassphrase) { |
| 60 | + errorMessage = "Passphrases do not match."; |
| 61 | + return; |
| 62 | + } |
| 63 | +
|
| 64 | + isLoading = true; |
| 65 | + try { |
| 66 | + await globalState?.vaultController.setRecoveryPassphrase( |
| 67 | + passphrase, |
| 68 | + confirmPassphrase, |
| 69 | + ); |
| 70 | + passphrase = ""; |
| 71 | + confirmPassphrase = ""; |
| 72 | + hasExistingPassphrase = true; |
| 73 | + showSuccessDrawer = true; |
| 74 | + } catch (err) { |
| 75 | + const message = err instanceof Error ? err.message : String(err); |
| 76 | + errorMessage = message.includes("requirements") |
| 77 | + ? message |
| 78 | + : "Failed to save passphrase. Please try again."; |
| 79 | + console.error("setRecoveryPassphrase failed:", err); |
| 80 | + } finally { |
| 81 | + isLoading = false; |
| 82 | + } |
| 83 | +} |
| 84 | +
|
| 85 | +async function handleClose() { |
| 86 | + showSuccessDrawer = false; |
| 87 | + await goto("/settings"); |
| 88 | +} |
| 89 | +
|
| 90 | +const allMet = $derived( |
| 91 | + passphrase.length > 0 && REQUIREMENTS.every((r) => r.test(passphrase)), |
| 92 | +); |
| 93 | +const mismatch = $derived( |
| 94 | + confirmPassphrase.length > 0 && confirmPassphrase !== passphrase, |
| 95 | +); |
| 96 | +</script> |
| 97 | + |
| 98 | +<main |
| 99 | + class="h-[85vh] px-[5vw] pb-[8svh] flex flex-col justify-between" |
| 100 | + style="padding-top: max(4svh, env(safe-area-inset-top));" |
| 101 | +> |
| 102 | + <section class="flex flex-col gap-[3svh]"> |
| 103 | + {#if hasExistingPassphrase} |
| 104 | + <p class="text-black-700"> |
| 105 | + A recovery passphrase is already set. Enter a new one below to replace it. |
| 106 | + </p> |
| 107 | + {:else} |
| 108 | + <p class="text-black-700"> |
| 109 | + Set a passphrase that will be required when recovering your eVault. |
| 110 | + Only a secure hash is stored — your passphrase is never readable. |
| 111 | + </p> |
| 112 | + {/if} |
| 113 | + |
| 114 | + <div> |
| 115 | + <p class="mb-[1svh]">New passphrase</p> |
| 116 | + <input |
| 117 | + type="password" |
| 118 | + bind:value={passphrase} |
| 119 | + autocomplete="new-password" |
| 120 | + placeholder="Enter your passphrase" |
| 121 | + class="w-full rounded-xl border border-transparent bg-gray px-4 py-3 focus:outline-none focus:border-primary transition-colors" |
| 122 | + /> |
| 123 | + |
| 124 | + {#if passphrase} |
| 125 | + <ul class="mt-[1.5svh] flex flex-col gap-[0.6svh]"> |
| 126 | + {#each REQUIREMENTS as req} |
| 127 | + <li class="small flex items-center gap-2 {req.test(passphrase) ? 'text-green-600' : 'text-black-300'}"> |
| 128 | + <span class="font-bold w-3 text-center">{req.test(passphrase) ? "✓" : "·"}</span> |
| 129 | + {req.label} |
| 130 | + </li> |
| 131 | + {/each} |
| 132 | + </ul> |
| 133 | + {/if} |
| 134 | + </div> |
| 135 | + |
| 136 | + <div> |
| 137 | + <p class="mb-[1svh]">Confirm passphrase</p> |
| 138 | + <input |
| 139 | + type="password" |
| 140 | + bind:value={confirmPassphrase} |
| 141 | + autocomplete="new-password" |
| 142 | + placeholder="Re-enter your passphrase" |
| 143 | + class="w-full rounded-xl border {mismatch ? 'border-danger' : 'border-transparent'} bg-gray px-4 py-3 focus:outline-none focus:border-primary transition-colors" |
| 144 | + /> |
| 145 | + {#if mismatch} |
| 146 | + <p class="text-danger mt-[0.5svh]">Passphrases do not match.</p> |
| 147 | + {/if} |
| 148 | + </div> |
| 149 | + |
| 150 | + {#if errorMessage} |
| 151 | + <p class="text-danger">{errorMessage}</p> |
| 152 | + {/if} |
| 153 | + </section> |
| 154 | + |
| 155 | + <ButtonAction |
| 156 | + class="w-full" |
| 157 | + callback={handleSave} |
| 158 | + disabled={isLoading || !allMet || !confirmPassphrase || mismatch} |
| 159 | + > |
| 160 | + {isLoading ? "Saving…" : hasExistingPassphrase ? "Update Passphrase" : "Set Passphrase"} |
| 161 | + </ButtonAction> |
| 162 | +</main> |
| 163 | + |
| 164 | +<Drawer bind:isPaneOpen={showSuccessDrawer}> |
| 165 | + <div class="relative bg-gray w-18 h-18 rounded-3xl flex justify-center items-center mb-[2.3svh]"> |
| 166 | + <span class="relative z-1"> |
| 167 | + <HugeiconsIcon icon={ShieldKeyIcon} color="var(--color-primary)" /> |
| 168 | + </span> |
| 169 | + <img class="absolute top-0 start-0" src="/images/Line.svg" alt="" /> |
| 170 | + <img class="absolute top-0 start-0" src="/images/Line2.svg" alt="" /> |
| 171 | + </div> |
| 172 | + <h4>Recovery Passphrase {hasExistingPassphrase ? "Updated" : "Set"}!</h4> |
| 173 | + <p class="text-black-700 mt-[0.5svh] mb-[2.3svh]"> |
| 174 | + Your recovery passphrase has been securely stored. You will need it when recovering your eVault. |
| 175 | + </p> |
| 176 | + <ButtonAction class="w-full" callback={handleClose}>Done</ButtonAction> |
| 177 | +</Drawer> |
0 commit comments