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()?;