polish: onboarding step indicator + safe backup escape hatch

- Onboarding Shell: larger accent VEGA wordmark + step-progress dots
  across the create -> backup -> interests path.
- Backup step gains an "I'll back up my key later" escape hatch. It runs
  the SAME keychain-saving login as confirming (skips only the checkbox)
  — wiring it to onComplete alone would have lost the generated key.
- Settings -> Identity now reveals the secret key (nsec): reveal/hide/
  copy via the existing load_nsec keychain command, hidden for read-only
  and remote-signer accounts. This makes the escape hatch honest — its
  note truthfully points users to where they can retrieve the key, and
  fills a real gap (the nsec was previously unreachable after onboarding).
This commit is contained in:
Jure
2026-05-21 21:19:55 +02:00
parent da8aab7de5
commit 349e9d12f8
2 changed files with 97 additions and 6 deletions
+50 -5
View File
@@ -10,11 +10,36 @@ interface OnboardingFlowProps {
// ─── Shared layout ───────────────────────────────────────────────────────────
function Shell({ children }: { children: React.ReactNode }) {
function Shell({
children,
step,
totalSteps,
}: {
children: React.ReactNode;
step?: number;
totalSteps?: number;
}) {
return (
<div className="h-screen w-screen bg-bg flex items-center justify-center">
<div className="w-full max-w-md px-8">
<div className="text-text-dim text-[10px] font-bold tracking-[0.3em] uppercase mb-8">VEGA</div>
{/* Logo wordmark */}
<div className="text-accent text-[18px] font-bold tracking-[0.25em] mb-2 select-none">VEGA</div>
{/* Step dots — shown only for the linear create→backup→interests path */}
{step !== undefined && totalSteps !== undefined && (
<div className="flex gap-1.5 mb-8">
{Array.from({ length: totalSteps }).map((_, i) => (
<span
key={i}
className={`h-1 rounded-full transition-all duration-300 ${
i < step ? "bg-accent w-4" : i === step ? "bg-accent w-6" : "bg-border w-4"
}`}
/>
))}
</div>
)}
{step === undefined && <div className="mb-8" />}
{children}
</div>
</div>
@@ -70,7 +95,7 @@ function InterestsStep({ onComplete }: { onComplete: () => void }) {
};
return (
<Shell>
<Shell step={2} totalSteps={3}>
<Heading>What are you into?</Heading>
<Body>Pick a few topics to get your feed started. You can always change this later.</Body>
@@ -157,7 +182,7 @@ function CreateStep({ onNext }: { onNext: (signer: NDKPrivateKeySigner) => void
};
return (
<Shell>
<Shell step={0} totalSteps={3}>
<Heading>Your identity is ready.</Heading>
<Body>
We generated a unique key pair for you. Your <strong className="text-text">public key</strong> is
@@ -215,8 +240,18 @@ function BackupStep({ signer, onComplete }: { signer: NDKPrivateKeySigner; onCom
onComplete();
};
// "Back up later" escape hatch — runs the SAME login as confirming (this is
// what saves the key to the keychain and signs in); it only skips the
// checkbox gate. The key stays retrievable via Settings → Identity.
const handleSkipBackup = async () => {
setSaving(true);
await loginWithNsec(signer.nsec);
setSaving(false);
onComplete();
};
return (
<Shell>
<Shell step={1} totalSteps={3}>
<Heading>Save your secret key.</Heading>
<Body>
Your <strong className="text-text">secret key</strong> is the only way to recover your
@@ -272,6 +307,16 @@ function BackupStep({ signer, onComplete }: { signer: NDKPrivateKeySigner; onCom
>
{saving ? "Setting up…" : "Start using Vega"}
</button>
<button
onClick={handleSkipBackup}
disabled={saving}
className="w-full py-2 text-[11px] text-text-dim hover:text-text transition-colors mt-1 disabled:opacity-30"
>
I'll back up my key later
</button>
<p className="text-warning text-[10px] text-center mt-1 opacity-70">
You can reveal your key any time in Settings Identity.
</p>
</Shell>
);
}
+47 -1
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from "react";
import { save } from "@tauri-apps/plugin-dialog";
import { writeTextFile } from "@tauri-apps/plugin-fs";
import { invoke } from "@tauri-apps/api/core";
import { useUserStore } from "../../stores/user";
import { useUIStore } from "../../stores/ui";
import { useWoTStore } from "../../stores/wot";
@@ -206,8 +207,18 @@ function WoTSection() {
}
function IdentitySection() {
const { npub, loggedIn } = useUserStore();
const { npub, loggedIn, pubkey } = useUserStore();
const [copied, setCopied] = useState(false);
const [nsec, setNsec] = useState<string | null>(null);
const [revealed, setRevealed] = useState(false);
const [nsecCopied, setNsecCopied] = useState(false);
// Load the secret key from the OS keychain. Returns null for read-only
// (npub) and remote-signer (bunker) accounts — the nsec row is hidden then.
useEffect(() => {
if (!pubkey) return;
invoke<string | null>("load_nsec", { pubkey }).then(setNsec).catch(() => setNsec(null));
}, [pubkey]);
if (!loggedIn || !npub) {
return (
@@ -225,6 +236,14 @@ function IdentitySection() {
});
};
const handleCopyNsec = () => {
if (!nsec) return;
navigator.clipboard.writeText(nsec).then(() => {
setNsecCopied(true);
setTimeout(() => setNsecCopied(false), 2000);
});
};
return (
<section>
<h2 className="text-text text-[11px] font-medium uppercase tracking-widest mb-2 text-text-dim">Identity</h2>
@@ -238,6 +257,33 @@ function IdentitySection() {
</button>
</div>
<p className="text-text-dim text-[10px] mt-1 px-1">Your public key. Safe to share.</p>
{nsec && (
<>
<div className="flex items-center gap-2 px-3 py-2 border border-danger/40 mt-3">
<span className={`font-mono text-[11px] truncate flex-1 ${revealed ? "text-text" : "text-text-dim"}`}>
{revealed ? nsec : "•".repeat(48)}
</span>
<button
onClick={() => setRevealed((v) => !v)}
className="text-[10px] text-text-dim hover:text-text transition-colors shrink-0"
>
{revealed ? "hide" : "reveal"}
</button>
{revealed && (
<button
onClick={handleCopyNsec}
className="text-[10px] text-text-dim hover:text-danger transition-colors shrink-0"
>
{nsecCopied ? "copied ✓" : "copy"}
</button>
)}
</div>
<p className="text-text-dim text-[10px] mt-1 px-1">
Your secret key the only way to recover this account. Never share it. Back it up somewhere safe.
</p>
</>
)}
</section>
);
}