+228
-434
frontend/deno.lock
+228
-434
frontend/deno.lock
···
5
"npm:@atcute/crypto@^2.3.0": "2.3.0",
6
"npm:@atcute/did-plc@~0.3.1": "0.3.1",
7
"npm:@atcute/multibase@^1.1.6": "1.1.6",
8
-
"npm:@noble/secp256k1@^2.1.0": "2.3.0",
9
-
"npm:@sveltejs/vite-plugin-svelte@5": "5.1.1_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3",
10
-
"npm:@testing-library/jest-dom@^6.6.3": "6.9.1",
11
-
"npm:@testing-library/svelte@^5.2.6": "5.2.9_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3_vitest@2.1.9__jsdom@25.0.1__vite@5.4.21_jsdom@25.0.1",
12
-
"npm:@testing-library/user-event@^14.5.2": "14.6.1_@testing-library+dom@10.4.1",
13
"npm:jsdom@^25.0.1": "25.0.1",
14
-
"npm:multiformats@^13.3.1": "13.4.2",
15
-
"npm:svelte-check@*": "4.3.5_svelte@5.45.10__acorn@8.15.0_typescript@5.9.3",
16
-
"npm:svelte-i18n@^4.0.1": "4.0.1_svelte@5.45.10__acorn@8.15.0",
17
-
"npm:svelte@5": "5.45.10_acorn@8.15.0",
18
-
"npm:vite@*": "6.4.1_picomatch@4.0.3",
19
-
"npm:vite@6": "6.4.1_picomatch@4.0.3",
20
-
"npm:vitest@*": "2.1.9_jsdom@25.0.1_vite@5.4.21",
21
-
"npm:vitest@^2.1.8": "2.1.9_jsdom@25.0.1_vite@5.4.21"
22
},
23
"npm": {
24
"@adobe/css-tools@4.4.4": {
···
54
"dependencies": [
55
"@atcute/multibase",
56
"@atcute/uint8array",
57
-
"@noble/secp256k1@3.0.0"
58
]
59
},
60
"@atcute/did-plc@0.3.1": {
···
96
"@atcute/uint8array@1.0.6": {
97
"integrity": "sha512-ucfRBQc7BFT8n9eCyGOzDHEMKF/nZwhS2pPao4Xtab1ML3HdFYcX2DM1tadCzas85QTGxHe5urnUAAcNKGRi9A=="
98
},
99
-
"@atcute/util-fetch@1.0.4": {
100
-
"integrity": "sha512-sIU9Qk0dE8PLEXSfhy+gIJV+HpiiknMytCI2SqLlqd0vgZUtEKI/EQfP+23LHWvP+CLCzVDOa6cpH045OlmNBg==",
101
"dependencies": [
102
"@badrap/valita"
103
]
···
158
"os": ["aix"],
159
"cpu": ["ppc64"]
160
},
161
-
"@esbuild/aix-ppc64@0.21.5": {
162
-
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
163
-
"os": ["aix"],
164
-
"cpu": ["ppc64"]
165
-
},
166
-
"@esbuild/aix-ppc64@0.25.12": {
167
-
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
168
"os": ["aix"],
169
"cpu": ["ppc64"]
170
},
···
173
"os": ["android"],
174
"cpu": ["arm64"]
175
},
176
-
"@esbuild/android-arm64@0.21.5": {
177
-
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
178
-
"os": ["android"],
179
-
"cpu": ["arm64"]
180
-
},
181
-
"@esbuild/android-arm64@0.25.12": {
182
-
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
183
"os": ["android"],
184
"cpu": ["arm64"]
185
},
···
188
"os": ["android"],
189
"cpu": ["arm"]
190
},
191
-
"@esbuild/android-arm@0.21.5": {
192
-
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
193
-
"os": ["android"],
194
-
"cpu": ["arm"]
195
-
},
196
-
"@esbuild/android-arm@0.25.12": {
197
-
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
198
"os": ["android"],
199
"cpu": ["arm"]
200
},
···
203
"os": ["android"],
204
"cpu": ["x64"]
205
},
206
-
"@esbuild/android-x64@0.21.5": {
207
-
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
208
-
"os": ["android"],
209
-
"cpu": ["x64"]
210
-
},
211
-
"@esbuild/android-x64@0.25.12": {
212
-
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
213
"os": ["android"],
214
"cpu": ["x64"]
215
},
···
218
"os": ["darwin"],
219
"cpu": ["arm64"]
220
},
221
-
"@esbuild/darwin-arm64@0.21.5": {
222
-
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
223
-
"os": ["darwin"],
224
-
"cpu": ["arm64"]
225
-
},
226
-
"@esbuild/darwin-arm64@0.25.12": {
227
-
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
228
"os": ["darwin"],
229
"cpu": ["arm64"]
230
},
···
233
"os": ["darwin"],
234
"cpu": ["x64"]
235
},
236
-
"@esbuild/darwin-x64@0.21.5": {
237
-
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
238
-
"os": ["darwin"],
239
-
"cpu": ["x64"]
240
-
},
241
-
"@esbuild/darwin-x64@0.25.12": {
242
-
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
243
"os": ["darwin"],
244
"cpu": ["x64"]
245
},
···
248
"os": ["freebsd"],
249
"cpu": ["arm64"]
250
},
251
-
"@esbuild/freebsd-arm64@0.21.5": {
252
-
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
253
-
"os": ["freebsd"],
254
-
"cpu": ["arm64"]
255
-
},
256
-
"@esbuild/freebsd-arm64@0.25.12": {
257
-
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
258
"os": ["freebsd"],
259
"cpu": ["arm64"]
260
},
···
263
"os": ["freebsd"],
264
"cpu": ["x64"]
265
},
266
-
"@esbuild/freebsd-x64@0.21.5": {
267
-
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
268
-
"os": ["freebsd"],
269
-
"cpu": ["x64"]
270
-
},
271
-
"@esbuild/freebsd-x64@0.25.12": {
272
-
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
273
"os": ["freebsd"],
274
"cpu": ["x64"]
275
},
···
278
"os": ["linux"],
279
"cpu": ["arm64"]
280
},
281
-
"@esbuild/linux-arm64@0.21.5": {
282
-
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
283
-
"os": ["linux"],
284
-
"cpu": ["arm64"]
285
-
},
286
-
"@esbuild/linux-arm64@0.25.12": {
287
-
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
288
"os": ["linux"],
289
"cpu": ["arm64"]
290
},
···
293
"os": ["linux"],
294
"cpu": ["arm"]
295
},
296
-
"@esbuild/linux-arm@0.21.5": {
297
-
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
298
-
"os": ["linux"],
299
-
"cpu": ["arm"]
300
-
},
301
-
"@esbuild/linux-arm@0.25.12": {
302
-
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
303
"os": ["linux"],
304
"cpu": ["arm"]
305
},
···
308
"os": ["linux"],
309
"cpu": ["ia32"]
310
},
311
-
"@esbuild/linux-ia32@0.21.5": {
312
-
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
313
-
"os": ["linux"],
314
-
"cpu": ["ia32"]
315
-
},
316
-
"@esbuild/linux-ia32@0.25.12": {
317
-
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
318
"os": ["linux"],
319
"cpu": ["ia32"]
320
},
···
323
"os": ["linux"],
324
"cpu": ["loong64"]
325
},
326
-
"@esbuild/linux-loong64@0.21.5": {
327
-
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
328
-
"os": ["linux"],
329
-
"cpu": ["loong64"]
330
-
},
331
-
"@esbuild/linux-loong64@0.25.12": {
332
-
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
333
"os": ["linux"],
334
"cpu": ["loong64"]
335
},
···
338
"os": ["linux"],
339
"cpu": ["mips64el"]
340
},
341
-
"@esbuild/linux-mips64el@0.21.5": {
342
-
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
343
-
"os": ["linux"],
344
-
"cpu": ["mips64el"]
345
-
},
346
-
"@esbuild/linux-mips64el@0.25.12": {
347
-
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
348
"os": ["linux"],
349
"cpu": ["mips64el"]
350
},
···
353
"os": ["linux"],
354
"cpu": ["ppc64"]
355
},
356
-
"@esbuild/linux-ppc64@0.21.5": {
357
-
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
358
-
"os": ["linux"],
359
-
"cpu": ["ppc64"]
360
-
},
361
-
"@esbuild/linux-ppc64@0.25.12": {
362
-
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
363
"os": ["linux"],
364
"cpu": ["ppc64"]
365
},
···
368
"os": ["linux"],
369
"cpu": ["riscv64"]
370
},
371
-
"@esbuild/linux-riscv64@0.21.5": {
372
-
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
373
-
"os": ["linux"],
374
-
"cpu": ["riscv64"]
375
-
},
376
-
"@esbuild/linux-riscv64@0.25.12": {
377
-
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
378
"os": ["linux"],
379
"cpu": ["riscv64"]
380
},
···
383
"os": ["linux"],
384
"cpu": ["s390x"]
385
},
386
-
"@esbuild/linux-s390x@0.21.5": {
387
-
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
388
-
"os": ["linux"],
389
-
"cpu": ["s390x"]
390
-
},
391
-
"@esbuild/linux-s390x@0.25.12": {
392
-
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
393
"os": ["linux"],
394
"cpu": ["s390x"]
395
},
···
398
"os": ["linux"],
399
"cpu": ["x64"]
400
},
401
-
"@esbuild/linux-x64@0.21.5": {
402
-
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
403
-
"os": ["linux"],
404
-
"cpu": ["x64"]
405
-
},
406
-
"@esbuild/linux-x64@0.25.12": {
407
-
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
408
"os": ["linux"],
409
"cpu": ["x64"]
410
},
411
-
"@esbuild/netbsd-arm64@0.25.12": {
412
-
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
413
"os": ["netbsd"],
414
"cpu": ["arm64"]
415
},
···
418
"os": ["netbsd"],
419
"cpu": ["x64"]
420
},
421
-
"@esbuild/netbsd-x64@0.21.5": {
422
-
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
423
"os": ["netbsd"],
424
"cpu": ["x64"]
425
},
426
-
"@esbuild/netbsd-x64@0.25.12": {
427
-
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
428
-
"os": ["netbsd"],
429
-
"cpu": ["x64"]
430
-
},
431
-
"@esbuild/openbsd-arm64@0.25.12": {
432
-
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
433
"os": ["openbsd"],
434
"cpu": ["arm64"]
435
},
···
438
"os": ["openbsd"],
439
"cpu": ["x64"]
440
},
441
-
"@esbuild/openbsd-x64@0.21.5": {
442
-
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
443
"os": ["openbsd"],
444
"cpu": ["x64"]
445
},
446
-
"@esbuild/openbsd-x64@0.25.12": {
447
-
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
448
-
"os": ["openbsd"],
449
-
"cpu": ["x64"]
450
-
},
451
-
"@esbuild/openharmony-arm64@0.25.12": {
452
-
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
453
"os": ["openharmony"],
454
"cpu": ["arm64"]
455
},
···
458
"os": ["sunos"],
459
"cpu": ["x64"]
460
},
461
-
"@esbuild/sunos-x64@0.21.5": {
462
-
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
463
-
"os": ["sunos"],
464
-
"cpu": ["x64"]
465
-
},
466
-
"@esbuild/sunos-x64@0.25.12": {
467
-
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
468
"os": ["sunos"],
469
"cpu": ["x64"]
470
},
···
473
"os": ["win32"],
474
"cpu": ["arm64"]
475
},
476
-
"@esbuild/win32-arm64@0.21.5": {
477
-
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
478
-
"os": ["win32"],
479
-
"cpu": ["arm64"]
480
-
},
481
-
"@esbuild/win32-arm64@0.25.12": {
482
-
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
483
"os": ["win32"],
484
"cpu": ["arm64"]
485
},
···
488
"os": ["win32"],
489
"cpu": ["ia32"]
490
},
491
-
"@esbuild/win32-ia32@0.21.5": {
492
-
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
493
-
"os": ["win32"],
494
-
"cpu": ["ia32"]
495
-
},
496
-
"@esbuild/win32-ia32@0.25.12": {
497
-
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
498
"os": ["win32"],
499
"cpu": ["ia32"]
500
},
···
503
"os": ["win32"],
504
"cpu": ["x64"]
505
},
506
-
"@esbuild/win32-x64@0.21.5": {
507
-
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
508
-
"os": ["win32"],
509
-
"cpu": ["x64"]
510
-
},
511
-
"@esbuild/win32-x64@0.25.12": {
512
-
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
513
"os": ["win32"],
514
"cpu": ["x64"]
515
},
···
576
"@jridgewell/sourcemap-codec"
577
]
578
},
579
-
"@noble/secp256k1@2.3.0": {
580
-
"integrity": "sha512-0TQed2gcBbIrh7Ccyw+y/uZQvbJwm7Ao4scBUxqpBCcsOlZG0O4KGfjtNAy/li4W8n1xt3dxrwJ0beZ2h2G6Kw=="
581
-
},
582
"@noble/secp256k1@3.0.0": {
583
"integrity": "sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg=="
584
},
585
-
"@rollup/rollup-android-arm-eabi@4.53.3": {
586
-
"integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==",
587
"os": ["android"],
588
"cpu": ["arm"]
589
},
590
-
"@rollup/rollup-android-arm64@4.53.3": {
591
-
"integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==",
592
"os": ["android"],
593
"cpu": ["arm64"]
594
},
595
-
"@rollup/rollup-darwin-arm64@4.53.3": {
596
-
"integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==",
597
"os": ["darwin"],
598
"cpu": ["arm64"]
599
},
600
-
"@rollup/rollup-darwin-x64@4.53.3": {
601
-
"integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==",
602
"os": ["darwin"],
603
"cpu": ["x64"]
604
},
605
-
"@rollup/rollup-freebsd-arm64@4.53.3": {
606
-
"integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==",
607
"os": ["freebsd"],
608
"cpu": ["arm64"]
609
},
610
-
"@rollup/rollup-freebsd-x64@4.53.3": {
611
-
"integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==",
612
"os": ["freebsd"],
613
"cpu": ["x64"]
614
},
615
-
"@rollup/rollup-linux-arm-gnueabihf@4.53.3": {
616
-
"integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==",
617
"os": ["linux"],
618
"cpu": ["arm"]
619
},
620
-
"@rollup/rollup-linux-arm-musleabihf@4.53.3": {
621
-
"integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==",
622
"os": ["linux"],
623
"cpu": ["arm"]
624
},
625
-
"@rollup/rollup-linux-arm64-gnu@4.53.3": {
626
-
"integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==",
627
"os": ["linux"],
628
"cpu": ["arm64"]
629
},
630
-
"@rollup/rollup-linux-arm64-musl@4.53.3": {
631
-
"integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==",
632
"os": ["linux"],
633
"cpu": ["arm64"]
634
},
635
-
"@rollup/rollup-linux-loong64-gnu@4.53.3": {
636
-
"integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==",
637
"os": ["linux"],
638
"cpu": ["loong64"]
639
},
640
-
"@rollup/rollup-linux-ppc64-gnu@4.53.3": {
641
-
"integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==",
642
"os": ["linux"],
643
"cpu": ["ppc64"]
644
},
645
-
"@rollup/rollup-linux-riscv64-gnu@4.53.3": {
646
-
"integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==",
647
"os": ["linux"],
648
"cpu": ["riscv64"]
649
},
650
-
"@rollup/rollup-linux-riscv64-musl@4.53.3": {
651
-
"integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==",
652
"os": ["linux"],
653
"cpu": ["riscv64"]
654
},
655
-
"@rollup/rollup-linux-s390x-gnu@4.53.3": {
656
-
"integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==",
657
"os": ["linux"],
658
"cpu": ["s390x"]
659
},
660
-
"@rollup/rollup-linux-x64-gnu@4.53.3": {
661
-
"integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==",
662
"os": ["linux"],
663
"cpu": ["x64"]
664
},
665
-
"@rollup/rollup-linux-x64-musl@4.53.3": {
666
-
"integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==",
667
"os": ["linux"],
668
"cpu": ["x64"]
669
},
670
-
"@rollup/rollup-openharmony-arm64@4.53.3": {
671
-
"integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==",
672
"os": ["openharmony"],
673
"cpu": ["arm64"]
674
},
675
-
"@rollup/rollup-win32-arm64-msvc@4.53.3": {
676
-
"integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==",
677
"os": ["win32"],
678
"cpu": ["arm64"]
679
},
680
-
"@rollup/rollup-win32-ia32-msvc@4.53.3": {
681
-
"integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==",
682
"os": ["win32"],
683
"cpu": ["ia32"]
684
},
685
-
"@rollup/rollup-win32-x64-gnu@4.53.3": {
686
-
"integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==",
687
"os": ["win32"],
688
"cpu": ["x64"]
689
},
690
-
"@rollup/rollup-win32-x64-msvc@4.53.3": {
691
-
"integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==",
692
"os": ["win32"],
693
"cpu": ["x64"]
694
},
···
701
"acorn"
702
]
703
},
704
-
"@sveltejs/vite-plugin-svelte-inspector@4.0.1_@sveltejs+vite-plugin-svelte@5.1.1__svelte@5.45.10___acorn@8.15.0__vite@6.4.1___picomatch@4.0.3_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3": {
705
-
"integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==",
706
"dependencies": [
707
"@sveltejs/vite-plugin-svelte",
708
"debug",
709
"svelte",
710
-
"vite@6.4.1_picomatch@4.0.3"
711
]
712
},
713
-
"@sveltejs/vite-plugin-svelte@5.1.1_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3": {
714
-
"integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==",
715
"dependencies": [
716
"@sveltejs/vite-plugin-svelte-inspector",
717
"debug",
718
"deepmerge",
719
-
"kleur",
720
"magic-string",
721
"svelte",
722
-
"vite@6.4.1_picomatch@4.0.3",
723
"vitefu"
724
]
725
},
···
747
"redent"
748
]
749
},
750
-
"@testing-library/svelte@5.2.9_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3_vitest@2.1.9__jsdom@25.0.1__vite@5.4.21_jsdom@25.0.1": {
751
-
"integrity": "sha512-p0Lg/vL1iEsEasXKSipvW9nBCtItQGhYvxL8OZ4w7/IDdC+LGoSJw4mMS5bndVFON/gWryitEhMr29AlO4FvBg==",
752
"dependencies": [
753
"@testing-library/dom",
754
"svelte",
755
-
"vite@6.4.1_picomatch@4.0.3",
756
"vitest"
757
],
758
"optionalPeers": [
759
-
"vite@6.4.1_picomatch@4.0.3",
760
"vitest"
761
]
762
},
···
769
"@types/aria-query@5.0.4": {
770
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="
771
},
772
"@types/estree@1.0.8": {
773
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="
774
},
775
-
"@vitest/expect@2.1.9": {
776
-
"integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==",
777
"dependencies": [
778
"@vitest/spy",
779
"@vitest/utils",
780
"chai",
781
"tinyrainbow"
782
]
783
},
784
-
"@vitest/mocker@2.1.9_vite@5.4.21": {
785
-
"integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==",
786
"dependencies": [
787
"@vitest/spy",
788
"estree-walker@3.0.3",
789
"magic-string",
790
-
"vite@5.4.21"
791
],
792
"optionalPeers": [
793
-
"vite@5.4.21"
794
]
795
},
796
-
"@vitest/pretty-format@2.1.9": {
797
-
"integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==",
798
"dependencies": [
799
"tinyrainbow"
800
]
801
},
802
-
"@vitest/runner@2.1.9": {
803
-
"integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==",
804
"dependencies": [
805
"@vitest/utils",
806
"pathe"
807
]
808
},
809
-
"@vitest/snapshot@2.1.9": {
810
-
"integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==",
811
"dependencies": [
812
"@vitest/pretty-format",
813
"magic-string",
814
"pathe"
815
]
816
},
817
-
"@vitest/spy@2.1.9": {
818
-
"integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==",
819
-
"dependencies": [
820
-
"tinyspy"
821
-
]
822
},
823
-
"@vitest/utils@2.1.9": {
824
-
"integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==",
825
"dependencies": [
826
"@vitest/pretty-format",
827
-
"loupe",
828
"tinyrainbow"
829
]
830
},
···
859
"axobject-query@4.1.0": {
860
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="
861
},
862
-
"cac@6.7.14": {
863
-
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="
864
-
},
865
"call-bind-apply-helpers@1.0.2": {
866
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
867
"dependencies": [
···
869
"function-bind"
870
]
871
},
872
-
"chai@5.3.3": {
873
-
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
874
-
"dependencies": [
875
-
"assertion-error",
876
-
"check-error",
877
-
"deep-eql",
878
-
"loupe",
879
-
"pathval"
880
-
]
881
-
},
882
-
"check-error@2.1.1": {
883
-
"integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw=="
884
-
},
885
-
"chokidar@4.0.3": {
886
-
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
887
-
"dependencies": [
888
-
"readdirp"
889
-
]
890
},
891
"cli-color@2.0.4": {
892
"integrity": "sha512-zlnpg0jNcibNrO7GG9IeHH7maWFeCz+Ja1wx/7tZNU5ASSSSZ+/qZciM0/LHCYxSdqv5h2sdbQ/PXYdOuetXvA==",
···
939
},
940
"decimal.js@10.6.0": {
941
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="
942
-
},
943
-
"deep-eql@5.0.2": {
944
-
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="
945
},
946
"deepmerge@4.3.1": {
947
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="
···
1060
"scripts": true,
1061
"bin": true
1062
},
1063
-
"esbuild@0.21.5": {
1064
-
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
1065
"optionalDependencies": [
1066
-
"@esbuild/aix-ppc64@0.21.5",
1067
-
"@esbuild/android-arm@0.21.5",
1068
-
"@esbuild/android-arm64@0.21.5",
1069
-
"@esbuild/android-x64@0.21.5",
1070
-
"@esbuild/darwin-arm64@0.21.5",
1071
-
"@esbuild/darwin-x64@0.21.5",
1072
-
"@esbuild/freebsd-arm64@0.21.5",
1073
-
"@esbuild/freebsd-x64@0.21.5",
1074
-
"@esbuild/linux-arm@0.21.5",
1075
-
"@esbuild/linux-arm64@0.21.5",
1076
-
"@esbuild/linux-ia32@0.21.5",
1077
-
"@esbuild/linux-loong64@0.21.5",
1078
-
"@esbuild/linux-mips64el@0.21.5",
1079
-
"@esbuild/linux-ppc64@0.21.5",
1080
-
"@esbuild/linux-riscv64@0.21.5",
1081
-
"@esbuild/linux-s390x@0.21.5",
1082
-
"@esbuild/linux-x64@0.21.5",
1083
-
"@esbuild/netbsd-x64@0.21.5",
1084
-
"@esbuild/openbsd-x64@0.21.5",
1085
-
"@esbuild/sunos-x64@0.21.5",
1086
-
"@esbuild/win32-arm64@0.21.5",
1087
-
"@esbuild/win32-ia32@0.21.5",
1088
-
"@esbuild/win32-x64@0.21.5"
1089
-
],
1090
-
"scripts": true,
1091
-
"bin": true
1092
-
},
1093
-
"esbuild@0.25.12": {
1094
-
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
1095
-
"optionalDependencies": [
1096
-
"@esbuild/aix-ppc64@0.25.12",
1097
-
"@esbuild/android-arm@0.25.12",
1098
-
"@esbuild/android-arm64@0.25.12",
1099
-
"@esbuild/android-x64@0.25.12",
1100
-
"@esbuild/darwin-arm64@0.25.12",
1101
-
"@esbuild/darwin-x64@0.25.12",
1102
-
"@esbuild/freebsd-arm64@0.25.12",
1103
-
"@esbuild/freebsd-x64@0.25.12",
1104
-
"@esbuild/linux-arm@0.25.12",
1105
-
"@esbuild/linux-arm64@0.25.12",
1106
-
"@esbuild/linux-ia32@0.25.12",
1107
-
"@esbuild/linux-loong64@0.25.12",
1108
-
"@esbuild/linux-mips64el@0.25.12",
1109
-
"@esbuild/linux-ppc64@0.25.12",
1110
-
"@esbuild/linux-riscv64@0.25.12",
1111
-
"@esbuild/linux-s390x@0.25.12",
1112
-
"@esbuild/linux-x64@0.25.12",
1113
"@esbuild/netbsd-arm64",
1114
-
"@esbuild/netbsd-x64@0.25.12",
1115
"@esbuild/openbsd-arm64",
1116
-
"@esbuild/openbsd-x64@0.25.12",
1117
"@esbuild/openharmony-arm64",
1118
-
"@esbuild/sunos-x64@0.25.12",
1119
-
"@esbuild/win32-arm64@0.25.12",
1120
-
"@esbuild/win32-ia32@0.25.12",
1121
-
"@esbuild/win32-x64@0.25.12"
1122
],
1123
"scripts": true,
1124
"bin": true
···
1318
"xml-name-validator"
1319
]
1320
},
1321
-
"kleur@4.1.5": {
1322
-
"integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="
1323
-
},
1324
"locate-character@3.0.0": {
1325
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="
1326
-
},
1327
-
"loupe@3.2.1": {
1328
-
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="
1329
},
1330
"lru-cache@10.4.3": {
1331
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
···
1393
"nwsapi@2.2.23": {
1394
"integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ=="
1395
},
1396
"parse5@7.3.0": {
1397
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
1398
"dependencies": [
1399
"entities"
1400
]
1401
},
1402
-
"pathe@1.1.2": {
1403
-
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="
1404
-
},
1405
-
"pathval@2.0.1": {
1406
-
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="
1407
},
1408
"picocolors@1.1.1": {
1409
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
···
1433
"react-is@17.0.2": {
1434
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
1435
},
1436
-
"readdirp@4.1.2": {
1437
-
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="
1438
-
},
1439
"redent@3.0.0": {
1440
"integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
1441
"dependencies": [
···
1443
"strip-indent"
1444
]
1445
},
1446
-
"rollup@4.53.3": {
1447
-
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
1448
"dependencies": [
1449
"@types/estree"
1450
],
···
1514
"min-indent"
1515
]
1516
},
1517
-
"svelte-check@4.3.5_svelte@5.45.10__acorn@8.15.0_typescript@5.9.3": {
1518
-
"integrity": "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q==",
1519
-
"dependencies": [
1520
-
"@jridgewell/trace-mapping",
1521
-
"chokidar",
1522
-
"fdir",
1523
-
"picocolors",
1524
-
"sade",
1525
-
"svelte",
1526
-
"typescript"
1527
-
],
1528
-
"bin": true
1529
-
},
1530
-
"svelte-i18n@4.0.1_svelte@5.45.10__acorn@8.15.0": {
1531
"integrity": "sha512-jaykGlGT5PUaaq04JWbJREvivlCnALtT+m87Kbm0fxyYHynkQaxQMnIKHLm2WeIuBRoljzwgyvz0Z6/CMwfdmQ==",
1532
"dependencies": [
1533
"cli-color",
···
1541
],
1542
"bin": true
1543
},
1544
-
"svelte@5.45.10_acorn@8.15.0": {
1545
-
"integrity": "sha512-GiWXq6akkEN3zVDMQ1BVlRolmks5JkEdzD/67mvXOz6drRfuddT5JwsGZjMGSnsTRv/PjAXX8fqBcOr2g2qc/Q==",
1546
"dependencies": [
1547
"@jridgewell/remapping",
1548
"@jridgewell/sourcemap-codec",
···
1581
"tinybench@2.9.0": {
1582
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="
1583
},
1584
-
"tinyexec@0.3.2": {
1585
-
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="
1586
},
1587
"tinyglobby@0.2.15_picomatch@4.0.3": {
1588
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
···
1591
"picomatch"
1592
]
1593
},
1594
-
"tinypool@1.1.1": {
1595
-
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="
1596
-
},
1597
-
"tinyrainbow@1.2.0": {
1598
-
"integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ=="
1599
-
},
1600
-
"tinyspy@3.0.2": {
1601
-
"integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q=="
1602
},
1603
"tldts-core@6.1.86": {
1604
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="
···
1628
"type@2.7.3": {
1629
"integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="
1630
},
1631
-
"typescript@5.9.3": {
1632
-
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
1633
-
"bin": true
1634
},
1635
-
"unicode-segmenter@0.14.4": {
1636
-
"integrity": "sha512-pR5VCiCrLrKOL6FRW61jnk9+wyMtKKowq+jyFY9oc6uHbWKhDL4yVRiI4YZPksGMK72Pahh8m0cn/0JvbDDyJg=="
1637
-
},
1638
-
"vite-node@2.1.9": {
1639
-
"integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==",
1640
"dependencies": [
1641
-
"cac",
1642
-
"debug",
1643
-
"es-module-lexer",
1644
-
"pathe",
1645
-
"vite@5.4.21"
1646
-
],
1647
-
"bin": true
1648
-
},
1649
-
"vite@5.4.21": {
1650
-
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
1651
-
"dependencies": [
1652
-
"esbuild@0.21.5",
1653
-
"postcss",
1654
-
"rollup"
1655
-
],
1656
-
"optionalDependencies": [
1657
-
"fsevents"
1658
-
],
1659
-
"bin": true
1660
-
},
1661
-
"vite@6.4.1_picomatch@4.0.3": {
1662
-
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
1663
-
"dependencies": [
1664
-
"esbuild@0.25.12",
1665
"fdir",
1666
"picomatch",
1667
"postcss",
···
1673
],
1674
"bin": true
1675
},
1676
-
"vitefu@1.1.1_vite@6.4.1__picomatch@4.0.3": {
1677
"integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==",
1678
"dependencies": [
1679
-
"vite@6.4.1_picomatch@4.0.3"
1680
],
1681
"optionalPeers": [
1682
-
"vite@6.4.1_picomatch@4.0.3"
1683
]
1684
},
1685
-
"vitest@2.1.9_jsdom@25.0.1_vite@5.4.21": {
1686
-
"integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
1687
"dependencies": [
1688
"@vitest/expect",
1689
"@vitest/mocker",
···
1692
"@vitest/snapshot",
1693
"@vitest/spy",
1694
"@vitest/utils",
1695
-
"chai",
1696
-
"debug",
1697
"expect-type",
1698
"jsdom",
1699
"magic-string",
1700
"pathe",
1701
"std-env",
1702
"tinybench",
1703
"tinyexec",
1704
-
"tinypool",
1705
"tinyrainbow",
1706
-
"vite@5.4.21",
1707
-
"vite-node",
1708
"why-is-node-running"
1709
],
1710
"optionalPeers": [
···
1725
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
1726
"dependencies": [
1727
"iconv-lite"
1728
-
]
1729
},
1730
"whatwg-mimetype@4.0.0": {
1731
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="
···
1756
},
1757
"zimmerframe@1.1.4": {
1758
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="
1759
}
1760
},
1761
"workspace": {
···
1765
"npm:@atcute/crypto@^2.3.0",
1766
"npm:@atcute/did-plc@~0.3.1",
1767
"npm:@atcute/multibase@^1.1.6",
1768
-
"npm:@noble/secp256k1@^2.1.0",
1769
-
"npm:@sveltejs/vite-plugin-svelte@5",
1770
-
"npm:@testing-library/jest-dom@^6.6.3",
1771
-
"npm:@testing-library/svelte@^5.2.6",
1772
-
"npm:@testing-library/user-event@^14.5.2",
1773
"npm:jsdom@^25.0.1",
1774
-
"npm:multiformats@^13.3.1",
1775
"npm:svelte-i18n@^4.0.1",
1776
-
"npm:svelte@5",
1777
-
"npm:vite@6",
1778
-
"npm:vitest@^2.1.8"
1779
]
1780
}
1781
}
···
5
"npm:@atcute/crypto@^2.3.0": "2.3.0",
6
"npm:@atcute/did-plc@~0.3.1": "0.3.1",
7
"npm:@atcute/multibase@^1.1.6": "1.1.6",
8
+
"npm:@noble/secp256k1@3": "3.0.0",
9
+
"npm:@sveltejs/vite-plugin-svelte@^6.2.1": "6.2.1_svelte@5.46.1__acorn@8.15.0_vite@7.3.0__picomatch@4.0.3",
10
+
"npm:@testing-library/jest-dom@^6.9.1": "6.9.1",
11
+
"npm:@testing-library/svelte@^5.3.1": "5.3.1_svelte@5.46.1__acorn@8.15.0_vite@7.3.0__picomatch@4.0.3_vitest@4.0.16__jsdom@25.0.1__vite@7.3.0___picomatch@4.0.3_jsdom@25.0.1",
12
+
"npm:@testing-library/user-event@^14.6.1": "14.6.1_@testing-library+dom@10.4.1",
13
"npm:jsdom@^25.0.1": "25.0.1",
14
+
"npm:multiformats@^13.4.2": "13.4.2",
15
+
"npm:svelte-i18n@^4.0.1": "4.0.1_svelte@5.46.1__acorn@8.15.0",
16
+
"npm:svelte@^5.46.1": "5.46.1_acorn@8.15.0",
17
+
"npm:vite@*": "7.3.0_picomatch@4.0.3",
18
+
"npm:vite@^7.3.0": "7.3.0_picomatch@4.0.3",
19
+
"npm:vitest@*": "4.0.16_jsdom@25.0.1_vite@7.3.0__picomatch@4.0.3",
20
+
"npm:vitest@^4.0.16": "4.0.16_jsdom@25.0.1_vite@7.3.0__picomatch@4.0.3",
21
+
"npm:zod@^4.3.5": "4.3.5"
22
},
23
"npm": {
24
"@adobe/css-tools@4.4.4": {
···
54
"dependencies": [
55
"@atcute/multibase",
56
"@atcute/uint8array",
57
+
"@noble/secp256k1"
58
]
59
},
60
"@atcute/did-plc@0.3.1": {
···
96
"@atcute/uint8array@1.0.6": {
97
"integrity": "sha512-ucfRBQc7BFT8n9eCyGOzDHEMKF/nZwhS2pPao4Xtab1ML3HdFYcX2DM1tadCzas85QTGxHe5urnUAAcNKGRi9A=="
98
},
99
+
"@atcute/util-fetch@1.0.5": {
100
+
"integrity": "sha512-qjHj01BGxjSjIFdPiAjSARnodJIIyKxnCMMEcXMESo9TAyND6XZQqrie5fia+LlYWVXdpsTds8uFQwc9jdKTig==",
101
"dependencies": [
102
"@badrap/valita"
103
]
···
158
"os": ["aix"],
159
"cpu": ["ppc64"]
160
},
161
+
"@esbuild/aix-ppc64@0.27.2": {
162
+
"integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
163
"os": ["aix"],
164
"cpu": ["ppc64"]
165
},
···
168
"os": ["android"],
169
"cpu": ["arm64"]
170
},
171
+
"@esbuild/android-arm64@0.27.2": {
172
+
"integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
173
"os": ["android"],
174
"cpu": ["arm64"]
175
},
···
178
"os": ["android"],
179
"cpu": ["arm"]
180
},
181
+
"@esbuild/android-arm@0.27.2": {
182
+
"integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
183
"os": ["android"],
184
"cpu": ["arm"]
185
},
···
188
"os": ["android"],
189
"cpu": ["x64"]
190
},
191
+
"@esbuild/android-x64@0.27.2": {
192
+
"integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
193
"os": ["android"],
194
"cpu": ["x64"]
195
},
···
198
"os": ["darwin"],
199
"cpu": ["arm64"]
200
},
201
+
"@esbuild/darwin-arm64@0.27.2": {
202
+
"integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
203
"os": ["darwin"],
204
"cpu": ["arm64"]
205
},
···
208
"os": ["darwin"],
209
"cpu": ["x64"]
210
},
211
+
"@esbuild/darwin-x64@0.27.2": {
212
+
"integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
213
"os": ["darwin"],
214
"cpu": ["x64"]
215
},
···
218
"os": ["freebsd"],
219
"cpu": ["arm64"]
220
},
221
+
"@esbuild/freebsd-arm64@0.27.2": {
222
+
"integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
223
"os": ["freebsd"],
224
"cpu": ["arm64"]
225
},
···
228
"os": ["freebsd"],
229
"cpu": ["x64"]
230
},
231
+
"@esbuild/freebsd-x64@0.27.2": {
232
+
"integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
233
"os": ["freebsd"],
234
"cpu": ["x64"]
235
},
···
238
"os": ["linux"],
239
"cpu": ["arm64"]
240
},
241
+
"@esbuild/linux-arm64@0.27.2": {
242
+
"integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
243
"os": ["linux"],
244
"cpu": ["arm64"]
245
},
···
248
"os": ["linux"],
249
"cpu": ["arm"]
250
},
251
+
"@esbuild/linux-arm@0.27.2": {
252
+
"integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
253
"os": ["linux"],
254
"cpu": ["arm"]
255
},
···
258
"os": ["linux"],
259
"cpu": ["ia32"]
260
},
261
+
"@esbuild/linux-ia32@0.27.2": {
262
+
"integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
263
"os": ["linux"],
264
"cpu": ["ia32"]
265
},
···
268
"os": ["linux"],
269
"cpu": ["loong64"]
270
},
271
+
"@esbuild/linux-loong64@0.27.2": {
272
+
"integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
273
"os": ["linux"],
274
"cpu": ["loong64"]
275
},
···
278
"os": ["linux"],
279
"cpu": ["mips64el"]
280
},
281
+
"@esbuild/linux-mips64el@0.27.2": {
282
+
"integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
283
"os": ["linux"],
284
"cpu": ["mips64el"]
285
},
···
288
"os": ["linux"],
289
"cpu": ["ppc64"]
290
},
291
+
"@esbuild/linux-ppc64@0.27.2": {
292
+
"integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
293
"os": ["linux"],
294
"cpu": ["ppc64"]
295
},
···
298
"os": ["linux"],
299
"cpu": ["riscv64"]
300
},
301
+
"@esbuild/linux-riscv64@0.27.2": {
302
+
"integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
303
"os": ["linux"],
304
"cpu": ["riscv64"]
305
},
···
308
"os": ["linux"],
309
"cpu": ["s390x"]
310
},
311
+
"@esbuild/linux-s390x@0.27.2": {
312
+
"integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
313
"os": ["linux"],
314
"cpu": ["s390x"]
315
},
···
318
"os": ["linux"],
319
"cpu": ["x64"]
320
},
321
+
"@esbuild/linux-x64@0.27.2": {
322
+
"integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
323
"os": ["linux"],
324
"cpu": ["x64"]
325
},
326
+
"@esbuild/netbsd-arm64@0.27.2": {
327
+
"integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
328
"os": ["netbsd"],
329
"cpu": ["arm64"]
330
},
···
333
"os": ["netbsd"],
334
"cpu": ["x64"]
335
},
336
+
"@esbuild/netbsd-x64@0.27.2": {
337
+
"integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
338
"os": ["netbsd"],
339
"cpu": ["x64"]
340
},
341
+
"@esbuild/openbsd-arm64@0.27.2": {
342
+
"integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
343
"os": ["openbsd"],
344
"cpu": ["arm64"]
345
},
···
348
"os": ["openbsd"],
349
"cpu": ["x64"]
350
},
351
+
"@esbuild/openbsd-x64@0.27.2": {
352
+
"integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
353
"os": ["openbsd"],
354
"cpu": ["x64"]
355
},
356
+
"@esbuild/openharmony-arm64@0.27.2": {
357
+
"integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
358
"os": ["openharmony"],
359
"cpu": ["arm64"]
360
},
···
363
"os": ["sunos"],
364
"cpu": ["x64"]
365
},
366
+
"@esbuild/sunos-x64@0.27.2": {
367
+
"integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
368
"os": ["sunos"],
369
"cpu": ["x64"]
370
},
···
373
"os": ["win32"],
374
"cpu": ["arm64"]
375
},
376
+
"@esbuild/win32-arm64@0.27.2": {
377
+
"integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
378
"os": ["win32"],
379
"cpu": ["arm64"]
380
},
···
383
"os": ["win32"],
384
"cpu": ["ia32"]
385
},
386
+
"@esbuild/win32-ia32@0.27.2": {
387
+
"integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
388
"os": ["win32"],
389
"cpu": ["ia32"]
390
},
···
393
"os": ["win32"],
394
"cpu": ["x64"]
395
},
396
+
"@esbuild/win32-x64@0.27.2": {
397
+
"integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
398
"os": ["win32"],
399
"cpu": ["x64"]
400
},
···
461
"@jridgewell/sourcemap-codec"
462
]
463
},
464
"@noble/secp256k1@3.0.0": {
465
"integrity": "sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg=="
466
},
467
+
"@rollup/rollup-android-arm-eabi@4.54.0": {
468
+
"integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==",
469
"os": ["android"],
470
"cpu": ["arm"]
471
},
472
+
"@rollup/rollup-android-arm64@4.54.0": {
473
+
"integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==",
474
"os": ["android"],
475
"cpu": ["arm64"]
476
},
477
+
"@rollup/rollup-darwin-arm64@4.54.0": {
478
+
"integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==",
479
"os": ["darwin"],
480
"cpu": ["arm64"]
481
},
482
+
"@rollup/rollup-darwin-x64@4.54.0": {
483
+
"integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==",
484
"os": ["darwin"],
485
"cpu": ["x64"]
486
},
487
+
"@rollup/rollup-freebsd-arm64@4.54.0": {
488
+
"integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==",
489
"os": ["freebsd"],
490
"cpu": ["arm64"]
491
},
492
+
"@rollup/rollup-freebsd-x64@4.54.0": {
493
+
"integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==",
494
"os": ["freebsd"],
495
"cpu": ["x64"]
496
},
497
+
"@rollup/rollup-linux-arm-gnueabihf@4.54.0": {
498
+
"integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==",
499
"os": ["linux"],
500
"cpu": ["arm"]
501
},
502
+
"@rollup/rollup-linux-arm-musleabihf@4.54.0": {
503
+
"integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==",
504
"os": ["linux"],
505
"cpu": ["arm"]
506
},
507
+
"@rollup/rollup-linux-arm64-gnu@4.54.0": {
508
+
"integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==",
509
"os": ["linux"],
510
"cpu": ["arm64"]
511
},
512
+
"@rollup/rollup-linux-arm64-musl@4.54.0": {
513
+
"integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==",
514
"os": ["linux"],
515
"cpu": ["arm64"]
516
},
517
+
"@rollup/rollup-linux-loong64-gnu@4.54.0": {
518
+
"integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==",
519
"os": ["linux"],
520
"cpu": ["loong64"]
521
},
522
+
"@rollup/rollup-linux-ppc64-gnu@4.54.0": {
523
+
"integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==",
524
"os": ["linux"],
525
"cpu": ["ppc64"]
526
},
527
+
"@rollup/rollup-linux-riscv64-gnu@4.54.0": {
528
+
"integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==",
529
"os": ["linux"],
530
"cpu": ["riscv64"]
531
},
532
+
"@rollup/rollup-linux-riscv64-musl@4.54.0": {
533
+
"integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==",
534
"os": ["linux"],
535
"cpu": ["riscv64"]
536
},
537
+
"@rollup/rollup-linux-s390x-gnu@4.54.0": {
538
+
"integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==",
539
"os": ["linux"],
540
"cpu": ["s390x"]
541
},
542
+
"@rollup/rollup-linux-x64-gnu@4.54.0": {
543
+
"integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==",
544
"os": ["linux"],
545
"cpu": ["x64"]
546
},
547
+
"@rollup/rollup-linux-x64-musl@4.54.0": {
548
+
"integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==",
549
"os": ["linux"],
550
"cpu": ["x64"]
551
},
552
+
"@rollup/rollup-openharmony-arm64@4.54.0": {
553
+
"integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==",
554
"os": ["openharmony"],
555
"cpu": ["arm64"]
556
},
557
+
"@rollup/rollup-win32-arm64-msvc@4.54.0": {
558
+
"integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==",
559
"os": ["win32"],
560
"cpu": ["arm64"]
561
},
562
+
"@rollup/rollup-win32-ia32-msvc@4.54.0": {
563
+
"integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==",
564
"os": ["win32"],
565
"cpu": ["ia32"]
566
},
567
+
"@rollup/rollup-win32-x64-gnu@4.54.0": {
568
+
"integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==",
569
"os": ["win32"],
570
"cpu": ["x64"]
571
},
572
+
"@rollup/rollup-win32-x64-msvc@4.54.0": {
573
+
"integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==",
574
"os": ["win32"],
575
"cpu": ["x64"]
576
},
···
583
"acorn"
584
]
585
},
586
+
"@sveltejs/vite-plugin-svelte-inspector@5.0.1_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.46.1___acorn@8.15.0__vite@7.3.0___picomatch@4.0.3_svelte@5.46.1__acorn@8.15.0_vite@7.3.0__picomatch@4.0.3": {
587
+
"integrity": "sha512-ubWshlMk4bc8mkwWbg6vNvCeT7lGQojE3ijDh3QTR6Zr/R+GXxsGbyH4PExEPpiFmqPhYiVSVmHBjUcVc1JIrA==",
588
"dependencies": [
589
"@sveltejs/vite-plugin-svelte",
590
"debug",
591
"svelte",
592
+
"vite"
593
]
594
},
595
+
"@sveltejs/vite-plugin-svelte@6.2.1_svelte@5.46.1__acorn@8.15.0_vite@7.3.0__picomatch@4.0.3": {
596
+
"integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==",
597
"dependencies": [
598
"@sveltejs/vite-plugin-svelte-inspector",
599
"debug",
600
"deepmerge",
601
"magic-string",
602
"svelte",
603
+
"vite",
604
"vitefu"
605
]
606
},
···
628
"redent"
629
]
630
},
631
+
"@testing-library/svelte-core@1.0.0_svelte@5.46.1__acorn@8.15.0": {
632
+
"integrity": "sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ==",
633
+
"dependencies": [
634
+
"svelte"
635
+
]
636
+
},
637
+
"@testing-library/svelte@5.3.1_svelte@5.46.1__acorn@8.15.0_vite@7.3.0__picomatch@4.0.3_vitest@4.0.16__jsdom@25.0.1__vite@7.3.0___picomatch@4.0.3_jsdom@25.0.1": {
638
+
"integrity": "sha512-8Ez7ZOqW5geRf9PF5rkuopODe5RGy3I9XR+kc7zHh26gBiktLaxTfKmhlGaSHYUOTQE7wFsLMN9xCJVCszw47w==",
639
"dependencies": [
640
"@testing-library/dom",
641
+
"@testing-library/svelte-core",
642
"svelte",
643
+
"vite",
644
"vitest"
645
],
646
"optionalPeers": [
647
+
"vite",
648
"vitest"
649
]
650
},
···
657
"@types/aria-query@5.0.4": {
658
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="
659
},
660
+
"@types/chai@5.2.3": {
661
+
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
662
+
"dependencies": [
663
+
"@types/deep-eql",
664
+
"assertion-error"
665
+
]
666
+
},
667
+
"@types/deep-eql@4.0.2": {
668
+
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="
669
+
},
670
"@types/estree@1.0.8": {
671
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="
672
},
673
+
"@vitest/expect@4.0.16": {
674
+
"integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==",
675
"dependencies": [
676
+
"@standard-schema/spec",
677
+
"@types/chai",
678
"@vitest/spy",
679
"@vitest/utils",
680
"chai",
681
"tinyrainbow"
682
]
683
},
684
+
"@vitest/mocker@4.0.16_vite@7.3.0__picomatch@4.0.3": {
685
+
"integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==",
686
"dependencies": [
687
"@vitest/spy",
688
"estree-walker@3.0.3",
689
"magic-string",
690
+
"vite"
691
],
692
"optionalPeers": [
693
+
"vite"
694
]
695
},
696
+
"@vitest/pretty-format@4.0.16": {
697
+
"integrity": "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==",
698
"dependencies": [
699
"tinyrainbow"
700
]
701
},
702
+
"@vitest/runner@4.0.16": {
703
+
"integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==",
704
"dependencies": [
705
"@vitest/utils",
706
"pathe"
707
]
708
},
709
+
"@vitest/snapshot@4.0.16": {
710
+
"integrity": "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==",
711
"dependencies": [
712
"@vitest/pretty-format",
713
"magic-string",
714
"pathe"
715
]
716
},
717
+
"@vitest/spy@4.0.16": {
718
+
"integrity": "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw=="
719
},
720
+
"@vitest/utils@4.0.16": {
721
+
"integrity": "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==",
722
"dependencies": [
723
"@vitest/pretty-format",
724
"tinyrainbow"
725
]
726
},
···
755
"axobject-query@4.1.0": {
756
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="
757
},
758
"call-bind-apply-helpers@1.0.2": {
759
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
760
"dependencies": [
···
762
"function-bind"
763
]
764
},
765
+
"chai@6.2.2": {
766
+
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="
767
},
768
"cli-color@2.0.4": {
769
"integrity": "sha512-zlnpg0jNcibNrO7GG9IeHH7maWFeCz+Ja1wx/7tZNU5ASSSSZ+/qZciM0/LHCYxSdqv5h2sdbQ/PXYdOuetXvA==",
···
816
},
817
"decimal.js@10.6.0": {
818
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="
819
},
820
"deepmerge@4.3.1": {
821
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="
···
934
"scripts": true,
935
"bin": true
936
},
937
+
"esbuild@0.27.2": {
938
+
"integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
939
"optionalDependencies": [
940
+
"@esbuild/aix-ppc64@0.27.2",
941
+
"@esbuild/android-arm@0.27.2",
942
+
"@esbuild/android-arm64@0.27.2",
943
+
"@esbuild/android-x64@0.27.2",
944
+
"@esbuild/darwin-arm64@0.27.2",
945
+
"@esbuild/darwin-x64@0.27.2",
946
+
"@esbuild/freebsd-arm64@0.27.2",
947
+
"@esbuild/freebsd-x64@0.27.2",
948
+
"@esbuild/linux-arm@0.27.2",
949
+
"@esbuild/linux-arm64@0.27.2",
950
+
"@esbuild/linux-ia32@0.27.2",
951
+
"@esbuild/linux-loong64@0.27.2",
952
+
"@esbuild/linux-mips64el@0.27.2",
953
+
"@esbuild/linux-ppc64@0.27.2",
954
+
"@esbuild/linux-riscv64@0.27.2",
955
+
"@esbuild/linux-s390x@0.27.2",
956
+
"@esbuild/linux-x64@0.27.2",
957
"@esbuild/netbsd-arm64",
958
+
"@esbuild/netbsd-x64@0.27.2",
959
"@esbuild/openbsd-arm64",
960
+
"@esbuild/openbsd-x64@0.27.2",
961
"@esbuild/openharmony-arm64",
962
+
"@esbuild/sunos-x64@0.27.2",
963
+
"@esbuild/win32-arm64@0.27.2",
964
+
"@esbuild/win32-ia32@0.27.2",
965
+
"@esbuild/win32-x64@0.27.2"
966
],
967
"scripts": true,
968
"bin": true
···
1162
"xml-name-validator"
1163
]
1164
},
1165
"locate-character@3.0.0": {
1166
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="
1167
},
1168
"lru-cache@10.4.3": {
1169
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
···
1231
"nwsapi@2.2.23": {
1232
"integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ=="
1233
},
1234
+
"obug@2.1.1": {
1235
+
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="
1236
+
},
1237
"parse5@7.3.0": {
1238
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
1239
"dependencies": [
1240
"entities"
1241
]
1242
},
1243
+
"pathe@2.0.3": {
1244
+
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="
1245
},
1246
"picocolors@1.1.1": {
1247
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
···
1271
"react-is@17.0.2": {
1272
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
1273
},
1274
"redent@3.0.0": {
1275
"integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
1276
"dependencies": [
···
1278
"strip-indent"
1279
]
1280
},
1281
+
"rollup@4.54.0": {
1282
+
"integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==",
1283
"dependencies": [
1284
"@types/estree"
1285
],
···
1349
"min-indent"
1350
]
1351
},
1352
+
"svelte-i18n@4.0.1_svelte@5.46.1__acorn@8.15.0": {
1353
"integrity": "sha512-jaykGlGT5PUaaq04JWbJREvivlCnALtT+m87Kbm0fxyYHynkQaxQMnIKHLm2WeIuBRoljzwgyvz0Z6/CMwfdmQ==",
1354
"dependencies": [
1355
"cli-color",
···
1363
],
1364
"bin": true
1365
},
1366
+
"svelte@5.46.1_acorn@8.15.0": {
1367
+
"integrity": "sha512-ynjfCHD3nP2el70kN5Pmg37sSi0EjOm9FgHYQdC4giWG/hzO3AatzXXJJgP305uIhGQxSufJLuYWtkY8uK/8RA==",
1368
"dependencies": [
1369
"@jridgewell/remapping",
1370
"@jridgewell/sourcemap-codec",
···
1403
"tinybench@2.9.0": {
1404
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="
1405
},
1406
+
"tinyexec@1.0.2": {
1407
+
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="
1408
},
1409
"tinyglobby@0.2.15_picomatch@4.0.3": {
1410
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
···
1413
"picomatch"
1414
]
1415
},
1416
+
"tinyrainbow@3.0.3": {
1417
+
"integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="
1418
},
1419
"tldts-core@6.1.86": {
1420
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="
···
1444
"type@2.7.3": {
1445
"integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="
1446
},
1447
+
"unicode-segmenter@0.14.5": {
1448
+
"integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g=="
1449
},
1450
+
"vite@7.3.0_picomatch@4.0.3": {
1451
+
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
1452
"dependencies": [
1453
+
"esbuild@0.27.2",
1454
"fdir",
1455
"picomatch",
1456
"postcss",
···
1462
],
1463
"bin": true
1464
},
1465
+
"vitefu@1.1.1_vite@7.3.0__picomatch@4.0.3": {
1466
"integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==",
1467
"dependencies": [
1468
+
"vite"
1469
],
1470
"optionalPeers": [
1471
+
"vite"
1472
]
1473
},
1474
+
"vitest@4.0.16_jsdom@25.0.1_vite@7.3.0__picomatch@4.0.3": {
1475
+
"integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==",
1476
"dependencies": [
1477
"@vitest/expect",
1478
"@vitest/mocker",
···
1481
"@vitest/snapshot",
1482
"@vitest/spy",
1483
"@vitest/utils",
1484
+
"es-module-lexer",
1485
"expect-type",
1486
"jsdom",
1487
"magic-string",
1488
+
"obug",
1489
"pathe",
1490
+
"picomatch",
1491
"std-env",
1492
"tinybench",
1493
"tinyexec",
1494
+
"tinyglobby",
1495
"tinyrainbow",
1496
+
"vite",
1497
"why-is-node-running"
1498
],
1499
"optionalPeers": [
···
1514
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
1515
"dependencies": [
1516
"iconv-lite"
1517
+
],
1518
+
"deprecated": true
1519
},
1520
"whatwg-mimetype@4.0.0": {
1521
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="
···
1546
},
1547
"zimmerframe@1.1.4": {
1548
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="
1549
+
},
1550
+
"zod@4.3.5": {
1551
+
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="
1552
}
1553
},
1554
"workspace": {
···
1558
"npm:@atcute/crypto@^2.3.0",
1559
"npm:@atcute/did-plc@~0.3.1",
1560
"npm:@atcute/multibase@^1.1.6",
1561
+
"npm:@noble/secp256k1@3",
1562
+
"npm:@sveltejs/vite-plugin-svelte@^6.2.1",
1563
+
"npm:@testing-library/jest-dom@^6.9.1",
1564
+
"npm:@testing-library/svelte@^5.3.1",
1565
+
"npm:@testing-library/user-event@^14.6.1",
1566
"npm:jsdom@^25.0.1",
1567
+
"npm:multiformats@^13.4.2",
1568
"npm:svelte-i18n@^4.0.1",
1569
+
"npm:svelte@^5.46.1",
1570
+
"npm:vite@^7.3.0",
1571
+
"npm:vitest@^4.0.16",
1572
+
"npm:zod@^4.3.5"
1573
]
1574
}
1575
}
+11
-10
frontend/package.json
+11
-10
frontend/package.json
···
16
"@atcute/crypto": "^2.3.0",
17
"@atcute/did-plc": "^0.3.1",
18
"@atcute/multibase": "^1.1.6",
19
-
"@noble/secp256k1": "^2.1.0",
20
-
"multiformats": "^13.3.1",
21
-
"svelte-i18n": "^4.0.1"
22
},
23
"devDependencies": {
24
-
"@sveltejs/vite-plugin-svelte": "^5.0.0",
25
-
"@testing-library/jest-dom": "^6.6.3",
26
-
"@testing-library/svelte": "^5.2.6",
27
-
"@testing-library/user-event": "^14.5.2",
28
"jsdom": "^25.0.1",
29
-
"svelte": "^5.0.0",
30
-
"vite": "^6.0.0",
31
-
"vitest": "^2.1.8"
32
}
33
}
···
16
"@atcute/crypto": "^2.3.0",
17
"@atcute/did-plc": "^0.3.1",
18
"@atcute/multibase": "^1.1.6",
19
+
"@noble/secp256k1": "^3.0.0",
20
+
"multiformats": "^13.4.2",
21
+
"svelte-i18n": "^4.0.1",
22
+
"zod": "^4.3.5"
23
},
24
"devDependencies": {
25
+
"@sveltejs/vite-plugin-svelte": "^6.2.1",
26
+
"@testing-library/jest-dom": "^6.9.1",
27
+
"@testing-library/svelte": "^5.3.1",
28
+
"@testing-library/user-event": "^14.6.1",
29
"jsdom": "^25.0.1",
30
+
"svelte": "^5.46.1",
31
+
"vite": "^7.3.0",
32
+
"vitest": "^4.0.16"
33
}
34
}
+7
-11
frontend/src/App.svelte
+7
-11
frontend/src/App.svelte
···
4
import { initServerConfig } from './lib/serverConfig.svelte'
5
import { initI18n } from './lib/i18n'
6
import { isLoading as i18nLoading } from 'svelte-i18n'
7
import Login from './routes/Login.svelte'
8
import Register from './routes/Register.svelte'
9
import RegisterPasskey from './routes/RegisterPasskey.svelte'
···
36
import DidDocumentEditor from './routes/DidDocumentEditor.svelte'
37
initI18n()
38
39
-
const auth = getAuthState()
40
41
let oauthCallbackPending = $state(hasOAuthCallback())
42
···
59
})
60
61
$effect(() => {
62
-
if (auth.loading) return
63
const path = getCurrentPath()
64
if (path === '/') {
65
-
if (auth.session) {
66
navigate('/dashboard', true)
67
} else {
68
navigate('/login', true)
···
142
</script>
143
144
<main>
145
-
{#if auth.loading || $i18nLoading || oauthCallbackPending}
146
-
<div class="loading">
147
-
<p>Loading...</p>
148
-
</div>
149
{:else}
150
<CurrentComponent />
151
{/if}
152
</main>
153
154
<style>
155
main {
···
157
}
158
159
.loading {
160
-
display: flex;
161
-
align-items: center;
162
-
justify-content: center;
163
min-height: 100vh;
164
-
color: var(--text-secondary);
165
}
166
</style>
···
4
import { initServerConfig } from './lib/serverConfig.svelte'
5
import { initI18n } from './lib/i18n'
6
import { isLoading as i18nLoading } from 'svelte-i18n'
7
+
import Toast from './components/Toast.svelte'
8
import Login from './routes/Login.svelte'
9
import Register from './routes/Register.svelte'
10
import RegisterPasskey from './routes/RegisterPasskey.svelte'
···
37
import DidDocumentEditor from './routes/DidDocumentEditor.svelte'
38
initI18n()
39
40
+
const auth = $derived(getAuthState())
41
42
let oauthCallbackPending = $state(hasOAuthCallback())
43
···
60
})
61
62
$effect(() => {
63
+
if (auth.kind === 'loading') return
64
const path = getCurrentPath()
65
if (path === '/') {
66
+
if (auth.kind === 'authenticated') {
67
navigate('/dashboard', true)
68
} else {
69
navigate('/login', true)
···
143
</script>
144
145
<main>
146
+
{#if auth.kind === 'loading' || $i18nLoading || oauthCallbackPending}
147
+
<div class="loading"></div>
148
{:else}
149
<CurrentComponent />
150
{/if}
151
</main>
152
+
<Toast />
153
154
<style>
155
main {
···
157
}
158
159
.loading {
160
min-height: 100vh;
161
}
162
</style>
+18
-49
frontend/src/components/ReauthModal.svelte
+18
-49
frontend/src/components/ReauthModal.svelte
···
2
import { getAuthState, getValidToken } from '../lib/auth.svelte'
3
import { api, ApiError } from '../lib/api'
4
import { _ } from '../lib/i18n'
5
6
interface Props {
7
show: boolean
···
12
13
let { show = $bindable(), availableMethods = ['password'], onSuccess, onCancel }: Props = $props()
14
15
-
const auth = getAuthState()
16
let activeMethod = $state<'password' | 'totp' | 'passkey'>('password')
17
let password = $state('')
18
let totpCode = $state('')
···
37
}
38
})
39
40
-
function arrayBufferToBase64Url(buffer: ArrayBuffer): string {
41
-
const bytes = new Uint8Array(buffer)
42
-
let binary = ''
43
-
for (let i = 0; i < bytes.byteLength; i++) {
44
-
binary += String.fromCharCode(bytes[i])
45
-
}
46
-
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
47
-
}
48
-
49
-
function base64UrlToArrayBuffer(base64url: string): ArrayBuffer {
50
-
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
51
-
const padded = base64 + '='.repeat((4 - base64.length % 4) % 4)
52
-
const binary = atob(padded)
53
-
const bytes = new Uint8Array(binary.length)
54
-
for (let i = 0; i < binary.length; i++) {
55
-
bytes[i] = binary.charCodeAt(i)
56
-
}
57
-
return bytes.buffer
58
-
}
59
-
60
-
function prepareAuthOptions(options: any): PublicKeyCredentialRequestOptions {
61
-
return {
62
-
...options.publicKey,
63
-
challenge: base64UrlToArrayBuffer(options.publicKey.challenge),
64
-
allowCredentials: options.publicKey.allowCredentials?.map((cred: any) => ({
65
-
...cred,
66
-
id: base64UrlToArrayBuffer(cred.id)
67
-
})) || []
68
-
}
69
-
}
70
-
71
async function handlePasswordSubmit(e: Event) {
72
e.preventDefault()
73
-
if (!auth.session || !password) return
74
loading = true
75
error = ''
76
try {
···
91
92
async function handleTotpSubmit(e: Event) {
93
e.preventDefault()
94
-
if (!auth.session || !totpCode) return
95
loading = true
96
error = ''
97
try {
···
111
}
112
113
async function handlePasskeyAuth() {
114
-
if (!auth.session) return
115
if (!window.PublicKeyCredential) {
116
error = 'Passkeys are not supported in this browser'
117
return
···
125
return
126
}
127
const { options } = await api.reauthPasskeyStart(token)
128
-
const publicKeyOptions = prepareAuthOptions(options)
129
const credential = await navigator.credentials.get({
130
publicKey: publicKeyOptions
131
})
···
133
error = 'Passkey authentication was cancelled'
134
return
135
}
136
-
const pkCredential = credential as PublicKeyCredential
137
-
const response = pkCredential.response as AuthenticatorAssertionResponse
138
-
const credentialResponse = {
139
-
id: pkCredential.id,
140
-
type: pkCredential.type,
141
-
rawId: arrayBufferToBase64Url(pkCredential.rawId),
142
-
response: {
143
-
clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON),
144
-
authenticatorData: arrayBufferToBase64Url(response.authenticatorData),
145
-
signature: arrayBufferToBase64Url(response.signature),
146
-
userHandle: response.userHandle ? arrayBufferToBase64Url(response.userHandle) : null,
147
-
},
148
-
}
149
await api.reauthPasskeyFinish(token, credentialResponse)
150
show = false
151
onSuccess()
···
2
import { getAuthState, getValidToken } from '../lib/auth.svelte'
3
import { api, ApiError } from '../lib/api'
4
import { _ } from '../lib/i18n'
5
+
import type { Session } from '../lib/types/api'
6
+
import {
7
+
prepareRequestOptions,
8
+
serializeAssertionResponse,
9
+
type WebAuthnRequestOptionsResponse,
10
+
} from '../lib/webauthn'
11
12
interface Props {
13
show: boolean
···
18
19
let { show = $bindable(), availableMethods = ['password'], onSuccess, onCancel }: Props = $props()
20
21
+
const auth = $derived(getAuthState())
22
+
23
+
function getSession(): Session | null {
24
+
return auth.kind === 'authenticated' ? auth.session : null
25
+
}
26
+
27
+
const session = $derived(getSession())
28
let activeMethod = $state<'password' | 'totp' | 'passkey'>('password')
29
let password = $state('')
30
let totpCode = $state('')
···
49
}
50
})
51
52
async function handlePasswordSubmit(e: Event) {
53
e.preventDefault()
54
+
if (!session || !password) return
55
loading = true
56
error = ''
57
try {
···
72
73
async function handleTotpSubmit(e: Event) {
74
e.preventDefault()
75
+
if (!session || !totpCode) return
76
loading = true
77
error = ''
78
try {
···
92
}
93
94
async function handlePasskeyAuth() {
95
+
if (!session) return
96
if (!window.PublicKeyCredential) {
97
error = 'Passkeys are not supported in this browser'
98
return
···
106
return
107
}
108
const { options } = await api.reauthPasskeyStart(token)
109
+
const publicKeyOptions = prepareRequestOptions(options as WebAuthnRequestOptionsResponse)
110
const credential = await navigator.credentials.get({
111
publicKey: publicKeyOptions
112
})
···
114
error = 'Passkey authentication was cancelled'
115
return
116
}
117
+
const credentialResponse = serializeAssertionResponse(credential as PublicKeyCredential)
118
await api.reauthPasskeyFinish(token, credentialResponse)
119
show = false
120
onSuccess()
+77
frontend/src/components/Skeleton.svelte
+77
frontend/src/components/Skeleton.svelte
···
···
1
+
<script lang="ts">
2
+
type Variant = 'line' | 'circle' | 'card'
3
+
type Size = 'tiny' | 'short' | 'medium' | 'full'
4
+
5
+
interface Props {
6
+
variant?: Variant
7
+
size?: Size
8
+
lines?: number
9
+
class?: string
10
+
}
11
+
12
+
let { variant = 'line', size = 'full', lines = 1, class: className = '' }: Props = $props()
13
+
</script>
14
+
15
+
{#if variant === 'card'}
16
+
<div class="skeleton-card {className}">
17
+
<div class="skeleton-header">
18
+
<div class="skeleton-line short"></div>
19
+
<div class="skeleton-line tiny"></div>
20
+
</div>
21
+
{#each Array(lines) as _}
22
+
<div class="skeleton-line"></div>
23
+
{/each}
24
+
<div class="skeleton-line medium"></div>
25
+
</div>
26
+
{:else if variant === 'circle'}
27
+
<div class="skeleton-circle {className}"></div>
28
+
{:else}
29
+
{#each Array(lines) as _, i}
30
+
<div class="skeleton-line {size} {className}" class:last={i === lines - 1}></div>
31
+
{/each}
32
+
{/if}
33
+
34
+
<style>
35
+
.skeleton-card {
36
+
background: var(--bg-card);
37
+
border: 1px solid var(--border-color);
38
+
border-radius: var(--radius-md);
39
+
padding: var(--space-3);
40
+
}
41
+
42
+
.skeleton-header {
43
+
display: flex;
44
+
gap: var(--space-2);
45
+
margin-bottom: var(--space-2);
46
+
}
47
+
48
+
.skeleton-line {
49
+
height: 14px;
50
+
background: var(--bg-tertiary);
51
+
border-radius: var(--radius-sm);
52
+
animation: skeleton-pulse 1.5s ease-in-out infinite;
53
+
margin-bottom: var(--space-1);
54
+
}
55
+
56
+
.skeleton-line.last {
57
+
margin-bottom: 0;
58
+
}
59
+
60
+
.skeleton-line.tiny { width: 50px; }
61
+
.skeleton-line.short { width: 80px; }
62
+
.skeleton-line.medium { width: 60%; }
63
+
.skeleton-line.full { width: 100%; }
64
+
65
+
.skeleton-circle {
66
+
width: 40px;
67
+
height: 40px;
68
+
border-radius: 50%;
69
+
background: var(--bg-tertiary);
70
+
animation: skeleton-pulse 1.5s ease-in-out infinite;
71
+
}
72
+
73
+
@keyframes skeleton-pulse {
74
+
0%, 100% { opacity: 1; }
75
+
50% { opacity: 0.4; }
76
+
}
77
+
</style>
+188
frontend/src/components/Toast.svelte
+188
frontend/src/components/Toast.svelte
···
···
1
+
<script lang="ts">
2
+
import { getToasts, dismissToast, type Toast } from '../lib/toast.svelte'
3
+
4
+
const toasts = $derived(getToasts())
5
+
6
+
function handleDismiss(id: number) {
7
+
dismissToast(id)
8
+
}
9
+
10
+
function getIcon(type: Toast['type']): string {
11
+
switch (type) {
12
+
case 'success':
13
+
return '✓'
14
+
case 'error':
15
+
return '!'
16
+
case 'warning':
17
+
return '⚠'
18
+
case 'info':
19
+
return 'i'
20
+
}
21
+
}
22
+
</script>
23
+
24
+
{#if toasts.length > 0}
25
+
<div class="toast-container" role="region" aria-label="Notifications">
26
+
{#each toasts as toast (toast.id)}
27
+
<div
28
+
class="toast toast-{toast.type}"
29
+
class:dismissing={toast.dismissing}
30
+
role="alert"
31
+
aria-live="polite"
32
+
>
33
+
<span class="toast-icon">{getIcon(toast.type)}</span>
34
+
<span class="toast-message">{toast.message}</span>
35
+
<button
36
+
type="button"
37
+
class="toast-dismiss"
38
+
onclick={() => handleDismiss(toast.id)}
39
+
aria-label="Dismiss notification"
40
+
>
41
+
x
42
+
</button>
43
+
</div>
44
+
{/each}
45
+
</div>
46
+
{/if}
47
+
48
+
<style>
49
+
.toast-container {
50
+
position: fixed;
51
+
top: var(--space-6);
52
+
right: var(--space-6);
53
+
z-index: 9999;
54
+
display: flex;
55
+
flex-direction: column;
56
+
gap: var(--space-3);
57
+
max-width: min(400px, calc(100vw - var(--space-12)));
58
+
pointer-events: none;
59
+
}
60
+
61
+
.toast {
62
+
display: flex;
63
+
align-items: flex-start;
64
+
gap: var(--space-3);
65
+
padding: var(--space-4);
66
+
border-radius: var(--radius-lg);
67
+
box-shadow: var(--shadow-lg);
68
+
pointer-events: auto;
69
+
animation: toast-in 0.1s ease-out;
70
+
}
71
+
72
+
.toast.dismissing {
73
+
animation: toast-out 0.15s ease-in forwards;
74
+
}
75
+
76
+
@keyframes toast-in {
77
+
from {
78
+
opacity: 0;
79
+
transform: scale(0.95);
80
+
}
81
+
to {
82
+
opacity: 1;
83
+
transform: scale(1);
84
+
}
85
+
}
86
+
87
+
@keyframes toast-out {
88
+
from {
89
+
opacity: 1;
90
+
transform: scale(1);
91
+
}
92
+
to {
93
+
opacity: 0;
94
+
transform: scale(0.95);
95
+
}
96
+
}
97
+
98
+
.toast-success {
99
+
background: var(--success-bg);
100
+
border: 1px solid var(--success-border);
101
+
color: var(--success-text);
102
+
}
103
+
104
+
.toast-error {
105
+
background: var(--error-bg);
106
+
border: 1px solid var(--error-border);
107
+
color: var(--error-text);
108
+
}
109
+
110
+
.toast-warning {
111
+
background: var(--warning-bg);
112
+
border: 1px solid var(--warning-border);
113
+
color: var(--warning-text);
114
+
}
115
+
116
+
.toast-info {
117
+
background: var(--accent-muted);
118
+
border: 1px solid var(--accent);
119
+
color: var(--text-primary);
120
+
}
121
+
122
+
.toast-icon {
123
+
flex-shrink: 0;
124
+
width: 20px;
125
+
height: 20px;
126
+
display: flex;
127
+
align-items: center;
128
+
justify-content: center;
129
+
border-radius: 50%;
130
+
font-size: var(--text-xs);
131
+
font-weight: var(--font-bold);
132
+
}
133
+
134
+
.toast-success .toast-icon {
135
+
background: var(--success-text);
136
+
color: var(--success-bg);
137
+
}
138
+
139
+
.toast-error .toast-icon {
140
+
background: var(--error-text);
141
+
color: var(--error-bg);
142
+
}
143
+
144
+
.toast-warning .toast-icon {
145
+
background: var(--warning-text);
146
+
color: var(--warning-bg);
147
+
}
148
+
149
+
.toast-info .toast-icon {
150
+
background: var(--accent);
151
+
color: var(--bg-card);
152
+
}
153
+
154
+
.toast-message {
155
+
flex: 1;
156
+
font-size: var(--text-sm);
157
+
line-height: 1.4;
158
+
}
159
+
160
+
.toast-dismiss {
161
+
flex-shrink: 0;
162
+
width: 20px;
163
+
height: 20px;
164
+
padding: 0;
165
+
border: none;
166
+
background: transparent;
167
+
cursor: pointer;
168
+
opacity: 0.6;
169
+
font-size: var(--text-sm);
170
+
line-height: 1;
171
+
color: inherit;
172
+
border-radius: var(--radius-sm);
173
+
}
174
+
175
+
.toast-dismiss:hover {
176
+
opacity: 1;
177
+
background: rgba(0, 0, 0, 0.1);
178
+
}
179
+
180
+
@media (max-width: 480px) {
181
+
.toast-container {
182
+
top: var(--space-4);
183
+
right: var(--space-4);
184
+
left: var(--space-4);
185
+
max-width: none;
186
+
}
187
+
}
188
+
</style>
+345
frontend/src/lib/api-validated.ts
+345
frontend/src/lib/api-validated.ts
···
···
1
+
import { z } from 'zod'
2
+
import { ok, err, type Result } from './types/result'
3
+
import { ApiError } from './api'
4
+
import type { AccessToken, RefreshToken, Did, Handle, Nsid, Rkey } from './types/branded'
5
+
import {
6
+
sessionSchema,
7
+
serverDescriptionSchema,
8
+
appPasswordSchema,
9
+
createdAppPasswordSchema,
10
+
listSessionsResponseSchema,
11
+
totpStatusSchema,
12
+
totpSecretSchema,
13
+
enableTotpResponseSchema,
14
+
listPasskeysResponseSchema,
15
+
listTrustedDevicesResponseSchema,
16
+
reauthStatusSchema,
17
+
notificationPrefsSchema,
18
+
didDocumentSchema,
19
+
repoDescriptionSchema,
20
+
listRecordsResponseSchema,
21
+
recordResponseSchema,
22
+
createRecordResponseSchema,
23
+
serverStatsSchema,
24
+
serverConfigSchema,
25
+
passwordStatusSchema,
26
+
successResponseSchema,
27
+
legacyLoginPreferenceSchema,
28
+
accountInfoSchema,
29
+
searchAccountsResponseSchema,
30
+
listBackupsResponseSchema,
31
+
createBackupResponseSchema,
32
+
type ValidatedSession,
33
+
type ValidatedServerDescription,
34
+
type ValidatedListSessionsResponse,
35
+
type ValidatedTotpStatus,
36
+
type ValidatedTotpSecret,
37
+
type ValidatedEnableTotpResponse,
38
+
type ValidatedListPasskeysResponse,
39
+
type ValidatedListTrustedDevicesResponse,
40
+
type ValidatedReauthStatus,
41
+
type ValidatedNotificationPrefs,
42
+
type ValidatedDidDocument,
43
+
type ValidatedRepoDescription,
44
+
type ValidatedListRecordsResponse,
45
+
type ValidatedRecordResponse,
46
+
type ValidatedCreateRecordResponse,
47
+
type ValidatedServerStats,
48
+
type ValidatedServerConfig,
49
+
type ValidatedPasswordStatus,
50
+
type ValidatedSuccessResponse,
51
+
type ValidatedLegacyLoginPreference,
52
+
type ValidatedAccountInfo,
53
+
type ValidatedSearchAccountsResponse,
54
+
type ValidatedListBackupsResponse,
55
+
type ValidatedCreateBackupResponse,
56
+
type ValidatedCreatedAppPassword,
57
+
type ValidatedAppPassword,
58
+
} from './types/schemas'
59
+
60
+
const API_BASE = '/xrpc'
61
+
62
+
interface XrpcOptions {
63
+
method?: 'GET' | 'POST'
64
+
params?: Record<string, string>
65
+
body?: unknown
66
+
token?: string
67
+
}
68
+
69
+
class ValidationError extends Error {
70
+
constructor(
71
+
public issues: z.ZodIssue[],
72
+
message: string = 'API response validation failed'
73
+
) {
74
+
super(message)
75
+
this.name = 'ValidationError'
76
+
}
77
+
}
78
+
79
+
async function xrpcValidated<T>(
80
+
method: string,
81
+
schema: z.ZodType<T>,
82
+
options?: XrpcOptions
83
+
): Promise<Result<T, ApiError | ValidationError>> {
84
+
const { method: httpMethod = 'GET', params, body, token } = options ?? {}
85
+
let url = `${API_BASE}/${method}`
86
+
if (params) {
87
+
const searchParams = new URLSearchParams(params)
88
+
url += `?${searchParams}`
89
+
}
90
+
const headers: Record<string, string> = {}
91
+
if (token) {
92
+
headers['Authorization'] = `Bearer ${token}`
93
+
}
94
+
if (body) {
95
+
headers['Content-Type'] = 'application/json'
96
+
}
97
+
98
+
try {
99
+
const res = await fetch(url, {
100
+
method: httpMethod,
101
+
headers,
102
+
body: body ? JSON.stringify(body) : undefined,
103
+
})
104
+
105
+
if (!res.ok) {
106
+
const errData = await res.json().catch(() => ({
107
+
error: 'Unknown',
108
+
message: res.statusText,
109
+
}))
110
+
return err(new ApiError(res.status, errData.error, errData.message))
111
+
}
112
+
113
+
const data = await res.json()
114
+
const parsed = schema.safeParse(data)
115
+
116
+
if (!parsed.success) {
117
+
return err(new ValidationError(parsed.error.issues))
118
+
}
119
+
120
+
return ok(parsed.data)
121
+
} catch (e) {
122
+
if (e instanceof ApiError || e instanceof ValidationError) {
123
+
return err(e)
124
+
}
125
+
return err(new ApiError(0, 'Unknown', e instanceof Error ? e.message : String(e)))
126
+
}
127
+
}
128
+
129
+
export const validatedApi = {
130
+
getSession(token: AccessToken): Promise<Result<ValidatedSession, ApiError | ValidationError>> {
131
+
return xrpcValidated('com.atproto.server.getSession', sessionSchema, { token })
132
+
},
133
+
134
+
refreshSession(refreshJwt: RefreshToken): Promise<Result<ValidatedSession, ApiError | ValidationError>> {
135
+
return xrpcValidated('com.atproto.server.refreshSession', sessionSchema, {
136
+
method: 'POST',
137
+
token: refreshJwt,
138
+
})
139
+
},
140
+
141
+
createSession(
142
+
identifier: string,
143
+
password: string
144
+
): Promise<Result<ValidatedSession, ApiError | ValidationError>> {
145
+
return xrpcValidated('com.atproto.server.createSession', sessionSchema, {
146
+
method: 'POST',
147
+
body: { identifier, password },
148
+
})
149
+
},
150
+
151
+
describeServer(): Promise<Result<ValidatedServerDescription, ApiError | ValidationError>> {
152
+
return xrpcValidated('com.atproto.server.describeServer', serverDescriptionSchema)
153
+
},
154
+
155
+
listAppPasswords(
156
+
token: AccessToken
157
+
): Promise<Result<{ passwords: ValidatedAppPassword[] }, ApiError | ValidationError>> {
158
+
return xrpcValidated(
159
+
'com.atproto.server.listAppPasswords',
160
+
z.object({ passwords: z.array(appPasswordSchema) }),
161
+
{ token }
162
+
)
163
+
},
164
+
165
+
createAppPassword(
166
+
token: AccessToken,
167
+
name: string,
168
+
scopes?: string
169
+
): Promise<Result<ValidatedCreatedAppPassword, ApiError | ValidationError>> {
170
+
return xrpcValidated('com.atproto.server.createAppPassword', createdAppPasswordSchema, {
171
+
method: 'POST',
172
+
token,
173
+
body: { name, scopes },
174
+
})
175
+
},
176
+
177
+
listSessions(token: AccessToken): Promise<Result<ValidatedListSessionsResponse, ApiError | ValidationError>> {
178
+
return xrpcValidated('_account.listSessions', listSessionsResponseSchema, { token })
179
+
},
180
+
181
+
getTotpStatus(token: AccessToken): Promise<Result<ValidatedTotpStatus, ApiError | ValidationError>> {
182
+
return xrpcValidated('com.atproto.server.getTotpStatus', totpStatusSchema, { token })
183
+
},
184
+
185
+
createTotpSecret(token: AccessToken): Promise<Result<ValidatedTotpSecret, ApiError | ValidationError>> {
186
+
return xrpcValidated('com.atproto.server.createTotpSecret', totpSecretSchema, {
187
+
method: 'POST',
188
+
token,
189
+
})
190
+
},
191
+
192
+
enableTotp(
193
+
token: AccessToken,
194
+
code: string
195
+
): Promise<Result<ValidatedEnableTotpResponse, ApiError | ValidationError>> {
196
+
return xrpcValidated('com.atproto.server.enableTotp', enableTotpResponseSchema, {
197
+
method: 'POST',
198
+
token,
199
+
body: { code },
200
+
})
201
+
},
202
+
203
+
listPasskeys(token: AccessToken): Promise<Result<ValidatedListPasskeysResponse, ApiError | ValidationError>> {
204
+
return xrpcValidated('com.atproto.server.listPasskeys', listPasskeysResponseSchema, { token })
205
+
},
206
+
207
+
listTrustedDevices(
208
+
token: AccessToken
209
+
): Promise<Result<ValidatedListTrustedDevicesResponse, ApiError | ValidationError>> {
210
+
return xrpcValidated('_account.listTrustedDevices', listTrustedDevicesResponseSchema, { token })
211
+
},
212
+
213
+
getReauthStatus(token: AccessToken): Promise<Result<ValidatedReauthStatus, ApiError | ValidationError>> {
214
+
return xrpcValidated('_account.getReauthStatus', reauthStatusSchema, { token })
215
+
},
216
+
217
+
getNotificationPrefs(
218
+
token: AccessToken
219
+
): Promise<Result<ValidatedNotificationPrefs, ApiError | ValidationError>> {
220
+
return xrpcValidated('_account.getNotificationPrefs', notificationPrefsSchema, { token })
221
+
},
222
+
223
+
getDidDocument(token: AccessToken): Promise<Result<ValidatedDidDocument, ApiError | ValidationError>> {
224
+
return xrpcValidated('_account.getDidDocument', didDocumentSchema, { token })
225
+
},
226
+
227
+
describeRepo(
228
+
token: AccessToken,
229
+
repo: Did
230
+
): Promise<Result<ValidatedRepoDescription, ApiError | ValidationError>> {
231
+
return xrpcValidated('com.atproto.repo.describeRepo', repoDescriptionSchema, {
232
+
token,
233
+
params: { repo },
234
+
})
235
+
},
236
+
237
+
listRecords(
238
+
token: AccessToken,
239
+
repo: Did,
240
+
collection: Nsid,
241
+
options?: { limit?: number; cursor?: string; reverse?: boolean }
242
+
): Promise<Result<ValidatedListRecordsResponse, ApiError | ValidationError>> {
243
+
const params: Record<string, string> = { repo, collection }
244
+
if (options?.limit) params.limit = String(options.limit)
245
+
if (options?.cursor) params.cursor = options.cursor
246
+
if (options?.reverse) params.reverse = 'true'
247
+
return xrpcValidated('com.atproto.repo.listRecords', listRecordsResponseSchema, {
248
+
token,
249
+
params,
250
+
})
251
+
},
252
+
253
+
getRecord(
254
+
token: AccessToken,
255
+
repo: Did,
256
+
collection: Nsid,
257
+
rkey: Rkey
258
+
): Promise<Result<ValidatedRecordResponse, ApiError | ValidationError>> {
259
+
return xrpcValidated('com.atproto.repo.getRecord', recordResponseSchema, {
260
+
token,
261
+
params: { repo, collection, rkey },
262
+
})
263
+
},
264
+
265
+
createRecord(
266
+
token: AccessToken,
267
+
repo: Did,
268
+
collection: Nsid,
269
+
record: unknown,
270
+
rkey?: Rkey
271
+
): Promise<Result<ValidatedCreateRecordResponse, ApiError | ValidationError>> {
272
+
return xrpcValidated('com.atproto.repo.createRecord', createRecordResponseSchema, {
273
+
method: 'POST',
274
+
token,
275
+
body: { repo, collection, record, rkey },
276
+
})
277
+
},
278
+
279
+
getServerStats(token: AccessToken): Promise<Result<ValidatedServerStats, ApiError | ValidationError>> {
280
+
return xrpcValidated('_admin.getServerStats', serverStatsSchema, { token })
281
+
},
282
+
283
+
getServerConfig(): Promise<Result<ValidatedServerConfig, ApiError | ValidationError>> {
284
+
return xrpcValidated('_server.getConfig', serverConfigSchema)
285
+
},
286
+
287
+
getPasswordStatus(token: AccessToken): Promise<Result<ValidatedPasswordStatus, ApiError | ValidationError>> {
288
+
return xrpcValidated('_account.getPasswordStatus', passwordStatusSchema, { token })
289
+
},
290
+
291
+
changePassword(
292
+
token: AccessToken,
293
+
currentPassword: string,
294
+
newPassword: string
295
+
): Promise<Result<ValidatedSuccessResponse, ApiError | ValidationError>> {
296
+
return xrpcValidated('_account.changePassword', successResponseSchema, {
297
+
method: 'POST',
298
+
token,
299
+
body: { currentPassword, newPassword },
300
+
})
301
+
},
302
+
303
+
getLegacyLoginPreference(
304
+
token: AccessToken
305
+
): Promise<Result<ValidatedLegacyLoginPreference, ApiError | ValidationError>> {
306
+
return xrpcValidated('_account.getLegacyLoginPreference', legacyLoginPreferenceSchema, { token })
307
+
},
308
+
309
+
getAccountInfo(
310
+
token: AccessToken,
311
+
did: Did
312
+
): Promise<Result<ValidatedAccountInfo, ApiError | ValidationError>> {
313
+
return xrpcValidated('com.atproto.admin.getAccountInfo', accountInfoSchema, {
314
+
token,
315
+
params: { did },
316
+
})
317
+
},
318
+
319
+
searchAccounts(
320
+
token: AccessToken,
321
+
options?: { handle?: string; cursor?: string; limit?: number }
322
+
): Promise<Result<ValidatedSearchAccountsResponse, ApiError | ValidationError>> {
323
+
const params: Record<string, string> = {}
324
+
if (options?.handle) params.handle = options.handle
325
+
if (options?.cursor) params.cursor = options.cursor
326
+
if (options?.limit) params.limit = String(options.limit)
327
+
return xrpcValidated('com.atproto.admin.searchAccounts', searchAccountsResponseSchema, {
328
+
token,
329
+
params,
330
+
})
331
+
},
332
+
333
+
listBackups(token: AccessToken): Promise<Result<ValidatedListBackupsResponse, ApiError | ValidationError>> {
334
+
return xrpcValidated('_backup.listBackups', listBackupsResponseSchema, { token })
335
+
},
336
+
337
+
createBackup(token: AccessToken): Promise<Result<ValidatedCreateBackupResponse, ApiError | ValidationError>> {
338
+
return xrpcValidated('_backup.createBackup', createBackupResponseSchema, {
339
+
method: 'POST',
340
+
token,
341
+
})
342
+
},
343
+
}
344
+
345
+
export { ValidationError }
+1165
-766
frontend/src/lib/api.ts
+1165
-766
frontend/src/lib/api.ts
···
1
-
const API_BASE = "/xrpc";
2
3
export class ApiError extends Error {
4
-
public did?: string;
5
-
public reauthMethods?: string[];
6
constructor(
7
public status: number,
8
-
public error: string,
9
message: string,
10
did?: string,
11
reauthMethods?: string[],
12
) {
13
-
super(message);
14
-
this.name = "ApiError";
15
-
this.did = did;
16
-
this.reauthMethods = reauthMethods;
17
}
18
}
19
20
-
let tokenRefreshCallback: (() => Promise<string | null>) | null = null;
21
22
export function setTokenRefreshCallback(
23
callback: () => Promise<string | null>,
24
) {
25
-
tokenRefreshCallback = callback;
26
}
27
28
-
async function xrpc<T>(method: string, options?: {
29
-
method?: "GET" | "POST";
30
-
params?: Record<string, string>;
31
-
body?: unknown;
32
-
token?: string;
33
-
skipRetry?: boolean;
34
-
}): Promise<T> {
35
-
const { method: httpMethod = "GET", params, body, token, skipRetry } =
36
-
options ?? {};
37
-
let url = `${API_BASE}/${method}`;
38
if (params) {
39
-
const searchParams = new URLSearchParams(params);
40
-
url += `?${searchParams}`;
41
}
42
-
const headers: Record<string, string> = {};
43
if (token) {
44
-
headers["Authorization"] = `Bearer ${token}`;
45
}
46
if (body) {
47
-
headers["Content-Type"] = "application/json";
48
}
49
const res = await fetch(url, {
50
method: httpMethod,
51
headers,
52
body: body ? JSON.stringify(body) : undefined,
53
-
});
54
if (!res.ok) {
55
-
const err = await res.json().catch(() => ({
56
-
error: "Unknown",
57
message: res.statusText,
58
-
}));
59
if (
60
res.status === 401 &&
61
-
(err.error === "AuthenticationFailed" || err.error === "ExpiredToken") &&
62
token && tokenRefreshCallback && !skipRetry
63
) {
64
-
const newToken = await tokenRefreshCallback();
65
if (newToken && newToken !== token) {
66
-
return xrpc(method, { ...options, token: newToken, skipRetry: true });
67
}
68
}
69
throw new ApiError(
70
res.status,
71
-
err.error,
72
-
err.message,
73
-
err.did,
74
-
err.reauthMethods,
75
-
);
76
}
77
-
return res.json();
78
}
79
80
-
export interface Session {
81
-
did: string;
82
-
handle: string;
83
-
email?: string;
84
-
emailConfirmed?: boolean;
85
-
preferredChannel?: string;
86
-
preferredChannelVerified?: boolean;
87
-
isAdmin?: boolean;
88
-
active?: boolean;
89
-
status?: "active" | "deactivated" | "migrated";
90
-
migratedToPds?: string;
91
-
migratedAt?: string;
92
-
accessJwt: string;
93
-
refreshJwt: string;
94
}
95
96
export interface VerificationMethod {
97
-
id: string;
98
-
type: string;
99
-
publicKeyMultibase: string;
100
}
101
102
-
export interface DidDocument {
103
-
"@context": string[];
104
-
id: string;
105
-
alsoKnownAs: string[];
106
-
verificationMethod: Array<{
107
-
id: string;
108
-
type: string;
109
-
controller: string;
110
-
publicKeyMultibase: string;
111
-
}>;
112
-
service: Array<{
113
-
id: string;
114
-
type: string;
115
-
serviceEndpoint: string;
116
-
}>;
117
-
}
118
119
-
export interface AppPassword {
120
-
name: string;
121
-
createdAt: string;
122
-
scopes?: string;
123
-
createdByController?: string;
124
-
}
125
-
126
-
export interface InviteCode {
127
-
code: string;
128
-
available: number;
129
-
disabled: boolean;
130
-
forAccount: string;
131
-
createdBy: string;
132
-
createdAt: string;
133
-
uses: { usedBy: string; usedByHandle?: string; usedAt: string }[];
134
-
}
135
-
136
-
export type VerificationChannel = "email" | "discord" | "telegram" | "signal";
137
-
138
-
export type DidType = "plc" | "web" | "web-external";
139
-
140
-
export interface CreateAccountParams {
141
-
handle: string;
142
-
email: string;
143
-
password: string;
144
-
inviteCode?: string;
145
-
didType?: DidType;
146
-
did?: string;
147
-
signingKey?: string;
148
-
verificationChannel?: VerificationChannel;
149
-
discordId?: string;
150
-
telegramUsername?: string;
151
-
signalNumber?: string;
152
-
}
153
-
154
-
export interface CreateAccountResult {
155
-
handle: string;
156
-
did: string;
157
-
verificationRequired: boolean;
158
-
verificationChannel: string;
159
-
}
160
-
161
-
export interface ConfirmSignupResult {
162
-
accessJwt: string;
163
-
refreshJwt: string;
164
-
handle: string;
165
-
did: string;
166
-
email?: string;
167
-
emailConfirmed?: boolean;
168
-
preferredChannel?: string;
169
-
preferredChannelVerified?: boolean;
170
}
171
172
export const api = {
···
174
params: CreateAccountParams,
175
byodToken?: string,
176
): Promise<CreateAccountResult> {
177
-
const url = `${API_BASE}/com.atproto.server.createAccount`;
178
const headers: Record<string, string> = {
179
-
"Content-Type": "application/json",
180
-
};
181
if (byodToken) {
182
-
headers["Authorization"] = `Bearer ${byodToken}`;
183
}
184
const response = await fetch(url, {
185
-
method: "POST",
186
headers,
187
body: JSON.stringify({
188
handle: params.handle,
···
197
telegramUsername: params.telegramUsername,
198
signalNumber: params.signalNumber,
199
}),
200
-
});
201
-
const data = await response.json();
202
if (!response.ok) {
203
-
throw new ApiError(response.status, data.error, data.message);
204
}
205
-
return data;
206
},
207
208
async createAccountWithServiceAuth(
209
serviceAuthToken: string,
210
params: {
211
-
did: string;
212
-
handle: string;
213
-
email: string;
214
-
password: string;
215
-
inviteCode?: string;
216
},
217
): Promise<Session> {
218
-
const url = `${API_BASE}/com.atproto.server.createAccount`;
219
const response = await fetch(url, {
220
-
method: "POST",
221
headers: {
222
-
"Content-Type": "application/json",
223
-
"Authorization": `Bearer ${serviceAuthToken}`,
224
},
225
body: JSON.stringify({
226
did: params.did,
···
229
password: params.password,
230
inviteCode: params.inviteCode,
231
}),
232
-
});
233
-
const data = await response.json();
234
if (!response.ok) {
235
-
throw new ApiError(response.status, data.error, data.message);
236
}
237
-
return data;
238
},
239
240
confirmSignup(
241
-
did: string,
242
verificationCode: string,
243
): Promise<ConfirmSignupResult> {
244
-
return xrpc("com.atproto.server.confirmSignup", {
245
-
method: "POST",
246
body: { did, verificationCode },
247
-
});
248
},
249
250
-
resendVerification(did: string): Promise<{ success: boolean }> {
251
-
return xrpc("com.atproto.server.resendVerification", {
252
-
method: "POST",
253
body: { did },
254
-
});
255
},
256
257
-
createSession(identifier: string, password: string): Promise<Session> {
258
-
return xrpc("com.atproto.server.createSession", {
259
-
method: "POST",
260
body: { identifier, password },
261
-
});
262
},
263
264
checkEmailVerified(identifier: string): Promise<{ verified: boolean }> {
265
-
return xrpc("_checkEmailVerified", {
266
-
method: "POST",
267
body: { identifier },
268
-
});
269
},
270
271
-
getSession(token: string): Promise<Session> {
272
-
return xrpc("com.atproto.server.getSession", { token });
273
},
274
275
-
refreshSession(refreshJwt: string): Promise<Session> {
276
-
return xrpc("com.atproto.server.refreshSession", {
277
-
method: "POST",
278
token: refreshJwt,
279
-
});
280
},
281
282
-
async deleteSession(token: string): Promise<void> {
283
-
await xrpc("com.atproto.server.deleteSession", {
284
-
method: "POST",
285
token,
286
-
});
287
},
288
289
-
listAppPasswords(token: string): Promise<{ passwords: AppPassword[] }> {
290
-
return xrpc("com.atproto.server.listAppPasswords", { token });
291
},
292
293
createAppPassword(
294
-
token: string,
295
name: string,
296
scopes?: string,
297
-
): Promise<
298
-
{ name: string; password: string; createdAt: string; scopes?: string }
299
-
> {
300
-
return xrpc("com.atproto.server.createAppPassword", {
301
-
method: "POST",
302
token,
303
body: { name, scopes },
304
-
});
305
},
306
307
-
async revokeAppPassword(token: string, name: string): Promise<void> {
308
-
await xrpc("com.atproto.server.revokeAppPassword", {
309
-
method: "POST",
310
token,
311
body: { name },
312
-
});
313
},
314
315
-
getAccountInviteCodes(token: string): Promise<{ codes: InviteCode[] }> {
316
-
return xrpc("com.atproto.server.getAccountInviteCodes", { token });
317
},
318
319
createInviteCode(
320
-
token: string,
321
useCount: number = 1,
322
): Promise<{ code: string }> {
323
-
return xrpc("com.atproto.server.createInviteCode", {
324
-
method: "POST",
325
token,
326
body: { useCount },
327
-
});
328
},
329
330
-
async requestPasswordReset(email: string): Promise<void> {
331
-
await xrpc("com.atproto.server.requestPasswordReset", {
332
-
method: "POST",
333
body: { email },
334
-
});
335
},
336
337
async resetPassword(token: string, password: string): Promise<void> {
338
-
await xrpc("com.atproto.server.resetPassword", {
339
-
method: "POST",
340
body: { token, password },
341
-
});
342
},
343
344
-
requestEmailUpdate(
345
-
token: string,
346
-
): Promise<{ tokenRequired: boolean }> {
347
-
return xrpc("com.atproto.server.requestEmailUpdate", {
348
-
method: "POST",
349
token,
350
-
});
351
},
352
353
async updateEmail(
354
-
token: string,
355
email: string,
356
emailToken?: string,
357
): Promise<void> {
358
-
await xrpc("com.atproto.server.updateEmail", {
359
-
method: "POST",
360
token,
361
body: { email, token: emailToken },
362
-
});
363
},
364
365
-
async updateHandle(token: string, handle: string): Promise<void> {
366
-
await xrpc("com.atproto.identity.updateHandle", {
367
-
method: "POST",
368
token,
369
body: { handle },
370
-
});
371
},
372
373
-
async requestAccountDelete(token: string): Promise<void> {
374
-
await xrpc("com.atproto.server.requestAccountDelete", {
375
-
method: "POST",
376
token,
377
-
});
378
},
379
380
async deleteAccount(
381
-
did: string,
382
password: string,
383
deleteToken: string,
384
): Promise<void> {
385
-
await xrpc("com.atproto.server.deleteAccount", {
386
-
method: "POST",
387
body: { did, password, token: deleteToken },
388
-
});
389
},
390
391
-
describeServer(): Promise<{
392
-
availableUserDomains: string[];
393
-
inviteCodeRequired: boolean;
394
-
links?: { privacyPolicy?: string; termsOfService?: string };
395
-
version?: string;
396
-
availableCommsChannels?: string[];
397
-
selfHostedDidWebEnabled?: boolean;
398
-
}> {
399
-
return xrpc("com.atproto.server.describeServer");
400
},
401
402
-
listRepos(limit?: number): Promise<{
403
-
repos: Array<{ did: string; head: string; rev: string }>;
404
-
cursor?: string;
405
-
}> {
406
-
const params: Record<string, string> = {};
407
-
if (limit) params.limit = String(limit);
408
-
return xrpc("com.atproto.sync.listRepos", { params });
409
},
410
411
-
getNotificationPrefs(token: string): Promise<{
412
-
preferredChannel: string;
413
-
email: string;
414
-
discordId: string | null;
415
-
discordVerified: boolean;
416
-
telegramUsername: string | null;
417
-
telegramVerified: boolean;
418
-
signalNumber: string | null;
419
-
signalVerified: boolean;
420
-
}> {
421
-
return xrpc("_account.getNotificationPrefs", { token });
422
},
423
424
-
updateNotificationPrefs(token: string, prefs: {
425
-
preferredChannel?: string;
426
-
discordId?: string;
427
-
telegramUsername?: string;
428
-
signalNumber?: string;
429
-
}): Promise<{ success: boolean }> {
430
-
return xrpc("_account.updateNotificationPrefs", {
431
-
method: "POST",
432
token,
433
body: prefs,
434
-
});
435
},
436
437
confirmChannelVerification(
438
-
token: string,
439
channel: string,
440
identifier: string,
441
code: string,
442
-
): Promise<{ success: boolean }> {
443
-
return xrpc("_account.confirmChannelVerification", {
444
-
method: "POST",
445
token,
446
body: { channel, identifier, code },
447
-
});
448
},
449
450
-
getNotificationHistory(token: string): Promise<{
451
-
notifications: Array<{
452
-
createdAt: string;
453
-
channel: string;
454
-
notificationType: string;
455
-
status: string;
456
-
subject: string | null;
457
-
body: string;
458
-
}>;
459
-
}> {
460
-
return xrpc("_account.getNotificationHistory", { token });
461
},
462
463
-
getServerStats(token: string): Promise<{
464
-
userCount: number;
465
-
repoCount: number;
466
-
recordCount: number;
467
-
blobStorageBytes: number;
468
-
}> {
469
-
return xrpc("_admin.getServerStats", { token });
470
},
471
472
-
getServerConfig(): Promise<{
473
-
serverName: string;
474
-
primaryColor: string | null;
475
-
primaryColorDark: string | null;
476
-
secondaryColor: string | null;
477
-
secondaryColorDark: string | null;
478
-
logoCid: string | null;
479
-
}> {
480
-
return xrpc("_server.getConfig");
481
},
482
483
updateServerConfig(
484
-
token: string,
485
config: {
486
-
serverName?: string;
487
-
primaryColor?: string;
488
-
primaryColorDark?: string;
489
-
secondaryColor?: string;
490
-
secondaryColorDark?: string;
491
-
logoCid?: string;
492
},
493
-
): Promise<{ success: boolean }> {
494
-
return xrpc("_admin.updateServerConfig", {
495
-
method: "POST",
496
token,
497
body: config,
498
-
});
499
},
500
501
-
async uploadBlob(
502
-
token: string,
503
-
file: File,
504
-
): Promise<
505
-
{
506
-
blob: {
507
-
$type: string;
508
-
ref: { $link: string };
509
-
mimeType: string;
510
-
size: number;
511
-
};
512
-
}
513
-
> {
514
-
const res = await fetch("/xrpc/com.atproto.repo.uploadBlob", {
515
-
method: "POST",
516
headers: {
517
-
"Authorization": `Bearer ${token}`,
518
-
"Content-Type": file.type,
519
},
520
body: file,
521
-
});
522
if (!res.ok) {
523
-
const err = await res.json().catch(() => ({
524
-
error: "Unknown",
525
message: res.statusText,
526
-
}));
527
-
throw new ApiError(res.status, err.error, err.message);
528
}
529
-
return res.json();
530
},
531
532
async changePassword(
533
-
token: string,
534
currentPassword: string,
535
newPassword: string,
536
): Promise<void> {
537
-
await xrpc("_account.changePassword", {
538
-
method: "POST",
539
token,
540
body: { currentPassword, newPassword },
541
-
});
542
},
543
544
-
removePassword(token: string): Promise<{ success: boolean }> {
545
-
return xrpc("_account.removePassword", {
546
-
method: "POST",
547
token,
548
-
});
549
},
550
551
-
getPasswordStatus(token: string): Promise<{ hasPassword: boolean }> {
552
-
return xrpc("_account.getPasswordStatus", { token });
553
},
554
555
-
getLegacyLoginPreference(
556
-
token: string,
557
-
): Promise<{ allowLegacyLogin: boolean; hasMfa: boolean }> {
558
-
return xrpc("_account.getLegacyLoginPreference", { token });
559
},
560
561
updateLegacyLoginPreference(
562
-
token: string,
563
allowLegacyLogin: boolean,
564
-
): Promise<{ allowLegacyLogin: boolean }> {
565
-
return xrpc("_account.updateLegacyLoginPreference", {
566
-
method: "POST",
567
token,
568
body: { allowLegacyLogin },
569
-
});
570
},
571
572
-
updateLocale(
573
-
token: string,
574
-
preferredLocale: string,
575
-
): Promise<{ preferredLocale: string }> {
576
-
return xrpc("_account.updateLocale", {
577
-
method: "POST",
578
token,
579
body: { preferredLocale },
580
-
});
581
},
582
583
-
listSessions(token: string): Promise<{
584
-
sessions: Array<{
585
-
id: string;
586
-
sessionType: string;
587
-
clientName: string | null;
588
-
createdAt: string;
589
-
expiresAt: string;
590
-
isCurrent: boolean;
591
-
}>;
592
-
}> {
593
-
return xrpc("_account.listSessions", { token });
594
},
595
596
-
async revokeSession(token: string, sessionId: string): Promise<void> {
597
-
await xrpc("_account.revokeSession", {
598
-
method: "POST",
599
token,
600
body: { sessionId },
601
-
});
602
},
603
604
-
revokeAllSessions(token: string): Promise<{ revokedCount: number }> {
605
-
return xrpc("_account.revokeAllSessions", {
606
-
method: "POST",
607
token,
608
-
});
609
},
610
611
-
searchAccounts(token: string, options?: {
612
-
handle?: string;
613
-
cursor?: string;
614
-
limit?: number;
615
-
}): Promise<{
616
-
cursor?: string;
617
-
accounts: Array<{
618
-
did: string;
619
-
handle: string;
620
-
email?: string;
621
-
indexedAt: string;
622
-
emailConfirmedAt?: string;
623
-
deactivatedAt?: string;
624
-
}>;
625
-
}> {
626
-
const params: Record<string, string> = {};
627
-
if (options?.handle) params.handle = options.handle;
628
-
if (options?.cursor) params.cursor = options.cursor;
629
-
if (options?.limit) params.limit = String(options.limit);
630
-
return xrpc("com.atproto.admin.searchAccounts", { token, params });
631
},
632
633
-
getInviteCodes(token: string, options?: {
634
-
sort?: "recent" | "usage";
635
-
cursor?: string;
636
-
limit?: number;
637
-
}): Promise<{
638
-
cursor?: string;
639
-
codes: Array<{
640
-
code: string;
641
-
available: number;
642
-
disabled: boolean;
643
-
forAccount: string;
644
-
createdBy: string;
645
-
createdAt: string;
646
-
uses: Array<{ usedBy: string; usedAt: string }>;
647
-
}>;
648
-
}> {
649
-
const params: Record<string, string> = {};
650
-
if (options?.sort) params.sort = options.sort;
651
-
if (options?.cursor) params.cursor = options.cursor;
652
-
if (options?.limit) params.limit = String(options.limit);
653
-
return xrpc("com.atproto.admin.getInviteCodes", { token, params });
654
},
655
656
async disableInviteCodes(
657
-
token: string,
658
codes?: string[],
659
accounts?: string[],
660
): Promise<void> {
661
-
await xrpc("com.atproto.admin.disableInviteCodes", {
662
-
method: "POST",
663
token,
664
body: { codes, accounts },
665
-
});
666
},
667
668
-
getAccountInfo(token: string, did: string): Promise<{
669
-
did: string;
670
-
handle: string;
671
-
email?: string;
672
-
indexedAt: string;
673
-
emailConfirmedAt?: string;
674
-
invitesDisabled?: boolean;
675
-
deactivatedAt?: string;
676
-
}> {
677
-
return xrpc("com.atproto.admin.getAccountInfo", { token, params: { did } });
678
},
679
680
-
async disableAccountInvites(token: string, account: string): Promise<void> {
681
-
await xrpc("com.atproto.admin.disableAccountInvites", {
682
-
method: "POST",
683
token,
684
body: { account },
685
-
});
686
},
687
688
-
async enableAccountInvites(token: string, account: string): Promise<void> {
689
-
await xrpc("com.atproto.admin.enableAccountInvites", {
690
-
method: "POST",
691
token,
692
body: { account },
693
-
});
694
},
695
696
-
async adminDeleteAccount(token: string, did: string): Promise<void> {
697
-
await xrpc("com.atproto.admin.deleteAccount", {
698
-
method: "POST",
699
token,
700
body: { did },
701
-
});
702
},
703
704
-
describeRepo(token: string, repo: string): Promise<{
705
-
handle: string;
706
-
did: string;
707
-
didDoc: unknown;
708
-
collections: string[];
709
-
handleIsCorrect: boolean;
710
-
}> {
711
-
return xrpc("com.atproto.repo.describeRepo", {
712
token,
713
params: { repo },
714
-
});
715
},
716
717
-
listRecords(token: string, repo: string, collection: string, options?: {
718
-
limit?: number;
719
-
cursor?: string;
720
-
reverse?: boolean;
721
-
}): Promise<{
722
-
records: Array<{ uri: string; cid: string; value: unknown }>;
723
-
cursor?: string;
724
-
}> {
725
-
const params: Record<string, string> = { repo, collection };
726
-
if (options?.limit) params.limit = String(options.limit);
727
-
if (options?.cursor) params.cursor = options.cursor;
728
-
if (options?.reverse) params.reverse = "true";
729
-
return xrpc("com.atproto.repo.listRecords", { token, params });
730
},
731
732
getRecord(
733
-
token: string,
734
-
repo: string,
735
-
collection: string,
736
-
rkey: string,
737
-
): Promise<{
738
-
uri: string;
739
-
cid: string;
740
-
value: unknown;
741
-
}> {
742
-
return xrpc("com.atproto.repo.getRecord", {
743
token,
744
params: { repo, collection, rkey },
745
-
});
746
},
747
748
createRecord(
749
-
token: string,
750
-
repo: string,
751
-
collection: string,
752
record: unknown,
753
-
rkey?: string,
754
-
): Promise<{
755
-
uri: string;
756
-
cid: string;
757
-
}> {
758
-
return xrpc("com.atproto.repo.createRecord", {
759
-
method: "POST",
760
token,
761
body: { repo, collection, record, rkey },
762
-
});
763
},
764
765
putRecord(
766
-
token: string,
767
-
repo: string,
768
-
collection: string,
769
-
rkey: string,
770
record: unknown,
771
-
): Promise<{
772
-
uri: string;
773
-
cid: string;
774
-
}> {
775
-
return xrpc("com.atproto.repo.putRecord", {
776
-
method: "POST",
777
token,
778
body: { repo, collection, rkey, record },
779
-
});
780
},
781
782
async deleteRecord(
783
-
token: string,
784
-
repo: string,
785
-
collection: string,
786
-
rkey: string,
787
): Promise<void> {
788
-
await xrpc("com.atproto.repo.deleteRecord", {
789
-
method: "POST",
790
token,
791
body: { repo, collection, rkey },
792
-
});
793
},
794
795
-
getTotpStatus(
796
-
token: string,
797
-
): Promise<{ enabled: boolean; hasBackupCodes: boolean }> {
798
-
return xrpc("com.atproto.server.getTotpStatus", { token });
799
},
800
801
-
createTotpSecret(
802
-
token: string,
803
-
): Promise<{ uri: string; qrBase64: string }> {
804
-
return xrpc("com.atproto.server.createTotpSecret", {
805
-
method: "POST",
806
token,
807
-
});
808
},
809
810
-
enableTotp(
811
-
token: string,
812
-
code: string,
813
-
): Promise<{ success: boolean; backupCodes: string[] }> {
814
-
return xrpc("com.atproto.server.enableTotp", {
815
-
method: "POST",
816
token,
817
body: { code },
818
-
});
819
},
820
821
disableTotp(
822
-
token: string,
823
password: string,
824
code: string,
825
-
): Promise<{ success: boolean }> {
826
-
return xrpc("com.atproto.server.disableTotp", {
827
-
method: "POST",
828
token,
829
body: { password, code },
830
-
});
831
},
832
833
regenerateBackupCodes(
834
-
token: string,
835
password: string,
836
code: string,
837
-
): Promise<{ backupCodes: string[] }> {
838
-
return xrpc("com.atproto.server.regenerateBackupCodes", {
839
-
method: "POST",
840
token,
841
body: { password, code },
842
-
});
843
},
844
845
startPasskeyRegistration(
846
-
token: string,
847
friendlyName?: string,
848
-
): Promise<{ options: unknown }> {
849
-
return xrpc("com.atproto.server.startPasskeyRegistration", {
850
-
method: "POST",
851
token,
852
body: { friendlyName },
853
-
});
854
},
855
856
finishPasskeyRegistration(
857
-
token: string,
858
credential: unknown,
859
friendlyName?: string,
860
-
): Promise<{ id: string; credentialId: string }> {
861
-
return xrpc("com.atproto.server.finishPasskeyRegistration", {
862
-
method: "POST",
863
token,
864
body: { credential, friendlyName },
865
-
});
866
},
867
868
-
listPasskeys(token: string): Promise<{
869
-
passkeys: Array<{
870
-
id: string;
871
-
credentialId: string;
872
-
friendlyName: string | null;
873
-
createdAt: string;
874
-
lastUsed: string | null;
875
-
}>;
876
-
}> {
877
-
return xrpc("com.atproto.server.listPasskeys", { token });
878
},
879
880
-
async deletePasskey(token: string, id: string): Promise<void> {
881
-
await xrpc("com.atproto.server.deletePasskey", {
882
-
method: "POST",
883
token,
884
body: { id },
885
-
});
886
},
887
888
async updatePasskey(
889
-
token: string,
890
id: string,
891
friendlyName: string,
892
): Promise<void> {
893
-
await xrpc("com.atproto.server.updatePasskey", {
894
-
method: "POST",
895
token,
896
body: { id, friendlyName },
897
-
});
898
},
899
900
-
listTrustedDevices(token: string): Promise<{
901
-
devices: Array<{
902
-
id: string;
903
-
userAgent: string | null;
904
-
friendlyName: string | null;
905
-
trustedAt: string | null;
906
-
trustedUntil: string | null;
907
-
lastSeenAt: string;
908
-
}>;
909
-
}> {
910
-
return xrpc("_account.listTrustedDevices", { token });
911
},
912
913
-
revokeTrustedDevice(
914
-
token: string,
915
-
deviceId: string,
916
-
): Promise<{ success: boolean }> {
917
-
return xrpc("_account.revokeTrustedDevice", {
918
-
method: "POST",
919
token,
920
body: { deviceId },
921
-
});
922
},
923
924
updateTrustedDevice(
925
-
token: string,
926
deviceId: string,
927
friendlyName: string,
928
-
): Promise<{ success: boolean }> {
929
-
return xrpc("_account.updateTrustedDevice", {
930
-
method: "POST",
931
token,
932
body: { deviceId, friendlyName },
933
-
});
934
},
935
936
-
getReauthStatus(token: string): Promise<{
937
-
requiresReauth: boolean;
938
-
lastReauthAt: string | null;
939
-
availableMethods: string[];
940
-
}> {
941
-
return xrpc("_account.getReauthStatus", { token });
942
},
943
944
-
reauthPassword(
945
-
token: string,
946
-
password: string,
947
-
): Promise<{ success: boolean; reauthAt: string }> {
948
-
return xrpc("_account.reauthPassword", {
949
-
method: "POST",
950
token,
951
body: { password },
952
-
});
953
},
954
955
-
reauthTotp(
956
-
token: string,
957
-
code: string,
958
-
): Promise<{ success: boolean; reauthAt: string }> {
959
-
return xrpc("_account.reauthTotp", {
960
-
method: "POST",
961
token,
962
body: { code },
963
-
});
964
},
965
966
-
reauthPasskeyStart(token: string): Promise<{ options: unknown }> {
967
-
return xrpc("_account.reauthPasskeyStart", {
968
-
method: "POST",
969
token,
970
-
});
971
},
972
973
-
reauthPasskeyFinish(
974
-
token: string,
975
-
credential: unknown,
976
-
): Promise<{ success: boolean; reauthAt: string }> {
977
-
return xrpc("_account.reauthPasskeyFinish", {
978
-
method: "POST",
979
token,
980
body: { credential },
981
-
});
982
},
983
984
-
reserveSigningKey(did?: string): Promise<{ signingKey: string }> {
985
-
return xrpc("com.atproto.server.reserveSigningKey", {
986
-
method: "POST",
987
body: { did },
988
-
});
989
},
990
991
-
getRecommendedDidCredentials(token: string): Promise<{
992
-
rotationKeys?: string[];
993
-
alsoKnownAs?: string[];
994
-
verificationMethods?: { atproto?: string };
995
-
services?: { atproto_pds?: { type: string; endpoint: string } };
996
-
}> {
997
-
return xrpc("com.atproto.identity.getRecommendedDidCredentials", { token });
998
},
999
1000
-
async activateAccount(token: string): Promise<void> {
1001
-
await xrpc("com.atproto.server.activateAccount", {
1002
-
method: "POST",
1003
token,
1004
-
});
1005
},
1006
1007
async createPasskeyAccount(params: {
1008
-
handle: string;
1009
-
email?: string;
1010
-
inviteCode?: string;
1011
-
didType?: DidType;
1012
-
did?: string;
1013
-
signingKey?: string;
1014
-
verificationChannel?: VerificationChannel;
1015
-
discordId?: string;
1016
-
telegramUsername?: string;
1017
-
signalNumber?: string;
1018
-
}, byodToken?: string): Promise<{
1019
-
did: string;
1020
-
handle: string;
1021
-
setupToken: string;
1022
-
setupExpiresAt: string;
1023
-
}> {
1024
-
const url = `${API_BASE}/_account.createPasskeyAccount`;
1025
const headers: Record<string, string> = {
1026
-
"Content-Type": "application/json",
1027
-
};
1028
if (byodToken) {
1029
-
headers["Authorization"] = `Bearer ${byodToken}`;
1030
}
1031
const res = await fetch(url, {
1032
-
method: "POST",
1033
headers,
1034
body: JSON.stringify(params),
1035
-
});
1036
if (!res.ok) {
1037
-
const err = await res.json().catch(() => ({
1038
-
error: "Unknown",
1039
message: res.statusText,
1040
-
}));
1041
-
throw new ApiError(res.status, err.error, err.message);
1042
}
1043
-
return res.json();
1044
},
1045
1046
startPasskeyRegistrationForSetup(
1047
-
did: string,
1048
setupToken: string,
1049
friendlyName?: string,
1050
-
): Promise<{ options: unknown }> {
1051
-
return xrpc("_account.startPasskeyRegistrationForSetup", {
1052
-
method: "POST",
1053
body: { did, setupToken, friendlyName },
1054
-
});
1055
},
1056
1057
completePasskeySetup(
1058
-
did: string,
1059
setupToken: string,
1060
passkeyCredential: unknown,
1061
passkeyFriendlyName?: string,
1062
-
): Promise<{
1063
-
did: string;
1064
-
handle: string;
1065
-
appPassword: string;
1066
-
appPasswordName: string;
1067
-
}> {
1068
-
return xrpc("_account.completePasskeySetup", {
1069
-
method: "POST",
1070
body: { did, setupToken, passkeyCredential, passkeyFriendlyName },
1071
-
});
1072
},
1073
1074
-
requestPasskeyRecovery(email: string): Promise<{ success: boolean }> {
1075
-
return xrpc("_account.requestPasskeyRecovery", {
1076
-
method: "POST",
1077
body: { email },
1078
-
});
1079
},
1080
1081
recoverPasskeyAccount(
1082
-
did: string,
1083
recoveryToken: string,
1084
newPassword: string,
1085
-
): Promise<{ success: boolean }> {
1086
-
return xrpc("_account.recoverPasskeyAccount", {
1087
-
method: "POST",
1088
body: { did, recoveryToken, newPassword },
1089
-
});
1090
},
1091
1092
-
verifyMigrationEmail(
1093
-
token: string,
1094
-
email: string,
1095
-
): Promise<{ success: boolean; did: string }> {
1096
-
return xrpc("com.atproto.server.verifyMigrationEmail", {
1097
-
method: "POST",
1098
body: { token, email },
1099
-
});
1100
},
1101
1102
-
resendMigrationVerification(email: string): Promise<{ sent: boolean }> {
1103
-
return xrpc("com.atproto.server.resendMigrationVerification", {
1104
-
method: "POST",
1105
body: { email },
1106
-
});
1107
},
1108
1109
verifyToken(
1110
token: string,
1111
identifier: string,
1112
-
accessToken?: string,
1113
-
): Promise<{
1114
-
success: boolean;
1115
-
did: string;
1116
-
purpose: string;
1117
-
channel: string;
1118
-
}> {
1119
-
return xrpc("_account.verifyToken", {
1120
-
method: "POST",
1121
body: { token, identifier },
1122
token: accessToken,
1123
-
});
1124
},
1125
1126
-
getDidDocument(token: string): Promise<DidDocument> {
1127
-
return xrpc("_account.getDidDocument", { token });
1128
},
1129
1130
updateDidDocument(
1131
-
token: string,
1132
params: {
1133
-
verificationMethods?: VerificationMethod[];
1134
-
alsoKnownAs?: string[];
1135
-
serviceEndpoint?: string;
1136
},
1137
-
): Promise<{ success: boolean }> {
1138
-
return xrpc("_account.updateDidDocument", {
1139
-
method: "POST",
1140
token,
1141
body: params,
1142
-
});
1143
},
1144
1145
-
async deactivateAccount(
1146
-
token: string,
1147
-
deleteAfter?: string,
1148
-
): Promise<void> {
1149
-
await xrpc("com.atproto.server.deactivateAccount", {
1150
-
method: "POST",
1151
token,
1152
body: { deleteAfter },
1153
-
});
1154
},
1155
1156
-
async getRepo(token: string, did: string): Promise<ArrayBuffer> {
1157
-
const url = `${API_BASE}/com.atproto.sync.getRepo?did=${
1158
-
encodeURIComponent(did)
1159
-
}`;
1160
const res = await fetch(url, {
1161
headers: { Authorization: `Bearer ${token}` },
1162
-
});
1163
if (!res.ok) {
1164
-
const err = await res.json().catch(() => ({
1165
-
error: "Unknown",
1166
message: res.statusText,
1167
-
}));
1168
-
throw new ApiError(res.status, err.error, err.message);
1169
}
1170
-
return res.arrayBuffer();
1171
},
1172
1173
-
listBackups(token: string): Promise<{
1174
-
backups: Array<{
1175
-
id: string;
1176
-
repoRev: string;
1177
-
repoRootCid: string;
1178
-
blockCount: number;
1179
-
sizeBytes: number;
1180
-
createdAt: string;
1181
-
}>;
1182
-
backupEnabled: boolean;
1183
-
}> {
1184
-
return xrpc("_backup.listBackups", { token });
1185
},
1186
1187
-
async getBackup(token: string, id: string): Promise<Blob> {
1188
-
const url = `${API_BASE}/_backup.getBackup?id=${encodeURIComponent(id)}`;
1189
const res = await fetch(url, {
1190
headers: { Authorization: `Bearer ${token}` },
1191
-
});
1192
if (!res.ok) {
1193
-
const err = await res.json().catch(() => ({
1194
-
error: "Unknown",
1195
message: res.statusText,
1196
-
}));
1197
-
throw new ApiError(res.status, err.error, err.message);
1198
}
1199
-
return res.blob();
1200
},
1201
1202
-
createBackup(token: string): Promise<{
1203
-
id: string;
1204
-
repoRev: string;
1205
-
sizeBytes: number;
1206
-
blockCount: number;
1207
-
}> {
1208
-
return xrpc("_backup.createBackup", {
1209
-
method: "POST",
1210
token,
1211
-
});
1212
},
1213
1214
-
async deleteBackup(token: string, id: string): Promise<void> {
1215
-
await xrpc("_backup.deleteBackup", {
1216
-
method: "POST",
1217
token,
1218
params: { id },
1219
-
});
1220
},
1221
1222
-
setBackupEnabled(
1223
-
token: string,
1224
-
enabled: boolean,
1225
-
): Promise<{ enabled: boolean }> {
1226
-
return xrpc("_backup.setEnabled", {
1227
-
method: "POST",
1228
token,
1229
body: { enabled },
1230
-
});
1231
},
1232
1233
-
async importRepo(token: string, car: Uint8Array): Promise<void> {
1234
-
const url = `${API_BASE}/com.atproto.repo.importRepo`;
1235
const res = await fetch(url, {
1236
-
method: "POST",
1237
headers: {
1238
Authorization: `Bearer ${token}`,
1239
-
"Content-Type": "application/vnd.ipld.car",
1240
},
1241
body: car,
1242
-
});
1243
if (!res.ok) {
1244
-
const err = await res.json().catch(() => ({
1245
-
error: "Unknown",
1246
message: res.statusText,
1247
-
}));
1248
-
throw new ApiError(res.status, err.error, err.message);
1249
}
1250
},
1251
-
};
···
1
+
import { ok, err, type Result } from './types/result'
2
+
import type {
3
+
Did,
4
+
Handle,
5
+
AccessToken,
6
+
RefreshToken,
7
+
Cid,
8
+
Rkey,
9
+
AtUri,
10
+
Nsid,
11
+
ISODateString,
12
+
EmailAddress,
13
+
InviteCode as InviteCodeBrand,
14
+
} from './types/branded'
15
+
import {
16
+
unsafeAsDid,
17
+
unsafeAsHandle,
18
+
unsafeAsAccessToken,
19
+
unsafeAsRefreshToken,
20
+
unsafeAsCid,
21
+
unsafeAsISODate,
22
+
unsafeAsEmail,
23
+
unsafeAsInviteCode,
24
+
} from './types/branded'
25
+
import type {
26
+
Session,
27
+
DidDocument,
28
+
AppPassword,
29
+
CreatedAppPassword,
30
+
InviteCodeInfo,
31
+
ServerDescription,
32
+
NotificationPrefs,
33
+
NotificationHistoryResponse,
34
+
ServerStats,
35
+
ServerConfig,
36
+
UploadBlobResponse,
37
+
ListSessionsResponse,
38
+
SearchAccountsResponse,
39
+
GetInviteCodesResponse,
40
+
AccountInfo,
41
+
RepoDescription,
42
+
ListRecordsResponse,
43
+
RecordResponse,
44
+
CreateRecordResponse,
45
+
TotpStatus,
46
+
TotpSecret,
47
+
EnableTotpResponse,
48
+
RegenerateBackupCodesResponse,
49
+
ListPasskeysResponse,
50
+
StartPasskeyRegistrationResponse,
51
+
FinishPasskeyRegistrationResponse,
52
+
ListTrustedDevicesResponse,
53
+
ReauthStatus,
54
+
ReauthResponse,
55
+
ReauthPasskeyStartResponse,
56
+
ReserveSigningKeyResponse,
57
+
RecommendedDidCredentials,
58
+
PasskeyAccountCreateResponse,
59
+
CompletePasskeySetupResponse,
60
+
VerifyTokenResponse,
61
+
ListBackupsResponse,
62
+
CreateBackupResponse,
63
+
SetBackupEnabledResponse,
64
+
EmailUpdateResponse,
65
+
LegacyLoginPreference,
66
+
UpdateLegacyLoginResponse,
67
+
UpdateLocaleResponse,
68
+
PasswordStatus,
69
+
SuccessResponse,
70
+
CheckEmailVerifiedResponse,
71
+
VerifyMigrationEmailResponse,
72
+
ResendMigrationVerificationResponse,
73
+
ListReposResponse,
74
+
VerificationChannel,
75
+
DidType,
76
+
ApiErrorCode,
77
+
VerificationMethod as VerificationMethodType,
78
+
CreateAccountParams,
79
+
CreateAccountResult,
80
+
ConfirmSignupResult,
81
+
} from './types/api'
82
+
83
+
const API_BASE = '/xrpc'
84
85
export class ApiError extends Error {
86
+
public did?: Did
87
+
public reauthMethods?: string[]
88
constructor(
89
public status: number,
90
+
public error: ApiErrorCode,
91
message: string,
92
did?: string,
93
reauthMethods?: string[],
94
) {
95
+
super(message)
96
+
this.name = 'ApiError'
97
+
this.did = did ? unsafeAsDid(did) : undefined
98
+
this.reauthMethods = reauthMethods
99
}
100
}
101
102
+
let tokenRefreshCallback: (() => Promise<string | null>) | null = null
103
104
export function setTokenRefreshCallback(
105
callback: () => Promise<string | null>,
106
) {
107
+
tokenRefreshCallback = callback
108
}
109
110
+
interface XrpcOptions {
111
+
method?: 'GET' | 'POST'
112
+
params?: Record<string, string>
113
+
body?: unknown
114
+
token?: string
115
+
skipRetry?: boolean
116
+
}
117
+
118
+
async function xrpc<T>(method: string, options?: XrpcOptions): Promise<T> {
119
+
const { method: httpMethod = 'GET', params, body, token, skipRetry } =
120
+
options ?? {}
121
+
let url = `${API_BASE}/${method}`
122
if (params) {
123
+
const searchParams = new URLSearchParams(params)
124
+
url += `?${searchParams}`
125
}
126
+
const headers: Record<string, string> = {}
127
if (token) {
128
+
headers['Authorization'] = `Bearer ${token}`
129
}
130
if (body) {
131
+
headers['Content-Type'] = 'application/json'
132
}
133
const res = await fetch(url, {
134
method: httpMethod,
135
headers,
136
body: body ? JSON.stringify(body) : undefined,
137
+
})
138
if (!res.ok) {
139
+
const errData = await res.json().catch(() => ({
140
+
error: 'Unknown',
141
message: res.statusText,
142
+
}))
143
if (
144
res.status === 401 &&
145
+
(errData.error === 'AuthenticationFailed' || errData.error === 'ExpiredToken') &&
146
token && tokenRefreshCallback && !skipRetry
147
) {
148
+
const newToken = await tokenRefreshCallback()
149
if (newToken && newToken !== token) {
150
+
return xrpc(method, { ...options, token: newToken, skipRetry: true })
151
}
152
}
153
throw new ApiError(
154
res.status,
155
+
errData.error as ApiErrorCode,
156
+
errData.message,
157
+
errData.did,
158
+
errData.reauthMethods,
159
+
)
160
}
161
+
return res.json()
162
}
163
164
+
async function xrpcResult<T>(
165
+
method: string,
166
+
options?: XrpcOptions
167
+
): Promise<Result<T, ApiError>> {
168
+
try {
169
+
const value = await xrpc<T>(method, options)
170
+
return ok(value)
171
+
} catch (e) {
172
+
if (e instanceof ApiError) {
173
+
return err(e)
174
+
}
175
+
return err(new ApiError(0, 'Unknown', e instanceof Error ? e.message : String(e)))
176
+
}
177
}
178
179
export interface VerificationMethod {
180
+
id: string
181
+
type: string
182
+
publicKeyMultibase: string
183
}
184
185
+
export type { Session, DidDocument, AppPassword, InviteCodeInfo as InviteCode }
186
+
export type { VerificationChannel, DidType, CreateAccountParams, CreateAccountResult, ConfirmSignupResult }
187
188
+
function castSession(raw: unknown): Session {
189
+
const s = raw as Record<string, unknown>
190
+
return {
191
+
did: unsafeAsDid(s.did as string),
192
+
handle: unsafeAsHandle(s.handle as string),
193
+
email: s.email ? unsafeAsEmail(s.email as string) : undefined,
194
+
emailConfirmed: s.emailConfirmed as boolean | undefined,
195
+
preferredChannel: s.preferredChannel as VerificationChannel | undefined,
196
+
preferredChannelVerified: s.preferredChannelVerified as boolean | undefined,
197
+
isAdmin: s.isAdmin as boolean | undefined,
198
+
active: s.active as boolean | undefined,
199
+
status: s.status as Session['status'],
200
+
migratedToPds: s.migratedToPds as string | undefined,
201
+
migratedAt: s.migratedAt ? unsafeAsISODate(s.migratedAt as string) : undefined,
202
+
accessJwt: unsafeAsAccessToken(s.accessJwt as string),
203
+
refreshJwt: unsafeAsRefreshToken(s.refreshJwt as string),
204
+
}
205
}
206
207
export const api = {
···
209
params: CreateAccountParams,
210
byodToken?: string,
211
): Promise<CreateAccountResult> {
212
+
const url = `${API_BASE}/com.atproto.server.createAccount`
213
const headers: Record<string, string> = {
214
+
'Content-Type': 'application/json',
215
+
}
216
if (byodToken) {
217
+
headers['Authorization'] = `Bearer ${byodToken}`
218
}
219
const response = await fetch(url, {
220
+
method: 'POST',
221
headers,
222
body: JSON.stringify({
223
handle: params.handle,
···
232
telegramUsername: params.telegramUsername,
233
signalNumber: params.signalNumber,
234
}),
235
+
})
236
+
const data = await response.json()
237
if (!response.ok) {
238
+
throw new ApiError(response.status, data.error, data.message)
239
}
240
+
return data
241
},
242
243
async createAccountWithServiceAuth(
244
serviceAuthToken: string,
245
params: {
246
+
did: Did
247
+
handle: Handle
248
+
email: EmailAddress
249
+
password: string
250
+
inviteCode?: string
251
},
252
): Promise<Session> {
253
+
const url = `${API_BASE}/com.atproto.server.createAccount`
254
const response = await fetch(url, {
255
+
method: 'POST',
256
headers: {
257
+
'Content-Type': 'application/json',
258
+
'Authorization': `Bearer ${serviceAuthToken}`,
259
},
260
body: JSON.stringify({
261
did: params.did,
···
264
password: params.password,
265
inviteCode: params.inviteCode,
266
}),
267
+
})
268
+
const data = await response.json()
269
if (!response.ok) {
270
+
throw new ApiError(response.status, data.error, data.message)
271
}
272
+
return castSession(data)
273
},
274
275
confirmSignup(
276
+
did: Did,
277
verificationCode: string,
278
): Promise<ConfirmSignupResult> {
279
+
return xrpc('com.atproto.server.confirmSignup', {
280
+
method: 'POST',
281
body: { did, verificationCode },
282
+
})
283
},
284
285
+
resendVerification(did: Did): Promise<{ success: boolean }> {
286
+
return xrpc('com.atproto.server.resendVerification', {
287
+
method: 'POST',
288
body: { did },
289
+
})
290
},
291
292
+
async createSession(identifier: string, password: string): Promise<Session> {
293
+
const raw = await xrpc<unknown>('com.atproto.server.createSession', {
294
+
method: 'POST',
295
body: { identifier, password },
296
+
})
297
+
return castSession(raw)
298
},
299
300
checkEmailVerified(identifier: string): Promise<{ verified: boolean }> {
301
+
return xrpc('_checkEmailVerified', {
302
+
method: 'POST',
303
body: { identifier },
304
+
})
305
},
306
307
+
async getSession(token: AccessToken): Promise<Session> {
308
+
const raw = await xrpc<unknown>('com.atproto.server.getSession', { token })
309
+
return castSession(raw)
310
},
311
312
+
async refreshSession(refreshJwt: RefreshToken): Promise<Session> {
313
+
const raw = await xrpc<unknown>('com.atproto.server.refreshSession', {
314
+
method: 'POST',
315
token: refreshJwt,
316
+
})
317
+
return castSession(raw)
318
},
319
320
+
async deleteSession(token: AccessToken): Promise<void> {
321
+
await xrpc('com.atproto.server.deleteSession', {
322
+
method: 'POST',
323
token,
324
+
})
325
},
326
327
+
listAppPasswords(token: AccessToken): Promise<{ passwords: AppPassword[] }> {
328
+
return xrpc('com.atproto.server.listAppPasswords', { token })
329
},
330
331
createAppPassword(
332
+
token: AccessToken,
333
name: string,
334
scopes?: string,
335
+
): Promise<CreatedAppPassword> {
336
+
return xrpc('com.atproto.server.createAppPassword', {
337
+
method: 'POST',
338
token,
339
body: { name, scopes },
340
+
})
341
},
342
343
+
async revokeAppPassword(token: AccessToken, name: string): Promise<void> {
344
+
await xrpc('com.atproto.server.revokeAppPassword', {
345
+
method: 'POST',
346
token,
347
body: { name },
348
+
})
349
},
350
351
+
getAccountInviteCodes(token: AccessToken): Promise<{ codes: InviteCodeInfo[] }> {
352
+
return xrpc('com.atproto.server.getAccountInviteCodes', { token })
353
},
354
355
createInviteCode(
356
+
token: AccessToken,
357
useCount: number = 1,
358
): Promise<{ code: string }> {
359
+
return xrpc('com.atproto.server.createInviteCode', {
360
+
method: 'POST',
361
token,
362
body: { useCount },
363
+
})
364
},
365
366
+
async requestPasswordReset(email: EmailAddress): Promise<void> {
367
+
await xrpc('com.atproto.server.requestPasswordReset', {
368
+
method: 'POST',
369
body: { email },
370
+
})
371
},
372
373
async resetPassword(token: string, password: string): Promise<void> {
374
+
await xrpc('com.atproto.server.resetPassword', {
375
+
method: 'POST',
376
body: { token, password },
377
+
})
378
},
379
380
+
requestEmailUpdate(token: AccessToken): Promise<EmailUpdateResponse> {
381
+
return xrpc('com.atproto.server.requestEmailUpdate', {
382
+
method: 'POST',
383
token,
384
+
})
385
},
386
387
async updateEmail(
388
+
token: AccessToken,
389
email: string,
390
emailToken?: string,
391
): Promise<void> {
392
+
await xrpc('com.atproto.server.updateEmail', {
393
+
method: 'POST',
394
token,
395
body: { email, token: emailToken },
396
+
})
397
},
398
399
+
async updateHandle(token: AccessToken, handle: Handle): Promise<void> {
400
+
await xrpc('com.atproto.identity.updateHandle', {
401
+
method: 'POST',
402
token,
403
body: { handle },
404
+
})
405
},
406
407
+
async requestAccountDelete(token: AccessToken): Promise<void> {
408
+
await xrpc('com.atproto.server.requestAccountDelete', {
409
+
method: 'POST',
410
token,
411
+
})
412
},
413
414
async deleteAccount(
415
+
did: Did,
416
password: string,
417
deleteToken: string,
418
): Promise<void> {
419
+
await xrpc('com.atproto.server.deleteAccount', {
420
+
method: 'POST',
421
body: { did, password, token: deleteToken },
422
+
})
423
},
424
425
+
describeServer(): Promise<ServerDescription> {
426
+
return xrpc('com.atproto.server.describeServer')
427
},
428
429
+
listRepos(limit?: number): Promise<ListReposResponse> {
430
+
const params: Record<string, string> = {}
431
+
if (limit) params.limit = String(limit)
432
+
return xrpc('com.atproto.sync.listRepos', { params })
433
},
434
435
+
getNotificationPrefs(token: AccessToken): Promise<NotificationPrefs> {
436
+
return xrpc('_account.getNotificationPrefs', { token })
437
},
438
439
+
updateNotificationPrefs(token: AccessToken, prefs: {
440
+
preferredChannel?: string
441
+
discordId?: string
442
+
telegramUsername?: string
443
+
signalNumber?: string
444
+
}): Promise<SuccessResponse> {
445
+
return xrpc('_account.updateNotificationPrefs', {
446
+
method: 'POST',
447
token,
448
body: prefs,
449
+
})
450
},
451
452
confirmChannelVerification(
453
+
token: AccessToken,
454
channel: string,
455
identifier: string,
456
code: string,
457
+
): Promise<SuccessResponse> {
458
+
return xrpc('_account.confirmChannelVerification', {
459
+
method: 'POST',
460
token,
461
body: { channel, identifier, code },
462
+
})
463
},
464
465
+
getNotificationHistory(token: AccessToken): Promise<NotificationHistoryResponse> {
466
+
return xrpc('_account.getNotificationHistory', { token })
467
},
468
469
+
getServerStats(token: AccessToken): Promise<ServerStats> {
470
+
return xrpc('_admin.getServerStats', { token })
471
},
472
473
+
getServerConfig(): Promise<ServerConfig> {
474
+
return xrpc('_server.getConfig')
475
},
476
477
updateServerConfig(
478
+
token: AccessToken,
479
config: {
480
+
serverName?: string
481
+
primaryColor?: string
482
+
primaryColorDark?: string
483
+
secondaryColor?: string
484
+
secondaryColorDark?: string
485
+
logoCid?: string
486
},
487
+
): Promise<SuccessResponse> {
488
+
return xrpc('_admin.updateServerConfig', {
489
+
method: 'POST',
490
token,
491
body: config,
492
+
})
493
},
494
495
+
async uploadBlob(token: AccessToken, file: File): Promise<UploadBlobResponse> {
496
+
const res = await fetch('/xrpc/com.atproto.repo.uploadBlob', {
497
+
method: 'POST',
498
headers: {
499
+
'Authorization': `Bearer ${token}`,
500
+
'Content-Type': file.type,
501
},
502
body: file,
503
+
})
504
if (!res.ok) {
505
+
const errData = await res.json().catch(() => ({
506
+
error: 'Unknown',
507
message: res.statusText,
508
+
}))
509
+
throw new ApiError(res.status, errData.error, errData.message)
510
}
511
+
return res.json()
512
},
513
514
async changePassword(
515
+
token: AccessToken,
516
currentPassword: string,
517
newPassword: string,
518
): Promise<void> {
519
+
await xrpc('_account.changePassword', {
520
+
method: 'POST',
521
token,
522
body: { currentPassword, newPassword },
523
+
})
524
},
525
526
+
removePassword(token: AccessToken): Promise<SuccessResponse> {
527
+
return xrpc('_account.removePassword', {
528
+
method: 'POST',
529
token,
530
+
})
531
},
532
533
+
getPasswordStatus(token: AccessToken): Promise<PasswordStatus> {
534
+
return xrpc('_account.getPasswordStatus', { token })
535
},
536
537
+
getLegacyLoginPreference(token: AccessToken): Promise<LegacyLoginPreference> {
538
+
return xrpc('_account.getLegacyLoginPreference', { token })
539
},
540
541
updateLegacyLoginPreference(
542
+
token: AccessToken,
543
allowLegacyLogin: boolean,
544
+
): Promise<UpdateLegacyLoginResponse> {
545
+
return xrpc('_account.updateLegacyLoginPreference', {
546
+
method: 'POST',
547
token,
548
body: { allowLegacyLogin },
549
+
})
550
},
551
552
+
updateLocale(token: AccessToken, preferredLocale: string): Promise<UpdateLocaleResponse> {
553
+
return xrpc('_account.updateLocale', {
554
+
method: 'POST',
555
token,
556
body: { preferredLocale },
557
+
})
558
},
559
560
+
listSessions(token: AccessToken): Promise<ListSessionsResponse> {
561
+
return xrpc('_account.listSessions', { token })
562
},
563
564
+
async revokeSession(token: AccessToken, sessionId: string): Promise<void> {
565
+
await xrpc('_account.revokeSession', {
566
+
method: 'POST',
567
token,
568
body: { sessionId },
569
+
})
570
},
571
572
+
revokeAllSessions(token: AccessToken): Promise<{ revokedCount: number }> {
573
+
return xrpc('_account.revokeAllSessions', {
574
+
method: 'POST',
575
token,
576
+
})
577
},
578
579
+
searchAccounts(token: AccessToken, options?: {
580
+
handle?: string
581
+
cursor?: string
582
+
limit?: number
583
+
}): Promise<SearchAccountsResponse> {
584
+
const params: Record<string, string> = {}
585
+
if (options?.handle) params.handle = options.handle
586
+
if (options?.cursor) params.cursor = options.cursor
587
+
if (options?.limit) params.limit = String(options.limit)
588
+
return xrpc('com.atproto.admin.searchAccounts', { token, params })
589
},
590
591
+
getInviteCodes(token: AccessToken, options?: {
592
+
sort?: 'recent' | 'usage'
593
+
cursor?: string
594
+
limit?: number
595
+
}): Promise<GetInviteCodesResponse> {
596
+
const params: Record<string, string> = {}
597
+
if (options?.sort) params.sort = options.sort
598
+
if (options?.cursor) params.cursor = options.cursor
599
+
if (options?.limit) params.limit = String(options.limit)
600
+
return xrpc('com.atproto.admin.getInviteCodes', { token, params })
601
},
602
603
async disableInviteCodes(
604
+
token: AccessToken,
605
codes?: string[],
606
accounts?: string[],
607
): Promise<void> {
608
+
await xrpc('com.atproto.admin.disableInviteCodes', {
609
+
method: 'POST',
610
token,
611
body: { codes, accounts },
612
+
})
613
},
614
615
+
getAccountInfo(token: AccessToken, did: Did): Promise<AccountInfo> {
616
+
return xrpc('com.atproto.admin.getAccountInfo', { token, params: { did } })
617
},
618
619
+
async disableAccountInvites(token: AccessToken, account: Did): Promise<void> {
620
+
await xrpc('com.atproto.admin.disableAccountInvites', {
621
+
method: 'POST',
622
token,
623
body: { account },
624
+
})
625
},
626
627
+
async enableAccountInvites(token: AccessToken, account: Did): Promise<void> {
628
+
await xrpc('com.atproto.admin.enableAccountInvites', {
629
+
method: 'POST',
630
token,
631
body: { account },
632
+
})
633
},
634
635
+
async adminDeleteAccount(token: AccessToken, did: Did): Promise<void> {
636
+
await xrpc('com.atproto.admin.deleteAccount', {
637
+
method: 'POST',
638
token,
639
body: { did },
640
+
})
641
},
642
643
+
describeRepo(token: AccessToken, repo: Did): Promise<RepoDescription> {
644
+
return xrpc('com.atproto.repo.describeRepo', {
645
token,
646
params: { repo },
647
+
})
648
},
649
650
+
listRecords(token: AccessToken, repo: Did, collection: Nsid, options?: {
651
+
limit?: number
652
+
cursor?: string
653
+
reverse?: boolean
654
+
}): Promise<ListRecordsResponse> {
655
+
const params: Record<string, string> = { repo, collection }
656
+
if (options?.limit) params.limit = String(options.limit)
657
+
if (options?.cursor) params.cursor = options.cursor
658
+
if (options?.reverse) params.reverse = 'true'
659
+
return xrpc('com.atproto.repo.listRecords', { token, params })
660
},
661
662
getRecord(
663
+
token: AccessToken,
664
+
repo: Did,
665
+
collection: Nsid,
666
+
rkey: Rkey,
667
+
): Promise<RecordResponse> {
668
+
return xrpc('com.atproto.repo.getRecord', {
669
token,
670
params: { repo, collection, rkey },
671
+
})
672
},
673
674
createRecord(
675
+
token: AccessToken,
676
+
repo: Did,
677
+
collection: Nsid,
678
record: unknown,
679
+
rkey?: Rkey,
680
+
): Promise<CreateRecordResponse> {
681
+
return xrpc('com.atproto.repo.createRecord', {
682
+
method: 'POST',
683
token,
684
body: { repo, collection, record, rkey },
685
+
})
686
},
687
688
putRecord(
689
+
token: AccessToken,
690
+
repo: Did,
691
+
collection: Nsid,
692
+
rkey: Rkey,
693
record: unknown,
694
+
): Promise<CreateRecordResponse> {
695
+
return xrpc('com.atproto.repo.putRecord', {
696
+
method: 'POST',
697
token,
698
body: { repo, collection, rkey, record },
699
+
})
700
},
701
702
async deleteRecord(
703
+
token: AccessToken,
704
+
repo: Did,
705
+
collection: Nsid,
706
+
rkey: Rkey,
707
): Promise<void> {
708
+
await xrpc('com.atproto.repo.deleteRecord', {
709
+
method: 'POST',
710
token,
711
body: { repo, collection, rkey },
712
+
})
713
},
714
715
+
getTotpStatus(token: AccessToken): Promise<TotpStatus> {
716
+
return xrpc('com.atproto.server.getTotpStatus', { token })
717
},
718
719
+
createTotpSecret(token: AccessToken): Promise<TotpSecret> {
720
+
return xrpc('com.atproto.server.createTotpSecret', {
721
+
method: 'POST',
722
token,
723
+
})
724
},
725
726
+
enableTotp(token: AccessToken, code: string): Promise<EnableTotpResponse> {
727
+
return xrpc('com.atproto.server.enableTotp', {
728
+
method: 'POST',
729
token,
730
body: { code },
731
+
})
732
},
733
734
disableTotp(
735
+
token: AccessToken,
736
password: string,
737
code: string,
738
+
): Promise<SuccessResponse> {
739
+
return xrpc('com.atproto.server.disableTotp', {
740
+
method: 'POST',
741
token,
742
body: { password, code },
743
+
})
744
},
745
746
regenerateBackupCodes(
747
+
token: AccessToken,
748
password: string,
749
code: string,
750
+
): Promise<RegenerateBackupCodesResponse> {
751
+
return xrpc('com.atproto.server.regenerateBackupCodes', {
752
+
method: 'POST',
753
token,
754
body: { password, code },
755
+
})
756
},
757
758
startPasskeyRegistration(
759
+
token: AccessToken,
760
friendlyName?: string,
761
+
): Promise<StartPasskeyRegistrationResponse> {
762
+
return xrpc('com.atproto.server.startPasskeyRegistration', {
763
+
method: 'POST',
764
token,
765
body: { friendlyName },
766
+
})
767
},
768
769
finishPasskeyRegistration(
770
+
token: AccessToken,
771
credential: unknown,
772
friendlyName?: string,
773
+
): Promise<FinishPasskeyRegistrationResponse> {
774
+
return xrpc('com.atproto.server.finishPasskeyRegistration', {
775
+
method: 'POST',
776
token,
777
body: { credential, friendlyName },
778
+
})
779
},
780
781
+
listPasskeys(token: AccessToken): Promise<ListPasskeysResponse> {
782
+
return xrpc('com.atproto.server.listPasskeys', { token })
783
},
784
785
+
async deletePasskey(token: AccessToken, id: string): Promise<void> {
786
+
await xrpc('com.atproto.server.deletePasskey', {
787
+
method: 'POST',
788
token,
789
body: { id },
790
+
})
791
},
792
793
async updatePasskey(
794
+
token: AccessToken,
795
id: string,
796
friendlyName: string,
797
): Promise<void> {
798
+
await xrpc('com.atproto.server.updatePasskey', {
799
+
method: 'POST',
800
token,
801
body: { id, friendlyName },
802
+
})
803
},
804
805
+
listTrustedDevices(token: AccessToken): Promise<ListTrustedDevicesResponse> {
806
+
return xrpc('_account.listTrustedDevices', { token })
807
},
808
809
+
revokeTrustedDevice(token: AccessToken, deviceId: string): Promise<SuccessResponse> {
810
+
return xrpc('_account.revokeTrustedDevice', {
811
+
method: 'POST',
812
token,
813
body: { deviceId },
814
+
})
815
},
816
817
updateTrustedDevice(
818
+
token: AccessToken,
819
deviceId: string,
820
friendlyName: string,
821
+
): Promise<SuccessResponse> {
822
+
return xrpc('_account.updateTrustedDevice', {
823
+
method: 'POST',
824
token,
825
body: { deviceId, friendlyName },
826
+
})
827
},
828
829
+
getReauthStatus(token: AccessToken): Promise<ReauthStatus> {
830
+
return xrpc('_account.getReauthStatus', { token })
831
},
832
833
+
reauthPassword(token: AccessToken, password: string): Promise<ReauthResponse> {
834
+
return xrpc('_account.reauthPassword', {
835
+
method: 'POST',
836
token,
837
body: { password },
838
+
})
839
},
840
841
+
reauthTotp(token: AccessToken, code: string): Promise<ReauthResponse> {
842
+
return xrpc('_account.reauthTotp', {
843
+
method: 'POST',
844
token,
845
body: { code },
846
+
})
847
},
848
849
+
reauthPasskeyStart(token: AccessToken): Promise<ReauthPasskeyStartResponse> {
850
+
return xrpc('_account.reauthPasskeyStart', {
851
+
method: 'POST',
852
token,
853
+
})
854
},
855
856
+
reauthPasskeyFinish(token: AccessToken, credential: unknown): Promise<ReauthResponse> {
857
+
return xrpc('_account.reauthPasskeyFinish', {
858
+
method: 'POST',
859
token,
860
body: { credential },
861
+
})
862
},
863
864
+
reserveSigningKey(did?: Did): Promise<ReserveSigningKeyResponse> {
865
+
return xrpc('com.atproto.server.reserveSigningKey', {
866
+
method: 'POST',
867
body: { did },
868
+
})
869
},
870
871
+
getRecommendedDidCredentials(token: AccessToken): Promise<RecommendedDidCredentials> {
872
+
return xrpc('com.atproto.identity.getRecommendedDidCredentials', { token })
873
},
874
875
+
async activateAccount(token: AccessToken): Promise<void> {
876
+
await xrpc('com.atproto.server.activateAccount', {
877
+
method: 'POST',
878
token,
879
+
})
880
},
881
882
async createPasskeyAccount(params: {
883
+
handle: Handle
884
+
email?: EmailAddress
885
+
inviteCode?: string
886
+
didType?: DidType
887
+
did?: Did
888
+
signingKey?: string
889
+
verificationChannel?: VerificationChannel
890
+
discordId?: string
891
+
telegramUsername?: string
892
+
signalNumber?: string
893
+
}, byodToken?: string): Promise<PasskeyAccountCreateResponse> {
894
+
const url = `${API_BASE}/_account.createPasskeyAccount`
895
const headers: Record<string, string> = {
896
+
'Content-Type': 'application/json',
897
+
}
898
if (byodToken) {
899
+
headers['Authorization'] = `Bearer ${byodToken}`
900
}
901
const res = await fetch(url, {
902
+
method: 'POST',
903
headers,
904
body: JSON.stringify(params),
905
+
})
906
if (!res.ok) {
907
+
const errData = await res.json().catch(() => ({
908
+
error: 'Unknown',
909
message: res.statusText,
910
+
}))
911
+
throw new ApiError(res.status, errData.error, errData.message)
912
}
913
+
return res.json()
914
},
915
916
startPasskeyRegistrationForSetup(
917
+
did: Did,
918
setupToken: string,
919
friendlyName?: string,
920
+
): Promise<StartPasskeyRegistrationResponse> {
921
+
return xrpc('_account.startPasskeyRegistrationForSetup', {
922
+
method: 'POST',
923
body: { did, setupToken, friendlyName },
924
+
})
925
},
926
927
completePasskeySetup(
928
+
did: Did,
929
setupToken: string,
930
passkeyCredential: unknown,
931
passkeyFriendlyName?: string,
932
+
): Promise<CompletePasskeySetupResponse> {
933
+
return xrpc('_account.completePasskeySetup', {
934
+
method: 'POST',
935
body: { did, setupToken, passkeyCredential, passkeyFriendlyName },
936
+
})
937
},
938
939
+
requestPasskeyRecovery(email: EmailAddress): Promise<SuccessResponse> {
940
+
return xrpc('_account.requestPasskeyRecovery', {
941
+
method: 'POST',
942
body: { email },
943
+
})
944
},
945
946
recoverPasskeyAccount(
947
+
did: Did,
948
recoveryToken: string,
949
newPassword: string,
950
+
): Promise<SuccessResponse> {
951
+
return xrpc('_account.recoverPasskeyAccount', {
952
+
method: 'POST',
953
body: { did, recoveryToken, newPassword },
954
+
})
955
},
956
957
+
verifyMigrationEmail(token: string, email: EmailAddress): Promise<VerifyMigrationEmailResponse> {
958
+
return xrpc('com.atproto.server.verifyMigrationEmail', {
959
+
method: 'POST',
960
body: { token, email },
961
+
})
962
},
963
964
+
resendMigrationVerification(email: EmailAddress): Promise<ResendMigrationVerificationResponse> {
965
+
return xrpc('com.atproto.server.resendMigrationVerification', {
966
+
method: 'POST',
967
body: { email },
968
+
})
969
},
970
971
verifyToken(
972
token: string,
973
identifier: string,
974
+
accessToken?: AccessToken,
975
+
): Promise<VerifyTokenResponse> {
976
+
return xrpc('_account.verifyToken', {
977
+
method: 'POST',
978
body: { token, identifier },
979
token: accessToken,
980
+
})
981
},
982
983
+
getDidDocument(token: AccessToken): Promise<DidDocument> {
984
+
return xrpc('_account.getDidDocument', { token })
985
},
986
987
updateDidDocument(
988
+
token: AccessToken,
989
params: {
990
+
verificationMethods?: VerificationMethod[]
991
+
alsoKnownAs?: string[]
992
+
serviceEndpoint?: string
993
},
994
+
): Promise<SuccessResponse> {
995
+
return xrpc('_account.updateDidDocument', {
996
+
method: 'POST',
997
token,
998
body: params,
999
+
})
1000
},
1001
1002
+
async deactivateAccount(token: AccessToken, deleteAfter?: string): Promise<void> {
1003
+
await xrpc('com.atproto.server.deactivateAccount', {
1004
+
method: 'POST',
1005
token,
1006
body: { deleteAfter },
1007
+
})
1008
},
1009
1010
+
async getRepo(token: AccessToken, did: Did): Promise<ArrayBuffer> {
1011
+
const url = `${API_BASE}/com.atproto.sync.getRepo?did=${encodeURIComponent(did)}`
1012
const res = await fetch(url, {
1013
headers: { Authorization: `Bearer ${token}` },
1014
+
})
1015
if (!res.ok) {
1016
+
const errData = await res.json().catch(() => ({
1017
+
error: 'Unknown',
1018
message: res.statusText,
1019
+
}))
1020
+
throw new ApiError(res.status, errData.error, errData.message)
1021
}
1022
+
return res.arrayBuffer()
1023
},
1024
1025
+
listBackups(token: AccessToken): Promise<ListBackupsResponse> {
1026
+
return xrpc('_backup.listBackups', { token })
1027
},
1028
1029
+
async getBackup(token: AccessToken, id: string): Promise<Blob> {
1030
+
const url = `${API_BASE}/_backup.getBackup?id=${encodeURIComponent(id)}`
1031
const res = await fetch(url, {
1032
headers: { Authorization: `Bearer ${token}` },
1033
+
})
1034
if (!res.ok) {
1035
+
const errData = await res.json().catch(() => ({
1036
+
error: 'Unknown',
1037
message: res.statusText,
1038
+
}))
1039
+
throw new ApiError(res.status, errData.error, errData.message)
1040
}
1041
+
return res.blob()
1042
},
1043
1044
+
createBackup(token: AccessToken): Promise<CreateBackupResponse> {
1045
+
return xrpc('_backup.createBackup', {
1046
+
method: 'POST',
1047
token,
1048
+
})
1049
},
1050
1051
+
async deleteBackup(token: AccessToken, id: string): Promise<void> {
1052
+
await xrpc('_backup.deleteBackup', {
1053
+
method: 'POST',
1054
token,
1055
params: { id },
1056
+
})
1057
},
1058
1059
+
setBackupEnabled(token: AccessToken, enabled: boolean): Promise<SetBackupEnabledResponse> {
1060
+
return xrpc('_backup.setEnabled', {
1061
+
method: 'POST',
1062
token,
1063
body: { enabled },
1064
+
})
1065
},
1066
1067
+
async importRepo(token: AccessToken, car: Uint8Array): Promise<void> {
1068
+
const url = `${API_BASE}/com.atproto.repo.importRepo`
1069
const res = await fetch(url, {
1070
+
method: 'POST',
1071
headers: {
1072
Authorization: `Bearer ${token}`,
1073
+
'Content-Type': 'application/vnd.ipld.car',
1074
},
1075
body: car,
1076
+
})
1077
if (!res.ok) {
1078
+
const errData = await res.json().catch(() => ({
1079
+
error: 'Unknown',
1080
message: res.statusText,
1081
+
}))
1082
+
throw new ApiError(res.status, errData.error, errData.message)
1083
+
}
1084
+
},
1085
+
}
1086
+
1087
+
export const typedApi = {
1088
+
createSession(
1089
+
identifier: string,
1090
+
password: string
1091
+
): Promise<Result<Session, ApiError>> {
1092
+
return xrpcResult<Session>('com.atproto.server.createSession', {
1093
+
method: 'POST',
1094
+
body: { identifier, password },
1095
+
}).then(r => r.ok ? ok(castSession(r.value)) : r)
1096
+
},
1097
+
1098
+
getSession(token: AccessToken): Promise<Result<Session, ApiError>> {
1099
+
return xrpcResult<Session>('com.atproto.server.getSession', { token })
1100
+
.then(r => r.ok ? ok(castSession(r.value)) : r)
1101
+
},
1102
+
1103
+
refreshSession(refreshJwt: RefreshToken): Promise<Result<Session, ApiError>> {
1104
+
return xrpcResult<Session>('com.atproto.server.refreshSession', {
1105
+
method: 'POST',
1106
+
token: refreshJwt,
1107
+
}).then(r => r.ok ? ok(castSession(r.value)) : r)
1108
+
},
1109
+
1110
+
describeServer(): Promise<Result<ServerDescription, ApiError>> {
1111
+
return xrpcResult('com.atproto.server.describeServer')
1112
+
},
1113
+
1114
+
listAppPasswords(token: AccessToken): Promise<Result<{ passwords: AppPassword[] }, ApiError>> {
1115
+
return xrpcResult('com.atproto.server.listAppPasswords', { token })
1116
+
},
1117
+
1118
+
createAppPassword(
1119
+
token: AccessToken,
1120
+
name: string,
1121
+
scopes?: string
1122
+
): Promise<Result<CreatedAppPassword, ApiError>> {
1123
+
return xrpcResult('com.atproto.server.createAppPassword', {
1124
+
method: 'POST',
1125
+
token,
1126
+
body: { name, scopes },
1127
+
})
1128
+
},
1129
+
1130
+
revokeAppPassword(token: AccessToken, name: string): Promise<Result<void, ApiError>> {
1131
+
return xrpcResult<void>('com.atproto.server.revokeAppPassword', {
1132
+
method: 'POST',
1133
+
token,
1134
+
body: { name },
1135
+
})
1136
+
},
1137
+
1138
+
listSessions(token: AccessToken): Promise<Result<ListSessionsResponse, ApiError>> {
1139
+
return xrpcResult('_account.listSessions', { token })
1140
+
},
1141
+
1142
+
revokeSession(token: AccessToken, sessionId: string): Promise<Result<void, ApiError>> {
1143
+
return xrpcResult<void>('_account.revokeSession', {
1144
+
method: 'POST',
1145
+
token,
1146
+
body: { sessionId },
1147
+
})
1148
+
},
1149
+
1150
+
getTotpStatus(token: AccessToken): Promise<Result<TotpStatus, ApiError>> {
1151
+
return xrpcResult('com.atproto.server.getTotpStatus', { token })
1152
+
},
1153
+
1154
+
createTotpSecret(token: AccessToken): Promise<Result<TotpSecret, ApiError>> {
1155
+
return xrpcResult('com.atproto.server.createTotpSecret', {
1156
+
method: 'POST',
1157
+
token,
1158
+
})
1159
+
},
1160
+
1161
+
enableTotp(token: AccessToken, code: string): Promise<Result<EnableTotpResponse, ApiError>> {
1162
+
return xrpcResult('com.atproto.server.enableTotp', {
1163
+
method: 'POST',
1164
+
token,
1165
+
body: { code },
1166
+
})
1167
+
},
1168
+
1169
+
disableTotp(
1170
+
token: AccessToken,
1171
+
password: string,
1172
+
code: string
1173
+
): Promise<Result<SuccessResponse, ApiError>> {
1174
+
return xrpcResult('com.atproto.server.disableTotp', {
1175
+
method: 'POST',
1176
+
token,
1177
+
body: { password, code },
1178
+
})
1179
+
},
1180
+
1181
+
listPasskeys(token: AccessToken): Promise<Result<ListPasskeysResponse, ApiError>> {
1182
+
return xrpcResult('com.atproto.server.listPasskeys', { token })
1183
+
},
1184
+
1185
+
deletePasskey(token: AccessToken, id: string): Promise<Result<void, ApiError>> {
1186
+
return xrpcResult<void>('com.atproto.server.deletePasskey', {
1187
+
method: 'POST',
1188
+
token,
1189
+
body: { id },
1190
+
})
1191
+
},
1192
+
1193
+
listTrustedDevices(token: AccessToken): Promise<Result<ListTrustedDevicesResponse, ApiError>> {
1194
+
return xrpcResult('_account.listTrustedDevices', { token })
1195
+
},
1196
+
1197
+
getReauthStatus(token: AccessToken): Promise<Result<ReauthStatus, ApiError>> {
1198
+
return xrpcResult('_account.getReauthStatus', { token })
1199
+
},
1200
+
1201
+
getNotificationPrefs(token: AccessToken): Promise<Result<NotificationPrefs, ApiError>> {
1202
+
return xrpcResult('_account.getNotificationPrefs', { token })
1203
+
},
1204
+
1205
+
updateHandle(token: AccessToken, handle: Handle): Promise<Result<void, ApiError>> {
1206
+
return xrpcResult<void>('com.atproto.identity.updateHandle', {
1207
+
method: 'POST',
1208
+
token,
1209
+
body: { handle },
1210
+
})
1211
+
},
1212
+
1213
+
describeRepo(token: AccessToken, repo: Did): Promise<Result<RepoDescription, ApiError>> {
1214
+
return xrpcResult('com.atproto.repo.describeRepo', {
1215
+
token,
1216
+
params: { repo },
1217
+
})
1218
+
},
1219
+
1220
+
listRecords(
1221
+
token: AccessToken,
1222
+
repo: Did,
1223
+
collection: Nsid,
1224
+
options?: { limit?: number; cursor?: string; reverse?: boolean }
1225
+
): Promise<Result<ListRecordsResponse, ApiError>> {
1226
+
const params: Record<string, string> = { repo, collection }
1227
+
if (options?.limit) params.limit = String(options.limit)
1228
+
if (options?.cursor) params.cursor = options.cursor
1229
+
if (options?.reverse) params.reverse = 'true'
1230
+
return xrpcResult('com.atproto.repo.listRecords', { token, params })
1231
+
},
1232
+
1233
+
getRecord(
1234
+
token: AccessToken,
1235
+
repo: Did,
1236
+
collection: Nsid,
1237
+
rkey: Rkey
1238
+
): Promise<Result<RecordResponse, ApiError>> {
1239
+
return xrpcResult('com.atproto.repo.getRecord', {
1240
+
token,
1241
+
params: { repo, collection, rkey },
1242
+
})
1243
+
},
1244
+
1245
+
deleteRecord(
1246
+
token: AccessToken,
1247
+
repo: Did,
1248
+
collection: Nsid,
1249
+
rkey: Rkey
1250
+
): Promise<Result<void, ApiError>> {
1251
+
return xrpcResult<void>('com.atproto.repo.deleteRecord', {
1252
+
method: 'POST',
1253
+
token,
1254
+
body: { repo, collection, rkey },
1255
+
})
1256
+
},
1257
+
1258
+
searchAccounts(
1259
+
token: AccessToken,
1260
+
options?: { handle?: string; cursor?: string; limit?: number }
1261
+
): Promise<Result<SearchAccountsResponse, ApiError>> {
1262
+
const params: Record<string, string> = {}
1263
+
if (options?.handle) params.handle = options.handle
1264
+
if (options?.cursor) params.cursor = options.cursor
1265
+
if (options?.limit) params.limit = String(options.limit)
1266
+
return xrpcResult('com.atproto.admin.searchAccounts', { token, params })
1267
+
},
1268
+
1269
+
getAccountInfo(token: AccessToken, did: Did): Promise<Result<AccountInfo, ApiError>> {
1270
+
return xrpcResult('com.atproto.admin.getAccountInfo', { token, params: { did } })
1271
+
},
1272
+
1273
+
getServerStats(token: AccessToken): Promise<Result<ServerStats, ApiError>> {
1274
+
return xrpcResult('_admin.getServerStats', { token })
1275
+
},
1276
+
1277
+
listBackups(token: AccessToken): Promise<Result<ListBackupsResponse, ApiError>> {
1278
+
return xrpcResult('_backup.listBackups', { token })
1279
+
},
1280
+
1281
+
createBackup(token: AccessToken): Promise<Result<CreateBackupResponse, ApiError>> {
1282
+
return xrpcResult('_backup.createBackup', {
1283
+
method: 'POST',
1284
+
token,
1285
+
})
1286
+
},
1287
+
1288
+
getDidDocument(token: AccessToken): Promise<Result<DidDocument, ApiError>> {
1289
+
return xrpcResult('_account.getDidDocument', { token })
1290
+
},
1291
+
1292
+
deleteSession(token: AccessToken): Promise<Result<void, ApiError>> {
1293
+
return xrpcResult<void>('com.atproto.server.deleteSession', {
1294
+
method: 'POST',
1295
+
token,
1296
+
})
1297
+
},
1298
+
1299
+
revokeAllSessions(token: AccessToken): Promise<Result<{ revokedCount: number }, ApiError>> {
1300
+
return xrpcResult('_account.revokeAllSessions', {
1301
+
method: 'POST',
1302
+
token,
1303
+
})
1304
+
},
1305
+
1306
+
getAccountInviteCodes(token: AccessToken): Promise<Result<{ codes: InviteCodeInfo[] }, ApiError>> {
1307
+
return xrpcResult('com.atproto.server.getAccountInviteCodes', { token })
1308
+
},
1309
+
1310
+
createInviteCode(token: AccessToken, useCount: number = 1): Promise<Result<{ code: string }, ApiError>> {
1311
+
return xrpcResult('com.atproto.server.createInviteCode', {
1312
+
method: 'POST',
1313
+
token,
1314
+
body: { useCount },
1315
+
})
1316
+
},
1317
+
1318
+
changePassword(
1319
+
token: AccessToken,
1320
+
currentPassword: string,
1321
+
newPassword: string
1322
+
): Promise<Result<void, ApiError>> {
1323
+
return xrpcResult<void>('_account.changePassword', {
1324
+
method: 'POST',
1325
+
token,
1326
+
body: { currentPassword, newPassword },
1327
+
})
1328
+
},
1329
+
1330
+
getPasswordStatus(token: AccessToken): Promise<Result<PasswordStatus, ApiError>> {
1331
+
return xrpcResult('_account.getPasswordStatus', { token })
1332
+
},
1333
+
1334
+
getServerConfig(): Promise<Result<ServerConfig, ApiError>> {
1335
+
return xrpcResult('_server.getConfig')
1336
+
},
1337
+
1338
+
getLegacyLoginPreference(token: AccessToken): Promise<Result<LegacyLoginPreference, ApiError>> {
1339
+
return xrpcResult('_account.getLegacyLoginPreference', { token })
1340
+
},
1341
+
1342
+
updateLegacyLoginPreference(
1343
+
token: AccessToken,
1344
+
allowLegacyLogin: boolean
1345
+
): Promise<Result<UpdateLegacyLoginResponse, ApiError>> {
1346
+
return xrpcResult('_account.updateLegacyLoginPreference', {
1347
+
method: 'POST',
1348
+
token,
1349
+
body: { allowLegacyLogin },
1350
+
})
1351
+
},
1352
+
1353
+
getNotificationHistory(token: AccessToken): Promise<Result<NotificationHistoryResponse, ApiError>> {
1354
+
return xrpcResult('_account.getNotificationHistory', { token })
1355
+
},
1356
+
1357
+
updateNotificationPrefs(
1358
+
token: AccessToken,
1359
+
prefs: {
1360
+
preferredChannel?: string
1361
+
discordId?: string
1362
+
telegramUsername?: string
1363
+
signalNumber?: string
1364
}
1365
+
): Promise<Result<SuccessResponse, ApiError>> {
1366
+
return xrpcResult('_account.updateNotificationPrefs', {
1367
+
method: 'POST',
1368
+
token,
1369
+
body: prefs,
1370
+
})
1371
},
1372
+
1373
+
revokeTrustedDevice(token: AccessToken, deviceId: string): Promise<Result<SuccessResponse, ApiError>> {
1374
+
return xrpcResult('_account.revokeTrustedDevice', {
1375
+
method: 'POST',
1376
+
token,
1377
+
body: { deviceId },
1378
+
})
1379
+
},
1380
+
1381
+
updateTrustedDevice(
1382
+
token: AccessToken,
1383
+
deviceId: string,
1384
+
friendlyName: string
1385
+
): Promise<Result<SuccessResponse, ApiError>> {
1386
+
return xrpcResult('_account.updateTrustedDevice', {
1387
+
method: 'POST',
1388
+
token,
1389
+
body: { deviceId, friendlyName },
1390
+
})
1391
+
},
1392
+
1393
+
reauthPassword(token: AccessToken, password: string): Promise<Result<ReauthResponse, ApiError>> {
1394
+
return xrpcResult('_account.reauthPassword', {
1395
+
method: 'POST',
1396
+
token,
1397
+
body: { password },
1398
+
})
1399
+
},
1400
+
1401
+
reauthTotp(token: AccessToken, code: string): Promise<Result<ReauthResponse, ApiError>> {
1402
+
return xrpcResult('_account.reauthTotp', {
1403
+
method: 'POST',
1404
+
token,
1405
+
body: { code },
1406
+
})
1407
+
},
1408
+
1409
+
reauthPasskeyStart(token: AccessToken): Promise<Result<ReauthPasskeyStartResponse, ApiError>> {
1410
+
return xrpcResult('_account.reauthPasskeyStart', {
1411
+
method: 'POST',
1412
+
token,
1413
+
})
1414
+
},
1415
+
1416
+
reauthPasskeyFinish(token: AccessToken, credential: unknown): Promise<Result<ReauthResponse, ApiError>> {
1417
+
return xrpcResult('_account.reauthPasskeyFinish', {
1418
+
method: 'POST',
1419
+
token,
1420
+
body: { credential },
1421
+
})
1422
+
},
1423
+
1424
+
confirmSignup(did: Did, verificationCode: string): Promise<Result<ConfirmSignupResult, ApiError>> {
1425
+
return xrpcResult('com.atproto.server.confirmSignup', {
1426
+
method: 'POST',
1427
+
body: { did, verificationCode },
1428
+
})
1429
+
},
1430
+
1431
+
resendVerification(did: Did): Promise<Result<{ success: boolean }, ApiError>> {
1432
+
return xrpcResult('com.atproto.server.resendVerification', {
1433
+
method: 'POST',
1434
+
body: { did },
1435
+
})
1436
+
},
1437
+
1438
+
requestEmailUpdate(token: AccessToken): Promise<Result<EmailUpdateResponse, ApiError>> {
1439
+
return xrpcResult('com.atproto.server.requestEmailUpdate', {
1440
+
method: 'POST',
1441
+
token,
1442
+
})
1443
+
},
1444
+
1445
+
updateEmail(token: AccessToken, email: string, emailToken?: string): Promise<Result<void, ApiError>> {
1446
+
return xrpcResult<void>('com.atproto.server.updateEmail', {
1447
+
method: 'POST',
1448
+
token,
1449
+
body: { email, token: emailToken },
1450
+
})
1451
+
},
1452
+
1453
+
requestAccountDelete(token: AccessToken): Promise<Result<void, ApiError>> {
1454
+
return xrpcResult<void>('com.atproto.server.requestAccountDelete', {
1455
+
method: 'POST',
1456
+
token,
1457
+
})
1458
+
},
1459
+
1460
+
deleteAccount(did: Did, password: string, deleteToken: string): Promise<Result<void, ApiError>> {
1461
+
return xrpcResult<void>('com.atproto.server.deleteAccount', {
1462
+
method: 'POST',
1463
+
body: { did, password, token: deleteToken },
1464
+
})
1465
+
},
1466
+
1467
+
updateDidDocument(
1468
+
token: AccessToken,
1469
+
params: {
1470
+
verificationMethods?: VerificationMethod[]
1471
+
alsoKnownAs?: string[]
1472
+
serviceEndpoint?: string
1473
+
}
1474
+
): Promise<Result<SuccessResponse, ApiError>> {
1475
+
return xrpcResult('_account.updateDidDocument', {
1476
+
method: 'POST',
1477
+
token,
1478
+
body: params,
1479
+
})
1480
+
},
1481
+
1482
+
deactivateAccount(token: AccessToken, deleteAfter?: string): Promise<Result<void, ApiError>> {
1483
+
return xrpcResult<void>('com.atproto.server.deactivateAccount', {
1484
+
method: 'POST',
1485
+
token,
1486
+
body: { deleteAfter },
1487
+
})
1488
+
},
1489
+
1490
+
activateAccount(token: AccessToken): Promise<Result<void, ApiError>> {
1491
+
return xrpcResult<void>('com.atproto.server.activateAccount', {
1492
+
method: 'POST',
1493
+
token,
1494
+
})
1495
+
},
1496
+
1497
+
setBackupEnabled(token: AccessToken, enabled: boolean): Promise<Result<SetBackupEnabledResponse, ApiError>> {
1498
+
return xrpcResult('_backup.setEnabled', {
1499
+
method: 'POST',
1500
+
token,
1501
+
body: { enabled },
1502
+
})
1503
+
},
1504
+
1505
+
deleteBackup(token: AccessToken, id: string): Promise<Result<void, ApiError>> {
1506
+
return xrpcResult<void>('_backup.deleteBackup', {
1507
+
method: 'POST',
1508
+
token,
1509
+
params: { id },
1510
+
})
1511
+
},
1512
+
1513
+
createRecord(
1514
+
token: AccessToken,
1515
+
repo: Did,
1516
+
collection: Nsid,
1517
+
record: unknown,
1518
+
rkey?: Rkey
1519
+
): Promise<Result<CreateRecordResponse, ApiError>> {
1520
+
return xrpcResult('com.atproto.repo.createRecord', {
1521
+
method: 'POST',
1522
+
token,
1523
+
body: { repo, collection, record, rkey },
1524
+
})
1525
+
},
1526
+
1527
+
putRecord(
1528
+
token: AccessToken,
1529
+
repo: Did,
1530
+
collection: Nsid,
1531
+
rkey: Rkey,
1532
+
record: unknown
1533
+
): Promise<Result<CreateRecordResponse, ApiError>> {
1534
+
return xrpcResult('com.atproto.repo.putRecord', {
1535
+
method: 'POST',
1536
+
token,
1537
+
body: { repo, collection, rkey, record },
1538
+
})
1539
+
},
1540
+
1541
+
getInviteCodes(
1542
+
token: AccessToken,
1543
+
options?: { sort?: 'recent' | 'usage'; cursor?: string; limit?: number }
1544
+
): Promise<Result<GetInviteCodesResponse, ApiError>> {
1545
+
const params: Record<string, string> = {}
1546
+
if (options?.sort) params.sort = options.sort
1547
+
if (options?.cursor) params.cursor = options.cursor
1548
+
if (options?.limit) params.limit = String(options.limit)
1549
+
return xrpcResult('com.atproto.admin.getInviteCodes', { token, params })
1550
+
},
1551
+
1552
+
disableAccountInvites(token: AccessToken, account: Did): Promise<Result<void, ApiError>> {
1553
+
return xrpcResult<void>('com.atproto.admin.disableAccountInvites', {
1554
+
method: 'POST',
1555
+
token,
1556
+
body: { account },
1557
+
})
1558
+
},
1559
+
1560
+
enableAccountInvites(token: AccessToken, account: Did): Promise<Result<void, ApiError>> {
1561
+
return xrpcResult<void>('com.atproto.admin.enableAccountInvites', {
1562
+
method: 'POST',
1563
+
token,
1564
+
body: { account },
1565
+
})
1566
+
},
1567
+
1568
+
adminDeleteAccount(token: AccessToken, did: Did): Promise<Result<void, ApiError>> {
1569
+
return xrpcResult<void>('com.atproto.admin.deleteAccount', {
1570
+
method: 'POST',
1571
+
token,
1572
+
body: { did },
1573
+
})
1574
+
},
1575
+
1576
+
startPasskeyRegistration(
1577
+
token: AccessToken,
1578
+
friendlyName?: string
1579
+
): Promise<Result<StartPasskeyRegistrationResponse, ApiError>> {
1580
+
return xrpcResult('com.atproto.server.startPasskeyRegistration', {
1581
+
method: 'POST',
1582
+
token,
1583
+
body: { friendlyName },
1584
+
})
1585
+
},
1586
+
1587
+
finishPasskeyRegistration(
1588
+
token: AccessToken,
1589
+
credential: unknown,
1590
+
friendlyName?: string
1591
+
): Promise<Result<FinishPasskeyRegistrationResponse, ApiError>> {
1592
+
return xrpcResult('com.atproto.server.finishPasskeyRegistration', {
1593
+
method: 'POST',
1594
+
token,
1595
+
body: { credential, friendlyName },
1596
+
})
1597
+
},
1598
+
1599
+
updatePasskey(
1600
+
token: AccessToken,
1601
+
id: string,
1602
+
friendlyName: string
1603
+
): Promise<Result<void, ApiError>> {
1604
+
return xrpcResult<void>('com.atproto.server.updatePasskey', {
1605
+
method: 'POST',
1606
+
token,
1607
+
body: { id, friendlyName },
1608
+
})
1609
+
},
1610
+
1611
+
regenerateBackupCodes(
1612
+
token: AccessToken,
1613
+
password: string,
1614
+
code: string
1615
+
): Promise<Result<RegenerateBackupCodesResponse, ApiError>> {
1616
+
return xrpcResult('com.atproto.server.regenerateBackupCodes', {
1617
+
method: 'POST',
1618
+
token,
1619
+
body: { password, code },
1620
+
})
1621
+
},
1622
+
1623
+
updateLocale(token: AccessToken, preferredLocale: string): Promise<Result<UpdateLocaleResponse, ApiError>> {
1624
+
return xrpcResult('_account.updateLocale', {
1625
+
method: 'POST',
1626
+
token,
1627
+
body: { preferredLocale },
1628
+
})
1629
+
},
1630
+
1631
+
confirmChannelVerification(
1632
+
token: AccessToken,
1633
+
channel: string,
1634
+
identifier: string,
1635
+
code: string
1636
+
): Promise<Result<SuccessResponse, ApiError>> {
1637
+
return xrpcResult('_account.confirmChannelVerification', {
1638
+
method: 'POST',
1639
+
token,
1640
+
body: { channel, identifier, code },
1641
+
})
1642
+
},
1643
+
1644
+
removePassword(token: AccessToken): Promise<Result<SuccessResponse, ApiError>> {
1645
+
return xrpcResult('_account.removePassword', {
1646
+
method: 'POST',
1647
+
token,
1648
+
})
1649
+
},
1650
+
}
+420
-231
frontend/src/lib/auth.svelte.ts
+420
-231
frontend/src/lib/auth.svelte.ts
···
1
import {
2
api,
3
ApiError,
4
type CreateAccountParams,
5
type CreateAccountResult,
6
-
type Session,
7
-
setTokenRefreshCallback,
8
} from "./api";
9
import {
10
checkForOAuthCallback,
11
clearOAuthCallbackParams,
···
15
} from "./oauth";
16
import { setLocale, type SupportedLocale } from "./i18n";
17
18
-
function applyLocaleFromSession(
19
-
sessionInfo: { preferredLocale?: string | null },
20
-
) {
21
-
if (sessionInfo.preferredLocale) {
22
-
setLocale(sessionInfo.preferredLocale as SupportedLocale);
23
}
24
}
25
26
-
const STORAGE_KEY = "tranquil_pds_session";
27
-
const ACCOUNTS_KEY = "tranquil_pds_accounts";
28
29
-
export interface SavedAccount {
30
-
did: string;
31
-
handle: string;
32
-
accessJwt: string;
33
-
refreshJwt: string;
34
}
35
36
-
interface AuthState {
37
-
session: Session | null;
38
-
loading: boolean;
39
-
error: string | null;
40
-
savedAccounts: SavedAccount[];
41
}
42
43
-
const state = $state<AuthState>({
44
-
session: null,
45
-
loading: true,
46
-
error: null,
47
-
savedAccounts: [],
48
});
49
50
-
function saveSession(session: Session | null) {
51
-
if (session) {
52
-
localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
53
-
} else {
54
-
localStorage.removeItem(STORAGE_KEY);
55
}
56
}
57
58
-
function loadSession(): Session | null {
59
-
const stored = localStorage.getItem(STORAGE_KEY);
60
-
if (stored) {
61
-
try {
62
-
return JSON.parse(stored);
63
-
} catch {
64
-
return null;
65
}
66
}
67
-
return null;
68
}
69
70
-
function loadSavedAccounts(): SavedAccount[] {
71
-
const stored = localStorage.getItem(ACCOUNTS_KEY);
72
-
if (stored) {
73
-
try {
74
-
return JSON.parse(stored);
75
-
} catch {
76
-
return [];
77
}
78
}
79
-
return [];
80
}
81
82
-
function saveSavedAccounts(accounts: SavedAccount[]) {
83
-
localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts));
84
}
85
86
-
function addOrUpdateSavedAccount(session: Session) {
87
-
const accounts = loadSavedAccounts();
88
-
const existing = accounts.findIndex((a) => a.did === session.did);
89
-
const savedAccount: SavedAccount = {
90
-
did: session.did,
91
-
handle: session.handle,
92
-
accessJwt: session.accessJwt,
93
-
refreshJwt: session.refreshJwt,
94
-
};
95
-
if (existing >= 0) {
96
-
accounts[existing] = savedAccount;
97
} else {
98
-
accounts.push(savedAccount);
99
}
100
-
saveSavedAccounts(accounts);
101
-
state.savedAccounts = accounts;
102
}
103
104
-
function removeSavedAccount(did: string) {
105
-
const accounts = loadSavedAccounts().filter((a) => a.did !== did);
106
-
saveSavedAccounts(accounts);
107
-
state.savedAccounts = accounts;
108
}
109
110
async function tryRefreshToken(): Promise<string | null> {
111
-
if (!state.session) return null;
112
try {
113
-
const tokens = await refreshOAuthToken(state.session.refreshJwt);
114
const sessionInfo = await api.getSession(tokens.access_token);
115
const session: Session = {
116
...sessionInfo,
117
accessJwt: tokens.access_token,
118
-
refreshJwt: tokens.refresh_token || state.session.refreshJwt,
119
};
120
-
state.session = session;
121
-
saveSession(session);
122
-
addOrUpdateSavedAccount(session);
123
return session.accessJwt;
124
} catch {
125
return null;
126
}
127
}
128
129
export async function initAuth(): Promise<{ oauthLoginCompleted: boolean }> {
130
setTokenRefreshCallback(tryRefreshToken);
131
-
state.loading = true;
132
-
state.error = null;
133
-
state.savedAccounts = loadSavedAccounts();
134
135
const oauthCallback = checkForOAuthCallback();
136
if (oauthCallback) {
···
146
accessJwt: tokens.access_token,
147
refreshJwt: tokens.refresh_token || "",
148
};
149
-
state.session = session;
150
-
saveSession(session);
151
-
addOrUpdateSavedAccount(session);
152
applyLocaleFromSession(sessionInfo);
153
-
state.loading = false;
154
return { oauthLoginCompleted: true };
155
} catch (e) {
156
-
state.error = e instanceof Error ? e.message : "OAuth login failed";
157
-
state.loading = false;
158
return { oauthLoginCompleted: false };
159
}
160
}
161
162
-
const stored = loadSession();
163
if (stored) {
164
try {
165
const sessionInfo = await api.getSession(stored.accessJwt);
166
-
state.session = {
167
...sessionInfo,
168
accessJwt: stored.accessJwt,
169
refreshJwt: stored.refreshJwt,
170
};
171
-
addOrUpdateSavedAccount(state.session);
172
applyLocaleFromSession(sessionInfo);
173
} catch (e) {
174
if (e instanceof ApiError && e.status === 401) {
···
180
accessJwt: tokens.access_token,
181
refreshJwt: tokens.refresh_token || stored.refreshJwt,
182
};
183
-
state.session = session;
184
-
saveSession(session);
185
-
addOrUpdateSavedAccount(session);
186
applyLocaleFromSession(sessionInfo);
187
} catch (refreshError) {
188
console.error("Token refresh failed during init:", refreshError);
189
-
saveSession(null);
190
-
state.session = null;
191
}
192
} else {
193
console.error("Non-401 error during getSession:", e);
194
-
saveSession(null);
195
-
state.session = null;
196
}
197
}
198
}
199
-
state.loading = false;
200
return { oauthLoginCompleted: false };
201
}
202
203
export async function login(
204
identifier: string,
205
password: string,
206
-
): Promise<void> {
207
-
state.loading = true;
208
-
state.error = null;
209
-
try {
210
-
const session = await api.createSession(identifier, password);
211
-
state.session = session;
212
-
saveSession(session);
213
-
addOrUpdateSavedAccount(session);
214
-
} catch (e) {
215
-
if (e instanceof ApiError) {
216
-
state.error = e.message;
217
-
} else {
218
-
state.error = "Login failed";
219
-
}
220
-
throw e;
221
-
} finally {
222
-
state.loading = false;
223
}
224
}
225
226
-
export async function loginWithOAuth(): Promise<void> {
227
-
state.loading = true;
228
-
state.error = null;
229
try {
230
await startOAuthLogin();
231
} catch (e) {
232
-
state.loading = false;
233
-
state.error = e instanceof Error
234
-
? e.message
235
-
: "Failed to start OAuth login";
236
-
throw e;
237
}
238
}
239
240
export async function register(
241
params: CreateAccountParams,
242
-
): Promise<CreateAccountResult> {
243
try {
244
const result = await api.createAccount(params);
245
-
return result;
246
} catch (e) {
247
-
if (e instanceof ApiError) {
248
-
state.error = e.message;
249
-
} else {
250
-
state.error = "Registration failed";
251
-
}
252
-
throw e;
253
}
254
}
255
256
export async function confirmSignup(
257
did: string,
258
verificationCode: string,
259
-
): Promise<void> {
260
-
state.loading = true;
261
-
state.error = null;
262
try {
263
const result = await api.confirmSignup(did, verificationCode);
264
const session: Session = {
···
271
preferredChannel: result.preferredChannel,
272
preferredChannelVerified: result.preferredChannelVerified,
273
};
274
-
state.session = session;
275
-
saveSession(session);
276
-
addOrUpdateSavedAccount(session);
277
} catch (e) {
278
-
if (e instanceof ApiError) {
279
-
state.error = e.message;
280
-
} else {
281
-
state.error = "Verification failed";
282
-
}
283
-
throw e;
284
-
} finally {
285
-
state.loading = false;
286
}
287
}
288
289
-
export async function resendVerification(did: string): Promise<void> {
290
try {
291
await api.resendVerification(did);
292
} catch (e) {
293
-
if (e instanceof ApiError) {
294
-
throw e;
295
-
}
296
-
throw new Error("Failed to resend verification code");
297
}
298
}
299
300
-
export function setSession(
301
-
session: {
302
-
did: string;
303
-
handle: string;
304
-
accessJwt: string;
305
-
refreshJwt: string;
306
-
},
307
-
): void {
308
const newSession: Session = {
309
did: session.did,
310
handle: session.handle,
311
accessJwt: session.accessJwt,
312
refreshJwt: session.refreshJwt,
313
};
314
-
state.session = newSession;
315
-
saveSession(newSession);
316
-
addOrUpdateSavedAccount(newSession);
317
}
318
319
-
export async function logout(): Promise<void> {
320
-
if (state.session) {
321
-
const did = state.session.did;
322
-
const refreshToken = state.session.refreshJwt;
323
try {
324
await fetch("/oauth/revoke", {
325
method: "POST",
326
headers: { "Content-Type": "application/x-www-form-urlencoded" },
327
-
body: new URLSearchParams({ token: refreshToken }),
328
});
329
} catch {
330
-
// Ignore errors on logout
331
}
332
-
removeSavedAccount(did);
333
}
334
-
state.session = null;
335
-
saveSession(null);
336
}
337
338
-
export async function switchAccount(did: string): Promise<void> {
339
-
const account = state.savedAccounts.find((a) => a.did === did);
340
if (!account) {
341
-
throw new Error("Account not found");
342
}
343
-
state.loading = true;
344
-
state.error = null;
345
try {
346
-
const session = await api.getSession(account.accessJwt);
347
-
state.session = {
348
-
...session,
349
-
accessJwt: account.accessJwt,
350
-
refreshJwt: account.refreshJwt,
351
};
352
-
saveSession(state.session);
353
-
addOrUpdateSavedAccount(state.session);
354
} catch (e) {
355
if (e instanceof ApiError && e.status === 401) {
356
try {
357
-
const tokens = await refreshOAuthToken(account.refreshJwt);
358
const sessionInfo = await api.getSession(tokens.access_token);
359
const session: Session = {
360
...sessionInfo,
361
accessJwt: tokens.access_token,
362
-
refreshJwt: tokens.refresh_token || account.refreshJwt,
363
};
364
-
state.session = session;
365
-
saveSession(session);
366
-
addOrUpdateSavedAccount(session);
367
} catch {
368
-
removeSavedAccount(did);
369
-
state.error = "Session expired. Please log in again.";
370
-
throw new Error("Session expired");
371
}
372
-
} else {
373
-
state.error = "Failed to switch account";
374
-
throw e;
375
}
376
-
} finally {
377
-
state.loading = false;
378
}
379
}
380
381
-
export function forgetAccount(did: string): void {
382
-
removeSavedAccount(did);
383
}
384
385
-
export function getAuthState() {
386
-
return state;
387
}
388
389
-
export async function refreshSession(): Promise<void> {
390
-
if (!state.session) return;
391
try {
392
-
const sessionInfo = await api.getSession(state.session.accessJwt);
393
-
state.session = {
394
...sessionInfo,
395
-
accessJwt: state.session.accessJwt,
396
-
refreshJwt: state.session.refreshJwt,
397
};
398
-
saveSession(state.session);
399
-
addOrUpdateSavedAccount(state.session);
400
} catch (e) {
401
console.error("Failed to refresh session:", e);
402
}
403
}
404
405
-
export function getToken(): string | null {
406
-
return state.session?.accessJwt ?? null;
407
}
408
409
-
export async function getValidToken(): Promise<string | null> {
410
-
if (!state.session) return null;
411
try {
412
-
await api.getSession(state.session.accessJwt);
413
-
return state.session.accessJwt;
414
} catch (e) {
415
if (e instanceof ApiError && e.status === 401) {
416
try {
417
-
const tokens = await refreshOAuthToken(state.session.refreshJwt);
418
const sessionInfo = await api.getSession(tokens.access_token);
419
const session: Session = {
420
...sessionInfo,
421
accessJwt: tokens.access_token,
422
-
refreshJwt: tokens.refresh_token || state.session.refreshJwt,
423
};
424
-
state.session = session;
425
-
saveSession(session);
426
-
addOrUpdateSavedAccount(session);
427
-
return session.accessJwt;
428
} catch {
429
return null;
430
}
···
434
}
435
436
export function isAuthenticated(): boolean {
437
-
return state.session !== null;
438
}
439
440
-
export function _testSetState(
441
-
newState: {
442
-
session: Session | null;
443
-
loading: boolean;
444
-
error: string | null;
445
-
savedAccounts?: SavedAccount[];
446
-
},
447
-
) {
448
-
state.session = newState.session;
449
-
state.loading = newState.loading;
450
-
state.error = newState.error;
451
-
state.savedAccounts = newState.savedAccounts ?? [];
452
}
453
454
-
export function _testResetState() {
455
-
state.session = null;
456
-
state.loading = true;
457
-
state.error = null;
458
-
state.savedAccounts = [];
459
}
460
461
-
export function _testReset() {
462
_testResetState();
463
localStorage.removeItem(STORAGE_KEY);
464
localStorage.removeItem(ACCOUNTS_KEY);
465
}
···
1
import {
2
api,
3
ApiError,
4
+
typedApi,
5
type CreateAccountParams,
6
type CreateAccountResult,
7
} from "./api";
8
+
import type { Session } from "./types/api";
9
+
import {
10
+
type Did,
11
+
type Handle,
12
+
type AccessToken,
13
+
type RefreshToken,
14
+
unsafeAsDid,
15
+
unsafeAsHandle,
16
+
unsafeAsAccessToken,
17
+
unsafeAsRefreshToken,
18
+
} from "./types/branded";
19
+
import { type Result, ok, err, isOk, isErr, map } from "./types/result";
20
+
import { assertNever } from "./types/exhaustive";
21
import {
22
checkForOAuthCallback,
23
clearOAuthCallbackParams,
···
27
} from "./oauth";
28
import { setLocale, type SupportedLocale } from "./i18n";
29
30
+
const STORAGE_KEY = "tranquil_pds_session";
31
+
const ACCOUNTS_KEY = "tranquil_pds_accounts";
32
+
33
+
export interface SavedAccount {
34
+
readonly did: Did;
35
+
readonly handle: Handle;
36
+
readonly accessJwt: AccessToken;
37
+
readonly refreshJwt: RefreshToken;
38
+
}
39
+
40
+
export type AuthError =
41
+
| { readonly type: "network"; readonly message: string }
42
+
| { readonly type: "unauthorized"; readonly message: string }
43
+
| { readonly type: "validation"; readonly message: string }
44
+
| { readonly type: "oauth"; readonly message: string }
45
+
| { readonly type: "unknown"; readonly message: string };
46
+
47
+
function toAuthError(e: unknown): AuthError {
48
+
if (e instanceof ApiError) {
49
+
if (e.status === 401) {
50
+
return { type: "unauthorized", message: e.message };
51
+
}
52
+
return { type: "validation", message: e.message };
53
}
54
+
if (e instanceof Error) {
55
+
if (e.message.includes("network") || e.message.includes("fetch")) {
56
+
return { type: "network", message: e.message };
57
+
}
58
+
return { type: "unknown", message: e.message };
59
+
}
60
+
return { type: "unknown", message: "An unknown error occurred" };
61
}
62
63
+
type AuthStateKind = "unauthenticated" | "loading" | "authenticated" | "error";
64
+
65
+
export type AuthState =
66
+
| {
67
+
readonly kind: "unauthenticated";
68
+
readonly savedAccounts: readonly SavedAccount[];
69
+
}
70
+
| {
71
+
readonly kind: "loading";
72
+
readonly savedAccounts: readonly SavedAccount[];
73
+
readonly previousSession: Session | null;
74
+
}
75
+
| {
76
+
readonly kind: "authenticated";
77
+
readonly session: Session;
78
+
readonly savedAccounts: readonly SavedAccount[];
79
+
}
80
+
| {
81
+
readonly kind: "error";
82
+
readonly error: AuthError;
83
+
readonly savedAccounts: readonly SavedAccount[];
84
+
};
85
+
86
+
function createUnauthenticated(
87
+
savedAccounts: readonly SavedAccount[],
88
+
): AuthState {
89
+
return { kind: "unauthenticated", savedAccounts };
90
+
}
91
+
92
+
function createLoading(
93
+
savedAccounts: readonly SavedAccount[],
94
+
previousSession: Session | null = null,
95
+
): AuthState {
96
+
return { kind: "loading", savedAccounts, previousSession };
97
+
}
98
99
+
function createAuthenticated(
100
+
session: Session,
101
+
savedAccounts: readonly SavedAccount[],
102
+
): AuthState {
103
+
return { kind: "authenticated", session, savedAccounts };
104
}
105
106
+
function createError(
107
+
error: AuthError,
108
+
savedAccounts: readonly SavedAccount[],
109
+
): AuthState {
110
+
return { kind: "error", error, savedAccounts };
111
}
112
113
+
const state = $state<{ current: AuthState }>({
114
+
current: createLoading([]),
115
});
116
117
+
function applyLocaleFromSession(sessionInfo: {
118
+
preferredLocale?: string | null;
119
+
}): void {
120
+
if (sessionInfo.preferredLocale) {
121
+
setLocale(sessionInfo.preferredLocale as SupportedLocale);
122
}
123
}
124
125
+
function sessionToSavedAccount(session: Session): SavedAccount {
126
+
return {
127
+
did: unsafeAsDid(session.did),
128
+
handle: unsafeAsHandle(session.handle),
129
+
accessJwt: unsafeAsAccessToken(session.accessJwt),
130
+
refreshJwt: unsafeAsRefreshToken(session.refreshJwt),
131
+
};
132
+
}
133
+
134
+
interface StoredSession {
135
+
readonly did: string;
136
+
readonly handle: string;
137
+
readonly accessJwt: string;
138
+
readonly refreshJwt: string;
139
+
readonly email?: string;
140
+
readonly emailConfirmed?: boolean;
141
+
readonly preferredChannel?: string;
142
+
readonly preferredChannelVerified?: boolean;
143
+
readonly preferredLocale?: string | null;
144
+
}
145
+
146
+
function parseStoredSession(json: string): Result<StoredSession, Error> {
147
+
try {
148
+
const parsed = JSON.parse(json);
149
+
if (
150
+
typeof parsed === "object" &&
151
+
parsed !== null &&
152
+
typeof parsed.did === "string" &&
153
+
typeof parsed.handle === "string" &&
154
+
typeof parsed.accessJwt === "string" &&
155
+
typeof parsed.refreshJwt === "string"
156
+
) {
157
+
return ok(parsed as StoredSession);
158
}
159
+
return err(new Error("Invalid session format"));
160
+
} catch (e) {
161
+
return err(e instanceof Error ? e : new Error("Failed to parse session"));
162
}
163
}
164
165
+
function parseStoredAccounts(json: string): Result<SavedAccount[], Error> {
166
+
try {
167
+
const parsed = JSON.parse(json);
168
+
if (!Array.isArray(parsed)) {
169
+
return err(new Error("Invalid accounts format"));
170
}
171
+
const accounts: SavedAccount[] = parsed
172
+
.filter(
173
+
(a): a is { did: string; handle: string; accessJwt: string; refreshJwt: string } =>
174
+
typeof a === "object" &&
175
+
a !== null &&
176
+
typeof a.did === "string" &&
177
+
typeof a.handle === "string" &&
178
+
typeof a.accessJwt === "string" &&
179
+
typeof a.refreshJwt === "string",
180
+
)
181
+
.map((a) => ({
182
+
did: unsafeAsDid(a.did),
183
+
handle: unsafeAsHandle(a.handle),
184
+
accessJwt: unsafeAsAccessToken(a.accessJwt),
185
+
refreshJwt: unsafeAsRefreshToken(a.refreshJwt),
186
+
}));
187
+
return ok(accounts);
188
+
} catch (e) {
189
+
return err(e instanceof Error ? e : new Error("Failed to parse accounts"));
190
}
191
}
192
193
+
function loadSessionFromStorage(): StoredSession | null {
194
+
const stored = localStorage.getItem(STORAGE_KEY);
195
+
if (!stored) return null;
196
+
const result = parseStoredSession(stored);
197
+
return isOk(result) ? result.value : null;
198
+
}
199
+
200
+
function loadSavedAccountsFromStorage(): readonly SavedAccount[] {
201
+
const stored = localStorage.getItem(ACCOUNTS_KEY);
202
+
if (!stored) return [];
203
+
const result = parseStoredAccounts(stored);
204
+
return isOk(result) ? result.value : [];
205
}
206
207
+
function persistSession(session: Session | null): void {
208
+
if (session) {
209
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
210
} else {
211
+
localStorage.removeItem(STORAGE_KEY);
212
}
213
+
}
214
+
215
+
function persistSavedAccounts(accounts: readonly SavedAccount[]): void {
216
+
localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts));
217
+
}
218
+
219
+
function updateSavedAccounts(
220
+
accounts: readonly SavedAccount[],
221
+
session: Session,
222
+
): readonly SavedAccount[] {
223
+
const newAccount = sessionToSavedAccount(session);
224
+
const filtered = accounts.filter((a) => a.did !== newAccount.did);
225
+
return [...filtered, newAccount];
226
+
}
227
+
228
+
function removeSavedAccountByDid(
229
+
accounts: readonly SavedAccount[],
230
+
did: Did,
231
+
): readonly SavedAccount[] {
232
+
return accounts.filter((a) => a.did !== did);
233
+
}
234
+
235
+
function findSavedAccount(
236
+
accounts: readonly SavedAccount[],
237
+
did: Did,
238
+
): SavedAccount | undefined {
239
+
return accounts.find((a) => a.did === did);
240
+
}
241
+
242
+
function getSavedAccounts(): readonly SavedAccount[] {
243
+
return state.current.savedAccounts;
244
+
}
245
+
246
+
function setState(newState: AuthState): void {
247
+
state.current = newState;
248
}
249
250
+
function setAuthenticated(session: Session): void {
251
+
const accounts = updateSavedAccounts(getSavedAccounts(), session);
252
+
persistSession(session);
253
+
persistSavedAccounts(accounts);
254
+
setState(createAuthenticated(session, accounts));
255
+
}
256
+
257
+
function setUnauthenticated(): void {
258
+
persistSession(null);
259
+
setState(createUnauthenticated(getSavedAccounts()));
260
+
}
261
+
262
+
function setError(error: AuthError): void {
263
+
setState(createError(error, getSavedAccounts()));
264
+
}
265
+
266
+
function setLoading(previousSession: Session | null = null): void {
267
+
setState(createLoading(getSavedAccounts(), previousSession));
268
}
269
270
async function tryRefreshToken(): Promise<string | null> {
271
+
if (state.current.kind !== "authenticated") return null;
272
+
const currentSession = state.current.session;
273
try {
274
+
const tokens = await refreshOAuthToken(currentSession.refreshJwt);
275
const sessionInfo = await api.getSession(tokens.access_token);
276
const session: Session = {
277
...sessionInfo,
278
accessJwt: tokens.access_token,
279
+
refreshJwt: tokens.refresh_token || currentSession.refreshJwt,
280
};
281
+
setAuthenticated(session);
282
return session.accessJwt;
283
} catch {
284
return null;
285
}
286
}
287
288
+
import { setTokenRefreshCallback } from "./api";
289
+
290
export async function initAuth(): Promise<{ oauthLoginCompleted: boolean }> {
291
setTokenRefreshCallback(tryRefreshToken);
292
+
const savedAccounts = loadSavedAccountsFromStorage();
293
+
setState(createLoading(savedAccounts));
294
295
const oauthCallback = checkForOAuthCallback();
296
if (oauthCallback) {
···
306
accessJwt: tokens.access_token,
307
refreshJwt: tokens.refresh_token || "",
308
};
309
+
setAuthenticated(session);
310
applyLocaleFromSession(sessionInfo);
311
return { oauthLoginCompleted: true };
312
} catch (e) {
313
+
setError({ type: "oauth", message: e instanceof Error ? e.message : "OAuth login failed" });
314
return { oauthLoginCompleted: false };
315
}
316
}
317
318
+
const stored = loadSessionFromStorage();
319
if (stored) {
320
try {
321
const sessionInfo = await api.getSession(stored.accessJwt);
322
+
const session: Session = {
323
...sessionInfo,
324
accessJwt: stored.accessJwt,
325
refreshJwt: stored.refreshJwt,
326
};
327
+
setAuthenticated(session);
328
applyLocaleFromSession(sessionInfo);
329
} catch (e) {
330
if (e instanceof ApiError && e.status === 401) {
···
336
accessJwt: tokens.access_token,
337
refreshJwt: tokens.refresh_token || stored.refreshJwt,
338
};
339
+
setAuthenticated(session);
340
applyLocaleFromSession(sessionInfo);
341
} catch (refreshError) {
342
console.error("Token refresh failed during init:", refreshError);
343
+
setUnauthenticated();
344
}
345
} else {
346
console.error("Non-401 error during getSession:", e);
347
+
setUnauthenticated();
348
}
349
}
350
+
} else {
351
+
setState(createUnauthenticated(savedAccounts));
352
}
353
+
354
return { oauthLoginCompleted: false };
355
}
356
357
export async function login(
358
identifier: string,
359
password: string,
360
+
): Promise<Result<Session, AuthError>> {
361
+
const currentState = state.current;
362
+
const previousSession =
363
+
currentState.kind === "authenticated" ? currentState.session : null;
364
+
setLoading(previousSession);
365
+
366
+
const result = await typedApi.createSession(identifier, password);
367
+
if (isErr(result)) {
368
+
const error = toAuthError(result.error);
369
+
setError(error);
370
+
return err(error);
371
}
372
+
373
+
setAuthenticated(result.value);
374
+
return ok(result.value);
375
}
376
377
+
export async function loginWithOAuth(): Promise<Result<void, AuthError>> {
378
+
setLoading();
379
try {
380
await startOAuthLogin();
381
+
return ok(undefined);
382
} catch (e) {
383
+
const error = toAuthError(e);
384
+
setError(error);
385
+
return err(error);
386
}
387
}
388
389
export async function register(
390
params: CreateAccountParams,
391
+
): Promise<Result<CreateAccountResult, AuthError>> {
392
try {
393
const result = await api.createAccount(params);
394
+
return ok(result);
395
} catch (e) {
396
+
return err(toAuthError(e));
397
}
398
}
399
400
export async function confirmSignup(
401
did: string,
402
verificationCode: string,
403
+
): Promise<Result<Session, AuthError>> {
404
+
setLoading();
405
try {
406
const result = await api.confirmSignup(did, verificationCode);
407
const session: Session = {
···
414
preferredChannel: result.preferredChannel,
415
preferredChannelVerified: result.preferredChannelVerified,
416
};
417
+
setAuthenticated(session);
418
+
return ok(session);
419
} catch (e) {
420
+
const error = toAuthError(e);
421
+
setError(error);
422
+
return err(error);
423
}
424
}
425
426
+
export async function resendVerification(
427
+
did: string,
428
+
): Promise<Result<void, AuthError>> {
429
try {
430
await api.resendVerification(did);
431
+
return ok(undefined);
432
} catch (e) {
433
+
return err(toAuthError(e));
434
}
435
}
436
437
+
export function setSession(session: {
438
+
did: string;
439
+
handle: string;
440
+
accessJwt: string;
441
+
refreshJwt: string;
442
+
}): void {
443
const newSession: Session = {
444
did: session.did,
445
handle: session.handle,
446
accessJwt: session.accessJwt,
447
refreshJwt: session.refreshJwt,
448
};
449
+
setAuthenticated(newSession);
450
}
451
452
+
export async function logout(): Promise<Result<void, AuthError>> {
453
+
if (state.current.kind === "authenticated") {
454
+
const { session } = state.current;
455
+
const did = unsafeAsDid(session.did);
456
try {
457
await fetch("/oauth/revoke", {
458
method: "POST",
459
headers: { "Content-Type": "application/x-www-form-urlencoded" },
460
+
body: new URLSearchParams({ token: session.refreshJwt }),
461
});
462
} catch {
463
+
// Ignore revocation errors
464
}
465
+
const accounts = removeSavedAccountByDid(getSavedAccounts(), did);
466
+
persistSavedAccounts(accounts);
467
+
persistSession(null);
468
+
setState(createUnauthenticated(accounts));
469
+
} else {
470
+
setUnauthenticated();
471
}
472
+
return ok(undefined);
473
}
474
475
+
export async function switchAccount(
476
+
did: Did,
477
+
): Promise<Result<Session, AuthError>> {
478
+
const account = findSavedAccount(getSavedAccounts(), did);
479
if (!account) {
480
+
return err({ type: "validation", message: "Account not found" });
481
}
482
+
483
+
setLoading();
484
+
485
try {
486
+
const sessionInfo = await api.getSession(account.accessJwt as string);
487
+
const session: Session = {
488
+
...sessionInfo,
489
+
accessJwt: account.accessJwt as string,
490
+
refreshJwt: account.refreshJwt as string,
491
};
492
+
setAuthenticated(session);
493
+
return ok(session);
494
} catch (e) {
495
if (e instanceof ApiError && e.status === 401) {
496
try {
497
+
const tokens = await refreshOAuthToken(account.refreshJwt as string);
498
const sessionInfo = await api.getSession(tokens.access_token);
499
const session: Session = {
500
...sessionInfo,
501
accessJwt: tokens.access_token,
502
+
refreshJwt: tokens.refresh_token || (account.refreshJwt as string),
503
};
504
+
setAuthenticated(session);
505
+
return ok(session);
506
} catch {
507
+
const accounts = removeSavedAccountByDid(getSavedAccounts(), did);
508
+
persistSavedAccounts(accounts);
509
+
const error: AuthError = {
510
+
type: "unauthorized",
511
+
message: "Session expired. Please log in again.",
512
+
};
513
+
setState(createError(error, accounts));
514
+
return err(error);
515
}
516
}
517
+
const error = toAuthError(e);
518
+
setError(error);
519
+
return err(error);
520
}
521
}
522
523
+
export function forgetAccount(did: Did): void {
524
+
const accounts = removeSavedAccountByDid(getSavedAccounts(), did);
525
+
persistSavedAccounts(accounts);
526
+
setState({
527
+
...state.current,
528
+
savedAccounts: accounts,
529
+
} as AuthState);
530
}
531
532
+
export function getAuthState(): AuthState {
533
+
return state.current;
534
}
535
536
+
export async function refreshSession(): Promise<Result<Session, AuthError>> {
537
+
if (state.current.kind !== "authenticated") {
538
+
return err({ type: "unauthorized", message: "Not authenticated" });
539
+
}
540
+
const currentSession = state.current.session;
541
try {
542
+
const sessionInfo = await api.getSession(currentSession.accessJwt);
543
+
const session: Session = {
544
...sessionInfo,
545
+
accessJwt: currentSession.accessJwt,
546
+
refreshJwt: currentSession.refreshJwt,
547
};
548
+
setAuthenticated(session);
549
+
return ok(session);
550
} catch (e) {
551
console.error("Failed to refresh session:", e);
552
+
return err(toAuthError(e));
553
}
554
}
555
556
+
export function getToken(): AccessToken | null {
557
+
if (state.current.kind === "authenticated") {
558
+
return unsafeAsAccessToken(state.current.session.accessJwt);
559
+
}
560
+
return null;
561
}
562
563
+
export async function getValidToken(): Promise<AccessToken | null> {
564
+
if (state.current.kind !== "authenticated") return null;
565
+
const currentSession = state.current.session;
566
try {
567
+
await api.getSession(currentSession.accessJwt);
568
+
return unsafeAsAccessToken(currentSession.accessJwt);
569
} catch (e) {
570
if (e instanceof ApiError && e.status === 401) {
571
try {
572
+
const tokens = await refreshOAuthToken(currentSession.refreshJwt);
573
const sessionInfo = await api.getSession(tokens.access_token);
574
const session: Session = {
575
...sessionInfo,
576
accessJwt: tokens.access_token,
577
+
refreshJwt: tokens.refresh_token || currentSession.refreshJwt,
578
};
579
+
setAuthenticated(session);
580
+
return unsafeAsAccessToken(session.accessJwt);
581
} catch {
582
return null;
583
}
···
587
}
588
589
export function isAuthenticated(): boolean {
590
+
return state.current.kind === "authenticated";
591
+
}
592
+
593
+
export function isLoading(): boolean {
594
+
return state.current.kind === "loading";
595
+
}
596
+
597
+
export function getError(): AuthError | null {
598
+
return state.current.kind === "error" ? state.current.error : null;
599
+
}
600
+
601
+
export function getSession(): Session | null {
602
+
return state.current.kind === "authenticated" ? state.current.session : null;
603
+
}
604
+
605
+
export function matchAuthState<T>(handlers: {
606
+
unauthenticated: (accounts: readonly SavedAccount[]) => T;
607
+
loading: (accounts: readonly SavedAccount[], previousSession: Session | null) => T;
608
+
authenticated: (session: Session, accounts: readonly SavedAccount[]) => T;
609
+
error: (error: AuthError, accounts: readonly SavedAccount[]) => T;
610
+
}): T {
611
+
const current = state.current;
612
+
switch (current.kind) {
613
+
case "unauthenticated":
614
+
return handlers.unauthenticated(current.savedAccounts);
615
+
case "loading":
616
+
return handlers.loading(current.savedAccounts, current.previousSession);
617
+
case "authenticated":
618
+
return handlers.authenticated(current.session, current.savedAccounts);
619
+
case "error":
620
+
return handlers.error(current.error, current.savedAccounts);
621
+
default:
622
+
return assertNever(current);
623
+
}
624
}
625
626
+
export function _testSetState(newState: {
627
+
session: Session | null;
628
+
loading: boolean;
629
+
error: string | null;
630
+
savedAccounts?: SavedAccount[];
631
+
}): void {
632
+
const accounts = newState.savedAccounts ?? [];
633
+
if (newState.loading) {
634
+
setState(createLoading(accounts, newState.session));
635
+
} else if (newState.error) {
636
+
setState(createError({ type: "unknown", message: newState.error }, accounts));
637
+
} else if (newState.session) {
638
+
setState(createAuthenticated(newState.session, accounts));
639
+
} else {
640
+
setState(createUnauthenticated(accounts));
641
+
}
642
}
643
644
+
export function _testResetState(): void {
645
+
setState(createLoading([]));
646
}
647
648
+
export function _testReset(): void {
649
_testResetState();
650
localStorage.removeItem(STORAGE_KEY);
651
localStorage.removeItem(ACCOUNTS_KEY);
652
}
653
+
654
+
export { type Session };
+1
-4
frontend/src/lib/crypto.ts
+1
-4
frontend/src/lib/crypto.ts
···
35
const bytes = typeof data === "string"
36
? new TextEncoder().encode(data)
37
: data;
38
-
let binary = "";
39
-
for (let i = 0; i < bytes.length; i++) {
40
-
binary += String.fromCharCode(bytes[i]);
41
-
}
42
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
43
}
44
+8
-16
frontend/src/lib/migration/atproto-client.ts
+8
-16
frontend/src/lib/migration/atproto-client.ts
···
600
601
export function base64UrlEncode(buffer: Uint8Array | ArrayBuffer): string {
602
const bytes = buffer instanceof ArrayBuffer ? new Uint8Array(buffer) : buffer;
603
-
let binary = "";
604
-
for (let i = 0; i < bytes.length; i++) {
605
-
binary += String.fromCharCode(bytes[i]);
606
-
}
607
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(
608
/=+$/,
609
"",
···
614
const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
615
const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
616
const binary = atob(padded);
617
-
const bytes = new Uint8Array(binary.length);
618
-
for (let i = 0; i < binary.length; i++) {
619
-
bytes[i] = binary.charCodeAt(i);
620
-
}
621
-
return bytes;
622
}
623
624
export function prepareWebAuthnCreationOptions(
···
865
);
866
if (dnsRes.ok) {
867
const dnsData = await dnsRes.json();
868
-
const txtRecords = dnsData.Answer ?? [];
869
-
for (const record of txtRecords) {
870
-
const txt = record.data?.replace(/"/g, "") ?? "";
871
-
if (txt.startsWith("did=")) {
872
-
did = txt.slice(4);
873
-
break;
874
-
}
875
}
876
}
877
···
600
601
export function base64UrlEncode(buffer: Uint8Array | ArrayBuffer): string {
602
const bytes = buffer instanceof ArrayBuffer ? new Uint8Array(buffer) : buffer;
603
+
const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('')
604
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(
605
/=+$/,
606
"",
···
611
const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
612
const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
613
const binary = atob(padded);
614
+
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
615
}
616
617
export function prepareWebAuthnCreationOptions(
···
858
);
859
if (dnsRes.ok) {
860
const dnsData = await dnsRes.json();
861
+
const txtRecords: Array<{ data?: string }> = dnsData.Answer ?? [];
862
+
const didRecord = txtRecords
863
+
.map((record) => record.data?.replace(/"/g, "") ?? "")
864
+
.find((txt) => txt.startsWith("did="));
865
+
if (didRecord) {
866
+
did = didRecord.slice(4);
867
}
868
}
869
+1
-3
frontend/src/lib/migration/blob-migration.ts
+1
-3
frontend/src/lib/migration/blob-migration.ts
+1
-4
frontend/src/lib/oauth.ts
+1
-4
frontend/src/lib/oauth.ts
+1
-1
frontend/src/lib/registration/VerificationStep.svelte
+1
-1
frontend/src/lib/registration/VerificationStep.svelte
···
1
<script lang="ts">
2
import { api, ApiError } from '../api'
3
import type { RegistrationFlow } from './flow.svelte'
4
5
interface Props {
···
36
flow.clearError()
37
38
try {
39
-
const { resendVerification } = await import('../auth.svelte')
40
await resendVerification(flow.account.did)
41
resendMessage = 'Verification code resent!'
42
} catch (err) {
···
1
<script lang="ts">
2
import { api, ApiError } from '../api'
3
+
import { resendVerification } from '../auth.svelte'
4
import type { RegistrationFlow } from './flow.svelte'
5
6
interface Props {
···
37
flow.clearError()
38
39
try {
40
await resendVerification(flow.account.did)
41
resendMessage = 'Verification code resent!'
42
} catch (err) {
+1
-1
frontend/src/lib/registration/flow.svelte.ts
+1
-1
frontend/src/lib/registration/flow.svelte.ts
···
1
import { api, ApiError } from "../api";
2
import {
3
createServiceJwt,
4
generateDidDocument,
···
341
342
async function finalizeSession() {
343
if (!state.session || !state.account) return;
344
-
const { setSession } = await import("../auth.svelte");
345
setSession({
346
did: state.account.did,
347
handle: state.account.handle,
···
1
import { api, ApiError } from "../api";
2
+
import { setSession } from "../auth.svelte";
3
import {
4
createServiceJwt,
5
generateDidDocument,
···
342
343
async function finalizeSession() {
344
if (!state.session || !state.account) return;
345
setSession({
346
did: state.account.did,
347
handle: state.account.handle,
+115
-11
frontend/src/lib/router.svelte.ts
+115
-11
frontend/src/lib/router.svelte.ts
···
1
const APP_BASE = "/app";
2
3
-
function getAppPath(): string {
4
const pathname = globalThis.location.pathname;
5
if (pathname.startsWith(APP_BASE)) {
6
const path = pathname.slice(APP_BASE.length) || "/";
7
-
return path.startsWith("/") ? path : "/" + path;
8
}
9
-
return "/";
10
}
11
12
-
let currentPath = $state(getAppPath());
13
14
-
globalThis.addEventListener("popstate", () => {
15
-
currentPath = getAppPath();
16
});
17
18
-
export function navigate(path: string, replace = false) {
19
-
const fullPath = APP_BASE + (path.startsWith("/") ? path : "/" + path);
20
if (replace) {
21
globalThis.history.replaceState(null, "", fullPath);
22
} else {
23
globalThis.history.pushState(null, "", fullPath);
24
}
25
-
currentPath = path.startsWith("/") ? path : "/" + path;
26
}
27
28
-
export function getCurrentPath() {
29
-
return currentPath;
30
}
31
32
export function getFullUrl(path: string): string {
33
return APP_BASE + (path.startsWith("/") ? path : "/" + path);
34
}
···
1
+
import {
2
+
routes,
3
+
type Route,
4
+
type RouteParams,
5
+
type RoutesWithParams,
6
+
buildUrl,
7
+
parseRouteParams,
8
+
isValidRoute,
9
+
} from "./types/routes";
10
+
11
const APP_BASE = "/app";
12
13
+
type Brand<T, B extends string> = T & { readonly __brand: B };
14
+
type AppPath = Brand<string, "AppPath">;
15
+
16
+
function asAppPath(path: string): AppPath {
17
+
const normalized = path.startsWith("/") ? path : "/" + path;
18
+
return normalized as AppPath;
19
+
}
20
+
21
+
function getAppPath(): AppPath {
22
const pathname = globalThis.location.pathname;
23
if (pathname.startsWith(APP_BASE)) {
24
const path = pathname.slice(APP_BASE.length) || "/";
25
+
return asAppPath(path);
26
}
27
+
return asAppPath("/");
28
}
29
30
+
function getSearchParams(): URLSearchParams {
31
+
return new URLSearchParams(globalThis.location.search);
32
+
}
33
+
34
+
interface RouterState {
35
+
readonly path: AppPath;
36
+
readonly searchParams: URLSearchParams;
37
+
}
38
39
+
const state = $state<{ current: RouterState }>({
40
+
current: {
41
+
path: getAppPath(),
42
+
searchParams: getSearchParams(),
43
+
},
44
});
45
46
+
function updateState(): void {
47
+
state.current = {
48
+
path: getAppPath(),
49
+
searchParams: getSearchParams(),
50
+
};
51
+
}
52
+
53
+
globalThis.addEventListener("popstate", updateState);
54
+
55
+
export function navigate<R extends Route>(
56
+
route: R,
57
+
options?: {
58
+
params?: R extends RoutesWithParams ? RouteParams[R] : never;
59
+
replace?: boolean;
60
+
},
61
+
): void {
62
+
const url = options?.params ? buildUrl(route, options.params) : route;
63
+
const fullPath = APP_BASE + (url.startsWith("/") ? url : "/" + url);
64
+
65
+
if (options?.replace) {
66
+
globalThis.history.replaceState(null, "", fullPath);
67
+
} else {
68
+
globalThis.history.pushState(null, "", fullPath);
69
+
}
70
+
71
+
updateState();
72
+
}
73
+
74
+
export function navigateTo(path: string, replace = false): void {
75
+
const normalizedPath = path.startsWith("/") ? path : "/" + path;
76
+
const fullPath = APP_BASE + normalizedPath;
77
+
78
if (replace) {
79
globalThis.history.replaceState(null, "", fullPath);
80
} else {
81
globalThis.history.pushState(null, "", fullPath);
82
}
83
+
84
+
updateState();
85
}
86
87
+
export function getCurrentPath(): AppPath {
88
+
return state.current.path;
89
+
}
90
+
91
+
export function getCurrentSearchParams(): URLSearchParams {
92
+
return state.current.searchParams;
93
+
}
94
+
95
+
export function getSearchParam(key: string): string | null {
96
+
return state.current.searchParams.get(key);
97
}
98
99
export function getFullUrl(path: string): string {
100
return APP_BASE + (path.startsWith("/") ? path : "/" + path);
101
}
102
+
103
+
export function matchRoute(path: AppPath): Route | null {
104
+
const pathWithoutQuery = path.split("?")[0];
105
+
if (isValidRoute(pathWithoutQuery)) {
106
+
return pathWithoutQuery;
107
+
}
108
+
return null;
109
+
}
110
+
111
+
export function isCurrentRoute(route: Route): boolean {
112
+
const pathWithoutQuery = state.current.path.split("?")[0];
113
+
return pathWithoutQuery === route;
114
+
}
115
+
116
+
export function getRouteParams<R extends RoutesWithParams>(
117
+
_route: R,
118
+
): RouteParams[R] {
119
+
return parseRouteParams(_route);
120
+
}
121
+
122
+
export type RouteMatch =
123
+
| { readonly matched: true; readonly route: Route; readonly params: URLSearchParams }
124
+
| { readonly matched: false };
125
+
126
+
export function match(): RouteMatch {
127
+
const route = matchRoute(state.current.path);
128
+
if (route) {
129
+
return {
130
+
matched: true,
131
+
route,
132
+
params: state.current.searchParams,
133
+
};
134
+
}
135
+
return { matched: false };
136
+
}
137
+
138
+
export { routes, type Route, type RouteParams, type RoutesWithParams };
+74
frontend/src/lib/toast.svelte.ts
+74
frontend/src/lib/toast.svelte.ts
···
···
1
+
export type ToastType = 'success' | 'error' | 'warning' | 'info'
2
+
3
+
export interface Toast {
4
+
id: number
5
+
type: ToastType
6
+
message: string
7
+
duration: number
8
+
dismissing?: boolean
9
+
}
10
+
11
+
let nextId = 0
12
+
let toasts = $state<Toast[]>([])
13
+
14
+
export function getToasts(): readonly Toast[] {
15
+
return toasts
16
+
}
17
+
18
+
export function showToast(
19
+
type: ToastType,
20
+
message: string,
21
+
duration = 5000
22
+
): number {
23
+
const id = nextId++
24
+
toasts = [...toasts, { id, type, message, duration }]
25
+
26
+
if (duration > 0) {
27
+
setTimeout(() => {
28
+
dismissToast(id)
29
+
}, duration)
30
+
}
31
+
32
+
return id
33
+
}
34
+
35
+
export function dismissToast(id: number): void {
36
+
const toast = toasts.find(t => t.id === id)
37
+
if (!toast || toast.dismissing) return
38
+
39
+
toasts = toasts.map(t => t.id === id ? { ...t, dismissing: true } : t)
40
+
41
+
setTimeout(() => {
42
+
toasts = toasts.filter(t => t.id !== id)
43
+
}, 150)
44
+
}
45
+
46
+
export function clearAllToasts(): void {
47
+
toasts = []
48
+
}
49
+
50
+
export function success(message: string, duration?: number): number {
51
+
return showToast('success', message, duration)
52
+
}
53
+
54
+
export function error(message: string, duration?: number): number {
55
+
return showToast('error', message, duration)
56
+
}
57
+
58
+
export function warning(message: string, duration?: number): number {
59
+
return showToast('warning', message, duration)
60
+
}
61
+
62
+
export function info(message: string, duration?: number): number {
63
+
return showToast('info', message, duration)
64
+
}
65
+
66
+
export const toast = {
67
+
show: showToast,
68
+
success,
69
+
error,
70
+
warning,
71
+
info,
72
+
dismiss: dismissToast,
73
+
clear: clearAllToasts,
74
+
}
+486
frontend/src/lib/types/api.ts
+486
frontend/src/lib/types/api.ts
···
···
1
+
import type {
2
+
Did,
3
+
Handle,
4
+
AccessToken,
5
+
RefreshToken,
6
+
Cid,
7
+
Rkey,
8
+
AtUri,
9
+
Nsid,
10
+
ISODateString,
11
+
EmailAddress,
12
+
InviteCode as InviteCodeBrand,
13
+
PublicKeyMultibase,
14
+
} from './branded'
15
+
16
+
export type ApiErrorCode =
17
+
| 'InvalidRequest'
18
+
| 'AuthenticationRequired'
19
+
| 'ExpiredToken'
20
+
| 'InvalidToken'
21
+
| 'AccountNotFound'
22
+
| 'HandleNotAvailable'
23
+
| 'InvalidHandle'
24
+
| 'InvalidPassword'
25
+
| 'RateLimitExceeded'
26
+
| 'InternalServerError'
27
+
| 'AccountTakedown'
28
+
| 'AccountDeactivated'
29
+
| 'AccountNotVerified'
30
+
| 'RepoNotFound'
31
+
| 'RecordNotFound'
32
+
| 'BlobNotFound'
33
+
| 'InvalidInviteCode'
34
+
| 'DuplicateCreate'
35
+
| 'Unknown'
36
+
37
+
export type AccountStatus = 'active' | 'deactivated' | 'migrated' | 'suspended' | 'deleted'
38
+
39
+
export type SessionType = 'oauth' | 'legacy' | 'app_password'
40
+
41
+
export type VerificationChannel = 'email' | 'discord' | 'telegram' | 'signal'
42
+
43
+
export type DidType = 'plc' | 'web' | 'web-external'
44
+
45
+
export type ReauthMethod = 'password' | 'totp' | 'passkey'
46
+
47
+
export interface Session {
48
+
did: Did
49
+
handle: Handle
50
+
email?: EmailAddress
51
+
emailConfirmed?: boolean
52
+
preferredChannel?: VerificationChannel
53
+
preferredChannelVerified?: boolean
54
+
isAdmin?: boolean
55
+
active?: boolean
56
+
status?: AccountStatus
57
+
migratedToPds?: string
58
+
migratedAt?: ISODateString
59
+
accessJwt: AccessToken
60
+
refreshJwt: RefreshToken
61
+
}
62
+
63
+
export interface VerificationMethod {
64
+
id: string
65
+
type: string
66
+
controller: string
67
+
publicKeyMultibase: PublicKeyMultibase
68
+
}
69
+
70
+
export interface ServiceEndpoint {
71
+
id: string
72
+
type: string
73
+
serviceEndpoint: string
74
+
}
75
+
76
+
export interface DidDocument {
77
+
'@context': string[]
78
+
id: Did
79
+
alsoKnownAs: string[]
80
+
verificationMethod: VerificationMethod[]
81
+
service: ServiceEndpoint[]
82
+
}
83
+
84
+
export interface AppPassword {
85
+
name: string
86
+
createdAt: ISODateString
87
+
scopes?: string
88
+
createdByController?: string
89
+
}
90
+
91
+
export interface CreatedAppPassword {
92
+
name: string
93
+
password: string
94
+
createdAt: ISODateString
95
+
scopes?: string
96
+
}
97
+
98
+
export interface InviteCodeUse {
99
+
usedBy: Did
100
+
usedByHandle?: Handle
101
+
usedAt: ISODateString
102
+
}
103
+
104
+
export interface InviteCodeInfo {
105
+
code: InviteCodeBrand
106
+
available: number
107
+
disabled: boolean
108
+
forAccount: Did
109
+
createdBy: Did
110
+
createdAt: ISODateString
111
+
uses: InviteCodeUse[]
112
+
}
113
+
114
+
export interface CreateAccountParams {
115
+
handle: string
116
+
email: string
117
+
password: string
118
+
inviteCode?: string
119
+
didType?: DidType
120
+
did?: string
121
+
signingKey?: string
122
+
verificationChannel?: VerificationChannel
123
+
discordId?: string
124
+
telegramUsername?: string
125
+
signalNumber?: string
126
+
}
127
+
128
+
export interface CreateAccountResult {
129
+
handle: Handle
130
+
did: Did
131
+
verificationRequired: boolean
132
+
verificationChannel: VerificationChannel
133
+
}
134
+
135
+
export interface ConfirmSignupResult {
136
+
accessJwt: AccessToken
137
+
refreshJwt: RefreshToken
138
+
handle: Handle
139
+
did: Did
140
+
email?: EmailAddress
141
+
emailConfirmed?: boolean
142
+
preferredChannel?: VerificationChannel
143
+
preferredChannelVerified?: boolean
144
+
}
145
+
146
+
export interface ListAppPasswordsResponse {
147
+
passwords: AppPassword[]
148
+
}
149
+
150
+
export interface AccountInviteCodesResponse {
151
+
codes: InviteCodeInfo[]
152
+
}
153
+
154
+
export interface CreateInviteCodeResponse {
155
+
code: InviteCodeBrand
156
+
}
157
+
158
+
export interface ServerLinks {
159
+
privacyPolicy?: string
160
+
termsOfService?: string
161
+
}
162
+
163
+
export interface ServerDescription {
164
+
availableUserDomains: string[]
165
+
inviteCodeRequired: boolean
166
+
links?: ServerLinks
167
+
version?: string
168
+
availableCommsChannels?: VerificationChannel[]
169
+
selfHostedDidWebEnabled?: boolean
170
+
}
171
+
172
+
export interface RepoInfo {
173
+
did: Did
174
+
head: Cid
175
+
rev: string
176
+
}
177
+
178
+
export interface ListReposResponse {
179
+
repos: RepoInfo[]
180
+
cursor?: string
181
+
}
182
+
183
+
export interface NotificationPrefs {
184
+
preferredChannel: VerificationChannel
185
+
email: EmailAddress
186
+
discordId: string | null
187
+
discordVerified: boolean
188
+
telegramUsername: string | null
189
+
telegramVerified: boolean
190
+
signalNumber: string | null
191
+
signalVerified: boolean
192
+
}
193
+
194
+
export interface NotificationHistoryItem {
195
+
createdAt: ISODateString
196
+
channel: VerificationChannel
197
+
notificationType: string
198
+
status: string
199
+
subject: string | null
200
+
body: string
201
+
}
202
+
203
+
export interface NotificationHistoryResponse {
204
+
notifications: NotificationHistoryItem[]
205
+
}
206
+
207
+
export interface ServerStats {
208
+
userCount: number
209
+
repoCount: number
210
+
recordCount: number
211
+
blobStorageBytes: number
212
+
}
213
+
214
+
export interface ServerConfig {
215
+
serverName: string
216
+
primaryColor: string | null
217
+
primaryColorDark: string | null
218
+
secondaryColor: string | null
219
+
secondaryColorDark: string | null
220
+
logoCid: Cid | null
221
+
}
222
+
223
+
export interface BlobRef {
224
+
$type: 'blob'
225
+
ref: { $link: Cid }
226
+
mimeType: string
227
+
size: number
228
+
}
229
+
230
+
export interface UploadBlobResponse {
231
+
blob: BlobRef
232
+
}
233
+
234
+
export interface SessionInfo {
235
+
id: string
236
+
sessionType: SessionType
237
+
clientName: string | null
238
+
createdAt: ISODateString
239
+
expiresAt: ISODateString
240
+
isCurrent: boolean
241
+
}
242
+
243
+
export interface ListSessionsResponse {
244
+
sessions: SessionInfo[]
245
+
}
246
+
247
+
export interface RevokeAllSessionsResponse {
248
+
revokedCount: number
249
+
}
250
+
251
+
export interface AccountSearchResult {
252
+
did: Did
253
+
handle: Handle
254
+
email?: EmailAddress
255
+
indexedAt: ISODateString
256
+
emailConfirmedAt?: ISODateString
257
+
deactivatedAt?: ISODateString
258
+
}
259
+
260
+
export interface SearchAccountsResponse {
261
+
cursor?: string
262
+
accounts: AccountSearchResult[]
263
+
}
264
+
265
+
export interface AdminInviteCodeUse {
266
+
usedBy: Did
267
+
usedAt: ISODateString
268
+
}
269
+
270
+
export interface AdminInviteCode {
271
+
code: InviteCodeBrand
272
+
available: number
273
+
disabled: boolean
274
+
forAccount: Did
275
+
createdBy: Did
276
+
createdAt: ISODateString
277
+
uses: AdminInviteCodeUse[]
278
+
}
279
+
280
+
export interface GetInviteCodesResponse {
281
+
cursor?: string
282
+
codes: AdminInviteCode[]
283
+
}
284
+
285
+
export interface AccountInfo {
286
+
did: Did
287
+
handle: Handle
288
+
email?: EmailAddress
289
+
indexedAt: ISODateString
290
+
emailConfirmedAt?: ISODateString
291
+
invitesDisabled?: boolean
292
+
deactivatedAt?: ISODateString
293
+
}
294
+
295
+
export interface RepoDescription {
296
+
handle: Handle
297
+
did: Did
298
+
didDoc: DidDocument
299
+
collections: Nsid[]
300
+
handleIsCorrect: boolean
301
+
}
302
+
303
+
export interface RecordInfo {
304
+
uri: AtUri
305
+
cid: Cid
306
+
value: unknown
307
+
}
308
+
309
+
export interface ListRecordsResponse {
310
+
records: RecordInfo[]
311
+
cursor?: string
312
+
}
313
+
314
+
export interface RecordResponse {
315
+
uri: AtUri
316
+
cid: Cid
317
+
value: unknown
318
+
}
319
+
320
+
export interface CreateRecordResponse {
321
+
uri: AtUri
322
+
cid: Cid
323
+
}
324
+
325
+
export interface TotpStatus {
326
+
enabled: boolean
327
+
hasBackupCodes: boolean
328
+
}
329
+
330
+
export interface TotpSecret {
331
+
uri: string
332
+
qrBase64: string
333
+
}
334
+
335
+
export interface EnableTotpResponse {
336
+
success: boolean
337
+
backupCodes: string[]
338
+
}
339
+
340
+
export interface RegenerateBackupCodesResponse {
341
+
backupCodes: string[]
342
+
}
343
+
344
+
export interface PasskeyInfo {
345
+
id: string
346
+
credentialId: string
347
+
friendlyName: string | null
348
+
createdAt: ISODateString
349
+
lastUsed: ISODateString | null
350
+
}
351
+
352
+
export interface ListPasskeysResponse {
353
+
passkeys: PasskeyInfo[]
354
+
}
355
+
356
+
export interface StartPasskeyRegistrationResponse {
357
+
options: PublicKeyCredentialCreationOptions
358
+
}
359
+
360
+
export interface FinishPasskeyRegistrationResponse {
361
+
id: string
362
+
credentialId: string
363
+
}
364
+
365
+
export interface TrustedDevice {
366
+
id: string
367
+
userAgent: string | null
368
+
friendlyName: string | null
369
+
trustedAt: ISODateString | null
370
+
trustedUntil: ISODateString | null
371
+
lastSeenAt: ISODateString
372
+
}
373
+
374
+
export interface ListTrustedDevicesResponse {
375
+
devices: TrustedDevice[]
376
+
}
377
+
378
+
export interface ReauthStatus {
379
+
requiresReauth: boolean
380
+
lastReauthAt: ISODateString | null
381
+
availableMethods: ReauthMethod[]
382
+
}
383
+
384
+
export interface ReauthResponse {
385
+
success: boolean
386
+
reauthAt: ISODateString
387
+
}
388
+
389
+
export interface ReauthPasskeyStartResponse {
390
+
options: PublicKeyCredentialRequestOptions
391
+
}
392
+
393
+
export interface ReserveSigningKeyResponse {
394
+
signingKey: PublicKeyMultibase
395
+
}
396
+
397
+
export interface RecommendedDidCredentials {
398
+
rotationKeys?: PublicKeyMultibase[]
399
+
alsoKnownAs?: string[]
400
+
verificationMethods?: { atproto?: PublicKeyMultibase }
401
+
services?: { atproto_pds?: { type: string; endpoint: string } }
402
+
}
403
+
404
+
export interface PasskeyAccountCreateResponse {
405
+
did: Did
406
+
handle: Handle
407
+
setupToken: string
408
+
setupExpiresAt: ISODateString
409
+
}
410
+
411
+
export interface CompletePasskeySetupResponse {
412
+
did: Did
413
+
handle: Handle
414
+
appPassword: string
415
+
appPasswordName: string
416
+
}
417
+
418
+
export interface VerifyTokenResponse {
419
+
success: boolean
420
+
did: Did
421
+
purpose: string
422
+
channel: VerificationChannel
423
+
}
424
+
425
+
export interface BackupInfo {
426
+
id: string
427
+
repoRev: string
428
+
repoRootCid: Cid
429
+
blockCount: number
430
+
sizeBytes: number
431
+
createdAt: ISODateString
432
+
}
433
+
434
+
export interface ListBackupsResponse {
435
+
backups: BackupInfo[]
436
+
backupEnabled: boolean
437
+
}
438
+
439
+
export interface CreateBackupResponse {
440
+
id: string
441
+
repoRev: string
442
+
sizeBytes: number
443
+
blockCount: number
444
+
}
445
+
446
+
export interface SetBackupEnabledResponse {
447
+
enabled: boolean
448
+
}
449
+
450
+
export interface EmailUpdateResponse {
451
+
tokenRequired: boolean
452
+
}
453
+
454
+
export interface LegacyLoginPreference {
455
+
allowLegacyLogin: boolean
456
+
hasMfa: boolean
457
+
}
458
+
459
+
export interface UpdateLegacyLoginResponse {
460
+
allowLegacyLogin: boolean
461
+
}
462
+
463
+
export interface UpdateLocaleResponse {
464
+
preferredLocale: string
465
+
}
466
+
467
+
export interface PasswordStatus {
468
+
hasPassword: boolean
469
+
}
470
+
471
+
export interface SuccessResponse {
472
+
success: boolean
473
+
}
474
+
475
+
export interface CheckEmailVerifiedResponse {
476
+
verified: boolean
477
+
}
478
+
479
+
export interface VerifyMigrationEmailResponse {
480
+
success: boolean
481
+
did: Did
482
+
}
483
+
484
+
export interface ResendMigrationVerificationResponse {
485
+
sent: boolean
486
+
}
+188
frontend/src/lib/types/branded.ts
+188
frontend/src/lib/types/branded.ts
···
···
1
+
declare const __brand: unique symbol
2
+
3
+
type Brand<T, B extends string> = T & { readonly [__brand]: B }
4
+
5
+
export type Did = Brand<string, 'Did'>
6
+
export type DidPlc = Brand<Did, 'DidPlc'>
7
+
export type DidWeb = Brand<Did, 'DidWeb'>
8
+
9
+
export type Handle = Brand<string, 'Handle'>
10
+
export type AccessToken = Brand<string, 'AccessToken'>
11
+
export type RefreshToken = Brand<string, 'RefreshToken'>
12
+
export type ServiceToken = Brand<string, 'ServiceToken'>
13
+
export type SetupToken = Brand<string, 'SetupToken'>
14
+
15
+
export type Cid = Brand<string, 'Cid'>
16
+
export type Rkey = Brand<string, 'Rkey'>
17
+
export type AtUri = Brand<string, 'AtUri'>
18
+
export type Nsid = Brand<string, 'Nsid'>
19
+
20
+
export type ISODateString = Brand<string, 'ISODateString'>
21
+
export type EmailAddress = Brand<string, 'EmailAddress'>
22
+
export type InviteCode = Brand<string, 'InviteCode'>
23
+
24
+
export type PublicKeyMultibase = Brand<string, 'PublicKeyMultibase'>
25
+
export type DidKeyString = Brand<string, 'DidKeyString'>
26
+
27
+
const DID_PLC_REGEX = /^did:plc:[a-z2-7]{24}$/
28
+
const DID_WEB_REGEX = /^did:web:.+$/
29
+
const HANDLE_REGEX = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/
30
+
const AT_URI_REGEX = /^at:\/\/[^/]+\/[^/]+\/[^/]+$/
31
+
const CID_REGEX = /^[a-z2-7]{59}$|^baf[a-z2-7]+$/
32
+
const NSID_REGEX = /^[a-z]([a-z0-9-]*[a-z0-9])?(\.[a-z]([a-z0-9-]*[a-z0-9])?)+$/
33
+
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
34
+
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/
35
+
36
+
export function isDid(s: string): s is Did {
37
+
return s.startsWith('did:plc:') || s.startsWith('did:web:')
38
+
}
39
+
40
+
export function isDidPlc(s: string): s is DidPlc {
41
+
return DID_PLC_REGEX.test(s)
42
+
}
43
+
44
+
export function isDidWeb(s: string): s is DidWeb {
45
+
return DID_WEB_REGEX.test(s)
46
+
}
47
+
48
+
export function isHandle(s: string): s is Handle {
49
+
return HANDLE_REGEX.test(s) && s.length <= 253
50
+
}
51
+
52
+
export function isAtUri(s: string): s is AtUri {
53
+
return AT_URI_REGEX.test(s)
54
+
}
55
+
56
+
export function isCid(s: string): s is Cid {
57
+
return CID_REGEX.test(s)
58
+
}
59
+
60
+
export function isNsid(s: string): s is Nsid {
61
+
return NSID_REGEX.test(s)
62
+
}
63
+
64
+
export function isEmail(s: string): s is EmailAddress {
65
+
return EMAIL_REGEX.test(s)
66
+
}
67
+
68
+
export function isISODate(s: string): s is ISODateString {
69
+
return ISO_DATE_REGEX.test(s)
70
+
}
71
+
72
+
export function asDid(s: string): Did {
73
+
if (!isDid(s)) throw new TypeError(`Invalid DID: ${s}`)
74
+
return s
75
+
}
76
+
77
+
export function asDidPlc(s: string): DidPlc {
78
+
if (!isDidPlc(s)) throw new TypeError(`Invalid DID:PLC: ${s}`)
79
+
return s as DidPlc
80
+
}
81
+
82
+
export function asDidWeb(s: string): DidWeb {
83
+
if (!isDidWeb(s)) throw new TypeError(`Invalid DID:WEB: ${s}`)
84
+
return s as DidWeb
85
+
}
86
+
87
+
export function asHandle(s: string): Handle {
88
+
if (!isHandle(s)) throw new TypeError(`Invalid handle: ${s}`)
89
+
return s
90
+
}
91
+
92
+
export function asAtUri(s: string): AtUri {
93
+
if (!isAtUri(s)) throw new TypeError(`Invalid AT-URI: ${s}`)
94
+
return s
95
+
}
96
+
97
+
export function asCid(s: string): Cid {
98
+
if (!isCid(s)) throw new TypeError(`Invalid CID: ${s}`)
99
+
return s
100
+
}
101
+
102
+
export function asNsid(s: string): Nsid {
103
+
if (!isNsid(s)) throw new TypeError(`Invalid NSID: ${s}`)
104
+
return s
105
+
}
106
+
107
+
export function asEmail(s: string): EmailAddress {
108
+
if (!isEmail(s)) throw new TypeError(`Invalid email: ${s}`)
109
+
return s
110
+
}
111
+
112
+
export function asISODate(s: string): ISODateString {
113
+
if (!isISODate(s)) throw new TypeError(`Invalid ISO date: ${s}`)
114
+
return s
115
+
}
116
+
117
+
export function unsafeAsDid(s: string): Did {
118
+
return s as Did
119
+
}
120
+
121
+
export function unsafeAsHandle(s: string): Handle {
122
+
return s as Handle
123
+
}
124
+
125
+
export function unsafeAsAccessToken(s: string): AccessToken {
126
+
return s as AccessToken
127
+
}
128
+
129
+
export function unsafeAsRefreshToken(s: string): RefreshToken {
130
+
return s as RefreshToken
131
+
}
132
+
133
+
export function unsafeAsServiceToken(s: string): ServiceToken {
134
+
return s as ServiceToken
135
+
}
136
+
137
+
export function unsafeAsSetupToken(s: string): SetupToken {
138
+
return s as SetupToken
139
+
}
140
+
141
+
export function unsafeAsCid(s: string): Cid {
142
+
return s as Cid
143
+
}
144
+
145
+
export function unsafeAsRkey(s: string): Rkey {
146
+
return s as Rkey
147
+
}
148
+
149
+
export function unsafeAsAtUri(s: string): AtUri {
150
+
return s as AtUri
151
+
}
152
+
153
+
export function unsafeAsNsid(s: string): Nsid {
154
+
return s as Nsid
155
+
}
156
+
157
+
export function unsafeAsISODate(s: string): ISODateString {
158
+
return s as ISODateString
159
+
}
160
+
161
+
export function unsafeAsEmail(s: string): EmailAddress {
162
+
return s as EmailAddress
163
+
}
164
+
165
+
export function unsafeAsInviteCode(s: string): InviteCode {
166
+
return s as InviteCode
167
+
}
168
+
169
+
export function unsafeAsPublicKeyMultibase(s: string): PublicKeyMultibase {
170
+
return s as PublicKeyMultibase
171
+
}
172
+
173
+
export function unsafeAsDidKey(s: string): DidKeyString {
174
+
return s as DidKeyString
175
+
}
176
+
177
+
export function parseAtUri(uri: AtUri): { repo: Did; collection: Nsid; rkey: Rkey } {
178
+
const parts = uri.replace('at://', '').split('/')
179
+
return {
180
+
repo: unsafeAsDid(parts[0]),
181
+
collection: unsafeAsNsid(parts[1]),
182
+
rkey: unsafeAsRkey(parts[2]),
183
+
}
184
+
}
185
+
186
+
export function makeAtUri(repo: Did, collection: Nsid, rkey: Rkey): AtUri {
187
+
return `at://${repo}/${collection}/${rkey}` as AtUri
188
+
}
+49
frontend/src/lib/types/exhaustive.ts
+49
frontend/src/lib/types/exhaustive.ts
···
···
1
+
export function assertNever(x: never, message?: string): never {
2
+
throw new Error(message ?? `Unexpected value: ${JSON.stringify(x)}`)
3
+
}
4
+
5
+
export function exhaustive<T extends string | number | symbol>(
6
+
value: T,
7
+
handlers: Record<T, () => void>
8
+
): void {
9
+
const handler = handlers[value]
10
+
if (handler) {
11
+
handler()
12
+
} else {
13
+
assertNever(value as never, `Unhandled case: ${String(value)}`)
14
+
}
15
+
}
16
+
17
+
export function exhaustiveMap<T extends string | number | symbol, R>(
18
+
value: T,
19
+
handlers: Record<T, () => R>
20
+
): R {
21
+
const handler = handlers[value]
22
+
if (handler) {
23
+
return handler()
24
+
}
25
+
return assertNever(value as never, `Unhandled case: ${String(value)}`)
26
+
}
27
+
28
+
export async function exhaustiveAsync<T extends string | number | symbol>(
29
+
value: T,
30
+
handlers: Record<T, () => Promise<void>>
31
+
): Promise<void> {
32
+
const handler = handlers[value]
33
+
if (handler) {
34
+
await handler()
35
+
} else {
36
+
assertNever(value as never, `Unhandled case: ${String(value)}`)
37
+
}
38
+
}
39
+
40
+
export async function exhaustiveMapAsync<T extends string | number | symbol, R>(
41
+
value: T,
42
+
handlers: Record<T, () => Promise<R>>
43
+
): Promise<R> {
44
+
const handler = handlers[value]
45
+
if (handler) {
46
+
return handler()
47
+
}
48
+
return assertNever(value as never, `Unhandled case: ${String(value)}`)
49
+
}
+5
frontend/src/lib/types/index.ts
+5
frontend/src/lib/types/index.ts
+94
frontend/src/lib/types/result.ts
+94
frontend/src/lib/types/result.ts
···
···
1
+
export type Result<T, E = Error> =
2
+
| { ok: true; value: T }
3
+
| { ok: false; error: E }
4
+
5
+
export function ok<T>(value: T): Result<T, never> {
6
+
return { ok: true, value }
7
+
}
8
+
9
+
export function err<E>(error: E): Result<never, E> {
10
+
return { ok: false, error }
11
+
}
12
+
13
+
export function isOk<T, E>(result: Result<T, E>): result is { ok: true; value: T } {
14
+
return result.ok
15
+
}
16
+
17
+
export function isErr<T, E>(result: Result<T, E>): result is { ok: false; error: E } {
18
+
return !result.ok
19
+
}
20
+
21
+
export function map<T, U, E>(result: Result<T, E>, fn: (t: T) => U): Result<U, E> {
22
+
return result.ok ? ok(fn(result.value)) : result
23
+
}
24
+
25
+
export function mapErr<T, E, F>(result: Result<T, E>, fn: (e: E) => F): Result<T, F> {
26
+
return result.ok ? result : err(fn(result.error))
27
+
}
28
+
29
+
export function flatMap<T, U, E>(result: Result<T, E>, fn: (t: T) => Result<U, E>): Result<U, E> {
30
+
return result.ok ? fn(result.value) : result
31
+
}
32
+
33
+
export function unwrap<T, E>(result: Result<T, E>): T {
34
+
if (result.ok) return result.value
35
+
throw result.error instanceof Error ? result.error : new Error(String(result.error))
36
+
}
37
+
38
+
export function unwrapOr<T, E>(result: Result<T, E>, defaultValue: T): T {
39
+
return result.ok ? result.value : defaultValue
40
+
}
41
+
42
+
export function unwrapOrElse<T, E>(result: Result<T, E>, fn: (e: E) => T): T {
43
+
return result.ok ? result.value : fn(result.error)
44
+
}
45
+
46
+
export function match<T, E, U>(
47
+
result: Result<T, E>,
48
+
handlers: { ok: (t: T) => U; err: (e: E) => U }
49
+
): U {
50
+
return result.ok ? handlers.ok(result.value) : handlers.err(result.error)
51
+
}
52
+
53
+
export async function tryAsync<T>(fn: () => Promise<T>): Promise<Result<T, Error>> {
54
+
try {
55
+
return ok(await fn())
56
+
} catch (e) {
57
+
return err(e instanceof Error ? e : new Error(String(e)))
58
+
}
59
+
}
60
+
61
+
export async function tryAsyncWith<T, E>(
62
+
fn: () => Promise<T>,
63
+
mapError: (e: unknown) => E
64
+
): Promise<Result<T, E>> {
65
+
try {
66
+
return ok(await fn())
67
+
} catch (e) {
68
+
return err(mapError(e))
69
+
}
70
+
}
71
+
72
+
export function fromNullable<T>(value: T | null | undefined): Result<T, null> {
73
+
return value != null ? ok(value) : err(null)
74
+
}
75
+
76
+
export function toNullable<T, E>(result: Result<T, E>): T | null {
77
+
return result.ok ? result.value : null
78
+
}
79
+
80
+
export function collect<T, E>(results: Result<T, E>[]): Result<T[], E> {
81
+
const values: T[] = []
82
+
for (const result of results) {
83
+
if (!result.ok) return result
84
+
values.push(result.value)
85
+
}
86
+
return ok(values)
87
+
}
88
+
89
+
export async function collectAsync<T, E>(
90
+
results: Promise<Result<T, E>>[]
91
+
): Promise<Result<T[], E>> {
92
+
const settled = await Promise.all(results)
93
+
return collect(settled)
94
+
}
+83
frontend/src/lib/types/routes.ts
+83
frontend/src/lib/types/routes.ts
···
···
1
+
export const routes = {
2
+
login: '/login',
3
+
register: '/register',
4
+
registerPasskey: '/register-passkey',
5
+
dashboard: '/dashboard',
6
+
settings: '/settings',
7
+
security: '/security',
8
+
sessions: '/sessions',
9
+
appPasswords: '/app-passwords',
10
+
trustedDevices: '/trusted-devices',
11
+
inviteCodes: '/invite-codes',
12
+
comms: '/comms',
13
+
repo: '/repo',
14
+
controllers: '/controllers',
15
+
delegationAudit: '/delegation-audit',
16
+
actAs: '/act-as',
17
+
didDocument: '/did-document',
18
+
migrate: '/migrate',
19
+
admin: '/admin',
20
+
verify: '/verify',
21
+
resetPassword: '/reset-password',
22
+
recoverPasskey: '/recover-passkey',
23
+
requestPasskeyRecovery: '/request-passkey-recovery',
24
+
oauthLogin: '/oauth/login',
25
+
oauthConsent: '/oauth/consent',
26
+
oauthAccounts: '/oauth/accounts',
27
+
oauth2fa: '/oauth/2fa',
28
+
oauthTotp: '/oauth/totp',
29
+
oauthPasskey: '/oauth/passkey',
30
+
oauthDelegation: '/oauth/delegation',
31
+
oauthError: '/oauth/error',
32
+
} as const
33
+
34
+
export type Route = (typeof routes)[keyof typeof routes]
35
+
36
+
export type RouteKey = keyof typeof routes
37
+
38
+
export function isValidRoute(path: string): path is Route {
39
+
return Object.values(routes).includes(path as Route)
40
+
}
41
+
42
+
export interface RouteParams {
43
+
[routes.verify]: { token?: string; email?: string }
44
+
[routes.resetPassword]: { token?: string }
45
+
[routes.recoverPasskey]: { token?: string; did?: string }
46
+
[routes.oauthLogin]: { request_uri?: string; error?: string }
47
+
[routes.oauthConsent]: { request_uri?: string; client_id?: string }
48
+
[routes.oauthAccounts]: { request_uri?: string }
49
+
[routes.oauth2fa]: { request_uri?: string; channel?: string }
50
+
[routes.oauthTotp]: { request_uri?: string }
51
+
[routes.oauthPasskey]: { request_uri?: string }
52
+
[routes.oauthDelegation]: { request_uri?: string; delegated_did?: string }
53
+
[routes.oauthError]: { error?: string; error_description?: string }
54
+
[routes.migrate]: { code?: string; state?: string }
55
+
}
56
+
57
+
export type RoutesWithParams = keyof RouteParams
58
+
59
+
export function buildUrl<R extends Route>(
60
+
route: R,
61
+
params?: R extends RoutesWithParams ? RouteParams[R] : never
62
+
): string {
63
+
if (!params) return route
64
+
const searchParams = new URLSearchParams()
65
+
for (const [key, value] of Object.entries(params)) {
66
+
if (value != null) {
67
+
searchParams.set(key, String(value))
68
+
}
69
+
}
70
+
const queryString = searchParams.toString()
71
+
return queryString ? `${route}?${queryString}` : route
72
+
}
73
+
74
+
export function parseRouteParams<R extends RoutesWithParams>(
75
+
route: R
76
+
): RouteParams[R] {
77
+
const params = new URLSearchParams(globalThis.location.search)
78
+
const result: Record<string, string> = {}
79
+
for (const [key, value] of params.entries()) {
80
+
result[key] = value
81
+
}
82
+
return result as RouteParams[R]
83
+
}
+332
frontend/src/lib/types/schemas.ts
+332
frontend/src/lib/types/schemas.ts
···
···
1
+
import { z } from 'zod'
2
+
import type {
3
+
Did,
4
+
Handle,
5
+
AccessToken,
6
+
RefreshToken,
7
+
Cid,
8
+
Nsid,
9
+
AtUri,
10
+
Rkey,
11
+
ISODateString,
12
+
EmailAddress,
13
+
InviteCode,
14
+
PublicKeyMultibase,
15
+
} from './branded'
16
+
import {
17
+
unsafeAsDid,
18
+
unsafeAsHandle,
19
+
unsafeAsAccessToken,
20
+
unsafeAsRefreshToken,
21
+
unsafeAsCid,
22
+
unsafeAsNsid,
23
+
unsafeAsAtUri,
24
+
unsafeAsRkey,
25
+
unsafeAsISODate,
26
+
unsafeAsEmail,
27
+
unsafeAsInviteCode,
28
+
unsafeAsPublicKeyMultibase,
29
+
} from './branded'
30
+
31
+
const did = z.string().transform((s) => unsafeAsDid(s))
32
+
const handle = z.string().transform((s) => unsafeAsHandle(s))
33
+
const accessToken = z.string().transform((s) => unsafeAsAccessToken(s))
34
+
const refreshToken = z.string().transform((s) => unsafeAsRefreshToken(s))
35
+
const cid = z.string().transform((s) => unsafeAsCid(s))
36
+
const nsid = z.string().transform((s) => unsafeAsNsid(s))
37
+
const atUri = z.string().transform((s) => unsafeAsAtUri(s))
38
+
const rkey = z.string().transform((s) => unsafeAsRkey(s))
39
+
const isoDate = z.string().transform((s) => unsafeAsISODate(s))
40
+
const email = z.string().transform((s) => unsafeAsEmail(s))
41
+
const inviteCode = z.string().transform((s) => unsafeAsInviteCode(s))
42
+
const publicKeyMultibase = z.string().transform((s) => unsafeAsPublicKeyMultibase(s))
43
+
44
+
export const verificationChannel = z.enum(['email', 'discord', 'telegram', 'signal'])
45
+
export const didType = z.enum(['plc', 'web', 'web-external'])
46
+
export const accountStatus = z.enum(['active', 'deactivated', 'migrated', 'suspended', 'deleted'])
47
+
export const sessionType = z.enum(['oauth', 'legacy', 'app_password'])
48
+
export const reauthMethod = z.enum(['password', 'totp', 'passkey'])
49
+
50
+
export const sessionSchema = z.object({
51
+
did: did,
52
+
handle: handle,
53
+
email: email.optional(),
54
+
emailConfirmed: z.boolean().optional(),
55
+
preferredChannel: verificationChannel.optional(),
56
+
preferredChannelVerified: z.boolean().optional(),
57
+
isAdmin: z.boolean().optional(),
58
+
active: z.boolean().optional(),
59
+
status: accountStatus.optional(),
60
+
migratedToPds: z.string().optional(),
61
+
migratedAt: isoDate.optional(),
62
+
accessJwt: accessToken,
63
+
refreshJwt: refreshToken,
64
+
})
65
+
66
+
export const serverLinksSchema = z.object({
67
+
privacyPolicy: z.string().optional(),
68
+
termsOfService: z.string().optional(),
69
+
})
70
+
71
+
export const serverDescriptionSchema = z.object({
72
+
availableUserDomains: z.array(z.string()),
73
+
inviteCodeRequired: z.boolean(),
74
+
links: serverLinksSchema.optional(),
75
+
version: z.string().optional(),
76
+
availableCommsChannels: z.array(verificationChannel).optional(),
77
+
selfHostedDidWebEnabled: z.boolean().optional(),
78
+
})
79
+
80
+
export const appPasswordSchema = z.object({
81
+
name: z.string(),
82
+
createdAt: isoDate,
83
+
scopes: z.string().optional(),
84
+
createdByController: z.string().optional(),
85
+
})
86
+
87
+
export const createdAppPasswordSchema = z.object({
88
+
name: z.string(),
89
+
password: z.string(),
90
+
createdAt: isoDate,
91
+
scopes: z.string().optional(),
92
+
})
93
+
94
+
export const inviteCodeUseSchema = z.object({
95
+
usedBy: did,
96
+
usedByHandle: handle.optional(),
97
+
usedAt: isoDate,
98
+
})
99
+
100
+
export const inviteCodeInfoSchema = z.object({
101
+
code: inviteCode,
102
+
available: z.number(),
103
+
disabled: z.boolean(),
104
+
forAccount: did,
105
+
createdBy: did,
106
+
createdAt: isoDate,
107
+
uses: z.array(inviteCodeUseSchema),
108
+
})
109
+
110
+
export const sessionInfoSchema = z.object({
111
+
id: z.string(),
112
+
sessionType: sessionType,
113
+
clientName: z.string().nullable(),
114
+
createdAt: isoDate,
115
+
expiresAt: isoDate,
116
+
isCurrent: z.boolean(),
117
+
})
118
+
119
+
export const listSessionsResponseSchema = z.object({
120
+
sessions: z.array(sessionInfoSchema),
121
+
})
122
+
123
+
export const totpStatusSchema = z.object({
124
+
enabled: z.boolean(),
125
+
hasBackupCodes: z.boolean(),
126
+
})
127
+
128
+
export const totpSecretSchema = z.object({
129
+
uri: z.string(),
130
+
qrBase64: z.string(),
131
+
})
132
+
133
+
export const enableTotpResponseSchema = z.object({
134
+
success: z.boolean(),
135
+
backupCodes: z.array(z.string()),
136
+
})
137
+
138
+
export const passkeyInfoSchema = z.object({
139
+
id: z.string(),
140
+
credentialId: z.string(),
141
+
friendlyName: z.string().nullable(),
142
+
createdAt: isoDate,
143
+
lastUsed: isoDate.nullable(),
144
+
})
145
+
146
+
export const listPasskeysResponseSchema = z.object({
147
+
passkeys: z.array(passkeyInfoSchema),
148
+
})
149
+
150
+
export const trustedDeviceSchema = z.object({
151
+
id: z.string(),
152
+
userAgent: z.string().nullable(),
153
+
friendlyName: z.string().nullable(),
154
+
trustedAt: isoDate.nullable(),
155
+
trustedUntil: isoDate.nullable(),
156
+
lastSeenAt: isoDate,
157
+
})
158
+
159
+
export const listTrustedDevicesResponseSchema = z.object({
160
+
devices: z.array(trustedDeviceSchema),
161
+
})
162
+
163
+
export const reauthStatusSchema = z.object({
164
+
requiresReauth: z.boolean(),
165
+
lastReauthAt: isoDate.nullable(),
166
+
availableMethods: z.array(reauthMethod),
167
+
})
168
+
169
+
export const reauthResponseSchema = z.object({
170
+
success: z.boolean(),
171
+
reauthAt: isoDate,
172
+
})
173
+
174
+
export const notificationPrefsSchema = z.object({
175
+
preferredChannel: verificationChannel,
176
+
email: email,
177
+
discordId: z.string().nullable(),
178
+
discordVerified: z.boolean(),
179
+
telegramUsername: z.string().nullable(),
180
+
telegramVerified: z.boolean(),
181
+
signalNumber: z.string().nullable(),
182
+
signalVerified: z.boolean(),
183
+
})
184
+
185
+
export const verificationMethodSchema = z.object({
186
+
id: z.string(),
187
+
type: z.string(),
188
+
controller: z.string(),
189
+
publicKeyMultibase: publicKeyMultibase,
190
+
})
191
+
192
+
export const serviceEndpointSchema = z.object({
193
+
id: z.string(),
194
+
type: z.string(),
195
+
serviceEndpoint: z.string(),
196
+
})
197
+
198
+
export const didDocumentSchema = z.object({
199
+
'@context': z.array(z.string()),
200
+
id: did,
201
+
alsoKnownAs: z.array(z.string()),
202
+
verificationMethod: z.array(verificationMethodSchema),
203
+
service: z.array(serviceEndpointSchema),
204
+
})
205
+
206
+
export const repoDescriptionSchema = z.object({
207
+
handle: handle,
208
+
did: did,
209
+
didDoc: didDocumentSchema,
210
+
collections: z.array(nsid),
211
+
handleIsCorrect: z.boolean(),
212
+
})
213
+
214
+
export const recordInfoSchema = z.object({
215
+
uri: atUri,
216
+
cid: cid,
217
+
value: z.unknown(),
218
+
})
219
+
220
+
export const listRecordsResponseSchema = z.object({
221
+
records: z.array(recordInfoSchema),
222
+
cursor: z.string().optional(),
223
+
})
224
+
225
+
export const recordResponseSchema = z.object({
226
+
uri: atUri,
227
+
cid: cid,
228
+
value: z.unknown(),
229
+
})
230
+
231
+
export const createRecordResponseSchema = z.object({
232
+
uri: atUri,
233
+
cid: cid,
234
+
})
235
+
236
+
export const serverStatsSchema = z.object({
237
+
userCount: z.number(),
238
+
repoCount: z.number(),
239
+
recordCount: z.number(),
240
+
blobStorageBytes: z.number(),
241
+
})
242
+
243
+
export const serverConfigSchema = z.object({
244
+
serverName: z.string(),
245
+
primaryColor: z.string().nullable(),
246
+
primaryColorDark: z.string().nullable(),
247
+
secondaryColor: z.string().nullable(),
248
+
secondaryColorDark: z.string().nullable(),
249
+
logoCid: cid.nullable(),
250
+
})
251
+
252
+
export const passwordStatusSchema = z.object({
253
+
hasPassword: z.boolean(),
254
+
})
255
+
256
+
export const successResponseSchema = z.object({
257
+
success: z.boolean(),
258
+
})
259
+
260
+
export const legacyLoginPreferenceSchema = z.object({
261
+
allowLegacyLogin: z.boolean(),
262
+
hasMfa: z.boolean(),
263
+
})
264
+
265
+
export const accountInfoSchema = z.object({
266
+
did: did,
267
+
handle: handle,
268
+
email: email.optional(),
269
+
indexedAt: isoDate,
270
+
emailConfirmedAt: isoDate.optional(),
271
+
invitesDisabled: z.boolean().optional(),
272
+
deactivatedAt: isoDate.optional(),
273
+
})
274
+
275
+
export const searchAccountsResponseSchema = z.object({
276
+
cursor: z.string().optional(),
277
+
accounts: z.array(accountInfoSchema),
278
+
})
279
+
280
+
export const backupInfoSchema = z.object({
281
+
id: z.string(),
282
+
repoRev: z.string(),
283
+
repoRootCid: cid,
284
+
blockCount: z.number(),
285
+
sizeBytes: z.number(),
286
+
createdAt: isoDate,
287
+
})
288
+
289
+
export const listBackupsResponseSchema = z.object({
290
+
backups: z.array(backupInfoSchema),
291
+
backupEnabled: z.boolean(),
292
+
})
293
+
294
+
export const createBackupResponseSchema = z.object({
295
+
id: z.string(),
296
+
repoRev: z.string(),
297
+
sizeBytes: z.number(),
298
+
blockCount: z.number(),
299
+
})
300
+
301
+
export type ValidatedSession = z.infer<typeof sessionSchema>
302
+
export type ValidatedServerDescription = z.infer<typeof serverDescriptionSchema>
303
+
export type ValidatedAppPassword = z.infer<typeof appPasswordSchema>
304
+
export type ValidatedCreatedAppPassword = z.infer<typeof createdAppPasswordSchema>
305
+
export type ValidatedInviteCodeInfo = z.infer<typeof inviteCodeInfoSchema>
306
+
export type ValidatedSessionInfo = z.infer<typeof sessionInfoSchema>
307
+
export type ValidatedListSessionsResponse = z.infer<typeof listSessionsResponseSchema>
308
+
export type ValidatedTotpStatus = z.infer<typeof totpStatusSchema>
309
+
export type ValidatedTotpSecret = z.infer<typeof totpSecretSchema>
310
+
export type ValidatedEnableTotpResponse = z.infer<typeof enableTotpResponseSchema>
311
+
export type ValidatedPasskeyInfo = z.infer<typeof passkeyInfoSchema>
312
+
export type ValidatedListPasskeysResponse = z.infer<typeof listPasskeysResponseSchema>
313
+
export type ValidatedTrustedDevice = z.infer<typeof trustedDeviceSchema>
314
+
export type ValidatedListTrustedDevicesResponse = z.infer<typeof listTrustedDevicesResponseSchema>
315
+
export type ValidatedReauthStatus = z.infer<typeof reauthStatusSchema>
316
+
export type ValidatedReauthResponse = z.infer<typeof reauthResponseSchema>
317
+
export type ValidatedNotificationPrefs = z.infer<typeof notificationPrefsSchema>
318
+
export type ValidatedDidDocument = z.infer<typeof didDocumentSchema>
319
+
export type ValidatedRepoDescription = z.infer<typeof repoDescriptionSchema>
320
+
export type ValidatedListRecordsResponse = z.infer<typeof listRecordsResponseSchema>
321
+
export type ValidatedRecordResponse = z.infer<typeof recordResponseSchema>
322
+
export type ValidatedCreateRecordResponse = z.infer<typeof createRecordResponseSchema>
323
+
export type ValidatedServerStats = z.infer<typeof serverStatsSchema>
324
+
export type ValidatedServerConfig = z.infer<typeof serverConfigSchema>
325
+
export type ValidatedPasswordStatus = z.infer<typeof passwordStatusSchema>
326
+
export type ValidatedSuccessResponse = z.infer<typeof successResponseSchema>
327
+
export type ValidatedLegacyLoginPreference = z.infer<typeof legacyLoginPreferenceSchema>
328
+
export type ValidatedAccountInfo = z.infer<typeof accountInfoSchema>
329
+
export type ValidatedSearchAccountsResponse = z.infer<typeof searchAccountsResponseSchema>
330
+
export type ValidatedBackupInfo = z.infer<typeof backupInfoSchema>
331
+
export type ValidatedListBackupsResponse = z.infer<typeof listBackupsResponseSchema>
332
+
export type ValidatedCreateBackupResponse = z.infer<typeof createBackupResponseSchema>
+190
frontend/src/lib/utils/array.ts
+190
frontend/src/lib/utils/array.ts
···
···
1
+
import type { Option } from './option'
2
+
3
+
export function first<T>(arr: readonly T[]): Option<T> {
4
+
return arr[0] ?? null
5
+
}
6
+
7
+
export function last<T>(arr: readonly T[]): Option<T> {
8
+
return arr[arr.length - 1] ?? null
9
+
}
10
+
11
+
export function at<T>(arr: readonly T[], index: number): Option<T> {
12
+
if (index < 0) index = arr.length + index
13
+
return arr[index] ?? null
14
+
}
15
+
16
+
export function find<T>(arr: readonly T[], predicate: (t: T) => boolean): Option<T> {
17
+
return arr.find(predicate) ?? null
18
+
}
19
+
20
+
export function findMap<T, U>(arr: readonly T[], fn: (t: T) => Option<U>): Option<U> {
21
+
for (const item of arr) {
22
+
const result = fn(item)
23
+
if (result != null) return result
24
+
}
25
+
return null
26
+
}
27
+
28
+
export function findIndex<T>(arr: readonly T[], predicate: (t: T) => boolean): Option<number> {
29
+
const index = arr.findIndex(predicate)
30
+
return index >= 0 ? index : null
31
+
}
32
+
33
+
export function partition<T>(
34
+
arr: readonly T[],
35
+
predicate: (t: T) => boolean
36
+
): [T[], T[]] {
37
+
const pass: T[] = []
38
+
const fail: T[] = []
39
+
for (const item of arr) {
40
+
if (predicate(item)) {
41
+
pass.push(item)
42
+
} else {
43
+
fail.push(item)
44
+
}
45
+
}
46
+
return [pass, fail]
47
+
}
48
+
49
+
export function groupBy<T, K extends string | number>(
50
+
arr: readonly T[],
51
+
keyFn: (t: T) => K
52
+
): Record<K, T[]> {
53
+
const result = {} as Record<K, T[]>
54
+
for (const item of arr) {
55
+
const key = keyFn(item)
56
+
if (!result[key]) {
57
+
result[key] = []
58
+
}
59
+
result[key].push(item)
60
+
}
61
+
return result
62
+
}
63
+
64
+
export function unique<T>(arr: readonly T[]): T[] {
65
+
return [...new Set(arr)]
66
+
}
67
+
68
+
export function uniqueBy<T, K>(arr: readonly T[], keyFn: (t: T) => K): T[] {
69
+
const seen = new Set<K>()
70
+
const result: T[] = []
71
+
for (const item of arr) {
72
+
const key = keyFn(item)
73
+
if (!seen.has(key)) {
74
+
seen.add(key)
75
+
result.push(item)
76
+
}
77
+
}
78
+
return result
79
+
}
80
+
81
+
export function sortBy<T>(arr: readonly T[], keyFn: (t: T) => number | string): T[] {
82
+
return [...arr].sort((a, b) => {
83
+
const ka = keyFn(a)
84
+
const kb = keyFn(b)
85
+
if (ka < kb) return -1
86
+
if (ka > kb) return 1
87
+
return 0
88
+
})
89
+
}
90
+
91
+
export function sortByDesc<T>(arr: readonly T[], keyFn: (t: T) => number | string): T[] {
92
+
return [...arr].sort((a, b) => {
93
+
const ka = keyFn(a)
94
+
const kb = keyFn(b)
95
+
if (ka > kb) return -1
96
+
if (ka < kb) return 1
97
+
return 0
98
+
})
99
+
}
100
+
101
+
export function chunk<T>(arr: readonly T[], size: number): T[][] {
102
+
const result: T[][] = []
103
+
for (let i = 0; i < arr.length; i += size) {
104
+
result.push(arr.slice(i, i + size))
105
+
}
106
+
return result
107
+
}
108
+
109
+
export function zip<T, U>(a: readonly T[], b: readonly U[]): [T, U][] {
110
+
const length = Math.min(a.length, b.length)
111
+
const result: [T, U][] = []
112
+
for (let i = 0; i < length; i++) {
113
+
result.push([a[i], b[i]])
114
+
}
115
+
return result
116
+
}
117
+
118
+
export function zipWith<T, U, R>(
119
+
a: readonly T[],
120
+
b: readonly U[],
121
+
fn: (t: T, u: U) => R
122
+
): R[] {
123
+
const length = Math.min(a.length, b.length)
124
+
const result: R[] = []
125
+
for (let i = 0; i < length; i++) {
126
+
result.push(fn(a[i], b[i]))
127
+
}
128
+
return result
129
+
}
130
+
131
+
export function intersperse<T>(arr: readonly T[], separator: T): T[] {
132
+
if (arr.length <= 1) return [...arr]
133
+
const result: T[] = [arr[0]]
134
+
for (let i = 1; i < arr.length; i++) {
135
+
result.push(separator, arr[i])
136
+
}
137
+
return result
138
+
}
139
+
140
+
export function range(start: number, end: number): number[] {
141
+
const result: number[] = []
142
+
for (let i = start; i < end; i++) {
143
+
result.push(i)
144
+
}
145
+
return result
146
+
}
147
+
148
+
export function isEmpty<T>(arr: readonly T[]): boolean {
149
+
return arr.length === 0
150
+
}
151
+
152
+
export function isNonEmpty<T>(arr: readonly T[]): arr is [T, ...T[]] {
153
+
return arr.length > 0
154
+
}
155
+
156
+
export function sum(arr: readonly number[]): number {
157
+
return arr.reduce((acc, n) => acc + n, 0)
158
+
}
159
+
160
+
export function sumBy<T>(arr: readonly T[], fn: (t: T) => number): number {
161
+
return arr.reduce((acc, t) => acc + fn(t), 0)
162
+
}
163
+
164
+
export function maxBy<T>(arr: readonly T[], fn: (t: T) => number): Option<T> {
165
+
if (arr.length === 0) return null
166
+
let max = arr[0]
167
+
let maxValue = fn(max)
168
+
for (let i = 1; i < arr.length; i++) {
169
+
const value = fn(arr[i])
170
+
if (value > maxValue) {
171
+
max = arr[i]
172
+
maxValue = value
173
+
}
174
+
}
175
+
return max
176
+
}
177
+
178
+
export function minBy<T>(arr: readonly T[], fn: (t: T) => number): Option<T> {
179
+
if (arr.length === 0) return null
180
+
let min = arr[0]
181
+
let minValue = fn(min)
182
+
for (let i = 1; i < arr.length; i++) {
183
+
const value = fn(arr[i])
184
+
if (value < minValue) {
185
+
min = arr[i]
186
+
minValue = value
187
+
}
188
+
}
189
+
return min
190
+
}
+246
frontend/src/lib/utils/async.ts
+246
frontend/src/lib/utils/async.ts
···
···
1
+
import { ok, err, type Result } from '../types/result'
2
+
3
+
export function debounce<T extends (...args: Parameters<T>) => void>(
4
+
fn: T,
5
+
ms: number
6
+
): T & { cancel: () => void } {
7
+
let timeoutId: ReturnType<typeof setTimeout> | null = null
8
+
9
+
const debounced = ((...args: Parameters<T>) => {
10
+
if (timeoutId) clearTimeout(timeoutId)
11
+
timeoutId = setTimeout(() => {
12
+
fn(...args)
13
+
timeoutId = null
14
+
}, ms)
15
+
}) as T & { cancel: () => void }
16
+
17
+
debounced.cancel = () => {
18
+
if (timeoutId) {
19
+
clearTimeout(timeoutId)
20
+
timeoutId = null
21
+
}
22
+
}
23
+
24
+
return debounced
25
+
}
26
+
27
+
export function throttle<T extends (...args: Parameters<T>) => void>(
28
+
fn: T,
29
+
ms: number
30
+
): T {
31
+
let lastCall = 0
32
+
let timeoutId: ReturnType<typeof setTimeout> | null = null
33
+
34
+
return ((...args: Parameters<T>) => {
35
+
const now = Date.now()
36
+
const remaining = ms - (now - lastCall)
37
+
38
+
if (remaining <= 0) {
39
+
if (timeoutId) {
40
+
clearTimeout(timeoutId)
41
+
timeoutId = null
42
+
}
43
+
lastCall = now
44
+
fn(...args)
45
+
} else if (!timeoutId) {
46
+
timeoutId = setTimeout(() => {
47
+
lastCall = Date.now()
48
+
timeoutId = null
49
+
fn(...args)
50
+
}, remaining)
51
+
}
52
+
}) as T
53
+
}
54
+
55
+
export function sleep(ms: number): Promise<void> {
56
+
return new Promise((resolve) => setTimeout(resolve, ms))
57
+
}
58
+
59
+
export async function retry<T>(
60
+
fn: () => Promise<T>,
61
+
options: {
62
+
attempts?: number
63
+
delay?: number
64
+
backoff?: number
65
+
shouldRetry?: (error: unknown, attempt: number) => boolean
66
+
} = {}
67
+
): Promise<T> {
68
+
const {
69
+
attempts = 3,
70
+
delay = 1000,
71
+
backoff = 2,
72
+
shouldRetry = () => true,
73
+
} = options
74
+
75
+
let lastError: unknown
76
+
let currentDelay = delay
77
+
78
+
for (let attempt = 1; attempt <= attempts; attempt++) {
79
+
try {
80
+
return await fn()
81
+
} catch (error) {
82
+
lastError = error
83
+
if (attempt === attempts || !shouldRetry(error, attempt)) {
84
+
throw error
85
+
}
86
+
await sleep(currentDelay)
87
+
currentDelay *= backoff
88
+
}
89
+
}
90
+
91
+
throw lastError
92
+
}
93
+
94
+
export async function retryResult<T, E>(
95
+
fn: () => Promise<Result<T, E>>,
96
+
options: {
97
+
attempts?: number
98
+
delay?: number
99
+
backoff?: number
100
+
shouldRetry?: (error: E, attempt: number) => boolean
101
+
} = {}
102
+
): Promise<Result<T, E>> {
103
+
const {
104
+
attempts = 3,
105
+
delay = 1000,
106
+
backoff = 2,
107
+
shouldRetry = () => true,
108
+
} = options
109
+
110
+
let lastResult: Result<T, E> | null = null
111
+
let currentDelay = delay
112
+
113
+
for (let attempt = 1; attempt <= attempts; attempt++) {
114
+
const result = await fn()
115
+
lastResult = result
116
+
117
+
if (result.ok) {
118
+
return result
119
+
}
120
+
121
+
if (attempt === attempts || !shouldRetry(result.error, attempt)) {
122
+
return result
123
+
}
124
+
125
+
await sleep(currentDelay)
126
+
currentDelay *= backoff
127
+
}
128
+
129
+
return lastResult!
130
+
}
131
+
132
+
export function timeout<T>(promise: Promise<T>, ms: number): Promise<T> {
133
+
return new Promise((resolve, reject) => {
134
+
const timeoutId = setTimeout(() => {
135
+
reject(new Error(`Timeout after ${ms}ms`))
136
+
}, ms)
137
+
138
+
promise
139
+
.then((value) => {
140
+
clearTimeout(timeoutId)
141
+
resolve(value)
142
+
})
143
+
.catch((error) => {
144
+
clearTimeout(timeoutId)
145
+
reject(error)
146
+
})
147
+
})
148
+
}
149
+
150
+
export async function timeoutResult<T>(
151
+
promise: Promise<Result<T, Error>>,
152
+
ms: number
153
+
): Promise<Result<T, Error>> {
154
+
try {
155
+
return await timeout(promise, ms)
156
+
} catch (e) {
157
+
return err(e instanceof Error ? e : new Error(String(e)))
158
+
}
159
+
}
160
+
161
+
export async function parallel<T>(
162
+
tasks: (() => Promise<T>)[],
163
+
concurrency: number
164
+
): Promise<T[]> {
165
+
const results: T[] = []
166
+
const executing: Promise<void>[] = []
167
+
168
+
for (const task of tasks) {
169
+
const p = task().then((result) => {
170
+
results.push(result)
171
+
})
172
+
173
+
executing.push(p)
174
+
175
+
if (executing.length >= concurrency) {
176
+
await Promise.race(executing)
177
+
executing.splice(
178
+
executing.findIndex((e) => e === p),
179
+
1
180
+
)
181
+
}
182
+
}
183
+
184
+
await Promise.all(executing)
185
+
return results
186
+
}
187
+
188
+
export async function mapParallel<T, U>(
189
+
items: T[],
190
+
fn: (item: T, index: number) => Promise<U>,
191
+
concurrency: number
192
+
): Promise<U[]> {
193
+
const results: U[] = new Array(items.length)
194
+
const executing: Promise<void>[] = []
195
+
196
+
for (let i = 0; i < items.length; i++) {
197
+
const index = i
198
+
const p = fn(items[index], index).then((result) => {
199
+
results[index] = result
200
+
})
201
+
202
+
executing.push(p)
203
+
204
+
if (executing.length >= concurrency) {
205
+
await Promise.race(executing)
206
+
const doneIndex = executing.findIndex(
207
+
(e) =>
208
+
(e as Promise<void> & { _done?: boolean })._done !== false
209
+
)
210
+
if (doneIndex >= 0) {
211
+
executing.splice(doneIndex, 1)
212
+
}
213
+
}
214
+
}
215
+
216
+
await Promise.all(executing)
217
+
return results
218
+
}
219
+
220
+
export function createAbortable<T>(
221
+
fn: (signal: AbortSignal) => Promise<T>
222
+
): { promise: Promise<T>; abort: () => void } {
223
+
const controller = new AbortController()
224
+
return {
225
+
promise: fn(controller.signal),
226
+
abort: () => controller.abort(),
227
+
}
228
+
}
229
+
230
+
export interface Deferred<T> {
231
+
promise: Promise<T>
232
+
resolve: (value: T) => void
233
+
reject: (error: unknown) => void
234
+
}
235
+
236
+
export function deferred<T>(): Deferred<T> {
237
+
let resolve!: (value: T) => void
238
+
let reject!: (error: unknown) => void
239
+
240
+
const promise = new Promise<T>((res, rej) => {
241
+
resolve = res
242
+
reject = rej
243
+
})
244
+
245
+
return { promise, resolve, reject }
246
+
}
+3
frontend/src/lib/utils/index.ts
+3
frontend/src/lib/utils/index.ts
+79
frontend/src/lib/utils/option.ts
+79
frontend/src/lib/utils/option.ts
···
···
1
+
export type Option<T> = T | null | undefined
2
+
3
+
export function isSome<T>(opt: Option<T>): opt is T {
4
+
return opt != null
5
+
}
6
+
7
+
export function isNone<T>(opt: Option<T>): opt is null | undefined {
8
+
return opt == null
9
+
}
10
+
11
+
export function map<T, U>(opt: Option<T>, fn: (t: T) => U): Option<U> {
12
+
return isSome(opt) ? fn(opt) : null
13
+
}
14
+
15
+
export function flatMap<T, U>(opt: Option<T>, fn: (t: T) => Option<U>): Option<U> {
16
+
return isSome(opt) ? fn(opt) : null
17
+
}
18
+
19
+
export function filter<T>(opt: Option<T>, predicate: (t: T) => boolean): Option<T> {
20
+
return isSome(opt) && predicate(opt) ? opt : null
21
+
}
22
+
23
+
export function getOrElse<T>(opt: Option<T>, defaultValue: T): T {
24
+
return isSome(opt) ? opt : defaultValue
25
+
}
26
+
27
+
export function getOrElseLazy<T>(opt: Option<T>, fn: () => T): T {
28
+
return isSome(opt) ? opt : fn()
29
+
}
30
+
31
+
export function getOrThrow<T>(opt: Option<T>, error?: string | Error): T {
32
+
if (isSome(opt)) return opt
33
+
if (error instanceof Error) throw error
34
+
throw new Error(error ?? 'Expected value but got null/undefined')
35
+
}
36
+
37
+
export function tap<T>(opt: Option<T>, fn: (t: T) => void): Option<T> {
38
+
if (isSome(opt)) fn(opt)
39
+
return opt
40
+
}
41
+
42
+
export function match<T, U>(
43
+
opt: Option<T>,
44
+
handlers: { some: (t: T) => U; none: () => U }
45
+
): U {
46
+
return isSome(opt) ? handlers.some(opt) : handlers.none()
47
+
}
48
+
49
+
export function toArray<T>(opt: Option<T>): T[] {
50
+
return isSome(opt) ? [opt] : []
51
+
}
52
+
53
+
export function fromArray<T>(arr: T[]): Option<T> {
54
+
return arr.length > 0 ? arr[0] : null
55
+
}
56
+
57
+
export function zip<T, U>(a: Option<T>, b: Option<U>): Option<[T, U]> {
58
+
return isSome(a) && isSome(b) ? [a, b] : null
59
+
}
60
+
61
+
export function zipWith<T, U, R>(
62
+
a: Option<T>,
63
+
b: Option<U>,
64
+
fn: (t: T, u: U) => R
65
+
): Option<R> {
66
+
return isSome(a) && isSome(b) ? fn(a, b) : null
67
+
}
68
+
69
+
export function or<T>(a: Option<T>, b: Option<T>): Option<T> {
70
+
return isSome(a) ? a : b
71
+
}
72
+
73
+
export function orLazy<T>(a: Option<T>, fn: () => Option<T>): Option<T> {
74
+
return isSome(a) ? a : fn()
75
+
}
76
+
77
+
export function and<T, U>(a: Option<T>, b: Option<U>): Option<U> {
78
+
return isSome(a) ? b : null
79
+
}
+260
frontend/src/lib/validation.ts
+260
frontend/src/lib/validation.ts
···
···
1
+
import { ok, err, type Result } from './types/result'
2
+
import {
3
+
type Did,
4
+
type DidPlc,
5
+
type DidWeb,
6
+
type Handle,
7
+
type EmailAddress,
8
+
type AtUri,
9
+
type Cid,
10
+
type Nsid,
11
+
type ISODateString,
12
+
isDid,
13
+
isDidPlc,
14
+
isDidWeb,
15
+
isHandle,
16
+
isEmail,
17
+
isAtUri,
18
+
isCid,
19
+
isNsid,
20
+
isISODate,
21
+
} from './types/branded'
22
+
23
+
export class ValidationError extends Error {
24
+
constructor(
25
+
message: string,
26
+
public readonly field?: string,
27
+
public readonly value?: unknown
28
+
) {
29
+
super(message)
30
+
this.name = 'ValidationError'
31
+
}
32
+
}
33
+
34
+
export function parseDid(s: string): Result<Did, ValidationError> {
35
+
if (isDid(s)) {
36
+
return ok(s)
37
+
}
38
+
return err(new ValidationError(`Invalid DID: ${s}`, 'did', s))
39
+
}
40
+
41
+
export function parseDidPlc(s: string): Result<DidPlc, ValidationError> {
42
+
if (isDidPlc(s)) {
43
+
return ok(s)
44
+
}
45
+
return err(new ValidationError(`Invalid DID:PLC: ${s}`, 'did', s))
46
+
}
47
+
48
+
export function parseDidWeb(s: string): Result<DidWeb, ValidationError> {
49
+
if (isDidWeb(s)) {
50
+
return ok(s)
51
+
}
52
+
return err(new ValidationError(`Invalid DID:WEB: ${s}`, 'did', s))
53
+
}
54
+
55
+
export function parseHandle(s: string): Result<Handle, ValidationError> {
56
+
const trimmed = s.trim().toLowerCase()
57
+
if (isHandle(trimmed)) {
58
+
return ok(trimmed)
59
+
}
60
+
return err(new ValidationError(`Invalid handle: ${s}`, 'handle', s))
61
+
}
62
+
63
+
export function parseEmail(s: string): Result<EmailAddress, ValidationError> {
64
+
const trimmed = s.trim().toLowerCase()
65
+
if (isEmail(trimmed)) {
66
+
return ok(trimmed)
67
+
}
68
+
return err(new ValidationError(`Invalid email: ${s}`, 'email', s))
69
+
}
70
+
71
+
export function parseAtUri(s: string): Result<AtUri, ValidationError> {
72
+
if (isAtUri(s)) {
73
+
return ok(s)
74
+
}
75
+
return err(new ValidationError(`Invalid AT-URI: ${s}`, 'uri', s))
76
+
}
77
+
78
+
export function parseCid(s: string): Result<Cid, ValidationError> {
79
+
if (isCid(s)) {
80
+
return ok(s)
81
+
}
82
+
return err(new ValidationError(`Invalid CID: ${s}`, 'cid', s))
83
+
}
84
+
85
+
export function parseNsid(s: string): Result<Nsid, ValidationError> {
86
+
if (isNsid(s)) {
87
+
return ok(s)
88
+
}
89
+
return err(new ValidationError(`Invalid NSID: ${s}`, 'nsid', s))
90
+
}
91
+
92
+
export function parseISODate(s: string): Result<ISODateString, ValidationError> {
93
+
if (isISODate(s)) {
94
+
return ok(s)
95
+
}
96
+
return err(new ValidationError(`Invalid ISO date: ${s}`, 'date', s))
97
+
}
98
+
99
+
export interface PasswordValidationResult {
100
+
valid: boolean
101
+
errors: string[]
102
+
strength: 'weak' | 'fair' | 'good' | 'strong'
103
+
}
104
+
105
+
export function validatePassword(password: string): PasswordValidationResult {
106
+
const errors: string[] = []
107
+
108
+
if (password.length < 8) {
109
+
errors.push('Password must be at least 8 characters')
110
+
}
111
+
if (password.length > 256) {
112
+
errors.push('Password must be at most 256 characters')
113
+
}
114
+
if (!/[a-z]/.test(password)) {
115
+
errors.push('Password must contain a lowercase letter')
116
+
}
117
+
if (!/[A-Z]/.test(password)) {
118
+
errors.push('Password must contain an uppercase letter')
119
+
}
120
+
if (!/\d/.test(password)) {
121
+
errors.push('Password must contain a number')
122
+
}
123
+
124
+
let strength: PasswordValidationResult['strength'] = 'weak'
125
+
if (errors.length === 0) {
126
+
const hasSpecial = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)
127
+
const isLong = password.length >= 12
128
+
const isVeryLong = password.length >= 16
129
+
130
+
if (isVeryLong && hasSpecial) {
131
+
strength = 'strong'
132
+
} else if (isLong || hasSpecial) {
133
+
strength = 'good'
134
+
} else {
135
+
strength = 'fair'
136
+
}
137
+
}
138
+
139
+
return {
140
+
valid: errors.length === 0,
141
+
errors,
142
+
strength,
143
+
}
144
+
}
145
+
146
+
export function validateHandle(handle: string): Result<Handle, ValidationError> {
147
+
const trimmed = handle.trim().toLowerCase()
148
+
149
+
if (trimmed.length < 3) {
150
+
return err(new ValidationError('Handle must be at least 3 characters', 'handle', handle))
151
+
}
152
+
153
+
if (trimmed.length > 253) {
154
+
return err(new ValidationError('Handle must be at most 253 characters', 'handle', handle))
155
+
}
156
+
157
+
if (!isHandle(trimmed)) {
158
+
return err(new ValidationError('Invalid handle format', 'handle', handle))
159
+
}
160
+
161
+
return ok(trimmed)
162
+
}
163
+
164
+
export function validateInviteCode(code: string): Result<string, ValidationError> {
165
+
const trimmed = code.trim()
166
+
167
+
if (trimmed.length === 0) {
168
+
return err(new ValidationError('Invite code is required', 'inviteCode', code))
169
+
}
170
+
171
+
const pattern = /^[a-zA-Z0-9-]+$/
172
+
if (!pattern.test(trimmed)) {
173
+
return err(new ValidationError('Invalid invite code format', 'inviteCode', code))
174
+
}
175
+
176
+
return ok(trimmed)
177
+
}
178
+
179
+
export function validateTotpCode(code: string): Result<string, ValidationError> {
180
+
const trimmed = code.trim().replace(/\s/g, '')
181
+
182
+
if (!/^\d{6}$/.test(trimmed)) {
183
+
return err(new ValidationError('TOTP code must be 6 digits', 'code', code))
184
+
}
185
+
186
+
return ok(trimmed)
187
+
}
188
+
189
+
export function validateBackupCode(code: string): Result<string, ValidationError> {
190
+
const trimmed = code.trim().replace(/\s/g, '').toLowerCase()
191
+
192
+
if (!/^[a-z0-9]{8}$/.test(trimmed)) {
193
+
return err(new ValidationError('Invalid backup code format', 'code', code))
194
+
}
195
+
196
+
return ok(trimmed)
197
+
}
198
+
199
+
export interface FormValidation<T> {
200
+
validate: () => Result<T, ValidationError[]>
201
+
field: <K extends keyof T>(
202
+
key: K,
203
+
validator: (value: unknown) => Result<T[K], ValidationError>
204
+
) => FormValidation<T>
205
+
optional: <K extends keyof T>(
206
+
key: K,
207
+
validator: (value: unknown) => Result<T[K], ValidationError>
208
+
) => FormValidation<T>
209
+
}
210
+
211
+
export function createFormValidation<T extends Record<string, unknown>>(
212
+
data: Record<string, unknown>
213
+
): FormValidation<T> {
214
+
const validators: Array<{
215
+
key: string
216
+
validator: (value: unknown) => Result<unknown, ValidationError>
217
+
optional: boolean
218
+
}> = []
219
+
220
+
const builder: FormValidation<T> = {
221
+
field: (key, validator) => {
222
+
validators.push({ key: key as string, validator, optional: false })
223
+
return builder
224
+
},
225
+
optional: (key, validator) => {
226
+
validators.push({ key: key as string, validator, optional: true })
227
+
return builder
228
+
},
229
+
validate: () => {
230
+
const errors: ValidationError[] = []
231
+
const result: Record<string, unknown> = {}
232
+
233
+
for (const { key, validator, optional } of validators) {
234
+
const value = data[key]
235
+
236
+
if (value == null || value === '') {
237
+
if (!optional) {
238
+
errors.push(new ValidationError(`${key} is required`, key))
239
+
}
240
+
continue
241
+
}
242
+
243
+
const validated = validator(value)
244
+
if (validated.ok) {
245
+
result[key] = validated.value
246
+
} else {
247
+
errors.push(validated.error)
248
+
}
249
+
}
250
+
251
+
if (errors.length > 0) {
252
+
return err(errors)
253
+
}
254
+
255
+
return ok(result as T)
256
+
},
257
+
}
258
+
259
+
return builder
260
+
}
+156
frontend/src/lib/webauthn.ts
+156
frontend/src/lib/webauthn.ts
···
···
1
+
export interface PublicKeyCredentialDescriptorJSON {
2
+
type: 'public-key'
3
+
id: string
4
+
transports?: AuthenticatorTransport[]
5
+
}
6
+
7
+
export interface PublicKeyCredentialUserEntityJSON {
8
+
id: string
9
+
name: string
10
+
displayName: string
11
+
}
12
+
13
+
export interface PublicKeyCredentialRpEntityJSON {
14
+
name: string
15
+
id?: string
16
+
}
17
+
18
+
export interface PublicKeyCredentialParametersJSON {
19
+
type: 'public-key'
20
+
alg: number
21
+
}
22
+
23
+
export interface AuthenticatorSelectionCriteriaJSON {
24
+
authenticatorAttachment?: AuthenticatorAttachment
25
+
residentKey?: ResidentKeyRequirement
26
+
requireResidentKey?: boolean
27
+
userVerification?: UserVerificationRequirement
28
+
}
29
+
30
+
export interface PublicKeyCredentialCreationOptionsJSON {
31
+
rp: PublicKeyCredentialRpEntityJSON
32
+
user: PublicKeyCredentialUserEntityJSON
33
+
challenge: string
34
+
pubKeyCredParams: PublicKeyCredentialParametersJSON[]
35
+
timeout?: number
36
+
excludeCredentials?: PublicKeyCredentialDescriptorJSON[]
37
+
authenticatorSelection?: AuthenticatorSelectionCriteriaJSON
38
+
attestation?: AttestationConveyancePreference
39
+
}
40
+
41
+
export interface PublicKeyCredentialRequestOptionsJSON {
42
+
challenge: string
43
+
timeout?: number
44
+
rpId?: string
45
+
allowCredentials?: PublicKeyCredentialDescriptorJSON[]
46
+
userVerification?: UserVerificationRequirement
47
+
}
48
+
49
+
export interface WebAuthnCreationOptionsResponse {
50
+
publicKey: PublicKeyCredentialCreationOptionsJSON
51
+
}
52
+
53
+
export interface WebAuthnRequestOptionsResponse {
54
+
publicKey: PublicKeyCredentialRequestOptionsJSON
55
+
}
56
+
57
+
export interface CredentialAssertionJSON {
58
+
id: string
59
+
type: string
60
+
rawId: string
61
+
response: {
62
+
clientDataJSON: string
63
+
authenticatorData: string
64
+
signature: string
65
+
userHandle: string | null
66
+
}
67
+
}
68
+
69
+
export interface CredentialAttestationJSON {
70
+
id: string
71
+
type: string
72
+
rawId: string
73
+
response: {
74
+
clientDataJSON: string
75
+
attestationObject: string
76
+
}
77
+
}
78
+
79
+
export function base64UrlToArrayBuffer(base64url: string): ArrayBuffer {
80
+
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
81
+
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4)
82
+
const binary = atob(padded)
83
+
return Uint8Array.from(binary, (char) => char.charCodeAt(0)).buffer
84
+
}
85
+
86
+
export function arrayBufferToBase64Url(buffer: ArrayBuffer): string {
87
+
const bytes = new Uint8Array(buffer)
88
+
const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('')
89
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
90
+
}
91
+
92
+
export function prepareCreationOptions(
93
+
options: WebAuthnCreationOptionsResponse
94
+
): PublicKeyCredentialCreationOptions {
95
+
const pk = options.publicKey
96
+
return {
97
+
...pk,
98
+
challenge: base64UrlToArrayBuffer(pk.challenge),
99
+
user: {
100
+
...pk.user,
101
+
id: base64UrlToArrayBuffer(pk.user.id),
102
+
},
103
+
excludeCredentials: (pk.excludeCredentials ?? []).map((cred) => ({
104
+
...cred,
105
+
id: base64UrlToArrayBuffer(cred.id),
106
+
})),
107
+
}
108
+
}
109
+
110
+
export function prepareRequestOptions(
111
+
options: WebAuthnRequestOptionsResponse
112
+
): PublicKeyCredentialRequestOptions {
113
+
const pk = options.publicKey
114
+
return {
115
+
...pk,
116
+
challenge: base64UrlToArrayBuffer(pk.challenge),
117
+
allowCredentials: (pk.allowCredentials ?? []).map((cred) => ({
118
+
...cred,
119
+
id: base64UrlToArrayBuffer(cred.id),
120
+
})),
121
+
}
122
+
}
123
+
124
+
export function serializeAttestationResponse(
125
+
credential: PublicKeyCredential
126
+
): CredentialAttestationJSON {
127
+
const response = credential.response as AuthenticatorAttestationResponse
128
+
return {
129
+
id: credential.id,
130
+
type: credential.type,
131
+
rawId: arrayBufferToBase64Url(credential.rawId),
132
+
response: {
133
+
clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON),
134
+
attestationObject: arrayBufferToBase64Url(response.attestationObject),
135
+
},
136
+
}
137
+
}
138
+
139
+
export function serializeAssertionResponse(
140
+
credential: PublicKeyCredential
141
+
): CredentialAssertionJSON {
142
+
const response = credential.response as AuthenticatorAssertionResponse
143
+
return {
144
+
id: credential.id,
145
+
type: credential.type,
146
+
rawId: arrayBufferToBase64Url(credential.rawId),
147
+
response: {
148
+
clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON),
149
+
authenticatorData: arrayBufferToBase64Url(response.authenticatorData),
150
+
signature: arrayBufferToBase64Url(response.signature),
151
+
userHandle: response.userHandle
152
+
? arrayBufferToBase64Url(response.userHandle)
153
+
: null,
154
+
},
155
+
}
156
+
}
+18
-6
frontend/src/routes/ActAs.svelte
+18
-6
frontend/src/routes/ActAs.svelte
···
1
<script lang="ts">
2
import { getAuthState, logout } from '../lib/auth.svelte'
3
-
import { navigate } from '../lib/router.svelte'
4
import { generateCodeVerifier, generateCodeChallenge, saveOAuthState, generateState } from '../lib/oauth'
5
import { _ } from '../lib/i18n'
6
7
-
const auth = getAuthState()
8
let error = $state<string | null>(null)
9
let loading = $state(true)
10
let actAsInProgress = $state(false)
···
15
}
16
17
$effect(() => {
18
-
if (!auth.loading && !auth.session && !actAsInProgress) {
19
-
navigate('/login')
20
}
21
})
22
23
$effect(() => {
24
-
if (auth.session && !actAsInProgress) {
25
actAsInProgress = true
26
initiateActAs()
27
}
···
39
const response = await fetch(
40
`/xrpc/_delegation.listControlledAccounts`,
41
{
42
-
headers: { 'Authorization': `Bearer ${auth.session!.accessJwt}` }
43
}
44
)
45
···
1
<script lang="ts">
2
import { getAuthState, logout } from '../lib/auth.svelte'
3
+
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
4
import { generateCodeVerifier, generateCodeChallenge, saveOAuthState, generateState } from '../lib/oauth'
5
import { _ } from '../lib/i18n'
6
+
import type { Session } from '../lib/types/api'
7
8
+
const auth = $derived(getAuthState())
9
+
10
+
function getSession(): Session | null {
11
+
return auth.kind === 'authenticated' ? auth.session : null
12
+
}
13
+
14
+
function isLoading(): boolean {
15
+
return auth.kind === 'loading'
16
+
}
17
+
18
+
const session = $derived(getSession())
19
+
const authLoading = $derived(isLoading())
20
let error = $state<string | null>(null)
21
let loading = $state(true)
22
let actAsInProgress = $state(false)
···
27
}
28
29
$effect(() => {
30
+
if (!authLoading && !session && !actAsInProgress) {
31
+
navigate(routes.login)
32
}
33
})
34
35
$effect(() => {
36
+
if (session && !actAsInProgress) {
37
actAsInProgress = true
38
initiateActAs()
39
}
···
51
const response = await fetch(
52
`/xrpc/_delegation.listControlledAccounts`,
53
{
54
+
headers: { 'Authorization': `Bearer ${session!.accessJwt}` }
55
}
56
)
57
+54
-62
frontend/src/routes/Admin.svelte
+54
-62
frontend/src/routes/Admin.svelte
···
1
<script lang="ts">
2
import { getAuthState } from '../lib/auth.svelte'
3
import { setServerName as setGlobalServerName, setColors as setGlobalColors, setHasLogo as setGlobalHasLogo } from '../lib/serverConfig.svelte'
4
-
import { navigate } from '../lib/router.svelte'
5
import { api, ApiError } from '../lib/api'
6
import { _ } from '../lib/i18n'
7
import { formatDate, formatDateTime } from '../lib/date'
8
-
const auth = getAuthState()
9
const DEFAULT_COLORS = {
10
primaryLight: '#1A1D1D',
11
primaryDark: '#E6E8E8',
···
13
secondaryDark: '#E6E8E8',
14
}
15
let loading = $state(true)
16
-
let error = $state<string | null>(null)
17
let stats = $state<{
18
userCount: number
19
repoCount: number
···
21
blobStorageBytes: number
22
} | null>(null)
23
let usersLoading = $state(false)
24
-
let usersError = $state<string | null>(null)
25
let users = $state<Array<{
26
did: string
27
handle: string
···
34
let handleSearchQuery = $state('')
35
let showUsers = $state(false)
36
let invitesLoading = $state(false)
37
-
let invitesError = $state<string | null>(null)
38
let invites = $state<Array<{
39
code: string
40
available: number
···
72
let logoFile = $state<File | null>(null)
73
let logoPreview = $state<string | null>(null)
74
let serverConfigLoading = $state(false)
75
-
let serverConfigError = $state<string | null>(null)
76
-
let serverConfigSuccess = $state(false)
77
$effect(() => {
78
-
if (!auth.loading && !auth.session) {
79
-
navigate('/login')
80
-
} else if (!auth.loading && auth.session && !auth.session.isAdmin) {
81
-
navigate('/dashboard')
82
}
83
})
84
$effect(() => {
85
-
if (auth.session?.isAdmin) {
86
loadStats()
87
loadServerConfig()
88
}
···
106
logoPreview = '/logo'
107
}
108
} catch (e) {
109
-
serverConfigError = e instanceof ApiError ? e.message : 'Failed to load server config'
110
}
111
}
112
async function saveServerConfig(e: Event) {
113
e.preventDefault()
114
-
if (!auth.session) return
115
serverConfigLoading = true
116
-
serverConfigError = null
117
-
serverConfigSuccess = false
118
try {
119
let newLogoCid = logoCid
120
if (logoFile) {
121
-
const result = await api.uploadBlob(auth.session.accessJwt, logoFile)
122
newLogoCid = result.blob.ref.$link
123
}
124
-
await api.updateServerConfig(auth.session.accessJwt, {
125
serverName: serverNameInput,
126
primaryColor: primaryColorInput,
127
primaryColorDark: primaryColorDarkInput,
···
145
secondaryColorDark: secondaryColorDarkInput || null,
146
})
147
setGlobalHasLogo(!!newLogoCid)
148
-
serverConfigSuccess = true
149
-
setTimeout(() => { serverConfigSuccess = false }, 3000)
150
} catch (e) {
151
-
serverConfigError = e instanceof ApiError ? e.message : 'Failed to save server config'
152
} finally {
153
serverConfigLoading = false
154
}
···
179
logoChanged
180
}
181
async function loadStats() {
182
-
if (!auth.session) return
183
loading = true
184
-
error = null
185
try {
186
-
stats = await api.getServerStats(auth.session.accessJwt)
187
} catch (e) {
188
-
error = e instanceof ApiError ? e.message : 'Failed to load server stats'
189
} finally {
190
loading = false
191
}
192
}
193
async function loadUsers(reset = false) {
194
-
if (!auth.session) return
195
usersLoading = true
196
-
usersError = null
197
if (reset) {
198
users = []
199
usersCursor = undefined
200
}
201
try {
202
-
const result = await api.searchAccounts(auth.session.accessJwt, {
203
handle: handleSearchQuery || undefined,
204
cursor: reset ? undefined : usersCursor,
205
limit: 25,
···
208
usersCursor = result.cursor
209
showUsers = true
210
} catch (e) {
211
-
usersError = e instanceof ApiError ? e.message : 'Failed to load users'
212
} finally {
213
usersLoading = false
214
}
···
218
loadUsers(true)
219
}
220
async function loadInvites(reset = false) {
221
-
if (!auth.session) return
222
invitesLoading = true
223
-
invitesError = null
224
if (reset) {
225
invites = []
226
invitesCursor = undefined
227
}
228
try {
229
-
const result = await api.getInviteCodes(auth.session.accessJwt, {
230
cursor: reset ? undefined : invitesCursor,
231
limit: 25,
232
})
···
234
invitesCursor = result.cursor
235
showInvites = true
236
} catch (e) {
237
-
invitesError = e instanceof ApiError ? e.message : 'Failed to load invites'
238
} finally {
239
invitesLoading = false
240
}
241
}
242
async function disableInvite(code: string) {
243
-
if (!auth.session) return
244
if (!confirm($_('admin.disableInviteConfirm', { values: { code } }))) return
245
try {
246
-
await api.disableInviteCodes(auth.session.accessJwt, [code])
247
invites = invites.map(inv => inv.code === code ? { ...inv, disabled: true } : inv)
248
} catch (e) {
249
-
invitesError = e instanceof ApiError ? e.message : 'Failed to disable invite'
250
}
251
}
252
async function selectUser(did: string) {
253
-
if (!auth.session) return
254
userDetailLoading = true
255
try {
256
-
selectedUser = await api.getAccountInfo(auth.session.accessJwt, did)
257
} catch (e) {
258
-
usersError = e instanceof ApiError ? e.message : 'Failed to load user details'
259
} finally {
260
userDetailLoading = false
261
}
···
264
selectedUser = null
265
}
266
async function toggleUserInvites() {
267
-
if (!auth.session || !selectedUser) return
268
userActionLoading = true
269
try {
270
if (selectedUser.invitesDisabled) {
271
-
await api.enableAccountInvites(auth.session.accessJwt, selectedUser.did)
272
selectedUser = { ...selectedUser, invitesDisabled: false }
273
} else {
274
-
await api.disableAccountInvites(auth.session.accessJwt, selectedUser.did)
275
selectedUser = { ...selectedUser, invitesDisabled: true }
276
}
277
} catch (e) {
278
-
usersError = e instanceof ApiError ? e.message : 'Failed to update user'
279
} finally {
280
userActionLoading = false
281
}
282
}
283
async function deleteUser() {
284
-
if (!auth.session || !selectedUser) return
285
if (!confirm($_('admin.deleteConfirm', { values: { handle: selectedUser.handle } }))) return
286
userActionLoading = true
287
try {
288
-
await api.adminDeleteAccount(auth.session.accessJwt, selectedUser.did)
289
users = users.filter(u => u.did !== selectedUser!.did)
290
selectedUser = null
291
} catch (e) {
292
-
usersError = e instanceof ApiError ? e.message : 'Failed to delete user'
293
} finally {
294
userActionLoading = false
295
}
···
305
return num.toLocaleString()
306
}
307
</script>
308
-
{#if auth.session?.isAdmin}
309
<div class="page">
310
<header>
311
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
···
314
{#if loading}
315
<p class="loading">{$_('admin.loading')}</p>
316
{:else}
317
-
{#if error}
318
-
<div class="message error">{error}</div>
319
-
{/if}
320
<section>
321
<h2>{$_('admin.serverConfig')}</h2>
322
<form class="config-form" onsubmit={saveServerConfig}>
···
428
</div>
429
</div>
430
431
-
{#if serverConfigError}
432
-
<div class="message error">{serverConfigError}</div>
433
-
{/if}
434
-
{#if serverConfigSuccess}
435
-
<div class="message success">{$_('admin.configSaved')}</div>
436
-
{/if}
437
<button type="submit" disabled={serverConfigLoading || !hasConfigChanges()}>
438
{serverConfigLoading ? $_('common.saving') : $_('admin.saveConfig')}
439
</button>
···
476
{usersLoading ? $_('admin.loading') : $_('admin.searchUsers')}
477
</button>
478
</form>
479
-
{#if usersError}
480
-
<div class="message error">{usersError}</div>
481
-
{/if}
482
{#if showUsers}
483
<div class="user-list">
484
{#if users.length === 0}
···
528
{invitesLoading ? $_('admin.loading') : showInvites ? $_('admin.refresh') : $_('admin.loadInviteCodes')}
529
</button>
530
</div>
531
-
{#if invitesError}
532
-
<div class="message error">{invitesError}</div>
533
-
{/if}
534
{#if showInvites}
535
<div class="invite-list">
536
{#if invites.length === 0}
···
1
<script lang="ts">
2
import { getAuthState } from '../lib/auth.svelte'
3
import { setServerName as setGlobalServerName, setColors as setGlobalColors, setHasLogo as setGlobalHasLogo } from '../lib/serverConfig.svelte'
4
+
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
5
import { api, ApiError } from '../lib/api'
6
import { _ } from '../lib/i18n'
7
import { formatDate, formatDateTime } from '../lib/date'
8
+
import type { Session } from '../lib/types/api'
9
+
import { toast } from '../lib/toast.svelte'
10
+
11
+
const auth = $derived(getAuthState())
12
+
13
+
function getSession(): Session | null {
14
+
return auth.kind === 'authenticated' ? auth.session : null
15
+
}
16
+
17
+
function isLoading(): boolean {
18
+
return auth.kind === 'loading'
19
+
}
20
+
21
+
const session = $derived(getSession())
22
+
const authLoading = $derived(isLoading())
23
const DEFAULT_COLORS = {
24
primaryLight: '#1A1D1D',
25
primaryDark: '#E6E8E8',
···
27
secondaryDark: '#E6E8E8',
28
}
29
let loading = $state(true)
30
let stats = $state<{
31
userCount: number
32
repoCount: number
···
34
blobStorageBytes: number
35
} | null>(null)
36
let usersLoading = $state(false)
37
let users = $state<Array<{
38
did: string
39
handle: string
···
46
let handleSearchQuery = $state('')
47
let showUsers = $state(false)
48
let invitesLoading = $state(false)
49
let invites = $state<Array<{
50
code: string
51
available: number
···
83
let logoFile = $state<File | null>(null)
84
let logoPreview = $state<string | null>(null)
85
let serverConfigLoading = $state(false)
86
$effect(() => {
87
+
if (!authLoading && !session) {
88
+
navigate(routes.login)
89
+
} else if (!authLoading && session && !session.isAdmin) {
90
+
navigate(routes.dashboard)
91
}
92
})
93
$effect(() => {
94
+
if (session?.isAdmin) {
95
loadStats()
96
loadServerConfig()
97
}
···
115
logoPreview = '/logo'
116
}
117
} catch (e) {
118
+
toast.error(e instanceof ApiError ? e.message : $_('admin.failedToLoadConfig'))
119
}
120
}
121
async function saveServerConfig(e: Event) {
122
e.preventDefault()
123
+
if (!session) return
124
serverConfigLoading = true
125
try {
126
let newLogoCid = logoCid
127
if (logoFile) {
128
+
const result = await api.uploadBlob(session.accessJwt, logoFile)
129
newLogoCid = result.blob.ref.$link
130
}
131
+
await api.updateServerConfig(session.accessJwt, {
132
serverName: serverNameInput,
133
primaryColor: primaryColorInput,
134
primaryColorDark: primaryColorDarkInput,
···
152
secondaryColorDark: secondaryColorDarkInput || null,
153
})
154
setGlobalHasLogo(!!newLogoCid)
155
+
toast.success($_('admin.configSaved'))
156
} catch (e) {
157
+
toast.error(e instanceof ApiError ? e.message : $_('admin.failedToSaveConfig'))
158
} finally {
159
serverConfigLoading = false
160
}
···
185
logoChanged
186
}
187
async function loadStats() {
188
+
if (!session) return
189
loading = true
190
try {
191
+
stats = await api.getServerStats(session.accessJwt)
192
} catch (e) {
193
+
toast.error(e instanceof ApiError ? e.message : $_('admin.failedToLoadStats'))
194
} finally {
195
loading = false
196
}
197
}
198
async function loadUsers(reset = false) {
199
+
if (!session) return
200
usersLoading = true
201
if (reset) {
202
users = []
203
usersCursor = undefined
204
}
205
try {
206
+
const result = await api.searchAccounts(session.accessJwt, {
207
handle: handleSearchQuery || undefined,
208
cursor: reset ? undefined : usersCursor,
209
limit: 25,
···
212
usersCursor = result.cursor
213
showUsers = true
214
} catch (e) {
215
+
toast.error(e instanceof ApiError ? e.message : $_('admin.failedToLoadUsers'))
216
} finally {
217
usersLoading = false
218
}
···
222
loadUsers(true)
223
}
224
async function loadInvites(reset = false) {
225
+
if (!session) return
226
invitesLoading = true
227
if (reset) {
228
invites = []
229
invitesCursor = undefined
230
}
231
try {
232
+
const result = await api.getInviteCodes(session.accessJwt, {
233
cursor: reset ? undefined : invitesCursor,
234
limit: 25,
235
})
···
237
invitesCursor = result.cursor
238
showInvites = true
239
} catch (e) {
240
+
toast.error(e instanceof ApiError ? e.message : $_('admin.failedToLoadInvites'))
241
} finally {
242
invitesLoading = false
243
}
244
}
245
async function disableInvite(code: string) {
246
+
if (!session) return
247
if (!confirm($_('admin.disableInviteConfirm', { values: { code } }))) return
248
try {
249
+
await api.disableInviteCodes(session.accessJwt, [code])
250
invites = invites.map(inv => inv.code === code ? { ...inv, disabled: true } : inv)
251
+
toast.success($_('admin.inviteDisabled'))
252
} catch (e) {
253
+
toast.error(e instanceof ApiError ? e.message : $_('admin.failedToDisableInvite'))
254
}
255
}
256
async function selectUser(did: string) {
257
+
if (!session) return
258
userDetailLoading = true
259
try {
260
+
selectedUser = await api.getAccountInfo(session.accessJwt, did)
261
} catch (e) {
262
+
toast.error(e instanceof ApiError ? e.message : $_('admin.failedToLoadUserDetails'))
263
} finally {
264
userDetailLoading = false
265
}
···
268
selectedUser = null
269
}
270
async function toggleUserInvites() {
271
+
if (!session || !selectedUser) return
272
userActionLoading = true
273
try {
274
if (selectedUser.invitesDisabled) {
275
+
await api.enableAccountInvites(session.accessJwt, selectedUser.did)
276
selectedUser = { ...selectedUser, invitesDisabled: false }
277
+
toast.success($_('admin.invitesEnabled'))
278
} else {
279
+
await api.disableAccountInvites(session.accessJwt, selectedUser.did)
280
selectedUser = { ...selectedUser, invitesDisabled: true }
281
+
toast.success($_('admin.invitesDisabled'))
282
}
283
} catch (e) {
284
+
toast.error(e instanceof ApiError ? e.message : $_('admin.failedToUpdateUser'))
285
} finally {
286
userActionLoading = false
287
}
288
}
289
async function deleteUser() {
290
+
if (!session || !selectedUser) return
291
if (!confirm($_('admin.deleteConfirm', { values: { handle: selectedUser.handle } }))) return
292
userActionLoading = true
293
try {
294
+
await api.adminDeleteAccount(session.accessJwt, selectedUser.did)
295
users = users.filter(u => u.did !== selectedUser!.did)
296
selectedUser = null
297
+
toast.success($_('admin.userDeleted'))
298
} catch (e) {
299
+
toast.error(e instanceof ApiError ? e.message : $_('admin.failedToDeleteUser'))
300
} finally {
301
userActionLoading = false
302
}
···
312
return num.toLocaleString()
313
}
314
</script>
315
+
{#if session?.isAdmin}
316
<div class="page">
317
<header>
318
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
···
321
{#if loading}
322
<p class="loading">{$_('admin.loading')}</p>
323
{:else}
324
<section>
325
<h2>{$_('admin.serverConfig')}</h2>
326
<form class="config-form" onsubmit={saveServerConfig}>
···
432
</div>
433
</div>
434
435
<button type="submit" disabled={serverConfigLoading || !hasConfigChanges()}>
436
{serverConfigLoading ? $_('common.saving') : $_('admin.saveConfig')}
437
</button>
···
474
{usersLoading ? $_('admin.loading') : $_('admin.searchUsers')}
475
</button>
476
</form>
477
{#if showUsers}
478
<div class="user-list">
479
{#if users.length === 0}
···
523
{invitesLoading ? $_('admin.loading') : showInvites ? $_('admin.refresh') : $_('admin.loadInviteCodes')}
524
</button>
525
</div>
526
{#if showInvites}
527
<div class="invite-list">
528
{#if invites.length === 0}
+46
-23
frontend/src/routes/AppPasswords.svelte
+46
-23
frontend/src/routes/AppPasswords.svelte
···
1
<script lang="ts">
2
import { getAuthState } from '../lib/auth.svelte'
3
-
import { navigate } from '../lib/router.svelte'
4
import { api, type AppPassword, ApiError } from '../lib/api'
5
import { _ } from '../lib/i18n'
6
import { formatDate } from '../lib/date'
7
-
const auth = getAuthState()
8
let passwords = $state<AppPassword[]>([])
9
let loading = $state(true)
10
-
let error = $state<string | null>(null)
11
let newPasswordName = $state('')
12
let selectedScope = $state<string | null>(null)
13
let creating = $state(false)
···
29
return $_('appPasswords.scopeCustom')
30
}
31
$effect(() => {
32
-
if (!auth.loading && !auth.session) {
33
-
navigate('/login')
34
}
35
})
36
$effect(() => {
37
-
if (auth.session) {
38
loadPasswords()
39
}
40
})
41
async function loadPasswords() {
42
-
if (!auth.session) return
43
loading = true
44
-
error = null
45
try {
46
-
const result = await api.listAppPasswords(auth.session.accessJwt)
47
passwords = result.passwords
48
} catch (e) {
49
-
error = e instanceof ApiError ? e.message : 'Failed to load app passwords'
50
} finally {
51
loading = false
52
}
53
}
54
async function handleCreate(e: Event) {
55
e.preventDefault()
56
-
if (!auth.session || !newPasswordName.trim()) return
57
creating = true
58
-
error = null
59
try {
60
const scopeValue = selectedScope === null ? undefined : selectedScope
61
-
const result = await api.createAppPassword(auth.session.accessJwt, newPasswordName.trim(), scopeValue ?? undefined)
62
createdPassword = { name: result.name, password: result.password }
63
newPasswordName = ''
64
selectedScope = null
65
await loadPasswords()
66
} catch (e) {
67
-
error = e instanceof ApiError ? e.message : 'Failed to create app password'
68
} finally {
69
creating = false
70
}
71
}
72
async function handleRevoke(name: string) {
73
-
if (!auth.session) return
74
if (!confirm($_('appPasswords.revokeConfirm', { values: { name } }))) {
75
return
76
}
77
revoking = name
78
-
error = null
79
try {
80
-
await api.revokeAppPassword(auth.session.accessJwt, name)
81
await loadPasswords()
82
} catch (e) {
83
-
error = e instanceof ApiError ? e.message : 'Failed to revoke app password'
84
} finally {
85
revoking = null
86
}
···
99
</script>
100
<div class="page">
101
<header>
102
-
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
103
<h1>{$_('appPasswords.title')}</h1>
104
</header>
105
<p class="description">
106
{$_('appPasswords.description')}
107
</p>
108
-
{#if error}
109
-
<div class="error">{error}</div>
110
-
{/if}
111
{#if createdPassword}
112
<div class="created-password">
113
<div class="warning-box">
···
162
<section class="list-section">
163
<h2>{$_('appPasswords.yourPasswords')}</h2>
164
{#if loading}
165
-
<p class="empty">{$_('common.loading')}</p>
166
{:else if passwords.length === 0}
167
<p class="empty">{$_('appPasswords.noPasswords')}</p>
168
{:else}
···
458
color: var(--text-secondary);
459
text-align: center;
460
padding: var(--space-7);
461
}
462
</style>
···
1
<script lang="ts">
2
import { getAuthState } from '../lib/auth.svelte'
3
+
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
4
import { api, type AppPassword, ApiError } from '../lib/api'
5
import { _ } from '../lib/i18n'
6
import { formatDate } from '../lib/date'
7
+
import type { Session } from '../lib/types/api'
8
+
import { toast } from '../lib/toast.svelte'
9
+
10
+
const auth = $derived(getAuthState())
11
+
12
+
function getSession(): Session | null {
13
+
return auth.kind === 'authenticated' ? auth.session : null
14
+
}
15
+
16
+
function isLoading(): boolean {
17
+
return auth.kind === 'loading'
18
+
}
19
+
20
+
const session = $derived(getSession())
21
+
const authLoading = $derived(isLoading())
22
let passwords = $state<AppPassword[]>([])
23
let loading = $state(true)
24
let newPasswordName = $state('')
25
let selectedScope = $state<string | null>(null)
26
let creating = $state(false)
···
42
return $_('appPasswords.scopeCustom')
43
}
44
$effect(() => {
45
+
if (!authLoading && !session) {
46
+
navigate(routes.login)
47
}
48
})
49
$effect(() => {
50
+
if (session) {
51
loadPasswords()
52
}
53
})
54
async function loadPasswords() {
55
+
if (!session) return
56
loading = true
57
try {
58
+
const result = await api.listAppPasswords(session.accessJwt)
59
passwords = result.passwords
60
} catch (e) {
61
+
toast.error(e instanceof ApiError ? e.message : $_('appPasswords.failedToLoad'))
62
} finally {
63
loading = false
64
}
65
}
66
async function handleCreate(e: Event) {
67
e.preventDefault()
68
+
if (!session || !newPasswordName.trim()) return
69
creating = true
70
try {
71
const scopeValue = selectedScope === null ? undefined : selectedScope
72
+
const result = await api.createAppPassword(session.accessJwt, newPasswordName.trim(), scopeValue ?? undefined)
73
createdPassword = { name: result.name, password: result.password }
74
newPasswordName = ''
75
selectedScope = null
76
await loadPasswords()
77
} catch (e) {
78
+
toast.error(e instanceof ApiError ? e.message : $_('appPasswords.failedToCreate'))
79
} finally {
80
creating = false
81
}
82
}
83
async function handleRevoke(name: string) {
84
+
if (!session) return
85
if (!confirm($_('appPasswords.revokeConfirm', { values: { name } }))) {
86
return
87
}
88
revoking = name
89
try {
90
+
await api.revokeAppPassword(session.accessJwt, name)
91
await loadPasswords()
92
+
toast.success($_('appPasswords.passwordRevoked'))
93
} catch (e) {
94
+
toast.error(e instanceof ApiError ? e.message : $_('appPasswords.failedToRevoke'))
95
} finally {
96
revoking = null
97
}
···
110
</script>
111
<div class="page">
112
<header>
113
+
<a href={getFullUrl(routes.dashboard)} class="back">{$_('common.backToDashboard')}</a>
114
<h1>{$_('appPasswords.title')}</h1>
115
</header>
116
<p class="description">
117
{$_('appPasswords.description')}
118
</p>
119
{#if createdPassword}
120
<div class="created-password">
121
<div class="warning-box">
···
170
<section class="list-section">
171
<h2>{$_('appPasswords.yourPasswords')}</h2>
172
{#if loading}
173
+
<ul class="password-list">
174
+
{#each Array(2) as _}
175
+
<li class="skeleton-item"></li>
176
+
{/each}
177
+
</ul>
178
{:else if passwords.length === 0}
179
<p class="empty">{$_('appPasswords.noPasswords')}</p>
180
{:else}
···
470
color: var(--text-secondary);
471
text-align: center;
472
padding: var(--space-7);
473
+
}
474
+
475
+
.skeleton-item {
476
+
height: 60px;
477
+
background: var(--bg-tertiary);
478
+
animation: skeleton-pulse 1.5s ease-in-out infinite;
479
+
}
480
+
481
+
@keyframes skeleton-pulse {
482
+
0%, 100% { opacity: 1; }
483
+
50% { opacity: 0.5; }
484
}
485
</style>
+56
-47
frontend/src/routes/Comms.svelte
+56
-47
frontend/src/routes/Comms.svelte
···
1
<script lang="ts">
2
import { getAuthState, refreshSession } from '../lib/auth.svelte'
3
-
import { navigate } from '../lib/router.svelte'
4
import { api, ApiError } from '../lib/api'
5
import { _ } from '../lib/i18n'
6
import { formatDateTime } from '../lib/date'
7
-
const auth = getAuthState()
8
let loading = $state(true)
9
let saving = $state(false)
10
-
let error = $state<string | null>(null)
11
-
let success = $state<string | null>(null)
12
let preferredChannel = $state('email')
13
let availableCommsChannels = $state<string[]>(['email'])
14
let email = $state('')
···
20
let signalVerified = $state(false)
21
let verifyingChannel = $state<string | null>(null)
22
let verificationCode = $state('')
23
-
let verificationError = $state<string | null>(null)
24
-
let verificationSuccess = $state<string | null>(null)
25
let historyLoading = $state(true)
26
-
let historyError = $state<string | null>(null)
27
let messages = $state<Array<{
28
createdAt: string
29
channel: string
···
33
body: string
34
}>>([])
35
$effect(() => {
36
-
if (!auth.loading && !auth.session) {
37
-
navigate('/login')
38
}
39
})
40
$effect(() => {
41
-
if (auth.session) {
42
loadPrefs()
43
loadHistory()
44
}
45
})
46
async function loadPrefs() {
47
-
if (!auth.session) return
48
loading = true
49
-
error = null
50
try {
51
const [prefs, serverInfo] = await Promise.all([
52
-
api.getNotificationPrefs(auth.session.accessJwt),
53
api.describeServer()
54
])
55
preferredChannel = prefs.preferredChannel
···
62
signalVerified = prefs.signalVerified
63
availableCommsChannels = serverInfo.availableCommsChannels ?? ['email']
64
} catch (e) {
65
-
error = e instanceof ApiError ? e.message : 'Failed to load notification preferences'
66
} finally {
67
loading = false
68
}
69
}
70
async function handleSave(e: Event) {
71
e.preventDefault()
72
-
if (!auth.session) return
73
saving = true
74
-
error = null
75
-
success = null
76
try {
77
-
await api.updateNotificationPrefs(auth.session.accessJwt, {
78
preferredChannel,
79
discordId: discordId || undefined,
80
telegramUsername: telegramUsername || undefined,
81
signalNumber: signalNumber || undefined,
82
})
83
await refreshSession()
84
-
success = $_('comms.preferencesSaved')
85
await loadPrefs()
86
} catch (e) {
87
-
error = e instanceof ApiError ? e.message : 'Failed to save preferences'
88
} finally {
89
saving = false
90
}
91
}
92
async function handleVerify(channel: string) {
93
-
if (!auth.session || !verificationCode) return
94
-
verificationError = null
95
-
verificationSuccess = null
96
97
let identifier = ''
98
switch (channel) {
···
103
if (!identifier) return
104
105
try {
106
-
await api.confirmChannelVerification(auth.session.accessJwt, channel, identifier, verificationCode)
107
await refreshSession()
108
-
verificationSuccess = $_('comms.verifiedSuccess', { values: { channel } })
109
verificationCode = ''
110
verifyingChannel = null
111
await loadPrefs()
112
} catch (e) {
113
-
verificationError = e instanceof ApiError ? e.message : 'Failed to verify channel'
114
}
115
}
116
async function loadHistory() {
117
-
if (!auth.session) return
118
historyLoading = true
119
-
historyError = null
120
try {
121
-
const result = await api.getNotificationHistory(auth.session.accessJwt)
122
messages = result.notifications
123
} catch (e) {
124
-
historyError = e instanceof ApiError ? e.message : 'Failed to load notification history'
125
} finally {
126
historyLoading = false
127
}
···
168
</script>
169
<div class="page">
170
<header>
171
-
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
172
<h1>{$_('comms.title')}</h1>
173
<p class="description">{$_('comms.description')}</p>
174
</header>
175
176
{#if loading}
177
-
<p class="loading">{$_('common.loading')}</p>
178
{:else}
179
-
{#if error}
180
-
<div class="message error">{error}</div>
181
-
{/if}
182
-
{#if success}
183
-
<div class="message success">{success}</div>
184
-
{/if}
185
-
186
<div class="split-layout">
187
<div class="main-column">
188
<form onsubmit={handleSave}>
···
331
</div>
332
</div>
333
334
-
{#if verificationError}
335
-
<div class="message error" style="margin-top: 1rem">{verificationError}</div>
336
-
{/if}
337
-
{#if verificationSuccess}
338
-
<div class="message success" style="margin-top: 1rem">{verificationSuccess}</div>
339
-
{/if}
340
</section>
341
342
<div class="actions">
···
364
</div>
365
{/each}
366
</div>
367
-
{:else if historyError}
368
-
<div class="message error">{historyError}</div>
369
{:else if messages.length === 0}
370
<p class="no-messages">{$_('comms.noMessages')}</p>
371
{:else}
···
789
font-size: var(--text-xs);
790
color: var(--text-muted);
791
margin-top: var(--space-2);
792
}
793
</style>
···
1
<script lang="ts">
2
import { getAuthState, refreshSession } from '../lib/auth.svelte'
3
+
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
4
import { api, ApiError } from '../lib/api'
5
import { _ } from '../lib/i18n'
6
import { formatDateTime } from '../lib/date'
7
+
import type { Session } from '../lib/types/api'
8
+
import { toast } from '../lib/toast.svelte'
9
+
10
+
const auth = $derived(getAuthState())
11
+
12
+
function getSession(): Session | null {
13
+
return auth.kind === 'authenticated' ? auth.session : null
14
+
}
15
+
16
+
function isLoading(): boolean {
17
+
return auth.kind === 'loading'
18
+
}
19
+
20
+
const session = $derived(getSession())
21
+
const authLoading = $derived(isLoading())
22
let loading = $state(true)
23
let saving = $state(false)
24
let preferredChannel = $state('email')
25
let availableCommsChannels = $state<string[]>(['email'])
26
let email = $state('')
···
32
let signalVerified = $state(false)
33
let verifyingChannel = $state<string | null>(null)
34
let verificationCode = $state('')
35
let historyLoading = $state(true)
36
let messages = $state<Array<{
37
createdAt: string
38
channel: string
···
42
body: string
43
}>>([])
44
$effect(() => {
45
+
if (!authLoading && !session) {
46
+
navigate(routes.login)
47
}
48
})
49
$effect(() => {
50
+
if (session) {
51
loadPrefs()
52
loadHistory()
53
}
54
})
55
async function loadPrefs() {
56
+
if (!session) return
57
loading = true
58
try {
59
const [prefs, serverInfo] = await Promise.all([
60
+
api.getNotificationPrefs(session.accessJwt),
61
api.describeServer()
62
])
63
preferredChannel = prefs.preferredChannel
···
70
signalVerified = prefs.signalVerified
71
availableCommsChannels = serverInfo.availableCommsChannels ?? ['email']
72
} catch (e) {
73
+
toast.error(e instanceof ApiError ? e.message : $_('comms.failedToLoad'))
74
} finally {
75
loading = false
76
}
77
}
78
async function handleSave(e: Event) {
79
e.preventDefault()
80
+
if (!session) return
81
saving = true
82
try {
83
+
await api.updateNotificationPrefs(session.accessJwt, {
84
preferredChannel,
85
discordId: discordId || undefined,
86
telegramUsername: telegramUsername || undefined,
87
signalNumber: signalNumber || undefined,
88
})
89
await refreshSession()
90
+
toast.success($_('comms.preferencesSaved'))
91
await loadPrefs()
92
} catch (e) {
93
+
toast.error(e instanceof ApiError ? e.message : $_('comms.failedToSave'))
94
} finally {
95
saving = false
96
}
97
}
98
async function handleVerify(channel: string) {
99
+
if (!session || !verificationCode) return
100
101
let identifier = ''
102
switch (channel) {
···
107
if (!identifier) return
108
109
try {
110
+
await api.confirmChannelVerification(session.accessJwt, channel, identifier, verificationCode)
111
await refreshSession()
112
+
toast.success($_('comms.verifiedSuccess', { values: { channel } }))
113
verificationCode = ''
114
verifyingChannel = null
115
await loadPrefs()
116
} catch (e) {
117
+
toast.error(e instanceof ApiError ? e.message : $_('comms.failedToVerify'))
118
}
119
}
120
async function loadHistory() {
121
+
if (!session) return
122
historyLoading = true
123
try {
124
+
const result = await api.getNotificationHistory(session.accessJwt)
125
messages = result.notifications
126
} catch (e) {
127
+
toast.error(e instanceof ApiError ? e.message : $_('comms.failedToLoadHistory'))
128
} finally {
129
historyLoading = false
130
}
···
171
</script>
172
<div class="page">
173
<header>
174
+
<a href={getFullUrl(routes.dashboard)} class="back">{$_('common.backToDashboard')}</a>
175
<h1>{$_('comms.title')}</h1>
176
<p class="description">{$_('comms.description')}</p>
177
</header>
178
179
{#if loading}
180
+
<div class="skeleton-sections">
181
+
<div class="skeleton-section"></div>
182
+
<div class="skeleton-section"></div>
183
+
</div>
184
{:else}
185
<div class="split-layout">
186
<div class="main-column">
187
<form onsubmit={handleSave}>
···
330
</div>
331
</div>
332
333
</section>
334
335
<div class="actions">
···
357
</div>
358
{/each}
359
</div>
360
{:else if messages.length === 0}
361
<p class="no-messages">{$_('comms.noMessages')}</p>
362
{:else}
···
780
font-size: var(--text-xs);
781
color: var(--text-muted);
782
margin-top: var(--space-2);
783
+
}
784
+
785
+
.skeleton-sections {
786
+
display: flex;
787
+
flex-direction: column;
788
+
gap: var(--space-6);
789
+
}
790
+
791
+
.skeleton-section {
792
+
height: 180px;
793
+
background: var(--bg-secondary);
794
+
border-radius: var(--radius-xl);
795
+
animation: skeleton-pulse 1.5s ease-in-out infinite;
796
+
}
797
+
798
+
@keyframes skeleton-pulse {
799
+
0%, 100% { opacity: 1; }
800
+
50% { opacity: 0.5; }
801
}
802
</style>
+62
-43
frontend/src/routes/Controllers.svelte
+62
-43
frontend/src/routes/Controllers.svelte
···
1
<script lang="ts">
2
import { getAuthState } from '../lib/auth.svelte'
3
-
import { navigate } from '../lib/router.svelte'
4
import { _ } from '../lib/i18n'
5
import { formatDateTime } from '../lib/date'
6
7
interface Controller {
8
did: string
···
26
scopes: string
27
}
28
29
-
const auth = getAuthState()
30
let loading = $state(true)
31
-
let error = $state<string | null>(null)
32
-
let success = $state<string | null>(null)
33
let controllers = $state<Controller[]>([])
34
let controlledAccounts = $state<ControlledAccount[]>([])
35
let scopePresets = $state<ScopePreset[]>([])
···
51
let creatingDelegated = $state(false)
52
53
$effect(() => {
54
-
if (!auth.loading && !auth.session) {
55
-
navigate('/login')
56
}
57
})
58
59
$effect(() => {
60
-
if (auth.session) {
61
loadData()
62
}
63
})
64
65
async function loadData() {
66
loading = true
67
-
error = null
68
try {
69
await Promise.all([loadControllers(), loadControlledAccounts(), loadScopePresets()])
70
} finally {
···
73
}
74
75
async function loadControllers() {
76
-
if (!auth.session) return
77
try {
78
const response = await fetch('/xrpc/_delegation.listControllers', {
79
-
headers: { 'Authorization': `Bearer ${auth.session.accessJwt}` }
80
})
81
if (response.ok) {
82
const data = await response.json()
···
88
}
89
90
async function loadControlledAccounts() {
91
-
if (!auth.session) return
92
try {
93
const response = await fetch('/xrpc/_delegation.listControlledAccounts', {
94
-
headers: { 'Authorization': `Bearer ${auth.session.accessJwt}` }
95
})
96
if (response.ok) {
97
const data = await response.json()
···
115
}
116
117
async function addController() {
118
-
if (!auth.session || !addControllerDid.trim()) return
119
addingController = true
120
-
error = null
121
-
success = null
122
123
try {
124
const response = await fetch('/xrpc/_delegation.addController', {
125
method: 'POST',
126
headers: {
127
-
'Authorization': `Bearer ${auth.session.accessJwt}`,
128
'Content-Type': 'application/json'
129
},
130
body: JSON.stringify({
···
135
136
if (!response.ok) {
137
const data = await response.json()
138
-
error = data.message || data.error || $_('delegation.failedToAddController')
139
return
140
}
141
142
-
success = $_('delegation.controllerAdded')
143
addControllerDid = ''
144
addControllerScopes = 'atproto'
145
showAddController = false
146
await loadControllers()
147
} catch (e) {
148
-
error = $_('delegation.failedToAddController')
149
} finally {
150
addingController = false
151
}
152
}
153
154
async function removeController(controllerDid: string) {
155
-
if (!auth.session) return
156
if (!confirm($_('delegation.removeConfirm'))) return
157
158
-
error = null
159
-
success = null
160
-
161
try {
162
const response = await fetch('/xrpc/_delegation.removeController', {
163
method: 'POST',
164
headers: {
165
-
'Authorization': `Bearer ${auth.session.accessJwt}`,
166
'Content-Type': 'application/json'
167
},
168
body: JSON.stringify({ controller_did: controllerDid })
···
170
171
if (!response.ok) {
172
const data = await response.json()
173
-
error = data.message || data.error || $_('delegation.failedToRemoveController')
174
return
175
}
176
177
-
success = $_('delegation.controllerRemoved')
178
await loadControllers()
179
} catch (e) {
180
-
error = $_('delegation.failedToRemoveController')
181
}
182
}
183
184
async function createDelegatedAccount() {
185
-
if (!auth.session || !newDelegatedHandle.trim()) return
186
creatingDelegated = true
187
-
error = null
188
-
success = null
189
190
try {
191
const response = await fetch('/xrpc/_delegation.createDelegatedAccount', {
192
method: 'POST',
193
headers: {
194
-
'Authorization': `Bearer ${auth.session.accessJwt}`,
195
'Content-Type': 'application/json'
196
},
197
body: JSON.stringify({
···
203
204
if (!response.ok) {
205
const data = await response.json()
206
-
error = data.message || data.error || $_('delegation.failedToCreateAccount')
207
return
208
}
209
210
const data = await response.json()
211
-
success = $_('delegation.accountCreated', { values: { handle: data.handle } })
212
newDelegatedHandle = ''
213
newDelegatedEmail = ''
214
newDelegatedScopes = 'atproto'
215
showCreateDelegated = false
216
await loadControlledAccounts()
217
} catch (e) {
218
-
error = $_('delegation.failedToCreateAccount')
219
} finally {
220
creatingDelegated = false
221
}
···
237
</header>
238
239
{#if loading}
240
-
<p class="loading">{$_('delegation.loading')}</p>
241
{:else}
242
-
{#if error}
243
-
<div class="message error">{error}</div>
244
-
{/if}
245
-
246
-
{#if success}
247
-
<div class="message success">{success}</div>
248
-
{/if}
249
-
250
<section class="section">
251
<div class="section-header">
252
<h2>{$_('delegation.controllers')}</h2>
···
676
.form-actions button {
677
padding: var(--space-2) var(--space-4);
678
font-size: var(--text-sm);
679
}
680
</style>
···
1
<script lang="ts">
2
import { getAuthState } from '../lib/auth.svelte'
3
+
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
4
import { _ } from '../lib/i18n'
5
import { formatDateTime } from '../lib/date'
6
+
import type { Session } from '../lib/types/api'
7
+
import { toast } from '../lib/toast.svelte'
8
9
interface Controller {
10
did: string
···
28
scopes: string
29
}
30
31
+
const auth = $derived(getAuthState())
32
+
33
+
function getSession(): Session | null {
34
+
return auth.kind === 'authenticated' ? auth.session : null
35
+
}
36
+
37
+
function isLoading(): boolean {
38
+
return auth.kind === 'loading'
39
+
}
40
+
41
+
const session = $derived(getSession())
42
+
const authLoading = $derived(isLoading())
43
+
44
let loading = $state(true)
45
let controllers = $state<Controller[]>([])
46
let controlledAccounts = $state<ControlledAccount[]>([])
47
let scopePresets = $state<ScopePreset[]>([])
···
63
let creatingDelegated = $state(false)
64
65
$effect(() => {
66
+
if (!authLoading && !session) {
67
+
navigate(routes.login)
68
}
69
})
70
71
$effect(() => {
72
+
if (session) {
73
loadData()
74
}
75
})
76
77
async function loadData() {
78
loading = true
79
try {
80
await Promise.all([loadControllers(), loadControlledAccounts(), loadScopePresets()])
81
} finally {
···
84
}
85
86
async function loadControllers() {
87
+
if (!session) return
88
try {
89
const response = await fetch('/xrpc/_delegation.listControllers', {
90
+
headers: { 'Authorization': `Bearer ${session.accessJwt}` }
91
})
92
if (response.ok) {
93
const data = await response.json()
···
99
}
100
101
async function loadControlledAccounts() {
102
+
if (!session) return
103
try {
104
const response = await fetch('/xrpc/_delegation.listControlledAccounts', {
105
+
headers: { 'Authorization': `Bearer ${session.accessJwt}` }
106
})
107
if (response.ok) {
108
const data = await response.json()
···
126
}
127
128
async function addController() {
129
+
if (!session || !addControllerDid.trim()) return
130
addingController = true
131
132
try {
133
const response = await fetch('/xrpc/_delegation.addController', {
134
method: 'POST',
135
headers: {
136
+
'Authorization': `Bearer ${session.accessJwt}`,
137
'Content-Type': 'application/json'
138
},
139
body: JSON.stringify({
···
144
145
if (!response.ok) {
146
const data = await response.json()
147
+
toast.error(data.message || data.error || $_('delegation.failedToAddController'))
148
return
149
}
150
151
+
toast.success($_('delegation.controllerAdded'))
152
addControllerDid = ''
153
addControllerScopes = 'atproto'
154
showAddController = false
155
await loadControllers()
156
} catch (e) {
157
+
toast.error($_('delegation.failedToAddController'))
158
} finally {
159
addingController = false
160
}
161
}
162
163
async function removeController(controllerDid: string) {
164
+
if (!session) return
165
if (!confirm($_('delegation.removeConfirm'))) return
166
167
try {
168
const response = await fetch('/xrpc/_delegation.removeController', {
169
method: 'POST',
170
headers: {
171
+
'Authorization': `Bearer ${session.accessJwt}`,
172
'Content-Type': 'application/json'
173
},
174
body: JSON.stringify({ controller_did: controllerDid })
···
176
177
if (!response.ok) {
178
const data = await response.json()
179
+
toast.error(data.message || data.error || $_('delegation.failedToRemoveController'))
180
return
181
}
182
183
+
toast.success($_('delegation.controllerRemoved'))
184
await loadControllers()
185
} catch (e) {
186
+
toast.error($_('delegation.failedToRemoveController'))
187
}
188
}
189
190
async function createDelegatedAccount() {
191
+
if (!session || !newDelegatedHandle.trim()) return
192
creatingDelegated = true
193
194
try {
195
const response = await fetch('/xrpc/_delegation.createDelegatedAccount', {
196
method: 'POST',
197
headers: {
198
+
'Authorization': `Bearer ${session.accessJwt}`,
199
'Content-Type': 'application/json'
200
},
201
body: JSON.stringify({
···
207
208
if (!response.ok) {
209
const data = await response.json()
210
+
toast.error(data.message || data.error || $_('delegation.failedToCreateAccount'))
211
return
212
}
213
214
const data = await response.json()
215
+
toast.success($_('delegation.accountCreated', { values: { handle: data.handle } }))
216
newDelegatedHandle = ''
217
newDelegatedEmail = ''
218
newDelegatedScopes = 'atproto'
219
showCreateDelegated = false
220
await loadControlledAccounts()
221
} catch (e) {
222
+
toast.error($_('delegation.failedToCreateAccount'))
223
} finally {
224
creatingDelegated = false
225
}
···
241
</header>
242
243
{#if loading}
244
+
<div class="skeleton-list">
245
+
{#each Array(2) as _}
246
+
<div class="skeleton-card"></div>
247
+
{/each}
248
+
</div>
249
{:else}
250
<section class="section">
251
<div class="section-header">
252
<h2>{$_('delegation.controllers')}</h2>
···
676
.form-actions button {
677
padding: var(--space-2) var(--space-4);
678
font-size: var(--text-sm);
679
+
}
680
+
681
+
.skeleton-list {
682
+
display: flex;
683
+
flex-direction: column;
684
+
gap: var(--space-4);
685
+
}
686
+
687
+
.skeleton-card {
688
+
height: 120px;
689
+
background: var(--bg-secondary);
690
+
border: 1px solid var(--border-color);
691
+
border-radius: var(--radius-xl);
692
+
animation: skeleton-pulse 1.5s ease-in-out infinite;
693
+
}
694
+
695
+
@keyframes skeleton-pulse {
696
+
0%, 100% { opacity: 1; }
697
+
50% { opacity: 0.5; }
698
}
699
</style>
+106
-66
frontend/src/routes/Dashboard.svelte
+106
-66
frontend/src/routes/Dashboard.svelte
···
1
<script lang="ts">
2
-
import { getAuthState, logout, switchAccount } from '../lib/auth.svelte'
3
-
import { navigate } from '../lib/router.svelte'
4
import { _ } from '../lib/i18n'
5
import { api } from '../lib/api'
6
import { onMount } from 'svelte'
7
8
-
const auth = getAuthState()
9
let dropdownOpen = $state(false)
10
let switching = $state(false)
11
let inviteCodesEnabled = $state(false)
12
13
-
const isDidWeb = $derived(auth.session?.did?.startsWith('did:web:') ?? false)
14
15
onMount(async () => {
16
try {
···
22
})
23
24
$effect(() => {
25
-
if (!auth.loading && !auth.session) {
26
-
navigate('/login')
27
}
28
})
29
30
async function handleLogout() {
31
await logout()
32
-
navigate('/login')
33
}
34
35
-
async function handleSwitchAccount(did: string) {
36
switching = true
37
dropdownOpen = false
38
-
try {
39
-
await switchAccount(did)
40
-
} catch {
41
-
navigate('/login')
42
-
} finally {
43
-
switching = false
44
}
45
}
46
47
function toggleDropdown() {
···
61
return () => document.removeEventListener('click', closeDropdown)
62
}
63
})
64
-
65
-
let otherAccounts = $derived(
66
-
auth.savedAccounts.filter(a => a.did !== auth.session?.did)
67
-
)
68
</script>
69
70
-
{#if auth.session}
71
<div class="dashboard">
72
<header>
73
<h1>{$_('dashboard.title')}</h1>
74
<div class="account-dropdown">
75
<button class="account-trigger" onclick={toggleDropdown} disabled={switching}>
76
-
<span class="account-handle">@{auth.session.handle}</span>
77
<span class="dropdown-arrow">{dropdownOpen ? '▲' : '▼'}</span>
78
</button>
79
{#if dropdownOpen}
···
89
</div>
90
<div class="dropdown-divider"></div>
91
{/if}
92
-
<button type="button" class="dropdown-item" onclick={() => { dropdownOpen = false; navigate('/login') }}>
93
{$_('dashboard.addAnotherAccount')}
94
</button>
95
<div class="dropdown-divider"></div>
96
<button type="button" class="dropdown-item logout-item" onclick={handleLogout}>
97
-
{$_('dashboard.signOut', { values: { handle: auth.session.handle } })}
98
</button>
99
</div>
100
{/if}
101
</div>
102
</header>
103
104
-
{#if auth.session.status === 'migrated'}
105
<div class="migrated-banner">
106
<strong>{$_('dashboard.migratedTitle')}</strong>
107
-
<p>{$_('dashboard.migratedMessage', { values: { pds: auth.session.migratedToPds || 'another PDS' } })}</p>
108
</div>
109
-
{:else if auth.session.status === 'deactivated' || auth.session.active === false}
110
<div class="deactivated-banner">
111
<strong>{$_('dashboard.deactivatedTitle')}</strong>
112
<p>{$_('dashboard.deactivatedMessage')}</p>
···
118
<dl>
119
<dt>{$_('dashboard.handle')}</dt>
120
<dd>
121
-
@{auth.session.handle}
122
-
{#if auth.session.isAdmin}
123
<span class="badge admin">{$_('dashboard.admin')}</span>
124
{/if}
125
-
{#if auth.session.status === 'migrated'}
126
<span class="badge migrated">{$_('dashboard.migrated')}</span>
127
-
{:else if auth.session.status === 'deactivated' || auth.session.active === false}
128
<span class="badge deactivated">{$_('dashboard.deactivated')}</span>
129
{/if}
130
</dd>
131
<dt>{$_('dashboard.did')}</dt>
132
-
<dd class="mono">{auth.session.did}</dd>
133
-
{#if auth.session.preferredChannel}
134
<dt>{$_('dashboard.primaryContact')}</dt>
135
<dd>
136
-
{#if auth.session.preferredChannel === 'email'}
137
-
{auth.session.email || $_('register.email')}
138
-
{:else if auth.session.preferredChannel === 'discord'}
139
{$_('register.discord')}
140
-
{:else if auth.session.preferredChannel === 'telegram'}
141
{$_('register.telegram')}
142
-
{:else if auth.session.preferredChannel === 'signal'}
143
{$_('register.signal')}
144
{:else}
145
-
{auth.session.preferredChannel}
146
{/if}
147
-
{#if auth.session.preferredChannelVerified}
148
<span class="badge success">{$_('dashboard.verified')}</span>
149
{:else}
150
<span class="badge warning">{$_('dashboard.unverified')}</span>
151
{/if}
152
</dd>
153
-
{:else if auth.session.email}
154
<dt>{$_('register.email')}</dt>
155
<dd>
156
-
{auth.session.email}
157
-
{#if auth.session.emailConfirmed}
158
<span class="badge success">{$_('dashboard.verified')}</span>
159
{:else}
160
<span class="badge warning">{$_('dashboard.unverified')}</span>
···
165
</section>
166
167
<nav class="nav-grid">
168
-
{#if auth.session.status === 'migrated'}
169
-
<a href="/app/did-document" class="nav-card migrated-card">
170
<h3>{$_('dashboard.navDidDocument')}</h3>
171
<p>{$_('dashboard.navDidDocumentDesc')}</p>
172
</a>
173
-
<a href="/app/sessions" class="nav-card">
174
<h3>{$_('dashboard.navSessions')}</h3>
175
<p>{$_('dashboard.navSessionsDesc')}</p>
176
</a>
177
-
<a href="/app/security" class="nav-card">
178
<h3>{$_('dashboard.navSecurity')}</h3>
179
<p>{$_('dashboard.navSecurityDesc')}</p>
180
</a>
181
-
<a href="/app/settings" class="nav-card">
182
<h3>{$_('dashboard.navSettings')}</h3>
183
<p>{$_('dashboard.navSettingsDesc')}</p>
184
</a>
185
-
<a href="/app/migrate" class="nav-card">
186
<h3>{$_('dashboard.navMigrateAgain')}</h3>
187
<p>{$_('dashboard.navMigrateAgainDesc')}</p>
188
</a>
189
{:else}
190
-
<a href="/app/app-passwords" class="nav-card">
191
<h3>{$_('dashboard.navAppPasswords')}</h3>
192
<p>{$_('dashboard.navAppPasswordsDesc')}</p>
193
</a>
194
-
<a href="/app/sessions" class="nav-card">
195
<h3>{$_('dashboard.navSessions')}</h3>
196
<p>{$_('dashboard.navSessionsDesc')}</p>
197
</a>
198
-
{#if inviteCodesEnabled && auth.session.isAdmin}
199
-
<a href="/app/invite-codes" class="nav-card">
200
<h3>{$_('dashboard.navInviteCodes')}</h3>
201
<p>{$_('dashboard.navInviteCodesDesc')}</p>
202
</a>
203
{/if}
204
-
<a href="/app/settings" class="nav-card">
205
<h3>{$_('dashboard.navSettings')}</h3>
206
<p>{$_('dashboard.navSettingsDesc')}</p>
207
</a>
208
-
<a href="/app/security" class="nav-card">
209
<h3>{$_('dashboard.navSecurity')}</h3>
210
<p>{$_('dashboard.navSecurityDesc')}</p>
211
</a>
212
-
<a href="/app/comms" class="nav-card">
213
<h3>{$_('dashboard.navComms')}</h3>
214
<p>{$_('dashboard.navCommsDesc')}</p>
215
</a>
216
-
<a href="/app/repo" class="nav-card">
217
<h3>{$_('dashboard.navRepo')}</h3>
218
<p>{$_('dashboard.navRepoDesc')}</p>
219
</a>
220
-
<a href="/app/controllers" class="nav-card">
221
<h3>{$_('dashboard.navDelegation')}</h3>
222
<p>{$_('dashboard.navDelegationDesc')}</p>
223
</a>
224
{#if isDidWeb}
225
-
<a href="/app/did-document" class="nav-card did-web-card">
226
<h3>{$_('dashboard.navDidDocument')}</h3>
227
<p>{$_('dashboard.navDidDocumentDescActive')}</p>
228
</a>
229
{/if}
230
-
<a href="/app/migrate" class="nav-card">
231
<h3>{$_('migration.navTitle')}</h3>
232
<p>{$_('migration.navDesc')}</p>
233
</a>
234
-
{#if auth.session.isAdmin}
235
-
<a href="/app/admin" class="nav-card admin-card">
236
<h3>{$_('dashboard.navAdmin')}</h3>
237
<p>{$_('dashboard.navAdminDesc')}</p>
238
</a>
···
240
{/if}
241
</nav>
242
</div>
243
-
{:else if auth.loading}
244
-
<div class="loading">{$_('common.loading')}</div>
245
{/if}
246
247
<style>
···
460
box-shadow: 0 2px 12px var(--accent-muted);
461
}
462
463
-
.loading {
464
-
text-align: center;
465
-
padding: var(--space-9);
466
-
color: var(--text-secondary);
467
}
468
469
.deactivated-banner {
···
1
<script lang="ts">
2
+
import {
3
+
getAuthState,
4
+
logout,
5
+
switchAccount,
6
+
type SavedAccount,
7
+
} from '../lib/auth.svelte'
8
+
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
9
import { _ } from '../lib/i18n'
10
import { api } from '../lib/api'
11
+
import { isOk } from '../lib/types/result'
12
+
import { unsafeAsDid, type Did } from '../lib/types/branded'
13
+
import type { Session } from '../lib/types/api'
14
import { onMount } from 'svelte'
15
16
+
const auth = $derived(getAuthState())
17
let dropdownOpen = $state(false)
18
let switching = $state(false)
19
let inviteCodesEnabled = $state(false)
20
21
+
function getSession(): Session | null {
22
+
return auth.kind === 'authenticated' ? auth.session : null
23
+
}
24
+
25
+
function getSavedAccounts(): readonly SavedAccount[] {
26
+
return auth.savedAccounts
27
+
}
28
+
29
+
function isLoading(): boolean {
30
+
return auth.kind === 'loading'
31
+
}
32
+
33
+
const session = $derived(getSession())
34
+
const savedAccounts = $derived(getSavedAccounts())
35
+
const loading = $derived(isLoading())
36
+
const isDidWeb = $derived(session?.did?.startsWith('did:web:') ?? false)
37
+
const otherAccounts = $derived(savedAccounts.filter(a => a.did !== session?.did))
38
39
onMount(async () => {
40
try {
···
46
})
47
48
$effect(() => {
49
+
if (!loading && !session) {
50
+
navigate(routes.login)
51
}
52
})
53
54
async function handleLogout() {
55
await logout()
56
+
navigate(routes.login)
57
}
58
59
+
async function handleSwitchAccount(did: Did) {
60
switching = true
61
dropdownOpen = false
62
+
const result = await switchAccount(did)
63
+
if (!isOk(result)) {
64
+
navigate(routes.login)
65
}
66
+
switching = false
67
}
68
69
function toggleDropdown() {
···
83
return () => document.removeEventListener('click', closeDropdown)
84
}
85
})
86
</script>
87
88
+
{#if session}
89
<div class="dashboard">
90
<header>
91
<h1>{$_('dashboard.title')}</h1>
92
<div class="account-dropdown">
93
<button class="account-trigger" onclick={toggleDropdown} disabled={switching}>
94
+
<span class="account-handle">@{session.handle}</span>
95
<span class="dropdown-arrow">{dropdownOpen ? '▲' : '▼'}</span>
96
</button>
97
{#if dropdownOpen}
···
107
</div>
108
<div class="dropdown-divider"></div>
109
{/if}
110
+
<button type="button" class="dropdown-item" onclick={() => { dropdownOpen = false; navigate(routes.login) }}>
111
{$_('dashboard.addAnotherAccount')}
112
</button>
113
<div class="dropdown-divider"></div>
114
<button type="button" class="dropdown-item logout-item" onclick={handleLogout}>
115
+
{$_('dashboard.signOut', { values: { handle: session.handle } })}
116
</button>
117
</div>
118
{/if}
119
</div>
120
</header>
121
122
+
{#if session.status === 'migrated'}
123
<div class="migrated-banner">
124
<strong>{$_('dashboard.migratedTitle')}</strong>
125
+
<p>{$_('dashboard.migratedMessage', { values: { pds: session.migratedToPds || 'another PDS' } })}</p>
126
</div>
127
+
{:else if session.status === 'deactivated' || session.active === false}
128
<div class="deactivated-banner">
129
<strong>{$_('dashboard.deactivatedTitle')}</strong>
130
<p>{$_('dashboard.deactivatedMessage')}</p>
···
136
<dl>
137
<dt>{$_('dashboard.handle')}</dt>
138
<dd>
139
+
@{session.handle}
140
+
{#if session.isAdmin}
141
<span class="badge admin">{$_('dashboard.admin')}</span>
142
{/if}
143
+
{#if session.status === 'migrated'}
144
<span class="badge migrated">{$_('dashboard.migrated')}</span>
145
+
{:else if session.status === 'deactivated' || session.active === false}
146
<span class="badge deactivated">{$_('dashboard.deactivated')}</span>
147
{/if}
148
</dd>
149
<dt>{$_('dashboard.did')}</dt>
150
+
<dd class="mono">{session.did}</dd>
151
+
{#if session.preferredChannel}
152
<dt>{$_('dashboard.primaryContact')}</dt>
153
<dd>
154
+
{#if session.preferredChannel === 'email'}
155
+
{session.email || $_('register.email')}
156
+
{:else if session.preferredChannel === 'discord'}
157
{$_('register.discord')}
158
+
{:else if session.preferredChannel === 'telegram'}
159
{$_('register.telegram')}
160
+
{:else if session.preferredChannel === 'signal'}
161
{$_('register.signal')}
162
{:else}
163
+
{session.preferredChannel}
164
{/if}
165
+
{#if session.preferredChannelVerified}
166
<span class="badge success">{$_('dashboard.verified')}</span>
167
{:else}
168
<span class="badge warning">{$_('dashboard.unverified')}</span>
169
{/if}
170
</dd>
171
+
{:else if session.email}
172
<dt>{$_('register.email')}</dt>
173
<dd>
174
+
{session.email}
175
+
{#if session.emailConfirmed}
176
<span class="badge success">{$_('dashboard.verified')}</span>
177
{:else}
178
<span class="badge warning">{$_('dashboard.unverified')}</span>
···
183
</section>
184
185
<nav class="nav-grid">
186
+
{#if session.status === 'migrated'}
187
+
<a href={getFullUrl(routes.didDocument)} class="nav-card migrated-card">
188
<h3>{$_('dashboard.navDidDocument')}</h3>
189
<p>{$_('dashboard.navDidDocumentDesc')}</p>
190
</a>
191
+
<a href={getFullUrl(routes.sessions)} class="nav-card">
192
<h3>{$_('dashboard.navSessions')}</h3>
193
<p>{$_('dashboard.navSessionsDesc')}</p>
194
</a>
195
+
<a href={getFullUrl(routes.security)} class="nav-card">
196
<h3>{$_('dashboard.navSecurity')}</h3>
197
<p>{$_('dashboard.navSecurityDesc')}</p>
198
</a>
199
+
<a href={getFullUrl(routes.settings)} class="nav-card">
200
<h3>{$_('dashboard.navSettings')}</h3>
201
<p>{$_('dashboard.navSettingsDesc')}</p>
202
</a>
203
+
<a href={getFullUrl(routes.migrate)} class="nav-card">
204
<h3>{$_('dashboard.navMigrateAgain')}</h3>
205
<p>{$_('dashboard.navMigrateAgainDesc')}</p>
206
</a>
207
{:else}
208
+
<a href={getFullUrl(routes.appPasswords)} class="nav-card">
209
<h3>{$_('dashboard.navAppPasswords')}</h3>
210
<p>{$_('dashboard.navAppPasswordsDesc')}</p>
211
</a>
212
+
<a href={getFullUrl(routes.sessions)} class="nav-card">
213
<h3>{$_('dashboard.navSessions')}</h3>
214
<p>{$_('dashboard.navSessionsDesc')}</p>
215
</a>
216
+
{#if inviteCodesEnabled && session.isAdmin}
217
+
<a href={getFullUrl(routes.inviteCodes)} class="nav-card">
218
<h3>{$_('dashboard.navInviteCodes')}</h3>
219
<p>{$_('dashboard.navInviteCodesDesc')}</p>
220
</a>
221
{/if}
222
+
<a href={getFullUrl(routes.settings)} class="nav-card">
223
<h3>{$_('dashboard.navSettings')}</h3>
224
<p>{$_('dashboard.navSettingsDesc')}</p>
225
</a>
226
+
<a href={getFullUrl(routes.security)} class="nav-card">
227
<h3>{$_('dashboard.navSecurity')}</h3>
228
<p>{$_('dashboard.navSecurityDesc')}</p>
229
</a>
230
+
<a href={getFullUrl(routes.comms)} class="nav-card">
231
<h3>{$_('dashboard.navComms')}</h3>
232
<p>{$_('dashboard.navCommsDesc')}</p>
233
</a>
234
+
<a href={getFullUrl(routes.repo)} class="nav-card">
235
<h3>{$_('dashboard.navRepo')}</h3>
236
<p>{$_('dashboard.navRepoDesc')}</p>
237
</a>
238
+
<a href={getFullUrl(routes.controllers)} class="nav-card">
239
<h3>{$_('dashboard.navDelegation')}</h3>
240
<p>{$_('dashboard.navDelegationDesc')}</p>
241
</a>
242
{#if isDidWeb}
243
+
<a href={getFullUrl(routes.didDocument)} class="nav-card did-web-card">
244
<h3>{$_('dashboard.navDidDocument')}</h3>
245
<p>{$_('dashboard.navDidDocumentDescActive')}</p>
246
</a>
247
{/if}
248
+
<a href={getFullUrl(routes.migrate)} class="nav-card">
249
<h3>{$_('migration.navTitle')}</h3>
250
<p>{$_('migration.navDesc')}</p>
251
</a>
252
+
{#if session.isAdmin}
253
+
<a href={getFullUrl(routes.admin)} class="nav-card admin-card">
254
<h3>{$_('dashboard.navAdmin')}</h3>
255
<p>{$_('dashboard.navAdminDesc')}</p>
256
</a>
···
258
{/if}
259
</nav>
260
</div>
261
+
{:else if loading}
262
+
<div class="dashboard">
263
+
<div class="skeleton-section"></div>
264
+
<nav class="nav-grid">
265
+
{#each Array(6) as _}
266
+
<div class="skeleton-card"></div>
267
+
{/each}
268
+
</nav>
269
+
</div>
270
{/if}
271
272
<style>
···
485
box-shadow: 0 2px 12px var(--accent-muted);
486
}
487
488
+
.skeleton-section {
489
+
height: 140px;
490
+
background: var(--bg-secondary);
491
+
border-radius: var(--radius-xl);
492
+
margin-bottom: var(--space-7);
493
+
animation: skeleton-pulse 1.5s ease-in-out infinite;
494
+
}
495
+
496
+
.skeleton-card {
497
+
height: 100px;
498
+
background: var(--bg-tertiary);
499
+
border: 1px solid var(--border-color);
500
+
border-radius: var(--radius-xl);
501
+
animation: skeleton-pulse 1.5s ease-in-out infinite;
502
+
}
503
+
504
+
@keyframes skeleton-pulse {
505
+
0%, 100% { opacity: 1; }
506
+
50% { opacity: 0.5; }
507
}
508
509
.deactivated-banner {
+50
-22
frontend/src/routes/DelegationAudit.svelte
+50
-22
frontend/src/routes/DelegationAudit.svelte
···
1
<script lang="ts">
2
import { getAuthState } from '../lib/auth.svelte'
3
-
import { navigate } from '../lib/router.svelte'
4
import { _ } from '../lib/i18n'
5
import { formatDateTime } from '../lib/date'
6
7
interface AuditEntry {
8
id: string
···
14
createdAt: string
15
}
16
17
-
const auth = getAuthState()
18
let loading = $state(true)
19
-
let error = $state<string | null>(null)
20
let entries = $state<AuditEntry[]>([])
21
let total = $state(0)
22
let offset = $state(0)
23
const limit = 20
24
25
$effect(() => {
26
-
if (!auth.loading && !auth.session) {
27
-
navigate('/login')
28
}
29
})
30
31
$effect(() => {
32
-
if (auth.session) {
33
loadAuditLog()
34
}
35
})
36
37
async function loadAuditLog() {
38
-
if (!auth.session) return
39
loading = true
40
-
error = null
41
42
try {
43
const response = await fetch(
44
`/xrpc/_delegation.getAuditLog?limit=${limit}&offset=${offset}`,
45
{
46
-
headers: { 'Authorization': `Bearer ${auth.session.accessJwt}` }
47
}
48
)
49
50
if (!response.ok) {
51
const data = await response.json()
52
-
error = data.message || data.error || $_('delegation.failedToLoadAuditLog')
53
return
54
}
55
···
57
entries = data.entries || []
58
total = data.total || 0
59
} catch (e) {
60
-
error = $_('delegation.failedToLoadAuditLog')
61
} finally {
62
loading = false
63
}
···
92
93
function formatActionDetails(details: Record<string, unknown> | null): string {
94
if (!details) return ''
95
-
const parts: string[] = []
96
-
for (const [key, value] of Object.entries(details)) {
97
-
const formattedKey = key.replace(/_/g, ' ')
98
-
parts.push(`${formattedKey}: ${JSON.stringify(value)}`)
99
-
}
100
-
return parts.join(', ')
101
}
102
103
function truncateDid(did: string): string {
···
113
</header>
114
115
{#if loading}
116
-
<p class="loading">{$_('delegation.loading')}</p>
117
{:else}
118
-
{#if error}
119
-
<div class="message error">{error}</div>
120
-
{/if}
121
-
122
{#if entries.length === 0}
123
<p class="empty">{$_('delegation.noActivity')}</p>
124
{:else}
···
318
.actions-bar button {
319
padding: var(--space-2) var(--space-4);
320
font-size: var(--text-sm);
321
}
322
</style>
···
1
<script lang="ts">
2
import { getAuthState } from '../lib/auth.svelte'
3
+
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
4
import { _ } from '../lib/i18n'
5
import { formatDateTime } from '../lib/date'
6
+
import type { Session } from '../lib/types/api'
7
+
import { toast } from '../lib/toast.svelte'
8
9
interface AuditEntry {
10
id: string
···
16
createdAt: string
17
}
18
19
+
const auth = $derived(getAuthState())
20
+
21
+
function getSession(): Session | null {
22
+
return auth.kind === 'authenticated' ? auth.session : null
23
+
}
24
+
25
+
function isLoading(): boolean {
26
+
return auth.kind === 'loading'
27
+
}
28
+
29
+
const session = $derived(getSession())
30
+
const authLoading = $derived(isLoading())
31
+
32
let loading = $state(true)
33
let entries = $state<AuditEntry[]>([])
34
let total = $state(0)
35
let offset = $state(0)
36
const limit = 20
37
38
$effect(() => {
39
+
if (!authLoading && !session) {
40
+
navigate(routes.login)
41
}
42
})
43
44
$effect(() => {
45
+
if (session) {
46
loadAuditLog()
47
}
48
})
49
50
async function loadAuditLog() {
51
+
if (!session) return
52
loading = true
53
54
try {
55
const response = await fetch(
56
`/xrpc/_delegation.getAuditLog?limit=${limit}&offset=${offset}`,
57
{
58
+
headers: { 'Authorization': `Bearer ${session.accessJwt}` }
59
}
60
)
61
62
if (!response.ok) {
63
const data = await response.json()
64
+
toast.error(data.message || data.error || $_('delegation.failedToLoadAuditLog'))
65
return
66
}
67
···
69
entries = data.entries || []
70
total = data.total || 0
71
} catch (e) {
72
+
toast.error($_('delegation.failedToLoadAuditLog'))
73
} finally {
74
loading = false
75
}
···
104
105
function formatActionDetails(details: Record<string, unknown> | null): string {
106
if (!details) return ''
107
+
return Object.entries(details)
108
+
.map(([key, value]) => `${key.replace(/_/g, ' ')}: ${JSON.stringify(value)}`)
109
+
.join(', ')
110
}
111
112
function truncateDid(did: string): string {
···
122
</header>
123
124
{#if loading}
125
+
<div class="skeleton-list">
126
+
{#each Array(3) as _}
127
+
<div class="skeleton-entry"></div>
128
+
{/each}
129
+
</div>
130
{:else}
131
{#if entries.length === 0}
132
<p class="empty">{$_('delegation.noActivity')}</p>
133
{:else}
···
327
.actions-bar button {
328
padding: var(--space-2) var(--space-4);
329
font-size: var(--text-sm);
330
+
}
331
+
332
+
.skeleton-list {
333
+
display: flex;
334
+
flex-direction: column;
335
+
gap: var(--space-3);
336
+
}
337
+
338
+
.skeleton-entry {
339
+
height: 100px;
340
+
background: var(--bg-secondary);
341
+
border: 1px solid var(--border-color);
342
+
border-radius: var(--radius-lg);
343
+
animation: skeleton-pulse 1.5s ease-in-out infinite;
344
+
}
345
+
346
+
@keyframes skeleton-pulse {
347
+
0%, 100% { opacity: 1; }
348
+
50% { opacity: 0.5; }
349
}
350
</style>
+59
-28
frontend/src/routes/DidDocumentEditor.svelte
+59
-28
frontend/src/routes/DidDocumentEditor.svelte
···
1
<script lang="ts">
2
import { onMount } from 'svelte'
3
import { getAuthState } from '../lib/auth.svelte'
4
-
import { navigate } from '../lib/router.svelte'
5
import { api, ApiError, type VerificationMethod, type DidDocument } from '../lib/api'
6
import { _ } from '../lib/i18n'
7
8
-
const auth = getAuthState()
9
10
let loading = $state(true)
11
let saving = $state(false)
12
-
let message = $state<{ type: 'success' | 'error'; text: string } | null>(null)
13
let didDocument = $state<DidDocument | null>(null)
14
let verificationMethods = $state<VerificationMethod[]>([])
15
let alsoKnownAs = $state<string[]>([])
···
19
let newHandle = $state('')
20
21
$effect(() => {
22
-
if (!auth.loading && !auth.session) {
23
-
navigate('/login')
24
}
25
})
26
27
onMount(async () => {
28
-
if (!auth.session) return
29
try {
30
-
didDocument = await api.getDidDocument(auth.session.accessJwt)
31
verificationMethods = didDocument.verificationMethod.map(vm => ({
32
id: vm.id.replace(didDocument!.id, ''),
33
type: vm.type,
···
37
const pdsService = didDocument.service.find(s => s.id === '#atproto_pds')
38
serviceEndpoint = pdsService?.serviceEndpoint || ''
39
} catch (e) {
40
-
showMessage('error', e instanceof ApiError ? e.message : $_('didEditor.loadFailed'))
41
} finally {
42
loading = false
43
}
44
})
45
46
-
function showMessage(type: 'success' | 'error', text: string) {
47
-
message = { type, text }
48
-
setTimeout(() => {
49
-
if (message?.text === text) message = null
50
-
}, 5000)
51
-
}
52
-
53
function addVerificationMethod() {
54
if (!newKeyId || !newKeyPublic) return
55
if (!newKeyPublic.startsWith('z')) {
56
-
showMessage('error', $_('didEditor.invalidMultibase'))
57
return
58
}
59
verificationMethods = [...verificationMethods, {
···
72
function addHandle() {
73
if (!newHandle) return
74
if (!newHandle.startsWith('at://')) {
75
-
showMessage('error', $_('didEditor.invalidHandle'))
76
return
77
}
78
alsoKnownAs = [...alsoKnownAs, newHandle]
···
84
}
85
86
async function handleSave() {
87
-
if (!auth.session) return
88
saving = true
89
-
message = null
90
try {
91
-
await api.updateDidDocument(auth.session.accessJwt, {
92
verificationMethods: verificationMethods.length > 0 ? verificationMethods : undefined,
93
alsoKnownAs: alsoKnownAs.length > 0 ? alsoKnownAs : undefined,
94
serviceEndpoint: serviceEndpoint || undefined
95
})
96
-
showMessage('success', $_('didEditor.success'))
97
-
didDocument = await api.getDidDocument(auth.session.accessJwt)
98
} catch (e) {
99
-
showMessage('error', e instanceof ApiError ? e.message : $_('didEditor.saveFailed'))
100
} finally {
101
saving = false
102
}
···
109
<h1>{$_('didEditor.title')}</h1>
110
</header>
111
112
-
{#if message}
113
-
<div class="message {message.type}">{message.text}</div>
114
-
{/if}
115
-
116
{#if loading}
117
-
<div class="loading">{$_('common.loading')}</div>
118
{:else}
119
<div class="help-section">
120
<h3>{$_('didEditor.helpTitle')}</h3>
···
453
.add-btn {
454
width: 100%;
455
}
456
}
457
</style>
···
1
<script lang="ts">
2
import { onMount } from 'svelte'
3
import { getAuthState } from '../lib/auth.svelte'
4
+
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
5
import { api, ApiError, type VerificationMethod, type DidDocument } from '../lib/api'
6
import { _ } from '../lib/i18n'
7
+
import type { Session } from '../lib/types/api'
8
+
import { toast } from '../lib/toast.svelte'
9
10
+
const auth = $derived(getAuthState())
11
+
12
+
function getSession(): Session | null {
13
+
return auth.kind === 'authenticated' ? auth.session : null
14
+
}
15
+
16
+
function isLoading(): boolean {
17
+
return auth.kind === 'loading'
18
+
}
19
+
20
+
const session = $derived(getSession())
21
+
const authLoading = $derived(isLoading())
22
23
let loading = $state(true)
24
let saving = $state(false)
25
let didDocument = $state<DidDocument | null>(null)
26
let verificationMethods = $state<VerificationMethod[]>([])
27
let alsoKnownAs = $state<string[]>([])
···
31
let newHandle = $state('')
32
33
$effect(() => {
34
+
if (!authLoading && !session) {
35
+
navigate(routes.login)
36
}
37
})
38
39
onMount(async () => {
40
+
if (!session) return
41
try {
42
+
didDocument = await api.getDidDocument(session.accessJwt)
43
verificationMethods = didDocument.verificationMethod.map(vm => ({
44
id: vm.id.replace(didDocument!.id, ''),
45
type: vm.type,
···
49
const pdsService = didDocument.service.find(s => s.id === '#atproto_pds')
50
serviceEndpoint = pdsService?.serviceEndpoint || ''
51
} catch (e) {
52
+
toast.error(e instanceof ApiError ? e.message : $_('didEditor.loadFailed'))
53
} finally {
54
loading = false
55
}
56
})
57
58
function addVerificationMethod() {
59
if (!newKeyId || !newKeyPublic) return
60
if (!newKeyPublic.startsWith('z')) {
61
+
toast.error($_('didEditor.invalidMultibase'))
62
return
63
}
64
verificationMethods = [...verificationMethods, {
···
77
function addHandle() {
78
if (!newHandle) return
79
if (!newHandle.startsWith('at://')) {
80
+
toast.error($_('didEditor.invalidHandle'))
81
return
82
}
83
alsoKnownAs = [...alsoKnownAs, newHandle]
···
89
}
90
91
async function handleSave() {
92
+
if (!session) return
93
saving = true
94
try {
95
+
await api.updateDidDocument(session.accessJwt, {
96
verificationMethods: verificationMethods.length > 0 ? verificationMethods : undefined,
97
alsoKnownAs: alsoKnownAs.length > 0 ? alsoKnownAs : undefined,
98
serviceEndpoint: serviceEndpoint || undefined
99
})
100
+
toast.success($_('didEditor.success'))
101
+
didDocument = await api.getDidDocument(session.accessJwt)
102
} catch (e) {
103
+
toast.error(e instanceof ApiError ? e.message : $_('didEditor.saveFailed'))
104
} finally {
105
saving = false
106
}
···
113
<h1>{$_('didEditor.title')}</h1>
114
</header>
115
116
{#if loading}
117
+
<div class="skeleton-sections">
118
+
<div class="skeleton-section small"></div>
119
+
<div class="skeleton-section large"></div>
120
+
<div class="skeleton-section"></div>
121
+
<div class="skeleton-section"></div>
122
+
</div>
123
{:else}
124
<div class="help-section">
125
<h3>{$_('didEditor.helpTitle')}</h3>
···
458
.add-btn {
459
width: 100%;
460
}
461
+
}
462
+
463
+
.skeleton-sections {
464
+
display: flex;
465
+
flex-direction: column;
466
+
gap: var(--space-6);
467
+
}
468
+
469
+
.skeleton-section {
470
+
height: 180px;
471
+
background: var(--bg-secondary);
472
+
border-radius: var(--radius-xl);
473
+
animation: skeleton-pulse 1.5s ease-in-out infinite;
474
+
}
475
+
476
+
.skeleton-section.small {
477
+
height: 80px;
478
+
}
479
+
480
+
.skeleton-section.large {
481
+
height: 250px;
482
+
}
483
+
484
+
@keyframes skeleton-pulse {
485
+
0%, 100% { opacity: 1; }
486
+
50% { opacity: 0.5; }
487
}
488
</style>
+44
-22
frontend/src/routes/InviteCodes.svelte
+44
-22
frontend/src/routes/InviteCodes.svelte
···
1
<script lang="ts">
2
import { getAuthState } from '../lib/auth.svelte'
3
-
import { navigate } from '../lib/router.svelte'
4
import { api, type InviteCode, ApiError } from '../lib/api'
5
import { _ } from '../lib/i18n'
6
import { formatDate } from '../lib/date'
7
import { onMount } from 'svelte'
8
9
-
const auth = getAuthState()
10
let codes = $state<InviteCode[]>([])
11
let loading = $state(true)
12
-
let error = $state<string | null>(null)
13
let creating = $state(false)
14
let createdCode = $state<string | null>(null)
15
let createdCodeCopied = $state(false)
···
21
const serverInfo = await api.describeServer()
22
inviteCodesEnabled = serverInfo.inviteCodeRequired
23
if (!serverInfo.inviteCodeRequired) {
24
-
navigate('/dashboard')
25
}
26
} catch {
27
-
navigate('/dashboard')
28
}
29
})
30
31
$effect(() => {
32
-
if (!auth.loading && !auth.session) {
33
-
navigate('/login')
34
}
35
})
36
$effect(() => {
37
-
if (auth.session && inviteCodesEnabled) {
38
loadCodes()
39
}
40
})
41
async function loadCodes() {
42
-
if (!auth.session) return
43
loading = true
44
-
error = null
45
try {
46
-
const result = await api.getAccountInviteCodes(auth.session.accessJwt)
47
codes = result.codes
48
} catch (e) {
49
-
error = e instanceof ApiError ? e.message : 'Failed to load invite codes'
50
} finally {
51
loading = false
52
}
53
}
54
async function handleCreate() {
55
-
if (!auth.session) return
56
creating = true
57
-
error = null
58
try {
59
-
const result = await api.createInviteCode(auth.session.accessJwt, 1)
60
createdCode = result.code
61
await loadCodes()
62
} catch (e) {
63
-
error = e instanceof ApiError ? e.message : 'Failed to create invite code'
64
} finally {
65
creating = false
66
}
···
87
</script>
88
<div class="page">
89
<header>
90
-
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
91
<h1>{$_('inviteCodes.title')}</h1>
92
</header>
93
<p class="description">
94
{$_('inviteCodes.description')}
95
</p>
96
-
{#if error}
97
-
<div class="error">{error}</div>
98
-
{/if}
99
{#if createdCode}
100
<div class="created-code">
101
<h3>{$_('inviteCodes.created')}</h3>
···
108
<button onclick={dismissCreated}>{$_('common.done')}</button>
109
</div>
110
{/if}
111
-
{#if auth.session?.isAdmin}
112
<section class="create-section">
113
<button onclick={handleCreate} disabled={creating}>
114
{creating ? $_('common.creating') : $_('inviteCodes.createNew')}
···
118
<section class="list-section">
119
<h2>{$_('inviteCodes.yourCodes')}</h2>
120
{#if loading}
121
-
<p class="empty">{$_('common.loading')}</p>
122
{:else if codes.length === 0}
123
<p class="empty">{$_('inviteCodes.noCodes')}</p>
124
{:else}
···
324
color: var(--text-secondary);
325
text-align: center;
326
padding: var(--space-7);
327
}
328
</style>
···
1
<script lang="ts">
2
import { getAuthState } from '../lib/auth.svelte'
3
+
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
4
import { api, type InviteCode, ApiError } from '../lib/api'
5
import { _ } from '../lib/i18n'
6
import { formatDate } from '../lib/date'
7
import { onMount } from 'svelte'
8
+
import type { Session } from '../lib/types/api'
9
+
import { toast } from '../lib/toast.svelte'
10
11
+
const auth = $derived(getAuthState())
12
+
13
+
function getSession(): Session | null {
14
+
return auth.kind === 'authenticated' ? auth.session : null
15
+
}
16
+
17
+
function isLoading(): boolean {
18
+
return auth.kind === 'loading'
19
+
}
20
+
21
+
const session = $derived(getSession())
22
+
const authLoading = $derived(isLoading())
23
let codes = $state<InviteCode[]>([])
24
let loading = $state(true)
25
let creating = $state(false)
26
let createdCode = $state<string | null>(null)
27
let createdCodeCopied = $state(false)
···
33
const serverInfo = await api.describeServer()
34
inviteCodesEnabled = serverInfo.inviteCodeRequired
35
if (!serverInfo.inviteCodeRequired) {
36
+
navigate(routes.dashboard)
37
}
38
} catch {
39
+
navigate(routes.dashboard)
40
}
41
})
42
43
$effect(() => {
44
+
if (!authLoading && !session) {
45
+
navigate(routes.login)
46
}
47
})
48
$effect(() => {
49
+
if (session && inviteCodesEnabled) {
50
loadCodes()
51
}
52
})
53
async function loadCodes() {
54
+
if (!session) return
55
loading = true
56
try {
57
+
const result = await api.getAccountInviteCodes(session.accessJwt)
58
codes = result.codes
59
} catch (e) {
60
+
toast.error(e instanceof ApiError ? e.message : $_('inviteCodes.failedToLoad'))
61
} finally {
62
loading = false
63
}
64
}
65
async function handleCreate() {
66
+
if (!session) return
67
creating = true
68
try {
69
+
const result = await api.createInviteCode(session.accessJwt, 1)
70
createdCode = result.code
71
await loadCodes()
72
} catch (e) {
73
+
toast.error(e instanceof ApiError ? e.message : $_('inviteCodes.failedToCreate'))
74
} finally {
75
creating = false
76
}
···
97
</script>
98
<div class="page">
99
<header>
100
+
<a href={getFullUrl(routes.dashboard)} class="back">{$_('common.backToDashboard')}</a>
101
<h1>{$_('inviteCodes.title')}</h1>
102
</header>
103
<p class="description">
104
{$_('inviteCodes.description')}
105
</p>
106
{#if createdCode}
107
<div class="created-code">
108
<h3>{$_('inviteCodes.created')}</h3>
···
115
<button onclick={dismissCreated}>{$_('common.done')}</button>
116
</div>
117
{/if}
118
+
{#if session?.isAdmin}
119
<section class="create-section">
120
<button onclick={handleCreate} disabled={creating}>
121
{creating ? $_('common.creating') : $_('inviteCodes.createNew')}
···
125
<section class="list-section">
126
<h2>{$_('inviteCodes.yourCodes')}</h2>
127
{#if loading}
128
+
<ul class="code-list">
129
+
{#each Array(2) as _}
130
+
<li class="skeleton-item"></li>
131
+
{/each}
132
+
</ul>
133
{:else if codes.length === 0}
134
<p class="empty">{$_('inviteCodes.noCodes')}</p>
135
{:else}
···
335
color: var(--text-secondary);
336
text-align: center;
337
padding: var(--space-7);
338
+
}
339
+
340
+
.skeleton-item {
341
+
height: 50px;
342
+
background: var(--bg-tertiary);
343
+
animation: skeleton-pulse 1.5s ease-in-out infinite;
344
+
}
345
+
346
+
@keyframes skeleton-pulse {
347
+
0%, 100% { opacity: 1; }
348
+
50% { opacity: 0.5; }
349
}
350
</style>
+73
-35
frontend/src/routes/Login.svelte
+73
-35
frontend/src/routes/Login.svelte
···
1
<script lang="ts">
2
-
import { loginWithOAuth, confirmSignup, resendVerification, getAuthState, switchAccount, forgetAccount } from '../lib/auth.svelte'
3
-
import { navigate } from '../lib/router.svelte'
4
import { _ } from '../lib/i18n'
5
6
let submitting = $state(false)
7
-
let pendingVerification = $state<{ did: string } | null>(null)
8
let verificationCode = $state('')
9
let resendingCode = $state(false)
10
let resendMessage = $state<string | null>(null)
11
let autoRedirectAttempted = $state(false)
12
-
const auth = getAuthState()
13
14
$effect(() => {
15
-
if (!auth.loading && !auth.error && auth.savedAccounts.length === 0 && !pendingVerification && !autoRedirectAttempted) {
16
autoRedirectAttempted = true
17
loginWithOAuth()
18
}
19
})
20
21
-
async function handleSwitchAccount(did: string) {
22
submitting = true
23
-
try {
24
-
await switchAccount(did)
25
-
navigate('/dashboard')
26
-
} catch {
27
submitting = false
28
}
29
}
30
31
-
function handleForgetAccount(did: string, e: Event) {
32
e.stopPropagation()
33
forgetAccount(did)
34
}
35
36
async function handleOAuthLogin() {
37
submitting = true
38
-
try {
39
-
await loginWithOAuth()
40
-
} catch {
41
submitting = false
42
}
43
}
44
45
async function handleVerification(e: Event) {
46
e.preventDefault()
47
-
if (!pendingVerification || !verificationCode.trim()) return
48
submitting = true
49
-
try {
50
-
await confirmSignup(pendingVerification.did, verificationCode.trim())
51
-
navigate('/dashboard')
52
-
} catch {
53
submitting = false
54
}
55
}
56
57
async function handleResendCode() {
58
-
if (!pendingVerification || resendingCode) return
59
resendingCode = true
60
resendMessage = null
61
-
try {
62
-
await resendVerification(pendingVerification.did)
63
resendMessage = $_('verification.resent')
64
-
} catch {
65
-
resendMessage = null
66
-
} finally {
67
-
resendingCode = false
68
}
69
}
70
71
function backToLogin() {
72
-
pendingVerification = null
73
verificationCode = ''
74
resendMessage = null
75
}
76
</script>
77
78
<div class="login-page">
79
-
{#if auth.error}
80
-
<div class="message error">{auth.error}</div>
81
{/if}
82
83
-
{#if pendingVerification}
84
<header class="page-header">
85
<h1>{$_('verification.title')}</h1>
86
<p class="subtitle">{$_('verification.subtitle')}</p>
···
121
{:else}
122
<header class="page-header">
123
<h1>{$_('login.title')}</h1>
124
-
<p class="subtitle">{auth.savedAccounts.length > 0 ? $_('login.chooseAccount') : $_('login.subtitle')}</p>
125
</header>
126
127
<div class="split-layout sidebar-right">
128
<div class="main-section">
129
-
{#if auth.savedAccounts.length > 0}
130
<div class="saved-accounts">
131
-
{#each auth.savedAccounts as account}
132
<div
133
class="account-item"
134
class:disabled={submitting}
···
156
<p class="or-divider">{$_('login.signInToAnother')}</p>
157
{/if}
158
159
-
<button type="button" class="oauth-btn" onclick={handleOAuthLogin} disabled={submitting || auth.loading}>
160
{submitting ? $_('login.redirecting') : $_('login.button')}
161
</button>
162
···
172
</div>
173
174
<aside class="info-panel">
175
-
{#if auth.savedAccounts.length > 0}
176
<h3>{$_('login.infoSavedAccountsTitle')}</h3>
177
<p>{$_('login.infoSavedAccountsDesc')}</p>
178
···
1
<script lang="ts">
2
+
import {
3
+
loginWithOAuth,
4
+
confirmSignup,
5
+
resendVerification,
6
+
getAuthState,
7
+
switchAccount,
8
+
forgetAccount,
9
+
matchAuthState,
10
+
type SavedAccount,
11
+
type AuthError,
12
+
} from '../lib/auth.svelte'
13
+
import { navigate, routes } from '../lib/router.svelte'
14
import { _ } from '../lib/i18n'
15
+
import { isOk, isErr } from '../lib/types/result'
16
+
import { unsafeAsDid, type Did } from '../lib/types/branded'
17
18
+
type PageState =
19
+
| { kind: 'login' }
20
+
| { kind: 'verification'; did: string }
21
+
22
+
let pageState = $state<PageState>({ kind: 'login' })
23
let submitting = $state(false)
24
let verificationCode = $state('')
25
let resendingCode = $state(false)
26
let resendMessage = $state<string | null>(null)
27
let autoRedirectAttempted = $state(false)
28
+
29
+
const auth = $derived(getAuthState())
30
+
31
+
function getSavedAccounts(): readonly SavedAccount[] {
32
+
return auth.savedAccounts
33
+
}
34
+
35
+
function getErrorMessage(): string | null {
36
+
if (auth.kind === 'error') {
37
+
return auth.error.message
38
+
}
39
+
return null
40
+
}
41
+
42
+
function isLoading(): boolean {
43
+
return auth.kind === 'loading'
44
+
}
45
46
$effect(() => {
47
+
const accounts = getSavedAccounts()
48
+
const loading = isLoading()
49
+
const hasError = auth.kind === 'error'
50
+
51
+
if (!loading && !hasError && accounts.length === 0 && pageState.kind === 'login' && !autoRedirectAttempted) {
52
autoRedirectAttempted = true
53
loginWithOAuth()
54
}
55
})
56
57
+
async function handleSwitchAccount(did: Did) {
58
submitting = true
59
+
const result = await switchAccount(did)
60
+
if (isOk(result)) {
61
+
navigate(routes.dashboard)
62
+
} else {
63
submitting = false
64
}
65
}
66
67
+
function handleForgetAccount(did: Did, e: Event) {
68
e.stopPropagation()
69
forgetAccount(did)
70
}
71
72
async function handleOAuthLogin() {
73
submitting = true
74
+
const result = await loginWithOAuth()
75
+
if (isErr(result)) {
76
submitting = false
77
}
78
}
79
80
async function handleVerification(e: Event) {
81
e.preventDefault()
82
+
if (pageState.kind !== 'verification' || !verificationCode.trim()) return
83
+
84
submitting = true
85
+
const result = await confirmSignup(pageState.did, verificationCode.trim())
86
+
if (isOk(result)) {
87
+
navigate(routes.dashboard)
88
+
} else {
89
submitting = false
90
}
91
}
92
93
async function handleResendCode() {
94
+
if (pageState.kind !== 'verification' || resendingCode) return
95
+
96
resendingCode = true
97
resendMessage = null
98
+
const result = await resendVerification(pageState.did)
99
+
if (isOk(result)) {
100
resendMessage = $_('verification.resent')
101
}
102
+
resendingCode = false
103
}
104
105
function backToLogin() {
106
+
pageState = { kind: 'login' }
107
verificationCode = ''
108
resendMessage = null
109
}
110
+
111
+
const errorMessage = $derived(getErrorMessage())
112
+
const savedAccounts = $derived(getSavedAccounts())
113
+
const loading = $derived(isLoading())
114
</script>
115
116
<div class="login-page">
117
+
{#if errorMessage}
118
+
<div class="message error">{errorMessage}</div>
119
{/if}
120
121
+
{#if pageState.kind === 'verification'}
122
<header class="page-header">
123
<h1>{$_('verification.title')}</h1>
124
<p class="subtitle">{$_('verification.subtitle')}</p>
···
159
{:else}
160
<header class="page-header">
161
<h1>{$_('login.title')}</h1>
162
+
<p class="subtitle">{savedAccounts.length > 0 ? $_('login.chooseAccount') : $_('login.subtitle')}</p>
163
</header>
164
165
<div class="split-layout sidebar-right">
166
<div class="main-section">
167
+
{#if savedAccounts.length > 0}
168
<div class="saved-accounts">
169
+
{#each savedAccounts as account}
170
<div
171
class="account-item"
172
class:disabled={submitting}
···
194
<p class="or-divider">{$_('login.signInToAnother')}</p>
195
{/if}
196
197
+
<button type="button" class="oauth-btn" onclick={handleOAuthLogin} disabled={submitting || loading}>
198
{submitting ? $_('login.redirecting') : $_('login.button')}
199
</button>
200
···
210
</div>
211
212
<aside class="info-panel">
213
+
{#if savedAccounts.length > 0}
214
<h3>{$_('login.infoSavedAccountsTitle')}</h3>
215
<p>{$_('login.infoSavedAccountsDesc')}</p>
216
+3
-3
frontend/src/routes/Migration.svelte
+3
-3
frontend/src/routes/Migration.svelte
···
1
<script lang="ts">
2
import { setSession } from '../lib/auth.svelte'
3
-
import { navigate } from '../lib/router.svelte'
4
import { _ } from '../lib/i18n'
5
import {
6
createInboundMigrationFlow,
···
151
refreshJwt: '',
152
})
153
}
154
-
navigate('/dashboard')
155
}
156
157
function handleOfflineComplete() {
···
164
refreshJwt: '',
165
})
166
}
167
-
navigate('/dashboard')
168
}
169
</script>
170
···
1
<script lang="ts">
2
import { setSession } from '../lib/auth.svelte'
3
+
import { navigate, routes } from '../lib/router.svelte'
4
import { _ } from '../lib/i18n'
5
import {
6
createInboundMigrationFlow,
···
151
refreshJwt: '',
152
})
153
}
154
+
navigate(routes.dashboard)
155
}
156
157
function handleOfflineComplete() {
···
164
refreshJwt: '',
165
})
166
}
167
+
navigate(routes.dashboard)
168
}
169
</script>
170
+2
-2
frontend/src/routes/OAuth2FA.svelte
+2
-2
frontend/src/routes/OAuth2FA.svelte
···
1
<script lang="ts">
2
-
import { navigate } from '../lib/router.svelte'
3
import { _ } from '../lib/i18n'
4
5
let code = $state('')
···
64
function handleCancel() {
65
const requestUri = getRequestUri()
66
if (requestUri) {
67
-
navigate(`/oauth/login?request_uri=${encodeURIComponent(requestUri)}`)
68
} else {
69
window.history.back()
70
}
···
1
<script lang="ts">
2
+
import { navigate, routes } from '../lib/router.svelte'
3
import { _ } from '../lib/i18n'
4
5
let code = $state('')
···
64
function handleCancel() {
65
const requestUri = getRequestUri()
66
if (requestUri) {
67
+
navigate(routes.oauthLogin, { params: { request_uri: requestUri } })
68
} else {
69
window.history.back()
70
}
+6
-8
frontend/src/routes/OAuthAccounts.svelte
+6
-8
frontend/src/routes/OAuthAccounts.svelte
···
1
<script lang="ts">
2
-
import { navigate } from '../lib/router.svelte'
3
import { _ } from '../lib/i18n'
4
5
interface AccountInfo {
···
75
}
76
77
if (data.needs_totp) {
78
-
navigate(`/oauth/totp?request_uri=${encodeURIComponent(requestUri)}`)
79
return
80
}
81
82
if (data.needs_2fa) {
83
-
navigate(`/oauth/2fa?request_uri=${encodeURIComponent(requestUri)}&channel=${encodeURIComponent(data.channel || '')}`)
84
return
85
}
86
···
100
function handleDifferentAccount() {
101
const requestUri = getRequestUri()
102
if (requestUri) {
103
-
navigate(`/oauth/login?request_uri=${encodeURIComponent(requestUri)}`)
104
} else {
105
-
navigate('/oauth/login')
106
}
107
}
108
···
113
114
<div class="oauth-accounts-container">
115
{#if loading}
116
-
<div class="loading">
117
-
<p>{$_('common.loading')}</p>
118
-
</div>
119
{:else if error}
120
<div class="error-container">
121
<h1>Error</h1>
···
1
<script lang="ts">
2
+
import { navigate, routes } from '../lib/router.svelte'
3
import { _ } from '../lib/i18n'
4
5
interface AccountInfo {
···
75
}
76
77
if (data.needs_totp) {
78
+
navigate(routes.oauthTotp, { params: { request_uri: requestUri } })
79
return
80
}
81
82
if (data.needs_2fa) {
83
+
navigate(routes.oauth2fa, { params: { request_uri: requestUri, channel: data.channel || '' } })
84
return
85
}
86
···
100
function handleDifferentAccount() {
101
const requestUri = getRequestUri()
102
if (requestUri) {
103
+
navigate(routes.oauthLogin, { params: { request_uri: requestUri } })
104
} else {
105
+
navigate(routes.oauthLogin)
106
}
107
}
108
···
113
114
<div class="oauth-accounts-container">
115
{#if loading}
116
+
<div class="loading"></div>
117
{:else if error}
118
<div class="error-container">
119
<h1>Error</h1>
+16
-22
frontend/src/routes/OAuthConsent.svelte
+16
-22
frontend/src/routes/OAuthConsent.svelte
···
1
<script lang="ts">
2
-
import { navigate } from '../lib/router.svelte'
3
import { _ } from '../lib/i18n'
4
5
interface ScopeInfo {
···
57
const data: ConsentData = await response.json()
58
consentData = data
59
60
-
for (const scope of data.scopes) {
61
-
if (scope.required) {
62
-
scopeSelections[scope.scope] = true
63
-
} else if (scope.granted !== null) {
64
-
scopeSelections[scope.scope] = scope.granted
65
-
} else {
66
-
scopeSelections[scope.scope] = true
67
-
}
68
-
}
69
70
if (!data.show_consent) {
71
await submitConsent()
···
144
}
145
146
function groupScopesByCategory(scopes: ScopeInfo[]): Record<string, ScopeInfo[]> {
147
-
const groups: Record<string, ScopeInfo[]> = {}
148
-
for (const scope of scopes) {
149
-
if (!groups[scope.category]) {
150
-
groups[scope.category] = []
151
-
}
152
-
groups[scope.category].push(scope)
153
-
}
154
-
return groups
155
}
156
157
$effect(() => {
···
163
164
<div class="consent-container">
165
{#if loading}
166
-
<div class="loading">
167
-
<p>{$_('common.loading')}</p>
168
-
</div>
169
{:else if error}
170
<div class="error-container">
171
<h1>{$_('oauth.error.title')}</h1>
172
<div class="error">{error}</div>
173
-
<button type="button" onclick={() => navigate('/login')}>
174
{$_('common.backToLogin')}
175
</button>
176
</div>
···
1
<script lang="ts">
2
+
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
3
import { _ } from '../lib/i18n'
4
5
interface ScopeInfo {
···
57
const data: ConsentData = await response.json()
58
consentData = data
59
60
+
scopeSelections = Object.fromEntries(
61
+
data.scopes.map((scope) => [
62
+
scope.scope,
63
+
scope.required ? true : scope.granted ?? true,
64
+
])
65
+
)
66
67
if (!data.show_consent) {
68
await submitConsent()
···
141
}
142
143
function groupScopesByCategory(scopes: ScopeInfo[]): Record<string, ScopeInfo[]> {
144
+
return scopes.reduce(
145
+
(groups, scope) => ({
146
+
...groups,
147
+
[scope.category]: [...(groups[scope.category] ?? []), scope],
148
+
}),
149
+
{} as Record<string, ScopeInfo[]>
150
+
)
151
}
152
153
$effect(() => {
···
159
160
<div class="consent-container">
161
{#if loading}
162
+
<div class="loading"></div>
163
{:else if error}
164
<div class="error-container">
165
<h1>{$_('oauth.error.title')}</h1>
166
<div class="error">{error}</div>
167
+
<button type="button" onclick={() => navigate(routes.login)}>
168
{$_('common.backToLogin')}
169
</button>
170
</div>
+13
-49
frontend/src/routes/OAuthDelegation.svelte
+13
-49
frontend/src/routes/OAuthDelegation.svelte
···
1
<script lang="ts">
2
-
import { navigate } from '../lib/router.svelte'
3
import { _ } from '../lib/i18n'
4
5
let delegatedDid = $state<string | null>(null)
6
let delegatedHandle = $state<string | null>(null)
···
103
}
104
}
105
106
-
function arrayBufferToBase64Url(buffer: ArrayBuffer): string {
107
-
const bytes = new Uint8Array(buffer)
108
-
let binary = ''
109
-
for (let i = 0; i < bytes.byteLength; i++) {
110
-
binary += String.fromCharCode(bytes[i])
111
-
}
112
-
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
113
-
}
114
-
115
-
function base64UrlToArrayBuffer(base64url: string): ArrayBuffer {
116
-
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
117
-
const padded = base64 + '='.repeat((4 - base64.length % 4) % 4)
118
-
const binary = atob(padded)
119
-
const bytes = new Uint8Array(binary.length)
120
-
for (let i = 0; i < binary.length; i++) {
121
-
bytes[i] = binary.charCodeAt(i)
122
-
}
123
-
return bytes.buffer
124
-
}
125
-
126
-
function prepareCredentialRequestOptions(options: any): PublicKeyCredentialRequestOptions {
127
-
return {
128
-
...options,
129
-
challenge: base64UrlToArrayBuffer(options.challenge),
130
-
allowCredentials: options.allowCredentials?.map((cred: any) => ({
131
-
...cred,
132
-
id: base64UrlToArrayBuffer(cred.id)
133
-
})) || []
134
-
}
135
-
}
136
-
137
async function handlePasskeyLogin() {
138
const requestUri = getRequestUri()
139
if (!requestUri || !controllerDid || !delegatedDid) {
···
165
}
166
167
const { options } = await startResponse.json()
168
169
const credential = await navigator.credentials.get({
170
-
publicKey: prepareCredentialRequestOptions(options.publicKey)
171
}) as PublicKeyCredential | null
172
173
if (!credential) {
···
176
return
177
}
178
179
-
const assertionResponse = credential.response as AuthenticatorAssertionResponse
180
-
const credentialData = {
181
-
id: credential.id,
182
-
type: credential.type,
183
-
rawId: arrayBufferToBase64Url(credential.rawId),
184
-
response: {
185
-
clientDataJSON: arrayBufferToBase64Url(assertionResponse.clientDataJSON),
186
-
authenticatorData: arrayBufferToBase64Url(assertionResponse.authenticatorData),
187
-
signature: arrayBufferToBase64Url(assertionResponse.signature),
188
-
userHandle: assertionResponse.userHandle ? arrayBufferToBase64Url(assertionResponse.userHandle) : null
189
-
}
190
-
}
191
192
const finishResponse = await fetch('/oauth/passkey/finish', {
193
method: 'POST',
···
213
}
214
215
if (data.needs_totp) {
216
-
navigate(`/oauth/totp?request_uri=${encodeURIComponent(requestUri)}`)
217
return
218
}
219
220
if (data.needs_2fa) {
221
-
navigate(`/oauth/2fa?request_uri=${encodeURIComponent(requestUri)}&channel=${encodeURIComponent(data.channel || '')}`)
222
return
223
}
224
···
272
}
273
274
if (data.needs_totp) {
275
-
navigate(`/oauth/totp?request_uri=${encodeURIComponent(requestUri)}`)
276
return
277
}
278
279
if (data.needs_2fa) {
280
-
navigate(`/oauth/2fa?request_uri=${encodeURIComponent(requestUri)}&channel=${encodeURIComponent(data.channel || '')}`)
281
return
282
}
283
···
1
<script lang="ts">
2
+
import { navigate, routes } from '../lib/router.svelte'
3
import { _ } from '../lib/i18n'
4
+
import {
5
+
prepareRequestOptions,
6
+
serializeAssertionResponse,
7
+
type WebAuthnRequestOptionsResponse,
8
+
} from '../lib/webauthn'
9
10
let delegatedDid = $state<string | null>(null)
11
let delegatedHandle = $state<string | null>(null)
···
108
}
109
}
110
111
async function handlePasskeyLogin() {
112
const requestUri = getRequestUri()
113
if (!requestUri || !controllerDid || !delegatedDid) {
···
139
}
140
141
const { options } = await startResponse.json()
142
+
const publicKeyOptions = prepareRequestOptions(options as WebAuthnRequestOptionsResponse)
143
144
const credential = await navigator.credentials.get({
145
+
publicKey: publicKeyOptions
146
}) as PublicKeyCredential | null
147
148
if (!credential) {
···
151
return
152
}
153
154
+
const credentialData = serializeAssertionResponse(credential)
155
156
const finishResponse = await fetch('/oauth/passkey/finish', {
157
method: 'POST',
···
177
}
178
179
if (data.needs_totp) {
180
+
navigate(routes.oauthTotp, { params: { request_uri: requestUri } })
181
return
182
}
183
184
if (data.needs_2fa) {
185
+
navigate(routes.oauth2fa, { params: { request_uri: requestUri, channel: data.channel || '' } })
186
return
187
}
188
···
236
}
237
238
if (data.needs_totp) {
239
+
navigate(routes.oauthTotp, { params: { request_uri: requestUri } })
240
return
241
}
242
243
if (data.needs_2fa) {
244
+
navigate(routes.oauth2fa, { params: { request_uri: requestUri, channel: data.channel || '' } })
245
return
246
}
247
+15
-51
frontend/src/routes/OAuthLogin.svelte
+15
-51
frontend/src/routes/OAuthLogin.svelte
···
1
<script lang="ts">
2
-
import { navigate } from '../lib/router.svelte'
3
import { _ } from '../lib/i18n'
4
5
let username = $state('')
6
let password = $state('')
···
95
if (!hasPassword && !hasPasskeys && isDelegated && data.did) {
96
const requestUri = getRequestUri()
97
if (requestUri) {
98
-
navigate(`/oauth/delegation?request_uri=${encodeURIComponent(requestUri)}&delegated_did=${encodeURIComponent(data.did)}`)
99
return
100
}
101
}
···
142
}
143
144
const { options } = await startResponse.json()
145
146
const credential = await navigator.credentials.get({
147
-
publicKey: prepareCredentialRequestOptions(options.publicKey)
148
}) as PublicKeyCredential | null
149
150
if (!credential) {
···
153
return
154
}
155
156
-
const assertionResponse = credential.response as AuthenticatorAssertionResponse
157
-
const credentialData = {
158
-
id: credential.id,
159
-
type: credential.type,
160
-
rawId: arrayBufferToBase64Url(credential.rawId),
161
-
response: {
162
-
clientDataJSON: arrayBufferToBase64Url(assertionResponse.clientDataJSON),
163
-
authenticatorData: arrayBufferToBase64Url(assertionResponse.authenticatorData),
164
-
signature: arrayBufferToBase64Url(assertionResponse.signature),
165
-
userHandle: assertionResponse.userHandle ? arrayBufferToBase64Url(assertionResponse.userHandle) : null
166
-
}
167
-
}
168
169
const finishResponse = await fetch('/oauth/passkey/finish', {
170
method: 'POST',
···
187
}
188
189
if (data.needs_totp) {
190
-
navigate(`/oauth/totp?request_uri=${encodeURIComponent(requestUri)}`)
191
return
192
}
193
194
if (data.needs_2fa) {
195
-
navigate(`/oauth/2fa?request_uri=${encodeURIComponent(requestUri)}&channel=${encodeURIComponent(data.channel || '')}`)
196
return
197
}
198
···
214
}
215
}
216
217
-
function arrayBufferToBase64Url(buffer: ArrayBuffer): string {
218
-
const bytes = new Uint8Array(buffer)
219
-
let binary = ''
220
-
for (let i = 0; i < bytes.byteLength; i++) {
221
-
binary += String.fromCharCode(bytes[i])
222
-
}
223
-
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
224
-
}
225
-
226
-
function base64UrlToArrayBuffer(base64url: string): ArrayBuffer {
227
-
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
228
-
const padded = base64 + '='.repeat((4 - base64.length % 4) % 4)
229
-
const binary = atob(padded)
230
-
const bytes = new Uint8Array(binary.length)
231
-
for (let i = 0; i < binary.length; i++) {
232
-
bytes[i] = binary.charCodeAt(i)
233
-
}
234
-
return bytes.buffer
235
-
}
236
-
237
-
function prepareCredentialRequestOptions(options: any): PublicKeyCredentialRequestOptions {
238
-
return {
239
-
...options,
240
-
challenge: base64UrlToArrayBuffer(options.challenge),
241
-
allowCredentials: options.allowCredentials?.map((cred: any) => ({
242
-
...cred,
243
-
id: base64UrlToArrayBuffer(cred.id)
244
-
})) || []
245
-
}
246
-
}
247
-
248
async function handleSubmit(e: Event) {
249
e.preventDefault()
250
const requestUri = getRequestUri()
···
280
}
281
282
if (data.needs_totp) {
283
-
navigate(`/oauth/totp?request_uri=${encodeURIComponent(requestUri)}`)
284
return
285
}
286
287
if (data.needs_2fa) {
288
-
navigate(`/oauth/2fa?request_uri=${encodeURIComponent(requestUri)}&channel=${encodeURIComponent(data.channel || '')}`)
289
return
290
}
291
···
456
</form>
457
458
<p class="help-links">
459
-
<a href="/app/reset-password">{$_('login.forgotPassword')}</a> · <a href="/app/request-passkey-recovery">{$_('login.lostPasskey')}</a>
460
</p>
461
</div>
462
···
1
<script lang="ts">
2
+
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
3
import { _ } from '../lib/i18n'
4
+
import {
5
+
prepareRequestOptions,
6
+
serializeAssertionResponse,
7
+
type WebAuthnRequestOptionsResponse,
8
+
} from '../lib/webauthn'
9
10
let username = $state('')
11
let password = $state('')
···
100
if (!hasPassword && !hasPasskeys && isDelegated && data.did) {
101
const requestUri = getRequestUri()
102
if (requestUri) {
103
+
navigate(routes.oauthDelegation, { params: { request_uri: requestUri, delegated_did: data.did } })
104
return
105
}
106
}
···
147
}
148
149
const { options } = await startResponse.json()
150
+
const publicKeyOptions = prepareRequestOptions(options as WebAuthnRequestOptionsResponse)
151
152
const credential = await navigator.credentials.get({
153
+
publicKey: publicKeyOptions
154
}) as PublicKeyCredential | null
155
156
if (!credential) {
···
159
return
160
}
161
162
+
const credentialData = serializeAssertionResponse(credential)
163
164
const finishResponse = await fetch('/oauth/passkey/finish', {
165
method: 'POST',
···
182
}
183
184
if (data.needs_totp) {
185
+
navigate(routes.oauthTotp, { params: { request_uri: requestUri } })
186
return
187
}
188
189
if (data.needs_2fa) {
190
+
navigate(routes.oauth2fa, { params: { request_uri: requestUri, channel: data.channel || '' } })
191
return
192
}
193
···
209
}
210
}
211
212
async function handleSubmit(e: Event) {
213
e.preventDefault()
214
const requestUri = getRequestUri()
···
244
}
245
246
if (data.needs_totp) {
247
+
navigate(routes.oauthTotp, { params: { request_uri: requestUri } })
248
return
249
}
250
251
if (data.needs_2fa) {
252
+
navigate(routes.oauth2fa, { params: { request_uri: requestUri, channel: data.channel || '' } })
253
return
254
}
255
···
420
</form>
421
422
<p class="help-links">
423
+
<a href={getFullUrl(routes.resetPassword)}>{$_('login.forgotPassword')}</a> · <a href={getFullUrl(routes.requestPasskeyRecovery)}>{$_('login.lostPasskey')}</a>
424
</p>
425
</div>
426
+9
-47
frontend/src/routes/OAuthPasskey.svelte
+9
-47
frontend/src/routes/OAuthPasskey.svelte
···
1
<script lang="ts">
2
-
import { navigate } from '../lib/router.svelte'
3
import { _ } from '../lib/i18n'
4
5
let loading = $state(false)
6
let error = $state<string | null>(null)
···
13
14
const t = $_
15
16
-
function arrayBufferToBase64Url(buffer: ArrayBuffer): string {
17
-
const bytes = new Uint8Array(buffer)
18
-
let binary = ''
19
-
for (let i = 0; i < bytes.byteLength; i++) {
20
-
binary += String.fromCharCode(bytes[i])
21
-
}
22
-
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
23
-
}
24
-
25
-
function base64UrlToArrayBuffer(base64url: string): ArrayBuffer {
26
-
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
27
-
const padded = base64 + '='.repeat((4 - base64.length % 4) % 4)
28
-
const binary = atob(padded)
29
-
const bytes = new Uint8Array(binary.length)
30
-
for (let i = 0; i < binary.length; i++) {
31
-
bytes[i] = binary.charCodeAt(i)
32
-
}
33
-
return bytes.buffer
34
-
}
35
-
36
-
function prepareAuthOptions(options: any): PublicKeyCredentialRequestOptions {
37
-
return {
38
-
...options.publicKey,
39
-
challenge: base64UrlToArrayBuffer(options.publicKey.challenge),
40
-
allowCredentials: options.publicKey.allowCredentials?.map((cred: any) => ({
41
-
...cred,
42
-
id: base64UrlToArrayBuffer(cred.id)
43
-
})) || []
44
-
}
45
-
}
46
-
47
async function startPasskeyAuth() {
48
const requestUri = getRequestUri()
49
if (!requestUri) {
···
75
}
76
77
const { options } = await startResponse.json()
78
-
const publicKeyOptions = prepareAuthOptions(options)
79
80
const credential = await navigator.credentials.get({
81
publicKey: publicKeyOptions
···
87
return
88
}
89
90
-
const pkCredential = credential as PublicKeyCredential
91
-
const response = pkCredential.response as AuthenticatorAssertionResponse
92
-
const credentialResponse = {
93
-
id: pkCredential.id,
94
-
type: pkCredential.type,
95
-
rawId: arrayBufferToBase64Url(pkCredential.rawId),
96
-
response: {
97
-
clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON),
98
-
authenticatorData: arrayBufferToBase64Url(response.authenticatorData),
99
-
signature: arrayBufferToBase64Url(response.signature),
100
-
userHandle: response.userHandle ? arrayBufferToBase64Url(response.userHandle) : null,
101
-
},
102
-
}
103
104
const finishResponse = await fetch('/oauth/authorize/passkey', {
105
method: 'POST',
···
141
function handleCancel() {
142
const requestUri = getRequestUri()
143
if (requestUri) {
144
-
navigate(`/oauth/login?request_uri=${encodeURIComponent(requestUri)}`)
145
} else {
146
window.history.back()
147
}
···
1
<script lang="ts">
2
+
import { navigate, routes } from '../lib/router.svelte'
3
import { _ } from '../lib/i18n'
4
+
import {
5
+
prepareRequestOptions,
6
+
serializeAssertionResponse,
7
+
type WebAuthnRequestOptionsResponse,
8
+
} from '../lib/webauthn'
9
10
let loading = $state(false)
11
let error = $state<string | null>(null)
···
18
19
const t = $_
20
21
async function startPasskeyAuth() {
22
const requestUri = getRequestUri()
23
if (!requestUri) {
···
49
}
50
51
const { options } = await startResponse.json()
52
+
const publicKeyOptions = prepareRequestOptions(options as WebAuthnRequestOptionsResponse)
53
54
const credential = await navigator.credentials.get({
55
publicKey: publicKeyOptions
···
61
return
62
}
63
64
+
const credentialResponse = serializeAssertionResponse(credential as PublicKeyCredential)
65
66
const finishResponse = await fetch('/oauth/authorize/passkey', {
67
method: 'POST',
···
103
function handleCancel() {
104
const requestUri = getRequestUri()
105
if (requestUri) {
106
+
navigate(routes.oauthLogin, { params: { request_uri: requestUri } })
107
} else {
108
window.history.back()
109
}
+2
-2
frontend/src/routes/OAuthTotp.svelte
+2
-2
frontend/src/routes/OAuthTotp.svelte
···
1
<script lang="ts">
2
-
import { navigate } from '../lib/router.svelte'
3
import { _ } from '../lib/i18n'
4
5
let code = $state('')
···
61
function handleCancel() {
62
const requestUri = getRequestUri()
63
if (requestUri) {
64
-
navigate(`/oauth/login?request_uri=${encodeURIComponent(requestUri)}`)
65
} else {
66
window.history.back()
67
}
···
1
<script lang="ts">
2
+
import { navigate, routes } from '../lib/router.svelte'
3
import { _ } from '../lib/i18n'
4
5
let code = $state('')
···
61
function handleCancel() {
62
const requestUri = getRequestUri()
63
if (requestUri) {
64
+
navigate(routes.oauthLogin, { params: { request_uri: requestUri } })
65
} else {
66
window.history.back()
67
}
+3
-3
frontend/src/routes/RecoverPasskey.svelte
+3
-3
frontend/src/routes/RecoverPasskey.svelte
···
1
<script lang="ts">
2
-
import { navigate } from '../lib/router.svelte'
3
import { api, ApiError } from '../lib/api'
4
import { _ } from '../lib/i18n'
5
···
66
}
67
68
function goToLogin() {
69
-
navigate('/login')
70
}
71
72
function requestNewLink() {
73
-
navigate('/login')
74
}
75
</script>
76
···
1
<script lang="ts">
2
+
import { navigate, routes } from '../lib/router.svelte'
3
import { api, ApiError } from '../lib/api'
4
import { _ } from '../lib/i18n'
5
···
66
}
67
68
function goToLogin() {
69
+
navigate(routes.login)
70
}
71
72
function requestNewLink() {
73
+
navigate(routes.login)
74
}
75
</script>
76
+7
-8
frontend/src/routes/Register.svelte
+7
-8
frontend/src/routes/Register.svelte
···
1
<script lang="ts">
2
-
import { navigate } from '../lib/router.svelte'
3
import { api, ApiError } from '../lib/api'
4
import { _ } from '../lib/i18n'
5
import {
···
30
31
$effect(() => {
32
if (flow?.state.step === 'redirect-to-dashboard') {
33
-
navigate('/dashboard')
34
}
35
})
36
···
109
if (flow) {
110
await flow.finalizeSession()
111
}
112
-
navigate('/dashboard')
113
}
114
115
function isChannelAvailable(ch: string): boolean {
···
166
{/if}
167
168
{#if loadingServerInfo || !flow}
169
-
<p class="loading">{$_('common.loading')}</p>
170
-
171
{:else if flow.state.step === 'info'}
172
<div class="migrate-callout">
173
<div class="migrate-icon">↗</div>
174
<div class="migrate-content">
175
<strong>{$_('register.migrateTitle')}</strong>
176
<p>{$_('register.migrateDescription')}</p>
177
-
<a href="/app/migrate" class="migrate-link">
178
{$_('register.migrateLink')} →
179
</a>
180
</div>
···
381
382
<div class="form-links">
383
<p class="link-text">
384
-
{$_('register.alreadyHaveAccount')} <a href="/app/login">{$_('register.signIn')}</a>
385
</p>
386
<p class="link-text">
387
-
{$_('register.wantPasswordless')} <a href="/app/register-passkey">{$_('register.createPasskeyAccount')}</a>
388
</p>
389
</div>
390
</div>
···
1
<script lang="ts">
2
+
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
3
import { api, ApiError } from '../lib/api'
4
import { _ } from '../lib/i18n'
5
import {
···
30
31
$effect(() => {
32
if (flow?.state.step === 'redirect-to-dashboard') {
33
+
navigate(routes.dashboard)
34
}
35
})
36
···
109
if (flow) {
110
await flow.finalizeSession()
111
}
112
+
navigate(routes.dashboard)
113
}
114
115
function isChannelAvailable(ch: string): boolean {
···
166
{/if}
167
168
{#if loadingServerInfo || !flow}
169
+
<div class="loading"></div>
170
{:else if flow.state.step === 'info'}
171
<div class="migrate-callout">
172
<div class="migrate-icon">↗</div>
173
<div class="migrate-content">
174
<strong>{$_('register.migrateTitle')}</strong>
175
<p>{$_('register.migrateDescription')}</p>
176
+
<a href={getFullUrl(routes.migrate)} class="migrate-link">
177
{$_('register.migrateLink')} →
178
</a>
179
</div>
···
380
381
<div class="form-links">
382
<p class="link-text">
383
+
{$_('register.alreadyHaveAccount')} <a href={getFullUrl(routes.login)}>{$_('register.signIn')}</a>
384
</p>
385
<p class="link-text">
386
+
{$_('register.wantPasswordless')} <a href={getFullUrl(routes.registerPasskey)}>{$_('register.createPasskeyAccount')}</a>
387
</p>
388
</div>
389
</div>
+7
-47
frontend/src/routes/RegisterPasskey.svelte
+7
-47
frontend/src/routes/RegisterPasskey.svelte
···
9
DidDocStep,
10
AppPasswordStep,
11
} from '../lib/registration'
12
13
let serverInfo = $state<{
14
availableUserDomains: string[]
···
84
return null
85
}
86
87
-
function arrayBufferToBase64Url(buffer: ArrayBuffer): string {
88
-
const bytes = new Uint8Array(buffer)
89
-
let binary = ''
90
-
for (let i = 0; i < bytes.byteLength; i++) {
91
-
binary += String.fromCharCode(bytes[i])
92
-
}
93
-
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
94
-
}
95
-
96
-
function base64UrlToArrayBuffer(base64url: string): ArrayBuffer {
97
-
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
98
-
const padded = base64 + '='.repeat((4 - base64.length % 4) % 4)
99
-
const binary = atob(padded)
100
-
const bytes = new Uint8Array(binary.length)
101
-
for (let i = 0; i < binary.length; i++) {
102
-
bytes[i] = binary.charCodeAt(i)
103
-
}
104
-
return bytes.buffer
105
-
}
106
-
107
-
function preparePublicKeyOptions(options: any): PublicKeyCredentialCreationOptions {
108
-
return {
109
-
...options.publicKey,
110
-
challenge: base64UrlToArrayBuffer(options.publicKey.challenge),
111
-
user: {
112
-
...options.publicKey.user,
113
-
id: base64UrlToArrayBuffer(options.publicKey.user.id)
114
-
},
115
-
excludeCredentials: options.publicKey.excludeCredentials?.map((cred: any) => ({
116
-
...cred,
117
-
id: base64UrlToArrayBuffer(cred.id)
118
-
})) || []
119
-
}
120
-
}
121
-
122
async function handleInfoSubmit(e: Event) {
123
e.preventDefault()
124
if (!flow) return
···
156
passkeyName || undefined
157
)
158
159
-
const publicKeyOptions = preparePublicKeyOptions(options)
160
const credential = await navigator.credentials.create({
161
publicKey: publicKeyOptions
162
})
···
167
return
168
}
169
170
-
const pkCredential = credential as PublicKeyCredential
171
-
const response = pkCredential.response as AuthenticatorAttestationResponse
172
-
const credentialResponse = {
173
-
id: pkCredential.id,
174
-
type: pkCredential.type,
175
-
rawId: arrayBufferToBase64Url(pkCredential.rawId),
176
-
response: {
177
-
clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON),
178
-
attestationObject: arrayBufferToBase64Url(response.attestationObject),
179
-
},
180
-
}
181
182
const result = await api.completePasskeySetup(
183
flow.account.did,
···
9
DidDocStep,
10
AppPasswordStep,
11
} from '../lib/registration'
12
+
import {
13
+
prepareCreationOptions,
14
+
serializeAttestationResponse,
15
+
type WebAuthnCreationOptionsResponse,
16
+
} from '../lib/webauthn'
17
18
let serverInfo = $state<{
19
availableUserDomains: string[]
···
89
return null
90
}
91
92
async function handleInfoSubmit(e: Event) {
93
e.preventDefault()
94
if (!flow) return
···
126
passkeyName || undefined
127
)
128
129
+
const publicKeyOptions = prepareCreationOptions(options as WebAuthnCreationOptionsResponse)
130
const credential = await navigator.credentials.create({
131
publicKey: publicKeyOptions
132
})
···
137
return
138
}
139
140
+
const credentialResponse = serializeAttestationResponse(credential as PublicKeyCredential)
141
142
const result = await api.completePasskeySetup(
143
flow.account.did,
+63
-33
frontend/src/routes/RepoExplorer.svelte
+63
-33
frontend/src/routes/RepoExplorer.svelte
···
1
<script lang="ts">
2
import { getAuthState } from '../lib/auth.svelte'
3
-
import { navigate } from '../lib/router.svelte'
4
import { api, ApiError } from '../lib/api'
5
import { _, locale } from '../lib/i18n'
6
-
const auth = getAuthState()
7
type View = 'collections' | 'records' | 'record' | 'create'
8
let view = $state<View>('collections')
9
let collections = $state<string[]>([])
···
31
let saving = $state(false)
32
let filter = $state('')
33
$effect(() => {
34
-
if (!auth.loading && !auth.session) {
35
-
navigate('/login')
36
}
37
})
38
$effect(() => {
39
-
if (auth.session) {
40
loadCollections()
41
}
42
})
43
async function loadCollections() {
44
-
if (!auth.session) return
45
loading = true
46
error = null
47
try {
48
-
const result = await api.describeRepo(auth.session.accessJwt, auth.session.did)
49
collections = result.collections.sort()
50
} catch (e) {
51
setError(e)
···
54
}
55
}
56
async function selectCollection(collection: string) {
57
-
if (!auth.session) return
58
selectedCollection = collection
59
records = []
60
recordsCursor = undefined
···
62
loading = true
63
error = null
64
try {
65
-
const result = await api.listRecords(auth.session.accessJwt, auth.session.did, collection, { limit: 50 })
66
records = result.records.map(r => ({
67
...r,
68
rkey: r.uri.split('/').pop()!
···
75
}
76
}
77
async function loadMoreRecords() {
78
-
if (!auth.session || !selectedCollection || !recordsCursor || loadingMore) return
79
loadingMore = true
80
try {
81
-
const result = await api.listRecords(auth.session.accessJwt, auth.session.did, selectedCollection, {
82
limit: 50,
83
cursor: recordsCursor
84
})
···
154
}
155
async function handleCreate(e: Event) {
156
e.preventDefault()
157
-
if (!auth.session) return
158
const record = validateJson()
159
if (!record) return
160
if (!newCollection.trim()) {
···
165
error = null
166
try {
167
const result = await api.createRecord(
168
-
auth.session.accessJwt,
169
-
auth.session.did,
170
newCollection.trim(),
171
record,
172
newRkey.trim() || undefined
···
182
}
183
async function handleUpdate(e: Event) {
184
e.preventDefault()
185
-
if (!auth.session || !selectedRecord || !selectedCollection) return
186
const record = validateJson()
187
if (!record) return
188
saving = true
189
error = null
190
try {
191
await api.putRecord(
192
-
auth.session.accessJwt,
193
-
auth.session.did,
194
selectedCollection,
195
selectedRecord.rkey,
196
record
197
)
198
success = $_('repoExplorer.recordUpdated')
199
const updated = await api.getRecord(
200
-
auth.session.accessJwt,
201
-
auth.session.did,
202
selectedCollection,
203
selectedRecord.rkey
204
)
···
211
}
212
}
213
async function handleDelete() {
214
-
if (!auth.session || !selectedRecord || !selectedCollection) return
215
if (!confirm($_('repoExplorer.deleteConfirm', { values: { rkey: selectedRecord.rkey } }))) return
216
saving = true
217
error = null
218
try {
219
await api.deleteRecord(
220
-
auth.session.accessJwt,
221
-
auth.session.did,
222
selectedCollection,
223
selectedRecord.rkey
224
)
···
259
: records
260
)
261
function groupCollectionsByAuthority(cols: string[]): Map<string, string[]> {
262
-
const groups = new Map<string, string[]>()
263
-
for (const col of cols) {
264
const parts = col.split('.')
265
const authority = parts.slice(0, -1).join('.')
266
const name = parts[parts.length - 1]
267
-
if (!groups.has(authority)) {
268
-
groups.set(authority, [])
269
-
}
270
-
groups.get(authority)!.push(name)
271
-
}
272
-
return groups
273
}
274
let groupedCollections = $derived(groupCollectionsByAuthority(filteredCollections))
275
</script>
···
303
{$_('repoExplorer.createRecord')}
304
{/if}
305
</h1>
306
-
{#if auth.session}
307
-
<p class="did">{auth.session.did}</p>
308
{/if}
309
</header>
310
{#if error}
···
319
<div class="message success">{success}</div>
320
{/if}
321
{#if loading}
322
-
<p class="loading-text">{$_('common.loading')}</p>
323
{:else if view === 'collections'}
324
<div class="toolbar">
325
<input
···
979
.page ::-moz-selection {
980
background: var(--accent);
981
color: var(--text-inverse);
982
}
983
</style>
···
1
<script lang="ts">
2
import { getAuthState } from '../lib/auth.svelte'
3
+
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
4
import { api, ApiError } from '../lib/api'
5
import { _, locale } from '../lib/i18n'
6
+
import type { Session } from '../lib/types/api'
7
+
8
+
const auth = $derived(getAuthState())
9
+
10
+
function getSession(): Session | null {
11
+
return auth.kind === 'authenticated' ? auth.session : null
12
+
}
13
+
14
+
function isLoading(): boolean {
15
+
return auth.kind === 'loading'
16
+
}
17
+
18
+
const session = $derived(getSession())
19
+
const authLoading = $derived(isLoading())
20
type View = 'collections' | 'records' | 'record' | 'create'
21
let view = $state<View>('collections')
22
let collections = $state<string[]>([])
···
44
let saving = $state(false)
45
let filter = $state('')
46
$effect(() => {
47
+
if (!authLoading && !session) {
48
+
navigate(routes.login)
49
}
50
})
51
$effect(() => {
52
+
if (session) {
53
loadCollections()
54
}
55
})
56
async function loadCollections() {
57
+
if (!session) return
58
loading = true
59
error = null
60
try {
61
+
const result = await api.describeRepo(session.accessJwt, session.did)
62
collections = result.collections.sort()
63
} catch (e) {
64
setError(e)
···
67
}
68
}
69
async function selectCollection(collection: string) {
70
+
if (!session) return
71
selectedCollection = collection
72
records = []
73
recordsCursor = undefined
···
75
loading = true
76
error = null
77
try {
78
+
const result = await api.listRecords(session.accessJwt, session.did, collection, { limit: 50 })
79
records = result.records.map(r => ({
80
...r,
81
rkey: r.uri.split('/').pop()!
···
88
}
89
}
90
async function loadMoreRecords() {
91
+
if (!session || !selectedCollection || !recordsCursor || loadingMore) return
92
loadingMore = true
93
try {
94
+
const result = await api.listRecords(session.accessJwt, session.did, selectedCollection, {
95
limit: 50,
96
cursor: recordsCursor
97
})
···
167
}
168
async function handleCreate(e: Event) {
169
e.preventDefault()
170
+
if (!session) return
171
const record = validateJson()
172
if (!record) return
173
if (!newCollection.trim()) {
···
178
error = null
179
try {
180
const result = await api.createRecord(
181
+
session.accessJwt,
182
+
session.did,
183
newCollection.trim(),
184
record,
185
newRkey.trim() || undefined
···
195
}
196
async function handleUpdate(e: Event) {
197
e.preventDefault()
198
+
if (!session || !selectedRecord || !selectedCollection) return
199
const record = validateJson()
200
if (!record) return
201
saving = true
202
error = null
203
try {
204
await api.putRecord(
205
+
session.accessJwt,
206
+
session.did,
207
selectedCollection,
208
selectedRecord.rkey,
209
record
210
)
211
success = $_('repoExplorer.recordUpdated')
212
const updated = await api.getRecord(
213
+
session.accessJwt,
214
+
session.did,
215
selectedCollection,
216
selectedRecord.rkey
217
)
···
224
}
225
}
226
async function handleDelete() {
227
+
if (!session || !selectedRecord || !selectedCollection) return
228
if (!confirm($_('repoExplorer.deleteConfirm', { values: { rkey: selectedRecord.rkey } }))) return
229
saving = true
230
error = null
231
try {
232
await api.deleteRecord(
233
+
session.accessJwt,
234
+
session.did,
235
selectedCollection,
236
selectedRecord.rkey
237
)
···
272
: records
273
)
274
function groupCollectionsByAuthority(cols: string[]): Map<string, string[]> {
275
+
return cols.reduce((groups, col) => {
276
const parts = col.split('.')
277
const authority = parts.slice(0, -1).join('.')
278
const name = parts[parts.length - 1]
279
+
return groups.set(authority, [...(groups.get(authority) ?? []), name])
280
+
}, new Map<string, string[]>())
281
}
282
let groupedCollections = $derived(groupCollectionsByAuthority(filteredCollections))
283
</script>
···
311
{$_('repoExplorer.createRecord')}
312
{/if}
313
</h1>
314
+
{#if session}
315
+
<p class="did">{session.did}</p>
316
{/if}
317
</header>
318
{#if error}
···
327
<div class="message success">{success}</div>
328
{/if}
329
{#if loading}
330
+
<div class="skeleton-list">
331
+
{#each Array(4) as _}
332
+
<div class="skeleton-row"></div>
333
+
{/each}
334
+
</div>
335
{:else if view === 'collections'}
336
<div class="toolbar">
337
<input
···
991
.page ::-moz-selection {
992
background: var(--accent);
993
color: var(--text-inverse);
994
+
}
995
+
996
+
.skeleton-list {
997
+
display: flex;
998
+
flex-direction: column;
999
+
gap: var(--space-2);
1000
+
}
1001
+
1002
+
.skeleton-row {
1003
+
height: 44px;
1004
+
background: var(--bg-secondary);
1005
+
border-radius: var(--radius-md);
1006
+
animation: skeleton-pulse 1.5s ease-in-out infinite;
1007
+
}
1008
+
1009
+
@keyframes skeleton-pulse {
1010
+
0%, 100% { opacity: 1; }
1011
+
50% { opacity: 0.5; }
1012
}
1013
</style>
+3
-3
frontend/src/routes/RequestPasskeyRecovery.svelte
+3
-3
frontend/src/routes/RequestPasskeyRecovery.svelte
···
1
<script lang="ts">
2
-
import { navigate } from '../lib/router.svelte'
3
import { api, ApiError } from '../lib/api'
4
import { _ } from '../lib/i18n'
5
···
36
<h1>{$_('requestPasskeyRecovery.successTitle')}</h1>
37
<p class="subtitle">{$_('requestPasskeyRecovery.successMessage')}</p>
38
<p class="info-text">{$_('requestPasskeyRecovery.successInfo')}</p>
39
-
<button onclick={() => navigate('/login')}>{$_('common.backToLogin')}</button>
40
</div>
41
{:else}
42
<h1>{$_('requestPasskeyRecovery.title')}</h1>
···
71
{/if}
72
73
<p class="link-text">
74
-
<a href="/app/login">{$_('common.backToLogin')}</a>
75
</p>
76
</div>
77
···
1
<script lang="ts">
2
+
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
3
import { api, ApiError } from '../lib/api'
4
import { _ } from '../lib/i18n'
5
···
36
<h1>{$_('requestPasskeyRecovery.successTitle')}</h1>
37
<p class="subtitle">{$_('requestPasskeyRecovery.successMessage')}</p>
38
<p class="info-text">{$_('requestPasskeyRecovery.successInfo')}</p>
39
+
<button onclick={() => navigate(routes.login)}>{$_('common.backToLogin')}</button>
40
</div>
41
{:else}
42
<h1>{$_('requestPasskeyRecovery.title')}</h1>
···
71
{/if}
72
73
<p class="link-text">
74
+
<a href={getFullUrl(routes.login)}>{$_('common.backToLogin')}</a>
75
</p>
76
</div>
77
+12
-5
frontend/src/routes/ResetPassword.svelte
+12
-5
frontend/src/routes/ResetPassword.svelte
···
1
<script lang="ts">
2
-
import { navigate } from '../lib/router.svelte'
3
import { api, ApiError } from '../lib/api'
4
import { getAuthState } from '../lib/auth.svelte'
5
import { _ } from '../lib/i18n'
6
7
-
const auth = getAuthState()
8
9
let email = $state('')
10
let token = $state('')
···
16
let tokenSent = $state(false)
17
18
$effect(() => {
19
-
if (auth.session) {
20
-
navigate('/dashboard')
21
}
22
})
23
···
55
try {
56
await api.resetPassword(token, newPassword)
57
success = $_('resetPassword.success')
58
-
setTimeout(() => navigate('/login'), 2000)
59
} catch (e) {
60
error = e instanceof ApiError ? e.message : 'Failed to reset password'
61
} finally {
···
1
<script lang="ts">
2
+
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
3
import { api, ApiError } from '../lib/api'
4
import { getAuthState } from '../lib/auth.svelte'
5
import { _ } from '../lib/i18n'
6
+
import type { Session } from '../lib/types/api'
7
8
+
const auth = $derived(getAuthState())
9
+
10
+
function getSession(): Session | null {
11
+
return auth.kind === 'authenticated' ? auth.session : null
12
+
}
13
+
14
+
const session = $derived(getSession())
15
16
let email = $state('')
17
let token = $state('')
···
23
let tokenSent = $state(false)
24
25
$effect(() => {
26
+
if (session) {
27
+
navigate(routes.dashboard)
28
}
29
})
30
···
62
try {
63
await api.resetPassword(token, newPassword)
64
success = $_('resetPassword.success')
65
+
setTimeout(() => navigate(routes.login), 2000)
66
} catch (e) {
67
error = e instanceof ApiError ? e.message : 'Failed to reset password'
68
} finally {
+113
-127
frontend/src/routes/Security.svelte
+113
-127
frontend/src/routes/Security.svelte
···
1
<script lang="ts">
2
import { getAuthState, getValidToken } from '../lib/auth.svelte'
3
-
import { navigate } from '../lib/router.svelte'
4
import { api, ApiError } from '../lib/api'
5
import ReauthModal from '../components/ReauthModal.svelte'
6
import { _ } from '../lib/i18n'
7
import { formatDate as formatDateUtil } from '../lib/date'
8
9
-
const auth = getAuthState()
10
-
let message = $state<{ type: 'success' | 'error'; text: string } | null>(null)
11
let loading = $state(true)
12
let totpEnabled = $state(false)
13
let hasBackupCodes = $state(false)
···
56
let pendingAction = $state<(() => Promise<void>) | null>(null)
57
58
$effect(() => {
59
-
if (!auth.loading && !auth.session) {
60
-
navigate('/login')
61
}
62
})
63
64
$effect(() => {
65
-
if (auth.session) {
66
loadTotpStatus()
67
loadPasskeys()
68
loadPasswordStatus()
···
71
})
72
73
async function loadPasswordStatus() {
74
-
if (!auth.session) return
75
passwordLoading = true
76
try {
77
-
const status = await api.getPasswordStatus(auth.session.accessJwt)
78
hasPassword = status.hasPassword
79
} catch {
80
hasPassword = true
···
84
}
85
86
async function loadLegacyLoginPreference() {
87
-
if (!auth.session) return
88
legacyLoginLoading = true
89
try {
90
-
const pref = await api.getLegacyLoginPreference(auth.session.accessJwt)
91
allowLegacyLogin = pref.allowLegacyLogin
92
hasMfa = pref.hasMfa
93
} catch {
···
99
}
100
101
async function handleToggleLegacyLogin() {
102
-
if (!auth.session) return
103
legacyLoginUpdating = true
104
try {
105
-
const result = await api.updateLegacyLoginPreference(auth.session.accessJwt, !allowLegacyLogin)
106
allowLegacyLogin = result.allowLegacyLogin
107
-
showMessage('success', allowLegacyLogin
108
? $_('security.legacyLoginEnabled')
109
: $_('security.legacyLoginDisabled'))
110
} catch (e) {
···
114
pendingAction = handleToggleLegacyLogin
115
showReauthModal = true
116
} else {
117
-
showMessage('error', e.message)
118
}
119
} else {
120
-
showMessage('error', $_('security.failedToUpdatePreference'))
121
}
122
} finally {
123
legacyLoginUpdating = false
···
125
}
126
127
async function handleRemovePassword() {
128
-
if (!auth.session) return
129
removePasswordLoading = true
130
try {
131
const token = await getValidToken()
132
if (!token) {
133
-
showMessage('error', $_('security.sessionExpired'))
134
return
135
}
136
await api.removePassword(token)
137
hasPassword = false
138
showRemovePasswordForm = false
139
-
showMessage('success', $_('security.passwordRemoved'))
140
} catch (e) {
141
if (e instanceof ApiError) {
142
if (e.error === 'ReauthRequired') {
···
144
pendingAction = handleRemovePassword
145
showReauthModal = true
146
} else {
147
-
showMessage('error', e.message)
148
}
149
} else {
150
-
showMessage('error', $_('security.failedToRemovePassword'))
151
}
152
} finally {
153
removePasswordLoading = false
···
166
}
167
168
async function loadTotpStatus() {
169
-
if (!auth.session) return
170
loading = true
171
try {
172
-
const status = await api.getTotpStatus(auth.session.accessJwt)
173
totpEnabled = status.enabled
174
hasBackupCodes = status.hasBackupCodes
175
} catch {
176
-
showMessage('error', $_('security.failedToLoadTotpStatus'))
177
} finally {
178
loading = false
179
}
180
}
181
182
-
function showMessage(type: 'success' | 'error', text: string) {
183
-
message = { type, text }
184
-
setTimeout(() => {
185
-
if (message?.text === text) message = null
186
-
}, 5000)
187
-
}
188
-
189
async function handleStartSetup() {
190
-
if (!auth.session) return
191
verifyLoading = true
192
try {
193
-
const result = await api.createTotpSecret(auth.session.accessJwt)
194
qrBase64 = result.qrBase64
195
totpUri = result.uri
196
setupStep = 'qr'
197
} catch (e) {
198
-
showMessage('error', e instanceof ApiError ? e.message : 'Failed to generate TOTP secret')
199
} finally {
200
verifyLoading = false
201
}
···
203
204
async function handleVerifySetup(e: Event) {
205
e.preventDefault()
206
-
if (!auth.session || !verifyCode) return
207
verifyLoading = true
208
try {
209
-
const result = await api.enableTotp(auth.session.accessJwt, verifyCode)
210
backupCodes = result.backupCodes
211
setupStep = 'backup'
212
totpEnabled = true
213
hasBackupCodes = true
214
verifyCodeRaw = ''
215
} catch (e) {
216
-
showMessage('error', e instanceof ApiError ? e.message : 'Invalid code. Please try again.')
217
} finally {
218
verifyLoading = false
219
}
···
224
backupCodes = []
225
qrBase64 = ''
226
totpUri = ''
227
-
showMessage('success', $_('security.totpEnabledSuccess'))
228
}
229
230
async function handleDisable(e: Event) {
231
e.preventDefault()
232
-
if (!auth.session || !disablePassword || !disableCode) return
233
disableLoading = true
234
try {
235
-
await api.disableTotp(auth.session.accessJwt, disablePassword, disableCode)
236
totpEnabled = false
237
hasBackupCodes = false
238
showDisableForm = false
239
disablePassword = ''
240
disableCode = ''
241
-
showMessage('success', $_('security.totpDisabledSuccess'))
242
} catch (e) {
243
-
showMessage('error', e instanceof ApiError ? e.message : 'Failed to disable TOTP')
244
} finally {
245
disableLoading = false
246
}
···
248
249
async function handleRegenerate(e: Event) {
250
e.preventDefault()
251
-
if (!auth.session || !regenPassword || !regenCode) return
252
regenLoading = true
253
try {
254
-
const result = await api.regenerateBackupCodes(auth.session.accessJwt, regenPassword, regenCode)
255
backupCodes = result.backupCodes
256
setupStep = 'backup'
257
showRegenForm = false
258
regenPassword = ''
259
regenCode = ''
260
} catch (e) {
261
-
showMessage('error', e instanceof ApiError ? e.message : 'Failed to regenerate backup codes')
262
} finally {
263
regenLoading = false
264
}
···
267
function copyBackupCodes() {
268
const text = backupCodes.join('\n')
269
navigator.clipboard.writeText(text)
270
-
showMessage('success', $_('security.backupCodesCopied'))
271
}
272
273
async function loadPasskeys() {
274
-
if (!auth.session) return
275
passkeysLoading = true
276
try {
277
-
const result = await api.listPasskeys(auth.session.accessJwt)
278
passkeys = result.passkeys
279
} catch {
280
-
showMessage('error', $_('security.failedToLoadPasskeys'))
281
} finally {
282
passkeysLoading = false
283
}
284
}
285
286
async function handleAddPasskey() {
287
-
if (!auth.session) return
288
if (!window.PublicKeyCredential) {
289
-
showMessage('error', $_('security.passkeysNotSupported'))
290
return
291
}
292
addingPasskey = true
293
try {
294
-
const { options } = await api.startPasskeyRegistration(auth.session.accessJwt, newPasskeyName || undefined)
295
-
const publicKeyOptions = preparePublicKeyOptions(options)
296
const credential = await navigator.credentials.create({
297
publicKey: publicKeyOptions
298
})
299
if (!credential) {
300
-
showMessage('error', $_('security.passkeyCreationCancelled'))
301
return
302
}
303
-
const credentialResponse = {
304
-
id: credential.id,
305
-
type: credential.type,
306
-
rawId: arrayBufferToBase64Url((credential as PublicKeyCredential).rawId),
307
-
response: {
308
-
clientDataJSON: arrayBufferToBase64Url((credential as PublicKeyCredential).response.clientDataJSON),
309
-
attestationObject: arrayBufferToBase64Url(((credential as PublicKeyCredential).response as AuthenticatorAttestationResponse).attestationObject),
310
-
},
311
-
}
312
-
await api.finishPasskeyRegistration(auth.session.accessJwt, credentialResponse, newPasskeyName || undefined)
313
await loadPasskeys()
314
newPasskeyName = ''
315
-
showMessage('success', $_('security.passkeyAddedSuccess'))
316
} catch (e) {
317
if (e instanceof DOMException && e.name === 'NotAllowedError') {
318
-
showMessage('error', $_('security.passkeyCreationCancelled'))
319
} else {
320
-
showMessage('error', e instanceof ApiError ? e.message : 'Failed to add passkey')
321
}
322
} finally {
323
addingPasskey = false
···
325
}
326
327
async function handleDeletePasskey(id: string) {
328
-
if (!auth.session) return
329
const passkey = passkeys.find(p => p.id === id)
330
const name = passkey?.friendlyName || 'this passkey'
331
if (!confirm($_('security.deletePasskeyConfirm', { values: { name } }))) return
332
try {
333
-
await api.deletePasskey(auth.session.accessJwt, id)
334
await loadPasskeys()
335
-
showMessage('success', $_('security.passkeyDeleted'))
336
} catch (e) {
337
-
showMessage('error', e instanceof ApiError ? e.message : 'Failed to delete passkey')
338
}
339
}
340
341
async function handleSavePasskeyName() {
342
-
if (!auth.session || !editingPasskeyId || !editPasskeyName.trim()) return
343
try {
344
-
await api.updatePasskey(auth.session.accessJwt, editingPasskeyId, editPasskeyName.trim())
345
await loadPasskeys()
346
editingPasskeyId = null
347
editPasskeyName = ''
348
-
showMessage('success', $_('security.passkeyRenamed'))
349
} catch (e) {
350
-
showMessage('error', e instanceof ApiError ? e.message : 'Failed to rename passkey')
351
}
352
}
353
···
361
editPasskeyName = ''
362
}
363
364
-
function arrayBufferToBase64Url(buffer: ArrayBuffer): string {
365
-
const bytes = new Uint8Array(buffer)
366
-
let binary = ''
367
-
for (let i = 0; i < bytes.byteLength; i++) {
368
-
binary += String.fromCharCode(bytes[i])
369
-
}
370
-
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
371
-
}
372
-
373
-
function base64UrlToArrayBuffer(base64url: string): ArrayBuffer {
374
-
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
375
-
const padded = base64 + '='.repeat((4 - base64.length % 4) % 4)
376
-
const binary = atob(padded)
377
-
const bytes = new Uint8Array(binary.length)
378
-
for (let i = 0; i < binary.length; i++) {
379
-
bytes[i] = binary.charCodeAt(i)
380
-
}
381
-
return bytes.buffer
382
-
}
383
-
384
-
function preparePublicKeyOptions(options: any): PublicKeyCredentialCreationOptions {
385
-
return {
386
-
...options.publicKey,
387
-
challenge: base64UrlToArrayBuffer(options.publicKey.challenge),
388
-
user: {
389
-
...options.publicKey.user,
390
-
id: base64UrlToArrayBuffer(options.publicKey.user.id)
391
-
},
392
-
excludeCredentials: options.publicKey.excludeCredentials?.map((cred: any) => ({
393
-
...cred,
394
-
id: base64UrlToArrayBuffer(cred.id)
395
-
})) || []
396
-
}
397
-
}
398
-
399
function formatDate(dateStr: string): string {
400
return formatDateUtil(dateStr)
401
}
···
403
404
<div class="page">
405
<header>
406
-
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
407
<h1>{$_('security.title')}</h1>
408
</header>
409
410
-
{#if message}
411
-
<div class="message {message.type}">{message.text}</div>
412
-
{/if}
413
-
414
{#if loading}
415
-
<div class="loading">{$_('common.loading')}</div>
416
{:else}
417
<div class="sections-grid">
418
<section>
···
594
{$_('security.passkeysDescription')}
595
</p>
596
597
-
{#if passkeysLoading}
598
-
<div class="loading">{$_('security.loadingPasskeys')}</div>
599
-
{:else}
600
{#if passkeys.length > 0}
601
<div class="passkey-list">
602
{#each passkeys as passkey}
···
668
{$_('security.passwordDescription')}
669
</p>
670
671
-
{#if passwordLoading}
672
-
<div class="loading">{$_('common.loading')}</div>
673
-
{:else if hasPassword}
674
<div class="status enabled">
675
<span>{$_('security.passwordStatus')}</span>
676
</div>
···
722
<p class="description">
723
{$_('security.trustedDevicesDescription')}
724
</p>
725
-
<a href="/app/trusted-devices" class="section-link">
726
{$_('security.manageTrustedDevices')} →
727
</a>
728
</section>
···
735
{$_('security.legacyLoginDescription')}
736
</p>
737
738
-
{#if legacyLoginLoading}
739
-
<div class="loading">{$_('common.loading')}</div>
740
-
{:else}
741
<div class="toggle-row">
742
<div class="toggle-info">
743
<span class="toggle-label">{$_('security.legacyLogin')}</span>
···
765
<strong>{$_('security.legacyLoginWarning')}</strong>
766
<p>{$_('security.totpPasswordWarning')}</p>
767
<ol>
768
-
<li><strong>{$_('security.totpPasswordOption1Label')}</strong> {$_('security.totpPasswordOption1Text')} <a href="/app/settings">{$_('security.totpPasswordOption1Link')}</a> {$_('security.totpPasswordOption1Suffix')}</li>
769
-
<li><strong>{$_('security.totpPasswordOption2Label')}</strong> {$_('security.totpPasswordOption2Text')} <a href="/app/settings">{$_('security.totpPasswordOption2Link')}</a> {$_('security.totpPasswordOption2Suffix')}</li>
770
</ol>
771
</div>
772
{/if}
···
1221
1222
.warning-box a {
1223
color: var(--accent);
1224
}
1225
</style>
···
1
<script lang="ts">
2
import { getAuthState, getValidToken } from '../lib/auth.svelte'
3
+
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
4
import { api, ApiError } from '../lib/api'
5
import ReauthModal from '../components/ReauthModal.svelte'
6
import { _ } from '../lib/i18n'
7
import { formatDate as formatDateUtil } from '../lib/date'
8
+
import type { Session } from '../lib/types/api'
9
+
import {
10
+
prepareCreationOptions,
11
+
serializeAttestationResponse,
12
+
type WebAuthnCreationOptionsResponse,
13
+
} from '../lib/webauthn'
14
+
import { toast } from '../lib/toast.svelte'
15
16
+
const auth = $derived(getAuthState())
17
+
18
+
function getSession(): Session | null {
19
+
return auth.kind === 'authenticated' ? auth.session : null
20
+
}
21
+
22
+
function isLoading(): boolean {
23
+
return auth.kind === 'loading'
24
+
}
25
+
26
+
const session = $derived(getSession())
27
+
const authLoading = $derived(isLoading())
28
+
29
let loading = $state(true)
30
let totpEnabled = $state(false)
31
let hasBackupCodes = $state(false)
···
74
let pendingAction = $state<(() => Promise<void>) | null>(null)
75
76
$effect(() => {
77
+
if (!authLoading && !session) {
78
+
navigate(routes.login)
79
}
80
})
81
82
$effect(() => {
83
+
if (session) {
84
loadTotpStatus()
85
loadPasskeys()
86
loadPasswordStatus()
···
89
})
90
91
async function loadPasswordStatus() {
92
+
if (!session) return
93
passwordLoading = true
94
try {
95
+
const status = await api.getPasswordStatus(session.accessJwt)
96
hasPassword = status.hasPassword
97
} catch {
98
hasPassword = true
···
102
}
103
104
async function loadLegacyLoginPreference() {
105
+
if (!session) return
106
legacyLoginLoading = true
107
try {
108
+
const pref = await api.getLegacyLoginPreference(session.accessJwt)
109
allowLegacyLogin = pref.allowLegacyLogin
110
hasMfa = pref.hasMfa
111
} catch {
···
117
}
118
119
async function handleToggleLegacyLogin() {
120
+
if (!session) return
121
legacyLoginUpdating = true
122
try {
123
+
const result = await api.updateLegacyLoginPreference(session.accessJwt, !allowLegacyLogin)
124
allowLegacyLogin = result.allowLegacyLogin
125
+
toast.success(allowLegacyLogin
126
? $_('security.legacyLoginEnabled')
127
: $_('security.legacyLoginDisabled'))
128
} catch (e) {
···
132
pendingAction = handleToggleLegacyLogin
133
showReauthModal = true
134
} else {
135
+
toast.error(e.message)
136
}
137
} else {
138
+
toast.error($_('security.failedToUpdatePreference'))
139
}
140
} finally {
141
legacyLoginUpdating = false
···
143
}
144
145
async function handleRemovePassword() {
146
+
if (!session) return
147
removePasswordLoading = true
148
try {
149
const token = await getValidToken()
150
if (!token) {
151
+
toast.error($_('security.sessionExpired'))
152
return
153
}
154
await api.removePassword(token)
155
hasPassword = false
156
showRemovePasswordForm = false
157
+
toast.success($_('security.passwordRemoved'))
158
} catch (e) {
159
if (e instanceof ApiError) {
160
if (e.error === 'ReauthRequired') {
···
162
pendingAction = handleRemovePassword
163
showReauthModal = true
164
} else {
165
+
toast.error(e.message)
166
}
167
} else {
168
+
toast.error($_('security.failedToRemovePassword'))
169
}
170
} finally {
171
removePasswordLoading = false
···
184
}
185
186
async function loadTotpStatus() {
187
+
if (!session) return
188
loading = true
189
try {
190
+
const status = await api.getTotpStatus(session.accessJwt)
191
totpEnabled = status.enabled
192
hasBackupCodes = status.hasBackupCodes
193
} catch {
194
+
toast.error($_('security.failedToLoadTotpStatus'))
195
} finally {
196
loading = false
197
}
198
}
199
200
async function handleStartSetup() {
201
+
if (!session) return
202
verifyLoading = true
203
try {
204
+
const result = await api.createTotpSecret(session.accessJwt)
205
qrBase64 = result.qrBase64
206
totpUri = result.uri
207
setupStep = 'qr'
208
} catch (e) {
209
+
toast.error(e instanceof ApiError ? e.message : 'Failed to generate TOTP secret')
210
} finally {
211
verifyLoading = false
212
}
···
214
215
async function handleVerifySetup(e: Event) {
216
e.preventDefault()
217
+
if (!session || !verifyCode) return
218
verifyLoading = true
219
try {
220
+
const result = await api.enableTotp(session.accessJwt, verifyCode)
221
backupCodes = result.backupCodes
222
setupStep = 'backup'
223
totpEnabled = true
224
hasBackupCodes = true
225
verifyCodeRaw = ''
226
} catch (e) {
227
+
toast.error(e instanceof ApiError ? e.message : 'Invalid code. Please try again.')
228
} finally {
229
verifyLoading = false
230
}
···
235
backupCodes = []
236
qrBase64 = ''
237
totpUri = ''
238
+
toast.success($_('security.totpEnabledSuccess'))
239
}
240
241
async function handleDisable(e: Event) {
242
e.preventDefault()
243
+
if (!session || !disablePassword || !disableCode) return
244
disableLoading = true
245
try {
246
+
await api.disableTotp(session.accessJwt, disablePassword, disableCode)
247
totpEnabled = false
248
hasBackupCodes = false
249
showDisableForm = false
250
disablePassword = ''
251
disableCode = ''
252
+
toast.success($_('security.totpDisabledSuccess'))
253
} catch (e) {
254
+
toast.error(e instanceof ApiError ? e.message : 'Failed to disable TOTP')
255
} finally {
256
disableLoading = false
257
}
···
259
260
async function handleRegenerate(e: Event) {
261
e.preventDefault()
262
+
if (!session || !regenPassword || !regenCode) return
263
regenLoading = true
264
try {
265
+
const result = await api.regenerateBackupCodes(session.accessJwt, regenPassword, regenCode)
266
backupCodes = result.backupCodes
267
setupStep = 'backup'
268
showRegenForm = false
269
regenPassword = ''
270
regenCode = ''
271
} catch (e) {
272
+
toast.error(e instanceof ApiError ? e.message : 'Failed to regenerate backup codes')
273
} finally {
274
regenLoading = false
275
}
···
278
function copyBackupCodes() {
279
const text = backupCodes.join('\n')
280
navigator.clipboard.writeText(text)
281
+
toast.success($_('security.backupCodesCopied'))
282
}
283
284
async function loadPasskeys() {
285
+
if (!session) return
286
passkeysLoading = true
287
try {
288
+
const result = await api.listPasskeys(session.accessJwt)
289
passkeys = result.passkeys
290
} catch {
291
+
toast.error($_('security.failedToLoadPasskeys'))
292
} finally {
293
passkeysLoading = false
294
}
295
}
296
297
async function handleAddPasskey() {
298
+
if (!session) return
299
if (!window.PublicKeyCredential) {
300
+
toast.error($_('security.passkeysNotSupported'))
301
return
302
}
303
addingPasskey = true
304
try {
305
+
const { options } = await api.startPasskeyRegistration(session.accessJwt, newPasskeyName || undefined)
306
+
const publicKeyOptions = prepareCreationOptions(options as WebAuthnCreationOptionsResponse)
307
const credential = await navigator.credentials.create({
308
publicKey: publicKeyOptions
309
})
310
if (!credential) {
311
+
toast.error($_('security.passkeyCreationCancelled'))
312
return
313
}
314
+
const credentialResponse = serializeAttestationResponse(credential as PublicKeyCredential)
315
+
await api.finishPasskeyRegistration(session.accessJwt, credentialResponse, newPasskeyName || undefined)
316
await loadPasskeys()
317
newPasskeyName = ''
318
+
toast.success($_('security.passkeyAddedSuccess'))
319
} catch (e) {
320
if (e instanceof DOMException && e.name === 'NotAllowedError') {
321
+
toast.error($_('security.passkeyCreationCancelled'))
322
} else {
323
+
toast.error(e instanceof ApiError ? e.message : 'Failed to add passkey')
324
}
325
} finally {
326
addingPasskey = false
···
328
}
329
330
async function handleDeletePasskey(id: string) {
331
+
if (!session) return
332
const passkey = passkeys.find(p => p.id === id)
333
const name = passkey?.friendlyName || 'this passkey'
334
if (!confirm($_('security.deletePasskeyConfirm', { values: { name } }))) return
335
try {
336
+
await api.deletePasskey(session.accessJwt, id)
337
await loadPasskeys()
338
+
toast.success($_('security.passkeyDeleted'))
339
} catch (e) {
340
+
toast.error(e instanceof ApiError ? e.message : 'Failed to delete passkey')
341
}
342
}
343
344
async function handleSavePasskeyName() {
345
+
if (!session || !editingPasskeyId || !editPasskeyName.trim()) return
346
try {
347
+
await api.updatePasskey(session.accessJwt, editingPasskeyId, editPasskeyName.trim())
348
await loadPasskeys()
349
editingPasskeyId = null
350
editPasskeyName = ''
351
+
toast.success($_('security.passkeyRenamed'))
352
} catch (e) {
353
+
toast.error(e instanceof ApiError ? e.message : 'Failed to rename passkey')
354
}
355
}
356
···
364
editPasskeyName = ''
365
}
366
367
function formatDate(dateStr: string): string {
368
return formatDateUtil(dateStr)
369
}
···
371
372
<div class="page">
373
<header>
374
+
<a href={getFullUrl(routes.dashboard)} class="back">{$_('common.backToDashboard')}</a>
375
<h1>{$_('security.title')}</h1>
376
</header>
377
378
{#if loading}
379
+
<div class="skeleton-grid">
380
+
{#each Array(4) as _}
381
+
<div class="skeleton-section"></div>
382
+
{/each}
383
+
</div>
384
{:else}
385
<div class="sections-grid">
386
<section>
···
562
{$_('security.passkeysDescription')}
563
</p>
564
565
+
{#if !passkeysLoading}
566
{#if passkeys.length > 0}
567
<div class="passkey-list">
568
{#each passkeys as passkey}
···
634
{$_('security.passwordDescription')}
635
</p>
636
637
+
{#if !passwordLoading && hasPassword}
638
<div class="status enabled">
639
<span>{$_('security.passwordStatus')}</span>
640
</div>
···
686
<p class="description">
687
{$_('security.trustedDevicesDescription')}
688
</p>
689
+
<a href={getFullUrl(routes.trustedDevices)} class="section-link">
690
{$_('security.manageTrustedDevices')} →
691
</a>
692
</section>
···
699
{$_('security.legacyLoginDescription')}
700
</p>
701
702
+
{#if !legacyLoginLoading}
703
<div class="toggle-row">
704
<div class="toggle-info">
705
<span class="toggle-label">{$_('security.legacyLogin')}</span>
···
727
<strong>{$_('security.legacyLoginWarning')}</strong>
728
<p>{$_('security.totpPasswordWarning')}</p>
729
<ol>
730
+
<li><strong>{$_('security.totpPasswordOption1Label')}</strong> {$_('security.totpPasswordOption1Text')} <a href={getFullUrl(routes.settings)}>{$_('security.totpPasswordOption1Link')}</a> {$_('security.totpPasswordOption1Suffix')}</li>
731
+
<li><strong>{$_('security.totpPasswordOption2Label')}</strong> {$_('security.totpPasswordOption2Text')} <a href={getFullUrl(routes.settings)}>{$_('security.totpPasswordOption2Link')}</a> {$_('security.totpPasswordOption2Suffix')}</li>
732
</ol>
733
</div>
734
{/if}
···
1183
1184
.warning-box a {
1185
color: var(--accent);
1186
+
}
1187
+
1188
+
.skeleton-grid {
1189
+
display: grid;
1190
+
grid-template-columns: repeat(2, 1fr);
1191
+
gap: var(--space-6);
1192
+
}
1193
+
1194
+
.skeleton-section {
1195
+
height: 200px;
1196
+
background: var(--bg-secondary);
1197
+
border-radius: var(--radius-xl);
1198
+
animation: skeleton-pulse 1.5s ease-in-out infinite;
1199
+
}
1200
+
1201
+
@keyframes skeleton-pulse {
1202
+
0%, 100% { opacity: 1; }
1203
+
50% { opacity: 0.5; }
1204
+
}
1205
+
1206
+
@media (max-width: 900px) {
1207
+
.skeleton-grid {
1208
+
grid-template-columns: 1fr;
1209
+
}
1210
}
1211
</style>
+51
-24
frontend/src/routes/Sessions.svelte
+51
-24
frontend/src/routes/Sessions.svelte
···
1
<script lang="ts">
2
import { getAuthState } from '../lib/auth.svelte'
3
-
import { navigate } from '../lib/router.svelte'
4
import { api, ApiError } from '../lib/api'
5
import { _ } from '../lib/i18n'
6
import { formatDateTime } from '../lib/date'
7
-
const auth = getAuthState()
8
let loading = $state(true)
9
-
let error = $state<string | null>(null)
10
let sessions = $state<Array<{
11
id: string
12
sessionType: string
···
16
isCurrent: boolean
17
}>>([])
18
$effect(() => {
19
-
if (!auth.loading && !auth.session) {
20
-
navigate('/login')
21
}
22
})
23
$effect(() => {
24
-
if (auth.session) {
25
loadSessions()
26
}
27
})
28
async function loadSessions() {
29
-
if (!auth.session) return
30
loading = true
31
-
error = null
32
try {
33
-
const result = await api.listSessions(auth.session.accessJwt)
34
sessions = result.sessions
35
} catch (e) {
36
-
error = e instanceof ApiError ? e.message : $_('sessions.failedToLoad')
37
} finally {
38
loading = false
39
}
40
}
41
async function revokeSession(sessionId: string, isCurrent: boolean) {
42
-
if (!auth.session) return
43
const msg = isCurrent
44
? $_('sessions.revokeCurrentConfirm')
45
: $_('sessions.revokeConfirm')
46
if (!confirm(msg)) return
47
try {
48
-
await api.revokeSession(auth.session.accessJwt, sessionId)
49
if (isCurrent) {
50
-
navigate('/login')
51
} else {
52
sessions = sessions.filter(s => s.id !== sessionId)
53
}
54
} catch (e) {
55
-
error = e instanceof ApiError ? e.message : $_('sessions.failedToRevoke')
56
}
57
}
58
async function revokeAllSessions() {
59
-
if (!auth.session) return
60
const otherSessions = sessions.filter(s => !s.isCurrent)
61
if (otherSessions.length === 0) {
62
-
error = $_('sessions.noOtherSessions')
63
return
64
}
65
if (!confirm($_('sessions.revokeAllConfirm', { values: { count: otherSessions.length } }))) return
66
try {
67
-
await api.revokeAllSessions(auth.session.accessJwt)
68
sessions = sessions.filter(s => s.isCurrent)
69
} catch (e) {
70
-
error = e instanceof ApiError ? e.message : $_('sessions.failedToRevokeAll')
71
}
72
}
73
function formatDate(dateStr: string): string {
···
88
</script>
89
<div class="page">
90
<header>
91
-
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
92
<h1>{$_('sessions.title')}</h1>
93
</header>
94
{#if loading}
95
-
<p class="loading">{$_('sessions.loadingSessions')}</p>
96
{:else}
97
-
{#if error}
98
-
<div class="message error">{error}</div>
99
-
{/if}
100
{#if sessions.length === 0}
101
<p class="empty">{$_('sessions.noSessions')}</p>
102
{:else}
···
172
margin: var(--space-2) 0 0 0;
173
}
174
175
-
.loading,
176
.empty {
177
text-align: center;
178
color: var(--text-secondary);
179
padding: var(--space-7);
180
}
181
182
.sessions-list {
···
1
<script lang="ts">
2
import { getAuthState } from '../lib/auth.svelte'
3
+
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
4
import { api, ApiError } from '../lib/api'
5
import { _ } from '../lib/i18n'
6
import { formatDateTime } from '../lib/date'
7
+
import type { Session } from '../lib/types/api'
8
+
import { toast } from '../lib/toast.svelte'
9
+
10
+
const auth = $derived(getAuthState())
11
+
12
+
function getSession(): Session | null {
13
+
return auth.kind === 'authenticated' ? auth.session : null
14
+
}
15
+
16
+
function isLoading(): boolean {
17
+
return auth.kind === 'loading'
18
+
}
19
+
20
+
const session = $derived(getSession())
21
+
const authLoading = $derived(isLoading())
22
let loading = $state(true)
23
let sessions = $state<Array<{
24
id: string
25
sessionType: string
···
29
isCurrent: boolean
30
}>>([])
31
$effect(() => {
32
+
if (!authLoading && !session) {
33
+
navigate(routes.login)
34
}
35
})
36
$effect(() => {
37
+
if (session) {
38
loadSessions()
39
}
40
})
41
async function loadSessions() {
42
+
if (!session) return
43
loading = true
44
try {
45
+
const result = await api.listSessions(session.accessJwt)
46
sessions = result.sessions
47
} catch (e) {
48
+
toast.error(e instanceof ApiError ? e.message : $_('sessions.failedToLoad'))
49
} finally {
50
loading = false
51
}
52
}
53
async function revokeSession(sessionId: string, isCurrent: boolean) {
54
+
if (!session) return
55
const msg = isCurrent
56
? $_('sessions.revokeCurrentConfirm')
57
: $_('sessions.revokeConfirm')
58
if (!confirm(msg)) return
59
try {
60
+
await api.revokeSession(session.accessJwt, sessionId)
61
if (isCurrent) {
62
+
navigate(routes.login)
63
} else {
64
sessions = sessions.filter(s => s.id !== sessionId)
65
+
toast.success($_('sessions.sessionRevoked'))
66
}
67
} catch (e) {
68
+
toast.error(e instanceof ApiError ? e.message : $_('sessions.failedToRevoke'))
69
}
70
}
71
async function revokeAllSessions() {
72
+
if (!session) return
73
const otherSessions = sessions.filter(s => !s.isCurrent)
74
if (otherSessions.length === 0) {
75
+
toast.warning($_('sessions.noOtherSessions'))
76
return
77
}
78
if (!confirm($_('sessions.revokeAllConfirm', { values: { count: otherSessions.length } }))) return
79
try {
80
+
await api.revokeAllSessions(session.accessJwt)
81
sessions = sessions.filter(s => s.isCurrent)
82
+
toast.success($_('sessions.allSessionsRevoked'))
83
} catch (e) {
84
+
toast.error(e instanceof ApiError ? e.message : $_('sessions.failedToRevokeAll'))
85
}
86
}
87
function formatDate(dateStr: string): string {
···
102
</script>
103
<div class="page">
104
<header>
105
+
<a href={getFullUrl(routes.dashboard)} class="back">{$_('common.backToDashboard')}</a>
106
<h1>{$_('sessions.title')}</h1>
107
</header>
108
{#if loading}
109
+
<div class="sessions-list">
110
+
{#each Array(3) as _}
111
+
<div class="skeleton-card"></div>
112
+
{/each}
113
+
</div>
114
{:else}
115
{#if sessions.length === 0}
116
<p class="empty">{$_('sessions.noSessions')}</p>
117
{:else}
···
187
margin: var(--space-2) 0 0 0;
188
}
189
190
.empty {
191
text-align: center;
192
color: var(--text-secondary);
193
padding: var(--space-7);
194
+
}
195
+
196
+
.skeleton-card {
197
+
height: 80px;
198
+
background: var(--bg-secondary);
199
+
border: 1px solid var(--border-color);
200
+
border-radius: var(--radius-xl);
201
+
animation: skeleton-pulse 1.5s ease-in-out infinite;
202
+
}
203
+
204
+
@keyframes skeleton-pulse {
205
+
0%, 100% { opacity: 1; }
206
+
50% { opacity: 0.5; }
207
}
208
209
.sessions-list {
+100
-97
frontend/src/routes/Settings.svelte
+100
-97
frontend/src/routes/Settings.svelte
···
1
<script lang="ts">
2
import { onMount } from 'svelte'
3
import { getAuthState, logout, refreshSession } from '../lib/auth.svelte'
4
-
import { navigate } from '../lib/router.svelte'
5
import { api, ApiError } from '../lib/api'
6
import { locale, setLocale, getSupportedLocales, localeNames, _, type SupportedLocale } from '../lib/i18n'
7
-
const auth = getAuthState()
8
const supportedLocales = getSupportedLocales()
9
let pdsHostname = $state<string | null>(null)
10
11
onMount(() => {
12
api.describeServer().then(info => {
13
if (info.availableUserDomains?.length) {
···
15
}
16
}).catch(() => {})
17
})
18
let localeLoading = $state(false)
19
async function handleLocaleChange(newLocale: SupportedLocale) {
20
-
if (!auth.session) return
21
setLocale(newLocale)
22
localeLoading = true
23
try {
24
-
await api.updateLocale(auth.session.accessJwt, newLocale)
25
} catch (e) {
26
console.error('Failed to save locale preference:', e)
27
} finally {
28
localeLoading = false
29
}
30
}
31
-
let message = $state<{ type: 'success' | 'error'; text: string } | null>(null)
32
let emailLoading = $state(false)
33
let newEmail = $state('')
34
let emailToken = $state('')
···
46
let newPassword = $state('')
47
let confirmNewPassword = $state('')
48
let showBYOHandle = $state(false)
49
$effect(() => {
50
-
if (!auth.loading && !auth.session) {
51
-
navigate('/login')
52
}
53
})
54
-
function showMessage(type: 'success' | 'error', text: string) {
55
-
message = { type, text }
56
-
setTimeout(() => {
57
-
if (message?.text === text) message = null
58
-
}, 5000)
59
-
}
60
async function handleRequestEmailUpdate() {
61
-
if (!auth.session) return
62
emailLoading = true
63
-
message = null
64
try {
65
-
const result = await api.requestEmailUpdate(auth.session.accessJwt)
66
emailTokenRequired = result.tokenRequired
67
if (emailTokenRequired) {
68
-
showMessage('success', $_('settings.messages.emailCodeSentToCurrent'))
69
} else {
70
emailTokenRequired = true
71
}
72
} catch (e) {
73
-
showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.emailUpdateFailed'))
74
} finally {
75
emailLoading = false
76
}
77
}
78
async function handleConfirmEmailUpdate(e: Event) {
79
e.preventDefault()
80
-
if (!auth.session || !newEmail || !emailToken) return
81
emailLoading = true
82
-
message = null
83
try {
84
-
await api.updateEmail(auth.session.accessJwt, newEmail, emailToken)
85
await refreshSession()
86
-
showMessage('success', $_('settings.messages.emailUpdated'))
87
newEmail = ''
88
emailToken = ''
89
emailTokenRequired = false
90
} catch (e) {
91
-
showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.emailUpdateFailed'))
92
} finally {
93
emailLoading = false
94
}
95
}
96
async function handleUpdateHandle(e: Event) {
97
e.preventDefault()
98
-
if (!auth.session || !newHandle) return
99
handleLoading = true
100
-
message = null
101
try {
102
const fullHandle = showBYOHandle
103
? newHandle
104
: `${newHandle}.${pdsHostname}`
105
-
await api.updateHandle(auth.session.accessJwt, fullHandle)
106
await refreshSession()
107
-
showMessage('success', $_('settings.messages.handleUpdated'))
108
newHandle = ''
109
} catch (e) {
110
-
showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.handleUpdateFailed'))
111
} finally {
112
handleLoading = false
113
}
114
}
115
async function handleRequestDelete() {
116
-
if (!auth.session) return
117
deleteLoading = true
118
-
message = null
119
try {
120
-
await api.requestAccountDelete(auth.session.accessJwt)
121
deleteTokenSent = true
122
-
showMessage('success', $_('settings.messages.deletionConfirmationSent'))
123
} catch (e) {
124
-
showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.deletionRequestFailed'))
125
} finally {
126
deleteLoading = false
127
}
128
}
129
async function handleConfirmDelete(e: Event) {
130
e.preventDefault()
131
-
if (!auth.session || !deletePassword || !deleteToken) return
132
if (!confirm($_('settings.messages.deleteConfirmation'))) {
133
return
134
}
135
deleteLoading = true
136
-
message = null
137
try {
138
-
await api.deleteAccount(auth.session.did, deletePassword, deleteToken)
139
await logout()
140
-
navigate('/login')
141
} catch (e) {
142
-
showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.deletionFailed'))
143
} finally {
144
deleteLoading = false
145
}
146
}
147
async function handleExportRepo() {
148
-
if (!auth.session) return
149
exportLoading = true
150
-
message = null
151
try {
152
-
const response = await fetch(`/xrpc/com.atproto.sync.getRepo?did=${encodeURIComponent(auth.session.did)}`, {
153
headers: {
154
-
'Authorization': `Bearer ${auth.session.accessJwt}`
155
}
156
})
157
if (!response.ok) {
···
162
const url = URL.createObjectURL(blob)
163
const a = document.createElement('a')
164
a.href = url
165
-
a.download = `${auth.session.handle}-repo.car`
166
document.body.appendChild(a)
167
a.click()
168
document.body.removeChild(a)
169
URL.revokeObjectURL(url)
170
-
showMessage('success', $_('settings.messages.repoExported'))
171
} catch (e) {
172
-
showMessage('error', e instanceof Error ? e.message : $_('settings.messages.exportFailed'))
173
} finally {
174
exportLoading = false
175
}
176
}
177
async function handleExportBlobs() {
178
-
if (!auth.session) return
179
exportBlobsLoading = true
180
-
message = null
181
try {
182
const response = await fetch('/xrpc/_backup.exportBlobs', {
183
headers: {
184
-
'Authorization': `Bearer ${auth.session.accessJwt}`
185
}
186
})
187
if (!response.ok) {
···
190
}
191
const blob = await response.blob()
192
if (blob.size === 0) {
193
-
showMessage('success', $_('settings.messages.noBlobsToExport'))
194
return
195
}
196
const url = URL.createObjectURL(blob)
197
const a = document.createElement('a')
198
a.href = url
199
-
a.download = `${auth.session.handle}-blobs.zip`
200
document.body.appendChild(a)
201
a.click()
202
document.body.removeChild(a)
203
URL.revokeObjectURL(url)
204
-
showMessage('success', $_('settings.messages.blobsExported'))
205
} catch (e) {
206
-
showMessage('error', e instanceof Error ? e.message : $_('settings.messages.exportFailed'))
207
} finally {
208
exportBlobsLoading = false
209
}
···
225
let restoreLoading = $state(false)
226
227
async function loadBackups() {
228
-
if (!auth.session) return
229
backupsLoading = true
230
try {
231
-
const result = await api.listBackups(auth.session.accessJwt)
232
backups = result.backups
233
backupEnabled = result.backupEnabled
234
} catch (e) {
···
243
})
244
245
async function handleToggleBackup() {
246
-
if (!auth.session) return
247
const newEnabled = !backupEnabled
248
backupsLoading = true
249
try {
250
-
await api.setBackupEnabled(auth.session.accessJwt, newEnabled)
251
backupEnabled = newEnabled
252
-
showMessage('success', newEnabled ? $_('settings.backups.enabled') : $_('settings.backups.disabled'))
253
} catch (e) {
254
-
showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.toggleFailed'))
255
} finally {
256
backupsLoading = false
257
}
258
}
259
260
async function handleCreateBackup() {
261
-
if (!auth.session) return
262
createBackupLoading = true
263
-
message = null
264
try {
265
-
await api.createBackup(auth.session.accessJwt)
266
await loadBackups()
267
-
showMessage('success', $_('settings.backups.created'))
268
} catch (e) {
269
-
showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.createFailed'))
270
} finally {
271
createBackupLoading = false
272
}
273
}
274
275
async function handleDownloadBackup(id: string, rev: string) {
276
-
if (!auth.session) return
277
try {
278
-
const blob = await api.getBackup(auth.session.accessJwt, id)
279
const url = URL.createObjectURL(blob)
280
const a = document.createElement('a')
281
a.href = url
282
-
a.download = `${auth.session.handle}-${rev}.car`
283
document.body.appendChild(a)
284
a.click()
285
document.body.removeChild(a)
286
URL.revokeObjectURL(url)
287
} catch (e) {
288
-
showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.downloadFailed'))
289
}
290
}
291
292
async function handleDeleteBackup(id: string) {
293
-
if (!auth.session) return
294
try {
295
-
await api.deleteBackup(auth.session.accessJwt, id)
296
await loadBackups()
297
-
showMessage('success', $_('settings.backups.deleted'))
298
} catch (e) {
299
-
showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.deleteFailed'))
300
}
301
}
302
···
308
}
309
310
async function handleRestore() {
311
-
if (!auth.session || !restoreFile) return
312
restoreLoading = true
313
-
message = null
314
try {
315
const buffer = await restoreFile.arrayBuffer()
316
const car = new Uint8Array(buffer)
317
-
await api.importRepo(auth.session.accessJwt, car)
318
-
showMessage('success', $_('settings.backups.restored'))
319
restoreFile = null
320
} catch (e) {
321
-
showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.restoreFailed'))
322
} finally {
323
restoreLoading = false
324
}
···
342
343
async function handleChangePassword(e: Event) {
344
e.preventDefault()
345
-
if (!auth.session || !currentPassword || !newPassword || !confirmNewPassword) return
346
if (newPassword !== confirmNewPassword) {
347
-
showMessage('error', $_('settings.messages.passwordsDoNotMatch'))
348
return
349
}
350
if (newPassword.length < 8) {
351
-
showMessage('error', $_('settings.messages.passwordTooShort'))
352
return
353
}
354
passwordLoading = true
355
-
message = null
356
try {
357
-
await api.changePassword(auth.session.accessJwt, currentPassword, newPassword)
358
-
showMessage('success', $_('settings.messages.passwordChanged'))
359
currentPassword = ''
360
newPassword = ''
361
confirmNewPassword = ''
362
} catch (e) {
363
-
showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.passwordChangeFailed'))
364
} finally {
365
passwordLoading = false
366
}
···
368
</script>
369
<div class="page">
370
<header>
371
-
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
372
<h1>{$_('settings.title')}</h1>
373
</header>
374
-
{#if message}
375
-
<div class="message {message.type}">{message.text}</div>
376
-
{/if}
377
<div class="sections-grid">
378
<section>
379
<h2>{$_('settings.language')}</h2>
···
391
</section>
392
<section>
393
<h2>{$_('settings.changeEmail')}</h2>
394
-
{#if auth.session?.email}
395
-
<p class="current">{$_('settings.currentEmail', { values: { email: auth.session.email } })}</p>
396
{/if}
397
{#if emailTokenRequired}
398
<form onsubmit={handleConfirmEmailUpdate}>
···
435
</section>
436
<section>
437
<h2>{$_('settings.changeHandle')}</h2>
438
-
{#if auth.session}
439
-
<p class="current">{$_('settings.currentHandle', { values: { handle: auth.session.handle } })}</p>
440
{/if}
441
<div class="tabs">
442
<button
···
459
{#if showBYOHandle}
460
<div class="byo-handle">
461
<p class="description">{$_('settings.customDomainDescription')}</p>
462
-
{#if auth.session}
463
<div class="verification-info">
464
<h3>{$_('settings.setupInstructions')}</h3>
465
<p>{$_('settings.setupMethodsIntro')}</p>
466
<div class="method">
467
<h4>{$_('settings.dnsMethod')}</h4>
468
<p>{$_('settings.dnsMethodDesc')}</p>
469
-
<code class="record">_atproto.{newHandle || 'yourdomain.com'} TXT "did={auth.session.did}"</code>
470
</div>
471
<div class="method">
472
<h4>{$_('settings.httpMethod')}</h4>
473
<p>{$_('settings.httpMethodDesc')}</p>
474
<code class="record">https://{newHandle || 'yourdomain.com'}/.well-known/atproto-did</code>
475
<p>{$_('settings.httpMethodContent')}</p>
476
-
<code class="record">{auth.session.did}</code>
477
</div>
478
</div>
479
{/if}
···
579
<span>{$_('settings.backups.enableAutomatic')}</span>
580
</label>
581
582
-
{#if backupsLoading}
583
-
<p class="loading">{$_('common.loading')}</p>
584
-
{:else if backups.length > 0}
585
<ul class="backup-list">
586
{#each backups as backup}
587
<li class="backup-item">
···
1
<script lang="ts">
2
import { onMount } from 'svelte'
3
import { getAuthState, logout, refreshSession } from '../lib/auth.svelte'
4
+
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
5
import { api, ApiError } from '../lib/api'
6
import { locale, setLocale, getSupportedLocales, localeNames, _, type SupportedLocale } from '../lib/i18n'
7
+
import { isOk } from '../lib/types/result'
8
+
import type { Session } from '../lib/types/api'
9
+
import { toast } from '../lib/toast.svelte'
10
+
11
+
const auth = $derived(getAuthState())
12
const supportedLocales = getSupportedLocales()
13
let pdsHostname = $state<string | null>(null)
14
15
+
function getSession(): Session | null {
16
+
return auth.kind === 'authenticated' ? auth.session : null
17
+
}
18
+
19
+
function isLoading(): boolean {
20
+
return auth.kind === 'loading'
21
+
}
22
+
23
+
const session = $derived(getSession())
24
+
const loading = $derived(isLoading())
25
+
26
onMount(() => {
27
api.describeServer().then(info => {
28
if (info.availableUserDomains?.length) {
···
30
}
31
}).catch(() => {})
32
})
33
+
34
let localeLoading = $state(false)
35
async function handleLocaleChange(newLocale: SupportedLocale) {
36
+
if (!session) return
37
setLocale(newLocale)
38
localeLoading = true
39
try {
40
+
await api.updateLocale(session.accessJwt, newLocale)
41
} catch (e) {
42
console.error('Failed to save locale preference:', e)
43
} finally {
44
localeLoading = false
45
}
46
}
47
+
48
let emailLoading = $state(false)
49
let newEmail = $state('')
50
let emailToken = $state('')
···
62
let newPassword = $state('')
63
let confirmNewPassword = $state('')
64
let showBYOHandle = $state(false)
65
+
66
$effect(() => {
67
+
if (!loading && !session) {
68
+
navigate(routes.login)
69
}
70
})
71
+
72
async function handleRequestEmailUpdate() {
73
+
if (!session) return
74
emailLoading = true
75
try {
76
+
const result = await api.requestEmailUpdate(session.accessJwt)
77
emailTokenRequired = result.tokenRequired
78
if (emailTokenRequired) {
79
+
toast.success($_('settings.messages.emailCodeSentToCurrent'))
80
} else {
81
emailTokenRequired = true
82
}
83
} catch (e) {
84
+
toast.error(e instanceof ApiError ? e.message : $_('settings.messages.emailUpdateFailed'))
85
} finally {
86
emailLoading = false
87
}
88
}
89
+
90
async function handleConfirmEmailUpdate(e: Event) {
91
e.preventDefault()
92
+
if (!session || !newEmail || !emailToken) return
93
emailLoading = true
94
try {
95
+
await api.updateEmail(session.accessJwt, newEmail, emailToken)
96
await refreshSession()
97
+
toast.success($_('settings.messages.emailUpdated'))
98
newEmail = ''
99
emailToken = ''
100
emailTokenRequired = false
101
} catch (e) {
102
+
toast.error(e instanceof ApiError ? e.message : $_('settings.messages.emailUpdateFailed'))
103
} finally {
104
emailLoading = false
105
}
106
}
107
+
108
async function handleUpdateHandle(e: Event) {
109
e.preventDefault()
110
+
if (!session || !newHandle) return
111
handleLoading = true
112
try {
113
const fullHandle = showBYOHandle
114
? newHandle
115
: `${newHandle}.${pdsHostname}`
116
+
await api.updateHandle(session.accessJwt, fullHandle)
117
await refreshSession()
118
+
toast.success($_('settings.messages.handleUpdated'))
119
newHandle = ''
120
} catch (e) {
121
+
toast.error(e instanceof ApiError ? e.message : $_('settings.messages.handleUpdateFailed'))
122
} finally {
123
handleLoading = false
124
}
125
}
126
+
127
async function handleRequestDelete() {
128
+
if (!session) return
129
deleteLoading = true
130
try {
131
+
await api.requestAccountDelete(session.accessJwt)
132
deleteTokenSent = true
133
+
toast.success($_('settings.messages.deletionConfirmationSent'))
134
} catch (e) {
135
+
toast.error(e instanceof ApiError ? e.message : $_('settings.messages.deletionRequestFailed'))
136
} finally {
137
deleteLoading = false
138
}
139
}
140
+
141
async function handleConfirmDelete(e: Event) {
142
e.preventDefault()
143
+
if (!session || !deletePassword || !deleteToken) return
144
if (!confirm($_('settings.messages.deleteConfirmation'))) {
145
return
146
}
147
deleteLoading = true
148
try {
149
+
await api.deleteAccount(session.did, deletePassword, deleteToken)
150
await logout()
151
+
navigate(routes.login)
152
} catch (e) {
153
+
toast.error(e instanceof ApiError ? e.message : $_('settings.messages.deletionFailed'))
154
} finally {
155
deleteLoading = false
156
}
157
}
158
+
159
async function handleExportRepo() {
160
+
if (!session) return
161
exportLoading = true
162
try {
163
+
const response = await fetch(`/xrpc/com.atproto.sync.getRepo?did=${encodeURIComponent(session.did)}`, {
164
headers: {
165
+
'Authorization': `Bearer ${session.accessJwt}`
166
}
167
})
168
if (!response.ok) {
···
173
const url = URL.createObjectURL(blob)
174
const a = document.createElement('a')
175
a.href = url
176
+
a.download = `${session.handle}-repo.car`
177
document.body.appendChild(a)
178
a.click()
179
document.body.removeChild(a)
180
URL.revokeObjectURL(url)
181
+
toast.success($_('settings.messages.repoExported'))
182
} catch (e) {
183
+
toast.error(e instanceof Error ? e.message : $_('settings.messages.exportFailed'))
184
} finally {
185
exportLoading = false
186
}
187
}
188
+
189
async function handleExportBlobs() {
190
+
if (!session) return
191
exportBlobsLoading = true
192
try {
193
const response = await fetch('/xrpc/_backup.exportBlobs', {
194
headers: {
195
+
'Authorization': `Bearer ${session.accessJwt}`
196
}
197
})
198
if (!response.ok) {
···
201
}
202
const blob = await response.blob()
203
if (blob.size === 0) {
204
+
toast.success($_('settings.messages.noBlobsToExport'))
205
return
206
}
207
const url = URL.createObjectURL(blob)
208
const a = document.createElement('a')
209
a.href = url
210
+
a.download = `${session.handle}-blobs.zip`
211
document.body.appendChild(a)
212
a.click()
213
document.body.removeChild(a)
214
URL.revokeObjectURL(url)
215
+
toast.success($_('settings.messages.blobsExported'))
216
} catch (e) {
217
+
toast.error(e instanceof Error ? e.message : $_('settings.messages.exportFailed'))
218
} finally {
219
exportBlobsLoading = false
220
}
···
236
let restoreLoading = $state(false)
237
238
async function loadBackups() {
239
+
if (!session) return
240
backupsLoading = true
241
try {
242
+
const result = await api.listBackups(session.accessJwt)
243
backups = result.backups
244
backupEnabled = result.backupEnabled
245
} catch (e) {
···
254
})
255
256
async function handleToggleBackup() {
257
+
if (!session) return
258
const newEnabled = !backupEnabled
259
backupsLoading = true
260
try {
261
+
await api.setBackupEnabled(session.accessJwt, newEnabled)
262
backupEnabled = newEnabled
263
+
toast.success(newEnabled ? $_('settings.backups.enabled') : $_('settings.backups.disabled'))
264
} catch (e) {
265
+
toast.error(e instanceof ApiError ? e.message : $_('settings.backups.toggleFailed'))
266
} finally {
267
backupsLoading = false
268
}
269
}
270
271
async function handleCreateBackup() {
272
+
if (!session) return
273
createBackupLoading = true
274
try {
275
+
await api.createBackup(session.accessJwt)
276
await loadBackups()
277
+
toast.success($_('settings.backups.created'))
278
} catch (e) {
279
+
toast.error(e instanceof ApiError ? e.message : $_('settings.backups.createFailed'))
280
} finally {
281
createBackupLoading = false
282
}
283
}
284
285
async function handleDownloadBackup(id: string, rev: string) {
286
+
if (!session) return
287
try {
288
+
const blob = await api.getBackup(session.accessJwt, id)
289
const url = URL.createObjectURL(blob)
290
const a = document.createElement('a')
291
a.href = url
292
+
a.download = `${session.handle}-${rev}.car`
293
document.body.appendChild(a)
294
a.click()
295
document.body.removeChild(a)
296
URL.revokeObjectURL(url)
297
} catch (e) {
298
+
toast.error(e instanceof ApiError ? e.message : $_('settings.backups.downloadFailed'))
299
}
300
}
301
302
async function handleDeleteBackup(id: string) {
303
+
if (!session) return
304
try {
305
+
await api.deleteBackup(session.accessJwt, id)
306
await loadBackups()
307
+
toast.success($_('settings.backups.deleted'))
308
} catch (e) {
309
+
toast.error(e instanceof ApiError ? e.message : $_('settings.backups.deleteFailed'))
310
}
311
}
312
···
318
}
319
320
async function handleRestore() {
321
+
if (!session || !restoreFile) return
322
restoreLoading = true
323
try {
324
const buffer = await restoreFile.arrayBuffer()
325
const car = new Uint8Array(buffer)
326
+
await api.importRepo(session.accessJwt, car)
327
+
toast.success($_('settings.backups.restored'))
328
restoreFile = null
329
} catch (e) {
330
+
toast.error(e instanceof ApiError ? e.message : $_('settings.backups.restoreFailed'))
331
} finally {
332
restoreLoading = false
333
}
···
351
352
async function handleChangePassword(e: Event) {
353
e.preventDefault()
354
+
if (!session || !currentPassword || !newPassword || !confirmNewPassword) return
355
if (newPassword !== confirmNewPassword) {
356
+
toast.error($_('settings.messages.passwordsDoNotMatch'))
357
return
358
}
359
if (newPassword.length < 8) {
360
+
toast.error($_('settings.messages.passwordTooShort'))
361
return
362
}
363
passwordLoading = true
364
try {
365
+
await api.changePassword(session.accessJwt, currentPassword, newPassword)
366
+
toast.success($_('settings.messages.passwordChanged'))
367
currentPassword = ''
368
newPassword = ''
369
confirmNewPassword = ''
370
} catch (e) {
371
+
toast.error(e instanceof ApiError ? e.message : $_('settings.messages.passwordChangeFailed'))
372
} finally {
373
passwordLoading = false
374
}
···
376
</script>
377
<div class="page">
378
<header>
379
+
<a href={getFullUrl(routes.dashboard)} class="back">{$_('common.backToDashboard')}</a>
380
<h1>{$_('settings.title')}</h1>
381
</header>
382
<div class="sections-grid">
383
<section>
384
<h2>{$_('settings.language')}</h2>
···
396
</section>
397
<section>
398
<h2>{$_('settings.changeEmail')}</h2>
399
+
{#if session?.email}
400
+
<p class="current">{$_('settings.currentEmail', { values: { email: session.email } })}</p>
401
{/if}
402
{#if emailTokenRequired}
403
<form onsubmit={handleConfirmEmailUpdate}>
···
440
</section>
441
<section>
442
<h2>{$_('settings.changeHandle')}</h2>
443
+
{#if session}
444
+
<p class="current">{$_('settings.currentHandle', { values: { handle: session.handle } })}</p>
445
{/if}
446
<div class="tabs">
447
<button
···
464
{#if showBYOHandle}
465
<div class="byo-handle">
466
<p class="description">{$_('settings.customDomainDescription')}</p>
467
+
{#if session}
468
<div class="verification-info">
469
<h3>{$_('settings.setupInstructions')}</h3>
470
<p>{$_('settings.setupMethodsIntro')}</p>
471
<div class="method">
472
<h4>{$_('settings.dnsMethod')}</h4>
473
<p>{$_('settings.dnsMethodDesc')}</p>
474
+
<code class="record">_atproto.{newHandle || 'yourdomain.com'} TXT "did={session.did}"</code>
475
</div>
476
<div class="method">
477
<h4>{$_('settings.httpMethod')}</h4>
478
<p>{$_('settings.httpMethodDesc')}</p>
479
<code class="record">https://{newHandle || 'yourdomain.com'}/.well-known/atproto-did</code>
480
<p>{$_('settings.httpMethodContent')}</p>
481
+
<code class="record">{session.did}</code>
482
</div>
483
</div>
484
{/if}
···
584
<span>{$_('settings.backups.enableAutomatic')}</span>
585
</label>
586
587
+
{#if !backupsLoading && backups.length > 0}
588
<ul class="backup-list">
589
{#each backups as backup}
590
<li class="backup-item">
+54
-30
frontend/src/routes/TrustedDevices.svelte
+54
-30
frontend/src/routes/TrustedDevices.svelte
···
1
<script lang="ts">
2
import { getAuthState } from '../lib/auth.svelte'
3
-
import { navigate } from '../lib/router.svelte'
4
import { api, ApiError } from '../lib/api'
5
import { _ } from '../lib/i18n'
6
import { formatDateTime } from '../lib/date'
7
8
interface TrustedDevice {
9
id: string
···
14
lastSeenAt: string
15
}
16
17
-
const auth = getAuthState()
18
let devices = $state<TrustedDevice[]>([])
19
let loading = $state(true)
20
-
let message = $state<{ type: 'success' | 'error'; text: string } | null>(null)
21
let editingDeviceId = $state<string | null>(null)
22
let editDeviceName = $state('')
23
24
$effect(() => {
25
-
if (!auth.loading && !auth.session) {
26
-
navigate('/login')
27
}
28
})
29
30
$effect(() => {
31
-
if (auth.session) {
32
loadDevices()
33
}
34
})
35
36
async function loadDevices() {
37
-
if (!auth.session) return
38
loading = true
39
try {
40
-
const result = await api.listTrustedDevices(auth.session.accessJwt)
41
devices = result.devices
42
} catch {
43
-
showMessage('error', $_('trustedDevices.failedToLoad'))
44
} finally {
45
loading = false
46
}
47
}
48
49
-
function showMessage(type: 'success' | 'error', text: string) {
50
-
message = { type, text }
51
-
setTimeout(() => {
52
-
if (message?.text === text) message = null
53
-
}, 5000)
54
-
}
55
-
56
async function handleRevoke(deviceId: string) {
57
-
if (!auth.session) return
58
if (!confirm($_('trustedDevices.revokeConfirm'))) return
59
try {
60
-
await api.revokeTrustedDevice(auth.session.accessJwt, deviceId)
61
await loadDevices()
62
-
showMessage('success', $_('trustedDevices.deviceRevoked'))
63
} catch (e) {
64
-
showMessage('error', e instanceof ApiError ? e.message : $_('common.error'))
65
}
66
}
67
···
76
}
77
78
async function handleSaveDeviceName() {
79
-
if (!auth.session || !editingDeviceId || !editDeviceName.trim()) return
80
try {
81
-
await api.updateTrustedDevice(auth.session.accessJwt, editingDeviceId, editDeviceName.trim())
82
await loadDevices()
83
editingDeviceId = null
84
editDeviceName = ''
85
-
showMessage('success', $_('trustedDevices.deviceRenamed'))
86
} catch (e) {
87
-
showMessage('error', e instanceof ApiError ? e.message : $_('common.error'))
88
}
89
}
90
···
112
113
<div class="page">
114
<header>
115
-
<a href="/app/security" class="back">{$_('trustedDevices.backToSecurity')}</a>
116
<h1>{$_('trustedDevices.title')}</h1>
117
</header>
118
-
119
-
{#if message}
120
-
<div class="message {message.type}">{message.text}</div>
121
-
{/if}
122
123
<div class="description">
124
<p>
···
127
</div>
128
129
{#if loading}
130
-
<div class="loading">{$_('common.loading')}</div>
131
{:else if devices.length === 0}
132
<div class="empty-state">
133
<p>{$_('trustedDevices.noDevices')}</p>
···
378
379
.btn-danger:hover {
380
background: var(--error-bg);
381
}
382
</style>
···
1
<script lang="ts">
2
import { getAuthState } from '../lib/auth.svelte'
3
+
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
4
import { api, ApiError } from '../lib/api'
5
import { _ } from '../lib/i18n'
6
import { formatDateTime } from '../lib/date'
7
+
import type { Session } from '../lib/types/api'
8
+
import { toast } from '../lib/toast.svelte'
9
10
interface TrustedDevice {
11
id: string
···
16
lastSeenAt: string
17
}
18
19
+
const auth = $derived(getAuthState())
20
+
21
+
function getSession(): Session | null {
22
+
return auth.kind === 'authenticated' ? auth.session : null
23
+
}
24
+
25
+
function isLoading(): boolean {
26
+
return auth.kind === 'loading'
27
+
}
28
+
29
+
const session = $derived(getSession())
30
+
const authLoading = $derived(isLoading())
31
let devices = $state<TrustedDevice[]>([])
32
let loading = $state(true)
33
let editingDeviceId = $state<string | null>(null)
34
let editDeviceName = $state('')
35
36
$effect(() => {
37
+
if (!authLoading && !session) {
38
+
navigate(routes.login)
39
}
40
})
41
42
$effect(() => {
43
+
if (session) {
44
loadDevices()
45
}
46
})
47
48
async function loadDevices() {
49
+
if (!session) return
50
loading = true
51
try {
52
+
const result = await api.listTrustedDevices(session.accessJwt)
53
devices = result.devices
54
} catch {
55
+
toast.error($_('trustedDevices.failedToLoad'))
56
} finally {
57
loading = false
58
}
59
}
60
61
async function handleRevoke(deviceId: string) {
62
+
if (!session) return
63
if (!confirm($_('trustedDevices.revokeConfirm'))) return
64
try {
65
+
await api.revokeTrustedDevice(session.accessJwt, deviceId)
66
await loadDevices()
67
+
toast.success($_('trustedDevices.deviceRevoked'))
68
} catch (e) {
69
+
toast.error(e instanceof ApiError ? e.message : $_('common.error'))
70
}
71
}
72
···
81
}
82
83
async function handleSaveDeviceName() {
84
+
if (!session || !editingDeviceId || !editDeviceName.trim()) return
85
try {
86
+
await api.updateTrustedDevice(session.accessJwt, editingDeviceId, editDeviceName.trim())
87
await loadDevices()
88
editingDeviceId = null
89
editDeviceName = ''
90
+
toast.success($_('trustedDevices.deviceRenamed'))
91
} catch (e) {
92
+
toast.error(e instanceof ApiError ? e.message : $_('common.error'))
93
}
94
}
95
···
117
118
<div class="page">
119
<header>
120
+
<a href={getFullUrl(routes.security)} class="back">{$_('trustedDevices.backToSecurity')}</a>
121
<h1>{$_('trustedDevices.title')}</h1>
122
</header>
123
124
<div class="description">
125
<p>
···
128
</div>
129
130
{#if loading}
131
+
<div class="skeleton-list">
132
+
{#each Array(2) as _}
133
+
<div class="skeleton-card"></div>
134
+
{/each}
135
+
</div>
136
{:else if devices.length === 0}
137
<div class="empty-state">
138
<p>{$_('trustedDevices.noDevices')}</p>
···
383
384
.btn-danger:hover {
385
background: var(--error-bg);
386
+
}
387
+
388
+
.skeleton-list {
389
+
display: flex;
390
+
flex-direction: column;
391
+
gap: var(--space-4);
392
+
}
393
+
394
+
.skeleton-card {
395
+
height: 100px;
396
+
background: var(--bg-secondary);
397
+
border: 1px solid var(--border-color);
398
+
border-radius: var(--radius-xl);
399
+
animation: skeleton-pulse 1.5s ease-in-out infinite;
400
+
}
401
+
402
+
@keyframes skeleton-pulse {
403
+
0%, 100% { opacity: 1; }
404
+
50% { opacity: 0.5; }
405
}
406
</style>
+20
-19
frontend/src/routes/Verify.svelte
+20
-19
frontend/src/routes/Verify.svelte
···
2
import { onMount } from 'svelte'
3
import { confirmSignup, resendVerification, getAuthState } from '../lib/auth.svelte'
4
import { api, ApiError } from '../lib/api'
5
-
import { navigate } from '../lib/router.svelte'
6
import { _ } from '../lib/i18n'
7
8
const STORAGE_KEY = 'tranquil_pds_pending_verification'
9
···
29
let successPurpose = $state<string | null>(null)
30
let successChannel = $state<string | null>(null)
31
32
-
const auth = getAuthState()
33
34
35
-
function parseQueryParams() {
36
-
const params: Record<string, string> = {}
37
-
const searchParams = new URLSearchParams(window.location.search)
38
-
for (const [key, value] of searchParams.entries()) {
39
-
params[key] = value
40
-
}
41
-
return params
42
}
43
44
onMount(async () => {
···
74
})
75
76
$effect(() => {
77
-
if (mode === 'signup' && auth.session) {
78
clearPendingVerification()
79
-
navigate('/dashboard')
80
}
81
})
82
···
96
await confirmSignup(pendingVerification.did, verificationCode.trim())
97
clearPendingVerification()
98
navigate('/dashboard')
99
-
} catch (e: any) {
100
-
error = e.message || 'Verification failed'
101
} finally {
102
submitting = false
103
}
···
118
success = true
119
successPurpose = result.purpose
120
successChannel = result.channel
121
-
} catch (e: any) {
122
if (e instanceof ApiError) {
123
if (e.error === 'AuthenticationRequired') {
124
error = 'You must be signed in to complete this verification. Please sign in and try again.'
···
149
success = true
150
successPurpose = 'email-update'
151
successChannel = 'email'
152
-
} catch (e: any) {
153
if (e instanceof ApiError) {
154
error = e.message
155
} else {
···
171
try {
172
await resendVerification(pendingVerification.did)
173
resendMessage = $_('verify.codeResent')
174
-
} catch (e: any) {
175
-
error = e.message || 'Failed to resend code'
176
} finally {
177
resendingCode = false
178
}
···
186
try {
187
await api.resendMigrationVerification(identifier.trim())
188
resendMessage = $_('verify.codeResentDetail')
189
-
} catch (e: any) {
190
-
error = e.message || 'Failed to resend verification'
191
} finally {
192
resendingCode = false
193
}
···
2
import { onMount } from 'svelte'
3
import { confirmSignup, resendVerification, getAuthState } from '../lib/auth.svelte'
4
import { api, ApiError } from '../lib/api'
5
+
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
6
import { _ } from '../lib/i18n'
7
+
import type { Session } from '../lib/types/api'
8
9
const STORAGE_KEY = 'tranquil_pds_pending_verification'
10
···
30
let successPurpose = $state<string | null>(null)
31
let successChannel = $state<string | null>(null)
32
33
+
const auth = $derived(getAuthState())
34
35
+
function getSession(): Session | null {
36
+
return auth.kind === 'authenticated' ? auth.session : null
37
+
}
38
39
+
const session = $derived(getSession())
40
+
41
+
function parseQueryParams(): Record<string, string> {
42
+
return Object.fromEntries(new URLSearchParams(window.location.search))
43
}
44
45
onMount(async () => {
···
75
})
76
77
$effect(() => {
78
+
if (mode === 'signup' && session) {
79
clearPendingVerification()
80
+
navigate(routes.dashboard)
81
}
82
})
83
···
97
await confirmSignup(pendingVerification.did, verificationCode.trim())
98
clearPendingVerification()
99
navigate('/dashboard')
100
+
} catch (e) {
101
+
error = e instanceof Error ? e.message : 'Verification failed'
102
} finally {
103
submitting = false
104
}
···
119
success = true
120
successPurpose = result.purpose
121
successChannel = result.channel
122
+
} catch (e) {
123
if (e instanceof ApiError) {
124
if (e.error === 'AuthenticationRequired') {
125
error = 'You must be signed in to complete this verification. Please sign in and try again.'
···
150
success = true
151
successPurpose = 'email-update'
152
successChannel = 'email'
153
+
} catch (e) {
154
if (e instanceof ApiError) {
155
error = e.message
156
} else {
···
172
try {
173
await resendVerification(pendingVerification.did)
174
resendMessage = $_('verify.codeResent')
175
+
} catch (e) {
176
+
error = e instanceof Error ? e.message : 'Failed to resend code'
177
} finally {
178
resendingCode = false
179
}
···
187
try {
188
await api.resendMigrationVerification(identifier.trim())
189
resendMessage = $_('verify.codeResentDetail')
190
+
} catch (e) {
191
+
error = e instanceof Error ? e.message : 'Failed to resend verification'
192
} finally {
193
resendingCode = false
194
}
+2
-2
src/api/error.rs
+2
-2
src/api/error.rs
···
128
| Self::AccountTakedown
129
| Self::InvalidCode(_)
130
| Self::InvalidPassword(_)
131
| Self::PasskeyCounterAnomaly => StatusCode::UNAUTHORIZED,
132
Self::Forbidden
133
| Self::AdminRequired
···
196
| Self::InvalidVerificationChannel
197
| Self::SelfHostedDidWebDisabled
198
| Self::AccountAlreadyExists
199
-
| Self::InvalidToken(_)
200
-
| Self::ExpiredToken(_)
201
| Self::TokenRequired => StatusCode::BAD_REQUEST,
202
Self::PasskeyNotFound => StatusCode::NOT_FOUND,
203
}
···
128
| Self::AccountTakedown
129
| Self::InvalidCode(_)
130
| Self::InvalidPassword(_)
131
+
| Self::InvalidToken(_)
132
+
| Self::ExpiredToken(_)
133
| Self::PasskeyCounterAnomaly => StatusCode::UNAUTHORIZED,
134
Self::Forbidden
135
| Self::AdminRequired
···
198
| Self::InvalidVerificationChannel
199
| Self::SelfHostedDidWebDisabled
200
| Self::AccountAlreadyExists
201
| Self::TokenRequired => StatusCode::BAD_REQUEST,
202
Self::PasskeyNotFound => StatusCode::NOT_FOUND,
203
}