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:
parent
2604cf9d1e
commit
0207636a7a
11 changed files with 194 additions and 27 deletions
|
|
@ -195,6 +195,7 @@ const RENDERER_METHODS: ReadonlySet<string> = new Set([
|
||||||
'init',
|
'init',
|
||||||
'get_state',
|
'get_state',
|
||||||
'create_profile',
|
'create_profile',
|
||||||
|
'import_profile',
|
||||||
'select_profile',
|
'select_profile',
|
||||||
'publish_profile_metadata',
|
'publish_profile_metadata',
|
||||||
'set_profile_picture',
|
'set_profile_picture',
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@
|
||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
"electron:build": "tsc -p tsconfig.electron.json",
|
"electron:build": "tsc -p tsconfig.electron.json",
|
||||||
"start": "npm run electron:build && electron .",
|
"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",
|
"desktop:install": "bash scripts/install-desktop-entry.sh",
|
||||||
"dist": "npm run build && npm run electron:build && electron-builder --linux dir"
|
"dist": "npm run build && npm run electron:build && electron-builder --linux dir"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,8 @@ export const api = {
|
||||||
getState: () => call<AppState>('get_state'),
|
getState: () => call<AppState>('get_state'),
|
||||||
createProfile: (label: string, settings?: Settings) =>
|
createProfile: (label: string, settings?: Settings) =>
|
||||||
call<{ profile: ProfileSummary; state: AppState }>('create_profile', { label, 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 }),
|
selectProfile: (npub: string) => call<AppState>('select_profile', { npub }),
|
||||||
publishProfileMetadata: (npub: string) =>
|
publishProfileMetadata: (npub: string) =>
|
||||||
call<MetadataPublishReport>('publish_profile_metadata', { npub }),
|
call<MetadataPublishReport>('publish_profile_metadata', { npub }),
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
/** Lifecycle of the NIP-46 remote signer. */
|
||||||
export type SignerPhase = 'stopped' | 'connecting' | 'connected';
|
export type SignerPhase = 'stopped' | 'connecting' | 'connected';
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,31 @@ export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
|
||||||
</Button>
|
</Button>
|
||||||
</header>
|
</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">
|
<div className="home-grid">
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<header className="card-header">
|
<header className="card-header">
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,9 @@ export function ImportProfileModal({ open, onClose }: { open: boolean; onClose:
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setSecret(''); setError(null); setSaving(false);
|
setSecret('');
|
||||||
|
setError(null);
|
||||||
|
setSaving(false);
|
||||||
requestAnimationFrame(() => labelRef.current?.focus());
|
requestAnimationFrame(() => labelRef.current?.focus());
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
@ -21,23 +23,45 @@ export function ImportProfileModal({ open, onClose }: { open: boolean; onClose:
|
||||||
const submit = async (event: FormEvent) => {
|
const submit = async (event: FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!secret.trim() || saving) return;
|
if (!secret.trim() || saving) return;
|
||||||
setSaving(true); setError(null);
|
setSaving(true);
|
||||||
try { await importProfile('', secret.trim()); onClose(); }
|
setError(null);
|
||||||
catch (err) { setError(err instanceof Error ? err.message : String(err)); setSaving(false); }
|
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>
|
<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">
|
<div className="field">
|
||||||
<label htmlFor="import-profile-secret">Private key</label>
|
<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>}
|
{error && <ErrorText>{error}</ErrorText>}
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-actions">
|
<div className="modal-actions">
|
||||||
<Button variant="ghost" onClick={onClose} disabled={saving}>Cancel</Button>
|
<Button variant="ghost" onClick={onClose} disabled={saving}>
|
||||||
<Button variant="primary" type="submit" loading={saving} disabled={!secret.trim()}>{saving ? 'Adding…' : 'Add account'}</Button>
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" type="submit" loading={saving} disabled={!secret.trim()}>
|
||||||
|
{saving ? 'Adding…' : 'Add account'}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Modal>;
|
</Modal>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { ErrorText } from '../components/ErrorText';
|
||||||
import { Icon } from '../components/Icon';
|
import { Icon } from '../components/Icon';
|
||||||
import { Modal } from '../components/Modal';
|
import { Modal } from '../components/Modal';
|
||||||
import { ProfileEditModal } from '../components/ProfileEditModal';
|
import { ProfileEditModal } from '../components/ProfileEditModal';
|
||||||
|
import { ImportProfileModal } from './ImportProfileModal';
|
||||||
import { ShowSecretKeyModal } from '../components/ShowSecretKeyModal';
|
import { ShowSecretKeyModal } from '../components/ShowSecretKeyModal';
|
||||||
import { formatDate, shortenNpub } from '../lib/format';
|
import { formatDate, shortenNpub } from '../lib/format';
|
||||||
import type { MetadataPublishReport } from '../lib/types';
|
import type { MetadataPublishReport } from '../lib/types';
|
||||||
|
|
@ -41,6 +42,7 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
|
||||||
picture?: string | null;
|
picture?: string | null;
|
||||||
nip05?: string | null;
|
nip05?: string | null;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
|
|
||||||
const profiles = state?.profiles ?? [];
|
const profiles = state?.profiles ?? [];
|
||||||
const shorten = state?.settings.shorten_npub ?? true;
|
const shorten = state?.settings.shorten_npub ?? true;
|
||||||
|
|
@ -120,10 +122,15 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
|
||||||
title="No profiles yet"
|
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."
|
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={
|
action={
|
||||||
|
<div className="modal-actions">
|
||||||
<Button variant="primary" onClick={onCreateProfile}>
|
<Button variant="primary" onClick={onCreateProfile}>
|
||||||
<Icon name="plus" size={18} />
|
<Icon name="plus" size={18} />
|
||||||
Create Profile
|
Create Profile
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={() => setImportOpen(true)}>
|
||||||
|
Add existing account
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -158,6 +165,9 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
|
||||||
<Icon name="plus" size={18} />
|
<Icon name="plus" size={18} />
|
||||||
Create Profile
|
Create Profile
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={() => setImportOpen(true)}>
|
||||||
|
Add existing account
|
||||||
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{error && <ErrorText id={errorId}>{error}</ErrorText>}
|
{error && <ErrorText id={errorId}>{error}</ErrorText>}
|
||||||
|
|
@ -394,6 +404,7 @@ export function ProfilesScreen({ onCreateProfile }: ProfilesScreenProps) {
|
||||||
onError={setError}
|
onError={setError}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<ImportProfileModal open={importOpen} onClose={() => setImportOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ interface AppContextValue {
|
||||||
lastPublish: LastPublish | null;
|
lastPublish: LastPublish | null;
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
createProfile: (label: string) => Promise<ProfileSummary>;
|
createProfile: (label: string) => Promise<ProfileSummary>;
|
||||||
|
importProfile: (label: string, secret: string) => Promise<ProfileSummary>;
|
||||||
selectProfile: (npub: string) => Promise<void>;
|
selectProfile: (npub: string) => Promise<void>;
|
||||||
publishProfileMetadata: (npub: string) => Promise<MetadataPublishReport>;
|
publishProfileMetadata: (npub: string) => Promise<MetadataPublishReport>;
|
||||||
setProfilePicture: (npub: string, url: string | null) => Promise<MetadataPublishReport>;
|
setProfilePicture: (npub: string, url: string | null) => Promise<MetadataPublishReport>;
|
||||||
|
|
@ -122,6 +123,15 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
[state?.settings],
|
[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 selectProfile = useCallback(async (npub: string) => {
|
||||||
const fresh = await api.selectProfile(npub);
|
const fresh = await api.selectProfile(npub);
|
||||||
setState(fresh);
|
setState(fresh);
|
||||||
|
|
@ -261,6 +271,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
lastPublish,
|
lastPublish,
|
||||||
refresh,
|
refresh,
|
||||||
createProfile,
|
createProfile,
|
||||||
|
importProfile,
|
||||||
selectProfile,
|
selectProfile,
|
||||||
publishNote,
|
publishNote,
|
||||||
recordPublishFailure,
|
recordPublishFailure,
|
||||||
|
|
@ -302,6 +313,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
|
||||||
lastPublish,
|
lastPublish,
|
||||||
refresh,
|
refresh,
|
||||||
createProfile,
|
createProfile,
|
||||||
|
importProfile,
|
||||||
selectProfile,
|
selectProfile,
|
||||||
publishProfileMetadata,
|
publishProfileMetadata,
|
||||||
setProfilePicture,
|
setProfilePicture,
|
||||||
|
|
|
||||||
|
|
@ -1475,6 +1475,70 @@ select {
|
||||||
gap: 10px;
|
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 {
|
.home-profile-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,12 @@ describe('HomeScreen', () => {
|
||||||
const { onNavigate } = renderHome(backend);
|
const { onNavigate } = renderHome(backend);
|
||||||
renderWithApp(<HomeScreen onNavigate={onNavigate} onCreateProfile={vi.fn()} />);
|
renderWithApp(<HomeScreen onNavigate={onNavigate} onCreateProfile={vi.fn()} />);
|
||||||
|
|
||||||
expect(await screen.findByText('Alice')).toBeInTheDocument();
|
// The active profile name appears both in the identity card and in the profile row.
|
||||||
expect(screen.getByText(/npub1alice\.\.\./)).toBeInTheDocument();
|
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 });
|
const compose = screen.getByRole('button', { name: /Compose note/i });
|
||||||
await userEvent.setup().click(compose);
|
await userEvent.setup().click(compose);
|
||||||
|
|
@ -36,7 +40,11 @@ describe('HomeScreen', () => {
|
||||||
renderHome(backend);
|
renderHome(backend);
|
||||||
renderWithApp(<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} />);
|
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 userEvent.setup().click(within(aliceRow).getByRole('button', { name: 'Copy full npub' }));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(backend.copied).toContain(ALICE);
|
expect(backend.copied).toContain(ALICE);
|
||||||
|
|
|
||||||
18
src/ipc.rs
18
src/ipc.rs
|
|
@ -37,6 +37,10 @@ pub enum Request {
|
||||||
CreateProfile {
|
CreateProfile {
|
||||||
label: String,
|
label: String,
|
||||||
},
|
},
|
||||||
|
ImportProfile {
|
||||||
|
label: String,
|
||||||
|
secret: String,
|
||||||
|
},
|
||||||
SelectProfile {
|
SelectProfile {
|
||||||
npub: String,
|
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() }))
|
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 } => {
|
Request::SelectProfile { npub } => {
|
||||||
profiles::set_active(&mut app.vault, &npub)?;
|
profiles::set_active(&mut app.vault, &npub)?;
|
||||||
app.save_vault()?;
|
app.save_vault()?;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue