local-first-auth-import-export 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/react.cjs ADDED
@@ -0,0 +1,1056 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/react/index.tsx
31
+ var react_exports = {};
32
+ __export(react_exports, {
33
+ EXPORT_FILE_TYPE: () => EXPORT_FILE_TYPE,
34
+ EXPORT_FILE_VERSION: () => EXPORT_FILE_VERSION,
35
+ ExportProfile: () => ExportProfile,
36
+ ImportExport: () => ImportExport,
37
+ ImportProfile: () => ImportProfile,
38
+ STORAGE_KEYS: () => STORAGE_KEYS,
39
+ clearProfile: () => clearProfile,
40
+ createDidFromPublicKey: () => createDidFromPublicKey,
41
+ createJWT: () => createJWT,
42
+ decodeJWT: () => decodeJWT,
43
+ decodePublicKey: () => decodePublicKey,
44
+ defaultExportFilename: () => defaultExportFilename,
45
+ deriveKeysFromPrivateKey: () => deriveKeysFromPrivateKey,
46
+ downloadProfile: () => downloadProfile,
47
+ exportProfile: () => exportProfile,
48
+ exportProfileToJSON: () => exportProfileToJSON,
49
+ getCurrentProfile: () => getCurrentProfile,
50
+ getPrivateKey: () => getPrivateKey,
51
+ hasLocalFirstAuthAPI: () => hasLocalFirstAuthAPI,
52
+ hasProfile: () => hasProfile,
53
+ importProfile: () => importProfile,
54
+ importProfileFromFile: () => importProfileFromFile,
55
+ injectLocalFirstAuthAPI: () => injectLocalFirstAuthAPI,
56
+ parseExportedProfile: () => parseExportedProfile,
57
+ removeLocalFirstAuthAPI: () => removeLocalFirstAuthAPI,
58
+ useProfile: () => useProfile,
59
+ validateExportedProfile: () => validateExportedProfile,
60
+ validatePrivateKey: () => validatePrivateKey,
61
+ verifyJWT: () => verifyJWT
62
+ });
63
+ module.exports = __toCommonJS(react_exports);
64
+
65
+ // src/types.ts
66
+ var EXPORT_FILE_TYPE = "local-first-auth:export";
67
+ var EXPORT_FILE_VERSION = 1;
68
+
69
+ // src/react/components/ImportProfile.tsx
70
+ var import_react = __toESM(require("react"), 1);
71
+
72
+ // src/core/crypto.ts
73
+ var ed25519 = __toESM(require("@stablelib/ed25519"), 1);
74
+ var import_base58_universal = require("base58-universal");
75
+ var base64 = __toESM(require("base64-js"), 1);
76
+ var ED25519_MULTICODEC_PREFIX = new Uint8Array([237, 1]);
77
+ var SECRET_KEY_SIZE = 64;
78
+ var base64url = {
79
+ encode: (input) => {
80
+ const base64String = base64.fromByteArray(input);
81
+ return base64String.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
82
+ },
83
+ decode: (input) => {
84
+ let base64String = input.replace(/-/g, "+").replace(/_/g, "/");
85
+ const padding = (4 - base64String.length % 4) % 4;
86
+ base64String += "=".repeat(padding);
87
+ return base64.toByteArray(base64String);
88
+ }
89
+ };
90
+ function createDidFromPublicKey(publicKey) {
91
+ const multicodecKey = new Uint8Array(ED25519_MULTICODEC_PREFIX.length + publicKey.length);
92
+ multicodecKey.set(ED25519_MULTICODEC_PREFIX);
93
+ multicodecKey.set(publicKey, ED25519_MULTICODEC_PREFIX.length);
94
+ const encoded = (0, import_base58_universal.encode)(multicodecKey);
95
+ return `did:key:z${encoded}`;
96
+ }
97
+ function validatePrivateKey(privateKey) {
98
+ if (typeof privateKey !== "string" || privateKey.trim() === "") {
99
+ return { valid: false, error: "Private key must be a non-empty string" };
100
+ }
101
+ let bytes;
102
+ try {
103
+ bytes = base64.toByteArray(privateKey.trim());
104
+ } catch {
105
+ return { valid: false, error: "Private key is not valid base64" };
106
+ }
107
+ if (bytes.length !== SECRET_KEY_SIZE) {
108
+ return {
109
+ valid: false,
110
+ error: `Private key must decode to ${SECRET_KEY_SIZE} bytes, got ${bytes.length}`
111
+ };
112
+ }
113
+ return { valid: true };
114
+ }
115
+ function deriveKeysFromPrivateKey(privateKey) {
116
+ const { valid, error } = validatePrivateKey(privateKey);
117
+ if (!valid) {
118
+ throw new Error(`Invalid private key: ${error}`);
119
+ }
120
+ const trimmed = privateKey.trim();
121
+ const secretKey = base64.toByteArray(trimmed);
122
+ const publicKey = ed25519.extractPublicKeyFromSecretKey(secretKey);
123
+ const did = createDidFromPublicKey(publicKey);
124
+ return {
125
+ did,
126
+ publicKey: base64.fromByteArray(publicKey),
127
+ privateKey: trimmed
128
+ };
129
+ }
130
+ function decodePublicKey(publicKey) {
131
+ return base64.toByteArray(publicKey);
132
+ }
133
+ async function createJWT(payload, privateKey) {
134
+ const privateKeyBytes = base64.toByteArray(privateKey);
135
+ if (privateKeyBytes.length !== SECRET_KEY_SIZE) {
136
+ throw new Error("Invalid private key length. Expected 64 bytes.");
137
+ }
138
+ const header = {
139
+ alg: "EdDSA",
140
+ typ: "JWT"
141
+ };
142
+ const headerB64 = base64url.encode(new TextEncoder().encode(JSON.stringify(header)));
143
+ const payloadB64 = base64url.encode(new TextEncoder().encode(JSON.stringify(payload)));
144
+ const signingInput = `${headerB64}.${payloadB64}`;
145
+ const signingInputBytes = new TextEncoder().encode(signingInput);
146
+ const signature = ed25519.sign(privateKeyBytes, signingInputBytes);
147
+ const signatureB64 = base64url.encode(signature);
148
+ return `${signingInput}.${signatureB64}`;
149
+ }
150
+ function decodeJWT(jwt) {
151
+ const parts = jwt.split(".");
152
+ if (parts.length !== 3) {
153
+ throw new Error("Invalid JWT format. Expected 3 parts separated by dots.");
154
+ }
155
+ const [encodedHeader, encodedPayload, signature] = parts;
156
+ if (!encodedHeader || !encodedPayload || !signature) {
157
+ throw new Error("Invalid JWT format: missing parts");
158
+ }
159
+ const headerBytes = base64url.decode(encodedHeader);
160
+ const payloadBytes = base64url.decode(encodedPayload);
161
+ const header = JSON.parse(new TextDecoder().decode(headerBytes));
162
+ const payload = JSON.parse(new TextDecoder().decode(payloadBytes));
163
+ return { header, payload, signature };
164
+ }
165
+ function verifyJWT(jwt, publicKey) {
166
+ try {
167
+ const parts = jwt.split(".");
168
+ if (parts.length !== 3) {
169
+ return false;
170
+ }
171
+ const [encodedHeader, encodedPayload, encodedSignature] = parts;
172
+ const signingInput = `${encodedHeader}.${encodedPayload}`;
173
+ const signingInputBytes = new TextEncoder().encode(signingInput);
174
+ const signatureBytes = base64url.decode(encodedSignature);
175
+ return ed25519.verify(publicKey, signingInputBytes, signatureBytes);
176
+ } catch (error) {
177
+ console.error("JWT verification error:", error);
178
+ return false;
179
+ }
180
+ }
181
+
182
+ // src/core/storage.ts
183
+ var STORAGE_KEYS = {
184
+ PROFILE: "local-first-auth:profile",
185
+ PRIVATE_KEY: "local-first-auth:privateKey"
186
+ };
187
+ function saveProfile(profile) {
188
+ try {
189
+ localStorage.setItem(STORAGE_KEYS.PROFILE, JSON.stringify(profile));
190
+ } catch (error) {
191
+ console.error("Failed to save profile:", error);
192
+ throw new Error("Failed to save profile to LocalStorage");
193
+ }
194
+ }
195
+ function getProfile() {
196
+ try {
197
+ const profileString = localStorage.getItem(STORAGE_KEYS.PROFILE);
198
+ if (!profileString) {
199
+ return null;
200
+ }
201
+ return JSON.parse(profileString);
202
+ } catch (error) {
203
+ console.error("Failed to get profile:", error);
204
+ return null;
205
+ }
206
+ }
207
+ function savePrivateKey(privateKey) {
208
+ try {
209
+ localStorage.setItem(STORAGE_KEYS.PRIVATE_KEY, privateKey);
210
+ } catch (error) {
211
+ console.error("Failed to save private key:", error);
212
+ throw new Error("Failed to save private key to LocalStorage");
213
+ }
214
+ }
215
+ function getPrivateKey() {
216
+ try {
217
+ return localStorage.getItem(STORAGE_KEYS.PRIVATE_KEY);
218
+ } catch (error) {
219
+ console.error("Failed to get private key:", error);
220
+ return null;
221
+ }
222
+ }
223
+ function clearProfile() {
224
+ try {
225
+ localStorage.removeItem(STORAGE_KEYS.PROFILE);
226
+ localStorage.removeItem(STORAGE_KEYS.PRIVATE_KEY);
227
+ } catch (error) {
228
+ console.error("Failed to clear profile:", error);
229
+ }
230
+ }
231
+ function hasProfile() {
232
+ return getProfile() !== null && getPrivateKey() !== null;
233
+ }
234
+ function getCurrentProfile() {
235
+ const storedProfile = getProfile();
236
+ const privateKey = getPrivateKey();
237
+ if (!storedProfile || !privateKey) {
238
+ return null;
239
+ }
240
+ return {
241
+ did: storedProfile.did,
242
+ name: storedProfile.name,
243
+ socials: storedProfile.socials,
244
+ avatar: storedProfile.avatar
245
+ };
246
+ }
247
+
248
+ // src/core/api.ts
249
+ var LocalFirstAuthAPI = class {
250
+ /**
251
+ * Get profile details as a signed JWT
252
+ */
253
+ async getProfileDetails() {
254
+ const profile = getProfile();
255
+ const privateKey = getPrivateKey();
256
+ if (!profile || !privateKey) {
257
+ throw new Error("No profile found. User must create or import a profile first.");
258
+ }
259
+ const now = Math.floor(Date.now() / 1e3);
260
+ const payload = {
261
+ iss: profile.did,
262
+ aud: window.location.origin,
263
+ iat: now,
264
+ exp: now + 120,
265
+ // 2 minutes
266
+ type: "localFirstAuth:profile:details",
267
+ data: {
268
+ did: profile.did,
269
+ name: profile.name,
270
+ socials: profile.socials || []
271
+ }
272
+ };
273
+ return createJWT(payload, privateKey);
274
+ }
275
+ /**
276
+ * Get avatar as base64-encoded string in a signed JWT
277
+ */
278
+ async getAvatar() {
279
+ const profile = getProfile();
280
+ const privateKey = getPrivateKey();
281
+ if (!profile || !privateKey) {
282
+ throw new Error("No profile found. User must create or import a profile first.");
283
+ }
284
+ if (!profile.avatar) {
285
+ return null;
286
+ }
287
+ const now = Math.floor(Date.now() / 1e3);
288
+ const payload = {
289
+ iss: profile.did,
290
+ aud: window.location.origin,
291
+ iat: now,
292
+ exp: now + 120,
293
+ type: "localFirstAuth:avatar",
294
+ data: {
295
+ did: profile.did,
296
+ avatar: profile.avatar
297
+ }
298
+ };
299
+ return createJWT(payload, privateKey);
300
+ }
301
+ getAppDetails() {
302
+ return {
303
+ name: "Local First Auth",
304
+ version: "2.0.0",
305
+ platform: "browser",
306
+ supportedPermissions: ["profile"]
307
+ };
308
+ }
309
+ async requestPermission(permission) {
310
+ if (permission === "profile") {
311
+ return true;
312
+ }
313
+ console.warn(`Permission "${permission}" is not yet supported`);
314
+ return false;
315
+ }
316
+ close() {
317
+ console.log("close() called - no-op in web version");
318
+ }
319
+ };
320
+ function injectLocalFirstAuthAPI() {
321
+ if (typeof window === "undefined") {
322
+ console.warn("Cannot inject Local First Auth API: window is undefined (not in browser)");
323
+ return;
324
+ }
325
+ if (window.localFirstAuth) {
326
+ return;
327
+ }
328
+ ;
329
+ window.localFirstAuth = new LocalFirstAuthAPI();
330
+ }
331
+ function removeLocalFirstAuthAPI() {
332
+ if (typeof window === "undefined") {
333
+ return;
334
+ }
335
+ delete window.localFirstAuth;
336
+ }
337
+ function hasLocalFirstAuthAPI() {
338
+ return typeof window !== "undefined" && !!window.localFirstAuth;
339
+ }
340
+
341
+ // src/core/import.ts
342
+ function validateExportedProfile(input) {
343
+ const errors = [];
344
+ let value = input;
345
+ if (typeof value === "string") {
346
+ try {
347
+ value = JSON.parse(value);
348
+ } catch {
349
+ return { valid: false, errors: ["File is not valid JSON"] };
350
+ }
351
+ }
352
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
353
+ return { valid: false, errors: ["Expected a JSON object"] };
354
+ }
355
+ const candidate = value;
356
+ if (candidate.type !== EXPORT_FILE_TYPE) {
357
+ errors.push(
358
+ `Not a Local First Auth profile export (expected type "${EXPORT_FILE_TYPE}", got ${JSON.stringify(candidate.type)})`
359
+ );
360
+ }
361
+ if (candidate.version !== EXPORT_FILE_VERSION) {
362
+ errors.push(
363
+ `Unsupported export version: ${JSON.stringify(candidate.version)} (this package supports version ${EXPORT_FILE_VERSION})`
364
+ );
365
+ }
366
+ const keyCheck = validatePrivateKey(candidate.privateKey);
367
+ if (!keyCheck.valid) {
368
+ errors.push(keyCheck.error);
369
+ return { valid: false, errors };
370
+ }
371
+ const derived = deriveKeysFromPrivateKey(candidate.privateKey);
372
+ if (typeof candidate.did !== "string" || candidate.did !== derived.did) {
373
+ errors.push(
374
+ `Profile file is inconsistent: "did" does not match the private key (expected ${derived.did})`
375
+ );
376
+ }
377
+ if (typeof candidate.publicKey !== "string" || candidate.publicKey !== derived.publicKey) {
378
+ errors.push('Profile file is inconsistent: "publicKey" does not match the private key');
379
+ }
380
+ if (candidate.name !== void 0 && typeof candidate.name !== "string") {
381
+ errors.push('"name" must be a string');
382
+ }
383
+ if (candidate.socials !== void 0) {
384
+ if (!Array.isArray(candidate.socials)) {
385
+ errors.push('"socials" must be an array');
386
+ } else {
387
+ const malformed = candidate.socials.some(
388
+ (s) => typeof s !== "object" || s === null || typeof s.platform !== "string" || typeof s.handle !== "string"
389
+ );
390
+ if (malformed) {
391
+ errors.push('"socials" entries must each be { platform, handle }');
392
+ }
393
+ }
394
+ }
395
+ if (candidate.avatar !== void 0 && candidate.avatar !== null && typeof candidate.avatar !== "string") {
396
+ errors.push('"avatar" must be a string or null');
397
+ }
398
+ if (errors.length > 0) {
399
+ return { valid: false, errors };
400
+ }
401
+ return {
402
+ valid: true,
403
+ errors: [],
404
+ profile: candidate
405
+ };
406
+ }
407
+ function parseExportedProfile(input) {
408
+ if (typeof input === "string") {
409
+ const trimmed = input.trim();
410
+ if (!trimmed) {
411
+ throw new Error("Nothing to import: input is empty");
412
+ }
413
+ const looksLikeJSON = trimmed.startsWith("{");
414
+ if (!looksLikeJSON) {
415
+ const keyCheck = validatePrivateKey(trimmed);
416
+ if (!keyCheck.valid) {
417
+ throw new Error(
418
+ `Could not import: input is neither a profile export file nor a valid private key (${keyCheck.error})`
419
+ );
420
+ }
421
+ const { did, publicKey, privateKey } = deriveKeysFromPrivateKey(trimmed);
422
+ return {
423
+ type: EXPORT_FILE_TYPE,
424
+ version: EXPORT_FILE_VERSION,
425
+ did,
426
+ publicKey,
427
+ privateKey
428
+ };
429
+ }
430
+ }
431
+ const result = validateExportedProfile(input);
432
+ if (!result.valid || !result.profile) {
433
+ throw new Error(result.errors.join("; "));
434
+ }
435
+ return result.profile;
436
+ }
437
+ async function importProfile(input, options = {}) {
438
+ const exported = parseExportedProfile(input);
439
+ const { did, privateKey } = deriveKeysFromPrivateKey(exported.privateKey);
440
+ if (hasProfile() && !options.overwrite) {
441
+ const existing = getProfile();
442
+ if (existing && existing.did !== did) {
443
+ throw new Error(
444
+ "A different profile already exists on this device. Pass { overwrite: true } to replace it \u2014 this permanently discards the existing private key."
445
+ );
446
+ }
447
+ }
448
+ const profile = {
449
+ did,
450
+ name: exported.name || "anonymous",
451
+ socials: exported.socials ?? [],
452
+ avatar: exported.avatar ?? null
453
+ };
454
+ saveProfile({
455
+ did: profile.did,
456
+ name: profile.name,
457
+ socials: profile.socials,
458
+ avatar: profile.avatar
459
+ });
460
+ savePrivateKey(privateKey);
461
+ injectLocalFirstAuthAPI();
462
+ return profile;
463
+ }
464
+ async function importProfileFromFile(file, options = {}) {
465
+ const text = await file.text();
466
+ return importProfile(text, options);
467
+ }
468
+
469
+ // src/react/styles.ts
470
+ var DANGER_COLOR = "#ff3b30";
471
+ var WARNING_BG = "#fff8e1";
472
+ var WARNING_BORDER = "#f0c36d";
473
+ function resolveTheme(customStyles = {}) {
474
+ const {
475
+ primaryColor = "#403B51",
476
+ backgroundColor = "#ffffff",
477
+ textColor = "#403B51",
478
+ borderRadius = "12px",
479
+ fontFamily = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
480
+ buttonRadius = "12px",
481
+ inputRadius = "8px",
482
+ inputTextColor = "#333333",
483
+ inputBackgroundColor = "#ffffff",
484
+ mobileButtonPressScale = 0.95,
485
+ mobileTapHighlightColor = "transparent",
486
+ useSafeAreaInsets = true
487
+ } = customStyles;
488
+ return {
489
+ primaryColor,
490
+ backgroundColor,
491
+ textColor,
492
+ borderRadius,
493
+ fontFamily,
494
+ buttonRadius,
495
+ inputRadius,
496
+ inputTextColor,
497
+ inputBackgroundColor,
498
+ mobileButtonPressScale,
499
+ mobileTapHighlightColor,
500
+ useSafeAreaInsets
501
+ };
502
+ }
503
+ function containerPadding(useSafeAreaInsets) {
504
+ return useSafeAreaInsets ? "calc(48px + env(safe-area-inset-top)) calc(20px + env(safe-area-inset-right)) calc(48px + env(safe-area-inset-bottom)) calc(20px + env(safe-area-inset-left))" : "48px 20px";
505
+ }
506
+ function monoBoxStyle(theme) {
507
+ return {
508
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
509
+ fontSize: "12px",
510
+ lineHeight: 1.5,
511
+ wordBreak: "break-all",
512
+ padding: "12px",
513
+ borderRadius: theme.inputRadius,
514
+ backgroundColor: "rgba(0, 0, 0, 0.04)",
515
+ border: "1px solid rgba(0, 0, 0, 0.08)",
516
+ color: theme.inputTextColor,
517
+ boxSizing: "border-box"
518
+ };
519
+ }
520
+ function primaryButtonStyle(theme, pressed, disabled = false) {
521
+ return {
522
+ width: "100%",
523
+ padding: "16px",
524
+ fontSize: "16px",
525
+ fontWeight: 600,
526
+ color: "#ffffff",
527
+ backgroundColor: theme.primaryColor,
528
+ border: "none",
529
+ borderRadius: theme.buttonRadius,
530
+ cursor: disabled ? "not-allowed" : "pointer",
531
+ opacity: disabled ? 0.5 : 1,
532
+ fontFamily: theme.fontFamily,
533
+ transform: pressed && !disabled ? `scale(${theme.mobileButtonPressScale})` : "scale(1)",
534
+ transition: "transform 0.1s ease, opacity 0.2s",
535
+ WebkitTapHighlightColor: theme.mobileTapHighlightColor
536
+ };
537
+ }
538
+ function secondaryButtonStyle(theme, pressed) {
539
+ return {
540
+ width: "100%",
541
+ padding: "14px",
542
+ fontSize: "15px",
543
+ fontWeight: 500,
544
+ color: theme.primaryColor,
545
+ backgroundColor: "transparent",
546
+ border: `2px solid ${theme.primaryColor}`,
547
+ borderRadius: theme.buttonRadius,
548
+ cursor: "pointer",
549
+ fontFamily: theme.fontFamily,
550
+ transform: pressed ? `scale(${theme.mobileButtonPressScale})` : "scale(1)",
551
+ transition: "transform 0.1s ease",
552
+ WebkitTapHighlightColor: theme.mobileTapHighlightColor
553
+ };
554
+ }
555
+
556
+ // src/react/components/ImportProfile.tsx
557
+ function ImportProfile({ customStyles = {}, onComplete, onBack }) {
558
+ const theme = resolveTheme(customStyles);
559
+ const fileInputRef = (0, import_react.useRef)(null);
560
+ const [text, setText] = (0, import_react.useState)("");
561
+ const [errors, setErrors] = (0, import_react.useState)([]);
562
+ const [pressed, setPressed] = (0, import_react.useState)(null);
563
+ const [confirmOverwrite, setConfirmOverwrite] = (0, import_react.useState)(false);
564
+ const [isImporting, setIsImporting] = (0, import_react.useState)(false);
565
+ const [imported, setImported] = (0, import_react.useState)(null);
566
+ const existing = hasProfile() ? getCurrentProfile() : null;
567
+ const runImport = async (input) => {
568
+ setErrors([]);
569
+ setIsImporting(true);
570
+ try {
571
+ const profile = await importProfile(input, { overwrite: confirmOverwrite });
572
+ setImported(profile);
573
+ onComplete?.(profile);
574
+ } catch (error) {
575
+ setErrors([error instanceof Error ? error.message : String(error)]);
576
+ } finally {
577
+ setIsImporting(false);
578
+ }
579
+ };
580
+ const handleFile = async (event) => {
581
+ const file = event.target.files?.[0];
582
+ if (!file) return;
583
+ try {
584
+ const contents = await file.text();
585
+ setText(contents);
586
+ await runImport(contents);
587
+ } catch {
588
+ setErrors(["Could not read that file"]);
589
+ } finally {
590
+ if (fileInputRef.current) fileInputRef.current.value = "";
591
+ }
592
+ };
593
+ const handleSubmit = (event) => {
594
+ event.preventDefault();
595
+ if (!text.trim()) {
596
+ setErrors(["Paste your profile backup, or choose a file"]);
597
+ return;
598
+ }
599
+ void runImport(text);
600
+ };
601
+ const blockedByOverwrite = !!existing && !confirmOverwrite;
602
+ if (imported) {
603
+ return /* @__PURE__ */ import_react.default.createElement(
604
+ "div",
605
+ {
606
+ style: {
607
+ display: "flex",
608
+ flexDirection: "column",
609
+ alignItems: "center",
610
+ justifyContent: "center",
611
+ padding: containerPadding(theme.useSafeAreaInsets),
612
+ backgroundColor: theme.backgroundColor,
613
+ color: theme.textColor,
614
+ fontFamily: theme.fontFamily,
615
+ minHeight: "100vh",
616
+ textAlign: "center",
617
+ boxSizing: "border-box"
618
+ }
619
+ },
620
+ /* @__PURE__ */ import_react.default.createElement("div", { style: { width: "100%", maxWidth: "500px" } }, /* @__PURE__ */ import_react.default.createElement("h1", { style: { fontSize: "28px", color: theme.primaryColor, marginBottom: "12px" } }, "Profile imported"), /* @__PURE__ */ import_react.default.createElement("p", { style: { opacity: 0.7, marginBottom: "16px" } }, "You're signed in as ", /* @__PURE__ */ import_react.default.createElement("strong", null, imported.name), "."), /* @__PURE__ */ import_react.default.createElement(
621
+ "div",
622
+ {
623
+ style: {
624
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
625
+ fontSize: "12px",
626
+ wordBreak: "break-all",
627
+ opacity: 0.6
628
+ }
629
+ },
630
+ imported.did
631
+ ))
632
+ );
633
+ }
634
+ return /* @__PURE__ */ import_react.default.createElement(
635
+ "div",
636
+ {
637
+ style: {
638
+ display: "flex",
639
+ flexDirection: "column",
640
+ alignItems: "center",
641
+ padding: containerPadding(theme.useSafeAreaInsets),
642
+ backgroundColor: theme.backgroundColor,
643
+ color: theme.textColor,
644
+ fontFamily: theme.fontFamily,
645
+ minHeight: "100vh",
646
+ boxSizing: "border-box"
647
+ }
648
+ },
649
+ /* @__PURE__ */ import_react.default.createElement("div", { style: { width: "100%", maxWidth: "500px" } }, /* @__PURE__ */ import_react.default.createElement("header", { style: { textAlign: "center", marginBottom: "32px" } }, /* @__PURE__ */ import_react.default.createElement(
650
+ "h1",
651
+ {
652
+ style: {
653
+ fontSize: "32px",
654
+ fontWeight: "bold",
655
+ color: theme.primaryColor,
656
+ marginBottom: "8px",
657
+ lineHeight: 1.2
658
+ }
659
+ },
660
+ "Import your profile"
661
+ ), /* @__PURE__ */ import_react.default.createElement("p", { style: { fontSize: "16px", opacity: 0.7, marginTop: "12px" } }, "Restore an identity you already have. Your existing DID is preserved.")), existing && /* @__PURE__ */ import_react.default.createElement(
662
+ "div",
663
+ {
664
+ style: {
665
+ padding: "14px",
666
+ marginBottom: "24px",
667
+ backgroundColor: WARNING_BG,
668
+ border: `1px solid ${WARNING_BORDER}`,
669
+ borderRadius: theme.borderRadius,
670
+ fontSize: "14px",
671
+ lineHeight: 1.5,
672
+ color: "#5c4400"
673
+ }
674
+ },
675
+ /* @__PURE__ */ import_react.default.createElement("p", { style: { margin: "0 0 10px" } }, "\u26A0\uFE0F A profile already exists on this device (", /* @__PURE__ */ import_react.default.createElement("strong", null, existing.name), "). Importing a different one permanently discards its private key."),
676
+ /* @__PURE__ */ import_react.default.createElement("label", { style: { display: "flex", gap: "8px", alignItems: "flex-start", cursor: "pointer" } }, /* @__PURE__ */ import_react.default.createElement(
677
+ "input",
678
+ {
679
+ type: "checkbox",
680
+ checked: confirmOverwrite,
681
+ onChange: (e) => setConfirmOverwrite(e.target.checked),
682
+ style: { marginTop: "2px" }
683
+ }
684
+ ), /* @__PURE__ */ import_react.default.createElement("span", null, "I understand \u2014 replace the existing profile"))
685
+ ), /* @__PURE__ */ import_react.default.createElement("form", { onSubmit: handleSubmit, style: { display: "flex", flexDirection: "column", gap: "20px" } }, /* @__PURE__ */ import_react.default.createElement("div", { style: { display: "flex", flexDirection: "column", gap: "8px" } }, /* @__PURE__ */ import_react.default.createElement("label", { style: { fontSize: "14px", fontWeight: 500, opacity: 0.8, paddingLeft: "4px" } }, "Backup file"), /* @__PURE__ */ import_react.default.createElement(
686
+ "input",
687
+ {
688
+ ref: fileInputRef,
689
+ type: "file",
690
+ accept: ".json,application/json",
691
+ onChange: handleFile,
692
+ disabled: blockedByOverwrite || isImporting,
693
+ style: {
694
+ width: "100%",
695
+ padding: "14px",
696
+ fontSize: "15px",
697
+ color: theme.inputTextColor,
698
+ backgroundColor: theme.inputBackgroundColor,
699
+ border: "2px dashed rgba(0, 0, 0, 0.15)",
700
+ borderRadius: theme.inputRadius,
701
+ fontFamily: theme.fontFamily,
702
+ boxSizing: "border-box",
703
+ cursor: blockedByOverwrite || isImporting ? "not-allowed" : "pointer",
704
+ opacity: blockedByOverwrite ? 0.5 : 1
705
+ }
706
+ }
707
+ )), /* @__PURE__ */ import_react.default.createElement("div", { style: { textAlign: "center", fontSize: "13px", opacity: 0.5 } }, "or paste it"), /* @__PURE__ */ import_react.default.createElement("div", { style: { display: "flex", flexDirection: "column", gap: "8px" } }, /* @__PURE__ */ import_react.default.createElement(
708
+ "textarea",
709
+ {
710
+ value: text,
711
+ onChange: (e) => setText(e.target.value),
712
+ disabled: blockedByOverwrite || isImporting,
713
+ rows: 6,
714
+ placeholder: '{ "type": "local-first-auth:export", "version": 1, ... }',
715
+ style: {
716
+ width: "100%",
717
+ padding: "14px",
718
+ fontSize: "13px",
719
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
720
+ color: theme.inputTextColor,
721
+ backgroundColor: theme.inputBackgroundColor,
722
+ border: `2px solid ${errors.length ? DANGER_COLOR : "rgba(0, 0, 0, 0.1)"}`,
723
+ borderRadius: theme.inputRadius,
724
+ outline: "none",
725
+ resize: "vertical",
726
+ boxSizing: "border-box",
727
+ opacity: blockedByOverwrite ? 0.5 : 1
728
+ }
729
+ }
730
+ )), errors.length > 0 && /* @__PURE__ */ import_react.default.createElement("div", { role: "alert", style: { color: DANGER_COLOR, fontSize: "14px", lineHeight: 1.5 } }, errors.map((error, i) => /* @__PURE__ */ import_react.default.createElement("p", { key: i, style: { margin: "0 0 4px" } }, error))), /* @__PURE__ */ import_react.default.createElement(
731
+ "button",
732
+ {
733
+ type: "submit",
734
+ disabled: blockedByOverwrite || isImporting,
735
+ onMouseDown: () => setPressed("import"),
736
+ onMouseUp: () => setPressed(null),
737
+ onMouseLeave: () => setPressed(null),
738
+ onTouchStart: () => setPressed("import"),
739
+ onTouchEnd: () => setPressed(null),
740
+ style: primaryButtonStyle(theme, pressed === "import", blockedByOverwrite || isImporting)
741
+ },
742
+ isImporting ? "Importing\u2026" : "Import profile"
743
+ ), onBack && /* @__PURE__ */ import_react.default.createElement(
744
+ "button",
745
+ {
746
+ type: "button",
747
+ onClick: onBack,
748
+ onMouseDown: () => setPressed("back"),
749
+ onMouseUp: () => setPressed(null),
750
+ onMouseLeave: () => setPressed(null),
751
+ style: secondaryButtonStyle(theme, pressed === "back")
752
+ },
753
+ "Back"
754
+ )))
755
+ );
756
+ }
757
+
758
+ // src/react/components/ExportProfile.tsx
759
+ var import_react2 = __toESM(require("react"), 1);
760
+
761
+ // src/core/export.ts
762
+ function exportProfile() {
763
+ const profile = getProfile();
764
+ const privateKey = getPrivateKey();
765
+ if (!profile || !privateKey) {
766
+ return null;
767
+ }
768
+ const { did, publicKey } = deriveKeysFromPrivateKey(privateKey);
769
+ return {
770
+ type: EXPORT_FILE_TYPE,
771
+ version: EXPORT_FILE_VERSION,
772
+ did,
773
+ publicKey,
774
+ privateKey,
775
+ name: profile.name,
776
+ socials: profile.socials ?? [],
777
+ avatar: profile.avatar ?? null,
778
+ exportedAt: (/* @__PURE__ */ new Date()).toISOString()
779
+ };
780
+ }
781
+ function exportProfileToJSON(pretty = true) {
782
+ const exported = exportProfile();
783
+ if (!exported) {
784
+ return null;
785
+ }
786
+ return JSON.stringify(exported, null, pretty ? 2 : 0);
787
+ }
788
+ function defaultExportFilename(did) {
789
+ const key = did.replace(/^did:key:/, "");
790
+ const shortKey = key.slice(0, 12).replace(/[^a-zA-Z0-9]/g, "");
791
+ return `local-first-auth-profile-${shortKey}.json`;
792
+ }
793
+ function downloadProfile(filename) {
794
+ if (typeof window === "undefined" || typeof document === "undefined") {
795
+ console.warn("Cannot download profile: not in a browser environment");
796
+ return null;
797
+ }
798
+ const exported = exportProfile();
799
+ if (!exported) {
800
+ console.warn("Cannot download profile: no profile found");
801
+ return null;
802
+ }
803
+ const json = JSON.stringify(exported, null, 2);
804
+ const blob = new Blob([json], { type: "application/json" });
805
+ const url = URL.createObjectURL(blob);
806
+ const anchor = document.createElement("a");
807
+ anchor.href = url;
808
+ anchor.download = filename || defaultExportFilename(exported.did);
809
+ anchor.style.display = "none";
810
+ document.body.appendChild(anchor);
811
+ anchor.click();
812
+ document.body.removeChild(anchor);
813
+ URL.revokeObjectURL(url);
814
+ return exported;
815
+ }
816
+
817
+ // src/react/components/ExportProfile.tsx
818
+ function ExportProfile({ customStyles = {}, onExport, emptyState }) {
819
+ const theme = resolveTheme(customStyles);
820
+ const [revealed, setRevealed] = (0, import_react2.useState)(false);
821
+ const [pressed, setPressed] = (0, import_react2.useState)(null);
822
+ const [downloaded, setDownloaded] = (0, import_react2.useState)(false);
823
+ const profile = exportProfile();
824
+ if (!profile) {
825
+ if (emptyState !== void 0) {
826
+ return /* @__PURE__ */ import_react2.default.createElement(import_react2.default.Fragment, null, emptyState);
827
+ }
828
+ return /* @__PURE__ */ import_react2.default.createElement(
829
+ "div",
830
+ {
831
+ style: {
832
+ padding: containerPadding(theme.useSafeAreaInsets),
833
+ backgroundColor: theme.backgroundColor,
834
+ color: theme.textColor,
835
+ fontFamily: theme.fontFamily,
836
+ textAlign: "center"
837
+ }
838
+ },
839
+ /* @__PURE__ */ import_react2.default.createElement("p", { style: { opacity: 0.7 } }, "No profile on this device yet \u2014 create or import one first.")
840
+ );
841
+ }
842
+ const handleDownload = () => {
843
+ const exported = downloadProfile();
844
+ if (exported) {
845
+ setDownloaded(true);
846
+ onExport?.(exported);
847
+ }
848
+ };
849
+ return /* @__PURE__ */ import_react2.default.createElement(
850
+ "div",
851
+ {
852
+ style: {
853
+ display: "flex",
854
+ flexDirection: "column",
855
+ alignItems: "center",
856
+ padding: containerPadding(theme.useSafeAreaInsets),
857
+ backgroundColor: theme.backgroundColor,
858
+ color: theme.textColor,
859
+ fontFamily: theme.fontFamily,
860
+ minHeight: "100vh",
861
+ boxSizing: "border-box"
862
+ }
863
+ },
864
+ /* @__PURE__ */ import_react2.default.createElement("div", { style: { width: "100%", maxWidth: "500px" } }, /* @__PURE__ */ import_react2.default.createElement("header", { style: { textAlign: "center", marginBottom: "32px" } }, /* @__PURE__ */ import_react2.default.createElement(
865
+ "h1",
866
+ {
867
+ style: {
868
+ fontSize: "32px",
869
+ fontWeight: "bold",
870
+ color: theme.primaryColor,
871
+ marginBottom: "8px",
872
+ lineHeight: 1.2
873
+ }
874
+ },
875
+ "Export your profile"
876
+ ), /* @__PURE__ */ import_react2.default.createElement("p", { style: { fontSize: "16px", opacity: 0.7, marginTop: "12px" } }, "Save a backup file so you can restore this identity on another device.")), /* @__PURE__ */ import_react2.default.createElement(
877
+ "div",
878
+ {
879
+ role: "note",
880
+ style: {
881
+ display: "flex",
882
+ gap: "10px",
883
+ padding: "14px",
884
+ marginBottom: "24px",
885
+ backgroundColor: WARNING_BG,
886
+ border: `1px solid ${WARNING_BORDER}`,
887
+ borderRadius: theme.borderRadius,
888
+ fontSize: "14px",
889
+ lineHeight: 1.5,
890
+ color: "#5c4400"
891
+ }
892
+ },
893
+ /* @__PURE__ */ import_react2.default.createElement("span", { "aria-hidden": "true" }, "\u26A0\uFE0F"),
894
+ /* @__PURE__ */ import_react2.default.createElement("span", null, "This file contains your ", /* @__PURE__ */ import_react2.default.createElement("strong", null, "private key"), " in plain text. Anyone who has it can act as you. Store it somewhere safe and never share it.")
895
+ ), /* @__PURE__ */ import_react2.default.createElement("section", { style: { display: "flex", flexDirection: "column", gap: "20px" } }, /* @__PURE__ */ import_react2.default.createElement("div", { style: { display: "flex", flexDirection: "column", gap: "8px" } }, /* @__PURE__ */ import_react2.default.createElement("label", { style: { fontSize: "14px", fontWeight: 500, opacity: 0.8, paddingLeft: "4px" } }, "Name"), /* @__PURE__ */ import_react2.default.createElement("div", { style: monoBoxStyle(theme) }, profile.name || "anonymous")), /* @__PURE__ */ import_react2.default.createElement("div", { style: { display: "flex", flexDirection: "column", gap: "8px" } }, /* @__PURE__ */ import_react2.default.createElement("label", { style: { fontSize: "14px", fontWeight: 500, opacity: 0.8, paddingLeft: "4px" } }, "DID (public \u2014 safe to share)"), /* @__PURE__ */ import_react2.default.createElement("div", { style: monoBoxStyle(theme) }, profile.did)), /* @__PURE__ */ import_react2.default.createElement("div", { style: { display: "flex", flexDirection: "column", gap: "8px" } }, /* @__PURE__ */ import_react2.default.createElement("label", { style: { fontSize: "14px", fontWeight: 500, opacity: 0.8, paddingLeft: "4px" } }, "Private key (secret)"), revealed ? /* @__PURE__ */ import_react2.default.createElement("div", { style: { ...monoBoxStyle(theme), border: `1px solid ${DANGER_COLOR}` } }, profile.privateKey) : /* @__PURE__ */ import_react2.default.createElement(
896
+ "button",
897
+ {
898
+ type: "button",
899
+ onClick: () => setRevealed(true),
900
+ onMouseDown: () => setPressed("reveal"),
901
+ onMouseUp: () => setPressed(null),
902
+ onMouseLeave: () => setPressed(null),
903
+ onTouchStart: () => setPressed("reveal"),
904
+ onTouchEnd: () => setPressed(null),
905
+ style: secondaryButtonStyle(theme, pressed === "reveal")
906
+ },
907
+ "Reveal private key"
908
+ ))), /* @__PURE__ */ import_react2.default.createElement("div", { style: { marginTop: "32px" } }, /* @__PURE__ */ import_react2.default.createElement(
909
+ "button",
910
+ {
911
+ type: "button",
912
+ onClick: handleDownload,
913
+ onMouseDown: () => setPressed("download"),
914
+ onMouseUp: () => setPressed(null),
915
+ onMouseLeave: () => setPressed(null),
916
+ onTouchStart: () => setPressed("download"),
917
+ onTouchEnd: () => setPressed(null),
918
+ style: primaryButtonStyle(theme, pressed === "download")
919
+ },
920
+ "Download backup (.json)"
921
+ ), downloaded && /* @__PURE__ */ import_react2.default.createElement(
922
+ "p",
923
+ {
924
+ role: "status",
925
+ style: { fontSize: "14px", textAlign: "center", marginTop: "12px", opacity: 0.7 }
926
+ },
927
+ "Backup downloaded. Keep it somewhere safe."
928
+ )))
929
+ );
930
+ }
931
+
932
+ // src/react/components/ImportExport.tsx
933
+ var import_react3 = __toESM(require("react"), 1);
934
+ function ImportExport({
935
+ customStyles = {},
936
+ onComplete,
937
+ onExport,
938
+ defaultTab = "import",
939
+ skipImport = false,
940
+ skipExport = false
941
+ }) {
942
+ const theme = resolveTheme(customStyles);
943
+ const initialTab = skipImport ? "export" : skipExport ? "import" : defaultTab;
944
+ const [tab, setTab] = (0, import_react3.useState)(initialTab);
945
+ if (skipImport && skipExport) {
946
+ return null;
947
+ }
948
+ const showTabs = !skipImport && !skipExport;
949
+ const tabStyle = (active) => ({
950
+ flex: 1,
951
+ padding: "12px 16px",
952
+ fontSize: "15px",
953
+ fontWeight: active ? 600 : 500,
954
+ fontFamily: theme.fontFamily,
955
+ color: active ? "#ffffff" : theme.primaryColor,
956
+ backgroundColor: active ? theme.primaryColor : "transparent",
957
+ border: "none",
958
+ borderRadius: theme.buttonRadius,
959
+ cursor: "pointer",
960
+ transition: "background-color 0.15s, color 0.15s",
961
+ WebkitTapHighlightColor: theme.mobileTapHighlightColor
962
+ });
963
+ return /* @__PURE__ */ import_react3.default.createElement("div", { style: { backgroundColor: theme.backgroundColor, minHeight: "100vh" } }, showTabs && /* @__PURE__ */ import_react3.default.createElement(
964
+ "div",
965
+ {
966
+ role: "tablist",
967
+ style: {
968
+ display: "flex",
969
+ gap: "4px",
970
+ padding: "4px",
971
+ margin: "0 auto",
972
+ maxWidth: "500px",
973
+ backgroundColor: "rgba(0, 0, 0, 0.04)",
974
+ borderRadius: theme.buttonRadius,
975
+ position: "sticky",
976
+ top: 0,
977
+ zIndex: 1
978
+ }
979
+ },
980
+ /* @__PURE__ */ import_react3.default.createElement(
981
+ "button",
982
+ {
983
+ role: "tab",
984
+ "aria-selected": tab === "import",
985
+ onClick: () => setTab("import"),
986
+ style: tabStyle(tab === "import")
987
+ },
988
+ "Import"
989
+ ),
990
+ /* @__PURE__ */ import_react3.default.createElement(
991
+ "button",
992
+ {
993
+ role: "tab",
994
+ "aria-selected": tab === "export",
995
+ onClick: () => setTab("export"),
996
+ style: tabStyle(tab === "export")
997
+ },
998
+ "Export"
999
+ )
1000
+ ), tab === "import" && !skipImport && /* @__PURE__ */ import_react3.default.createElement(ImportProfile, { customStyles, onComplete }), tab === "export" && !skipExport && /* @__PURE__ */ import_react3.default.createElement(ExportProfile, { customStyles, onExport }));
1001
+ }
1002
+
1003
+ // src/react/hooks/useProfile.ts
1004
+ var import_react4 = require("react");
1005
+ function useProfile() {
1006
+ const [profile, setProfile] = (0, import_react4.useState)(null);
1007
+ const refresh = (0, import_react4.useCallback)(() => {
1008
+ setProfile(getCurrentProfile());
1009
+ }, []);
1010
+ (0, import_react4.useEffect)(() => {
1011
+ refresh();
1012
+ const handleStorageChange = (e) => {
1013
+ if (e.key === STORAGE_KEYS.PROFILE || e.key === STORAGE_KEYS.PRIVATE_KEY || e.key === null) {
1014
+ refresh();
1015
+ }
1016
+ };
1017
+ window.addEventListener("storage", handleStorageChange);
1018
+ return () => {
1019
+ window.removeEventListener("storage", handleStorageChange);
1020
+ };
1021
+ }, [refresh]);
1022
+ return { profile, refresh };
1023
+ }
1024
+ // Annotate the CommonJS export names for ESM import in node:
1025
+ 0 && (module.exports = {
1026
+ EXPORT_FILE_TYPE,
1027
+ EXPORT_FILE_VERSION,
1028
+ ExportProfile,
1029
+ ImportExport,
1030
+ ImportProfile,
1031
+ STORAGE_KEYS,
1032
+ clearProfile,
1033
+ createDidFromPublicKey,
1034
+ createJWT,
1035
+ decodeJWT,
1036
+ decodePublicKey,
1037
+ defaultExportFilename,
1038
+ deriveKeysFromPrivateKey,
1039
+ downloadProfile,
1040
+ exportProfile,
1041
+ exportProfileToJSON,
1042
+ getCurrentProfile,
1043
+ getPrivateKey,
1044
+ hasLocalFirstAuthAPI,
1045
+ hasProfile,
1046
+ importProfile,
1047
+ importProfileFromFile,
1048
+ injectLocalFirstAuthAPI,
1049
+ parseExportedProfile,
1050
+ removeLocalFirstAuthAPI,
1051
+ useProfile,
1052
+ validateExportedProfile,
1053
+ validatePrivateKey,
1054
+ verifyJWT
1055
+ });
1056
+ //# sourceMappingURL=react.cjs.map