@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
@@ -9,7 +9,18 @@ const jsx_runtime_1 = require("react/jsx-runtime");
9
9
  const peers_sdk_1 = require("@peers-app/peers-sdk");
10
10
  const react_1 = require("react");
11
11
  const react_qr_code_1 = __importDefault(require("react-qr-code"));
12
+ const qr_scanner_1 = require("../../components/qr-scanner");
12
13
  const use_device_pairing_state_1 = require("./use-device-pairing-state");
14
+ function isPairingCode(text) {
15
+ try {
16
+ (0, peers_sdk_1.parsePairingCode)(text);
17
+ return true;
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }
23
+ const TERMINAL_PHASES = ["complete", "rejected", "error"];
13
24
  function formatCountdown(expiresAt) {
14
25
  if (!expiresAt) {
15
26
  return "";
@@ -17,17 +28,30 @@ function formatCountdown(expiresAt) {
17
28
  const seconds = Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000));
18
29
  return `${Math.floor(seconds / 60)}:${(seconds % 60).toString().padStart(2, "0")}`;
19
30
  }
20
- function PairingProgress({ state }) {
21
- const messages = {
22
- inviting: "Waiting for the new device…",
23
- connecting: "Establishing a secure connection…",
24
- "waiting-for-approval": "Waiting for approval on the other device…",
25
- transferring: "Installing encrypted account credentials…",
26
- };
27
- return ((0, jsx_runtime_1.jsxs)("output", { className: "text-center py-4 d-block", children: [(0, jsx_runtime_1.jsx)("div", { className: "spinner-border text-primary mb-3", "aria-hidden": "true" }), (0, jsx_runtime_1.jsx)("p", { className: "text-muted mb-0", children: messages[state.phase] ?? "Working…" })] }));
31
+ function detectDeviceType() {
32
+ return /Mobi|Android/i.test(navigator.userAgent) ? "mobile" : "browser";
33
+ }
34
+ function useCountdownClock(expiresAt) {
35
+ const [, setClock] = (0, react_1.useState)(0);
36
+ (0, react_1.useEffect)(() => {
37
+ if (!expiresAt) {
38
+ return;
39
+ }
40
+ const timer = window.setInterval(() => setClock((value) => value + 1), 1000);
41
+ return () => window.clearInterval(timer);
42
+ }, [expiresAt]);
43
+ }
44
+ function PairingProgress(props) {
45
+ return ((0, jsx_runtime_1.jsxs)("output", { className: "text-center py-4 d-block", children: [(0, jsx_runtime_1.jsx)("div", { className: "spinner-border text-primary mb-3", "aria-hidden": "true" }), (0, jsx_runtime_1.jsx)("p", { className: "text-muted mb-0", children: props.messages[props.state.phase] ?? "Working…" })] }));
46
+ }
47
+ function ProgressWithCancel(props) {
48
+ return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(PairingProgress, { state: props.state, messages: props.messages }), (0, jsx_runtime_1.jsx)("div", { className: "text-center", children: (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-secondary", onClick: props.onCancel, children: "Cancel" }) })] }));
49
+ }
50
+ function RemoteDeviceLabel({ state }) {
51
+ return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [state.remoteDevice?.deviceName || "Unnamed device", state.remoteDevice?.deviceType ? ` (${state.remoteDevice.deviceType})` : ""] }));
28
52
  }
29
- function PairingComparison(props) {
30
- return ((0, jsx_runtime_1.jsxs)("div", { className: "text-center", style: { maxWidth: 440, margin: "0 auto" }, children: [(0, jsx_runtime_1.jsx)("h4", { children: "Compare these numbers" }), (0, jsx_runtime_1.jsx)("p", { className: "text-muted", children: "Confirm that both devices show the same number and the expected device before approving." }), (0, jsx_runtime_1.jsx)("div", { className: "display-4 font-monospace text-primary mb-3", style: { letterSpacing: "0.12em" }, children: props.state.sas }), (0, jsx_runtime_1.jsxs)("p", { className: "mb-4", children: [props.state.remoteDevice?.deviceName || "Unnamed device", props.state.remoteDevice?.deviceType ? ` (${props.state.remoteDevice.deviceType})` : ""] }), (0, jsx_runtime_1.jsxs)("div", { className: "d-flex gap-2 justify-content-center", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-primary", disabled: props.approving, onClick: props.onApprove, children: props.approving ? "Approving…" : "Numbers match — approve" }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-danger", disabled: props.approving, onClick: props.onReject, children: "Reject" }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-secondary", disabled: props.approving, onClick: props.onCancel, children: "Cancel" })] })] }));
53
+ function SourceApproval(props) {
54
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "text-center", style: { maxWidth: 440, margin: "0 auto" }, children: [(0, jsx_runtime_1.jsx)("h4", { children: "Add this device?" }), (0, jsx_runtime_1.jsx)("p", { className: "text-muted", children: "The code matched. Approving sends your account credentials to this device so it can sign in as you." }), (0, jsx_runtime_1.jsx)("p", { className: "fs-5 mb-4", children: (0, jsx_runtime_1.jsx)(RemoteDeviceLabel, { state: props.state }) }), (0, jsx_runtime_1.jsxs)("div", { className: "d-flex gap-2 justify-content-center", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-primary", disabled: props.approving, onClick: props.onApprove, children: props.approving ? "Approving…" : "Approve" }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-danger", disabled: props.approving, onClick: props.onReject, children: "Reject" }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-secondary", disabled: props.approving, onClick: props.onCancel, children: "Cancel" })] })] }));
31
55
  }
32
56
  function TerminalPanel(props) {
33
57
  const complete = props.state.phase === "complete";
@@ -37,15 +61,14 @@ function TerminalPanel(props) {
37
61
  ? "Pairing was rejected. No credentials were installed."
38
62
  : props.state.error || "Pairing could not be completed. No credentials were installed." }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-secondary", onClick: props.onClose, children: complete ? "Done" : "Back" })] }));
39
63
  }
40
- function ProgressWithCancel(props) {
41
- return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(PairingProgress, { state: props.state }), (0, jsx_runtime_1.jsx)("div", { className: "text-center", children: (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-secondary", onClick: props.onCancel, children: "Cancel" }) })] }));
42
- }
43
64
  function usePairingSessionLifecycle(state) {
44
65
  const sessionId = (0, react_1.useRef)(undefined);
45
66
  const phase = (0, react_1.useRef)("idle");
46
67
  const mounted = (0, react_1.useRef)(true);
47
68
  (0, react_1.useEffect)(() => {
48
- if (state.sessionId && state.sessionId === sessionId.current) {
69
+ if (state.sessionId) {
70
+ // The host may rotate to a new session (destination code rotation); follow it.
71
+ sessionId.current = state.sessionId;
49
72
  phase.current = state.phase;
50
73
  }
51
74
  }, [state.phase, state.sessionId]);
@@ -54,7 +77,7 @@ function usePairingSessionLifecycle(state) {
54
77
  return () => {
55
78
  mounted.current = false;
56
79
  const activeSessionId = sessionId.current;
57
- if (activeSessionId && !["idle", "complete", "rejected", "error"].includes(phase.current)) {
80
+ if (activeSessionId && !["idle", ...TERMINAL_PHASES].includes(phase.current)) {
58
81
  void peers_sdk_1.rpcServerCalls
59
82
  .devicePairingCancel({ sessionId: activeSessionId })
60
83
  .catch(() => undefined);
@@ -63,47 +86,8 @@ function usePairingSessionLifecycle(state) {
63
86
  }, []);
64
87
  return { sessionId, phase, mounted };
65
88
  }
66
- /** Signed-in source flow opened from Settings. */
67
- function SourceDevicePairing(props) {
68
- const state = (0, use_device_pairing_state_1.useDevicePairingState)();
69
- const lifecycle = usePairingSessionLifecycle(state);
70
- const started = (0, react_1.useRef)(false);
71
- const [invitationUrl, setInvitationUrl] = (0, react_1.useState)("");
72
- const [localError, setLocalError] = (0, react_1.useState)("");
89
+ function useCeremonyActions(lifecycle, onLeave, setLocalError) {
73
90
  const [approving, setApproving] = (0, react_1.useState)(false);
74
- const [, setClock] = (0, react_1.useState)(0);
75
- (0, react_1.useEffect)(() => {
76
- if (started.current) {
77
- return;
78
- }
79
- started.current = true;
80
- void peers_sdk_1.rpcServerCalls
81
- .devicePairingStartSource()
82
- .then(async (result) => {
83
- lifecycle.sessionId.current = result.state.sessionId;
84
- lifecycle.phase.current = result.state.phase;
85
- if (!lifecycle.mounted.current && result.state.sessionId) {
86
- await peers_sdk_1.rpcServerCalls
87
- .devicePairingCancel({ sessionId: result.state.sessionId })
88
- .catch(() => undefined);
89
- return;
90
- }
91
- setInvitationUrl(result.invitationUrl);
92
- })
93
- .catch(() => setLocalError("Could not create a pairing invitation."));
94
- }, []);
95
- (0, react_1.useEffect)(() => {
96
- if (!state.expiresAt) {
97
- return;
98
- }
99
- const timer = window.setInterval(() => setClock((value) => value + 1), 1000);
100
- return () => window.clearInterval(timer);
101
- }, [state.expiresAt]);
102
- (0, react_1.useEffect)(() => {
103
- if (state.phase !== "inviting") {
104
- setInvitationUrl("");
105
- }
106
- }, [state.phase]);
107
91
  const act = async (action) => {
108
92
  const sessionId = lifecycle.sessionId.current;
109
93
  if (!sessionId) {
@@ -131,107 +115,122 @@ function SourceDevicePairing(props) {
131
115
  if (sessionId) {
132
116
  await peers_sdk_1.rpcServerCalls.devicePairingCancel({ sessionId }).catch(() => undefined);
133
117
  }
134
- props.onClose();
118
+ onLeave();
135
119
  };
136
- if (localError) {
137
- return (0, jsx_runtime_1.jsx)("div", { className: "alert alert-danger", children: localError });
138
- }
139
- if (["complete", "rejected", "error"].includes(state.phase)) {
140
- return (0, jsx_runtime_1.jsx)(TerminalPanel, { state: state, onClose: props.onClose });
141
- }
142
- if (state.phase === "comparing" && state.sas) {
143
- return ((0, jsx_runtime_1.jsx)(PairingComparison, { state: state, approving: approving, onApprove: () => void act("approve"), onReject: () => void act("reject"), onCancel: () => void cancel() }));
144
- }
145
- if (state.phase === "waiting-for-approval" || state.phase === "transferring") {
146
- return (0, jsx_runtime_1.jsx)(ProgressWithCancel, { state: state, onCancel: () => void cancel() });
147
- }
148
- if (invitationUrl) {
149
- return ((0, jsx_runtime_1.jsxs)("div", { className: "text-center", style: { maxWidth: 440, margin: "0 auto" }, children: [(0, jsx_runtime_1.jsx)("p", { className: "text-muted", children: "Scan this on the signed-out device you want to add." }), (0, jsx_runtime_1.jsx)("div", { className: "d-inline-block bg-white rounded p-3 mb-3", children: (0, jsx_runtime_1.jsx)(react_qr_code_1.default, { value: invitationUrl, size: 220, "aria-label": "Device pairing QR code" }) }), (0, jsx_runtime_1.jsxs)("p", { className: "small text-muted", children: ["Expires in ", formatCountdown(state.expiresAt)] }), (0, jsx_runtime_1.jsxs)("div", { className: "d-flex gap-2 justify-content-center", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-primary", onClick: () => void (0, peers_sdk_1.copyToClipboard)(invitationUrl), children: "Copy pairing link" }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-secondary", onClick: () => void cancel(), children: "Cancel" })] })] }));
150
- }
151
- return (0, jsx_runtime_1.jsx)(PairingProgress, { state: state });
120
+ return { approving, act, cancel };
152
121
  }
153
- /** Signed-out destination flow opened by a QR link or manual invitation paste. */
154
- function DestinationDevicePairing(props) {
122
+ const SOURCE_PROGRESS_MESSAGES = {
123
+ connecting: "Connecting to the new device…",
124
+ authenticating: "Verifying the pairing code…",
125
+ transferring: "Sending encrypted account credentials…",
126
+ };
127
+ /** Signed-in source flow opened from Settings: type the code shown on the new device, then approve. */
128
+ function SourceDevicePairing(props) {
155
129
  const state = (0, use_device_pairing_state_1.useDevicePairingState)();
156
130
  const lifecycle = usePairingSessionLifecycle(state);
157
- const [invitation, setInvitation] = (0, react_1.useState)(props.initialInvitation ?? "");
131
+ const [code, setCode] = (0, react_1.useState)("");
132
+ const [scanning, setScanning] = (0, react_1.useState)(false);
133
+ const [starting, setStarting] = (0, react_1.useState)(false);
158
134
  const [localError, setLocalError] = (0, react_1.useState)("");
159
- const [approving, setApproving] = (0, react_1.useState)(false);
160
- const autoStarted = (0, react_1.useRef)(false);
161
- const start = async (value = invitation) => {
135
+ const { approving, act, cancel } = useCeremonyActions(lifecycle, props.onClose, setLocalError);
136
+ useCountdownClock(state.expiresAt);
137
+ const start = async (rawCode = code) => {
162
138
  setLocalError("");
139
+ setStarting(true);
163
140
  try {
164
- const result = await peers_sdk_1.rpcServerCalls.devicePairingStartDestination({
165
- invitation: value.trim(),
166
- deviceType: /Mobi|Android/i.test(navigator.userAgent) ? "mobile" : "browser",
167
- });
141
+ const result = await peers_sdk_1.rpcServerCalls.devicePairingStartSource({ code: rawCode.trim() });
168
142
  lifecycle.sessionId.current = result.sessionId;
169
143
  lifecycle.phase.current = result.phase;
170
144
  if (!lifecycle.mounted.current && result.sessionId) {
171
145
  await peers_sdk_1.rpcServerCalls
172
146
  .devicePairingCancel({ sessionId: result.sessionId })
173
147
  .catch(() => undefined);
174
- return;
175
148
  }
176
- setInvitation("");
177
- props.onInvitationConsumed?.();
178
149
  }
179
150
  catch {
180
- setLocalError("That pairing invitation is invalid, expired, or unavailable.");
151
+ setLocalError("That code was not recognized or the new device is no longer waiting.");
152
+ }
153
+ finally {
154
+ setStarting(false);
181
155
  }
182
156
  };
157
+ if (TERMINAL_PHASES.includes(state.phase)) {
158
+ return (0, jsx_runtime_1.jsx)(TerminalPanel, { state: state, onClose: props.onClose });
159
+ }
160
+ if (state.phase === "waiting-for-approval") {
161
+ return ((0, jsx_runtime_1.jsx)(SourceApproval, { state: state, approving: approving, onApprove: () => void act("approve"), onReject: () => void act("reject"), onCancel: () => void cancel() }));
162
+ }
163
+ if (state.phase === "connecting" ||
164
+ state.phase === "authenticating" ||
165
+ state.phase === "transferring") {
166
+ return ((0, jsx_runtime_1.jsx)(ProgressWithCancel, { state: state, messages: SOURCE_PROGRESS_MESSAGES, onCancel: () => void cancel() }));
167
+ }
168
+ if (scanning) {
169
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "text-center", style: { maxWidth: 440, margin: "0 auto" }, children: [(0, jsx_runtime_1.jsx)("p", { className: "text-muted", children: "Point the camera at the code on the new device." }), (0, jsx_runtime_1.jsx)(qr_scanner_1.QrScanner, { accept: isPairingCode, onScan: (scanned) => {
170
+ setScanning(false);
171
+ setCode(scanned);
172
+ void start(scanned);
173
+ }, onError: (message) => {
174
+ setScanning(false);
175
+ setLocalError(message);
176
+ } }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-secondary mt-3", onClick: () => setScanning(false), children: "Type it instead" })] }));
177
+ }
178
+ return ((0, jsx_runtime_1.jsxs)("form", { className: "text-center", style: { maxWidth: 440, margin: "0 auto" }, onSubmit: (event) => {
179
+ event.preventDefault();
180
+ void start();
181
+ }, children: [(0, jsx_runtime_1.jsxs)("p", { className: "text-muted", children: ["On the new device, open Peers and choose ", (0, jsx_runtime_1.jsx)("strong", { children: "Sign in from another device" }), ". Scan the code it shows, or type it here."] }), (0, qr_scanner_1.canScanQr)() ? ((0, jsx_runtime_1.jsxs)("button", { type: "button", className: "btn btn-outline-primary mb-3", disabled: starting, onClick: () => setScanning(true), children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-qr-code-scan me-1" }), "Scan code"] })) : null, (0, jsx_runtime_1.jsx)("input", { className: "form-control form-control-lg font-monospace text-center mb-3", placeholder: "7-guitar-revenge-tunnel", value: code, onChange: (event) => setCode(event.target.value), autoComplete: "off", autoCapitalize: "none", spellCheck: false, "aria-label": "Pairing code", disabled: starting }), localError ? (0, jsx_runtime_1.jsx)("div", { className: "alert alert-danger", children: localError }) : null, (0, jsx_runtime_1.jsxs)("div", { className: "d-flex gap-2 justify-content-center", children: [(0, jsx_runtime_1.jsx)("button", { type: "submit", className: "btn btn-primary", disabled: starting || code.trim().split(/[\s-]+/).length < 4, children: starting ? "Connecting…" : "Connect" }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-secondary", disabled: starting, onClick: props.onClose, children: "Cancel" })] })] }));
182
+ }
183
+ const DESTINATION_PROGRESS_MESSAGES = {
184
+ idle: "Preparing a pairing code…",
185
+ connecting: "Connecting to your other device…",
186
+ authenticating: "Verifying the pairing code…",
187
+ "waiting-for-approval": "Waiting for approval on your other device…",
188
+ transferring: "Installing encrypted account credentials…",
189
+ };
190
+ function ShowingCode(props) {
191
+ const code = props.state.code ?? "";
192
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "text-center", style: { maxWidth: 480, margin: "0 auto" }, children: [(0, jsx_runtime_1.jsx)("h3", { children: "Sign in from another device" }), (0, jsx_runtime_1.jsxs)("p", { className: "text-muted", children: ["On a device already signed in to Peers, open Settings \u2192 ", (0, jsx_runtime_1.jsx)("strong", { children: "Add another device" }), " ", "and type this code or scan it."] }), props.state.notice ? (0, jsx_runtime_1.jsx)("div", { className: "alert alert-warning", children: props.state.notice }) : null, (0, jsx_runtime_1.jsx)("output", { className: "d-block fs-3 font-monospace text-primary mb-3", style: { letterSpacing: "0.04em", wordBreak: "break-word" }, "aria-label": "Pairing code", children: code }), (0, jsx_runtime_1.jsx)("div", { className: "d-inline-block bg-white rounded p-3 mb-3", children: (0, jsx_runtime_1.jsx)(react_qr_code_1.default, { value: code, size: 200, "aria-label": "Pairing code QR" }) }), (0, jsx_runtime_1.jsxs)("p", { className: "small text-muted", children: ["Expires in ", formatCountdown(props.state.expiresAt)] }), (0, jsx_runtime_1.jsxs)("div", { className: "d-flex gap-2 justify-content-center", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-primary", onClick: () => void (0, peers_sdk_1.copyToClipboard)(code), children: "Copy code" }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-secondary", onClick: props.onCancel, children: "Cancel" })] })] }));
193
+ }
194
+ /** Signed-out destination flow: shows a code and QR, then installs credentials after approval. */
195
+ function DestinationDevicePairing(props) {
196
+ const state = (0, use_device_pairing_state_1.useDevicePairingState)();
197
+ const lifecycle = usePairingSessionLifecycle(state);
198
+ const [localError, setLocalError] = (0, react_1.useState)("");
199
+ const started = (0, react_1.useRef)(false);
200
+ const { cancel } = useCeremonyActions(lifecycle, props.onBack, setLocalError);
201
+ useCountdownClock(state.expiresAt);
183
202
  (0, react_1.useEffect)(() => {
184
- if (!props.initialInvitation || autoStarted.current) {
203
+ if (started.current) {
185
204
  return;
186
205
  }
187
- autoStarted.current = true;
188
- void start(props.initialInvitation);
189
- }, [props.initialInvitation]);
206
+ started.current = true;
207
+ void peers_sdk_1.rpcServerCalls
208
+ .devicePairingStartDestination({ deviceType: detectDeviceType() })
209
+ .then(async (result) => {
210
+ lifecycle.sessionId.current = result.sessionId;
211
+ lifecycle.phase.current = result.phase;
212
+ if (!lifecycle.mounted.current && result.sessionId) {
213
+ await peers_sdk_1.rpcServerCalls
214
+ .devicePairingCancel({ sessionId: result.sessionId })
215
+ .catch(() => undefined);
216
+ }
217
+ })
218
+ .catch(() => setLocalError("Could not reach the pairing service. Check your connection."));
219
+ }, []);
190
220
  (0, react_1.useEffect)(() => {
191
221
  if (state.phase !== "complete") {
192
222
  return;
193
223
  }
194
224
  window.setTimeout(() => window.location.reload(), 750);
195
225
  }, [state.phase]);
196
- const act = async (action) => {
197
- const sessionId = lifecycle.sessionId.current;
198
- if (!sessionId) {
199
- return;
200
- }
201
- setApproving(true);
202
- try {
203
- if (action === "approve") {
204
- await peers_sdk_1.rpcServerCalls.devicePairingApprove({ sessionId });
205
- }
206
- else {
207
- await peers_sdk_1.rpcServerCalls.devicePairingReject({ sessionId });
208
- }
209
- }
210
- catch {
211
- setLocalError("Could not update the pairing ceremony.");
212
- }
213
- finally {
214
- setApproving(false);
215
- }
216
- };
217
- const cancel = async () => {
218
- const sessionId = lifecycle.sessionId.current;
219
- lifecycle.phase.current = "idle";
220
- if (sessionId) {
221
- await peers_sdk_1.rpcServerCalls.devicePairingCancel({ sessionId }).catch(() => undefined);
222
- }
223
- props.onBack();
224
- };
225
- if (["complete", "rejected", "error"].includes(state.phase)) {
226
- return (0, jsx_runtime_1.jsx)(TerminalPanel, { state: state, onClose: props.onBack });
226
+ if (localError) {
227
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "text-center", style: { maxWidth: 440, margin: "0 auto" }, children: [(0, jsx_runtime_1.jsx)("div", { className: "alert alert-danger", children: localError }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-secondary", onClick: props.onBack, children: "Back" })] }));
227
228
  }
228
- if (state.phase === "comparing" && state.sas) {
229
- return ((0, jsx_runtime_1.jsx)(PairingComparison, { state: state, approving: approving, onApprove: () => void act("approve"), onReject: () => void act("reject"), onCancel: () => void cancel() }));
229
+ if (TERMINAL_PHASES.includes(state.phase)) {
230
+ return (0, jsx_runtime_1.jsx)(TerminalPanel, { state: state, onClose: props.onBack });
230
231
  }
231
- if (state.phase === "connecting" ||
232
- state.phase === "waiting-for-approval" ||
233
- state.phase === "transferring") {
234
- return (0, jsx_runtime_1.jsx)(ProgressWithCancel, { state: state, onCancel: () => void cancel() });
232
+ if (state.phase === "showing-code" && state.code) {
233
+ return (0, jsx_runtime_1.jsx)(ShowingCode, { state: state, onCancel: () => void cancel() });
235
234
  }
236
- return ((0, jsx_runtime_1.jsxs)("div", { className: "text-center", style: { maxWidth: 500, margin: "0 auto" }, children: [(0, jsx_runtime_1.jsx)("h3", { children: "Sign in using another device" }), (0, jsx_runtime_1.jsx)("p", { className: "text-muted", children: "On a device already signed in to Peers, choose Add another device. Scan its QR code, or paste the complete pairing link or token below." }), (0, jsx_runtime_1.jsx)("textarea", { className: "form-control font-monospace mb-3", rows: 4, placeholder: "Paste pairing link or token", value: invitation, onChange: (event) => setInvitation(event.target.value), autoComplete: "off", "aria-label": "Device pairing invitation" }), localError ? (0, jsx_runtime_1.jsx)("div", { className: "alert alert-danger", children: localError }) : null, (0, jsx_runtime_1.jsxs)("div", { className: "d-grid gap-2", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-primary btn-lg", disabled: !invitation.trim(), onClick: () => void start(), children: "Connect securely" }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-secondary", onClick: props.onBack, children: "Back" })] })] }));
235
+ return ((0, jsx_runtime_1.jsx)(ProgressWithCancel, { state: state, messages: DESTINATION_PROGRESS_MESSAGES, onCancel: () => void cancel() }));
237
236
  }
@@ -28,17 +28,19 @@ describe("device pairing UI lifecycle", () => {
28
28
  mockPairingState = { phase: "idle" };
29
29
  mockRpc.cancel.mockResolvedValue({ phase: "idle" });
30
30
  });
31
- it("uses the host-owned invitation URL and cancels a source that resolves after unmount", async () => {
31
+ it("starts the destination on mount and cancels when it resolves after unmount", async () => {
32
32
  let resolveStart;
33
- mockRpc.startSource.mockReturnValue(new Promise((resolve) => {
33
+ mockRpc.startDestination.mockReturnValue(new Promise((resolve) => {
34
34
  resolveStart = resolve;
35
35
  }));
36
- const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(device_pairing_1.SourceDevicePairing, { onClose: jest.fn() }));
37
- expect(mockRpc.startSource).toHaveBeenCalledWith();
36
+ const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(device_pairing_1.DestinationDevicePairing, { onBack: jest.fn() }));
37
+ expect(mockRpc.startDestination).toHaveBeenCalledWith({ deviceType: "browser" });
38
38
  view.unmount();
39
39
  resolveStart({
40
- state: { sessionId: "00mh0wlipkdbeaw8tests0001", role: "source", phase: "inviting" },
41
- invitationUrl: "https://peers.app/?app#pair=token",
40
+ sessionId: "00mh0wlipkdbeaw8tests0001",
41
+ role: "destination",
42
+ phase: "showing-code",
43
+ code: "7-aardvark-adroitness-absurd",
42
44
  });
43
45
  await (0, react_1.waitFor)(() => {
44
46
  expect(mockRpc.cancel).toHaveBeenCalledWith({
@@ -46,23 +48,56 @@ describe("device pairing UI lifecycle", () => {
46
48
  });
47
49
  });
48
50
  });
49
- it("keeps source cancellation available while waiting for remote approval", async () => {
50
- const onClose = jest.fn();
51
+ it("shows the destination code and QR while waiting for a source", () => {
52
+ mockPairingState = {
53
+ sessionId: "00mh0wlipkdbeaw8tests0004",
54
+ role: "destination",
55
+ phase: "showing-code",
56
+ code: "7-aardvark-adroitness-absurd",
57
+ notice: "Pairing code did not match. Try the new code.",
58
+ expiresAt: Date.now() + 60_000,
59
+ };
60
+ mockRpc.startDestination.mockResolvedValue(mockPairingState);
61
+ const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(device_pairing_1.DestinationDevicePairing, { onBack: jest.fn() }));
62
+ expect(view.getByLabelText("Pairing code")).toHaveTextContent("7-aardvark-adroitness-absurd");
63
+ expect(view.getByLabelText("Pairing code QR")).toBeInTheDocument();
64
+ expect(view.getByText("Pairing code did not match. Try the new code.")).toBeInTheDocument();
65
+ });
66
+ it("sends the typed code to the host and only approves from the source", async () => {
67
+ mockRpc.startSource.mockResolvedValue({
68
+ sessionId: "00mh0wlipkdbeaw8tests0002",
69
+ role: "source",
70
+ phase: "connecting",
71
+ });
72
+ const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(device_pairing_1.SourceDevicePairing, { onClose: jest.fn() }));
73
+ react_1.fireEvent.change(view.getByLabelText("Pairing code"), {
74
+ target: { value: " 7 aardvark adroitness absurd " },
75
+ });
76
+ react_1.fireEvent.click(view.getByRole("button", { name: "Connect" }));
77
+ await (0, react_1.waitFor)(() => expect(mockRpc.startSource).toHaveBeenCalledWith({ code: "7 aardvark adroitness absurd" }));
51
78
  mockPairingState = {
52
79
  sessionId: "00mh0wlipkdbeaw8tests0002",
53
80
  role: "source",
54
81
  phase: "waiting-for-approval",
82
+ remoteDevice: { deviceName: "New phone", deviceType: "mobile" },
83
+ };
84
+ view.rerender((0, jsx_runtime_1.jsx)(device_pairing_1.SourceDevicePairing, { onClose: jest.fn() }));
85
+ expect(view.getByText("New phone (mobile)")).toBeInTheDocument();
86
+ react_1.fireEvent.click(view.getByRole("button", { name: "Approve" }));
87
+ await (0, react_1.waitFor)(() => expect(mockRpc.approve).toHaveBeenCalledWith({ sessionId: "00mh0wlipkdbeaw8tests0002" }));
88
+ });
89
+ it("keeps source cancellation available while transferring", async () => {
90
+ const onClose = jest.fn();
91
+ mockPairingState = {
92
+ sessionId: "00mh0wlipkdbeaw8tests0003",
93
+ role: "source",
94
+ phase: "transferring",
55
95
  };
56
- mockRpc.startSource.mockResolvedValue({
57
- state: mockPairingState,
58
- invitationUrl: "https://peers.app/?app#pair=token",
59
- });
60
96
  const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(device_pairing_1.SourceDevicePairing, { onClose: onClose }));
61
- await (0, react_1.waitFor)(() => expect(mockRpc.startSource).toHaveBeenCalled());
62
97
  react_1.fireEvent.click(view.getByRole("button", { name: "Cancel" }));
63
98
  await (0, react_1.waitFor)(() => {
64
99
  expect(mockRpc.cancel).toHaveBeenCalledWith({
65
- sessionId: "00mh0wlipkdbeaw8tests0002",
100
+ sessionId: "00mh0wlipkdbeaw8tests0003",
66
101
  });
67
102
  expect(onClose).toHaveBeenCalled();
68
103
  });
@@ -70,10 +105,11 @@ describe("device pairing UI lifecycle", () => {
70
105
  it("does not clear the destination success reload when Done unmounts the flow", () => {
71
106
  jest.useFakeTimers();
72
107
  mockPairingState = {
73
- sessionId: "00mh0wlipkdbeaw8tests0003",
108
+ sessionId: "00mh0wlipkdbeaw8tests0005",
74
109
  role: "destination",
75
110
  phase: "complete",
76
111
  };
112
+ mockRpc.startDestination.mockResolvedValue(mockPairingState);
77
113
  const setTimeoutSpy = jest.spyOn(window, "setTimeout");
78
114
  const clearTimeoutSpy = jest.spyOn(window, "clearTimeout");
79
115
  const view = (0, react_1.render)((0, jsx_runtime_1.jsx)(device_pairing_1.DestinationDevicePairing, { onBack: jest.fn() }));
@@ -1,3 +1,2 @@
1
1
  export * from "./device-pairing";
2
- export * from "./device-pairing-invite";
3
2
  export * from "./use-device-pairing-state";
@@ -15,5 +15,4 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  __exportStar(require("./device-pairing"), exports);
18
- __exportStar(require("./device-pairing-invite"), exports);
19
18
  __exportStar(require("./use-device-pairing-state"), exports);
@@ -73,8 +73,6 @@ const ShareButton = ({ groupId }) => {
73
73
  try {
74
74
  // Generate the group share data
75
75
  const groupShare = await (0, peers_sdk_1.generateGroupShare)(groupId);
76
- // Check QR code compatibility
77
- const qrInfo = (0, peers_sdk_1.checkQRCodeCompatibility)(groupShare, 20);
78
76
  // Convert to JSON
79
77
  const groupShareJson = JSON.stringify(groupShare, null, 2);
80
78
  // Copy to clipboard
@@ -82,15 +80,13 @@ const ShareButton = ({ groupId }) => {
82
80
  if (copied) {
83
81
  setShareResult({
84
82
  success: true,
85
- message: `Group share data copied to clipboard! (${qrInfo.size} chars, ${qrInfo.memberCount} admin members)`,
86
- qrInfo,
83
+ message: `Group share data copied to clipboard! (${groupShareJson.length} chars, ${groupShare.groupMembers.length} admin members)`,
87
84
  });
88
85
  }
89
86
  else {
90
87
  setShareResult({
91
88
  success: false,
92
89
  message: "Failed to copy to clipboard. Please copy manually from the modal.",
93
- qrInfo,
94
90
  });
95
91
  }
96
92
  }
@@ -104,7 +100,7 @@ const ShareButton = ({ groupId }) => {
104
100
  setIsSharing(false);
105
101
  }
106
102
  };
107
- return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("button", { className: "btn btn-secondary btn-sm", onClick: handleShare, disabled: isSharing, title: "Share group with admin members", children: isSharing ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("span", { className: "spinner-border spinner-border-sm me-1", role: "status", "aria-hidden": "true" }), "Sharing..."] })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("i", { className: "bi-share me-1" }), "Share"] })) }), shareResult && ((0, jsx_runtime_1.jsx)("div", { className: "position-fixed top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center", style: { backgroundColor: "rgba(0,0,0,0.5)", zIndex: 1050 }, children: (0, jsx_runtime_1.jsxs)("div", { className: "card", style: { maxWidth: "500px", width: "90%" }, children: [(0, jsx_runtime_1.jsxs)("div", { className: "card-header d-flex justify-content-between align-items-center", children: [(0, jsx_runtime_1.jsxs)("h6", { className: "mb-0", children: [(0, jsx_runtime_1.jsx)("i", { className: `bi-${shareResult.success ? "check-circle text-success" : "exclamation-triangle text-warning"} me-2` }), "Group Share ", shareResult.success ? "Ready" : "Error"] }), (0, jsx_runtime_1.jsx)("button", { className: "btn-close", onClick: () => setShareResult(null), "aria-label": "Close" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "card-body", children: [(0, jsx_runtime_1.jsx)("p", { className: "mb-3", children: shareResult.message }), shareResult.qrInfo && ((0, jsx_runtime_1.jsxs)("div", { className: "mb-3", children: [(0, jsx_runtime_1.jsx)("h6", { children: "QR Code Compatibility" }), (0, jsx_runtime_1.jsxs)("div", { className: "row g-2", children: [(0, jsx_runtime_1.jsxs)("div", { className: "col-6", children: [(0, jsx_runtime_1.jsx)("small", { className: "text-muted", children: "Size:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsxs)("strong", { children: [shareResult.qrInfo.size, " chars"] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "col-6", children: [(0, jsx_runtime_1.jsx)("small", { className: "text-muted", children: "QR Version 20:" }), (0, jsx_runtime_1.jsx)("br", {}), (0, jsx_runtime_1.jsxs)("span", { className: `badge bg-${shareResult.qrInfo.fits ? "success" : "warning"}`, children: [shareResult.qrInfo.fits ? "✓ Fits" : "⚠ Too Large", " (", shareResult.qrInfo.usagePercent, "%)"] })] })] }), !shareResult.qrInfo.fits && ((0, jsx_runtime_1.jsx)("div", { className: "alert alert-warning mt-2 mb-0", children: (0, jsx_runtime_1.jsxs)("small", { children: [(0, jsx_runtime_1.jsx)("i", { className: "bi-exclamation-triangle me-1" }), "Data may be too large for standard QR codes. Consider reducing admin members or use text sharing."] }) }))] })), (0, jsx_runtime_1.jsxs)("div", { className: "text-end", children: [(0, jsx_runtime_1.jsx)("button", { className: "btn btn-secondary me-2", onClick: () => setShareResult(null), children: "Close" }), shareResult.success && ((0, jsx_runtime_1.jsx)("button", { className: "btn btn-primary", onClick: handleShare, children: "Copy Again" }))] })] })] }) }))] }));
103
+ return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("button", { className: "btn btn-secondary btn-sm", onClick: handleShare, disabled: isSharing, title: "Share group with admin members", children: isSharing ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("span", { className: "spinner-border spinner-border-sm me-1", role: "status", "aria-hidden": "true" }), "Sharing..."] })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("i", { className: "bi-share me-1" }), "Share"] })) }), shareResult && ((0, jsx_runtime_1.jsx)("div", { className: "position-fixed top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center", style: { backgroundColor: "rgba(0,0,0,0.5)", zIndex: 1050 }, children: (0, jsx_runtime_1.jsxs)("div", { className: "card", style: { maxWidth: "500px", width: "90%" }, children: [(0, jsx_runtime_1.jsxs)("div", { className: "card-header d-flex justify-content-between align-items-center", children: [(0, jsx_runtime_1.jsxs)("h6", { className: "mb-0", children: [(0, jsx_runtime_1.jsx)("i", { className: `bi-${shareResult.success ? "check-circle text-success" : "exclamation-triangle text-warning"} me-2` }), "Group Share ", shareResult.success ? "Ready" : "Error"] }), (0, jsx_runtime_1.jsx)("button", { className: "btn-close", onClick: () => setShareResult(null), "aria-label": "Close" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "card-body", children: [(0, jsx_runtime_1.jsx)("p", { className: "mb-3", children: shareResult.message }), (0, jsx_runtime_1.jsxs)("div", { className: "text-end", children: [(0, jsx_runtime_1.jsx)("button", { className: "btn btn-secondary me-2", onClick: () => setShareResult(null), children: "Close" }), shareResult.success && ((0, jsx_runtime_1.jsx)("button", { className: "btn btn-primary", onClick: handleShare, children: "Copy Again" }))] })] })] }) }))] }));
108
104
  };
109
105
  const GroupMembersWrapper = (props) => {
110
106
  const userContext = (0, hooks_1.usePromise)(() => (0, peers_sdk_1.getUserContext)());
@@ -0,0 +1,14 @@
1
+ import { GroupMemberRole } from "@peers-app/peers-sdk";
2
+ /** Props for {@link GroupInvitePanel}. */
3
+ export interface GroupInvitePanelProps {
4
+ groupId: string;
5
+ /** Users who are already members; hidden from the contact picker. */
6
+ excludeUserIds: string[];
7
+ /** The local user's role; caps the roles offered. */
8
+ myRole: GroupMemberRole;
9
+ }
10
+ /**
11
+ * Admin controls for growing a group: invite existing contacts first, share a link second,
12
+ * and act on pending join requests. Membership changes land when the other side accepts.
13
+ */
14
+ export declare function GroupInvitePanel(props: GroupInvitePanelProps): import("react/jsx-runtime").JSX.Element;