+4
.gitignore
+4
.gitignore
+1
-1
.sqlx/query-1658a90aede20695b0e6e87d2536fad5a538dbfc442625ef306272d2530ddc3a.json
+1
-1
.sqlx/query-1658a90aede20695b0e6e87d2536fad5a538dbfc442625ef306272d2530ddc3a.json
+1
-1
.sqlx/query-176d30f31356a4d128764c9c2eece81f8079a29e40b07ba58adc4380d58068c8.json
+1
-1
.sqlx/query-176d30f31356a4d128764c9c2eece81f8079a29e40b07ba58adc4380d58068c8.json
+76
.sqlx/query-1f1d099cc5f5800a939c03b60b24e889c615bb4dab0895863fd59c913f7895fd.json
+76
.sqlx/query-1f1d099cc5f5800a939c03b60b24e889c615bb4dab0895863fd59c913f7895fd.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT\n u.id, u.did, u.handle, u.password_hash,\n u.email_confirmed, u.discord_verified, u.telegram_verified, u.signal_verified,\n k.key_bytes, k.encryption_version\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.handle = $1 OR u.email = $1",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "id",
9
+
"type_info": "Uuid"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "did",
14
+
"type_info": "Text"
15
+
},
16
+
{
17
+
"ordinal": 2,
18
+
"name": "handle",
19
+
"type_info": "Text"
20
+
},
21
+
{
22
+
"ordinal": 3,
23
+
"name": "password_hash",
24
+
"type_info": "Text"
25
+
},
26
+
{
27
+
"ordinal": 4,
28
+
"name": "email_confirmed",
29
+
"type_info": "Bool"
30
+
},
31
+
{
32
+
"ordinal": 5,
33
+
"name": "discord_verified",
34
+
"type_info": "Bool"
35
+
},
36
+
{
37
+
"ordinal": 6,
38
+
"name": "telegram_verified",
39
+
"type_info": "Bool"
40
+
},
41
+
{
42
+
"ordinal": 7,
43
+
"name": "signal_verified",
44
+
"type_info": "Bool"
45
+
},
46
+
{
47
+
"ordinal": 8,
48
+
"name": "key_bytes",
49
+
"type_info": "Bytea"
50
+
},
51
+
{
52
+
"ordinal": 9,
53
+
"name": "encryption_version",
54
+
"type_info": "Int4"
55
+
}
56
+
],
57
+
"parameters": {
58
+
"Left": [
59
+
"Text"
60
+
]
61
+
},
62
+
"nullable": [
63
+
false,
64
+
false,
65
+
false,
66
+
false,
67
+
false,
68
+
false,
69
+
false,
70
+
false,
71
+
false,
72
+
true
73
+
]
74
+
},
75
+
"hash": "1f1d099cc5f5800a939c03b60b24e889c615bb4dab0895863fd59c913f7895fd"
76
+
}
+1
-1
.sqlx/query-458c98edc9c01286dc2677fcff82c1f84c5db138fdef9a8e8756771c30b66810.json
+1
-1
.sqlx/query-458c98edc9c01286dc2677fcff82c1f84c5db138fdef9a8e8756771c30b66810.json
-52
.sqlx/query-583ab12e7634fa1ac888dbe319f8cd77405ae6246656c8698a7618a5a29a4ccb.json
-52
.sqlx/query-583ab12e7634fa1ac888dbe319f8cd77405ae6246656c8698a7618a5a29a4ccb.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "SELECT u.id, u.did, u.handle, u.password_hash, k.key_bytes, k.encryption_version FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.handle = $1 OR u.email = $1",
4
-
"describe": {
5
-
"columns": [
6
-
{
7
-
"ordinal": 0,
8
-
"name": "id",
9
-
"type_info": "Uuid"
10
-
},
11
-
{
12
-
"ordinal": 1,
13
-
"name": "did",
14
-
"type_info": "Text"
15
-
},
16
-
{
17
-
"ordinal": 2,
18
-
"name": "handle",
19
-
"type_info": "Text"
20
-
},
21
-
{
22
-
"ordinal": 3,
23
-
"name": "password_hash",
24
-
"type_info": "Text"
25
-
},
26
-
{
27
-
"ordinal": 4,
28
-
"name": "key_bytes",
29
-
"type_info": "Bytea"
30
-
},
31
-
{
32
-
"ordinal": 5,
33
-
"name": "encryption_version",
34
-
"type_info": "Int4"
35
-
}
36
-
],
37
-
"parameters": {
38
-
"Left": [
39
-
"Text"
40
-
]
41
-
},
42
-
"nullable": [
43
-
false,
44
-
false,
45
-
false,
46
-
false,
47
-
false,
48
-
true
49
-
]
50
-
},
51
-
"hash": "583ab12e7634fa1ac888dbe319f8cd77405ae6246656c8698a7618a5a29a4ccb"
52
-
}
-25
.sqlx/query-6c3a6dbf8d0d2a460054f093bd2ec1130ea91911d7d187cafcb4573be12bfcf4.json
-25
.sqlx/query-6c3a6dbf8d0d2a460054f093bd2ec1130ea91911d7d187cafcb4573be12bfcf4.json
···
1
-
{
2
-
"db_name": "PostgreSQL",
3
-
"query": "INSERT INTO users (handle, email, did, password_hash) VALUES ($1, $2, $3, $4) RETURNING id",
4
-
"describe": {
5
-
"columns": [
6
-
{
7
-
"ordinal": 0,
8
-
"name": "id",
9
-
"type_info": "Uuid"
10
-
}
11
-
],
12
-
"parameters": {
13
-
"Left": [
14
-
"Text",
15
-
"Text",
16
-
"Text",
17
-
"Text"
18
-
]
19
-
},
20
-
"nullable": [
21
-
false
22
-
]
23
-
},
24
-
"hash": "6c3a6dbf8d0d2a460054f093bd2ec1130ea91911d7d187cafcb4573be12bfcf4"
25
-
}
+1
-1
.sqlx/query-841452a9e325ea5f4ae3bff00cd77c98667913225305df559559e36110516cfb.json
+1
-1
.sqlx/query-841452a9e325ea5f4ae3bff00cd77c98667913225305df559559e36110516cfb.json
+16
.sqlx/query-a507e7cd1c4d31c70f8e7d6c80fe4b6f8ac0b712c63421989faa413556bef6f1.json
+16
.sqlx/query-a507e7cd1c4d31c70f8e7d6c80fe4b6f8ac0b712c63421989faa413556bef6f1.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "UPDATE users SET email_confirmation_code = $1, email_confirmation_code_expires_at = $2 WHERE did = $3",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Text",
9
+
"Timestamptz",
10
+
"Text"
11
+
]
12
+
},
13
+
"nullable": []
14
+
},
15
+
"hash": "a507e7cd1c4d31c70f8e7d6c80fe4b6f8ac0b712c63421989faa413556bef6f1"
16
+
}
+1
-1
.sqlx/query-a7e1e6092df6481e64bf0c2237737b846628ed20ffa70b81fa2e416d5776185a.json
+1
-1
.sqlx/query-a7e1e6092df6481e64bf0c2237737b846628ed20ffa70b81fa2e416d5776185a.json
+94
.sqlx/query-ae85520d67815e95802c0e28db120c3c10badee74f78722d3cea58d183734bf6.json
+94
.sqlx/query-ae85520d67815e95802c0e28db120c3c10badee74f78722d3cea58d183734bf6.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT\n id, handle, email,\n preferred_notification_channel as \"channel: crate::notifications::NotificationChannel\",\n discord_id, telegram_username, signal_number,\n email_confirmed, discord_verified, telegram_verified, signal_verified\n FROM users\n WHERE did = $1",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "id",
9
+
"type_info": "Uuid"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "handle",
14
+
"type_info": "Text"
15
+
},
16
+
{
17
+
"ordinal": 2,
18
+
"name": "email",
19
+
"type_info": "Text"
20
+
},
21
+
{
22
+
"ordinal": 3,
23
+
"name": "channel: crate::notifications::NotificationChannel",
24
+
"type_info": {
25
+
"Custom": {
26
+
"name": "notification_channel",
27
+
"kind": {
28
+
"Enum": [
29
+
"email",
30
+
"discord",
31
+
"telegram",
32
+
"signal"
33
+
]
34
+
}
35
+
}
36
+
}
37
+
},
38
+
{
39
+
"ordinal": 4,
40
+
"name": "discord_id",
41
+
"type_info": "Text"
42
+
},
43
+
{
44
+
"ordinal": 5,
45
+
"name": "telegram_username",
46
+
"type_info": "Text"
47
+
},
48
+
{
49
+
"ordinal": 6,
50
+
"name": "signal_number",
51
+
"type_info": "Text"
52
+
},
53
+
{
54
+
"ordinal": 7,
55
+
"name": "email_confirmed",
56
+
"type_info": "Bool"
57
+
},
58
+
{
59
+
"ordinal": 8,
60
+
"name": "discord_verified",
61
+
"type_info": "Bool"
62
+
},
63
+
{
64
+
"ordinal": 9,
65
+
"name": "telegram_verified",
66
+
"type_info": "Bool"
67
+
},
68
+
{
69
+
"ordinal": 10,
70
+
"name": "signal_verified",
71
+
"type_info": "Bool"
72
+
}
73
+
],
74
+
"parameters": {
75
+
"Left": [
76
+
"Text"
77
+
]
78
+
},
79
+
"nullable": [
80
+
false,
81
+
false,
82
+
true,
83
+
false,
84
+
true,
85
+
true,
86
+
true,
87
+
false,
88
+
false,
89
+
false,
90
+
false
91
+
]
92
+
},
93
+
"hash": "ae85520d67815e95802c0e28db120c3c10badee74f78722d3cea58d183734bf6"
94
+
}
+1
-1
.sqlx/query-bfb9ee0187a0062cb83c9295cf266f56fed0edd0f9f154c1786f2b0cdbe39508.json
+1
-1
.sqlx/query-bfb9ee0187a0062cb83c9295cf266f56fed0edd0f9f154c1786f2b0cdbe39508.json
+1
-1
.sqlx/query-c2a90157c47bf1c36f08f4608932d214cc26b4794e0b922b1dae3dad18a7ddc0.json
+1
-1
.sqlx/query-c2a90157c47bf1c36f08f4608932d214cc26b4794e0b922b1dae3dad18a7ddc0.json
+76
.sqlx/query-cab71411113374c8c388a35281c676b3822629d505e84d51b60162e80a43d190.json
+76
.sqlx/query-cab71411113374c8c388a35281c676b3822629d505e84d51b60162e80a43d190.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT\n u.id, u.did, u.handle,\n u.email_confirmation_code,\n u.email_confirmation_code_expires_at,\n u.preferred_notification_channel as \"channel: crate::notifications::NotificationChannel\",\n k.key_bytes, k.encryption_version\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.did = $1",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "id",
9
+
"type_info": "Uuid"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "did",
14
+
"type_info": "Text"
15
+
},
16
+
{
17
+
"ordinal": 2,
18
+
"name": "handle",
19
+
"type_info": "Text"
20
+
},
21
+
{
22
+
"ordinal": 3,
23
+
"name": "email_confirmation_code",
24
+
"type_info": "Text"
25
+
},
26
+
{
27
+
"ordinal": 4,
28
+
"name": "email_confirmation_code_expires_at",
29
+
"type_info": "Timestamptz"
30
+
},
31
+
{
32
+
"ordinal": 5,
33
+
"name": "channel: crate::notifications::NotificationChannel",
34
+
"type_info": {
35
+
"Custom": {
36
+
"name": "notification_channel",
37
+
"kind": {
38
+
"Enum": [
39
+
"email",
40
+
"discord",
41
+
"telegram",
42
+
"signal"
43
+
]
44
+
}
45
+
}
46
+
}
47
+
},
48
+
{
49
+
"ordinal": 6,
50
+
"name": "key_bytes",
51
+
"type_info": "Bytea"
52
+
},
53
+
{
54
+
"ordinal": 7,
55
+
"name": "encryption_version",
56
+
"type_info": "Int4"
57
+
}
58
+
],
59
+
"parameters": {
60
+
"Left": [
61
+
"Text"
62
+
]
63
+
},
64
+
"nullable": [
65
+
false,
66
+
false,
67
+
false,
68
+
true,
69
+
true,
70
+
false,
71
+
false,
72
+
true
73
+
]
74
+
},
75
+
"hash": "cab71411113374c8c388a35281c676b3822629d505e84d51b60162e80a43d190"
76
+
}
+1
-1
.sqlx/query-e6a085193cbc5901c41e23c296ce3358bfd252e68502e5b8ccc9821d479d3c67.json
+1
-1
.sqlx/query-e6a085193cbc5901c41e23c296ce3358bfd252e68502e5b8ccc9821d479d3c67.json
+26
Cargo.lock
+26
Cargo.lock
···
960
960
"thiserror 2.0.17",
961
961
"tokio",
962
962
"tokio-tungstenite",
963
+
"tower-http",
963
964
"tracing",
964
965
"tracing-subscriber",
965
966
"urlencoding",
···
2559
2560
]
2560
2561
2561
2562
[[package]]
2563
+
name = "http-range-header"
2564
+
version = "0.4.2"
2565
+
source = "registry+https://github.com/rust-lang/crates.io-index"
2566
+
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
2567
+
2568
+
[[package]]
2562
2569
name = "httparse"
2563
2570
version = "1.10.1"
2564
2571
source = "registry+https://github.com/rust-lang/crates.io-index"
···
3581
3588
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
3582
3589
3583
3590
[[package]]
3591
+
name = "mime_guess"
3592
+
version = "2.0.5"
3593
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3594
+
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
3595
+
dependencies = [
3596
+
"mime",
3597
+
"unicase",
3598
+
]
3599
+
3600
+
[[package]]
3584
3601
name = "mini-moka"
3585
3602
version = "0.10.3"
3586
3603
source = "registry+https://github.com/rust-lang/crates.io-index"
···
6009
6026
dependencies = [
6010
6027
"bitflags",
6011
6028
"bytes",
6029
+
"futures-core",
6012
6030
"futures-util",
6013
6031
"http 1.4.0",
6014
6032
"http-body 1.0.1",
6033
+
"http-body-util",
6034
+
"http-range-header",
6035
+
"httpdate",
6015
6036
"iri-string",
6037
+
"mime",
6038
+
"mime_guess",
6039
+
"percent-encoding",
6016
6040
"pin-project-lite",
6041
+
"tokio",
6042
+
"tokio-util",
6017
6043
"tower",
6018
6044
"tower-layer",
6019
6045
"tower-service",
+1
Cargo.toml
+1
Cargo.toml
···
50
50
iroh-car = "0.5.1"
51
51
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] }
52
52
redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] }
53
+
tower-http = { version = "0.6", features = ["fs"] }
53
54
54
55
[features]
55
56
external-infra = []
+10
Dockerfile
+10
Dockerfile
···
1
+
# Stage 1: Build frontend with Deno
2
+
FROM denoland/deno:alpine AS frontend-builder
3
+
WORKDIR /frontend
4
+
COPY frontend/ ./
5
+
RUN deno task build
6
+
7
+
# Stage 2: Build Rust backend
1
8
FROM rust:1.91.1-alpine AS builder
2
9
3
10
RUN apk add ca-certificates openssl openssl-dev pkgconfig
···
13
20
COPY .sqlx ./.sqlx
14
21
RUN touch src/main.rs && cargo build --release
15
22
23
+
# Stage 3: Final image
16
24
FROM alpine:3.23
17
25
18
26
COPY --from=builder /app/target/release/bspds /usr/local/bin/bspds
19
27
COPY --from=builder /app/migrations /app/migrations
28
+
COPY --from=frontend-builder /frontend/dist /app/frontend/dist
20
29
21
30
WORKDIR /app
22
31
23
32
ENV SERVER_HOST=0.0.0.0
24
33
ENV SERVER_PORT=3000
34
+
ENV FRONTEND_DIR=/app/frontend/dist
25
35
26
36
EXPOSE 3000
27
37
+21
README.md
+21
README.md
···
14
14
- Crawler notifications via `requestCrawl`
15
15
- Multi-channel notifications: email, discord, telegram, signal
16
16
- Per-IP rate limiting on sensitive endpoints
17
+
- Built-in web UI for account management
17
18
18
19
## Running Locally
19
20
···
77
78
just db-reset # Drop and recreate local database
78
79
```
79
80
81
+
## Web UI
82
+
83
+
BSPDS includes a built-in web frontend for users to manage their accounts. Users can:
84
+
85
+
- Sign in and register new accounts
86
+
- Manage app passwords
87
+
- View and create invite codes
88
+
- Update email and handle
89
+
- Configure notification preferences
90
+
- Browse their repository data
91
+
92
+
The frontend is built with svelte and deno, and is served directly by the PDS.
93
+
94
+
```bash
95
+
just frontend-dev # Run frontend dev server
96
+
just frontend-build # Build for production
97
+
just frontend-test # Run frontend tests
98
+
```
99
+
80
100
## Project Structure
81
101
82
102
```
···
94
114
plc/ PLC directory client
95
115
circuit_breaker/ Circuit breaker for external services
96
116
rate_limit/ Per-IP rate limiting
117
+
frontend/ Svelte web UI (deno)
97
118
tests/ Integration tests
98
119
migrations/ SQLx migrations
99
120
```
+23
-14
TODO.md
+23
-14
TODO.md
···
258
258
A single-page web app for account management. The frontend (JS framework) calls existing ATProto XRPC endpoints - no server-side rendering or bespoke HTML form handlers.
259
259
260
260
### Architecture
261
-
- [ ] Static SPA served from PDS (or separate static host)
261
+
- [x] Static SPA served from PDS (or separate static host)
262
262
- [ ] Frontend authenticates via OAuth 2.1 flow (same as any ATProto client)
263
-
- [ ] All operations use standard XRPC endpoints (existing + new PDS-specific ones below)
264
-
- [ ] No server-side sessions or CSRF - pure API client
263
+
- [x] All operations use standard XRPC endpoints (existing + new PDS-specific ones below)
264
+
- [x] No server-side sessions or CSRF - pure API client
265
265
266
266
### PDS-Specific XRPC Endpoints (new)
267
267
Absolutely subject to change, "bspds" isn't even the real name of this pds thus far :D
268
268
Anyway... endpoints for PDS settings not covered by standard ATProto:
269
-
- [ ] `com.bspds.account.getNotificationPrefs` - get preferred channel, verified channels
270
-
- [ ] `com.bspds.account.updateNotificationPrefs` - set preferred channel
269
+
- [x] `com.bspds.account.getNotificationPrefs` - get preferred channel, verified channels
270
+
- [x] `com.bspds.account.updateNotificationPrefs` - set preferred channel
271
271
- [ ] `com.bspds.account.getNotificationHistory` - list past notifications
272
272
- [ ] `com.bspds.account.verifyChannel` - initiate verification for Discord/Telegram/Signal
273
273
- [ ] `com.bspds.account.confirmChannelVerification` - confirm with code
···
276
276
### Frontend Views
277
277
Uses existing ATProto endpoints where possible:
278
278
279
+
Authentication
280
+
- [x] Login page (uses `com.atproto.server.createSession`)
281
+
- [x] Registration page (uses `com.atproto.server.createAccount`)
282
+
- [x] Signup verification flow (uses `com.atproto.server.confirmSignup`, `resendVerification`)
283
+
- [ ] Password reset flow (uses `com.atproto.server.requestPasswordReset`, `resetPassword`)
284
+
279
285
User Dashboard
280
-
- [ ] Account overview (uses `com.atproto.server.getSession`, `com.atproto.admin.getAccountInfo`)
286
+
- [x] Account overview (uses `com.atproto.server.getSession`, `com.atproto.admin.getAccountInfo`)
281
287
- [ ] Active sessions view (needs new endpoint or extend existing)
282
-
- [ ] App passwords (uses `com.atproto.server.listAppPasswords`, `createAppPassword`, `revokeAppPassword`)
283
-
- [ ] Invite codes (uses `com.atproto.server.getAccountInviteCodes`, `createInviteCode`)
288
+
- [x] App passwords (uses `com.atproto.server.listAppPasswords`, `createAppPassword`, `revokeAppPassword`)
289
+
- [x] Invite codes (uses `com.atproto.server.getAccountInviteCodes`, `createInviteCode`)
284
290
285
291
Notification Preferences
286
-
- [ ] Channel selector (uses `com.bspds.account.*` endpoints above)
292
+
- [x] Channel selector (uses `com.bspds.account.*` endpoints above)
287
293
- [ ] Verification flows for Discord/Telegram/Signal
288
294
- [ ] Notification history view
289
295
290
296
Account Settings
291
-
- [ ] Email change (uses `com.atproto.server.requestEmailUpdate`, `updateEmail`)
292
-
- [ ] Password change (uses `com.atproto.server.requestPasswordReset`, `resetPassword`)
293
-
- [ ] Handle change (uses `com.atproto.identity.updateHandle`)
294
-
- [ ] Account deletion (uses `com.atproto.server.requestAccountDelete`, `deleteAccount`)
295
-
- [ ] Data export (uses `com.atproto.sync.getRepo`)
297
+
- [x] Email change (uses `com.atproto.server.requestEmailUpdate`, `updateEmail`)
298
+
- [ ] Password change while logged in (needs new endpoint - change password with current password)
299
+
- [x] Handle change (uses `com.atproto.identity.updateHandle`)
300
+
- [x] Account deletion (uses `com.atproto.server.requestAccountDelete`, `deleteAccount`)
301
+
302
+
Data Management
303
+
- [x] Repo browser (browse collections, view/create/delete records via `com.atproto.repo.*`)
304
+
- [ ] Data export/download (CAR file download via `com.atproto.sync.getRepo`)
296
305
297
306
Admin Dashboard (privileged users only)
298
307
- [ ] User list (uses `com.atproto.admin.getAccountInfos` with pagination)
+13
frontend/deno.json
+13
frontend/deno.json
···
1
+
{
2
+
"tasks": {
3
+
"dev": "deno run -A npm:vite",
4
+
"build": "deno run -A npm:vite build",
5
+
"preview": "deno run -A npm:vite preview",
6
+
"test": "deno run -A npm:vitest",
7
+
"test:run": "deno run -A npm:vitest run",
8
+
"test:watch": "deno run -A npm:vitest watch",
9
+
"test:ui": "deno run -A npm:vitest --ui",
10
+
"test:coverage": "deno run -A npm:vitest run --coverage"
11
+
},
12
+
"nodeModulesDir": "auto"
13
+
}
+1309
frontend/deno.lock
+1309
frontend/deno.lock
···
1
+
{
2
+
"version": "5",
3
+
"specifiers": {
4
+
"npm:@sveltejs/vite-plugin-svelte@5": "5.1.1_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3",
5
+
"npm:@testing-library/jest-dom@^6.6.3": "6.9.1",
6
+
"npm:@testing-library/svelte@^5.2.6": "5.2.9_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3_vitest@2.1.9__jsdom@25.0.1__vite@5.4.21_jsdom@25.0.1",
7
+
"npm:@testing-library/user-event@^14.5.2": "14.6.1_@testing-library+dom@10.4.1",
8
+
"npm:jsdom@^25.0.1": "25.0.1",
9
+
"npm:svelte@5": "5.45.10_acorn@8.15.0",
10
+
"npm:vite@*": "6.4.1_picomatch@4.0.3",
11
+
"npm:vite@6": "6.4.1_picomatch@4.0.3",
12
+
"npm:vitest@*": "2.1.9_jsdom@25.0.1_vite@5.4.21",
13
+
"npm:vitest@^2.1.8": "2.1.9_jsdom@25.0.1_vite@5.4.21"
14
+
},
15
+
"npm": {
16
+
"@adobe/css-tools@4.4.4": {
17
+
"integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="
18
+
},
19
+
"@asamuzakjp/css-color@3.2.0_@csstools+css-parser-algorithms@3.0.5__@csstools+css-tokenizer@3.0.4_@csstools+css-tokenizer@3.0.4": {
20
+
"integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
21
+
"dependencies": [
22
+
"@csstools/css-calc",
23
+
"@csstools/css-color-parser",
24
+
"@csstools/css-parser-algorithms",
25
+
"@csstools/css-tokenizer",
26
+
"lru-cache"
27
+
]
28
+
},
29
+
"@babel/code-frame@7.27.1": {
30
+
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
31
+
"dependencies": [
32
+
"@babel/helper-validator-identifier",
33
+
"js-tokens",
34
+
"picocolors"
35
+
]
36
+
},
37
+
"@babel/helper-validator-identifier@7.28.5": {
38
+
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="
39
+
},
40
+
"@babel/runtime@7.28.4": {
41
+
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="
42
+
},
43
+
"@csstools/color-helpers@5.1.0": {
44
+
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="
45
+
},
46
+
"@csstools/css-calc@2.1.4_@csstools+css-parser-algorithms@3.0.5__@csstools+css-tokenizer@3.0.4_@csstools+css-tokenizer@3.0.4": {
47
+
"integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
48
+
"dependencies": [
49
+
"@csstools/css-parser-algorithms",
50
+
"@csstools/css-tokenizer"
51
+
]
52
+
},
53
+
"@csstools/css-color-parser@3.1.0_@csstools+css-parser-algorithms@3.0.5__@csstools+css-tokenizer@3.0.4_@csstools+css-tokenizer@3.0.4": {
54
+
"integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
55
+
"dependencies": [
56
+
"@csstools/color-helpers",
57
+
"@csstools/css-calc",
58
+
"@csstools/css-parser-algorithms",
59
+
"@csstools/css-tokenizer"
60
+
]
61
+
},
62
+
"@csstools/css-parser-algorithms@3.0.5_@csstools+css-tokenizer@3.0.4": {
63
+
"integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
64
+
"dependencies": [
65
+
"@csstools/css-tokenizer"
66
+
]
67
+
},
68
+
"@csstools/css-tokenizer@3.0.4": {
69
+
"integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="
70
+
},
71
+
"@esbuild/aix-ppc64@0.21.5": {
72
+
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
73
+
"os": ["aix"],
74
+
"cpu": ["ppc64"]
75
+
},
76
+
"@esbuild/aix-ppc64@0.25.12": {
77
+
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
78
+
"os": ["aix"],
79
+
"cpu": ["ppc64"]
80
+
},
81
+
"@esbuild/android-arm64@0.21.5": {
82
+
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
83
+
"os": ["android"],
84
+
"cpu": ["arm64"]
85
+
},
86
+
"@esbuild/android-arm64@0.25.12": {
87
+
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
88
+
"os": ["android"],
89
+
"cpu": ["arm64"]
90
+
},
91
+
"@esbuild/android-arm@0.21.5": {
92
+
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
93
+
"os": ["android"],
94
+
"cpu": ["arm"]
95
+
},
96
+
"@esbuild/android-arm@0.25.12": {
97
+
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
98
+
"os": ["android"],
99
+
"cpu": ["arm"]
100
+
},
101
+
"@esbuild/android-x64@0.21.5": {
102
+
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
103
+
"os": ["android"],
104
+
"cpu": ["x64"]
105
+
},
106
+
"@esbuild/android-x64@0.25.12": {
107
+
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
108
+
"os": ["android"],
109
+
"cpu": ["x64"]
110
+
},
111
+
"@esbuild/darwin-arm64@0.21.5": {
112
+
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
113
+
"os": ["darwin"],
114
+
"cpu": ["arm64"]
115
+
},
116
+
"@esbuild/darwin-arm64@0.25.12": {
117
+
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
118
+
"os": ["darwin"],
119
+
"cpu": ["arm64"]
120
+
},
121
+
"@esbuild/darwin-x64@0.21.5": {
122
+
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
123
+
"os": ["darwin"],
124
+
"cpu": ["x64"]
125
+
},
126
+
"@esbuild/darwin-x64@0.25.12": {
127
+
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
128
+
"os": ["darwin"],
129
+
"cpu": ["x64"]
130
+
},
131
+
"@esbuild/freebsd-arm64@0.21.5": {
132
+
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
133
+
"os": ["freebsd"],
134
+
"cpu": ["arm64"]
135
+
},
136
+
"@esbuild/freebsd-arm64@0.25.12": {
137
+
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
138
+
"os": ["freebsd"],
139
+
"cpu": ["arm64"]
140
+
},
141
+
"@esbuild/freebsd-x64@0.21.5": {
142
+
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
143
+
"os": ["freebsd"],
144
+
"cpu": ["x64"]
145
+
},
146
+
"@esbuild/freebsd-x64@0.25.12": {
147
+
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
148
+
"os": ["freebsd"],
149
+
"cpu": ["x64"]
150
+
},
151
+
"@esbuild/linux-arm64@0.21.5": {
152
+
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
153
+
"os": ["linux"],
154
+
"cpu": ["arm64"]
155
+
},
156
+
"@esbuild/linux-arm64@0.25.12": {
157
+
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
158
+
"os": ["linux"],
159
+
"cpu": ["arm64"]
160
+
},
161
+
"@esbuild/linux-arm@0.21.5": {
162
+
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
163
+
"os": ["linux"],
164
+
"cpu": ["arm"]
165
+
},
166
+
"@esbuild/linux-arm@0.25.12": {
167
+
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
168
+
"os": ["linux"],
169
+
"cpu": ["arm"]
170
+
},
171
+
"@esbuild/linux-ia32@0.21.5": {
172
+
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
173
+
"os": ["linux"],
174
+
"cpu": ["ia32"]
175
+
},
176
+
"@esbuild/linux-ia32@0.25.12": {
177
+
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
178
+
"os": ["linux"],
179
+
"cpu": ["ia32"]
180
+
},
181
+
"@esbuild/linux-loong64@0.21.5": {
182
+
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
183
+
"os": ["linux"],
184
+
"cpu": ["loong64"]
185
+
},
186
+
"@esbuild/linux-loong64@0.25.12": {
187
+
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
188
+
"os": ["linux"],
189
+
"cpu": ["loong64"]
190
+
},
191
+
"@esbuild/linux-mips64el@0.21.5": {
192
+
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
193
+
"os": ["linux"],
194
+
"cpu": ["mips64el"]
195
+
},
196
+
"@esbuild/linux-mips64el@0.25.12": {
197
+
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
198
+
"os": ["linux"],
199
+
"cpu": ["mips64el"]
200
+
},
201
+
"@esbuild/linux-ppc64@0.21.5": {
202
+
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
203
+
"os": ["linux"],
204
+
"cpu": ["ppc64"]
205
+
},
206
+
"@esbuild/linux-ppc64@0.25.12": {
207
+
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
208
+
"os": ["linux"],
209
+
"cpu": ["ppc64"]
210
+
},
211
+
"@esbuild/linux-riscv64@0.21.5": {
212
+
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
213
+
"os": ["linux"],
214
+
"cpu": ["riscv64"]
215
+
},
216
+
"@esbuild/linux-riscv64@0.25.12": {
217
+
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
218
+
"os": ["linux"],
219
+
"cpu": ["riscv64"]
220
+
},
221
+
"@esbuild/linux-s390x@0.21.5": {
222
+
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
223
+
"os": ["linux"],
224
+
"cpu": ["s390x"]
225
+
},
226
+
"@esbuild/linux-s390x@0.25.12": {
227
+
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
228
+
"os": ["linux"],
229
+
"cpu": ["s390x"]
230
+
},
231
+
"@esbuild/linux-x64@0.21.5": {
232
+
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
233
+
"os": ["linux"],
234
+
"cpu": ["x64"]
235
+
},
236
+
"@esbuild/linux-x64@0.25.12": {
237
+
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
238
+
"os": ["linux"],
239
+
"cpu": ["x64"]
240
+
},
241
+
"@esbuild/netbsd-arm64@0.25.12": {
242
+
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
243
+
"os": ["netbsd"],
244
+
"cpu": ["arm64"]
245
+
},
246
+
"@esbuild/netbsd-x64@0.21.5": {
247
+
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
248
+
"os": ["netbsd"],
249
+
"cpu": ["x64"]
250
+
},
251
+
"@esbuild/netbsd-x64@0.25.12": {
252
+
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
253
+
"os": ["netbsd"],
254
+
"cpu": ["x64"]
255
+
},
256
+
"@esbuild/openbsd-arm64@0.25.12": {
257
+
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
258
+
"os": ["openbsd"],
259
+
"cpu": ["arm64"]
260
+
},
261
+
"@esbuild/openbsd-x64@0.21.5": {
262
+
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
263
+
"os": ["openbsd"],
264
+
"cpu": ["x64"]
265
+
},
266
+
"@esbuild/openbsd-x64@0.25.12": {
267
+
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
268
+
"os": ["openbsd"],
269
+
"cpu": ["x64"]
270
+
},
271
+
"@esbuild/openharmony-arm64@0.25.12": {
272
+
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
273
+
"os": ["openharmony"],
274
+
"cpu": ["arm64"]
275
+
},
276
+
"@esbuild/sunos-x64@0.21.5": {
277
+
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
278
+
"os": ["sunos"],
279
+
"cpu": ["x64"]
280
+
},
281
+
"@esbuild/sunos-x64@0.25.12": {
282
+
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
283
+
"os": ["sunos"],
284
+
"cpu": ["x64"]
285
+
},
286
+
"@esbuild/win32-arm64@0.21.5": {
287
+
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
288
+
"os": ["win32"],
289
+
"cpu": ["arm64"]
290
+
},
291
+
"@esbuild/win32-arm64@0.25.12": {
292
+
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
293
+
"os": ["win32"],
294
+
"cpu": ["arm64"]
295
+
},
296
+
"@esbuild/win32-ia32@0.21.5": {
297
+
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
298
+
"os": ["win32"],
299
+
"cpu": ["ia32"]
300
+
},
301
+
"@esbuild/win32-ia32@0.25.12": {
302
+
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
303
+
"os": ["win32"],
304
+
"cpu": ["ia32"]
305
+
},
306
+
"@esbuild/win32-x64@0.21.5": {
307
+
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
308
+
"os": ["win32"],
309
+
"cpu": ["x64"]
310
+
},
311
+
"@esbuild/win32-x64@0.25.12": {
312
+
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
313
+
"os": ["win32"],
314
+
"cpu": ["x64"]
315
+
},
316
+
"@jridgewell/gen-mapping@0.3.13": {
317
+
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
318
+
"dependencies": [
319
+
"@jridgewell/sourcemap-codec",
320
+
"@jridgewell/trace-mapping"
321
+
]
322
+
},
323
+
"@jridgewell/remapping@2.3.5": {
324
+
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
325
+
"dependencies": [
326
+
"@jridgewell/gen-mapping",
327
+
"@jridgewell/trace-mapping"
328
+
]
329
+
},
330
+
"@jridgewell/resolve-uri@3.1.2": {
331
+
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="
332
+
},
333
+
"@jridgewell/sourcemap-codec@1.5.5": {
334
+
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="
335
+
},
336
+
"@jridgewell/trace-mapping@0.3.31": {
337
+
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
338
+
"dependencies": [
339
+
"@jridgewell/resolve-uri",
340
+
"@jridgewell/sourcemap-codec"
341
+
]
342
+
},
343
+
"@rollup/rollup-android-arm-eabi@4.53.3": {
344
+
"integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==",
345
+
"os": ["android"],
346
+
"cpu": ["arm"]
347
+
},
348
+
"@rollup/rollup-android-arm64@4.53.3": {
349
+
"integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==",
350
+
"os": ["android"],
351
+
"cpu": ["arm64"]
352
+
},
353
+
"@rollup/rollup-darwin-arm64@4.53.3": {
354
+
"integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==",
355
+
"os": ["darwin"],
356
+
"cpu": ["arm64"]
357
+
},
358
+
"@rollup/rollup-darwin-x64@4.53.3": {
359
+
"integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==",
360
+
"os": ["darwin"],
361
+
"cpu": ["x64"]
362
+
},
363
+
"@rollup/rollup-freebsd-arm64@4.53.3": {
364
+
"integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==",
365
+
"os": ["freebsd"],
366
+
"cpu": ["arm64"]
367
+
},
368
+
"@rollup/rollup-freebsd-x64@4.53.3": {
369
+
"integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==",
370
+
"os": ["freebsd"],
371
+
"cpu": ["x64"]
372
+
},
373
+
"@rollup/rollup-linux-arm-gnueabihf@4.53.3": {
374
+
"integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==",
375
+
"os": ["linux"],
376
+
"cpu": ["arm"]
377
+
},
378
+
"@rollup/rollup-linux-arm-musleabihf@4.53.3": {
379
+
"integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==",
380
+
"os": ["linux"],
381
+
"cpu": ["arm"]
382
+
},
383
+
"@rollup/rollup-linux-arm64-gnu@4.53.3": {
384
+
"integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==",
385
+
"os": ["linux"],
386
+
"cpu": ["arm64"]
387
+
},
388
+
"@rollup/rollup-linux-arm64-musl@4.53.3": {
389
+
"integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==",
390
+
"os": ["linux"],
391
+
"cpu": ["arm64"]
392
+
},
393
+
"@rollup/rollup-linux-loong64-gnu@4.53.3": {
394
+
"integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==",
395
+
"os": ["linux"],
396
+
"cpu": ["loong64"]
397
+
},
398
+
"@rollup/rollup-linux-ppc64-gnu@4.53.3": {
399
+
"integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==",
400
+
"os": ["linux"],
401
+
"cpu": ["ppc64"]
402
+
},
403
+
"@rollup/rollup-linux-riscv64-gnu@4.53.3": {
404
+
"integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==",
405
+
"os": ["linux"],
406
+
"cpu": ["riscv64"]
407
+
},
408
+
"@rollup/rollup-linux-riscv64-musl@4.53.3": {
409
+
"integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==",
410
+
"os": ["linux"],
411
+
"cpu": ["riscv64"]
412
+
},
413
+
"@rollup/rollup-linux-s390x-gnu@4.53.3": {
414
+
"integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==",
415
+
"os": ["linux"],
416
+
"cpu": ["s390x"]
417
+
},
418
+
"@rollup/rollup-linux-x64-gnu@4.53.3": {
419
+
"integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==",
420
+
"os": ["linux"],
421
+
"cpu": ["x64"]
422
+
},
423
+
"@rollup/rollup-linux-x64-musl@4.53.3": {
424
+
"integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==",
425
+
"os": ["linux"],
426
+
"cpu": ["x64"]
427
+
},
428
+
"@rollup/rollup-openharmony-arm64@4.53.3": {
429
+
"integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==",
430
+
"os": ["openharmony"],
431
+
"cpu": ["arm64"]
432
+
},
433
+
"@rollup/rollup-win32-arm64-msvc@4.53.3": {
434
+
"integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==",
435
+
"os": ["win32"],
436
+
"cpu": ["arm64"]
437
+
},
438
+
"@rollup/rollup-win32-ia32-msvc@4.53.3": {
439
+
"integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==",
440
+
"os": ["win32"],
441
+
"cpu": ["ia32"]
442
+
},
443
+
"@rollup/rollup-win32-x64-gnu@4.53.3": {
444
+
"integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==",
445
+
"os": ["win32"],
446
+
"cpu": ["x64"]
447
+
},
448
+
"@rollup/rollup-win32-x64-msvc@4.53.3": {
449
+
"integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==",
450
+
"os": ["win32"],
451
+
"cpu": ["x64"]
452
+
},
453
+
"@sveltejs/acorn-typescript@1.0.8_acorn@8.15.0": {
454
+
"integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==",
455
+
"dependencies": [
456
+
"acorn"
457
+
]
458
+
},
459
+
"@sveltejs/vite-plugin-svelte-inspector@4.0.1_@sveltejs+vite-plugin-svelte@5.1.1__svelte@5.45.10___acorn@8.15.0__vite@6.4.1___picomatch@4.0.3_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3": {
460
+
"integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==",
461
+
"dependencies": [
462
+
"@sveltejs/vite-plugin-svelte",
463
+
"debug",
464
+
"svelte",
465
+
"vite@6.4.1_picomatch@4.0.3"
466
+
]
467
+
},
468
+
"@sveltejs/vite-plugin-svelte@5.1.1_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3": {
469
+
"integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==",
470
+
"dependencies": [
471
+
"@sveltejs/vite-plugin-svelte-inspector",
472
+
"debug",
473
+
"deepmerge",
474
+
"kleur",
475
+
"magic-string",
476
+
"svelte",
477
+
"vite@6.4.1_picomatch@4.0.3",
478
+
"vitefu"
479
+
]
480
+
},
481
+
"@testing-library/dom@10.4.1": {
482
+
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
483
+
"dependencies": [
484
+
"@babel/code-frame",
485
+
"@babel/runtime",
486
+
"@types/aria-query",
487
+
"aria-query@5.3.0",
488
+
"dom-accessibility-api@0.5.16",
489
+
"lz-string",
490
+
"picocolors",
491
+
"pretty-format"
492
+
]
493
+
},
494
+
"@testing-library/jest-dom@6.9.1": {
495
+
"integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
496
+
"dependencies": [
497
+
"@adobe/css-tools",
498
+
"aria-query@5.3.2",
499
+
"css.escape",
500
+
"dom-accessibility-api@0.6.3",
501
+
"picocolors",
502
+
"redent"
503
+
]
504
+
},
505
+
"@testing-library/svelte@5.2.9_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3_vitest@2.1.9__jsdom@25.0.1__vite@5.4.21_jsdom@25.0.1": {
506
+
"integrity": "sha512-p0Lg/vL1iEsEasXKSipvW9nBCtItQGhYvxL8OZ4w7/IDdC+LGoSJw4mMS5bndVFON/gWryitEhMr29AlO4FvBg==",
507
+
"dependencies": [
508
+
"@testing-library/dom",
509
+
"svelte",
510
+
"vite@6.4.1_picomatch@4.0.3",
511
+
"vitest"
512
+
],
513
+
"optionalPeers": [
514
+
"vite@6.4.1_picomatch@4.0.3",
515
+
"vitest"
516
+
]
517
+
},
518
+
"@testing-library/user-event@14.6.1_@testing-library+dom@10.4.1": {
519
+
"integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
520
+
"dependencies": [
521
+
"@testing-library/dom"
522
+
]
523
+
},
524
+
"@types/aria-query@5.0.4": {
525
+
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="
526
+
},
527
+
"@types/estree@1.0.8": {
528
+
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="
529
+
},
530
+
"@vitest/expect@2.1.9": {
531
+
"integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==",
532
+
"dependencies": [
533
+
"@vitest/spy",
534
+
"@vitest/utils",
535
+
"chai",
536
+
"tinyrainbow"
537
+
]
538
+
},
539
+
"@vitest/mocker@2.1.9_vite@5.4.21": {
540
+
"integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==",
541
+
"dependencies": [
542
+
"@vitest/spy",
543
+
"estree-walker",
544
+
"magic-string",
545
+
"vite@5.4.21"
546
+
],
547
+
"optionalPeers": [
548
+
"vite@5.4.21"
549
+
]
550
+
},
551
+
"@vitest/pretty-format@2.1.9": {
552
+
"integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==",
553
+
"dependencies": [
554
+
"tinyrainbow"
555
+
]
556
+
},
557
+
"@vitest/runner@2.1.9": {
558
+
"integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==",
559
+
"dependencies": [
560
+
"@vitest/utils",
561
+
"pathe"
562
+
]
563
+
},
564
+
"@vitest/snapshot@2.1.9": {
565
+
"integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==",
566
+
"dependencies": [
567
+
"@vitest/pretty-format",
568
+
"magic-string",
569
+
"pathe"
570
+
]
571
+
},
572
+
"@vitest/spy@2.1.9": {
573
+
"integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==",
574
+
"dependencies": [
575
+
"tinyspy"
576
+
]
577
+
},
578
+
"@vitest/utils@2.1.9": {
579
+
"integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==",
580
+
"dependencies": [
581
+
"@vitest/pretty-format",
582
+
"loupe",
583
+
"tinyrainbow"
584
+
]
585
+
},
586
+
"acorn@8.15.0": {
587
+
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
588
+
"bin": true
589
+
},
590
+
"agent-base@7.1.4": {
591
+
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="
592
+
},
593
+
"ansi-regex@5.0.1": {
594
+
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="
595
+
},
596
+
"ansi-styles@5.2.0": {
597
+
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="
598
+
},
599
+
"aria-query@5.3.0": {
600
+
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
601
+
"dependencies": [
602
+
"dequal"
603
+
]
604
+
},
605
+
"aria-query@5.3.2": {
606
+
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="
607
+
},
608
+
"assertion-error@2.0.1": {
609
+
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="
610
+
},
611
+
"asynckit@0.4.0": {
612
+
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
613
+
},
614
+
"axobject-query@4.1.0": {
615
+
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="
616
+
},
617
+
"cac@6.7.14": {
618
+
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="
619
+
},
620
+
"call-bind-apply-helpers@1.0.2": {
621
+
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
622
+
"dependencies": [
623
+
"es-errors",
624
+
"function-bind"
625
+
]
626
+
},
627
+
"chai@5.3.3": {
628
+
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
629
+
"dependencies": [
630
+
"assertion-error",
631
+
"check-error",
632
+
"deep-eql",
633
+
"loupe",
634
+
"pathval"
635
+
]
636
+
},
637
+
"check-error@2.1.1": {
638
+
"integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw=="
639
+
},
640
+
"clsx@2.1.1": {
641
+
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="
642
+
},
643
+
"combined-stream@1.0.8": {
644
+
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
645
+
"dependencies": [
646
+
"delayed-stream"
647
+
]
648
+
},
649
+
"css.escape@1.5.1": {
650
+
"integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="
651
+
},
652
+
"cssstyle@4.6.0": {
653
+
"integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
654
+
"dependencies": [
655
+
"@asamuzakjp/css-color",
656
+
"rrweb-cssom@0.8.0"
657
+
]
658
+
},
659
+
"data-urls@5.0.0": {
660
+
"integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
661
+
"dependencies": [
662
+
"whatwg-mimetype",
663
+
"whatwg-url"
664
+
]
665
+
},
666
+
"debug@4.4.3": {
667
+
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
668
+
"dependencies": [
669
+
"ms"
670
+
]
671
+
},
672
+
"decimal.js@10.6.0": {
673
+
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="
674
+
},
675
+
"deep-eql@5.0.2": {
676
+
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="
677
+
},
678
+
"deepmerge@4.3.1": {
679
+
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="
680
+
},
681
+
"delayed-stream@1.0.0": {
682
+
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="
683
+
},
684
+
"dequal@2.0.3": {
685
+
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="
686
+
},
687
+
"devalue@5.6.1": {
688
+
"integrity": "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A=="
689
+
},
690
+
"dom-accessibility-api@0.5.16": {
691
+
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="
692
+
},
693
+
"dom-accessibility-api@0.6.3": {
694
+
"integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="
695
+
},
696
+
"dunder-proto@1.0.1": {
697
+
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
698
+
"dependencies": [
699
+
"call-bind-apply-helpers",
700
+
"es-errors",
701
+
"gopd"
702
+
]
703
+
},
704
+
"entities@6.0.1": {
705
+
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="
706
+
},
707
+
"es-define-property@1.0.1": {
708
+
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="
709
+
},
710
+
"es-errors@1.3.0": {
711
+
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="
712
+
},
713
+
"es-module-lexer@1.7.0": {
714
+
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="
715
+
},
716
+
"es-object-atoms@1.1.1": {
717
+
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
718
+
"dependencies": [
719
+
"es-errors"
720
+
]
721
+
},
722
+
"es-set-tostringtag@2.1.0": {
723
+
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
724
+
"dependencies": [
725
+
"es-errors",
726
+
"get-intrinsic",
727
+
"has-tostringtag",
728
+
"hasown"
729
+
]
730
+
},
731
+
"esbuild@0.21.5": {
732
+
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
733
+
"optionalDependencies": [
734
+
"@esbuild/aix-ppc64@0.21.5",
735
+
"@esbuild/android-arm@0.21.5",
736
+
"@esbuild/android-arm64@0.21.5",
737
+
"@esbuild/android-x64@0.21.5",
738
+
"@esbuild/darwin-arm64@0.21.5",
739
+
"@esbuild/darwin-x64@0.21.5",
740
+
"@esbuild/freebsd-arm64@0.21.5",
741
+
"@esbuild/freebsd-x64@0.21.5",
742
+
"@esbuild/linux-arm@0.21.5",
743
+
"@esbuild/linux-arm64@0.21.5",
744
+
"@esbuild/linux-ia32@0.21.5",
745
+
"@esbuild/linux-loong64@0.21.5",
746
+
"@esbuild/linux-mips64el@0.21.5",
747
+
"@esbuild/linux-ppc64@0.21.5",
748
+
"@esbuild/linux-riscv64@0.21.5",
749
+
"@esbuild/linux-s390x@0.21.5",
750
+
"@esbuild/linux-x64@0.21.5",
751
+
"@esbuild/netbsd-x64@0.21.5",
752
+
"@esbuild/openbsd-x64@0.21.5",
753
+
"@esbuild/sunos-x64@0.21.5",
754
+
"@esbuild/win32-arm64@0.21.5",
755
+
"@esbuild/win32-ia32@0.21.5",
756
+
"@esbuild/win32-x64@0.21.5"
757
+
],
758
+
"scripts": true,
759
+
"bin": true
760
+
},
761
+
"esbuild@0.25.12": {
762
+
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
763
+
"optionalDependencies": [
764
+
"@esbuild/aix-ppc64@0.25.12",
765
+
"@esbuild/android-arm@0.25.12",
766
+
"@esbuild/android-arm64@0.25.12",
767
+
"@esbuild/android-x64@0.25.12",
768
+
"@esbuild/darwin-arm64@0.25.12",
769
+
"@esbuild/darwin-x64@0.25.12",
770
+
"@esbuild/freebsd-arm64@0.25.12",
771
+
"@esbuild/freebsd-x64@0.25.12",
772
+
"@esbuild/linux-arm@0.25.12",
773
+
"@esbuild/linux-arm64@0.25.12",
774
+
"@esbuild/linux-ia32@0.25.12",
775
+
"@esbuild/linux-loong64@0.25.12",
776
+
"@esbuild/linux-mips64el@0.25.12",
777
+
"@esbuild/linux-ppc64@0.25.12",
778
+
"@esbuild/linux-riscv64@0.25.12",
779
+
"@esbuild/linux-s390x@0.25.12",
780
+
"@esbuild/linux-x64@0.25.12",
781
+
"@esbuild/netbsd-arm64",
782
+
"@esbuild/netbsd-x64@0.25.12",
783
+
"@esbuild/openbsd-arm64",
784
+
"@esbuild/openbsd-x64@0.25.12",
785
+
"@esbuild/openharmony-arm64",
786
+
"@esbuild/sunos-x64@0.25.12",
787
+
"@esbuild/win32-arm64@0.25.12",
788
+
"@esbuild/win32-ia32@0.25.12",
789
+
"@esbuild/win32-x64@0.25.12"
790
+
],
791
+
"scripts": true,
792
+
"bin": true
793
+
},
794
+
"esm-env@1.2.2": {
795
+
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="
796
+
},
797
+
"esrap@2.2.1": {
798
+
"integrity": "sha512-GiYWG34AN/4CUyaWAgunGt0Rxvr1PTMlGC0vvEov/uOQYWne2bpN03Um+k8jT+q3op33mKouP2zeJ6OlM+qeUg==",
799
+
"dependencies": [
800
+
"@jridgewell/sourcemap-codec"
801
+
]
802
+
},
803
+
"estree-walker@3.0.3": {
804
+
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
805
+
"dependencies": [
806
+
"@types/estree"
807
+
]
808
+
},
809
+
"expect-type@1.3.0": {
810
+
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="
811
+
},
812
+
"fdir@6.5.0_picomatch@4.0.3": {
813
+
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
814
+
"dependencies": [
815
+
"picomatch"
816
+
],
817
+
"optionalPeers": [
818
+
"picomatch"
819
+
]
820
+
},
821
+
"form-data@4.0.5": {
822
+
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
823
+
"dependencies": [
824
+
"asynckit",
825
+
"combined-stream",
826
+
"es-set-tostringtag",
827
+
"hasown",
828
+
"mime-types"
829
+
]
830
+
},
831
+
"fsevents@2.3.3": {
832
+
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
833
+
"os": ["darwin"],
834
+
"scripts": true
835
+
},
836
+
"function-bind@1.1.2": {
837
+
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="
838
+
},
839
+
"get-intrinsic@1.3.0": {
840
+
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
841
+
"dependencies": [
842
+
"call-bind-apply-helpers",
843
+
"es-define-property",
844
+
"es-errors",
845
+
"es-object-atoms",
846
+
"function-bind",
847
+
"get-proto",
848
+
"gopd",
849
+
"has-symbols",
850
+
"hasown",
851
+
"math-intrinsics"
852
+
]
853
+
},
854
+
"get-proto@1.0.1": {
855
+
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
856
+
"dependencies": [
857
+
"dunder-proto",
858
+
"es-object-atoms"
859
+
]
860
+
},
861
+
"gopd@1.2.0": {
862
+
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="
863
+
},
864
+
"has-symbols@1.1.0": {
865
+
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="
866
+
},
867
+
"has-tostringtag@1.0.2": {
868
+
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
869
+
"dependencies": [
870
+
"has-symbols"
871
+
]
872
+
},
873
+
"hasown@2.0.2": {
874
+
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
875
+
"dependencies": [
876
+
"function-bind"
877
+
]
878
+
},
879
+
"html-encoding-sniffer@4.0.0": {
880
+
"integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
881
+
"dependencies": [
882
+
"whatwg-encoding"
883
+
]
884
+
},
885
+
"http-proxy-agent@7.0.2": {
886
+
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
887
+
"dependencies": [
888
+
"agent-base",
889
+
"debug"
890
+
]
891
+
},
892
+
"https-proxy-agent@7.0.6": {
893
+
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
894
+
"dependencies": [
895
+
"agent-base",
896
+
"debug"
897
+
]
898
+
},
899
+
"iconv-lite@0.6.3": {
900
+
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
901
+
"dependencies": [
902
+
"safer-buffer"
903
+
]
904
+
},
905
+
"indent-string@4.0.0": {
906
+
"integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="
907
+
},
908
+
"is-potential-custom-element-name@1.0.1": {
909
+
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="
910
+
},
911
+
"is-reference@3.0.3": {
912
+
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
913
+
"dependencies": [
914
+
"@types/estree"
915
+
]
916
+
},
917
+
"js-tokens@4.0.0": {
918
+
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
919
+
},
920
+
"jsdom@25.0.1": {
921
+
"integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==",
922
+
"dependencies": [
923
+
"cssstyle",
924
+
"data-urls",
925
+
"decimal.js",
926
+
"form-data",
927
+
"html-encoding-sniffer",
928
+
"http-proxy-agent",
929
+
"https-proxy-agent",
930
+
"is-potential-custom-element-name",
931
+
"nwsapi",
932
+
"parse5",
933
+
"rrweb-cssom@0.7.1",
934
+
"saxes",
935
+
"symbol-tree",
936
+
"tough-cookie",
937
+
"w3c-xmlserializer",
938
+
"webidl-conversions",
939
+
"whatwg-encoding",
940
+
"whatwg-mimetype",
941
+
"whatwg-url",
942
+
"ws",
943
+
"xml-name-validator"
944
+
]
945
+
},
946
+
"kleur@4.1.5": {
947
+
"integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="
948
+
},
949
+
"locate-character@3.0.0": {
950
+
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="
951
+
},
952
+
"loupe@3.2.1": {
953
+
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="
954
+
},
955
+
"lru-cache@10.4.3": {
956
+
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
957
+
},
958
+
"lz-string@1.5.0": {
959
+
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
960
+
"bin": true
961
+
},
962
+
"magic-string@0.30.21": {
963
+
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
964
+
"dependencies": [
965
+
"@jridgewell/sourcemap-codec"
966
+
]
967
+
},
968
+
"math-intrinsics@1.1.0": {
969
+
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="
970
+
},
971
+
"mime-db@1.52.0": {
972
+
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="
973
+
},
974
+
"mime-types@2.1.35": {
975
+
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
976
+
"dependencies": [
977
+
"mime-db"
978
+
]
979
+
},
980
+
"min-indent@1.0.1": {
981
+
"integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="
982
+
},
983
+
"ms@2.1.3": {
984
+
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
985
+
},
986
+
"nanoid@3.3.11": {
987
+
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
988
+
"bin": true
989
+
},
990
+
"nwsapi@2.2.23": {
991
+
"integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ=="
992
+
},
993
+
"parse5@7.3.0": {
994
+
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
995
+
"dependencies": [
996
+
"entities"
997
+
]
998
+
},
999
+
"pathe@1.1.2": {
1000
+
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="
1001
+
},
1002
+
"pathval@2.0.1": {
1003
+
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="
1004
+
},
1005
+
"picocolors@1.1.1": {
1006
+
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
1007
+
},
1008
+
"picomatch@4.0.3": {
1009
+
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="
1010
+
},
1011
+
"postcss@8.5.6": {
1012
+
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
1013
+
"dependencies": [
1014
+
"nanoid",
1015
+
"picocolors",
1016
+
"source-map-js"
1017
+
]
1018
+
},
1019
+
"pretty-format@27.5.1": {
1020
+
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
1021
+
"dependencies": [
1022
+
"ansi-regex",
1023
+
"ansi-styles",
1024
+
"react-is"
1025
+
]
1026
+
},
1027
+
"punycode@2.3.1": {
1028
+
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="
1029
+
},
1030
+
"react-is@17.0.2": {
1031
+
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
1032
+
},
1033
+
"redent@3.0.0": {
1034
+
"integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
1035
+
"dependencies": [
1036
+
"indent-string",
1037
+
"strip-indent"
1038
+
]
1039
+
},
1040
+
"rollup@4.53.3": {
1041
+
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
1042
+
"dependencies": [
1043
+
"@types/estree"
1044
+
],
1045
+
"optionalDependencies": [
1046
+
"@rollup/rollup-android-arm-eabi",
1047
+
"@rollup/rollup-android-arm64",
1048
+
"@rollup/rollup-darwin-arm64",
1049
+
"@rollup/rollup-darwin-x64",
1050
+
"@rollup/rollup-freebsd-arm64",
1051
+
"@rollup/rollup-freebsd-x64",
1052
+
"@rollup/rollup-linux-arm-gnueabihf",
1053
+
"@rollup/rollup-linux-arm-musleabihf",
1054
+
"@rollup/rollup-linux-arm64-gnu",
1055
+
"@rollup/rollup-linux-arm64-musl",
1056
+
"@rollup/rollup-linux-loong64-gnu",
1057
+
"@rollup/rollup-linux-ppc64-gnu",
1058
+
"@rollup/rollup-linux-riscv64-gnu",
1059
+
"@rollup/rollup-linux-riscv64-musl",
1060
+
"@rollup/rollup-linux-s390x-gnu",
1061
+
"@rollup/rollup-linux-x64-gnu",
1062
+
"@rollup/rollup-linux-x64-musl",
1063
+
"@rollup/rollup-openharmony-arm64",
1064
+
"@rollup/rollup-win32-arm64-msvc",
1065
+
"@rollup/rollup-win32-ia32-msvc",
1066
+
"@rollup/rollup-win32-x64-gnu",
1067
+
"@rollup/rollup-win32-x64-msvc",
1068
+
"fsevents"
1069
+
],
1070
+
"bin": true
1071
+
},
1072
+
"rrweb-cssom@0.7.1": {
1073
+
"integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg=="
1074
+
},
1075
+
"rrweb-cssom@0.8.0": {
1076
+
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="
1077
+
},
1078
+
"safer-buffer@2.1.2": {
1079
+
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
1080
+
},
1081
+
"saxes@6.0.0": {
1082
+
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
1083
+
"dependencies": [
1084
+
"xmlchars"
1085
+
]
1086
+
},
1087
+
"siginfo@2.0.0": {
1088
+
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="
1089
+
},
1090
+
"source-map-js@1.2.1": {
1091
+
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="
1092
+
},
1093
+
"stackback@0.0.2": {
1094
+
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="
1095
+
},
1096
+
"std-env@3.10.0": {
1097
+
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="
1098
+
},
1099
+
"strip-indent@3.0.0": {
1100
+
"integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
1101
+
"dependencies": [
1102
+
"min-indent"
1103
+
]
1104
+
},
1105
+
"svelte@5.45.10_acorn@8.15.0": {
1106
+
"integrity": "sha512-GiWXq6akkEN3zVDMQ1BVlRolmks5JkEdzD/67mvXOz6drRfuddT5JwsGZjMGSnsTRv/PjAXX8fqBcOr2g2qc/Q==",
1107
+
"dependencies": [
1108
+
"@jridgewell/remapping",
1109
+
"@jridgewell/sourcemap-codec",
1110
+
"@sveltejs/acorn-typescript",
1111
+
"@types/estree",
1112
+
"acorn",
1113
+
"aria-query@5.3.2",
1114
+
"axobject-query",
1115
+
"clsx",
1116
+
"devalue",
1117
+
"esm-env",
1118
+
"esrap",
1119
+
"is-reference",
1120
+
"locate-character",
1121
+
"magic-string",
1122
+
"zimmerframe"
1123
+
]
1124
+
},
1125
+
"symbol-tree@3.2.4": {
1126
+
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="
1127
+
},
1128
+
"tinybench@2.9.0": {
1129
+
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="
1130
+
},
1131
+
"tinyexec@0.3.2": {
1132
+
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="
1133
+
},
1134
+
"tinyglobby@0.2.15_picomatch@4.0.3": {
1135
+
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
1136
+
"dependencies": [
1137
+
"fdir",
1138
+
"picomatch"
1139
+
]
1140
+
},
1141
+
"tinypool@1.1.1": {
1142
+
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="
1143
+
},
1144
+
"tinyrainbow@1.2.0": {
1145
+
"integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ=="
1146
+
},
1147
+
"tinyspy@3.0.2": {
1148
+
"integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q=="
1149
+
},
1150
+
"tldts-core@6.1.86": {
1151
+
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="
1152
+
},
1153
+
"tldts@6.1.86": {
1154
+
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
1155
+
"dependencies": [
1156
+
"tldts-core"
1157
+
],
1158
+
"bin": true
1159
+
},
1160
+
"tough-cookie@5.1.2": {
1161
+
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
1162
+
"dependencies": [
1163
+
"tldts"
1164
+
]
1165
+
},
1166
+
"tr46@5.1.1": {
1167
+
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
1168
+
"dependencies": [
1169
+
"punycode"
1170
+
]
1171
+
},
1172
+
"vite-node@2.1.9": {
1173
+
"integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==",
1174
+
"dependencies": [
1175
+
"cac",
1176
+
"debug",
1177
+
"es-module-lexer",
1178
+
"pathe",
1179
+
"vite@5.4.21"
1180
+
],
1181
+
"bin": true
1182
+
},
1183
+
"vite@5.4.21": {
1184
+
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
1185
+
"dependencies": [
1186
+
"esbuild@0.21.5",
1187
+
"postcss",
1188
+
"rollup"
1189
+
],
1190
+
"optionalDependencies": [
1191
+
"fsevents"
1192
+
],
1193
+
"bin": true
1194
+
},
1195
+
"vite@6.4.1_picomatch@4.0.3": {
1196
+
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
1197
+
"dependencies": [
1198
+
"esbuild@0.25.12",
1199
+
"fdir",
1200
+
"picomatch",
1201
+
"postcss",
1202
+
"rollup",
1203
+
"tinyglobby"
1204
+
],
1205
+
"optionalDependencies": [
1206
+
"fsevents"
1207
+
],
1208
+
"bin": true
1209
+
},
1210
+
"vitefu@1.1.1_vite@6.4.1__picomatch@4.0.3": {
1211
+
"integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==",
1212
+
"dependencies": [
1213
+
"vite@6.4.1_picomatch@4.0.3"
1214
+
],
1215
+
"optionalPeers": [
1216
+
"vite@6.4.1_picomatch@4.0.3"
1217
+
]
1218
+
},
1219
+
"vitest@2.1.9_jsdom@25.0.1_vite@5.4.21": {
1220
+
"integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==",
1221
+
"dependencies": [
1222
+
"@vitest/expect",
1223
+
"@vitest/mocker",
1224
+
"@vitest/pretty-format",
1225
+
"@vitest/runner",
1226
+
"@vitest/snapshot",
1227
+
"@vitest/spy",
1228
+
"@vitest/utils",
1229
+
"chai",
1230
+
"debug",
1231
+
"expect-type",
1232
+
"jsdom",
1233
+
"magic-string",
1234
+
"pathe",
1235
+
"std-env",
1236
+
"tinybench",
1237
+
"tinyexec",
1238
+
"tinypool",
1239
+
"tinyrainbow",
1240
+
"vite@5.4.21",
1241
+
"vite-node",
1242
+
"why-is-node-running"
1243
+
],
1244
+
"optionalPeers": [
1245
+
"jsdom"
1246
+
],
1247
+
"bin": true
1248
+
},
1249
+
"w3c-xmlserializer@5.0.0": {
1250
+
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
1251
+
"dependencies": [
1252
+
"xml-name-validator"
1253
+
]
1254
+
},
1255
+
"webidl-conversions@7.0.0": {
1256
+
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="
1257
+
},
1258
+
"whatwg-encoding@3.1.1": {
1259
+
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
1260
+
"dependencies": [
1261
+
"iconv-lite"
1262
+
]
1263
+
},
1264
+
"whatwg-mimetype@4.0.0": {
1265
+
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="
1266
+
},
1267
+
"whatwg-url@14.2.0": {
1268
+
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
1269
+
"dependencies": [
1270
+
"tr46",
1271
+
"webidl-conversions"
1272
+
]
1273
+
},
1274
+
"why-is-node-running@2.3.0": {
1275
+
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
1276
+
"dependencies": [
1277
+
"siginfo",
1278
+
"stackback"
1279
+
],
1280
+
"bin": true
1281
+
},
1282
+
"ws@8.18.3": {
1283
+
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="
1284
+
},
1285
+
"xml-name-validator@5.0.0": {
1286
+
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="
1287
+
},
1288
+
"xmlchars@2.2.0": {
1289
+
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="
1290
+
},
1291
+
"zimmerframe@1.1.4": {
1292
+
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="
1293
+
}
1294
+
},
1295
+
"workspace": {
1296
+
"packageJson": {
1297
+
"dependencies": [
1298
+
"npm:@sveltejs/vite-plugin-svelte@5",
1299
+
"npm:@testing-library/jest-dom@^6.6.3",
1300
+
"npm:@testing-library/svelte@^5.2.6",
1301
+
"npm:@testing-library/user-event@^14.5.2",
1302
+
"npm:jsdom@^25.0.1",
1303
+
"npm:svelte@5",
1304
+
"npm:vite@6",
1305
+
"npm:vitest@^2.1.8"
1306
+
]
1307
+
}
1308
+
}
1309
+
}
+16
frontend/index.html
+16
frontend/index.html
···
1
+
<!DOCTYPE html>
2
+
<html lang="en">
3
+
<head>
4
+
<meta charset="UTF-8" />
5
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+
<title>BSPDS</title>
7
+
<style>
8
+
html { background: #fafafa; }
9
+
@media (prefers-color-scheme: dark) { html { background: #1a1a1a; } }
10
+
</style>
11
+
</head>
12
+
<body>
13
+
<div id="app"></div>
14
+
<script type="module" src="/src/main.ts"></script>
15
+
</body>
16
+
</html>
+24
frontend/package.json
+24
frontend/package.json
···
1
+
{
2
+
"name": "bspds-frontend",
3
+
"private": true,
4
+
"version": "0.0.0",
5
+
"type": "module",
6
+
"scripts": {
7
+
"dev": "vite",
8
+
"build": "vite build",
9
+
"preview": "vite preview",
10
+
"test": "vitest",
11
+
"test:run": "vitest run",
12
+
"test:coverage": "vitest run --coverage"
13
+
},
14
+
"devDependencies": {
15
+
"@sveltejs/vite-plugin-svelte": "^5.0.0",
16
+
"@testing-library/jest-dom": "^6.6.3",
17
+
"@testing-library/svelte": "^5.2.6",
18
+
"@testing-library/user-event": "^14.5.2",
19
+
"jsdom": "^25.0.1",
20
+
"svelte": "^5.0.0",
21
+
"vite": "^6.0.0",
22
+
"vitest": "^2.1.8"
23
+
}
24
+
}
+129
frontend/src/App.svelte
+129
frontend/src/App.svelte
···
1
+
<script lang="ts">
2
+
import { getCurrentPath } from './lib/router.svelte'
3
+
import { initAuth, getAuthState } from './lib/auth.svelte'
4
+
import Login from './routes/Login.svelte'
5
+
import Register from './routes/Register.svelte'
6
+
import Dashboard from './routes/Dashboard.svelte'
7
+
import AppPasswords from './routes/AppPasswords.svelte'
8
+
import InviteCodes from './routes/InviteCodes.svelte'
9
+
import Settings from './routes/Settings.svelte'
10
+
import Notifications from './routes/Notifications.svelte'
11
+
import RepoExplorer from './routes/RepoExplorer.svelte'
12
+
13
+
const auth = getAuthState()
14
+
15
+
$effect(() => {
16
+
initAuth()
17
+
})
18
+
19
+
function getComponent(path: string) {
20
+
switch (path) {
21
+
case '/login':
22
+
return Login
23
+
case '/register':
24
+
return Register
25
+
case '/dashboard':
26
+
return Dashboard
27
+
case '/app-passwords':
28
+
return AppPasswords
29
+
case '/invite-codes':
30
+
return InviteCodes
31
+
case '/settings':
32
+
return Settings
33
+
case '/notifications':
34
+
return Notifications
35
+
case '/repo':
36
+
return RepoExplorer
37
+
default:
38
+
return auth.session ? Dashboard : Login
39
+
}
40
+
}
41
+
42
+
let currentPath = $derived(getCurrentPath())
43
+
let CurrentComponent = $derived(getComponent(currentPath))
44
+
</script>
45
+
46
+
<main>
47
+
{#if auth.loading}
48
+
<div class="loading">
49
+
<p>Loading...</p>
50
+
</div>
51
+
{:else}
52
+
<CurrentComponent />
53
+
{/if}
54
+
</main>
55
+
56
+
<style>
57
+
:global(:root) {
58
+
--bg-primary: #fafafa;
59
+
--bg-secondary: #f9f9f9;
60
+
--bg-card: #ffffff;
61
+
--bg-input: #ffffff;
62
+
--bg-input-disabled: #f5f5f5;
63
+
--text-primary: #333333;
64
+
--text-secondary: #666666;
65
+
--text-muted: #999999;
66
+
--border-color: #dddddd;
67
+
--border-color-light: #cccccc;
68
+
--accent: #0066cc;
69
+
--accent-hover: #0052a3;
70
+
--success-bg: #dfd;
71
+
--success-border: #8c8;
72
+
--success-text: #060;
73
+
--error-bg: #fee;
74
+
--error-border: #fcc;
75
+
--error-text: #c00;
76
+
--warning-bg: #ffd;
77
+
--warning-text: #660;
78
+
}
79
+
80
+
@media (prefers-color-scheme: dark) {
81
+
:global(:root) {
82
+
--bg-primary: #1a1a1a;
83
+
--bg-secondary: #242424;
84
+
--bg-card: #2a2a2a;
85
+
--bg-input: #333333;
86
+
--bg-input-disabled: #2a2a2a;
87
+
--text-primary: #e0e0e0;
88
+
--text-secondary: #a0a0a0;
89
+
--text-muted: #707070;
90
+
--border-color: #404040;
91
+
--border-color-light: #505050;
92
+
--accent: #4da6ff;
93
+
--accent-hover: #7abbff;
94
+
--success-bg: #1a3d1a;
95
+
--success-border: #2d5a2d;
96
+
--success-text: #7bc67b;
97
+
--error-bg: #3d1a1a;
98
+
--error-border: #5a2d2d;
99
+
--error-text: #ff7b7b;
100
+
--warning-bg: #3d3d1a;
101
+
--warning-text: #c6c67b;
102
+
}
103
+
}
104
+
105
+
:global(body) {
106
+
margin: 0;
107
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
108
+
line-height: 1.5;
109
+
color: var(--text-primary);
110
+
background: var(--bg-primary);
111
+
}
112
+
113
+
:global(*) {
114
+
box-sizing: border-box;
115
+
}
116
+
117
+
main {
118
+
min-height: 100vh;
119
+
background: var(--bg-primary);
120
+
}
121
+
122
+
.loading {
123
+
display: flex;
124
+
align-items: center;
125
+
justify-content: center;
126
+
min-height: 100vh;
127
+
color: var(--text-secondary);
128
+
}
129
+
</style>
+341
frontend/src/lib/api.ts
+341
frontend/src/lib/api.ts
···
1
+
const API_BASE = '/xrpc'
2
+
3
+
export class ApiError extends Error {
4
+
public did?: string
5
+
6
+
constructor(public status: number, public error: string, message: string, did?: string) {
7
+
super(message)
8
+
this.name = 'ApiError'
9
+
this.did = did
10
+
}
11
+
}
12
+
13
+
async function xrpc<T>(method: string, options?: {
14
+
method?: 'GET' | 'POST'
15
+
params?: Record<string, string>
16
+
body?: unknown
17
+
token?: string
18
+
}): Promise<T> {
19
+
const { method: httpMethod = 'GET', params, body, token } = options ?? {}
20
+
21
+
let url = `${API_BASE}/${method}`
22
+
if (params) {
23
+
const searchParams = new URLSearchParams(params)
24
+
url += `?${searchParams}`
25
+
}
26
+
27
+
const headers: Record<string, string> = {}
28
+
if (token) {
29
+
headers['Authorization'] = `Bearer ${token}`
30
+
}
31
+
if (body) {
32
+
headers['Content-Type'] = 'application/json'
33
+
}
34
+
35
+
const res = await fetch(url, {
36
+
method: httpMethod,
37
+
headers,
38
+
body: body ? JSON.stringify(body) : undefined,
39
+
})
40
+
41
+
if (!res.ok) {
42
+
const err = await res.json().catch(() => ({ error: 'Unknown', message: res.statusText }))
43
+
throw new ApiError(res.status, err.error, err.message, err.did)
44
+
}
45
+
46
+
return res.json()
47
+
}
48
+
49
+
export interface Session {
50
+
did: string
51
+
handle: string
52
+
email?: string
53
+
emailConfirmed?: boolean
54
+
accessJwt: string
55
+
refreshJwt: string
56
+
}
57
+
58
+
export interface AppPassword {
59
+
name: string
60
+
createdAt: string
61
+
}
62
+
63
+
export interface InviteCode {
64
+
code: string
65
+
available: number
66
+
disabled: boolean
67
+
forAccount: string
68
+
createdBy: string
69
+
createdAt: string
70
+
uses: { usedBy: string; usedAt: string }[]
71
+
}
72
+
73
+
export type VerificationChannel = 'email' | 'discord' | 'telegram' | 'signal'
74
+
75
+
export interface CreateAccountParams {
76
+
handle: string
77
+
email: string
78
+
password: string
79
+
inviteCode?: string
80
+
verificationChannel?: VerificationChannel
81
+
discordId?: string
82
+
telegramUsername?: string
83
+
signalNumber?: string
84
+
}
85
+
86
+
export interface CreateAccountResult {
87
+
handle: string
88
+
did: string
89
+
verificationRequired: boolean
90
+
verificationChannel: string
91
+
}
92
+
93
+
export interface ConfirmSignupResult {
94
+
accessJwt: string
95
+
refreshJwt: string
96
+
handle: string
97
+
did: string
98
+
}
99
+
100
+
export const api = {
101
+
async createAccount(params: CreateAccountParams): Promise<CreateAccountResult> {
102
+
return xrpc('com.atproto.server.createAccount', {
103
+
method: 'POST',
104
+
body: {
105
+
handle: params.handle,
106
+
email: params.email,
107
+
password: params.password,
108
+
inviteCode: params.inviteCode,
109
+
verificationChannel: params.verificationChannel,
110
+
discordId: params.discordId,
111
+
telegramUsername: params.telegramUsername,
112
+
signalNumber: params.signalNumber,
113
+
},
114
+
})
115
+
},
116
+
117
+
async confirmSignup(did: string, verificationCode: string): Promise<ConfirmSignupResult> {
118
+
return xrpc('com.atproto.server.confirmSignup', {
119
+
method: 'POST',
120
+
body: { did, verificationCode },
121
+
})
122
+
},
123
+
124
+
async resendVerification(did: string): Promise<{ success: boolean }> {
125
+
return xrpc('com.atproto.server.resendVerification', {
126
+
method: 'POST',
127
+
body: { did },
128
+
})
129
+
},
130
+
131
+
async createSession(identifier: string, password: string): Promise<Session> {
132
+
return xrpc('com.atproto.server.createSession', {
133
+
method: 'POST',
134
+
body: { identifier, password },
135
+
})
136
+
},
137
+
138
+
async getSession(token: string): Promise<Session> {
139
+
return xrpc('com.atproto.server.getSession', { token })
140
+
},
141
+
142
+
async refreshSession(refreshJwt: string): Promise<Session> {
143
+
return xrpc('com.atproto.server.refreshSession', {
144
+
method: 'POST',
145
+
token: refreshJwt,
146
+
})
147
+
},
148
+
149
+
async deleteSession(token: string): Promise<void> {
150
+
await xrpc('com.atproto.server.deleteSession', {
151
+
method: 'POST',
152
+
token,
153
+
})
154
+
},
155
+
156
+
async listAppPasswords(token: string): Promise<{ passwords: AppPassword[] }> {
157
+
return xrpc('com.atproto.server.listAppPasswords', { token })
158
+
},
159
+
160
+
async createAppPassword(token: string, name: string): Promise<{ name: string; password: string; createdAt: string }> {
161
+
return xrpc('com.atproto.server.createAppPassword', {
162
+
method: 'POST',
163
+
token,
164
+
body: { name },
165
+
})
166
+
},
167
+
168
+
async revokeAppPassword(token: string, name: string): Promise<void> {
169
+
await xrpc('com.atproto.server.revokeAppPassword', {
170
+
method: 'POST',
171
+
token,
172
+
body: { name },
173
+
})
174
+
},
175
+
176
+
async getAccountInviteCodes(token: string): Promise<{ codes: InviteCode[] }> {
177
+
return xrpc('com.atproto.server.getAccountInviteCodes', { token })
178
+
},
179
+
180
+
async createInviteCode(token: string, useCount: number = 1): Promise<{ code: string }> {
181
+
return xrpc('com.atproto.server.createInviteCode', {
182
+
method: 'POST',
183
+
token,
184
+
body: { useCount },
185
+
})
186
+
},
187
+
188
+
async requestPasswordReset(email: string): Promise<void> {
189
+
await xrpc('com.atproto.server.requestPasswordReset', {
190
+
method: 'POST',
191
+
body: { email },
192
+
})
193
+
},
194
+
195
+
async resetPassword(token: string, password: string): Promise<void> {
196
+
await xrpc('com.atproto.server.resetPassword', {
197
+
method: 'POST',
198
+
body: { token, password },
199
+
})
200
+
},
201
+
202
+
async requestEmailUpdate(token: string): Promise<{ tokenRequired: boolean }> {
203
+
return xrpc('com.atproto.server.requestEmailUpdate', {
204
+
method: 'POST',
205
+
token,
206
+
})
207
+
},
208
+
209
+
async updateEmail(token: string, email: string, emailToken?: string): Promise<void> {
210
+
await xrpc('com.atproto.server.updateEmail', {
211
+
method: 'POST',
212
+
token,
213
+
body: { email, token: emailToken },
214
+
})
215
+
},
216
+
217
+
async updateHandle(token: string, handle: string): Promise<void> {
218
+
await xrpc('com.atproto.identity.updateHandle', {
219
+
method: 'POST',
220
+
token,
221
+
body: { handle },
222
+
})
223
+
},
224
+
225
+
async requestAccountDelete(token: string): Promise<void> {
226
+
await xrpc('com.atproto.server.requestAccountDelete', {
227
+
method: 'POST',
228
+
token,
229
+
})
230
+
},
231
+
232
+
async deleteAccount(did: string, password: string, deleteToken: string): Promise<void> {
233
+
await xrpc('com.atproto.server.deleteAccount', {
234
+
method: 'POST',
235
+
body: { did, password, token: deleteToken },
236
+
})
237
+
},
238
+
239
+
async describeServer(): Promise<{
240
+
availableUserDomains: string[]
241
+
inviteCodeRequired: boolean
242
+
links?: { privacyPolicy?: string; termsOfService?: string }
243
+
}> {
244
+
return xrpc('com.atproto.server.describeServer')
245
+
},
246
+
247
+
async getNotificationPrefs(token: string): Promise<{
248
+
preferredChannel: string
249
+
email: string
250
+
discordId: string | null
251
+
discordVerified: boolean
252
+
telegramUsername: string | null
253
+
telegramVerified: boolean
254
+
signalNumber: string | null
255
+
signalVerified: boolean
256
+
}> {
257
+
return xrpc('com.bspds.account.getNotificationPrefs', { token })
258
+
},
259
+
260
+
async updateNotificationPrefs(token: string, prefs: {
261
+
preferredChannel?: string
262
+
discordId?: string
263
+
telegramUsername?: string
264
+
signalNumber?: string
265
+
}): Promise<{ success: boolean }> {
266
+
return xrpc('com.bspds.account.updateNotificationPrefs', {
267
+
method: 'POST',
268
+
token,
269
+
body: prefs,
270
+
})
271
+
},
272
+
273
+
async describeRepo(token: string, repo: string): Promise<{
274
+
handle: string
275
+
did: string
276
+
didDoc: unknown
277
+
collections: string[]
278
+
handleIsCorrect: boolean
279
+
}> {
280
+
return xrpc('com.atproto.repo.describeRepo', {
281
+
token,
282
+
params: { repo },
283
+
})
284
+
},
285
+
286
+
async listRecords(token: string, repo: string, collection: string, options?: {
287
+
limit?: number
288
+
cursor?: string
289
+
reverse?: boolean
290
+
}): Promise<{
291
+
records: Array<{ uri: string; cid: string; value: unknown }>
292
+
cursor?: string
293
+
}> {
294
+
const params: Record<string, string> = { repo, collection }
295
+
if (options?.limit) params.limit = String(options.limit)
296
+
if (options?.cursor) params.cursor = options.cursor
297
+
if (options?.reverse) params.reverse = 'true'
298
+
return xrpc('com.atproto.repo.listRecords', { token, params })
299
+
},
300
+
301
+
async getRecord(token: string, repo: string, collection: string, rkey: string): Promise<{
302
+
uri: string
303
+
cid: string
304
+
value: unknown
305
+
}> {
306
+
return xrpc('com.atproto.repo.getRecord', {
307
+
token,
308
+
params: { repo, collection, rkey },
309
+
})
310
+
},
311
+
312
+
async createRecord(token: string, repo: string, collection: string, record: unknown, rkey?: string): Promise<{
313
+
uri: string
314
+
cid: string
315
+
}> {
316
+
return xrpc('com.atproto.repo.createRecord', {
317
+
method: 'POST',
318
+
token,
319
+
body: { repo, collection, record, rkey },
320
+
})
321
+
},
322
+
323
+
async putRecord(token: string, repo: string, collection: string, rkey: string, record: unknown): Promise<{
324
+
uri: string
325
+
cid: string
326
+
}> {
327
+
return xrpc('com.atproto.repo.putRecord', {
328
+
method: 'POST',
329
+
token,
330
+
body: { repo, collection, rkey, record },
331
+
})
332
+
},
333
+
334
+
async deleteRecord(token: string, repo: string, collection: string, rkey: string): Promise<void> {
335
+
await xrpc('com.atproto.repo.deleteRecord', {
336
+
method: 'POST',
337
+
token,
338
+
body: { repo, collection, rkey },
339
+
})
340
+
},
341
+
}
+172
frontend/src/lib/auth.svelte.ts
+172
frontend/src/lib/auth.svelte.ts
···
1
+
import { api, type Session, type CreateAccountParams, type CreateAccountResult, ApiError } from './api'
2
+
3
+
const STORAGE_KEY = 'bspds_session'
4
+
5
+
interface AuthState {
6
+
session: Session | null
7
+
loading: boolean
8
+
error: string | null
9
+
}
10
+
11
+
let state = $state<AuthState>({
12
+
session: null,
13
+
loading: true,
14
+
error: null,
15
+
})
16
+
17
+
function saveSession(session: Session | null) {
18
+
if (session) {
19
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(session))
20
+
} else {
21
+
localStorage.removeItem(STORAGE_KEY)
22
+
}
23
+
}
24
+
25
+
function loadSession(): Session | null {
26
+
const stored = localStorage.getItem(STORAGE_KEY)
27
+
if (stored) {
28
+
try {
29
+
return JSON.parse(stored)
30
+
} catch {
31
+
return null
32
+
}
33
+
}
34
+
return null
35
+
}
36
+
37
+
export async function initAuth() {
38
+
state.loading = true
39
+
state.error = null
40
+
41
+
const stored = loadSession()
42
+
if (stored) {
43
+
try {
44
+
const session = await api.getSession(stored.accessJwt)
45
+
state.session = { ...session, accessJwt: stored.accessJwt, refreshJwt: stored.refreshJwt }
46
+
} catch (e) {
47
+
if (e instanceof ApiError && e.status === 401) {
48
+
try {
49
+
const refreshed = await api.refreshSession(stored.refreshJwt)
50
+
state.session = refreshed
51
+
saveSession(refreshed)
52
+
} catch {
53
+
saveSession(null)
54
+
state.session = null
55
+
}
56
+
} else {
57
+
saveSession(null)
58
+
state.session = null
59
+
}
60
+
}
61
+
}
62
+
63
+
state.loading = false
64
+
}
65
+
66
+
export async function login(identifier: string, password: string): Promise<void> {
67
+
state.loading = true
68
+
state.error = null
69
+
70
+
try {
71
+
const session = await api.createSession(identifier, password)
72
+
state.session = session
73
+
saveSession(session)
74
+
} catch (e) {
75
+
if (e instanceof ApiError) {
76
+
state.error = e.message
77
+
} else {
78
+
state.error = 'Login failed'
79
+
}
80
+
throw e
81
+
} finally {
82
+
state.loading = false
83
+
}
84
+
}
85
+
86
+
export async function register(params: CreateAccountParams): Promise<CreateAccountResult> {
87
+
try {
88
+
const result = await api.createAccount(params)
89
+
return result
90
+
} catch (e) {
91
+
if (e instanceof ApiError) {
92
+
state.error = e.message
93
+
} else {
94
+
state.error = 'Registration failed'
95
+
}
96
+
throw e
97
+
}
98
+
}
99
+
100
+
export async function confirmSignup(did: string, verificationCode: string): Promise<void> {
101
+
state.loading = true
102
+
state.error = null
103
+
104
+
try {
105
+
const result = await api.confirmSignup(did, verificationCode)
106
+
const session: Session = {
107
+
did: result.did,
108
+
handle: result.handle,
109
+
accessJwt: result.accessJwt,
110
+
refreshJwt: result.refreshJwt,
111
+
}
112
+
state.session = session
113
+
saveSession(session)
114
+
} catch (e) {
115
+
if (e instanceof ApiError) {
116
+
state.error = e.message
117
+
} else {
118
+
state.error = 'Verification failed'
119
+
}
120
+
throw e
121
+
} finally {
122
+
state.loading = false
123
+
}
124
+
}
125
+
126
+
export async function resendVerification(did: string): Promise<void> {
127
+
try {
128
+
await api.resendVerification(did)
129
+
} catch (e) {
130
+
if (e instanceof ApiError) {
131
+
throw e
132
+
}
133
+
throw new Error('Failed to resend verification code')
134
+
}
135
+
}
136
+
137
+
export async function logout(): Promise<void> {
138
+
if (state.session) {
139
+
try {
140
+
await api.deleteSession(state.session.accessJwt)
141
+
} catch {
142
+
// Ignore errors on logout
143
+
}
144
+
}
145
+
state.session = null
146
+
saveSession(null)
147
+
}
148
+
149
+
export function getAuthState() {
150
+
return state
151
+
}
152
+
153
+
export function getToken(): string | null {
154
+
return state.session?.accessJwt ?? null
155
+
}
156
+
157
+
export function isAuthenticated(): boolean {
158
+
return state.session !== null
159
+
}
160
+
161
+
export function _testSetState(newState: { session: Session | null; loading: boolean; error: string | null }) {
162
+
state.session = newState.session
163
+
state.loading = newState.loading
164
+
state.error = newState.error
165
+
}
166
+
167
+
export function _testReset() {
168
+
state.session = null
169
+
state.loading = true
170
+
state.error = null
171
+
localStorage.removeItem(STORAGE_KEY)
172
+
}
+13
frontend/src/lib/router.svelte.ts
+13
frontend/src/lib/router.svelte.ts
···
1
+
let currentPath = $state(window.location.hash.slice(1) || '/')
2
+
3
+
window.addEventListener('hashchange', () => {
4
+
currentPath = window.location.hash.slice(1) || '/'
5
+
})
6
+
7
+
export function navigate(path: string) {
8
+
window.location.hash = path
9
+
}
10
+
11
+
export function getCurrentPath() {
12
+
return currentPath
13
+
}
+8
frontend/src/main.ts
+8
frontend/src/main.ts
+333
frontend/src/routes/AppPasswords.svelte
+333
frontend/src/routes/AppPasswords.svelte
···
1
+
<script lang="ts">
2
+
import { getAuthState } from '../lib/auth.svelte'
3
+
import { navigate } from '../lib/router.svelte'
4
+
import { api, type AppPassword, ApiError } from '../lib/api'
5
+
6
+
const auth = getAuthState()
7
+
8
+
let passwords = $state<AppPassword[]>([])
9
+
let loading = $state(true)
10
+
let error = $state<string | null>(null)
11
+
12
+
let newPasswordName = $state('')
13
+
let creating = $state(false)
14
+
let createdPassword = $state<{ name: string; password: string } | null>(null)
15
+
16
+
let revoking = $state<string | null>(null)
17
+
18
+
$effect(() => {
19
+
if (!auth.loading && !auth.session) {
20
+
navigate('/login')
21
+
}
22
+
})
23
+
24
+
$effect(() => {
25
+
if (auth.session) {
26
+
loadPasswords()
27
+
}
28
+
})
29
+
30
+
async function loadPasswords() {
31
+
if (!auth.session) return
32
+
loading = true
33
+
error = null
34
+
35
+
try {
36
+
const result = await api.listAppPasswords(auth.session.accessJwt)
37
+
passwords = result.passwords
38
+
} catch (e) {
39
+
error = e instanceof ApiError ? e.message : 'Failed to load app passwords'
40
+
} finally {
41
+
loading = false
42
+
}
43
+
}
44
+
45
+
async function handleCreate(e: Event) {
46
+
e.preventDefault()
47
+
if (!auth.session || !newPasswordName.trim()) return
48
+
49
+
creating = true
50
+
error = null
51
+
52
+
try {
53
+
const result = await api.createAppPassword(auth.session.accessJwt, newPasswordName.trim())
54
+
createdPassword = { name: result.name, password: result.password }
55
+
newPasswordName = ''
56
+
await loadPasswords()
57
+
} catch (e) {
58
+
error = e instanceof ApiError ? e.message : 'Failed to create app password'
59
+
} finally {
60
+
creating = false
61
+
}
62
+
}
63
+
64
+
async function handleRevoke(name: string) {
65
+
if (!auth.session) return
66
+
if (!confirm(`Revoke app password "${name}"? Apps using this password will no longer be able to access your account.`)) {
67
+
return
68
+
}
69
+
70
+
revoking = name
71
+
error = null
72
+
73
+
try {
74
+
await api.revokeAppPassword(auth.session.accessJwt, name)
75
+
await loadPasswords()
76
+
} catch (e) {
77
+
error = e instanceof ApiError ? e.message : 'Failed to revoke app password'
78
+
} finally {
79
+
revoking = null
80
+
}
81
+
}
82
+
83
+
function dismissCreated() {
84
+
createdPassword = null
85
+
}
86
+
</script>
87
+
88
+
<div class="page">
89
+
<header>
90
+
<a href="#/dashboard" class="back">← Dashboard</a>
91
+
<h1>App Passwords</h1>
92
+
</header>
93
+
94
+
<p class="description">
95
+
App passwords let you sign in to third-party apps without giving them your main password.
96
+
Each app password can be revoked individually.
97
+
</p>
98
+
99
+
{#if error}
100
+
<div class="error">{error}</div>
101
+
{/if}
102
+
103
+
{#if createdPassword}
104
+
<div class="created-password">
105
+
<h3>App Password Created</h3>
106
+
<p>Copy this password now. You won't be able to see it again.</p>
107
+
<div class="password-display">
108
+
<code>{createdPassword.password}</code>
109
+
</div>
110
+
<p class="password-name">Name: {createdPassword.name}</p>
111
+
<button onclick={dismissCreated}>Done</button>
112
+
</div>
113
+
{/if}
114
+
115
+
<section class="create-section">
116
+
<h2>Create New App Password</h2>
117
+
<form onsubmit={handleCreate}>
118
+
<input
119
+
type="text"
120
+
bind:value={newPasswordName}
121
+
placeholder="App name (e.g., Graysky, Skeets)"
122
+
disabled={creating}
123
+
required
124
+
/>
125
+
<button type="submit" disabled={creating || !newPasswordName.trim()}>
126
+
{creating ? 'Creating...' : 'Create'}
127
+
</button>
128
+
</form>
129
+
</section>
130
+
131
+
<section class="list-section">
132
+
<h2>Your App Passwords</h2>
133
+
134
+
{#if loading}
135
+
<p class="empty">Loading...</p>
136
+
{:else if passwords.length === 0}
137
+
<p class="empty">No app passwords yet</p>
138
+
{:else}
139
+
<ul class="password-list">
140
+
{#each passwords as pw}
141
+
<li>
142
+
<div class="password-info">
143
+
<span class="name">{pw.name}</span>
144
+
<span class="date">Created {new Date(pw.createdAt).toLocaleDateString()}</span>
145
+
</div>
146
+
<button
147
+
class="revoke"
148
+
onclick={() => handleRevoke(pw.name)}
149
+
disabled={revoking === pw.name}
150
+
>
151
+
{revoking === pw.name ? 'Revoking...' : 'Revoke'}
152
+
</button>
153
+
</li>
154
+
{/each}
155
+
</ul>
156
+
{/if}
157
+
</section>
158
+
</div>
159
+
160
+
<style>
161
+
.page {
162
+
max-width: 600px;
163
+
margin: 0 auto;
164
+
padding: 2rem;
165
+
}
166
+
167
+
header {
168
+
margin-bottom: 1rem;
169
+
}
170
+
171
+
.back {
172
+
color: var(--text-secondary);
173
+
text-decoration: none;
174
+
font-size: 0.875rem;
175
+
}
176
+
177
+
.back:hover {
178
+
color: var(--accent);
179
+
}
180
+
181
+
h1 {
182
+
margin: 0.5rem 0 0 0;
183
+
}
184
+
185
+
.description {
186
+
color: var(--text-secondary);
187
+
margin-bottom: 2rem;
188
+
}
189
+
190
+
.error {
191
+
padding: 0.75rem;
192
+
background: var(--error-bg);
193
+
border: 1px solid var(--error-border);
194
+
border-radius: 4px;
195
+
color: var(--error-text);
196
+
margin-bottom: 1rem;
197
+
}
198
+
199
+
.created-password {
200
+
padding: 1.5rem;
201
+
background: var(--success-bg);
202
+
border: 1px solid var(--success-border);
203
+
border-radius: 8px;
204
+
margin-bottom: 2rem;
205
+
}
206
+
207
+
.created-password h3 {
208
+
margin: 0 0 0.5rem 0;
209
+
color: var(--success-text);
210
+
}
211
+
212
+
.password-display {
213
+
background: var(--bg-card);
214
+
padding: 1rem;
215
+
border-radius: 4px;
216
+
margin: 1rem 0;
217
+
}
218
+
219
+
.password-display code {
220
+
font-size: 1.25rem;
221
+
font-family: monospace;
222
+
word-break: break-all;
223
+
}
224
+
225
+
.password-name {
226
+
color: var(--text-secondary);
227
+
font-size: 0.875rem;
228
+
margin-bottom: 1rem;
229
+
}
230
+
231
+
section {
232
+
margin-bottom: 2rem;
233
+
}
234
+
235
+
section h2 {
236
+
font-size: 1.125rem;
237
+
margin: 0 0 1rem 0;
238
+
}
239
+
240
+
.create-section form {
241
+
display: flex;
242
+
gap: 0.5rem;
243
+
}
244
+
245
+
.create-section input {
246
+
flex: 1;
247
+
padding: 0.75rem;
248
+
border: 1px solid var(--border-color-light);
249
+
border-radius: 4px;
250
+
font-size: 1rem;
251
+
background: var(--bg-input);
252
+
color: var(--text-primary);
253
+
}
254
+
255
+
.create-section input:focus {
256
+
outline: none;
257
+
border-color: var(--accent);
258
+
}
259
+
260
+
.create-section button {
261
+
padding: 0.75rem 1.5rem;
262
+
background: var(--accent);
263
+
color: white;
264
+
border: none;
265
+
border-radius: 4px;
266
+
cursor: pointer;
267
+
}
268
+
269
+
.create-section button:hover:not(:disabled) {
270
+
background: var(--accent-hover);
271
+
}
272
+
273
+
.create-section button:disabled {
274
+
opacity: 0.6;
275
+
cursor: not-allowed;
276
+
}
277
+
278
+
.password-list {
279
+
list-style: none;
280
+
padding: 0;
281
+
margin: 0;
282
+
}
283
+
284
+
.password-list li {
285
+
display: flex;
286
+
justify-content: space-between;
287
+
align-items: center;
288
+
padding: 1rem;
289
+
border: 1px solid var(--border-color);
290
+
border-radius: 4px;
291
+
margin-bottom: 0.5rem;
292
+
background: var(--bg-card);
293
+
}
294
+
295
+
.password-info {
296
+
display: flex;
297
+
flex-direction: column;
298
+
gap: 0.25rem;
299
+
}
300
+
301
+
.name {
302
+
font-weight: 500;
303
+
}
304
+
305
+
.date {
306
+
font-size: 0.875rem;
307
+
color: var(--text-secondary);
308
+
}
309
+
310
+
.revoke {
311
+
padding: 0.5rem 1rem;
312
+
background: transparent;
313
+
border: 1px solid var(--error-text);
314
+
border-radius: 4px;
315
+
color: var(--error-text);
316
+
cursor: pointer;
317
+
}
318
+
319
+
.revoke:hover:not(:disabled) {
320
+
background: var(--error-bg);
321
+
}
322
+
323
+
.revoke:disabled {
324
+
opacity: 0.6;
325
+
cursor: not-allowed;
326
+
}
327
+
328
+
.empty {
329
+
color: var(--text-secondary);
330
+
text-align: center;
331
+
padding: 2rem;
332
+
}
333
+
</style>
+201
frontend/src/routes/Dashboard.svelte
+201
frontend/src/routes/Dashboard.svelte
···
1
+
<script lang="ts">
2
+
import { getAuthState, logout } from '../lib/auth.svelte'
3
+
import { navigate } from '../lib/router.svelte'
4
+
5
+
const auth = getAuthState()
6
+
7
+
$effect(() => {
8
+
if (!auth.loading && !auth.session) {
9
+
navigate('/login')
10
+
}
11
+
})
12
+
13
+
async function handleLogout() {
14
+
await logout()
15
+
navigate('/login')
16
+
}
17
+
</script>
18
+
19
+
{#if auth.session}
20
+
<div class="dashboard">
21
+
<header>
22
+
<h1>Dashboard</h1>
23
+
<button class="logout" onclick={handleLogout}>Sign Out</button>
24
+
</header>
25
+
26
+
<section class="account-overview">
27
+
<h2>Account Overview</h2>
28
+
<dl>
29
+
<dt>Handle</dt>
30
+
<dd>@{auth.session.handle}</dd>
31
+
32
+
<dt>DID</dt>
33
+
<dd class="mono">{auth.session.did}</dd>
34
+
35
+
{#if auth.session.email}
36
+
<dt>Email</dt>
37
+
<dd>
38
+
{auth.session.email}
39
+
{#if auth.session.emailConfirmed}
40
+
<span class="badge success">Verified</span>
41
+
{:else}
42
+
<span class="badge warning">Unverified</span>
43
+
{/if}
44
+
</dd>
45
+
{/if}
46
+
</dl>
47
+
</section>
48
+
49
+
<nav class="nav-grid">
50
+
<a href="#/app-passwords" class="nav-card">
51
+
<h3>App Passwords</h3>
52
+
<p>Manage passwords for third-party apps</p>
53
+
</a>
54
+
55
+
<a href="#/invite-codes" class="nav-card">
56
+
<h3>Invite Codes</h3>
57
+
<p>View and create invite codes</p>
58
+
</a>
59
+
60
+
<a href="#/settings" class="nav-card">
61
+
<h3>Account Settings</h3>
62
+
<p>Email, password, handle, and more</p>
63
+
</a>
64
+
65
+
<a href="#/notifications" class="nav-card">
66
+
<h3>Notification Preferences</h3>
67
+
<p>Discord, Telegram, Signal channels</p>
68
+
</a>
69
+
70
+
<a href="#/repo" class="nav-card">
71
+
<h3>Repository Explorer</h3>
72
+
<p>Browse and manage raw AT Protocol records</p>
73
+
</a>
74
+
</nav>
75
+
</div>
76
+
{:else if auth.loading}
77
+
<div class="loading">Loading...</div>
78
+
{/if}
79
+
80
+
<style>
81
+
.dashboard {
82
+
max-width: 800px;
83
+
margin: 0 auto;
84
+
padding: 2rem;
85
+
}
86
+
87
+
header {
88
+
display: flex;
89
+
justify-content: space-between;
90
+
align-items: center;
91
+
margin-bottom: 2rem;
92
+
}
93
+
94
+
header h1 {
95
+
margin: 0;
96
+
}
97
+
98
+
.logout {
99
+
padding: 0.5rem 1rem;
100
+
background: transparent;
101
+
border: 1px solid var(--border-color-light);
102
+
border-radius: 4px;
103
+
cursor: pointer;
104
+
color: var(--text-primary);
105
+
}
106
+
107
+
.logout:hover {
108
+
background: var(--bg-secondary);
109
+
}
110
+
111
+
section {
112
+
background: var(--bg-secondary);
113
+
padding: 1.5rem;
114
+
border-radius: 8px;
115
+
margin-bottom: 2rem;
116
+
}
117
+
118
+
section h2 {
119
+
margin: 0 0 1rem 0;
120
+
font-size: 1.25rem;
121
+
}
122
+
123
+
dl {
124
+
display: grid;
125
+
grid-template-columns: auto 1fr;
126
+
gap: 0.5rem 1rem;
127
+
margin: 0;
128
+
}
129
+
130
+
dt {
131
+
font-weight: 500;
132
+
color: var(--text-secondary);
133
+
}
134
+
135
+
dd {
136
+
margin: 0;
137
+
}
138
+
139
+
.mono {
140
+
font-family: monospace;
141
+
font-size: 0.875rem;
142
+
word-break: break-all;
143
+
}
144
+
145
+
.badge {
146
+
display: inline-block;
147
+
padding: 0.125rem 0.5rem;
148
+
border-radius: 4px;
149
+
font-size: 0.75rem;
150
+
margin-left: 0.5rem;
151
+
}
152
+
153
+
.badge.success {
154
+
background: var(--success-bg);
155
+
color: var(--success-text);
156
+
}
157
+
158
+
.badge.warning {
159
+
background: var(--warning-bg);
160
+
color: var(--warning-text);
161
+
}
162
+
163
+
.nav-grid {
164
+
display: grid;
165
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
166
+
gap: 1rem;
167
+
}
168
+
169
+
.nav-card {
170
+
display: block;
171
+
padding: 1.5rem;
172
+
background: var(--bg-card);
173
+
border: 1px solid var(--border-color);
174
+
border-radius: 8px;
175
+
text-decoration: none;
176
+
color: inherit;
177
+
transition: border-color 0.15s, box-shadow 0.15s;
178
+
}
179
+
180
+
.nav-card:hover {
181
+
border-color: var(--accent);
182
+
box-shadow: 0 2px 8px rgba(77, 166, 255, 0.15);
183
+
}
184
+
185
+
.nav-card h3 {
186
+
margin: 0 0 0.5rem 0;
187
+
color: var(--accent);
188
+
}
189
+
190
+
.nav-card p {
191
+
margin: 0;
192
+
color: var(--text-secondary);
193
+
font-size: 0.875rem;
194
+
}
195
+
196
+
.loading {
197
+
text-align: center;
198
+
padding: 4rem;
199
+
color: var(--text-secondary);
200
+
}
201
+
</style>
+326
frontend/src/routes/InviteCodes.svelte
+326
frontend/src/routes/InviteCodes.svelte
···
1
+
<script lang="ts">
2
+
import { getAuthState } from '../lib/auth.svelte'
3
+
import { navigate } from '../lib/router.svelte'
4
+
import { api, type InviteCode, ApiError } from '../lib/api'
5
+
6
+
const auth = getAuthState()
7
+
8
+
let codes = $state<InviteCode[]>([])
9
+
let loading = $state(true)
10
+
let error = $state<string | null>(null)
11
+
12
+
let creating = $state(false)
13
+
let createdCode = $state<string | null>(null)
14
+
15
+
$effect(() => {
16
+
if (!auth.loading && !auth.session) {
17
+
navigate('/login')
18
+
}
19
+
})
20
+
21
+
$effect(() => {
22
+
if (auth.session) {
23
+
loadCodes()
24
+
}
25
+
})
26
+
27
+
async function loadCodes() {
28
+
if (!auth.session) return
29
+
loading = true
30
+
error = null
31
+
32
+
try {
33
+
const result = await api.getAccountInviteCodes(auth.session.accessJwt)
34
+
codes = result.codes
35
+
} catch (e) {
36
+
error = e instanceof ApiError ? e.message : 'Failed to load invite codes'
37
+
} finally {
38
+
loading = false
39
+
}
40
+
}
41
+
42
+
async function handleCreate() {
43
+
if (!auth.session) return
44
+
45
+
creating = true
46
+
error = null
47
+
48
+
try {
49
+
const result = await api.createInviteCode(auth.session.accessJwt, 1)
50
+
createdCode = result.code
51
+
await loadCodes()
52
+
} catch (e) {
53
+
error = e instanceof ApiError ? e.message : 'Failed to create invite code'
54
+
} finally {
55
+
creating = false
56
+
}
57
+
}
58
+
59
+
function dismissCreated() {
60
+
createdCode = null
61
+
}
62
+
63
+
function copyCode(code: string) {
64
+
navigator.clipboard.writeText(code)
65
+
}
66
+
</script>
67
+
68
+
<div class="page">
69
+
<header>
70
+
<a href="#/dashboard" class="back">← Dashboard</a>
71
+
<h1>Invite Codes</h1>
72
+
</header>
73
+
74
+
<p class="description">
75
+
Invite codes let you invite friends to join. Each code can be used once.
76
+
</p>
77
+
78
+
{#if error}
79
+
<div class="error">{error}</div>
80
+
{/if}
81
+
82
+
{#if createdCode}
83
+
<div class="created-code">
84
+
<h3>Invite Code Created</h3>
85
+
<div class="code-display">
86
+
<code>{createdCode}</code>
87
+
<button class="copy" onclick={() => copyCode(createdCode!)}>Copy</button>
88
+
</div>
89
+
<button onclick={dismissCreated}>Done</button>
90
+
</div>
91
+
{/if}
92
+
93
+
<section class="create-section">
94
+
<button onclick={handleCreate} disabled={creating}>
95
+
{creating ? 'Creating...' : 'Create New Invite Code'}
96
+
</button>
97
+
</section>
98
+
99
+
<section class="list-section">
100
+
<h2>Your Invite Codes</h2>
101
+
102
+
{#if loading}
103
+
<p class="empty">Loading...</p>
104
+
{:else if codes.length === 0}
105
+
<p class="empty">No invite codes yet</p>
106
+
{:else}
107
+
<ul class="code-list">
108
+
{#each codes as code}
109
+
<li class:disabled={code.disabled} class:used={code.uses.length > 0 && code.available === 0}>
110
+
<div class="code-main">
111
+
<code>{code.code}</code>
112
+
<button class="copy-small" onclick={() => copyCode(code.code)} title="Copy">
113
+
Copy
114
+
</button>
115
+
</div>
116
+
<div class="code-meta">
117
+
<span class="date">Created {new Date(code.createdAt).toLocaleDateString()}</span>
118
+
{#if code.disabled}
119
+
<span class="status disabled">Disabled</span>
120
+
{:else if code.uses.length > 0}
121
+
<span class="status used">Used by @{code.uses[0].usedBy.split(':').pop()}</span>
122
+
{:else}
123
+
<span class="status available">Available</span>
124
+
{/if}
125
+
</div>
126
+
</li>
127
+
{/each}
128
+
</ul>
129
+
{/if}
130
+
</section>
131
+
</div>
132
+
133
+
<style>
134
+
.page {
135
+
max-width: 600px;
136
+
margin: 0 auto;
137
+
padding: 2rem;
138
+
}
139
+
140
+
header {
141
+
margin-bottom: 1rem;
142
+
}
143
+
144
+
.back {
145
+
color: var(--text-secondary);
146
+
text-decoration: none;
147
+
font-size: 0.875rem;
148
+
}
149
+
150
+
.back:hover {
151
+
color: var(--accent);
152
+
}
153
+
154
+
h1 {
155
+
margin: 0.5rem 0 0 0;
156
+
}
157
+
158
+
.description {
159
+
color: var(--text-secondary);
160
+
margin-bottom: 2rem;
161
+
}
162
+
163
+
.error {
164
+
padding: 0.75rem;
165
+
background: var(--error-bg);
166
+
border: 1px solid var(--error-border);
167
+
border-radius: 4px;
168
+
color: var(--error-text);
169
+
margin-bottom: 1rem;
170
+
}
171
+
172
+
.created-code {
173
+
padding: 1.5rem;
174
+
background: var(--success-bg);
175
+
border: 1px solid var(--success-border);
176
+
border-radius: 8px;
177
+
margin-bottom: 2rem;
178
+
}
179
+
180
+
.created-code h3 {
181
+
margin: 0 0 1rem 0;
182
+
color: var(--success-text);
183
+
}
184
+
185
+
.code-display {
186
+
display: flex;
187
+
align-items: center;
188
+
gap: 1rem;
189
+
background: var(--bg-card);
190
+
padding: 1rem;
191
+
border-radius: 4px;
192
+
margin-bottom: 1rem;
193
+
}
194
+
195
+
.code-display code {
196
+
font-size: 1.125rem;
197
+
font-family: monospace;
198
+
flex: 1;
199
+
}
200
+
201
+
.copy {
202
+
padding: 0.5rem 1rem;
203
+
background: var(--accent);
204
+
color: white;
205
+
border: none;
206
+
border-radius: 4px;
207
+
cursor: pointer;
208
+
}
209
+
210
+
.copy:hover {
211
+
background: var(--accent-hover);
212
+
}
213
+
214
+
.create-section {
215
+
margin-bottom: 2rem;
216
+
}
217
+
218
+
.create-section button {
219
+
padding: 0.75rem 1.5rem;
220
+
background: var(--accent);
221
+
color: white;
222
+
border: none;
223
+
border-radius: 4px;
224
+
cursor: pointer;
225
+
font-size: 1rem;
226
+
}
227
+
228
+
.create-section button:hover:not(:disabled) {
229
+
background: var(--accent-hover);
230
+
}
231
+
232
+
.create-section button:disabled {
233
+
opacity: 0.6;
234
+
cursor: not-allowed;
235
+
}
236
+
237
+
section h2 {
238
+
font-size: 1.125rem;
239
+
margin: 0 0 1rem 0;
240
+
}
241
+
242
+
.code-list {
243
+
list-style: none;
244
+
padding: 0;
245
+
margin: 0;
246
+
}
247
+
248
+
.code-list li {
249
+
padding: 1rem;
250
+
border: 1px solid var(--border-color);
251
+
border-radius: 4px;
252
+
margin-bottom: 0.5rem;
253
+
background: var(--bg-card);
254
+
}
255
+
256
+
.code-list li.disabled {
257
+
opacity: 0.6;
258
+
}
259
+
260
+
.code-list li.used {
261
+
background: var(--bg-secondary);
262
+
}
263
+
264
+
.code-main {
265
+
display: flex;
266
+
align-items: center;
267
+
gap: 0.5rem;
268
+
margin-bottom: 0.5rem;
269
+
}
270
+
271
+
.code-main code {
272
+
font-family: monospace;
273
+
font-size: 0.9rem;
274
+
}
275
+
276
+
.copy-small {
277
+
padding: 0.25rem 0.5rem;
278
+
background: var(--bg-secondary);
279
+
border: 1px solid var(--border-color);
280
+
border-radius: 4px;
281
+
font-size: 0.75rem;
282
+
cursor: pointer;
283
+
color: var(--text-primary);
284
+
}
285
+
286
+
.copy-small:hover {
287
+
background: var(--bg-input-disabled);
288
+
}
289
+
290
+
.code-meta {
291
+
display: flex;
292
+
gap: 1rem;
293
+
font-size: 0.875rem;
294
+
}
295
+
296
+
.date {
297
+
color: var(--text-secondary);
298
+
}
299
+
300
+
.status {
301
+
padding: 0.125rem 0.5rem;
302
+
border-radius: 4px;
303
+
font-size: 0.75rem;
304
+
}
305
+
306
+
.status.available {
307
+
background: var(--success-bg);
308
+
color: var(--success-text);
309
+
}
310
+
311
+
.status.used {
312
+
background: var(--bg-secondary);
313
+
color: var(--text-secondary);
314
+
}
315
+
316
+
.status.disabled {
317
+
background: var(--error-bg);
318
+
color: var(--error-text);
319
+
}
320
+
321
+
.empty {
322
+
color: var(--text-secondary);
323
+
text-align: center;
324
+
padding: 2rem;
325
+
}
326
+
</style>
+289
frontend/src/routes/Login.svelte
+289
frontend/src/routes/Login.svelte
···
1
+
<script lang="ts">
2
+
import { login, confirmSignup, resendVerification, getAuthState } from '../lib/auth.svelte'
3
+
import { navigate } from '../lib/router.svelte'
4
+
import { ApiError } from '../lib/api'
5
+
6
+
let identifier = $state('')
7
+
let password = $state('')
8
+
let submitting = $state(false)
9
+
let error = $state<string | null>(null)
10
+
11
+
let pendingVerification = $state<{ did: string } | null>(null)
12
+
let verificationCode = $state('')
13
+
let resendingCode = $state(false)
14
+
let resendMessage = $state<string | null>(null)
15
+
16
+
const auth = getAuthState()
17
+
18
+
$effect(() => {
19
+
if (auth.session) {
20
+
navigate('/dashboard')
21
+
}
22
+
})
23
+
24
+
async function handleSubmit(e: Event) {
25
+
e.preventDefault()
26
+
if (!identifier || !password) return
27
+
28
+
submitting = true
29
+
error = null
30
+
pendingVerification = null
31
+
32
+
try {
33
+
await login(identifier, password)
34
+
navigate('/dashboard')
35
+
} catch (e: any) {
36
+
if (e instanceof ApiError && e.error === 'AccountNotVerified') {
37
+
if (e.did) {
38
+
pendingVerification = { did: e.did }
39
+
} else {
40
+
error = 'Account not verified. Please check your verification method for a code.'
41
+
}
42
+
} else {
43
+
error = e.message || 'Login failed'
44
+
}
45
+
} finally {
46
+
submitting = false
47
+
}
48
+
}
49
+
50
+
async function handleVerification(e: Event) {
51
+
e.preventDefault()
52
+
53
+
if (!pendingVerification || !verificationCode.trim()) return
54
+
55
+
submitting = true
56
+
error = null
57
+
58
+
try {
59
+
await confirmSignup(pendingVerification.did, verificationCode.trim())
60
+
navigate('/dashboard')
61
+
} catch (e: any) {
62
+
error = e.message || 'Verification failed'
63
+
} finally {
64
+
submitting = false
65
+
}
66
+
}
67
+
68
+
async function handleResendCode() {
69
+
if (!pendingVerification || resendingCode) return
70
+
71
+
resendingCode = true
72
+
resendMessage = null
73
+
error = null
74
+
75
+
try {
76
+
await resendVerification(pendingVerification.did)
77
+
resendMessage = 'Verification code resent!'
78
+
} catch (e: any) {
79
+
error = e.message || 'Failed to resend code'
80
+
} finally {
81
+
resendingCode = false
82
+
}
83
+
}
84
+
85
+
function backToLogin() {
86
+
pendingVerification = null
87
+
verificationCode = ''
88
+
error = null
89
+
resendMessage = null
90
+
}
91
+
</script>
92
+
93
+
<div class="login-container">
94
+
{#if error}
95
+
<div class="error">{error}</div>
96
+
{/if}
97
+
98
+
{#if pendingVerification}
99
+
<h1>Verify Your Account</h1>
100
+
<p class="subtitle">
101
+
Your account needs verification. Enter the code sent to your verification method.
102
+
</p>
103
+
104
+
{#if resendMessage}
105
+
<div class="success">{resendMessage}</div>
106
+
{/if}
107
+
108
+
<form onsubmit={(e) => { e.preventDefault(); handleVerification(e); }}>
109
+
<div class="field">
110
+
<label for="verification-code">Verification Code</label>
111
+
<input
112
+
id="verification-code"
113
+
type="text"
114
+
bind:value={verificationCode}
115
+
placeholder="Enter 6-digit code"
116
+
disabled={submitting}
117
+
required
118
+
maxlength="6"
119
+
pattern="[0-9]{6}"
120
+
autocomplete="one-time-code"
121
+
/>
122
+
</div>
123
+
124
+
<button type="submit" disabled={submitting || !verificationCode.trim()}>
125
+
{submitting ? 'Verifying...' : 'Verify Account'}
126
+
</button>
127
+
128
+
<button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}>
129
+
{resendingCode ? 'Resending...' : 'Resend Code'}
130
+
</button>
131
+
132
+
<button type="button" class="tertiary" onclick={backToLogin}>
133
+
Back to Login
134
+
</button>
135
+
</form>
136
+
{:else}
137
+
<h1>Sign In</h1>
138
+
<p class="subtitle">Sign in to manage your PDS account</p>
139
+
140
+
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(e); }}>
141
+
<div class="field">
142
+
<label for="identifier">Handle or Email</label>
143
+
<input
144
+
id="identifier"
145
+
type="text"
146
+
bind:value={identifier}
147
+
placeholder="you.bsky.social or you@example.com"
148
+
disabled={submitting}
149
+
required
150
+
/>
151
+
</div>
152
+
153
+
<div class="field">
154
+
<label for="password">Password</label>
155
+
<input
156
+
id="password"
157
+
type="password"
158
+
bind:value={password}
159
+
placeholder="Password"
160
+
disabled={submitting}
161
+
required
162
+
/>
163
+
</div>
164
+
165
+
<button type="submit" disabled={submitting || !identifier || !password}>
166
+
{submitting ? 'Signing in...' : 'Sign In'}
167
+
</button>
168
+
</form>
169
+
170
+
<p class="register-link">
171
+
Don't have an account? <a href="#/register">Create one</a>
172
+
</p>
173
+
{/if}
174
+
</div>
175
+
176
+
<style>
177
+
.login-container {
178
+
max-width: 400px;
179
+
margin: 4rem auto;
180
+
padding: 2rem;
181
+
}
182
+
183
+
h1 {
184
+
margin: 0 0 0.5rem 0;
185
+
}
186
+
187
+
.subtitle {
188
+
color: var(--text-secondary);
189
+
margin: 0 0 2rem 0;
190
+
}
191
+
192
+
form {
193
+
display: flex;
194
+
flex-direction: column;
195
+
gap: 1rem;
196
+
}
197
+
198
+
.field {
199
+
display: flex;
200
+
flex-direction: column;
201
+
gap: 0.25rem;
202
+
}
203
+
204
+
label {
205
+
font-size: 0.875rem;
206
+
font-weight: 500;
207
+
}
208
+
209
+
input {
210
+
padding: 0.75rem;
211
+
border: 1px solid var(--border-color-light);
212
+
border-radius: 4px;
213
+
font-size: 1rem;
214
+
background: var(--bg-input);
215
+
color: var(--text-primary);
216
+
}
217
+
218
+
input:focus {
219
+
outline: none;
220
+
border-color: var(--accent);
221
+
}
222
+
223
+
button {
224
+
padding: 0.75rem;
225
+
background: var(--accent);
226
+
color: white;
227
+
border: none;
228
+
border-radius: 4px;
229
+
font-size: 1rem;
230
+
cursor: pointer;
231
+
margin-top: 0.5rem;
232
+
}
233
+
234
+
button:hover:not(:disabled) {
235
+
background: var(--accent-hover);
236
+
}
237
+
238
+
button:disabled {
239
+
opacity: 0.6;
240
+
cursor: not-allowed;
241
+
}
242
+
243
+
button.secondary {
244
+
background: transparent;
245
+
color: var(--accent);
246
+
border: 1px solid var(--accent);
247
+
}
248
+
249
+
button.secondary:hover:not(:disabled) {
250
+
background: var(--accent);
251
+
color: white;
252
+
}
253
+
254
+
button.tertiary {
255
+
background: transparent;
256
+
color: var(--text-secondary);
257
+
border: none;
258
+
}
259
+
260
+
button.tertiary:hover:not(:disabled) {
261
+
color: var(--text-primary);
262
+
}
263
+
264
+
.error {
265
+
padding: 0.75rem;
266
+
background: var(--error-bg);
267
+
border: 1px solid var(--error-border);
268
+
border-radius: 4px;
269
+
color: var(--error-text);
270
+
}
271
+
272
+
.success {
273
+
padding: 0.75rem;
274
+
background: var(--success-bg);
275
+
border: 1px solid var(--success-border);
276
+
border-radius: 4px;
277
+
color: var(--success-text);
278
+
}
279
+
280
+
.register-link {
281
+
text-align: center;
282
+
margin-top: 1.5rem;
283
+
color: var(--text-secondary);
284
+
}
285
+
286
+
.register-link a {
287
+
color: var(--accent);
288
+
}
289
+
</style>
+453
frontend/src/routes/Notifications.svelte
+453
frontend/src/routes/Notifications.svelte
···
1
+
<script lang="ts">
2
+
import { getAuthState } from '../lib/auth.svelte'
3
+
import { navigate } from '../lib/router.svelte'
4
+
import { api, ApiError } from '../lib/api'
5
+
6
+
const auth = getAuthState()
7
+
8
+
let loading = $state(true)
9
+
let saving = $state(false)
10
+
let error = $state<string | null>(null)
11
+
let success = $state<string | null>(null)
12
+
13
+
let preferredChannel = $state('email')
14
+
let email = $state('')
15
+
let discordId = $state('')
16
+
let discordVerified = $state(false)
17
+
let telegramUsername = $state('')
18
+
let telegramVerified = $state(false)
19
+
let signalNumber = $state('')
20
+
let signalVerified = $state(false)
21
+
22
+
$effect(() => {
23
+
if (!auth.loading && !auth.session) {
24
+
navigate('/login')
25
+
}
26
+
})
27
+
28
+
$effect(() => {
29
+
if (auth.session) {
30
+
loadPrefs()
31
+
}
32
+
})
33
+
34
+
async function loadPrefs() {
35
+
if (!auth.session) return
36
+
loading = true
37
+
error = null
38
+
39
+
try {
40
+
const prefs = await api.getNotificationPrefs(auth.session.accessJwt)
41
+
preferredChannel = prefs.preferredChannel
42
+
email = prefs.email
43
+
discordId = prefs.discordId ?? ''
44
+
discordVerified = prefs.discordVerified
45
+
telegramUsername = prefs.telegramUsername ?? ''
46
+
telegramVerified = prefs.telegramVerified
47
+
signalNumber = prefs.signalNumber ?? ''
48
+
signalVerified = prefs.signalVerified
49
+
} catch (e) {
50
+
error = e instanceof ApiError ? e.message : 'Failed to load notification preferences'
51
+
} finally {
52
+
loading = false
53
+
}
54
+
}
55
+
56
+
async function handleSave(e: Event) {
57
+
e.preventDefault()
58
+
if (!auth.session) return
59
+
60
+
saving = true
61
+
error = null
62
+
success = null
63
+
64
+
try {
65
+
await api.updateNotificationPrefs(auth.session.accessJwt, {
66
+
preferredChannel,
67
+
discordId: discordId || undefined,
68
+
telegramUsername: telegramUsername || undefined,
69
+
signalNumber: signalNumber || undefined,
70
+
})
71
+
success = 'Notification preferences saved'
72
+
await loadPrefs()
73
+
} catch (e) {
74
+
error = e instanceof ApiError ? e.message : 'Failed to save preferences'
75
+
} finally {
76
+
saving = false
77
+
}
78
+
}
79
+
80
+
const channels = [
81
+
{ id: 'email', name: 'Email', description: 'Receive notifications via email' },
82
+
{ id: 'discord', name: 'Discord', description: 'Receive notifications via Discord DM' },
83
+
{ id: 'telegram', name: 'Telegram', description: 'Receive notifications via Telegram' },
84
+
{ id: 'signal', name: 'Signal', description: 'Receive notifications via Signal' },
85
+
]
86
+
87
+
function canSelectChannel(channelId: string): boolean {
88
+
if (channelId === 'email') return true
89
+
if (channelId === 'discord') return !!discordId
90
+
if (channelId === 'telegram') return !!telegramUsername
91
+
if (channelId === 'signal') return !!signalNumber
92
+
return false
93
+
}
94
+
</script>
95
+
96
+
<div class="page">
97
+
<header>
98
+
<a href="#/dashboard" class="back">← Dashboard</a>
99
+
<h1>Notification Preferences</h1>
100
+
</header>
101
+
102
+
<p class="description">
103
+
Choose how you want to receive important notifications like password resets,
104
+
security alerts, and account updates.
105
+
</p>
106
+
107
+
{#if loading}
108
+
<p class="loading">Loading...</p>
109
+
{:else}
110
+
{#if error}
111
+
<div class="message error">{error}</div>
112
+
{/if}
113
+
114
+
{#if success}
115
+
<div class="message success">{success}</div>
116
+
{/if}
117
+
118
+
<form onsubmit={handleSave}>
119
+
<section>
120
+
<h2>Preferred Channel</h2>
121
+
<p class="section-description">
122
+
Select your preferred way to receive notifications. You must configure a channel before you can select it.
123
+
</p>
124
+
125
+
<div class="channel-options">
126
+
{#each channels as channel}
127
+
<label class="channel-option" class:disabled={!canSelectChannel(channel.id)}>
128
+
<input
129
+
type="radio"
130
+
name="preferredChannel"
131
+
value={channel.id}
132
+
bind:group={preferredChannel}
133
+
disabled={!canSelectChannel(channel.id) || saving}
134
+
/>
135
+
<div class="channel-info">
136
+
<span class="channel-name">{channel.name}</span>
137
+
<span class="channel-description">{channel.description}</span>
138
+
{#if channel.id !== 'email' && !canSelectChannel(channel.id)}
139
+
<span class="channel-hint">Configure below to enable</span>
140
+
{/if}
141
+
</div>
142
+
</label>
143
+
{/each}
144
+
</div>
145
+
</section>
146
+
147
+
<section>
148
+
<h2>Channel Configuration</h2>
149
+
150
+
<div class="channel-config">
151
+
<div class="config-item">
152
+
<label for="email">Email</label>
153
+
<div class="config-input">
154
+
<input
155
+
id="email"
156
+
type="email"
157
+
value={email}
158
+
disabled
159
+
class="readonly"
160
+
/>
161
+
<span class="status verified">Primary</span>
162
+
</div>
163
+
<p class="config-hint">Your email is managed in Account Settings</p>
164
+
</div>
165
+
166
+
<div class="config-item">
167
+
<label for="discord">Discord User ID</label>
168
+
<div class="config-input">
169
+
<input
170
+
id="discord"
171
+
type="text"
172
+
bind:value={discordId}
173
+
placeholder="e.g., 123456789012345678"
174
+
disabled={saving}
175
+
/>
176
+
{#if discordId}
177
+
{#if discordVerified}
178
+
<span class="status verified">Verified</span>
179
+
{:else}
180
+
<span class="status unverified">Not verified</span>
181
+
{/if}
182
+
{/if}
183
+
</div>
184
+
<p class="config-hint">Your Discord user ID (not username). Enable Developer Mode in Discord to copy it.</p>
185
+
</div>
186
+
187
+
<div class="config-item">
188
+
<label for="telegram">Telegram Username</label>
189
+
<div class="config-input">
190
+
<input
191
+
id="telegram"
192
+
type="text"
193
+
bind:value={telegramUsername}
194
+
placeholder="e.g., username"
195
+
disabled={saving}
196
+
/>
197
+
{#if telegramUsername}
198
+
{#if telegramVerified}
199
+
<span class="status verified">Verified</span>
200
+
{:else}
201
+
<span class="status unverified">Not verified</span>
202
+
{/if}
203
+
{/if}
204
+
</div>
205
+
<p class="config-hint">Your Telegram username without the @ symbol</p>
206
+
</div>
207
+
208
+
<div class="config-item">
209
+
<label for="signal">Signal Phone Number</label>
210
+
<div class="config-input">
211
+
<input
212
+
id="signal"
213
+
type="tel"
214
+
bind:value={signalNumber}
215
+
placeholder="e.g., +1234567890"
216
+
disabled={saving}
217
+
/>
218
+
{#if signalNumber}
219
+
{#if signalVerified}
220
+
<span class="status verified">Verified</span>
221
+
{:else}
222
+
<span class="status unverified">Not verified</span>
223
+
{/if}
224
+
{/if}
225
+
</div>
226
+
<p class="config-hint">Your Signal phone number with country code</p>
227
+
</div>
228
+
</div>
229
+
</section>
230
+
231
+
<div class="actions">
232
+
<button type="submit" disabled={saving}>
233
+
{saving ? 'Saving...' : 'Save Preferences'}
234
+
</button>
235
+
</div>
236
+
</form>
237
+
{/if}
238
+
</div>
239
+
240
+
<style>
241
+
.page {
242
+
max-width: 600px;
243
+
margin: 0 auto;
244
+
padding: 2rem;
245
+
}
246
+
247
+
header {
248
+
margin-bottom: 1rem;
249
+
}
250
+
251
+
.back {
252
+
color: var(--text-secondary);
253
+
text-decoration: none;
254
+
font-size: 0.875rem;
255
+
}
256
+
257
+
.back:hover {
258
+
color: var(--accent);
259
+
}
260
+
261
+
h1 {
262
+
margin: 0.5rem 0 0 0;
263
+
}
264
+
265
+
.description {
266
+
color: var(--text-secondary);
267
+
margin-bottom: 2rem;
268
+
}
269
+
270
+
.loading {
271
+
text-align: center;
272
+
color: var(--text-secondary);
273
+
padding: 2rem;
274
+
}
275
+
276
+
.message {
277
+
padding: 0.75rem;
278
+
border-radius: 4px;
279
+
margin-bottom: 1rem;
280
+
}
281
+
282
+
.message.error {
283
+
background: var(--error-bg);
284
+
border: 1px solid var(--error-border);
285
+
color: var(--error-text);
286
+
}
287
+
288
+
.message.success {
289
+
background: var(--success-bg);
290
+
border: 1px solid var(--success-border);
291
+
color: var(--success-text);
292
+
}
293
+
294
+
section {
295
+
background: var(--bg-secondary);
296
+
padding: 1.5rem;
297
+
border-radius: 8px;
298
+
margin-bottom: 1.5rem;
299
+
}
300
+
301
+
section h2 {
302
+
margin: 0 0 0.5rem 0;
303
+
font-size: 1.125rem;
304
+
}
305
+
306
+
.section-description {
307
+
color: var(--text-secondary);
308
+
font-size: 0.875rem;
309
+
margin: 0 0 1rem 0;
310
+
}
311
+
312
+
.channel-options {
313
+
display: flex;
314
+
flex-direction: column;
315
+
gap: 0.5rem;
316
+
}
317
+
318
+
.channel-option {
319
+
display: flex;
320
+
align-items: flex-start;
321
+
gap: 0.75rem;
322
+
padding: 0.75rem;
323
+
background: var(--bg-card);
324
+
border: 1px solid var(--border-color);
325
+
border-radius: 4px;
326
+
cursor: pointer;
327
+
transition: border-color 0.15s;
328
+
}
329
+
330
+
.channel-option:hover:not(.disabled) {
331
+
border-color: var(--accent);
332
+
}
333
+
334
+
.channel-option.disabled {
335
+
opacity: 0.6;
336
+
cursor: not-allowed;
337
+
}
338
+
339
+
.channel-option input {
340
+
margin-top: 0.25rem;
341
+
}
342
+
343
+
.channel-info {
344
+
display: flex;
345
+
flex-direction: column;
346
+
gap: 0.125rem;
347
+
}
348
+
349
+
.channel-name {
350
+
font-weight: 500;
351
+
}
352
+
353
+
.channel-description {
354
+
font-size: 0.875rem;
355
+
color: var(--text-secondary);
356
+
}
357
+
358
+
.channel-hint {
359
+
font-size: 0.75rem;
360
+
color: var(--text-muted);
361
+
font-style: italic;
362
+
}
363
+
364
+
.channel-config {
365
+
display: flex;
366
+
flex-direction: column;
367
+
gap: 1.25rem;
368
+
}
369
+
370
+
.config-item {
371
+
display: flex;
372
+
flex-direction: column;
373
+
gap: 0.25rem;
374
+
}
375
+
376
+
.config-item label {
377
+
font-size: 0.875rem;
378
+
font-weight: 500;
379
+
}
380
+
381
+
.config-input {
382
+
display: flex;
383
+
align-items: center;
384
+
gap: 0.5rem;
385
+
}
386
+
387
+
.config-input input {
388
+
flex: 1;
389
+
padding: 0.75rem;
390
+
border: 1px solid var(--border-color-light);
391
+
border-radius: 4px;
392
+
font-size: 1rem;
393
+
background: var(--bg-input);
394
+
color: var(--text-primary);
395
+
}
396
+
397
+
.config-input input:focus {
398
+
outline: none;
399
+
border-color: var(--accent);
400
+
}
401
+
402
+
.config-input input.readonly {
403
+
background: var(--bg-input-disabled);
404
+
color: var(--text-secondary);
405
+
}
406
+
407
+
.status {
408
+
padding: 0.25rem 0.5rem;
409
+
border-radius: 4px;
410
+
font-size: 0.75rem;
411
+
white-space: nowrap;
412
+
}
413
+
414
+
.status.verified {
415
+
background: var(--success-bg);
416
+
color: var(--success-text);
417
+
}
418
+
419
+
.status.unverified {
420
+
background: var(--warning-bg);
421
+
color: var(--warning-text);
422
+
}
423
+
424
+
.config-hint {
425
+
font-size: 0.75rem;
426
+
color: var(--text-secondary);
427
+
margin: 0;
428
+
}
429
+
430
+
.actions {
431
+
display: flex;
432
+
justify-content: flex-end;
433
+
}
434
+
435
+
.actions button {
436
+
padding: 0.75rem 2rem;
437
+
background: var(--accent);
438
+
color: white;
439
+
border: none;
440
+
border-radius: 4px;
441
+
font-size: 1rem;
442
+
cursor: pointer;
443
+
}
444
+
445
+
.actions button:hover:not(:disabled) {
446
+
background: var(--accent-hover);
447
+
}
448
+
449
+
.actions button:disabled {
450
+
opacity: 0.6;
451
+
cursor: not-allowed;
452
+
}
453
+
</style>
+523
frontend/src/routes/Register.svelte
+523
frontend/src/routes/Register.svelte
···
1
+
<script lang="ts">
2
+
import { register, confirmSignup, resendVerification, getAuthState } from '../lib/auth.svelte'
3
+
import { navigate } from '../lib/router.svelte'
4
+
import { api, ApiError, type VerificationChannel } from '../lib/api'
5
+
6
+
let handle = $state('')
7
+
let email = $state('')
8
+
let password = $state('')
9
+
let confirmPassword = $state('')
10
+
let inviteCode = $state('')
11
+
let verificationChannel = $state<VerificationChannel>('email')
12
+
let discordId = $state('')
13
+
let telegramUsername = $state('')
14
+
let signalNumber = $state('')
15
+
let submitting = $state(false)
16
+
let error = $state<string | null>(null)
17
+
18
+
let pendingVerification = $state<{ did: string; handle: string; channel: string } | null>(null)
19
+
let verificationCode = $state('')
20
+
let resendingCode = $state(false)
21
+
let resendMessage = $state<string | null>(null)
22
+
23
+
let serverInfo = $state<{
24
+
availableUserDomains: string[]
25
+
inviteCodeRequired: boolean
26
+
} | null>(null)
27
+
let loadingServerInfo = $state(true)
28
+
let serverInfoLoaded = false
29
+
30
+
const auth = getAuthState()
31
+
32
+
$effect(() => {
33
+
if (auth.session) {
34
+
navigate('/dashboard')
35
+
}
36
+
})
37
+
38
+
$effect(() => {
39
+
if (!serverInfoLoaded) {
40
+
serverInfoLoaded = true
41
+
loadServerInfo()
42
+
}
43
+
})
44
+
45
+
async function loadServerInfo() {
46
+
try {
47
+
serverInfo = await api.describeServer()
48
+
} catch (e) {
49
+
console.error('Failed to load server info:', e)
50
+
} finally {
51
+
loadingServerInfo = false
52
+
}
53
+
}
54
+
55
+
function validateForm(): string | null {
56
+
if (!handle.trim()) return 'Handle is required'
57
+
if (!password) return 'Password is required'
58
+
if (password.length < 8) return 'Password must be at least 8 characters'
59
+
if (password !== confirmPassword) return 'Passwords do not match'
60
+
if (serverInfo?.inviteCodeRequired && !inviteCode.trim()) {
61
+
return 'Invite code is required'
62
+
}
63
+
switch (verificationChannel) {
64
+
case 'email':
65
+
if (!email.trim()) return 'Email is required for email verification'
66
+
break
67
+
case 'discord':
68
+
if (!discordId.trim()) return 'Discord ID is required for Discord verification'
69
+
break
70
+
case 'telegram':
71
+
if (!telegramUsername.trim()) return 'Telegram username is required for Telegram verification'
72
+
break
73
+
case 'signal':
74
+
if (!signalNumber.trim()) return 'Phone number is required for Signal verification'
75
+
break
76
+
}
77
+
return null
78
+
}
79
+
80
+
async function handleSubmit(e: Event) {
81
+
e.preventDefault()
82
+
83
+
const validationError = validateForm()
84
+
if (validationError) {
85
+
error = validationError
86
+
return
87
+
}
88
+
89
+
submitting = true
90
+
error = null
91
+
92
+
try {
93
+
const result = await register({
94
+
handle: handle.trim(),
95
+
email: email.trim(),
96
+
password,
97
+
inviteCode: inviteCode.trim() || undefined,
98
+
verificationChannel,
99
+
discordId: discordId.trim() || undefined,
100
+
telegramUsername: telegramUsername.trim() || undefined,
101
+
signalNumber: signalNumber.trim() || undefined,
102
+
})
103
+
104
+
if (result.verificationRequired) {
105
+
pendingVerification = {
106
+
did: result.did,
107
+
handle: result.handle,
108
+
channel: result.verificationChannel,
109
+
}
110
+
} else {
111
+
navigate('/dashboard')
112
+
}
113
+
} catch (err: any) {
114
+
if (err instanceof ApiError) {
115
+
error = err.message || 'Registration failed'
116
+
} else if (err instanceof Error) {
117
+
error = err.message || 'Registration failed'
118
+
} else {
119
+
error = 'Registration failed'
120
+
}
121
+
} finally {
122
+
submitting = false
123
+
}
124
+
}
125
+
126
+
async function handleVerification(e: Event) {
127
+
e.preventDefault()
128
+
129
+
if (!pendingVerification || !verificationCode.trim()) return
130
+
131
+
submitting = true
132
+
error = null
133
+
134
+
try {
135
+
await confirmSignup(pendingVerification.did, verificationCode.trim())
136
+
navigate('/dashboard')
137
+
} catch (e: any) {
138
+
error = e.message || 'Verification failed'
139
+
} finally {
140
+
submitting = false
141
+
}
142
+
}
143
+
144
+
async function handleResendCode() {
145
+
if (!pendingVerification || resendingCode) return
146
+
147
+
resendingCode = true
148
+
resendMessage = null
149
+
error = null
150
+
151
+
try {
152
+
await resendVerification(pendingVerification.did)
153
+
resendMessage = 'Verification code resent!'
154
+
} catch (e: any) {
155
+
error = e.message || 'Failed to resend code'
156
+
} finally {
157
+
resendingCode = false
158
+
}
159
+
}
160
+
161
+
let fullHandle = $derived(() => {
162
+
if (!handle.trim()) return ''
163
+
if (handle.includes('.')) return handle.trim()
164
+
const domain = serverInfo?.availableUserDomains?.[0]
165
+
if (domain) return `${handle.trim()}.${domain}`
166
+
return handle.trim()
167
+
})
168
+
169
+
function channelLabel(ch: string): string {
170
+
switch (ch) {
171
+
case 'email': return 'Email'
172
+
case 'discord': return 'Discord'
173
+
case 'telegram': return 'Telegram'
174
+
case 'signal': return 'Signal'
175
+
default: return ch
176
+
}
177
+
}
178
+
</script>
179
+
180
+
<div class="register-container">
181
+
{#if error}
182
+
<div class="error">{error}</div>
183
+
{/if}
184
+
185
+
{#if pendingVerification}
186
+
<h1>Verify Your Account</h1>
187
+
<p class="subtitle">
188
+
We've sent a verification code to your {channelLabel(pendingVerification.channel)}.
189
+
Enter it below to complete registration.
190
+
</p>
191
+
192
+
{#if resendMessage}
193
+
<div class="success">{resendMessage}</div>
194
+
{/if}
195
+
196
+
<form onsubmit={(e) => { e.preventDefault(); handleVerification(e); }}>
197
+
198
+
<div class="field">
199
+
<label for="verification-code">Verification Code</label>
200
+
<input
201
+
id="verification-code"
202
+
type="text"
203
+
bind:value={verificationCode}
204
+
placeholder="Enter 6-digit code"
205
+
disabled={submitting}
206
+
required
207
+
maxlength="6"
208
+
pattern="[0-9]{6}"
209
+
autocomplete="one-time-code"
210
+
/>
211
+
</div>
212
+
213
+
<button type="submit" disabled={submitting || !verificationCode.trim()}>
214
+
{submitting ? 'Verifying...' : 'Verify Account'}
215
+
</button>
216
+
217
+
<button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}>
218
+
{resendingCode ? 'Resending...' : 'Resend Code'}
219
+
</button>
220
+
</form>
221
+
{:else}
222
+
<h1>Create Account</h1>
223
+
<p class="subtitle">Create a new account on this PDS</p>
224
+
225
+
{#if loadingServerInfo}
226
+
<p class="loading">Loading...</p>
227
+
{:else}
228
+
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(e); }}>
229
+
<div class="field">
230
+
<label for="handle">Handle</label>
231
+
<input
232
+
id="handle"
233
+
type="text"
234
+
bind:value={handle}
235
+
placeholder="yourname"
236
+
disabled={submitting}
237
+
required
238
+
/>
239
+
{#if fullHandle()}
240
+
<p class="hint">Your full handle will be: @{fullHandle()}</p>
241
+
{/if}
242
+
</div>
243
+
244
+
<div class="field">
245
+
<label for="password">Password</label>
246
+
<input
247
+
id="password"
248
+
type="password"
249
+
bind:value={password}
250
+
placeholder="At least 8 characters"
251
+
disabled={submitting}
252
+
required
253
+
minlength="8"
254
+
/>
255
+
</div>
256
+
257
+
<div class="field">
258
+
<label for="confirm-password">Confirm Password</label>
259
+
<input
260
+
id="confirm-password"
261
+
type="password"
262
+
bind:value={confirmPassword}
263
+
placeholder="Confirm your password"
264
+
disabled={submitting}
265
+
required
266
+
/>
267
+
</div>
268
+
269
+
<fieldset class="verification-section">
270
+
<legend>Contact Method</legend>
271
+
<p class="section-hint">Choose how you'd like to verify your account and receive notifications. You only need one.</p>
272
+
273
+
<div class="field">
274
+
<label for="verification-channel">Verification Method</label>
275
+
<select
276
+
id="verification-channel"
277
+
bind:value={verificationChannel}
278
+
disabled={submitting}
279
+
>
280
+
<option value="email">Email</option>
281
+
<option value="discord">Discord</option>
282
+
<option value="telegram">Telegram</option>
283
+
<option value="signal">Signal</option>
284
+
</select>
285
+
</div>
286
+
287
+
{#if verificationChannel === 'email'}
288
+
<div class="field">
289
+
<label for="email">Email Address</label>
290
+
<input
291
+
id="email"
292
+
type="email"
293
+
bind:value={email}
294
+
placeholder="you@example.com"
295
+
disabled={submitting}
296
+
required
297
+
/>
298
+
</div>
299
+
{:else if verificationChannel === 'discord'}
300
+
<div class="field">
301
+
<label for="discord-id">Discord User ID</label>
302
+
<input
303
+
id="discord-id"
304
+
type="text"
305
+
bind:value={discordId}
306
+
placeholder="Your Discord user ID"
307
+
disabled={submitting}
308
+
required
309
+
/>
310
+
<p class="hint">Your numeric Discord user ID (enable Developer Mode to find it)</p>
311
+
</div>
312
+
{:else if verificationChannel === 'telegram'}
313
+
<div class="field">
314
+
<label for="telegram-username">Telegram Username</label>
315
+
<input
316
+
id="telegram-username"
317
+
type="text"
318
+
bind:value={telegramUsername}
319
+
placeholder="@yourusername"
320
+
disabled={submitting}
321
+
required
322
+
/>
323
+
</div>
324
+
{:else if verificationChannel === 'signal'}
325
+
<div class="field">
326
+
<label for="signal-number">Signal Phone Number</label>
327
+
<input
328
+
id="signal-number"
329
+
type="tel"
330
+
bind:value={signalNumber}
331
+
placeholder="+1234567890"
332
+
disabled={submitting}
333
+
required
334
+
/>
335
+
<p class="hint">Include country code (e.g., +1 for US)</p>
336
+
</div>
337
+
{/if}
338
+
</fieldset>
339
+
340
+
{#if serverInfo?.inviteCodeRequired}
341
+
<div class="field">
342
+
<label for="invite-code">Invite Code <span class="required">*</span></label>
343
+
<input
344
+
id="invite-code"
345
+
type="text"
346
+
bind:value={inviteCode}
347
+
placeholder="Enter your invite code"
348
+
disabled={submitting}
349
+
required
350
+
/>
351
+
</div>
352
+
{:else}
353
+
<div class="field optional">
354
+
<label for="invite-code">Invite Code <span class="optional-label">(optional)</span></label>
355
+
<input
356
+
id="invite-code"
357
+
type="text"
358
+
bind:value={inviteCode}
359
+
placeholder="Enter invite code if you have one"
360
+
disabled={submitting}
361
+
/>
362
+
</div>
363
+
{/if}
364
+
365
+
<button type="submit" disabled={submitting}>
366
+
{submitting ? 'Creating account...' : 'Create Account'}
367
+
</button>
368
+
</form>
369
+
370
+
<p class="login-link">
371
+
Already have an account? <a href="#/login">Sign in</a>
372
+
</p>
373
+
{/if}
374
+
{/if}
375
+
</div>
376
+
377
+
<style>
378
+
.register-container {
379
+
max-width: 400px;
380
+
margin: 4rem auto;
381
+
padding: 2rem;
382
+
}
383
+
384
+
h1 {
385
+
margin: 0 0 0.5rem 0;
386
+
}
387
+
388
+
.subtitle {
389
+
color: var(--text-secondary);
390
+
margin: 0 0 2rem 0;
391
+
}
392
+
393
+
.loading {
394
+
text-align: center;
395
+
color: var(--text-secondary);
396
+
}
397
+
398
+
form {
399
+
display: flex;
400
+
flex-direction: column;
401
+
gap: 1rem;
402
+
}
403
+
404
+
.field {
405
+
display: flex;
406
+
flex-direction: column;
407
+
gap: 0.25rem;
408
+
}
409
+
410
+
.field.optional {
411
+
opacity: 0.8;
412
+
}
413
+
414
+
label {
415
+
font-size: 0.875rem;
416
+
font-weight: 500;
417
+
}
418
+
419
+
.required {
420
+
color: var(--error-text);
421
+
}
422
+
423
+
.optional-label {
424
+
color: var(--text-secondary);
425
+
font-weight: normal;
426
+
}
427
+
428
+
input, select {
429
+
padding: 0.75rem;
430
+
border: 1px solid var(--border-color-light);
431
+
border-radius: 4px;
432
+
font-size: 1rem;
433
+
background: var(--bg-input);
434
+
color: var(--text-primary);
435
+
}
436
+
437
+
input:focus, select:focus {
438
+
outline: none;
439
+
border-color: var(--accent);
440
+
}
441
+
442
+
.hint {
443
+
font-size: 0.75rem;
444
+
color: var(--text-secondary);
445
+
margin: 0.25rem 0 0 0;
446
+
}
447
+
448
+
.verification-section {
449
+
border: 1px solid var(--border-color-light);
450
+
border-radius: 6px;
451
+
padding: 1rem;
452
+
margin: 0.5rem 0;
453
+
}
454
+
455
+
.verification-section legend {
456
+
font-weight: 600;
457
+
padding: 0 0.5rem;
458
+
color: var(--text-primary);
459
+
}
460
+
461
+
.section-hint {
462
+
font-size: 0.8rem;
463
+
color: var(--text-secondary);
464
+
margin: 0 0 1rem 0;
465
+
}
466
+
467
+
button {
468
+
padding: 0.75rem;
469
+
background: var(--accent);
470
+
color: white;
471
+
border: none;
472
+
border-radius: 4px;
473
+
font-size: 1rem;
474
+
cursor: pointer;
475
+
margin-top: 0.5rem;
476
+
}
477
+
478
+
button:hover:not(:disabled) {
479
+
background: var(--accent-hover);
480
+
}
481
+
482
+
button:disabled {
483
+
opacity: 0.6;
484
+
cursor: not-allowed;
485
+
}
486
+
487
+
button.secondary {
488
+
background: transparent;
489
+
color: var(--accent);
490
+
border: 1px solid var(--accent);
491
+
}
492
+
493
+
button.secondary:hover:not(:disabled) {
494
+
background: var(--accent);
495
+
color: white;
496
+
}
497
+
498
+
.error {
499
+
padding: 0.75rem;
500
+
background: var(--error-bg);
501
+
border: 1px solid var(--error-border);
502
+
border-radius: 4px;
503
+
color: var(--error-text);
504
+
}
505
+
506
+
.success {
507
+
padding: 0.75rem;
508
+
background: var(--success-bg);
509
+
border: 1px solid var(--success-border);
510
+
border-radius: 4px;
511
+
color: var(--success-text);
512
+
}
513
+
514
+
.login-link {
515
+
text-align: center;
516
+
margin-top: 1.5rem;
517
+
color: var(--text-secondary);
518
+
}
519
+
520
+
.login-link a {
521
+
color: var(--accent);
522
+
}
523
+
</style>
+940
frontend/src/routes/RepoExplorer.svelte
+940
frontend/src/routes/RepoExplorer.svelte
···
1
+
<script lang="ts">
2
+
import { getAuthState } from '../lib/auth.svelte'
3
+
import { navigate } from '../lib/router.svelte'
4
+
import { api, ApiError } from '../lib/api'
5
+
6
+
const auth = getAuthState()
7
+
8
+
type View = 'collections' | 'records' | 'record' | 'create'
9
+
10
+
let view = $state<View>('collections')
11
+
let collections = $state<string[]>([])
12
+
let selectedCollection = $state<string | null>(null)
13
+
let records = $state<Array<{ uri: string; cid: string; value: unknown; rkey: string }>>([])
14
+
let recordsCursor = $state<string | undefined>(undefined)
15
+
let selectedRecord = $state<{ uri: string; cid: string; value: unknown; rkey: string } | null>(null)
16
+
17
+
let loading = $state(true)
18
+
let loadingMore = $state(false)
19
+
let error = $state<{ code?: string; message: string } | null>(null)
20
+
let success = $state<string | null>(null)
21
+
22
+
function setError(e: unknown) {
23
+
if (e instanceof ApiError) {
24
+
error = { code: e.error, message: e.message }
25
+
} else if (e instanceof Error) {
26
+
error = { message: e.message }
27
+
} else {
28
+
error = { message: 'An unknown error occurred' }
29
+
}
30
+
}
31
+
32
+
let newCollection = $state('')
33
+
let newRkey = $state('')
34
+
let recordJson = $state('')
35
+
let jsonError = $state<string | null>(null)
36
+
let saving = $state(false)
37
+
38
+
let filter = $state('')
39
+
40
+
$effect(() => {
41
+
if (!auth.loading && !auth.session) {
42
+
navigate('/login')
43
+
}
44
+
})
45
+
46
+
$effect(() => {
47
+
if (auth.session) {
48
+
loadCollections()
49
+
}
50
+
})
51
+
52
+
async function loadCollections() {
53
+
if (!auth.session) return
54
+
loading = true
55
+
error = null
56
+
57
+
try {
58
+
const result = await api.describeRepo(auth.session.accessJwt, auth.session.did)
59
+
collections = result.collections.sort()
60
+
} catch (e) {
61
+
setError(e)
62
+
} finally {
63
+
loading = false
64
+
}
65
+
}
66
+
67
+
async function selectCollection(collection: string) {
68
+
if (!auth.session) return
69
+
selectedCollection = collection
70
+
records = []
71
+
recordsCursor = undefined
72
+
view = 'records'
73
+
loading = true
74
+
error = null
75
+
76
+
try {
77
+
const result = await api.listRecords(auth.session.accessJwt, auth.session.did, collection, { limit: 50 })
78
+
records = result.records.map(r => ({
79
+
...r,
80
+
rkey: r.uri.split('/').pop()!
81
+
}))
82
+
recordsCursor = result.cursor
83
+
} catch (e) {
84
+
setError(e)
85
+
} finally {
86
+
loading = false
87
+
}
88
+
}
89
+
90
+
async function loadMoreRecords() {
91
+
if (!auth.session || !selectedCollection || !recordsCursor) return
92
+
loadingMore = true
93
+
94
+
try {
95
+
const result = await api.listRecords(auth.session.accessJwt, auth.session.did, selectedCollection, {
96
+
limit: 50,
97
+
cursor: recordsCursor
98
+
})
99
+
records = [...records, ...result.records.map(r => ({
100
+
...r,
101
+
rkey: r.uri.split('/').pop()!
102
+
}))]
103
+
recordsCursor = result.cursor
104
+
} catch (e) {
105
+
setError(e)
106
+
} finally {
107
+
loadingMore = false
108
+
}
109
+
}
110
+
111
+
async function selectRecord(record: { uri: string; cid: string; value: unknown; rkey: string }) {
112
+
selectedRecord = record
113
+
recordJson = JSON.stringify(record.value, null, 2)
114
+
jsonError = null
115
+
view = 'record'
116
+
}
117
+
118
+
function startCreate(collection?: string) {
119
+
newCollection = collection || 'app.bsky.feed.post'
120
+
newRkey = ''
121
+
122
+
const exampleRecords: Record<string, unknown> = {
123
+
'app.bsky.feed.post': {
124
+
$type: 'app.bsky.feed.post',
125
+
text: 'Hello from my PDS! This is my first post.',
126
+
createdAt: new Date().toISOString(),
127
+
},
128
+
'app.bsky.actor.profile': {
129
+
$type: 'app.bsky.actor.profile',
130
+
displayName: 'Your Display Name',
131
+
description: 'A short bio about yourself.',
132
+
},
133
+
'app.bsky.graph.follow': {
134
+
$type: 'app.bsky.graph.follow',
135
+
subject: 'did:web:example.com',
136
+
createdAt: new Date().toISOString(),
137
+
},
138
+
'app.bsky.feed.like': {
139
+
$type: 'app.bsky.feed.like',
140
+
subject: {
141
+
uri: 'at://did:web:example.com/app.bsky.feed.post/abc123',
142
+
cid: 'bafyreiabc123...',
143
+
},
144
+
createdAt: new Date().toISOString(),
145
+
},
146
+
}
147
+
148
+
const example = exampleRecords[collection || 'app.bsky.feed.post'] || {
149
+
$type: collection || 'app.bsky.feed.post',
150
+
}
151
+
152
+
recordJson = JSON.stringify(example, null, 2)
153
+
jsonError = null
154
+
view = 'create'
155
+
}
156
+
157
+
function validateJson(): unknown | null {
158
+
try {
159
+
const parsed = JSON.parse(recordJson)
160
+
jsonError = null
161
+
return parsed
162
+
} catch (e) {
163
+
jsonError = e instanceof Error ? e.message : 'Invalid JSON'
164
+
return null
165
+
}
166
+
}
167
+
168
+
async function handleCreate(e: Event) {
169
+
e.preventDefault()
170
+
if (!auth.session) return
171
+
172
+
const record = validateJson()
173
+
if (!record) return
174
+
175
+
if (!newCollection.trim()) {
176
+
error = { message: 'Collection is required' }
177
+
return
178
+
}
179
+
180
+
saving = true
181
+
error = null
182
+
183
+
try {
184
+
const result = await api.createRecord(
185
+
auth.session.accessJwt,
186
+
auth.session.did,
187
+
newCollection.trim(),
188
+
record,
189
+
newRkey.trim() || undefined
190
+
)
191
+
success = `Record created: ${result.uri}`
192
+
await loadCollections()
193
+
await selectCollection(newCollection.trim())
194
+
} catch (e) {
195
+
setError(e)
196
+
} finally {
197
+
saving = false
198
+
}
199
+
}
200
+
201
+
async function handleUpdate(e: Event) {
202
+
e.preventDefault()
203
+
if (!auth.session || !selectedRecord || !selectedCollection) return
204
+
205
+
const record = validateJson()
206
+
if (!record) return
207
+
208
+
saving = true
209
+
error = null
210
+
211
+
try {
212
+
await api.putRecord(
213
+
auth.session.accessJwt,
214
+
auth.session.did,
215
+
selectedCollection,
216
+
selectedRecord.rkey,
217
+
record
218
+
)
219
+
success = 'Record updated'
220
+
const updated = await api.getRecord(
221
+
auth.session.accessJwt,
222
+
auth.session.did,
223
+
selectedCollection,
224
+
selectedRecord.rkey
225
+
)
226
+
selectedRecord = { ...updated, rkey: selectedRecord.rkey }
227
+
recordJson = JSON.stringify(updated.value, null, 2)
228
+
} catch (e) {
229
+
setError(e)
230
+
} finally {
231
+
saving = false
232
+
}
233
+
}
234
+
235
+
async function handleDelete() {
236
+
if (!auth.session || !selectedRecord || !selectedCollection) return
237
+
if (!confirm(`Delete record ${selectedRecord.rkey}? This cannot be undone.`)) return
238
+
239
+
saving = true
240
+
error = null
241
+
242
+
try {
243
+
await api.deleteRecord(
244
+
auth.session.accessJwt,
245
+
auth.session.did,
246
+
selectedCollection,
247
+
selectedRecord.rkey
248
+
)
249
+
success = 'Record deleted'
250
+
selectedRecord = null
251
+
await selectCollection(selectedCollection)
252
+
} catch (e) {
253
+
setError(e)
254
+
} finally {
255
+
saving = false
256
+
}
257
+
}
258
+
259
+
function goBack() {
260
+
if (view === 'record' || view === 'create') {
261
+
if (selectedCollection) {
262
+
view = 'records'
263
+
} else {
264
+
view = 'collections'
265
+
}
266
+
} else if (view === 'records') {
267
+
selectedCollection = null
268
+
view = 'collections'
269
+
}
270
+
error = null
271
+
success = null
272
+
}
273
+
274
+
let filteredCollections = $derived(
275
+
filter
276
+
? collections.filter(c => c.toLowerCase().includes(filter.toLowerCase()))
277
+
: collections
278
+
)
279
+
280
+
let filteredRecords = $derived(
281
+
filter
282
+
? records.filter(r =>
283
+
r.rkey.toLowerCase().includes(filter.toLowerCase()) ||
284
+
JSON.stringify(r.value).toLowerCase().includes(filter.toLowerCase())
285
+
)
286
+
: records
287
+
)
288
+
289
+
function groupCollectionsByAuthority(cols: string[]): Map<string, string[]> {
290
+
const groups = new Map<string, string[]>()
291
+
for (const col of cols) {
292
+
const parts = col.split('.')
293
+
const authority = parts.slice(0, -1).join('.')
294
+
const name = parts[parts.length - 1]
295
+
if (!groups.has(authority)) {
296
+
groups.set(authority, [])
297
+
}
298
+
groups.get(authority)!.push(name)
299
+
}
300
+
return groups
301
+
}
302
+
303
+
let groupedCollections = $derived(groupCollectionsByAuthority(filteredCollections))
304
+
</script>
305
+
306
+
<div class="page">
307
+
<header>
308
+
<div class="breadcrumb">
309
+
<a href="#/dashboard" class="back">← Dashboard</a>
310
+
{#if view !== 'collections'}
311
+
<span class="sep">/</span>
312
+
<button class="breadcrumb-link" onclick={goBack}>
313
+
{view === 'records' || view === 'create' ? 'Collections' : selectedCollection}
314
+
</button>
315
+
{/if}
316
+
{#if view === 'record' && selectedRecord}
317
+
<span class="sep">/</span>
318
+
<span class="current">{selectedRecord.rkey}</span>
319
+
{/if}
320
+
{#if view === 'create'}
321
+
<span class="sep">/</span>
322
+
<span class="current">New Record</span>
323
+
{/if}
324
+
</div>
325
+
<h1>
326
+
{#if view === 'collections'}
327
+
Repository Explorer
328
+
{:else if view === 'records'}
329
+
{selectedCollection}
330
+
{:else if view === 'record'}
331
+
Record Detail
332
+
{:else}
333
+
Create Record
334
+
{/if}
335
+
</h1>
336
+
{#if auth.session}
337
+
<p class="did">{auth.session.did}</p>
338
+
{/if}
339
+
</header>
340
+
341
+
{#if error}
342
+
<div class="message error">
343
+
{#if error.code}
344
+
<strong class="error-code">{error.code}</strong>
345
+
{/if}
346
+
<span class="error-message">{error.message}</span>
347
+
</div>
348
+
{/if}
349
+
350
+
{#if success}
351
+
<div class="message success">{success}</div>
352
+
{/if}
353
+
354
+
{#if loading}
355
+
<p class="loading-text">Loading...</p>
356
+
{:else if view === 'collections'}
357
+
<div class="toolbar">
358
+
<input
359
+
type="text"
360
+
placeholder="Filter collections..."
361
+
bind:value={filter}
362
+
class="filter-input"
363
+
/>
364
+
<button class="primary" onclick={() => startCreate()}>Create Record</button>
365
+
</div>
366
+
367
+
{#if collections.length === 0}
368
+
<p class="empty">No collections yet. Create your first record to get started.</p>
369
+
{:else}
370
+
<div class="collections">
371
+
{#each [...groupedCollections.entries()] as [authority, nsids]}
372
+
<div class="collection-group">
373
+
<h3 class="authority">{authority}</h3>
374
+
<ul class="nsid-list">
375
+
{#each nsids as nsid}
376
+
<li>
377
+
<button class="collection-link" onclick={() => selectCollection(`${authority}.${nsid}`)}>
378
+
<span class="nsid">{nsid}</span>
379
+
<span class="arrow">→</span>
380
+
</button>
381
+
</li>
382
+
{/each}
383
+
</ul>
384
+
</div>
385
+
{/each}
386
+
</div>
387
+
{/if}
388
+
389
+
{:else if view === 'records'}
390
+
<div class="toolbar">
391
+
<input
392
+
type="text"
393
+
placeholder="Filter records..."
394
+
bind:value={filter}
395
+
class="filter-input"
396
+
/>
397
+
<button class="primary" onclick={() => startCreate(selectedCollection!)}>Create Record</button>
398
+
</div>
399
+
400
+
{#if records.length === 0}
401
+
<p class="empty">No records in this collection.</p>
402
+
{:else}
403
+
<ul class="record-list">
404
+
{#each filteredRecords as record}
405
+
<li>
406
+
<button class="record-item" onclick={() => selectRecord(record)}>
407
+
<div class="record-info">
408
+
<span class="rkey">{record.rkey}</span>
409
+
<span class="cid" title={record.cid}>{record.cid.slice(0, 12)}...</span>
410
+
</div>
411
+
<pre class="record-preview">{JSON.stringify(record.value, null, 2).slice(0, 200)}{JSON.stringify(record.value).length > 200 ? '...' : ''}</pre>
412
+
</button>
413
+
</li>
414
+
{/each}
415
+
</ul>
416
+
417
+
{#if recordsCursor}
418
+
<div class="load-more">
419
+
<button onclick={loadMoreRecords} disabled={loadingMore}>
420
+
{loadingMore ? 'Loading...' : 'Load More'}
421
+
</button>
422
+
</div>
423
+
{/if}
424
+
{/if}
425
+
426
+
{:else if view === 'record' && selectedRecord}
427
+
<div class="record-detail">
428
+
<div class="record-meta">
429
+
<dl>
430
+
<dt>URI</dt>
431
+
<dd class="mono">{selectedRecord.uri}</dd>
432
+
<dt>CID</dt>
433
+
<dd class="mono">{selectedRecord.cid}</dd>
434
+
</dl>
435
+
</div>
436
+
437
+
<form onsubmit={handleUpdate}>
438
+
<div class="editor-container">
439
+
<label for="record-json">Record JSON</label>
440
+
<textarea
441
+
id="record-json"
442
+
bind:value={recordJson}
443
+
oninput={() => validateJson()}
444
+
class:has-error={jsonError}
445
+
spellcheck="false"
446
+
></textarea>
447
+
{#if jsonError}
448
+
<p class="json-error">{jsonError}</p>
449
+
{/if}
450
+
</div>
451
+
452
+
<div class="actions">
453
+
<button type="submit" class="primary" disabled={saving || !!jsonError}>
454
+
{saving ? 'Saving...' : 'Update Record'}
455
+
</button>
456
+
<button type="button" class="danger" onclick={handleDelete} disabled={saving}>
457
+
Delete
458
+
</button>
459
+
</div>
460
+
</form>
461
+
</div>
462
+
463
+
{:else if view === 'create'}
464
+
<form class="create-form" onsubmit={handleCreate}>
465
+
<div class="field">
466
+
<label for="collection">Collection (NSID)</label>
467
+
<input
468
+
id="collection"
469
+
type="text"
470
+
bind:value={newCollection}
471
+
placeholder="app.bsky.feed.post"
472
+
disabled={saving}
473
+
required
474
+
/>
475
+
</div>
476
+
477
+
<div class="field">
478
+
<label for="rkey">Record Key (optional)</label>
479
+
<input
480
+
id="rkey"
481
+
type="text"
482
+
bind:value={newRkey}
483
+
placeholder="Auto-generated if empty (TID)"
484
+
disabled={saving}
485
+
/>
486
+
<p class="hint">Leave empty to auto-generate a TID-based key</p>
487
+
</div>
488
+
489
+
<div class="editor-container">
490
+
<label for="new-record-json">Record JSON</label>
491
+
<textarea
492
+
id="new-record-json"
493
+
bind:value={recordJson}
494
+
oninput={() => validateJson()}
495
+
class:has-error={jsonError}
496
+
spellcheck="false"
497
+
></textarea>
498
+
{#if jsonError}
499
+
<p class="json-error">{jsonError}</p>
500
+
{/if}
501
+
</div>
502
+
503
+
<div class="actions">
504
+
<button type="submit" class="primary" disabled={saving || !!jsonError || !newCollection.trim()}>
505
+
{saving ? 'Creating...' : 'Create Record'}
506
+
</button>
507
+
<button type="button" class="secondary" onclick={goBack}>
508
+
Cancel
509
+
</button>
510
+
</div>
511
+
</form>
512
+
{/if}
513
+
</div>
514
+
515
+
<style>
516
+
.page {
517
+
max-width: 900px;
518
+
margin: 0 auto;
519
+
padding: 2rem;
520
+
}
521
+
522
+
header {
523
+
margin-bottom: 1.5rem;
524
+
}
525
+
526
+
.breadcrumb {
527
+
display: flex;
528
+
align-items: center;
529
+
gap: 0.5rem;
530
+
font-size: 0.875rem;
531
+
margin-bottom: 0.5rem;
532
+
}
533
+
534
+
.back {
535
+
color: var(--text-secondary);
536
+
text-decoration: none;
537
+
}
538
+
539
+
.back:hover {
540
+
color: var(--accent);
541
+
}
542
+
543
+
.sep {
544
+
color: var(--text-muted);
545
+
}
546
+
547
+
.breadcrumb-link {
548
+
background: none;
549
+
border: none;
550
+
padding: 0;
551
+
color: var(--accent);
552
+
cursor: pointer;
553
+
font-size: inherit;
554
+
}
555
+
556
+
.breadcrumb-link:hover {
557
+
text-decoration: underline;
558
+
}
559
+
560
+
.current {
561
+
color: var(--text-secondary);
562
+
}
563
+
564
+
h1 {
565
+
margin: 0;
566
+
font-size: 1.5rem;
567
+
}
568
+
569
+
.did {
570
+
margin: 0.25rem 0 0 0;
571
+
font-family: monospace;
572
+
font-size: 0.75rem;
573
+
color: var(--text-muted);
574
+
word-break: break-all;
575
+
}
576
+
577
+
.message {
578
+
padding: 1rem;
579
+
border-radius: 8px;
580
+
margin-bottom: 1rem;
581
+
}
582
+
583
+
.message.error {
584
+
background: var(--error-bg);
585
+
border: 1px solid var(--error-border);
586
+
color: var(--error-text);
587
+
display: flex;
588
+
flex-direction: column;
589
+
gap: 0.25rem;
590
+
}
591
+
592
+
.error-code {
593
+
font-family: monospace;
594
+
font-size: 0.875rem;
595
+
opacity: 0.9;
596
+
}
597
+
598
+
.error-message {
599
+
font-size: 0.9375rem;
600
+
line-height: 1.5;
601
+
}
602
+
603
+
.message.success {
604
+
background: var(--success-bg);
605
+
border: 1px solid var(--success-border);
606
+
color: var(--success-text);
607
+
}
608
+
609
+
.loading-text {
610
+
text-align: center;
611
+
color: var(--text-secondary);
612
+
padding: 2rem;
613
+
}
614
+
615
+
.toolbar {
616
+
display: flex;
617
+
gap: 0.5rem;
618
+
margin-bottom: 1rem;
619
+
}
620
+
621
+
.filter-input {
622
+
flex: 1;
623
+
padding: 0.5rem 0.75rem;
624
+
border: 1px solid var(--border-color-light);
625
+
border-radius: 4px;
626
+
font-size: 0.875rem;
627
+
background: var(--bg-input);
628
+
color: var(--text-primary);
629
+
}
630
+
631
+
.filter-input:focus {
632
+
outline: none;
633
+
border-color: var(--accent);
634
+
}
635
+
636
+
button.primary {
637
+
padding: 0.5rem 1rem;
638
+
background: var(--accent);
639
+
color: white;
640
+
border: none;
641
+
border-radius: 4px;
642
+
cursor: pointer;
643
+
font-size: 0.875rem;
644
+
}
645
+
646
+
button.primary:hover:not(:disabled) {
647
+
background: var(--accent-hover);
648
+
}
649
+
650
+
button.primary:disabled {
651
+
opacity: 0.6;
652
+
cursor: not-allowed;
653
+
}
654
+
655
+
button.secondary {
656
+
padding: 0.5rem 1rem;
657
+
background: transparent;
658
+
color: var(--text-secondary);
659
+
border: 1px solid var(--border-color-light);
660
+
border-radius: 4px;
661
+
cursor: pointer;
662
+
font-size: 0.875rem;
663
+
}
664
+
665
+
button.secondary:hover:not(:disabled) {
666
+
background: var(--bg-secondary);
667
+
}
668
+
669
+
button.danger {
670
+
padding: 0.5rem 1rem;
671
+
background: transparent;
672
+
color: var(--error-text);
673
+
border: 1px solid var(--error-text);
674
+
border-radius: 4px;
675
+
cursor: pointer;
676
+
font-size: 0.875rem;
677
+
}
678
+
679
+
button.danger:hover:not(:disabled) {
680
+
background: var(--error-bg);
681
+
}
682
+
683
+
.empty {
684
+
text-align: center;
685
+
color: var(--text-secondary);
686
+
padding: 3rem;
687
+
background: var(--bg-secondary);
688
+
border-radius: 8px;
689
+
}
690
+
691
+
.collections {
692
+
display: flex;
693
+
flex-direction: column;
694
+
gap: 1rem;
695
+
}
696
+
697
+
.collection-group {
698
+
background: var(--bg-secondary);
699
+
border-radius: 8px;
700
+
padding: 1rem;
701
+
}
702
+
703
+
.authority {
704
+
margin: 0 0 0.75rem 0;
705
+
font-size: 0.875rem;
706
+
color: var(--text-secondary);
707
+
font-weight: 500;
708
+
}
709
+
710
+
.nsid-list {
711
+
list-style: none;
712
+
padding: 0;
713
+
margin: 0;
714
+
display: flex;
715
+
flex-direction: column;
716
+
gap: 0.25rem;
717
+
}
718
+
719
+
.collection-link {
720
+
display: flex;
721
+
justify-content: space-between;
722
+
align-items: center;
723
+
width: 100%;
724
+
padding: 0.75rem;
725
+
background: var(--bg-card);
726
+
border: 1px solid var(--border-color);
727
+
border-radius: 4px;
728
+
cursor: pointer;
729
+
text-align: left;
730
+
color: var(--text-primary);
731
+
transition: border-color 0.15s;
732
+
}
733
+
734
+
.collection-link:hover {
735
+
border-color: var(--accent);
736
+
}
737
+
738
+
.nsid {
739
+
font-weight: 500;
740
+
color: var(--accent);
741
+
}
742
+
743
+
.arrow {
744
+
color: var(--text-muted);
745
+
}
746
+
747
+
.record-list {
748
+
list-style: none;
749
+
padding: 0;
750
+
margin: 0;
751
+
display: flex;
752
+
flex-direction: column;
753
+
gap: 0.5rem;
754
+
}
755
+
756
+
.record-item {
757
+
display: block;
758
+
width: 100%;
759
+
padding: 1rem;
760
+
background: var(--bg-card);
761
+
border: 1px solid var(--border-color);
762
+
border-radius: 4px;
763
+
cursor: pointer;
764
+
text-align: left;
765
+
color: var(--text-primary);
766
+
transition: border-color 0.15s;
767
+
}
768
+
769
+
.record-item:hover {
770
+
border-color: var(--accent);
771
+
}
772
+
773
+
.record-info {
774
+
display: flex;
775
+
justify-content: space-between;
776
+
margin-bottom: 0.5rem;
777
+
}
778
+
779
+
.rkey {
780
+
font-family: monospace;
781
+
font-weight: 500;
782
+
color: var(--accent);
783
+
}
784
+
785
+
.cid {
786
+
font-family: monospace;
787
+
font-size: 0.75rem;
788
+
color: var(--text-muted);
789
+
}
790
+
791
+
.record-preview {
792
+
margin: 0;
793
+
padding: 0.5rem;
794
+
background: var(--bg-secondary);
795
+
border-radius: 4px;
796
+
font-family: monospace;
797
+
font-size: 0.75rem;
798
+
color: var(--text-secondary);
799
+
white-space: pre-wrap;
800
+
word-break: break-word;
801
+
max-height: 100px;
802
+
overflow: hidden;
803
+
}
804
+
805
+
.load-more {
806
+
text-align: center;
807
+
padding: 1rem;
808
+
}
809
+
810
+
.load-more button {
811
+
padding: 0.5rem 2rem;
812
+
background: var(--bg-secondary);
813
+
border: 1px solid var(--border-color);
814
+
border-radius: 4px;
815
+
cursor: pointer;
816
+
color: var(--text-primary);
817
+
}
818
+
819
+
.load-more button:hover:not(:disabled) {
820
+
background: var(--bg-card);
821
+
}
822
+
823
+
.record-detail {
824
+
display: flex;
825
+
flex-direction: column;
826
+
gap: 1.5rem;
827
+
}
828
+
829
+
.record-meta {
830
+
background: var(--bg-secondary);
831
+
padding: 1rem;
832
+
border-radius: 8px;
833
+
}
834
+
835
+
.record-meta dl {
836
+
display: grid;
837
+
grid-template-columns: auto 1fr;
838
+
gap: 0.5rem 1rem;
839
+
margin: 0;
840
+
}
841
+
842
+
.record-meta dt {
843
+
font-weight: 500;
844
+
color: var(--text-secondary);
845
+
}
846
+
847
+
.record-meta dd {
848
+
margin: 0;
849
+
}
850
+
851
+
.mono {
852
+
font-family: monospace;
853
+
font-size: 0.75rem;
854
+
word-break: break-all;
855
+
}
856
+
857
+
.field {
858
+
margin-bottom: 1rem;
859
+
}
860
+
861
+
.field label {
862
+
display: block;
863
+
font-size: 0.875rem;
864
+
font-weight: 500;
865
+
margin-bottom: 0.25rem;
866
+
}
867
+
868
+
.field input {
869
+
width: 100%;
870
+
padding: 0.75rem;
871
+
border: 1px solid var(--border-color-light);
872
+
border-radius: 4px;
873
+
font-size: 1rem;
874
+
background: var(--bg-input);
875
+
color: var(--text-primary);
876
+
box-sizing: border-box;
877
+
}
878
+
879
+
.field input:focus {
880
+
outline: none;
881
+
border-color: var(--accent);
882
+
}
883
+
884
+
.hint {
885
+
font-size: 0.75rem;
886
+
color: var(--text-muted);
887
+
margin: 0.25rem 0 0 0;
888
+
}
889
+
890
+
.editor-container {
891
+
margin-bottom: 1rem;
892
+
}
893
+
894
+
.editor-container label {
895
+
display: block;
896
+
font-size: 0.875rem;
897
+
font-weight: 500;
898
+
margin-bottom: 0.25rem;
899
+
}
900
+
901
+
textarea {
902
+
width: 100%;
903
+
min-height: 300px;
904
+
padding: 1rem;
905
+
border: 1px solid var(--border-color-light);
906
+
border-radius: 4px;
907
+
font-family: monospace;
908
+
font-size: 0.875rem;
909
+
background: var(--bg-input);
910
+
color: var(--text-primary);
911
+
resize: vertical;
912
+
box-sizing: border-box;
913
+
}
914
+
915
+
textarea:focus {
916
+
outline: none;
917
+
border-color: var(--accent);
918
+
}
919
+
920
+
textarea.has-error {
921
+
border-color: var(--error-text);
922
+
}
923
+
924
+
.json-error {
925
+
margin: 0.25rem 0 0 0;
926
+
font-size: 0.75rem;
927
+
color: var(--error-text);
928
+
}
929
+
930
+
.actions {
931
+
display: flex;
932
+
gap: 0.5rem;
933
+
}
934
+
935
+
.create-form {
936
+
background: var(--bg-secondary);
937
+
padding: 1.5rem;
938
+
border-radius: 8px;
939
+
}
940
+
</style>
+409
frontend/src/routes/Settings.svelte
+409
frontend/src/routes/Settings.svelte
···
1
+
<script lang="ts">
2
+
import { getAuthState, logout } from '../lib/auth.svelte'
3
+
import { navigate } from '../lib/router.svelte'
4
+
import { api, ApiError } from '../lib/api'
5
+
6
+
const auth = getAuthState()
7
+
8
+
let message = $state<{ type: 'success' | 'error'; text: string } | null>(null)
9
+
10
+
let emailLoading = $state(false)
11
+
let newEmail = $state('')
12
+
let emailToken = $state('')
13
+
let emailTokenRequired = $state(false)
14
+
15
+
let handleLoading = $state(false)
16
+
let newHandle = $state('')
17
+
18
+
let deleteLoading = $state(false)
19
+
let deletePassword = $state('')
20
+
let deleteToken = $state('')
21
+
let deleteTokenSent = $state(false)
22
+
23
+
$effect(() => {
24
+
if (!auth.loading && !auth.session) {
25
+
navigate('/login')
26
+
}
27
+
})
28
+
29
+
function showMessage(type: 'success' | 'error', text: string) {
30
+
message = { type, text }
31
+
setTimeout(() => {
32
+
if (message?.text === text) message = null
33
+
}, 5000)
34
+
}
35
+
36
+
async function handleRequestEmailUpdate(e: Event) {
37
+
e.preventDefault()
38
+
if (!auth.session || !newEmail) return
39
+
40
+
emailLoading = true
41
+
message = null
42
+
43
+
try {
44
+
const result = await api.requestEmailUpdate(auth.session.accessJwt)
45
+
emailTokenRequired = result.tokenRequired
46
+
if (emailTokenRequired) {
47
+
showMessage('success', 'Verification code sent to your current email')
48
+
} else {
49
+
await api.updateEmail(auth.session.accessJwt, newEmail)
50
+
showMessage('success', 'Email updated successfully')
51
+
newEmail = ''
52
+
}
53
+
} catch (e) {
54
+
showMessage('error', e instanceof ApiError ? e.message : 'Failed to update email')
55
+
} finally {
56
+
emailLoading = false
57
+
}
58
+
}
59
+
60
+
async function handleConfirmEmailUpdate(e: Event) {
61
+
e.preventDefault()
62
+
if (!auth.session || !newEmail || !emailToken) return
63
+
64
+
emailLoading = true
65
+
message = null
66
+
67
+
try {
68
+
await api.updateEmail(auth.session.accessJwt, newEmail, emailToken)
69
+
showMessage('success', 'Email updated successfully')
70
+
newEmail = ''
71
+
emailToken = ''
72
+
emailTokenRequired = false
73
+
} catch (e) {
74
+
showMessage('error', e instanceof ApiError ? e.message : 'Failed to update email')
75
+
} finally {
76
+
emailLoading = false
77
+
}
78
+
}
79
+
80
+
async function handleUpdateHandle(e: Event) {
81
+
e.preventDefault()
82
+
if (!auth.session || !newHandle) return
83
+
84
+
handleLoading = true
85
+
message = null
86
+
87
+
try {
88
+
await api.updateHandle(auth.session.accessJwt, newHandle)
89
+
showMessage('success', 'Handle updated successfully')
90
+
newHandle = ''
91
+
} catch (e) {
92
+
showMessage('error', e instanceof ApiError ? e.message : 'Failed to update handle')
93
+
} finally {
94
+
handleLoading = false
95
+
}
96
+
}
97
+
98
+
async function handleRequestDelete() {
99
+
if (!auth.session) return
100
+
101
+
deleteLoading = true
102
+
message = null
103
+
104
+
try {
105
+
await api.requestAccountDelete(auth.session.accessJwt)
106
+
deleteTokenSent = true
107
+
showMessage('success', 'Deletion confirmation sent to your email')
108
+
} catch (e) {
109
+
showMessage('error', e instanceof ApiError ? e.message : 'Failed to request deletion')
110
+
} finally {
111
+
deleteLoading = false
112
+
}
113
+
}
114
+
115
+
async function handleConfirmDelete(e: Event) {
116
+
e.preventDefault()
117
+
if (!auth.session || !deletePassword || !deleteToken) return
118
+
119
+
if (!confirm('Are you absolutely sure you want to delete your account? This cannot be undone.')) {
120
+
return
121
+
}
122
+
123
+
deleteLoading = true
124
+
message = null
125
+
126
+
try {
127
+
await api.deleteAccount(auth.session.did, deletePassword, deleteToken)
128
+
await logout()
129
+
navigate('/login')
130
+
} catch (e) {
131
+
showMessage('error', e instanceof ApiError ? e.message : 'Failed to delete account')
132
+
} finally {
133
+
deleteLoading = false
134
+
}
135
+
}
136
+
</script>
137
+
138
+
<div class="page">
139
+
<header>
140
+
<a href="#/dashboard" class="back">← Dashboard</a>
141
+
<h1>Account Settings</h1>
142
+
</header>
143
+
144
+
{#if message}
145
+
<div class="message {message.type}">{message.text}</div>
146
+
{/if}
147
+
148
+
<section>
149
+
<h2>Change Email</h2>
150
+
{#if auth.session?.email}
151
+
<p class="current">Current: {auth.session.email}</p>
152
+
{/if}
153
+
154
+
{#if emailTokenRequired}
155
+
<form onsubmit={handleConfirmEmailUpdate}>
156
+
<div class="field">
157
+
<label for="email-token">Verification Code</label>
158
+
<input
159
+
id="email-token"
160
+
type="text"
161
+
bind:value={emailToken}
162
+
placeholder="Enter code from email"
163
+
disabled={emailLoading}
164
+
required
165
+
/>
166
+
</div>
167
+
<div class="actions">
168
+
<button type="submit" disabled={emailLoading || !emailToken}>
169
+
{emailLoading ? 'Updating...' : 'Confirm Email Change'}
170
+
</button>
171
+
<button type="button" class="secondary" onclick={() => { emailTokenRequired = false; emailToken = '' }}>
172
+
Cancel
173
+
</button>
174
+
</div>
175
+
</form>
176
+
{:else}
177
+
<form onsubmit={handleRequestEmailUpdate}>
178
+
<div class="field">
179
+
<label for="new-email">New Email</label>
180
+
<input
181
+
id="new-email"
182
+
type="email"
183
+
bind:value={newEmail}
184
+
placeholder="new@example.com"
185
+
disabled={emailLoading}
186
+
required
187
+
/>
188
+
</div>
189
+
<button type="submit" disabled={emailLoading || !newEmail}>
190
+
{emailLoading ? 'Requesting...' : 'Change Email'}
191
+
</button>
192
+
</form>
193
+
{/if}
194
+
</section>
195
+
196
+
<section>
197
+
<h2>Change Handle</h2>
198
+
{#if auth.session}
199
+
<p class="current">Current: @{auth.session.handle}</p>
200
+
{/if}
201
+
202
+
<form onsubmit={handleUpdateHandle}>
203
+
<div class="field">
204
+
<label for="new-handle">New Handle</label>
205
+
<input
206
+
id="new-handle"
207
+
type="text"
208
+
bind:value={newHandle}
209
+
placeholder="newhandle.bsky.social"
210
+
disabled={handleLoading}
211
+
required
212
+
/>
213
+
</div>
214
+
<button type="submit" disabled={handleLoading || !newHandle}>
215
+
{handleLoading ? 'Updating...' : 'Change Handle'}
216
+
</button>
217
+
</form>
218
+
</section>
219
+
220
+
<section class="danger-zone">
221
+
<h2>Delete Account</h2>
222
+
<p class="warning">This action is irreversible. All your data will be permanently deleted.</p>
223
+
224
+
{#if deleteTokenSent}
225
+
<form onsubmit={handleConfirmDelete}>
226
+
<div class="field">
227
+
<label for="delete-token">Confirmation Code (from email)</label>
228
+
<input
229
+
id="delete-token"
230
+
type="text"
231
+
bind:value={deleteToken}
232
+
placeholder="Enter confirmation code"
233
+
disabled={deleteLoading}
234
+
required
235
+
/>
236
+
</div>
237
+
<div class="field">
238
+
<label for="delete-password">Your Password</label>
239
+
<input
240
+
id="delete-password"
241
+
type="password"
242
+
bind:value={deletePassword}
243
+
placeholder="Enter your password"
244
+
disabled={deleteLoading}
245
+
required
246
+
/>
247
+
</div>
248
+
<div class="actions">
249
+
<button type="submit" class="danger" disabled={deleteLoading || !deleteToken || !deletePassword}>
250
+
{deleteLoading ? 'Deleting...' : 'Permanently Delete Account'}
251
+
</button>
252
+
<button type="button" class="secondary" onclick={() => { deleteTokenSent = false; deleteToken = ''; deletePassword = '' }}>
253
+
Cancel
254
+
</button>
255
+
</div>
256
+
</form>
257
+
{:else}
258
+
<button class="danger" onclick={handleRequestDelete} disabled={deleteLoading}>
259
+
{deleteLoading ? 'Requesting...' : 'Request Account Deletion'}
260
+
</button>
261
+
{/if}
262
+
</section>
263
+
</div>
264
+
265
+
<style>
266
+
.page {
267
+
max-width: 600px;
268
+
margin: 0 auto;
269
+
padding: 2rem;
270
+
}
271
+
272
+
header {
273
+
margin-bottom: 2rem;
274
+
}
275
+
276
+
.back {
277
+
color: var(--text-secondary);
278
+
text-decoration: none;
279
+
font-size: 0.875rem;
280
+
}
281
+
282
+
.back:hover {
283
+
color: var(--accent);
284
+
}
285
+
286
+
h1 {
287
+
margin: 0.5rem 0 0 0;
288
+
}
289
+
290
+
.message {
291
+
padding: 0.75rem;
292
+
border-radius: 4px;
293
+
margin-bottom: 1rem;
294
+
}
295
+
296
+
.message.success {
297
+
background: var(--success-bg);
298
+
border: 1px solid var(--success-border);
299
+
color: var(--success-text);
300
+
}
301
+
302
+
.message.error {
303
+
background: var(--error-bg);
304
+
border: 1px solid var(--error-border);
305
+
color: var(--error-text);
306
+
}
307
+
308
+
section {
309
+
padding: 1.5rem;
310
+
background: var(--bg-secondary);
311
+
border-radius: 8px;
312
+
margin-bottom: 1.5rem;
313
+
}
314
+
315
+
section h2 {
316
+
margin: 0 0 0.5rem 0;
317
+
font-size: 1.125rem;
318
+
}
319
+
320
+
.current {
321
+
color: var(--text-secondary);
322
+
font-size: 0.875rem;
323
+
margin-bottom: 1rem;
324
+
}
325
+
326
+
.field {
327
+
margin-bottom: 1rem;
328
+
}
329
+
330
+
label {
331
+
display: block;
332
+
font-size: 0.875rem;
333
+
font-weight: 500;
334
+
margin-bottom: 0.25rem;
335
+
}
336
+
337
+
input {
338
+
width: 100%;
339
+
padding: 0.75rem;
340
+
border: 1px solid var(--border-color-light);
341
+
border-radius: 4px;
342
+
font-size: 1rem;
343
+
box-sizing: border-box;
344
+
background: var(--bg-input);
345
+
color: var(--text-primary);
346
+
}
347
+
348
+
input:focus {
349
+
outline: none;
350
+
border-color: var(--accent);
351
+
}
352
+
353
+
button {
354
+
padding: 0.75rem 1.5rem;
355
+
background: var(--accent);
356
+
color: white;
357
+
border: none;
358
+
border-radius: 4px;
359
+
cursor: pointer;
360
+
font-size: 1rem;
361
+
}
362
+
363
+
button:hover:not(:disabled) {
364
+
background: var(--accent-hover);
365
+
}
366
+
367
+
button:disabled {
368
+
opacity: 0.6;
369
+
cursor: not-allowed;
370
+
}
371
+
372
+
button.secondary {
373
+
background: transparent;
374
+
color: var(--text-secondary);
375
+
border: 1px solid var(--border-color-light);
376
+
}
377
+
378
+
button.secondary:hover:not(:disabled) {
379
+
background: var(--bg-secondary);
380
+
}
381
+
382
+
button.danger {
383
+
background: var(--error-text);
384
+
}
385
+
386
+
button.danger:hover:not(:disabled) {
387
+
background: #900;
388
+
}
389
+
390
+
.actions {
391
+
display: flex;
392
+
gap: 0.5rem;
393
+
}
394
+
395
+
.danger-zone {
396
+
background: var(--error-bg);
397
+
border: 1px solid var(--error-border);
398
+
}
399
+
400
+
.danger-zone h2 {
401
+
color: var(--error-text);
402
+
}
403
+
404
+
.warning {
405
+
color: var(--error-text);
406
+
font-size: 0.875rem;
407
+
margin-bottom: 1rem;
408
+
}
409
+
</style>
+453
frontend/src/tests/AppPasswords.test.ts
+453
frontend/src/tests/AppPasswords.test.ts
···
1
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'
3
+
import AppPasswords from '../routes/AppPasswords.svelte'
4
+
import {
5
+
setupFetchMock,
6
+
mockEndpoint,
7
+
jsonResponse,
8
+
errorResponse,
9
+
mockData,
10
+
clearMocks,
11
+
setupAuthenticatedUser,
12
+
setupUnauthenticatedUser,
13
+
} from './mocks'
14
+
15
+
describe('AppPasswords', () => {
16
+
beforeEach(() => {
17
+
clearMocks()
18
+
setupFetchMock()
19
+
window.confirm = vi.fn(() => true)
20
+
})
21
+
22
+
describe('authentication guard', () => {
23
+
it('redirects to login when not authenticated', async () => {
24
+
setupUnauthenticatedUser()
25
+
render(AppPasswords)
26
+
27
+
await waitFor(() => {
28
+
expect(window.location.hash).toBe('#/login')
29
+
})
30
+
})
31
+
})
32
+
33
+
describe('page structure', () => {
34
+
beforeEach(() => {
35
+
setupAuthenticatedUser()
36
+
mockEndpoint('com.atproto.server.listAppPasswords', () =>
37
+
jsonResponse({ passwords: [] })
38
+
)
39
+
})
40
+
41
+
it('displays all page elements', async () => {
42
+
render(AppPasswords)
43
+
44
+
await waitFor(() => {
45
+
expect(screen.getByRole('heading', { name: /app passwords/i, level: 1 })).toBeInTheDocument()
46
+
expect(screen.getByRole('link', { name: /dashboard/i })).toHaveAttribute('href', '#/dashboard')
47
+
expect(screen.getByText(/third-party apps/i)).toBeInTheDocument()
48
+
})
49
+
})
50
+
})
51
+
52
+
describe('loading state', () => {
53
+
beforeEach(() => {
54
+
setupAuthenticatedUser()
55
+
})
56
+
57
+
it('shows loading text while fetching passwords', async () => {
58
+
mockEndpoint('com.atproto.server.listAppPasswords', async () => {
59
+
await new Promise(resolve => setTimeout(resolve, 100))
60
+
return jsonResponse({ passwords: [] })
61
+
})
62
+
63
+
render(AppPasswords)
64
+
65
+
expect(screen.getByText(/loading/i)).toBeInTheDocument()
66
+
})
67
+
})
68
+
69
+
describe('empty state', () => {
70
+
beforeEach(() => {
71
+
setupAuthenticatedUser()
72
+
mockEndpoint('com.atproto.server.listAppPasswords', () =>
73
+
jsonResponse({ passwords: [] })
74
+
)
75
+
})
76
+
77
+
it('shows empty message when no passwords exist', async () => {
78
+
render(AppPasswords)
79
+
80
+
await waitFor(() => {
81
+
expect(screen.getByText(/no app passwords yet/i)).toBeInTheDocument()
82
+
})
83
+
})
84
+
})
85
+
86
+
describe('password list', () => {
87
+
const testPasswords = [
88
+
mockData.appPassword({ name: 'Graysky', createdAt: '2024-01-15T10:00:00Z' }),
89
+
mockData.appPassword({ name: 'Skeets', createdAt: '2024-02-20T15:30:00Z' }),
90
+
]
91
+
92
+
beforeEach(() => {
93
+
setupAuthenticatedUser()
94
+
mockEndpoint('com.atproto.server.listAppPasswords', () =>
95
+
jsonResponse({ passwords: testPasswords })
96
+
)
97
+
})
98
+
99
+
it('displays all app passwords with dates and revoke buttons', async () => {
100
+
render(AppPasswords)
101
+
102
+
await waitFor(() => {
103
+
expect(screen.getByText('Graysky')).toBeInTheDocument()
104
+
expect(screen.getByText('Skeets')).toBeInTheDocument()
105
+
expect(screen.getByText(/created.*1\/15\/2024/i)).toBeInTheDocument()
106
+
expect(screen.getByText(/created.*2\/20\/2024/i)).toBeInTheDocument()
107
+
expect(screen.getAllByRole('button', { name: /revoke/i })).toHaveLength(2)
108
+
})
109
+
})
110
+
})
111
+
112
+
describe('create app password', () => {
113
+
beforeEach(() => {
114
+
setupAuthenticatedUser()
115
+
mockEndpoint('com.atproto.server.listAppPasswords', () =>
116
+
jsonResponse({ passwords: [] })
117
+
)
118
+
})
119
+
120
+
it('displays create form with input and button', async () => {
121
+
render(AppPasswords)
122
+
123
+
await waitFor(() => {
124
+
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
125
+
expect(screen.getByRole('button', { name: /create/i })).toBeInTheDocument()
126
+
})
127
+
})
128
+
129
+
it('disables create button when input is empty', async () => {
130
+
render(AppPasswords)
131
+
132
+
await waitFor(() => {
133
+
expect(screen.getByRole('button', { name: /create/i })).toBeDisabled()
134
+
})
135
+
})
136
+
137
+
it('enables create button when input has value', async () => {
138
+
render(AppPasswords)
139
+
140
+
await waitFor(() => {
141
+
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
142
+
})
143
+
144
+
await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'My New App' } })
145
+
146
+
expect(screen.getByRole('button', { name: /create/i })).not.toBeDisabled()
147
+
})
148
+
149
+
it('calls createAppPassword with correct name', async () => {
150
+
let capturedName: string | null = null
151
+
152
+
mockEndpoint('com.atproto.server.createAppPassword', (_url, options) => {
153
+
const body = JSON.parse((options?.body as string) || '{}')
154
+
capturedName = body.name
155
+
return jsonResponse({
156
+
name: body.name,
157
+
password: 'xxxx-xxxx-xxxx-xxxx',
158
+
createdAt: new Date().toISOString(),
159
+
})
160
+
})
161
+
162
+
render(AppPasswords)
163
+
164
+
await waitFor(() => {
165
+
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
166
+
})
167
+
168
+
await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'Graysky' } })
169
+
await fireEvent.click(screen.getByRole('button', { name: /create/i }))
170
+
171
+
await waitFor(() => {
172
+
expect(capturedName).toBe('Graysky')
173
+
})
174
+
})
175
+
176
+
it('shows loading state while creating', async () => {
177
+
mockEndpoint('com.atproto.server.createAppPassword', async () => {
178
+
await new Promise(resolve => setTimeout(resolve, 100))
179
+
return jsonResponse({
180
+
name: 'Test',
181
+
password: 'xxxx-xxxx-xxxx-xxxx',
182
+
createdAt: new Date().toISOString(),
183
+
})
184
+
})
185
+
186
+
render(AppPasswords)
187
+
188
+
await waitFor(() => {
189
+
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
190
+
})
191
+
192
+
await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'Test' } })
193
+
await fireEvent.click(screen.getByRole('button', { name: /create/i }))
194
+
195
+
expect(screen.getByRole('button', { name: /creating/i })).toBeInTheDocument()
196
+
expect(screen.getByRole('button', { name: /creating/i })).toBeDisabled()
197
+
})
198
+
199
+
it('displays created password in success box and clears input', async () => {
200
+
mockEndpoint('com.atproto.server.createAppPassword', () =>
201
+
jsonResponse({
202
+
name: 'MyApp',
203
+
password: 'abcd-efgh-ijkl-mnop',
204
+
createdAt: new Date().toISOString(),
205
+
})
206
+
)
207
+
208
+
render(AppPasswords)
209
+
210
+
await waitFor(() => {
211
+
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
212
+
})
213
+
214
+
const input = screen.getByPlaceholderText(/app name/i) as HTMLInputElement
215
+
await fireEvent.input(input, { target: { value: 'MyApp' } })
216
+
await fireEvent.click(screen.getByRole('button', { name: /create/i }))
217
+
218
+
await waitFor(() => {
219
+
expect(screen.getByText(/app password created/i)).toBeInTheDocument()
220
+
expect(screen.getByText('abcd-efgh-ijkl-mnop')).toBeInTheDocument()
221
+
expect(screen.getByText(/name: myapp/i)).toBeInTheDocument()
222
+
expect(input.value).toBe('')
223
+
})
224
+
})
225
+
226
+
it('dismisses created password box when clicking Done', async () => {
227
+
mockEndpoint('com.atproto.server.createAppPassword', () =>
228
+
jsonResponse({
229
+
name: 'Test',
230
+
password: 'xxxx-xxxx-xxxx-xxxx',
231
+
createdAt: new Date().toISOString(),
232
+
})
233
+
)
234
+
235
+
render(AppPasswords)
236
+
237
+
await waitFor(() => {
238
+
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
239
+
})
240
+
241
+
await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'Test' } })
242
+
await fireEvent.click(screen.getByRole('button', { name: /create/i }))
243
+
244
+
await waitFor(() => {
245
+
expect(screen.getByText(/app password created/i)).toBeInTheDocument()
246
+
})
247
+
248
+
await fireEvent.click(screen.getByRole('button', { name: /done/i }))
249
+
250
+
await waitFor(() => {
251
+
expect(screen.queryByText(/app password created/i)).not.toBeInTheDocument()
252
+
})
253
+
})
254
+
255
+
it('shows error when creation fails', async () => {
256
+
mockEndpoint('com.atproto.server.createAppPassword', () =>
257
+
errorResponse('InvalidRequest', 'Name already exists', 400)
258
+
)
259
+
260
+
render(AppPasswords)
261
+
262
+
await waitFor(() => {
263
+
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
264
+
})
265
+
266
+
await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'Duplicate' } })
267
+
await fireEvent.click(screen.getByRole('button', { name: /create/i }))
268
+
269
+
await waitFor(() => {
270
+
expect(screen.getByText(/name already exists/i)).toBeInTheDocument()
271
+
expect(screen.getByText(/name already exists/i)).toHaveClass('error')
272
+
})
273
+
})
274
+
})
275
+
276
+
describe('revoke app password', () => {
277
+
const testPassword = mockData.appPassword({ name: 'TestApp' })
278
+
279
+
beforeEach(() => {
280
+
setupAuthenticatedUser()
281
+
})
282
+
283
+
it('shows confirmation dialog before revoking', async () => {
284
+
const confirmSpy = vi.fn(() => false)
285
+
window.confirm = confirmSpy
286
+
287
+
mockEndpoint('com.atproto.server.listAppPasswords', () =>
288
+
jsonResponse({ passwords: [testPassword] })
289
+
)
290
+
291
+
render(AppPasswords)
292
+
293
+
await waitFor(() => {
294
+
expect(screen.getByText('TestApp')).toBeInTheDocument()
295
+
})
296
+
297
+
await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
298
+
299
+
expect(confirmSpy).toHaveBeenCalledWith(
300
+
expect.stringContaining('TestApp')
301
+
)
302
+
})
303
+
304
+
it('does not revoke when confirmation is cancelled', async () => {
305
+
window.confirm = vi.fn(() => false)
306
+
let revokeCalled = false
307
+
308
+
mockEndpoint('com.atproto.server.listAppPasswords', () =>
309
+
jsonResponse({ passwords: [testPassword] })
310
+
)
311
+
312
+
mockEndpoint('com.atproto.server.revokeAppPassword', () => {
313
+
revokeCalled = true
314
+
return jsonResponse({})
315
+
})
316
+
317
+
render(AppPasswords)
318
+
319
+
await waitFor(() => {
320
+
expect(screen.getByText('TestApp')).toBeInTheDocument()
321
+
})
322
+
323
+
await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
324
+
325
+
expect(revokeCalled).toBe(false)
326
+
})
327
+
328
+
it('calls revokeAppPassword with correct name', async () => {
329
+
window.confirm = vi.fn(() => true)
330
+
let capturedName: string | null = null
331
+
332
+
mockEndpoint('com.atproto.server.listAppPasswords', () =>
333
+
jsonResponse({ passwords: [testPassword] })
334
+
)
335
+
336
+
mockEndpoint('com.atproto.server.revokeAppPassword', (_url, options) => {
337
+
const body = JSON.parse((options?.body as string) || '{}')
338
+
capturedName = body.name
339
+
return jsonResponse({})
340
+
})
341
+
342
+
render(AppPasswords)
343
+
344
+
await waitFor(() => {
345
+
expect(screen.getByText('TestApp')).toBeInTheDocument()
346
+
})
347
+
348
+
await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
349
+
350
+
await waitFor(() => {
351
+
expect(capturedName).toBe('TestApp')
352
+
})
353
+
})
354
+
355
+
it('shows loading state while revoking', async () => {
356
+
window.confirm = vi.fn(() => true)
357
+
358
+
mockEndpoint('com.atproto.server.listAppPasswords', () =>
359
+
jsonResponse({ passwords: [testPassword] })
360
+
)
361
+
362
+
mockEndpoint('com.atproto.server.revokeAppPassword', async () => {
363
+
await new Promise(resolve => setTimeout(resolve, 100))
364
+
return jsonResponse({})
365
+
})
366
+
367
+
render(AppPasswords)
368
+
369
+
await waitFor(() => {
370
+
expect(screen.getByText('TestApp')).toBeInTheDocument()
371
+
})
372
+
373
+
await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
374
+
375
+
expect(screen.getByRole('button', { name: /revoking/i })).toBeInTheDocument()
376
+
expect(screen.getByRole('button', { name: /revoking/i })).toBeDisabled()
377
+
})
378
+
379
+
it('reloads password list after successful revocation', async () => {
380
+
window.confirm = vi.fn(() => true)
381
+
let listCallCount = 0
382
+
383
+
mockEndpoint('com.atproto.server.listAppPasswords', () => {
384
+
listCallCount++
385
+
if (listCallCount === 1) {
386
+
return jsonResponse({ passwords: [testPassword] })
387
+
}
388
+
return jsonResponse({ passwords: [] })
389
+
})
390
+
391
+
mockEndpoint('com.atproto.server.revokeAppPassword', () =>
392
+
jsonResponse({})
393
+
)
394
+
395
+
render(AppPasswords)
396
+
397
+
await waitFor(() => {
398
+
expect(screen.getByText('TestApp')).toBeInTheDocument()
399
+
})
400
+
401
+
await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
402
+
403
+
await waitFor(() => {
404
+
expect(screen.queryByText('TestApp')).not.toBeInTheDocument()
405
+
expect(screen.getByText(/no app passwords yet/i)).toBeInTheDocument()
406
+
})
407
+
})
408
+
409
+
it('shows error when revocation fails', async () => {
410
+
window.confirm = vi.fn(() => true)
411
+
412
+
mockEndpoint('com.atproto.server.listAppPasswords', () =>
413
+
jsonResponse({ passwords: [testPassword] })
414
+
)
415
+
416
+
mockEndpoint('com.atproto.server.revokeAppPassword', () =>
417
+
errorResponse('InternalError', 'Server error', 500)
418
+
)
419
+
420
+
render(AppPasswords)
421
+
422
+
await waitFor(() => {
423
+
expect(screen.getByText('TestApp')).toBeInTheDocument()
424
+
})
425
+
426
+
await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
427
+
428
+
await waitFor(() => {
429
+
expect(screen.getByText(/server error/i)).toBeInTheDocument()
430
+
expect(screen.getByText(/server error/i)).toHaveClass('error')
431
+
})
432
+
})
433
+
})
434
+
435
+
describe('error handling', () => {
436
+
beforeEach(() => {
437
+
setupAuthenticatedUser()
438
+
})
439
+
440
+
it('shows error when loading passwords fails', async () => {
441
+
mockEndpoint('com.atproto.server.listAppPasswords', () =>
442
+
errorResponse('InternalError', 'Database connection failed', 500)
443
+
)
444
+
445
+
render(AppPasswords)
446
+
447
+
await waitFor(() => {
448
+
expect(screen.getByText(/database connection failed/i)).toBeInTheDocument()
449
+
expect(screen.getByText(/database connection failed/i)).toHaveClass('error')
450
+
})
451
+
})
452
+
})
453
+
})
+138
frontend/src/tests/Dashboard.test.ts
+138
frontend/src/tests/Dashboard.test.ts
···
1
+
import { describe, it, expect, beforeEach } from 'vitest'
2
+
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'
3
+
import Dashboard from '../routes/Dashboard.svelte'
4
+
import {
5
+
setupFetchMock,
6
+
mockEndpoint,
7
+
jsonResponse,
8
+
mockData,
9
+
clearMocks,
10
+
setupAuthenticatedUser,
11
+
setupUnauthenticatedUser,
12
+
} from './mocks'
13
+
14
+
const STORAGE_KEY = 'bspds_session'
15
+
16
+
describe('Dashboard', () => {
17
+
beforeEach(() => {
18
+
clearMocks()
19
+
setupFetchMock()
20
+
})
21
+
22
+
describe('authentication guard', () => {
23
+
it('redirects to login when not authenticated', async () => {
24
+
setupUnauthenticatedUser()
25
+
render(Dashboard)
26
+
27
+
await waitFor(() => {
28
+
expect(window.location.hash).toBe('#/login')
29
+
})
30
+
})
31
+
32
+
it('shows loading state while checking auth', () => {
33
+
render(Dashboard)
34
+
35
+
expect(screen.getByText(/loading/i)).toBeInTheDocument()
36
+
})
37
+
})
38
+
39
+
describe('authenticated view', () => {
40
+
beforeEach(() => {
41
+
setupAuthenticatedUser()
42
+
})
43
+
44
+
it('displays user account info and page structure', async () => {
45
+
render(Dashboard)
46
+
47
+
await waitFor(() => {
48
+
expect(screen.getByRole('heading', { name: /dashboard/i })).toBeInTheDocument()
49
+
expect(screen.getByRole('heading', { name: /account overview/i })).toBeInTheDocument()
50
+
expect(screen.getByText(/@testuser\.test\.bspds\.dev/)).toBeInTheDocument()
51
+
expect(screen.getByText(/did:web:test\.bspds\.dev:u:testuser/)).toBeInTheDocument()
52
+
expect(screen.getByText('test@example.com')).toBeInTheDocument()
53
+
expect(screen.getByText('Verified')).toBeInTheDocument()
54
+
expect(screen.getByText('Verified')).toHaveClass('badge', 'success')
55
+
})
56
+
})
57
+
58
+
it('displays unverified badge when email not confirmed', async () => {
59
+
setupAuthenticatedUser({ emailConfirmed: false })
60
+
render(Dashboard)
61
+
62
+
await waitFor(() => {
63
+
expect(screen.getByText('Unverified')).toBeInTheDocument()
64
+
expect(screen.getByText('Unverified')).toHaveClass('badge', 'warning')
65
+
})
66
+
})
67
+
68
+
it('displays all navigation cards', async () => {
69
+
render(Dashboard)
70
+
71
+
await waitFor(() => {
72
+
const navCards = [
73
+
{ name: /app passwords/i, href: '#/app-passwords' },
74
+
{ name: /invite codes/i, href: '#/invite-codes' },
75
+
{ name: /account settings/i, href: '#/settings' },
76
+
{ name: /notification preferences/i, href: '#/notifications' },
77
+
{ name: /repository explorer/i, href: '#/repo' },
78
+
]
79
+
80
+
for (const { name, href } of navCards) {
81
+
const card = screen.getByRole('link', { name })
82
+
expect(card).toBeInTheDocument()
83
+
expect(card).toHaveAttribute('href', href)
84
+
}
85
+
})
86
+
})
87
+
})
88
+
89
+
describe('logout functionality', () => {
90
+
beforeEach(() => {
91
+
setupAuthenticatedUser()
92
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(mockData.session()))
93
+
94
+
mockEndpoint('com.atproto.server.deleteSession', () =>
95
+
jsonResponse({})
96
+
)
97
+
})
98
+
99
+
it('calls deleteSession and navigates to login on logout', async () => {
100
+
let deleteSessionCalled = false
101
+
102
+
mockEndpoint('com.atproto.server.deleteSession', () => {
103
+
deleteSessionCalled = true
104
+
return jsonResponse({})
105
+
})
106
+
107
+
render(Dashboard)
108
+
109
+
await waitFor(() => {
110
+
expect(screen.getByRole('button', { name: /sign out/i })).toBeInTheDocument()
111
+
})
112
+
113
+
await fireEvent.click(screen.getByRole('button', { name: /sign out/i }))
114
+
115
+
await waitFor(() => {
116
+
expect(deleteSessionCalled).toBe(true)
117
+
expect(window.location.hash).toBe('#/login')
118
+
})
119
+
})
120
+
121
+
it('clears session from localStorage after logout', async () => {
122
+
const storedSession = localStorage.getItem(STORAGE_KEY)
123
+
expect(storedSession).not.toBeNull()
124
+
125
+
render(Dashboard)
126
+
127
+
await waitFor(() => {
128
+
expect(screen.getByRole('button', { name: /sign out/i })).toBeInTheDocument()
129
+
})
130
+
131
+
await fireEvent.click(screen.getByRole('button', { name: /sign out/i }))
132
+
133
+
await waitFor(() => {
134
+
expect(localStorage.getItem(STORAGE_KEY)).toBeNull()
135
+
})
136
+
})
137
+
})
138
+
})
+167
frontend/src/tests/Login.test.ts
+167
frontend/src/tests/Login.test.ts
···
1
+
import { describe, it, expect, beforeEach } from 'vitest'
2
+
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'
3
+
import Login from '../routes/Login.svelte'
4
+
import {
5
+
setupFetchMock,
6
+
mockEndpoint,
7
+
jsonResponse,
8
+
errorResponse,
9
+
mockData,
10
+
clearMocks,
11
+
} from './mocks'
12
+
13
+
describe('Login', () => {
14
+
beforeEach(() => {
15
+
clearMocks()
16
+
setupFetchMock()
17
+
window.location.hash = ''
18
+
})
19
+
20
+
describe('initial render', () => {
21
+
it('renders login form with all elements and correct initial state', () => {
22
+
render(Login)
23
+
24
+
expect(screen.getByRole('heading', { name: /sign in/i })).toBeInTheDocument()
25
+
expect(screen.getByLabelText(/handle or email/i)).toBeInTheDocument()
26
+
expect(screen.getByLabelText(/password/i)).toBeInTheDocument()
27
+
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument()
28
+
expect(screen.getByRole('button', { name: /sign in/i })).toBeDisabled()
29
+
expect(screen.getByText(/don't have an account/i)).toBeInTheDocument()
30
+
expect(screen.getByRole('link', { name: /create one/i })).toHaveAttribute('href', '#/register')
31
+
})
32
+
})
33
+
34
+
describe('form validation', () => {
35
+
it('enables submit button only when both fields are filled', async () => {
36
+
render(Login)
37
+
38
+
const identifierInput = screen.getByLabelText(/handle or email/i)
39
+
const passwordInput = screen.getByLabelText(/password/i)
40
+
const submitButton = screen.getByRole('button', { name: /sign in/i })
41
+
42
+
await fireEvent.input(identifierInput, { target: { value: 'testuser' } })
43
+
expect(submitButton).toBeDisabled()
44
+
45
+
await fireEvent.input(identifierInput, { target: { value: '' } })
46
+
await fireEvent.input(passwordInput, { target: { value: 'password123' } })
47
+
expect(submitButton).toBeDisabled()
48
+
49
+
await fireEvent.input(identifierInput, { target: { value: 'testuser' } })
50
+
expect(submitButton).not.toBeDisabled()
51
+
})
52
+
})
53
+
54
+
describe('login submission', () => {
55
+
it('calls createSession with correct credentials', async () => {
56
+
let capturedBody: Record<string, string> | null = null
57
+
58
+
mockEndpoint('com.atproto.server.createSession', (_url, options) => {
59
+
capturedBody = JSON.parse((options?.body as string) || '{}')
60
+
return jsonResponse(mockData.session())
61
+
})
62
+
63
+
render(Login)
64
+
65
+
await fireEvent.input(screen.getByLabelText(/handle or email/i), { target: { value: 'testuser@example.com' } })
66
+
await fireEvent.input(screen.getByLabelText(/password/i), { target: { value: 'mypassword' } })
67
+
await fireEvent.click(screen.getByRole('button', { name: /sign in/i }))
68
+
69
+
await waitFor(() => {
70
+
expect(capturedBody).toEqual({
71
+
identifier: 'testuser@example.com',
72
+
password: 'mypassword',
73
+
})
74
+
})
75
+
})
76
+
77
+
it('shows styled error message on invalid credentials', async () => {
78
+
mockEndpoint('com.atproto.server.createSession', () =>
79
+
errorResponse('AuthenticationRequired', 'Invalid identifier or password', 401)
80
+
)
81
+
82
+
render(Login)
83
+
84
+
await fireEvent.input(screen.getByLabelText(/handle or email/i), { target: { value: 'wronguser' } })
85
+
await fireEvent.input(screen.getByLabelText(/password/i), { target: { value: 'wrongpassword' } })
86
+
await fireEvent.click(screen.getByRole('button', { name: /sign in/i }))
87
+
88
+
await waitFor(() => {
89
+
const errorDiv = screen.getByText(/invalid identifier or password/i)
90
+
expect(errorDiv).toBeInTheDocument()
91
+
expect(errorDiv).toHaveClass('error')
92
+
})
93
+
})
94
+
95
+
it('navigates to dashboard on successful login', async () => {
96
+
mockEndpoint('com.atproto.server.createSession', () =>
97
+
jsonResponse(mockData.session())
98
+
)
99
+
100
+
render(Login)
101
+
102
+
await fireEvent.input(screen.getByLabelText(/handle or email/i), { target: { value: 'test' } })
103
+
await fireEvent.input(screen.getByLabelText(/password/i), { target: { value: 'password' } })
104
+
await fireEvent.click(screen.getByRole('button', { name: /sign in/i }))
105
+
106
+
await waitFor(() => {
107
+
expect(window.location.hash).toBe('#/dashboard')
108
+
})
109
+
})
110
+
})
111
+
112
+
describe('account verification flow', () => {
113
+
it('shows verification form with all controls when account is not verified', async () => {
114
+
mockEndpoint('com.atproto.server.createSession', () => ({
115
+
ok: false,
116
+
status: 401,
117
+
json: async () => ({
118
+
error: 'AccountNotVerified',
119
+
message: 'Account not verified',
120
+
did: 'did:web:test.bspds.dev:u:testuser',
121
+
}),
122
+
}))
123
+
124
+
render(Login)
125
+
126
+
await fireEvent.input(screen.getByLabelText(/handle or email/i), { target: { value: 'unverified@test.com' } })
127
+
await fireEvent.input(screen.getByLabelText(/password/i), { target: { value: 'password' } })
128
+
await fireEvent.click(screen.getByRole('button', { name: /sign in/i }))
129
+
130
+
await waitFor(() => {
131
+
expect(screen.getByRole('heading', { name: /verify your account/i })).toBeInTheDocument()
132
+
expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument()
133
+
expect(screen.getByRole('button', { name: /resend code/i })).toBeInTheDocument()
134
+
expect(screen.getByRole('button', { name: /back to login/i })).toBeInTheDocument()
135
+
})
136
+
})
137
+
138
+
it('returns to login form when clicking back', async () => {
139
+
mockEndpoint('com.atproto.server.createSession', () => ({
140
+
ok: false,
141
+
status: 401,
142
+
json: async () => ({
143
+
error: 'AccountNotVerified',
144
+
message: 'Account not verified',
145
+
did: 'did:web:test.bspds.dev:u:testuser',
146
+
}),
147
+
}))
148
+
149
+
render(Login)
150
+
151
+
await fireEvent.input(screen.getByLabelText(/handle or email/i), { target: { value: 'test' } })
152
+
await fireEvent.input(screen.getByLabelText(/password/i), { target: { value: 'password' } })
153
+
await fireEvent.click(screen.getByRole('button', { name: /sign in/i }))
154
+
155
+
await waitFor(() => {
156
+
expect(screen.getByRole('button', { name: /back to login/i })).toBeInTheDocument()
157
+
})
158
+
159
+
await fireEvent.click(screen.getByRole('button', { name: /back to login/i }))
160
+
161
+
await waitFor(() => {
162
+
expect(screen.getByRole('heading', { name: /sign in/i })).toBeInTheDocument()
163
+
expect(screen.queryByLabelText(/verification code/i)).not.toBeInTheDocument()
164
+
})
165
+
})
166
+
})
167
+
})
+443
frontend/src/tests/Notifications.test.ts
+443
frontend/src/tests/Notifications.test.ts
···
1
+
import { describe, it, expect, beforeEach } from 'vitest'
2
+
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'
3
+
import Notifications from '../routes/Notifications.svelte'
4
+
import {
5
+
setupFetchMock,
6
+
mockEndpoint,
7
+
jsonResponse,
8
+
errorResponse,
9
+
mockData,
10
+
clearMocks,
11
+
setupAuthenticatedUser,
12
+
setupUnauthenticatedUser,
13
+
} from './mocks'
14
+
15
+
describe('Notifications', () => {
16
+
beforeEach(() => {
17
+
clearMocks()
18
+
setupFetchMock()
19
+
})
20
+
21
+
describe('authentication guard', () => {
22
+
it('redirects to login when not authenticated', async () => {
23
+
setupUnauthenticatedUser()
24
+
render(Notifications)
25
+
26
+
await waitFor(() => {
27
+
expect(window.location.hash).toBe('#/login')
28
+
})
29
+
})
30
+
})
31
+
32
+
describe('page structure', () => {
33
+
beforeEach(() => {
34
+
setupAuthenticatedUser()
35
+
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
36
+
jsonResponse(mockData.notificationPrefs())
37
+
)
38
+
})
39
+
40
+
it('displays all page elements and sections', async () => {
41
+
render(Notifications)
42
+
43
+
await waitFor(() => {
44
+
expect(screen.getByRole('heading', { name: /notification preferences/i, level: 1 })).toBeInTheDocument()
45
+
expect(screen.getByRole('link', { name: /dashboard/i })).toHaveAttribute('href', '#/dashboard')
46
+
expect(screen.getByText(/password resets/i)).toBeInTheDocument()
47
+
expect(screen.getByRole('heading', { name: /preferred channel/i })).toBeInTheDocument()
48
+
expect(screen.getByRole('heading', { name: /channel configuration/i })).toBeInTheDocument()
49
+
})
50
+
})
51
+
})
52
+
53
+
describe('loading state', () => {
54
+
beforeEach(() => {
55
+
setupAuthenticatedUser()
56
+
})
57
+
58
+
it('shows loading text while fetching preferences', async () => {
59
+
mockEndpoint('com.bspds.account.getNotificationPrefs', async () => {
60
+
await new Promise(resolve => setTimeout(resolve, 100))
61
+
return jsonResponse(mockData.notificationPrefs())
62
+
})
63
+
64
+
render(Notifications)
65
+
66
+
expect(screen.getByText(/loading/i)).toBeInTheDocument()
67
+
})
68
+
})
69
+
70
+
describe('channel options', () => {
71
+
beforeEach(() => {
72
+
setupAuthenticatedUser()
73
+
})
74
+
75
+
it('displays all four channel options', async () => {
76
+
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
77
+
jsonResponse(mockData.notificationPrefs())
78
+
)
79
+
80
+
render(Notifications)
81
+
82
+
await waitFor(() => {
83
+
expect(screen.getByRole('radio', { name: /email/i })).toBeInTheDocument()
84
+
expect(screen.getByRole('radio', { name: /discord/i })).toBeInTheDocument()
85
+
expect(screen.getByRole('radio', { name: /telegram/i })).toBeInTheDocument()
86
+
expect(screen.getByRole('radio', { name: /signal/i })).toBeInTheDocument()
87
+
})
88
+
})
89
+
90
+
it('email channel is always selectable', async () => {
91
+
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
92
+
jsonResponse(mockData.notificationPrefs())
93
+
)
94
+
95
+
render(Notifications)
96
+
97
+
await waitFor(() => {
98
+
const emailRadio = screen.getByRole('radio', { name: /email/i })
99
+
expect(emailRadio).not.toBeDisabled()
100
+
})
101
+
})
102
+
103
+
it('discord channel is disabled when not configured', async () => {
104
+
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
105
+
jsonResponse(mockData.notificationPrefs({ discordId: null }))
106
+
)
107
+
108
+
render(Notifications)
109
+
110
+
await waitFor(() => {
111
+
const discordRadio = screen.getByRole('radio', { name: /discord/i })
112
+
expect(discordRadio).toBeDisabled()
113
+
})
114
+
})
115
+
116
+
it('discord channel is enabled when configured', async () => {
117
+
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
118
+
jsonResponse(mockData.notificationPrefs({ discordId: '123456789' }))
119
+
)
120
+
121
+
render(Notifications)
122
+
123
+
await waitFor(() => {
124
+
const discordRadio = screen.getByRole('radio', { name: /discord/i })
125
+
expect(discordRadio).not.toBeDisabled()
126
+
})
127
+
})
128
+
129
+
it('shows hint for disabled channels', async () => {
130
+
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
131
+
jsonResponse(mockData.notificationPrefs())
132
+
)
133
+
134
+
render(Notifications)
135
+
136
+
await waitFor(() => {
137
+
expect(screen.getAllByText(/configure below to enable/i).length).toBeGreaterThan(0)
138
+
})
139
+
})
140
+
141
+
it('selects current preferred channel', async () => {
142
+
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
143
+
jsonResponse(mockData.notificationPrefs({ preferredChannel: 'email' }))
144
+
)
145
+
146
+
render(Notifications)
147
+
148
+
await waitFor(() => {
149
+
const emailRadio = screen.getByRole('radio', { name: /email/i }) as HTMLInputElement
150
+
expect(emailRadio.checked).toBe(true)
151
+
})
152
+
})
153
+
})
154
+
155
+
describe('channel configuration', () => {
156
+
beforeEach(() => {
157
+
setupAuthenticatedUser()
158
+
})
159
+
160
+
it('displays email as readonly with current value', async () => {
161
+
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
162
+
jsonResponse(mockData.notificationPrefs())
163
+
)
164
+
165
+
render(Notifications)
166
+
167
+
await waitFor(() => {
168
+
const emailInput = screen.getByLabelText(/^email$/i) as HTMLInputElement
169
+
expect(emailInput).toBeDisabled()
170
+
expect(emailInput.value).toBe('test@example.com')
171
+
})
172
+
})
173
+
174
+
it('displays all channel inputs with current values', async () => {
175
+
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
176
+
jsonResponse(mockData.notificationPrefs({
177
+
discordId: '123456789',
178
+
telegramUsername: 'testuser',
179
+
signalNumber: '+1234567890',
180
+
}))
181
+
)
182
+
183
+
render(Notifications)
184
+
185
+
await waitFor(() => {
186
+
expect((screen.getByLabelText(/discord user id/i) as HTMLInputElement).value).toBe('123456789')
187
+
expect((screen.getByLabelText(/telegram username/i) as HTMLInputElement).value).toBe('testuser')
188
+
expect((screen.getByLabelText(/signal phone number/i) as HTMLInputElement).value).toBe('+1234567890')
189
+
})
190
+
})
191
+
})
192
+
193
+
describe('verification status badges', () => {
194
+
beforeEach(() => {
195
+
setupAuthenticatedUser()
196
+
})
197
+
198
+
it('shows Primary badge for email', async () => {
199
+
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
200
+
jsonResponse(mockData.notificationPrefs())
201
+
)
202
+
203
+
render(Notifications)
204
+
205
+
await waitFor(() => {
206
+
expect(screen.getByText('Primary')).toBeInTheDocument()
207
+
})
208
+
})
209
+
210
+
it('shows Verified badge for verified discord', async () => {
211
+
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
212
+
jsonResponse(mockData.notificationPrefs({
213
+
discordId: '123456789',
214
+
discordVerified: true,
215
+
}))
216
+
)
217
+
218
+
render(Notifications)
219
+
220
+
await waitFor(() => {
221
+
const verifiedBadges = screen.getAllByText('Verified')
222
+
expect(verifiedBadges.length).toBeGreaterThan(0)
223
+
})
224
+
})
225
+
226
+
it('shows Not verified badge for unverified discord', async () => {
227
+
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
228
+
jsonResponse(mockData.notificationPrefs({
229
+
discordId: '123456789',
230
+
discordVerified: false,
231
+
}))
232
+
)
233
+
234
+
render(Notifications)
235
+
236
+
await waitFor(() => {
237
+
expect(screen.getByText('Not verified')).toBeInTheDocument()
238
+
})
239
+
})
240
+
241
+
it('does not show badge when channel not configured', async () => {
242
+
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
243
+
jsonResponse(mockData.notificationPrefs())
244
+
)
245
+
246
+
render(Notifications)
247
+
248
+
await waitFor(() => {
249
+
expect(screen.getByText('Primary')).toBeInTheDocument()
250
+
expect(screen.queryByText('Not verified')).not.toBeInTheDocument()
251
+
})
252
+
})
253
+
})
254
+
255
+
describe('save preferences', () => {
256
+
beforeEach(() => {
257
+
setupAuthenticatedUser()
258
+
})
259
+
260
+
it('calls updateNotificationPrefs with correct data', async () => {
261
+
let capturedBody: Record<string, unknown> | null = null
262
+
263
+
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
264
+
jsonResponse(mockData.notificationPrefs())
265
+
)
266
+
267
+
mockEndpoint('com.bspds.account.updateNotificationPrefs', (_url, options) => {
268
+
capturedBody = JSON.parse((options?.body as string) || '{}')
269
+
return jsonResponse({ success: true })
270
+
})
271
+
272
+
render(Notifications)
273
+
274
+
await waitFor(() => {
275
+
expect(screen.getByLabelText(/discord user id/i)).toBeInTheDocument()
276
+
})
277
+
278
+
await fireEvent.input(screen.getByLabelText(/discord user id/i), { target: { value: '999888777' } })
279
+
await fireEvent.click(screen.getByRole('button', { name: /save preferences/i }))
280
+
281
+
await waitFor(() => {
282
+
expect(capturedBody).not.toBeNull()
283
+
expect(capturedBody?.discordId).toBe('999888777')
284
+
expect(capturedBody?.preferredChannel).toBe('email')
285
+
})
286
+
})
287
+
288
+
it('shows loading state while saving', async () => {
289
+
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
290
+
jsonResponse(mockData.notificationPrefs())
291
+
)
292
+
293
+
mockEndpoint('com.bspds.account.updateNotificationPrefs', async () => {
294
+
await new Promise(resolve => setTimeout(resolve, 100))
295
+
return jsonResponse({ success: true })
296
+
})
297
+
298
+
render(Notifications)
299
+
300
+
await waitFor(() => {
301
+
expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument()
302
+
})
303
+
304
+
await fireEvent.click(screen.getByRole('button', { name: /save preferences/i }))
305
+
306
+
expect(screen.getByRole('button', { name: /saving/i })).toBeInTheDocument()
307
+
expect(screen.getByRole('button', { name: /saving/i })).toBeDisabled()
308
+
})
309
+
310
+
it('shows success message after saving', async () => {
311
+
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
312
+
jsonResponse(mockData.notificationPrefs())
313
+
)
314
+
315
+
mockEndpoint('com.bspds.account.updateNotificationPrefs', () =>
316
+
jsonResponse({ success: true })
317
+
)
318
+
319
+
render(Notifications)
320
+
321
+
await waitFor(() => {
322
+
expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument()
323
+
})
324
+
325
+
await fireEvent.click(screen.getByRole('button', { name: /save preferences/i }))
326
+
327
+
await waitFor(() => {
328
+
expect(screen.getByText(/notification preferences saved/i)).toBeInTheDocument()
329
+
})
330
+
})
331
+
332
+
it('shows error when save fails', async () => {
333
+
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
334
+
jsonResponse(mockData.notificationPrefs())
335
+
)
336
+
337
+
mockEndpoint('com.bspds.account.updateNotificationPrefs', () =>
338
+
errorResponse('InvalidRequest', 'Invalid channel configuration', 400)
339
+
)
340
+
341
+
render(Notifications)
342
+
343
+
await waitFor(() => {
344
+
expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument()
345
+
})
346
+
347
+
await fireEvent.click(screen.getByRole('button', { name: /save preferences/i }))
348
+
349
+
await waitFor(() => {
350
+
expect(screen.getByText(/invalid channel configuration/i)).toBeInTheDocument()
351
+
expect(screen.getByText(/invalid channel configuration/i).closest('.message')).toHaveClass('error')
352
+
})
353
+
})
354
+
355
+
it('reloads preferences after successful save', async () => {
356
+
let loadCount = 0
357
+
358
+
mockEndpoint('com.bspds.account.getNotificationPrefs', () => {
359
+
loadCount++
360
+
return jsonResponse(mockData.notificationPrefs())
361
+
})
362
+
363
+
mockEndpoint('com.bspds.account.updateNotificationPrefs', () =>
364
+
jsonResponse({ success: true })
365
+
)
366
+
367
+
render(Notifications)
368
+
369
+
await waitFor(() => {
370
+
expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument()
371
+
})
372
+
373
+
const initialLoadCount = loadCount
374
+
await fireEvent.click(screen.getByRole('button', { name: /save preferences/i }))
375
+
376
+
await waitFor(() => {
377
+
expect(loadCount).toBeGreaterThan(initialLoadCount)
378
+
})
379
+
})
380
+
})
381
+
382
+
describe('channel selection interaction', () => {
383
+
beforeEach(() => {
384
+
setupAuthenticatedUser()
385
+
})
386
+
387
+
it('enables discord channel after entering discord ID', async () => {
388
+
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
389
+
jsonResponse(mockData.notificationPrefs())
390
+
)
391
+
392
+
render(Notifications)
393
+
394
+
await waitFor(() => {
395
+
expect(screen.getByRole('radio', { name: /discord/i })).toBeDisabled()
396
+
})
397
+
398
+
await fireEvent.input(screen.getByLabelText(/discord user id/i), { target: { value: '123456789' } })
399
+
400
+
await waitFor(() => {
401
+
expect(screen.getByRole('radio', { name: /discord/i })).not.toBeDisabled()
402
+
})
403
+
})
404
+
405
+
it('allows selecting a configured channel', async () => {
406
+
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
407
+
jsonResponse(mockData.notificationPrefs({
408
+
discordId: '123456789',
409
+
discordVerified: true,
410
+
}))
411
+
)
412
+
413
+
render(Notifications)
414
+
415
+
await waitFor(() => {
416
+
expect(screen.getByRole('radio', { name: /discord/i })).not.toBeDisabled()
417
+
})
418
+
419
+
await fireEvent.click(screen.getByRole('radio', { name: /discord/i }))
420
+
421
+
const discordRadio = screen.getByRole('radio', { name: /discord/i }) as HTMLInputElement
422
+
expect(discordRadio.checked).toBe(true)
423
+
})
424
+
})
425
+
426
+
describe('error handling', () => {
427
+
beforeEach(() => {
428
+
setupAuthenticatedUser()
429
+
})
430
+
431
+
it('shows error when loading preferences fails', async () => {
432
+
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
433
+
errorResponse('InternalError', 'Database connection failed', 500)
434
+
)
435
+
436
+
render(Notifications)
437
+
438
+
await waitFor(() => {
439
+
expect(screen.getByText(/database connection failed/i)).toBeInTheDocument()
440
+
})
441
+
})
442
+
})
443
+
})
+516
frontend/src/tests/Settings.test.ts
+516
frontend/src/tests/Settings.test.ts
···
1
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'
3
+
import Settings from '../routes/Settings.svelte'
4
+
import {
5
+
setupFetchMock,
6
+
mockEndpoint,
7
+
jsonResponse,
8
+
errorResponse,
9
+
clearMocks,
10
+
setupAuthenticatedUser,
11
+
setupUnauthenticatedUser,
12
+
} from './mocks'
13
+
14
+
describe('Settings', () => {
15
+
beforeEach(() => {
16
+
clearMocks()
17
+
setupFetchMock()
18
+
window.confirm = vi.fn(() => true)
19
+
})
20
+
21
+
describe('authentication guard', () => {
22
+
it('redirects to login when not authenticated', async () => {
23
+
setupUnauthenticatedUser()
24
+
render(Settings)
25
+
26
+
await waitFor(() => {
27
+
expect(window.location.hash).toBe('#/login')
28
+
})
29
+
})
30
+
})
31
+
32
+
describe('page structure', () => {
33
+
beforeEach(() => {
34
+
setupAuthenticatedUser()
35
+
})
36
+
37
+
it('displays all page elements and sections', async () => {
38
+
render(Settings)
39
+
40
+
await waitFor(() => {
41
+
expect(screen.getByRole('heading', { name: /account settings/i, level: 1 })).toBeInTheDocument()
42
+
expect(screen.getByRole('link', { name: /dashboard/i })).toHaveAttribute('href', '#/dashboard')
43
+
expect(screen.getByRole('heading', { name: /change email/i })).toBeInTheDocument()
44
+
expect(screen.getByRole('heading', { name: /change handle/i })).toBeInTheDocument()
45
+
expect(screen.getByRole('heading', { name: /delete account/i })).toBeInTheDocument()
46
+
})
47
+
})
48
+
})
49
+
50
+
describe('email change', () => {
51
+
beforeEach(() => {
52
+
setupAuthenticatedUser()
53
+
})
54
+
55
+
it('displays current email and input field', async () => {
56
+
render(Settings)
57
+
58
+
await waitFor(() => {
59
+
expect(screen.getByText(/current: test@example.com/i)).toBeInTheDocument()
60
+
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
61
+
})
62
+
})
63
+
64
+
it('calls requestEmailUpdate when submitting', async () => {
65
+
let requestCalled = false
66
+
67
+
mockEndpoint('com.atproto.server.requestEmailUpdate', () => {
68
+
requestCalled = true
69
+
return jsonResponse({ tokenRequired: true })
70
+
})
71
+
72
+
render(Settings)
73
+
74
+
await waitFor(() => {
75
+
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
76
+
})
77
+
78
+
await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'newemail@example.com' } })
79
+
await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
80
+
81
+
await waitFor(() => {
82
+
expect(requestCalled).toBe(true)
83
+
})
84
+
})
85
+
86
+
it('shows verification code input when token is required', async () => {
87
+
mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
88
+
jsonResponse({ tokenRequired: true })
89
+
)
90
+
91
+
render(Settings)
92
+
93
+
await waitFor(() => {
94
+
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
95
+
})
96
+
97
+
await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'newemail@example.com' } })
98
+
await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
99
+
100
+
await waitFor(() => {
101
+
expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument()
102
+
expect(screen.getByRole('button', { name: /confirm email change/i })).toBeInTheDocument()
103
+
})
104
+
})
105
+
106
+
it('calls updateEmail with token when confirming', async () => {
107
+
let updateCalled = false
108
+
let capturedBody: Record<string, string> | null = null
109
+
110
+
mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
111
+
jsonResponse({ tokenRequired: true })
112
+
)
113
+
114
+
mockEndpoint('com.atproto.server.updateEmail', (_url, options) => {
115
+
updateCalled = true
116
+
capturedBody = JSON.parse((options?.body as string) || '{}')
117
+
return jsonResponse({})
118
+
})
119
+
120
+
render(Settings)
121
+
122
+
await waitFor(() => {
123
+
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
124
+
})
125
+
126
+
await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'newemail@example.com' } })
127
+
await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
128
+
129
+
await waitFor(() => {
130
+
expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument()
131
+
})
132
+
133
+
await fireEvent.input(screen.getByLabelText(/verification code/i), { target: { value: '123456' } })
134
+
await fireEvent.click(screen.getByRole('button', { name: /confirm email change/i }))
135
+
136
+
await waitFor(() => {
137
+
expect(updateCalled).toBe(true)
138
+
expect(capturedBody?.email).toBe('newemail@example.com')
139
+
expect(capturedBody?.token).toBe('123456')
140
+
})
141
+
})
142
+
143
+
it('shows success message after email update', async () => {
144
+
mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
145
+
jsonResponse({ tokenRequired: true })
146
+
)
147
+
148
+
mockEndpoint('com.atproto.server.updateEmail', () =>
149
+
jsonResponse({})
150
+
)
151
+
152
+
render(Settings)
153
+
154
+
await waitFor(() => {
155
+
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
156
+
})
157
+
158
+
await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'new@test.com' } })
159
+
await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
160
+
161
+
await waitFor(() => {
162
+
expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument()
163
+
})
164
+
165
+
await fireEvent.input(screen.getByLabelText(/verification code/i), { target: { value: '123456' } })
166
+
await fireEvent.click(screen.getByRole('button', { name: /confirm email change/i }))
167
+
168
+
await waitFor(() => {
169
+
expect(screen.getByText(/email updated successfully/i)).toBeInTheDocument()
170
+
})
171
+
})
172
+
173
+
it('shows cancel button to return to email form', async () => {
174
+
mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
175
+
jsonResponse({ tokenRequired: true })
176
+
)
177
+
178
+
render(Settings)
179
+
180
+
await waitFor(() => {
181
+
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
182
+
})
183
+
184
+
await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'new@test.com' } })
185
+
await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
186
+
187
+
await waitFor(() => {
188
+
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
189
+
})
190
+
191
+
await fireEvent.click(screen.getByRole('button', { name: /cancel/i }))
192
+
193
+
await waitFor(() => {
194
+
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
195
+
expect(screen.queryByLabelText(/verification code/i)).not.toBeInTheDocument()
196
+
})
197
+
})
198
+
199
+
it('shows error when email update fails', async () => {
200
+
mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
201
+
errorResponse('InvalidEmail', 'Invalid email format', 400)
202
+
)
203
+
204
+
render(Settings)
205
+
206
+
await waitFor(() => {
207
+
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
208
+
})
209
+
210
+
await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'invalid@test.com' } })
211
+
212
+
await waitFor(() => {
213
+
expect(screen.getByRole('button', { name: /change email/i })).not.toBeDisabled()
214
+
})
215
+
216
+
await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
217
+
218
+
await waitFor(() => {
219
+
expect(screen.getByText(/invalid email format/i)).toBeInTheDocument()
220
+
})
221
+
})
222
+
})
223
+
224
+
describe('handle change', () => {
225
+
beforeEach(() => {
226
+
setupAuthenticatedUser()
227
+
})
228
+
229
+
it('displays current handle', async () => {
230
+
render(Settings)
231
+
232
+
await waitFor(() => {
233
+
expect(screen.getByText(/current: @testuser\.test\.bspds\.dev/i)).toBeInTheDocument()
234
+
})
235
+
})
236
+
237
+
it('calls updateHandle with new handle', async () => {
238
+
let capturedHandle: string | null = null
239
+
240
+
mockEndpoint('com.atproto.identity.updateHandle', (_url, options) => {
241
+
const body = JSON.parse((options?.body as string) || '{}')
242
+
capturedHandle = body.handle
243
+
return jsonResponse({})
244
+
})
245
+
246
+
render(Settings)
247
+
248
+
await waitFor(() => {
249
+
expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument()
250
+
})
251
+
252
+
await fireEvent.input(screen.getByLabelText(/new handle/i), { target: { value: 'newhandle.bsky.social' } })
253
+
await fireEvent.click(screen.getByRole('button', { name: /change handle/i }))
254
+
255
+
await waitFor(() => {
256
+
expect(capturedHandle).toBe('newhandle.bsky.social')
257
+
})
258
+
})
259
+
260
+
it('shows success message after handle change', async () => {
261
+
mockEndpoint('com.atproto.identity.updateHandle', () =>
262
+
jsonResponse({})
263
+
)
264
+
265
+
render(Settings)
266
+
267
+
await waitFor(() => {
268
+
expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument()
269
+
})
270
+
271
+
await fireEvent.input(screen.getByLabelText(/new handle/i), { target: { value: 'newhandle' } })
272
+
await fireEvent.click(screen.getByRole('button', { name: /change handle/i }))
273
+
274
+
await waitFor(() => {
275
+
expect(screen.getByText(/handle updated successfully/i)).toBeInTheDocument()
276
+
})
277
+
})
278
+
279
+
it('shows error when handle change fails', async () => {
280
+
mockEndpoint('com.atproto.identity.updateHandle', () =>
281
+
errorResponse('HandleNotAvailable', 'Handle is already taken', 400)
282
+
)
283
+
284
+
render(Settings)
285
+
286
+
await waitFor(() => {
287
+
expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument()
288
+
})
289
+
290
+
await fireEvent.input(screen.getByLabelText(/new handle/i), { target: { value: 'taken' } })
291
+
await fireEvent.click(screen.getByRole('button', { name: /change handle/i }))
292
+
293
+
await waitFor(() => {
294
+
expect(screen.getByText(/handle is already taken/i)).toBeInTheDocument()
295
+
})
296
+
})
297
+
})
298
+
299
+
describe('account deletion', () => {
300
+
beforeEach(() => {
301
+
setupAuthenticatedUser()
302
+
mockEndpoint('com.atproto.server.deleteSession', () =>
303
+
jsonResponse({})
304
+
)
305
+
})
306
+
307
+
it('displays delete section with warning and request button', async () => {
308
+
render(Settings)
309
+
310
+
await waitFor(() => {
311
+
expect(screen.getByText(/this action is irreversible/i)).toBeInTheDocument()
312
+
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
313
+
})
314
+
})
315
+
316
+
it('calls requestAccountDelete when clicking request', async () => {
317
+
let requestCalled = false
318
+
319
+
mockEndpoint('com.atproto.server.requestAccountDelete', () => {
320
+
requestCalled = true
321
+
return jsonResponse({})
322
+
})
323
+
324
+
render(Settings)
325
+
326
+
await waitFor(() => {
327
+
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
328
+
})
329
+
330
+
await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
331
+
332
+
await waitFor(() => {
333
+
expect(requestCalled).toBe(true)
334
+
})
335
+
})
336
+
337
+
it('shows confirmation form after requesting deletion', async () => {
338
+
mockEndpoint('com.atproto.server.requestAccountDelete', () =>
339
+
jsonResponse({})
340
+
)
341
+
342
+
render(Settings)
343
+
344
+
await waitFor(() => {
345
+
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
346
+
})
347
+
348
+
await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
349
+
350
+
await waitFor(() => {
351
+
expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument()
352
+
expect(screen.getByLabelText(/your password/i)).toBeInTheDocument()
353
+
expect(screen.getByRole('button', { name: /permanently delete account/i })).toBeInTheDocument()
354
+
})
355
+
})
356
+
357
+
it('shows confirmation dialog before final deletion', async () => {
358
+
const confirmSpy = vi.fn(() => false)
359
+
window.confirm = confirmSpy
360
+
361
+
mockEndpoint('com.atproto.server.requestAccountDelete', () =>
362
+
jsonResponse({})
363
+
)
364
+
365
+
render(Settings)
366
+
367
+
await waitFor(() => {
368
+
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
369
+
})
370
+
371
+
await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
372
+
373
+
await waitFor(() => {
374
+
expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument()
375
+
})
376
+
377
+
await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: 'ABC123' } })
378
+
await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: 'password' } })
379
+
await fireEvent.click(screen.getByRole('button', { name: /permanently delete account/i }))
380
+
381
+
expect(confirmSpy).toHaveBeenCalledWith(
382
+
expect.stringContaining('absolutely sure')
383
+
)
384
+
})
385
+
386
+
it('calls deleteAccount with correct parameters', async () => {
387
+
window.confirm = vi.fn(() => true)
388
+
let capturedBody: Record<string, string> | null = null
389
+
390
+
mockEndpoint('com.atproto.server.requestAccountDelete', () =>
391
+
jsonResponse({})
392
+
)
393
+
394
+
mockEndpoint('com.atproto.server.deleteAccount', (_url, options) => {
395
+
capturedBody = JSON.parse((options?.body as string) || '{}')
396
+
return jsonResponse({})
397
+
})
398
+
399
+
render(Settings)
400
+
401
+
await waitFor(() => {
402
+
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
403
+
})
404
+
405
+
await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
406
+
407
+
await waitFor(() => {
408
+
expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument()
409
+
})
410
+
411
+
await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: 'DEL123' } })
412
+
await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: 'mypassword' } })
413
+
await fireEvent.click(screen.getByRole('button', { name: /permanently delete account/i }))
414
+
415
+
await waitFor(() => {
416
+
expect(capturedBody?.token).toBe('DEL123')
417
+
expect(capturedBody?.password).toBe('mypassword')
418
+
expect(capturedBody?.did).toBe('did:web:test.bspds.dev:u:testuser')
419
+
})
420
+
})
421
+
422
+
it('navigates to login after successful deletion', async () => {
423
+
window.confirm = vi.fn(() => true)
424
+
425
+
mockEndpoint('com.atproto.server.requestAccountDelete', () =>
426
+
jsonResponse({})
427
+
)
428
+
429
+
mockEndpoint('com.atproto.server.deleteAccount', () =>
430
+
jsonResponse({})
431
+
)
432
+
433
+
render(Settings)
434
+
435
+
await waitFor(() => {
436
+
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
437
+
})
438
+
439
+
await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
440
+
441
+
await waitFor(() => {
442
+
expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument()
443
+
})
444
+
445
+
await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: 'DEL123' } })
446
+
await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: 'password' } })
447
+
await fireEvent.click(screen.getByRole('button', { name: /permanently delete account/i }))
448
+
449
+
await waitFor(() => {
450
+
expect(window.location.hash).toBe('#/login')
451
+
})
452
+
})
453
+
454
+
it('shows cancel button to return to request state', async () => {
455
+
mockEndpoint('com.atproto.server.requestAccountDelete', () =>
456
+
jsonResponse({})
457
+
)
458
+
459
+
render(Settings)
460
+
461
+
await waitFor(() => {
462
+
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
463
+
})
464
+
465
+
await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
466
+
467
+
await waitFor(() => {
468
+
const cancelButtons = screen.getAllByRole('button', { name: /cancel/i })
469
+
expect(cancelButtons.length).toBeGreaterThan(0)
470
+
})
471
+
472
+
const deleteHeading = screen.getByRole('heading', { name: /delete account/i })
473
+
const deleteSection = deleteHeading.closest('section')
474
+
const cancelButton = deleteSection?.querySelector('button.secondary')
475
+
if (cancelButton) {
476
+
await fireEvent.click(cancelButton)
477
+
}
478
+
479
+
await waitFor(() => {
480
+
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
481
+
})
482
+
})
483
+
484
+
it('shows error when deletion fails', async () => {
485
+
window.confirm = vi.fn(() => true)
486
+
487
+
mockEndpoint('com.atproto.server.requestAccountDelete', () =>
488
+
jsonResponse({})
489
+
)
490
+
491
+
mockEndpoint('com.atproto.server.deleteAccount', () =>
492
+
errorResponse('InvalidToken', 'Invalid confirmation code', 400)
493
+
)
494
+
495
+
render(Settings)
496
+
497
+
await waitFor(() => {
498
+
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
499
+
})
500
+
501
+
await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
502
+
503
+
await waitFor(() => {
504
+
expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument()
505
+
})
506
+
507
+
await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: 'WRONG' } })
508
+
await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: 'password' } })
509
+
await fireEvent.click(screen.getByRole('button', { name: /permanently delete account/i }))
510
+
511
+
await waitFor(() => {
512
+
expect(screen.getByText(/invalid confirmation code/i)).toBeInTheDocument()
513
+
})
514
+
})
515
+
})
516
+
})
+264
frontend/src/tests/mocks.ts
+264
frontend/src/tests/mocks.ts
···
1
+
import { vi } from 'vitest'
2
+
import type { Session, AppPassword, InviteCode } from '../lib/api'
3
+
import { _testSetState } from '../lib/auth.svelte'
4
+
5
+
export interface MockResponse {
6
+
ok: boolean
7
+
status: number
8
+
json: () => Promise<unknown>
9
+
}
10
+
11
+
export type MockHandler = (url: string, options?: RequestInit) => MockResponse | Promise<MockResponse>
12
+
13
+
const mockHandlers: Map<string, MockHandler> = new Map()
14
+
15
+
export function mockEndpoint(endpoint: string, handler: MockHandler): void {
16
+
mockHandlers.set(endpoint, handler)
17
+
}
18
+
19
+
export function mockEndpointOnce(endpoint: string, handler: MockHandler): void {
20
+
const originalHandler = mockHandlers.get(endpoint)
21
+
mockHandlers.set(endpoint, (url, options) => {
22
+
mockHandlers.set(endpoint, originalHandler!)
23
+
return handler(url, options)
24
+
})
25
+
}
26
+
27
+
export function clearMocks(): void {
28
+
mockHandlers.clear()
29
+
}
30
+
31
+
function extractEndpoint(url: string): string {
32
+
const match = url.match(/\/xrpc\/([^?]+)/)
33
+
return match ? match[1] : url
34
+
}
35
+
36
+
export function setupFetchMock(): void {
37
+
global.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
38
+
const url = typeof input === 'string' ? input : input.toString()
39
+
const endpoint = extractEndpoint(url)
40
+
41
+
const handler = mockHandlers.get(endpoint)
42
+
if (handler) {
43
+
const result = await handler(url, init)
44
+
return {
45
+
ok: result.ok,
46
+
status: result.status,
47
+
json: result.json,
48
+
text: async () => JSON.stringify(await result.json()),
49
+
headers: new Headers(),
50
+
redirected: false,
51
+
statusText: result.ok ? 'OK' : 'Error',
52
+
type: 'basic',
53
+
url,
54
+
clone: () => ({ ...result }) as Response,
55
+
body: null,
56
+
bodyUsed: false,
57
+
arrayBuffer: async () => new ArrayBuffer(0),
58
+
blob: async () => new Blob(),
59
+
formData: async () => new FormData(),
60
+
} as Response
61
+
}
62
+
63
+
return {
64
+
ok: false,
65
+
status: 404,
66
+
json: async () => ({ error: 'NotFound', message: `No mock for ${endpoint}` }),
67
+
text: async () => JSON.stringify({ error: 'NotFound', message: `No mock for ${endpoint}` }),
68
+
headers: new Headers(),
69
+
redirected: false,
70
+
statusText: 'Not Found',
71
+
type: 'basic',
72
+
url,
73
+
clone: function() { return this },
74
+
body: null,
75
+
bodyUsed: false,
76
+
arrayBuffer: async () => new ArrayBuffer(0),
77
+
blob: async () => new Blob(),
78
+
formData: async () => new FormData(),
79
+
} as Response
80
+
})
81
+
}
82
+
83
+
export function jsonResponse<T>(data: T, status = 200): MockResponse {
84
+
return {
85
+
ok: status >= 200 && status < 300,
86
+
status,
87
+
json: async () => data,
88
+
}
89
+
}
90
+
91
+
export function errorResponse(error: string, message: string, status = 400): MockResponse {
92
+
return {
93
+
ok: false,
94
+
status,
95
+
json: async () => ({ error, message }),
96
+
}
97
+
}
98
+
99
+
export const mockData = {
100
+
session: (overrides?: Partial<Session>): Session => ({
101
+
did: 'did:web:test.bspds.dev:u:testuser',
102
+
handle: 'testuser.test.bspds.dev',
103
+
email: 'test@example.com',
104
+
emailConfirmed: true,
105
+
accessJwt: 'mock-access-jwt-token',
106
+
refreshJwt: 'mock-refresh-jwt-token',
107
+
...overrides,
108
+
}),
109
+
110
+
appPassword: (overrides?: Partial<AppPassword>): AppPassword => ({
111
+
name: 'Test App',
112
+
createdAt: new Date().toISOString(),
113
+
...overrides,
114
+
}),
115
+
116
+
inviteCode: (overrides?: Partial<InviteCode>): InviteCode => ({
117
+
code: 'test-invite-123',
118
+
available: 1,
119
+
disabled: false,
120
+
forAccount: 'did:web:test.bspds.dev:u:testuser',
121
+
createdBy: 'did:web:test.bspds.dev:u:testuser',
122
+
createdAt: new Date().toISOString(),
123
+
uses: [],
124
+
...overrides,
125
+
}),
126
+
127
+
notificationPrefs: (overrides?: Record<string, unknown>) => ({
128
+
preferredChannel: 'email',
129
+
email: 'test@example.com',
130
+
discordId: null,
131
+
discordVerified: false,
132
+
telegramUsername: null,
133
+
telegramVerified: false,
134
+
signalNumber: null,
135
+
signalVerified: false,
136
+
...overrides,
137
+
}),
138
+
139
+
describeServer: () => ({
140
+
availableUserDomains: ['test.bspds.dev'],
141
+
inviteCodeRequired: false,
142
+
links: {
143
+
privacyPolicy: 'https://example.com/privacy',
144
+
termsOfService: 'https://example.com/tos',
145
+
},
146
+
}),
147
+
148
+
describeRepo: (did: string) => ({
149
+
handle: 'testuser.test.bspds.dev',
150
+
did,
151
+
didDoc: {},
152
+
collections: ['app.bsky.feed.post', 'app.bsky.feed.like', 'app.bsky.graph.follow'],
153
+
handleIsCorrect: true,
154
+
}),
155
+
}
156
+
157
+
export function setupDefaultMocks(): void {
158
+
setupFetchMock()
159
+
160
+
mockEndpoint('com.atproto.server.getSession', () =>
161
+
jsonResponse(mockData.session())
162
+
)
163
+
164
+
mockEndpoint('com.atproto.server.createSession', (_url, options) => {
165
+
const body = JSON.parse((options?.body as string) || '{}')
166
+
if (body.identifier && body.password === 'correctpassword') {
167
+
return jsonResponse(mockData.session({ handle: body.identifier.replace('@', '') }))
168
+
}
169
+
return errorResponse('AuthenticationRequired', 'Invalid identifier or password', 401)
170
+
})
171
+
172
+
mockEndpoint('com.atproto.server.refreshSession', () =>
173
+
jsonResponse(mockData.session())
174
+
)
175
+
176
+
mockEndpoint('com.atproto.server.deleteSession', () =>
177
+
jsonResponse({})
178
+
)
179
+
180
+
mockEndpoint('com.atproto.server.listAppPasswords', () =>
181
+
jsonResponse({ passwords: [mockData.appPassword()] })
182
+
)
183
+
184
+
mockEndpoint('com.atproto.server.createAppPassword', (_url, options) => {
185
+
const body = JSON.parse((options?.body as string) || '{}')
186
+
return jsonResponse({
187
+
name: body.name,
188
+
password: 'xxxx-xxxx-xxxx-xxxx',
189
+
createdAt: new Date().toISOString(),
190
+
})
191
+
})
192
+
193
+
mockEndpoint('com.atproto.server.revokeAppPassword', () =>
194
+
jsonResponse({})
195
+
)
196
+
197
+
mockEndpoint('com.atproto.server.getAccountInviteCodes', () =>
198
+
jsonResponse({ codes: [mockData.inviteCode()] })
199
+
)
200
+
201
+
mockEndpoint('com.atproto.server.createInviteCode', () =>
202
+
jsonResponse({ code: 'new-invite-' + Date.now() })
203
+
)
204
+
205
+
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
206
+
jsonResponse(mockData.notificationPrefs())
207
+
)
208
+
209
+
mockEndpoint('com.bspds.account.updateNotificationPrefs', () =>
210
+
jsonResponse({ success: true })
211
+
)
212
+
213
+
mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
214
+
jsonResponse({ tokenRequired: true })
215
+
)
216
+
217
+
mockEndpoint('com.atproto.server.updateEmail', () =>
218
+
jsonResponse({})
219
+
)
220
+
221
+
mockEndpoint('com.atproto.identity.updateHandle', () =>
222
+
jsonResponse({})
223
+
)
224
+
225
+
mockEndpoint('com.atproto.server.requestAccountDelete', () =>
226
+
jsonResponse({})
227
+
)
228
+
229
+
mockEndpoint('com.atproto.server.deleteAccount', () =>
230
+
jsonResponse({})
231
+
)
232
+
233
+
mockEndpoint('com.atproto.server.describeServer', () =>
234
+
jsonResponse(mockData.describeServer())
235
+
)
236
+
237
+
mockEndpoint('com.atproto.repo.describeRepo', (url) => {
238
+
const params = new URLSearchParams(url.split('?')[1])
239
+
const repo = params.get('repo') || 'did:web:test'
240
+
return jsonResponse(mockData.describeRepo(repo))
241
+
})
242
+
243
+
mockEndpoint('com.atproto.repo.listRecords', () =>
244
+
jsonResponse({ records: [] })
245
+
)
246
+
}
247
+
248
+
export function setupAuthenticatedUser(sessionOverrides?: Partial<Session>): Session {
249
+
const session = mockData.session(sessionOverrides)
250
+
_testSetState({
251
+
session,
252
+
loading: false,
253
+
error: null,
254
+
})
255
+
return session
256
+
}
257
+
258
+
export function setupUnauthenticatedUser(): void {
259
+
_testSetState({
260
+
session: null,
261
+
loading: false,
262
+
error: null,
263
+
})
264
+
}
+35
frontend/src/tests/setup.ts
+35
frontend/src/tests/setup.ts
···
1
+
import '@testing-library/jest-dom/vitest'
2
+
import { vi, beforeEach, afterEach } from 'vitest'
3
+
import { _testReset } from '../lib/auth.svelte'
4
+
5
+
let locationHash = ''
6
+
7
+
Object.defineProperty(window, 'location', {
8
+
value: {
9
+
get hash() { return locationHash },
10
+
set hash(value: string) {
11
+
locationHash = value.startsWith('#') ? value : `#${value}`
12
+
},
13
+
href: 'http://localhost:3000/',
14
+
origin: 'http://localhost:3000',
15
+
pathname: '/',
16
+
search: '',
17
+
assign: vi.fn(),
18
+
replace: vi.fn(),
19
+
reload: vi.fn(),
20
+
},
21
+
writable: true,
22
+
configurable: true,
23
+
})
24
+
25
+
beforeEach(() => {
26
+
vi.clearAllMocks()
27
+
localStorage.clear()
28
+
sessionStorage.clear()
29
+
locationHash = ''
30
+
_testReset()
31
+
})
32
+
33
+
afterEach(() => {
34
+
vi.restoreAllMocks()
35
+
})
+86
frontend/src/tests/utils.ts
+86
frontend/src/tests/utils.ts
···
1
+
import { render, type RenderResult } from '@testing-library/svelte'
2
+
import { tick } from 'svelte'
3
+
import type { ComponentType } from 'svelte'
4
+
5
+
export async function renderAndWait<T extends ComponentType>(
6
+
component: T,
7
+
options?: Parameters<typeof render>[1]
8
+
): Promise<RenderResult<T>> {
9
+
const result = render(component, options)
10
+
await tick()
11
+
await new Promise(resolve => setTimeout(resolve, 0))
12
+
return result
13
+
}
14
+
15
+
export async function waitForElement(
16
+
queryFn: () => HTMLElement | null,
17
+
timeout = 1000
18
+
): Promise<HTMLElement> {
19
+
const start = Date.now()
20
+
while (Date.now() - start < timeout) {
21
+
const element = queryFn()
22
+
if (element) return element
23
+
await new Promise(resolve => setTimeout(resolve, 10))
24
+
}
25
+
throw new Error('Element not found within timeout')
26
+
}
27
+
28
+
export async function waitForElementToDisappear(
29
+
queryFn: () => HTMLElement | null,
30
+
timeout = 1000
31
+
): Promise<void> {
32
+
const start = Date.now()
33
+
while (Date.now() - start < timeout) {
34
+
const element = queryFn()
35
+
if (!element) return
36
+
await new Promise(resolve => setTimeout(resolve, 10))
37
+
}
38
+
throw new Error('Element still present after timeout')
39
+
}
40
+
41
+
export async function waitForText(
42
+
container: HTMLElement,
43
+
text: string | RegExp,
44
+
timeout = 1000
45
+
): Promise<void> {
46
+
const start = Date.now()
47
+
while (Date.now() - start < timeout) {
48
+
const content = container.textContent || ''
49
+
if (typeof text === 'string' ? content.includes(text) : text.test(content)) {
50
+
return
51
+
}
52
+
await new Promise(resolve => setTimeout(resolve, 10))
53
+
}
54
+
throw new Error(`Text "${text}" not found within timeout`)
55
+
}
56
+
57
+
export function mockLocalStorage(initialData: Record<string, string> = {}): void {
58
+
const store: Record<string, string> = { ...initialData }
59
+
60
+
Object.defineProperty(window, 'localStorage', {
61
+
value: {
62
+
getItem: (key: string) => store[key] || null,
63
+
setItem: (key: string, value: string) => { store[key] = value },
64
+
removeItem: (key: string) => { delete store[key] },
65
+
clear: () => { Object.keys(store).forEach(key => delete store[key]) },
66
+
key: (index: number) => Object.keys(store)[index] || null,
67
+
get length() { return Object.keys(store).length },
68
+
},
69
+
writable: true,
70
+
})
71
+
}
72
+
73
+
export function setAuthState(session: {
74
+
did: string
75
+
handle: string
76
+
email?: string
77
+
emailConfirmed?: boolean
78
+
accessJwt: string
79
+
refreshJwt: string
80
+
}): void {
81
+
localStorage.setItem('session', JSON.stringify(session))
82
+
}
83
+
84
+
export function clearAuthState(): void {
85
+
localStorage.removeItem('session')
86
+
}
+7
frontend/svelte.config.js
+7
frontend/svelte.config.js
+19
frontend/vite.config.ts
+19
frontend/vite.config.ts
···
1
+
import { defineConfig } from 'vite'
2
+
import { svelte } from '@sveltejs/vite-plugin-svelte'
3
+
4
+
export default defineConfig({
5
+
plugins: [svelte()],
6
+
build: {
7
+
outDir: 'dist',
8
+
},
9
+
server: {
10
+
port: 5173,
11
+
proxy: {
12
+
'/xrpc': 'http://localhost:3000',
13
+
'/oauth': 'http://localhost:3000',
14
+
'/.well-known': 'http://localhost:3000',
15
+
'/health': 'http://localhost:3000',
16
+
'/u': 'http://localhost:3000',
17
+
}
18
+
}
19
+
})
+22
frontend/vitest.config.ts
+22
frontend/vitest.config.ts
···
1
+
import { defineConfig } from 'vitest/config'
2
+
import { svelte } from '@sveltejs/vite-plugin-svelte'
3
+
4
+
export default defineConfig({
5
+
plugins: [
6
+
svelte({
7
+
hot: false,
8
+
}),
9
+
],
10
+
resolve: {
11
+
conditions: ['browser', 'development'],
12
+
},
13
+
test: {
14
+
environment: 'jsdom',
15
+
globals: true,
16
+
setupFiles: ['./src/tests/setup.ts'],
17
+
include: ['src/**/*.{test,spec}.{js,ts}'],
18
+
alias: {
19
+
'svelte': 'svelte',
20
+
},
21
+
},
22
+
})
+29
justfile
+29
justfile
···
77
77
78
78
docker-build:
79
79
docker compose build
80
+
81
+
# Frontend commands (Deno)
82
+
frontend-dev:
83
+
. ~/.deno/env && cd frontend && deno task dev
84
+
85
+
frontend-build:
86
+
. ~/.deno/env && cd frontend && deno task build
87
+
88
+
frontend-clean:
89
+
rm -rf frontend/dist frontend/node_modules
90
+
91
+
# Frontend tests
92
+
frontend-test *args:
93
+
. ~/.deno/env && cd frontend && VITEST=true deno task test:run {{args}}
94
+
95
+
frontend-test-watch:
96
+
. ~/.deno/env && cd frontend && VITEST=true deno task test:watch
97
+
98
+
frontend-test-ui:
99
+
. ~/.deno/env && cd frontend && VITEST=true deno task test:ui
100
+
101
+
frontend-test-coverage:
102
+
. ~/.deno/env && cd frontend && VITEST=true deno task test:run --coverage
103
+
104
+
# Build all (frontend + backend)
105
+
build-all: frontend-build build
106
+
107
+
# Test all (backend + frontend)
108
+
test-all: test frontend-test
+61
-3
migrations/202512211400_initial_schema.sql
migrations/20251211_initial_schema.sql
+61
-3
migrations/202512211400_initial_schema.sql
migrations/20251211_initial_schema.sql
···
6
6
'password_reset',
7
7
'email_update',
8
8
'account_deletion',
9
-
'admin_email'
9
+
'admin_email',
10
+
'plc_operation',
11
+
'two_factor_code'
10
12
);
11
13
12
14
CREATE TABLE IF NOT EXISTS users (
13
15
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
14
16
handle TEXT NOT NULL UNIQUE,
15
-
email TEXT NOT NULL UNIQUE,
17
+
email TEXT UNIQUE,
16
18
did TEXT NOT NULL UNIQUE,
17
19
password_hash TEXT NOT NULL,
18
20
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
···
29
31
30
32
email_pending_verification TEXT,
31
33
email_confirmation_code TEXT,
32
-
email_confirmation_code_expires_at TIMESTAMPTZ
34
+
email_confirmation_code_expires_at TIMESTAMPTZ,
35
+
email_confirmed BOOLEAN NOT NULL DEFAULT FALSE,
36
+
37
+
two_factor_enabled BOOLEAN NOT NULL DEFAULT FALSE,
38
+
39
+
discord_id TEXT,
40
+
discord_verified BOOLEAN NOT NULL DEFAULT FALSE,
41
+
42
+
telegram_username TEXT,
43
+
telegram_verified BOOLEAN NOT NULL DEFAULT FALSE,
44
+
45
+
signal_number TEXT,
46
+
signal_verified BOOLEAN NOT NULL DEFAULT FALSE
33
47
);
34
48
35
49
CREATE INDEX IF NOT EXISTS idx_users_password_reset_code ON users(password_reset_code) WHERE password_reset_code IS NOT NULL;
36
50
CREATE INDEX IF NOT EXISTS idx_users_email_confirmation_code ON users(email_confirmation_code) WHERE email_confirmation_code IS NOT NULL;
51
+
CREATE INDEX IF NOT EXISTS idx_users_discord_id ON users(discord_id) WHERE discord_id IS NOT NULL;
52
+
CREATE INDEX IF NOT EXISTS idx_users_telegram_username ON users(telegram_username) WHERE telegram_username IS NOT NULL;
53
+
CREATE INDEX IF NOT EXISTS idx_users_signal_number ON users(signal_number) WHERE signal_number IS NOT NULL;
37
54
38
55
CREATE TABLE IF NOT EXISTS invite_codes (
39
56
code TEXT PRIMARY KEY,
···
62
79
CREATE TABLE IF NOT EXISTS repos (
63
80
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
64
81
repo_root_cid TEXT NOT NULL,
82
+
repo_rev TEXT,
65
83
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
66
84
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
67
85
);
···
79
97
rkey TEXT NOT NULL,
80
98
record_cid TEXT NOT NULL,
81
99
takedown_ref TEXT,
100
+
repo_rev TEXT,
82
101
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
83
102
UNIQUE(repo_id, collection, rkey)
84
103
);
104
+
105
+
CREATE INDEX idx_records_repo_rev ON records(repo_rev);
85
106
86
107
CREATE TABLE IF NOT EXISTS blobs (
87
108
cid TEXT PRIMARY KEY,
···
265
286
);
266
287
267
288
CREATE INDEX idx_oauth_dpop_jti_created_at ON oauth_dpop_jti(created_at);
289
+
290
+
CREATE TABLE plc_operation_tokens (
291
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
292
+
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
293
+
token TEXT NOT NULL UNIQUE,
294
+
expires_at TIMESTAMPTZ NOT NULL,
295
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
296
+
);
297
+
298
+
CREATE INDEX idx_plc_op_tokens_user ON plc_operation_tokens(user_id);
299
+
CREATE INDEX idx_plc_op_tokens_expires ON plc_operation_tokens(expires_at);
300
+
301
+
CREATE TABLE IF NOT EXISTS account_preferences (
302
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
303
+
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
304
+
name TEXT NOT NULL,
305
+
value_json JSONB NOT NULL,
306
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
307
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
308
+
UNIQUE(user_id, name)
309
+
);
310
+
311
+
CREATE INDEX IF NOT EXISTS idx_account_preferences_user_id ON account_preferences(user_id);
312
+
CREATE INDEX IF NOT EXISTS idx_account_preferences_name ON account_preferences(name);
313
+
314
+
CREATE TABLE oauth_2fa_challenge (
315
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
316
+
did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
317
+
request_uri TEXT NOT NULL,
318
+
code TEXT NOT NULL,
319
+
attempts INTEGER NOT NULL DEFAULT 0,
320
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
321
+
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '10 minutes'
322
+
);
323
+
324
+
CREATE INDEX idx_oauth_2fa_challenge_request_uri ON oauth_2fa_challenge(request_uri);
325
+
CREATE INDEX idx_oauth_2fa_challenge_expires ON oauth_2fa_challenge(expires_at);
-10
migrations/202512211406_plc_operation_tokens.sql
-10
migrations/202512211406_plc_operation_tokens.sql
···
1
-
CREATE TABLE plc_operation_tokens (
2
-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
3
-
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
4
-
token TEXT NOT NULL UNIQUE,
5
-
expires_at TIMESTAMPTZ NOT NULL,
6
-
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
7
-
);
8
-
9
-
CREATE INDEX idx_plc_op_tokens_user ON plc_operation_tokens(user_id);
10
-
CREATE INDEX idx_plc_op_tokens_expires ON plc_operation_tokens(expires_at);
-1
migrations/202512211407_add_plc_operation_notification_type.sql
-1
migrations/202512211407_add_plc_operation_notification_type.sql
···
1
-
ALTER TYPE notification_type ADD VALUE 'plc_operation';
-12
migrations/202512211500_account_preferences.sql
-12
migrations/202512211500_account_preferences.sql
···
1
-
CREATE TABLE IF NOT EXISTS account_preferences (
2
-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
3
-
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
4
-
name TEXT NOT NULL,
5
-
value_json JSONB NOT NULL,
6
-
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
7
-
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
8
-
UNIQUE(user_id, name)
9
-
);
10
-
11
-
CREATE INDEX IF NOT EXISTS idx_account_preferences_user_id ON account_preferences(user_id);
12
-
CREATE INDEX IF NOT EXISTS idx_account_preferences_name ON account_preferences(name);
-2
migrations/202512211600_add_repo_rev.sql
-2
migrations/202512211600_add_repo_rev.sql
-16
migrations/202512211700_add_2fa.sql
-16
migrations/202512211700_add_2fa.sql
···
1
-
ALTER TABLE users ADD COLUMN two_factor_enabled BOOLEAN NOT NULL DEFAULT FALSE;
2
-
3
-
ALTER TYPE notification_type ADD VALUE 'two_factor_code';
4
-
5
-
CREATE TABLE oauth_2fa_challenge (
6
-
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
7
-
did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
8
-
request_uri TEXT NOT NULL,
9
-
code TEXT NOT NULL,
10
-
attempts INTEGER NOT NULL DEFAULT 0,
11
-
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
12
-
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '10 minutes'
13
-
);
14
-
15
-
CREATE INDEX idx_oauth_2fa_challenge_request_uri ON oauth_2fa_challenge(request_uri);
16
-
CREATE INDEX idx_oauth_2fa_challenge_expires ON oauth_2fa_challenge(expires_at);
+13
-1
src/api/admin/account/email.rs
+13
-1
src/api/admin/account/email.rs
···
65
65
.await;
66
66
67
67
let (user_id, email, handle) = match user {
68
-
Ok(Some(row)) => (row.id, row.email, row.handle),
68
+
Ok(Some(row)) => {
69
+
let email = match row.email {
70
+
Some(e) => e,
71
+
None => {
72
+
return (
73
+
StatusCode::BAD_REQUEST,
74
+
Json(json!({"error": "NoEmail", "message": "Recipient has no email address"})),
75
+
)
76
+
.into_response();
77
+
}
78
+
};
79
+
(row.id, email, row.handle)
80
+
}
69
81
Ok(None) => {
70
82
return (
71
83
StatusCode::NOT_FOUND,
+2
-2
src/api/admin/account/info.rs
+2
-2
src/api/admin/account/info.rs
···
74
74
Json(AccountInfo {
75
75
did: row.did,
76
76
handle: row.handle,
77
-
email: Some(row.email),
77
+
email: row.email,
78
78
indexed_at: row.created_at.to_rfc3339(),
79
79
invite_note: None,
80
80
invites_disabled: false,
···
150
150
infos.push(AccountInfo {
151
151
did: row.did,
152
152
handle: row.handle,
153
-
email: Some(row.email),
153
+
email: row.email,
154
154
indexed_at: row.created_at.to_rfc3339(),
155
155
invite_note: None,
156
156
invites_disabled: false,
+94
-68
src/api/identity/account.rs
+94
-68
src/api/identity/account.rs
···
36
36
#[serde(rename_all = "camelCase")]
37
37
pub struct CreateAccountInput {
38
38
pub handle: String,
39
-
pub email: String,
39
+
pub email: Option<String>,
40
40
pub password: String,
41
41
pub invite_code: Option<String>,
42
42
pub did: Option<String>,
43
43
pub signing_key: Option<String>,
44
+
pub verification_channel: Option<String>,
45
+
pub discord_id: Option<String>,
46
+
pub telegram_username: Option<String>,
47
+
pub signal_number: Option<String>,
44
48
}
45
49
46
50
#[derive(Serialize)]
47
51
#[serde(rename_all = "camelCase")]
48
52
pub struct CreateAccountOutput {
49
-
pub access_jwt: String,
50
-
pub refresh_jwt: String,
51
53
pub handle: String,
52
54
pub did: String,
55
+
pub verification_required: bool,
56
+
pub verification_channel: String,
53
57
}
54
58
55
59
pub async fn create_account(
···
82
86
.into_response();
83
87
}
84
88
85
-
if !crate::api::validation::is_valid_email(&input.email) {
86
-
return (
87
-
StatusCode::BAD_REQUEST,
88
-
Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})),
89
-
)
90
-
.into_response();
89
+
let email: Option<String> = input.email.as_ref()
90
+
.map(|e| e.trim().to_string())
91
+
.filter(|e| !e.is_empty());
92
+
if let Some(ref email) = email {
93
+
if !crate::api::validation::is_valid_email(email) {
94
+
return (
95
+
StatusCode::BAD_REQUEST,
96
+
Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})),
97
+
)
98
+
.into_response();
99
+
}
91
100
}
92
101
93
102
let did = if let Some(d) = &input.did {
···
202
211
}
203
212
};
204
213
205
-
let user_insert = sqlx::query!(
206
-
"INSERT INTO users (handle, email, did, password_hash) VALUES ($1, $2, $3, $4) RETURNING id",
207
-
input.handle,
208
-
input.email,
209
-
did,
210
-
password_hash
214
+
let verification_channel = input.verification_channel.as_deref().unwrap_or("email");
215
+
let valid_channels = ["email", "discord", "telegram", "signal"];
216
+
if !valid_channels.contains(&verification_channel) {
217
+
return (
218
+
StatusCode::BAD_REQUEST,
219
+
Json(json!({"error": "InvalidVerificationChannel", "message": "Invalid verification channel. Must be one of: email, discord, telegram, signal"})),
220
+
)
221
+
.into_response();
222
+
}
223
+
224
+
let verification_recipient = match verification_channel {
225
+
"email" => match &input.email {
226
+
Some(email) if !email.trim().is_empty() => email.trim().to_string(),
227
+
_ => return (
228
+
StatusCode::BAD_REQUEST,
229
+
Json(json!({"error": "MissingEmail", "message": "Email is required when using email verification"})),
230
+
).into_response(),
231
+
},
232
+
"discord" => match &input.discord_id {
233
+
Some(id) if !id.trim().is_empty() => id.trim().to_string(),
234
+
_ => return (
235
+
StatusCode::BAD_REQUEST,
236
+
Json(json!({"error": "MissingDiscordId", "message": "Discord ID is required when using Discord verification"})),
237
+
).into_response(),
238
+
},
239
+
"telegram" => match &input.telegram_username {
240
+
Some(username) if !username.trim().is_empty() => username.trim().to_string(),
241
+
_ => return (
242
+
StatusCode::BAD_REQUEST,
243
+
Json(json!({"error": "MissingTelegramUsername", "message": "Telegram username is required when using Telegram verification"})),
244
+
).into_response(),
245
+
},
246
+
"signal" => match &input.signal_number {
247
+
Some(number) if !number.trim().is_empty() => number.trim().to_string(),
248
+
_ => return (
249
+
StatusCode::BAD_REQUEST,
250
+
Json(json!({"error": "MissingSignalNumber", "message": "Signal phone number is required when using Signal verification"})),
251
+
).into_response(),
252
+
},
253
+
_ => return (
254
+
StatusCode::BAD_REQUEST,
255
+
Json(json!({"error": "InvalidVerificationChannel", "message": "Invalid verification channel"})),
256
+
).into_response(),
257
+
};
258
+
259
+
let verification_code = format!("{:06}", rand::random::<u32>() % 1_000_000);
260
+
let code_expires_at = chrono::Utc::now() + chrono::Duration::minutes(30);
261
+
262
+
let user_insert: Result<(uuid::Uuid,), _> = sqlx::query_as(
263
+
r#"INSERT INTO users (
264
+
handle, email, did, password_hash,
265
+
email_confirmation_code, email_confirmation_code_expires_at,
266
+
preferred_notification_channel,
267
+
discord_id, telegram_username, signal_number
268
+
) VALUES ($1, $2, $3, $4, $5, $6, $7::notification_channel, $8, $9, $10) RETURNING id"#,
211
269
)
270
+
.bind(&input.handle)
271
+
.bind(&email)
272
+
.bind(&did)
273
+
.bind(&password_hash)
274
+
.bind(&verification_code)
275
+
.bind(&code_expires_at)
276
+
.bind(verification_channel)
277
+
.bind(input.discord_id.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty()))
278
+
.bind(input.telegram_username.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty()))
279
+
.bind(input.signal_number.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty()))
212
280
.fetch_one(&mut *tx)
213
281
.await;
214
282
215
283
let user_id = match user_insert {
216
-
Ok(row) => row.id,
284
+
Ok((id,)) => id,
217
285
Err(e) => {
218
286
if let Some(db_err) = e.as_database_error() {
219
287
if db_err.code().as_deref() == Some("23505") {
···
453
521
}
454
522
}
455
523
456
-
let access_meta = crate::auth::create_access_token_with_metadata(&did, &secret_key_bytes[..]).map_err(|e| {
457
-
error!("Error creating access token: {:?}", e);
458
-
(
459
-
StatusCode::INTERNAL_SERVER_ERROR,
460
-
Json(json!({"error": "InternalError"})),
461
-
)
462
-
.into_response()
463
-
});
464
-
let access_meta = match access_meta {
465
-
Ok(m) => m,
466
-
Err(r) => return r,
467
-
};
468
-
469
-
let refresh_meta = crate::auth::create_refresh_token_with_metadata(&did, &secret_key_bytes[..]).map_err(|e| {
470
-
error!("Error creating refresh token: {:?}", e);
471
-
(
472
-
StatusCode::INTERNAL_SERVER_ERROR,
473
-
Json(json!({"error": "InternalError"})),
474
-
)
475
-
.into_response()
476
-
});
477
-
let refresh_meta = match refresh_meta {
478
-
Ok(m) => m,
479
-
Err(r) => return r,
480
-
};
481
-
482
-
let session_insert =
483
-
sqlx::query!(
484
-
"INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)",
485
-
did,
486
-
access_meta.jti,
487
-
refresh_meta.jti,
488
-
access_meta.expires_at,
489
-
refresh_meta.expires_at
490
-
)
491
-
.execute(&mut *tx)
492
-
.await;
493
-
494
-
if let Err(e) = session_insert {
495
-
error!("Error inserting session: {:?}", e);
496
-
return (
497
-
StatusCode::INTERNAL_SERVER_ERROR,
498
-
Json(json!({"error": "InternalError"})),
499
-
)
500
-
.into_response();
501
-
}
502
-
503
524
if let Err(e) = tx.commit().await {
504
525
error!("Error committing transaction: {:?}", e);
505
526
return (
···
509
530
.into_response();
510
531
}
511
532
512
-
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
513
-
if let Err(e) = crate::notifications::enqueue_welcome(&state.db, user_id, &hostname).await {
514
-
warn!("Failed to enqueue welcome notification: {:?}", e);
533
+
if let Err(e) = crate::notifications::enqueue_signup_verification(
534
+
&state.db,
535
+
user_id,
536
+
verification_channel,
537
+
&verification_recipient,
538
+
&verification_code,
539
+
).await {
540
+
warn!("Failed to enqueue signup verification notification: {:?}", e);
515
541
}
516
542
517
543
(
518
544
StatusCode::OK,
519
545
Json(CreateAccountOutput {
520
-
access_jwt: access_meta.token,
521
-
refresh_jwt: refresh_meta.token,
522
546
handle: input.handle,
523
547
did,
548
+
verification_required: true,
549
+
verification_channel: verification_channel.to_string(),
524
550
}),
525
551
)
526
552
.into_response()
+1
src/api/mod.rs
+1
src/api/mod.rs
+248
src/api/notification_prefs.rs
+248
src/api/notification_prefs.rs
···
1
+
use axum::{
2
+
Json,
3
+
extract::State,
4
+
http::{HeaderMap, StatusCode},
5
+
response::{IntoResponse, Response},
6
+
};
7
+
use serde::{Deserialize, Serialize};
8
+
use serde_json::json;
9
+
use sqlx::Row;
10
+
use tracing::info;
11
+
12
+
use crate::auth::validate_bearer_token;
13
+
use crate::state::AppState;
14
+
15
+
#[derive(Serialize)]
16
+
#[serde(rename_all = "camelCase")]
17
+
pub struct NotificationPrefsResponse {
18
+
pub preferred_channel: String,
19
+
pub email: String,
20
+
pub discord_id: Option<String>,
21
+
pub discord_verified: bool,
22
+
pub telegram_username: Option<String>,
23
+
pub telegram_verified: bool,
24
+
pub signal_number: Option<String>,
25
+
pub signal_verified: bool,
26
+
}
27
+
28
+
pub async fn get_notification_prefs(
29
+
State(state): State<AppState>,
30
+
headers: HeaderMap,
31
+
) -> Response {
32
+
let token = match crate::auth::extract_bearer_token_from_header(
33
+
headers.get("Authorization").and_then(|h| h.to_str().ok()),
34
+
) {
35
+
Some(t) => t,
36
+
None => {
37
+
return (
38
+
StatusCode::UNAUTHORIZED,
39
+
Json(json!({"error": "AuthenticationRequired", "message": "Authentication required"})),
40
+
)
41
+
.into_response()
42
+
}
43
+
};
44
+
45
+
let user = match validate_bearer_token(&state.db, &token).await {
46
+
Ok(u) => u,
47
+
Err(_) => {
48
+
return (
49
+
StatusCode::UNAUTHORIZED,
50
+
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token"})),
51
+
)
52
+
.into_response()
53
+
}
54
+
};
55
+
56
+
let row = match sqlx::query(
57
+
r#"
58
+
SELECT
59
+
email,
60
+
preferred_notification_channel::text as channel,
61
+
discord_id,
62
+
discord_verified,
63
+
telegram_username,
64
+
telegram_verified,
65
+
signal_number,
66
+
signal_verified
67
+
FROM users
68
+
WHERE did = $1
69
+
"#
70
+
)
71
+
.bind(&user.did)
72
+
.fetch_one(&state.db)
73
+
.await
74
+
{
75
+
Ok(r) => r,
76
+
Err(e) => {
77
+
return (
78
+
StatusCode::INTERNAL_SERVER_ERROR,
79
+
Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})),
80
+
)
81
+
.into_response()
82
+
}
83
+
};
84
+
85
+
let email: String = row.get("email");
86
+
let channel: String = row.get("channel");
87
+
let discord_id: Option<String> = row.get("discord_id");
88
+
let discord_verified: bool = row.get("discord_verified");
89
+
let telegram_username: Option<String> = row.get("telegram_username");
90
+
let telegram_verified: bool = row.get("telegram_verified");
91
+
let signal_number: Option<String> = row.get("signal_number");
92
+
let signal_verified: bool = row.get("signal_verified");
93
+
94
+
Json(NotificationPrefsResponse {
95
+
preferred_channel: channel,
96
+
email,
97
+
discord_id,
98
+
discord_verified,
99
+
telegram_username,
100
+
telegram_verified,
101
+
signal_number,
102
+
signal_verified,
103
+
})
104
+
.into_response()
105
+
}
106
+
107
+
#[derive(Deserialize)]
108
+
#[serde(rename_all = "camelCase")]
109
+
pub struct UpdateNotificationPrefsInput {
110
+
pub preferred_channel: Option<String>,
111
+
pub discord_id: Option<String>,
112
+
pub telegram_username: Option<String>,
113
+
pub signal_number: Option<String>,
114
+
}
115
+
116
+
pub async fn update_notification_prefs(
117
+
State(state): State<AppState>,
118
+
headers: HeaderMap,
119
+
Json(input): Json<UpdateNotificationPrefsInput>,
120
+
) -> Response {
121
+
let token = match crate::auth::extract_bearer_token_from_header(
122
+
headers.get("Authorization").and_then(|h| h.to_str().ok()),
123
+
) {
124
+
Some(t) => t,
125
+
None => {
126
+
return (
127
+
StatusCode::UNAUTHORIZED,
128
+
Json(json!({"error": "AuthenticationRequired", "message": "Authentication required"})),
129
+
)
130
+
.into_response()
131
+
}
132
+
};
133
+
134
+
let user = match validate_bearer_token(&state.db, &token).await {
135
+
Ok(u) => u,
136
+
Err(_) => {
137
+
return (
138
+
StatusCode::UNAUTHORIZED,
139
+
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token"})),
140
+
)
141
+
.into_response()
142
+
}
143
+
};
144
+
145
+
if let Some(ref channel) = input.preferred_channel {
146
+
let valid_channels = ["email", "discord", "telegram", "signal"];
147
+
if !valid_channels.contains(&channel.as_str()) {
148
+
return (
149
+
StatusCode::BAD_REQUEST,
150
+
Json(json!({
151
+
"error": "InvalidRequest",
152
+
"message": "Invalid channel. Must be one of: email, discord, telegram, signal"
153
+
})),
154
+
)
155
+
.into_response();
156
+
}
157
+
158
+
if let Err(e) = sqlx::query(
159
+
r#"UPDATE users SET preferred_notification_channel = $1::notification_channel, updated_at = NOW() WHERE did = $2"#
160
+
)
161
+
.bind(channel)
162
+
.bind(&user.did)
163
+
.execute(&state.db)
164
+
.await
165
+
{
166
+
return (
167
+
StatusCode::INTERNAL_SERVER_ERROR,
168
+
Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})),
169
+
)
170
+
.into_response();
171
+
}
172
+
173
+
info!(did = %user.did, channel = %channel, "Updated preferred notification channel");
174
+
}
175
+
176
+
if let Some(ref discord_id) = input.discord_id {
177
+
let discord_id_clean: Option<&str> = if discord_id.is_empty() {
178
+
None
179
+
} else {
180
+
Some(discord_id.as_str())
181
+
};
182
+
183
+
if let Err(e) = sqlx::query(
184
+
r#"UPDATE users SET discord_id = $1, discord_verified = FALSE, updated_at = NOW() WHERE did = $2"#
185
+
)
186
+
.bind(discord_id_clean)
187
+
.bind(&user.did)
188
+
.execute(&state.db)
189
+
.await
190
+
{
191
+
return (
192
+
StatusCode::INTERNAL_SERVER_ERROR,
193
+
Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})),
194
+
)
195
+
.into_response();
196
+
}
197
+
198
+
info!(did = %user.did, "Updated Discord ID");
199
+
}
200
+
201
+
if let Some(ref telegram) = input.telegram_username {
202
+
let telegram_clean: Option<&str> = if telegram.is_empty() {
203
+
None
204
+
} else {
205
+
Some(telegram.trim_start_matches('@'))
206
+
};
207
+
208
+
if let Err(e) = sqlx::query(
209
+
r#"UPDATE users SET telegram_username = $1, telegram_verified = FALSE, updated_at = NOW() WHERE did = $2"#
210
+
)
211
+
.bind(telegram_clean)
212
+
.bind(&user.did)
213
+
.execute(&state.db)
214
+
.await
215
+
{
216
+
return (
217
+
StatusCode::INTERNAL_SERVER_ERROR,
218
+
Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})),
219
+
)
220
+
.into_response();
221
+
}
222
+
223
+
info!(did = %user.did, "Updated Telegram username");
224
+
}
225
+
226
+
if let Some(ref signal) = input.signal_number {
227
+
let signal_clean: Option<&str> = if signal.is_empty() { None } else { Some(signal.as_str()) };
228
+
229
+
if let Err(e) = sqlx::query(
230
+
r#"UPDATE users SET signal_number = $1, signal_verified = FALSE, updated_at = NOW() WHERE did = $2"#
231
+
)
232
+
.bind(signal_clean)
233
+
.bind(&user.did)
234
+
.execute(&state.db)
235
+
.await
236
+
{
237
+
return (
238
+
StatusCode::INTERNAL_SERVER_ERROR,
239
+
Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})),
240
+
)
241
+
.into_response();
242
+
}
243
+
244
+
info!(did = %user.did, "Updated Signal number");
245
+
}
246
+
247
+
Json(json!({"success": true})).into_response()
248
+
}
+23
src/api/repo/record/batch.rs
+23
src/api/repo/record/batch.rs
···
1
1
use super::validation::validate_record;
2
+
use super::write::has_verified_notification_channel;
2
3
use crate::api::repo::record::utils::{commit_and_log, RecordOp};
3
4
use crate::repo::tracking::TrackingBlockStore;
4
5
use crate::state::AppState;
···
108
109
Json(json!({"error": "InvalidRepo", "message": "Repo does not match authenticated user"})),
109
110
)
110
111
.into_response();
112
+
}
113
+
114
+
match has_verified_notification_channel(&state.db, &did).await {
115
+
Ok(true) => {}
116
+
Ok(false) => {
117
+
return (
118
+
StatusCode::FORBIDDEN,
119
+
Json(json!({
120
+
"error": "AccountNotVerified",
121
+
"message": "You must verify at least one notification channel (email, Discord, Telegram, or Signal) before creating records"
122
+
})),
123
+
)
124
+
.into_response();
125
+
}
126
+
Err(e) => {
127
+
error!("DB error checking notification channels: {}", e);
128
+
return (
129
+
StatusCode::INTERNAL_SERVER_ERROR,
130
+
Json(json!({"error": "InternalError"})),
131
+
)
132
+
.into_response();
133
+
}
111
134
}
112
135
113
136
if input.writes.is_empty() {
+51
src/api/repo/record/write.rs
+51
src/api/repo/record/write.rs
···
14
14
use jacquard_repo::{commit::Commit, mst::Mst, storage::BlockStore};
15
15
use serde::{Deserialize, Serialize};
16
16
use serde_json::json;
17
+
use sqlx::{PgPool, Row};
17
18
use std::str::FromStr;
18
19
use std::sync::Arc;
19
20
use tracing::error;
20
21
use uuid::Uuid;
21
22
23
+
pub async fn has_verified_notification_channel(db: &PgPool, did: &str) -> Result<bool, sqlx::Error> {
24
+
let row = sqlx::query(
25
+
r#"
26
+
SELECT
27
+
email_confirmed,
28
+
discord_verified,
29
+
telegram_verified,
30
+
signal_verified
31
+
FROM users
32
+
WHERE did = $1
33
+
"#
34
+
)
35
+
.bind(did)
36
+
.fetch_optional(db)
37
+
.await?;
38
+
39
+
match row {
40
+
Some(r) => {
41
+
let email_confirmed: bool = r.get("email_confirmed");
42
+
let discord_verified: bool = r.get("discord_verified");
43
+
let telegram_verified: bool = r.get("telegram_verified");
44
+
let signal_verified: bool = r.get("signal_verified");
45
+
Ok(email_confirmed || discord_verified || telegram_verified || signal_verified)
46
+
}
47
+
None => Ok(false),
48
+
}
49
+
}
50
+
22
51
pub async fn prepare_repo_write(
23
52
state: &AppState,
24
53
headers: &HeaderMap,
···
50
79
Json(json!({"error": "InvalidRepo", "message": "Repo does not match authenticated user"})),
51
80
)
52
81
.into_response());
82
+
}
83
+
84
+
match has_verified_notification_channel(&state.db, &auth_user.did).await {
85
+
Ok(true) => {}
86
+
Ok(false) => {
87
+
return Err((
88
+
StatusCode::FORBIDDEN,
89
+
Json(json!({
90
+
"error": "AccountNotVerified",
91
+
"message": "You must verify at least one notification channel (email, Discord, Telegram, or Signal) before creating records"
92
+
})),
93
+
)
94
+
.into_response());
95
+
}
96
+
Err(e) => {
97
+
error!("DB error checking notification channels: {}", e);
98
+
return Err((
99
+
StatusCode::INTERNAL_SERVER_ERROR,
100
+
Json(json!({"error": "InternalError"})),
101
+
)
102
+
.into_response());
103
+
}
53
104
}
54
105
55
106
let user_id = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", auth_user.did)
+4
-2
src/api/server/email.rs
+4
-2
src/api/server/email.rs
···
343
343
.into_response();
344
344
}
345
345
346
-
if new_email == current_email.to_lowercase() {
347
-
return (StatusCode::OK, Json(json!({}))).into_response();
346
+
if let Some(ref current) = current_email {
347
+
if new_email == current.to_lowercase() {
348
+
return (StatusCode::OK, Json(json!({}))).into_response();
349
+
}
348
350
}
349
351
350
352
let email_confirmed = stored_code.is_some() && email_pending_verification.is_some();
+9
-2
src/api/server/meta.rs
+9
-2
src/api/server/meta.rs
···
13
13
}
14
14
15
15
pub async fn describe_server() -> impl IntoResponse {
16
+
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
16
17
let domains_str =
17
-
std::env::var("AVAILABLE_USER_DOMAINS").unwrap_or_else(|_| "example.com".to_string());
18
+
std::env::var("AVAILABLE_USER_DOMAINS").unwrap_or_else(|_| pds_hostname.clone());
18
19
let domains: Vec<&str> = domains_str.split(',').map(|s| s.trim()).collect();
19
20
21
+
let invite_code_required = std::env::var("INVITE_CODE_REQUIRED")
22
+
.map(|v| v == "true" || v == "1")
23
+
.unwrap_or(false);
24
+
20
25
Json(json!({
21
-
"availableUserDomains": domains
26
+
"availableUserDomains": domains,
27
+
"inviteCodeRequired": invite_code_required,
28
+
"did": format!("did:web:{}", pds_hostname)
22
29
}))
23
30
}
24
31
+1
-1
src/api/server/mod.rs
+1
-1
src/api/server/mod.rs
···
18
18
pub use meta::{describe_server, health, robots_txt};
19
19
pub use password::{request_password_reset, reset_password};
20
20
pub use service_auth::get_service_auth;
21
-
pub use session::{create_session, delete_session, get_session, refresh_session};
21
+
pub use session::{confirm_signup, create_session, delete_session, get_session, refresh_session, resend_verification};
22
22
pub use signing_key::reserve_signing_key;
+252
-1
src/api/server/session.rs
+252
-1
src/api/server/session.rs
···
8
8
response::{IntoResponse, Response},
9
9
};
10
10
use bcrypt::verify;
11
+
use chrono::Utc;
11
12
use serde::{Deserialize, Serialize};
12
13
use serde_json::json;
13
14
use tracing::{error, info, warn};
···
64
65
}
65
66
66
67
let row = match sqlx::query!(
67
-
"SELECT u.id, u.did, u.handle, u.password_hash, k.key_bytes, k.encryption_version FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.handle = $1 OR u.email = $1",
68
+
r#"SELECT
69
+
u.id, u.did, u.handle, u.password_hash,
70
+
u.email_confirmed, u.discord_verified, u.telegram_verified, u.signal_verified,
71
+
k.key_bytes, k.encryption_version
72
+
FROM users u
73
+
JOIN user_keys k ON u.id = k.user_id
74
+
WHERE u.handle = $1 OR u.email = $1"#,
68
75
input.identifier
69
76
)
70
77
.fetch_optional(&state.db)
···
101
108
if !password_valid {
102
109
warn!("Password verification failed for login attempt");
103
110
return ApiError::AuthenticationFailedMsg("Invalid identifier or password".into()).into_response();
111
+
}
112
+
113
+
let is_verified = row.email_confirmed
114
+
|| row.discord_verified
115
+
|| row.telegram_verified
116
+
|| row.signal_verified;
117
+
118
+
if !is_verified {
119
+
warn!("Login attempt for unverified account: {}", row.did);
120
+
return (
121
+
StatusCode::FORBIDDEN,
122
+
Json(json!({
123
+
"error": "AccountNotVerified",
124
+
"message": "Please verify your account before logging in",
125
+
"did": row.did
126
+
})),
127
+
).into_response();
104
128
}
105
129
106
130
let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) {
···
361
385
}
362
386
}
363
387
}
388
+
389
+
#[derive(Deserialize)]
390
+
#[serde(rename_all = "camelCase")]
391
+
pub struct ConfirmSignupInput {
392
+
pub did: String,
393
+
pub verification_code: String,
394
+
}
395
+
396
+
#[derive(Serialize)]
397
+
#[serde(rename_all = "camelCase")]
398
+
pub struct ConfirmSignupOutput {
399
+
pub access_jwt: String,
400
+
pub refresh_jwt: String,
401
+
pub handle: String,
402
+
pub did: String,
403
+
}
404
+
405
+
pub async fn confirm_signup(
406
+
State(state): State<AppState>,
407
+
Json(input): Json<ConfirmSignupInput>,
408
+
) -> Response {
409
+
info!("confirm_signup called for DID: {}", input.did);
410
+
411
+
let row = match sqlx::query!(
412
+
r#"SELECT
413
+
u.id, u.did, u.handle,
414
+
u.email_confirmation_code,
415
+
u.email_confirmation_code_expires_at,
416
+
u.preferred_notification_channel as "channel: crate::notifications::NotificationChannel",
417
+
k.key_bytes, k.encryption_version
418
+
FROM users u
419
+
JOIN user_keys k ON u.id = k.user_id
420
+
WHERE u.did = $1"#,
421
+
input.did
422
+
)
423
+
.fetch_optional(&state.db)
424
+
.await
425
+
{
426
+
Ok(Some(row)) => row,
427
+
Ok(None) => {
428
+
warn!("User not found for confirm_signup: {}", input.did);
429
+
return ApiError::InvalidRequest("Invalid DID or verification code".into()).into_response();
430
+
}
431
+
Err(e) => {
432
+
error!("Database error in confirm_signup: {:?}", e);
433
+
return ApiError::InternalError.into_response();
434
+
}
435
+
};
436
+
437
+
let stored_code = match &row.email_confirmation_code {
438
+
Some(code) => code,
439
+
None => {
440
+
warn!("No verification code found for user: {}", input.did);
441
+
return ApiError::InvalidRequest("No pending verification".into()).into_response();
442
+
}
443
+
};
444
+
445
+
if stored_code != &input.verification_code {
446
+
warn!("Invalid verification code for user: {}", input.did);
447
+
return ApiError::InvalidRequest("Invalid verification code".into()).into_response();
448
+
}
449
+
450
+
if let Some(expires_at) = row.email_confirmation_code_expires_at {
451
+
if expires_at < Utc::now() {
452
+
warn!("Verification code expired for user: {}", input.did);
453
+
return ApiError::ExpiredTokenMsg("Verification code has expired".into()).into_response();
454
+
}
455
+
}
456
+
457
+
let key_bytes = match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
458
+
Ok(k) => k,
459
+
Err(e) => {
460
+
error!("Failed to decrypt user key: {:?}", e);
461
+
return ApiError::InternalError.into_response();
462
+
}
463
+
};
464
+
465
+
let verified_column = match row.channel {
466
+
crate::notifications::NotificationChannel::Email => "email_confirmed",
467
+
crate::notifications::NotificationChannel::Discord => "discord_verified",
468
+
crate::notifications::NotificationChannel::Telegram => "telegram_verified",
469
+
crate::notifications::NotificationChannel::Signal => "signal_verified",
470
+
};
471
+
472
+
let update_query = format!(
473
+
"UPDATE users SET {} = TRUE, email_confirmation_code = NULL, email_confirmation_code_expires_at = NULL WHERE did = $1",
474
+
verified_column
475
+
);
476
+
477
+
if let Err(e) = sqlx::query(&update_query)
478
+
.bind(&input.did)
479
+
.execute(&state.db)
480
+
.await
481
+
{
482
+
error!("Failed to update verification status: {:?}", e);
483
+
return ApiError::InternalError.into_response();
484
+
}
485
+
486
+
let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) {
487
+
Ok(m) => m,
488
+
Err(e) => {
489
+
error!("Failed to create access token: {:?}", e);
490
+
return ApiError::InternalError.into_response();
491
+
}
492
+
};
493
+
494
+
let refresh_meta = match crate::auth::create_refresh_token_with_metadata(&row.did, &key_bytes) {
495
+
Ok(m) => m,
496
+
Err(e) => {
497
+
error!("Failed to create refresh token: {:?}", e);
498
+
return ApiError::InternalError.into_response();
499
+
}
500
+
};
501
+
502
+
if let Err(e) = sqlx::query!(
503
+
"INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)",
504
+
row.did,
505
+
access_meta.jti,
506
+
refresh_meta.jti,
507
+
access_meta.expires_at,
508
+
refresh_meta.expires_at
509
+
)
510
+
.execute(&state.db)
511
+
.await
512
+
{
513
+
error!("Failed to insert session: {:?}", e);
514
+
return ApiError::InternalError.into_response();
515
+
}
516
+
517
+
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
518
+
if let Err(e) = crate::notifications::enqueue_welcome(&state.db, row.id, &hostname).await {
519
+
warn!("Failed to enqueue welcome notification: {:?}", e);
520
+
}
521
+
522
+
Json(ConfirmSignupOutput {
523
+
access_jwt: access_meta.token,
524
+
refresh_jwt: refresh_meta.token,
525
+
handle: row.handle,
526
+
did: row.did,
527
+
}).into_response()
528
+
}
529
+
530
+
#[derive(Deserialize)]
531
+
#[serde(rename_all = "camelCase")]
532
+
pub struct ResendVerificationInput {
533
+
pub did: String,
534
+
}
535
+
536
+
pub async fn resend_verification(
537
+
State(state): State<AppState>,
538
+
Json(input): Json<ResendVerificationInput>,
539
+
) -> Response {
540
+
info!("resend_verification called for DID: {}", input.did);
541
+
542
+
let row = match sqlx::query!(
543
+
r#"SELECT
544
+
id, handle, email,
545
+
preferred_notification_channel as "channel: crate::notifications::NotificationChannel",
546
+
discord_id, telegram_username, signal_number,
547
+
email_confirmed, discord_verified, telegram_verified, signal_verified
548
+
FROM users
549
+
WHERE did = $1"#,
550
+
input.did
551
+
)
552
+
.fetch_optional(&state.db)
553
+
.await
554
+
{
555
+
Ok(Some(row)) => row,
556
+
Ok(None) => {
557
+
return ApiError::InvalidRequest("User not found".into()).into_response();
558
+
}
559
+
Err(e) => {
560
+
error!("Database error in resend_verification: {:?}", e);
561
+
return ApiError::InternalError.into_response();
562
+
}
563
+
};
564
+
565
+
let is_verified = row.email_confirmed
566
+
|| row.discord_verified
567
+
|| row.telegram_verified
568
+
|| row.signal_verified;
569
+
570
+
if is_verified {
571
+
return ApiError::InvalidRequest("Account is already verified".into()).into_response();
572
+
}
573
+
574
+
let verification_code = format!("{:06}", rand::random::<u32>() % 1_000_000);
575
+
let code_expires_at = Utc::now() + chrono::Duration::minutes(30);
576
+
577
+
if let Err(e) = sqlx::query!(
578
+
"UPDATE users SET email_confirmation_code = $1, email_confirmation_code_expires_at = $2 WHERE did = $3",
579
+
verification_code,
580
+
code_expires_at,
581
+
input.did
582
+
)
583
+
.execute(&state.db)
584
+
.await
585
+
{
586
+
error!("Failed to update verification code: {:?}", e);
587
+
return ApiError::InternalError.into_response();
588
+
}
589
+
590
+
let (channel_str, recipient) = match row.channel {
591
+
crate::notifications::NotificationChannel::Email => ("email", row.email.clone().unwrap_or_default()),
592
+
crate::notifications::NotificationChannel::Discord => {
593
+
("discord", row.discord_id.unwrap_or_default())
594
+
}
595
+
crate::notifications::NotificationChannel::Telegram => {
596
+
("telegram", row.telegram_username.unwrap_or_default())
597
+
}
598
+
crate::notifications::NotificationChannel::Signal => {
599
+
("signal", row.signal_number.unwrap_or_default())
600
+
}
601
+
};
602
+
603
+
if let Err(e) = crate::notifications::enqueue_signup_verification(
604
+
&state.db,
605
+
row.id,
606
+
channel_str,
607
+
&recipient,
608
+
&verification_code,
609
+
).await {
610
+
warn!("Failed to enqueue verification notification: {:?}", e);
611
+
}
612
+
613
+
Json(json!({"success": true})).into_response()
614
+
}
+5
-1
src/crawlers.rs
+5
-1
src/crawlers.rs
···
106
106
cb.record_success().await;
107
107
}
108
108
} else {
109
+
let status = response.status();
110
+
let body = response.text().await.unwrap_or_default();
109
111
warn!(
110
112
crawler = %url,
111
-
status = %response.status(),
113
+
status = %status,
114
+
body = %body,
115
+
hostname = %hostname,
112
116
"Crawler notification returned non-success status"
113
117
);
114
118
if let Some(cb) = cb {
+31
-2
src/lib.rs
+31
-2
src/lib.rs
···
21
21
routing::{any, get, post},
22
22
};
23
23
use state::AppState;
24
+
use tower_http::services::{ServeDir, ServeFile};
24
25
25
26
pub fn app(state: AppState) -> Router {
26
-
Router::new()
27
+
let router = Router::new()
27
28
.route("/health", get(api::server::health))
28
29
.route("/xrpc/_health", get(api::server::health))
29
30
.route("/robots.txt", get(api::server::robots_txt))
···
50
51
.route(
51
52
"/xrpc/com.atproto.server.refreshSession",
52
53
post(api::server::refresh_session),
54
+
)
55
+
.route(
56
+
"/xrpc/com.atproto.server.confirmSignup",
57
+
post(api::server::confirm_signup),
58
+
)
59
+
.route(
60
+
"/xrpc/com.atproto.server.resendVerification",
61
+
post(api::server::resend_verification),
53
62
)
54
63
.route(
55
64
"/xrpc/com.atproto.server.getServiceAuth",
···
364
373
"/xrpc/com.atproto.temp.checkSignupQueue",
365
374
get(api::temp::check_signup_queue),
366
375
)
376
+
.route(
377
+
"/xrpc/com.bspds.account.getNotificationPrefs",
378
+
get(api::notification_prefs::get_notification_prefs),
379
+
)
380
+
.route(
381
+
"/xrpc/com.bspds.account.updateNotificationPrefs",
382
+
post(api::notification_prefs::update_notification_prefs),
383
+
)
367
384
.route("/xrpc/{*method}", any(api::proxy::proxy_handler))
368
-
.with_state(state)
385
+
.with_state(state);
386
+
387
+
let frontend_dir = std::env::var("FRONTEND_DIR")
388
+
.unwrap_or_else(|_| "./frontend/dist".to_string());
389
+
390
+
if std::path::Path::new(&frontend_dir).join("index.html").exists() {
391
+
let index_path = format!("{}/index.html", frontend_dir);
392
+
let serve_dir = ServeDir::new(&frontend_dir)
393
+
.not_found_service(ServeFile::new(index_path));
394
+
router.fallback_service(serve_dir)
395
+
} else {
396
+
router
397
+
}
369
398
}
+1
-1
src/notifications/mod.rs
+1
-1
src/notifications/mod.rs
···
9
9
pub use service::{
10
10
channel_display_name, enqueue_2fa_code, enqueue_account_deletion, enqueue_email_update,
11
11
enqueue_email_verification, enqueue_notification, enqueue_password_reset,
12
-
enqueue_plc_operation, enqueue_welcome, NotificationService,
12
+
enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome, NotificationService,
13
13
};
14
14
pub use types::{
15
15
NewNotification, NotificationChannel, NotificationStatus, NotificationType, QueuedNotification,
+47
-6
src/notifications/service.rs
+47
-6
src/notifications/service.rs
···
256
256
257
257
pub struct UserNotificationPrefs {
258
258
pub channel: NotificationChannel,
259
-
pub email: String,
259
+
pub email: Option<String>,
260
260
pub handle: String,
261
261
}
262
262
···
303
303
user_id,
304
304
prefs.channel,
305
305
super::types::NotificationType::Welcome,
306
-
prefs.email.clone(),
306
+
prefs.email.clone().unwrap_or_default(),
307
307
Some(format!("Welcome to {}", hostname)),
308
308
body,
309
309
),
···
356
356
user_id,
357
357
prefs.channel,
358
358
super::types::NotificationType::PasswordReset,
359
-
prefs.email.clone(),
359
+
prefs.email.clone().unwrap_or_default(),
360
360
Some(format!("Password Reset - {}", hostname)),
361
361
body,
362
362
),
···
409
409
user_id,
410
410
prefs.channel,
411
411
super::types::NotificationType::AccountDeletion,
412
-
prefs.email.clone(),
412
+
prefs.email.clone().unwrap_or_default(),
413
413
Some(format!("Account Deletion Request - {}", hostname)),
414
414
body,
415
415
),
···
436
436
user_id,
437
437
prefs.channel,
438
438
super::types::NotificationType::PlcOperation,
439
-
prefs.email.clone(),
439
+
prefs.email.clone().unwrap_or_default(),
440
440
Some(format!("{} - PLC Operation Token", hostname)),
441
441
body,
442
442
),
···
463
463
user_id,
464
464
prefs.channel,
465
465
super::types::NotificationType::TwoFactorCode,
466
-
prefs.email.clone(),
466
+
prefs.email.clone().unwrap_or_default(),
467
467
Some(format!("Sign-in Verification - {}", hostname)),
468
468
body,
469
469
),
···
479
479
NotificationChannel::Signal => "Signal",
480
480
}
481
481
}
482
+
483
+
pub async fn enqueue_signup_verification(
484
+
db: &PgPool,
485
+
user_id: Uuid,
486
+
channel: &str,
487
+
recipient: &str,
488
+
code: &str,
489
+
) -> Result<Uuid, sqlx::Error> {
490
+
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
491
+
492
+
let notification_channel = match channel {
493
+
"email" => NotificationChannel::Email,
494
+
"discord" => NotificationChannel::Discord,
495
+
"telegram" => NotificationChannel::Telegram,
496
+
"signal" => NotificationChannel::Signal,
497
+
_ => NotificationChannel::Email,
498
+
};
499
+
500
+
let body = format!(
501
+
"Welcome! Your account verification code is: {}\n\nThis code will expire in 30 minutes.\n\nEnter this code to complete your registration on {}.",
502
+
code, hostname
503
+
);
504
+
505
+
let subject = match notification_channel {
506
+
NotificationChannel::Email => Some(format!("Verify your account - {}", hostname)),
507
+
_ => None,
508
+
};
509
+
510
+
enqueue_notification(
511
+
db,
512
+
NewNotification::new(
513
+
user_id,
514
+
notification_channel,
515
+
super::types::NotificationType::EmailVerification,
516
+
recipient.to_string(),
517
+
subject,
518
+
body,
519
+
),
520
+
)
521
+
.await
522
+
}
+1
-1
src/oauth/db/device.rs
+1
-1
src/oauth/db/device.rs
+3
-2
src/oauth/templates.rs
+3
-2
src/oauth/templates.rs
···
477
477
pub struct DeviceAccount {
478
478
pub did: String,
479
479
pub handle: String,
480
-
pub email: String,
480
+
pub email: Option<String>,
481
481
pub last_used_at: DateTime<Utc>,
482
482
}
483
483
···
493
493
.iter()
494
494
.map(|account| {
495
495
let initials = get_initials(&account.handle);
496
+
let email_display = account.email.as_deref().unwrap_or("");
496
497
format!(
497
498
r#"<form method="POST" action="/oauth/authorize/select" style="margin:0">
498
499
<input type="hidden" name="request_uri" value="{request_uri}">
···
510
511
did = html_escape(&account.did),
511
512
initials = html_escape(&initials),
512
513
handle = html_escape(&account.handle),
513
-
email = html_escape(&account.email),
514
+
email = html_escape(email_display),
514
515
)
515
516
})
516
517
.collect();