Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/next/48-new-bitkit-profile.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Create profile no longer invents a random name. It prefills name, bio and links from an existing Pubky App or Bitkit profile, and leaves the name empty otherwise. When the homeserver account does not allow Pubky App data (Bitkit accounts created through Homegate), saving shows that reason instead of "Could not save profile".
1 change: 1 addition & 0 deletions changelog.d/next/48-signin-both-qrs.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Sign in shows the Pubky Ring and Bitkit QR codes side by side, each labelled, and both stay scannable until one is approved. On mobile both "Authorize with" buttons are shown.
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ vi.mock('@/controllers/profile/profile', () => ({
upload: vi.fn(),
create: vi.fn(),
commitCreate: vi.fn(),
readProfileSeed: vi.fn(async () => null),
},
}));
vi.mock('@/controllers/file/file', () => ({
Expand Down
53 changes: 42 additions & 11 deletions src/components/organisms/SignIn/SignIn.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { useGrantSignInAvailable } from '@/hooks/useGrantSignInAvailable/useGrantSignInAvailable';
import { useMobileAuth } from '@/hooks/useMobileAuth/useMobileAuth';
Expand Down Expand Up @@ -536,28 +536,59 @@ describe('SignInContent - Bitkit grant sign-in', () => {
expect(useMobileAuth).not.toHaveBeenCalledWith({ type: 'grant' });
});

it('switches to a Bitkit grant QR and back to a fresh Ring QR', async () => {
it('shows the Ring and Bitkit QRs side by side, each labelled, with no link to click', async () => {
vi.mocked(useGrantSignInAvailable).mockReturnValue(true);
await act(async () => {
render(<SignInContent />);
});

expect(useMobileAuth).not.toHaveBeenCalledWith({ type: 'grant' });
expect(useMobileAuth).toHaveBeenCalledWith();
expect(useMobileAuth).toHaveBeenCalledWith({ type: 'grant' });
const ring = screen.getByTestId('sign-in-ring-option');
const bitkit = screen.getByTestId('sign-in-bitkit-option');
expect(within(ring).getByText('Pubky Ring')).toBeInTheDocument();
expect(within(ring).getByRole('button', { name: 'Copy authentication link' })).toBeInTheDocument();
expect(within(bitkit).getByText('Bitkit')).toBeInTheDocument();
expect(within(bitkit).getByText('Scan with Bitkit 2.5 or newer.')).toBeInTheDocument();
expect(within(bitkit).getByRole('button', { name: 'Copy Bitkit authentication link' })).toBeInTheDocument();
expect(screen.queryByTestId('sign-in-use-grant')).not.toBeInTheDocument();
expect(screen.queryByTestId('sign-in-use-ring')).not.toBeInTheDocument();
});

it('offers both authorize buttons on mobile', async () => {
vi.mocked(useGrantSignInAvailable).mockReturnValue(true);
await act(async () => {
fireEvent.click(screen.getAllByTestId('sign-in-use-grant')[0]);
render(<SignInContent />);
});

expect(useMobileAuth).toHaveBeenCalledWith({ type: 'grant' });
expect(screen.getByTestId('sign-in-grant-qr-card')).toBeInTheDocument();
expect(screen.queryByTestId('sign-in-qr-card')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Copy Bitkit authentication link' })).toBeInTheDocument();
expect(screen.getByText('Authorize with Pubky Ring')).toBeInTheDocument();
expect(screen.getByText('Authorize with Bitkit')).toBeInTheDocument();
expect(screen.getByTestId('sign-in-grant-button')).toBeInTheDocument();
});

it('copying the Bitkit QR copies the Bitkit flow URL, not the Ring one', async () => {
vi.mocked(useGrantSignInAvailable).mockReturnValue(true);
const ringCopy = vi.fn().mockResolvedValue(undefined);
const bitkitCopy = vi.fn().mockResolvedValue(undefined);
vi.mocked(useMobileAuth).mockImplementation((options) => ({
url: options?.type === 'grant' ? 'pubkyauth://signin_grant?x' : 'pubkyauth://signin?x',
isLoading: false,
isExpired: false,
fetchUrl: mockFetchUrl,
copyAuthUrl: options?.type === 'grant' ? bitkitCopy : ringCopy,
isOpeningRing: false,
onAuthorizeClick: mockOnAuthorizeClick,
}));
await act(async () => {
render(<SignInContent />);
});

await act(async () => {
fireEvent.click(screen.getAllByTestId('sign-in-use-ring')[0]);
fireEvent.click(screen.getByRole('button', { name: 'Copy Bitkit authentication link' }));
});

expect(screen.getByTestId('sign-in-qr-card')).toBeInTheDocument();
expect(mockFetchUrl).toHaveBeenCalledTimes(1);
expect(bitkitCopy).toHaveBeenCalledTimes(1);
expect(ringCopy).not.toHaveBeenCalled();
});
});

Expand Down
219 changes: 118 additions & 101 deletions src/components/organisms/SignIn/SignIn.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
'use client';

import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import Image from 'next/image';
import { CheckCircle, Circle, Key, Loader2, RefreshCw } from 'lucide-react';
import { Button } from '@/atoms/Button/Button';
import { Card } from '@/atoms/Card/Card';
import { Container } from '@/atoms/Container/Container';
import { FooterLinks } from '@/atoms/FooterLinks/FooterLinks';
import { Link } from '@/atoms/Link/Link';
Expand Down Expand Up @@ -107,100 +108,125 @@ async function copyWithToast(copy: () => Promise<void>) {
}
}

/**
* Bitkit sign-in: a grant QR (`pubkyauth://signin_grant`). Mounting it starts
* the grant flow, which supersedes the Ring QR's flow.
*/
const SignInGrantPanel = ({ onUseRing }: { onUseRing: () => void }) => {
const { url, isLoading, isExpired, fetchUrl, copyAuthUrl, isOpeningRing, onAuthorizeClick } = useMobileAuth({
type: 'grant',
});
const isMobileLaunching = isLoading || isOpeningRing;
type TSignerAuth = ReturnType<typeof useMobileAuth>;

const SIGNERS = {
ring: {
name: 'Pubky Ring',
hint: 'Scan with Pubky Ring.',
copyLabel: 'Copy authentication link',
reloadLabel: 'Reload sign-in QR code',
openingLabel: 'Opening Pubky Ring...',
showRingLogo: true,
},
bitkit: {
name: 'Bitkit',
hint: 'Scan with Bitkit 2.5 or newer.',
copyLabel: 'Copy Bitkit authentication link',
reloadLabel: 'Reload Bitkit sign-in QR code',
openingLabel: 'Opening Bitkit...',
showRingLogo: false,
},
} as const;

/** One signer's labelled QR in the side-by-side desktop layout. */
const SignInQrOption = ({ signer, auth }: { signer: keyof typeof SIGNERS; auth: TSignerAuth }) => {
const copy = SIGNERS[signer];
const { url, isLoading, isExpired, fetchUrl, copyAuthUrl } = auth;
const handleQRClick = async () => {
if (!url) return;
await copyWithToast(copyAuthUrl);
};
const ringSwitch = (
<Button variant="link" onClick={onUseRing} data-testid="sign-in-use-ring">
{'Use Pubky Ring instead'}
return (
<div className="flex flex-col items-center gap-4" data-testid={`sign-in-${signer}-option`}>
<Typography as="h2" className="text-xl font-bold text-foreground">
{copy.name}
</Typography>
<button
type="button"
className="group relative flex size-48 cursor-pointer items-center justify-center rounded-md bg-foreground p-2"
onClick={isExpired ? fetchUrl : handleQRClick}
disabled={isLoading || (!url && !isExpired)}
aria-label={isExpired ? copy.reloadLabel : copy.copyLabel}
>
<QrCodeSlot
isLoading={isLoading}
isExpired={isExpired}
url={url}
generatingLabel={'Generating QR Code...'}
clickToReloadLabel={'Click to reload'}
activeQrHasHoverEffect
showRingLogo={copy.showRingLogo}
/>
</button>
<Typography as="span" className="text-center text-muted-foreground">
{copy.hint}
</Typography>
</div>
);
};

/** One signer's deeplink button in the stacked mobile layout. */
const SignInAuthorizeButton = ({ signer, auth }: { signer: keyof typeof SIGNERS; auth: TSignerAuth }) => {
const copy = SIGNERS[signer];
const { url, isLoading, isExpired, isOpeningRing, onAuthorizeClick } = auth;
const isMobileLaunching = isLoading || isOpeningRing;
return (
<Button
className="w-full"
size="lg"
onClick={onAuthorizeClick}
disabled={isMobileLaunching || (!url && !isExpired)}
aria-busy={isMobileLaunching}
data-testid={signer === 'ring' ? 'button' : 'sign-in-grant-button'}
>
{isMobileLaunching ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
<Typography as="span" overrideDefaults aria-live="polite">
{isOpeningRing ? copy.openingLabel : 'Generating...'}
</Typography>
</>
) : isExpired ? (
<>
<RefreshCw className="mr-2 size-4" />
{'Click to reload'}
</>
) : (
<>
<Key className="mr-2 size-4" />
{`Authorize with ${copy.name}`}
</>
)}
</Button>
);
};

/**
* Ring and Bitkit side by side. Each QR runs its own flow; the first approval
* wins and the controller cancels the other.
*/
const SignInBothSigners = ({ ring }: { ring: TSignerAuth }) => {
const bitkit = useMobileAuth({ type: 'grant' });
return (
<>
<Container size="container" className="hidden md:flex">
<SignInHeader signer="bitkit" />
<BalancedQrCard
data-testid="sign-in-grant-qr-card"
illustration={
<Image
priority
src="/images/scan.webp"
alt="Phone scanning a QR code"
width={192}
height={192}
className="size-48"
/>
}
<SignInHeader signer="both" />
<Card
data-testid="sign-in-qr-card"
className="w-full flex-row items-start justify-center gap-12 rounded-md p-6 lg:gap-24 lg:p-12"
>
<button
type="button"
className="group relative flex size-48 cursor-pointer items-center justify-center rounded-md bg-foreground p-2"
onClick={isExpired ? fetchUrl : handleQRClick}
disabled={isLoading || (!url && !isExpired)}
aria-label={isExpired ? 'Reload Bitkit sign-in QR code' : 'Copy Bitkit authentication link'}
>
<QrCodeSlot
isLoading={isLoading}
isExpired={isExpired}
url={url}
generatingLabel={'Generating QR Code...'}
clickToReloadLabel={'Click to reload'}
activeQrHasHoverEffect
showRingLogo={false}
/>
</button>
</BalancedQrCard>
<Container className="flex-row items-center gap-2">
<Typography as="span" className="text-muted-foreground">
{'Scan with Bitkit 2.5 or newer.'}
</Typography>
{ringSwitch}
</Container>
<SignInQrOption signer="ring" auth={ring} />
<SignInQrOption signer="bitkit" auth={bitkit} />
</Card>
</Container>

<Container size="container" className="md:hidden">
<SignInHeader signer="bitkit" />
<SignInHeader signer="both" />
<ContentCard layout="column">
<Container className="flex-col items-center justify-center gap-6">
<Button
className="w-full"
size="lg"
onClick={onAuthorizeClick}
disabled={isMobileLaunching || (!url && !isExpired)}
aria-busy={isMobileLaunching}
data-testid="sign-in-grant-button"
>
{isMobileLaunching ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
<Typography as="span" overrideDefaults aria-live="polite">
{isOpeningRing ? 'Opening Bitkit...' : 'Generating...'}
</Typography>
</>
) : isExpired ? (
<>
<RefreshCw className="mr-2 size-4" />
{'Click to reload'}
</>
) : (
<>
<Key className="mr-2 size-4" />
{'Authorize with Bitkit'}
</>
)}
</Button>
{ringSwitch}
<Container className="flex-col items-center justify-center gap-4">
<SignInAuthorizeButton signer="ring" auth={ring} />
<SignInAuthorizeButton signer="bitkit" auth={bitkit} />
</Container>
</ContentCard>
</Container>
Expand All @@ -209,10 +235,10 @@ const SignInGrantPanel = ({ onUseRing }: { onUseRing: () => void }) => {
};

export const SignInContent = () => {
const { url, isLoading, isExpired, fetchUrl, copyAuthUrl, isOpeningRing, onAuthorizeClick } = useMobileAuth();
const ringAuth = useMobileAuth();
const { url, isLoading, isExpired, fetchUrl, copyAuthUrl, isOpeningRing, onAuthorizeClick } = ringAuth;
const authUrlResolved = useSignInStore((state) => state.authUrlResolved);
const isGrantSignInAvailable = useGrantSignInAvailable();
const [signer, setSigner] = useState<'ring' | 'bitkit'>('ring');
useEffect(() => {
// Clear onboarding storage when sign-in flow begins to prevent backup reminders from showing for existing users
useOnboardingStore.getState().reset();
Expand Down Expand Up @@ -252,22 +278,9 @@ export const SignInContent = () => {
</Container>
);
}
if (signer === 'bitkit') {
return (
<SignInGrantPanel
onUseRing={() => {
setSigner('ring');
// The grant flow superseded the Ring flow; mint a fresh Ring QR.
void fetchUrl();
}}
/>
);
if (isGrantSignInAvailable) {
return <SignInBothSigners ring={ringAuth} />;
}
const bitkitSwitch = isGrantSignInAvailable ? (
<Button variant="link" onClick={() => setSigner('bitkit')} data-testid="sign-in-use-grant">
{'Signing in with Bitkit? Use Bitkit instead'}
</Button>
) : null;
return (
<>
<Container size="container" className="hidden md:flex">
Expand Down Expand Up @@ -302,7 +315,6 @@ export const SignInContent = () => {
/>
</button>
</BalancedQrCard>
{bitkitSwitch}
</Container>

{/** Mobile view */}
Expand All @@ -321,7 +333,6 @@ export const SignInContent = () => {
>
{mobileAuthorizeContent}
</Button>
{bitkitSwitch}
</Container>
</ContentCard>
</Container>
Expand All @@ -341,7 +352,7 @@ export const SignInFooter = () => {
</FooterLinks>
);
};
export const SignInHeader = ({ signer = 'ring' }: { signer?: 'ring' | 'bitkit' }) => {
export const SignInHeader = ({ signer = 'ring' }: { signer?: 'ring' | 'both' }) => {
return (
<PageHeader>
<PageTitle size="large">
Expand All @@ -350,7 +361,13 @@ export const SignInHeader = ({ signer = 'ring' }: { signer?: 'ring' | 'bitkit' }
</PageTitle>
<PageSubtitle>
{'Authorize with '}
<span className="text-brand">{signer === 'bitkit' ? 'Bitkit' : 'Pubky Ring'}</span>
<span className="text-brand">{'Pubky Ring'}</span>
{signer === 'both' ? (
<>
{' or '}
<span className="text-brand">{'Bitkit'}</span>
</>
) : null}
{' to sign in.'}
</PageSubtitle>
</PageHeader>
Expand Down
Loading
Loading