mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-04 09:16:54 +00:00
Frontend style updates
This commit is contained in:
+12
-3
@@ -6,10 +6,19 @@
|
||||
<title>Tranquil PDS</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
>
|
||||
<style>
|
||||
html { background: #ffffff; }
|
||||
@media (prefers-color-scheme: dark) { html { background: #0a0a0a; } }
|
||||
html {
|
||||
background: #f9fafa;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
background: #0a0c0c;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,650 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Tranquil</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
line-height: 1.7;
|
||||
background: #2c00ff;
|
||||
color: #ffffff;
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.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(255,255,255,0.15);
|
||||
border-radius: 50%;
|
||||
transition: transform 0.04s linear;
|
||||
}
|
||||
|
||||
.pattern-fade {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(135deg, transparent 50%, #2c00ff 75%);
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
@keyframes drift {
|
||||
0% { transform: translateX(-500px); }
|
||||
100% { transform: translateX(0); }
|
||||
}
|
||||
|
||||
nav { z-index: 100; }
|
||||
main { position: relative; z-index: 10; }
|
||||
.site-footer { position: relative; z-index: 10; }
|
||||
|
||||
a { color: #ff2400; text-decoration: none; }
|
||||
a:hover { color: #ff5533; }
|
||||
|
||||
nav {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 32px;
|
||||
right: 32px;
|
||||
background: #1a00a3;
|
||||
padding: 10px 18px;
|
||||
z-index: 100;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
nav .brand {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.08em;
|
||||
color: #ffffff;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
nav .nav-meta {
|
||||
font-size: 0.85rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 80px 32px 80px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.category {
|
||||
color: #ff2400;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.read-time {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.15;
|
||||
color: #ffffff;
|
||||
margin-bottom: 32px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.byline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 24px 0;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #ff2400 0%, #ff6b4a 100%);
|
||||
}
|
||||
|
||||
.author-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.author {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
color: #ffffff;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.author-handle {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.verification {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.placeholder-image {
|
||||
aspect-ratio: 16 / 9;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.9rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
figcaption {
|
||||
margin-top: 12px;
|
||||
font-size: 0.85rem;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.carousel {
|
||||
margin: 64px 0 0;
|
||||
}
|
||||
|
||||
.carousel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.carousel-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.carousel-nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.carousel-nav button {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 6px;
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.carousel-nav button:hover {
|
||||
background: rgba(255, 36, 0, 0.15);
|
||||
border-color: #ff2400;
|
||||
}
|
||||
|
||||
.carousel-track {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
overflow-x: auto;
|
||||
scroll-snap-type: x mandatory;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
padding-bottom: 8px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.carousel-track::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.carousel-slide {
|
||||
flex: 0 0 70%;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
.carousel-slide .placeholder-image {
|
||||
aspect-ratio: 16 / 10;
|
||||
}
|
||||
|
||||
.carousel-label {
|
||||
margin-top: 12px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.content {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.content p {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.lede {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 500;
|
||||
color: #ffffff;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.content h2 {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: #ffffff;
|
||||
margin: 56px 0 24px;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 40px 0;
|
||||
padding: 32px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-left: 2px solid #ff2400;
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
|
||||
blockquote p {
|
||||
font-size: 1.15rem;
|
||||
color: #ffffff;
|
||||
font-style: italic;
|
||||
margin-bottom: 16px !important;
|
||||
}
|
||||
|
||||
blockquote cite {
|
||||
font-size: 0.8rem;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-style: normal;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.context-panel {
|
||||
margin: 40px 0;
|
||||
padding: 24px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.context-panel h3 {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: #ffffff;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.context-panel ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.context-panel li {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.context-panel li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.context-panel a {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: #ff2400;
|
||||
text-decoration: none;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.context-panel a:hover {
|
||||
color: #ff5533;
|
||||
}
|
||||
|
||||
.article-footer {
|
||||
margin-top: 64px;
|
||||
padding-top: 32px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 14px 24px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 6px;
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.actions button:hover {
|
||||
background: rgba(255, 36, 0, 0.15);
|
||||
border-color: #ff2400;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.attestation-info {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
font-size: 0.8rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 32px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8rem;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(255, 36, 0, 0.4);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="pattern-container">
|
||||
<div class="pattern"></div>
|
||||
</div>
|
||||
<div class="pattern-fade"></div>
|
||||
|
||||
<nav>
|
||||
<span class="brand">Tranquil</span>
|
||||
<span class="nav-meta">0.1.0</span>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<article>
|
||||
<div class="meta">
|
||||
<span class="category">Landing page</span>
|
||||
<span class="read-time">1 min read</span>
|
||||
</div>
|
||||
|
||||
<h1>Lorem Ipsum Dolor Sit Amet Consectetur</h1>
|
||||
|
||||
<div class="byline">
|
||||
<div class="avatar"></div>
|
||||
<div class="author-info">
|
||||
<span class="author">Mysterious benefactor</span>
|
||||
<span class="author-handle">@lewis.moe</span>
|
||||
</div>
|
||||
<div class="verification">47 attestations</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<blockquote>
|
||||
<p>"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."</p>
|
||||
<cite>Cicero, De Finibus Bonorum et Malorum</cite>
|
||||
</blockquote>
|
||||
|
||||
<p class="lede">Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit.</p>
|
||||
|
||||
<p>Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.</p>
|
||||
|
||||
<p>Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</p>
|
||||
|
||||
<h2>Neque Porro Quisquam</h2>
|
||||
|
||||
<p>Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.</p>
|
||||
|
||||
<p>Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur.</p>
|
||||
|
||||
<h2>Quis Autem Vel Eum</h2>
|
||||
|
||||
<p>Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur.</p>
|
||||
|
||||
<p>At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident.</p>
|
||||
|
||||
<p>Similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio.</p>
|
||||
|
||||
<p>Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus.</p>
|
||||
|
||||
<p>Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae.</p>
|
||||
|
||||
<div class="carousel">
|
||||
<div class="carousel-header">
|
||||
<span class="carousel-title">Interface</span>
|
||||
<div class="carousel-nav">
|
||||
<button class="carousel-prev">←</button>
|
||||
<button class="carousel-next">→</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="carousel-track">
|
||||
<div class="carousel-slide">
|
||||
<div class="placeholder-image">Dashboard goes here</div>
|
||||
<div class="carousel-label">Dashboard</div>
|
||||
</div>
|
||||
<div class="carousel-slide">
|
||||
<div class="placeholder-image">Profile Settings go here</div>
|
||||
<div class="carousel-label">Profile Settings</div>
|
||||
</div>
|
||||
<div class="carousel-slide">
|
||||
<div class="placeholder-image">Account Security goes here</div>
|
||||
<div class="carousel-label">Account Security</div>
|
||||
</div>
|
||||
<div class="carousel-slide">
|
||||
<div class="placeholder-image">Repository Browser goes here</div>
|
||||
<div class="carousel-label">Repository Browser</div>
|
||||
</div>
|
||||
<div class="carousel-slide">
|
||||
<div class="placeholder-image">OAuth Applications go here</div>
|
||||
<div class="carousel-label">OAuth Applications</div>
|
||||
</div>
|
||||
<div class="carousel-slide">
|
||||
<div class="placeholder-image">Invite Codes go here</div>
|
||||
<div class="carousel-label">Invite Codes</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="article-footer">
|
||||
<div class="actions">
|
||||
<button>Propagate</button>
|
||||
<button>Annotate</button>
|
||||
<button>Verify Source</button>
|
||||
</div>
|
||||
|
||||
<div class="attestation-info">
|
||||
<span>hash: 7f3a9c...</span>
|
||||
<span>signed: 2847.12.03</span>
|
||||
<span>nodes: 12,847</span>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div>Mesh Commons License</div>
|
||||
<div>node: local-7f3a</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
const pattern = document.querySelector('.pattern');
|
||||
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();
|
||||
|
||||
const track = document.querySelector('.carousel-track');
|
||||
const prevBtn = document.querySelector('.carousel-prev');
|
||||
const nextBtn = document.querySelector('.carousel-next');
|
||||
const slideWidth = track?.querySelector('.carousel-slide')?.offsetWidth + 16;
|
||||
|
||||
prevBtn?.addEventListener('click', () => {
|
||||
track.scrollBy({ left: -slideWidth, behavior: 'smooth' });
|
||||
});
|
||||
nextBtn?.addEventListener('click', () => {
|
||||
track.scrollBy({ left: slideWidth, behavior: 'smooth' });
|
||||
});
|
||||
|
||||
let isDragging = false;
|
||||
let startX, scrollLeft;
|
||||
|
||||
track?.addEventListener('mousedown', e => {
|
||||
isDragging = true;
|
||||
track.style.cursor = 'grabbing';
|
||||
track.style.scrollSnapType = 'none';
|
||||
startX = e.pageX - track.offsetLeft;
|
||||
scrollLeft = track.scrollLeft;
|
||||
});
|
||||
|
||||
track?.addEventListener('mouseleave', () => {
|
||||
isDragging = false;
|
||||
track.style.cursor = 'grab';
|
||||
track.style.scrollSnapType = 'x mandatory';
|
||||
});
|
||||
|
||||
function snapTo(target, duration = 120) {
|
||||
const start = track.scrollLeft;
|
||||
const distance = target - start;
|
||||
const startTime = performance.now();
|
||||
function step(currentTime) {
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const ease = 1 - Math.pow(1 - progress, 3);
|
||||
track.scrollLeft = start + distance * ease;
|
||||
if (progress < 1) requestAnimationFrame(step);
|
||||
else track.style.scrollSnapType = 'x mandatory';
|
||||
}
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
track?.addEventListener('mouseup', () => {
|
||||
isDragging = false;
|
||||
track.style.cursor = 'grab';
|
||||
const slideW = track.querySelector('.carousel-slide').offsetWidth + 16;
|
||||
const targetIndex = Math.round(track.scrollLeft / slideW);
|
||||
snapTo(targetIndex * slideW);
|
||||
});
|
||||
|
||||
track?.addEventListener('mousemove', e => {
|
||||
if (!isDragging) return;
|
||||
e.preventDefault();
|
||||
const x = e.pageX - track.offsetLeft;
|
||||
const walk = (x - startX) * 1.5;
|
||||
track.scrollLeft = scrollLeft - walk;
|
||||
});
|
||||
|
||||
if (track) track.style.cursor = 'grab';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,679 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Tranquil</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
:root {
|
||||
--primary: #2c00ff;
|
||||
--primary-dark: #1a00a3;
|
||||
--primary-light: #4d33ff;
|
||||
--primary-muted: #e8e5ff;
|
||||
--secondary: #ff2400;
|
||||
--secondary-hover: #ff5533;
|
||||
--bg: #ffffff;
|
||||
--bg-subtle: #f8f8fa;
|
||||
--text: #1a1a1a;
|
||||
--text-muted: #666666;
|
||||
--text-light: #999999;
|
||||
--border: #e5e5e5;
|
||||
--border-light: #f0f0f0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
line-height: 1.7;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.pattern-fade {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(135deg, transparent 50%, var(--bg) 75%);
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
@keyframes drift {
|
||||
0% { transform: translateX(-500px); }
|
||||
100% { transform: translateX(0); }
|
||||
}
|
||||
|
||||
nav { z-index: 100; }
|
||||
main { position: relative; z-index: 10; }
|
||||
.site-footer { position: relative; z-index: 10; }
|
||||
|
||||
a { color: var(--secondary); text-decoration: none; }
|
||||
a:hover { color: var(--secondary-hover); }
|
||||
|
||||
nav {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 32px;
|
||||
right: 32px;
|
||||
background: var(--primary);
|
||||
padding: 10px 18px;
|
||||
z-index: 100;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
nav .brand {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.08em;
|
||||
color: #ffffff;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
nav .nav-meta {
|
||||
font-size: 0.85rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 100px 32px 80px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.category {
|
||||
color: #ffffff;
|
||||
background: var(--primary);
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.read-time {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.15;
|
||||
color: var(--text);
|
||||
margin-bottom: 32px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.byline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 24px 0;
|
||||
border-top: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--secondary) 0%, #ff6b4a 100%);
|
||||
}
|
||||
|
||||
.author-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.author {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.author-handle {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.verification {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.placeholder-image {
|
||||
aspect-ratio: 16 / 9;
|
||||
background: var(--bg-subtle);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-light);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
figcaption {
|
||||
margin-top: 12px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.carousel {
|
||||
margin: 64px 0 0;
|
||||
}
|
||||
|
||||
.carousel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.carousel-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.carousel-nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.carousel-nav button {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.carousel-nav button:hover {
|
||||
background: rgba(255, 36, 0, 0.08);
|
||||
border-color: var(--secondary);
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
.carousel-track {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
overflow-x: auto;
|
||||
scroll-snap-type: x mandatory;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
padding-bottom: 8px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.carousel-track::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.carousel-slide {
|
||||
flex: 0 0 70%;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
.carousel-slide .placeholder-image {
|
||||
aspect-ratio: 16 / 10;
|
||||
}
|
||||
|
||||
.carousel-label {
|
||||
margin-top: 12px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.content {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.content p {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.lede {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.content h2 {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--primary-dark);
|
||||
margin: 56px 0 24px;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 40px 0;
|
||||
padding: 32px;
|
||||
background: var(--primary-muted);
|
||||
border-left: 3px solid var(--primary);
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
|
||||
blockquote p {
|
||||
font-size: 1.15rem;
|
||||
color: var(--primary-dark);
|
||||
font-style: italic;
|
||||
margin-bottom: 16px !important;
|
||||
}
|
||||
|
||||
blockquote cite {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
font-style: normal;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.context-panel {
|
||||
margin: 40px 0;
|
||||
padding: 24px;
|
||||
background: var(--bg-subtle);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.context-panel h3 {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.context-panel ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.context-panel li {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.context-panel li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.context-panel a {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: var(--secondary);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.context-panel a:hover {
|
||||
color: var(--secondary-hover);
|
||||
}
|
||||
|
||||
.article-footer {
|
||||
margin-top: 64px;
|
||||
padding-top: 32px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 14px 24px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.actions button:hover {
|
||||
background: rgba(255, 36, 0, 0.08);
|
||||
border-color: var(--secondary);
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
.actions button:first-child {
|
||||
background: var(--secondary);
|
||||
border-color: var(--secondary);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.actions button:first-child:hover {
|
||||
background: #cc1d00;
|
||||
border-color: #cc1d00;
|
||||
}
|
||||
|
||||
.attestation-info {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 32px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(255, 36, 0, 0.2);
|
||||
color: var(--text);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="pattern-container">
|
||||
<div class="pattern"></div>
|
||||
</div>
|
||||
<div class="pattern-fade"></div>
|
||||
|
||||
<nav>
|
||||
<span class="brand">Tranquil PDS</span>
|
||||
<span class="nav-meta">0.1.0</span>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<article>
|
||||
<div class="meta">
|
||||
<span class="category">Landing page</span>
|
||||
<span class="read-time">1 min read</span>
|
||||
</div>
|
||||
|
||||
<h1>Lorem Ipsum Dolor Sit Amet Consectetur</h1>
|
||||
|
||||
<div class="byline">
|
||||
<div class="avatar"></div>
|
||||
<div class="author-info">
|
||||
<span class="author">Mysterious benefactor</span>
|
||||
<span class="author-handle">@lewis.moe</span>
|
||||
</div>
|
||||
<div class="verification">47 attestations</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<blockquote>
|
||||
<p>"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."</p>
|
||||
<cite>Cicero, De Finibus Bonorum et Malorum</cite>
|
||||
</blockquote>
|
||||
|
||||
<p class="lede">Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit.</p>
|
||||
|
||||
<p>Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.</p>
|
||||
|
||||
<p>Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt.</p>
|
||||
|
||||
<h2>Neque Porro Quisquam</h2>
|
||||
|
||||
<p>Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem.</p>
|
||||
|
||||
<p>Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur.</p>
|
||||
|
||||
<h2>Quis Autem Vel Eum</h2>
|
||||
|
||||
<p>Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur.</p>
|
||||
|
||||
<p>At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident.</p>
|
||||
|
||||
<p>Similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio.</p>
|
||||
|
||||
<p>Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus.</p>
|
||||
|
||||
<p>Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae.</p>
|
||||
|
||||
<div class="carousel">
|
||||
<div class="carousel-header">
|
||||
<span class="carousel-title">Interface</span>
|
||||
<div class="carousel-nav">
|
||||
<button class="carousel-prev">←</button>
|
||||
<button class="carousel-next">→</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="carousel-track">
|
||||
<div class="carousel-slide">
|
||||
<div class="placeholder-image">Dashboard goes here</div>
|
||||
<div class="carousel-label">Dashboard</div>
|
||||
</div>
|
||||
<div class="carousel-slide">
|
||||
<div class="placeholder-image">Profile Settings go here</div>
|
||||
<div class="carousel-label">Profile Settings</div>
|
||||
</div>
|
||||
<div class="carousel-slide">
|
||||
<div class="placeholder-image">Account Security goes here</div>
|
||||
<div class="carousel-label">Account Security</div>
|
||||
</div>
|
||||
<div class="carousel-slide">
|
||||
<div class="placeholder-image">Repository Browser goes here</div>
|
||||
<div class="carousel-label">Repository Browser</div>
|
||||
</div>
|
||||
<div class="carousel-slide">
|
||||
<div class="placeholder-image">OAuth Applications goes here</div>
|
||||
<div class="carousel-label">OAuth Applications</div>
|
||||
</div>
|
||||
<div class="carousel-slide">
|
||||
<div class="placeholder-image">Invite Codes goes here</div>
|
||||
<div class="carousel-label">Invite Codes</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="article-footer">
|
||||
<div class="actions">
|
||||
<button>Propagate</button>
|
||||
<button>Annotate</button>
|
||||
<button>Verify Source</button>
|
||||
</div>
|
||||
|
||||
<div class="attestation-info">
|
||||
<span>hash: 7f3a9c...</span>
|
||||
<span>signed: 2847.12.03</span>
|
||||
<span>nodes: 12,847</span>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div>Mesh Commons License</div>
|
||||
<div>node: local-7f3a</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
const pattern = document.querySelector('.pattern');
|
||||
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();
|
||||
|
||||
const track = document.querySelector('.carousel-track');
|
||||
const prevBtn = document.querySelector('.carousel-prev');
|
||||
const nextBtn = document.querySelector('.carousel-next');
|
||||
const slideWidth = track?.querySelector('.carousel-slide')?.offsetWidth + 16;
|
||||
|
||||
prevBtn?.addEventListener('click', () => {
|
||||
track.scrollBy({ left: -slideWidth, behavior: 'smooth' });
|
||||
});
|
||||
nextBtn?.addEventListener('click', () => {
|
||||
track.scrollBy({ left: slideWidth, behavior: 'smooth' });
|
||||
});
|
||||
|
||||
let isDragging = false;
|
||||
let startX, scrollLeft;
|
||||
|
||||
track?.addEventListener('mousedown', e => {
|
||||
isDragging = true;
|
||||
track.style.cursor = 'grabbing';
|
||||
track.style.scrollSnapType = 'none';
|
||||
startX = e.pageX - track.offsetLeft;
|
||||
scrollLeft = track.scrollLeft;
|
||||
});
|
||||
|
||||
track?.addEventListener('mouseleave', () => {
|
||||
isDragging = false;
|
||||
track.style.cursor = 'grab';
|
||||
track.style.scrollSnapType = 'x mandatory';
|
||||
});
|
||||
|
||||
function snapTo(target, duration = 120) {
|
||||
const start = track.scrollLeft;
|
||||
const distance = target - start;
|
||||
const startTime = performance.now();
|
||||
function step(currentTime) {
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const ease = 1 - Math.pow(1 - progress, 3);
|
||||
track.scrollLeft = start + distance * ease;
|
||||
if (progress < 1) requestAnimationFrame(step);
|
||||
else track.style.scrollSnapType = 'x mandatory';
|
||||
}
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
track?.addEventListener('mouseup', () => {
|
||||
isDragging = false;
|
||||
track.style.cursor = 'grab';
|
||||
const slideW = track.querySelector('.carousel-slide').offsetWidth + 16;
|
||||
const targetIndex = Math.round(track.scrollLeft / slideW);
|
||||
snapTo(targetIndex * slideW);
|
||||
});
|
||||
|
||||
track?.addEventListener('mousemove', e => {
|
||||
if (!isDragging) return;
|
||||
e.preventDefault();
|
||||
const x = e.pageX - track.offsetLeft;
|
||||
const walk = (x - startX) * 1.5;
|
||||
track.scrollLeft = scrollLeft - walk;
|
||||
});
|
||||
|
||||
if (track) track.style.cursor = 'grab';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,714 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Tranquil</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
:root {
|
||||
--primary: #2c00ff;
|
||||
--primary-dark: #1a00a3;
|
||||
--primary-light: #4d33ff;
|
||||
--primary-muted: #e8e5ff;
|
||||
--secondary: #ff2400;
|
||||
--secondary-hover: #ff5533;
|
||||
--bg: #ffffff;
|
||||
--bg-subtle: #f8f8fa;
|
||||
--text: #1a1a1a;
|
||||
--text-muted: #666666;
|
||||
--text-light: #999999;
|
||||
--border: #e5e5e5;
|
||||
--border-light: #f0f0f0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
line-height: 1.7;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.pattern-fade {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(135deg, transparent 50%, var(--bg) 75%);
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
@keyframes drift {
|
||||
0% { transform: translateX(-500px); }
|
||||
100% { transform: translateX(0); }
|
||||
}
|
||||
|
||||
nav { z-index: 100; }
|
||||
main { position: relative; z-index: 10; }
|
||||
.site-footer { position: relative; z-index: 10; }
|
||||
|
||||
a { color: var(--secondary); text-decoration: none; }
|
||||
a:hover { color: var(--secondary-hover); }
|
||||
|
||||
nav {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 32px;
|
||||
right: 32px;
|
||||
background: var(--primary);
|
||||
padding: 10px 18px;
|
||||
z-index: 100;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
nav .brand {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.08em;
|
||||
color: #ffffff;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
nav .nav-meta {
|
||||
font-size: 0.85rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 72px 32px 80px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.category {
|
||||
color: #ffffff;
|
||||
background: var(--primary);
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.read-time {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.15;
|
||||
color: var(--text);
|
||||
margin-bottom: 32px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.byline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 24px 0;
|
||||
border-top: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--secondary) 0%, #ff6b4a 100%);
|
||||
}
|
||||
|
||||
.author-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.author {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.author-handle {
|
||||
display: block;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.verification {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.placeholder-image {
|
||||
aspect-ratio: 16 / 9;
|
||||
background: var(--bg-subtle);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-light);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
figcaption {
|
||||
margin-top: 12px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.carousel {
|
||||
margin: 64px 0 0;
|
||||
}
|
||||
|
||||
.carousel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.carousel-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.carousel-nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.carousel-nav button {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.carousel-nav button:hover {
|
||||
background: rgba(255, 36, 0, 0.08);
|
||||
border-color: var(--secondary);
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
.carousel-track {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
overflow-x: auto;
|
||||
scroll-snap-type: x mandatory;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
padding-bottom: 8px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.carousel-track::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.carousel-slide {
|
||||
flex: 0 0 70%;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
.carousel-slide .placeholder-image {
|
||||
aspect-ratio: 16 / 10;
|
||||
}
|
||||
|
||||
.carousel-label {
|
||||
margin-top: 12px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.content {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.content p {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.lede {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 32px 0 40px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.content h2 {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--primary-dark);
|
||||
margin: 56px 0 24px;
|
||||
}
|
||||
|
||||
.content h2:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.features {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 32px;
|
||||
margin: 32px 0 56px;
|
||||
}
|
||||
|
||||
.feature {
|
||||
padding: 24px;
|
||||
background: var(--bg-subtle);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.feature h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.feature p {
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.features {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 40px 0;
|
||||
padding: 32px;
|
||||
background: var(--primary-muted);
|
||||
border-left: 3px solid var(--primary);
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
|
||||
blockquote p {
|
||||
font-size: 1.15rem;
|
||||
color: var(--primary-dark);
|
||||
font-style: italic;
|
||||
margin-bottom: 16px !important;
|
||||
}
|
||||
|
||||
blockquote cite {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
font-style: normal;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.context-panel {
|
||||
margin: 40px 0;
|
||||
padding: 24px;
|
||||
background: var(--bg-subtle);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.context-panel h3 {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.context-panel ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.context-panel li {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.context-panel li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.context-panel a {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: var(--secondary);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.context-panel a:hover {
|
||||
color: var(--secondary-hover);
|
||||
}
|
||||
|
||||
.article-footer {
|
||||
margin-top: 64px;
|
||||
padding-top: 32px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 14px 24px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.actions button:hover {
|
||||
background: rgba(255, 36, 0, 0.08);
|
||||
border-color: var(--secondary);
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
.actions button:first-child {
|
||||
background: var(--secondary);
|
||||
border-color: var(--secondary);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.actions button:first-child:hover {
|
||||
background: #cc1d00;
|
||||
border-color: #cc1d00;
|
||||
}
|
||||
|
||||
.attestation-info {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 32px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(255, 36, 0, 0.2);
|
||||
color: var(--text);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="pattern-container">
|
||||
<div class="pattern"></div>
|
||||
</div>
|
||||
<div class="pattern-fade"></div>
|
||||
|
||||
<nav>
|
||||
<span class="brand">Tranquil PDS</span>
|
||||
<span class="nav-meta">0.1.0</span>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
<section class="hero">
|
||||
<h1>A home for your ATProto 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" style="margin-top: 40px; margin-bottom: 0;">
|
||||
<button>Join This Server</button>
|
||||
<button>Run Your Own</button>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
|
||||
<div class="carousel">
|
||||
<div class="carousel-header">
|
||||
<span class="carousel-title">Interface</span>
|
||||
<div class="carousel-nav">
|
||||
<button class="carousel-prev">←</button>
|
||||
<button class="carousel-next">→</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="carousel-track">
|
||||
<div class="carousel-slide">
|
||||
<div class="placeholder-image">Dashboard</div>
|
||||
<div class="carousel-label">Dashboard</div>
|
||||
</div>
|
||||
<div class="carousel-slide">
|
||||
<div class="placeholder-image">Profile Settings</div>
|
||||
<div class="carousel-label">Profile Settings</div>
|
||||
</div>
|
||||
<div class="carousel-slide">
|
||||
<div class="placeholder-image">Account Security</div>
|
||||
<div class="carousel-label">Account Security</div>
|
||||
</div>
|
||||
<div class="carousel-slide">
|
||||
<div class="placeholder-image">Connected Apps</div>
|
||||
<div class="carousel-label">Connected Apps</div>
|
||||
</div>
|
||||
<div class="carousel-slide">
|
||||
<div class="placeholder-image">Invite Friends</div>
|
||||
<div class="carousel-label">Invite Friends</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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, tools, and bots 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" style="margin-top: 32px;">
|
||||
<button>Join This Server</button>
|
||||
<button>View Source</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div>Open Source</div>
|
||||
<div>Made with care</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
const pattern = document.querySelector('.pattern');
|
||||
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();
|
||||
|
||||
const track = document.querySelector('.carousel-track');
|
||||
const prevBtn = document.querySelector('.carousel-prev');
|
||||
const nextBtn = document.querySelector('.carousel-next');
|
||||
const slideWidth = track?.querySelector('.carousel-slide')?.offsetWidth + 16;
|
||||
|
||||
prevBtn?.addEventListener('click', () => {
|
||||
track.scrollBy({ left: -slideWidth, behavior: 'smooth' });
|
||||
});
|
||||
nextBtn?.addEventListener('click', () => {
|
||||
track.scrollBy({ left: slideWidth, behavior: 'smooth' });
|
||||
});
|
||||
|
||||
let isDragging = false;
|
||||
let startX, scrollLeft;
|
||||
|
||||
track?.addEventListener('mousedown', e => {
|
||||
isDragging = true;
|
||||
track.style.cursor = 'grabbing';
|
||||
track.style.scrollSnapType = 'none';
|
||||
startX = e.pageX - track.offsetLeft;
|
||||
scrollLeft = track.scrollLeft;
|
||||
});
|
||||
|
||||
track?.addEventListener('mouseleave', () => {
|
||||
isDragging = false;
|
||||
track.style.cursor = 'grab';
|
||||
track.style.scrollSnapType = 'x mandatory';
|
||||
});
|
||||
|
||||
function snapTo(target, duration = 120) {
|
||||
const start = track.scrollLeft;
|
||||
const distance = target - start;
|
||||
const startTime = performance.now();
|
||||
function step(currentTime) {
|
||||
const elapsed = currentTime - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const ease = 1 - Math.pow(1 - progress, 3);
|
||||
track.scrollLeft = start + distance * ease;
|
||||
if (progress < 1) requestAnimationFrame(step);
|
||||
else track.style.scrollSnapType = 'x mandatory';
|
||||
}
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
track?.addEventListener('mouseup', () => {
|
||||
isDragging = false;
|
||||
track.style.cursor = 'grab';
|
||||
const slideW = track.querySelector('.carousel-slide').offsetWidth + 16;
|
||||
const targetIndex = Math.round(track.scrollLeft / slideW);
|
||||
snapTo(targetIndex * slideW);
|
||||
});
|
||||
|
||||
track?.addEventListener('mousemove', e => {
|
||||
if (!isDragging) return;
|
||||
e.preventDefault();
|
||||
const x = e.pageX - track.offsetLeft;
|
||||
const walk = (x - startX) * 1.5;
|
||||
track.scrollLeft = scrollLeft - walk;
|
||||
});
|
||||
|
||||
if (track) track.style.cursor = 'grab';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -44,9 +44,9 @@
|
||||
padding: var(--space-7);
|
||||
}
|
||||
|
||||
.page-sm { max-width: var(--width-sm); }
|
||||
.page-md { max-width: var(--width-md); }
|
||||
.page-lg { max-width: var(--width-lg); }
|
||||
.page-sm { max-width: var(--width-md); }
|
||||
.page-md { max-width: var(--width-lg); }
|
||||
.page-lg { max-width: var(--width-xl); }
|
||||
|
||||
header {
|
||||
margin-bottom: var(--space-7);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export { default as Button } from './Button.svelte'
|
||||
export { default as Card } from './Card.svelte'
|
||||
export { default as Input } from './Input.svelte'
|
||||
export { default as Message } from './Message.svelte'
|
||||
export { default as Page } from './Page.svelte'
|
||||
export { default as Section } from './Section.svelte'
|
||||
export { default as Button } from "./Button.svelte";
|
||||
export { default as Card } from "./Card.svelte";
|
||||
export { default as Input } from "./Input.svelte";
|
||||
export { default as Message } from "./Message.svelte";
|
||||
export { default as Page } from "./Page.svelte";
|
||||
export { default as Section } from "./Section.svelte";
|
||||
|
||||
+647
-475
File diff suppressed because it is too large
Load Diff
+234
-184
@@ -1,28 +1,43 @@
|
||||
import { api, setTokenRefreshCallback, type Session, type CreateAccountParams, type CreateAccountResult, ApiError } from './api'
|
||||
import { startOAuthLogin, handleOAuthCallback, checkForOAuthCallback, clearOAuthCallbackParams, refreshOAuthToken } from './oauth'
|
||||
import { setLocale, type SupportedLocale } from './i18n'
|
||||
import {
|
||||
api,
|
||||
ApiError,
|
||||
type CreateAccountParams,
|
||||
type CreateAccountResult,
|
||||
type Session,
|
||||
setTokenRefreshCallback,
|
||||
} from "./api";
|
||||
import {
|
||||
checkForOAuthCallback,
|
||||
clearOAuthCallbackParams,
|
||||
handleOAuthCallback,
|
||||
refreshOAuthToken,
|
||||
startOAuthLogin,
|
||||
} from "./oauth";
|
||||
import { setLocale, type SupportedLocale } from "./i18n";
|
||||
|
||||
function applyLocaleFromSession(sessionInfo: { preferredLocale?: string | null }) {
|
||||
function applyLocaleFromSession(
|
||||
sessionInfo: { preferredLocale?: string | null },
|
||||
) {
|
||||
if (sessionInfo.preferredLocale) {
|
||||
setLocale(sessionInfo.preferredLocale as SupportedLocale)
|
||||
setLocale(sessionInfo.preferredLocale as SupportedLocale);
|
||||
}
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'tranquil_pds_session'
|
||||
const ACCOUNTS_KEY = 'tranquil_pds_accounts'
|
||||
const STORAGE_KEY = "tranquil_pds_session";
|
||||
const ACCOUNTS_KEY = "tranquil_pds_accounts";
|
||||
|
||||
export interface SavedAccount {
|
||||
did: string
|
||||
handle: string
|
||||
accessJwt: string
|
||||
refreshJwt: string
|
||||
did: string;
|
||||
handle: string;
|
||||
accessJwt: string;
|
||||
refreshJwt: string;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
session: Session | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
savedAccounts: SavedAccount[]
|
||||
session: Session | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
savedAccounts: SavedAccount[];
|
||||
}
|
||||
|
||||
let state = $state<AuthState>({
|
||||
@@ -30,205 +45,222 @@ let state = $state<AuthState>({
|
||||
loading: true,
|
||||
error: null,
|
||||
savedAccounts: [],
|
||||
})
|
||||
});
|
||||
|
||||
function saveSession(session: Session | null) {
|
||||
if (session) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(session))
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
|
||||
} else {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
function loadSession(): Session | null {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
try {
|
||||
return JSON.parse(stored)
|
||||
return JSON.parse(stored);
|
||||
} catch {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
function loadSavedAccounts(): SavedAccount[] {
|
||||
const stored = localStorage.getItem(ACCOUNTS_KEY)
|
||||
const stored = localStorage.getItem(ACCOUNTS_KEY);
|
||||
if (stored) {
|
||||
try {
|
||||
return JSON.parse(stored)
|
||||
return JSON.parse(stored);
|
||||
} catch {
|
||||
return []
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return []
|
||||
return [];
|
||||
}
|
||||
|
||||
function saveSavedAccounts(accounts: SavedAccount[]) {
|
||||
localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts))
|
||||
localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts));
|
||||
}
|
||||
|
||||
function addOrUpdateSavedAccount(session: Session) {
|
||||
const accounts = loadSavedAccounts()
|
||||
const existing = accounts.findIndex(a => a.did === session.did)
|
||||
const accounts = loadSavedAccounts();
|
||||
const existing = accounts.findIndex((a) => a.did === session.did);
|
||||
const savedAccount: SavedAccount = {
|
||||
did: session.did,
|
||||
handle: session.handle,
|
||||
accessJwt: session.accessJwt,
|
||||
refreshJwt: session.refreshJwt,
|
||||
}
|
||||
};
|
||||
if (existing >= 0) {
|
||||
accounts[existing] = savedAccount
|
||||
accounts[existing] = savedAccount;
|
||||
} else {
|
||||
accounts.push(savedAccount)
|
||||
accounts.push(savedAccount);
|
||||
}
|
||||
saveSavedAccounts(accounts)
|
||||
state.savedAccounts = accounts
|
||||
saveSavedAccounts(accounts);
|
||||
state.savedAccounts = accounts;
|
||||
}
|
||||
|
||||
function removeSavedAccount(did: string) {
|
||||
const accounts = loadSavedAccounts().filter(a => a.did !== did)
|
||||
saveSavedAccounts(accounts)
|
||||
state.savedAccounts = accounts
|
||||
const accounts = loadSavedAccounts().filter((a) => a.did !== did);
|
||||
saveSavedAccounts(accounts);
|
||||
state.savedAccounts = accounts;
|
||||
}
|
||||
|
||||
async function tryRefreshToken(): Promise<string | null> {
|
||||
if (!state.session) return null
|
||||
if (!state.session) return null;
|
||||
try {
|
||||
const tokens = await refreshOAuthToken(state.session.refreshJwt)
|
||||
const sessionInfo = await api.getSession(tokens.access_token)
|
||||
const tokens = await refreshOAuthToken(state.session.refreshJwt);
|
||||
const sessionInfo = await api.getSession(tokens.access_token);
|
||||
const session: Session = {
|
||||
...sessionInfo,
|
||||
accessJwt: tokens.access_token,
|
||||
refreshJwt: tokens.refresh_token || state.session.refreshJwt,
|
||||
}
|
||||
state.session = session
|
||||
saveSession(session)
|
||||
addOrUpdateSavedAccount(session)
|
||||
return session.accessJwt
|
||||
};
|
||||
state.session = session;
|
||||
saveSession(session);
|
||||
addOrUpdateSavedAccount(session);
|
||||
return session.accessJwt;
|
||||
} catch {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function initAuth(): Promise<{ oauthLoginCompleted: boolean }> {
|
||||
setTokenRefreshCallback(tryRefreshToken)
|
||||
state.loading = true
|
||||
state.error = null
|
||||
state.savedAccounts = loadSavedAccounts()
|
||||
setTokenRefreshCallback(tryRefreshToken);
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
state.savedAccounts = loadSavedAccounts();
|
||||
|
||||
const oauthCallback = checkForOAuthCallback()
|
||||
const oauthCallback = checkForOAuthCallback();
|
||||
if (oauthCallback) {
|
||||
clearOAuthCallbackParams()
|
||||
clearOAuthCallbackParams();
|
||||
try {
|
||||
const tokens = await handleOAuthCallback(oauthCallback.code, oauthCallback.state)
|
||||
const sessionInfo = await api.getSession(tokens.access_token)
|
||||
const tokens = await handleOAuthCallback(
|
||||
oauthCallback.code,
|
||||
oauthCallback.state,
|
||||
);
|
||||
const sessionInfo = await api.getSession(tokens.access_token);
|
||||
const session: Session = {
|
||||
...sessionInfo,
|
||||
accessJwt: tokens.access_token,
|
||||
refreshJwt: tokens.refresh_token || '',
|
||||
}
|
||||
state.session = session
|
||||
saveSession(session)
|
||||
addOrUpdateSavedAccount(session)
|
||||
applyLocaleFromSession(sessionInfo)
|
||||
state.loading = false
|
||||
return { oauthLoginCompleted: true }
|
||||
refreshJwt: tokens.refresh_token || "",
|
||||
};
|
||||
state.session = session;
|
||||
saveSession(session);
|
||||
addOrUpdateSavedAccount(session);
|
||||
applyLocaleFromSession(sessionInfo);
|
||||
state.loading = false;
|
||||
return { oauthLoginCompleted: true };
|
||||
} catch (e) {
|
||||
state.error = e instanceof Error ? e.message : 'OAuth login failed'
|
||||
state.loading = false
|
||||
return { oauthLoginCompleted: false }
|
||||
state.error = e instanceof Error ? e.message : "OAuth login failed";
|
||||
state.loading = false;
|
||||
return { oauthLoginCompleted: false };
|
||||
}
|
||||
}
|
||||
|
||||
const stored = loadSession()
|
||||
const stored = loadSession();
|
||||
if (stored) {
|
||||
try {
|
||||
const sessionInfo = await api.getSession(stored.accessJwt)
|
||||
state.session = { ...sessionInfo, accessJwt: stored.accessJwt, refreshJwt: stored.refreshJwt }
|
||||
addOrUpdateSavedAccount(state.session)
|
||||
applyLocaleFromSession(sessionInfo)
|
||||
const sessionInfo = await api.getSession(stored.accessJwt);
|
||||
state.session = {
|
||||
...sessionInfo,
|
||||
accessJwt: stored.accessJwt,
|
||||
refreshJwt: stored.refreshJwt,
|
||||
};
|
||||
addOrUpdateSavedAccount(state.session);
|
||||
applyLocaleFromSession(sessionInfo);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 401) {
|
||||
try {
|
||||
const tokens = await refreshOAuthToken(stored.refreshJwt)
|
||||
const sessionInfo = await api.getSession(tokens.access_token)
|
||||
const tokens = await refreshOAuthToken(stored.refreshJwt);
|
||||
const sessionInfo = await api.getSession(tokens.access_token);
|
||||
const session: Session = {
|
||||
...sessionInfo,
|
||||
accessJwt: tokens.access_token,
|
||||
refreshJwt: tokens.refresh_token || stored.refreshJwt,
|
||||
}
|
||||
state.session = session
|
||||
saveSession(session)
|
||||
addOrUpdateSavedAccount(session)
|
||||
applyLocaleFromSession(sessionInfo)
|
||||
};
|
||||
state.session = session;
|
||||
saveSession(session);
|
||||
addOrUpdateSavedAccount(session);
|
||||
applyLocaleFromSession(sessionInfo);
|
||||
} catch (refreshError) {
|
||||
console.error('Token refresh failed during init:', refreshError)
|
||||
saveSession(null)
|
||||
state.session = null
|
||||
console.error("Token refresh failed during init:", refreshError);
|
||||
saveSession(null);
|
||||
state.session = null;
|
||||
}
|
||||
} else {
|
||||
console.error('Non-401 error during getSession:', e)
|
||||
saveSession(null)
|
||||
state.session = null
|
||||
console.error("Non-401 error during getSession:", e);
|
||||
saveSession(null);
|
||||
state.session = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
state.loading = false
|
||||
return { oauthLoginCompleted: false }
|
||||
state.loading = false;
|
||||
return { oauthLoginCompleted: false };
|
||||
}
|
||||
|
||||
export async function login(identifier: string, password: string): Promise<void> {
|
||||
state.loading = true
|
||||
state.error = null
|
||||
export async function login(
|
||||
identifier: string,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
try {
|
||||
const session = await api.createSession(identifier, password)
|
||||
state.session = session
|
||||
saveSession(session)
|
||||
addOrUpdateSavedAccount(session)
|
||||
const session = await api.createSession(identifier, password);
|
||||
state.session = session;
|
||||
saveSession(session);
|
||||
addOrUpdateSavedAccount(session);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
state.error = e.message
|
||||
state.error = e.message;
|
||||
} else {
|
||||
state.error = 'Login failed'
|
||||
state.error = "Login failed";
|
||||
}
|
||||
throw e
|
||||
throw e;
|
||||
} finally {
|
||||
state.loading = false
|
||||
state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loginWithOAuth(): Promise<void> {
|
||||
state.loading = true
|
||||
state.error = null
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
try {
|
||||
await startOAuthLogin()
|
||||
await startOAuthLogin();
|
||||
} catch (e) {
|
||||
state.loading = false
|
||||
state.error = e instanceof Error ? e.message : 'Failed to start OAuth login'
|
||||
throw e
|
||||
state.loading = false;
|
||||
state.error = e instanceof Error
|
||||
? e.message
|
||||
: "Failed to start OAuth login";
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function register(params: CreateAccountParams): Promise<CreateAccountResult> {
|
||||
export async function register(
|
||||
params: CreateAccountParams,
|
||||
): Promise<CreateAccountResult> {
|
||||
try {
|
||||
const result = await api.createAccount(params)
|
||||
return result
|
||||
const result = await api.createAccount(params);
|
||||
return result;
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
state.error = e.message
|
||||
state.error = e.message;
|
||||
} else {
|
||||
state.error = 'Registration failed'
|
||||
state.error = "Registration failed";
|
||||
}
|
||||
throw e
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function confirmSignup(did: string, verificationCode: string): Promise<void> {
|
||||
state.loading = true
|
||||
state.error = null
|
||||
export async function confirmSignup(
|
||||
did: string,
|
||||
verificationCode: string,
|
||||
): Promise<void> {
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
try {
|
||||
const result = await api.confirmSignup(did, verificationCode)
|
||||
const result = await api.confirmSignup(did, verificationCode);
|
||||
const session: Session = {
|
||||
did: result.did,
|
||||
handle: result.handle,
|
||||
@@ -238,167 +270,185 @@ export async function confirmSignup(did: string, verificationCode: string): Prom
|
||||
emailConfirmed: result.emailConfirmed,
|
||||
preferredChannel: result.preferredChannel,
|
||||
preferredChannelVerified: result.preferredChannelVerified,
|
||||
}
|
||||
state.session = session
|
||||
saveSession(session)
|
||||
addOrUpdateSavedAccount(session)
|
||||
};
|
||||
state.session = session;
|
||||
saveSession(session);
|
||||
addOrUpdateSavedAccount(session);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
state.error = e.message
|
||||
state.error = e.message;
|
||||
} else {
|
||||
state.error = 'Verification failed'
|
||||
state.error = "Verification failed";
|
||||
}
|
||||
throw e
|
||||
throw e;
|
||||
} finally {
|
||||
state.loading = false
|
||||
state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function resendVerification(did: string): Promise<void> {
|
||||
try {
|
||||
await api.resendVerification(did)
|
||||
await api.resendVerification(did);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
throw e
|
||||
throw e;
|
||||
}
|
||||
throw new Error('Failed to resend verification code')
|
||||
throw new Error("Failed to resend verification code");
|
||||
}
|
||||
}
|
||||
|
||||
export function setSession(session: { did: string; handle: string; accessJwt: string; refreshJwt: string }): void {
|
||||
export function setSession(
|
||||
session: {
|
||||
did: string;
|
||||
handle: string;
|
||||
accessJwt: string;
|
||||
refreshJwt: string;
|
||||
},
|
||||
): void {
|
||||
const newSession: Session = {
|
||||
did: session.did,
|
||||
handle: session.handle,
|
||||
accessJwt: session.accessJwt,
|
||||
refreshJwt: session.refreshJwt,
|
||||
}
|
||||
state.session = newSession
|
||||
saveSession(newSession)
|
||||
addOrUpdateSavedAccount(newSession)
|
||||
};
|
||||
state.session = newSession;
|
||||
saveSession(newSession);
|
||||
addOrUpdateSavedAccount(newSession);
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
if (state.session) {
|
||||
try {
|
||||
await api.deleteSession(state.session.accessJwt)
|
||||
await api.deleteSession(state.session.accessJwt);
|
||||
} catch {
|
||||
// Ignore errors on logout
|
||||
}
|
||||
}
|
||||
state.session = null
|
||||
saveSession(null)
|
||||
state.session = null;
|
||||
saveSession(null);
|
||||
}
|
||||
|
||||
export async function switchAccount(did: string): Promise<void> {
|
||||
const account = state.savedAccounts.find(a => a.did === did)
|
||||
const account = state.savedAccounts.find((a) => a.did === did);
|
||||
if (!account) {
|
||||
throw new Error('Account not found')
|
||||
throw new Error("Account not found");
|
||||
}
|
||||
state.loading = true
|
||||
state.error = null
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
try {
|
||||
const session = await api.getSession(account.accessJwt)
|
||||
state.session = { ...session, accessJwt: account.accessJwt, refreshJwt: account.refreshJwt }
|
||||
saveSession(state.session)
|
||||
addOrUpdateSavedAccount(state.session)
|
||||
const session = await api.getSession(account.accessJwt);
|
||||
state.session = {
|
||||
...session,
|
||||
accessJwt: account.accessJwt,
|
||||
refreshJwt: account.refreshJwt,
|
||||
};
|
||||
saveSession(state.session);
|
||||
addOrUpdateSavedAccount(state.session);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 401) {
|
||||
try {
|
||||
const tokens = await refreshOAuthToken(account.refreshJwt)
|
||||
const sessionInfo = await api.getSession(tokens.access_token)
|
||||
const tokens = await refreshOAuthToken(account.refreshJwt);
|
||||
const sessionInfo = await api.getSession(tokens.access_token);
|
||||
const session: Session = {
|
||||
...sessionInfo,
|
||||
accessJwt: tokens.access_token,
|
||||
refreshJwt: tokens.refresh_token || account.refreshJwt,
|
||||
}
|
||||
state.session = session
|
||||
saveSession(session)
|
||||
addOrUpdateSavedAccount(session)
|
||||
};
|
||||
state.session = session;
|
||||
saveSession(session);
|
||||
addOrUpdateSavedAccount(session);
|
||||
} catch {
|
||||
removeSavedAccount(did)
|
||||
state.error = 'Session expired. Please log in again.'
|
||||
throw new Error('Session expired')
|
||||
removeSavedAccount(did);
|
||||
state.error = "Session expired. Please log in again.";
|
||||
throw new Error("Session expired");
|
||||
}
|
||||
} else {
|
||||
state.error = 'Failed to switch account'
|
||||
throw e
|
||||
state.error = "Failed to switch account";
|
||||
throw e;
|
||||
}
|
||||
} finally {
|
||||
state.loading = false
|
||||
state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function forgetAccount(did: string): void {
|
||||
removeSavedAccount(did)
|
||||
removeSavedAccount(did);
|
||||
}
|
||||
|
||||
export function getAuthState() {
|
||||
return state
|
||||
return state;
|
||||
}
|
||||
|
||||
export async function refreshSession(): Promise<void> {
|
||||
if (!state.session) return
|
||||
if (!state.session) return;
|
||||
try {
|
||||
const sessionInfo = await api.getSession(state.session.accessJwt)
|
||||
const sessionInfo = await api.getSession(state.session.accessJwt);
|
||||
state.session = {
|
||||
...sessionInfo,
|
||||
accessJwt: state.session.accessJwt,
|
||||
refreshJwt: state.session.refreshJwt,
|
||||
}
|
||||
saveSession(state.session)
|
||||
addOrUpdateSavedAccount(state.session)
|
||||
};
|
||||
saveSession(state.session);
|
||||
addOrUpdateSavedAccount(state.session);
|
||||
} catch (e) {
|
||||
console.error('Failed to refresh session:', e)
|
||||
console.error("Failed to refresh session:", e);
|
||||
}
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
return state.session?.accessJwt ?? null
|
||||
return state.session?.accessJwt ?? null;
|
||||
}
|
||||
|
||||
export async function getValidToken(): Promise<string | null> {
|
||||
if (!state.session) return null
|
||||
if (!state.session) return null;
|
||||
try {
|
||||
await api.getSession(state.session.accessJwt)
|
||||
return state.session.accessJwt
|
||||
await api.getSession(state.session.accessJwt);
|
||||
return state.session.accessJwt;
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 401) {
|
||||
try {
|
||||
const tokens = await refreshOAuthToken(state.session.refreshJwt)
|
||||
const sessionInfo = await api.getSession(tokens.access_token)
|
||||
const tokens = await refreshOAuthToken(state.session.refreshJwt);
|
||||
const sessionInfo = await api.getSession(tokens.access_token);
|
||||
const session: Session = {
|
||||
...sessionInfo,
|
||||
accessJwt: tokens.access_token,
|
||||
refreshJwt: tokens.refresh_token || state.session.refreshJwt,
|
||||
}
|
||||
state.session = session
|
||||
saveSession(session)
|
||||
addOrUpdateSavedAccount(session)
|
||||
return session.accessJwt
|
||||
};
|
||||
state.session = session;
|
||||
saveSession(session);
|
||||
addOrUpdateSavedAccount(session);
|
||||
return session.accessJwt;
|
||||
} catch {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isAuthenticated(): boolean {
|
||||
return state.session !== null
|
||||
return state.session !== null;
|
||||
}
|
||||
|
||||
export function _testSetState(newState: { session: Session | null; loading: boolean; error: string | null; savedAccounts?: SavedAccount[] }) {
|
||||
state.session = newState.session
|
||||
state.loading = newState.loading
|
||||
state.error = newState.error
|
||||
state.savedAccounts = newState.savedAccounts ?? []
|
||||
export function _testSetState(
|
||||
newState: {
|
||||
session: Session | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
savedAccounts?: SavedAccount[];
|
||||
},
|
||||
) {
|
||||
state.session = newState.session;
|
||||
state.loading = newState.loading;
|
||||
state.error = newState.error;
|
||||
state.savedAccounts = newState.savedAccounts ?? [];
|
||||
}
|
||||
|
||||
export function _testReset() {
|
||||
state.session = null
|
||||
state.loading = true
|
||||
state.error = null
|
||||
state.savedAccounts = []
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
localStorage.removeItem(ACCOUNTS_KEY)
|
||||
state.session = null;
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
state.savedAccounts = [];
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
localStorage.removeItem(ACCOUNTS_KEY);
|
||||
}
|
||||
|
||||
+48
-44
@@ -1,55 +1,59 @@
|
||||
import * as secp from '@noble/secp256k1'
|
||||
import { base58btc } from 'multiformats/bases/base58'
|
||||
import * as secp from "@noble/secp256k1";
|
||||
import { base58btc } from "multiformats/bases/base58";
|
||||
|
||||
const SECP256K1_MULTICODEC_PREFIX = new Uint8Array([0xe7, 0x01])
|
||||
const SECP256K1_MULTICODEC_PREFIX = new Uint8Array([0xe7, 0x01]);
|
||||
|
||||
export interface Keypair {
|
||||
privateKey: Uint8Array
|
||||
publicKey: Uint8Array
|
||||
publicKeyMultibase: string
|
||||
publicKeyDidKey: string
|
||||
privateKey: Uint8Array;
|
||||
publicKey: Uint8Array;
|
||||
publicKeyMultibase: string;
|
||||
publicKeyDidKey: string;
|
||||
}
|
||||
|
||||
export async function generateKeypair(): Promise<Keypair> {
|
||||
const privateKey = secp.utils.randomPrivateKey()
|
||||
const publicKey = secp.getPublicKey(privateKey, true)
|
||||
const privateKey = secp.utils.randomPrivateKey();
|
||||
const publicKey = secp.getPublicKey(privateKey, true);
|
||||
|
||||
const multicodecKey = new Uint8Array(SECP256K1_MULTICODEC_PREFIX.length + publicKey.length)
|
||||
multicodecKey.set(SECP256K1_MULTICODEC_PREFIX, 0)
|
||||
multicodecKey.set(publicKey, SECP256K1_MULTICODEC_PREFIX.length)
|
||||
const multicodecKey = new Uint8Array(
|
||||
SECP256K1_MULTICODEC_PREFIX.length + publicKey.length,
|
||||
);
|
||||
multicodecKey.set(SECP256K1_MULTICODEC_PREFIX, 0);
|
||||
multicodecKey.set(publicKey, SECP256K1_MULTICODEC_PREFIX.length);
|
||||
|
||||
const publicKeyMultibase = base58btc.encode(multicodecKey)
|
||||
const publicKeyDidKey = `did:key:${publicKeyMultibase}`
|
||||
const publicKeyMultibase = base58btc.encode(multicodecKey);
|
||||
const publicKeyDidKey = `did:key:${publicKeyMultibase}`;
|
||||
|
||||
return {
|
||||
privateKey,
|
||||
publicKey,
|
||||
publicKeyMultibase,
|
||||
publicKeyDidKey,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function base64UrlEncode(data: Uint8Array | string): string {
|
||||
const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data
|
||||
let binary = ''
|
||||
const bytes = typeof data === "string"
|
||||
? new TextEncoder().encode(data)
|
||||
: data;
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i])
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
||||
}
|
||||
|
||||
export async function createServiceJwt(
|
||||
privateKey: Uint8Array,
|
||||
issuerDid: string,
|
||||
audienceDid: string,
|
||||
lxm: string
|
||||
lxm: string,
|
||||
): Promise<string> {
|
||||
const header = {
|
||||
alg: 'ES256K',
|
||||
typ: 'JWT',
|
||||
}
|
||||
alg: "ES256K",
|
||||
typ: "JWT",
|
||||
};
|
||||
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = {
|
||||
iss: issuerDid,
|
||||
sub: issuerDid,
|
||||
@@ -57,50 +61,50 @@ export async function createServiceJwt(
|
||||
exp: now + 180,
|
||||
iat: now,
|
||||
lxm: lxm,
|
||||
}
|
||||
};
|
||||
|
||||
const headerEncoded = base64UrlEncode(JSON.stringify(header))
|
||||
const payloadEncoded = base64UrlEncode(JSON.stringify(payload))
|
||||
const message = `${headerEncoded}.${payloadEncoded}`
|
||||
const headerEncoded = base64UrlEncode(JSON.stringify(header));
|
||||
const payloadEncoded = base64UrlEncode(JSON.stringify(payload));
|
||||
const message = `${headerEncoded}.${payloadEncoded}`;
|
||||
|
||||
const msgBytes = new TextEncoder().encode(message)
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBytes)
|
||||
const msgHash = new Uint8Array(hashBuffer)
|
||||
const signature = await secp.signAsync(msgHash, privateKey)
|
||||
const sigBytes = signature.toCompactRawBytes()
|
||||
const signatureEncoded = base64UrlEncode(sigBytes)
|
||||
const msgBytes = new TextEncoder().encode(message);
|
||||
const hashBuffer = await crypto.subtle.digest("SHA-256", msgBytes);
|
||||
const msgHash = new Uint8Array(hashBuffer);
|
||||
const signature = await secp.signAsync(msgHash, privateKey);
|
||||
const sigBytes = signature.toCompactRawBytes();
|
||||
const signatureEncoded = base64UrlEncode(sigBytes);
|
||||
|
||||
return `${message}.${signatureEncoded}`
|
||||
return `${message}.${signatureEncoded}`;
|
||||
}
|
||||
|
||||
export function generateDidDocument(
|
||||
did: string,
|
||||
publicKeyMultibase: string,
|
||||
handle: string,
|
||||
pdsEndpoint: string
|
||||
pdsEndpoint: string,
|
||||
): object {
|
||||
return {
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/did/v1',
|
||||
'https://w3id.org/security/multikey/v1',
|
||||
'https://w3id.org/security/suites/secp256k1-2019/v1',
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/did/v1",
|
||||
"https://w3id.org/security/multikey/v1",
|
||||
"https://w3id.org/security/suites/secp256k1-2019/v1",
|
||||
],
|
||||
id: did,
|
||||
alsoKnownAs: [`at://${handle}`],
|
||||
verificationMethod: [
|
||||
{
|
||||
id: `${did}#atproto`,
|
||||
type: 'Multikey',
|
||||
type: "Multikey",
|
||||
controller: did,
|
||||
publicKeyMultibase: publicKeyMultibase,
|
||||
},
|
||||
],
|
||||
service: [
|
||||
{
|
||||
id: '#atproto_pds',
|
||||
type: 'AtprotoPersonalDataServer',
|
||||
id: "#atproto_pds",
|
||||
type: "AtprotoPersonalDataServer",
|
||||
serviceEndpoint: pdsEndpoint,
|
||||
},
|
||||
],
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
+12
-12
@@ -1,17 +1,17 @@
|
||||
export function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}`
|
||||
const date = new Date(dateStr);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export function formatDateTime(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||
const date = new Date(dateStr);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const hours = String(date.getHours()).padStart(2, "0");
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
+31
-31
@@ -1,58 +1,58 @@
|
||||
import { register, init, getLocaleFromNavigator, locale, _ } from 'svelte-i18n'
|
||||
import { _, getLocaleFromNavigator, init, locale, register } from "svelte-i18n";
|
||||
|
||||
const LOCALE_STORAGE_KEY = 'tranquil-pds-locale'
|
||||
const LOCALE_STORAGE_KEY = "tranquil-pds-locale";
|
||||
|
||||
const SUPPORTED_LOCALES = ['en', 'zh', 'ja', 'ko', 'sv', 'fi'] as const
|
||||
export type SupportedLocale = typeof SUPPORTED_LOCALES[number]
|
||||
const SUPPORTED_LOCALES = ["en", "zh", "ja", "ko", "sv", "fi"] as const;
|
||||
export type SupportedLocale = typeof SUPPORTED_LOCALES[number];
|
||||
|
||||
export const localeNames: Record<SupportedLocale, string> = {
|
||||
en: 'English',
|
||||
zh: '中文',
|
||||
ja: '日本語',
|
||||
ko: '한국어',
|
||||
sv: 'Svenska',
|
||||
fi: 'Suomi'
|
||||
}
|
||||
en: "English",
|
||||
zh: "中文",
|
||||
ja: "日本語",
|
||||
ko: "한국어",
|
||||
sv: "Svenska",
|
||||
fi: "Suomi",
|
||||
};
|
||||
|
||||
register('en', () => import('../locales/en.json'))
|
||||
register('zh', () => import('../locales/zh.json'))
|
||||
register('ja', () => import('../locales/ja.json'))
|
||||
register('ko', () => import('../locales/ko.json'))
|
||||
register('sv', () => import('../locales/sv.json'))
|
||||
register('fi', () => import('../locales/fi.json'))
|
||||
register("en", () => import("../locales/en.json"));
|
||||
register("zh", () => import("../locales/zh.json"));
|
||||
register("ja", () => import("../locales/ja.json"));
|
||||
register("ko", () => import("../locales/ko.json"));
|
||||
register("sv", () => import("../locales/sv.json"));
|
||||
register("fi", () => import("../locales/fi.json"));
|
||||
|
||||
function getInitialLocale(): string {
|
||||
const stored = localStorage.getItem(LOCALE_STORAGE_KEY)
|
||||
const stored = localStorage.getItem(LOCALE_STORAGE_KEY);
|
||||
if (stored && SUPPORTED_LOCALES.includes(stored as SupportedLocale)) {
|
||||
return stored
|
||||
return stored;
|
||||
}
|
||||
|
||||
const browserLocale = getLocaleFromNavigator()
|
||||
const browserLocale = getLocaleFromNavigator();
|
||||
if (browserLocale) {
|
||||
const lang = browserLocale.split('-')[0]
|
||||
const lang = browserLocale.split("-")[0];
|
||||
if (SUPPORTED_LOCALES.includes(lang as SupportedLocale)) {
|
||||
return lang
|
||||
return lang;
|
||||
}
|
||||
}
|
||||
|
||||
return 'en'
|
||||
return "en";
|
||||
}
|
||||
|
||||
export function initI18n() {
|
||||
init({
|
||||
fallbackLocale: 'en',
|
||||
initialLocale: getInitialLocale()
|
||||
})
|
||||
fallbackLocale: "en",
|
||||
initialLocale: getInitialLocale(),
|
||||
});
|
||||
}
|
||||
|
||||
export function setLocale(newLocale: SupportedLocale) {
|
||||
locale.set(newLocale)
|
||||
localStorage.setItem(LOCALE_STORAGE_KEY, newLocale)
|
||||
document.documentElement.lang = newLocale
|
||||
locale.set(newLocale);
|
||||
localStorage.setItem(LOCALE_STORAGE_KEY, newLocale);
|
||||
document.documentElement.lang = newLocale;
|
||||
}
|
||||
|
||||
export function getSupportedLocales(): SupportedLocale[] {
|
||||
return [...SUPPORTED_LOCALES]
|
||||
return [...SUPPORTED_LOCALES];
|
||||
}
|
||||
|
||||
export { locale, _ }
|
||||
export { _, locale };
|
||||
|
||||
+116
-91
@@ -1,184 +1,209 @@
|
||||
const OAUTH_STATE_KEY = 'tranquil_pds_oauth_state'
|
||||
const OAUTH_VERIFIER_KEY = 'tranquil_pds_oauth_verifier'
|
||||
const OAUTH_STATE_KEY = "tranquil_pds_oauth_state";
|
||||
const OAUTH_VERIFIER_KEY = "tranquil_pds_oauth_verifier";
|
||||
const SCOPES = [
|
||||
'atproto',
|
||||
'repo:*?action=create',
|
||||
'repo:*?action=update',
|
||||
'repo:*?action=delete',
|
||||
'blob:*/*',
|
||||
].join(' ')
|
||||
"atproto",
|
||||
"repo:*?action=create",
|
||||
"repo:*?action=update",
|
||||
"repo:*?action=delete",
|
||||
"blob:*/*",
|
||||
].join(" ");
|
||||
const CLIENT_ID = !(import.meta.env.DEV)
|
||||
? `${window.location.origin}/oauth/client-metadata.json`
|
||||
: `http://localhost/?scope=${SCOPES}`
|
||||
const REDIRECT_URI = `${window.location.origin}/`
|
||||
? `${window.location.origin}/oauth/client-metadata.json`
|
||||
: `http://localhost/?scope=${SCOPES}`;
|
||||
const REDIRECT_URI = `${window.location.origin}/`;
|
||||
|
||||
interface OAuthState {
|
||||
state: string
|
||||
codeVerifier: string
|
||||
returnTo?: string
|
||||
state: string;
|
||||
codeVerifier: string;
|
||||
returnTo?: string;
|
||||
}
|
||||
|
||||
function generateRandomString(length: number): string {
|
||||
const array = new Uint8Array(length)
|
||||
crypto.getRandomValues(array)
|
||||
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('')
|
||||
const array = new Uint8Array(length);
|
||||
crypto.getRandomValues(array);
|
||||
return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join(
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
async function sha256(plain: string): Promise<ArrayBuffer> {
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(plain)
|
||||
return crypto.subtle.digest('SHA-256', data)
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(plain);
|
||||
return crypto.subtle.digest("SHA-256", data);
|
||||
}
|
||||
|
||||
function base64UrlEncode(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer)
|
||||
let binary = ''
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = "";
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte)
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(
|
||||
/=+$/,
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
async function generateCodeChallenge(verifier: string): Promise<string> {
|
||||
const hash = await sha256(verifier)
|
||||
return base64UrlEncode(hash)
|
||||
const hash = await sha256(verifier);
|
||||
return base64UrlEncode(hash);
|
||||
}
|
||||
|
||||
function generateState(): string {
|
||||
return generateRandomString(32)
|
||||
return generateRandomString(32);
|
||||
}
|
||||
|
||||
function generateCodeVerifier(): string {
|
||||
return generateRandomString(32)
|
||||
return generateRandomString(32);
|
||||
}
|
||||
|
||||
function saveOAuthState(state: OAuthState): void {
|
||||
sessionStorage.setItem(OAUTH_STATE_KEY, state.state)
|
||||
sessionStorage.setItem(OAUTH_VERIFIER_KEY, state.codeVerifier)
|
||||
sessionStorage.setItem(OAUTH_STATE_KEY, state.state);
|
||||
sessionStorage.setItem(OAUTH_VERIFIER_KEY, state.codeVerifier);
|
||||
}
|
||||
|
||||
function getOAuthState(): OAuthState | null {
|
||||
const state = sessionStorage.getItem(OAUTH_STATE_KEY)
|
||||
const codeVerifier = sessionStorage.getItem(OAUTH_VERIFIER_KEY)
|
||||
if (!state || !codeVerifier) return null
|
||||
return { state, codeVerifier }
|
||||
const state = sessionStorage.getItem(OAUTH_STATE_KEY);
|
||||
const codeVerifier = sessionStorage.getItem(OAUTH_VERIFIER_KEY);
|
||||
if (!state || !codeVerifier) return null;
|
||||
return { state, codeVerifier };
|
||||
}
|
||||
|
||||
function clearOAuthState(): void {
|
||||
sessionStorage.removeItem(OAUTH_STATE_KEY)
|
||||
sessionStorage.removeItem(OAUTH_VERIFIER_KEY)
|
||||
sessionStorage.removeItem(OAUTH_STATE_KEY);
|
||||
sessionStorage.removeItem(OAUTH_VERIFIER_KEY);
|
||||
}
|
||||
|
||||
export async function startOAuthLogin(): Promise<void> {
|
||||
const state = generateState()
|
||||
const codeVerifier = generateCodeVerifier()
|
||||
const codeChallenge = await generateCodeChallenge(codeVerifier)
|
||||
const state = generateState();
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
||||
|
||||
saveOAuthState({ state, codeVerifier })
|
||||
saveOAuthState({ state, codeVerifier });
|
||||
|
||||
const parResponse = await fetch('/oauth/par', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
const parResponse = await fetch("/oauth/par", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
response_type: 'code',
|
||||
response_type: "code",
|
||||
scope: SCOPES,
|
||||
state: state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
code_challenge_method: "S256",
|
||||
}),
|
||||
})
|
||||
});
|
||||
|
||||
if (!parResponse.ok) {
|
||||
const error = await parResponse.json().catch(() => ({ error: 'Unknown error' }))
|
||||
throw new Error(error.error_description || error.error || 'Failed to start OAuth flow')
|
||||
const error = await parResponse.json().catch(() => ({
|
||||
error: "Unknown error",
|
||||
}));
|
||||
throw new Error(
|
||||
error.error_description || error.error || "Failed to start OAuth flow",
|
||||
);
|
||||
}
|
||||
|
||||
const { request_uri } = await parResponse.json()
|
||||
const { request_uri } = await parResponse.json();
|
||||
|
||||
const authorizeUrl = new URL('/oauth/authorize', window.location.origin)
|
||||
authorizeUrl.searchParams.set('client_id', CLIENT_ID)
|
||||
authorizeUrl.searchParams.set('request_uri', request_uri)
|
||||
const authorizeUrl = new URL("/oauth/authorize", window.location.origin);
|
||||
authorizeUrl.searchParams.set("client_id", CLIENT_ID);
|
||||
authorizeUrl.searchParams.set("request_uri", request_uri);
|
||||
|
||||
window.location.href = authorizeUrl.toString()
|
||||
window.location.href = authorizeUrl.toString();
|
||||
}
|
||||
|
||||
export interface OAuthTokens {
|
||||
access_token: string
|
||||
refresh_token?: string
|
||||
token_type: string
|
||||
expires_in?: number
|
||||
scope?: string
|
||||
sub: string
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
token_type: string;
|
||||
expires_in?: number;
|
||||
scope?: string;
|
||||
sub: string;
|
||||
}
|
||||
|
||||
export async function handleOAuthCallback(code: string, state: string): Promise<OAuthTokens> {
|
||||
const savedState = getOAuthState()
|
||||
export async function handleOAuthCallback(
|
||||
code: string,
|
||||
state: string,
|
||||
): Promise<OAuthTokens> {
|
||||
const savedState = getOAuthState();
|
||||
if (!savedState) {
|
||||
throw new Error('No OAuth state found. Please try logging in again.')
|
||||
throw new Error("No OAuth state found. Please try logging in again.");
|
||||
}
|
||||
|
||||
if (savedState.state !== state) {
|
||||
clearOAuthState()
|
||||
throw new Error('OAuth state mismatch. Please try logging in again.')
|
||||
clearOAuthState();
|
||||
throw new Error("OAuth state mismatch. Please try logging in again.");
|
||||
}
|
||||
|
||||
const tokenResponse = await fetch('/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
const tokenResponse = await fetch("/oauth/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
grant_type: "authorization_code",
|
||||
client_id: CLIENT_ID,
|
||||
code: code,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
code_verifier: savedState.codeVerifier,
|
||||
}),
|
||||
})
|
||||
});
|
||||
|
||||
clearOAuthState()
|
||||
clearOAuthState();
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const error = await tokenResponse.json().catch(() => ({ error: 'Unknown error' }))
|
||||
throw new Error(error.error_description || error.error || 'Failed to exchange code for tokens')
|
||||
const error = await tokenResponse.json().catch(() => ({
|
||||
error: "Unknown error",
|
||||
}));
|
||||
throw new Error(
|
||||
error.error_description || error.error ||
|
||||
"Failed to exchange code for tokens",
|
||||
);
|
||||
}
|
||||
|
||||
return tokenResponse.json()
|
||||
return tokenResponse.json();
|
||||
}
|
||||
|
||||
export async function refreshOAuthToken(refreshToken: string): Promise<OAuthTokens> {
|
||||
const tokenResponse = await fetch('/oauth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
export async function refreshOAuthToken(
|
||||
refreshToken: string,
|
||||
): Promise<OAuthTokens> {
|
||||
const tokenResponse = await fetch("/oauth/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
grant_type: "refresh_token",
|
||||
client_id: CLIENT_ID,
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
})
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const error = await tokenResponse.json().catch(() => ({ error: 'Unknown error' }))
|
||||
throw new Error(error.error_description || error.error || 'Failed to refresh token')
|
||||
const error = await tokenResponse.json().catch(() => ({
|
||||
error: "Unknown error",
|
||||
}));
|
||||
throw new Error(
|
||||
error.error_description || error.error || "Failed to refresh token",
|
||||
);
|
||||
}
|
||||
|
||||
return tokenResponse.json()
|
||||
return tokenResponse.json();
|
||||
}
|
||||
|
||||
export function checkForOAuthCallback(): { code: string; state: string } | null {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const code = params.get('code')
|
||||
const state = params.get('state')
|
||||
export function checkForOAuthCallback():
|
||||
| { code: string; state: string }
|
||||
| null {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const code = params.get("code");
|
||||
const state = params.get("state");
|
||||
|
||||
if (code && state) {
|
||||
return { code, state }
|
||||
return { code, state };
|
||||
}
|
||||
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
export function clearOAuthCallbackParams(): void {
|
||||
const url = new URL(window.location.href)
|
||||
url.search = ''
|
||||
window.history.replaceState({}, '', url.toString())
|
||||
const url = new URL(window.location.href);
|
||||
url.search = "";
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
}
|
||||
|
||||
@@ -1,139 +1,157 @@
|
||||
import { api, ApiError } from '../api'
|
||||
import { generateKeypair, createServiceJwt, generateDidDocument } from '../crypto'
|
||||
import { api, ApiError } from "../api";
|
||||
import {
|
||||
createServiceJwt,
|
||||
generateDidDocument,
|
||||
generateKeypair,
|
||||
} from "../crypto";
|
||||
import type {
|
||||
AccountResult,
|
||||
ExternalDidWebState,
|
||||
RegistrationInfo,
|
||||
RegistrationMode,
|
||||
RegistrationStep,
|
||||
RegistrationInfo,
|
||||
ExternalDidWebState,
|
||||
AccountResult,
|
||||
SessionState,
|
||||
} from './types'
|
||||
} from "./types";
|
||||
|
||||
export interface RegistrationFlowState {
|
||||
mode: RegistrationMode
|
||||
step: RegistrationStep
|
||||
info: RegistrationInfo
|
||||
externalDidWeb: ExternalDidWebState
|
||||
account: AccountResult | null
|
||||
session: SessionState | null
|
||||
error: string | null
|
||||
submitting: boolean
|
||||
pdsHostname: string
|
||||
mode: RegistrationMode;
|
||||
step: RegistrationStep;
|
||||
info: RegistrationInfo;
|
||||
externalDidWeb: ExternalDidWebState;
|
||||
account: AccountResult | null;
|
||||
session: SessionState | null;
|
||||
error: string | null;
|
||||
submitting: boolean;
|
||||
pdsHostname: string;
|
||||
}
|
||||
|
||||
export function createRegistrationFlow(mode: RegistrationMode, pdsHostname: string) {
|
||||
export function createRegistrationFlow(
|
||||
mode: RegistrationMode,
|
||||
pdsHostname: string,
|
||||
) {
|
||||
let state = $state<RegistrationFlowState>({
|
||||
mode,
|
||||
step: 'info',
|
||||
step: "info",
|
||||
info: {
|
||||
handle: '',
|
||||
email: '',
|
||||
password: '',
|
||||
inviteCode: '',
|
||||
didType: 'plc',
|
||||
externalDid: '',
|
||||
verificationChannel: 'email',
|
||||
discordId: '',
|
||||
telegramUsername: '',
|
||||
signalNumber: '',
|
||||
handle: "",
|
||||
email: "",
|
||||
password: "",
|
||||
inviteCode: "",
|
||||
didType: "plc",
|
||||
externalDid: "",
|
||||
verificationChannel: "email",
|
||||
discordId: "",
|
||||
telegramUsername: "",
|
||||
signalNumber: "",
|
||||
},
|
||||
externalDidWeb: {
|
||||
keyMode: 'reserved',
|
||||
keyMode: "reserved",
|
||||
},
|
||||
account: null,
|
||||
session: null,
|
||||
error: null,
|
||||
submitting: false,
|
||||
pdsHostname,
|
||||
})
|
||||
});
|
||||
|
||||
function getPdsEndpoint(): string {
|
||||
return `https://${state.pdsHostname}`
|
||||
return `https://${state.pdsHostname}`;
|
||||
}
|
||||
|
||||
function getPdsDid(): string {
|
||||
return `did:web:${state.pdsHostname}`
|
||||
return `did:web:${state.pdsHostname}`;
|
||||
}
|
||||
|
||||
function getFullHandle(): string {
|
||||
return `${state.info.handle.trim()}.${state.pdsHostname}`
|
||||
return `${state.info.handle.trim()}.${state.pdsHostname}`;
|
||||
}
|
||||
|
||||
function extractDomain(did: string): string {
|
||||
return did.replace('did:web:', '').replace(/%3A/g, ':')
|
||||
return did.replace("did:web:", "").replace(/%3A/g, ":");
|
||||
}
|
||||
|
||||
function setError(err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
state.error = err.message || 'An error occurred'
|
||||
state.error = err.message || "An error occurred";
|
||||
} else if (err instanceof Error) {
|
||||
state.error = err.message || 'An error occurred'
|
||||
state.error = err.message || "An error occurred";
|
||||
} else {
|
||||
state.error = 'An error occurred'
|
||||
state.error = "An error occurred";
|
||||
}
|
||||
}
|
||||
|
||||
async function proceedFromInfo() {
|
||||
state.error = null
|
||||
if (state.info.didType === 'web-external') {
|
||||
state.step = 'key-choice'
|
||||
state.error = null;
|
||||
if (state.info.didType === "web-external") {
|
||||
state.step = "key-choice";
|
||||
} else {
|
||||
state.step = 'creating'
|
||||
state.step = "creating";
|
||||
}
|
||||
}
|
||||
|
||||
async function selectKeyMode(keyMode: 'reserved' | 'byod') {
|
||||
state.submitting = true
|
||||
state.error = null
|
||||
state.externalDidWeb.keyMode = keyMode
|
||||
async function selectKeyMode(keyMode: "reserved" | "byod") {
|
||||
state.submitting = true;
|
||||
state.error = null;
|
||||
state.externalDidWeb.keyMode = keyMode;
|
||||
|
||||
try {
|
||||
let publicKeyMultibase: string
|
||||
let publicKeyMultibase: string;
|
||||
|
||||
if (keyMode === 'reserved') {
|
||||
const result = await api.reserveSigningKey(state.info.externalDid!.trim())
|
||||
state.externalDidWeb.reservedSigningKey = result.signingKey
|
||||
publicKeyMultibase = result.signingKey.replace('did:key:', '')
|
||||
if (keyMode === "reserved") {
|
||||
const result = await api.reserveSigningKey(
|
||||
state.info.externalDid!.trim(),
|
||||
);
|
||||
state.externalDidWeb.reservedSigningKey = result.signingKey;
|
||||
publicKeyMultibase = result.signingKey.replace("did:key:", "");
|
||||
} else {
|
||||
const keypair = await generateKeypair()
|
||||
state.externalDidWeb.byodPrivateKey = keypair.privateKey
|
||||
state.externalDidWeb.byodPublicKeyMultibase = keypair.publicKeyMultibase
|
||||
publicKeyMultibase = keypair.publicKeyMultibase
|
||||
const keypair = await generateKeypair();
|
||||
state.externalDidWeb.byodPrivateKey = keypair.privateKey;
|
||||
state.externalDidWeb.byodPublicKeyMultibase =
|
||||
keypair.publicKeyMultibase;
|
||||
publicKeyMultibase = keypair.publicKeyMultibase;
|
||||
}
|
||||
|
||||
const didDoc = generateDidDocument(
|
||||
state.info.externalDid!.trim(),
|
||||
publicKeyMultibase,
|
||||
getFullHandle(),
|
||||
getPdsEndpoint()
|
||||
)
|
||||
state.externalDidWeb.initialDidDocument = JSON.stringify(didDoc, null, '\t')
|
||||
state.step = 'initial-did-doc'
|
||||
getPdsEndpoint(),
|
||||
);
|
||||
state.externalDidWeb.initialDidDocument = JSON.stringify(
|
||||
didDoc,
|
||||
null,
|
||||
"\t",
|
||||
);
|
||||
state.step = "initial-did-doc";
|
||||
} catch (err) {
|
||||
setError(err)
|
||||
setError(err);
|
||||
} finally {
|
||||
state.submitting = false
|
||||
state.submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmInitialDidDoc() {
|
||||
state.step = 'creating'
|
||||
state.step = "creating";
|
||||
}
|
||||
|
||||
async function createPasswordAccount() {
|
||||
state.submitting = true
|
||||
state.error = null
|
||||
state.submitting = true;
|
||||
state.error = null;
|
||||
|
||||
try {
|
||||
let byodToken: string | undefined
|
||||
let byodToken: string | undefined;
|
||||
|
||||
if (state.info.didType === 'web-external' && state.externalDidWeb.keyMode === 'byod' && state.externalDidWeb.byodPrivateKey) {
|
||||
if (
|
||||
state.info.didType === "web-external" &&
|
||||
state.externalDidWeb.keyMode === "byod" &&
|
||||
state.externalDidWeb.byodPrivateKey
|
||||
) {
|
||||
byodToken = await createServiceJwt(
|
||||
state.externalDidWeb.byodPrivateKey,
|
||||
state.info.externalDid!.trim(),
|
||||
getPdsDid(),
|
||||
'com.atproto.server.createAccount'
|
||||
)
|
||||
"com.atproto.server.createAccount",
|
||||
);
|
||||
}
|
||||
|
||||
const result = await api.createAccount({
|
||||
@@ -142,42 +160,49 @@ export function createRegistrationFlow(mode: RegistrationMode, pdsHostname: stri
|
||||
password: state.info.password!,
|
||||
inviteCode: state.info.inviteCode?.trim() || undefined,
|
||||
didType: state.info.didType,
|
||||
did: state.info.didType === 'web-external' ? state.info.externalDid!.trim() : undefined,
|
||||
signingKey: state.info.didType === 'web-external' && state.externalDidWeb.keyMode === 'reserved'
|
||||
did: state.info.didType === "web-external"
|
||||
? state.info.externalDid!.trim()
|
||||
: undefined,
|
||||
signingKey: state.info.didType === "web-external" &&
|
||||
state.externalDidWeb.keyMode === "reserved"
|
||||
? state.externalDidWeb.reservedSigningKey
|
||||
: undefined,
|
||||
verificationChannel: state.info.verificationChannel,
|
||||
discordId: state.info.discordId?.trim() || undefined,
|
||||
telegramUsername: state.info.telegramUsername?.trim() || undefined,
|
||||
signalNumber: state.info.signalNumber?.trim() || undefined,
|
||||
}, byodToken)
|
||||
}, byodToken);
|
||||
|
||||
state.account = {
|
||||
did: result.did,
|
||||
handle: result.handle,
|
||||
}
|
||||
state.step = 'verify'
|
||||
};
|
||||
state.step = "verify";
|
||||
} catch (err) {
|
||||
setError(err)
|
||||
setError(err);
|
||||
} finally {
|
||||
state.submitting = false
|
||||
state.submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createPasskeyAccount() {
|
||||
state.submitting = true
|
||||
state.error = null
|
||||
state.submitting = true;
|
||||
state.error = null;
|
||||
|
||||
try {
|
||||
let byodToken: string | undefined
|
||||
let byodToken: string | undefined;
|
||||
|
||||
if (state.info.didType === 'web-external' && state.externalDidWeb.keyMode === 'byod' && state.externalDidWeb.byodPrivateKey) {
|
||||
if (
|
||||
state.info.didType === "web-external" &&
|
||||
state.externalDidWeb.keyMode === "byod" &&
|
||||
state.externalDidWeb.byodPrivateKey
|
||||
) {
|
||||
byodToken = await createServiceJwt(
|
||||
state.externalDidWeb.byodPrivateKey,
|
||||
state.info.externalDid!.trim(),
|
||||
getPdsDid(),
|
||||
'com.atproto.server.createAccount'
|
||||
)
|
||||
"com.atproto.server.createAccount",
|
||||
);
|
||||
}
|
||||
|
||||
const result = await api.createPasskeyAccount({
|
||||
@@ -185,134 +210,162 @@ export function createRegistrationFlow(mode: RegistrationMode, pdsHostname: stri
|
||||
email: state.info.email?.trim() || undefined,
|
||||
inviteCode: state.info.inviteCode?.trim() || undefined,
|
||||
didType: state.info.didType,
|
||||
did: state.info.didType === 'web-external' ? state.info.externalDid!.trim() : undefined,
|
||||
signingKey: state.info.didType === 'web-external' && state.externalDidWeb.keyMode === 'reserved'
|
||||
did: state.info.didType === "web-external"
|
||||
? state.info.externalDid!.trim()
|
||||
: undefined,
|
||||
signingKey: state.info.didType === "web-external" &&
|
||||
state.externalDidWeb.keyMode === "reserved"
|
||||
? state.externalDidWeb.reservedSigningKey
|
||||
: undefined,
|
||||
verificationChannel: state.info.verificationChannel,
|
||||
discordId: state.info.discordId?.trim() || undefined,
|
||||
telegramUsername: state.info.telegramUsername?.trim() || undefined,
|
||||
signalNumber: state.info.signalNumber?.trim() || undefined,
|
||||
}, byodToken)
|
||||
}, byodToken);
|
||||
|
||||
state.account = {
|
||||
did: result.did,
|
||||
handle: result.handle,
|
||||
setupToken: result.setupToken,
|
||||
}
|
||||
state.step = 'passkey'
|
||||
};
|
||||
state.step = "passkey";
|
||||
} catch (err) {
|
||||
setError(err)
|
||||
setError(err);
|
||||
} finally {
|
||||
state.submitting = false
|
||||
state.submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function setPasskeyComplete(appPassword: string, appPasswordName: string) {
|
||||
if (state.account) {
|
||||
state.account.appPassword = appPassword
|
||||
state.account.appPasswordName = appPasswordName
|
||||
state.account.appPassword = appPassword;
|
||||
state.account.appPasswordName = appPasswordName;
|
||||
}
|
||||
state.step = 'app-password'
|
||||
state.step = "app-password";
|
||||
}
|
||||
|
||||
function proceedFromAppPassword() {
|
||||
state.step = 'verify'
|
||||
state.step = "verify";
|
||||
}
|
||||
|
||||
async function verifyAccount(code: string) {
|
||||
state.submitting = true
|
||||
state.error = null
|
||||
state.submitting = true;
|
||||
state.error = null;
|
||||
|
||||
try {
|
||||
const confirmResult = await api.confirmSignup(state.account!.did, code.trim())
|
||||
const confirmResult = await api.confirmSignup(
|
||||
state.account!.did,
|
||||
code.trim(),
|
||||
);
|
||||
|
||||
if (state.info.didType === 'web-external') {
|
||||
const password = state.mode === 'passkey' ? state.account!.appPassword! : state.info.password!
|
||||
const session = await api.createSession(state.account!.did, password)
|
||||
if (state.info.didType === "web-external") {
|
||||
const password = state.mode === "passkey"
|
||||
? state.account!.appPassword!
|
||||
: state.info.password!;
|
||||
const session = await api.createSession(state.account!.did, password);
|
||||
state.session = {
|
||||
accessJwt: session.accessJwt,
|
||||
refreshJwt: session.refreshJwt,
|
||||
}
|
||||
};
|
||||
|
||||
if (state.externalDidWeb.keyMode === 'byod') {
|
||||
const credentials = await api.getRecommendedDidCredentials(session.accessJwt)
|
||||
const newPublicKeyMultibase = credentials.verificationMethods?.atproto?.replace('did:key:', '') || ''
|
||||
if (state.externalDidWeb.keyMode === "byod") {
|
||||
const credentials = await api.getRecommendedDidCredentials(
|
||||
session.accessJwt,
|
||||
);
|
||||
const newPublicKeyMultibase =
|
||||
credentials.verificationMethods?.atproto?.replace("did:key:", "") ||
|
||||
"";
|
||||
|
||||
const didDoc = generateDidDocument(
|
||||
state.info.externalDid!.trim(),
|
||||
newPublicKeyMultibase,
|
||||
state.account!.handle,
|
||||
getPdsEndpoint()
|
||||
)
|
||||
state.externalDidWeb.updatedDidDocument = JSON.stringify(didDoc, null, '\t')
|
||||
state.step = 'updated-did-doc'
|
||||
getPdsEndpoint(),
|
||||
);
|
||||
state.externalDidWeb.updatedDidDocument = JSON.stringify(
|
||||
didDoc,
|
||||
null,
|
||||
"\t",
|
||||
);
|
||||
state.step = "updated-did-doc";
|
||||
} else {
|
||||
await api.activateAccount(session.accessJwt)
|
||||
await finalizeSession()
|
||||
state.step = 'redirect-to-dashboard'
|
||||
await api.activateAccount(session.accessJwt);
|
||||
await finalizeSession();
|
||||
state.step = "redirect-to-dashboard";
|
||||
}
|
||||
} else {
|
||||
state.session = {
|
||||
accessJwt: confirmResult.accessJwt,
|
||||
refreshJwt: confirmResult.refreshJwt,
|
||||
}
|
||||
await finalizeSession()
|
||||
state.step = 'redirect-to-dashboard'
|
||||
};
|
||||
await finalizeSession();
|
||||
state.step = "redirect-to-dashboard";
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err)
|
||||
setError(err);
|
||||
} finally {
|
||||
state.submitting = false
|
||||
state.submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function activateAccount() {
|
||||
state.submitting = true
|
||||
state.error = null
|
||||
state.submitting = true;
|
||||
state.error = null;
|
||||
|
||||
try {
|
||||
await api.activateAccount(state.session!.accessJwt)
|
||||
await finalizeSession()
|
||||
state.step = 'redirect-to-dashboard'
|
||||
await api.activateAccount(state.session!.accessJwt);
|
||||
await finalizeSession();
|
||||
state.step = "redirect-to-dashboard";
|
||||
} catch (err) {
|
||||
setError(err)
|
||||
setError(err);
|
||||
} finally {
|
||||
state.submitting = false
|
||||
state.submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
switch (state.step) {
|
||||
case 'key-choice':
|
||||
state.step = 'info'
|
||||
break
|
||||
case 'initial-did-doc':
|
||||
state.step = 'key-choice'
|
||||
break
|
||||
case 'passkey':
|
||||
state.step = state.info.didType === 'web-external' ? 'initial-did-doc' : 'info'
|
||||
break
|
||||
case "key-choice":
|
||||
state.step = "info";
|
||||
break;
|
||||
case "initial-did-doc":
|
||||
state.step = "key-choice";
|
||||
break;
|
||||
case "passkey":
|
||||
state.step = state.info.didType === "web-external"
|
||||
? "initial-did-doc"
|
||||
: "info";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function finalizeSession() {
|
||||
if (!state.session || !state.account) return
|
||||
const { setSession } = await import('../auth.svelte')
|
||||
if (!state.session || !state.account) return;
|
||||
const { setSession } = await import("../auth.svelte");
|
||||
setSession({
|
||||
did: state.account.did,
|
||||
handle: state.account.handle,
|
||||
accessJwt: state.session.accessJwt,
|
||||
refreshJwt: state.session.refreshJwt,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
get state() { return state },
|
||||
get info() { return state.info },
|
||||
get externalDidWeb() { return state.externalDidWeb },
|
||||
get account() { return state.account },
|
||||
get session() { return state.session },
|
||||
get state() {
|
||||
return state;
|
||||
},
|
||||
get info() {
|
||||
return state.info;
|
||||
},
|
||||
get externalDidWeb() {
|
||||
return state.externalDidWeb;
|
||||
},
|
||||
get account() {
|
||||
return state.account;
|
||||
},
|
||||
get session() {
|
||||
return state.session;
|
||||
},
|
||||
|
||||
getPdsEndpoint,
|
||||
getPdsDid,
|
||||
@@ -331,10 +384,16 @@ export function createRegistrationFlow(mode: RegistrationMode, pdsHostname: stri
|
||||
finalizeSession,
|
||||
goBack,
|
||||
|
||||
setError(msg: string) { state.error = msg },
|
||||
clearError() { state.error = null },
|
||||
setSubmitting(val: boolean) { state.submitting = val },
|
||||
}
|
||||
setError(msg: string) {
|
||||
state.error = msg;
|
||||
},
|
||||
clearError() {
|
||||
state.error = null;
|
||||
},
|
||||
setSubmitting(val: boolean) {
|
||||
state.submitting = val;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type RegistrationFlow = ReturnType<typeof createRegistrationFlow>
|
||||
export type RegistrationFlow = ReturnType<typeof createRegistrationFlow>;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * from './types'
|
||||
export * from './flow.svelte'
|
||||
export { default as VerificationStep } from './VerificationStep.svelte'
|
||||
export { default as KeyChoiceStep } from './KeyChoiceStep.svelte'
|
||||
export { default as DidDocStep } from './DidDocStep.svelte'
|
||||
export { default as AppPasswordStep } from './AppPasswordStep.svelte'
|
||||
export * from "./types";
|
||||
export * from "./flow.svelte";
|
||||
export { default as VerificationStep } from "./VerificationStep.svelte";
|
||||
export { default as KeyChoiceStep } from "./KeyChoiceStep.svelte";
|
||||
export { default as DidDocStep } from "./DidDocStep.svelte";
|
||||
export { default as AppPasswordStep } from "./AppPasswordStep.svelte";
|
||||
|
||||
@@ -1,50 +1,50 @@
|
||||
import type { VerificationChannel, DidType } from '../api'
|
||||
import type { DidType, VerificationChannel } from "../api";
|
||||
|
||||
export type RegistrationMode = 'password' | 'passkey'
|
||||
export type RegistrationMode = "password" | "passkey";
|
||||
|
||||
export type RegistrationStep =
|
||||
| 'info'
|
||||
| 'key-choice'
|
||||
| 'initial-did-doc'
|
||||
| 'creating'
|
||||
| 'passkey'
|
||||
| 'app-password'
|
||||
| 'verify'
|
||||
| 'updated-did-doc'
|
||||
| 'activating'
|
||||
| 'redirect-to-dashboard'
|
||||
| "info"
|
||||
| "key-choice"
|
||||
| "initial-did-doc"
|
||||
| "creating"
|
||||
| "passkey"
|
||||
| "app-password"
|
||||
| "verify"
|
||||
| "updated-did-doc"
|
||||
| "activating"
|
||||
| "redirect-to-dashboard";
|
||||
|
||||
export interface RegistrationInfo {
|
||||
handle: string
|
||||
email: string
|
||||
password?: string
|
||||
inviteCode?: string
|
||||
didType: DidType
|
||||
externalDid?: string
|
||||
verificationChannel: VerificationChannel
|
||||
discordId?: string
|
||||
telegramUsername?: string
|
||||
signalNumber?: string
|
||||
handle: string;
|
||||
email: string;
|
||||
password?: string;
|
||||
inviteCode?: string;
|
||||
didType: DidType;
|
||||
externalDid?: string;
|
||||
verificationChannel: VerificationChannel;
|
||||
discordId?: string;
|
||||
telegramUsername?: string;
|
||||
signalNumber?: string;
|
||||
}
|
||||
|
||||
export interface ExternalDidWebState {
|
||||
keyMode: 'reserved' | 'byod'
|
||||
reservedSigningKey?: string
|
||||
byodPrivateKey?: Uint8Array
|
||||
byodPublicKeyMultibase?: string
|
||||
initialDidDocument?: string
|
||||
updatedDidDocument?: string
|
||||
keyMode: "reserved" | "byod";
|
||||
reservedSigningKey?: string;
|
||||
byodPrivateKey?: Uint8Array;
|
||||
byodPublicKeyMultibase?: string;
|
||||
initialDidDocument?: string;
|
||||
updatedDidDocument?: string;
|
||||
}
|
||||
|
||||
export interface AccountResult {
|
||||
did: string
|
||||
handle: string
|
||||
setupToken?: string
|
||||
appPassword?: string
|
||||
appPasswordName?: string
|
||||
did: string;
|
||||
handle: string;
|
||||
setupToken?: string;
|
||||
appPassword?: string;
|
||||
appPasswordName?: string;
|
||||
}
|
||||
|
||||
export interface SessionState {
|
||||
accessJwt: string
|
||||
refreshJwt: string
|
||||
accessJwt: string;
|
||||
refreshJwt: string;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
let currentPath = $state(getPathWithoutQuery(window.location.hash.slice(1) || '/'))
|
||||
let currentPath = $state(
|
||||
getPathWithoutQuery(window.location.hash.slice(1) || "/"),
|
||||
);
|
||||
|
||||
function getPathWithoutQuery(hash: string): string {
|
||||
const queryIndex = hash.indexOf('?')
|
||||
return queryIndex === -1 ? hash : hash.slice(0, queryIndex)
|
||||
const queryIndex = hash.indexOf("?");
|
||||
return queryIndex === -1 ? hash : hash.slice(0, queryIndex);
|
||||
}
|
||||
|
||||
window.addEventListener('hashchange', () => {
|
||||
currentPath = getPathWithoutQuery(window.location.hash.slice(1) || '/')
|
||||
})
|
||||
window.addEventListener("hashchange", () => {
|
||||
currentPath = getPathWithoutQuery(window.location.hash.slice(1) || "/");
|
||||
});
|
||||
|
||||
export function navigate(path: string) {
|
||||
currentPath = path
|
||||
window.location.hash = path
|
||||
currentPath = path;
|
||||
window.location.hash = path;
|
||||
}
|
||||
|
||||
export function getCurrentPath() {
|
||||
return currentPath
|
||||
return currentPath;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { api } from './api'
|
||||
import { api } from "./api";
|
||||
|
||||
interface ServerConfigState {
|
||||
serverName: string | null
|
||||
primaryColor: string | null
|
||||
primaryColorDark: string | null
|
||||
secondaryColor: string | null
|
||||
secondaryColorDark: string | null
|
||||
hasLogo: boolean
|
||||
loading: boolean
|
||||
serverName: string | null;
|
||||
primaryColor: string | null;
|
||||
primaryColorDark: string | null;
|
||||
secondaryColor: string | null;
|
||||
secondaryColorDark: string | null;
|
||||
hasLogo: boolean;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
let state = $state<ServerConfigState>({
|
||||
@@ -18,106 +18,114 @@ let state = $state<ServerConfigState>({
|
||||
secondaryColorDark: null,
|
||||
hasLogo: false,
|
||||
loading: true,
|
||||
})
|
||||
});
|
||||
|
||||
let initialized = false
|
||||
let darkModeQuery: MediaQueryList | null = null
|
||||
let initialized = false;
|
||||
let darkModeQuery: MediaQueryList | null = null;
|
||||
|
||||
function isDarkMode(): boolean {
|
||||
return darkModeQuery?.matches ?? false
|
||||
return darkModeQuery?.matches ?? false;
|
||||
}
|
||||
|
||||
function applyColors() {
|
||||
const root = document.documentElement
|
||||
const dark = isDarkMode()
|
||||
const root = document.documentElement;
|
||||
const dark = isDarkMode();
|
||||
|
||||
if (dark) {
|
||||
if (state.primaryColorDark) {
|
||||
root.style.setProperty('--accent', state.primaryColorDark)
|
||||
root.style.setProperty("--accent", state.primaryColorDark);
|
||||
} else {
|
||||
root.style.removeProperty('--accent')
|
||||
root.style.removeProperty("--accent");
|
||||
}
|
||||
if (state.secondaryColorDark) {
|
||||
root.style.setProperty('--secondary', state.secondaryColorDark)
|
||||
root.style.setProperty("--secondary", state.secondaryColorDark);
|
||||
} else {
|
||||
root.style.removeProperty('--secondary')
|
||||
root.style.removeProperty("--secondary");
|
||||
}
|
||||
} else {
|
||||
if (state.primaryColor) {
|
||||
root.style.setProperty('--accent', state.primaryColor)
|
||||
root.style.setProperty("--accent", state.primaryColor);
|
||||
} else {
|
||||
root.style.removeProperty('--accent')
|
||||
root.style.removeProperty("--accent");
|
||||
}
|
||||
if (state.secondaryColor) {
|
||||
root.style.setProperty('--secondary', state.secondaryColor)
|
||||
root.style.setProperty("--secondary", state.secondaryColor);
|
||||
} else {
|
||||
root.style.removeProperty('--secondary')
|
||||
root.style.removeProperty("--secondary");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setFavicon(hasLogo: boolean) {
|
||||
let link = document.querySelector<HTMLLinkElement>("link[rel~='icon']")
|
||||
let link = document.querySelector<HTMLLinkElement>("link[rel~='icon']");
|
||||
if (hasLogo) {
|
||||
if (!link) {
|
||||
link = document.createElement('link')
|
||||
link.rel = 'icon'
|
||||
document.head.appendChild(link)
|
||||
link = document.createElement("link");
|
||||
link.rel = "icon";
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
link.href = '/logo'
|
||||
link.href = "/logo";
|
||||
} else if (link) {
|
||||
link.remove()
|
||||
link.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export async function initServerConfig(): Promise<void> {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
if (initialized) return;
|
||||
initialized = true;
|
||||
|
||||
darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
darkModeQuery.addEventListener('change', applyColors)
|
||||
darkModeQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
darkModeQuery.addEventListener("change", applyColors);
|
||||
|
||||
try {
|
||||
const config = await api.getServerConfig()
|
||||
state.serverName = config.serverName
|
||||
state.primaryColor = config.primaryColor
|
||||
state.primaryColorDark = config.primaryColorDark
|
||||
state.secondaryColor = config.secondaryColor
|
||||
state.secondaryColorDark = config.secondaryColorDark
|
||||
state.hasLogo = !!config.logoCid
|
||||
document.title = config.serverName
|
||||
applyColors()
|
||||
setFavicon(state.hasLogo)
|
||||
const config = await api.getServerConfig();
|
||||
state.serverName = config.serverName;
|
||||
state.primaryColor = config.primaryColor;
|
||||
state.primaryColorDark = config.primaryColorDark;
|
||||
state.secondaryColor = config.secondaryColor;
|
||||
state.secondaryColorDark = config.secondaryColorDark;
|
||||
state.hasLogo = !!config.logoCid;
|
||||
document.title = config.serverName;
|
||||
applyColors();
|
||||
setFavicon(state.hasLogo);
|
||||
} catch {
|
||||
state.serverName = null
|
||||
state.serverName = null;
|
||||
} finally {
|
||||
state.loading = false
|
||||
state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function getServerConfigState() {
|
||||
return state
|
||||
return state;
|
||||
}
|
||||
|
||||
export function setServerName(name: string) {
|
||||
state.serverName = name
|
||||
document.title = name
|
||||
state.serverName = name;
|
||||
document.title = name;
|
||||
}
|
||||
|
||||
export function setColors(colors: {
|
||||
primaryColor?: string | null
|
||||
primaryColorDark?: string | null
|
||||
secondaryColor?: string | null
|
||||
secondaryColorDark?: string | null
|
||||
primaryColor?: string | null;
|
||||
primaryColorDark?: string | null;
|
||||
secondaryColor?: string | null;
|
||||
secondaryColorDark?: string | null;
|
||||
}) {
|
||||
if (colors.primaryColor !== undefined) state.primaryColor = colors.primaryColor
|
||||
if (colors.primaryColorDark !== undefined) state.primaryColorDark = colors.primaryColorDark
|
||||
if (colors.secondaryColor !== undefined) state.secondaryColor = colors.secondaryColor
|
||||
if (colors.secondaryColorDark !== undefined) state.secondaryColorDark = colors.secondaryColorDark
|
||||
applyColors()
|
||||
if (colors.primaryColor !== undefined) {
|
||||
state.primaryColor = colors.primaryColor;
|
||||
}
|
||||
if (colors.primaryColorDark !== undefined) {
|
||||
state.primaryColorDark = colors.primaryColorDark;
|
||||
}
|
||||
if (colors.secondaryColor !== undefined) {
|
||||
state.secondaryColor = colors.secondaryColor;
|
||||
}
|
||||
if (colors.secondaryColorDark !== undefined) {
|
||||
state.secondaryColorDark = colors.secondaryColorDark;
|
||||
}
|
||||
applyColors();
|
||||
}
|
||||
|
||||
export function setHasLogo(hasLogo: boolean) {
|
||||
state.hasLogo = hasLogo
|
||||
setFavicon(hasLogo)
|
||||
state.hasLogo = hasLogo;
|
||||
setFavicon(hasLogo);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,17 @@
|
||||
"lostPasskey": "Lost passkey?",
|
||||
"noAccount": "Don't have an account?",
|
||||
"createAccount": "Create account",
|
||||
"removeAccount": "Remove from saved accounts"
|
||||
"removeAccount": "Remove from saved accounts",
|
||||
"infoSavedAccountsTitle": "Saved accounts",
|
||||
"infoSavedAccountsDesc": "Click an account to sign in instantly. Your session tokens are stored securely in this browser.",
|
||||
"infoNewAccountTitle": "New account",
|
||||
"infoNewAccountDesc": "Use the sign-in button to add a different account. Click the × to remove saved accounts from this browser.",
|
||||
"infoSecureSignInTitle": "Secure sign-in",
|
||||
"infoSecureSignInDesc": "You'll be redirected to authenticate securely. If you have passkeys or two-factor authentication enabled, you'll be prompted for those too.",
|
||||
"infoStaySignedInTitle": "Stay signed in",
|
||||
"infoStaySignedInDesc": "After signing in, your account will be saved to this browser for quick access next time.",
|
||||
"infoRecoveryTitle": "Account recovery",
|
||||
"infoRecoveryDesc": "Lost your password or passkey? Use the recovery links below the sign-in button."
|
||||
},
|
||||
"verification": {
|
||||
"title": "Verify Your Account",
|
||||
@@ -47,6 +57,17 @@
|
||||
"register": {
|
||||
"title": "Create Account",
|
||||
"subtitle": "Create a new account on this PDS",
|
||||
"subtitleKeyChoice": "Choose how to set up your external did:web identity.",
|
||||
"subtitleInitialDidDoc": "Upload your DID document to continue.",
|
||||
"subtitleVerify": "Verify your {channel} to continue.",
|
||||
"subtitleUpdatedDidDoc": "Update your DID document with the PDS signing key.",
|
||||
"subtitleActivating": "Activating your account...",
|
||||
"subtitleComplete": "Your account has been created successfully!",
|
||||
"redirecting": "Redirecting to dashboard...",
|
||||
"infoIdentityDesc": "Your identity determines how your account is identified across the ATProto network. Most users should choose the standard option.",
|
||||
"infoContactDesc": "We'll use this to verify your account and send important notifications about your account security.",
|
||||
"infoNextTitle": "What happens next?",
|
||||
"infoNextDesc": "After creating your account, you'll verify your contact method and then you're ready to use any ATProto app with your new identity.",
|
||||
"migrateTitle": "Already have a Bluesky account?",
|
||||
"migrateDescription": "You can migrate your existing account to this PDS instead of creating a new one. Your followers, posts, and identity will come with you.",
|
||||
"migrateLink": "Migrate with PDS Moover",
|
||||
@@ -211,12 +232,22 @@
|
||||
"messages": {
|
||||
"emailCodeSent": "Verification code sent to your notification channel",
|
||||
"emailUpdated": "Email updated successfully",
|
||||
"emailUpdateFailed": "Failed to update email",
|
||||
"handleUpdated": "Handle updated successfully",
|
||||
"handleUpdateFailed": "Failed to update handle",
|
||||
"passwordChanged": "Password changed successfully",
|
||||
"passwordChangeFailed": "Failed to change password",
|
||||
"passwordsMismatch": "Passwords do not match",
|
||||
"passwordsDoNotMatch": "Passwords do not match",
|
||||
"passwordLength": "Password must be at least 8 characters",
|
||||
"passwordTooShort": "Password must be at least 8 characters",
|
||||
"deletionCodeSent": "Deletion confirmation sent to your email",
|
||||
"deletionConfirmationSent": "Deletion confirmation sent to your email",
|
||||
"deletionRequestFailed": "Failed to request account deletion",
|
||||
"deleteConfirmation": "Are you absolutely sure you want to delete your account? This cannot be undone.",
|
||||
"deletionFailed": "Failed to delete account",
|
||||
"repoExported": "Repository exported successfully",
|
||||
"exportFailed": "Failed to export repository",
|
||||
"confirmDelete": "Are you absolutely sure you want to delete your account? This cannot be undone."
|
||||
}
|
||||
},
|
||||
@@ -362,6 +393,7 @@
|
||||
"manageTrustedDevices": "Manage Trusted Devices",
|
||||
"appCompatibility": "App Compatibility",
|
||||
"enterPassword": "Enter your password",
|
||||
"sessionExpired": "Session expired. Please log in again.",
|
||||
"legacyLoginEnabled": "Legacy app login enabled",
|
||||
"legacyLoginDisabled": "Legacy app login disabled - only OAuth apps can sign in",
|
||||
"failedToUpdatePreference": "Failed to update preference",
|
||||
@@ -421,6 +453,7 @@
|
||||
"noRecords": "No records in this collection",
|
||||
"recordDetails": "Record Details",
|
||||
"rkey": "Record Key",
|
||||
"uri": "URI",
|
||||
"cid": "CID",
|
||||
"value": "Value",
|
||||
"deleteRecord": "Delete Record",
|
||||
@@ -463,13 +496,10 @@
|
||||
"themeColors": "Theme Colors",
|
||||
"themeColorsHint": "Leave blank to use default colors.",
|
||||
"primaryLight": "Primary (Light Mode)",
|
||||
"primaryLightDefault": "#2c00ff (default)",
|
||||
"colorDefault": "{color} (default)",
|
||||
"primaryDark": "Primary (Dark Mode)",
|
||||
"primaryDarkDefault": "#7b6bff (default)",
|
||||
"secondaryLight": "Secondary (Light Mode)",
|
||||
"secondaryLightDefault": "#ff2400 (default)",
|
||||
"secondaryDark": "Secondary (Dark Mode)",
|
||||
"secondaryDarkDefault": "#ff6b5b (default)",
|
||||
"configSaved": "Server configuration saved",
|
||||
"saving": "Saving...",
|
||||
"saveConfig": "Save Configuration",
|
||||
@@ -527,7 +557,10 @@
|
||||
"rememberDevice": "Remember this device",
|
||||
"passkeyHintChecking": "Checking passkey status...",
|
||||
"passkeyHintAvailable": "Sign in with your passkey",
|
||||
"passkeyHintNotAvailable": "No passkeys registered for this account"
|
||||
"passkeyHintNotAvailable": "No passkeys registered for this account",
|
||||
"passkeyHint": "Use your device's biometrics or security key",
|
||||
"passwordPlaceholder": "Enter your password",
|
||||
"usePasskey": "Use Passkey"
|
||||
},
|
||||
"consent": {
|
||||
"title": "Authorize Application",
|
||||
@@ -741,6 +774,7 @@
|
||||
"didWebBYODHint": "Bring your own domain",
|
||||
"didWebWarningTitle": "Important: Understand the trade-offs",
|
||||
"didWebWarning1": "Permanent tie to this PDS:",
|
||||
"didWebWarning1Detail": "Your identity will be {did}.",
|
||||
"didWebWarning2": "No recovery mechanism:",
|
||||
"didWebWarning2Detail": "Unlike did:plc, did:web has no rotation keys.",
|
||||
"didWebWarning3": "We commit to you:",
|
||||
@@ -785,6 +819,7 @@
|
||||
"title": "Trusted Devices",
|
||||
"backToSecurity": "← Security Settings",
|
||||
"description": "Trusted devices can skip two-factor authentication when logging in. Trust is granted for 30 days and automatically extends when you use the device.",
|
||||
"failedToLoad": "Failed to load trusted devices",
|
||||
"noDevices": "No trusted devices yet.",
|
||||
"noDevicesHint": "When you log in with two-factor authentication enabled, you can choose to trust the device for 30 days.",
|
||||
"lastSeen": "Last seen:",
|
||||
|
||||
@@ -30,7 +30,17 @@
|
||||
"lostPasskey": "Kadotitko pääsyavaimen?",
|
||||
"noAccount": "Eikö sinulla ole tiliä?",
|
||||
"createAccount": "Luo tili",
|
||||
"removeAccount": "Poista tallennetuista tileistä"
|
||||
"removeAccount": "Poista tallennetuista tileistä",
|
||||
"infoSavedAccountsTitle": "Tallennetut tilit",
|
||||
"infoSavedAccountsDesc": "Napsauta tiliä kirjautuaksesi heti. Istuntotunnuksesi on tallennettu turvallisesti tähän selaimeen.",
|
||||
"infoNewAccountTitle": "Uusi tili",
|
||||
"infoNewAccountDesc": "Käytä kirjautumispainiketta lisätäksesi toisen tilin. Napsauta × poistaaksesi tallennettuja tilejä.",
|
||||
"infoSecureSignInTitle": "Turvallinen kirjautuminen",
|
||||
"infoSecureSignInDesc": "Sinut ohjataan turvalliseen todennukseen. Jos sinulla on pääsyavaimia tai kaksivaiheinen tunnistautuminen käytössä, sinulta pyydetään myös ne.",
|
||||
"infoStaySignedInTitle": "Pysy kirjautuneena",
|
||||
"infoStaySignedInDesc": "Kirjautumisen jälkeen tilisi tallennetaan tähän selaimeen nopeaa pääsyä varten.",
|
||||
"infoRecoveryTitle": "Tilin palautus",
|
||||
"infoRecoveryDesc": "Kadotitko salasanasi tai pääsyavaimesi? Käytä palautuslinkkejä kirjautumispainikkeen alla."
|
||||
},
|
||||
"verification": {
|
||||
"title": "Vahvista tilisi",
|
||||
@@ -47,6 +57,17 @@
|
||||
"register": {
|
||||
"title": "Luo tili",
|
||||
"subtitle": "Luo uusi tili tälle PDS:lle",
|
||||
"subtitleKeyChoice": "Valitse, miten haluat määrittää ulkoisen did:web-identiteettisi.",
|
||||
"subtitleInitialDidDoc": "Lataa DID-dokumenttisi jatkaaksesi.",
|
||||
"subtitleVerify": "Vahvista {channel} jatkaaksesi.",
|
||||
"subtitleUpdatedDidDoc": "Päivitä DID-dokumenttisi PDS-allekirjoitusavaimella.",
|
||||
"subtitleActivating": "Aktivoidaan tiliäsi...",
|
||||
"subtitleComplete": "Tilisi on luotu onnistuneesti!",
|
||||
"redirecting": "Siirrytään kojelaudalle...",
|
||||
"infoIdentityDesc": "Identiteettisi määrittää, miten tilisi tunnistetaan ATProto-verkossa. Useimpien käyttäjien tulisi valita vakiovaihtoehto.",
|
||||
"infoContactDesc": "Käytämme tätä tilisi vahvistamiseen ja tärkeiden turvallisuusilmoitusten lähettämiseen.",
|
||||
"infoNextTitle": "Mitä tapahtuu seuraavaksi?",
|
||||
"infoNextDesc": "Tilin luomisen jälkeen vahvistat yhteysmenetelmäsi ja olet valmis käyttämään mitä tahansa ATProto-sovellusta uudella identiteetilläsi.",
|
||||
"migrateTitle": "Onko sinulla jo Bluesky-tili?",
|
||||
"migrateDescription": "Voit siirtää olemassa olevan tilisi tälle PDS:lle uuden luomisen sijaan. Seuraajasi, julkaisusi ja identiteettisi siirtyvät mukana.",
|
||||
"migrateLink": "Siirrä PDS Mooverilla",
|
||||
@@ -211,12 +232,22 @@
|
||||
"messages": {
|
||||
"emailCodeSent": "Vahvistuskoodi lähetetty ilmoituskanavallesi",
|
||||
"emailUpdated": "Sähköposti päivitetty",
|
||||
"emailUpdateFailed": "Sähköpostin päivitys epäonnistui",
|
||||
"handleUpdated": "Käyttäjänimi päivitetty",
|
||||
"handleUpdateFailed": "Käyttäjänimen päivitys epäonnistui",
|
||||
"passwordChanged": "Salasana vaihdettu",
|
||||
"passwordChangeFailed": "Salasanan vaihto epäonnistui",
|
||||
"passwordsMismatch": "Salasanat eivät täsmää",
|
||||
"passwordsDoNotMatch": "Salasanat eivät täsmää",
|
||||
"passwordLength": "Salasanan on oltava vähintään 8 merkkiä",
|
||||
"passwordTooShort": "Salasanan on oltava vähintään 8 merkkiä",
|
||||
"deletionCodeSent": "Poistovahvistus lähetetty sähköpostiisi",
|
||||
"deletionConfirmationSent": "Poistovahvistus lähetetty sähköpostiisi",
|
||||
"deletionRequestFailed": "Tilin poistopyyntö epäonnistui",
|
||||
"deleteConfirmation": "Oletko täysin varma, että haluat poistaa tilisi? Tätä ei voi perua.",
|
||||
"deletionFailed": "Tilin poisto epäonnistui",
|
||||
"repoExported": "Tietovarasto viety",
|
||||
"exportFailed": "Tietovaraston vienti epäonnistui",
|
||||
"confirmDelete": "Oletko täysin varma, että haluat poistaa tilisi? Tätä ei voi perua."
|
||||
}
|
||||
},
|
||||
@@ -362,6 +393,7 @@
|
||||
"manageTrustedDevices": "Hallitse luotettuja laitteita",
|
||||
"appCompatibility": "Sovellusyhteensopivuus",
|
||||
"enterPassword": "Syötä salasanasi",
|
||||
"sessionExpired": "Istunto vanhentunut. Kirjaudu sisään uudelleen.",
|
||||
"legacyLoginEnabled": "Vanhentuneiden sovellusten kirjautuminen käytössä",
|
||||
"legacyLoginDisabled": "Vanhentuneiden sovellusten kirjautuminen poistettu käytöstä - vain OAuth-sovellukset voivat kirjautua",
|
||||
"failedToUpdatePreference": "Asetuksen päivittäminen epäonnistui",
|
||||
@@ -421,6 +453,7 @@
|
||||
"noRecords": "Ei tietueita tässä kokoelmassa",
|
||||
"recordDetails": "Tietueen tiedot",
|
||||
"rkey": "Tietueavain",
|
||||
"uri": "URI",
|
||||
"cid": "CID",
|
||||
"value": "Arvo",
|
||||
"deleteRecord": "Poista tietue",
|
||||
@@ -464,9 +497,6 @@
|
||||
"themeColorsHint": "Jätä tyhjäksi käyttääksesi oletusvärejä.",
|
||||
"primaryLight": "Ensisijainen (vaalea tila)",
|
||||
"primaryDark": "Ensisijainen (tumma tila)",
|
||||
"accentLight": "Korostus (vaalea tila)",
|
||||
"accentDark": "Korostus (tumma tila)",
|
||||
"faviconExample": "Favicon-esimerkki",
|
||||
"configSaved": "Palvelinasetukset tallennettu",
|
||||
"saving": "Tallennetaan...",
|
||||
"saveConfig": "Tallenna asetukset",
|
||||
@@ -508,7 +538,10 @@
|
||||
"deleteConfirm": "Poista tili @{handle}? Tätä ei voi perua.",
|
||||
"verified": "Vahvistettu",
|
||||
"unverified": "Vahvistamaton",
|
||||
"deactivated": "Poistettu käytöstä"
|
||||
"deactivated": "Poistettu käytöstä",
|
||||
"colorDefault": "{color} (oletus)",
|
||||
"secondaryLight": "Toissijainen (vaalea tila)",
|
||||
"secondaryDark": "Toissijainen (tumma tila)"
|
||||
},
|
||||
"oauth": {
|
||||
"login": {
|
||||
@@ -524,7 +557,10 @@
|
||||
"rememberDevice": "Muista tämä laite",
|
||||
"passkeyHintChecking": "Tarkistetaan pääsyavaimen tilaa...",
|
||||
"passkeyHintAvailable": "Kirjaudu pääsyavaimellasi",
|
||||
"passkeyHintNotAvailable": "Ei rekisteröityjä pääsyavaimia tälle tilille"
|
||||
"passkeyHintNotAvailable": "Ei rekisteröityjä pääsyavaimia tälle tilille",
|
||||
"passkeyHint": "Käytä laitteesi biometriikkaa tai suojausavainta",
|
||||
"passwordPlaceholder": "Syötä salasanasi",
|
||||
"usePasskey": "Käytä pääsyavainta"
|
||||
},
|
||||
"consent": {
|
||||
"title": "Valtuuta sovellus",
|
||||
@@ -740,13 +776,66 @@
|
||||
"handleNoDots": "Käyttäjänimi ei voi sisältää pisteitä. Voit määrittää oman verkkotunnuksen tilin luomisen jälkeen.",
|
||||
"passkeysNotSupported": "Pääsyavaimia ei tueta tässä selaimessa. Luo salasanapohjainen tili tai käytä selainta, joka tukee pääsyavaimia.",
|
||||
"passkeyCancelled": "Pääsyavaimen luominen peruutettu",
|
||||
"passkeyFailed": "Pääsyavaimen rekisteröinti epäonnistui"
|
||||
}
|
||||
"passkeyFailed": "Pääsyavaimen rekisteröinti epäonnistui",
|
||||
"signalRequired": "Puhelinnumero vaaditaan Signal-vahvistukseen",
|
||||
"inviteRequired": "Kutsukoodi vaaditaan",
|
||||
"externalDidRequired": "Ulkoinen did:web vaaditaan",
|
||||
"emailRequired": "Sähköposti vaaditaan sähköpostivahvistukseen",
|
||||
"telegramRequired": "Telegram-käyttäjänimi vaaditaan Telegram-vahvistukseen",
|
||||
"externalDidFormat": "Ulkoisen DID:n on alettava did:web:",
|
||||
"discordRequired": "Discord-tunnus vaaditaan Discord-vahvistukseen"
|
||||
},
|
||||
"whyPasskeyBullet1": "Ei voi kalastella tai varastaa tietomurroissa",
|
||||
"whyPasskeyBullet2": "Käyttää laitteistopohjaisia salausavaimia",
|
||||
"whyPasskeyBullet3": "Vaatii biometrisen tunnistuksen tai laitteen PIN-koodin",
|
||||
"whyPasskeyOnly": "Miksi vain pääsyavain?",
|
||||
"whyPasskeyOnlyDesc": "Pääsyavaintilit ovat turvallisempia kuin salasanapohjaiset tilit, koska ne:",
|
||||
"subtitleInitialDidDoc": "Lataa DID-dokumenttisi jatkaaksesi.",
|
||||
"subtitleUpdatedDidDoc": "Päivitä DID-dokumenttisi PDS-allekirjoitusavaimella.",
|
||||
"subtitleActivating": "Aktivoidaan tiliäsi...",
|
||||
"subtitleComplete": "Tilisi on luotu onnistuneesti!",
|
||||
"subtitleCreating": "Luodaan tiliäsi...",
|
||||
"subtitleAppPassword": "Tallenna sovellussalasanasi kolmannen osapuolen sovelluksia varten.",
|
||||
"creatingPasskey": "Luodaan pääsyavainta...",
|
||||
"passkeyPrompt": "Napsauta alla olevaa painiketta luodaksesi pääsyavaimesi. Sinua pyydetään käyttämään:",
|
||||
"passkeyPromptBullet1": "Touch ID tai Face ID",
|
||||
"passkeyPromptBullet2": "Laitteesi PIN-koodi tai salasana",
|
||||
"passkeyPromptBullet3": "Turva-avain (jos sinulla on sellainen)",
|
||||
"identityType": "Identiteettityyppi",
|
||||
"identityTypeHint": "Valitse, miten hajautettua identiteettiäsi hallitaan.",
|
||||
"passkeyNameLabel": "Pääsyavaimen nimi (valinnainen)",
|
||||
"passkeyNamePlaceholder": "esim. MacBook Touch ID",
|
||||
"passkeyNameHint": "Ystävällinen nimi tämän pääsyavaimen tunnistamiseksi",
|
||||
"createPasskey": "Luo pääsyavain",
|
||||
"didPlcRecommended": "did:plc (Suositeltava)",
|
||||
"didPlcHint": "Siirrettävä identiteetti, jota hallinnoi PLC Directory",
|
||||
"didWeb": "did:web",
|
||||
"didWebHint": "Tällä PDS:llä isännöity identiteetti (lue varoitus alla)",
|
||||
"didWebBYOD": "did:web (BYOD)",
|
||||
"didWebBYODHint": "Tuo oma verkkotunnuksesi",
|
||||
"didWebWarningTitle": "Tärkeää: Ymmärrä kompromissit",
|
||||
"didWebWarning1": "Pysyvä sidos tähän PDS:ään:",
|
||||
"didWebWarning1Detail": "Identiteettisi {did} on sidottu tähän palvelimeen.",
|
||||
"didWebWarning2": "Ei palautusmekanismia:",
|
||||
"didWebWarning2Detail": "Toisin kuin did:plc, did:web ei sisällä kiertoavaimia.",
|
||||
"didWebWarning3": "Sitoudumme sinulle:",
|
||||
"didWebWarning3Detail": "Jos siirryt pois, jatkamme minimaalisen DID-dokumentin tarjoamista.",
|
||||
"didWebWarning4": "Suositus:",
|
||||
"didWebWarning4Detail": "Valitse did:plc, ellei sinulla ole erityistä syytä suosia did:web.",
|
||||
"externalDidHint": "Sinun on tarjottava DID-dokumentti osoitteessa",
|
||||
"continue": "Jatka",
|
||||
"back": "Takaisin",
|
||||
"loading": "Ladataan...",
|
||||
"redirecting": "Ohjataan hallintapaneeliin...",
|
||||
"handleDotWarning": "Mukautetut verkkotunnuskahvat voidaan määrittää tilin luomisen jälkeen.",
|
||||
"wantTraditional": "Haluatko perinteisen salasanan?",
|
||||
"registerWithPassword": "Rekisteröidy salasanalla"
|
||||
},
|
||||
"trustedDevices": {
|
||||
"title": "Luotetut laitteet",
|
||||
"backToSecurity": "← Turvallisuusasetukset",
|
||||
"description": "Luotetut laitteet voivat ohittaa kaksivaiheisen tunnistautumisen kirjautuessaan. Luottamus myönnetään 30 päiväksi ja jatkuu automaattisesti, kun käytät laitetta.",
|
||||
"failedToLoad": "Luotettujen laitteiden lataaminen epäonnistui",
|
||||
"noDevices": "Ei vielä luotettuja laitteita.",
|
||||
"noDevicesHint": "Kun kirjaudut sisään kaksivaiheisen tunnistautumisen ollessa käytössä, voit valita luottaa laitteeseen 30 päivää.",
|
||||
"lastSeen": "Viimeksi nähty:",
|
||||
|
||||
@@ -30,7 +30,17 @@
|
||||
"lostPasskey": "パスキーを紛失しましたか?",
|
||||
"noAccount": "アカウントをお持ちでないですか?",
|
||||
"createAccount": "アカウントを作成",
|
||||
"removeAccount": "保存済みアカウントから削除"
|
||||
"removeAccount": "保存済みアカウントから削除",
|
||||
"infoSavedAccountsTitle": "保存済みアカウント",
|
||||
"infoSavedAccountsDesc": "アカウントをクリックすると即座にサインインできます。セッショントークンはこのブラウザに安全に保存されています。",
|
||||
"infoNewAccountTitle": "新規アカウント",
|
||||
"infoNewAccountDesc": "サインインボタンで別のアカウントを追加できます。×をクリックすると保存済みアカウントを削除できます。",
|
||||
"infoSecureSignInTitle": "安全なサインイン",
|
||||
"infoSecureSignInDesc": "安全な認証のためにリダイレクトされます。パスキーや二要素認証が有効な場合は、それらも求められます。",
|
||||
"infoStaySignedInTitle": "サインイン状態を維持",
|
||||
"infoStaySignedInDesc": "サインイン後、アカウントはこのブラウザに保存され、次回から素早くアクセスできます。",
|
||||
"infoRecoveryTitle": "アカウント復旧",
|
||||
"infoRecoveryDesc": "パスワードやパスキーを紛失しましたか?サインインボタンの下の復旧リンクをご利用ください。"
|
||||
},
|
||||
"verification": {
|
||||
"title": "アカウント確認",
|
||||
@@ -47,6 +57,17 @@
|
||||
"register": {
|
||||
"title": "アカウント作成",
|
||||
"subtitle": "この PDS で新規アカウントを作成",
|
||||
"subtitleKeyChoice": "外部 did:web アイデンティティの設定方法を選択してください。",
|
||||
"subtitleInitialDidDoc": "続行するには DID ドキュメントをアップロードしてください。",
|
||||
"subtitleVerify": "続行するには{channel}を確認してください。",
|
||||
"subtitleUpdatedDidDoc": "PDS 署名キーで DID ドキュメントを更新してください。",
|
||||
"subtitleActivating": "アカウントを有効化しています...",
|
||||
"subtitleComplete": "アカウントが正常に作成されました!",
|
||||
"redirecting": "ダッシュボードへ移動中...",
|
||||
"infoIdentityDesc": "アイデンティティは、ATProto ネットワーク上でアカウントがどのように識別されるかを決定します。ほとんどのユーザーは標準オプションを選択してください。",
|
||||
"infoContactDesc": "この情報はアカウントの確認と、アカウントセキュリティに関する重要な通知の送信に使用されます。",
|
||||
"infoNextTitle": "次のステップは?",
|
||||
"infoNextDesc": "アカウント作成後、連絡方法を確認すると、新しいアイデンティティで任意の ATProto アプリを使用できます。",
|
||||
"migrateTitle": "すでにBlueskyアカウントをお持ちですか?",
|
||||
"migrateDescription": "新しいアカウントを作成する代わりに、既存のアカウントをこのPDSに移行できます。フォロワー、投稿、IDも一緒に移行されます。",
|
||||
"migrateLink": "PDS Mooverで移行する",
|
||||
@@ -211,12 +232,22 @@
|
||||
"messages": {
|
||||
"emailCodeSent": "通知チャンネルに確認コードを送信しました",
|
||||
"emailUpdated": "メールを更新しました",
|
||||
"emailUpdateFailed": "メールの更新に失敗しました",
|
||||
"handleUpdated": "ハンドルを更新しました",
|
||||
"handleUpdateFailed": "ハンドルの更新に失敗しました",
|
||||
"passwordChanged": "パスワードを変更しました",
|
||||
"passwordChangeFailed": "パスワードの変更に失敗しました",
|
||||
"passwordsMismatch": "パスワードが一致しません",
|
||||
"passwordsDoNotMatch": "パスワードが一致しません",
|
||||
"passwordLength": "パスワードは8文字以上である必要があります",
|
||||
"passwordTooShort": "パスワードは8文字以上である必要があります",
|
||||
"deletionCodeSent": "削除確認をメールに送信しました",
|
||||
"deletionConfirmationSent": "削除確認をメールに送信しました",
|
||||
"deletionRequestFailed": "アカウント削除リクエストに失敗しました",
|
||||
"deleteConfirmation": "本当にアカウントを削除しますか?この操作は取り消せません。",
|
||||
"deletionFailed": "アカウントの削除に失敗しました",
|
||||
"repoExported": "リポジトリをエクスポートしました",
|
||||
"exportFailed": "リポジトリのエクスポートに失敗しました",
|
||||
"confirmDelete": "本当にアカウントを削除しますか?この操作は取り消せません。"
|
||||
}
|
||||
},
|
||||
@@ -362,6 +393,7 @@
|
||||
"manageTrustedDevices": "信頼済みデバイスを管理",
|
||||
"appCompatibility": "アプリ互換性",
|
||||
"enterPassword": "パスワードを入力",
|
||||
"sessionExpired": "セッションが期限切れです。再度ログインしてください。",
|
||||
"legacyLoginEnabled": "レガシーアプリログインが有効",
|
||||
"legacyLoginDisabled": "レガシーアプリログインが無効 - OAuth アプリのみサインイン可能",
|
||||
"failedToUpdatePreference": "設定の更新に失敗しました",
|
||||
@@ -421,6 +453,7 @@
|
||||
"noRecords": "このコレクションにレコードはありません",
|
||||
"recordDetails": "レコード詳細",
|
||||
"rkey": "レコードキー",
|
||||
"uri": "URI",
|
||||
"cid": "CID",
|
||||
"value": "値",
|
||||
"deleteRecord": "レコードを削除",
|
||||
@@ -464,9 +497,6 @@
|
||||
"themeColorsHint": "デフォルトカラーを使用する場合は空白のままにしてください。",
|
||||
"primaryLight": "プライマリ(ライトモード)",
|
||||
"primaryDark": "プライマリ(ダークモード)",
|
||||
"accentLight": "アクセント(ライトモード)",
|
||||
"accentDark": "アクセント(ダークモード)",
|
||||
"faviconExample": "ファビコン例",
|
||||
"configSaved": "サーバー設定を保存しました",
|
||||
"saving": "保存中...",
|
||||
"saveConfig": "設定を保存",
|
||||
@@ -508,7 +538,10 @@
|
||||
"deleteConfirm": "アカウント @{handle} を削除しますか?この操作は取り消せません。",
|
||||
"verified": "確認済み",
|
||||
"unverified": "未確認",
|
||||
"deactivated": "無効化"
|
||||
"deactivated": "無効化",
|
||||
"colorDefault": "{color}(デフォルト)",
|
||||
"secondaryLight": "セカンダリ(ライトモード)",
|
||||
"secondaryDark": "セカンダリ(ダークモード)"
|
||||
},
|
||||
"oauth": {
|
||||
"login": {
|
||||
@@ -524,7 +557,10 @@
|
||||
"rememberDevice": "このデバイスを記憶する",
|
||||
"passkeyHintChecking": "パスキーの状態を確認中...",
|
||||
"passkeyHintAvailable": "パスキーでサインイン",
|
||||
"passkeyHintNotAvailable": "このアカウントにはパスキーが登録されていません"
|
||||
"passkeyHintNotAvailable": "このアカウントにはパスキーが登録されていません",
|
||||
"passkeyHint": "デバイスの生体認証またはセキュリティキーを使用",
|
||||
"passwordPlaceholder": "パスワードを入力",
|
||||
"usePasskey": "パスキーを使用"
|
||||
},
|
||||
"consent": {
|
||||
"title": "アプリを承認",
|
||||
@@ -740,13 +776,66 @@
|
||||
"handleNoDots": "ハンドルにドットは使用できません。アカウント作成後にカスタムドメインを設定できます。",
|
||||
"passkeysNotSupported": "このブラウザではパスキーがサポートされていません。パスワードベースのアカウントを作成するか、パスキーをサポートするブラウザを使用してください。",
|
||||
"passkeyCancelled": "パスキーの作成がキャンセルされました",
|
||||
"passkeyFailed": "パスキーの登録に失敗しました"
|
||||
}
|
||||
"passkeyFailed": "パスキーの登録に失敗しました",
|
||||
"signalRequired": "Signal認証には電話番号が必要です",
|
||||
"inviteRequired": "招待コードが必要です",
|
||||
"externalDidRequired": "外部did:webが必要です",
|
||||
"emailRequired": "メール認証にはメールアドレスが必要です",
|
||||
"telegramRequired": "Telegram認証にはTelegramユーザー名が必要です",
|
||||
"externalDidFormat": "外部DIDはdid:web:で始まる必要があります",
|
||||
"discordRequired": "Discord認証にはDiscord IDが必要です"
|
||||
},
|
||||
"whyPasskeyBullet1": "フィッシングやデータ侵害で盗まれない",
|
||||
"whyPasskeyBullet2": "ハードウェア支援の暗号鍵を使用",
|
||||
"whyPasskeyBullet3": "生体認証またはデバイスPINが必要",
|
||||
"whyPasskeyOnly": "なぜパスキーのみ?",
|
||||
"whyPasskeyOnlyDesc": "パスキーアカウントはパスワードベースのアカウントより安全です:",
|
||||
"subtitleInitialDidDoc": "続行するにはDIDドキュメントをアップロードしてください。",
|
||||
"subtitleUpdatedDidDoc": "PDS署名鍵でDIDドキュメントを更新してください。",
|
||||
"subtitleActivating": "アカウントを有効化しています...",
|
||||
"subtitleComplete": "アカウントが正常に作成されました!",
|
||||
"subtitleCreating": "アカウントを作成しています...",
|
||||
"subtitleAppPassword": "サードパーティアプリ用のアプリパスワードを保存してください。",
|
||||
"creatingPasskey": "パスキーを作成中...",
|
||||
"passkeyPrompt": "下のボタンをクリックしてパスキーを作成してください。以下の使用を求められます:",
|
||||
"passkeyPromptBullet1": "Touch IDまたはFace ID",
|
||||
"passkeyPromptBullet2": "デバイスのPINまたはパスワード",
|
||||
"passkeyPromptBullet3": "セキュリティキー(お持ちの場合)",
|
||||
"identityType": "アイデンティティタイプ",
|
||||
"identityTypeHint": "分散型アイデンティティの管理方法を選択してください。",
|
||||
"passkeyNameLabel": "パスキー名(任意)",
|
||||
"passkeyNamePlaceholder": "例:MacBook Touch ID",
|
||||
"passkeyNameHint": "このパスキーを識別するための名前",
|
||||
"createPasskey": "パスキーを作成",
|
||||
"didPlcRecommended": "did:plc(推奨)",
|
||||
"didPlcHint": "PLC Directoryで管理されるポータブルなアイデンティティ",
|
||||
"didWeb": "did:web",
|
||||
"didWebHint": "このPDSでホストされるアイデンティティ(以下の警告を参照)",
|
||||
"didWebBYOD": "did:web(BYOD)",
|
||||
"didWebBYODHint": "独自ドメインを持ち込む",
|
||||
"didWebWarningTitle": "重要:トレードオフを理解する",
|
||||
"didWebWarning1": "このPDSへの永続的な紐付け:",
|
||||
"didWebWarning1Detail": "あなたのアイデンティティ{did}はこのサーバーに紐付けられます。",
|
||||
"didWebWarning2": "回復メカニズムなし:",
|
||||
"didWebWarning2Detail": "did:plcと異なり、did:webにはローテーションキーがありません。",
|
||||
"didWebWarning3": "私たちの約束:",
|
||||
"didWebWarning3Detail": "移行後も最小限のDIDドキュメントを提供し続けます。",
|
||||
"didWebWarning4": "推奨事項:",
|
||||
"didWebWarning4Detail": "did:webを好む特別な理由がない限り、did:plcを選択してください。",
|
||||
"externalDidHint": "以下の場所でDIDドキュメントを提供する必要があります",
|
||||
"continue": "続行",
|
||||
"back": "戻る",
|
||||
"loading": "読み込み中...",
|
||||
"redirecting": "ダッシュボードに移動中...",
|
||||
"handleDotWarning": "カスタムドメインハンドルはアカウント作成後に設定できます。",
|
||||
"wantTraditional": "従来のパスワードを使用しますか?",
|
||||
"registerWithPassword": "パスワードで登録"
|
||||
},
|
||||
"trustedDevices": {
|
||||
"title": "信頼済みデバイス",
|
||||
"backToSecurity": "← セキュリティ設定",
|
||||
"description": "信頼済みデバイスはログイン時に二要素認証をスキップできます。信頼は30日間有効で、デバイスを使用すると自動的に延長されます。",
|
||||
"failedToLoad": "信頼済みデバイスの読み込みに失敗しました",
|
||||
"noDevices": "信頼済みデバイスはまだありません。",
|
||||
"noDevicesHint": "二要素認証を有効にしてログインする際に、デバイスを30日間信頼することを選択できます。",
|
||||
"lastSeen": "最終使用:",
|
||||
|
||||
@@ -30,7 +30,17 @@
|
||||
"lostPasskey": "패스키를 분실하셨나요?",
|
||||
"noAccount": "계정이 없으신가요?",
|
||||
"createAccount": "계정 만들기",
|
||||
"removeAccount": "저장된 계정에서 삭제"
|
||||
"removeAccount": "저장된 계정에서 삭제",
|
||||
"infoSavedAccountsTitle": "저장된 계정",
|
||||
"infoSavedAccountsDesc": "계정을 클릭하면 즉시 로그인할 수 있습니다. 세션 토큰은 이 브라우저에 안전하게 저장됩니다.",
|
||||
"infoNewAccountTitle": "새 계정",
|
||||
"infoNewAccountDesc": "로그인 버튼을 사용하여 다른 계정을 추가하세요. ×를 클릭하여 저장된 계정을 제거할 수 있습니다.",
|
||||
"infoSecureSignInTitle": "안전한 로그인",
|
||||
"infoSecureSignInDesc": "안전한 인증을 위해 리디렉션됩니다. 패스키나 2단계 인증이 활성화되어 있으면 해당 인증도 요청됩니다.",
|
||||
"infoStaySignedInTitle": "로그인 유지",
|
||||
"infoStaySignedInDesc": "로그인 후 계정이 이 브라우저에 저장되어 다음에 빠르게 접속할 수 있습니다.",
|
||||
"infoRecoveryTitle": "계정 복구",
|
||||
"infoRecoveryDesc": "비밀번호나 패스키를 분실하셨나요? 로그인 버튼 아래의 복구 링크를 사용하세요."
|
||||
},
|
||||
"verification": {
|
||||
"title": "계정 인증",
|
||||
@@ -47,6 +57,17 @@
|
||||
"register": {
|
||||
"title": "계정 만들기",
|
||||
"subtitle": "이 PDS에 새 계정을 만듭니다",
|
||||
"subtitleKeyChoice": "외부 did:web 신원을 설정하는 방법을 선택하세요.",
|
||||
"subtitleInitialDidDoc": "계속하려면 DID 문서를 업로드하세요.",
|
||||
"subtitleVerify": "계속하려면 {channel}을(를) 인증하세요.",
|
||||
"subtitleUpdatedDidDoc": "PDS 서명 키로 DID 문서를 업데이트하세요.",
|
||||
"subtitleActivating": "계정을 활성화하는 중...",
|
||||
"subtitleComplete": "계정이 성공적으로 생성되었습니다!",
|
||||
"redirecting": "대시보드로 이동 중...",
|
||||
"infoIdentityDesc": "신원은 ATProto 네트워크에서 계정이 어떻게 식별되는지를 결정합니다. 대부분의 사용자는 표준 옵션을 선택해야 합니다.",
|
||||
"infoContactDesc": "이 정보는 계정 인증과 계정 보안에 관한 중요한 알림을 보내는 데 사용됩니다.",
|
||||
"infoNextTitle": "다음 단계는?",
|
||||
"infoNextDesc": "계정 생성 후 연락 방법을 인증하면 새로운 신원으로 모든 ATProto 앱을 사용할 수 있습니다.",
|
||||
"migrateTitle": "이미 Bluesky 계정이 있으신가요?",
|
||||
"migrateDescription": "새 계정을 만드는 대신 기존 계정을 이 PDS로 마이그레이션할 수 있습니다. 팔로워, 게시물, ID가 함께 이전됩니다.",
|
||||
"migrateLink": "PDS Moover로 마이그레이션",
|
||||
@@ -211,12 +232,22 @@
|
||||
"messages": {
|
||||
"emailCodeSent": "알림 채널로 인증 코드를 보냈습니다",
|
||||
"emailUpdated": "이메일이 업데이트되었습니다",
|
||||
"emailUpdateFailed": "이메일 업데이트에 실패했습니다",
|
||||
"handleUpdated": "핸들이 업데이트되었습니다",
|
||||
"handleUpdateFailed": "핸들 업데이트에 실패했습니다",
|
||||
"passwordChanged": "비밀번호가 변경되었습니다",
|
||||
"passwordChangeFailed": "비밀번호 변경에 실패했습니다",
|
||||
"passwordsMismatch": "비밀번호가 일치하지 않습니다",
|
||||
"passwordsDoNotMatch": "비밀번호가 일치하지 않습니다",
|
||||
"passwordLength": "비밀번호는 8자 이상이어야 합니다",
|
||||
"passwordTooShort": "비밀번호는 8자 이상이어야 합니다",
|
||||
"deletionCodeSent": "이메일로 삭제 확인을 보냈습니다",
|
||||
"deletionConfirmationSent": "이메일로 삭제 확인을 보냈습니다",
|
||||
"deletionRequestFailed": "계정 삭제 요청에 실패했습니다",
|
||||
"deleteConfirmation": "정말로 계정을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
|
||||
"deletionFailed": "계정 삭제에 실패했습니다",
|
||||
"repoExported": "저장소를 내보냈습니다",
|
||||
"exportFailed": "저장소 내보내기에 실패했습니다",
|
||||
"confirmDelete": "정말로 계정을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
|
||||
}
|
||||
},
|
||||
@@ -362,6 +393,7 @@
|
||||
"manageTrustedDevices": "신뢰할 수 있는 기기 관리",
|
||||
"appCompatibility": "앱 호환성",
|
||||
"enterPassword": "비밀번호를 입력하세요",
|
||||
"sessionExpired": "세션이 만료되었습니다. 다시 로그인하세요.",
|
||||
"legacyLoginEnabled": "레거시 앱 로그인 활성화됨",
|
||||
"legacyLoginDisabled": "레거시 앱 로그인 비활성화됨 - OAuth 앱만 로그인 가능",
|
||||
"failedToUpdatePreference": "설정 업데이트에 실패했습니다",
|
||||
@@ -421,6 +453,7 @@
|
||||
"noRecords": "이 컬렉션에 레코드가 없습니다",
|
||||
"recordDetails": "레코드 세부 정보",
|
||||
"rkey": "레코드 키",
|
||||
"uri": "URI",
|
||||
"cid": "CID",
|
||||
"value": "값",
|
||||
"deleteRecord": "레코드 삭제",
|
||||
@@ -464,9 +497,6 @@
|
||||
"themeColorsHint": "기본 색상을 사용하려면 비워 두세요.",
|
||||
"primaryLight": "기본 (라이트 모드)",
|
||||
"primaryDark": "기본 (다크 모드)",
|
||||
"accentLight": "강조 (라이트 모드)",
|
||||
"accentDark": "강조 (다크 모드)",
|
||||
"faviconExample": "파비콘 예시",
|
||||
"configSaved": "서버 설정이 저장되었습니다",
|
||||
"saving": "저장 중...",
|
||||
"saveConfig": "설정 저장",
|
||||
@@ -508,7 +538,10 @@
|
||||
"deleteConfirm": "계정 @{handle}을(를) 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
|
||||
"verified": "인증됨",
|
||||
"unverified": "미인증",
|
||||
"deactivated": "비활성화됨"
|
||||
"deactivated": "비활성화됨",
|
||||
"colorDefault": "{color} (기본값)",
|
||||
"secondaryLight": "보조 (라이트 모드)",
|
||||
"secondaryDark": "보조 (다크 모드)"
|
||||
},
|
||||
"oauth": {
|
||||
"login": {
|
||||
@@ -524,7 +557,10 @@
|
||||
"rememberDevice": "이 기기 기억하기",
|
||||
"passkeyHintChecking": "패스키 상태 확인 중...",
|
||||
"passkeyHintAvailable": "패스키로 로그인",
|
||||
"passkeyHintNotAvailable": "이 계정에 등록된 패스키가 없습니다"
|
||||
"passkeyHintNotAvailable": "이 계정에 등록된 패스키가 없습니다",
|
||||
"passkeyHint": "기기의 생체 인식 또는 보안 키 사용",
|
||||
"passwordPlaceholder": "비밀번호 입력",
|
||||
"usePasskey": "패스키 사용"
|
||||
},
|
||||
"consent": {
|
||||
"title": "앱 승인",
|
||||
@@ -740,13 +776,66 @@
|
||||
"handleNoDots": "핸들에 점을 포함할 수 없습니다. 계정 생성 후 사용자 정의 도메인을 설정할 수 있습니다.",
|
||||
"passkeysNotSupported": "이 브라우저에서 패스키가 지원되지 않습니다. 비밀번호 기반 계정을 만들거나 패스키를 지원하는 브라우저를 사용하세요.",
|
||||
"passkeyCancelled": "패스키 생성이 취소되었습니다",
|
||||
"passkeyFailed": "패스키 등록에 실패했습니다"
|
||||
}
|
||||
"passkeyFailed": "패스키 등록에 실패했습니다",
|
||||
"signalRequired": "Signal 인증에는 전화번호가 필요합니다",
|
||||
"inviteRequired": "초대 코드가 필요합니다",
|
||||
"externalDidRequired": "외부 did:web이 필요합니다",
|
||||
"emailRequired": "이메일 인증에는 이메일이 필요합니다",
|
||||
"telegramRequired": "Telegram 인증에는 Telegram 사용자 이름이 필요합니다",
|
||||
"externalDidFormat": "외부 DID는 did:web:으로 시작해야 합니다",
|
||||
"discordRequired": "Discord 인증에는 Discord ID가 필요합니다"
|
||||
},
|
||||
"whyPasskeyBullet1": "피싱이나 데이터 유출로 도난당할 수 없음",
|
||||
"whyPasskeyBullet2": "하드웨어 기반 암호화 키 사용",
|
||||
"whyPasskeyBullet3": "생체 인식 또는 기기 PIN 필요",
|
||||
"whyPasskeyOnly": "왜 패스키만 사용하나요?",
|
||||
"whyPasskeyOnlyDesc": "패스키 계정은 비밀번호 기반 계정보다 안전합니다:",
|
||||
"subtitleInitialDidDoc": "계속하려면 DID 문서를 업로드하세요.",
|
||||
"subtitleUpdatedDidDoc": "PDS 서명 키로 DID 문서를 업데이트하세요.",
|
||||
"subtitleActivating": "계정을 활성화하는 중...",
|
||||
"subtitleComplete": "계정이 성공적으로 생성되었습니다!",
|
||||
"subtitleCreating": "계정을 생성하는 중...",
|
||||
"subtitleAppPassword": "서드파티 앱용 앱 비밀번호를 저장하세요.",
|
||||
"creatingPasskey": "패스키 생성 중...",
|
||||
"passkeyPrompt": "아래 버튼을 클릭하여 패스키를 생성하세요. 다음을 사용하라는 메시지가 표시됩니다:",
|
||||
"passkeyPromptBullet1": "Touch ID 또는 Face ID",
|
||||
"passkeyPromptBullet2": "기기 PIN 또는 비밀번호",
|
||||
"passkeyPromptBullet3": "보안 키 (있는 경우)",
|
||||
"identityType": "아이덴티티 유형",
|
||||
"identityTypeHint": "분산 아이덴티티 관리 방법을 선택하세요.",
|
||||
"passkeyNameLabel": "패스키 이름 (선택사항)",
|
||||
"passkeyNamePlaceholder": "예: MacBook Touch ID",
|
||||
"passkeyNameHint": "이 패스키를 식별할 수 있는 이름",
|
||||
"createPasskey": "패스키 생성",
|
||||
"didPlcRecommended": "did:plc (권장)",
|
||||
"didPlcHint": "PLC Directory에서 관리하는 이동 가능한 아이덴티티",
|
||||
"didWeb": "did:web",
|
||||
"didWebHint": "이 PDS에서 호스팅되는 아이덴티티 (아래 경고 읽기)",
|
||||
"didWebBYOD": "did:web (BYOD)",
|
||||
"didWebBYODHint": "자체 도메인 사용",
|
||||
"didWebWarningTitle": "중요: 장단점 이해하기",
|
||||
"didWebWarning1": "이 PDS에 영구적으로 연결됨:",
|
||||
"didWebWarning1Detail": "귀하의 아이덴티티 {did}는 이 서버에 연결됩니다.",
|
||||
"didWebWarning2": "복구 메커니즘 없음:",
|
||||
"didWebWarning2Detail": "did:plc와 달리 did:web에는 순환 키가 없습니다.",
|
||||
"didWebWarning3": "우리의 약속:",
|
||||
"didWebWarning3Detail": "마이그레이션하더라도 최소한의 DID 문서를 계속 제공합니다.",
|
||||
"didWebWarning4": "권장 사항:",
|
||||
"didWebWarning4Detail": "did:web을 선호할 특별한 이유가 없다면 did:plc를 선택하세요.",
|
||||
"externalDidHint": "다음 위치에서 DID 문서를 제공해야 합니다",
|
||||
"continue": "계속",
|
||||
"back": "뒤로",
|
||||
"loading": "로딩 중...",
|
||||
"redirecting": "대시보드로 이동 중...",
|
||||
"handleDotWarning": "사용자 정의 도메인 핸들은 계정 생성 후 설정할 수 있습니다.",
|
||||
"wantTraditional": "기존 비밀번호를 원하시나요?",
|
||||
"registerWithPassword": "비밀번호로 가입"
|
||||
},
|
||||
"trustedDevices": {
|
||||
"title": "신뢰할 수 있는 기기",
|
||||
"backToSecurity": "← 보안 설정",
|
||||
"description": "신뢰할 수 있는 기기는 로그인 시 2단계 인증을 건너뛸 수 있습니다. 신뢰는 30일간 유효하며 기기를 사용할 때 자동으로 연장됩니다.",
|
||||
"failedToLoad": "신뢰할 수 있는 기기를 불러오지 못했습니다",
|
||||
"noDevices": "신뢰할 수 있는 기기가 아직 없습니다.",
|
||||
"noDevicesHint": "2단계 인증이 활성화된 상태로 로그인할 때 기기를 30일간 신뢰하도록 선택할 수 있습니다.",
|
||||
"lastSeen": "마지막 접속:",
|
||||
|
||||
@@ -30,7 +30,17 @@
|
||||
"lostPasskey": "Tappat bort nyckeln?",
|
||||
"noAccount": "Har du inget konto?",
|
||||
"createAccount": "Skapa konto",
|
||||
"removeAccount": "Ta bort från sparade konton"
|
||||
"removeAccount": "Ta bort från sparade konton",
|
||||
"infoSavedAccountsTitle": "Sparade konton",
|
||||
"infoSavedAccountsDesc": "Klicka på ett konto för att logga in direkt. Dina sessionstoken lagras säkert i denna webbläsare.",
|
||||
"infoNewAccountTitle": "Nytt konto",
|
||||
"infoNewAccountDesc": "Använd inloggningsknappen för att lägga till ett annat konto. Klicka på × för att ta bort sparade konton.",
|
||||
"infoSecureSignInTitle": "Säker inloggning",
|
||||
"infoSecureSignInDesc": "Du omdirigeras för säker autentisering. Om du har aktiverat nycklar eller tvåfaktorsautentisering kommer du också att behöva ange dessa.",
|
||||
"infoStaySignedInTitle": "Förbli inloggad",
|
||||
"infoStaySignedInDesc": "Efter inloggning sparas ditt konto i denna webbläsare för snabb åtkomst nästa gång.",
|
||||
"infoRecoveryTitle": "Kontoåterställning",
|
||||
"infoRecoveryDesc": "Har du tappat bort ditt lösenord eller din nyckel? Använd återställningslänkarna under inloggningsknappen."
|
||||
},
|
||||
"verification": {
|
||||
"title": "Verifiera ditt konto",
|
||||
@@ -47,6 +57,17 @@
|
||||
"register": {
|
||||
"title": "Skapa konto",
|
||||
"subtitle": "Skapa ett nytt konto på denna PDS",
|
||||
"subtitleKeyChoice": "Välj hur du vill konfigurera din externa did:web-identitet.",
|
||||
"subtitleInitialDidDoc": "Ladda upp ditt DID-dokument för att fortsätta.",
|
||||
"subtitleVerify": "Verifiera din {channel} för att fortsätta.",
|
||||
"subtitleUpdatedDidDoc": "Uppdatera ditt DID-dokument med PDS-signeringsnyckeln.",
|
||||
"subtitleActivating": "Aktiverar ditt konto...",
|
||||
"subtitleComplete": "Ditt konto har skapats!",
|
||||
"redirecting": "Omdirigerar till kontrollpanelen...",
|
||||
"infoIdentityDesc": "Din identitet avgör hur ditt konto identifieras i ATProto-nätverket. De flesta användare bör välja standardalternativet.",
|
||||
"infoContactDesc": "Vi använder detta för att verifiera ditt konto och skicka viktiga meddelanden om din kontosäkerhet.",
|
||||
"infoNextTitle": "Vad händer härnäst?",
|
||||
"infoNextDesc": "Efter att du skapat ditt konto verifierar du din kontaktmetod och sedan är du redo att använda vilken ATProto-app som helst med din nya identitet.",
|
||||
"migrateTitle": "Har du redan ett Bluesky-konto?",
|
||||
"migrateDescription": "Du kan flytta ditt befintliga konto till denna PDS istället för att skapa ett nytt. Dina följare, inlägg och identitet följer med.",
|
||||
"migrateLink": "Flytta med PDS Moover",
|
||||
@@ -211,12 +232,22 @@
|
||||
"messages": {
|
||||
"emailCodeSent": "Verifieringskod skickad till din meddelandekanal",
|
||||
"emailUpdated": "E-post uppdaterad",
|
||||
"emailUpdateFailed": "Kunde inte uppdatera e-post",
|
||||
"handleUpdated": "Användarnamn uppdaterat",
|
||||
"handleUpdateFailed": "Kunde inte uppdatera användarnamn",
|
||||
"passwordChanged": "Lösenord ändrat",
|
||||
"passwordChangeFailed": "Kunde inte ändra lösenord",
|
||||
"passwordsMismatch": "Lösenorden matchar inte",
|
||||
"passwordsDoNotMatch": "Lösenorden matchar inte",
|
||||
"passwordLength": "Lösenordet måste vara minst 8 tecken",
|
||||
"passwordTooShort": "Lösenordet måste vara minst 8 tecken",
|
||||
"deletionCodeSent": "Bekräftelse för radering skickad till din e-post",
|
||||
"deletionConfirmationSent": "Bekräftelse för radering skickad till din e-post",
|
||||
"deletionRequestFailed": "Kunde inte begära kontoradering",
|
||||
"deleteConfirmation": "Är du helt säker på att du vill radera ditt konto? Detta kan inte ångras.",
|
||||
"deletionFailed": "Kunde inte radera kontot",
|
||||
"repoExported": "Arkiv exporterat",
|
||||
"exportFailed": "Kunde inte exportera arkiv",
|
||||
"confirmDelete": "Är du helt säker på att du vill radera ditt konto? Detta kan inte ångras."
|
||||
}
|
||||
},
|
||||
@@ -362,6 +393,7 @@
|
||||
"manageTrustedDevices": "Hantera betrodda enheter",
|
||||
"appCompatibility": "Appkompatibilitet",
|
||||
"enterPassword": "Ange ditt lösenord",
|
||||
"sessionExpired": "Sessionen har gått ut. Logga in igen.",
|
||||
"legacyLoginEnabled": "Föråldrad appinloggning aktiverad",
|
||||
"legacyLoginDisabled": "Föråldrad appinloggning inaktiverad - endast OAuth-appar kan logga in",
|
||||
"failedToUpdatePreference": "Kunde inte uppdatera inställning",
|
||||
@@ -421,6 +453,7 @@
|
||||
"noRecords": "Inga poster i denna samling",
|
||||
"recordDetails": "Postdetaljer",
|
||||
"rkey": "Postnyckel",
|
||||
"uri": "URI",
|
||||
"cid": "CID",
|
||||
"value": "Värde",
|
||||
"deleteRecord": "Radera post",
|
||||
@@ -464,9 +497,6 @@
|
||||
"themeColorsHint": "Lämna tomt för att använda standardfärger.",
|
||||
"primaryLight": "Primär (ljust läge)",
|
||||
"primaryDark": "Primär (mörkt läge)",
|
||||
"accentLight": "Accent (ljust läge)",
|
||||
"accentDark": "Accent (mörkt läge)",
|
||||
"faviconExample": "Favicon-exempel",
|
||||
"configSaved": "Serverkonfiguration sparad",
|
||||
"saving": "Sparar...",
|
||||
"saveConfig": "Spara konfiguration",
|
||||
@@ -508,7 +538,10 @@
|
||||
"deleteConfirm": "Radera konto @{handle}? Detta kan inte ångras.",
|
||||
"verified": "Verifierad",
|
||||
"unverified": "Ej verifierad",
|
||||
"deactivated": "Inaktiverad"
|
||||
"deactivated": "Inaktiverad",
|
||||
"colorDefault": "{color} (standard)",
|
||||
"secondaryLight": "Sekundär (Ljust läge)",
|
||||
"secondaryDark": "Sekundär (Mörkt läge)"
|
||||
},
|
||||
"oauth": {
|
||||
"login": {
|
||||
@@ -524,7 +557,10 @@
|
||||
"rememberDevice": "Kom ihåg denna enhet",
|
||||
"passkeyHintChecking": "Kontrollerar nyckelstatus...",
|
||||
"passkeyHintAvailable": "Logga in med din nyckel",
|
||||
"passkeyHintNotAvailable": "Inga nycklar registrerade för detta konto"
|
||||
"passkeyHintNotAvailable": "Inga nycklar registrerade för detta konto",
|
||||
"passkeyHint": "Använd enhetens biometri eller säkerhetsnyckel",
|
||||
"passwordPlaceholder": "Ange ditt lösenord",
|
||||
"usePasskey": "Använd nyckel"
|
||||
},
|
||||
"consent": {
|
||||
"title": "Auktorisera applikation",
|
||||
@@ -740,13 +776,66 @@
|
||||
"handleNoDots": "Användarnamn kan inte innehålla punkter. Du kan konfigurera ett eget domännamn efter att kontot skapats.",
|
||||
"passkeysNotSupported": "Nycklar stöds inte i denna webbläsare. Skapa ett lösenordsbaserat konto eller använd en webbläsare som stöder nycklar.",
|
||||
"passkeyCancelled": "Nyckelskapande avbröts",
|
||||
"passkeyFailed": "Nyckelregistrering misslyckades"
|
||||
}
|
||||
"passkeyFailed": "Nyckelregistrering misslyckades",
|
||||
"signalRequired": "Telefonnummer krävs för Signal-verifiering",
|
||||
"inviteRequired": "Inbjudningskod krävs",
|
||||
"externalDidRequired": "Extern did:web krävs",
|
||||
"emailRequired": "E-post krävs för e-postverifiering",
|
||||
"telegramRequired": "Telegram-användarnamn krävs för Telegram-verifiering",
|
||||
"externalDidFormat": "Extern DID måste börja med did:web:",
|
||||
"discordRequired": "Discord-ID krävs för Discord-verifiering"
|
||||
},
|
||||
"whyPasskeyBullet1": "Kan inte nätfiskas eller stjälas vid dataintrång",
|
||||
"whyPasskeyBullet2": "Använder hårdvarubaserade kryptografiska nycklar",
|
||||
"whyPasskeyBullet3": "Kräver din biometri eller enhets-PIN för att använda",
|
||||
"whyPasskeyOnly": "Varför endast nyckel?",
|
||||
"whyPasskeyOnlyDesc": "Nyckelkonton är säkrare än lösenordsbaserade konton eftersom de:",
|
||||
"subtitleInitialDidDoc": "Ladda upp ditt DID-dokument för att fortsätta.",
|
||||
"subtitleUpdatedDidDoc": "Uppdatera ditt DID-dokument med PDS-signeringsnyckeln.",
|
||||
"subtitleActivating": "Aktiverar ditt konto...",
|
||||
"subtitleComplete": "Ditt konto har skapats!",
|
||||
"subtitleCreating": "Skapar ditt konto...",
|
||||
"subtitleAppPassword": "Spara ditt applösenord för tredjepartsappar.",
|
||||
"creatingPasskey": "Skapar nyckel...",
|
||||
"passkeyPrompt": "Klicka på knappen nedan för att skapa din nyckel. Du kommer att uppmanas att använda:",
|
||||
"passkeyPromptBullet1": "Touch ID eller Face ID",
|
||||
"passkeyPromptBullet2": "Din enhets PIN-kod eller lösenord",
|
||||
"passkeyPromptBullet3": "En säkerhetsnyckel (om du har en)",
|
||||
"identityType": "Identitetstyp",
|
||||
"identityTypeHint": "Välj hur din decentraliserade identitet ska hanteras.",
|
||||
"passkeyNameLabel": "Nyckelnamn (valfritt)",
|
||||
"passkeyNamePlaceholder": "t.ex. MacBook Touch ID",
|
||||
"passkeyNameHint": "Ett vänligt namn för att identifiera denna nyckel",
|
||||
"createPasskey": "Skapa nyckel",
|
||||
"didPlcRecommended": "did:plc (Rekommenderas)",
|
||||
"didPlcHint": "Portabel identitet som hanteras av PLC Directory",
|
||||
"didWeb": "did:web",
|
||||
"didWebHint": "Identitet som lagras på denna PDS (läs varningen nedan)",
|
||||
"didWebBYOD": "did:web (BYOD)",
|
||||
"didWebBYODHint": "Ta med din egen domän",
|
||||
"didWebWarningTitle": "Viktigt: Förstå kompromisserna",
|
||||
"didWebWarning1": "Permanent koppling till denna PDS:",
|
||||
"didWebWarning1Detail": "Din identitet {did} är knuten till denna server.",
|
||||
"didWebWarning2": "Ingen återställningsmekanism:",
|
||||
"didWebWarning2Detail": "Till skillnad från did:plc har did:web inga rotationsnycklar.",
|
||||
"didWebWarning3": "Vi förbinder oss till dig:",
|
||||
"didWebWarning3Detail": "Om du migrerar bort kommer vi att fortsätta servera ett minimalt DID-dokument.",
|
||||
"didWebWarning4": "Rekommendation:",
|
||||
"didWebWarning4Detail": "Välj did:plc om du inte har en specifik anledning att föredra did:web.",
|
||||
"externalDidHint": "Du behöver servera ett DID-dokument på",
|
||||
"continue": "Fortsätt",
|
||||
"back": "Tillbaka",
|
||||
"loading": "Laddar...",
|
||||
"redirecting": "Omdirigerar till instrumentpanelen...",
|
||||
"handleDotWarning": "Egna domännamn kan konfigureras efter att kontot skapats.",
|
||||
"wantTraditional": "Vill du ha ett traditionellt lösenord?",
|
||||
"registerWithPassword": "Registrera med lösenord"
|
||||
},
|
||||
"trustedDevices": {
|
||||
"title": "Betrodda enheter",
|
||||
"backToSecurity": "← Säkerhetsinställningar",
|
||||
"description": "Betrodda enheter kan hoppa över tvåfaktorsautentisering vid inloggning. Förtroende beviljas i 30 dagar och förlängs automatiskt när du använder enheten.",
|
||||
"failedToLoad": "Kunde inte ladda betrodda enheter",
|
||||
"noDevices": "Inga betrodda enheter ännu.",
|
||||
"noDevicesHint": "När du loggar in med tvåfaktorsautentisering aktiverat kan du välja att lita på enheten i 30 dagar.",
|
||||
"lastSeen": "Senast sedd:",
|
||||
|
||||
@@ -30,7 +30,17 @@
|
||||
"lostPasskey": "丢失通行密钥?",
|
||||
"noAccount": "还没有账户?",
|
||||
"createAccount": "立即注册",
|
||||
"removeAccount": "从已保存账户中移除"
|
||||
"removeAccount": "从已保存账户中移除",
|
||||
"infoSavedAccountsTitle": "已保存账户",
|
||||
"infoSavedAccountsDesc": "点击账户即可快速登录。您的会话令牌安全存储在此浏览器中。",
|
||||
"infoNewAccountTitle": "新账户",
|
||||
"infoNewAccountDesc": "使用登录按钮添加其他账户。点击 × 可从此浏览器中移除已保存的账户。",
|
||||
"infoSecureSignInTitle": "安全登录",
|
||||
"infoSecureSignInDesc": "您将被重定向进行安全认证。如果您启用了通行密钥或双重身份验证,也会提示您进行验证。",
|
||||
"infoStaySignedInTitle": "保持登录",
|
||||
"infoStaySignedInDesc": "登录后,您的账户将保存在此浏览器中,方便下次快速访问。",
|
||||
"infoRecoveryTitle": "账户恢复",
|
||||
"infoRecoveryDesc": "忘记密码或丢失通行密钥?使用登录按钮下方的恢复链接。"
|
||||
},
|
||||
"verification": {
|
||||
"title": "验证账户",
|
||||
@@ -47,6 +57,17 @@
|
||||
"register": {
|
||||
"title": "创建账户",
|
||||
"subtitle": "在此 PDS 上创建新账户",
|
||||
"subtitleKeyChoice": "选择如何设置您的外部 did:web 身份。",
|
||||
"subtitleInitialDidDoc": "上传您的 DID 文档以继续。",
|
||||
"subtitleVerify": "验证您的{channel}以继续。",
|
||||
"subtitleUpdatedDidDoc": "使用 PDS 签名密钥更新您的 DID 文档。",
|
||||
"subtitleActivating": "正在激活您的账户...",
|
||||
"subtitleComplete": "您的账户已成功创建!",
|
||||
"redirecting": "正在跳转到控制台...",
|
||||
"infoIdentityDesc": "您的身份决定了您的账户在 ATProto 网络中的识别方式。大多数用户应选择标准选项。",
|
||||
"infoContactDesc": "我们将使用此信息验证您的账户并发送有关账户安全的重要通知。",
|
||||
"infoNextTitle": "接下来会发生什么?",
|
||||
"infoNextDesc": "创建账户后,您需要验证联系方式,然后即可使用任何 ATProto 应用程序。",
|
||||
"migrateTitle": "已有 Bluesky 账户?",
|
||||
"migrateDescription": "您可以将现有账户迁移到此 PDS,而无需创建新账户。您的关注者、帖子和身份都会一起迁移。",
|
||||
"migrateLink": "使用 PDS Moover 迁移",
|
||||
@@ -211,12 +232,22 @@
|
||||
"messages": {
|
||||
"emailCodeSent": "验证码已发送到您的通知渠道",
|
||||
"emailUpdated": "邮箱更新成功",
|
||||
"emailUpdateFailed": "邮箱更新失败",
|
||||
"handleUpdated": "用户名更新成功",
|
||||
"handleUpdateFailed": "用户名更新失败",
|
||||
"passwordChanged": "密码更改成功",
|
||||
"passwordChangeFailed": "密码更改失败",
|
||||
"passwordsMismatch": "两次输入的密码不一致",
|
||||
"passwordsDoNotMatch": "两次输入的密码不一致",
|
||||
"passwordLength": "密码至少需要8位字符",
|
||||
"passwordTooShort": "密码至少需要8位字符",
|
||||
"deletionCodeSent": "删除确认码已发送到您的邮箱",
|
||||
"deletionConfirmationSent": "删除确认码已发送到您的邮箱",
|
||||
"deletionRequestFailed": "账户删除请求失败",
|
||||
"deleteConfirmation": "您确定要删除账户吗?此操作无法撤销。",
|
||||
"deletionFailed": "账户删除失败",
|
||||
"repoExported": "数据导出成功",
|
||||
"exportFailed": "数据导出失败",
|
||||
"confirmDelete": "您确定要删除账户吗?此操作无法撤销。"
|
||||
}
|
||||
},
|
||||
@@ -362,6 +393,7 @@
|
||||
"manageTrustedDevices": "管理受信任设备",
|
||||
"appCompatibility": "应用兼容性",
|
||||
"enterPassword": "输入您的密码",
|
||||
"sessionExpired": "会话已过期,请重新登录。",
|
||||
"legacyLoginEnabled": "已启用传统应用登录",
|
||||
"legacyLoginDisabled": "已禁用传统应用登录 - 仅 OAuth 应用可登录",
|
||||
"failedToUpdatePreference": "更新偏好设置失败",
|
||||
@@ -421,6 +453,7 @@
|
||||
"noRecords": "此集合中暂无记录",
|
||||
"recordDetails": "记录详情",
|
||||
"rkey": "记录键",
|
||||
"uri": "URI",
|
||||
"cid": "CID",
|
||||
"value": "值",
|
||||
"deleteRecord": "删除记录",
|
||||
@@ -463,13 +496,10 @@
|
||||
"themeColors": "主题颜色",
|
||||
"themeColorsHint": "留空使用默认颜色。",
|
||||
"primaryLight": "主色(浅色模式)",
|
||||
"primaryLightDefault": "#2c00ff(默认)",
|
||||
"colorDefault": "{color}(默认)",
|
||||
"primaryDark": "主色(深色模式)",
|
||||
"primaryDarkDefault": "#7b6bff(默认)",
|
||||
"secondaryLight": "副色(浅色模式)",
|
||||
"secondaryLightDefault": "#ff2400(默认)",
|
||||
"secondaryDark": "副色(深色模式)",
|
||||
"secondaryDarkDefault": "#ff6b5b(默认)",
|
||||
"configSaved": "服务器配置已保存",
|
||||
"saving": "保存中...",
|
||||
"saveConfig": "保存配置",
|
||||
@@ -527,7 +557,10 @@
|
||||
"rememberDevice": "记住此设备",
|
||||
"passkeyHintChecking": "正在检查通行密钥状态...",
|
||||
"passkeyHintAvailable": "使用您的通行密钥登录",
|
||||
"passkeyHintNotAvailable": "此账户未注册通行密钥"
|
||||
"passkeyHintNotAvailable": "此账户未注册通行密钥",
|
||||
"passkeyHint": "使用设备的生物识别或安全密钥",
|
||||
"passwordPlaceholder": "输入您的密码",
|
||||
"usePasskey": "使用通行密钥"
|
||||
},
|
||||
"consent": {
|
||||
"title": "授权应用",
|
||||
@@ -785,6 +818,7 @@
|
||||
"title": "受信任设备",
|
||||
"backToSecurity": "← 安全设置",
|
||||
"description": "受信任设备可以跳过双重身份验证。信任有效期为30天,使用设备时自动延长。",
|
||||
"failedToLoad": "加载受信任设备失败",
|
||||
"noDevices": "暂无受信任设备",
|
||||
"noDevicesHint": "开启双重身份验证后登录时,可以选择信任设备30天。",
|
||||
"lastSeen": "最后使用:",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import './styles/base.css'
|
||||
import App from './App.svelte'
|
||||
import { mount } from 'svelte'
|
||||
import "./styles/base.css";
|
||||
import App from "./App.svelte";
|
||||
import { mount } from "svelte";
|
||||
|
||||
const app = mount(App, {
|
||||
target: document.getElementById('app')!,
|
||||
})
|
||||
target: document.getElementById("app")!,
|
||||
});
|
||||
|
||||
export default app
|
||||
export default app;
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
import { _ } from '../lib/i18n'
|
||||
import { formatDate, formatDateTime } from '../lib/date'
|
||||
const auth = getAuthState()
|
||||
const DEFAULT_COLORS = {
|
||||
primaryLight: '#1A1D1D',
|
||||
primaryDark: '#E6E8E8',
|
||||
secondaryLight: '#1A1D1D',
|
||||
secondaryDark: '#E6E8E8',
|
||||
}
|
||||
let loading = $state(true)
|
||||
let error = $state<string | null>(null)
|
||||
let stats = $state<{
|
||||
@@ -364,7 +370,7 @@
|
||||
type="text"
|
||||
id="primaryColor"
|
||||
bind:value={primaryColorInput}
|
||||
placeholder={$_('admin.primaryLightDefault')}
|
||||
placeholder={$_('admin.colorDefault', { values: { color: DEFAULT_COLORS.primaryLight } })}
|
||||
disabled={serverConfigLoading}
|
||||
/>
|
||||
</div>
|
||||
@@ -381,7 +387,7 @@
|
||||
type="text"
|
||||
id="primaryColorDark"
|
||||
bind:value={primaryColorDarkInput}
|
||||
placeholder={$_('admin.primaryDarkDefault')}
|
||||
placeholder={$_('admin.colorDefault', { values: { color: DEFAULT_COLORS.primaryDark } })}
|
||||
disabled={serverConfigLoading}
|
||||
/>
|
||||
</div>
|
||||
@@ -398,7 +404,7 @@
|
||||
type="text"
|
||||
id="secondaryColor"
|
||||
bind:value={secondaryColorInput}
|
||||
placeholder={$_('admin.secondaryLightDefault')}
|
||||
placeholder={$_('admin.colorDefault', { values: { color: DEFAULT_COLORS.secondaryLight } })}
|
||||
disabled={serverConfigLoading}
|
||||
/>
|
||||
</div>
|
||||
@@ -415,7 +421,7 @@
|
||||
type="text"
|
||||
id="secondaryColorDark"
|
||||
bind:value={secondaryColorDarkInput}
|
||||
placeholder={$_('admin.secondaryDarkDefault')}
|
||||
placeholder={$_('admin.colorDefault', { values: { color: DEFAULT_COLORS.secondaryDark } })}
|
||||
disabled={serverConfigLoading}
|
||||
/>
|
||||
</div>
|
||||
@@ -646,7 +652,7 @@
|
||||
{/if}
|
||||
<style>
|
||||
.page {
|
||||
max-width: var(--width-lg);
|
||||
max-width: var(--width-xl);
|
||||
margin: 0 auto;
|
||||
padding: var(--space-7);
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@
|
||||
</div>
|
||||
<style>
|
||||
.page {
|
||||
max-width: var(--width-md);
|
||||
max-width: var(--width-lg);
|
||||
margin: 0 auto;
|
||||
padding: var(--space-7);
|
||||
}
|
||||
|
||||
+281
-223
@@ -22,7 +22,7 @@
|
||||
let verificationCode = $state('')
|
||||
let verificationError = $state<string | null>(null)
|
||||
let verificationSuccess = $state<string | null>(null)
|
||||
let historyLoading = $state(false)
|
||||
let historyLoading = $state(true)
|
||||
let historyError = $state<string | null>(null)
|
||||
let messages = $state<Array<{
|
||||
createdAt: string
|
||||
@@ -32,7 +32,6 @@
|
||||
subject: string | null
|
||||
body: string
|
||||
}>>([])
|
||||
let showHistory = $state(false)
|
||||
$effect(() => {
|
||||
if (!auth.loading && !auth.session) {
|
||||
navigate('/login')
|
||||
@@ -41,6 +40,7 @@
|
||||
$effect(() => {
|
||||
if (auth.session) {
|
||||
loadPrefs()
|
||||
loadHistory()
|
||||
}
|
||||
})
|
||||
async function loadPrefs() {
|
||||
@@ -120,7 +120,6 @@
|
||||
try {
|
||||
const result = await api.getNotificationHistory(auth.session.accessJwt)
|
||||
messages = result.notifications
|
||||
showHistory = true
|
||||
} catch (e) {
|
||||
historyError = e instanceof ApiError ? e.message : 'Failed to load notification history'
|
||||
} finally {
|
||||
@@ -171,10 +170,9 @@
|
||||
<header>
|
||||
<a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a>
|
||||
<h1>{$_('comms.title')}</h1>
|
||||
<p class="description">{$_('comms.description')}</p>
|
||||
</header>
|
||||
<p class="description">
|
||||
{$_('comms.description')}
|
||||
</p>
|
||||
|
||||
{#if loading}
|
||||
<p class="loading">{$_('common.loading')}</p>
|
||||
{:else}
|
||||
@@ -184,215 +182,224 @@
|
||||
{#if success}
|
||||
<div class="message success">{success}</div>
|
||||
{/if}
|
||||
<form onsubmit={handleSave}>
|
||||
<section>
|
||||
<h2>{$_('comms.preferredChannel')}</h2>
|
||||
<p class="section-description">
|
||||
{$_('comms.preferredChannelDescription')}
|
||||
</p>
|
||||
<div class="channel-options">
|
||||
{#each channels as channelId}
|
||||
<label class="channel-option" class:disabled={!canSelectChannel(channelId)} class:unavailable={!isChannelAvailableOnServer(channelId)}>
|
||||
<input
|
||||
type="radio"
|
||||
name="preferredChannel"
|
||||
value={channelId}
|
||||
bind:group={preferredChannel}
|
||||
disabled={!canSelectChannel(channelId) || saving}
|
||||
/>
|
||||
<div class="channel-info">
|
||||
<span class="channel-name">{getChannelName(channelId)}</span>
|
||||
<span class="channel-description">{getChannelDescription(channelId)}</span>
|
||||
{#if !isChannelAvailableOnServer(channelId)}
|
||||
<span class="channel-hint server-unavailable">{$_('comms.notConfiguredOnServer')}</span>
|
||||
{:else if channelId !== 'email' && !canSelectChannel(channelId)}
|
||||
<span class="channel-hint">{$_('comms.configureToEnable')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2>{$_('comms.channelConfiguration')}</h2>
|
||||
<div class="channel-config">
|
||||
<div class="config-item">
|
||||
<label for="email">{$_('register.email')}</label>
|
||||
<div class="config-input">
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
disabled
|
||||
class="readonly"
|
||||
/>
|
||||
<span class="status verified">{$_('comms.primary')}</span>
|
||||
|
||||
<div class="split-layout">
|
||||
<div class="main-column">
|
||||
<form onsubmit={handleSave}>
|
||||
<section>
|
||||
<h2>{$_('comms.preferredChannel')}</h2>
|
||||
<p class="section-description">{$_('comms.preferredChannelDescription')}</p>
|
||||
<div class="channel-options">
|
||||
{#each channels as channelId}
|
||||
<label class="channel-option" class:disabled={!canSelectChannel(channelId)} class:unavailable={!isChannelAvailableOnServer(channelId)}>
|
||||
<input
|
||||
type="radio"
|
||||
name="preferredChannel"
|
||||
value={channelId}
|
||||
bind:group={preferredChannel}
|
||||
disabled={!canSelectChannel(channelId) || saving}
|
||||
/>
|
||||
<div class="channel-info">
|
||||
<span class="channel-name">{getChannelName(channelId)}</span>
|
||||
<span class="channel-description">{getChannelDescription(channelId)}</span>
|
||||
{#if !isChannelAvailableOnServer(channelId)}
|
||||
<span class="channel-hint server-unavailable">{$_('comms.notConfiguredOnServer')}</span>
|
||||
{:else if channelId !== 'email' && !canSelectChannel(channelId)}
|
||||
<span class="channel-hint">{$_('comms.configureToEnable')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="config-hint">{$_('comms.emailManagedInSettings')}</p>
|
||||
</div>
|
||||
<div class="config-item" class:unavailable={!isChannelAvailableOnServer('discord')}>
|
||||
<label for="discord">{$_('register.discordId')}</label>
|
||||
<div class="config-input">
|
||||
<input
|
||||
id="discord"
|
||||
type="text"
|
||||
bind:value={discordId}
|
||||
placeholder={$_('register.discordIdPlaceholder')}
|
||||
disabled={saving || !isChannelAvailableOnServer('discord')}
|
||||
/>
|
||||
{#if !isChannelAvailableOnServer('discord')}
|
||||
<span class="status unavailable">{$_('comms.notConfiguredOnServer')}</span>
|
||||
{:else if discordId}
|
||||
{#if discordVerified}
|
||||
<span class="status verified">{$_('comms.verified')}</span>
|
||||
{:else}
|
||||
<span class="status unverified">{$_('comms.notVerified')}</span>
|
||||
<button type="button" class="verify-btn" onclick={() => verifyingChannel = 'discord'}>{$_('comms.verifyButton')}</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<p class="config-hint">{$_('comms.discordIdHint')}</p>
|
||||
{#if verifyingChannel === 'discord'}
|
||||
<div class="verify-form">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={verificationCode}
|
||||
placeholder={$_('comms.verifyCodePlaceholder')}
|
||||
maxlength="6"
|
||||
/>
|
||||
<button type="button" onclick={() => handleVerify('discord')}>{$_('comms.submit')}</button>
|
||||
<button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="config-item" class:unavailable={!isChannelAvailableOnServer('telegram')}>
|
||||
<label for="telegram">{$_('register.telegramUsername')}</label>
|
||||
<div class="config-input">
|
||||
<input
|
||||
id="telegram"
|
||||
type="text"
|
||||
bind:value={telegramUsername}
|
||||
placeholder={$_('register.telegramUsernamePlaceholder')}
|
||||
disabled={saving || !isChannelAvailableOnServer('telegram')}
|
||||
/>
|
||||
{#if !isChannelAvailableOnServer('telegram')}
|
||||
<span class="status unavailable">{$_('comms.notConfiguredOnServer')}</span>
|
||||
{:else if telegramUsername}
|
||||
{#if telegramVerified}
|
||||
<span class="status verified">{$_('comms.verified')}</span>
|
||||
{:else}
|
||||
<span class="status unverified">{$_('comms.notVerified')}</span>
|
||||
<button type="button" class="verify-btn" onclick={() => verifyingChannel = 'telegram'}>{$_('comms.verifyButton')}</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<p class="config-hint">{$_('comms.telegramHint')}</p>
|
||||
{#if verifyingChannel === 'telegram'}
|
||||
<div class="verify-form">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={verificationCode}
|
||||
placeholder={$_('comms.verifyCodePlaceholder')}
|
||||
maxlength="6"
|
||||
/>
|
||||
<button type="button" onclick={() => handleVerify('telegram')}>{$_('comms.submit')}</button>
|
||||
<button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="config-item" class:unavailable={!isChannelAvailableOnServer('signal')}>
|
||||
<label for="signal">{$_('register.signalNumber')}</label>
|
||||
<div class="config-input">
|
||||
<input
|
||||
id="signal"
|
||||
type="tel"
|
||||
bind:value={signalNumber}
|
||||
placeholder={$_('register.signalNumberPlaceholder')}
|
||||
disabled={saving || !isChannelAvailableOnServer('signal')}
|
||||
/>
|
||||
{#if !isChannelAvailableOnServer('signal')}
|
||||
<span class="status unavailable">{$_('comms.notConfiguredOnServer')}</span>
|
||||
{:else if signalNumber}
|
||||
{#if signalVerified}
|
||||
<span class="status verified">{$_('comms.verified')}</span>
|
||||
{:else}
|
||||
<span class="status unverified">{$_('comms.notVerified')}</span>
|
||||
<button type="button" class="verify-btn" onclick={() => verifyingChannel = 'signal'}>{$_('comms.verifyButton')}</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<p class="config-hint">{$_('comms.signalHint')}</p>
|
||||
{#if verifyingChannel === 'signal'}
|
||||
<div class="verify-form">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={verificationCode}
|
||||
placeholder={$_('comms.verifyCodePlaceholder')}
|
||||
maxlength="6"
|
||||
/>
|
||||
<button type="button" onclick={() => handleVerify('signal')}>{$_('comms.submit')}</button>
|
||||
<button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if verificationError}
|
||||
<div class="message error" style="margin-top: 1rem">{verificationError}</div>
|
||||
{/if}
|
||||
{#if verificationSuccess}
|
||||
<div class="message success" style="margin-top: 1rem">{verificationSuccess}</div>
|
||||
{/if}
|
||||
</section>
|
||||
<div class="actions">
|
||||
<button type="submit" disabled={saving}>
|
||||
{saving ? $_('comms.saving') : $_('comms.savePreferences')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<section class="history-section">
|
||||
<h2>{$_('comms.messageHistory')}</h2>
|
||||
<p class="section-description">{$_('comms.historyDescription')}</p>
|
||||
{#if !showHistory}
|
||||
<button class="load-history" onclick={loadHistory} disabled={historyLoading}>
|
||||
{historyLoading ? $_('common.loading') : $_('comms.loadHistory')}
|
||||
</button>
|
||||
{:else}
|
||||
<button class="load-history" onclick={() => showHistory = false}>{$_('comms.hideHistory')}</button>
|
||||
{#if historyError}
|
||||
<div class="message error">{historyError}</div>
|
||||
{:else if messages.length === 0}
|
||||
<p class="no-messages">{$_('comms.noMessages')}</p>
|
||||
{:else}
|
||||
<div class="message-list">
|
||||
{#each messages as msg}
|
||||
<div class="message-item">
|
||||
<div class="message-header">
|
||||
<span class="message-type">{msg.notificationType}</span>
|
||||
<span class="message-channel">{msg.channel}</span>
|
||||
<span class="message-status" class:sent={msg.status === 'sent'} class:failed={msg.status === 'failed'}>{msg.status}</span>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>{$_('comms.channelConfiguration')}</h2>
|
||||
<div class="channel-config">
|
||||
<div class="config-item">
|
||||
<div class="config-header">
|
||||
<label for="email">{$_('register.email')}</label>
|
||||
<span class="status verified">{$_('comms.primary')}</span>
|
||||
</div>
|
||||
{#if msg.subject}
|
||||
<div class="message-subject">{msg.subject}</div>
|
||||
{/if}
|
||||
<div class="message-body">{msg.body}</div>
|
||||
<div class="message-date">{formatDate(msg.createdAt)}</div>
|
||||
<input id="email" type="email" value={email} disabled class="readonly" />
|
||||
<p class="config-hint">{$_('comms.emailManagedInSettings')}</p>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="config-item" class:unavailable={!isChannelAvailableOnServer('discord')}>
|
||||
<div class="config-header">
|
||||
<label for="discord">{$_('register.discordId')}</label>
|
||||
{#if !isChannelAvailableOnServer('discord')}
|
||||
<span class="status unavailable">{$_('comms.notConfiguredOnServer')}</span>
|
||||
{:else if discordId}
|
||||
{#if discordVerified}
|
||||
<span class="status verified">{$_('comms.verified')}</span>
|
||||
{:else}
|
||||
<span class="status unverified">{$_('comms.notVerified')}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="config-input">
|
||||
<input
|
||||
id="discord"
|
||||
type="text"
|
||||
bind:value={discordId}
|
||||
placeholder={$_('register.discordIdPlaceholder')}
|
||||
disabled={saving || !isChannelAvailableOnServer('discord')}
|
||||
/>
|
||||
{#if discordId && !discordVerified && isChannelAvailableOnServer('discord')}
|
||||
<button type="button" class="verify-btn" onclick={() => verifyingChannel = 'discord'}>{$_('comms.verifyButton')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="config-hint">{$_('comms.discordIdHint')}</p>
|
||||
{#if verifyingChannel === 'discord'}
|
||||
<div class="verify-form">
|
||||
<input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="6" />
|
||||
<button type="button" onclick={() => handleVerify('discord')}>{$_('comms.submit')}</button>
|
||||
<button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="config-item" class:unavailable={!isChannelAvailableOnServer('telegram')}>
|
||||
<div class="config-header">
|
||||
<label for="telegram">{$_('register.telegramUsername')}</label>
|
||||
{#if !isChannelAvailableOnServer('telegram')}
|
||||
<span class="status unavailable">{$_('comms.notConfiguredOnServer')}</span>
|
||||
{:else if telegramUsername}
|
||||
{#if telegramVerified}
|
||||
<span class="status verified">{$_('comms.verified')}</span>
|
||||
{:else}
|
||||
<span class="status unverified">{$_('comms.notVerified')}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="config-input">
|
||||
<input
|
||||
id="telegram"
|
||||
type="text"
|
||||
bind:value={telegramUsername}
|
||||
placeholder={$_('register.telegramUsernamePlaceholder')}
|
||||
disabled={saving || !isChannelAvailableOnServer('telegram')}
|
||||
/>
|
||||
{#if telegramUsername && !telegramVerified && isChannelAvailableOnServer('telegram')}
|
||||
<button type="button" class="verify-btn" onclick={() => verifyingChannel = 'telegram'}>{$_('comms.verifyButton')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="config-hint">{$_('comms.telegramHint')}</p>
|
||||
{#if verifyingChannel === 'telegram'}
|
||||
<div class="verify-form">
|
||||
<input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="6" />
|
||||
<button type="button" onclick={() => handleVerify('telegram')}>{$_('comms.submit')}</button>
|
||||
<button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="config-item" class:unavailable={!isChannelAvailableOnServer('signal')}>
|
||||
<div class="config-header">
|
||||
<label for="signal">{$_('register.signalNumber')}</label>
|
||||
{#if !isChannelAvailableOnServer('signal')}
|
||||
<span class="status unavailable">{$_('comms.notConfiguredOnServer')}</span>
|
||||
{:else if signalNumber}
|
||||
{#if signalVerified}
|
||||
<span class="status verified">{$_('comms.verified')}</span>
|
||||
{:else}
|
||||
<span class="status unverified">{$_('comms.notVerified')}</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="config-input">
|
||||
<input
|
||||
id="signal"
|
||||
type="tel"
|
||||
bind:value={signalNumber}
|
||||
placeholder={$_('register.signalNumberPlaceholder')}
|
||||
disabled={saving || !isChannelAvailableOnServer('signal')}
|
||||
/>
|
||||
{#if signalNumber && !signalVerified && isChannelAvailableOnServer('signal')}
|
||||
<button type="button" class="verify-btn" onclick={() => verifyingChannel = 'signal'}>{$_('comms.verifyButton')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="config-hint">{$_('comms.signalHint')}</p>
|
||||
{#if verifyingChannel === 'signal'}
|
||||
<div class="verify-form">
|
||||
<input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="6" />
|
||||
<button type="button" onclick={() => handleVerify('signal')}>{$_('comms.submit')}</button>
|
||||
<button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if verificationError}
|
||||
<div class="message error" style="margin-top: 1rem">{verificationError}</div>
|
||||
{/if}
|
||||
{#if verificationSuccess}
|
||||
<div class="message success" style="margin-top: 1rem">{verificationSuccess}</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit" disabled={saving}>
|
||||
{saving ? $_('comms.saving') : $_('comms.savePreferences')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="side-column">
|
||||
<section class="history-section">
|
||||
<h2>{$_('comms.messageHistory')}</h2>
|
||||
<p class="section-description">{$_('comms.historyDescription')}</p>
|
||||
{#if historyLoading}
|
||||
<div class="skeleton-list">
|
||||
{#each [1, 2, 3] as _}
|
||||
<div class="skeleton-item">
|
||||
<div class="skeleton-header">
|
||||
<div class="skeleton-line short"></div>
|
||||
<div class="skeleton-line tiny"></div>
|
||||
</div>
|
||||
<div class="skeleton-line"></div>
|
||||
<div class="skeleton-line medium"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if historyError}
|
||||
<div class="message error">{historyError}</div>
|
||||
{:else if messages.length === 0}
|
||||
<p class="no-messages">{$_('comms.noMessages')}</p>
|
||||
{:else}
|
||||
<div class="message-list">
|
||||
{#each messages as msg}
|
||||
<div class="message-item">
|
||||
<div class="message-header">
|
||||
<span class="message-type">{msg.notificationType}</span>
|
||||
<span class="message-channel">{msg.channel}</span>
|
||||
<span class="message-status" class:sent={msg.status === 'sent'} class:failed={msg.status === 'failed'}>{msg.status}</span>
|
||||
</div>
|
||||
{#if msg.subject}
|
||||
<div class="message-subject">{msg.subject}</div>
|
||||
{/if}
|
||||
<div class="message-body">{msg.body}</div>
|
||||
<div class="message-date">{formatDate(msg.createdAt)}</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<style>
|
||||
.page {
|
||||
max-width: var(--width-md);
|
||||
max-width: var(--width-xl);
|
||||
margin: 0 auto;
|
||||
padding: var(--space-7);
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: var(--space-4);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.back {
|
||||
@@ -411,7 +418,7 @@
|
||||
|
||||
.description {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-7);
|
||||
margin: var(--space-2) 0 0 0;
|
||||
}
|
||||
|
||||
.loading {
|
||||
@@ -420,6 +427,23 @@
|
||||
padding: var(--space-7);
|
||||
}
|
||||
|
||||
.split-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.split-layout {
|
||||
grid-template-columns: 1.5fr 1fr;
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
.main-column, .side-column {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
section {
|
||||
background: var(--bg-secondary);
|
||||
padding: var(--space-6);
|
||||
@@ -427,6 +451,10 @@
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.side-column section {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
section h2 {
|
||||
margin: 0 0 var(--space-2) 0;
|
||||
font-size: var(--text-lg);
|
||||
@@ -520,6 +548,14 @@
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.config-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.config-item label {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
@@ -533,9 +569,10 @@
|
||||
|
||||
.config-input input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.config-input input.readonly {
|
||||
.config-item input.readonly {
|
||||
background: var(--bg-input-disabled);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@@ -624,36 +661,57 @@
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.history-section {
|
||||
background: var(--bg-secondary);
|
||||
padding: var(--space-6);
|
||||
border-radius: var(--radius-xl);
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
|
||||
.history-section h2 {
|
||||
margin: 0 0 var(--space-2) 0;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.load-history {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: transparent;
|
||||
.skeleton-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.skeleton-item {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
margin-top: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.load-history:hover:not(:disabled) {
|
||||
background: var(--bg-card);
|
||||
border-color: var(--accent);
|
||||
.skeleton-header {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.load-history:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
.skeleton-line {
|
||||
height: 14px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.skeleton-line.short {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.skeleton-line.tiny {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.skeleton-line.medium {
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.skeleton-line:not(.short):not(.tiny):not(.medium) {
|
||||
width: 100%;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.no-messages {
|
||||
|
||||
@@ -2,10 +2,22 @@
|
||||
import { getAuthState, logout, switchAccount } from '../lib/auth.svelte'
|
||||
import { navigate } from '../lib/router.svelte'
|
||||
import { _ } from '../lib/i18n'
|
||||
import { api } from '../lib/api'
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
const auth = getAuthState()
|
||||
let dropdownOpen = $state(false)
|
||||
let switching = $state(false)
|
||||
let inviteCodesEnabled = $state(false)
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const serverInfo = await api.describeServer()
|
||||
inviteCodesEnabled = serverInfo.inviteCodeRequired
|
||||
} catch {
|
||||
inviteCodesEnabled = false
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (!auth.loading && !auth.session) {
|
||||
@@ -152,10 +164,12 @@
|
||||
<h3>{$_('dashboard.navSessions')}</h3>
|
||||
<p>{$_('dashboard.navSessionsDesc')}</p>
|
||||
</a>
|
||||
<a href="#/invite-codes" class="nav-card">
|
||||
<h3>{$_('dashboard.navInviteCodes')}</h3>
|
||||
<p>{$_('dashboard.navInviteCodesDesc')}</p>
|
||||
</a>
|
||||
{#if inviteCodesEnabled}
|
||||
<a href="#/invite-codes" class="nav-card">
|
||||
<h3>{$_('dashboard.navInviteCodes')}</h3>
|
||||
<p>{$_('dashboard.navInviteCodesDesc')}</p>
|
||||
</a>
|
||||
{/if}
|
||||
<a href="#/settings" class="nav-card">
|
||||
<h3>{$_('dashboard.navSettings')}</h3>
|
||||
<p>{$_('dashboard.navSettingsDesc')}</p>
|
||||
@@ -186,7 +200,7 @@
|
||||
|
||||
<style>
|
||||
.dashboard {
|
||||
max-width: var(--width-lg);
|
||||
max-width: var(--width-xl);
|
||||
margin: 0 auto;
|
||||
padding: var(--space-7);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,18 @@
|
||||
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) {
|
||||
@@ -23,6 +35,21 @@
|
||||
}
|
||||
}).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(() => {})
|
||||
@@ -75,6 +102,7 @@
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
cancelAnimationFrame(animationId)
|
||||
clearTimeout(wordTimeout)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -103,7 +131,7 @@
|
||||
|
||||
<div class="home">
|
||||
<section class="hero">
|
||||
<h1>A home for your ATProto account</h1>
|
||||
<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>
|
||||
|
||||
@@ -268,15 +296,24 @@
|
||||
|
||||
.user-count {
|
||||
font-size: var(--text-sm);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
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: rgba(255, 255, 255, 0.7);
|
||||
color: var(--text-inverse);
|
||||
opacity: 0.6;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
@@ -302,6 +339,22 @@
|
||||
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);
|
||||
@@ -439,6 +492,7 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.user-count,
|
||||
.nav-meta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -4,19 +4,35 @@
|
||||
import { api, type InviteCode, ApiError } from '../lib/api'
|
||||
import { _ } from '../lib/i18n'
|
||||
import { formatDate } from '../lib/date'
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
const auth = getAuthState()
|
||||
let codes = $state<InviteCode[]>([])
|
||||
let loading = $state(true)
|
||||
let error = $state<string | null>(null)
|
||||
let creating = $state(false)
|
||||
let createdCode = $state<string | null>(null)
|
||||
let inviteCodesEnabled = $state<boolean | null>(null)
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const serverInfo = await api.describeServer()
|
||||
inviteCodesEnabled = serverInfo.inviteCodeRequired
|
||||
if (!serverInfo.inviteCodeRequired) {
|
||||
navigate('/dashboard')
|
||||
}
|
||||
} catch {
|
||||
navigate('/dashboard')
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (!auth.loading && !auth.session) {
|
||||
navigate('/login')
|
||||
}
|
||||
})
|
||||
$effect(() => {
|
||||
if (auth.session) {
|
||||
if (auth.session && inviteCodesEnabled) {
|
||||
loadCodes()
|
||||
}
|
||||
})
|
||||
@@ -114,7 +130,7 @@
|
||||
</div>
|
||||
<style>
|
||||
.page {
|
||||
max-width: var(--width-md);
|
||||
max-width: var(--width-lg);
|
||||
margin: 0 auto;
|
||||
padding: var(--space-7);
|
||||
}
|
||||
|
||||
@@ -8,9 +8,16 @@
|
||||
let verificationCode = $state('')
|
||||
let resendingCode = $state(false)
|
||||
let resendMessage = $state<string | null>(null)
|
||||
let showNewLogin = $state(false)
|
||||
let autoRedirectAttempted = $state(false)
|
||||
const auth = getAuthState()
|
||||
|
||||
$effect(() => {
|
||||
if (!auth.loading && !auth.error && auth.savedAccounts.length === 0 && !pendingVerification && !autoRedirectAttempted) {
|
||||
autoRedirectAttempted = true
|
||||
loginWithOAuth()
|
||||
}
|
||||
})
|
||||
|
||||
async function handleSwitchAccount(did: string) {
|
||||
submitting = true
|
||||
try {
|
||||
@@ -74,8 +81,10 @@
|
||||
{/if}
|
||||
|
||||
{#if pendingVerification}
|
||||
<h1>{$_('verification.title')}</h1>
|
||||
<p class="subtitle">{$_('verification.subtitle')}</p>
|
||||
<header class="page-header">
|
||||
<h1>{$_('verification.title')}</h1>
|
||||
<p class="subtitle">{$_('verification.subtitle')}</p>
|
||||
</header>
|
||||
|
||||
{#if resendMessage}
|
||||
<div class="message success">{resendMessage}</div>
|
||||
@@ -109,90 +118,110 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{:else if auth.savedAccounts.length > 0 && !showNewLogin}
|
||||
<h1>{$_('login.title')}</h1>
|
||||
<p class="subtitle">{$_('login.chooseAccount')}</p>
|
||||
|
||||
<div class="saved-accounts">
|
||||
{#each auth.savedAccounts as account}
|
||||
<div
|
||||
class="account-item"
|
||||
class:disabled={submitting}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => !submitting && handleSwitchAccount(account.did)}
|
||||
onkeydown={(e) => e.key === 'Enter' && !submitting && handleSwitchAccount(account.did)}
|
||||
>
|
||||
<div class="account-info">
|
||||
<span class="account-handle">@{account.handle}</span>
|
||||
<span class="account-did">{account.did}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="forget-btn"
|
||||
onclick={(e) => handleForgetAccount(account.did, e)}
|
||||
title={$_('login.removeAccount')}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<button type="button" class="secondary full-width" onclick={() => showNewLogin = true}>
|
||||
{$_('login.signInToAnother')}
|
||||
</button>
|
||||
|
||||
<p class="link-text">
|
||||
{$_('login.noAccount')} <a href="#/register">{$_('login.createAccount')}</a>
|
||||
</p>
|
||||
|
||||
{:else}
|
||||
<h1>{$_('login.title')}</h1>
|
||||
<p class="subtitle">{$_('login.subtitle')}</p>
|
||||
<header class="page-header">
|
||||
<h1>{$_('login.title')}</h1>
|
||||
<p class="subtitle">{auth.savedAccounts.length > 0 ? $_('login.chooseAccount') : $_('login.subtitle')}</p>
|
||||
</header>
|
||||
|
||||
{#if auth.savedAccounts.length > 0}
|
||||
<button type="button" class="tertiary back-btn" onclick={() => showNewLogin = false}>
|
||||
{$_('login.backToSaved')}
|
||||
</button>
|
||||
{/if}
|
||||
<div class="split-layout sidebar-right">
|
||||
<div class="main-section">
|
||||
{#if auth.savedAccounts.length > 0}
|
||||
<div class="saved-accounts">
|
||||
{#each auth.savedAccounts as account}
|
||||
<div
|
||||
class="account-item"
|
||||
class:disabled={submitting}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => !submitting && handleSwitchAccount(account.did)}
|
||||
onkeydown={(e) => e.key === 'Enter' && !submitting && handleSwitchAccount(account.did)}
|
||||
>
|
||||
<div class="account-info">
|
||||
<span class="account-handle">@{account.handle}</span>
|
||||
<span class="account-did">{account.did}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="forget-btn"
|
||||
onclick={(e) => handleForgetAccount(account.did, e)}
|
||||
title={$_('login.removeAccount')}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<button type="button" class="oauth-btn" onclick={handleOAuthLogin} disabled={submitting || auth.loading}>
|
||||
{submitting ? $_('login.redirecting') : $_('login.button')}
|
||||
</button>
|
||||
<p class="or-divider">{$_('login.signInToAnother')}</p>
|
||||
{/if}
|
||||
|
||||
<p class="forgot-links">
|
||||
<a href="#/reset-password">{$_('login.forgotPassword')}</a>
|
||||
<span class="separator">·</span>
|
||||
<a href="#/request-passkey-recovery">{$_('login.lostPasskey')}</a>
|
||||
</p>
|
||||
<button type="button" class="oauth-btn" onclick={handleOAuthLogin} disabled={submitting || auth.loading}>
|
||||
{submitting ? $_('login.redirecting') : $_('login.button')}
|
||||
</button>
|
||||
|
||||
<p class="link-text">
|
||||
{$_('login.noAccount')} <a href="#/register">{$_('login.createAccount')}</a>
|
||||
</p>
|
||||
<p class="forgot-links">
|
||||
<a href="#/reset-password">{$_('login.forgotPassword')}</a>
|
||||
<span class="separator">·</span>
|
||||
<a href="#/request-passkey-recovery">{$_('login.lostPasskey')}</a>
|
||||
</p>
|
||||
|
||||
<p class="link-text">
|
||||
{$_('login.noAccount')} <a href="#/register">{$_('login.createAccount')}</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<aside class="info-panel">
|
||||
{#if auth.savedAccounts.length > 0}
|
||||
<h3>{$_('login.infoSavedAccountsTitle')}</h3>
|
||||
<p>{$_('login.infoSavedAccountsDesc')}</p>
|
||||
|
||||
<h3>{$_('login.infoNewAccountTitle')}</h3>
|
||||
<p>{$_('login.infoNewAccountDesc')}</p>
|
||||
{:else}
|
||||
<h3>{$_('login.infoSecureSignInTitle')}</h3>
|
||||
<p>{$_('login.infoSecureSignInDesc')}</p>
|
||||
|
||||
<h3>{$_('login.infoStaySignedInTitle')}</h3>
|
||||
<p>{$_('login.infoStaySignedInDesc')}</p>
|
||||
{/if}
|
||||
|
||||
<h3>{$_('login.infoRecoveryTitle')}</h3>
|
||||
<p>{$_('login.infoRecoveryDesc')}</p>
|
||||
</aside>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.login-page {
|
||||
max-width: var(--width-sm);
|
||||
max-width: var(--width-lg);
|
||||
margin: var(--space-9) auto;
|
||||
padding: var(--space-7);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 var(--space-3) 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 var(--space-7) 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.main-section {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
max-width: var(--width-sm);
|
||||
}
|
||||
|
||||
.actions {
|
||||
@@ -202,6 +231,16 @@
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.actions {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.oauth-btn {
|
||||
width: 100%;
|
||||
padding: var(--space-5);
|
||||
@@ -209,8 +248,8 @@
|
||||
}
|
||||
|
||||
.forgot-links {
|
||||
text-align: center;
|
||||
margin-top: var(--space-5);
|
||||
margin-top: var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@@ -223,8 +262,8 @@
|
||||
}
|
||||
|
||||
.link-text {
|
||||
text-align: center;
|
||||
margin-top: var(--space-4);
|
||||
margin-top: var(--space-6);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@@ -297,12 +336,10 @@
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
margin-bottom: var(--space-5);
|
||||
padding: 0;
|
||||
.or-divider {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-sm);
|
||||
margin: var(--space-5) 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -167,54 +167,60 @@
|
||||
</button>
|
||||
</div>
|
||||
{:else if consentData}
|
||||
<div class="client-info">
|
||||
{#if consentData.logo_uri}
|
||||
<img src={consentData.logo_uri} alt="" class="client-logo" />
|
||||
{/if}
|
||||
<h1>{consentData.client_name || $_('oauth.consent.title')}</h1>
|
||||
<p class="subtitle">{$_('oauth.consent.appWantsAccess', { values: { app: '' } })}</p>
|
||||
{#if consentData.client_uri}
|
||||
<a href={consentData.client_uri} target="_blank" rel="noopener noreferrer" class="client-link">
|
||||
{consentData.client_uri}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="split-layout sidebar-left">
|
||||
<div class="client-panel">
|
||||
<div class="client-info">
|
||||
{#if consentData.logo_uri}
|
||||
<img src={consentData.logo_uri} alt="" class="client-logo" />
|
||||
{/if}
|
||||
<h1>{consentData.client_name || $_('oauth.consent.title')}</h1>
|
||||
<p class="subtitle">{$_('oauth.consent.appWantsAccess', { values: { app: '' } })}</p>
|
||||
{#if consentData.client_uri}
|
||||
<a href={consentData.client_uri} target="_blank" rel="noopener noreferrer" class="client-link">
|
||||
{consentData.client_uri}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="account-info">
|
||||
<span class="label">{$_('oauth.consent.signingInAs')}</span>
|
||||
<span class="did">{consentData.did}</span>
|
||||
</div>
|
||||
<div class="account-info">
|
||||
<span class="label">{$_('oauth.consent.signingInAs')}</span>
|
||||
<span class="did">{consentData.did}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scopes-section">
|
||||
<h2>{$_('oauth.consent.permissionsRequested')}</h2>
|
||||
{#each Object.entries(scopeGroups) as [category, scopes]}
|
||||
<div class="scope-group">
|
||||
<h3 class="category-title">{category}</h3>
|
||||
{#each scopes as scope}
|
||||
<label class="scope-item" class:required={scope.required}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={scopeSelections[scope.scope]}
|
||||
disabled={scope.required || submitting}
|
||||
onchange={() => handleScopeToggle(scope.scope)}
|
||||
/>
|
||||
<div class="scope-info">
|
||||
<span class="scope-name">{scope.display_name}</span>
|
||||
<span class="scope-description">{scope.description}</span>
|
||||
{#if scope.required}
|
||||
<span class="required-badge">{$_('oauth.consent.required')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
<div class="permissions-panel">
|
||||
<div class="scopes-section">
|
||||
<h2>{$_('oauth.consent.permissionsRequested')}</h2>
|
||||
{#each Object.entries(scopeGroups) as [category, scopes]}
|
||||
<div class="scope-group">
|
||||
<h3 class="category-title">{category}</h3>
|
||||
{#each scopes as scope}
|
||||
<label class="scope-item" class:required={scope.required}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={scopeSelections[scope.scope]}
|
||||
disabled={scope.required || submitting}
|
||||
onchange={() => handleScopeToggle(scope.scope)}
|
||||
/>
|
||||
<div class="scope-info">
|
||||
<span class="scope-name">{scope.display_name}</span>
|
||||
<span class="scope-description">{scope.description}</span>
|
||||
{#if scope.required}
|
||||
<span class="required-badge">{$_('oauth.consent.required')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<label class="remember-choice">
|
||||
<input type="checkbox" bind:checked={rememberChoice} disabled={submitting} />
|
||||
<span>{$_('oauth.consent.rememberChoiceLabel')}</span>
|
||||
</label>
|
||||
<label class="remember-choice">
|
||||
<input type="checkbox" bind:checked={rememberChoice} disabled={submitting} />
|
||||
<span>{$_('oauth.consent.rememberChoiceLabel')}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="deny-btn" onclick={handleDeny} disabled={submitting}>
|
||||
@@ -229,7 +235,7 @@
|
||||
|
||||
<style>
|
||||
.consent-container {
|
||||
max-width: 480px;
|
||||
max-width: var(--width-lg);
|
||||
margin: var(--space-7) auto;
|
||||
padding: var(--space-7);
|
||||
}
|
||||
@@ -244,6 +250,8 @@
|
||||
|
||||
.error-container {
|
||||
text-align: center;
|
||||
max-width: var(--width-sm);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.error {
|
||||
@@ -255,9 +263,27 @@
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.client-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.permissions-panel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.client-info {
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-6);
|
||||
padding: var(--space-6);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
@media (min-width: 800px) {
|
||||
.client-info {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.client-logo {
|
||||
@@ -397,7 +423,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-6);
|
||||
margin-top: var(--space-5);
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
@@ -411,6 +437,14 @@
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
|
||||
@media (min-width: 800px) {
|
||||
.actions {
|
||||
max-width: 400px;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.actions button {
|
||||
|
||||
@@ -315,14 +315,16 @@
|
||||
</script>
|
||||
|
||||
<div class="oauth-login-container">
|
||||
<h1>{$_('oauth.login.title')}</h1>
|
||||
<p class="subtitle">
|
||||
{#if clientName}
|
||||
{$_('oauth.login.subtitle')} <strong>{clientName}</strong>
|
||||
{:else}
|
||||
{$_('oauth.login.subtitle')}
|
||||
{/if}
|
||||
</p>
|
||||
<header class="page-header">
|
||||
<h1>{$_('oauth.login.title')}</h1>
|
||||
<p class="subtitle">
|
||||
{#if clientName}
|
||||
{$_('oauth.login.subtitle')} <strong>{clientName}</strong>
|
||||
{:else}
|
||||
{$_('oauth.login.subtitle')}
|
||||
{/if}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
@@ -343,62 +345,98 @@
|
||||
</div>
|
||||
|
||||
{#if passkeySupported && username.length >= 3}
|
||||
<button
|
||||
type="button"
|
||||
class="passkey-btn"
|
||||
class:passkey-unavailable={!hasPasskeys || checkingSecurityStatus || !securityStatusChecked}
|
||||
onclick={handlePasskeyLogin}
|
||||
disabled={submitting || !hasPasskeys || !username || checkingSecurityStatus || !securityStatusChecked}
|
||||
title={checkingSecurityStatus ? $_('oauth.login.passkeyHintChecking') : hasPasskeys ? $_('oauth.login.passkeyHintAvailable') : $_('oauth.login.passkeyHintNotAvailable')}
|
||||
>
|
||||
<svg class="passkey-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M15 7a4 4 0 1 0-8 0 4 4 0 0 0 8 0z" />
|
||||
<path d="M17 17v4l3-2-3-2z" />
|
||||
<path d="M12 11c-4 0-6 2-6 4v4h9" />
|
||||
</svg>
|
||||
<span class="passkey-text">
|
||||
{#if submitting}
|
||||
{$_('oauth.login.authenticating')}
|
||||
{:else if checkingSecurityStatus || !securityStatusChecked}
|
||||
{$_('oauth.login.checkingPasskey')}
|
||||
{:else if hasPasskeys}
|
||||
{$_('oauth.login.signInWithPasskey')}
|
||||
{:else}
|
||||
{$_('oauth.login.passkeyNotSetUp')}
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
<div class="auth-methods">
|
||||
<div class="passkey-method">
|
||||
<h3>{$_('oauth.login.signInWithPasskey')}</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="passkey-btn"
|
||||
class:passkey-unavailable={!hasPasskeys || checkingSecurityStatus || !securityStatusChecked}
|
||||
onclick={handlePasskeyLogin}
|
||||
disabled={submitting || !hasPasskeys || !username || checkingSecurityStatus || !securityStatusChecked}
|
||||
title={checkingSecurityStatus ? $_('oauth.login.passkeyHintChecking') : hasPasskeys ? $_('oauth.login.passkeyHintAvailable') : $_('oauth.login.passkeyHintNotAvailable')}
|
||||
>
|
||||
<svg class="passkey-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M15 7a4 4 0 1 0-8 0 4 4 0 0 0 8 0z" />
|
||||
<path d="M17 17v4l3-2-3-2z" />
|
||||
<path d="M12 11c-4 0-6 2-6 4v4h9" />
|
||||
</svg>
|
||||
<span class="passkey-text">
|
||||
{#if submitting}
|
||||
{$_('oauth.login.authenticating')}
|
||||
{:else if checkingSecurityStatus || !securityStatusChecked}
|
||||
{$_('oauth.login.checkingPasskey')}
|
||||
{:else if hasPasskeys}
|
||||
{$_('oauth.login.usePasskey')}
|
||||
{:else}
|
||||
{$_('oauth.login.passkeyNotSetUp')}
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
<p class="method-hint">{$_('oauth.login.passkeyHint')}</p>
|
||||
</div>
|
||||
|
||||
<div class="auth-divider">
|
||||
<span>{$_('oauth.login.orUsePassword')}</span>
|
||||
<div class="method-divider">
|
||||
<span>{$_('oauth.login.orUsePassword')}</span>
|
||||
</div>
|
||||
|
||||
<div class="password-method">
|
||||
<h3>{$_('oauth.login.password')}</h3>
|
||||
<div class="field">
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
disabled={submitting}
|
||||
required
|
||||
autocomplete="current-password"
|
||||
placeholder={$_('oauth.login.passwordPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label class="remember-device">
|
||||
<input type="checkbox" bind:checked={rememberDevice} disabled={submitting} />
|
||||
<span>{$_('oauth.login.rememberDevice')}</span>
|
||||
</label>
|
||||
|
||||
<button type="submit" class="submit-btn" disabled={submitting || !username || !password}>
|
||||
{submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}>
|
||||
{$_('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="field">
|
||||
<label for="password">{$_('oauth.login.password')}</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
disabled={submitting}
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label class="remember-device">
|
||||
<input type="checkbox" bind:checked={rememberDevice} disabled={submitting} />
|
||||
<span>{$_('oauth.login.rememberDevice')}</span>
|
||||
</label>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}>
|
||||
{$_('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" class="submit-btn" disabled={submitting || !username || !password}>
|
||||
{submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="field">
|
||||
<label for="password">{$_('oauth.login.password')}</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
disabled={submitting}
|
||||
required
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label class="remember-device">
|
||||
<input type="checkbox" bind:checked={rememberDevice} disabled={submitting} />
|
||||
<span>{$_('oauth.login.rememberDevice')}</span>
|
||||
</label>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}>
|
||||
{$_('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" class="submit-btn" disabled={submitting || !username || !password}>
|
||||
{submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p class="help-links">
|
||||
@@ -423,18 +461,22 @@
|
||||
}
|
||||
|
||||
.oauth-login-container {
|
||||
max-width: var(--width-sm);
|
||||
max-width: var(--width-md);
|
||||
margin: var(--space-9) auto;
|
||||
padding: var(--space-7);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 var(--space-2) 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 var(--space-7) 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
form {
|
||||
@@ -443,6 +485,90 @@
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.auth-methods {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-5);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.auth-methods {
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
|
||||
.passkey-method,
|
||||
.password-method {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-5);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
.passkey-method h3,
|
||||
.password-method h3 {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.method-hint {
|
||||
margin: 0;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.method-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.method-divider {
|
||||
flex-direction: column;
|
||||
padding: 0 var(--space-3);
|
||||
}
|
||||
|
||||
.method-divider::before,
|
||||
.method-divider::after {
|
||||
content: '';
|
||||
width: 1px;
|
||||
height: var(--space-6);
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.method-divider span {
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
transform: rotate(180deg);
|
||||
padding: var(--space-2) 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 599px) {
|
||||
.method-divider {
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.method-divider::before,
|
||||
.method-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border-color);
|
||||
}
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -534,25 +660,6 @@
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.auth-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
margin: var(--space-2) 0;
|
||||
}
|
||||
|
||||
.auth-divider::before,
|
||||
.auth-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.auth-divider span {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.passkey-btn {
|
||||
display: flex;
|
||||
|
||||
+242
-224
@@ -142,20 +142,32 @@
|
||||
if (!flow) return ''
|
||||
switch (flow.state.step) {
|
||||
case 'info': return $_('register.subtitle')
|
||||
case 'key-choice': return 'Choose how to set up your external did:web identity.'
|
||||
case 'initial-did-doc': return 'Upload your DID document to continue.'
|
||||
case 'key-choice': return $_('register.subtitleKeyChoice')
|
||||
case 'initial-did-doc': return $_('register.subtitleInitialDidDoc')
|
||||
case 'creating': return $_('register.creating')
|
||||
case 'verify': return `Verify your ${channelLabel(flow.info.verificationChannel)} to continue.`
|
||||
case 'updated-did-doc': return 'Update your DID document with the PDS signing key.'
|
||||
case 'activating': return 'Activating your account...'
|
||||
case 'redirect-to-dashboard': return 'Your account has been created successfully!'
|
||||
case 'verify': return $_('register.subtitleVerify', { values: { channel: channelLabel(flow.info.verificationChannel) } })
|
||||
case 'updated-did-doc': return $_('register.subtitleUpdatedDidDoc')
|
||||
case 'activating': return $_('register.subtitleActivating')
|
||||
case 'redirect-to-dashboard': return $_('register.subtitleComplete')
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="register-page">
|
||||
{#if flow?.state.step === 'info'}
|
||||
<header class="page-header">
|
||||
<h1>{$_('register.title')}</h1>
|
||||
<p class="subtitle">{getSubtitle()}</p>
|
||||
</header>
|
||||
|
||||
{#if flow?.state.error}
|
||||
<div class="message error">{flow.state.error}</div>
|
||||
{/if}
|
||||
|
||||
{#if loadingServerInfo || !flow}
|
||||
<p class="loading">{$_('common.loading')}</p>
|
||||
|
||||
{:else if flow.state.step === 'info'}
|
||||
<div class="migrate-callout">
|
||||
<div class="migrate-icon">↗</div>
|
||||
<div class="migrate-content">
|
||||
@@ -166,218 +178,223 @@
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<h1>{$_('register.title')}</h1>
|
||||
<p class="subtitle">{getSubtitle()}</p>
|
||||
<div class="split-layout sidebar-right">
|
||||
<div class="form-section">
|
||||
<form onsubmit={handleInfoSubmit}>
|
||||
<div class="field">
|
||||
<label for="handle">{$_('register.handle')}</label>
|
||||
<input
|
||||
id="handle"
|
||||
type="text"
|
||||
bind:value={flow.info.handle}
|
||||
placeholder={$_('register.handlePlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
{#if flow.info.handle.includes('.')}
|
||||
<p class="hint warning">{$_('register.handleDotWarning')}</p>
|
||||
{:else if fullHandle()}
|
||||
<p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if flow?.state.error}
|
||||
<div class="message error">{flow.state.error}</div>
|
||||
{/if}
|
||||
<div class="form-row">
|
||||
<div class="field">
|
||||
<label for="password">{$_('register.password')}</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={flow.info.password}
|
||||
placeholder={$_('register.passwordPlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
minlength="8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if loadingServerInfo || !flow}
|
||||
<p class="loading">{$_('common.loading')}</p>
|
||||
<div class="field">
|
||||
<label for="confirm-password">{$_('register.confirmPassword')}</label>
|
||||
<input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
bind:value={confirmPassword}
|
||||
placeholder={$_('register.confirmPasswordPlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if flow.state.step === 'info'}
|
||||
<form onsubmit={handleInfoSubmit}>
|
||||
<div class="field">
|
||||
<label for="handle">{$_('register.handle')}</label>
|
||||
<input
|
||||
id="handle"
|
||||
type="text"
|
||||
bind:value={flow.info.handle}
|
||||
placeholder={$_('register.handlePlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
{#if flow.info.handle.includes('.')}
|
||||
<p class="hint warning">{$_('register.handleDotWarning')}</p>
|
||||
{:else if fullHandle()}
|
||||
<p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p>
|
||||
{/if}
|
||||
<fieldset class="section-fieldset">
|
||||
<legend>{$_('register.identityType')}</legend>
|
||||
<div class="radio-group">
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} />
|
||||
<span class="radio-content">
|
||||
<strong>{$_('register.didPlc')}</strong> {$_('register.didPlcRecommended')}
|
||||
<span class="radio-hint">{$_('register.didPlcHint')}</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting} />
|
||||
<span class="radio-content">
|
||||
<strong>{$_('register.didWeb')}</strong>
|
||||
<span class="radio-hint">{$_('register.didWebHint')}</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} />
|
||||
<span class="radio-content">
|
||||
<strong>{$_('register.didWebBYOD')}</strong>
|
||||
<span class="radio-hint">{$_('register.didWebBYODHint')}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if flow.info.didType === 'web'}
|
||||
<div class="warning-box">
|
||||
<strong>{$_('register.didWebWarningTitle')}</strong>
|
||||
<ul>
|
||||
<li><strong>{$_('register.didWebWarning1')}</strong> {$_('register.didWebWarning1Detail', { values: { did: `did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}` } })}</li>
|
||||
<li><strong>{$_('register.didWebWarning2')}</strong> {$_('register.didWebWarning2Detail')}</li>
|
||||
<li><strong>{$_('register.didWebWarning3')}</strong> {$_('register.didWebWarning3Detail')}</li>
|
||||
<li><strong>{$_('register.didWebWarning4')}</strong> {$_('register.didWebWarning4Detail')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if flow.info.didType === 'web-external'}
|
||||
<div class="field">
|
||||
<label for="external-did">{$_('register.externalDid')}</label>
|
||||
<input
|
||||
id="external-did"
|
||||
type="text"
|
||||
bind:value={flow.info.externalDid}
|
||||
placeholder={$_('register.externalDidPlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
<p class="hint">{$_('register.externalDidHint')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="section-fieldset">
|
||||
<legend>{$_('register.contactMethod')}</legend>
|
||||
<div class="contact-fields">
|
||||
<div class="field">
|
||||
<label for="verification-channel">{$_('register.verificationMethod')}</label>
|
||||
<select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}>
|
||||
<option value="email">{$_('register.email')}</option>
|
||||
<option value="discord" disabled={!isChannelAvailable('discord')}>
|
||||
{$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`}
|
||||
</option>
|
||||
<option value="telegram" disabled={!isChannelAvailable('telegram')}>
|
||||
{$_('register.telegram')}{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`}
|
||||
</option>
|
||||
<option value="signal" disabled={!isChannelAvailable('signal')}>
|
||||
{$_('register.signal')}{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if flow.info.verificationChannel === 'email'}
|
||||
<div class="field">
|
||||
<label for="email">{$_('register.emailAddress')}</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
bind:value={flow.info.email}
|
||||
placeholder={$_('register.emailPlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{:else if flow.info.verificationChannel === 'discord'}
|
||||
<div class="field">
|
||||
<label for="discord-id">{$_('register.discordId')}</label>
|
||||
<input
|
||||
id="discord-id"
|
||||
type="text"
|
||||
bind:value={flow.info.discordId}
|
||||
placeholder={$_('register.discordIdPlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
<p class="hint">{$_('register.discordIdHint')}</p>
|
||||
</div>
|
||||
{:else if flow.info.verificationChannel === 'telegram'}
|
||||
<div class="field">
|
||||
<label for="telegram-username">{$_('register.telegramUsername')}</label>
|
||||
<input
|
||||
id="telegram-username"
|
||||
type="text"
|
||||
bind:value={flow.info.telegramUsername}
|
||||
placeholder={$_('register.telegramUsernamePlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{:else if flow.info.verificationChannel === 'signal'}
|
||||
<div class="field">
|
||||
<label for="signal-number">{$_('register.signalNumber')}</label>
|
||||
<input
|
||||
id="signal-number"
|
||||
type="tel"
|
||||
bind:value={flow.info.signalNumber}
|
||||
placeholder={$_('register.signalNumberPlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
<p class="hint">{$_('register.signalNumberHint')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{#if serverInfo?.inviteCodeRequired}
|
||||
<div class="field">
|
||||
<label for="invite-code">{$_('register.inviteCode')} <span class="required">{$_('register.inviteCodeRequired')}</span></label>
|
||||
<input
|
||||
id="invite-code"
|
||||
type="text"
|
||||
bind:value={flow.info.inviteCode}
|
||||
placeholder={$_('register.inviteCodePlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button type="submit" disabled={flow.state.submitting}>
|
||||
{flow.state.submitting ? $_('register.creating') : $_('register.createButton')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="form-links">
|
||||
<p class="link-text">
|
||||
{$_('register.alreadyHaveAccount')} <a href="#/login">{$_('register.signIn')}</a>
|
||||
</p>
|
||||
<p class="link-text">
|
||||
{$_('register.wantPasswordless')} <a href="#/register-passkey">{$_('register.createPasskeyAccount')}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="password">{$_('register.password')}</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={flow.info.password}
|
||||
placeholder={$_('register.passwordPlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
minlength="8"
|
||||
/>
|
||||
</div>
|
||||
<aside class="info-panel">
|
||||
<h3>{$_('register.identityHint')}</h3>
|
||||
<p>{$_('register.infoIdentityDesc')}</p>
|
||||
|
||||
<div class="field">
|
||||
<label for="confirm-password">{$_('register.confirmPassword')}</label>
|
||||
<input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
bind:value={confirmPassword}
|
||||
placeholder={$_('register.confirmPasswordPlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<h3>{$_('register.contactMethodHint')}</h3>
|
||||
<p>{$_('register.infoContactDesc')}</p>
|
||||
|
||||
<fieldset class="section-fieldset">
|
||||
<legend>{$_('register.identityType')}</legend>
|
||||
<p class="section-hint">{$_('register.identityHint')}</p>
|
||||
|
||||
<div class="radio-group">
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} />
|
||||
<span class="radio-content">
|
||||
<strong>{$_('register.didPlc')}</strong> {$_('register.didPlcRecommended')}
|
||||
<span class="radio-hint">{$_('register.didPlcHint')}</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting} />
|
||||
<span class="radio-content">
|
||||
<strong>{$_('register.didWeb')}</strong>
|
||||
<span class="radio-hint">{$_('register.didWebHint')}</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="radio-label">
|
||||
<input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} />
|
||||
<span class="radio-content">
|
||||
<strong>{$_('register.didWebBYOD')}</strong>
|
||||
<span class="radio-hint">{$_('register.didWebBYODHint')}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if flow.info.didType === 'web'}
|
||||
<div class="warning-box">
|
||||
<strong>{$_('register.didWebWarningTitle')}</strong>
|
||||
<ul>
|
||||
<li><strong>{$_('register.didWebWarning1')}</strong> {$_('register.didWebWarning1Detail', { values: { did: `did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}` } })}</li>
|
||||
<li><strong>{$_('register.didWebWarning2')}</strong> {$_('register.didWebWarning2Detail')}</li>
|
||||
<li><strong>{$_('register.didWebWarning3')}</strong> {$_('register.didWebWarning3Detail')}</li>
|
||||
<li><strong>{$_('register.didWebWarning4')}</strong> {$_('register.didWebWarning4Detail')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if flow.info.didType === 'web-external'}
|
||||
<div class="field">
|
||||
<label for="external-did">{$_('register.externalDid')}</label>
|
||||
<input
|
||||
id="external-did"
|
||||
type="text"
|
||||
bind:value={flow.info.externalDid}
|
||||
placeholder={$_('register.externalDidPlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
<p class="hint">{$_('register.externalDidHint')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="section-fieldset">
|
||||
<legend>{$_('register.contactMethod')}</legend>
|
||||
<p class="section-hint">{$_('register.contactMethodHint')}</p>
|
||||
|
||||
<div class="field">
|
||||
<label for="verification-channel">{$_('register.verificationMethod')}</label>
|
||||
<select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}>
|
||||
<option value="email">{$_('register.email')}</option>
|
||||
<option value="discord" disabled={!isChannelAvailable('discord')}>
|
||||
{$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`}
|
||||
</option>
|
||||
<option value="telegram" disabled={!isChannelAvailable('telegram')}>
|
||||
{$_('register.telegram')}{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`}
|
||||
</option>
|
||||
<option value="signal" disabled={!isChannelAvailable('signal')}>
|
||||
{$_('register.signal')}{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if flow.info.verificationChannel === 'email'}
|
||||
<div class="field">
|
||||
<label for="email">{$_('register.emailAddress')}</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
bind:value={flow.info.email}
|
||||
placeholder={$_('register.emailPlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{:else if flow.info.verificationChannel === 'discord'}
|
||||
<div class="field">
|
||||
<label for="discord-id">{$_('register.discordId')}</label>
|
||||
<input
|
||||
id="discord-id"
|
||||
type="text"
|
||||
bind:value={flow.info.discordId}
|
||||
placeholder={$_('register.discordIdPlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
<p class="hint">{$_('register.discordIdHint')}</p>
|
||||
</div>
|
||||
{:else if flow.info.verificationChannel === 'telegram'}
|
||||
<div class="field">
|
||||
<label for="telegram-username">{$_('register.telegramUsername')}</label>
|
||||
<input
|
||||
id="telegram-username"
|
||||
type="text"
|
||||
bind:value={flow.info.telegramUsername}
|
||||
placeholder={$_('register.telegramUsernamePlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{:else if flow.info.verificationChannel === 'signal'}
|
||||
<div class="field">
|
||||
<label for="signal-number">{$_('register.signalNumber')}</label>
|
||||
<input
|
||||
id="signal-number"
|
||||
type="tel"
|
||||
bind:value={flow.info.signalNumber}
|
||||
placeholder={$_('register.signalNumberPlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
<p class="hint">{$_('register.signalNumberHint')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
|
||||
{#if serverInfo?.inviteCodeRequired}
|
||||
<div class="field">
|
||||
<label for="invite-code">{$_('register.inviteCode')} <span class="required">{$_('register.inviteCodeRequired')}</span></label>
|
||||
<input
|
||||
id="invite-code"
|
||||
type="text"
|
||||
bind:value={flow.info.inviteCode}
|
||||
placeholder={$_('register.inviteCodePlaceholder')}
|
||||
disabled={flow.state.submitting}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button type="submit" disabled={flow.state.submitting}>
|
||||
{flow.state.submitting ? $_('register.creating') : $_('register.createButton')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="link-text">
|
||||
{$_('register.alreadyHaveAccount')} <a href="#/login">{$_('register.signIn')}</a>
|
||||
</p>
|
||||
<p class="link-text">
|
||||
{$_('register.wantPasswordless')} <a href="#/register-passkey">{$_('register.createPasskeyAccount')}</a>
|
||||
</p>
|
||||
<h3>{$_('register.infoNextTitle')}</h3>
|
||||
<p>{$_('register.infoNextDesc')}</p>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{:else if flow.state.step === 'key-choice'}
|
||||
<KeyChoiceStep {flow} />
|
||||
@@ -404,17 +421,29 @@
|
||||
/>
|
||||
|
||||
{:else if flow.state.step === 'redirect-to-dashboard'}
|
||||
<p class="loading">Redirecting to dashboard...</p>
|
||||
<p class="loading">{$_('register.redirecting')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.register-page {
|
||||
max-width: var(--width-sm);
|
||||
max-width: var(--width-lg);
|
||||
margin: var(--space-9) auto;
|
||||
padding: var(--space-7);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.form-section {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.form-links {
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
|
||||
.migrate-callout {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
@@ -483,17 +512,6 @@
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.section-fieldset {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.section-fieldset legend {
|
||||
font-weight: var(--font-semibold);
|
||||
padding: 0 var(--space-3);
|
||||
}
|
||||
|
||||
.section-hint {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
|
||||
@@ -369,7 +369,7 @@
|
||||
<div class="warning-box">
|
||||
<strong>{$_('registerPasskey.didWebWarningTitle')}</strong>
|
||||
<ul>
|
||||
<li><strong>{$_('registerPasskey.didWebWarning1')}</strong> Your identity will be <code>did:web:yourhandle.{serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>.</li>
|
||||
<li><strong>{$_('registerPasskey.didWebWarning1')}</strong> {@html $_('registerPasskey.didWebWarning1Detail', { values: { did: `<code>did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>` } })}</li>
|
||||
<li><strong>{$_('registerPasskey.didWebWarning2')}</strong> {$_('registerPasskey.didWebWarning2Detail')}</li>
|
||||
<li><strong>{$_('registerPasskey.didWebWarning3')}</strong> {$_('registerPasskey.didWebWarning3Detail')}</li>
|
||||
<li><strong>{$_('registerPasskey.didWebWarning4')}</strong> {$_('registerPasskey.didWebWarning4Detail')}</li>
|
||||
@@ -544,17 +544,6 @@
|
||||
color: var(--error-text);
|
||||
}
|
||||
|
||||
.section-fieldset {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.section-fieldset legend {
|
||||
font-weight: var(--font-semibold);
|
||||
padding: 0 var(--space-3);
|
||||
}
|
||||
|
||||
.section-hint {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
}
|
||||
}
|
||||
async function loadMoreRecords() {
|
||||
if (!auth.session || !selectedCollection || !recordsCursor) return
|
||||
if (!auth.session || !selectedCollection || !recordsCursor || loadingMore) return
|
||||
loadingMore = true
|
||||
try {
|
||||
const result = await api.listRecords(auth.session.accessJwt, auth.session.did, selectedCollection, {
|
||||
@@ -93,6 +93,12 @@
|
||||
loadingMore = false
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (view === 'records' && recordsCursor && !loadingMore && !loading) {
|
||||
loadMoreRecords()
|
||||
}
|
||||
})
|
||||
async function selectRecord(record: { uri: string; cid: string; value: unknown; rkey: string }) {
|
||||
selectedRecord = record
|
||||
recordJson = JSON.stringify(record.value, null, 2)
|
||||
@@ -371,11 +377,17 @@
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if recordsCursor}
|
||||
<div class="load-more">
|
||||
<button onclick={loadMoreRecords} disabled={loadingMore}>
|
||||
{loadingMore ? $_('common.loading') : $_('repoExplorer.loadMore')}
|
||||
</button>
|
||||
{#if loadingMore}
|
||||
<div class="skeleton-records">
|
||||
{#each [1, 2, 3] as _}
|
||||
<div class="skeleton-record">
|
||||
<div class="skeleton-record-header">
|
||||
<div class="skeleton-line short"></div>
|
||||
<div class="skeleton-line tiny"></div>
|
||||
</div>
|
||||
<div class="skeleton-preview"></div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -383,9 +395,9 @@
|
||||
<div class="record-detail">
|
||||
<div class="record-meta">
|
||||
<dl>
|
||||
<dt>URI</dt>
|
||||
<dt>{$_('repoExplorer.uri')}</dt>
|
||||
<dd class="mono">{selectedRecord.uri}</dd>
|
||||
<dt>CID</dt>
|
||||
<dt>{$_('repoExplorer.cid')}</dt>
|
||||
<dd class="mono">{selectedRecord.cid}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
@@ -463,7 +475,7 @@
|
||||
</div>
|
||||
<style>
|
||||
.page {
|
||||
max-width: var(--width-lg);
|
||||
max-width: var(--width-xl);
|
||||
margin: 0 auto;
|
||||
padding: var(--space-7);
|
||||
}
|
||||
@@ -751,22 +763,51 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.load-more {
|
||||
text-align: center;
|
||||
padding: var(--space-4);
|
||||
.skeleton-records {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.load-more button {
|
||||
padding: var(--space-2) var(--space-7);
|
||||
background: var(--bg-secondary);
|
||||
.skeleton-record {
|
||||
padding: var(--space-4);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.load-more button:hover:not(:disabled) {
|
||||
background: var(--bg-card);
|
||||
.skeleton-record-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
height: 14px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.skeleton-line.short {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.skeleton-line.tiny {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.skeleton-preview {
|
||||
height: 60px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.record-detail {
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
try {
|
||||
const token = await getValidToken()
|
||||
if (!token) {
|
||||
showMessage('error', 'Session expired. Please log in again.')
|
||||
showMessage('error', $_('security.sessionExpired'))
|
||||
return
|
||||
}
|
||||
await api.removePassword(token)
|
||||
@@ -414,6 +414,7 @@
|
||||
{#if loading}
|
||||
<div class="loading">{$_('common.loading')}</div>
|
||||
{:else}
|
||||
<div class="sections-grid">
|
||||
<section>
|
||||
<h2>{$_('security.totp')}</h2>
|
||||
<p class="description">
|
||||
@@ -725,6 +726,7 @@
|
||||
{$_('security.manageTrustedDevices')} →
|
||||
</a>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{#if hasMfa}
|
||||
<section>
|
||||
@@ -788,7 +790,7 @@
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: var(--width-md);
|
||||
max-width: var(--width-lg);
|
||||
margin: 0 auto;
|
||||
padding: var(--space-7);
|
||||
}
|
||||
@@ -797,6 +799,26 @@
|
||||
margin-bottom: var(--space-7);
|
||||
}
|
||||
|
||||
.sections-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
@media (min-width: 800px) {
|
||||
.sections-grid {
|
||||
columns: 2;
|
||||
column-gap: var(--space-6);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sections-grid section {
|
||||
break-inside: avoid;
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
}
|
||||
|
||||
.back {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
@@ -822,6 +844,7 @@
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-xl);
|
||||
margin-bottom: var(--space-6);
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
section h2 {
|
||||
|
||||
@@ -149,7 +149,7 @@
|
||||
</div>
|
||||
<style>
|
||||
.page {
|
||||
max-width: var(--width-md);
|
||||
max-width: var(--width-lg);
|
||||
margin: 0 auto;
|
||||
padding: var(--space-7);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { getAuthState, logout, refreshSession } from '../lib/auth.svelte'
|
||||
import { navigate } from '../lib/router.svelte'
|
||||
import { api, ApiError } from '../lib/api'
|
||||
import { locale, setLocale, getSupportedLocales, localeNames, _, type SupportedLocale } from '../lib/i18n'
|
||||
const auth = getAuthState()
|
||||
const supportedLocales = getSupportedLocales()
|
||||
let pdsHostname = $state<string | null>(null)
|
||||
|
||||
onMount(() => {
|
||||
api.describeServer().then(info => {
|
||||
if (info.availableUserDomains?.length) {
|
||||
pdsHostname = info.availableUserDomains[0]
|
||||
}
|
||||
}).catch(() => {})
|
||||
})
|
||||
let localeLoading = $state(false)
|
||||
async function handleLocaleChange(newLocale: SupportedLocale) {
|
||||
if (!auth.session) return
|
||||
@@ -94,7 +104,7 @@
|
||||
try {
|
||||
const fullHandle = showBYOHandle
|
||||
? newHandle
|
||||
: `${newHandle}.${window.location.hostname}`
|
||||
: `${newHandle}.${pdsHostname}`
|
||||
await api.updateHandle(auth.session.accessJwt, fullHandle)
|
||||
await refreshSession()
|
||||
showMessage('success', $_('settings.messages.handleUpdated'))
|
||||
@@ -201,6 +211,7 @@
|
||||
{#if message}
|
||||
<div class="message {message.type}">{message.text}</div>
|
||||
{/if}
|
||||
<div class="sections-grid">
|
||||
<section>
|
||||
<h2>{$_('settings.language')}</h2>
|
||||
<p class="description">{$_('settings.languageDescription')}</p>
|
||||
@@ -335,10 +346,10 @@
|
||||
disabled={handleLoading}
|
||||
required
|
||||
/>
|
||||
<span class="handle-suffix">.{window.location.hostname}</span>
|
||||
<span class="handle-suffix">.{pdsHostname ?? '...'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" disabled={handleLoading || !newHandle}>
|
||||
<button type="submit" disabled={handleLoading || !newHandle || !pdsHostname}>
|
||||
{handleLoading ? $_('settings.updating') : $_('settings.changeHandleButton')}
|
||||
</button>
|
||||
</form>
|
||||
@@ -393,6 +404,7 @@
|
||||
{exportLoading ? $_('settings.exporting') : $_('settings.downloadRepo')}
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
<section class="danger-zone">
|
||||
<h2>{$_('settings.deleteAccount')}</h2>
|
||||
<p class="warning">{$_('settings.deleteWarning')}</p>
|
||||
@@ -438,7 +450,7 @@
|
||||
</div>
|
||||
<style>
|
||||
.page {
|
||||
max-width: var(--width-md);
|
||||
max-width: var(--width-lg);
|
||||
margin: 0 auto;
|
||||
padding: var(--space-7);
|
||||
}
|
||||
@@ -447,6 +459,25 @@
|
||||
margin-bottom: var(--space-7);
|
||||
}
|
||||
|
||||
.sections-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
@media (min-width: 800px) {
|
||||
.sections-grid {
|
||||
columns: 2;
|
||||
column-gap: var(--space-6);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sections-grid section {
|
||||
break-inside: avoid;
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
}
|
||||
|
||||
.back {
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
@@ -466,6 +497,11 @@
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-xl);
|
||||
margin-bottom: var(--space-6);
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.danger-zone {
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
|
||||
section h2 {
|
||||
@@ -484,6 +520,11 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
form > button,
|
||||
form > .actions {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
const result = await api.listTrustedDevices(auth.session.accessJwt)
|
||||
devices = result.devices
|
||||
} catch {
|
||||
showMessage('error', 'Failed to load trusted devices')
|
||||
showMessage('error', $_('trustedDevices.failedToLoad'))
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
@@ -199,9 +199,9 @@
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: var(--width-md);
|
||||
max-width: var(--width-lg);
|
||||
margin: 0 auto;
|
||||
padding: var(--space-7) var(--space-4);
|
||||
padding: var(--space-7);
|
||||
}
|
||||
|
||||
header {
|
||||
|
||||
+131
-27
@@ -1,15 +1,15 @@
|
||||
@import './tokens.css';
|
||||
@import "./tokens.css";
|
||||
|
||||
@property --accent {
|
||||
syntax: '<color>';
|
||||
syntax: "<color>";
|
||||
inherits: true;
|
||||
initial-value: #2c00ff;
|
||||
initial-value: #1a1d1d;
|
||||
}
|
||||
|
||||
@property --secondary {
|
||||
syntax: '<color>';
|
||||
syntax: "<color>";
|
||||
inherits: true;
|
||||
initial-value: #ff2400;
|
||||
initial-value: #1a1d1d;
|
||||
}
|
||||
|
||||
*,
|
||||
@@ -20,7 +20,8 @@
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Monaco, monospace;
|
||||
font-family:
|
||||
"Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-normal);
|
||||
color: var(--text-primary);
|
||||
@@ -35,10 +36,18 @@ h1, h2, h3, h4, h5, h6 {
|
||||
line-height: var(--leading-tight);
|
||||
}
|
||||
|
||||
h1 { font-size: var(--text-2xl); }
|
||||
h2 { font-size: var(--text-xl); }
|
||||
h3 { font-size: var(--text-lg); }
|
||||
h4 { font-size: var(--text-base); }
|
||||
h1 {
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
h2 {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
h3 {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
h4 {
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
@@ -70,7 +79,9 @@ textarea {
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
transition: border-color var(--transition-normal), box-shadow var(--transition-normal);
|
||||
transition:
|
||||
border-color var(--transition-normal),
|
||||
box-shadow var(--transition-normal);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -113,7 +124,10 @@ button {
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-normal), border-color var(--transition-normal), opacity var(--transition-normal);
|
||||
transition:
|
||||
background var(--transition-normal),
|
||||
border-color var(--transition-normal),
|
||||
opacity var(--transition-normal);
|
||||
background: var(--accent);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
@@ -177,20 +191,34 @@ label {
|
||||
}
|
||||
|
||||
fieldset {
|
||||
border: 1px solid var(--border-dark);
|
||||
border: none;
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
padding-left: var(--space-6);
|
||||
margin: 0;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
fieldset legend {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-semibold);
|
||||
padding: 0 var(--space-3);
|
||||
color: var(--text-primary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0;
|
||||
margin-left: calc(-1 * var(--space-1));
|
||||
margin-bottom: var(--space-3);
|
||||
color: var(--text-secondary);
|
||||
float: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
fieldset legend + * {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: inherit;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, Monaco, monospace;
|
||||
font-size: 0.9em;
|
||||
background: var(--bg-tertiary);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
@@ -198,7 +226,7 @@ code {
|
||||
}
|
||||
|
||||
pre {
|
||||
font-family: inherit;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, Monaco, monospace;
|
||||
font-size: var(--text-sm);
|
||||
background: var(--bg-tertiary);
|
||||
padding: var(--space-4);
|
||||
@@ -223,6 +251,10 @@ hr {
|
||||
margin-top: var(--space-5);
|
||||
}
|
||||
|
||||
.form-row .field + .field {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
@@ -307,19 +339,19 @@ hr {
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: var(--width-md);
|
||||
max-width: var(--width-lg);
|
||||
margin: 0 auto;
|
||||
padding: var(--space-7);
|
||||
}
|
||||
|
||||
.page-sm {
|
||||
max-width: var(--width-sm);
|
||||
max-width: var(--width-md);
|
||||
margin: 0 auto;
|
||||
padding: var(--space-7);
|
||||
}
|
||||
|
||||
.page-lg {
|
||||
max-width: var(--width-lg);
|
||||
max-width: var(--width-xl);
|
||||
margin: 0 auto;
|
||||
padding: var(--space-7);
|
||||
}
|
||||
@@ -357,12 +389,84 @@ hr {
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: inherit;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, Monaco, monospace;
|
||||
}
|
||||
|
||||
.mt-4 { margin-top: var(--space-4); }
|
||||
.mt-5 { margin-top: var(--space-5); }
|
||||
.mt-6 { margin-top: var(--space-6); }
|
||||
.mb-4 { margin-bottom: var(--space-4); }
|
||||
.mb-5 { margin-bottom: var(--space-5); }
|
||||
.mb-6 { margin-bottom: var(--space-6); }
|
||||
.mt-4 {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
.mt-5 {
|
||||
margin-top: var(--space-5);
|
||||
}
|
||||
.mt-6 {
|
||||
margin-top: var(--space-6);
|
||||
}
|
||||
.mb-4 {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.mb-5 {
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
.mb-6 {
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.split-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
@media (min-width: 800px) {
|
||||
.split-layout {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.split-layout.sidebar-right {
|
||||
grid-template-columns: 1.5fr 1fr;
|
||||
}
|
||||
.split-layout.sidebar-left {
|
||||
grid-template-columns: 1fr 1.5fr;
|
||||
}
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
.form-row {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.form-row.thirds {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.info-panel {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--space-6);
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.info-panel h3 {
|
||||
margin: 0 0 var(--space-3) 0;
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-semibold);
|
||||
}
|
||||
|
||||
.info-panel p {
|
||||
margin: 0 0 var(--space-4) 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.info-panel p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -33,11 +33,11 @@
|
||||
--radius-lg: 6px;
|
||||
--radius-xl: 8px;
|
||||
|
||||
--width-xs: 320px;
|
||||
--width-sm: 400px;
|
||||
--width-md: 600px;
|
||||
--width-lg: 800px;
|
||||
--width-xl: 1000px;
|
||||
--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);
|
||||
@@ -48,30 +48,30 @@
|
||||
--transition-normal: 0.15s ease;
|
||||
--transition-slow: 0.25s ease;
|
||||
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f8f8fa;
|
||||
--bg-tertiary: #f0f0f2;
|
||||
--bg-primary: #f9fafa;
|
||||
--bg-secondary: #f1f3f3;
|
||||
--bg-tertiary: #e8ebeb;
|
||||
--bg-card: #ffffff;
|
||||
--bg-input: #ffffff;
|
||||
--bg-input-disabled: #f8f8fa;
|
||||
--bg-input-disabled: #f1f3f3;
|
||||
|
||||
--text-primary: #1a1a1a;
|
||||
--text-secondary: #666666;
|
||||
--text-muted: #999999;
|
||||
--text-primary: #1a1d1d;
|
||||
--text-secondary: #5a605f;
|
||||
--text-muted: #8a8f8e;
|
||||
--text-inverse: #ffffff;
|
||||
|
||||
--border-color: #e5e5e5;
|
||||
--border-light: #f0f0f0;
|
||||
--border-dark: #cccccc;
|
||||
--border-color: #dce0df;
|
||||
--border-light: #e8ebeb;
|
||||
--border-dark: #c8cecc;
|
||||
|
||||
--accent: #2c00ff;
|
||||
--accent-hover: #1a00a3;
|
||||
--accent-muted: rgba(44, 0, 255, 0.08);
|
||||
--accent-light: #4d33ff;
|
||||
--accent: #1a1d1d;
|
||||
--accent-hover: #2e3332;
|
||||
--accent-muted: rgba(26, 29, 29, 0.06);
|
||||
--accent-light: #3a403f;
|
||||
|
||||
--secondary: #ff2400;
|
||||
--secondary-hover: #cc1d00;
|
||||
--secondary-muted: rgba(255, 36, 0, 0.08);
|
||||
--secondary: #1a1d1d;
|
||||
--secondary-hover: #2e3332;
|
||||
--secondary-muted: rgba(26, 29, 29, 0.06);
|
||||
|
||||
--success-bg: #dfd;
|
||||
--success-border: #8c8;
|
||||
@@ -90,41 +90,41 @@
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg-primary: #0a0a0a;
|
||||
--bg-secondary: #141414;
|
||||
--bg-tertiary: #1a1a1a;
|
||||
--bg-card: #141414;
|
||||
--bg-input: #1a1a1a;
|
||||
--bg-input-disabled: #141414;
|
||||
--bg-primary: #0a0c0c;
|
||||
--bg-secondary: #131616;
|
||||
--bg-tertiary: #1a1d1d;
|
||||
--bg-card: #131616;
|
||||
--bg-input: #1a1d1d;
|
||||
--bg-input-disabled: #131616;
|
||||
|
||||
--text-primary: #e8e8e8;
|
||||
--text-secondary: #a0a0a0;
|
||||
--text-muted: #666666;
|
||||
--text-inverse: #0a0a0a;
|
||||
--text-primary: #e6e8e8;
|
||||
--text-secondary: #9ca1a0;
|
||||
--text-muted: #686d6c;
|
||||
--text-inverse: #0a0c0c;
|
||||
|
||||
--border-color: #2a2a2a;
|
||||
--border-light: #222222;
|
||||
--border-dark: #333333;
|
||||
--border-color: #282c2b;
|
||||
--border-light: #1f2322;
|
||||
--border-dark: #343938;
|
||||
|
||||
--accent: #7b6bff;
|
||||
--accent-hover: #9588ff;
|
||||
--accent-muted: rgba(123, 107, 255, 0.2);
|
||||
--accent-light: #9588ff;
|
||||
--accent: #e6e8e8;
|
||||
--accent-hover: #ffffff;
|
||||
--accent-muted: rgba(230, 232, 232, 0.1);
|
||||
--accent-light: #ffffff;
|
||||
|
||||
--secondary: #ff6b5b;
|
||||
--secondary-hover: #ff8577;
|
||||
--secondary-muted: rgba(255, 107, 91, 0.2);
|
||||
--secondary: #e6e8e8;
|
||||
--secondary-hover: #ffffff;
|
||||
--secondary-muted: rgba(230, 232, 232, 0.1);
|
||||
|
||||
--success-bg: #1a3d1a;
|
||||
--success-border: #2d5a2d;
|
||||
--success-text: #7bc67b;
|
||||
--success-bg: #0f1f1a;
|
||||
--success-border: #1a3d2d;
|
||||
--success-text: #7bc6a0;
|
||||
|
||||
--error-bg: #3d1a1a;
|
||||
--error-border: #5a2d2d;
|
||||
--error-text: #ff7b7b;
|
||||
--error-bg: #1f0f0f;
|
||||
--error-border: #3d1a1a;
|
||||
--error-text: #ff8a8a;
|
||||
|
||||
--warning-bg: #3d3d1a;
|
||||
--warning-border: #5a5a2d;
|
||||
--warning-text: #c6c67b;
|
||||
--warning-bg: #1f1a0f;
|
||||
--warning-border: #3d351a;
|
||||
--warning-text: #c6b87b;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,352 +1,394 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'
|
||||
import AppPasswords from '../routes/AppPasswords.svelte'
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/svelte";
|
||||
import AppPasswords from "../routes/AppPasswords.svelte";
|
||||
import {
|
||||
setupFetchMock,
|
||||
mockEndpoint,
|
||||
jsonResponse,
|
||||
errorResponse,
|
||||
mockData,
|
||||
clearMocks,
|
||||
errorResponse,
|
||||
jsonResponse,
|
||||
mockData,
|
||||
mockEndpoint,
|
||||
setupAuthenticatedUser,
|
||||
setupFetchMock,
|
||||
setupUnauthenticatedUser,
|
||||
} from './mocks'
|
||||
describe('AppPasswords', () => {
|
||||
} from "./mocks";
|
||||
describe("AppPasswords", () => {
|
||||
beforeEach(() => {
|
||||
clearMocks()
|
||||
setupFetchMock()
|
||||
window.confirm = vi.fn(() => true)
|
||||
})
|
||||
describe('authentication guard', () => {
|
||||
it('redirects to login when not authenticated', async () => {
|
||||
setupUnauthenticatedUser()
|
||||
render(AppPasswords)
|
||||
clearMocks();
|
||||
setupFetchMock();
|
||||
window.confirm = vi.fn(() => true);
|
||||
});
|
||||
describe("authentication guard", () => {
|
||||
it("redirects to login when not authenticated", async () => {
|
||||
setupUnauthenticatedUser();
|
||||
render(AppPasswords);
|
||||
await waitFor(() => {
|
||||
expect(window.location.hash).toBe('#/login')
|
||||
})
|
||||
})
|
||||
})
|
||||
describe('page structure', () => {
|
||||
expect(window.location.hash).toBe("#/login");
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("page structure", () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
mockEndpoint('com.atproto.server.listAppPasswords', () =>
|
||||
jsonResponse({ passwords: [] })
|
||||
)
|
||||
})
|
||||
it('displays all page elements', async () => {
|
||||
render(AppPasswords)
|
||||
setupAuthenticatedUser();
|
||||
mockEndpoint(
|
||||
"com.atproto.server.listAppPasswords",
|
||||
() => jsonResponse({ passwords: [] }),
|
||||
);
|
||||
});
|
||||
it("displays all page elements", async () => {
|
||||
render(AppPasswords);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /app passwords/i, level: 1 })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /dashboard/i })).toHaveAttribute('href', '#/dashboard')
|
||||
expect(screen.getByText(/third-party apps/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
describe('loading state', () => {
|
||||
expect(
|
||||
screen.getByRole("heading", { name: /app passwords/i, level: 1 }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /dashboard/i }))
|
||||
.toHaveAttribute("href", "#/dashboard");
|
||||
expect(screen.getByText(/third-party apps/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("loading state", () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
})
|
||||
it('shows loading text while fetching passwords', async () => {
|
||||
mockEndpoint('com.atproto.server.listAppPasswords', async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
return jsonResponse({ passwords: [] })
|
||||
})
|
||||
render(AppPasswords)
|
||||
expect(screen.getByText(/loading/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
describe('empty state', () => {
|
||||
setupAuthenticatedUser();
|
||||
});
|
||||
it("shows loading text while fetching passwords", async () => {
|
||||
mockEndpoint("com.atproto.server.listAppPasswords", async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
return jsonResponse({ passwords: [] });
|
||||
});
|
||||
render(AppPasswords);
|
||||
expect(screen.getByText(/loading/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
describe("empty state", () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
mockEndpoint('com.atproto.server.listAppPasswords', () =>
|
||||
jsonResponse({ passwords: [] })
|
||||
)
|
||||
})
|
||||
it('shows empty message when no passwords exist', async () => {
|
||||
render(AppPasswords)
|
||||
setupAuthenticatedUser();
|
||||
mockEndpoint(
|
||||
"com.atproto.server.listAppPasswords",
|
||||
() => jsonResponse({ passwords: [] }),
|
||||
);
|
||||
});
|
||||
it("shows empty message when no passwords exist", async () => {
|
||||
render(AppPasswords);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/no app passwords yet/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
describe('password list', () => {
|
||||
expect(screen.getByText(/no app passwords yet/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("password list", () => {
|
||||
const testPasswords = [
|
||||
mockData.appPassword({ name: 'Graysky', createdAt: '2024-01-15T10:00:00Z' }),
|
||||
mockData.appPassword({ name: 'Skeets', createdAt: '2024-02-20T15:30:00Z' }),
|
||||
]
|
||||
mockData.appPassword({
|
||||
name: "Graysky",
|
||||
createdAt: "2024-01-15T10:00:00Z",
|
||||
}),
|
||||
mockData.appPassword({
|
||||
name: "Skeets",
|
||||
createdAt: "2024-02-20T15:30:00Z",
|
||||
}),
|
||||
];
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
mockEndpoint('com.atproto.server.listAppPasswords', () =>
|
||||
jsonResponse({ passwords: testPasswords })
|
||||
)
|
||||
})
|
||||
it('displays all app passwords with dates and revoke buttons', async () => {
|
||||
render(AppPasswords)
|
||||
setupAuthenticatedUser();
|
||||
mockEndpoint(
|
||||
"com.atproto.server.listAppPasswords",
|
||||
() => jsonResponse({ passwords: testPasswords }),
|
||||
);
|
||||
});
|
||||
it("displays all app passwords with dates and revoke buttons", async () => {
|
||||
render(AppPasswords);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Graysky')).toBeInTheDocument()
|
||||
expect(screen.getByText('Skeets')).toBeInTheDocument()
|
||||
expect(screen.getByText(/created.*1\/15\/2024/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/created.*2\/20\/2024/i)).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('button', { name: /revoke/i })).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
describe('create app password', () => {
|
||||
expect(screen.getByText("Graysky")).toBeInTheDocument();
|
||||
expect(screen.getByText("Skeets")).toBeInTheDocument();
|
||||
expect(screen.getByText(/created.*1\/15\/2024/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/created.*2\/20\/2024/i)).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("button", { name: /revoke/i })).toHaveLength(
|
||||
2,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("create app password", () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
mockEndpoint('com.atproto.server.listAppPasswords', () =>
|
||||
jsonResponse({ passwords: [] })
|
||||
)
|
||||
})
|
||||
it('displays create form with input and button', async () => {
|
||||
render(AppPasswords)
|
||||
setupAuthenticatedUser();
|
||||
mockEndpoint(
|
||||
"com.atproto.server.listAppPasswords",
|
||||
() => jsonResponse({ passwords: [] }),
|
||||
);
|
||||
});
|
||||
it("displays create form with input and button", async () => {
|
||||
render(AppPasswords);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /create/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
it('disables create button when input is empty', async () => {
|
||||
render(AppPasswords)
|
||||
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /create/i }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it("disables create button when input is empty", async () => {
|
||||
render(AppPasswords);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /create/i })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
it('enables create button when input has value', async () => {
|
||||
render(AppPasswords)
|
||||
expect(screen.getByRole("button", { name: /create/i })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
it("enables create button when input has value", async () => {
|
||||
render(AppPasswords);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'My New App' } })
|
||||
expect(screen.getByRole('button', { name: /create/i })).not.toBeDisabled()
|
||||
})
|
||||
it('calls createAppPassword with correct name', async () => {
|
||||
let capturedName: string | null = null
|
||||
mockEndpoint('com.atproto.server.createAppPassword', (_url, options) => {
|
||||
const body = JSON.parse((options?.body as string) || '{}')
|
||||
capturedName = body.name
|
||||
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.input(screen.getByPlaceholderText(/app name/i), {
|
||||
target: { value: "My New App" },
|
||||
});
|
||||
expect(screen.getByRole("button", { name: /create/i })).not
|
||||
.toBeDisabled();
|
||||
});
|
||||
it("calls createAppPassword with correct name", async () => {
|
||||
let capturedName: string | null = null;
|
||||
mockEndpoint("com.atproto.server.createAppPassword", (_url, options) => {
|
||||
const body = JSON.parse((options?.body as string) || "{}");
|
||||
capturedName = body.name;
|
||||
return jsonResponse({
|
||||
name: body.name,
|
||||
password: 'xxxx-xxxx-xxxx-xxxx',
|
||||
password: "xxxx-xxxx-xxxx-xxxx",
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
})
|
||||
render(AppPasswords)
|
||||
});
|
||||
});
|
||||
render(AppPasswords);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'Graysky' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /create/i }))
|
||||
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.input(screen.getByPlaceholderText(/app name/i), {
|
||||
target: { value: "Graysky" },
|
||||
});
|
||||
await fireEvent.click(screen.getByRole("button", { name: /create/i }));
|
||||
await waitFor(() => {
|
||||
expect(capturedName).toBe('Graysky')
|
||||
})
|
||||
})
|
||||
it('shows loading state while creating', async () => {
|
||||
mockEndpoint('com.atproto.server.createAppPassword', async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
expect(capturedName).toBe("Graysky");
|
||||
});
|
||||
});
|
||||
it("shows loading state while creating", async () => {
|
||||
mockEndpoint("com.atproto.server.createAppPassword", async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
return jsonResponse({
|
||||
name: 'Test',
|
||||
password: 'xxxx-xxxx-xxxx-xxxx',
|
||||
name: "Test",
|
||||
password: "xxxx-xxxx-xxxx-xxxx",
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
})
|
||||
render(AppPasswords)
|
||||
});
|
||||
});
|
||||
render(AppPasswords);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'Test' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /create/i }))
|
||||
expect(screen.getByRole('button', { name: /creating/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /creating/i })).toBeDisabled()
|
||||
})
|
||||
it('displays created password in success box and clears input', async () => {
|
||||
mockEndpoint('com.atproto.server.createAppPassword', () =>
|
||||
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.input(screen.getByPlaceholderText(/app name/i), {
|
||||
target: { value: "Test" },
|
||||
});
|
||||
await fireEvent.click(screen.getByRole("button", { name: /create/i }));
|
||||
expect(screen.getByRole("button", { name: /creating/i }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /creating/i })).toBeDisabled();
|
||||
});
|
||||
it("displays created password in success box and clears input", async () => {
|
||||
mockEndpoint("com.atproto.server.createAppPassword", () =>
|
||||
jsonResponse({
|
||||
name: 'MyApp',
|
||||
password: 'abcd-efgh-ijkl-mnop',
|
||||
name: "MyApp",
|
||||
password: "abcd-efgh-ijkl-mnop",
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
)
|
||||
render(AppPasswords)
|
||||
}));
|
||||
render(AppPasswords);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
|
||||
})
|
||||
const input = screen.getByPlaceholderText(/app name/i) as HTMLInputElement
|
||||
await fireEvent.input(input, { target: { value: 'MyApp' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /create/i }))
|
||||
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument();
|
||||
});
|
||||
const input = screen.getByPlaceholderText(
|
||||
/app name/i,
|
||||
) as HTMLInputElement;
|
||||
await fireEvent.input(input, { target: { value: "MyApp" } });
|
||||
await fireEvent.click(screen.getByRole("button", { name: /create/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/app password created/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('abcd-efgh-ijkl-mnop')).toBeInTheDocument()
|
||||
expect(screen.getByText(/name: myapp/i)).toBeInTheDocument()
|
||||
expect(input.value).toBe('')
|
||||
})
|
||||
})
|
||||
it('dismisses created password box when clicking Done', async () => {
|
||||
mockEndpoint('com.atproto.server.createAppPassword', () =>
|
||||
expect(screen.getByText(/app password created/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("abcd-efgh-ijkl-mnop")).toBeInTheDocument();
|
||||
expect(screen.getByText(/name: myapp/i)).toBeInTheDocument();
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
});
|
||||
it("dismisses created password box when clicking Done", async () => {
|
||||
mockEndpoint("com.atproto.server.createAppPassword", () =>
|
||||
jsonResponse({
|
||||
name: 'Test',
|
||||
password: 'xxxx-xxxx-xxxx-xxxx',
|
||||
name: "Test",
|
||||
password: "xxxx-xxxx-xxxx-xxxx",
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
)
|
||||
render(AppPasswords)
|
||||
}));
|
||||
render(AppPasswords);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'Test' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /create/i }))
|
||||
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.input(screen.getByPlaceholderText(/app name/i), {
|
||||
target: { value: "Test" },
|
||||
});
|
||||
await fireEvent.click(screen.getByRole("button", { name: /create/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/app password created/i)).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.click(screen.getByRole('button', { name: /done/i }))
|
||||
expect(screen.getByText(/app password created/i)).toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.click(screen.getByRole("button", { name: /done/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/app password created/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
it('shows error when creation fails', async () => {
|
||||
mockEndpoint('com.atproto.server.createAppPassword', () =>
|
||||
errorResponse('InvalidRequest', 'Name already exists', 400)
|
||||
)
|
||||
render(AppPasswords)
|
||||
expect(screen.queryByText(/app password created/i)).not
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it("shows error when creation fails", async () => {
|
||||
mockEndpoint(
|
||||
"com.atproto.server.createAppPassword",
|
||||
() => errorResponse("InvalidRequest", "Name already exists", 400),
|
||||
);
|
||||
render(AppPasswords);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'Duplicate' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /create/i }))
|
||||
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.input(screen.getByPlaceholderText(/app name/i), {
|
||||
target: { value: "Duplicate" },
|
||||
});
|
||||
await fireEvent.click(screen.getByRole("button", { name: /create/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/name already exists/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/name already exists/i)).toHaveClass('error')
|
||||
})
|
||||
})
|
||||
})
|
||||
describe('revoke app password', () => {
|
||||
const testPassword = mockData.appPassword({ name: 'TestApp' })
|
||||
expect(screen.getByText(/name already exists/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/name already exists/i)).toHaveClass("error");
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("revoke app password", () => {
|
||||
const testPassword = mockData.appPassword({ name: "TestApp" });
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
})
|
||||
it('shows confirmation dialog before revoking', async () => {
|
||||
const confirmSpy = vi.fn(() => false)
|
||||
window.confirm = confirmSpy
|
||||
mockEndpoint('com.atproto.server.listAppPasswords', () =>
|
||||
jsonResponse({ passwords: [testPassword] })
|
||||
)
|
||||
render(AppPasswords)
|
||||
setupAuthenticatedUser();
|
||||
});
|
||||
it("shows confirmation dialog before revoking", async () => {
|
||||
const confirmSpy = vi.fn(() => false);
|
||||
window.confirm = confirmSpy;
|
||||
mockEndpoint(
|
||||
"com.atproto.server.listAppPasswords",
|
||||
() => jsonResponse({ passwords: [testPassword] }),
|
||||
);
|
||||
render(AppPasswords);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('TestApp')).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
|
||||
expect(screen.getByText("TestApp")).toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.click(screen.getByRole("button", { name: /revoke/i }));
|
||||
expect(confirmSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('TestApp')
|
||||
)
|
||||
})
|
||||
it('does not revoke when confirmation is cancelled', async () => {
|
||||
window.confirm = vi.fn(() => false)
|
||||
let revokeCalled = false
|
||||
mockEndpoint('com.atproto.server.listAppPasswords', () =>
|
||||
jsonResponse({ passwords: [testPassword] })
|
||||
)
|
||||
mockEndpoint('com.atproto.server.revokeAppPassword', () => {
|
||||
revokeCalled = true
|
||||
return jsonResponse({})
|
||||
})
|
||||
render(AppPasswords)
|
||||
expect.stringContaining("TestApp"),
|
||||
);
|
||||
});
|
||||
it("does not revoke when confirmation is cancelled", async () => {
|
||||
window.confirm = vi.fn(() => false);
|
||||
let revokeCalled = false;
|
||||
mockEndpoint(
|
||||
"com.atproto.server.listAppPasswords",
|
||||
() => jsonResponse({ passwords: [testPassword] }),
|
||||
);
|
||||
mockEndpoint("com.atproto.server.revokeAppPassword", () => {
|
||||
revokeCalled = true;
|
||||
return jsonResponse({});
|
||||
});
|
||||
render(AppPasswords);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('TestApp')).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
|
||||
expect(revokeCalled).toBe(false)
|
||||
})
|
||||
it('calls revokeAppPassword with correct name', async () => {
|
||||
window.confirm = vi.fn(() => true)
|
||||
let capturedName: string | null = null
|
||||
mockEndpoint('com.atproto.server.listAppPasswords', () =>
|
||||
jsonResponse({ passwords: [testPassword] })
|
||||
)
|
||||
mockEndpoint('com.atproto.server.revokeAppPassword', (_url, options) => {
|
||||
const body = JSON.parse((options?.body as string) || '{}')
|
||||
capturedName = body.name
|
||||
return jsonResponse({})
|
||||
})
|
||||
render(AppPasswords)
|
||||
expect(screen.getByText("TestApp")).toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.click(screen.getByRole("button", { name: /revoke/i }));
|
||||
expect(revokeCalled).toBe(false);
|
||||
});
|
||||
it("calls revokeAppPassword with correct name", async () => {
|
||||
window.confirm = vi.fn(() => true);
|
||||
let capturedName: string | null = null;
|
||||
mockEndpoint(
|
||||
"com.atproto.server.listAppPasswords",
|
||||
() => jsonResponse({ passwords: [testPassword] }),
|
||||
);
|
||||
mockEndpoint("com.atproto.server.revokeAppPassword", (_url, options) => {
|
||||
const body = JSON.parse((options?.body as string) || "{}");
|
||||
capturedName = body.name;
|
||||
return jsonResponse({});
|
||||
});
|
||||
render(AppPasswords);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('TestApp')).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
|
||||
expect(screen.getByText("TestApp")).toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.click(screen.getByRole("button", { name: /revoke/i }));
|
||||
await waitFor(() => {
|
||||
expect(capturedName).toBe('TestApp')
|
||||
})
|
||||
})
|
||||
it('shows loading state while revoking', async () => {
|
||||
window.confirm = vi.fn(() => true)
|
||||
mockEndpoint('com.atproto.server.listAppPasswords', () =>
|
||||
jsonResponse({ passwords: [testPassword] })
|
||||
)
|
||||
mockEndpoint('com.atproto.server.revokeAppPassword', async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
return jsonResponse({})
|
||||
})
|
||||
render(AppPasswords)
|
||||
expect(capturedName).toBe("TestApp");
|
||||
});
|
||||
});
|
||||
it("shows loading state while revoking", async () => {
|
||||
window.confirm = vi.fn(() => true);
|
||||
mockEndpoint(
|
||||
"com.atproto.server.listAppPasswords",
|
||||
() => jsonResponse({ passwords: [testPassword] }),
|
||||
);
|
||||
mockEndpoint("com.atproto.server.revokeAppPassword", async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
return jsonResponse({});
|
||||
});
|
||||
render(AppPasswords);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('TestApp')).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
|
||||
expect(screen.getByRole('button', { name: /revoking/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /revoking/i })).toBeDisabled()
|
||||
})
|
||||
it('reloads password list after successful revocation', async () => {
|
||||
window.confirm = vi.fn(() => true)
|
||||
let listCallCount = 0
|
||||
mockEndpoint('com.atproto.server.listAppPasswords', () => {
|
||||
listCallCount++
|
||||
expect(screen.getByText("TestApp")).toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.click(screen.getByRole("button", { name: /revoke/i }));
|
||||
expect(screen.getByRole("button", { name: /revoking/i }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /revoking/i })).toBeDisabled();
|
||||
});
|
||||
it("reloads password list after successful revocation", async () => {
|
||||
window.confirm = vi.fn(() => true);
|
||||
let listCallCount = 0;
|
||||
mockEndpoint("com.atproto.server.listAppPasswords", () => {
|
||||
listCallCount++;
|
||||
if (listCallCount === 1) {
|
||||
return jsonResponse({ passwords: [testPassword] })
|
||||
return jsonResponse({ passwords: [testPassword] });
|
||||
}
|
||||
return jsonResponse({ passwords: [] })
|
||||
})
|
||||
mockEndpoint('com.atproto.server.revokeAppPassword', () =>
|
||||
jsonResponse({})
|
||||
)
|
||||
render(AppPasswords)
|
||||
return jsonResponse({ passwords: [] });
|
||||
});
|
||||
mockEndpoint(
|
||||
"com.atproto.server.revokeAppPassword",
|
||||
() => jsonResponse({}),
|
||||
);
|
||||
render(AppPasswords);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('TestApp')).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
|
||||
expect(screen.getByText("TestApp")).toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.click(screen.getByRole("button", { name: /revoke/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('TestApp')).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/no app passwords yet/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
it('shows error when revocation fails', async () => {
|
||||
window.confirm = vi.fn(() => true)
|
||||
mockEndpoint('com.atproto.server.listAppPasswords', () =>
|
||||
jsonResponse({ passwords: [testPassword] })
|
||||
)
|
||||
mockEndpoint('com.atproto.server.revokeAppPassword', () =>
|
||||
errorResponse('InternalError', 'Server error', 500)
|
||||
)
|
||||
render(AppPasswords)
|
||||
expect(screen.queryByText("TestApp")).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/no app passwords yet/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it("shows error when revocation fails", async () => {
|
||||
window.confirm = vi.fn(() => true);
|
||||
mockEndpoint(
|
||||
"com.atproto.server.listAppPasswords",
|
||||
() => jsonResponse({ passwords: [testPassword] }),
|
||||
);
|
||||
mockEndpoint(
|
||||
"com.atproto.server.revokeAppPassword",
|
||||
() => errorResponse("InternalError", "Server error", 500),
|
||||
);
|
||||
render(AppPasswords);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('TestApp')).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
|
||||
expect(screen.getByText("TestApp")).toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.click(screen.getByRole("button", { name: /revoke/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/server error/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/server error/i)).toHaveClass('error')
|
||||
})
|
||||
})
|
||||
})
|
||||
describe('error handling', () => {
|
||||
expect(screen.getByText(/server error/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/server error/i)).toHaveClass("error");
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("error handling", () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
})
|
||||
it('shows error when loading passwords fails', async () => {
|
||||
mockEndpoint('com.atproto.server.listAppPasswords', () =>
|
||||
errorResponse('InternalError', 'Database connection failed', 500)
|
||||
)
|
||||
render(AppPasswords)
|
||||
setupAuthenticatedUser();
|
||||
});
|
||||
it("shows error when loading passwords fails", async () => {
|
||||
mockEndpoint(
|
||||
"com.atproto.server.listAppPasswords",
|
||||
() => errorResponse("InternalError", "Database connection failed", 500),
|
||||
);
|
||||
render(AppPasswords);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/database connection failed/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/database connection failed/i)).toHaveClass('error')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
expect(screen.getByText(/database connection failed/i))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByText(/database connection failed/i)).toHaveClass(
|
||||
"error",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+396
-306
@@ -1,346 +1,436 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'
|
||||
import Comms from '../routes/Comms.svelte'
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/svelte";
|
||||
import Comms from "../routes/Comms.svelte";
|
||||
import {
|
||||
setupFetchMock,
|
||||
mockEndpoint,
|
||||
jsonResponse,
|
||||
errorResponse,
|
||||
mockData,
|
||||
clearMocks,
|
||||
errorResponse,
|
||||
jsonResponse,
|
||||
mockData,
|
||||
mockEndpoint,
|
||||
setupAuthenticatedUser,
|
||||
setupFetchMock,
|
||||
setupUnauthenticatedUser,
|
||||
} from './mocks'
|
||||
describe('Comms', () => {
|
||||
} from "./mocks";
|
||||
describe("Comms", () => {
|
||||
beforeEach(() => {
|
||||
clearMocks()
|
||||
setupFetchMock()
|
||||
})
|
||||
describe('authentication guard', () => {
|
||||
it('redirects to login when not authenticated', async () => {
|
||||
setupUnauthenticatedUser()
|
||||
render(Comms)
|
||||
clearMocks();
|
||||
setupFetchMock();
|
||||
});
|
||||
describe("authentication guard", () => {
|
||||
it("redirects to login when not authenticated", async () => {
|
||||
setupUnauthenticatedUser();
|
||||
render(Comms);
|
||||
await waitFor(() => {
|
||||
expect(window.location.hash).toBe('#/login')
|
||||
})
|
||||
})
|
||||
})
|
||||
describe('page structure', () => {
|
||||
expect(window.location.hash).toBe("#/login");
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("page structure", () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
|
||||
jsonResponse(mockData.notificationPrefs())
|
||||
)
|
||||
})
|
||||
it('displays all page elements and sections', async () => {
|
||||
render(Comms)
|
||||
setupAuthenticatedUser();
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
() => jsonResponse(mockData.notificationPrefs()),
|
||||
);
|
||||
});
|
||||
it("displays all page elements and sections", async () => {
|
||||
render(Comms);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /notification preferences/i, level: 1 })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /dashboard/i })).toHaveAttribute('href', '#/dashboard')
|
||||
expect(screen.getByText(/password resets/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: /preferred channel/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: /channel configuration/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
describe('loading state', () => {
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
name: /notification preferences/i,
|
||||
level: 1,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /dashboard/i }))
|
||||
.toHaveAttribute("href", "#/dashboard");
|
||||
expect(screen.getByText(/password resets/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: /preferred channel/i }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: /channel configuration/i }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("loading state", () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
})
|
||||
it('shows loading text while fetching preferences', async () => {
|
||||
mockEndpoint('com.tranquil.account.getNotificationPrefs', async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
return jsonResponse(mockData.notificationPrefs())
|
||||
})
|
||||
render(Comms)
|
||||
expect(screen.getByText(/loading/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
describe('channel options', () => {
|
||||
setupAuthenticatedUser();
|
||||
});
|
||||
it("shows loading text while fetching preferences", async () => {
|
||||
mockEndpoint("com.tranquil.account.getNotificationPrefs", async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
return jsonResponse(mockData.notificationPrefs());
|
||||
});
|
||||
render(Comms);
|
||||
expect(screen.getByText(/loading/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
describe("channel options", () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
})
|
||||
it('displays all four channel options', async () => {
|
||||
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
|
||||
jsonResponse(mockData.notificationPrefs())
|
||||
)
|
||||
render(Comms)
|
||||
setupAuthenticatedUser();
|
||||
});
|
||||
it("displays all four channel options", async () => {
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
() => jsonResponse(mockData.notificationPrefs()),
|
||||
);
|
||||
render(Comms);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('radio', { name: /email/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('radio', { name: /discord/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('radio', { name: /telegram/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('radio', { name: /signal/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
it('email channel is always selectable', async () => {
|
||||
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
|
||||
jsonResponse(mockData.notificationPrefs())
|
||||
)
|
||||
render(Comms)
|
||||
expect(screen.getByRole("radio", { name: /email/i }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("radio", { name: /discord/i }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("radio", { name: /telegram/i }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("radio", { name: /signal/i }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it("email channel is always selectable", async () => {
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
() => jsonResponse(mockData.notificationPrefs()),
|
||||
);
|
||||
render(Comms);
|
||||
await waitFor(() => {
|
||||
const emailRadio = screen.getByRole('radio', { name: /email/i })
|
||||
expect(emailRadio).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
it('discord channel is disabled when not configured', async () => {
|
||||
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
|
||||
jsonResponse(mockData.notificationPrefs({ discordId: null }))
|
||||
)
|
||||
render(Comms)
|
||||
const emailRadio = screen.getByRole("radio", { name: /email/i });
|
||||
expect(emailRadio).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
it("discord channel is disabled when not configured", async () => {
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
() => jsonResponse(mockData.notificationPrefs({ discordId: null })),
|
||||
);
|
||||
render(Comms);
|
||||
await waitFor(() => {
|
||||
const discordRadio = screen.getByRole('radio', { name: /discord/i })
|
||||
expect(discordRadio).toBeDisabled()
|
||||
})
|
||||
})
|
||||
it('discord channel is enabled when configured', async () => {
|
||||
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
|
||||
jsonResponse(mockData.notificationPrefs({ discordId: '123456789' }))
|
||||
)
|
||||
render(Comms)
|
||||
const discordRadio = screen.getByRole("radio", { name: /discord/i });
|
||||
expect(discordRadio).toBeDisabled();
|
||||
});
|
||||
});
|
||||
it("discord channel is enabled when configured", async () => {
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
() =>
|
||||
jsonResponse(mockData.notificationPrefs({ discordId: "123456789" })),
|
||||
);
|
||||
render(Comms);
|
||||
await waitFor(() => {
|
||||
const discordRadio = screen.getByRole('radio', { name: /discord/i })
|
||||
expect(discordRadio).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
it('shows hint for disabled channels', async () => {
|
||||
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
|
||||
jsonResponse(mockData.notificationPrefs())
|
||||
)
|
||||
render(Comms)
|
||||
const discordRadio = screen.getByRole("radio", { name: /discord/i });
|
||||
expect(discordRadio).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
it("shows hint for disabled channels", async () => {
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
() => jsonResponse(mockData.notificationPrefs()),
|
||||
);
|
||||
render(Comms);
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText(/configure below to enable/i).length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
it('selects current preferred channel', async () => {
|
||||
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
|
||||
jsonResponse(mockData.notificationPrefs({ preferredChannel: 'email' }))
|
||||
)
|
||||
render(Comms)
|
||||
expect(screen.getAllByText(/configure below to enable/i).length)
|
||||
.toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
it("selects current preferred channel", async () => {
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
() =>
|
||||
jsonResponse(
|
||||
mockData.notificationPrefs({ preferredChannel: "email" }),
|
||||
),
|
||||
);
|
||||
render(Comms);
|
||||
await waitFor(() => {
|
||||
const emailRadio = screen.getByRole('radio', { name: /email/i }) as HTMLInputElement
|
||||
expect(emailRadio.checked).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
describe('channel configuration', () => {
|
||||
const emailRadio = screen.getByRole("radio", {
|
||||
name: /email/i,
|
||||
}) as HTMLInputElement;
|
||||
expect(emailRadio.checked).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("channel configuration", () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
})
|
||||
it('displays email as readonly with current value', async () => {
|
||||
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
|
||||
jsonResponse(mockData.notificationPrefs())
|
||||
)
|
||||
render(Comms)
|
||||
setupAuthenticatedUser();
|
||||
});
|
||||
it("displays email as readonly with current value", async () => {
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
() => jsonResponse(mockData.notificationPrefs()),
|
||||
);
|
||||
render(Comms);
|
||||
await waitFor(() => {
|
||||
const emailInput = screen.getByLabelText(/^email$/i) as HTMLInputElement
|
||||
expect(emailInput).toBeDisabled()
|
||||
expect(emailInput.value).toBe('test@example.com')
|
||||
})
|
||||
})
|
||||
it('displays all channel inputs with current values', async () => {
|
||||
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
|
||||
jsonResponse(mockData.notificationPrefs({
|
||||
discordId: '123456789',
|
||||
telegramUsername: 'testuser',
|
||||
signalNumber: '+1234567890',
|
||||
}))
|
||||
)
|
||||
render(Comms)
|
||||
const emailInput = screen.getByLabelText(
|
||||
/^email$/i,
|
||||
) as HTMLInputElement;
|
||||
expect(emailInput).toBeDisabled();
|
||||
expect(emailInput.value).toBe("test@example.com");
|
||||
});
|
||||
});
|
||||
it("displays all channel inputs with current values", async () => {
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
() =>
|
||||
jsonResponse(mockData.notificationPrefs({
|
||||
discordId: "123456789",
|
||||
telegramUsername: "testuser",
|
||||
signalNumber: "+1234567890",
|
||||
})),
|
||||
);
|
||||
render(Comms);
|
||||
await waitFor(() => {
|
||||
expect((screen.getByLabelText(/discord user id/i) as HTMLInputElement).value).toBe('123456789')
|
||||
expect((screen.getByLabelText(/telegram username/i) as HTMLInputElement).value).toBe('testuser')
|
||||
expect((screen.getByLabelText(/signal phone number/i) as HTMLInputElement).value).toBe('+1234567890')
|
||||
})
|
||||
})
|
||||
})
|
||||
describe('verification status badges', () => {
|
||||
expect(
|
||||
(screen.getByLabelText(/discord user id/i) as HTMLInputElement).value,
|
||||
).toBe("123456789");
|
||||
expect(
|
||||
(screen.getByLabelText(/telegram username/i) as HTMLInputElement)
|
||||
.value,
|
||||
).toBe("testuser");
|
||||
expect(
|
||||
(screen.getByLabelText(/signal phone number/i) as HTMLInputElement)
|
||||
.value,
|
||||
).toBe("+1234567890");
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("verification status badges", () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
})
|
||||
it('shows Primary badge for email', async () => {
|
||||
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
|
||||
jsonResponse(mockData.notificationPrefs())
|
||||
)
|
||||
render(Comms)
|
||||
setupAuthenticatedUser();
|
||||
});
|
||||
it("shows Primary badge for email", async () => {
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
() => jsonResponse(mockData.notificationPrefs()),
|
||||
);
|
||||
render(Comms);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Primary')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
it('shows Verified badge for verified discord', async () => {
|
||||
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
|
||||
jsonResponse(mockData.notificationPrefs({
|
||||
discordId: '123456789',
|
||||
discordVerified: true,
|
||||
}))
|
||||
)
|
||||
render(Comms)
|
||||
expect(screen.getByText("Primary")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it("shows Verified badge for verified discord", async () => {
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
() =>
|
||||
jsonResponse(mockData.notificationPrefs({
|
||||
discordId: "123456789",
|
||||
discordVerified: true,
|
||||
})),
|
||||
);
|
||||
render(Comms);
|
||||
await waitFor(() => {
|
||||
const verifiedBadges = screen.getAllByText('Verified')
|
||||
expect(verifiedBadges.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
it('shows Not verified badge for unverified discord', async () => {
|
||||
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
|
||||
jsonResponse(mockData.notificationPrefs({
|
||||
discordId: '123456789',
|
||||
discordVerified: false,
|
||||
}))
|
||||
)
|
||||
render(Comms)
|
||||
const verifiedBadges = screen.getAllByText("Verified");
|
||||
expect(verifiedBadges.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
it("shows Not verified badge for unverified discord", async () => {
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
() =>
|
||||
jsonResponse(mockData.notificationPrefs({
|
||||
discordId: "123456789",
|
||||
discordVerified: false,
|
||||
})),
|
||||
);
|
||||
render(Comms);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Not verified')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
it('does not show badge when channel not configured', async () => {
|
||||
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
|
||||
jsonResponse(mockData.notificationPrefs())
|
||||
)
|
||||
render(Comms)
|
||||
expect(screen.getByText("Not verified")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it("does not show badge when channel not configured", async () => {
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
() => jsonResponse(mockData.notificationPrefs()),
|
||||
);
|
||||
render(Comms);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Primary')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Not verified')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
describe('save preferences', () => {
|
||||
expect(screen.getByText("Primary")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Not verified")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("save preferences", () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
})
|
||||
it('calls updateNotificationPrefs with correct data', async () => {
|
||||
let capturedBody: Record<string, unknown> | null = null
|
||||
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
|
||||
jsonResponse(mockData.notificationPrefs())
|
||||
)
|
||||
mockEndpoint('com.tranquil.account.updateNotificationPrefs', (_url, options) => {
|
||||
capturedBody = JSON.parse((options?.body as string) || '{}')
|
||||
return jsonResponse({ success: true })
|
||||
})
|
||||
render(Comms)
|
||||
setupAuthenticatedUser();
|
||||
});
|
||||
it("calls updateNotificationPrefs with correct data", async () => {
|
||||
let capturedBody: Record<string, unknown> | null = null;
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
() => jsonResponse(mockData.notificationPrefs()),
|
||||
);
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.updateNotificationPrefs",
|
||||
(_url, options) => {
|
||||
capturedBody = JSON.parse((options?.body as string) || "{}");
|
||||
return jsonResponse({ success: true });
|
||||
},
|
||||
);
|
||||
render(Comms);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/discord user id/i)).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.input(screen.getByLabelText(/discord user id/i), { target: { value: '999888777' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /save preferences/i }))
|
||||
expect(screen.getByLabelText(/discord user id/i)).toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/discord user id/i), {
|
||||
target: { value: "999888777" },
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: /save preferences/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(capturedBody).not.toBeNull()
|
||||
expect(capturedBody?.discordId).toBe('999888777')
|
||||
expect(capturedBody?.preferredChannel).toBe('email')
|
||||
})
|
||||
})
|
||||
it('shows loading state while saving', async () => {
|
||||
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
|
||||
jsonResponse(mockData.notificationPrefs())
|
||||
)
|
||||
mockEndpoint('com.tranquil.account.updateNotificationPrefs', async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
return jsonResponse({ success: true })
|
||||
})
|
||||
render(Comms)
|
||||
expect(capturedBody).not.toBeNull();
|
||||
expect(capturedBody?.discordId).toBe("999888777");
|
||||
expect(capturedBody?.preferredChannel).toBe("email");
|
||||
});
|
||||
});
|
||||
it("shows loading state while saving", async () => {
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
() => jsonResponse(mockData.notificationPrefs()),
|
||||
);
|
||||
mockEndpoint("com.tranquil.account.updateNotificationPrefs", async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
return jsonResponse({ success: true });
|
||||
});
|
||||
render(Comms);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.click(screen.getByRole('button', { name: /save preferences/i }))
|
||||
expect(screen.getByRole('button', { name: /saving/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /saving/i })).toBeDisabled()
|
||||
})
|
||||
it('shows success message after saving', async () => {
|
||||
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
|
||||
jsonResponse(mockData.notificationPrefs())
|
||||
)
|
||||
mockEndpoint('com.tranquil.account.updateNotificationPrefs', () =>
|
||||
jsonResponse({ success: true })
|
||||
)
|
||||
render(Comms)
|
||||
expect(screen.getByRole("button", { name: /save preferences/i }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: /save preferences/i }),
|
||||
);
|
||||
expect(screen.getByRole("button", { name: /saving/i }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /saving/i })).toBeDisabled();
|
||||
});
|
||||
it("shows success message after saving", async () => {
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
() => jsonResponse(mockData.notificationPrefs()),
|
||||
);
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.updateNotificationPrefs",
|
||||
() => jsonResponse({ success: true }),
|
||||
);
|
||||
render(Comms);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.click(screen.getByRole('button', { name: /save preferences/i }))
|
||||
expect(screen.getByRole("button", { name: /save preferences/i }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: /save preferences/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/notification preferences saved/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
it('shows error when save fails', async () => {
|
||||
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
|
||||
jsonResponse(mockData.notificationPrefs())
|
||||
)
|
||||
mockEndpoint('com.tranquil.account.updateNotificationPrefs', () =>
|
||||
errorResponse('InvalidRequest', 'Invalid channel configuration', 400)
|
||||
)
|
||||
render(Comms)
|
||||
expect(screen.getByText(/notification preferences saved/i))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it("shows error when save fails", async () => {
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
() => jsonResponse(mockData.notificationPrefs()),
|
||||
);
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.updateNotificationPrefs",
|
||||
() =>
|
||||
errorResponse("InvalidRequest", "Invalid channel configuration", 400),
|
||||
);
|
||||
render(Comms);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.click(screen.getByRole('button', { name: /save preferences/i }))
|
||||
expect(screen.getByRole("button", { name: /save preferences/i }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: /save preferences/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/invalid channel configuration/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/invalid channel configuration/i).closest('.message')).toHaveClass('error')
|
||||
})
|
||||
})
|
||||
it('reloads preferences after successful save', async () => {
|
||||
let loadCount = 0
|
||||
mockEndpoint('com.tranquil.account.getNotificationPrefs', () => {
|
||||
loadCount++
|
||||
return jsonResponse(mockData.notificationPrefs())
|
||||
})
|
||||
mockEndpoint('com.tranquil.account.updateNotificationPrefs', () =>
|
||||
jsonResponse({ success: true })
|
||||
)
|
||||
render(Comms)
|
||||
expect(screen.getByText(/invalid channel configuration/i))
|
||||
.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/invalid channel configuration/i).closest(
|
||||
".message",
|
||||
),
|
||||
).toHaveClass("error");
|
||||
});
|
||||
});
|
||||
it("reloads preferences after successful save", async () => {
|
||||
let loadCount = 0;
|
||||
mockEndpoint("com.tranquil.account.getNotificationPrefs", () => {
|
||||
loadCount++;
|
||||
return jsonResponse(mockData.notificationPrefs());
|
||||
});
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.updateNotificationPrefs",
|
||||
() => jsonResponse({ success: true }),
|
||||
);
|
||||
render(Comms);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument()
|
||||
})
|
||||
const initialLoadCount = loadCount
|
||||
await fireEvent.click(screen.getByRole('button', { name: /save preferences/i }))
|
||||
expect(screen.getByRole("button", { name: /save preferences/i }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
const initialLoadCount = loadCount;
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: /save preferences/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(loadCount).toBeGreaterThan(initialLoadCount)
|
||||
})
|
||||
})
|
||||
})
|
||||
describe('channel selection interaction', () => {
|
||||
expect(loadCount).toBeGreaterThan(initialLoadCount);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("channel selection interaction", () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
})
|
||||
it('enables discord channel after entering discord ID', async () => {
|
||||
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
|
||||
jsonResponse(mockData.notificationPrefs())
|
||||
)
|
||||
render(Comms)
|
||||
setupAuthenticatedUser();
|
||||
});
|
||||
it("enables discord channel after entering discord ID", async () => {
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
() => jsonResponse(mockData.notificationPrefs()),
|
||||
);
|
||||
render(Comms);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('radio', { name: /discord/i })).toBeDisabled()
|
||||
})
|
||||
await fireEvent.input(screen.getByLabelText(/discord user id/i), { target: { value: '123456789' } })
|
||||
expect(screen.getByRole("radio", { name: /discord/i })).toBeDisabled();
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/discord user id/i), {
|
||||
target: { value: "123456789" },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('radio', { name: /discord/i })).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
it('allows selecting a configured channel', async () => {
|
||||
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
|
||||
jsonResponse(mockData.notificationPrefs({
|
||||
discordId: '123456789',
|
||||
discordVerified: true,
|
||||
}))
|
||||
)
|
||||
render(Comms)
|
||||
expect(screen.getByRole("radio", { name: /discord/i })).not
|
||||
.toBeDisabled();
|
||||
});
|
||||
});
|
||||
it("allows selecting a configured channel", async () => {
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
() =>
|
||||
jsonResponse(mockData.notificationPrefs({
|
||||
discordId: "123456789",
|
||||
discordVerified: true,
|
||||
})),
|
||||
);
|
||||
render(Comms);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('radio', { name: /discord/i })).not.toBeDisabled()
|
||||
})
|
||||
await fireEvent.click(screen.getByRole('radio', { name: /discord/i }))
|
||||
const discordRadio = screen.getByRole('radio', { name: /discord/i }) as HTMLInputElement
|
||||
expect(discordRadio.checked).toBe(true)
|
||||
})
|
||||
})
|
||||
describe('error handling', () => {
|
||||
expect(screen.getByRole("radio", { name: /discord/i })).not
|
||||
.toBeDisabled();
|
||||
});
|
||||
await fireEvent.click(screen.getByRole("radio", { name: /discord/i }));
|
||||
const discordRadio = screen.getByRole("radio", {
|
||||
name: /discord/i,
|
||||
}) as HTMLInputElement;
|
||||
expect(discordRadio.checked).toBe(true);
|
||||
});
|
||||
});
|
||||
describe("error handling", () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
})
|
||||
it('shows error when loading preferences fails', async () => {
|
||||
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
|
||||
errorResponse('InternalError', 'Database connection failed', 500)
|
||||
)
|
||||
render(Comms)
|
||||
setupAuthenticatedUser();
|
||||
});
|
||||
it("shows error when loading preferences fails", async () => {
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
() => errorResponse("InternalError", "Database connection failed", 500),
|
||||
);
|
||||
render(Comms);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/database connection failed/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
expect(screen.getByText(/database connection failed/i))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,111 +1,115 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'
|
||||
import Dashboard from '../routes/Dashboard.svelte'
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/svelte";
|
||||
import Dashboard from "../routes/Dashboard.svelte";
|
||||
import {
|
||||
setupFetchMock,
|
||||
mockEndpoint,
|
||||
clearMocks,
|
||||
jsonResponse,
|
||||
mockData,
|
||||
clearMocks,
|
||||
mockEndpoint,
|
||||
setupAuthenticatedUser,
|
||||
setupFetchMock,
|
||||
setupUnauthenticatedUser,
|
||||
} from './mocks'
|
||||
const STORAGE_KEY = 'tranquil_pds_session'
|
||||
describe('Dashboard', () => {
|
||||
} from "./mocks";
|
||||
const STORAGE_KEY = "tranquil_pds_session";
|
||||
describe("Dashboard", () => {
|
||||
beforeEach(() => {
|
||||
clearMocks()
|
||||
setupFetchMock()
|
||||
})
|
||||
describe('authentication guard', () => {
|
||||
it('redirects to login when not authenticated', async () => {
|
||||
setupUnauthenticatedUser()
|
||||
render(Dashboard)
|
||||
clearMocks();
|
||||
setupFetchMock();
|
||||
});
|
||||
describe("authentication guard", () => {
|
||||
it("redirects to login when not authenticated", async () => {
|
||||
setupUnauthenticatedUser();
|
||||
render(Dashboard);
|
||||
await waitFor(() => {
|
||||
expect(window.location.hash).toBe('#/login')
|
||||
})
|
||||
})
|
||||
it('shows loading state while checking auth', () => {
|
||||
render(Dashboard)
|
||||
expect(screen.getByText(/loading/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
describe('authenticated view', () => {
|
||||
expect(window.location.hash).toBe("#/login");
|
||||
});
|
||||
});
|
||||
it("shows loading state while checking auth", () => {
|
||||
render(Dashboard);
|
||||
expect(screen.getByText(/loading/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
describe("authenticated view", () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
})
|
||||
it('displays user account info and page structure', async () => {
|
||||
render(Dashboard)
|
||||
setupAuthenticatedUser();
|
||||
});
|
||||
it("displays user account info and page structure", async () => {
|
||||
render(Dashboard);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /dashboard/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: /account overview/i })).toBeInTheDocument()
|
||||
expect(screen.getByText(/@testuser\.test\.tranquil\.dev/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/did:web:test\.tranquil\.dev:u:testuser/)).toBeInTheDocument()
|
||||
expect(screen.getByText('test@example.com')).toBeInTheDocument()
|
||||
expect(screen.getByText('Verified')).toBeInTheDocument()
|
||||
expect(screen.getByText('Verified')).toHaveClass('badge', 'success')
|
||||
})
|
||||
})
|
||||
it('displays unverified badge when email not confirmed', async () => {
|
||||
setupAuthenticatedUser({ emailConfirmed: false })
|
||||
render(Dashboard)
|
||||
expect(screen.getByRole("heading", { name: /dashboard/i }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: /account overview/i }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByText(/@testuser\.test\.tranquil\.dev/))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByText(/did:web:test\.tranquil\.dev:u:testuser/))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByText("test@example.com")).toBeInTheDocument();
|
||||
expect(screen.getByText("Verified")).toBeInTheDocument();
|
||||
expect(screen.getByText("Verified")).toHaveClass("badge", "success");
|
||||
});
|
||||
});
|
||||
it("displays unverified badge when email not confirmed", async () => {
|
||||
setupAuthenticatedUser({ emailConfirmed: false });
|
||||
render(Dashboard);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Unverified')).toBeInTheDocument()
|
||||
expect(screen.getByText('Unverified')).toHaveClass('badge', 'warning')
|
||||
})
|
||||
})
|
||||
it('displays all navigation cards', async () => {
|
||||
render(Dashboard)
|
||||
expect(screen.getByText("Unverified")).toBeInTheDocument();
|
||||
expect(screen.getByText("Unverified")).toHaveClass("badge", "warning");
|
||||
});
|
||||
});
|
||||
it("displays all navigation cards", async () => {
|
||||
render(Dashboard);
|
||||
await waitFor(() => {
|
||||
const navCards = [
|
||||
{ name: /app passwords/i, href: '#/app-passwords' },
|
||||
{ name: /invite codes/i, href: '#/invite-codes' },
|
||||
{ name: /account settings/i, href: '#/settings' },
|
||||
{ name: /communication preferences/i, href: '#/comms' },
|
||||
{ name: /repository explorer/i, href: '#/repo' },
|
||||
]
|
||||
{ name: /app passwords/i, href: "#/app-passwords" },
|
||||
{ name: /invite codes/i, href: "#/invite-codes" },
|
||||
{ name: /account settings/i, href: "#/settings" },
|
||||
{ name: /communication preferences/i, href: "#/comms" },
|
||||
{ name: /repository explorer/i, href: "#/repo" },
|
||||
];
|
||||
for (const { name, href } of navCards) {
|
||||
const card = screen.getByRole('link', { name })
|
||||
expect(card).toBeInTheDocument()
|
||||
expect(card).toHaveAttribute('href', href)
|
||||
const card = screen.getByRole("link", { name });
|
||||
expect(card).toBeInTheDocument();
|
||||
expect(card).toHaveAttribute("href", href);
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
describe('logout functionality', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("logout functionality", () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(mockData.session()))
|
||||
mockEndpoint('com.atproto.server.deleteSession', () =>
|
||||
jsonResponse({})
|
||||
)
|
||||
})
|
||||
it('calls deleteSession and navigates to login on logout', async () => {
|
||||
let deleteSessionCalled = false
|
||||
mockEndpoint('com.atproto.server.deleteSession', () => {
|
||||
deleteSessionCalled = true
|
||||
return jsonResponse({})
|
||||
})
|
||||
render(Dashboard)
|
||||
setupAuthenticatedUser();
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(mockData.session()));
|
||||
mockEndpoint("com.atproto.server.deleteSession", () => jsonResponse({}));
|
||||
});
|
||||
it("calls deleteSession and navigates to login on logout", async () => {
|
||||
let deleteSessionCalled = false;
|
||||
mockEndpoint("com.atproto.server.deleteSession", () => {
|
||||
deleteSessionCalled = true;
|
||||
return jsonResponse({});
|
||||
});
|
||||
render(Dashboard);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /sign out/i })).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.click(screen.getByRole('button', { name: /sign out/i }))
|
||||
expect(screen.getByRole("button", { name: /sign out/i }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.click(screen.getByRole("button", { name: /sign out/i }));
|
||||
await waitFor(() => {
|
||||
expect(deleteSessionCalled).toBe(true)
|
||||
expect(window.location.hash).toBe('#/login')
|
||||
})
|
||||
})
|
||||
it('clears session from localStorage after logout', async () => {
|
||||
const storedSession = localStorage.getItem(STORAGE_KEY)
|
||||
expect(storedSession).not.toBeNull()
|
||||
render(Dashboard)
|
||||
expect(deleteSessionCalled).toBe(true);
|
||||
expect(window.location.hash).toBe("#/login");
|
||||
});
|
||||
});
|
||||
it("clears session from localStorage after logout", async () => {
|
||||
const storedSession = localStorage.getItem(STORAGE_KEY);
|
||||
expect(storedSession).not.toBeNull();
|
||||
render(Dashboard);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /sign out/i })).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.click(screen.getByRole('button', { name: /sign out/i }))
|
||||
expect(screen.getByRole("button", { name: /sign out/i }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.click(screen.getByRole("button", { name: /sign out/i }));
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+160
-118
@@ -1,136 +1,178 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'
|
||||
import Login from '../routes/Login.svelte'
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/svelte";
|
||||
import Login from "../routes/Login.svelte";
|
||||
import {
|
||||
setupFetchMock,
|
||||
mockEndpoint,
|
||||
jsonResponse,
|
||||
errorResponse,
|
||||
mockData,
|
||||
clearMocks,
|
||||
} from './mocks'
|
||||
describe('Login', () => {
|
||||
errorResponse,
|
||||
jsonResponse,
|
||||
mockData,
|
||||
mockEndpoint,
|
||||
setupFetchMock,
|
||||
} from "./mocks";
|
||||
describe("Login", () => {
|
||||
beforeEach(() => {
|
||||
clearMocks()
|
||||
setupFetchMock()
|
||||
window.location.hash = ''
|
||||
})
|
||||
describe('initial render', () => {
|
||||
it('renders login form with all elements and correct initial state', () => {
|
||||
render(Login)
|
||||
expect(screen.getByRole('heading', { name: /sign in/i })).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/handle or email/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/password/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /sign in/i })).toBeDisabled()
|
||||
expect(screen.getByText(/don't have an account/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /create one/i })).toHaveAttribute('href', '#/register')
|
||||
})
|
||||
})
|
||||
describe('form validation', () => {
|
||||
it('enables submit button only when both fields are filled', async () => {
|
||||
render(Login)
|
||||
const identifierInput = screen.getByLabelText(/handle or email/i)
|
||||
const passwordInput = screen.getByLabelText(/password/i)
|
||||
const submitButton = screen.getByRole('button', { name: /sign in/i })
|
||||
await fireEvent.input(identifierInput, { target: { value: 'testuser' } })
|
||||
expect(submitButton).toBeDisabled()
|
||||
await fireEvent.input(identifierInput, { target: { value: '' } })
|
||||
await fireEvent.input(passwordInput, { target: { value: 'password123' } })
|
||||
expect(submitButton).toBeDisabled()
|
||||
await fireEvent.input(identifierInput, { target: { value: 'testuser' } })
|
||||
expect(submitButton).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
describe('login submission', () => {
|
||||
it('calls createSession with correct credentials', async () => {
|
||||
let capturedBody: Record<string, string> | null = null
|
||||
mockEndpoint('com.atproto.server.createSession', (_url, options) => {
|
||||
capturedBody = JSON.parse((options?.body as string) || '{}')
|
||||
return jsonResponse(mockData.session())
|
||||
})
|
||||
render(Login)
|
||||
await fireEvent.input(screen.getByLabelText(/handle or email/i), { target: { value: 'testuser@example.com' } })
|
||||
await fireEvent.input(screen.getByLabelText(/password/i), { target: { value: 'mypassword' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /sign in/i }))
|
||||
clearMocks();
|
||||
setupFetchMock();
|
||||
window.location.hash = "";
|
||||
});
|
||||
describe("initial render", () => {
|
||||
it("renders login form with all elements and correct initial state", () => {
|
||||
render(Login);
|
||||
expect(screen.getByRole("heading", { name: /sign in/i }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/handle or email/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /sign in/i }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /sign in/i })).toBeDisabled();
|
||||
expect(screen.getByText(/don't have an account/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /create one/i })).toHaveAttribute(
|
||||
"href",
|
||||
"#/register",
|
||||
);
|
||||
});
|
||||
});
|
||||
describe("form validation", () => {
|
||||
it("enables submit button only when both fields are filled", async () => {
|
||||
render(Login);
|
||||
const identifierInput = screen.getByLabelText(/handle or email/i);
|
||||
const passwordInput = screen.getByLabelText(/password/i);
|
||||
const submitButton = screen.getByRole("button", { name: /sign in/i });
|
||||
await fireEvent.input(identifierInput, { target: { value: "testuser" } });
|
||||
expect(submitButton).toBeDisabled();
|
||||
await fireEvent.input(identifierInput, { target: { value: "" } });
|
||||
await fireEvent.input(passwordInput, {
|
||||
target: { value: "password123" },
|
||||
});
|
||||
expect(submitButton).toBeDisabled();
|
||||
await fireEvent.input(identifierInput, { target: { value: "testuser" } });
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
describe("login submission", () => {
|
||||
it("calls createSession with correct credentials", async () => {
|
||||
let capturedBody: Record<string, string> | null = null;
|
||||
mockEndpoint("com.atproto.server.createSession", (_url, options) => {
|
||||
capturedBody = JSON.parse((options?.body as string) || "{}");
|
||||
return jsonResponse(mockData.session());
|
||||
});
|
||||
render(Login);
|
||||
await fireEvent.input(screen.getByLabelText(/handle or email/i), {
|
||||
target: { value: "testuser@example.com" },
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/password/i), {
|
||||
target: { value: "mypassword" },
|
||||
});
|
||||
await fireEvent.click(screen.getByRole("button", { name: /sign in/i }));
|
||||
await waitFor(() => {
|
||||
expect(capturedBody).toEqual({
|
||||
identifier: 'testuser@example.com',
|
||||
password: 'mypassword',
|
||||
})
|
||||
})
|
||||
})
|
||||
it('shows styled error message on invalid credentials', async () => {
|
||||
mockEndpoint('com.atproto.server.createSession', () =>
|
||||
errorResponse('AuthenticationRequired', 'Invalid identifier or password', 401)
|
||||
)
|
||||
render(Login)
|
||||
await fireEvent.input(screen.getByLabelText(/handle or email/i), { target: { value: 'wronguser' } })
|
||||
await fireEvent.input(screen.getByLabelText(/password/i), { target: { value: 'wrongpassword' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /sign in/i }))
|
||||
identifier: "testuser@example.com",
|
||||
password: "mypassword",
|
||||
});
|
||||
});
|
||||
});
|
||||
it("shows styled error message on invalid credentials", async () => {
|
||||
mockEndpoint(
|
||||
"com.atproto.server.createSession",
|
||||
() =>
|
||||
errorResponse(
|
||||
"AuthenticationRequired",
|
||||
"Invalid identifier or password",
|
||||
401,
|
||||
),
|
||||
);
|
||||
render(Login);
|
||||
await fireEvent.input(screen.getByLabelText(/handle or email/i), {
|
||||
target: { value: "wronguser" },
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/password/i), {
|
||||
target: { value: "wrongpassword" },
|
||||
});
|
||||
await fireEvent.click(screen.getByRole("button", { name: /sign in/i }));
|
||||
await waitFor(() => {
|
||||
const errorDiv = screen.getByText(/invalid identifier or password/i)
|
||||
expect(errorDiv).toBeInTheDocument()
|
||||
expect(errorDiv).toHaveClass('error')
|
||||
})
|
||||
})
|
||||
it('navigates to dashboard on successful login', async () => {
|
||||
mockEndpoint('com.atproto.server.createSession', () =>
|
||||
jsonResponse(mockData.session())
|
||||
)
|
||||
render(Login)
|
||||
await fireEvent.input(screen.getByLabelText(/handle or email/i), { target: { value: 'test' } })
|
||||
await fireEvent.input(screen.getByLabelText(/password/i), { target: { value: 'password' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /sign in/i }))
|
||||
const errorDiv = screen.getByText(/invalid identifier or password/i);
|
||||
expect(errorDiv).toBeInTheDocument();
|
||||
expect(errorDiv).toHaveClass("error");
|
||||
});
|
||||
});
|
||||
it("navigates to dashboard on successful login", async () => {
|
||||
mockEndpoint(
|
||||
"com.atproto.server.createSession",
|
||||
() => jsonResponse(mockData.session()),
|
||||
);
|
||||
render(Login);
|
||||
await fireEvent.input(screen.getByLabelText(/handle or email/i), {
|
||||
target: { value: "test" },
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/password/i), {
|
||||
target: { value: "password" },
|
||||
});
|
||||
await fireEvent.click(screen.getByRole("button", { name: /sign in/i }));
|
||||
await waitFor(() => {
|
||||
expect(window.location.hash).toBe('#/dashboard')
|
||||
})
|
||||
})
|
||||
})
|
||||
describe('account verification flow', () => {
|
||||
it('shows verification form with all controls when account is not verified', async () => {
|
||||
mockEndpoint('com.atproto.server.createSession', () => ({
|
||||
expect(window.location.hash).toBe("#/dashboard");
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("account verification flow", () => {
|
||||
it("shows verification form with all controls when account is not verified", async () => {
|
||||
mockEndpoint("com.atproto.server.createSession", () => ({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({
|
||||
error: 'AccountNotVerified',
|
||||
message: 'Account not verified',
|
||||
did: 'did:web:test.tranquil.dev:u:testuser',
|
||||
error: "AccountNotVerified",
|
||||
message: "Account not verified",
|
||||
did: "did:web:test.tranquil.dev:u:testuser",
|
||||
}),
|
||||
}))
|
||||
render(Login)
|
||||
await fireEvent.input(screen.getByLabelText(/handle or email/i), { target: { value: 'unverified@test.com' } })
|
||||
await fireEvent.input(screen.getByLabelText(/password/i), { target: { value: 'password' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /sign in/i }))
|
||||
}));
|
||||
render(Login);
|
||||
await fireEvent.input(screen.getByLabelText(/handle or email/i), {
|
||||
target: { value: "unverified@test.com" },
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/password/i), {
|
||||
target: { value: "password" },
|
||||
});
|
||||
await fireEvent.click(screen.getByRole("button", { name: /sign in/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /verify your account/i })).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /resend code/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /back to login/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
it('returns to login form when clicking back', async () => {
|
||||
mockEndpoint('com.atproto.server.createSession', () => ({
|
||||
expect(screen.getByRole("heading", { name: /verify your account/i }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /resend code/i }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /back to login/i }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it("returns to login form when clicking back", async () => {
|
||||
mockEndpoint("com.atproto.server.createSession", () => ({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({
|
||||
error: 'AccountNotVerified',
|
||||
message: 'Account not verified',
|
||||
did: 'did:web:test.tranquil.dev:u:testuser',
|
||||
error: "AccountNotVerified",
|
||||
message: "Account not verified",
|
||||
did: "did:web:test.tranquil.dev:u:testuser",
|
||||
}),
|
||||
}))
|
||||
render(Login)
|
||||
await fireEvent.input(screen.getByLabelText(/handle or email/i), { target: { value: 'test' } })
|
||||
await fireEvent.input(screen.getByLabelText(/password/i), { target: { value: 'password' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /sign in/i }))
|
||||
}));
|
||||
render(Login);
|
||||
await fireEvent.input(screen.getByLabelText(/handle or email/i), {
|
||||
target: { value: "test" },
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/password/i), {
|
||||
target: { value: "password" },
|
||||
});
|
||||
await fireEvent.click(screen.getByRole("button", { name: /sign in/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /back to login/i })).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.click(screen.getByRole('button', { name: /back to login/i }))
|
||||
expect(screen.getByRole("button", { name: /back to login/i }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: /back to login/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /sign in/i })).toBeInTheDocument()
|
||||
expect(screen.queryByLabelText(/verification code/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
expect(screen.getByRole("heading", { name: /sign in/i }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/verification code/i)).not
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+462
-333
@@ -1,390 +1,519 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'
|
||||
import Settings from '../routes/Settings.svelte'
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/svelte";
|
||||
import Settings from "../routes/Settings.svelte";
|
||||
import {
|
||||
setupFetchMock,
|
||||
mockEndpoint,
|
||||
jsonResponse,
|
||||
errorResponse,
|
||||
clearMocks,
|
||||
errorResponse,
|
||||
jsonResponse,
|
||||
mockEndpoint,
|
||||
setupAuthenticatedUser,
|
||||
setupFetchMock,
|
||||
setupUnauthenticatedUser,
|
||||
} from './mocks'
|
||||
describe('Settings', () => {
|
||||
} from "./mocks";
|
||||
describe("Settings", () => {
|
||||
beforeEach(() => {
|
||||
clearMocks()
|
||||
setupFetchMock()
|
||||
window.confirm = vi.fn(() => true)
|
||||
})
|
||||
describe('authentication guard', () => {
|
||||
it('redirects to login when not authenticated', async () => {
|
||||
setupUnauthenticatedUser()
|
||||
render(Settings)
|
||||
clearMocks();
|
||||
setupFetchMock();
|
||||
window.confirm = vi.fn(() => true);
|
||||
});
|
||||
describe("authentication guard", () => {
|
||||
it("redirects to login when not authenticated", async () => {
|
||||
setupUnauthenticatedUser();
|
||||
render(Settings);
|
||||
await waitFor(() => {
|
||||
expect(window.location.hash).toBe('#/login')
|
||||
})
|
||||
})
|
||||
})
|
||||
describe('page structure', () => {
|
||||
expect(window.location.hash).toBe("#/login");
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("page structure", () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
})
|
||||
it('displays all page elements and sections', async () => {
|
||||
render(Settings)
|
||||
setupAuthenticatedUser();
|
||||
});
|
||||
it("displays all page elements and sections", async () => {
|
||||
render(Settings);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /account settings/i, level: 1 })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /dashboard/i })).toHaveAttribute('href', '#/dashboard')
|
||||
expect(screen.getByRole('heading', { name: /change email/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: /change handle/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: /delete account/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
describe('email change', () => {
|
||||
expect(
|
||||
screen.getByRole("heading", { name: /account settings/i, level: 1 }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /dashboard/i }))
|
||||
.toHaveAttribute("href", "#/dashboard");
|
||||
expect(screen.getByRole("heading", { name: /change email/i }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: /change handle/i }))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: /delete account/i }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("email change", () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
})
|
||||
it('displays current email and input field', async () => {
|
||||
render(Settings)
|
||||
setupAuthenticatedUser();
|
||||
});
|
||||
it("displays current email and input field", async () => {
|
||||
render(Settings);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/current: test@example.com/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
it('calls requestEmailUpdate when submitting', async () => {
|
||||
let requestCalled = false
|
||||
mockEndpoint('com.atproto.server.requestEmailUpdate', () => {
|
||||
requestCalled = true
|
||||
return jsonResponse({ tokenRequired: true })
|
||||
})
|
||||
render(Settings)
|
||||
expect(screen.getByText(/current: test@example.com/i))
|
||||
.toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it("calls requestEmailUpdate when submitting", async () => {
|
||||
let requestCalled = false;
|
||||
mockEndpoint("com.atproto.server.requestEmailUpdate", () => {
|
||||
requestCalled = true;
|
||||
return jsonResponse({ tokenRequired: true });
|
||||
});
|
||||
render(Settings);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'newemail@example.com' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
|
||||
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/new email/i), {
|
||||
target: { value: "newemail@example.com" },
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: /change email/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(requestCalled).toBe(true)
|
||||
})
|
||||
})
|
||||
it('shows verification code input when token is required', async () => {
|
||||
mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
|
||||
jsonResponse({ tokenRequired: true })
|
||||
)
|
||||
render(Settings)
|
||||
expect(requestCalled).toBe(true);
|
||||
});
|
||||
});
|
||||
it("shows verification code input when token is required", async () => {
|
||||
mockEndpoint(
|
||||
"com.atproto.server.requestEmailUpdate",
|
||||
() => jsonResponse({ tokenRequired: true }),
|
||||
);
|
||||
render(Settings);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'newemail@example.com' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
|
||||
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/new email/i), {
|
||||
target: { value: "newemail@example.com" },
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: /change email/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /confirm email change/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
it('calls updateEmail with token when confirming', async () => {
|
||||
let updateCalled = false
|
||||
let capturedBody: Record<string, string> | null = null
|
||||
mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
|
||||
jsonResponse({ tokenRequired: true })
|
||||
)
|
||||
mockEndpoint('com.atproto.server.updateEmail', (_url, options) => {
|
||||
updateCalled = true
|
||||
capturedBody = JSON.parse((options?.body as string) || '{}')
|
||||
return jsonResponse({})
|
||||
})
|
||||
render(Settings)
|
||||
expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /confirm email change/i }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it("calls updateEmail with token when confirming", async () => {
|
||||
let updateCalled = false;
|
||||
let capturedBody: Record<string, string> | null = null;
|
||||
mockEndpoint(
|
||||
"com.atproto.server.requestEmailUpdate",
|
||||
() => jsonResponse({ tokenRequired: true }),
|
||||
);
|
||||
mockEndpoint("com.atproto.server.updateEmail", (_url, options) => {
|
||||
updateCalled = true;
|
||||
capturedBody = JSON.parse((options?.body as string) || "{}");
|
||||
return jsonResponse({});
|
||||
});
|
||||
render(Settings);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'newemail@example.com' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
|
||||
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/new email/i), {
|
||||
target: { value: "newemail@example.com" },
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: /change email/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.input(screen.getByLabelText(/verification code/i), { target: { value: '123456' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /confirm email change/i }))
|
||||
expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/verification code/i), {
|
||||
target: { value: "123456" },
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: /confirm email change/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(updateCalled).toBe(true)
|
||||
expect(capturedBody?.email).toBe('newemail@example.com')
|
||||
expect(capturedBody?.token).toBe('123456')
|
||||
})
|
||||
})
|
||||
it('shows success message after email update', async () => {
|
||||
mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
|
||||
jsonResponse({ tokenRequired: true })
|
||||
)
|
||||
mockEndpoint('com.atproto.server.updateEmail', () =>
|
||||
jsonResponse({})
|
||||
)
|
||||
render(Settings)
|
||||
expect(updateCalled).toBe(true);
|
||||
expect(capturedBody?.email).toBe("newemail@example.com");
|
||||
expect(capturedBody?.token).toBe("123456");
|
||||
});
|
||||
});
|
||||
it("shows success message after email update", async () => {
|
||||
mockEndpoint(
|
||||
"com.atproto.server.requestEmailUpdate",
|
||||
() => jsonResponse({ tokenRequired: true }),
|
||||
);
|
||||
mockEndpoint("com.atproto.server.updateEmail", () => jsonResponse({}));
|
||||
render(Settings);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'new@test.com' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
|
||||
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/new email/i), {
|
||||
target: { value: "new@test.com" },
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: /change email/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.input(screen.getByLabelText(/verification code/i), { target: { value: '123456' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /confirm email change/i }))
|
||||
expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/verification code/i), {
|
||||
target: { value: "123456" },
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: /confirm email change/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/email updated successfully/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
it('shows cancel button to return to email form', async () => {
|
||||
mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
|
||||
jsonResponse({ tokenRequired: true })
|
||||
)
|
||||
render(Settings)
|
||||
expect(screen.getByText(/email updated successfully/i))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it("shows cancel button to return to email form", async () => {
|
||||
mockEndpoint(
|
||||
"com.atproto.server.requestEmailUpdate",
|
||||
() => jsonResponse({ tokenRequired: true }),
|
||||
);
|
||||
render(Settings);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'new@test.com' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
|
||||
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/new email/i), {
|
||||
target: { value: "new@test.com" },
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: /change email/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.click(screen.getByRole('button', { name: /cancel/i }))
|
||||
expect(screen.getByRole("button", { name: /cancel/i }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
|
||||
expect(screen.queryByLabelText(/verification code/i)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
it('shows error when email update fails', async () => {
|
||||
mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
|
||||
errorResponse('InvalidEmail', 'Invalid email format', 400)
|
||||
)
|
||||
render(Settings)
|
||||
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/verification code/i)).not
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it("shows error when email update fails", async () => {
|
||||
mockEndpoint(
|
||||
"com.atproto.server.requestEmailUpdate",
|
||||
() => errorResponse("InvalidEmail", "Invalid email format", 400),
|
||||
);
|
||||
render(Settings);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'invalid@test.com' } })
|
||||
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/new email/i), {
|
||||
target: { value: "invalid@test.com" },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /change email/i })).not.toBeDisabled()
|
||||
})
|
||||
await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
|
||||
expect(screen.getByRole("button", { name: /change email/i })).not
|
||||
.toBeDisabled();
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: /change email/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/invalid email format/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
describe('handle change', () => {
|
||||
expect(screen.getByText(/invalid email format/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("handle change", () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
})
|
||||
it('displays current handle', async () => {
|
||||
render(Settings)
|
||||
setupAuthenticatedUser();
|
||||
});
|
||||
it("displays current handle", async () => {
|
||||
render(Settings);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/current: @testuser\.test\.tranquil\.dev/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
it('calls updateHandle with new handle', async () => {
|
||||
let capturedHandle: string | null = null
|
||||
mockEndpoint('com.atproto.identity.updateHandle', (_url, options) => {
|
||||
const body = JSON.parse((options?.body as string) || '{}')
|
||||
capturedHandle = body.handle
|
||||
return jsonResponse({})
|
||||
})
|
||||
render(Settings)
|
||||
expect(screen.getByText(/current: @testuser\.test\.tranquil\.dev/i))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it("calls updateHandle with new handle", async () => {
|
||||
let capturedHandle: string | null = null;
|
||||
mockEndpoint("com.atproto.identity.updateHandle", (_url, options) => {
|
||||
const body = JSON.parse((options?.body as string) || "{}");
|
||||
capturedHandle = body.handle;
|
||||
return jsonResponse({});
|
||||
});
|
||||
render(Settings);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.input(screen.getByLabelText(/new handle/i), { target: { value: 'newhandle.bsky.social' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /change handle/i }))
|
||||
expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/new handle/i), {
|
||||
target: { value: "newhandle.bsky.social" },
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: /change handle/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(capturedHandle).toBe('newhandle.bsky.social')
|
||||
})
|
||||
})
|
||||
it('shows success message after handle change', async () => {
|
||||
mockEndpoint('com.atproto.identity.updateHandle', () =>
|
||||
jsonResponse({})
|
||||
)
|
||||
render(Settings)
|
||||
expect(capturedHandle).toBe("newhandle.bsky.social");
|
||||
});
|
||||
});
|
||||
it("shows success message after handle change", async () => {
|
||||
mockEndpoint("com.atproto.identity.updateHandle", () => jsonResponse({}));
|
||||
render(Settings);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.input(screen.getByLabelText(/new handle/i), { target: { value: 'newhandle' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /change handle/i }))
|
||||
expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/new handle/i), {
|
||||
target: { value: "newhandle" },
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: /change handle/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/handle updated successfully/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
it('shows error when handle change fails', async () => {
|
||||
mockEndpoint('com.atproto.identity.updateHandle', () =>
|
||||
errorResponse('HandleNotAvailable', 'Handle is already taken', 400)
|
||||
)
|
||||
render(Settings)
|
||||
expect(screen.getByText(/handle updated successfully/i))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it("shows error when handle change fails", async () => {
|
||||
mockEndpoint(
|
||||
"com.atproto.identity.updateHandle",
|
||||
() =>
|
||||
errorResponse("HandleNotAvailable", "Handle is already taken", 400),
|
||||
);
|
||||
render(Settings);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.input(screen.getByLabelText(/new handle/i), { target: { value: 'taken' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /change handle/i }))
|
||||
expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/new handle/i), {
|
||||
target: { value: "taken" },
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: /change handle/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/handle is already taken/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
describe('account deletion', () => {
|
||||
expect(screen.getByText(/handle is already taken/i))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("account deletion", () => {
|
||||
beforeEach(() => {
|
||||
setupAuthenticatedUser()
|
||||
mockEndpoint('com.atproto.server.deleteSession', () =>
|
||||
jsonResponse({})
|
||||
)
|
||||
})
|
||||
it('displays delete section with warning and request button', async () => {
|
||||
render(Settings)
|
||||
setupAuthenticatedUser();
|
||||
mockEndpoint("com.atproto.server.deleteSession", () => jsonResponse({}));
|
||||
});
|
||||
it("displays delete section with warning and request button", async () => {
|
||||
render(Settings);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/this action is irreversible/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
it('calls requestAccountDelete when clicking request', async () => {
|
||||
let requestCalled = false
|
||||
mockEndpoint('com.atproto.server.requestAccountDelete', () => {
|
||||
requestCalled = true
|
||||
return jsonResponse({})
|
||||
})
|
||||
render(Settings)
|
||||
expect(screen.getByText(/this action is irreversible/i))
|
||||
.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /request account deletion/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it("calls requestAccountDelete when clicking request", async () => {
|
||||
let requestCalled = false;
|
||||
mockEndpoint("com.atproto.server.requestAccountDelete", () => {
|
||||
requestCalled = true;
|
||||
return jsonResponse({});
|
||||
});
|
||||
render(Settings);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
|
||||
expect(
|
||||
screen.getByRole("button", { name: /request account deletion/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: /request account deletion/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(requestCalled).toBe(true)
|
||||
})
|
||||
})
|
||||
it('shows confirmation form after requesting deletion', async () => {
|
||||
mockEndpoint('com.atproto.server.requestAccountDelete', () =>
|
||||
jsonResponse({})
|
||||
)
|
||||
render(Settings)
|
||||
expect(requestCalled).toBe(true);
|
||||
});
|
||||
});
|
||||
it("shows confirmation form after requesting deletion", async () => {
|
||||
mockEndpoint(
|
||||
"com.atproto.server.requestAccountDelete",
|
||||
() => jsonResponse({}),
|
||||
);
|
||||
render(Settings);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
|
||||
expect(
|
||||
screen.getByRole("button", { name: /request account deletion/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: /request account deletion/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument()
|
||||
expect(screen.getByLabelText(/your password/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /permanently delete account/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
it('shows confirmation dialog before final deletion', async () => {
|
||||
const confirmSpy = vi.fn(() => false)
|
||||
window.confirm = confirmSpy
|
||||
mockEndpoint('com.atproto.server.requestAccountDelete', () =>
|
||||
jsonResponse({})
|
||||
)
|
||||
render(Settings)
|
||||
expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/your password/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /permanently delete account/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it("shows confirmation dialog before final deletion", async () => {
|
||||
const confirmSpy = vi.fn(() => false);
|
||||
window.confirm = confirmSpy;
|
||||
mockEndpoint(
|
||||
"com.atproto.server.requestAccountDelete",
|
||||
() => jsonResponse({}),
|
||||
);
|
||||
render(Settings);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
|
||||
expect(
|
||||
screen.getByRole("button", { name: /request account deletion/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: /request account deletion/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: 'ABC123' } })
|
||||
await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: 'password' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /permanently delete account/i }))
|
||||
expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/confirmation code/i), {
|
||||
target: { value: "ABC123" },
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/your password/i), {
|
||||
target: { value: "password" },
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: /permanently delete account/i }),
|
||||
);
|
||||
expect(confirmSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('absolutely sure')
|
||||
)
|
||||
})
|
||||
it('calls deleteAccount with correct parameters', async () => {
|
||||
window.confirm = vi.fn(() => true)
|
||||
let capturedBody: Record<string, string> | null = null
|
||||
mockEndpoint('com.atproto.server.requestAccountDelete', () =>
|
||||
jsonResponse({})
|
||||
)
|
||||
mockEndpoint('com.atproto.server.deleteAccount', (_url, options) => {
|
||||
capturedBody = JSON.parse((options?.body as string) || '{}')
|
||||
return jsonResponse({})
|
||||
})
|
||||
render(Settings)
|
||||
expect.stringContaining("absolutely sure"),
|
||||
);
|
||||
});
|
||||
it("calls deleteAccount with correct parameters", async () => {
|
||||
window.confirm = vi.fn(() => true);
|
||||
let capturedBody: Record<string, string> | null = null;
|
||||
mockEndpoint(
|
||||
"com.atproto.server.requestAccountDelete",
|
||||
() => jsonResponse({}),
|
||||
);
|
||||
mockEndpoint("com.atproto.server.deleteAccount", (_url, options) => {
|
||||
capturedBody = JSON.parse((options?.body as string) || "{}");
|
||||
return jsonResponse({});
|
||||
});
|
||||
render(Settings);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
|
||||
expect(
|
||||
screen.getByRole("button", { name: /request account deletion/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: /request account deletion/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: 'DEL123' } })
|
||||
await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: 'mypassword' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /permanently delete account/i }))
|
||||
expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/confirmation code/i), {
|
||||
target: { value: "DEL123" },
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/your password/i), {
|
||||
target: { value: "mypassword" },
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: /permanently delete account/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(capturedBody?.token).toBe('DEL123')
|
||||
expect(capturedBody?.password).toBe('mypassword')
|
||||
expect(capturedBody?.did).toBe('did:web:test.tranquil.dev:u:testuser')
|
||||
})
|
||||
})
|
||||
it('navigates to login after successful deletion', async () => {
|
||||
window.confirm = vi.fn(() => true)
|
||||
mockEndpoint('com.atproto.server.requestAccountDelete', () =>
|
||||
jsonResponse({})
|
||||
)
|
||||
mockEndpoint('com.atproto.server.deleteAccount', () =>
|
||||
jsonResponse({})
|
||||
)
|
||||
render(Settings)
|
||||
expect(capturedBody?.token).toBe("DEL123");
|
||||
expect(capturedBody?.password).toBe("mypassword");
|
||||
expect(capturedBody?.did).toBe("did:web:test.tranquil.dev:u:testuser");
|
||||
});
|
||||
});
|
||||
it("navigates to login after successful deletion", async () => {
|
||||
window.confirm = vi.fn(() => true);
|
||||
mockEndpoint(
|
||||
"com.atproto.server.requestAccountDelete",
|
||||
() => jsonResponse({}),
|
||||
);
|
||||
mockEndpoint("com.atproto.server.deleteAccount", () => jsonResponse({}));
|
||||
render(Settings);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
|
||||
expect(
|
||||
screen.getByRole("button", { name: /request account deletion/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: /request account deletion/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: 'DEL123' } })
|
||||
await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: 'password' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /permanently delete account/i }))
|
||||
expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/confirmation code/i), {
|
||||
target: { value: "DEL123" },
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/your password/i), {
|
||||
target: { value: "password" },
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: /permanently delete account/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(window.location.hash).toBe('#/login')
|
||||
})
|
||||
})
|
||||
it('shows cancel button to return to request state', async () => {
|
||||
mockEndpoint('com.atproto.server.requestAccountDelete', () =>
|
||||
jsonResponse({})
|
||||
)
|
||||
render(Settings)
|
||||
expect(window.location.hash).toBe("#/login");
|
||||
});
|
||||
});
|
||||
it("shows cancel button to return to request state", async () => {
|
||||
mockEndpoint(
|
||||
"com.atproto.server.requestAccountDelete",
|
||||
() => jsonResponse({}),
|
||||
);
|
||||
render(Settings);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
|
||||
expect(
|
||||
screen.getByRole("button", { name: /request account deletion/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: /request account deletion/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
const cancelButtons = screen.getAllByRole('button', { name: /cancel/i })
|
||||
expect(cancelButtons.length).toBeGreaterThan(0)
|
||||
})
|
||||
const deleteHeading = screen.getByRole('heading', { name: /delete account/i })
|
||||
const deleteSection = deleteHeading.closest('section')
|
||||
const cancelButton = deleteSection?.querySelector('button.secondary')
|
||||
const cancelButtons = screen.getAllByRole("button", {
|
||||
name: /cancel/i,
|
||||
});
|
||||
expect(cancelButtons.length).toBeGreaterThan(0);
|
||||
});
|
||||
const deleteHeading = screen.getByRole("heading", {
|
||||
name: /delete account/i,
|
||||
});
|
||||
const deleteSection = deleteHeading.closest("section");
|
||||
const cancelButton = deleteSection?.querySelector("button.secondary");
|
||||
if (cancelButton) {
|
||||
await fireEvent.click(cancelButton)
|
||||
await fireEvent.click(cancelButton);
|
||||
}
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
it('shows error when deletion fails', async () => {
|
||||
window.confirm = vi.fn(() => true)
|
||||
mockEndpoint('com.atproto.server.requestAccountDelete', () =>
|
||||
jsonResponse({})
|
||||
)
|
||||
mockEndpoint('com.atproto.server.deleteAccount', () =>
|
||||
errorResponse('InvalidToken', 'Invalid confirmation code', 400)
|
||||
)
|
||||
render(Settings)
|
||||
expect(
|
||||
screen.getByRole("button", { name: /request account deletion/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it("shows error when deletion fails", async () => {
|
||||
window.confirm = vi.fn(() => true);
|
||||
mockEndpoint(
|
||||
"com.atproto.server.requestAccountDelete",
|
||||
() => jsonResponse({}),
|
||||
);
|
||||
mockEndpoint(
|
||||
"com.atproto.server.deleteAccount",
|
||||
() => errorResponse("InvalidToken", "Invalid confirmation code", 400),
|
||||
);
|
||||
render(Settings);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
|
||||
expect(
|
||||
screen.getByRole("button", { name: /request account deletion/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: /request account deletion/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument()
|
||||
})
|
||||
await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: 'WRONG' } })
|
||||
await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: 'password' } })
|
||||
await fireEvent.click(screen.getByRole('button', { name: /permanently delete account/i }))
|
||||
expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument();
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/confirmation code/i), {
|
||||
target: { value: "WRONG" },
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText(/your password/i), {
|
||||
target: { value: "password" },
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: /permanently delete account/i }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/invalid confirmation code/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
expect(screen.getByText(/invalid confirmation code/i))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+170
-139
@@ -1,116 +1,134 @@
|
||||
import { vi } from 'vitest'
|
||||
import type { Session, AppPassword, InviteCode } from '../lib/api'
|
||||
import { _testSetState } from '../lib/auth.svelte'
|
||||
import { vi } from "vitest";
|
||||
import type { AppPassword, InviteCode, Session } from "../lib/api";
|
||||
import { _testSetState } from "../lib/auth.svelte";
|
||||
export interface MockResponse {
|
||||
ok: boolean
|
||||
status: number
|
||||
json: () => Promise<unknown>
|
||||
ok: boolean;
|
||||
status: number;
|
||||
json: () => Promise<unknown>;
|
||||
}
|
||||
export type MockHandler = (url: string, options?: RequestInit) => MockResponse | Promise<MockResponse>
|
||||
const mockHandlers: Map<string, MockHandler> = new Map()
|
||||
export type MockHandler = (
|
||||
url: string,
|
||||
options?: RequestInit,
|
||||
) => MockResponse | Promise<MockResponse>;
|
||||
const mockHandlers: Map<string, MockHandler> = new Map();
|
||||
export function mockEndpoint(endpoint: string, handler: MockHandler): void {
|
||||
mockHandlers.set(endpoint, handler)
|
||||
mockHandlers.set(endpoint, handler);
|
||||
}
|
||||
export function mockEndpointOnce(endpoint: string, handler: MockHandler): void {
|
||||
const originalHandler = mockHandlers.get(endpoint)
|
||||
const originalHandler = mockHandlers.get(endpoint);
|
||||
mockHandlers.set(endpoint, (url, options) => {
|
||||
mockHandlers.set(endpoint, originalHandler!)
|
||||
return handler(url, options)
|
||||
})
|
||||
mockHandlers.set(endpoint, originalHandler!);
|
||||
return handler(url, options);
|
||||
});
|
||||
}
|
||||
export function clearMocks(): void {
|
||||
mockHandlers.clear()
|
||||
mockHandlers.clear();
|
||||
}
|
||||
function extractEndpoint(url: string): string {
|
||||
const match = url.match(/\/xrpc\/([^?]+)/)
|
||||
return match ? match[1] : url
|
||||
const match = url.match(/\/xrpc\/([^?]+)/);
|
||||
return match ? match[1] : url;
|
||||
}
|
||||
export function setupFetchMock(): void {
|
||||
global.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const url = typeof input === 'string' ? input : input.toString()
|
||||
const endpoint = extractEndpoint(url)
|
||||
const handler = mockHandlers.get(endpoint)
|
||||
if (handler) {
|
||||
const result = await handler(url, init)
|
||||
global.fetch = vi.fn(
|
||||
async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
const endpoint = extractEndpoint(url);
|
||||
const handler = mockHandlers.get(endpoint);
|
||||
if (handler) {
|
||||
const result = await handler(url, init);
|
||||
return {
|
||||
ok: result.ok,
|
||||
status: result.status,
|
||||
json: result.json,
|
||||
text: async () => JSON.stringify(await result.json()),
|
||||
headers: new Headers(),
|
||||
redirected: false,
|
||||
statusText: result.ok ? "OK" : "Error",
|
||||
type: "basic",
|
||||
url,
|
||||
clone: () => ({ ...result }) as Response,
|
||||
body: null,
|
||||
bodyUsed: false,
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
blob: async () => new Blob(),
|
||||
formData: async () => new FormData(),
|
||||
} as Response;
|
||||
}
|
||||
return {
|
||||
ok: result.ok,
|
||||
status: result.status,
|
||||
json: result.json,
|
||||
text: async () => JSON.stringify(await result.json()),
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({
|
||||
error: "NotFound",
|
||||
message: `No mock for ${endpoint}`,
|
||||
}),
|
||||
text: async () =>
|
||||
JSON.stringify({
|
||||
error: "NotFound",
|
||||
message: `No mock for ${endpoint}`,
|
||||
}),
|
||||
headers: new Headers(),
|
||||
redirected: false,
|
||||
statusText: result.ok ? 'OK' : 'Error',
|
||||
type: 'basic',
|
||||
statusText: "Not Found",
|
||||
type: "basic",
|
||||
url,
|
||||
clone: () => ({ ...result }) as Response,
|
||||
clone: function () {
|
||||
return this;
|
||||
},
|
||||
body: null,
|
||||
bodyUsed: false,
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
blob: async () => new Blob(),
|
||||
formData: async () => new FormData(),
|
||||
} as Response
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({ error: 'NotFound', message: `No mock for ${endpoint}` }),
|
||||
text: async () => JSON.stringify({ error: 'NotFound', message: `No mock for ${endpoint}` }),
|
||||
headers: new Headers(),
|
||||
redirected: false,
|
||||
statusText: 'Not Found',
|
||||
type: 'basic',
|
||||
url,
|
||||
clone: function() { return this },
|
||||
body: null,
|
||||
bodyUsed: false,
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
blob: async () => new Blob(),
|
||||
formData: async () => new FormData(),
|
||||
} as Response
|
||||
})
|
||||
} as Response;
|
||||
},
|
||||
);
|
||||
}
|
||||
export function jsonResponse<T>(data: T, status = 200): MockResponse {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => data,
|
||||
}
|
||||
};
|
||||
}
|
||||
export function errorResponse(error: string, message: string, status = 400): MockResponse {
|
||||
export function errorResponse(
|
||||
error: string,
|
||||
message: string,
|
||||
status = 400,
|
||||
): MockResponse {
|
||||
return {
|
||||
ok: false,
|
||||
status,
|
||||
json: async () => ({ error, message }),
|
||||
}
|
||||
};
|
||||
}
|
||||
export const mockData = {
|
||||
session: (overrides?: Partial<Session>): Session => ({
|
||||
did: 'did:web:test.tranquil.dev:u:testuser',
|
||||
handle: 'testuser.test.tranquil.dev',
|
||||
email: 'test@example.com',
|
||||
did: "did:web:test.tranquil.dev:u:testuser",
|
||||
handle: "testuser.test.tranquil.dev",
|
||||
email: "test@example.com",
|
||||
emailConfirmed: true,
|
||||
accessJwt: 'mock-access-jwt-token',
|
||||
refreshJwt: 'mock-refresh-jwt-token',
|
||||
accessJwt: "mock-access-jwt-token",
|
||||
refreshJwt: "mock-refresh-jwt-token",
|
||||
...overrides,
|
||||
}),
|
||||
appPassword: (overrides?: Partial<AppPassword>): AppPassword => ({
|
||||
name: 'Test App',
|
||||
name: "Test App",
|
||||
createdAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
}),
|
||||
inviteCode: (overrides?: Partial<InviteCode>): InviteCode => ({
|
||||
code: 'test-invite-123',
|
||||
code: "test-invite-123",
|
||||
available: 1,
|
||||
disabled: false,
|
||||
forAccount: 'did:web:test.tranquil.dev:u:testuser',
|
||||
createdBy: 'did:web:test.tranquil.dev:u:testuser',
|
||||
forAccount: "did:web:test.tranquil.dev:u:testuser",
|
||||
createdBy: "did:web:test.tranquil.dev:u:testuser",
|
||||
createdAt: new Date().toISOString(),
|
||||
uses: [],
|
||||
...overrides,
|
||||
}),
|
||||
notificationPrefs: (overrides?: Record<string, unknown>) => ({
|
||||
preferredChannel: 'email',
|
||||
email: 'test@example.com',
|
||||
preferredChannel: "email",
|
||||
email: "test@example.com",
|
||||
discordId: null,
|
||||
discordVerified: false,
|
||||
telegramUsername: null,
|
||||
@@ -120,105 +138,118 @@ export const mockData = {
|
||||
...overrides,
|
||||
}),
|
||||
describeServer: () => ({
|
||||
availableUserDomains: ['test.tranquil.dev'],
|
||||
availableUserDomains: ["test.tranquil.dev"],
|
||||
inviteCodeRequired: false,
|
||||
links: {
|
||||
privacyPolicy: 'https://example.com/privacy',
|
||||
termsOfService: 'https://example.com/tos',
|
||||
privacyPolicy: "https://example.com/privacy",
|
||||
termsOfService: "https://example.com/tos",
|
||||
},
|
||||
}),
|
||||
describeRepo: (did: string) => ({
|
||||
handle: 'testuser.test.tranquil.dev',
|
||||
handle: "testuser.test.tranquil.dev",
|
||||
did,
|
||||
didDoc: {},
|
||||
collections: ['app.bsky.feed.post', 'app.bsky.feed.like', 'app.bsky.graph.follow'],
|
||||
collections: [
|
||||
"app.bsky.feed.post",
|
||||
"app.bsky.feed.like",
|
||||
"app.bsky.graph.follow",
|
||||
],
|
||||
handleIsCorrect: true,
|
||||
}),
|
||||
}
|
||||
};
|
||||
export function setupDefaultMocks(): void {
|
||||
setupFetchMock()
|
||||
mockEndpoint('com.atproto.server.getSession', () =>
|
||||
jsonResponse(mockData.session())
|
||||
)
|
||||
mockEndpoint('com.atproto.server.createSession', (_url, options) => {
|
||||
const body = JSON.parse((options?.body as string) || '{}')
|
||||
if (body.identifier && body.password === 'correctpassword') {
|
||||
return jsonResponse(mockData.session({ handle: body.identifier.replace('@', '') }))
|
||||
setupFetchMock();
|
||||
mockEndpoint(
|
||||
"com.atproto.server.getSession",
|
||||
() => jsonResponse(mockData.session()),
|
||||
);
|
||||
mockEndpoint("com.atproto.server.createSession", (_url, options) => {
|
||||
const body = JSON.parse((options?.body as string) || "{}");
|
||||
if (body.identifier && body.password === "correctpassword") {
|
||||
return jsonResponse(
|
||||
mockData.session({ handle: body.identifier.replace("@", "") }),
|
||||
);
|
||||
}
|
||||
return errorResponse('AuthenticationRequired', 'Invalid identifier or password', 401)
|
||||
})
|
||||
mockEndpoint('com.atproto.server.refreshSession', () =>
|
||||
jsonResponse(mockData.session())
|
||||
)
|
||||
mockEndpoint('com.atproto.server.deleteSession', () =>
|
||||
jsonResponse({})
|
||||
)
|
||||
mockEndpoint('com.atproto.server.listAppPasswords', () =>
|
||||
jsonResponse({ passwords: [mockData.appPassword()] })
|
||||
)
|
||||
mockEndpoint('com.atproto.server.createAppPassword', (_url, options) => {
|
||||
const body = JSON.parse((options?.body as string) || '{}')
|
||||
return errorResponse(
|
||||
"AuthenticationRequired",
|
||||
"Invalid identifier or password",
|
||||
401,
|
||||
);
|
||||
});
|
||||
mockEndpoint(
|
||||
"com.atproto.server.refreshSession",
|
||||
() => jsonResponse(mockData.session()),
|
||||
);
|
||||
mockEndpoint("com.atproto.server.deleteSession", () => jsonResponse({}));
|
||||
mockEndpoint(
|
||||
"com.atproto.server.listAppPasswords",
|
||||
() => jsonResponse({ passwords: [mockData.appPassword()] }),
|
||||
);
|
||||
mockEndpoint("com.atproto.server.createAppPassword", (_url, options) => {
|
||||
const body = JSON.parse((options?.body as string) || "{}");
|
||||
return jsonResponse({
|
||||
name: body.name,
|
||||
password: 'xxxx-xxxx-xxxx-xxxx',
|
||||
password: "xxxx-xxxx-xxxx-xxxx",
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
})
|
||||
mockEndpoint('com.atproto.server.revokeAppPassword', () =>
|
||||
jsonResponse({})
|
||||
)
|
||||
mockEndpoint('com.atproto.server.getAccountInviteCodes', () =>
|
||||
jsonResponse({ codes: [mockData.inviteCode()] })
|
||||
)
|
||||
mockEndpoint('com.atproto.server.createInviteCode', () =>
|
||||
jsonResponse({ code: 'new-invite-' + Date.now() })
|
||||
)
|
||||
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
|
||||
jsonResponse(mockData.notificationPrefs())
|
||||
)
|
||||
mockEndpoint('com.tranquil.account.updateNotificationPrefs', () =>
|
||||
jsonResponse({ success: true })
|
||||
)
|
||||
mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
|
||||
jsonResponse({ tokenRequired: true })
|
||||
)
|
||||
mockEndpoint('com.atproto.server.updateEmail', () =>
|
||||
jsonResponse({})
|
||||
)
|
||||
mockEndpoint('com.atproto.identity.updateHandle', () =>
|
||||
jsonResponse({})
|
||||
)
|
||||
mockEndpoint('com.atproto.server.requestAccountDelete', () =>
|
||||
jsonResponse({})
|
||||
)
|
||||
mockEndpoint('com.atproto.server.deleteAccount', () =>
|
||||
jsonResponse({})
|
||||
)
|
||||
mockEndpoint('com.atproto.server.describeServer', () =>
|
||||
jsonResponse(mockData.describeServer())
|
||||
)
|
||||
mockEndpoint('com.atproto.repo.describeRepo', (url) => {
|
||||
const params = new URLSearchParams(url.split('?')[1])
|
||||
const repo = params.get('repo') || 'did:web:test'
|
||||
return jsonResponse(mockData.describeRepo(repo))
|
||||
})
|
||||
mockEndpoint('com.atproto.repo.listRecords', () =>
|
||||
jsonResponse({ records: [] })
|
||||
)
|
||||
});
|
||||
});
|
||||
mockEndpoint("com.atproto.server.revokeAppPassword", () => jsonResponse({}));
|
||||
mockEndpoint(
|
||||
"com.atproto.server.getAccountInviteCodes",
|
||||
() => jsonResponse({ codes: [mockData.inviteCode()] }),
|
||||
);
|
||||
mockEndpoint(
|
||||
"com.atproto.server.createInviteCode",
|
||||
() => jsonResponse({ code: "new-invite-" + Date.now() }),
|
||||
);
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.getNotificationPrefs",
|
||||
() => jsonResponse(mockData.notificationPrefs()),
|
||||
);
|
||||
mockEndpoint(
|
||||
"com.tranquil.account.updateNotificationPrefs",
|
||||
() => jsonResponse({ success: true }),
|
||||
);
|
||||
mockEndpoint(
|
||||
"com.atproto.server.requestEmailUpdate",
|
||||
() => jsonResponse({ tokenRequired: true }),
|
||||
);
|
||||
mockEndpoint("com.atproto.server.updateEmail", () => jsonResponse({}));
|
||||
mockEndpoint("com.atproto.identity.updateHandle", () => jsonResponse({}));
|
||||
mockEndpoint(
|
||||
"com.atproto.server.requestAccountDelete",
|
||||
() => jsonResponse({}),
|
||||
);
|
||||
mockEndpoint("com.atproto.server.deleteAccount", () => jsonResponse({}));
|
||||
mockEndpoint(
|
||||
"com.atproto.server.describeServer",
|
||||
() => jsonResponse(mockData.describeServer()),
|
||||
);
|
||||
mockEndpoint("com.atproto.repo.describeRepo", (url) => {
|
||||
const params = new URLSearchParams(url.split("?")[1]);
|
||||
const repo = params.get("repo") || "did:web:test";
|
||||
return jsonResponse(mockData.describeRepo(repo));
|
||||
});
|
||||
mockEndpoint(
|
||||
"com.atproto.repo.listRecords",
|
||||
() => jsonResponse({ records: [] }),
|
||||
);
|
||||
}
|
||||
export function setupAuthenticatedUser(sessionOverrides?: Partial<Session>): Session {
|
||||
const session = mockData.session(sessionOverrides)
|
||||
export function setupAuthenticatedUser(
|
||||
sessionOverrides?: Partial<Session>,
|
||||
): Session {
|
||||
const session = mockData.session(sessionOverrides);
|
||||
_testSetState({
|
||||
session,
|
||||
loading: false,
|
||||
error: null,
|
||||
})
|
||||
return session
|
||||
});
|
||||
return session;
|
||||
}
|
||||
export function setupUnauthenticatedUser(): void {
|
||||
_testSetState({
|
||||
session: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
+23
-21
@@ -1,35 +1,37 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { vi, beforeEach, afterEach } from 'vitest'
|
||||
import { _testReset } from '../lib/auth.svelte'
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach, beforeEach, vi } from "vitest";
|
||||
import { _testReset } from "../lib/auth.svelte";
|
||||
|
||||
let locationHash = ''
|
||||
let locationHash = "";
|
||||
|
||||
Object.defineProperty(window, 'location', {
|
||||
Object.defineProperty(window, "location", {
|
||||
value: {
|
||||
get hash() { return locationHash },
|
||||
set hash(value: string) {
|
||||
locationHash = value.startsWith('#') ? value : `#${value}`
|
||||
get hash() {
|
||||
return locationHash;
|
||||
},
|
||||
href: 'http://localhost:3000/',
|
||||
origin: 'http://localhost:3000',
|
||||
pathname: '/',
|
||||
search: '',
|
||||
set hash(value: string) {
|
||||
locationHash = value.startsWith("#") ? value : `#${value}`;
|
||||
},
|
||||
href: "http://localhost:3000/",
|
||||
origin: "http://localhost:3000",
|
||||
pathname: "/",
|
||||
search: "",
|
||||
assign: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
reload: vi.fn(),
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
})
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
locationHash = ''
|
||||
_testReset()
|
||||
})
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
locationHash = "";
|
||||
_testReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
+55
-43
@@ -1,85 +1,97 @@
|
||||
import { render, type RenderResult } from '@testing-library/svelte'
|
||||
import { tick } from 'svelte'
|
||||
import type { ComponentType } from 'svelte'
|
||||
import { render, type RenderResult } from "@testing-library/svelte";
|
||||
import { tick } from "svelte";
|
||||
import type { ComponentType } from "svelte";
|
||||
|
||||
export async function renderAndWait<T extends ComponentType>(
|
||||
component: T,
|
||||
options?: Parameters<typeof render>[1]
|
||||
options?: Parameters<typeof render>[1],
|
||||
): Promise<RenderResult<T>> {
|
||||
const result = render(component, options)
|
||||
await tick()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
return result
|
||||
const result = render(component, options);
|
||||
await tick();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function waitForElement(
|
||||
queryFn: () => HTMLElement | null,
|
||||
timeout = 1000
|
||||
timeout = 1000,
|
||||
): Promise<HTMLElement> {
|
||||
const start = Date.now()
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
const element = queryFn()
|
||||
if (element) return element
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
const element = queryFn();
|
||||
if (element) return element;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
throw new Error('Element not found within timeout')
|
||||
throw new Error("Element not found within timeout");
|
||||
}
|
||||
|
||||
export async function waitForElementToDisappear(
|
||||
queryFn: () => HTMLElement | null,
|
||||
timeout = 1000
|
||||
timeout = 1000,
|
||||
): Promise<void> {
|
||||
const start = Date.now()
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
const element = queryFn()
|
||||
if (!element) return
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
const element = queryFn();
|
||||
if (!element) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
throw new Error('Element still present after timeout')
|
||||
throw new Error("Element still present after timeout");
|
||||
}
|
||||
|
||||
export async function waitForText(
|
||||
container: HTMLElement,
|
||||
text: string | RegExp,
|
||||
timeout = 1000
|
||||
timeout = 1000,
|
||||
): Promise<void> {
|
||||
const start = Date.now()
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
const content = container.textContent || ''
|
||||
if (typeof text === 'string' ? content.includes(text) : text.test(content)) {
|
||||
return
|
||||
const content = container.textContent || "";
|
||||
if (
|
||||
typeof text === "string" ? content.includes(text) : text.test(content)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
throw new Error(`Text "${text}" not found within timeout`)
|
||||
throw new Error(`Text "${text}" not found within timeout`);
|
||||
}
|
||||
|
||||
export function mockLocalStorage(initialData: Record<string, string> = {}): void {
|
||||
const store: Record<string, string> = { ...initialData }
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
export function mockLocalStorage(
|
||||
initialData: Record<string, string> = {},
|
||||
): void {
|
||||
const store: Record<string, string> = { ...initialData };
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: {
|
||||
getItem: (key: string) => store[key] || null,
|
||||
setItem: (key: string, value: string) => { store[key] = value },
|
||||
removeItem: (key: string) => { delete store[key] },
|
||||
clear: () => { Object.keys(store).forEach(key => delete store[key]) },
|
||||
setItem: (key: string, value: string) => {
|
||||
store[key] = value;
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
delete store[key];
|
||||
},
|
||||
clear: () => {
|
||||
Object.keys(store).forEach((key) => delete store[key]);
|
||||
},
|
||||
key: (index: number) => Object.keys(store)[index] || null,
|
||||
get length() { return Object.keys(store).length },
|
||||
get length() {
|
||||
return Object.keys(store).length;
|
||||
},
|
||||
},
|
||||
writable: true,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export function setAuthState(session: {
|
||||
did: string
|
||||
handle: string
|
||||
email?: string
|
||||
emailConfirmed?: boolean
|
||||
accessJwt: string
|
||||
refreshJwt: string
|
||||
did: string;
|
||||
handle: string;
|
||||
email?: string;
|
||||
emailConfirmed?: boolean;
|
||||
accessJwt: string;
|
||||
refreshJwt: string;
|
||||
}): void {
|
||||
localStorage.setItem('session', JSON.stringify(session))
|
||||
localStorage.setItem("session", JSON.stringify(session));
|
||||
}
|
||||
|
||||
export function clearAuthState(): void {
|
||||
localStorage.removeItem('session')
|
||||
localStorage.removeItem("session");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
|
||||
const isTest = process.env.VITEST === 'true' || process.env.VITEST === true
|
||||
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||
const isTest = process.env.VITEST === "true" || process.env.VITEST === true;
|
||||
export default {
|
||||
preprocess: isTest ? [] : vitePreprocess(),
|
||||
}
|
||||
};
|
||||
|
||||
+14
-14
@@ -1,24 +1,24 @@
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
const target = env.VITE_API_URL || 'http://localhost:3000'
|
||||
const env = loadEnv(mode, process.cwd(), "");
|
||||
const target = env.VITE_API_URL || "http://localhost:3000";
|
||||
|
||||
return {
|
||||
plugins: [svelte()],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
outDir: "dist",
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/xrpc': target,
|
||||
'/oauth': target,
|
||||
'/.well-known': target,
|
||||
'/health': target,
|
||||
'/u': target,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
"/xrpc": target,
|
||||
"/oauth": target,
|
||||
"/.well-known": target,
|
||||
"/health": target,
|
||||
"/u": target,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
svelte({
|
||||
@@ -7,15 +7,15 @@ export default defineConfig({
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
conditions: ['browser', 'development'],
|
||||
conditions: ["browser", "development"],
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: ['./src/tests/setup.ts'],
|
||||
include: ['src/**/*.{test,spec}.{js,ts}'],
|
||||
setupFiles: ["./src/tests/setup.ts"],
|
||||
include: ["src/**/*.{test,spec}.{js,ts}"],
|
||||
alias: {
|
||||
'svelte': 'svelte',
|
||||
"svelte": "svelte",
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
+1
-4
@@ -126,10 +126,7 @@ impl ClientMetadataCache {
|
||||
client_uri: None,
|
||||
logo_uri: None,
|
||||
redirect_uris,
|
||||
grant_types: vec![
|
||||
"authorization_code".into(),
|
||||
"refresh_token".into(),
|
||||
],
|
||||
grant_types: vec!["authorization_code".into(), "refresh_token".into()],
|
||||
response_types: vec!["code".into()],
|
||||
scope,
|
||||
token_endpoint_auth_method: Some("none".into()),
|
||||
|
||||
Reference in New Issue
Block a user