@peers-app/peers-ui 0.23.14 → 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.
Files changed (93) hide show
  1. package/biome.json +2 -2
  2. package/dist/components/qr-scanner.d.ts +18 -0
  3. package/dist/components/qr-scanner.js +165 -0
  4. package/dist/components/router.d.ts +1 -1
  5. package/dist/components/router.js +1 -1
  6. package/dist/root-layout/root-layout-host.js +18 -19
  7. package/dist/root-layout/root-layout-host.test.js +14 -9
  8. package/dist/screens/account/account-screen.js +52 -1
  9. package/dist/screens/contacts/contact-list.js +28 -1
  10. package/dist/screens/contacts/index.d.ts +0 -2
  11. package/dist/screens/contacts/index.js +0 -2
  12. package/dist/screens/device-pairing/device-pairing.d.ts +2 -4
  13. package/dist/screens/device-pairing/device-pairing.js +127 -128
  14. package/dist/screens/device-pairing/device-pairing.test.js +51 -15
  15. package/dist/screens/device-pairing/index.d.ts +0 -1
  16. package/dist/screens/device-pairing/index.js +0 -1
  17. package/dist/screens/groups/group-details.js +2 -6
  18. package/dist/screens/groups/group-invite-panel.d.ts +14 -0
  19. package/dist/screens/groups/group-invite-panel.js +125 -0
  20. package/dist/screens/groups/group-members.js +9 -103
  21. package/dist/screens/invites/accept-invite.d.ts +11 -0
  22. package/dist/screens/invites/accept-invite.js +114 -0
  23. package/dist/screens/invites/index.d.ts +7 -0
  24. package/dist/screens/{join-group → invites}/index.js +8 -3
  25. package/dist/screens/invites/invite-capture.d.ts +34 -0
  26. package/dist/screens/invites/invite-capture.js +94 -0
  27. package/dist/screens/invites/invite-share.d.ts +15 -0
  28. package/dist/screens/invites/invite-share.js +40 -0
  29. package/dist/screens/invites/invites-ipc.d.ts +10 -0
  30. package/dist/screens/invites/invites-ipc.js +61 -0
  31. package/dist/screens/invites/invites-screen.d.ts +9 -0
  32. package/dist/screens/invites/invites-screen.js +36 -0
  33. package/dist/screens/invites/pending-invites-list.d.ts +29 -0
  34. package/dist/screens/invites/pending-invites-list.js +156 -0
  35. package/dist/screens/settings/settings-page.js +1 -1
  36. package/dist/screens/setup-user.js +4 -18
  37. package/dist/system-apps/index.d.ts +1 -1
  38. package/dist/system-apps/index.js +5 -5
  39. package/dist/system-apps/{join-group.app.d.ts → invites.app.d.ts} +1 -1
  40. package/dist/system-apps/invites.app.js +9 -0
  41. package/package.json +4 -3
  42. package/src/components/qr-scanner.tsx +180 -0
  43. package/src/components/router.tsx +1 -1
  44. package/src/root-layout/root-layout-host.test.tsx +18 -13
  45. package/src/root-layout/root-layout-host.tsx +23 -21
  46. package/src/screens/account/account-screen.tsx +89 -2
  47. package/src/screens/contacts/contact-list.tsx +64 -4
  48. package/src/screens/contacts/index.ts +0 -2
  49. package/src/screens/device-pairing/device-pairing.test.tsx +61 -16
  50. package/src/screens/device-pairing/device-pairing.tsx +283 -208
  51. package/src/screens/device-pairing/index.ts +0 -1
  52. package/src/screens/groups/group-details.tsx +1 -41
  53. package/src/screens/groups/group-invite-panel.tsx +316 -0
  54. package/src/screens/groups/group-members.tsx +15 -177
  55. package/src/screens/invites/accept-invite.tsx +271 -0
  56. package/src/screens/invites/index.ts +9 -0
  57. package/src/screens/invites/invite-capture.ts +107 -0
  58. package/src/screens/invites/invite-share.tsx +104 -0
  59. package/src/screens/invites/invites-ipc.ts +74 -0
  60. package/src/screens/invites/invites-screen.tsx +78 -0
  61. package/src/screens/invites/pending-invites-list.tsx +224 -0
  62. package/src/screens/settings/settings-page.tsx +2 -2
  63. package/src/screens/setup-user.tsx +29 -47
  64. package/src/system-apps/index.ts +3 -3
  65. package/src/system-apps/invites.app.ts +8 -0
  66. package/dist/screens/contacts/user-connect-invite.d.ts +0 -118
  67. package/dist/screens/contacts/user-connect-invite.js +0 -264
  68. package/dist/screens/contacts/user-connect-invite.test.d.ts +0 -1
  69. package/dist/screens/contacts/user-connect-invite.test.js +0 -215
  70. package/dist/screens/contacts/user-connect.d.ts +0 -8
  71. package/dist/screens/contacts/user-connect.js +0 -487
  72. package/dist/screens/contacts/user-connect.test.d.ts +0 -1
  73. package/dist/screens/contacts/user-connect.test.js +0 -291
  74. package/dist/screens/device-pairing/device-pairing-invite.d.ts +0 -10
  75. package/dist/screens/device-pairing/device-pairing-invite.js +0 -50
  76. package/dist/screens/device-pairing/device-pairing-invite.test.d.ts +0 -1
  77. package/dist/screens/device-pairing/device-pairing-invite.test.js +0 -30
  78. package/dist/screens/groups/group-invite-listener.d.ts +0 -14
  79. package/dist/screens/groups/group-invite-listener.js +0 -93
  80. package/dist/screens/join-group/index.d.ts +0 -2
  81. package/dist/screens/join-group/join-group.d.ts +0 -12
  82. package/dist/screens/join-group/join-group.js +0 -102
  83. package/dist/system-apps/join-group.app.js +0 -9
  84. package/src/screens/contacts/user-connect-invite.test.ts +0 -256
  85. package/src/screens/contacts/user-connect-invite.ts +0 -332
  86. package/src/screens/contacts/user-connect.test.tsx +0 -382
  87. package/src/screens/contacts/user-connect.tsx +0 -883
  88. package/src/screens/device-pairing/device-pairing-invite.test.ts +0 -50
  89. package/src/screens/device-pairing/device-pairing-invite.ts +0 -55
  90. package/src/screens/groups/group-invite-listener.tsx +0 -282
  91. package/src/screens/join-group/index.ts +0 -4
  92. package/src/screens/join-group/join-group.tsx +0 -296
  93. package/src/system-apps/join-group.app.ts +0 -8
@@ -1,8 +1,25 @@
1
- import { copyToClipboard, type DevicePairingState, rpcServerCalls } from "@peers-app/peers-sdk";
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 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
- };
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 PairingComparison(props: {
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>Compare these numbers</h4>
93
+ <h4>Add this device?</h4>
39
94
  <p className="text-muted">
40
- Confirm that both devices show the same number and the expected device before approving.
95
+ The code matched. Approving sends your account credentials to this device so it can sign in
96
+ as you.
41
97
  </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})` : ""}
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…" : "Numbers match — approve"}
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 && state.sessionId === sessionId.current) {
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", "complete", "rejected", "error"].includes(phase.current)) {
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
- /** 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("");
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
- props.onClose();
210
+ onLeave();
211
211
  };
212
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} />;
213
+ return { approving, act, cancel };
257
214
  }
258
215
 
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
- }) {
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 [invitation, setInvitation] = useState(props.initialInvitation ?? "");
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 [approving, setApproving] = useState(false);
270
- const autoStarted = useRef(false);
230
+ const { approving, act, cancel } = useCeremonyActions(lifecycle, props.onClose, setLocalError);
231
+ useCountdownClock(state.expiresAt);
271
232
 
272
- const start = async (value = invitation) => {
233
+ const start = async (rawCode: string = code) => {
273
234
  setLocalError("");
235
+ setStarting(true);
274
236
  try {
275
- const result = await rpcServerCalls.devicePairingStartDestination({
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 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.");
246
+ setLocalError("That code was not recognized or the new device is no longer waiting.");
323
247
  } 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);
248
+ setStarting(false);
333
249
  }
334
- props.onBack();
335
250
  };
336
251
 
337
- if (["complete", "rejected", "error"].includes(state.phase)) {
338
- return <TerminalPanel state={state} onClose={props.onBack} />;
252
+ if (TERMINAL_PHASES.includes(state.phase)) {
253
+ return <TerminalPanel state={state} onClose={props.onClose} />;
339
254
  }
340
- if (state.phase === "comparing" && state.sas) {
255
+ if (state.phase === "waiting-for-approval") {
341
256
  return (
342
- <PairingComparison
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 === "waiting-for-approval" ||
268
+ state.phase === "authenticating" ||
354
269
  state.phase === "transferring"
355
270
  ) {
356
- return <ProgressWithCancel state={state} onCancel={() => void cancel()} />;
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
- <div className="text-center" style={{ maxWidth: 500, margin: "0 auto" }}>
360
- <h3>Sign in using another device</h3>
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 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.
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
- <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)}
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
- aria-label="Device pairing invitation"
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-grid gap-2">
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 btn-lg"
379
- disabled={!invitation.trim()}
380
- onClick={() => void start()}
394
+ className="btn btn-outline-primary"
395
+ onClick={() => void copyToClipboard(code)}
381
396
  >
382
- Connect securely
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
- </div>
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
  }
@@ -1,3 +1,2 @@
1
1
  export * from "./device-pairing";
2
- export * from "./device-pairing-invite";
3
2
  export * from "./use-device-pairing-state";