@peers-app/peers-ui 0.20.6 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/root-layout/root-layout-host.js +3 -0
- package/dist/screens/device-pairing/device-pairing-invite.d.ts +10 -0
- package/dist/screens/device-pairing/device-pairing-invite.js +50 -0
- package/dist/screens/device-pairing/device-pairing-invite.test.d.ts +1 -0
- package/dist/screens/device-pairing/device-pairing-invite.test.js +30 -0
- package/dist/screens/device-pairing/device-pairing.d.ts +10 -0
- package/dist/screens/device-pairing/device-pairing.js +237 -0
- package/dist/screens/device-pairing/device-pairing.test.d.ts +1 -0
- package/dist/screens/device-pairing/device-pairing.test.js +88 -0
- package/dist/screens/device-pairing/index.d.ts +3 -0
- package/dist/screens/device-pairing/index.js +19 -0
- package/dist/screens/device-pairing/use-device-pairing-state.d.ts +3 -0
- package/dist/screens/device-pairing/use-device-pairing-state.js +31 -0
- package/dist/screens/settings/settings-page.js +6 -1
- package/dist/screens/setup-user.js +24 -1
- package/package.json +3 -3
- package/src/root-layout/root-layout-host.tsx +3 -0
- package/src/screens/device-pairing/device-pairing-invite.test.ts +50 -0
- package/src/screens/device-pairing/device-pairing-invite.ts +55 -0
- package/src/screens/device-pairing/device-pairing.test.tsx +100 -0
- package/src/screens/device-pairing/device-pairing.tsx +390 -0
- package/src/screens/device-pairing/index.ts +3 -0
- package/src/screens/device-pairing/use-device-pairing-state.ts +38 -0
- package/src/screens/settings/settings-page.tsx +22 -0
- package/src/screens/setup-user.tsx +63 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peers-app/peers-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/peers-app/peers-ui.git"
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"lint:fix": "biome check --write ."
|
|
29
29
|
},
|
|
30
30
|
"peerDependencies": {
|
|
31
|
-
"@peers-app/peers-sdk": "^0.
|
|
31
|
+
"@peers-app/peers-sdk": "^0.21.0",
|
|
32
32
|
"bootstrap": "^5.3.3",
|
|
33
33
|
"react": "^18.0.0",
|
|
34
34
|
"react-dom": "^18.0.0"
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"@babel/preset-react": "^7.24.1",
|
|
40
40
|
"@babel/preset-typescript": "^7.27.1",
|
|
41
41
|
"@biomejs/biome": "2.3.11",
|
|
42
|
-
"@peers-app/peers-sdk": "0.
|
|
42
|
+
"@peers-app/peers-sdk": "0.21.0",
|
|
43
43
|
"@testing-library/dom": "^10.4.0",
|
|
44
44
|
"@testing-library/jest-dom": "^6.6.3",
|
|
45
45
|
"@testing-library/react": "^16.3.0",
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
captureUserConnectInviteFromLocation,
|
|
26
26
|
readUserConnectPendingIntent,
|
|
27
27
|
} from "../screens/contacts/user-connect-invite";
|
|
28
|
+
import { captureDevicePairingInviteFromLocation } from "../screens/device-pairing";
|
|
28
29
|
import { colorMode } from "../screens/settings/color-mode-dropdown";
|
|
29
30
|
import { SetupUser } from "../screens/setup-user";
|
|
30
31
|
import { WelcomeModal } from "../screens/welcome-modal";
|
|
@@ -41,6 +42,7 @@ import { selectedRootLayoutId } from "./root-layout-state";
|
|
|
41
42
|
*/
|
|
42
43
|
export function RootLayoutHost() {
|
|
43
44
|
useState(() => {
|
|
45
|
+
captureDevicePairingInviteFromLocation();
|
|
44
46
|
captureUserConnectInviteFromLocation();
|
|
45
47
|
return true;
|
|
46
48
|
});
|
|
@@ -55,6 +57,7 @@ export function RootLayoutHost() {
|
|
|
55
57
|
useEffect(() => {
|
|
56
58
|
const captureInviteFromHashChange = () => {
|
|
57
59
|
const previousHash = window.location.hash;
|
|
60
|
+
captureDevicePairingInviteFromLocation();
|
|
58
61
|
captureUserConnectInviteFromLocation();
|
|
59
62
|
if (window.location.hash === previousHash) {
|
|
60
63
|
return;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createDevicePairingInvitation,
|
|
3
|
+
createDevicePairingInvitationUrl,
|
|
4
|
+
parseDevicePairingInvitation,
|
|
5
|
+
} from "@peers-app/peers-sdk";
|
|
6
|
+
import {
|
|
7
|
+
captureDevicePairingInviteFromLocation,
|
|
8
|
+
clearPendingDevicePairingInvite,
|
|
9
|
+
consumePendingDevicePairingInvite,
|
|
10
|
+
readPendingDevicePairingInvite,
|
|
11
|
+
} from "./device-pairing-invite";
|
|
12
|
+
|
|
13
|
+
describe("device pairing invitation capture", () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
clearPendingDevicePairingInvite();
|
|
16
|
+
window.history.replaceState({}, "", "/?app");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("captures the fragment only in memory and removes it from browser history", () => {
|
|
20
|
+
const invitation = createDevicePairingInvitation({
|
|
21
|
+
serviceOrigin: "https://peers.app",
|
|
22
|
+
});
|
|
23
|
+
const inviteUrl = createDevicePairingInvitationUrl(
|
|
24
|
+
`${window.location.origin}/?app`,
|
|
25
|
+
invitation,
|
|
26
|
+
);
|
|
27
|
+
window.history.replaceState(
|
|
28
|
+
{},
|
|
29
|
+
"",
|
|
30
|
+
new URL(inviteUrl).pathname + new URL(inviteUrl).search + new URL(inviteUrl).hash,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const token = captureDevicePairingInviteFromLocation();
|
|
34
|
+
|
|
35
|
+
expect(token).toBeDefined();
|
|
36
|
+
expect(parseDevicePairingInvitation(token ?? "")).toEqual(invitation);
|
|
37
|
+
expect(window.location.hash).toBe("");
|
|
38
|
+
expect(readPendingDevicePairingInvite()).toBe(token);
|
|
39
|
+
expect(consumePendingDevicePairingInvite()).toBe(token);
|
|
40
|
+
expect(readPendingDevicePairingInvite()).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("ignores invalid fragments without retaining them", () => {
|
|
44
|
+
window.history.replaceState({}, "", "/?app#pair=invalid");
|
|
45
|
+
|
|
46
|
+
expect(captureDevicePairingInviteFromLocation()).toBeUndefined();
|
|
47
|
+
expect(readPendingDevicePairingInvite()).toBeUndefined();
|
|
48
|
+
expect(window.location.hash).toBe("");
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { encodeDevicePairingInvitation, parseDevicePairingInvitation } from "@peers-app/peers-sdk";
|
|
2
|
+
|
|
3
|
+
let pendingInvitation: string | undefined;
|
|
4
|
+
|
|
5
|
+
/** Browser event emitted after a pairing invitation is captured and removed from the URL. */
|
|
6
|
+
export const DEVICE_PAIRING_INVITE_CAPTURED_EVENT = "device-pairing:invite-captured";
|
|
7
|
+
|
|
8
|
+
/** Captures a pairing fragment in memory and immediately removes it from browser history. */
|
|
9
|
+
export function captureDevicePairingInviteFromLocation(
|
|
10
|
+
location: Location = window.location,
|
|
11
|
+
): string | undefined {
|
|
12
|
+
if (!location.hash.startsWith("#pair=") && !location.hash.startsWith("#pair?")) {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
let capturedInvitation: string | undefined;
|
|
16
|
+
try {
|
|
17
|
+
capturedInvitation = encodeDevicePairingInvitation(parseDevicePairingInvitation(location.href));
|
|
18
|
+
pendingInvitation = capturedInvitation;
|
|
19
|
+
return capturedInvitation;
|
|
20
|
+
} catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
} finally {
|
|
23
|
+
if (typeof window !== "undefined" && location === window.location) {
|
|
24
|
+
window.history.replaceState(
|
|
25
|
+
window.history.state,
|
|
26
|
+
document.title,
|
|
27
|
+
`${location.pathname}${location.search}`,
|
|
28
|
+
);
|
|
29
|
+
if (capturedInvitation) {
|
|
30
|
+
window.dispatchEvent(
|
|
31
|
+
new CustomEvent(DEVICE_PAIRING_INVITE_CAPTURED_EVENT, {
|
|
32
|
+
detail: capturedInvitation,
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Returns the in-memory invitation without consuming it. */
|
|
41
|
+
export function readPendingDevicePairingInvite(): string | undefined {
|
|
42
|
+
return pendingInvitation;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Returns and erases the in-memory invitation. */
|
|
46
|
+
export function consumePendingDevicePairingInvite(): string | undefined {
|
|
47
|
+
const invitation = pendingInvitation;
|
|
48
|
+
pendingInvitation = undefined;
|
|
49
|
+
return invitation;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Erases any unconsumed invitation. */
|
|
53
|
+
export function clearPendingDevicePairingInvite(): void {
|
|
54
|
+
pendingInvitation = undefined;
|
|
55
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { type DevicePairingState, rpcServerCalls } from "@peers-app/peers-sdk";
|
|
2
|
+
import { fireEvent, render, waitFor } from "@testing-library/react";
|
|
3
|
+
import { DestinationDevicePairing, SourceDevicePairing } from "./device-pairing";
|
|
4
|
+
|
|
5
|
+
const mockRpc = {
|
|
6
|
+
startSource: jest.fn(),
|
|
7
|
+
startDestination: jest.fn(),
|
|
8
|
+
approve: jest.fn(),
|
|
9
|
+
reject: jest.fn(),
|
|
10
|
+
cancel: jest.fn(),
|
|
11
|
+
};
|
|
12
|
+
let mockPairingState: DevicePairingState = { phase: "idle" };
|
|
13
|
+
|
|
14
|
+
rpcServerCalls.devicePairingStartSource =
|
|
15
|
+
mockRpc.startSource as typeof rpcServerCalls.devicePairingStartSource;
|
|
16
|
+
rpcServerCalls.devicePairingStartDestination =
|
|
17
|
+
mockRpc.startDestination as typeof rpcServerCalls.devicePairingStartDestination;
|
|
18
|
+
rpcServerCalls.devicePairingApprove = mockRpc.approve as typeof rpcServerCalls.devicePairingApprove;
|
|
19
|
+
rpcServerCalls.devicePairingReject = mockRpc.reject as typeof rpcServerCalls.devicePairingReject;
|
|
20
|
+
rpcServerCalls.devicePairingCancel = mockRpc.cancel as typeof rpcServerCalls.devicePairingCancel;
|
|
21
|
+
|
|
22
|
+
jest.mock("./use-device-pairing-state", () => ({
|
|
23
|
+
useDevicePairingState: () => mockPairingState,
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
describe("device pairing UI lifecycle", () => {
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
jest.clearAllMocks();
|
|
29
|
+
mockPairingState = { phase: "idle" };
|
|
30
|
+
mockRpc.cancel.mockResolvedValue({ phase: "idle" });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("uses the host-owned invitation URL and cancels a source that resolves after unmount", async () => {
|
|
34
|
+
let resolveStart!: (value: { state: DevicePairingState; invitationUrl: string }) => void;
|
|
35
|
+
mockRpc.startSource.mockReturnValue(
|
|
36
|
+
new Promise((resolve) => {
|
|
37
|
+
resolveStart = resolve;
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
const view = render(<SourceDevicePairing onClose={jest.fn()} />);
|
|
41
|
+
|
|
42
|
+
expect(mockRpc.startSource).toHaveBeenCalledWith();
|
|
43
|
+
view.unmount();
|
|
44
|
+
resolveStart({
|
|
45
|
+
state: { sessionId: "00mh0wlipkdbeaw8tests0001", role: "source", phase: "inviting" },
|
|
46
|
+
invitationUrl: "https://peers.app/?app#pair=token",
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
await waitFor(() => {
|
|
50
|
+
expect(mockRpc.cancel).toHaveBeenCalledWith({
|
|
51
|
+
sessionId: "00mh0wlipkdbeaw8tests0001",
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("keeps source cancellation available while waiting for remote approval", async () => {
|
|
57
|
+
const onClose = jest.fn();
|
|
58
|
+
mockPairingState = {
|
|
59
|
+
sessionId: "00mh0wlipkdbeaw8tests0002",
|
|
60
|
+
role: "source",
|
|
61
|
+
phase: "waiting-for-approval",
|
|
62
|
+
};
|
|
63
|
+
mockRpc.startSource.mockResolvedValue({
|
|
64
|
+
state: mockPairingState,
|
|
65
|
+
invitationUrl: "https://peers.app/?app#pair=token",
|
|
66
|
+
});
|
|
67
|
+
const view = render(<SourceDevicePairing onClose={onClose} />);
|
|
68
|
+
await waitFor(() => expect(mockRpc.startSource).toHaveBeenCalled());
|
|
69
|
+
|
|
70
|
+
fireEvent.click(view.getByRole("button", { name: "Cancel" }));
|
|
71
|
+
|
|
72
|
+
await waitFor(() => {
|
|
73
|
+
expect(mockRpc.cancel).toHaveBeenCalledWith({
|
|
74
|
+
sessionId: "00mh0wlipkdbeaw8tests0002",
|
|
75
|
+
});
|
|
76
|
+
expect(onClose).toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("does not clear the destination success reload when Done unmounts the flow", () => {
|
|
81
|
+
jest.useFakeTimers();
|
|
82
|
+
mockPairingState = {
|
|
83
|
+
sessionId: "00mh0wlipkdbeaw8tests0003",
|
|
84
|
+
role: "destination",
|
|
85
|
+
phase: "complete",
|
|
86
|
+
};
|
|
87
|
+
const setTimeoutSpy = jest.spyOn(window, "setTimeout");
|
|
88
|
+
const clearTimeoutSpy = jest.spyOn(window, "clearTimeout");
|
|
89
|
+
const view = render(<DestinationDevicePairing onBack={jest.fn()} />);
|
|
90
|
+
const reloadTimer = setTimeoutSpy.mock.calls.find(([, delay]) => delay === 750);
|
|
91
|
+
|
|
92
|
+
expect(reloadTimer).toBeDefined();
|
|
93
|
+
view.unmount();
|
|
94
|
+
expect(clearTimeoutSpy).not.toHaveBeenCalled();
|
|
95
|
+
|
|
96
|
+
setTimeoutSpy.mockRestore();
|
|
97
|
+
clearTimeoutSpy.mockRestore();
|
|
98
|
+
jest.useRealTimers();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import { copyToClipboard, type DevicePairingState, rpcServerCalls } from "@peers-app/peers-sdk";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import QRCode from "react-qr-code";
|
|
4
|
+
import { useDevicePairingState } from "./use-device-pairing-state";
|
|
5
|
+
|
|
6
|
+
function formatCountdown(expiresAt?: number): string {
|
|
7
|
+
if (!expiresAt) {
|
|
8
|
+
return "";
|
|
9
|
+
}
|
|
10
|
+
const seconds = Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000));
|
|
11
|
+
return `${Math.floor(seconds / 60)}:${(seconds % 60).toString().padStart(2, "0")}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function PairingProgress({ state }: { state: DevicePairingState }) {
|
|
15
|
+
const messages: Partial<Record<DevicePairingState["phase"], string>> = {
|
|
16
|
+
inviting: "Waiting for the new device…",
|
|
17
|
+
connecting: "Establishing a secure connection…",
|
|
18
|
+
"waiting-for-approval": "Waiting for approval on the other device…",
|
|
19
|
+
transferring: "Installing encrypted account credentials…",
|
|
20
|
+
};
|
|
21
|
+
return (
|
|
22
|
+
<output className="text-center py-4 d-block">
|
|
23
|
+
<div className="spinner-border text-primary mb-3" aria-hidden="true" />
|
|
24
|
+
<p className="text-muted mb-0">{messages[state.phase] ?? "Working…"}</p>
|
|
25
|
+
</output>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function PairingComparison(props: {
|
|
30
|
+
state: DevicePairingState;
|
|
31
|
+
approving: boolean;
|
|
32
|
+
onApprove: () => void;
|
|
33
|
+
onReject: () => void;
|
|
34
|
+
onCancel: () => void;
|
|
35
|
+
}) {
|
|
36
|
+
return (
|
|
37
|
+
<div className="text-center" style={{ maxWidth: 440, margin: "0 auto" }}>
|
|
38
|
+
<h4>Compare these numbers</h4>
|
|
39
|
+
<p className="text-muted">
|
|
40
|
+
Confirm that both devices show the same number and the expected device before approving.
|
|
41
|
+
</p>
|
|
42
|
+
<div
|
|
43
|
+
className="display-4 font-monospace text-primary mb-3"
|
|
44
|
+
style={{ letterSpacing: "0.12em" }}
|
|
45
|
+
>
|
|
46
|
+
{props.state.sas}
|
|
47
|
+
</div>
|
|
48
|
+
<p className="mb-4">
|
|
49
|
+
{props.state.remoteDevice?.deviceName || "Unnamed device"}
|
|
50
|
+
{props.state.remoteDevice?.deviceType ? ` (${props.state.remoteDevice.deviceType})` : ""}
|
|
51
|
+
</p>
|
|
52
|
+
<div className="d-flex gap-2 justify-content-center">
|
|
53
|
+
<button
|
|
54
|
+
type="button"
|
|
55
|
+
className="btn btn-primary"
|
|
56
|
+
disabled={props.approving}
|
|
57
|
+
onClick={props.onApprove}
|
|
58
|
+
>
|
|
59
|
+
{props.approving ? "Approving…" : "Numbers match — approve"}
|
|
60
|
+
</button>
|
|
61
|
+
<button
|
|
62
|
+
type="button"
|
|
63
|
+
className="btn btn-outline-danger"
|
|
64
|
+
disabled={props.approving}
|
|
65
|
+
onClick={props.onReject}
|
|
66
|
+
>
|
|
67
|
+
Reject
|
|
68
|
+
</button>
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
className="btn btn-outline-secondary"
|
|
72
|
+
disabled={props.approving}
|
|
73
|
+
onClick={props.onCancel}
|
|
74
|
+
>
|
|
75
|
+
Cancel
|
|
76
|
+
</button>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function TerminalPanel(props: { state: DevicePairingState; onClose: () => void }) {
|
|
83
|
+
const complete = props.state.phase === "complete";
|
|
84
|
+
return (
|
|
85
|
+
<div className="text-center" style={{ maxWidth: 440, margin: "0 auto" }}>
|
|
86
|
+
<output className={`alert d-block ${complete ? "alert-success" : "alert-warning"}`}>
|
|
87
|
+
{complete
|
|
88
|
+
? "Device added successfully."
|
|
89
|
+
: props.state.phase === "rejected"
|
|
90
|
+
? "Pairing was rejected. No credentials were installed."
|
|
91
|
+
: props.state.error || "Pairing could not be completed. No credentials were installed."}
|
|
92
|
+
</output>
|
|
93
|
+
<button type="button" className="btn btn-outline-secondary" onClick={props.onClose}>
|
|
94
|
+
{complete ? "Done" : "Back"}
|
|
95
|
+
</button>
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function ProgressWithCancel(props: { state: DevicePairingState; onCancel: () => void }) {
|
|
101
|
+
return (
|
|
102
|
+
<>
|
|
103
|
+
<PairingProgress state={props.state} />
|
|
104
|
+
<div className="text-center">
|
|
105
|
+
<button type="button" className="btn btn-outline-secondary" onClick={props.onCancel}>
|
|
106
|
+
Cancel
|
|
107
|
+
</button>
|
|
108
|
+
</div>
|
|
109
|
+
</>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function usePairingSessionLifecycle(state: DevicePairingState) {
|
|
114
|
+
const sessionId = useRef<string | undefined>(undefined);
|
|
115
|
+
const phase = useRef<DevicePairingState["phase"]>("idle");
|
|
116
|
+
const mounted = useRef(true);
|
|
117
|
+
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
if (state.sessionId && state.sessionId === sessionId.current) {
|
|
120
|
+
phase.current = state.phase;
|
|
121
|
+
}
|
|
122
|
+
}, [state.phase, state.sessionId]);
|
|
123
|
+
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
mounted.current = true;
|
|
126
|
+
return () => {
|
|
127
|
+
mounted.current = false;
|
|
128
|
+
const activeSessionId = sessionId.current;
|
|
129
|
+
if (activeSessionId && !["idle", "complete", "rejected", "error"].includes(phase.current)) {
|
|
130
|
+
void rpcServerCalls
|
|
131
|
+
.devicePairingCancel({ sessionId: activeSessionId })
|
|
132
|
+
.catch(() => undefined);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
}, []);
|
|
136
|
+
|
|
137
|
+
return { sessionId, phase, mounted };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Signed-in source flow opened from Settings. */
|
|
141
|
+
export function SourceDevicePairing(props: { onClose: () => void }) {
|
|
142
|
+
const state = useDevicePairingState();
|
|
143
|
+
const lifecycle = usePairingSessionLifecycle(state);
|
|
144
|
+
const started = useRef(false);
|
|
145
|
+
const [invitationUrl, setInvitationUrl] = useState("");
|
|
146
|
+
const [localError, setLocalError] = useState("");
|
|
147
|
+
const [approving, setApproving] = useState(false);
|
|
148
|
+
const [, setClock] = useState(0);
|
|
149
|
+
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
if (started.current) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
started.current = true;
|
|
155
|
+
void rpcServerCalls
|
|
156
|
+
.devicePairingStartSource()
|
|
157
|
+
.then(async (result) => {
|
|
158
|
+
lifecycle.sessionId.current = result.state.sessionId;
|
|
159
|
+
lifecycle.phase.current = result.state.phase;
|
|
160
|
+
if (!lifecycle.mounted.current && result.state.sessionId) {
|
|
161
|
+
await rpcServerCalls
|
|
162
|
+
.devicePairingCancel({ sessionId: result.state.sessionId })
|
|
163
|
+
.catch(() => undefined);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
setInvitationUrl(result.invitationUrl);
|
|
167
|
+
})
|
|
168
|
+
.catch(() => setLocalError("Could not create a pairing invitation."));
|
|
169
|
+
}, []);
|
|
170
|
+
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
if (!state.expiresAt) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const timer = window.setInterval(() => setClock((value) => value + 1), 1000);
|
|
176
|
+
return () => window.clearInterval(timer);
|
|
177
|
+
}, [state.expiresAt]);
|
|
178
|
+
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
if (state.phase !== "inviting") {
|
|
181
|
+
setInvitationUrl("");
|
|
182
|
+
}
|
|
183
|
+
}, [state.phase]);
|
|
184
|
+
|
|
185
|
+
const act = async (action: "approve" | "reject") => {
|
|
186
|
+
const sessionId = lifecycle.sessionId.current;
|
|
187
|
+
if (!sessionId) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
setApproving(true);
|
|
191
|
+
try {
|
|
192
|
+
if (action === "approve") {
|
|
193
|
+
await rpcServerCalls.devicePairingApprove({ sessionId });
|
|
194
|
+
} else {
|
|
195
|
+
await rpcServerCalls.devicePairingReject({ sessionId });
|
|
196
|
+
}
|
|
197
|
+
} catch {
|
|
198
|
+
setLocalError("Could not update the pairing ceremony.");
|
|
199
|
+
} finally {
|
|
200
|
+
setApproving(false);
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const cancel = async () => {
|
|
205
|
+
const sessionId = lifecycle.sessionId.current;
|
|
206
|
+
lifecycle.phase.current = "idle";
|
|
207
|
+
if (sessionId) {
|
|
208
|
+
await rpcServerCalls.devicePairingCancel({ sessionId }).catch(() => undefined);
|
|
209
|
+
}
|
|
210
|
+
props.onClose();
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
if (localError) {
|
|
214
|
+
return <div className="alert alert-danger">{localError}</div>;
|
|
215
|
+
}
|
|
216
|
+
if (["complete", "rejected", "error"].includes(state.phase)) {
|
|
217
|
+
return <TerminalPanel state={state} onClose={props.onClose} />;
|
|
218
|
+
}
|
|
219
|
+
if (state.phase === "comparing" && state.sas) {
|
|
220
|
+
return (
|
|
221
|
+
<PairingComparison
|
|
222
|
+
state={state}
|
|
223
|
+
approving={approving}
|
|
224
|
+
onApprove={() => void act("approve")}
|
|
225
|
+
onReject={() => void act("reject")}
|
|
226
|
+
onCancel={() => void cancel()}
|
|
227
|
+
/>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
if (state.phase === "waiting-for-approval" || state.phase === "transferring") {
|
|
231
|
+
return <ProgressWithCancel state={state} onCancel={() => void cancel()} />;
|
|
232
|
+
}
|
|
233
|
+
if (invitationUrl) {
|
|
234
|
+
return (
|
|
235
|
+
<div className="text-center" style={{ maxWidth: 440, margin: "0 auto" }}>
|
|
236
|
+
<p className="text-muted">Scan this on the signed-out device you want to add.</p>
|
|
237
|
+
<div className="d-inline-block bg-white rounded p-3 mb-3">
|
|
238
|
+
<QRCode value={invitationUrl} size={220} aria-label="Device pairing QR code" />
|
|
239
|
+
</div>
|
|
240
|
+
<p className="small text-muted">Expires in {formatCountdown(state.expiresAt)}</p>
|
|
241
|
+
<div className="d-flex gap-2 justify-content-center">
|
|
242
|
+
<button
|
|
243
|
+
type="button"
|
|
244
|
+
className="btn btn-outline-primary"
|
|
245
|
+
onClick={() => void copyToClipboard(invitationUrl)}
|
|
246
|
+
>
|
|
247
|
+
Copy pairing link
|
|
248
|
+
</button>
|
|
249
|
+
<button type="button" className="btn btn-outline-secondary" onClick={() => void cancel()}>
|
|
250
|
+
Cancel
|
|
251
|
+
</button>
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
return <PairingProgress state={state} />;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Signed-out destination flow opened by a QR link or manual invitation paste. */
|
|
260
|
+
export function DestinationDevicePairing(props: {
|
|
261
|
+
initialInvitation?: string;
|
|
262
|
+
onInvitationConsumed?: () => void;
|
|
263
|
+
onBack: () => void;
|
|
264
|
+
}) {
|
|
265
|
+
const state = useDevicePairingState();
|
|
266
|
+
const lifecycle = usePairingSessionLifecycle(state);
|
|
267
|
+
const [invitation, setInvitation] = useState(props.initialInvitation ?? "");
|
|
268
|
+
const [localError, setLocalError] = useState("");
|
|
269
|
+
const [approving, setApproving] = useState(false);
|
|
270
|
+
const autoStarted = useRef(false);
|
|
271
|
+
|
|
272
|
+
const start = async (value = invitation) => {
|
|
273
|
+
setLocalError("");
|
|
274
|
+
try {
|
|
275
|
+
const result = await rpcServerCalls.devicePairingStartDestination({
|
|
276
|
+
invitation: value.trim(),
|
|
277
|
+
deviceType: /Mobi|Android/i.test(navigator.userAgent) ? "mobile" : "browser",
|
|
278
|
+
});
|
|
279
|
+
lifecycle.sessionId.current = result.sessionId;
|
|
280
|
+
lifecycle.phase.current = result.phase;
|
|
281
|
+
if (!lifecycle.mounted.current && result.sessionId) {
|
|
282
|
+
await rpcServerCalls
|
|
283
|
+
.devicePairingCancel({ sessionId: result.sessionId })
|
|
284
|
+
.catch(() => undefined);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
setInvitation("");
|
|
288
|
+
props.onInvitationConsumed?.();
|
|
289
|
+
} catch {
|
|
290
|
+
setLocalError("That pairing invitation is invalid, expired, or unavailable.");
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
useEffect(() => {
|
|
295
|
+
if (!props.initialInvitation || autoStarted.current) {
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
autoStarted.current = true;
|
|
299
|
+
void start(props.initialInvitation);
|
|
300
|
+
}, [props.initialInvitation]);
|
|
301
|
+
|
|
302
|
+
useEffect(() => {
|
|
303
|
+
if (state.phase !== "complete") {
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
window.setTimeout(() => window.location.reload(), 750);
|
|
307
|
+
}, [state.phase]);
|
|
308
|
+
|
|
309
|
+
const act = async (action: "approve" | "reject") => {
|
|
310
|
+
const sessionId = lifecycle.sessionId.current;
|
|
311
|
+
if (!sessionId) {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
setApproving(true);
|
|
315
|
+
try {
|
|
316
|
+
if (action === "approve") {
|
|
317
|
+
await rpcServerCalls.devicePairingApprove({ sessionId });
|
|
318
|
+
} else {
|
|
319
|
+
await rpcServerCalls.devicePairingReject({ sessionId });
|
|
320
|
+
}
|
|
321
|
+
} catch {
|
|
322
|
+
setLocalError("Could not update the pairing ceremony.");
|
|
323
|
+
} finally {
|
|
324
|
+
setApproving(false);
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const cancel = async () => {
|
|
329
|
+
const sessionId = lifecycle.sessionId.current;
|
|
330
|
+
lifecycle.phase.current = "idle";
|
|
331
|
+
if (sessionId) {
|
|
332
|
+
await rpcServerCalls.devicePairingCancel({ sessionId }).catch(() => undefined);
|
|
333
|
+
}
|
|
334
|
+
props.onBack();
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
if (["complete", "rejected", "error"].includes(state.phase)) {
|
|
338
|
+
return <TerminalPanel state={state} onClose={props.onBack} />;
|
|
339
|
+
}
|
|
340
|
+
if (state.phase === "comparing" && state.sas) {
|
|
341
|
+
return (
|
|
342
|
+
<PairingComparison
|
|
343
|
+
state={state}
|
|
344
|
+
approving={approving}
|
|
345
|
+
onApprove={() => void act("approve")}
|
|
346
|
+
onReject={() => void act("reject")}
|
|
347
|
+
onCancel={() => void cancel()}
|
|
348
|
+
/>
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
if (
|
|
352
|
+
state.phase === "connecting" ||
|
|
353
|
+
state.phase === "waiting-for-approval" ||
|
|
354
|
+
state.phase === "transferring"
|
|
355
|
+
) {
|
|
356
|
+
return <ProgressWithCancel state={state} onCancel={() => void cancel()} />;
|
|
357
|
+
}
|
|
358
|
+
return (
|
|
359
|
+
<div className="text-center" style={{ maxWidth: 500, margin: "0 auto" }}>
|
|
360
|
+
<h3>Sign in using another device</h3>
|
|
361
|
+
<p className="text-muted">
|
|
362
|
+
On a device already signed in to Peers, choose Add another device. Scan its QR code, or
|
|
363
|
+
paste the complete pairing link or token below.
|
|
364
|
+
</p>
|
|
365
|
+
<textarea
|
|
366
|
+
className="form-control font-monospace mb-3"
|
|
367
|
+
rows={4}
|
|
368
|
+
placeholder="Paste pairing link or token"
|
|
369
|
+
value={invitation}
|
|
370
|
+
onChange={(event) => setInvitation(event.target.value)}
|
|
371
|
+
autoComplete="off"
|
|
372
|
+
aria-label="Device pairing invitation"
|
|
373
|
+
/>
|
|
374
|
+
{localError ? <div className="alert alert-danger">{localError}</div> : null}
|
|
375
|
+
<div className="d-grid gap-2">
|
|
376
|
+
<button
|
|
377
|
+
type="button"
|
|
378
|
+
className="btn btn-primary btn-lg"
|
|
379
|
+
disabled={!invitation.trim()}
|
|
380
|
+
onClick={() => void start()}
|
|
381
|
+
>
|
|
382
|
+
Connect securely
|
|
383
|
+
</button>
|
|
384
|
+
<button type="button" className="btn btn-outline-secondary" onClick={props.onBack}>
|
|
385
|
+
Back
|
|
386
|
+
</button>
|
|
387
|
+
</div>
|
|
388
|
+
</div>
|
|
389
|
+
);
|
|
390
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEVICE_PAIRING_STATE_EVENT,
|
|
3
|
+
type DevicePairingState,
|
|
4
|
+
devicePairingStateSchema,
|
|
5
|
+
rpcServerCalls,
|
|
6
|
+
subscribe,
|
|
7
|
+
} from "@peers-app/peers-sdk";
|
|
8
|
+
import { useEffect, useState } from "react";
|
|
9
|
+
|
|
10
|
+
const IDLE_PAIRING_STATE: DevicePairingState = { phase: "idle" };
|
|
11
|
+
|
|
12
|
+
/** Subscribes to renderer-safe host pairing state and loads its current snapshot. */
|
|
13
|
+
export function useDevicePairingState(): DevicePairingState {
|
|
14
|
+
const [state, setState] = useState<DevicePairingState>(IDLE_PAIRING_STATE);
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
let active = true;
|
|
18
|
+
const subscription = subscribe<DevicePairingState>(DEVICE_PAIRING_STATE_EVENT, (event) => {
|
|
19
|
+
if (active) {
|
|
20
|
+
setState(devicePairingStateSchema.parse(event.data));
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
void rpcServerCalls
|
|
24
|
+
.devicePairingGetState()
|
|
25
|
+
.then((value) => {
|
|
26
|
+
if (active) {
|
|
27
|
+
setState(devicePairingStateSchema.parse(value));
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
.catch(() => undefined);
|
|
31
|
+
return () => {
|
|
32
|
+
active = false;
|
|
33
|
+
subscription.unsubscribe();
|
|
34
|
+
};
|
|
35
|
+
}, []);
|
|
36
|
+
|
|
37
|
+
return state;
|
|
38
|
+
}
|