fix: make frontend more type-safe

This commit is contained in:
lewis
2026-01-26 21:25:46 +02:00
committed by Tangled
parent d7b96773fd
commit c19904f6d6
23 changed files with 1669 additions and 886 deletions

View File

@@ -113,6 +113,10 @@ pub async fn get_service_auth(
)
.into_response();
}
Err(crate::oauth::OAuthError::ExpiredToken(msg)) => {
warn!(error = %msg, "getServiceAuth DPoP token expired");
return ApiError::OAuthExpiredToken(Some(msg)).into_response();
}
Err(e) => {
warn!(error = ?e, "getServiceAuth DPoP auth validation failed");
return ApiError::AuthenticationFailed(Some(format!("{:?}", e))).into_response();

333
frontend/deno.lock generated
View File

@@ -11,6 +11,7 @@
"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",
"npm:@testing-library/user-event@^14.6.1": "14.6.1_@testing-library+dom@10.4.1",
"npm:jsdom@^25.0.1": "25.0.1",
"npm:knip@*": "5.82.1_@types+node@25.0.3_typescript@5.9.3",
"npm:multiformats@^13.4.2": "13.4.2",
"npm:svelte-check@*": "4.3.5_svelte@5.46.1__acorn@8.15.0_typescript@5.9.3",
"npm:svelte-check@^4.3.5": "4.3.5_svelte@5.46.1__acorn@8.15.0_typescript@5.9.3",
@@ -156,6 +157,25 @@
"@csstools/css-tokenizer@3.0.4": {
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="
},
"@emnapi/core@1.8.1": {
"integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==",
"dependencies": [
"@emnapi/wasi-threads",
"tslib"
]
},
"@emnapi/runtime@1.8.1": {
"integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
"dependencies": [
"tslib"
]
},
"@emnapi/wasi-threads@1.1.0": {
"integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==",
"dependencies": [
"tslib"
]
},
"@esbuild/aix-ppc64@0.19.12": {
"integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==",
"os": ["aix"],
@@ -464,9 +484,136 @@
"@jridgewell/sourcemap-codec"
]
},
"@napi-rs/wasm-runtime@1.1.1": {
"integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==",
"dependencies": [
"@emnapi/core",
"@emnapi/runtime",
"@tybys/wasm-util"
]
},
"@noble/secp256k1@3.0.0": {
"integrity": "sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg=="
},
"@nodelib/fs.scandir@2.1.5": {
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dependencies": [
"@nodelib/fs.stat",
"run-parallel"
]
},
"@nodelib/fs.stat@2.0.5": {
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="
},
"@nodelib/fs.walk@1.2.8": {
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dependencies": [
"@nodelib/fs.scandir",
"fastq"
]
},
"@oxc-resolver/binding-android-arm-eabi@11.16.4": {
"integrity": "sha512-6XUHilmj8D6Ggus+sTBp64x/DUQ7LgC/dvTDdUOt4iMQnDdSep6N1mnvVLIiG+qM5tRnNHravNzBJnUlYwRQoA==",
"os": ["android"],
"cpu": ["arm"]
},
"@oxc-resolver/binding-android-arm64@11.16.4": {
"integrity": "sha512-5ODwd1F5mdkm6JIg1CNny9yxIrCzrkKpxmqas7Alw23vE0Ot8D4ykqNBW5Z/nIZkXVEo5VDmnm0sMBBIANcpeQ==",
"os": ["android"],
"cpu": ["arm64"]
},
"@oxc-resolver/binding-darwin-arm64@11.16.4": {
"integrity": "sha512-egwvDK9DMU4Q8F4BG74/n4E22pQ0lT5ukOVB6VXkTj0iG2fnyoStHoFaBnmDseLNRA4r61Mxxz8k940CIaJMDg==",
"os": ["darwin"],
"cpu": ["arm64"]
},
"@oxc-resolver/binding-darwin-x64@11.16.4": {
"integrity": "sha512-HMkODYrAG4HaFNCpaYzSQFkxeiz2wzl+smXwxeORIQVEo1WAgUrWbvYT/0RNJg/A8z2aGMGK5KWTUr2nX5GiMw==",
"os": ["darwin"],
"cpu": ["x64"]
},
"@oxc-resolver/binding-freebsd-x64@11.16.4": {
"integrity": "sha512-mkcKhIdSlUqnndD928WAVVFMEr1D5EwHOBGHadypW0PkM0h4pn89ZacQvU7Qs/Z2qquzvbyw8m4Mq3jOYI+4Dw==",
"os": ["freebsd"],
"cpu": ["x64"]
},
"@oxc-resolver/binding-linux-arm-gnueabihf@11.16.4": {
"integrity": "sha512-ZJvzbmXI/cILQVcJL9S2Fp7GLAIY4Yr6mpGb+k6LKLUSEq85yhG+rJ9eWCqgULVIf2BFps/NlmPTa7B7oj8jhQ==",
"os": ["linux"],
"cpu": ["arm"]
},
"@oxc-resolver/binding-linux-arm-musleabihf@11.16.4": {
"integrity": "sha512-iZUB0W52uB10gBUDAi79eTnzqp1ralikCAjfq7CdokItwZUVJXclNYANnzXmtc0Xr0ox+YsDsG2jGcj875SatA==",
"os": ["linux"],
"cpu": ["arm"]
},
"@oxc-resolver/binding-linux-arm64-gnu@11.16.4": {
"integrity": "sha512-qNQk0H6q1CnwS9cnvyjk9a+JN8BTbxK7K15Bb5hYfJcKTG1hfloQf6egndKauYOO0wu9ldCMPBrEP1FNIQEhaA==",
"os": ["linux"],
"cpu": ["arm64"]
},
"@oxc-resolver/binding-linux-arm64-musl@11.16.4": {
"integrity": "sha512-wEXSaEaYxGGoVSbw0i2etjDDWcqErKr8xSkTdwATP798efsZmodUAcLYJhN0Nd4W35Oq6qAvFGHpKwFrrhpTrA==",
"os": ["linux"],
"cpu": ["arm64"]
},
"@oxc-resolver/binding-linux-ppc64-gnu@11.16.4": {
"integrity": "sha512-CUFOlpb07DVOFLoYiaTfbSBRPIhNgwc/MtlYeg3p6GJJw+kEm/vzc9lohPSjzF2MLPB5hzsJdk+L/GjrTT3UPw==",
"os": ["linux"],
"cpu": ["ppc64"]
},
"@oxc-resolver/binding-linux-riscv64-gnu@11.16.4": {
"integrity": "sha512-d8It4AH8cN9ReK1hW6ZO4x3rMT0hB2LYH0RNidGogV9xtnjLRU+Y3MrCeClLyOSGCibmweJJAjnwB7AQ31GEhg==",
"os": ["linux"],
"cpu": ["riscv64"]
},
"@oxc-resolver/binding-linux-riscv64-musl@11.16.4": {
"integrity": "sha512-d09dOww9iKyEHSxuOQ/Iu2aYswl0j7ExBcyy14D6lJ5ijQSP9FXcJYJsJ3yvzboO/PDEFjvRuF41f8O1skiPVg==",
"os": ["linux"],
"cpu": ["riscv64"]
},
"@oxc-resolver/binding-linux-s390x-gnu@11.16.4": {
"integrity": "sha512-lhjyGmUzTWHduZF3MkdUSEPMRIdExnhsqv8u1upX3A15epVn6YVwv4msFQPJl1x1wszkACPeDHGOtzHsITXGdw==",
"os": ["linux"],
"cpu": ["s390x"]
},
"@oxc-resolver/binding-linux-x64-gnu@11.16.4": {
"integrity": "sha512-ZtqqiI5rzlrYBm/IMMDIg3zvvVj4WO/90Dg/zX+iA8lWaLN7K5nroXb17MQ4WhI5RqlEAgrnYDXW+hok1D9Kaw==",
"os": ["linux"],
"cpu": ["x64"]
},
"@oxc-resolver/binding-linux-x64-musl@11.16.4": {
"integrity": "sha512-LM424h7aaKcMlqHnQWgTzO+GRNLyjcNnMpqm8SygEtFRVW693XS+XGXYvjORlmJtsyjo84ej1FMb3U2HE5eyjg==",
"os": ["linux"],
"cpu": ["x64"]
},
"@oxc-resolver/binding-openharmony-arm64@11.16.4": {
"integrity": "sha512-8w8U6A5DDWTBv3OUxSD9fNk37liZuEC5jnAc9wQRv9DeYKAXvuUtBfT09aIZ58swaci0q1WS48/CoMVEO6jdCA==",
"os": ["openharmony"],
"cpu": ["arm64"]
},
"@oxc-resolver/binding-wasm32-wasi@11.16.4": {
"integrity": "sha512-hnjb0mDVQOon6NdfNJ1EmNquonJUjoYkp7UyasjxVa4iiMcApziHP4czzzme6WZbp+vzakhVv2Yi5ACTon3Zlw==",
"dependencies": [
"@napi-rs/wasm-runtime"
],
"cpu": ["wasm32"]
},
"@oxc-resolver/binding-win32-arm64-msvc@11.16.4": {
"integrity": "sha512-+i0XtNfSP7cfnh1T8FMrMm4HxTeh0jxKP/VQCLWbjdUxaAQ4damho4gN9lF5dl0tZahtdszXLUboBFNloSJNOQ==",
"os": ["win32"],
"cpu": ["arm64"]
},
"@oxc-resolver/binding-win32-ia32-msvc@11.16.4": {
"integrity": "sha512-ePW1islJrv3lPnef/iWwrjrSpRH8kLlftdKf2auQNWvYLx6F0xvcnv9d+r/upnVuttoQY9amLnWJf+JnCRksTw==",
"os": ["win32"],
"cpu": ["ia32"]
},
"@oxc-resolver/binding-win32-x64-msvc@11.16.4": {
"integrity": "sha512-qnjQhjHI4TDL3hkidZyEmQRK43w2NHl6TP5Rnt/0XxYuLdEgx/1yzShhYidyqWzdnhGhSPTM/WVP2mK66XLegA==",
"os": ["win32"],
"cpu": ["x64"]
},
"@rollup/rollup-android-arm-eabi@4.54.0": {
"integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==",
"os": ["android"],
@@ -657,6 +804,12 @@
"@testing-library/dom"
]
},
"@tybys/wasm-util@0.10.1": {
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
"dependencies": [
"tslib"
]
},
"@types/aria-query@5.0.4": {
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="
},
@@ -673,6 +826,12 @@
"@types/estree@1.0.8": {
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="
},
"@types/node@25.0.3": {
"integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==",
"dependencies": [
"undici-types"
]
},
"@vitest/expect@4.0.16": {
"integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==",
"dependencies": [
@@ -740,6 +899,9 @@
"ansi-styles@5.2.0": {
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="
},
"argparse@2.0.1": {
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
},
"aria-query@5.3.0": {
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dependencies": [
@@ -758,6 +920,12 @@
"axobject-query@4.1.0": {
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="
},
"braces@3.0.3": {
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dependencies": [
"fill-range"
]
},
"call-bind-apply-helpers@1.0.2": {
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dependencies": [
@@ -1019,13 +1187,41 @@
"type"
]
},
"fast-glob@3.3.3": {
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
"dependencies": [
"@nodelib/fs.stat",
"@nodelib/fs.walk",
"glob-parent",
"merge2",
"micromatch"
]
},
"fastq@1.20.1": {
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"dependencies": [
"reusify"
]
},
"fd-package-json@2.0.0": {
"integrity": "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==",
"dependencies": [
"walk-up-path"
]
},
"fdir@6.5.0_picomatch@4.0.3": {
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dependencies": [
"picomatch"
"picomatch@4.0.3"
],
"optionalPeers": [
"picomatch"
"picomatch@4.0.3"
]
},
"fill-range@7.1.1": {
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dependencies": [
"to-regex-range"
]
},
"form-data@4.0.5": {
@@ -1038,6 +1234,13 @@
"mime-types"
]
},
"formatly@0.3.0": {
"integrity": "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==",
"dependencies": [
"fd-package-json"
],
"bin": true
},
"fsevents@2.3.3": {
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"os": ["darwin"],
@@ -1068,6 +1271,12 @@
"es-object-atoms"
]
},
"glob-parent@5.1.2": {
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dependencies": [
"is-glob"
]
},
"globalyzer@0.1.0": {
"integrity": "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q=="
},
@@ -1130,6 +1339,18 @@
"tslib"
]
},
"is-extglob@2.1.1": {
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="
},
"is-glob@4.0.3": {
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dependencies": [
"is-extglob"
]
},
"is-number@7.0.0": {
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="
},
"is-potential-custom-element-name@1.0.1": {
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="
},
@@ -1142,9 +1363,20 @@
"@types/estree"
]
},
"jiti@2.6.1": {
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"bin": true
},
"js-tokens@4.0.0": {
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
},
"js-yaml@4.1.1": {
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dependencies": [
"argparse"
],
"bin": true
},
"jsdom@25.0.1": {
"integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==",
"dependencies": [
@@ -1171,6 +1403,26 @@
"xml-name-validator"
]
},
"knip@5.82.1_@types+node@25.0.3_typescript@5.9.3": {
"integrity": "sha512-1nQk+5AcnkqL40kGQXfouzAEXkTR+eSrgo/8m1d0BMei4eAzFwghoXC4gOKbACgBiCof7hE8wkBVDsEvznf85w==",
"dependencies": [
"@nodelib/fs.walk",
"@types/node",
"fast-glob",
"formatly",
"jiti",
"js-yaml",
"minimist",
"oxc-resolver",
"picocolors",
"picomatch@4.0.3",
"smol-toml",
"strip-json-comments",
"typescript",
"zod"
],
"bin": true
},
"locate-character@3.0.0": {
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="
},
@@ -1209,6 +1461,16 @@
"timers-ext"
]
},
"merge2@1.4.1": {
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="
},
"micromatch@4.0.8": {
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dependencies": [
"braces",
"picomatch@2.3.1"
]
},
"mime-db@1.52.0": {
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="
},
@@ -1221,6 +1483,9 @@
"min-indent@1.0.1": {
"integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="
},
"minimist@1.2.8": {
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="
},
"mri@1.2.0": {
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="
},
@@ -1243,6 +1508,31 @@
"obug@2.1.1": {
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="
},
"oxc-resolver@11.16.4": {
"integrity": "sha512-nvJr3orFz1wNaBA4neRw7CAn0SsjgVaEw1UHpgO/lzVW12w+nsFnvU/S6vVX3kYyFaZdxZheTExi/fa8R8PrZA==",
"optionalDependencies": [
"@oxc-resolver/binding-android-arm-eabi",
"@oxc-resolver/binding-android-arm64",
"@oxc-resolver/binding-darwin-arm64",
"@oxc-resolver/binding-darwin-x64",
"@oxc-resolver/binding-freebsd-x64",
"@oxc-resolver/binding-linux-arm-gnueabihf",
"@oxc-resolver/binding-linux-arm-musleabihf",
"@oxc-resolver/binding-linux-arm64-gnu",
"@oxc-resolver/binding-linux-arm64-musl",
"@oxc-resolver/binding-linux-ppc64-gnu",
"@oxc-resolver/binding-linux-riscv64-gnu",
"@oxc-resolver/binding-linux-riscv64-musl",
"@oxc-resolver/binding-linux-s390x-gnu",
"@oxc-resolver/binding-linux-x64-gnu",
"@oxc-resolver/binding-linux-x64-musl",
"@oxc-resolver/binding-openharmony-arm64",
"@oxc-resolver/binding-wasm32-wasi",
"@oxc-resolver/binding-win32-arm64-msvc",
"@oxc-resolver/binding-win32-ia32-msvc",
"@oxc-resolver/binding-win32-x64-msvc"
]
},
"parse5@7.3.0": {
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"dependencies": [
@@ -1255,6 +1545,9 @@
"picocolors@1.1.1": {
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
},
"picomatch@2.3.1": {
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="
},
"picomatch@4.0.3": {
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="
},
@@ -1277,6 +1570,9 @@
"punycode@2.3.1": {
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="
},
"queue-microtask@1.2.3": {
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="
},
"react-is@17.0.2": {
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
},
@@ -1290,6 +1586,9 @@
"strip-indent"
]
},
"reusify@1.1.0": {
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="
},
"rollup@4.54.0": {
"integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==",
"dependencies": [
@@ -1328,6 +1627,12 @@
"rrweb-cssom@0.8.0": {
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="
},
"run-parallel@1.2.0": {
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dependencies": [
"queue-microtask"
]
},
"sade@1.8.1": {
"integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
"dependencies": [
@@ -1346,6 +1651,9 @@
"siginfo@2.0.0": {
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="
},
"smol-toml@1.6.0": {
"integrity": "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw=="
},
"source-map-js@1.2.1": {
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="
},
@@ -1361,6 +1669,9 @@
"min-indent"
]
},
"strip-json-comments@5.0.3": {
"integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="
},
"svelte-check@4.3.5_svelte@5.46.1__acorn@8.15.0_typescript@5.9.3": {
"integrity": "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q==",
"dependencies": [
@@ -1435,7 +1746,7 @@
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dependencies": [
"fdir",
"picomatch"
"picomatch@4.0.3"
]
},
"tinyrainbow@3.0.3": {
@@ -1451,6 +1762,12 @@
],
"bin": true
},
"to-regex-range@5.0.1": {
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dependencies": [
"is-number"
]
},
"tough-cookie@5.1.2": {
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
"dependencies": [
@@ -1473,6 +1790,9 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"bin": true
},
"undici-types@7.16.0": {
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="
},
"unicode-segmenter@0.14.5": {
"integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g=="
},
@@ -1481,7 +1801,7 @@
"dependencies": [
"esbuild@0.27.2",
"fdir",
"picomatch",
"picomatch@4.0.3",
"postcss",
"rollup",
"tinyglobby"
@@ -1516,7 +1836,7 @@
"magic-string",
"obug",
"pathe",
"picomatch",
"picomatch@4.0.3",
"std-env",
"tinybench",
"tinyexec",
@@ -1536,6 +1856,9 @@
"xml-name-validator"
]
},
"walk-up-path@4.0.0": {
"integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="
},
"webidl-conversions@7.0.0": {
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="
},

View File

@@ -0,0 +1,59 @@
<script lang="ts">
import { getAuthState } from '../lib/auth.svelte'
import { navigate, routes } from '../lib/router.svelte'
import type { Snippet } from 'svelte'
import type { Session } from '../lib/types/api'
import { createAuthenticatedClient, type AuthenticatedClient } from '../lib/authenticated-client'
interface Props {
children: Snippet<[{ session: Session; client: AuthenticatedClient }]>
requireAdmin?: boolean
onReady?: (session: Session, client: AuthenticatedClient) => void
}
let { children, requireAdmin = false, onReady }: Props = $props()
const auth = $derived(getAuthState())
let readyCalled = $state(false)
$effect(() => {
if (auth.kind === 'unauthenticated' || auth.kind === 'error') {
navigate(routes.login)
}
if (requireAdmin && auth.kind === 'authenticated' && !auth.session.isAdmin) {
navigate(routes.dashboard)
}
if (auth.kind === 'authenticated' && onReady && !readyCalled) {
readyCalled = true
onReady(auth.session, createAuthenticatedClient(auth.session))
}
})
</script>
{#if auth.kind === 'authenticated'}
{@render children({ session: auth.session, client: createAuthenticatedClient(auth.session) })}
{:else}
<div class="loading-container"><div class="loading-spinner"></div></div>
{/if}
<style>
.loading-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
padding: var(--space-7);
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border-color);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>

View File

@@ -7,6 +7,7 @@ import type {
Nsid,
RefreshToken,
Rkey,
ScopeSet,
} from "./types/branded.ts";
import {
unsafeAsAccessToken,
@@ -15,6 +16,7 @@ import {
unsafeAsHandle,
unsafeAsISODate,
unsafeAsRefreshToken,
unsafeAsScopeSet,
} from "./types/branded.ts";
import {
createDPoPProofForRequest,
@@ -23,15 +25,21 @@ import {
} from "./oauth.ts";
import type {
AccountInfo,
AccountState,
ApiErrorCode,
AppPassword,
CompletePasskeySetupResponse,
ConfirmSignupResult,
ContactState,
CreateAccountParams,
CreateAccountResult,
CreateBackupResponse,
CreatedAppPassword,
CreateRecordResponse,
DelegationAuditEntry,
DelegationControlledAccount,
DelegationController,
DelegationScopePreset,
DidDocument,
DidType,
EmailUpdateResponse,
@@ -65,6 +73,7 @@ import type {
ServerStats,
Session,
SetBackupEnabledResponse,
SsoLinkedAccount,
StartPasskeyRegistrationResponse,
SuccessResponse,
TotpSecret,
@@ -241,32 +250,119 @@ export interface VerificationMethod {
}
export type { AppPassword, DidDocument, InviteCodeInfo as InviteCode, Session };
export type {
ConfirmSignupResult,
CreateAccountParams,
CreateAccountResult,
DidType,
VerificationChannel,
};
export type { DidType, VerificationChannel };
function castSession(raw: unknown): Session {
function buildContactState(s: Record<string, unknown>): ContactState {
const preferredChannel = s.preferredChannel as VerificationChannel | undefined;
const email = s.email ? unsafeAsEmail(s.email as string) : undefined;
if (preferredChannel) {
return {
contactKind: "channel",
preferredChannel,
preferredChannelVerified: Boolean(s.preferredChannelVerified),
email,
};
}
if (email) {
return {
contactKind: "email",
email,
emailConfirmed: Boolean(s.emailConfirmed),
};
}
return { contactKind: "none" };
}
function buildAccountState(s: Record<string, unknown>): AccountState {
const status = s.status as string | undefined;
const isAdmin = Boolean(s.isAdmin);
const active = s.active as boolean | undefined;
if (status === "migrated") {
return {
accountKind: "migrated",
migratedToPds: (s.migratedToPds as string) || "",
migratedAt: s.migratedAt
? unsafeAsISODate(s.migratedAt as string)
: unsafeAsISODate(new Date().toISOString()),
isAdmin,
};
}
if (status === "deactivated" || active === false) {
return { accountKind: "deactivated", isAdmin };
}
if (status === "suspended") {
return { accountKind: "suspended", isAdmin };
}
return { accountKind: "active", isAdmin };
}
export function castSession(raw: unknown): Session {
const s = raw as Record<string, unknown>;
const contact = buildContactState(s);
const account = buildAccountState(s);
return {
did: unsafeAsDid(s.did as string),
handle: unsafeAsHandle(s.handle as string),
email: s.email ? unsafeAsEmail(s.email as string) : undefined,
emailConfirmed: s.emailConfirmed as boolean | undefined,
preferredChannel: s.preferredChannel as VerificationChannel | undefined,
preferredChannelVerified: s.preferredChannelVerified as boolean | undefined,
isAdmin: s.isAdmin as boolean | undefined,
active: s.active as boolean | undefined,
status: s.status as Session["status"],
migratedToPds: s.migratedToPds as string | undefined,
migratedAt: s.migratedAt
? unsafeAsISODate(s.migratedAt as string)
: undefined,
accessJwt: unsafeAsAccessToken(s.accessJwt as string),
refreshJwt: unsafeAsRefreshToken(s.refreshJwt as string),
preferredLocale: s.preferredLocale as string | null | undefined,
...contact,
...account,
};
}
function castDelegationController(raw: unknown): DelegationController {
const c = raw as Record<string, unknown>;
return {
did: unsafeAsDid(c.did as string),
granted_scopes: unsafeAsScopeSet(c.granted_scopes as string),
added_at: unsafeAsISODate(c.added_at as string),
};
}
function castDelegationControlledAccount(
raw: unknown,
): DelegationControlledAccount {
const a = raw as Record<string, unknown>;
return {
did: unsafeAsDid(a.did as string),
handle: unsafeAsHandle(a.handle as string),
granted_scopes: unsafeAsScopeSet(a.granted_scopes as string),
};
}
function castDelegationAuditEntry(raw: unknown): DelegationAuditEntry {
const e = raw as Record<string, unknown>;
return {
id: e.id as string,
action: e.action as string,
actor_did: unsafeAsDid(e.actor_did as string),
target_did: e.target_did ? unsafeAsDid(e.target_did as string) : undefined,
details: e.details as string | undefined,
created_at: unsafeAsISODate(e.created_at as string),
};
}
function castSsoLinkedAccount(raw: unknown): SsoLinkedAccount {
const a = raw as Record<string, unknown>;
return {
id: a.id as string,
provider: a.provider as string,
provider_name: a.provider_name as string,
provider_username: a.provider_username as string,
provider_email: a.provider_email as string | undefined,
created_at: unsafeAsISODate(a.created_at as string),
last_login_at: a.last_login_at
? unsafeAsISODate(a.last_login_at as string)
: undefined,
};
}
@@ -1142,7 +1238,9 @@ export const api = {
},
async getRepo(token: AccessToken, did: Did): Promise<ArrayBuffer> {
const url = `${API_BASE}/com.atproto.sync.getRepo?did=${encodeURIComponent(did)}`;
const url = `${API_BASE}/com.atproto.sync.getRepo?did=${
encodeURIComponent(did)
}`;
const res = await authenticatedFetch(url, { token });
if (!res.ok) {
const errData = await res.json().catch(() => ({
@@ -1198,12 +1296,15 @@ export const api = {
},
async importRepo(token: AccessToken, car: Uint8Array): Promise<void> {
const res = await authenticatedFetch(`${API_BASE}/com.atproto.repo.importRepo`, {
method: "POST",
token,
headers: { "Content-Type": "application/vnd.ipld.car" },
body: car as unknown as BodyInit,
});
const res = await authenticatedFetch(
`${API_BASE}/com.atproto.repo.importRepo`,
{
method: "POST",
token,
headers: { "Content-Type": "application/vnd.ipld.car" },
body: car as unknown as BodyInit,
},
);
if (!res.ok) {
const errData = await res.json().catch(() => ({
error: "Unknown",
@@ -1213,7 +1314,9 @@ export const api = {
}
},
async establishOAuthSession(token: AccessToken): Promise<{ success: boolean; device_id: string }> {
async establishOAuthSession(
token: AccessToken,
): Promise<{ success: boolean; device_id: string }> {
const res = await authenticatedFetch("/oauth/establish-session", {
method: "POST",
token,
@@ -1228,6 +1331,101 @@ export const api = {
}
return res.json();
},
async getSsoLinkedAccounts(
token: AccessToken,
): Promise<{ accounts: SsoLinkedAccount[] }> {
const res = await authenticatedFetch("/oauth/sso/linked", { token });
if (!res.ok) {
const errData = await res.json().catch(() => ({
error: "Unknown",
message: res.statusText,
}));
throw new ApiError(res.status, errData.error, errData.message);
}
return res.json();
},
listDelegationControllers(
token: AccessToken,
): Promise<Result<{ controllers: DelegationController[] }, ApiError>> {
return xrpcResult("_delegation.listControllers", { token });
},
listDelegationControlledAccounts(
token: AccessToken,
): Promise<Result<{ accounts: DelegationControlledAccount[] }, ApiError>> {
return xrpcResult("_delegation.listControlledAccounts", { token });
},
getDelegationScopePresets(): Promise<
Result<{ presets: DelegationScopePreset[] }, ApiError>
> {
return xrpcResult("_delegation.getScopePresets");
},
addDelegationController(
token: AccessToken,
controllerDid: Did,
grantedScopes: ScopeSet,
): Promise<Result<{ success: boolean }, ApiError>> {
return xrpcResult("_delegation.addController", {
method: "POST",
token,
body: { controller_did: controllerDid, granted_scopes: grantedScopes },
});
},
removeDelegationController(
token: AccessToken,
controllerDid: Did,
): Promise<Result<{ success: boolean }, ApiError>> {
return xrpcResult("_delegation.removeController", {
method: "POST",
token,
body: { controller_did: controllerDid },
});
},
createDelegatedAccount(
token: AccessToken,
handle: Handle,
email?: EmailAddress,
controllerScopes?: ScopeSet,
): Promise<Result<{ did: Did; handle: Handle }, ApiError>> {
return xrpcResult("_delegation.createDelegatedAccount", {
method: "POST",
token,
body: { handle, email, controllerScopes },
});
},
getDelegationAuditLog(
token: AccessToken,
limit: number,
offset: number,
): Promise<
Result<{ entries: DelegationAuditEntry[]; total: number }, ApiError>
> {
return xrpcResult("_delegation.getAuditLog", {
token,
params: { limit: String(limit), offset: String(offset) },
});
},
async exportBlobs(token: AccessToken): Promise<Blob> {
const res = await authenticatedFetch(`${API_BASE}/_backup.exportBlobs`, {
token,
});
if (!res.ok) {
const errData = await res.json().catch(() => ({
error: "Unknown",
message: res.statusText,
}));
throw new ApiError(res.status, errData.error, errData.message);
}
return res.blob();
},
};
export const typedApi = {

View File

@@ -1,11 +1,9 @@
import {
api,
ApiError,
type CreateAccountParams,
type CreateAccountResult,
typedApi,
} from "./api.ts";
import type { Session } from "./types/api.ts";
import { api, ApiError, typedApi, castSession } from "./api.ts";
import type {
CreateAccountParams,
CreateAccountResult,
Session,
} from "./types/api.ts";
import {
type AccessToken,
type Did,
@@ -436,8 +434,9 @@ export async function confirmSignup(
setLoading();
try {
const result = await api.confirmSignup(did, verificationCode);
setAuthenticated(result);
return ok(result);
const session = castSession(result);
setAuthenticated(session);
return ok(session);
} catch (e) {
const error = toAuthError(e);
setError(error);
@@ -467,6 +466,9 @@ export function setSession(session: {
handle: unsafeAsHandle(session.handle),
accessJwt: unsafeAsAccessToken(session.accessJwt),
refreshJwt: unsafeAsRefreshToken(session.refreshJwt),
contactKind: "none",
accountKind: "active",
isAdmin: false,
};
setAuthenticated(newSession);
}

View File

@@ -0,0 +1,78 @@
import type { AccessToken, Did, EmailAddress, Handle, ScopeSet } from "./types/branded.ts";
import type { Session } from "./types/api.ts";
import type {
DelegationAuditEntry,
DelegationControlledAccount,
DelegationController,
DelegationScopePreset,
SsoLinkedAccount,
} from "./types/api.ts";
import { api, ApiError } from "./api.ts";
import type { Result } from "./types/result.ts";
export interface AuthenticatedClient {
readonly token: AccessToken;
readonly session: Session;
getSsoLinkedAccounts(): Promise<{ accounts: SsoLinkedAccount[] }>;
listDelegationControllers(): Promise<
Result<{ controllers: DelegationController[] }, ApiError>
>;
listDelegationControlledAccounts(): Promise<
Result<{ accounts: DelegationControlledAccount[] }, ApiError>
>;
getDelegationScopePresets(): Promise<
Result<{ presets: DelegationScopePreset[] }, ApiError>
>;
addDelegationController(
controllerDid: Did,
grantedScopes: ScopeSet,
): Promise<Result<{ success: boolean }, ApiError>>;
removeDelegationController(
controllerDid: Did,
): Promise<Result<{ success: boolean }, ApiError>>;
createDelegatedAccount(
handle: Handle,
email?: EmailAddress,
controllerScopes?: ScopeSet,
): Promise<Result<{ did: Did; handle: Handle }, ApiError>>;
getDelegationAuditLog(
limit: number,
offset: number,
): Promise<
Result<{ entries: DelegationAuditEntry[]; total: number }, ApiError>
>;
exportBlobs(): Promise<Blob>;
}
export function createAuthenticatedClient(
session: Session,
): AuthenticatedClient {
const token = session.accessJwt;
return {
token,
session,
getSsoLinkedAccounts: () => api.getSsoLinkedAccounts(token),
listDelegationControllers: () => api.listDelegationControllers(token),
listDelegationControlledAccounts: () =>
api.listDelegationControlledAccounts(token),
getDelegationScopePresets: () => api.getDelegationScopePresets(),
addDelegationController: (controllerDid, grantedScopes) =>
api.addDelegationController(token, controllerDid, grantedScopes),
removeDelegationController: (controllerDid) =>
api.removeDelegationController(token, controllerDid),
createDelegatedAccount: (handle, email, controllerScopes) =>
api.createDelegatedAccount(token, handle, email, controllerScopes),
getDelegationAuditLog: (limit, offset) =>
api.getDelegationAuditLog(token, limit, offset),
exportBlobs: () => api.exportBlobs(token),
};
}
export { ApiError };

View File

@@ -33,8 +33,10 @@ function apiLog(
export class AtprotoClient {
private baseUrl: string;
private accessToken: string | null = null;
private refreshToken: string | null = null;
private dpopKeyPair: DPoPKeyPair | null = null;
private dpopNonce: string | null = null;
private isRefreshing = false;
constructor(pdsUrl: string) {
this.baseUrl = pdsUrl.replace(/\/$/, "");
@@ -48,6 +50,14 @@ export class AtprotoClient {
return this.accessToken;
}
setRefreshToken(token: string | null) {
this.refreshToken = token;
}
getRefreshToken(): string | null {
return this.refreshToken;
}
getBaseUrl(): string {
return this.baseUrl;
}
@@ -56,6 +66,69 @@ export class AtprotoClient {
this.dpopKeyPair = keyPair;
}
private async tryRefreshToken(): Promise<boolean> {
if (!this.refreshToken || this.isRefreshing) return false;
this.isRefreshing = true;
try {
const session = await this.refreshSessionInternal(this.refreshToken);
this.accessToken = session.accessJwt;
this.refreshToken = session.refreshJwt;
return true;
} catch {
return false;
} finally {
this.isRefreshing = false;
}
}
private async refreshSessionInternal(refreshJwt: string): Promise<Session> {
const url = `${this.baseUrl}/xrpc/com.atproto.server.refreshSession`;
const headers: Record<string, string> = {};
if (this.dpopKeyPair) {
headers["Authorization"] = `DPoP ${refreshJwt}`;
const tokenHash = await computeAccessTokenHash(refreshJwt);
const dpopProof = await createDPoPProof(
this.dpopKeyPair,
"POST",
url,
this.dpopNonce ?? undefined,
tokenHash,
);
headers["DPoP"] = dpopProof;
} else {
headers["Authorization"] = `Bearer ${refreshJwt}`;
}
let res = await fetch(url, { method: "POST", headers });
if (!res.ok && this.dpopKeyPair) {
const dpopNonce = res.headers.get("DPoP-Nonce");
if (dpopNonce && dpopNonce !== this.dpopNonce) {
this.dpopNonce = dpopNonce;
headers["DPoP"] = await createDPoPProof(
this.dpopKeyPair,
"POST",
url,
dpopNonce,
await computeAccessTokenHash(refreshJwt),
);
res = await fetch(url, { method: "POST", headers });
}
}
if (!res.ok) {
throw new Error("Token refresh failed");
}
const newNonce = res.headers.get("DPoP-Nonce");
if (newNonce) {
this.dpopNonce = newNonce;
}
return res.json();
}
private async xrpc<T>(
method: string,
options?: {
@@ -135,6 +208,46 @@ export class AtprotoClient {
error: "Unknown",
message: res.statusText,
}));
const isTokenExpired = res.status === 401 &&
(err.error === "ExpiredToken" || err.error === "invalid_token" ||
(err.message && err.message.includes("expired")));
if (isTokenExpired && !authToken && await this.tryRefreshToken()) {
const retryNonce = res.headers.get("DPoP-Nonce") ?? this.dpopNonce;
if (retryNonce) this.dpopNonce = retryNonce;
res = await makeRequest(this.dpopNonce ?? undefined);
if (!res.ok && this.dpopKeyPair) {
const dpopNonce = res.headers.get("DPoP-Nonce");
if (dpopNonce && dpopNonce !== this.dpopNonce) {
this.dpopNonce = dpopNonce;
res = await makeRequest(dpopNonce);
}
}
if (res.ok) {
const newNonce = res.headers.get("DPoP-Nonce");
if (newNonce) this.dpopNonce = newNonce;
const responseContentType = res.headers.get("content-type") ?? "";
if (responseContentType.includes("application/json")) {
return res.json();
}
return res.arrayBuffer().then((buf) => new Uint8Array(buf)) as T;
}
const retryErr = await res.json().catch(() => ({
error: "Unknown",
message: res.statusText,
}));
const retryError = new Error(retryErr.message || retryErr.error || res.statusText) as
& Error
& { status: number; error: string };
retryError.status = res.status;
retryError.error = retryErr.error;
throw retryError;
}
const error = new Error(err.message || err.error || res.statusText) as
& Error
& {

View File

@@ -261,6 +261,7 @@ export function createInboundMigrationFlow() {
state.sourceAccessToken = tokenResponse.access_token;
state.sourceRefreshToken = tokenResponse.refresh_token ?? null;
sourceClient.setAccessToken(tokenResponse.access_token);
sourceClient.setRefreshToken(tokenResponse.refresh_token ?? null);
sourceClient.setDPoPKeyPair(dpopKeyPair);
cleanupOAuthSessionData();

View File

@@ -1,7 +1,5 @@
import {
buildUrl,
isValidRoute,
parseRouteParams,
type Route,
type RouteParams,
routes,
@@ -71,19 +69,6 @@ export function navigate<R extends Route>(
updateState();
}
export function navigateTo(path: string, replace = false): void {
const normalizedPath = path.startsWith("/") ? path : "/" + path;
const fullPath = APP_BASE + normalizedPath;
if (replace) {
globalThis.history.replaceState(null, "", fullPath);
} else {
globalThis.history.pushState(null, "", fullPath);
}
updateState();
}
export function getCurrentPath(): AppPath {
return state.current.path;
}
@@ -100,43 +85,9 @@ export function getFullUrl(path: string): string {
return APP_BASE + (path.startsWith("/") ? path : "/" + path);
}
export function matchRoute(path: AppPath): Route | null {
const pathWithoutQuery = path.split("?")[0];
if (isValidRoute(pathWithoutQuery)) {
return pathWithoutQuery;
}
return null;
}
export function isCurrentRoute(route: Route): boolean {
const pathWithoutQuery = state.current.path.split("?")[0];
return pathWithoutQuery === route;
}
export function getRouteParams<R extends RoutesWithParams>(
_route: R,
): RouteParams[R] {
return parseRouteParams(_route);
}
export type RouteMatch =
| {
readonly matched: true;
readonly route: Route;
readonly params: URLSearchParams;
}
| { readonly matched: false };
export function match(): RouteMatch {
const route = matchRoute(state.current.path);
if (route) {
return {
matched: true,
route,
params: state.current.searchParams,
};
}
return { matched: false };
}
export { type Route, type RouteParams, routes, type RoutesWithParams };
export { type Route, type RouteParams, routes };

View File

@@ -10,6 +10,7 @@ import type {
Nsid,
PublicKeyMultibase,
RefreshToken,
ScopeSet,
} from "./branded.ts";
export type ApiErrorCode =
@@ -52,21 +53,76 @@ export type DidType = "plc" | "web" | "web-external";
export type ReauthMethod = "password" | "totp" | "passkey";
export interface Session {
did: Did;
handle: Handle;
email?: EmailAddress;
emailConfirmed?: boolean;
preferredChannel?: VerificationChannel;
preferredChannelVerified?: boolean;
preferredLocale?: string | null;
isAdmin?: boolean;
active?: boolean;
status?: AccountStatus;
migratedToPds?: string;
migratedAt?: ISODateString;
accessJwt: AccessToken;
refreshJwt: RefreshToken;
export type ContactState =
| {
readonly contactKind: "channel";
readonly preferredChannel: VerificationChannel;
readonly preferredChannelVerified: boolean;
readonly email?: EmailAddress;
}
| {
readonly contactKind: "email";
readonly email: EmailAddress;
readonly emailConfirmed: boolean;
}
| { readonly contactKind: "none" };
export type AccountState =
| { readonly accountKind: "active"; readonly isAdmin: boolean }
| {
readonly accountKind: "migrated";
readonly migratedToPds: string;
readonly migratedAt: ISODateString;
readonly isAdmin: boolean;
}
| { readonly accountKind: "deactivated"; readonly isAdmin: boolean }
| { readonly accountKind: "suspended"; readonly isAdmin: boolean };
type SessionBase = {
readonly did: Did;
readonly handle: Handle;
readonly accessJwt: AccessToken;
readonly refreshJwt: RefreshToken;
readonly preferredLocale?: string | null;
};
export type Session = SessionBase & ContactState & AccountState;
export function hasEmail(
session: Session,
): session is Session & { email: EmailAddress } {
return session.contactKind === "email" ||
(session.contactKind === "channel" && session.email !== undefined);
}
export function getSessionEmail(session: Session): EmailAddress | undefined {
return session.contactKind === "email"
? session.email
: session.contactKind === "channel"
? session.email
: undefined;
}
export function isEmailVerified(session: Session): boolean {
return session.contactKind === "email"
? session.emailConfirmed
: session.contactKind === "channel"
? session.preferredChannelVerified
: false;
}
export function isMigrated(
session: Session,
): session is Session & { accountKind: "migrated" } {
return session.accountKind === "migrated";
}
export function isDeactivated(session: Session): boolean {
return session.accountKind === "deactivated";
}
export function isActive(session: Session): boolean {
return session.accountKind === "active";
}
export interface VerificationMethod {
@@ -493,3 +549,42 @@ export interface VerifyMigrationEmailResponse {
export interface ResendMigrationVerificationResponse {
sent: boolean;
}
export interface SsoLinkedAccount {
id: string;
provider: string;
provider_name: string;
provider_username: string;
provider_email?: string;
created_at: ISODateString;
last_login_at?: ISODateString;
}
export interface DelegationController {
did: Did;
grantedScopes: ScopeSet;
grantedAt: ISODateString;
isActive: boolean;
}
export interface DelegationControlledAccount {
did: Did;
handle: Handle;
grantedScopes: ScopeSet;
grantedAt: ISODateString;
}
export interface DelegationScopePreset {
name: string;
scopes: ScopeSet;
description: string;
}
export interface DelegationAuditEntry {
id: string;
action: string;
actor_did: Did;
target_did?: Did;
details?: string;
created_at: ISODateString;
}

View File

@@ -23,6 +23,7 @@ export type InviteCode = Brand<string, "InviteCode">;
export type PublicKeyMultibase = Brand<string, "PublicKeyMultibase">;
export type DidKeyString = Brand<string, "DidKeyString">;
export type ScopeSet = Brand<string, "ScopeSet">;
const DID_PLC_REGEX = /^did:plc:[a-z2-7]{24}$/;
const DID_WEB_REGEX = /^did:web:.+$/;
@@ -179,6 +180,10 @@ export function unsafeAsDidKey(s: string): DidKeyString {
return s as DidKeyString;
}
export function unsafeAsScopeSet(s: string): ScopeSet {
return s as ScopeSet;
}
export function parseAtUri(
uri: AtUri,
): { repo: Did; collection: Nsid; rkey: Rkey } {

View File

@@ -0,0 +1,67 @@
declare const __step: unique symbol;
export type TotpIdle = {
readonly step: "idle";
readonly [__step]: "idle";
};
export type TotpQr = {
readonly step: "qr";
readonly qrBase64: string;
readonly totpUri: string;
readonly [__step]: "qr";
};
export type TotpVerify = {
readonly step: "verify";
readonly qrBase64: string;
readonly totpUri: string;
readonly [__step]: "verify";
};
export type TotpBackup = {
readonly step: "backup";
readonly backupCodes: readonly string[];
readonly [__step]: "backup";
};
export type TotpSetupState = TotpIdle | TotpQr | TotpVerify | TotpBackup;
export const idleState: TotpIdle = { step: "idle" } as TotpIdle;
export function qrState(qrBase64: string, totpUri: string): TotpQr {
return { step: "qr", qrBase64, totpUri } as TotpQr;
}
export function verifyState(state: TotpQr): TotpVerify {
return { step: "verify", qrBase64: state.qrBase64, totpUri: state.totpUri } as TotpVerify;
}
export function backupState(state: TotpVerify, backupCodes: readonly string[]): TotpBackup {
void state;
return { step: "backup", backupCodes } as TotpBackup;
}
export function goBackToQr(state: TotpVerify): TotpQr {
return { step: "qr", qrBase64: state.qrBase64, totpUri: state.totpUri } as TotpQr;
}
export function finish(_state: TotpBackup): TotpIdle {
return idleState;
}
export function isIdle(state: TotpSetupState): state is TotpIdle {
return state.step === "idle";
}
export function isQr(state: TotpSetupState): state is TotpQr {
return state.step === "qr";
}
export function isVerify(state: TotpSetupState): state is TotpVerify {
return state.step === "verify";
}
export function isBackup(state: TotpSetupState): state is TotpBackup {
return state.step === "backup";
}

View File

@@ -1,45 +1,24 @@
<script lang="ts">
import { getAuthState } from '../lib/auth.svelte'
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
import { generateCodeVerifier, generateCodeChallenge, saveOAuthState, generateState, createDPoPProofForRequest } from '../lib/oauth'
import AuthenticatedRoute from '../components/AuthenticatedRoute.svelte'
import { navigate } from '../lib/router.svelte'
import { generateCodeVerifier, generateCodeChallenge, saveOAuthState, generateState, createDPoPProofForRequest, setDPoPNonce } from '../lib/oauth'
import { _ } from '../lib/i18n'
import type { Session } from '../lib/types/api'
import type { Session, DelegationControlledAccount } from '../lib/types/api'
import type { AuthenticatedClient } from '../lib/authenticated-client'
const auth = $derived(getAuthState())
function getSession(): Session | null {
return auth.kind === 'authenticated' ? auth.session : null
}
function isLoading(): boolean {
return auth.kind === 'loading'
}
const session = $derived(getSession())
const authLoading = $derived(isLoading())
let error = $state<string | null>(null)
let loading = $state(true)
let actAsInProgress = $state(false)
let actAsStarted = $state(false)
function getDid(): string | null {
const params = new URLSearchParams(window.location.search)
return params.get('did')
}
$effect(() => {
if (!authLoading && !session && !actAsInProgress) {
navigate(routes.login)
}
})
async function initiateActAs(session: Session, client: AuthenticatedClient) {
if (actAsStarted) return
actAsStarted = true
$effect(() => {
if (session && !actAsInProgress) {
actAsInProgress = true
initiateActAs()
}
})
async function initiateActAs() {
const did = getDid()
if (!did) {
error = $_('actAs.noAccountSpecified')
@@ -47,87 +26,89 @@
return
}
try {
const response = await fetch(
`/xrpc/_delegation.listControlledAccounts`,
{
headers: { 'Authorization': `Bearer ${session!.accessJwt}` }
}
)
const result = await client.listDelegationControlledAccounts()
if (!result.ok) {
error = $_('actAs.failedToInitiate')
loading = false
return
}
if (!response.ok) {
error = $_('actAs.failedToVerify')
loading = false
return
}
const account = result.value.accounts?.find((a: DelegationControlledAccount) => a.did === did)
const data = await response.json()
const account = data.accounts?.find((a: { did: string }) => a.did === did)
if (!account) {
error = $_('actAs.noAccess')
loading = false
return
}
if (!account) {
error = $_('actAs.noAccess')
loading = false
return
}
const hostname = window.location.origin
const state = generateState()
const codeVerifier = generateCodeVerifier()
const codeChallenge = await generateCodeChallenge(codeVerifier)
saveOAuthState({ state, codeVerifier })
const hostname = window.location.origin
const state = generateState()
const codeVerifier = generateCodeVerifier()
const codeChallenge = await generateCodeChallenge(codeVerifier)
saveOAuthState({ state, codeVerifier })
const parResponse = await fetch('/oauth/par', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: `${hostname}/oauth/client-metadata.json`,
redirect_uri: `${hostname}/app/`,
response_type: 'code',
scope: 'atproto',
state: state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
login_hint: account.handle
})
const parResponse = await fetch('/oauth/par', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: `${hostname}/oauth/client-metadata.json`,
redirect_uri: `${hostname}/app/`,
response_type: 'code',
scope: 'atproto',
state: state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
login_hint: account.handle
})
})
if (!parResponse.ok) {
error = $_('actAs.failedToInitiate')
loading = false
return
}
if (!parResponse.ok) {
error = $_('actAs.failedToInitiate')
loading = false
return
}
const parData = await parResponse.json()
if (!parData.request_uri) {
error = $_('actAs.invalidResponse')
loading = false
return
}
const parData = await parResponse.json()
if (!parData.request_uri) {
error = $_('actAs.invalidResponse')
loading = false
return
}
const authUrl = `${window.location.origin}/oauth/delegation/auth-token`
const dpopProof = await createDPoPProofForRequest('POST', authUrl, session!.accessJwt)
const authResponse = await fetch('/oauth/delegation/auth-token', {
const authUrl = `${window.location.origin}/delegation/auth-token`
const body = JSON.stringify({
request_uri: parData.request_uri,
delegated_did: did
})
async function callAuthToken(retry: boolean): Promise<Response> {
const dpopProof = await createDPoPProofForRequest('POST', authUrl, session.accessJwt)
const response = await fetch('/oauth/delegation/auth-token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `DPoP ${session!.accessJwt}`,
'Authorization': `DPoP ${session.accessJwt}`,
'DPoP': dpopProof
},
body: JSON.stringify({
request_uri: parData.request_uri,
delegated_did: did
})
body
})
const authData = await authResponse.json()
if (authData.success && authData.redirect_uri) {
window.location.href = authData.redirect_uri
} else {
error = authData.error || $_('actAs.failedToInitiate')
loading = false
if (!response.ok && retry) {
const nonce = response.headers.get('DPoP-Nonce')
if (nonce) {
setDPoPNonce(nonce)
return callAuthToken(false)
}
}
} catch (e) {
error = $_('actAs.failedError', { values: { error: e instanceof Error ? e.message : String(e) } })
return response
}
const authResponse = await callAuthToken(true)
const authData = await authResponse.json()
if (authData.success && authData.redirect_uri) {
window.location.href = authData.redirect_uri
} else {
error = authData.error || $_('actAs.failedToInitiate')
loading = false
}
}
@@ -135,29 +116,37 @@
function goBack() {
navigate('/controllers')
}
function handleReady(session: Session, client: AuthenticatedClient) {
initiateActAs(session, client)
}
</script>
<div class="page">
{#if loading}
<div class="loading">
<p>{$_('actAs.preparing')}</p>
</div>
{:else}
<header>
<h1>{$_('actAs.title')}</h1>
</header>
<AuthenticatedRoute onReady={handleReady}>
{#snippet children({ session, client })}
<div class="page">
{#if loading}
<div class="loading">
<p>{$_('actAs.preparing')}</p>
</div>
{:else}
<header>
<h1>{$_('actAs.title')}</h1>
</header>
{#if error}
<div class="message error">{error}</div>
{/if}
{#if error}
<div class="message error">{error}</div>
{/if}
<div class="actions">
<button class="back-btn" onclick={goBack}>
{$_('actAs.backToControllers')}
</button>
<div class="actions">
<button class="back-btn" onclick={goBack}>
{$_('actAs.backToControllers')}
</button>
</div>
{/if}
</div>
{/if}
</div>
{/snippet}
</AuthenticatedRoute>
<style>
.page {

View File

@@ -1,23 +1,25 @@
<script lang="ts">
import { getAuthState } from '../lib/auth.svelte'
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
import AuthenticatedRoute from '../components/AuthenticatedRoute.svelte'
import { _ } from '../lib/i18n'
import { formatDateTime } from '../lib/date'
import type { Session } from '../lib/types/api'
import type { Session, DelegationController, DelegationControlledAccount, DelegationScopePreset } from '../lib/types/api'
import { toast } from '../lib/toast.svelte'
import type { AuthenticatedClient } from '../lib/authenticated-client'
import { unsafeAsDid, unsafeAsHandle, unsafeAsEmail, unsafeAsScopeSet } from '../lib/types/branded'
import type { Did, Handle, ScopeSet } from '../lib/types/branded'
interface Controller {
did: string
handle: string
grantedScopes: string
did: Did
handle: Handle
grantedScopes: ScopeSet
grantedAt: string
isActive: boolean
}
interface ControlledAccount {
did: string
handle: string
grantedScopes: string
did: Did
handle: Handle
grantedScopes: ScopeSet
grantedAt: string
}
@@ -25,22 +27,9 @@
name: string
label: string
description: string
scopes: string
scopes: ScopeSet
}
const auth = $derived(getAuthState())
function getSession(): Session | null {
return auth.kind === 'authenticated' ? auth.session : null
}
function isLoading(): boolean {
return auth.kind === 'loading'
}
const session = $derived(getSession())
const authLoading = $derived(isLoading())
let loading = $state(true)
let controllers = $state<Controller[]>([])
let controlledAccounts = $state<ControlledAccount[]>([])
@@ -63,394 +52,332 @@
let newDelegatedScopes = $state('atproto')
let creatingDelegated = $state(false)
$effect(() => {
if (!authLoading && !session) {
navigate(routes.login)
}
})
let currentClient: AuthenticatedClient | null = $state(null)
$effect(() => {
if (session) {
loadData()
}
})
function handleReady(_session: Session, client: AuthenticatedClient) {
currentClient = client
loadData(client)
}
async function loadData() {
async function loadData(client: AuthenticatedClient) {
loading = true
try {
await Promise.all([loadControllers(), loadControlledAccounts(), loadScopePresets()])
} finally {
loading = false
await Promise.all([loadControllers(client), loadControlledAccounts(client), loadScopePresets(client)])
loading = false
}
async function loadControllers(client: AuthenticatedClient) {
const result = await client.listDelegationControllers()
if (result.ok) {
controllers = (result.value.controllers ?? []).map((c: DelegationController) => ({
did: c.did,
handle: c.did as unknown as Handle,
grantedScopes: c.grantedScopes,
grantedAt: c.grantedAt,
isActive: c.isActive
}))
}
}
async function loadControllers() {
if (!session) return
try {
const response = await fetch('/xrpc/_delegation.listControllers', {
headers: { 'Authorization': `Bearer ${session.accessJwt}` }
})
if (response.ok) {
const data = await response.json()
controllers = data.controllers || []
}
} catch (e) {
console.error('Failed to load controllers:', e)
async function loadControlledAccounts(client: AuthenticatedClient) {
const result = await client.listDelegationControlledAccounts()
if (result.ok) {
controlledAccounts = (result.value.accounts ?? []).map((a: DelegationControlledAccount) => ({
did: a.did,
handle: a.handle,
grantedScopes: a.grantedScopes,
grantedAt: a.grantedAt
}))
}
}
async function loadControlledAccounts() {
if (!session) return
try {
const response = await fetch('/xrpc/_delegation.listControlledAccounts', {
headers: { 'Authorization': `Bearer ${session.accessJwt}` }
})
if (response.ok) {
const data = await response.json()
controlledAccounts = data.accounts || []
}
} catch (e) {
console.error('Failed to load controlled accounts:', e)
}
}
async function loadScopePresets() {
try {
const response = await fetch('/xrpc/_delegation.getScopePresets')
if (response.ok) {
const data = await response.json()
scopePresets = data.presets || []
}
} catch (e) {
console.error('Failed to load scope presets:', e)
async function loadScopePresets(client: AuthenticatedClient) {
const result = await client.getDelegationScopePresets()
if (result.ok) {
scopePresets = (result.value.presets ?? []).map((p: DelegationScopePreset) => ({
name: p.name,
label: p.name,
description: p.description,
scopes: unsafeAsScopeSet(p.scopes)
}))
}
}
async function addController() {
if (!session || !addControllerDid.trim()) return
if (!currentClient || !addControllerDid.trim()) return
addingController = true
try {
const response = await fetch('/xrpc/_delegation.addController', {
method: 'POST',
headers: {
'Authorization': `Bearer ${session.accessJwt}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
controller_did: addControllerDid.trim(),
granted_scopes: addControllerScopes
})
})
if (!response.ok) {
const data = await response.json()
toast.error(data.message || data.error || $_('delegation.failedToAddController'))
return
}
const controllerDid = unsafeAsDid(addControllerDid.trim())
const scopes = unsafeAsScopeSet(addControllerScopes)
const result = await currentClient.addDelegationController(controllerDid, scopes)
if (result.ok) {
toast.success($_('delegation.controllerAdded'))
addControllerDid = ''
addControllerScopes = 'atproto'
addControllerConfirmed = false
showAddController = false
await loadControllers()
} catch (e) {
toast.error($_('delegation.failedToAddController'))
} finally {
addingController = false
await loadControllers(currentClient)
}
addingController = false
}
async function removeController(controllerDid: string) {
if (!session) return
async function removeController(controllerDid: Did) {
if (!currentClient) return
if (!confirm($_('delegation.removeConfirm'))) return
try {
const response = await fetch('/xrpc/_delegation.removeController', {
method: 'POST',
headers: {
'Authorization': `Bearer ${session.accessJwt}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ controller_did: controllerDid })
})
if (!response.ok) {
const data = await response.json()
toast.error(data.message || data.error || $_('delegation.failedToRemoveController'))
return
}
const result = await currentClient.removeDelegationController(controllerDid)
if (result.ok) {
toast.success($_('delegation.controllerRemoved'))
await loadControllers()
} catch (e) {
toast.error($_('delegation.failedToRemoveController'))
await loadControllers(currentClient)
}
}
async function createDelegatedAccount() {
if (!session || !newDelegatedHandle.trim()) return
if (!currentClient || !newDelegatedHandle.trim()) return
creatingDelegated = true
try {
const response = await fetch('/xrpc/_delegation.createDelegatedAccount', {
method: 'POST',
headers: {
'Authorization': `Bearer ${session.accessJwt}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
handle: newDelegatedHandle.trim(),
email: newDelegatedEmail.trim() || undefined,
controllerScopes: newDelegatedScopes
})
})
if (!response.ok) {
const data = await response.json()
toast.error(data.message || data.error || $_('delegation.failedToCreateAccount'))
return
}
const data = await response.json()
toast.success($_('delegation.accountCreated', { values: { handle: data.handle } }))
const handle = unsafeAsHandle(newDelegatedHandle.trim())
const email = newDelegatedEmail.trim() ? unsafeAsEmail(newDelegatedEmail.trim()) : undefined
const scopes = unsafeAsScopeSet(newDelegatedScopes)
const result = await currentClient.createDelegatedAccount(handle, email, scopes)
if (result.ok) {
toast.success($_('delegation.accountCreated', { values: { handle: result.value.handle } }))
newDelegatedHandle = ''
newDelegatedEmail = ''
newDelegatedScopes = 'atproto'
showCreateDelegated = false
await loadControlledAccounts()
} catch (e) {
toast.error($_('delegation.failedToCreateAccount'))
} finally {
creatingDelegated = false
await loadControlledAccounts(currentClient)
}
creatingDelegated = false
}
function getScopeLabel(scopes: string): string {
function getScopeLabel(scopes: ScopeSet): string {
const preset = scopePresets.find(p => p.scopes === scopes)
if (preset) return preset.label
if (scopes === 'atproto') return $_('delegation.scopeOwner')
if (scopes === '') return $_('delegation.scopeViewer')
if ((scopes as string) === 'atproto') return $_('delegation.scopeOwner')
if ((scopes as string) === '') return $_('delegation.scopeViewer')
return $_('delegation.scopeCustom')
}
</script>
<div class="page">
<header>
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
<h1>{$_('delegation.title')}</h1>
</header>
<AuthenticatedRoute onReady={handleReady}>
{#snippet children({ session, client })}
<div class="page">
<header>
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
<h1>{$_('delegation.title')}</h1>
</header>
{#if loading}
<div class="skeleton-list">
{#each Array(2) as _}
<div class="skeleton-card"></div>
{/each}
</div>
{:else}
<section class="section">
<div class="section-header">
<h2>{$_('delegation.controllers')}</h2>
<p class="section-description">{$_('delegation.controllersDesc')}</p>
</div>
{#if controllers.length === 0}
<p class="empty">{$_('delegation.noControllers')}</p>
{#if loading}
<div class="skeleton-list">
{#each Array(2) as _}
<div class="skeleton-card"></div>
{/each}
</div>
{:else}
<div class="items-list">
{#each controllers as controller}
<div class="item-card" class:inactive={!controller.isActive}>
<div class="item-info">
<div class="item-header">
<span class="item-handle">@{controller.handle}</span>
<span class="badge scope">{getScopeLabel(controller.grantedScopes)}</span>
{#if !controller.isActive}
<span class="badge inactive">{$_('delegation.inactive')}</span>
{/if}
</div>
<div class="item-details">
<div class="detail">
<span class="label">{$_('delegation.did')}</span>
<span class="value did">{controller.did}</span>
<section class="section">
<div class="section-header">
<h2>{$_('delegation.controllers')}</h2>
<p class="section-description">{$_('delegation.controllersDesc')}</p>
</div>
{#if controllers.length === 0}
<p class="empty">{$_('delegation.noControllers')}</p>
{:else}
<div class="items-list">
{#each controllers as controller}
<div class="item-card" class:inactive={!controller.isActive}>
<div class="item-info">
<div class="item-header">
<span class="item-handle">@{controller.handle}</span>
<span class="badge scope">{getScopeLabel(controller.grantedScopes)}</span>
{#if !controller.isActive}
<span class="badge inactive">{$_('delegation.inactive')}</span>
{/if}
</div>
<div class="item-details">
<div class="detail">
<span class="label">{$_('delegation.did')}</span>
<span class="value did">{controller.did}</span>
</div>
<div class="detail">
<span class="label">{$_('delegation.granted')}</span>
<span class="value">{formatDateTime(controller.grantedAt)}</span>
</div>
</div>
</div>
<div class="detail">
<span class="label">{$_('delegation.granted')}</span>
<span class="value">{formatDateTime(controller.grantedAt)}</span>
<div class="item-actions">
<button class="danger-outline" onclick={() => removeController(controller.did)}>
{$_('delegation.remove')}
</button>
</div>
</div>
{/each}
</div>
{/if}
{#if !canAddControllers}
<div class="constraint-notice">
<p>{$_('delegation.cannotAddControllers')}</p>
</div>
{:else if showAddController}
<div class="form-card">
<h3>{$_('delegation.addController')}</h3>
<div class="warning-box">
<div class="warning-header">
<svg class="warning-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
<span>{$_('delegation.addControllerWarningTitle')}</span>
</div>
<p class="warning-text">{$_('delegation.addControllerWarningText')}</p>
<ul class="warning-bullets">
<li>{$_('delegation.addControllerWarningBullet1')}</li>
<li>{$_('delegation.addControllerWarningBullet2')}</li>
<li>{$_('delegation.addControllerWarningBullet3')}</li>
</ul>
</div>
<div class="item-actions">
<button class="danger-outline" onclick={() => removeController(controller.did)}>
{$_('delegation.remove')}
<div class="field">
<label for="controllerDid">{$_('delegation.controllerDid')}</label>
<input
id="controllerDid"
type="text"
bind:value={addControllerDid}
placeholder="did:plc:..."
disabled={addingController}
/>
</div>
<div class="field">
<label for="controllerScopes">{$_('delegation.accessLevel')}</label>
<select id="controllerScopes" bind:value={addControllerScopes} disabled={addingController}>
{#each scopePresets as preset}
<option value={preset.scopes}>{preset.label} - {preset.description}</option>
{/each}
</select>
</div>
<label class="confirm-checkbox">
<input type="checkbox" bind:checked={addControllerConfirmed} disabled={addingController} />
<span>{$_('delegation.addControllerConfirm')}</span>
</label>
<div class="form-actions">
<button class="ghost" onclick={() => { showAddController = false; addControllerConfirmed = false }} disabled={addingController}>
{$_('common.cancel')}
</button>
<button onclick={addController} disabled={addingController || !addControllerDid.trim() || !addControllerConfirmed}>
{addingController ? $_('delegation.adding') : $_('delegation.addController')}
</button>
</div>
</div>
{/each}
</div>
{/if}
{#if !canAddControllers}
<div class="constraint-notice">
<p>{$_('delegation.cannotAddControllers')}</p>
</div>
{:else if showAddController}
<div class="form-card">
<h3>{$_('delegation.addController')}</h3>
<div class="warning-box">
<div class="warning-header">
<svg class="warning-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
<line x1="12" y1="9" x2="12" y2="13"></line>
<line x1="12" y1="17" x2="12.01" y2="17"></line>
</svg>
<span>{$_('delegation.addControllerWarningTitle')}</span>
</div>
<p class="warning-text">{$_('delegation.addControllerWarningText')}</p>
<ul class="warning-bullets">
<li>{$_('delegation.addControllerWarningBullet1')}</li>
<li>{$_('delegation.addControllerWarningBullet2')}</li>
<li>{$_('delegation.addControllerWarningBullet3')}</li>
</ul>
</div>
<div class="field">
<label for="controllerDid">{$_('delegation.controllerDid')}</label>
<input
id="controllerDid"
type="text"
bind:value={addControllerDid}
placeholder="did:plc:..."
disabled={addingController}
/>
</div>
<div class="field">
<label for="controllerScopes">{$_('delegation.accessLevel')}</label>
<select id="controllerScopes" bind:value={addControllerScopes} disabled={addingController}>
{#each scopePresets as preset}
<option value={preset.scopes}>{preset.label} - {preset.description}</option>
{/each}
</select>
</div>
<label class="confirm-checkbox">
<input type="checkbox" bind:checked={addControllerConfirmed} disabled={addingController} />
<span>{$_('delegation.addControllerConfirm')}</span>
</label>
<div class="form-actions">
<button class="ghost" onclick={() => { showAddController = false; addControllerConfirmed = false }} disabled={addingController}>
{$_('common.cancel')}
</button>
<button onclick={addController} disabled={addingController || !addControllerDid.trim() || !addControllerConfirmed}>
{addingController ? $_('delegation.adding') : $_('delegation.addController')}
{:else}
<button class="ghost full-width" onclick={() => showAddController = true}>
{$_('delegation.addControllerButton')}
</button>
{/if}
</section>
<section class="section">
<div class="section-header">
<h2>{$_('delegation.controlledAccounts')}</h2>
<p class="section-description">{$_('delegation.controlledAccountsDesc')}</p>
</div>
</div>
{:else}
<button class="ghost full-width" onclick={() => showAddController = true}>
{$_('delegation.addControllerButton')}
</button>
{/if}
</section>
<section class="section">
<div class="section-header">
<h2>{$_('delegation.controlledAccounts')}</h2>
<p class="section-description">{$_('delegation.controlledAccountsDesc')}</p>
</div>
{#if controlledAccounts.length === 0}
<p class="empty">{$_('delegation.noControlledAccounts')}</p>
{:else}
<div class="items-list">
{#each controlledAccounts as account}
<div class="item-card">
<div class="item-info">
<div class="item-header">
<span class="item-handle">@{account.handle}</span>
<span class="badge scope">{getScopeLabel(account.grantedScopes)}</span>
</div>
<div class="item-details">
<div class="detail">
<span class="label">{$_('delegation.did')}</span>
<span class="value did">{account.did}</span>
{#if controlledAccounts.length === 0}
<p class="empty">{$_('delegation.noControlledAccounts')}</p>
{:else}
<div class="items-list">
{#each controlledAccounts as account}
<div class="item-card">
<div class="item-info">
<div class="item-header">
<span class="item-handle">@{account.handle}</span>
<span class="badge scope">{getScopeLabel(account.grantedScopes)}</span>
</div>
<div class="item-details">
<div class="detail">
<span class="label">{$_('delegation.did')}</span>
<span class="value did">{account.did}</span>
</div>
<div class="detail">
<span class="label">{$_('delegation.granted')}</span>
<span class="value">{formatDateTime(account.grantedAt)}</span>
</div>
</div>
</div>
<div class="detail">
<span class="label">{$_('delegation.granted')}</span>
<span class="value">{formatDateTime(account.grantedAt)}</span>
<div class="item-actions">
<a href="/app/act-as?did={encodeURIComponent(account.did)}" class="btn-link">
{$_('delegation.actAs')}
</a>
</div>
</div>
{/each}
</div>
{/if}
{#if !canControlAccounts}
<div class="constraint-notice">
<p>{$_('delegation.cannotControlAccounts')}</p>
</div>
{:else if showCreateDelegated}
<div class="form-card">
<h3>{$_('delegation.createDelegatedAccount')}</h3>
<div class="field">
<label for="delegatedHandle">{$_('delegation.handle')}</label>
<input
id="delegatedHandle"
type="text"
bind:value={newDelegatedHandle}
placeholder="username"
disabled={creatingDelegated}
/>
</div>
<div class="item-actions">
<a href="/app/act-as?did={encodeURIComponent(account.did)}" class="btn-link">
{$_('delegation.actAs')}
</a>
<div class="field">
<label for="delegatedEmail">{$_('delegation.emailOptional')}</label>
<input
id="delegatedEmail"
type="email"
bind:value={newDelegatedEmail}
placeholder="email@example.com"
disabled={creatingDelegated}
/>
</div>
<div class="field">
<label for="delegatedScopes">{$_('delegation.yourAccessLevel')}</label>
<select id="delegatedScopes" bind:value={newDelegatedScopes} disabled={creatingDelegated}>
{#each scopePresets as preset}
<option value={preset.scopes}>{preset.label} - {preset.description}</option>
{/each}
</select>
</div>
<div class="form-actions">
<button class="ghost" onclick={() => showCreateDelegated = false} disabled={creatingDelegated}>
{$_('common.cancel')}
</button>
<button onclick={createDelegatedAccount} disabled={creatingDelegated || !newDelegatedHandle.trim()}>
{creatingDelegated ? $_('common.creating') : $_('delegation.createAccount')}
</button>
</div>
</div>
{/each}
</div>
{/if}
{#if !canControlAccounts}
<div class="constraint-notice">
<p>{$_('delegation.cannotControlAccounts')}</p>
</div>
{:else if showCreateDelegated}
<div class="form-card">
<h3>{$_('delegation.createDelegatedAccount')}</h3>
<div class="field">
<label for="delegatedHandle">{$_('delegation.handle')}</label>
<input
id="delegatedHandle"
type="text"
bind:value={newDelegatedHandle}
placeholder="username"
disabled={creatingDelegated}
/>
</div>
<div class="field">
<label for="delegatedEmail">{$_('delegation.emailOptional')}</label>
<input
id="delegatedEmail"
type="email"
bind:value={newDelegatedEmail}
placeholder="email@example.com"
disabled={creatingDelegated}
/>
</div>
<div class="field">
<label for="delegatedScopes">{$_('delegation.yourAccessLevel')}</label>
<select id="delegatedScopes" bind:value={newDelegatedScopes} disabled={creatingDelegated}>
{#each scopePresets as preset}
<option value={preset.scopes}>{preset.label} - {preset.description}</option>
{/each}
</select>
</div>
<div class="form-actions">
<button class="ghost" onclick={() => showCreateDelegated = false} disabled={creatingDelegated}>
{$_('common.cancel')}
{:else}
<button class="ghost full-width" onclick={() => showCreateDelegated = true}>
{$_('delegation.createDelegatedAccountButton')}
</button>
<button onclick={createDelegatedAccount} disabled={creatingDelegated || !newDelegatedHandle.trim()}>
{creatingDelegated ? $_('common.creating') : $_('delegation.createAccount')}
</button>
</div>
</div>
{:else}
<button class="ghost full-width" onclick={() => showCreateDelegated = true}>
{$_('delegation.createDelegatedAccountButton')}
</button>
{/if}
</section>
{/if}
</section>
<section class="section">
<div class="section-header">
<h2>{$_('delegation.auditLog')}</h2>
<p class="section-description">{$_('delegation.auditLogDesc')}</p>
</div>
<a href="/app/delegation-audit" class="btn-link">{$_('delegation.viewAuditLog')}</a>
</section>
{/if}
</div>
<section class="section">
<div class="section-header">
<h2>{$_('delegation.auditLog')}</h2>
<p class="section-description">{$_('delegation.auditLogDesc')}</p>
</div>
<a href="/app/delegation-audit" class="btn-link">{$_('delegation.viewAuditLog')}</a>
</section>
{/if}
</div>
{/snippet}
</AuthenticatedRoute>
<style>
.page {
@@ -769,5 +696,4 @@
border-radius: var(--radius-xl);
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
</style>

View File

@@ -11,6 +11,7 @@
import { isOk } from '../lib/types/result'
import { unsafeAsDid, type Did } from '../lib/types/branded'
import type { Session } from '../lib/types/api'
import { isMigrated, isDeactivated, getSessionEmail, isEmailVerified } from '../lib/types/api'
import { onMount } from 'svelte'
const auth = $derived(getAuthState())
@@ -123,12 +124,12 @@
</div>
</header>
{#if session.status === 'migrated'}
{#if session.accountKind === 'migrated'}
<div class="migrated-banner">
<strong>{$_('dashboard.migratedTitle')}</strong>
<p>{$_('dashboard.migratedMessage', { values: { pds: session.migratedToPds || 'another PDS' } })}</p>
</div>
{:else if session.status === 'deactivated' || session.active === false}
{:else if session.accountKind === 'deactivated'}
<div class="deactivated-banner">
<strong>{$_('dashboard.deactivatedTitle')}</strong>
<p>{$_('dashboard.deactivatedMessage')}</p>
@@ -144,15 +145,15 @@
{#if session.isAdmin}
<span class="badge admin">{$_('dashboard.admin')}</span>
{/if}
{#if session.status === 'migrated'}
{#if session.accountKind === 'migrated'}
<span class="badge migrated">{$_('dashboard.migrated')}</span>
{:else if session.status === 'deactivated' || session.active === false}
{:else if session.accountKind === 'deactivated'}
<span class="badge deactivated">{$_('dashboard.deactivated')}</span>
{/if}
</dd>
<dt>{$_('dashboard.did')}</dt>
<dd class="mono">{session.did}</dd>
{#if session.preferredChannel}
{#if session.contactKind === 'channel'}
<dt>{$_('dashboard.primaryContact')}</dt>
<dd>
{#if session.preferredChannel === 'email'}
@@ -172,7 +173,7 @@
<span class="badge warning">{$_('dashboard.unverified')}</span>
{/if}
</dd>
{:else if session.email}
{:else if session.contactKind === 'email'}
<dt>{$_('register.email')}</dt>
<dd>
{session.email}
@@ -187,7 +188,7 @@
</section>
<nav class="nav-grid">
{#if session.status === 'migrated'}
{#if session.accountKind === 'migrated'}
<a href={getFullUrl(routes.didDocument)} class="nav-card migrated-card">
<h3>{$_('dashboard.navDidDocument')}</h3>
<p>{$_('dashboard.navDidDocumentDesc')}</p>

View File

@@ -1,10 +1,9 @@
<script lang="ts">
import { getAuthState } from '../lib/auth.svelte'
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
import AuthenticatedRoute from '../components/AuthenticatedRoute.svelte'
import { _ } from '../lib/i18n'
import { formatDateTime } from '../lib/date'
import type { Session } from '../lib/types/api'
import { toast } from '../lib/toast.svelte'
import type { DelegationAuditEntry } from '../lib/types/api'
import type { AuthenticatedClient } from '../lib/authenticated-client'
interface AuditEntry {
id: string
@@ -16,76 +15,49 @@
createdAt: string
}
const auth = $derived(getAuthState())
function getSession(): Session | null {
return auth.kind === 'authenticated' ? auth.session : null
}
function isLoading(): boolean {
return auth.kind === 'loading'
}
const session = $derived(getSession())
const authLoading = $derived(isLoading())
let loading = $state(true)
let entries = $state<AuditEntry[]>([])
let total = $state(0)
let offset = $state(0)
const limit = 20
$effect(() => {
if (!authLoading && !session) {
navigate(routes.login)
}
})
let currentClient: AuthenticatedClient | null = $state(null)
$effect(() => {
if (session) {
loadAuditLog()
}
})
function handleReady(_session: unknown, client: AuthenticatedClient) {
currentClient = client
loadAuditLog(client)
}
async function loadAuditLog() {
if (!session) return
async function loadAuditLog(client: AuthenticatedClient) {
loading = true
try {
const response = await fetch(
`/xrpc/_delegation.getAuditLog?limit=${limit}&offset=${offset}`,
{
headers: { 'Authorization': `Bearer ${session.accessJwt}` }
}
)
if (!response.ok) {
const data = await response.json()
toast.error(data.message || data.error || $_('delegation.failedToLoadAuditLog'))
return
}
const data = await response.json()
entries = data.entries || []
total = data.total || 0
} catch (e) {
toast.error($_('delegation.failedToLoadAuditLog'))
} finally {
loading = false
const result = await client.getDelegationAuditLog(limit, offset)
if (result.ok) {
entries = (result.value.entries ?? []).map((e: DelegationAuditEntry) => ({
id: e.id,
delegatedDid: e.target_did ?? '',
actorDid: e.actor_did,
controllerDid: null,
actionType: e.action,
actionDetails: e.details ? JSON.parse(e.details) : null,
createdAt: e.created_at
}))
total = result.value.total ?? 0
}
loading = false
}
function prevPage() {
if (offset > 0) {
if (offset > 0 && currentClient) {
offset = Math.max(0, offset - limit)
loadAuditLog()
loadAuditLog(currentClient)
}
}
function nextPage() {
if (offset + limit < total) {
if (offset + limit < total && currentClient) {
offset = offset + limit
loadAuditLog()
loadAuditLog(currentClient)
}
}
@@ -115,81 +87,85 @@
}
</script>
<div class="page">
<header>
<a href="/app/controllers" class="back">{$_('delegation.backToControllers')}</a>
<h1>{$_('delegation.auditLogTitle')}</h1>
</header>
<AuthenticatedRoute onReady={handleReady}>
{#snippet children({ session, client })}
<div class="page">
<header>
<a href="/app/controllers" class="back">{$_('delegation.backToControllers')}</a>
<h1>{$_('delegation.auditLogTitle')}</h1>
</header>
{#if loading}
<div class="skeleton-list">
{#each Array(3) as _}
<div class="skeleton-entry"></div>
{/each}
</div>
{:else}
{#if entries.length === 0}
<p class="empty">{$_('delegation.noActivity')}</p>
{:else}
<div class="audit-list">
{#each entries as entry}
<div class="audit-entry">
<div class="entry-header">
<span class="action-type">{formatActionType(entry.actionType)}</span>
<span class="timestamp">{formatDateTime(entry.createdAt)}</span>
</div>
<div class="entry-details">
<div class="detail">
<span class="label">{$_('delegation.actor')}</span>
<span class="value did" title={entry.actorDid}>{truncateDid(entry.actorDid)}</span>
</div>
{#if entry.controllerDid}
<div class="detail">
<span class="label">{$_('delegation.controller')}</span>
<span class="value did" title={entry.controllerDid}>{truncateDid(entry.controllerDid)}</span>
{#if loading}
<div class="skeleton-list">
{#each Array(3) as _}
<div class="skeleton-entry"></div>
{/each}
</div>
{:else}
{#if entries.length === 0}
<p class="empty">{$_('delegation.noActivity')}</p>
{:else}
<div class="audit-list">
{#each entries as entry}
<div class="audit-entry">
<div class="entry-header">
<span class="action-type">{formatActionType(entry.actionType)}</span>
<span class="timestamp">{formatDateTime(entry.createdAt)}</span>
</div>
{/if}
<div class="detail">
<span class="label">{$_('delegation.account')}</span>
<span class="value did" title={entry.delegatedDid}>{truncateDid(entry.delegatedDid)}</span>
</div>
{#if entry.actionDetails}
<div class="detail">
<span class="label">{$_('delegation.details')}</span>
<span class="value details">{formatActionDetails(entry.actionDetails)}</span>
<div class="entry-details">
<div class="detail">
<span class="label">{$_('delegation.actor')}</span>
<span class="value did" title={entry.actorDid}>{truncateDid(entry.actorDid)}</span>
</div>
{#if entry.controllerDid}
<div class="detail">
<span class="label">{$_('delegation.controller')}</span>
<span class="value did" title={entry.controllerDid}>{truncateDid(entry.controllerDid)}</span>
</div>
{/if}
<div class="detail">
<span class="label">{$_('delegation.account')}</span>
<span class="value did" title={entry.delegatedDid}>{truncateDid(entry.delegatedDid)}</span>
</div>
{#if entry.actionDetails}
<div class="detail">
<span class="label">{$_('delegation.details')}</span>
<span class="value details">{formatActionDetails(entry.actionDetails)}</span>
</div>
{/if}
</div>
{/if}
</div>
</div>
{/each}
</div>
{/each}
</div>
<div class="pagination">
<button
class="ghost"
onclick={prevPage}
disabled={offset === 0}
>
{$_('delegation.previous')}
</button>
<span class="page-info">
{$_('delegation.showing', { values: { start: offset + 1, end: Math.min(offset + limit, total), total } })}
</span>
<button
class="ghost"
onclick={nextPage}
disabled={offset + limit >= total}
>
{$_('delegation.next')}
</button>
</div>
{/if}
<div class="pagination">
<button
class="ghost"
onclick={prevPage}
disabled={offset === 0}
>
{$_('delegation.previous')}
</button>
<span class="page-info">
{$_('delegation.showing', { values: { start: offset + 1, end: Math.min(offset + limit, total), total } })}
</span>
<button
class="ghost"
onclick={nextPage}
disabled={offset + limit >= total}
>
{$_('delegation.next')}
</button>
</div>
{/if}
<div class="actions-bar">
<button class="ghost" onclick={loadAuditLog}>{$_('delegation.refresh')}</button>
<div class="actions-bar">
<button class="ghost" onclick={() => currentClient && loadAuditLog(currentClient)}>{$_('delegation.refresh')}</button>
</div>
{/if}
</div>
{/if}
</div>
{/snippet}
</AuthenticatedRoute>
<style>
.page {
@@ -332,5 +308,4 @@
border-radius: var(--radius-lg);
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
</style>

View File

@@ -1,18 +1,30 @@
<script lang="ts">
import { getAuthState, getValidToken } from '../lib/auth.svelte'
import { navigate, routes, getFullUrl } from '../lib/router.svelte'
import AuthenticatedRoute from '../components/AuthenticatedRoute.svelte'
import { getValidToken } from '../lib/auth.svelte'
import { routes, getFullUrl } from '../lib/router.svelte'
import { api, ApiError } from '../lib/api'
import ReauthModal from '../components/ReauthModal.svelte'
import SsoIcon from '../components/SsoIcon.svelte'
import { _ } from '../lib/i18n'
import { formatDate as formatDateUtil } from '../lib/date'
import type { Session } from '../lib/types/api'
import type { Session, SsoLinkedAccount } from '../lib/types/api'
import type { AuthenticatedClient } from '../lib/authenticated-client'
import {
prepareCreationOptions,
serializeAttestationResponse,
type WebAuthnCreationOptionsResponse,
} from '../lib/webauthn'
import { toast } from '../lib/toast.svelte'
import {
type TotpSetupState,
idleState,
qrState,
verifyState,
backupState,
goBackToQr,
finish,
type TotpQr,
} from '../lib/types/totp-state'
interface SsoProvider {
provider: string
@@ -20,39 +32,16 @@
icon: string
}
interface LinkedAccount {
id: string
provider: string
provider_name: string
provider_username: string | null
provider_email: string | null
created_at: string
last_login_at: string | null
}
const auth = $derived(getAuthState())
function getSession(): Session | null {
return auth.kind === 'authenticated' ? auth.session : null
}
function isLoading(): boolean {
return auth.kind === 'loading'
}
const session = $derived(getSession())
const authLoading = $derived(isLoading())
let currentSession: Session | null = $state(null)
let currentClient: AuthenticatedClient | null = $state(null)
let loading = $state(true)
let totpEnabled = $state(false)
let hasBackupCodes = $state(false)
let setupStep = $state<'idle' | 'qr' | 'verify' | 'backup'>('idle')
let qrBase64 = $state('')
let totpUri = $state('')
let totpSetup = $state<TotpSetupState>(idleState)
let verifyCodeRaw = $state('')
let verifyCode = $derived(verifyCodeRaw.replace(/\s/g, ''))
let verifyLoading = $state(false)
let backupCodes = $state<string[]>([])
let disablePassword = $state('')
let disableCode = $state('')
let disableLoading = $state(false)
@@ -87,7 +76,7 @@
let legacyLoginUpdating = $state(false)
let ssoProviders = $state<SsoProvider[]>([])
let linkedAccounts = $state<LinkedAccount[]>([])
let linkedAccounts = $state<SsoLinkedAccount[]>([])
let linkedAccountsLoading = $state(true)
let linkingProvider = $state<string | null>(null)
let unlinkingId = $state<string | null>(null)
@@ -97,13 +86,7 @@
let pendingAction = $state<(() => Promise<void>) | null>(null)
$effect(() => {
if (!authLoading && !session) {
navigate(routes.login)
}
})
$effect(() => {
if (session) {
if (currentSession && currentClient) {
loadTotpStatus()
loadPasskeys()
loadPasswordStatus()
@@ -126,16 +109,11 @@
}
async function loadLinkedAccounts() {
if (!session) return
if (!currentClient) return
linkedAccountsLoading = true
try {
const response = await fetch('/oauth/sso/linked', {
headers: { 'Authorization': `Bearer ${session.accessJwt}` }
})
if (response.ok) {
const data = await response.json()
linkedAccounts = data.accounts || []
}
const data = await currentClient.getSsoLinkedAccounts()
linkedAccounts = data.accounts || []
} catch {
linkedAccounts = []
} finally {
@@ -154,7 +132,7 @@
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': `Bearer ${session?.accessJwt}`
'Authorization': `Bearer ${currentSession?.accessJwt}`
},
body: JSON.stringify({
provider,
@@ -200,7 +178,7 @@
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${session?.accessJwt}`
'Authorization': `Bearer ${currentSession?.accessJwt}`
},
body: JSON.stringify({ id })
})
@@ -228,10 +206,10 @@
}
async function loadPasswordStatus() {
if (!session) return
if (!currentSession) return
passwordLoading = true
try {
const status = await api.getPasswordStatus(session.accessJwt)
const status = await api.getPasswordStatus(currentSession.accessJwt)
hasPassword = status.hasPassword
} catch {
hasPassword = true
@@ -241,10 +219,10 @@
}
async function loadLegacyLoginPreference() {
if (!session) return
if (!currentSession) return
legacyLoginLoading = true
try {
const pref = await api.getLegacyLoginPreference(session.accessJwt)
const pref = await api.getLegacyLoginPreference(currentSession.accessJwt)
allowLegacyLogin = pref.allowLegacyLogin
hasMfa = pref.hasMfa
} catch {
@@ -256,10 +234,10 @@
}
async function handleToggleLegacyLogin() {
if (!session) return
if (!currentSession) return
legacyLoginUpdating = true
try {
const result = await api.updateLegacyLoginPreference(session.accessJwt, !allowLegacyLogin)
const result = await api.updateLegacyLoginPreference(currentSession.accessJwt, !allowLegacyLogin)
allowLegacyLogin = result.allowLegacyLogin
toast.success(allowLegacyLogin
? $_('security.legacyLoginEnabled')
@@ -282,7 +260,7 @@
}
async function handleRemovePassword() {
if (!session) return
if (!currentSession) return
removePasswordLoading = true
try {
const token = await getValidToken()
@@ -323,10 +301,10 @@
}
async function loadTotpStatus() {
if (!session) return
if (!currentSession) return
loading = true
try {
const status = await api.getTotpStatus(session.accessJwt)
const status = await api.getTotpStatus(currentSession.accessJwt)
totpEnabled = status.enabled
hasBackupCodes = status.hasBackupCodes
} catch {
@@ -337,13 +315,11 @@
}
async function handleStartSetup() {
if (!session) return
if (!currentSession) return
verifyLoading = true
try {
const result = await api.createTotpSecret(session.accessJwt)
qrBase64 = result.qrBase64
totpUri = result.uri
setupStep = 'qr'
const result = await api.createTotpSecret(currentSession.accessJwt)
totpSetup = qrState(result.qrBase64, result.uri)
} catch (e) {
toast.error(e instanceof ApiError ? e.message : 'Failed to generate TOTP secret')
} finally {
@@ -353,12 +329,11 @@
async function handleVerifySetup(e: Event) {
e.preventDefault()
if (!session || !verifyCode) return
if (!currentSession || !verifyCode || totpSetup.step !== 'verify') return
verifyLoading = true
try {
const result = await api.enableTotp(session.accessJwt, verifyCode)
backupCodes = result.backupCodes
setupStep = 'backup'
const result = await api.enableTotp(currentSession.accessJwt, verifyCode)
totpSetup = backupState(totpSetup, result.backupCodes)
totpEnabled = true
hasBackupCodes = true
verifyCodeRaw = ''
@@ -370,19 +345,17 @@
}
function handleFinishSetup() {
setupStep = 'idle'
backupCodes = []
qrBase64 = ''
totpUri = ''
if (totpSetup.step !== 'backup') return
totpSetup = finish(totpSetup)
toast.success($_('security.totpEnabledSuccess'))
}
async function handleDisable(e: Event) {
e.preventDefault()
if (!session || !disablePassword || !disableCode) return
if (!currentSession || !disablePassword || !disableCode) return
disableLoading = true
try {
await api.disableTotp(session.accessJwt, disablePassword, disableCode)
await api.disableTotp(currentSession.accessJwt, disablePassword, disableCode)
totpEnabled = false
hasBackupCodes = false
showDisableForm = false
@@ -398,12 +371,12 @@
async function handleRegenerate(e: Event) {
e.preventDefault()
if (!session || !regenPassword || !regenCode) return
if (!currentSession || !regenPassword || !regenCode) return
regenLoading = true
try {
const result = await api.regenerateBackupCodes(session.accessJwt, regenPassword, regenCode)
backupCodes = result.backupCodes
setupStep = 'backup'
const result = await api.regenerateBackupCodes(currentSession.accessJwt, regenPassword, regenCode)
const dummyVerify = verifyState(qrState('', ''))
totpSetup = backupState(dummyVerify, result.backupCodes)
showRegenForm = false
regenPassword = ''
regenCode = ''
@@ -415,16 +388,17 @@
}
function copyBackupCodes() {
const text = backupCodes.join('\n')
if (totpSetup.step !== 'backup') return
const text = totpSetup.backupCodes.join('\n')
navigator.clipboard.writeText(text)
toast.success($_('security.backupCodesCopied'))
}
async function loadPasskeys() {
if (!session) return
if (!currentSession) return
passkeysLoading = true
try {
const result = await api.listPasskeys(session.accessJwt)
const result = await api.listPasskeys(currentSession.accessJwt)
passkeys = result.passkeys
} catch {
toast.error($_('security.failedToLoadPasskeys'))
@@ -434,14 +408,14 @@
}
async function handleAddPasskey() {
if (!session) return
if (!currentSession) return
if (!window.PublicKeyCredential) {
toast.error($_('security.passkeysNotSupported'))
return
}
addingPasskey = true
try {
const { options } = await api.startPasskeyRegistration(session.accessJwt, newPasskeyName || undefined)
const { options } = await api.startPasskeyRegistration(currentSession.accessJwt, newPasskeyName || undefined)
const publicKeyOptions = prepareCreationOptions(options as unknown as WebAuthnCreationOptionsResponse)
const credential = await navigator.credentials.create({
publicKey: publicKeyOptions
@@ -451,7 +425,7 @@
return
}
const credentialResponse = serializeAttestationResponse(credential as PublicKeyCredential)
await api.finishPasskeyRegistration(session.accessJwt, credentialResponse, newPasskeyName || undefined)
await api.finishPasskeyRegistration(currentSession.accessJwt, credentialResponse, newPasskeyName || undefined)
await loadPasskeys()
newPasskeyName = ''
toast.success($_('security.passkeyAddedSuccess'))
@@ -467,12 +441,12 @@
}
async function handleDeletePasskey(id: string) {
if (!session) return
if (!currentSession) return
const passkey = passkeys.find(p => p.id === id)
const name = passkey?.friendlyName || 'this passkey'
if (!confirm($_('security.deletePasskeyConfirm', { values: { name } }))) return
try {
await api.deletePasskey(session.accessJwt, id)
await api.deletePasskey(currentSession.accessJwt, id)
await loadPasskeys()
toast.success($_('security.passkeyDeleted'))
} catch (e) {
@@ -481,9 +455,9 @@
}
async function handleSavePasskeyName() {
if (!session || !editingPasskeyId || !editPasskeyName.trim()) return
if (!currentSession || !editingPasskeyId || !editPasskeyName.trim()) return
try {
await api.updatePasskey(session.accessJwt, editingPasskeyId, editPasskeyName.trim())
await api.updatePasskey(currentSession.accessJwt, editingPasskeyId, editPasskeyName.trim())
await loadPasskeys()
editingPasskeyId = null
editPasskeyName = ''
@@ -506,15 +480,22 @@
function formatDate(dateStr: string): string {
return formatDateUtil(dateStr)
}
function handleReady(session: Session, client: AuthenticatedClient) {
currentSession = session
currentClient = client
}
</script>
<div class="page">
<header>
<a href={getFullUrl(routes.dashboard)} class="back">{$_('common.backToDashboard')}</a>
<h1>{$_('security.title')}</h1>
</header>
<AuthenticatedRoute onReady={handleReady}>
{#snippet children({ session, client })}
<div class="page">
<header>
<a href={getFullUrl(routes.dashboard)} class="back">{$_('common.backToDashboard')}</a>
<h1>{$_('security.title')}</h1>
</header>
{#if loading}
{#if loading}
<div class="skeleton-grid">
{#each Array(4) as _}
<div class="skeleton-section"></div>
@@ -528,7 +509,7 @@
{$_('security.totpDescription')}
</p>
{#if setupStep === 'idle'}
{#if totpSetup.step === 'idle'}
{#if totpEnabled}
<div class="status enabled">
<span>{$_('security.totpEnabled')}</span>
@@ -632,22 +613,24 @@
{$_('security.enableTotp')}
</button>
{/if}
{:else if setupStep === 'qr'}
{:else if totpSetup.step === 'qr'}
{@const qrData = totpSetup as TotpQr}
<div class="setup-step">
<h3>{$_('security.totpSetup')}</h3>
<p>{$_('security.totpSetupInstructions')}</p>
<div class="qr-container">
<img src="data:image/png;base64,{qrBase64}" alt="TOTP QR Code" class="qr-code" />
<img src="data:image/png;base64,{qrData.qrBase64}" alt="TOTP QR Code" class="qr-code" />
</div>
<details class="manual-entry">
<summary>{$_('security.cantScan')}</summary>
<code class="secret-code">{totpUri.split('secret=')[1]?.split('&')[0] || ''}</code>
<code class="secret-code">{qrData.totpUri.split('secret=')[1]?.split('&')[0] || ''}</code>
</details>
<button onclick={() => setupStep = 'verify'}>
<button onclick={() => totpSetup = verifyState(qrData)}>
{$_('security.next')}
</button>
</div>
{:else if setupStep === 'verify'}
{:else if totpSetup.step === 'verify'}
{@const verifyData = totpSetup}
<div class="setup-step">
<h3>{$_('security.totpSetup')}</h3>
<p>{$_('security.totpCodePlaceholder')}</p>
@@ -663,7 +646,7 @@
/>
</div>
<div class="actions">
<button type="button" class="secondary" onclick={() => { setupStep = 'qr' }}>
<button type="button" class="secondary" onclick={() => totpSetup = goBackToQr(verifyData)}>
{$_('common.back')}
</button>
<button type="submit" disabled={verifyLoading || verifyCode.length !== 6}>
@@ -672,14 +655,14 @@
</div>
</form>
</div>
{:else if setupStep === 'backup'}
{:else if totpSetup.step === 'backup'}
<div class="setup-step">
<h3>{$_('security.backupCodes')}</h3>
<p class="warning-text">
{$_('security.backupCodesDescription')}
</p>
<div class="backup-codes">
{#each backupCodes as code}
{#each totpSetup.backupCodes as code}
<code class="backup-code">{code}</code>
{/each}
</div>
@@ -960,14 +943,16 @@
</section>
{/if}
{/if}
</div>
</div>
<ReauthModal
bind:show={showReauthModal}
availableMethods={reauthMethods}
onSuccess={handleReauthSuccess}
onCancel={handleReauthCancel}
/>
<ReauthModal
bind:show={showReauthModal}
availableMethods={reauthMethods}
onSuccess={handleReauthSuccess}
onCancel={handleReauthCancel}
/>
{/snippet}
</AuthenticatedRoute>
<style>
.page {

View File

@@ -7,8 +7,10 @@
import { isOk } from '../lib/types/result'
import { unsafeAsHandle } from '../lib/types/branded'
import type { Session } from '../lib/types/api'
import { getSessionEmail } from '../lib/types/api'
import { toast } from '../lib/toast.svelte'
import ReauthModal from '../components/ReauthModal.svelte'
import { createAuthenticatedClient } from '../lib/authenticated-client'
const auth = $derived(getAuthState())
const supportedLocales = getSupportedLocales()
@@ -24,6 +26,7 @@
const session = $derived(getSession())
const loading = $derived(isLoading())
const client = $derived(session ? createAuthenticatedClient(session) : null)
onMount(() => {
api.describeServer().then(info => {
@@ -276,19 +279,10 @@
}
async function handleExportBlobs() {
if (!session) return
if (!client) return
exportBlobsLoading = true
try {
const response = await fetch('/xrpc/_backup.exportBlobs', {
headers: {
'Authorization': `Bearer ${session.accessJwt}`
}
})
if (!response.ok) {
const err = await response.json().catch(() => ({ message: 'Export failed' }))
throw new Error(err.message || 'Export failed')
}
const blob = await response.blob()
const blob = await client.exportBlobs()
if (blob.size === 0) {
toast.success($_('settings.messages.noBlobsToExport'))
return
@@ -296,14 +290,13 @@
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${session.handle}-blobs.zip`
a.download = `${client.session.handle}-blobs.zip`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
toast.success($_('settings.messages.blobsExported'))
} catch (e) {
toast.error(e instanceof Error ? e.message : $_('settings.messages.exportFailed'))
} catch {
} finally {
exportBlobsLoading = false
}
@@ -531,8 +524,8 @@
</section>
<section>
<h2>{$_('settings.changeEmail')}</h2>
{#if session?.email}
<p class="current">{$_('settings.currentEmail', { values: { email: session.email } })}</p>
{#if session && getSessionEmail(session)}
<p class="current">{$_('settings.currentEmail', { values: { email: getSessionEmail(session) } })}</p>
{/if}
{#if emailTokenRequired}
<form onsubmit={handleConfirmEmailUpdate}>

View File

@@ -611,8 +611,12 @@ hr {
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.section-hint {

View File

@@ -10,6 +10,7 @@ import {
mockEndpoint,
setupAuthenticatedUser,
setupFetchMock,
setupIndexedDBMock,
setupUnauthenticatedUser,
} from "./mocks.ts";
import { unsafeAsISODateString } from "../lib/types/branded.ts";
@@ -17,6 +18,7 @@ describe("AppPasswords", () => {
beforeEach(() => {
clearMocks();
setupFetchMock();
setupIndexedDBMock();
globalThis.confirm = vi.fn(() => true);
});
describe("authentication guard", () => {

View File

@@ -7,6 +7,7 @@ import {
mockData,
mockEndpoint,
setupFetchMock,
setupIndexedDBMock,
} from "./mocks.ts";
import { _testSetState, type SavedAccount } from "../lib/auth.svelte.ts";
import {
@@ -21,6 +22,7 @@ describe("Login", () => {
beforeEach(() => {
clearMocks();
setupFetchMock();
setupIndexedDBMock();
mockEndpoint(
"/oauth/par",
() => jsonResponse({ request_uri: "urn:mock:request" }),

View File

@@ -12,6 +12,63 @@ import {
unsafeAsRefreshToken,
} from "../lib/types/branded.ts";
function createMockIndexedDB() {
const stores: Map<string, Map<string, unknown>> = new Map();
return {
open: vi.fn((_name: string, _version?: number) => {
const createTransaction = (_storeName: string, _mode?: string) => {
const tx = {
objectStore: (name: string) => {
if (!stores.has(name)) {
stores.set(name, new Map());
}
const store = stores.get(name)!;
return {
put: (value: unknown, key: string) => {
store.set(key, value);
return { result: undefined };
},
get: (key: string) => ({
result: store.get(key),
}),
};
},
oncomplete: null as (() => void) | null,
onerror: null as (() => void) | null,
};
setTimeout(() => tx.oncomplete?.(), 0);
return tx;
};
const request = {
result: {
objectStoreNames: { contains: () => true },
createObjectStore: vi.fn(),
transaction: createTransaction,
close: vi.fn(),
},
error: null,
onsuccess: null as (() => void) | null,
onerror: null as (() => void) | null,
onupgradeneeded: null as (() => void) | null,
};
setTimeout(() => {
request.onupgradeneeded?.();
request.onsuccess?.();
}, 0);
return request;
}),
};
}
export function setupIndexedDBMock(): void {
(globalThis as unknown as { indexedDB: unknown }).indexedDB =
createMockIndexedDB();
}
const originalPushState = globalThis.history.pushState.bind(globalThis.history);
const originalReplaceState = globalThis.history.replaceState.bind(
globalThis.history,
@@ -165,15 +222,20 @@ export function errorResponse(
};
}
export const mockData = {
session: (overrides?: Partial<Session>): Session => ({
did: unsafeAsDid("did:web:test.tranquil.dev:u:testuser"),
handle: unsafeAsHandle("testuser.test.tranquil.dev"),
email: unsafeAsEmail("test@example.com"),
emailConfirmed: true,
accessJwt: unsafeAsAccessToken("mock-access-jwt-token"),
refreshJwt: unsafeAsRefreshToken("mock-refresh-jwt-token"),
...overrides,
}),
session: (overrides?: Partial<Session>): Session => {
const base = {
did: unsafeAsDid("did:web:test.tranquil.dev:u:testuser"),
handle: unsafeAsHandle("testuser.test.tranquil.dev"),
accessJwt: unsafeAsAccessToken("mock-access-jwt-token"),
refreshJwt: unsafeAsRefreshToken("mock-refresh-jwt-token"),
contactKind: "email" as const,
email: unsafeAsEmail("test@example.com"),
emailConfirmed: true,
accountKind: "active" as const,
isAdmin: false,
};
return { ...base, ...overrides } as Session;
},
appPassword: (overrides?: Partial<AppPassword>): AppPassword => ({
name: "Test App",
createdAt: unsafeAsISODateString(new Date().toISOString()),
@@ -225,6 +287,7 @@ export const mockData = {
};
export function setupDefaultMocks(): void {
setupFetchMock();
setupIndexedDBMock();
mockEndpoint(
"com.atproto.server.getSession",
() => jsonResponse(mockData.session()),

View File

@@ -6,71 +6,18 @@ import {
mockData,
mockEndpoint,
setupFetchMock,
setupIndexedDBMock,
} from "./mocks.ts";
import { _testSetState } from "../lib/auth.svelte.ts";
function createMockIndexedDB() {
const stores: Map<string, Map<string, unknown>> = new Map();
return {
open: vi.fn((_name: string, _version?: number) => {
const createTransaction = (_storeName: string, _mode?: string) => {
const tx = {
objectStore: (name: string) => {
if (!stores.has(name)) {
stores.set(name, new Map());
}
const store = stores.get(name)!;
return {
put: (value: unknown, key: string) => {
store.set(key, value);
return { result: undefined };
},
get: (key: string) => ({
result: store.get(key),
}),
};
},
oncomplete: null as (() => void) | null,
onerror: null as (() => void) | null,
};
setTimeout(() => tx.oncomplete?.(), 0);
return tx;
};
const request = {
result: {
objectStoreNames: { contains: () => true },
createObjectStore: vi.fn(),
transaction: createTransaction,
close: vi.fn(),
},
error: null,
onsuccess: null as (() => void) | null,
onerror: null as (() => void) | null,
onupgradeneeded: null as (() => void) | null,
};
setTimeout(() => {
request.onupgradeneeded?.();
request.onsuccess?.();
}, 0);
return request;
}),
};
}
describe("OAuth Registration Flow", () => {
beforeEach(() => {
clearMocks();
setupFetchMock();
setupIndexedDBMock();
sessionStorage.clear();
vi.restoreAllMocks();
(globalThis as unknown as { indexedDB: unknown }).indexedDB =
createMockIndexedDB();
Object.defineProperty(globalThis.location, "search", {
value: "",
writable: true,