Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Typography } from '@/atoms/Typography/Typography';

/** Shown instead of a Pubky Ring approval QR when the Shop session came from a Bitkit sign-in. */
export function GrantSessionRefusal() {
return (
<div
role="status"
data-testid="grant-session-refusal"
className="rounded-xl border border-border bg-muted/40 p-4 text-sm text-muted-foreground"
>
<Typography as="p" className="text-sm text-muted-foreground">
{'Bitkit sign-in does not cover this step yet. Sign in with Pubky Ring to continue.'}
</Typography>
</div>
);
}
23 changes: 13 additions & 10 deletions src/components/molecules/QrCodeSlot/QrCodeSlot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export function QrCodeSlot({
size = DEFAULT_QR_SIZE,
activeQrHasHoverEffect = false,
expiredReloadAction,
showRingLogo = true,
}: QrCodeSlotProps) {
const ringLogoSize = Math.round((DEFAULT_RING_LOGO_SIZE / DEFAULT_QR_SIZE) * size);

Expand Down Expand Up @@ -68,16 +69,18 @@ export function QrCodeSlot({
return (
<span data-testid="qr-auth-url" data-auth-url={url} className="contents">
<QRCodeSVG value={url} size={size} className={cn(activeQrHasHoverEffect && HOVER_OPACITY)} />
<Image
src="/images/ring-logo.svg"
alt="Pubky Ring"
width={ringLogoSize}
height={ringLogoSize}
className={cn(
'absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2',
activeQrHasHoverEffect && HOVER_OPACITY,
)}
/>
{showRingLogo && (
<Image
src="/images/ring-logo.svg"
alt="Pubky Ring"
width={ringLogoSize}
height={ringLogoSize}
className={cn(
'absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2',
activeQrHasHoverEffect && HOVER_OPACITY,
)}
/>
)}
</span>
);
}
2 changes: 2 additions & 0 deletions src/components/molecules/QrCodeSlot/QrCodeSlot.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,6 @@ export interface QrCodeSlotProps {
onClick: () => void;
ariaLabel: string;
};
/** The Pubky Ring logo over the QR. Off for QRs meant for another signer (Bitkit). Defaults to true. */
showRingLogo?: boolean;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MarketplaceInventoryGrantDialog } from './MarketplaceInventoryGrantDialog';
import { MarketplaceMessagingEnablePanel } from './MarketplaceMessagingEnableDialog';

const state = vi.hoisted(() => ({
isGrantSession: true,
inventoryStart: vi.fn(),
messagingStart: vi.fn(),
}));

vi.mock('@/hooks/useIsGrantSession/useIsGrantSession', () => ({
useIsGrantSession: () => state.isGrantSession,
}));

vi.mock('@/hooks/useMarketplaceInventoryGrantConnect/useMarketplaceInventoryGrantConnect', () => ({
useMarketplaceInventoryGrantConnect: () => ({
status: 'awaiting',
authorizationUrl: 'pubkyauth:///?relay=https%3A%2F%2Frelay.example.com%2Finbox&secret=x',
errorMessage: null,
start: state.inventoryStart,
cancel: vi.fn(),
copyAuthUrl: vi.fn(async () => {}),
openInSigner: vi.fn(),
isOpeningSigner: false,
}),
}));

vi.mock('@/hooks/useMarketplaceMessagingEnable/useMarketplaceMessagingEnable', () => ({
useMarketplaceMessagingEnable: () => ({
status: 'awaiting',
authorizationUrl: 'pubkyauth:///?relay=https%3A%2F%2Frelay.example.com%2Finbox&secret=y',
errorMessage: null,
start: state.messagingStart,
cancel: vi.fn(),
copyAuthUrl: vi.fn(async () => {}),
openInRing: vi.fn(),
isOpeningRing: false,
}),
}));

vi.mock('@/atoms/Dialog/Dialog', () => ({
Dialog: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogFooter: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>,
DialogTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));

describe('classic Pubky Ring approvals refuse a grant session', () => {
beforeEach(() => {
state.isGrantSession = true;
state.inventoryStart.mockClear();
state.messagingStart.mockClear();
});

it('grant session sees refusal not classic qr (inventory grant)', () => {
render(<MarketplaceInventoryGrantDialog autoOpen />);

expect(screen.getByTestId('grant-session-refusal')).toBeInTheDocument();
expect(state.inventoryStart).not.toHaveBeenCalled();
});

it('grant session sees refusal not classic qr (messaging enable)', () => {
render(<MarketplaceMessagingEnablePanel reconnect={false} />);

expect(screen.getByTestId('grant-session-refusal')).toBeInTheDocument();
expect(state.messagingStart).not.toHaveBeenCalled();
});

it('cookie session keeps messaging resume path (the paykit-wasm flow still starts)', () => {
state.isGrantSession = false;
render(<MarketplaceMessagingEnablePanel reconnect={false} />);

expect(screen.queryByTestId('grant-session-refusal')).not.toBeInTheDocument();
expect(state.messagingStart).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { Copy, KeyRound, Loader2, Smartphone } from 'lucide-react';
import { Button } from '@/atoms/Button/Button';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/atoms/Dialog/Dialog';
import { Typography } from '@/atoms/Typography/Typography';
import { useIsGrantSession } from '@/hooks/useIsGrantSession/useIsGrantSession';
import { useMarketplaceInventoryGrantConnect } from '@/hooks/useMarketplaceInventoryGrantConnect/useMarketplaceInventoryGrantConnect';
import { GrantSessionRefusal } from '@/molecules/GrantSessionRefusal/GrantSessionRefusal';
import { QrCodeSlot } from '@/molecules/QrCodeSlot/QrCodeSlot';
import { toast } from '@/molecules/Toaster/use-toast';

Expand Down Expand Up @@ -35,13 +37,14 @@ export function MarketplaceInventoryGrantDialog({
if (autoOpen) setOpen(true);
}, [autoOpen]);

const isGrantSession = useIsGrantSession();
useEffect(() => {
if (open) {
start();
if (!isGrantSession) start();
return;
}
cancel();
}, [open, start, cancel]);
}, [open, start, cancel, isGrantSession]);

const copyUrl = async () => {
try {
Expand All @@ -67,7 +70,9 @@ export function MarketplaceInventoryGrantDialog({
<Typography as="p" className="text-sm text-muted-foreground">
Approve this grant in Bitkit or Pubky Ring; it does not replace your purchase session.
</Typography>
{grant.status === 'error' ? (
{isGrantSession ? (
<GrantSessionRefusal />
) : grant.status === 'error' ? (
<div role="alert" className="rounded-xl border border-destructive/40 p-4 text-sm">
{grant.errorMessage}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import { Copy, Loader2, LockKeyhole, RefreshCw, Smartphone } from 'lucide-react'
import { Button } from '@/atoms/Button/Button';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/atoms/Dialog/Dialog';
import { Typography } from '@/atoms/Typography/Typography';
import { useIsGrantSession } from '@/hooks/useIsGrantSession/useIsGrantSession';
import { useMarketplaceMessagingEnable } from '@/hooks/useMarketplaceMessagingEnable/useMarketplaceMessagingEnable';
import { MESSAGING_COPY } from '@/libs/commerce/messaging-copy';
import { Logger } from '@/libs/logger/logger';
import { GrantSessionRefusal } from '@/molecules/GrantSessionRefusal/GrantSessionRefusal';
import { QrCodeSlot } from '@/molecules/QrCodeSlot/QrCodeSlot';
import { toast } from '@/molecules/Toaster/use-toast';

Expand Down Expand Up @@ -46,10 +48,12 @@ export function MarketplaceMessagingEnablePanel({
});

const { start, cancel } = enable;
const isGrantSession = useIsGrantSession();
useEffect(() => {
if (isGrantSession) return;
start();
return cancel;
}, [start, cancel]);
}, [start, cancel, isGrantSession]);

const copyUrl = async () => {
try {
Expand All @@ -67,7 +71,9 @@ export function MarketplaceMessagingEnablePanel({
{reconnect ? MESSAGING_COPY.reconnect : MESSAGING_COPY.enable}
</Typography>

{enable.status === 'error' ? (
{isGrantSession ? (
<GrantSessionRefusal />
) : enable.status === 'error' ? (
<div className="grid gap-3">
<div role="alert" className="rounded-xl border border-destructive/40 p-4 text-sm">
{enable.errorMessage}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CAPABILITIES } from '@/config/app';
import type { UseStepUpReauthReturn } from '@/hooks/useStepUpReauth/useStepUpReauth.types';
import { MarketplaceReauthDialog } from './MarketplaceReauthDialog';
Expand All @@ -20,7 +20,28 @@ vi.mock('@/hooks/useStepUpReauth/useStepUpReauth', () => ({
useStepUpReauth: () => reauth,
}));

const grant = vi.hoisted(() => ({ isGrantSession: false }));
vi.mock('@/hooks/useIsGrantSession/useIsGrantSession', () => ({
useIsGrantSession: () => grant.isGrantSession,
}));

describe('MarketplaceReauthDialog', () => {
beforeEach(() => {
grant.isGrantSession = false;
vi.mocked(reauth.start).mockClear();
});

it('grant session sees refusal not classic qr (step-up)', async () => {
grant.isGrantSession = true;
render(<MarketplaceReauthDialog triggerLabel="Sign in again" />);

await userEvent.setup().click(screen.getByRole('button', { name: 'Sign in again' }));

expect(screen.getByTestId('grant-session-refusal')).toBeInTheDocument();
expect(screen.queryByLabelText('Copy authorization link')).not.toBeInTheDocument();
expect(reauth.start).not.toHaveBeenCalled();
});

it('asks for a sign-in in product language and does not print capability paths', async () => {
render(<MarketplaceReauthDialog triggerLabel="Sign in again" />);

Expand Down
11 changes: 8 additions & 3 deletions src/components/organisms/Marketplace/MarketplaceReauthDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import { Copy, KeyRound, Loader2, RefreshCw, Smartphone } from 'lucide-react';
import { Button } from '@/atoms/Button/Button';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/atoms/Dialog/Dialog';
import { Typography } from '@/atoms/Typography/Typography';
import { useIsGrantSession } from '@/hooks/useIsGrantSession/useIsGrantSession';
import { useStepUpReauth } from '@/hooks/useStepUpReauth/useStepUpReauth';
import { Logger } from '@/libs/logger/logger';
import { GrantSessionRefusal } from '@/molecules/GrantSessionRefusal/GrantSessionRefusal';
import { QrCodeSlot } from '@/molecules/QrCodeSlot/QrCodeSlot';
import { toast } from '@/molecules/Toaster/use-toast';

Expand Down Expand Up @@ -45,13 +47,14 @@ export function MarketplaceReauthDialog({
// Referencing `reauth.start`/`reauth.cancel` directly keeps the effect
// dependency-stable: both are useCallback-memoized in the hook.
const { start, cancel } = reauth;
const isGrantSession = useIsGrantSession();
useEffect(() => {
if (open) {
start();
if (!isGrantSession) start();
return;
}
cancel();
}, [open, start, cancel]);
}, [open, start, cancel, isGrantSession]);

const copyUrl = async () => {
try {
Expand Down Expand Up @@ -80,7 +83,9 @@ export function MarketplaceReauthDialog({
Sign in again for this device.
</Typography>

{reauth.status === 'error' ? (
{isGrantSession ? (
<GrantSessionRefusal />
) : reauth.status === 'error' ? (
<div className="grid gap-3">
<div role="alert" className="rounded-xl border border-destructive/40 p-4 text-sm">
{reauth.errorMessage}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ const view = vi.hoisted(() => ({
isOpeningRing: false,
requestsFullGrant: true,
requestsGrantReconnect: false,
isGrantSession: false,
start: vi.fn(),
}));
vi.mock('@/hooks/useIsGrantSession/useIsGrantSession', () => ({
useIsGrantSession: () => view.isGrantSession,
}));

vi.mock('@/hooks/useMarketplaceSessionConnect/useMarketplaceSessionConnect', () => ({
Expand All @@ -25,7 +30,7 @@ vi.mock('@/hooks/useMarketplaceSessionConnect/useMarketplaceSessionConnect', ()
errorMessage: view.errorMessage,
requestsFullGrant: view.requestsFullGrant,
requestsGrantReconnect: view.requestsGrantReconnect,
start: vi.fn(),
start: view.start,
cancel: vi.fn(),
copyAuthUrl: vi.fn(async () => {}),
openInRing: vi.fn(),
Expand Down Expand Up @@ -56,6 +61,29 @@ describe('MarketplaceSessionConnectDialog', () => {
view.isOpeningRing = false;
view.requestsFullGrant = true;
view.requestsGrantReconnect = false;
view.isGrantSession = false;
view.start.mockClear();
});

it('grant session sees refusal not classic qr', () => {
view.status = 'awaiting';
view.authorizationUrl = 'pubkyauth:///?relay=https%3A%2F%2Frelay.example.com%2Finbox&secret=x';
view.isGrantSession = true;

render(<MarketplaceSessionConnectDialog autoOpen />);

expect(screen.getByTestId('grant-session-refusal')).toBeInTheDocument();
expect(screen.queryByLabelText('Copy authorization link')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /open in pubky ring/i })).not.toBeInTheDocument();
expect(view.start).not.toHaveBeenCalled();
});

it('a cookie session still starts the classic approval when opened', () => {
view.status = 'awaiting';
render(<MarketplaceSessionConnectDialog autoOpen />);

expect(view.start).toHaveBeenCalled();
expect(screen.queryByTestId('grant-session-refusal')).not.toBeInTheDocument();
});

it('joined state: honest copy, and no QR slot, Copy, or Open affordances', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import { Copy, KeyRound, Loader2, RefreshCw, Smartphone } from 'lucide-react';
import { Button } from '@/atoms/Button/Button';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/atoms/Dialog/Dialog';
import { Typography } from '@/atoms/Typography/Typography';
import { useIsGrantSession } from '@/hooks/useIsGrantSession/useIsGrantSession';
import { useMarketplaceSessionConnect } from '@/hooks/useMarketplaceSessionConnect/useMarketplaceSessionConnect';
import { Logger } from '@/libs/logger/logger';
import { getMarketplaceGrantFlowEnabled } from '@/libs/runtime-config/runtime-config';
import { GrantSessionRefusal } from '@/molecules/GrantSessionRefusal/GrantSessionRefusal';
import { QrCodeSlot } from '@/molecules/QrCodeSlot/QrCodeSlot';
import { toast } from '@/molecules/Toaster/use-toast';

Expand Down Expand Up @@ -51,13 +53,14 @@ export function MarketplaceSessionConnectDialog({
if (autoOpen) setOpen(true);
}, [autoOpen]);

const isGrantSession = useIsGrantSession();
useEffect(() => {
if (open) {
start();
if (!isGrantSession) start();
return;
}
cancel();
}, [open, start, cancel]);
}, [open, start, cancel, isGrantSession]);

const copyUrl = async () => {
try {
Expand Down Expand Up @@ -96,7 +99,9 @@ export function MarketplaceSessionConnectDialog({
: 'Approve purchases for this device.'}
</Typography>

{['error', 'mismatch', 'expired', 'cancelled'].includes(session.status) ? (
{isGrantSession ? (
<GrantSessionRefusal />
) : ['error', 'mismatch', 'expired', 'cancelled'].includes(session.status) ? (
<div className="grid gap-3">
<div role="alert" className="rounded-xl border border-destructive/40 p-4 text-sm">
{session.status === 'mismatch'
Expand Down
Loading
Loading