mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-05-01 21:45:44 +00:00
fix: make frontend more type-safe
This commit is contained in:
@@ -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
333
frontend/deno.lock
generated
@@ -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=="
|
||||
},
|
||||
|
||||
59
frontend/src/components/AuthenticatedRoute.svelte
Normal file
59
frontend/src/components/AuthenticatedRoute.svelte
Normal 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>
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
78
frontend/src/lib/authenticated-client.ts
Normal file
78
frontend/src/lib/authenticated-client.ts
Normal 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 };
|
||||
@@ -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
|
||||
& {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 } {
|
||||
|
||||
67
frontend/src/lib/types/totp-state.ts
Normal file
67
frontend/src/lib/types/totp-state.ts
Normal 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";
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user