From 13a66f28d8bac244489c173cf3d17c5b4b7e7b4c Mon Sep 17 00:00:00 2001 From: Avi Date: Wed, 23 Sep 2026 09:00:07 -0500 Subject: [PATCH] feat(onboarding): first-run screen offers import-existing-account and external-signer paths - Home first-run now has three entry points: create a new profile, I already have an account (opens ImportProfileModal), and Sign in with a signer (navigates to Signer Mode where Amber/NIP-46 pairing lives). - Copy states the per-mode truth: local-vault keys vs remote signer where the private key never lives on this device. - Tests updated to assert all three entry points; 116 frontend tests green. --- frontend/src/App.tsx | 9 +++++- frontend/src/screens/HomeScreen.tsx | 28 +++++++++++++------ frontend/src/styles.css | 12 ++++++++ frontend/src/test/App.test.tsx | 6 ++-- frontend/src/test/HomeScreen.test.tsx | 37 +++++++++++++++++++------ frontend/src/test/publications.test.tsx | 4 ++- 6 files changed, 76 insertions(+), 20 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4646a00..8aa3c87 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ import { SignerScreen } from './screens/SignerScreen'; import { SignerModeScreen } from './screens/SignerModeScreen'; import { SettingsScreen } from './screens/SettingsScreen'; import { CreateProfileModal } from './screens/CreateProfileModal'; +import { ImportProfileModal } from './screens/ImportProfileModal'; import { AppProvider, useApp, useThemeSync } from './state/AppProvider'; import type { Screen } from './lib/navigation'; @@ -21,6 +22,7 @@ function Shell() { const { state, loading, bootstrapError } = useApp(); const [screen, setScreen] = useState('home'); const [createOpen, setCreateOpen] = useState(false); + const [importOpen, setImportOpen] = useState(false); const [unlockOpen, setUnlockOpen] = useState(false); useThemeSync(state?.settings.theme); @@ -68,7 +70,11 @@ function Shell() { )} {screen === 'home' && ( - setCreateOpen(true)} /> + setCreateOpen(true)} + onImportProfile={() => setImportOpen(true)} + /> )} {screen === 'feed' && } {screen === 'profiles' && setCreateOpen(true)} />} @@ -79,6 +85,7 @@ function Shell() { {screen === 'settings' && } setCreateOpen(false)} /> + setImportOpen(false)} /> setUnlockOpen(false)} /> ); diff --git a/frontend/src/screens/HomeScreen.tsx b/frontend/src/screens/HomeScreen.tsx index 54e9cf1..a0f2e3b 100644 --- a/frontend/src/screens/HomeScreen.tsx +++ b/frontend/src/screens/HomeScreen.tsx @@ -15,9 +15,10 @@ import { useApp } from '../state/AppProvider'; interface HomeScreenProps { onNavigate: (screen: Screen) => void; onCreateProfile: () => void; + onImportProfile: () => void; } -export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) { +export function HomeScreen({ onNavigate, onCreateProfile, onImportProfile }: HomeScreenProps) { const { state, selectProfile } = useApp(); const { publications, fullyPublished, loading, error } = useProfilePublications(); const [selecting, setSelecting] = useState(null); @@ -43,16 +44,27 @@ export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) { title="Welcome to Keynctr" description={ - You haven't created a profile yet. A Nostr profile is your identity on the public - Nostr network — a npub address you can share, plus a private key kept - safely on this computer. Create your first profile to start publishing notes. + A Nostr profile is your identity on the public Nostr network — a npub{' '} + address you can share. You can create a new one here (its private key is generated + and kept in this computer’s encrypted vault), import an account you already + have, or connect an external signer like Amber so the private key never lives on + this device. } action={ - +
+ + + +
} /> diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 4294cb6..3d1f373 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1402,6 +1402,18 @@ select { margin-top: 8px; } +.onboarding-actions { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; +} + +.onboarding-actions .btn { + min-width: 260px; + justify-content: center; +} + /* ------------------------------------------------------------------------- Home ------------------------------------------------------------------------- */ diff --git a/frontend/src/test/App.test.tsx b/frontend/src/test/App.test.tsx index 221d7dc..1c02c43 100644 --- a/frontend/src/test/App.test.tsx +++ b/frontend/src/test/App.test.tsx @@ -16,10 +16,12 @@ describe('App', () => { render(); expect(await screen.findByText('Welcome to Keynctr')).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /Create your first profile/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Create a new profile/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /I already have an account/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Sign in with a signer/i })).toBeInTheDocument(); expect(screen.getByText(/A Nostr profile is your identity/i)).toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: /Create your first profile/i })); + await user.click(screen.getByRole('button', { name: /Create a new profile/i })); expect( await screen.findByRole('dialog', { name: 'Create a Nostr profile' }), ).toBeInTheDocument(); diff --git a/frontend/src/test/HomeScreen.test.tsx b/frontend/src/test/HomeScreen.test.tsx index 9eec91b..88e4e13 100644 --- a/frontend/src/test/HomeScreen.test.tsx +++ b/frontend/src/test/HomeScreen.test.tsx @@ -7,13 +7,18 @@ import { createFakeBackend, installFakeBackend } from './fakeBackend'; function renderHome( backend: ReturnType, - overrides: { onNavigate?: () => void; onCreateProfile?: () => void } = {}, + overrides: { + onNavigate?: () => void; + onCreateProfile?: () => void; + onImportProfile?: () => void; + } = {}, ) { installFakeBackend(backend); return { user: userEvent.setup(), onNavigate: overrides.onNavigate ?? vi.fn(), onCreateProfile: overrides.onCreateProfile ?? vi.fn(), + onImportProfile: overrides.onImportProfile ?? vi.fn(), }; } @@ -21,7 +26,9 @@ describe('HomeScreen', () => { it('shows the active profile, a shortened npub, and a compose button', async () => { const backend = createFakeBackend(); const { onNavigate } = renderHome(backend); - renderWithApp(); + renderWithApp( + , + ); // The active profile appears in the profile list with its shortened npub. const profileList = await screen.findByRole('listbox'); @@ -36,7 +43,9 @@ describe('HomeScreen', () => { it('copies the complete npub when the copy button is clicked', async () => { const backend = createFakeBackend(); renderHome(backend); - renderWithApp(); + renderWithApp( + , + ); // The active profile row carries the "Selected" badge; find Alice via the profile list. const profileList = await screen.findByRole('listbox'); @@ -51,20 +60,32 @@ describe('HomeScreen', () => { it('shows the first-run state and guides the user to create a profile', async () => { const backend = createFakeBackend(makeEmptyState()); - const { onCreateProfile } = renderHome(backend); - renderWithApp(); + const { onCreateProfile, onImportProfile } = renderHome(backend); + renderWithApp( + , + ); expect(await screen.findByText('Welcome to Keynctr')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /I already have an account/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Sign in with a signer/i })).toBeInTheDocument(); + await userEvent.setup().click(screen.getByRole('button', { name: /Create a new profile/i })); + expect(onCreateProfile).toHaveBeenCalled(); await userEvent .setup() - .click(screen.getByRole('button', { name: /Create your first profile/i })); - expect(onCreateProfile).toHaveBeenCalled(); + .click(screen.getByRole('button', { name: /I already have an account/i })); + expect(onImportProfile).toHaveBeenCalled(); }); it('selects a profile when its row is clicked (not just the Select button)', async () => { const backend = createFakeBackend(); renderHome(backend); - renderWithApp(); + renderWithApp( + , + ); const bobRow = (await screen.findByText('Bob')).closest('.home-profile-row') as HTMLElement; // Clicking the name (not the Select button) should select the profile. diff --git a/frontend/src/test/publications.test.tsx b/frontend/src/test/publications.test.tsx index 90d62c8..02583f8 100644 --- a/frontend/src/test/publications.test.tsx +++ b/frontend/src/test/publications.test.tsx @@ -23,7 +23,9 @@ function makeItem(overrides: Partial & { id: string; relays: string[] function renderHome(backend: ReturnType) { installFakeBackend(backend); - renderWithApp(); + renderWithApp( + , + ); } async function waitForData() {