update onboarding flow to support membership checks and byok

This commit is contained in:
Bradley Axen
2026-05-02 16:27:04 -07:00
parent 1487ce6252
commit 060403f8aa
15 changed files with 661 additions and 41 deletions
+1 -1
View File
@@ -196,7 +196,7 @@ fn load_key_file(path: &std::path::Path) -> Result<Keys, String> {
/// On Unix, the file is created with mode 0600 (owner read/write only).
/// On Windows, default ACLs apply — the app data directory is already
/// per-user, so the key is not world-readable in practice.
fn save_key_file(path: &std::path::Path, keys: &Keys) -> Result<(), String> {
pub(crate) fn save_key_file(path: &std::path::Path, keys: &Keys) -> Result<(), String> {
use atomic_write_file::AtomicWriteFile;
let nsec = keys
+52 -1
View File
@@ -1,4 +1,5 @@
use nostr::{nips::nip44, EventBuilder, JsonUtil, Kind, Tag, Timestamp, ToBech32};
use nostr::{nips::nip44, EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp, ToBech32};
use tauri::Manager;
use nostr_compat::{
Event as CompatEvent, JsonUtil as CompatJsonUtil, Keys as CompatKeys,
PublicKey as CompatPublicKey,
@@ -146,6 +147,56 @@ pub fn get_nsec(state: State<'_, AppState>) -> Result<String, String> {
.map_err(|error| format!("encode nsec: {error}"))
}
#[tauri::command]
pub fn import_identity(
nsec: String,
app_handle: tauri::AppHandle,
state: State<'_, AppState>,
) -> Result<IdentityInfo, String> {
let trimmed = nsec.trim();
let keys = Keys::parse(trimmed).map_err(|e| format!("Invalid private key: {e}"))?;
// Persist to identity.key
let data_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| format!("app data dir: {e}"))?;
std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?;
let key_path = data_dir.join("identity.key");
crate::app_state::save_key_file(&key_path, &keys)?;
// Update in-memory keys
let pubkey = keys.public_key();
*state.keys.lock().map_err(|e| e.to_string())? = keys;
// Clear any session token: it was minted for the previous pubkey and
// would be invalid (or worse, identify us as the previous pubkey) for
// any subsequent relay requests.
if let Ok(mut token) = state.session_token.lock() {
*token = None;
}
let pubkey_hex = pubkey.to_hex();
let bech32 = pubkey
.to_bech32()
.map_err(|error| format!("bech32 encode failed: {error}"))?;
let display_name = if bech32.len() > 16 {
format!("{}…{}", &bech32[..10], &bech32[bech32.len() - 4..])
} else {
bech32
};
eprintln!(
"sprout-desktop: imported identity pubkey {}",
pubkey_hex
);
Ok(IdentityInfo {
pubkey: pubkey_hex,
display_name,
})
}
#[tauri::command]
pub fn create_auth_event(
challenge: String,
+1
View File
@@ -357,6 +357,7 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![
get_identity,
get_nsec,
import_identity,
get_profile,
update_profile,
get_user_profile,
@@ -0,0 +1,102 @@
import * as React from "react";
import { Check, Copy, KeyRound, ShieldX } from "lucide-react";
import { pubkeyToNpub } from "@/shared/lib/nostrUtils";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
type MembershipDeniedProps = {
onChangeKey?: () => void;
onRetry: () => void;
pubkey: string;
};
export function MembershipDenied({
onChangeKey,
onRetry,
pubkey,
}: MembershipDeniedProps) {
const npub = React.useMemo(() => pubkeyToNpub(pubkey), [pubkey]);
const [copied, setCopied] = React.useState(false);
const handleCopy = React.useCallback(async () => {
try {
await navigator.clipboard.writeText(npub);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Fallback: select the text so the user can copy manually
}
}, [npub]);
return (
<div className="flex min-h-dvh items-center justify-center bg-[radial-gradient(circle_at_top,hsl(var(--primary)/0.14),transparent_48%),linear-gradient(180deg,hsl(var(--background)),hsl(var(--muted)/0.55))] px-4 py-8">
<div className="w-full max-w-md rounded-[28px] border border-border/70 bg-background/92 p-8 shadow-2xl backdrop-blur">
<div className="space-y-3">
<Badge variant="warning">Membership required</Badge>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-destructive/10">
<ShieldX className="h-5 w-5 text-destructive" />
</div>
<h1 className="text-2xl font-semibold tracking-tight text-foreground">
Not a member yet
</h1>
</div>
<p className="text-sm leading-6 text-muted-foreground">
This relay requires an invitation. Ask a relay admin to add you as a
member, then come back and try again.
</p>
</div>
<div className="mt-6 space-y-3">
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">
Your public key (npub)
</p>
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded-xl border border-border/70 bg-muted/30 px-3 py-2.5 font-mono text-xs text-foreground">
{npub}
</code>
<Button
className="shrink-0"
onClick={() => {
void handleCopy();
}}
size="icon"
title="Copy npub"
type="button"
variant="outline"
>
{copied ? (
<Check className="h-4 w-4 text-emerald-500" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
<p className="text-xs leading-5 text-muted-foreground">
This is your public identity — it&apos;s safe to share. Send it to
the relay admin so they can invite you.
</p>
</div>
<div className="mt-6 flex flex-col gap-2">
<Button className="w-full" onClick={onRetry} type="button">
Try again
</Button>
{onChangeKey ? (
<button
className="flex w-full items-center justify-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
onClick={onChangeKey}
type="button"
>
<KeyRound className="h-3 w-3" />
Use a different key
</button>
) : null}
</div>
</div>
</div>
);
}
@@ -1,7 +1,21 @@
import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useUpdateProfileMutation } from "@/features/profile/hooks";
import { uploadMediaBytes } from "@/shared/api/tauri";
import {
profileQueryKey,
useUpdateProfileMutation,
} from "@/features/profile/hooks";
import { useWorkspaces } from "@/features/workspaces/useWorkspaces";
import {
getIdentity,
getMyRelayMembership,
importIdentity as tauriImportIdentity,
uploadMediaBytes,
} from "@/shared/api/tauri";
import { useIdentityQuery } from "@/shared/api/hooks";
import { pubkeyToNpub } from "@/shared/lib/nostrUtils";
import { relayClient } from "@/shared/api/relayClient";
import { MembershipDenied } from "./MembershipDenied";
import { ProfileStep } from "./ProfileStep";
import { SetupStep } from "./SetupStep";
import type {
@@ -13,6 +27,31 @@ import type {
ProfileStepState,
} from "./types";
/**
* Check whether the relay denies access due to membership gating.
*
* Uses the `/api/relay/members/me` endpoint which bypasses the membership
* middleware — it returns null (404) when authenticated but not a member.
*
* Returns `true` if denied, `false` if the user is a member (or if the
* relay doesn't enforce membership / isn't reachable).
*/
async function checkMembershipDenied(): Promise<boolean> {
try {
const membership = await getMyRelayMembership();
return membership === null;
} catch (error) {
if (
error instanceof Error &&
error.message.includes("relay returned 403")
) {
return true;
}
// Network errors, 401s, 500s — not membership denials.
return false;
}
}
type OnboardingFlowProps = {
actions: OnboardingActions;
initialProfile: OnboardingProfileSeed;
@@ -108,6 +147,32 @@ export function OnboardingFlow({
string | null
>(null);
const [isUploadingAvatar, setIsUploadingAvatar] = React.useState(false);
const [deniedPubkey, setDeniedPubkey] = React.useState<string>("");
// For displaying the current identity at the top of the profile step and
// for refreshing the UI in place after `import_identity` completes — the
// `key={currentPubkey}` on this component in App.tsx remounts the whole
// tree once the cache update lands, giving us a clean reset of all
// form/import state without a `window.location.reload()`.
const queryClient = useQueryClient();
const identityQuery = useIdentityQuery();
const currentNpub = React.useMemo(() => {
const pubkey = identityQuery.data?.pubkey;
if (!pubkey) {
return null;
}
try {
return pubkeyToNpub(pubkey);
} catch {
return null;
}
}, [identityQuery.data?.pubkey]);
// Used by the import action to update the active workspace's display
// pubkey. Workspaces never store the nsec — `identity.key` on disk is the
// single source of truth — but we keep `pubkey` accurate so switcher
// labels and similar UI reflect the active identity.
const { activeWorkspace, updateWorkspace } = useWorkspaces();
const openAvatarPicker = React.useCallback(() => {
avatarInputRef.current?.click();
@@ -185,6 +250,20 @@ export function OnboardingFlow({
return;
}
// Check membership before attempting the profile save. On open relays
// this passes instantly. On gated relays it prevents a 403 during save.
const denied = await checkMembershipDenied();
if (denied) {
try {
const identity = await getIdentity();
setDeniedPubkey(identity.pubkey);
} catch {
setDeniedPubkey("");
}
setCurrentPage("membership-denied");
return;
}
const updatePayload = createProfileUpdatePayload({
draftProfile: profileDraft,
savedProfile,
@@ -194,6 +273,7 @@ export function OnboardingFlow({
try {
await profileUpdateMutation.mutateAsync(updatePayload);
} catch {
// Error falls through to the error banner / recovery buttons.
return;
}
}
@@ -235,6 +315,7 @@ export function OnboardingFlow({
isUploading: isUploadingAvatar,
savedUrl: savedProfile.avatarUrl,
},
currentNpub,
isSaving: isSavingProfile,
name: {
draftValue: profileDraft.displayName,
@@ -246,6 +327,57 @@ export function OnboardingFlow({
),
};
const handleImportIdentity = React.useCallback(
async (nsec: string) => {
// Backend writes the nsec to `identity.key`, swaps `state.keys`, and
// clears any session token. After this returns, every Rust command
// reads the new key fresh on the next call.
const next = await tauriImportIdentity(nsec);
// Drop the WebSocket so it re-AUTHs as the new pubkey on next use.
// Stale subscriptions bound to the old pubkey would otherwise leak
// through and cause confusing membership/permission errors until the
// user navigated away.
try {
relayClient.disconnect();
} catch (error) {
console.warn("relayClient.disconnect() during import failed", error);
}
// Update the active workspace's display pubkey. The workspace never
// stores nsec — this is purely cosmetic for the workspace switcher.
if (activeWorkspace && activeWorkspace.pubkey !== next.pubkey) {
updateWorkspace(activeWorkspace.id, { pubkey: next.pubkey });
}
// Drop any membership-denied banner from a previous identity.
setDeniedPubkey("");
// Refresh identity + profile caches. The identity query lives at
// staleTime: Infinity so an explicit invalidation is required.
// Once `["identity"]` updates, App.tsx's `key={currentPubkey}` will
// remount this entire component, giving us a clean form state for
// the new identity without a page reload.
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["identity"] }),
queryClient.invalidateQueries({ queryKey: profileQueryKey }),
]);
},
[activeWorkspace, queryClient, updateWorkspace],
);
if (currentPage === "membership-denied") {
return (
<MembershipDenied
onChangeKey={showProfilePage}
onRetry={() => {
void saveProfileAndContinue();
}}
pubkey={deniedPubkey}
/>
);
}
return (
<div
className="flex min-h-dvh items-center justify-center bg-[radial-gradient(circle_at_top,hsl(var(--primary)/0.16),transparent_44%),linear-gradient(180deg,hsl(var(--background)),hsl(var(--muted)/0.5))] px-4 py-8"
@@ -257,6 +389,7 @@ export function OnboardingFlow({
actions={{
advanceWithoutSaving: showSetupPage,
clearAvatarDraft: resetAvatarDraft,
importIdentity: handleImportIdentity,
openAvatarPicker,
skipForNow,
submit: () => {
@@ -1,6 +1,16 @@
import { Camera, Link2, Loader2, UserRound } from "lucide-react";
import * as React from "react";
import {
Camera,
Check,
KeyRound,
Link2,
Loader2,
Upload,
UserRound,
} from "lucide-react";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { nsecToNpub, shortenNpub } from "@/shared/lib/nostrUtils";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
@@ -137,10 +147,225 @@ function AvatarSection({
);
}
/**
* Import-key flow.
*
* UX goals:
* - Treat the input as a password field (masked) so over-the-shoulder peeks
* don't leak the secret.
* - Accept a `.key` (or any text) file dropped onto the section: read its
* contents, trim, and use as the nsec.
* - As soon as the value parses as a valid `nsec1…`, decode it and show the
* matching `npub1…` inline so the user can confirm *before* committing.
* - On success, the parent (`OnboardingFlow`) invalidates the identity
* query, which causes `App.tsx` to remount this whole subtree under the
* new pubkey — local state here resets naturally; no reload needed.
*/
function ImportKeySection({
onImport,
}: {
onImport: (nsec: string) => Promise<void>;
}) {
const [expanded, setExpanded] = React.useState(false);
const [nsecInput, setNsecInput] = React.useState("");
const [isImporting, setIsImporting] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [isDragging, setIsDragging] = React.useState(false);
// Live-decode the current input. `null` means "not (yet) a valid nsec";
// we only show the preview once decoding succeeds, so partial typing
// doesn't flicker errors at the user.
const previewNpub = React.useMemo(() => nsecToNpub(nsecInput), [nsecInput]);
const trimmedInput = nsecInput.trim();
const hasInput = trimmedInput.length > 0;
const isValid = previewNpub !== null;
const showInvalidHint = hasInput && !isValid && trimmedInput.length >= 5;
const handleImport = React.useCallback(async () => {
if (!previewNpub) {
setError("That doesn't look like a valid nsec. Paste an nsec1… key.");
return;
}
setIsImporting(true);
setError(null);
try {
await onImport(trimmedInput);
// On success the parent invalidates the identity query and this
// component remounts via App.tsx's `key={currentPubkey}`. We don't
// need to clear local state here, but we still flip `isImporting`
// back off in case the remount is delayed (e.g. cache settling).
setIsImporting(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to import key.");
setIsImporting(false);
}
}, [onImport, previewNpub, trimmedInput]);
const handleFiles = React.useCallback(async (files: FileList | null) => {
const file = files?.[0];
if (!file) {
return;
}
// Cap at 1 KB to avoid accidentally reading something huge if the user
// drops the wrong file. A bech32 nsec is ~63 chars; even with trailing
// whitespace this is plenty.
if (file.size > 1024) {
setError(
"That file is too large to be a key. Drop a .key file or paste your nsec.",
);
return;
}
try {
const text = await file.text();
// Take the first non-empty line — tolerates trailing newlines from
// `echo nsec1… > identity.key` and similar.
const firstLine =
text.split(/\r?\n/).find((line) => line.trim().length > 0) ?? "";
setNsecInput(firstLine.trim());
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Couldn't read that file.");
}
}, []);
if (!expanded) {
return (
<button
className="flex w-full items-center justify-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
onClick={() => setExpanded(true)}
type="button"
>
<KeyRound className="h-3 w-3" />I already have a Nostr key
</button>
);
}
return (
<fieldset
className={`space-y-3 rounded-[28px] border bg-muted/20 p-5 transition-colors ${
isDragging ? "border-primary/60 bg-primary/5" : "border-border/70"
}`}
onDragEnter={(e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}}
onDragLeave={(e) => {
e.preventDefault();
e.stopPropagation();
// Only clear when leaving the section itself (not a child).
if (e.currentTarget.contains(e.relatedTarget as Node | null)) {
return;
}
setIsDragging(false);
}}
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
// Required for drop to fire.
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
void handleFiles(e.dataTransfer.files);
}}
>
<div className="space-y-1.5">
<label
className="text-xs font-medium text-muted-foreground"
htmlFor="onboarding-nsec-import"
>
Private key (nsec)
</label>
<Input
autoComplete="off"
autoCorrect="off"
data-testid="onboarding-nsec-input"
id="onboarding-nsec-import"
onChange={(e) => {
setNsecInput(e.target.value);
setError(null);
}}
placeholder="nsec1… (or drop a .key file)"
spellCheck={false}
type="password"
value={nsecInput}
/>
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Upload className="h-3 w-3" />
Drop a `.key` file anywhere in this box, or paste your nsec.
</p>
</div>
{/* Live preview of the resolved npub once the input is valid. */}
{isValid && previewNpub ? (
<div
className="flex items-start gap-2 rounded-2xl border border-primary/30 bg-primary/5 px-3 py-2 text-xs"
data-testid="onboarding-nsec-preview"
>
<Check className="mt-0.5 h-3.5 w-3.5 shrink-0 text-primary" />
<div className="min-w-0 space-y-0.5">
<p className="font-medium text-foreground">
This will switch your identity to:
</p>
<p className="break-all font-mono text-[11px] text-muted-foreground">
{shortenNpub(previewNpub)}
</p>
</div>
</div>
) : null}
{showInvalidHint && !error ? (
<p className="text-xs text-muted-foreground">
Waiting for a valid `nsec1…` key.
</p>
) : null}
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<div className="flex gap-2">
<Button
className="flex-1"
disabled={!isValid || isImporting}
onClick={() => {
void handleImport();
}}
type="button"
>
{isImporting ? (
<>
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
Importing…
</>
) : (
"Use this key"
)}
</Button>
<Button
disabled={isImporting}
onClick={() => {
setExpanded(false);
setNsecInput("");
setError(null);
}}
type="button"
variant="ghost"
>
Cancel
</Button>
</div>
</fieldset>
);
}
export function ProfileStep({ actions, state }: ProfileStepProps) {
const {
advanceWithoutSaving,
clearAvatarDraft,
importIdentity,
openAvatarPicker,
skipForNow,
submit,
@@ -148,7 +373,7 @@ export function ProfileStep({ actions, state }: ProfileStepProps) {
updateDisplayName,
uploadAvatarFile,
} = actions;
const { avatar, isSaving, name, saveRecovery } = state;
const { avatar, currentNpub, isSaving, name, saveRecovery } = state;
const { errorMessage: avatarErrorMessage } = avatar;
const { draftValue: displayNameDraft, savedValue: savedDisplayName } = name;
const isSubmittingDisabled = isSaving || avatar.isUploading;
@@ -168,6 +393,21 @@ export function ProfileStep({ actions, state }: ProfileStepProps) {
Add the name people will see in Sprout. A photo is optional, but it
helps people spot you faster.
</p>
{/* Show the active identity so the user can confirm which key
they're saving the profile for — and so it's obvious when
they need to swap to a different key (e.g. an allowlisted
one for a gated relay). */}
{currentNpub ? (
<p
className="font-mono text-[11px] text-muted-foreground"
data-testid="onboarding-current-npub"
>
You are{" "}
<span className="text-foreground">
{shortenNpub(currentNpub)}
</span>
</p>
) : null}
</div>
</div>
@@ -214,6 +454,8 @@ export function ProfileStep({ actions, state }: ProfileStepProps) {
previewName={avatarPreviewLabel}
/>
<ImportKeySection onImport={importIdentity} />
<ErrorBanner message={avatarErrorMessage} />
<ErrorBanner message={saveRecovery.errorMessage} />
+5 -1
View File
@@ -6,7 +6,7 @@ import type {
} from "@/features/notifications/hooks";
import type { AcpProvider, Profile } from "@/shared/api/types";
export type OnboardingPage = "profile" | "setup";
export type OnboardingPage = "profile" | "setup" | "membership-denied";
export type OnboardingActions = {
complete: () => void;
@@ -51,6 +51,9 @@ export type ProfileStepAvatarState = {
export type ProfileStepState = {
avatar: ProfileStepAvatarState;
/** Bech32-encoded current pubkey (npub1…), shown so the user can confirm
* which identity they're saving the profile for. */
currentNpub: string | null;
isSaving: boolean;
name: ProfileStepNameState;
saveRecovery: ProfileStepSaveRecovery;
@@ -59,6 +62,7 @@ export type ProfileStepState = {
export type ProfileStepActions = {
advanceWithoutSaving: () => void;
clearAvatarDraft: () => void;
importIdentity: (nsec: string) => Promise<void>;
openAvatarPicker: () => void;
skipForNow: () => void;
submit: () => void;
+12 -1
View File
@@ -3,7 +3,18 @@ export type Workspace = {
name: string;
relayUrl: string;
token?: string;
nsec?: string;
/**
* The pubkey associated with the active identity at the time the workspace
* was created. Display-only — auth always uses the persisted `identity.key`
* file resolved at startup, never this field.
*/
pubkey?: string;
addedAt: string;
/**
* @deprecated Never read. Kept on the type so old localStorage entries
* deserialise without errors. New entries never set this field, and
* `loadWorkspaces()` strips it on read so it cannot leak forward. The
* authoritative private key is the on-disk `identity.key` file.
*/
nsec?: never;
};
@@ -29,14 +29,12 @@ export function AddWorkspaceDialog({
const [name, setName] = React.useState("");
const [relayUrl, setRelayUrl] = React.useState("");
const [token, setToken] = React.useState("");
const [nsec, setNsec] = React.useState("");
const handleClose = React.useCallback(() => {
onOpenChange(false);
setName("");
setRelayUrl("");
setToken("");
setNsec("");
}, [onOpenChange]);
const handleSubmit = React.useCallback(
@@ -51,14 +49,13 @@ export function AddWorkspaceDialog({
name: name.trim() || deriveWorkspaceName(relayUrl.trim()),
relayUrl: normalizeRelayUrl(relayUrl.trim()),
token: token.trim() || undefined,
nsec: nsec.trim() || undefined,
addedAt: new Date().toISOString(),
};
onSubmit(workspace);
handleClose();
},
[name, relayUrl, token, nsec, onSubmit, handleClose],
[name, relayUrl, token, onSubmit, handleClose],
);
return (
@@ -124,24 +121,10 @@ export function AddWorkspaceDialog({
value={token}
/>
</div>
<div className="flex flex-col gap-1.5">
<label
className="text-sm font-medium text-foreground"
htmlFor="ws-nsec"
>
Private Key (nsec)
<span className="ml-1 text-xs font-normal text-muted-foreground">
(optional — uses current identity if blank)
</span>
</label>
<Input
id="ws-nsec"
onChange={(e) => setNsec(e.target.value)}
placeholder="nsec1..."
type="password"
value={nsec}
/>
</div>
<p className="text-xs text-muted-foreground">
Workspaces share your active identity. To use a different key,
import it on the profile step (or in settings).
</p>
<div className="flex justify-end gap-2 pt-2">
<Button onClick={handleClose} type="button" variant="outline">
Cancel
@@ -1,6 +1,6 @@
import * as React from "react";
import { getIdentity, getNsec } from "@/shared/api/tauri";
import { getIdentity } from "@/shared/api/tauri";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
@@ -40,13 +40,15 @@ export function WelcomeSetup({
setError(null);
try {
const [identity, nsec] = await Promise.all([getIdentity(), getNsec()]);
// We snapshot only the pubkey for display purposes (workspace switcher
// labels, etc.). The private key lives on disk in `identity.key` and
// is the single source of truth — never copied into localStorage.
const identity = await getIdentity();
const workspace: Workspace = {
id: crypto.randomUUID(),
name: deriveWorkspaceName(normalizedUrl),
relayUrl: normalizedUrl,
nsec,
pubkey: identity.pubkey,
addedAt: new Date().toISOString(),
};
@@ -25,7 +25,11 @@ function resetWorkspaceState(): void {
type WorkspaceInitResult =
| { isReady: true; needsSetup: false }
| { isReady: false; needsSetup: true; defaultRelayUrl: string }
| {
isReady: false;
needsSetup: true;
defaultRelayUrl: string;
}
| { isReady: false; needsSetup: false };
/**
@@ -58,7 +62,11 @@ export function useWorkspaceInit(
try {
const defaultRelayUrl = await getDefaultRelayUrl();
if (!cancelled) {
setResult({ isReady: false, needsSetup: true, defaultRelayUrl });
setResult({
isReady: false,
needsSetup: true,
defaultRelayUrl,
});
}
} catch {
if (!cancelled) {
@@ -82,11 +90,19 @@ export function useWorkspaceInit(
// Show loading gate while we apply the new workspace config
setResult({ isReady: false, needsSetup: false });
// Apply workspace config to the Tauri backend
// Apply workspace config to the Tauri backend.
//
// Note: we deliberately do NOT pass an nsec here. The persisted
// `identity.key` file (resolved at startup by `resolve_persisted_identity`,
// and updated atomically by `import_identity`) is the single source of
// truth for the active key. Older builds stored the nsec in localStorage
// and re-applied it on every reload, which silently overwrote any
// imported key. `loadWorkspaces()` strips lingering `nsec` fields from
// legacy entries; this site refuses to apply one even if present.
try {
await applyWorkspace(
activeWorkspace.relayUrl,
activeWorkspace.nsec,
undefined,
activeWorkspace.token,
);
} catch (error) {
@@ -27,7 +27,7 @@ export type UseWorkspacesReturn = {
switchWorkspace: (id: string) => void;
updateWorkspace: (
id: string,
updates: Partial<Pick<Workspace, "name" | "relayUrl" | "token">>,
updates: Partial<Pick<Workspace, "name" | "relayUrl" | "token" | "pubkey">>,
) => void;
};
@@ -80,7 +80,6 @@ function useWorkspacesInternal(): UseWorkspacesReturn {
...w,
name: workspace.name || w.name,
token: workspace.token ?? w.token,
nsec: workspace.nsec ?? w.nsec,
pubkey: workspace.pubkey ?? w.pubkey,
}
: w,
@@ -130,7 +129,9 @@ function useWorkspacesInternal(): UseWorkspacesReturn {
const updateWorkspace = useCallback(
(
id: string,
updates: Partial<Pick<Workspace, "name" | "relayUrl" | "token">>,
updates: Partial<
Pick<Workspace, "name" | "relayUrl" | "token" | "pubkey">
>,
) => {
setWorkspacesState((prev) => {
// Prevent duplicate relay URLs across workspaces
@@ -13,7 +13,25 @@ export function loadWorkspaces(): Workspace[] {
if (!Array.isArray(parsed)) {
return [];
}
return parsed as Workspace[];
// Migration: older builds stored the user's `nsec` in localStorage and
// re-applied it to the backend on every reload, which silently overwrote
// any `import_identity` result with the original generated key. The
// on-disk `identity.key` file is the only source of truth now. Strip
// any lingering `nsec` from existing entries on read and persist the
// cleaned list back so it cannot leak into future sessions.
let didStrip = false;
const cleaned = (parsed as Array<Record<string, unknown>>).map((entry) => {
if (entry && typeof entry === "object" && "nsec" in entry) {
const { nsec: _nsec, ...rest } = entry;
didStrip = true;
return rest;
}
return entry;
}) as Workspace[];
if (didStrip) {
localStorage.setItem(WORKSPACES_KEY, JSON.stringify(cleaned));
}
return cleaned;
} catch {
return [];
}
+8
View File
@@ -469,6 +469,14 @@ export async function getNsec(): Promise<string> {
return invokeTauri<string>("get_nsec");
}
export async function importIdentity(nsec: string): Promise<Identity> {
const raw = await invokeTauri<RawIdentity>("import_identity", { nsec });
return {
pubkey: raw.pubkey,
displayName: raw.display_name,
};
}
export async function getProfile(): Promise<Profile> {
const profile = await invokeTauri<RawProfile>("get_profile");
return fromRawProfile(profile);
+48
View File
@@ -0,0 +1,48 @@
import { decode, npubEncode } from "nostr-tools/nip19";
import { getPublicKey } from "nostr-tools/pure";
/**
* Convert a hex-encoded Nostr public key to its npub (bech32) representation.
*
* @param hexPubkey — 64-character hex string
* @returns npub1… bech32-encoded public key
*/
export function pubkeyToNpub(hexPubkey: string): string {
return npubEncode(hexPubkey);
}
/**
* Decode a bech32 nsec string and derive the matching npub. Returns null if
* the input is not a syntactically valid `nsec1…` (does NOT throw — this is
* intended for live form validation where the user is mid-typing).
*
* The input is trimmed first; surrounding whitespace from copy-paste or a
* dropped `.key` file is tolerated.
*/
export function nsecToNpub(nsec: string): string | null {
const trimmed = nsec.trim();
if (!trimmed.startsWith("nsec1")) {
return null;
}
try {
const decoded = decode(trimmed);
if (decoded.type !== "nsec") {
return null;
}
const pubkeyHex = getPublicKey(decoded.data);
return npubEncode(pubkeyHex);
} catch {
return null;
}
}
/**
* Format an npub for compact display: `npub1abcd…wxyz`. Falls back to the
* original string if it's shorter than the truncation thresholds.
*/
export function shortenNpub(npub: string): string {
if (npub.length <= 16) {
return npub;
}
return `${npub.slice(0, 12)}…${npub.slice(-6)}`;
}