Frontend on /app path for easy custom homepage

This commit is contained in:
lewis
2026-01-02 21:38:42 +02:00
parent df2135b5e1
commit 4375d795a0
68 changed files with 1430 additions and 841 deletions
+6
View File
@@ -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
View File
@@ -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
```
+25 -4
View File
@@ -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
View File
@@ -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
```
+22
View File
@@ -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
View File
@@ -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
```
+696
View File
@@ -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
View File
@@ -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
View File
@@ -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 }> {
+9 -2
View File
@@ -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);
+59 -4
View File
@@ -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 {
+4 -3
View File
@@ -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({
+35 -13
View File
@@ -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();
}
+2
View File
@@ -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 {
+3 -3
View File
@@ -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;
}
+3 -3
View File
@@ -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";
}
+24 -11
View File
@@ -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);
}
+1 -1
View File
@@ -10,7 +10,7 @@ interface ServerConfigState {
loading: boolean;
}
let state = $state<ServerConfigState>({
const state = $state<ServerConfigState>({
serverName: null,
primaryColor: null,
primaryColorDark: null,
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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}
+1 -1
View File
@@ -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">
+1 -1
View File
@@ -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>
+3 -3
View File
@@ -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>
+16 -16
View File
@@ -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>
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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>
-527
View File
@@ -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>
+1 -1
View File
@@ -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">
+3 -3
View File
@@ -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">&middot;</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>
+7 -2
View File
@@ -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
+2 -2
View File
@@ -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'
}
+1 -1
View File
@@ -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')
}
+1 -1
View File
@@ -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')
}
+2 -2
View File
@@ -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 -2
View File
@@ -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')
}
+3 -3
View File
@@ -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> &middot; <a href="#/request-passkey-recovery">{$_('login.lostPasskey')}</a>
<a href="/app/reset-password">{$_('login.forgotPassword')}</a> &middot; <a href="/app/request-passkey-recovery">{$_('login.lostPasskey')}</a>
</p>
</div>
+1 -1
View File
@@ -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')
}
+1 -1
View File
@@ -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')
}
+1 -1
View File
@@ -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'),
+3 -3
View File
@@ -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>
+1 -1
View File
@@ -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'}
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -141,7 +141,7 @@
{/if}
<p class="link-text">
<a href="#/login">{$_('common.backToLogin')}</a>
<a href="/app/login">{$_('common.backToLogin')}</a>
</p>
</div>
+4 -4
View File
@@ -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')} &rarr;
</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}
+1 -1
View File
@@ -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}
+1 -1
View File
@@ -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}
+1 -1
View File
@@ -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>
+11 -18
View File
@@ -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>
+10 -7
View File
@@ -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();
});
+13 -7
View File
@@ -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();
});
+13 -13
View File
@@ -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 () => {
+6 -7
View File
@@ -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,
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
}
}
+12 -5
View File
@@ -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,
+43 -12
View File
@@ -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,
+1 -1
View File
@@ -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)
+1
View File
@@ -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),
}
}
+8 -8
View File
@@ -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
View File
@@ -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
}
+16 -15
View File
@@ -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)
);
(
+3 -3
View File
@@ -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,
+2 -2
View File
@@ -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(),
+7 -4
View File
@@ -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
View File
@@ -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,
})
}
}