this repo has no description

First UI idea done

lewis 519048c3 dc066596

Changed files
+8880 -233
.sqlx
frontend
migrations
src
+4
.gitignore
··· 4 5 reference-pds-hailey/ 6 reference-pds-bsky/
··· 4 5 reference-pds-hailey/ 6 reference-pds-bsky/ 7 + 8 + # Frontend build artifacts 9 + frontend/node_modules/ 10 + frontend/dist/
+1 -1
.sqlx/query-1658a90aede20695b0e6e87d2536fad5a538dbfc442625ef306272d2530ddc3a.json
··· 21 }, 22 "nullable": [ 23 false, 24 - false 25 ] 26 }, 27 "hash": "1658a90aede20695b0e6e87d2536fad5a538dbfc442625ef306272d2530ddc3a"
··· 21 }, 22 "nullable": [ 23 false, 24 + true 25 ] 26 }, 27 "hash": "1658a90aede20695b0e6e87d2536fad5a538dbfc442625ef306272d2530ddc3a"
+1 -1
.sqlx/query-176d30f31356a4d128764c9c2eece81f8079a29e40b07ba58adc4380d58068c8.json
··· 32 "nullable": [ 33 false, 34 false, 35 - false, 36 false 37 ] 38 },
··· 32 "nullable": [ 33 false, 34 false, 35 + true, 36 false 37 ] 38 },
+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
··· 64 "nullable": [ 65 false, 66 false, 67 - false, 68 false, 69 false, 70 false,
··· 64 "nullable": [ 65 false, 66 false, 67 + true, 68 false, 69 false, 70 false,
-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
··· 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
··· 32 "nullable": [ 33 false, 34 false, 35 - false, 36 false 37 ] 38 },
··· 32 "nullable": [ 33 false, 34 false, 35 + true, 36 false 37 ] 38 },
+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
··· 36 }, 37 "nullable": [ 38 false, 39 - false, 40 true, 41 true, 42 true
··· 36 }, 37 "nullable": [ 38 false, 39 + true, 40 true, 41 true, 42 true
+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
··· 37 ] 38 }, 39 "nullable": [ 40 - false, 41 false, 42 false 43 ]
··· 37 ] 38 }, 39 "nullable": [ 40 + true, 41 false, 42 false 43 ]
+1 -1
.sqlx/query-c2a90157c47bf1c36f08f4608932d214cc26b4794e0b922b1dae3dad18a7ddc0.json
··· 32 "nullable": [ 33 false, 34 false, 35 - false, 36 false 37 ] 38 },
··· 32 "nullable": [ 33 false, 34 false, 35 + true, 36 false 37 ] 38 },
+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
··· 26 }, 27 "nullable": [ 28 false, 29 - false, 30 false 31 ] 32 },
··· 26 }, 27 "nullable": [ 28 false, 29 + true, 30 false 31 ] 32 },
+26
Cargo.lock
··· 960 "thiserror 2.0.17", 961 "tokio", 962 "tokio-tungstenite", 963 "tracing", 964 "tracing-subscriber", 965 "urlencoding", ··· 2559 ] 2560 2561 [[package]] 2562 name = "httparse" 2563 version = "1.10.1" 2564 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3581 checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 3582 3583 [[package]] 3584 name = "mini-moka" 3585 version = "0.10.3" 3586 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 6009 dependencies = [ 6010 "bitflags", 6011 "bytes", 6012 "futures-util", 6013 "http 1.4.0", 6014 "http-body 1.0.1", 6015 "iri-string", 6016 "pin-project-lite", 6017 "tower", 6018 "tower-layer", 6019 "tower-service",
··· 960 "thiserror 2.0.17", 961 "tokio", 962 "tokio-tungstenite", 963 + "tower-http", 964 "tracing", 965 "tracing-subscriber", 966 "urlencoding", ··· 2560 ] 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]] 2569 name = "httparse" 2570 version = "1.10.1" 2571 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3588 checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" 3589 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]] 3601 name = "mini-moka" 3602 version = "0.10.3" 3603 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 6026 dependencies = [ 6027 "bitflags", 6028 "bytes", 6029 + "futures-core", 6030 "futures-util", 6031 "http 1.4.0", 6032 "http-body 1.0.1", 6033 + "http-body-util", 6034 + "http-range-header", 6035 + "httpdate", 6036 "iri-string", 6037 + "mime", 6038 + "mime_guess", 6039 + "percent-encoding", 6040 "pin-project-lite", 6041 + "tokio", 6042 + "tokio-util", 6043 "tower", 6044 "tower-layer", 6045 "tower-service",
+1
Cargo.toml
··· 50 iroh-car = "0.5.1" 51 image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] } 52 redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] } 53 54 [features] 55 external-infra = []
··· 50 iroh-car = "0.5.1" 51 image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] } 52 redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] } 53 + tower-http = { version = "0.6", features = ["fs"] } 54 55 [features] 56 external-infra = []
+10
Dockerfile
··· 1 FROM rust:1.91.1-alpine AS builder 2 3 RUN apk add ca-certificates openssl openssl-dev pkgconfig ··· 13 COPY .sqlx ./.sqlx 14 RUN touch src/main.rs && cargo build --release 15 16 FROM alpine:3.23 17 18 COPY --from=builder /app/target/release/bspds /usr/local/bin/bspds 19 COPY --from=builder /app/migrations /app/migrations 20 21 WORKDIR /app 22 23 ENV SERVER_HOST=0.0.0.0 24 ENV SERVER_PORT=3000 25 26 EXPOSE 3000 27
··· 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 8 FROM rust:1.91.1-alpine AS builder 9 10 RUN apk add ca-certificates openssl openssl-dev pkgconfig ··· 20 COPY .sqlx ./.sqlx 21 RUN touch src/main.rs && cargo build --release 22 23 + # Stage 3: Final image 24 FROM alpine:3.23 25 26 COPY --from=builder /app/target/release/bspds /usr/local/bin/bspds 27 COPY --from=builder /app/migrations /app/migrations 28 + COPY --from=frontend-builder /frontend/dist /app/frontend/dist 29 30 WORKDIR /app 31 32 ENV SERVER_HOST=0.0.0.0 33 ENV SERVER_PORT=3000 34 + ENV FRONTEND_DIR=/app/frontend/dist 35 36 EXPOSE 3000 37
+21
README.md
··· 14 - Crawler notifications via `requestCrawl` 15 - Multi-channel notifications: email, discord, telegram, signal 16 - Per-IP rate limiting on sensitive endpoints 17 18 ## Running Locally 19 ··· 77 just db-reset # Drop and recreate local database 78 ``` 79 80 ## Project Structure 81 82 ``` ··· 94 plc/ PLC directory client 95 circuit_breaker/ Circuit breaker for external services 96 rate_limit/ Per-IP rate limiting 97 tests/ Integration tests 98 migrations/ SQLx migrations 99 ```
··· 14 - Crawler notifications via `requestCrawl` 15 - Multi-channel notifications: email, discord, telegram, signal 16 - Per-IP rate limiting on sensitive endpoints 17 + - Built-in web UI for account management 18 19 ## Running Locally 20 ··· 78 just db-reset # Drop and recreate local database 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 + 100 ## Project Structure 101 102 ``` ··· 114 plc/ PLC directory client 115 circuit_breaker/ Circuit breaker for external services 116 rate_limit/ Per-IP rate limiting 117 + frontend/ Svelte web UI (deno) 118 tests/ Integration tests 119 migrations/ SQLx migrations 120 ```
+23 -14
TODO.md
··· 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 260 ### Architecture 261 - - [ ] Static SPA served from PDS (or separate static host) 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 265 266 ### PDS-Specific XRPC Endpoints (new) 267 Absolutely subject to change, "bspds" isn't even the real name of this pds thus far :D 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 271 - [ ] `com.bspds.account.getNotificationHistory` - list past notifications 272 - [ ] `com.bspds.account.verifyChannel` - initiate verification for Discord/Telegram/Signal 273 - [ ] `com.bspds.account.confirmChannelVerification` - confirm with code ··· 276 ### Frontend Views 277 Uses existing ATProto endpoints where possible: 278 279 User Dashboard 280 - - [ ] Account overview (uses `com.atproto.server.getSession`, `com.atproto.admin.getAccountInfo`) 281 - [ ] 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`) 284 285 Notification Preferences 286 - - [ ] Channel selector (uses `com.bspds.account.*` endpoints above) 287 - [ ] Verification flows for Discord/Telegram/Signal 288 - [ ] Notification history view 289 290 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`) 296 297 Admin Dashboard (privileged users only) 298 - [ ] User list (uses `com.atproto.admin.getAccountInfos` with pagination)
··· 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 260 ### Architecture 261 + - [x] Static SPA served from PDS (or separate static host) 262 - [ ] Frontend authenticates via OAuth 2.1 flow (same as any ATProto 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 266 ### PDS-Specific XRPC Endpoints (new) 267 Absolutely subject to change, "bspds" isn't even the real name of this pds thus far :D 268 Anyway... endpoints for PDS settings not covered by standard ATProto: 269 + - [x] `com.bspds.account.getNotificationPrefs` - get preferred channel, verified channels 270 + - [x] `com.bspds.account.updateNotificationPrefs` - set preferred channel 271 - [ ] `com.bspds.account.getNotificationHistory` - list past notifications 272 - [ ] `com.bspds.account.verifyChannel` - initiate verification for Discord/Telegram/Signal 273 - [ ] `com.bspds.account.confirmChannelVerification` - confirm with code ··· 276 ### Frontend Views 277 Uses existing ATProto endpoints where possible: 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 + 285 User Dashboard 286 + - [x] Account overview (uses `com.atproto.server.getSession`, `com.atproto.admin.getAccountInfo`) 287 - [ ] Active sessions view (needs new endpoint or extend existing) 288 + - [x] App passwords (uses `com.atproto.server.listAppPasswords`, `createAppPassword`, `revokeAppPassword`) 289 + - [x] Invite codes (uses `com.atproto.server.getAccountInviteCodes`, `createInviteCode`) 290 291 Notification Preferences 292 + - [x] Channel selector (uses `com.bspds.account.*` endpoints above) 293 - [ ] Verification flows for Discord/Telegram/Signal 294 - [ ] Notification history view 295 296 Account Settings 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`) 305 306 Admin Dashboard (privileged users only) 307 - [ ] User list (uses `com.atproto.admin.getAccountInfos` with pagination)
+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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 1 + import App from './App.svelte' 2 + import { mount } from 'svelte' 3 + 4 + const app = mount(App, { 5 + target: document.getElementById('app')!, 6 + }) 7 + 8 + export default app
+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">&larr; 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
···
··· 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
···
··· 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">&larr; 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
···
··· 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
···
··· 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">&larr; 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
···
··· 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
···
··· 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">&larr; 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">&rarr;</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
···
··· 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">&larr; 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 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
···
··· 1 + import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' 2 + 3 + const isTest = process.env.VITEST === 'true' || process.env.VITEST === true 4 + 5 + export default { 6 + preprocess: isTest ? [] : vitePreprocess(), 7 + }
+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
···
··· 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
··· 77 78 docker-build: 79 docker compose build
··· 77 78 docker-build: 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
··· 6 'password_reset', 7 'email_update', 8 'account_deletion', 9 - 'admin_email' 10 ); 11 12 CREATE TABLE IF NOT EXISTS users ( 13 id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 14 handle TEXT NOT NULL UNIQUE, 15 - email TEXT NOT NULL UNIQUE, 16 did TEXT NOT NULL UNIQUE, 17 password_hash TEXT NOT NULL, 18 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), ··· 29 30 email_pending_verification TEXT, 31 email_confirmation_code TEXT, 32 - email_confirmation_code_expires_at TIMESTAMPTZ 33 ); 34 35 CREATE INDEX IF NOT EXISTS idx_users_password_reset_code ON users(password_reset_code) WHERE password_reset_code IS NOT NULL; 36 CREATE INDEX IF NOT EXISTS idx_users_email_confirmation_code ON users(email_confirmation_code) WHERE email_confirmation_code IS NOT NULL; 37 38 CREATE TABLE IF NOT EXISTS invite_codes ( 39 code TEXT PRIMARY KEY, ··· 62 CREATE TABLE IF NOT EXISTS repos ( 63 user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, 64 repo_root_cid TEXT NOT NULL, 65 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 66 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 67 ); ··· 79 rkey TEXT NOT NULL, 80 record_cid TEXT NOT NULL, 81 takedown_ref TEXT, 82 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 83 UNIQUE(repo_id, collection, rkey) 84 ); 85 86 CREATE TABLE IF NOT EXISTS blobs ( 87 cid TEXT PRIMARY KEY, ··· 265 ); 266 267 CREATE INDEX idx_oauth_dpop_jti_created_at ON oauth_dpop_jti(created_at);
··· 6 'password_reset', 7 'email_update', 8 'account_deletion', 9 + 'admin_email', 10 + 'plc_operation', 11 + 'two_factor_code' 12 ); 13 14 CREATE TABLE IF NOT EXISTS users ( 15 id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 16 handle TEXT NOT NULL UNIQUE, 17 + email TEXT UNIQUE, 18 did TEXT NOT NULL UNIQUE, 19 password_hash TEXT NOT NULL, 20 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), ··· 31 32 email_pending_verification TEXT, 33 email_confirmation_code TEXT, 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 47 ); 48 49 CREATE INDEX IF NOT EXISTS idx_users_password_reset_code ON users(password_reset_code) WHERE password_reset_code IS NOT NULL; 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; 54 55 CREATE TABLE IF NOT EXISTS invite_codes ( 56 code TEXT PRIMARY KEY, ··· 79 CREATE TABLE IF NOT EXISTS repos ( 80 user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, 81 repo_root_cid TEXT NOT NULL, 82 + repo_rev TEXT, 83 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 84 updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 85 ); ··· 97 rkey TEXT NOT NULL, 98 record_cid TEXT NOT NULL, 99 takedown_ref TEXT, 100 + repo_rev TEXT, 101 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 102 UNIQUE(repo_id, collection, rkey) 103 ); 104 + 105 + CREATE INDEX idx_records_repo_rev ON records(repo_rev); 106 107 CREATE TABLE IF NOT EXISTS blobs ( 108 cid TEXT PRIMARY KEY, ··· 286 ); 287 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
··· 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 - ALTER TYPE notification_type ADD VALUE 'plc_operation';
···
-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
··· 1 - ALTER TABLE records ADD COLUMN repo_rev TEXT; 2 - CREATE INDEX idx_records_repo_rev ON records(repo_rev);
···
-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
··· 65 .await; 66 67 let (user_id, email, handle) = match user { 68 - Ok(Some(row)) => (row.id, row.email, row.handle), 69 Ok(None) => { 70 return ( 71 StatusCode::NOT_FOUND,
··· 65 .await; 66 67 let (user_id, email, handle) = match user { 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 + } 81 Ok(None) => { 82 return ( 83 StatusCode::NOT_FOUND,
+2 -2
src/api/admin/account/info.rs
··· 74 Json(AccountInfo { 75 did: row.did, 76 handle: row.handle, 77 - email: Some(row.email), 78 indexed_at: row.created_at.to_rfc3339(), 79 invite_note: None, 80 invites_disabled: false, ··· 150 infos.push(AccountInfo { 151 did: row.did, 152 handle: row.handle, 153 - email: Some(row.email), 154 indexed_at: row.created_at.to_rfc3339(), 155 invite_note: None, 156 invites_disabled: false,
··· 74 Json(AccountInfo { 75 did: row.did, 76 handle: row.handle, 77 + email: row.email, 78 indexed_at: row.created_at.to_rfc3339(), 79 invite_note: None, 80 invites_disabled: false, ··· 150 infos.push(AccountInfo { 151 did: row.did, 152 handle: row.handle, 153 + email: row.email, 154 indexed_at: row.created_at.to_rfc3339(), 155 invite_note: None, 156 invites_disabled: false,
+94 -68
src/api/identity/account.rs
··· 36 #[serde(rename_all = "camelCase")] 37 pub struct CreateAccountInput { 38 pub handle: String, 39 - pub email: String, 40 pub password: String, 41 pub invite_code: Option<String>, 42 pub did: Option<String>, 43 pub signing_key: Option<String>, 44 } 45 46 #[derive(Serialize)] 47 #[serde(rename_all = "camelCase")] 48 pub struct CreateAccountOutput { 49 - pub access_jwt: String, 50 - pub refresh_jwt: String, 51 pub handle: String, 52 pub did: String, 53 } 54 55 pub async fn create_account( ··· 82 .into_response(); 83 } 84 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(); 91 } 92 93 let did = if let Some(d) = &input.did { ··· 202 } 203 }; 204 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 211 ) 212 .fetch_one(&mut *tx) 213 .await; 214 215 let user_id = match user_insert { 216 - Ok(row) => row.id, 217 Err(e) => { 218 if let Some(db_err) = e.as_database_error() { 219 if db_err.code().as_deref() == Some("23505") { ··· 453 } 454 } 455 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 if let Err(e) = tx.commit().await { 504 error!("Error committing transaction: {:?}", e); 505 return ( ··· 509 .into_response(); 510 } 511 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); 515 } 516 517 ( 518 StatusCode::OK, 519 Json(CreateAccountOutput { 520 - access_jwt: access_meta.token, 521 - refresh_jwt: refresh_meta.token, 522 handle: input.handle, 523 did, 524 }), 525 ) 526 .into_response()
··· 36 #[serde(rename_all = "camelCase")] 37 pub struct CreateAccountInput { 38 pub handle: String, 39 + pub email: Option<String>, 40 pub password: String, 41 pub invite_code: Option<String>, 42 pub did: Option<String>, 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>, 48 } 49 50 #[derive(Serialize)] 51 #[serde(rename_all = "camelCase")] 52 pub struct CreateAccountOutput { 53 pub handle: String, 54 pub did: String, 55 + pub verification_required: bool, 56 + pub verification_channel: String, 57 } 58 59 pub async fn create_account( ··· 86 .into_response(); 87 } 88 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 + } 100 } 101 102 let did = if let Some(d) = &input.did { ··· 211 } 212 }; 213 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"#, 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())) 280 .fetch_one(&mut *tx) 281 .await; 282 283 let user_id = match user_insert { 284 + Ok((id,)) => id, 285 Err(e) => { 286 if let Some(db_err) = e.as_database_error() { 287 if db_err.code().as_deref() == Some("23505") { ··· 521 } 522 } 523 524 if let Err(e) = tx.commit().await { 525 error!("Error committing transaction: {:?}", e); 526 return ( ··· 530 .into_response(); 531 } 532 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); 541 } 542 543 ( 544 StatusCode::OK, 545 Json(CreateAccountOutput { 546 handle: input.handle, 547 did, 548 + verification_required: true, 549 + verification_channel: verification_channel.to_string(), 550 }), 551 ) 552 .into_response()
+1
src/api/mod.rs
··· 5 pub mod identity; 6 pub mod moderation; 7 pub mod notification; 8 pub mod proxy; 9 pub mod proxy_client; 10 pub mod read_after_write;
··· 5 pub mod identity; 6 pub mod moderation; 7 pub mod notification; 8 + pub mod notification_prefs; 9 pub mod proxy; 10 pub mod proxy_client; 11 pub mod read_after_write;
+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
··· 1 use super::validation::validate_record; 2 use crate::api::repo::record::utils::{commit_and_log, RecordOp}; 3 use crate::repo::tracking::TrackingBlockStore; 4 use crate::state::AppState; ··· 108 Json(json!({"error": "InvalidRepo", "message": "Repo does not match authenticated user"})), 109 ) 110 .into_response(); 111 } 112 113 if input.writes.is_empty() {
··· 1 use super::validation::validate_record; 2 + use super::write::has_verified_notification_channel; 3 use crate::api::repo::record::utils::{commit_and_log, RecordOp}; 4 use crate::repo::tracking::TrackingBlockStore; 5 use crate::state::AppState; ··· 109 Json(json!({"error": "InvalidRepo", "message": "Repo does not match authenticated user"})), 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 + } 134 } 135 136 if input.writes.is_empty() {
+51
src/api/repo/record/write.rs
··· 14 use jacquard_repo::{commit::Commit, mst::Mst, storage::BlockStore}; 15 use serde::{Deserialize, Serialize}; 16 use serde_json::json; 17 use std::str::FromStr; 18 use std::sync::Arc; 19 use tracing::error; 20 use uuid::Uuid; 21 22 pub async fn prepare_repo_write( 23 state: &AppState, 24 headers: &HeaderMap, ··· 50 Json(json!({"error": "InvalidRepo", "message": "Repo does not match authenticated user"})), 51 ) 52 .into_response()); 53 } 54 55 let user_id = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", auth_user.did)
··· 14 use jacquard_repo::{commit::Commit, mst::Mst, storage::BlockStore}; 15 use serde::{Deserialize, Serialize}; 16 use serde_json::json; 17 + use sqlx::{PgPool, Row}; 18 use std::str::FromStr; 19 use std::sync::Arc; 20 use tracing::error; 21 use uuid::Uuid; 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 + 51 pub async fn prepare_repo_write( 52 state: &AppState, 53 headers: &HeaderMap, ··· 79 Json(json!({"error": "InvalidRepo", "message": "Repo does not match authenticated user"})), 80 ) 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 + } 104 } 105 106 let user_id = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", auth_user.did)
+4 -2
src/api/server/email.rs
··· 343 .into_response(); 344 } 345 346 - if new_email == current_email.to_lowercase() { 347 - return (StatusCode::OK, Json(json!({}))).into_response(); 348 } 349 350 let email_confirmed = stored_code.is_some() && email_pending_verification.is_some();
··· 343 .into_response(); 344 } 345 346 + if let Some(ref current) = current_email { 347 + if new_email == current.to_lowercase() { 348 + return (StatusCode::OK, Json(json!({}))).into_response(); 349 + } 350 } 351 352 let email_confirmed = stored_code.is_some() && email_pending_verification.is_some();
+9 -2
src/api/server/meta.rs
··· 13 } 14 15 pub async fn describe_server() -> impl IntoResponse { 16 let domains_str = 17 - std::env::var("AVAILABLE_USER_DOMAINS").unwrap_or_else(|_| "example.com".to_string()); 18 let domains: Vec<&str> = domains_str.split(',').map(|s| s.trim()).collect(); 19 20 Json(json!({ 21 - "availableUserDomains": domains 22 })) 23 } 24
··· 13 } 14 15 pub async fn describe_server() -> impl IntoResponse { 16 + let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 17 let domains_str = 18 + std::env::var("AVAILABLE_USER_DOMAINS").unwrap_or_else(|_| pds_hostname.clone()); 19 let domains: Vec<&str> = domains_str.split(',').map(|s| s.trim()).collect(); 20 21 + let invite_code_required = std::env::var("INVITE_CODE_REQUIRED") 22 + .map(|v| v == "true" || v == "1") 23 + .unwrap_or(false); 24 + 25 Json(json!({ 26 + "availableUserDomains": domains, 27 + "inviteCodeRequired": invite_code_required, 28 + "did": format!("did:web:{}", pds_hostname) 29 })) 30 } 31
+1 -1
src/api/server/mod.rs
··· 18 pub use meta::{describe_server, health, robots_txt}; 19 pub use password::{request_password_reset, reset_password}; 20 pub use service_auth::get_service_auth; 21 - pub use session::{create_session, delete_session, get_session, refresh_session}; 22 pub use signing_key::reserve_signing_key;
··· 18 pub use meta::{describe_server, health, robots_txt}; 19 pub use password::{request_password_reset, reset_password}; 20 pub use service_auth::get_service_auth; 21 + pub use session::{confirm_signup, create_session, delete_session, get_session, refresh_session, resend_verification}; 22 pub use signing_key::reserve_signing_key;
+252 -1
src/api/server/session.rs
··· 8 response::{IntoResponse, Response}, 9 }; 10 use bcrypt::verify; 11 use serde::{Deserialize, Serialize}; 12 use serde_json::json; 13 use tracing::{error, info, warn}; ··· 64 } 65 66 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 input.identifier 69 ) 70 .fetch_optional(&state.db) ··· 101 if !password_valid { 102 warn!("Password verification failed for login attempt"); 103 return ApiError::AuthenticationFailedMsg("Invalid identifier or password".into()).into_response(); 104 } 105 106 let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) { ··· 361 } 362 } 363 }
··· 8 response::{IntoResponse, Response}, 9 }; 10 use bcrypt::verify; 11 + use chrono::Utc; 12 use serde::{Deserialize, Serialize}; 13 use serde_json::json; 14 use tracing::{error, info, warn}; ··· 65 } 66 67 let row = match sqlx::query!( 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"#, 75 input.identifier 76 ) 77 .fetch_optional(&state.db) ··· 108 if !password_valid { 109 warn!("Password verification failed for login attempt"); 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(); 128 } 129 130 let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) { ··· 385 } 386 } 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
··· 106 cb.record_success().await; 107 } 108 } else { 109 warn!( 110 crawler = %url, 111 - status = %response.status(), 112 "Crawler notification returned non-success status" 113 ); 114 if let Some(cb) = cb {
··· 106 cb.record_success().await; 107 } 108 } else { 109 + let status = response.status(); 110 + let body = response.text().await.unwrap_or_default(); 111 warn!( 112 crawler = %url, 113 + status = %status, 114 + body = %body, 115 + hostname = %hostname, 116 "Crawler notification returned non-success status" 117 ); 118 if let Some(cb) = cb {
+31 -2
src/lib.rs
··· 21 routing::{any, get, post}, 22 }; 23 use state::AppState; 24 25 pub fn app(state: AppState) -> Router { 26 - Router::new() 27 .route("/health", get(api::server::health)) 28 .route("/xrpc/_health", get(api::server::health)) 29 .route("/robots.txt", get(api::server::robots_txt)) ··· 50 .route( 51 "/xrpc/com.atproto.server.refreshSession", 52 post(api::server::refresh_session), 53 ) 54 .route( 55 "/xrpc/com.atproto.server.getServiceAuth", ··· 364 "/xrpc/com.atproto.temp.checkSignupQueue", 365 get(api::temp::check_signup_queue), 366 ) 367 .route("/xrpc/{*method}", any(api::proxy::proxy_handler)) 368 - .with_state(state) 369 }
··· 21 routing::{any, get, post}, 22 }; 23 use state::AppState; 24 + use tower_http::services::{ServeDir, ServeFile}; 25 26 pub fn app(state: AppState) -> Router { 27 + let router = Router::new() 28 .route("/health", get(api::server::health)) 29 .route("/xrpc/_health", get(api::server::health)) 30 .route("/robots.txt", get(api::server::robots_txt)) ··· 51 .route( 52 "/xrpc/com.atproto.server.refreshSession", 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), 62 ) 63 .route( 64 "/xrpc/com.atproto.server.getServiceAuth", ··· 373 "/xrpc/com.atproto.temp.checkSignupQueue", 374 get(api::temp::check_signup_queue), 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 + ) 384 .route("/xrpc/{*method}", any(api::proxy::proxy_handler)) 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 + } 398 }
+1 -1
src/notifications/mod.rs
··· 9 pub use service::{ 10 channel_display_name, enqueue_2fa_code, enqueue_account_deletion, enqueue_email_update, 11 enqueue_email_verification, enqueue_notification, enqueue_password_reset, 12 - enqueue_plc_operation, enqueue_welcome, NotificationService, 13 }; 14 pub use types::{ 15 NewNotification, NotificationChannel, NotificationStatus, NotificationType, QueuedNotification,
··· 9 pub use service::{ 10 channel_display_name, enqueue_2fa_code, enqueue_account_deletion, enqueue_email_update, 11 enqueue_email_verification, enqueue_notification, enqueue_password_reset, 12 + enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome, NotificationService, 13 }; 14 pub use types::{ 15 NewNotification, NotificationChannel, NotificationStatus, NotificationType, QueuedNotification,
+47 -6
src/notifications/service.rs
··· 256 257 pub struct UserNotificationPrefs { 258 pub channel: NotificationChannel, 259 - pub email: String, 260 pub handle: String, 261 } 262 ··· 303 user_id, 304 prefs.channel, 305 super::types::NotificationType::Welcome, 306 - prefs.email.clone(), 307 Some(format!("Welcome to {}", hostname)), 308 body, 309 ), ··· 356 user_id, 357 prefs.channel, 358 super::types::NotificationType::PasswordReset, 359 - prefs.email.clone(), 360 Some(format!("Password Reset - {}", hostname)), 361 body, 362 ), ··· 409 user_id, 410 prefs.channel, 411 super::types::NotificationType::AccountDeletion, 412 - prefs.email.clone(), 413 Some(format!("Account Deletion Request - {}", hostname)), 414 body, 415 ), ··· 436 user_id, 437 prefs.channel, 438 super::types::NotificationType::PlcOperation, 439 - prefs.email.clone(), 440 Some(format!("{} - PLC Operation Token", hostname)), 441 body, 442 ), ··· 463 user_id, 464 prefs.channel, 465 super::types::NotificationType::TwoFactorCode, 466 - prefs.email.clone(), 467 Some(format!("Sign-in Verification - {}", hostname)), 468 body, 469 ), ··· 479 NotificationChannel::Signal => "Signal", 480 } 481 }
··· 256 257 pub struct UserNotificationPrefs { 258 pub channel: NotificationChannel, 259 + pub email: Option<String>, 260 pub handle: String, 261 } 262 ··· 303 user_id, 304 prefs.channel, 305 super::types::NotificationType::Welcome, 306 + prefs.email.clone().unwrap_or_default(), 307 Some(format!("Welcome to {}", hostname)), 308 body, 309 ), ··· 356 user_id, 357 prefs.channel, 358 super::types::NotificationType::PasswordReset, 359 + prefs.email.clone().unwrap_or_default(), 360 Some(format!("Password Reset - {}", hostname)), 361 body, 362 ), ··· 409 user_id, 410 prefs.channel, 411 super::types::NotificationType::AccountDeletion, 412 + prefs.email.clone().unwrap_or_default(), 413 Some(format!("Account Deletion Request - {}", hostname)), 414 body, 415 ), ··· 436 user_id, 437 prefs.channel, 438 super::types::NotificationType::PlcOperation, 439 + prefs.email.clone().unwrap_or_default(), 440 Some(format!("{} - PLC Operation Token", hostname)), 441 body, 442 ), ··· 463 user_id, 464 prefs.channel, 465 super::types::NotificationType::TwoFactorCode, 466 + prefs.email.clone().unwrap_or_default(), 467 Some(format!("Sign-in Verification - {}", hostname)), 468 body, 469 ), ··· 479 NotificationChannel::Signal => "Signal", 480 } 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
··· 6 pub struct DeviceAccountRow { 7 pub did: String, 8 pub handle: String, 9 - pub email: String, 10 pub last_used_at: DateTime<Utc>, 11 } 12
··· 6 pub struct DeviceAccountRow { 7 pub did: String, 8 pub handle: String, 9 + pub email: Option<String>, 10 pub last_used_at: DateTime<Utc>, 11 } 12
+3 -2
src/oauth/templates.rs
··· 477 pub struct DeviceAccount { 478 pub did: String, 479 pub handle: String, 480 - pub email: String, 481 pub last_used_at: DateTime<Utc>, 482 } 483 ··· 493 .iter() 494 .map(|account| { 495 let initials = get_initials(&account.handle); 496 format!( 497 r#"<form method="POST" action="/oauth/authorize/select" style="margin:0"> 498 <input type="hidden" name="request_uri" value="{request_uri}"> ··· 510 did = html_escape(&account.did), 511 initials = html_escape(&initials), 512 handle = html_escape(&account.handle), 513 - email = html_escape(&account.email), 514 ) 515 }) 516 .collect();
··· 477 pub struct DeviceAccount { 478 pub did: String, 479 pub handle: String, 480 + pub email: Option<String>, 481 pub last_used_at: DateTime<Utc>, 482 } 483 ··· 493 .iter() 494 .map(|account| { 495 let initials = get_initials(&account.handle); 496 + let email_display = account.email.as_deref().unwrap_or(""); 497 format!( 498 r#"<form method="POST" action="/oauth/authorize/select" style="margin:0"> 499 <input type="hidden" name="request_uri" value="{request_uri}"> ··· 511 did = html_escape(&account.did), 512 initials = html_escape(&initials), 513 handle = html_escape(&account.handle), 514 + email = html_escape(email_display), 515 ) 516 }) 517 .collect();