Our Personal Data Server from scratch! tranquil.farm
oauth atproto pds rust postgresql objectstorage fun

Frontend type-safety improvements #6

merged opened by lewis.moe targeting main from fix/frontend-type-safety-improvements

It should be impossible to call authenticated endpoints in unauthed contexts, ya know?

Labels

None yet.

assignee
Participants 1
Referenced by
AT URI
at://did:plc:3fwecdnvtcscjnrx2p4n7alz/sh.tangled.repo.pull/3mddxypxq5f22
+1676 -893
Diff #1
+4
crates/tranquil-pds/src/api/server/service_auth.rs
··· 113 113 ) 114 114 .into_response(); 115 115 } 116 + Err(crate::oauth::OAuthError::ExpiredToken(msg)) => { 117 + warn!(error = %msg, "getServiceAuth DPoP token expired"); 118 + return ApiError::OAuthExpiredToken(Some(msg)).into_response(); 119 + } 116 120 Err(e) => { 117 121 warn!(error = ?e, "getServiceAuth DPoP auth validation failed"); 118 122 return ApiError::AuthenticationFailed(Some(format!("{:?}", e))).into_response();
+328 -5
frontend/deno.lock
··· 11 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 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:knip@*": "5.82.1_@types+node@25.0.3_typescript@5.9.3", 14 15 "npm:multiformats@^13.4.2": "13.4.2", 15 16 "npm:svelte-check@*": "4.3.5_svelte@5.46.1__acorn@8.15.0_typescript@5.9.3", 16 17 "npm:svelte-check@^4.3.5": "4.3.5_svelte@5.46.1__acorn@8.15.0_typescript@5.9.3", ··· 156 157 "@csstools/css-tokenizer@3.0.4": { 157 158 "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==" 158 159 }, 160 + "@emnapi/core@1.8.1": { 161 + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", 162 + "dependencies": [ 163 + "@emnapi/wasi-threads", 164 + "tslib" 165 + ] 166 + }, 167 + "@emnapi/runtime@1.8.1": { 168 + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", 169 + "dependencies": [ 170 + "tslib" 171 + ] 172 + }, 173 + "@emnapi/wasi-threads@1.1.0": { 174 + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", 175 + "dependencies": [ 176 + "tslib" 177 + ] 178 + }, 159 179 "@esbuild/aix-ppc64@0.19.12": { 160 180 "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", 161 181 "os": ["aix"], ··· 464 484 "@jridgewell/sourcemap-codec" 465 485 ] 466 486 }, 487 + "@napi-rs/wasm-runtime@1.1.1": { 488 + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", 489 + "dependencies": [ 490 + "@emnapi/core", 491 + "@emnapi/runtime", 492 + "@tybys/wasm-util" 493 + ] 494 + }, 467 495 "@noble/secp256k1@3.0.0": { 468 496 "integrity": "sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg==" 469 497 }, 498 + "@nodelib/fs.scandir@2.1.5": { 499 + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", 500 + "dependencies": [ 501 + "@nodelib/fs.stat", 502 + "run-parallel" 503 + ] 504 + }, 505 + "@nodelib/fs.stat@2.0.5": { 506 + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==" 507 + }, 508 + "@nodelib/fs.walk@1.2.8": { 509 + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", 510 + "dependencies": [ 511 + "@nodelib/fs.scandir", 512 + "fastq" 513 + ] 514 + }, 515 + "@oxc-resolver/binding-android-arm-eabi@11.16.4": { 516 + "integrity": "sha512-6XUHilmj8D6Ggus+sTBp64x/DUQ7LgC/dvTDdUOt4iMQnDdSep6N1mnvVLIiG+qM5tRnNHravNzBJnUlYwRQoA==", 517 + "os": ["android"], 518 + "cpu": ["arm"] 519 + }, 520 + "@oxc-resolver/binding-android-arm64@11.16.4": { 521 + "integrity": "sha512-5ODwd1F5mdkm6JIg1CNny9yxIrCzrkKpxmqas7Alw23vE0Ot8D4ykqNBW5Z/nIZkXVEo5VDmnm0sMBBIANcpeQ==", 522 + "os": ["android"], 523 + "cpu": ["arm64"] 524 + }, 525 + "@oxc-resolver/binding-darwin-arm64@11.16.4": { 526 + "integrity": "sha512-egwvDK9DMU4Q8F4BG74/n4E22pQ0lT5ukOVB6VXkTj0iG2fnyoStHoFaBnmDseLNRA4r61Mxxz8k940CIaJMDg==", 527 + "os": ["darwin"], 528 + "cpu": ["arm64"] 529 + }, 530 + "@oxc-resolver/binding-darwin-x64@11.16.4": { 531 + "integrity": "sha512-HMkODYrAG4HaFNCpaYzSQFkxeiz2wzl+smXwxeORIQVEo1WAgUrWbvYT/0RNJg/A8z2aGMGK5KWTUr2nX5GiMw==", 532 + "os": ["darwin"], 533 + "cpu": ["x64"] 534 + }, 535 + "@oxc-resolver/binding-freebsd-x64@11.16.4": { 536 + "integrity": "sha512-mkcKhIdSlUqnndD928WAVVFMEr1D5EwHOBGHadypW0PkM0h4pn89ZacQvU7Qs/Z2qquzvbyw8m4Mq3jOYI+4Dw==", 537 + "os": ["freebsd"], 538 + "cpu": ["x64"] 539 + }, 540 + "@oxc-resolver/binding-linux-arm-gnueabihf@11.16.4": { 541 + "integrity": "sha512-ZJvzbmXI/cILQVcJL9S2Fp7GLAIY4Yr6mpGb+k6LKLUSEq85yhG+rJ9eWCqgULVIf2BFps/NlmPTa7B7oj8jhQ==", 542 + "os": ["linux"], 543 + "cpu": ["arm"] 544 + }, 545 + "@oxc-resolver/binding-linux-arm-musleabihf@11.16.4": { 546 + "integrity": "sha512-iZUB0W52uB10gBUDAi79eTnzqp1ralikCAjfq7CdokItwZUVJXclNYANnzXmtc0Xr0ox+YsDsG2jGcj875SatA==", 547 + "os": ["linux"], 548 + "cpu": ["arm"] 549 + }, 550 + "@oxc-resolver/binding-linux-arm64-gnu@11.16.4": { 551 + "integrity": "sha512-qNQk0H6q1CnwS9cnvyjk9a+JN8BTbxK7K15Bb5hYfJcKTG1hfloQf6egndKauYOO0wu9ldCMPBrEP1FNIQEhaA==", 552 + "os": ["linux"], 553 + "cpu": ["arm64"] 554 + }, 555 + "@oxc-resolver/binding-linux-arm64-musl@11.16.4": { 556 + "integrity": "sha512-wEXSaEaYxGGoVSbw0i2etjDDWcqErKr8xSkTdwATP798efsZmodUAcLYJhN0Nd4W35Oq6qAvFGHpKwFrrhpTrA==", 557 + "os": ["linux"], 558 + "cpu": ["arm64"] 559 + }, 560 + "@oxc-resolver/binding-linux-ppc64-gnu@11.16.4": { 561 + "integrity": "sha512-CUFOlpb07DVOFLoYiaTfbSBRPIhNgwc/MtlYeg3p6GJJw+kEm/vzc9lohPSjzF2MLPB5hzsJdk+L/GjrTT3UPw==", 562 + "os": ["linux"], 563 + "cpu": ["ppc64"] 564 + }, 565 + "@oxc-resolver/binding-linux-riscv64-gnu@11.16.4": { 566 + "integrity": "sha512-d8It4AH8cN9ReK1hW6ZO4x3rMT0hB2LYH0RNidGogV9xtnjLRU+Y3MrCeClLyOSGCibmweJJAjnwB7AQ31GEhg==", 567 + "os": ["linux"], 568 + "cpu": ["riscv64"] 569 + }, 570 + "@oxc-resolver/binding-linux-riscv64-musl@11.16.4": { 571 + "integrity": "sha512-d09dOww9iKyEHSxuOQ/Iu2aYswl0j7ExBcyy14D6lJ5ijQSP9FXcJYJsJ3yvzboO/PDEFjvRuF41f8O1skiPVg==", 572 + "os": ["linux"], 573 + "cpu": ["riscv64"] 574 + }, 575 + "@oxc-resolver/binding-linux-s390x-gnu@11.16.4": { 576 + "integrity": "sha512-lhjyGmUzTWHduZF3MkdUSEPMRIdExnhsqv8u1upX3A15epVn6YVwv4msFQPJl1x1wszkACPeDHGOtzHsITXGdw==", 577 + "os": ["linux"], 578 + "cpu": ["s390x"] 579 + }, 580 + "@oxc-resolver/binding-linux-x64-gnu@11.16.4": { 581 + "integrity": "sha512-ZtqqiI5rzlrYBm/IMMDIg3zvvVj4WO/90Dg/zX+iA8lWaLN7K5nroXb17MQ4WhI5RqlEAgrnYDXW+hok1D9Kaw==", 582 + "os": ["linux"], 583 + "cpu": ["x64"] 584 + }, 585 + "@oxc-resolver/binding-linux-x64-musl@11.16.4": { 586 + "integrity": "sha512-LM424h7aaKcMlqHnQWgTzO+GRNLyjcNnMpqm8SygEtFRVW693XS+XGXYvjORlmJtsyjo84ej1FMb3U2HE5eyjg==", 587 + "os": ["linux"], 588 + "cpu": ["x64"] 589 + }, 590 + "@oxc-resolver/binding-openharmony-arm64@11.16.4": { 591 + "integrity": "sha512-8w8U6A5DDWTBv3OUxSD9fNk37liZuEC5jnAc9wQRv9DeYKAXvuUtBfT09aIZ58swaci0q1WS48/CoMVEO6jdCA==", 592 + "os": ["openharmony"], 593 + "cpu": ["arm64"] 594 + }, 595 + "@oxc-resolver/binding-wasm32-wasi@11.16.4": { 596 + "integrity": "sha512-hnjb0mDVQOon6NdfNJ1EmNquonJUjoYkp7UyasjxVa4iiMcApziHP4czzzme6WZbp+vzakhVv2Yi5ACTon3Zlw==", 597 + "dependencies": [ 598 + "@napi-rs/wasm-runtime" 599 + ], 600 + "cpu": ["wasm32"] 601 + }, 602 + "@oxc-resolver/binding-win32-arm64-msvc@11.16.4": { 603 + "integrity": "sha512-+i0XtNfSP7cfnh1T8FMrMm4HxTeh0jxKP/VQCLWbjdUxaAQ4damho4gN9lF5dl0tZahtdszXLUboBFNloSJNOQ==", 604 + "os": ["win32"], 605 + "cpu": ["arm64"] 606 + }, 607 + "@oxc-resolver/binding-win32-ia32-msvc@11.16.4": { 608 + "integrity": "sha512-ePW1islJrv3lPnef/iWwrjrSpRH8kLlftdKf2auQNWvYLx6F0xvcnv9d+r/upnVuttoQY9amLnWJf+JnCRksTw==", 609 + "os": ["win32"], 610 + "cpu": ["ia32"] 611 + }, 612 + "@oxc-resolver/binding-win32-x64-msvc@11.16.4": { 613 + "integrity": "sha512-qnjQhjHI4TDL3hkidZyEmQRK43w2NHl6TP5Rnt/0XxYuLdEgx/1yzShhYidyqWzdnhGhSPTM/WVP2mK66XLegA==", 614 + "os": ["win32"], 615 + "cpu": ["x64"] 616 + }, 470 617 "@rollup/rollup-android-arm-eabi@4.54.0": { 471 618 "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", 472 619 "os": ["android"], ··· 657 804 "@testing-library/dom" 658 805 ] 659 806 }, 807 + "@tybys/wasm-util@0.10.1": { 808 + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", 809 + "dependencies": [ 810 + "tslib" 811 + ] 812 + }, 660 813 "@types/aria-query@5.0.4": { 661 814 "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==" 662 815 }, ··· 673 826 "@types/estree@1.0.8": { 674 827 "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" 675 828 }, 829 + "@types/node@25.0.3": { 830 + "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", 831 + "dependencies": [ 832 + "undici-types" 833 + ] 834 + }, 676 835 "@vitest/expect@4.0.16": { 677 836 "integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==", 678 837 "dependencies": [ ··· 740 899 "ansi-styles@5.2.0": { 741 900 "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==" 742 901 }, 902 + "argparse@2.0.1": { 903 + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" 904 + }, 743 905 "aria-query@5.3.0": { 744 906 "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", 745 907 "dependencies": [ ··· 758 920 "axobject-query@4.1.0": { 759 921 "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==" 760 922 }, 923 + "braces@3.0.3": { 924 + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", 925 + "dependencies": [ 926 + "fill-range" 927 + ] 928 + }, 761 929 "call-bind-apply-helpers@1.0.2": { 762 930 "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", 763 931 "dependencies": [ ··· 1019 1187 "type" 1020 1188 ] 1021 1189 }, 1190 + "fast-glob@3.3.3": { 1191 + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", 1192 + "dependencies": [ 1193 + "@nodelib/fs.stat", 1194 + "@nodelib/fs.walk", 1195 + "glob-parent", 1196 + "merge2", 1197 + "micromatch" 1198 + ] 1199 + }, 1200 + "fastq@1.20.1": { 1201 + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", 1202 + "dependencies": [ 1203 + "reusify" 1204 + ] 1205 + }, 1206 + "fd-package-json@2.0.0": { 1207 + "integrity": "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==", 1208 + "dependencies": [ 1209 + "walk-up-path" 1210 + ] 1211 + }, 1022 1212 "fdir@6.5.0_picomatch@4.0.3": { 1023 1213 "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", 1024 1214 "dependencies": [ 1025 - "picomatch" 1215 + "picomatch@4.0.3" 1026 1216 ], 1027 1217 "optionalPeers": [ 1028 - "picomatch" 1218 + "picomatch@4.0.3" 1219 + ] 1220 + }, 1221 + "fill-range@7.1.1": { 1222 + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", 1223 + "dependencies": [ 1224 + "to-regex-range" 1029 1225 ] 1030 1226 }, 1031 1227 "form-data@4.0.5": { ··· 1038 1234 "mime-types" 1039 1235 ] 1040 1236 }, 1237 + "formatly@0.3.0": { 1238 + "integrity": "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==", 1239 + "dependencies": [ 1240 + "fd-package-json" 1241 + ], 1242 + "bin": true 1243 + }, 1041 1244 "fsevents@2.3.3": { 1042 1245 "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", 1043 1246 "os": ["darwin"], ··· 1068 1271 "es-object-atoms" 1069 1272 ] 1070 1273 }, 1274 + "glob-parent@5.1.2": { 1275 + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", 1276 + "dependencies": [ 1277 + "is-glob" 1278 + ] 1279 + }, 1071 1280 "globalyzer@0.1.0": { 1072 1281 "integrity": "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==" 1073 1282 }, ··· 1130 1339 "tslib" 1131 1340 ] 1132 1341 }, 1342 + "is-extglob@2.1.1": { 1343 + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==" 1344 + }, 1345 + "is-glob@4.0.3": { 1346 + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", 1347 + "dependencies": [ 1348 + "is-extglob" 1349 + ] 1350 + }, 1351 + "is-number@7.0.0": { 1352 + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==" 1353 + }, 1133 1354 "is-potential-custom-element-name@1.0.1": { 1134 1355 "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==" 1135 1356 }, ··· 1142 1363 "@types/estree" 1143 1364 ] 1144 1365 }, 1366 + "jiti@2.6.1": { 1367 + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", 1368 + "bin": true 1369 + }, 1145 1370 "js-tokens@4.0.0": { 1146 1371 "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" 1147 1372 }, 1373 + "js-yaml@4.1.1": { 1374 + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", 1375 + "dependencies": [ 1376 + "argparse" 1377 + ], 1378 + "bin": true 1379 + }, 1148 1380 "jsdom@25.0.1": { 1149 1381 "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", 1150 1382 "dependencies": [ ··· 1171 1403 "xml-name-validator" 1172 1404 ] 1173 1405 }, 1406 + "knip@5.82.1_@types+node@25.0.3_typescript@5.9.3": { 1407 + "integrity": "sha512-1nQk+5AcnkqL40kGQXfouzAEXkTR+eSrgo/8m1d0BMei4eAzFwghoXC4gOKbACgBiCof7hE8wkBVDsEvznf85w==", 1408 + "dependencies": [ 1409 + "@nodelib/fs.walk", 1410 + "@types/node", 1411 + "fast-glob", 1412 + "formatly", 1413 + "jiti", 1414 + "js-yaml", 1415 + "minimist", 1416 + "oxc-resolver", 1417 + "picocolors", 1418 + "picomatch@4.0.3", 1419 + "smol-toml", 1420 + "strip-json-comments", 1421 + "typescript", 1422 + "zod" 1423 + ], 1424 + "bin": true 1425 + }, 1174 1426 "locate-character@3.0.0": { 1175 1427 "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==" 1176 1428 }, ··· 1209 1461 "timers-ext" 1210 1462 ] 1211 1463 }, 1464 + "merge2@1.4.1": { 1465 + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==" 1466 + }, 1467 + "micromatch@4.0.8": { 1468 + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", 1469 + "dependencies": [ 1470 + "braces", 1471 + "picomatch@2.3.1" 1472 + ] 1473 + }, 1212 1474 "mime-db@1.52.0": { 1213 1475 "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" 1214 1476 }, ··· 1221 1483 "min-indent@1.0.1": { 1222 1484 "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==" 1223 1485 }, 1486 + "minimist@1.2.8": { 1487 + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==" 1488 + }, 1224 1489 "mri@1.2.0": { 1225 1490 "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==" 1226 1491 }, ··· 1243 1508 "obug@2.1.1": { 1244 1509 "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==" 1245 1510 }, 1511 + "oxc-resolver@11.16.4": { 1512 + "integrity": "sha512-nvJr3orFz1wNaBA4neRw7CAn0SsjgVaEw1UHpgO/lzVW12w+nsFnvU/S6vVX3kYyFaZdxZheTExi/fa8R8PrZA==", 1513 + "optionalDependencies": [ 1514 + "@oxc-resolver/binding-android-arm-eabi", 1515 + "@oxc-resolver/binding-android-arm64", 1516 + "@oxc-resolver/binding-darwin-arm64", 1517 + "@oxc-resolver/binding-darwin-x64", 1518 + "@oxc-resolver/binding-freebsd-x64", 1519 + "@oxc-resolver/binding-linux-arm-gnueabihf", 1520 + "@oxc-resolver/binding-linux-arm-musleabihf", 1521 + "@oxc-resolver/binding-linux-arm64-gnu", 1522 + "@oxc-resolver/binding-linux-arm64-musl", 1523 + "@oxc-resolver/binding-linux-ppc64-gnu", 1524 + "@oxc-resolver/binding-linux-riscv64-gnu", 1525 + "@oxc-resolver/binding-linux-riscv64-musl", 1526 + "@oxc-resolver/binding-linux-s390x-gnu", 1527 + "@oxc-resolver/binding-linux-x64-gnu", 1528 + "@oxc-resolver/binding-linux-x64-musl", 1529 + "@oxc-resolver/binding-openharmony-arm64", 1530 + "@oxc-resolver/binding-wasm32-wasi", 1531 + "@oxc-resolver/binding-win32-arm64-msvc", 1532 + "@oxc-resolver/binding-win32-ia32-msvc", 1533 + "@oxc-resolver/binding-win32-x64-msvc" 1534 + ] 1535 + }, 1246 1536 "parse5@7.3.0": { 1247 1537 "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", 1248 1538 "dependencies": [ ··· 1255 1545 "picocolors@1.1.1": { 1256 1546 "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" 1257 1547 }, 1548 + "picomatch@2.3.1": { 1549 + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==" 1550 + }, 1258 1551 "picomatch@4.0.3": { 1259 1552 "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==" 1260 1553 }, ··· 1277 1570 "punycode@2.3.1": { 1278 1571 "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==" 1279 1572 }, 1573 + "queue-microtask@1.2.3": { 1574 + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==" 1575 + }, 1280 1576 "react-is@17.0.2": { 1281 1577 "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" 1282 1578 }, ··· 1290 1586 "strip-indent" 1291 1587 ] 1292 1588 }, 1589 + "reusify@1.1.0": { 1590 + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==" 1591 + }, 1293 1592 "rollup@4.54.0": { 1294 1593 "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", 1295 1594 "dependencies": [ ··· 1328 1627 "rrweb-cssom@0.8.0": { 1329 1628 "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==" 1330 1629 }, 1630 + "run-parallel@1.2.0": { 1631 + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", 1632 + "dependencies": [ 1633 + "queue-microtask" 1634 + ] 1635 + }, 1331 1636 "sade@1.8.1": { 1332 1637 "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", 1333 1638 "dependencies": [ ··· 1346 1651 "siginfo@2.0.0": { 1347 1652 "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==" 1348 1653 }, 1654 + "smol-toml@1.6.0": { 1655 + "integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==" 1656 + }, 1349 1657 "source-map-js@1.2.1": { 1350 1658 "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" 1351 1659 }, ··· 1361 1669 "min-indent" 1362 1670 ] 1363 1671 }, 1672 + "strip-json-comments@5.0.3": { 1673 + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==" 1674 + }, 1364 1675 "svelte-check@4.3.5_svelte@5.46.1__acorn@8.15.0_typescript@5.9.3": { 1365 1676 "integrity": "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q==", 1366 1677 "dependencies": [ ··· 1435 1746 "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", 1436 1747 "dependencies": [ 1437 1748 "fdir", 1438 - "picomatch" 1749 + "picomatch@4.0.3" 1439 1750 ] 1440 1751 }, 1441 1752 "tinyrainbow@3.0.3": { ··· 1451 1762 ], 1452 1763 "bin": true 1453 1764 }, 1765 + "to-regex-range@5.0.1": { 1766 + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", 1767 + "dependencies": [ 1768 + "is-number" 1769 + ] 1770 + }, 1454 1771 "tough-cookie@5.1.2": { 1455 1772 "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", 1456 1773 "dependencies": [ ··· 1473 1790 "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", 1474 1791 "bin": true 1475 1792 }, 1793 + "undici-types@7.16.0": { 1794 + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==" 1795 + }, 1476 1796 "unicode-segmenter@0.14.5": { 1477 1797 "integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==" 1478 1798 }, ··· 1481 1801 "dependencies": [ 1482 1802 "esbuild@0.27.2", 1483 1803 "fdir", 1484 - "picomatch", 1804 + "picomatch@4.0.3", 1485 1805 "postcss", 1486 1806 "rollup", 1487 1807 "tinyglobby" ··· 1516 1836 "magic-string", 1517 1837 "obug", 1518 1838 "pathe", 1519 - "picomatch", 1839 + "picomatch@4.0.3", 1520 1840 "std-env", 1521 1841 "tinybench", 1522 1842 "tinyexec", ··· 1536 1856 "xml-name-validator" 1537 1857 ] 1538 1858 }, 1859 + "walk-up-path@4.0.0": { 1860 + "integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==" 1861 + }, 1539 1862 "webidl-conversions@7.0.0": { 1540 1863 "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==" 1541 1864 },
+59
frontend/src/components/AuthenticatedRoute.svelte
··· 1 + <script lang="ts"> 2 + import { getAuthState } from '../lib/auth.svelte' 3 + import { navigate, routes } from '../lib/router.svelte' 4 + import type { Snippet } from 'svelte' 5 + import type { Session } from '../lib/types/api' 6 + import { createAuthenticatedClient, type AuthenticatedClient } from '../lib/authenticated-client' 7 + 8 + interface Props { 9 + children: Snippet<[{ session: Session; client: AuthenticatedClient }]> 10 + requireAdmin?: boolean 11 + onReady?: (session: Session, client: AuthenticatedClient) => void 12 + } 13 + 14 + let { children, requireAdmin = false, onReady }: Props = $props() 15 + const auth = $derived(getAuthState()) 16 + let readyCalled = $state(false) 17 + 18 + $effect(() => { 19 + if (auth.kind === 'unauthenticated' || auth.kind === 'error') { 20 + navigate(routes.login) 21 + } 22 + if (requireAdmin && auth.kind === 'authenticated' && !auth.session.isAdmin) { 23 + navigate(routes.dashboard) 24 + } 25 + if (auth.kind === 'authenticated' && onReady && !readyCalled) { 26 + readyCalled = true 27 + onReady(auth.session, createAuthenticatedClient(auth.session)) 28 + } 29 + }) 30 + </script> 31 + 32 + {#if auth.kind === 'authenticated'} 33 + {@render children({ session: auth.session, client: createAuthenticatedClient(auth.session) })} 34 + {:else} 35 + <div class="loading-container"><div class="loading-spinner"></div></div> 36 + {/if} 37 + 38 + <style> 39 + .loading-container { 40 + display: flex; 41 + justify-content: center; 42 + align-items: center; 43 + min-height: 200px; 44 + padding: var(--space-7); 45 + } 46 + 47 + .loading-spinner { 48 + width: 32px; 49 + height: 32px; 50 + border: 3px solid var(--border-color); 51 + border-top-color: var(--accent); 52 + border-radius: 50%; 53 + animation: spin 0.8s linear infinite; 54 + } 55 + 56 + @keyframes spin { 57 + to { transform: rotate(360deg); } 58 + } 59 + </style>
+225 -27
frontend/src/lib/api.ts
··· 7 7 Nsid, 8 8 RefreshToken, 9 9 Rkey, 10 + ScopeSet, 10 11 } from "./types/branded.ts"; 11 12 import { 12 13 unsafeAsAccessToken, ··· 15 16 unsafeAsHandle, 16 17 unsafeAsISODate, 17 18 unsafeAsRefreshToken, 19 + unsafeAsScopeSet, 18 20 } from "./types/branded.ts"; 19 21 import { 20 22 createDPoPProofForRequest, ··· 23 25 } from "./oauth.ts"; 24 26 import type { 25 27 AccountInfo, 28 + AccountState, 26 29 ApiErrorCode, 27 30 AppPassword, 28 31 CompletePasskeySetupResponse, 29 32 ConfirmSignupResult, 33 + ContactState, 30 34 CreateAccountParams, 31 35 CreateAccountResult, 32 36 CreateBackupResponse, 33 37 CreatedAppPassword, 34 38 CreateRecordResponse, 39 + DelegationAuditEntry, 40 + DelegationControlledAccount, 41 + DelegationController, 42 + DelegationScopePreset, 35 43 DidDocument, 36 44 DidType, 37 45 EmailUpdateResponse, ··· 65 73 ServerStats, 66 74 Session, 67 75 SetBackupEnabledResponse, 76 + SsoLinkedAccount, 68 77 StartPasskeyRegistrationResponse, 69 78 SuccessResponse, 70 79 TotpSecret, ··· 241 250 } 242 251 243 252 export type { AppPassword, DidDocument, InviteCodeInfo as InviteCode, Session }; 244 - export type { 245 - ConfirmSignupResult, 246 - CreateAccountParams, 247 - CreateAccountResult, 248 - DidType, 249 - VerificationChannel, 250 - }; 253 + export type { DidType, VerificationChannel }; 254 + 255 + function buildContactState(s: Record<string, unknown>): ContactState { 256 + const preferredChannel = s.preferredChannel as VerificationChannel | undefined; 257 + const email = s.email ? unsafeAsEmail(s.email as string) : undefined; 258 + 259 + if (preferredChannel) { 260 + return { 261 + contactKind: "channel", 262 + preferredChannel, 263 + preferredChannelVerified: Boolean(s.preferredChannelVerified), 264 + email, 265 + }; 266 + } 267 + 268 + if (email) { 269 + return { 270 + contactKind: "email", 271 + email, 272 + emailConfirmed: Boolean(s.emailConfirmed), 273 + }; 274 + } 275 + 276 + return { contactKind: "none" }; 277 + } 251 278 252 - function castSession(raw: unknown): Session { 279 + function buildAccountState(s: Record<string, unknown>): AccountState { 280 + const status = s.status as string | undefined; 281 + const isAdmin = Boolean(s.isAdmin); 282 + const active = s.active as boolean | undefined; 283 + 284 + if (status === "migrated") { 285 + return { 286 + accountKind: "migrated", 287 + migratedToPds: (s.migratedToPds as string) || "", 288 + migratedAt: s.migratedAt 289 + ? unsafeAsISODate(s.migratedAt as string) 290 + : unsafeAsISODate(new Date().toISOString()), 291 + isAdmin, 292 + }; 293 + } 294 + 295 + if (status === "deactivated" || active === false) { 296 + return { accountKind: "deactivated", isAdmin }; 297 + } 298 + 299 + if (status === "suspended") { 300 + return { accountKind: "suspended", isAdmin }; 301 + } 302 + 303 + return { accountKind: "active", isAdmin }; 304 + } 305 + 306 + export function castSession(raw: unknown): Session { 253 307 const s = raw as Record<string, unknown>; 308 + const contact = buildContactState(s); 309 + const account = buildAccountState(s); 310 + 254 311 return { 255 312 did: unsafeAsDid(s.did as string), 256 313 handle: unsafeAsHandle(s.handle as string), 257 - email: s.email ? unsafeAsEmail(s.email as string) : undefined, 258 - emailConfirmed: s.emailConfirmed as boolean | undefined, 259 - preferredChannel: s.preferredChannel as VerificationChannel | undefined, 260 - preferredChannelVerified: s.preferredChannelVerified as boolean | undefined, 261 - isAdmin: s.isAdmin as boolean | undefined, 262 - active: s.active as boolean | undefined, 263 - status: s.status as Session["status"], 264 - migratedToPds: s.migratedToPds as string | undefined, 265 - migratedAt: s.migratedAt 266 - ? unsafeAsISODate(s.migratedAt as string) 267 - : undefined, 268 314 accessJwt: unsafeAsAccessToken(s.accessJwt as string), 269 315 refreshJwt: unsafeAsRefreshToken(s.refreshJwt as string), 316 + preferredLocale: s.preferredLocale as string | null | undefined, 317 + ...contact, 318 + ...account, 319 + }; 320 + } 321 + 322 + function castDelegationController(raw: unknown): DelegationController { 323 + const c = raw as Record<string, unknown>; 324 + return { 325 + did: unsafeAsDid(c.did as string), 326 + granted_scopes: unsafeAsScopeSet(c.granted_scopes as string), 327 + added_at: unsafeAsISODate(c.added_at as string), 328 + }; 329 + } 330 + 331 + function castDelegationControlledAccount( 332 + raw: unknown, 333 + ): DelegationControlledAccount { 334 + const a = raw as Record<string, unknown>; 335 + return { 336 + did: unsafeAsDid(a.did as string), 337 + handle: unsafeAsHandle(a.handle as string), 338 + granted_scopes: unsafeAsScopeSet(a.granted_scopes as string), 339 + }; 340 + } 341 + 342 + function castDelegationAuditEntry(raw: unknown): DelegationAuditEntry { 343 + const e = raw as Record<string, unknown>; 344 + return { 345 + id: e.id as string, 346 + action: e.action as string, 347 + actor_did: unsafeAsDid(e.actor_did as string), 348 + target_did: e.target_did ? unsafeAsDid(e.target_did as string) : undefined, 349 + details: e.details as string | undefined, 350 + created_at: unsafeAsISODate(e.created_at as string), 351 + }; 352 + } 353 + 354 + function castSsoLinkedAccount(raw: unknown): SsoLinkedAccount { 355 + const a = raw as Record<string, unknown>; 356 + return { 357 + id: a.id as string, 358 + provider: a.provider as string, 359 + provider_name: a.provider_name as string, 360 + provider_username: a.provider_username as string, 361 + provider_email: a.provider_email as string | undefined, 362 + created_at: unsafeAsISODate(a.created_at as string), 363 + last_login_at: a.last_login_at 364 + ? unsafeAsISODate(a.last_login_at as string) 365 + : undefined, 270 366 }; 271 367 } 272 368 ··· 1142 1238 }, 1143 1239 1144 1240 async getRepo(token: AccessToken, did: Did): Promise<ArrayBuffer> { 1145 - const url = `${API_BASE}/com.atproto.sync.getRepo?did=${encodeURIComponent(did)}`; 1241 + const url = `${API_BASE}/com.atproto.sync.getRepo?did=${ 1242 + encodeURIComponent(did) 1243 + }`; 1146 1244 const res = await authenticatedFetch(url, { token }); 1147 1245 if (!res.ok) { 1148 1246 const errData = await res.json().catch(() => ({ ··· 1198 1296 }, 1199 1297 1200 1298 async importRepo(token: AccessToken, car: Uint8Array): Promise<void> { 1201 - const res = await authenticatedFetch(`${API_BASE}/com.atproto.repo.importRepo`, { 1202 - method: "POST", 1203 - token, 1204 - headers: { "Content-Type": "application/vnd.ipld.car" }, 1205 - body: car as unknown as BodyInit, 1206 - }); 1299 + const res = await authenticatedFetch( 1300 + `${API_BASE}/com.atproto.repo.importRepo`, 1301 + { 1302 + method: "POST", 1303 + token, 1304 + headers: { "Content-Type": "application/vnd.ipld.car" }, 1305 + body: car as unknown as BodyInit, 1306 + }, 1307 + ); 1207 1308 if (!res.ok) { 1208 1309 const errData = await res.json().catch(() => ({ 1209 1310 error: "Unknown", ··· 1213 1314 } 1214 1315 }, 1215 1316 1216 - async establishOAuthSession(token: AccessToken): Promise<{ success: boolean; device_id: string }> { 1317 + async establishOAuthSession( 1318 + token: AccessToken, 1319 + ): Promise<{ success: boolean; device_id: string }> { 1217 1320 const res = await authenticatedFetch("/oauth/establish-session", { 1218 1321 method: "POST", 1219 1322 token, ··· 1228 1331 } 1229 1332 return res.json(); 1230 1333 }, 1334 + 1335 + async getSsoLinkedAccounts( 1336 + token: AccessToken, 1337 + ): Promise<{ accounts: SsoLinkedAccount[] }> { 1338 + const res = await authenticatedFetch("/oauth/sso/linked", { token }); 1339 + if (!res.ok) { 1340 + const errData = await res.json().catch(() => ({ 1341 + error: "Unknown", 1342 + message: res.statusText, 1343 + })); 1344 + throw new ApiError(res.status, errData.error, errData.message); 1345 + } 1346 + return res.json(); 1347 + }, 1348 + 1349 + listDelegationControllers( 1350 + token: AccessToken, 1351 + ): Promise<Result<{ controllers: DelegationController[] }, ApiError>> { 1352 + return xrpcResult("_delegation.listControllers", { token }); 1353 + }, 1354 + 1355 + listDelegationControlledAccounts( 1356 + token: AccessToken, 1357 + ): Promise<Result<{ accounts: DelegationControlledAccount[] }, ApiError>> { 1358 + return xrpcResult("_delegation.listControlledAccounts", { token }); 1359 + }, 1360 + 1361 + getDelegationScopePresets(): Promise< 1362 + Result<{ presets: DelegationScopePreset[] }, ApiError> 1363 + > { 1364 + return xrpcResult("_delegation.getScopePresets"); 1365 + }, 1366 + 1367 + addDelegationController( 1368 + token: AccessToken, 1369 + controllerDid: Did, 1370 + grantedScopes: ScopeSet, 1371 + ): Promise<Result<{ success: boolean }, ApiError>> { 1372 + return xrpcResult("_delegation.addController", { 1373 + method: "POST", 1374 + token, 1375 + body: { controller_did: controllerDid, granted_scopes: grantedScopes }, 1376 + }); 1377 + }, 1378 + 1379 + removeDelegationController( 1380 + token: AccessToken, 1381 + controllerDid: Did, 1382 + ): Promise<Result<{ success: boolean }, ApiError>> { 1383 + return xrpcResult("_delegation.removeController", { 1384 + method: "POST", 1385 + token, 1386 + body: { controller_did: controllerDid }, 1387 + }); 1388 + }, 1389 + 1390 + createDelegatedAccount( 1391 + token: AccessToken, 1392 + handle: Handle, 1393 + email?: EmailAddress, 1394 + controllerScopes?: ScopeSet, 1395 + ): Promise<Result<{ did: Did; handle: Handle }, ApiError>> { 1396 + return xrpcResult("_delegation.createDelegatedAccount", { 1397 + method: "POST", 1398 + token, 1399 + body: { handle, email, controllerScopes }, 1400 + }); 1401 + }, 1402 + 1403 + getDelegationAuditLog( 1404 + token: AccessToken, 1405 + limit: number, 1406 + offset: number, 1407 + ): Promise< 1408 + Result<{ entries: DelegationAuditEntry[]; total: number }, ApiError> 1409 + > { 1410 + return xrpcResult("_delegation.getAuditLog", { 1411 + token, 1412 + params: { limit: String(limit), offset: String(offset) }, 1413 + }); 1414 + }, 1415 + 1416 + async exportBlobs(token: AccessToken): Promise<Blob> { 1417 + const res = await authenticatedFetch(`${API_BASE}/_backup.exportBlobs`, { 1418 + token, 1419 + }); 1420 + if (!res.ok) { 1421 + const errData = await res.json().catch(() => ({ 1422 + error: "Unknown", 1423 + message: res.statusText, 1424 + })); 1425 + throw new ApiError(res.status, errData.error, errData.message); 1426 + } 1427 + return res.blob(); 1428 + }, 1231 1429 }; 1232 1430 1233 1431 export const typedApi = {
+12 -10
frontend/src/lib/auth.svelte.ts
··· 1 - import { 2 - api, 3 - ApiError, 4 - type CreateAccountParams, 5 - type CreateAccountResult, 6 - typedApi, 7 - } from "./api.ts"; 8 - import type { Session } from "./types/api.ts"; 1 + import { api, ApiError, typedApi, castSession } from "./api.ts"; 2 + import type { 3 + CreateAccountParams, 4 + CreateAccountResult, 5 + Session, 6 + } from "./types/api.ts"; 9 7 import { 10 8 type AccessToken, 11 9 type Did, ··· 436 434 setLoading(); 437 435 try { 438 436 const result = await api.confirmSignup(did, verificationCode); 439 - setAuthenticated(result); 440 - return ok(result); 437 + const session = castSession(result); 438 + setAuthenticated(session); 439 + return ok(session); 441 440 } catch (e) { 442 441 const error = toAuthError(e); 443 442 setError(error); ··· 467 466 handle: unsafeAsHandle(session.handle), 468 467 accessJwt: unsafeAsAccessToken(session.accessJwt), 469 468 refreshJwt: unsafeAsRefreshToken(session.refreshJwt), 469 + contactKind: "none", 470 + accountKind: "active", 471 + isAdmin: false, 470 472 }; 471 473 setAuthenticated(newSession); 472 474 }
+78
frontend/src/lib/authenticated-client.ts
··· 1 + import type { AccessToken, Did, EmailAddress, Handle, ScopeSet } from "./types/branded.ts"; 2 + import type { Session } from "./types/api.ts"; 3 + import type { 4 + DelegationAuditEntry, 5 + DelegationControlledAccount, 6 + DelegationController, 7 + DelegationScopePreset, 8 + SsoLinkedAccount, 9 + } from "./types/api.ts"; 10 + import { api, ApiError } from "./api.ts"; 11 + import type { Result } from "./types/result.ts"; 12 + 13 + export interface AuthenticatedClient { 14 + readonly token: AccessToken; 15 + readonly session: Session; 16 + 17 + getSsoLinkedAccounts(): Promise<{ accounts: SsoLinkedAccount[] }>; 18 + 19 + listDelegationControllers(): Promise< 20 + Result<{ controllers: DelegationController[] }, ApiError> 21 + >; 22 + listDelegationControlledAccounts(): Promise< 23 + Result<{ accounts: DelegationControlledAccount[] }, ApiError> 24 + >; 25 + getDelegationScopePresets(): Promise< 26 + Result<{ presets: DelegationScopePreset[] }, ApiError> 27 + >; 28 + addDelegationController( 29 + controllerDid: Did, 30 + grantedScopes: ScopeSet, 31 + ): Promise<Result<{ success: boolean }, ApiError>>; 32 + removeDelegationController( 33 + controllerDid: Did, 34 + ): Promise<Result<{ success: boolean }, ApiError>>; 35 + createDelegatedAccount( 36 + handle: Handle, 37 + email?: EmailAddress, 38 + controllerScopes?: ScopeSet, 39 + ): Promise<Result<{ did: Did; handle: Handle }, ApiError>>; 40 + getDelegationAuditLog( 41 + limit: number, 42 + offset: number, 43 + ): Promise< 44 + Result<{ entries: DelegationAuditEntry[]; total: number }, ApiError> 45 + >; 46 + 47 + exportBlobs(): Promise<Blob>; 48 + } 49 + 50 + export function createAuthenticatedClient( 51 + session: Session, 52 + ): AuthenticatedClient { 53 + const token = session.accessJwt; 54 + 55 + return { 56 + token, 57 + session, 58 + 59 + getSsoLinkedAccounts: () => api.getSsoLinkedAccounts(token), 60 + 61 + listDelegationControllers: () => api.listDelegationControllers(token), 62 + listDelegationControlledAccounts: () => 63 + api.listDelegationControlledAccounts(token), 64 + getDelegationScopePresets: () => api.getDelegationScopePresets(), 65 + addDelegationController: (controllerDid, grantedScopes) => 66 + api.addDelegationController(token, controllerDid, grantedScopes), 67 + removeDelegationController: (controllerDid) => 68 + api.removeDelegationController(token, controllerDid), 69 + createDelegatedAccount: (handle, email, controllerScopes) => 70 + api.createDelegatedAccount(token, handle, email, controllerScopes), 71 + getDelegationAuditLog: (limit, offset) => 72 + api.getDelegationAuditLog(token, limit, offset), 73 + 74 + exportBlobs: () => api.exportBlobs(token), 75 + }; 76 + } 77 + 78 + export { ApiError };
+113
frontend/src/lib/migration/atproto-client.ts
··· 33 33 export class AtprotoClient { 34 34 private baseUrl: string; 35 35 private accessToken: string | null = null; 36 + private refreshToken: string | null = null; 36 37 private dpopKeyPair: DPoPKeyPair | null = null; 37 38 private dpopNonce: string | null = null; 39 + private isRefreshing = false; 38 40 39 41 constructor(pdsUrl: string) { 40 42 this.baseUrl = pdsUrl.replace(/\/$/, ""); ··· 48 50 return this.accessToken; 49 51 } 50 52 53 + setRefreshToken(token: string | null) { 54 + this.refreshToken = token; 55 + } 56 + 57 + getRefreshToken(): string | null { 58 + return this.refreshToken; 59 + } 60 + 51 61 getBaseUrl(): string { 52 62 return this.baseUrl; 53 63 } ··· 56 66 this.dpopKeyPair = keyPair; 57 67 } 58 68 69 + private async tryRefreshToken(): Promise<boolean> { 70 + if (!this.refreshToken || this.isRefreshing) return false; 71 + this.isRefreshing = true; 72 + try { 73 + const session = await this.refreshSessionInternal(this.refreshToken); 74 + this.accessToken = session.accessJwt; 75 + this.refreshToken = session.refreshJwt; 76 + return true; 77 + } catch { 78 + return false; 79 + } finally { 80 + this.isRefreshing = false; 81 + } 82 + } 83 + 84 + private async refreshSessionInternal(refreshJwt: string): Promise<Session> { 85 + const url = `${this.baseUrl}/xrpc/com.atproto.server.refreshSession`; 86 + const headers: Record<string, string> = {}; 87 + 88 + if (this.dpopKeyPair) { 89 + headers["Authorization"] = `DPoP ${refreshJwt}`; 90 + const tokenHash = await computeAccessTokenHash(refreshJwt); 91 + const dpopProof = await createDPoPProof( 92 + this.dpopKeyPair, 93 + "POST", 94 + url, 95 + this.dpopNonce ?? undefined, 96 + tokenHash, 97 + ); 98 + headers["DPoP"] = dpopProof; 99 + } else { 100 + headers["Authorization"] = `Bearer ${refreshJwt}`; 101 + } 102 + 103 + let res = await fetch(url, { method: "POST", headers }); 104 + 105 + if (!res.ok && this.dpopKeyPair) { 106 + const dpopNonce = res.headers.get("DPoP-Nonce"); 107 + if (dpopNonce && dpopNonce !== this.dpopNonce) { 108 + this.dpopNonce = dpopNonce; 109 + headers["DPoP"] = await createDPoPProof( 110 + this.dpopKeyPair, 111 + "POST", 112 + url, 113 + dpopNonce, 114 + await computeAccessTokenHash(refreshJwt), 115 + ); 116 + res = await fetch(url, { method: "POST", headers }); 117 + } 118 + } 119 + 120 + if (!res.ok) { 121 + throw new Error("Token refresh failed"); 122 + } 123 + 124 + const newNonce = res.headers.get("DPoP-Nonce"); 125 + if (newNonce) { 126 + this.dpopNonce = newNonce; 127 + } 128 + 129 + return res.json(); 130 + } 131 + 59 132 private async xrpc<T>( 60 133 method: string, 61 134 options?: { ··· 135 208 error: "Unknown", 136 209 message: res.statusText, 137 210 })); 211 + 212 + const isTokenExpired = res.status === 401 && 213 + (err.error === "ExpiredToken" || err.error === "invalid_token" || 214 + (err.message && err.message.includes("expired"))); 215 + 216 + if (isTokenExpired && !authToken && await this.tryRefreshToken()) { 217 + const retryNonce = res.headers.get("DPoP-Nonce") ?? this.dpopNonce; 218 + if (retryNonce) this.dpopNonce = retryNonce; 219 + res = await makeRequest(this.dpopNonce ?? undefined); 220 + 221 + if (!res.ok && this.dpopKeyPair) { 222 + const dpopNonce = res.headers.get("DPoP-Nonce"); 223 + if (dpopNonce && dpopNonce !== this.dpopNonce) { 224 + this.dpopNonce = dpopNonce; 225 + res = await makeRequest(dpopNonce); 226 + } 227 + } 228 + 229 + if (res.ok) { 230 + const newNonce = res.headers.get("DPoP-Nonce"); 231 + if (newNonce) this.dpopNonce = newNonce; 232 + const responseContentType = res.headers.get("content-type") ?? ""; 233 + if (responseContentType.includes("application/json")) { 234 + return res.json(); 235 + } 236 + return res.arrayBuffer().then((buf) => new Uint8Array(buf)) as T; 237 + } 238 + 239 + const retryErr = await res.json().catch(() => ({ 240 + error: "Unknown", 241 + message: res.statusText, 242 + })); 243 + const retryError = new Error(retryErr.message || retryErr.error || res.statusText) as 244 + & Error 245 + & { status: number; error: string }; 246 + retryError.status = res.status; 247 + retryError.error = retryErr.error; 248 + throw retryError; 249 + } 250 + 138 251 const error = new Error(err.message || err.error || res.statusText) as 139 252 & Error 140 253 & {
+1
frontend/src/lib/migration/flow.svelte.ts
··· 261 261 state.sourceAccessToken = tokenResponse.access_token; 262 262 state.sourceRefreshToken = tokenResponse.refresh_token ?? null; 263 263 sourceClient.setAccessToken(tokenResponse.access_token); 264 + sourceClient.setRefreshToken(tokenResponse.refresh_token ?? null); 264 265 sourceClient.setDPoPKeyPair(dpopKeyPair); 265 266 266 267 cleanupOAuthSessionData();
+1 -50
frontend/src/lib/router.svelte.ts
··· 1 1 import { 2 2 buildUrl, 3 - isValidRoute, 4 - parseRouteParams, 5 3 type Route, 6 4 type RouteParams, 7 5 routes, ··· 71 69 updateState(); 72 70 } 73 71 74 - export function navigateTo(path: string, replace = false): void { 75 - const normalizedPath = path.startsWith("/") ? path : "/" + path; 76 - const fullPath = APP_BASE + normalizedPath; 77 - 78 - if (replace) { 79 - globalThis.history.replaceState(null, "", fullPath); 80 - } else { 81 - globalThis.history.pushState(null, "", fullPath); 82 - } 83 - 84 - updateState(); 85 - } 86 - 87 72 export function getCurrentPath(): AppPath { 88 73 return state.current.path; 89 74 } ··· 100 85 return APP_BASE + (path.startsWith("/") ? path : "/" + path); 101 86 } 102 87 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 88 export function isCurrentRoute(route: Route): boolean { 112 89 const pathWithoutQuery = state.current.path.split("?")[0]; 113 90 return pathWithoutQuery === route; 114 91 } 115 92 116 - export function getRouteParams<R extends RoutesWithParams>( 117 - _route: R, 118 - ): RouteParams[R] { 119 - return parseRouteParams(_route); 120 - } 121 - 122 - export type RouteMatch = 123 - | { 124 - readonly matched: true; 125 - readonly route: Route; 126 - readonly params: URLSearchParams; 127 - } 128 - | { readonly matched: false }; 129 - 130 - export function match(): RouteMatch { 131 - const route = matchRoute(state.current.path); 132 - if (route) { 133 - return { 134 - matched: true, 135 - route, 136 - params: state.current.searchParams, 137 - }; 138 - } 139 - return { matched: false }; 140 - } 141 - 142 - export { type Route, type RouteParams, routes, type RoutesWithParams }; 93 + export { type Route, type RouteParams, routes };
+110 -15
frontend/src/lib/types/api.ts
··· 10 10 Nsid, 11 11 PublicKeyMultibase, 12 12 RefreshToken, 13 + ScopeSet, 13 14 } from "./branded.ts"; 14 15 15 16 export type ApiErrorCode = ··· 52 53 53 54 export type ReauthMethod = "password" | "totp" | "passkey"; 54 55 55 - export interface Session { 56 - did: Did; 57 - handle: Handle; 58 - email?: EmailAddress; 59 - emailConfirmed?: boolean; 60 - preferredChannel?: VerificationChannel; 61 - preferredChannelVerified?: boolean; 62 - preferredLocale?: string | null; 63 - isAdmin?: boolean; 64 - active?: boolean; 65 - status?: AccountStatus; 66 - migratedToPds?: string; 67 - migratedAt?: ISODateString; 68 - accessJwt: AccessToken; 69 - refreshJwt: RefreshToken; 56 + export type ContactState = 57 + | { 58 + readonly contactKind: "channel"; 59 + readonly preferredChannel: VerificationChannel; 60 + readonly preferredChannelVerified: boolean; 61 + readonly email?: EmailAddress; 62 + } 63 + | { 64 + readonly contactKind: "email"; 65 + readonly email: EmailAddress; 66 + readonly emailConfirmed: boolean; 67 + } 68 + | { readonly contactKind: "none" }; 69 + 70 + export type AccountState = 71 + | { readonly accountKind: "active"; readonly isAdmin: boolean } 72 + | { 73 + readonly accountKind: "migrated"; 74 + readonly migratedToPds: string; 75 + readonly migratedAt: ISODateString; 76 + readonly isAdmin: boolean; 77 + } 78 + | { readonly accountKind: "deactivated"; readonly isAdmin: boolean } 79 + | { readonly accountKind: "suspended"; readonly isAdmin: boolean }; 80 + 81 + type SessionBase = { 82 + readonly did: Did; 83 + readonly handle: Handle; 84 + readonly accessJwt: AccessToken; 85 + readonly refreshJwt: RefreshToken; 86 + readonly preferredLocale?: string | null; 87 + }; 88 + 89 + export type Session = SessionBase & ContactState & AccountState; 90 + 91 + export function hasEmail( 92 + session: Session, 93 + ): session is Session & { email: EmailAddress } { 94 + return session.contactKind === "email" || 95 + (session.contactKind === "channel" && session.email !== undefined); 96 + } 97 + 98 + export function getSessionEmail(session: Session): EmailAddress | undefined { 99 + return session.contactKind === "email" 100 + ? session.email 101 + : session.contactKind === "channel" 102 + ? session.email 103 + : undefined; 104 + } 105 + 106 + export function isEmailVerified(session: Session): boolean { 107 + return session.contactKind === "email" 108 + ? session.emailConfirmed 109 + : session.contactKind === "channel" 110 + ? session.preferredChannelVerified 111 + : false; 112 + } 113 + 114 + export function isMigrated( 115 + session: Session, 116 + ): session is Session & { accountKind: "migrated" } { 117 + return session.accountKind === "migrated"; 118 + } 119 + 120 + export function isDeactivated(session: Session): boolean { 121 + return session.accountKind === "deactivated"; 122 + } 123 + 124 + export function isActive(session: Session): boolean { 125 + return session.accountKind === "active"; 70 126 } 71 127 72 128 export interface VerificationMethod { ··· 493 549 export interface ResendMigrationVerificationResponse { 494 550 sent: boolean; 495 551 } 552 + 553 + export interface SsoLinkedAccount { 554 + id: string; 555 + provider: string; 556 + provider_name: string; 557 + provider_username: string; 558 + provider_email?: string; 559 + created_at: ISODateString; 560 + last_login_at?: ISODateString; 561 + } 562 + 563 + export interface DelegationController { 564 + did: Did; 565 + grantedScopes: ScopeSet; 566 + grantedAt: ISODateString; 567 + isActive: boolean; 568 + } 569 + 570 + export interface DelegationControlledAccount { 571 + did: Did; 572 + handle: Handle; 573 + grantedScopes: ScopeSet; 574 + grantedAt: ISODateString; 575 + } 576 + 577 + export interface DelegationScopePreset { 578 + name: string; 579 + scopes: ScopeSet; 580 + description: string; 581 + } 582 + 583 + export interface DelegationAuditEntry { 584 + id: string; 585 + action: string; 586 + actor_did: Did; 587 + target_did?: Did; 588 + details?: string; 589 + created_at: ISODateString; 590 + }
+5
frontend/src/lib/types/branded.ts
··· 23 23 24 24 export type PublicKeyMultibase = Brand<string, "PublicKeyMultibase">; 25 25 export type DidKeyString = Brand<string, "DidKeyString">; 26 + export type ScopeSet = Brand<string, "ScopeSet">; 26 27 27 28 const DID_PLC_REGEX = /^did:plc:[a-z2-7]{24}$/; 28 29 const DID_WEB_REGEX = /^did:web:.+$/; ··· 179 180 return s as DidKeyString; 180 181 } 181 182 183 + export function unsafeAsScopeSet(s: string): ScopeSet { 184 + return s as ScopeSet; 185 + } 186 + 182 187 export function parseAtUri( 183 188 uri: AtUri, 184 189 ): { repo: Did; collection: Nsid; rkey: Rkey } {
+67
frontend/src/lib/types/totp-state.ts
··· 1 + declare const __step: unique symbol; 2 + 3 + export type TotpIdle = { 4 + readonly step: "idle"; 5 + readonly [__step]: "idle"; 6 + }; 7 + 8 + export type TotpQr = { 9 + readonly step: "qr"; 10 + readonly qrBase64: string; 11 + readonly totpUri: string; 12 + readonly [__step]: "qr"; 13 + }; 14 + 15 + export type TotpVerify = { 16 + readonly step: "verify"; 17 + readonly qrBase64: string; 18 + readonly totpUri: string; 19 + readonly [__step]: "verify"; 20 + }; 21 + 22 + export type TotpBackup = { 23 + readonly step: "backup"; 24 + readonly backupCodes: readonly string[]; 25 + readonly [__step]: "backup"; 26 + }; 27 + 28 + export type TotpSetupState = TotpIdle | TotpQr | TotpVerify | TotpBackup; 29 + 30 + export const idleState: TotpIdle = { step: "idle" } as TotpIdle; 31 + 32 + export function qrState(qrBase64: string, totpUri: string): TotpQr { 33 + return { step: "qr", qrBase64, totpUri } as TotpQr; 34 + } 35 + 36 + export function verifyState(state: TotpQr): TotpVerify { 37 + return { step: "verify", qrBase64: state.qrBase64, totpUri: state.totpUri } as TotpVerify; 38 + } 39 + 40 + export function backupState(state: TotpVerify, backupCodes: readonly string[]): TotpBackup { 41 + void state; 42 + return { step: "backup", backupCodes } as TotpBackup; 43 + } 44 + 45 + export function goBackToQr(state: TotpVerify): TotpQr { 46 + return { step: "qr", qrBase64: state.qrBase64, totpUri: state.totpUri } as TotpQr; 47 + } 48 + 49 + export function finish(_state: TotpBackup): TotpIdle { 50 + return idleState; 51 + } 52 + 53 + export function isIdle(state: TotpSetupState): state is TotpIdle { 54 + return state.step === "idle"; 55 + } 56 + 57 + export function isQr(state: TotpSetupState): state is TotpQr { 58 + return state.step === "qr"; 59 + } 60 + 61 + export function isVerify(state: TotpSetupState): state is TotpVerify { 62 + return state.step === "verify"; 63 + } 64 + 65 + export function isBackup(state: TotpSetupState): state is TotpBackup { 66 + return state.step === "backup"; 67 + }
+105 -116
frontend/src/routes/ActAs.svelte
··· 1 1 <script lang="ts"> 2 - import { getAuthState } from '../lib/auth.svelte' 3 - import { navigate, routes, getFullUrl } from '../lib/router.svelte' 4 - import { generateCodeVerifier, generateCodeChallenge, saveOAuthState, generateState, createDPoPProofForRequest } from '../lib/oauth' 2 + import AuthenticatedRoute from '../components/AuthenticatedRoute.svelte' 3 + import { navigate } from '../lib/router.svelte' 4 + import { generateCodeVerifier, generateCodeChallenge, saveOAuthState, generateState, createDPoPProofForRequest, setDPoPNonce } from '../lib/oauth' 5 5 import { _ } from '../lib/i18n' 6 - import type { Session } from '../lib/types/api' 6 + import type { Session, DelegationControlledAccount } from '../lib/types/api' 7 + import type { AuthenticatedClient } from '../lib/authenticated-client' 7 8 8 - const auth = $derived(getAuthState()) 9 - 10 - function getSession(): Session | null { 11 - return auth.kind === 'authenticated' ? auth.session : null 12 - } 13 - 14 - function isLoading(): boolean { 15 - return auth.kind === 'loading' 16 - } 17 - 18 - const session = $derived(getSession()) 19 - const authLoading = $derived(isLoading()) 20 9 let error = $state<string | null>(null) 21 10 let loading = $state(true) 22 - let actAsInProgress = $state(false) 11 + let actAsStarted = $state(false) 23 12 24 13 function getDid(): string | null { 25 14 const params = new URLSearchParams(window.location.search) 26 15 return params.get('did') 27 16 } 28 17 29 - $effect(() => { 30 - if (!authLoading && !session && !actAsInProgress) { 31 - navigate(routes.login) 32 - } 33 - }) 34 - 35 - $effect(() => { 36 - if (session && !actAsInProgress) { 37 - actAsInProgress = true 38 - initiateActAs() 39 - } 40 - }) 18 + async function initiateActAs(session: Session, client: AuthenticatedClient) { 19 + if (actAsStarted) return 20 + actAsStarted = true 41 21 42 - async function initiateActAs() { 43 22 const did = getDid() 44 23 if (!did) { 45 24 error = $_('actAs.noAccountSpecified') ··· 47 26 return 48 27 } 49 28 50 - try { 51 - const response = await fetch( 52 - `/xrpc/_delegation.listControlledAccounts`, 53 - { 54 - headers: { 'Authorization': `Bearer ${session!.accessJwt}` } 55 - } 56 - ) 57 - 58 - if (!response.ok) { 59 - error = $_('actAs.failedToVerify') 60 - loading = false 61 - return 62 - } 63 - 64 - const data = await response.json() 65 - const account = data.accounts?.find((a: { did: string }) => a.did === did) 29 + const result = await client.listDelegationControlledAccounts() 30 + if (!result.ok) { 31 + error = $_('actAs.failedToInitiate') 32 + loading = false 33 + return 34 + } 66 35 67 - if (!account) { 68 - error = $_('actAs.noAccess') 69 - loading = false 70 - return 71 - } 36 + const account = result.value.accounts?.find((a: DelegationControlledAccount) => a.did === did) 72 37 73 - const hostname = window.location.origin 74 - const state = generateState() 75 - const codeVerifier = generateCodeVerifier() 76 - const codeChallenge = await generateCodeChallenge(codeVerifier) 77 - saveOAuthState({ state, codeVerifier }) 38 + if (!account) { 39 + error = $_('actAs.noAccess') 40 + loading = false 41 + return 42 + } 78 43 79 - const parResponse = await fetch('/oauth/par', { 80 - method: 'POST', 81 - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 82 - body: new URLSearchParams({ 83 - client_id: `${hostname}/oauth/client-metadata.json`, 84 - redirect_uri: `${hostname}/app/`, 85 - response_type: 'code', 86 - scope: 'atproto', 87 - state: state, 88 - code_challenge: codeChallenge, 89 - code_challenge_method: 'S256', 90 - login_hint: account.handle 91 - }) 44 + const hostname = window.location.origin 45 + const state = generateState() 46 + const codeVerifier = generateCodeVerifier() 47 + const codeChallenge = await generateCodeChallenge(codeVerifier) 48 + saveOAuthState({ state, codeVerifier }) 49 + 50 + const parResponse = await fetch('/oauth/par', { 51 + method: 'POST', 52 + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 53 + body: new URLSearchParams({ 54 + client_id: `${hostname}/oauth/client-metadata.json`, 55 + redirect_uri: `${hostname}/app/`, 56 + response_type: 'code', 57 + scope: 'atproto', 58 + state: state, 59 + code_challenge: codeChallenge, 60 + code_challenge_method: 'S256', 61 + login_hint: account.handle 92 62 }) 63 + }) 93 64 94 - if (!parResponse.ok) { 95 - error = $_('actAs.failedToInitiate') 96 - loading = false 97 - return 98 - } 65 + if (!parResponse.ok) { 66 + error = $_('actAs.failedToInitiate') 67 + loading = false 68 + return 69 + } 99 70 100 - const parData = await parResponse.json() 101 - if (!parData.request_uri) { 102 - error = $_('actAs.invalidResponse') 103 - loading = false 104 - return 105 - } 71 + const parData = await parResponse.json() 72 + if (!parData.request_uri) { 73 + error = $_('actAs.invalidResponse') 74 + loading = false 75 + return 76 + } 77 + 78 + const authUrl = `${window.location.origin}/delegation/auth-token` 79 + const body = JSON.stringify({ 80 + request_uri: parData.request_uri, 81 + delegated_did: did 82 + }) 106 83 107 - const authUrl = `${window.location.origin}/oauth/delegation/auth-token` 108 - const dpopProof = await createDPoPProofForRequest('POST', authUrl, session!.accessJwt) 109 - const authResponse = await fetch('/oauth/delegation/auth-token', { 84 + async function callAuthToken(retry: boolean): Promise<Response> { 85 + const dpopProof = await createDPoPProofForRequest('POST', authUrl, session.accessJwt) 86 + const response = await fetch('/oauth/delegation/auth-token', { 110 87 method: 'POST', 111 88 headers: { 112 89 'Content-Type': 'application/json', 113 - 'Authorization': `DPoP ${session!.accessJwt}`, 90 + 'Authorization': `DPoP ${session.accessJwt}`, 114 91 'DPoP': dpopProof 115 92 }, 116 - body: JSON.stringify({ 117 - request_uri: parData.request_uri, 118 - delegated_did: did 119 - }) 93 + body 120 94 }) 121 95 122 - const authData = await authResponse.json() 123 - if (authData.success && authData.redirect_uri) { 124 - window.location.href = authData.redirect_uri 125 - } else { 126 - error = authData.error || $_('actAs.failedToInitiate') 127 - loading = false 96 + if (!response.ok && retry) { 97 + const nonce = response.headers.get('DPoP-Nonce') 98 + if (nonce) { 99 + setDPoPNonce(nonce) 100 + return callAuthToken(false) 101 + } 128 102 } 129 - } catch (e) { 130 - error = $_('actAs.failedError', { values: { error: e instanceof Error ? e.message : String(e) } }) 103 + return response 104 + } 105 + 106 + const authResponse = await callAuthToken(true) 107 + const authData = await authResponse.json() 108 + if (authData.success && authData.redirect_uri) { 109 + window.location.href = authData.redirect_uri 110 + } else { 111 + error = authData.error || $_('actAs.failedToInitiate') 131 112 loading = false 132 113 } 133 114 } ··· 135 116 function goBack() { 136 117 navigate('/controllers') 137 118 } 119 + 120 + function handleReady(session: Session, client: AuthenticatedClient) { 121 + initiateActAs(session, client) 122 + } 138 123 </script> 139 124 140 - <div class="page"> 141 - {#if loading} 142 - <div class="loading"> 143 - <p>{$_('actAs.preparing')}</p> 144 - </div> 145 - {:else} 146 - <header> 147 - <h1>{$_('actAs.title')}</h1> 148 - </header> 149 - 150 - {#if error} 151 - <div class="message error">{error}</div> 152 - {/if} 153 - 154 - <div class="actions"> 155 - <button class="back-btn" onclick={goBack}> 156 - {$_('actAs.backToControllers')} 157 - </button> 125 + <AuthenticatedRoute onReady={handleReady}> 126 + {#snippet children({ session, client })} 127 + <div class="page"> 128 + {#if loading} 129 + <div class="loading"> 130 + <p>{$_('actAs.preparing')}</p> 131 + </div> 132 + {:else} 133 + <header> 134 + <h1>{$_('actAs.title')}</h1> 135 + </header> 136 + 137 + {#if error} 138 + <div class="message error">{error}</div> 139 + {/if} 140 + 141 + <div class="actions"> 142 + <button class="back-btn" onclick={goBack}> 143 + {$_('actAs.backToControllers')} 144 + </button> 145 + </div> 146 + {/if} 158 147 </div> 159 - {/if} 160 - </div> 148 + {/snippet} 149 + </AuthenticatedRoute> 161 150 162 151 <style> 163 152 .page {
+273 -347
frontend/src/routes/Controllers.svelte
··· 1 1 <script lang="ts"> 2 - import { getAuthState } from '../lib/auth.svelte' 3 - import { navigate, routes, getFullUrl } from '../lib/router.svelte' 2 + import AuthenticatedRoute from '../components/AuthenticatedRoute.svelte' 4 3 import { _ } from '../lib/i18n' 5 4 import { formatDateTime } from '../lib/date' 6 - import type { Session } from '../lib/types/api' 5 + import type { Session, DelegationController, DelegationControlledAccount, DelegationScopePreset } from '../lib/types/api' 7 6 import { toast } from '../lib/toast.svelte' 7 + import type { AuthenticatedClient } from '../lib/authenticated-client' 8 + import { unsafeAsDid, unsafeAsHandle, unsafeAsEmail, unsafeAsScopeSet } from '../lib/types/branded' 9 + import type { Did, Handle, ScopeSet } from '../lib/types/branded' 8 10 9 11 interface Controller { 10 - did: string 11 - handle: string 12 - grantedScopes: string 12 + did: Did 13 + handle: Handle 14 + grantedScopes: ScopeSet 13 15 grantedAt: string 14 16 isActive: boolean 15 17 } 16 18 17 19 interface ControlledAccount { 18 - did: string 19 - handle: string 20 - grantedScopes: string 20 + did: Did 21 + handle: Handle 22 + grantedScopes: ScopeSet 21 23 grantedAt: string 22 24 } 23 25 ··· 25 27 name: string 26 28 label: string 27 29 description: string 28 - scopes: string 30 + scopes: ScopeSet 29 31 } 30 32 31 - const auth = $derived(getAuthState()) 32 - 33 - function getSession(): Session | null { 34 - return auth.kind === 'authenticated' ? auth.session : null 35 - } 36 - 37 - function isLoading(): boolean { 38 - return auth.kind === 'loading' 39 - } 40 - 41 - const session = $derived(getSession()) 42 - const authLoading = $derived(isLoading()) 43 - 44 33 let loading = $state(true) 45 34 let controllers = $state<Controller[]>([]) 46 35 let controlledAccounts = $state<ControlledAccount[]>([]) ··· 63 52 let newDelegatedScopes = $state('atproto') 64 53 let creatingDelegated = $state(false) 65 54 66 - $effect(() => { 67 - if (!authLoading && !session) { 68 - navigate(routes.login) 69 - } 70 - }) 71 - 72 - $effect(() => { 73 - if (session) { 74 - loadData() 75 - } 76 - }) 55 + let currentClient: AuthenticatedClient | null = $state(null) 77 56 78 - async function loadData() { 79 - loading = true 80 - try { 81 - await Promise.all([loadControllers(), loadControlledAccounts(), loadScopePresets()]) 82 - } finally { 83 - loading = false 84 - } 57 + function handleReady(_session: Session, client: AuthenticatedClient) { 58 + currentClient = client 59 + loadData(client) 85 60 } 86 61 87 - async function loadControllers() { 88 - if (!session) return 89 - try { 90 - const response = await fetch('/xrpc/_delegation.listControllers', { 91 - headers: { 'Authorization': `Bearer ${session.accessJwt}` } 92 - }) 93 - if (response.ok) { 94 - const data = await response.json() 95 - controllers = data.controllers || [] 96 - } 97 - } catch (e) { 98 - console.error('Failed to load controllers:', e) 62 + async function loadData(client: AuthenticatedClient) { 63 + loading = true 64 + await Promise.all([loadControllers(client), loadControlledAccounts(client), loadScopePresets(client)]) 65 + loading = false 66 + } 67 + 68 + async function loadControllers(client: AuthenticatedClient) { 69 + const result = await client.listDelegationControllers() 70 + if (result.ok) { 71 + controllers = (result.value.controllers ?? []).map((c: DelegationController) => ({ 72 + did: c.did, 73 + handle: c.did as unknown as Handle, 74 + grantedScopes: c.grantedScopes, 75 + grantedAt: c.grantedAt, 76 + isActive: c.isActive 77 + })) 99 78 } 100 79 } 101 80 102 - async function loadControlledAccounts() { 103 - if (!session) return 104 - try { 105 - const response = await fetch('/xrpc/_delegation.listControlledAccounts', { 106 - headers: { 'Authorization': `Bearer ${session.accessJwt}` } 107 - }) 108 - if (response.ok) { 109 - const data = await response.json() 110 - controlledAccounts = data.accounts || [] 111 - } 112 - } catch (e) { 113 - console.error('Failed to load controlled accounts:', e) 81 + async function loadControlledAccounts(client: AuthenticatedClient) { 82 + const result = await client.listDelegationControlledAccounts() 83 + if (result.ok) { 84 + controlledAccounts = (result.value.accounts ?? []).map((a: DelegationControlledAccount) => ({ 85 + did: a.did, 86 + handle: a.handle, 87 + grantedScopes: a.grantedScopes, 88 + grantedAt: a.grantedAt 89 + })) 114 90 } 115 91 } 116 92 117 - async function loadScopePresets() { 118 - try { 119 - const response = await fetch('/xrpc/_delegation.getScopePresets') 120 - if (response.ok) { 121 - const data = await response.json() 122 - scopePresets = data.presets || [] 123 - } 124 - } catch (e) { 125 - console.error('Failed to load scope presets:', e) 93 + async function loadScopePresets(client: AuthenticatedClient) { 94 + const result = await client.getDelegationScopePresets() 95 + if (result.ok) { 96 + scopePresets = (result.value.presets ?? []).map((p: DelegationScopePreset) => ({ 97 + name: p.name, 98 + label: p.name, 99 + description: p.description, 100 + scopes: unsafeAsScopeSet(p.scopes) 101 + })) 126 102 } 127 103 } 128 104 129 105 async function addController() { 130 - if (!session || !addControllerDid.trim()) return 106 + if (!currentClient || !addControllerDid.trim()) return 131 107 addingController = true 132 108 133 - try { 134 - const response = await fetch('/xrpc/_delegation.addController', { 135 - method: 'POST', 136 - headers: { 137 - 'Authorization': `Bearer ${session.accessJwt}`, 138 - 'Content-Type': 'application/json' 139 - }, 140 - body: JSON.stringify({ 141 - controller_did: addControllerDid.trim(), 142 - granted_scopes: addControllerScopes 143 - }) 144 - }) 145 - 146 - if (!response.ok) { 147 - const data = await response.json() 148 - toast.error(data.message || data.error || $_('delegation.failedToAddController')) 149 - return 150 - } 151 - 109 + const controllerDid = unsafeAsDid(addControllerDid.trim()) 110 + const scopes = unsafeAsScopeSet(addControllerScopes) 111 + const result = await currentClient.addDelegationController(controllerDid, scopes) 112 + if (result.ok) { 152 113 toast.success($_('delegation.controllerAdded')) 153 114 addControllerDid = '' 154 115 addControllerScopes = 'atproto' 155 116 addControllerConfirmed = false 156 117 showAddController = false 157 - await loadControllers() 158 - } catch (e) { 159 - toast.error($_('delegation.failedToAddController')) 160 - } finally { 161 - addingController = false 118 + await loadControllers(currentClient) 162 119 } 120 + addingController = false 163 121 } 164 122 165 - async function removeController(controllerDid: string) { 166 - if (!session) return 123 + async function removeController(controllerDid: Did) { 124 + if (!currentClient) return 167 125 if (!confirm($_('delegation.removeConfirm'))) return 168 126 169 - try { 170 - const response = await fetch('/xrpc/_delegation.removeController', { 171 - method: 'POST', 172 - headers: { 173 - 'Authorization': `Bearer ${session.accessJwt}`, 174 - 'Content-Type': 'application/json' 175 - }, 176 - body: JSON.stringify({ controller_did: controllerDid }) 177 - }) 178 - 179 - if (!response.ok) { 180 - const data = await response.json() 181 - toast.error(data.message || data.error || $_('delegation.failedToRemoveController')) 182 - return 183 - } 184 - 127 + const result = await currentClient.removeDelegationController(controllerDid) 128 + if (result.ok) { 185 129 toast.success($_('delegation.controllerRemoved')) 186 - await loadControllers() 187 - } catch (e) { 188 - toast.error($_('delegation.failedToRemoveController')) 130 + await loadControllers(currentClient) 189 131 } 190 132 } 191 133 192 134 async function createDelegatedAccount() { 193 - if (!session || !newDelegatedHandle.trim()) return 135 + if (!currentClient || !newDelegatedHandle.trim()) return 194 136 creatingDelegated = true 195 137 196 - try { 197 - const response = await fetch('/xrpc/_delegation.createDelegatedAccount', { 198 - method: 'POST', 199 - headers: { 200 - 'Authorization': `Bearer ${session.accessJwt}`, 201 - 'Content-Type': 'application/json' 202 - }, 203 - body: JSON.stringify({ 204 - handle: newDelegatedHandle.trim(), 205 - email: newDelegatedEmail.trim() || undefined, 206 - controllerScopes: newDelegatedScopes 207 - }) 208 - }) 209 - 210 - if (!response.ok) { 211 - const data = await response.json() 212 - toast.error(data.message || data.error || $_('delegation.failedToCreateAccount')) 213 - return 214 - } 215 - 216 - const data = await response.json() 217 - toast.success($_('delegation.accountCreated', { values: { handle: data.handle } })) 138 + const handle = unsafeAsHandle(newDelegatedHandle.trim()) 139 + const email = newDelegatedEmail.trim() ? unsafeAsEmail(newDelegatedEmail.trim()) : undefined 140 + const scopes = unsafeAsScopeSet(newDelegatedScopes) 141 + const result = await currentClient.createDelegatedAccount(handle, email, scopes) 142 + if (result.ok) { 143 + toast.success($_('delegation.accountCreated', { values: { handle: result.value.handle } })) 218 144 newDelegatedHandle = '' 219 145 newDelegatedEmail = '' 220 146 newDelegatedScopes = 'atproto' 221 147 showCreateDelegated = false 222 - await loadControlledAccounts() 223 - } catch (e) { 224 - toast.error($_('delegation.failedToCreateAccount')) 225 - } finally { 226 - creatingDelegated = false 148 + await loadControlledAccounts(currentClient) 227 149 } 150 + creatingDelegated = false 228 151 } 229 152 230 - function getScopeLabel(scopes: string): string { 153 + function getScopeLabel(scopes: ScopeSet): string { 231 154 const preset = scopePresets.find(p => p.scopes === scopes) 232 155 if (preset) return preset.label 233 - if (scopes === 'atproto') return $_('delegation.scopeOwner') 234 - if (scopes === '') return $_('delegation.scopeViewer') 156 + if ((scopes as string) === 'atproto') return $_('delegation.scopeOwner') 157 + if ((scopes as string) === '') return $_('delegation.scopeViewer') 235 158 return $_('delegation.scopeCustom') 236 159 } 237 160 </script> 238 161 239 - <div class="page"> 240 - <header> 241 - <a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a> 242 - <h1>{$_('delegation.title')}</h1> 243 - </header> 244 - 245 - {#if loading} 246 - <div class="skeleton-list"> 247 - {#each Array(2) as _} 248 - <div class="skeleton-card"></div> 249 - {/each} 250 - </div> 251 - {:else} 252 - <section class="section"> 253 - <div class="section-header"> 254 - <h2>{$_('delegation.controllers')}</h2> 255 - <p class="section-description">{$_('delegation.controllersDesc')}</p> 256 - </div> 257 - 258 - {#if controllers.length === 0} 259 - <p class="empty">{$_('delegation.noControllers')}</p> 162 + <AuthenticatedRoute onReady={handleReady}> 163 + {#snippet children({ session, client })} 164 + <div class="page"> 165 + <header> 166 + <a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a> 167 + <h1>{$_('delegation.title')}</h1> 168 + </header> 169 + 170 + {#if loading} 171 + <div class="skeleton-list"> 172 + {#each Array(2) as _} 173 + <div class="skeleton-card"></div> 174 + {/each} 175 + </div> 260 176 {:else} 261 - <div class="items-list"> 262 - {#each controllers as controller} 263 - <div class="item-card" class:inactive={!controller.isActive}> 264 - <div class="item-info"> 265 - <div class="item-header"> 266 - <span class="item-handle">@{controller.handle}</span> 267 - <span class="badge scope">{getScopeLabel(controller.grantedScopes)}</span> 268 - {#if !controller.isActive} 269 - <span class="badge inactive">{$_('delegation.inactive')}</span> 270 - {/if} 271 - </div> 272 - <div class="item-details"> 273 - <div class="detail"> 274 - <span class="label">{$_('delegation.did')}</span> 275 - <span class="value did">{controller.did}</span> 177 + <section class="section"> 178 + <div class="section-header"> 179 + <h2>{$_('delegation.controllers')}</h2> 180 + <p class="section-description">{$_('delegation.controllersDesc')}</p> 181 + </div> 182 + 183 + {#if controllers.length === 0} 184 + <p class="empty">{$_('delegation.noControllers')}</p> 185 + {:else} 186 + <div class="items-list"> 187 + {#each controllers as controller} 188 + <div class="item-card" class:inactive={!controller.isActive}> 189 + <div class="item-info"> 190 + <div class="item-header"> 191 + <span class="item-handle">@{controller.handle}</span> 192 + <span class="badge scope">{getScopeLabel(controller.grantedScopes)}</span> 193 + {#if !controller.isActive} 194 + <span class="badge inactive">{$_('delegation.inactive')}</span> 195 + {/if} 196 + </div> 197 + <div class="item-details"> 198 + <div class="detail"> 199 + <span class="label">{$_('delegation.did')}</span> 200 + <span class="value did">{controller.did}</span> 201 + </div> 202 + <div class="detail"> 203 + <span class="label">{$_('delegation.granted')}</span> 204 + <span class="value">{formatDateTime(controller.grantedAt)}</span> 205 + </div> 206 + </div> 276 207 </div> 277 - <div class="detail"> 278 - <span class="label">{$_('delegation.granted')}</span> 279 - <span class="value">{formatDateTime(controller.grantedAt)}</span> 208 + <div class="item-actions"> 209 + <button class="danger-outline" onclick={() => removeController(controller.did)}> 210 + {$_('delegation.remove')} 211 + </button> 280 212 </div> 281 213 </div> 282 - </div> 283 - <div class="item-actions"> 284 - <button class="danger-outline" onclick={() => removeController(controller.did)}> 285 - {$_('delegation.remove')} 286 - </button> 287 - </div> 214 + {/each} 288 215 </div> 289 - {/each} 290 - </div> 291 - {/if} 216 + {/if} 292 217 293 - {#if !canAddControllers} 294 - <div class="constraint-notice"> 295 - <p>{$_('delegation.cannotAddControllers')}</p> 296 - </div> 297 - {:else if showAddController} 298 - <div class="form-card"> 299 - <h3>{$_('delegation.addController')}</h3> 300 - 301 - <div class="warning-box"> 302 - <div class="warning-header"> 303 - <svg class="warning-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 304 - <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path> 305 - <line x1="12" y1="9" x2="12" y2="13"></line> 306 - <line x1="12" y1="17" x2="12.01" y2="17"></line> 307 - </svg> 308 - <span>{$_('delegation.addControllerWarningTitle')}</span> 218 + {#if !canAddControllers} 219 + <div class="constraint-notice"> 220 + <p>{$_('delegation.cannotAddControllers')}</p> 309 221 </div> 310 - <p class="warning-text">{$_('delegation.addControllerWarningText')}</p> 311 - <ul class="warning-bullets"> 312 - <li>{$_('delegation.addControllerWarningBullet1')}</li> 313 - <li>{$_('delegation.addControllerWarningBullet2')}</li> 314 - <li>{$_('delegation.addControllerWarningBullet3')}</li> 315 - </ul> 316 - </div> 222 + {:else if showAddController} 223 + <div class="form-card"> 224 + <h3>{$_('delegation.addController')}</h3> 225 + 226 + <div class="warning-box"> 227 + <div class="warning-header"> 228 + <svg class="warning-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 229 + <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path> 230 + <line x1="12" y1="9" x2="12" y2="13"></line> 231 + <line x1="12" y1="17" x2="12.01" y2="17"></line> 232 + </svg> 233 + <span>{$_('delegation.addControllerWarningTitle')}</span> 234 + </div> 235 + <p class="warning-text">{$_('delegation.addControllerWarningText')}</p> 236 + <ul class="warning-bullets"> 237 + <li>{$_('delegation.addControllerWarningBullet1')}</li> 238 + <li>{$_('delegation.addControllerWarningBullet2')}</li> 239 + <li>{$_('delegation.addControllerWarningBullet3')}</li> 240 + </ul> 241 + </div> 317 242 318 - <div class="field"> 319 - <label for="controllerDid">{$_('delegation.controllerDid')}</label> 320 - <input 321 - id="controllerDid" 322 - type="text" 323 - bind:value={addControllerDid} 324 - placeholder="did:plc:..." 325 - disabled={addingController} 326 - /> 327 - </div> 328 - <div class="field"> 329 - <label for="controllerScopes">{$_('delegation.accessLevel')}</label> 330 - <select id="controllerScopes" bind:value={addControllerScopes} disabled={addingController}> 331 - {#each scopePresets as preset} 332 - <option value={preset.scopes}>{preset.label} - {preset.description}</option> 333 - {/each} 334 - </select> 335 - </div> 336 - <label class="confirm-checkbox"> 337 - <input type="checkbox" bind:checked={addControllerConfirmed} disabled={addingController} /> 338 - <span>{$_('delegation.addControllerConfirm')}</span> 339 - </label> 340 - <div class="form-actions"> 341 - <button class="ghost" onclick={() => { showAddController = false; addControllerConfirmed = false }} disabled={addingController}> 342 - {$_('common.cancel')} 343 - </button> 344 - <button onclick={addController} disabled={addingController || !addControllerDid.trim() || !addControllerConfirmed}> 345 - {addingController ? $_('delegation.adding') : $_('delegation.addController')} 243 + <div class="field"> 244 + <label for="controllerDid">{$_('delegation.controllerDid')}</label> 245 + <input 246 + id="controllerDid" 247 + type="text" 248 + bind:value={addControllerDid} 249 + placeholder="did:plc:..." 250 + disabled={addingController} 251 + /> 252 + </div> 253 + <div class="field"> 254 + <label for="controllerScopes">{$_('delegation.accessLevel')}</label> 255 + <select id="controllerScopes" bind:value={addControllerScopes} disabled={addingController}> 256 + {#each scopePresets as preset} 257 + <option value={preset.scopes}>{preset.label} - {preset.description}</option> 258 + {/each} 259 + </select> 260 + </div> 261 + <label class="confirm-checkbox"> 262 + <input type="checkbox" bind:checked={addControllerConfirmed} disabled={addingController} /> 263 + <span>{$_('delegation.addControllerConfirm')}</span> 264 + </label> 265 + <div class="form-actions"> 266 + <button class="ghost" onclick={() => { showAddController = false; addControllerConfirmed = false }} disabled={addingController}> 267 + {$_('common.cancel')} 268 + </button> 269 + <button onclick={addController} disabled={addingController || !addControllerDid.trim() || !addControllerConfirmed}> 270 + {addingController ? $_('delegation.adding') : $_('delegation.addController')} 271 + </button> 272 + </div> 273 + </div> 274 + {:else} 275 + <button class="ghost full-width" onclick={() => showAddController = true}> 276 + {$_('delegation.addControllerButton')} 346 277 </button> 347 - </div> 348 - </div> 349 - {:else} 350 - <button class="ghost full-width" onclick={() => showAddController = true}> 351 - {$_('delegation.addControllerButton')} 352 - </button> 353 - {/if} 354 - </section> 278 + {/if} 279 + </section> 355 280 356 - <section class="section"> 357 - <div class="section-header"> 358 - <h2>{$_('delegation.controlledAccounts')}</h2> 359 - <p class="section-description">{$_('delegation.controlledAccountsDesc')}</p> 360 - </div> 281 + <section class="section"> 282 + <div class="section-header"> 283 + <h2>{$_('delegation.controlledAccounts')}</h2> 284 + <p class="section-description">{$_('delegation.controlledAccountsDesc')}</p> 285 + </div> 361 286 362 - {#if controlledAccounts.length === 0} 363 - <p class="empty">{$_('delegation.noControlledAccounts')}</p> 364 - {:else} 365 - <div class="items-list"> 366 - {#each controlledAccounts as account} 367 - <div class="item-card"> 368 - <div class="item-info"> 369 - <div class="item-header"> 370 - <span class="item-handle">@{account.handle}</span> 371 - <span class="badge scope">{getScopeLabel(account.grantedScopes)}</span> 372 - </div> 373 - <div class="item-details"> 374 - <div class="detail"> 375 - <span class="label">{$_('delegation.did')}</span> 376 - <span class="value did">{account.did}</span> 287 + {#if controlledAccounts.length === 0} 288 + <p class="empty">{$_('delegation.noControlledAccounts')}</p> 289 + {:else} 290 + <div class="items-list"> 291 + {#each controlledAccounts as account} 292 + <div class="item-card"> 293 + <div class="item-info"> 294 + <div class="item-header"> 295 + <span class="item-handle">@{account.handle}</span> 296 + <span class="badge scope">{getScopeLabel(account.grantedScopes)}</span> 297 + </div> 298 + <div class="item-details"> 299 + <div class="detail"> 300 + <span class="label">{$_('delegation.did')}</span> 301 + <span class="value did">{account.did}</span> 302 + </div> 303 + <div class="detail"> 304 + <span class="label">{$_('delegation.granted')}</span> 305 + <span class="value">{formatDateTime(account.grantedAt)}</span> 306 + </div> 307 + </div> 377 308 </div> 378 - <div class="detail"> 379 - <span class="label">{$_('delegation.granted')}</span> 380 - <span class="value">{formatDateTime(account.grantedAt)}</span> 309 + <div class="item-actions"> 310 + <a href="/app/act-as?did={encodeURIComponent(account.did)}" class="btn-link"> 311 + {$_('delegation.actAs')} 312 + </a> 381 313 </div> 382 314 </div> 315 + {/each} 316 + </div> 317 + {/if} 318 + 319 + {#if !canControlAccounts} 320 + <div class="constraint-notice"> 321 + <p>{$_('delegation.cannotControlAccounts')}</p> 322 + </div> 323 + {:else if showCreateDelegated} 324 + <div class="form-card"> 325 + <h3>{$_('delegation.createDelegatedAccount')}</h3> 326 + <div class="field"> 327 + <label for="delegatedHandle">{$_('delegation.handle')}</label> 328 + <input 329 + id="delegatedHandle" 330 + type="text" 331 + bind:value={newDelegatedHandle} 332 + placeholder="username" 333 + disabled={creatingDelegated} 334 + /> 383 335 </div> 384 - <div class="item-actions"> 385 - <a href="/app/act-as?did={encodeURIComponent(account.did)}" class="btn-link"> 386 - {$_('delegation.actAs')} 387 - </a> 336 + <div class="field"> 337 + <label for="delegatedEmail">{$_('delegation.emailOptional')}</label> 338 + <input 339 + id="delegatedEmail" 340 + type="email" 341 + bind:value={newDelegatedEmail} 342 + placeholder="email@example.com" 343 + disabled={creatingDelegated} 344 + /> 345 + </div> 346 + <div class="field"> 347 + <label for="delegatedScopes">{$_('delegation.yourAccessLevel')}</label> 348 + <select id="delegatedScopes" bind:value={newDelegatedScopes} disabled={creatingDelegated}> 349 + {#each scopePresets as preset} 350 + <option value={preset.scopes}>{preset.label} - {preset.description}</option> 351 + {/each} 352 + </select> 353 + </div> 354 + <div class="form-actions"> 355 + <button class="ghost" onclick={() => showCreateDelegated = false} disabled={creatingDelegated}> 356 + {$_('common.cancel')} 357 + </button> 358 + <button onclick={createDelegatedAccount} disabled={creatingDelegated || !newDelegatedHandle.trim()}> 359 + {creatingDelegated ? $_('common.creating') : $_('delegation.createAccount')} 360 + </button> 388 361 </div> 389 362 </div> 390 - {/each} 391 - </div> 392 - {/if} 393 - 394 - {#if !canControlAccounts} 395 - <div class="constraint-notice"> 396 - <p>{$_('delegation.cannotControlAccounts')}</p> 397 - </div> 398 - {:else if showCreateDelegated} 399 - <div class="form-card"> 400 - <h3>{$_('delegation.createDelegatedAccount')}</h3> 401 - <div class="field"> 402 - <label for="delegatedHandle">{$_('delegation.handle')}</label> 403 - <input 404 - id="delegatedHandle" 405 - type="text" 406 - bind:value={newDelegatedHandle} 407 - placeholder="username" 408 - disabled={creatingDelegated} 409 - /> 410 - </div> 411 - <div class="field"> 412 - <label for="delegatedEmail">{$_('delegation.emailOptional')}</label> 413 - <input 414 - id="delegatedEmail" 415 - type="email" 416 - bind:value={newDelegatedEmail} 417 - placeholder="email@example.com" 418 - disabled={creatingDelegated} 419 - /> 420 - </div> 421 - <div class="field"> 422 - <label for="delegatedScopes">{$_('delegation.yourAccessLevel')}</label> 423 - <select id="delegatedScopes" bind:value={newDelegatedScopes} disabled={creatingDelegated}> 424 - {#each scopePresets as preset} 425 - <option value={preset.scopes}>{preset.label} - {preset.description}</option> 426 - {/each} 427 - </select> 428 - </div> 429 - <div class="form-actions"> 430 - <button class="ghost" onclick={() => showCreateDelegated = false} disabled={creatingDelegated}> 431 - {$_('common.cancel')} 432 - </button> 433 - <button onclick={createDelegatedAccount} disabled={creatingDelegated || !newDelegatedHandle.trim()}> 434 - {creatingDelegated ? $_('common.creating') : $_('delegation.createAccount')} 363 + {:else} 364 + <button class="ghost full-width" onclick={() => showCreateDelegated = true}> 365 + {$_('delegation.createDelegatedAccountButton')} 435 366 </button> 367 + {/if} 368 + </section> 369 + 370 + <section class="section"> 371 + <div class="section-header"> 372 + <h2>{$_('delegation.auditLog')}</h2> 373 + <p class="section-description">{$_('delegation.auditLogDesc')}</p> 436 374 </div> 437 - </div> 438 - {:else} 439 - <button class="ghost full-width" onclick={() => showCreateDelegated = true}> 440 - {$_('delegation.createDelegatedAccountButton')} 441 - </button> 375 + <a href="/app/delegation-audit" class="btn-link">{$_('delegation.viewAuditLog')}</a> 376 + </section> 442 377 {/if} 443 - </section> 444 - 445 - <section class="section"> 446 - <div class="section-header"> 447 - <h2>{$_('delegation.auditLog')}</h2> 448 - <p class="section-description">{$_('delegation.auditLogDesc')}</p> 449 - </div> 450 - <a href="/app/delegation-audit" class="btn-link">{$_('delegation.viewAuditLog')}</a> 451 - </section> 452 - {/if} 453 - </div> 378 + </div> 379 + {/snippet} 380 + </AuthenticatedRoute> 454 381 455 382 <style> 456 383 .page { ··· 769 696 border-radius: var(--radius-xl); 770 697 animation: skeleton-pulse 1.5s ease-in-out infinite; 771 698 } 772 - 773 699 </style>
+8 -7
frontend/src/routes/Dashboard.svelte
··· 11 11 import { isOk } from '../lib/types/result' 12 12 import { unsafeAsDid, type Did } from '../lib/types/branded' 13 13 import type { Session } from '../lib/types/api' 14 + import { isMigrated, isDeactivated, getSessionEmail, isEmailVerified } from '../lib/types/api' 14 15 import { onMount } from 'svelte' 15 16 16 17 const auth = $derived(getAuthState()) ··· 123 124 </div> 124 125 </header> 125 126 126 - {#if session.status === 'migrated'} 127 + {#if session.accountKind === 'migrated'} 127 128 <div class="migrated-banner"> 128 129 <strong>{$_('dashboard.migratedTitle')}</strong> 129 130 <p>{$_('dashboard.migratedMessage', { values: { pds: session.migratedToPds || 'another PDS' } })}</p> 130 131 </div> 131 - {:else if session.status === 'deactivated' || session.active === false} 132 + {:else if session.accountKind === 'deactivated'} 132 133 <div class="deactivated-banner"> 133 134 <strong>{$_('dashboard.deactivatedTitle')}</strong> 134 135 <p>{$_('dashboard.deactivatedMessage')}</p> ··· 144 145 {#if session.isAdmin} 145 146 <span class="badge admin">{$_('dashboard.admin')}</span> 146 147 {/if} 147 - {#if session.status === 'migrated'} 148 + {#if session.accountKind === 'migrated'} 148 149 <span class="badge migrated">{$_('dashboard.migrated')}</span> 149 - {:else if session.status === 'deactivated' || session.active === false} 150 + {:else if session.accountKind === 'deactivated'} 150 151 <span class="badge deactivated">{$_('dashboard.deactivated')}</span> 151 152 {/if} 152 153 </dd> 153 154 <dt>{$_('dashboard.did')}</dt> 154 155 <dd class="mono">{session.did}</dd> 155 - {#if session.preferredChannel} 156 + {#if session.contactKind === 'channel'} 156 157 <dt>{$_('dashboard.primaryContact')}</dt> 157 158 <dd> 158 159 {#if session.preferredChannel === 'email'} ··· 172 173 <span class="badge warning">{$_('dashboard.unverified')}</span> 173 174 {/if} 174 175 </dd> 175 - {:else if session.email} 176 + {:else if session.contactKind === 'email'} 176 177 <dt>{$_('register.email')}</dt> 177 178 <dd> 178 179 {session.email} ··· 187 188 </section> 188 189 189 190 <nav class="nav-grid"> 190 - {#if session.status === 'migrated'} 191 + {#if session.accountKind === 'migrated'} 191 192 <a href={getFullUrl(routes.didDocument)} class="nav-card migrated-card"> 192 193 <h3>{$_('dashboard.navDidDocument')}</h3> 193 194 <p>{$_('dashboard.navDidDocumentDesc')}</p>
+101 -126
frontend/src/routes/DelegationAudit.svelte
··· 1 1 <script lang="ts"> 2 - import { getAuthState } from '../lib/auth.svelte' 3 - import { navigate, routes, getFullUrl } from '../lib/router.svelte' 2 + import AuthenticatedRoute from '../components/AuthenticatedRoute.svelte' 4 3 import { _ } from '../lib/i18n' 5 4 import { formatDateTime } from '../lib/date' 6 - import type { Session } from '../lib/types/api' 7 - import { toast } from '../lib/toast.svelte' 5 + import type { DelegationAuditEntry } from '../lib/types/api' 6 + import type { AuthenticatedClient } from '../lib/authenticated-client' 8 7 9 8 interface AuditEntry { 10 9 id: string ··· 16 15 createdAt: string 17 16 } 18 17 19 - const auth = $derived(getAuthState()) 20 - 21 - function getSession(): Session | null { 22 - return auth.kind === 'authenticated' ? auth.session : null 23 - } 24 - 25 - function isLoading(): boolean { 26 - return auth.kind === 'loading' 27 - } 28 - 29 - const session = $derived(getSession()) 30 - const authLoading = $derived(isLoading()) 31 - 32 18 let loading = $state(true) 33 19 let entries = $state<AuditEntry[]>([]) 34 20 let total = $state(0) 35 21 let offset = $state(0) 36 22 const limit = 20 37 23 38 - $effect(() => { 39 - if (!authLoading && !session) { 40 - navigate(routes.login) 41 - } 42 - }) 24 + let currentClient: AuthenticatedClient | null = $state(null) 43 25 44 - $effect(() => { 45 - if (session) { 46 - loadAuditLog() 47 - } 48 - }) 26 + function handleReady(_session: unknown, client: AuthenticatedClient) { 27 + currentClient = client 28 + loadAuditLog(client) 29 + } 49 30 50 - async function loadAuditLog() { 51 - if (!session) return 31 + async function loadAuditLog(client: AuthenticatedClient) { 52 32 loading = true 53 33 54 - try { 55 - const response = await fetch( 56 - `/xrpc/_delegation.getAuditLog?limit=${limit}&offset=${offset}`, 57 - { 58 - headers: { 'Authorization': `Bearer ${session.accessJwt}` } 59 - } 60 - ) 61 - 62 - if (!response.ok) { 63 - const data = await response.json() 64 - toast.error(data.message || data.error || $_('delegation.failedToLoadAuditLog')) 65 - return 66 - } 67 - 68 - const data = await response.json() 69 - entries = data.entries || [] 70 - total = data.total || 0 71 - } catch (e) { 72 - toast.error($_('delegation.failedToLoadAuditLog')) 73 - } finally { 74 - loading = false 34 + const result = await client.getDelegationAuditLog(limit, offset) 35 + if (result.ok) { 36 + entries = (result.value.entries ?? []).map((e: DelegationAuditEntry) => ({ 37 + id: e.id, 38 + delegatedDid: e.target_did ?? '', 39 + actorDid: e.actor_did, 40 + controllerDid: null, 41 + actionType: e.action, 42 + actionDetails: e.details ? JSON.parse(e.details) : null, 43 + createdAt: e.created_at 44 + })) 45 + total = result.value.total ?? 0 75 46 } 47 + loading = false 76 48 } 77 49 78 50 function prevPage() { 79 - if (offset > 0) { 51 + if (offset > 0 && currentClient) { 80 52 offset = Math.max(0, offset - limit) 81 - loadAuditLog() 53 + loadAuditLog(currentClient) 82 54 } 83 55 } 84 56 85 57 function nextPage() { 86 - if (offset + limit < total) { 58 + if (offset + limit < total && currentClient) { 87 59 offset = offset + limit 88 - loadAuditLog() 60 + loadAuditLog(currentClient) 89 61 } 90 62 } 91 63 ··· 115 87 } 116 88 </script> 117 89 118 - <div class="page"> 119 - <header> 120 - <a href="/app/controllers" class="back">{$_('delegation.backToControllers')}</a> 121 - <h1>{$_('delegation.auditLogTitle')}</h1> 122 - </header> 123 - 124 - {#if loading} 125 - <div class="skeleton-list"> 126 - {#each Array(3) as _} 127 - <div class="skeleton-entry"></div> 128 - {/each} 129 - </div> 130 - {:else} 131 - {#if entries.length === 0} 132 - <p class="empty">{$_('delegation.noActivity')}</p> 133 - {:else} 134 - <div class="audit-list"> 135 - {#each entries as entry} 136 - <div class="audit-entry"> 137 - <div class="entry-header"> 138 - <span class="action-type">{formatActionType(entry.actionType)}</span> 139 - <span class="timestamp">{formatDateTime(entry.createdAt)}</span> 140 - </div> 141 - <div class="entry-details"> 142 - <div class="detail"> 143 - <span class="label">{$_('delegation.actor')}</span> 144 - <span class="value did" title={entry.actorDid}>{truncateDid(entry.actorDid)}</span> 145 - </div> 146 - {#if entry.controllerDid} 147 - <div class="detail"> 148 - <span class="label">{$_('delegation.controller')}</span> 149 - <span class="value did" title={entry.controllerDid}>{truncateDid(entry.controllerDid)}</span> 90 + <AuthenticatedRoute onReady={handleReady}> 91 + {#snippet children({ session, client })} 92 + <div class="page"> 93 + <header> 94 + <a href="/app/controllers" class="back">{$_('delegation.backToControllers')}</a> 95 + <h1>{$_('delegation.auditLogTitle')}</h1> 96 + </header> 97 + 98 + {#if loading} 99 + <div class="skeleton-list"> 100 + {#each Array(3) as _} 101 + <div class="skeleton-entry"></div> 102 + {/each} 103 + </div> 104 + {:else} 105 + {#if entries.length === 0} 106 + <p class="empty">{$_('delegation.noActivity')}</p> 107 + {:else} 108 + <div class="audit-list"> 109 + {#each entries as entry} 110 + <div class="audit-entry"> 111 + <div class="entry-header"> 112 + <span class="action-type">{formatActionType(entry.actionType)}</span> 113 + <span class="timestamp">{formatDateTime(entry.createdAt)}</span> 150 114 </div> 151 - {/if} 152 - <div class="detail"> 153 - <span class="label">{$_('delegation.account')}</span> 154 - <span class="value did" title={entry.delegatedDid}>{truncateDid(entry.delegatedDid)}</span> 155 - </div> 156 - {#if entry.actionDetails} 157 - <div class="detail"> 158 - <span class="label">{$_('delegation.details')}</span> 159 - <span class="value details">{formatActionDetails(entry.actionDetails)}</span> 115 + <div class="entry-details"> 116 + <div class="detail"> 117 + <span class="label">{$_('delegation.actor')}</span> 118 + <span class="value did" title={entry.actorDid}>{truncateDid(entry.actorDid)}</span> 119 + </div> 120 + {#if entry.controllerDid} 121 + <div class="detail"> 122 + <span class="label">{$_('delegation.controller')}</span> 123 + <span class="value did" title={entry.controllerDid}>{truncateDid(entry.controllerDid)}</span> 124 + </div> 125 + {/if} 126 + <div class="detail"> 127 + <span class="label">{$_('delegation.account')}</span> 128 + <span class="value did" title={entry.delegatedDid}>{truncateDid(entry.delegatedDid)}</span> 129 + </div> 130 + {#if entry.actionDetails} 131 + <div class="detail"> 132 + <span class="label">{$_('delegation.details')}</span> 133 + <span class="value details">{formatActionDetails(entry.actionDetails)}</span> 134 + </div> 135 + {/if} 160 136 </div> 161 - {/if} 162 - </div> 137 + </div> 138 + {/each} 163 139 </div> 164 - {/each} 165 - </div> 166 - 167 - <div class="pagination"> 168 - <button 169 - class="ghost" 170 - onclick={prevPage} 171 - disabled={offset === 0} 172 - > 173 - {$_('delegation.previous')} 174 - </button> 175 - <span class="page-info"> 176 - {$_('delegation.showing', { values: { start: offset + 1, end: Math.min(offset + limit, total), total } })} 177 - </span> 178 - <button 179 - class="ghost" 180 - onclick={nextPage} 181 - disabled={offset + limit >= total} 182 - > 183 - {$_('delegation.next')} 184 - </button> 185 - </div> 186 - {/if} 187 - 188 - <div class="actions-bar"> 189 - <button class="ghost" onclick={loadAuditLog}>{$_('delegation.refresh')}</button> 140 + 141 + <div class="pagination"> 142 + <button 143 + class="ghost" 144 + onclick={prevPage} 145 + disabled={offset === 0} 146 + > 147 + {$_('delegation.previous')} 148 + </button> 149 + <span class="page-info"> 150 + {$_('delegation.showing', { values: { start: offset + 1, end: Math.min(offset + limit, total), total } })} 151 + </span> 152 + <button 153 + class="ghost" 154 + onclick={nextPage} 155 + disabled={offset + limit >= total} 156 + > 157 + {$_('delegation.next')} 158 + </button> 159 + </div> 160 + {/if} 161 + 162 + <div class="actions-bar"> 163 + <button class="ghost" onclick={() => currentClient && loadAuditLog(currentClient)}>{$_('delegation.refresh')}</button> 164 + </div> 165 + {/if} 190 166 </div> 191 - {/if} 192 - </div> 167 + {/snippet} 168 + </AuthenticatedRoute> 193 169 194 170 <style> 195 171 .page { ··· 332 308 border-radius: var(--radius-lg); 333 309 animation: skeleton-pulse 1.5s ease-in-out infinite; 334 310 } 335 - 336 311 </style>
+93 -108
frontend/src/routes/Security.svelte
··· 1 1 <script lang="ts"> 2 - import { getAuthState, getValidToken } from '../lib/auth.svelte' 3 - import { navigate, routes, getFullUrl } from '../lib/router.svelte' 2 + import AuthenticatedRoute from '../components/AuthenticatedRoute.svelte' 3 + import { getValidToken } from '../lib/auth.svelte' 4 + import { routes, getFullUrl } from '../lib/router.svelte' 4 5 import { api, ApiError } from '../lib/api' 5 6 import ReauthModal from '../components/ReauthModal.svelte' 6 7 import SsoIcon from '../components/SsoIcon.svelte' 7 8 import { _ } from '../lib/i18n' 8 9 import { formatDate as formatDateUtil } from '../lib/date' 9 - import type { Session } from '../lib/types/api' 10 + import type { Session, SsoLinkedAccount } from '../lib/types/api' 11 + import type { AuthenticatedClient } from '../lib/authenticated-client' 10 12 import { 11 13 prepareCreationOptions, 12 14 serializeAttestationResponse, 13 15 type WebAuthnCreationOptionsResponse, 14 16 } from '../lib/webauthn' 15 17 import { toast } from '../lib/toast.svelte' 18 + import { 19 + type TotpSetupState, 20 + idleState, 21 + qrState, 22 + verifyState, 23 + backupState, 24 + goBackToQr, 25 + finish, 26 + type TotpQr, 27 + } from '../lib/types/totp-state' 16 28 17 29 interface SsoProvider { 18 30 provider: string ··· 20 32 icon: string 21 33 } 22 34 23 - interface LinkedAccount { 24 - id: string 25 - provider: string 26 - provider_name: string 27 - provider_username: string | null 28 - provider_email: string | null 29 - created_at: string 30 - last_login_at: string | null 31 - } 32 - 33 - const auth = $derived(getAuthState()) 34 - 35 - function getSession(): Session | null { 36 - return auth.kind === 'authenticated' ? auth.session : null 37 - } 38 - 39 - function isLoading(): boolean { 40 - return auth.kind === 'loading' 41 - } 42 - 43 - const session = $derived(getSession()) 44 - const authLoading = $derived(isLoading()) 35 + let currentSession: Session | null = $state(null) 36 + let currentClient: AuthenticatedClient | null = $state(null) 45 37 46 38 let loading = $state(true) 47 39 let totpEnabled = $state(false) 48 40 let hasBackupCodes = $state(false) 49 - let setupStep = $state<'idle' | 'qr' | 'verify' | 'backup'>('idle') 50 - let qrBase64 = $state('') 51 - let totpUri = $state('') 41 + let totpSetup = $state<TotpSetupState>(idleState) 52 42 let verifyCodeRaw = $state('') 53 43 let verifyCode = $derived(verifyCodeRaw.replace(/\s/g, '')) 54 44 let verifyLoading = $state(false) 55 - let backupCodes = $state<string[]>([]) 56 45 let disablePassword = $state('') 57 46 let disableCode = $state('') 58 47 let disableLoading = $state(false) ··· 87 76 let legacyLoginUpdating = $state(false) 88 77 89 78 let ssoProviders = $state<SsoProvider[]>([]) 90 - let linkedAccounts = $state<LinkedAccount[]>([]) 79 + let linkedAccounts = $state<SsoLinkedAccount[]>([]) 91 80 let linkedAccountsLoading = $state(true) 92 81 let linkingProvider = $state<string | null>(null) 93 82 let unlinkingId = $state<string | null>(null) ··· 97 86 let pendingAction = $state<(() => Promise<void>) | null>(null) 98 87 99 88 $effect(() => { 100 - if (!authLoading && !session) { 101 - navigate(routes.login) 102 - } 103 - }) 104 - 105 - $effect(() => { 106 - if (session) { 89 + if (currentSession && currentClient) { 107 90 loadTotpStatus() 108 91 loadPasskeys() 109 92 loadPasswordStatus() ··· 126 109 } 127 110 128 111 async function loadLinkedAccounts() { 129 - if (!session) return 112 + if (!currentClient) return 130 113 linkedAccountsLoading = true 131 114 try { 132 - const response = await fetch('/oauth/sso/linked', { 133 - headers: { 'Authorization': `Bearer ${session.accessJwt}` } 134 - }) 135 - if (response.ok) { 136 - const data = await response.json() 137 - linkedAccounts = data.accounts || [] 138 - } 115 + const data = await currentClient.getSsoLinkedAccounts() 116 + linkedAccounts = data.accounts || [] 139 117 } catch { 140 118 linkedAccounts = [] 141 119 } finally { ··· 154 132 headers: { 155 133 'Content-Type': 'application/json', 156 134 'Accept': 'application/json', 157 - 'Authorization': `Bearer ${session?.accessJwt}` 135 + 'Authorization': `Bearer ${currentSession?.accessJwt}` 158 136 }, 159 137 body: JSON.stringify({ 160 138 provider, ··· 200 178 method: 'POST', 201 179 headers: { 202 180 'Content-Type': 'application/json', 203 - 'Authorization': `Bearer ${session?.accessJwt}` 181 + 'Authorization': `Bearer ${currentSession?.accessJwt}` 204 182 }, 205 183 body: JSON.stringify({ id }) 206 184 }) ··· 228 206 } 229 207 230 208 async function loadPasswordStatus() { 231 - if (!session) return 209 + if (!currentSession) return 232 210 passwordLoading = true 233 211 try { 234 - const status = await api.getPasswordStatus(session.accessJwt) 212 + const status = await api.getPasswordStatus(currentSession.accessJwt) 235 213 hasPassword = status.hasPassword 236 214 } catch { 237 215 hasPassword = true ··· 241 219 } 242 220 243 221 async function loadLegacyLoginPreference() { 244 - if (!session) return 222 + if (!currentSession) return 245 223 legacyLoginLoading = true 246 224 try { 247 - const pref = await api.getLegacyLoginPreference(session.accessJwt) 225 + const pref = await api.getLegacyLoginPreference(currentSession.accessJwt) 248 226 allowLegacyLogin = pref.allowLegacyLogin 249 227 hasMfa = pref.hasMfa 250 228 } catch { ··· 256 234 } 257 235 258 236 async function handleToggleLegacyLogin() { 259 - if (!session) return 237 + if (!currentSession) return 260 238 legacyLoginUpdating = true 261 239 try { 262 - const result = await api.updateLegacyLoginPreference(session.accessJwt, !allowLegacyLogin) 240 + const result = await api.updateLegacyLoginPreference(currentSession.accessJwt, !allowLegacyLogin) 263 241 allowLegacyLogin = result.allowLegacyLogin 264 242 toast.success(allowLegacyLogin 265 243 ? $_('security.legacyLoginEnabled') ··· 282 260 } 283 261 284 262 async function handleRemovePassword() { 285 - if (!session) return 263 + if (!currentSession) return 286 264 removePasswordLoading = true 287 265 try { 288 266 const token = await getValidToken() ··· 323 301 } 324 302 325 303 async function loadTotpStatus() { 326 - if (!session) return 304 + if (!currentSession) return 327 305 loading = true 328 306 try { 329 - const status = await api.getTotpStatus(session.accessJwt) 307 + const status = await api.getTotpStatus(currentSession.accessJwt) 330 308 totpEnabled = status.enabled 331 309 hasBackupCodes = status.hasBackupCodes 332 310 } catch { ··· 337 315 } 338 316 339 317 async function handleStartSetup() { 340 - if (!session) return 318 + if (!currentSession) return 341 319 verifyLoading = true 342 320 try { 343 - const result = await api.createTotpSecret(session.accessJwt) 344 - qrBase64 = result.qrBase64 345 - totpUri = result.uri 346 - setupStep = 'qr' 321 + const result = await api.createTotpSecret(currentSession.accessJwt) 322 + totpSetup = qrState(result.qrBase64, result.uri) 347 323 } catch (e) { 348 324 toast.error(e instanceof ApiError ? e.message : 'Failed to generate TOTP secret') 349 325 } finally { ··· 353 329 354 330 async function handleVerifySetup(e: Event) { 355 331 e.preventDefault() 356 - if (!session || !verifyCode) return 332 + if (!currentSession || !verifyCode || totpSetup.step !== 'verify') return 357 333 verifyLoading = true 358 334 try { 359 - const result = await api.enableTotp(session.accessJwt, verifyCode) 360 - backupCodes = result.backupCodes 361 - setupStep = 'backup' 335 + const result = await api.enableTotp(currentSession.accessJwt, verifyCode) 336 + totpSetup = backupState(totpSetup, result.backupCodes) 362 337 totpEnabled = true 363 338 hasBackupCodes = true 364 339 verifyCodeRaw = '' ··· 370 345 } 371 346 372 347 function handleFinishSetup() { 373 - setupStep = 'idle' 374 - backupCodes = [] 375 - qrBase64 = '' 376 - totpUri = '' 348 + if (totpSetup.step !== 'backup') return 349 + totpSetup = finish(totpSetup) 377 350 toast.success($_('security.totpEnabledSuccess')) 378 351 } 379 352 380 353 async function handleDisable(e: Event) { 381 354 e.preventDefault() 382 - if (!session || !disablePassword || !disableCode) return 355 + if (!currentSession || !disablePassword || !disableCode) return 383 356 disableLoading = true 384 357 try { 385 - await api.disableTotp(session.accessJwt, disablePassword, disableCode) 358 + await api.disableTotp(currentSession.accessJwt, disablePassword, disableCode) 386 359 totpEnabled = false 387 360 hasBackupCodes = false 388 361 showDisableForm = false ··· 398 371 399 372 async function handleRegenerate(e: Event) { 400 373 e.preventDefault() 401 - if (!session || !regenPassword || !regenCode) return 374 + if (!currentSession || !regenPassword || !regenCode) return 402 375 regenLoading = true 403 376 try { 404 - const result = await api.regenerateBackupCodes(session.accessJwt, regenPassword, regenCode) 405 - backupCodes = result.backupCodes 406 - setupStep = 'backup' 377 + const result = await api.regenerateBackupCodes(currentSession.accessJwt, regenPassword, regenCode) 378 + const dummyVerify = verifyState(qrState('', '')) 379 + totpSetup = backupState(dummyVerify, result.backupCodes) 407 380 showRegenForm = false 408 381 regenPassword = '' 409 382 regenCode = '' ··· 415 388 } 416 389 417 390 function copyBackupCodes() { 418 - const text = backupCodes.join('\n') 391 + if (totpSetup.step !== 'backup') return 392 + const text = totpSetup.backupCodes.join('\n') 419 393 navigator.clipboard.writeText(text) 420 394 toast.success($_('security.backupCodesCopied')) 421 395 } 422 396 423 397 async function loadPasskeys() { 424 - if (!session) return 398 + if (!currentSession) return 425 399 passkeysLoading = true 426 400 try { 427 - const result = await api.listPasskeys(session.accessJwt) 401 + const result = await api.listPasskeys(currentSession.accessJwt) 428 402 passkeys = result.passkeys 429 403 } catch { 430 404 toast.error($_('security.failedToLoadPasskeys')) ··· 434 408 } 435 409 436 410 async function handleAddPasskey() { 437 - if (!session) return 411 + if (!currentSession) return 438 412 if (!window.PublicKeyCredential) { 439 413 toast.error($_('security.passkeysNotSupported')) 440 414 return 441 415 } 442 416 addingPasskey = true 443 417 try { 444 - const { options } = await api.startPasskeyRegistration(session.accessJwt, newPasskeyName || undefined) 418 + const { options } = await api.startPasskeyRegistration(currentSession.accessJwt, newPasskeyName || undefined) 445 419 const publicKeyOptions = prepareCreationOptions(options as unknown as WebAuthnCreationOptionsResponse) 446 420 const credential = await navigator.credentials.create({ 447 421 publicKey: publicKeyOptions ··· 451 425 return 452 426 } 453 427 const credentialResponse = serializeAttestationResponse(credential as PublicKeyCredential) 454 - await api.finishPasskeyRegistration(session.accessJwt, credentialResponse, newPasskeyName || undefined) 428 + await api.finishPasskeyRegistration(currentSession.accessJwt, credentialResponse, newPasskeyName || undefined) 455 429 await loadPasskeys() 456 430 newPasskeyName = '' 457 431 toast.success($_('security.passkeyAddedSuccess')) ··· 467 441 } 468 442 469 443 async function handleDeletePasskey(id: string) { 470 - if (!session) return 444 + if (!currentSession) return 471 445 const passkey = passkeys.find(p => p.id === id) 472 446 const name = passkey?.friendlyName || 'this passkey' 473 447 if (!confirm($_('security.deletePasskeyConfirm', { values: { name } }))) return 474 448 try { 475 - await api.deletePasskey(session.accessJwt, id) 449 + await api.deletePasskey(currentSession.accessJwt, id) 476 450 await loadPasskeys() 477 451 toast.success($_('security.passkeyDeleted')) 478 452 } catch (e) { ··· 481 455 } 482 456 483 457 async function handleSavePasskeyName() { 484 - if (!session || !editingPasskeyId || !editPasskeyName.trim()) return 458 + if (!currentSession || !editingPasskeyId || !editPasskeyName.trim()) return 485 459 try { 486 - await api.updatePasskey(session.accessJwt, editingPasskeyId, editPasskeyName.trim()) 460 + await api.updatePasskey(currentSession.accessJwt, editingPasskeyId, editPasskeyName.trim()) 487 461 await loadPasskeys() 488 462 editingPasskeyId = null 489 463 editPasskeyName = '' ··· 506 480 function formatDate(dateStr: string): string { 507 481 return formatDateUtil(dateStr) 508 482 } 483 + 484 + function handleReady(session: Session, client: AuthenticatedClient) { 485 + currentSession = session 486 + currentClient = client 487 + } 509 488 </script> 510 489 511 - <div class="page"> 512 - <header> 513 - <a href={getFullUrl(routes.dashboard)} class="back">{$_('common.backToDashboard')}</a> 514 - <h1>{$_('security.title')}</h1> 515 - </header> 490 + <AuthenticatedRoute onReady={handleReady}> 491 + {#snippet children({ session, client })} 492 + <div class="page"> 493 + <header> 494 + <a href={getFullUrl(routes.dashboard)} class="back">{$_('common.backToDashboard')}</a> 495 + <h1>{$_('security.title')}</h1> 496 + </header> 516 497 517 - {#if loading} 498 + {#if loading} 518 499 <div class="skeleton-grid"> 519 500 {#each Array(4) as _} 520 501 <div class="skeleton-section"></div> ··· 528 509 {$_('security.totpDescription')} 529 510 </p> 530 511 531 - {#if setupStep === 'idle'} 512 + {#if totpSetup.step === 'idle'} 532 513 {#if totpEnabled} 533 514 <div class="status enabled"> 534 515 <span>{$_('security.totpEnabled')}</span> ··· 632 613 {$_('security.enableTotp')} 633 614 </button> 634 615 {/if} 635 - {:else if setupStep === 'qr'} 616 + {:else if totpSetup.step === 'qr'} 617 + {@const qrData = totpSetup as TotpQr} 636 618 <div class="setup-step"> 637 619 <h3>{$_('security.totpSetup')}</h3> 638 620 <p>{$_('security.totpSetupInstructions')}</p> 639 621 <div class="qr-container"> 640 - <img src="data:image/png;base64,{qrBase64}" alt="TOTP QR Code" class="qr-code" /> 622 + <img src="data:image/png;base64,{qrData.qrBase64}" alt="TOTP QR Code" class="qr-code" /> 641 623 </div> 642 624 <details class="manual-entry"> 643 625 <summary>{$_('security.cantScan')}</summary> 644 - <code class="secret-code">{totpUri.split('secret=')[1]?.split('&')[0] || ''}</code> 626 + <code class="secret-code">{qrData.totpUri.split('secret=')[1]?.split('&')[0] || ''}</code> 645 627 </details> 646 - <button onclick={() => setupStep = 'verify'}> 628 + <button onclick={() => totpSetup = verifyState(qrData)}> 647 629 {$_('security.next')} 648 630 </button> 649 631 </div> 650 - {:else if setupStep === 'verify'} 632 + {:else if totpSetup.step === 'verify'} 633 + {@const verifyData = totpSetup} 651 634 <div class="setup-step"> 652 635 <h3>{$_('security.totpSetup')}</h3> 653 636 <p>{$_('security.totpCodePlaceholder')}</p> ··· 663 646 /> 664 647 </div> 665 648 <div class="actions"> 666 - <button type="button" class="secondary" onclick={() => { setupStep = 'qr' }}> 649 + <button type="button" class="secondary" onclick={() => totpSetup = goBackToQr(verifyData)}> 667 650 {$_('common.back')} 668 651 </button> 669 652 <button type="submit" disabled={verifyLoading || verifyCode.length !== 6}> ··· 672 655 </div> 673 656 </form> 674 657 </div> 675 - {:else if setupStep === 'backup'} 658 + {:else if totpSetup.step === 'backup'} 676 659 <div class="setup-step"> 677 660 <h3>{$_('security.backupCodes')}</h3> 678 661 <p class="warning-text"> 679 662 {$_('security.backupCodesDescription')} 680 663 </p> 681 664 <div class="backup-codes"> 682 - {#each backupCodes as code} 665 + {#each totpSetup.backupCodes as code} 683 666 <code class="backup-code">{code}</code> 684 667 {/each} 685 668 </div> ··· 960 943 </section> 961 944 {/if} 962 945 {/if} 963 - </div> 964 - 965 - <ReauthModal 966 - bind:show={showReauthModal} 967 - availableMethods={reauthMethods} 968 - onSuccess={handleReauthSuccess} 969 - onCancel={handleReauthCancel} 970 - /> 946 + </div> 947 + 948 + <ReauthModal 949 + bind:show={showReauthModal} 950 + availableMethods={reauthMethods} 951 + onSuccess={handleReauthSuccess} 952 + onCancel={handleReauthCancel} 953 + /> 954 + {/snippet} 955 + </AuthenticatedRoute> 971 956 972 957 <style> 973 958 .page {
+9 -16
frontend/src/routes/Settings.svelte
··· 7 7 import { isOk } from '../lib/types/result' 8 8 import { unsafeAsHandle } from '../lib/types/branded' 9 9 import type { Session } from '../lib/types/api' 10 + import { getSessionEmail } from '../lib/types/api' 10 11 import { toast } from '../lib/toast.svelte' 11 12 import ReauthModal from '../components/ReauthModal.svelte' 13 + import { createAuthenticatedClient } from '../lib/authenticated-client' 12 14 13 15 const auth = $derived(getAuthState()) 14 16 const supportedLocales = getSupportedLocales() ··· 24 26 25 27 const session = $derived(getSession()) 26 28 const loading = $derived(isLoading()) 29 + const client = $derived(session ? createAuthenticatedClient(session) : null) 27 30 28 31 onMount(() => { 29 32 api.describeServer().then(info => { ··· 276 279 } 277 280 278 281 async function handleExportBlobs() { 279 - if (!session) return 282 + if (!client) return 280 283 exportBlobsLoading = true 281 284 try { 282 - const response = await fetch('/xrpc/_backup.exportBlobs', { 283 - headers: { 284 - 'Authorization': `Bearer ${session.accessJwt}` 285 - } 286 - }) 287 - if (!response.ok) { 288 - const err = await response.json().catch(() => ({ message: 'Export failed' })) 289 - throw new Error(err.message || 'Export failed') 290 - } 291 - const blob = await response.blob() 285 + const blob = await client.exportBlobs() 292 286 if (blob.size === 0) { 293 287 toast.success($_('settings.messages.noBlobsToExport')) 294 288 return ··· 296 290 const url = URL.createObjectURL(blob) 297 291 const a = document.createElement('a') 298 292 a.href = url 299 - a.download = `${session.handle}-blobs.zip` 293 + a.download = `${client.session.handle}-blobs.zip` 300 294 document.body.appendChild(a) 301 295 a.click() 302 296 document.body.removeChild(a) 303 297 URL.revokeObjectURL(url) 304 298 toast.success($_('settings.messages.blobsExported')) 305 - } catch (e) { 306 - toast.error(e instanceof Error ? e.message : $_('settings.messages.exportFailed')) 299 + } catch { 307 300 } finally { 308 301 exportBlobsLoading = false 309 302 } ··· 531 524 </section> 532 525 <section> 533 526 <h2>{$_('settings.changeEmail')}</h2> 534 - {#if session?.email} 535 - <p class="current">{$_('settings.currentEmail', { values: { email: session.email } })}</p> 527 + {#if session && getSessionEmail(session)} 528 + <p class="current">{$_('settings.currentEmail', { values: { email: getSessionEmail(session) } })}</p> 536 529 {/if} 537 530 {#if emailTokenRequired} 538 531 <form onsubmit={handleConfirmEmailUpdate}>
+6 -2
frontend/src/styles/base.css
··· 611 611 } 612 612 613 613 @keyframes skeleton-pulse { 614 - 0%, 100% { opacity: 1; } 615 - 50% { opacity: 0.5; } 614 + 0%, 100% { 615 + opacity: 1; 616 + } 617 + 50% { 618 + opacity: 0.5; 619 + } 616 620 } 617 621 618 622 .section-hint {
+2
frontend/src/tests/AppPasswords.test.ts
··· 10 10 mockEndpoint, 11 11 setupAuthenticatedUser, 12 12 setupFetchMock, 13 + setupIndexedDBMock, 13 14 setupUnauthenticatedUser, 14 15 } from "./mocks.ts"; 15 16 import { unsafeAsISODateString } from "../lib/types/branded.ts"; ··· 17 18 beforeEach(() => { 18 19 clearMocks(); 19 20 setupFetchMock(); 21 + setupIndexedDBMock(); 20 22 globalThis.confirm = vi.fn(() => true); 21 23 }); 22 24 describe("authentication guard", () => {
+2
frontend/src/tests/Login.test.ts
··· 7 7 mockData, 8 8 mockEndpoint, 9 9 setupFetchMock, 10 + setupIndexedDBMock, 10 11 } from "./mocks.ts"; 11 12 import { _testSetState, type SavedAccount } from "../lib/auth.svelte.ts"; 12 13 import { ··· 21 22 beforeEach(() => { 22 23 clearMocks(); 23 24 setupFetchMock(); 25 + setupIndexedDBMock(); 24 26 mockEndpoint( 25 27 "/oauth/par", 26 28 () => jsonResponse({ request_uri: "urn:mock:request" }),
+72 -9
frontend/src/tests/mocks.ts
··· 12 12 unsafeAsRefreshToken, 13 13 } from "../lib/types/branded.ts"; 14 14 15 + function createMockIndexedDB() { 16 + const stores: Map<string, Map<string, unknown>> = new Map(); 17 + 18 + return { 19 + open: vi.fn((_name: string, _version?: number) => { 20 + const createTransaction = (_storeName: string, _mode?: string) => { 21 + const tx = { 22 + objectStore: (name: string) => { 23 + if (!stores.has(name)) { 24 + stores.set(name, new Map()); 25 + } 26 + const store = stores.get(name)!; 27 + return { 28 + put: (value: unknown, key: string) => { 29 + store.set(key, value); 30 + return { result: undefined }; 31 + }, 32 + get: (key: string) => ({ 33 + result: store.get(key), 34 + }), 35 + }; 36 + }, 37 + oncomplete: null as (() => void) | null, 38 + onerror: null as (() => void) | null, 39 + }; 40 + setTimeout(() => tx.oncomplete?.(), 0); 41 + return tx; 42 + }; 43 + 44 + const request = { 45 + result: { 46 + objectStoreNames: { contains: () => true }, 47 + createObjectStore: vi.fn(), 48 + transaction: createTransaction, 49 + close: vi.fn(), 50 + }, 51 + error: null, 52 + onsuccess: null as (() => void) | null, 53 + onerror: null as (() => void) | null, 54 + onupgradeneeded: null as (() => void) | null, 55 + }; 56 + 57 + setTimeout(() => { 58 + request.onupgradeneeded?.(); 59 + request.onsuccess?.(); 60 + }, 0); 61 + 62 + return request; 63 + }), 64 + }; 65 + } 66 + 67 + export function setupIndexedDBMock(): void { 68 + (globalThis as unknown as { indexedDB: unknown }).indexedDB = 69 + createMockIndexedDB(); 70 + } 71 + 15 72 const originalPushState = globalThis.history.pushState.bind(globalThis.history); 16 73 const originalReplaceState = globalThis.history.replaceState.bind( 17 74 globalThis.history, ··· 165 222 }; 166 223 } 167 224 export const mockData = { 168 - session: (overrides?: Partial<Session>): Session => ({ 169 - did: unsafeAsDid("did:web:test.tranquil.dev:u:testuser"), 170 - handle: unsafeAsHandle("testuser.test.tranquil.dev"), 171 - email: unsafeAsEmail("test@example.com"), 172 - emailConfirmed: true, 173 - accessJwt: unsafeAsAccessToken("mock-access-jwt-token"), 174 - refreshJwt: unsafeAsRefreshToken("mock-refresh-jwt-token"), 175 - ...overrides, 176 - }), 225 + session: (overrides?: Partial<Session>): Session => { 226 + const base = { 227 + did: unsafeAsDid("did:web:test.tranquil.dev:u:testuser"), 228 + handle: unsafeAsHandle("testuser.test.tranquil.dev"), 229 + accessJwt: unsafeAsAccessToken("mock-access-jwt-token"), 230 + refreshJwt: unsafeAsRefreshToken("mock-refresh-jwt-token"), 231 + contactKind: "email" as const, 232 + email: unsafeAsEmail("test@example.com"), 233 + emailConfirmed: true, 234 + accountKind: "active" as const, 235 + isAdmin: false, 236 + }; 237 + return { ...base, ...overrides } as Session; 238 + }, 177 239 appPassword: (overrides?: Partial<AppPassword>): AppPassword => ({ 178 240 name: "Test App", 179 241 createdAt: unsafeAsISODateString(new Date().toISOString()), ··· 225 287 }; 226 288 export function setupDefaultMocks(): void { 227 289 setupFetchMock(); 290 + setupIndexedDBMock(); 228 291 mockEndpoint( 229 292 "com.atproto.server.getSession", 230 293 () => jsonResponse(mockData.session()),
+2 -55
frontend/src/tests/oauth-registration.test.ts
··· 6 6 mockData, 7 7 mockEndpoint, 8 8 setupFetchMock, 9 + setupIndexedDBMock, 9 10 } from "./mocks.ts"; 10 11 import { _testSetState } from "../lib/auth.svelte.ts"; 11 12 12 - function createMockIndexedDB() { 13 - const stores: Map<string, Map<string, unknown>> = new Map(); 14 - 15 - return { 16 - open: vi.fn((_name: string, _version?: number) => { 17 - const createTransaction = (_storeName: string, _mode?: string) => { 18 - const tx = { 19 - objectStore: (name: string) => { 20 - if (!stores.has(name)) { 21 - stores.set(name, new Map()); 22 - } 23 - const store = stores.get(name)!; 24 - return { 25 - put: (value: unknown, key: string) => { 26 - store.set(key, value); 27 - return { result: undefined }; 28 - }, 29 - get: (key: string) => ({ 30 - result: store.get(key), 31 - }), 32 - }; 33 - }, 34 - oncomplete: null as (() => void) | null, 35 - onerror: null as (() => void) | null, 36 - }; 37 - setTimeout(() => tx.oncomplete?.(), 0); 38 - return tx; 39 - }; 40 - 41 - const request = { 42 - result: { 43 - objectStoreNames: { contains: () => true }, 44 - createObjectStore: vi.fn(), 45 - transaction: createTransaction, 46 - close: vi.fn(), 47 - }, 48 - error: null, 49 - onsuccess: null as (() => void) | null, 50 - onerror: null as (() => void) | null, 51 - onupgradeneeded: null as (() => void) | null, 52 - }; 53 - 54 - setTimeout(() => { 55 - request.onupgradeneeded?.(); 56 - request.onsuccess?.(); 57 - }, 0); 58 - 59 - return request; 60 - }), 61 - }; 62 - } 63 - 64 13 describe("OAuth Registration Flow", () => { 65 14 beforeEach(() => { 66 15 clearMocks(); 67 16 setupFetchMock(); 17 + setupIndexedDBMock(); 68 18 sessionStorage.clear(); 69 19 vi.restoreAllMocks(); 70 20 71 - (globalThis as unknown as { indexedDB: unknown }).indexedDB = 72 - createMockIndexedDB(); 73 - 74 21 Object.defineProperty(globalThis.location, "search", { 75 22 value: "", 76 23 writable: true,

History

2 rounds 0 comments
sign up or login to add to the discussion
1 commit
expand
fix: make frontend more type-safe
expand 0 comments
pull request successfully merged
lewis.moe submitted #0
1 commit
expand
fix: make frontend more type-safe
expand 0 comments