@peers-app/peers-ui 0.20.6 → 0.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/root-layout/root-layout-host.js +3 -0
- package/dist/screens/device-pairing/device-pairing-invite.d.ts +10 -0
- package/dist/screens/device-pairing/device-pairing-invite.js +50 -0
- package/dist/screens/device-pairing/device-pairing-invite.test.d.ts +1 -0
- package/dist/screens/device-pairing/device-pairing-invite.test.js +30 -0
- package/dist/screens/device-pairing/device-pairing.d.ts +10 -0
- package/dist/screens/device-pairing/device-pairing.js +237 -0
- package/dist/screens/device-pairing/device-pairing.test.d.ts +1 -0
- package/dist/screens/device-pairing/device-pairing.test.js +88 -0
- package/dist/screens/device-pairing/index.d.ts +3 -0
- package/dist/screens/device-pairing/index.js +19 -0
- package/dist/screens/device-pairing/use-device-pairing-state.d.ts +3 -0
- package/dist/screens/device-pairing/use-device-pairing-state.js +31 -0
- package/dist/screens/packages/package-helpers.d.ts +21 -0
- package/dist/screens/packages/package-helpers.js +32 -0
- package/dist/screens/packages/package-versions.d.ts +1 -0
- package/dist/screens/packages/package-versions.js +48 -12
- package/dist/screens/settings/settings-page.js +6 -1
- package/dist/screens/setup-user.js +24 -1
- package/package.json +3 -3
- package/src/root-layout/root-layout-host.tsx +3 -0
- package/src/screens/device-pairing/device-pairing-invite.test.ts +50 -0
- package/src/screens/device-pairing/device-pairing-invite.ts +55 -0
- package/src/screens/device-pairing/device-pairing.test.tsx +100 -0
- package/src/screens/device-pairing/device-pairing.tsx +390 -0
- package/src/screens/device-pairing/index.ts +3 -0
- package/src/screens/device-pairing/use-device-pairing-state.ts +38 -0
- package/src/screens/packages/package-helpers.ts +39 -0
- package/src/screens/packages/package-versions.tsx +52 -13
- package/src/screens/settings/settings-page.tsx +22 -0
- package/src/screens/setup-user.tsx +63 -1
|
@@ -39,6 +39,7 @@ function tagBadgeClass(tag) {
|
|
|
39
39
|
return "text-bg-success";
|
|
40
40
|
return "text-bg-secondary";
|
|
41
41
|
}
|
|
42
|
+
/** Lists package versions with activate, promote, pin, edit-semver, and delete actions. */
|
|
42
43
|
const PackageVersionsList = (props) => {
|
|
43
44
|
const { pkg } = props;
|
|
44
45
|
const refreshKey = (0, hooks_1.useObservableState)(0);
|
|
@@ -115,24 +116,41 @@ const PackageVersionsList = (props) => {
|
|
|
115
116
|
signature: "",
|
|
116
117
|
});
|
|
117
118
|
const pvHash = (0, peers_sdk_1.computePackageVersionHash)(pv.version, newTag, pv.packageBundleFileHash, pv.routesBundleFileHash, pv.uiBundleFileHash);
|
|
119
|
+
// Auto-sign with package author key if available (server/PWA host — key never
|
|
120
|
+
// exposed to UI). Soft-fail when the key is missing so group admins can still
|
|
121
|
+
// promote, but never keep a signature that was computed for the old tag.
|
|
122
|
+
const authorSig = await peers_sdk_1.rpcServerCalls.signPackageAuthorVersion({
|
|
123
|
+
packageId: pv.packageId,
|
|
124
|
+
packageVersionId: pv.packageVersionId,
|
|
125
|
+
version: pv.version,
|
|
126
|
+
versionTag: newTag,
|
|
127
|
+
packageBundleFileHash: pv.packageBundleFileHash,
|
|
128
|
+
routesBundleFileHash: pv.routesBundleFileHash,
|
|
129
|
+
uiBundleFileHash: pv.uiBundleFileHash,
|
|
130
|
+
appNavs: pv.appNavs,
|
|
131
|
+
createdBy: pv.createdBy,
|
|
132
|
+
createdAt: pv.createdAt,
|
|
133
|
+
});
|
|
118
134
|
const updated = {
|
|
119
135
|
...pv,
|
|
120
136
|
versionTag: newTag,
|
|
121
137
|
packageVersionHash: pvHash,
|
|
122
138
|
history,
|
|
139
|
+
packageAuthorSignature: authorSig,
|
|
123
140
|
};
|
|
124
|
-
// Auto-sign with package author key if available (server-side, key never exposed)
|
|
125
|
-
const authorSig = await peers_sdk_1.rpcServerCalls.signPackageAuthorVersion({
|
|
126
|
-
packageId: updated.packageId,
|
|
127
|
-
packageVersionId: updated.packageVersionId,
|
|
128
|
-
version: updated.version,
|
|
129
|
-
versionTag: newTag,
|
|
130
|
-
packageBundleFileHash: updated.packageBundleFileHash,
|
|
131
|
-
routesBundleFileHash: updated.routesBundleFileHash,
|
|
132
|
-
uiBundleFileHash: updated.uiBundleFileHash,
|
|
133
|
-
});
|
|
134
|
-
updated.packageAuthorSignature = authorSig;
|
|
135
141
|
await (0, peers_sdk_1.PackageVersions)().signAndSave(updated, { saveAsSnapshot: true });
|
|
142
|
+
// Promoting the version this device is already running should advance the
|
|
143
|
+
// group active version when the new tag matches group follow policy
|
|
144
|
+
// (e.g. promote active → stable with default follow settings).
|
|
145
|
+
const pkgJs = pkg.toJS();
|
|
146
|
+
const activated = await (0, package_helpers_1.maybeActivateAfterPromote)(pkg.packageId, updated, {
|
|
147
|
+
wasEffectiveActive: pv.packageVersionId === effectiveActiveVersionId,
|
|
148
|
+
groupFollowTags: pkgJs.followVersionTags,
|
|
149
|
+
groupPinned: pkgJs.versionFollowRange === "pinned",
|
|
150
|
+
});
|
|
151
|
+
if (activated) {
|
|
152
|
+
await pkg.load();
|
|
153
|
+
}
|
|
136
154
|
refreshKey(refreshKey() + 1);
|
|
137
155
|
}
|
|
138
156
|
catch (err) {
|
|
@@ -142,7 +160,25 @@ const PackageVersionsList = (props) => {
|
|
|
142
160
|
async function updateVersion(pv, newSemver) {
|
|
143
161
|
try {
|
|
144
162
|
const pvHash = (0, peers_sdk_1.computePackageVersionHash)(newSemver, pv.versionTag || "dev", pv.packageBundleFileHash, pv.routesBundleFileHash, pv.uiBundleFileHash);
|
|
145
|
-
|
|
163
|
+
// Semver is part of the author-signed payload — re-sign or clear the old signature.
|
|
164
|
+
const authorSig = await peers_sdk_1.rpcServerCalls.signPackageAuthorVersion({
|
|
165
|
+
packageId: pv.packageId,
|
|
166
|
+
packageVersionId: pv.packageVersionId,
|
|
167
|
+
version: newSemver,
|
|
168
|
+
versionTag: pv.versionTag,
|
|
169
|
+
packageBundleFileHash: pv.packageBundleFileHash,
|
|
170
|
+
routesBundleFileHash: pv.routesBundleFileHash,
|
|
171
|
+
uiBundleFileHash: pv.uiBundleFileHash,
|
|
172
|
+
appNavs: pv.appNavs,
|
|
173
|
+
createdBy: pv.createdBy,
|
|
174
|
+
createdAt: pv.createdAt,
|
|
175
|
+
});
|
|
176
|
+
const updated = {
|
|
177
|
+
...pv,
|
|
178
|
+
version: newSemver,
|
|
179
|
+
packageVersionHash: pvHash,
|
|
180
|
+
packageAuthorSignature: authorSig,
|
|
181
|
+
};
|
|
146
182
|
await (0, peers_sdk_1.PackageVersions)().signAndSave(updated, { saveAsSnapshot: true });
|
|
147
183
|
refreshKey(refreshKey() + 1);
|
|
148
184
|
}
|
|
@@ -43,6 +43,7 @@ const tooltip_1 = require("../../components/tooltip");
|
|
|
43
43
|
const globals = __importStar(require("../../globals"));
|
|
44
44
|
const hooks_1 = require("../../hooks");
|
|
45
45
|
const layout_picker_1 = require("../../root-layout/layout-picker");
|
|
46
|
+
const device_pairing_1 = require("../device-pairing");
|
|
46
47
|
const color_mode_dropdown_1 = require("./color-mode-dropdown");
|
|
47
48
|
const voice_settings_1 = require("./voice-settings");
|
|
48
49
|
function getAppVersion() {
|
|
@@ -72,7 +73,7 @@ const SettingsPage = () => {
|
|
|
72
73
|
};
|
|
73
74
|
exports.SettingsPage = SettingsPage;
|
|
74
75
|
const UserSettingsTab = () => {
|
|
75
|
-
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(ProfileSection, {}), (0, jsx_runtime_1.jsx)(LogoutSection, {})] }));
|
|
76
|
+
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(ProfileSection, {}), (0, jsx_runtime_1.jsx)(AddDeviceSection, {}), (0, jsx_runtime_1.jsx)(LogoutSection, {})] }));
|
|
76
77
|
};
|
|
77
78
|
const AppearanceSettingsTab = () => {
|
|
78
79
|
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("h5", { className: "mb-3", children: "Appearance" }), (0, jsx_runtime_1.jsx)("div", { className: "mb-3", children: (0, jsx_runtime_1.jsx)(color_mode_dropdown_1.ColorModeDropdown, {}) }), (0, jsx_runtime_1.jsx)("div", { className: "mb-3", children: (0, jsx_runtime_1.jsx)(layout_picker_1.LayoutPicker, {}) })] }));
|
|
@@ -148,6 +149,10 @@ const ProfileSection = () => {
|
|
|
148
149
|
const hasChanges = !!me.q() || deviceName() !== savedDeviceName();
|
|
149
150
|
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-center mb-3", children: [(0, jsx_runtime_1.jsx)("h5", { className: "mb-0 me-auto", children: "Settings" }), (0, jsx_runtime_1.jsx)("button", { className: "btn btn-primary btn-sm", onClick: handleSave, disabled: !hasChanges, children: "Save" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "mb-3", children: [(0, jsx_runtime_1.jsx)("small", { className: "form-label", children: "Display Name:" }), (0, jsx_runtime_1.jsx)(input_1.Input, { value: me.qs.name, className: "form-control", maxLength: peers_sdk_1.DISPLAY_NAME_MAX_LENGTH, placeholder: "Enter your name" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "mb-3", children: [(0, jsx_runtime_1.jsxs)("small", { className: "form-label", children: ["Device Name:", (0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { markdownContent: "Give this device a friendly name to identify it in your network." })] }), (0, jsx_runtime_1.jsx)(input_1.Input, { value: deviceName, className: "form-control", maxLength: peers_sdk_1.DISPLAY_NAME_MAX_LENGTH, placeholder: "e.g., My Laptop" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "mb-3", children: [(0, jsx_runtime_1.jsxs)("small", { className: "form-label", children: ["User ID:", (0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { markdownContent: "This uniquely identifies you to all other users." })] }), (0, jsx_runtime_1.jsx)(input_1.Input, { value: me.userId, className: "form-control", disabled: true })] }), (0, jsx_runtime_1.jsxs)("div", { className: "row", children: [(0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsxs)("small", { className: "form-label", children: ["Public Key:", (0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { markdownContent: "This is your public key that other users will use to verify your signatures." })] }), (0, jsx_runtime_1.jsx)(input_1.Input, { value: me.publicKey, className: "form-control form-control-sm", disabled: true })] }), (0, jsx_runtime_1.jsxs)("div", { className: "col-md-6 mb-3", children: [(0, jsx_runtime_1.jsxs)("small", { className: "form-label", children: ["Public Box Key:", (0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { markdownContent: "This is the public key that other users can use to encrypt data so that only you can open it." })] }), (0, jsx_runtime_1.jsx)(input_1.Input, { value: me.publicBoxKey, className: "form-control form-control-sm", disabled: true })] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "mb-3", children: [(0, jsx_runtime_1.jsxs)("small", { className: "form-label", children: ["Device ID:", (0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { markdownContent: "This uniquely identifies this device on Peers networks." })] }), (0, jsx_runtime_1.jsx)(input_1.Input, { value: deviceId || "", className: "form-control form-control-sm", disabled: true })] }), (0, jsx_runtime_1.jsxs)("div", { className: "mb-3", children: [(0, jsx_runtime_1.jsx)("small", { className: "form-label", children: "App Version:" }), (0, jsx_runtime_1.jsx)(input_1.Input, { value: getAppVersion(), className: "form-control form-control-sm", disabled: true })] })] }));
|
|
150
151
|
};
|
|
152
|
+
const AddDeviceSection = () => {
|
|
153
|
+
const [open, setOpen] = (0, react_1.useState)(false);
|
|
154
|
+
return ((0, jsx_runtime_1.jsxs)("section", { className: "mt-4 pt-3 border-top", children: [(0, jsx_runtime_1.jsx)("h6", { children: "Add another device" }), (0, jsx_runtime_1.jsx)("p", { className: "small text-muted", children: "Create a five-minute invitation, compare the same number on both devices, and approve on each one before account access is transferred." }), open ? ((0, jsx_runtime_1.jsx)(device_pairing_1.SourceDevicePairing, { onClose: () => setOpen(false) })) : ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-primary", onClick: () => setOpen(true), children: "Add another device" }))] }));
|
|
155
|
+
};
|
|
151
156
|
const PackagesRootDirectory = () => {
|
|
152
157
|
return ((0, jsx_runtime_1.jsxs)("div", { className: "mt-3", children: [(0, jsx_runtime_1.jsxs)("small", { children: ["Packages Directory:", (0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { markdownContent: `Changing this will not effect existing packages.` })] }), (0, jsx_runtime_1.jsx)(input_1.Input, { value: peers_sdk_1.packagesRootDir, className: "form-control" })] }));
|
|
153
158
|
};
|
|
@@ -5,10 +5,13 @@ const jsx_runtime_1 = require("react/jsx-runtime");
|
|
|
5
5
|
const peers_sdk_1 = require("@peers-app/peers-sdk");
|
|
6
6
|
const react_1 = require("react");
|
|
7
7
|
const hooks_1 = require("../hooks");
|
|
8
|
+
const device_pairing_1 = require("./device-pairing");
|
|
8
9
|
const color_mode_dropdown_1 = require("./settings/color-mode-dropdown");
|
|
9
10
|
const SetupUser = () => {
|
|
10
11
|
const [_colorMode] = (0, hooks_1.useObservable)(color_mode_dropdown_1.colorMode);
|
|
11
12
|
const [step, _setStep] = (0, react_1.useState)("select-user-type");
|
|
13
|
+
const [pairingInvitation, setPairingInvitation] = (0, react_1.useState)(() => (0, device_pairing_1.consumePendingDevicePairingInvite)());
|
|
14
|
+
const [showPairing, setShowPairing] = (0, react_1.useState)(() => Boolean(pairingInvitation));
|
|
12
15
|
const [isExistingUser, setIsExistingUser] = (0, react_1.useState)(null);
|
|
13
16
|
const [userId, setUserId] = (0, react_1.useState)("");
|
|
14
17
|
const [secretKey, setSecretKey] = (0, react_1.useState)("");
|
|
@@ -16,6 +19,16 @@ const SetupUser = () => {
|
|
|
16
19
|
const [isValidating, setIsValidating] = (0, react_1.useState)(false);
|
|
17
20
|
document.getElementById("appLoadingDiv")?.remove();
|
|
18
21
|
const isDark = _colorMode === "dark";
|
|
22
|
+
(0, react_1.useEffect)(() => {
|
|
23
|
+
const capture = (event) => {
|
|
24
|
+
const invitation = event.detail;
|
|
25
|
+
setPairingInvitation(invitation);
|
|
26
|
+
(0, device_pairing_1.consumePendingDevicePairingInvite)();
|
|
27
|
+
setShowPairing(true);
|
|
28
|
+
};
|
|
29
|
+
window.addEventListener(device_pairing_1.DEVICE_PAIRING_INVITE_CAPTURED_EVENT, capture);
|
|
30
|
+
return () => window.removeEventListener(device_pairing_1.DEVICE_PAIRING_INVITE_CAPTURED_EVENT, capture);
|
|
31
|
+
}, []);
|
|
19
32
|
const handleNewUser = async () => {
|
|
20
33
|
setError("");
|
|
21
34
|
setIsValidating(true);
|
|
@@ -65,13 +78,23 @@ const SetupUser = () => {
|
|
|
65
78
|
setIsValidating(false);
|
|
66
79
|
}
|
|
67
80
|
};
|
|
81
|
+
if (showPairing) {
|
|
82
|
+
return ((0, jsx_runtime_1.jsx)("div", { className: "container-fluid d-flex align-items-start justify-content-center", style: { paddingTop: "20px", backgroundColor: isDark ? "#212529" : "#f8f9fa" }, children: (0, jsx_runtime_1.jsx)("div", { className: "card shadow", style: {
|
|
83
|
+
maxWidth: "560px",
|
|
84
|
+
width: "100%",
|
|
85
|
+
backgroundColor: isDark ? "#2d3238" : "#ffffff",
|
|
86
|
+
}, children: (0, jsx_runtime_1.jsx)("div", { className: "card-body p-4 p-md-5", children: (0, jsx_runtime_1.jsx)(device_pairing_1.DestinationDevicePairing, { initialInvitation: pairingInvitation, onInvitationConsumed: () => setPairingInvitation(undefined), onBack: () => {
|
|
87
|
+
setPairingInvitation(undefined);
|
|
88
|
+
setShowPairing(false);
|
|
89
|
+
} }) }) }) }));
|
|
90
|
+
}
|
|
68
91
|
// Step 1: Select User Type
|
|
69
92
|
if (step === "select-user-type" && isExistingUser === null) {
|
|
70
93
|
return ((0, jsx_runtime_1.jsx)("div", { className: "container-fluid d-flex align-items-start justify-content-center", style: { paddingTop: "20px", backgroundColor: isDark ? "#212529" : "#f8f9fa" }, children: (0, jsx_runtime_1.jsx)("div", { className: "card shadow", style: {
|
|
71
94
|
maxWidth: "500px",
|
|
72
95
|
width: "100%",
|
|
73
96
|
backgroundColor: isDark ? "#2d3238" : "#ffffff",
|
|
74
|
-
}, children: (0, jsx_runtime_1.jsxs)("div", { className: "card-body p-4 p-md-5", children: [(0, jsx_runtime_1.jsxs)("div", { className: "text-center mb-4", children: [(0, jsx_runtime_1.jsx)("h2", { className: `fw-bold mb-2 ${isDark ? "text-light" : ""}`, children: "Welcome to Peers" }), (0, jsx_runtime_1.jsx)("p", { className: isDark ? "text-light opacity-75" : "text-muted", children: "Let's get you set up" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "d-grid gap-3", children: [(0, jsx_runtime_1.jsx)("button", { className: "btn btn-lg btn-primary py-3", onClick: () => setIsExistingUser(false), children: (0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-center justify-content-center", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-person-plus fs-4 me-2" }), (0, jsx_runtime_1.jsxs)("div", { className: "text-start", children: [(0, jsx_runtime_1.jsx)("div", { className: "fw-semibold", children: "I'm a New User" }), (0, jsx_runtime_1.jsx)("small", { className: "opacity-75", children: "Create a new account" })] })] }) }), (0, jsx_runtime_1.jsx)("button", { className: `btn btn-lg py-3 ${isDark ? "btn-outline-light" : "btn-outline-primary"}`, onClick: () => setIsExistingUser(true), children: (0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-center justify-content-center", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-box-arrow-in-right fs-4 me-2" }), (0, jsx_runtime_1.jsxs)("div", { className: "text-start", children: [(0, jsx_runtime_1.jsx)("div", { className: "fw-semibold", children: "I'm an Existing User" }), (0, jsx_runtime_1.jsx)("small", { className: "opacity-75", children: "Sign in with your credentials" })] })] }) })] })] }) }) }));
|
|
97
|
+
}, children: (0, jsx_runtime_1.jsxs)("div", { className: "card-body p-4 p-md-5", children: [(0, jsx_runtime_1.jsxs)("div", { className: "text-center mb-4", children: [(0, jsx_runtime_1.jsx)("h2", { className: `fw-bold mb-2 ${isDark ? "text-light" : ""}`, children: "Welcome to Peers" }), (0, jsx_runtime_1.jsx)("p", { className: isDark ? "text-light opacity-75" : "text-muted", children: "Let's get you set up" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "d-grid gap-3", children: [(0, jsx_runtime_1.jsx)("button", { className: "btn btn-lg btn-primary py-3", onClick: () => setIsExistingUser(false), children: (0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-center justify-content-center", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-person-plus fs-4 me-2" }), (0, jsx_runtime_1.jsxs)("div", { className: "text-start", children: [(0, jsx_runtime_1.jsx)("div", { className: "fw-semibold", children: "I'm a New User" }), (0, jsx_runtime_1.jsx)("small", { className: "opacity-75", children: "Create a new account" })] })] }) }), (0, jsx_runtime_1.jsx)("button", { className: `btn btn-lg py-3 ${isDark ? "btn-outline-light" : "btn-outline-primary"}`, onClick: () => setIsExistingUser(true), children: (0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-center justify-content-center", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-box-arrow-in-right fs-4 me-2" }), (0, jsx_runtime_1.jsxs)("div", { className: "text-start", children: [(0, jsx_runtime_1.jsx)("div", { className: "fw-semibold", children: "I'm an Existing User" }), (0, jsx_runtime_1.jsx)("small", { className: "opacity-75", children: "Sign in with your credentials" })] })] }) }), (0, jsx_runtime_1.jsx)("button", { className: `btn btn-lg py-3 ${isDark ? "btn-outline-light" : "btn-outline-primary"}`, onClick: () => setShowPairing(true), children: (0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-center justify-content-center", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-phone fs-4 me-2" }), (0, jsx_runtime_1.jsxs)("div", { className: "text-start", children: [(0, jsx_runtime_1.jsx)("div", { className: "fw-semibold", children: "Use Another Device" }), (0, jsx_runtime_1.jsx)("small", { className: "opacity-75", children: "Sign in with a device already on Peers" })] })] }) })] })] }) }) }));
|
|
75
98
|
}
|
|
76
99
|
// Step 2: New User Confirmation
|
|
77
100
|
if (isExistingUser === false) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@peers-app/peers-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.1",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/peers-app/peers-ui.git"
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"lint:fix": "biome check --write ."
|
|
29
29
|
},
|
|
30
30
|
"peerDependencies": {
|
|
31
|
-
"@peers-app/peers-sdk": "^0.
|
|
31
|
+
"@peers-app/peers-sdk": "^0.21.1",
|
|
32
32
|
"bootstrap": "^5.3.3",
|
|
33
33
|
"react": "^18.0.0",
|
|
34
34
|
"react-dom": "^18.0.0"
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"@babel/preset-react": "^7.24.1",
|
|
40
40
|
"@babel/preset-typescript": "^7.27.1",
|
|
41
41
|
"@biomejs/biome": "2.3.11",
|
|
42
|
-
"@peers-app/peers-sdk": "0.
|
|
42
|
+
"@peers-app/peers-sdk": "0.21.1",
|
|
43
43
|
"@testing-library/dom": "^10.4.0",
|
|
44
44
|
"@testing-library/jest-dom": "^6.6.3",
|
|
45
45
|
"@testing-library/react": "^16.3.0",
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
captureUserConnectInviteFromLocation,
|
|
26
26
|
readUserConnectPendingIntent,
|
|
27
27
|
} from "../screens/contacts/user-connect-invite";
|
|
28
|
+
import { captureDevicePairingInviteFromLocation } from "../screens/device-pairing";
|
|
28
29
|
import { colorMode } from "../screens/settings/color-mode-dropdown";
|
|
29
30
|
import { SetupUser } from "../screens/setup-user";
|
|
30
31
|
import { WelcomeModal } from "../screens/welcome-modal";
|
|
@@ -41,6 +42,7 @@ import { selectedRootLayoutId } from "./root-layout-state";
|
|
|
41
42
|
*/
|
|
42
43
|
export function RootLayoutHost() {
|
|
43
44
|
useState(() => {
|
|
45
|
+
captureDevicePairingInviteFromLocation();
|
|
44
46
|
captureUserConnectInviteFromLocation();
|
|
45
47
|
return true;
|
|
46
48
|
});
|
|
@@ -55,6 +57,7 @@ export function RootLayoutHost() {
|
|
|
55
57
|
useEffect(() => {
|
|
56
58
|
const captureInviteFromHashChange = () => {
|
|
57
59
|
const previousHash = window.location.hash;
|
|
60
|
+
captureDevicePairingInviteFromLocation();
|
|
58
61
|
captureUserConnectInviteFromLocation();
|
|
59
62
|
if (window.location.hash === previousHash) {
|
|
60
63
|
return;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createDevicePairingInvitation,
|
|
3
|
+
createDevicePairingInvitationUrl,
|
|
4
|
+
parseDevicePairingInvitation,
|
|
5
|
+
} from "@peers-app/peers-sdk";
|
|
6
|
+
import {
|
|
7
|
+
captureDevicePairingInviteFromLocation,
|
|
8
|
+
clearPendingDevicePairingInvite,
|
|
9
|
+
consumePendingDevicePairingInvite,
|
|
10
|
+
readPendingDevicePairingInvite,
|
|
11
|
+
} from "./device-pairing-invite";
|
|
12
|
+
|
|
13
|
+
describe("device pairing invitation capture", () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
clearPendingDevicePairingInvite();
|
|
16
|
+
window.history.replaceState({}, "", "/?app");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("captures the fragment only in memory and removes it from browser history", () => {
|
|
20
|
+
const invitation = createDevicePairingInvitation({
|
|
21
|
+
serviceOrigin: "https://peers.app",
|
|
22
|
+
});
|
|
23
|
+
const inviteUrl = createDevicePairingInvitationUrl(
|
|
24
|
+
`${window.location.origin}/?app`,
|
|
25
|
+
invitation,
|
|
26
|
+
);
|
|
27
|
+
window.history.replaceState(
|
|
28
|
+
{},
|
|
29
|
+
"",
|
|
30
|
+
new URL(inviteUrl).pathname + new URL(inviteUrl).search + new URL(inviteUrl).hash,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const token = captureDevicePairingInviteFromLocation();
|
|
34
|
+
|
|
35
|
+
expect(token).toBeDefined();
|
|
36
|
+
expect(parseDevicePairingInvitation(token ?? "")).toEqual(invitation);
|
|
37
|
+
expect(window.location.hash).toBe("");
|
|
38
|
+
expect(readPendingDevicePairingInvite()).toBe(token);
|
|
39
|
+
expect(consumePendingDevicePairingInvite()).toBe(token);
|
|
40
|
+
expect(readPendingDevicePairingInvite()).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("ignores invalid fragments without retaining them", () => {
|
|
44
|
+
window.history.replaceState({}, "", "/?app#pair=invalid");
|
|
45
|
+
|
|
46
|
+
expect(captureDevicePairingInviteFromLocation()).toBeUndefined();
|
|
47
|
+
expect(readPendingDevicePairingInvite()).toBeUndefined();
|
|
48
|
+
expect(window.location.hash).toBe("");
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { encodeDevicePairingInvitation, parseDevicePairingInvitation } from "@peers-app/peers-sdk";
|
|
2
|
+
|
|
3
|
+
let pendingInvitation: string | undefined;
|
|
4
|
+
|
|
5
|
+
/** Browser event emitted after a pairing invitation is captured and removed from the URL. */
|
|
6
|
+
export const DEVICE_PAIRING_INVITE_CAPTURED_EVENT = "device-pairing:invite-captured";
|
|
7
|
+
|
|
8
|
+
/** Captures a pairing fragment in memory and immediately removes it from browser history. */
|
|
9
|
+
export function captureDevicePairingInviteFromLocation(
|
|
10
|
+
location: Location = window.location,
|
|
11
|
+
): string | undefined {
|
|
12
|
+
if (!location.hash.startsWith("#pair=") && !location.hash.startsWith("#pair?")) {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
let capturedInvitation: string | undefined;
|
|
16
|
+
try {
|
|
17
|
+
capturedInvitation = encodeDevicePairingInvitation(parseDevicePairingInvitation(location.href));
|
|
18
|
+
pendingInvitation = capturedInvitation;
|
|
19
|
+
return capturedInvitation;
|
|
20
|
+
} catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
} finally {
|
|
23
|
+
if (typeof window !== "undefined" && location === window.location) {
|
|
24
|
+
window.history.replaceState(
|
|
25
|
+
window.history.state,
|
|
26
|
+
document.title,
|
|
27
|
+
`${location.pathname}${location.search}`,
|
|
28
|
+
);
|
|
29
|
+
if (capturedInvitation) {
|
|
30
|
+
window.dispatchEvent(
|
|
31
|
+
new CustomEvent(DEVICE_PAIRING_INVITE_CAPTURED_EVENT, {
|
|
32
|
+
detail: capturedInvitation,
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Returns the in-memory invitation without consuming it. */
|
|
41
|
+
export function readPendingDevicePairingInvite(): string | undefined {
|
|
42
|
+
return pendingInvitation;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Returns and erases the in-memory invitation. */
|
|
46
|
+
export function consumePendingDevicePairingInvite(): string | undefined {
|
|
47
|
+
const invitation = pendingInvitation;
|
|
48
|
+
pendingInvitation = undefined;
|
|
49
|
+
return invitation;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Erases any unconsumed invitation. */
|
|
53
|
+
export function clearPendingDevicePairingInvite(): void {
|
|
54
|
+
pendingInvitation = undefined;
|
|
55
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { type DevicePairingState, rpcServerCalls } from "@peers-app/peers-sdk";
|
|
2
|
+
import { fireEvent, render, waitFor } from "@testing-library/react";
|
|
3
|
+
import { DestinationDevicePairing, SourceDevicePairing } from "./device-pairing";
|
|
4
|
+
|
|
5
|
+
const mockRpc = {
|
|
6
|
+
startSource: jest.fn(),
|
|
7
|
+
startDestination: jest.fn(),
|
|
8
|
+
approve: jest.fn(),
|
|
9
|
+
reject: jest.fn(),
|
|
10
|
+
cancel: jest.fn(),
|
|
11
|
+
};
|
|
12
|
+
let mockPairingState: DevicePairingState = { phase: "idle" };
|
|
13
|
+
|
|
14
|
+
rpcServerCalls.devicePairingStartSource =
|
|
15
|
+
mockRpc.startSource as typeof rpcServerCalls.devicePairingStartSource;
|
|
16
|
+
rpcServerCalls.devicePairingStartDestination =
|
|
17
|
+
mockRpc.startDestination as typeof rpcServerCalls.devicePairingStartDestination;
|
|
18
|
+
rpcServerCalls.devicePairingApprove = mockRpc.approve as typeof rpcServerCalls.devicePairingApprove;
|
|
19
|
+
rpcServerCalls.devicePairingReject = mockRpc.reject as typeof rpcServerCalls.devicePairingReject;
|
|
20
|
+
rpcServerCalls.devicePairingCancel = mockRpc.cancel as typeof rpcServerCalls.devicePairingCancel;
|
|
21
|
+
|
|
22
|
+
jest.mock("./use-device-pairing-state", () => ({
|
|
23
|
+
useDevicePairingState: () => mockPairingState,
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
describe("device pairing UI lifecycle", () => {
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
jest.clearAllMocks();
|
|
29
|
+
mockPairingState = { phase: "idle" };
|
|
30
|
+
mockRpc.cancel.mockResolvedValue({ phase: "idle" });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("uses the host-owned invitation URL and cancels a source that resolves after unmount", async () => {
|
|
34
|
+
let resolveStart!: (value: { state: DevicePairingState; invitationUrl: string }) => void;
|
|
35
|
+
mockRpc.startSource.mockReturnValue(
|
|
36
|
+
new Promise((resolve) => {
|
|
37
|
+
resolveStart = resolve;
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
const view = render(<SourceDevicePairing onClose={jest.fn()} />);
|
|
41
|
+
|
|
42
|
+
expect(mockRpc.startSource).toHaveBeenCalledWith();
|
|
43
|
+
view.unmount();
|
|
44
|
+
resolveStart({
|
|
45
|
+
state: { sessionId: "00mh0wlipkdbeaw8tests0001", role: "source", phase: "inviting" },
|
|
46
|
+
invitationUrl: "https://peers.app/?app#pair=token",
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
await waitFor(() => {
|
|
50
|
+
expect(mockRpc.cancel).toHaveBeenCalledWith({
|
|
51
|
+
sessionId: "00mh0wlipkdbeaw8tests0001",
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("keeps source cancellation available while waiting for remote approval", async () => {
|
|
57
|
+
const onClose = jest.fn();
|
|
58
|
+
mockPairingState = {
|
|
59
|
+
sessionId: "00mh0wlipkdbeaw8tests0002",
|
|
60
|
+
role: "source",
|
|
61
|
+
phase: "waiting-for-approval",
|
|
62
|
+
};
|
|
63
|
+
mockRpc.startSource.mockResolvedValue({
|
|
64
|
+
state: mockPairingState,
|
|
65
|
+
invitationUrl: "https://peers.app/?app#pair=token",
|
|
66
|
+
});
|
|
67
|
+
const view = render(<SourceDevicePairing onClose={onClose} />);
|
|
68
|
+
await waitFor(() => expect(mockRpc.startSource).toHaveBeenCalled());
|
|
69
|
+
|
|
70
|
+
fireEvent.click(view.getByRole("button", { name: "Cancel" }));
|
|
71
|
+
|
|
72
|
+
await waitFor(() => {
|
|
73
|
+
expect(mockRpc.cancel).toHaveBeenCalledWith({
|
|
74
|
+
sessionId: "00mh0wlipkdbeaw8tests0002",
|
|
75
|
+
});
|
|
76
|
+
expect(onClose).toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("does not clear the destination success reload when Done unmounts the flow", () => {
|
|
81
|
+
jest.useFakeTimers();
|
|
82
|
+
mockPairingState = {
|
|
83
|
+
sessionId: "00mh0wlipkdbeaw8tests0003",
|
|
84
|
+
role: "destination",
|
|
85
|
+
phase: "complete",
|
|
86
|
+
};
|
|
87
|
+
const setTimeoutSpy = jest.spyOn(window, "setTimeout");
|
|
88
|
+
const clearTimeoutSpy = jest.spyOn(window, "clearTimeout");
|
|
89
|
+
const view = render(<DestinationDevicePairing onBack={jest.fn()} />);
|
|
90
|
+
const reloadTimer = setTimeoutSpy.mock.calls.find(([, delay]) => delay === 750);
|
|
91
|
+
|
|
92
|
+
expect(reloadTimer).toBeDefined();
|
|
93
|
+
view.unmount();
|
|
94
|
+
expect(clearTimeoutSpy).not.toHaveBeenCalled();
|
|
95
|
+
|
|
96
|
+
setTimeoutSpy.mockRestore();
|
|
97
|
+
clearTimeoutSpy.mockRestore();
|
|
98
|
+
jest.useRealTimers();
|
|
99
|
+
});
|
|
100
|
+
});
|