+228
-434
frontend/deno.lock
+228
-434
frontend/deno.lock
···
5
5
"npm:@atcute/crypto@^2.3.0": "2.3.0",
6
6
"npm:@atcute/did-plc@~0.3.1": "0.3.1",
7
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",
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
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"
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
22
},
23
23
"npm": {
24
24
"@adobe/css-tools@4.4.4": {
···
54
54
"dependencies": [
55
55
"@atcute/multibase",
56
56
"@atcute/uint8array",
57
-
"@noble/secp256k1@3.0.0"
57
+
"@noble/secp256k1"
58
58
]
59
59
},
60
60
"@atcute/did-plc@0.3.1": {
···
96
96
"@atcute/uint8array@1.0.6": {
97
97
"integrity": "sha512-ucfRBQc7BFT8n9eCyGOzDHEMKF/nZwhS2pPao4Xtab1ML3HdFYcX2DM1tadCzas85QTGxHe5urnUAAcNKGRi9A=="
98
98
},
99
-
"@atcute/util-fetch@1.0.4": {
100
-
"integrity": "sha512-sIU9Qk0dE8PLEXSfhy+gIJV+HpiiknMytCI2SqLlqd0vgZUtEKI/EQfP+23LHWvP+CLCzVDOa6cpH045OlmNBg==",
99
+
"@atcute/util-fetch@1.0.5": {
100
+
"integrity": "sha512-qjHj01BGxjSjIFdPiAjSARnodJIIyKxnCMMEcXMESo9TAyND6XZQqrie5fia+LlYWVXdpsTds8uFQwc9jdKTig==",
101
101
"dependencies": [
102
102
"@badrap/valita"
103
103
]
···
158
158
"os": ["aix"],
159
159
"cpu": ["ppc64"]
160
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==",
161
+
"@esbuild/aix-ppc64@0.27.2": {
162
+
"integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
168
163
"os": ["aix"],
169
164
"cpu": ["ppc64"]
170
165
},
···
173
168
"os": ["android"],
174
169
"cpu": ["arm64"]
175
170
},
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==",
171
+
"@esbuild/android-arm64@0.27.2": {
172
+
"integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
183
173
"os": ["android"],
184
174
"cpu": ["arm64"]
185
175
},
···
188
178
"os": ["android"],
189
179
"cpu": ["arm"]
190
180
},
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==",
181
+
"@esbuild/android-arm@0.27.2": {
182
+
"integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
198
183
"os": ["android"],
199
184
"cpu": ["arm"]
200
185
},
···
203
188
"os": ["android"],
204
189
"cpu": ["x64"]
205
190
},
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==",
191
+
"@esbuild/android-x64@0.27.2": {
192
+
"integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
213
193
"os": ["android"],
214
194
"cpu": ["x64"]
215
195
},
···
218
198
"os": ["darwin"],
219
199
"cpu": ["arm64"]
220
200
},
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==",
201
+
"@esbuild/darwin-arm64@0.27.2": {
202
+
"integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
228
203
"os": ["darwin"],
229
204
"cpu": ["arm64"]
230
205
},
···
233
208
"os": ["darwin"],
234
209
"cpu": ["x64"]
235
210
},
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==",
211
+
"@esbuild/darwin-x64@0.27.2": {
212
+
"integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
243
213
"os": ["darwin"],
244
214
"cpu": ["x64"]
245
215
},
···
248
218
"os": ["freebsd"],
249
219
"cpu": ["arm64"]
250
220
},
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==",
221
+
"@esbuild/freebsd-arm64@0.27.2": {
222
+
"integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
258
223
"os": ["freebsd"],
259
224
"cpu": ["arm64"]
260
225
},
···
263
228
"os": ["freebsd"],
264
229
"cpu": ["x64"]
265
230
},
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==",
231
+
"@esbuild/freebsd-x64@0.27.2": {
232
+
"integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
273
233
"os": ["freebsd"],
274
234
"cpu": ["x64"]
275
235
},
···
278
238
"os": ["linux"],
279
239
"cpu": ["arm64"]
280
240
},
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==",
241
+
"@esbuild/linux-arm64@0.27.2": {
242
+
"integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
288
243
"os": ["linux"],
289
244
"cpu": ["arm64"]
290
245
},
···
293
248
"os": ["linux"],
294
249
"cpu": ["arm"]
295
250
},
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==",
251
+
"@esbuild/linux-arm@0.27.2": {
252
+
"integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
303
253
"os": ["linux"],
304
254
"cpu": ["arm"]
305
255
},
···
308
258
"os": ["linux"],
309
259
"cpu": ["ia32"]
310
260
},
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==",
261
+
"@esbuild/linux-ia32@0.27.2": {
262
+
"integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
318
263
"os": ["linux"],
319
264
"cpu": ["ia32"]
320
265
},
···
323
268
"os": ["linux"],
324
269
"cpu": ["loong64"]
325
270
},
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==",
271
+
"@esbuild/linux-loong64@0.27.2": {
272
+
"integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
333
273
"os": ["linux"],
334
274
"cpu": ["loong64"]
335
275
},
···
338
278
"os": ["linux"],
339
279
"cpu": ["mips64el"]
340
280
},
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==",
281
+
"@esbuild/linux-mips64el@0.27.2": {
282
+
"integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
348
283
"os": ["linux"],
349
284
"cpu": ["mips64el"]
350
285
},
···
353
288
"os": ["linux"],
354
289
"cpu": ["ppc64"]
355
290
},
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==",
291
+
"@esbuild/linux-ppc64@0.27.2": {
292
+
"integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
363
293
"os": ["linux"],
364
294
"cpu": ["ppc64"]
365
295
},
···
368
298
"os": ["linux"],
369
299
"cpu": ["riscv64"]
370
300
},
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==",
301
+
"@esbuild/linux-riscv64@0.27.2": {
302
+
"integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
378
303
"os": ["linux"],
379
304
"cpu": ["riscv64"]
380
305
},
···
383
308
"os": ["linux"],
384
309
"cpu": ["s390x"]
385
310
},
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==",
311
+
"@esbuild/linux-s390x@0.27.2": {
312
+
"integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
393
313
"os": ["linux"],
394
314
"cpu": ["s390x"]
395
315
},
···
398
318
"os": ["linux"],
399
319
"cpu": ["x64"]
400
320
},
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==",
321
+
"@esbuild/linux-x64@0.27.2": {
322
+
"integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
408
323
"os": ["linux"],
409
324
"cpu": ["x64"]
410
325
},
411
-
"@esbuild/netbsd-arm64@0.25.12": {
412
-
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
326
+
"@esbuild/netbsd-arm64@0.27.2": {
327
+
"integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
413
328
"os": ["netbsd"],
414
329
"cpu": ["arm64"]
415
330
},
···
418
333
"os": ["netbsd"],
419
334
"cpu": ["x64"]
420
335
},
421
-
"@esbuild/netbsd-x64@0.21.5": {
422
-
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
336
+
"@esbuild/netbsd-x64@0.27.2": {
337
+
"integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
423
338
"os": ["netbsd"],
424
339
"cpu": ["x64"]
425
340
},
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==",
341
+
"@esbuild/openbsd-arm64@0.27.2": {
342
+
"integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
433
343
"os": ["openbsd"],
434
344
"cpu": ["arm64"]
435
345
},
···
438
348
"os": ["openbsd"],
439
349
"cpu": ["x64"]
440
350
},
441
-
"@esbuild/openbsd-x64@0.21.5": {
442
-
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
351
+
"@esbuild/openbsd-x64@0.27.2": {
352
+
"integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
443
353
"os": ["openbsd"],
444
354
"cpu": ["x64"]
445
355
},
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==",
356
+
"@esbuild/openharmony-arm64@0.27.2": {
357
+
"integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
453
358
"os": ["openharmony"],
454
359
"cpu": ["arm64"]
455
360
},
···
458
363
"os": ["sunos"],
459
364
"cpu": ["x64"]
460
365
},
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==",
366
+
"@esbuild/sunos-x64@0.27.2": {
367
+
"integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
468
368
"os": ["sunos"],
469
369
"cpu": ["x64"]
470
370
},
···
473
373
"os": ["win32"],
474
374
"cpu": ["arm64"]
475
375
},
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==",
376
+
"@esbuild/win32-arm64@0.27.2": {
377
+
"integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
483
378
"os": ["win32"],
484
379
"cpu": ["arm64"]
485
380
},
···
488
383
"os": ["win32"],
489
384
"cpu": ["ia32"]
490
385
},
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==",
386
+
"@esbuild/win32-ia32@0.27.2": {
387
+
"integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
498
388
"os": ["win32"],
499
389
"cpu": ["ia32"]
500
390
},
···
503
393
"os": ["win32"],
504
394
"cpu": ["x64"]
505
395
},
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==",
396
+
"@esbuild/win32-x64@0.27.2": {
397
+
"integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
513
398
"os": ["win32"],
514
399
"cpu": ["x64"]
515
400
},
···
576
461
"@jridgewell/sourcemap-codec"
577
462
]
578
463
},
579
-
"@noble/secp256k1@2.3.0": {
580
-
"integrity": "sha512-0TQed2gcBbIrh7Ccyw+y/uZQvbJwm7Ao4scBUxqpBCcsOlZG0O4KGfjtNAy/li4W8n1xt3dxrwJ0beZ2h2G6Kw=="
581
-
},
582
464
"@noble/secp256k1@3.0.0": {
583
465
"integrity": "sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg=="
584
466
},
585
-
"@rollup/rollup-android-arm-eabi@4.53.3": {
586
-
"integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==",
467
+
"@rollup/rollup-android-arm-eabi@4.54.0": {
468
+
"integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==",
587
469
"os": ["android"],
588
470
"cpu": ["arm"]
589
471
},
590
-
"@rollup/rollup-android-arm64@4.53.3": {
591
-
"integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==",
472
+
"@rollup/rollup-android-arm64@4.54.0": {
473
+
"integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==",
592
474
"os": ["android"],
593
475
"cpu": ["arm64"]
594
476
},
595
-
"@rollup/rollup-darwin-arm64@4.53.3": {
596
-
"integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==",
477
+
"@rollup/rollup-darwin-arm64@4.54.0": {
478
+
"integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==",
597
479
"os": ["darwin"],
598
480
"cpu": ["arm64"]
599
481
},
600
-
"@rollup/rollup-darwin-x64@4.53.3": {
601
-
"integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==",
482
+
"@rollup/rollup-darwin-x64@4.54.0": {
483
+
"integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==",
602
484
"os": ["darwin"],
603
485
"cpu": ["x64"]
604
486
},
605
-
"@rollup/rollup-freebsd-arm64@4.53.3": {
606
-
"integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==",
487
+
"@rollup/rollup-freebsd-arm64@4.54.0": {
488
+
"integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==",
607
489
"os": ["freebsd"],
608
490
"cpu": ["arm64"]
609
491
},
610
-
"@rollup/rollup-freebsd-x64@4.53.3": {
611
-
"integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==",
492
+
"@rollup/rollup-freebsd-x64@4.54.0": {
493
+
"integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==",
612
494
"os": ["freebsd"],
613
495
"cpu": ["x64"]
614
496
},
615
-
"@rollup/rollup-linux-arm-gnueabihf@4.53.3": {
616
-
"integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==",
497
+
"@rollup/rollup-linux-arm-gnueabihf@4.54.0": {
498
+
"integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==",
617
499
"os": ["linux"],
618
500
"cpu": ["arm"]
619
501
},
620
-
"@rollup/rollup-linux-arm-musleabihf@4.53.3": {
621
-
"integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==",
502
+
"@rollup/rollup-linux-arm-musleabihf@4.54.0": {
503
+
"integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==",
622
504
"os": ["linux"],
623
505
"cpu": ["arm"]
624
506
},
625
-
"@rollup/rollup-linux-arm64-gnu@4.53.3": {
626
-
"integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==",
507
+
"@rollup/rollup-linux-arm64-gnu@4.54.0": {
508
+
"integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==",
627
509
"os": ["linux"],
628
510
"cpu": ["arm64"]
629
511
},
630
-
"@rollup/rollup-linux-arm64-musl@4.53.3": {
631
-
"integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==",
512
+
"@rollup/rollup-linux-arm64-musl@4.54.0": {
513
+
"integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==",
632
514
"os": ["linux"],
633
515
"cpu": ["arm64"]
634
516
},
635
-
"@rollup/rollup-linux-loong64-gnu@4.53.3": {
636
-
"integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==",
517
+
"@rollup/rollup-linux-loong64-gnu@4.54.0": {
518
+
"integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==",
637
519
"os": ["linux"],
638
520
"cpu": ["loong64"]
639
521
},
640
-
"@rollup/rollup-linux-ppc64-gnu@4.53.3": {
641
-
"integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==",
522
+
"@rollup/rollup-linux-ppc64-gnu@4.54.0": {
523
+
"integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==",
642
524
"os": ["linux"],
643
525
"cpu": ["ppc64"]
644
526
},
645
-
"@rollup/rollup-linux-riscv64-gnu@4.53.3": {
646
-
"integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==",
527
+
"@rollup/rollup-linux-riscv64-gnu@4.54.0": {
528
+
"integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==",
647
529
"os": ["linux"],
648
530
"cpu": ["riscv64"]
649
531
},
650
-
"@rollup/rollup-linux-riscv64-musl@4.53.3": {
651
-
"integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==",
532
+
"@rollup/rollup-linux-riscv64-musl@4.54.0": {
533
+
"integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==",
652
534
"os": ["linux"],
653
535
"cpu": ["riscv64"]
654
536
},
655
-
"@rollup/rollup-linux-s390x-gnu@4.53.3": {
656
-
"integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==",
537
+
"@rollup/rollup-linux-s390x-gnu@4.54.0": {
538
+
"integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==",
657
539
"os": ["linux"],
658
540
"cpu": ["s390x"]
659
541
},
660
-
"@rollup/rollup-linux-x64-gnu@4.53.3": {
661
-
"integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==",
542
+
"@rollup/rollup-linux-x64-gnu@4.54.0": {
543
+
"integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==",
662
544
"os": ["linux"],
663
545
"cpu": ["x64"]
664
546
},
665
-
"@rollup/rollup-linux-x64-musl@4.53.3": {
666
-
"integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==",
547
+
"@rollup/rollup-linux-x64-musl@4.54.0": {
548
+
"integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==",
667
549
"os": ["linux"],
668
550
"cpu": ["x64"]
669
551
},
670
-
"@rollup/rollup-openharmony-arm64@4.53.3": {
671
-
"integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==",
552
+
"@rollup/rollup-openharmony-arm64@4.54.0": {
553
+
"integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==",
672
554
"os": ["openharmony"],
673
555
"cpu": ["arm64"]
674
556
},
675
-
"@rollup/rollup-win32-arm64-msvc@4.53.3": {
676
-
"integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==",
557
+
"@rollup/rollup-win32-arm64-msvc@4.54.0": {
558
+
"integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==",
677
559
"os": ["win32"],
678
560
"cpu": ["arm64"]
679
561
},
680
-
"@rollup/rollup-win32-ia32-msvc@4.53.3": {
681
-
"integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==",
562
+
"@rollup/rollup-win32-ia32-msvc@4.54.0": {
563
+
"integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==",
682
564
"os": ["win32"],
683
565
"cpu": ["ia32"]
684
566
},
685
-
"@rollup/rollup-win32-x64-gnu@4.53.3": {
686
-
"integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==",
567
+
"@rollup/rollup-win32-x64-gnu@4.54.0": {
568
+
"integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==",
687
569
"os": ["win32"],
688
570
"cpu": ["x64"]
689
571
},
690
-
"@rollup/rollup-win32-x64-msvc@4.53.3": {
691
-
"integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==",
572
+
"@rollup/rollup-win32-x64-msvc@4.54.0": {
573
+
"integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==",
692
574
"os": ["win32"],
693
575
"cpu": ["x64"]
694
576
},
···
701
583
"acorn"
702
584
]
703
585
},
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==",
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==",
706
588
"dependencies": [
707
589
"@sveltejs/vite-plugin-svelte",
708
590
"debug",
709
591
"svelte",
710
-
"vite@6.4.1_picomatch@4.0.3"
592
+
"vite"
711
593
]
712
594
},
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==",
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==",
715
597
"dependencies": [
716
598
"@sveltejs/vite-plugin-svelte-inspector",
717
599
"debug",
718
600
"deepmerge",
719
-
"kleur",
720
601
"magic-string",
721
602
"svelte",
722
-
"vite@6.4.1_picomatch@4.0.3",
603
+
"vite",
723
604
"vitefu"
724
605
]
725
606
},
···
747
628
"redent"
748
629
]
749
630
},
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==",
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==",
752
639
"dependencies": [
753
640
"@testing-library/dom",
641
+
"@testing-library/svelte-core",
754
642
"svelte",
755
-
"vite@6.4.1_picomatch@4.0.3",
643
+
"vite",
756
644
"vitest"
757
645
],
758
646
"optionalPeers": [
759
-
"vite@6.4.1_picomatch@4.0.3",
647
+
"vite",
760
648
"vitest"
761
649
]
762
650
},
···
769
657
"@types/aria-query@5.0.4": {
770
658
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="
771
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
+
},
772
670
"@types/estree@1.0.8": {
773
671
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="
774
672
},
775
-
"@vitest/expect@2.1.9": {
776
-
"integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==",
673
+
"@vitest/expect@4.0.16": {
674
+
"integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==",
777
675
"dependencies": [
676
+
"@standard-schema/spec",
677
+
"@types/chai",
778
678
"@vitest/spy",
779
679
"@vitest/utils",
780
680
"chai",
781
681
"tinyrainbow"
782
682
]
783
683
},
784
-
"@vitest/mocker@2.1.9_vite@5.4.21": {
785
-
"integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==",
684
+
"@vitest/mocker@4.0.16_vite@7.3.0__picomatch@4.0.3": {
685
+
"integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==",
786
686
"dependencies": [
787
687
"@vitest/spy",
788
688
"estree-walker@3.0.3",
789
689
"magic-string",
790
-
"vite@5.4.21"
690
+
"vite"
791
691
],
792
692
"optionalPeers": [
793
-
"vite@5.4.21"
693
+
"vite"
794
694
]
795
695
},
796
-
"@vitest/pretty-format@2.1.9": {
797
-
"integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==",
696
+
"@vitest/pretty-format@4.0.16": {
697
+
"integrity": "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==",
798
698
"dependencies": [
799
699
"tinyrainbow"
800
700
]
801
701
},
802
-
"@vitest/runner@2.1.9": {
803
-
"integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==",
702
+
"@vitest/runner@4.0.16": {
703
+
"integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==",
804
704
"dependencies": [
805
705
"@vitest/utils",
806
706
"pathe"
807
707
]
808
708
},
809
-
"@vitest/snapshot@2.1.9": {
810
-
"integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==",
709
+
"@vitest/snapshot@4.0.16": {
710
+
"integrity": "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==",
811
711
"dependencies": [
812
712
"@vitest/pretty-format",
813
713
"magic-string",
814
714
"pathe"
815
715
]
816
716
},
817
-
"@vitest/spy@2.1.9": {
818
-
"integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==",
819
-
"dependencies": [
820
-
"tinyspy"
821
-
]
717
+
"@vitest/spy@4.0.16": {
718
+
"integrity": "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw=="
822
719
},
823
-
"@vitest/utils@2.1.9": {
824
-
"integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==",
720
+
"@vitest/utils@4.0.16": {
721
+
"integrity": "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==",
825
722
"dependencies": [
826
723
"@vitest/pretty-format",
827
-
"loupe",
828
724
"tinyrainbow"
829
725
]
830
726
},
···
859
755
"axobject-query@4.1.0": {
860
756
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="
861
757
},
862
-
"cac@6.7.14": {
863
-
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="
864
-
},
865
758
"call-bind-apply-helpers@1.0.2": {
866
759
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
867
760
"dependencies": [
···
869
762
"function-bind"
870
763
]
871
764
},
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
-
]
765
+
"chai@6.2.2": {
766
+
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="
890
767
},
891
768
"cli-color@2.0.4": {
892
769
"integrity": "sha512-zlnpg0jNcibNrO7GG9IeHH7maWFeCz+Ja1wx/7tZNU5ASSSSZ+/qZciM0/LHCYxSdqv5h2sdbQ/PXYdOuetXvA==",
···
939
816
},
940
817
"decimal.js@10.6.0": {
941
818
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="
942
-
},
943
-
"deep-eql@5.0.2": {
944
-
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="
945
819
},
946
820
"deepmerge@4.3.1": {
947
821
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="
···
1060
934
"scripts": true,
1061
935
"bin": true
1062
936
},
1063
-
"esbuild@0.21.5": {
1064
-
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
937
+
"esbuild@0.27.2": {
938
+
"integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
1065
939
"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",
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",
1113
957
"@esbuild/netbsd-arm64",
1114
-
"@esbuild/netbsd-x64@0.25.12",
958
+
"@esbuild/netbsd-x64@0.27.2",
1115
959
"@esbuild/openbsd-arm64",
1116
-
"@esbuild/openbsd-x64@0.25.12",
960
+
"@esbuild/openbsd-x64@0.27.2",
1117
961
"@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"
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"
1122
966
],
1123
967
"scripts": true,
1124
968
"bin": true
···
1318
1162
"xml-name-validator"
1319
1163
]
1320
1164
},
1321
-
"kleur@4.1.5": {
1322
-
"integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="
1323
-
},
1324
1165
"locate-character@3.0.0": {
1325
1166
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="
1326
-
},
1327
-
"loupe@3.2.1": {
1328
-
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="
1329
1167
},
1330
1168
"lru-cache@10.4.3": {
1331
1169
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
···
1393
1231
"nwsapi@2.2.23": {
1394
1232
"integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ=="
1395
1233
},
1234
+
"obug@2.1.1": {
1235
+
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="
1236
+
},
1396
1237
"parse5@7.3.0": {
1397
1238
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
1398
1239
"dependencies": [
1399
1240
"entities"
1400
1241
]
1401
1242
},
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=="
1243
+
"pathe@2.0.3": {
1244
+
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="
1407
1245
},
1408
1246
"picocolors@1.1.1": {
1409
1247
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
···
1433
1271
"react-is@17.0.2": {
1434
1272
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
1435
1273
},
1436
-
"readdirp@4.1.2": {
1437
-
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="
1438
-
},
1439
1274
"redent@3.0.0": {
1440
1275
"integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
1441
1276
"dependencies": [
···
1443
1278
"strip-indent"
1444
1279
]
1445
1280
},
1446
-
"rollup@4.53.3": {
1447
-
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
1281
+
"rollup@4.54.0": {
1282
+
"integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==",
1448
1283
"dependencies": [
1449
1284
"@types/estree"
1450
1285
],
···
1514
1349
"min-indent"
1515
1350
]
1516
1351
},
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": {
1352
+
"svelte-i18n@4.0.1_svelte@5.46.1__acorn@8.15.0": {
1531
1353
"integrity": "sha512-jaykGlGT5PUaaq04JWbJREvivlCnALtT+m87Kbm0fxyYHynkQaxQMnIKHLm2WeIuBRoljzwgyvz0Z6/CMwfdmQ==",
1532
1354
"dependencies": [
1533
1355
"cli-color",
···
1541
1363
],
1542
1364
"bin": true
1543
1365
},
1544
-
"svelte@5.45.10_acorn@8.15.0": {
1545
-
"integrity": "sha512-GiWXq6akkEN3zVDMQ1BVlRolmks5JkEdzD/67mvXOz6drRfuddT5JwsGZjMGSnsTRv/PjAXX8fqBcOr2g2qc/Q==",
1366
+
"svelte@5.46.1_acorn@8.15.0": {
1367
+
"integrity": "sha512-ynjfCHD3nP2el70kN5Pmg37sSi0EjOm9FgHYQdC4giWG/hzO3AatzXXJJgP305uIhGQxSufJLuYWtkY8uK/8RA==",
1546
1368
"dependencies": [
1547
1369
"@jridgewell/remapping",
1548
1370
"@jridgewell/sourcemap-codec",
···
1581
1403
"tinybench@2.9.0": {
1582
1404
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="
1583
1405
},
1584
-
"tinyexec@0.3.2": {
1585
-
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="
1406
+
"tinyexec@1.0.2": {
1407
+
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="
1586
1408
},
1587
1409
"tinyglobby@0.2.15_picomatch@4.0.3": {
1588
1410
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
···
1591
1413
"picomatch"
1592
1414
]
1593
1415
},
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=="
1416
+
"tinyrainbow@3.0.3": {
1417
+
"integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="
1602
1418
},
1603
1419
"tldts-core@6.1.86": {
1604
1420
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="
···
1628
1444
"type@2.7.3": {
1629
1445
"integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="
1630
1446
},
1631
-
"typescript@5.9.3": {
1632
-
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
1633
-
"bin": true
1447
+
"unicode-segmenter@0.14.5": {
1448
+
"integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g=="
1634
1449
},
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==",
1450
+
"vite@7.3.0_picomatch@4.0.3": {
1451
+
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
1640
1452
"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",
1453
+
"esbuild@0.27.2",
1665
1454
"fdir",
1666
1455
"picomatch",
1667
1456
"postcss",
···
1673
1462
],
1674
1463
"bin": true
1675
1464
},
1676
-
"vitefu@1.1.1_vite@6.4.1__picomatch@4.0.3": {
1465
+
"vitefu@1.1.1_vite@7.3.0__picomatch@4.0.3": {
1677
1466
"integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==",
1678
1467
"dependencies": [
1679
-
"vite@6.4.1_picomatch@4.0.3"
1468
+
"vite"
1680
1469
],
1681
1470
"optionalPeers": [
1682
-
"vite@6.4.1_picomatch@4.0.3"
1471
+
"vite"
1683
1472
]
1684
1473
},
1685
-
"vitest@2.1.9_jsdom@25.0.1_vite@5.4.21": {
1686
-
"integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
1474
+
"vitest@4.0.16_jsdom@25.0.1_vite@7.3.0__picomatch@4.0.3": {
1475
+
"integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==",
1687
1476
"dependencies": [
1688
1477
"@vitest/expect",
1689
1478
"@vitest/mocker",
···
1692
1481
"@vitest/snapshot",
1693
1482
"@vitest/spy",
1694
1483
"@vitest/utils",
1695
-
"chai",
1696
-
"debug",
1484
+
"es-module-lexer",
1697
1485
"expect-type",
1698
1486
"jsdom",
1699
1487
"magic-string",
1488
+
"obug",
1700
1489
"pathe",
1490
+
"picomatch",
1701
1491
"std-env",
1702
1492
"tinybench",
1703
1493
"tinyexec",
1704
-
"tinypool",
1494
+
"tinyglobby",
1705
1495
"tinyrainbow",
1706
-
"vite@5.4.21",
1707
-
"vite-node",
1496
+
"vite",
1708
1497
"why-is-node-running"
1709
1498
],
1710
1499
"optionalPeers": [
···
1725
1514
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
1726
1515
"dependencies": [
1727
1516
"iconv-lite"
1728
-
]
1517
+
],
1518
+
"deprecated": true
1729
1519
},
1730
1520
"whatwg-mimetype@4.0.0": {
1731
1521
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="
···
1756
1546
},
1757
1547
"zimmerframe@1.1.4": {
1758
1548
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="
1549
+
},
1550
+
"zod@4.3.5": {
1551
+
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="
1759
1552
}
1760
1553
},
1761
1554
"workspace": {
···
1765
1558
"npm:@atcute/crypto@^2.3.0",
1766
1559
"npm:@atcute/did-plc@~0.3.1",
1767
1560
"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",
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",
1773
1566
"npm:jsdom@^25.0.1",
1774
-
"npm:multiformats@^13.3.1",
1567
+
"npm:multiformats@^13.4.2",
1775
1568
"npm:svelte-i18n@^4.0.1",
1776
-
"npm:svelte@5",
1777
-
"npm:vite@6",
1778
-
"npm:vitest@^2.1.8"
1569
+
"npm:svelte@^5.46.1",
1570
+
"npm:vite@^7.3.0",
1571
+
"npm:vitest@^4.0.16",
1572
+
"npm:zod@^4.3.5"
1779
1573
]
1780
1574
}
1781
1575
}
+11
-10
frontend/package.json
+11
-10
frontend/package.json
···
16
16
"@atcute/crypto": "^2.3.0",
17
17
"@atcute/did-plc": "^0.3.1",
18
18
"@atcute/multibase": "^1.1.6",
19
-
"@noble/secp256k1": "^2.1.0",
20
-
"multiformats": "^13.3.1",
21
-
"svelte-i18n": "^4.0.1"
19
+
"@noble/secp256k1": "^3.0.0",
20
+
"multiformats": "^13.4.2",
21
+
"svelte-i18n": "^4.0.1",
22
+
"zod": "^4.3.5"
22
23
},
23
24
"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",
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",
28
29
"jsdom": "^25.0.1",
29
-
"svelte": "^5.0.0",
30
-
"vite": "^6.0.0",
31
-
"vitest": "^2.1.8"
30
+
"svelte": "^5.46.1",
31
+
"vite": "^7.3.0",
32
+
"vitest": "^4.0.16"
32
33
}
33
34
}
+7
-11
frontend/src/App.svelte
+7
-11
frontend/src/App.svelte
···
4
4
import { initServerConfig } from './lib/serverConfig.svelte'
5
5
import { initI18n } from './lib/i18n'
6
6
import { isLoading as i18nLoading } from 'svelte-i18n'
7
+
import Toast from './components/Toast.svelte'
7
8
import Login from './routes/Login.svelte'
8
9
import Register from './routes/Register.svelte'
9
10
import RegisterPasskey from './routes/RegisterPasskey.svelte'
···
36
37
import DidDocumentEditor from './routes/DidDocumentEditor.svelte'
37
38
initI18n()
38
39
39
-
const auth = getAuthState()
40
+
const auth = $derived(getAuthState())
40
41
41
42
let oauthCallbackPending = $state(hasOAuthCallback())
42
43
···
59
60
})
60
61
61
62
$effect(() => {
62
-
if (auth.loading) return
63
+
if (auth.kind === 'loading') return
63
64
const path = getCurrentPath()
64
65
if (path === '/') {
65
-
if (auth.session) {
66
+
if (auth.kind === 'authenticated') {
66
67
navigate('/dashboard', true)
67
68
} else {
68
69
navigate('/login', true)
···
142
143
</script>
143
144
144
145
<main>
145
-
{#if auth.loading || $i18nLoading || oauthCallbackPending}
146
-
<div class="loading">
147
-
<p>Loading...</p>
148
-
</div>
146
+
{#if auth.kind === 'loading' || $i18nLoading || oauthCallbackPending}
147
+
<div class="loading"></div>
149
148
{:else}
150
149
<CurrentComponent />
151
150
{/if}
152
151
</main>
152
+
<Toast />
153
153
154
154
<style>
155
155
main {
···
157
157
}
158
158
159
159
.loading {
160
-
display: flex;
161
-
align-items: center;
162
-
justify-content: center;
163
160
min-height: 100vh;
164
-
color: var(--text-secondary);
165
161
}
166
162
</style>
+18
-49
frontend/src/components/ReauthModal.svelte
+18
-49
frontend/src/components/ReauthModal.svelte
···
2
2
import { getAuthState, getValidToken } from '../lib/auth.svelte'
3
3
import { api, ApiError } from '../lib/api'
4
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'
5
11
6
12
interface Props {
7
13
show: boolean
···
12
18
13
19
let { show = $bindable(), availableMethods = ['password'], onSuccess, onCancel }: Props = $props()
14
20
15
-
const auth = getAuthState()
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())
16
28
let activeMethod = $state<'password' | 'totp' | 'passkey'>('password')
17
29
let password = $state('')
18
30
let totpCode = $state('')
···
37
49
}
38
50
})
39
51
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
52
async function handlePasswordSubmit(e: Event) {
72
53
e.preventDefault()
73
-
if (!auth.session || !password) return
54
+
if (!session || !password) return
74
55
loading = true
75
56
error = ''
76
57
try {
···
91
72
92
73
async function handleTotpSubmit(e: Event) {
93
74
e.preventDefault()
94
-
if (!auth.session || !totpCode) return
75
+
if (!session || !totpCode) return
95
76
loading = true
96
77
error = ''
97
78
try {
···
111
92
}
112
93
113
94
async function handlePasskeyAuth() {
114
-
if (!auth.session) return
95
+
if (!session) return
115
96
if (!window.PublicKeyCredential) {
116
97
error = 'Passkeys are not supported in this browser'
117
98
return
···
125
106
return
126
107
}
127
108
const { options } = await api.reauthPasskeyStart(token)
128
-
const publicKeyOptions = prepareAuthOptions(options)
109
+
const publicKeyOptions = prepareRequestOptions(options as WebAuthnRequestOptionsResponse)
129
110
const credential = await navigator.credentials.get({
130
111
publicKey: publicKeyOptions
131
112
})
···
133
114
error = 'Passkey authentication was cancelled'
134
115
return
135
116
}
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
-
}
117
+
const credentialResponse = serializeAssertionResponse(credential as PublicKeyCredential)
149
118
await api.reauthPasskeyFinish(token, credentialResponse)
150
119
show = false
151
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";
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'
2
84
3
85
export class ApiError extends Error {
4
-
public did?: string;
5
-
public reauthMethods?: string[];
86
+
public did?: Did
87
+
public reauthMethods?: string[]
6
88
constructor(
7
89
public status: number,
8
-
public error: string,
90
+
public error: ApiErrorCode,
9
91
message: string,
10
92
did?: string,
11
93
reauthMethods?: string[],
12
94
) {
13
-
super(message);
14
-
this.name = "ApiError";
15
-
this.did = did;
16
-
this.reauthMethods = reauthMethods;
95
+
super(message)
96
+
this.name = 'ApiError'
97
+
this.did = did ? unsafeAsDid(did) : undefined
98
+
this.reauthMethods = reauthMethods
17
99
}
18
100
}
19
101
20
-
let tokenRefreshCallback: (() => Promise<string | null>) | null = null;
102
+
let tokenRefreshCallback: (() => Promise<string | null>) | null = null
21
103
22
104
export function setTokenRefreshCallback(
23
105
callback: () => Promise<string | null>,
24
106
) {
25
-
tokenRefreshCallback = callback;
107
+
tokenRefreshCallback = callback
26
108
}
27
109
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}`;
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}`
38
122
if (params) {
39
-
const searchParams = new URLSearchParams(params);
40
-
url += `?${searchParams}`;
123
+
const searchParams = new URLSearchParams(params)
124
+
url += `?${searchParams}`
41
125
}
42
-
const headers: Record<string, string> = {};
126
+
const headers: Record<string, string> = {}
43
127
if (token) {
44
-
headers["Authorization"] = `Bearer ${token}`;
128
+
headers['Authorization'] = `Bearer ${token}`
45
129
}
46
130
if (body) {
47
-
headers["Content-Type"] = "application/json";
131
+
headers['Content-Type'] = 'application/json'
48
132
}
49
133
const res = await fetch(url, {
50
134
method: httpMethod,
51
135
headers,
52
136
body: body ? JSON.stringify(body) : undefined,
53
-
});
137
+
})
54
138
if (!res.ok) {
55
-
const err = await res.json().catch(() => ({
56
-
error: "Unknown",
139
+
const errData = await res.json().catch(() => ({
140
+
error: 'Unknown',
57
141
message: res.statusText,
58
-
}));
142
+
}))
59
143
if (
60
144
res.status === 401 &&
61
-
(err.error === "AuthenticationFailed" || err.error === "ExpiredToken") &&
145
+
(errData.error === 'AuthenticationFailed' || errData.error === 'ExpiredToken') &&
62
146
token && tokenRefreshCallback && !skipRetry
63
147
) {
64
-
const newToken = await tokenRefreshCallback();
148
+
const newToken = await tokenRefreshCallback()
65
149
if (newToken && newToken !== token) {
66
-
return xrpc(method, { ...options, token: newToken, skipRetry: true });
150
+
return xrpc(method, { ...options, token: newToken, skipRetry: true })
67
151
}
68
152
}
69
153
throw new ApiError(
70
154
res.status,
71
-
err.error,
72
-
err.message,
73
-
err.did,
74
-
err.reauthMethods,
75
-
);
155
+
errData.error as ApiErrorCode,
156
+
errData.message,
157
+
errData.did,
158
+
errData.reauthMethods,
159
+
)
76
160
}
77
-
return res.json();
161
+
return res.json()
78
162
}
79
163
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;
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
+
}
94
177
}
95
178
96
179
export interface VerificationMethod {
97
-
id: string;
98
-
type: string;
99
-
publicKeyMultibase: string;
180
+
id: string
181
+
type: string
182
+
publicKeyMultibase: string
100
183
}
101
184
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
-
}
185
+
export type { Session, DidDocument, AppPassword, InviteCodeInfo as InviteCode }
186
+
export type { VerificationChannel, DidType, CreateAccountParams, CreateAccountResult, ConfirmSignupResult }
118
187
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;
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
+
}
170
205
}
171
206
172
207
export const api = {
···
174
209
params: CreateAccountParams,
175
210
byodToken?: string,
176
211
): Promise<CreateAccountResult> {
177
-
const url = `${API_BASE}/com.atproto.server.createAccount`;
212
+
const url = `${API_BASE}/com.atproto.server.createAccount`
178
213
const headers: Record<string, string> = {
179
-
"Content-Type": "application/json",
180
-
};
214
+
'Content-Type': 'application/json',
215
+
}
181
216
if (byodToken) {
182
-
headers["Authorization"] = `Bearer ${byodToken}`;
217
+
headers['Authorization'] = `Bearer ${byodToken}`
183
218
}
184
219
const response = await fetch(url, {
185
-
method: "POST",
220
+
method: 'POST',
186
221
headers,
187
222
body: JSON.stringify({
188
223
handle: params.handle,
···
197
232
telegramUsername: params.telegramUsername,
198
233
signalNumber: params.signalNumber,
199
234
}),
200
-
});
201
-
const data = await response.json();
235
+
})
236
+
const data = await response.json()
202
237
if (!response.ok) {
203
-
throw new ApiError(response.status, data.error, data.message);
238
+
throw new ApiError(response.status, data.error, data.message)
204
239
}
205
-
return data;
240
+
return data
206
241
},
207
242
208
243
async createAccountWithServiceAuth(
209
244
serviceAuthToken: string,
210
245
params: {
211
-
did: string;
212
-
handle: string;
213
-
email: string;
214
-
password: string;
215
-
inviteCode?: string;
246
+
did: Did
247
+
handle: Handle
248
+
email: EmailAddress
249
+
password: string
250
+
inviteCode?: string
216
251
},
217
252
): Promise<Session> {
218
-
const url = `${API_BASE}/com.atproto.server.createAccount`;
253
+
const url = `${API_BASE}/com.atproto.server.createAccount`
219
254
const response = await fetch(url, {
220
-
method: "POST",
255
+
method: 'POST',
221
256
headers: {
222
-
"Content-Type": "application/json",
223
-
"Authorization": `Bearer ${serviceAuthToken}`,
257
+
'Content-Type': 'application/json',
258
+
'Authorization': `Bearer ${serviceAuthToken}`,
224
259
},
225
260
body: JSON.stringify({
226
261
did: params.did,
···
229
264
password: params.password,
230
265
inviteCode: params.inviteCode,
231
266
}),
232
-
});
233
-
const data = await response.json();
267
+
})
268
+
const data = await response.json()
234
269
if (!response.ok) {
235
-
throw new ApiError(response.status, data.error, data.message);
270
+
throw new ApiError(response.status, data.error, data.message)
236
271
}
237
-
return data;
272
+
return castSession(data)
238
273
},
239
274
240
275
confirmSignup(
241
-
did: string,
276
+
did: Did,
242
277
verificationCode: string,
243
278
): Promise<ConfirmSignupResult> {
244
-
return xrpc("com.atproto.server.confirmSignup", {
245
-
method: "POST",
279
+
return xrpc('com.atproto.server.confirmSignup', {
280
+
method: 'POST',
246
281
body: { did, verificationCode },
247
-
});
282
+
})
248
283
},
249
284
250
-
resendVerification(did: string): Promise<{ success: boolean }> {
251
-
return xrpc("com.atproto.server.resendVerification", {
252
-
method: "POST",
285
+
resendVerification(did: Did): Promise<{ success: boolean }> {
286
+
return xrpc('com.atproto.server.resendVerification', {
287
+
method: 'POST',
253
288
body: { did },
254
-
});
289
+
})
255
290
},
256
291
257
-
createSession(identifier: string, password: string): Promise<Session> {
258
-
return xrpc("com.atproto.server.createSession", {
259
-
method: "POST",
292
+
async createSession(identifier: string, password: string): Promise<Session> {
293
+
const raw = await xrpc<unknown>('com.atproto.server.createSession', {
294
+
method: 'POST',
260
295
body: { identifier, password },
261
-
});
296
+
})
297
+
return castSession(raw)
262
298
},
263
299
264
300
checkEmailVerified(identifier: string): Promise<{ verified: boolean }> {
265
-
return xrpc("_checkEmailVerified", {
266
-
method: "POST",
301
+
return xrpc('_checkEmailVerified', {
302
+
method: 'POST',
267
303
body: { identifier },
268
-
});
304
+
})
269
305
},
270
306
271
-
getSession(token: string): Promise<Session> {
272
-
return xrpc("com.atproto.server.getSession", { token });
307
+
async getSession(token: AccessToken): Promise<Session> {
308
+
const raw = await xrpc<unknown>('com.atproto.server.getSession', { token })
309
+
return castSession(raw)
273
310
},
274
311
275
-
refreshSession(refreshJwt: string): Promise<Session> {
276
-
return xrpc("com.atproto.server.refreshSession", {
277
-
method: "POST",
312
+
async refreshSession(refreshJwt: RefreshToken): Promise<Session> {
313
+
const raw = await xrpc<unknown>('com.atproto.server.refreshSession', {
314
+
method: 'POST',
278
315
token: refreshJwt,
279
-
});
316
+
})
317
+
return castSession(raw)
280
318
},
281
319
282
-
async deleteSession(token: string): Promise<void> {
283
-
await xrpc("com.atproto.server.deleteSession", {
284
-
method: "POST",
320
+
async deleteSession(token: AccessToken): Promise<void> {
321
+
await xrpc('com.atproto.server.deleteSession', {
322
+
method: 'POST',
285
323
token,
286
-
});
324
+
})
287
325
},
288
326
289
-
listAppPasswords(token: string): Promise<{ passwords: AppPassword[] }> {
290
-
return xrpc("com.atproto.server.listAppPasswords", { token });
327
+
listAppPasswords(token: AccessToken): Promise<{ passwords: AppPassword[] }> {
328
+
return xrpc('com.atproto.server.listAppPasswords', { token })
291
329
},
292
330
293
331
createAppPassword(
294
-
token: string,
332
+
token: AccessToken,
295
333
name: string,
296
334
scopes?: string,
297
-
): Promise<
298
-
{ name: string; password: string; createdAt: string; scopes?: string }
299
-
> {
300
-
return xrpc("com.atproto.server.createAppPassword", {
301
-
method: "POST",
335
+
): Promise<CreatedAppPassword> {
336
+
return xrpc('com.atproto.server.createAppPassword', {
337
+
method: 'POST',
302
338
token,
303
339
body: { name, scopes },
304
-
});
340
+
})
305
341
},
306
342
307
-
async revokeAppPassword(token: string, name: string): Promise<void> {
308
-
await xrpc("com.atproto.server.revokeAppPassword", {
309
-
method: "POST",
343
+
async revokeAppPassword(token: AccessToken, name: string): Promise<void> {
344
+
await xrpc('com.atproto.server.revokeAppPassword', {
345
+
method: 'POST',
310
346
token,
311
347
body: { name },
312
-
});
348
+
})
313
349
},
314
350
315
-
getAccountInviteCodes(token: string): Promise<{ codes: InviteCode[] }> {
316
-
return xrpc("com.atproto.server.getAccountInviteCodes", { token });
351
+
getAccountInviteCodes(token: AccessToken): Promise<{ codes: InviteCodeInfo[] }> {
352
+
return xrpc('com.atproto.server.getAccountInviteCodes', { token })
317
353
},
318
354
319
355
createInviteCode(
320
-
token: string,
356
+
token: AccessToken,
321
357
useCount: number = 1,
322
358
): Promise<{ code: string }> {
323
-
return xrpc("com.atproto.server.createInviteCode", {
324
-
method: "POST",
359
+
return xrpc('com.atproto.server.createInviteCode', {
360
+
method: 'POST',
325
361
token,
326
362
body: { useCount },
327
-
});
363
+
})
328
364
},
329
365
330
-
async requestPasswordReset(email: string): Promise<void> {
331
-
await xrpc("com.atproto.server.requestPasswordReset", {
332
-
method: "POST",
366
+
async requestPasswordReset(email: EmailAddress): Promise<void> {
367
+
await xrpc('com.atproto.server.requestPasswordReset', {
368
+
method: 'POST',
333
369
body: { email },
334
-
});
370
+
})
335
371
},
336
372
337
373
async resetPassword(token: string, password: string): Promise<void> {
338
-
await xrpc("com.atproto.server.resetPassword", {
339
-
method: "POST",
374
+
await xrpc('com.atproto.server.resetPassword', {
375
+
method: 'POST',
340
376
body: { token, password },
341
-
});
377
+
})
342
378
},
343
379
344
-
requestEmailUpdate(
345
-
token: string,
346
-
): Promise<{ tokenRequired: boolean }> {
347
-
return xrpc("com.atproto.server.requestEmailUpdate", {
348
-
method: "POST",
380
+
requestEmailUpdate(token: AccessToken): Promise<EmailUpdateResponse> {
381
+
return xrpc('com.atproto.server.requestEmailUpdate', {
382
+
method: 'POST',
349
383
token,
350
-
});
384
+
})
351
385
},
352
386
353
387
async updateEmail(
354
-
token: string,
388
+
token: AccessToken,
355
389
email: string,
356
390
emailToken?: string,
357
391
): Promise<void> {
358
-
await xrpc("com.atproto.server.updateEmail", {
359
-
method: "POST",
392
+
await xrpc('com.atproto.server.updateEmail', {
393
+
method: 'POST',
360
394
token,
361
395
body: { email, token: emailToken },
362
-
});
396
+
})
363
397
},
364
398
365
-
async updateHandle(token: string, handle: string): Promise<void> {
366
-
await xrpc("com.atproto.identity.updateHandle", {
367
-
method: "POST",
399
+
async updateHandle(token: AccessToken, handle: Handle): Promise<void> {
400
+
await xrpc('com.atproto.identity.updateHandle', {
401
+
method: 'POST',
368
402
token,
369
403
body: { handle },
370
-
});
404
+
})
371
405
},
372
406
373
-
async requestAccountDelete(token: string): Promise<void> {
374
-
await xrpc("com.atproto.server.requestAccountDelete", {
375
-
method: "POST",
407
+
async requestAccountDelete(token: AccessToken): Promise<void> {
408
+
await xrpc('com.atproto.server.requestAccountDelete', {
409
+
method: 'POST',
376
410
token,
377
-
});
411
+
})
378
412
},
379
413
380
414
async deleteAccount(
381
-
did: string,
415
+
did: Did,
382
416
password: string,
383
417
deleteToken: string,
384
418
): Promise<void> {
385
-
await xrpc("com.atproto.server.deleteAccount", {
386
-
method: "POST",
419
+
await xrpc('com.atproto.server.deleteAccount', {
420
+
method: 'POST',
387
421
body: { did, password, token: deleteToken },
388
-
});
422
+
})
389
423
},
390
424
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");
425
+
describeServer(): Promise<ServerDescription> {
426
+
return xrpc('com.atproto.server.describeServer')
400
427
},
401
428
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 });
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 })
409
433
},
410
434
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 });
435
+
getNotificationPrefs(token: AccessToken): Promise<NotificationPrefs> {
436
+
return xrpc('_account.getNotificationPrefs', { token })
422
437
},
423
438
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",
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',
432
447
token,
433
448
body: prefs,
434
-
});
449
+
})
435
450
},
436
451
437
452
confirmChannelVerification(
438
-
token: string,
453
+
token: AccessToken,
439
454
channel: string,
440
455
identifier: string,
441
456
code: string,
442
-
): Promise<{ success: boolean }> {
443
-
return xrpc("_account.confirmChannelVerification", {
444
-
method: "POST",
457
+
): Promise<SuccessResponse> {
458
+
return xrpc('_account.confirmChannelVerification', {
459
+
method: 'POST',
445
460
token,
446
461
body: { channel, identifier, code },
447
-
});
462
+
})
448
463
},
449
464
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 });
465
+
getNotificationHistory(token: AccessToken): Promise<NotificationHistoryResponse> {
466
+
return xrpc('_account.getNotificationHistory', { token })
461
467
},
462
468
463
-
getServerStats(token: string): Promise<{
464
-
userCount: number;
465
-
repoCount: number;
466
-
recordCount: number;
467
-
blobStorageBytes: number;
468
-
}> {
469
-
return xrpc("_admin.getServerStats", { token });
469
+
getServerStats(token: AccessToken): Promise<ServerStats> {
470
+
return xrpc('_admin.getServerStats', { token })
470
471
},
471
472
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");
473
+
getServerConfig(): Promise<ServerConfig> {
474
+
return xrpc('_server.getConfig')
481
475
},
482
476
483
477
updateServerConfig(
484
-
token: string,
478
+
token: AccessToken,
485
479
config: {
486
-
serverName?: string;
487
-
primaryColor?: string;
488
-
primaryColorDark?: string;
489
-
secondaryColor?: string;
490
-
secondaryColorDark?: string;
491
-
logoCid?: string;
480
+
serverName?: string
481
+
primaryColor?: string
482
+
primaryColorDark?: string
483
+
secondaryColor?: string
484
+
secondaryColorDark?: string
485
+
logoCid?: string
492
486
},
493
-
): Promise<{ success: boolean }> {
494
-
return xrpc("_admin.updateServerConfig", {
495
-
method: "POST",
487
+
): Promise<SuccessResponse> {
488
+
return xrpc('_admin.updateServerConfig', {
489
+
method: 'POST',
496
490
token,
497
491
body: config,
498
-
});
492
+
})
499
493
},
500
494
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",
495
+
async uploadBlob(token: AccessToken, file: File): Promise<UploadBlobResponse> {
496
+
const res = await fetch('/xrpc/com.atproto.repo.uploadBlob', {
497
+
method: 'POST',
516
498
headers: {
517
-
"Authorization": `Bearer ${token}`,
518
-
"Content-Type": file.type,
499
+
'Authorization': `Bearer ${token}`,
500
+
'Content-Type': file.type,
519
501
},
520
502
body: file,
521
-
});
503
+
})
522
504
if (!res.ok) {
523
-
const err = await res.json().catch(() => ({
524
-
error: "Unknown",
505
+
const errData = await res.json().catch(() => ({
506
+
error: 'Unknown',
525
507
message: res.statusText,
526
-
}));
527
-
throw new ApiError(res.status, err.error, err.message);
508
+
}))
509
+
throw new ApiError(res.status, errData.error, errData.message)
528
510
}
529
-
return res.json();
511
+
return res.json()
530
512
},
531
513
532
514
async changePassword(
533
-
token: string,
515
+
token: AccessToken,
534
516
currentPassword: string,
535
517
newPassword: string,
536
518
): Promise<void> {
537
-
await xrpc("_account.changePassword", {
538
-
method: "POST",
519
+
await xrpc('_account.changePassword', {
520
+
method: 'POST',
539
521
token,
540
522
body: { currentPassword, newPassword },
541
-
});
523
+
})
542
524
},
543
525
544
-
removePassword(token: string): Promise<{ success: boolean }> {
545
-
return xrpc("_account.removePassword", {
546
-
method: "POST",
526
+
removePassword(token: AccessToken): Promise<SuccessResponse> {
527
+
return xrpc('_account.removePassword', {
528
+
method: 'POST',
547
529
token,
548
-
});
530
+
})
549
531
},
550
532
551
-
getPasswordStatus(token: string): Promise<{ hasPassword: boolean }> {
552
-
return xrpc("_account.getPasswordStatus", { token });
533
+
getPasswordStatus(token: AccessToken): Promise<PasswordStatus> {
534
+
return xrpc('_account.getPasswordStatus', { token })
553
535
},
554
536
555
-
getLegacyLoginPreference(
556
-
token: string,
557
-
): Promise<{ allowLegacyLogin: boolean; hasMfa: boolean }> {
558
-
return xrpc("_account.getLegacyLoginPreference", { token });
537
+
getLegacyLoginPreference(token: AccessToken): Promise<LegacyLoginPreference> {
538
+
return xrpc('_account.getLegacyLoginPreference', { token })
559
539
},
560
540
561
541
updateLegacyLoginPreference(
562
-
token: string,
542
+
token: AccessToken,
563
543
allowLegacyLogin: boolean,
564
-
): Promise<{ allowLegacyLogin: boolean }> {
565
-
return xrpc("_account.updateLegacyLoginPreference", {
566
-
method: "POST",
544
+
): Promise<UpdateLegacyLoginResponse> {
545
+
return xrpc('_account.updateLegacyLoginPreference', {
546
+
method: 'POST',
567
547
token,
568
548
body: { allowLegacyLogin },
569
-
});
549
+
})
570
550
},
571
551
572
-
updateLocale(
573
-
token: string,
574
-
preferredLocale: string,
575
-
): Promise<{ preferredLocale: string }> {
576
-
return xrpc("_account.updateLocale", {
577
-
method: "POST",
552
+
updateLocale(token: AccessToken, preferredLocale: string): Promise<UpdateLocaleResponse> {
553
+
return xrpc('_account.updateLocale', {
554
+
method: 'POST',
578
555
token,
579
556
body: { preferredLocale },
580
-
});
557
+
})
581
558
},
582
559
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 });
560
+
listSessions(token: AccessToken): Promise<ListSessionsResponse> {
561
+
return xrpc('_account.listSessions', { token })
594
562
},
595
563
596
-
async revokeSession(token: string, sessionId: string): Promise<void> {
597
-
await xrpc("_account.revokeSession", {
598
-
method: "POST",
564
+
async revokeSession(token: AccessToken, sessionId: string): Promise<void> {
565
+
await xrpc('_account.revokeSession', {
566
+
method: 'POST',
599
567
token,
600
568
body: { sessionId },
601
-
});
569
+
})
602
570
},
603
571
604
-
revokeAllSessions(token: string): Promise<{ revokedCount: number }> {
605
-
return xrpc("_account.revokeAllSessions", {
606
-
method: "POST",
572
+
revokeAllSessions(token: AccessToken): Promise<{ revokedCount: number }> {
573
+
return xrpc('_account.revokeAllSessions', {
574
+
method: 'POST',
607
575
token,
608
-
});
576
+
})
609
577
},
610
578
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 });
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 })
631
589
},
632
590
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 });
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 })
654
601
},
655
602
656
603
async disableInviteCodes(
657
-
token: string,
604
+
token: AccessToken,
658
605
codes?: string[],
659
606
accounts?: string[],
660
607
): Promise<void> {
661
-
await xrpc("com.atproto.admin.disableInviteCodes", {
662
-
method: "POST",
608
+
await xrpc('com.atproto.admin.disableInviteCodes', {
609
+
method: 'POST',
663
610
token,
664
611
body: { codes, accounts },
665
-
});
612
+
})
666
613
},
667
614
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 } });
615
+
getAccountInfo(token: AccessToken, did: Did): Promise<AccountInfo> {
616
+
return xrpc('com.atproto.admin.getAccountInfo', { token, params: { did } })
678
617
},
679
618
680
-
async disableAccountInvites(token: string, account: string): Promise<void> {
681
-
await xrpc("com.atproto.admin.disableAccountInvites", {
682
-
method: "POST",
619
+
async disableAccountInvites(token: AccessToken, account: Did): Promise<void> {
620
+
await xrpc('com.atproto.admin.disableAccountInvites', {
621
+
method: 'POST',
683
622
token,
684
623
body: { account },
685
-
});
624
+
})
686
625
},
687
626
688
-
async enableAccountInvites(token: string, account: string): Promise<void> {
689
-
await xrpc("com.atproto.admin.enableAccountInvites", {
690
-
method: "POST",
627
+
async enableAccountInvites(token: AccessToken, account: Did): Promise<void> {
628
+
await xrpc('com.atproto.admin.enableAccountInvites', {
629
+
method: 'POST',
691
630
token,
692
631
body: { account },
693
-
});
632
+
})
694
633
},
695
634
696
-
async adminDeleteAccount(token: string, did: string): Promise<void> {
697
-
await xrpc("com.atproto.admin.deleteAccount", {
698
-
method: "POST",
635
+
async adminDeleteAccount(token: AccessToken, did: Did): Promise<void> {
636
+
await xrpc('com.atproto.admin.deleteAccount', {
637
+
method: 'POST',
699
638
token,
700
639
body: { did },
701
-
});
640
+
})
702
641
},
703
642
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", {
643
+
describeRepo(token: AccessToken, repo: Did): Promise<RepoDescription> {
644
+
return xrpc('com.atproto.repo.describeRepo', {
712
645
token,
713
646
params: { repo },
714
-
});
647
+
})
715
648
},
716
649
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 });
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 })
730
660
},
731
661
732
662
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", {
663
+
token: AccessToken,
664
+
repo: Did,
665
+
collection: Nsid,
666
+
rkey: Rkey,
667
+
): Promise<RecordResponse> {
668
+
return xrpc('com.atproto.repo.getRecord', {
743
669
token,
744
670
params: { repo, collection, rkey },
745
-
});
671
+
})
746
672
},
747
673
748
674
createRecord(
749
-
token: string,
750
-
repo: string,
751
-
collection: string,
675
+
token: AccessToken,
676
+
repo: Did,
677
+
collection: Nsid,
752
678
record: unknown,
753
-
rkey?: string,
754
-
): Promise<{
755
-
uri: string;
756
-
cid: string;
757
-
}> {
758
-
return xrpc("com.atproto.repo.createRecord", {
759
-
method: "POST",
679
+
rkey?: Rkey,
680
+
): Promise<CreateRecordResponse> {
681
+
return xrpc('com.atproto.repo.createRecord', {
682
+
method: 'POST',
760
683
token,
761
684
body: { repo, collection, record, rkey },
762
-
});
685
+
})
763
686
},
764
687
765
688
putRecord(
766
-
token: string,
767
-
repo: string,
768
-
collection: string,
769
-
rkey: string,
689
+
token: AccessToken,
690
+
repo: Did,
691
+
collection: Nsid,
692
+
rkey: Rkey,
770
693
record: unknown,
771
-
): Promise<{
772
-
uri: string;
773
-
cid: string;
774
-
}> {
775
-
return xrpc("com.atproto.repo.putRecord", {
776
-
method: "POST",
694
+
): Promise<CreateRecordResponse> {
695
+
return xrpc('com.atproto.repo.putRecord', {
696
+
method: 'POST',
777
697
token,
778
698
body: { repo, collection, rkey, record },
779
-
});
699
+
})
780
700
},
781
701
782
702
async deleteRecord(
783
-
token: string,
784
-
repo: string,
785
-
collection: string,
786
-
rkey: string,
703
+
token: AccessToken,
704
+
repo: Did,
705
+
collection: Nsid,
706
+
rkey: Rkey,
787
707
): Promise<void> {
788
-
await xrpc("com.atproto.repo.deleteRecord", {
789
-
method: "POST",
708
+
await xrpc('com.atproto.repo.deleteRecord', {
709
+
method: 'POST',
790
710
token,
791
711
body: { repo, collection, rkey },
792
-
});
712
+
})
793
713
},
794
714
795
-
getTotpStatus(
796
-
token: string,
797
-
): Promise<{ enabled: boolean; hasBackupCodes: boolean }> {
798
-
return xrpc("com.atproto.server.getTotpStatus", { token });
715
+
getTotpStatus(token: AccessToken): Promise<TotpStatus> {
716
+
return xrpc('com.atproto.server.getTotpStatus', { token })
799
717
},
800
718
801
-
createTotpSecret(
802
-
token: string,
803
-
): Promise<{ uri: string; qrBase64: string }> {
804
-
return xrpc("com.atproto.server.createTotpSecret", {
805
-
method: "POST",
719
+
createTotpSecret(token: AccessToken): Promise<TotpSecret> {
720
+
return xrpc('com.atproto.server.createTotpSecret', {
721
+
method: 'POST',
806
722
token,
807
-
});
723
+
})
808
724
},
809
725
810
-
enableTotp(
811
-
token: string,
812
-
code: string,
813
-
): Promise<{ success: boolean; backupCodes: string[] }> {
814
-
return xrpc("com.atproto.server.enableTotp", {
815
-
method: "POST",
726
+
enableTotp(token: AccessToken, code: string): Promise<EnableTotpResponse> {
727
+
return xrpc('com.atproto.server.enableTotp', {
728
+
method: 'POST',
816
729
token,
817
730
body: { code },
818
-
});
731
+
})
819
732
},
820
733
821
734
disableTotp(
822
-
token: string,
735
+
token: AccessToken,
823
736
password: string,
824
737
code: string,
825
-
): Promise<{ success: boolean }> {
826
-
return xrpc("com.atproto.server.disableTotp", {
827
-
method: "POST",
738
+
): Promise<SuccessResponse> {
739
+
return xrpc('com.atproto.server.disableTotp', {
740
+
method: 'POST',
828
741
token,
829
742
body: { password, code },
830
-
});
743
+
})
831
744
},
832
745
833
746
regenerateBackupCodes(
834
-
token: string,
747
+
token: AccessToken,
835
748
password: string,
836
749
code: string,
837
-
): Promise<{ backupCodes: string[] }> {
838
-
return xrpc("com.atproto.server.regenerateBackupCodes", {
839
-
method: "POST",
750
+
): Promise<RegenerateBackupCodesResponse> {
751
+
return xrpc('com.atproto.server.regenerateBackupCodes', {
752
+
method: 'POST',
840
753
token,
841
754
body: { password, code },
842
-
});
755
+
})
843
756
},
844
757
845
758
startPasskeyRegistration(
846
-
token: string,
759
+
token: AccessToken,
847
760
friendlyName?: string,
848
-
): Promise<{ options: unknown }> {
849
-
return xrpc("com.atproto.server.startPasskeyRegistration", {
850
-
method: "POST",
761
+
): Promise<StartPasskeyRegistrationResponse> {
762
+
return xrpc('com.atproto.server.startPasskeyRegistration', {
763
+
method: 'POST',
851
764
token,
852
765
body: { friendlyName },
853
-
});
766
+
})
854
767
},
855
768
856
769
finishPasskeyRegistration(
857
-
token: string,
770
+
token: AccessToken,
858
771
credential: unknown,
859
772
friendlyName?: string,
860
-
): Promise<{ id: string; credentialId: string }> {
861
-
return xrpc("com.atproto.server.finishPasskeyRegistration", {
862
-
method: "POST",
773
+
): Promise<FinishPasskeyRegistrationResponse> {
774
+
return xrpc('com.atproto.server.finishPasskeyRegistration', {
775
+
method: 'POST',
863
776
token,
864
777
body: { credential, friendlyName },
865
-
});
778
+
})
866
779
},
867
780
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 });
781
+
listPasskeys(token: AccessToken): Promise<ListPasskeysResponse> {
782
+
return xrpc('com.atproto.server.listPasskeys', { token })
878
783
},
879
784
880
-
async deletePasskey(token: string, id: string): Promise<void> {
881
-
await xrpc("com.atproto.server.deletePasskey", {
882
-
method: "POST",
785
+
async deletePasskey(token: AccessToken, id: string): Promise<void> {
786
+
await xrpc('com.atproto.server.deletePasskey', {
787
+
method: 'POST',
883
788
token,
884
789
body: { id },
885
-
});
790
+
})
886
791
},
887
792
888
793
async updatePasskey(
889
-
token: string,
794
+
token: AccessToken,
890
795
id: string,
891
796
friendlyName: string,
892
797
): Promise<void> {
893
-
await xrpc("com.atproto.server.updatePasskey", {
894
-
method: "POST",
798
+
await xrpc('com.atproto.server.updatePasskey', {
799
+
method: 'POST',
895
800
token,
896
801
body: { id, friendlyName },
897
-
});
802
+
})
898
803
},
899
804
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 });
805
+
listTrustedDevices(token: AccessToken): Promise<ListTrustedDevicesResponse> {
806
+
return xrpc('_account.listTrustedDevices', { token })
911
807
},
912
808
913
-
revokeTrustedDevice(
914
-
token: string,
915
-
deviceId: string,
916
-
): Promise<{ success: boolean }> {
917
-
return xrpc("_account.revokeTrustedDevice", {
918
-
method: "POST",
809
+
revokeTrustedDevice(token: AccessToken, deviceId: string): Promise<SuccessResponse> {
810
+
return xrpc('_account.revokeTrustedDevice', {
811
+
method: 'POST',
919
812
token,
920
813
body: { deviceId },
921
-
});
814
+
})
922
815
},
923
816
924
817
updateTrustedDevice(
925
-
token: string,
818
+
token: AccessToken,
926
819
deviceId: string,
927
820
friendlyName: string,
928
-
): Promise<{ success: boolean }> {
929
-
return xrpc("_account.updateTrustedDevice", {
930
-
method: "POST",
821
+
): Promise<SuccessResponse> {
822
+
return xrpc('_account.updateTrustedDevice', {
823
+
method: 'POST',
931
824
token,
932
825
body: { deviceId, friendlyName },
933
-
});
826
+
})
934
827
},
935
828
936
-
getReauthStatus(token: string): Promise<{
937
-
requiresReauth: boolean;
938
-
lastReauthAt: string | null;
939
-
availableMethods: string[];
940
-
}> {
941
-
return xrpc("_account.getReauthStatus", { token });
829
+
getReauthStatus(token: AccessToken): Promise<ReauthStatus> {
830
+
return xrpc('_account.getReauthStatus', { token })
942
831
},
943
832
944
-
reauthPassword(
945
-
token: string,
946
-
password: string,
947
-
): Promise<{ success: boolean; reauthAt: string }> {
948
-
return xrpc("_account.reauthPassword", {
949
-
method: "POST",
833
+
reauthPassword(token: AccessToken, password: string): Promise<ReauthResponse> {
834
+
return xrpc('_account.reauthPassword', {
835
+
method: 'POST',
950
836
token,
951
837
body: { password },
952
-
});
838
+
})
953
839
},
954
840
955
-
reauthTotp(
956
-
token: string,
957
-
code: string,
958
-
): Promise<{ success: boolean; reauthAt: string }> {
959
-
return xrpc("_account.reauthTotp", {
960
-
method: "POST",
841
+
reauthTotp(token: AccessToken, code: string): Promise<ReauthResponse> {
842
+
return xrpc('_account.reauthTotp', {
843
+
method: 'POST',
961
844
token,
962
845
body: { code },
963
-
});
846
+
})
964
847
},
965
848
966
-
reauthPasskeyStart(token: string): Promise<{ options: unknown }> {
967
-
return xrpc("_account.reauthPasskeyStart", {
968
-
method: "POST",
849
+
reauthPasskeyStart(token: AccessToken): Promise<ReauthPasskeyStartResponse> {
850
+
return xrpc('_account.reauthPasskeyStart', {
851
+
method: 'POST',
969
852
token,
970
-
});
853
+
})
971
854
},
972
855
973
-
reauthPasskeyFinish(
974
-
token: string,
975
-
credential: unknown,
976
-
): Promise<{ success: boolean; reauthAt: string }> {
977
-
return xrpc("_account.reauthPasskeyFinish", {
978
-
method: "POST",
856
+
reauthPasskeyFinish(token: AccessToken, credential: unknown): Promise<ReauthResponse> {
857
+
return xrpc('_account.reauthPasskeyFinish', {
858
+
method: 'POST',
979
859
token,
980
860
body: { credential },
981
-
});
861
+
})
982
862
},
983
863
984
-
reserveSigningKey(did?: string): Promise<{ signingKey: string }> {
985
-
return xrpc("com.atproto.server.reserveSigningKey", {
986
-
method: "POST",
864
+
reserveSigningKey(did?: Did): Promise<ReserveSigningKeyResponse> {
865
+
return xrpc('com.atproto.server.reserveSigningKey', {
866
+
method: 'POST',
987
867
body: { did },
988
-
});
868
+
})
989
869
},
990
870
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 });
871
+
getRecommendedDidCredentials(token: AccessToken): Promise<RecommendedDidCredentials> {
872
+
return xrpc('com.atproto.identity.getRecommendedDidCredentials', { token })
998
873
},
999
874
1000
-
async activateAccount(token: string): Promise<void> {
1001
-
await xrpc("com.atproto.server.activateAccount", {
1002
-
method: "POST",
875
+
async activateAccount(token: AccessToken): Promise<void> {
876
+
await xrpc('com.atproto.server.activateAccount', {
877
+
method: 'POST',
1003
878
token,
1004
-
});
879
+
})
1005
880
},
1006
881
1007
882
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`;
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`
1025
895
const headers: Record<string, string> = {
1026
-
"Content-Type": "application/json",
1027
-
};
896
+
'Content-Type': 'application/json',
897
+
}
1028
898
if (byodToken) {
1029
-
headers["Authorization"] = `Bearer ${byodToken}`;
899
+
headers['Authorization'] = `Bearer ${byodToken}`
1030
900
}
1031
901
const res = await fetch(url, {
1032
-
method: "POST",
902
+
method: 'POST',
1033
903
headers,
1034
904
body: JSON.stringify(params),
1035
-
});
905
+
})
1036
906
if (!res.ok) {
1037
-
const err = await res.json().catch(() => ({
1038
-
error: "Unknown",
907
+
const errData = await res.json().catch(() => ({
908
+
error: 'Unknown',
1039
909
message: res.statusText,
1040
-
}));
1041
-
throw new ApiError(res.status, err.error, err.message);
910
+
}))
911
+
throw new ApiError(res.status, errData.error, errData.message)
1042
912
}
1043
-
return res.json();
913
+
return res.json()
1044
914
},
1045
915
1046
916
startPasskeyRegistrationForSetup(
1047
-
did: string,
917
+
did: Did,
1048
918
setupToken: string,
1049
919
friendlyName?: string,
1050
-
): Promise<{ options: unknown }> {
1051
-
return xrpc("_account.startPasskeyRegistrationForSetup", {
1052
-
method: "POST",
920
+
): Promise<StartPasskeyRegistrationResponse> {
921
+
return xrpc('_account.startPasskeyRegistrationForSetup', {
922
+
method: 'POST',
1053
923
body: { did, setupToken, friendlyName },
1054
-
});
924
+
})
1055
925
},
1056
926
1057
927
completePasskeySetup(
1058
-
did: string,
928
+
did: Did,
1059
929
setupToken: string,
1060
930
passkeyCredential: unknown,
1061
931
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",
932
+
): Promise<CompletePasskeySetupResponse> {
933
+
return xrpc('_account.completePasskeySetup', {
934
+
method: 'POST',
1070
935
body: { did, setupToken, passkeyCredential, passkeyFriendlyName },
1071
-
});
936
+
})
1072
937
},
1073
938
1074
-
requestPasskeyRecovery(email: string): Promise<{ success: boolean }> {
1075
-
return xrpc("_account.requestPasskeyRecovery", {
1076
-
method: "POST",
939
+
requestPasskeyRecovery(email: EmailAddress): Promise<SuccessResponse> {
940
+
return xrpc('_account.requestPasskeyRecovery', {
941
+
method: 'POST',
1077
942
body: { email },
1078
-
});
943
+
})
1079
944
},
1080
945
1081
946
recoverPasskeyAccount(
1082
-
did: string,
947
+
did: Did,
1083
948
recoveryToken: string,
1084
949
newPassword: string,
1085
-
): Promise<{ success: boolean }> {
1086
-
return xrpc("_account.recoverPasskeyAccount", {
1087
-
method: "POST",
950
+
): Promise<SuccessResponse> {
951
+
return xrpc('_account.recoverPasskeyAccount', {
952
+
method: 'POST',
1088
953
body: { did, recoveryToken, newPassword },
1089
-
});
954
+
})
1090
955
},
1091
956
1092
-
verifyMigrationEmail(
1093
-
token: string,
1094
-
email: string,
1095
-
): Promise<{ success: boolean; did: string }> {
1096
-
return xrpc("com.atproto.server.verifyMigrationEmail", {
1097
-
method: "POST",
957
+
verifyMigrationEmail(token: string, email: EmailAddress): Promise<VerifyMigrationEmailResponse> {
958
+
return xrpc('com.atproto.server.verifyMigrationEmail', {
959
+
method: 'POST',
1098
960
body: { token, email },
1099
-
});
961
+
})
1100
962
},
1101
963
1102
-
resendMigrationVerification(email: string): Promise<{ sent: boolean }> {
1103
-
return xrpc("com.atproto.server.resendMigrationVerification", {
1104
-
method: "POST",
964
+
resendMigrationVerification(email: EmailAddress): Promise<ResendMigrationVerificationResponse> {
965
+
return xrpc('com.atproto.server.resendMigrationVerification', {
966
+
method: 'POST',
1105
967
body: { email },
1106
-
});
968
+
})
1107
969
},
1108
970
1109
971
verifyToken(
1110
972
token: string,
1111
973
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",
974
+
accessToken?: AccessToken,
975
+
): Promise<VerifyTokenResponse> {
976
+
return xrpc('_account.verifyToken', {
977
+
method: 'POST',
1121
978
body: { token, identifier },
1122
979
token: accessToken,
1123
-
});
980
+
})
1124
981
},
1125
982
1126
-
getDidDocument(token: string): Promise<DidDocument> {
1127
-
return xrpc("_account.getDidDocument", { token });
983
+
getDidDocument(token: AccessToken): Promise<DidDocument> {
984
+
return xrpc('_account.getDidDocument', { token })
1128
985
},
1129
986
1130
987
updateDidDocument(
1131
-
token: string,
988
+
token: AccessToken,
1132
989
params: {
1133
-
verificationMethods?: VerificationMethod[];
1134
-
alsoKnownAs?: string[];
1135
-
serviceEndpoint?: string;
990
+
verificationMethods?: VerificationMethod[]
991
+
alsoKnownAs?: string[]
992
+
serviceEndpoint?: string
1136
993
},
1137
-
): Promise<{ success: boolean }> {
1138
-
return xrpc("_account.updateDidDocument", {
1139
-
method: "POST",
994
+
): Promise<SuccessResponse> {
995
+
return xrpc('_account.updateDidDocument', {
996
+
method: 'POST',
1140
997
token,
1141
998
body: params,
1142
-
});
999
+
})
1143
1000
},
1144
1001
1145
-
async deactivateAccount(
1146
-
token: string,
1147
-
deleteAfter?: string,
1148
-
): Promise<void> {
1149
-
await xrpc("com.atproto.server.deactivateAccount", {
1150
-
method: "POST",
1002
+
async deactivateAccount(token: AccessToken, deleteAfter?: string): Promise<void> {
1003
+
await xrpc('com.atproto.server.deactivateAccount', {
1004
+
method: 'POST',
1151
1005
token,
1152
1006
body: { deleteAfter },
1153
-
});
1007
+
})
1154
1008
},
1155
1009
1156
-
async getRepo(token: string, did: string): Promise<ArrayBuffer> {
1157
-
const url = `${API_BASE}/com.atproto.sync.getRepo?did=${
1158
-
encodeURIComponent(did)
1159
-
}`;
1010
+
async getRepo(token: AccessToken, did: Did): Promise<ArrayBuffer> {
1011
+
const url = `${API_BASE}/com.atproto.sync.getRepo?did=${encodeURIComponent(did)}`
1160
1012
const res = await fetch(url, {
1161
1013
headers: { Authorization: `Bearer ${token}` },
1162
-
});
1014
+
})
1163
1015
if (!res.ok) {
1164
-
const err = await res.json().catch(() => ({
1165
-
error: "Unknown",
1016
+
const errData = await res.json().catch(() => ({
1017
+
error: 'Unknown',
1166
1018
message: res.statusText,
1167
-
}));
1168
-
throw new ApiError(res.status, err.error, err.message);
1019
+
}))
1020
+
throw new ApiError(res.status, errData.error, errData.message)
1169
1021
}
1170
-
return res.arrayBuffer();
1022
+
return res.arrayBuffer()
1171
1023
},
1172
1024
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 });
1025
+
listBackups(token: AccessToken): Promise<ListBackupsResponse> {
1026
+
return xrpc('_backup.listBackups', { token })
1185
1027
},
1186
1028
1187
-
async getBackup(token: string, id: string): Promise<Blob> {
1188
-
const url = `${API_BASE}/_backup.getBackup?id=${encodeURIComponent(id)}`;
1029
+
async getBackup(token: AccessToken, id: string): Promise<Blob> {
1030
+
const url = `${API_BASE}/_backup.getBackup?id=${encodeURIComponent(id)}`
1189
1031
const res = await fetch(url, {
1190
1032
headers: { Authorization: `Bearer ${token}` },
1191
-
});
1033
+
})
1192
1034
if (!res.ok) {
1193
-
const err = await res.json().catch(() => ({
1194
-
error: "Unknown",
1035
+
const errData = await res.json().catch(() => ({
1036
+
error: 'Unknown',
1195
1037
message: res.statusText,
1196
-
}));
1197
-
throw new ApiError(res.status, err.error, err.message);
1038
+
}))
1039
+
throw new ApiError(res.status, errData.error, errData.message)
1198
1040
}
1199
-
return res.blob();
1041
+
return res.blob()
1200
1042
},
1201
1043
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",
1044
+
createBackup(token: AccessToken): Promise<CreateBackupResponse> {
1045
+
return xrpc('_backup.createBackup', {
1046
+
method: 'POST',
1210
1047
token,
1211
-
});
1048
+
})
1212
1049
},
1213
1050
1214
-
async deleteBackup(token: string, id: string): Promise<void> {
1215
-
await xrpc("_backup.deleteBackup", {
1216
-
method: "POST",
1051
+
async deleteBackup(token: AccessToken, id: string): Promise<void> {
1052
+
await xrpc('_backup.deleteBackup', {
1053
+
method: 'POST',
1217
1054
token,
1218
1055
params: { id },
1219
-
});
1056
+
})
1220
1057
},
1221
1058
1222
-
setBackupEnabled(
1223
-
token: string,
1224
-
enabled: boolean,
1225
-
): Promise<{ enabled: boolean }> {
1226
-
return xrpc("_backup.setEnabled", {
1227
-
method: "POST",
1059
+
setBackupEnabled(token: AccessToken, enabled: boolean): Promise<SetBackupEnabledResponse> {
1060
+
return xrpc('_backup.setEnabled', {
1061
+
method: 'POST',
1228
1062
token,
1229
1063
body: { enabled },
1230
-
});
1064
+
})
1231
1065
},
1232
1066
1233
-
async importRepo(token: string, car: Uint8Array): Promise<void> {
1234
-
const url = `${API_BASE}/com.atproto.repo.importRepo`;
1067
+
async importRepo(token: AccessToken, car: Uint8Array): Promise<void> {
1068
+
const url = `${API_BASE}/com.atproto.repo.importRepo`
1235
1069
const res = await fetch(url, {
1236
-
method: "POST",
1070
+
method: 'POST',
1237
1071
headers: {
1238
1072
Authorization: `Bearer ${token}`,
1239
-
"Content-Type": "application/vnd.ipld.car",
1073
+
'Content-Type': 'application/vnd.ipld.car',
1240
1074
},
1241
1075
body: car,
1242
-
});
1076
+
})
1243
1077
if (!res.ok) {
1244
-
const err = await res.json().catch(() => ({
1245
-
error: "Unknown",
1078
+
const errData = await res.json().catch(() => ({
1079
+
error: 'Unknown',
1246
1080
message: res.statusText,
1247
-
}));
1248
-
throw new ApiError(res.status, err.error, err.message);
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
1249
1364
}
1365
+
): Promise<Result<SuccessResponse, ApiError>> {
1366
+
return xrpcResult('_account.updateNotificationPrefs', {
1367
+
method: 'POST',
1368
+
token,
1369
+
body: prefs,
1370
+
})
1250
1371
},
1251
-
};
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
1
import {
2
2
api,
3
3
ApiError,
4
+
typedApi,
4
5
type CreateAccountParams,
5
6
type CreateAccountResult,
6
-
type Session,
7
-
setTokenRefreshCallback,
8
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";
9
21
import {
10
22
checkForOAuthCallback,
11
23
clearOAuthCallbackParams,
···
15
27
} from "./oauth";
16
28
import { setLocale, type SupportedLocale } from "./i18n";
17
29
18
-
function applyLocaleFromSession(
19
-
sessionInfo: { preferredLocale?: string | null },
20
-
) {
21
-
if (sessionInfo.preferredLocale) {
22
-
setLocale(sessionInfo.preferredLocale as SupportedLocale);
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 };
23
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" };
24
61
}
25
62
26
-
const STORAGE_KEY = "tranquil_pds_session";
27
-
const ACCOUNTS_KEY = "tranquil_pds_accounts";
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
+
}
28
98
29
-
export interface SavedAccount {
30
-
did: string;
31
-
handle: string;
32
-
accessJwt: string;
33
-
refreshJwt: string;
99
+
function createAuthenticated(
100
+
session: Session,
101
+
savedAccounts: readonly SavedAccount[],
102
+
): AuthState {
103
+
return { kind: "authenticated", session, savedAccounts };
34
104
}
35
105
36
-
interface AuthState {
37
-
session: Session | null;
38
-
loading: boolean;
39
-
error: string | null;
40
-
savedAccounts: SavedAccount[];
106
+
function createError(
107
+
error: AuthError,
108
+
savedAccounts: readonly SavedAccount[],
109
+
): AuthState {
110
+
return { kind: "error", error, savedAccounts };
41
111
}
42
112
43
-
const state = $state<AuthState>({
44
-
session: null,
45
-
loading: true,
46
-
error: null,
47
-
savedAccounts: [],
113
+
const state = $state<{ current: AuthState }>({
114
+
current: createLoading([]),
48
115
});
49
116
50
-
function saveSession(session: Session | null) {
51
-
if (session) {
52
-
localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
53
-
} else {
54
-
localStorage.removeItem(STORAGE_KEY);
117
+
function applyLocaleFromSession(sessionInfo: {
118
+
preferredLocale?: string | null;
119
+
}): void {
120
+
if (sessionInfo.preferredLocale) {
121
+
setLocale(sessionInfo.preferredLocale as SupportedLocale);
55
122
}
56
123
}
57
124
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;
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);
65
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"));
66
162
}
67
-
return null;
68
163
}
69
164
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 [];
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"));
77
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"));
78
190
}
79
-
return [];
80
191
}
81
192
82
-
function saveSavedAccounts(accounts: SavedAccount[]) {
83
-
localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts));
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 : [];
84
205
}
85
206
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;
207
+
function persistSession(session: Session | null): void {
208
+
if (session) {
209
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
97
210
} else {
98
-
accounts.push(savedAccount);
211
+
localStorage.removeItem(STORAGE_KEY);
99
212
}
100
-
saveSavedAccounts(accounts);
101
-
state.savedAccounts = accounts;
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;
102
248
}
103
249
104
-
function removeSavedAccount(did: string) {
105
-
const accounts = loadSavedAccounts().filter((a) => a.did !== did);
106
-
saveSavedAccounts(accounts);
107
-
state.savedAccounts = accounts;
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));
108
268
}
109
269
110
270
async function tryRefreshToken(): Promise<string | null> {
111
-
if (!state.session) return null;
271
+
if (state.current.kind !== "authenticated") return null;
272
+
const currentSession = state.current.session;
112
273
try {
113
-
const tokens = await refreshOAuthToken(state.session.refreshJwt);
274
+
const tokens = await refreshOAuthToken(currentSession.refreshJwt);
114
275
const sessionInfo = await api.getSession(tokens.access_token);
115
276
const session: Session = {
116
277
...sessionInfo,
117
278
accessJwt: tokens.access_token,
118
-
refreshJwt: tokens.refresh_token || state.session.refreshJwt,
279
+
refreshJwt: tokens.refresh_token || currentSession.refreshJwt,
119
280
};
120
-
state.session = session;
121
-
saveSession(session);
122
-
addOrUpdateSavedAccount(session);
281
+
setAuthenticated(session);
123
282
return session.accessJwt;
124
283
} catch {
125
284
return null;
126
285
}
127
286
}
128
287
288
+
import { setTokenRefreshCallback } from "./api";
289
+
129
290
export async function initAuth(): Promise<{ oauthLoginCompleted: boolean }> {
130
291
setTokenRefreshCallback(tryRefreshToken);
131
-
state.loading = true;
132
-
state.error = null;
133
-
state.savedAccounts = loadSavedAccounts();
292
+
const savedAccounts = loadSavedAccountsFromStorage();
293
+
setState(createLoading(savedAccounts));
134
294
135
295
const oauthCallback = checkForOAuthCallback();
136
296
if (oauthCallback) {
···
146
306
accessJwt: tokens.access_token,
147
307
refreshJwt: tokens.refresh_token || "",
148
308
};
149
-
state.session = session;
150
-
saveSession(session);
151
-
addOrUpdateSavedAccount(session);
309
+
setAuthenticated(session);
152
310
applyLocaleFromSession(sessionInfo);
153
-
state.loading = false;
154
311
return { oauthLoginCompleted: true };
155
312
} catch (e) {
156
-
state.error = e instanceof Error ? e.message : "OAuth login failed";
157
-
state.loading = false;
313
+
setError({ type: "oauth", message: e instanceof Error ? e.message : "OAuth login failed" });
158
314
return { oauthLoginCompleted: false };
159
315
}
160
316
}
161
317
162
-
const stored = loadSession();
318
+
const stored = loadSessionFromStorage();
163
319
if (stored) {
164
320
try {
165
321
const sessionInfo = await api.getSession(stored.accessJwt);
166
-
state.session = {
322
+
const session: Session = {
167
323
...sessionInfo,
168
324
accessJwt: stored.accessJwt,
169
325
refreshJwt: stored.refreshJwt,
170
326
};
171
-
addOrUpdateSavedAccount(state.session);
327
+
setAuthenticated(session);
172
328
applyLocaleFromSession(sessionInfo);
173
329
} catch (e) {
174
330
if (e instanceof ApiError && e.status === 401) {
···
180
336
accessJwt: tokens.access_token,
181
337
refreshJwt: tokens.refresh_token || stored.refreshJwt,
182
338
};
183
-
state.session = session;
184
-
saveSession(session);
185
-
addOrUpdateSavedAccount(session);
339
+
setAuthenticated(session);
186
340
applyLocaleFromSession(sessionInfo);
187
341
} catch (refreshError) {
188
342
console.error("Token refresh failed during init:", refreshError);
189
-
saveSession(null);
190
-
state.session = null;
343
+
setUnauthenticated();
191
344
}
192
345
} else {
193
346
console.error("Non-401 error during getSession:", e);
194
-
saveSession(null);
195
-
state.session = null;
347
+
setUnauthenticated();
196
348
}
197
349
}
350
+
} else {
351
+
setState(createUnauthenticated(savedAccounts));
198
352
}
199
-
state.loading = false;
353
+
200
354
return { oauthLoginCompleted: false };
201
355
}
202
356
203
357
export async function login(
204
358
identifier: string,
205
359
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;
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);
223
371
}
372
+
373
+
setAuthenticated(result.value);
374
+
return ok(result.value);
224
375
}
225
376
226
-
export async function loginWithOAuth(): Promise<void> {
227
-
state.loading = true;
228
-
state.error = null;
377
+
export async function loginWithOAuth(): Promise<Result<void, AuthError>> {
378
+
setLoading();
229
379
try {
230
380
await startOAuthLogin();
381
+
return ok(undefined);
231
382
} catch (e) {
232
-
state.loading = false;
233
-
state.error = e instanceof Error
234
-
? e.message
235
-
: "Failed to start OAuth login";
236
-
throw e;
383
+
const error = toAuthError(e);
384
+
setError(error);
385
+
return err(error);
237
386
}
238
387
}
239
388
240
389
export async function register(
241
390
params: CreateAccountParams,
242
-
): Promise<CreateAccountResult> {
391
+
): Promise<Result<CreateAccountResult, AuthError>> {
243
392
try {
244
393
const result = await api.createAccount(params);
245
-
return result;
394
+
return ok(result);
246
395
} catch (e) {
247
-
if (e instanceof ApiError) {
248
-
state.error = e.message;
249
-
} else {
250
-
state.error = "Registration failed";
251
-
}
252
-
throw e;
396
+
return err(toAuthError(e));
253
397
}
254
398
}
255
399
256
400
export async function confirmSignup(
257
401
did: string,
258
402
verificationCode: string,
259
-
): Promise<void> {
260
-
state.loading = true;
261
-
state.error = null;
403
+
): Promise<Result<Session, AuthError>> {
404
+
setLoading();
262
405
try {
263
406
const result = await api.confirmSignup(did, verificationCode);
264
407
const session: Session = {
···
271
414
preferredChannel: result.preferredChannel,
272
415
preferredChannelVerified: result.preferredChannelVerified,
273
416
};
274
-
state.session = session;
275
-
saveSession(session);
276
-
addOrUpdateSavedAccount(session);
417
+
setAuthenticated(session);
418
+
return ok(session);
277
419
} 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;
420
+
const error = toAuthError(e);
421
+
setError(error);
422
+
return err(error);
286
423
}
287
424
}
288
425
289
-
export async function resendVerification(did: string): Promise<void> {
426
+
export async function resendVerification(
427
+
did: string,
428
+
): Promise<Result<void, AuthError>> {
290
429
try {
291
430
await api.resendVerification(did);
431
+
return ok(undefined);
292
432
} catch (e) {
293
-
if (e instanceof ApiError) {
294
-
throw e;
295
-
}
296
-
throw new Error("Failed to resend verification code");
433
+
return err(toAuthError(e));
297
434
}
298
435
}
299
436
300
-
export function setSession(
301
-
session: {
302
-
did: string;
303
-
handle: string;
304
-
accessJwt: string;
305
-
refreshJwt: string;
306
-
},
307
-
): void {
437
+
export function setSession(session: {
438
+
did: string;
439
+
handle: string;
440
+
accessJwt: string;
441
+
refreshJwt: string;
442
+
}): void {
308
443
const newSession: Session = {
309
444
did: session.did,
310
445
handle: session.handle,
311
446
accessJwt: session.accessJwt,
312
447
refreshJwt: session.refreshJwt,
313
448
};
314
-
state.session = newSession;
315
-
saveSession(newSession);
316
-
addOrUpdateSavedAccount(newSession);
449
+
setAuthenticated(newSession);
317
450
}
318
451
319
-
export async function logout(): Promise<void> {
320
-
if (state.session) {
321
-
const did = state.session.did;
322
-
const refreshToken = state.session.refreshJwt;
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);
323
456
try {
324
457
await fetch("/oauth/revoke", {
325
458
method: "POST",
326
459
headers: { "Content-Type": "application/x-www-form-urlencoded" },
327
-
body: new URLSearchParams({ token: refreshToken }),
460
+
body: new URLSearchParams({ token: session.refreshJwt }),
328
461
});
329
462
} catch {
330
-
// Ignore errors on logout
463
+
// Ignore revocation errors
331
464
}
332
-
removeSavedAccount(did);
465
+
const accounts = removeSavedAccountByDid(getSavedAccounts(), did);
466
+
persistSavedAccounts(accounts);
467
+
persistSession(null);
468
+
setState(createUnauthenticated(accounts));
469
+
} else {
470
+
setUnauthenticated();
333
471
}
334
-
state.session = null;
335
-
saveSession(null);
472
+
return ok(undefined);
336
473
}
337
474
338
-
export async function switchAccount(did: string): Promise<void> {
339
-
const account = state.savedAccounts.find((a) => a.did === did);
475
+
export async function switchAccount(
476
+
did: Did,
477
+
): Promise<Result<Session, AuthError>> {
478
+
const account = findSavedAccount(getSavedAccounts(), did);
340
479
if (!account) {
341
-
throw new Error("Account not found");
480
+
return err({ type: "validation", message: "Account not found" });
342
481
}
343
-
state.loading = true;
344
-
state.error = null;
482
+
483
+
setLoading();
484
+
345
485
try {
346
-
const session = await api.getSession(account.accessJwt);
347
-
state.session = {
348
-
...session,
349
-
accessJwt: account.accessJwt,
350
-
refreshJwt: account.refreshJwt,
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,
351
491
};
352
-
saveSession(state.session);
353
-
addOrUpdateSavedAccount(state.session);
492
+
setAuthenticated(session);
493
+
return ok(session);
354
494
} catch (e) {
355
495
if (e instanceof ApiError && e.status === 401) {
356
496
try {
357
-
const tokens = await refreshOAuthToken(account.refreshJwt);
497
+
const tokens = await refreshOAuthToken(account.refreshJwt as string);
358
498
const sessionInfo = await api.getSession(tokens.access_token);
359
499
const session: Session = {
360
500
...sessionInfo,
361
501
accessJwt: tokens.access_token,
362
-
refreshJwt: tokens.refresh_token || account.refreshJwt,
502
+
refreshJwt: tokens.refresh_token || (account.refreshJwt as string),
363
503
};
364
-
state.session = session;
365
-
saveSession(session);
366
-
addOrUpdateSavedAccount(session);
504
+
setAuthenticated(session);
505
+
return ok(session);
367
506
} catch {
368
-
removeSavedAccount(did);
369
-
state.error = "Session expired. Please log in again.";
370
-
throw new Error("Session expired");
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);
371
515
}
372
-
} else {
373
-
state.error = "Failed to switch account";
374
-
throw e;
375
516
}
376
-
} finally {
377
-
state.loading = false;
517
+
const error = toAuthError(e);
518
+
setError(error);
519
+
return err(error);
378
520
}
379
521
}
380
522
381
-
export function forgetAccount(did: string): void {
382
-
removeSavedAccount(did);
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);
383
530
}
384
531
385
-
export function getAuthState() {
386
-
return state;
532
+
export function getAuthState(): AuthState {
533
+
return state.current;
387
534
}
388
535
389
-
export async function refreshSession(): Promise<void> {
390
-
if (!state.session) return;
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;
391
541
try {
392
-
const sessionInfo = await api.getSession(state.session.accessJwt);
393
-
state.session = {
542
+
const sessionInfo = await api.getSession(currentSession.accessJwt);
543
+
const session: Session = {
394
544
...sessionInfo,
395
-
accessJwt: state.session.accessJwt,
396
-
refreshJwt: state.session.refreshJwt,
545
+
accessJwt: currentSession.accessJwt,
546
+
refreshJwt: currentSession.refreshJwt,
397
547
};
398
-
saveSession(state.session);
399
-
addOrUpdateSavedAccount(state.session);
548
+
setAuthenticated(session);
549
+
return ok(session);
400
550
} catch (e) {
401
551
console.error("Failed to refresh session:", e);
552
+
return err(toAuthError(e));
402
553
}
403
554
}
404
555
405
-
export function getToken(): string | null {
406
-
return state.session?.accessJwt ?? null;
556
+
export function getToken(): AccessToken | null {
557
+
if (state.current.kind === "authenticated") {
558
+
return unsafeAsAccessToken(state.current.session.accessJwt);
559
+
}
560
+
return null;
407
561
}
408
562
409
-
export async function getValidToken(): Promise<string | null> {
410
-
if (!state.session) return null;
563
+
export async function getValidToken(): Promise<AccessToken | null> {
564
+
if (state.current.kind !== "authenticated") return null;
565
+
const currentSession = state.current.session;
411
566
try {
412
-
await api.getSession(state.session.accessJwt);
413
-
return state.session.accessJwt;
567
+
await api.getSession(currentSession.accessJwt);
568
+
return unsafeAsAccessToken(currentSession.accessJwt);
414
569
} catch (e) {
415
570
if (e instanceof ApiError && e.status === 401) {
416
571
try {
417
-
const tokens = await refreshOAuthToken(state.session.refreshJwt);
572
+
const tokens = await refreshOAuthToken(currentSession.refreshJwt);
418
573
const sessionInfo = await api.getSession(tokens.access_token);
419
574
const session: Session = {
420
575
...sessionInfo,
421
576
accessJwt: tokens.access_token,
422
-
refreshJwt: tokens.refresh_token || state.session.refreshJwt,
577
+
refreshJwt: tokens.refresh_token || currentSession.refreshJwt,
423
578
};
424
-
state.session = session;
425
-
saveSession(session);
426
-
addOrUpdateSavedAccount(session);
427
-
return session.accessJwt;
579
+
setAuthenticated(session);
580
+
return unsafeAsAccessToken(session.accessJwt);
428
581
} catch {
429
582
return null;
430
583
}
···
434
587
}
435
588
436
589
export function isAuthenticated(): boolean {
437
-
return state.session !== null;
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
+
}
438
624
}
439
625
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 ?? [];
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
+
}
452
642
}
453
643
454
-
export function _testResetState() {
455
-
state.session = null;
456
-
state.loading = true;
457
-
state.error = null;
458
-
state.savedAccounts = [];
644
+
export function _testResetState(): void {
645
+
setState(createLoading([]));
459
646
}
460
647
461
-
export function _testReset() {
648
+
export function _testReset(): void {
462
649
_testResetState();
463
650
localStorage.removeItem(STORAGE_KEY);
464
651
localStorage.removeItem(ACCOUNTS_KEY);
465
652
}
653
+
654
+
export { type Session };
+1
-4
frontend/src/lib/crypto.ts
+1
-4
frontend/src/lib/crypto.ts
···
35
35
const bytes = typeof data === "string"
36
36
? new TextEncoder().encode(data)
37
37
: data;
38
-
let binary = "";
39
-
for (let i = 0; i < bytes.length; i++) {
40
-
binary += String.fromCharCode(bytes[i]);
41
-
}
38
+
const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('')
42
39
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
43
40
}
44
41
+8
-16
frontend/src/lib/migration/atproto-client.ts
+8
-16
frontend/src/lib/migration/atproto-client.ts
···
600
600
601
601
export function base64UrlEncode(buffer: Uint8Array | ArrayBuffer): string {
602
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
-
}
603
+
const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('')
607
604
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(
608
605
/=+$/,
609
606
"",
···
614
611
const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
615
612
const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
616
613
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;
614
+
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
622
615
}
623
616
624
617
export function prepareWebAuthnCreationOptions(
···
865
858
);
866
859
if (dnsRes.ok) {
867
860
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
-
}
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);
875
867
}
876
868
}
877
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
···
34
34
35
35
function base64UrlEncode(buffer: ArrayBuffer): string {
36
36
const bytes = new Uint8Array(buffer);
37
-
let binary = "";
38
-
for (const byte of bytes) {
39
-
binary += String.fromCharCode(byte);
40
-
}
37
+
const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('')
41
38
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(
42
39
/=+$/,
43
40
"",
+1
-1
frontend/src/lib/registration/VerificationStep.svelte
+1
-1
frontend/src/lib/registration/VerificationStep.svelte
···
1
1
<script lang="ts">
2
2
import { api, ApiError } from '../api'
3
+
import { resendVerification } from '../auth.svelte'
3
4
import type { RegistrationFlow } from './flow.svelte'
4
5
5
6
interface Props {
···
36
37
flow.clearError()
37
38
38
39
try {
39
-
const { resendVerification } = await import('../auth.svelte')
40
40
await resendVerification(flow.account.did)
41
41
resendMessage = 'Verification code resent!'
42
42
} catch (err) {
+1
-1
frontend/src/lib/registration/flow.svelte.ts
+1
-1
frontend/src/lib/registration/flow.svelte.ts
···
1
1
import { api, ApiError } from "../api";
2
+
import { setSession } from "../auth.svelte";
2
3
import {
3
4
createServiceJwt,
4
5
generateDidDocument,
···
341
342
342
343
async function finalizeSession() {
343
344
if (!state.session || !state.account) return;
344
-
const { setSession } = await import("../auth.svelte");
345
345
setSession({
346
346
did: state.account.did,
347
347
handle: state.account.handle,
+115
-11
frontend/src/lib/router.svelte.ts
+115
-11
frontend/src/lib/router.svelte.ts
···
1
+
import {
2
+
routes,
3
+
type Route,
4
+
type RouteParams,
5
+
type RoutesWithParams,
6
+
buildUrl,
7
+
parseRouteParams,
8
+
isValidRoute,
9
+
} from "./types/routes";
10
+
1
11
const APP_BASE = "/app";
2
12
3
-
function getAppPath(): string {
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 {
4
22
const pathname = globalThis.location.pathname;
5
23
if (pathname.startsWith(APP_BASE)) {
6
24
const path = pathname.slice(APP_BASE.length) || "/";
7
-
return path.startsWith("/") ? path : "/" + path;
25
+
return asAppPath(path);
8
26
}
9
-
return "/";
27
+
return asAppPath("/");
10
28
}
11
29
12
-
let currentPath = $state(getAppPath());
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
+
}
13
38
14
-
globalThis.addEventListener("popstate", () => {
15
-
currentPath = getAppPath();
39
+
const state = $state<{ current: RouterState }>({
40
+
current: {
41
+
path: getAppPath(),
42
+
searchParams: getSearchParams(),
43
+
},
16
44
});
17
45
18
-
export function navigate(path: string, replace = false) {
19
-
const fullPath = APP_BASE + (path.startsWith("/") ? path : "/" + path);
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
+
20
78
if (replace) {
21
79
globalThis.history.replaceState(null, "", fullPath);
22
80
} else {
23
81
globalThis.history.pushState(null, "", fullPath);
24
82
}
25
-
currentPath = path.startsWith("/") ? path : "/" + path;
83
+
84
+
updateState();
26
85
}
27
86
28
-
export function getCurrentPath() {
29
-
return currentPath;
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);
30
97
}
31
98
32
99
export function getFullUrl(path: string): string {
33
100
return APP_BASE + (path.startsWith("/") ? path : "/" + path);
34
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
1
<script lang="ts">
2
2
import { getAuthState, logout } from '../lib/auth.svelte'
3
-
import { navigate } from '../lib/router.svelte'
3
+
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
4
4
import { generateCodeVerifier, generateCodeChallenge, saveOAuthState, generateState } from '../lib/oauth'
5
5
import { _ } from '../lib/i18n'
6
+
import type { Session } from '../lib/types/api'
6
7
7
-
const auth = getAuthState()
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())
8
20
let error = $state<string | null>(null)
9
21
let loading = $state(true)
10
22
let actAsInProgress = $state(false)
···
15
27
}
16
28
17
29
$effect(() => {
18
-
if (!auth.loading && !auth.session && !actAsInProgress) {
19
-
navigate('/login')
30
+
if (!authLoading && !session && !actAsInProgress) {
31
+
navigate(routes.login)
20
32
}
21
33
})
22
34
23
35
$effect(() => {
24
-
if (auth.session && !actAsInProgress) {
36
+
if (session && !actAsInProgress) {
25
37
actAsInProgress = true
26
38
initiateActAs()
27
39
}
···
39
51
const response = await fetch(
40
52
`/xrpc/_delegation.listControlledAccounts`,
41
53
{
42
-
headers: { 'Authorization': `Bearer ${auth.session!.accessJwt}` }
54
+
headers: { 'Authorization': `Bearer ${session!.accessJwt}` }
43
55
}
44
56
)
45
57
+54
-62
frontend/src/routes/Admin.svelte
+54
-62
frontend/src/routes/Admin.svelte
···
1
1
<script lang="ts">
2
2
import { getAuthState } from '../lib/auth.svelte'
3
3
import { setServerName as setGlobalServerName, setColors as setGlobalColors, setHasLogo as setGlobalHasLogo } from '../lib/serverConfig.svelte'
4
-
import { navigate } from '../lib/router.svelte'
4
+
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
5
5
import { api, ApiError } from '../lib/api'
6
6
import { _ } from '../lib/i18n'
7
7
import { formatDate, formatDateTime } from '../lib/date'
8
-
const auth = getAuthState()
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())
9
23
const DEFAULT_COLORS = {
10
24
primaryLight: '#1A1D1D',
11
25
primaryDark: '#E6E8E8',
···
13
27
secondaryDark: '#E6E8E8',
14
28
}
15
29
let loading = $state(true)
16
-
let error = $state<string | null>(null)
17
30
let stats = $state<{
18
31
userCount: number
19
32
repoCount: number
···
21
34
blobStorageBytes: number
22
35
} | null>(null)
23
36
let usersLoading = $state(false)
24
-
let usersError = $state<string | null>(null)
25
37
let users = $state<Array<{
26
38
did: string
27
39
handle: string
···
34
46
let handleSearchQuery = $state('')
35
47
let showUsers = $state(false)
36
48
let invitesLoading = $state(false)
37
-
let invitesError = $state<string | null>(null)
38
49
let invites = $state<Array<{
39
50
code: string
40
51
available: number
···
72
83
let logoFile = $state<File | null>(null)
73
84
let logoPreview = $state<string | null>(null)
74
85
let serverConfigLoading = $state(false)
75
-
let serverConfigError = $state<string | null>(null)
76
-
let serverConfigSuccess = $state(false)
77
86
$effect(() => {
78
-
if (!auth.loading && !auth.session) {
79
-
navigate('/login')
80
-
} else if (!auth.loading && auth.session && !auth.session.isAdmin) {
81
-
navigate('/dashboard')
87
+
if (!authLoading && !session) {
88
+
navigate(routes.login)
89
+
} else if (!authLoading && session && !session.isAdmin) {
90
+
navigate(routes.dashboard)
82
91
}
83
92
})
84
93
$effect(() => {
85
-
if (auth.session?.isAdmin) {
94
+
if (session?.isAdmin) {
86
95
loadStats()
87
96
loadServerConfig()
88
97
}
···
106
115
logoPreview = '/logo'
107
116
}
108
117
} catch (e) {
109
-
serverConfigError = e instanceof ApiError ? e.message : 'Failed to load server config'
118
+
toast.error(e instanceof ApiError ? e.message : $_('admin.failedToLoadConfig'))
110
119
}
111
120
}
112
121
async function saveServerConfig(e: Event) {
113
122
e.preventDefault()
114
-
if (!auth.session) return
123
+
if (!session) return
115
124
serverConfigLoading = true
116
-
serverConfigError = null
117
-
serverConfigSuccess = false
118
125
try {
119
126
let newLogoCid = logoCid
120
127
if (logoFile) {
121
-
const result = await api.uploadBlob(auth.session.accessJwt, logoFile)
128
+
const result = await api.uploadBlob(session.accessJwt, logoFile)
122
129
newLogoCid = result.blob.ref.$link
123
130
}
124
-
await api.updateServerConfig(auth.session.accessJwt, {
131
+
await api.updateServerConfig(session.accessJwt, {
125
132
serverName: serverNameInput,
126
133
primaryColor: primaryColorInput,
127
134
primaryColorDark: primaryColorDarkInput,
···
145
152
secondaryColorDark: secondaryColorDarkInput || null,
146
153
})
147
154
setGlobalHasLogo(!!newLogoCid)
148
-
serverConfigSuccess = true
149
-
setTimeout(() => { serverConfigSuccess = false }, 3000)
155
+
toast.success($_('admin.configSaved'))
150
156
} catch (e) {
151
-
serverConfigError = e instanceof ApiError ? e.message : 'Failed to save server config'
157
+
toast.error(e instanceof ApiError ? e.message : $_('admin.failedToSaveConfig'))
152
158
} finally {
153
159
serverConfigLoading = false
154
160
}
···
179
185
logoChanged
180
186
}
181
187
async function loadStats() {
182
-
if (!auth.session) return
188
+
if (!session) return
183
189
loading = true
184
-
error = null
185
190
try {
186
-
stats = await api.getServerStats(auth.session.accessJwt)
191
+
stats = await api.getServerStats(session.accessJwt)
187
192
} catch (e) {
188
-
error = e instanceof ApiError ? e.message : 'Failed to load server stats'
193
+
toast.error(e instanceof ApiError ? e.message : $_('admin.failedToLoadStats'))
189
194
} finally {
190
195
loading = false
191
196
}
192
197
}
193
198
async function loadUsers(reset = false) {
194
-
if (!auth.session) return
199
+
if (!session) return
195
200
usersLoading = true
196
-
usersError = null
197
201
if (reset) {
198
202
users = []
199
203
usersCursor = undefined
200
204
}
201
205
try {
202
-
const result = await api.searchAccounts(auth.session.accessJwt, {
206
+
const result = await api.searchAccounts(session.accessJwt, {
203
207
handle: handleSearchQuery || undefined,
204
208
cursor: reset ? undefined : usersCursor,
205
209
limit: 25,
···
208
212
usersCursor = result.cursor
209
213
showUsers = true
210
214
} catch (e) {
211
-
usersError = e instanceof ApiError ? e.message : 'Failed to load users'
215
+
toast.error(e instanceof ApiError ? e.message : $_('admin.failedToLoadUsers'))
212
216
} finally {
213
217
usersLoading = false
214
218
}
···
218
222
loadUsers(true)
219
223
}
220
224
async function loadInvites(reset = false) {
221
-
if (!auth.session) return
225
+
if (!session) return
222
226
invitesLoading = true
223
-
invitesError = null
224
227
if (reset) {
225
228
invites = []
226
229
invitesCursor = undefined
227
230
}
228
231
try {
229
-
const result = await api.getInviteCodes(auth.session.accessJwt, {
232
+
const result = await api.getInviteCodes(session.accessJwt, {
230
233
cursor: reset ? undefined : invitesCursor,
231
234
limit: 25,
232
235
})
···
234
237
invitesCursor = result.cursor
235
238
showInvites = true
236
239
} catch (e) {
237
-
invitesError = e instanceof ApiError ? e.message : 'Failed to load invites'
240
+
toast.error(e instanceof ApiError ? e.message : $_('admin.failedToLoadInvites'))
238
241
} finally {
239
242
invitesLoading = false
240
243
}
241
244
}
242
245
async function disableInvite(code: string) {
243
-
if (!auth.session) return
246
+
if (!session) return
244
247
if (!confirm($_('admin.disableInviteConfirm', { values: { code } }))) return
245
248
try {
246
-
await api.disableInviteCodes(auth.session.accessJwt, [code])
249
+
await api.disableInviteCodes(session.accessJwt, [code])
247
250
invites = invites.map(inv => inv.code === code ? { ...inv, disabled: true } : inv)
251
+
toast.success($_('admin.inviteDisabled'))
248
252
} catch (e) {
249
-
invitesError = e instanceof ApiError ? e.message : 'Failed to disable invite'
253
+
toast.error(e instanceof ApiError ? e.message : $_('admin.failedToDisableInvite'))
250
254
}
251
255
}
252
256
async function selectUser(did: string) {
253
-
if (!auth.session) return
257
+
if (!session) return
254
258
userDetailLoading = true
255
259
try {
256
-
selectedUser = await api.getAccountInfo(auth.session.accessJwt, did)
260
+
selectedUser = await api.getAccountInfo(session.accessJwt, did)
257
261
} catch (e) {
258
-
usersError = e instanceof ApiError ? e.message : 'Failed to load user details'
262
+
toast.error(e instanceof ApiError ? e.message : $_('admin.failedToLoadUserDetails'))
259
263
} finally {
260
264
userDetailLoading = false
261
265
}
···
264
268
selectedUser = null
265
269
}
266
270
async function toggleUserInvites() {
267
-
if (!auth.session || !selectedUser) return
271
+
if (!session || !selectedUser) return
268
272
userActionLoading = true
269
273
try {
270
274
if (selectedUser.invitesDisabled) {
271
-
await api.enableAccountInvites(auth.session.accessJwt, selectedUser.did)
275
+
await api.enableAccountInvites(session.accessJwt, selectedUser.did)
272
276
selectedUser = { ...selectedUser, invitesDisabled: false }
277
+
toast.success($_('admin.invitesEnabled'))
273
278
} else {
274
-
await api.disableAccountInvites(auth.session.accessJwt, selectedUser.did)
279
+
await api.disableAccountInvites(session.accessJwt, selectedUser.did)
275
280
selectedUser = { ...selectedUser, invitesDisabled: true }
281
+
toast.success($_('admin.invitesDisabled'))
276
282
}
277
283
} catch (e) {
278
-
usersError = e instanceof ApiError ? e.message : 'Failed to update user'
284
+
toast.error(e instanceof ApiError ? e.message : $_('admin.failedToUpdateUser'))
279
285
} finally {
280
286
userActionLoading = false
281
287
}
282
288
}
283
289
async function deleteUser() {
284
-
if (!auth.session || !selectedUser) return
290
+
if (!session || !selectedUser) return
285
291
if (!confirm($_('admin.deleteConfirm', { values: { handle: selectedUser.handle } }))) return
286
292
userActionLoading = true
287
293
try {
288
-
await api.adminDeleteAccount(auth.session.accessJwt, selectedUser.did)
294
+
await api.adminDeleteAccount(session.accessJwt, selectedUser.did)
289
295
users = users.filter(u => u.did !== selectedUser!.did)
290
296
selectedUser = null
297
+
toast.success($_('admin.userDeleted'))
291
298
} catch (e) {
292
-
usersError = e instanceof ApiError ? e.message : 'Failed to delete user'
299
+
toast.error(e instanceof ApiError ? e.message : $_('admin.failedToDeleteUser'))
293
300
} finally {
294
301
userActionLoading = false
295
302
}
···
305
312
return num.toLocaleString()
306
313
}
307
314
</script>
308
-
{#if auth.session?.isAdmin}
315
+
{#if session?.isAdmin}
309
316
<div class="page">
310
317
<header>
311
318
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
···
314
321
{#if loading}
315
322
<p class="loading">{$_('admin.loading')}</p>
316
323
{:else}
317
-
{#if error}
318
-
<div class="message error">{error}</div>
319
-
{/if}
320
324
<section>
321
325
<h2>{$_('admin.serverConfig')}</h2>
322
326
<form class="config-form" onsubmit={saveServerConfig}>
···
428
432
</div>
429
433
</div>
430
434
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
435
<button type="submit" disabled={serverConfigLoading || !hasConfigChanges()}>
438
436
{serverConfigLoading ? $_('common.saving') : $_('admin.saveConfig')}
439
437
</button>
···
476
474
{usersLoading ? $_('admin.loading') : $_('admin.searchUsers')}
477
475
</button>
478
476
</form>
479
-
{#if usersError}
480
-
<div class="message error">{usersError}</div>
481
-
{/if}
482
477
{#if showUsers}
483
478
<div class="user-list">
484
479
{#if users.length === 0}
···
528
523
{invitesLoading ? $_('admin.loading') : showInvites ? $_('admin.refresh') : $_('admin.loadInviteCodes')}
529
524
</button>
530
525
</div>
531
-
{#if invitesError}
532
-
<div class="message error">{invitesError}</div>
533
-
{/if}
534
526
{#if showInvites}
535
527
<div class="invite-list">
536
528
{#if invites.length === 0}
+46
-23
frontend/src/routes/AppPasswords.svelte
+46
-23
frontend/src/routes/AppPasswords.svelte
···
1
1
<script lang="ts">
2
2
import { getAuthState } from '../lib/auth.svelte'
3
-
import { navigate } from '../lib/router.svelte'
3
+
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
4
4
import { api, type AppPassword, ApiError } from '../lib/api'
5
5
import { _ } from '../lib/i18n'
6
6
import { formatDate } from '../lib/date'
7
-
const auth = getAuthState()
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())
8
22
let passwords = $state<AppPassword[]>([])
9
23
let loading = $state(true)
10
-
let error = $state<string | null>(null)
11
24
let newPasswordName = $state('')
12
25
let selectedScope = $state<string | null>(null)
13
26
let creating = $state(false)
···
29
42
return $_('appPasswords.scopeCustom')
30
43
}
31
44
$effect(() => {
32
-
if (!auth.loading && !auth.session) {
33
-
navigate('/login')
45
+
if (!authLoading && !session) {
46
+
navigate(routes.login)
34
47
}
35
48
})
36
49
$effect(() => {
37
-
if (auth.session) {
50
+
if (session) {
38
51
loadPasswords()
39
52
}
40
53
})
41
54
async function loadPasswords() {
42
-
if (!auth.session) return
55
+
if (!session) return
43
56
loading = true
44
-
error = null
45
57
try {
46
-
const result = await api.listAppPasswords(auth.session.accessJwt)
58
+
const result = await api.listAppPasswords(session.accessJwt)
47
59
passwords = result.passwords
48
60
} catch (e) {
49
-
error = e instanceof ApiError ? e.message : 'Failed to load app passwords'
61
+
toast.error(e instanceof ApiError ? e.message : $_('appPasswords.failedToLoad'))
50
62
} finally {
51
63
loading = false
52
64
}
53
65
}
54
66
async function handleCreate(e: Event) {
55
67
e.preventDefault()
56
-
if (!auth.session || !newPasswordName.trim()) return
68
+
if (!session || !newPasswordName.trim()) return
57
69
creating = true
58
-
error = null
59
70
try {
60
71
const scopeValue = selectedScope === null ? undefined : selectedScope
61
-
const result = await api.createAppPassword(auth.session.accessJwt, newPasswordName.trim(), scopeValue ?? undefined)
72
+
const result = await api.createAppPassword(session.accessJwt, newPasswordName.trim(), scopeValue ?? undefined)
62
73
createdPassword = { name: result.name, password: result.password }
63
74
newPasswordName = ''
64
75
selectedScope = null
65
76
await loadPasswords()
66
77
} catch (e) {
67
-
error = e instanceof ApiError ? e.message : 'Failed to create app password'
78
+
toast.error(e instanceof ApiError ? e.message : $_('appPasswords.failedToCreate'))
68
79
} finally {
69
80
creating = false
70
81
}
71
82
}
72
83
async function handleRevoke(name: string) {
73
-
if (!auth.session) return
84
+
if (!session) return
74
85
if (!confirm($_('appPasswords.revokeConfirm', { values: { name } }))) {
75
86
return
76
87
}
77
88
revoking = name
78
-
error = null
79
89
try {
80
-
await api.revokeAppPassword(auth.session.accessJwt, name)
90
+
await api.revokeAppPassword(session.accessJwt, name)
81
91
await loadPasswords()
92
+
toast.success($_('appPasswords.passwordRevoked'))
82
93
} catch (e) {
83
-
error = e instanceof ApiError ? e.message : 'Failed to revoke app password'
94
+
toast.error(e instanceof ApiError ? e.message : $_('appPasswords.failedToRevoke'))
84
95
} finally {
85
96
revoking = null
86
97
}
···
99
110
</script>
100
111
<div class="page">
101
112
<header>
102
-
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
113
+
<a href={getFullUrl(routes.dashboard)} class="back">{$_('common.backToDashboard')}</a>
103
114
<h1>{$_('appPasswords.title')}</h1>
104
115
</header>
105
116
<p class="description">
106
117
{$_('appPasswords.description')}
107
118
</p>
108
-
{#if error}
109
-
<div class="error">{error}</div>
110
-
{/if}
111
119
{#if createdPassword}
112
120
<div class="created-password">
113
121
<div class="warning-box">
···
162
170
<section class="list-section">
163
171
<h2>{$_('appPasswords.yourPasswords')}</h2>
164
172
{#if loading}
165
-
<p class="empty">{$_('common.loading')}</p>
173
+
<ul class="password-list">
174
+
{#each Array(2) as _}
175
+
<li class="skeleton-item"></li>
176
+
{/each}
177
+
</ul>
166
178
{:else if passwords.length === 0}
167
179
<p class="empty">{$_('appPasswords.noPasswords')}</p>
168
180
{:else}
···
458
470
color: var(--text-secondary);
459
471
text-align: center;
460
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; }
461
484
}
462
485
</style>
+56
-47
frontend/src/routes/Comms.svelte
+56
-47
frontend/src/routes/Comms.svelte
···
1
1
<script lang="ts">
2
2
import { getAuthState, refreshSession } from '../lib/auth.svelte'
3
-
import { navigate } from '../lib/router.svelte'
3
+
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
4
4
import { api, ApiError } from '../lib/api'
5
5
import { _ } from '../lib/i18n'
6
6
import { formatDateTime } from '../lib/date'
7
-
const auth = getAuthState()
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())
8
22
let loading = $state(true)
9
23
let saving = $state(false)
10
-
let error = $state<string | null>(null)
11
-
let success = $state<string | null>(null)
12
24
let preferredChannel = $state('email')
13
25
let availableCommsChannels = $state<string[]>(['email'])
14
26
let email = $state('')
···
20
32
let signalVerified = $state(false)
21
33
let verifyingChannel = $state<string | null>(null)
22
34
let verificationCode = $state('')
23
-
let verificationError = $state<string | null>(null)
24
-
let verificationSuccess = $state<string | null>(null)
25
35
let historyLoading = $state(true)
26
-
let historyError = $state<string | null>(null)
27
36
let messages = $state<Array<{
28
37
createdAt: string
29
38
channel: string
···
33
42
body: string
34
43
}>>([])
35
44
$effect(() => {
36
-
if (!auth.loading && !auth.session) {
37
-
navigate('/login')
45
+
if (!authLoading && !session) {
46
+
navigate(routes.login)
38
47
}
39
48
})
40
49
$effect(() => {
41
-
if (auth.session) {
50
+
if (session) {
42
51
loadPrefs()
43
52
loadHistory()
44
53
}
45
54
})
46
55
async function loadPrefs() {
47
-
if (!auth.session) return
56
+
if (!session) return
48
57
loading = true
49
-
error = null
50
58
try {
51
59
const [prefs, serverInfo] = await Promise.all([
52
-
api.getNotificationPrefs(auth.session.accessJwt),
60
+
api.getNotificationPrefs(session.accessJwt),
53
61
api.describeServer()
54
62
])
55
63
preferredChannel = prefs.preferredChannel
···
62
70
signalVerified = prefs.signalVerified
63
71
availableCommsChannels = serverInfo.availableCommsChannels ?? ['email']
64
72
} catch (e) {
65
-
error = e instanceof ApiError ? e.message : 'Failed to load notification preferences'
73
+
toast.error(e instanceof ApiError ? e.message : $_('comms.failedToLoad'))
66
74
} finally {
67
75
loading = false
68
76
}
69
77
}
70
78
async function handleSave(e: Event) {
71
79
e.preventDefault()
72
-
if (!auth.session) return
80
+
if (!session) return
73
81
saving = true
74
-
error = null
75
-
success = null
76
82
try {
77
-
await api.updateNotificationPrefs(auth.session.accessJwt, {
83
+
await api.updateNotificationPrefs(session.accessJwt, {
78
84
preferredChannel,
79
85
discordId: discordId || undefined,
80
86
telegramUsername: telegramUsername || undefined,
81
87
signalNumber: signalNumber || undefined,
82
88
})
83
89
await refreshSession()
84
-
success = $_('comms.preferencesSaved')
90
+
toast.success($_('comms.preferencesSaved'))
85
91
await loadPrefs()
86
92
} catch (e) {
87
-
error = e instanceof ApiError ? e.message : 'Failed to save preferences'
93
+
toast.error(e instanceof ApiError ? e.message : $_('comms.failedToSave'))
88
94
} finally {
89
95
saving = false
90
96
}
91
97
}
92
98
async function handleVerify(channel: string) {
93
-
if (!auth.session || !verificationCode) return
94
-
verificationError = null
95
-
verificationSuccess = null
99
+
if (!session || !verificationCode) return
96
100
97
101
let identifier = ''
98
102
switch (channel) {
···
103
107
if (!identifier) return
104
108
105
109
try {
106
-
await api.confirmChannelVerification(auth.session.accessJwt, channel, identifier, verificationCode)
110
+
await api.confirmChannelVerification(session.accessJwt, channel, identifier, verificationCode)
107
111
await refreshSession()
108
-
verificationSuccess = $_('comms.verifiedSuccess', { values: { channel } })
112
+
toast.success($_('comms.verifiedSuccess', { values: { channel } }))
109
113
verificationCode = ''
110
114
verifyingChannel = null
111
115
await loadPrefs()
112
116
} catch (e) {
113
-
verificationError = e instanceof ApiError ? e.message : 'Failed to verify channel'
117
+
toast.error(e instanceof ApiError ? e.message : $_('comms.failedToVerify'))
114
118
}
115
119
}
116
120
async function loadHistory() {
117
-
if (!auth.session) return
121
+
if (!session) return
118
122
historyLoading = true
119
-
historyError = null
120
123
try {
121
-
const result = await api.getNotificationHistory(auth.session.accessJwt)
124
+
const result = await api.getNotificationHistory(session.accessJwt)
122
125
messages = result.notifications
123
126
} catch (e) {
124
-
historyError = e instanceof ApiError ? e.message : 'Failed to load notification history'
127
+
toast.error(e instanceof ApiError ? e.message : $_('comms.failedToLoadHistory'))
125
128
} finally {
126
129
historyLoading = false
127
130
}
···
168
171
</script>
169
172
<div class="page">
170
173
<header>
171
-
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
174
+
<a href={getFullUrl(routes.dashboard)} class="back">{$_('common.backToDashboard')}</a>
172
175
<h1>{$_('comms.title')}</h1>
173
176
<p class="description">{$_('comms.description')}</p>
174
177
</header>
175
178
176
179
{#if loading}
177
-
<p class="loading">{$_('common.loading')}</p>
180
+
<div class="skeleton-sections">
181
+
<div class="skeleton-section"></div>
182
+
<div class="skeleton-section"></div>
183
+
</div>
178
184
{: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
185
<div class="split-layout">
187
186
<div class="main-column">
188
187
<form onsubmit={handleSave}>
···
331
330
</div>
332
331
</div>
333
332
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
333
</section>
341
334
342
335
<div class="actions">
···
364
357
</div>
365
358
{/each}
366
359
</div>
367
-
{:else if historyError}
368
-
<div class="message error">{historyError}</div>
369
360
{:else if messages.length === 0}
370
361
<p class="no-messages">{$_('comms.noMessages')}</p>
371
362
{:else}
···
789
780
font-size: var(--text-xs);
790
781
color: var(--text-muted);
791
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; }
792
801
}
793
802
</style>
+62
-43
frontend/src/routes/Controllers.svelte
+62
-43
frontend/src/routes/Controllers.svelte
···
1
1
<script lang="ts">
2
2
import { getAuthState } from '../lib/auth.svelte'
3
-
import { navigate } from '../lib/router.svelte'
3
+
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
4
4
import { _ } from '../lib/i18n'
5
5
import { formatDateTime } from '../lib/date'
6
+
import type { Session } from '../lib/types/api'
7
+
import { toast } from '../lib/toast.svelte'
6
8
7
9
interface Controller {
8
10
did: string
···
26
28
scopes: string
27
29
}
28
30
29
-
const auth = getAuthState()
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
+
30
44
let loading = $state(true)
31
-
let error = $state<string | null>(null)
32
-
let success = $state<string | null>(null)
33
45
let controllers = $state<Controller[]>([])
34
46
let controlledAccounts = $state<ControlledAccount[]>([])
35
47
let scopePresets = $state<ScopePreset[]>([])
···
51
63
let creatingDelegated = $state(false)
52
64
53
65
$effect(() => {
54
-
if (!auth.loading && !auth.session) {
55
-
navigate('/login')
66
+
if (!authLoading && !session) {
67
+
navigate(routes.login)
56
68
}
57
69
})
58
70
59
71
$effect(() => {
60
-
if (auth.session) {
72
+
if (session) {
61
73
loadData()
62
74
}
63
75
})
64
76
65
77
async function loadData() {
66
78
loading = true
67
-
error = null
68
79
try {
69
80
await Promise.all([loadControllers(), loadControlledAccounts(), loadScopePresets()])
70
81
} finally {
···
73
84
}
74
85
75
86
async function loadControllers() {
76
-
if (!auth.session) return
87
+
if (!session) return
77
88
try {
78
89
const response = await fetch('/xrpc/_delegation.listControllers', {
79
-
headers: { 'Authorization': `Bearer ${auth.session.accessJwt}` }
90
+
headers: { 'Authorization': `Bearer ${session.accessJwt}` }
80
91
})
81
92
if (response.ok) {
82
93
const data = await response.json()
···
88
99
}
89
100
90
101
async function loadControlledAccounts() {
91
-
if (!auth.session) return
102
+
if (!session) return
92
103
try {
93
104
const response = await fetch('/xrpc/_delegation.listControlledAccounts', {
94
-
headers: { 'Authorization': `Bearer ${auth.session.accessJwt}` }
105
+
headers: { 'Authorization': `Bearer ${session.accessJwt}` }
95
106
})
96
107
if (response.ok) {
97
108
const data = await response.json()
···
115
126
}
116
127
117
128
async function addController() {
118
-
if (!auth.session || !addControllerDid.trim()) return
129
+
if (!session || !addControllerDid.trim()) return
119
130
addingController = true
120
-
error = null
121
-
success = null
122
131
123
132
try {
124
133
const response = await fetch('/xrpc/_delegation.addController', {
125
134
method: 'POST',
126
135
headers: {
127
-
'Authorization': `Bearer ${auth.session.accessJwt}`,
136
+
'Authorization': `Bearer ${session.accessJwt}`,
128
137
'Content-Type': 'application/json'
129
138
},
130
139
body: JSON.stringify({
···
135
144
136
145
if (!response.ok) {
137
146
const data = await response.json()
138
-
error = data.message || data.error || $_('delegation.failedToAddController')
147
+
toast.error(data.message || data.error || $_('delegation.failedToAddController'))
139
148
return
140
149
}
141
150
142
-
success = $_('delegation.controllerAdded')
151
+
toast.success($_('delegation.controllerAdded'))
143
152
addControllerDid = ''
144
153
addControllerScopes = 'atproto'
145
154
showAddController = false
146
155
await loadControllers()
147
156
} catch (e) {
148
-
error = $_('delegation.failedToAddController')
157
+
toast.error($_('delegation.failedToAddController'))
149
158
} finally {
150
159
addingController = false
151
160
}
152
161
}
153
162
154
163
async function removeController(controllerDid: string) {
155
-
if (!auth.session) return
164
+
if (!session) return
156
165
if (!confirm($_('delegation.removeConfirm'))) return
157
166
158
-
error = null
159
-
success = null
160
-
161
167
try {
162
168
const response = await fetch('/xrpc/_delegation.removeController', {
163
169
method: 'POST',
164
170
headers: {
165
-
'Authorization': `Bearer ${auth.session.accessJwt}`,
171
+
'Authorization': `Bearer ${session.accessJwt}`,
166
172
'Content-Type': 'application/json'
167
173
},
168
174
body: JSON.stringify({ controller_did: controllerDid })
···
170
176
171
177
if (!response.ok) {
172
178
const data = await response.json()
173
-
error = data.message || data.error || $_('delegation.failedToRemoveController')
179
+
toast.error(data.message || data.error || $_('delegation.failedToRemoveController'))
174
180
return
175
181
}
176
182
177
-
success = $_('delegation.controllerRemoved')
183
+
toast.success($_('delegation.controllerRemoved'))
178
184
await loadControllers()
179
185
} catch (e) {
180
-
error = $_('delegation.failedToRemoveController')
186
+
toast.error($_('delegation.failedToRemoveController'))
181
187
}
182
188
}
183
189
184
190
async function createDelegatedAccount() {
185
-
if (!auth.session || !newDelegatedHandle.trim()) return
191
+
if (!session || !newDelegatedHandle.trim()) return
186
192
creatingDelegated = true
187
-
error = null
188
-
success = null
189
193
190
194
try {
191
195
const response = await fetch('/xrpc/_delegation.createDelegatedAccount', {
192
196
method: 'POST',
193
197
headers: {
194
-
'Authorization': `Bearer ${auth.session.accessJwt}`,
198
+
'Authorization': `Bearer ${session.accessJwt}`,
195
199
'Content-Type': 'application/json'
196
200
},
197
201
body: JSON.stringify({
···
203
207
204
208
if (!response.ok) {
205
209
const data = await response.json()
206
-
error = data.message || data.error || $_('delegation.failedToCreateAccount')
210
+
toast.error(data.message || data.error || $_('delegation.failedToCreateAccount'))
207
211
return
208
212
}
209
213
210
214
const data = await response.json()
211
-
success = $_('delegation.accountCreated', { values: { handle: data.handle } })
215
+
toast.success($_('delegation.accountCreated', { values: { handle: data.handle } }))
212
216
newDelegatedHandle = ''
213
217
newDelegatedEmail = ''
214
218
newDelegatedScopes = 'atproto'
215
219
showCreateDelegated = false
216
220
await loadControlledAccounts()
217
221
} catch (e) {
218
-
error = $_('delegation.failedToCreateAccount')
222
+
toast.error($_('delegation.failedToCreateAccount'))
219
223
} finally {
220
224
creatingDelegated = false
221
225
}
···
237
241
</header>
238
242
239
243
{#if loading}
240
-
<p class="loading">{$_('delegation.loading')}</p>
244
+
<div class="skeleton-list">
245
+
{#each Array(2) as _}
246
+
<div class="skeleton-card"></div>
247
+
{/each}
248
+
</div>
241
249
{: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
250
<section class="section">
251
251
<div class="section-header">
252
252
<h2>{$_('delegation.controllers')}</h2>
···
676
676
.form-actions button {
677
677
padding: var(--space-2) var(--space-4);
678
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; }
679
698
}
680
699
</style>
+106
-66
frontend/src/routes/Dashboard.svelte
+106
-66
frontend/src/routes/Dashboard.svelte
···
1
1
<script lang="ts">
2
-
import { getAuthState, logout, switchAccount } from '../lib/auth.svelte'
3
-
import { navigate } from '../lib/router.svelte'
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'
4
9
import { _ } from '../lib/i18n'
5
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'
6
14
import { onMount } from 'svelte'
7
15
8
-
const auth = getAuthState()
16
+
const auth = $derived(getAuthState())
9
17
let dropdownOpen = $state(false)
10
18
let switching = $state(false)
11
19
let inviteCodesEnabled = $state(false)
12
20
13
-
const isDidWeb = $derived(auth.session?.did?.startsWith('did:web:') ?? false)
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))
14
38
15
39
onMount(async () => {
16
40
try {
···
22
46
})
23
47
24
48
$effect(() => {
25
-
if (!auth.loading && !auth.session) {
26
-
navigate('/login')
49
+
if (!loading && !session) {
50
+
navigate(routes.login)
27
51
}
28
52
})
29
53
30
54
async function handleLogout() {
31
55
await logout()
32
-
navigate('/login')
56
+
navigate(routes.login)
33
57
}
34
58
35
-
async function handleSwitchAccount(did: string) {
59
+
async function handleSwitchAccount(did: Did) {
36
60
switching = true
37
61
dropdownOpen = false
38
-
try {
39
-
await switchAccount(did)
40
-
} catch {
41
-
navigate('/login')
42
-
} finally {
43
-
switching = false
62
+
const result = await switchAccount(did)
63
+
if (!isOk(result)) {
64
+
navigate(routes.login)
44
65
}
66
+
switching = false
45
67
}
46
68
47
69
function toggleDropdown() {
···
61
83
return () => document.removeEventListener('click', closeDropdown)
62
84
}
63
85
})
64
-
65
-
let otherAccounts = $derived(
66
-
auth.savedAccounts.filter(a => a.did !== auth.session?.did)
67
-
)
68
86
</script>
69
87
70
-
{#if auth.session}
88
+
{#if session}
71
89
<div class="dashboard">
72
90
<header>
73
91
<h1>{$_('dashboard.title')}</h1>
74
92
<div class="account-dropdown">
75
93
<button class="account-trigger" onclick={toggleDropdown} disabled={switching}>
76
-
<span class="account-handle">@{auth.session.handle}</span>
94
+
<span class="account-handle">@{session.handle}</span>
77
95
<span class="dropdown-arrow">{dropdownOpen ? '▲' : '▼'}</span>
78
96
</button>
79
97
{#if dropdownOpen}
···
89
107
</div>
90
108
<div class="dropdown-divider"></div>
91
109
{/if}
92
-
<button type="button" class="dropdown-item" onclick={() => { dropdownOpen = false; navigate('/login') }}>
110
+
<button type="button" class="dropdown-item" onclick={() => { dropdownOpen = false; navigate(routes.login) }}>
93
111
{$_('dashboard.addAnotherAccount')}
94
112
</button>
95
113
<div class="dropdown-divider"></div>
96
114
<button type="button" class="dropdown-item logout-item" onclick={handleLogout}>
97
-
{$_('dashboard.signOut', { values: { handle: auth.session.handle } })}
115
+
{$_('dashboard.signOut', { values: { handle: session.handle } })}
98
116
</button>
99
117
</div>
100
118
{/if}
101
119
</div>
102
120
</header>
103
121
104
-
{#if auth.session.status === 'migrated'}
122
+
{#if session.status === 'migrated'}
105
123
<div class="migrated-banner">
106
124
<strong>{$_('dashboard.migratedTitle')}</strong>
107
-
<p>{$_('dashboard.migratedMessage', { values: { pds: auth.session.migratedToPds || 'another PDS' } })}</p>
125
+
<p>{$_('dashboard.migratedMessage', { values: { pds: session.migratedToPds || 'another PDS' } })}</p>
108
126
</div>
109
-
{:else if auth.session.status === 'deactivated' || auth.session.active === false}
127
+
{:else if session.status === 'deactivated' || session.active === false}
110
128
<div class="deactivated-banner">
111
129
<strong>{$_('dashboard.deactivatedTitle')}</strong>
112
130
<p>{$_('dashboard.deactivatedMessage')}</p>
···
118
136
<dl>
119
137
<dt>{$_('dashboard.handle')}</dt>
120
138
<dd>
121
-
@{auth.session.handle}
122
-
{#if auth.session.isAdmin}
139
+
@{session.handle}
140
+
{#if session.isAdmin}
123
141
<span class="badge admin">{$_('dashboard.admin')}</span>
124
142
{/if}
125
-
{#if auth.session.status === 'migrated'}
143
+
{#if session.status === 'migrated'}
126
144
<span class="badge migrated">{$_('dashboard.migrated')}</span>
127
-
{:else if auth.session.status === 'deactivated' || auth.session.active === false}
145
+
{:else if session.status === 'deactivated' || session.active === false}
128
146
<span class="badge deactivated">{$_('dashboard.deactivated')}</span>
129
147
{/if}
130
148
</dd>
131
149
<dt>{$_('dashboard.did')}</dt>
132
-
<dd class="mono">{auth.session.did}</dd>
133
-
{#if auth.session.preferredChannel}
150
+
<dd class="mono">{session.did}</dd>
151
+
{#if session.preferredChannel}
134
152
<dt>{$_('dashboard.primaryContact')}</dt>
135
153
<dd>
136
-
{#if auth.session.preferredChannel === 'email'}
137
-
{auth.session.email || $_('register.email')}
138
-
{:else if auth.session.preferredChannel === 'discord'}
154
+
{#if session.preferredChannel === 'email'}
155
+
{session.email || $_('register.email')}
156
+
{:else if session.preferredChannel === 'discord'}
139
157
{$_('register.discord')}
140
-
{:else if auth.session.preferredChannel === 'telegram'}
158
+
{:else if session.preferredChannel === 'telegram'}
141
159
{$_('register.telegram')}
142
-
{:else if auth.session.preferredChannel === 'signal'}
160
+
{:else if session.preferredChannel === 'signal'}
143
161
{$_('register.signal')}
144
162
{:else}
145
-
{auth.session.preferredChannel}
163
+
{session.preferredChannel}
146
164
{/if}
147
-
{#if auth.session.preferredChannelVerified}
165
+
{#if session.preferredChannelVerified}
148
166
<span class="badge success">{$_('dashboard.verified')}</span>
149
167
{:else}
150
168
<span class="badge warning">{$_('dashboard.unverified')}</span>
151
169
{/if}
152
170
</dd>
153
-
{:else if auth.session.email}
171
+
{:else if session.email}
154
172
<dt>{$_('register.email')}</dt>
155
173
<dd>
156
-
{auth.session.email}
157
-
{#if auth.session.emailConfirmed}
174
+
{session.email}
175
+
{#if session.emailConfirmed}
158
176
<span class="badge success">{$_('dashboard.verified')}</span>
159
177
{:else}
160
178
<span class="badge warning">{$_('dashboard.unverified')}</span>
···
165
183
</section>
166
184
167
185
<nav class="nav-grid">
168
-
{#if auth.session.status === 'migrated'}
169
-
<a href="/app/did-document" class="nav-card migrated-card">
186
+
{#if session.status === 'migrated'}
187
+
<a href={getFullUrl(routes.didDocument)} class="nav-card migrated-card">
170
188
<h3>{$_('dashboard.navDidDocument')}</h3>
171
189
<p>{$_('dashboard.navDidDocumentDesc')}</p>
172
190
</a>
173
-
<a href="/app/sessions" class="nav-card">
191
+
<a href={getFullUrl(routes.sessions)} class="nav-card">
174
192
<h3>{$_('dashboard.navSessions')}</h3>
175
193
<p>{$_('dashboard.navSessionsDesc')}</p>
176
194
</a>
177
-
<a href="/app/security" class="nav-card">
195
+
<a href={getFullUrl(routes.security)} class="nav-card">
178
196
<h3>{$_('dashboard.navSecurity')}</h3>
179
197
<p>{$_('dashboard.navSecurityDesc')}</p>
180
198
</a>
181
-
<a href="/app/settings" class="nav-card">
199
+
<a href={getFullUrl(routes.settings)} class="nav-card">
182
200
<h3>{$_('dashboard.navSettings')}</h3>
183
201
<p>{$_('dashboard.navSettingsDesc')}</p>
184
202
</a>
185
-
<a href="/app/migrate" class="nav-card">
203
+
<a href={getFullUrl(routes.migrate)} class="nav-card">
186
204
<h3>{$_('dashboard.navMigrateAgain')}</h3>
187
205
<p>{$_('dashboard.navMigrateAgainDesc')}</p>
188
206
</a>
189
207
{:else}
190
-
<a href="/app/app-passwords" class="nav-card">
208
+
<a href={getFullUrl(routes.appPasswords)} class="nav-card">
191
209
<h3>{$_('dashboard.navAppPasswords')}</h3>
192
210
<p>{$_('dashboard.navAppPasswordsDesc')}</p>
193
211
</a>
194
-
<a href="/app/sessions" class="nav-card">
212
+
<a href={getFullUrl(routes.sessions)} class="nav-card">
195
213
<h3>{$_('dashboard.navSessions')}</h3>
196
214
<p>{$_('dashboard.navSessionsDesc')}</p>
197
215
</a>
198
-
{#if inviteCodesEnabled && auth.session.isAdmin}
199
-
<a href="/app/invite-codes" class="nav-card">
216
+
{#if inviteCodesEnabled && session.isAdmin}
217
+
<a href={getFullUrl(routes.inviteCodes)} class="nav-card">
200
218
<h3>{$_('dashboard.navInviteCodes')}</h3>
201
219
<p>{$_('dashboard.navInviteCodesDesc')}</p>
202
220
</a>
203
221
{/if}
204
-
<a href="/app/settings" class="nav-card">
222
+
<a href={getFullUrl(routes.settings)} class="nav-card">
205
223
<h3>{$_('dashboard.navSettings')}</h3>
206
224
<p>{$_('dashboard.navSettingsDesc')}</p>
207
225
</a>
208
-
<a href="/app/security" class="nav-card">
226
+
<a href={getFullUrl(routes.security)} class="nav-card">
209
227
<h3>{$_('dashboard.navSecurity')}</h3>
210
228
<p>{$_('dashboard.navSecurityDesc')}</p>
211
229
</a>
212
-
<a href="/app/comms" class="nav-card">
230
+
<a href={getFullUrl(routes.comms)} class="nav-card">
213
231
<h3>{$_('dashboard.navComms')}</h3>
214
232
<p>{$_('dashboard.navCommsDesc')}</p>
215
233
</a>
216
-
<a href="/app/repo" class="nav-card">
234
+
<a href={getFullUrl(routes.repo)} class="nav-card">
217
235
<h3>{$_('dashboard.navRepo')}</h3>
218
236
<p>{$_('dashboard.navRepoDesc')}</p>
219
237
</a>
220
-
<a href="/app/controllers" class="nav-card">
238
+
<a href={getFullUrl(routes.controllers)} class="nav-card">
221
239
<h3>{$_('dashboard.navDelegation')}</h3>
222
240
<p>{$_('dashboard.navDelegationDesc')}</p>
223
241
</a>
224
242
{#if isDidWeb}
225
-
<a href="/app/did-document" class="nav-card did-web-card">
243
+
<a href={getFullUrl(routes.didDocument)} class="nav-card did-web-card">
226
244
<h3>{$_('dashboard.navDidDocument')}</h3>
227
245
<p>{$_('dashboard.navDidDocumentDescActive')}</p>
228
246
</a>
229
247
{/if}
230
-
<a href="/app/migrate" class="nav-card">
248
+
<a href={getFullUrl(routes.migrate)} class="nav-card">
231
249
<h3>{$_('migration.navTitle')}</h3>
232
250
<p>{$_('migration.navDesc')}</p>
233
251
</a>
234
-
{#if auth.session.isAdmin}
235
-
<a href="/app/admin" class="nav-card admin-card">
252
+
{#if session.isAdmin}
253
+
<a href={getFullUrl(routes.admin)} class="nav-card admin-card">
236
254
<h3>{$_('dashboard.navAdmin')}</h3>
237
255
<p>{$_('dashboard.navAdminDesc')}</p>
238
256
</a>
···
240
258
{/if}
241
259
</nav>
242
260
</div>
243
-
{:else if auth.loading}
244
-
<div class="loading">{$_('common.loading')}</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>
245
270
{/if}
246
271
247
272
<style>
···
460
485
box-shadow: 0 2px 12px var(--accent-muted);
461
486
}
462
487
463
-
.loading {
464
-
text-align: center;
465
-
padding: var(--space-9);
466
-
color: var(--text-secondary);
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; }
467
507
}
468
508
469
509
.deactivated-banner {
+50
-22
frontend/src/routes/DelegationAudit.svelte
+50
-22
frontend/src/routes/DelegationAudit.svelte
···
1
1
<script lang="ts">
2
2
import { getAuthState } from '../lib/auth.svelte'
3
-
import { navigate } from '../lib/router.svelte'
3
+
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
4
4
import { _ } from '../lib/i18n'
5
5
import { formatDateTime } from '../lib/date'
6
+
import type { Session } from '../lib/types/api'
7
+
import { toast } from '../lib/toast.svelte'
6
8
7
9
interface AuditEntry {
8
10
id: string
···
14
16
createdAt: string
15
17
}
16
18
17
-
const auth = getAuthState()
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
+
18
32
let loading = $state(true)
19
-
let error = $state<string | null>(null)
20
33
let entries = $state<AuditEntry[]>([])
21
34
let total = $state(0)
22
35
let offset = $state(0)
23
36
const limit = 20
24
37
25
38
$effect(() => {
26
-
if (!auth.loading && !auth.session) {
27
-
navigate('/login')
39
+
if (!authLoading && !session) {
40
+
navigate(routes.login)
28
41
}
29
42
})
30
43
31
44
$effect(() => {
32
-
if (auth.session) {
45
+
if (session) {
33
46
loadAuditLog()
34
47
}
35
48
})
36
49
37
50
async function loadAuditLog() {
38
-
if (!auth.session) return
51
+
if (!session) return
39
52
loading = true
40
-
error = null
41
53
42
54
try {
43
55
const response = await fetch(
44
56
`/xrpc/_delegation.getAuditLog?limit=${limit}&offset=${offset}`,
45
57
{
46
-
headers: { 'Authorization': `Bearer ${auth.session.accessJwt}` }
58
+
headers: { 'Authorization': `Bearer ${session.accessJwt}` }
47
59
}
48
60
)
49
61
50
62
if (!response.ok) {
51
63
const data = await response.json()
52
-
error = data.message || data.error || $_('delegation.failedToLoadAuditLog')
64
+
toast.error(data.message || data.error || $_('delegation.failedToLoadAuditLog'))
53
65
return
54
66
}
55
67
···
57
69
entries = data.entries || []
58
70
total = data.total || 0
59
71
} catch (e) {
60
-
error = $_('delegation.failedToLoadAuditLog')
72
+
toast.error($_('delegation.failedToLoadAuditLog'))
61
73
} finally {
62
74
loading = false
63
75
}
···
92
104
93
105
function formatActionDetails(details: Record<string, unknown> | null): string {
94
106
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(', ')
107
+
return Object.entries(details)
108
+
.map(([key, value]) => `${key.replace(/_/g, ' ')}: ${JSON.stringify(value)}`)
109
+
.join(', ')
101
110
}
102
111
103
112
function truncateDid(did: string): string {
···
113
122
</header>
114
123
115
124
{#if loading}
116
-
<p class="loading">{$_('delegation.loading')}</p>
125
+
<div class="skeleton-list">
126
+
{#each Array(3) as _}
127
+
<div class="skeleton-entry"></div>
128
+
{/each}
129
+
</div>
117
130
{:else}
118
-
{#if error}
119
-
<div class="message error">{error}</div>
120
-
{/if}
121
-
122
131
{#if entries.length === 0}
123
132
<p class="empty">{$_('delegation.noActivity')}</p>
124
133
{:else}
···
318
327
.actions-bar button {
319
328
padding: var(--space-2) var(--space-4);
320
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; }
321
349
}
322
350
</style>
+59
-28
frontend/src/routes/DidDocumentEditor.svelte
+59
-28
frontend/src/routes/DidDocumentEditor.svelte
···
1
1
<script lang="ts">
2
2
import { onMount } from 'svelte'
3
3
import { getAuthState } from '../lib/auth.svelte'
4
-
import { navigate } from '../lib/router.svelte'
4
+
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
5
5
import { api, ApiError, type VerificationMethod, type DidDocument } from '../lib/api'
6
6
import { _ } from '../lib/i18n'
7
+
import type { Session } from '../lib/types/api'
8
+
import { toast } from '../lib/toast.svelte'
7
9
8
-
const auth = getAuthState()
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())
9
22
10
23
let loading = $state(true)
11
24
let saving = $state(false)
12
-
let message = $state<{ type: 'success' | 'error'; text: string } | null>(null)
13
25
let didDocument = $state<DidDocument | null>(null)
14
26
let verificationMethods = $state<VerificationMethod[]>([])
15
27
let alsoKnownAs = $state<string[]>([])
···
19
31
let newHandle = $state('')
20
32
21
33
$effect(() => {
22
-
if (!auth.loading && !auth.session) {
23
-
navigate('/login')
34
+
if (!authLoading && !session) {
35
+
navigate(routes.login)
24
36
}
25
37
})
26
38
27
39
onMount(async () => {
28
-
if (!auth.session) return
40
+
if (!session) return
29
41
try {
30
-
didDocument = await api.getDidDocument(auth.session.accessJwt)
42
+
didDocument = await api.getDidDocument(session.accessJwt)
31
43
verificationMethods = didDocument.verificationMethod.map(vm => ({
32
44
id: vm.id.replace(didDocument!.id, ''),
33
45
type: vm.type,
···
37
49
const pdsService = didDocument.service.find(s => s.id === '#atproto_pds')
38
50
serviceEndpoint = pdsService?.serviceEndpoint || ''
39
51
} catch (e) {
40
-
showMessage('error', e instanceof ApiError ? e.message : $_('didEditor.loadFailed'))
52
+
toast.error(e instanceof ApiError ? e.message : $_('didEditor.loadFailed'))
41
53
} finally {
42
54
loading = false
43
55
}
44
56
})
45
57
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
58
function addVerificationMethod() {
54
59
if (!newKeyId || !newKeyPublic) return
55
60
if (!newKeyPublic.startsWith('z')) {
56
-
showMessage('error', $_('didEditor.invalidMultibase'))
61
+
toast.error($_('didEditor.invalidMultibase'))
57
62
return
58
63
}
59
64
verificationMethods = [...verificationMethods, {
···
72
77
function addHandle() {
73
78
if (!newHandle) return
74
79
if (!newHandle.startsWith('at://')) {
75
-
showMessage('error', $_('didEditor.invalidHandle'))
80
+
toast.error($_('didEditor.invalidHandle'))
76
81
return
77
82
}
78
83
alsoKnownAs = [...alsoKnownAs, newHandle]
···
84
89
}
85
90
86
91
async function handleSave() {
87
-
if (!auth.session) return
92
+
if (!session) return
88
93
saving = true
89
-
message = null
90
94
try {
91
-
await api.updateDidDocument(auth.session.accessJwt, {
95
+
await api.updateDidDocument(session.accessJwt, {
92
96
verificationMethods: verificationMethods.length > 0 ? verificationMethods : undefined,
93
97
alsoKnownAs: alsoKnownAs.length > 0 ? alsoKnownAs : undefined,
94
98
serviceEndpoint: serviceEndpoint || undefined
95
99
})
96
-
showMessage('success', $_('didEditor.success'))
97
-
didDocument = await api.getDidDocument(auth.session.accessJwt)
100
+
toast.success($_('didEditor.success'))
101
+
didDocument = await api.getDidDocument(session.accessJwt)
98
102
} catch (e) {
99
-
showMessage('error', e instanceof ApiError ? e.message : $_('didEditor.saveFailed'))
103
+
toast.error(e instanceof ApiError ? e.message : $_('didEditor.saveFailed'))
100
104
} finally {
101
105
saving = false
102
106
}
···
109
113
<h1>{$_('didEditor.title')}</h1>
110
114
</header>
111
115
112
-
{#if message}
113
-
<div class="message {message.type}">{message.text}</div>
114
-
{/if}
115
-
116
116
{#if loading}
117
-
<div class="loading">{$_('common.loading')}</div>
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>
118
123
{:else}
119
124
<div class="help-section">
120
125
<h3>{$_('didEditor.helpTitle')}</h3>
···
453
458
.add-btn {
454
459
width: 100%;
455
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; }
456
487
}
457
488
</style>
+44
-22
frontend/src/routes/InviteCodes.svelte
+44
-22
frontend/src/routes/InviteCodes.svelte
···
1
1
<script lang="ts">
2
2
import { getAuthState } from '../lib/auth.svelte'
3
-
import { navigate } from '../lib/router.svelte'
3
+
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
4
4
import { api, type InviteCode, ApiError } from '../lib/api'
5
5
import { _ } from '../lib/i18n'
6
6
import { formatDate } from '../lib/date'
7
7
import { onMount } from 'svelte'
8
+
import type { Session } from '../lib/types/api'
9
+
import { toast } from '../lib/toast.svelte'
8
10
9
-
const auth = getAuthState()
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())
10
23
let codes = $state<InviteCode[]>([])
11
24
let loading = $state(true)
12
-
let error = $state<string | null>(null)
13
25
let creating = $state(false)
14
26
let createdCode = $state<string | null>(null)
15
27
let createdCodeCopied = $state(false)
···
21
33
const serverInfo = await api.describeServer()
22
34
inviteCodesEnabled = serverInfo.inviteCodeRequired
23
35
if (!serverInfo.inviteCodeRequired) {
24
-
navigate('/dashboard')
36
+
navigate(routes.dashboard)
25
37
}
26
38
} catch {
27
-
navigate('/dashboard')
39
+
navigate(routes.dashboard)
28
40
}
29
41
})
30
42
31
43
$effect(() => {
32
-
if (!auth.loading && !auth.session) {
33
-
navigate('/login')
44
+
if (!authLoading && !session) {
45
+
navigate(routes.login)
34
46
}
35
47
})
36
48
$effect(() => {
37
-
if (auth.session && inviteCodesEnabled) {
49
+
if (session && inviteCodesEnabled) {
38
50
loadCodes()
39
51
}
40
52
})
41
53
async function loadCodes() {
42
-
if (!auth.session) return
54
+
if (!session) return
43
55
loading = true
44
-
error = null
45
56
try {
46
-
const result = await api.getAccountInviteCodes(auth.session.accessJwt)
57
+
const result = await api.getAccountInviteCodes(session.accessJwt)
47
58
codes = result.codes
48
59
} catch (e) {
49
-
error = e instanceof ApiError ? e.message : 'Failed to load invite codes'
60
+
toast.error(e instanceof ApiError ? e.message : $_('inviteCodes.failedToLoad'))
50
61
} finally {
51
62
loading = false
52
63
}
53
64
}
54
65
async function handleCreate() {
55
-
if (!auth.session) return
66
+
if (!session) return
56
67
creating = true
57
-
error = null
58
68
try {
59
-
const result = await api.createInviteCode(auth.session.accessJwt, 1)
69
+
const result = await api.createInviteCode(session.accessJwt, 1)
60
70
createdCode = result.code
61
71
await loadCodes()
62
72
} catch (e) {
63
-
error = e instanceof ApiError ? e.message : 'Failed to create invite code'
73
+
toast.error(e instanceof ApiError ? e.message : $_('inviteCodes.failedToCreate'))
64
74
} finally {
65
75
creating = false
66
76
}
···
87
97
</script>
88
98
<div class="page">
89
99
<header>
90
-
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
100
+
<a href={getFullUrl(routes.dashboard)} class="back">{$_('common.backToDashboard')}</a>
91
101
<h1>{$_('inviteCodes.title')}</h1>
92
102
</header>
93
103
<p class="description">
94
104
{$_('inviteCodes.description')}
95
105
</p>
96
-
{#if error}
97
-
<div class="error">{error}</div>
98
-
{/if}
99
106
{#if createdCode}
100
107
<div class="created-code">
101
108
<h3>{$_('inviteCodes.created')}</h3>
···
108
115
<button onclick={dismissCreated}>{$_('common.done')}</button>
109
116
</div>
110
117
{/if}
111
-
{#if auth.session?.isAdmin}
118
+
{#if session?.isAdmin}
112
119
<section class="create-section">
113
120
<button onclick={handleCreate} disabled={creating}>
114
121
{creating ? $_('common.creating') : $_('inviteCodes.createNew')}
···
118
125
<section class="list-section">
119
126
<h2>{$_('inviteCodes.yourCodes')}</h2>
120
127
{#if loading}
121
-
<p class="empty">{$_('common.loading')}</p>
128
+
<ul class="code-list">
129
+
{#each Array(2) as _}
130
+
<li class="skeleton-item"></li>
131
+
{/each}
132
+
</ul>
122
133
{:else if codes.length === 0}
123
134
<p class="empty">{$_('inviteCodes.noCodes')}</p>
124
135
{:else}
···
324
335
color: var(--text-secondary);
325
336
text-align: center;
326
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; }
327
349
}
328
350
</style>
+73
-35
frontend/src/routes/Login.svelte
+73
-35
frontend/src/routes/Login.svelte
···
1
1
<script lang="ts">
2
-
import { loginWithOAuth, confirmSignup, resendVerification, getAuthState, switchAccount, forgetAccount } from '../lib/auth.svelte'
3
-
import { navigate } from '../lib/router.svelte'
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'
4
14
import { _ } from '../lib/i18n'
15
+
import { isOk, isErr } from '../lib/types/result'
16
+
import { unsafeAsDid, type Did } from '../lib/types/branded'
5
17
18
+
type PageState =
19
+
| { kind: 'login' }
20
+
| { kind: 'verification'; did: string }
21
+
22
+
let pageState = $state<PageState>({ kind: 'login' })
6
23
let submitting = $state(false)
7
-
let pendingVerification = $state<{ did: string } | null>(null)
8
24
let verificationCode = $state('')
9
25
let resendingCode = $state(false)
10
26
let resendMessage = $state<string | null>(null)
11
27
let autoRedirectAttempted = $state(false)
12
-
const auth = getAuthState()
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
+
}
13
45
14
46
$effect(() => {
15
-
if (!auth.loading && !auth.error && auth.savedAccounts.length === 0 && !pendingVerification && !autoRedirectAttempted) {
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) {
16
52
autoRedirectAttempted = true
17
53
loginWithOAuth()
18
54
}
19
55
})
20
56
21
-
async function handleSwitchAccount(did: string) {
57
+
async function handleSwitchAccount(did: Did) {
22
58
submitting = true
23
-
try {
24
-
await switchAccount(did)
25
-
navigate('/dashboard')
26
-
} catch {
59
+
const result = await switchAccount(did)
60
+
if (isOk(result)) {
61
+
navigate(routes.dashboard)
62
+
} else {
27
63
submitting = false
28
64
}
29
65
}
30
66
31
-
function handleForgetAccount(did: string, e: Event) {
67
+
function handleForgetAccount(did: Did, e: Event) {
32
68
e.stopPropagation()
33
69
forgetAccount(did)
34
70
}
35
71
36
72
async function handleOAuthLogin() {
37
73
submitting = true
38
-
try {
39
-
await loginWithOAuth()
40
-
} catch {
74
+
const result = await loginWithOAuth()
75
+
if (isErr(result)) {
41
76
submitting = false
42
77
}
43
78
}
44
79
45
80
async function handleVerification(e: Event) {
46
81
e.preventDefault()
47
-
if (!pendingVerification || !verificationCode.trim()) return
82
+
if (pageState.kind !== 'verification' || !verificationCode.trim()) return
83
+
48
84
submitting = true
49
-
try {
50
-
await confirmSignup(pendingVerification.did, verificationCode.trim())
51
-
navigate('/dashboard')
52
-
} catch {
85
+
const result = await confirmSignup(pageState.did, verificationCode.trim())
86
+
if (isOk(result)) {
87
+
navigate(routes.dashboard)
88
+
} else {
53
89
submitting = false
54
90
}
55
91
}
56
92
57
93
async function handleResendCode() {
58
-
if (!pendingVerification || resendingCode) return
94
+
if (pageState.kind !== 'verification' || resendingCode) return
95
+
59
96
resendingCode = true
60
97
resendMessage = null
61
-
try {
62
-
await resendVerification(pendingVerification.did)
98
+
const result = await resendVerification(pageState.did)
99
+
if (isOk(result)) {
63
100
resendMessage = $_('verification.resent')
64
-
} catch {
65
-
resendMessage = null
66
-
} finally {
67
-
resendingCode = false
68
101
}
102
+
resendingCode = false
69
103
}
70
104
71
105
function backToLogin() {
72
-
pendingVerification = null
106
+
pageState = { kind: 'login' }
73
107
verificationCode = ''
74
108
resendMessage = null
75
109
}
110
+
111
+
const errorMessage = $derived(getErrorMessage())
112
+
const savedAccounts = $derived(getSavedAccounts())
113
+
const loading = $derived(isLoading())
76
114
</script>
77
115
78
116
<div class="login-page">
79
-
{#if auth.error}
80
-
<div class="message error">{auth.error}</div>
117
+
{#if errorMessage}
118
+
<div class="message error">{errorMessage}</div>
81
119
{/if}
82
120
83
-
{#if pendingVerification}
121
+
{#if pageState.kind === 'verification'}
84
122
<header class="page-header">
85
123
<h1>{$_('verification.title')}</h1>
86
124
<p class="subtitle">{$_('verification.subtitle')}</p>
···
121
159
{:else}
122
160
<header class="page-header">
123
161
<h1>{$_('login.title')}</h1>
124
-
<p class="subtitle">{auth.savedAccounts.length > 0 ? $_('login.chooseAccount') : $_('login.subtitle')}</p>
162
+
<p class="subtitle">{savedAccounts.length > 0 ? $_('login.chooseAccount') : $_('login.subtitle')}</p>
125
163
</header>
126
164
127
165
<div class="split-layout sidebar-right">
128
166
<div class="main-section">
129
-
{#if auth.savedAccounts.length > 0}
167
+
{#if savedAccounts.length > 0}
130
168
<div class="saved-accounts">
131
-
{#each auth.savedAccounts as account}
169
+
{#each savedAccounts as account}
132
170
<div
133
171
class="account-item"
134
172
class:disabled={submitting}
···
156
194
<p class="or-divider">{$_('login.signInToAnother')}</p>
157
195
{/if}
158
196
159
-
<button type="button" class="oauth-btn" onclick={handleOAuthLogin} disabled={submitting || auth.loading}>
197
+
<button type="button" class="oauth-btn" onclick={handleOAuthLogin} disabled={submitting || loading}>
160
198
{submitting ? $_('login.redirecting') : $_('login.button')}
161
199
</button>
162
200
···
172
210
</div>
173
211
174
212
<aside class="info-panel">
175
-
{#if auth.savedAccounts.length > 0}
213
+
{#if savedAccounts.length > 0}
176
214
<h3>{$_('login.infoSavedAccountsTitle')}</h3>
177
215
<p>{$_('login.infoSavedAccountsDesc')}</p>
178
216
+3
-3
frontend/src/routes/Migration.svelte
+3
-3
frontend/src/routes/Migration.svelte
···
1
1
<script lang="ts">
2
2
import { setSession } from '../lib/auth.svelte'
3
-
import { navigate } from '../lib/router.svelte'
3
+
import { navigate, routes } from '../lib/router.svelte'
4
4
import { _ } from '../lib/i18n'
5
5
import {
6
6
createInboundMigrationFlow,
···
151
151
refreshJwt: '',
152
152
})
153
153
}
154
-
navigate('/dashboard')
154
+
navigate(routes.dashboard)
155
155
}
156
156
157
157
function handleOfflineComplete() {
···
164
164
refreshJwt: '',
165
165
})
166
166
}
167
-
navigate('/dashboard')
167
+
navigate(routes.dashboard)
168
168
}
169
169
</script>
170
170
+2
-2
frontend/src/routes/OAuth2FA.svelte
+2
-2
frontend/src/routes/OAuth2FA.svelte
···
1
1
<script lang="ts">
2
-
import { navigate } from '../lib/router.svelte'
2
+
import { navigate, routes } from '../lib/router.svelte'
3
3
import { _ } from '../lib/i18n'
4
4
5
5
let code = $state('')
···
64
64
function handleCancel() {
65
65
const requestUri = getRequestUri()
66
66
if (requestUri) {
67
-
navigate(`/oauth/login?request_uri=${encodeURIComponent(requestUri)}`)
67
+
navigate(routes.oauthLogin, { params: { request_uri: requestUri } })
68
68
} else {
69
69
window.history.back()
70
70
}
+6
-8
frontend/src/routes/OAuthAccounts.svelte
+6
-8
frontend/src/routes/OAuthAccounts.svelte
···
1
1
<script lang="ts">
2
-
import { navigate } from '../lib/router.svelte'
2
+
import { navigate, routes } from '../lib/router.svelte'
3
3
import { _ } from '../lib/i18n'
4
4
5
5
interface AccountInfo {
···
75
75
}
76
76
77
77
if (data.needs_totp) {
78
-
navigate(`/oauth/totp?request_uri=${encodeURIComponent(requestUri)}`)
78
+
navigate(routes.oauthTotp, { params: { request_uri: requestUri } })
79
79
return
80
80
}
81
81
82
82
if (data.needs_2fa) {
83
-
navigate(`/oauth/2fa?request_uri=${encodeURIComponent(requestUri)}&channel=${encodeURIComponent(data.channel || '')}`)
83
+
navigate(routes.oauth2fa, { params: { request_uri: requestUri, channel: data.channel || '' } })
84
84
return
85
85
}
86
86
···
100
100
function handleDifferentAccount() {
101
101
const requestUri = getRequestUri()
102
102
if (requestUri) {
103
-
navigate(`/oauth/login?request_uri=${encodeURIComponent(requestUri)}`)
103
+
navigate(routes.oauthLogin, { params: { request_uri: requestUri } })
104
104
} else {
105
-
navigate('/oauth/login')
105
+
navigate(routes.oauthLogin)
106
106
}
107
107
}
108
108
···
113
113
114
114
<div class="oauth-accounts-container">
115
115
{#if loading}
116
-
<div class="loading">
117
-
<p>{$_('common.loading')}</p>
118
-
</div>
116
+
<div class="loading"></div>
119
117
{:else if error}
120
118
<div class="error-container">
121
119
<h1>Error</h1>
+16
-22
frontend/src/routes/OAuthConsent.svelte
+16
-22
frontend/src/routes/OAuthConsent.svelte
···
1
1
<script lang="ts">
2
-
import { navigate } from '../lib/router.svelte'
2
+
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
3
3
import { _ } from '../lib/i18n'
4
4
5
5
interface ScopeInfo {
···
57
57
const data: ConsentData = await response.json()
58
58
consentData = data
59
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
-
}
60
+
scopeSelections = Object.fromEntries(
61
+
data.scopes.map((scope) => [
62
+
scope.scope,
63
+
scope.required ? true : scope.granted ?? true,
64
+
])
65
+
)
69
66
70
67
if (!data.show_consent) {
71
68
await submitConsent()
···
144
141
}
145
142
146
143
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
144
+
return scopes.reduce(
145
+
(groups, scope) => ({
146
+
...groups,
147
+
[scope.category]: [...(groups[scope.category] ?? []), scope],
148
+
}),
149
+
{} as Record<string, ScopeInfo[]>
150
+
)
155
151
}
156
152
157
153
$effect(() => {
···
163
159
164
160
<div class="consent-container">
165
161
{#if loading}
166
-
<div class="loading">
167
-
<p>{$_('common.loading')}</p>
168
-
</div>
162
+
<div class="loading"></div>
169
163
{:else if error}
170
164
<div class="error-container">
171
165
<h1>{$_('oauth.error.title')}</h1>
172
166
<div class="error">{error}</div>
173
-
<button type="button" onclick={() => navigate('/login')}>
167
+
<button type="button" onclick={() => navigate(routes.login)}>
174
168
{$_('common.backToLogin')}
175
169
</button>
176
170
</div>
+13
-49
frontend/src/routes/OAuthDelegation.svelte
+13
-49
frontend/src/routes/OAuthDelegation.svelte
···
1
1
<script lang="ts">
2
-
import { navigate } from '../lib/router.svelte'
2
+
import { navigate, routes } from '../lib/router.svelte'
3
3
import { _ } from '../lib/i18n'
4
+
import {
5
+
prepareRequestOptions,
6
+
serializeAssertionResponse,
7
+
type WebAuthnRequestOptionsResponse,
8
+
} from '../lib/webauthn'
4
9
5
10
let delegatedDid = $state<string | null>(null)
6
11
let delegatedHandle = $state<string | null>(null)
···
103
108
}
104
109
}
105
110
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
111
async function handlePasskeyLogin() {
138
112
const requestUri = getRequestUri()
139
113
if (!requestUri || !controllerDid || !delegatedDid) {
···
165
139
}
166
140
167
141
const { options } = await startResponse.json()
142
+
const publicKeyOptions = prepareRequestOptions(options as WebAuthnRequestOptionsResponse)
168
143
169
144
const credential = await navigator.credentials.get({
170
-
publicKey: prepareCredentialRequestOptions(options.publicKey)
145
+
publicKey: publicKeyOptions
171
146
}) as PublicKeyCredential | null
172
147
173
148
if (!credential) {
···
176
151
return
177
152
}
178
153
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
-
}
154
+
const credentialData = serializeAssertionResponse(credential)
191
155
192
156
const finishResponse = await fetch('/oauth/passkey/finish', {
193
157
method: 'POST',
···
213
177
}
214
178
215
179
if (data.needs_totp) {
216
-
navigate(`/oauth/totp?request_uri=${encodeURIComponent(requestUri)}`)
180
+
navigate(routes.oauthTotp, { params: { request_uri: requestUri } })
217
181
return
218
182
}
219
183
220
184
if (data.needs_2fa) {
221
-
navigate(`/oauth/2fa?request_uri=${encodeURIComponent(requestUri)}&channel=${encodeURIComponent(data.channel || '')}`)
185
+
navigate(routes.oauth2fa, { params: { request_uri: requestUri, channel: data.channel || '' } })
222
186
return
223
187
}
224
188
···
272
236
}
273
237
274
238
if (data.needs_totp) {
275
-
navigate(`/oauth/totp?request_uri=${encodeURIComponent(requestUri)}`)
239
+
navigate(routes.oauthTotp, { params: { request_uri: requestUri } })
276
240
return
277
241
}
278
242
279
243
if (data.needs_2fa) {
280
-
navigate(`/oauth/2fa?request_uri=${encodeURIComponent(requestUri)}&channel=${encodeURIComponent(data.channel || '')}`)
244
+
navigate(routes.oauth2fa, { params: { request_uri: requestUri, channel: data.channel || '' } })
281
245
return
282
246
}
283
247
+15
-51
frontend/src/routes/OAuthLogin.svelte
+15
-51
frontend/src/routes/OAuthLogin.svelte
···
1
1
<script lang="ts">
2
-
import { navigate } from '../lib/router.svelte'
2
+
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
3
3
import { _ } from '../lib/i18n'
4
+
import {
5
+
prepareRequestOptions,
6
+
serializeAssertionResponse,
7
+
type WebAuthnRequestOptionsResponse,
8
+
} from '../lib/webauthn'
4
9
5
10
let username = $state('')
6
11
let password = $state('')
···
95
100
if (!hasPassword && !hasPasskeys && isDelegated && data.did) {
96
101
const requestUri = getRequestUri()
97
102
if (requestUri) {
98
-
navigate(`/oauth/delegation?request_uri=${encodeURIComponent(requestUri)}&delegated_did=${encodeURIComponent(data.did)}`)
103
+
navigate(routes.oauthDelegation, { params: { request_uri: requestUri, delegated_did: data.did } })
99
104
return
100
105
}
101
106
}
···
142
147
}
143
148
144
149
const { options } = await startResponse.json()
150
+
const publicKeyOptions = prepareRequestOptions(options as WebAuthnRequestOptionsResponse)
145
151
146
152
const credential = await navigator.credentials.get({
147
-
publicKey: prepareCredentialRequestOptions(options.publicKey)
153
+
publicKey: publicKeyOptions
148
154
}) as PublicKeyCredential | null
149
155
150
156
if (!credential) {
···
153
159
return
154
160
}
155
161
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
-
}
162
+
const credentialData = serializeAssertionResponse(credential)
168
163
169
164
const finishResponse = await fetch('/oauth/passkey/finish', {
170
165
method: 'POST',
···
187
182
}
188
183
189
184
if (data.needs_totp) {
190
-
navigate(`/oauth/totp?request_uri=${encodeURIComponent(requestUri)}`)
185
+
navigate(routes.oauthTotp, { params: { request_uri: requestUri } })
191
186
return
192
187
}
193
188
194
189
if (data.needs_2fa) {
195
-
navigate(`/oauth/2fa?request_uri=${encodeURIComponent(requestUri)}&channel=${encodeURIComponent(data.channel || '')}`)
190
+
navigate(routes.oauth2fa, { params: { request_uri: requestUri, channel: data.channel || '' } })
196
191
return
197
192
}
198
193
···
214
209
}
215
210
}
216
211
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
212
async function handleSubmit(e: Event) {
249
213
e.preventDefault()
250
214
const requestUri = getRequestUri()
···
280
244
}
281
245
282
246
if (data.needs_totp) {
283
-
navigate(`/oauth/totp?request_uri=${encodeURIComponent(requestUri)}`)
247
+
navigate(routes.oauthTotp, { params: { request_uri: requestUri } })
284
248
return
285
249
}
286
250
287
251
if (data.needs_2fa) {
288
-
navigate(`/oauth/2fa?request_uri=${encodeURIComponent(requestUri)}&channel=${encodeURIComponent(data.channel || '')}`)
252
+
navigate(routes.oauth2fa, { params: { request_uri: requestUri, channel: data.channel || '' } })
289
253
return
290
254
}
291
255
···
456
420
</form>
457
421
458
422
<p class="help-links">
459
-
<a href="/app/reset-password">{$_('login.forgotPassword')}</a> · <a href="/app/request-passkey-recovery">{$_('login.lostPasskey')}</a>
423
+
<a href={getFullUrl(routes.resetPassword)}>{$_('login.forgotPassword')}</a> · <a href={getFullUrl(routes.requestPasskeyRecovery)}>{$_('login.lostPasskey')}</a>
460
424
</p>
461
425
</div>
462
426
+9
-47
frontend/src/routes/OAuthPasskey.svelte
+9
-47
frontend/src/routes/OAuthPasskey.svelte
···
1
1
<script lang="ts">
2
-
import { navigate } from '../lib/router.svelte'
2
+
import { navigate, routes } from '../lib/router.svelte'
3
3
import { _ } from '../lib/i18n'
4
+
import {
5
+
prepareRequestOptions,
6
+
serializeAssertionResponse,
7
+
type WebAuthnRequestOptionsResponse,
8
+
} from '../lib/webauthn'
4
9
5
10
let loading = $state(false)
6
11
let error = $state<string | null>(null)
···
13
18
14
19
const t = $_
15
20
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
21
async function startPasskeyAuth() {
48
22
const requestUri = getRequestUri()
49
23
if (!requestUri) {
···
75
49
}
76
50
77
51
const { options } = await startResponse.json()
78
-
const publicKeyOptions = prepareAuthOptions(options)
52
+
const publicKeyOptions = prepareRequestOptions(options as WebAuthnRequestOptionsResponse)
79
53
80
54
const credential = await navigator.credentials.get({
81
55
publicKey: publicKeyOptions
···
87
61
return
88
62
}
89
63
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
-
}
64
+
const credentialResponse = serializeAssertionResponse(credential as PublicKeyCredential)
103
65
104
66
const finishResponse = await fetch('/oauth/authorize/passkey', {
105
67
method: 'POST',
···
141
103
function handleCancel() {
142
104
const requestUri = getRequestUri()
143
105
if (requestUri) {
144
-
navigate(`/oauth/login?request_uri=${encodeURIComponent(requestUri)}`)
106
+
navigate(routes.oauthLogin, { params: { request_uri: requestUri } })
145
107
} else {
146
108
window.history.back()
147
109
}
+2
-2
frontend/src/routes/OAuthTotp.svelte
+2
-2
frontend/src/routes/OAuthTotp.svelte
···
1
1
<script lang="ts">
2
-
import { navigate } from '../lib/router.svelte'
2
+
import { navigate, routes } from '../lib/router.svelte'
3
3
import { _ } from '../lib/i18n'
4
4
5
5
let code = $state('')
···
61
61
function handleCancel() {
62
62
const requestUri = getRequestUri()
63
63
if (requestUri) {
64
-
navigate(`/oauth/login?request_uri=${encodeURIComponent(requestUri)}`)
64
+
navigate(routes.oauthLogin, { params: { request_uri: requestUri } })
65
65
} else {
66
66
window.history.back()
67
67
}
+3
-3
frontend/src/routes/RecoverPasskey.svelte
+3
-3
frontend/src/routes/RecoverPasskey.svelte
···
1
1
<script lang="ts">
2
-
import { navigate } from '../lib/router.svelte'
2
+
import { navigate, routes } from '../lib/router.svelte'
3
3
import { api, ApiError } from '../lib/api'
4
4
import { _ } from '../lib/i18n'
5
5
···
66
66
}
67
67
68
68
function goToLogin() {
69
-
navigate('/login')
69
+
navigate(routes.login)
70
70
}
71
71
72
72
function requestNewLink() {
73
-
navigate('/login')
73
+
navigate(routes.login)
74
74
}
75
75
</script>
76
76
+7
-8
frontend/src/routes/Register.svelte
+7
-8
frontend/src/routes/Register.svelte
···
1
1
<script lang="ts">
2
-
import { navigate } from '../lib/router.svelte'
2
+
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
3
3
import { api, ApiError } from '../lib/api'
4
4
import { _ } from '../lib/i18n'
5
5
import {
···
30
30
31
31
$effect(() => {
32
32
if (flow?.state.step === 'redirect-to-dashboard') {
33
-
navigate('/dashboard')
33
+
navigate(routes.dashboard)
34
34
}
35
35
})
36
36
···
109
109
if (flow) {
110
110
await flow.finalizeSession()
111
111
}
112
-
navigate('/dashboard')
112
+
navigate(routes.dashboard)
113
113
}
114
114
115
115
function isChannelAvailable(ch: string): boolean {
···
166
166
{/if}
167
167
168
168
{#if loadingServerInfo || !flow}
169
-
<p class="loading">{$_('common.loading')}</p>
170
-
169
+
<div class="loading"></div>
171
170
{:else if flow.state.step === 'info'}
172
171
<div class="migrate-callout">
173
172
<div class="migrate-icon">↗</div>
174
173
<div class="migrate-content">
175
174
<strong>{$_('register.migrateTitle')}</strong>
176
175
<p>{$_('register.migrateDescription')}</p>
177
-
<a href="/app/migrate" class="migrate-link">
176
+
<a href={getFullUrl(routes.migrate)} class="migrate-link">
178
177
{$_('register.migrateLink')} →
179
178
</a>
180
179
</div>
···
381
380
382
381
<div class="form-links">
383
382
<p class="link-text">
384
-
{$_('register.alreadyHaveAccount')} <a href="/app/login">{$_('register.signIn')}</a>
383
+
{$_('register.alreadyHaveAccount')} <a href={getFullUrl(routes.login)}>{$_('register.signIn')}</a>
385
384
</p>
386
385
<p class="link-text">
387
-
{$_('register.wantPasswordless')} <a href="/app/register-passkey">{$_('register.createPasskeyAccount')}</a>
386
+
{$_('register.wantPasswordless')} <a href={getFullUrl(routes.registerPasskey)}>{$_('register.createPasskeyAccount')}</a>
388
387
</p>
389
388
</div>
390
389
</div>
+7
-47
frontend/src/routes/RegisterPasskey.svelte
+7
-47
frontend/src/routes/RegisterPasskey.svelte
···
9
9
DidDocStep,
10
10
AppPasswordStep,
11
11
} from '../lib/registration'
12
+
import {
13
+
prepareCreationOptions,
14
+
serializeAttestationResponse,
15
+
type WebAuthnCreationOptionsResponse,
16
+
} from '../lib/webauthn'
12
17
13
18
let serverInfo = $state<{
14
19
availableUserDomains: string[]
···
84
89
return null
85
90
}
86
91
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
92
async function handleInfoSubmit(e: Event) {
123
93
e.preventDefault()
124
94
if (!flow) return
···
156
126
passkeyName || undefined
157
127
)
158
128
159
-
const publicKeyOptions = preparePublicKeyOptions(options)
129
+
const publicKeyOptions = prepareCreationOptions(options as WebAuthnCreationOptionsResponse)
160
130
const credential = await navigator.credentials.create({
161
131
publicKey: publicKeyOptions
162
132
})
···
167
137
return
168
138
}
169
139
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
-
}
140
+
const credentialResponse = serializeAttestationResponse(credential as PublicKeyCredential)
181
141
182
142
const result = await api.completePasskeySetup(
183
143
flow.account.did,
+63
-33
frontend/src/routes/RepoExplorer.svelte
+63
-33
frontend/src/routes/RepoExplorer.svelte
···
1
1
<script lang="ts">
2
2
import { getAuthState } from '../lib/auth.svelte'
3
-
import { navigate } from '../lib/router.svelte'
3
+
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
4
4
import { api, ApiError } from '../lib/api'
5
5
import { _, locale } from '../lib/i18n'
6
-
const auth = getAuthState()
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())
7
20
type View = 'collections' | 'records' | 'record' | 'create'
8
21
let view = $state<View>('collections')
9
22
let collections = $state<string[]>([])
···
31
44
let saving = $state(false)
32
45
let filter = $state('')
33
46
$effect(() => {
34
-
if (!auth.loading && !auth.session) {
35
-
navigate('/login')
47
+
if (!authLoading && !session) {
48
+
navigate(routes.login)
36
49
}
37
50
})
38
51
$effect(() => {
39
-
if (auth.session) {
52
+
if (session) {
40
53
loadCollections()
41
54
}
42
55
})
43
56
async function loadCollections() {
44
-
if (!auth.session) return
57
+
if (!session) return
45
58
loading = true
46
59
error = null
47
60
try {
48
-
const result = await api.describeRepo(auth.session.accessJwt, auth.session.did)
61
+
const result = await api.describeRepo(session.accessJwt, session.did)
49
62
collections = result.collections.sort()
50
63
} catch (e) {
51
64
setError(e)
···
54
67
}
55
68
}
56
69
async function selectCollection(collection: string) {
57
-
if (!auth.session) return
70
+
if (!session) return
58
71
selectedCollection = collection
59
72
records = []
60
73
recordsCursor = undefined
···
62
75
loading = true
63
76
error = null
64
77
try {
65
-
const result = await api.listRecords(auth.session.accessJwt, auth.session.did, collection, { limit: 50 })
78
+
const result = await api.listRecords(session.accessJwt, session.did, collection, { limit: 50 })
66
79
records = result.records.map(r => ({
67
80
...r,
68
81
rkey: r.uri.split('/').pop()!
···
75
88
}
76
89
}
77
90
async function loadMoreRecords() {
78
-
if (!auth.session || !selectedCollection || !recordsCursor || loadingMore) return
91
+
if (!session || !selectedCollection || !recordsCursor || loadingMore) return
79
92
loadingMore = true
80
93
try {
81
-
const result = await api.listRecords(auth.session.accessJwt, auth.session.did, selectedCollection, {
94
+
const result = await api.listRecords(session.accessJwt, session.did, selectedCollection, {
82
95
limit: 50,
83
96
cursor: recordsCursor
84
97
})
···
154
167
}
155
168
async function handleCreate(e: Event) {
156
169
e.preventDefault()
157
-
if (!auth.session) return
170
+
if (!session) return
158
171
const record = validateJson()
159
172
if (!record) return
160
173
if (!newCollection.trim()) {
···
165
178
error = null
166
179
try {
167
180
const result = await api.createRecord(
168
-
auth.session.accessJwt,
169
-
auth.session.did,
181
+
session.accessJwt,
182
+
session.did,
170
183
newCollection.trim(),
171
184
record,
172
185
newRkey.trim() || undefined
···
182
195
}
183
196
async function handleUpdate(e: Event) {
184
197
e.preventDefault()
185
-
if (!auth.session || !selectedRecord || !selectedCollection) return
198
+
if (!session || !selectedRecord || !selectedCollection) return
186
199
const record = validateJson()
187
200
if (!record) return
188
201
saving = true
189
202
error = null
190
203
try {
191
204
await api.putRecord(
192
-
auth.session.accessJwt,
193
-
auth.session.did,
205
+
session.accessJwt,
206
+
session.did,
194
207
selectedCollection,
195
208
selectedRecord.rkey,
196
209
record
197
210
)
198
211
success = $_('repoExplorer.recordUpdated')
199
212
const updated = await api.getRecord(
200
-
auth.session.accessJwt,
201
-
auth.session.did,
213
+
session.accessJwt,
214
+
session.did,
202
215
selectedCollection,
203
216
selectedRecord.rkey
204
217
)
···
211
224
}
212
225
}
213
226
async function handleDelete() {
214
-
if (!auth.session || !selectedRecord || !selectedCollection) return
227
+
if (!session || !selectedRecord || !selectedCollection) return
215
228
if (!confirm($_('repoExplorer.deleteConfirm', { values: { rkey: selectedRecord.rkey } }))) return
216
229
saving = true
217
230
error = null
218
231
try {
219
232
await api.deleteRecord(
220
-
auth.session.accessJwt,
221
-
auth.session.did,
233
+
session.accessJwt,
234
+
session.did,
222
235
selectedCollection,
223
236
selectedRecord.rkey
224
237
)
···
259
272
: records
260
273
)
261
274
function groupCollectionsByAuthority(cols: string[]): Map<string, string[]> {
262
-
const groups = new Map<string, string[]>()
263
-
for (const col of cols) {
275
+
return cols.reduce((groups, col) => {
264
276
const parts = col.split('.')
265
277
const authority = parts.slice(0, -1).join('.')
266
278
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
279
+
return groups.set(authority, [...(groups.get(authority) ?? []), name])
280
+
}, new Map<string, string[]>())
273
281
}
274
282
let groupedCollections = $derived(groupCollectionsByAuthority(filteredCollections))
275
283
</script>
···
303
311
{$_('repoExplorer.createRecord')}
304
312
{/if}
305
313
</h1>
306
-
{#if auth.session}
307
-
<p class="did">{auth.session.did}</p>
314
+
{#if session}
315
+
<p class="did">{session.did}</p>
308
316
{/if}
309
317
</header>
310
318
{#if error}
···
319
327
<div class="message success">{success}</div>
320
328
{/if}
321
329
{#if loading}
322
-
<p class="loading-text">{$_('common.loading')}</p>
330
+
<div class="skeleton-list">
331
+
{#each Array(4) as _}
332
+
<div class="skeleton-row"></div>
333
+
{/each}
334
+
</div>
323
335
{:else if view === 'collections'}
324
336
<div class="toolbar">
325
337
<input
···
979
991
.page ::-moz-selection {
980
992
background: var(--accent);
981
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; }
982
1012
}
983
1013
</style>
+3
-3
frontend/src/routes/RequestPasskeyRecovery.svelte
+3
-3
frontend/src/routes/RequestPasskeyRecovery.svelte
···
1
1
<script lang="ts">
2
-
import { navigate } from '../lib/router.svelte'
2
+
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
3
3
import { api, ApiError } from '../lib/api'
4
4
import { _ } from '../lib/i18n'
5
5
···
36
36
<h1>{$_('requestPasskeyRecovery.successTitle')}</h1>
37
37
<p class="subtitle">{$_('requestPasskeyRecovery.successMessage')}</p>
38
38
<p class="info-text">{$_('requestPasskeyRecovery.successInfo')}</p>
39
-
<button onclick={() => navigate('/login')}>{$_('common.backToLogin')}</button>
39
+
<button onclick={() => navigate(routes.login)}>{$_('common.backToLogin')}</button>
40
40
</div>
41
41
{:else}
42
42
<h1>{$_('requestPasskeyRecovery.title')}</h1>
···
71
71
{/if}
72
72
73
73
<p class="link-text">
74
-
<a href="/app/login">{$_('common.backToLogin')}</a>
74
+
<a href={getFullUrl(routes.login)}>{$_('common.backToLogin')}</a>
75
75
</p>
76
76
</div>
77
77
+12
-5
frontend/src/routes/ResetPassword.svelte
+12
-5
frontend/src/routes/ResetPassword.svelte
···
1
1
<script lang="ts">
2
-
import { navigate } from '../lib/router.svelte'
2
+
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
3
3
import { api, ApiError } from '../lib/api'
4
4
import { getAuthState } from '../lib/auth.svelte'
5
5
import { _ } from '../lib/i18n'
6
+
import type { Session } from '../lib/types/api'
6
7
7
-
const auth = getAuthState()
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())
8
15
9
16
let email = $state('')
10
17
let token = $state('')
···
16
23
let tokenSent = $state(false)
17
24
18
25
$effect(() => {
19
-
if (auth.session) {
20
-
navigate('/dashboard')
26
+
if (session) {
27
+
navigate(routes.dashboard)
21
28
}
22
29
})
23
30
···
55
62
try {
56
63
await api.resetPassword(token, newPassword)
57
64
success = $_('resetPassword.success')
58
-
setTimeout(() => navigate('/login'), 2000)
65
+
setTimeout(() => navigate(routes.login), 2000)
59
66
} catch (e) {
60
67
error = e instanceof ApiError ? e.message : 'Failed to reset password'
61
68
} finally {
+113
-127
frontend/src/routes/Security.svelte
+113
-127
frontend/src/routes/Security.svelte
···
1
1
<script lang="ts">
2
2
import { getAuthState, getValidToken } from '../lib/auth.svelte'
3
-
import { navigate } from '../lib/router.svelte'
3
+
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
4
4
import { api, ApiError } from '../lib/api'
5
5
import ReauthModal from '../components/ReauthModal.svelte'
6
6
import { _ } from '../lib/i18n'
7
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'
8
15
9
-
const auth = getAuthState()
10
-
let message = $state<{ type: 'success' | 'error'; text: string } | null>(null)
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
+
11
29
let loading = $state(true)
12
30
let totpEnabled = $state(false)
13
31
let hasBackupCodes = $state(false)
···
56
74
let pendingAction = $state<(() => Promise<void>) | null>(null)
57
75
58
76
$effect(() => {
59
-
if (!auth.loading && !auth.session) {
60
-
navigate('/login')
77
+
if (!authLoading && !session) {
78
+
navigate(routes.login)
61
79
}
62
80
})
63
81
64
82
$effect(() => {
65
-
if (auth.session) {
83
+
if (session) {
66
84
loadTotpStatus()
67
85
loadPasskeys()
68
86
loadPasswordStatus()
···
71
89
})
72
90
73
91
async function loadPasswordStatus() {
74
-
if (!auth.session) return
92
+
if (!session) return
75
93
passwordLoading = true
76
94
try {
77
-
const status = await api.getPasswordStatus(auth.session.accessJwt)
95
+
const status = await api.getPasswordStatus(session.accessJwt)
78
96
hasPassword = status.hasPassword
79
97
} catch {
80
98
hasPassword = true
···
84
102
}
85
103
86
104
async function loadLegacyLoginPreference() {
87
-
if (!auth.session) return
105
+
if (!session) return
88
106
legacyLoginLoading = true
89
107
try {
90
-
const pref = await api.getLegacyLoginPreference(auth.session.accessJwt)
108
+
const pref = await api.getLegacyLoginPreference(session.accessJwt)
91
109
allowLegacyLogin = pref.allowLegacyLogin
92
110
hasMfa = pref.hasMfa
93
111
} catch {
···
99
117
}
100
118
101
119
async function handleToggleLegacyLogin() {
102
-
if (!auth.session) return
120
+
if (!session) return
103
121
legacyLoginUpdating = true
104
122
try {
105
-
const result = await api.updateLegacyLoginPreference(auth.session.accessJwt, !allowLegacyLogin)
123
+
const result = await api.updateLegacyLoginPreference(session.accessJwt, !allowLegacyLogin)
106
124
allowLegacyLogin = result.allowLegacyLogin
107
-
showMessage('success', allowLegacyLogin
125
+
toast.success(allowLegacyLogin
108
126
? $_('security.legacyLoginEnabled')
109
127
: $_('security.legacyLoginDisabled'))
110
128
} catch (e) {
···
114
132
pendingAction = handleToggleLegacyLogin
115
133
showReauthModal = true
116
134
} else {
117
-
showMessage('error', e.message)
135
+
toast.error(e.message)
118
136
}
119
137
} else {
120
-
showMessage('error', $_('security.failedToUpdatePreference'))
138
+
toast.error($_('security.failedToUpdatePreference'))
121
139
}
122
140
} finally {
123
141
legacyLoginUpdating = false
···
125
143
}
126
144
127
145
async function handleRemovePassword() {
128
-
if (!auth.session) return
146
+
if (!session) return
129
147
removePasswordLoading = true
130
148
try {
131
149
const token = await getValidToken()
132
150
if (!token) {
133
-
showMessage('error', $_('security.sessionExpired'))
151
+
toast.error($_('security.sessionExpired'))
134
152
return
135
153
}
136
154
await api.removePassword(token)
137
155
hasPassword = false
138
156
showRemovePasswordForm = false
139
-
showMessage('success', $_('security.passwordRemoved'))
157
+
toast.success($_('security.passwordRemoved'))
140
158
} catch (e) {
141
159
if (e instanceof ApiError) {
142
160
if (e.error === 'ReauthRequired') {
···
144
162
pendingAction = handleRemovePassword
145
163
showReauthModal = true
146
164
} else {
147
-
showMessage('error', e.message)
165
+
toast.error(e.message)
148
166
}
149
167
} else {
150
-
showMessage('error', $_('security.failedToRemovePassword'))
168
+
toast.error($_('security.failedToRemovePassword'))
151
169
}
152
170
} finally {
153
171
removePasswordLoading = false
···
166
184
}
167
185
168
186
async function loadTotpStatus() {
169
-
if (!auth.session) return
187
+
if (!session) return
170
188
loading = true
171
189
try {
172
-
const status = await api.getTotpStatus(auth.session.accessJwt)
190
+
const status = await api.getTotpStatus(session.accessJwt)
173
191
totpEnabled = status.enabled
174
192
hasBackupCodes = status.hasBackupCodes
175
193
} catch {
176
-
showMessage('error', $_('security.failedToLoadTotpStatus'))
194
+
toast.error($_('security.failedToLoadTotpStatus'))
177
195
} finally {
178
196
loading = false
179
197
}
180
198
}
181
199
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
200
async function handleStartSetup() {
190
-
if (!auth.session) return
201
+
if (!session) return
191
202
verifyLoading = true
192
203
try {
193
-
const result = await api.createTotpSecret(auth.session.accessJwt)
204
+
const result = await api.createTotpSecret(session.accessJwt)
194
205
qrBase64 = result.qrBase64
195
206
totpUri = result.uri
196
207
setupStep = 'qr'
197
208
} catch (e) {
198
-
showMessage('error', e instanceof ApiError ? e.message : 'Failed to generate TOTP secret')
209
+
toast.error(e instanceof ApiError ? e.message : 'Failed to generate TOTP secret')
199
210
} finally {
200
211
verifyLoading = false
201
212
}
···
203
214
204
215
async function handleVerifySetup(e: Event) {
205
216
e.preventDefault()
206
-
if (!auth.session || !verifyCode) return
217
+
if (!session || !verifyCode) return
207
218
verifyLoading = true
208
219
try {
209
-
const result = await api.enableTotp(auth.session.accessJwt, verifyCode)
220
+
const result = await api.enableTotp(session.accessJwt, verifyCode)
210
221
backupCodes = result.backupCodes
211
222
setupStep = 'backup'
212
223
totpEnabled = true
213
224
hasBackupCodes = true
214
225
verifyCodeRaw = ''
215
226
} catch (e) {
216
-
showMessage('error', e instanceof ApiError ? e.message : 'Invalid code. Please try again.')
227
+
toast.error(e instanceof ApiError ? e.message : 'Invalid code. Please try again.')
217
228
} finally {
218
229
verifyLoading = false
219
230
}
···
224
235
backupCodes = []
225
236
qrBase64 = ''
226
237
totpUri = ''
227
-
showMessage('success', $_('security.totpEnabledSuccess'))
238
+
toast.success($_('security.totpEnabledSuccess'))
228
239
}
229
240
230
241
async function handleDisable(e: Event) {
231
242
e.preventDefault()
232
-
if (!auth.session || !disablePassword || !disableCode) return
243
+
if (!session || !disablePassword || !disableCode) return
233
244
disableLoading = true
234
245
try {
235
-
await api.disableTotp(auth.session.accessJwt, disablePassword, disableCode)
246
+
await api.disableTotp(session.accessJwt, disablePassword, disableCode)
236
247
totpEnabled = false
237
248
hasBackupCodes = false
238
249
showDisableForm = false
239
250
disablePassword = ''
240
251
disableCode = ''
241
-
showMessage('success', $_('security.totpDisabledSuccess'))
252
+
toast.success($_('security.totpDisabledSuccess'))
242
253
} catch (e) {
243
-
showMessage('error', e instanceof ApiError ? e.message : 'Failed to disable TOTP')
254
+
toast.error(e instanceof ApiError ? e.message : 'Failed to disable TOTP')
244
255
} finally {
245
256
disableLoading = false
246
257
}
···
248
259
249
260
async function handleRegenerate(e: Event) {
250
261
e.preventDefault()
251
-
if (!auth.session || !regenPassword || !regenCode) return
262
+
if (!session || !regenPassword || !regenCode) return
252
263
regenLoading = true
253
264
try {
254
-
const result = await api.regenerateBackupCodes(auth.session.accessJwt, regenPassword, regenCode)
265
+
const result = await api.regenerateBackupCodes(session.accessJwt, regenPassword, regenCode)
255
266
backupCodes = result.backupCodes
256
267
setupStep = 'backup'
257
268
showRegenForm = false
258
269
regenPassword = ''
259
270
regenCode = ''
260
271
} catch (e) {
261
-
showMessage('error', e instanceof ApiError ? e.message : 'Failed to regenerate backup codes')
272
+
toast.error(e instanceof ApiError ? e.message : 'Failed to regenerate backup codes')
262
273
} finally {
263
274
regenLoading = false
264
275
}
···
267
278
function copyBackupCodes() {
268
279
const text = backupCodes.join('\n')
269
280
navigator.clipboard.writeText(text)
270
-
showMessage('success', $_('security.backupCodesCopied'))
281
+
toast.success($_('security.backupCodesCopied'))
271
282
}
272
283
273
284
async function loadPasskeys() {
274
-
if (!auth.session) return
285
+
if (!session) return
275
286
passkeysLoading = true
276
287
try {
277
-
const result = await api.listPasskeys(auth.session.accessJwt)
288
+
const result = await api.listPasskeys(session.accessJwt)
278
289
passkeys = result.passkeys
279
290
} catch {
280
-
showMessage('error', $_('security.failedToLoadPasskeys'))
291
+
toast.error($_('security.failedToLoadPasskeys'))
281
292
} finally {
282
293
passkeysLoading = false
283
294
}
284
295
}
285
296
286
297
async function handleAddPasskey() {
287
-
if (!auth.session) return
298
+
if (!session) return
288
299
if (!window.PublicKeyCredential) {
289
-
showMessage('error', $_('security.passkeysNotSupported'))
300
+
toast.error($_('security.passkeysNotSupported'))
290
301
return
291
302
}
292
303
addingPasskey = true
293
304
try {
294
-
const { options } = await api.startPasskeyRegistration(auth.session.accessJwt, newPasskeyName || undefined)
295
-
const publicKeyOptions = preparePublicKeyOptions(options)
305
+
const { options } = await api.startPasskeyRegistration(session.accessJwt, newPasskeyName || undefined)
306
+
const publicKeyOptions = prepareCreationOptions(options as WebAuthnCreationOptionsResponse)
296
307
const credential = await navigator.credentials.create({
297
308
publicKey: publicKeyOptions
298
309
})
299
310
if (!credential) {
300
-
showMessage('error', $_('security.passkeyCreationCancelled'))
311
+
toast.error($_('security.passkeyCreationCancelled'))
301
312
return
302
313
}
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)
314
+
const credentialResponse = serializeAttestationResponse(credential as PublicKeyCredential)
315
+
await api.finishPasskeyRegistration(session.accessJwt, credentialResponse, newPasskeyName || undefined)
313
316
await loadPasskeys()
314
317
newPasskeyName = ''
315
-
showMessage('success', $_('security.passkeyAddedSuccess'))
318
+
toast.success($_('security.passkeyAddedSuccess'))
316
319
} catch (e) {
317
320
if (e instanceof DOMException && e.name === 'NotAllowedError') {
318
-
showMessage('error', $_('security.passkeyCreationCancelled'))
321
+
toast.error($_('security.passkeyCreationCancelled'))
319
322
} else {
320
-
showMessage('error', e instanceof ApiError ? e.message : 'Failed to add passkey')
323
+
toast.error(e instanceof ApiError ? e.message : 'Failed to add passkey')
321
324
}
322
325
} finally {
323
326
addingPasskey = false
···
325
328
}
326
329
327
330
async function handleDeletePasskey(id: string) {
328
-
if (!auth.session) return
331
+
if (!session) return
329
332
const passkey = passkeys.find(p => p.id === id)
330
333
const name = passkey?.friendlyName || 'this passkey'
331
334
if (!confirm($_('security.deletePasskeyConfirm', { values: { name } }))) return
332
335
try {
333
-
await api.deletePasskey(auth.session.accessJwt, id)
336
+
await api.deletePasskey(session.accessJwt, id)
334
337
await loadPasskeys()
335
-
showMessage('success', $_('security.passkeyDeleted'))
338
+
toast.success($_('security.passkeyDeleted'))
336
339
} catch (e) {
337
-
showMessage('error', e instanceof ApiError ? e.message : 'Failed to delete passkey')
340
+
toast.error(e instanceof ApiError ? e.message : 'Failed to delete passkey')
338
341
}
339
342
}
340
343
341
344
async function handleSavePasskeyName() {
342
-
if (!auth.session || !editingPasskeyId || !editPasskeyName.trim()) return
345
+
if (!session || !editingPasskeyId || !editPasskeyName.trim()) return
343
346
try {
344
-
await api.updatePasskey(auth.session.accessJwt, editingPasskeyId, editPasskeyName.trim())
347
+
await api.updatePasskey(session.accessJwt, editingPasskeyId, editPasskeyName.trim())
345
348
await loadPasskeys()
346
349
editingPasskeyId = null
347
350
editPasskeyName = ''
348
-
showMessage('success', $_('security.passkeyRenamed'))
351
+
toast.success($_('security.passkeyRenamed'))
349
352
} catch (e) {
350
-
showMessage('error', e instanceof ApiError ? e.message : 'Failed to rename passkey')
353
+
toast.error(e instanceof ApiError ? e.message : 'Failed to rename passkey')
351
354
}
352
355
}
353
356
···
361
364
editPasskeyName = ''
362
365
}
363
366
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
367
function formatDate(dateStr: string): string {
400
368
return formatDateUtil(dateStr)
401
369
}
···
403
371
404
372
<div class="page">
405
373
<header>
406
-
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
374
+
<a href={getFullUrl(routes.dashboard)} class="back">{$_('common.backToDashboard')}</a>
407
375
<h1>{$_('security.title')}</h1>
408
376
</header>
409
377
410
-
{#if message}
411
-
<div class="message {message.type}">{message.text}</div>
412
-
{/if}
413
-
414
378
{#if loading}
415
-
<div class="loading">{$_('common.loading')}</div>
379
+
<div class="skeleton-grid">
380
+
{#each Array(4) as _}
381
+
<div class="skeleton-section"></div>
382
+
{/each}
383
+
</div>
416
384
{:else}
417
385
<div class="sections-grid">
418
386
<section>
···
594
562
{$_('security.passkeysDescription')}
595
563
</p>
596
564
597
-
{#if passkeysLoading}
598
-
<div class="loading">{$_('security.loadingPasskeys')}</div>
599
-
{:else}
565
+
{#if !passkeysLoading}
600
566
{#if passkeys.length > 0}
601
567
<div class="passkey-list">
602
568
{#each passkeys as passkey}
···
668
634
{$_('security.passwordDescription')}
669
635
</p>
670
636
671
-
{#if passwordLoading}
672
-
<div class="loading">{$_('common.loading')}</div>
673
-
{:else if hasPassword}
637
+
{#if !passwordLoading && hasPassword}
674
638
<div class="status enabled">
675
639
<span>{$_('security.passwordStatus')}</span>
676
640
</div>
···
722
686
<p class="description">
723
687
{$_('security.trustedDevicesDescription')}
724
688
</p>
725
-
<a href="/app/trusted-devices" class="section-link">
689
+
<a href={getFullUrl(routes.trustedDevices)} class="section-link">
726
690
{$_('security.manageTrustedDevices')} →
727
691
</a>
728
692
</section>
···
735
699
{$_('security.legacyLoginDescription')}
736
700
</p>
737
701
738
-
{#if legacyLoginLoading}
739
-
<div class="loading">{$_('common.loading')}</div>
740
-
{:else}
702
+
{#if !legacyLoginLoading}
741
703
<div class="toggle-row">
742
704
<div class="toggle-info">
743
705
<span class="toggle-label">{$_('security.legacyLogin')}</span>
···
765
727
<strong>{$_('security.legacyLoginWarning')}</strong>
766
728
<p>{$_('security.totpPasswordWarning')}</p>
767
729
<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>
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>
770
732
</ol>
771
733
</div>
772
734
{/if}
···
1221
1183
1222
1184
.warning-box a {
1223
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
+
}
1224
1210
}
1225
1211
</style>
+51
-24
frontend/src/routes/Sessions.svelte
+51
-24
frontend/src/routes/Sessions.svelte
···
1
1
<script lang="ts">
2
2
import { getAuthState } from '../lib/auth.svelte'
3
-
import { navigate } from '../lib/router.svelte'
3
+
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
4
4
import { api, ApiError } from '../lib/api'
5
5
import { _ } from '../lib/i18n'
6
6
import { formatDateTime } from '../lib/date'
7
-
const auth = getAuthState()
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())
8
22
let loading = $state(true)
9
-
let error = $state<string | null>(null)
10
23
let sessions = $state<Array<{
11
24
id: string
12
25
sessionType: string
···
16
29
isCurrent: boolean
17
30
}>>([])
18
31
$effect(() => {
19
-
if (!auth.loading && !auth.session) {
20
-
navigate('/login')
32
+
if (!authLoading && !session) {
33
+
navigate(routes.login)
21
34
}
22
35
})
23
36
$effect(() => {
24
-
if (auth.session) {
37
+
if (session) {
25
38
loadSessions()
26
39
}
27
40
})
28
41
async function loadSessions() {
29
-
if (!auth.session) return
42
+
if (!session) return
30
43
loading = true
31
-
error = null
32
44
try {
33
-
const result = await api.listSessions(auth.session.accessJwt)
45
+
const result = await api.listSessions(session.accessJwt)
34
46
sessions = result.sessions
35
47
} catch (e) {
36
-
error = e instanceof ApiError ? e.message : $_('sessions.failedToLoad')
48
+
toast.error(e instanceof ApiError ? e.message : $_('sessions.failedToLoad'))
37
49
} finally {
38
50
loading = false
39
51
}
40
52
}
41
53
async function revokeSession(sessionId: string, isCurrent: boolean) {
42
-
if (!auth.session) return
54
+
if (!session) return
43
55
const msg = isCurrent
44
56
? $_('sessions.revokeCurrentConfirm')
45
57
: $_('sessions.revokeConfirm')
46
58
if (!confirm(msg)) return
47
59
try {
48
-
await api.revokeSession(auth.session.accessJwt, sessionId)
60
+
await api.revokeSession(session.accessJwt, sessionId)
49
61
if (isCurrent) {
50
-
navigate('/login')
62
+
navigate(routes.login)
51
63
} else {
52
64
sessions = sessions.filter(s => s.id !== sessionId)
65
+
toast.success($_('sessions.sessionRevoked'))
53
66
}
54
67
} catch (e) {
55
-
error = e instanceof ApiError ? e.message : $_('sessions.failedToRevoke')
68
+
toast.error(e instanceof ApiError ? e.message : $_('sessions.failedToRevoke'))
56
69
}
57
70
}
58
71
async function revokeAllSessions() {
59
-
if (!auth.session) return
72
+
if (!session) return
60
73
const otherSessions = sessions.filter(s => !s.isCurrent)
61
74
if (otherSessions.length === 0) {
62
-
error = $_('sessions.noOtherSessions')
75
+
toast.warning($_('sessions.noOtherSessions'))
63
76
return
64
77
}
65
78
if (!confirm($_('sessions.revokeAllConfirm', { values: { count: otherSessions.length } }))) return
66
79
try {
67
-
await api.revokeAllSessions(auth.session.accessJwt)
80
+
await api.revokeAllSessions(session.accessJwt)
68
81
sessions = sessions.filter(s => s.isCurrent)
82
+
toast.success($_('sessions.allSessionsRevoked'))
69
83
} catch (e) {
70
-
error = e instanceof ApiError ? e.message : $_('sessions.failedToRevokeAll')
84
+
toast.error(e instanceof ApiError ? e.message : $_('sessions.failedToRevokeAll'))
71
85
}
72
86
}
73
87
function formatDate(dateStr: string): string {
···
88
102
</script>
89
103
<div class="page">
90
104
<header>
91
-
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
105
+
<a href={getFullUrl(routes.dashboard)} class="back">{$_('common.backToDashboard')}</a>
92
106
<h1>{$_('sessions.title')}</h1>
93
107
</header>
94
108
{#if loading}
95
-
<p class="loading">{$_('sessions.loadingSessions')}</p>
109
+
<div class="sessions-list">
110
+
{#each Array(3) as _}
111
+
<div class="skeleton-card"></div>
112
+
{/each}
113
+
</div>
96
114
{:else}
97
-
{#if error}
98
-
<div class="message error">{error}</div>
99
-
{/if}
100
115
{#if sessions.length === 0}
101
116
<p class="empty">{$_('sessions.noSessions')}</p>
102
117
{:else}
···
172
187
margin: var(--space-2) 0 0 0;
173
188
}
174
189
175
-
.loading,
176
190
.empty {
177
191
text-align: center;
178
192
color: var(--text-secondary);
179
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; }
180
207
}
181
208
182
209
.sessions-list {
+100
-97
frontend/src/routes/Settings.svelte
+100
-97
frontend/src/routes/Settings.svelte
···
1
1
<script lang="ts">
2
2
import { onMount } from 'svelte'
3
3
import { getAuthState, logout, refreshSession } from '../lib/auth.svelte'
4
-
import { navigate } from '../lib/router.svelte'
4
+
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
5
5
import { api, ApiError } from '../lib/api'
6
6
import { locale, setLocale, getSupportedLocales, localeNames, _, type SupportedLocale } from '../lib/i18n'
7
-
const auth = getAuthState()
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())
8
12
const supportedLocales = getSupportedLocales()
9
13
let pdsHostname = $state<string | null>(null)
10
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
+
11
26
onMount(() => {
12
27
api.describeServer().then(info => {
13
28
if (info.availableUserDomains?.length) {
···
15
30
}
16
31
}).catch(() => {})
17
32
})
33
+
18
34
let localeLoading = $state(false)
19
35
async function handleLocaleChange(newLocale: SupportedLocale) {
20
-
if (!auth.session) return
36
+
if (!session) return
21
37
setLocale(newLocale)
22
38
localeLoading = true
23
39
try {
24
-
await api.updateLocale(auth.session.accessJwt, newLocale)
40
+
await api.updateLocale(session.accessJwt, newLocale)
25
41
} catch (e) {
26
42
console.error('Failed to save locale preference:', e)
27
43
} finally {
28
44
localeLoading = false
29
45
}
30
46
}
31
-
let message = $state<{ type: 'success' | 'error'; text: string } | null>(null)
47
+
32
48
let emailLoading = $state(false)
33
49
let newEmail = $state('')
34
50
let emailToken = $state('')
···
46
62
let newPassword = $state('')
47
63
let confirmNewPassword = $state('')
48
64
let showBYOHandle = $state(false)
65
+
49
66
$effect(() => {
50
-
if (!auth.loading && !auth.session) {
51
-
navigate('/login')
67
+
if (!loading && !session) {
68
+
navigate(routes.login)
52
69
}
53
70
})
54
-
function showMessage(type: 'success' | 'error', text: string) {
55
-
message = { type, text }
56
-
setTimeout(() => {
57
-
if (message?.text === text) message = null
58
-
}, 5000)
59
-
}
71
+
60
72
async function handleRequestEmailUpdate() {
61
-
if (!auth.session) return
73
+
if (!session) return
62
74
emailLoading = true
63
-
message = null
64
75
try {
65
-
const result = await api.requestEmailUpdate(auth.session.accessJwt)
76
+
const result = await api.requestEmailUpdate(session.accessJwt)
66
77
emailTokenRequired = result.tokenRequired
67
78
if (emailTokenRequired) {
68
-
showMessage('success', $_('settings.messages.emailCodeSentToCurrent'))
79
+
toast.success($_('settings.messages.emailCodeSentToCurrent'))
69
80
} else {
70
81
emailTokenRequired = true
71
82
}
72
83
} catch (e) {
73
-
showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.emailUpdateFailed'))
84
+
toast.error(e instanceof ApiError ? e.message : $_('settings.messages.emailUpdateFailed'))
74
85
} finally {
75
86
emailLoading = false
76
87
}
77
88
}
89
+
78
90
async function handleConfirmEmailUpdate(e: Event) {
79
91
e.preventDefault()
80
-
if (!auth.session || !newEmail || !emailToken) return
92
+
if (!session || !newEmail || !emailToken) return
81
93
emailLoading = true
82
-
message = null
83
94
try {
84
-
await api.updateEmail(auth.session.accessJwt, newEmail, emailToken)
95
+
await api.updateEmail(session.accessJwt, newEmail, emailToken)
85
96
await refreshSession()
86
-
showMessage('success', $_('settings.messages.emailUpdated'))
97
+
toast.success($_('settings.messages.emailUpdated'))
87
98
newEmail = ''
88
99
emailToken = ''
89
100
emailTokenRequired = false
90
101
} catch (e) {
91
-
showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.emailUpdateFailed'))
102
+
toast.error(e instanceof ApiError ? e.message : $_('settings.messages.emailUpdateFailed'))
92
103
} finally {
93
104
emailLoading = false
94
105
}
95
106
}
107
+
96
108
async function handleUpdateHandle(e: Event) {
97
109
e.preventDefault()
98
-
if (!auth.session || !newHandle) return
110
+
if (!session || !newHandle) return
99
111
handleLoading = true
100
-
message = null
101
112
try {
102
113
const fullHandle = showBYOHandle
103
114
? newHandle
104
115
: `${newHandle}.${pdsHostname}`
105
-
await api.updateHandle(auth.session.accessJwt, fullHandle)
116
+
await api.updateHandle(session.accessJwt, fullHandle)
106
117
await refreshSession()
107
-
showMessage('success', $_('settings.messages.handleUpdated'))
118
+
toast.success($_('settings.messages.handleUpdated'))
108
119
newHandle = ''
109
120
} catch (e) {
110
-
showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.handleUpdateFailed'))
121
+
toast.error(e instanceof ApiError ? e.message : $_('settings.messages.handleUpdateFailed'))
111
122
} finally {
112
123
handleLoading = false
113
124
}
114
125
}
126
+
115
127
async function handleRequestDelete() {
116
-
if (!auth.session) return
128
+
if (!session) return
117
129
deleteLoading = true
118
-
message = null
119
130
try {
120
-
await api.requestAccountDelete(auth.session.accessJwt)
131
+
await api.requestAccountDelete(session.accessJwt)
121
132
deleteTokenSent = true
122
-
showMessage('success', $_('settings.messages.deletionConfirmationSent'))
133
+
toast.success($_('settings.messages.deletionConfirmationSent'))
123
134
} catch (e) {
124
-
showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.deletionRequestFailed'))
135
+
toast.error(e instanceof ApiError ? e.message : $_('settings.messages.deletionRequestFailed'))
125
136
} finally {
126
137
deleteLoading = false
127
138
}
128
139
}
140
+
129
141
async function handleConfirmDelete(e: Event) {
130
142
e.preventDefault()
131
-
if (!auth.session || !deletePassword || !deleteToken) return
143
+
if (!session || !deletePassword || !deleteToken) return
132
144
if (!confirm($_('settings.messages.deleteConfirmation'))) {
133
145
return
134
146
}
135
147
deleteLoading = true
136
-
message = null
137
148
try {
138
-
await api.deleteAccount(auth.session.did, deletePassword, deleteToken)
149
+
await api.deleteAccount(session.did, deletePassword, deleteToken)
139
150
await logout()
140
-
navigate('/login')
151
+
navigate(routes.login)
141
152
} catch (e) {
142
-
showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.deletionFailed'))
153
+
toast.error(e instanceof ApiError ? e.message : $_('settings.messages.deletionFailed'))
143
154
} finally {
144
155
deleteLoading = false
145
156
}
146
157
}
158
+
147
159
async function handleExportRepo() {
148
-
if (!auth.session) return
160
+
if (!session) return
149
161
exportLoading = true
150
-
message = null
151
162
try {
152
-
const response = await fetch(`/xrpc/com.atproto.sync.getRepo?did=${encodeURIComponent(auth.session.did)}`, {
163
+
const response = await fetch(`/xrpc/com.atproto.sync.getRepo?did=${encodeURIComponent(session.did)}`, {
153
164
headers: {
154
-
'Authorization': `Bearer ${auth.session.accessJwt}`
165
+
'Authorization': `Bearer ${session.accessJwt}`
155
166
}
156
167
})
157
168
if (!response.ok) {
···
162
173
const url = URL.createObjectURL(blob)
163
174
const a = document.createElement('a')
164
175
a.href = url
165
-
a.download = `${auth.session.handle}-repo.car`
176
+
a.download = `${session.handle}-repo.car`
166
177
document.body.appendChild(a)
167
178
a.click()
168
179
document.body.removeChild(a)
169
180
URL.revokeObjectURL(url)
170
-
showMessage('success', $_('settings.messages.repoExported'))
181
+
toast.success($_('settings.messages.repoExported'))
171
182
} catch (e) {
172
-
showMessage('error', e instanceof Error ? e.message : $_('settings.messages.exportFailed'))
183
+
toast.error(e instanceof Error ? e.message : $_('settings.messages.exportFailed'))
173
184
} finally {
174
185
exportLoading = false
175
186
}
176
187
}
188
+
177
189
async function handleExportBlobs() {
178
-
if (!auth.session) return
190
+
if (!session) return
179
191
exportBlobsLoading = true
180
-
message = null
181
192
try {
182
193
const response = await fetch('/xrpc/_backup.exportBlobs', {
183
194
headers: {
184
-
'Authorization': `Bearer ${auth.session.accessJwt}`
195
+
'Authorization': `Bearer ${session.accessJwt}`
185
196
}
186
197
})
187
198
if (!response.ok) {
···
190
201
}
191
202
const blob = await response.blob()
192
203
if (blob.size === 0) {
193
-
showMessage('success', $_('settings.messages.noBlobsToExport'))
204
+
toast.success($_('settings.messages.noBlobsToExport'))
194
205
return
195
206
}
196
207
const url = URL.createObjectURL(blob)
197
208
const a = document.createElement('a')
198
209
a.href = url
199
-
a.download = `${auth.session.handle}-blobs.zip`
210
+
a.download = `${session.handle}-blobs.zip`
200
211
document.body.appendChild(a)
201
212
a.click()
202
213
document.body.removeChild(a)
203
214
URL.revokeObjectURL(url)
204
-
showMessage('success', $_('settings.messages.blobsExported'))
215
+
toast.success($_('settings.messages.blobsExported'))
205
216
} catch (e) {
206
-
showMessage('error', e instanceof Error ? e.message : $_('settings.messages.exportFailed'))
217
+
toast.error(e instanceof Error ? e.message : $_('settings.messages.exportFailed'))
207
218
} finally {
208
219
exportBlobsLoading = false
209
220
}
···
225
236
let restoreLoading = $state(false)
226
237
227
238
async function loadBackups() {
228
-
if (!auth.session) return
239
+
if (!session) return
229
240
backupsLoading = true
230
241
try {
231
-
const result = await api.listBackups(auth.session.accessJwt)
242
+
const result = await api.listBackups(session.accessJwt)
232
243
backups = result.backups
233
244
backupEnabled = result.backupEnabled
234
245
} catch (e) {
···
243
254
})
244
255
245
256
async function handleToggleBackup() {
246
-
if (!auth.session) return
257
+
if (!session) return
247
258
const newEnabled = !backupEnabled
248
259
backupsLoading = true
249
260
try {
250
-
await api.setBackupEnabled(auth.session.accessJwt, newEnabled)
261
+
await api.setBackupEnabled(session.accessJwt, newEnabled)
251
262
backupEnabled = newEnabled
252
-
showMessage('success', newEnabled ? $_('settings.backups.enabled') : $_('settings.backups.disabled'))
263
+
toast.success(newEnabled ? $_('settings.backups.enabled') : $_('settings.backups.disabled'))
253
264
} catch (e) {
254
-
showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.toggleFailed'))
265
+
toast.error(e instanceof ApiError ? e.message : $_('settings.backups.toggleFailed'))
255
266
} finally {
256
267
backupsLoading = false
257
268
}
258
269
}
259
270
260
271
async function handleCreateBackup() {
261
-
if (!auth.session) return
272
+
if (!session) return
262
273
createBackupLoading = true
263
-
message = null
264
274
try {
265
-
await api.createBackup(auth.session.accessJwt)
275
+
await api.createBackup(session.accessJwt)
266
276
await loadBackups()
267
-
showMessage('success', $_('settings.backups.created'))
277
+
toast.success($_('settings.backups.created'))
268
278
} catch (e) {
269
-
showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.createFailed'))
279
+
toast.error(e instanceof ApiError ? e.message : $_('settings.backups.createFailed'))
270
280
} finally {
271
281
createBackupLoading = false
272
282
}
273
283
}
274
284
275
285
async function handleDownloadBackup(id: string, rev: string) {
276
-
if (!auth.session) return
286
+
if (!session) return
277
287
try {
278
-
const blob = await api.getBackup(auth.session.accessJwt, id)
288
+
const blob = await api.getBackup(session.accessJwt, id)
279
289
const url = URL.createObjectURL(blob)
280
290
const a = document.createElement('a')
281
291
a.href = url
282
-
a.download = `${auth.session.handle}-${rev}.car`
292
+
a.download = `${session.handle}-${rev}.car`
283
293
document.body.appendChild(a)
284
294
a.click()
285
295
document.body.removeChild(a)
286
296
URL.revokeObjectURL(url)
287
297
} catch (e) {
288
-
showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.downloadFailed'))
298
+
toast.error(e instanceof ApiError ? e.message : $_('settings.backups.downloadFailed'))
289
299
}
290
300
}
291
301
292
302
async function handleDeleteBackup(id: string) {
293
-
if (!auth.session) return
303
+
if (!session) return
294
304
try {
295
-
await api.deleteBackup(auth.session.accessJwt, id)
305
+
await api.deleteBackup(session.accessJwt, id)
296
306
await loadBackups()
297
-
showMessage('success', $_('settings.backups.deleted'))
307
+
toast.success($_('settings.backups.deleted'))
298
308
} catch (e) {
299
-
showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.deleteFailed'))
309
+
toast.error(e instanceof ApiError ? e.message : $_('settings.backups.deleteFailed'))
300
310
}
301
311
}
302
312
···
308
318
}
309
319
310
320
async function handleRestore() {
311
-
if (!auth.session || !restoreFile) return
321
+
if (!session || !restoreFile) return
312
322
restoreLoading = true
313
-
message = null
314
323
try {
315
324
const buffer = await restoreFile.arrayBuffer()
316
325
const car = new Uint8Array(buffer)
317
-
await api.importRepo(auth.session.accessJwt, car)
318
-
showMessage('success', $_('settings.backups.restored'))
326
+
await api.importRepo(session.accessJwt, car)
327
+
toast.success($_('settings.backups.restored'))
319
328
restoreFile = null
320
329
} catch (e) {
321
-
showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.restoreFailed'))
330
+
toast.error(e instanceof ApiError ? e.message : $_('settings.backups.restoreFailed'))
322
331
} finally {
323
332
restoreLoading = false
324
333
}
···
342
351
343
352
async function handleChangePassword(e: Event) {
344
353
e.preventDefault()
345
-
if (!auth.session || !currentPassword || !newPassword || !confirmNewPassword) return
354
+
if (!session || !currentPassword || !newPassword || !confirmNewPassword) return
346
355
if (newPassword !== confirmNewPassword) {
347
-
showMessage('error', $_('settings.messages.passwordsDoNotMatch'))
356
+
toast.error($_('settings.messages.passwordsDoNotMatch'))
348
357
return
349
358
}
350
359
if (newPassword.length < 8) {
351
-
showMessage('error', $_('settings.messages.passwordTooShort'))
360
+
toast.error($_('settings.messages.passwordTooShort'))
352
361
return
353
362
}
354
363
passwordLoading = true
355
-
message = null
356
364
try {
357
-
await api.changePassword(auth.session.accessJwt, currentPassword, newPassword)
358
-
showMessage('success', $_('settings.messages.passwordChanged'))
365
+
await api.changePassword(session.accessJwt, currentPassword, newPassword)
366
+
toast.success($_('settings.messages.passwordChanged'))
359
367
currentPassword = ''
360
368
newPassword = ''
361
369
confirmNewPassword = ''
362
370
} catch (e) {
363
-
showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.passwordChangeFailed'))
371
+
toast.error(e instanceof ApiError ? e.message : $_('settings.messages.passwordChangeFailed'))
364
372
} finally {
365
373
passwordLoading = false
366
374
}
···
368
376
</script>
369
377
<div class="page">
370
378
<header>
371
-
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
379
+
<a href={getFullUrl(routes.dashboard)} class="back">{$_('common.backToDashboard')}</a>
372
380
<h1>{$_('settings.title')}</h1>
373
381
</header>
374
-
{#if message}
375
-
<div class="message {message.type}">{message.text}</div>
376
-
{/if}
377
382
<div class="sections-grid">
378
383
<section>
379
384
<h2>{$_('settings.language')}</h2>
···
391
396
</section>
392
397
<section>
393
398
<h2>{$_('settings.changeEmail')}</h2>
394
-
{#if auth.session?.email}
395
-
<p class="current">{$_('settings.currentEmail', { values: { email: auth.session.email } })}</p>
399
+
{#if session?.email}
400
+
<p class="current">{$_('settings.currentEmail', { values: { email: session.email } })}</p>
396
401
{/if}
397
402
{#if emailTokenRequired}
398
403
<form onsubmit={handleConfirmEmailUpdate}>
···
435
440
</section>
436
441
<section>
437
442
<h2>{$_('settings.changeHandle')}</h2>
438
-
{#if auth.session}
439
-
<p class="current">{$_('settings.currentHandle', { values: { handle: auth.session.handle } })}</p>
443
+
{#if session}
444
+
<p class="current">{$_('settings.currentHandle', { values: { handle: session.handle } })}</p>
440
445
{/if}
441
446
<div class="tabs">
442
447
<button
···
459
464
{#if showBYOHandle}
460
465
<div class="byo-handle">
461
466
<p class="description">{$_('settings.customDomainDescription')}</p>
462
-
{#if auth.session}
467
+
{#if session}
463
468
<div class="verification-info">
464
469
<h3>{$_('settings.setupInstructions')}</h3>
465
470
<p>{$_('settings.setupMethodsIntro')}</p>
466
471
<div class="method">
467
472
<h4>{$_('settings.dnsMethod')}</h4>
468
473
<p>{$_('settings.dnsMethodDesc')}</p>
469
-
<code class="record">_atproto.{newHandle || 'yourdomain.com'} TXT "did={auth.session.did}"</code>
474
+
<code class="record">_atproto.{newHandle || 'yourdomain.com'} TXT "did={session.did}"</code>
470
475
</div>
471
476
<div class="method">
472
477
<h4>{$_('settings.httpMethod')}</h4>
473
478
<p>{$_('settings.httpMethodDesc')}</p>
474
479
<code class="record">https://{newHandle || 'yourdomain.com'}/.well-known/atproto-did</code>
475
480
<p>{$_('settings.httpMethodContent')}</p>
476
-
<code class="record">{auth.session.did}</code>
481
+
<code class="record">{session.did}</code>
477
482
</div>
478
483
</div>
479
484
{/if}
···
579
584
<span>{$_('settings.backups.enableAutomatic')}</span>
580
585
</label>
581
586
582
-
{#if backupsLoading}
583
-
<p class="loading">{$_('common.loading')}</p>
584
-
{:else if backups.length > 0}
587
+
{#if !backupsLoading && backups.length > 0}
585
588
<ul class="backup-list">
586
589
{#each backups as backup}
587
590
<li class="backup-item">
+54
-30
frontend/src/routes/TrustedDevices.svelte
+54
-30
frontend/src/routes/TrustedDevices.svelte
···
1
1
<script lang="ts">
2
2
import { getAuthState } from '../lib/auth.svelte'
3
-
import { navigate } from '../lib/router.svelte'
3
+
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
4
4
import { api, ApiError } from '../lib/api'
5
5
import { _ } from '../lib/i18n'
6
6
import { formatDateTime } from '../lib/date'
7
+
import type { Session } from '../lib/types/api'
8
+
import { toast } from '../lib/toast.svelte'
7
9
8
10
interface TrustedDevice {
9
11
id: string
···
14
16
lastSeenAt: string
15
17
}
16
18
17
-
const auth = getAuthState()
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())
18
31
let devices = $state<TrustedDevice[]>([])
19
32
let loading = $state(true)
20
-
let message = $state<{ type: 'success' | 'error'; text: string } | null>(null)
21
33
let editingDeviceId = $state<string | null>(null)
22
34
let editDeviceName = $state('')
23
35
24
36
$effect(() => {
25
-
if (!auth.loading && !auth.session) {
26
-
navigate('/login')
37
+
if (!authLoading && !session) {
38
+
navigate(routes.login)
27
39
}
28
40
})
29
41
30
42
$effect(() => {
31
-
if (auth.session) {
43
+
if (session) {
32
44
loadDevices()
33
45
}
34
46
})
35
47
36
48
async function loadDevices() {
37
-
if (!auth.session) return
49
+
if (!session) return
38
50
loading = true
39
51
try {
40
-
const result = await api.listTrustedDevices(auth.session.accessJwt)
52
+
const result = await api.listTrustedDevices(session.accessJwt)
41
53
devices = result.devices
42
54
} catch {
43
-
showMessage('error', $_('trustedDevices.failedToLoad'))
55
+
toast.error($_('trustedDevices.failedToLoad'))
44
56
} finally {
45
57
loading = false
46
58
}
47
59
}
48
60
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
61
async function handleRevoke(deviceId: string) {
57
-
if (!auth.session) return
62
+
if (!session) return
58
63
if (!confirm($_('trustedDevices.revokeConfirm'))) return
59
64
try {
60
-
await api.revokeTrustedDevice(auth.session.accessJwt, deviceId)
65
+
await api.revokeTrustedDevice(session.accessJwt, deviceId)
61
66
await loadDevices()
62
-
showMessage('success', $_('trustedDevices.deviceRevoked'))
67
+
toast.success($_('trustedDevices.deviceRevoked'))
63
68
} catch (e) {
64
-
showMessage('error', e instanceof ApiError ? e.message : $_('common.error'))
69
+
toast.error(e instanceof ApiError ? e.message : $_('common.error'))
65
70
}
66
71
}
67
72
···
76
81
}
77
82
78
83
async function handleSaveDeviceName() {
79
-
if (!auth.session || !editingDeviceId || !editDeviceName.trim()) return
84
+
if (!session || !editingDeviceId || !editDeviceName.trim()) return
80
85
try {
81
-
await api.updateTrustedDevice(auth.session.accessJwt, editingDeviceId, editDeviceName.trim())
86
+
await api.updateTrustedDevice(session.accessJwt, editingDeviceId, editDeviceName.trim())
82
87
await loadDevices()
83
88
editingDeviceId = null
84
89
editDeviceName = ''
85
-
showMessage('success', $_('trustedDevices.deviceRenamed'))
90
+
toast.success($_('trustedDevices.deviceRenamed'))
86
91
} catch (e) {
87
-
showMessage('error', e instanceof ApiError ? e.message : $_('common.error'))
92
+
toast.error(e instanceof ApiError ? e.message : $_('common.error'))
88
93
}
89
94
}
90
95
···
112
117
113
118
<div class="page">
114
119
<header>
115
-
<a href="/app/security" class="back">{$_('trustedDevices.backToSecurity')}</a>
120
+
<a href={getFullUrl(routes.security)} class="back">{$_('trustedDevices.backToSecurity')}</a>
116
121
<h1>{$_('trustedDevices.title')}</h1>
117
122
</header>
118
-
119
-
{#if message}
120
-
<div class="message {message.type}">{message.text}</div>
121
-
{/if}
122
123
123
124
<div class="description">
124
125
<p>
···
127
128
</div>
128
129
129
130
{#if loading}
130
-
<div class="loading">{$_('common.loading')}</div>
131
+
<div class="skeleton-list">
132
+
{#each Array(2) as _}
133
+
<div class="skeleton-card"></div>
134
+
{/each}
135
+
</div>
131
136
{:else if devices.length === 0}
132
137
<div class="empty-state">
133
138
<p>{$_('trustedDevices.noDevices')}</p>
···
378
383
379
384
.btn-danger:hover {
380
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; }
381
405
}
382
406
</style>
+20
-19
frontend/src/routes/Verify.svelte
+20
-19
frontend/src/routes/Verify.svelte
···
2
2
import { onMount } from 'svelte'
3
3
import { confirmSignup, resendVerification, getAuthState } from '../lib/auth.svelte'
4
4
import { api, ApiError } from '../lib/api'
5
-
import { navigate } from '../lib/router.svelte'
5
+
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
6
6
import { _ } from '../lib/i18n'
7
+
import type { Session } from '../lib/types/api'
7
8
8
9
const STORAGE_KEY = 'tranquil_pds_pending_verification'
9
10
···
29
30
let successPurpose = $state<string | null>(null)
30
31
let successChannel = $state<string | null>(null)
31
32
32
-
const auth = getAuthState()
33
+
const auth = $derived(getAuthState())
33
34
35
+
function getSession(): Session | null {
36
+
return auth.kind === 'authenticated' ? auth.session : null
37
+
}
34
38
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
39
+
const session = $derived(getSession())
40
+
41
+
function parseQueryParams(): Record<string, string> {
42
+
return Object.fromEntries(new URLSearchParams(window.location.search))
42
43
}
43
44
44
45
onMount(async () => {
···
74
75
})
75
76
76
77
$effect(() => {
77
-
if (mode === 'signup' && auth.session) {
78
+
if (mode === 'signup' && session) {
78
79
clearPendingVerification()
79
-
navigate('/dashboard')
80
+
navigate(routes.dashboard)
80
81
}
81
82
})
82
83
···
96
97
await confirmSignup(pendingVerification.did, verificationCode.trim())
97
98
clearPendingVerification()
98
99
navigate('/dashboard')
99
-
} catch (e: any) {
100
-
error = e.message || 'Verification failed'
100
+
} catch (e) {
101
+
error = e instanceof Error ? e.message : 'Verification failed'
101
102
} finally {
102
103
submitting = false
103
104
}
···
118
119
success = true
119
120
successPurpose = result.purpose
120
121
successChannel = result.channel
121
-
} catch (e: any) {
122
+
} catch (e) {
122
123
if (e instanceof ApiError) {
123
124
if (e.error === 'AuthenticationRequired') {
124
125
error = 'You must be signed in to complete this verification. Please sign in and try again.'
···
149
150
success = true
150
151
successPurpose = 'email-update'
151
152
successChannel = 'email'
152
-
} catch (e: any) {
153
+
} catch (e) {
153
154
if (e instanceof ApiError) {
154
155
error = e.message
155
156
} else {
···
171
172
try {
172
173
await resendVerification(pendingVerification.did)
173
174
resendMessage = $_('verify.codeResent')
174
-
} catch (e: any) {
175
-
error = e.message || 'Failed to resend code'
175
+
} catch (e) {
176
+
error = e instanceof Error ? e.message : 'Failed to resend code'
176
177
} finally {
177
178
resendingCode = false
178
179
}
···
186
187
try {
187
188
await api.resendMigrationVerification(identifier.trim())
188
189
resendMessage = $_('verify.codeResentDetail')
189
-
} catch (e: any) {
190
-
error = e.message || 'Failed to resend verification'
190
+
} catch (e) {
191
+
error = e instanceof Error ? e.message : 'Failed to resend verification'
191
192
} finally {
192
193
resendingCode = false
193
194
}
+2
-2
src/api/error.rs
+2
-2
src/api/error.rs
···
128
128
| Self::AccountTakedown
129
129
| Self::InvalidCode(_)
130
130
| Self::InvalidPassword(_)
131
+
| Self::InvalidToken(_)
132
+
| Self::ExpiredToken(_)
131
133
| Self::PasskeyCounterAnomaly => StatusCode::UNAUTHORIZED,
132
134
Self::Forbidden
133
135
| Self::AdminRequired
···
196
198
| Self::InvalidVerificationChannel
197
199
| Self::SelfHostedDidWebDisabled
198
200
| Self::AccountAlreadyExists
199
-
| Self::InvalidToken(_)
200
-
| Self::ExpiredToken(_)
201
201
| Self::TokenRequired => StatusCode::BAD_REQUEST,
202
202
Self::PasskeyNotFound => StatusCode::NOT_FOUND,
203
203
}