@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.
Files changed (31) hide show
  1. package/dist/root-layout/root-layout-host.js +3 -0
  2. package/dist/screens/device-pairing/device-pairing-invite.d.ts +10 -0
  3. package/dist/screens/device-pairing/device-pairing-invite.js +50 -0
  4. package/dist/screens/device-pairing/device-pairing-invite.test.d.ts +1 -0
  5. package/dist/screens/device-pairing/device-pairing-invite.test.js +30 -0
  6. package/dist/screens/device-pairing/device-pairing.d.ts +10 -0
  7. package/dist/screens/device-pairing/device-pairing.js +237 -0
  8. package/dist/screens/device-pairing/device-pairing.test.d.ts +1 -0
  9. package/dist/screens/device-pairing/device-pairing.test.js +88 -0
  10. package/dist/screens/device-pairing/index.d.ts +3 -0
  11. package/dist/screens/device-pairing/index.js +19 -0
  12. package/dist/screens/device-pairing/use-device-pairing-state.d.ts +3 -0
  13. package/dist/screens/device-pairing/use-device-pairing-state.js +31 -0
  14. package/dist/screens/packages/package-helpers.d.ts +21 -0
  15. package/dist/screens/packages/package-helpers.js +32 -0
  16. package/dist/screens/packages/package-versions.d.ts +1 -0
  17. package/dist/screens/packages/package-versions.js +48 -12
  18. package/dist/screens/settings/settings-page.js +6 -1
  19. package/dist/screens/setup-user.js +24 -1
  20. package/package.json +3 -3
  21. package/src/root-layout/root-layout-host.tsx +3 -0
  22. package/src/screens/device-pairing/device-pairing-invite.test.ts +50 -0
  23. package/src/screens/device-pairing/device-pairing-invite.ts +55 -0
  24. package/src/screens/device-pairing/device-pairing.test.tsx +100 -0
  25. package/src/screens/device-pairing/device-pairing.tsx +390 -0
  26. package/src/screens/device-pairing/index.ts +3 -0
  27. package/src/screens/device-pairing/use-device-pairing-state.ts +38 -0
  28. package/src/screens/packages/package-helpers.ts +39 -0
  29. package/src/screens/packages/package-versions.tsx +52 -13
  30. package/src/screens/settings/settings-page.tsx +22 -0
  31. package/src/screens/setup-user.tsx +63 -1
@@ -0,0 +1,390 @@
1
+ import { copyToClipboard, type DevicePairingState, rpcServerCalls } from "@peers-app/peers-sdk";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import QRCode from "react-qr-code";
4
+ import { useDevicePairingState } from "./use-device-pairing-state";
5
+
6
+ function formatCountdown(expiresAt?: number): string {
7
+ if (!expiresAt) {
8
+ return "";
9
+ }
10
+ const seconds = Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000));
11
+ return `${Math.floor(seconds / 60)}:${(seconds % 60).toString().padStart(2, "0")}`;
12
+ }
13
+
14
+ function PairingProgress({ state }: { state: DevicePairingState }) {
15
+ const messages: Partial<Record<DevicePairingState["phase"], string>> = {
16
+ inviting: "Waiting for the new device…",
17
+ connecting: "Establishing a secure connection…",
18
+ "waiting-for-approval": "Waiting for approval on the other device…",
19
+ transferring: "Installing encrypted account credentials…",
20
+ };
21
+ return (
22
+ <output className="text-center py-4 d-block">
23
+ <div className="spinner-border text-primary mb-3" aria-hidden="true" />
24
+ <p className="text-muted mb-0">{messages[state.phase] ?? "Working…"}</p>
25
+ </output>
26
+ );
27
+ }
28
+
29
+ function PairingComparison(props: {
30
+ state: DevicePairingState;
31
+ approving: boolean;
32
+ onApprove: () => void;
33
+ onReject: () => void;
34
+ onCancel: () => void;
35
+ }) {
36
+ return (
37
+ <div className="text-center" style={{ maxWidth: 440, margin: "0 auto" }}>
38
+ <h4>Compare these numbers</h4>
39
+ <p className="text-muted">
40
+ Confirm that both devices show the same number and the expected device before approving.
41
+ </p>
42
+ <div
43
+ className="display-4 font-monospace text-primary mb-3"
44
+ style={{ letterSpacing: "0.12em" }}
45
+ >
46
+ {props.state.sas}
47
+ </div>
48
+ <p className="mb-4">
49
+ {props.state.remoteDevice?.deviceName || "Unnamed device"}
50
+ {props.state.remoteDevice?.deviceType ? ` (${props.state.remoteDevice.deviceType})` : ""}
51
+ </p>
52
+ <div className="d-flex gap-2 justify-content-center">
53
+ <button
54
+ type="button"
55
+ className="btn btn-primary"
56
+ disabled={props.approving}
57
+ onClick={props.onApprove}
58
+ >
59
+ {props.approving ? "Approving…" : "Numbers match — approve"}
60
+ </button>
61
+ <button
62
+ type="button"
63
+ className="btn btn-outline-danger"
64
+ disabled={props.approving}
65
+ onClick={props.onReject}
66
+ >
67
+ Reject
68
+ </button>
69
+ <button
70
+ type="button"
71
+ className="btn btn-outline-secondary"
72
+ disabled={props.approving}
73
+ onClick={props.onCancel}
74
+ >
75
+ Cancel
76
+ </button>
77
+ </div>
78
+ </div>
79
+ );
80
+ }
81
+
82
+ function TerminalPanel(props: { state: DevicePairingState; onClose: () => void }) {
83
+ const complete = props.state.phase === "complete";
84
+ return (
85
+ <div className="text-center" style={{ maxWidth: 440, margin: "0 auto" }}>
86
+ <output className={`alert d-block ${complete ? "alert-success" : "alert-warning"}`}>
87
+ {complete
88
+ ? "Device added successfully."
89
+ : props.state.phase === "rejected"
90
+ ? "Pairing was rejected. No credentials were installed."
91
+ : props.state.error || "Pairing could not be completed. No credentials were installed."}
92
+ </output>
93
+ <button type="button" className="btn btn-outline-secondary" onClick={props.onClose}>
94
+ {complete ? "Done" : "Back"}
95
+ </button>
96
+ </div>
97
+ );
98
+ }
99
+
100
+ function ProgressWithCancel(props: { state: DevicePairingState; onCancel: () => void }) {
101
+ return (
102
+ <>
103
+ <PairingProgress state={props.state} />
104
+ <div className="text-center">
105
+ <button type="button" className="btn btn-outline-secondary" onClick={props.onCancel}>
106
+ Cancel
107
+ </button>
108
+ </div>
109
+ </>
110
+ );
111
+ }
112
+
113
+ function usePairingSessionLifecycle(state: DevicePairingState) {
114
+ const sessionId = useRef<string | undefined>(undefined);
115
+ const phase = useRef<DevicePairingState["phase"]>("idle");
116
+ const mounted = useRef(true);
117
+
118
+ useEffect(() => {
119
+ if (state.sessionId && state.sessionId === sessionId.current) {
120
+ phase.current = state.phase;
121
+ }
122
+ }, [state.phase, state.sessionId]);
123
+
124
+ useEffect(() => {
125
+ mounted.current = true;
126
+ return () => {
127
+ mounted.current = false;
128
+ const activeSessionId = sessionId.current;
129
+ if (activeSessionId && !["idle", "complete", "rejected", "error"].includes(phase.current)) {
130
+ void rpcServerCalls
131
+ .devicePairingCancel({ sessionId: activeSessionId })
132
+ .catch(() => undefined);
133
+ }
134
+ };
135
+ }, []);
136
+
137
+ return { sessionId, phase, mounted };
138
+ }
139
+
140
+ /** Signed-in source flow opened from Settings. */
141
+ export function SourceDevicePairing(props: { onClose: () => void }) {
142
+ const state = useDevicePairingState();
143
+ const lifecycle = usePairingSessionLifecycle(state);
144
+ const started = useRef(false);
145
+ const [invitationUrl, setInvitationUrl] = useState("");
146
+ const [localError, setLocalError] = useState("");
147
+ const [approving, setApproving] = useState(false);
148
+ const [, setClock] = useState(0);
149
+
150
+ useEffect(() => {
151
+ if (started.current) {
152
+ return;
153
+ }
154
+ started.current = true;
155
+ void rpcServerCalls
156
+ .devicePairingStartSource()
157
+ .then(async (result) => {
158
+ lifecycle.sessionId.current = result.state.sessionId;
159
+ lifecycle.phase.current = result.state.phase;
160
+ if (!lifecycle.mounted.current && result.state.sessionId) {
161
+ await rpcServerCalls
162
+ .devicePairingCancel({ sessionId: result.state.sessionId })
163
+ .catch(() => undefined);
164
+ return;
165
+ }
166
+ setInvitationUrl(result.invitationUrl);
167
+ })
168
+ .catch(() => setLocalError("Could not create a pairing invitation."));
169
+ }, []);
170
+
171
+ useEffect(() => {
172
+ if (!state.expiresAt) {
173
+ return;
174
+ }
175
+ const timer = window.setInterval(() => setClock((value) => value + 1), 1000);
176
+ return () => window.clearInterval(timer);
177
+ }, [state.expiresAt]);
178
+
179
+ useEffect(() => {
180
+ if (state.phase !== "inviting") {
181
+ setInvitationUrl("");
182
+ }
183
+ }, [state.phase]);
184
+
185
+ const act = async (action: "approve" | "reject") => {
186
+ const sessionId = lifecycle.sessionId.current;
187
+ if (!sessionId) {
188
+ return;
189
+ }
190
+ setApproving(true);
191
+ try {
192
+ if (action === "approve") {
193
+ await rpcServerCalls.devicePairingApprove({ sessionId });
194
+ } else {
195
+ await rpcServerCalls.devicePairingReject({ sessionId });
196
+ }
197
+ } catch {
198
+ setLocalError("Could not update the pairing ceremony.");
199
+ } finally {
200
+ setApproving(false);
201
+ }
202
+ };
203
+
204
+ const cancel = async () => {
205
+ const sessionId = lifecycle.sessionId.current;
206
+ lifecycle.phase.current = "idle";
207
+ if (sessionId) {
208
+ await rpcServerCalls.devicePairingCancel({ sessionId }).catch(() => undefined);
209
+ }
210
+ props.onClose();
211
+ };
212
+
213
+ if (localError) {
214
+ return <div className="alert alert-danger">{localError}</div>;
215
+ }
216
+ if (["complete", "rejected", "error"].includes(state.phase)) {
217
+ return <TerminalPanel state={state} onClose={props.onClose} />;
218
+ }
219
+ if (state.phase === "comparing" && state.sas) {
220
+ return (
221
+ <PairingComparison
222
+ state={state}
223
+ approving={approving}
224
+ onApprove={() => void act("approve")}
225
+ onReject={() => void act("reject")}
226
+ onCancel={() => void cancel()}
227
+ />
228
+ );
229
+ }
230
+ if (state.phase === "waiting-for-approval" || state.phase === "transferring") {
231
+ return <ProgressWithCancel state={state} onCancel={() => void cancel()} />;
232
+ }
233
+ if (invitationUrl) {
234
+ return (
235
+ <div className="text-center" style={{ maxWidth: 440, margin: "0 auto" }}>
236
+ <p className="text-muted">Scan this on the signed-out device you want to add.</p>
237
+ <div className="d-inline-block bg-white rounded p-3 mb-3">
238
+ <QRCode value={invitationUrl} size={220} aria-label="Device pairing QR code" />
239
+ </div>
240
+ <p className="small text-muted">Expires in {formatCountdown(state.expiresAt)}</p>
241
+ <div className="d-flex gap-2 justify-content-center">
242
+ <button
243
+ type="button"
244
+ className="btn btn-outline-primary"
245
+ onClick={() => void copyToClipboard(invitationUrl)}
246
+ >
247
+ Copy pairing link
248
+ </button>
249
+ <button type="button" className="btn btn-outline-secondary" onClick={() => void cancel()}>
250
+ Cancel
251
+ </button>
252
+ </div>
253
+ </div>
254
+ );
255
+ }
256
+ return <PairingProgress state={state} />;
257
+ }
258
+
259
+ /** Signed-out destination flow opened by a QR link or manual invitation paste. */
260
+ export function DestinationDevicePairing(props: {
261
+ initialInvitation?: string;
262
+ onInvitationConsumed?: () => void;
263
+ onBack: () => void;
264
+ }) {
265
+ const state = useDevicePairingState();
266
+ const lifecycle = usePairingSessionLifecycle(state);
267
+ const [invitation, setInvitation] = useState(props.initialInvitation ?? "");
268
+ const [localError, setLocalError] = useState("");
269
+ const [approving, setApproving] = useState(false);
270
+ const autoStarted = useRef(false);
271
+
272
+ const start = async (value = invitation) => {
273
+ setLocalError("");
274
+ try {
275
+ const result = await rpcServerCalls.devicePairingStartDestination({
276
+ invitation: value.trim(),
277
+ deviceType: /Mobi|Android/i.test(navigator.userAgent) ? "mobile" : "browser",
278
+ });
279
+ lifecycle.sessionId.current = result.sessionId;
280
+ lifecycle.phase.current = result.phase;
281
+ if (!lifecycle.mounted.current && result.sessionId) {
282
+ await rpcServerCalls
283
+ .devicePairingCancel({ sessionId: result.sessionId })
284
+ .catch(() => undefined);
285
+ return;
286
+ }
287
+ setInvitation("");
288
+ props.onInvitationConsumed?.();
289
+ } catch {
290
+ setLocalError("That pairing invitation is invalid, expired, or unavailable.");
291
+ }
292
+ };
293
+
294
+ useEffect(() => {
295
+ if (!props.initialInvitation || autoStarted.current) {
296
+ return;
297
+ }
298
+ autoStarted.current = true;
299
+ void start(props.initialInvitation);
300
+ }, [props.initialInvitation]);
301
+
302
+ useEffect(() => {
303
+ if (state.phase !== "complete") {
304
+ return;
305
+ }
306
+ window.setTimeout(() => window.location.reload(), 750);
307
+ }, [state.phase]);
308
+
309
+ const act = async (action: "approve" | "reject") => {
310
+ const sessionId = lifecycle.sessionId.current;
311
+ if (!sessionId) {
312
+ return;
313
+ }
314
+ setApproving(true);
315
+ try {
316
+ if (action === "approve") {
317
+ await rpcServerCalls.devicePairingApprove({ sessionId });
318
+ } else {
319
+ await rpcServerCalls.devicePairingReject({ sessionId });
320
+ }
321
+ } catch {
322
+ setLocalError("Could not update the pairing ceremony.");
323
+ } finally {
324
+ setApproving(false);
325
+ }
326
+ };
327
+
328
+ const cancel = async () => {
329
+ const sessionId = lifecycle.sessionId.current;
330
+ lifecycle.phase.current = "idle";
331
+ if (sessionId) {
332
+ await rpcServerCalls.devicePairingCancel({ sessionId }).catch(() => undefined);
333
+ }
334
+ props.onBack();
335
+ };
336
+
337
+ if (["complete", "rejected", "error"].includes(state.phase)) {
338
+ return <TerminalPanel state={state} onClose={props.onBack} />;
339
+ }
340
+ if (state.phase === "comparing" && state.sas) {
341
+ return (
342
+ <PairingComparison
343
+ state={state}
344
+ approving={approving}
345
+ onApprove={() => void act("approve")}
346
+ onReject={() => void act("reject")}
347
+ onCancel={() => void cancel()}
348
+ />
349
+ );
350
+ }
351
+ if (
352
+ state.phase === "connecting" ||
353
+ state.phase === "waiting-for-approval" ||
354
+ state.phase === "transferring"
355
+ ) {
356
+ return <ProgressWithCancel state={state} onCancel={() => void cancel()} />;
357
+ }
358
+ return (
359
+ <div className="text-center" style={{ maxWidth: 500, margin: "0 auto" }}>
360
+ <h3>Sign in using another device</h3>
361
+ <p className="text-muted">
362
+ On a device already signed in to Peers, choose Add another device. Scan its QR code, or
363
+ paste the complete pairing link or token below.
364
+ </p>
365
+ <textarea
366
+ className="form-control font-monospace mb-3"
367
+ rows={4}
368
+ placeholder="Paste pairing link or token"
369
+ value={invitation}
370
+ onChange={(event) => setInvitation(event.target.value)}
371
+ autoComplete="off"
372
+ aria-label="Device pairing invitation"
373
+ />
374
+ {localError ? <div className="alert alert-danger">{localError}</div> : null}
375
+ <div className="d-grid gap-2">
376
+ <button
377
+ type="button"
378
+ className="btn btn-primary btn-lg"
379
+ disabled={!invitation.trim()}
380
+ onClick={() => void start()}
381
+ >
382
+ Connect securely
383
+ </button>
384
+ <button type="button" className="btn btn-outline-secondary" onClick={props.onBack}>
385
+ Back
386
+ </button>
387
+ </div>
388
+ </div>
389
+ );
390
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./device-pairing";
2
+ export * from "./device-pairing-invite";
3
+ export * from "./use-device-pairing-state";
@@ -0,0 +1,38 @@
1
+ import {
2
+ DEVICE_PAIRING_STATE_EVENT,
3
+ type DevicePairingState,
4
+ devicePairingStateSchema,
5
+ rpcServerCalls,
6
+ subscribe,
7
+ } from "@peers-app/peers-sdk";
8
+ import { useEffect, useState } from "react";
9
+
10
+ const IDLE_PAIRING_STATE: DevicePairingState = { phase: "idle" };
11
+
12
+ /** Subscribes to renderer-safe host pairing state and loads its current snapshot. */
13
+ export function useDevicePairingState(): DevicePairingState {
14
+ const [state, setState] = useState<DevicePairingState>(IDLE_PAIRING_STATE);
15
+
16
+ useEffect(() => {
17
+ let active = true;
18
+ const subscription = subscribe<DevicePairingState>(DEVICE_PAIRING_STATE_EVENT, (event) => {
19
+ if (active) {
20
+ setState(devicePairingStateSchema.parse(event.data));
21
+ }
22
+ });
23
+ void rpcServerCalls
24
+ .devicePairingGetState()
25
+ .then((value) => {
26
+ if (active) {
27
+ setState(devicePairingStateSchema.parse(value));
28
+ }
29
+ })
30
+ .catch(() => undefined);
31
+ return () => {
32
+ active = false;
33
+ subscription.unsubscribe();
34
+ };
35
+ }, []);
36
+
37
+ return state;
38
+ }
@@ -136,5 +136,44 @@ export async function activatePackageVersion(
136
136
  }
137
137
  }
138
138
 
139
+ /**
140
+ * After promoting a version that is currently effective-active on this device,
141
+ * activate it when the new tag matches the group's auto-activate / follow rules.
142
+ *
143
+ * This closes the gotcha where a device is already running a `dev` build, promotes
144
+ * it to `beta`/`stable`, and expects the group active version to advance without a
145
+ * separate Activate click — but only when the group would accept that channel
146
+ * (e.g. default follow is `stable`; `beta` only when the group follows beta).
147
+ *
148
+ * @param packageId - Package that was promoted
149
+ * @param promotedPv - PV after the tag change (must include the new `versionTag`)
150
+ * @param opts.wasEffectiveActive - True when this PV was this device's effective active before promote
151
+ * @param opts.groupFollowTags - Group `IPackage.followVersionTags` (not device override)
152
+ * @param opts.groupPinned - True when group `versionFollowRange` is `pinned`
153
+ * @returns Whether activation was attempted
154
+ */
155
+ export async function maybeActivateAfterPromote(
156
+ packageId: string,
157
+ promotedPv: IPackageVersion,
158
+ opts: {
159
+ wasEffectiveActive: boolean;
160
+ groupFollowTags: string | undefined;
161
+ groupPinned: boolean;
162
+ },
163
+ ): Promise<boolean> {
164
+ if (!opts.wasEffectiveActive) return false;
165
+ if (opts.groupPinned) return false;
166
+ if (!promotedPv.versionTag || promotedPv.versionTag === "dev") return false;
167
+
168
+ // Docs default: groups follow `stable` when followVersionTags is unset.
169
+ const followTags = opts.groupFollowTags ?? "stable";
170
+ if (!doesTagMatch(undefined, promotedPv.versionTag, followTags, undefined)) {
171
+ return false;
172
+ }
173
+
174
+ await activatePackageVersion(packageId, promotedPv);
175
+ return true;
176
+ }
177
+
139
178
  /** Numeric rank for sorting packages by update urgency. */
140
179
  export const updateLevelRank: Record<string, number> = { major: 3, minor: 2, patch: 1 };
@@ -15,7 +15,7 @@ import {
15
15
  } from "@peers-app/peers-sdk";
16
16
  import { useState } from "react";
17
17
  import { useObservable, useObservableState, usePromise } from "../../hooks";
18
- import { activatePackageVersion, isGroupAdmin } from "./package-helpers";
18
+ import { activatePackageVersion, isGroupAdmin, maybeActivateAfterPromote } from "./package-helpers";
19
19
 
20
20
  function formatDate(iso: string): string {
21
21
  try {
@@ -43,6 +43,7 @@ function tagBadgeClass(tag?: string): string {
43
43
  return "text-bg-secondary";
44
44
  }
45
45
 
46
+ /** Lists package versions with activate, promote, pin, edit-semver, and delete actions. */
46
47
  export const PackageVersionsList = (props: { pkg: IDoc<IPackage> }) => {
47
48
  const { pkg } = props;
48
49
  const refreshKey = useObservableState(0);
@@ -127,26 +128,46 @@ export const PackageVersionsList = (props: { pkg: IDoc<IPackage> }) => {
127
128
  pv.routesBundleFileHash,
128
129
  pv.uiBundleFileHash,
129
130
  );
131
+
132
+ // Auto-sign with package author key if available (server/PWA host — key never
133
+ // exposed to UI). Soft-fail when the key is missing so group admins can still
134
+ // promote, but never keep a signature that was computed for the old tag.
135
+ const authorSig = await rpcServerCalls.signPackageAuthorVersion({
136
+ packageId: pv.packageId,
137
+ packageVersionId: pv.packageVersionId,
138
+ version: pv.version,
139
+ versionTag: newTag,
140
+ packageBundleFileHash: pv.packageBundleFileHash,
141
+ routesBundleFileHash: pv.routesBundleFileHash,
142
+ uiBundleFileHash: pv.uiBundleFileHash,
143
+ appNavs: pv.appNavs,
144
+ createdBy: pv.createdBy,
145
+ createdAt: pv.createdAt,
146
+ });
147
+
130
148
  const updated: IPackageVersion = {
131
149
  ...pv,
132
150
  versionTag: newTag,
133
151
  packageVersionHash: pvHash,
134
152
  history,
153
+ packageAuthorSignature: authorSig,
135
154
  };
136
155
 
137
- // Auto-sign with package author key if available (server-side, key never exposed)
138
- const authorSig = await rpcServerCalls.signPackageAuthorVersion({
139
- packageId: updated.packageId,
140
- packageVersionId: updated.packageVersionId,
141
- version: updated.version,
142
- versionTag: newTag,
143
- packageBundleFileHash: updated.packageBundleFileHash,
144
- routesBundleFileHash: updated.routesBundleFileHash,
145
- uiBundleFileHash: updated.uiBundleFileHash,
156
+ await PackageVersions().signAndSave(updated, { saveAsSnapshot: true });
157
+
158
+ // Promoting the version this device is already running should advance the
159
+ // group active version when the new tag matches group follow policy
160
+ // (e.g. promote active → stable with default follow settings).
161
+ const pkgJs = pkg.toJS();
162
+ const activated = await maybeActivateAfterPromote(pkg.packageId, updated, {
163
+ wasEffectiveActive: pv.packageVersionId === effectiveActiveVersionId,
164
+ groupFollowTags: pkgJs.followVersionTags,
165
+ groupPinned: pkgJs.versionFollowRange === "pinned",
146
166
  });
147
- updated.packageAuthorSignature = authorSig;
167
+ if (activated) {
168
+ await pkg.load();
169
+ }
148
170
 
149
- await PackageVersions().signAndSave(updated, { saveAsSnapshot: true });
150
171
  refreshKey(refreshKey() + 1);
151
172
  } catch (err: unknown) {
152
173
  alert(`Failed to promote version: ${err instanceof Error ? err.message : String(err)}`);
@@ -162,7 +183,25 @@ export const PackageVersionsList = (props: { pkg: IDoc<IPackage> }) => {
162
183
  pv.routesBundleFileHash,
163
184
  pv.uiBundleFileHash,
164
185
  );
165
- const updated = { ...pv, version: newSemver, packageVersionHash: pvHash };
186
+ // Semver is part of the author-signed payload — re-sign or clear the old signature.
187
+ const authorSig = await rpcServerCalls.signPackageAuthorVersion({
188
+ packageId: pv.packageId,
189
+ packageVersionId: pv.packageVersionId,
190
+ version: newSemver,
191
+ versionTag: pv.versionTag,
192
+ packageBundleFileHash: pv.packageBundleFileHash,
193
+ routesBundleFileHash: pv.routesBundleFileHash,
194
+ uiBundleFileHash: pv.uiBundleFileHash,
195
+ appNavs: pv.appNavs,
196
+ createdBy: pv.createdBy,
197
+ createdAt: pv.createdAt,
198
+ });
199
+ const updated: IPackageVersion = {
200
+ ...pv,
201
+ version: newSemver,
202
+ packageVersionHash: pvHash,
203
+ packageAuthorSignature: authorSig,
204
+ };
166
205
  await PackageVersions().signAndSave(updated, { saveAsSnapshot: true });
167
206
  refreshKey(refreshKey() + 1);
168
207
  } catch (err: unknown) {
@@ -19,6 +19,7 @@ import { Tooltip } from "../../components/tooltip";
19
19
  import * as globals from "../../globals";
20
20
  import { useObservable, useObservableState, usePromise } from "../../hooks";
21
21
  import { LayoutPicker } from "../../root-layout/layout-picker";
22
+ import { SourceDevicePairing } from "../device-pairing";
22
23
  import { ColorModeDropdown } from "./color-mode-dropdown";
23
24
  import { VoiceSettings } from "./voice-settings";
24
25
 
@@ -71,6 +72,7 @@ const UserSettingsTab: React.FC = () => {
71
72
  return (
72
73
  <>
73
74
  <ProfileSection />
75
+ <AddDeviceSection />
74
76
  <LogoutSection />
75
77
  </>
76
78
  );
@@ -250,6 +252,26 @@ const ProfileSection: React.FC = () => {
250
252
  );
251
253
  };
252
254
 
255
+ const AddDeviceSection: React.FC = () => {
256
+ const [open, setOpen] = useState(false);
257
+ return (
258
+ <section className="mt-4 pt-3 border-top">
259
+ <h6>Add another device</h6>
260
+ <p className="small text-muted">
261
+ Create a five-minute invitation, compare the same number on both devices, and approve on
262
+ each one before account access is transferred.
263
+ </p>
264
+ {open ? (
265
+ <SourceDevicePairing onClose={() => setOpen(false)} />
266
+ ) : (
267
+ <button type="button" className="btn btn-outline-primary" onClick={() => setOpen(true)}>
268
+ Add another device
269
+ </button>
270
+ )}
271
+ </section>
272
+ );
273
+ };
274
+
253
275
  const PackagesRootDirectory: React.FC = () => {
254
276
  return (
255
277
  <div className="mt-3">