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.
This commit is contained in:
parent
963b740992
commit
13a66f28d8
6 changed files with 76 additions and 20 deletions
|
|
@ -14,6 +14,7 @@ import { SignerScreen } from './screens/SignerScreen';
|
||||||
import { SignerModeScreen } from './screens/SignerModeScreen';
|
import { SignerModeScreen } from './screens/SignerModeScreen';
|
||||||
import { SettingsScreen } from './screens/SettingsScreen';
|
import { SettingsScreen } from './screens/SettingsScreen';
|
||||||
import { CreateProfileModal } from './screens/CreateProfileModal';
|
import { CreateProfileModal } from './screens/CreateProfileModal';
|
||||||
|
import { ImportProfileModal } from './screens/ImportProfileModal';
|
||||||
import { AppProvider, useApp, useThemeSync } from './state/AppProvider';
|
import { AppProvider, useApp, useThemeSync } from './state/AppProvider';
|
||||||
import type { Screen } from './lib/navigation';
|
import type { Screen } from './lib/navigation';
|
||||||
|
|
||||||
|
|
@ -21,6 +22,7 @@ function Shell() {
|
||||||
const { state, loading, bootstrapError } = useApp();
|
const { state, loading, bootstrapError } = useApp();
|
||||||
const [screen, setScreen] = useState<Screen>('home');
|
const [screen, setScreen] = useState<Screen>('home');
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
const [unlockOpen, setUnlockOpen] = useState(false);
|
const [unlockOpen, setUnlockOpen] = useState(false);
|
||||||
|
|
||||||
useThemeSync(state?.settings.theme);
|
useThemeSync(state?.settings.theme);
|
||||||
|
|
@ -68,7 +70,11 @@ function Shell() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{screen === 'home' && (
|
{screen === 'home' && (
|
||||||
<HomeScreen onNavigate={setScreen} onCreateProfile={() => setCreateOpen(true)} />
|
<HomeScreen
|
||||||
|
onNavigate={setScreen}
|
||||||
|
onCreateProfile={() => setCreateOpen(true)}
|
||||||
|
onImportProfile={() => setImportOpen(true)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{screen === 'feed' && <FeedScreen onNavigate={setScreen} />}
|
{screen === 'feed' && <FeedScreen onNavigate={setScreen} />}
|
||||||
{screen === 'profiles' && <ProfilesScreen onCreateProfile={() => setCreateOpen(true)} />}
|
{screen === 'profiles' && <ProfilesScreen onCreateProfile={() => setCreateOpen(true)} />}
|
||||||
|
|
@ -79,6 +85,7 @@ function Shell() {
|
||||||
{screen === 'settings' && <SettingsScreen />}
|
{screen === 'settings' && <SettingsScreen />}
|
||||||
</main>
|
</main>
|
||||||
<CreateProfileModal open={createOpen} onClose={() => setCreateOpen(false)} />
|
<CreateProfileModal open={createOpen} onClose={() => setCreateOpen(false)} />
|
||||||
|
<ImportProfileModal open={importOpen} onClose={() => setImportOpen(false)} />
|
||||||
<UnlockModal open={unlockOpen} onClose={() => setUnlockOpen(false)} />
|
<UnlockModal open={unlockOpen} onClose={() => setUnlockOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,10 @@ import { useApp } from '../state/AppProvider';
|
||||||
interface HomeScreenProps {
|
interface HomeScreenProps {
|
||||||
onNavigate: (screen: Screen) => void;
|
onNavigate: (screen: Screen) => void;
|
||||||
onCreateProfile: () => void;
|
onCreateProfile: () => void;
|
||||||
|
onImportProfile: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
|
export function HomeScreen({ onNavigate, onCreateProfile, onImportProfile }: HomeScreenProps) {
|
||||||
const { state, selectProfile } = useApp();
|
const { state, selectProfile } = useApp();
|
||||||
const { publications, fullyPublished, loading, error } = useProfilePublications();
|
const { publications, fullyPublished, loading, error } = useProfilePublications();
|
||||||
const [selecting, setSelecting] = useState<string | null>(null);
|
const [selecting, setSelecting] = useState<string | null>(null);
|
||||||
|
|
@ -43,16 +44,27 @@ export function HomeScreen({ onNavigate, onCreateProfile }: HomeScreenProps) {
|
||||||
title="Welcome to Keynctr"
|
title="Welcome to Keynctr"
|
||||||
description={
|
description={
|
||||||
<span>
|
<span>
|
||||||
You haven't created a profile yet. A Nostr profile is your identity on the public
|
A Nostr profile is your identity on the public Nostr network — a <code>npub</code>{' '}
|
||||||
Nostr network — a <code>npub</code> address you can share, plus a private key kept
|
address you can share. You can create a new one here (its private key is generated
|
||||||
safely on this computer. Create your first profile to start publishing notes.
|
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.
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
action={
|
action={
|
||||||
|
<div className="onboarding-actions">
|
||||||
<Button variant="primary" onClick={onCreateProfile}>
|
<Button variant="primary" onClick={onCreateProfile}>
|
||||||
<Icon name="plus" size={18} />
|
<Icon name="plus" size={18} />
|
||||||
Create your first profile
|
Create a new profile
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={onImportProfile}>
|
||||||
|
<Icon name="key" size={18} />I already have an account
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={() => onNavigate('signer-mode')}>
|
||||||
|
<Icon name="server" size={18} />
|
||||||
|
Sign in with a signer (Amber, NIP-46)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<FirstRunGuide />
|
<FirstRunGuide />
|
||||||
|
|
|
||||||
|
|
@ -1402,6 +1402,18 @@ select {
|
||||||
margin-top: 8px;
|
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
|
Home
|
||||||
------------------------------------------------------------------------- */
|
------------------------------------------------------------------------- */
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,12 @@ describe('App', () => {
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
expect(await screen.findByText('Welcome to Keynctr')).toBeInTheDocument();
|
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();
|
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(
|
expect(
|
||||||
await screen.findByRole('dialog', { name: 'Create a Nostr profile' }),
|
await screen.findByRole('dialog', { name: 'Create a Nostr profile' }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,18 @@ import { createFakeBackend, installFakeBackend } from './fakeBackend';
|
||||||
|
|
||||||
function renderHome(
|
function renderHome(
|
||||||
backend: ReturnType<typeof createFakeBackend>,
|
backend: ReturnType<typeof createFakeBackend>,
|
||||||
overrides: { onNavigate?: () => void; onCreateProfile?: () => void } = {},
|
overrides: {
|
||||||
|
onNavigate?: () => void;
|
||||||
|
onCreateProfile?: () => void;
|
||||||
|
onImportProfile?: () => void;
|
||||||
|
} = {},
|
||||||
) {
|
) {
|
||||||
installFakeBackend(backend);
|
installFakeBackend(backend);
|
||||||
return {
|
return {
|
||||||
user: userEvent.setup(),
|
user: userEvent.setup(),
|
||||||
onNavigate: overrides.onNavigate ?? vi.fn(),
|
onNavigate: overrides.onNavigate ?? vi.fn(),
|
||||||
onCreateProfile: overrides.onCreateProfile ?? 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 () => {
|
it('shows the active profile, a shortened npub, and a compose button', async () => {
|
||||||
const backend = createFakeBackend();
|
const backend = createFakeBackend();
|
||||||
const { onNavigate } = renderHome(backend);
|
const { onNavigate } = renderHome(backend);
|
||||||
renderWithApp(<HomeScreen onNavigate={onNavigate} onCreateProfile={vi.fn()} />);
|
renderWithApp(
|
||||||
|
<HomeScreen onNavigate={onNavigate} onCreateProfile={vi.fn()} onImportProfile={vi.fn()} />,
|
||||||
|
);
|
||||||
|
|
||||||
// The active profile appears in the profile list with its shortened npub.
|
// The active profile appears in the profile list with its shortened npub.
|
||||||
const profileList = await screen.findByRole('listbox');
|
const profileList = await screen.findByRole('listbox');
|
||||||
|
|
@ -36,7 +43,9 @@ describe('HomeScreen', () => {
|
||||||
it('copies the complete npub when the copy button is clicked', async () => {
|
it('copies the complete npub when the copy button is clicked', async () => {
|
||||||
const backend = createFakeBackend();
|
const backend = createFakeBackend();
|
||||||
renderHome(backend);
|
renderHome(backend);
|
||||||
renderWithApp(<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} />);
|
renderWithApp(
|
||||||
|
<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} onImportProfile={vi.fn()} />,
|
||||||
|
);
|
||||||
|
|
||||||
// The active profile row carries the "Selected" badge; find Alice via the profile list.
|
// The active profile row carries the "Selected" badge; find Alice via the profile list.
|
||||||
const profileList = await screen.findByRole('listbox');
|
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 () => {
|
it('shows the first-run state and guides the user to create a profile', async () => {
|
||||||
const backend = createFakeBackend(makeEmptyState());
|
const backend = createFakeBackend(makeEmptyState());
|
||||||
const { onCreateProfile } = renderHome(backend);
|
const { onCreateProfile, onImportProfile } = renderHome(backend);
|
||||||
renderWithApp(<HomeScreen onNavigate={vi.fn()} onCreateProfile={onCreateProfile} />);
|
renderWithApp(
|
||||||
|
<HomeScreen
|
||||||
|
onNavigate={vi.fn()}
|
||||||
|
onCreateProfile={onCreateProfile}
|
||||||
|
onImportProfile={onImportProfile}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
expect(await screen.findByText('Welcome to Keynctr')).toBeInTheDocument();
|
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
|
await userEvent
|
||||||
.setup()
|
.setup()
|
||||||
.click(screen.getByRole('button', { name: /Create your first profile/i }));
|
.click(screen.getByRole('button', { name: /I already have an account/i }));
|
||||||
expect(onCreateProfile).toHaveBeenCalled();
|
expect(onImportProfile).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('selects a profile when its row is clicked (not just the Select button)', async () => {
|
it('selects a profile when its row is clicked (not just the Select button)', async () => {
|
||||||
const backend = createFakeBackend();
|
const backend = createFakeBackend();
|
||||||
renderHome(backend);
|
renderHome(backend);
|
||||||
renderWithApp(<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} />);
|
renderWithApp(
|
||||||
|
<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} onImportProfile={vi.fn()} />,
|
||||||
|
);
|
||||||
|
|
||||||
const bobRow = (await screen.findByText('Bob')).closest('.home-profile-row') as HTMLElement;
|
const bobRow = (await screen.findByText('Bob')).closest('.home-profile-row') as HTMLElement;
|
||||||
// Clicking the name (not the Select button) should select the profile.
|
// Clicking the name (not the Select button) should select the profile.
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,9 @@ function makeItem(overrides: Partial<FeedItem> & { id: string; relays: string[]
|
||||||
|
|
||||||
function renderHome(backend: ReturnType<typeof createFakeBackend>) {
|
function renderHome(backend: ReturnType<typeof createFakeBackend>) {
|
||||||
installFakeBackend(backend);
|
installFakeBackend(backend);
|
||||||
renderWithApp(<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} />);
|
renderWithApp(
|
||||||
|
<HomeScreen onNavigate={vi.fn()} onCreateProfile={vi.fn()} onImportProfile={vi.fn()} />,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForData() {
|
async function waitForData() {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue