mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-04 09:16:54 +00:00
First UI idea done
This commit is contained in:
@@ -4,3 +4,7 @@
|
||||
|
||||
reference-pds-hailey/
|
||||
reference-pds-bsky/
|
||||
|
||||
# Frontend build artifacts
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "1658a90aede20695b0e6e87d2536fad5a538dbfc442625ef306272d2530ddc3a"
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"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",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "did",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "handle",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "password_hash",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "email_confirmed",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "discord_verified",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "telegram_verified",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "signal_verified",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "key_bytes",
|
||||
"type_info": "Bytea"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "encryption_version",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "1f1d099cc5f5800a939c03b60b24e889c615bb4dab0895863fd59c913f7895fd"
|
||||
}
|
||||
+1
-1
@@ -64,7 +64,7 @@
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"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",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "did",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "handle",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "password_hash",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "key_bytes",
|
||||
"type_info": "Bytea"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "encryption_version",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "583ab12e7634fa1ac888dbe319f8cd77405ae6246656c8698a7618a5a29a4ccb"
|
||||
}
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO users (handle, email, did, password_hash) VALUES ($1, $2, $3, $4) RETURNING id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "6c3a6dbf8d0d2a460054f093bd2ec1130ea91911d7d187cafcb4573be12bfcf4"
|
||||
}
|
||||
+1
-1
@@ -32,7 +32,7 @@
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE users SET email_confirmation_code = $1, email_confirmation_code_expires_at = $2 WHERE did = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Timestamptz",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a507e7cd1c4d31c70f8e7d6c80fe4b6f8ac0b712c63421989faa413556bef6f1"
|
||||
}
|
||||
+1
-1
@@ -36,7 +36,7 @@
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"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",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "handle",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "email",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "channel: crate::notifications::NotificationChannel",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "notification_channel",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"email",
|
||||
"discord",
|
||||
"telegram",
|
||||
"signal"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "discord_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "telegram_username",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "signal_number",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "email_confirmed",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "discord_verified",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "telegram_verified",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "signal_verified",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "ae85520d67815e95802c0e28db120c3c10badee74f78722d3cea58d183734bf6"
|
||||
}
|
||||
+1
-1
@@ -37,7 +37,7 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"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",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "did",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "handle",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "email_confirmation_code",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "email_confirmation_code_expires_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "channel: crate::notifications::NotificationChannel",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "notification_channel",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"email",
|
||||
"discord",
|
||||
"telegram",
|
||||
"signal"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "key_bytes",
|
||||
"type_info": "Bytea"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "encryption_version",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "cab71411113374c8c388a35281c676b3822629d505e84d51b60162e80a43d190"
|
||||
}
|
||||
+1
-1
@@ -26,7 +26,7 @@
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
|
||||
Generated
+26
@@ -960,6 +960,7 @@ dependencies = [
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"urlencoding",
|
||||
@@ -2558,6 +2559,12 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-range-header"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
@@ -3580,6 +3587,16 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mime_guess"
|
||||
version = "2.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mini-moka"
|
||||
version = "0.10.3"
|
||||
@@ -6009,11 +6026,20 @@ checksum = "9cf146f99d442e8e68e585f5d798ccd3cad9a7835b917e09728880a862706456"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http 1.4.0",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"http-range-header",
|
||||
"httpdate",
|
||||
"iri-string",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
|
||||
@@ -50,6 +50,7 @@ uuid = { version = "1.19.0", features = ["v4", "fast-rng"] }
|
||||
iroh-car = "0.5.1"
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] }
|
||||
redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] }
|
||||
tower-http = { version = "0.6", features = ["fs"] }
|
||||
|
||||
[features]
|
||||
external-infra = []
|
||||
|
||||
+10
@@ -1,3 +1,10 @@
|
||||
# Stage 1: Build frontend with Deno
|
||||
FROM denoland/deno:alpine AS frontend-builder
|
||||
WORKDIR /frontend
|
||||
COPY frontend/ ./
|
||||
RUN deno task build
|
||||
|
||||
# Stage 2: Build Rust backend
|
||||
FROM rust:1.91.1-alpine AS builder
|
||||
|
||||
RUN apk add ca-certificates openssl openssl-dev pkgconfig
|
||||
@@ -13,15 +20,18 @@ COPY migrations ./migrations
|
||||
COPY .sqlx ./.sqlx
|
||||
RUN touch src/main.rs && cargo build --release
|
||||
|
||||
# Stage 3: Final image
|
||||
FROM alpine:3.23
|
||||
|
||||
COPY --from=builder /app/target/release/bspds /usr/local/bin/bspds
|
||||
COPY --from=builder /app/migrations /app/migrations
|
||||
COPY --from=frontend-builder /frontend/dist /app/frontend/dist
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV SERVER_HOST=0.0.0.0
|
||||
ENV SERVER_PORT=3000
|
||||
ENV FRONTEND_DIR=/app/frontend/dist
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ Uses PostgreSQL instead of SQLite, S3-compatible blob storage, and is designed t
|
||||
- Crawler notifications via `requestCrawl`
|
||||
- Multi-channel notifications: email, discord, telegram, signal
|
||||
- Per-IP rate limiting on sensitive endpoints
|
||||
- Built-in web UI for account management
|
||||
|
||||
## Running Locally
|
||||
|
||||
@@ -77,6 +78,25 @@ just lint # Clippy + fmt check
|
||||
just db-reset # Drop and recreate local database
|
||||
```
|
||||
|
||||
## Web UI
|
||||
|
||||
BSPDS includes a built-in web frontend for users to manage their accounts. Users can:
|
||||
|
||||
- Sign in and register new accounts
|
||||
- Manage app passwords
|
||||
- View and create invite codes
|
||||
- Update email and handle
|
||||
- Configure notification preferences
|
||||
- Browse their repository data
|
||||
|
||||
The frontend is built with svelte and deno, and is served directly by the PDS.
|
||||
|
||||
```bash
|
||||
just frontend-dev # Run frontend dev server
|
||||
just frontend-build # Build for production
|
||||
just frontend-test # Run frontend tests
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
@@ -94,6 +114,7 @@ src/
|
||||
plc/ PLC directory client
|
||||
circuit_breaker/ Circuit breaker for external services
|
||||
rate_limit/ Per-IP rate limiting
|
||||
frontend/ Svelte web UI (deno)
|
||||
tests/ Integration tests
|
||||
migrations/ SQLx migrations
|
||||
```
|
||||
|
||||
@@ -258,16 +258,16 @@ These are implemented at PDS level to enable local-first reads (read-after-write
|
||||
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.
|
||||
|
||||
### Architecture
|
||||
- [ ] Static SPA served from PDS (or separate static host)
|
||||
- [x] Static SPA served from PDS (or separate static host)
|
||||
- [ ] Frontend authenticates via OAuth 2.1 flow (same as any ATProto client)
|
||||
- [ ] All operations use standard XRPC endpoints (existing + new PDS-specific ones below)
|
||||
- [ ] No server-side sessions or CSRF - pure API client
|
||||
- [x] All operations use standard XRPC endpoints (existing + new PDS-specific ones below)
|
||||
- [x] No server-side sessions or CSRF - pure API client
|
||||
|
||||
### PDS-Specific XRPC Endpoints (new)
|
||||
Absolutely subject to change, "bspds" isn't even the real name of this pds thus far :D
|
||||
Anyway... endpoints for PDS settings not covered by standard ATProto:
|
||||
- [ ] `com.bspds.account.getNotificationPrefs` - get preferred channel, verified channels
|
||||
- [ ] `com.bspds.account.updateNotificationPrefs` - set preferred channel
|
||||
- [x] `com.bspds.account.getNotificationPrefs` - get preferred channel, verified channels
|
||||
- [x] `com.bspds.account.updateNotificationPrefs` - set preferred channel
|
||||
- [ ] `com.bspds.account.getNotificationHistory` - list past notifications
|
||||
- [ ] `com.bspds.account.verifyChannel` - initiate verification for Discord/Telegram/Signal
|
||||
- [ ] `com.bspds.account.confirmChannelVerification` - confirm with code
|
||||
@@ -276,23 +276,32 @@ Anyway... endpoints for PDS settings not covered by standard ATProto:
|
||||
### Frontend Views
|
||||
Uses existing ATProto endpoints where possible:
|
||||
|
||||
Authentication
|
||||
- [x] Login page (uses `com.atproto.server.createSession`)
|
||||
- [x] Registration page (uses `com.atproto.server.createAccount`)
|
||||
- [x] Signup verification flow (uses `com.atproto.server.confirmSignup`, `resendVerification`)
|
||||
- [ ] Password reset flow (uses `com.atproto.server.requestPasswordReset`, `resetPassword`)
|
||||
|
||||
User Dashboard
|
||||
- [ ] Account overview (uses `com.atproto.server.getSession`, `com.atproto.admin.getAccountInfo`)
|
||||
- [x] Account overview (uses `com.atproto.server.getSession`, `com.atproto.admin.getAccountInfo`)
|
||||
- [ ] Active sessions view (needs new endpoint or extend existing)
|
||||
- [ ] App passwords (uses `com.atproto.server.listAppPasswords`, `createAppPassword`, `revokeAppPassword`)
|
||||
- [ ] Invite codes (uses `com.atproto.server.getAccountInviteCodes`, `createInviteCode`)
|
||||
- [x] App passwords (uses `com.atproto.server.listAppPasswords`, `createAppPassword`, `revokeAppPassword`)
|
||||
- [x] Invite codes (uses `com.atproto.server.getAccountInviteCodes`, `createInviteCode`)
|
||||
|
||||
Notification Preferences
|
||||
- [ ] Channel selector (uses `com.bspds.account.*` endpoints above)
|
||||
- [x] Channel selector (uses `com.bspds.account.*` endpoints above)
|
||||
- [ ] Verification flows for Discord/Telegram/Signal
|
||||
- [ ] Notification history view
|
||||
|
||||
Account Settings
|
||||
- [ ] Email change (uses `com.atproto.server.requestEmailUpdate`, `updateEmail`)
|
||||
- [ ] Password change (uses `com.atproto.server.requestPasswordReset`, `resetPassword`)
|
||||
- [ ] Handle change (uses `com.atproto.identity.updateHandle`)
|
||||
- [ ] Account deletion (uses `com.atproto.server.requestAccountDelete`, `deleteAccount`)
|
||||
- [ ] Data export (uses `com.atproto.sync.getRepo`)
|
||||
- [x] Email change (uses `com.atproto.server.requestEmailUpdate`, `updateEmail`)
|
||||
- [ ] Password change while logged in (needs new endpoint - change password with current password)
|
||||
- [x] Handle change (uses `com.atproto.identity.updateHandle`)
|
||||
- [x] Account deletion (uses `com.atproto.server.requestAccountDelete`, `deleteAccount`)
|
||||
|
||||
Data Management
|
||||
- [x] Repo browser (browse collections, view/create/delete records via `com.atproto.repo.*`)
|
||||
- [ ] Data export/download (CAR file download via `com.atproto.sync.getRepo`)
|
||||
|
||||
Admin Dashboard (privileged users only)
|
||||
- [ ] User list (uses `com.atproto.admin.getAccountInfos` with pagination)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"tasks": {
|
||||
"dev": "deno run -A npm:vite",
|
||||
"build": "deno run -A npm:vite build",
|
||||
"preview": "deno run -A npm:vite preview",
|
||||
"test": "deno run -A npm:vitest",
|
||||
"test:run": "deno run -A npm:vitest run",
|
||||
"test:watch": "deno run -A npm:vitest watch",
|
||||
"test:ui": "deno run -A npm:vitest --ui",
|
||||
"test:coverage": "deno run -A npm:vitest run --coverage"
|
||||
},
|
||||
"nodeModulesDir": "auto"
|
||||
}
|
||||
Generated
+1309
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>BSPDS</title>
|
||||
<style>
|
||||
html { background: #fafafa; }
|
||||
@media (prefers-color-scheme: dark) { html { background: #1a1a1a; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "bspds-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/svelte": "^5.2.6",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"jsdom": "^25.0.1",
|
||||
"svelte": "^5.0.0",
|
||||
"vite": "^6.0.0",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<script lang="ts">
|
||||
import { getCurrentPath } from './lib/router.svelte'
|
||||
import { initAuth, getAuthState } from './lib/auth.svelte'
|
||||
import Login from './routes/Login.svelte'
|
||||
import Register from './routes/Register.svelte'
|
||||
import Dashboard from './routes/Dashboard.svelte'
|
||||
import AppPasswords from './routes/AppPasswords.svelte'
|
||||
import InviteCodes from './routes/InviteCodes.svelte'
|
||||
import Settings from './routes/Settings.svelte'
|
||||
import Notifications from './routes/Notifications.svelte'
|
||||
import RepoExplorer from './routes/RepoExplorer.svelte'
|
||||
|
||||
const auth = getAuthState()
|
||||
|
||||
$effect(() => {
|
||||
initAuth()
|
||||
})
|
||||
|
||||
function getComponent(path: string) {
|
||||
switch (path) {
|
||||
case '/login':
|
||||
return Login
|
||||
case '/register':
|
||||
return Register
|
||||
case '/dashboard':
|
||||
return Dashboard
|
||||
case '/app-passwords':
|
||||
return AppPasswords
|
||||
case '/invite-codes':
|
||||
return InviteCodes
|
||||
case '/settings':
|
||||
return Settings
|
||||
case '/notifications':
|
||||
return Notifications
|
||||
case '/repo':
|
||||
return RepoExplorer
|
||||
default:
|
||||
return auth.session ? Dashboard : Login
|
||||
}
|
||||
}
|
||||
|
||||
let currentPath = $derived(getCurrentPath())
|
||||
let CurrentComponent = $derived(getComponent(currentPath))
|
||||
</script>
|
||||
|
||||
<main>
|
||||
{#if auth.loading}
|
||||
<div class="loading">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
{:else}
|
||||
<CurrentComponent />
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
:global(:root) {
|
||||
--bg-primary: #fafafa;
|
||||
--bg-secondary: #f9f9f9;
|
||||
--bg-card: #ffffff;
|
||||
--bg-input: #ffffff;
|
||||
--bg-input-disabled: #f5f5f5;
|
||||
--text-primary: #333333;
|
||||
--text-secondary: #666666;
|
||||
--text-muted: #999999;
|
||||
--border-color: #dddddd;
|
||||
--border-color-light: #cccccc;
|
||||
--accent: #0066cc;
|
||||
--accent-hover: #0052a3;
|
||||
--success-bg: #dfd;
|
||||
--success-border: #8c8;
|
||||
--success-text: #060;
|
||||
--error-bg: #fee;
|
||||
--error-border: #fcc;
|
||||
--error-text: #c00;
|
||||
--warning-bg: #ffd;
|
||||
--warning-text: #660;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:global(:root) {
|
||||
--bg-primary: #1a1a1a;
|
||||
--bg-secondary: #242424;
|
||||
--bg-card: #2a2a2a;
|
||||
--bg-input: #333333;
|
||||
--bg-input-disabled: #2a2a2a;
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary: #a0a0a0;
|
||||
--text-muted: #707070;
|
||||
--border-color: #404040;
|
||||
--border-color-light: #505050;
|
||||
--accent: #4da6ff;
|
||||
--accent-hover: #7abbff;
|
||||
--success-bg: #1a3d1a;
|
||||
--success-border: #2d5a2d;
|
||||
--success-text: #7bc67b;
|
||||
--error-bg: #3d1a1a;
|
||||
--error-border: #5a2d2d;
|
||||
--error-text: #ff7b7b;
|
||||
--warning-bg: #3d3d1a;
|
||||
--warning-text: #c6c67b;
|
||||
}
|
||||
}
|
||||
|
||||
:global(body) {
|
||||
margin: 0;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
:global(*) {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
main {
|
||||
min-height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,341 @@
|
||||
const API_BASE = '/xrpc'
|
||||
|
||||
export class ApiError extends Error {
|
||||
public did?: string
|
||||
|
||||
constructor(public status: number, public error: string, message: string, did?: string) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.did = did
|
||||
}
|
||||
}
|
||||
|
||||
async function xrpc<T>(method: string, options?: {
|
||||
method?: 'GET' | 'POST'
|
||||
params?: Record<string, string>
|
||||
body?: unknown
|
||||
token?: string
|
||||
}): Promise<T> {
|
||||
const { method: httpMethod = 'GET', params, body, token } = options ?? {}
|
||||
|
||||
let url = `${API_BASE}/${method}`
|
||||
if (params) {
|
||||
const searchParams = new URLSearchParams(params)
|
||||
url += `?${searchParams}`
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {}
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
if (body) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: httpMethod,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: 'Unknown', message: res.statusText }))
|
||||
throw new ApiError(res.status, err.error, err.message, err.did)
|
||||
}
|
||||
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
did: string
|
||||
handle: string
|
||||
email?: string
|
||||
emailConfirmed?: boolean
|
||||
accessJwt: string
|
||||
refreshJwt: string
|
||||
}
|
||||
|
||||
export interface AppPassword {
|
||||
name: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface InviteCode {
|
||||
code: string
|
||||
available: number
|
||||
disabled: boolean
|
||||
forAccount: string
|
||||
createdBy: string
|
||||
createdAt: string
|
||||
uses: { usedBy: string; usedAt: string }[]
|
||||
}
|
||||
|
||||
export type VerificationChannel = 'email' | 'discord' | 'telegram' | 'signal'
|
||||
|
||||
export interface CreateAccountParams {
|
||||
handle: string
|
||||
email: string
|
||||
password: string
|
||||
inviteCode?: string
|
||||
verificationChannel?: VerificationChannel
|
||||
discordId?: string
|
||||
telegramUsername?: string
|
||||
signalNumber?: string
|
||||
}
|
||||
|
||||
export interface CreateAccountResult {
|
||||
handle: string
|
||||
did: string
|
||||
verificationRequired: boolean
|
||||
verificationChannel: string
|
||||
}
|
||||
|
||||
export interface ConfirmSignupResult {
|
||||
accessJwt: string
|
||||
refreshJwt: string
|
||||
handle: string
|
||||
did: string
|
||||
}
|
||||
|
||||
export const api = {
|
||||
async createAccount(params: CreateAccountParams): Promise<CreateAccountResult> {
|
||||
return xrpc('com.atproto.server.createAccount', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
handle: params.handle,
|
||||
email: params.email,
|
||||
password: params.password,
|
||||
inviteCode: params.inviteCode,
|
||||
verificationChannel: params.verificationChannel,
|
||||
discordId: params.discordId,
|
||||
telegramUsername: params.telegramUsername,
|
||||
signalNumber: params.signalNumber,
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
async confirmSignup(did: string, verificationCode: string): Promise<ConfirmSignupResult> {
|
||||
return xrpc('com.atproto.server.confirmSignup', {
|
||||
method: 'POST',
|
||||
body: { did, verificationCode },
|
||||
})
|
||||
},
|
||||
|
||||
async resendVerification(did: string): Promise<{ success: boolean }> {
|
||||
return xrpc('com.atproto.server.resendVerification', {
|
||||
method: 'POST',
|
||||
body: { did },
|
||||
})
|
||||
},
|
||||
|
||||
async createSession(identifier: string, password: string): Promise<Session> {
|
||||
return xrpc('com.atproto.server.createSession', {
|
||||
method: 'POST',
|
||||
body: { identifier, password },
|
||||
})
|
||||
},
|
||||
|
||||
async getSession(token: string): Promise<Session> {
|
||||
return xrpc('com.atproto.server.getSession', { token })
|
||||
},
|
||||
|
||||
async refreshSession(refreshJwt: string): Promise<Session> {
|
||||
return xrpc('com.atproto.server.refreshSession', {
|
||||
method: 'POST',
|
||||
token: refreshJwt,
|
||||
})
|
||||
},
|
||||
|
||||
async deleteSession(token: string): Promise<void> {
|
||||
await xrpc('com.atproto.server.deleteSession', {
|
||||
method: 'POST',
|
||||
token,
|
||||
})
|
||||
},
|
||||
|
||||
async listAppPasswords(token: string): Promise<{ passwords: AppPassword[] }> {
|
||||
return xrpc('com.atproto.server.listAppPasswords', { token })
|
||||
},
|
||||
|
||||
async createAppPassword(token: string, name: string): Promise<{ name: string; password: string; createdAt: string }> {
|
||||
return xrpc('com.atproto.server.createAppPassword', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { name },
|
||||
})
|
||||
},
|
||||
|
||||
async revokeAppPassword(token: string, name: string): Promise<void> {
|
||||
await xrpc('com.atproto.server.revokeAppPassword', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { name },
|
||||
})
|
||||
},
|
||||
|
||||
async getAccountInviteCodes(token: string): Promise<{ codes: InviteCode[] }> {
|
||||
return xrpc('com.atproto.server.getAccountInviteCodes', { token })
|
||||
},
|
||||
|
||||
async createInviteCode(token: string, useCount: number = 1): Promise<{ code: string }> {
|
||||
return xrpc('com.atproto.server.createInviteCode', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { useCount },
|
||||
})
|
||||
},
|
||||
|
||||
async requestPasswordReset(email: string): Promise<void> {
|
||||
await xrpc('com.atproto.server.requestPasswordReset', {
|
||||
method: 'POST',
|
||||
body: { email },
|
||||
})
|
||||
},
|
||||
|
||||
async resetPassword(token: string, password: string): Promise<void> {
|
||||
await xrpc('com.atproto.server.resetPassword', {
|
||||
method: 'POST',
|
||||
body: { token, password },
|
||||
})
|
||||
},
|
||||
|
||||
async requestEmailUpdate(token: string): Promise<{ tokenRequired: boolean }> {
|
||||
return xrpc('com.atproto.server.requestEmailUpdate', {
|
||||
method: 'POST',
|
||||
token,
|
||||
})
|
||||
},
|
||||
|
||||
async updateEmail(token: string, email: string, emailToken?: string): Promise<void> {
|
||||
await xrpc('com.atproto.server.updateEmail', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { email, token: emailToken },
|
||||
})
|
||||
},
|
||||
|
||||
async updateHandle(token: string, handle: string): Promise<void> {
|
||||
await xrpc('com.atproto.identity.updateHandle', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { handle },
|
||||
})
|
||||
},
|
||||
|
||||
async requestAccountDelete(token: string): Promise<void> {
|
||||
await xrpc('com.atproto.server.requestAccountDelete', {
|
||||
method: 'POST',
|
||||
token,
|
||||
})
|
||||
},
|
||||
|
||||
async deleteAccount(did: string, password: string, deleteToken: string): Promise<void> {
|
||||
await xrpc('com.atproto.server.deleteAccount', {
|
||||
method: 'POST',
|
||||
body: { did, password, token: deleteToken },
|
||||
})
|
||||
},
|
||||
|
||||
async describeServer(): Promise<{
|
||||
availableUserDomains: string[]
|
||||
inviteCodeRequired: boolean
|
||||
links?: { privacyPolicy?: string; termsOfService?: string }
|
||||
}> {
|
||||
return xrpc('com.atproto.server.describeServer')
|
||||
},
|
||||
|
||||
async getNotificationPrefs(token: string): Promise<{
|
||||
preferredChannel: string
|
||||
email: string
|
||||
discordId: string | null
|
||||
discordVerified: boolean
|
||||
telegramUsername: string | null
|
||||
telegramVerified: boolean
|
||||
signalNumber: string | null
|
||||
signalVerified: boolean
|
||||
}> {
|
||||
return xrpc('com.bspds.account.getNotificationPrefs', { token })
|
||||
},
|
||||
|
||||
async updateNotificationPrefs(token: string, prefs: {
|
||||
preferredChannel?: string
|
||||
discordId?: string
|
||||
telegramUsername?: string
|
||||
signalNumber?: string
|
||||
}): Promise<{ success: boolean }> {
|
||||
return xrpc('com.bspds.account.updateNotificationPrefs', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: prefs,
|
||||
})
|
||||
},
|
||||
|
||||
async describeRepo(token: string, repo: string): Promise<{
|
||||
handle: string
|
||||
did: string
|
||||
didDoc: unknown
|
||||
collections: string[]
|
||||
handleIsCorrect: boolean
|
||||
}> {
|
||||
return xrpc('com.atproto.repo.describeRepo', {
|
||||
token,
|
||||
params: { repo },
|
||||
})
|
||||
},
|
||||
|
||||
async listRecords(token: string, repo: string, collection: string, options?: {
|
||||
limit?: number
|
||||
cursor?: string
|
||||
reverse?: boolean
|
||||
}): Promise<{
|
||||
records: Array<{ uri: string; cid: string; value: unknown }>
|
||||
cursor?: string
|
||||
}> {
|
||||
const params: Record<string, string> = { repo, collection }
|
||||
if (options?.limit) params.limit = String(options.limit)
|
||||
if (options?.cursor) params.cursor = options.cursor
|
||||
if (options?.reverse) params.reverse = 'true'
|
||||
return xrpc('com.atproto.repo.listRecords', { token, params })
|
||||
},
|
||||
|
||||
async getRecord(token: string, repo: string, collection: string, rkey: string): Promise<{
|
||||
uri: string
|
||||
cid: string
|
||||
value: unknown
|
||||
}> {
|
||||
return xrpc('com.atproto.repo.getRecord', {
|
||||
token,
|
||||
params: { repo, collection, rkey },
|
||||
})
|
||||
},
|
||||
|
||||
async createRecord(token: string, repo: string, collection: string, record: unknown, rkey?: string): Promise<{
|
||||
uri: string
|
||||
cid: string
|
||||
}> {
|
||||
return xrpc('com.atproto.repo.createRecord', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { repo, collection, record, rkey },
|
||||
})
|
||||
},
|
||||
|
||||
async putRecord(token: string, repo: string, collection: string, rkey: string, record: unknown): Promise<{
|
||||
uri: string
|
||||
cid: string
|
||||
}> {
|
||||
return xrpc('com.atproto.repo.putRecord', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { repo, collection, rkey, record },
|
||||
})
|
||||
},
|
||||
|
||||
async deleteRecord(token: string, repo: string, collection: string, rkey: string): Promise<void> {
|
||||
await xrpc('com.atproto.repo.deleteRecord', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { repo, collection, rkey },
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { api, type Session, type CreateAccountParams, type CreateAccountResult, ApiError } from './api'
|
||||
|
||||
const STORAGE_KEY = 'bspds_session'
|
||||
|
||||
interface AuthState {
|
||||
session: Session | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
let state = $state<AuthState>({
|
||||
session: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
})
|
||||
|
||||
function saveSession(session: Session | null) {
|
||||
if (session) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(session))
|
||||
} else {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
function loadSession(): Session | null {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
try {
|
||||
return JSON.parse(stored)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function initAuth() {
|
||||
state.loading = true
|
||||
state.error = null
|
||||
|
||||
const stored = loadSession()
|
||||
if (stored) {
|
||||
try {
|
||||
const session = await api.getSession(stored.accessJwt)
|
||||
state.session = { ...session, accessJwt: stored.accessJwt, refreshJwt: stored.refreshJwt }
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 401) {
|
||||
try {
|
||||
const refreshed = await api.refreshSession(stored.refreshJwt)
|
||||
state.session = refreshed
|
||||
saveSession(refreshed)
|
||||
} catch {
|
||||
saveSession(null)
|
||||
state.session = null
|
||||
}
|
||||
} else {
|
||||
saveSession(null)
|
||||
state.session = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state.loading = false
|
||||
}
|
||||
|
||||
export async function login(identifier: string, password: string): Promise<void> {
|
||||
state.loading = true
|
||||
state.error = null
|
||||
|
||||
try {
|
||||
const session = await api.createSession(identifier, password)
|
||||
state.session = session
|
||||
saveSession(session)
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
state.error = e.message
|
||||
} else {
|
||||
state.error = 'Login failed'
|
||||
}
|
||||
throw e
|
||||
} finally {
|
||||
state.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
export async function register(params: CreateAccountParams): Promise<CreateAccountResult> {
|
||||
try {
|
||||
const result = await api.createAccount(params)
|
||||
return result
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
state.error = e.message
|
||||
} else {
|
||||
state.error = 'Registration failed'
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
export async function confirmSignup(did: string, verificationCode: string): Promise<void> {
|
||||
state.loading = true
|
||||
state.error = null
|
||||
|
||||
try {
|
||||
const result = await api.confirmSignup(did, verificationCode)
|
||||
const session: Session = {
|
||||
did: result.did,
|
||||
handle: result.handle,
|
||||
accessJwt: result.accessJwt,
|
||||
refreshJwt: result.refreshJwt,
|
||||
}
|
||||
state.session = session
|
||||
saveSession(session)
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
state.error = e.message
|
||||
} else {
|
||||
state.error = 'Verification failed'
|
||||
}
|
||||
throw e
|
||||
} finally {
|
||||
state.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
export async function resendVerification(did: string): Promise<void> {
|
||||
try {
|
||||
await api.resendVerification(did)
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
throw e
|
||||
}
|
||||
throw new Error('Failed to resend verification code')
|
||||
}
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
if (state.session) {
|
||||
try {
|
||||
await api.deleteSession(state.session.accessJwt)
|
||||
} catch {
|
||||
// Ignore errors on logout
|
||||
}
|
||||
}
|
||||
state.session = null
|
||||
saveSession(null)
|
||||
}
|
||||
|
||||
export function getAuthState() {
|
||||
return state
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
return state.session?.accessJwt ?? null
|
||||
}
|
||||
|
||||
export function isAuthenticated(): boolean {
|
||||
return state.session !== null
|
||||
}
|
||||
|
||||
export function _testSetState(newState: { session: Session | null; loading: boolean; error: string | null }) {
|
||||
state.session = newState.session
|
||||
state.loading = newState.loading
|
||||
state.error = newState.error
|
||||
}
|
||||
|
||||
export function _testReset() {
|
||||
state.session = null
|
||||
state.loading = true
|
||||
state.error = null
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
let currentPath = $state(window.location.hash.slice(1) || '/')
|
||||
|
||||
window.addEventListener('hashchange', () => {
|
||||
currentPath = window.location.hash.slice(1) || '/'
|
||||
})
|
||||
|
||||
export function navigate(path: string) {
|
||||
window.location.hash = path
|
||||
}
|
||||
|
||||
export function getCurrentPath() {
|
||||
return currentPath
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import App from './App.svelte'
|
||||
import { mount } from 'svelte'
|
||||
|
||||
const app = mount(App, {
|
||||
target: document.getElementById('app')!,
|
||||
})
|
||||
|
||||
export default app
|
||||
@@ -0,0 +1,333 @@
|
||||
<script lang="ts">
|
||||
import { getAuthState } from '../lib/auth.svelte'
|
||||
import { navigate } from '../lib/router.svelte'
|
||||
import { api, type AppPassword, ApiError } from '../lib/api'
|
||||
|
||||
const auth = getAuthState()
|
||||
|
||||
let passwords = $state<AppPassword[]>([])
|
||||
let loading = $state(true)
|
||||
let error = $state<string | null>(null)
|
||||
|
||||
let newPasswordName = $state('')
|
||||
let creating = $state(false)
|
||||
let createdPassword = $state<{ name: string; password: string } | null>(null)
|
||||
|
||||
let revoking = $state<string | null>(null)
|
||||
|
||||
$effect(() => {
|
||||
if (!auth.loading && !auth.session) {
|
||||
navigate('/login')
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (auth.session) {
|
||||
loadPasswords()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadPasswords() {
|
||||
if (!auth.session) return
|
||||
loading = true
|
||||
error = null
|
||||
|
||||
try {
|
||||
const result = await api.listAppPasswords(auth.session.accessJwt)
|
||||
passwords = result.passwords
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : 'Failed to load app passwords'
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate(e: Event) {
|
||||
e.preventDefault()
|
||||
if (!auth.session || !newPasswordName.trim()) return
|
||||
|
||||
creating = true
|
||||
error = null
|
||||
|
||||
try {
|
||||
const result = await api.createAppPassword(auth.session.accessJwt, newPasswordName.trim())
|
||||
createdPassword = { name: result.name, password: result.password }
|
||||
newPasswordName = ''
|
||||
await loadPasswords()
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : 'Failed to create app password'
|
||||
} finally {
|
||||
creating = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevoke(name: string) {
|
||||
if (!auth.session) return
|
||||
if (!confirm(`Revoke app password "${name}"? Apps using this password will no longer be able to access your account.`)) {
|
||||
return
|
||||
}
|
||||
|
||||
revoking = name
|
||||
error = null
|
||||
|
||||
try {
|
||||
await api.revokeAppPassword(auth.session.accessJwt, name)
|
||||
await loadPasswords()
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : 'Failed to revoke app password'
|
||||
} finally {
|
||||
revoking = null
|
||||
}
|
||||
}
|
||||
|
||||
function dismissCreated() {
|
||||
createdPassword = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<header>
|
||||
<a href="#/dashboard" class="back">← Dashboard</a>
|
||||
<h1>App Passwords</h1>
|
||||
</header>
|
||||
|
||||
<p class="description">
|
||||
App passwords let you sign in to third-party apps without giving them your main password.
|
||||
Each app password can be revoked individually.
|
||||
</p>
|
||||
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if createdPassword}
|
||||
<div class="created-password">
|
||||
<h3>App Password Created</h3>
|
||||
<p>Copy this password now. You won't be able to see it again.</p>
|
||||
<div class="password-display">
|
||||
<code>{createdPassword.password}</code>
|
||||
</div>
|
||||
<p class="password-name">Name: {createdPassword.name}</p>
|
||||
<button onclick={dismissCreated}>Done</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<section class="create-section">
|
||||
<h2>Create New App Password</h2>
|
||||
<form onsubmit={handleCreate}>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newPasswordName}
|
||||
placeholder="App name (e.g., Graysky, Skeets)"
|
||||
disabled={creating}
|
||||
required
|
||||
/>
|
||||
<button type="submit" disabled={creating || !newPasswordName.trim()}>
|
||||
{creating ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="list-section">
|
||||
<h2>Your App Passwords</h2>
|
||||
|
||||
{#if loading}
|
||||
<p class="empty">Loading...</p>
|
||||
{:else if passwords.length === 0}
|
||||
<p class="empty">No app passwords yet</p>
|
||||
{:else}
|
||||
<ul class="password-list">
|
||||
{#each passwords as pw}
|
||||
<li>
|
||||
<div class="password-info">
|
||||
<span class="name">{pw.name}</span>
|
||||
<span class="date">Created {new Date(pw.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<button
|
||||
class="revoke"
|
||||
onclick={() => handleRevoke(pw.name)}
|
||||
disabled={revoking === pw.name}
|
||||
>
|
||||
{revoking === pw.name ? 'Revoking...' : 'Revoke'}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.back {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.back:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0.5rem 0 0 0;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 0.75rem;
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--error-border);
|
||||
border-radius: 4px;
|
||||
color: var(--error-text);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.created-password {
|
||||
padding: 1.5rem;
|
||||
background: var(--success-bg);
|
||||
border: 1px solid var(--success-border);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.created-password h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.password-display {
|
||||
background: var(--bg-card);
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.password-display code {
|
||||
font-size: 1.25rem;
|
||||
font-family: monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.password-name {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
section h2 {
|
||||
font-size: 1.125rem;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.create-section form {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.create-section input {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-color-light);
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.create-section input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.create-section button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.create-section button:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.create-section button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.password-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.password-list li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.password-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.date {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.revoke {
|
||||
padding: 0.5rem 1rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--error-text);
|
||||
border-radius: 4px;
|
||||
color: var(--error-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.revoke:hover:not(:disabled) {
|
||||
background: var(--error-bg);
|
||||
}
|
||||
|
||||
.revoke:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,201 @@
|
||||
<script lang="ts">
|
||||
import { getAuthState, logout } from '../lib/auth.svelte'
|
||||
import { navigate } from '../lib/router.svelte'
|
||||
|
||||
const auth = getAuthState()
|
||||
|
||||
$effect(() => {
|
||||
if (!auth.loading && !auth.session) {
|
||||
navigate('/login')
|
||||
}
|
||||
})
|
||||
|
||||
async function handleLogout() {
|
||||
await logout()
|
||||
navigate('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if auth.session}
|
||||
<div class="dashboard">
|
||||
<header>
|
||||
<h1>Dashboard</h1>
|
||||
<button class="logout" onclick={handleLogout}>Sign Out</button>
|
||||
</header>
|
||||
|
||||
<section class="account-overview">
|
||||
<h2>Account Overview</h2>
|
||||
<dl>
|
||||
<dt>Handle</dt>
|
||||
<dd>@{auth.session.handle}</dd>
|
||||
|
||||
<dt>DID</dt>
|
||||
<dd class="mono">{auth.session.did}</dd>
|
||||
|
||||
{#if auth.session.email}
|
||||
<dt>Email</dt>
|
||||
<dd>
|
||||
{auth.session.email}
|
||||
{#if auth.session.emailConfirmed}
|
||||
<span class="badge success">Verified</span>
|
||||
{:else}
|
||||
<span class="badge warning">Unverified</span>
|
||||
{/if}
|
||||
</dd>
|
||||
{/if}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<nav class="nav-grid">
|
||||
<a href="#/app-passwords" class="nav-card">
|
||||
<h3>App Passwords</h3>
|
||||
<p>Manage passwords for third-party apps</p>
|
||||
</a>
|
||||
|
||||
<a href="#/invite-codes" class="nav-card">
|
||||
<h3>Invite Codes</h3>
|
||||
<p>View and create invite codes</p>
|
||||
</a>
|
||||
|
||||
<a href="#/settings" class="nav-card">
|
||||
<h3>Account Settings</h3>
|
||||
<p>Email, password, handle, and more</p>
|
||||
</a>
|
||||
|
||||
<a href="#/notifications" class="nav-card">
|
||||
<h3>Notification Preferences</h3>
|
||||
<p>Discord, Telegram, Signal channels</p>
|
||||
</a>
|
||||
|
||||
<a href="#/repo" class="nav-card">
|
||||
<h3>Repository Explorer</h3>
|
||||
<p>Browse and manage raw AT Protocol records</p>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
{:else if auth.loading}
|
||||
<div class="loading">Loading...</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.dashboard {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.logout {
|
||||
padding: 0.5rem 1rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color-light);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.logout:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
section {
|
||||
background: var(--bg-secondary);
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
section h2 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.5rem 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.badge.success {
|
||||
background: var(--success-bg);
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.badge.warning {
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.nav-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.nav-card {
|
||||
display: block;
|
||||
padding: 1.5rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.nav-card:hover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 2px 8px rgba(77, 166, 255, 0.15);
|
||||
}
|
||||
|
||||
.nav-card h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.nav-card p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 4rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,326 @@
|
||||
<script lang="ts">
|
||||
import { getAuthState } from '../lib/auth.svelte'
|
||||
import { navigate } from '../lib/router.svelte'
|
||||
import { api, type InviteCode, ApiError } from '../lib/api'
|
||||
|
||||
const auth = getAuthState()
|
||||
|
||||
let codes = $state<InviteCode[]>([])
|
||||
let loading = $state(true)
|
||||
let error = $state<string | null>(null)
|
||||
|
||||
let creating = $state(false)
|
||||
let createdCode = $state<string | null>(null)
|
||||
|
||||
$effect(() => {
|
||||
if (!auth.loading && !auth.session) {
|
||||
navigate('/login')
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (auth.session) {
|
||||
loadCodes()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadCodes() {
|
||||
if (!auth.session) return
|
||||
loading = true
|
||||
error = null
|
||||
|
||||
try {
|
||||
const result = await api.getAccountInviteCodes(auth.session.accessJwt)
|
||||
codes = result.codes
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : 'Failed to load invite codes'
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!auth.session) return
|
||||
|
||||
creating = true
|
||||
error = null
|
||||
|
||||
try {
|
||||
const result = await api.createInviteCode(auth.session.accessJwt, 1)
|
||||
createdCode = result.code
|
||||
await loadCodes()
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : 'Failed to create invite code'
|
||||
} finally {
|
||||
creating = false
|
||||
}
|
||||
}
|
||||
|
||||
function dismissCreated() {
|
||||
createdCode = null
|
||||
}
|
||||
|
||||
function copyCode(code: string) {
|
||||
navigator.clipboard.writeText(code)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<header>
|
||||
<a href="#/dashboard" class="back">← Dashboard</a>
|
||||
<h1>Invite Codes</h1>
|
||||
</header>
|
||||
|
||||
<p class="description">
|
||||
Invite codes let you invite friends to join. Each code can be used once.
|
||||
</p>
|
||||
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if createdCode}
|
||||
<div class="created-code">
|
||||
<h3>Invite Code Created</h3>
|
||||
<div class="code-display">
|
||||
<code>{createdCode}</code>
|
||||
<button class="copy" onclick={() => copyCode(createdCode!)}>Copy</button>
|
||||
</div>
|
||||
<button onclick={dismissCreated}>Done</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<section class="create-section">
|
||||
<button onclick={handleCreate} disabled={creating}>
|
||||
{creating ? 'Creating...' : 'Create New Invite Code'}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="list-section">
|
||||
<h2>Your Invite Codes</h2>
|
||||
|
||||
{#if loading}
|
||||
<p class="empty">Loading...</p>
|
||||
{:else if codes.length === 0}
|
||||
<p class="empty">No invite codes yet</p>
|
||||
{:else}
|
||||
<ul class="code-list">
|
||||
{#each codes as code}
|
||||
<li class:disabled={code.disabled} class:used={code.uses.length > 0 && code.available === 0}>
|
||||
<div class="code-main">
|
||||
<code>{code.code}</code>
|
||||
<button class="copy-small" onclick={() => copyCode(code.code)} title="Copy">
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<div class="code-meta">
|
||||
<span class="date">Created {new Date(code.createdAt).toLocaleDateString()}</span>
|
||||
{#if code.disabled}
|
||||
<span class="status disabled">Disabled</span>
|
||||
{:else if code.uses.length > 0}
|
||||
<span class="status used">Used by @{code.uses[0].usedBy.split(':').pop()}</span>
|
||||
{:else}
|
||||
<span class="status available">Available</span>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.back {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.back:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0.5rem 0 0 0;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 0.75rem;
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--error-border);
|
||||
border-radius: 4px;
|
||||
color: var(--error-text);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.created-code {
|
||||
padding: 1.5rem;
|
||||
background: var(--success-bg);
|
||||
border: 1px solid var(--success-border);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.created-code h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.code-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
background: var(--bg-card);
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.code-display code {
|
||||
font-size: 1.125rem;
|
||||
font-family: monospace;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.copy {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.copy:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.create-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.create-section button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.create-section button:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.create-section button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
section h2 {
|
||||
font-size: 1.125rem;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.code-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.code-list li {
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.code-list li.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.code-list li.used {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.code-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.code-main code {
|
||||
font-family: monospace;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.copy-small {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.copy-small:hover {
|
||||
background: var(--bg-input-disabled);
|
||||
}
|
||||
|
||||
.code-meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.date {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.status.available {
|
||||
background: var(--success-bg);
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.status.used {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status.disabled {
|
||||
background: var(--error-bg);
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,289 @@
|
||||
<script lang="ts">
|
||||
import { login, confirmSignup, resendVerification, getAuthState } from '../lib/auth.svelte'
|
||||
import { navigate } from '../lib/router.svelte'
|
||||
import { ApiError } from '../lib/api'
|
||||
|
||||
let identifier = $state('')
|
||||
let password = $state('')
|
||||
let submitting = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
|
||||
let pendingVerification = $state<{ did: string } | null>(null)
|
||||
let verificationCode = $state('')
|
||||
let resendingCode = $state(false)
|
||||
let resendMessage = $state<string | null>(null)
|
||||
|
||||
const auth = getAuthState()
|
||||
|
||||
$effect(() => {
|
||||
if (auth.session) {
|
||||
navigate('/dashboard')
|
||||
}
|
||||
})
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault()
|
||||
if (!identifier || !password) return
|
||||
|
||||
submitting = true
|
||||
error = null
|
||||
pendingVerification = null
|
||||
|
||||
try {
|
||||
await login(identifier, password)
|
||||
navigate('/dashboard')
|
||||
} catch (e: any) {
|
||||
if (e instanceof ApiError && e.error === 'AccountNotVerified') {
|
||||
if (e.did) {
|
||||
pendingVerification = { did: e.did }
|
||||
} else {
|
||||
error = 'Account not verified. Please check your verification method for a code.'
|
||||
}
|
||||
} else {
|
||||
error = e.message || 'Login failed'
|
||||
}
|
||||
} finally {
|
||||
submitting = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVerification(e: Event) {
|
||||
e.preventDefault()
|
||||
|
||||
if (!pendingVerification || !verificationCode.trim()) return
|
||||
|
||||
submitting = true
|
||||
error = null
|
||||
|
||||
try {
|
||||
await confirmSignup(pendingVerification.did, verificationCode.trim())
|
||||
navigate('/dashboard')
|
||||
} catch (e: any) {
|
||||
error = e.message || 'Verification failed'
|
||||
} finally {
|
||||
submitting = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResendCode() {
|
||||
if (!pendingVerification || resendingCode) return
|
||||
|
||||
resendingCode = true
|
||||
resendMessage = null
|
||||
error = null
|
||||
|
||||
try {
|
||||
await resendVerification(pendingVerification.did)
|
||||
resendMessage = 'Verification code resent!'
|
||||
} catch (e: any) {
|
||||
error = e.message || 'Failed to resend code'
|
||||
} finally {
|
||||
resendingCode = false
|
||||
}
|
||||
}
|
||||
|
||||
function backToLogin() {
|
||||
pendingVerification = null
|
||||
verificationCode = ''
|
||||
error = null
|
||||
resendMessage = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="login-container">
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if pendingVerification}
|
||||
<h1>Verify Your Account</h1>
|
||||
<p class="subtitle">
|
||||
Your account needs verification. Enter the code sent to your verification method.
|
||||
</p>
|
||||
|
||||
{#if resendMessage}
|
||||
<div class="success">{resendMessage}</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleVerification(e); }}>
|
||||
<div class="field">
|
||||
<label for="verification-code">Verification Code</label>
|
||||
<input
|
||||
id="verification-code"
|
||||
type="text"
|
||||
bind:value={verificationCode}
|
||||
placeholder="Enter 6-digit code"
|
||||
disabled={submitting}
|
||||
required
|
||||
maxlength="6"
|
||||
pattern="[0-9]{6}"
|
||||
autocomplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={submitting || !verificationCode.trim()}>
|
||||
{submitting ? 'Verifying...' : 'Verify Account'}
|
||||
</button>
|
||||
|
||||
<button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}>
|
||||
{resendingCode ? 'Resending...' : 'Resend Code'}
|
||||
</button>
|
||||
|
||||
<button type="button" class="tertiary" onclick={backToLogin}>
|
||||
Back to Login
|
||||
</button>
|
||||
</form>
|
||||
{:else}
|
||||
<h1>Sign In</h1>
|
||||
<p class="subtitle">Sign in to manage your PDS account</p>
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(e); }}>
|
||||
<div class="field">
|
||||
<label for="identifier">Handle or Email</label>
|
||||
<input
|
||||
id="identifier"
|
||||
type="text"
|
||||
bind:value={identifier}
|
||||
placeholder="you.bsky.social or you@example.com"
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
placeholder="Password"
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={submitting || !identifier || !password}>
|
||||
{submitting ? 'Signing in...' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="register-link">
|
||||
Don't have an account? <a href="#/register">Create one</a>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.login-container {
|
||||
max-width: 400px;
|
||||
margin: 4rem auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 2rem 0;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-color-light);
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.75rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
}
|
||||
|
||||
button.secondary:hover:not(:disabled) {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
button.tertiary {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: none;
|
||||
}
|
||||
|
||||
button.tertiary:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 0.75rem;
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--error-border);
|
||||
border-radius: 4px;
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.success {
|
||||
padding: 0.75rem;
|
||||
background: var(--success-bg);
|
||||
border: 1px solid var(--success-border);
|
||||
border-radius: 4px;
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.register-link {
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.register-link a {
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,453 @@
|
||||
<script lang="ts">
|
||||
import { getAuthState } from '../lib/auth.svelte'
|
||||
import { navigate } from '../lib/router.svelte'
|
||||
import { api, ApiError } from '../lib/api'
|
||||
|
||||
const auth = getAuthState()
|
||||
|
||||
let loading = $state(true)
|
||||
let saving = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
let success = $state<string | null>(null)
|
||||
|
||||
let preferredChannel = $state('email')
|
||||
let email = $state('')
|
||||
let discordId = $state('')
|
||||
let discordVerified = $state(false)
|
||||
let telegramUsername = $state('')
|
||||
let telegramVerified = $state(false)
|
||||
let signalNumber = $state('')
|
||||
let signalVerified = $state(false)
|
||||
|
||||
$effect(() => {
|
||||
if (!auth.loading && !auth.session) {
|
||||
navigate('/login')
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (auth.session) {
|
||||
loadPrefs()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadPrefs() {
|
||||
if (!auth.session) return
|
||||
loading = true
|
||||
error = null
|
||||
|
||||
try {
|
||||
const prefs = await api.getNotificationPrefs(auth.session.accessJwt)
|
||||
preferredChannel = prefs.preferredChannel
|
||||
email = prefs.email
|
||||
discordId = prefs.discordId ?? ''
|
||||
discordVerified = prefs.discordVerified
|
||||
telegramUsername = prefs.telegramUsername ?? ''
|
||||
telegramVerified = prefs.telegramVerified
|
||||
signalNumber = prefs.signalNumber ?? ''
|
||||
signalVerified = prefs.signalVerified
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : 'Failed to load notification preferences'
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(e: Event) {
|
||||
e.preventDefault()
|
||||
if (!auth.session) return
|
||||
|
||||
saving = true
|
||||
error = null
|
||||
success = null
|
||||
|
||||
try {
|
||||
await api.updateNotificationPrefs(auth.session.accessJwt, {
|
||||
preferredChannel,
|
||||
discordId: discordId || undefined,
|
||||
telegramUsername: telegramUsername || undefined,
|
||||
signalNumber: signalNumber || undefined,
|
||||
})
|
||||
success = 'Notification preferences saved'
|
||||
await loadPrefs()
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : 'Failed to save preferences'
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
|
||||
const channels = [
|
||||
{ id: 'email', name: 'Email', description: 'Receive notifications via email' },
|
||||
{ id: 'discord', name: 'Discord', description: 'Receive notifications via Discord DM' },
|
||||
{ id: 'telegram', name: 'Telegram', description: 'Receive notifications via Telegram' },
|
||||
{ id: 'signal', name: 'Signal', description: 'Receive notifications via Signal' },
|
||||
]
|
||||
|
||||
function canSelectChannel(channelId: string): boolean {
|
||||
if (channelId === 'email') return true
|
||||
if (channelId === 'discord') return !!discordId
|
||||
if (channelId === 'telegram') return !!telegramUsername
|
||||
if (channelId === 'signal') return !!signalNumber
|
||||
return false
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<header>
|
||||
<a href="#/dashboard" class="back">← Dashboard</a>
|
||||
<h1>Notification Preferences</h1>
|
||||
</header>
|
||||
|
||||
<p class="description">
|
||||
Choose how you want to receive important notifications like password resets,
|
||||
security alerts, and account updates.
|
||||
</p>
|
||||
|
||||
{#if loading}
|
||||
<p class="loading">Loading...</p>
|
||||
{:else}
|
||||
{#if error}
|
||||
<div class="message error">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if success}
|
||||
<div class="message success">{success}</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={handleSave}>
|
||||
<section>
|
||||
<h2>Preferred Channel</h2>
|
||||
<p class="section-description">
|
||||
Select your preferred way to receive notifications. You must configure a channel before you can select it.
|
||||
</p>
|
||||
|
||||
<div class="channel-options">
|
||||
{#each channels as channel}
|
||||
<label class="channel-option" class:disabled={!canSelectChannel(channel.id)}>
|
||||
<input
|
||||
type="radio"
|
||||
name="preferredChannel"
|
||||
value={channel.id}
|
||||
bind:group={preferredChannel}
|
||||
disabled={!canSelectChannel(channel.id) || saving}
|
||||
/>
|
||||
<div class="channel-info">
|
||||
<span class="channel-name">{channel.name}</span>
|
||||
<span class="channel-description">{channel.description}</span>
|
||||
{#if channel.id !== 'email' && !canSelectChannel(channel.id)}
|
||||
<span class="channel-hint">Configure below to enable</span>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Channel Configuration</h2>
|
||||
|
||||
<div class="channel-config">
|
||||
<div class="config-item">
|
||||
<label for="email">Email</label>
|
||||
<div class="config-input">
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
disabled
|
||||
class="readonly"
|
||||
/>
|
||||
<span class="status verified">Primary</span>
|
||||
</div>
|
||||
<p class="config-hint">Your email is managed in Account Settings</p>
|
||||
</div>
|
||||
|
||||
<div class="config-item">
|
||||
<label for="discord">Discord User ID</label>
|
||||
<div class="config-input">
|
||||
<input
|
||||
id="discord"
|
||||
type="text"
|
||||
bind:value={discordId}
|
||||
placeholder="e.g., 123456789012345678"
|
||||
disabled={saving}
|
||||
/>
|
||||
{#if discordId}
|
||||
{#if discordVerified}
|
||||
<span class="status verified">Verified</span>
|
||||
{:else}
|
||||
<span class="status unverified">Not verified</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<p class="config-hint">Your Discord user ID (not username). Enable Developer Mode in Discord to copy it.</p>
|
||||
</div>
|
||||
|
||||
<div class="config-item">
|
||||
<label for="telegram">Telegram Username</label>
|
||||
<div class="config-input">
|
||||
<input
|
||||
id="telegram"
|
||||
type="text"
|
||||
bind:value={telegramUsername}
|
||||
placeholder="e.g., username"
|
||||
disabled={saving}
|
||||
/>
|
||||
{#if telegramUsername}
|
||||
{#if telegramVerified}
|
||||
<span class="status verified">Verified</span>
|
||||
{:else}
|
||||
<span class="status unverified">Not verified</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<p class="config-hint">Your Telegram username without the @ symbol</p>
|
||||
</div>
|
||||
|
||||
<div class="config-item">
|
||||
<label for="signal">Signal Phone Number</label>
|
||||
<div class="config-input">
|
||||
<input
|
||||
id="signal"
|
||||
type="tel"
|
||||
bind:value={signalNumber}
|
||||
placeholder="e.g., +1234567890"
|
||||
disabled={saving}
|
||||
/>
|
||||
{#if signalNumber}
|
||||
{#if signalVerified}
|
||||
<span class="status verified">Verified</span>
|
||||
{:else}
|
||||
<span class="status unverified">Not verified</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<p class="config-hint">Your Signal phone number with country code</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit" disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save Preferences'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.back {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.back:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0.5rem 0 0 0;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--error-border);
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background: var(--success-bg);
|
||||
border: 1px solid var(--success-border);
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
section {
|
||||
background: var(--bg-secondary);
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
section h2 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.section-description {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.channel-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.channel-option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.channel-option:hover:not(.disabled) {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.channel-option.disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.channel-option input {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.channel-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.channel-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.channel-description {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.channel-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.channel-config {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.config-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.config-item label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.config-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.config-input input {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-color-light);
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.config-input input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.config-input input.readonly {
|
||||
background: var(--bg-input-disabled);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status.verified {
|
||||
background: var(--success-bg);
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.status.unverified {
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.config-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
padding: 0.75rem 2rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.actions button:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.actions button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,523 @@
|
||||
<script lang="ts">
|
||||
import { register, confirmSignup, resendVerification, getAuthState } from '../lib/auth.svelte'
|
||||
import { navigate } from '../lib/router.svelte'
|
||||
import { api, ApiError, type VerificationChannel } from '../lib/api'
|
||||
|
||||
let handle = $state('')
|
||||
let email = $state('')
|
||||
let password = $state('')
|
||||
let confirmPassword = $state('')
|
||||
let inviteCode = $state('')
|
||||
let verificationChannel = $state<VerificationChannel>('email')
|
||||
let discordId = $state('')
|
||||
let telegramUsername = $state('')
|
||||
let signalNumber = $state('')
|
||||
let submitting = $state(false)
|
||||
let error = $state<string | null>(null)
|
||||
|
||||
let pendingVerification = $state<{ did: string; handle: string; channel: string } | null>(null)
|
||||
let verificationCode = $state('')
|
||||
let resendingCode = $state(false)
|
||||
let resendMessage = $state<string | null>(null)
|
||||
|
||||
let serverInfo = $state<{
|
||||
availableUserDomains: string[]
|
||||
inviteCodeRequired: boolean
|
||||
} | null>(null)
|
||||
let loadingServerInfo = $state(true)
|
||||
let serverInfoLoaded = false
|
||||
|
||||
const auth = getAuthState()
|
||||
|
||||
$effect(() => {
|
||||
if (auth.session) {
|
||||
navigate('/dashboard')
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (!serverInfoLoaded) {
|
||||
serverInfoLoaded = true
|
||||
loadServerInfo()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadServerInfo() {
|
||||
try {
|
||||
serverInfo = await api.describeServer()
|
||||
} catch (e) {
|
||||
console.error('Failed to load server info:', e)
|
||||
} finally {
|
||||
loadingServerInfo = false
|
||||
}
|
||||
}
|
||||
|
||||
function validateForm(): string | null {
|
||||
if (!handle.trim()) return 'Handle is required'
|
||||
if (!password) return 'Password is required'
|
||||
if (password.length < 8) return 'Password must be at least 8 characters'
|
||||
if (password !== confirmPassword) return 'Passwords do not match'
|
||||
if (serverInfo?.inviteCodeRequired && !inviteCode.trim()) {
|
||||
return 'Invite code is required'
|
||||
}
|
||||
switch (verificationChannel) {
|
||||
case 'email':
|
||||
if (!email.trim()) return 'Email is required for email verification'
|
||||
break
|
||||
case 'discord':
|
||||
if (!discordId.trim()) return 'Discord ID is required for Discord verification'
|
||||
break
|
||||
case 'telegram':
|
||||
if (!telegramUsername.trim()) return 'Telegram username is required for Telegram verification'
|
||||
break
|
||||
case 'signal':
|
||||
if (!signalNumber.trim()) return 'Phone number is required for Signal verification'
|
||||
break
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault()
|
||||
|
||||
const validationError = validateForm()
|
||||
if (validationError) {
|
||||
error = validationError
|
||||
return
|
||||
}
|
||||
|
||||
submitting = true
|
||||
error = null
|
||||
|
||||
try {
|
||||
const result = await register({
|
||||
handle: handle.trim(),
|
||||
email: email.trim(),
|
||||
password,
|
||||
inviteCode: inviteCode.trim() || undefined,
|
||||
verificationChannel,
|
||||
discordId: discordId.trim() || undefined,
|
||||
telegramUsername: telegramUsername.trim() || undefined,
|
||||
signalNumber: signalNumber.trim() || undefined,
|
||||
})
|
||||
|
||||
if (result.verificationRequired) {
|
||||
pendingVerification = {
|
||||
did: result.did,
|
||||
handle: result.handle,
|
||||
channel: result.verificationChannel,
|
||||
}
|
||||
} else {
|
||||
navigate('/dashboard')
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err instanceof ApiError) {
|
||||
error = err.message || 'Registration failed'
|
||||
} else if (err instanceof Error) {
|
||||
error = err.message || 'Registration failed'
|
||||
} else {
|
||||
error = 'Registration failed'
|
||||
}
|
||||
} finally {
|
||||
submitting = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVerification(e: Event) {
|
||||
e.preventDefault()
|
||||
|
||||
if (!pendingVerification || !verificationCode.trim()) return
|
||||
|
||||
submitting = true
|
||||
error = null
|
||||
|
||||
try {
|
||||
await confirmSignup(pendingVerification.did, verificationCode.trim())
|
||||
navigate('/dashboard')
|
||||
} catch (e: any) {
|
||||
error = e.message || 'Verification failed'
|
||||
} finally {
|
||||
submitting = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResendCode() {
|
||||
if (!pendingVerification || resendingCode) return
|
||||
|
||||
resendingCode = true
|
||||
resendMessage = null
|
||||
error = null
|
||||
|
||||
try {
|
||||
await resendVerification(pendingVerification.did)
|
||||
resendMessage = 'Verification code resent!'
|
||||
} catch (e: any) {
|
||||
error = e.message || 'Failed to resend code'
|
||||
} finally {
|
||||
resendingCode = false
|
||||
}
|
||||
}
|
||||
|
||||
let fullHandle = $derived(() => {
|
||||
if (!handle.trim()) return ''
|
||||
if (handle.includes('.')) return handle.trim()
|
||||
const domain = serverInfo?.availableUserDomains?.[0]
|
||||
if (domain) return `${handle.trim()}.${domain}`
|
||||
return handle.trim()
|
||||
})
|
||||
|
||||
function channelLabel(ch: string): string {
|
||||
switch (ch) {
|
||||
case 'email': return 'Email'
|
||||
case 'discord': return 'Discord'
|
||||
case 'telegram': return 'Telegram'
|
||||
case 'signal': return 'Signal'
|
||||
default: return ch
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="register-container">
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if pendingVerification}
|
||||
<h1>Verify Your Account</h1>
|
||||
<p class="subtitle">
|
||||
We've sent a verification code to your {channelLabel(pendingVerification.channel)}.
|
||||
Enter it below to complete registration.
|
||||
</p>
|
||||
|
||||
{#if resendMessage}
|
||||
<div class="success">{resendMessage}</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleVerification(e); }}>
|
||||
|
||||
<div class="field">
|
||||
<label for="verification-code">Verification Code</label>
|
||||
<input
|
||||
id="verification-code"
|
||||
type="text"
|
||||
bind:value={verificationCode}
|
||||
placeholder="Enter 6-digit code"
|
||||
disabled={submitting}
|
||||
required
|
||||
maxlength="6"
|
||||
pattern="[0-9]{6}"
|
||||
autocomplete="one-time-code"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={submitting || !verificationCode.trim()}>
|
||||
{submitting ? 'Verifying...' : 'Verify Account'}
|
||||
</button>
|
||||
|
||||
<button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}>
|
||||
{resendingCode ? 'Resending...' : 'Resend Code'}
|
||||
</button>
|
||||
</form>
|
||||
{:else}
|
||||
<h1>Create Account</h1>
|
||||
<p class="subtitle">Create a new account on this PDS</p>
|
||||
|
||||
{#if loadingServerInfo}
|
||||
<p class="loading">Loading...</p>
|
||||
{:else}
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(e); }}>
|
||||
<div class="field">
|
||||
<label for="handle">Handle</label>
|
||||
<input
|
||||
id="handle"
|
||||
type="text"
|
||||
bind:value={handle}
|
||||
placeholder="yourname"
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
{#if fullHandle()}
|
||||
<p class="hint">Your full handle will be: @{fullHandle()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
placeholder="At least 8 characters"
|
||||
disabled={submitting}
|
||||
required
|
||||
minlength="8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="confirm-password">Confirm Password</label>
|
||||
<input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
bind:value={confirmPassword}
|
||||
placeholder="Confirm your password"
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<fieldset class="verification-section">
|
||||
<legend>Contact Method</legend>
|
||||
<p class="section-hint">Choose how you'd like to verify your account and receive notifications. You only need one.</p>
|
||||
|
||||
<div class="field">
|
||||
<label for="verification-channel">Verification Method</label>
|
||||
<select
|
||||
id="verification-channel"
|
||||
bind:value={verificationChannel}
|
||||
disabled={submitting}
|
||||
>
|
||||
<option value="email">Email</option>
|
||||
<option value="discord">Discord</option>
|
||||
<option value="telegram">Telegram</option>
|
||||
<option value="signal">Signal</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if verificationChannel === 'email'}
|
||||
<div class="field">
|
||||
<label for="email">Email Address</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
bind:value={email}
|
||||
placeholder="you@example.com"
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{:else if verificationChannel === 'discord'}
|
||||
<div class="field">
|
||||
<label for="discord-id">Discord User ID</label>
|
||||
<input
|
||||
id="discord-id"
|
||||
type="text"
|
||||
bind:value={discordId}
|
||||
placeholder="Your Discord user ID"
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
<p class="hint">Your numeric Discord user ID (enable Developer Mode to find it)</p>
|
||||
</div>
|
||||
{:else if verificationChannel === 'telegram'}
|
||||
<div class="field">
|
||||
<label for="telegram-username">Telegram Username</label>
|
||||
<input
|
||||
id="telegram-username"
|
||||
type="text"
|
||||
bind:value={telegramUsername}
|
||||
placeholder="@yourusername"
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{:else if verificationChannel === 'signal'}
|
||||
<div class="field">
|
||||
<label for="signal-number">Signal Phone Number</label>
|
||||
<input
|
||||
id="signal-number"
|
||||
type="tel"
|
||||
bind:value={signalNumber}
|
||||
placeholder="+1234567890"
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
<p class="hint">Include country code (e.g., +1 for US)</p>
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
|
||||
{#if serverInfo?.inviteCodeRequired}
|
||||
<div class="field">
|
||||
<label for="invite-code">Invite Code <span class="required">*</span></label>
|
||||
<input
|
||||
id="invite-code"
|
||||
type="text"
|
||||
bind:value={inviteCode}
|
||||
placeholder="Enter your invite code"
|
||||
disabled={submitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="field optional">
|
||||
<label for="invite-code">Invite Code <span class="optional-label">(optional)</span></label>
|
||||
<input
|
||||
id="invite-code"
|
||||
type="text"
|
||||
bind:value={inviteCode}
|
||||
placeholder="Enter invite code if you have one"
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button type="submit" disabled={submitting}>
|
||||
{submitting ? 'Creating account...' : 'Create Account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="login-link">
|
||||
Already have an account? <a href="#/login">Sign in</a>
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.register-container {
|
||||
max-width: 400px;
|
||||
margin: 4rem auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 2rem 0;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.field.optional {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.optional-label {
|
||||
color: var(--text-secondary);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
input, select {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-color-light);
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
input:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0.25rem 0 0 0;
|
||||
}
|
||||
|
||||
.verification-section {
|
||||
border: 1px solid var(--border-color-light);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.verification-section legend {
|
||||
font-weight: 600;
|
||||
padding: 0 0.5rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.section-hint {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.75rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
}
|
||||
|
||||
button.secondary:hover:not(:disabled) {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 0.75rem;
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--error-border);
|
||||
border-radius: 4px;
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.success {
|
||||
padding: 0.75rem;
|
||||
background: var(--success-bg);
|
||||
border: 1px solid var(--success-border);
|
||||
border-radius: 4px;
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.login-link {
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.login-link a {
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,940 @@
|
||||
<script lang="ts">
|
||||
import { getAuthState } from '../lib/auth.svelte'
|
||||
import { navigate } from '../lib/router.svelte'
|
||||
import { api, ApiError } from '../lib/api'
|
||||
|
||||
const auth = getAuthState()
|
||||
|
||||
type View = 'collections' | 'records' | 'record' | 'create'
|
||||
|
||||
let view = $state<View>('collections')
|
||||
let collections = $state<string[]>([])
|
||||
let selectedCollection = $state<string | null>(null)
|
||||
let records = $state<Array<{ uri: string; cid: string; value: unknown; rkey: string }>>([])
|
||||
let recordsCursor = $state<string | undefined>(undefined)
|
||||
let selectedRecord = $state<{ uri: string; cid: string; value: unknown; rkey: string } | null>(null)
|
||||
|
||||
let loading = $state(true)
|
||||
let loadingMore = $state(false)
|
||||
let error = $state<{ code?: string; message: string } | null>(null)
|
||||
let success = $state<string | null>(null)
|
||||
|
||||
function setError(e: unknown) {
|
||||
if (e instanceof ApiError) {
|
||||
error = { code: e.error, message: e.message }
|
||||
} else if (e instanceof Error) {
|
||||
error = { message: e.message }
|
||||
} else {
|
||||
error = { message: 'An unknown error occurred' }
|
||||
}
|
||||
}
|
||||
|
||||
let newCollection = $state('')
|
||||
let newRkey = $state('')
|
||||
let recordJson = $state('')
|
||||
let jsonError = $state<string | null>(null)
|
||||
let saving = $state(false)
|
||||
|
||||
let filter = $state('')
|
||||
|
||||
$effect(() => {
|
||||
if (!auth.loading && !auth.session) {
|
||||
navigate('/login')
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (auth.session) {
|
||||
loadCollections()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadCollections() {
|
||||
if (!auth.session) return
|
||||
loading = true
|
||||
error = null
|
||||
|
||||
try {
|
||||
const result = await api.describeRepo(auth.session.accessJwt, auth.session.did)
|
||||
collections = result.collections.sort()
|
||||
} catch (e) {
|
||||
setError(e)
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function selectCollection(collection: string) {
|
||||
if (!auth.session) return
|
||||
selectedCollection = collection
|
||||
records = []
|
||||
recordsCursor = undefined
|
||||
view = 'records'
|
||||
loading = true
|
||||
error = null
|
||||
|
||||
try {
|
||||
const result = await api.listRecords(auth.session.accessJwt, auth.session.did, collection, { limit: 50 })
|
||||
records = result.records.map(r => ({
|
||||
...r,
|
||||
rkey: r.uri.split('/').pop()!
|
||||
}))
|
||||
recordsCursor = result.cursor
|
||||
} catch (e) {
|
||||
setError(e)
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMoreRecords() {
|
||||
if (!auth.session || !selectedCollection || !recordsCursor) return
|
||||
loadingMore = true
|
||||
|
||||
try {
|
||||
const result = await api.listRecords(auth.session.accessJwt, auth.session.did, selectedCollection, {
|
||||
limit: 50,
|
||||
cursor: recordsCursor
|
||||
})
|
||||
records = [...records, ...result.records.map(r => ({
|
||||
...r,
|
||||
rkey: r.uri.split('/').pop()!
|
||||
}))]
|
||||
recordsCursor = result.cursor
|
||||
} catch (e) {
|
||||
setError(e)
|
||||
} finally {
|
||||
loadingMore = false
|
||||
}
|
||||
}
|
||||
|
||||
async function selectRecord(record: { uri: string; cid: string; value: unknown; rkey: string }) {
|
||||
selectedRecord = record
|
||||
recordJson = JSON.stringify(record.value, null, 2)
|
||||
jsonError = null
|
||||
view = 'record'
|
||||
}
|
||||
|
||||
function startCreate(collection?: string) {
|
||||
newCollection = collection || 'app.bsky.feed.post'
|
||||
newRkey = ''
|
||||
|
||||
const exampleRecords: Record<string, unknown> = {
|
||||
'app.bsky.feed.post': {
|
||||
$type: 'app.bsky.feed.post',
|
||||
text: 'Hello from my PDS! This is my first post.',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
'app.bsky.actor.profile': {
|
||||
$type: 'app.bsky.actor.profile',
|
||||
displayName: 'Your Display Name',
|
||||
description: 'A short bio about yourself.',
|
||||
},
|
||||
'app.bsky.graph.follow': {
|
||||
$type: 'app.bsky.graph.follow',
|
||||
subject: 'did:web:example.com',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
'app.bsky.feed.like': {
|
||||
$type: 'app.bsky.feed.like',
|
||||
subject: {
|
||||
uri: 'at://did:web:example.com/app.bsky.feed.post/abc123',
|
||||
cid: 'bafyreiabc123...',
|
||||
},
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
|
||||
const example = exampleRecords[collection || 'app.bsky.feed.post'] || {
|
||||
$type: collection || 'app.bsky.feed.post',
|
||||
}
|
||||
|
||||
recordJson = JSON.stringify(example, null, 2)
|
||||
jsonError = null
|
||||
view = 'create'
|
||||
}
|
||||
|
||||
function validateJson(): unknown | null {
|
||||
try {
|
||||
const parsed = JSON.parse(recordJson)
|
||||
jsonError = null
|
||||
return parsed
|
||||
} catch (e) {
|
||||
jsonError = e instanceof Error ? e.message : 'Invalid JSON'
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate(e: Event) {
|
||||
e.preventDefault()
|
||||
if (!auth.session) return
|
||||
|
||||
const record = validateJson()
|
||||
if (!record) return
|
||||
|
||||
if (!newCollection.trim()) {
|
||||
error = { message: 'Collection is required' }
|
||||
return
|
||||
}
|
||||
|
||||
saving = true
|
||||
error = null
|
||||
|
||||
try {
|
||||
const result = await api.createRecord(
|
||||
auth.session.accessJwt,
|
||||
auth.session.did,
|
||||
newCollection.trim(),
|
||||
record,
|
||||
newRkey.trim() || undefined
|
||||
)
|
||||
success = `Record created: ${result.uri}`
|
||||
await loadCollections()
|
||||
await selectCollection(newCollection.trim())
|
||||
} catch (e) {
|
||||
setError(e)
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdate(e: Event) {
|
||||
e.preventDefault()
|
||||
if (!auth.session || !selectedRecord || !selectedCollection) return
|
||||
|
||||
const record = validateJson()
|
||||
if (!record) return
|
||||
|
||||
saving = true
|
||||
error = null
|
||||
|
||||
try {
|
||||
await api.putRecord(
|
||||
auth.session.accessJwt,
|
||||
auth.session.did,
|
||||
selectedCollection,
|
||||
selectedRecord.rkey,
|
||||
record
|
||||
)
|
||||
success = 'Record updated'
|
||||
const updated = await api.getRecord(
|
||||
auth.session.accessJwt,
|
||||
auth.session.did,
|
||||
selectedCollection,
|
||||
selectedRecord.rkey
|
||||
)
|
||||
selectedRecord = { ...updated, rkey: selectedRecord.rkey }
|
||||
recordJson = JSON.stringify(updated.value, null, 2)
|
||||
} catch (e) {
|
||||
setError(e)
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!auth.session || !selectedRecord || !selectedCollection) return
|
||||
if (!confirm(`Delete record ${selectedRecord.rkey}? This cannot be undone.`)) return
|
||||
|
||||
saving = true
|
||||
error = null
|
||||
|
||||
try {
|
||||
await api.deleteRecord(
|
||||
auth.session.accessJwt,
|
||||
auth.session.did,
|
||||
selectedCollection,
|
||||
selectedRecord.rkey
|
||||
)
|
||||
success = 'Record deleted'
|
||||
selectedRecord = null
|
||||
await selectCollection(selectedCollection)
|
||||
} catch (e) {
|
||||
setError(e)
|
||||
} finally {
|
||||
saving = false
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (view === 'record' || view === 'create') {
|
||||
if (selectedCollection) {
|
||||
view = 'records'
|
||||
} else {
|
||||
view = 'collections'
|
||||
}
|
||||
} else if (view === 'records') {
|
||||
selectedCollection = null
|
||||
view = 'collections'
|
||||
}
|
||||
error = null
|
||||
success = null
|
||||
}
|
||||
|
||||
let filteredCollections = $derived(
|
||||
filter
|
||||
? collections.filter(c => c.toLowerCase().includes(filter.toLowerCase()))
|
||||
: collections
|
||||
)
|
||||
|
||||
let filteredRecords = $derived(
|
||||
filter
|
||||
? records.filter(r =>
|
||||
r.rkey.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
JSON.stringify(r.value).toLowerCase().includes(filter.toLowerCase())
|
||||
)
|
||||
: records
|
||||
)
|
||||
|
||||
function groupCollectionsByAuthority(cols: string[]): Map<string, string[]> {
|
||||
const groups = new Map<string, string[]>()
|
||||
for (const col of cols) {
|
||||
const parts = col.split('.')
|
||||
const authority = parts.slice(0, -1).join('.')
|
||||
const name = parts[parts.length - 1]
|
||||
if (!groups.has(authority)) {
|
||||
groups.set(authority, [])
|
||||
}
|
||||
groups.get(authority)!.push(name)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
let groupedCollections = $derived(groupCollectionsByAuthority(filteredCollections))
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<header>
|
||||
<div class="breadcrumb">
|
||||
<a href="#/dashboard" class="back">← Dashboard</a>
|
||||
{#if view !== 'collections'}
|
||||
<span class="sep">/</span>
|
||||
<button class="breadcrumb-link" onclick={goBack}>
|
||||
{view === 'records' || view === 'create' ? 'Collections' : selectedCollection}
|
||||
</button>
|
||||
{/if}
|
||||
{#if view === 'record' && selectedRecord}
|
||||
<span class="sep">/</span>
|
||||
<span class="current">{selectedRecord.rkey}</span>
|
||||
{/if}
|
||||
{#if view === 'create'}
|
||||
<span class="sep">/</span>
|
||||
<span class="current">New Record</span>
|
||||
{/if}
|
||||
</div>
|
||||
<h1>
|
||||
{#if view === 'collections'}
|
||||
Repository Explorer
|
||||
{:else if view === 'records'}
|
||||
{selectedCollection}
|
||||
{:else if view === 'record'}
|
||||
Record Detail
|
||||
{:else}
|
||||
Create Record
|
||||
{/if}
|
||||
</h1>
|
||||
{#if auth.session}
|
||||
<p class="did">{auth.session.did}</p>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<div class="message error">
|
||||
{#if error.code}
|
||||
<strong class="error-code">{error.code}</strong>
|
||||
{/if}
|
||||
<span class="error-message">{error.message}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if success}
|
||||
<div class="message success">{success}</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<p class="loading-text">Loading...</p>
|
||||
{:else if view === 'collections'}
|
||||
<div class="toolbar">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter collections..."
|
||||
bind:value={filter}
|
||||
class="filter-input"
|
||||
/>
|
||||
<button class="primary" onclick={() => startCreate()}>Create Record</button>
|
||||
</div>
|
||||
|
||||
{#if collections.length === 0}
|
||||
<p class="empty">No collections yet. Create your first record to get started.</p>
|
||||
{:else}
|
||||
<div class="collections">
|
||||
{#each [...groupedCollections.entries()] as [authority, nsids]}
|
||||
<div class="collection-group">
|
||||
<h3 class="authority">{authority}</h3>
|
||||
<ul class="nsid-list">
|
||||
{#each nsids as nsid}
|
||||
<li>
|
||||
<button class="collection-link" onclick={() => selectCollection(`${authority}.${nsid}`)}>
|
||||
<span class="nsid">{nsid}</span>
|
||||
<span class="arrow">→</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{:else if view === 'records'}
|
||||
<div class="toolbar">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter records..."
|
||||
bind:value={filter}
|
||||
class="filter-input"
|
||||
/>
|
||||
<button class="primary" onclick={() => startCreate(selectedCollection!)}>Create Record</button>
|
||||
</div>
|
||||
|
||||
{#if records.length === 0}
|
||||
<p class="empty">No records in this collection.</p>
|
||||
{:else}
|
||||
<ul class="record-list">
|
||||
{#each filteredRecords as record}
|
||||
<li>
|
||||
<button class="record-item" onclick={() => selectRecord(record)}>
|
||||
<div class="record-info">
|
||||
<span class="rkey">{record.rkey}</span>
|
||||
<span class="cid" title={record.cid}>{record.cid.slice(0, 12)}...</span>
|
||||
</div>
|
||||
<pre class="record-preview">{JSON.stringify(record.value, null, 2).slice(0, 200)}{JSON.stringify(record.value).length > 200 ? '...' : ''}</pre>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
{#if recordsCursor}
|
||||
<div class="load-more">
|
||||
<button onclick={loadMoreRecords} disabled={loadingMore}>
|
||||
{loadingMore ? 'Loading...' : 'Load More'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{:else if view === 'record' && selectedRecord}
|
||||
<div class="record-detail">
|
||||
<div class="record-meta">
|
||||
<dl>
|
||||
<dt>URI</dt>
|
||||
<dd class="mono">{selectedRecord.uri}</dd>
|
||||
<dt>CID</dt>
|
||||
<dd class="mono">{selectedRecord.cid}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<form onsubmit={handleUpdate}>
|
||||
<div class="editor-container">
|
||||
<label for="record-json">Record JSON</label>
|
||||
<textarea
|
||||
id="record-json"
|
||||
bind:value={recordJson}
|
||||
oninput={() => validateJson()}
|
||||
class:has-error={jsonError}
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
{#if jsonError}
|
||||
<p class="json-error">{jsonError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit" class="primary" disabled={saving || !!jsonError}>
|
||||
{saving ? 'Saving...' : 'Update Record'}
|
||||
</button>
|
||||
<button type="button" class="danger" onclick={handleDelete} disabled={saving}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{:else if view === 'create'}
|
||||
<form class="create-form" onsubmit={handleCreate}>
|
||||
<div class="field">
|
||||
<label for="collection">Collection (NSID)</label>
|
||||
<input
|
||||
id="collection"
|
||||
type="text"
|
||||
bind:value={newCollection}
|
||||
placeholder="app.bsky.feed.post"
|
||||
disabled={saving}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="rkey">Record Key (optional)</label>
|
||||
<input
|
||||
id="rkey"
|
||||
type="text"
|
||||
bind:value={newRkey}
|
||||
placeholder="Auto-generated if empty (TID)"
|
||||
disabled={saving}
|
||||
/>
|
||||
<p class="hint">Leave empty to auto-generate a TID-based key</p>
|
||||
</div>
|
||||
|
||||
<div class="editor-container">
|
||||
<label for="new-record-json">Record JSON</label>
|
||||
<textarea
|
||||
id="new-record-json"
|
||||
bind:value={recordJson}
|
||||
oninput={() => validateJson()}
|
||||
class:has-error={jsonError}
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
{#if jsonError}
|
||||
<p class="json-error">{jsonError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit" class="primary" disabled={saving || !!jsonError || !newCollection.trim()}>
|
||||
{saving ? 'Creating...' : 'Create Record'}
|
||||
</button>
|
||||
<button type="button" class="secondary" onclick={goBack}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.back {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.back:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.sep {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.breadcrumb-link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.breadcrumb-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.current {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.did {
|
||||
margin: 0.25rem 0 0 0;
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--error-border);
|
||||
color: var(--error-text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background: var(--success-bg);
|
||||
border: 1px solid var(--success-border);
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.filter-input {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border-color-light);
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.filter-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
button.primary {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
button.primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
button.primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
padding: 0.5rem 1rem;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color-light);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
button.secondary:hover:not(:disabled) {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
button.danger {
|
||||
padding: 0.5rem 1rem;
|
||||
background: transparent;
|
||||
color: var(--error-text);
|
||||
border: 1px solid var(--error-text);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
button.danger:hover:not(:disabled) {
|
||||
background: var(--error-bg);
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
padding: 3rem;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.collections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.collection-group {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.authority {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nsid-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.collection-link {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: var(--text-primary);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.collection-link:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.nsid {
|
||||
font-weight: 500;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.arrow {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.record-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.record-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: var(--text-primary);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.record-item:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.record-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.rkey {
|
||||
font-family: monospace;
|
||||
font-weight: 500;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.cid {
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.record-preview {
|
||||
margin: 0;
|
||||
padding: 0.5rem;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 100px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.load-more {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.load-more button {
|
||||
padding: 0.5rem 2rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.load-more button:hover:not(:disabled) {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.record-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.record-meta {
|
||||
background: var(--bg-secondary);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.record-meta dl {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.5rem 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.record-meta dt {
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.record-meta dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.field input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-color-light);
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.field input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0.25rem 0 0 0;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.editor-container label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 300px;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--border-color-light);
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
textarea.has-error {
|
||||
border-color: var(--error-text);
|
||||
}
|
||||
|
||||
.json-error {
|
||||
margin: 0.25rem 0 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.create-form {
|
||||
background: var(--bg-secondary);
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,409 @@
|
||||
<script lang="ts">
|
||||
import { getAuthState, logout } from '../lib/auth.svelte'
|
||||
import { navigate } from '../lib/router.svelte'
|
||||
import { api, ApiError } from '../lib/api'
|
||||
|
||||
const auth = getAuthState()
|
||||
|
||||
let message = $state<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||
|
||||
let emailLoading = $state(false)
|
||||
let newEmail = $state('')
|
||||
let emailToken = $state('')
|
||||
let emailTokenRequired = $state(false)
|
||||
|
||||
let handleLoading = $state(false)
|
||||
let newHandle = $state('')
|
||||
|
||||
let deleteLoading = $state(false)
|
||||
let deletePassword = $state('')
|
||||
let deleteToken = $state('')
|
||||
let deleteTokenSent = $state(false)
|
||||
|
||||
$effect(() => {
|
||||
if (!auth.loading && !auth.session) {
|
||||
navigate('/login')
|
||||
}
|
||||
})
|
||||
|
||||
function showMessage(type: 'success' | 'error', text: string) {
|
||||
message = { type, text }
|
||||
setTimeout(() => {
|
||||
if (message?.text === text) message = null
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
async function handleRequestEmailUpdate(e: Event) {
|
||||
e.preventDefault()
|
||||
if (!auth.session || !newEmail) return
|
||||
|
||||
emailLoading = true
|
||||
message = null
|
||||
|
||||
try {
|
||||
const result = await api.requestEmailUpdate(auth.session.accessJwt)
|
||||
emailTokenRequired = result.tokenRequired
|
||||
if (emailTokenRequired) {
|
||||
showMessage('success', 'Verification code sent to your current email')
|
||||
} else {
|
||||
await api.updateEmail(auth.session.accessJwt, newEmail)
|
||||
showMessage('success', 'Email updated successfully')
|
||||
newEmail = ''
|
||||
}
|
||||
} catch (e) {
|
||||
showMessage('error', e instanceof ApiError ? e.message : 'Failed to update email')
|
||||
} finally {
|
||||
emailLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirmEmailUpdate(e: Event) {
|
||||
e.preventDefault()
|
||||
if (!auth.session || !newEmail || !emailToken) return
|
||||
|
||||
emailLoading = true
|
||||
message = null
|
||||
|
||||
try {
|
||||
await api.updateEmail(auth.session.accessJwt, newEmail, emailToken)
|
||||
showMessage('success', 'Email updated successfully')
|
||||
newEmail = ''
|
||||
emailToken = ''
|
||||
emailTokenRequired = false
|
||||
} catch (e) {
|
||||
showMessage('error', e instanceof ApiError ? e.message : 'Failed to update email')
|
||||
} finally {
|
||||
emailLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdateHandle(e: Event) {
|
||||
e.preventDefault()
|
||||
if (!auth.session || !newHandle) return
|
||||
|
||||
handleLoading = true
|
||||
message = null
|
||||
|
||||
try {
|
||||
await api.updateHandle(auth.session.accessJwt, newHandle)
|
||||
showMessage('success', 'Handle updated successfully')
|
||||
newHandle = ''
|
||||
} catch (e) {
|
||||
showMessage('error', e instanceof ApiError ? e.message : 'Failed to update handle')
|
||||
} finally {
|
||||
handleLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRequestDelete() {
|
||||
if (!auth.session) return
|
||||
|
||||
deleteLoading = true
|
||||
message = null
|
||||
|
||||
try {
|
||||
await api.requestAccountDelete(auth.session.accessJwt)
|
||||
deleteTokenSent = true
|
||||
showMessage('success', 'Deletion confirmation sent to your email')
|
||||
} catch (e) {
|
||||
showMessage('error', e instanceof ApiError ? e.message : 'Failed to request deletion')
|
||||
} finally {
|
||||
deleteLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirmDelete(e: Event) {
|
||||
e.preventDefault()
|
||||
if (!auth.session || !deletePassword || !deleteToken) return
|
||||
|
||||
if (!confirm('Are you absolutely sure you want to delete your account? This cannot be undone.')) {
|
||||
return
|
||||
}
|
||||
|
||||
deleteLoading = true
|
||||
message = null
|
||||
|
||||
try {
|
||||
await api.deleteAccount(auth.session.did, deletePassword, deleteToken)
|
||||
await logout()
|
||||
navigate('/login')
|
||||
} catch (e) {
|
||||
showMessage('error', e instanceof ApiError ? e.message : 'Failed to delete account')
|
||||
} finally {
|
||||
deleteLoading = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<header>
|
||||
<a href="#/dashboard" class="back">← Dashboard</a>
|
||||
<h1>Account Settings</h1>
|
||||
</header>
|
||||
|
||||
{#if message}
|
||||
<div class="message {message.type}">{message.text}</div>
|
||||
{/if}
|
||||
|
||||
<section>
|
||||
<h2>Change Email</h2>
|
||||
{#if auth.session?.email}
|
||||
<p class="current">Current: {auth.session.email}</p>
|
||||
{/if}
|
||||
|
||||
{#if emailTokenRequired}
|
||||
<form onsubmit={handleConfirmEmailUpdate}>
|
||||
<div class="field">
|
||||
<label for="email-token">Verification Code</label>
|
||||
<input
|
||||
id="email-token"
|
||||
type="text"
|
||||
bind:value={emailToken}
|
||||
placeholder="Enter code from email"
|
||||
disabled={emailLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit" disabled={emailLoading || !emailToken}>
|
||||
{emailLoading ? 'Updating...' : 'Confirm Email Change'}
|
||||
</button>
|
||||
<button type="button" class="secondary" onclick={() => { emailTokenRequired = false; emailToken = '' }}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<form onsubmit={handleRequestEmailUpdate}>
|
||||
<div class="field">
|
||||
<label for="new-email">New Email</label>
|
||||
<input
|
||||
id="new-email"
|
||||
type="email"
|
||||
bind:value={newEmail}
|
||||
placeholder="new@example.com"
|
||||
disabled={emailLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={emailLoading || !newEmail}>
|
||||
{emailLoading ? 'Requesting...' : 'Change Email'}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Change Handle</h2>
|
||||
{#if auth.session}
|
||||
<p class="current">Current: @{auth.session.handle}</p>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={handleUpdateHandle}>
|
||||
<div class="field">
|
||||
<label for="new-handle">New Handle</label>
|
||||
<input
|
||||
id="new-handle"
|
||||
type="text"
|
||||
bind:value={newHandle}
|
||||
placeholder="newhandle.bsky.social"
|
||||
disabled={handleLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={handleLoading || !newHandle}>
|
||||
{handleLoading ? 'Updating...' : 'Change Handle'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="danger-zone">
|
||||
<h2>Delete Account</h2>
|
||||
<p class="warning">This action is irreversible. All your data will be permanently deleted.</p>
|
||||
|
||||
{#if deleteTokenSent}
|
||||
<form onsubmit={handleConfirmDelete}>
|
||||
<div class="field">
|
||||
<label for="delete-token">Confirmation Code (from email)</label>
|
||||
<input
|
||||
id="delete-token"
|
||||
type="text"
|
||||
bind:value={deleteToken}
|
||||
placeholder="Enter confirmation code"
|
||||
disabled={deleteLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="delete-password">Your Password</label>
|
||||
<input
|
||||
id="delete-password"
|
||||
type="password"
|
||||
bind:value={deletePassword}
|
||||
placeholder="Enter your password"
|
||||
disabled={deleteLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit" class="danger" disabled={deleteLoading || !deleteToken || !deletePassword}>
|
||||
{deleteLoading ? 'Deleting...' : 'Permanently Delete Account'}
|
||||
</button>
|
||||
<button type="button" class="secondary" onclick={() => { deleteTokenSent = false; deleteToken = ''; deletePassword = '' }}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<button class="danger" onclick={handleRequestDelete} disabled={deleteLoading}>
|
||||
{deleteLoading ? 'Requesting...' : 'Request Account Deletion'}
|
||||
</button>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.back {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.back:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0.5rem 0 0 0;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background: var(--success-bg);
|
||||
border: 1px solid var(--success-border);
|
||||
color: var(--success-text);
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--error-border);
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
section {
|
||||
padding: 1.5rem;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
section h2 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.current {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-color-light);
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
box-sizing: border-box;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color-light);
|
||||
}
|
||||
|
||||
button.secondary:hover:not(:disabled) {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background: var(--error-text);
|
||||
}
|
||||
|
||||
button.danger:hover:not(:disabled) {
|
||||
background: #900;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.danger-zone {
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--error-border);
|
||||
}
|
||||
|
||||
.danger-zone h2 {
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--error-text);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,453 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'
|
||||
import AppPasswords from '../routes/AppPasswords.svelte'
|
||||
import {
|
||||
setupFetchMock,
|
||||
mockEndpoint,
|
||||
jsonResponse,
|
||||
errorResponse,
|
||||
mockData,
|
||||
clearMocks,
|
||||
setupAuthenticatedUser,
|
||||
setupUnauthenticatedUser,
|
||||
} from './mocks'
|
||||
|
||||
describe('AppPasswords', () => {
|
||||
beforeEach(() => {
|
||||
clearMocks()
|
||||
setupFetchMock()
|
||||
window.confirm = vi.fn(() => true)
|
||||
})
|
||||
|
||||
describe('authentication guard', () => {
|
||||
it('redirects to login when not authenticated', async () => {
|
||||
setupUnauthenticatedUser()
|
||||
render(AppPasswords)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.hash).toBe('#/login')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('page structure', () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
mockEndpoint('com.atproto.server.listAppPasswords', () =>
|
||||
jsonResponse({ passwords: [] })
|
||||
)
|
||||
})
|
||||
|
||||
it('displays all page elements', async () => {
|
||||
render(AppPasswords)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /app passwords/i, level: 1 })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /dashboard/i })).toHaveAttribute('href', '#/dashboard')
|
||||
expect(screen.getByText(/third-party apps/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('loading state', () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
})
|
||||
|
||||
it('shows loading text while fetching passwords', async () => {
|
||||
mockEndpoint('com.atproto.server.listAppPasswords', async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
return jsonResponse({ passwords: [] })
|
||||
})
|
||||
|
||||
render(AppPasswords)
|
||||
|
||||
expect(screen.getByText(/loading/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('empty state', () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
mockEndpoint('com.atproto.server.listAppPasswords', () =>
|
||||
jsonResponse({ passwords: [] })
|
||||
)
|
||||
})
|
||||
|
||||
it('shows empty message when no passwords exist', async () => {
|
||||
render(AppPasswords)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no app passwords yet/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('password list', () => {
|
||||
const testPasswords = [
|
||||
mockData.appPassword({ name: 'Graysky', createdAt: '2024-01-15T10:00:00Z' }),
|
||||
mockData.appPassword({ name: 'Skeets', createdAt: '2024-02-20T15:30:00Z' }),
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
mockEndpoint('com.atproto.server.listAppPasswords', () =>
|
||||
jsonResponse({ passwords: testPasswords })
|
||||
)
|
||||
})
|
||||
|
||||
it('displays all app passwords with dates and revoke buttons', async () => {
|
||||
render(AppPasswords)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Graysky')).toBeInTheDocument()
|
||||
expect(screen.getByText('Skeets')).toBeInTheDocument()
|
||||
expect(screen.getByText(/created.*1\/15\/2024/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/created.*2\/20\/2024/i)).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('button', { name: /revoke/i })).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('create app password', () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
mockEndpoint('com.atproto.server.listAppPasswords', () =>
|
||||
jsonResponse({ passwords: [] })
|
||||
)
|
||||
})
|
||||
|
||||
it('displays create form with input and button', async () => {
|
||||
render(AppPasswords)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /create/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('disables create button when input is empty', async () => {
|
||||
render(AppPasswords)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /create/i })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
it('enables create button when input has value', async () => {
|
||||
render(AppPasswords)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'My New App' } })
|
||||
|
||||
expect(screen.getByRole('button', { name: /create/i })).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('calls createAppPassword with correct name', async () => {
|
||||
let capturedName: string | null = null
|
||||
|
||||
mockEndpoint('com.atproto.server.createAppPassword', (_url, options) => {
|
||||
const body = JSON.parse((options?.body as string) || '{}')
|
||||
capturedName = body.name
|
||||
return jsonResponse({
|
||||
name: body.name,
|
||||
password: 'xxxx-xxxx-xxxx-xxxx',
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
})
|
||||
|
||||
render(AppPasswords)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'Graysky' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /create/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedName).toBe('Graysky')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows loading state while creating', async () => {
|
||||
mockEndpoint('com.atproto.server.createAppPassword', async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
return jsonResponse({
|
||||
name: 'Test',
|
||||
password: 'xxxx-xxxx-xxxx-xxxx',
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
})
|
||||
|
||||
render(AppPasswords)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'Test' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /create/i }))
|
||||
|
||||
expect(screen.getByRole('button', { name: /creating/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /creating/i })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('displays created password in success box and clears input', async () => {
|
||||
mockEndpoint('com.atproto.server.createAppPassword', () =>
|
||||
jsonResponse({
|
||||
name: 'MyApp',
|
||||
password: 'abcd-efgh-ijkl-mnop',
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
)
|
||||
|
||||
render(AppPasswords)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const input = screen.getByPlaceholderText(/app name/i) as HTMLInputElement
|
||||
await fireEvent.input(input, { target: { value: 'MyApp' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /create/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/app password created/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('abcd-efgh-ijkl-mnop')).toBeInTheDocument()
|
||||
expect(screen.getByText(/name: myapp/i)).toBeInTheDocument()
|
||||
expect(input.value).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
it('dismisses created password box when clicking Done', async () => {
|
||||
mockEndpoint('com.atproto.server.createAppPassword', () =>
|
||||
jsonResponse({
|
||||
name: 'Test',
|
||||
password: 'xxxx-xxxx-xxxx-xxxx',
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
)
|
||||
|
||||
render(AppPasswords)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'Test' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /create/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/app password created/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: /done/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/app password created/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error when creation fails', async () => {
|
||||
mockEndpoint('com.atproto.server.createAppPassword', () =>
|
||||
errorResponse('InvalidRequest', 'Name already exists', 400)
|
||||
)
|
||||
|
||||
render(AppPasswords)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'Duplicate' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /create/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/name already exists/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/name already exists/i)).toHaveClass('error')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('revoke app password', () => {
|
||||
const testPassword = mockData.appPassword({ name: 'TestApp' })
|
||||
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
})
|
||||
|
||||
it('shows confirmation dialog before revoking', async () => {
|
||||
const confirmSpy = vi.fn(() => false)
|
||||
window.confirm = confirmSpy
|
||||
|
||||
mockEndpoint('com.atproto.server.listAppPasswords', () =>
|
||||
jsonResponse({ passwords: [testPassword] })
|
||||
)
|
||||
|
||||
render(AppPasswords)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('TestApp')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('TestApp')
|
||||
)
|
||||
})
|
||||
|
||||
it('does not revoke when confirmation is cancelled', async () => {
|
||||
window.confirm = vi.fn(() => false)
|
||||
let revokeCalled = false
|
||||
|
||||
mockEndpoint('com.atproto.server.listAppPasswords', () =>
|
||||
jsonResponse({ passwords: [testPassword] })
|
||||
)
|
||||
|
||||
mockEndpoint('com.atproto.server.revokeAppPassword', () => {
|
||||
revokeCalled = true
|
||||
return jsonResponse({})
|
||||
})
|
||||
|
||||
render(AppPasswords)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('TestApp')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
|
||||
|
||||
expect(revokeCalled).toBe(false)
|
||||
})
|
||||
|
||||
it('calls revokeAppPassword with correct name', async () => {
|
||||
window.confirm = vi.fn(() => true)
|
||||
let capturedName: string | null = null
|
||||
|
||||
mockEndpoint('com.atproto.server.listAppPasswords', () =>
|
||||
jsonResponse({ passwords: [testPassword] })
|
||||
)
|
||||
|
||||
mockEndpoint('com.atproto.server.revokeAppPassword', (_url, options) => {
|
||||
const body = JSON.parse((options?.body as string) || '{}')
|
||||
capturedName = body.name
|
||||
return jsonResponse({})
|
||||
})
|
||||
|
||||
render(AppPasswords)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('TestApp')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedName).toBe('TestApp')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows loading state while revoking', async () => {
|
||||
window.confirm = vi.fn(() => true)
|
||||
|
||||
mockEndpoint('com.atproto.server.listAppPasswords', () =>
|
||||
jsonResponse({ passwords: [testPassword] })
|
||||
)
|
||||
|
||||
mockEndpoint('com.atproto.server.revokeAppPassword', async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
return jsonResponse({})
|
||||
})
|
||||
|
||||
render(AppPasswords)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('TestApp')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
|
||||
|
||||
expect(screen.getByRole('button', { name: /revoking/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /revoking/i })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('reloads password list after successful revocation', async () => {
|
||||
window.confirm = vi.fn(() => true)
|
||||
let listCallCount = 0
|
||||
|
||||
mockEndpoint('com.atproto.server.listAppPasswords', () => {
|
||||
listCallCount++
|
||||
if (listCallCount === 1) {
|
||||
return jsonResponse({ passwords: [testPassword] })
|
||||
}
|
||||
return jsonResponse({ passwords: [] })
|
||||
})
|
||||
|
||||
mockEndpoint('com.atproto.server.revokeAppPassword', () =>
|
||||
jsonResponse({})
|
||||
)
|
||||
|
||||
render(AppPasswords)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('TestApp')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('TestApp')).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/no app passwords yet/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error when revocation fails', async () => {
|
||||
window.confirm = vi.fn(() => true)
|
||||
|
||||
mockEndpoint('com.atproto.server.listAppPasswords', () =>
|
||||
jsonResponse({ passwords: [testPassword] })
|
||||
)
|
||||
|
||||
mockEndpoint('com.atproto.server.revokeAppPassword', () =>
|
||||
errorResponse('InternalError', 'Server error', 500)
|
||||
)
|
||||
|
||||
render(AppPasswords)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('TestApp')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/server error/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/server error/i)).toHaveClass('error')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
})
|
||||
|
||||
it('shows error when loading passwords fails', async () => {
|
||||
mockEndpoint('com.atproto.server.listAppPasswords', () =>
|
||||
errorResponse('InternalError', 'Database connection failed', 500)
|
||||
)
|
||||
|
||||
render(AppPasswords)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/database connection failed/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/database connection failed/i)).toHaveClass('error')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'
|
||||
import Dashboard from '../routes/Dashboard.svelte'
|
||||
import {
|
||||
setupFetchMock,
|
||||
mockEndpoint,
|
||||
jsonResponse,
|
||||
mockData,
|
||||
clearMocks,
|
||||
setupAuthenticatedUser,
|
||||
setupUnauthenticatedUser,
|
||||
} from './mocks'
|
||||
|
||||
const STORAGE_KEY = 'bspds_session'
|
||||
|
||||
describe('Dashboard', () => {
|
||||
beforeEach(() => {
|
||||
clearMocks()
|
||||
setupFetchMock()
|
||||
})
|
||||
|
||||
describe('authentication guard', () => {
|
||||
it('redirects to login when not authenticated', async () => {
|
||||
setupUnauthenticatedUser()
|
||||
render(Dashboard)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.hash).toBe('#/login')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows loading state while checking auth', () => {
|
||||
render(Dashboard)
|
||||
|
||||
expect(screen.getByText(/loading/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('authenticated view', () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
})
|
||||
|
||||
it('displays user account info and page structure', async () => {
|
||||
render(Dashboard)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /dashboard/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: /account overview/i })).toBeInTheDocument()
|
||||
expect(screen.getByText(/@testuser\.test\.bspds\.dev/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/did:web:test\.bspds\.dev:u:testuser/)).toBeInTheDocument()
|
||||
expect(screen.getByText('test@example.com')).toBeInTheDocument()
|
||||
expect(screen.getByText('Verified')).toBeInTheDocument()
|
||||
expect(screen.getByText('Verified')).toHaveClass('badge', 'success')
|
||||
})
|
||||
})
|
||||
|
||||
it('displays unverified badge when email not confirmed', async () => {
|
||||
setupAuthenticatedUser({ emailConfirmed: false })
|
||||
render(Dashboard)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Unverified')).toBeInTheDocument()
|
||||
expect(screen.getByText('Unverified')).toHaveClass('badge', 'warning')
|
||||
})
|
||||
})
|
||||
|
||||
it('displays all navigation cards', async () => {
|
||||
render(Dashboard)
|
||||
|
||||
await waitFor(() => {
|
||||
const navCards = [
|
||||
{ name: /app passwords/i, href: '#/app-passwords' },
|
||||
{ name: /invite codes/i, href: '#/invite-codes' },
|
||||
{ name: /account settings/i, href: '#/settings' },
|
||||
{ name: /notification preferences/i, href: '#/notifications' },
|
||||
{ name: /repository explorer/i, href: '#/repo' },
|
||||
]
|
||||
|
||||
for (const { name, href } of navCards) {
|
||||
const card = screen.getByRole('link', { name })
|
||||
expect(card).toBeInTheDocument()
|
||||
expect(card).toHaveAttribute('href', href)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('logout functionality', () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(mockData.session()))
|
||||
|
||||
mockEndpoint('com.atproto.server.deleteSession', () =>
|
||||
jsonResponse({})
|
||||
)
|
||||
})
|
||||
|
||||
it('calls deleteSession and navigates to login on logout', async () => {
|
||||
let deleteSessionCalled = false
|
||||
|
||||
mockEndpoint('com.atproto.server.deleteSession', () => {
|
||||
deleteSessionCalled = true
|
||||
return jsonResponse({})
|
||||
})
|
||||
|
||||
render(Dashboard)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /sign out/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: /sign out/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(deleteSessionCalled).toBe(true)
|
||||
expect(window.location.hash).toBe('#/login')
|
||||
})
|
||||
})
|
||||
|
||||
it('clears session from localStorage after logout', async () => {
|
||||
const storedSession = localStorage.getItem(STORAGE_KEY)
|
||||
expect(storedSession).not.toBeNull()
|
||||
|
||||
render(Dashboard)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /sign out/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: /sign out/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'
|
||||
import Login from '../routes/Login.svelte'
|
||||
import {
|
||||
setupFetchMock,
|
||||
mockEndpoint,
|
||||
jsonResponse,
|
||||
errorResponse,
|
||||
mockData,
|
||||
clearMocks,
|
||||
} from './mocks'
|
||||
|
||||
describe('Login', () => {
|
||||
beforeEach(() => {
|
||||
clearMocks()
|
||||
setupFetchMock()
|
||||
window.location.hash = ''
|
||||
})
|
||||
|
||||
describe('initial render', () => {
|
||||
it('renders login form with all elements and correct initial state', () => {
|
||||
render(Login)
|
||||
|
||||
expect(screen.getByRole('heading', { name: /sign in/i })).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/handle or email/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/password/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /sign in/i })).toBeDisabled()
|
||||
expect(screen.getByText(/don't have an account/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /create one/i })).toHaveAttribute('href', '#/register')
|
||||
})
|
||||
})
|
||||
|
||||
describe('form validation', () => {
|
||||
it('enables submit button only when both fields are filled', async () => {
|
||||
render(Login)
|
||||
|
||||
const identifierInput = screen.getByLabelText(/handle or email/i)
|
||||
const passwordInput = screen.getByLabelText(/password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
|
||||
await fireEvent.input(identifierInput, { target: { value: 'testuser' } })
|
||||
expect(submitButton).toBeDisabled()
|
||||
|
||||
await fireEvent.input(identifierInput, { target: { value: '' } })
|
||||
await fireEvent.input(passwordInput, { target: { value: 'password123' } })
|
||||
expect(submitButton).toBeDisabled()
|
||||
|
||||
await fireEvent.input(identifierInput, { target: { value: 'testuser' } })
|
||||
expect(submitButton).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('login submission', () => {
|
||||
it('calls createSession with correct credentials', async () => {
|
||||
let capturedBody: Record<string, string> | null = null
|
||||
|
||||
mockEndpoint('com.atproto.server.createSession', (_url, options) => {
|
||||
capturedBody = JSON.parse((options?.body as string) || '{}')
|
||||
return jsonResponse(mockData.session())
|
||||
})
|
||||
|
||||
render(Login)
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/handle or email/i), { target: { value: 'testuser@example.com' } })
|
||||
await fireEvent.input(screen.getByLabelText(/password/i), { target: { value: 'mypassword' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /sign in/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedBody).toEqual({
|
||||
identifier: 'testuser@example.com',
|
||||
password: 'mypassword',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('shows styled error message on invalid credentials', async () => {
|
||||
mockEndpoint('com.atproto.server.createSession', () =>
|
||||
errorResponse('AuthenticationRequired', 'Invalid identifier or password', 401)
|
||||
)
|
||||
|
||||
render(Login)
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/handle or email/i), { target: { value: 'wronguser' } })
|
||||
await fireEvent.input(screen.getByLabelText(/password/i), { target: { value: 'wrongpassword' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /sign in/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
const errorDiv = screen.getByText(/invalid identifier or password/i)
|
||||
expect(errorDiv).toBeInTheDocument()
|
||||
expect(errorDiv).toHaveClass('error')
|
||||
})
|
||||
})
|
||||
|
||||
it('navigates to dashboard on successful login', async () => {
|
||||
mockEndpoint('com.atproto.server.createSession', () =>
|
||||
jsonResponse(mockData.session())
|
||||
)
|
||||
|
||||
render(Login)
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/handle or email/i), { target: { value: 'test' } })
|
||||
await fireEvent.input(screen.getByLabelText(/password/i), { target: { value: 'password' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /sign in/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.hash).toBe('#/dashboard')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('account verification flow', () => {
|
||||
it('shows verification form with all controls when account is not verified', async () => {
|
||||
mockEndpoint('com.atproto.server.createSession', () => ({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({
|
||||
error: 'AccountNotVerified',
|
||||
message: 'Account not verified',
|
||||
did: 'did:web:test.bspds.dev:u:testuser',
|
||||
}),
|
||||
}))
|
||||
|
||||
render(Login)
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/handle or email/i), { target: { value: 'unverified@test.com' } })
|
||||
await fireEvent.input(screen.getByLabelText(/password/i), { target: { value: 'password' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /sign in/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /verify your account/i })).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /resend code/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /back to login/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('returns to login form when clicking back', async () => {
|
||||
mockEndpoint('com.atproto.server.createSession', () => ({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({
|
||||
error: 'AccountNotVerified',
|
||||
message: 'Account not verified',
|
||||
did: 'did:web:test.bspds.dev:u:testuser',
|
||||
}),
|
||||
}))
|
||||
|
||||
render(Login)
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/handle or email/i), { target: { value: 'test' } })
|
||||
await fireEvent.input(screen.getByLabelText(/password/i), { target: { value: 'password' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /sign in/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /back to login/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: /back to login/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /sign in/i })).toBeInTheDocument()
|
||||
expect(screen.queryByLabelText(/verification code/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,443 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'
|
||||
import Notifications from '../routes/Notifications.svelte'
|
||||
import {
|
||||
setupFetchMock,
|
||||
mockEndpoint,
|
||||
jsonResponse,
|
||||
errorResponse,
|
||||
mockData,
|
||||
clearMocks,
|
||||
setupAuthenticatedUser,
|
||||
setupUnauthenticatedUser,
|
||||
} from './mocks'
|
||||
|
||||
describe('Notifications', () => {
|
||||
beforeEach(() => {
|
||||
clearMocks()
|
||||
setupFetchMock()
|
||||
})
|
||||
|
||||
describe('authentication guard', () => {
|
||||
it('redirects to login when not authenticated', async () => {
|
||||
setupUnauthenticatedUser()
|
||||
render(Notifications)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.hash).toBe('#/login')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('page structure', () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
|
||||
jsonResponse(mockData.notificationPrefs())
|
||||
)
|
||||
})
|
||||
|
||||
it('displays all page elements and sections', async () => {
|
||||
render(Notifications)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /notification preferences/i, level: 1 })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /dashboard/i })).toHaveAttribute('href', '#/dashboard')
|
||||
expect(screen.getByText(/password resets/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: /preferred channel/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: /channel configuration/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('loading state', () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
})
|
||||
|
||||
it('shows loading text while fetching preferences', async () => {
|
||||
mockEndpoint('com.bspds.account.getNotificationPrefs', async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
return jsonResponse(mockData.notificationPrefs())
|
||||
})
|
||||
|
||||
render(Notifications)
|
||||
|
||||
expect(screen.getByText(/loading/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('channel options', () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
})
|
||||
|
||||
it('displays all four channel options', async () => {
|
||||
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
|
||||
jsonResponse(mockData.notificationPrefs())
|
||||
)
|
||||
|
||||
render(Notifications)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('radio', { name: /email/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('radio', { name: /discord/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('radio', { name: /telegram/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('radio', { name: /signal/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('email channel is always selectable', async () => {
|
||||
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
|
||||
jsonResponse(mockData.notificationPrefs())
|
||||
)
|
||||
|
||||
render(Notifications)
|
||||
|
||||
await waitFor(() => {
|
||||
const emailRadio = screen.getByRole('radio', { name: /email/i })
|
||||
expect(emailRadio).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
it('discord channel is disabled when not configured', async () => {
|
||||
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
|
||||
jsonResponse(mockData.notificationPrefs({ discordId: null }))
|
||||
)
|
||||
|
||||
render(Notifications)
|
||||
|
||||
await waitFor(() => {
|
||||
const discordRadio = screen.getByRole('radio', { name: /discord/i })
|
||||
expect(discordRadio).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
it('discord channel is enabled when configured', async () => {
|
||||
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
|
||||
jsonResponse(mockData.notificationPrefs({ discordId: '123456789' }))
|
||||
)
|
||||
|
||||
render(Notifications)
|
||||
|
||||
await waitFor(() => {
|
||||
const discordRadio = screen.getByRole('radio', { name: /discord/i })
|
||||
expect(discordRadio).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows hint for disabled channels', async () => {
|
||||
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
|
||||
jsonResponse(mockData.notificationPrefs())
|
||||
)
|
||||
|
||||
render(Notifications)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText(/configure below to enable/i).length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('selects current preferred channel', async () => {
|
||||
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
|
||||
jsonResponse(mockData.notificationPrefs({ preferredChannel: 'email' }))
|
||||
)
|
||||
|
||||
render(Notifications)
|
||||
|
||||
await waitFor(() => {
|
||||
const emailRadio = screen.getByRole('radio', { name: /email/i }) as HTMLInputElement
|
||||
expect(emailRadio.checked).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('channel configuration', () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
})
|
||||
|
||||
it('displays email as readonly with current value', async () => {
|
||||
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
|
||||
jsonResponse(mockData.notificationPrefs())
|
||||
)
|
||||
|
||||
render(Notifications)
|
||||
|
||||
await waitFor(() => {
|
||||
const emailInput = screen.getByLabelText(/^email$/i) as HTMLInputElement
|
||||
expect(emailInput).toBeDisabled()
|
||||
expect(emailInput.value).toBe('test@example.com')
|
||||
})
|
||||
})
|
||||
|
||||
it('displays all channel inputs with current values', async () => {
|
||||
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
|
||||
jsonResponse(mockData.notificationPrefs({
|
||||
discordId: '123456789',
|
||||
telegramUsername: 'testuser',
|
||||
signalNumber: '+1234567890',
|
||||
}))
|
||||
)
|
||||
|
||||
render(Notifications)
|
||||
|
||||
await waitFor(() => {
|
||||
expect((screen.getByLabelText(/discord user id/i) as HTMLInputElement).value).toBe('123456789')
|
||||
expect((screen.getByLabelText(/telegram username/i) as HTMLInputElement).value).toBe('testuser')
|
||||
expect((screen.getByLabelText(/signal phone number/i) as HTMLInputElement).value).toBe('+1234567890')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('verification status badges', () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
})
|
||||
|
||||
it('shows Primary badge for email', async () => {
|
||||
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
|
||||
jsonResponse(mockData.notificationPrefs())
|
||||
)
|
||||
|
||||
render(Notifications)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Primary')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows Verified badge for verified discord', async () => {
|
||||
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
|
||||
jsonResponse(mockData.notificationPrefs({
|
||||
discordId: '123456789',
|
||||
discordVerified: true,
|
||||
}))
|
||||
)
|
||||
|
||||
render(Notifications)
|
||||
|
||||
await waitFor(() => {
|
||||
const verifiedBadges = screen.getAllByText('Verified')
|
||||
expect(verifiedBadges.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows Not verified badge for unverified discord', async () => {
|
||||
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
|
||||
jsonResponse(mockData.notificationPrefs({
|
||||
discordId: '123456789',
|
||||
discordVerified: false,
|
||||
}))
|
||||
)
|
||||
|
||||
render(Notifications)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Not verified')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('does not show badge when channel not configured', async () => {
|
||||
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
|
||||
jsonResponse(mockData.notificationPrefs())
|
||||
)
|
||||
|
||||
render(Notifications)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Primary')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Not verified')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('save preferences', () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
})
|
||||
|
||||
it('calls updateNotificationPrefs with correct data', async () => {
|
||||
let capturedBody: Record<string, unknown> | null = null
|
||||
|
||||
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
|
||||
jsonResponse(mockData.notificationPrefs())
|
||||
)
|
||||
|
||||
mockEndpoint('com.bspds.account.updateNotificationPrefs', (_url, options) => {
|
||||
capturedBody = JSON.parse((options?.body as string) || '{}')
|
||||
return jsonResponse({ success: true })
|
||||
})
|
||||
|
||||
render(Notifications)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/discord user id/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/discord user id/i), { target: { value: '999888777' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /save preferences/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedBody).not.toBeNull()
|
||||
expect(capturedBody?.discordId).toBe('999888777')
|
||||
expect(capturedBody?.preferredChannel).toBe('email')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows loading state while saving', async () => {
|
||||
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
|
||||
jsonResponse(mockData.notificationPrefs())
|
||||
)
|
||||
|
||||
mockEndpoint('com.bspds.account.updateNotificationPrefs', async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
return jsonResponse({ success: true })
|
||||
})
|
||||
|
||||
render(Notifications)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: /save preferences/i }))
|
||||
|
||||
expect(screen.getByRole('button', { name: /saving/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /saving/i })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('shows success message after saving', async () => {
|
||||
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
|
||||
jsonResponse(mockData.notificationPrefs())
|
||||
)
|
||||
|
||||
mockEndpoint('com.bspds.account.updateNotificationPrefs', () =>
|
||||
jsonResponse({ success: true })
|
||||
)
|
||||
|
||||
render(Notifications)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: /save preferences/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/notification preferences saved/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error when save fails', async () => {
|
||||
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
|
||||
jsonResponse(mockData.notificationPrefs())
|
||||
)
|
||||
|
||||
mockEndpoint('com.bspds.account.updateNotificationPrefs', () =>
|
||||
errorResponse('InvalidRequest', 'Invalid channel configuration', 400)
|
||||
)
|
||||
|
||||
render(Notifications)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: /save preferences/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/invalid channel configuration/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/invalid channel configuration/i).closest('.message')).toHaveClass('error')
|
||||
})
|
||||
})
|
||||
|
||||
it('reloads preferences after successful save', async () => {
|
||||
let loadCount = 0
|
||||
|
||||
mockEndpoint('com.bspds.account.getNotificationPrefs', () => {
|
||||
loadCount++
|
||||
return jsonResponse(mockData.notificationPrefs())
|
||||
})
|
||||
|
||||
mockEndpoint('com.bspds.account.updateNotificationPrefs', () =>
|
||||
jsonResponse({ success: true })
|
||||
)
|
||||
|
||||
render(Notifications)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const initialLoadCount = loadCount
|
||||
await fireEvent.click(screen.getByRole('button', { name: /save preferences/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(loadCount).toBeGreaterThan(initialLoadCount)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('channel selection interaction', () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
})
|
||||
|
||||
it('enables discord channel after entering discord ID', async () => {
|
||||
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
|
||||
jsonResponse(mockData.notificationPrefs())
|
||||
)
|
||||
|
||||
render(Notifications)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('radio', { name: /discord/i })).toBeDisabled()
|
||||
})
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/discord user id/i), { target: { value: '123456789' } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('radio', { name: /discord/i })).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
it('allows selecting a configured channel', async () => {
|
||||
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
|
||||
jsonResponse(mockData.notificationPrefs({
|
||||
discordId: '123456789',
|
||||
discordVerified: true,
|
||||
}))
|
||||
)
|
||||
|
||||
render(Notifications)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('radio', { name: /discord/i })).not.toBeDisabled()
|
||||
})
|
||||
|
||||
await fireEvent.click(screen.getByRole('radio', { name: /discord/i }))
|
||||
|
||||
const discordRadio = screen.getByRole('radio', { name: /discord/i }) as HTMLInputElement
|
||||
expect(discordRadio.checked).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
})
|
||||
|
||||
it('shows error when loading preferences fails', async () => {
|
||||
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
|
||||
errorResponse('InternalError', 'Database connection failed', 500)
|
||||
)
|
||||
|
||||
render(Notifications)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/database connection failed/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,516 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'
|
||||
import Settings from '../routes/Settings.svelte'
|
||||
import {
|
||||
setupFetchMock,
|
||||
mockEndpoint,
|
||||
jsonResponse,
|
||||
errorResponse,
|
||||
clearMocks,
|
||||
setupAuthenticatedUser,
|
||||
setupUnauthenticatedUser,
|
||||
} from './mocks'
|
||||
|
||||
describe('Settings', () => {
|
||||
beforeEach(() => {
|
||||
clearMocks()
|
||||
setupFetchMock()
|
||||
window.confirm = vi.fn(() => true)
|
||||
})
|
||||
|
||||
describe('authentication guard', () => {
|
||||
it('redirects to login when not authenticated', async () => {
|
||||
setupUnauthenticatedUser()
|
||||
render(Settings)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.hash).toBe('#/login')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('page structure', () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
})
|
||||
|
||||
it('displays all page elements and sections', async () => {
|
||||
render(Settings)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /account settings/i, level: 1 })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /dashboard/i })).toHaveAttribute('href', '#/dashboard')
|
||||
expect(screen.getByRole('heading', { name: /change email/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: /change handle/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: /delete account/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('email change', () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
})
|
||||
|
||||
it('displays current email and input field', async () => {
|
||||
render(Settings)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/current: test@example.com/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('calls requestEmailUpdate when submitting', async () => {
|
||||
let requestCalled = false
|
||||
|
||||
mockEndpoint('com.atproto.server.requestEmailUpdate', () => {
|
||||
requestCalled = true
|
||||
return jsonResponse({ tokenRequired: true })
|
||||
})
|
||||
|
||||
render(Settings)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'newemail@example.com' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestCalled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows verification code input when token is required', async () => {
|
||||
mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
|
||||
jsonResponse({ tokenRequired: true })
|
||||
)
|
||||
|
||||
render(Settings)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'newemail@example.com' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /confirm email change/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('calls updateEmail with token when confirming', async () => {
|
||||
let updateCalled = false
|
||||
let capturedBody: Record<string, string> | null = null
|
||||
|
||||
mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
|
||||
jsonResponse({ tokenRequired: true })
|
||||
)
|
||||
|
||||
mockEndpoint('com.atproto.server.updateEmail', (_url, options) => {
|
||||
updateCalled = true
|
||||
capturedBody = JSON.parse((options?.body as string) || '{}')
|
||||
return jsonResponse({})
|
||||
})
|
||||
|
||||
render(Settings)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'newemail@example.com' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/verification code/i), { target: { value: '123456' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /confirm email change/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateCalled).toBe(true)
|
||||
expect(capturedBody?.email).toBe('newemail@example.com')
|
||||
expect(capturedBody?.token).toBe('123456')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows success message after email update', async () => {
|
||||
mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
|
||||
jsonResponse({ tokenRequired: true })
|
||||
)
|
||||
|
||||
mockEndpoint('com.atproto.server.updateEmail', () =>
|
||||
jsonResponse({})
|
||||
)
|
||||
|
||||
render(Settings)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'new@test.com' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/verification code/i), { target: { value: '123456' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /confirm email change/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/email updated successfully/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows cancel button to return to email form', async () => {
|
||||
mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
|
||||
jsonResponse({ tokenRequired: true })
|
||||
)
|
||||
|
||||
render(Settings)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'new@test.com' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: /cancel/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
|
||||
expect(screen.queryByLabelText(/verification code/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error when email update fails', async () => {
|
||||
mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
|
||||
errorResponse('InvalidEmail', 'Invalid email format', 400)
|
||||
)
|
||||
|
||||
render(Settings)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'invalid@test.com' } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /change email/i })).not.toBeDisabled()
|
||||
})
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/invalid email format/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('handle change', () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
})
|
||||
|
||||
it('displays current handle', async () => {
|
||||
render(Settings)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/current: @testuser\.test\.bspds\.dev/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('calls updateHandle with new handle', async () => {
|
||||
let capturedHandle: string | null = null
|
||||
|
||||
mockEndpoint('com.atproto.identity.updateHandle', (_url, options) => {
|
||||
const body = JSON.parse((options?.body as string) || '{}')
|
||||
capturedHandle = body.handle
|
||||
return jsonResponse({})
|
||||
})
|
||||
|
||||
render(Settings)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/new handle/i), { target: { value: 'newhandle.bsky.social' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /change handle/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedHandle).toBe('newhandle.bsky.social')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows success message after handle change', async () => {
|
||||
mockEndpoint('com.atproto.identity.updateHandle', () =>
|
||||
jsonResponse({})
|
||||
)
|
||||
|
||||
render(Settings)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/new handle/i), { target: { value: 'newhandle' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /change handle/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/handle updated successfully/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error when handle change fails', async () => {
|
||||
mockEndpoint('com.atproto.identity.updateHandle', () =>
|
||||
errorResponse('HandleNotAvailable', 'Handle is already taken', 400)
|
||||
)
|
||||
|
||||
render(Settings)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/new handle/i), { target: { value: 'taken' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /change handle/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/handle is already taken/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('account deletion', () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
mockEndpoint('com.atproto.server.deleteSession', () =>
|
||||
jsonResponse({})
|
||||
)
|
||||
})
|
||||
|
||||
it('displays delete section with warning and request button', async () => {
|
||||
render(Settings)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/this action is irreversible/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('calls requestAccountDelete when clicking request', async () => {
|
||||
let requestCalled = false
|
||||
|
||||
mockEndpoint('com.atproto.server.requestAccountDelete', () => {
|
||||
requestCalled = true
|
||||
return jsonResponse({})
|
||||
})
|
||||
|
||||
render(Settings)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(requestCalled).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows confirmation form after requesting deletion', async () => {
|
||||
mockEndpoint('com.atproto.server.requestAccountDelete', () =>
|
||||
jsonResponse({})
|
||||
)
|
||||
|
||||
render(Settings)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/your password/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /permanently delete account/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows confirmation dialog before final deletion', async () => {
|
||||
const confirmSpy = vi.fn(() => false)
|
||||
window.confirm = confirmSpy
|
||||
|
||||
mockEndpoint('com.atproto.server.requestAccountDelete', () =>
|
||||
jsonResponse({})
|
||||
)
|
||||
|
||||
render(Settings)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: 'ABC123' } })
|
||||
await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: 'password' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /permanently delete account/i }))
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('absolutely sure')
|
||||
)
|
||||
})
|
||||
|
||||
it('calls deleteAccount with correct parameters', async () => {
|
||||
window.confirm = vi.fn(() => true)
|
||||
let capturedBody: Record<string, string> | null = null
|
||||
|
||||
mockEndpoint('com.atproto.server.requestAccountDelete', () =>
|
||||
jsonResponse({})
|
||||
)
|
||||
|
||||
mockEndpoint('com.atproto.server.deleteAccount', (_url, options) => {
|
||||
capturedBody = JSON.parse((options?.body as string) || '{}')
|
||||
return jsonResponse({})
|
||||
})
|
||||
|
||||
render(Settings)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: 'DEL123' } })
|
||||
await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: 'mypassword' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /permanently delete account/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(capturedBody?.token).toBe('DEL123')
|
||||
expect(capturedBody?.password).toBe('mypassword')
|
||||
expect(capturedBody?.did).toBe('did:web:test.bspds.dev:u:testuser')
|
||||
})
|
||||
})
|
||||
|
||||
it('navigates to login after successful deletion', async () => {
|
||||
window.confirm = vi.fn(() => true)
|
||||
|
||||
mockEndpoint('com.atproto.server.requestAccountDelete', () =>
|
||||
jsonResponse({})
|
||||
)
|
||||
|
||||
mockEndpoint('com.atproto.server.deleteAccount', () =>
|
||||
jsonResponse({})
|
||||
)
|
||||
|
||||
render(Settings)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: 'DEL123' } })
|
||||
await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: 'password' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /permanently delete account/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.hash).toBe('#/login')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows cancel button to return to request state', async () => {
|
||||
mockEndpoint('com.atproto.server.requestAccountDelete', () =>
|
||||
jsonResponse({})
|
||||
)
|
||||
|
||||
render(Settings)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
const cancelButtons = screen.getAllByRole('button', { name: /cancel/i })
|
||||
expect(cancelButtons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
const deleteHeading = screen.getByRole('heading', { name: /delete account/i })
|
||||
const deleteSection = deleteHeading.closest('section')
|
||||
const cancelButton = deleteSection?.querySelector('button.secondary')
|
||||
if (cancelButton) {
|
||||
await fireEvent.click(cancelButton)
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error when deletion fails', async () => {
|
||||
window.confirm = vi.fn(() => true)
|
||||
|
||||
mockEndpoint('com.atproto.server.requestAccountDelete', () =>
|
||||
jsonResponse({})
|
||||
)
|
||||
|
||||
mockEndpoint('com.atproto.server.deleteAccount', () =>
|
||||
errorResponse('InvalidToken', 'Invalid confirmation code', 400)
|
||||
)
|
||||
|
||||
render(Settings)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: 'WRONG' } })
|
||||
await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: 'password' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /permanently delete account/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/invalid confirmation code/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,264 @@
|
||||
import { vi } from 'vitest'
|
||||
import type { Session, AppPassword, InviteCode } from '../lib/api'
|
||||
import { _testSetState } from '../lib/auth.svelte'
|
||||
|
||||
export interface MockResponse {
|
||||
ok: boolean
|
||||
status: number
|
||||
json: () => Promise<unknown>
|
||||
}
|
||||
|
||||
export type MockHandler = (url: string, options?: RequestInit) => MockResponse | Promise<MockResponse>
|
||||
|
||||
const mockHandlers: Map<string, MockHandler> = new Map()
|
||||
|
||||
export function mockEndpoint(endpoint: string, handler: MockHandler): void {
|
||||
mockHandlers.set(endpoint, handler)
|
||||
}
|
||||
|
||||
export function mockEndpointOnce(endpoint: string, handler: MockHandler): void {
|
||||
const originalHandler = mockHandlers.get(endpoint)
|
||||
mockHandlers.set(endpoint, (url, options) => {
|
||||
mockHandlers.set(endpoint, originalHandler!)
|
||||
return handler(url, options)
|
||||
})
|
||||
}
|
||||
|
||||
export function clearMocks(): void {
|
||||
mockHandlers.clear()
|
||||
}
|
||||
|
||||
function extractEndpoint(url: string): string {
|
||||
const match = url.match(/\/xrpc\/([^?]+)/)
|
||||
return match ? match[1] : url
|
||||
}
|
||||
|
||||
export function setupFetchMock(): void {
|
||||
global.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const url = typeof input === 'string' ? input : input.toString()
|
||||
const endpoint = extractEndpoint(url)
|
||||
|
||||
const handler = mockHandlers.get(endpoint)
|
||||
if (handler) {
|
||||
const result = await handler(url, init)
|
||||
return {
|
||||
ok: result.ok,
|
||||
status: result.status,
|
||||
json: result.json,
|
||||
text: async () => JSON.stringify(await result.json()),
|
||||
headers: new Headers(),
|
||||
redirected: false,
|
||||
statusText: result.ok ? 'OK' : 'Error',
|
||||
type: 'basic',
|
||||
url,
|
||||
clone: () => ({ ...result }) as Response,
|
||||
body: null,
|
||||
bodyUsed: false,
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
blob: async () => new Blob(),
|
||||
formData: async () => new FormData(),
|
||||
} as Response
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({ error: 'NotFound', message: `No mock for ${endpoint}` }),
|
||||
text: async () => JSON.stringify({ error: 'NotFound', message: `No mock for ${endpoint}` }),
|
||||
headers: new Headers(),
|
||||
redirected: false,
|
||||
statusText: 'Not Found',
|
||||
type: 'basic',
|
||||
url,
|
||||
clone: function() { return this },
|
||||
body: null,
|
||||
bodyUsed: false,
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
blob: async () => new Blob(),
|
||||
formData: async () => new FormData(),
|
||||
} as Response
|
||||
})
|
||||
}
|
||||
|
||||
export function jsonResponse<T>(data: T, status = 200): MockResponse {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => data,
|
||||
}
|
||||
}
|
||||
|
||||
export function errorResponse(error: string, message: string, status = 400): MockResponse {
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
json: async () => ({ error, message }),
|
||||
}
|
||||
}
|
||||
|
||||
export const mockData = {
|
||||
session: (overrides?: Partial<Session>): Session => ({
|
||||
did: 'did:web:test.bspds.dev:u:testuser',
|
||||
handle: 'testuser.test.bspds.dev',
|
||||
email: 'test@example.com',
|
||||
emailConfirmed: true,
|
||||
accessJwt: 'mock-access-jwt-token',
|
||||
refreshJwt: 'mock-refresh-jwt-token',
|
||||
...overrides,
|
||||
}),
|
||||
|
||||
appPassword: (overrides?: Partial<AppPassword>): AppPassword => ({
|
||||
name: 'Test App',
|
||||
createdAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
}),
|
||||
|
||||
inviteCode: (overrides?: Partial<InviteCode>): InviteCode => ({
|
||||
code: 'test-invite-123',
|
||||
available: 1,
|
||||
disabled: false,
|
||||
forAccount: 'did:web:test.bspds.dev:u:testuser',
|
||||
createdBy: 'did:web:test.bspds.dev:u:testuser',
|
||||
createdAt: new Date().toISOString(),
|
||||
uses: [],
|
||||
...overrides,
|
||||
}),
|
||||
|
||||
notificationPrefs: (overrides?: Record<string, unknown>) => ({
|
||||
preferredChannel: 'email',
|
||||
email: 'test@example.com',
|
||||
discordId: null,
|
||||
discordVerified: false,
|
||||
telegramUsername: null,
|
||||
telegramVerified: false,
|
||||
signalNumber: null,
|
||||
signalVerified: false,
|
||||
...overrides,
|
||||
}),
|
||||
|
||||
describeServer: () => ({
|
||||
availableUserDomains: ['test.bspds.dev'],
|
||||
inviteCodeRequired: false,
|
||||
links: {
|
||||
privacyPolicy: 'https://example.com/privacy',
|
||||
termsOfService: 'https://example.com/tos',
|
||||
},
|
||||
}),
|
||||
|
||||
describeRepo: (did: string) => ({
|
||||
handle: 'testuser.test.bspds.dev',
|
||||
did,
|
||||
didDoc: {},
|
||||
collections: ['app.bsky.feed.post', 'app.bsky.feed.like', 'app.bsky.graph.follow'],
|
||||
handleIsCorrect: true,
|
||||
}),
|
||||
}
|
||||
|
||||
export function setupDefaultMocks(): void {
|
||||
setupFetchMock()
|
||||
|
||||
mockEndpoint('com.atproto.server.getSession', () =>
|
||||
jsonResponse(mockData.session())
|
||||
)
|
||||
|
||||
mockEndpoint('com.atproto.server.createSession', (_url, options) => {
|
||||
const body = JSON.parse((options?.body as string) || '{}')
|
||||
if (body.identifier && body.password === 'correctpassword') {
|
||||
return jsonResponse(mockData.session({ handle: body.identifier.replace('@', '') }))
|
||||
}
|
||||
return errorResponse('AuthenticationRequired', 'Invalid identifier or password', 401)
|
||||
})
|
||||
|
||||
mockEndpoint('com.atproto.server.refreshSession', () =>
|
||||
jsonResponse(mockData.session())
|
||||
)
|
||||
|
||||
mockEndpoint('com.atproto.server.deleteSession', () =>
|
||||
jsonResponse({})
|
||||
)
|
||||
|
||||
mockEndpoint('com.atproto.server.listAppPasswords', () =>
|
||||
jsonResponse({ passwords: [mockData.appPassword()] })
|
||||
)
|
||||
|
||||
mockEndpoint('com.atproto.server.createAppPassword', (_url, options) => {
|
||||
const body = JSON.parse((options?.body as string) || '{}')
|
||||
return jsonResponse({
|
||||
name: body.name,
|
||||
password: 'xxxx-xxxx-xxxx-xxxx',
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
})
|
||||
|
||||
mockEndpoint('com.atproto.server.revokeAppPassword', () =>
|
||||
jsonResponse({})
|
||||
)
|
||||
|
||||
mockEndpoint('com.atproto.server.getAccountInviteCodes', () =>
|
||||
jsonResponse({ codes: [mockData.inviteCode()] })
|
||||
)
|
||||
|
||||
mockEndpoint('com.atproto.server.createInviteCode', () =>
|
||||
jsonResponse({ code: 'new-invite-' + Date.now() })
|
||||
)
|
||||
|
||||
mockEndpoint('com.bspds.account.getNotificationPrefs', () =>
|
||||
jsonResponse(mockData.notificationPrefs())
|
||||
)
|
||||
|
||||
mockEndpoint('com.bspds.account.updateNotificationPrefs', () =>
|
||||
jsonResponse({ success: true })
|
||||
)
|
||||
|
||||
mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
|
||||
jsonResponse({ tokenRequired: true })
|
||||
)
|
||||
|
||||
mockEndpoint('com.atproto.server.updateEmail', () =>
|
||||
jsonResponse({})
|
||||
)
|
||||
|
||||
mockEndpoint('com.atproto.identity.updateHandle', () =>
|
||||
jsonResponse({})
|
||||
)
|
||||
|
||||
mockEndpoint('com.atproto.server.requestAccountDelete', () =>
|
||||
jsonResponse({})
|
||||
)
|
||||
|
||||
mockEndpoint('com.atproto.server.deleteAccount', () =>
|
||||
jsonResponse({})
|
||||
)
|
||||
|
||||
mockEndpoint('com.atproto.server.describeServer', () =>
|
||||
jsonResponse(mockData.describeServer())
|
||||
)
|
||||
|
||||
mockEndpoint('com.atproto.repo.describeRepo', (url) => {
|
||||
const params = new URLSearchParams(url.split('?')[1])
|
||||
const repo = params.get('repo') || 'did:web:test'
|
||||
return jsonResponse(mockData.describeRepo(repo))
|
||||
})
|
||||
|
||||
mockEndpoint('com.atproto.repo.listRecords', () =>
|
||||
jsonResponse({ records: [] })
|
||||
)
|
||||
}
|
||||
|
||||
export function setupAuthenticatedUser(sessionOverrides?: Partial<Session>): Session {
|
||||
const session = mockData.session(sessionOverrides)
|
||||
_testSetState({
|
||||
session,
|
||||
loading: false,
|
||||
error: null,
|
||||
})
|
||||
return session
|
||||
}
|
||||
|
||||
export function setupUnauthenticatedUser(): void {
|
||||
_testSetState({
|
||||
session: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { vi, beforeEach, afterEach } from 'vitest'
|
||||
import { _testReset } from '../lib/auth.svelte'
|
||||
|
||||
let locationHash = ''
|
||||
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
get hash() { return locationHash },
|
||||
set hash(value: string) {
|
||||
locationHash = value.startsWith('#') ? value : `#${value}`
|
||||
},
|
||||
href: 'http://localhost:3000/',
|
||||
origin: 'http://localhost:3000',
|
||||
pathname: '/',
|
||||
search: '',
|
||||
assign: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
reload: vi.fn(),
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
locationHash = ''
|
||||
_testReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { render, type RenderResult } from '@testing-library/svelte'
|
||||
import { tick } from 'svelte'
|
||||
import type { ComponentType } from 'svelte'
|
||||
|
||||
export async function renderAndWait<T extends ComponentType>(
|
||||
component: T,
|
||||
options?: Parameters<typeof render>[1]
|
||||
): Promise<RenderResult<T>> {
|
||||
const result = render(component, options)
|
||||
await tick()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
return result
|
||||
}
|
||||
|
||||
export async function waitForElement(
|
||||
queryFn: () => HTMLElement | null,
|
||||
timeout = 1000
|
||||
): Promise<HTMLElement> {
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeout) {
|
||||
const element = queryFn()
|
||||
if (element) return element
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
throw new Error('Element not found within timeout')
|
||||
}
|
||||
|
||||
export async function waitForElementToDisappear(
|
||||
queryFn: () => HTMLElement | null,
|
||||
timeout = 1000
|
||||
): Promise<void> {
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeout) {
|
||||
const element = queryFn()
|
||||
if (!element) return
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
throw new Error('Element still present after timeout')
|
||||
}
|
||||
|
||||
export async function waitForText(
|
||||
container: HTMLElement,
|
||||
text: string | RegExp,
|
||||
timeout = 1000
|
||||
): Promise<void> {
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeout) {
|
||||
const content = container.textContent || ''
|
||||
if (typeof text === 'string' ? content.includes(text) : text.test(content)) {
|
||||
return
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
throw new Error(`Text "${text}" not found within timeout`)
|
||||
}
|
||||
|
||||
export function mockLocalStorage(initialData: Record<string, string> = {}): void {
|
||||
const store: Record<string, string> = { ...initialData }
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: {
|
||||
getItem: (key: string) => store[key] || null,
|
||||
setItem: (key: string, value: string) => { store[key] = value },
|
||||
removeItem: (key: string) => { delete store[key] },
|
||||
clear: () => { Object.keys(store).forEach(key => delete store[key]) },
|
||||
key: (index: number) => Object.keys(store)[index] || null,
|
||||
get length() { return Object.keys(store).length },
|
||||
},
|
||||
writable: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function setAuthState(session: {
|
||||
did: string
|
||||
handle: string
|
||||
email?: string
|
||||
emailConfirmed?: boolean
|
||||
accessJwt: string
|
||||
refreshJwt: string
|
||||
}): void {
|
||||
localStorage.setItem('session', JSON.stringify(session))
|
||||
}
|
||||
|
||||
export function clearAuthState(): void {
|
||||
localStorage.removeItem('session')
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
|
||||
|
||||
const isTest = process.env.VITEST === 'true' || process.env.VITEST === true
|
||||
|
||||
export default {
|
||||
preprocess: isTest ? [] : vitePreprocess(),
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/xrpc': 'http://localhost:3000',
|
||||
'/oauth': 'http://localhost:3000',
|
||||
'/.well-known': 'http://localhost:3000',
|
||||
'/health': 'http://localhost:3000',
|
||||
'/u': 'http://localhost:3000',
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
svelte({
|
||||
hot: false,
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
conditions: ['browser', 'development'],
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./src/tests/setup.ts'],
|
||||
include: ['src/**/*.{test,spec}.{js,ts}'],
|
||||
alias: {
|
||||
'svelte': 'svelte',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -77,3 +77,32 @@ docker-logs:
|
||||
|
||||
docker-build:
|
||||
docker compose build
|
||||
|
||||
# Frontend commands (Deno)
|
||||
frontend-dev:
|
||||
. ~/.deno/env && cd frontend && deno task dev
|
||||
|
||||
frontend-build:
|
||||
. ~/.deno/env && cd frontend && deno task build
|
||||
|
||||
frontend-clean:
|
||||
rm -rf frontend/dist frontend/node_modules
|
||||
|
||||
# Frontend tests
|
||||
frontend-test *args:
|
||||
. ~/.deno/env && cd frontend && VITEST=true deno task test:run {{args}}
|
||||
|
||||
frontend-test-watch:
|
||||
. ~/.deno/env && cd frontend && VITEST=true deno task test:watch
|
||||
|
||||
frontend-test-ui:
|
||||
. ~/.deno/env && cd frontend && VITEST=true deno task test:ui
|
||||
|
||||
frontend-test-coverage:
|
||||
. ~/.deno/env && cd frontend && VITEST=true deno task test:run --coverage
|
||||
|
||||
# Build all (frontend + backend)
|
||||
build-all: frontend-build build
|
||||
|
||||
# Test all (backend + frontend)
|
||||
test-all: test frontend-test
|
||||
|
||||
+61
-3
@@ -6,13 +6,15 @@ CREATE TYPE notification_type AS ENUM (
|
||||
'password_reset',
|
||||
'email_update',
|
||||
'account_deletion',
|
||||
'admin_email'
|
||||
'admin_email',
|
||||
'plc_operation',
|
||||
'two_factor_code'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
handle TEXT NOT NULL UNIQUE,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
email TEXT UNIQUE,
|
||||
did TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
@@ -29,11 +31,26 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
|
||||
email_pending_verification TEXT,
|
||||
email_confirmation_code TEXT,
|
||||
email_confirmation_code_expires_at TIMESTAMPTZ
|
||||
email_confirmation_code_expires_at TIMESTAMPTZ,
|
||||
email_confirmed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
two_factor_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
discord_id TEXT,
|
||||
discord_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
telegram_username TEXT,
|
||||
telegram_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
signal_number TEXT,
|
||||
signal_verified BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_password_reset_code ON users(password_reset_code) WHERE password_reset_code IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email_confirmation_code ON users(email_confirmation_code) WHERE email_confirmation_code IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_users_discord_id ON users(discord_id) WHERE discord_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_users_telegram_username ON users(telegram_username) WHERE telegram_username IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_users_signal_number ON users(signal_number) WHERE signal_number IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS invite_codes (
|
||||
code TEXT PRIMARY KEY,
|
||||
@@ -62,6 +79,7 @@ CREATE TABLE IF NOT EXISTS user_keys (
|
||||
CREATE TABLE IF NOT EXISTS repos (
|
||||
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
repo_root_cid TEXT NOT NULL,
|
||||
repo_rev TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
@@ -79,10 +97,13 @@ CREATE TABLE IF NOT EXISTS records (
|
||||
rkey TEXT NOT NULL,
|
||||
record_cid TEXT NOT NULL,
|
||||
takedown_ref TEXT,
|
||||
repo_rev TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(repo_id, collection, rkey)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_records_repo_rev ON records(repo_rev);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS blobs (
|
||||
cid TEXT PRIMARY KEY,
|
||||
mime_type TEXT NOT NULL,
|
||||
@@ -265,3 +286,40 @@ CREATE TABLE oauth_dpop_jti (
|
||||
);
|
||||
|
||||
CREATE INDEX idx_oauth_dpop_jti_created_at ON oauth_dpop_jti(created_at);
|
||||
|
||||
CREATE TABLE plc_operation_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_plc_op_tokens_user ON plc_operation_tokens(user_id);
|
||||
CREATE INDEX idx_plc_op_tokens_expires ON plc_operation_tokens(expires_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS account_preferences (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
value_json JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(user_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_account_preferences_user_id ON account_preferences(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_account_preferences_name ON account_preferences(name);
|
||||
|
||||
CREATE TABLE oauth_2fa_challenge (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
|
||||
request_uri TEXT NOT NULL,
|
||||
code TEXT NOT NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '10 minutes'
|
||||
);
|
||||
|
||||
CREATE INDEX idx_oauth_2fa_challenge_request_uri ON oauth_2fa_challenge(request_uri);
|
||||
CREATE INDEX idx_oauth_2fa_challenge_expires ON oauth_2fa_challenge(expires_at);
|
||||
@@ -1,10 +0,0 @@
|
||||
CREATE TABLE plc_operation_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_plc_op_tokens_user ON plc_operation_tokens(user_id);
|
||||
CREATE INDEX idx_plc_op_tokens_expires ON plc_operation_tokens(expires_at);
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TYPE notification_type ADD VALUE 'plc_operation';
|
||||
@@ -1,12 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS account_preferences (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
value_json JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE(user_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_account_preferences_user_id ON account_preferences(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_account_preferences_name ON account_preferences(name);
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE records ADD COLUMN repo_rev TEXT;
|
||||
CREATE INDEX idx_records_repo_rev ON records(repo_rev);
|
||||
@@ -1,16 +0,0 @@
|
||||
ALTER TABLE users ADD COLUMN two_factor_enabled BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
ALTER TYPE notification_type ADD VALUE 'two_factor_code';
|
||||
|
||||
CREATE TABLE oauth_2fa_challenge (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
did TEXT NOT NULL REFERENCES users(did) ON DELETE CASCADE,
|
||||
request_uri TEXT NOT NULL,
|
||||
code TEXT NOT NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '10 minutes'
|
||||
);
|
||||
|
||||
CREATE INDEX idx_oauth_2fa_challenge_request_uri ON oauth_2fa_challenge(request_uri);
|
||||
CREATE INDEX idx_oauth_2fa_challenge_expires ON oauth_2fa_challenge(expires_at);
|
||||
@@ -65,7 +65,19 @@ pub async fn send_email(
|
||||
.await;
|
||||
|
||||
let (user_id, email, handle) = match user {
|
||||
Ok(Some(row)) => (row.id, row.email, row.handle),
|
||||
Ok(Some(row)) => {
|
||||
let email = match row.email {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "NoEmail", "message": "Recipient has no email address"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
(row.id, email, row.handle)
|
||||
}
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
|
||||
@@ -74,7 +74,7 @@ pub async fn get_account_info(
|
||||
Json(AccountInfo {
|
||||
did: row.did,
|
||||
handle: row.handle,
|
||||
email: Some(row.email),
|
||||
email: row.email,
|
||||
indexed_at: row.created_at.to_rfc3339(),
|
||||
invite_note: None,
|
||||
invites_disabled: false,
|
||||
@@ -150,7 +150,7 @@ pub async fn get_account_infos(
|
||||
infos.push(AccountInfo {
|
||||
did: row.did,
|
||||
handle: row.handle,
|
||||
email: Some(row.email),
|
||||
email: row.email,
|
||||
indexed_at: row.created_at.to_rfc3339(),
|
||||
invite_note: None,
|
||||
invites_disabled: false,
|
||||
|
||||
+94
-68
@@ -36,20 +36,24 @@ fn extract_client_ip(headers: &HeaderMap) -> String {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateAccountInput {
|
||||
pub handle: String,
|
||||
pub email: String,
|
||||
pub email: Option<String>,
|
||||
pub password: String,
|
||||
pub invite_code: Option<String>,
|
||||
pub did: Option<String>,
|
||||
pub signing_key: Option<String>,
|
||||
pub verification_channel: Option<String>,
|
||||
pub discord_id: Option<String>,
|
||||
pub telegram_username: Option<String>,
|
||||
pub signal_number: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateAccountOutput {
|
||||
pub access_jwt: String,
|
||||
pub refresh_jwt: String,
|
||||
pub handle: String,
|
||||
pub did: String,
|
||||
pub verification_required: bool,
|
||||
pub verification_channel: String,
|
||||
}
|
||||
|
||||
pub async fn create_account(
|
||||
@@ -82,12 +86,17 @@ pub async fn create_account(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if !crate::api::validation::is_valid_email(&input.email) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})),
|
||||
)
|
||||
.into_response();
|
||||
let email: Option<String> = input.email.as_ref()
|
||||
.map(|e| e.trim().to_string())
|
||||
.filter(|e| !e.is_empty());
|
||||
if let Some(ref email) = email {
|
||||
if !crate::api::validation::is_valid_email(email) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let did = if let Some(d) = &input.did {
|
||||
@@ -202,18 +211,77 @@ pub async fn create_account(
|
||||
}
|
||||
};
|
||||
|
||||
let user_insert = sqlx::query!(
|
||||
"INSERT INTO users (handle, email, did, password_hash) VALUES ($1, $2, $3, $4) RETURNING id",
|
||||
input.handle,
|
||||
input.email,
|
||||
did,
|
||||
password_hash
|
||||
let verification_channel = input.verification_channel.as_deref().unwrap_or("email");
|
||||
let valid_channels = ["email", "discord", "telegram", "signal"];
|
||||
if !valid_channels.contains(&verification_channel) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidVerificationChannel", "message": "Invalid verification channel. Must be one of: email, discord, telegram, signal"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let verification_recipient = match verification_channel {
|
||||
"email" => match &input.email {
|
||||
Some(email) if !email.trim().is_empty() => email.trim().to_string(),
|
||||
_ => return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "MissingEmail", "message": "Email is required when using email verification"})),
|
||||
).into_response(),
|
||||
},
|
||||
"discord" => match &input.discord_id {
|
||||
Some(id) if !id.trim().is_empty() => id.trim().to_string(),
|
||||
_ => return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "MissingDiscordId", "message": "Discord ID is required when using Discord verification"})),
|
||||
).into_response(),
|
||||
},
|
||||
"telegram" => match &input.telegram_username {
|
||||
Some(username) if !username.trim().is_empty() => username.trim().to_string(),
|
||||
_ => return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "MissingTelegramUsername", "message": "Telegram username is required when using Telegram verification"})),
|
||||
).into_response(),
|
||||
},
|
||||
"signal" => match &input.signal_number {
|
||||
Some(number) if !number.trim().is_empty() => number.trim().to_string(),
|
||||
_ => return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "MissingSignalNumber", "message": "Signal phone number is required when using Signal verification"})),
|
||||
).into_response(),
|
||||
},
|
||||
_ => return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidVerificationChannel", "message": "Invalid verification channel"})),
|
||||
).into_response(),
|
||||
};
|
||||
|
||||
let verification_code = format!("{:06}", rand::random::<u32>() % 1_000_000);
|
||||
let code_expires_at = chrono::Utc::now() + chrono::Duration::minutes(30);
|
||||
|
||||
let user_insert: Result<(uuid::Uuid,), _> = sqlx::query_as(
|
||||
r#"INSERT INTO users (
|
||||
handle, email, did, password_hash,
|
||||
email_confirmation_code, email_confirmation_code_expires_at,
|
||||
preferred_notification_channel,
|
||||
discord_id, telegram_username, signal_number
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7::notification_channel, $8, $9, $10) RETURNING id"#,
|
||||
)
|
||||
.bind(&input.handle)
|
||||
.bind(&email)
|
||||
.bind(&did)
|
||||
.bind(&password_hash)
|
||||
.bind(&verification_code)
|
||||
.bind(&code_expires_at)
|
||||
.bind(verification_channel)
|
||||
.bind(input.discord_id.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty()))
|
||||
.bind(input.telegram_username.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty()))
|
||||
.bind(input.signal_number.as_deref().map(|s| s.trim()).filter(|s| !s.is_empty()))
|
||||
.fetch_one(&mut *tx)
|
||||
.await;
|
||||
|
||||
let user_id = match user_insert {
|
||||
Ok(row) => row.id,
|
||||
Ok((id,)) => id,
|
||||
Err(e) => {
|
||||
if let Some(db_err) = e.as_database_error() {
|
||||
if db_err.code().as_deref() == Some("23505") {
|
||||
@@ -453,53 +521,6 @@ pub async fn create_account(
|
||||
}
|
||||
}
|
||||
|
||||
let access_meta = crate::auth::create_access_token_with_metadata(&did, &secret_key_bytes[..]).map_err(|e| {
|
||||
error!("Error creating access token: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
});
|
||||
let access_meta = match access_meta {
|
||||
Ok(m) => m,
|
||||
Err(r) => return r,
|
||||
};
|
||||
|
||||
let refresh_meta = crate::auth::create_refresh_token_with_metadata(&did, &secret_key_bytes[..]).map_err(|e| {
|
||||
error!("Error creating refresh token: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
});
|
||||
let refresh_meta = match refresh_meta {
|
||||
Ok(m) => m,
|
||||
Err(r) => return r,
|
||||
};
|
||||
|
||||
let session_insert =
|
||||
sqlx::query!(
|
||||
"INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)",
|
||||
did,
|
||||
access_meta.jti,
|
||||
refresh_meta.jti,
|
||||
access_meta.expires_at,
|
||||
refresh_meta.expires_at
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await;
|
||||
|
||||
if let Err(e) = session_insert {
|
||||
error!("Error inserting session: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Err(e) = tx.commit().await {
|
||||
error!("Error committing transaction: {:?}", e);
|
||||
return (
|
||||
@@ -509,18 +530,23 @@ pub async fn create_account(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
if let Err(e) = crate::notifications::enqueue_welcome(&state.db, user_id, &hostname).await {
|
||||
warn!("Failed to enqueue welcome notification: {:?}", e);
|
||||
if let Err(e) = crate::notifications::enqueue_signup_verification(
|
||||
&state.db,
|
||||
user_id,
|
||||
verification_channel,
|
||||
&verification_recipient,
|
||||
&verification_code,
|
||||
).await {
|
||||
warn!("Failed to enqueue signup verification notification: {:?}", e);
|
||||
}
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
Json(CreateAccountOutput {
|
||||
access_jwt: access_meta.token,
|
||||
refresh_jwt: refresh_meta.token,
|
||||
handle: input.handle,
|
||||
did,
|
||||
verification_required: true,
|
||||
verification_channel: verification_channel.to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod feed;
|
||||
pub mod identity;
|
||||
pub mod moderation;
|
||||
pub mod notification;
|
||||
pub mod notification_prefs;
|
||||
pub mod proxy;
|
||||
pub mod proxy_client;
|
||||
pub mod read_after_write;
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use sqlx::Row;
|
||||
use tracing::info;
|
||||
|
||||
use crate::auth::validate_bearer_token;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NotificationPrefsResponse {
|
||||
pub preferred_channel: String,
|
||||
pub email: String,
|
||||
pub discord_id: Option<String>,
|
||||
pub discord_verified: bool,
|
||||
pub telegram_username: Option<String>,
|
||||
pub telegram_verified: bool,
|
||||
pub signal_number: Option<String>,
|
||||
pub signal_verified: bool,
|
||||
}
|
||||
|
||||
pub async fn get_notification_prefs(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
let token = match crate::auth::extract_bearer_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationRequired", "message": "Authentication required"})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let user = match validate_bearer_token(&state.db, &token).await {
|
||||
Ok(u) => u,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token"})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let row = match sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
email,
|
||||
preferred_notification_channel::text as channel,
|
||||
discord_id,
|
||||
discord_verified,
|
||||
telegram_username,
|
||||
telegram_verified,
|
||||
signal_number,
|
||||
signal_verified
|
||||
FROM users
|
||||
WHERE did = $1
|
||||
"#
|
||||
)
|
||||
.bind(&user.did)
|
||||
.fetch_one(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let email: String = row.get("email");
|
||||
let channel: String = row.get("channel");
|
||||
let discord_id: Option<String> = row.get("discord_id");
|
||||
let discord_verified: bool = row.get("discord_verified");
|
||||
let telegram_username: Option<String> = row.get("telegram_username");
|
||||
let telegram_verified: bool = row.get("telegram_verified");
|
||||
let signal_number: Option<String> = row.get("signal_number");
|
||||
let signal_verified: bool = row.get("signal_verified");
|
||||
|
||||
Json(NotificationPrefsResponse {
|
||||
preferred_channel: channel,
|
||||
email,
|
||||
discord_id,
|
||||
discord_verified,
|
||||
telegram_username,
|
||||
telegram_verified,
|
||||
signal_number,
|
||||
signal_verified,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateNotificationPrefsInput {
|
||||
pub preferred_channel: Option<String>,
|
||||
pub discord_id: Option<String>,
|
||||
pub telegram_username: Option<String>,
|
||||
pub signal_number: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn update_notification_prefs(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(input): Json<UpdateNotificationPrefsInput>,
|
||||
) -> Response {
|
||||
let token = match crate::auth::extract_bearer_token_from_header(
|
||||
headers.get("Authorization").and_then(|h| h.to_str().ok()),
|
||||
) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationRequired", "message": "Authentication required"})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let user = match validate_bearer_token(&state.db, &token).await {
|
||||
Ok(u) => u,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": "AuthenticationFailed", "message": "Invalid token"})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(ref channel) = input.preferred_channel {
|
||||
let valid_channels = ["email", "discord", "telegram", "signal"];
|
||||
if !valid_channels.contains(&channel.as_str()) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidRequest",
|
||||
"message": "Invalid channel. Must be one of: email, discord, telegram, signal"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Err(e) = sqlx::query(
|
||||
r#"UPDATE users SET preferred_notification_channel = $1::notification_channel, updated_at = NOW() WHERE did = $2"#
|
||||
)
|
||||
.bind(channel)
|
||||
.bind(&user.did)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
info!(did = %user.did, channel = %channel, "Updated preferred notification channel");
|
||||
}
|
||||
|
||||
if let Some(ref discord_id) = input.discord_id {
|
||||
let discord_id_clean: Option<&str> = if discord_id.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(discord_id.as_str())
|
||||
};
|
||||
|
||||
if let Err(e) = sqlx::query(
|
||||
r#"UPDATE users SET discord_id = $1, discord_verified = FALSE, updated_at = NOW() WHERE did = $2"#
|
||||
)
|
||||
.bind(discord_id_clean)
|
||||
.bind(&user.did)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
info!(did = %user.did, "Updated Discord ID");
|
||||
}
|
||||
|
||||
if let Some(ref telegram) = input.telegram_username {
|
||||
let telegram_clean: Option<&str> = if telegram.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(telegram.trim_start_matches('@'))
|
||||
};
|
||||
|
||||
if let Err(e) = sqlx::query(
|
||||
r#"UPDATE users SET telegram_username = $1, telegram_verified = FALSE, updated_at = NOW() WHERE did = $2"#
|
||||
)
|
||||
.bind(telegram_clean)
|
||||
.bind(&user.did)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
info!(did = %user.did, "Updated Telegram username");
|
||||
}
|
||||
|
||||
if let Some(ref signal) = input.signal_number {
|
||||
let signal_clean: Option<&str> = if signal.is_empty() { None } else { Some(signal.as_str()) };
|
||||
|
||||
if let Err(e) = sqlx::query(
|
||||
r#"UPDATE users SET signal_number = $1, signal_verified = FALSE, updated_at = NOW() WHERE did = $2"#
|
||||
)
|
||||
.bind(signal_clean)
|
||||
.bind(&user.did)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
info!(did = %user.did, "Updated Signal number");
|
||||
}
|
||||
|
||||
Json(json!({"success": true})).into_response()
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::validation::validate_record;
|
||||
use super::write::has_verified_notification_channel;
|
||||
use crate::api::repo::record::utils::{commit_and_log, RecordOp};
|
||||
use crate::repo::tracking::TrackingBlockStore;
|
||||
use crate::state::AppState;
|
||||
@@ -110,6 +111,28 @@ pub async fn apply_writes(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
match has_verified_notification_channel(&state.db, &did).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"error": "AccountNotVerified",
|
||||
"message": "You must verify at least one notification channel (email, Discord, Telegram, or Signal) before creating records"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error checking notification channels: {}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
if input.writes.is_empty() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
|
||||
@@ -14,11 +14,40 @@ use jacquard::types::string::Nsid;
|
||||
use jacquard_repo::{commit::Commit, mst::Mst, storage::BlockStore};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use sqlx::{PgPool, Row};
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use tracing::error;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub async fn has_verified_notification_channel(db: &PgPool, did: &str) -> Result<bool, sqlx::Error> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
email_confirmed,
|
||||
discord_verified,
|
||||
telegram_verified,
|
||||
signal_verified
|
||||
FROM users
|
||||
WHERE did = $1
|
||||
"#
|
||||
)
|
||||
.bind(did)
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
|
||||
match row {
|
||||
Some(r) => {
|
||||
let email_confirmed: bool = r.get("email_confirmed");
|
||||
let discord_verified: bool = r.get("discord_verified");
|
||||
let telegram_verified: bool = r.get("telegram_verified");
|
||||
let signal_verified: bool = r.get("signal_verified");
|
||||
Ok(email_confirmed || discord_verified || telegram_verified || signal_verified)
|
||||
}
|
||||
None => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn prepare_repo_write(
|
||||
state: &AppState,
|
||||
headers: &HeaderMap,
|
||||
@@ -52,6 +81,28 @@ pub async fn prepare_repo_write(
|
||||
.into_response());
|
||||
}
|
||||
|
||||
match has_verified_notification_channel(&state.db, &auth_user.did).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => {
|
||||
return Err((
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"error": "AccountNotVerified",
|
||||
"message": "You must verify at least one notification channel (email, Discord, Telegram, or Signal) before creating records"
|
||||
})),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error checking notification channels: {}", e);
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
}
|
||||
|
||||
let user_id = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", auth_user.did)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
|
||||
@@ -343,8 +343,10 @@ pub async fn update_email(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if new_email == current_email.to_lowercase() {
|
||||
return (StatusCode::OK, Json(json!({}))).into_response();
|
||||
if let Some(ref current) = current_email {
|
||||
if new_email == current.to_lowercase() {
|
||||
return (StatusCode::OK, Json(json!({}))).into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let email_confirmed = stored_code.is_some() && email_pending_verification.is_some();
|
||||
|
||||
@@ -13,12 +13,19 @@ pub async fn robots_txt() -> impl IntoResponse {
|
||||
}
|
||||
|
||||
pub async fn describe_server() -> impl IntoResponse {
|
||||
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let domains_str =
|
||||
std::env::var("AVAILABLE_USER_DOMAINS").unwrap_or_else(|_| "example.com".to_string());
|
||||
std::env::var("AVAILABLE_USER_DOMAINS").unwrap_or_else(|_| pds_hostname.clone());
|
||||
let domains: Vec<&str> = domains_str.split(',').map(|s| s.trim()).collect();
|
||||
|
||||
let invite_code_required = std::env::var("INVITE_CODE_REQUIRED")
|
||||
.map(|v| v == "true" || v == "1")
|
||||
.unwrap_or(false);
|
||||
|
||||
Json(json!({
|
||||
"availableUserDomains": domains
|
||||
"availableUserDomains": domains,
|
||||
"inviteCodeRequired": invite_code_required,
|
||||
"did": format!("did:web:{}", pds_hostname)
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -18,5 +18,5 @@ pub use invite::{create_invite_code, create_invite_codes, get_account_invite_cod
|
||||
pub use meta::{describe_server, health, robots_txt};
|
||||
pub use password::{request_password_reset, reset_password};
|
||||
pub use service_auth::get_service_auth;
|
||||
pub use session::{create_session, delete_session, get_session, refresh_session};
|
||||
pub use session::{confirm_signup, create_session, delete_session, get_session, refresh_session, resend_verification};
|
||||
pub use signing_key::reserve_signing_key;
|
||||
|
||||
+252
-1
@@ -8,6 +8,7 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use bcrypt::verify;
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tracing::{error, info, warn};
|
||||
@@ -64,7 +65,13 @@ pub async fn create_session(
|
||||
}
|
||||
|
||||
let row = match sqlx::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",
|
||||
r#"SELECT
|
||||
u.id, u.did, u.handle, u.password_hash,
|
||||
u.email_confirmed, u.discord_verified, u.telegram_verified, u.signal_verified,
|
||||
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"#,
|
||||
input.identifier
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
@@ -103,6 +110,23 @@ pub async fn create_session(
|
||||
return ApiError::AuthenticationFailedMsg("Invalid identifier or password".into()).into_response();
|
||||
}
|
||||
|
||||
let is_verified = row.email_confirmed
|
||||
|| row.discord_verified
|
||||
|| row.telegram_verified
|
||||
|| row.signal_verified;
|
||||
|
||||
if !is_verified {
|
||||
warn!("Login attempt for unverified account: {}", row.did);
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"error": "AccountNotVerified",
|
||||
"message": "Please verify your account before logging in",
|
||||
"did": row.did
|
||||
})),
|
||||
).into_response();
|
||||
}
|
||||
|
||||
let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
@@ -361,3 +385,230 @@ pub async fn refresh_session(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConfirmSignupInput {
|
||||
pub did: String,
|
||||
pub verification_code: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConfirmSignupOutput {
|
||||
pub access_jwt: String,
|
||||
pub refresh_jwt: String,
|
||||
pub handle: String,
|
||||
pub did: String,
|
||||
}
|
||||
|
||||
pub async fn confirm_signup(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<ConfirmSignupInput>,
|
||||
) -> Response {
|
||||
info!("confirm_signup called for DID: {}", input.did);
|
||||
|
||||
let row = match sqlx::query!(
|
||||
r#"SELECT
|
||||
u.id, u.did, u.handle,
|
||||
u.email_confirmation_code,
|
||||
u.email_confirmation_code_expires_at,
|
||||
u.preferred_notification_channel as "channel: crate::notifications::NotificationChannel",
|
||||
k.key_bytes, k.encryption_version
|
||||
FROM users u
|
||||
JOIN user_keys k ON u.id = k.user_id
|
||||
WHERE u.did = $1"#,
|
||||
input.did
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(row)) => row,
|
||||
Ok(None) => {
|
||||
warn!("User not found for confirm_signup: {}", input.did);
|
||||
return ApiError::InvalidRequest("Invalid DID or verification code".into()).into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Database error in confirm_signup: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let stored_code = match &row.email_confirmation_code {
|
||||
Some(code) => code,
|
||||
None => {
|
||||
warn!("No verification code found for user: {}", input.did);
|
||||
return ApiError::InvalidRequest("No pending verification".into()).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if stored_code != &input.verification_code {
|
||||
warn!("Invalid verification code for user: {}", input.did);
|
||||
return ApiError::InvalidRequest("Invalid verification code".into()).into_response();
|
||||
}
|
||||
|
||||
if let Some(expires_at) = row.email_confirmation_code_expires_at {
|
||||
if expires_at < Utc::now() {
|
||||
warn!("Verification code expired for user: {}", input.did);
|
||||
return ApiError::ExpiredTokenMsg("Verification code has expired".into()).into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let key_bytes = match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
|
||||
Ok(k) => k,
|
||||
Err(e) => {
|
||||
error!("Failed to decrypt user key: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let verified_column = match row.channel {
|
||||
crate::notifications::NotificationChannel::Email => "email_confirmed",
|
||||
crate::notifications::NotificationChannel::Discord => "discord_verified",
|
||||
crate::notifications::NotificationChannel::Telegram => "telegram_verified",
|
||||
crate::notifications::NotificationChannel::Signal => "signal_verified",
|
||||
};
|
||||
|
||||
let update_query = format!(
|
||||
"UPDATE users SET {} = TRUE, email_confirmation_code = NULL, email_confirmation_code_expires_at = NULL WHERE did = $1",
|
||||
verified_column
|
||||
);
|
||||
|
||||
if let Err(e) = sqlx::query(&update_query)
|
||||
.bind(&input.did)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
error!("Failed to update verification status: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
}
|
||||
|
||||
let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
error!("Failed to create access token: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let refresh_meta = match crate::auth::create_refresh_token_with_metadata(&row.did, &key_bytes) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
error!("Failed to create refresh token: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = sqlx::query!(
|
||||
"INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)",
|
||||
row.did,
|
||||
access_meta.jti,
|
||||
refresh_meta.jti,
|
||||
access_meta.expires_at,
|
||||
refresh_meta.expires_at
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
error!("Failed to insert session: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
}
|
||||
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
if let Err(e) = crate::notifications::enqueue_welcome(&state.db, row.id, &hostname).await {
|
||||
warn!("Failed to enqueue welcome notification: {:?}", e);
|
||||
}
|
||||
|
||||
Json(ConfirmSignupOutput {
|
||||
access_jwt: access_meta.token,
|
||||
refresh_jwt: refresh_meta.token,
|
||||
handle: row.handle,
|
||||
did: row.did,
|
||||
}).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ResendVerificationInput {
|
||||
pub did: String,
|
||||
}
|
||||
|
||||
pub async fn resend_verification(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<ResendVerificationInput>,
|
||||
) -> Response {
|
||||
info!("resend_verification called for DID: {}", input.did);
|
||||
|
||||
let row = match sqlx::query!(
|
||||
r#"SELECT
|
||||
id, handle, email,
|
||||
preferred_notification_channel as "channel: crate::notifications::NotificationChannel",
|
||||
discord_id, telegram_username, signal_number,
|
||||
email_confirmed, discord_verified, telegram_verified, signal_verified
|
||||
FROM users
|
||||
WHERE did = $1"#,
|
||||
input.did
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(row)) => row,
|
||||
Ok(None) => {
|
||||
return ApiError::InvalidRequest("User not found".into()).into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Database error in resend_verification: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let is_verified = row.email_confirmed
|
||||
|| row.discord_verified
|
||||
|| row.telegram_verified
|
||||
|| row.signal_verified;
|
||||
|
||||
if is_verified {
|
||||
return ApiError::InvalidRequest("Account is already verified".into()).into_response();
|
||||
}
|
||||
|
||||
let verification_code = format!("{:06}", rand::random::<u32>() % 1_000_000);
|
||||
let code_expires_at = Utc::now() + chrono::Duration::minutes(30);
|
||||
|
||||
if let Err(e) = sqlx::query!(
|
||||
"UPDATE users SET email_confirmation_code = $1, email_confirmation_code_expires_at = $2 WHERE did = $3",
|
||||
verification_code,
|
||||
code_expires_at,
|
||||
input.did
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
error!("Failed to update verification code: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
}
|
||||
|
||||
let (channel_str, recipient) = match row.channel {
|
||||
crate::notifications::NotificationChannel::Email => ("email", row.email.clone().unwrap_or_default()),
|
||||
crate::notifications::NotificationChannel::Discord => {
|
||||
("discord", row.discord_id.unwrap_or_default())
|
||||
}
|
||||
crate::notifications::NotificationChannel::Telegram => {
|
||||
("telegram", row.telegram_username.unwrap_or_default())
|
||||
}
|
||||
crate::notifications::NotificationChannel::Signal => {
|
||||
("signal", row.signal_number.unwrap_or_default())
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = crate::notifications::enqueue_signup_verification(
|
||||
&state.db,
|
||||
row.id,
|
||||
channel_str,
|
||||
&recipient,
|
||||
&verification_code,
|
||||
).await {
|
||||
warn!("Failed to enqueue verification notification: {:?}", e);
|
||||
}
|
||||
|
||||
Json(json!({"success": true})).into_response()
|
||||
}
|
||||
|
||||
+5
-1
@@ -106,9 +106,13 @@ impl Crawlers {
|
||||
cb.record_success().await;
|
||||
}
|
||||
} else {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
warn!(
|
||||
crawler = %url,
|
||||
status = %response.status(),
|
||||
status = %status,
|
||||
body = %body,
|
||||
hostname = %hostname,
|
||||
"Crawler notification returned non-success status"
|
||||
);
|
||||
if let Some(cb) = cb {
|
||||
|
||||
+31
-2
@@ -21,9 +21,10 @@ use axum::{
|
||||
routing::{any, get, post},
|
||||
};
|
||||
use state::AppState;
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
|
||||
pub fn app(state: AppState) -> Router {
|
||||
Router::new()
|
||||
let router = Router::new()
|
||||
.route("/health", get(api::server::health))
|
||||
.route("/xrpc/_health", get(api::server::health))
|
||||
.route("/robots.txt", get(api::server::robots_txt))
|
||||
@@ -51,6 +52,14 @@ pub fn app(state: AppState) -> Router {
|
||||
"/xrpc/com.atproto.server.refreshSession",
|
||||
post(api::server::refresh_session),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.atproto.server.confirmSignup",
|
||||
post(api::server::confirm_signup),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.atproto.server.resendVerification",
|
||||
post(api::server::resend_verification),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.atproto.server.getServiceAuth",
|
||||
get(api::server::get_service_auth),
|
||||
@@ -364,6 +373,26 @@ pub fn app(state: AppState) -> Router {
|
||||
"/xrpc/com.atproto.temp.checkSignupQueue",
|
||||
get(api::temp::check_signup_queue),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.bspds.account.getNotificationPrefs",
|
||||
get(api::notification_prefs::get_notification_prefs),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.bspds.account.updateNotificationPrefs",
|
||||
post(api::notification_prefs::update_notification_prefs),
|
||||
)
|
||||
.route("/xrpc/{*method}", any(api::proxy::proxy_handler))
|
||||
.with_state(state)
|
||||
.with_state(state);
|
||||
|
||||
let frontend_dir = std::env::var("FRONTEND_DIR")
|
||||
.unwrap_or_else(|_| "./frontend/dist".to_string());
|
||||
|
||||
if std::path::Path::new(&frontend_dir).join("index.html").exists() {
|
||||
let index_path = format!("{}/index.html", frontend_dir);
|
||||
let serve_dir = ServeDir::new(&frontend_dir)
|
||||
.not_found_service(ServeFile::new(index_path));
|
||||
router.fallback_service(serve_dir)
|
||||
} else {
|
||||
router
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ pub use sender::{
|
||||
pub use service::{
|
||||
channel_display_name, enqueue_2fa_code, enqueue_account_deletion, enqueue_email_update,
|
||||
enqueue_email_verification, enqueue_notification, enqueue_password_reset,
|
||||
enqueue_plc_operation, enqueue_welcome, NotificationService,
|
||||
enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome, NotificationService,
|
||||
};
|
||||
pub use types::{
|
||||
NewNotification, NotificationChannel, NotificationStatus, NotificationType, QueuedNotification,
|
||||
|
||||
@@ -256,7 +256,7 @@ pub async fn enqueue_notification(db: &PgPool, notification: NewNotification) ->
|
||||
|
||||
pub struct UserNotificationPrefs {
|
||||
pub channel: NotificationChannel,
|
||||
pub email: String,
|
||||
pub email: Option<String>,
|
||||
pub handle: String,
|
||||
}
|
||||
|
||||
@@ -303,7 +303,7 @@ pub async fn enqueue_welcome(
|
||||
user_id,
|
||||
prefs.channel,
|
||||
super::types::NotificationType::Welcome,
|
||||
prefs.email.clone(),
|
||||
prefs.email.clone().unwrap_or_default(),
|
||||
Some(format!("Welcome to {}", hostname)),
|
||||
body,
|
||||
),
|
||||
@@ -356,7 +356,7 @@ pub async fn enqueue_password_reset(
|
||||
user_id,
|
||||
prefs.channel,
|
||||
super::types::NotificationType::PasswordReset,
|
||||
prefs.email.clone(),
|
||||
prefs.email.clone().unwrap_or_default(),
|
||||
Some(format!("Password Reset - {}", hostname)),
|
||||
body,
|
||||
),
|
||||
@@ -409,7 +409,7 @@ pub async fn enqueue_account_deletion(
|
||||
user_id,
|
||||
prefs.channel,
|
||||
super::types::NotificationType::AccountDeletion,
|
||||
prefs.email.clone(),
|
||||
prefs.email.clone().unwrap_or_default(),
|
||||
Some(format!("Account Deletion Request - {}", hostname)),
|
||||
body,
|
||||
),
|
||||
@@ -436,7 +436,7 @@ pub async fn enqueue_plc_operation(
|
||||
user_id,
|
||||
prefs.channel,
|
||||
super::types::NotificationType::PlcOperation,
|
||||
prefs.email.clone(),
|
||||
prefs.email.clone().unwrap_or_default(),
|
||||
Some(format!("{} - PLC Operation Token", hostname)),
|
||||
body,
|
||||
),
|
||||
@@ -463,7 +463,7 @@ pub async fn enqueue_2fa_code(
|
||||
user_id,
|
||||
prefs.channel,
|
||||
super::types::NotificationType::TwoFactorCode,
|
||||
prefs.email.clone(),
|
||||
prefs.email.clone().unwrap_or_default(),
|
||||
Some(format!("Sign-in Verification - {}", hostname)),
|
||||
body,
|
||||
),
|
||||
@@ -479,3 +479,44 @@ pub fn channel_display_name(channel: NotificationChannel) -> &'static str {
|
||||
NotificationChannel::Signal => "Signal",
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn enqueue_signup_verification(
|
||||
db: &PgPool,
|
||||
user_id: Uuid,
|
||||
channel: &str,
|
||||
recipient: &str,
|
||||
code: &str,
|
||||
) -> Result<Uuid, sqlx::Error> {
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
|
||||
let notification_channel = match channel {
|
||||
"email" => NotificationChannel::Email,
|
||||
"discord" => NotificationChannel::Discord,
|
||||
"telegram" => NotificationChannel::Telegram,
|
||||
"signal" => NotificationChannel::Signal,
|
||||
_ => NotificationChannel::Email,
|
||||
};
|
||||
|
||||
let body = format!(
|
||||
"Welcome! Your account verification code is: {}\n\nThis code will expire in 30 minutes.\n\nEnter this code to complete your registration on {}.",
|
||||
code, hostname
|
||||
);
|
||||
|
||||
let subject = match notification_channel {
|
||||
NotificationChannel::Email => Some(format!("Verify your account - {}", hostname)),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
enqueue_notification(
|
||||
db,
|
||||
NewNotification::new(
|
||||
user_id,
|
||||
notification_channel,
|
||||
super::types::NotificationType::EmailVerification,
|
||||
recipient.to_string(),
|
||||
subject,
|
||||
body,
|
||||
),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use super::super::{DeviceData, OAuthError};
|
||||
pub struct DeviceAccountRow {
|
||||
pub did: String,
|
||||
pub handle: String,
|
||||
pub email: String,
|
||||
pub email: Option<String>,
|
||||
pub last_used_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
|
||||
@@ -477,7 +477,7 @@ pub fn login_page(
|
||||
pub struct DeviceAccount {
|
||||
pub did: String,
|
||||
pub handle: String,
|
||||
pub email: String,
|
||||
pub email: Option<String>,
|
||||
pub last_used_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
@@ -493,6 +493,7 @@ pub fn account_selector_page(
|
||||
.iter()
|
||||
.map(|account| {
|
||||
let initials = get_initials(&account.handle);
|
||||
let email_display = account.email.as_deref().unwrap_or("");
|
||||
format!(
|
||||
r#"<form method="POST" action="/oauth/authorize/select" style="margin:0">
|
||||
<input type="hidden" name="request_uri" value="{request_uri}">
|
||||
@@ -510,7 +511,7 @@ pub fn account_selector_page(
|
||||
did = html_escape(&account.did),
|
||||
initials = html_escape(&initials),
|
||||
handle = html_escape(&account.handle),
|
||||
email = html_escape(&account.email),
|
||||
email = html_escape(email_display),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
Reference in New Issue
Block a user