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