From 0207636a7a2a18ef7a6c375d0bc5b572e60a6150 Mon Sep 17 00:00:00 2001 From: Avi Date: Tue, 1 Sep 2026 10:41:28 -0500 Subject: [PATCH] feat: expose profile import over IPC Wire the existing profiles::import_profile through the JSON-lines IPC protocol so the GUI can add an existing account: ImportProfile request and handler in src/ipc.rs, import_profile added to the Electron method allowlist, api/AppProvider importProfile, and the Add existing account buttons in ProfilesScreen. Also add the active signing identity card on Home and fix the HomeScreen tests whose text queries now match the identity card as well as the profile row. --- frontend/electron/main.ts | 1 + frontend/package.json | 1 + frontend/src/lib/api.ts | 2 + frontend/src/lib/types.ts | 3 +- frontend/src/screens/HomeScreen.tsx | 25 ++++++++ frontend/src/screens/ImportProfileModal.tsx | 62 ++++++++++++++------ frontend/src/screens/ProfilesScreen.tsx | 19 ++++-- frontend/src/state/AppProvider.tsx | 12 ++++ frontend/src/styles.css | 64 +++++++++++++++++++++ frontend/src/test/HomeScreen.test.tsx | 14 ++++- src/ipc.rs | 18 ++++++ 11 files changed, 194 insertions(+), 27 deletions(-) diff --git a/frontend/electron/main.ts b/frontend/electron/main.ts index d973028..604c9a3 100644 --- a/frontend/electron/main.ts +++ b/frontend/electron/main.ts @@ -195,6 +195,7 @@ const RENDERER_METHODS: ReadonlySet = new Set([ 'init', 'get_state', 'create_profile', + 'import_profile', 'select_profile', 'publish_profile_metadata', 'set_profile_picture', diff --git a/frontend/package.json b/frontend/package.json index 8fa43e2..5886ed4 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,7 @@ "format:check": "prettier --check .", "electron:build": "tsc -p tsconfig.electron.json", "start": "npm run electron:build && electron .", + "start:dev": "npm run electron:build && NOSTR_GUI_DEV_URL=${NOSTR_GUI_DEV_URL:-http://localhost:5173} electron .", "desktop:install": "bash scripts/install-desktop-entry.sh", "dist": "npm run build && npm run electron:build && electron-builder --linux dir" }, diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 8f0f7d6..ff1c928 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -52,6 +52,8 @@ export const api = { getState: () => call('get_state'), createProfile: (label: string, settings?: Settings) => call<{ profile: ProfileSummary; state: AppState }>('create_profile', { label, settings }), + importProfile: (label: string, secret: string) => + call<{ profile: ProfileSummary; state: AppState }>('import_profile', { label, secret }), selectProfile: (npub: string) => call('select_profile', { npub }), publishProfileMetadata: (npub: string) => call('publish_profile_metadata', { npub }), diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index f64bd77..5030525 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -1,4 +1,5 @@ -export type Theme = 'light' | 'dark' | 'glass' | 'neon' | 'impeccable' | 'impeccable-dark' | 'cosmic'; +export type Theme = + 'light' | 'dark' | 'glass' | 'neon' | 'impeccable' | 'impeccable-dark' | 'cosmic'; /** Lifecycle of the NIP-46 remote signer. */ export type SignerPhase = 'stopped' | 'connecting' | 'connected'; diff --git a/frontend/src/screens/HomeScreen.tsx b/frontend/src/screens/HomeScreen.tsx index 0bf3db6..f391857 100644 --- a/frontend/src/screens/HomeScreen.tsx +++ b/frontend/src/screens/HomeScreen.tsx @@ -76,6 +76,31 @@ export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) { + {active && ( +
+
+ +
+

Active signing identity

+

Active profile

+ + {active.label} + + + {shortenNpub(active.npub, shorten)} + +

+ Notes will be signed as this profile. Private keys never leave this computer. +

+
+
+ +
+ )} +
diff --git a/frontend/src/screens/ImportProfileModal.tsx b/frontend/src/screens/ImportProfileModal.tsx index 09430fb..d5018d8 100644 --- a/frontend/src/screens/ImportProfileModal.tsx +++ b/frontend/src/screens/ImportProfileModal.tsx @@ -13,31 +13,55 @@ export function ImportProfileModal({ open, onClose }: { open: boolean; onClose: useEffect(() => { if (open) { - setSecret(''); setError(null); setSaving(false); - requestAnimationFrame(() => labelRef.current?.focus()); + setSecret(''); + setError(null); + setSaving(false); + requestAnimationFrame(() => labelRef.current?.focus()); } }, [open]); const submit = async (event: FormEvent) => { event.preventDefault(); if (!secret.trim() || saving) return; - setSaving(true); setError(null); - try { await importProfile('', secret.trim()); onClose(); } - catch (err) { setError(err instanceof Error ? err.message : String(err)); setSaving(false); } + setSaving(true); + setError(null); + try { + await importProfile('', secret.trim()); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + setSaving(false); + } }; - return -
-

Import an account using its private key. The key stays in your local vault and is never displayed.

-
- - setSecret(e.target.value)} placeholder="nsec1... or 64-character hex" autoComplete="off" /> - {error && {error}} -
-
- - -
-
-
; + return ( + +
+

+ Import an account using its private key. The key stays in your local vault and is never + displayed. +

+
+ + setSecret(e.target.value)} + placeholder="nsec1... or 64-character hex" + autoComplete="off" + /> + {error && {error}} +
+
+ + +
+
+
+ ); } diff --git a/frontend/src/screens/ProfilesScreen.tsx b/frontend/src/screens/ProfilesScreen.tsx index fdd796b..4831794 100644 --- a/frontend/src/screens/ProfilesScreen.tsx +++ b/frontend/src/screens/ProfilesScreen.tsx @@ -8,6 +8,7 @@ import { ErrorText } from '../components/ErrorText'; import { Icon } from '../components/Icon'; import { Modal } from '../components/Modal'; import { ProfileEditModal } from '../components/ProfileEditModal'; +import { ImportProfileModal } from './ImportProfileModal'; import { ShowSecretKeyModal } from '../components/ShowSecretKeyModal'; import { formatDate, shortenNpub } from '../lib/format'; import type { MetadataPublishReport } from '../lib/types'; @@ -41,6 +42,7 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) { picture?: string | null; nip05?: string | null; } | null>(null); + const [importOpen, setImportOpen] = useState(false); const profiles = state?.profiles ?? []; const shorten = state?.settings.shorten_npub ?? true; @@ -120,10 +122,15 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) { title="No profiles yet" description="Create a profile to get your own Nostr identity — a public npub address you can share, with a private key kept safely on this computer." action={ - +
+ + +
} />
@@ -158,6 +165,9 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) { Create Profile + {error && {error}} @@ -394,6 +404,7 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) { onError={setError} /> )} + setImportOpen(false)} /> ); diff --git a/frontend/src/state/AppProvider.tsx b/frontend/src/state/AppProvider.tsx index 473d782..9703932 100644 --- a/frontend/src/state/AppProvider.tsx +++ b/frontend/src/state/AppProvider.tsx @@ -40,6 +40,7 @@ interface AppContextValue { lastPublish: LastPublish | null; refresh: () => Promise; createProfile: (label: string) => Promise; + importProfile: (label: string, secret: string) => Promise; selectProfile: (npub: string) => Promise; publishProfileMetadata: (npub: string) => Promise; setProfilePicture: (npub: string, url: string | null) => Promise; @@ -122,6 +123,15 @@ export function AppProvider({ children }: { children: ReactNode }) { [state?.settings], ); + const importProfile = useCallback( + async (label: string, secret: string): Promise => { + const result = await api.importProfile(label, secret); + setState(result.state); + return result.profile; + }, + [], + ); + const selectProfile = useCallback(async (npub: string) => { const fresh = await api.selectProfile(npub); setState(fresh); @@ -261,6 +271,7 @@ export function AppProvider({ children }: { children: ReactNode }) { lastPublish, refresh, createProfile, + importProfile, selectProfile, publishNote, recordPublishFailure, @@ -302,6 +313,7 @@ export function AppProvider({ children }: { children: ReactNode }) { lastPublish, refresh, createProfile, + importProfile, selectProfile, publishProfileMetadata, setProfilePicture, diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 5ecf371..aeca148 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1475,6 +1475,70 @@ select { gap: 10px; } +.home-identity-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + border-color: var(--success); + background: linear-gradient(135deg, var(--surface), var(--success-soft)); +} + +.home-identity-content { + display: flex; + align-items: center; + gap: 16px; + min-width: 0; +} + +.home-identity-copy { + min-width: 0; +} + +.home-identity-kicker { + margin: 0 0 4px; + color: var(--success); + font-size: 12px; + font-weight: 650; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.home-identity-copy h2 { + margin: 0 0 4px; +} + +.home-identity-name { + margin: 0 0 4px; + color: var(--text); + font-size: 18px; + font-weight: 650; +} + +.home-identity-copy .mono { + display: inline-block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: bottom; +} + +.home-identity-copy .hint { + max-width: 58ch; + margin: 8px 0 0; +} + +@media (max-width: 620px) { + .home-identity-card { + align-items: stretch; + flex-direction: column; + } + + .home-identity-card .btn { + width: 100%; + } +} + .home-profile-row { display: flex; align-items: center; diff --git a/frontend/src/test/HomeScreen.test.tsx b/frontend/src/test/HomeScreen.test.tsx index 9d56d97..7eef738 100644 --- a/frontend/src/test/HomeScreen.test.tsx +++ b/frontend/src/test/HomeScreen.test.tsx @@ -23,8 +23,12 @@ describe('HomeScreen', () => { const { onNavigate } = renderHome(backend); renderWithApp(); - expect(await screen.findByText('Alice')).toBeInTheDocument(); - expect(screen.getByText(/npub1alice\.\.\./)).toBeInTheDocument(); + // The active profile name appears both in the identity card and in the profile row. + const identityCard = (await screen.findByText('Active signing identity')).closest( + '.home-identity-card', + ) as HTMLElement; + expect(within(identityCard).getByText('Alice')).toBeInTheDocument(); + expect(within(identityCard).getByText(/npub1alice\.\.\./)).toBeInTheDocument(); const compose = screen.getByRole('button', { name: /Compose note/i }); await userEvent.setup().click(compose); @@ -36,7 +40,11 @@ describe('HomeScreen', () => { renderHome(backend); renderWithApp(); - const aliceRow = (await screen.findByText('Alice')).closest('.home-profile-row') as HTMLElement; + // The active profile row carries the "Selected" badge; find Alice via the profile list. + const profileList = await screen.findByRole('list'); + const aliceRow = within(profileList) + .getByText('Alice') + .closest('.home-profile-row') as HTMLElement; await userEvent.setup().click(within(aliceRow).getByRole('button', { name: 'Copy full npub' })); await waitFor(() => { expect(backend.copied).toContain(ALICE); diff --git a/src/ipc.rs b/src/ipc.rs index f5554d9..1cc7e65 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -37,6 +37,10 @@ pub enum Request { CreateProfile { label: String, }, + ImportProfile { + label: String, + secret: String, + }, SelectProfile { npub: String, }, @@ -395,6 +399,20 @@ async fn run_with_app(app: &mut App, request: Request) -> Result { + let label = normalise_label(&label); + let key = app.vault_key().copied(); + let summary = profiles::import_profile( + &mut app.vault, + label, + &secret, + key.as_ref(), + &app.settings, + )?; + app.save_vault()?; + Ok(json!({ "profile": summary, "state": app.state_view() })) + } + Request::SelectProfile { npub } => { profiles::set_active(&mut app.vault, &npub)?; app.save_vault()?;