Our Personal Data Server from scratch! tranquil.farm
pds rust database fun oauth atproto
238
fork

Configure Feed

Select the types of activity you want to include in your feed.

More functional and typesafe frontend

+5901 -2477
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 36 36 "blobs, cursor:", 37 37 nextCursor, 38 38 ); 39 - for (const blob of blobs) { 40 - missingBlobs.push(blob.cid); 41 - } 39 + missingBlobs.push(...blobs.map((blob) => blob.cid)); 42 40 cursor = nextCursor; 43 41 } while (cursor); 44 42
+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 <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 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 1 + export * from './result' 2 + export * from './branded' 3 + export * from './exhaustive' 4 + export * from './api' 5 + export * from './routes'
+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
··· 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
··· 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
··· 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
··· 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
··· 1 + export * from './option' 2 + export * from './array' 3 + export * from './async'
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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> &middot; <a href="/app/request-passkey-recovery">{$_('login.lostPasskey')}</a> 423 + <a href={getFullUrl(routes.resetPassword)}>{$_('login.forgotPassword')}</a> &middot; <a href={getFullUrl(routes.requestPasskeyRecovery)}>{$_('login.lostPasskey')}</a> 460 424 </p> 461 425 </div> 462 426
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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')} &rarr; 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
··· 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
··· 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
··· 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
··· 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
··· 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 }