this repo has no description

More functional and typesafe frontend

lewis dcdb8e28 6404b1dc

+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 }