@peers-app/peers-ui 0.23.13 → 0.24.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/biome.json +2 -2
- package/dist/components/qr-scanner.d.ts +18 -0
- package/dist/components/qr-scanner.js +165 -0
- package/dist/components/router.d.ts +1 -1
- package/dist/components/router.js +1 -1
- package/dist/root-layout/root-layout-host.js +18 -19
- package/dist/root-layout/root-layout-host.test.js +14 -9
- package/dist/screens/account/account-screen.js +52 -1
- package/dist/screens/contacts/contact-list.js +28 -1
- package/dist/screens/contacts/index.d.ts +0 -2
- package/dist/screens/contacts/index.js +0 -2
- package/dist/screens/device-pairing/device-pairing.d.ts +2 -4
- package/dist/screens/device-pairing/device-pairing.js +127 -128
- package/dist/screens/device-pairing/device-pairing.test.js +51 -15
- package/dist/screens/device-pairing/index.d.ts +0 -1
- package/dist/screens/device-pairing/index.js +0 -1
- package/dist/screens/groups/group-details.js +2 -6
- package/dist/screens/groups/group-invite-panel.d.ts +14 -0
- package/dist/screens/groups/group-invite-panel.js +125 -0
- package/dist/screens/groups/group-members.js +9 -103
- package/dist/screens/invites/accept-invite.d.ts +11 -0
- package/dist/screens/invites/accept-invite.js +114 -0
- package/dist/screens/invites/index.d.ts +7 -0
- package/dist/screens/{join-group → invites}/index.js +8 -3
- package/dist/screens/invites/invite-capture.d.ts +34 -0
- package/dist/screens/invites/invite-capture.js +94 -0
- package/dist/screens/invites/invite-share.d.ts +15 -0
- package/dist/screens/invites/invite-share.js +40 -0
- package/dist/screens/invites/invites-ipc.d.ts +10 -0
- package/dist/screens/invites/invites-ipc.js +61 -0
- package/dist/screens/invites/invites-screen.d.ts +9 -0
- package/dist/screens/invites/invites-screen.js +36 -0
- package/dist/screens/invites/pending-invites-list.d.ts +29 -0
- package/dist/screens/invites/pending-invites-list.js +156 -0
- package/dist/screens/settings/settings-page.js +1 -1
- package/dist/screens/setup-user.js +4 -18
- package/dist/system-apps/index.d.ts +1 -1
- package/dist/system-apps/index.js +5 -5
- package/dist/system-apps/{join-group.app.d.ts → invites.app.d.ts} +1 -1
- package/dist/system-apps/invites.app.js +9 -0
- package/package.json +4 -3
- package/src/components/qr-scanner.tsx +180 -0
- package/src/components/router.tsx +1 -1
- package/src/root-layout/root-layout-host.test.tsx +18 -13
- package/src/root-layout/root-layout-host.tsx +23 -21
- package/src/screens/account/account-screen.tsx +89 -2
- package/src/screens/contacts/contact-list.tsx +64 -4
- package/src/screens/contacts/index.ts +0 -2
- package/src/screens/device-pairing/device-pairing.test.tsx +61 -16
- package/src/screens/device-pairing/device-pairing.tsx +283 -208
- package/src/screens/device-pairing/index.ts +0 -1
- package/src/screens/groups/group-details.tsx +1 -41
- package/src/screens/groups/group-invite-panel.tsx +316 -0
- package/src/screens/groups/group-members.tsx +15 -177
- package/src/screens/invites/accept-invite.tsx +271 -0
- package/src/screens/invites/index.ts +9 -0
- package/src/screens/invites/invite-capture.ts +107 -0
- package/src/screens/invites/invite-share.tsx +104 -0
- package/src/screens/invites/invites-ipc.ts +74 -0
- package/src/screens/invites/invites-screen.tsx +78 -0
- package/src/screens/invites/pending-invites-list.tsx +224 -0
- package/src/screens/settings/settings-page.tsx +2 -2
- package/src/screens/setup-user.tsx +29 -47
- package/src/system-apps/index.ts +3 -3
- package/src/system-apps/invites.app.ts +8 -0
- package/dist/screens/contacts/user-connect-invite.d.ts +0 -118
- package/dist/screens/contacts/user-connect-invite.js +0 -264
- package/dist/screens/contacts/user-connect-invite.test.d.ts +0 -1
- package/dist/screens/contacts/user-connect-invite.test.js +0 -215
- package/dist/screens/contacts/user-connect.d.ts +0 -8
- package/dist/screens/contacts/user-connect.js +0 -487
- package/dist/screens/contacts/user-connect.test.d.ts +0 -1
- package/dist/screens/contacts/user-connect.test.js +0 -291
- package/dist/screens/device-pairing/device-pairing-invite.d.ts +0 -10
- package/dist/screens/device-pairing/device-pairing-invite.js +0 -50
- package/dist/screens/device-pairing/device-pairing-invite.test.d.ts +0 -1
- package/dist/screens/device-pairing/device-pairing-invite.test.js +0 -30
- package/dist/screens/groups/group-invite-listener.d.ts +0 -14
- package/dist/screens/groups/group-invite-listener.js +0 -93
- package/dist/screens/join-group/index.d.ts +0 -2
- package/dist/screens/join-group/join-group.d.ts +0 -12
- package/dist/screens/join-group/join-group.js +0 -102
- package/dist/system-apps/join-group.app.js +0 -9
- package/src/screens/contacts/user-connect-invite.test.ts +0 -256
- package/src/screens/contacts/user-connect-invite.ts +0 -332
- package/src/screens/contacts/user-connect.test.tsx +0 -382
- package/src/screens/contacts/user-connect.tsx +0 -883
- package/src/screens/device-pairing/device-pairing-invite.test.ts +0 -50
- package/src/screens/device-pairing/device-pairing-invite.ts +0 -55
- package/src/screens/groups/group-invite-listener.tsx +0 -282
- package/src/screens/join-group/index.ts +0 -4
- package/src/screens/join-group/join-group.tsx +0 -296
- package/src/system-apps/join-group.app.ts +0 -8
|
@@ -1,8 +1,25 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
copyToClipboard,
|
|
3
|
+
type DevicePairingState,
|
|
4
|
+
parsePairingCode,
|
|
5
|
+
rpcServerCalls,
|
|
6
|
+
} from "@peers-app/peers-sdk";
|
|
2
7
|
import { useEffect, useRef, useState } from "react";
|
|
3
8
|
import QRCode from "react-qr-code";
|
|
9
|
+
import { canScanQr, QrScanner } from "../../components/qr-scanner";
|
|
4
10
|
import { useDevicePairingState } from "./use-device-pairing-state";
|
|
5
11
|
|
|
12
|
+
function isPairingCode(text: string): boolean {
|
|
13
|
+
try {
|
|
14
|
+
parsePairingCode(text);
|
|
15
|
+
return true;
|
|
16
|
+
} catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const TERMINAL_PHASES: DevicePairingState["phase"][] = ["complete", "rejected", "error"];
|
|
22
|
+
|
|
6
23
|
function formatCountdown(expiresAt?: number): string {
|
|
7
24
|
if (!expiresAt) {
|
|
8
25
|
return "";
|
|
@@ -11,22 +28,60 @@ function formatCountdown(expiresAt?: number): string {
|
|
|
11
28
|
return `${Math.floor(seconds / 60)}:${(seconds % 60).toString().padStart(2, "0")}`;
|
|
12
29
|
}
|
|
13
30
|
|
|
14
|
-
function
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
31
|
+
function detectDeviceType(): string {
|
|
32
|
+
return /Mobi|Android/i.test(navigator.userAgent) ? "mobile" : "browser";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function useCountdownClock(expiresAt?: number) {
|
|
36
|
+
const [, setClock] = useState(0);
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (!expiresAt) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const timer = window.setInterval(() => setClock((value) => value + 1), 1000);
|
|
42
|
+
return () => window.clearInterval(timer);
|
|
43
|
+
}, [expiresAt]);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function PairingProgress(props: {
|
|
47
|
+
state: DevicePairingState;
|
|
48
|
+
messages: Partial<Record<string, string>>;
|
|
49
|
+
}) {
|
|
21
50
|
return (
|
|
22
51
|
<output className="text-center py-4 d-block">
|
|
23
52
|
<div className="spinner-border text-primary mb-3" aria-hidden="true" />
|
|
24
|
-
<p className="text-muted mb-0">{messages[state.phase] ?? "Working…"}</p>
|
|
53
|
+
<p className="text-muted mb-0">{props.messages[props.state.phase] ?? "Working…"}</p>
|
|
25
54
|
</output>
|
|
26
55
|
);
|
|
27
56
|
}
|
|
28
57
|
|
|
29
|
-
function
|
|
58
|
+
function ProgressWithCancel(props: {
|
|
59
|
+
state: DevicePairingState;
|
|
60
|
+
messages: Partial<Record<string, string>>;
|
|
61
|
+
onCancel: () => void;
|
|
62
|
+
}) {
|
|
63
|
+
return (
|
|
64
|
+
<>
|
|
65
|
+
<PairingProgress state={props.state} messages={props.messages} />
|
|
66
|
+
<div className="text-center">
|
|
67
|
+
<button type="button" className="btn btn-outline-secondary" onClick={props.onCancel}>
|
|
68
|
+
Cancel
|
|
69
|
+
</button>
|
|
70
|
+
</div>
|
|
71
|
+
</>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function RemoteDeviceLabel({ state }: { state: DevicePairingState }) {
|
|
76
|
+
return (
|
|
77
|
+
<>
|
|
78
|
+
{state.remoteDevice?.deviceName || "Unnamed device"}
|
|
79
|
+
{state.remoteDevice?.deviceType ? ` (${state.remoteDevice.deviceType})` : ""}
|
|
80
|
+
</>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function SourceApproval(props: {
|
|
30
85
|
state: DevicePairingState;
|
|
31
86
|
approving: boolean;
|
|
32
87
|
onApprove: () => void;
|
|
@@ -35,19 +90,13 @@ function PairingComparison(props: {
|
|
|
35
90
|
}) {
|
|
36
91
|
return (
|
|
37
92
|
<div className="text-center" style={{ maxWidth: 440, margin: "0 auto" }}>
|
|
38
|
-
<h4>
|
|
93
|
+
<h4>Add this device?</h4>
|
|
39
94
|
<p className="text-muted">
|
|
40
|
-
|
|
95
|
+
The code matched. Approving sends your account credentials to this device so it can sign in
|
|
96
|
+
as you.
|
|
41
97
|
</p>
|
|
42
|
-
<
|
|
43
|
-
|
|
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})` : ""}
|
|
98
|
+
<p className="fs-5 mb-4">
|
|
99
|
+
<RemoteDeviceLabel state={props.state} />
|
|
51
100
|
</p>
|
|
52
101
|
<div className="d-flex gap-2 justify-content-center">
|
|
53
102
|
<button
|
|
@@ -56,7 +105,7 @@ function PairingComparison(props: {
|
|
|
56
105
|
disabled={props.approving}
|
|
57
106
|
onClick={props.onApprove}
|
|
58
107
|
>
|
|
59
|
-
{props.approving ? "Approving…" : "
|
|
108
|
+
{props.approving ? "Approving…" : "Approve"}
|
|
60
109
|
</button>
|
|
61
110
|
<button
|
|
62
111
|
type="button"
|
|
@@ -97,26 +146,15 @@ function TerminalPanel(props: { state: DevicePairingState; onClose: () => void }
|
|
|
97
146
|
);
|
|
98
147
|
}
|
|
99
148
|
|
|
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
149
|
function usePairingSessionLifecycle(state: DevicePairingState) {
|
|
114
150
|
const sessionId = useRef<string | undefined>(undefined);
|
|
115
151
|
const phase = useRef<DevicePairingState["phase"]>("idle");
|
|
116
152
|
const mounted = useRef(true);
|
|
117
153
|
|
|
118
154
|
useEffect(() => {
|
|
119
|
-
if (state.sessionId
|
|
155
|
+
if (state.sessionId) {
|
|
156
|
+
// The host may rotate to a new session (destination code rotation); follow it.
|
|
157
|
+
sessionId.current = state.sessionId;
|
|
120
158
|
phase.current = state.phase;
|
|
121
159
|
}
|
|
122
160
|
}, [state.phase, state.sessionId]);
|
|
@@ -126,7 +164,7 @@ function usePairingSessionLifecycle(state: DevicePairingState) {
|
|
|
126
164
|
return () => {
|
|
127
165
|
mounted.current = false;
|
|
128
166
|
const activeSessionId = sessionId.current;
|
|
129
|
-
if (activeSessionId && !["idle",
|
|
167
|
+
if (activeSessionId && !["idle", ...TERMINAL_PHASES].includes(phase.current)) {
|
|
130
168
|
void rpcServerCalls
|
|
131
169
|
.devicePairingCancel({ sessionId: activeSessionId })
|
|
132
170
|
.catch(() => undefined);
|
|
@@ -137,50 +175,12 @@ function usePairingSessionLifecycle(state: DevicePairingState) {
|
|
|
137
175
|
return { sessionId, phase, mounted };
|
|
138
176
|
}
|
|
139
177
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const [invitationUrl, setInvitationUrl] = useState("");
|
|
146
|
-
const [localError, setLocalError] = useState("");
|
|
178
|
+
function useCeremonyActions(
|
|
179
|
+
lifecycle: ReturnType<typeof usePairingSessionLifecycle>,
|
|
180
|
+
onLeave: () => void,
|
|
181
|
+
setLocalError: (value: string) => void,
|
|
182
|
+
) {
|
|
147
183
|
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
184
|
|
|
185
185
|
const act = async (action: "approve" | "reject") => {
|
|
186
186
|
const sessionId = lifecycle.sessionId.current;
|
|
@@ -207,139 +207,54 @@ export function SourceDevicePairing(props: { onClose: () => void }) {
|
|
|
207
207
|
if (sessionId) {
|
|
208
208
|
await rpcServerCalls.devicePairingCancel({ sessionId }).catch(() => undefined);
|
|
209
209
|
}
|
|
210
|
-
|
|
210
|
+
onLeave();
|
|
211
211
|
};
|
|
212
212
|
|
|
213
|
-
|
|
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} />;
|
|
213
|
+
return { approving, act, cancel };
|
|
257
214
|
}
|
|
258
215
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
216
|
+
const SOURCE_PROGRESS_MESSAGES: Partial<Record<DevicePairingState["phase"], string>> = {
|
|
217
|
+
connecting: "Connecting to the new device…",
|
|
218
|
+
authenticating: "Verifying the pairing code…",
|
|
219
|
+
transferring: "Sending encrypted account credentials…",
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/** Signed-in source flow opened from Settings: type the code shown on the new device, then approve. */
|
|
223
|
+
export function SourceDevicePairing(props: { onClose: () => void }) {
|
|
265
224
|
const state = useDevicePairingState();
|
|
266
225
|
const lifecycle = usePairingSessionLifecycle(state);
|
|
267
|
-
const [
|
|
226
|
+
const [code, setCode] = useState("");
|
|
227
|
+
const [scanning, setScanning] = useState(false);
|
|
228
|
+
const [starting, setStarting] = useState(false);
|
|
268
229
|
const [localError, setLocalError] = useState("");
|
|
269
|
-
const
|
|
270
|
-
|
|
230
|
+
const { approving, act, cancel } = useCeremonyActions(lifecycle, props.onClose, setLocalError);
|
|
231
|
+
useCountdownClock(state.expiresAt);
|
|
271
232
|
|
|
272
|
-
const start = async (
|
|
233
|
+
const start = async (rawCode: string = code) => {
|
|
273
234
|
setLocalError("");
|
|
235
|
+
setStarting(true);
|
|
274
236
|
try {
|
|
275
|
-
const result = await rpcServerCalls.
|
|
276
|
-
invitation: value.trim(),
|
|
277
|
-
deviceType: /Mobi|Android/i.test(navigator.userAgent) ? "mobile" : "browser",
|
|
278
|
-
});
|
|
237
|
+
const result = await rpcServerCalls.devicePairingStartSource({ code: rawCode.trim() });
|
|
279
238
|
lifecycle.sessionId.current = result.sessionId;
|
|
280
239
|
lifecycle.phase.current = result.phase;
|
|
281
240
|
if (!lifecycle.mounted.current && result.sessionId) {
|
|
282
241
|
await rpcServerCalls
|
|
283
242
|
.devicePairingCancel({ sessionId: result.sessionId })
|
|
284
243
|
.catch(() => undefined);
|
|
285
|
-
return;
|
|
286
244
|
}
|
|
287
|
-
setInvitation("");
|
|
288
|
-
props.onInvitationConsumed?.();
|
|
289
245
|
} catch {
|
|
290
|
-
setLocalError("That
|
|
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.");
|
|
246
|
+
setLocalError("That code was not recognized or the new device is no longer waiting.");
|
|
323
247
|
} finally {
|
|
324
|
-
|
|
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);
|
|
248
|
+
setStarting(false);
|
|
333
249
|
}
|
|
334
|
-
props.onBack();
|
|
335
250
|
};
|
|
336
251
|
|
|
337
|
-
if (
|
|
338
|
-
return <TerminalPanel state={state} onClose={props.
|
|
252
|
+
if (TERMINAL_PHASES.includes(state.phase)) {
|
|
253
|
+
return <TerminalPanel state={state} onClose={props.onClose} />;
|
|
339
254
|
}
|
|
340
|
-
if (state.phase === "
|
|
255
|
+
if (state.phase === "waiting-for-approval") {
|
|
341
256
|
return (
|
|
342
|
-
<
|
|
257
|
+
<SourceApproval
|
|
343
258
|
state={state}
|
|
344
259
|
approving={approving}
|
|
345
260
|
onApprove={() => void act("approve")}
|
|
@@ -350,41 +265,201 @@ export function DestinationDevicePairing(props: {
|
|
|
350
265
|
}
|
|
351
266
|
if (
|
|
352
267
|
state.phase === "connecting" ||
|
|
353
|
-
state.phase === "
|
|
268
|
+
state.phase === "authenticating" ||
|
|
354
269
|
state.phase === "transferring"
|
|
355
270
|
) {
|
|
356
|
-
return
|
|
271
|
+
return (
|
|
272
|
+
<ProgressWithCancel
|
|
273
|
+
state={state}
|
|
274
|
+
messages={SOURCE_PROGRESS_MESSAGES}
|
|
275
|
+
onCancel={() => void cancel()}
|
|
276
|
+
/>
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
if (scanning) {
|
|
280
|
+
return (
|
|
281
|
+
<div className="text-center" style={{ maxWidth: 440, margin: "0 auto" }}>
|
|
282
|
+
<p className="text-muted">Point the camera at the code on the new device.</p>
|
|
283
|
+
<QrScanner
|
|
284
|
+
accept={isPairingCode}
|
|
285
|
+
onScan={(scanned) => {
|
|
286
|
+
setScanning(false);
|
|
287
|
+
setCode(scanned);
|
|
288
|
+
void start(scanned);
|
|
289
|
+
}}
|
|
290
|
+
onError={(message) => {
|
|
291
|
+
setScanning(false);
|
|
292
|
+
setLocalError(message);
|
|
293
|
+
}}
|
|
294
|
+
/>
|
|
295
|
+
<button
|
|
296
|
+
type="button"
|
|
297
|
+
className="btn btn-outline-secondary mt-3"
|
|
298
|
+
onClick={() => setScanning(false)}
|
|
299
|
+
>
|
|
300
|
+
Type it instead
|
|
301
|
+
</button>
|
|
302
|
+
</div>
|
|
303
|
+
);
|
|
357
304
|
}
|
|
358
305
|
return (
|
|
359
|
-
<
|
|
360
|
-
|
|
306
|
+
<form
|
|
307
|
+
className="text-center"
|
|
308
|
+
style={{ maxWidth: 440, margin: "0 auto" }}
|
|
309
|
+
onSubmit={(event) => {
|
|
310
|
+
event.preventDefault();
|
|
311
|
+
void start();
|
|
312
|
+
}}
|
|
313
|
+
>
|
|
361
314
|
<p className="text-muted">
|
|
362
|
-
On
|
|
363
|
-
|
|
315
|
+
On the new device, open Peers and choose <strong>Sign in from another device</strong>. Scan
|
|
316
|
+
the code it shows, or type it here.
|
|
364
317
|
</p>
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
318
|
+
{canScanQr() ? (
|
|
319
|
+
<button
|
|
320
|
+
type="button"
|
|
321
|
+
className="btn btn-outline-primary mb-3"
|
|
322
|
+
disabled={starting}
|
|
323
|
+
onClick={() => setScanning(true)}
|
|
324
|
+
>
|
|
325
|
+
<i className="bi bi-qr-code-scan me-1" />
|
|
326
|
+
Scan code
|
|
327
|
+
</button>
|
|
328
|
+
) : null}
|
|
329
|
+
<input
|
|
330
|
+
className="form-control form-control-lg font-monospace text-center mb-3"
|
|
331
|
+
placeholder="7-guitar-revenge-tunnel"
|
|
332
|
+
value={code}
|
|
333
|
+
onChange={(event) => setCode(event.target.value)}
|
|
371
334
|
autoComplete="off"
|
|
372
|
-
|
|
335
|
+
autoCapitalize="none"
|
|
336
|
+
spellCheck={false}
|
|
337
|
+
aria-label="Pairing code"
|
|
338
|
+
disabled={starting}
|
|
373
339
|
/>
|
|
374
340
|
{localError ? <div className="alert alert-danger">{localError}</div> : null}
|
|
375
|
-
<div className="d-
|
|
341
|
+
<div className="d-flex gap-2 justify-content-center">
|
|
342
|
+
<button
|
|
343
|
+
type="submit"
|
|
344
|
+
className="btn btn-primary"
|
|
345
|
+
disabled={starting || code.trim().split(/[\s-]+/).length < 4}
|
|
346
|
+
>
|
|
347
|
+
{starting ? "Connecting…" : "Connect"}
|
|
348
|
+
</button>
|
|
349
|
+
<button
|
|
350
|
+
type="button"
|
|
351
|
+
className="btn btn-outline-secondary"
|
|
352
|
+
disabled={starting}
|
|
353
|
+
onClick={props.onClose}
|
|
354
|
+
>
|
|
355
|
+
Cancel
|
|
356
|
+
</button>
|
|
357
|
+
</div>
|
|
358
|
+
</form>
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const DESTINATION_PROGRESS_MESSAGES: Partial<Record<DevicePairingState["phase"], string>> = {
|
|
363
|
+
idle: "Preparing a pairing code…",
|
|
364
|
+
connecting: "Connecting to your other device…",
|
|
365
|
+
authenticating: "Verifying the pairing code…",
|
|
366
|
+
"waiting-for-approval": "Waiting for approval on your other device…",
|
|
367
|
+
transferring: "Installing encrypted account credentials…",
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
function ShowingCode(props: { state: DevicePairingState; onCancel: () => void }) {
|
|
371
|
+
const code = props.state.code ?? "";
|
|
372
|
+
return (
|
|
373
|
+
<div className="text-center" style={{ maxWidth: 480, margin: "0 auto" }}>
|
|
374
|
+
<h3>Sign in from another device</h3>
|
|
375
|
+
<p className="text-muted">
|
|
376
|
+
On a device already signed in to Peers, open Settings → <strong>Add another device</strong>{" "}
|
|
377
|
+
and type this code or scan it.
|
|
378
|
+
</p>
|
|
379
|
+
{props.state.notice ? <div className="alert alert-warning">{props.state.notice}</div> : null}
|
|
380
|
+
<output
|
|
381
|
+
className="d-block fs-3 font-monospace text-primary mb-3"
|
|
382
|
+
style={{ letterSpacing: "0.04em", wordBreak: "break-word" }}
|
|
383
|
+
aria-label="Pairing code"
|
|
384
|
+
>
|
|
385
|
+
{code}
|
|
386
|
+
</output>
|
|
387
|
+
<div className="d-inline-block bg-white rounded p-3 mb-3">
|
|
388
|
+
<QRCode value={code} size={200} aria-label="Pairing code QR" />
|
|
389
|
+
</div>
|
|
390
|
+
<p className="small text-muted">Expires in {formatCountdown(props.state.expiresAt)}</p>
|
|
391
|
+
<div className="d-flex gap-2 justify-content-center">
|
|
376
392
|
<button
|
|
377
393
|
type="button"
|
|
378
|
-
className="btn btn-primary
|
|
379
|
-
|
|
380
|
-
onClick={() => void start()}
|
|
394
|
+
className="btn btn-outline-primary"
|
|
395
|
+
onClick={() => void copyToClipboard(code)}
|
|
381
396
|
>
|
|
382
|
-
|
|
397
|
+
Copy code
|
|
398
|
+
</button>
|
|
399
|
+
<button type="button" className="btn btn-outline-secondary" onClick={props.onCancel}>
|
|
400
|
+
Cancel
|
|
383
401
|
</button>
|
|
402
|
+
</div>
|
|
403
|
+
</div>
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Signed-out destination flow: shows a code and QR, then installs credentials after approval. */
|
|
408
|
+
export function DestinationDevicePairing(props: { onBack: () => void }) {
|
|
409
|
+
const state = useDevicePairingState();
|
|
410
|
+
const lifecycle = usePairingSessionLifecycle(state);
|
|
411
|
+
const [localError, setLocalError] = useState("");
|
|
412
|
+
const started = useRef(false);
|
|
413
|
+
const { cancel } = useCeremonyActions(lifecycle, props.onBack, setLocalError);
|
|
414
|
+
useCountdownClock(state.expiresAt);
|
|
415
|
+
|
|
416
|
+
useEffect(() => {
|
|
417
|
+
if (started.current) {
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
started.current = true;
|
|
421
|
+
void rpcServerCalls
|
|
422
|
+
.devicePairingStartDestination({ deviceType: detectDeviceType() })
|
|
423
|
+
.then(async (result) => {
|
|
424
|
+
lifecycle.sessionId.current = result.sessionId;
|
|
425
|
+
lifecycle.phase.current = result.phase;
|
|
426
|
+
if (!lifecycle.mounted.current && result.sessionId) {
|
|
427
|
+
await rpcServerCalls
|
|
428
|
+
.devicePairingCancel({ sessionId: result.sessionId })
|
|
429
|
+
.catch(() => undefined);
|
|
430
|
+
}
|
|
431
|
+
})
|
|
432
|
+
.catch(() => setLocalError("Could not reach the pairing service. Check your connection."));
|
|
433
|
+
}, []);
|
|
434
|
+
|
|
435
|
+
useEffect(() => {
|
|
436
|
+
if (state.phase !== "complete") {
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
window.setTimeout(() => window.location.reload(), 750);
|
|
440
|
+
}, [state.phase]);
|
|
441
|
+
|
|
442
|
+
if (localError) {
|
|
443
|
+
return (
|
|
444
|
+
<div className="text-center" style={{ maxWidth: 440, margin: "0 auto" }}>
|
|
445
|
+
<div className="alert alert-danger">{localError}</div>
|
|
384
446
|
<button type="button" className="btn btn-outline-secondary" onClick={props.onBack}>
|
|
385
447
|
Back
|
|
386
448
|
</button>
|
|
387
449
|
</div>
|
|
388
|
-
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
if (TERMINAL_PHASES.includes(state.phase)) {
|
|
453
|
+
return <TerminalPanel state={state} onClose={props.onBack} />;
|
|
454
|
+
}
|
|
455
|
+
if (state.phase === "showing-code" && state.code) {
|
|
456
|
+
return <ShowingCode state={state} onCancel={() => void cancel()} />;
|
|
457
|
+
}
|
|
458
|
+
return (
|
|
459
|
+
<ProgressWithCancel
|
|
460
|
+
state={state}
|
|
461
|
+
messages={DESTINATION_PROGRESS_MESSAGES}
|
|
462
|
+
onCancel={() => void cancel()}
|
|
463
|
+
/>
|
|
389
464
|
);
|
|
390
465
|
}
|