First UI idea done

This commit is contained in:
lewis
2025-12-12 23:52:52 +02:00
parent 10ba422e36
commit 7b6807c316
72 changed files with 8880 additions and 233 deletions
+4
View File
@@ -4,3 +4,7 @@
reference-pds-hailey/
reference-pds-bsky/
# Frontend build artifacts
frontend/node_modules/
frontend/dist/
@@ -21,7 +21,7 @@
},
"nullable": [
false,
false
true
]
},
"hash": "1658a90aede20695b0e6e87d2536fad5a538dbfc442625ef306272d2530ddc3a"
@@ -32,7 +32,7 @@
"nullable": [
false,
false,
false,
true,
false
]
},
@@ -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"
}
@@ -64,7 +64,7 @@
"nullable": [
false,
false,
false,
true,
false,
false,
false,
@@ -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"
}
@@ -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"
}
@@ -32,7 +32,7 @@
"nullable": [
false,
false,
false,
true,
false
]
},
@@ -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"
}
@@ -36,7 +36,7 @@
},
"nullable": [
false,
false,
true,
true,
true,
true
@@ -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"
}
@@ -37,7 +37,7 @@
]
},
"nullable": [
false,
true,
false,
false
]
@@ -32,7 +32,7 @@
"nullable": [
false,
false,
false,
true,
false
]
},
@@ -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"
}
@@ -26,7 +26,7 @@
},
"nullable": [
false,
false,
true,
false
]
},
Generated
+26
View File
@@ -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",
+1
View File
@@ -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
View File
@@ -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
+21
View File
@@ -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
```
+23 -14
View File
@@ -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)
+13
View File
@@ -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"
}
+1309
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -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>
+24
View File
@@ -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"
}
}
+129
View File
@@ -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>
+341
View File
@@ -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 },
})
},
}
+172
View File
@@ -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)
}
+13
View File
@@ -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
}
+8
View File
@@ -0,0 +1,8 @@
import App from './App.svelte'
import { mount } from 'svelte'
const app = mount(App, {
target: document.getElementById('app')!,
})
export default app
+333
View File
@@ -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">&larr; 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>
+201
View File
@@ -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>
+326
View File
@@ -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">&larr; 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>
+289
View File
@@ -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>
+453
View File
@@ -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">&larr; 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>
+523
View File
@@ -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>
+940
View File
@@ -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">&larr; 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">&rarr;</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>
+409
View File
@@ -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">&larr; 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>
+453
View File
@@ -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')
})
})
})
})
+138
View File
@@ -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()
})
})
})
})
+167
View File
@@ -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()
})
})
})
})
+443
View File
@@ -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()
})
})
})
})
+516
View File
@@ -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()
})
})
})
})
+264
View File
@@ -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,
})
}
+35
View File
@@ -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()
})
+86
View File
@@ -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')
}
+7
View File
@@ -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(),
}
+19
View File
@@ -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',
}
}
})
+22
View File
@@ -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',
},
},
})
+29
View File
@@ -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
@@ -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);
-2
View File
@@ -1,2 +0,0 @@
ALTER TABLE records ADD COLUMN repo_rev TEXT;
CREATE INDEX idx_records_repo_rev ON records(repo_rev);
-16
View File
@@ -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);
+13 -1
View File
@@ -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,
+2 -2
View File
@@ -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
View File
@@ -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()
+1
View File
@@ -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;
+248
View File
@@ -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()
}
+23
View File
@@ -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,
+51
View File
@@ -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
+4 -2
View File
@@ -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();
+9 -2
View File
@@ -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)
}))
}
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
}
+1 -1
View File
@@ -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,
+47 -6
View File
@@ -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
}
+1 -1
View File
@@ -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>,
}
+3 -2
View File
@@ -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();