From 060403f8aa08f63aefad109d60567d57a32a3e33 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Sat, 2 May 2026 16:27:04 -0700 Subject: [PATCH] update onboarding flow to support membership checks and byok --- desktop/src-tauri/src/app_state.rs | 2 +- desktop/src-tauri/src/commands/identity.rs | 53 +++- desktop/src-tauri/src/lib.rs | 1 + .../onboarding/ui/MembershipDenied.tsx | 102 ++++++++ .../features/onboarding/ui/OnboardingFlow.tsx | 137 +++++++++- .../features/onboarding/ui/ProfileStep.tsx | 246 +++++++++++++++++- desktop/src/features/onboarding/ui/types.ts | 6 +- desktop/src/features/workspaces/types.ts | 13 +- .../workspaces/ui/AddWorkspaceDialog.tsx | 27 +- .../features/workspaces/ui/WelcomeSetup.tsx | 8 +- .../features/workspaces/useWorkspaceInit.ts | 24 +- .../src/features/workspaces/useWorkspaces.tsx | 7 +- .../features/workspaces/workspaceStorage.ts | 20 +- desktop/src/shared/api/tauri.ts | 8 + desktop/src/shared/lib/nostrUtils.ts | 48 ++++ 15 files changed, 661 insertions(+), 41 deletions(-) create mode 100644 desktop/src/features/onboarding/ui/MembershipDenied.tsx create mode 100644 desktop/src/shared/lib/nostrUtils.ts diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index 0298d10c0..3d8d5fc8b 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -196,7 +196,7 @@ fn load_key_file(path: &std::path::Path) -> Result { /// 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 diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index e3c697cef..4b2870192 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -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 { .map_err(|error| format!("encode nsec: {error}")) } +#[tauri::command] +pub fn import_identity( + nsec: String, + app_handle: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result { + 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, diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 088916dc4..a6f2901e3 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -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, diff --git a/desktop/src/features/onboarding/ui/MembershipDenied.tsx b/desktop/src/features/onboarding/ui/MembershipDenied.tsx new file mode 100644 index 000000000..df81fa9f4 --- /dev/null +++ b/desktop/src/features/onboarding/ui/MembershipDenied.tsx @@ -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 ( +
+
+
+ Membership required +
+
+ +
+

+ Not a member yet +

+
+

+ This relay requires an invitation. Ask a relay admin to add you as a + member, then come back and try again. +

+
+ +
+
+

+ Your public key (npub) +

+
+ + {npub} + + +
+
+

+ This is your public identity — it's safe to share. Send it to + the relay admin so they can invite you. +

+
+ +
+ + {onChangeKey ? ( + + ) : null} +
+
+
+ ); +} diff --git a/desktop/src/features/onboarding/ui/OnboardingFlow.tsx b/desktop/src/features/onboarding/ui/OnboardingFlow.tsx index 5ef3d5e2e..51b781b81 100644 --- a/desktop/src/features/onboarding/ui/OnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/OnboardingFlow.tsx @@ -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 { + 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(""); + + // 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 ( + { + void saveProfileAndContinue(); + }} + pubkey={deniedPubkey} + /> + ); + } + return (
{ diff --git a/desktop/src/features/onboarding/ui/ProfileStep.tsx b/desktop/src/features/onboarding/ui/ProfileStep.tsx index bef9fd0bd..2c7f3fadb 100644 --- a/desktop/src/features/onboarding/ui/ProfileStep.tsx +++ b/desktop/src/features/onboarding/ui/ProfileStep.tsx @@ -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; +}) { + const [expanded, setExpanded] = React.useState(false); + const [nsecInput, setNsecInput] = React.useState(""); + const [isImporting, setIsImporting] = React.useState(false); + const [error, setError] = React.useState(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 ( + + ); + } + + return ( +
{ + 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); + }} + > +
+ + { + setNsecInput(e.target.value); + setError(null); + }} + placeholder="nsec1… (or drop a .key file)" + spellCheck={false} + type="password" + value={nsecInput} + /> +

+ + Drop a `.key` file anywhere in this box, or paste your nsec. +

+
+ + {/* Live preview of the resolved npub once the input is valid. */} + {isValid && previewNpub ? ( +
+ +
+

+ This will switch your identity to: +

+

+ {shortenNpub(previewNpub)} +

+
+
+ ) : null} + + {showInvalidHint && !error ? ( +

+ Waiting for a valid `nsec1…` key. +

+ ) : null} + + {error ?

{error}

: null} + +
+ + +
+
+ ); +} + 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.

+ {/* 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 ? ( +

+ You are{" "} + + {shortenNpub(currentNpub)} + +

+ ) : null}
@@ -214,6 +454,8 @@ export function ProfileStep({ actions, state }: ProfileStepProps) { previewName={avatarPreviewLabel} /> + + diff --git a/desktop/src/features/onboarding/ui/types.ts b/desktop/src/features/onboarding/ui/types.ts index 1dbd54bd8..51c49e8f4 100644 --- a/desktop/src/features/onboarding/ui/types.ts +++ b/desktop/src/features/onboarding/ui/types.ts @@ -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; openAvatarPicker: () => void; skipForNow: () => void; submit: () => void; diff --git a/desktop/src/features/workspaces/types.ts b/desktop/src/features/workspaces/types.ts index 7bbaa8375..77f346c65 100644 --- a/desktop/src/features/workspaces/types.ts +++ b/desktop/src/features/workspaces/types.ts @@ -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; }; diff --git a/desktop/src/features/workspaces/ui/AddWorkspaceDialog.tsx b/desktop/src/features/workspaces/ui/AddWorkspaceDialog.tsx index 227d2f1fa..69fcc34dc 100644 --- a/desktop/src/features/workspaces/ui/AddWorkspaceDialog.tsx +++ b/desktop/src/features/workspaces/ui/AddWorkspaceDialog.tsx @@ -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} /> -
- - setNsec(e.target.value)} - placeholder="nsec1..." - type="password" - value={nsec} - /> -
+

+ Workspaces share your active identity. To use a different key, + import it on the profile step (or in settings). +