@peers-app/peers-ui 0.25.13 → 0.25.15
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.
|
|
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.
|
|
3
|
+
"version": "0.25.15",
|
|
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.
|
|
33
|
+
"@peers-app/peers-sdk": "^0.25.15",
|
|
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.
|
|
44
|
+
"@peers-app/peers-sdk": "0.25.15",
|
|
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.
|
|
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 {
|
|
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 () => {
|