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.
This commit is contained in:
Avi 2026-09-01 10:41:28 -05:00
commit 0207636a7a
11 changed files with 194 additions and 27 deletions

View file

@ -195,6 +195,7 @@ const RENDERER_METHODS: ReadonlySet<string> = new Set([
'init',
'get_state',
'create_profile',
'import_profile',
'select_profile',
'publish_profile_metadata',
'set_profile_picture',

View file

@ -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"
},

View file

@ -52,6 +52,8 @@ export const api = {
getState: () => call<AppState>('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<AppState>('select_profile', { npub }),
publishProfileMetadata: (npub: string) =>
call<MetadataPublishReport>('publish_profile_metadata', { npub }),

View file

@ -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';

View file

@ -76,6 +76,31 @@ export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
</Button>
</header>
{active && (
<section className="card home-identity-card" aria-labelledby="active-identity-title">
<div className="home-identity-content">
<Avatar npub={active.npub} label={active.label} size="lg" picture={active.picture} />
<div className="home-identity-copy">
<p className="home-identity-kicker">Active signing identity</p>
<h2 id="active-identity-title">Active profile</h2>
<strong className="home-identity-name" aria-label="Active profile name">
{active.label}
</strong>
<code className="mono" title={active.npub}>
{shortenNpub(active.npub, shorten)}
</code>
<p className="hint">
Notes will be signed as this profile. Private keys never leave this computer.
</p>
</div>
</div>
<Button variant="secondary" onClick={() => onNavigate('profiles')}>
<Icon name="users" size={16} />
Switch profile
</Button>
</section>
)}
<div className="home-grid">
<section className="card">
<header className="card-header">

View file

@ -13,7 +13,9 @@ export function ImportProfileModal({ open, onClose }: { open: boolean; onClose:
useEffect(() => {
if (open) {
setSecret(''); setError(null); setSaving(false);
setSecret('');
setError(null);
setSaving(false);
requestAnimationFrame(() => labelRef.current?.focus());
}
}, [open]);
@ -21,23 +23,45 @@ export function ImportProfileModal({ open, onClose }: { open: boolean; onClose:
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 <Modal open={open} title="Add an existing account" onClose={onClose}>
return (
<Modal open={open} title="Add an existing account" onClose={onClose}>
<form onSubmit={submit} noValidate>
<p className="muted">Import an account using its private key. The key stays in your local vault and is never displayed.</p>
<p className="muted">
Import an account using its private key. The key stays in your local vault and is never
displayed.
</p>
<div className="field">
<label htmlFor="import-profile-secret">Private key</label>
<input id="import-profile-secret" type="password" value={secret} onChange={(e) => setSecret(e.target.value)} placeholder="nsec1... or 64-character hex" autoComplete="off" />
<input
id="import-profile-secret"
type="password"
value={secret}
onChange={(e) => setSecret(e.target.value)}
placeholder="nsec1... or 64-character hex"
autoComplete="off"
/>
{error && <ErrorText>{error}</ErrorText>}
</div>
<div className="modal-actions">
<Button variant="ghost" onClick={onClose} disabled={saving}>Cancel</Button>
<Button variant="primary" type="submit" loading={saving} disabled={!secret.trim()}>{saving ? 'Adding…' : 'Add account'}</Button>
<Button variant="ghost" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button variant="primary" type="submit" loading={saving} disabled={!secret.trim()}>
{saving ? 'Adding…' : 'Add account'}
</Button>
</div>
</form>
</Modal>;
</Modal>
);
}

View file

@ -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={
<div className="modal-actions">
<Button variant="primary" onClick={onCreateProfile}>
<Icon name="plus" size={18} />
Create Profile
</Button>
<Button variant="secondary" onClick={() => setImportOpen(true)}>
Add existing account
</Button>
</div>
}
/>
</div>
@ -158,6 +165,9 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
<Icon name="plus" size={18} />
Create Profile
</Button>
<Button variant="secondary" onClick={() => setImportOpen(true)}>
Add existing account
</Button>
</header>
{error && <ErrorText id={errorId}>{error}</ErrorText>}
@ -394,6 +404,7 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
onError={setError}
/>
)}
<ImportProfileModal open={importOpen} onClose={() => setImportOpen(false)} />
</div>
</div>
);

View file

@ -40,6 +40,7 @@ interface AppContextValue {
lastPublish: LastPublish | null;
refresh: () => Promise<void>;
createProfile: (label: string) => Promise<ProfileSummary>;
importProfile: (label: string, secret: string) => Promise<ProfileSummary>;
selectProfile: (npub: string) => Promise<void>;
publishProfileMetadata: (npub: string) => Promise<MetadataPublishReport>;
setProfilePicture: (npub: string, url: string | null) => Promise<MetadataPublishReport>;
@ -122,6 +123,15 @@ export function AppProvider({ children }: { children: ReactNode }) {
[state?.settings],
);
const importProfile = useCallback(
async (label: string, secret: string): Promise<ProfileSummary> => {
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,

View file

@ -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;

View file

@ -23,8 +23,12 @@ describe('HomeScreen', () => {
const { onNavigate } = renderHome(backend);
renderWithApp(<HomeScreen onNavigate={onNavigate} onCreateProfile={vi.fn()} />);
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(<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} />);
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);

View file

@ -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<serde_json::Val
Ok(json!({ "profile": summary, "state": app.state_view() }))
}
Request::ImportProfile { label, secret } => {
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()?;