mirror of
https://github.com/lbr77/SideImpactor.git
synced 2026-05-06 11:14:01 -04:00
feat: enhance TrustModal with new states and improve pairing flow tests
This commit is contained in:
@@ -84,6 +84,10 @@ import { App } from './App';
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
window.localStorage.clear();
|
window.localStorage.clear();
|
||||||
window.location.hash = '';
|
window.location.hash = '';
|
||||||
|
Object.defineProperty(navigator, 'usb', {
|
||||||
|
configurable: true,
|
||||||
|
value: {},
|
||||||
|
});
|
||||||
checkProvisionedMock.mockResolvedValue(false);
|
checkProvisionedMock.mockResolvedValue(false);
|
||||||
ensureAnisetteMock.mockResolvedValue({ anisetteData: fakeAnisette, provisioned: true });
|
ensureAnisetteMock.mockResolvedValue({ anisetteData: fakeAnisette, provisioned: true });
|
||||||
loginAccountMock.mockResolvedValue(fakeContext);
|
loginAccountMock.mockResolvedValue(fakeContext);
|
||||||
@@ -115,6 +119,35 @@ describe('App — page + nav', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('App — pairing modal', () => {
|
||||||
|
it('keeps trust pending separate from paired success and retries cleanly', async () => {
|
||||||
|
const pendingError = new Error('Pair error=PairingDialogResponsePending');
|
||||||
|
pairDeviceFlowMock
|
||||||
|
.mockImplementationOnce(async (ctx) => {
|
||||||
|
ctx.onTrustPending();
|
||||||
|
throw pendingError;
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({ udid: 'UDID-TEST', name: 'iPhone' });
|
||||||
|
isPairingDialogPendingErrorMock.mockImplementation(
|
||||||
|
(error) => error === pendingError || String(error).includes('PairingDialogResponsePending'),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /Sign & Install/ }));
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Connect Device' }));
|
||||||
|
|
||||||
|
expect(await screen.findByText('Trust This Device')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Device Paired')).toBeNull();
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'I Trusted This Device' }));
|
||||||
|
expect(await screen.findByText('Device Paired')).toBeInTheDocument();
|
||||||
|
await waitFor(() => expect(pairDeviceFlowMock).toHaveBeenCalledTimes(2));
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Continue' }));
|
||||||
|
expect(screen.queryByText('Device Paired')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('App — login modal', () => {
|
describe('App — login modal', () => {
|
||||||
it('opens login modal on Add Account, then logs in and navigates to sign', async () => {
|
it('opens login modal on Add Account, then logs in and navigates to sign', async () => {
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Header, type AppPage } from './components/Header';
|
|||||||
import { LoginPage } from './components/LoginPage';
|
import { LoginPage } from './components/LoginPage';
|
||||||
import { LoginModal } from './components/LoginModal';
|
import { LoginModal } from './components/LoginModal';
|
||||||
import { SignPage } from './components/SignPage';
|
import { SignPage } from './components/SignPage';
|
||||||
import { TrustModal } from './components/TrustModal';
|
import { TrustModal, type TrustModalState } from './components/TrustModal';
|
||||||
import { TwoFactorModal } from './components/TwoFactorModal';
|
import { TwoFactorModal } from './components/TwoFactorModal';
|
||||||
import { ProgressCard } from './components/ProgressCard';
|
import { ProgressCard } from './components/ProgressCard';
|
||||||
|
|
||||||
@@ -42,6 +42,7 @@ import { useLog } from './lib/use-log';
|
|||||||
|
|
||||||
const LOGIN_PAGE_HASH = '#/login';
|
const LOGIN_PAGE_HASH = '#/login';
|
||||||
const SIGN_PAGE_HASH = '#/sign';
|
const SIGN_PAGE_HASH = '#/sign';
|
||||||
|
const TRUST_MODAL_CLOSED: TrustModalState = 'closed';
|
||||||
|
|
||||||
type BusyState = {
|
type BusyState = {
|
||||||
pair: boolean;
|
pair: boolean;
|
||||||
@@ -50,6 +51,11 @@ type BusyState = {
|
|||||||
install: boolean;
|
install: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PairFlowResult =
|
||||||
|
| { kind: 'paired'; info: PairedDeviceInfo }
|
||||||
|
| { kind: 'pending' }
|
||||||
|
| { kind: 'failed' };
|
||||||
|
|
||||||
const idleBusy: BusyState = { pair: false, loginSign: false, sign: false, install: false };
|
const idleBusy: BusyState = { pair: false, loginSign: false, sign: false, install: false };
|
||||||
|
|
||||||
function resolvePageFromHash(hash: string): AppPage {
|
function resolvePageFromHash(hash: string): AppPage {
|
||||||
@@ -105,7 +111,7 @@ export function App() {
|
|||||||
const lastInstallPercentRef = useRef<number>(0);
|
const lastInstallPercentRef = useRef<number>(0);
|
||||||
|
|
||||||
// Modals
|
// Modals
|
||||||
const [trustOpen, setTrustOpen] = useState<boolean>(false);
|
const [trustState, setTrustState] = useState<TrustModalState>(TRUST_MODAL_CLOSED);
|
||||||
const [twoFactor, setTwoFactor] = useState<{
|
const [twoFactor, setTwoFactor] = useState<{
|
||||||
open: boolean;
|
open: boolean;
|
||||||
submit: ((code: string) => void) | null;
|
submit: ((code: string) => void) | null;
|
||||||
@@ -131,6 +137,7 @@ export function App() {
|
|||||||
const activeAccountKey = loginContext ? accountKey(loginContext.appleId, loginContext.team.identifier) : null;
|
const activeAccountKey = loginContext ? accountKey(loginContext.appleId, loginContext.team.identifier) : null;
|
||||||
|
|
||||||
const cachedAccountKeys = useMemo(() => new Set(accountContextMapRef.current.keys()), [savedAccounts, loginContext]);
|
const cachedAccountKeys = useMemo(() => new Set(accountContextMapRef.current.keys()), [savedAccounts, loginContext]);
|
||||||
|
const trustOpen = trustState !== TRUST_MODAL_CLOSED;
|
||||||
|
|
||||||
// ---- log + progress plumbing ----
|
// ---- log + progress plumbing ----
|
||||||
const addLog = useCallback(
|
const addLog = useCallback(
|
||||||
@@ -259,17 +266,25 @@ export function App() {
|
|||||||
}, [knownUdids, selectedTargetUdid]);
|
}, [knownUdids, selectedTargetUdid]);
|
||||||
|
|
||||||
// ---- pair flow ----
|
// ---- pair flow ----
|
||||||
const handlePair = useCallback(async (): Promise<PairedDeviceInfo | null> => {
|
/**
|
||||||
if (busyRef.current.pair) return null;
|
* lockdownd can return PairingDialogResponsePending before the on-device trust sheet is resolved.
|
||||||
|
* Keep that state separate so the UI never renders "paired" until pairDevice + startSession both finish.
|
||||||
|
*/
|
||||||
|
const runPairFlow = useCallback(async (options: {
|
||||||
|
showSuccess: boolean;
|
||||||
|
startLogMessage: string;
|
||||||
|
}): Promise<PairFlowResult> => {
|
||||||
|
if (busyRef.current.pair) return { kind: 'failed' };
|
||||||
setBusy((prev) => ({ ...prev, pair: true }));
|
setBusy((prev) => ({ ...prev, pair: true }));
|
||||||
setTrustOpen(true);
|
setTrustState('pairing');
|
||||||
addLog('pair: please continue on your device');
|
addLog(options.startLogMessage);
|
||||||
try {
|
try {
|
||||||
const info = await pairDeviceFlow({
|
const info = await pairDeviceFlow({
|
||||||
log: addLog,
|
log: addLog,
|
||||||
clientRef: directClientRef,
|
clientRef: directClientRef,
|
||||||
onStateChange: () => {},
|
onStateChange: () => {},
|
||||||
onTrustPending: () => {
|
onTrustPending: () => {
|
||||||
|
setTrustState('pending');
|
||||||
addLog('pair: waiting for trust confirmation on device');
|
addLog('pair: waiting for trust confirmation on device');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -279,20 +294,34 @@ export function App() {
|
|||||||
setSelectedTargetUdid(info.udid);
|
setSelectedTargetUdid(info.udid);
|
||||||
saveText(SELECTED_DEVICE_UDID_STORAGE_KEY, info.udid);
|
saveText(SELECTED_DEVICE_UDID_STORAGE_KEY, info.udid);
|
||||||
setPairRecordsVersion((v) => v + 1);
|
setPairRecordsVersion((v) => v + 1);
|
||||||
setTrustOpen(false);
|
setTrustState(options.showSuccess ? 'paired' : TRUST_MODAL_CLOSED);
|
||||||
return info;
|
return { kind: 'paired', info };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isPairingDialogPendingError(error)) {
|
if (isPairingDialogPendingError(error)) {
|
||||||
return null;
|
return { kind: 'pending' };
|
||||||
}
|
}
|
||||||
setTrustOpen(false);
|
setTrustState(TRUST_MODAL_CLOSED);
|
||||||
addLog(`pair failed: ${formatError(error)}`);
|
addLog(`pair failed: ${formatError(error)}`);
|
||||||
return null;
|
return { kind: 'failed' };
|
||||||
} finally {
|
} finally {
|
||||||
setBusy((prev) => ({ ...prev, pair: false }));
|
setBusy((prev) => ({ ...prev, pair: false }));
|
||||||
}
|
}
|
||||||
}, [addLog]);
|
}, [addLog]);
|
||||||
|
|
||||||
|
const handlePair = useCallback(() => {
|
||||||
|
void runPairFlow({
|
||||||
|
showSuccess: true,
|
||||||
|
startLogMessage: 'pair: please continue on your device',
|
||||||
|
});
|
||||||
|
}, [runPairFlow]);
|
||||||
|
|
||||||
|
const handleTrustRetry = useCallback(() => {
|
||||||
|
void runPairFlow({
|
||||||
|
showSuccess: true,
|
||||||
|
startLogMessage: 'pair: retrying after trust confirmation',
|
||||||
|
});
|
||||||
|
}, [runPairFlow]);
|
||||||
|
|
||||||
// ---- login flow ----
|
// ---- login flow ----
|
||||||
const handleLogin = useCallback(async () => {
|
const handleLogin = useCallback(async () => {
|
||||||
if (busyRef.current.loginSign) return;
|
if (busyRef.current.loginSign) return;
|
||||||
@@ -433,12 +462,24 @@ export function App() {
|
|||||||
log: addLog,
|
log: addLog,
|
||||||
clientRef: directClientRef,
|
clientRef: directClientRef,
|
||||||
onStateChange: () => {},
|
onStateChange: () => {},
|
||||||
onTrustPending: () => setTrustOpen(true),
|
onTrustPending: () => setTrustState('pending'),
|
||||||
});
|
});
|
||||||
let currentDeviceUdid = pairedDeviceInfo?.udid ?? null;
|
let currentDeviceUdid = pairedDeviceInfo?.udid ?? null;
|
||||||
if (!client.isSessionStarted) {
|
if (!client.isSessionStarted) {
|
||||||
const freshInfo = await handlePair();
|
const pairResult = await runPairFlow({
|
||||||
currentDeviceUdid = freshInfo?.udid ?? null;
|
showSuccess: false,
|
||||||
|
startLogMessage: 'install: device trust required, continue on your device',
|
||||||
|
});
|
||||||
|
if (pairResult.kind === 'pending') {
|
||||||
|
addLog('install paused: finish trusting the device, then install again');
|
||||||
|
setProgress({ percent: 0, status: 'idle' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pairResult.kind === 'failed') {
|
||||||
|
setProgress({ percent: 0, status: 'failed' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentDeviceUdid = pairResult.info.udid;
|
||||||
}
|
}
|
||||||
if (currentDeviceUdid !== targetUdid) {
|
if (currentDeviceUdid !== targetUdid) {
|
||||||
throw new Error('connected device udid does not match selected target');
|
throw new Error('connected device udid does not match selected target');
|
||||||
@@ -457,7 +498,7 @@ export function App() {
|
|||||||
} finally {
|
} finally {
|
||||||
setBusy((prev) => ({ ...prev, install: false }));
|
setBusy((prev) => ({ ...prev, install: false }));
|
||||||
}
|
}
|
||||||
}, [addLog, handlePair, pairedDeviceInfo, prepared, selectedIpaFile, selectedTargetUdid]);
|
}, [addLog, pairedDeviceInfo, prepared, runPairFlow, selectedIpaFile, selectedTargetUdid]);
|
||||||
|
|
||||||
// ---- switch account ----
|
// ---- switch account ----
|
||||||
const handleSwitchAccount = useCallback(
|
const handleSwitchAccount = useCallback(
|
||||||
@@ -620,7 +661,11 @@ export function App() {
|
|||||||
onDismiss={handleDismissProgress}
|
onDismiss={handleDismissProgress}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TrustModal open={trustOpen} onClose={() => setTrustOpen(false)} pairing={busy.pair} />
|
<TrustModal
|
||||||
|
state={trustState}
|
||||||
|
onClose={() => setTrustState(TRUST_MODAL_CLOSED)}
|
||||||
|
onRetry={handleTrustRetry}
|
||||||
|
/>
|
||||||
<TwoFactorModal
|
<TwoFactorModal
|
||||||
open={twoFactor.open}
|
open={twoFactor.open}
|
||||||
onSubmit={handleTwoFactorSubmit}
|
onSubmit={handleTwoFactorSubmit}
|
||||||
|
|||||||
@@ -5,20 +5,31 @@ import { TrustModal } from './TrustModal';
|
|||||||
|
|
||||||
describe('TrustModal', () => {
|
describe('TrustModal', () => {
|
||||||
it('is hidden when closed', () => {
|
it('is hidden when closed', () => {
|
||||||
const { container } = render(<TrustModal open={false} onClose={() => {}} pairing={false} />);
|
const { container } = render(<TrustModal state="closed" onClose={() => {}} onRetry={() => {}} />);
|
||||||
const modal = container.querySelector('.modal');
|
const modal = container.querySelector('.modal');
|
||||||
expect(modal).toHaveAttribute('aria-hidden', 'true');
|
expect(modal).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows pairing state with spinner when pairing', () => {
|
it('shows pairing state with spinner when pairing', () => {
|
||||||
render(<TrustModal open onClose={() => {}} pairing />);
|
render(<TrustModal state="pairing" onClose={() => {}} onRetry={() => {}} />);
|
||||||
expect(screen.getByText('Continue on Your Device')).toBeInTheDocument();
|
expect(screen.getByText('Continue on Your Device')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Waiting for device…')).toBeInTheDocument();
|
expect(screen.getByText('Waiting for device…')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows pending state with retry and close actions', async () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const onRetry = vi.fn();
|
||||||
|
render(<TrustModal state="pending" onClose={onClose} onRetry={onRetry} />);
|
||||||
|
expect(screen.getByText('Trust This Device')).toBeInTheDocument();
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'I Trusted This Device' }));
|
||||||
|
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('shows success state with Continue button when paired', async () => {
|
it('shows success state with Continue button when paired', async () => {
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
render(<TrustModal open onClose={onClose} pairing={false} />);
|
render(<TrustModal state="paired" onClose={onClose} onRetry={() => {}} />);
|
||||||
expect(screen.getByText('Device Paired')).toBeInTheDocument();
|
expect(screen.getByText('Device Paired')).toBeInTheDocument();
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Continue' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Continue' }));
|
||||||
expect(onClose).toHaveBeenCalledTimes(1);
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
|||||||
@@ -1,14 +1,25 @@
|
|||||||
|
import { Button } from './ui/Button';
|
||||||
import { Modal } from './ui/Modal';
|
import { Modal } from './ui/Modal';
|
||||||
|
|
||||||
|
export type TrustModalState = 'closed' | 'pairing' | 'pending' | 'paired';
|
||||||
|
|
||||||
interface TrustModalProps {
|
interface TrustModalProps {
|
||||||
open: boolean;
|
state: TrustModalState;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
pairing: boolean;
|
onRetry: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TrustModal({ open, onClose, pairing }: TrustModalProps) {
|
export function TrustModal({ state, onClose, onRetry }: TrustModalProps) {
|
||||||
|
if (state === 'closed') return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={open} onClose={onClose} labelledBy="trust-title" closeOnBackdrop={false} closeOnEscape={!pairing}>
|
<Modal
|
||||||
|
open
|
||||||
|
onClose={onClose}
|
||||||
|
labelledBy="trust-title"
|
||||||
|
closeOnBackdrop={false}
|
||||||
|
closeOnEscape={state !== 'pairing'}
|
||||||
|
>
|
||||||
<div className="flex flex-col items-center text-center">
|
<div className="flex flex-col items-center text-center">
|
||||||
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-surface" aria-hidden="true">
|
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-surface" aria-hidden="true">
|
||||||
<svg
|
<svg
|
||||||
@@ -26,7 +37,7 @@ export function TrustModal({ open, onClose, pairing }: TrustModalProps) {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pairing ? (
|
{state === 'pairing' ? (
|
||||||
<>
|
<>
|
||||||
<h2 id="trust-title" className="text-[16px] font-semibold tracking-tight text-ink">
|
<h2 id="trust-title" className="text-[16px] font-semibold tracking-tight text-ink">
|
||||||
Continue on Your Device
|
Continue on Your Device
|
||||||
@@ -43,15 +54,35 @@ export function TrustModal({ open, onClose, pairing }: TrustModalProps) {
|
|||||||
<span>Waiting for device…</span>
|
<span>Waiting for device…</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
) : state === 'pending' ? (
|
||||||
|
<>
|
||||||
|
<h2 id="trust-title" className="text-[16px] font-semibold tracking-tight text-ink">
|
||||||
|
Trust This Device
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-[13px] leading-[1.6] text-muted">
|
||||||
|
Approve the trust prompt on your iPhone or iPad, then return here to finish pairing.
|
||||||
|
</p>
|
||||||
|
<p className="mt-1.5 text-[12px] leading-[1.5] text-subtle">
|
||||||
|
Tap <strong className="font-semibold text-ink">Trust</strong> and enter your passcode if asked.
|
||||||
|
</p>
|
||||||
|
<div className="mt-5 grid w-full gap-2 sm:grid-cols-2">
|
||||||
|
<Button variant="ghost" onClick={onClose} className="w-full">
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={onRetry} className="w-full">
|
||||||
|
I Trusted This Device
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<h2 id="trust-title" className="text-[16px] font-semibold tracking-tight text-ink">
|
<h2 id="trust-title" className="text-[16px] font-semibold tracking-tight text-ink">
|
||||||
Device Paired
|
Device Paired
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-2 text-[13px] leading-[1.6] text-muted">Your device is connected and ready for signing.</p>
|
<p className="mt-2 text-[13px] leading-[1.6] text-muted">Your device is connected and ready for signing.</p>
|
||||||
<button type="button" onClick={onClose} className="btn btn-primary mt-5 w-full">
|
<Button variant="primary" onClick={onClose} className="mt-5 w-full">
|
||||||
Continue
|
Continue
|
||||||
</button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user