@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.
- 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
|
@@ -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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
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: "
|
|
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
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
118
|
+
onLeave();
|
|
135
119
|
};
|
|
136
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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 [
|
|
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
|
|
160
|
-
|
|
161
|
-
const start = async (
|
|
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.
|
|
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
|
|
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 (
|
|
203
|
+
if (started.current) {
|
|
185
204
|
return;
|
|
186
205
|
}
|
|
187
|
-
|
|
188
|
-
void
|
|
189
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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
|
|
229
|
-
return (
|
|
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 === "
|
|
232
|
-
|
|
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.
|
|
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("
|
|
31
|
+
it("starts the destination on mount and cancels when it resolves after unmount", async () => {
|
|
32
32
|
let resolveStart;
|
|
33
|
-
mockRpc.
|
|
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.
|
|
37
|
-
expect(mockRpc.
|
|
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
|
-
|
|
41
|
-
|
|
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("
|
|
50
|
-
|
|
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: "
|
|
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: "
|
|
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() }));
|
|
@@ -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! (${
|
|
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 }),
|
|
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;
|