mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
update onboarding flow to support membership checks and byok
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'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} />
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user