mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-07-20 06:52:33 +00:00
Frontend on /app path for easy custom homepage
This commit is contained in:
@@ -22,6 +22,12 @@ S3_BUCKET=pds-blobs
|
||||
AWS_ACCESS_KEY_ID=minioadmin
|
||||
AWS_SECRET_ACCESS_KEY=minioadmin
|
||||
# =============================================================================
|
||||
# Backups (S3-compatible)
|
||||
# =============================================================================
|
||||
# Set to enable automatic repo backups to S3
|
||||
# BACKUP_S3_BUCKET=pds-backups
|
||||
# BACKUP_ENABLED=true
|
||||
# =============================================================================
|
||||
# Valkey (for caching and distributed rate limiting)
|
||||
# =============================================================================
|
||||
# If not set, falls back to in-memory caching (single-node only)
|
||||
|
||||
+25
-1
@@ -68,13 +68,14 @@ chmod +x /etc/init.d/minio
|
||||
rc-update add minio
|
||||
rc-service minio start
|
||||
```
|
||||
Create the blob bucket (wait a few seconds for minio to start):
|
||||
Create the buckets (wait a few seconds for minio to start):
|
||||
```sh
|
||||
curl -O https://dl.min.io/client/mc/release/linux-amd64/mc
|
||||
chmod +x mc
|
||||
mv mc /usr/local/bin/
|
||||
mc alias set local http://localhost:9000 minioadmin your-minio-password
|
||||
mc mb local/pds-blobs
|
||||
mc mb local/pds-backups
|
||||
```
|
||||
## 5. Install valkey
|
||||
```sh
|
||||
@@ -239,3 +240,26 @@ Backup database:
|
||||
```sh
|
||||
pg_dump -U postgres pds > /var/backups/pds-$(date +%Y%m%d).sql
|
||||
```
|
||||
|
||||
## Custom Homepage
|
||||
|
||||
Drop a `homepage.html` in `/var/lib/tranquil-pds/frontend/` and it becomes your landing page. Go nuts with it. Account dashboard is at `/app/` so you won't break anything.
|
||||
|
||||
```sh
|
||||
cat > /var/lib/tranquil-pds/frontend/homepage.html << 'EOF'
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Welcome to my PDS</title>
|
||||
<style>
|
||||
body { font-family: system-ui; max-width: 600px; margin: 100px auto; padding: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Welcome to my amazing zoo pen</h1>
|
||||
<p>This is a <a href="https://atproto.com">AT Protocol</a> Personal Data Server.</p>
|
||||
<p><a href="/app/">Sign in</a> or learn more at <a href="https://bsky.social">Bluesky</a>.</p>
|
||||
</body>
|
||||
</html>
|
||||
EOF
|
||||
```
|
||||
|
||||
@@ -82,13 +82,13 @@ systemctl start tranquil-pds-db tranquil-pds-minio tranquil-pds-valkey
|
||||
sleep 10
|
||||
```
|
||||
|
||||
Create the minio bucket:
|
||||
Create the minio buckets:
|
||||
```bash
|
||||
podman run --rm --pod tranquil-pds \
|
||||
-e MINIO_ROOT_USER=minioadmin \
|
||||
-e MINIO_ROOT_PASSWORD=your-minio-password \
|
||||
docker.io/minio/mc:RELEASE.2025-07-16T15-35-03Z \
|
||||
sh -c "mc alias set local http://localhost:9000 \$MINIO_ROOT_USER \$MINIO_ROOT_PASSWORD && mc mb --ignore-existing local/pds-blobs"
|
||||
sh -c "mc alias set local http://localhost:9000 \$MINIO_ROOT_USER \$MINIO_ROOT_PASSWORD && mc mb --ignore-existing local/pds-blobs && mc mb --ignore-existing local/pds-backups"
|
||||
```
|
||||
|
||||
Run migrations:
|
||||
@@ -230,14 +230,14 @@ rc-service tranquil-pds start
|
||||
sleep 15
|
||||
```
|
||||
|
||||
Create the minio bucket:
|
||||
Create the minio buckets:
|
||||
```sh
|
||||
source /srv/tranquil-pds/config/tranquil-pds.env
|
||||
podman run --rm --network tranquil-pds_default \
|
||||
-e MINIO_ROOT_USER="$MINIO_ROOT_USER" \
|
||||
-e MINIO_ROOT_PASSWORD="$MINIO_ROOT_PASSWORD" \
|
||||
docker.io/minio/mc:RELEASE.2025-07-16T15-35-03Z \
|
||||
sh -c 'mc alias set local http://minio:9000 $MINIO_ROOT_USER $MINIO_ROOT_PASSWORD && mc mb --ignore-existing local/pds-blobs'
|
||||
sh -c 'mc alias set local http://minio:9000 $MINIO_ROOT_USER $MINIO_ROOT_PASSWORD && mc mb --ignore-existing local/pds-blobs && mc mb --ignore-existing local/pds-backups'
|
||||
```
|
||||
|
||||
Run migrations:
|
||||
@@ -350,3 +350,24 @@ podman exec tranquil-pds-db pg_dump -U tranquil_pds pds > /var/backups/pds-$(dat
|
||||
```sh
|
||||
podman exec tranquil-pds-db-1 pg_dump -U tranquil_pds pds > /var/backups/pds-$(date +%Y%m%d).sql
|
||||
```
|
||||
|
||||
## Custom Homepage
|
||||
|
||||
Mount a `homepage.html` into the container's frontend directory and it becomes your landing page. Go nuts with it. Account dashboard is at `/app/` so you won't break anything.
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Welcome to my PDS</title>
|
||||
<style>
|
||||
body { font-family: system-ui; max-width: 600px; margin: 100px auto; padding: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Welcome to my dark web popsocket store</h1>
|
||||
<p>This is a <a href="https://atproto.com">AT Protocol</a> Personal Data Server.</p>
|
||||
<p><a href="/app/">Sign in</a> or learn more at <a href="https://bsky.social">Bluesky</a>.</p>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
+25
-1
@@ -59,13 +59,14 @@ systemctl daemon-reload
|
||||
systemctl enable minio
|
||||
systemctl start minio
|
||||
```
|
||||
Create the blob bucket (wait a few seconds for minio to start):
|
||||
Create the buckets (wait a few seconds for minio to start):
|
||||
```bash
|
||||
curl -O https://dl.min.io/client/mc/release/linux-amd64/mc
|
||||
chmod +x mc
|
||||
mv mc /usr/local/bin/
|
||||
mc alias set local http://localhost:9000 minioadmin your-minio-password
|
||||
mc mb local/pds-blobs
|
||||
mc mb local/pds-backups
|
||||
```
|
||||
## 5. Install valkey
|
||||
```bash
|
||||
@@ -212,3 +213,26 @@ Backup database:
|
||||
```bash
|
||||
sudo -u postgres pg_dump pds > /var/backups/pds-$(date +%Y%m%d).sql
|
||||
```
|
||||
|
||||
## Custom Homepage
|
||||
|
||||
Drop a `homepage.html` in `/var/lib/tranquil-pds/frontend/` and it becomes your landing page. Go nuts with it. Account dashboard is at `/app/` so you won't break anything.
|
||||
|
||||
```bash
|
||||
cat > /var/lib/tranquil-pds/frontend/homepage.html << 'EOF'
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Welcome to my PDS</title>
|
||||
<style>
|
||||
body { font-family: system-ui; max-width: 600px; margin: 100px auto; padding: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Welcome to my secret PDS</h1>
|
||||
<p>This is a <a href="https://atproto.com">AT Protocol</a> Personal Data Server.</p>
|
||||
<p><a href="/app/">Sign in</a> or learn more at <a href="https://bsky.social">Bluesky</a>.</p>
|
||||
</body>
|
||||
</html>
|
||||
EOF
|
||||
```
|
||||
|
||||
@@ -12,6 +12,7 @@ You'll need a wildcard TLS certificate for `*.your-pds-hostname.example.com`. Us
|
||||
The container image expects:
|
||||
- `DATABASE_URL` - postgres connection string
|
||||
- `S3_ENDPOINT`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `S3_BUCKET`
|
||||
- `BACKUP_S3_BUCKET` - bucket for repo backups (optional but recommended)
|
||||
- `VALKEY_URL` - redis:// connection string
|
||||
- `PDS_HOSTNAME` - your PDS hostname (without protocol)
|
||||
- `JWT_SECRET`, `DPOP_SECRET`, `MASTER_KEY` - generate with `openssl rand -base64 48`
|
||||
@@ -20,3 +21,24 @@ and more, check the .env.example.
|
||||
|
||||
Health check: `GET /xrpc/_health`
|
||||
|
||||
## Custom Homepage
|
||||
|
||||
Mount a ConfigMap with your `homepage.html` into the container's frontend directory and it becomes your landing page. Go nuts with it. Account dashboard is at `/app/` so you won't break anything.
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: pds-homepage
|
||||
data:
|
||||
homepage.html: |
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Welcome to my PDS</title></head>
|
||||
<body>
|
||||
<h1>Welcome to my little evil secret lab!!!</h1>
|
||||
<p><a href="/app/">Sign in</a></p>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
|
||||
+25
-1
@@ -72,12 +72,13 @@ chmod +x /etc/rc.d/minio
|
||||
rcctl enable minio
|
||||
rcctl start minio
|
||||
```
|
||||
Create the blob bucket:
|
||||
Create the buckets:
|
||||
```sh
|
||||
ftp -o /usr/local/bin/mc https://dl.min.io/client/mc/release/openbsd-amd64/mc
|
||||
chmod +x /usr/local/bin/mc
|
||||
mc alias set local http://localhost:9000 minioadmin your-minio-password
|
||||
mc mb local/pds-blobs
|
||||
mc mb local/pds-backups
|
||||
```
|
||||
## 5. Install redis
|
||||
OpenBSD has redis in ports (valkey not available yet):
|
||||
@@ -251,3 +252,26 @@ Backup database:
|
||||
```sh
|
||||
pg_dump -U postgres pds > /var/backups/pds-$(date +%Y%m%d).sql
|
||||
```
|
||||
|
||||
## Custom Homepage
|
||||
|
||||
Drop a `homepage.html` in `/var/tranquil-pds/frontend/` and it becomes your landing page. Go nuts with it. Account dashboard is at `/app/` so you won't break anything.
|
||||
|
||||
```sh
|
||||
cat > /var/tranquil-pds/frontend/homepage.html << 'EOF'
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Welcome to my PDS</title>
|
||||
<style>
|
||||
body { font-family: system-ui; max-width: 600px; margin: 100px auto; padding: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Welcome to my uma musume shipping site!</h1>
|
||||
<p>This is a <a href="https://atproto.com">AT Protocol</a> Personal Data Server.</p>
|
||||
<p><a href="/app/">Sign in</a> or learn more at <a href="https://bsky.social">Bluesky</a>.</p>
|
||||
</body>
|
||||
</html>
|
||||
EOF
|
||||
```
|
||||
|
||||
@@ -0,0 +1,696 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Tranquil PDS</title>
|
||||
<style>
|
||||
:root {
|
||||
--space-0: 0;
|
||||
--space-1: 0.125rem;
|
||||
--space-2: 0.25rem;
|
||||
--space-3: 0.5rem;
|
||||
--space-4: 0.75rem;
|
||||
--space-5: 1rem;
|
||||
--space-6: 1.5rem;
|
||||
--space-7: 2rem;
|
||||
--space-8: 3rem;
|
||||
--space-9: 4rem;
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-base: 1rem;
|
||||
--text-lg: 1.125rem;
|
||||
--text-xl: 1.25rem;
|
||||
--text-2xl: 1.5rem;
|
||||
--text-3xl: 2rem;
|
||||
--text-4xl: 2.5rem;
|
||||
--font-normal: 400;
|
||||
--font-medium: 500;
|
||||
--font-semibold: 600;
|
||||
--font-bold: 700;
|
||||
--leading-tight: 1.25;
|
||||
--leading-normal: 1.5;
|
||||
--leading-relaxed: 1.75;
|
||||
--radius-sm: 3px;
|
||||
--radius-md: 4px;
|
||||
--radius-lg: 6px;
|
||||
--radius-xl: 8px;
|
||||
--width-xs: 360px;
|
||||
--width-sm: 480px;
|
||||
--width-md: 760px;
|
||||
--width-lg: 960px;
|
||||
--width-xl: 1100px;
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
--shadow-focus: 0 0 0 2px var(--accent-muted);
|
||||
--transition-fast: 0.1s ease;
|
||||
--transition-normal: 0.15s ease;
|
||||
--transition-slow: 0.25s ease;
|
||||
--font-mono: ui-monospace, "SF Mono", Menlo, Monaco, monospace;
|
||||
--bg-primary: #f9fafa;
|
||||
--bg-secondary: #f1f3f3;
|
||||
--bg-tertiary: #e8ebeb;
|
||||
--bg-hover: #e8ebeb;
|
||||
--bg-card: #ffffff;
|
||||
--bg-input: #ffffff;
|
||||
--bg-input-disabled: #f1f3f3;
|
||||
--text-primary: #1a1d1d;
|
||||
--text-secondary: #5a605f;
|
||||
--text-muted: #8a8f8e;
|
||||
--text-inverse: #ffffff;
|
||||
--border-color: #dce0df;
|
||||
--border-light: #e8ebeb;
|
||||
--border-dark: #c8cecc;
|
||||
--accent: #1a1d1d;
|
||||
--accent-hover: #2e3332;
|
||||
--accent-muted: rgba(26, 29, 29, 0.06);
|
||||
--accent-light: #3a403f;
|
||||
--secondary: #1a1d1d;
|
||||
--secondary-hover: #2e3332;
|
||||
--secondary-muted: rgba(26, 29, 29, 0.06);
|
||||
--success-bg: #dfd;
|
||||
--success-border: #8c8;
|
||||
--success-text: #060;
|
||||
--error-bg: #fee;
|
||||
--error-border: #fcc;
|
||||
--error-text: #c00;
|
||||
--warning-bg: #ffd;
|
||||
--warning-border: #d4a03c;
|
||||
--warning-text: #856404;
|
||||
--border-color-light: var(--border-dark);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg-primary: #0a0c0c;
|
||||
--bg-secondary: #131616;
|
||||
--bg-tertiary: #1a1d1d;
|
||||
--bg-hover: #1a1d1d;
|
||||
--bg-card: #131616;
|
||||
--bg-input: #1a1d1d;
|
||||
--bg-input-disabled: #131616;
|
||||
--text-primary: #e6e8e8;
|
||||
--text-secondary: #9ca1a0;
|
||||
--text-muted: #686d6c;
|
||||
--text-inverse: #0a0c0c;
|
||||
--border-color: #282c2b;
|
||||
--border-light: #1f2322;
|
||||
--border-dark: #343938;
|
||||
--accent: #e6e8e8;
|
||||
--accent-hover: #ffffff;
|
||||
--accent-muted: rgba(230, 232, 232, 0.1);
|
||||
--accent-light: #ffffff;
|
||||
--secondary: #e6e8e8;
|
||||
--secondary-hover: #ffffff;
|
||||
--secondary-muted: rgba(230, 232, 232, 0.1);
|
||||
--success-bg: #0f1f1a;
|
||||
--success-border: #1a3d2d;
|
||||
--success-text: #7bc6a0;
|
||||
--error-bg: #1f0f0f;
|
||||
--error-border: #3d1a1a;
|
||||
--error-text: #ff8a8a;
|
||||
--warning-bg: #1f1a0f;
|
||||
--warning-border: #3d351a;
|
||||
--warning-text: #c6b87b;
|
||||
}
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family:
|
||||
system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: var(--leading-normal);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.pattern-container {
|
||||
position: fixed;
|
||||
top: -32px;
|
||||
left: -32px;
|
||||
right: -32px;
|
||||
bottom: -32px;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
.pattern {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: calc(100% + 500px);
|
||||
height: 100%;
|
||||
animation: drift 80s linear infinite;
|
||||
}
|
||||
.dot {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
border-radius: 50%;
|
||||
transition: transform 0.04s linear;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.dot {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
.pattern-fade {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
transparent 50%,
|
||||
var(--bg-primary) 75%
|
||||
);
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
@keyframes drift {
|
||||
0% {
|
||||
transform: translateX(-500px);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
nav {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 32px;
|
||||
right: 32px;
|
||||
background: var(--accent);
|
||||
padding: 10px 18px;
|
||||
z-index: 100;
|
||||
border-radius: var(--radius-xl);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.nav-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.nav-logo {
|
||||
height: 28px;
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.hostname {
|
||||
font-weight: var(--font-semibold);
|
||||
font-size: var(--text-base);
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-inverse);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.hostname.placeholder {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.user-count {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-inverse);
|
||||
opacity: 0.85;
|
||||
padding: 4px 10px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: var(--radius-md);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.user-count {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
.nav-meta {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-inverse);
|
||||
opacity: 0.6;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.home {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
max-width: var(--width-xl);
|
||||
margin: 0 auto;
|
||||
padding: 72px 32px 32px;
|
||||
}
|
||||
.hero {
|
||||
padding: var(--space-7) 0 var(--space-8);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
h1 {
|
||||
font-size: var(--text-4xl);
|
||||
font-weight: var(--font-semibold);
|
||||
line-height: var(--leading-tight);
|
||||
margin-bottom: var(--space-6);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.cycling-word-container {
|
||||
display: inline-block;
|
||||
width: 3.9em;
|
||||
text-align: left;
|
||||
}
|
||||
.cycling-word {
|
||||
display: inline-block;
|
||||
transition: opacity 0.1s ease, transform 0.1s ease;
|
||||
}
|
||||
.cycling-word.transitioning {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
.lede {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-primary);
|
||||
line-height: var(--leading-relaxed);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
margin-top: var(--space-7);
|
||||
}
|
||||
.btn {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: var(--space-4) var(--space-6);
|
||||
border-radius: var(--radius-lg);
|
||||
text-decoration: none;
|
||||
transition: all var(--transition-normal);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.btn.primary {
|
||||
background: var(--secondary);
|
||||
color: var(--text-inverse);
|
||||
border-color: var(--secondary);
|
||||
}
|
||||
.btn.primary:hover {
|
||||
background: var(--secondary-hover);
|
||||
border-color: var(--secondary-hover);
|
||||
}
|
||||
.btn.secondary {
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
.btn.secondary:hover {
|
||||
background: var(--secondary-muted);
|
||||
border-color: var(--secondary);
|
||||
color: var(--secondary);
|
||||
}
|
||||
blockquote {
|
||||
margin: var(--space-8) 0 0 0;
|
||||
padding: var(--space-6);
|
||||
background: var(--accent-muted);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 0 var(--radius-xl) var(--radius-xl) 0;
|
||||
}
|
||||
blockquote p {
|
||||
font-size: var(--text-lg);
|
||||
color: var(--text-primary);
|
||||
font-style: italic;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
blockquote cite {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
font-style: normal;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.content h2 {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-bold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--accent-light);
|
||||
margin: var(--space-8) 0 var(--space-5);
|
||||
}
|
||||
.content h2:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.content > p {
|
||||
font-size: var(--text-base);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-5);
|
||||
line-height: var(--leading-relaxed);
|
||||
}
|
||||
.features {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--space-6);
|
||||
margin: var(--space-6) 0 var(--space-8);
|
||||
}
|
||||
.feature {
|
||||
padding: var(--space-5);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-xl);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.feature h3 {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
.feature p {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
line-height: var(--leading-relaxed);
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.features {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
h1 {
|
||||
font-size: var(--text-3xl);
|
||||
}
|
||||
.actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
.btn {
|
||||
text-align: center;
|
||||
}
|
||||
.user-count, .nav-meta {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.site-footer {
|
||||
margin-top: var(--space-9);
|
||||
padding-top: var(--space-7);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="pattern-container">
|
||||
<div class="pattern" id="dotPattern"></div>
|
||||
</div>
|
||||
<div class="pattern-fade"></div>
|
||||
|
||||
<nav>
|
||||
<div class="nav-left">
|
||||
<img src="/logo" alt="Logo" class="nav-logo hidden" id="navLogo">
|
||||
<span class="hostname" id="hostname">loading...</span>
|
||||
<span class="user-count hidden" id="userCount"></span>
|
||||
</div>
|
||||
<span class="nav-meta" id="version"></span>
|
||||
</nav>
|
||||
|
||||
<div class="home">
|
||||
<section class="hero">
|
||||
<h1>
|
||||
A home for your <span class="cycling-word-container"><span
|
||||
class="cycling-word"
|
||||
id="cyclingWord"
|
||||
>Bluesky</span></span> account
|
||||
</h1>
|
||||
|
||||
<p class="lede">
|
||||
Tranquil PDS is a Personal Data Server, the thing that stores your
|
||||
posts, profile, and keys. Bluesky runs one for you, but you can run
|
||||
your own.
|
||||
</p>
|
||||
|
||||
<div class="actions" id="heroActions">
|
||||
<a href="/app/register" class="btn primary" id="heroPrimary"
|
||||
>Join This Server</a>
|
||||
<a
|
||||
href="https://tangled.org/lewis.moe/bspds-sandbox"
|
||||
class="btn secondary"
|
||||
id="heroSecondary"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>Run Your Own</a>
|
||||
</div>
|
||||
|
||||
<blockquote>
|
||||
<p>"Nature does not hurry, yet everything is accomplished."</p>
|
||||
<cite>Lao Tzu</cite>
|
||||
</blockquote>
|
||||
</section>
|
||||
|
||||
<section class="content">
|
||||
<h2>What you get</h2>
|
||||
|
||||
<div class="features">
|
||||
<div class="feature">
|
||||
<h3>Real security</h3>
|
||||
<p>
|
||||
Sign in with passkeys, add two-factor authentication, set up
|
||||
backup codes, and mark devices you trust. Your account stays
|
||||
yours.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<h3>Your own identity</h3>
|
||||
<p>
|
||||
Use your own domain as your handle, or get a subdomain on ours.
|
||||
Either way, your identity moves with you if you ever leave.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<h3>Stay in the loop</h3>
|
||||
<p>
|
||||
Get important alerts where you actually see them: email, Discord,
|
||||
Telegram, or Signal.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<h3>You decide what apps can do</h3>
|
||||
<p>
|
||||
When an app asks for access, you'll see exactly what it wants in
|
||||
plain language. Grant what makes sense, deny what doesn't.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<h3>App passwords with guardrails</h3>
|
||||
<p>
|
||||
Create app passwords that can only do specific things: read-only
|
||||
for feed readers, post-only for bots. Full control over what each
|
||||
password can access.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<h3>Delegate without sharing passwords</h3>
|
||||
<p>
|
||||
Let team members or tools manage your account with specific
|
||||
permission levels. They authenticate with their own credentials,
|
||||
you see everything they do in an audit log.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<h3>Automatic backups</h3>
|
||||
<p>
|
||||
Your repository is backed up daily to object storage. Download any
|
||||
backup or restore with one click. You own your data, even if the
|
||||
worst happens.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Everything in one place</h2>
|
||||
|
||||
<p>
|
||||
Manage your profile, security settings, connected apps, and more from
|
||||
a clean dashboard. No command line or 3rd party apps required.
|
||||
</p>
|
||||
|
||||
<h2>Works with everything</h2>
|
||||
|
||||
<p>
|
||||
Use any ATProto app you already like. Tranquil PDS speaks the same
|
||||
language as Bluesky's servers, so all your favorite clients and tools
|
||||
just work.
|
||||
</p>
|
||||
|
||||
<h2>Ready to try it?</h2>
|
||||
|
||||
<p>
|
||||
Join this server, or grab the source and run your own. Either way, you
|
||||
can migrate an existing account over and your followers, posts, and
|
||||
identity come with you.
|
||||
</p>
|
||||
|
||||
<div class="actions" id="footerActions">
|
||||
<a href="/app/register" class="btn primary" id="footerPrimary"
|
||||
>Join This Server</a>
|
||||
<a
|
||||
href="https://tangled.org/lewis.moe/bspds-sandbox"
|
||||
class="btn secondary"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>View Source</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="site-footer">
|
||||
<span>Made by people who don't take themselves too seriously</span>
|
||||
<span>Open Source: issues & PRs welcome</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function checkSession() {
|
||||
try {
|
||||
const stored = localStorage.getItem("tranquil_pds_session");
|
||||
if (stored) {
|
||||
const session = JSON.parse(stored);
|
||||
if (session && session.handle) {
|
||||
const handle = "@" + session.handle;
|
||||
const heroPrimary = document.getElementById(
|
||||
"heroPrimary",
|
||||
);
|
||||
const footerPrimary = document.getElementById(
|
||||
"footerPrimary",
|
||||
);
|
||||
const heroSecondary = document.getElementById(
|
||||
"heroSecondary",
|
||||
);
|
||||
if (heroPrimary) {
|
||||
heroPrimary.href = "/app/dashboard";
|
||||
heroPrimary.textContent = handle;
|
||||
}
|
||||
if (footerPrimary) {
|
||||
footerPrimary.href = "/app/dashboard";
|
||||
footerPrimary.textContent = handle;
|
||||
}
|
||||
if (heroSecondary) {
|
||||
heroSecondary.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
|
||||
const heroWords = ["Bluesky", "Tangled", "Leaflet", "ATProto"];
|
||||
const wordSpacing = {
|
||||
"Bluesky": "0.01em",
|
||||
"Tangled": "0.02em",
|
||||
"Leaflet": "0.05em",
|
||||
"ATProto": "0",
|
||||
};
|
||||
let currentWordIndex = 0;
|
||||
const cyclingWord = document.getElementById("cyclingWord");
|
||||
|
||||
function cycleWord() {
|
||||
cyclingWord.classList.add("transitioning");
|
||||
setTimeout(() => {
|
||||
currentWordIndex = (currentWordIndex + 1) % heroWords.length;
|
||||
const word = heroWords[currentWordIndex];
|
||||
cyclingWord.textContent = word;
|
||||
cyclingWord.style.letterSpacing = wordSpacing[word] || "0";
|
||||
cyclingWord.classList.remove("transitioning");
|
||||
const duration = word === "ATProto" ? 4000 : 2000;
|
||||
setTimeout(cycleWord, duration);
|
||||
}, 100);
|
||||
}
|
||||
setTimeout(cycleWord, 2000);
|
||||
|
||||
fetch("/xrpc/com.atproto.server.describeServer")
|
||||
.then((r) => r.json())
|
||||
.then((info) => {
|
||||
if (info.availableUserDomains?.length) {
|
||||
document.getElementById("hostname").textContent =
|
||||
info.availableUserDomains[0];
|
||||
document.getElementById("hostname").classList.remove(
|
||||
"placeholder",
|
||||
);
|
||||
}
|
||||
if (info.version) {
|
||||
document.getElementById("version").textContent =
|
||||
info.version;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
fetch("/xrpc/com.atproto.sync.listRepos?limit=1000")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
const count = data.repos?.length || 0;
|
||||
const el = document.getElementById("userCount");
|
||||
el.textContent = count + " " +
|
||||
(count === 1 ? "user" : "users");
|
||||
el.classList.remove("hidden");
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
fetch("/logo", { method: "HEAD" })
|
||||
.then((r) => {
|
||||
if (r.ok) {
|
||||
document.getElementById("navLogo").classList.remove(
|
||||
"hidden",
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
const pattern = document.getElementById("dotPattern");
|
||||
const spacing = 32;
|
||||
const cols = Math.ceil((window.innerWidth + 600) / spacing);
|
||||
const rows = Math.ceil((window.innerHeight + 100) / spacing);
|
||||
const dots = [];
|
||||
|
||||
for (let y = 0; y < rows; y++) {
|
||||
for (let x = 0; x < cols; x++) {
|
||||
const dot = document.createElement("div");
|
||||
dot.className = "dot";
|
||||
dot.style.left = (x * spacing) + "px";
|
||||
dot.style.top = (y * spacing) + "px";
|
||||
pattern.appendChild(dot);
|
||||
dots.push({ el: dot, x: x * spacing, y: y * spacing });
|
||||
}
|
||||
}
|
||||
|
||||
let mouseX = -1000, mouseY = -1000;
|
||||
document.addEventListener("mousemove", (e) => {
|
||||
mouseX = e.clientX;
|
||||
mouseY = e.clientY;
|
||||
});
|
||||
|
||||
function updateDots() {
|
||||
const patternRect = pattern.getBoundingClientRect();
|
||||
dots.forEach((dot) => {
|
||||
const dotX = patternRect.left + dot.x + 5;
|
||||
const dotY = patternRect.top + dot.y + 5;
|
||||
const dist = Math.hypot(mouseX - dotX, mouseY - dotY);
|
||||
const maxDist = 120;
|
||||
const scale = Math.min(1, Math.max(0.1, dist / maxDist));
|
||||
dot.el.style.transform = "scale(" + scale + ")";
|
||||
});
|
||||
requestAnimationFrame(updateDots);
|
||||
}
|
||||
updateDots();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+16
-11
@@ -2,7 +2,7 @@
|
||||
import { getCurrentPath, navigate } from './lib/router.svelte'
|
||||
import { initAuth, getAuthState } from './lib/auth.svelte'
|
||||
import { initServerConfig } from './lib/serverConfig.svelte'
|
||||
import { initI18n, _ } from './lib/i18n'
|
||||
import { initI18n } from './lib/i18n'
|
||||
import { isLoading as i18nLoading } from 'svelte-i18n'
|
||||
import Login from './routes/Login.svelte'
|
||||
import Register from './routes/Register.svelte'
|
||||
@@ -34,13 +34,6 @@
|
||||
import ActAs from './routes/ActAs.svelte'
|
||||
import Migration from './routes/Migration.svelte'
|
||||
import DidDocumentEditor from './routes/DidDocumentEditor.svelte'
|
||||
import Home from './routes/Home.svelte'
|
||||
|
||||
if (window.location.pathname === '/migrate') {
|
||||
const newUrl = `${window.location.origin}/${window.location.search}#/migrate`
|
||||
window.location.replace(newUrl)
|
||||
}
|
||||
|
||||
initI18n()
|
||||
|
||||
const auth = getAuthState()
|
||||
@@ -48,7 +41,7 @@
|
||||
let oauthCallbackPending = $state(hasOAuthCallback())
|
||||
|
||||
function hasOAuthCallback(): boolean {
|
||||
if (window.location.hash === '#/migrate') {
|
||||
if (window.location.pathname === '/app/migrate') {
|
||||
return false
|
||||
}
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
@@ -59,12 +52,24 @@
|
||||
initServerConfig()
|
||||
initAuth().then(({ oauthLoginCompleted }) => {
|
||||
if (oauthLoginCompleted) {
|
||||
navigate('/dashboard')
|
||||
navigate('/dashboard', true)
|
||||
}
|
||||
oauthCallbackPending = false
|
||||
})
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (auth.loading) return
|
||||
const path = getCurrentPath()
|
||||
if (path === '/') {
|
||||
if (auth.session) {
|
||||
navigate('/dashboard', true)
|
||||
} else {
|
||||
navigate('/login', true)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function getComponent(path: string) {
|
||||
switch (path) {
|
||||
case '/login':
|
||||
@@ -128,7 +133,7 @@
|
||||
case '/did-document':
|
||||
return DidDocumentEditor
|
||||
default:
|
||||
return Home
|
||||
return Login
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+65
-65
@@ -237,7 +237,7 @@ export const api = {
|
||||
return data;
|
||||
},
|
||||
|
||||
async confirmSignup(
|
||||
confirmSignup(
|
||||
did: string,
|
||||
verificationCode: string,
|
||||
): Promise<ConfirmSignupResult> {
|
||||
@@ -247,32 +247,32 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async resendVerification(did: string): Promise<{ success: boolean }> {
|
||||
resendVerification(did: string): Promise<{ success: boolean }> {
|
||||
return xrpc("com.atproto.server.resendVerification", {
|
||||
method: "POST",
|
||||
body: { did },
|
||||
});
|
||||
},
|
||||
|
||||
async createSession(identifier: string, password: string): Promise<Session> {
|
||||
createSession(identifier: string, password: string): Promise<Session> {
|
||||
return xrpc("com.atproto.server.createSession", {
|
||||
method: "POST",
|
||||
body: { identifier, password },
|
||||
});
|
||||
},
|
||||
|
||||
async checkEmailVerified(identifier: string): Promise<{ verified: boolean }> {
|
||||
checkEmailVerified(identifier: string): Promise<{ verified: boolean }> {
|
||||
return xrpc("_checkEmailVerified", {
|
||||
method: "POST",
|
||||
body: { identifier },
|
||||
});
|
||||
},
|
||||
|
||||
async getSession(token: string): Promise<Session> {
|
||||
getSession(token: string): Promise<Session> {
|
||||
return xrpc("com.atproto.server.getSession", { token });
|
||||
},
|
||||
|
||||
async refreshSession(refreshJwt: string): Promise<Session> {
|
||||
refreshSession(refreshJwt: string): Promise<Session> {
|
||||
return xrpc("com.atproto.server.refreshSession", {
|
||||
method: "POST",
|
||||
token: refreshJwt,
|
||||
@@ -286,11 +286,11 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async listAppPasswords(token: string): Promise<{ passwords: AppPassword[] }> {
|
||||
listAppPasswords(token: string): Promise<{ passwords: AppPassword[] }> {
|
||||
return xrpc("com.atproto.server.listAppPasswords", { token });
|
||||
},
|
||||
|
||||
async createAppPassword(
|
||||
createAppPassword(
|
||||
token: string,
|
||||
name: string,
|
||||
scopes?: string,
|
||||
@@ -312,11 +312,11 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async getAccountInviteCodes(token: string): Promise<{ codes: InviteCode[] }> {
|
||||
getAccountInviteCodes(token: string): Promise<{ codes: InviteCode[] }> {
|
||||
return xrpc("com.atproto.server.getAccountInviteCodes", { token });
|
||||
},
|
||||
|
||||
async createInviteCode(
|
||||
createInviteCode(
|
||||
token: string,
|
||||
useCount: number = 1,
|
||||
): Promise<{ code: string }> {
|
||||
@@ -341,7 +341,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async requestEmailUpdate(
|
||||
requestEmailUpdate(
|
||||
token: string,
|
||||
): Promise<{ tokenRequired: boolean }> {
|
||||
return xrpc("com.atproto.server.requestEmailUpdate", {
|
||||
@@ -388,7 +388,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async describeServer(): Promise<{
|
||||
describeServer(): Promise<{
|
||||
availableUserDomains: string[];
|
||||
inviteCodeRequired: boolean;
|
||||
links?: { privacyPolicy?: string; termsOfService?: string };
|
||||
@@ -399,7 +399,7 @@ export const api = {
|
||||
return xrpc("com.atproto.server.describeServer");
|
||||
},
|
||||
|
||||
async listRepos(limit?: number): Promise<{
|
||||
listRepos(limit?: number): Promise<{
|
||||
repos: Array<{ did: string; head: string; rev: string }>;
|
||||
cursor?: string;
|
||||
}> {
|
||||
@@ -408,7 +408,7 @@ export const api = {
|
||||
return xrpc("com.atproto.sync.listRepos", { params });
|
||||
},
|
||||
|
||||
async getNotificationPrefs(token: string): Promise<{
|
||||
getNotificationPrefs(token: string): Promise<{
|
||||
preferredChannel: string;
|
||||
email: string;
|
||||
discordId: string | null;
|
||||
@@ -421,7 +421,7 @@ export const api = {
|
||||
return xrpc("_account.getNotificationPrefs", { token });
|
||||
},
|
||||
|
||||
async updateNotificationPrefs(token: string, prefs: {
|
||||
updateNotificationPrefs(token: string, prefs: {
|
||||
preferredChannel?: string;
|
||||
discordId?: string;
|
||||
telegramUsername?: string;
|
||||
@@ -434,7 +434,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async confirmChannelVerification(
|
||||
confirmChannelVerification(
|
||||
token: string,
|
||||
channel: string,
|
||||
identifier: string,
|
||||
@@ -447,7 +447,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async getNotificationHistory(token: string): Promise<{
|
||||
getNotificationHistory(token: string): Promise<{
|
||||
notifications: Array<{
|
||||
createdAt: string;
|
||||
channel: string;
|
||||
@@ -460,7 +460,7 @@ export const api = {
|
||||
return xrpc("_account.getNotificationHistory", { token });
|
||||
},
|
||||
|
||||
async getServerStats(token: string): Promise<{
|
||||
getServerStats(token: string): Promise<{
|
||||
userCount: number;
|
||||
repoCount: number;
|
||||
recordCount: number;
|
||||
@@ -469,7 +469,7 @@ export const api = {
|
||||
return xrpc("_admin.getServerStats", { token });
|
||||
},
|
||||
|
||||
async getServerConfig(): Promise<{
|
||||
getServerConfig(): Promise<{
|
||||
serverName: string;
|
||||
primaryColor: string | null;
|
||||
primaryColorDark: string | null;
|
||||
@@ -480,7 +480,7 @@ export const api = {
|
||||
return xrpc("_server.getConfig");
|
||||
},
|
||||
|
||||
async updateServerConfig(
|
||||
updateServerConfig(
|
||||
token: string,
|
||||
config: {
|
||||
serverName?: string;
|
||||
@@ -541,24 +541,24 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async removePassword(token: string): Promise<{ success: boolean }> {
|
||||
removePassword(token: string): Promise<{ success: boolean }> {
|
||||
return xrpc("_account.removePassword", {
|
||||
method: "POST",
|
||||
token,
|
||||
});
|
||||
},
|
||||
|
||||
async getPasswordStatus(token: string): Promise<{ hasPassword: boolean }> {
|
||||
getPasswordStatus(token: string): Promise<{ hasPassword: boolean }> {
|
||||
return xrpc("_account.getPasswordStatus", { token });
|
||||
},
|
||||
|
||||
async getLegacyLoginPreference(
|
||||
getLegacyLoginPreference(
|
||||
token: string,
|
||||
): Promise<{ allowLegacyLogin: boolean; hasMfa: boolean }> {
|
||||
return xrpc("_account.getLegacyLoginPreference", { token });
|
||||
},
|
||||
|
||||
async updateLegacyLoginPreference(
|
||||
updateLegacyLoginPreference(
|
||||
token: string,
|
||||
allowLegacyLogin: boolean,
|
||||
): Promise<{ allowLegacyLogin: boolean }> {
|
||||
@@ -569,7 +569,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async updateLocale(
|
||||
updateLocale(
|
||||
token: string,
|
||||
preferredLocale: string,
|
||||
): Promise<{ preferredLocale: string }> {
|
||||
@@ -580,7 +580,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async listSessions(token: string): Promise<{
|
||||
listSessions(token: string): Promise<{
|
||||
sessions: Array<{
|
||||
id: string;
|
||||
sessionType: string;
|
||||
@@ -601,14 +601,14 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async revokeAllSessions(token: string): Promise<{ revokedCount: number }> {
|
||||
revokeAllSessions(token: string): Promise<{ revokedCount: number }> {
|
||||
return xrpc("_account.revokeAllSessions", {
|
||||
method: "POST",
|
||||
token,
|
||||
});
|
||||
},
|
||||
|
||||
async searchAccounts(token: string, options?: {
|
||||
searchAccounts(token: string, options?: {
|
||||
handle?: string;
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
@@ -630,7 +630,7 @@ export const api = {
|
||||
return xrpc("com.atproto.admin.searchAccounts", { token, params });
|
||||
},
|
||||
|
||||
async getInviteCodes(token: string, options?: {
|
||||
getInviteCodes(token: string, options?: {
|
||||
sort?: "recent" | "usage";
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
@@ -665,7 +665,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async getAccountInfo(token: string, did: string): Promise<{
|
||||
getAccountInfo(token: string, did: string): Promise<{
|
||||
did: string;
|
||||
handle: string;
|
||||
email?: string;
|
||||
@@ -701,7 +701,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async describeRepo(token: string, repo: string): Promise<{
|
||||
describeRepo(token: string, repo: string): Promise<{
|
||||
handle: string;
|
||||
did: string;
|
||||
didDoc: unknown;
|
||||
@@ -714,7 +714,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async listRecords(token: string, repo: string, collection: string, options?: {
|
||||
listRecords(token: string, repo: string, collection: string, options?: {
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
reverse?: boolean;
|
||||
@@ -729,7 +729,7 @@ export const api = {
|
||||
return xrpc("com.atproto.repo.listRecords", { token, params });
|
||||
},
|
||||
|
||||
async getRecord(
|
||||
getRecord(
|
||||
token: string,
|
||||
repo: string,
|
||||
collection: string,
|
||||
@@ -745,7 +745,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async createRecord(
|
||||
createRecord(
|
||||
token: string,
|
||||
repo: string,
|
||||
collection: string,
|
||||
@@ -762,7 +762,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async putRecord(
|
||||
putRecord(
|
||||
token: string,
|
||||
repo: string,
|
||||
collection: string,
|
||||
@@ -792,13 +792,13 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async getTotpStatus(
|
||||
getTotpStatus(
|
||||
token: string,
|
||||
): Promise<{ enabled: boolean; hasBackupCodes: boolean }> {
|
||||
return xrpc("com.atproto.server.getTotpStatus", { token });
|
||||
},
|
||||
|
||||
async createTotpSecret(
|
||||
createTotpSecret(
|
||||
token: string,
|
||||
): Promise<{ uri: string; qrBase64: string }> {
|
||||
return xrpc("com.atproto.server.createTotpSecret", {
|
||||
@@ -807,7 +807,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async enableTotp(
|
||||
enableTotp(
|
||||
token: string,
|
||||
code: string,
|
||||
): Promise<{ success: boolean; backupCodes: string[] }> {
|
||||
@@ -818,7 +818,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async disableTotp(
|
||||
disableTotp(
|
||||
token: string,
|
||||
password: string,
|
||||
code: string,
|
||||
@@ -830,7 +830,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async regenerateBackupCodes(
|
||||
regenerateBackupCodes(
|
||||
token: string,
|
||||
password: string,
|
||||
code: string,
|
||||
@@ -842,7 +842,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async startPasskeyRegistration(
|
||||
startPasskeyRegistration(
|
||||
token: string,
|
||||
friendlyName?: string,
|
||||
): Promise<{ options: unknown }> {
|
||||
@@ -853,7 +853,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async finishPasskeyRegistration(
|
||||
finishPasskeyRegistration(
|
||||
token: string,
|
||||
credential: unknown,
|
||||
friendlyName?: string,
|
||||
@@ -865,7 +865,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async listPasskeys(token: string): Promise<{
|
||||
listPasskeys(token: string): Promise<{
|
||||
passkeys: Array<{
|
||||
id: string;
|
||||
credentialId: string;
|
||||
@@ -897,7 +897,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async listTrustedDevices(token: string): Promise<{
|
||||
listTrustedDevices(token: string): Promise<{
|
||||
devices: Array<{
|
||||
id: string;
|
||||
userAgent: string | null;
|
||||
@@ -910,7 +910,7 @@ export const api = {
|
||||
return xrpc("_account.listTrustedDevices", { token });
|
||||
},
|
||||
|
||||
async revokeTrustedDevice(
|
||||
revokeTrustedDevice(
|
||||
token: string,
|
||||
deviceId: string,
|
||||
): Promise<{ success: boolean }> {
|
||||
@@ -921,7 +921,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async updateTrustedDevice(
|
||||
updateTrustedDevice(
|
||||
token: string,
|
||||
deviceId: string,
|
||||
friendlyName: string,
|
||||
@@ -933,7 +933,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async getReauthStatus(token: string): Promise<{
|
||||
getReauthStatus(token: string): Promise<{
|
||||
requiresReauth: boolean;
|
||||
lastReauthAt: string | null;
|
||||
availableMethods: string[];
|
||||
@@ -941,7 +941,7 @@ export const api = {
|
||||
return xrpc("_account.getReauthStatus", { token });
|
||||
},
|
||||
|
||||
async reauthPassword(
|
||||
reauthPassword(
|
||||
token: string,
|
||||
password: string,
|
||||
): Promise<{ success: boolean; reauthAt: string }> {
|
||||
@@ -952,7 +952,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async reauthTotp(
|
||||
reauthTotp(
|
||||
token: string,
|
||||
code: string,
|
||||
): Promise<{ success: boolean; reauthAt: string }> {
|
||||
@@ -963,14 +963,14 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async reauthPasskeyStart(token: string): Promise<{ options: unknown }> {
|
||||
reauthPasskeyStart(token: string): Promise<{ options: unknown }> {
|
||||
return xrpc("_account.reauthPasskeyStart", {
|
||||
method: "POST",
|
||||
token,
|
||||
});
|
||||
},
|
||||
|
||||
async reauthPasskeyFinish(
|
||||
reauthPasskeyFinish(
|
||||
token: string,
|
||||
credential: unknown,
|
||||
): Promise<{ success: boolean; reauthAt: string }> {
|
||||
@@ -981,14 +981,14 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async reserveSigningKey(did?: string): Promise<{ signingKey: string }> {
|
||||
reserveSigningKey(did?: string): Promise<{ signingKey: string }> {
|
||||
return xrpc("com.atproto.server.reserveSigningKey", {
|
||||
method: "POST",
|
||||
body: { did },
|
||||
});
|
||||
},
|
||||
|
||||
async getRecommendedDidCredentials(token: string): Promise<{
|
||||
getRecommendedDidCredentials(token: string): Promise<{
|
||||
rotationKeys?: string[];
|
||||
alsoKnownAs?: string[];
|
||||
verificationMethods?: { atproto?: string };
|
||||
@@ -1043,7 +1043,7 @@ export const api = {
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async startPasskeyRegistrationForSetup(
|
||||
startPasskeyRegistrationForSetup(
|
||||
did: string,
|
||||
setupToken: string,
|
||||
friendlyName?: string,
|
||||
@@ -1054,7 +1054,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async completePasskeySetup(
|
||||
completePasskeySetup(
|
||||
did: string,
|
||||
setupToken: string,
|
||||
passkeyCredential: unknown,
|
||||
@@ -1071,14 +1071,14 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async requestPasskeyRecovery(email: string): Promise<{ success: boolean }> {
|
||||
requestPasskeyRecovery(email: string): Promise<{ success: boolean }> {
|
||||
return xrpc("_account.requestPasskeyRecovery", {
|
||||
method: "POST",
|
||||
body: { email },
|
||||
});
|
||||
},
|
||||
|
||||
async recoverPasskeyAccount(
|
||||
recoverPasskeyAccount(
|
||||
did: string,
|
||||
recoveryToken: string,
|
||||
newPassword: string,
|
||||
@@ -1089,7 +1089,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async verifyMigrationEmail(
|
||||
verifyMigrationEmail(
|
||||
token: string,
|
||||
email: string,
|
||||
): Promise<{ success: boolean; did: string }> {
|
||||
@@ -1099,14 +1099,14 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async resendMigrationVerification(email: string): Promise<{ sent: boolean }> {
|
||||
resendMigrationVerification(email: string): Promise<{ sent: boolean }> {
|
||||
return xrpc("com.atproto.server.resendMigrationVerification", {
|
||||
method: "POST",
|
||||
body: { email },
|
||||
});
|
||||
},
|
||||
|
||||
async verifyToken(
|
||||
verifyToken(
|
||||
token: string,
|
||||
identifier: string,
|
||||
accessToken?: string,
|
||||
@@ -1123,11 +1123,11 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async getDidDocument(token: string): Promise<DidDocument> {
|
||||
getDidDocument(token: string): Promise<DidDocument> {
|
||||
return xrpc("_account.getDidDocument", { token });
|
||||
},
|
||||
|
||||
async updateDidDocument(
|
||||
updateDidDocument(
|
||||
token: string,
|
||||
params: {
|
||||
verificationMethods?: VerificationMethod[];
|
||||
@@ -1170,7 +1170,7 @@ export const api = {
|
||||
return res.arrayBuffer();
|
||||
},
|
||||
|
||||
async listBackups(token: string): Promise<{
|
||||
listBackups(token: string): Promise<{
|
||||
backups: Array<{
|
||||
id: string;
|
||||
repoRev: string;
|
||||
@@ -1199,7 +1199,7 @@ export const api = {
|
||||
return res.blob();
|
||||
},
|
||||
|
||||
async createBackup(token: string): Promise<{
|
||||
createBackup(token: string): Promise<{
|
||||
id: string;
|
||||
repoRev: string;
|
||||
sizeBytes: number;
|
||||
@@ -1219,7 +1219,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async setBackupEnabled(
|
||||
setBackupEnabled(
|
||||
token: string,
|
||||
enabled: boolean,
|
||||
): Promise<{ enabled: boolean }> {
|
||||
|
||||
@@ -40,7 +40,7 @@ interface AuthState {
|
||||
savedAccounts: SavedAccount[];
|
||||
}
|
||||
|
||||
let state = $state<AuthState>({
|
||||
const state = $state<AuthState>({
|
||||
session: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
@@ -318,11 +318,18 @@ export function setSession(
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
if (state.session) {
|
||||
const did = state.session.did;
|
||||
const refreshToken = state.session.refreshJwt;
|
||||
try {
|
||||
await api.deleteSession(state.session.accessJwt);
|
||||
await fetch("/oauth/revoke", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({ token: refreshToken }),
|
||||
});
|
||||
} catch {
|
||||
// Ignore errors on logout
|
||||
}
|
||||
removeSavedAccount(did);
|
||||
}
|
||||
state.session = null;
|
||||
saveSession(null);
|
||||
|
||||
@@ -188,11 +188,11 @@ export class AtprotoClient {
|
||||
return session;
|
||||
}
|
||||
|
||||
async describeServer(): Promise<ServerDescription> {
|
||||
describeServer(): Promise<ServerDescription> {
|
||||
return this.xrpc<ServerDescription>("com.atproto.server.describeServer");
|
||||
}
|
||||
|
||||
async getServiceAuth(
|
||||
getServiceAuth(
|
||||
aud: string,
|
||||
lxm?: string,
|
||||
): Promise<{ token: string }> {
|
||||
@@ -203,7 +203,7 @@ export class AtprotoClient {
|
||||
return this.xrpc("com.atproto.server.getServiceAuth", { params });
|
||||
}
|
||||
|
||||
async getRepo(did: string): Promise<Uint8Array> {
|
||||
getRepo(did: string): Promise<Uint8Array> {
|
||||
return this.xrpc("com.atproto.sync.getRepo", {
|
||||
params: { did },
|
||||
});
|
||||
@@ -662,6 +662,61 @@ export function buildOAuthAuthorizationUrl(
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export async function initiateOAuthWithPAR(
|
||||
metadata: OAuthServerMetadata,
|
||||
params: {
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
codeChallenge: string;
|
||||
state: string;
|
||||
scope?: string;
|
||||
dpopJkt?: string;
|
||||
loginHint?: string;
|
||||
},
|
||||
): Promise<string> {
|
||||
if (!metadata.pushed_authorization_request_endpoint) {
|
||||
return buildOAuthAuthorizationUrl(metadata, params);
|
||||
}
|
||||
|
||||
const body = new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: params.clientId,
|
||||
redirect_uri: params.redirectUri,
|
||||
code_challenge: params.codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
state: params.state,
|
||||
scope: params.scope ?? "atproto",
|
||||
});
|
||||
|
||||
if (params.dpopJkt) {
|
||||
body.set("dpop_jkt", params.dpopJkt);
|
||||
}
|
||||
if (params.loginHint) {
|
||||
body.set("login_hint", params.loginHint);
|
||||
}
|
||||
|
||||
const res = await fetch(metadata.pushed_authorization_request_endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({
|
||||
error: "par_error",
|
||||
error_description: res.statusText,
|
||||
}));
|
||||
throw new Error(err.error_description || err.error || "PAR request failed");
|
||||
}
|
||||
|
||||
const { request_uri } = await res.json();
|
||||
|
||||
const authUrl = new URL(metadata.authorization_endpoint);
|
||||
authUrl.searchParams.set("client_id", params.clientId);
|
||||
authUrl.searchParams.set("request_uri", request_uri);
|
||||
return authUrl.toString();
|
||||
}
|
||||
|
||||
export async function exchangeOAuthCode(
|
||||
metadata: OAuthServerMetadata,
|
||||
params: {
|
||||
@@ -839,7 +894,7 @@ export function getMigrationOAuthClientId(): string {
|
||||
}
|
||||
|
||||
export function getMigrationOAuthRedirectUri(): string {
|
||||
return `${globalThis.location.origin}/migrate`;
|
||||
return `${globalThis.location.origin}/app/migrate`;
|
||||
}
|
||||
|
||||
export interface DPoPKeyPair {
|
||||
|
||||
@@ -107,8 +107,7 @@ export async function migrateBlobs(
|
||||
errorMessage,
|
||||
);
|
||||
|
||||
const isNetworkError =
|
||||
errorMessage.includes("fetch") ||
|
||||
const isNetworkError = errorMessage.includes("fetch") ||
|
||||
errorMessage.includes("network") ||
|
||||
errorMessage.includes("CORS") ||
|
||||
errorMessage.includes("Failed to fetch") ||
|
||||
@@ -124,7 +123,9 @@ export async function migrateBlobs(
|
||||
if (migrated > 0) {
|
||||
onProgress({
|
||||
currentOperation:
|
||||
`Source PDS unreachable (browser security restriction). ${migrated} media files migrated successfully. ${remaining + 1} could not be fetched - these may need to be re-uploaded.`,
|
||||
`Source PDS unreachable (browser security restriction). ${migrated} media files migrated successfully. ${
|
||||
remaining + 1
|
||||
} could not be fetched - these may need to be re-uploaded.`,
|
||||
});
|
||||
} else {
|
||||
onProgress({
|
||||
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
} from "./types";
|
||||
import {
|
||||
AtprotoClient,
|
||||
buildOAuthAuthorizationUrl,
|
||||
clearDPoPKey,
|
||||
createLocalClient,
|
||||
exchangeOAuthCode,
|
||||
@@ -18,6 +17,7 @@ import {
|
||||
getMigrationOAuthClientId,
|
||||
getMigrationOAuthRedirectUri,
|
||||
getOAuthServerMetadata,
|
||||
initiateOAuthWithPAR,
|
||||
loadDPoPKey,
|
||||
resolvePdsUrl,
|
||||
saveDPoPKey,
|
||||
@@ -84,8 +84,6 @@ export function createInboundMigrationFlow() {
|
||||
let sourceClient: AtprotoClient | null = null;
|
||||
let localClient: AtprotoClient | null = null;
|
||||
let localServerInfo: ServerDescription | null = null;
|
||||
let sourceOAuthMetadata: Awaited<ReturnType<typeof getOAuthServerMetadata>> =
|
||||
null;
|
||||
|
||||
function setStep(step: InboundStep) {
|
||||
state.step = step;
|
||||
@@ -141,7 +139,6 @@ export function createInboundMigrationFlow() {
|
||||
"Source PDS does not support OAuth. This PDS only supports OAuth-based migrations.",
|
||||
);
|
||||
}
|
||||
sourceOAuthMetadata = metadata;
|
||||
|
||||
const { codeVerifier, codeChallenge } = await generatePKCE();
|
||||
const oauthState = generateOAuthState();
|
||||
@@ -155,8 +152,11 @@ export function createInboundMigrationFlow() {
|
||||
localStorage.setItem("migration_source_did", state.sourceDid);
|
||||
localStorage.setItem("migration_source_handle", state.sourceHandle);
|
||||
localStorage.setItem("migration_oauth_issuer", metadata.issuer);
|
||||
if (state.resumeToStep) {
|
||||
localStorage.setItem("migration_resume_to_step", state.resumeToStep);
|
||||
}
|
||||
|
||||
const authUrl = buildOAuthAuthorizationUrl(metadata, {
|
||||
const authUrl = await initiateOAuthWithPAR(metadata, {
|
||||
clientId: getMigrationOAuthClientId(),
|
||||
redirectUri: getMigrationOAuthRedirectUri(),
|
||||
codeChallenge,
|
||||
@@ -185,6 +185,7 @@ export function createInboundMigrationFlow() {
|
||||
localStorage.removeItem("migration_source_did");
|
||||
localStorage.removeItem("migration_source_handle");
|
||||
localStorage.removeItem("migration_oauth_issuer");
|
||||
localStorage.removeItem("migration_resume_to_step");
|
||||
}
|
||||
|
||||
async function handleOAuthCallback(
|
||||
@@ -199,6 +200,12 @@ export function createInboundMigrationFlow() {
|
||||
const sourceDid = localStorage.getItem("migration_source_did");
|
||||
const sourceHandle = localStorage.getItem("migration_source_handle");
|
||||
const oauthIssuer = localStorage.getItem("migration_oauth_issuer");
|
||||
const savedResumeToStep = localStorage.getItem("migration_resume_to_step");
|
||||
|
||||
if (savedResumeToStep) {
|
||||
state.needsReauth = true;
|
||||
state.resumeToStep = savedResumeToStep as InboundMigrationState["step"];
|
||||
}
|
||||
|
||||
if (returnedState !== savedState) {
|
||||
cleanupOAuthSessionData();
|
||||
@@ -229,7 +236,6 @@ export function createInboundMigrationFlow() {
|
||||
cleanupOAuthSessionData();
|
||||
throw new Error("Could not fetch OAuth server metadata");
|
||||
}
|
||||
sourceOAuthMetadata = metadata;
|
||||
|
||||
migrationLog("handleOAuthCallback: Exchanging code for tokens");
|
||||
|
||||
@@ -269,8 +275,8 @@ export function createInboundMigrationFlow() {
|
||||
];
|
||||
|
||||
if (postEmailSteps.includes(targetStep)) {
|
||||
localClient = createLocalClient();
|
||||
if (state.authMethod === "passkey" && state.passkeySetupToken) {
|
||||
localClient = createLocalClient();
|
||||
setStep("passkey-setup");
|
||||
migrationLog(
|
||||
"handleOAuthCallback: Resuming passkey flow at passkey-setup",
|
||||
@@ -281,6 +287,10 @@ export function createInboundMigrationFlow() {
|
||||
"handleOAuthCallback: Resuming at email-verify for re-auth",
|
||||
);
|
||||
}
|
||||
} else if (targetStep === "email-verify") {
|
||||
localClient = createLocalClient();
|
||||
setStep("email-verify");
|
||||
migrationLog("handleOAuthCallback: Resuming at email-verify");
|
||||
} else {
|
||||
setStep(targetStep);
|
||||
}
|
||||
@@ -550,21 +560,34 @@ export function createInboundMigrationFlow() {
|
||||
|
||||
async function checkEmailVerifiedAndProceed(): Promise<boolean> {
|
||||
if (checkingEmailVerification) return false;
|
||||
if (!sourceClient || !localClient) return false;
|
||||
|
||||
if (state.authMethod === "passkey") {
|
||||
return false;
|
||||
}
|
||||
if (!localClient) return false;
|
||||
|
||||
checkingEmailVerification = true;
|
||||
try {
|
||||
const verified = await localClient.checkEmailVerified(state.targetEmail);
|
||||
if (!verified) return false;
|
||||
|
||||
if (state.authMethod === "passkey") {
|
||||
migrationLog(
|
||||
"checkEmailVerifiedAndProceed: Email verified, proceeding to passkey setup",
|
||||
);
|
||||
setStep("passkey-setup");
|
||||
return true;
|
||||
}
|
||||
|
||||
await localClient.loginDeactivated(
|
||||
state.targetEmail,
|
||||
state.targetPassword,
|
||||
);
|
||||
|
||||
if (!sourceClient) {
|
||||
setStep("source-handle");
|
||||
setError(
|
||||
"Email verified! Please log in to your old account again to complete the migration.",
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (state.sourceDid.startsWith("did:web:")) {
|
||||
const credentials = await localClient.getRecommendedDidCredentials();
|
||||
state.targetVerificationMethod =
|
||||
@@ -856,7 +879,6 @@ export function createInboundMigrationFlow() {
|
||||
};
|
||||
sourceClient = null;
|
||||
passkeySetup = null;
|
||||
sourceOAuthMetadata = null;
|
||||
clearMigrationState();
|
||||
clearDPoPKey();
|
||||
}
|
||||
|
||||
@@ -254,11 +254,13 @@ export interface OAuthServerMetadata {
|
||||
issuer: string;
|
||||
authorization_endpoint: string;
|
||||
token_endpoint: string;
|
||||
pushed_authorization_request_endpoint?: string;
|
||||
scopes_supported?: string[];
|
||||
response_types_supported?: string[];
|
||||
grant_types_supported?: string[];
|
||||
code_challenge_methods_supported?: string[];
|
||||
dpop_signing_alg_values_supported?: string[];
|
||||
require_pushed_authorization_requests?: boolean;
|
||||
}
|
||||
|
||||
export interface OAuthTokenResponse {
|
||||
|
||||
@@ -10,7 +10,7 @@ const SCOPES = [
|
||||
const CLIENT_ID = !(import.meta.env.DEV)
|
||||
? `${globalThis.location.origin}/oauth/client-metadata.json`
|
||||
: `http://localhost/?scope=${SCOPES}`;
|
||||
const REDIRECT_URI = `${globalThis.location.origin}/`;
|
||||
const REDIRECT_URI = `${globalThis.location.origin}/app/`;
|
||||
|
||||
interface OAuthState {
|
||||
state: string;
|
||||
@@ -26,7 +26,7 @@ function generateRandomString(length: number): string {
|
||||
);
|
||||
}
|
||||
|
||||
async function sha256(plain: string): Promise<ArrayBuffer> {
|
||||
function sha256(plain: string): Promise<ArrayBuffer> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(plain);
|
||||
return crypto.subtle.digest("SHA-256", data);
|
||||
@@ -191,7 +191,7 @@ export async function refreshOAuthToken(
|
||||
export function checkForOAuthCallback():
|
||||
| { code: string; state: string }
|
||||
| null {
|
||||
if (globalThis.location.hash === "#/migrate") {
|
||||
if (globalThis.location.pathname === "/app/migrate") {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ export function createRegistrationFlow(
|
||||
mode: RegistrationMode,
|
||||
pdsHostname: string,
|
||||
) {
|
||||
let state = $state<RegistrationFlowState>({
|
||||
const state = $state<RegistrationFlowState>({
|
||||
mode,
|
||||
step: "info",
|
||||
info: {
|
||||
@@ -80,7 +80,7 @@ export function createRegistrationFlow(
|
||||
}
|
||||
}
|
||||
|
||||
async function proceedFromInfo() {
|
||||
function proceedFromInfo() {
|
||||
state.error = null;
|
||||
if (state.info.didType === "web-external") {
|
||||
state.step = "key-choice";
|
||||
@@ -130,7 +130,7 @@ export function createRegistrationFlow(
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmInitialDidDoc() {
|
||||
function confirmInitialDidDoc() {
|
||||
state.step = "creating";
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,34 @@
|
||||
let currentPath = $state(
|
||||
getPathWithoutQuery(globalThis.location.hash.slice(1) || "/"),
|
||||
);
|
||||
const APP_BASE = "/app";
|
||||
|
||||
function getPathWithoutQuery(hash: string): string {
|
||||
const queryIndex = hash.indexOf("?");
|
||||
return queryIndex === -1 ? hash : hash.slice(0, queryIndex);
|
||||
function getAppPath(): string {
|
||||
const pathname = globalThis.location.pathname;
|
||||
if (pathname.startsWith(APP_BASE)) {
|
||||
const path = pathname.slice(APP_BASE.length) || "/";
|
||||
return path.startsWith("/") ? path : "/" + path;
|
||||
}
|
||||
return "/";
|
||||
}
|
||||
|
||||
globalThis.addEventListener("hashchange", () => {
|
||||
currentPath = getPathWithoutQuery(globalThis.location.hash.slice(1) || "/");
|
||||
let currentPath = $state(getAppPath());
|
||||
|
||||
globalThis.addEventListener("popstate", () => {
|
||||
currentPath = getAppPath();
|
||||
});
|
||||
|
||||
export function navigate(path: string) {
|
||||
currentPath = path;
|
||||
globalThis.location.hash = path;
|
||||
export function navigate(path: string, replace = false) {
|
||||
const fullPath = APP_BASE + (path.startsWith("/") ? path : "/" + path);
|
||||
if (replace) {
|
||||
globalThis.history.replaceState(null, "", fullPath);
|
||||
} else {
|
||||
globalThis.history.pushState(null, "", fullPath);
|
||||
}
|
||||
currentPath = path.startsWith("/") ? path : "/" + path;
|
||||
}
|
||||
|
||||
export function getCurrentPath() {
|
||||
return currentPath;
|
||||
}
|
||||
|
||||
export function getFullUrl(path: string): string {
|
||||
return APP_BASE + (path.startsWith("/") ? path : "/" + path);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ interface ServerConfigState {
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
let state = $state<ServerConfigState>({
|
||||
const state = $state<ServerConfigState>({
|
||||
serverName: null,
|
||||
primaryColor: null,
|
||||
primaryColorDark: null,
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
let actAsInProgress = $state(false)
|
||||
|
||||
function getDid(): string | null {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
return params.get('did')
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
|
||||
const parData = await parResponse.json()
|
||||
if (parData.request_uri) {
|
||||
window.location.href = `/#/oauth/login?request_uri=${encodeURIComponent(parData.request_uri)}`
|
||||
window.location.href = `/app/oauth/login?request_uri=${encodeURIComponent(parData.request_uri)}`
|
||||
} else {
|
||||
error = $_('actAs.invalidResponse')
|
||||
loading = false
|
||||
|
||||
@@ -308,7 +308,7 @@
|
||||
{#if auth.session?.isAdmin}
|
||||
<div class="page">
|
||||
<header>
|
||||
<a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
<h1>{$_('admin.title')}</h1>
|
||||
</header>
|
||||
{#if loading}
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
</script>
|
||||
<div class="page">
|
||||
<header>
|
||||
<a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
<h1>{$_('appPasswords.title')}</h1>
|
||||
</header>
|
||||
<p class="description">
|
||||
|
||||
@@ -168,7 +168,7 @@
|
||||
</script>
|
||||
<div class="page">
|
||||
<header>
|
||||
<a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
<h1>{$_('comms.title')}</h1>
|
||||
<p class="description">{$_('comms.description')}</p>
|
||||
</header>
|
||||
|
||||
@@ -232,7 +232,7 @@
|
||||
|
||||
<div class="page">
|
||||
<header>
|
||||
<a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
<h1>{$_('delegation.title')}</h1>
|
||||
</header>
|
||||
|
||||
@@ -358,7 +358,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="item-actions">
|
||||
<a href="/#/act-as?did={encodeURIComponent(account.did)}" class="btn-link">
|
||||
<a href="/app/act-as?did={encodeURIComponent(account.did)}" class="btn-link">
|
||||
{$_('delegation.actAs')}
|
||||
</a>
|
||||
</div>
|
||||
@@ -423,7 +423,7 @@
|
||||
<h2>{$_('delegation.auditLog')}</h2>
|
||||
<p class="section-description">{$_('delegation.auditLogDesc')}</p>
|
||||
</div>
|
||||
<a href="#/delegation-audit" class="btn-link">{$_('delegation.viewAuditLog')}</a>
|
||||
<a href="/app/delegation-audit" class="btn-link">{$_('delegation.viewAuditLog')}</a>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -166,73 +166,73 @@
|
||||
|
||||
<nav class="nav-grid">
|
||||
{#if auth.session.status === 'migrated'}
|
||||
<a href="#/did-document" class="nav-card migrated-card">
|
||||
<a href="/app/did-document" class="nav-card migrated-card">
|
||||
<h3>{$_('dashboard.navDidDocument')}</h3>
|
||||
<p>{$_('dashboard.navDidDocumentDesc')}</p>
|
||||
</a>
|
||||
<a href="#/sessions" class="nav-card">
|
||||
<a href="/app/sessions" class="nav-card">
|
||||
<h3>{$_('dashboard.navSessions')}</h3>
|
||||
<p>{$_('dashboard.navSessionsDesc')}</p>
|
||||
</a>
|
||||
<a href="#/security" class="nav-card">
|
||||
<a href="/app/security" class="nav-card">
|
||||
<h3>{$_('dashboard.navSecurity')}</h3>
|
||||
<p>{$_('dashboard.navSecurityDesc')}</p>
|
||||
</a>
|
||||
<a href="#/settings" class="nav-card">
|
||||
<a href="/app/settings" class="nav-card">
|
||||
<h3>{$_('dashboard.navSettings')}</h3>
|
||||
<p>{$_('dashboard.navSettingsDesc')}</p>
|
||||
</a>
|
||||
<a href="#/migrate" class="nav-card">
|
||||
<a href="/app/migrate" class="nav-card">
|
||||
<h3>{$_('dashboard.navMigrateAgain')}</h3>
|
||||
<p>{$_('dashboard.navMigrateAgainDesc')}</p>
|
||||
</a>
|
||||
{:else}
|
||||
<a href="#/app-passwords" class="nav-card">
|
||||
<a href="/app/app-passwords" class="nav-card">
|
||||
<h3>{$_('dashboard.navAppPasswords')}</h3>
|
||||
<p>{$_('dashboard.navAppPasswordsDesc')}</p>
|
||||
</a>
|
||||
<a href="#/sessions" class="nav-card">
|
||||
<a href="/app/sessions" class="nav-card">
|
||||
<h3>{$_('dashboard.navSessions')}</h3>
|
||||
<p>{$_('dashboard.navSessionsDesc')}</p>
|
||||
</a>
|
||||
{#if inviteCodesEnabled && auth.session.isAdmin}
|
||||
<a href="#/invite-codes" class="nav-card">
|
||||
<a href="/app/invite-codes" class="nav-card">
|
||||
<h3>{$_('dashboard.navInviteCodes')}</h3>
|
||||
<p>{$_('dashboard.navInviteCodesDesc')}</p>
|
||||
</a>
|
||||
{/if}
|
||||
<a href="#/settings" class="nav-card">
|
||||
<a href="/app/settings" class="nav-card">
|
||||
<h3>{$_('dashboard.navSettings')}</h3>
|
||||
<p>{$_('dashboard.navSettingsDesc')}</p>
|
||||
</a>
|
||||
<a href="#/security" class="nav-card">
|
||||
<a href="/app/security" class="nav-card">
|
||||
<h3>{$_('dashboard.navSecurity')}</h3>
|
||||
<p>{$_('dashboard.navSecurityDesc')}</p>
|
||||
</a>
|
||||
<a href="#/comms" class="nav-card">
|
||||
<a href="/app/comms" class="nav-card">
|
||||
<h3>{$_('dashboard.navComms')}</h3>
|
||||
<p>{$_('dashboard.navCommsDesc')}</p>
|
||||
</a>
|
||||
<a href="#/repo" class="nav-card">
|
||||
<a href="/app/repo" class="nav-card">
|
||||
<h3>{$_('dashboard.navRepo')}</h3>
|
||||
<p>{$_('dashboard.navRepoDesc')}</p>
|
||||
</a>
|
||||
<a href="#/controllers" class="nav-card">
|
||||
<a href="/app/controllers" class="nav-card">
|
||||
<h3>{$_('dashboard.navDelegation')}</h3>
|
||||
<p>{$_('dashboard.navDelegationDesc')}</p>
|
||||
</a>
|
||||
{#if isDidWeb}
|
||||
<a href="#/did-document" class="nav-card did-web-card">
|
||||
<a href="/app/did-document" class="nav-card did-web-card">
|
||||
<h3>{$_('dashboard.navDidDocument')}</h3>
|
||||
<p>{$_('dashboard.navDidDocumentDescActive')}</p>
|
||||
</a>
|
||||
{/if}
|
||||
<a href="#/migrate" class="nav-card">
|
||||
<a href="/app/migrate" class="nav-card">
|
||||
<h3>{$_('migration.navTitle')}</h3>
|
||||
<p>{$_('migration.navDesc')}</p>
|
||||
</a>
|
||||
{#if auth.session.isAdmin}
|
||||
<a href="#/admin" class="nav-card admin-card">
|
||||
<a href="/app/admin" class="nav-card admin-card">
|
||||
<h3>{$_('dashboard.navAdmin')}</h3>
|
||||
<p>{$_('dashboard.navAdminDesc')}</p>
|
||||
</a>
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
|
||||
<div class="page">
|
||||
<header>
|
||||
<a href="#/controllers" class="back">{$_('delegation.backToControllers')}</a>
|
||||
<a href="/app/controllers" class="back">{$_('delegation.backToControllers')}</a>
|
||||
<h1>{$_('delegation.auditLogTitle')}</h1>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
|
||||
<div class="page">
|
||||
<header>
|
||||
<a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
<h1>{$_('didEditor.title')}</h1>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -1,527 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { _ } from '../lib/i18n'
|
||||
import { getAuthState } from '../lib/auth.svelte'
|
||||
import { getServerConfigState } from '../lib/serverConfig.svelte'
|
||||
import { api } from '../lib/api'
|
||||
|
||||
const auth = getAuthState()
|
||||
const serverConfig = getServerConfigState()
|
||||
const sourceUrl = 'https://tangled.org/lewis.moe/bspds-sandbox'
|
||||
|
||||
let pdsHostname = $state<string | null>(null)
|
||||
let pdsVersion = $state<string | null>(null)
|
||||
let userCount = $state<number | null>(null)
|
||||
|
||||
const heroWords = ['Bluesky', 'Tangled', 'Leaflet', 'ATProto']
|
||||
const wordSpacing: Record<string, string> = {
|
||||
'Bluesky': '0.01em',
|
||||
'Tangled': '0.02em',
|
||||
'Leaflet': '0.05em',
|
||||
'ATProto': '0',
|
||||
}
|
||||
let currentWordIndex = $state(0)
|
||||
let isTransitioning = $state(false)
|
||||
let currentWord = $derived(heroWords[currentWordIndex])
|
||||
let currentSpacing = $derived(wordSpacing[currentWord] || '0')
|
||||
|
||||
onMount(() => {
|
||||
api.describeServer().then(info => {
|
||||
if (info.availableUserDomains?.length) {
|
||||
pdsHostname = info.availableUserDomains[0]
|
||||
}
|
||||
if (info.version) {
|
||||
pdsVersion = info.version
|
||||
}
|
||||
}).catch(() => {})
|
||||
|
||||
const baseDuration = 2000
|
||||
let wordTimeout: ReturnType<typeof setTimeout>
|
||||
|
||||
function cycleWord() {
|
||||
isTransitioning = true
|
||||
setTimeout(() => {
|
||||
currentWordIndex = (currentWordIndex + 1) % heroWords.length
|
||||
isTransitioning = false
|
||||
const duration = heroWords[currentWordIndex] === 'ATProto' ? baseDuration * 2 : baseDuration
|
||||
wordTimeout = setTimeout(cycleWord, duration)
|
||||
}, 100)
|
||||
}
|
||||
|
||||
wordTimeout = setTimeout(cycleWord, baseDuration)
|
||||
|
||||
api.listRepos(1000).then(data => {
|
||||
userCount = data.repos.length
|
||||
}).catch(() => {})
|
||||
|
||||
const pattern = document.getElementById('dotPattern')
|
||||
if (!pattern) return
|
||||
|
||||
const spacing = 32
|
||||
const cols = Math.ceil((window.innerWidth + 600) / spacing)
|
||||
const rows = Math.ceil((window.innerHeight + 100) / spacing)
|
||||
const dots: { el: HTMLElement; x: number; y: number }[] = []
|
||||
|
||||
for (let y = 0; y < rows; y++) {
|
||||
for (let x = 0; x < cols; x++) {
|
||||
const dot = document.createElement('div')
|
||||
dot.className = 'dot'
|
||||
dot.style.left = (x * spacing) + 'px'
|
||||
dot.style.top = (y * spacing) + 'px'
|
||||
pattern.appendChild(dot)
|
||||
dots.push({ el: dot, x: x * spacing, y: y * spacing })
|
||||
}
|
||||
}
|
||||
|
||||
let mouseX = -1000
|
||||
let mouseY = -1000
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
mouseX = e.clientX
|
||||
mouseY = e.clientY
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
|
||||
let animationId: number
|
||||
|
||||
function updateDots() {
|
||||
const patternRect = pattern.getBoundingClientRect()
|
||||
dots.forEach(dot => {
|
||||
const dotX = patternRect.left + dot.x + 5
|
||||
const dotY = patternRect.top + dot.y + 5
|
||||
const dist = Math.hypot(mouseX - dotX, mouseY - dotY)
|
||||
const maxDist = 120
|
||||
const scale = Math.min(1, Math.max(0.1, dist / maxDist))
|
||||
dot.el.style.transform = `scale(${scale})`
|
||||
})
|
||||
animationId = requestAnimationFrame(updateDots)
|
||||
}
|
||||
updateDots()
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
cancelAnimationFrame(animationId)
|
||||
clearTimeout(wordTimeout)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="pattern-container">
|
||||
<div class="pattern" id="dotPattern"></div>
|
||||
</div>
|
||||
<div class="pattern-fade"></div>
|
||||
|
||||
<nav>
|
||||
<div class="nav-left">
|
||||
{#if serverConfig.hasLogo}
|
||||
<img src="/logo" alt="Logo" class="nav-logo" />
|
||||
{/if}
|
||||
{#if pdsHostname}
|
||||
<span class="hostname">{pdsHostname}</span>
|
||||
{#if userCount !== null}
|
||||
<span class="user-count">{userCount} {userCount === 1 ? 'user' : 'users'}</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="hostname placeholder">loading...</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="nav-meta">{pdsVersion || ''}</span>
|
||||
</nav>
|
||||
|
||||
<div class="home">
|
||||
<section class="hero">
|
||||
<h1>A home for your <span class="cycling-word-container"><span class="cycling-word" class:transitioning={isTransitioning} style="letter-spacing: {currentSpacing}">{currentWord}</span></span> account</h1>
|
||||
|
||||
<p class="lede">Tranquil PDS is a Personal Data Server, the thing that stores your posts, profile, and keys. Bluesky runs one for you, but you can run your own.</p>
|
||||
|
||||
<div class="actions">
|
||||
{#if auth.session}
|
||||
<a href="#/dashboard" class="btn primary">@{auth.session.handle}</a>
|
||||
{:else}
|
||||
<a href="#/register" class="btn primary">Join This Server</a>
|
||||
<a href={sourceUrl} class="btn secondary" target="_blank" rel="noopener">Run Your Own</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<blockquote>
|
||||
<p>"Nature does not hurry, yet everything is accomplished."</p>
|
||||
<cite>Lao Tzu</cite>
|
||||
</blockquote>
|
||||
</section>
|
||||
|
||||
<section class="content">
|
||||
<h2>What you get</h2>
|
||||
|
||||
<div class="features">
|
||||
<div class="feature">
|
||||
<h3>Real security</h3>
|
||||
<p>Sign in with passkeys, add two-factor authentication, set up backup codes, and mark devices you trust. Your account stays yours.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<h3>Your own identity</h3>
|
||||
<p>Use your own domain as your handle, or get a subdomain on ours. Either way, your identity moves with you if you ever leave.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<h3>Stay in the loop</h3>
|
||||
<p>Get important alerts where you actually see them: email, Discord, Telegram, or Signal.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<h3>You decide what apps can do</h3>
|
||||
<p>When an app asks for access, you'll see exactly what it wants in plain language. Grant what makes sense, deny what doesn't.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<h3>App passwords with guardrails</h3>
|
||||
<p>Create app passwords that can only do specific things: read-only for feed readers, post-only for bots. Full control over what each password can access.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<h3>Delegate without sharing passwords</h3>
|
||||
<p>Let team members or tools manage your account with specific permission levels. They authenticate with their own credentials, you see everything they do in an audit log.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<h3>Automatic backups</h3>
|
||||
<p>Your repository is backed up daily to object storage. Download any backup or restore with one click. You own your data, even if the worst happens.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Everything in one place</h2>
|
||||
|
||||
<p>Manage your profile, security settings, connected apps, and more from a clean dashboard. No command line or 3rd party apps required.</p>
|
||||
|
||||
<h2>Works with everything</h2>
|
||||
|
||||
<p>Use any ATProto app you already like. Tranquil PDS speaks the same language as Bluesky's servers, so all your favorite clients and tools just work.</p>
|
||||
|
||||
<h2>Ready to try it?</h2>
|
||||
|
||||
<p>Join this server, or grab the source and run your own. Either way, you can migrate an existing account over and your followers, posts, and identity come with you.</p>
|
||||
|
||||
<div class="actions">
|
||||
{#if auth.session}
|
||||
<a href="#/dashboard" class="btn primary">@{auth.session.handle}</a>
|
||||
{:else}
|
||||
<a href="#/register" class="btn primary">Join This Server</a>
|
||||
<a href={sourceUrl} class="btn secondary" target="_blank" rel="noopener">View Source</a>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="site-footer">
|
||||
<span>Made by people who don't take themselves too seriously</span>
|
||||
<span>Open Source: issues & PRs welcome</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.pattern-container {
|
||||
position: fixed;
|
||||
top: -32px;
|
||||
left: -32px;
|
||||
right: -32px;
|
||||
bottom: -32px;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pattern {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: calc(100% + 500px);
|
||||
height: 100%;
|
||||
animation: drift 80s linear infinite;
|
||||
}
|
||||
|
||||
.pattern :global(.dot) {
|
||||
position: absolute;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
border-radius: 50%;
|
||||
transition: transform 0.04s linear;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.pattern :global(.dot) {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.pattern-fade {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(135deg, transparent 50%, var(--bg-primary) 75%);
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
@keyframes drift {
|
||||
0% { transform: translateX(-500px); }
|
||||
100% { transform: translateX(0); }
|
||||
}
|
||||
|
||||
nav {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 32px;
|
||||
right: 32px;
|
||||
background: var(--accent);
|
||||
padding: 10px 18px;
|
||||
z-index: 100;
|
||||
border-radius: var(--radius-xl);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.nav-logo {
|
||||
height: 28px;
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.hostname {
|
||||
font-weight: var(--font-semibold);
|
||||
font-size: var(--text-base);
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-inverse);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hostname.placeholder {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.user-count {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-inverse);
|
||||
opacity: 0.85;
|
||||
padding: 4px 10px;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: var(--radius-md);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.user-count {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.nav-meta {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-inverse);
|
||||
opacity: 0.6;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.home {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
max-width: var(--width-xl);
|
||||
margin: 0 auto;
|
||||
padding: 72px 32px 32px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: var(--space-7) 0 var(--space-8);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--text-4xl);
|
||||
font-weight: var(--font-semibold);
|
||||
line-height: var(--leading-tight);
|
||||
margin-bottom: var(--space-6);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.cycling-word-container {
|
||||
display: inline-block;
|
||||
width: 3.9em;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.cycling-word {
|
||||
display: inline-block;
|
||||
transition: opacity 0.1s ease, transform 0.1s ease;
|
||||
}
|
||||
|
||||
.cycling-word.transitioning {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.lede {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-primary);
|
||||
line-height: var(--leading-relaxed);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
margin-top: var(--space-7);
|
||||
}
|
||||
|
||||
.btn {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: var(--space-4) var(--space-6);
|
||||
border-radius: var(--radius-lg);
|
||||
text-decoration: none;
|
||||
transition: all var(--transition-normal);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: var(--secondary);
|
||||
color: var(--text-inverse);
|
||||
border-color: var(--secondary);
|
||||
}
|
||||
|
||||
.btn.primary:hover {
|
||||
background: var(--secondary-hover);
|
||||
border-color: var(--secondary-hover);
|
||||
}
|
||||
|
||||
.btn.secondary {
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.btn.secondary:hover {
|
||||
background: var(--secondary-muted);
|
||||
border-color: var(--secondary);
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: var(--space-8) 0 0 0;
|
||||
padding: var(--space-6);
|
||||
background: var(--accent-muted);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 0 var(--radius-xl) var(--radius-xl) 0;
|
||||
}
|
||||
|
||||
blockquote p {
|
||||
font-size: var(--text-lg);
|
||||
color: var(--text-primary);
|
||||
font-style: italic;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
blockquote cite {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
font-style: normal;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.content h2 {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-bold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--accent-light);
|
||||
margin: var(--space-8) 0 var(--space-5);
|
||||
}
|
||||
|
||||
.content h2:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.content > p {
|
||||
font-size: var(--text-base);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-5);
|
||||
line-height: var(--leading-relaxed);
|
||||
}
|
||||
|
||||
.features {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--space-6);
|
||||
margin: var(--space-6) 0 var(--space-8);
|
||||
}
|
||||
|
||||
.feature {
|
||||
padding: var(--space-5);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-xl);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.feature h3 {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.feature p {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
line-height: var(--leading-relaxed);
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.features {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--text-3xl);
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.user-count,
|
||||
.nav-meta {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
margin-top: var(--space-9);
|
||||
padding-top: var(--space-7);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
</style>
|
||||
@@ -87,7 +87,7 @@
|
||||
</script>
|
||||
<div class="page">
|
||||
<header>
|
||||
<a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
<h1>{$_('inviteCodes.title')}</h1>
|
||||
</header>
|
||||
<p class="description">
|
||||
|
||||
@@ -161,13 +161,13 @@
|
||||
</button>
|
||||
|
||||
<p class="forgot-links">
|
||||
<a href="#/reset-password">{$_('login.forgotPassword')}</a>
|
||||
<a href="/app/reset-password">{$_('login.forgotPassword')}</a>
|
||||
<span class="separator">·</span>
|
||||
<a href="#/request-passkey-recovery">{$_('login.lostPasskey')}</a>
|
||||
<a href="/app/request-passkey-recovery">{$_('login.lostPasskey')}</a>
|
||||
</p>
|
||||
|
||||
<p class="link-text">
|
||||
{$_('login.noAccount')} <a href="#/register">{$_('login.createAccount')}</a>
|
||||
{$_('login.noAccount')} <a href="/app/register">{$_('login.createAccount')}</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -39,17 +39,22 @@
|
||||
if (errorParam) {
|
||||
oauthCallbackProcessed = true
|
||||
oauthError = errorDescription || errorParam
|
||||
window.history.replaceState({}, '', '/#/migrate')
|
||||
window.history.replaceState({}, '', '/app/migrate')
|
||||
return
|
||||
}
|
||||
|
||||
if (code && state) {
|
||||
oauthCallbackProcessed = true
|
||||
window.history.replaceState({}, '', '/#/migrate')
|
||||
window.history.replaceState({}, '', '/app/migrate')
|
||||
direction = 'inbound'
|
||||
oauthLoading = true
|
||||
inboundFlow = createInboundMigrationFlow()
|
||||
|
||||
const stored = loadMigrationState()
|
||||
if (stored && stored.direction === 'inbound') {
|
||||
inboundFlow.resumeFromState(stored)
|
||||
}
|
||||
|
||||
inboundFlow.handleOAuthCallback(code, state)
|
||||
.then(() => {
|
||||
oauthLoading = false
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
let error = $state<string | null>(null)
|
||||
|
||||
function getRequestUri(): string | null {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
return params.get('request_uri')
|
||||
}
|
||||
|
||||
function getChannel(): string {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
return params.get('channel') || 'email'
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
let accounts = $state<AccountInfo[]>([])
|
||||
|
||||
function getRequestUri(): string | null {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
return params.get('request_uri')
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
let rememberChoice = $state(false)
|
||||
|
||||
function getRequestUri(): string | null {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
return params.get('request_uri')
|
||||
}
|
||||
|
||||
|
||||
@@ -21,12 +21,12 @@
|
||||
})
|
||||
|
||||
function getRequestUri(): string | null {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
return params.get('request_uri')
|
||||
}
|
||||
|
||||
function getDelegatedDid(): string | null {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
return params.get('delegated_did')
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
import { _ } from '../lib/i18n'
|
||||
|
||||
function getError(): string {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
return params.get('error') || 'Unknown error'
|
||||
}
|
||||
|
||||
function getErrorDescription(): string | null {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
return params.get('error_description')
|
||||
}
|
||||
|
||||
|
||||
@@ -22,12 +22,12 @@
|
||||
})
|
||||
|
||||
function getRequestUri(): string | null {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
return params.get('request_uri')
|
||||
}
|
||||
|
||||
function getErrorFromUrl(): string | null {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
return params.get('error')
|
||||
}
|
||||
|
||||
@@ -456,7 +456,7 @@
|
||||
</form>
|
||||
|
||||
<p class="help-links">
|
||||
<a href="#/reset-password">{$_('login.forgotPassword')}</a> · <a href="#/request-passkey-recovery">{$_('login.lostPasskey')}</a>
|
||||
<a href="/app/reset-password">{$_('login.forgotPassword')}</a> · <a href="/app/request-passkey-recovery">{$_('login.lostPasskey')}</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
let autoStarted = $state(false)
|
||||
|
||||
function getRequestUri(): string | null {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
return params.get('request_uri')
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
let error = $state<string | null>(null)
|
||||
|
||||
function getRequestUri(): string | null {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
return params.get('request_uri')
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
let success = $state(false)
|
||||
|
||||
function getUrlParams(): { did: string | null; token: string | null } {
|
||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
return {
|
||||
did: params.get('did'),
|
||||
token: params.get('token'),
|
||||
|
||||
@@ -174,7 +174,7 @@
|
||||
<div class="migrate-content">
|
||||
<strong>{$_('register.migrateTitle')}</strong>
|
||||
<p>{$_('register.migrateDescription')}</p>
|
||||
<a href="#/migrate" class="migrate-link">
|
||||
<a href="/app/migrate" class="migrate-link">
|
||||
{$_('register.migrateLink')} →
|
||||
</a>
|
||||
</div>
|
||||
@@ -381,10 +381,10 @@
|
||||
|
||||
<div class="form-links">
|
||||
<p class="link-text">
|
||||
{$_('register.alreadyHaveAccount')} <a href="#/login">{$_('register.signIn')}</a>
|
||||
{$_('register.alreadyHaveAccount')} <a href="/app/login">{$_('register.signIn')}</a>
|
||||
</p>
|
||||
<p class="link-text">
|
||||
{$_('register.wantPasswordless')} <a href="#/register-passkey">{$_('register.createPasskeyAccount')}</a>
|
||||
{$_('register.wantPasswordless')} <a href="/app/register-passkey">{$_('register.createPasskeyAccount')}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -413,7 +413,7 @@
|
||||
</form>
|
||||
|
||||
<p class="link-text">
|
||||
{$_('registerPasskey.wantTraditional')} <a href="#/register">{$_('registerPasskey.registerWithPassword')}</a>
|
||||
{$_('registerPasskey.wantTraditional')} <a href="/app/register">{$_('registerPasskey.registerWithPassword')}</a>
|
||||
</p>
|
||||
|
||||
{:else if flow.state.step === 'key-choice'}
|
||||
|
||||
@@ -276,7 +276,7 @@
|
||||
<div class="page">
|
||||
<header>
|
||||
<div class="breadcrumb">
|
||||
<a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
{#if view !== 'collections'}
|
||||
<span class="sep">/</span>
|
||||
<button class="breadcrumb-link" onclick={goBack}>
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
{/if}
|
||||
|
||||
<p class="link-text">
|
||||
<a href="#/login">{$_('common.backToLogin')}</a>
|
||||
<a href="/app/login">{$_('common.backToLogin')}</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@
|
||||
{/if}
|
||||
|
||||
<p class="link-text">
|
||||
<a href="#/login">{$_('common.backToLogin')}</a>
|
||||
<a href="/app/login">{$_('common.backToLogin')}</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -403,7 +403,7 @@
|
||||
|
||||
<div class="page">
|
||||
<header>
|
||||
<a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
<h1>{$_('security.title')}</h1>
|
||||
</header>
|
||||
|
||||
@@ -722,7 +722,7 @@
|
||||
<p class="description">
|
||||
{$_('security.trustedDevicesDescription')}
|
||||
</p>
|
||||
<a href="#/trusted-devices" class="section-link">
|
||||
<a href="/app/trusted-devices" class="section-link">
|
||||
{$_('security.manageTrustedDevices')} →
|
||||
</a>
|
||||
</section>
|
||||
@@ -765,8 +765,8 @@
|
||||
<strong>{$_('security.legacyLoginWarning')}</strong>
|
||||
<p>{$_('security.totpPasswordWarning')}</p>
|
||||
<ol>
|
||||
<li><strong>{$_('security.totpPasswordOption1Label')}</strong> {$_('security.totpPasswordOption1Text')} <a href="#/settings">{$_('security.totpPasswordOption1Link')}</a> {$_('security.totpPasswordOption1Suffix')}</li>
|
||||
<li><strong>{$_('security.totpPasswordOption2Label')}</strong> {$_('security.totpPasswordOption2Text')} <a href="#/settings">{$_('security.totpPasswordOption2Link')}</a> {$_('security.totpPasswordOption2Suffix')}</li>
|
||||
<li><strong>{$_('security.totpPasswordOption1Label')}</strong> {$_('security.totpPasswordOption1Text')} <a href="/app/settings">{$_('security.totpPasswordOption1Link')}</a> {$_('security.totpPasswordOption1Suffix')}</li>
|
||||
<li><strong>{$_('security.totpPasswordOption2Label')}</strong> {$_('security.totpPasswordOption2Text')} <a href="/app/settings">{$_('security.totpPasswordOption2Link')}</a> {$_('security.totpPasswordOption2Suffix')}</li>
|
||||
</ol>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
</script>
|
||||
<div class="page">
|
||||
<header>
|
||||
<a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
<h1>{$_('sessions.title')}</h1>
|
||||
</header>
|
||||
{#if loading}
|
||||
|
||||
@@ -368,7 +368,7 @@
|
||||
</script>
|
||||
<div class="page">
|
||||
<header>
|
||||
<a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
<a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
<h1>{$_('settings.title')}</h1>
|
||||
</header>
|
||||
{#if message}
|
||||
|
||||
@@ -112,7 +112,7 @@
|
||||
|
||||
<div class="page">
|
||||
<header>
|
||||
<a href="#/security" class="back">{$_('trustedDevices.backToSecurity')}</a>
|
||||
<a href="/app/security" class="back">{$_('trustedDevices.backToSecurity')}</a>
|
||||
<h1>{$_('trustedDevices.title')}</h1>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -33,17 +33,10 @@
|
||||
|
||||
|
||||
function parseQueryParams() {
|
||||
const hash = window.location.hash
|
||||
const queryIndex = hash.indexOf('?')
|
||||
if (queryIndex === -1) return {}
|
||||
|
||||
const queryString = hash.slice(queryIndex + 1)
|
||||
const params: Record<string, string> = {}
|
||||
for (const pair of queryString.split('&')) {
|
||||
const [key, value] = pair.split('=')
|
||||
if (key && value) {
|
||||
params[decodeURIComponent(key)] = decodeURIComponent(value)
|
||||
}
|
||||
const searchParams = new URLSearchParams(window.location.search)
|
||||
for (const [key, value] of searchParams.entries()) {
|
||||
params[key] = value
|
||||
}
|
||||
return params
|
||||
}
|
||||
@@ -235,13 +228,13 @@
|
||||
<p class="subtitle">{$_('verify.emailUpdated')}</p>
|
||||
<p class="info-text">{$_('verify.emailUpdatedInfo')}</p>
|
||||
<div class="actions">
|
||||
<a href="#/settings" class="btn">{$_('common.backToSettings')}</a>
|
||||
<a href="/app/settings" class="btn">{$_('common.backToSettings')}</a>
|
||||
</div>
|
||||
{:else if successPurpose === 'migration' || successPurpose === 'signup'}
|
||||
<p class="subtitle">{$_('verify.channelVerified', { values: { channel: channelLabel(successChannel || '') } })}</p>
|
||||
<p class="info-text">{$_('verify.canNowSignIn')}</p>
|
||||
<div class="actions">
|
||||
<a href="#/login" class="btn">{$_('verify.signIn')}</a>
|
||||
<a href="/app/login" class="btn">{$_('verify.signIn')}</a>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="subtitle">
|
||||
@@ -259,7 +252,7 @@
|
||||
{#if !auth.session}
|
||||
<div class="message warning">{$_('verify.emailUpdateRequiresAuth')}</div>
|
||||
<div class="actions">
|
||||
<a href="#/login" class="btn">{$_('verify.signIn')}</a>
|
||||
<a href="/app/login" class="btn">{$_('verify.signIn')}</a>
|
||||
</div>
|
||||
{:else}
|
||||
{#if error}
|
||||
@@ -301,7 +294,7 @@
|
||||
</form>
|
||||
|
||||
<p class="link-text">
|
||||
<a href="#/settings">{$_('common.backToSettings')}</a>
|
||||
<a href="/app/settings">{$_('common.backToSettings')}</a>
|
||||
</p>
|
||||
{/if}
|
||||
{:else if mode === 'token'}
|
||||
@@ -356,7 +349,7 @@
|
||||
</form>
|
||||
|
||||
<p class="link-text">
|
||||
<a href="#/login">{$_('common.backToLogin')}</a>
|
||||
<a href="/app/login">{$_('common.backToLogin')}</a>
|
||||
</p>
|
||||
{:else if pendingVerification}
|
||||
<h1>{$_('verify.title')}</h1>
|
||||
@@ -399,7 +392,7 @@
|
||||
</form>
|
||||
|
||||
<p class="link-text">
|
||||
<a href="#/register" onclick={() => clearPendingVerification()}>{$_('verify.startOver')}</a>
|
||||
<a href="/app/register" onclick={() => clearPendingVerification()}>{$_('verify.startOver')}</a>
|
||||
</p>
|
||||
{:else}
|
||||
<h1>{$_('verify.title')}</h1>
|
||||
@@ -407,8 +400,8 @@
|
||||
<p class="info-text">{$_('verify.noPendingInfo')}</p>
|
||||
|
||||
<div class="actions">
|
||||
<a href="#/register" class="btn">{$_('verify.createAccount')}</a>
|
||||
<a href="#/login" class="btn secondary">{$_('verify.signIn')}</a>
|
||||
<a href="/app/register" class="btn">{$_('verify.createAccount')}</a>
|
||||
<a href="/app/login" class="btn secondary">{$_('verify.signIn')}</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,7 @@ describe("AppPasswords", () => {
|
||||
setupUnauthenticatedUser();
|
||||
render(AppPasswords);
|
||||
await waitFor(() => {
|
||||
expect(globalThis.location.hash).toBe("#/login");
|
||||
expect(globalThis.location.pathname).toBe("/app/login");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -41,7 +41,7 @@ describe("AppPasswords", () => {
|
||||
screen.getByRole("heading", { name: /app passwords/i, level: 1 }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /dashboard/i }))
|
||||
.toHaveAttribute("href", "#/dashboard");
|
||||
.toHaveAttribute("href", "/app/dashboard");
|
||||
expect(screen.getByText(/third-party apps/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -50,11 +50,14 @@ describe("AppPasswords", () => {
|
||||
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: [] });
|
||||
});
|
||||
it("shows loading text while fetching passwords", () => {
|
||||
mockEndpoint(
|
||||
"com.atproto.server.listAppPasswords",
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
setTimeout(() => resolve(jsonResponse({ passwords: [] })), 100)
|
||||
),
|
||||
);
|
||||
render(AppPasswords);
|
||||
expect(screen.getByText(/loading/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ describe("Comms", () => {
|
||||
setupUnauthenticatedUser();
|
||||
render(Comms);
|
||||
await waitFor(() => {
|
||||
expect(globalThis.location.hash).toBe("#/login");
|
||||
expect(globalThis.location.pathname).toBe("/app/login");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -51,7 +51,7 @@ describe("Comms", () => {
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /dashboard/i }))
|
||||
.toHaveAttribute("href", "#/dashboard");
|
||||
.toHaveAttribute("href", "/app/dashboard");
|
||||
expect(screen.getByRole("heading", { name: /preferred channel/i }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: /channel configuration/i }))
|
||||
@@ -71,11 +71,17 @@ describe("Comms", () => {
|
||||
() => jsonResponse({ notifications: [] }),
|
||||
);
|
||||
});
|
||||
it("shows loading text while fetching preferences", async () => {
|
||||
mockEndpoint("_account.getNotificationPrefs", async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
return jsonResponse(mockData.notificationPrefs());
|
||||
});
|
||||
it("shows loading text while fetching preferences", () => {
|
||||
mockEndpoint(
|
||||
"_account.getNotificationPrefs",
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
setTimeout(
|
||||
() => resolve(jsonResponse(mockData.notificationPrefs())),
|
||||
100,
|
||||
)
|
||||
),
|
||||
);
|
||||
render(Comms);
|
||||
expect(screen.getByText(/loading/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ describe("Dashboard", () => {
|
||||
setupUnauthenticatedUser();
|
||||
render(Dashboard);
|
||||
await waitFor(() => {
|
||||
expect(globalThis.location.hash).toBe("#/login");
|
||||
expect(globalThis.location.pathname).toBe("/app/login");
|
||||
});
|
||||
});
|
||||
it("shows loading state while checking auth", () => {
|
||||
@@ -61,10 +61,10 @@ describe("Dashboard", () => {
|
||||
render(Dashboard);
|
||||
await waitFor(() => {
|
||||
const navCards = [
|
||||
{ name: /app passwords/i, href: "#/app-passwords" },
|
||||
{ name: /account settings/i, href: "#/settings" },
|
||||
{ name: /communication preferences/i, href: "#/comms" },
|
||||
{ name: /repository explorer/i, href: "#/repo" },
|
||||
{ name: /app passwords/i, href: "/app/app-passwords" },
|
||||
{ name: /account settings/i, href: "/app/settings" },
|
||||
{ name: /communication preferences/i, href: "/app/comms" },
|
||||
{ name: /repository explorer/i, href: "/app/repo" },
|
||||
];
|
||||
for (const { name, href } of navCards) {
|
||||
const card = screen.getByRole("link", { name });
|
||||
@@ -84,7 +84,7 @@ describe("Dashboard", () => {
|
||||
await waitFor(() => {
|
||||
const inviteCard = screen.getByRole("link", { name: /invite codes/i });
|
||||
expect(inviteCard).toBeInTheDocument();
|
||||
expect(inviteCard).toHaveAttribute("href", "#/invite-codes");
|
||||
expect(inviteCard).toHaveAttribute("href", "/app/invite-codes");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -92,12 +92,12 @@ describe("Dashboard", () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser();
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(mockData.session()));
|
||||
mockEndpoint("com.atproto.server.deleteSession", () => jsonResponse({}));
|
||||
mockEndpoint("/oauth/revoke", () => jsonResponse({}));
|
||||
});
|
||||
it("calls deleteSession and navigates to login on logout", async () => {
|
||||
let deleteSessionCalled = false;
|
||||
mockEndpoint("com.atproto.server.deleteSession", () => {
|
||||
deleteSessionCalled = true;
|
||||
it("calls oauth revoke and navigates to login on logout", async () => {
|
||||
let revokeCalled = false;
|
||||
mockEndpoint("/oauth/revoke", () => {
|
||||
revokeCalled = true;
|
||||
return jsonResponse({});
|
||||
});
|
||||
render(Dashboard);
|
||||
@@ -112,8 +112,8 @@ describe("Dashboard", () => {
|
||||
});
|
||||
await fireEvent.click(screen.getByRole("button", { name: /sign out/i }));
|
||||
await waitFor(() => {
|
||||
expect(deleteSessionCalled).toBe(true);
|
||||
expect(globalThis.location.hash).toBe("#/login");
|
||||
expect(revokeCalled).toBe(true);
|
||||
expect(globalThis.location.pathname).toBe("/app/login");
|
||||
});
|
||||
});
|
||||
it("clears session from localStorage after logout", async () => {
|
||||
|
||||
@@ -14,7 +14,6 @@ describe("Login", () => {
|
||||
beforeEach(() => {
|
||||
clearMocks();
|
||||
setupFetchMock();
|
||||
globalThis.location.hash = "";
|
||||
mockEndpoint(
|
||||
"/oauth/par",
|
||||
() => jsonResponse({ request_uri: "urn:mock:request" }),
|
||||
@@ -47,7 +46,7 @@ describe("Login", () => {
|
||||
expect(screen.getByText(/don't have an account/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /create/i })).toHaveAttribute(
|
||||
"href",
|
||||
"#/register",
|
||||
"/app/register",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -56,9 +55,9 @@ describe("Login", () => {
|
||||
render(Login);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("link", { name: /forgot password/i }))
|
||||
.toHaveAttribute("href", "#/reset-password");
|
||||
.toHaveAttribute("href", "/app/reset-password");
|
||||
expect(screen.getByRole("link", { name: /lost passkey/i }))
|
||||
.toHaveAttribute("href", "#/request-passkey-recovery");
|
||||
.toHaveAttribute("href", "/app/request-passkey-recovery");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -122,7 +121,7 @@ describe("Login", () => {
|
||||
await fireEvent.click(aliceAccount);
|
||||
}
|
||||
await waitFor(() => {
|
||||
expect(globalThis.location.hash).toBe("#/dashboard");
|
||||
expect(globalThis.location.pathname).toBe("/app/dashboard");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -163,13 +162,13 @@ describe("Login", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows verification form when pending verification exists", async () => {
|
||||
it("shows verification form when pending verification exists", () => {
|
||||
render(Login);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loading state", () => {
|
||||
it("shows loading state while auth is initializing", async () => {
|
||||
it("shows loading state while auth is initializing", () => {
|
||||
_testSetState({
|
||||
session: null,
|
||||
loading: true,
|
||||
|
||||
@@ -22,7 +22,7 @@ describe("Settings", () => {
|
||||
setupUnauthenticatedUser();
|
||||
render(Settings);
|
||||
await waitFor(() => {
|
||||
expect(globalThis.location.hash).toBe("#/login");
|
||||
expect(globalThis.location.pathname).toBe("/app/login");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -37,7 +37,7 @@ describe("Settings", () => {
|
||||
screen.getByRole("heading", { name: /account settings/i, level: 1 }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /dashboard/i }))
|
||||
.toHaveAttribute("href", "#/dashboard");
|
||||
.toHaveAttribute("href", "/app/dashboard");
|
||||
expect(screen.getByRole("heading", { name: /change email/i }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: /change handle/i }))
|
||||
@@ -463,7 +463,7 @@ describe("Settings", () => {
|
||||
screen.getByRole("button", { name: /permanently delete account/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(globalThis.location.hash).toBe("#/login");
|
||||
expect(globalThis.location.pathname).toBe("/app/login");
|
||||
});
|
||||
});
|
||||
it("shows cancel button to return to request state", async () => {
|
||||
|
||||
@@ -263,7 +263,7 @@ describe("migration/atproto-client", () => {
|
||||
describe("getMigrationOAuthRedirectUri", () => {
|
||||
it("returns migrate path based on origin", () => {
|
||||
const redirectUri = getMigrationOAuthRedirectUri();
|
||||
expect(redirectUri).toBe(`${globalThis.location.origin}/migrate`);
|
||||
expect(redirectUri).toBe(`${globalThis.location.origin}/app/migrate`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+55
-14
@@ -1,6 +1,44 @@
|
||||
import { vi } from "vitest";
|
||||
import type { AppPassword, InviteCode, Session } from "../lib/api";
|
||||
import { _testSetState } from "../lib/auth.svelte";
|
||||
|
||||
const originalPushState = globalThis.history.pushState.bind(globalThis.history);
|
||||
const originalReplaceState = globalThis.history.replaceState.bind(
|
||||
globalThis.history,
|
||||
);
|
||||
|
||||
globalThis.history.pushState = (
|
||||
data: unknown,
|
||||
unused: string,
|
||||
url?: string | URL | null,
|
||||
) => {
|
||||
originalPushState(data, unused, url);
|
||||
if (url) {
|
||||
const urlStr = typeof url === "string" ? url : url.toString();
|
||||
Object.defineProperty(globalThis.location, "pathname", {
|
||||
value: urlStr.split("?")[0],
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
globalThis.history.replaceState = (
|
||||
data: unknown,
|
||||
unused: string,
|
||||
url?: string | URL | null,
|
||||
) => {
|
||||
originalReplaceState(data, unused, url);
|
||||
if (url) {
|
||||
const urlStr = typeof url === "string" ? url : url.toString();
|
||||
Object.defineProperty(globalThis.location, "pathname", {
|
||||
value: urlStr.split("?")[0],
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export interface MockResponse {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
@@ -49,23 +87,26 @@ export function setupFetchMock(): void {
|
||||
clone: () => ({ ...result }) as Response,
|
||||
body: null,
|
||||
bodyUsed: false,
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
blob: async () => new Blob(),
|
||||
formData: async () => new FormData(),
|
||||
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
|
||||
blob: () => Promise.resolve(new Blob()),
|
||||
formData: () => Promise.resolve(new FormData()),
|
||||
} as Response;
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({
|
||||
error: "NotFound",
|
||||
message: `No mock for ${endpoint}`,
|
||||
}),
|
||||
text: async () =>
|
||||
JSON.stringify({
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
error: "NotFound",
|
||||
message: `No mock for ${endpoint}`,
|
||||
}),
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
JSON.stringify({
|
||||
error: "NotFound",
|
||||
message: `No mock for ${endpoint}`,
|
||||
}),
|
||||
),
|
||||
headers: new Headers(),
|
||||
redirected: false,
|
||||
statusText: "Not Found",
|
||||
@@ -76,9 +117,9 @@ export function setupFetchMock(): void {
|
||||
},
|
||||
body: null,
|
||||
bodyUsed: false,
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
blob: async () => new Blob(),
|
||||
formData: async () => new FormData(),
|
||||
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
|
||||
blob: () => Promise.resolve(new Blob()),
|
||||
formData: () => Promise.resolve(new FormData()),
|
||||
} as Response;
|
||||
},
|
||||
);
|
||||
@@ -87,7 +128,7 @@ export function jsonResponse<T>(data: T, status = 200): MockResponse {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => data,
|
||||
json: () => Promise.resolve(data),
|
||||
};
|
||||
}
|
||||
export function errorResponse(
|
||||
@@ -98,7 +139,7 @@ export function errorResponse(
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
json: async () => ({ error, message }),
|
||||
json: () => Promise.resolve({ error, message }),
|
||||
};
|
||||
}
|
||||
export const mockData = {
|
||||
|
||||
+25
-2
@@ -130,14 +130,37 @@ pub async fn proxy_handler(
|
||||
Err(e) => {
|
||||
warn!("Token validation failed: {:?}", e);
|
||||
if matches!(e, crate::auth::TokenValidationError::TokenExpired) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
let auth_header_str = headers
|
||||
.get("Authorization")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("");
|
||||
let is_dpop = auth_header_str
|
||||
.trim()
|
||||
.get(..5)
|
||||
.is_some_and(|s| s.eq_ignore_ascii_case("dpop "));
|
||||
let scheme = if is_dpop { "DPoP" } else { "Bearer" };
|
||||
let www_auth = format!(
|
||||
"{} error=\"invalid_token\", error_description=\"Token has expired\"",
|
||||
scheme
|
||||
);
|
||||
let mut response = (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
"error": "ExpiredToken",
|
||||
"message": "Token has expired"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("WWW-Authenticate", www_auth.parse().unwrap());
|
||||
if is_dpop {
|
||||
let nonce = crate::oauth::verify::generate_dpop_nonce();
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("DPoP-Nonce", nonce.parse().unwrap());
|
||||
}
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,11 +42,18 @@ pub async fn delete_record(
|
||||
axum::extract::OriginalUri(uri): axum::extract::OriginalUri,
|
||||
Json(input): Json<DeleteRecordInput>,
|
||||
) -> Response {
|
||||
let auth =
|
||||
match prepare_repo_write(&state, &headers, &input.repo, "POST", &uri.to_string()).await {
|
||||
Ok(res) => res,
|
||||
Err(err_res) => return err_res,
|
||||
};
|
||||
let auth = match prepare_repo_write(
|
||||
&state,
|
||||
&headers,
|
||||
&input.repo,
|
||||
"POST",
|
||||
&crate::util::build_full_url(&uri.to_string()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(res) => res,
|
||||
Err(err_res) => return err_res,
|
||||
};
|
||||
|
||||
if let Err(e) = crate::auth::scope_check::check_repo_scope(
|
||||
auth.is_oauth,
|
||||
|
||||
@@ -89,11 +89,28 @@ pub async fn prepare_repo_write(
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
tracing::warn!(error = ?e, is_dpop = extracted.is_dpop, "Token validation failed in prepare_repo_write");
|
||||
let mut response = (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({"error": e.to_string()})),
|
||||
)
|
||||
.into_response()
|
||||
.into_response();
|
||||
if matches!(e, crate::auth::TokenValidationError::TokenExpired) {
|
||||
let scheme = if extracted.is_dpop { "DPoP" } else { "Bearer" };
|
||||
let www_auth = format!(
|
||||
"{} error=\"invalid_token\", error_description=\"Token has expired\"",
|
||||
scheme
|
||||
);
|
||||
response.headers_mut().insert(
|
||||
"WWW-Authenticate",
|
||||
www_auth.parse().unwrap(),
|
||||
);
|
||||
if extracted.is_dpop {
|
||||
let nonce = crate::oauth::verify::generate_dpop_nonce();
|
||||
response.headers_mut().insert("DPoP-Nonce", nonce.parse().unwrap());
|
||||
}
|
||||
}
|
||||
response
|
||||
})?;
|
||||
if repo_did != auth_user.did {
|
||||
return Err((
|
||||
@@ -219,11 +236,18 @@ pub async fn create_record(
|
||||
axum::extract::OriginalUri(uri): axum::extract::OriginalUri,
|
||||
Json(input): Json<CreateRecordInput>,
|
||||
) -> Response {
|
||||
let auth =
|
||||
match prepare_repo_write(&state, &headers, &input.repo, "POST", &uri.to_string()).await {
|
||||
Ok(res) => res,
|
||||
Err(err_res) => return err_res,
|
||||
};
|
||||
let auth = match prepare_repo_write(
|
||||
&state,
|
||||
&headers,
|
||||
&input.repo,
|
||||
"POST",
|
||||
&crate::util::build_full_url(&uri.to_string()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(res) => res,
|
||||
Err(err_res) => return err_res,
|
||||
};
|
||||
|
||||
if let Err(e) = crate::auth::scope_check::check_repo_scope(
|
||||
auth.is_oauth,
|
||||
@@ -459,11 +483,18 @@ pub async fn put_record(
|
||||
axum::extract::OriginalUri(uri): axum::extract::OriginalUri,
|
||||
Json(input): Json<PutRecordInput>,
|
||||
) -> Response {
|
||||
let auth =
|
||||
match prepare_repo_write(&state, &headers, &input.repo, "POST", &uri.to_string()).await {
|
||||
Ok(res) => res,
|
||||
Err(err_res) => return err_res,
|
||||
};
|
||||
let auth = match prepare_repo_write(
|
||||
&state,
|
||||
&headers,
|
||||
&input.repo,
|
||||
"POST",
|
||||
&crate::util::build_full_url(&uri.to_string()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(res) => res,
|
||||
Err(err_res) => return err_res,
|
||||
};
|
||||
|
||||
if let Err(e) = crate::auth::scope_check::check_repo_scope(
|
||||
auth.is_oauth,
|
||||
|
||||
@@ -1257,7 +1257,7 @@ pub async fn request_passkey_recovery(
|
||||
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
let recovery_url = format!(
|
||||
"https://{}/#/recover-passkey?did={}&token={}",
|
||||
"https://{}/app/recover-passkey?did={}&token={}",
|
||||
hostname,
|
||||
urlencoding::encode(&user.did),
|
||||
urlencoding::encode(&recovery_token)
|
||||
|
||||
@@ -396,6 +396,7 @@ pub async fn validate_token_with_dpop(
|
||||
controller_did: None,
|
||||
})
|
||||
}
|
||||
Err(crate::oauth::OAuthError::ExpiredToken(_)) => Err(TokenValidationError::TokenExpired),
|
||||
Err(_) => Err(TokenValidationError::AuthenticationFailed),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,9 +352,9 @@ pub async fn enqueue_email_update(
|
||||
let strings = get_strings(&prefs.locale);
|
||||
let encoded_email = urlencoding::encode(new_email);
|
||||
let encoded_token = urlencoding::encode(code);
|
||||
let verify_page = format!("https://{}/#/verify", hostname);
|
||||
let verify_page = format!("https://{}/app/verify", hostname);
|
||||
let verify_link = format!(
|
||||
"https://{}/#/verify?token={}&identifier={}",
|
||||
"https://{}/app/verify?token={}&identifier={}",
|
||||
hostname, encoded_token, encoded_email
|
||||
);
|
||||
let body = format_message(
|
||||
@@ -389,9 +389,9 @@ pub async fn enqueue_email_update_token(
|
||||
let prefs = get_user_comms_prefs(db, user_id).await?;
|
||||
let strings = get_strings(&prefs.locale);
|
||||
let current_email = prefs.email.clone().unwrap_or_default();
|
||||
let verify_page = format!("https://{}/#/verify?type=email-update", hostname);
|
||||
let verify_page = format!("https://{}/app/verify?type=email-update", hostname);
|
||||
let verify_link = format!(
|
||||
"https://{}/#/verify?type=email-update&token={}",
|
||||
"https://{}/app/verify?type=email-update&token={}",
|
||||
hostname,
|
||||
urlencoding::encode(code)
|
||||
);
|
||||
@@ -556,9 +556,9 @@ pub async fn enqueue_signup_verification(
|
||||
let encoded_email = urlencoding::encode(recipient);
|
||||
let encoded_token = urlencoding::encode(code);
|
||||
(
|
||||
format!("https://{}/#/verify", hostname),
|
||||
format!("https://{}/app/verify", hostname),
|
||||
format!(
|
||||
"https://{}/#/verify?token={}&identifier={}",
|
||||
"https://{}/app/verify?token={}&identifier={}",
|
||||
hostname, encoded_token, encoded_email
|
||||
),
|
||||
)
|
||||
@@ -606,9 +606,9 @@ pub async fn enqueue_migration_verification(
|
||||
let strings = get_strings(&prefs.locale);
|
||||
let encoded_email = urlencoding::encode(email);
|
||||
let encoded_token = urlencoding::encode(token);
|
||||
let verify_page = format!("https://{}/#/verify", hostname);
|
||||
let verify_page = format!("https://{}/app/verify", hostname);
|
||||
let verify_link = format!(
|
||||
"https://{}/#/verify?token={}&identifier={}",
|
||||
"https://{}/app/verify?token={}&identifier={}",
|
||||
hostname, encoded_token, encoded_email
|
||||
);
|
||||
let body = format_message(
|
||||
|
||||
+17
-2
@@ -657,8 +657,23 @@ pub fn app(state: AppState) -> Router {
|
||||
.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)
|
||||
let homepage_path = format!("{}/homepage.html", frontend_dir);
|
||||
|
||||
let homepage_exists = std::path::Path::new(&homepage_path).exists();
|
||||
let homepage_file = if homepage_exists {
|
||||
homepage_path
|
||||
} else {
|
||||
index_path.clone()
|
||||
};
|
||||
|
||||
let spa_router = Router::new().fallback_service(ServeFile::new(&index_path));
|
||||
|
||||
let serve_dir = ServeDir::new(&frontend_dir).not_found_service(ServeFile::new(&index_path));
|
||||
|
||||
router
|
||||
.route_service("/", ServeFile::new(&homepage_file))
|
||||
.nest("/app", spa_router)
|
||||
.fallback_service(serve_dir)
|
||||
} else {
|
||||
router
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ fn redirect_see_other(uri: &str) -> Response {
|
||||
|
||||
fn redirect_to_frontend_error(error: &str, description: &str) -> Response {
|
||||
redirect_see_other(&format!(
|
||||
"/#/oauth/error?error={}&error_description={}",
|
||||
"/app/oauth/error?error={}&error_description={}",
|
||||
url_encode(error),
|
||||
url_encode(description)
|
||||
))
|
||||
@@ -236,7 +236,7 @@ pub async fn authorize_get(
|
||||
if is_delegated && !has_password {
|
||||
tracing::info!("Redirecting to delegation auth");
|
||||
return redirect_see_other(&format!(
|
||||
"/#/oauth/delegation?request_uri={}&delegated_did={}",
|
||||
"/app/oauth/delegation?request_uri={}&delegated_did={}",
|
||||
url_encode(&request_uri),
|
||||
url_encode(&user.did)
|
||||
));
|
||||
@@ -259,12 +259,12 @@ pub async fn authorize_get(
|
||||
&& !accounts.is_empty()
|
||||
{
|
||||
return redirect_see_other(&format!(
|
||||
"/#/oauth/accounts?request_uri={}",
|
||||
"/app/oauth/accounts?request_uri={}",
|
||||
url_encode(&request_uri)
|
||||
));
|
||||
}
|
||||
redirect_see_other(&format!(
|
||||
"/#/oauth/login?request_uri={}",
|
||||
"/app/oauth/login?request_uri={}",
|
||||
url_encode(&request_uri)
|
||||
))
|
||||
}
|
||||
@@ -466,7 +466,7 @@ pub async fn authorize_post(
|
||||
.into_response();
|
||||
}
|
||||
redirect_see_other(&format!(
|
||||
"/#/oauth/login?request_uri={}&error={}",
|
||||
"/app/oauth/login?request_uri={}&error={}",
|
||||
url_encode(&form.request_uri),
|
||||
url_encode(error_msg)
|
||||
))
|
||||
@@ -539,7 +539,7 @@ pub async fn authorize_post(
|
||||
return show_login_error("An error occurred. Please try again.", json_response);
|
||||
}
|
||||
let redirect_url = format!(
|
||||
"/#/oauth/delegation?request_uri={}&delegated_did={}",
|
||||
"/app/oauth/delegation?request_uri={}&delegated_did={}",
|
||||
url_encode(&form.request_uri),
|
||||
url_encode(&user.did)
|
||||
);
|
||||
@@ -565,7 +565,7 @@ pub async fn authorize_post(
|
||||
return show_login_error("An error occurred. Please try again.", json_response);
|
||||
}
|
||||
let redirect_url = format!(
|
||||
"/#/oauth/passkey?request_uri={}",
|
||||
"/app/oauth/passkey?request_uri={}",
|
||||
url_encode(&form.request_uri)
|
||||
);
|
||||
if json_response {
|
||||
@@ -620,7 +620,7 @@ pub async fn authorize_post(
|
||||
.into_response();
|
||||
}
|
||||
return redirect_see_other(&format!(
|
||||
"/#/oauth/totp?request_uri={}",
|
||||
"/app/oauth/totp?request_uri={}",
|
||||
url_encode(&form.request_uri)
|
||||
));
|
||||
}
|
||||
@@ -649,7 +649,7 @@ pub async fn authorize_post(
|
||||
.into_response();
|
||||
}
|
||||
return redirect_see_other(&format!(
|
||||
"/#/oauth/2fa?request_uri={}&channel={}",
|
||||
"/app/oauth/2fa?request_uri={}&channel={}",
|
||||
url_encode(&form.request_uri),
|
||||
url_encode(channel_name)
|
||||
));
|
||||
@@ -713,7 +713,7 @@ pub async fn authorize_post(
|
||||
.unwrap_or(true);
|
||||
if needs_consent {
|
||||
let consent_url = format!(
|
||||
"/#/oauth/consent?request_uri={}",
|
||||
"/app/oauth/consent?request_uri={}",
|
||||
url_encode(&form.request_uri)
|
||||
);
|
||||
if json_response {
|
||||
@@ -1103,7 +1103,7 @@ pub async fn authorize_2fa_get(
|
||||
};
|
||||
let channel = query.channel.as_deref().unwrap_or("email");
|
||||
redirect_see_other(&format!(
|
||||
"/#/oauth/2fa?request_uri={}&channel={}",
|
||||
"/app/oauth/2fa?request_uri={}&channel={}",
|
||||
url_encode(&query.request_uri),
|
||||
url_encode(channel)
|
||||
))
|
||||
@@ -1464,6 +1464,7 @@ pub async fn consent_post(
|
||||
|| s.starts_with("blob:")
|
||||
|| s.starts_with("rpc:")
|
||||
|| s.starts_with("account:")
|
||||
|| s.starts_with("identity:")
|
||||
|| s.starts_with("include:")
|
||||
});
|
||||
if !has_valid_scope {
|
||||
@@ -1708,7 +1709,7 @@ pub async fn authorize_2fa_post(
|
||||
.unwrap_or(true);
|
||||
if needs_consent {
|
||||
let consent_url = format!(
|
||||
"/#/oauth/consent?request_uri={}",
|
||||
"/app/oauth/consent?request_uri={}",
|
||||
url_encode(&form.request_uri)
|
||||
);
|
||||
return Json(serde_json::json!({"redirect_uri": consent_url})).into_response();
|
||||
@@ -2345,7 +2346,7 @@ pub async fn passkey_finish(
|
||||
|
||||
if needs_consent {
|
||||
let consent_url = format!(
|
||||
"/#/oauth/consent?request_uri={}",
|
||||
"/app/oauth/consent?request_uri={}",
|
||||
url_encode(&form.request_uri)
|
||||
);
|
||||
return Json(serde_json::json!({"redirect_uri": consent_url})).into_response();
|
||||
@@ -2729,7 +2730,7 @@ pub async fn authorize_passkey_finish(
|
||||
}
|
||||
let channel_name = channel_display_name(user.preferred_comms_channel);
|
||||
let redirect_url = format!(
|
||||
"/#/oauth/2fa?request_uri={}&channel={}",
|
||||
"/app/oauth/2fa?request_uri={}&channel={}",
|
||||
url_encode(&form.request_uri),
|
||||
url_encode(channel_name)
|
||||
);
|
||||
@@ -2754,7 +2755,7 @@ pub async fn authorize_passkey_finish(
|
||||
}
|
||||
|
||||
let redirect_url = format!(
|
||||
"/#/oauth/consent?request_uri={}",
|
||||
"/app/oauth/consent?request_uri={}",
|
||||
url_encode(&form.request_uri)
|
||||
);
|
||||
(
|
||||
|
||||
@@ -206,7 +206,7 @@ pub async fn delegation_auth(
|
||||
success: true,
|
||||
needs_totp: Some(true),
|
||||
redirect_uri: Some(format!(
|
||||
"/#/oauth/delegation-totp?request_uri={}",
|
||||
"/app/oauth/delegation-totp?request_uri={}",
|
||||
urlencoding::encode(&form.request_uri)
|
||||
)),
|
||||
error: None,
|
||||
@@ -239,7 +239,7 @@ pub async fn delegation_auth(
|
||||
success: true,
|
||||
needs_totp: None,
|
||||
redirect_uri: Some(format!(
|
||||
"/#/oauth/consent?request_uri={}",
|
||||
"/app/oauth/consent?request_uri={}",
|
||||
urlencoding::encode(&form.request_uri)
|
||||
)),
|
||||
error: None,
|
||||
@@ -374,7 +374,7 @@ pub async fn delegation_totp_verify(
|
||||
success: true,
|
||||
needs_totp: None,
|
||||
redirect_uri: Some(format!(
|
||||
"/#/oauth/consent?request_uri={}",
|
||||
"/app/oauth/consent?request_uri={}",
|
||||
urlencoding::encode(&form.request_uri)
|
||||
)),
|
||||
error: None,
|
||||
|
||||
@@ -168,8 +168,8 @@ pub async fn frontend_client_metadata(
|
||||
client_name: "PDS Account Manager".to_string(),
|
||||
client_uri: base_url.clone(),
|
||||
redirect_uris: vec![
|
||||
format!("{}/", base_url),
|
||||
format!("{}/migrate", base_url),
|
||||
format!("{}/app/", base_url),
|
||||
format!("{}/app/migrate", base_url),
|
||||
],
|
||||
grant_types: vec![
|
||||
"authorization_code".to_string(),
|
||||
|
||||
@@ -94,9 +94,10 @@ pub async fn handle_authorization_code_grant(
|
||||
));
|
||||
}
|
||||
Some(result.jkt)
|
||||
} else if auth_request.parameters.dpop_jkt.is_some() {
|
||||
return Err(OAuthError::InvalidRequest(
|
||||
"DPoP proof required for this authorization".to_string(),
|
||||
} else if auth_request.parameters.dpop_jkt.is_some() || client_metadata.requires_dpop() {
|
||||
return Err(OAuthError::UseDpopNonce(
|
||||
crate::oauth::dpop::DPoPVerifier::new(AuthConfig::get().dpop_secret().as_bytes())
|
||||
.generate_nonce(),
|
||||
));
|
||||
} else {
|
||||
None
|
||||
@@ -138,6 +139,8 @@ pub async fn handle_authorization_code_grant(
|
||||
} else {
|
||||
REFRESH_TOKEN_EXPIRY_DAYS_CONFIDENTIAL
|
||||
};
|
||||
let mut stored_parameters = auth_request.parameters.clone();
|
||||
stored_parameters.dpop_jkt = dpop_jkt.clone();
|
||||
let token_data = TokenData {
|
||||
did: did.clone(),
|
||||
token_id: token_id.0.clone(),
|
||||
@@ -147,7 +150,7 @@ pub async fn handle_authorization_code_grant(
|
||||
client_id: auth_request.client_id.clone(),
|
||||
client_auth: stored_client_auth,
|
||||
device_id: auth_request.device_id,
|
||||
parameters: auth_request.parameters.clone(),
|
||||
parameters: stored_parameters,
|
||||
details: None,
|
||||
code: None,
|
||||
current_refresh_token: Some(refresh_token.0.clone()),
|
||||
|
||||
+73
-7
@@ -42,21 +42,38 @@ pub async fn verify_oauth_access_token(
|
||||
http_uri: &str,
|
||||
) -> Result<VerifyResult, OAuthError> {
|
||||
let token_info = extract_oauth_token_info(access_token)?;
|
||||
tracing::debug!(
|
||||
token_id = %token_info.token_id,
|
||||
has_dpop_proof = dpop_proof.is_some(),
|
||||
"Verifying OAuth access token"
|
||||
);
|
||||
let token_data = db::get_token_by_id(pool, &token_info.token_id)
|
||||
.await?
|
||||
.ok_or_else(|| OAuthError::InvalidToken("Token not found or revoked".to_string()))?;
|
||||
.ok_or_else(|| {
|
||||
tracing::warn!(token_id = %token_info.token_id, "Token not found in database");
|
||||
OAuthError::InvalidToken("Token not found or revoked".to_string())
|
||||
})?;
|
||||
let now = chrono::Utc::now();
|
||||
if token_data.expires_at < now {
|
||||
return Err(OAuthError::InvalidToken("Token has expired".to_string()));
|
||||
return Err(OAuthError::ExpiredToken(
|
||||
"Token session has expired".to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(expected_jkt) = &token_data.parameters.dpop_jkt {
|
||||
let proof = dpop_proof
|
||||
.ok_or_else(|| OAuthError::UseDpopNonce("DPoP proof required".to_string()))?;
|
||||
tracing::debug!(expected_jkt = %expected_jkt, "Token requires DPoP");
|
||||
let proof = dpop_proof.ok_or_else(|| {
|
||||
tracing::warn!("DPoP proof required but not provided");
|
||||
OAuthError::UseDpopNonce("DPoP proof required".to_string())
|
||||
})?;
|
||||
let config = AuthConfig::get();
|
||||
let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes());
|
||||
let access_token_hash = compute_ath(access_token);
|
||||
let result =
|
||||
verifier.verify_proof(proof, http_method, http_uri, Some(&access_token_hash))?;
|
||||
let result = verifier
|
||||
.verify_proof(proof, http_method, http_uri, Some(&access_token_hash))
|
||||
.map_err(|e| {
|
||||
tracing::warn!(error = ?e, http_method = %http_method, http_uri = %http_uri, "DPoP proof verification failed");
|
||||
e
|
||||
})?;
|
||||
if !db::check_and_record_dpop_jti(pool, &result.jti).await? {
|
||||
return Err(OAuthError::InvalidDpopProof(
|
||||
"DPoP proof has already been used".to_string(),
|
||||
@@ -123,7 +140,7 @@ pub fn extract_oauth_token_info(token: &str) -> Result<OAuthTokenInfo, OAuthErro
|
||||
.ok_or_else(|| OAuthError::InvalidToken("Missing exp claim".to_string()))?;
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
if exp < now {
|
||||
return Err(OAuthError::InvalidToken("Token has expired".to_string()));
|
||||
return Err(OAuthError::ExpiredToken("Token has expired".to_string()));
|
||||
}
|
||||
let token_id = payload
|
||||
.get("jti")
|
||||
@@ -191,6 +208,7 @@ pub struct OAuthAuthError {
|
||||
pub error: String,
|
||||
pub message: String,
|
||||
pub dpop_nonce: Option<String>,
|
||||
pub www_authenticate: Option<String>,
|
||||
}
|
||||
|
||||
impl IntoResponse for OAuthAuthError {
|
||||
@@ -208,6 +226,11 @@ impl IntoResponse for OAuthAuthError {
|
||||
.headers_mut()
|
||||
.insert("DPoP-Nonce", nonce.parse().unwrap());
|
||||
}
|
||||
if let Some(www_auth) = self.www_authenticate {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("WWW-Authenticate", www_auth.parse().unwrap());
|
||||
}
|
||||
response
|
||||
}
|
||||
}
|
||||
@@ -228,6 +251,7 @@ impl FromRequestParts<AppState> for OAuthUser {
|
||||
error: "AuthenticationRequired".to_string(),
|
||||
message: "Authorization header required".to_string(),
|
||||
dpop_nonce: None,
|
||||
www_authenticate: None,
|
||||
})?;
|
||||
let auth_header_trimmed = auth_header.trim();
|
||||
let (token, is_dpop_token) = if auth_header_trimmed.len() >= 7
|
||||
@@ -244,6 +268,7 @@ impl FromRequestParts<AppState> for OAuthUser {
|
||||
error: "InvalidRequest".to_string(),
|
||||
message: "Invalid authorization scheme".to_string(),
|
||||
dpop_nonce: None,
|
||||
www_authenticate: None,
|
||||
});
|
||||
};
|
||||
let dpop_proof = parts.headers.get("DPoP").and_then(|v| v.to_str().ok());
|
||||
@@ -275,6 +300,7 @@ impl FromRequestParts<AppState> for OAuthUser {
|
||||
error: "use_dpop_nonce".to_string(),
|
||||
message: "DPoP nonce required".to_string(),
|
||||
dpop_nonce: Some(nonce),
|
||||
www_authenticate: Some("DPoP error=\"use_dpop_nonce\"".to_string()),
|
||||
}),
|
||||
Err(OAuthError::InvalidDpopProof(msg)) => {
|
||||
let nonce = generate_dpop_nonce();
|
||||
@@ -283,6 +309,45 @@ impl FromRequestParts<AppState> for OAuthUser {
|
||||
error: "invalid_dpop_proof".to_string(),
|
||||
message: msg,
|
||||
dpop_nonce: Some(nonce),
|
||||
www_authenticate: None,
|
||||
})
|
||||
}
|
||||
Err(OAuthError::ExpiredToken(msg)) => {
|
||||
let nonce = if is_dpop_token {
|
||||
Some(generate_dpop_nonce())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let scheme = if is_dpop_token { "DPoP" } else { "Bearer" };
|
||||
let www_auth = format!(
|
||||
"{} error=\"invalid_token\", error_description=\"{}\"",
|
||||
scheme, msg
|
||||
);
|
||||
Err(OAuthAuthError {
|
||||
status: StatusCode::UNAUTHORIZED,
|
||||
error: "ExpiredToken".to_string(),
|
||||
message: msg,
|
||||
dpop_nonce: nonce,
|
||||
www_authenticate: Some(www_auth),
|
||||
})
|
||||
}
|
||||
Err(OAuthError::InvalidToken(msg)) => {
|
||||
let nonce = if is_dpop_token {
|
||||
Some(generate_dpop_nonce())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let scheme = if is_dpop_token { "DPoP" } else { "Bearer" };
|
||||
let www_auth = format!(
|
||||
"{} error=\"invalid_token\", error_description=\"{}\"",
|
||||
scheme, msg
|
||||
);
|
||||
Err(OAuthAuthError {
|
||||
status: StatusCode::UNAUTHORIZED,
|
||||
error: "InvalidToken".to_string(),
|
||||
message: msg,
|
||||
dpop_nonce: nonce,
|
||||
www_authenticate: Some(www_auth),
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -296,6 +361,7 @@ impl FromRequestParts<AppState> for OAuthUser {
|
||||
error: "AuthenticationFailed".to_string(),
|
||||
message: format!("{:?}", e),
|
||||
dpop_nonce: nonce,
|
||||
www_authenticate: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user