@peers-app/peers-ui 0.25.14 → 0.25.16

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/globals.d.ts CHANGED
@@ -25,9 +25,9 @@ export declare const groups: import("@peers-app/peers-sdk").Observable<{
25
25
  description: string;
26
26
  publicKey: string;
27
27
  signature: string;
28
+ publicBoxKey: string;
28
29
  groupId: string;
29
30
  founderUserId: string;
30
- publicBoxKey: string;
31
31
  disabled?: boolean | undefined;
32
32
  iconClassName?: string | undefined;
33
33
  publicRole?: import("@peers-app/peers-sdk").GroupMemberRole | undefined;
@@ -53,12 +53,319 @@ function AccountScreen() {
53
53
  if (!me) {
54
54
  return (0, jsx_runtime_1.jsx)(loading_indicator_1.LoadingIndicator, {});
55
55
  }
56
- return ((0, jsx_runtime_1.jsxs)("div", { className: "container-xxl p-3", children: [(0, jsx_runtime_1.jsxs)("div", { className: "mb-3", children: [(0, jsx_runtime_1.jsxs)("h4", { className: "mb-1 d-flex align-items-center gap-2", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-person-circle" }), "Account"] }), (0, jsx_runtime_1.jsx)("div", { className: "small text-muted", children: "Your public identity and account-level services" })] }), error && ((0, jsx_runtime_1.jsxs)("div", { className: "alert alert-danger alert-dismissible", role: "alert", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-exclamation-triangle me-2" }), error, (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn-close", onClick: () => setError(null), "aria-label": "Close" })] })), success && ((0, jsx_runtime_1.jsxs)("div", { className: "alert alert-success alert-dismissible", role: "alert", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-check-circle me-2" }), success, (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn-close", onClick: () => setSuccess(null), "aria-label": "Close" })] })), (0, jsx_runtime_1.jsx)("div", { className: "card mb-3", children: (0, jsx_runtime_1.jsxs)("div", { className: "card-body d-flex align-items-center gap-3 flex-wrap", children: [(0, jsx_runtime_1.jsx)("span", { className: "rounded-circle bg-primary-subtle text-primary-emphasis d-inline-flex align-items-center justify-content-center fs-4 fw-semibold", style: { width: 58, height: 58 }, "aria-hidden": "true", children: profileInitials(me.name) }), (0, jsx_runtime_1.jsxs)("div", { className: "flex-grow-1", children: [(0, jsx_runtime_1.jsx)("h5", { className: "card-title mb-1", children: me.name || "Unnamed user" }), (0, jsx_runtime_1.jsx)("div", { className: "small text-muted", children: "Peers identity" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "d-flex gap-2 flex-wrap", children: [(0, jsx_runtime_1.jsx)("a", { href: "#settings", className: "btn btn-outline-secondary btn-sm", children: "Edit display name" }), (0, jsx_runtime_1.jsx)("a", { href: "#identity/devices", className: "btn btn-outline-primary btn-sm", children: "Manage devices" }), (0, jsx_runtime_1.jsx)("a", { href: "#identity/devices/pair", className: "btn btn-primary btn-sm", children: "Add device" })] })] }) }), (0, jsx_runtime_1.jsx)("div", { className: "card mb-3", children: (0, jsx_runtime_1.jsxs)("div", { className: "card-body", children: [(0, jsx_runtime_1.jsx)("h5", { className: "card-title", children: "Technical identity" }), (0, jsx_runtime_1.jsx)("p", { className: "small text-muted", children: "Share these values only when diagnosing identity or connection problems." }), (0, jsx_runtime_1.jsx)(TechnicalValue, { label: "User ID", value: me.userId, help: "Your stable identifier across Peers." }), (0, jsx_runtime_1.jsx)(TechnicalValue, { label: "Signing key", value: me.publicKey, help: "Other people use this public key to verify your signatures." }), (0, jsx_runtime_1.jsx)(TechnicalValue, { label: "Encryption key", value: me.publicBoxKey, help: "Other people use this public key to encrypt data for you." }), (0, jsx_runtime_1.jsx)(TechnicalValue, { label: "Current device ID", value: deviceId || "Not available", help: "Identifies this installation on Peers networks." })] }) }), (0, jsx_runtime_1.jsx)("div", { className: "card", children: (0, jsx_runtime_1.jsxs)("div", { className: "card-body", children: [(0, jsx_runtime_1.jsxs)("h5", { className: "card-title d-flex align-items-center gap-2", children: ["Peers Services Registration", (0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { markdownContent: INFO_TEXT })] }), isRegistered ? ((0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-center gap-2 mb-2", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-check-circle-fill text-success", style: { fontSize: "1.2em" } }), (0, jsx_runtime_1.jsx)("span", { children: "Registered" })] }), (0, jsx_runtime_1.jsxs)("small", { className: "text-muted d-block mb-3", children: ["Registered on", " ", new Date(registrationStatus).toLocaleDateString(undefined, {
56
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "container-xxl p-3", children: [(0, jsx_runtime_1.jsxs)("div", { className: "mb-3", children: [(0, jsx_runtime_1.jsxs)("h4", { className: "mb-1 d-flex align-items-center gap-2", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-person-circle" }), "Account"] }), (0, jsx_runtime_1.jsx)("div", { className: "small text-muted", children: "Your public identity and account-level services" })] }), error && ((0, jsx_runtime_1.jsxs)("div", { className: "alert alert-danger alert-dismissible", role: "alert", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-exclamation-triangle me-2" }), error, (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn-close", onClick: () => setError(null), "aria-label": "Close" })] })), success && ((0, jsx_runtime_1.jsxs)("div", { className: "alert alert-success alert-dismissible", role: "alert", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-check-circle me-2" }), success, (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn-close", onClick: () => setSuccess(null), "aria-label": "Close" })] })), (0, jsx_runtime_1.jsx)("div", { className: "card mb-3", children: (0, jsx_runtime_1.jsxs)("div", { className: "card-body d-flex align-items-center gap-3 flex-wrap", children: [(0, jsx_runtime_1.jsx)("span", { className: "rounded-circle bg-primary-subtle text-primary-emphasis d-inline-flex align-items-center justify-content-center fs-4 fw-semibold", style: { width: 58, height: 58 }, "aria-hidden": "true", children: profileInitials(me.name) }), (0, jsx_runtime_1.jsxs)("div", { className: "flex-grow-1", children: [(0, jsx_runtime_1.jsx)("h5", { className: "card-title mb-1", children: me.name || "Unnamed user" }), (0, jsx_runtime_1.jsx)("div", { className: "small text-muted", children: "Peers identity" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "d-flex gap-2 flex-wrap", children: [(0, jsx_runtime_1.jsx)("a", { href: "#settings", className: "btn btn-outline-secondary btn-sm", children: "Edit display name" }), (0, jsx_runtime_1.jsx)("a", { href: "#identity/devices", className: "btn btn-outline-primary btn-sm", children: "Manage devices" }), (0, jsx_runtime_1.jsx)("a", { href: "#identity/devices/pair", className: "btn btn-primary btn-sm", children: "Add device" })] })] }) }), (0, jsx_runtime_1.jsx)("div", { className: "card mb-3", children: (0, jsx_runtime_1.jsxs)("div", { className: "card-body", children: [(0, jsx_runtime_1.jsx)("h5", { className: "card-title", children: "Technical identity" }), (0, jsx_runtime_1.jsx)("p", { className: "small text-muted", children: "Share these values only when diagnosing identity or connection problems." }), (0, jsx_runtime_1.jsx)(TechnicalValue, { label: "User ID", value: me.userId, help: "Your stable identifier across Peers." }), (0, jsx_runtime_1.jsx)(TechnicalValue, { label: "Signing key", value: me.publicKey, help: "Other people use this public key to verify your signatures." }), (0, jsx_runtime_1.jsx)(TechnicalValue, { label: "Encryption key", value: me.publicBoxKey, help: "Other people use this public key to encrypt data for you." }), (0, jsx_runtime_1.jsx)(TechnicalValue, { label: "Current device ID", value: deviceId || "Not available", help: "Identifies this installation on Peers networks." })] }) }), (0, jsx_runtime_1.jsx)(RecoveryNotice, {}), (0, jsx_runtime_1.jsx)(SigningKeyCard, {}), (0, jsx_runtime_1.jsx)(RecoveryEmailCard, {}), (0, jsx_runtime_1.jsx)(AnchorsCard, {}), (0, jsx_runtime_1.jsx)(PasskeyCard, {}), (0, jsx_runtime_1.jsx)("div", { className: "card", children: (0, jsx_runtime_1.jsxs)("div", { className: "card-body", children: [(0, jsx_runtime_1.jsxs)("h5", { className: "card-title d-flex align-items-center gap-2", children: ["Peers Services Registration", (0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { markdownContent: INFO_TEXT })] }), isRegistered ? ((0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-center gap-2 mb-2", children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-check-circle-fill text-success", style: { fontSize: "1.2em" } }), (0, jsx_runtime_1.jsx)("span", { children: "Registered" })] }), (0, jsx_runtime_1.jsxs)("small", { className: "text-muted d-block mb-3", children: ["Registered on", " ", new Date(registrationStatus).toLocaleDateString(undefined, {
57
57
  year: "numeric",
58
58
  month: "long",
59
59
  day: "numeric",
60
60
  })] }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-secondary btn-sm", onClick: handleRegister, disabled: isRegistering, children: isRegistering ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("span", { className: "spinner-border spinner-border-sm me-2" }), "Refreshing..."] })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-arrow-clockwise me-2" }), "Refresh Token"] })) })] })) : ((0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("p", { className: "text-muted mb-3", children: "Register your account to enable cloud-powered features like the AI assistant." }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-primary", onClick: handleRegister, disabled: isRegistering, children: isRegistering ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("span", { className: "spinner-border spinner-border-sm me-2" }), "Registering..."] })) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("i", { className: "bi bi-cloud-upload me-2" }), "Register Account"] })) })] }))] }) })] }));
61
61
  }
62
+ const ROTATE_INFO = `### Rotating your signing key
63
+
64
+ Your signing key is how other people and your own devices verify that something came from you.
65
+ Replacing it needs an **anchor** already on your profile. A recovery email lets this device
66
+ publish the new key to Peers Services. An https anchor cannot be written to from here, so
67
+ **Rotate signing key** shows a document for you to place there. The key on this device changes
68
+ only after every anchor on your profile lists the new key.
69
+
70
+ The database key is stored separately, so a rotation does not re-encrypt the database.`;
71
+ /** A recovery waiting on the delay, or held because a device contested it. */
72
+ function RecoveryNotice() {
73
+ const [notices] = (0, hooks_1.useObservable)(peers_sdk_1.identityRecoveryNoticesVar);
74
+ const [busy, setBusy] = (0, react_1.useState)(null);
75
+ const [error, setError] = (0, react_1.useState)(null);
76
+ if (!notices || notices.length === 0)
77
+ return null;
78
+ const act = async (label, action) => {
79
+ setBusy(label);
80
+ setError(null);
81
+ try {
82
+ await action();
83
+ }
84
+ catch (err) {
85
+ setError(err instanceof Error ? err.message : String(err));
86
+ }
87
+ finally {
88
+ setBusy(null);
89
+ }
90
+ };
91
+ return ((0, jsx_runtime_1.jsx)("div", { className: "card mb-3", children: (0, jsx_runtime_1.jsxs)("div", { className: "card-body", children: [(0, jsx_runtime_1.jsx)("h5", { className: "card-title", children: "Key recovery" }), notices.map((notice) => ((0, jsx_runtime_1.jsxs)("div", { className: "mb-3", children: [(0, jsx_runtime_1.jsxs)("p", { className: "mb-2", children: [notice.own
92
+ ? "A new key was published for this account without your current key's signature."
93
+ : "A contact's key is being recovered.", " ", notice.contested
94
+ ? "A device that holds the current key objects. The new key stays untrusted until you accept it."
95
+ : `Peers adopt it after ${new Date(notice.readyAt).toLocaleString()} unless someone contests it.`] }), (0, jsx_runtime_1.jsx)("code", { className: "small text-break", children: notice.pendingPublicKey }), (0, jsx_runtime_1.jsx)("div", { className: "mt-2", children: notice.own ? ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-warning btn-sm", disabled: busy !== null, onClick: () => act(notice.pendingPublicKey, async () => {
96
+ const tools = await (0, identity_ipc_1.identityTools)();
97
+ await tools.contestRecovery({ pendingKey: notice.pendingPublicKey });
98
+ }), children: busy === notice.pendingPublicKey ? "Sending..." : "Contest" })) : ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-primary btn-sm", disabled: busy !== null, onClick: () => act(notice.pendingPublicKey, async () => {
99
+ const tools = await (0, identity_ipc_1.identityTools)();
100
+ await tools.acceptRecovery({
101
+ userId: notice.userId,
102
+ pendingKey: notice.pendingPublicKey,
103
+ });
104
+ }), children: busy === notice.pendingPublicKey ? "Accepting..." : "Accept the new key" })) })] }, `${notice.userId}:${notice.pendingPublicKey}`))), error && (0, jsx_runtime_1.jsx)("p", { className: "alert alert-danger mb-0", children: error })] }) }));
105
+ }
106
+ function bytesFromBase64Url(value) {
107
+ const padded = value.replace(/-/g, "+").replace(/_/g, "/");
108
+ const binary = atob(padded);
109
+ const bytes = new Uint8Array(binary.length);
110
+ for (let i = 0; i < binary.length; i++)
111
+ bytes[i] = binary.charCodeAt(i);
112
+ return bytes.buffer;
113
+ }
114
+ function base64UrlFromBytes(buffer) {
115
+ const bytes = new Uint8Array(buffer);
116
+ let binary = "";
117
+ for (const byte of bytes)
118
+ binary += String.fromCharCode(byte);
119
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
120
+ }
121
+ function credentialJson(credential) {
122
+ const withJson = credential;
123
+ if (typeof withJson.toJSON === "function")
124
+ return withJson.toJSON();
125
+ const response = credential.response;
126
+ const body = {
127
+ clientDataJSON: base64UrlFromBytes(response.clientDataJSON),
128
+ };
129
+ if ("attestationObject" in response) {
130
+ body.attestationObject = base64UrlFromBytes(response.attestationObject);
131
+ }
132
+ if ("authenticatorData" in response && "signature" in response) {
133
+ const assertion = response;
134
+ body.authenticatorData = base64UrlFromBytes(assertion.authenticatorData);
135
+ body.signature = base64UrlFromBytes(assertion.signature);
136
+ }
137
+ return {
138
+ id: credential.id,
139
+ rawId: base64UrlFromBytes(credential.rawId),
140
+ type: credential.type,
141
+ response: body,
142
+ clientExtensionResults: credential.getClientExtensionResults(),
143
+ };
144
+ }
145
+ /** Add or remove an anchor by publishing the new list at the anchors already on record. */
146
+ function AnchorsCard() {
147
+ const info = (0, hooks_1.usePromise)(async () => (await (0, identity_ipc_1.identityTools)()).getKeyInfo(), undefined, []);
148
+ const [anchors, setAnchors] = (0, react_1.useState)(null);
149
+ const [url, setUrl] = (0, react_1.useState)("");
150
+ const [busy, setBusy] = (0, react_1.useState)(false);
151
+ const [error, setError] = (0, react_1.useState)(null);
152
+ const shown = anchors ?? info?.anchors?.onRecord ?? [];
153
+ const publish = async (next) => {
154
+ setBusy(true);
155
+ setError(null);
156
+ try {
157
+ const tools = await (0, identity_ipc_1.identityTools)();
158
+ const saved = await tools.setAnchors({ anchors: next });
159
+ setAnchors(saved.anchors);
160
+ setUrl("");
161
+ }
162
+ catch (err) {
163
+ setError(err instanceof Error ? err.message : String(err));
164
+ }
165
+ finally {
166
+ setBusy(false);
167
+ }
168
+ };
169
+ return ((0, jsx_runtime_1.jsx)("div", { className: "card mb-3", children: (0, jsx_runtime_1.jsxs)("div", { className: "card-body", children: [(0, jsx_runtime_1.jsx)("h5", { className: "card-title", children: "Anchors" }), (0, jsx_runtime_1.jsx)("p", { className: "text-muted", children: "Peers ask these places before they believe a different key. Adding one publishes the new list at an anchor already here. An https anchor has to be updated first. Removing the last one means a lost key cannot be recovered." }), shown.length === 0 && (0, jsx_runtime_1.jsx)("p", { children: "None yet." }), (0, jsx_runtime_1.jsx)("ul", { className: "list-unstyled", children: shown.map((anchor) => ((0, jsx_runtime_1.jsxs)("li", { className: "d-flex justify-content-between mb-2", children: [(0, jsx_runtime_1.jsx)("code", { className: "small text-break", children: anchor.url }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-danger btn-sm ms-2", disabled: busy, onClick: () => publish(shown.filter((item) => item.url !== anchor.url)), children: "Remove" })] }, `${anchor.type}:${anchor.url}`))) }), (0, jsx_runtime_1.jsxs)("div", { className: "input-group", children: [(0, jsx_runtime_1.jsx)("input", { className: "form-control", placeholder: "https://example.com/.well-known/peers-identity.json", value: url, onChange: (event) => setUrl(event.target.value) }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-primary", disabled: busy || url.trim() === "", onClick: () => publish([...shown, { type: "https", url: url.trim() }]), children: busy ? "Publishing..." : "Add" })] }), error && (0, jsx_runtime_1.jsx)("p", { className: "alert alert-danger mt-3 mb-0", children: error })] }) }));
170
+ }
171
+ /** Bind a passkey that can open an anchor-write session. */
172
+ function PasskeyCard() {
173
+ // A passkey is bound to a website. The Electron renderer loads from a file, which has no
174
+ // host; the registry refuses that origin and the browser would too.
175
+ const onWebsite = typeof window !== "undefined" && /^https?:$/.test(window.location.protocol);
176
+ const supported = onWebsite && typeof PublicKeyCredential !== "undefined";
177
+ const [busy, setBusy] = (0, react_1.useState)(false);
178
+ const [message, setMessage] = (0, react_1.useState)(null);
179
+ const [error, setError] = (0, react_1.useState)(null);
180
+ const run = async (action) => {
181
+ setBusy(true);
182
+ setError(null);
183
+ setMessage(null);
184
+ try {
185
+ const tools = await (0, identity_ipc_1.identityTools)();
186
+ const origin = window.location.origin;
187
+ if (action === "register") {
188
+ const started = await tools.passkeyRegisterStart({ origin });
189
+ const options = started.options;
190
+ const credential = (await navigator.credentials.create({
191
+ publicKey: {
192
+ ...options,
193
+ challenge: bytesFromBase64Url(options.challenge),
194
+ user: { ...options.user, id: bytesFromBase64Url(options.user.id) },
195
+ excludeCredentials: options.excludeCredentials?.map((item) => ({
196
+ ...item,
197
+ id: bytesFromBase64Url(item.id),
198
+ })),
199
+ },
200
+ }));
201
+ if (!credential)
202
+ throw new Error("No passkey was created");
203
+ await tools.passkeyRegisterFinish({ response: credentialJson(credential) });
204
+ setMessage("Passkey saved. It can open an anchor session without the signing key.");
205
+ }
206
+ else {
207
+ const started = await tools.passkeyAuthStart({ origin });
208
+ const options = started.options;
209
+ const credential = (await navigator.credentials.get({
210
+ publicKey: {
211
+ ...options,
212
+ challenge: bytesFromBase64Url(options.challenge),
213
+ allowCredentials: options.allowCredentials?.map((item) => ({
214
+ ...item,
215
+ id: bytesFromBase64Url(item.id),
216
+ })),
217
+ },
218
+ }));
219
+ if (!credential)
220
+ throw new Error("No passkey was used");
221
+ const session = await tools.passkeyAuthFinish({ response: credentialJson(credential) });
222
+ const minutes = Math.round(session.expiresIn / 60);
223
+ setMessage(`Anchor session open for about ${minutes} minutes.`);
224
+ }
225
+ }
226
+ catch (err) {
227
+ setError(err instanceof Error ? err.message : String(err));
228
+ }
229
+ finally {
230
+ setBusy(false);
231
+ }
232
+ };
233
+ return ((0, jsx_runtime_1.jsx)("div", { className: "card mb-3", children: (0, jsx_runtime_1.jsxs)("div", { className: "card-body", children: [(0, jsx_runtime_1.jsx)("h5", { className: "card-title", children: "Passkey" }), (0, jsx_runtime_1.jsx)("p", { className: "text-muted", children: "A passkey opens the same short session as the recovery email. It does not replace the signing key. This page has to be served on the passkey's site." }), supported ? ((0, jsx_runtime_1.jsxs)("div", { className: "d-flex gap-2", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-primary btn-sm", disabled: busy, onClick: () => run("register"), children: busy ? "Waiting..." : "Add a passkey" }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-secondary btn-sm", disabled: busy, onClick: () => run("session"), children: "Use passkey" })] })) : ((0, jsx_runtime_1.jsx)("p", { className: "mb-0", children: onWebsite
234
+ ? "This browser cannot create a passkey."
235
+ : "This window is not a website, so it cannot hold a passkey. Open your account in the web app to add or use one." })), message && (0, jsx_runtime_1.jsx)("p", { className: "mt-3 mb-0", children: message }), error && (0, jsx_runtime_1.jsx)("p", { className: "alert alert-danger mt-3 mb-0", children: error })] }) }));
236
+ }
237
+ /** Current signing key, previous keys, on-record anchors, and manual rotation. */
238
+ function SigningKeyCard() {
239
+ const info = (0, hooks_1.usePromise)(() => (0, identity_ipc_1.identityTools)().then((tools) => tools.getKeyInfo()));
240
+ const [rotatedAway] = (0, hooks_1.useObservable)(peers_sdk_1.identityRotatedAwayVar);
241
+ const [manualDocument, setManualDocument] = (0, react_1.useState)(null);
242
+ const [busy, setBusy] = (0, react_1.useState)(null);
243
+ const [error, setError] = (0, react_1.useState)(null);
244
+ // Writes through every anchor that has a writer. When an anchor on record has none (an
245
+ // https document) the host hands the document back first; confirm polls until every
246
+ // anchor lists the new key. A committed rotation restarts the host.
247
+ const prepare = async () => {
248
+ setBusy("prepare");
249
+ setError(null);
250
+ try {
251
+ const tools = await (0, identity_ipc_1.identityTools)();
252
+ const result = await tools.rotateKey({ publish: "anchors", confirm: false });
253
+ if (result.committed)
254
+ return;
255
+ setManualDocument(JSON.stringify(result.manualDocument, null, 2));
256
+ }
257
+ catch (err) {
258
+ setError(err instanceof Error ? err.message : String(err));
259
+ }
260
+ finally {
261
+ setBusy(null);
262
+ }
263
+ };
264
+ const confirm = async () => {
265
+ setBusy("confirm");
266
+ setError(null);
267
+ try {
268
+ const tools = await (0, identity_ipc_1.identityTools)();
269
+ await tools.rotateKey({ publish: "anchors", confirm: true });
270
+ }
271
+ catch (err) {
272
+ setError(err instanceof Error ? err.message : String(err));
273
+ setBusy(null);
274
+ }
275
+ };
276
+ return ((0, jsx_runtime_1.jsx)("div", { className: "card mb-3", children: (0, jsx_runtime_1.jsxs)("div", { className: "card-body", children: [(0, jsx_runtime_1.jsxs)("h5", { className: "card-title d-flex align-items-center gap-2", children: ["Signing key", (0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { markdownContent: ROTATE_INFO })] }), rotatedAway && ((0, jsx_runtime_1.jsxs)("p", { className: "alert alert-warning", children: ["This device holds an old key. Pair it again. The anchors confirmed", " ", (0, jsx_runtime_1.jsx)("code", { children: rotatedAway.newPublicKey }), "."] })), !info ? ((0, jsx_runtime_1.jsx)(loading_indicator_1.LoadingIndicator, {})) : ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(KeyStatusList, { info: info }), info.canRotate ? ((0, jsx_runtime_1.jsxs)("div", { className: "mt-3", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-primary btn-sm", onClick: prepare, disabled: busy !== null, children: busy === "prepare" ? "Rotating..." : "Rotate signing key" }), manualDocument && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("pre", { className: "small mt-3 mb-2 text-break", children: manualDocument }), (0, jsx_runtime_1.jsx)("p", { className: "small text-muted", children: "Place this document at every https anchor listed above, then confirm. This device waits until every anchor lists the new key before it changes anything." }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-primary btn-sm", onClick: confirm, disabled: busy !== null, children: busy === "confirm" ? "Waiting for the anchors..." : "I've published this" })] }))] })) : ((0, jsx_runtime_1.jsx)("p", { className: "alert alert-secondary mt-3 mb-0", children: "This host's identity is fixed at startup and cannot be rotated here." })), error && (0, jsx_runtime_1.jsx)("p", { className: "alert alert-danger mt-3 mb-0", children: error })] }))] }) }));
277
+ }
278
+ const RECOVERY_INFO = `### Recovery email
279
+
280
+ This mailbox is how Peers Services agrees to publish a new key. The code proves you can read
281
+ the mailbox. The signing key is still what other people verify.
282
+
283
+ Without a recovery email, Peers Services refuses a new key. **Prepare manual rotation** still
284
+ works if another anchor is on your profile. If you lose the signing key and have no recovery
285
+ email, there is no way back into this account.`;
286
+ /** Bind, confirm, and use a recovery email to publish a key rotation. */
287
+ function RecoveryEmailCard() {
288
+ const current = (0, hooks_1.usePromise)(() => (0, identity_ipc_1.identityTools)()
289
+ .then((tools) => tools.getRecoveryEmail())
290
+ .catch(() => ({ email: undefined })));
291
+ const [email, setEmail] = (0, react_1.useState)("");
292
+ const [code, setCode] = (0, react_1.useState)("");
293
+ const [bound, setBound] = (0, react_1.useState)(undefined);
294
+ const [busy, setBusy] = (0, react_1.useState)(null);
295
+ const [error, setError] = (0, react_1.useState)(null);
296
+ const [notice, setNotice] = (0, react_1.useState)(null);
297
+ const shown = bound === undefined ? (current?.email ?? "") : (bound ?? "");
298
+ const run = async (label, action) => {
299
+ setBusy(label);
300
+ setError(null);
301
+ setNotice(null);
302
+ try {
303
+ setNotice(await action());
304
+ }
305
+ catch (err) {
306
+ setError(err instanceof Error ? err.message : String(err));
307
+ }
308
+ finally {
309
+ setBusy(null);
310
+ }
311
+ };
312
+ return ((0, jsx_runtime_1.jsx)("div", { className: "card mb-3", children: (0, jsx_runtime_1.jsxs)("div", { className: "card-body", children: [(0, jsx_runtime_1.jsxs)("h5", { className: "card-title d-flex align-items-center gap-2", children: ["Recovery email", (0, jsx_runtime_1.jsx)(tooltip_1.Tooltip, { markdownContent: RECOVERY_INFO })] }), shown ? ((0, jsx_runtime_1.jsxs)("p", { className: "mb-3", children: ["Bound to ", (0, jsx_runtime_1.jsx)("strong", { children: shown })] })) : ((0, jsx_runtime_1.jsx)("p", { className: "text-muted mb-3", children: "Optional. Bind one if you want Peers Services to accept a new signing key." })), !shown && ((0, jsx_runtime_1.jsx)("input", { className: "form-control form-control-sm mb-2", type: "email", placeholder: "you@example.com", value: email, onChange: (event) => setEmail(event.target.value), disabled: busy !== null })), (0, jsx_runtime_1.jsx)("input", { className: "form-control form-control-sm mb-2", inputMode: "numeric", placeholder: "Code from the email", value: code, onChange: (event) => setCode(event.target.value), disabled: busy !== null }), (0, jsx_runtime_1.jsxs)("div", { className: "d-flex flex-wrap gap-2", children: [!shown && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-primary btn-sm", disabled: busy !== null || email.trim() === "", onClick: () => run("bind", async () => {
313
+ const tools = await (0, identity_ipc_1.identityTools)();
314
+ await tools.bindEmailStart({ email: email.trim() });
315
+ return `A code was sent to ${email.trim()}.`;
316
+ }), children: busy === "bind" ? "Sending..." : "Send bind code" }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-primary btn-sm", disabled: busy !== null || code.trim() === "", onClick: () => run("verify", async () => {
317
+ const tools = await (0, identity_ipc_1.identityTools)();
318
+ const verified = await tools.bindEmailVerify({
319
+ email: email.trim(),
320
+ code: code.trim(),
321
+ });
322
+ setBound(verified.email);
323
+ setCode("");
324
+ return `Recovery email saved: ${verified.email}`;
325
+ }), children: busy === "verify" ? "Checking..." : "Verify" })] })), shown && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-primary btn-sm", disabled: busy !== null, onClick: () => run("session", async () => {
326
+ const tools = await (0, identity_ipc_1.identityTools)();
327
+ await tools.startAnchorSession();
328
+ return "A code was sent to the recovery email.";
329
+ }), children: busy === "session" ? "Sending..." : "Send rotation code" }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-primary btn-sm", disabled: busy !== null || code.trim() === "", onClick: () => run("confirm", async () => {
330
+ const tools = await (0, identity_ipc_1.identityTools)();
331
+ const session = await tools.confirmAnchorSession({ code: code.trim() });
332
+ setCode("");
333
+ const minutes = Math.max(1, Math.round(session.expiresIn / 60));
334
+ return `Session open for about ${minutes} minutes. You can rotate now.`;
335
+ }), children: busy === "confirm" ? "Checking..." : "Open session" }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-outline-secondary btn-sm", disabled: busy !== null, onClick: () => run("rotate", async () => {
336
+ const tools = await (0, identity_ipc_1.identityTools)();
337
+ const result = await tools.rotateKey({ publish: "anchors" });
338
+ return `Rotated to ${result.newPublicKey}. This host will restart.`;
339
+ }), children: busy === "rotate" ? "Rotating..." : "Rotate signing key" }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "btn btn-link btn-sm", disabled: busy !== null, onClick: () => run("unbind", async () => {
340
+ const tools = await (0, identity_ipc_1.identityTools)();
341
+ await tools.unbindEmail();
342
+ setBound(null);
343
+ return "Recovery email removed.";
344
+ }), children: "Remove" })] }))] }), notice && (0, jsx_runtime_1.jsx)("p", { className: "alert alert-success mt-3 mb-0", children: notice }), error && (0, jsx_runtime_1.jsx)("p", { className: "alert alert-danger mt-3 mb-0", children: error })] }) }));
345
+ }
346
+ function KeyStatusList({ info }) {
347
+ const registryStatus = (publicKey) => info.registry?.keys?.find((k) => k.publicKey === publicKey)?.status;
348
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "small", children: [(0, jsx_runtime_1.jsxs)("div", { className: "d-flex align-items-center gap-2 flex-wrap", children: [(0, jsx_runtime_1.jsx)("code", { className: "text-break", children: info.publicKey }), (0, jsx_runtime_1.jsx)("span", { className: "badge text-bg-success", children: "current" }), (0, jsx_runtime_1.jsx)(RegistryBadge, { status: registryStatus(info.publicKey) })] }), info.previousPublicKeys.length > 0 && ((0, jsx_runtime_1.jsx)("ul", { className: "list-unstyled mt-2 mb-0 text-muted", children: [...info.previousPublicKeys].reverse().map((previous) => ((0, jsx_runtime_1.jsxs)("li", { className: "d-flex align-items-center gap-2 flex-wrap", children: [(0, jsx_runtime_1.jsx)("code", { className: "text-break", children: previous.publicKey }), (0, jsx_runtime_1.jsx)("span", { className: "badge text-bg-secondary", children: previous.reason === "rotation" || !previous.reason ? "retired" : "revoked" }), (0, jsx_runtime_1.jsx)(RegistryBadge, { status: registryStatus(previous.publicKey) }), (0, jsx_runtime_1.jsx)("span", { children: new Date(previous.replacedAt).toLocaleDateString() })] }, previous.publicKey))) })), info.anchors && info.anchors.onRecord.length > 0 && ((0, jsx_runtime_1.jsx)("ul", { className: "list-unstyled mt-2 mb-0 text-muted", children: info.anchors.onRecord.map((anchor) => {
349
+ const view = info.anchors?.views.find((item) => item.url === anchor.url);
350
+ return ((0, jsx_runtime_1.jsxs)("li", { children: [(0, jsx_runtime_1.jsx)("span", { className: "badge text-bg-light me-2", children: anchor.type }), (0, jsx_runtime_1.jsx)("code", { className: "text-break", children: anchor.url }), view?.error && (0, jsx_runtime_1.jsx)("span", { className: "ms-2", children: view.error })] }, anchor.url));
351
+ }) })), (0, jsx_runtime_1.jsx)("div", { className: "text-muted mt-2", children: info.registry
352
+ ? info.registry.error
353
+ ? `Key registry at ${info.registry.url} is not reachable: ${info.registry.error}`
354
+ : info.registry.keys
355
+ ? `Key registry: ${info.registry.url}`
356
+ : `The key registry at ${info.registry.url} has no record of this account yet; it is created on the first rotation.`
357
+ : "No key registry is configured (peers-services disabled)." })] }));
358
+ }
359
+ function RegistryBadge({ status }) {
360
+ if (!status)
361
+ return null;
362
+ const tone = status === "active"
363
+ ? "text-bg-primary"
364
+ : status === "revoked"
365
+ ? "text-bg-danger"
366
+ : "text-bg-light";
367
+ return (0, jsx_runtime_1.jsxs)("span", { className: `badge ${tone}`, children: ["registry: ", status] });
368
+ }
62
369
  function TechnicalValue(props) {
63
370
  const [copied, setCopied] = (0, react_1.useState)(false);
64
371
  const copy = async () => {
@@ -8,11 +8,15 @@ jest.mock("../../components/tooltip", () => ({
8
8
  }));
9
9
  jest.mock("../../hooks", () => ({
10
10
  useObservable: (value) => value === "device-observable" ? ["device0000000000000000001"] : [undefined],
11
+ // One result for every usePromise on the screen. getKeyInfo reads previousPublicKeys
12
+ // and canRotate; the profile card reads the rest.
11
13
  usePromise: () => ({
12
14
  userId: "user00000000000000000001",
13
15
  name: "Taylor Example",
14
16
  publicKey: "public-signing-key",
15
17
  publicBoxKey: "public-encryption-key",
18
+ previousPublicKeys: [],
19
+ canRotate: false,
16
20
  }),
17
21
  }));
18
22
  jest.mock("../../peers-services-registration", () => ({
@@ -36,7 +40,7 @@ describe("AccountScreen", () => {
36
40
  (0, react_1.render)((0, jsx_runtime_1.jsx)(account_screen_1.AccountScreen, {}));
37
41
  expect(react_1.screen.getByText("Taylor Example")).toBeInTheDocument();
38
42
  expect(react_1.screen.getByText("user00000000000000000001")).toBeInTheDocument();
39
- expect(react_1.screen.getByText("public-signing-key")).toBeInTheDocument();
43
+ expect(react_1.screen.getAllByText("public-signing-key")).toHaveLength(2);
40
44
  expect(react_1.screen.getByText("public-encryption-key")).toBeInTheDocument();
41
45
  expect(react_1.screen.getByText("device0000000000000000001")).toBeInTheDocument();
42
46
  expect(react_1.screen.getByRole("link", { name: "Edit display name" })).toHaveAttribute("href", "#settings");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peers-app/peers-ui",
3
- "version": "0.25.14",
3
+ "version": "0.25.16",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/peers-app/peers-ui.git"
@@ -30,7 +30,7 @@
30
30
  "lint:fix": "biome check --write ."
31
31
  },
32
32
  "peerDependencies": {
33
- "@peers-app/peers-sdk": "^0.25.14",
33
+ "@peers-app/peers-sdk": "^0.25.16",
34
34
  "bootstrap": "^5.3.3",
35
35
  "react": "^18.0.0",
36
36
  "react-dom": "^18.0.0"
@@ -41,7 +41,7 @@
41
41
  "@babel/preset-react": "^7.24.1",
42
42
  "@babel/preset-typescript": "^7.27.1",
43
43
  "@biomejs/biome": "2.3.11",
44
- "@peers-app/peers-sdk": "0.25.14",
44
+ "@peers-app/peers-sdk": "0.25.16",
45
45
  "@testing-library/dom": "^10.4.0",
46
46
  "@testing-library/jest-dom": "^6.6.3",
47
47
  "@testing-library/react": "^16.3.0",
@@ -8,11 +8,15 @@ jest.mock("../../components/tooltip", () => ({
8
8
  jest.mock("../../hooks", () => ({
9
9
  useObservable: (value: unknown) =>
10
10
  value === "device-observable" ? ["device0000000000000000001"] : [undefined],
11
+ // One result for every usePromise on the screen. getKeyInfo reads previousPublicKeys
12
+ // and canRotate; the profile card reads the rest.
11
13
  usePromise: () => ({
12
14
  userId: "user00000000000000000001",
13
15
  name: "Taylor Example",
14
16
  publicKey: "public-signing-key",
15
17
  publicBoxKey: "public-encryption-key",
18
+ previousPublicKeys: [],
19
+ canRotate: false,
16
20
  }),
17
21
  }));
18
22
 
@@ -41,7 +45,7 @@ describe("AccountScreen", () => {
41
45
 
42
46
  expect(screen.getByText("Taylor Example")).toBeInTheDocument();
43
47
  expect(screen.getByText("user00000000000000000001")).toBeInTheDocument();
44
- expect(screen.getByText("public-signing-key")).toBeInTheDocument();
48
+ expect(screen.getAllByText("public-signing-key")).toHaveLength(2);
45
49
  expect(screen.getByText("public-encryption-key")).toBeInTheDocument();
46
50
  expect(screen.getByText("device0000000000000000001")).toBeInTheDocument();
47
51
  expect(screen.getByRole("link", { name: "Edit display name" })).toHaveAttribute(
@@ -1,4 +1,12 @@
1
- import { copyToClipboard, getMe, thisDeviceId } from "@peers-app/peers-sdk";
1
+ import {
2
+ copyToClipboard,
3
+ getMe,
4
+ type IAnchorRef,
5
+ type IIdentityKeyInfo,
6
+ identityRecoveryNoticesVar,
7
+ identityRotatedAwayVar,
8
+ thisDeviceId,
9
+ } from "@peers-app/peers-sdk";
2
10
  import { useState } from "react";
3
11
  import { LoadingIndicator } from "../../components/loading-indicator";
4
12
  import { Tooltip } from "../../components/tooltip";
@@ -149,6 +157,12 @@ export function AccountScreen() {
149
157
  </div>
150
158
  </div>
151
159
 
160
+ <RecoveryNotice />
161
+ <SigningKeyCard />
162
+ <RecoveryEmailCard />
163
+ <AnchorsCard />
164
+ <PasskeyCard />
165
+
152
166
  <div className="card">
153
167
  <div className="card-body">
154
168
  <h5 className="card-title d-flex align-items-center gap-2">
@@ -220,6 +234,673 @@ export function AccountScreen() {
220
234
  );
221
235
  }
222
236
 
237
+ const ROTATE_INFO = `### Rotating your signing key
238
+
239
+ Your signing key is how other people and your own devices verify that something came from you.
240
+ Replacing it needs an **anchor** already on your profile. A recovery email lets this device
241
+ publish the new key to Peers Services. An https anchor cannot be written to from here, so
242
+ **Rotate signing key** shows a document for you to place there. The key on this device changes
243
+ only after every anchor on your profile lists the new key.
244
+
245
+ The database key is stored separately, so a rotation does not re-encrypt the database.`;
246
+
247
+ /** A recovery waiting on the delay, or held because a device contested it. */
248
+ function RecoveryNotice() {
249
+ const [notices] = useObservable(identityRecoveryNoticesVar);
250
+ const [busy, setBusy] = useState<string | null>(null);
251
+ const [error, setError] = useState<string | null>(null);
252
+ if (!notices || notices.length === 0) return null;
253
+
254
+ const act = async (label: string, action: () => Promise<void>) => {
255
+ setBusy(label);
256
+ setError(null);
257
+ try {
258
+ await action();
259
+ } catch (err) {
260
+ setError(err instanceof Error ? err.message : String(err));
261
+ } finally {
262
+ setBusy(null);
263
+ }
264
+ };
265
+
266
+ return (
267
+ <div className="card mb-3">
268
+ <div className="card-body">
269
+ <h5 className="card-title">Key recovery</h5>
270
+ {notices.map((notice) => (
271
+ <div key={`${notice.userId}:${notice.pendingPublicKey}`} className="mb-3">
272
+ <p className="mb-2">
273
+ {notice.own
274
+ ? "A new key was published for this account without your current key's signature."
275
+ : "A contact's key is being recovered."}{" "}
276
+ {notice.contested
277
+ ? "A device that holds the current key objects. The new key stays untrusted until you accept it."
278
+ : `Peers adopt it after ${new Date(notice.readyAt).toLocaleString()} unless someone contests it.`}
279
+ </p>
280
+ <code className="small text-break">{notice.pendingPublicKey}</code>
281
+ <div className="mt-2">
282
+ {notice.own ? (
283
+ <button
284
+ type="button"
285
+ className="btn btn-warning btn-sm"
286
+ disabled={busy !== null}
287
+ onClick={() =>
288
+ act(notice.pendingPublicKey, async () => {
289
+ const tools = await identityTools();
290
+ await tools.contestRecovery({ pendingKey: notice.pendingPublicKey });
291
+ })
292
+ }
293
+ >
294
+ {busy === notice.pendingPublicKey ? "Sending..." : "Contest"}
295
+ </button>
296
+ ) : (
297
+ <button
298
+ type="button"
299
+ className="btn btn-outline-primary btn-sm"
300
+ disabled={busy !== null}
301
+ onClick={() =>
302
+ act(notice.pendingPublicKey, async () => {
303
+ const tools = await identityTools();
304
+ await tools.acceptRecovery({
305
+ userId: notice.userId,
306
+ pendingKey: notice.pendingPublicKey,
307
+ });
308
+ })
309
+ }
310
+ >
311
+ {busy === notice.pendingPublicKey ? "Accepting..." : "Accept the new key"}
312
+ </button>
313
+ )}
314
+ </div>
315
+ </div>
316
+ ))}
317
+ {error && <p className="alert alert-danger mb-0">{error}</p>}
318
+ </div>
319
+ </div>
320
+ );
321
+ }
322
+
323
+ function bytesFromBase64Url(value: string): ArrayBuffer {
324
+ const padded = value.replace(/-/g, "+").replace(/_/g, "/");
325
+ const binary = atob(padded);
326
+ const bytes = new Uint8Array(binary.length);
327
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
328
+ return bytes.buffer;
329
+ }
330
+
331
+ function base64UrlFromBytes(buffer: ArrayBuffer): string {
332
+ const bytes = new Uint8Array(buffer);
333
+ let binary = "";
334
+ for (const byte of bytes) binary += String.fromCharCode(byte);
335
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
336
+ }
337
+
338
+ function credentialJson(credential: PublicKeyCredential): Record<string, unknown> {
339
+ const withJson = credential as PublicKeyCredential & { toJSON?: () => Record<string, unknown> };
340
+ if (typeof withJson.toJSON === "function") return withJson.toJSON();
341
+ const response = credential.response;
342
+ const body: Record<string, string> = {
343
+ clientDataJSON: base64UrlFromBytes(response.clientDataJSON),
344
+ };
345
+ if ("attestationObject" in response) {
346
+ body.attestationObject = base64UrlFromBytes(
347
+ (response as AuthenticatorAttestationResponse).attestationObject,
348
+ );
349
+ }
350
+ if ("authenticatorData" in response && "signature" in response) {
351
+ const assertion = response as AuthenticatorAssertionResponse;
352
+ body.authenticatorData = base64UrlFromBytes(assertion.authenticatorData);
353
+ body.signature = base64UrlFromBytes(assertion.signature);
354
+ }
355
+ return {
356
+ id: credential.id,
357
+ rawId: base64UrlFromBytes(credential.rawId),
358
+ type: credential.type,
359
+ response: body,
360
+ clientExtensionResults: credential.getClientExtensionResults(),
361
+ };
362
+ }
363
+
364
+ /** Add or remove an anchor by publishing the new list at the anchors already on record. */
365
+ function AnchorsCard() {
366
+ const info = usePromise(async () => (await identityTools()).getKeyInfo(), undefined, []);
367
+ const [anchors, setAnchors] = useState<IAnchorRef[] | null>(null);
368
+ const [url, setUrl] = useState("");
369
+ const [busy, setBusy] = useState(false);
370
+ const [error, setError] = useState<string | null>(null);
371
+ const shown = anchors ?? info?.anchors?.onRecord ?? [];
372
+
373
+ const publish = async (next: { type: "https" | "peers-services"; url: string }[]) => {
374
+ setBusy(true);
375
+ setError(null);
376
+ try {
377
+ const tools = await identityTools();
378
+ const saved = await tools.setAnchors({ anchors: next });
379
+ setAnchors(saved.anchors);
380
+ setUrl("");
381
+ } catch (err) {
382
+ setError(err instanceof Error ? err.message : String(err));
383
+ } finally {
384
+ setBusy(false);
385
+ }
386
+ };
387
+
388
+ return (
389
+ <div className="card mb-3">
390
+ <div className="card-body">
391
+ <h5 className="card-title">Anchors</h5>
392
+ <p className="text-muted">
393
+ Peers ask these places before they believe a different key. Adding one publishes the new
394
+ list at an anchor already here. An https anchor has to be updated first. Removing the last
395
+ one means a lost key cannot be recovered.
396
+ </p>
397
+ {shown.length === 0 && <p>None yet.</p>}
398
+ <ul className="list-unstyled">
399
+ {shown.map((anchor) => (
400
+ <li
401
+ key={`${anchor.type}:${anchor.url}`}
402
+ className="d-flex justify-content-between mb-2"
403
+ >
404
+ <code className="small text-break">{anchor.url}</code>
405
+ <button
406
+ type="button"
407
+ className="btn btn-outline-danger btn-sm ms-2"
408
+ disabled={busy}
409
+ onClick={() => publish(shown.filter((item) => item.url !== anchor.url))}
410
+ >
411
+ Remove
412
+ </button>
413
+ </li>
414
+ ))}
415
+ </ul>
416
+ <div className="input-group">
417
+ <input
418
+ className="form-control"
419
+ placeholder="https://example.com/.well-known/peers-identity.json"
420
+ value={url}
421
+ onChange={(event) => setUrl(event.target.value)}
422
+ />
423
+ <button
424
+ type="button"
425
+ className="btn btn-outline-primary"
426
+ disabled={busy || url.trim() === ""}
427
+ onClick={() => publish([...shown, { type: "https", url: url.trim() }])}
428
+ >
429
+ {busy ? "Publishing..." : "Add"}
430
+ </button>
431
+ </div>
432
+ {error && <p className="alert alert-danger mt-3 mb-0">{error}</p>}
433
+ </div>
434
+ </div>
435
+ );
436
+ }
437
+
438
+ /** Bind a passkey that can open an anchor-write session. */
439
+ function PasskeyCard() {
440
+ // A passkey is bound to a website. The Electron renderer loads from a file, which has no
441
+ // host; the registry refuses that origin and the browser would too.
442
+ const onWebsite = typeof window !== "undefined" && /^https?:$/.test(window.location.protocol);
443
+ const supported = onWebsite && typeof PublicKeyCredential !== "undefined";
444
+ const [busy, setBusy] = useState(false);
445
+ const [message, setMessage] = useState<string | null>(null);
446
+ const [error, setError] = useState<string | null>(null);
447
+
448
+ const run = async (action: "register" | "session") => {
449
+ setBusy(true);
450
+ setError(null);
451
+ setMessage(null);
452
+ try {
453
+ const tools = await identityTools();
454
+ const origin = window.location.origin;
455
+ if (action === "register") {
456
+ const started = await tools.passkeyRegisterStart({ origin });
457
+ const options = started.options as PublicKeyCredentialCreationOptionsJSON;
458
+ const credential = (await navigator.credentials.create({
459
+ publicKey: {
460
+ ...options,
461
+ challenge: bytesFromBase64Url(options.challenge),
462
+ user: { ...options.user, id: bytesFromBase64Url(options.user.id) },
463
+ excludeCredentials: options.excludeCredentials?.map((item) => ({
464
+ ...item,
465
+ id: bytesFromBase64Url(item.id),
466
+ })),
467
+ },
468
+ })) as PublicKeyCredential | null;
469
+ if (!credential) throw new Error("No passkey was created");
470
+ await tools.passkeyRegisterFinish({ response: credentialJson(credential) });
471
+ setMessage("Passkey saved. It can open an anchor session without the signing key.");
472
+ } else {
473
+ const started = await tools.passkeyAuthStart({ origin });
474
+ const options = started.options as PublicKeyCredentialRequestOptionsJSON;
475
+ const credential = (await navigator.credentials.get({
476
+ publicKey: {
477
+ ...options,
478
+ challenge: bytesFromBase64Url(options.challenge),
479
+ allowCredentials: options.allowCredentials?.map((item) => ({
480
+ ...item,
481
+ id: bytesFromBase64Url(item.id),
482
+ })),
483
+ },
484
+ })) as PublicKeyCredential | null;
485
+ if (!credential) throw new Error("No passkey was used");
486
+ const session = await tools.passkeyAuthFinish({ response: credentialJson(credential) });
487
+ const minutes = Math.round(session.expiresIn / 60);
488
+ setMessage(`Anchor session open for about ${minutes} minutes.`);
489
+ }
490
+ } catch (err) {
491
+ setError(err instanceof Error ? err.message : String(err));
492
+ } finally {
493
+ setBusy(false);
494
+ }
495
+ };
496
+
497
+ return (
498
+ <div className="card mb-3">
499
+ <div className="card-body">
500
+ <h5 className="card-title">Passkey</h5>
501
+ <p className="text-muted">
502
+ A passkey opens the same short session as the recovery email. It does not replace the
503
+ signing key. This page has to be served on the passkey's site.
504
+ </p>
505
+ {supported ? (
506
+ <div className="d-flex gap-2">
507
+ <button
508
+ type="button"
509
+ className="btn btn-outline-primary btn-sm"
510
+ disabled={busy}
511
+ onClick={() => run("register")}
512
+ >
513
+ {busy ? "Waiting..." : "Add a passkey"}
514
+ </button>
515
+ <button
516
+ type="button"
517
+ className="btn btn-outline-secondary btn-sm"
518
+ disabled={busy}
519
+ onClick={() => run("session")}
520
+ >
521
+ Use passkey
522
+ </button>
523
+ </div>
524
+ ) : (
525
+ <p className="mb-0">
526
+ {onWebsite
527
+ ? "This browser cannot create a passkey."
528
+ : "This window is not a website, so it cannot hold a passkey. Open your account in the web app to add or use one."}
529
+ </p>
530
+ )}
531
+ {message && <p className="mt-3 mb-0">{message}</p>}
532
+ {error && <p className="alert alert-danger mt-3 mb-0">{error}</p>}
533
+ </div>
534
+ </div>
535
+ );
536
+ }
537
+
538
+ type PublicKeyCredentialCreationOptionsJSON = {
539
+ challenge: string;
540
+ user: { id: string; name: string; displayName: string };
541
+ excludeCredentials?: {
542
+ id: string;
543
+ type: PublicKeyCredentialType;
544
+ transports?: AuthenticatorTransport[];
545
+ }[];
546
+ rp: PublicKeyCredentialRpEntity;
547
+ pubKeyCredParams: PublicKeyCredentialParameters[];
548
+ timeout?: number;
549
+ attestation?: AttestationConveyancePreference;
550
+ authenticatorSelection?: AuthenticatorSelectionCriteria;
551
+ };
552
+
553
+ type PublicKeyCredentialRequestOptionsJSON = {
554
+ challenge: string;
555
+ allowCredentials?: {
556
+ id: string;
557
+ type: PublicKeyCredentialType;
558
+ transports?: AuthenticatorTransport[];
559
+ }[];
560
+ timeout?: number;
561
+ rpId?: string;
562
+ userVerification?: UserVerificationRequirement;
563
+ };
564
+
565
+ /** Current signing key, previous keys, on-record anchors, and manual rotation. */
566
+ function SigningKeyCard() {
567
+ const info = usePromise(() => identityTools().then((tools) => tools.getKeyInfo()));
568
+ const [rotatedAway] = useObservable(identityRotatedAwayVar);
569
+ const [manualDocument, setManualDocument] = useState<string | null>(null);
570
+ const [busy, setBusy] = useState<"prepare" | "confirm" | null>(null);
571
+ const [error, setError] = useState<string | null>(null);
572
+
573
+ // Writes through every anchor that has a writer. When an anchor on record has none (an
574
+ // https document) the host hands the document back first; confirm polls until every
575
+ // anchor lists the new key. A committed rotation restarts the host.
576
+ const prepare = async () => {
577
+ setBusy("prepare");
578
+ setError(null);
579
+ try {
580
+ const tools = await identityTools();
581
+ const result = await tools.rotateKey({ publish: "anchors", confirm: false });
582
+ if (result.committed) return;
583
+ setManualDocument(JSON.stringify(result.manualDocument, null, 2));
584
+ } catch (err) {
585
+ setError(err instanceof Error ? err.message : String(err));
586
+ } finally {
587
+ setBusy(null);
588
+ }
589
+ };
590
+
591
+ const confirm = async () => {
592
+ setBusy("confirm");
593
+ setError(null);
594
+ try {
595
+ const tools = await identityTools();
596
+ await tools.rotateKey({ publish: "anchors", confirm: true });
597
+ } catch (err) {
598
+ setError(err instanceof Error ? err.message : String(err));
599
+ setBusy(null);
600
+ }
601
+ };
602
+
603
+ return (
604
+ <div className="card mb-3">
605
+ <div className="card-body">
606
+ <h5 className="card-title d-flex align-items-center gap-2">
607
+ Signing key
608
+ <Tooltip markdownContent={ROTATE_INFO} />
609
+ </h5>
610
+ {rotatedAway && (
611
+ <p className="alert alert-warning">
612
+ This device holds an old key. Pair it again. The anchors confirmed{" "}
613
+ <code>{rotatedAway.newPublicKey}</code>.
614
+ </p>
615
+ )}
616
+ {!info ? (
617
+ <LoadingIndicator />
618
+ ) : (
619
+ <>
620
+ <KeyStatusList info={info} />
621
+ {info.canRotate ? (
622
+ <div className="mt-3">
623
+ <button
624
+ type="button"
625
+ className="btn btn-outline-primary btn-sm"
626
+ onClick={prepare}
627
+ disabled={busy !== null}
628
+ >
629
+ {busy === "prepare" ? "Rotating..." : "Rotate signing key"}
630
+ </button>
631
+ {manualDocument && (
632
+ <>
633
+ <pre className="small mt-3 mb-2 text-break">{manualDocument}</pre>
634
+ <p className="small text-muted">
635
+ Place this document at every https anchor listed above, then confirm. This
636
+ device waits until every anchor lists the new key before it changes anything.
637
+ </p>
638
+ <button
639
+ type="button"
640
+ className="btn btn-primary btn-sm"
641
+ onClick={confirm}
642
+ disabled={busy !== null}
643
+ >
644
+ {busy === "confirm" ? "Waiting for the anchors..." : "I've published this"}
645
+ </button>
646
+ </>
647
+ )}
648
+ </div>
649
+ ) : (
650
+ <p className="alert alert-secondary mt-3 mb-0">
651
+ This host's identity is fixed at startup and cannot be rotated here.
652
+ </p>
653
+ )}
654
+ {error && <p className="alert alert-danger mt-3 mb-0">{error}</p>}
655
+ </>
656
+ )}
657
+ </div>
658
+ </div>
659
+ );
660
+ }
661
+
662
+ const RECOVERY_INFO = `### Recovery email
663
+
664
+ This mailbox is how Peers Services agrees to publish a new key. The code proves you can read
665
+ the mailbox. The signing key is still what other people verify.
666
+
667
+ Without a recovery email, Peers Services refuses a new key. **Prepare manual rotation** still
668
+ works if another anchor is on your profile. If you lose the signing key and have no recovery
669
+ email, there is no way back into this account.`;
670
+
671
+ /** Bind, confirm, and use a recovery email to publish a key rotation. */
672
+ function RecoveryEmailCard() {
673
+ const current = usePromise(() =>
674
+ identityTools()
675
+ .then((tools) => tools.getRecoveryEmail())
676
+ .catch(() => ({ email: undefined })),
677
+ );
678
+ const [email, setEmail] = useState("");
679
+ const [code, setCode] = useState("");
680
+ const [bound, setBound] = useState<string | null | undefined>(undefined);
681
+ const [busy, setBusy] = useState<string | null>(null);
682
+ const [error, setError] = useState<string | null>(null);
683
+ const [notice, setNotice] = useState<string | null>(null);
684
+ const shown = bound === undefined ? (current?.email ?? "") : (bound ?? "");
685
+
686
+ const run = async (label: string, action: () => Promise<string>) => {
687
+ setBusy(label);
688
+ setError(null);
689
+ setNotice(null);
690
+ try {
691
+ setNotice(await action());
692
+ } catch (err) {
693
+ setError(err instanceof Error ? err.message : String(err));
694
+ } finally {
695
+ setBusy(null);
696
+ }
697
+ };
698
+
699
+ return (
700
+ <div className="card mb-3">
701
+ <div className="card-body">
702
+ <h5 className="card-title d-flex align-items-center gap-2">
703
+ Recovery email
704
+ <Tooltip markdownContent={RECOVERY_INFO} />
705
+ </h5>
706
+ {shown ? (
707
+ <p className="mb-3">
708
+ Bound to <strong>{shown}</strong>
709
+ </p>
710
+ ) : (
711
+ <p className="text-muted mb-3">
712
+ Optional. Bind one if you want Peers Services to accept a new signing key.
713
+ </p>
714
+ )}
715
+ {!shown && (
716
+ <input
717
+ className="form-control form-control-sm mb-2"
718
+ type="email"
719
+ placeholder="you@example.com"
720
+ value={email}
721
+ onChange={(event) => setEmail(event.target.value)}
722
+ disabled={busy !== null}
723
+ />
724
+ )}
725
+ <input
726
+ className="form-control form-control-sm mb-2"
727
+ inputMode="numeric"
728
+ placeholder="Code from the email"
729
+ value={code}
730
+ onChange={(event) => setCode(event.target.value)}
731
+ disabled={busy !== null}
732
+ />
733
+ <div className="d-flex flex-wrap gap-2">
734
+ {!shown && (
735
+ <>
736
+ <button
737
+ type="button"
738
+ className="btn btn-outline-primary btn-sm"
739
+ disabled={busy !== null || email.trim() === ""}
740
+ onClick={() =>
741
+ run("bind", async () => {
742
+ const tools = await identityTools();
743
+ await tools.bindEmailStart({ email: email.trim() });
744
+ return `A code was sent to ${email.trim()}.`;
745
+ })
746
+ }
747
+ >
748
+ {busy === "bind" ? "Sending..." : "Send bind code"}
749
+ </button>
750
+ <button
751
+ type="button"
752
+ className="btn btn-primary btn-sm"
753
+ disabled={busy !== null || code.trim() === ""}
754
+ onClick={() =>
755
+ run("verify", async () => {
756
+ const tools = await identityTools();
757
+ const verified = await tools.bindEmailVerify({
758
+ email: email.trim(),
759
+ code: code.trim(),
760
+ });
761
+ setBound(verified.email);
762
+ setCode("");
763
+ return `Recovery email saved: ${verified.email}`;
764
+ })
765
+ }
766
+ >
767
+ {busy === "verify" ? "Checking..." : "Verify"}
768
+ </button>
769
+ </>
770
+ )}
771
+ {shown && (
772
+ <>
773
+ <button
774
+ type="button"
775
+ className="btn btn-outline-primary btn-sm"
776
+ disabled={busy !== null}
777
+ onClick={() =>
778
+ run("session", async () => {
779
+ const tools = await identityTools();
780
+ await tools.startAnchorSession();
781
+ return "A code was sent to the recovery email.";
782
+ })
783
+ }
784
+ >
785
+ {busy === "session" ? "Sending..." : "Send rotation code"}
786
+ </button>
787
+ <button
788
+ type="button"
789
+ className="btn btn-primary btn-sm"
790
+ disabled={busy !== null || code.trim() === ""}
791
+ onClick={() =>
792
+ run("confirm", async () => {
793
+ const tools = await identityTools();
794
+ const session = await tools.confirmAnchorSession({ code: code.trim() });
795
+ setCode("");
796
+ const minutes = Math.max(1, Math.round(session.expiresIn / 60));
797
+ return `Session open for about ${minutes} minutes. You can rotate now.`;
798
+ })
799
+ }
800
+ >
801
+ {busy === "confirm" ? "Checking..." : "Open session"}
802
+ </button>
803
+ <button
804
+ type="button"
805
+ className="btn btn-outline-secondary btn-sm"
806
+ disabled={busy !== null}
807
+ onClick={() =>
808
+ run("rotate", async () => {
809
+ const tools = await identityTools();
810
+ const result = await tools.rotateKey({ publish: "anchors" });
811
+ return `Rotated to ${result.newPublicKey}. This host will restart.`;
812
+ })
813
+ }
814
+ >
815
+ {busy === "rotate" ? "Rotating..." : "Rotate signing key"}
816
+ </button>
817
+ <button
818
+ type="button"
819
+ className="btn btn-link btn-sm"
820
+ disabled={busy !== null}
821
+ onClick={() =>
822
+ run("unbind", async () => {
823
+ const tools = await identityTools();
824
+ await tools.unbindEmail();
825
+ setBound(null);
826
+ return "Recovery email removed.";
827
+ })
828
+ }
829
+ >
830
+ Remove
831
+ </button>
832
+ </>
833
+ )}
834
+ </div>
835
+ {notice && <p className="alert alert-success mt-3 mb-0">{notice}</p>}
836
+ {error && <p className="alert alert-danger mt-3 mb-0">{error}</p>}
837
+ </div>
838
+ </div>
839
+ );
840
+ }
841
+
842
+ function KeyStatusList({ info }: { info: IIdentityKeyInfo }) {
843
+ const registryStatus = (publicKey: string) =>
844
+ info.registry?.keys?.find((k) => k.publicKey === publicKey)?.status;
845
+ return (
846
+ <div className="small">
847
+ <div className="d-flex align-items-center gap-2 flex-wrap">
848
+ <code className="text-break">{info.publicKey}</code>
849
+ <span className="badge text-bg-success">current</span>
850
+ <RegistryBadge status={registryStatus(info.publicKey)} />
851
+ </div>
852
+ {info.previousPublicKeys.length > 0 && (
853
+ <ul className="list-unstyled mt-2 mb-0 text-muted">
854
+ {[...info.previousPublicKeys].reverse().map((previous) => (
855
+ <li key={previous.publicKey} className="d-flex align-items-center gap-2 flex-wrap">
856
+ <code className="text-break">{previous.publicKey}</code>
857
+ <span className="badge text-bg-secondary">
858
+ {previous.reason === "rotation" || !previous.reason ? "retired" : "revoked"}
859
+ </span>
860
+ <RegistryBadge status={registryStatus(previous.publicKey)} />
861
+ <span>{new Date(previous.replacedAt).toLocaleDateString()}</span>
862
+ </li>
863
+ ))}
864
+ </ul>
865
+ )}
866
+ {info.anchors && info.anchors.onRecord.length > 0 && (
867
+ <ul className="list-unstyled mt-2 mb-0 text-muted">
868
+ {info.anchors.onRecord.map((anchor) => {
869
+ const view = info.anchors?.views.find((item) => item.url === anchor.url);
870
+ return (
871
+ <li key={anchor.url}>
872
+ <span className="badge text-bg-light me-2">{anchor.type}</span>
873
+ <code className="text-break">{anchor.url}</code>
874
+ {view?.error && <span className="ms-2">{view.error}</span>}
875
+ </li>
876
+ );
877
+ })}
878
+ </ul>
879
+ )}
880
+ <div className="text-muted mt-2">
881
+ {info.registry
882
+ ? info.registry.error
883
+ ? `Key registry at ${info.registry.url} is not reachable: ${info.registry.error}`
884
+ : info.registry.keys
885
+ ? `Key registry: ${info.registry.url}`
886
+ : `The key registry at ${info.registry.url} has no record of this account yet; it is created on the first rotation.`
887
+ : "No key registry is configured (peers-services disabled)."}
888
+ </div>
889
+ </div>
890
+ );
891
+ }
892
+
893
+ function RegistryBadge({ status }: { status?: string }) {
894
+ if (!status) return null;
895
+ const tone =
896
+ status === "active"
897
+ ? "text-bg-primary"
898
+ : status === "revoked"
899
+ ? "text-bg-danger"
900
+ : "text-bg-light";
901
+ return <span className={`badge ${tone}`}>registry: {status}</span>;
902
+ }
903
+
223
904
  function TechnicalValue(props: { label: string; value: string; help: string }) {
224
905
  const [copied, setCopied] = useState(false);
225
906
  const copy = async () => {