this repo has no description

Frontend style updates

lewis ed3d8331 d4dc177f

+12 -3
frontend/index.html
··· 6 6 <title>Tranquil PDS</title> 7 7 <link rel="preconnect" href="https://fonts.googleapis.com"> 8 8 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 9 - <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet"> 9 + <link 10 + href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" 11 + rel="stylesheet" 12 + > 10 13 <style> 11 - html { background: #ffffff; } 12 - @media (prefers-color-scheme: dark) { html { background: #0a0a0a; } } 14 + html { 15 + background: #f9fafa; 16 + } 17 + @media (prefers-color-scheme: dark) { 18 + html { 19 + background: #0a0c0c; 20 + } 21 + } 13 22 </style> 14 23 </head> 15 24 <body>
-650
frontend/mockups/01-article-style.html
··· 1 - <!DOCTYPE html> 2 - <html lang="en"> 3 - <head> 4 - <meta charset="UTF-8"> 5 - <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 - <title>Tranquil</title> 7 - <link rel="preconnect" href="https://fonts.googleapis.com"> 8 - <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 9 - <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700;800&display=swap" rel="stylesheet"> 10 - <style> 11 - * { margin: 0; padding: 0; box-sizing: border-box; } 12 - 13 - body { 14 - font-family: 'JetBrains Mono', monospace; 15 - line-height: 1.7; 16 - background: #2c00ff; 17 - color: #ffffff; 18 - min-height: 100vh; 19 - position: relative; 20 - } 21 - 22 - .pattern-container { 23 - position: fixed; 24 - top: -32px; 25 - left: -32px; 26 - right: -32px; 27 - bottom: -32px; 28 - pointer-events: none; 29 - z-index: 1; 30 - overflow: hidden; 31 - } 32 - 33 - .pattern { 34 - position: absolute; 35 - top: 0; 36 - left: 0; 37 - width: calc(100% + 500px); 38 - height: 100%; 39 - animation: drift 80s linear infinite; 40 - } 41 - 42 - .dot { 43 - position: absolute; 44 - width: 10px; 45 - height: 10px; 46 - background: rgba(255,255,255,0.15); 47 - border-radius: 50%; 48 - transition: transform 0.04s linear; 49 - } 50 - 51 - .pattern-fade { 52 - position: fixed; 53 - top: 0; 54 - left: 0; 55 - right: 0; 56 - bottom: 0; 57 - background: linear-gradient(135deg, transparent 50%, #2c00ff 75%); 58 - pointer-events: none; 59 - z-index: 2; 60 - } 61 - 62 - @keyframes drift { 63 - 0% { transform: translateX(-500px); } 64 - 100% { transform: translateX(0); } 65 - } 66 - 67 - nav { z-index: 100; } 68 - main { position: relative; z-index: 10; } 69 - .site-footer { position: relative; z-index: 10; } 70 - 71 - a { color: #ff2400; text-decoration: none; } 72 - a:hover { color: #ff5533; } 73 - 74 - nav { 75 - position: fixed; 76 - top: 12px; 77 - left: 32px; 78 - right: 32px; 79 - background: #1a00a3; 80 - padding: 10px 18px; 81 - z-index: 100; 82 - border-radius: 8px; 83 - border: 1px solid rgba(255, 255, 255, 0.1); 84 - display: flex; 85 - justify-content: space-between; 86 - align-items: center; 87 - } 88 - 89 - nav .brand { 90 - font-weight: 600; 91 - font-size: 1rem; 92 - letter-spacing: 0.08em; 93 - color: #ffffff; 94 - text-transform: uppercase; 95 - } 96 - 97 - nav .nav-meta { 98 - font-size: 0.85rem; 99 - color: rgba(255, 255, 255, 0.7); 100 - letter-spacing: 0.05em; 101 - } 102 - 103 - main { 104 - max-width: 1000px; 105 - margin: 0 auto; 106 - padding: 80px 32px 80px; 107 - } 108 - 109 - .meta { 110 - display: flex; 111 - align-items: center; 112 - gap: 16px; 113 - margin-bottom: 32px; 114 - font-size: 0.8rem; 115 - font-weight: 500; 116 - text-transform: uppercase; 117 - letter-spacing: 0.1em; 118 - } 119 - 120 - .category { 121 - color: #ff2400; 122 - background: rgba(255, 255, 255, 0.95); 123 - padding: 4px 10px; 124 - border-radius: 4px; 125 - } 126 - 127 - .read-time { 128 - color: rgba(255, 255, 255, 0.8); 129 - } 130 - 131 - h1 { 132 - font-size: 2.75rem; 133 - font-weight: 600; 134 - line-height: 1.15; 135 - color: #ffffff; 136 - margin-bottom: 32px; 137 - letter-spacing: -0.02em; 138 - } 139 - 140 - .byline { 141 - display: flex; 142 - align-items: center; 143 - gap: 16px; 144 - padding: 24px 0; 145 - border-top: 1px solid rgba(255, 255, 255, 0.12); 146 - border-bottom: 1px solid rgba(255, 255, 255, 0.12); 147 - margin-bottom: 48px; 148 - } 149 - 150 - .avatar { 151 - width: 44px; 152 - height: 44px; 153 - border-radius: 50%; 154 - background: linear-gradient(135deg, #ff2400 0%, #ff6b4a 100%); 155 - } 156 - 157 - .author-info { 158 - flex: 1; 159 - } 160 - 161 - .author { 162 - display: block; 163 - font-weight: 500; 164 - color: #ffffff; 165 - font-size: 1rem; 166 - } 167 - 168 - .author-handle { 169 - display: block; 170 - font-size: 0.85rem; 171 - color: rgba(255, 255, 255, 0.8); 172 - margin-top: 2px; 173 - } 174 - 175 - .verification { 176 - font-size: 0.75rem; 177 - font-weight: 500; 178 - color: rgba(255, 255, 255, 0.85); 179 - text-transform: uppercase; 180 - letter-spacing: 0.08em; 181 - } 182 - 183 - .placeholder-image { 184 - aspect-ratio: 16 / 9; 185 - background: rgba(255, 255, 255, 0.08); 186 - border-radius: 8px; 187 - display: flex; 188 - align-items: center; 189 - justify-content: center; 190 - font-size: 0.9rem; 191 - color: rgba(255, 255, 255, 0.6); 192 - text-transform: uppercase; 193 - letter-spacing: 0.1em; 194 - border: 1px solid rgba(255, 255, 255, 0.15); 195 - } 196 - 197 - figcaption { 198 - margin-top: 12px; 199 - font-size: 0.85rem; 200 - color: rgba(255, 255, 255, 0.75); 201 - text-align: center; 202 - } 203 - 204 - .carousel { 205 - margin: 64px 0 0; 206 - } 207 - 208 - .carousel-header { 209 - display: flex; 210 - justify-content: space-between; 211 - align-items: center; 212 - margin-bottom: 20px; 213 - } 214 - 215 - .carousel-title { 216 - font-size: 0.85rem; 217 - font-weight: 600; 218 - text-transform: uppercase; 219 - letter-spacing: 0.1em; 220 - color: #ffffff; 221 - } 222 - 223 - .carousel-nav { 224 - display: flex; 225 - gap: 8px; 226 - } 227 - 228 - .carousel-nav button { 229 - font-family: 'JetBrains Mono', monospace; 230 - width: 36px; 231 - height: 36px; 232 - background: rgba(255, 255, 255, 0.08); 233 - border: 1px solid rgba(255, 255, 255, 0.15); 234 - border-radius: 6px; 235 - color: #ffffff; 236 - cursor: pointer; 237 - transition: all 0.15s ease; 238 - font-size: 1rem; 239 - } 240 - 241 - .carousel-nav button:hover { 242 - background: rgba(255, 36, 0, 0.15); 243 - border-color: #ff2400; 244 - } 245 - 246 - .carousel-track { 247 - display: flex; 248 - gap: 16px; 249 - overflow-x: auto; 250 - scroll-snap-type: x mandatory; 251 - scrollbar-width: none; 252 - -ms-overflow-style: none; 253 - padding-bottom: 8px; 254 - -webkit-overflow-scrolling: touch; 255 - user-select: none; 256 - } 257 - 258 - .carousel-track::-webkit-scrollbar { 259 - display: none; 260 - } 261 - 262 - .carousel-slide { 263 - flex: 0 0 70%; 264 - scroll-snap-align: start; 265 - } 266 - 267 - .carousel-slide .placeholder-image { 268 - aspect-ratio: 16 / 10; 269 - } 270 - 271 - .carousel-label { 272 - margin-top: 12px; 273 - font-size: 0.8rem; 274 - font-weight: 500; 275 - color: rgba(255, 255, 255, 0.85); 276 - text-transform: uppercase; 277 - letter-spacing: 0.08em; 278 - } 279 - 280 - .content { 281 - font-size: 1.05rem; 282 - font-weight: 400; 283 - } 284 - 285 - .content p { 286 - margin-bottom: 28px; 287 - } 288 - 289 - .lede { 290 - font-size: 1.3rem; 291 - font-weight: 500; 292 - color: #ffffff; 293 - line-height: 1.5; 294 - } 295 - 296 - .content h2 { 297 - font-size: 0.9rem; 298 - font-weight: 600; 299 - text-transform: uppercase; 300 - letter-spacing: 0.1em; 301 - color: #ffffff; 302 - margin: 56px 0 24px; 303 - } 304 - 305 - blockquote { 306 - margin: 40px 0; 307 - padding: 32px; 308 - background: rgba(255, 255, 255, 0.05); 309 - border-left: 2px solid #ff2400; 310 - border-radius: 0 8px 8px 0; 311 - } 312 - 313 - blockquote p { 314 - font-size: 1.15rem; 315 - color: #ffffff; 316 - font-style: italic; 317 - margin-bottom: 16px !important; 318 - } 319 - 320 - blockquote cite { 321 - font-size: 0.8rem; 322 - color: rgba(255, 255, 255, 0.8); 323 - font-style: normal; 324 - text-transform: uppercase; 325 - letter-spacing: 0.05em; 326 - } 327 - 328 - .context-panel { 329 - margin: 40px 0; 330 - padding: 24px; 331 - background: rgba(255, 255, 255, 0.05); 332 - border-radius: 8px; 333 - border: 1px solid rgba(255, 255, 255, 0.1); 334 - } 335 - 336 - .context-panel h3 { 337 - font-size: 0.8rem; 338 - font-weight: 600; 339 - text-transform: uppercase; 340 - letter-spacing: 0.1em; 341 - color: #ffffff; 342 - margin-bottom: 16px; 343 - } 344 - 345 - .context-panel ul { 346 - list-style: none; 347 - } 348 - 349 - .context-panel li { 350 - padding: 10px 0; 351 - border-bottom: 1px solid rgba(255, 255, 255, 0.1); 352 - } 353 - 354 - .context-panel li:last-child { 355 - border-bottom: none; 356 - } 357 - 358 - .context-panel a { 359 - font-size: 0.95rem; 360 - font-weight: 500; 361 - color: #ff2400; 362 - text-decoration: none; 363 - transition: color 0.15s ease; 364 - } 365 - 366 - .context-panel a:hover { 367 - color: #ff5533; 368 - } 369 - 370 - .article-footer { 371 - margin-top: 64px; 372 - padding-top: 32px; 373 - border-top: 1px solid rgba(255, 255, 255, 0.12); 374 - } 375 - 376 - .actions { 377 - display: flex; 378 - gap: 12px; 379 - margin-bottom: 24px; 380 - } 381 - 382 - .actions button { 383 - font-family: 'JetBrains Mono', monospace; 384 - font-size: 0.85rem; 385 - font-weight: 500; 386 - text-transform: uppercase; 387 - letter-spacing: 0.06em; 388 - padding: 14px 24px; 389 - background: rgba(255, 255, 255, 0.06); 390 - border: 1px solid rgba(255, 255, 255, 0.12); 391 - border-radius: 6px; 392 - color: #ffffff; 393 - cursor: pointer; 394 - transition: all 0.15s ease; 395 - } 396 - 397 - .actions button:hover { 398 - background: rgba(255, 36, 0, 0.15); 399 - border-color: #ff2400; 400 - color: #ffffff; 401 - } 402 - 403 - .attestation-info { 404 - display: flex; 405 - flex-wrap: wrap; 406 - gap: 24px; 407 - font-size: 0.8rem; 408 - color: rgba(255, 255, 255, 0.7); 409 - text-transform: uppercase; 410 - letter-spacing: 0.05em; 411 - } 412 - 413 - .site-footer { 414 - max-width: 1000px; 415 - margin: 0 auto; 416 - padding: 48px 32px; 417 - display: flex; 418 - justify-content: space-between; 419 - font-size: 0.8rem; 420 - color: rgba(255, 255, 255, 0.65); 421 - text-transform: uppercase; 422 - letter-spacing: 0.05em; 423 - border-top: 1px solid rgba(255, 255, 255, 0.12); 424 - } 425 - 426 - ::selection { 427 - background: rgba(255, 36, 0, 0.4); 428 - } 429 - </style> 430 - </head> 431 - <body> 432 - 433 - <div class="pattern-container"> 434 - <div class="pattern"></div> 435 - </div> 436 - <div class="pattern-fade"></div> 437 - 438 - <nav> 439 - <span class="brand">Tranquil</span> 440 - <span class="nav-meta">0.1.0</span> 441 - </nav> 442 - 443 - <main> 444 - <article> 445 - <div class="meta"> 446 - <span class="category">Landing page</span> 447 - <span class="read-time">1 min read</span> 448 - </div> 449 - 450 - <h1>Lorem Ipsum Dolor Sit Amet Consectetur</h1> 451 - 452 - <div class="byline"> 453 - <div class="avatar"></div> 454 - <div class="author-info"> 455 - <span class="author">Mysterious benefactor</span> 456 - <span class="author-handle">@lewis.moe</span> 457 - </div> 458 - <div class="verification">47 attestations</div> 459 - </div> 460 - 461 - <div class="content"> 462 - <blockquote> 463 - <p>"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."</p> 464 - <cite>Cicero, De Finibus Bonorum et Malorum</cite> 465 - </blockquote> 466 - 467 - <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> 468 - 469 - <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> 470 - 471 - <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> 472 - 473 - <h2>Neque Porro Quisquam</h2> 474 - 475 - <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> 476 - 477 - <p>Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur.</p> 478 - 479 - <h2>Quis Autem Vel Eum</h2> 480 - 481 - <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> 482 - 483 - <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> 484 - 485 - <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> 486 - 487 - <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> 488 - 489 - <p>Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae.</p> 490 - 491 - <div class="carousel"> 492 - <div class="carousel-header"> 493 - <span class="carousel-title">Interface</span> 494 - <div class="carousel-nav"> 495 - <button class="carousel-prev">←</button> 496 - <button class="carousel-next">→</button> 497 - </div> 498 - </div> 499 - <div class="carousel-track"> 500 - <div class="carousel-slide"> 501 - <div class="placeholder-image">Dashboard goes here</div> 502 - <div class="carousel-label">Dashboard</div> 503 - </div> 504 - <div class="carousel-slide"> 505 - <div class="placeholder-image">Profile Settings go here</div> 506 - <div class="carousel-label">Profile Settings</div> 507 - </div> 508 - <div class="carousel-slide"> 509 - <div class="placeholder-image">Account Security goes here</div> 510 - <div class="carousel-label">Account Security</div> 511 - </div> 512 - <div class="carousel-slide"> 513 - <div class="placeholder-image">Repository Browser goes here</div> 514 - <div class="carousel-label">Repository Browser</div> 515 - </div> 516 - <div class="carousel-slide"> 517 - <div class="placeholder-image">OAuth Applications go here</div> 518 - <div class="carousel-label">OAuth Applications</div> 519 - </div> 520 - <div class="carousel-slide"> 521 - <div class="placeholder-image">Invite Codes go here</div> 522 - <div class="carousel-label">Invite Codes</div> 523 - </div> 524 - </div> 525 - </div> 526 - </div> 527 - 528 - <footer class="article-footer"> 529 - <div class="actions"> 530 - <button>Propagate</button> 531 - <button>Annotate</button> 532 - <button>Verify Source</button> 533 - </div> 534 - 535 - <div class="attestation-info"> 536 - <span>hash: 7f3a9c...</span> 537 - <span>signed: 2847.12.03</span> 538 - <span>nodes: 12,847</span> 539 - </div> 540 - </footer> 541 - </article> 542 - </main> 543 - 544 - <footer class="site-footer"> 545 - <div>Mesh Commons License</div> 546 - <div>node: local-7f3a</div> 547 - </footer> 548 - 549 - <script> 550 - const pattern = document.querySelector('.pattern'); 551 - const spacing = 32; 552 - const cols = Math.ceil((window.innerWidth + 600) / spacing); 553 - const rows = Math.ceil((window.innerHeight + 100) / spacing); 554 - const dots = []; 555 - 556 - for (let y = 0; y < rows; y++) { 557 - for (let x = 0; x < cols; x++) { 558 - const dot = document.createElement('div'); 559 - dot.className = 'dot'; 560 - dot.style.left = (x * spacing) + 'px'; 561 - dot.style.top = (y * spacing) + 'px'; 562 - pattern.appendChild(dot); 563 - dots.push({ el: dot, x: x * spacing, y: y * spacing }); 564 - } 565 - } 566 - 567 - let mouseX = -1000, mouseY = -1000; 568 - document.addEventListener('mousemove', e => { 569 - mouseX = e.clientX; 570 - mouseY = e.clientY; 571 - }); 572 - 573 - function updateDots() { 574 - const patternRect = pattern.getBoundingClientRect(); 575 - dots.forEach(dot => { 576 - const dotX = patternRect.left + dot.x + 5; 577 - const dotY = patternRect.top + dot.y + 5; 578 - const dist = Math.hypot(mouseX - dotX, mouseY - dotY); 579 - const maxDist = 120; 580 - const scale = Math.min(1, Math.max(0.1, dist / maxDist)); 581 - dot.el.style.transform = `scale(${scale})`; 582 - }); 583 - requestAnimationFrame(updateDots); 584 - } 585 - updateDots(); 586 - 587 - const track = document.querySelector('.carousel-track'); 588 - const prevBtn = document.querySelector('.carousel-prev'); 589 - const nextBtn = document.querySelector('.carousel-next'); 590 - const slideWidth = track?.querySelector('.carousel-slide')?.offsetWidth + 16; 591 - 592 - prevBtn?.addEventListener('click', () => { 593 - track.scrollBy({ left: -slideWidth, behavior: 'smooth' }); 594 - }); 595 - nextBtn?.addEventListener('click', () => { 596 - track.scrollBy({ left: slideWidth, behavior: 'smooth' }); 597 - }); 598 - 599 - let isDragging = false; 600 - let startX, scrollLeft; 601 - 602 - track?.addEventListener('mousedown', e => { 603 - isDragging = true; 604 - track.style.cursor = 'grabbing'; 605 - track.style.scrollSnapType = 'none'; 606 - startX = e.pageX - track.offsetLeft; 607 - scrollLeft = track.scrollLeft; 608 - }); 609 - 610 - track?.addEventListener('mouseleave', () => { 611 - isDragging = false; 612 - track.style.cursor = 'grab'; 613 - track.style.scrollSnapType = 'x mandatory'; 614 - }); 615 - 616 - function snapTo(target, duration = 120) { 617 - const start = track.scrollLeft; 618 - const distance = target - start; 619 - const startTime = performance.now(); 620 - function step(currentTime) { 621 - const elapsed = currentTime - startTime; 622 - const progress = Math.min(elapsed / duration, 1); 623 - const ease = 1 - Math.pow(1 - progress, 3); 624 - track.scrollLeft = start + distance * ease; 625 - if (progress < 1) requestAnimationFrame(step); 626 - else track.style.scrollSnapType = 'x mandatory'; 627 - } 628 - requestAnimationFrame(step); 629 - } 630 - 631 - track?.addEventListener('mouseup', () => { 632 - isDragging = false; 633 - track.style.cursor = 'grab'; 634 - const slideW = track.querySelector('.carousel-slide').offsetWidth + 16; 635 - const targetIndex = Math.round(track.scrollLeft / slideW); 636 - snapTo(targetIndex * slideW); 637 - }); 638 - 639 - track?.addEventListener('mousemove', e => { 640 - if (!isDragging) return; 641 - e.preventDefault(); 642 - const x = e.pageX - track.offsetLeft; 643 - const walk = (x - startX) * 1.5; 644 - track.scrollLeft = scrollLeft - walk; 645 - }); 646 - 647 - if (track) track.style.cursor = 'grab'; 648 - </script> 649 - </body> 650 - </html>
-679
frontend/mockups/02-normal-colors.html
··· 1 - <!DOCTYPE html> 2 - <html lang="en"> 3 - <head> 4 - <meta charset="UTF-8"> 5 - <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 - <title>Tranquil</title> 7 - <link rel="preconnect" href="https://fonts.googleapis.com"> 8 - <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 9 - <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700;800&display=swap" rel="stylesheet"> 10 - <style> 11 - * { margin: 0; padding: 0; box-sizing: border-box; } 12 - 13 - :root { 14 - --primary: #2c00ff; 15 - --primary-dark: #1a00a3; 16 - --primary-light: #4d33ff; 17 - --primary-muted: #e8e5ff; 18 - --secondary: #ff2400; 19 - --secondary-hover: #ff5533; 20 - --bg: #ffffff; 21 - --bg-subtle: #f8f8fa; 22 - --text: #1a1a1a; 23 - --text-muted: #666666; 24 - --text-light: #999999; 25 - --border: #e5e5e5; 26 - --border-light: #f0f0f0; 27 - } 28 - 29 - body { 30 - font-family: 'JetBrains Mono', monospace; 31 - line-height: 1.7; 32 - background: var(--bg); 33 - color: var(--text); 34 - min-height: 100vh; 35 - position: relative; 36 - } 37 - 38 - .pattern-container { 39 - position: fixed; 40 - top: -32px; 41 - left: -32px; 42 - right: -32px; 43 - bottom: -32px; 44 - pointer-events: none; 45 - z-index: 1; 46 - overflow: hidden; 47 - } 48 - 49 - .pattern { 50 - position: absolute; 51 - top: 0; 52 - left: 0; 53 - width: calc(100% + 500px); 54 - height: 100%; 55 - animation: drift 80s linear infinite; 56 - } 57 - 58 - .dot { 59 - position: absolute; 60 - width: 10px; 61 - height: 10px; 62 - background: rgba(0, 0, 0, 0.06); 63 - border-radius: 50%; 64 - transition: transform 0.04s linear; 65 - } 66 - 67 - .pattern-fade { 68 - position: fixed; 69 - top: 0; 70 - left: 0; 71 - right: 0; 72 - bottom: 0; 73 - background: linear-gradient(135deg, transparent 50%, var(--bg) 75%); 74 - pointer-events: none; 75 - z-index: 2; 76 - } 77 - 78 - @keyframes drift { 79 - 0% { transform: translateX(-500px); } 80 - 100% { transform: translateX(0); } 81 - } 82 - 83 - nav { z-index: 100; } 84 - main { position: relative; z-index: 10; } 85 - .site-footer { position: relative; z-index: 10; } 86 - 87 - a { color: var(--secondary); text-decoration: none; } 88 - a:hover { color: var(--secondary-hover); } 89 - 90 - nav { 91 - position: fixed; 92 - top: 12px; 93 - left: 32px; 94 - right: 32px; 95 - background: var(--primary); 96 - padding: 10px 18px; 97 - z-index: 100; 98 - border-radius: 8px; 99 - border: 1px solid rgba(0, 0, 0, 0.1); 100 - display: flex; 101 - justify-content: space-between; 102 - align-items: center; 103 - } 104 - 105 - nav .brand { 106 - font-weight: 600; 107 - font-size: 1rem; 108 - letter-spacing: 0.08em; 109 - color: #ffffff; 110 - text-transform: uppercase; 111 - } 112 - 113 - nav .nav-meta { 114 - font-size: 0.85rem; 115 - color: rgba(255, 255, 255, 0.7); 116 - letter-spacing: 0.05em; 117 - } 118 - 119 - main { 120 - max-width: 1000px; 121 - margin: 0 auto; 122 - padding: 100px 32px 80px; 123 - } 124 - 125 - .meta { 126 - display: flex; 127 - align-items: center; 128 - gap: 16px; 129 - margin-bottom: 32px; 130 - font-size: 0.8rem; 131 - font-weight: 500; 132 - text-transform: uppercase; 133 - letter-spacing: 0.1em; 134 - } 135 - 136 - .category { 137 - color: #ffffff; 138 - background: var(--primary); 139 - padding: 4px 10px; 140 - border-radius: 4px; 141 - } 142 - 143 - .read-time { 144 - color: var(--text-muted); 145 - } 146 - 147 - h1 { 148 - font-size: 2.75rem; 149 - font-weight: 600; 150 - line-height: 1.15; 151 - color: var(--text); 152 - margin-bottom: 32px; 153 - letter-spacing: -0.02em; 154 - } 155 - 156 - .byline { 157 - display: flex; 158 - align-items: center; 159 - gap: 16px; 160 - padding: 24px 0; 161 - border-top: 1px solid var(--border); 162 - border-bottom: 1px solid var(--border); 163 - margin-bottom: 48px; 164 - } 165 - 166 - .avatar { 167 - width: 44px; 168 - height: 44px; 169 - border-radius: 50%; 170 - background: linear-gradient(135deg, var(--secondary) 0%, #ff6b4a 100%); 171 - } 172 - 173 - .author-info { 174 - flex: 1; 175 - } 176 - 177 - .author { 178 - display: block; 179 - font-weight: 500; 180 - color: var(--text); 181 - font-size: 1rem; 182 - } 183 - 184 - .author-handle { 185 - display: block; 186 - font-size: 0.85rem; 187 - color: var(--text-muted); 188 - margin-top: 2px; 189 - } 190 - 191 - .verification { 192 - font-size: 0.75rem; 193 - font-weight: 500; 194 - color: var(--secondary); 195 - text-transform: uppercase; 196 - letter-spacing: 0.08em; 197 - } 198 - 199 - .placeholder-image { 200 - aspect-ratio: 16 / 9; 201 - background: var(--bg-subtle); 202 - border-radius: 8px; 203 - display: flex; 204 - align-items: center; 205 - justify-content: center; 206 - font-size: 0.9rem; 207 - color: var(--text-light); 208 - text-transform: uppercase; 209 - letter-spacing: 0.1em; 210 - border: 1px solid var(--border); 211 - } 212 - 213 - figcaption { 214 - margin-top: 12px; 215 - font-size: 0.85rem; 216 - color: var(--text-muted); 217 - text-align: center; 218 - } 219 - 220 - .carousel { 221 - margin: 64px 0 0; 222 - } 223 - 224 - .carousel-header { 225 - display: flex; 226 - justify-content: space-between; 227 - align-items: center; 228 - margin-bottom: 20px; 229 - } 230 - 231 - .carousel-title { 232 - font-size: 0.85rem; 233 - font-weight: 600; 234 - text-transform: uppercase; 235 - letter-spacing: 0.1em; 236 - color: var(--text); 237 - } 238 - 239 - .carousel-nav { 240 - display: flex; 241 - gap: 8px; 242 - } 243 - 244 - .carousel-nav button { 245 - font-family: 'JetBrains Mono', monospace; 246 - width: 36px; 247 - height: 36px; 248 - background: var(--bg); 249 - border: 1px solid var(--border); 250 - border-radius: 6px; 251 - color: var(--text); 252 - cursor: pointer; 253 - transition: all 0.15s ease; 254 - font-size: 1rem; 255 - } 256 - 257 - .carousel-nav button:hover { 258 - background: rgba(255, 36, 0, 0.08); 259 - border-color: var(--secondary); 260 - color: var(--secondary); 261 - } 262 - 263 - .carousel-track { 264 - display: flex; 265 - gap: 16px; 266 - overflow-x: auto; 267 - scroll-snap-type: x mandatory; 268 - scrollbar-width: none; 269 - -ms-overflow-style: none; 270 - padding-bottom: 8px; 271 - -webkit-overflow-scrolling: touch; 272 - user-select: none; 273 - } 274 - 275 - .carousel-track::-webkit-scrollbar { 276 - display: none; 277 - } 278 - 279 - .carousel-slide { 280 - flex: 0 0 70%; 281 - scroll-snap-align: start; 282 - } 283 - 284 - .carousel-slide .placeholder-image { 285 - aspect-ratio: 16 / 10; 286 - } 287 - 288 - .carousel-label { 289 - margin-top: 12px; 290 - font-size: 0.8rem; 291 - font-weight: 500; 292 - color: var(--text-muted); 293 - text-transform: uppercase; 294 - letter-spacing: 0.08em; 295 - } 296 - 297 - .content { 298 - font-size: 1.05rem; 299 - font-weight: 400; 300 - } 301 - 302 - .content p { 303 - margin-bottom: 28px; 304 - } 305 - 306 - .lede { 307 - font-size: 1.3rem; 308 - font-weight: 500; 309 - color: var(--text); 310 - line-height: 1.5; 311 - } 312 - 313 - .content h2 { 314 - font-size: 0.9rem; 315 - font-weight: 600; 316 - text-transform: uppercase; 317 - letter-spacing: 0.1em; 318 - color: var(--primary-dark); 319 - margin: 56px 0 24px; 320 - } 321 - 322 - blockquote { 323 - margin: 40px 0; 324 - padding: 32px; 325 - background: var(--primary-muted); 326 - border-left: 3px solid var(--primary); 327 - border-radius: 0 8px 8px 0; 328 - } 329 - 330 - blockquote p { 331 - font-size: 1.15rem; 332 - color: var(--primary-dark); 333 - font-style: italic; 334 - margin-bottom: 16px !important; 335 - } 336 - 337 - blockquote cite { 338 - font-size: 0.8rem; 339 - color: var(--text-muted); 340 - font-style: normal; 341 - text-transform: uppercase; 342 - letter-spacing: 0.05em; 343 - } 344 - 345 - .context-panel { 346 - margin: 40px 0; 347 - padding: 24px; 348 - background: var(--bg-subtle); 349 - border-radius: 8px; 350 - border: 1px solid var(--border); 351 - } 352 - 353 - .context-panel h3 { 354 - font-size: 0.8rem; 355 - font-weight: 600; 356 - text-transform: uppercase; 357 - letter-spacing: 0.1em; 358 - color: var(--text); 359 - margin-bottom: 16px; 360 - } 361 - 362 - .context-panel ul { 363 - list-style: none; 364 - } 365 - 366 - .context-panel li { 367 - padding: 10px 0; 368 - border-bottom: 1px solid var(--border-light); 369 - } 370 - 371 - .context-panel li:last-child { 372 - border-bottom: none; 373 - } 374 - 375 - .context-panel a { 376 - font-size: 0.95rem; 377 - font-weight: 500; 378 - color: var(--secondary); 379 - text-decoration: none; 380 - transition: color 0.15s ease; 381 - } 382 - 383 - .context-panel a:hover { 384 - color: var(--secondary-hover); 385 - } 386 - 387 - .article-footer { 388 - margin-top: 64px; 389 - padding-top: 32px; 390 - border-top: 1px solid var(--border); 391 - } 392 - 393 - .actions { 394 - display: flex; 395 - gap: 12px; 396 - margin-bottom: 24px; 397 - } 398 - 399 - .actions button { 400 - font-family: 'JetBrains Mono', monospace; 401 - font-size: 0.85rem; 402 - font-weight: 500; 403 - text-transform: uppercase; 404 - letter-spacing: 0.06em; 405 - padding: 14px 24px; 406 - background: var(--bg); 407 - border: 1px solid var(--border); 408 - border-radius: 6px; 409 - color: var(--text); 410 - cursor: pointer; 411 - transition: all 0.15s ease; 412 - } 413 - 414 - .actions button:hover { 415 - background: rgba(255, 36, 0, 0.08); 416 - border-color: var(--secondary); 417 - color: var(--secondary); 418 - } 419 - 420 - .actions button:first-child { 421 - background: var(--secondary); 422 - border-color: var(--secondary); 423 - color: #ffffff; 424 - } 425 - 426 - .actions button:first-child:hover { 427 - background: #cc1d00; 428 - border-color: #cc1d00; 429 - } 430 - 431 - .attestation-info { 432 - display: flex; 433 - flex-wrap: wrap; 434 - gap: 24px; 435 - font-size: 0.8rem; 436 - color: var(--text-light); 437 - text-transform: uppercase; 438 - letter-spacing: 0.05em; 439 - } 440 - 441 - .site-footer { 442 - max-width: 1000px; 443 - margin: 0 auto; 444 - padding: 48px 32px; 445 - display: flex; 446 - justify-content: space-between; 447 - font-size: 0.8rem; 448 - color: var(--text-light); 449 - text-transform: uppercase; 450 - letter-spacing: 0.05em; 451 - border-top: 1px solid var(--border); 452 - } 453 - 454 - ::selection { 455 - background: rgba(255, 36, 0, 0.2); 456 - color: var(--text); 457 - } 458 - </style> 459 - </head> 460 - <body> 461 - 462 - <div class="pattern-container"> 463 - <div class="pattern"></div> 464 - </div> 465 - <div class="pattern-fade"></div> 466 - 467 - <nav> 468 - <span class="brand">Tranquil PDS</span> 469 - <span class="nav-meta">0.1.0</span> 470 - </nav> 471 - 472 - <main> 473 - <article> 474 - <div class="meta"> 475 - <span class="category">Landing page</span> 476 - <span class="read-time">1 min read</span> 477 - </div> 478 - 479 - <h1>Lorem Ipsum Dolor Sit Amet Consectetur</h1> 480 - 481 - <div class="byline"> 482 - <div class="avatar"></div> 483 - <div class="author-info"> 484 - <span class="author">Mysterious benefactor</span> 485 - <span class="author-handle">@lewis.moe</span> 486 - </div> 487 - <div class="verification">47 attestations</div> 488 - </div> 489 - 490 - <div class="content"> 491 - <blockquote> 492 - <p>"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua."</p> 493 - <cite>Cicero, De Finibus Bonorum et Malorum</cite> 494 - </blockquote> 495 - 496 - <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> 497 - 498 - <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> 499 - 500 - <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> 501 - 502 - <h2>Neque Porro Quisquam</h2> 503 - 504 - <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> 505 - 506 - <p>Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur.</p> 507 - 508 - <h2>Quis Autem Vel Eum</h2> 509 - 510 - <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> 511 - 512 - <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> 513 - 514 - <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> 515 - 516 - <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> 517 - 518 - <p>Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae.</p> 519 - 520 - <div class="carousel"> 521 - <div class="carousel-header"> 522 - <span class="carousel-title">Interface</span> 523 - <div class="carousel-nav"> 524 - <button class="carousel-prev">←</button> 525 - <button class="carousel-next">→</button> 526 - </div> 527 - </div> 528 - <div class="carousel-track"> 529 - <div class="carousel-slide"> 530 - <div class="placeholder-image">Dashboard goes here</div> 531 - <div class="carousel-label">Dashboard</div> 532 - </div> 533 - <div class="carousel-slide"> 534 - <div class="placeholder-image">Profile Settings go here</div> 535 - <div class="carousel-label">Profile Settings</div> 536 - </div> 537 - <div class="carousel-slide"> 538 - <div class="placeholder-image">Account Security goes here</div> 539 - <div class="carousel-label">Account Security</div> 540 - </div> 541 - <div class="carousel-slide"> 542 - <div class="placeholder-image">Repository Browser goes here</div> 543 - <div class="carousel-label">Repository Browser</div> 544 - </div> 545 - <div class="carousel-slide"> 546 - <div class="placeholder-image">OAuth Applications goes here</div> 547 - <div class="carousel-label">OAuth Applications</div> 548 - </div> 549 - <div class="carousel-slide"> 550 - <div class="placeholder-image">Invite Codes goes here</div> 551 - <div class="carousel-label">Invite Codes</div> 552 - </div> 553 - </div> 554 - </div> 555 - </div> 556 - 557 - <footer class="article-footer"> 558 - <div class="actions"> 559 - <button>Propagate</button> 560 - <button>Annotate</button> 561 - <button>Verify Source</button> 562 - </div> 563 - 564 - <div class="attestation-info"> 565 - <span>hash: 7f3a9c...</span> 566 - <span>signed: 2847.12.03</span> 567 - <span>nodes: 12,847</span> 568 - </div> 569 - </footer> 570 - </article> 571 - </main> 572 - 573 - <footer class="site-footer"> 574 - <div>Mesh Commons License</div> 575 - <div>node: local-7f3a</div> 576 - </footer> 577 - 578 - <script> 579 - const pattern = document.querySelector('.pattern'); 580 - const spacing = 32; 581 - const cols = Math.ceil((window.innerWidth + 600) / spacing); 582 - const rows = Math.ceil((window.innerHeight + 100) / spacing); 583 - const dots = []; 584 - 585 - for (let y = 0; y < rows; y++) { 586 - for (let x = 0; x < cols; x++) { 587 - const dot = document.createElement('div'); 588 - dot.className = 'dot'; 589 - dot.style.left = (x * spacing) + 'px'; 590 - dot.style.top = (y * spacing) + 'px'; 591 - pattern.appendChild(dot); 592 - dots.push({ el: dot, x: x * spacing, y: y * spacing }); 593 - } 594 - } 595 - 596 - let mouseX = -1000, mouseY = -1000; 597 - document.addEventListener('mousemove', e => { 598 - mouseX = e.clientX; 599 - mouseY = e.clientY; 600 - }); 601 - 602 - function updateDots() { 603 - const patternRect = pattern.getBoundingClientRect(); 604 - dots.forEach(dot => { 605 - const dotX = patternRect.left + dot.x + 5; 606 - const dotY = patternRect.top + dot.y + 5; 607 - const dist = Math.hypot(mouseX - dotX, mouseY - dotY); 608 - const maxDist = 120; 609 - const scale = Math.min(1, Math.max(0.1, dist / maxDist)); 610 - dot.el.style.transform = `scale(${scale})`; 611 - }); 612 - requestAnimationFrame(updateDots); 613 - } 614 - updateDots(); 615 - 616 - const track = document.querySelector('.carousel-track'); 617 - const prevBtn = document.querySelector('.carousel-prev'); 618 - const nextBtn = document.querySelector('.carousel-next'); 619 - const slideWidth = track?.querySelector('.carousel-slide')?.offsetWidth + 16; 620 - 621 - prevBtn?.addEventListener('click', () => { 622 - track.scrollBy({ left: -slideWidth, behavior: 'smooth' }); 623 - }); 624 - nextBtn?.addEventListener('click', () => { 625 - track.scrollBy({ left: slideWidth, behavior: 'smooth' }); 626 - }); 627 - 628 - let isDragging = false; 629 - let startX, scrollLeft; 630 - 631 - track?.addEventListener('mousedown', e => { 632 - isDragging = true; 633 - track.style.cursor = 'grabbing'; 634 - track.style.scrollSnapType = 'none'; 635 - startX = e.pageX - track.offsetLeft; 636 - scrollLeft = track.scrollLeft; 637 - }); 638 - 639 - track?.addEventListener('mouseleave', () => { 640 - isDragging = false; 641 - track.style.cursor = 'grab'; 642 - track.style.scrollSnapType = 'x mandatory'; 643 - }); 644 - 645 - function snapTo(target, duration = 120) { 646 - const start = track.scrollLeft; 647 - const distance = target - start; 648 - const startTime = performance.now(); 649 - function step(currentTime) { 650 - const elapsed = currentTime - startTime; 651 - const progress = Math.min(elapsed / duration, 1); 652 - const ease = 1 - Math.pow(1 - progress, 3); 653 - track.scrollLeft = start + distance * ease; 654 - if (progress < 1) requestAnimationFrame(step); 655 - else track.style.scrollSnapType = 'x mandatory'; 656 - } 657 - requestAnimationFrame(step); 658 - } 659 - 660 - track?.addEventListener('mouseup', () => { 661 - isDragging = false; 662 - track.style.cursor = 'grab'; 663 - const slideW = track.querySelector('.carousel-slide').offsetWidth + 16; 664 - const targetIndex = Math.round(track.scrollLeft / slideW); 665 - snapTo(targetIndex * slideW); 666 - }); 667 - 668 - track?.addEventListener('mousemove', e => { 669 - if (!isDragging) return; 670 - e.preventDefault(); 671 - const x = e.pageX - track.offsetLeft; 672 - const walk = (x - startX) * 1.5; 673 - track.scrollLeft = scrollLeft - walk; 674 - }); 675 - 676 - if (track) track.style.cursor = 'grab'; 677 - </script> 678 - </body> 679 - </html>
-714
frontend/mockups/03-landing-page.html
··· 1 - <!DOCTYPE html> 2 - <html lang="en"> 3 - <head> 4 - <meta charset="UTF-8"> 5 - <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 - <title>Tranquil</title> 7 - <link rel="preconnect" href="https://fonts.googleapis.com"> 8 - <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 9 - <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700;800&display=swap" rel="stylesheet"> 10 - <style> 11 - * { margin: 0; padding: 0; box-sizing: border-box; } 12 - 13 - :root { 14 - --primary: #2c00ff; 15 - --primary-dark: #1a00a3; 16 - --primary-light: #4d33ff; 17 - --primary-muted: #e8e5ff; 18 - --secondary: #ff2400; 19 - --secondary-hover: #ff5533; 20 - --bg: #ffffff; 21 - --bg-subtle: #f8f8fa; 22 - --text: #1a1a1a; 23 - --text-muted: #666666; 24 - --text-light: #999999; 25 - --border: #e5e5e5; 26 - --border-light: #f0f0f0; 27 - } 28 - 29 - body { 30 - font-family: 'JetBrains Mono', monospace; 31 - line-height: 1.7; 32 - background: var(--bg); 33 - color: var(--text); 34 - min-height: 100vh; 35 - position: relative; 36 - } 37 - 38 - .pattern-container { 39 - position: fixed; 40 - top: -32px; 41 - left: -32px; 42 - right: -32px; 43 - bottom: -32px; 44 - pointer-events: none; 45 - z-index: 1; 46 - overflow: hidden; 47 - } 48 - 49 - .pattern { 50 - position: absolute; 51 - top: 0; 52 - left: 0; 53 - width: calc(100% + 500px); 54 - height: 100%; 55 - animation: drift 80s linear infinite; 56 - } 57 - 58 - .dot { 59 - position: absolute; 60 - width: 10px; 61 - height: 10px; 62 - background: rgba(0, 0, 0, 0.06); 63 - border-radius: 50%; 64 - transition: transform 0.04s linear; 65 - } 66 - 67 - .pattern-fade { 68 - position: fixed; 69 - top: 0; 70 - left: 0; 71 - right: 0; 72 - bottom: 0; 73 - background: linear-gradient(135deg, transparent 50%, var(--bg) 75%); 74 - pointer-events: none; 75 - z-index: 2; 76 - } 77 - 78 - @keyframes drift { 79 - 0% { transform: translateX(-500px); } 80 - 100% { transform: translateX(0); } 81 - } 82 - 83 - nav { z-index: 100; } 84 - main { position: relative; z-index: 10; } 85 - .site-footer { position: relative; z-index: 10; } 86 - 87 - a { color: var(--secondary); text-decoration: none; } 88 - a:hover { color: var(--secondary-hover); } 89 - 90 - nav { 91 - position: fixed; 92 - top: 12px; 93 - left: 32px; 94 - right: 32px; 95 - background: var(--primary); 96 - padding: 10px 18px; 97 - z-index: 100; 98 - border-radius: 8px; 99 - border: 1px solid rgba(0, 0, 0, 0.1); 100 - display: flex; 101 - justify-content: space-between; 102 - align-items: center; 103 - } 104 - 105 - nav .brand { 106 - font-weight: 600; 107 - font-size: 1rem; 108 - letter-spacing: 0.08em; 109 - color: #ffffff; 110 - text-transform: uppercase; 111 - } 112 - 113 - nav .nav-meta { 114 - font-size: 0.85rem; 115 - color: rgba(255, 255, 255, 0.7); 116 - letter-spacing: 0.05em; 117 - } 118 - 119 - main { 120 - max-width: 1000px; 121 - margin: 0 auto; 122 - padding: 72px 32px 80px; 123 - } 124 - 125 - .meta { 126 - display: flex; 127 - align-items: center; 128 - gap: 16px; 129 - margin-bottom: 32px; 130 - font-size: 0.8rem; 131 - font-weight: 500; 132 - text-transform: uppercase; 133 - letter-spacing: 0.1em; 134 - } 135 - 136 - .category { 137 - color: #ffffff; 138 - background: var(--primary); 139 - padding: 4px 10px; 140 - border-radius: 4px; 141 - } 142 - 143 - .read-time { 144 - color: var(--text-muted); 145 - } 146 - 147 - h1 { 148 - font-size: 2.75rem; 149 - font-weight: 600; 150 - line-height: 1.15; 151 - color: var(--text); 152 - margin-bottom: 32px; 153 - letter-spacing: -0.02em; 154 - } 155 - 156 - .byline { 157 - display: flex; 158 - align-items: center; 159 - gap: 16px; 160 - padding: 24px 0; 161 - border-top: 1px solid var(--border); 162 - border-bottom: 1px solid var(--border); 163 - margin-bottom: 48px; 164 - } 165 - 166 - .avatar { 167 - width: 44px; 168 - height: 44px; 169 - border-radius: 50%; 170 - background: linear-gradient(135deg, var(--secondary) 0%, #ff6b4a 100%); 171 - } 172 - 173 - .author-info { 174 - flex: 1; 175 - } 176 - 177 - .author { 178 - display: block; 179 - font-weight: 500; 180 - color: var(--text); 181 - font-size: 1rem; 182 - } 183 - 184 - .author-handle { 185 - display: block; 186 - font-size: 0.85rem; 187 - color: var(--text-muted); 188 - margin-top: 2px; 189 - } 190 - 191 - .verification { 192 - font-size: 0.75rem; 193 - font-weight: 500; 194 - color: var(--secondary); 195 - text-transform: uppercase; 196 - letter-spacing: 0.08em; 197 - } 198 - 199 - .placeholder-image { 200 - aspect-ratio: 16 / 9; 201 - background: var(--bg-subtle); 202 - border-radius: 8px; 203 - display: flex; 204 - align-items: center; 205 - justify-content: center; 206 - font-size: 0.9rem; 207 - color: var(--text-light); 208 - text-transform: uppercase; 209 - letter-spacing: 0.1em; 210 - border: 1px solid var(--border); 211 - } 212 - 213 - figcaption { 214 - margin-top: 12px; 215 - font-size: 0.85rem; 216 - color: var(--text-muted); 217 - text-align: center; 218 - } 219 - 220 - .carousel { 221 - margin: 64px 0 0; 222 - } 223 - 224 - .carousel-header { 225 - display: flex; 226 - justify-content: space-between; 227 - align-items: center; 228 - margin-bottom: 20px; 229 - } 230 - 231 - .carousel-title { 232 - font-size: 0.85rem; 233 - font-weight: 600; 234 - text-transform: uppercase; 235 - letter-spacing: 0.1em; 236 - color: var(--text); 237 - } 238 - 239 - .carousel-nav { 240 - display: flex; 241 - gap: 8px; 242 - } 243 - 244 - .carousel-nav button { 245 - font-family: 'JetBrains Mono', monospace; 246 - width: 36px; 247 - height: 36px; 248 - background: var(--bg); 249 - border: 1px solid var(--border); 250 - border-radius: 6px; 251 - color: var(--text); 252 - cursor: pointer; 253 - transition: all 0.15s ease; 254 - font-size: 1rem; 255 - } 256 - 257 - .carousel-nav button:hover { 258 - background: rgba(255, 36, 0, 0.08); 259 - border-color: var(--secondary); 260 - color: var(--secondary); 261 - } 262 - 263 - .carousel-track { 264 - display: flex; 265 - gap: 16px; 266 - overflow-x: auto; 267 - scroll-snap-type: x mandatory; 268 - scrollbar-width: none; 269 - -ms-overflow-style: none; 270 - padding-bottom: 8px; 271 - -webkit-overflow-scrolling: touch; 272 - user-select: none; 273 - } 274 - 275 - .carousel-track::-webkit-scrollbar { 276 - display: none; 277 - } 278 - 279 - .carousel-slide { 280 - flex: 0 0 70%; 281 - scroll-snap-align: start; 282 - } 283 - 284 - .carousel-slide .placeholder-image { 285 - aspect-ratio: 16 / 10; 286 - } 287 - 288 - .carousel-label { 289 - margin-top: 12px; 290 - font-size: 0.8rem; 291 - font-weight: 500; 292 - color: var(--text-muted); 293 - text-transform: uppercase; 294 - letter-spacing: 0.08em; 295 - } 296 - 297 - .content { 298 - font-size: 1.05rem; 299 - font-weight: 400; 300 - } 301 - 302 - .content p { 303 - margin-bottom: 28px; 304 - } 305 - 306 - .lede { 307 - font-size: 1.3rem; 308 - font-weight: 500; 309 - color: var(--text); 310 - line-height: 1.5; 311 - } 312 - 313 - .hero { 314 - padding: 32px 0 40px; 315 - border-bottom: 1px solid var(--border); 316 - margin-bottom: 40px; 317 - } 318 - 319 - .content h2 { 320 - font-size: 0.9rem; 321 - font-weight: 600; 322 - text-transform: uppercase; 323 - letter-spacing: 0.1em; 324 - color: var(--primary-dark); 325 - margin: 56px 0 24px; 326 - } 327 - 328 - .content h2:first-child { 329 - margin-top: 0; 330 - } 331 - 332 - .features { 333 - display: grid; 334 - grid-template-columns: repeat(2, 1fr); 335 - gap: 32px; 336 - margin: 32px 0 56px; 337 - } 338 - 339 - .feature { 340 - padding: 24px; 341 - background: var(--bg-subtle); 342 - border-radius: 8px; 343 - border: 1px solid var(--border); 344 - } 345 - 346 - .feature h3 { 347 - font-size: 1rem; 348 - font-weight: 600; 349 - color: var(--text); 350 - margin-bottom: 12px; 351 - } 352 - 353 - .feature p { 354 - font-size: 0.95rem; 355 - color: var(--text-muted); 356 - margin-bottom: 0; 357 - line-height: 1.6; 358 - } 359 - 360 - @media (max-width: 700px) { 361 - .features { 362 - grid-template-columns: 1fr; 363 - } 364 - } 365 - 366 - blockquote { 367 - margin: 40px 0; 368 - padding: 32px; 369 - background: var(--primary-muted); 370 - border-left: 3px solid var(--primary); 371 - border-radius: 0 8px 8px 0; 372 - } 373 - 374 - blockquote p { 375 - font-size: 1.15rem; 376 - color: var(--primary-dark); 377 - font-style: italic; 378 - margin-bottom: 16px !important; 379 - } 380 - 381 - blockquote cite { 382 - font-size: 0.8rem; 383 - color: var(--text-muted); 384 - font-style: normal; 385 - text-transform: uppercase; 386 - letter-spacing: 0.05em; 387 - } 388 - 389 - .context-panel { 390 - margin: 40px 0; 391 - padding: 24px; 392 - background: var(--bg-subtle); 393 - border-radius: 8px; 394 - border: 1px solid var(--border); 395 - } 396 - 397 - .context-panel h3 { 398 - font-size: 0.8rem; 399 - font-weight: 600; 400 - text-transform: uppercase; 401 - letter-spacing: 0.1em; 402 - color: var(--text); 403 - margin-bottom: 16px; 404 - } 405 - 406 - .context-panel ul { 407 - list-style: none; 408 - } 409 - 410 - .context-panel li { 411 - padding: 10px 0; 412 - border-bottom: 1px solid var(--border-light); 413 - } 414 - 415 - .context-panel li:last-child { 416 - border-bottom: none; 417 - } 418 - 419 - .context-panel a { 420 - font-size: 0.95rem; 421 - font-weight: 500; 422 - color: var(--secondary); 423 - text-decoration: none; 424 - transition: color 0.15s ease; 425 - } 426 - 427 - .context-panel a:hover { 428 - color: var(--secondary-hover); 429 - } 430 - 431 - .article-footer { 432 - margin-top: 64px; 433 - padding-top: 32px; 434 - border-top: 1px solid var(--border); 435 - } 436 - 437 - .actions { 438 - display: flex; 439 - gap: 12px; 440 - margin-bottom: 24px; 441 - } 442 - 443 - .actions button { 444 - font-family: 'JetBrains Mono', monospace; 445 - font-size: 0.85rem; 446 - font-weight: 500; 447 - text-transform: uppercase; 448 - letter-spacing: 0.06em; 449 - padding: 14px 24px; 450 - background: var(--bg); 451 - border: 1px solid var(--border); 452 - border-radius: 6px; 453 - color: var(--text); 454 - cursor: pointer; 455 - transition: all 0.15s ease; 456 - } 457 - 458 - .actions button:hover { 459 - background: rgba(255, 36, 0, 0.08); 460 - border-color: var(--secondary); 461 - color: var(--secondary); 462 - } 463 - 464 - .actions button:first-child { 465 - background: var(--secondary); 466 - border-color: var(--secondary); 467 - color: #ffffff; 468 - } 469 - 470 - .actions button:first-child:hover { 471 - background: #cc1d00; 472 - border-color: #cc1d00; 473 - } 474 - 475 - .attestation-info { 476 - display: flex; 477 - flex-wrap: wrap; 478 - gap: 24px; 479 - font-size: 0.8rem; 480 - color: var(--text-light); 481 - text-transform: uppercase; 482 - letter-spacing: 0.05em; 483 - } 484 - 485 - .site-footer { 486 - max-width: 1000px; 487 - margin: 0 auto; 488 - padding: 48px 32px; 489 - display: flex; 490 - justify-content: space-between; 491 - font-size: 0.8rem; 492 - color: var(--text-light); 493 - text-transform: uppercase; 494 - letter-spacing: 0.05em; 495 - border-top: 1px solid var(--border); 496 - } 497 - 498 - ::selection { 499 - background: rgba(255, 36, 0, 0.2); 500 - color: var(--text); 501 - } 502 - </style> 503 - </head> 504 - <body> 505 - 506 - <div class="pattern-container"> 507 - <div class="pattern"></div> 508 - </div> 509 - <div class="pattern-fade"></div> 510 - 511 - <nav> 512 - <span class="brand">Tranquil PDS</span> 513 - <span class="nav-meta">0.1.0</span> 514 - </nav> 515 - 516 - <main> 517 - <section class="hero"> 518 - <h1>A home for your ATProto account</h1> 519 - 520 - <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> 521 - 522 - <div class="actions" style="margin-top: 40px; margin-bottom: 0;"> 523 - <button>Join This Server</button> 524 - <button>Run Your Own</button> 525 - </div> 526 - <blockquote> 527 - <p>"Nature does not hurry, yet everything is accomplished."</p> 528 - <cite>Lao Tzu</cite> 529 - </blockquote> 530 - </section> 531 - 532 - <section class="content"> 533 - <h2>What you get</h2> 534 - 535 - <div class="features"> 536 - <div class="feature"> 537 - <h3>Real security</h3> 538 - <p>Sign in with passkeys, add two-factor authentication, set up backup codes, and mark devices you trust. Your account stays yours.</p> 539 - </div> 540 - 541 - <div class="feature"> 542 - <h3>Your own identity</h3> 543 - <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> 544 - </div> 545 - 546 - <div class="feature"> 547 - <h3>Stay in the loop</h3> 548 - <p>Get important alerts where you actually see them: email, Discord, Telegram, or Signal.</p> 549 - </div> 550 - 551 - <div class="feature"> 552 - <h3>You decide what apps can do</h3> 553 - <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> 554 - </div> 555 - </div> 556 - 557 - <h2>Everything in one place</h2> 558 - 559 - <p>Manage your profile, security settings, connected apps, and more from a clean dashboard. No command line or 3rd party apps required.</p> 560 - 561 - <div class="carousel"> 562 - <div class="carousel-header"> 563 - <span class="carousel-title">Interface</span> 564 - <div class="carousel-nav"> 565 - <button class="carousel-prev">←</button> 566 - <button class="carousel-next">→</button> 567 - </div> 568 - </div> 569 - <div class="carousel-track"> 570 - <div class="carousel-slide"> 571 - <div class="placeholder-image">Dashboard</div> 572 - <div class="carousel-label">Dashboard</div> 573 - </div> 574 - <div class="carousel-slide"> 575 - <div class="placeholder-image">Profile Settings</div> 576 - <div class="carousel-label">Profile Settings</div> 577 - </div> 578 - <div class="carousel-slide"> 579 - <div class="placeholder-image">Account Security</div> 580 - <div class="carousel-label">Account Security</div> 581 - </div> 582 - <div class="carousel-slide"> 583 - <div class="placeholder-image">Connected Apps</div> 584 - <div class="carousel-label">Connected Apps</div> 585 - </div> 586 - <div class="carousel-slide"> 587 - <div class="placeholder-image">Invite Friends</div> 588 - <div class="carousel-label">Invite Friends</div> 589 - </div> 590 - </div> 591 - </div> 592 - 593 - <h2>Works with everything</h2> 594 - 595 - <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> 596 - 597 - <h2>Ready to try it?</h2> 598 - 599 - <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> 600 - 601 - <div class="actions" style="margin-top: 32px;"> 602 - <button>Join This Server</button> 603 - <button>View Source</button> 604 - </div> 605 - </section> 606 - </main> 607 - 608 - <footer class="site-footer"> 609 - <div>Open Source</div> 610 - <div>Made with care</div> 611 - </footer> 612 - 613 - <script> 614 - const pattern = document.querySelector('.pattern'); 615 - const spacing = 32; 616 - const cols = Math.ceil((window.innerWidth + 600) / spacing); 617 - const rows = Math.ceil((window.innerHeight + 100) / spacing); 618 - const dots = []; 619 - 620 - for (let y = 0; y < rows; y++) { 621 - for (let x = 0; x < cols; x++) { 622 - const dot = document.createElement('div'); 623 - dot.className = 'dot'; 624 - dot.style.left = (x * spacing) + 'px'; 625 - dot.style.top = (y * spacing) + 'px'; 626 - pattern.appendChild(dot); 627 - dots.push({ el: dot, x: x * spacing, y: y * spacing }); 628 - } 629 - } 630 - 631 - let mouseX = -1000, mouseY = -1000; 632 - document.addEventListener('mousemove', e => { 633 - mouseX = e.clientX; 634 - mouseY = e.clientY; 635 - }); 636 - 637 - function updateDots() { 638 - const patternRect = pattern.getBoundingClientRect(); 639 - dots.forEach(dot => { 640 - const dotX = patternRect.left + dot.x + 5; 641 - const dotY = patternRect.top + dot.y + 5; 642 - const dist = Math.hypot(mouseX - dotX, mouseY - dotY); 643 - const maxDist = 120; 644 - const scale = Math.min(1, Math.max(0.1, dist / maxDist)); 645 - dot.el.style.transform = `scale(${scale})`; 646 - }); 647 - requestAnimationFrame(updateDots); 648 - } 649 - updateDots(); 650 - 651 - const track = document.querySelector('.carousel-track'); 652 - const prevBtn = document.querySelector('.carousel-prev'); 653 - const nextBtn = document.querySelector('.carousel-next'); 654 - const slideWidth = track?.querySelector('.carousel-slide')?.offsetWidth + 16; 655 - 656 - prevBtn?.addEventListener('click', () => { 657 - track.scrollBy({ left: -slideWidth, behavior: 'smooth' }); 658 - }); 659 - nextBtn?.addEventListener('click', () => { 660 - track.scrollBy({ left: slideWidth, behavior: 'smooth' }); 661 - }); 662 - 663 - let isDragging = false; 664 - let startX, scrollLeft; 665 - 666 - track?.addEventListener('mousedown', e => { 667 - isDragging = true; 668 - track.style.cursor = 'grabbing'; 669 - track.style.scrollSnapType = 'none'; 670 - startX = e.pageX - track.offsetLeft; 671 - scrollLeft = track.scrollLeft; 672 - }); 673 - 674 - track?.addEventListener('mouseleave', () => { 675 - isDragging = false; 676 - track.style.cursor = 'grab'; 677 - track.style.scrollSnapType = 'x mandatory'; 678 - }); 679 - 680 - function snapTo(target, duration = 120) { 681 - const start = track.scrollLeft; 682 - const distance = target - start; 683 - const startTime = performance.now(); 684 - function step(currentTime) { 685 - const elapsed = currentTime - startTime; 686 - const progress = Math.min(elapsed / duration, 1); 687 - const ease = 1 - Math.pow(1 - progress, 3); 688 - track.scrollLeft = start + distance * ease; 689 - if (progress < 1) requestAnimationFrame(step); 690 - else track.style.scrollSnapType = 'x mandatory'; 691 - } 692 - requestAnimationFrame(step); 693 - } 694 - 695 - track?.addEventListener('mouseup', () => { 696 - isDragging = false; 697 - track.style.cursor = 'grab'; 698 - const slideW = track.querySelector('.carousel-slide').offsetWidth + 16; 699 - const targetIndex = Math.round(track.scrollLeft / slideW); 700 - snapTo(targetIndex * slideW); 701 - }); 702 - 703 - track?.addEventListener('mousemove', e => { 704 - if (!isDragging) return; 705 - e.preventDefault(); 706 - const x = e.pageX - track.offsetLeft; 707 - const walk = (x - startX) * 1.5; 708 - track.scrollLeft = scrollLeft - walk; 709 - }); 710 - 711 - if (track) track.style.cursor = 'grab'; 712 - </script> 713 - </body> 714 - </html>
+3 -3
frontend/src/components/ui/Page.svelte
··· 44 44 padding: var(--space-7); 45 45 } 46 46 47 - .page-sm { max-width: var(--width-sm); } 48 - .page-md { max-width: var(--width-md); } 49 - .page-lg { max-width: var(--width-lg); } 47 + .page-sm { max-width: var(--width-md); } 48 + .page-md { max-width: var(--width-lg); } 49 + .page-lg { max-width: var(--width-xl); } 50 50 51 51 header { 52 52 margin-bottom: var(--space-7);
+6 -6
frontend/src/components/ui/index.ts
··· 1 - export { default as Button } from './Button.svelte' 2 - export { default as Card } from './Card.svelte' 3 - export { default as Input } from './Input.svelte' 4 - export { default as Message } from './Message.svelte' 5 - export { default as Page } from './Page.svelte' 6 - export { default as Section } from './Section.svelte' 1 + export { default as Button } from "./Button.svelte"; 2 + export { default as Card } from "./Card.svelte"; 3 + export { default as Input } from "./Input.svelte"; 4 + export { default as Message } from "./Message.svelte"; 5 + export { default as Page } from "./Page.svelte"; 6 + export { default as Section } from "./Section.svelte";
+647 -475
frontend/src/lib/api.ts
··· 1 - const API_BASE = '/xrpc' 1 + const API_BASE = "/xrpc"; 2 2 3 3 export class ApiError extends Error { 4 - public did?: string 5 - public reauthMethods?: string[] 6 - constructor(public status: number, public error: string, message: string, did?: string, reauthMethods?: string[]) { 7 - super(message) 8 - this.name = 'ApiError' 9 - this.did = did 10 - this.reauthMethods = reauthMethods 4 + public did?: string; 5 + public reauthMethods?: string[]; 6 + constructor( 7 + public status: number, 8 + public error: string, 9 + message: string, 10 + did?: string, 11 + reauthMethods?: string[], 12 + ) { 13 + super(message); 14 + this.name = "ApiError"; 15 + this.did = did; 16 + this.reauthMethods = reauthMethods; 11 17 } 12 18 } 13 19 14 - let tokenRefreshCallback: (() => Promise<string | null>) | null = null 20 + let tokenRefreshCallback: (() => Promise<string | null>) | null = null; 15 21 16 - export function setTokenRefreshCallback(callback: () => Promise<string | null>) { 17 - tokenRefreshCallback = callback 22 + export function setTokenRefreshCallback( 23 + callback: () => Promise<string | null>, 24 + ) { 25 + tokenRefreshCallback = callback; 18 26 } 19 27 20 28 async function xrpc<T>(method: string, options?: { 21 - method?: 'GET' | 'POST' 22 - params?: Record<string, string> 23 - body?: unknown 24 - token?: string 25 - skipRetry?: boolean 29 + method?: "GET" | "POST"; 30 + params?: Record<string, string>; 31 + body?: unknown; 32 + token?: string; 33 + skipRetry?: boolean; 26 34 }): Promise<T> { 27 - const { method: httpMethod = 'GET', params, body, token, skipRetry } = options ?? {} 28 - let url = `${API_BASE}/${method}` 35 + const { method: httpMethod = "GET", params, body, token, skipRetry } = 36 + options ?? {}; 37 + let url = `${API_BASE}/${method}`; 29 38 if (params) { 30 - const searchParams = new URLSearchParams(params) 31 - url += `?${searchParams}` 39 + const searchParams = new URLSearchParams(params); 40 + url += `?${searchParams}`; 32 41 } 33 - const headers: Record<string, string> = {} 42 + const headers: Record<string, string> = {}; 34 43 if (token) { 35 - headers['Authorization'] = `Bearer ${token}` 44 + headers["Authorization"] = `Bearer ${token}`; 36 45 } 37 46 if (body) { 38 - headers['Content-Type'] = 'application/json' 47 + headers["Content-Type"] = "application/json"; 39 48 } 40 49 const res = await fetch(url, { 41 50 method: httpMethod, 42 51 headers, 43 52 body: body ? JSON.stringify(body) : undefined, 44 - }) 53 + }); 45 54 if (!res.ok) { 46 - const err = await res.json().catch(() => ({ error: 'Unknown', message: res.statusText })) 47 - if (res.status === 401 && err.error === 'AuthenticationFailed' && token && tokenRefreshCallback && !skipRetry) { 48 - const newToken = await tokenRefreshCallback() 55 + const err = await res.json().catch(() => ({ 56 + error: "Unknown", 57 + message: res.statusText, 58 + })); 59 + if ( 60 + res.status === 401 && err.error === "AuthenticationFailed" && token && 61 + tokenRefreshCallback && !skipRetry 62 + ) { 63 + const newToken = await tokenRefreshCallback(); 49 64 if (newToken && newToken !== token) { 50 - return xrpc(method, { ...options, token: newToken, skipRetry: true }) 65 + return xrpc(method, { ...options, token: newToken, skipRetry: true }); 51 66 } 52 67 } 53 - throw new ApiError(res.status, err.error, err.message, err.did, err.reauthMethods) 68 + throw new ApiError( 69 + res.status, 70 + err.error, 71 + err.message, 72 + err.did, 73 + err.reauthMethods, 74 + ); 54 75 } 55 - return res.json() 76 + return res.json(); 56 77 } 57 78 58 79 export interface Session { 59 - did: string 60 - handle: string 61 - email?: string 62 - emailConfirmed?: boolean 63 - preferredChannel?: string 64 - preferredChannelVerified?: boolean 65 - isAdmin?: boolean 66 - active?: boolean 67 - status?: 'active' | 'deactivated' 68 - accessJwt: string 69 - refreshJwt: string 80 + did: string; 81 + handle: string; 82 + email?: string; 83 + emailConfirmed?: boolean; 84 + preferredChannel?: string; 85 + preferredChannelVerified?: boolean; 86 + isAdmin?: boolean; 87 + active?: boolean; 88 + status?: "active" | "deactivated"; 89 + accessJwt: string; 90 + refreshJwt: string; 70 91 } 71 92 72 93 export interface AppPassword { 73 - name: string 74 - createdAt: string 94 + name: string; 95 + createdAt: string; 75 96 } 76 97 77 98 export interface InviteCode { 78 - code: string 79 - available: number 80 - disabled: boolean 81 - forAccount: string 82 - createdBy: string 83 - createdAt: string 84 - uses: { usedBy: string; usedAt: string }[] 99 + code: string; 100 + available: number; 101 + disabled: boolean; 102 + forAccount: string; 103 + createdBy: string; 104 + createdAt: string; 105 + uses: { usedBy: string; usedAt: string }[]; 85 106 } 86 107 87 - export type VerificationChannel = 'email' | 'discord' | 'telegram' | 'signal' 108 + export type VerificationChannel = "email" | "discord" | "telegram" | "signal"; 88 109 89 - export type DidType = 'plc' | 'web' | 'web-external' 110 + export type DidType = "plc" | "web" | "web-external"; 90 111 91 112 export interface CreateAccountParams { 92 - handle: string 93 - email: string 94 - password: string 95 - inviteCode?: string 96 - didType?: DidType 97 - did?: string 98 - signingKey?: string 99 - verificationChannel?: VerificationChannel 100 - discordId?: string 101 - telegramUsername?: string 102 - signalNumber?: string 113 + handle: string; 114 + email: string; 115 + password: string; 116 + inviteCode?: string; 117 + didType?: DidType; 118 + did?: string; 119 + signingKey?: string; 120 + verificationChannel?: VerificationChannel; 121 + discordId?: string; 122 + telegramUsername?: string; 123 + signalNumber?: string; 103 124 } 104 125 105 126 export interface CreateAccountResult { 106 - handle: string 107 - did: string 108 - verificationRequired: boolean 109 - verificationChannel: string 127 + handle: string; 128 + did: string; 129 + verificationRequired: boolean; 130 + verificationChannel: string; 110 131 } 111 132 112 133 export interface ConfirmSignupResult { 113 - accessJwt: string 114 - refreshJwt: string 115 - handle: string 116 - did: string 117 - email?: string 118 - emailConfirmed?: boolean 119 - preferredChannel?: string 120 - preferredChannelVerified?: boolean 134 + accessJwt: string; 135 + refreshJwt: string; 136 + handle: string; 137 + did: string; 138 + email?: string; 139 + emailConfirmed?: boolean; 140 + preferredChannel?: string; 141 + preferredChannelVerified?: boolean; 121 142 } 122 143 123 144 export const api = { 124 - async createAccount(params: CreateAccountParams, byodToken?: string): Promise<CreateAccountResult> { 125 - const url = `${API_BASE}/com.atproto.server.createAccount` 126 - const headers: Record<string, string> = { 'Content-Type': 'application/json' } 145 + async createAccount( 146 + params: CreateAccountParams, 147 + byodToken?: string, 148 + ): Promise<CreateAccountResult> { 149 + const url = `${API_BASE}/com.atproto.server.createAccount`; 150 + const headers: Record<string, string> = { 151 + "Content-Type": "application/json", 152 + }; 127 153 if (byodToken) { 128 - headers['Authorization'] = `Bearer ${byodToken}` 154 + headers["Authorization"] = `Bearer ${byodToken}`; 129 155 } 130 156 const response = await fetch(url, { 131 - method: 'POST', 157 + method: "POST", 132 158 headers, 133 159 body: JSON.stringify({ 134 160 handle: params.handle, ··· 143 169 telegramUsername: params.telegramUsername, 144 170 signalNumber: params.signalNumber, 145 171 }), 146 - }) 147 - const data = await response.json() 172 + }); 173 + const data = await response.json(); 148 174 if (!response.ok) { 149 - throw new ApiError(data.error, data.message, response.status) 175 + throw new ApiError(data.error, data.message, response.status); 150 176 } 151 - return data 177 + return data; 152 178 }, 153 179 154 - async confirmSignup(did: string, verificationCode: string): Promise<ConfirmSignupResult> { 155 - return xrpc('com.atproto.server.confirmSignup', { 156 - method: 'POST', 180 + async confirmSignup( 181 + did: string, 182 + verificationCode: string, 183 + ): Promise<ConfirmSignupResult> { 184 + return xrpc("com.atproto.server.confirmSignup", { 185 + method: "POST", 157 186 body: { did, verificationCode }, 158 - }) 187 + }); 159 188 }, 160 189 161 190 async resendVerification(did: string): Promise<{ success: boolean }> { 162 - return xrpc('com.atproto.server.resendVerification', { 163 - method: 'POST', 191 + return xrpc("com.atproto.server.resendVerification", { 192 + method: "POST", 164 193 body: { did }, 165 - }) 194 + }); 166 195 }, 167 196 168 197 async createSession(identifier: string, password: string): Promise<Session> { 169 - return xrpc('com.atproto.server.createSession', { 170 - method: 'POST', 198 + return xrpc("com.atproto.server.createSession", { 199 + method: "POST", 171 200 body: { identifier, password }, 172 - }) 201 + }); 173 202 }, 174 203 175 204 async getSession(token: string): Promise<Session> { 176 - return xrpc('com.atproto.server.getSession', { token }) 205 + return xrpc("com.atproto.server.getSession", { token }); 177 206 }, 178 207 179 208 async refreshSession(refreshJwt: string): Promise<Session> { 180 - return xrpc('com.atproto.server.refreshSession', { 181 - method: 'POST', 209 + return xrpc("com.atproto.server.refreshSession", { 210 + method: "POST", 182 211 token: refreshJwt, 183 - }) 212 + }); 184 213 }, 185 214 186 215 async deleteSession(token: string): Promise<void> { 187 - await xrpc('com.atproto.server.deleteSession', { 188 - method: 'POST', 216 + await xrpc("com.atproto.server.deleteSession", { 217 + method: "POST", 189 218 token, 190 - }) 219 + }); 191 220 }, 192 221 193 222 async listAppPasswords(token: string): Promise<{ passwords: AppPassword[] }> { 194 - return xrpc('com.atproto.server.listAppPasswords', { token }) 223 + return xrpc("com.atproto.server.listAppPasswords", { token }); 195 224 }, 196 225 197 - async createAppPassword(token: string, name: string): Promise<{ name: string; password: string; createdAt: string }> { 198 - return xrpc('com.atproto.server.createAppPassword', { 199 - method: 'POST', 226 + async createAppPassword( 227 + token: string, 228 + name: string, 229 + ): Promise<{ name: string; password: string; createdAt: string }> { 230 + return xrpc("com.atproto.server.createAppPassword", { 231 + method: "POST", 200 232 token, 201 233 body: { name }, 202 - }) 234 + }); 203 235 }, 204 236 205 237 async revokeAppPassword(token: string, name: string): Promise<void> { 206 - await xrpc('com.atproto.server.revokeAppPassword', { 207 - method: 'POST', 238 + await xrpc("com.atproto.server.revokeAppPassword", { 239 + method: "POST", 208 240 token, 209 241 body: { name }, 210 - }) 242 + }); 211 243 }, 212 244 213 245 async getAccountInviteCodes(token: string): Promise<{ codes: InviteCode[] }> { 214 - return xrpc('com.atproto.server.getAccountInviteCodes', { token }) 246 + return xrpc("com.atproto.server.getAccountInviteCodes", { token }); 215 247 }, 216 248 217 - async createInviteCode(token: string, useCount: number = 1): Promise<{ code: string }> { 218 - return xrpc('com.atproto.server.createInviteCode', { 219 - method: 'POST', 249 + async createInviteCode( 250 + token: string, 251 + useCount: number = 1, 252 + ): Promise<{ code: string }> { 253 + return xrpc("com.atproto.server.createInviteCode", { 254 + method: "POST", 220 255 token, 221 256 body: { useCount }, 222 - }) 257 + }); 223 258 }, 224 259 225 260 async requestPasswordReset(email: string): Promise<void> { 226 - await xrpc('com.atproto.server.requestPasswordReset', { 227 - method: 'POST', 261 + await xrpc("com.atproto.server.requestPasswordReset", { 262 + method: "POST", 228 263 body: { email }, 229 - }) 264 + }); 230 265 }, 231 266 232 267 async resetPassword(token: string, password: string): Promise<void> { 233 - await xrpc('com.atproto.server.resetPassword', { 234 - method: 'POST', 268 + await xrpc("com.atproto.server.resetPassword", { 269 + method: "POST", 235 270 body: { token, password }, 236 - }) 271 + }); 237 272 }, 238 273 239 - async requestEmailUpdate(token: string, email: string): Promise<{ tokenRequired: boolean }> { 240 - return xrpc('com.atproto.server.requestEmailUpdate', { 241 - method: 'POST', 274 + async requestEmailUpdate( 275 + token: string, 276 + email: string, 277 + ): Promise<{ tokenRequired: boolean }> { 278 + return xrpc("com.atproto.server.requestEmailUpdate", { 279 + method: "POST", 242 280 token, 243 281 body: { email }, 244 - }) 282 + }); 245 283 }, 246 284 247 - async updateEmail(token: string, email: string, emailToken?: string): Promise<void> { 248 - await xrpc('com.atproto.server.updateEmail', { 249 - method: 'POST', 285 + async updateEmail( 286 + token: string, 287 + email: string, 288 + emailToken?: string, 289 + ): Promise<void> { 290 + await xrpc("com.atproto.server.updateEmail", { 291 + method: "POST", 250 292 token, 251 293 body: { email, token: emailToken }, 252 - }) 294 + }); 253 295 }, 254 296 255 297 async updateHandle(token: string, handle: string): Promise<void> { 256 - await xrpc('com.atproto.identity.updateHandle', { 257 - method: 'POST', 298 + await xrpc("com.atproto.identity.updateHandle", { 299 + method: "POST", 258 300 token, 259 301 body: { handle }, 260 - }) 302 + }); 261 303 }, 262 304 263 305 async requestAccountDelete(token: string): Promise<void> { 264 - await xrpc('com.atproto.server.requestAccountDelete', { 265 - method: 'POST', 306 + await xrpc("com.atproto.server.requestAccountDelete", { 307 + method: "POST", 266 308 token, 267 - }) 309 + }); 268 310 }, 269 311 270 - async deleteAccount(did: string, password: string, deleteToken: string): Promise<void> { 271 - await xrpc('com.atproto.server.deleteAccount', { 272 - method: 'POST', 312 + async deleteAccount( 313 + did: string, 314 + password: string, 315 + deleteToken: string, 316 + ): Promise<void> { 317 + await xrpc("com.atproto.server.deleteAccount", { 318 + method: "POST", 273 319 body: { did, password, token: deleteToken }, 274 - }) 320 + }); 275 321 }, 276 322 277 323 async describeServer(): Promise<{ 278 - availableUserDomains: string[] 279 - inviteCodeRequired: boolean 280 - links?: { privacyPolicy?: string; termsOfService?: string } 281 - version?: string 282 - availableCommsChannels?: string[] 324 + availableUserDomains: string[]; 325 + inviteCodeRequired: boolean; 326 + links?: { privacyPolicy?: string; termsOfService?: string }; 327 + version?: string; 328 + availableCommsChannels?: string[]; 283 329 }> { 284 - return xrpc('com.atproto.server.describeServer') 330 + return xrpc("com.atproto.server.describeServer"); 285 331 }, 286 332 287 333 async listRepos(limit?: number): Promise<{ 288 - repos: Array<{ did: string; head: string; rev: string }> 289 - cursor?: string 334 + repos: Array<{ did: string; head: string; rev: string }>; 335 + cursor?: string; 290 336 }> { 291 - const params: Record<string, string> = {} 292 - if (limit) params.limit = String(limit) 293 - return xrpc('com.atproto.sync.listRepos', { params }) 337 + const params: Record<string, string> = {}; 338 + if (limit) params.limit = String(limit); 339 + return xrpc("com.atproto.sync.listRepos", { params }); 294 340 }, 295 341 296 342 async getNotificationPrefs(token: string): Promise<{ 297 - preferredChannel: string 298 - email: string 299 - discordId: string | null 300 - discordVerified: boolean 301 - telegramUsername: string | null 302 - telegramVerified: boolean 303 - signalNumber: string | null 304 - signalVerified: boolean 343 + preferredChannel: string; 344 + email: string; 345 + discordId: string | null; 346 + discordVerified: boolean; 347 + telegramUsername: string | null; 348 + telegramVerified: boolean; 349 + signalNumber: string | null; 350 + signalVerified: boolean; 305 351 }> { 306 - return xrpc('com.tranquil.account.getNotificationPrefs', { token }) 352 + return xrpc("com.tranquil.account.getNotificationPrefs", { token }); 307 353 }, 308 354 309 355 async updateNotificationPrefs(token: string, prefs: { 310 - preferredChannel?: string 311 - discordId?: string 312 - telegramUsername?: string 313 - signalNumber?: string 356 + preferredChannel?: string; 357 + discordId?: string; 358 + telegramUsername?: string; 359 + signalNumber?: string; 314 360 }): Promise<{ success: boolean }> { 315 - return xrpc('com.tranquil.account.updateNotificationPrefs', { 316 - method: 'POST', 361 + return xrpc("com.tranquil.account.updateNotificationPrefs", { 362 + method: "POST", 317 363 token, 318 364 body: prefs, 319 - }) 365 + }); 320 366 }, 321 367 322 - async confirmChannelVerification(token: string, channel: string, identifier: string, code: string): Promise<{ success: boolean }> { 323 - return xrpc('com.tranquil.account.confirmChannelVerification', { 324 - method: 'POST', 368 + async confirmChannelVerification( 369 + token: string, 370 + channel: string, 371 + identifier: string, 372 + code: string, 373 + ): Promise<{ success: boolean }> { 374 + return xrpc("com.tranquil.account.confirmChannelVerification", { 375 + method: "POST", 325 376 token, 326 377 body: { channel, identifier, code }, 327 - }) 378 + }); 328 379 }, 329 380 330 381 async getNotificationHistory(token: string): Promise<{ 331 382 notifications: Array<{ 332 - createdAt: string 333 - channel: string 334 - notificationType: string 335 - status: string 336 - subject: string | null 337 - body: string 338 - }> 383 + createdAt: string; 384 + channel: string; 385 + notificationType: string; 386 + status: string; 387 + subject: string | null; 388 + body: string; 389 + }>; 339 390 }> { 340 - return xrpc('com.tranquil.account.getNotificationHistory', { token }) 391 + return xrpc("com.tranquil.account.getNotificationHistory", { token }); 341 392 }, 342 393 343 394 async getServerStats(token: string): Promise<{ 344 - userCount: number 345 - repoCount: number 346 - recordCount: number 347 - blobStorageBytes: number 395 + userCount: number; 396 + repoCount: number; 397 + recordCount: number; 398 + blobStorageBytes: number; 348 399 }> { 349 - return xrpc('com.tranquil.admin.getServerStats', { token }) 400 + return xrpc("com.tranquil.admin.getServerStats", { token }); 350 401 }, 351 402 352 403 async getServerConfig(): Promise<{ 353 - serverName: string 354 - primaryColor: string | null 355 - primaryColorDark: string | null 356 - secondaryColor: string | null 357 - secondaryColorDark: string | null 358 - logoCid: string | null 404 + serverName: string; 405 + primaryColor: string | null; 406 + primaryColorDark: string | null; 407 + secondaryColor: string | null; 408 + secondaryColorDark: string | null; 409 + logoCid: string | null; 359 410 }> { 360 - return xrpc('com.tranquil.server.getConfig') 411 + return xrpc("com.tranquil.server.getConfig"); 361 412 }, 362 413 363 414 async updateServerConfig( 364 415 token: string, 365 416 config: { 366 - serverName?: string 367 - primaryColor?: string 368 - primaryColorDark?: string 369 - secondaryColor?: string 370 - secondaryColorDark?: string 371 - logoCid?: string 372 - } 417 + serverName?: string; 418 + primaryColor?: string; 419 + primaryColorDark?: string; 420 + secondaryColor?: string; 421 + secondaryColorDark?: string; 422 + logoCid?: string; 423 + }, 373 424 ): Promise<{ success: boolean }> { 374 - return xrpc('com.tranquil.admin.updateServerConfig', { 375 - method: 'POST', 425 + return xrpc("com.tranquil.admin.updateServerConfig", { 426 + method: "POST", 376 427 token, 377 428 body: config, 378 - }) 429 + }); 379 430 }, 380 431 381 - async uploadBlob(token: string, file: File): Promise<{ blob: { $type: string; ref: { $link: string }; mimeType: string; size: number } }> { 382 - const res = await fetch('/xrpc/com.atproto.repo.uploadBlob', { 383 - method: 'POST', 432 + async uploadBlob( 433 + token: string, 434 + file: File, 435 + ): Promise< 436 + { 437 + blob: { 438 + $type: string; 439 + ref: { $link: string }; 440 + mimeType: string; 441 + size: number; 442 + }; 443 + } 444 + > { 445 + const res = await fetch("/xrpc/com.atproto.repo.uploadBlob", { 446 + method: "POST", 384 447 headers: { 385 - 'Authorization': `Bearer ${token}`, 386 - 'Content-Type': file.type, 448 + "Authorization": `Bearer ${token}`, 449 + "Content-Type": file.type, 387 450 }, 388 451 body: file, 389 - }) 452 + }); 390 453 if (!res.ok) { 391 - const err = await res.json().catch(() => ({ error: 'Unknown', message: res.statusText })) 392 - throw new ApiError(res.status, err.error, err.message) 454 + const err = await res.json().catch(() => ({ 455 + error: "Unknown", 456 + message: res.statusText, 457 + })); 458 + throw new ApiError(res.status, err.error, err.message); 393 459 } 394 - return res.json() 460 + return res.json(); 395 461 }, 396 462 397 - async changePassword(token: string, currentPassword: string, newPassword: string): Promise<void> { 398 - await xrpc('com.tranquil.account.changePassword', { 399 - method: 'POST', 463 + async changePassword( 464 + token: string, 465 + currentPassword: string, 466 + newPassword: string, 467 + ): Promise<void> { 468 + await xrpc("com.tranquil.account.changePassword", { 469 + method: "POST", 400 470 token, 401 471 body: { currentPassword, newPassword }, 402 - }) 472 + }); 403 473 }, 404 474 405 475 async removePassword(token: string): Promise<{ success: boolean }> { 406 - return xrpc('com.tranquil.account.removePassword', { 407 - method: 'POST', 476 + return xrpc("com.tranquil.account.removePassword", { 477 + method: "POST", 408 478 token, 409 - }) 479 + }); 410 480 }, 411 481 412 482 async getPasswordStatus(token: string): Promise<{ hasPassword: boolean }> { 413 - return xrpc('com.tranquil.account.getPasswordStatus', { token }) 483 + return xrpc("com.tranquil.account.getPasswordStatus", { token }); 414 484 }, 415 485 416 - async getLegacyLoginPreference(token: string): Promise<{ allowLegacyLogin: boolean; hasMfa: boolean }> { 417 - return xrpc('com.tranquil.account.getLegacyLoginPreference', { token }) 486 + async getLegacyLoginPreference( 487 + token: string, 488 + ): Promise<{ allowLegacyLogin: boolean; hasMfa: boolean }> { 489 + return xrpc("com.tranquil.account.getLegacyLoginPreference", { token }); 418 490 }, 419 491 420 - async updateLegacyLoginPreference(token: string, allowLegacyLogin: boolean): Promise<{ allowLegacyLogin: boolean }> { 421 - return xrpc('com.tranquil.account.updateLegacyLoginPreference', { 422 - method: 'POST', 492 + async updateLegacyLoginPreference( 493 + token: string, 494 + allowLegacyLogin: boolean, 495 + ): Promise<{ allowLegacyLogin: boolean }> { 496 + return xrpc("com.tranquil.account.updateLegacyLoginPreference", { 497 + method: "POST", 423 498 token, 424 499 body: { allowLegacyLogin }, 425 - }) 500 + }); 426 501 }, 427 502 428 - async updateLocale(token: string, preferredLocale: string): Promise<{ preferredLocale: string }> { 429 - return xrpc('com.tranquil.account.updateLocale', { 430 - method: 'POST', 503 + async updateLocale( 504 + token: string, 505 + preferredLocale: string, 506 + ): Promise<{ preferredLocale: string }> { 507 + return xrpc("com.tranquil.account.updateLocale", { 508 + method: "POST", 431 509 token, 432 510 body: { preferredLocale }, 433 - }) 511 + }); 434 512 }, 435 513 436 514 async listSessions(token: string): Promise<{ 437 515 sessions: Array<{ 438 - id: string 439 - sessionType: string 440 - clientName: string | null 441 - createdAt: string 442 - expiresAt: string 443 - isCurrent: boolean 444 - }> 516 + id: string; 517 + sessionType: string; 518 + clientName: string | null; 519 + createdAt: string; 520 + expiresAt: string; 521 + isCurrent: boolean; 522 + }>; 445 523 }> { 446 - return xrpc('com.tranquil.account.listSessions', { token }) 524 + return xrpc("com.tranquil.account.listSessions", { token }); 447 525 }, 448 526 449 527 async revokeSession(token: string, sessionId: string): Promise<void> { 450 - await xrpc('com.tranquil.account.revokeSession', { 451 - method: 'POST', 528 + await xrpc("com.tranquil.account.revokeSession", { 529 + method: "POST", 452 530 token, 453 531 body: { sessionId }, 454 - }) 532 + }); 455 533 }, 456 534 457 535 async revokeAllSessions(token: string): Promise<{ revokedCount: number }> { 458 - return xrpc('com.tranquil.account.revokeAllSessions', { 459 - method: 'POST', 536 + return xrpc("com.tranquil.account.revokeAllSessions", { 537 + method: "POST", 460 538 token, 461 - }) 539 + }); 462 540 }, 463 541 464 542 async searchAccounts(token: string, options?: { 465 - handle?: string 466 - cursor?: string 467 - limit?: number 543 + handle?: string; 544 + cursor?: string; 545 + limit?: number; 468 546 }): Promise<{ 469 - cursor?: string 547 + cursor?: string; 470 548 accounts: Array<{ 471 - did: string 472 - handle: string 473 - email?: string 474 - indexedAt: string 475 - emailConfirmedAt?: string 476 - deactivatedAt?: string 477 - }> 549 + did: string; 550 + handle: string; 551 + email?: string; 552 + indexedAt: string; 553 + emailConfirmedAt?: string; 554 + deactivatedAt?: string; 555 + }>; 478 556 }> { 479 - const params: Record<string, string> = {} 480 - if (options?.handle) params.handle = options.handle 481 - if (options?.cursor) params.cursor = options.cursor 482 - if (options?.limit) params.limit = String(options.limit) 483 - return xrpc('com.atproto.admin.searchAccounts', { token, params }) 557 + const params: Record<string, string> = {}; 558 + if (options?.handle) params.handle = options.handle; 559 + if (options?.cursor) params.cursor = options.cursor; 560 + if (options?.limit) params.limit = String(options.limit); 561 + return xrpc("com.atproto.admin.searchAccounts", { token, params }); 484 562 }, 485 563 486 564 async getInviteCodes(token: string, options?: { 487 - sort?: 'recent' | 'usage' 488 - cursor?: string 489 - limit?: number 565 + sort?: "recent" | "usage"; 566 + cursor?: string; 567 + limit?: number; 490 568 }): Promise<{ 491 - cursor?: string 569 + cursor?: string; 492 570 codes: Array<{ 493 - code: string 494 - available: number 495 - disabled: boolean 496 - forAccount: string 497 - createdBy: string 498 - createdAt: string 499 - uses: Array<{ usedBy: string; usedAt: string }> 500 - }> 571 + code: string; 572 + available: number; 573 + disabled: boolean; 574 + forAccount: string; 575 + createdBy: string; 576 + createdAt: string; 577 + uses: Array<{ usedBy: string; usedAt: string }>; 578 + }>; 501 579 }> { 502 - const params: Record<string, string> = {} 503 - if (options?.sort) params.sort = options.sort 504 - if (options?.cursor) params.cursor = options.cursor 505 - if (options?.limit) params.limit = String(options.limit) 506 - return xrpc('com.atproto.admin.getInviteCodes', { token, params }) 580 + const params: Record<string, string> = {}; 581 + if (options?.sort) params.sort = options.sort; 582 + if (options?.cursor) params.cursor = options.cursor; 583 + if (options?.limit) params.limit = String(options.limit); 584 + return xrpc("com.atproto.admin.getInviteCodes", { token, params }); 507 585 }, 508 586 509 - async disableInviteCodes(token: string, codes?: string[], accounts?: string[]): Promise<void> { 510 - await xrpc('com.atproto.admin.disableInviteCodes', { 511 - method: 'POST', 587 + async disableInviteCodes( 588 + token: string, 589 + codes?: string[], 590 + accounts?: string[], 591 + ): Promise<void> { 592 + await xrpc("com.atproto.admin.disableInviteCodes", { 593 + method: "POST", 512 594 token, 513 595 body: { codes, accounts }, 514 - }) 596 + }); 515 597 }, 516 598 517 599 async getAccountInfo(token: string, did: string): Promise<{ 518 - did: string 519 - handle: string 520 - email?: string 521 - indexedAt: string 522 - emailConfirmedAt?: string 523 - invitesDisabled?: boolean 524 - deactivatedAt?: string 600 + did: string; 601 + handle: string; 602 + email?: string; 603 + indexedAt: string; 604 + emailConfirmedAt?: string; 605 + invitesDisabled?: boolean; 606 + deactivatedAt?: string; 525 607 }> { 526 - return xrpc('com.atproto.admin.getAccountInfo', { token, params: { did } }) 608 + return xrpc("com.atproto.admin.getAccountInfo", { token, params: { did } }); 527 609 }, 528 610 529 611 async disableAccountInvites(token: string, account: string): Promise<void> { 530 - await xrpc('com.atproto.admin.disableAccountInvites', { 531 - method: 'POST', 612 + await xrpc("com.atproto.admin.disableAccountInvites", { 613 + method: "POST", 532 614 token, 533 615 body: { account }, 534 - }) 616 + }); 535 617 }, 536 618 537 619 async enableAccountInvites(token: string, account: string): Promise<void> { 538 - await xrpc('com.atproto.admin.enableAccountInvites', { 539 - method: 'POST', 620 + await xrpc("com.atproto.admin.enableAccountInvites", { 621 + method: "POST", 540 622 token, 541 623 body: { account }, 542 - }) 624 + }); 543 625 }, 544 626 545 627 async adminDeleteAccount(token: string, did: string): Promise<void> { 546 - await xrpc('com.atproto.admin.deleteAccount', { 547 - method: 'POST', 628 + await xrpc("com.atproto.admin.deleteAccount", { 629 + method: "POST", 548 630 token, 549 631 body: { did }, 550 - }) 632 + }); 551 633 }, 552 634 553 635 async describeRepo(token: string, repo: string): Promise<{ 554 - handle: string 555 - did: string 556 - didDoc: unknown 557 - collections: string[] 558 - handleIsCorrect: boolean 636 + handle: string; 637 + did: string; 638 + didDoc: unknown; 639 + collections: string[]; 640 + handleIsCorrect: boolean; 559 641 }> { 560 - return xrpc('com.atproto.repo.describeRepo', { 642 + return xrpc("com.atproto.repo.describeRepo", { 561 643 token, 562 644 params: { repo }, 563 - }) 645 + }); 564 646 }, 565 647 566 648 async listRecords(token: string, repo: string, collection: string, options?: { 567 - limit?: number 568 - cursor?: string 569 - reverse?: boolean 649 + limit?: number; 650 + cursor?: string; 651 + reverse?: boolean; 570 652 }): Promise<{ 571 - records: Array<{ uri: string; cid: string; value: unknown }> 572 - cursor?: string 653 + records: Array<{ uri: string; cid: string; value: unknown }>; 654 + cursor?: string; 573 655 }> { 574 - const params: Record<string, string> = { repo, collection } 575 - if (options?.limit) params.limit = String(options.limit) 576 - if (options?.cursor) params.cursor = options.cursor 577 - if (options?.reverse) params.reverse = 'true' 578 - return xrpc('com.atproto.repo.listRecords', { token, params }) 656 + const params: Record<string, string> = { repo, collection }; 657 + if (options?.limit) params.limit = String(options.limit); 658 + if (options?.cursor) params.cursor = options.cursor; 659 + if (options?.reverse) params.reverse = "true"; 660 + return xrpc("com.atproto.repo.listRecords", { token, params }); 579 661 }, 580 662 581 - async getRecord(token: string, repo: string, collection: string, rkey: string): Promise<{ 582 - uri: string 583 - cid: string 584 - value: unknown 663 + async getRecord( 664 + token: string, 665 + repo: string, 666 + collection: string, 667 + rkey: string, 668 + ): Promise<{ 669 + uri: string; 670 + cid: string; 671 + value: unknown; 585 672 }> { 586 - return xrpc('com.atproto.repo.getRecord', { 673 + return xrpc("com.atproto.repo.getRecord", { 587 674 token, 588 675 params: { repo, collection, rkey }, 589 - }) 676 + }); 590 677 }, 591 678 592 - async createRecord(token: string, repo: string, collection: string, record: unknown, rkey?: string): Promise<{ 593 - uri: string 594 - cid: string 679 + async createRecord( 680 + token: string, 681 + repo: string, 682 + collection: string, 683 + record: unknown, 684 + rkey?: string, 685 + ): Promise<{ 686 + uri: string; 687 + cid: string; 595 688 }> { 596 - return xrpc('com.atproto.repo.createRecord', { 597 - method: 'POST', 689 + return xrpc("com.atproto.repo.createRecord", { 690 + method: "POST", 598 691 token, 599 692 body: { repo, collection, record, rkey }, 600 - }) 693 + }); 601 694 }, 602 695 603 - async putRecord(token: string, repo: string, collection: string, rkey: string, record: unknown): Promise<{ 604 - uri: string 605 - cid: string 696 + async putRecord( 697 + token: string, 698 + repo: string, 699 + collection: string, 700 + rkey: string, 701 + record: unknown, 702 + ): Promise<{ 703 + uri: string; 704 + cid: string; 606 705 }> { 607 - return xrpc('com.atproto.repo.putRecord', { 608 - method: 'POST', 706 + return xrpc("com.atproto.repo.putRecord", { 707 + method: "POST", 609 708 token, 610 709 body: { repo, collection, rkey, record }, 611 - }) 710 + }); 612 711 }, 613 712 614 - async deleteRecord(token: string, repo: string, collection: string, rkey: string): Promise<void> { 615 - await xrpc('com.atproto.repo.deleteRecord', { 616 - method: 'POST', 713 + async deleteRecord( 714 + token: string, 715 + repo: string, 716 + collection: string, 717 + rkey: string, 718 + ): Promise<void> { 719 + await xrpc("com.atproto.repo.deleteRecord", { 720 + method: "POST", 617 721 token, 618 722 body: { repo, collection, rkey }, 619 - }) 723 + }); 620 724 }, 621 725 622 - async getTotpStatus(token: string): Promise<{ enabled: boolean; hasBackupCodes: boolean }> { 623 - return xrpc('com.atproto.server.getTotpStatus', { token }) 726 + async getTotpStatus( 727 + token: string, 728 + ): Promise<{ enabled: boolean; hasBackupCodes: boolean }> { 729 + return xrpc("com.atproto.server.getTotpStatus", { token }); 624 730 }, 625 731 626 - async createTotpSecret(token: string): Promise<{ uri: string; qrBase64: string }> { 627 - return xrpc('com.atproto.server.createTotpSecret', { method: 'POST', token }) 732 + async createTotpSecret( 733 + token: string, 734 + ): Promise<{ uri: string; qrBase64: string }> { 735 + return xrpc("com.atproto.server.createTotpSecret", { 736 + method: "POST", 737 + token, 738 + }); 628 739 }, 629 740 630 - async enableTotp(token: string, code: string): Promise<{ success: boolean; backupCodes: string[] }> { 631 - return xrpc('com.atproto.server.enableTotp', { 632 - method: 'POST', 741 + async enableTotp( 742 + token: string, 743 + code: string, 744 + ): Promise<{ success: boolean; backupCodes: string[] }> { 745 + return xrpc("com.atproto.server.enableTotp", { 746 + method: "POST", 633 747 token, 634 748 body: { code }, 635 - }) 749 + }); 636 750 }, 637 751 638 - async disableTotp(token: string, password: string, code: string): Promise<{ success: boolean }> { 639 - return xrpc('com.atproto.server.disableTotp', { 640 - method: 'POST', 752 + async disableTotp( 753 + token: string, 754 + password: string, 755 + code: string, 756 + ): Promise<{ success: boolean }> { 757 + return xrpc("com.atproto.server.disableTotp", { 758 + method: "POST", 641 759 token, 642 760 body: { password, code }, 643 - }) 761 + }); 644 762 }, 645 763 646 - async regenerateBackupCodes(token: string, password: string, code: string): Promise<{ backupCodes: string[] }> { 647 - return xrpc('com.atproto.server.regenerateBackupCodes', { 648 - method: 'POST', 764 + async regenerateBackupCodes( 765 + token: string, 766 + password: string, 767 + code: string, 768 + ): Promise<{ backupCodes: string[] }> { 769 + return xrpc("com.atproto.server.regenerateBackupCodes", { 770 + method: "POST", 649 771 token, 650 772 body: { password, code }, 651 - }) 773 + }); 652 774 }, 653 775 654 - async startPasskeyRegistration(token: string, friendlyName?: string): Promise<{ options: unknown }> { 655 - return xrpc('com.atproto.server.startPasskeyRegistration', { 656 - method: 'POST', 776 + async startPasskeyRegistration( 777 + token: string, 778 + friendlyName?: string, 779 + ): Promise<{ options: unknown }> { 780 + return xrpc("com.atproto.server.startPasskeyRegistration", { 781 + method: "POST", 657 782 token, 658 783 body: { friendlyName }, 659 - }) 784 + }); 660 785 }, 661 786 662 - async finishPasskeyRegistration(token: string, credential: unknown, friendlyName?: string): Promise<{ id: string; credentialId: string }> { 663 - return xrpc('com.atproto.server.finishPasskeyRegistration', { 664 - method: 'POST', 787 + async finishPasskeyRegistration( 788 + token: string, 789 + credential: unknown, 790 + friendlyName?: string, 791 + ): Promise<{ id: string; credentialId: string }> { 792 + return xrpc("com.atproto.server.finishPasskeyRegistration", { 793 + method: "POST", 665 794 token, 666 795 body: { credential, friendlyName }, 667 - }) 796 + }); 668 797 }, 669 798 670 799 async listPasskeys(token: string): Promise<{ 671 800 passkeys: Array<{ 672 - id: string 673 - credentialId: string 674 - friendlyName: string | null 675 - createdAt: string 676 - lastUsed: string | null 677 - }> 801 + id: string; 802 + credentialId: string; 803 + friendlyName: string | null; 804 + createdAt: string; 805 + lastUsed: string | null; 806 + }>; 678 807 }> { 679 - return xrpc('com.atproto.server.listPasskeys', { token }) 808 + return xrpc("com.atproto.server.listPasskeys", { token }); 680 809 }, 681 810 682 811 async deletePasskey(token: string, id: string): Promise<void> { 683 - await xrpc('com.atproto.server.deletePasskey', { 684 - method: 'POST', 812 + await xrpc("com.atproto.server.deletePasskey", { 813 + method: "POST", 685 814 token, 686 815 body: { id }, 687 - }) 816 + }); 688 817 }, 689 818 690 - async updatePasskey(token: string, id: string, friendlyName: string): Promise<void> { 691 - await xrpc('com.atproto.server.updatePasskey', { 692 - method: 'POST', 819 + async updatePasskey( 820 + token: string, 821 + id: string, 822 + friendlyName: string, 823 + ): Promise<void> { 824 + await xrpc("com.atproto.server.updatePasskey", { 825 + method: "POST", 693 826 token, 694 827 body: { id, friendlyName }, 695 - }) 828 + }); 696 829 }, 697 830 698 831 async listTrustedDevices(token: string): Promise<{ 699 832 devices: Array<{ 700 - id: string 701 - userAgent: string | null 702 - friendlyName: string | null 703 - trustedAt: string | null 704 - trustedUntil: string | null 705 - lastSeenAt: string 706 - }> 833 + id: string; 834 + userAgent: string | null; 835 + friendlyName: string | null; 836 + trustedAt: string | null; 837 + trustedUntil: string | null; 838 + lastSeenAt: string; 839 + }>; 707 840 }> { 708 - return xrpc('com.tranquil.account.listTrustedDevices', { token }) 841 + return xrpc("com.tranquil.account.listTrustedDevices", { token }); 709 842 }, 710 843 711 - async revokeTrustedDevice(token: string, deviceId: string): Promise<{ success: boolean }> { 712 - return xrpc('com.tranquil.account.revokeTrustedDevice', { 713 - method: 'POST', 844 + async revokeTrustedDevice( 845 + token: string, 846 + deviceId: string, 847 + ): Promise<{ success: boolean }> { 848 + return xrpc("com.tranquil.account.revokeTrustedDevice", { 849 + method: "POST", 714 850 token, 715 851 body: { deviceId }, 716 - }) 852 + }); 717 853 }, 718 854 719 - async updateTrustedDevice(token: string, deviceId: string, friendlyName: string): Promise<{ success: boolean }> { 720 - return xrpc('com.tranquil.account.updateTrustedDevice', { 721 - method: 'POST', 855 + async updateTrustedDevice( 856 + token: string, 857 + deviceId: string, 858 + friendlyName: string, 859 + ): Promise<{ success: boolean }> { 860 + return xrpc("com.tranquil.account.updateTrustedDevice", { 861 + method: "POST", 722 862 token, 723 863 body: { deviceId, friendlyName }, 724 - }) 864 + }); 725 865 }, 726 866 727 867 async getReauthStatus(token: string): Promise<{ 728 - requiresReauth: boolean 729 - lastReauthAt: string | null 730 - availableMethods: string[] 868 + requiresReauth: boolean; 869 + lastReauthAt: string | null; 870 + availableMethods: string[]; 731 871 }> { 732 - return xrpc('com.tranquil.account.getReauthStatus', { token }) 872 + return xrpc("com.tranquil.account.getReauthStatus", { token }); 733 873 }, 734 874 735 - async reauthPassword(token: string, password: string): Promise<{ success: boolean; reauthAt: string }> { 736 - return xrpc('com.tranquil.account.reauthPassword', { 737 - method: 'POST', 875 + async reauthPassword( 876 + token: string, 877 + password: string, 878 + ): Promise<{ success: boolean; reauthAt: string }> { 879 + return xrpc("com.tranquil.account.reauthPassword", { 880 + method: "POST", 738 881 token, 739 882 body: { password }, 740 - }) 883 + }); 741 884 }, 742 885 743 - async reauthTotp(token: string, code: string): Promise<{ success: boolean; reauthAt: string }> { 744 - return xrpc('com.tranquil.account.reauthTotp', { 745 - method: 'POST', 886 + async reauthTotp( 887 + token: string, 888 + code: string, 889 + ): Promise<{ success: boolean; reauthAt: string }> { 890 + return xrpc("com.tranquil.account.reauthTotp", { 891 + method: "POST", 746 892 token, 747 893 body: { code }, 748 - }) 894 + }); 749 895 }, 750 896 751 897 async reauthPasskeyStart(token: string): Promise<{ options: unknown }> { 752 - return xrpc('com.tranquil.account.reauthPasskeyStart', { 753 - method: 'POST', 898 + return xrpc("com.tranquil.account.reauthPasskeyStart", { 899 + method: "POST", 754 900 token, 755 - }) 901 + }); 756 902 }, 757 903 758 - async reauthPasskeyFinish(token: string, credential: unknown): Promise<{ success: boolean; reauthAt: string }> { 759 - return xrpc('com.tranquil.account.reauthPasskeyFinish', { 760 - method: 'POST', 904 + async reauthPasskeyFinish( 905 + token: string, 906 + credential: unknown, 907 + ): Promise<{ success: boolean; reauthAt: string }> { 908 + return xrpc("com.tranquil.account.reauthPasskeyFinish", { 909 + method: "POST", 761 910 token, 762 911 body: { credential }, 763 - }) 912 + }); 764 913 }, 765 914 766 915 async reserveSigningKey(did?: string): Promise<{ signingKey: string }> { 767 - return xrpc('com.atproto.server.reserveSigningKey', { 768 - method: 'POST', 916 + return xrpc("com.atproto.server.reserveSigningKey", { 917 + method: "POST", 769 918 body: { did }, 770 - }) 919 + }); 771 920 }, 772 921 773 922 async getRecommendedDidCredentials(token: string): Promise<{ 774 - rotationKeys?: string[] 775 - alsoKnownAs?: string[] 776 - verificationMethods?: { atproto?: string } 777 - services?: { atproto_pds?: { type: string; endpoint: string } } 923 + rotationKeys?: string[]; 924 + alsoKnownAs?: string[]; 925 + verificationMethods?: { atproto?: string }; 926 + services?: { atproto_pds?: { type: string; endpoint: string } }; 778 927 }> { 779 - return xrpc('com.atproto.identity.getRecommendedDidCredentials', { token }) 928 + return xrpc("com.atproto.identity.getRecommendedDidCredentials", { token }); 780 929 }, 781 930 782 931 async activateAccount(token: string): Promise<void> { 783 - await xrpc('com.atproto.server.activateAccount', { 784 - method: 'POST', 932 + await xrpc("com.atproto.server.activateAccount", { 933 + method: "POST", 785 934 token, 786 - }) 935 + }); 787 936 }, 788 937 789 938 async createPasskeyAccount(params: { 790 - handle: string 791 - email?: string 792 - inviteCode?: string 793 - didType?: DidType 794 - did?: string 795 - signingKey?: string 796 - verificationChannel?: VerificationChannel 797 - discordId?: string 798 - telegramUsername?: string 799 - signalNumber?: string 939 + handle: string; 940 + email?: string; 941 + inviteCode?: string; 942 + didType?: DidType; 943 + did?: string; 944 + signingKey?: string; 945 + verificationChannel?: VerificationChannel; 946 + discordId?: string; 947 + telegramUsername?: string; 948 + signalNumber?: string; 800 949 }, byodToken?: string): Promise<{ 801 - did: string 802 - handle: string 803 - setupToken: string 804 - setupExpiresAt: string 950 + did: string; 951 + handle: string; 952 + setupToken: string; 953 + setupExpiresAt: string; 805 954 }> { 806 - const url = `${API_BASE}/com.tranquil.account.createPasskeyAccount` 955 + const url = `${API_BASE}/com.tranquil.account.createPasskeyAccount`; 807 956 const headers: Record<string, string> = { 808 - 'Content-Type': 'application/json' 809 - } 957 + "Content-Type": "application/json", 958 + }; 810 959 if (byodToken) { 811 - headers['Authorization'] = `Bearer ${byodToken}` 960 + headers["Authorization"] = `Bearer ${byodToken}`; 812 961 } 813 962 const res = await fetch(url, { 814 - method: 'POST', 963 + method: "POST", 815 964 headers, 816 965 body: JSON.stringify(params), 817 - }) 966 + }); 818 967 if (!res.ok) { 819 - const err = await res.json().catch(() => ({ error: 'Unknown', message: res.statusText })) 820 - throw new ApiError(res.status, err.error, err.message) 968 + const err = await res.json().catch(() => ({ 969 + error: "Unknown", 970 + message: res.statusText, 971 + })); 972 + throw new ApiError(res.status, err.error, err.message); 821 973 } 822 - return res.json() 974 + return res.json(); 823 975 }, 824 976 825 - async startPasskeyRegistrationForSetup(did: string, setupToken: string, friendlyName?: string): Promise<{ options: unknown }> { 826 - return xrpc('com.tranquil.account.startPasskeyRegistrationForSetup', { 827 - method: 'POST', 977 + async startPasskeyRegistrationForSetup( 978 + did: string, 979 + setupToken: string, 980 + friendlyName?: string, 981 + ): Promise<{ options: unknown }> { 982 + return xrpc("com.tranquil.account.startPasskeyRegistrationForSetup", { 983 + method: "POST", 828 984 body: { did, setupToken, friendlyName }, 829 - }) 985 + }); 830 986 }, 831 987 832 - async completePasskeySetup(did: string, setupToken: string, passkeyCredential: unknown, passkeyFriendlyName?: string): Promise<{ 833 - did: string 834 - handle: string 835 - appPassword: string 836 - appPasswordName: string 988 + async completePasskeySetup( 989 + did: string, 990 + setupToken: string, 991 + passkeyCredential: unknown, 992 + passkeyFriendlyName?: string, 993 + ): Promise<{ 994 + did: string; 995 + handle: string; 996 + appPassword: string; 997 + appPasswordName: string; 837 998 }> { 838 - return xrpc('com.tranquil.account.completePasskeySetup', { 839 - method: 'POST', 999 + return xrpc("com.tranquil.account.completePasskeySetup", { 1000 + method: "POST", 840 1001 body: { did, setupToken, passkeyCredential, passkeyFriendlyName }, 841 - }) 1002 + }); 842 1003 }, 843 1004 844 1005 async requestPasskeyRecovery(email: string): Promise<{ success: boolean }> { 845 - return xrpc('com.tranquil.account.requestPasskeyRecovery', { 846 - method: 'POST', 1006 + return xrpc("com.tranquil.account.requestPasskeyRecovery", { 1007 + method: "POST", 847 1008 body: { email }, 848 - }) 1009 + }); 849 1010 }, 850 1011 851 - async recoverPasskeyAccount(did: string, recoveryToken: string, newPassword: string): Promise<{ success: boolean }> { 852 - return xrpc('com.tranquil.account.recoverPasskeyAccount', { 853 - method: 'POST', 1012 + async recoverPasskeyAccount( 1013 + did: string, 1014 + recoveryToken: string, 1015 + newPassword: string, 1016 + ): Promise<{ success: boolean }> { 1017 + return xrpc("com.tranquil.account.recoverPasskeyAccount", { 1018 + method: "POST", 854 1019 body: { did, recoveryToken, newPassword }, 855 - }) 1020 + }); 856 1021 }, 857 1022 858 - async verifyMigrationEmail(token: string, email: string): Promise<{ success: boolean; did: string }> { 859 - return xrpc('com.atproto.server.verifyMigrationEmail', { 860 - method: 'POST', 1023 + async verifyMigrationEmail( 1024 + token: string, 1025 + email: string, 1026 + ): Promise<{ success: boolean; did: string }> { 1027 + return xrpc("com.atproto.server.verifyMigrationEmail", { 1028 + method: "POST", 861 1029 body: { token, email }, 862 - }) 1030 + }); 863 1031 }, 864 1032 865 1033 async resendMigrationVerification(email: string): Promise<{ sent: boolean }> { 866 - return xrpc('com.atproto.server.resendMigrationVerification', { 867 - method: 'POST', 1034 + return xrpc("com.atproto.server.resendMigrationVerification", { 1035 + method: "POST", 868 1036 body: { email }, 869 - }) 1037 + }); 870 1038 }, 871 1039 872 - async verifyToken(token: string, identifier: string, accessToken?: string): Promise<{ 873 - success: boolean 874 - did: string 875 - purpose: string 876 - channel: string 1040 + async verifyToken( 1041 + token: string, 1042 + identifier: string, 1043 + accessToken?: string, 1044 + ): Promise<{ 1045 + success: boolean; 1046 + did: string; 1047 + purpose: string; 1048 + channel: string; 877 1049 }> { 878 - return xrpc('com.tranquil.account.verifyToken', { 879 - method: 'POST', 1050 + return xrpc("com.tranquil.account.verifyToken", { 1051 + method: "POST", 880 1052 body: { token, identifier }, 881 1053 token: accessToken, 882 - }) 1054 + }); 883 1055 }, 884 - } 1056 + };
+234 -184
frontend/src/lib/auth.svelte.ts
··· 1 - import { api, setTokenRefreshCallback, type Session, type CreateAccountParams, type CreateAccountResult, ApiError } from './api' 2 - import { startOAuthLogin, handleOAuthCallback, checkForOAuthCallback, clearOAuthCallbackParams, refreshOAuthToken } from './oauth' 3 - import { setLocale, type SupportedLocale } from './i18n' 1 + import { 2 + api, 3 + ApiError, 4 + type CreateAccountParams, 5 + type CreateAccountResult, 6 + type Session, 7 + setTokenRefreshCallback, 8 + } from "./api"; 9 + import { 10 + checkForOAuthCallback, 11 + clearOAuthCallbackParams, 12 + handleOAuthCallback, 13 + refreshOAuthToken, 14 + startOAuthLogin, 15 + } from "./oauth"; 16 + import { setLocale, type SupportedLocale } from "./i18n"; 4 17 5 - function applyLocaleFromSession(sessionInfo: { preferredLocale?: string | null }) { 18 + function applyLocaleFromSession( 19 + sessionInfo: { preferredLocale?: string | null }, 20 + ) { 6 21 if (sessionInfo.preferredLocale) { 7 - setLocale(sessionInfo.preferredLocale as SupportedLocale) 22 + setLocale(sessionInfo.preferredLocale as SupportedLocale); 8 23 } 9 24 } 10 25 11 - const STORAGE_KEY = 'tranquil_pds_session' 12 - const ACCOUNTS_KEY = 'tranquil_pds_accounts' 26 + const STORAGE_KEY = "tranquil_pds_session"; 27 + const ACCOUNTS_KEY = "tranquil_pds_accounts"; 13 28 14 29 export interface SavedAccount { 15 - did: string 16 - handle: string 17 - accessJwt: string 18 - refreshJwt: string 30 + did: string; 31 + handle: string; 32 + accessJwt: string; 33 + refreshJwt: string; 19 34 } 20 35 21 36 interface AuthState { 22 - session: Session | null 23 - loading: boolean 24 - error: string | null 25 - savedAccounts: SavedAccount[] 37 + session: Session | null; 38 + loading: boolean; 39 + error: string | null; 40 + savedAccounts: SavedAccount[]; 26 41 } 27 42 28 43 let state = $state<AuthState>({ ··· 30 45 loading: true, 31 46 error: null, 32 47 savedAccounts: [], 33 - }) 48 + }); 34 49 35 50 function saveSession(session: Session | null) { 36 51 if (session) { 37 - localStorage.setItem(STORAGE_KEY, JSON.stringify(session)) 52 + localStorage.setItem(STORAGE_KEY, JSON.stringify(session)); 38 53 } else { 39 - localStorage.removeItem(STORAGE_KEY) 54 + localStorage.removeItem(STORAGE_KEY); 40 55 } 41 56 } 42 57 43 58 function loadSession(): Session | null { 44 - const stored = localStorage.getItem(STORAGE_KEY) 59 + const stored = localStorage.getItem(STORAGE_KEY); 45 60 if (stored) { 46 61 try { 47 - return JSON.parse(stored) 62 + return JSON.parse(stored); 48 63 } catch { 49 - return null 64 + return null; 50 65 } 51 66 } 52 - return null 67 + return null; 53 68 } 54 69 55 70 function loadSavedAccounts(): SavedAccount[] { 56 - const stored = localStorage.getItem(ACCOUNTS_KEY) 71 + const stored = localStorage.getItem(ACCOUNTS_KEY); 57 72 if (stored) { 58 73 try { 59 - return JSON.parse(stored) 74 + return JSON.parse(stored); 60 75 } catch { 61 - return [] 76 + return []; 62 77 } 63 78 } 64 - return [] 79 + return []; 65 80 } 66 81 67 82 function saveSavedAccounts(accounts: SavedAccount[]) { 68 - localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts)) 83 + localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts)); 69 84 } 70 85 71 86 function addOrUpdateSavedAccount(session: Session) { 72 - const accounts = loadSavedAccounts() 73 - const existing = accounts.findIndex(a => a.did === session.did) 87 + const accounts = loadSavedAccounts(); 88 + const existing = accounts.findIndex((a) => a.did === session.did); 74 89 const savedAccount: SavedAccount = { 75 90 did: session.did, 76 91 handle: session.handle, 77 92 accessJwt: session.accessJwt, 78 93 refreshJwt: session.refreshJwt, 79 - } 94 + }; 80 95 if (existing >= 0) { 81 - accounts[existing] = savedAccount 96 + accounts[existing] = savedAccount; 82 97 } else { 83 - accounts.push(savedAccount) 98 + accounts.push(savedAccount); 84 99 } 85 - saveSavedAccounts(accounts) 86 - state.savedAccounts = accounts 100 + saveSavedAccounts(accounts); 101 + state.savedAccounts = accounts; 87 102 } 88 103 89 104 function removeSavedAccount(did: string) { 90 - const accounts = loadSavedAccounts().filter(a => a.did !== did) 91 - saveSavedAccounts(accounts) 92 - state.savedAccounts = accounts 105 + const accounts = loadSavedAccounts().filter((a) => a.did !== did); 106 + saveSavedAccounts(accounts); 107 + state.savedAccounts = accounts; 93 108 } 94 109 95 110 async function tryRefreshToken(): Promise<string | null> { 96 - if (!state.session) return null 111 + if (!state.session) return null; 97 112 try { 98 - const tokens = await refreshOAuthToken(state.session.refreshJwt) 99 - const sessionInfo = await api.getSession(tokens.access_token) 113 + const tokens = await refreshOAuthToken(state.session.refreshJwt); 114 + const sessionInfo = await api.getSession(tokens.access_token); 100 115 const session: Session = { 101 116 ...sessionInfo, 102 117 accessJwt: tokens.access_token, 103 118 refreshJwt: tokens.refresh_token || state.session.refreshJwt, 104 - } 105 - state.session = session 106 - saveSession(session) 107 - addOrUpdateSavedAccount(session) 108 - return session.accessJwt 119 + }; 120 + state.session = session; 121 + saveSession(session); 122 + addOrUpdateSavedAccount(session); 123 + return session.accessJwt; 109 124 } catch { 110 - return null 125 + return null; 111 126 } 112 127 } 113 128 114 129 export async function initAuth(): Promise<{ oauthLoginCompleted: boolean }> { 115 - setTokenRefreshCallback(tryRefreshToken) 116 - state.loading = true 117 - state.error = null 118 - state.savedAccounts = loadSavedAccounts() 130 + setTokenRefreshCallback(tryRefreshToken); 131 + state.loading = true; 132 + state.error = null; 133 + state.savedAccounts = loadSavedAccounts(); 119 134 120 - const oauthCallback = checkForOAuthCallback() 135 + const oauthCallback = checkForOAuthCallback(); 121 136 if (oauthCallback) { 122 - clearOAuthCallbackParams() 137 + clearOAuthCallbackParams(); 123 138 try { 124 - const tokens = await handleOAuthCallback(oauthCallback.code, oauthCallback.state) 125 - const sessionInfo = await api.getSession(tokens.access_token) 139 + const tokens = await handleOAuthCallback( 140 + oauthCallback.code, 141 + oauthCallback.state, 142 + ); 143 + const sessionInfo = await api.getSession(tokens.access_token); 126 144 const session: Session = { 127 145 ...sessionInfo, 128 146 accessJwt: tokens.access_token, 129 - refreshJwt: tokens.refresh_token || '', 130 - } 131 - state.session = session 132 - saveSession(session) 133 - addOrUpdateSavedAccount(session) 134 - applyLocaleFromSession(sessionInfo) 135 - state.loading = false 136 - return { oauthLoginCompleted: true } 147 + refreshJwt: tokens.refresh_token || "", 148 + }; 149 + state.session = session; 150 + saveSession(session); 151 + addOrUpdateSavedAccount(session); 152 + applyLocaleFromSession(sessionInfo); 153 + state.loading = false; 154 + return { oauthLoginCompleted: true }; 137 155 } catch (e) { 138 - state.error = e instanceof Error ? e.message : 'OAuth login failed' 139 - state.loading = false 140 - return { oauthLoginCompleted: false } 156 + state.error = e instanceof Error ? e.message : "OAuth login failed"; 157 + state.loading = false; 158 + return { oauthLoginCompleted: false }; 141 159 } 142 160 } 143 161 144 - const stored = loadSession() 162 + const stored = loadSession(); 145 163 if (stored) { 146 164 try { 147 - const sessionInfo = await api.getSession(stored.accessJwt) 148 - state.session = { ...sessionInfo, accessJwt: stored.accessJwt, refreshJwt: stored.refreshJwt } 149 - addOrUpdateSavedAccount(state.session) 150 - applyLocaleFromSession(sessionInfo) 165 + const sessionInfo = await api.getSession(stored.accessJwt); 166 + state.session = { 167 + ...sessionInfo, 168 + accessJwt: stored.accessJwt, 169 + refreshJwt: stored.refreshJwt, 170 + }; 171 + addOrUpdateSavedAccount(state.session); 172 + applyLocaleFromSession(sessionInfo); 151 173 } catch (e) { 152 174 if (e instanceof ApiError && e.status === 401) { 153 175 try { 154 - const tokens = await refreshOAuthToken(stored.refreshJwt) 155 - const sessionInfo = await api.getSession(tokens.access_token) 176 + const tokens = await refreshOAuthToken(stored.refreshJwt); 177 + const sessionInfo = await api.getSession(tokens.access_token); 156 178 const session: Session = { 157 179 ...sessionInfo, 158 180 accessJwt: tokens.access_token, 159 181 refreshJwt: tokens.refresh_token || stored.refreshJwt, 160 - } 161 - state.session = session 162 - saveSession(session) 163 - addOrUpdateSavedAccount(session) 164 - applyLocaleFromSession(sessionInfo) 182 + }; 183 + state.session = session; 184 + saveSession(session); 185 + addOrUpdateSavedAccount(session); 186 + applyLocaleFromSession(sessionInfo); 165 187 } catch (refreshError) { 166 - console.error('Token refresh failed during init:', refreshError) 167 - saveSession(null) 168 - state.session = null 188 + console.error("Token refresh failed during init:", refreshError); 189 + saveSession(null); 190 + state.session = null; 169 191 } 170 192 } else { 171 - console.error('Non-401 error during getSession:', e) 172 - saveSession(null) 173 - state.session = null 193 + console.error("Non-401 error during getSession:", e); 194 + saveSession(null); 195 + state.session = null; 174 196 } 175 197 } 176 198 } 177 - state.loading = false 178 - return { oauthLoginCompleted: false } 199 + state.loading = false; 200 + return { oauthLoginCompleted: false }; 179 201 } 180 202 181 - export async function login(identifier: string, password: string): Promise<void> { 182 - state.loading = true 183 - state.error = null 203 + export async function login( 204 + identifier: string, 205 + password: string, 206 + ): Promise<void> { 207 + state.loading = true; 208 + state.error = null; 184 209 try { 185 - const session = await api.createSession(identifier, password) 186 - state.session = session 187 - saveSession(session) 188 - addOrUpdateSavedAccount(session) 210 + const session = await api.createSession(identifier, password); 211 + state.session = session; 212 + saveSession(session); 213 + addOrUpdateSavedAccount(session); 189 214 } catch (e) { 190 215 if (e instanceof ApiError) { 191 - state.error = e.message 216 + state.error = e.message; 192 217 } else { 193 - state.error = 'Login failed' 218 + state.error = "Login failed"; 194 219 } 195 - throw e 220 + throw e; 196 221 } finally { 197 - state.loading = false 222 + state.loading = false; 198 223 } 199 224 } 200 225 201 226 export async function loginWithOAuth(): Promise<void> { 202 - state.loading = true 203 - state.error = null 227 + state.loading = true; 228 + state.error = null; 204 229 try { 205 - await startOAuthLogin() 230 + await startOAuthLogin(); 206 231 } catch (e) { 207 - state.loading = false 208 - state.error = e instanceof Error ? e.message : 'Failed to start OAuth login' 209 - throw e 232 + state.loading = false; 233 + state.error = e instanceof Error 234 + ? e.message 235 + : "Failed to start OAuth login"; 236 + throw e; 210 237 } 211 238 } 212 239 213 - export async function register(params: CreateAccountParams): Promise<CreateAccountResult> { 240 + export async function register( 241 + params: CreateAccountParams, 242 + ): Promise<CreateAccountResult> { 214 243 try { 215 - const result = await api.createAccount(params) 216 - return result 244 + const result = await api.createAccount(params); 245 + return result; 217 246 } catch (e) { 218 247 if (e instanceof ApiError) { 219 - state.error = e.message 248 + state.error = e.message; 220 249 } else { 221 - state.error = 'Registration failed' 250 + state.error = "Registration failed"; 222 251 } 223 - throw e 252 + throw e; 224 253 } 225 254 } 226 255 227 - export async function confirmSignup(did: string, verificationCode: string): Promise<void> { 228 - state.loading = true 229 - state.error = null 256 + export async function confirmSignup( 257 + did: string, 258 + verificationCode: string, 259 + ): Promise<void> { 260 + state.loading = true; 261 + state.error = null; 230 262 try { 231 - const result = await api.confirmSignup(did, verificationCode) 263 + const result = await api.confirmSignup(did, verificationCode); 232 264 const session: Session = { 233 265 did: result.did, 234 266 handle: result.handle, ··· 238 270 emailConfirmed: result.emailConfirmed, 239 271 preferredChannel: result.preferredChannel, 240 272 preferredChannelVerified: result.preferredChannelVerified, 241 - } 242 - state.session = session 243 - saveSession(session) 244 - addOrUpdateSavedAccount(session) 273 + }; 274 + state.session = session; 275 + saveSession(session); 276 + addOrUpdateSavedAccount(session); 245 277 } catch (e) { 246 278 if (e instanceof ApiError) { 247 - state.error = e.message 279 + state.error = e.message; 248 280 } else { 249 - state.error = 'Verification failed' 281 + state.error = "Verification failed"; 250 282 } 251 - throw e 283 + throw e; 252 284 } finally { 253 - state.loading = false 285 + state.loading = false; 254 286 } 255 287 } 256 288 257 289 export async function resendVerification(did: string): Promise<void> { 258 290 try { 259 - await api.resendVerification(did) 291 + await api.resendVerification(did); 260 292 } catch (e) { 261 293 if (e instanceof ApiError) { 262 - throw e 294 + throw e; 263 295 } 264 - throw new Error('Failed to resend verification code') 296 + throw new Error("Failed to resend verification code"); 265 297 } 266 298 } 267 299 268 - export function setSession(session: { did: string; handle: string; accessJwt: string; refreshJwt: string }): void { 300 + export function setSession( 301 + session: { 302 + did: string; 303 + handle: string; 304 + accessJwt: string; 305 + refreshJwt: string; 306 + }, 307 + ): void { 269 308 const newSession: Session = { 270 309 did: session.did, 271 310 handle: session.handle, 272 311 accessJwt: session.accessJwt, 273 312 refreshJwt: session.refreshJwt, 274 - } 275 - state.session = newSession 276 - saveSession(newSession) 277 - addOrUpdateSavedAccount(newSession) 313 + }; 314 + state.session = newSession; 315 + saveSession(newSession); 316 + addOrUpdateSavedAccount(newSession); 278 317 } 279 318 280 319 export async function logout(): Promise<void> { 281 320 if (state.session) { 282 321 try { 283 - await api.deleteSession(state.session.accessJwt) 322 + await api.deleteSession(state.session.accessJwt); 284 323 } catch { 285 324 // Ignore errors on logout 286 325 } 287 326 } 288 - state.session = null 289 - saveSession(null) 327 + state.session = null; 328 + saveSession(null); 290 329 } 291 330 292 331 export async function switchAccount(did: string): Promise<void> { 293 - const account = state.savedAccounts.find(a => a.did === did) 332 + const account = state.savedAccounts.find((a) => a.did === did); 294 333 if (!account) { 295 - throw new Error('Account not found') 334 + throw new Error("Account not found"); 296 335 } 297 - state.loading = true 298 - state.error = null 336 + state.loading = true; 337 + state.error = null; 299 338 try { 300 - const session = await api.getSession(account.accessJwt) 301 - state.session = { ...session, accessJwt: account.accessJwt, refreshJwt: account.refreshJwt } 302 - saveSession(state.session) 303 - addOrUpdateSavedAccount(state.session) 339 + const session = await api.getSession(account.accessJwt); 340 + state.session = { 341 + ...session, 342 + accessJwt: account.accessJwt, 343 + refreshJwt: account.refreshJwt, 344 + }; 345 + saveSession(state.session); 346 + addOrUpdateSavedAccount(state.session); 304 347 } catch (e) { 305 348 if (e instanceof ApiError && e.status === 401) { 306 349 try { 307 - const tokens = await refreshOAuthToken(account.refreshJwt) 308 - const sessionInfo = await api.getSession(tokens.access_token) 350 + const tokens = await refreshOAuthToken(account.refreshJwt); 351 + const sessionInfo = await api.getSession(tokens.access_token); 309 352 const session: Session = { 310 353 ...sessionInfo, 311 354 accessJwt: tokens.access_token, 312 355 refreshJwt: tokens.refresh_token || account.refreshJwt, 313 - } 314 - state.session = session 315 - saveSession(session) 316 - addOrUpdateSavedAccount(session) 356 + }; 357 + state.session = session; 358 + saveSession(session); 359 + addOrUpdateSavedAccount(session); 317 360 } catch { 318 - removeSavedAccount(did) 319 - state.error = 'Session expired. Please log in again.' 320 - throw new Error('Session expired') 361 + removeSavedAccount(did); 362 + state.error = "Session expired. Please log in again."; 363 + throw new Error("Session expired"); 321 364 } 322 365 } else { 323 - state.error = 'Failed to switch account' 324 - throw e 366 + state.error = "Failed to switch account"; 367 + throw e; 325 368 } 326 369 } finally { 327 - state.loading = false 370 + state.loading = false; 328 371 } 329 372 } 330 373 331 374 export function forgetAccount(did: string): void { 332 - removeSavedAccount(did) 375 + removeSavedAccount(did); 333 376 } 334 377 335 378 export function getAuthState() { 336 - return state 379 + return state; 337 380 } 338 381 339 382 export async function refreshSession(): Promise<void> { 340 - if (!state.session) return 383 + if (!state.session) return; 341 384 try { 342 - const sessionInfo = await api.getSession(state.session.accessJwt) 385 + const sessionInfo = await api.getSession(state.session.accessJwt); 343 386 state.session = { 344 387 ...sessionInfo, 345 388 accessJwt: state.session.accessJwt, 346 389 refreshJwt: state.session.refreshJwt, 347 - } 348 - saveSession(state.session) 349 - addOrUpdateSavedAccount(state.session) 390 + }; 391 + saveSession(state.session); 392 + addOrUpdateSavedAccount(state.session); 350 393 } catch (e) { 351 - console.error('Failed to refresh session:', e) 394 + console.error("Failed to refresh session:", e); 352 395 } 353 396 } 354 397 355 398 export function getToken(): string | null { 356 - return state.session?.accessJwt ?? null 399 + return state.session?.accessJwt ?? null; 357 400 } 358 401 359 402 export async function getValidToken(): Promise<string | null> { 360 - if (!state.session) return null 403 + if (!state.session) return null; 361 404 try { 362 - await api.getSession(state.session.accessJwt) 363 - return state.session.accessJwt 405 + await api.getSession(state.session.accessJwt); 406 + return state.session.accessJwt; 364 407 } catch (e) { 365 408 if (e instanceof ApiError && e.status === 401) { 366 409 try { 367 - const tokens = await refreshOAuthToken(state.session.refreshJwt) 368 - const sessionInfo = await api.getSession(tokens.access_token) 410 + const tokens = await refreshOAuthToken(state.session.refreshJwt); 411 + const sessionInfo = await api.getSession(tokens.access_token); 369 412 const session: Session = { 370 413 ...sessionInfo, 371 414 accessJwt: tokens.access_token, 372 415 refreshJwt: tokens.refresh_token || state.session.refreshJwt, 373 - } 374 - state.session = session 375 - saveSession(session) 376 - addOrUpdateSavedAccount(session) 377 - return session.accessJwt 416 + }; 417 + state.session = session; 418 + saveSession(session); 419 + addOrUpdateSavedAccount(session); 420 + return session.accessJwt; 378 421 } catch { 379 - return null 422 + return null; 380 423 } 381 424 } 382 - return null 425 + return null; 383 426 } 384 427 } 385 428 386 429 export function isAuthenticated(): boolean { 387 - return state.session !== null 430 + return state.session !== null; 388 431 } 389 432 390 - export function _testSetState(newState: { session: Session | null; loading: boolean; error: string | null; savedAccounts?: SavedAccount[] }) { 391 - state.session = newState.session 392 - state.loading = newState.loading 393 - state.error = newState.error 394 - state.savedAccounts = newState.savedAccounts ?? [] 433 + export function _testSetState( 434 + newState: { 435 + session: Session | null; 436 + loading: boolean; 437 + error: string | null; 438 + savedAccounts?: SavedAccount[]; 439 + }, 440 + ) { 441 + state.session = newState.session; 442 + state.loading = newState.loading; 443 + state.error = newState.error; 444 + state.savedAccounts = newState.savedAccounts ?? []; 395 445 } 396 446 397 447 export function _testReset() { 398 - state.session = null 399 - state.loading = true 400 - state.error = null 401 - state.savedAccounts = [] 402 - localStorage.removeItem(STORAGE_KEY) 403 - localStorage.removeItem(ACCOUNTS_KEY) 448 + state.session = null; 449 + state.loading = true; 450 + state.error = null; 451 + state.savedAccounts = []; 452 + localStorage.removeItem(STORAGE_KEY); 453 + localStorage.removeItem(ACCOUNTS_KEY); 404 454 }
+48 -44
frontend/src/lib/crypto.ts
··· 1 - import * as secp from '@noble/secp256k1' 2 - import { base58btc } from 'multiformats/bases/base58' 1 + import * as secp from "@noble/secp256k1"; 2 + import { base58btc } from "multiformats/bases/base58"; 3 3 4 - const SECP256K1_MULTICODEC_PREFIX = new Uint8Array([0xe7, 0x01]) 4 + const SECP256K1_MULTICODEC_PREFIX = new Uint8Array([0xe7, 0x01]); 5 5 6 6 export interface Keypair { 7 - privateKey: Uint8Array 8 - publicKey: Uint8Array 9 - publicKeyMultibase: string 10 - publicKeyDidKey: string 7 + privateKey: Uint8Array; 8 + publicKey: Uint8Array; 9 + publicKeyMultibase: string; 10 + publicKeyDidKey: string; 11 11 } 12 12 13 13 export async function generateKeypair(): Promise<Keypair> { 14 - const privateKey = secp.utils.randomPrivateKey() 15 - const publicKey = secp.getPublicKey(privateKey, true) 14 + const privateKey = secp.utils.randomPrivateKey(); 15 + const publicKey = secp.getPublicKey(privateKey, true); 16 16 17 - const multicodecKey = new Uint8Array(SECP256K1_MULTICODEC_PREFIX.length + publicKey.length) 18 - multicodecKey.set(SECP256K1_MULTICODEC_PREFIX, 0) 19 - multicodecKey.set(publicKey, SECP256K1_MULTICODEC_PREFIX.length) 17 + const multicodecKey = new Uint8Array( 18 + SECP256K1_MULTICODEC_PREFIX.length + publicKey.length, 19 + ); 20 + multicodecKey.set(SECP256K1_MULTICODEC_PREFIX, 0); 21 + multicodecKey.set(publicKey, SECP256K1_MULTICODEC_PREFIX.length); 20 22 21 - const publicKeyMultibase = base58btc.encode(multicodecKey) 22 - const publicKeyDidKey = `did:key:${publicKeyMultibase}` 23 + const publicKeyMultibase = base58btc.encode(multicodecKey); 24 + const publicKeyDidKey = `did:key:${publicKeyMultibase}`; 23 25 24 26 return { 25 27 privateKey, 26 28 publicKey, 27 29 publicKeyMultibase, 28 30 publicKeyDidKey, 29 - } 31 + }; 30 32 } 31 33 32 34 function base64UrlEncode(data: Uint8Array | string): string { 33 - const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data 34 - let binary = '' 35 + const bytes = typeof data === "string" 36 + ? new TextEncoder().encode(data) 37 + : data; 38 + let binary = ""; 35 39 for (let i = 0; i < bytes.length; i++) { 36 - binary += String.fromCharCode(bytes[i]) 40 + binary += String.fromCharCode(bytes[i]); 37 41 } 38 - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') 42 + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); 39 43 } 40 44 41 45 export async function createServiceJwt( 42 46 privateKey: Uint8Array, 43 47 issuerDid: string, 44 48 audienceDid: string, 45 - lxm: string 49 + lxm: string, 46 50 ): Promise<string> { 47 51 const header = { 48 - alg: 'ES256K', 49 - typ: 'JWT', 50 - } 52 + alg: "ES256K", 53 + typ: "JWT", 54 + }; 51 55 52 - const now = Math.floor(Date.now() / 1000) 56 + const now = Math.floor(Date.now() / 1000); 53 57 const payload = { 54 58 iss: issuerDid, 55 59 sub: issuerDid, ··· 57 61 exp: now + 180, 58 62 iat: now, 59 63 lxm: lxm, 60 - } 64 + }; 61 65 62 - const headerEncoded = base64UrlEncode(JSON.stringify(header)) 63 - const payloadEncoded = base64UrlEncode(JSON.stringify(payload)) 64 - const message = `${headerEncoded}.${payloadEncoded}` 66 + const headerEncoded = base64UrlEncode(JSON.stringify(header)); 67 + const payloadEncoded = base64UrlEncode(JSON.stringify(payload)); 68 + const message = `${headerEncoded}.${payloadEncoded}`; 65 69 66 - const msgBytes = new TextEncoder().encode(message) 67 - const hashBuffer = await crypto.subtle.digest('SHA-256', msgBytes) 68 - const msgHash = new Uint8Array(hashBuffer) 69 - const signature = await secp.signAsync(msgHash, privateKey) 70 - const sigBytes = signature.toCompactRawBytes() 71 - const signatureEncoded = base64UrlEncode(sigBytes) 70 + const msgBytes = new TextEncoder().encode(message); 71 + const hashBuffer = await crypto.subtle.digest("SHA-256", msgBytes); 72 + const msgHash = new Uint8Array(hashBuffer); 73 + const signature = await secp.signAsync(msgHash, privateKey); 74 + const sigBytes = signature.toCompactRawBytes(); 75 + const signatureEncoded = base64UrlEncode(sigBytes); 72 76 73 - return `${message}.${signatureEncoded}` 77 + return `${message}.${signatureEncoded}`; 74 78 } 75 79 76 80 export function generateDidDocument( 77 81 did: string, 78 82 publicKeyMultibase: string, 79 83 handle: string, 80 - pdsEndpoint: string 84 + pdsEndpoint: string, 81 85 ): object { 82 86 return { 83 - '@context': [ 84 - 'https://www.w3.org/ns/did/v1', 85 - 'https://w3id.org/security/multikey/v1', 86 - 'https://w3id.org/security/suites/secp256k1-2019/v1', 87 + "@context": [ 88 + "https://www.w3.org/ns/did/v1", 89 + "https://w3id.org/security/multikey/v1", 90 + "https://w3id.org/security/suites/secp256k1-2019/v1", 87 91 ], 88 92 id: did, 89 93 alsoKnownAs: [`at://${handle}`], 90 94 verificationMethod: [ 91 95 { 92 96 id: `${did}#atproto`, 93 - type: 'Multikey', 97 + type: "Multikey", 94 98 controller: did, 95 99 publicKeyMultibase: publicKeyMultibase, 96 100 }, 97 101 ], 98 102 service: [ 99 103 { 100 - id: '#atproto_pds', 101 - type: 'AtprotoPersonalDataServer', 104 + id: "#atproto_pds", 105 + type: "AtprotoPersonalDataServer", 102 106 serviceEndpoint: pdsEndpoint, 103 107 }, 104 108 ], 105 - } 109 + }; 106 110 }
+12 -12
frontend/src/lib/date.ts
··· 1 1 export function formatDate(dateStr: string): string { 2 - const date = new Date(dateStr) 3 - const year = date.getFullYear() 4 - const month = String(date.getMonth() + 1).padStart(2, '0') 5 - const day = String(date.getDate()).padStart(2, '0') 6 - return `${year}-${month}-${day}` 2 + const date = new Date(dateStr); 3 + const year = date.getFullYear(); 4 + const month = String(date.getMonth() + 1).padStart(2, "0"); 5 + const day = String(date.getDate()).padStart(2, "0"); 6 + return `${year}-${month}-${day}`; 7 7 } 8 8 9 9 export function formatDateTime(dateStr: string): string { 10 - const date = new Date(dateStr) 11 - const year = date.getFullYear() 12 - const month = String(date.getMonth() + 1).padStart(2, '0') 13 - const day = String(date.getDate()).padStart(2, '0') 14 - const hours = String(date.getHours()).padStart(2, '0') 15 - const minutes = String(date.getMinutes()).padStart(2, '0') 16 - return `${year}-${month}-${day} ${hours}:${minutes}` 10 + const date = new Date(dateStr); 11 + const year = date.getFullYear(); 12 + const month = String(date.getMonth() + 1).padStart(2, "0"); 13 + const day = String(date.getDate()).padStart(2, "0"); 14 + const hours = String(date.getHours()).padStart(2, "0"); 15 + const minutes = String(date.getMinutes()).padStart(2, "0"); 16 + return `${year}-${month}-${day} ${hours}:${minutes}`; 17 17 }
+31 -31
frontend/src/lib/i18n.ts
··· 1 - import { register, init, getLocaleFromNavigator, locale, _ } from 'svelte-i18n' 1 + import { _, getLocaleFromNavigator, init, locale, register } from "svelte-i18n"; 2 2 3 - const LOCALE_STORAGE_KEY = 'tranquil-pds-locale' 3 + const LOCALE_STORAGE_KEY = "tranquil-pds-locale"; 4 4 5 - const SUPPORTED_LOCALES = ['en', 'zh', 'ja', 'ko', 'sv', 'fi'] as const 6 - export type SupportedLocale = typeof SUPPORTED_LOCALES[number] 5 + const SUPPORTED_LOCALES = ["en", "zh", "ja", "ko", "sv", "fi"] as const; 6 + export type SupportedLocale = typeof SUPPORTED_LOCALES[number]; 7 7 8 8 export const localeNames: Record<SupportedLocale, string> = { 9 - en: 'English', 10 - zh: '中文', 11 - ja: '日本語', 12 - ko: '한국어', 13 - sv: 'Svenska', 14 - fi: 'Suomi' 15 - } 9 + en: "English", 10 + zh: "中文", 11 + ja: "日本語", 12 + ko: "한국어", 13 + sv: "Svenska", 14 + fi: "Suomi", 15 + }; 16 16 17 - register('en', () => import('../locales/en.json')) 18 - register('zh', () => import('../locales/zh.json')) 19 - register('ja', () => import('../locales/ja.json')) 20 - register('ko', () => import('../locales/ko.json')) 21 - register('sv', () => import('../locales/sv.json')) 22 - register('fi', () => import('../locales/fi.json')) 17 + register("en", () => import("../locales/en.json")); 18 + register("zh", () => import("../locales/zh.json")); 19 + register("ja", () => import("../locales/ja.json")); 20 + register("ko", () => import("../locales/ko.json")); 21 + register("sv", () => import("../locales/sv.json")); 22 + register("fi", () => import("../locales/fi.json")); 23 23 24 24 function getInitialLocale(): string { 25 - const stored = localStorage.getItem(LOCALE_STORAGE_KEY) 25 + const stored = localStorage.getItem(LOCALE_STORAGE_KEY); 26 26 if (stored && SUPPORTED_LOCALES.includes(stored as SupportedLocale)) { 27 - return stored 27 + return stored; 28 28 } 29 29 30 - const browserLocale = getLocaleFromNavigator() 30 + const browserLocale = getLocaleFromNavigator(); 31 31 if (browserLocale) { 32 - const lang = browserLocale.split('-')[0] 32 + const lang = browserLocale.split("-")[0]; 33 33 if (SUPPORTED_LOCALES.includes(lang as SupportedLocale)) { 34 - return lang 34 + return lang; 35 35 } 36 36 } 37 37 38 - return 'en' 38 + return "en"; 39 39 } 40 40 41 41 export function initI18n() { 42 42 init({ 43 - fallbackLocale: 'en', 44 - initialLocale: getInitialLocale() 45 - }) 43 + fallbackLocale: "en", 44 + initialLocale: getInitialLocale(), 45 + }); 46 46 } 47 47 48 48 export function setLocale(newLocale: SupportedLocale) { 49 - locale.set(newLocale) 50 - localStorage.setItem(LOCALE_STORAGE_KEY, newLocale) 51 - document.documentElement.lang = newLocale 49 + locale.set(newLocale); 50 + localStorage.setItem(LOCALE_STORAGE_KEY, newLocale); 51 + document.documentElement.lang = newLocale; 52 52 } 53 53 54 54 export function getSupportedLocales(): SupportedLocale[] { 55 - return [...SUPPORTED_LOCALES] 55 + return [...SUPPORTED_LOCALES]; 56 56 } 57 57 58 - export { locale, _ } 58 + export { _, locale };
+116 -91
frontend/src/lib/oauth.ts
··· 1 - const OAUTH_STATE_KEY = 'tranquil_pds_oauth_state' 2 - const OAUTH_VERIFIER_KEY = 'tranquil_pds_oauth_verifier' 1 + const OAUTH_STATE_KEY = "tranquil_pds_oauth_state"; 2 + const OAUTH_VERIFIER_KEY = "tranquil_pds_oauth_verifier"; 3 3 const SCOPES = [ 4 - 'atproto', 5 - 'repo:*?action=create', 6 - 'repo:*?action=update', 7 - 'repo:*?action=delete', 8 - 'blob:*/*', 9 - ].join(' ') 4 + "atproto", 5 + "repo:*?action=create", 6 + "repo:*?action=update", 7 + "repo:*?action=delete", 8 + "blob:*/*", 9 + ].join(" "); 10 10 const CLIENT_ID = !(import.meta.env.DEV) 11 - ? `${window.location.origin}/oauth/client-metadata.json` 12 - : `http://localhost/?scope=${SCOPES}` 13 - const REDIRECT_URI = `${window.location.origin}/` 11 + ? `${window.location.origin}/oauth/client-metadata.json` 12 + : `http://localhost/?scope=${SCOPES}`; 13 + const REDIRECT_URI = `${window.location.origin}/`; 14 14 15 15 interface OAuthState { 16 - state: string 17 - codeVerifier: string 18 - returnTo?: string 16 + state: string; 17 + codeVerifier: string; 18 + returnTo?: string; 19 19 } 20 20 21 21 function generateRandomString(length: number): string { 22 - const array = new Uint8Array(length) 23 - crypto.getRandomValues(array) 24 - return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('') 22 + const array = new Uint8Array(length); 23 + crypto.getRandomValues(array); 24 + return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join( 25 + "", 26 + ); 25 27 } 26 28 27 29 async function sha256(plain: string): Promise<ArrayBuffer> { 28 - const encoder = new TextEncoder() 29 - const data = encoder.encode(plain) 30 - return crypto.subtle.digest('SHA-256', data) 30 + const encoder = new TextEncoder(); 31 + const data = encoder.encode(plain); 32 + return crypto.subtle.digest("SHA-256", data); 31 33 } 32 34 33 35 function base64UrlEncode(buffer: ArrayBuffer): string { 34 - const bytes = new Uint8Array(buffer) 35 - let binary = '' 36 + const bytes = new Uint8Array(buffer); 37 + let binary = ""; 36 38 for (const byte of bytes) { 37 - binary += String.fromCharCode(byte) 39 + binary += String.fromCharCode(byte); 38 40 } 39 - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') 41 + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace( 42 + /=+$/, 43 + "", 44 + ); 40 45 } 41 46 42 47 async function generateCodeChallenge(verifier: string): Promise<string> { 43 - const hash = await sha256(verifier) 44 - return base64UrlEncode(hash) 48 + const hash = await sha256(verifier); 49 + return base64UrlEncode(hash); 45 50 } 46 51 47 52 function generateState(): string { 48 - return generateRandomString(32) 53 + return generateRandomString(32); 49 54 } 50 55 51 56 function generateCodeVerifier(): string { 52 - return generateRandomString(32) 57 + return generateRandomString(32); 53 58 } 54 59 55 60 function saveOAuthState(state: OAuthState): void { 56 - sessionStorage.setItem(OAUTH_STATE_KEY, state.state) 57 - sessionStorage.setItem(OAUTH_VERIFIER_KEY, state.codeVerifier) 61 + sessionStorage.setItem(OAUTH_STATE_KEY, state.state); 62 + sessionStorage.setItem(OAUTH_VERIFIER_KEY, state.codeVerifier); 58 63 } 59 64 60 65 function getOAuthState(): OAuthState | null { 61 - const state = sessionStorage.getItem(OAUTH_STATE_KEY) 62 - const codeVerifier = sessionStorage.getItem(OAUTH_VERIFIER_KEY) 63 - if (!state || !codeVerifier) return null 64 - return { state, codeVerifier } 66 + const state = sessionStorage.getItem(OAUTH_STATE_KEY); 67 + const codeVerifier = sessionStorage.getItem(OAUTH_VERIFIER_KEY); 68 + if (!state || !codeVerifier) return null; 69 + return { state, codeVerifier }; 65 70 } 66 71 67 72 function clearOAuthState(): void { 68 - sessionStorage.removeItem(OAUTH_STATE_KEY) 69 - sessionStorage.removeItem(OAUTH_VERIFIER_KEY) 73 + sessionStorage.removeItem(OAUTH_STATE_KEY); 74 + sessionStorage.removeItem(OAUTH_VERIFIER_KEY); 70 75 } 71 76 72 77 export async function startOAuthLogin(): Promise<void> { 73 - const state = generateState() 74 - const codeVerifier = generateCodeVerifier() 75 - const codeChallenge = await generateCodeChallenge(codeVerifier) 78 + const state = generateState(); 79 + const codeVerifier = generateCodeVerifier(); 80 + const codeChallenge = await generateCodeChallenge(codeVerifier); 76 81 77 - saveOAuthState({ state, codeVerifier }) 82 + saveOAuthState({ state, codeVerifier }); 78 83 79 - const parResponse = await fetch('/oauth/par', { 80 - method: 'POST', 81 - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 84 + const parResponse = await fetch("/oauth/par", { 85 + method: "POST", 86 + headers: { "Content-Type": "application/x-www-form-urlencoded" }, 82 87 body: new URLSearchParams({ 83 88 client_id: CLIENT_ID, 84 89 redirect_uri: REDIRECT_URI, 85 - response_type: 'code', 90 + response_type: "code", 86 91 scope: SCOPES, 87 92 state: state, 88 93 code_challenge: codeChallenge, 89 - code_challenge_method: 'S256', 94 + code_challenge_method: "S256", 90 95 }), 91 - }) 96 + }); 92 97 93 98 if (!parResponse.ok) { 94 - const error = await parResponse.json().catch(() => ({ error: 'Unknown error' })) 95 - throw new Error(error.error_description || error.error || 'Failed to start OAuth flow') 99 + const error = await parResponse.json().catch(() => ({ 100 + error: "Unknown error", 101 + })); 102 + throw new Error( 103 + error.error_description || error.error || "Failed to start OAuth flow", 104 + ); 96 105 } 97 106 98 - const { request_uri } = await parResponse.json() 107 + const { request_uri } = await parResponse.json(); 99 108 100 - const authorizeUrl = new URL('/oauth/authorize', window.location.origin) 101 - authorizeUrl.searchParams.set('client_id', CLIENT_ID) 102 - authorizeUrl.searchParams.set('request_uri', request_uri) 109 + const authorizeUrl = new URL("/oauth/authorize", window.location.origin); 110 + authorizeUrl.searchParams.set("client_id", CLIENT_ID); 111 + authorizeUrl.searchParams.set("request_uri", request_uri); 103 112 104 - window.location.href = authorizeUrl.toString() 113 + window.location.href = authorizeUrl.toString(); 105 114 } 106 115 107 116 export interface OAuthTokens { 108 - access_token: string 109 - refresh_token?: string 110 - token_type: string 111 - expires_in?: number 112 - scope?: string 113 - sub: string 117 + access_token: string; 118 + refresh_token?: string; 119 + token_type: string; 120 + expires_in?: number; 121 + scope?: string; 122 + sub: string; 114 123 } 115 124 116 - export async function handleOAuthCallback(code: string, state: string): Promise<OAuthTokens> { 117 - const savedState = getOAuthState() 125 + export async function handleOAuthCallback( 126 + code: string, 127 + state: string, 128 + ): Promise<OAuthTokens> { 129 + const savedState = getOAuthState(); 118 130 if (!savedState) { 119 - throw new Error('No OAuth state found. Please try logging in again.') 131 + throw new Error("No OAuth state found. Please try logging in again."); 120 132 } 121 133 122 134 if (savedState.state !== state) { 123 - clearOAuthState() 124 - throw new Error('OAuth state mismatch. Please try logging in again.') 135 + clearOAuthState(); 136 + throw new Error("OAuth state mismatch. Please try logging in again."); 125 137 } 126 138 127 - const tokenResponse = await fetch('/oauth/token', { 128 - method: 'POST', 129 - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 139 + const tokenResponse = await fetch("/oauth/token", { 140 + method: "POST", 141 + headers: { "Content-Type": "application/x-www-form-urlencoded" }, 130 142 body: new URLSearchParams({ 131 - grant_type: 'authorization_code', 143 + grant_type: "authorization_code", 132 144 client_id: CLIENT_ID, 133 145 code: code, 134 146 redirect_uri: REDIRECT_URI, 135 147 code_verifier: savedState.codeVerifier, 136 148 }), 137 - }) 149 + }); 138 150 139 - clearOAuthState() 151 + clearOAuthState(); 140 152 141 153 if (!tokenResponse.ok) { 142 - const error = await tokenResponse.json().catch(() => ({ error: 'Unknown error' })) 143 - throw new Error(error.error_description || error.error || 'Failed to exchange code for tokens') 154 + const error = await tokenResponse.json().catch(() => ({ 155 + error: "Unknown error", 156 + })); 157 + throw new Error( 158 + error.error_description || error.error || 159 + "Failed to exchange code for tokens", 160 + ); 144 161 } 145 162 146 - return tokenResponse.json() 163 + return tokenResponse.json(); 147 164 } 148 165 149 - export async function refreshOAuthToken(refreshToken: string): Promise<OAuthTokens> { 150 - const tokenResponse = await fetch('/oauth/token', { 151 - method: 'POST', 152 - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 166 + export async function refreshOAuthToken( 167 + refreshToken: string, 168 + ): Promise<OAuthTokens> { 169 + const tokenResponse = await fetch("/oauth/token", { 170 + method: "POST", 171 + headers: { "Content-Type": "application/x-www-form-urlencoded" }, 153 172 body: new URLSearchParams({ 154 - grant_type: 'refresh_token', 173 + grant_type: "refresh_token", 155 174 client_id: CLIENT_ID, 156 175 refresh_token: refreshToken, 157 176 }), 158 - }) 177 + }); 159 178 160 179 if (!tokenResponse.ok) { 161 - const error = await tokenResponse.json().catch(() => ({ error: 'Unknown error' })) 162 - throw new Error(error.error_description || error.error || 'Failed to refresh token') 180 + const error = await tokenResponse.json().catch(() => ({ 181 + error: "Unknown error", 182 + })); 183 + throw new Error( 184 + error.error_description || error.error || "Failed to refresh token", 185 + ); 163 186 } 164 187 165 - return tokenResponse.json() 188 + return tokenResponse.json(); 166 189 } 167 190 168 - export function checkForOAuthCallback(): { code: string; state: string } | null { 169 - const params = new URLSearchParams(window.location.search) 170 - const code = params.get('code') 171 - const state = params.get('state') 191 + export function checkForOAuthCallback(): 192 + | { code: string; state: string } 193 + | null { 194 + const params = new URLSearchParams(window.location.search); 195 + const code = params.get("code"); 196 + const state = params.get("state"); 172 197 173 198 if (code && state) { 174 - return { code, state } 199 + return { code, state }; 175 200 } 176 201 177 - return null 202 + return null; 178 203 } 179 204 180 205 export function clearOAuthCallbackParams(): void { 181 - const url = new URL(window.location.href) 182 - url.search = '' 183 - window.history.replaceState({}, '', url.toString()) 206 + const url = new URL(window.location.href); 207 + url.search = ""; 208 + window.history.replaceState({}, "", url.toString()); 184 209 }
+200 -141
frontend/src/lib/registration/flow.svelte.ts
··· 1 - import { api, ApiError } from '../api' 2 - import { generateKeypair, createServiceJwt, generateDidDocument } from '../crypto' 1 + import { api, ApiError } from "../api"; 2 + import { 3 + createServiceJwt, 4 + generateDidDocument, 5 + generateKeypair, 6 + } from "../crypto"; 3 7 import type { 8 + AccountResult, 9 + ExternalDidWebState, 10 + RegistrationInfo, 4 11 RegistrationMode, 5 12 RegistrationStep, 6 - RegistrationInfo, 7 - ExternalDidWebState, 8 - AccountResult, 9 13 SessionState, 10 - } from './types' 14 + } from "./types"; 11 15 12 16 export interface RegistrationFlowState { 13 - mode: RegistrationMode 14 - step: RegistrationStep 15 - info: RegistrationInfo 16 - externalDidWeb: ExternalDidWebState 17 - account: AccountResult | null 18 - session: SessionState | null 19 - error: string | null 20 - submitting: boolean 21 - pdsHostname: string 17 + mode: RegistrationMode; 18 + step: RegistrationStep; 19 + info: RegistrationInfo; 20 + externalDidWeb: ExternalDidWebState; 21 + account: AccountResult | null; 22 + session: SessionState | null; 23 + error: string | null; 24 + submitting: boolean; 25 + pdsHostname: string; 22 26 } 23 27 24 - export function createRegistrationFlow(mode: RegistrationMode, pdsHostname: string) { 28 + export function createRegistrationFlow( 29 + mode: RegistrationMode, 30 + pdsHostname: string, 31 + ) { 25 32 let state = $state<RegistrationFlowState>({ 26 33 mode, 27 - step: 'info', 34 + step: "info", 28 35 info: { 29 - handle: '', 30 - email: '', 31 - password: '', 32 - inviteCode: '', 33 - didType: 'plc', 34 - externalDid: '', 35 - verificationChannel: 'email', 36 - discordId: '', 37 - telegramUsername: '', 38 - signalNumber: '', 36 + handle: "", 37 + email: "", 38 + password: "", 39 + inviteCode: "", 40 + didType: "plc", 41 + externalDid: "", 42 + verificationChannel: "email", 43 + discordId: "", 44 + telegramUsername: "", 45 + signalNumber: "", 39 46 }, 40 47 externalDidWeb: { 41 - keyMode: 'reserved', 48 + keyMode: "reserved", 42 49 }, 43 50 account: null, 44 51 session: null, 45 52 error: null, 46 53 submitting: false, 47 54 pdsHostname, 48 - }) 55 + }); 49 56 50 57 function getPdsEndpoint(): string { 51 - return `https://${state.pdsHostname}` 58 + return `https://${state.pdsHostname}`; 52 59 } 53 60 54 61 function getPdsDid(): string { 55 - return `did:web:${state.pdsHostname}` 62 + return `did:web:${state.pdsHostname}`; 56 63 } 57 64 58 65 function getFullHandle(): string { 59 - return `${state.info.handle.trim()}.${state.pdsHostname}` 66 + return `${state.info.handle.trim()}.${state.pdsHostname}`; 60 67 } 61 68 62 69 function extractDomain(did: string): string { 63 - return did.replace('did:web:', '').replace(/%3A/g, ':') 70 + return did.replace("did:web:", "").replace(/%3A/g, ":"); 64 71 } 65 72 66 73 function setError(err: unknown) { 67 74 if (err instanceof ApiError) { 68 - state.error = err.message || 'An error occurred' 75 + state.error = err.message || "An error occurred"; 69 76 } else if (err instanceof Error) { 70 - state.error = err.message || 'An error occurred' 77 + state.error = err.message || "An error occurred"; 71 78 } else { 72 - state.error = 'An error occurred' 79 + state.error = "An error occurred"; 73 80 } 74 81 } 75 82 76 83 async function proceedFromInfo() { 77 - state.error = null 78 - if (state.info.didType === 'web-external') { 79 - state.step = 'key-choice' 84 + state.error = null; 85 + if (state.info.didType === "web-external") { 86 + state.step = "key-choice"; 80 87 } else { 81 - state.step = 'creating' 88 + state.step = "creating"; 82 89 } 83 90 } 84 91 85 - async function selectKeyMode(keyMode: 'reserved' | 'byod') { 86 - state.submitting = true 87 - state.error = null 88 - state.externalDidWeb.keyMode = keyMode 92 + async function selectKeyMode(keyMode: "reserved" | "byod") { 93 + state.submitting = true; 94 + state.error = null; 95 + state.externalDidWeb.keyMode = keyMode; 89 96 90 97 try { 91 - let publicKeyMultibase: string 98 + let publicKeyMultibase: string; 92 99 93 - if (keyMode === 'reserved') { 94 - const result = await api.reserveSigningKey(state.info.externalDid!.trim()) 95 - state.externalDidWeb.reservedSigningKey = result.signingKey 96 - publicKeyMultibase = result.signingKey.replace('did:key:', '') 100 + if (keyMode === "reserved") { 101 + const result = await api.reserveSigningKey( 102 + state.info.externalDid!.trim(), 103 + ); 104 + state.externalDidWeb.reservedSigningKey = result.signingKey; 105 + publicKeyMultibase = result.signingKey.replace("did:key:", ""); 97 106 } else { 98 - const keypair = await generateKeypair() 99 - state.externalDidWeb.byodPrivateKey = keypair.privateKey 100 - state.externalDidWeb.byodPublicKeyMultibase = keypair.publicKeyMultibase 101 - publicKeyMultibase = keypair.publicKeyMultibase 107 + const keypair = await generateKeypair(); 108 + state.externalDidWeb.byodPrivateKey = keypair.privateKey; 109 + state.externalDidWeb.byodPublicKeyMultibase = 110 + keypair.publicKeyMultibase; 111 + publicKeyMultibase = keypair.publicKeyMultibase; 102 112 } 103 113 104 114 const didDoc = generateDidDocument( 105 115 state.info.externalDid!.trim(), 106 116 publicKeyMultibase, 107 117 getFullHandle(), 108 - getPdsEndpoint() 109 - ) 110 - state.externalDidWeb.initialDidDocument = JSON.stringify(didDoc, null, '\t') 111 - state.step = 'initial-did-doc' 118 + getPdsEndpoint(), 119 + ); 120 + state.externalDidWeb.initialDidDocument = JSON.stringify( 121 + didDoc, 122 + null, 123 + "\t", 124 + ); 125 + state.step = "initial-did-doc"; 112 126 } catch (err) { 113 - setError(err) 127 + setError(err); 114 128 } finally { 115 - state.submitting = false 129 + state.submitting = false; 116 130 } 117 131 } 118 132 119 133 async function confirmInitialDidDoc() { 120 - state.step = 'creating' 134 + state.step = "creating"; 121 135 } 122 136 123 137 async function createPasswordAccount() { 124 - state.submitting = true 125 - state.error = null 138 + state.submitting = true; 139 + state.error = null; 126 140 127 141 try { 128 - let byodToken: string | undefined 142 + let byodToken: string | undefined; 129 143 130 - if (state.info.didType === 'web-external' && state.externalDidWeb.keyMode === 'byod' && state.externalDidWeb.byodPrivateKey) { 144 + if ( 145 + state.info.didType === "web-external" && 146 + state.externalDidWeb.keyMode === "byod" && 147 + state.externalDidWeb.byodPrivateKey 148 + ) { 131 149 byodToken = await createServiceJwt( 132 150 state.externalDidWeb.byodPrivateKey, 133 151 state.info.externalDid!.trim(), 134 152 getPdsDid(), 135 - 'com.atproto.server.createAccount' 136 - ) 153 + "com.atproto.server.createAccount", 154 + ); 137 155 } 138 156 139 157 const result = await api.createAccount({ ··· 142 160 password: state.info.password!, 143 161 inviteCode: state.info.inviteCode?.trim() || undefined, 144 162 didType: state.info.didType, 145 - did: state.info.didType === 'web-external' ? state.info.externalDid!.trim() : undefined, 146 - signingKey: state.info.didType === 'web-external' && state.externalDidWeb.keyMode === 'reserved' 163 + did: state.info.didType === "web-external" 164 + ? state.info.externalDid!.trim() 165 + : undefined, 166 + signingKey: state.info.didType === "web-external" && 167 + state.externalDidWeb.keyMode === "reserved" 147 168 ? state.externalDidWeb.reservedSigningKey 148 169 : undefined, 149 170 verificationChannel: state.info.verificationChannel, 150 171 discordId: state.info.discordId?.trim() || undefined, 151 172 telegramUsername: state.info.telegramUsername?.trim() || undefined, 152 173 signalNumber: state.info.signalNumber?.trim() || undefined, 153 - }, byodToken) 174 + }, byodToken); 154 175 155 176 state.account = { 156 177 did: result.did, 157 178 handle: result.handle, 158 - } 159 - state.step = 'verify' 179 + }; 180 + state.step = "verify"; 160 181 } catch (err) { 161 - setError(err) 182 + setError(err); 162 183 } finally { 163 - state.submitting = false 184 + state.submitting = false; 164 185 } 165 186 } 166 187 167 188 async function createPasskeyAccount() { 168 - state.submitting = true 169 - state.error = null 189 + state.submitting = true; 190 + state.error = null; 170 191 171 192 try { 172 - let byodToken: string | undefined 193 + let byodToken: string | undefined; 173 194 174 - if (state.info.didType === 'web-external' && state.externalDidWeb.keyMode === 'byod' && state.externalDidWeb.byodPrivateKey) { 195 + if ( 196 + state.info.didType === "web-external" && 197 + state.externalDidWeb.keyMode === "byod" && 198 + state.externalDidWeb.byodPrivateKey 199 + ) { 175 200 byodToken = await createServiceJwt( 176 201 state.externalDidWeb.byodPrivateKey, 177 202 state.info.externalDid!.trim(), 178 203 getPdsDid(), 179 - 'com.atproto.server.createAccount' 180 - ) 204 + "com.atproto.server.createAccount", 205 + ); 181 206 } 182 207 183 208 const result = await api.createPasskeyAccount({ ··· 185 210 email: state.info.email?.trim() || undefined, 186 211 inviteCode: state.info.inviteCode?.trim() || undefined, 187 212 didType: state.info.didType, 188 - did: state.info.didType === 'web-external' ? state.info.externalDid!.trim() : undefined, 189 - signingKey: state.info.didType === 'web-external' && state.externalDidWeb.keyMode === 'reserved' 213 + did: state.info.didType === "web-external" 214 + ? state.info.externalDid!.trim() 215 + : undefined, 216 + signingKey: state.info.didType === "web-external" && 217 + state.externalDidWeb.keyMode === "reserved" 190 218 ? state.externalDidWeb.reservedSigningKey 191 219 : undefined, 192 220 verificationChannel: state.info.verificationChannel, 193 221 discordId: state.info.discordId?.trim() || undefined, 194 222 telegramUsername: state.info.telegramUsername?.trim() || undefined, 195 223 signalNumber: state.info.signalNumber?.trim() || undefined, 196 - }, byodToken) 224 + }, byodToken); 197 225 198 226 state.account = { 199 227 did: result.did, 200 228 handle: result.handle, 201 229 setupToken: result.setupToken, 202 - } 203 - state.step = 'passkey' 230 + }; 231 + state.step = "passkey"; 204 232 } catch (err) { 205 - setError(err) 233 + setError(err); 206 234 } finally { 207 - state.submitting = false 235 + state.submitting = false; 208 236 } 209 237 } 210 238 211 239 function setPasskeyComplete(appPassword: string, appPasswordName: string) { 212 240 if (state.account) { 213 - state.account.appPassword = appPassword 214 - state.account.appPasswordName = appPasswordName 241 + state.account.appPassword = appPassword; 242 + state.account.appPasswordName = appPasswordName; 215 243 } 216 - state.step = 'app-password' 244 + state.step = "app-password"; 217 245 } 218 246 219 247 function proceedFromAppPassword() { 220 - state.step = 'verify' 248 + state.step = "verify"; 221 249 } 222 250 223 251 async function verifyAccount(code: string) { 224 - state.submitting = true 225 - state.error = null 252 + state.submitting = true; 253 + state.error = null; 226 254 227 255 try { 228 - const confirmResult = await api.confirmSignup(state.account!.did, code.trim()) 256 + const confirmResult = await api.confirmSignup( 257 + state.account!.did, 258 + code.trim(), 259 + ); 229 260 230 - if (state.info.didType === 'web-external') { 231 - const password = state.mode === 'passkey' ? state.account!.appPassword! : state.info.password! 232 - const session = await api.createSession(state.account!.did, password) 261 + if (state.info.didType === "web-external") { 262 + const password = state.mode === "passkey" 263 + ? state.account!.appPassword! 264 + : state.info.password!; 265 + const session = await api.createSession(state.account!.did, password); 233 266 state.session = { 234 267 accessJwt: session.accessJwt, 235 268 refreshJwt: session.refreshJwt, 236 - } 269 + }; 237 270 238 - if (state.externalDidWeb.keyMode === 'byod') { 239 - const credentials = await api.getRecommendedDidCredentials(session.accessJwt) 240 - const newPublicKeyMultibase = credentials.verificationMethods?.atproto?.replace('did:key:', '') || '' 271 + if (state.externalDidWeb.keyMode === "byod") { 272 + const credentials = await api.getRecommendedDidCredentials( 273 + session.accessJwt, 274 + ); 275 + const newPublicKeyMultibase = 276 + credentials.verificationMethods?.atproto?.replace("did:key:", "") || 277 + ""; 241 278 242 279 const didDoc = generateDidDocument( 243 280 state.info.externalDid!.trim(), 244 281 newPublicKeyMultibase, 245 282 state.account!.handle, 246 - getPdsEndpoint() 247 - ) 248 - state.externalDidWeb.updatedDidDocument = JSON.stringify(didDoc, null, '\t') 249 - state.step = 'updated-did-doc' 283 + getPdsEndpoint(), 284 + ); 285 + state.externalDidWeb.updatedDidDocument = JSON.stringify( 286 + didDoc, 287 + null, 288 + "\t", 289 + ); 290 + state.step = "updated-did-doc"; 250 291 } else { 251 - await api.activateAccount(session.accessJwt) 252 - await finalizeSession() 253 - state.step = 'redirect-to-dashboard' 292 + await api.activateAccount(session.accessJwt); 293 + await finalizeSession(); 294 + state.step = "redirect-to-dashboard"; 254 295 } 255 296 } else { 256 297 state.session = { 257 298 accessJwt: confirmResult.accessJwt, 258 299 refreshJwt: confirmResult.refreshJwt, 259 - } 260 - await finalizeSession() 261 - state.step = 'redirect-to-dashboard' 300 + }; 301 + await finalizeSession(); 302 + state.step = "redirect-to-dashboard"; 262 303 } 263 304 } catch (err) { 264 - setError(err) 305 + setError(err); 265 306 } finally { 266 - state.submitting = false 307 + state.submitting = false; 267 308 } 268 309 } 269 310 270 311 async function activateAccount() { 271 - state.submitting = true 272 - state.error = null 312 + state.submitting = true; 313 + state.error = null; 273 314 274 315 try { 275 - await api.activateAccount(state.session!.accessJwt) 276 - await finalizeSession() 277 - state.step = 'redirect-to-dashboard' 316 + await api.activateAccount(state.session!.accessJwt); 317 + await finalizeSession(); 318 + state.step = "redirect-to-dashboard"; 278 319 } catch (err) { 279 - setError(err) 320 + setError(err); 280 321 } finally { 281 - state.submitting = false 322 + state.submitting = false; 282 323 } 283 324 } 284 325 285 326 function goBack() { 286 327 switch (state.step) { 287 - case 'key-choice': 288 - state.step = 'info' 289 - break 290 - case 'initial-did-doc': 291 - state.step = 'key-choice' 292 - break 293 - case 'passkey': 294 - state.step = state.info.didType === 'web-external' ? 'initial-did-doc' : 'info' 295 - break 328 + case "key-choice": 329 + state.step = "info"; 330 + break; 331 + case "initial-did-doc": 332 + state.step = "key-choice"; 333 + break; 334 + case "passkey": 335 + state.step = state.info.didType === "web-external" 336 + ? "initial-did-doc" 337 + : "info"; 338 + break; 296 339 } 297 340 } 298 341 299 342 async function finalizeSession() { 300 - if (!state.session || !state.account) return 301 - const { setSession } = await import('../auth.svelte') 343 + if (!state.session || !state.account) return; 344 + const { setSession } = await import("../auth.svelte"); 302 345 setSession({ 303 346 did: state.account.did, 304 347 handle: state.account.handle, 305 348 accessJwt: state.session.accessJwt, 306 349 refreshJwt: state.session.refreshJwt, 307 - }) 350 + }); 308 351 } 309 352 310 353 return { 311 - get state() { return state }, 312 - get info() { return state.info }, 313 - get externalDidWeb() { return state.externalDidWeb }, 314 - get account() { return state.account }, 315 - get session() { return state.session }, 354 + get state() { 355 + return state; 356 + }, 357 + get info() { 358 + return state.info; 359 + }, 360 + get externalDidWeb() { 361 + return state.externalDidWeb; 362 + }, 363 + get account() { 364 + return state.account; 365 + }, 366 + get session() { 367 + return state.session; 368 + }, 316 369 317 370 getPdsEndpoint, 318 371 getPdsDid, ··· 331 384 finalizeSession, 332 385 goBack, 333 386 334 - setError(msg: string) { state.error = msg }, 335 - clearError() { state.error = null }, 336 - setSubmitting(val: boolean) { state.submitting = val }, 337 - } 387 + setError(msg: string) { 388 + state.error = msg; 389 + }, 390 + clearError() { 391 + state.error = null; 392 + }, 393 + setSubmitting(val: boolean) { 394 + state.submitting = val; 395 + }, 396 + }; 338 397 } 339 398 340 - export type RegistrationFlow = ReturnType<typeof createRegistrationFlow> 399 + export type RegistrationFlow = ReturnType<typeof createRegistrationFlow>;
+6 -6
frontend/src/lib/registration/index.ts
··· 1 - export * from './types' 2 - export * from './flow.svelte' 3 - export { default as VerificationStep } from './VerificationStep.svelte' 4 - export { default as KeyChoiceStep } from './KeyChoiceStep.svelte' 5 - export { default as DidDocStep } from './DidDocStep.svelte' 6 - export { default as AppPasswordStep } from './AppPasswordStep.svelte' 1 + export * from "./types"; 2 + export * from "./flow.svelte"; 3 + export { default as VerificationStep } from "./VerificationStep.svelte"; 4 + export { default as KeyChoiceStep } from "./KeyChoiceStep.svelte"; 5 + export { default as DidDocStep } from "./DidDocStep.svelte"; 6 + export { default as AppPasswordStep } from "./AppPasswordStep.svelte";
+35 -35
frontend/src/lib/registration/types.ts
··· 1 - import type { VerificationChannel, DidType } from '../api' 1 + import type { DidType, VerificationChannel } from "../api"; 2 2 3 - export type RegistrationMode = 'password' | 'passkey' 3 + export type RegistrationMode = "password" | "passkey"; 4 4 5 5 export type RegistrationStep = 6 - | 'info' 7 - | 'key-choice' 8 - | 'initial-did-doc' 9 - | 'creating' 10 - | 'passkey' 11 - | 'app-password' 12 - | 'verify' 13 - | 'updated-did-doc' 14 - | 'activating' 15 - | 'redirect-to-dashboard' 6 + | "info" 7 + | "key-choice" 8 + | "initial-did-doc" 9 + | "creating" 10 + | "passkey" 11 + | "app-password" 12 + | "verify" 13 + | "updated-did-doc" 14 + | "activating" 15 + | "redirect-to-dashboard"; 16 16 17 17 export interface RegistrationInfo { 18 - handle: string 19 - email: string 20 - password?: string 21 - inviteCode?: string 22 - didType: DidType 23 - externalDid?: string 24 - verificationChannel: VerificationChannel 25 - discordId?: string 26 - telegramUsername?: string 27 - signalNumber?: string 18 + handle: string; 19 + email: string; 20 + password?: string; 21 + inviteCode?: string; 22 + didType: DidType; 23 + externalDid?: string; 24 + verificationChannel: VerificationChannel; 25 + discordId?: string; 26 + telegramUsername?: string; 27 + signalNumber?: string; 28 28 } 29 29 30 30 export interface ExternalDidWebState { 31 - keyMode: 'reserved' | 'byod' 32 - reservedSigningKey?: string 33 - byodPrivateKey?: Uint8Array 34 - byodPublicKeyMultibase?: string 35 - initialDidDocument?: string 36 - updatedDidDocument?: string 31 + keyMode: "reserved" | "byod"; 32 + reservedSigningKey?: string; 33 + byodPrivateKey?: Uint8Array; 34 + byodPublicKeyMultibase?: string; 35 + initialDidDocument?: string; 36 + updatedDidDocument?: string; 37 37 } 38 38 39 39 export interface AccountResult { 40 - did: string 41 - handle: string 42 - setupToken?: string 43 - appPassword?: string 44 - appPasswordName?: string 40 + did: string; 41 + handle: string; 42 + setupToken?: string; 43 + appPassword?: string; 44 + appPasswordName?: string; 45 45 } 46 46 47 47 export interface SessionState { 48 - accessJwt: string 49 - refreshJwt: string 48 + accessJwt: string; 49 + refreshJwt: string; 50 50 }
+11 -9
frontend/src/lib/router.svelte.ts
··· 1 - let currentPath = $state(getPathWithoutQuery(window.location.hash.slice(1) || '/')) 1 + let currentPath = $state( 2 + getPathWithoutQuery(window.location.hash.slice(1) || "/"), 3 + ); 2 4 3 5 function getPathWithoutQuery(hash: string): string { 4 - const queryIndex = hash.indexOf('?') 5 - return queryIndex === -1 ? hash : hash.slice(0, queryIndex) 6 + const queryIndex = hash.indexOf("?"); 7 + return queryIndex === -1 ? hash : hash.slice(0, queryIndex); 6 8 } 7 9 8 - window.addEventListener('hashchange', () => { 9 - currentPath = getPathWithoutQuery(window.location.hash.slice(1) || '/') 10 - }) 10 + window.addEventListener("hashchange", () => { 11 + currentPath = getPathWithoutQuery(window.location.hash.slice(1) || "/"); 12 + }); 11 13 12 14 export function navigate(path: string) { 13 - currentPath = path 14 - window.location.hash = path 15 + currentPath = path; 16 + window.location.hash = path; 15 17 } 16 18 17 19 export function getCurrentPath() { 18 - return currentPath 20 + return currentPath; 19 21 }
+66 -58
frontend/src/lib/serverConfig.svelte.ts
··· 1 - import { api } from './api' 1 + import { api } from "./api"; 2 2 3 3 interface ServerConfigState { 4 - serverName: string | null 5 - primaryColor: string | null 6 - primaryColorDark: string | null 7 - secondaryColor: string | null 8 - secondaryColorDark: string | null 9 - hasLogo: boolean 10 - loading: boolean 4 + serverName: string | null; 5 + primaryColor: string | null; 6 + primaryColorDark: string | null; 7 + secondaryColor: string | null; 8 + secondaryColorDark: string | null; 9 + hasLogo: boolean; 10 + loading: boolean; 11 11 } 12 12 13 13 let state = $state<ServerConfigState>({ ··· 18 18 secondaryColorDark: null, 19 19 hasLogo: false, 20 20 loading: true, 21 - }) 21 + }); 22 22 23 - let initialized = false 24 - let darkModeQuery: MediaQueryList | null = null 23 + let initialized = false; 24 + let darkModeQuery: MediaQueryList | null = null; 25 25 26 26 function isDarkMode(): boolean { 27 - return darkModeQuery?.matches ?? false 27 + return darkModeQuery?.matches ?? false; 28 28 } 29 29 30 30 function applyColors() { 31 - const root = document.documentElement 32 - const dark = isDarkMode() 31 + const root = document.documentElement; 32 + const dark = isDarkMode(); 33 33 34 34 if (dark) { 35 35 if (state.primaryColorDark) { 36 - root.style.setProperty('--accent', state.primaryColorDark) 36 + root.style.setProperty("--accent", state.primaryColorDark); 37 37 } else { 38 - root.style.removeProperty('--accent') 38 + root.style.removeProperty("--accent"); 39 39 } 40 40 if (state.secondaryColorDark) { 41 - root.style.setProperty('--secondary', state.secondaryColorDark) 41 + root.style.setProperty("--secondary", state.secondaryColorDark); 42 42 } else { 43 - root.style.removeProperty('--secondary') 43 + root.style.removeProperty("--secondary"); 44 44 } 45 45 } else { 46 46 if (state.primaryColor) { 47 - root.style.setProperty('--accent', state.primaryColor) 47 + root.style.setProperty("--accent", state.primaryColor); 48 48 } else { 49 - root.style.removeProperty('--accent') 49 + root.style.removeProperty("--accent"); 50 50 } 51 51 if (state.secondaryColor) { 52 - root.style.setProperty('--secondary', state.secondaryColor) 52 + root.style.setProperty("--secondary", state.secondaryColor); 53 53 } else { 54 - root.style.removeProperty('--secondary') 54 + root.style.removeProperty("--secondary"); 55 55 } 56 56 } 57 57 } 58 58 59 59 function setFavicon(hasLogo: boolean) { 60 - let link = document.querySelector<HTMLLinkElement>("link[rel~='icon']") 60 + let link = document.querySelector<HTMLLinkElement>("link[rel~='icon']"); 61 61 if (hasLogo) { 62 62 if (!link) { 63 - link = document.createElement('link') 64 - link.rel = 'icon' 65 - document.head.appendChild(link) 63 + link = document.createElement("link"); 64 + link.rel = "icon"; 65 + document.head.appendChild(link); 66 66 } 67 - link.href = '/logo' 67 + link.href = "/logo"; 68 68 } else if (link) { 69 - link.remove() 69 + link.remove(); 70 70 } 71 71 } 72 72 73 73 export async function initServerConfig(): Promise<void> { 74 - if (initialized) return 75 - initialized = true 74 + if (initialized) return; 75 + initialized = true; 76 76 77 - darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)') 78 - darkModeQuery.addEventListener('change', applyColors) 77 + darkModeQuery = window.matchMedia("(prefers-color-scheme: dark)"); 78 + darkModeQuery.addEventListener("change", applyColors); 79 79 80 80 try { 81 - const config = await api.getServerConfig() 82 - state.serverName = config.serverName 83 - state.primaryColor = config.primaryColor 84 - state.primaryColorDark = config.primaryColorDark 85 - state.secondaryColor = config.secondaryColor 86 - state.secondaryColorDark = config.secondaryColorDark 87 - state.hasLogo = !!config.logoCid 88 - document.title = config.serverName 89 - applyColors() 90 - setFavicon(state.hasLogo) 81 + const config = await api.getServerConfig(); 82 + state.serverName = config.serverName; 83 + state.primaryColor = config.primaryColor; 84 + state.primaryColorDark = config.primaryColorDark; 85 + state.secondaryColor = config.secondaryColor; 86 + state.secondaryColorDark = config.secondaryColorDark; 87 + state.hasLogo = !!config.logoCid; 88 + document.title = config.serverName; 89 + applyColors(); 90 + setFavicon(state.hasLogo); 91 91 } catch { 92 - state.serverName = null 92 + state.serverName = null; 93 93 } finally { 94 - state.loading = false 94 + state.loading = false; 95 95 } 96 96 } 97 97 98 98 export function getServerConfigState() { 99 - return state 99 + return state; 100 100 } 101 101 102 102 export function setServerName(name: string) { 103 - state.serverName = name 104 - document.title = name 103 + state.serverName = name; 104 + document.title = name; 105 105 } 106 106 107 107 export function setColors(colors: { 108 - primaryColor?: string | null 109 - primaryColorDark?: string | null 110 - secondaryColor?: string | null 111 - secondaryColorDark?: string | null 108 + primaryColor?: string | null; 109 + primaryColorDark?: string | null; 110 + secondaryColor?: string | null; 111 + secondaryColorDark?: string | null; 112 112 }) { 113 - if (colors.primaryColor !== undefined) state.primaryColor = colors.primaryColor 114 - if (colors.primaryColorDark !== undefined) state.primaryColorDark = colors.primaryColorDark 115 - if (colors.secondaryColor !== undefined) state.secondaryColor = colors.secondaryColor 116 - if (colors.secondaryColorDark !== undefined) state.secondaryColorDark = colors.secondaryColorDark 117 - applyColors() 113 + if (colors.primaryColor !== undefined) { 114 + state.primaryColor = colors.primaryColor; 115 + } 116 + if (colors.primaryColorDark !== undefined) { 117 + state.primaryColorDark = colors.primaryColorDark; 118 + } 119 + if (colors.secondaryColor !== undefined) { 120 + state.secondaryColor = colors.secondaryColor; 121 + } 122 + if (colors.secondaryColorDark !== undefined) { 123 + state.secondaryColorDark = colors.secondaryColorDark; 124 + } 125 + applyColors(); 118 126 } 119 127 120 128 export function setHasLogo(hasLogo: boolean) { 121 - state.hasLogo = hasLogo 122 - setFavicon(hasLogo) 129 + state.hasLogo = hasLogo; 130 + setFavicon(hasLogo); 123 131 }
+41 -6
frontend/src/locales/en.json
··· 30 30 "lostPasskey": "Lost passkey?", 31 31 "noAccount": "Don't have an account?", 32 32 "createAccount": "Create account", 33 - "removeAccount": "Remove from saved accounts" 33 + "removeAccount": "Remove from saved accounts", 34 + "infoSavedAccountsTitle": "Saved accounts", 35 + "infoSavedAccountsDesc": "Click an account to sign in instantly. Your session tokens are stored securely in this browser.", 36 + "infoNewAccountTitle": "New account", 37 + "infoNewAccountDesc": "Use the sign-in button to add a different account. Click the × to remove saved accounts from this browser.", 38 + "infoSecureSignInTitle": "Secure sign-in", 39 + "infoSecureSignInDesc": "You'll be redirected to authenticate securely. If you have passkeys or two-factor authentication enabled, you'll be prompted for those too.", 40 + "infoStaySignedInTitle": "Stay signed in", 41 + "infoStaySignedInDesc": "After signing in, your account will be saved to this browser for quick access next time.", 42 + "infoRecoveryTitle": "Account recovery", 43 + "infoRecoveryDesc": "Lost your password or passkey? Use the recovery links below the sign-in button." 34 44 }, 35 45 "verification": { 36 46 "title": "Verify Your Account", ··· 47 57 "register": { 48 58 "title": "Create Account", 49 59 "subtitle": "Create a new account on this PDS", 60 + "subtitleKeyChoice": "Choose how to set up your external did:web identity.", 61 + "subtitleInitialDidDoc": "Upload your DID document to continue.", 62 + "subtitleVerify": "Verify your {channel} to continue.", 63 + "subtitleUpdatedDidDoc": "Update your DID document with the PDS signing key.", 64 + "subtitleActivating": "Activating your account...", 65 + "subtitleComplete": "Your account has been created successfully!", 66 + "redirecting": "Redirecting to dashboard...", 67 + "infoIdentityDesc": "Your identity determines how your account is identified across the ATProto network. Most users should choose the standard option.", 68 + "infoContactDesc": "We'll use this to verify your account and send important notifications about your account security.", 69 + "infoNextTitle": "What happens next?", 70 + "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.", 50 71 "migrateTitle": "Already have a Bluesky account?", 51 72 "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.", 52 73 "migrateLink": "Migrate with PDS Moover", ··· 211 232 "messages": { 212 233 "emailCodeSent": "Verification code sent to your notification channel", 213 234 "emailUpdated": "Email updated successfully", 235 + "emailUpdateFailed": "Failed to update email", 214 236 "handleUpdated": "Handle updated successfully", 237 + "handleUpdateFailed": "Failed to update handle", 215 238 "passwordChanged": "Password changed successfully", 239 + "passwordChangeFailed": "Failed to change password", 216 240 "passwordsMismatch": "Passwords do not match", 241 + "passwordsDoNotMatch": "Passwords do not match", 217 242 "passwordLength": "Password must be at least 8 characters", 243 + "passwordTooShort": "Password must be at least 8 characters", 218 244 "deletionCodeSent": "Deletion confirmation sent to your email", 245 + "deletionConfirmationSent": "Deletion confirmation sent to your email", 246 + "deletionRequestFailed": "Failed to request account deletion", 247 + "deleteConfirmation": "Are you absolutely sure you want to delete your account? This cannot be undone.", 248 + "deletionFailed": "Failed to delete account", 219 249 "repoExported": "Repository exported successfully", 250 + "exportFailed": "Failed to export repository", 220 251 "confirmDelete": "Are you absolutely sure you want to delete your account? This cannot be undone." 221 252 } 222 253 }, ··· 362 393 "manageTrustedDevices": "Manage Trusted Devices", 363 394 "appCompatibility": "App Compatibility", 364 395 "enterPassword": "Enter your password", 396 + "sessionExpired": "Session expired. Please log in again.", 365 397 "legacyLoginEnabled": "Legacy app login enabled", 366 398 "legacyLoginDisabled": "Legacy app login disabled - only OAuth apps can sign in", 367 399 "failedToUpdatePreference": "Failed to update preference", ··· 421 453 "noRecords": "No records in this collection", 422 454 "recordDetails": "Record Details", 423 455 "rkey": "Record Key", 456 + "uri": "URI", 424 457 "cid": "CID", 425 458 "value": "Value", 426 459 "deleteRecord": "Delete Record", ··· 463 496 "themeColors": "Theme Colors", 464 497 "themeColorsHint": "Leave blank to use default colors.", 465 498 "primaryLight": "Primary (Light Mode)", 466 - "primaryLightDefault": "#2c00ff (default)", 499 + "colorDefault": "{color} (default)", 467 500 "primaryDark": "Primary (Dark Mode)", 468 - "primaryDarkDefault": "#7b6bff (default)", 469 501 "secondaryLight": "Secondary (Light Mode)", 470 - "secondaryLightDefault": "#ff2400 (default)", 471 502 "secondaryDark": "Secondary (Dark Mode)", 472 - "secondaryDarkDefault": "#ff6b5b (default)", 473 503 "configSaved": "Server configuration saved", 474 504 "saving": "Saving...", 475 505 "saveConfig": "Save Configuration", ··· 527 557 "rememberDevice": "Remember this device", 528 558 "passkeyHintChecking": "Checking passkey status...", 529 559 "passkeyHintAvailable": "Sign in with your passkey", 530 - "passkeyHintNotAvailable": "No passkeys registered for this account" 560 + "passkeyHintNotAvailable": "No passkeys registered for this account", 561 + "passkeyHint": "Use your device's biometrics or security key", 562 + "passwordPlaceholder": "Enter your password", 563 + "usePasskey": "Use Passkey" 531 564 }, 532 565 "consent": { 533 566 "title": "Authorize Application", ··· 741 774 "didWebBYODHint": "Bring your own domain", 742 775 "didWebWarningTitle": "Important: Understand the trade-offs", 743 776 "didWebWarning1": "Permanent tie to this PDS:", 777 + "didWebWarning1Detail": "Your identity will be {did}.", 744 778 "didWebWarning2": "No recovery mechanism:", 745 779 "didWebWarning2Detail": "Unlike did:plc, did:web has no rotation keys.", 746 780 "didWebWarning3": "We commit to you:", ··· 785 819 "title": "Trusted Devices", 786 820 "backToSecurity": "← Security Settings", 787 821 "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.", 822 + "failedToLoad": "Failed to load trusted devices", 788 823 "noDevices": "No trusted devices yet.", 789 824 "noDevicesHint": "When you log in with two-factor authentication enabled, you can choose to trust the device for 30 days.", 790 825 "lastSeen": "Last seen:",
+97 -8
frontend/src/locales/fi.json
··· 30 30 "lostPasskey": "Kadotitko pääsyavaimen?", 31 31 "noAccount": "Eikö sinulla ole tiliä?", 32 32 "createAccount": "Luo tili", 33 - "removeAccount": "Poista tallennetuista tileistä" 33 + "removeAccount": "Poista tallennetuista tileistä", 34 + "infoSavedAccountsTitle": "Tallennetut tilit", 35 + "infoSavedAccountsDesc": "Napsauta tiliä kirjautuaksesi heti. Istuntotunnuksesi on tallennettu turvallisesti tähän selaimeen.", 36 + "infoNewAccountTitle": "Uusi tili", 37 + "infoNewAccountDesc": "Käytä kirjautumispainiketta lisätäksesi toisen tilin. Napsauta × poistaaksesi tallennettuja tilejä.", 38 + "infoSecureSignInTitle": "Turvallinen kirjautuminen", 39 + "infoSecureSignInDesc": "Sinut ohjataan turvalliseen todennukseen. Jos sinulla on pääsyavaimia tai kaksivaiheinen tunnistautuminen käytössä, sinulta pyydetään myös ne.", 40 + "infoStaySignedInTitle": "Pysy kirjautuneena", 41 + "infoStaySignedInDesc": "Kirjautumisen jälkeen tilisi tallennetaan tähän selaimeen nopeaa pääsyä varten.", 42 + "infoRecoveryTitle": "Tilin palautus", 43 + "infoRecoveryDesc": "Kadotitko salasanasi tai pääsyavaimesi? Käytä palautuslinkkejä kirjautumispainikkeen alla." 34 44 }, 35 45 "verification": { 36 46 "title": "Vahvista tilisi", ··· 47 57 "register": { 48 58 "title": "Luo tili", 49 59 "subtitle": "Luo uusi tili tälle PDS:lle", 60 + "subtitleKeyChoice": "Valitse, miten haluat määrittää ulkoisen did:web-identiteettisi.", 61 + "subtitleInitialDidDoc": "Lataa DID-dokumenttisi jatkaaksesi.", 62 + "subtitleVerify": "Vahvista {channel} jatkaaksesi.", 63 + "subtitleUpdatedDidDoc": "Päivitä DID-dokumenttisi PDS-allekirjoitusavaimella.", 64 + "subtitleActivating": "Aktivoidaan tiliäsi...", 65 + "subtitleComplete": "Tilisi on luotu onnistuneesti!", 66 + "redirecting": "Siirrytään kojelaudalle...", 67 + "infoIdentityDesc": "Identiteettisi määrittää, miten tilisi tunnistetaan ATProto-verkossa. Useimpien käyttäjien tulisi valita vakiovaihtoehto.", 68 + "infoContactDesc": "Käytämme tätä tilisi vahvistamiseen ja tärkeiden turvallisuusilmoitusten lähettämiseen.", 69 + "infoNextTitle": "Mitä tapahtuu seuraavaksi?", 70 + "infoNextDesc": "Tilin luomisen jälkeen vahvistat yhteysmenetelmäsi ja olet valmis käyttämään mitä tahansa ATProto-sovellusta uudella identiteetilläsi.", 50 71 "migrateTitle": "Onko sinulla jo Bluesky-tili?", 51 72 "migrateDescription": "Voit siirtää olemassa olevan tilisi tälle PDS:lle uuden luomisen sijaan. Seuraajasi, julkaisusi ja identiteettisi siirtyvät mukana.", 52 73 "migrateLink": "Siirrä PDS Mooverilla", ··· 211 232 "messages": { 212 233 "emailCodeSent": "Vahvistuskoodi lähetetty ilmoituskanavallesi", 213 234 "emailUpdated": "Sähköposti päivitetty", 235 + "emailUpdateFailed": "Sähköpostin päivitys epäonnistui", 214 236 "handleUpdated": "Käyttäjänimi päivitetty", 237 + "handleUpdateFailed": "Käyttäjänimen päivitys epäonnistui", 215 238 "passwordChanged": "Salasana vaihdettu", 239 + "passwordChangeFailed": "Salasanan vaihto epäonnistui", 216 240 "passwordsMismatch": "Salasanat eivät täsmää", 241 + "passwordsDoNotMatch": "Salasanat eivät täsmää", 217 242 "passwordLength": "Salasanan on oltava vähintään 8 merkkiä", 243 + "passwordTooShort": "Salasanan on oltava vähintään 8 merkkiä", 218 244 "deletionCodeSent": "Poistovahvistus lähetetty sähköpostiisi", 245 + "deletionConfirmationSent": "Poistovahvistus lähetetty sähköpostiisi", 246 + "deletionRequestFailed": "Tilin poistopyyntö epäonnistui", 247 + "deleteConfirmation": "Oletko täysin varma, että haluat poistaa tilisi? Tätä ei voi perua.", 248 + "deletionFailed": "Tilin poisto epäonnistui", 219 249 "repoExported": "Tietovarasto viety", 250 + "exportFailed": "Tietovaraston vienti epäonnistui", 220 251 "confirmDelete": "Oletko täysin varma, että haluat poistaa tilisi? Tätä ei voi perua." 221 252 } 222 253 }, ··· 362 393 "manageTrustedDevices": "Hallitse luotettuja laitteita", 363 394 "appCompatibility": "Sovellusyhteensopivuus", 364 395 "enterPassword": "Syötä salasanasi", 396 + "sessionExpired": "Istunto vanhentunut. Kirjaudu sisään uudelleen.", 365 397 "legacyLoginEnabled": "Vanhentuneiden sovellusten kirjautuminen käytössä", 366 398 "legacyLoginDisabled": "Vanhentuneiden sovellusten kirjautuminen poistettu käytöstä - vain OAuth-sovellukset voivat kirjautua", 367 399 "failedToUpdatePreference": "Asetuksen päivittäminen epäonnistui", ··· 421 453 "noRecords": "Ei tietueita tässä kokoelmassa", 422 454 "recordDetails": "Tietueen tiedot", 423 455 "rkey": "Tietueavain", 456 + "uri": "URI", 424 457 "cid": "CID", 425 458 "value": "Arvo", 426 459 "deleteRecord": "Poista tietue", ··· 464 497 "themeColorsHint": "Jätä tyhjäksi käyttääksesi oletusvärejä.", 465 498 "primaryLight": "Ensisijainen (vaalea tila)", 466 499 "primaryDark": "Ensisijainen (tumma tila)", 467 - "accentLight": "Korostus (vaalea tila)", 468 - "accentDark": "Korostus (tumma tila)", 469 - "faviconExample": "Favicon-esimerkki", 470 500 "configSaved": "Palvelinasetukset tallennettu", 471 501 "saving": "Tallennetaan...", 472 502 "saveConfig": "Tallenna asetukset", ··· 508 538 "deleteConfirm": "Poista tili @{handle}? Tätä ei voi perua.", 509 539 "verified": "Vahvistettu", 510 540 "unverified": "Vahvistamaton", 511 - "deactivated": "Poistettu käytöstä" 541 + "deactivated": "Poistettu käytöstä", 542 + "colorDefault": "{color} (oletus)", 543 + "secondaryLight": "Toissijainen (vaalea tila)", 544 + "secondaryDark": "Toissijainen (tumma tila)" 512 545 }, 513 546 "oauth": { 514 547 "login": { ··· 524 557 "rememberDevice": "Muista tämä laite", 525 558 "passkeyHintChecking": "Tarkistetaan pääsyavaimen tilaa...", 526 559 "passkeyHintAvailable": "Kirjaudu pääsyavaimellasi", 527 - "passkeyHintNotAvailable": "Ei rekisteröityjä pääsyavaimia tälle tilille" 560 + "passkeyHintNotAvailable": "Ei rekisteröityjä pääsyavaimia tälle tilille", 561 + "passkeyHint": "Käytä laitteesi biometriikkaa tai suojausavainta", 562 + "passwordPlaceholder": "Syötä salasanasi", 563 + "usePasskey": "Käytä pääsyavainta" 528 564 }, 529 565 "consent": { 530 566 "title": "Valtuuta sovellus", ··· 740 776 "handleNoDots": "Käyttäjänimi ei voi sisältää pisteitä. Voit määrittää oman verkkotunnuksen tilin luomisen jälkeen.", 741 777 "passkeysNotSupported": "Pääsyavaimia ei tueta tässä selaimessa. Luo salasanapohjainen tili tai käytä selainta, joka tukee pääsyavaimia.", 742 778 "passkeyCancelled": "Pääsyavaimen luominen peruutettu", 743 - "passkeyFailed": "Pääsyavaimen rekisteröinti epäonnistui" 744 - } 779 + "passkeyFailed": "Pääsyavaimen rekisteröinti epäonnistui", 780 + "signalRequired": "Puhelinnumero vaaditaan Signal-vahvistukseen", 781 + "inviteRequired": "Kutsukoodi vaaditaan", 782 + "externalDidRequired": "Ulkoinen did:web vaaditaan", 783 + "emailRequired": "Sähköposti vaaditaan sähköpostivahvistukseen", 784 + "telegramRequired": "Telegram-käyttäjänimi vaaditaan Telegram-vahvistukseen", 785 + "externalDidFormat": "Ulkoisen DID:n on alettava did:web:", 786 + "discordRequired": "Discord-tunnus vaaditaan Discord-vahvistukseen" 787 + }, 788 + "whyPasskeyBullet1": "Ei voi kalastella tai varastaa tietomurroissa", 789 + "whyPasskeyBullet2": "Käyttää laitteistopohjaisia salausavaimia", 790 + "whyPasskeyBullet3": "Vaatii biometrisen tunnistuksen tai laitteen PIN-koodin", 791 + "whyPasskeyOnly": "Miksi vain pääsyavain?", 792 + "whyPasskeyOnlyDesc": "Pääsyavaintilit ovat turvallisempia kuin salasanapohjaiset tilit, koska ne:", 793 + "subtitleInitialDidDoc": "Lataa DID-dokumenttisi jatkaaksesi.", 794 + "subtitleUpdatedDidDoc": "Päivitä DID-dokumenttisi PDS-allekirjoitusavaimella.", 795 + "subtitleActivating": "Aktivoidaan tiliäsi...", 796 + "subtitleComplete": "Tilisi on luotu onnistuneesti!", 797 + "subtitleCreating": "Luodaan tiliäsi...", 798 + "subtitleAppPassword": "Tallenna sovellussalasanasi kolmannen osapuolen sovelluksia varten.", 799 + "creatingPasskey": "Luodaan pääsyavainta...", 800 + "passkeyPrompt": "Napsauta alla olevaa painiketta luodaksesi pääsyavaimesi. Sinua pyydetään käyttämään:", 801 + "passkeyPromptBullet1": "Touch ID tai Face ID", 802 + "passkeyPromptBullet2": "Laitteesi PIN-koodi tai salasana", 803 + "passkeyPromptBullet3": "Turva-avain (jos sinulla on sellainen)", 804 + "identityType": "Identiteettityyppi", 805 + "identityTypeHint": "Valitse, miten hajautettua identiteettiäsi hallitaan.", 806 + "passkeyNameLabel": "Pääsyavaimen nimi (valinnainen)", 807 + "passkeyNamePlaceholder": "esim. MacBook Touch ID", 808 + "passkeyNameHint": "Ystävällinen nimi tämän pääsyavaimen tunnistamiseksi", 809 + "createPasskey": "Luo pääsyavain", 810 + "didPlcRecommended": "did:plc (Suositeltava)", 811 + "didPlcHint": "Siirrettävä identiteetti, jota hallinnoi PLC Directory", 812 + "didWeb": "did:web", 813 + "didWebHint": "Tällä PDS:llä isännöity identiteetti (lue varoitus alla)", 814 + "didWebBYOD": "did:web (BYOD)", 815 + "didWebBYODHint": "Tuo oma verkkotunnuksesi", 816 + "didWebWarningTitle": "Tärkeää: Ymmärrä kompromissit", 817 + "didWebWarning1": "Pysyvä sidos tähän PDS:ään:", 818 + "didWebWarning1Detail": "Identiteettisi {did} on sidottu tähän palvelimeen.", 819 + "didWebWarning2": "Ei palautusmekanismia:", 820 + "didWebWarning2Detail": "Toisin kuin did:plc, did:web ei sisällä kiertoavaimia.", 821 + "didWebWarning3": "Sitoudumme sinulle:", 822 + "didWebWarning3Detail": "Jos siirryt pois, jatkamme minimaalisen DID-dokumentin tarjoamista.", 823 + "didWebWarning4": "Suositus:", 824 + "didWebWarning4Detail": "Valitse did:plc, ellei sinulla ole erityistä syytä suosia did:web.", 825 + "externalDidHint": "Sinun on tarjottava DID-dokumentti osoitteessa", 826 + "continue": "Jatka", 827 + "back": "Takaisin", 828 + "loading": "Ladataan...", 829 + "redirecting": "Ohjataan hallintapaneeliin...", 830 + "handleDotWarning": "Mukautetut verkkotunnuskahvat voidaan määrittää tilin luomisen jälkeen.", 831 + "wantTraditional": "Haluatko perinteisen salasanan?", 832 + "registerWithPassword": "Rekisteröidy salasanalla" 745 833 }, 746 834 "trustedDevices": { 747 835 "title": "Luotetut laitteet", 748 836 "backToSecurity": "← Turvallisuusasetukset", 749 837 "description": "Luotetut laitteet voivat ohittaa kaksivaiheisen tunnistautumisen kirjautuessaan. Luottamus myönnetään 30 päiväksi ja jatkuu automaattisesti, kun käytät laitetta.", 838 + "failedToLoad": "Luotettujen laitteiden lataaminen epäonnistui", 750 839 "noDevices": "Ei vielä luotettuja laitteita.", 751 840 "noDevicesHint": "Kun kirjaudut sisään kaksivaiheisen tunnistautumisen ollessa käytössä, voit valita luottaa laitteeseen 30 päivää.", 752 841 "lastSeen": "Viimeksi nähty:",
+97 -8
frontend/src/locales/ja.json
··· 30 30 "lostPasskey": "パスキーを紛失しましたか?", 31 31 "noAccount": "アカウントをお持ちでないですか?", 32 32 "createAccount": "アカウントを作成", 33 - "removeAccount": "保存済みアカウントから削除" 33 + "removeAccount": "保存済みアカウントから削除", 34 + "infoSavedAccountsTitle": "保存済みアカウント", 35 + "infoSavedAccountsDesc": "アカウントをクリックすると即座にサインインできます。セッショントークンはこのブラウザに安全に保存されています。", 36 + "infoNewAccountTitle": "新規アカウント", 37 + "infoNewAccountDesc": "サインインボタンで別のアカウントを追加できます。×をクリックすると保存済みアカウントを削除できます。", 38 + "infoSecureSignInTitle": "安全なサインイン", 39 + "infoSecureSignInDesc": "安全な認証のためにリダイレクトされます。パスキーや二要素認証が有効な場合は、それらも求められます。", 40 + "infoStaySignedInTitle": "サインイン状態を維持", 41 + "infoStaySignedInDesc": "サインイン後、アカウントはこのブラウザに保存され、次回から素早くアクセスできます。", 42 + "infoRecoveryTitle": "アカウント復旧", 43 + "infoRecoveryDesc": "パスワードやパスキーを紛失しましたか?サインインボタンの下の復旧リンクをご利用ください。" 34 44 }, 35 45 "verification": { 36 46 "title": "アカウント確認", ··· 47 57 "register": { 48 58 "title": "アカウント作成", 49 59 "subtitle": "この PDS で新規アカウントを作成", 60 + "subtitleKeyChoice": "外部 did:web アイデンティティの設定方法を選択してください。", 61 + "subtitleInitialDidDoc": "続行するには DID ドキュメントをアップロードしてください。", 62 + "subtitleVerify": "続行するには{channel}を確認してください。", 63 + "subtitleUpdatedDidDoc": "PDS 署名キーで DID ドキュメントを更新してください。", 64 + "subtitleActivating": "アカウントを有効化しています...", 65 + "subtitleComplete": "アカウントが正常に作成されました!", 66 + "redirecting": "ダッシュボードへ移動中...", 67 + "infoIdentityDesc": "アイデンティティは、ATProto ネットワーク上でアカウントがどのように識別されるかを決定します。ほとんどのユーザーは標準オプションを選択してください。", 68 + "infoContactDesc": "この情報はアカウントの確認と、アカウントセキュリティに関する重要な通知の送信に使用されます。", 69 + "infoNextTitle": "次のステップは?", 70 + "infoNextDesc": "アカウント作成後、連絡方法を確認すると、新しいアイデンティティで任意の ATProto アプリを使用できます。", 50 71 "migrateTitle": "すでにBlueskyアカウントをお持ちですか?", 51 72 "migrateDescription": "新しいアカウントを作成する代わりに、既存のアカウントをこのPDSに移行できます。フォロワー、投稿、IDも一緒に移行されます。", 52 73 "migrateLink": "PDS Mooverで移行する", ··· 211 232 "messages": { 212 233 "emailCodeSent": "通知チャンネルに確認コードを送信しました", 213 234 "emailUpdated": "メールを更新しました", 235 + "emailUpdateFailed": "メールの更新に失敗しました", 214 236 "handleUpdated": "ハンドルを更新しました", 237 + "handleUpdateFailed": "ハンドルの更新に失敗しました", 215 238 "passwordChanged": "パスワードを変更しました", 239 + "passwordChangeFailed": "パスワードの変更に失敗しました", 216 240 "passwordsMismatch": "パスワードが一致しません", 241 + "passwordsDoNotMatch": "パスワードが一致しません", 217 242 "passwordLength": "パスワードは8文字以上である必要があります", 243 + "passwordTooShort": "パスワードは8文字以上である必要があります", 218 244 "deletionCodeSent": "削除確認をメールに送信しました", 245 + "deletionConfirmationSent": "削除確認をメールに送信しました", 246 + "deletionRequestFailed": "アカウント削除リクエストに失敗しました", 247 + "deleteConfirmation": "本当にアカウントを削除しますか?この操作は取り消せません。", 248 + "deletionFailed": "アカウントの削除に失敗しました", 219 249 "repoExported": "リポジトリをエクスポートしました", 250 + "exportFailed": "リポジトリのエクスポートに失敗しました", 220 251 "confirmDelete": "本当にアカウントを削除しますか?この操作は取り消せません。" 221 252 } 222 253 }, ··· 362 393 "manageTrustedDevices": "信頼済みデバイスを管理", 363 394 "appCompatibility": "アプリ互換性", 364 395 "enterPassword": "パスワードを入力", 396 + "sessionExpired": "セッションが期限切れです。再度ログインしてください。", 365 397 "legacyLoginEnabled": "レガシーアプリログインが有効", 366 398 "legacyLoginDisabled": "レガシーアプリログインが無効 - OAuth アプリのみサインイン可能", 367 399 "failedToUpdatePreference": "設定の更新に失敗しました", ··· 421 453 "noRecords": "このコレクションにレコードはありません", 422 454 "recordDetails": "レコード詳細", 423 455 "rkey": "レコードキー", 456 + "uri": "URI", 424 457 "cid": "CID", 425 458 "value": "値", 426 459 "deleteRecord": "レコードを削除", ··· 464 497 "themeColorsHint": "デフォルトカラーを使用する場合は空白のままにしてください。", 465 498 "primaryLight": "プライマリ(ライトモード)", 466 499 "primaryDark": "プライマリ(ダークモード)", 467 - "accentLight": "アクセント(ライトモード)", 468 - "accentDark": "アクセント(ダークモード)", 469 - "faviconExample": "ファビコン例", 470 500 "configSaved": "サーバー設定を保存しました", 471 501 "saving": "保存中...", 472 502 "saveConfig": "設定を保存", ··· 508 538 "deleteConfirm": "アカウント @{handle} を削除しますか?この操作は取り消せません。", 509 539 "verified": "確認済み", 510 540 "unverified": "未確認", 511 - "deactivated": "無効化" 541 + "deactivated": "無効化", 542 + "colorDefault": "{color}(デフォルト)", 543 + "secondaryLight": "セカンダリ(ライトモード)", 544 + "secondaryDark": "セカンダリ(ダークモード)" 512 545 }, 513 546 "oauth": { 514 547 "login": { ··· 524 557 "rememberDevice": "このデバイスを記憶する", 525 558 "passkeyHintChecking": "パスキーの状態を確認中...", 526 559 "passkeyHintAvailable": "パスキーでサインイン", 527 - "passkeyHintNotAvailable": "このアカウントにはパスキーが登録されていません" 560 + "passkeyHintNotAvailable": "このアカウントにはパスキーが登録されていません", 561 + "passkeyHint": "デバイスの生体認証またはセキュリティキーを使用", 562 + "passwordPlaceholder": "パスワードを入力", 563 + "usePasskey": "パスキーを使用" 528 564 }, 529 565 "consent": { 530 566 "title": "アプリを承認", ··· 740 776 "handleNoDots": "ハンドルにドットは使用できません。アカウント作成後にカスタムドメインを設定できます。", 741 777 "passkeysNotSupported": "このブラウザではパスキーがサポートされていません。パスワードベースのアカウントを作成するか、パスキーをサポートするブラウザを使用してください。", 742 778 "passkeyCancelled": "パスキーの作成がキャンセルされました", 743 - "passkeyFailed": "パスキーの登録に失敗しました" 744 - } 779 + "passkeyFailed": "パスキーの登録に失敗しました", 780 + "signalRequired": "Signal認証には電話番号が必要です", 781 + "inviteRequired": "招待コードが必要です", 782 + "externalDidRequired": "外部did:webが必要です", 783 + "emailRequired": "メール認証にはメールアドレスが必要です", 784 + "telegramRequired": "Telegram認証にはTelegramユーザー名が必要です", 785 + "externalDidFormat": "外部DIDはdid:web:で始まる必要があります", 786 + "discordRequired": "Discord認証にはDiscord IDが必要です" 787 + }, 788 + "whyPasskeyBullet1": "フィッシングやデータ侵害で盗まれない", 789 + "whyPasskeyBullet2": "ハードウェア支援の暗号鍵を使用", 790 + "whyPasskeyBullet3": "生体認証またはデバイスPINが必要", 791 + "whyPasskeyOnly": "なぜパスキーのみ?", 792 + "whyPasskeyOnlyDesc": "パスキーアカウントはパスワードベースのアカウントより安全です:", 793 + "subtitleInitialDidDoc": "続行するにはDIDドキュメントをアップロードしてください。", 794 + "subtitleUpdatedDidDoc": "PDS署名鍵でDIDドキュメントを更新してください。", 795 + "subtitleActivating": "アカウントを有効化しています...", 796 + "subtitleComplete": "アカウントが正常に作成されました!", 797 + "subtitleCreating": "アカウントを作成しています...", 798 + "subtitleAppPassword": "サードパーティアプリ用のアプリパスワードを保存してください。", 799 + "creatingPasskey": "パスキーを作成中...", 800 + "passkeyPrompt": "下のボタンをクリックしてパスキーを作成してください。以下の使用を求められます:", 801 + "passkeyPromptBullet1": "Touch IDまたはFace ID", 802 + "passkeyPromptBullet2": "デバイスのPINまたはパスワード", 803 + "passkeyPromptBullet3": "セキュリティキー(お持ちの場合)", 804 + "identityType": "アイデンティティタイプ", 805 + "identityTypeHint": "分散型アイデンティティの管理方法を選択してください。", 806 + "passkeyNameLabel": "パスキー名(任意)", 807 + "passkeyNamePlaceholder": "例:MacBook Touch ID", 808 + "passkeyNameHint": "このパスキーを識別するための名前", 809 + "createPasskey": "パスキーを作成", 810 + "didPlcRecommended": "did:plc(推奨)", 811 + "didPlcHint": "PLC Directoryで管理されるポータブルなアイデンティティ", 812 + "didWeb": "did:web", 813 + "didWebHint": "このPDSでホストされるアイデンティティ(以下の警告を参照)", 814 + "didWebBYOD": "did:web(BYOD)", 815 + "didWebBYODHint": "独自ドメインを持ち込む", 816 + "didWebWarningTitle": "重要:トレードオフを理解する", 817 + "didWebWarning1": "このPDSへの永続的な紐付け:", 818 + "didWebWarning1Detail": "あなたのアイデンティティ{did}はこのサーバーに紐付けられます。", 819 + "didWebWarning2": "回復メカニズムなし:", 820 + "didWebWarning2Detail": "did:plcと異なり、did:webにはローテーションキーがありません。", 821 + "didWebWarning3": "私たちの約束:", 822 + "didWebWarning3Detail": "移行後も最小限のDIDドキュメントを提供し続けます。", 823 + "didWebWarning4": "推奨事項:", 824 + "didWebWarning4Detail": "did:webを好む特別な理由がない限り、did:plcを選択してください。", 825 + "externalDidHint": "以下の場所でDIDドキュメントを提供する必要があります", 826 + "continue": "続行", 827 + "back": "戻る", 828 + "loading": "読み込み中...", 829 + "redirecting": "ダッシュボードに移動中...", 830 + "handleDotWarning": "カスタムドメインハンドルはアカウント作成後に設定できます。", 831 + "wantTraditional": "従来のパスワードを使用しますか?", 832 + "registerWithPassword": "パスワードで登録" 745 833 }, 746 834 "trustedDevices": { 747 835 "title": "信頼済みデバイス", 748 836 "backToSecurity": "← セキュリティ設定", 749 837 "description": "信頼済みデバイスはログイン時に二要素認証をスキップできます。信頼は30日間有効で、デバイスを使用すると自動的に延長されます。", 838 + "failedToLoad": "信頼済みデバイスの読み込みに失敗しました", 750 839 "noDevices": "信頼済みデバイスはまだありません。", 751 840 "noDevicesHint": "二要素認証を有効にしてログインする際に、デバイスを30日間信頼することを選択できます。", 752 841 "lastSeen": "最終使用:",
+97 -8
frontend/src/locales/ko.json
··· 30 30 "lostPasskey": "패스키를 분실하셨나요?", 31 31 "noAccount": "계정이 없으신가요?", 32 32 "createAccount": "계정 만들기", 33 - "removeAccount": "저장된 계정에서 삭제" 33 + "removeAccount": "저장된 계정에서 삭제", 34 + "infoSavedAccountsTitle": "저장된 계정", 35 + "infoSavedAccountsDesc": "계정을 클릭하면 즉시 로그인할 수 있습니다. 세션 토큰은 이 브라우저에 안전하게 저장됩니다.", 36 + "infoNewAccountTitle": "새 계정", 37 + "infoNewAccountDesc": "로그인 버튼을 사용하여 다른 계정을 추가하세요. ×를 클릭하여 저장된 계정을 제거할 수 있습니다.", 38 + "infoSecureSignInTitle": "안전한 로그인", 39 + "infoSecureSignInDesc": "안전한 인증을 위해 리디렉션됩니다. 패스키나 2단계 인증이 활성화되어 있으면 해당 인증도 요청됩니다.", 40 + "infoStaySignedInTitle": "로그인 유지", 41 + "infoStaySignedInDesc": "로그인 후 계정이 이 브라우저에 저장되어 다음에 빠르게 접속할 수 있습니다.", 42 + "infoRecoveryTitle": "계정 복구", 43 + "infoRecoveryDesc": "비밀번호나 패스키를 분실하셨나요? 로그인 버튼 아래의 복구 링크를 사용하세요." 34 44 }, 35 45 "verification": { 36 46 "title": "계정 인증", ··· 47 57 "register": { 48 58 "title": "계정 만들기", 49 59 "subtitle": "이 PDS에 새 계정을 만듭니다", 60 + "subtitleKeyChoice": "외부 did:web 신원을 설정하는 방법을 선택하세요.", 61 + "subtitleInitialDidDoc": "계속하려면 DID 문서를 업로드하세요.", 62 + "subtitleVerify": "계속하려면 {channel}을(를) 인증하세요.", 63 + "subtitleUpdatedDidDoc": "PDS 서명 키로 DID 문서를 업데이트하세요.", 64 + "subtitleActivating": "계정을 활성화하는 중...", 65 + "subtitleComplete": "계정이 성공적으로 생성되었습니다!", 66 + "redirecting": "대시보드로 이동 중...", 67 + "infoIdentityDesc": "신원은 ATProto 네트워크에서 계정이 어떻게 식별되는지를 결정합니다. 대부분의 사용자는 표준 옵션을 선택해야 합니다.", 68 + "infoContactDesc": "이 정보는 계정 인증과 계정 보안에 관한 중요한 알림을 보내는 데 사용됩니다.", 69 + "infoNextTitle": "다음 단계는?", 70 + "infoNextDesc": "계정 생성 후 연락 방법을 인증하면 새로운 신원으로 모든 ATProto 앱을 사용할 수 있습니다.", 50 71 "migrateTitle": "이미 Bluesky 계정이 있으신가요?", 51 72 "migrateDescription": "새 계정을 만드는 대신 기존 계정을 이 PDS로 마이그레이션할 수 있습니다. 팔로워, 게시물, ID가 함께 이전됩니다.", 52 73 "migrateLink": "PDS Moover로 마이그레이션", ··· 211 232 "messages": { 212 233 "emailCodeSent": "알림 채널로 인증 코드를 보냈습니다", 213 234 "emailUpdated": "이메일이 업데이트되었습니다", 235 + "emailUpdateFailed": "이메일 업데이트에 실패했습니다", 214 236 "handleUpdated": "핸들이 업데이트되었습니다", 237 + "handleUpdateFailed": "핸들 업데이트에 실패했습니다", 215 238 "passwordChanged": "비밀번호가 변경되었습니다", 239 + "passwordChangeFailed": "비밀번호 변경에 실패했습니다", 216 240 "passwordsMismatch": "비밀번호가 일치하지 않습니다", 241 + "passwordsDoNotMatch": "비밀번호가 일치하지 않습니다", 217 242 "passwordLength": "비밀번호는 8자 이상이어야 합니다", 243 + "passwordTooShort": "비밀번호는 8자 이상이어야 합니다", 218 244 "deletionCodeSent": "이메일로 삭제 확인을 보냈습니다", 245 + "deletionConfirmationSent": "이메일로 삭제 확인을 보냈습니다", 246 + "deletionRequestFailed": "계정 삭제 요청에 실패했습니다", 247 + "deleteConfirmation": "정말로 계정을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", 248 + "deletionFailed": "계정 삭제에 실패했습니다", 219 249 "repoExported": "저장소를 내보냈습니다", 250 + "exportFailed": "저장소 내보내기에 실패했습니다", 220 251 "confirmDelete": "정말로 계정을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다." 221 252 } 222 253 }, ··· 362 393 "manageTrustedDevices": "신뢰할 수 있는 기기 관리", 363 394 "appCompatibility": "앱 호환성", 364 395 "enterPassword": "비밀번호를 입력하세요", 396 + "sessionExpired": "세션이 만료되었습니다. 다시 로그인하세요.", 365 397 "legacyLoginEnabled": "레거시 앱 로그인 활성화됨", 366 398 "legacyLoginDisabled": "레거시 앱 로그인 비활성화됨 - OAuth 앱만 로그인 가능", 367 399 "failedToUpdatePreference": "설정 업데이트에 실패했습니다", ··· 421 453 "noRecords": "이 컬렉션에 레코드가 없습니다", 422 454 "recordDetails": "레코드 세부 정보", 423 455 "rkey": "레코드 키", 456 + "uri": "URI", 424 457 "cid": "CID", 425 458 "value": "값", 426 459 "deleteRecord": "레코드 삭제", ··· 464 497 "themeColorsHint": "기본 색상을 사용하려면 비워 두세요.", 465 498 "primaryLight": "기본 (라이트 모드)", 466 499 "primaryDark": "기본 (다크 모드)", 467 - "accentLight": "강조 (라이트 모드)", 468 - "accentDark": "강조 (다크 모드)", 469 - "faviconExample": "파비콘 예시", 470 500 "configSaved": "서버 설정이 저장되었습니다", 471 501 "saving": "저장 중...", 472 502 "saveConfig": "설정 저장", ··· 508 538 "deleteConfirm": "계정 @{handle}을(를) 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", 509 539 "verified": "인증됨", 510 540 "unverified": "미인증", 511 - "deactivated": "비활성화됨" 541 + "deactivated": "비활성화됨", 542 + "colorDefault": "{color} (기본값)", 543 + "secondaryLight": "보조 (라이트 모드)", 544 + "secondaryDark": "보조 (다크 모드)" 512 545 }, 513 546 "oauth": { 514 547 "login": { ··· 524 557 "rememberDevice": "이 기기 기억하기", 525 558 "passkeyHintChecking": "패스키 상태 확인 중...", 526 559 "passkeyHintAvailable": "패스키로 로그인", 527 - "passkeyHintNotAvailable": "이 계정에 등록된 패스키가 없습니다" 560 + "passkeyHintNotAvailable": "이 계정에 등록된 패스키가 없습니다", 561 + "passkeyHint": "기기의 생체 인식 또는 보안 키 사용", 562 + "passwordPlaceholder": "비밀번호 입력", 563 + "usePasskey": "패스키 사용" 528 564 }, 529 565 "consent": { 530 566 "title": "앱 승인", ··· 740 776 "handleNoDots": "핸들에 점을 포함할 수 없습니다. 계정 생성 후 사용자 정의 도메인을 설정할 수 있습니다.", 741 777 "passkeysNotSupported": "이 브라우저에서 패스키가 지원되지 않습니다. 비밀번호 기반 계정을 만들거나 패스키를 지원하는 브라우저를 사용하세요.", 742 778 "passkeyCancelled": "패스키 생성이 취소되었습니다", 743 - "passkeyFailed": "패스키 등록에 실패했습니다" 744 - } 779 + "passkeyFailed": "패스키 등록에 실패했습니다", 780 + "signalRequired": "Signal 인증에는 전화번호가 필요합니다", 781 + "inviteRequired": "초대 코드가 필요합니다", 782 + "externalDidRequired": "외부 did:web이 필요합니다", 783 + "emailRequired": "이메일 인증에는 이메일이 필요합니다", 784 + "telegramRequired": "Telegram 인증에는 Telegram 사용자 이름이 필요합니다", 785 + "externalDidFormat": "외부 DID는 did:web:으로 시작해야 합니다", 786 + "discordRequired": "Discord 인증에는 Discord ID가 필요합니다" 787 + }, 788 + "whyPasskeyBullet1": "피싱이나 데이터 유출로 도난당할 수 없음", 789 + "whyPasskeyBullet2": "하드웨어 기반 암호화 키 사용", 790 + "whyPasskeyBullet3": "생체 인식 또는 기기 PIN 필요", 791 + "whyPasskeyOnly": "왜 패스키만 사용하나요?", 792 + "whyPasskeyOnlyDesc": "패스키 계정은 비밀번호 기반 계정보다 안전합니다:", 793 + "subtitleInitialDidDoc": "계속하려면 DID 문서를 업로드하세요.", 794 + "subtitleUpdatedDidDoc": "PDS 서명 키로 DID 문서를 업데이트하세요.", 795 + "subtitleActivating": "계정을 활성화하는 중...", 796 + "subtitleComplete": "계정이 성공적으로 생성되었습니다!", 797 + "subtitleCreating": "계정을 생성하는 중...", 798 + "subtitleAppPassword": "서드파티 앱용 앱 비밀번호를 저장하세요.", 799 + "creatingPasskey": "패스키 생성 중...", 800 + "passkeyPrompt": "아래 버튼을 클릭하여 패스키를 생성하세요. 다음을 사용하라는 메시지가 표시됩니다:", 801 + "passkeyPromptBullet1": "Touch ID 또는 Face ID", 802 + "passkeyPromptBullet2": "기기 PIN 또는 비밀번호", 803 + "passkeyPromptBullet3": "보안 키 (있는 경우)", 804 + "identityType": "아이덴티티 유형", 805 + "identityTypeHint": "분산 아이덴티티 관리 방법을 선택하세요.", 806 + "passkeyNameLabel": "패스키 이름 (선택사항)", 807 + "passkeyNamePlaceholder": "예: MacBook Touch ID", 808 + "passkeyNameHint": "이 패스키를 식별할 수 있는 이름", 809 + "createPasskey": "패스키 생성", 810 + "didPlcRecommended": "did:plc (권장)", 811 + "didPlcHint": "PLC Directory에서 관리하는 이동 가능한 아이덴티티", 812 + "didWeb": "did:web", 813 + "didWebHint": "이 PDS에서 호스팅되는 아이덴티티 (아래 경고 읽기)", 814 + "didWebBYOD": "did:web (BYOD)", 815 + "didWebBYODHint": "자체 도메인 사용", 816 + "didWebWarningTitle": "중요: 장단점 이해하기", 817 + "didWebWarning1": "이 PDS에 영구적으로 연결됨:", 818 + "didWebWarning1Detail": "귀하의 아이덴티티 {did}는 이 서버에 연결됩니다.", 819 + "didWebWarning2": "복구 메커니즘 없음:", 820 + "didWebWarning2Detail": "did:plc와 달리 did:web에는 순환 키가 없습니다.", 821 + "didWebWarning3": "우리의 약속:", 822 + "didWebWarning3Detail": "마이그레이션하더라도 최소한의 DID 문서를 계속 제공합니다.", 823 + "didWebWarning4": "권장 사항:", 824 + "didWebWarning4Detail": "did:web을 선호할 특별한 이유가 없다면 did:plc를 선택하세요.", 825 + "externalDidHint": "다음 위치에서 DID 문서를 제공해야 합니다", 826 + "continue": "계속", 827 + "back": "뒤로", 828 + "loading": "로딩 중...", 829 + "redirecting": "대시보드로 이동 중...", 830 + "handleDotWarning": "사용자 정의 도메인 핸들은 계정 생성 후 설정할 수 있습니다.", 831 + "wantTraditional": "기존 비밀번호를 원하시나요?", 832 + "registerWithPassword": "비밀번호로 가입" 745 833 }, 746 834 "trustedDevices": { 747 835 "title": "신뢰할 수 있는 기기", 748 836 "backToSecurity": "← 보안 설정", 749 837 "description": "신뢰할 수 있는 기기는 로그인 시 2단계 인증을 건너뛸 수 있습니다. 신뢰는 30일간 유효하며 기기를 사용할 때 자동으로 연장됩니다.", 838 + "failedToLoad": "신뢰할 수 있는 기기를 불러오지 못했습니다", 750 839 "noDevices": "신뢰할 수 있는 기기가 아직 없습니다.", 751 840 "noDevicesHint": "2단계 인증이 활성화된 상태로 로그인할 때 기기를 30일간 신뢰하도록 선택할 수 있습니다.", 752 841 "lastSeen": "마지막 접속:",
+97 -8
frontend/src/locales/sv.json
··· 30 30 "lostPasskey": "Tappat bort nyckeln?", 31 31 "noAccount": "Har du inget konto?", 32 32 "createAccount": "Skapa konto", 33 - "removeAccount": "Ta bort från sparade konton" 33 + "removeAccount": "Ta bort från sparade konton", 34 + "infoSavedAccountsTitle": "Sparade konton", 35 + "infoSavedAccountsDesc": "Klicka på ett konto för att logga in direkt. Dina sessionstoken lagras säkert i denna webbläsare.", 36 + "infoNewAccountTitle": "Nytt konto", 37 + "infoNewAccountDesc": "Använd inloggningsknappen för att lägga till ett annat konto. Klicka på × för att ta bort sparade konton.", 38 + "infoSecureSignInTitle": "Säker inloggning", 39 + "infoSecureSignInDesc": "Du omdirigeras för säker autentisering. Om du har aktiverat nycklar eller tvåfaktorsautentisering kommer du också att behöva ange dessa.", 40 + "infoStaySignedInTitle": "Förbli inloggad", 41 + "infoStaySignedInDesc": "Efter inloggning sparas ditt konto i denna webbläsare för snabb åtkomst nästa gång.", 42 + "infoRecoveryTitle": "Kontoåterställning", 43 + "infoRecoveryDesc": "Har du tappat bort ditt lösenord eller din nyckel? Använd återställningslänkarna under inloggningsknappen." 34 44 }, 35 45 "verification": { 36 46 "title": "Verifiera ditt konto", ··· 47 57 "register": { 48 58 "title": "Skapa konto", 49 59 "subtitle": "Skapa ett nytt konto på denna PDS", 60 + "subtitleKeyChoice": "Välj hur du vill konfigurera din externa did:web-identitet.", 61 + "subtitleInitialDidDoc": "Ladda upp ditt DID-dokument för att fortsätta.", 62 + "subtitleVerify": "Verifiera din {channel} för att fortsätta.", 63 + "subtitleUpdatedDidDoc": "Uppdatera ditt DID-dokument med PDS-signeringsnyckeln.", 64 + "subtitleActivating": "Aktiverar ditt konto...", 65 + "subtitleComplete": "Ditt konto har skapats!", 66 + "redirecting": "Omdirigerar till kontrollpanelen...", 67 + "infoIdentityDesc": "Din identitet avgör hur ditt konto identifieras i ATProto-nätverket. De flesta användare bör välja standardalternativet.", 68 + "infoContactDesc": "Vi använder detta för att verifiera ditt konto och skicka viktiga meddelanden om din kontosäkerhet.", 69 + "infoNextTitle": "Vad händer härnäst?", 70 + "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.", 50 71 "migrateTitle": "Har du redan ett Bluesky-konto?", 51 72 "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.", 52 73 "migrateLink": "Flytta med PDS Moover", ··· 211 232 "messages": { 212 233 "emailCodeSent": "Verifieringskod skickad till din meddelandekanal", 213 234 "emailUpdated": "E-post uppdaterad", 235 + "emailUpdateFailed": "Kunde inte uppdatera e-post", 214 236 "handleUpdated": "Användarnamn uppdaterat", 237 + "handleUpdateFailed": "Kunde inte uppdatera användarnamn", 215 238 "passwordChanged": "Lösenord ändrat", 239 + "passwordChangeFailed": "Kunde inte ändra lösenord", 216 240 "passwordsMismatch": "Lösenorden matchar inte", 241 + "passwordsDoNotMatch": "Lösenorden matchar inte", 217 242 "passwordLength": "Lösenordet måste vara minst 8 tecken", 243 + "passwordTooShort": "Lösenordet måste vara minst 8 tecken", 218 244 "deletionCodeSent": "Bekräftelse för radering skickad till din e-post", 245 + "deletionConfirmationSent": "Bekräftelse för radering skickad till din e-post", 246 + "deletionRequestFailed": "Kunde inte begära kontoradering", 247 + "deleteConfirmation": "Är du helt säker på att du vill radera ditt konto? Detta kan inte ångras.", 248 + "deletionFailed": "Kunde inte radera kontot", 219 249 "repoExported": "Arkiv exporterat", 250 + "exportFailed": "Kunde inte exportera arkiv", 220 251 "confirmDelete": "Är du helt säker på att du vill radera ditt konto? Detta kan inte ångras." 221 252 } 222 253 }, ··· 362 393 "manageTrustedDevices": "Hantera betrodda enheter", 363 394 "appCompatibility": "Appkompatibilitet", 364 395 "enterPassword": "Ange ditt lösenord", 396 + "sessionExpired": "Sessionen har gått ut. Logga in igen.", 365 397 "legacyLoginEnabled": "Föråldrad appinloggning aktiverad", 366 398 "legacyLoginDisabled": "Föråldrad appinloggning inaktiverad - endast OAuth-appar kan logga in", 367 399 "failedToUpdatePreference": "Kunde inte uppdatera inställning", ··· 421 453 "noRecords": "Inga poster i denna samling", 422 454 "recordDetails": "Postdetaljer", 423 455 "rkey": "Postnyckel", 456 + "uri": "URI", 424 457 "cid": "CID", 425 458 "value": "Värde", 426 459 "deleteRecord": "Radera post", ··· 464 497 "themeColorsHint": "Lämna tomt för att använda standardfärger.", 465 498 "primaryLight": "Primär (ljust läge)", 466 499 "primaryDark": "Primär (mörkt läge)", 467 - "accentLight": "Accent (ljust läge)", 468 - "accentDark": "Accent (mörkt läge)", 469 - "faviconExample": "Favicon-exempel", 470 500 "configSaved": "Serverkonfiguration sparad", 471 501 "saving": "Sparar...", 472 502 "saveConfig": "Spara konfiguration", ··· 508 538 "deleteConfirm": "Radera konto @{handle}? Detta kan inte ångras.", 509 539 "verified": "Verifierad", 510 540 "unverified": "Ej verifierad", 511 - "deactivated": "Inaktiverad" 541 + "deactivated": "Inaktiverad", 542 + "colorDefault": "{color} (standard)", 543 + "secondaryLight": "Sekundär (Ljust läge)", 544 + "secondaryDark": "Sekundär (Mörkt läge)" 512 545 }, 513 546 "oauth": { 514 547 "login": { ··· 524 557 "rememberDevice": "Kom ihåg denna enhet", 525 558 "passkeyHintChecking": "Kontrollerar nyckelstatus...", 526 559 "passkeyHintAvailable": "Logga in med din nyckel", 527 - "passkeyHintNotAvailable": "Inga nycklar registrerade för detta konto" 560 + "passkeyHintNotAvailable": "Inga nycklar registrerade för detta konto", 561 + "passkeyHint": "Använd enhetens biometri eller säkerhetsnyckel", 562 + "passwordPlaceholder": "Ange ditt lösenord", 563 + "usePasskey": "Använd nyckel" 528 564 }, 529 565 "consent": { 530 566 "title": "Auktorisera applikation", ··· 740 776 "handleNoDots": "Användarnamn kan inte innehålla punkter. Du kan konfigurera ett eget domännamn efter att kontot skapats.", 741 777 "passkeysNotSupported": "Nycklar stöds inte i denna webbläsare. Skapa ett lösenordsbaserat konto eller använd en webbläsare som stöder nycklar.", 742 778 "passkeyCancelled": "Nyckelskapande avbröts", 743 - "passkeyFailed": "Nyckelregistrering misslyckades" 744 - } 779 + "passkeyFailed": "Nyckelregistrering misslyckades", 780 + "signalRequired": "Telefonnummer krävs för Signal-verifiering", 781 + "inviteRequired": "Inbjudningskod krävs", 782 + "externalDidRequired": "Extern did:web krävs", 783 + "emailRequired": "E-post krävs för e-postverifiering", 784 + "telegramRequired": "Telegram-användarnamn krävs för Telegram-verifiering", 785 + "externalDidFormat": "Extern DID måste börja med did:web:", 786 + "discordRequired": "Discord-ID krävs för Discord-verifiering" 787 + }, 788 + "whyPasskeyBullet1": "Kan inte nätfiskas eller stjälas vid dataintrång", 789 + "whyPasskeyBullet2": "Använder hårdvarubaserade kryptografiska nycklar", 790 + "whyPasskeyBullet3": "Kräver din biometri eller enhets-PIN för att använda", 791 + "whyPasskeyOnly": "Varför endast nyckel?", 792 + "whyPasskeyOnlyDesc": "Nyckelkonton är säkrare än lösenordsbaserade konton eftersom de:", 793 + "subtitleInitialDidDoc": "Ladda upp ditt DID-dokument för att fortsätta.", 794 + "subtitleUpdatedDidDoc": "Uppdatera ditt DID-dokument med PDS-signeringsnyckeln.", 795 + "subtitleActivating": "Aktiverar ditt konto...", 796 + "subtitleComplete": "Ditt konto har skapats!", 797 + "subtitleCreating": "Skapar ditt konto...", 798 + "subtitleAppPassword": "Spara ditt applösenord för tredjepartsappar.", 799 + "creatingPasskey": "Skapar nyckel...", 800 + "passkeyPrompt": "Klicka på knappen nedan för att skapa din nyckel. Du kommer att uppmanas att använda:", 801 + "passkeyPromptBullet1": "Touch ID eller Face ID", 802 + "passkeyPromptBullet2": "Din enhets PIN-kod eller lösenord", 803 + "passkeyPromptBullet3": "En säkerhetsnyckel (om du har en)", 804 + "identityType": "Identitetstyp", 805 + "identityTypeHint": "Välj hur din decentraliserade identitet ska hanteras.", 806 + "passkeyNameLabel": "Nyckelnamn (valfritt)", 807 + "passkeyNamePlaceholder": "t.ex. MacBook Touch ID", 808 + "passkeyNameHint": "Ett vänligt namn för att identifiera denna nyckel", 809 + "createPasskey": "Skapa nyckel", 810 + "didPlcRecommended": "did:plc (Rekommenderas)", 811 + "didPlcHint": "Portabel identitet som hanteras av PLC Directory", 812 + "didWeb": "did:web", 813 + "didWebHint": "Identitet som lagras på denna PDS (läs varningen nedan)", 814 + "didWebBYOD": "did:web (BYOD)", 815 + "didWebBYODHint": "Ta med din egen domän", 816 + "didWebWarningTitle": "Viktigt: Förstå kompromisserna", 817 + "didWebWarning1": "Permanent koppling till denna PDS:", 818 + "didWebWarning1Detail": "Din identitet {did} är knuten till denna server.", 819 + "didWebWarning2": "Ingen återställningsmekanism:", 820 + "didWebWarning2Detail": "Till skillnad från did:plc har did:web inga rotationsnycklar.", 821 + "didWebWarning3": "Vi förbinder oss till dig:", 822 + "didWebWarning3Detail": "Om du migrerar bort kommer vi att fortsätta servera ett minimalt DID-dokument.", 823 + "didWebWarning4": "Rekommendation:", 824 + "didWebWarning4Detail": "Välj did:plc om du inte har en specifik anledning att föredra did:web.", 825 + "externalDidHint": "Du behöver servera ett DID-dokument på", 826 + "continue": "Fortsätt", 827 + "back": "Tillbaka", 828 + "loading": "Laddar...", 829 + "redirecting": "Omdirigerar till instrumentpanelen...", 830 + "handleDotWarning": "Egna domännamn kan konfigureras efter att kontot skapats.", 831 + "wantTraditional": "Vill du ha ett traditionellt lösenord?", 832 + "registerWithPassword": "Registrera med lösenord" 745 833 }, 746 834 "trustedDevices": { 747 835 "title": "Betrodda enheter", 748 836 "backToSecurity": "← Säkerhetsinställningar", 749 837 "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.", 838 + "failedToLoad": "Kunde inte ladda betrodda enheter", 750 839 "noDevices": "Inga betrodda enheter ännu.", 751 840 "noDevicesHint": "När du loggar in med tvåfaktorsautentisering aktiverat kan du välja att lita på enheten i 30 dagar.", 752 841 "lastSeen": "Senast sedd:",
+40 -6
frontend/src/locales/zh.json
··· 30 30 "lostPasskey": "丢失通行密钥?", 31 31 "noAccount": "还没有账户?", 32 32 "createAccount": "立即注册", 33 - "removeAccount": "从已保存账户中移除" 33 + "removeAccount": "从已保存账户中移除", 34 + "infoSavedAccountsTitle": "已保存账户", 35 + "infoSavedAccountsDesc": "点击账户即可快速登录。您的会话令牌安全存储在此浏览器中。", 36 + "infoNewAccountTitle": "新账户", 37 + "infoNewAccountDesc": "使用登录按钮添加其他账户。点击 × 可从此浏览器中移除已保存的账户。", 38 + "infoSecureSignInTitle": "安全登录", 39 + "infoSecureSignInDesc": "您将被重定向进行安全认证。如果您启用了通行密钥或双重身份验证,也会提示您进行验证。", 40 + "infoStaySignedInTitle": "保持登录", 41 + "infoStaySignedInDesc": "登录后,您的账户将保存在此浏览器中,方便下次快速访问。", 42 + "infoRecoveryTitle": "账户恢复", 43 + "infoRecoveryDesc": "忘记密码或丢失通行密钥?使用登录按钮下方的恢复链接。" 34 44 }, 35 45 "verification": { 36 46 "title": "验证账户", ··· 47 57 "register": { 48 58 "title": "创建账户", 49 59 "subtitle": "在此 PDS 上创建新账户", 60 + "subtitleKeyChoice": "选择如何设置您的外部 did:web 身份。", 61 + "subtitleInitialDidDoc": "上传您的 DID 文档以继续。", 62 + "subtitleVerify": "验证您的{channel}以继续。", 63 + "subtitleUpdatedDidDoc": "使用 PDS 签名密钥更新您的 DID 文档。", 64 + "subtitleActivating": "正在激活您的账户...", 65 + "subtitleComplete": "您的账户已成功创建!", 66 + "redirecting": "正在跳转到控制台...", 67 + "infoIdentityDesc": "您的身份决定了您的账户在 ATProto 网络中的识别方式。大多数用户应选择标准选项。", 68 + "infoContactDesc": "我们将使用此信息验证您的账户并发送有关账户安全的重要通知。", 69 + "infoNextTitle": "接下来会发生什么?", 70 + "infoNextDesc": "创建账户后,您需要验证联系方式,然后即可使用任何 ATProto 应用程序。", 50 71 "migrateTitle": "已有 Bluesky 账户?", 51 72 "migrateDescription": "您可以将现有账户迁移到此 PDS,而无需创建新账户。您的关注者、帖子和身份都会一起迁移。", 52 73 "migrateLink": "使用 PDS Moover 迁移", ··· 211 232 "messages": { 212 233 "emailCodeSent": "验证码已发送到您的通知渠道", 213 234 "emailUpdated": "邮箱更新成功", 235 + "emailUpdateFailed": "邮箱更新失败", 214 236 "handleUpdated": "用户名更新成功", 237 + "handleUpdateFailed": "用户名更新失败", 215 238 "passwordChanged": "密码更改成功", 239 + "passwordChangeFailed": "密码更改失败", 216 240 "passwordsMismatch": "两次输入的密码不一致", 241 + "passwordsDoNotMatch": "两次输入的密码不一致", 217 242 "passwordLength": "密码至少需要8位字符", 243 + "passwordTooShort": "密码至少需要8位字符", 218 244 "deletionCodeSent": "删除确认码已发送到您的邮箱", 245 + "deletionConfirmationSent": "删除确认码已发送到您的邮箱", 246 + "deletionRequestFailed": "账户删除请求失败", 247 + "deleteConfirmation": "您确定要删除账户吗?此操作无法撤销。", 248 + "deletionFailed": "账户删除失败", 219 249 "repoExported": "数据导出成功", 250 + "exportFailed": "数据导出失败", 220 251 "confirmDelete": "您确定要删除账户吗?此操作无法撤销。" 221 252 } 222 253 }, ··· 362 393 "manageTrustedDevices": "管理受信任设备", 363 394 "appCompatibility": "应用兼容性", 364 395 "enterPassword": "输入您的密码", 396 + "sessionExpired": "会话已过期,请重新登录。", 365 397 "legacyLoginEnabled": "已启用传统应用登录", 366 398 "legacyLoginDisabled": "已禁用传统应用登录 - 仅 OAuth 应用可登录", 367 399 "failedToUpdatePreference": "更新偏好设置失败", ··· 421 453 "noRecords": "此集合中暂无记录", 422 454 "recordDetails": "记录详情", 423 455 "rkey": "记录键", 456 + "uri": "URI", 424 457 "cid": "CID", 425 458 "value": "值", 426 459 "deleteRecord": "删除记录", ··· 463 496 "themeColors": "主题颜色", 464 497 "themeColorsHint": "留空使用默认颜色。", 465 498 "primaryLight": "主色(浅色模式)", 466 - "primaryLightDefault": "#2c00ff(默认)", 499 + "colorDefault": "{color}(默认)", 467 500 "primaryDark": "主色(深色模式)", 468 - "primaryDarkDefault": "#7b6bff(默认)", 469 501 "secondaryLight": "副色(浅色模式)", 470 - "secondaryLightDefault": "#ff2400(默认)", 471 502 "secondaryDark": "副色(深色模式)", 472 - "secondaryDarkDefault": "#ff6b5b(默认)", 473 503 "configSaved": "服务器配置已保存", 474 504 "saving": "保存中...", 475 505 "saveConfig": "保存配置", ··· 527 557 "rememberDevice": "记住此设备", 528 558 "passkeyHintChecking": "正在检查通行密钥状态...", 529 559 "passkeyHintAvailable": "使用您的通行密钥登录", 530 - "passkeyHintNotAvailable": "此账户未注册通行密钥" 560 + "passkeyHintNotAvailable": "此账户未注册通行密钥", 561 + "passkeyHint": "使用设备的生物识别或安全密钥", 562 + "passwordPlaceholder": "输入您的密码", 563 + "usePasskey": "使用通行密钥" 531 564 }, 532 565 "consent": { 533 566 "title": "授权应用", ··· 785 818 "title": "受信任设备", 786 819 "backToSecurity": "← 安全设置", 787 820 "description": "受信任设备可以跳过双重身份验证。信任有效期为30天,使用设备时自动延长。", 821 + "failedToLoad": "加载受信任设备失败", 788 822 "noDevices": "暂无受信任设备", 789 823 "noDevicesHint": "开启双重身份验证后登录时,可以选择信任设备30天。", 790 824 "lastSeen": "最后使用:",
+6 -6
frontend/src/main.ts
··· 1 - import './styles/base.css' 2 - import App from './App.svelte' 3 - import { mount } from 'svelte' 1 + import "./styles/base.css"; 2 + import App from "./App.svelte"; 3 + import { mount } from "svelte"; 4 4 5 5 const app = mount(App, { 6 - target: document.getElementById('app')!, 7 - }) 6 + target: document.getElementById("app")!, 7 + }); 8 8 9 - export default app 9 + export default app;
+11 -5
frontend/src/routes/Admin.svelte
··· 6 6 import { _ } from '../lib/i18n' 7 7 import { formatDate, formatDateTime } from '../lib/date' 8 8 const auth = getAuthState() 9 + const DEFAULT_COLORS = { 10 + primaryLight: '#1A1D1D', 11 + primaryDark: '#E6E8E8', 12 + secondaryLight: '#1A1D1D', 13 + secondaryDark: '#E6E8E8', 14 + } 9 15 let loading = $state(true) 10 16 let error = $state<string | null>(null) 11 17 let stats = $state<{ ··· 364 370 type="text" 365 371 id="primaryColor" 366 372 bind:value={primaryColorInput} 367 - placeholder={$_('admin.primaryLightDefault')} 373 + placeholder={$_('admin.colorDefault', { values: { color: DEFAULT_COLORS.primaryLight } })} 368 374 disabled={serverConfigLoading} 369 375 /> 370 376 </div> ··· 381 387 type="text" 382 388 id="primaryColorDark" 383 389 bind:value={primaryColorDarkInput} 384 - placeholder={$_('admin.primaryDarkDefault')} 390 + placeholder={$_('admin.colorDefault', { values: { color: DEFAULT_COLORS.primaryDark } })} 385 391 disabled={serverConfigLoading} 386 392 /> 387 393 </div> ··· 398 404 type="text" 399 405 id="secondaryColor" 400 406 bind:value={secondaryColorInput} 401 - placeholder={$_('admin.secondaryLightDefault')} 407 + placeholder={$_('admin.colorDefault', { values: { color: DEFAULT_COLORS.secondaryLight } })} 402 408 disabled={serverConfigLoading} 403 409 /> 404 410 </div> ··· 415 421 type="text" 416 422 id="secondaryColorDark" 417 423 bind:value={secondaryColorDarkInput} 418 - placeholder={$_('admin.secondaryDarkDefault')} 424 + placeholder={$_('admin.colorDefault', { values: { color: DEFAULT_COLORS.secondaryDark } })} 419 425 disabled={serverConfigLoading} 420 426 /> 421 427 </div> ··· 646 652 {/if} 647 653 <style> 648 654 .page { 649 - max-width: var(--width-lg); 655 + max-width: var(--width-xl); 650 656 margin: 0 auto; 651 657 padding: var(--space-7); 652 658 }
+1 -1
frontend/src/routes/AppPasswords.svelte
··· 156 156 </div> 157 157 <style> 158 158 .page { 159 - max-width: var(--width-md); 159 + max-width: var(--width-lg); 160 160 margin: 0 auto; 161 161 padding: var(--space-7); 162 162 }
+274 -216
frontend/src/routes/Comms.svelte
··· 22 22 let verificationCode = $state('') 23 23 let verificationError = $state<string | null>(null) 24 24 let verificationSuccess = $state<string | null>(null) 25 - let historyLoading = $state(false) 25 + let historyLoading = $state(true) 26 26 let historyError = $state<string | null>(null) 27 27 let messages = $state<Array<{ 28 28 createdAt: string ··· 32 32 subject: string | null 33 33 body: string 34 34 }>>([]) 35 - let showHistory = $state(false) 36 35 $effect(() => { 37 36 if (!auth.loading && !auth.session) { 38 37 navigate('/login') ··· 41 40 $effect(() => { 42 41 if (auth.session) { 43 42 loadPrefs() 43 + loadHistory() 44 44 } 45 45 }) 46 46 async function loadPrefs() { ··· 120 120 try { 121 121 const result = await api.getNotificationHistory(auth.session.accessJwt) 122 122 messages = result.notifications 123 - showHistory = true 124 123 } catch (e) { 125 124 historyError = e instanceof ApiError ? e.message : 'Failed to load notification history' 126 125 } finally { ··· 171 170 <header> 172 171 <a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a> 173 172 <h1>{$_('comms.title')}</h1> 173 + <p class="description">{$_('comms.description')}</p> 174 174 </header> 175 - <p class="description"> 176 - {$_('comms.description')} 177 - </p> 175 + 178 176 {#if loading} 179 177 <p class="loading">{$_('common.loading')}</p> 180 178 {:else} ··· 184 182 {#if success} 185 183 <div class="message success">{success}</div> 186 184 {/if} 187 - <form onsubmit={handleSave}> 188 - <section> 189 - <h2>{$_('comms.preferredChannel')}</h2> 190 - <p class="section-description"> 191 - {$_('comms.preferredChannelDescription')} 192 - </p> 193 - <div class="channel-options"> 194 - {#each channels as channelId} 195 - <label class="channel-option" class:disabled={!canSelectChannel(channelId)} class:unavailable={!isChannelAvailableOnServer(channelId)}> 196 - <input 197 - type="radio" 198 - name="preferredChannel" 199 - value={channelId} 200 - bind:group={preferredChannel} 201 - disabled={!canSelectChannel(channelId) || saving} 202 - /> 203 - <div class="channel-info"> 204 - <span class="channel-name">{getChannelName(channelId)}</span> 205 - <span class="channel-description">{getChannelDescription(channelId)}</span> 206 - {#if !isChannelAvailableOnServer(channelId)} 207 - <span class="channel-hint server-unavailable">{$_('comms.notConfiguredOnServer')}</span> 208 - {:else if channelId !== 'email' && !canSelectChannel(channelId)} 209 - <span class="channel-hint">{$_('comms.configureToEnable')}</span> 210 - {/if} 185 + 186 + <div class="split-layout"> 187 + <div class="main-column"> 188 + <form onsubmit={handleSave}> 189 + <section> 190 + <h2>{$_('comms.preferredChannel')}</h2> 191 + <p class="section-description">{$_('comms.preferredChannelDescription')}</p> 192 + <div class="channel-options"> 193 + {#each channels as channelId} 194 + <label class="channel-option" class:disabled={!canSelectChannel(channelId)} class:unavailable={!isChannelAvailableOnServer(channelId)}> 195 + <input 196 + type="radio" 197 + name="preferredChannel" 198 + value={channelId} 199 + bind:group={preferredChannel} 200 + disabled={!canSelectChannel(channelId) || saving} 201 + /> 202 + <div class="channel-info"> 203 + <span class="channel-name">{getChannelName(channelId)}</span> 204 + <span class="channel-description">{getChannelDescription(channelId)}</span> 205 + {#if !isChannelAvailableOnServer(channelId)} 206 + <span class="channel-hint server-unavailable">{$_('comms.notConfiguredOnServer')}</span> 207 + {:else if channelId !== 'email' && !canSelectChannel(channelId)} 208 + <span class="channel-hint">{$_('comms.configureToEnable')}</span> 209 + {/if} 210 + </div> 211 + </label> 212 + {/each} 213 + </div> 214 + </section> 215 + 216 + <section> 217 + <h2>{$_('comms.channelConfiguration')}</h2> 218 + <div class="channel-config"> 219 + <div class="config-item"> 220 + <div class="config-header"> 221 + <label for="email">{$_('register.email')}</label> 222 + <span class="status verified">{$_('comms.primary')}</span> 223 + </div> 224 + <input id="email" type="email" value={email} disabled class="readonly" /> 225 + <p class="config-hint">{$_('comms.emailManagedInSettings')}</p> 211 226 </div> 212 - </label> 213 - {/each} 214 - </div> 215 - </section> 216 - <section> 217 - <h2>{$_('comms.channelConfiguration')}</h2> 218 - <div class="channel-config"> 219 - <div class="config-item"> 220 - <label for="email">{$_('register.email')}</label> 221 - <div class="config-input"> 222 - <input 223 - id="email" 224 - type="email" 225 - value={email} 226 - disabled 227 - class="readonly" 228 - /> 229 - <span class="status verified">{$_('comms.primary')}</span> 230 - </div> 231 - <p class="config-hint">{$_('comms.emailManagedInSettings')}</p> 232 - </div> 233 - <div class="config-item" class:unavailable={!isChannelAvailableOnServer('discord')}> 234 - <label for="discord">{$_('register.discordId')}</label> 235 - <div class="config-input"> 236 - <input 237 - id="discord" 238 - type="text" 239 - bind:value={discordId} 240 - placeholder={$_('register.discordIdPlaceholder')} 241 - disabled={saving || !isChannelAvailableOnServer('discord')} 242 - /> 243 - {#if !isChannelAvailableOnServer('discord')} 244 - <span class="status unavailable">{$_('comms.notConfiguredOnServer')}</span> 245 - {:else if discordId} 246 - {#if discordVerified} 247 - <span class="status verified">{$_('comms.verified')}</span> 248 - {:else} 249 - <span class="status unverified">{$_('comms.notVerified')}</span> 250 - <button type="button" class="verify-btn" onclick={() => verifyingChannel = 'discord'}>{$_('comms.verifyButton')}</button> 227 + 228 + <div class="config-item" class:unavailable={!isChannelAvailableOnServer('discord')}> 229 + <div class="config-header"> 230 + <label for="discord">{$_('register.discordId')}</label> 231 + {#if !isChannelAvailableOnServer('discord')} 232 + <span class="status unavailable">{$_('comms.notConfiguredOnServer')}</span> 233 + {:else if discordId} 234 + {#if discordVerified} 235 + <span class="status verified">{$_('comms.verified')}</span> 236 + {:else} 237 + <span class="status unverified">{$_('comms.notVerified')}</span> 238 + {/if} 239 + {/if} 240 + </div> 241 + <div class="config-input"> 242 + <input 243 + id="discord" 244 + type="text" 245 + bind:value={discordId} 246 + placeholder={$_('register.discordIdPlaceholder')} 247 + disabled={saving || !isChannelAvailableOnServer('discord')} 248 + /> 249 + {#if discordId && !discordVerified && isChannelAvailableOnServer('discord')} 250 + <button type="button" class="verify-btn" onclick={() => verifyingChannel = 'discord'}>{$_('comms.verifyButton')}</button> 251 + {/if} 252 + </div> 253 + <p class="config-hint">{$_('comms.discordIdHint')}</p> 254 + {#if verifyingChannel === 'discord'} 255 + <div class="verify-form"> 256 + <input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="6" /> 257 + <button type="button" onclick={() => handleVerify('discord')}>{$_('comms.submit')}</button> 258 + <button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button> 259 + </div> 251 260 {/if} 252 - {/if} 253 - </div> 254 - <p class="config-hint">{$_('comms.discordIdHint')}</p> 255 - {#if verifyingChannel === 'discord'} 256 - <div class="verify-form"> 257 - <input 258 - type="text" 259 - bind:value={verificationCode} 260 - placeholder={$_('comms.verifyCodePlaceholder')} 261 - maxlength="6" 262 - /> 263 - <button type="button" onclick={() => handleVerify('discord')}>{$_('comms.submit')}</button> 264 - <button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button> 265 261 </div> 266 - {/if} 267 - </div> 268 - <div class="config-item" class:unavailable={!isChannelAvailableOnServer('telegram')}> 269 - <label for="telegram">{$_('register.telegramUsername')}</label> 270 - <div class="config-input"> 271 - <input 272 - id="telegram" 273 - type="text" 274 - bind:value={telegramUsername} 275 - placeholder={$_('register.telegramUsernamePlaceholder')} 276 - disabled={saving || !isChannelAvailableOnServer('telegram')} 277 - /> 278 - {#if !isChannelAvailableOnServer('telegram')} 279 - <span class="status unavailable">{$_('comms.notConfiguredOnServer')}</span> 280 - {:else if telegramUsername} 281 - {#if telegramVerified} 282 - <span class="status verified">{$_('comms.verified')}</span> 283 - {:else} 284 - <span class="status unverified">{$_('comms.notVerified')}</span> 285 - <button type="button" class="verify-btn" onclick={() => verifyingChannel = 'telegram'}>{$_('comms.verifyButton')}</button> 262 + 263 + <div class="config-item" class:unavailable={!isChannelAvailableOnServer('telegram')}> 264 + <div class="config-header"> 265 + <label for="telegram">{$_('register.telegramUsername')}</label> 266 + {#if !isChannelAvailableOnServer('telegram')} 267 + <span class="status unavailable">{$_('comms.notConfiguredOnServer')}</span> 268 + {:else if telegramUsername} 269 + {#if telegramVerified} 270 + <span class="status verified">{$_('comms.verified')}</span> 271 + {:else} 272 + <span class="status unverified">{$_('comms.notVerified')}</span> 273 + {/if} 274 + {/if} 275 + </div> 276 + <div class="config-input"> 277 + <input 278 + id="telegram" 279 + type="text" 280 + bind:value={telegramUsername} 281 + placeholder={$_('register.telegramUsernamePlaceholder')} 282 + disabled={saving || !isChannelAvailableOnServer('telegram')} 283 + /> 284 + {#if telegramUsername && !telegramVerified && isChannelAvailableOnServer('telegram')} 285 + <button type="button" class="verify-btn" onclick={() => verifyingChannel = 'telegram'}>{$_('comms.verifyButton')}</button> 286 + {/if} 287 + </div> 288 + <p class="config-hint">{$_('comms.telegramHint')}</p> 289 + {#if verifyingChannel === 'telegram'} 290 + <div class="verify-form"> 291 + <input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="6" /> 292 + <button type="button" onclick={() => handleVerify('telegram')}>{$_('comms.submit')}</button> 293 + <button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button> 294 + </div> 286 295 {/if} 287 - {/if} 288 - </div> 289 - <p class="config-hint">{$_('comms.telegramHint')}</p> 290 - {#if verifyingChannel === 'telegram'} 291 - <div class="verify-form"> 292 - <input 293 - type="text" 294 - bind:value={verificationCode} 295 - placeholder={$_('comms.verifyCodePlaceholder')} 296 - maxlength="6" 297 - /> 298 - <button type="button" onclick={() => handleVerify('telegram')}>{$_('comms.submit')}</button> 299 - <button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button> 300 296 </div> 301 - {/if} 302 - </div> 303 - <div class="config-item" class:unavailable={!isChannelAvailableOnServer('signal')}> 304 - <label for="signal">{$_('register.signalNumber')}</label> 305 - <div class="config-input"> 306 - <input 307 - id="signal" 308 - type="tel" 309 - bind:value={signalNumber} 310 - placeholder={$_('register.signalNumberPlaceholder')} 311 - disabled={saving || !isChannelAvailableOnServer('signal')} 312 - /> 313 - {#if !isChannelAvailableOnServer('signal')} 314 - <span class="status unavailable">{$_('comms.notConfiguredOnServer')}</span> 315 - {:else if signalNumber} 316 - {#if signalVerified} 317 - <span class="status verified">{$_('comms.verified')}</span> 318 - {:else} 319 - <span class="status unverified">{$_('comms.notVerified')}</span> 320 - <button type="button" class="verify-btn" onclick={() => verifyingChannel = 'signal'}>{$_('comms.verifyButton')}</button> 297 + 298 + <div class="config-item" class:unavailable={!isChannelAvailableOnServer('signal')}> 299 + <div class="config-header"> 300 + <label for="signal">{$_('register.signalNumber')}</label> 301 + {#if !isChannelAvailableOnServer('signal')} 302 + <span class="status unavailable">{$_('comms.notConfiguredOnServer')}</span> 303 + {:else if signalNumber} 304 + {#if signalVerified} 305 + <span class="status verified">{$_('comms.verified')}</span> 306 + {:else} 307 + <span class="status unverified">{$_('comms.notVerified')}</span> 308 + {/if} 309 + {/if} 310 + </div> 311 + <div class="config-input"> 312 + <input 313 + id="signal" 314 + type="tel" 315 + bind:value={signalNumber} 316 + placeholder={$_('register.signalNumberPlaceholder')} 317 + disabled={saving || !isChannelAvailableOnServer('signal')} 318 + /> 319 + {#if signalNumber && !signalVerified && isChannelAvailableOnServer('signal')} 320 + <button type="button" class="verify-btn" onclick={() => verifyingChannel = 'signal'}>{$_('comms.verifyButton')}</button> 321 + {/if} 322 + </div> 323 + <p class="config-hint">{$_('comms.signalHint')}</p> 324 + {#if verifyingChannel === 'signal'} 325 + <div class="verify-form"> 326 + <input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="6" /> 327 + <button type="button" onclick={() => handleVerify('signal')}>{$_('comms.submit')}</button> 328 + <button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button> 329 + </div> 321 330 {/if} 322 - {/if} 331 + </div> 323 332 </div> 324 - <p class="config-hint">{$_('comms.signalHint')}</p> 325 - {#if verifyingChannel === 'signal'} 326 - <div class="verify-form"> 327 - <input 328 - type="text" 329 - bind:value={verificationCode} 330 - placeholder={$_('comms.verifyCodePlaceholder')} 331 - maxlength="6" 332 - /> 333 - <button type="button" onclick={() => handleVerify('signal')}>{$_('comms.submit')}</button> 334 - <button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button> 335 - </div> 333 + 334 + {#if verificationError} 335 + <div class="message error" style="margin-top: 1rem">{verificationError}</div> 336 + {/if} 337 + {#if verificationSuccess} 338 + <div class="message success" style="margin-top: 1rem">{verificationSuccess}</div> 336 339 {/if} 340 + </section> 341 + 342 + <div class="actions"> 343 + <button type="submit" disabled={saving}> 344 + {saving ? $_('comms.saving') : $_('comms.savePreferences')} 345 + </button> 337 346 </div> 338 - </div> 339 - {#if verificationError} 340 - <div class="message error" style="margin-top: 1rem">{verificationError}</div> 341 - {/if} 342 - {#if verificationSuccess} 343 - <div class="message success" style="margin-top: 1rem">{verificationSuccess}</div> 344 - {/if} 345 - </section> 346 - <div class="actions"> 347 - <button type="submit" disabled={saving}> 348 - {saving ? $_('comms.saving') : $_('comms.savePreferences')} 349 - </button> 347 + </form> 350 348 </div> 351 - </form> 352 - <section class="history-section"> 353 - <h2>{$_('comms.messageHistory')}</h2> 354 - <p class="section-description">{$_('comms.historyDescription')}</p> 355 - {#if !showHistory} 356 - <button class="load-history" onclick={loadHistory} disabled={historyLoading}> 357 - {historyLoading ? $_('common.loading') : $_('comms.loadHistory')} 358 - </button> 359 - {:else} 360 - <button class="load-history" onclick={() => showHistory = false}>{$_('comms.hideHistory')}</button> 361 - {#if historyError} 362 - <div class="message error">{historyError}</div> 363 - {:else if messages.length === 0} 364 - <p class="no-messages">{$_('comms.noMessages')}</p> 365 - {:else} 366 - <div class="message-list"> 367 - {#each messages as msg} 368 - <div class="message-item"> 369 - <div class="message-header"> 370 - <span class="message-type">{msg.notificationType}</span> 371 - <span class="message-channel">{msg.channel}</span> 372 - <span class="message-status" class:sent={msg.status === 'sent'} class:failed={msg.status === 'failed'}>{msg.status}</span> 349 + 350 + <div class="side-column"> 351 + <section class="history-section"> 352 + <h2>{$_('comms.messageHistory')}</h2> 353 + <p class="section-description">{$_('comms.historyDescription')}</p> 354 + {#if historyLoading} 355 + <div class="skeleton-list"> 356 + {#each [1, 2, 3] as _} 357 + <div class="skeleton-item"> 358 + <div class="skeleton-header"> 359 + <div class="skeleton-line short"></div> 360 + <div class="skeleton-line tiny"></div> 361 + </div> 362 + <div class="skeleton-line"></div> 363 + <div class="skeleton-line medium"></div> 364 + </div> 365 + {/each} 366 + </div> 367 + {:else if historyError} 368 + <div class="message error">{historyError}</div> 369 + {:else if messages.length === 0} 370 + <p class="no-messages">{$_('comms.noMessages')}</p> 371 + {:else} 372 + <div class="message-list"> 373 + {#each messages as msg} 374 + <div class="message-item"> 375 + <div class="message-header"> 376 + <span class="message-type">{msg.notificationType}</span> 377 + <span class="message-channel">{msg.channel}</span> 378 + <span class="message-status" class:sent={msg.status === 'sent'} class:failed={msg.status === 'failed'}>{msg.status}</span> 379 + </div> 380 + {#if msg.subject} 381 + <div class="message-subject">{msg.subject}</div> 382 + {/if} 383 + <div class="message-body">{msg.body}</div> 384 + <div class="message-date">{formatDate(msg.createdAt)}</div> 373 385 </div> 374 - {#if msg.subject} 375 - <div class="message-subject">{msg.subject}</div> 376 - {/if} 377 - <div class="message-body">{msg.body}</div> 378 - <div class="message-date">{formatDate(msg.createdAt)}</div> 379 - </div> 380 - {/each} 381 - </div> 382 - {/if} 383 - {/if} 384 - </section> 386 + {/each} 387 + </div> 388 + {/if} 389 + </section> 390 + </div> 391 + </div> 385 392 {/if} 386 393 </div> 387 394 <style> 388 395 .page { 389 - max-width: var(--width-md); 396 + max-width: var(--width-xl); 390 397 margin: 0 auto; 391 398 padding: var(--space-7); 392 399 } 393 400 394 401 header { 395 - margin-bottom: var(--space-4); 402 + margin-bottom: var(--space-6); 396 403 } 397 404 398 405 .back { ··· 411 418 412 419 .description { 413 420 color: var(--text-secondary); 414 - margin-bottom: var(--space-7); 421 + margin: var(--space-2) 0 0 0; 415 422 } 416 423 417 424 .loading { ··· 420 427 padding: var(--space-7); 421 428 } 422 429 430 + .split-layout { 431 + display: grid; 432 + grid-template-columns: 1fr; 433 + gap: var(--space-6); 434 + } 435 + 436 + @media (min-width: 900px) { 437 + .split-layout { 438 + grid-template-columns: 1.5fr 1fr; 439 + align-items: start; 440 + } 441 + } 442 + 443 + .main-column, .side-column { 444 + min-width: 0; 445 + } 446 + 423 447 section { 424 448 background: var(--bg-secondary); 425 449 padding: var(--space-6); 426 450 border-radius: var(--radius-xl); 427 451 margin-bottom: var(--space-6); 452 + } 453 + 454 + .side-column section { 455 + margin-bottom: 0; 428 456 } 429 457 430 458 section h2 { ··· 520 548 opacity: 0.6; 521 549 } 522 550 551 + .config-header { 552 + display: flex; 553 + align-items: center; 554 + justify-content: space-between; 555 + gap: var(--space-3); 556 + margin-bottom: var(--space-1); 557 + } 558 + 523 559 .config-item label { 524 560 font-size: var(--text-sm); 525 561 font-weight: var(--font-medium); ··· 533 569 534 570 .config-input input { 535 571 flex: 1; 572 + min-width: 0; 536 573 } 537 574 538 - .config-input input.readonly { 575 + .config-item input.readonly { 539 576 background: var(--bg-input-disabled); 540 577 color: var(--text-secondary); 541 578 } ··· 624 661 background: var(--bg-secondary); 625 662 } 626 663 627 - .history-section { 628 - background: var(--bg-secondary); 629 - padding: var(--space-6); 630 - border-radius: var(--radius-xl); 631 - margin-top: var(--space-6); 632 - } 633 - 634 664 .history-section h2 { 635 665 margin: 0 0 var(--space-2) 0; 636 666 font-size: var(--text-lg); 637 667 } 638 668 639 - .load-history { 640 - padding: var(--space-2) var(--space-4); 641 - background: transparent; 669 + .skeleton-list { 670 + display: flex; 671 + flex-direction: column; 672 + gap: var(--space-3); 673 + } 674 + 675 + .skeleton-item { 676 + background: var(--bg-card); 642 677 border: 1px solid var(--border-color); 643 678 border-radius: var(--radius-md); 644 - cursor: pointer; 645 - color: var(--text-primary); 646 - margin-top: var(--space-2); 679 + padding: var(--space-3); 680 + } 681 + 682 + .skeleton-header { 683 + display: flex; 684 + gap: var(--space-2); 685 + margin-bottom: var(--space-2); 686 + } 687 + 688 + .skeleton-line { 689 + height: 14px; 690 + background: var(--bg-tertiary); 691 + border-radius: var(--radius-sm); 692 + animation: skeleton-pulse 1.5s ease-in-out infinite; 693 + } 694 + 695 + .skeleton-line.short { 696 + width: 80px; 697 + } 698 + 699 + .skeleton-line.tiny { 700 + width: 50px; 701 + } 702 + 703 + .skeleton-line.medium { 704 + width: 60%; 647 705 } 648 706 649 - .load-history:hover:not(:disabled) { 650 - background: var(--bg-card); 651 - border-color: var(--accent); 707 + .skeleton-line:not(.short):not(.tiny):not(.medium) { 708 + width: 100%; 709 + margin-bottom: var(--space-1); 652 710 } 653 711 654 - .load-history:disabled { 655 - opacity: 0.6; 656 - cursor: not-allowed; 712 + @keyframes skeleton-pulse { 713 + 0%, 100% { opacity: 1; } 714 + 50% { opacity: 0.4; } 657 715 } 658 716 659 717 .no-messages {
+19 -5
frontend/src/routes/Dashboard.svelte
··· 2 2 import { getAuthState, logout, switchAccount } from '../lib/auth.svelte' 3 3 import { navigate } from '../lib/router.svelte' 4 4 import { _ } from '../lib/i18n' 5 + import { api } from '../lib/api' 6 + import { onMount } from 'svelte' 5 7 6 8 const auth = getAuthState() 7 9 let dropdownOpen = $state(false) 8 10 let switching = $state(false) 11 + let inviteCodesEnabled = $state(false) 12 + 13 + onMount(async () => { 14 + try { 15 + const serverInfo = await api.describeServer() 16 + inviteCodesEnabled = serverInfo.inviteCodeRequired 17 + } catch { 18 + inviteCodesEnabled = false 19 + } 20 + }) 9 21 10 22 $effect(() => { 11 23 if (!auth.loading && !auth.session) { ··· 152 164 <h3>{$_('dashboard.navSessions')}</h3> 153 165 <p>{$_('dashboard.navSessionsDesc')}</p> 154 166 </a> 155 - <a href="#/invite-codes" class="nav-card"> 156 - <h3>{$_('dashboard.navInviteCodes')}</h3> 157 - <p>{$_('dashboard.navInviteCodesDesc')}</p> 158 - </a> 167 + {#if inviteCodesEnabled} 168 + <a href="#/invite-codes" class="nav-card"> 169 + <h3>{$_('dashboard.navInviteCodes')}</h3> 170 + <p>{$_('dashboard.navInviteCodesDesc')}</p> 171 + </a> 172 + {/if} 159 173 <a href="#/settings" class="nav-card"> 160 174 <h3>{$_('dashboard.navSettings')}</h3> 161 175 <p>{$_('dashboard.navSettingsDesc')}</p> ··· 186 200 187 201 <style> 188 202 .dashboard { 189 - max-width: var(--width-lg); 203 + max-width: var(--width-xl); 190 204 margin: 0 auto; 191 205 padding: var(--space-7); 192 206 }
+57 -3
frontend/src/routes/Home.svelte
··· 13 13 let pdsVersion = $state<string | null>(null) 14 14 let userCount = $state<number | null>(null) 15 15 16 + const heroWords = ['Bluesky', 'Tangled', 'Leaflet', 'ATProto'] 17 + const wordSpacing: Record<string, string> = { 18 + 'Bluesky': '0.01em', 19 + 'Tangled': '0.02em', 20 + 'Leaflet': '0.05em', 21 + 'ATProto': '0', 22 + } 23 + let currentWordIndex = $state(0) 24 + let isTransitioning = $state(false) 25 + let currentWord = $derived(heroWords[currentWordIndex]) 26 + let currentSpacing = $derived(wordSpacing[currentWord] || '0') 27 + 16 28 onMount(() => { 17 29 api.describeServer().then(info => { 18 30 if (info.availableUserDomains?.length) { ··· 23 35 } 24 36 }).catch(() => {}) 25 37 38 + const baseDuration = 2000 39 + let wordTimeout: ReturnType<typeof setTimeout> 40 + 41 + function cycleWord() { 42 + isTransitioning = true 43 + setTimeout(() => { 44 + currentWordIndex = (currentWordIndex + 1) % heroWords.length 45 + isTransitioning = false 46 + const duration = heroWords[currentWordIndex] === 'ATProto' ? baseDuration * 2 : baseDuration 47 + wordTimeout = setTimeout(cycleWord, duration) 48 + }, 100) 49 + } 50 + 51 + wordTimeout = setTimeout(cycleWord, baseDuration) 52 + 26 53 api.listRepos(1000).then(data => { 27 54 userCount = data.repos.length 28 55 }).catch(() => {}) ··· 75 102 return () => { 76 103 document.removeEventListener('mousemove', handleMouseMove) 77 104 cancelAnimationFrame(animationId) 105 + clearTimeout(wordTimeout) 78 106 } 79 107 }) 80 108 </script> ··· 103 131 104 132 <div class="home"> 105 133 <section class="hero"> 106 - <h1>A home for your ATProto account</h1> 134 + <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> 107 135 108 136 <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> 109 137 ··· 268 296 269 297 .user-count { 270 298 font-size: var(--text-sm); 271 - color: rgba(255, 255, 255, 0.85); 299 + color: var(--text-inverse); 300 + opacity: 0.85; 272 301 padding: 4px 10px; 273 302 background: rgba(255, 255, 255, 0.15); 274 303 border-radius: var(--radius-md); 304 + white-space: nowrap; 305 + } 306 + 307 + @media (prefers-color-scheme: dark) { 308 + .user-count { 309 + background: rgba(0, 0, 0, 0.15); 310 + } 275 311 } 276 312 277 313 .nav-meta { 278 314 font-size: var(--text-sm); 279 - color: rgba(255, 255, 255, 0.7); 315 + color: var(--text-inverse); 316 + opacity: 0.6; 280 317 letter-spacing: 0.05em; 281 318 } 282 319 ··· 300 337 line-height: var(--leading-tight); 301 338 margin-bottom: var(--space-6); 302 339 letter-spacing: -0.02em; 340 + } 341 + 342 + .cycling-word-container { 343 + display: inline-block; 344 + width: 3.9em; 345 + text-align: left; 346 + } 347 + 348 + .cycling-word { 349 + display: inline-block; 350 + transition: opacity 0.1s ease, transform 0.1s ease; 351 + } 352 + 353 + .cycling-word.transitioning { 354 + opacity: 0; 355 + transform: scale(0.95); 303 356 } 304 357 305 358 .lede { ··· 439 492 text-align: center; 440 493 } 441 494 495 + .user-count, 442 496 .nav-meta { 443 497 display: none; 444 498 }
+18 -2
frontend/src/routes/InviteCodes.svelte
··· 4 4 import { api, type InviteCode, ApiError } from '../lib/api' 5 5 import { _ } from '../lib/i18n' 6 6 import { formatDate } from '../lib/date' 7 + import { onMount } from 'svelte' 8 + 7 9 const auth = getAuthState() 8 10 let codes = $state<InviteCode[]>([]) 9 11 let loading = $state(true) 10 12 let error = $state<string | null>(null) 11 13 let creating = $state(false) 12 14 let createdCode = $state<string | null>(null) 15 + let inviteCodesEnabled = $state<boolean | null>(null) 16 + 17 + onMount(async () => { 18 + try { 19 + const serverInfo = await api.describeServer() 20 + inviteCodesEnabled = serverInfo.inviteCodeRequired 21 + if (!serverInfo.inviteCodeRequired) { 22 + navigate('/dashboard') 23 + } 24 + } catch { 25 + navigate('/dashboard') 26 + } 27 + }) 28 + 13 29 $effect(() => { 14 30 if (!auth.loading && !auth.session) { 15 31 navigate('/login') 16 32 } 17 33 }) 18 34 $effect(() => { 19 - if (auth.session) { 35 + if (auth.session && inviteCodesEnabled) { 20 36 loadCodes() 21 37 } 22 38 }) ··· 114 130 </div> 115 131 <style> 116 132 .page { 117 - max-width: var(--width-md); 133 + max-width: var(--width-lg); 118 134 margin: 0 auto; 119 135 padding: var(--space-7); 120 136 }
+105 -68
frontend/src/routes/Login.svelte
··· 8 8 let verificationCode = $state('') 9 9 let resendingCode = $state(false) 10 10 let resendMessage = $state<string | null>(null) 11 - let showNewLogin = $state(false) 11 + let autoRedirectAttempted = $state(false) 12 12 const auth = getAuthState() 13 + 14 + $effect(() => { 15 + if (!auth.loading && !auth.error && auth.savedAccounts.length === 0 && !pendingVerification && !autoRedirectAttempted) { 16 + autoRedirectAttempted = true 17 + loginWithOAuth() 18 + } 19 + }) 13 20 14 21 async function handleSwitchAccount(did: string) { 15 22 submitting = true ··· 74 81 {/if} 75 82 76 83 {#if pendingVerification} 77 - <h1>{$_('verification.title')}</h1> 78 - <p class="subtitle">{$_('verification.subtitle')}</p> 84 + <header class="page-header"> 85 + <h1>{$_('verification.title')}</h1> 86 + <p class="subtitle">{$_('verification.subtitle')}</p> 87 + </header> 79 88 80 89 {#if resendMessage} 81 90 <div class="message success">{resendMessage}</div> ··· 109 118 </div> 110 119 </form> 111 120 112 - {:else if auth.savedAccounts.length > 0 && !showNewLogin} 113 - <h1>{$_('login.title')}</h1> 114 - <p class="subtitle">{$_('login.chooseAccount')}</p> 121 + {:else} 122 + <header class="page-header"> 123 + <h1>{$_('login.title')}</h1> 124 + <p class="subtitle">{auth.savedAccounts.length > 0 ? $_('login.chooseAccount') : $_('login.subtitle')}</p> 125 + </header> 115 126 116 - <div class="saved-accounts"> 117 - {#each auth.savedAccounts as account} 118 - <div 119 - class="account-item" 120 - class:disabled={submitting} 121 - role="button" 122 - tabindex="0" 123 - onclick={() => !submitting && handleSwitchAccount(account.did)} 124 - onkeydown={(e) => e.key === 'Enter' && !submitting && handleSwitchAccount(account.did)} 125 - > 126 - <div class="account-info"> 127 - <span class="account-handle">@{account.handle}</span> 128 - <span class="account-did">{account.did}</span> 127 + <div class="split-layout sidebar-right"> 128 + <div class="main-section"> 129 + {#if auth.savedAccounts.length > 0} 130 + <div class="saved-accounts"> 131 + {#each auth.savedAccounts as account} 132 + <div 133 + class="account-item" 134 + class:disabled={submitting} 135 + role="button" 136 + tabindex="0" 137 + onclick={() => !submitting && handleSwitchAccount(account.did)} 138 + onkeydown={(e) => e.key === 'Enter' && !submitting && handleSwitchAccount(account.did)} 139 + > 140 + <div class="account-info"> 141 + <span class="account-handle">@{account.handle}</span> 142 + <span class="account-did">{account.did}</span> 143 + </div> 144 + <button 145 + type="button" 146 + class="forget-btn" 147 + onclick={(e) => handleForgetAccount(account.did, e)} 148 + title={$_('login.removeAccount')} 149 + > 150 + &times; 151 + </button> 152 + </div> 153 + {/each} 129 154 </div> 130 - <button 131 - type="button" 132 - class="forget-btn" 133 - onclick={(e) => handleForgetAccount(account.did, e)} 134 - title={$_('login.removeAccount')} 135 - > 136 - &times; 137 - </button> 138 - </div> 139 - {/each} 140 - </div> 155 + 156 + <p class="or-divider">{$_('login.signInToAnother')}</p> 157 + {/if} 141 158 142 - <button type="button" class="secondary full-width" onclick={() => showNewLogin = true}> 143 - {$_('login.signInToAnother')} 144 - </button> 159 + <button type="button" class="oauth-btn" onclick={handleOAuthLogin} disabled={submitting || auth.loading}> 160 + {submitting ? $_('login.redirecting') : $_('login.button')} 161 + </button> 145 162 146 - <p class="link-text"> 147 - {$_('login.noAccount')} <a href="#/register">{$_('login.createAccount')}</a> 148 - </p> 163 + <p class="forgot-links"> 164 + <a href="#/reset-password">{$_('login.forgotPassword')}</a> 165 + <span class="separator">&middot;</span> 166 + <a href="#/request-passkey-recovery">{$_('login.lostPasskey')}</a> 167 + </p> 149 168 150 - {:else} 151 - <h1>{$_('login.title')}</h1> 152 - <p class="subtitle">{$_('login.subtitle')}</p> 169 + <p class="link-text"> 170 + {$_('login.noAccount')} <a href="#/register">{$_('login.createAccount')}</a> 171 + </p> 172 + </div> 153 173 154 - {#if auth.savedAccounts.length > 0} 155 - <button type="button" class="tertiary back-btn" onclick={() => showNewLogin = false}> 156 - {$_('login.backToSaved')} 157 - </button> 158 - {/if} 174 + <aside class="info-panel"> 175 + {#if auth.savedAccounts.length > 0} 176 + <h3>{$_('login.infoSavedAccountsTitle')}</h3> 177 + <p>{$_('login.infoSavedAccountsDesc')}</p> 159 178 160 - <button type="button" class="oauth-btn" onclick={handleOAuthLogin} disabled={submitting || auth.loading}> 161 - {submitting ? $_('login.redirecting') : $_('login.button')} 162 - </button> 179 + <h3>{$_('login.infoNewAccountTitle')}</h3> 180 + <p>{$_('login.infoNewAccountDesc')}</p> 181 + {:else} 182 + <h3>{$_('login.infoSecureSignInTitle')}</h3> 183 + <p>{$_('login.infoSecureSignInDesc')}</p> 163 184 164 - <p class="forgot-links"> 165 - <a href="#/reset-password">{$_('login.forgotPassword')}</a> 166 - <span class="separator">&middot;</span> 167 - <a href="#/request-passkey-recovery">{$_('login.lostPasskey')}</a> 168 - </p> 185 + <h3>{$_('login.infoStaySignedInTitle')}</h3> 186 + <p>{$_('login.infoStaySignedInDesc')}</p> 187 + {/if} 169 188 170 - <p class="link-text"> 171 - {$_('login.noAccount')} <a href="#/register">{$_('login.createAccount')}</a> 172 - </p> 189 + <h3>{$_('login.infoRecoveryTitle')}</h3> 190 + <p>{$_('login.infoRecoveryDesc')}</p> 191 + </aside> 192 + </div> 173 193 {/if} 174 194 </div> 175 195 176 196 <style> 177 197 .login-page { 178 - max-width: var(--width-sm); 198 + max-width: var(--width-lg); 179 199 margin: var(--space-9) auto; 180 200 padding: var(--space-7); 201 + } 202 + 203 + .page-header { 204 + margin-bottom: var(--space-6); 181 205 } 182 206 183 207 h1 { ··· 186 210 187 211 .subtitle { 188 212 color: var(--text-secondary); 189 - margin: 0 0 var(--space-7) 0; 213 + margin: 0; 214 + } 215 + 216 + .main-section { 217 + min-width: 0; 190 218 } 191 219 192 220 form { 193 221 display: flex; 194 222 flex-direction: column; 195 223 gap: var(--space-4); 224 + max-width: var(--width-sm); 196 225 } 197 226 198 227 .actions { ··· 202 231 margin-top: var(--space-3); 203 232 } 204 233 234 + @media (min-width: 600px) { 235 + .actions { 236 + flex-direction: row; 237 + } 238 + 239 + .actions button { 240 + flex: 1; 241 + } 242 + } 243 + 205 244 .oauth-btn { 206 245 width: 100%; 207 246 padding: var(--space-5); ··· 209 248 } 210 249 211 250 .forgot-links { 212 - text-align: center; 213 - margin-top: var(--space-5); 251 + margin-top: var(--space-4); 252 + font-size: var(--text-sm); 214 253 color: var(--text-secondary); 215 254 } 216 255 ··· 223 262 } 224 263 225 264 .link-text { 226 - text-align: center; 227 - margin-top: var(--space-4); 265 + margin-top: var(--space-6); 266 + font-size: var(--text-sm); 228 267 color: var(--text-secondary); 229 268 } 230 269 ··· 297 336 color: var(--error-text); 298 337 } 299 338 300 - .full-width { 301 - width: 100%; 302 - } 303 - 304 - .back-btn { 305 - margin-bottom: var(--space-5); 306 - padding: 0; 339 + .or-divider { 340 + text-align: center; 341 + color: var(--text-muted); 342 + font-size: var(--text-sm); 343 + margin: var(--space-5) 0; 307 344 } 308 345 </style>
+80 -46
frontend/src/routes/OAuthConsent.svelte
··· 167 167 </button> 168 168 </div> 169 169 {:else if consentData} 170 - <div class="client-info"> 171 - {#if consentData.logo_uri} 172 - <img src={consentData.logo_uri} alt="" class="client-logo" /> 173 - {/if} 174 - <h1>{consentData.client_name || $_('oauth.consent.title')}</h1> 175 - <p class="subtitle">{$_('oauth.consent.appWantsAccess', { values: { app: '' } })}</p> 176 - {#if consentData.client_uri} 177 - <a href={consentData.client_uri} target="_blank" rel="noopener noreferrer" class="client-link"> 178 - {consentData.client_uri} 179 - </a> 180 - {/if} 181 - </div> 170 + <div class="split-layout sidebar-left"> 171 + <div class="client-panel"> 172 + <div class="client-info"> 173 + {#if consentData.logo_uri} 174 + <img src={consentData.logo_uri} alt="" class="client-logo" /> 175 + {/if} 176 + <h1>{consentData.client_name || $_('oauth.consent.title')}</h1> 177 + <p class="subtitle">{$_('oauth.consent.appWantsAccess', { values: { app: '' } })}</p> 178 + {#if consentData.client_uri} 179 + <a href={consentData.client_uri} target="_blank" rel="noopener noreferrer" class="client-link"> 180 + {consentData.client_uri} 181 + </a> 182 + {/if} 183 + </div> 182 184 183 - <div class="account-info"> 184 - <span class="label">{$_('oauth.consent.signingInAs')}</span> 185 - <span class="did">{consentData.did}</span> 186 - </div> 185 + <div class="account-info"> 186 + <span class="label">{$_('oauth.consent.signingInAs')}</span> 187 + <span class="did">{consentData.did}</span> 188 + </div> 189 + </div> 187 190 188 - <div class="scopes-section"> 189 - <h2>{$_('oauth.consent.permissionsRequested')}</h2> 190 - {#each Object.entries(scopeGroups) as [category, scopes]} 191 - <div class="scope-group"> 192 - <h3 class="category-title">{category}</h3> 193 - {#each scopes as scope} 194 - <label class="scope-item" class:required={scope.required}> 195 - <input 196 - type="checkbox" 197 - checked={scopeSelections[scope.scope]} 198 - disabled={scope.required || submitting} 199 - onchange={() => handleScopeToggle(scope.scope)} 200 - /> 201 - <div class="scope-info"> 202 - <span class="scope-name">{scope.display_name}</span> 203 - <span class="scope-description">{scope.description}</span> 204 - {#if scope.required} 205 - <span class="required-badge">{$_('oauth.consent.required')}</span> 206 - {/if} 207 - </div> 208 - </label> 191 + <div class="permissions-panel"> 192 + <div class="scopes-section"> 193 + <h2>{$_('oauth.consent.permissionsRequested')}</h2> 194 + {#each Object.entries(scopeGroups) as [category, scopes]} 195 + <div class="scope-group"> 196 + <h3 class="category-title">{category}</h3> 197 + {#each scopes as scope} 198 + <label class="scope-item" class:required={scope.required}> 199 + <input 200 + type="checkbox" 201 + checked={scopeSelections[scope.scope]} 202 + disabled={scope.required || submitting} 203 + onchange={() => handleScopeToggle(scope.scope)} 204 + /> 205 + <div class="scope-info"> 206 + <span class="scope-name">{scope.display_name}</span> 207 + <span class="scope-description">{scope.description}</span> 208 + {#if scope.required} 209 + <span class="required-badge">{$_('oauth.consent.required')}</span> 210 + {/if} 211 + </div> 212 + </label> 213 + {/each} 214 + </div> 209 215 {/each} 210 216 </div> 211 - {/each} 212 - </div> 213 217 214 - <label class="remember-choice"> 215 - <input type="checkbox" bind:checked={rememberChoice} disabled={submitting} /> 216 - <span>{$_('oauth.consent.rememberChoiceLabel')}</span> 217 - </label> 218 + <label class="remember-choice"> 219 + <input type="checkbox" bind:checked={rememberChoice} disabled={submitting} /> 220 + <span>{$_('oauth.consent.rememberChoiceLabel')}</span> 221 + </label> 222 + </div> 223 + </div> 218 224 219 225 <div class="actions"> 220 226 <button type="button" class="deny-btn" onclick={handleDeny} disabled={submitting}> ··· 229 235 230 236 <style> 231 237 .consent-container { 232 - max-width: 480px; 238 + max-width: var(--width-lg); 233 239 margin: var(--space-7) auto; 234 240 padding: var(--space-7); 235 241 } ··· 244 250 245 251 .error-container { 246 252 text-align: center; 253 + max-width: var(--width-sm); 254 + margin: 0 auto; 247 255 } 248 256 249 257 .error { ··· 255 263 margin-bottom: var(--space-4); 256 264 } 257 265 266 + .client-panel { 267 + display: flex; 268 + flex-direction: column; 269 + gap: var(--space-5); 270 + } 271 + 272 + .permissions-panel { 273 + min-width: 0; 274 + } 275 + 258 276 .client-info { 259 277 text-align: center; 260 - margin-bottom: var(--space-6); 278 + padding: var(--space-6); 279 + background: var(--bg-secondary); 280 + border-radius: var(--radius-xl); 281 + } 282 + 283 + @media (min-width: 800px) { 284 + .client-info { 285 + text-align: left; 286 + } 261 287 } 262 288 263 289 .client-logo { ··· 397 423 display: flex; 398 424 align-items: center; 399 425 gap: var(--space-2); 400 - margin-bottom: var(--space-6); 426 + margin-top: var(--space-5); 401 427 cursor: pointer; 402 428 color: var(--text-secondary); 403 429 font-size: var(--text-sm); ··· 411 437 .actions { 412 438 display: flex; 413 439 gap: var(--space-4); 440 + margin-top: var(--space-6); 441 + } 442 + 443 + @media (min-width: 800px) { 444 + .actions { 445 + max-width: 400px; 446 + margin-left: auto; 447 + } 414 448 } 415 449 416 450 .actions button {
+187 -80
frontend/src/routes/OAuthLogin.svelte
··· 315 315 </script> 316 316 317 317 <div class="oauth-login-container"> 318 - <h1>{$_('oauth.login.title')}</h1> 319 - <p class="subtitle"> 320 - {#if clientName} 321 - {$_('oauth.login.subtitle')} <strong>{clientName}</strong> 322 - {:else} 323 - {$_('oauth.login.subtitle')} 324 - {/if} 325 - </p> 318 + <header class="page-header"> 319 + <h1>{$_('oauth.login.title')}</h1> 320 + <p class="subtitle"> 321 + {#if clientName} 322 + {$_('oauth.login.subtitle')} <strong>{clientName}</strong> 323 + {:else} 324 + {$_('oauth.login.subtitle')} 325 + {/if} 326 + </p> 327 + </header> 326 328 327 329 {#if error} 328 330 <div class="error">{error}</div> ··· 343 345 </div> 344 346 345 347 {#if passkeySupported && username.length >= 3} 346 - <button 347 - type="button" 348 - class="passkey-btn" 349 - class:passkey-unavailable={!hasPasskeys || checkingSecurityStatus || !securityStatusChecked} 350 - onclick={handlePasskeyLogin} 351 - disabled={submitting || !hasPasskeys || !username || checkingSecurityStatus || !securityStatusChecked} 352 - title={checkingSecurityStatus ? $_('oauth.login.passkeyHintChecking') : hasPasskeys ? $_('oauth.login.passkeyHintAvailable') : $_('oauth.login.passkeyHintNotAvailable')} 353 - > 354 - <svg class="passkey-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 355 - <path d="M15 7a4 4 0 1 0-8 0 4 4 0 0 0 8 0z" /> 356 - <path d="M17 17v4l3-2-3-2z" /> 357 - <path d="M12 11c-4 0-6 2-6 4v4h9" /> 358 - </svg> 359 - <span class="passkey-text"> 360 - {#if submitting} 361 - {$_('oauth.login.authenticating')} 362 - {:else if checkingSecurityStatus || !securityStatusChecked} 363 - {$_('oauth.login.checkingPasskey')} 364 - {:else if hasPasskeys} 365 - {$_('oauth.login.signInWithPasskey')} 366 - {:else} 367 - {$_('oauth.login.passkeyNotSetUp')} 368 - {/if} 369 - </span> 370 - </button> 348 + <div class="auth-methods"> 349 + <div class="passkey-method"> 350 + <h3>{$_('oauth.login.signInWithPasskey')}</h3> 351 + <button 352 + type="button" 353 + class="passkey-btn" 354 + class:passkey-unavailable={!hasPasskeys || checkingSecurityStatus || !securityStatusChecked} 355 + onclick={handlePasskeyLogin} 356 + disabled={submitting || !hasPasskeys || !username || checkingSecurityStatus || !securityStatusChecked} 357 + title={checkingSecurityStatus ? $_('oauth.login.passkeyHintChecking') : hasPasskeys ? $_('oauth.login.passkeyHintAvailable') : $_('oauth.login.passkeyHintNotAvailable')} 358 + > 359 + <svg class="passkey-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 360 + <path d="M15 7a4 4 0 1 0-8 0 4 4 0 0 0 8 0z" /> 361 + <path d="M17 17v4l3-2-3-2z" /> 362 + <path d="M12 11c-4 0-6 2-6 4v4h9" /> 363 + </svg> 364 + <span class="passkey-text"> 365 + {#if submitting} 366 + {$_('oauth.login.authenticating')} 367 + {:else if checkingSecurityStatus || !securityStatusChecked} 368 + {$_('oauth.login.checkingPasskey')} 369 + {:else if hasPasskeys} 370 + {$_('oauth.login.usePasskey')} 371 + {:else} 372 + {$_('oauth.login.passkeyNotSetUp')} 373 + {/if} 374 + </span> 375 + </button> 376 + <p class="method-hint">{$_('oauth.login.passkeyHint')}</p> 377 + </div> 371 378 372 - <div class="auth-divider"> 373 - <span>{$_('oauth.login.orUsePassword')}</span> 379 + <div class="method-divider"> 380 + <span>{$_('oauth.login.orUsePassword')}</span> 381 + </div> 382 + 383 + <div class="password-method"> 384 + <h3>{$_('oauth.login.password')}</h3> 385 + <div class="field"> 386 + <input 387 + id="password" 388 + type="password" 389 + bind:value={password} 390 + disabled={submitting} 391 + required 392 + autocomplete="current-password" 393 + placeholder={$_('oauth.login.passwordPlaceholder')} 394 + /> 395 + </div> 396 + 397 + <label class="remember-device"> 398 + <input type="checkbox" bind:checked={rememberDevice} disabled={submitting} /> 399 + <span>{$_('oauth.login.rememberDevice')}</span> 400 + </label> 401 + 402 + <button type="submit" class="submit-btn" disabled={submitting || !username || !password}> 403 + {submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')} 404 + </button> 405 + </div> 374 406 </div> 375 - {/if} 376 407 377 - <div class="field"> 378 - <label for="password">{$_('oauth.login.password')}</label> 379 - <input 380 - id="password" 381 - type="password" 382 - bind:value={password} 383 - disabled={submitting} 384 - required 385 - autocomplete="current-password" 386 - /> 387 - </div> 408 + <div class="actions"> 409 + <button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}> 410 + {$_('common.cancel')} 411 + </button> 412 + </div> 413 + {:else} 414 + <div class="field"> 415 + <label for="password">{$_('oauth.login.password')}</label> 416 + <input 417 + id="password" 418 + type="password" 419 + bind:value={password} 420 + disabled={submitting} 421 + required 422 + autocomplete="current-password" 423 + /> 424 + </div> 388 425 389 - <label class="remember-device"> 390 - <input type="checkbox" bind:checked={rememberDevice} disabled={submitting} /> 391 - <span>{$_('oauth.login.rememberDevice')}</span> 392 - </label> 426 + <label class="remember-device"> 427 + <input type="checkbox" bind:checked={rememberDevice} disabled={submitting} /> 428 + <span>{$_('oauth.login.rememberDevice')}</span> 429 + </label> 393 430 394 - <div class="actions"> 395 - <button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}> 396 - {$_('common.cancel')} 397 - </button> 398 - <button type="submit" class="submit-btn" disabled={submitting || !username || !password}> 399 - {submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')} 400 - </button> 401 - </div> 431 + <div class="actions"> 432 + <button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}> 433 + {$_('common.cancel')} 434 + </button> 435 + <button type="submit" class="submit-btn" disabled={submitting || !username || !password}> 436 + {submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')} 437 + </button> 438 + </div> 439 + {/if} 402 440 </form> 403 441 404 442 <p class="help-links"> ··· 423 461 } 424 462 425 463 .oauth-login-container { 426 - max-width: var(--width-sm); 464 + max-width: var(--width-md); 427 465 margin: var(--space-9) auto; 428 466 padding: var(--space-7); 429 467 } 430 468 469 + .page-header { 470 + margin-bottom: var(--space-6); 471 + } 472 + 431 473 h1 { 432 474 margin: 0 0 var(--space-2) 0; 433 475 } 434 476 435 477 .subtitle { 436 478 color: var(--text-secondary); 437 - margin: 0 0 var(--space-7) 0; 479 + margin: 0; 438 480 } 439 481 440 482 form { ··· 443 485 gap: var(--space-4); 444 486 } 445 487 488 + .auth-methods { 489 + display: grid; 490 + grid-template-columns: 1fr; 491 + gap: var(--space-5); 492 + margin-top: var(--space-4); 493 + } 494 + 495 + @media (min-width: 600px) { 496 + .auth-methods { 497 + grid-template-columns: 1fr auto 1fr; 498 + align-items: start; 499 + } 500 + } 501 + 502 + .passkey-method, 503 + .password-method { 504 + display: flex; 505 + flex-direction: column; 506 + gap: var(--space-4); 507 + padding: var(--space-5); 508 + background: var(--bg-secondary); 509 + border-radius: var(--radius-xl); 510 + } 511 + 512 + .passkey-method h3, 513 + .password-method h3 { 514 + margin: 0; 515 + font-size: var(--text-sm); 516 + font-weight: var(--font-semibold); 517 + color: var(--text-secondary); 518 + text-transform: uppercase; 519 + letter-spacing: 0.05em; 520 + } 521 + 522 + .method-hint { 523 + margin: 0; 524 + font-size: var(--text-xs); 525 + color: var(--text-muted); 526 + } 527 + 528 + .method-divider { 529 + display: flex; 530 + align-items: center; 531 + justify-content: center; 532 + color: var(--text-muted); 533 + font-size: var(--text-sm); 534 + } 535 + 536 + @media (min-width: 600px) { 537 + .method-divider { 538 + flex-direction: column; 539 + padding: 0 var(--space-3); 540 + } 541 + 542 + .method-divider::before, 543 + .method-divider::after { 544 + content: ''; 545 + width: 1px; 546 + height: var(--space-6); 547 + background: var(--border-color); 548 + } 549 + 550 + .method-divider span { 551 + writing-mode: vertical-rl; 552 + text-orientation: mixed; 553 + transform: rotate(180deg); 554 + padding: var(--space-2) 0; 555 + } 556 + } 557 + 558 + @media (max-width: 599px) { 559 + .method-divider { 560 + gap: var(--space-4); 561 + } 562 + 563 + .method-divider::before, 564 + .method-divider::after { 565 + content: ''; 566 + flex: 1; 567 + height: 1px; 568 + background: var(--border-color); 569 + } 570 + } 571 + 446 572 .field { 447 573 display: flex; 448 574 flex-direction: column; ··· 534 660 background: var(--accent-hover); 535 661 } 536 662 537 - .auth-divider { 538 - display: flex; 539 - align-items: center; 540 - gap: var(--space-4); 541 - margin: var(--space-2) 0; 542 - } 543 - 544 - .auth-divider::before, 545 - .auth-divider::after { 546 - content: ''; 547 - flex: 1; 548 - height: 1px; 549 - background: var(--border-color); 550 - } 551 - 552 - .auth-divider span { 553 - color: var(--text-secondary); 554 - font-size: var(--text-sm); 555 - } 556 663 557 664 .passkey-btn { 558 665 display: flex;
+233 -215
frontend/src/routes/Register.svelte
··· 142 142 if (!flow) return '' 143 143 switch (flow.state.step) { 144 144 case 'info': return $_('register.subtitle') 145 - case 'key-choice': return 'Choose how to set up your external did:web identity.' 146 - case 'initial-did-doc': return 'Upload your DID document to continue.' 145 + case 'key-choice': return $_('register.subtitleKeyChoice') 146 + case 'initial-did-doc': return $_('register.subtitleInitialDidDoc') 147 147 case 'creating': return $_('register.creating') 148 - case 'verify': return `Verify your ${channelLabel(flow.info.verificationChannel)} to continue.` 149 - case 'updated-did-doc': return 'Update your DID document with the PDS signing key.' 150 - case 'activating': return 'Activating your account...' 151 - case 'redirect-to-dashboard': return 'Your account has been created successfully!' 148 + case 'verify': return $_('register.subtitleVerify', { values: { channel: channelLabel(flow.info.verificationChannel) } }) 149 + case 'updated-did-doc': return $_('register.subtitleUpdatedDidDoc') 150 + case 'activating': return $_('register.subtitleActivating') 151 + case 'redirect-to-dashboard': return $_('register.subtitleComplete') 152 152 default: return '' 153 153 } 154 154 } 155 155 </script> 156 156 157 157 <div class="register-page"> 158 - {#if flow?.state.step === 'info'} 158 + <header class="page-header"> 159 + <h1>{$_('register.title')}</h1> 160 + <p class="subtitle">{getSubtitle()}</p> 161 + </header> 162 + 163 + {#if flow?.state.error} 164 + <div class="message error">{flow.state.error}</div> 165 + {/if} 166 + 167 + {#if loadingServerInfo || !flow} 168 + <p class="loading">{$_('common.loading')}</p> 169 + 170 + {:else if flow.state.step === 'info'} 159 171 <div class="migrate-callout"> 160 172 <div class="migrate-icon">↗</div> 161 173 <div class="migrate-content"> ··· 166 178 </a> 167 179 </div> 168 180 </div> 169 - {/if} 170 181 171 - <h1>{$_('register.title')}</h1> 172 - <p class="subtitle">{getSubtitle()}</p> 173 - 174 - {#if flow?.state.error} 175 - <div class="message error">{flow.state.error}</div> 176 - {/if} 182 + <div class="split-layout sidebar-right"> 183 + <div class="form-section"> 184 + <form onsubmit={handleInfoSubmit}> 185 + <div class="field"> 186 + <label for="handle">{$_('register.handle')}</label> 187 + <input 188 + id="handle" 189 + type="text" 190 + bind:value={flow.info.handle} 191 + placeholder={$_('register.handlePlaceholder')} 192 + disabled={flow.state.submitting} 193 + required 194 + /> 195 + {#if flow.info.handle.includes('.')} 196 + <p class="hint warning">{$_('register.handleDotWarning')}</p> 197 + {:else if fullHandle()} 198 + <p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p> 199 + {/if} 200 + </div> 177 201 178 - {#if loadingServerInfo || !flow} 179 - <p class="loading">{$_('common.loading')}</p> 202 + <div class="form-row"> 203 + <div class="field"> 204 + <label for="password">{$_('register.password')}</label> 205 + <input 206 + id="password" 207 + type="password" 208 + bind:value={flow.info.password} 209 + placeholder={$_('register.passwordPlaceholder')} 210 + disabled={flow.state.submitting} 211 + required 212 + minlength="8" 213 + /> 214 + </div> 180 215 181 - {:else if flow.state.step === 'info'} 182 - <form onsubmit={handleInfoSubmit}> 183 - <div class="field"> 184 - <label for="handle">{$_('register.handle')}</label> 185 - <input 186 - id="handle" 187 - type="text" 188 - bind:value={flow.info.handle} 189 - placeholder={$_('register.handlePlaceholder')} 190 - disabled={flow.state.submitting} 191 - required 192 - /> 193 - {#if flow.info.handle.includes('.')} 194 - <p class="hint warning">{$_('register.handleDotWarning')}</p> 195 - {:else if fullHandle()} 196 - <p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p> 197 - {/if} 198 - </div> 216 + <div class="field"> 217 + <label for="confirm-password">{$_('register.confirmPassword')}</label> 218 + <input 219 + id="confirm-password" 220 + type="password" 221 + bind:value={confirmPassword} 222 + placeholder={$_('register.confirmPasswordPlaceholder')} 223 + disabled={flow.state.submitting} 224 + required 225 + /> 226 + </div> 227 + </div> 199 228 200 - <div class="field"> 201 - <label for="password">{$_('register.password')}</label> 202 - <input 203 - id="password" 204 - type="password" 205 - bind:value={flow.info.password} 206 - placeholder={$_('register.passwordPlaceholder')} 207 - disabled={flow.state.submitting} 208 - required 209 - minlength="8" 210 - /> 211 - </div> 229 + <fieldset class="section-fieldset"> 230 + <legend>{$_('register.identityType')}</legend> 231 + <div class="radio-group"> 232 + <label class="radio-label"> 233 + <input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} /> 234 + <span class="radio-content"> 235 + <strong>{$_('register.didPlc')}</strong> {$_('register.didPlcRecommended')} 236 + <span class="radio-hint">{$_('register.didPlcHint')}</span> 237 + </span> 238 + </label> 212 239 213 - <div class="field"> 214 - <label for="confirm-password">{$_('register.confirmPassword')}</label> 215 - <input 216 - id="confirm-password" 217 - type="password" 218 - bind:value={confirmPassword} 219 - placeholder={$_('register.confirmPasswordPlaceholder')} 220 - disabled={flow.state.submitting} 221 - required 222 - /> 223 - </div> 240 + <label class="radio-label"> 241 + <input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting} /> 242 + <span class="radio-content"> 243 + <strong>{$_('register.didWeb')}</strong> 244 + <span class="radio-hint">{$_('register.didWebHint')}</span> 245 + </span> 246 + </label> 224 247 225 - <fieldset class="section-fieldset"> 226 - <legend>{$_('register.identityType')}</legend> 227 - <p class="section-hint">{$_('register.identityHint')}</p> 248 + <label class="radio-label"> 249 + <input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} /> 250 + <span class="radio-content"> 251 + <strong>{$_('register.didWebBYOD')}</strong> 252 + <span class="radio-hint">{$_('register.didWebBYODHint')}</span> 253 + </span> 254 + </label> 255 + </div> 228 256 229 - <div class="radio-group"> 230 - <label class="radio-label"> 231 - <input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} /> 232 - <span class="radio-content"> 233 - <strong>{$_('register.didPlc')}</strong> {$_('register.didPlcRecommended')} 234 - <span class="radio-hint">{$_('register.didPlcHint')}</span> 235 - </span> 236 - </label> 257 + {#if flow.info.didType === 'web'} 258 + <div class="warning-box"> 259 + <strong>{$_('register.didWebWarningTitle')}</strong> 260 + <ul> 261 + <li><strong>{$_('register.didWebWarning1')}</strong> {$_('register.didWebWarning1Detail', { values: { did: `did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}` } })}</li> 262 + <li><strong>{$_('register.didWebWarning2')}</strong> {$_('register.didWebWarning2Detail')}</li> 263 + <li><strong>{$_('register.didWebWarning3')}</strong> {$_('register.didWebWarning3Detail')}</li> 264 + <li><strong>{$_('register.didWebWarning4')}</strong> {$_('register.didWebWarning4Detail')}</li> 265 + </ul> 266 + </div> 267 + {/if} 237 268 238 - <label class="radio-label"> 239 - <input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting} /> 240 - <span class="radio-content"> 241 - <strong>{$_('register.didWeb')}</strong> 242 - <span class="radio-hint">{$_('register.didWebHint')}</span> 243 - </span> 244 - </label> 269 + {#if flow.info.didType === 'web-external'} 270 + <div class="field"> 271 + <label for="external-did">{$_('register.externalDid')}</label> 272 + <input 273 + id="external-did" 274 + type="text" 275 + bind:value={flow.info.externalDid} 276 + placeholder={$_('register.externalDidPlaceholder')} 277 + disabled={flow.state.submitting} 278 + required 279 + /> 280 + <p class="hint">{$_('register.externalDidHint')}</p> 281 + </div> 282 + {/if} 283 + </fieldset> 245 284 246 - <label class="radio-label"> 247 - <input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} /> 248 - <span class="radio-content"> 249 - <strong>{$_('register.didWebBYOD')}</strong> 250 - <span class="radio-hint">{$_('register.didWebBYODHint')}</span> 251 - </span> 252 - </label> 253 - </div> 285 + <fieldset class="section-fieldset"> 286 + <legend>{$_('register.contactMethod')}</legend> 287 + <div class="contact-fields"> 288 + <div class="field"> 289 + <label for="verification-channel">{$_('register.verificationMethod')}</label> 290 + <select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}> 291 + <option value="email">{$_('register.email')}</option> 292 + <option value="discord" disabled={!isChannelAvailable('discord')}> 293 + {$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`} 294 + </option> 295 + <option value="telegram" disabled={!isChannelAvailable('telegram')}> 296 + {$_('register.telegram')}{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`} 297 + </option> 298 + <option value="signal" disabled={!isChannelAvailable('signal')}> 299 + {$_('register.signal')}{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`} 300 + </option> 301 + </select> 302 + </div> 254 303 255 - {#if flow.info.didType === 'web'} 256 - <div class="warning-box"> 257 - <strong>{$_('register.didWebWarningTitle')}</strong> 258 - <ul> 259 - <li><strong>{$_('register.didWebWarning1')}</strong> {$_('register.didWebWarning1Detail', { values: { did: `did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}` } })}</li> 260 - <li><strong>{$_('register.didWebWarning2')}</strong> {$_('register.didWebWarning2Detail')}</li> 261 - <li><strong>{$_('register.didWebWarning3')}</strong> {$_('register.didWebWarning3Detail')}</li> 262 - <li><strong>{$_('register.didWebWarning4')}</strong> {$_('register.didWebWarning4Detail')}</li> 263 - </ul> 264 - </div> 265 - {/if} 304 + {#if flow.info.verificationChannel === 'email'} 305 + <div class="field"> 306 + <label for="email">{$_('register.emailAddress')}</label> 307 + <input 308 + id="email" 309 + type="email" 310 + bind:value={flow.info.email} 311 + placeholder={$_('register.emailPlaceholder')} 312 + disabled={flow.state.submitting} 313 + required 314 + /> 315 + </div> 316 + {:else if flow.info.verificationChannel === 'discord'} 317 + <div class="field"> 318 + <label for="discord-id">{$_('register.discordId')}</label> 319 + <input 320 + id="discord-id" 321 + type="text" 322 + bind:value={flow.info.discordId} 323 + placeholder={$_('register.discordIdPlaceholder')} 324 + disabled={flow.state.submitting} 325 + required 326 + /> 327 + <p class="hint">{$_('register.discordIdHint')}</p> 328 + </div> 329 + {:else if flow.info.verificationChannel === 'telegram'} 330 + <div class="field"> 331 + <label for="telegram-username">{$_('register.telegramUsername')}</label> 332 + <input 333 + id="telegram-username" 334 + type="text" 335 + bind:value={flow.info.telegramUsername} 336 + placeholder={$_('register.telegramUsernamePlaceholder')} 337 + disabled={flow.state.submitting} 338 + required 339 + /> 340 + </div> 341 + {:else if flow.info.verificationChannel === 'signal'} 342 + <div class="field"> 343 + <label for="signal-number">{$_('register.signalNumber')}</label> 344 + <input 345 + id="signal-number" 346 + type="tel" 347 + bind:value={flow.info.signalNumber} 348 + placeholder={$_('register.signalNumberPlaceholder')} 349 + disabled={flow.state.submitting} 350 + required 351 + /> 352 + <p class="hint">{$_('register.signalNumberHint')}</p> 353 + </div> 354 + {/if} 355 + </div> 356 + </fieldset> 266 357 267 - {#if flow.info.didType === 'web-external'} 268 - <div class="field"> 269 - <label for="external-did">{$_('register.externalDid')}</label> 270 - <input 271 - id="external-did" 272 - type="text" 273 - bind:value={flow.info.externalDid} 274 - placeholder={$_('register.externalDidPlaceholder')} 275 - disabled={flow.state.submitting} 276 - required 277 - /> 278 - <p class="hint">{$_('register.externalDidHint')}</p> 279 - </div> 280 - {/if} 281 - </fieldset> 358 + {#if serverInfo?.inviteCodeRequired} 359 + <div class="field"> 360 + <label for="invite-code">{$_('register.inviteCode')} <span class="required">{$_('register.inviteCodeRequired')}</span></label> 361 + <input 362 + id="invite-code" 363 + type="text" 364 + bind:value={flow.info.inviteCode} 365 + placeholder={$_('register.inviteCodePlaceholder')} 366 + disabled={flow.state.submitting} 367 + required 368 + /> 369 + </div> 370 + {/if} 282 371 283 - <fieldset class="section-fieldset"> 284 - <legend>{$_('register.contactMethod')}</legend> 285 - <p class="section-hint">{$_('register.contactMethodHint')}</p> 372 + <button type="submit" disabled={flow.state.submitting}> 373 + {flow.state.submitting ? $_('register.creating') : $_('register.createButton')} 374 + </button> 375 + </form> 286 376 287 - <div class="field"> 288 - <label for="verification-channel">{$_('register.verificationMethod')}</label> 289 - <select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}> 290 - <option value="email">{$_('register.email')}</option> 291 - <option value="discord" disabled={!isChannelAvailable('discord')}> 292 - {$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`} 293 - </option> 294 - <option value="telegram" disabled={!isChannelAvailable('telegram')}> 295 - {$_('register.telegram')}{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`} 296 - </option> 297 - <option value="signal" disabled={!isChannelAvailable('signal')}> 298 - {$_('register.signal')}{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`} 299 - </option> 300 - </select> 377 + <div class="form-links"> 378 + <p class="link-text"> 379 + {$_('register.alreadyHaveAccount')} <a href="#/login">{$_('register.signIn')}</a> 380 + </p> 381 + <p class="link-text"> 382 + {$_('register.wantPasswordless')} <a href="#/register-passkey">{$_('register.createPasskeyAccount')}</a> 383 + </p> 301 384 </div> 302 - 303 - {#if flow.info.verificationChannel === 'email'} 304 - <div class="field"> 305 - <label for="email">{$_('register.emailAddress')}</label> 306 - <input 307 - id="email" 308 - type="email" 309 - bind:value={flow.info.email} 310 - placeholder={$_('register.emailPlaceholder')} 311 - disabled={flow.state.submitting} 312 - required 313 - /> 314 - </div> 315 - {:else if flow.info.verificationChannel === 'discord'} 316 - <div class="field"> 317 - <label for="discord-id">{$_('register.discordId')}</label> 318 - <input 319 - id="discord-id" 320 - type="text" 321 - bind:value={flow.info.discordId} 322 - placeholder={$_('register.discordIdPlaceholder')} 323 - disabled={flow.state.submitting} 324 - required 325 - /> 326 - <p class="hint">{$_('register.discordIdHint')}</p> 327 - </div> 328 - {:else if flow.info.verificationChannel === 'telegram'} 329 - <div class="field"> 330 - <label for="telegram-username">{$_('register.telegramUsername')}</label> 331 - <input 332 - id="telegram-username" 333 - type="text" 334 - bind:value={flow.info.telegramUsername} 335 - placeholder={$_('register.telegramUsernamePlaceholder')} 336 - disabled={flow.state.submitting} 337 - required 338 - /> 339 - </div> 340 - {:else if flow.info.verificationChannel === 'signal'} 341 - <div class="field"> 342 - <label for="signal-number">{$_('register.signalNumber')}</label> 343 - <input 344 - id="signal-number" 345 - type="tel" 346 - bind:value={flow.info.signalNumber} 347 - placeholder={$_('register.signalNumberPlaceholder')} 348 - disabled={flow.state.submitting} 349 - required 350 - /> 351 - <p class="hint">{$_('register.signalNumberHint')}</p> 352 - </div> 353 - {/if} 354 - </fieldset> 385 + </div> 355 386 356 - {#if serverInfo?.inviteCodeRequired} 357 - <div class="field"> 358 - <label for="invite-code">{$_('register.inviteCode')} <span class="required">{$_('register.inviteCodeRequired')}</span></label> 359 - <input 360 - id="invite-code" 361 - type="text" 362 - bind:value={flow.info.inviteCode} 363 - placeholder={$_('register.inviteCodePlaceholder')} 364 - disabled={flow.state.submitting} 365 - required 366 - /> 367 - </div> 368 - {/if} 387 + <aside class="info-panel"> 388 + <h3>{$_('register.identityHint')}</h3> 389 + <p>{$_('register.infoIdentityDesc')}</p> 369 390 370 - <button type="submit" disabled={flow.state.submitting}> 371 - {flow.state.submitting ? $_('register.creating') : $_('register.createButton')} 372 - </button> 373 - </form> 391 + <h3>{$_('register.contactMethodHint')}</h3> 392 + <p>{$_('register.infoContactDesc')}</p> 374 393 375 - <p class="link-text"> 376 - {$_('register.alreadyHaveAccount')} <a href="#/login">{$_('register.signIn')}</a> 377 - </p> 378 - <p class="link-text"> 379 - {$_('register.wantPasswordless')} <a href="#/register-passkey">{$_('register.createPasskeyAccount')}</a> 380 - </p> 394 + <h3>{$_('register.infoNextTitle')}</h3> 395 + <p>{$_('register.infoNextDesc')}</p> 396 + </aside> 397 + </div> 381 398 382 399 {:else if flow.state.step === 'key-choice'} 383 400 <KeyChoiceStep {flow} /> ··· 404 421 /> 405 422 406 423 {:else if flow.state.step === 'redirect-to-dashboard'} 407 - <p class="loading">Redirecting to dashboard...</p> 424 + <p class="loading">{$_('register.redirecting')}</p> 408 425 {/if} 409 426 </div> 410 427 411 428 <style> 412 429 .register-page { 413 - max-width: var(--width-sm); 430 + max-width: var(--width-lg); 414 431 margin: var(--space-9) auto; 415 432 padding: var(--space-7); 433 + } 434 + 435 + .page-header { 436 + margin-bottom: var(--space-6); 437 + } 438 + 439 + .form-section { 440 + min-width: 0; 441 + } 442 + 443 + .form-links { 444 + margin-top: var(--space-6); 416 445 } 417 446 418 447 .migrate-callout { ··· 481 510 482 511 .required { 483 512 color: var(--error-text); 484 - } 485 - 486 - .section-fieldset { 487 - border: 1px solid var(--border-color); 488 - border-radius: var(--radius-lg); 489 - padding: var(--space-5); 490 - } 491 - 492 - .section-fieldset legend { 493 - font-weight: var(--font-semibold); 494 - padding: 0 var(--space-3); 495 513 } 496 514 497 515 .section-hint {
+1 -12
frontend/src/routes/RegisterPasskey.svelte
··· 369 369 <div class="warning-box"> 370 370 <strong>{$_('registerPasskey.didWebWarningTitle')}</strong> 371 371 <ul> 372 - <li><strong>{$_('registerPasskey.didWebWarning1')}</strong> Your identity will be <code>did:web:yourhandle.{serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>.</li> 372 + <li><strong>{$_('registerPasskey.didWebWarning1')}</strong> {@html $_('registerPasskey.didWebWarning1Detail', { values: { did: `<code>did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>` } })}</li> 373 373 <li><strong>{$_('registerPasskey.didWebWarning2')}</strong> {$_('registerPasskey.didWebWarning2Detail')}</li> 374 374 <li><strong>{$_('registerPasskey.didWebWarning3')}</strong> {$_('registerPasskey.didWebWarning3Detail')}</li> 375 375 <li><strong>{$_('registerPasskey.didWebWarning4')}</strong> {$_('registerPasskey.didWebWarning4Detail')}</li> ··· 542 542 543 543 .required { 544 544 color: var(--error-text); 545 - } 546 - 547 - .section-fieldset { 548 - border: 1px solid var(--border-color); 549 - border-radius: var(--radius-lg); 550 - padding: var(--space-5); 551 - } 552 - 553 - .section-fieldset legend { 554 - font-weight: var(--font-semibold); 555 - padding: 0 var(--space-3); 556 545 } 557 546 558 547 .section-hint {
+59 -18
frontend/src/routes/RepoExplorer.svelte
··· 75 75 } 76 76 } 77 77 async function loadMoreRecords() { 78 - if (!auth.session || !selectedCollection || !recordsCursor) return 78 + if (!auth.session || !selectedCollection || !recordsCursor || loadingMore) return 79 79 loadingMore = true 80 80 try { 81 81 const result = await api.listRecords(auth.session.accessJwt, auth.session.did, selectedCollection, { ··· 93 93 loadingMore = false 94 94 } 95 95 } 96 + 97 + $effect(() => { 98 + if (view === 'records' && recordsCursor && !loadingMore && !loading) { 99 + loadMoreRecords() 100 + } 101 + }) 96 102 async function selectRecord(record: { uri: string; cid: string; value: unknown; rkey: string }) { 97 103 selectedRecord = record 98 104 recordJson = JSON.stringify(record.value, null, 2) ··· 371 377 </li> 372 378 {/each} 373 379 </ul> 374 - {#if recordsCursor} 375 - <div class="load-more"> 376 - <button onclick={loadMoreRecords} disabled={loadingMore}> 377 - {loadingMore ? $_('common.loading') : $_('repoExplorer.loadMore')} 378 - </button> 380 + {#if loadingMore} 381 + <div class="skeleton-records"> 382 + {#each [1, 2, 3] as _} 383 + <div class="skeleton-record"> 384 + <div class="skeleton-record-header"> 385 + <div class="skeleton-line short"></div> 386 + <div class="skeleton-line tiny"></div> 387 + </div> 388 + <div class="skeleton-preview"></div> 389 + </div> 390 + {/each} 379 391 </div> 380 392 {/if} 381 393 {/if} ··· 383 395 <div class="record-detail"> 384 396 <div class="record-meta"> 385 397 <dl> 386 - <dt>URI</dt> 398 + <dt>{$_('repoExplorer.uri')}</dt> 387 399 <dd class="mono">{selectedRecord.uri}</dd> 388 - <dt>CID</dt> 400 + <dt>{$_('repoExplorer.cid')}</dt> 389 401 <dd class="mono">{selectedRecord.cid}</dd> 390 402 </dl> 391 403 </div> ··· 463 475 </div> 464 476 <style> 465 477 .page { 466 - max-width: var(--width-lg); 478 + max-width: var(--width-xl); 467 479 margin: 0 auto; 468 480 padding: var(--space-7); 469 481 } ··· 751 763 overflow: hidden; 752 764 } 753 765 754 - .load-more { 755 - text-align: center; 766 + .skeleton-records { 767 + display: flex; 768 + flex-direction: column; 769 + gap: var(--space-2); 770 + margin-top: var(--space-2); 771 + } 772 + 773 + .skeleton-record { 756 774 padding: var(--space-4); 775 + background: var(--bg-card); 776 + border: 1px solid var(--border-color); 777 + border-radius: var(--radius-md); 778 + } 779 + 780 + .skeleton-record-header { 781 + display: flex; 782 + justify-content: space-between; 783 + margin-bottom: var(--space-2); 757 784 } 758 785 759 - .load-more button { 760 - padding: var(--space-2) var(--space-7); 786 + .skeleton-line { 787 + height: 14px; 788 + background: var(--bg-tertiary); 789 + border-radius: var(--radius-sm); 790 + animation: skeleton-pulse 1.5s ease-in-out infinite; 791 + } 792 + 793 + .skeleton-line.short { 794 + width: 120px; 795 + } 796 + 797 + .skeleton-line.tiny { 798 + width: 80px; 799 + } 800 + 801 + .skeleton-preview { 802 + height: 60px; 761 803 background: var(--bg-secondary); 762 - border: 1px solid var(--border-color); 763 804 border-radius: var(--radius-md); 764 - cursor: pointer; 765 - color: var(--text-primary); 805 + animation: skeleton-pulse 1.5s ease-in-out infinite; 766 806 } 767 807 768 - .load-more button:hover:not(:disabled) { 769 - background: var(--bg-card); 808 + @keyframes skeleton-pulse { 809 + 0%, 100% { opacity: 1; } 810 + 50% { opacity: 0.4; } 770 811 } 771 812 772 813 .record-detail {
+25 -2
frontend/src/routes/Security.svelte
··· 130 130 try { 131 131 const token = await getValidToken() 132 132 if (!token) { 133 - showMessage('error', 'Session expired. Please log in again.') 133 + showMessage('error', $_('security.sessionExpired')) 134 134 return 135 135 } 136 136 await api.removePassword(token) ··· 414 414 {#if loading} 415 415 <div class="loading">{$_('common.loading')}</div> 416 416 {:else} 417 + <div class="sections-grid"> 417 418 <section> 418 419 <h2>{$_('security.totp')}</h2> 419 420 <p class="description"> ··· 725 726 {$_('security.manageTrustedDevices')} &rarr; 726 727 </a> 727 728 </section> 729 + </div> 728 730 729 731 {#if hasMfa} 730 732 <section> ··· 788 790 789 791 <style> 790 792 .page { 791 - max-width: var(--width-md); 793 + max-width: var(--width-lg); 792 794 margin: 0 auto; 793 795 padding: var(--space-7); 794 796 } ··· 797 799 margin-bottom: var(--space-7); 798 800 } 799 801 802 + .sections-grid { 803 + display: flex; 804 + flex-direction: column; 805 + gap: var(--space-6); 806 + margin-bottom: var(--space-6); 807 + } 808 + 809 + @media (min-width: 800px) { 810 + .sections-grid { 811 + columns: 2; 812 + column-gap: var(--space-6); 813 + display: block; 814 + } 815 + 816 + .sections-grid section { 817 + break-inside: avoid; 818 + margin-bottom: var(--space-6); 819 + } 820 + } 821 + 800 822 .back { 801 823 color: var(--text-secondary); 802 824 text-decoration: none; ··· 822 844 background: var(--bg-secondary); 823 845 border-radius: var(--radius-xl); 824 846 margin-bottom: var(--space-6); 847 + height: fit-content; 825 848 } 826 849 827 850 section h2 {
+1 -1
frontend/src/routes/Sessions.svelte
··· 149 149 </div> 150 150 <style> 151 151 .page { 152 - max-width: var(--width-md); 152 + max-width: var(--width-lg); 153 153 margin: 0 auto; 154 154 padding: var(--space-7); 155 155 }
+45 -4
frontend/src/routes/Settings.svelte
··· 1 1 <script lang="ts"> 2 + import { onMount } from 'svelte' 2 3 import { getAuthState, logout, refreshSession } from '../lib/auth.svelte' 3 4 import { navigate } from '../lib/router.svelte' 4 5 import { api, ApiError } from '../lib/api' 5 6 import { locale, setLocale, getSupportedLocales, localeNames, _, type SupportedLocale } from '../lib/i18n' 6 7 const auth = getAuthState() 7 8 const supportedLocales = getSupportedLocales() 9 + let pdsHostname = $state<string | null>(null) 10 + 11 + onMount(() => { 12 + api.describeServer().then(info => { 13 + if (info.availableUserDomains?.length) { 14 + pdsHostname = info.availableUserDomains[0] 15 + } 16 + }).catch(() => {}) 17 + }) 8 18 let localeLoading = $state(false) 9 19 async function handleLocaleChange(newLocale: SupportedLocale) { 10 20 if (!auth.session) return ··· 94 104 try { 95 105 const fullHandle = showBYOHandle 96 106 ? newHandle 97 - : `${newHandle}.${window.location.hostname}` 107 + : `${newHandle}.${pdsHostname}` 98 108 await api.updateHandle(auth.session.accessJwt, fullHandle) 99 109 await refreshSession() 100 110 showMessage('success', $_('settings.messages.handleUpdated')) ··· 201 211 {#if message} 202 212 <div class="message {message.type}">{message.text}</div> 203 213 {/if} 214 + <div class="sections-grid"> 204 215 <section> 205 216 <h2>{$_('settings.language')}</h2> 206 217 <p class="description">{$_('settings.languageDescription')}</p> ··· 335 346 disabled={handleLoading} 336 347 required 337 348 /> 338 - <span class="handle-suffix">.{window.location.hostname}</span> 349 + <span class="handle-suffix">.{pdsHostname ?? '...'}</span> 339 350 </div> 340 351 </div> 341 - <button type="submit" disabled={handleLoading || !newHandle}> 352 + <button type="submit" disabled={handleLoading || !newHandle || !pdsHostname}> 342 353 {handleLoading ? $_('settings.updating') : $_('settings.changeHandleButton')} 343 354 </button> 344 355 </form> ··· 393 404 {exportLoading ? $_('settings.exporting') : $_('settings.downloadRepo')} 394 405 </button> 395 406 </section> 407 + </div> 396 408 <section class="danger-zone"> 397 409 <h2>{$_('settings.deleteAccount')}</h2> 398 410 <p class="warning">{$_('settings.deleteWarning')}</p> ··· 438 450 </div> 439 451 <style> 440 452 .page { 441 - max-width: var(--width-md); 453 + max-width: var(--width-lg); 442 454 margin: 0 auto; 443 455 padding: var(--space-7); 444 456 } ··· 447 459 margin-bottom: var(--space-7); 448 460 } 449 461 462 + .sections-grid { 463 + display: flex; 464 + flex-direction: column; 465 + gap: var(--space-6); 466 + } 467 + 468 + @media (min-width: 800px) { 469 + .sections-grid { 470 + columns: 2; 471 + column-gap: var(--space-6); 472 + display: block; 473 + } 474 + 475 + .sections-grid section { 476 + break-inside: avoid; 477 + margin-bottom: var(--space-6); 478 + } 479 + } 480 + 450 481 .back { 451 482 color: var(--text-secondary); 452 483 text-decoration: none; ··· 466 497 background: var(--bg-secondary); 467 498 border-radius: var(--radius-xl); 468 499 margin-bottom: var(--space-6); 500 + height: fit-content; 501 + } 502 + 503 + .danger-zone { 504 + margin-top: var(--space-6); 469 505 } 470 506 471 507 section h2 { ··· 482 518 483 519 .language-select { 484 520 width: 100%; 521 + } 522 + 523 + form > button, 524 + form > .actions { 525 + margin-top: var(--space-4); 485 526 } 486 527 487 528 .actions {
+3 -3
frontend/src/routes/TrustedDevices.svelte
··· 40 40 const result = await api.listTrustedDevices(auth.session.accessJwt) 41 41 devices = result.devices 42 42 } catch { 43 - showMessage('error', 'Failed to load trusted devices') 43 + showMessage('error', $_('trustedDevices.failedToLoad')) 44 44 } finally { 45 45 loading = false 46 46 } ··· 199 199 200 200 <style> 201 201 .page { 202 - max-width: var(--width-md); 202 + max-width: var(--width-lg); 203 203 margin: 0 auto; 204 - padding: var(--space-7) var(--space-4); 204 + padding: var(--space-7); 205 205 } 206 206 207 207 header {
+131 -27
frontend/src/styles/base.css
··· 1 - @import './tokens.css'; 1 + @import "./tokens.css"; 2 2 3 3 @property --accent { 4 - syntax: '<color>'; 4 + syntax: "<color>"; 5 5 inherits: true; 6 - initial-value: #2c00ff; 6 + initial-value: #1a1d1d; 7 7 } 8 8 9 9 @property --secondary { 10 - syntax: '<color>'; 10 + syntax: "<color>"; 11 11 inherits: true; 12 - initial-value: #ff2400; 12 + initial-value: #1a1d1d; 13 13 } 14 14 15 15 *, ··· 20 20 21 21 body { 22 22 margin: 0; 23 - font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Monaco, monospace; 23 + font-family: 24 + "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; 24 25 font-size: var(--text-base); 25 26 line-height: var(--leading-normal); 26 27 color: var(--text-primary); ··· 35 36 line-height: var(--leading-tight); 36 37 } 37 38 38 - h1 { font-size: var(--text-2xl); } 39 - h2 { font-size: var(--text-xl); } 40 - h3 { font-size: var(--text-lg); } 41 - h4 { font-size: var(--text-base); } 39 + h1 { 40 + font-size: var(--text-2xl); 41 + } 42 + h2 { 43 + font-size: var(--text-xl); 44 + } 45 + h3 { 46 + font-size: var(--text-lg); 47 + } 48 + h4 { 49 + font-size: var(--text-base); 50 + } 42 51 43 52 p { 44 53 margin: 0; ··· 70 79 border-radius: var(--radius-md); 71 80 background: var(--bg-input); 72 81 color: var(--text-primary); 73 - transition: border-color var(--transition-normal), box-shadow var(--transition-normal); 82 + transition: 83 + border-color var(--transition-normal), 84 + box-shadow var(--transition-normal); 74 85 width: 100%; 75 86 } 76 87 ··· 113 124 border: none; 114 125 border-radius: var(--radius-md); 115 126 cursor: pointer; 116 - transition: background var(--transition-normal), border-color var(--transition-normal), opacity var(--transition-normal); 127 + transition: 128 + background var(--transition-normal), 129 + border-color var(--transition-normal), 130 + opacity var(--transition-normal); 117 131 background: var(--accent); 118 132 color: var(--text-inverse); 119 133 } ··· 177 191 } 178 192 179 193 fieldset { 180 - border: 1px solid var(--border-dark); 194 + border: none; 195 + border-left: 3px solid var(--accent); 181 196 border-radius: var(--radius-lg); 182 197 padding: var(--space-5); 198 + padding-left: var(--space-6); 183 199 margin: 0; 200 + background: var(--bg-secondary); 184 201 } 185 202 186 203 fieldset legend { 204 + font-size: var(--text-xs); 187 205 font-weight: var(--font-semibold); 188 - padding: 0 var(--space-3); 189 - color: var(--text-primary); 206 + text-transform: uppercase; 207 + letter-spacing: 0.05em; 208 + padding: 0; 209 + margin-left: calc(-1 * var(--space-1)); 210 + margin-bottom: var(--space-3); 211 + color: var(--text-secondary); 212 + float: left; 213 + width: 100%; 214 + } 215 + 216 + fieldset legend + * { 217 + clear: both; 190 218 } 191 219 192 220 code { 193 - font-family: inherit; 221 + font-family: ui-monospace, "SF Mono", Menlo, Monaco, monospace; 194 222 font-size: 0.9em; 195 223 background: var(--bg-tertiary); 196 224 padding: var(--space-1) var(--space-2); ··· 198 226 } 199 227 200 228 pre { 201 - font-family: inherit; 229 + font-family: ui-monospace, "SF Mono", Menlo, Monaco, monospace; 202 230 font-size: var(--text-sm); 203 231 background: var(--bg-tertiary); 204 232 padding: var(--space-4); ··· 221 249 222 250 .field + .field { 223 251 margin-top: var(--space-5); 252 + } 253 + 254 + .form-row .field + .field { 255 + margin-top: 0; 224 256 } 225 257 226 258 .hint { ··· 307 339 } 308 340 309 341 .page { 310 - max-width: var(--width-md); 342 + max-width: var(--width-lg); 311 343 margin: 0 auto; 312 344 padding: var(--space-7); 313 345 } 314 346 315 347 .page-sm { 316 - max-width: var(--width-sm); 348 + max-width: var(--width-md); 317 349 margin: 0 auto; 318 350 padding: var(--space-7); 319 351 } 320 352 321 353 .page-lg { 322 - max-width: var(--width-lg); 354 + max-width: var(--width-xl); 323 355 margin: 0 auto; 324 356 padding: var(--space-7); 325 357 } ··· 357 389 } 358 390 359 391 .mono { 360 - font-family: inherit; 392 + font-family: ui-monospace, "SF Mono", Menlo, Monaco, monospace; 361 393 } 362 394 363 - .mt-4 { margin-top: var(--space-4); } 364 - .mt-5 { margin-top: var(--space-5); } 365 - .mt-6 { margin-top: var(--space-6); } 366 - .mb-4 { margin-bottom: var(--space-4); } 367 - .mb-5 { margin-bottom: var(--space-5); } 368 - .mb-6 { margin-bottom: var(--space-6); } 395 + .mt-4 { 396 + margin-top: var(--space-4); 397 + } 398 + .mt-5 { 399 + margin-top: var(--space-5); 400 + } 401 + .mt-6 { 402 + margin-top: var(--space-6); 403 + } 404 + .mb-4 { 405 + margin-bottom: var(--space-4); 406 + } 407 + .mb-5 { 408 + margin-bottom: var(--space-5); 409 + } 410 + .mb-6 { 411 + margin-bottom: var(--space-6); 412 + } 413 + 414 + .split-layout { 415 + display: grid; 416 + grid-template-columns: 1fr; 417 + gap: var(--space-6); 418 + } 419 + 420 + @media (min-width: 800px) { 421 + .split-layout { 422 + grid-template-columns: 1fr 1fr; 423 + } 424 + .split-layout.sidebar-right { 425 + grid-template-columns: 1.5fr 1fr; 426 + } 427 + .split-layout.sidebar-left { 428 + grid-template-columns: 1fr 1.5fr; 429 + } 430 + } 431 + 432 + .form-row { 433 + display: grid; 434 + grid-template-columns: 1fr; 435 + gap: var(--space-4); 436 + } 437 + 438 + @media (min-width: 600px) { 439 + .form-row { 440 + grid-template-columns: repeat(2, 1fr); 441 + } 442 + .form-row.thirds { 443 + grid-template-columns: repeat(3, 1fr); 444 + } 445 + } 446 + 447 + .full-width { 448 + grid-column: 1 / -1; 449 + } 450 + 451 + .info-panel { 452 + background: var(--bg-secondary); 453 + border-radius: var(--radius-xl); 454 + padding: var(--space-6); 455 + height: fit-content; 456 + } 457 + 458 + .info-panel h3 { 459 + margin: 0 0 var(--space-3) 0; 460 + font-size: var(--text-base); 461 + font-weight: var(--font-semibold); 462 + } 463 + 464 + .info-panel p { 465 + margin: 0 0 var(--space-4) 0; 466 + font-size: var(--text-sm); 467 + color: var(--text-secondary); 468 + } 469 + 470 + .info-panel p:last-child { 471 + margin-bottom: 0; 472 + }
+51 -51
frontend/src/styles/tokens.css
··· 33 33 --radius-lg: 6px; 34 34 --radius-xl: 8px; 35 35 36 - --width-xs: 320px; 37 - --width-sm: 400px; 38 - --width-md: 600px; 39 - --width-lg: 800px; 40 - --width-xl: 1000px; 36 + --width-xs: 360px; 37 + --width-sm: 480px; 38 + --width-md: 760px; 39 + --width-lg: 960px; 40 + --width-xl: 1100px; 41 41 42 42 --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); 43 43 --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.1); ··· 48 48 --transition-normal: 0.15s ease; 49 49 --transition-slow: 0.25s ease; 50 50 51 - --bg-primary: #ffffff; 52 - --bg-secondary: #f8f8fa; 53 - --bg-tertiary: #f0f0f2; 51 + --bg-primary: #f9fafa; 52 + --bg-secondary: #f1f3f3; 53 + --bg-tertiary: #e8ebeb; 54 54 --bg-card: #ffffff; 55 55 --bg-input: #ffffff; 56 - --bg-input-disabled: #f8f8fa; 56 + --bg-input-disabled: #f1f3f3; 57 57 58 - --text-primary: #1a1a1a; 59 - --text-secondary: #666666; 60 - --text-muted: #999999; 58 + --text-primary: #1a1d1d; 59 + --text-secondary: #5a605f; 60 + --text-muted: #8a8f8e; 61 61 --text-inverse: #ffffff; 62 62 63 - --border-color: #e5e5e5; 64 - --border-light: #f0f0f0; 65 - --border-dark: #cccccc; 63 + --border-color: #dce0df; 64 + --border-light: #e8ebeb; 65 + --border-dark: #c8cecc; 66 66 67 - --accent: #2c00ff; 68 - --accent-hover: #1a00a3; 69 - --accent-muted: rgba(44, 0, 255, 0.08); 70 - --accent-light: #4d33ff; 67 + --accent: #1a1d1d; 68 + --accent-hover: #2e3332; 69 + --accent-muted: rgba(26, 29, 29, 0.06); 70 + --accent-light: #3a403f; 71 71 72 - --secondary: #ff2400; 73 - --secondary-hover: #cc1d00; 74 - --secondary-muted: rgba(255, 36, 0, 0.08); 72 + --secondary: #1a1d1d; 73 + --secondary-hover: #2e3332; 74 + --secondary-muted: rgba(26, 29, 29, 0.06); 75 75 76 76 --success-bg: #dfd; 77 77 --success-border: #8c8; ··· 90 90 91 91 @media (prefers-color-scheme: dark) { 92 92 :root { 93 - --bg-primary: #0a0a0a; 94 - --bg-secondary: #141414; 95 - --bg-tertiary: #1a1a1a; 96 - --bg-card: #141414; 97 - --bg-input: #1a1a1a; 98 - --bg-input-disabled: #141414; 93 + --bg-primary: #0a0c0c; 94 + --bg-secondary: #131616; 95 + --bg-tertiary: #1a1d1d; 96 + --bg-card: #131616; 97 + --bg-input: #1a1d1d; 98 + --bg-input-disabled: #131616; 99 99 100 - --text-primary: #e8e8e8; 101 - --text-secondary: #a0a0a0; 102 - --text-muted: #666666; 103 - --text-inverse: #0a0a0a; 100 + --text-primary: #e6e8e8; 101 + --text-secondary: #9ca1a0; 102 + --text-muted: #686d6c; 103 + --text-inverse: #0a0c0c; 104 104 105 - --border-color: #2a2a2a; 106 - --border-light: #222222; 107 - --border-dark: #333333; 105 + --border-color: #282c2b; 106 + --border-light: #1f2322; 107 + --border-dark: #343938; 108 108 109 - --accent: #7b6bff; 110 - --accent-hover: #9588ff; 111 - --accent-muted: rgba(123, 107, 255, 0.2); 112 - --accent-light: #9588ff; 109 + --accent: #e6e8e8; 110 + --accent-hover: #ffffff; 111 + --accent-muted: rgba(230, 232, 232, 0.1); 112 + --accent-light: #ffffff; 113 113 114 - --secondary: #ff6b5b; 115 - --secondary-hover: #ff8577; 116 - --secondary-muted: rgba(255, 107, 91, 0.2); 114 + --secondary: #e6e8e8; 115 + --secondary-hover: #ffffff; 116 + --secondary-muted: rgba(230, 232, 232, 0.1); 117 117 118 - --success-bg: #1a3d1a; 119 - --success-border: #2d5a2d; 120 - --success-text: #7bc67b; 118 + --success-bg: #0f1f1a; 119 + --success-border: #1a3d2d; 120 + --success-text: #7bc6a0; 121 121 122 - --error-bg: #3d1a1a; 123 - --error-border: #5a2d2d; 124 - --error-text: #ff7b7b; 122 + --error-bg: #1f0f0f; 123 + --error-border: #3d1a1a; 124 + --error-text: #ff8a8a; 125 125 126 - --warning-bg: #3d3d1a; 127 - --warning-border: #5a5a2d; 128 - --warning-text: #c6c67b; 126 + --warning-bg: #1f1a0f; 127 + --warning-border: #3d351a; 128 + --warning-text: #c6b87b; 129 129 } 130 130 }
+341 -299
frontend/src/tests/AppPasswords.test.ts
··· 1 - import { describe, it, expect, beforeEach, vi } from 'vitest' 2 - import { render, screen, fireEvent, waitFor } from '@testing-library/svelte' 3 - import AppPasswords from '../routes/AppPasswords.svelte' 1 + import { beforeEach, describe, expect, it, vi } from "vitest"; 2 + import { fireEvent, render, screen, waitFor } from "@testing-library/svelte"; 3 + import AppPasswords from "../routes/AppPasswords.svelte"; 4 4 import { 5 - setupFetchMock, 6 - mockEndpoint, 7 - jsonResponse, 5 + clearMocks, 8 6 errorResponse, 7 + jsonResponse, 9 8 mockData, 10 - clearMocks, 9 + mockEndpoint, 11 10 setupAuthenticatedUser, 11 + setupFetchMock, 12 12 setupUnauthenticatedUser, 13 - } from './mocks' 14 - describe('AppPasswords', () => { 13 + } from "./mocks"; 14 + describe("AppPasswords", () => { 15 15 beforeEach(() => { 16 - clearMocks() 17 - setupFetchMock() 18 - window.confirm = vi.fn(() => true) 19 - }) 20 - describe('authentication guard', () => { 21 - it('redirects to login when not authenticated', async () => { 22 - setupUnauthenticatedUser() 23 - render(AppPasswords) 16 + clearMocks(); 17 + setupFetchMock(); 18 + window.confirm = vi.fn(() => true); 19 + }); 20 + describe("authentication guard", () => { 21 + it("redirects to login when not authenticated", async () => { 22 + setupUnauthenticatedUser(); 23 + render(AppPasswords); 24 24 await waitFor(() => { 25 - expect(window.location.hash).toBe('#/login') 26 - }) 27 - }) 28 - }) 29 - describe('page structure', () => { 25 + expect(window.location.hash).toBe("#/login"); 26 + }); 27 + }); 28 + }); 29 + describe("page structure", () => { 30 30 beforeEach(() => { 31 - setupAuthenticatedUser() 32 - mockEndpoint('com.atproto.server.listAppPasswords', () => 33 - jsonResponse({ passwords: [] }) 34 - ) 35 - }) 36 - it('displays all page elements', async () => { 37 - render(AppPasswords) 31 + setupAuthenticatedUser(); 32 + mockEndpoint( 33 + "com.atproto.server.listAppPasswords", 34 + () => jsonResponse({ passwords: [] }), 35 + ); 36 + }); 37 + it("displays all page elements", async () => { 38 + render(AppPasswords); 38 39 await waitFor(() => { 39 - expect(screen.getByRole('heading', { name: /app passwords/i, level: 1 })).toBeInTheDocument() 40 - expect(screen.getByRole('link', { name: /dashboard/i })).toHaveAttribute('href', '#/dashboard') 41 - expect(screen.getByText(/third-party apps/i)).toBeInTheDocument() 42 - }) 43 - }) 44 - }) 45 - describe('loading state', () => { 40 + expect( 41 + screen.getByRole("heading", { name: /app passwords/i, level: 1 }), 42 + ).toBeInTheDocument(); 43 + expect(screen.getByRole("link", { name: /dashboard/i })) 44 + .toHaveAttribute("href", "#/dashboard"); 45 + expect(screen.getByText(/third-party apps/i)).toBeInTheDocument(); 46 + }); 47 + }); 48 + }); 49 + describe("loading state", () => { 46 50 beforeEach(() => { 47 - setupAuthenticatedUser() 48 - }) 49 - it('shows loading text while fetching passwords', async () => { 50 - mockEndpoint('com.atproto.server.listAppPasswords', async () => { 51 - await new Promise(resolve => setTimeout(resolve, 100)) 52 - return jsonResponse({ passwords: [] }) 53 - }) 54 - render(AppPasswords) 55 - expect(screen.getByText(/loading/i)).toBeInTheDocument() 56 - }) 57 - }) 58 - describe('empty state', () => { 51 + setupAuthenticatedUser(); 52 + }); 53 + it("shows loading text while fetching passwords", async () => { 54 + mockEndpoint("com.atproto.server.listAppPasswords", async () => { 55 + await new Promise((resolve) => setTimeout(resolve, 100)); 56 + return jsonResponse({ passwords: [] }); 57 + }); 58 + render(AppPasswords); 59 + expect(screen.getByText(/loading/i)).toBeInTheDocument(); 60 + }); 61 + }); 62 + describe("empty state", () => { 59 63 beforeEach(() => { 60 - setupAuthenticatedUser() 61 - mockEndpoint('com.atproto.server.listAppPasswords', () => 62 - jsonResponse({ passwords: [] }) 63 - ) 64 - }) 65 - it('shows empty message when no passwords exist', async () => { 66 - render(AppPasswords) 64 + setupAuthenticatedUser(); 65 + mockEndpoint( 66 + "com.atproto.server.listAppPasswords", 67 + () => jsonResponse({ passwords: [] }), 68 + ); 69 + }); 70 + it("shows empty message when no passwords exist", async () => { 71 + render(AppPasswords); 67 72 await waitFor(() => { 68 - expect(screen.getByText(/no app passwords yet/i)).toBeInTheDocument() 69 - }) 70 - }) 71 - }) 72 - describe('password list', () => { 73 + expect(screen.getByText(/no app passwords yet/i)).toBeInTheDocument(); 74 + }); 75 + }); 76 + }); 77 + describe("password list", () => { 73 78 const testPasswords = [ 74 - mockData.appPassword({ name: 'Graysky', createdAt: '2024-01-15T10:00:00Z' }), 75 - mockData.appPassword({ name: 'Skeets', createdAt: '2024-02-20T15:30:00Z' }), 76 - ] 79 + mockData.appPassword({ 80 + name: "Graysky", 81 + createdAt: "2024-01-15T10:00:00Z", 82 + }), 83 + mockData.appPassword({ 84 + name: "Skeets", 85 + createdAt: "2024-02-20T15:30:00Z", 86 + }), 87 + ]; 77 88 beforeEach(() => { 78 - setupAuthenticatedUser() 79 - mockEndpoint('com.atproto.server.listAppPasswords', () => 80 - jsonResponse({ passwords: testPasswords }) 81 - ) 82 - }) 83 - it('displays all app passwords with dates and revoke buttons', async () => { 84 - render(AppPasswords) 89 + setupAuthenticatedUser(); 90 + mockEndpoint( 91 + "com.atproto.server.listAppPasswords", 92 + () => jsonResponse({ passwords: testPasswords }), 93 + ); 94 + }); 95 + it("displays all app passwords with dates and revoke buttons", async () => { 96 + render(AppPasswords); 85 97 await waitFor(() => { 86 - expect(screen.getByText('Graysky')).toBeInTheDocument() 87 - expect(screen.getByText('Skeets')).toBeInTheDocument() 88 - expect(screen.getByText(/created.*1\/15\/2024/i)).toBeInTheDocument() 89 - expect(screen.getByText(/created.*2\/20\/2024/i)).toBeInTheDocument() 90 - expect(screen.getAllByRole('button', { name: /revoke/i })).toHaveLength(2) 91 - }) 92 - }) 93 - }) 94 - describe('create app password', () => { 98 + expect(screen.getByText("Graysky")).toBeInTheDocument(); 99 + expect(screen.getByText("Skeets")).toBeInTheDocument(); 100 + expect(screen.getByText(/created.*1\/15\/2024/i)).toBeInTheDocument(); 101 + expect(screen.getByText(/created.*2\/20\/2024/i)).toBeInTheDocument(); 102 + expect(screen.getAllByRole("button", { name: /revoke/i })).toHaveLength( 103 + 2, 104 + ); 105 + }); 106 + }); 107 + }); 108 + describe("create app password", () => { 95 109 beforeEach(() => { 96 - setupAuthenticatedUser() 97 - mockEndpoint('com.atproto.server.listAppPasswords', () => 98 - jsonResponse({ passwords: [] }) 99 - ) 100 - }) 101 - it('displays create form with input and button', async () => { 102 - render(AppPasswords) 110 + setupAuthenticatedUser(); 111 + mockEndpoint( 112 + "com.atproto.server.listAppPasswords", 113 + () => jsonResponse({ passwords: [] }), 114 + ); 115 + }); 116 + it("displays create form with input and button", async () => { 117 + render(AppPasswords); 103 118 await waitFor(() => { 104 - expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument() 105 - expect(screen.getByRole('button', { name: /create/i })).toBeInTheDocument() 106 - }) 107 - }) 108 - it('disables create button when input is empty', async () => { 109 - render(AppPasswords) 119 + expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument(); 120 + expect(screen.getByRole("button", { name: /create/i })) 121 + .toBeInTheDocument(); 122 + }); 123 + }); 124 + it("disables create button when input is empty", async () => { 125 + render(AppPasswords); 110 126 await waitFor(() => { 111 - expect(screen.getByRole('button', { name: /create/i })).toBeDisabled() 112 - }) 113 - }) 114 - it('enables create button when input has value', async () => { 115 - render(AppPasswords) 127 + expect(screen.getByRole("button", { name: /create/i })).toBeDisabled(); 128 + }); 129 + }); 130 + it("enables create button when input has value", async () => { 131 + render(AppPasswords); 116 132 await waitFor(() => { 117 - expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument() 118 - }) 119 - await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'My New App' } }) 120 - expect(screen.getByRole('button', { name: /create/i })).not.toBeDisabled() 121 - }) 122 - it('calls createAppPassword with correct name', async () => { 123 - let capturedName: string | null = null 124 - mockEndpoint('com.atproto.server.createAppPassword', (_url, options) => { 125 - const body = JSON.parse((options?.body as string) || '{}') 126 - capturedName = body.name 133 + expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument(); 134 + }); 135 + await fireEvent.input(screen.getByPlaceholderText(/app name/i), { 136 + target: { value: "My New App" }, 137 + }); 138 + expect(screen.getByRole("button", { name: /create/i })).not 139 + .toBeDisabled(); 140 + }); 141 + it("calls createAppPassword with correct name", async () => { 142 + let capturedName: string | null = null; 143 + mockEndpoint("com.atproto.server.createAppPassword", (_url, options) => { 144 + const body = JSON.parse((options?.body as string) || "{}"); 145 + capturedName = body.name; 127 146 return jsonResponse({ 128 147 name: body.name, 129 - password: 'xxxx-xxxx-xxxx-xxxx', 148 + password: "xxxx-xxxx-xxxx-xxxx", 130 149 createdAt: new Date().toISOString(), 131 - }) 132 - }) 133 - render(AppPasswords) 150 + }); 151 + }); 152 + render(AppPasswords); 134 153 await waitFor(() => { 135 - expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument() 136 - }) 137 - await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'Graysky' } }) 138 - await fireEvent.click(screen.getByRole('button', { name: /create/i })) 154 + expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument(); 155 + }); 156 + await fireEvent.input(screen.getByPlaceholderText(/app name/i), { 157 + target: { value: "Graysky" }, 158 + }); 159 + await fireEvent.click(screen.getByRole("button", { name: /create/i })); 139 160 await waitFor(() => { 140 - expect(capturedName).toBe('Graysky') 141 - }) 142 - }) 143 - it('shows loading state while creating', async () => { 144 - mockEndpoint('com.atproto.server.createAppPassword', async () => { 145 - await new Promise(resolve => setTimeout(resolve, 100)) 161 + expect(capturedName).toBe("Graysky"); 162 + }); 163 + }); 164 + it("shows loading state while creating", async () => { 165 + mockEndpoint("com.atproto.server.createAppPassword", async () => { 166 + await new Promise((resolve) => setTimeout(resolve, 100)); 146 167 return jsonResponse({ 147 - name: 'Test', 148 - password: 'xxxx-xxxx-xxxx-xxxx', 168 + name: "Test", 169 + password: "xxxx-xxxx-xxxx-xxxx", 149 170 createdAt: new Date().toISOString(), 150 - }) 151 - }) 152 - render(AppPasswords) 171 + }); 172 + }); 173 + render(AppPasswords); 153 174 await waitFor(() => { 154 - expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument() 155 - }) 156 - await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'Test' } }) 157 - await fireEvent.click(screen.getByRole('button', { name: /create/i })) 158 - expect(screen.getByRole('button', { name: /creating/i })).toBeInTheDocument() 159 - expect(screen.getByRole('button', { name: /creating/i })).toBeDisabled() 160 - }) 161 - it('displays created password in success box and clears input', async () => { 162 - mockEndpoint('com.atproto.server.createAppPassword', () => 175 + expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument(); 176 + }); 177 + await fireEvent.input(screen.getByPlaceholderText(/app name/i), { 178 + target: { value: "Test" }, 179 + }); 180 + await fireEvent.click(screen.getByRole("button", { name: /create/i })); 181 + expect(screen.getByRole("button", { name: /creating/i })) 182 + .toBeInTheDocument(); 183 + expect(screen.getByRole("button", { name: /creating/i })).toBeDisabled(); 184 + }); 185 + it("displays created password in success box and clears input", async () => { 186 + mockEndpoint("com.atproto.server.createAppPassword", () => 163 187 jsonResponse({ 164 - name: 'MyApp', 165 - password: 'abcd-efgh-ijkl-mnop', 188 + name: "MyApp", 189 + password: "abcd-efgh-ijkl-mnop", 166 190 createdAt: new Date().toISOString(), 167 - }) 168 - ) 169 - render(AppPasswords) 191 + })); 192 + render(AppPasswords); 170 193 await waitFor(() => { 171 - expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument() 172 - }) 173 - const input = screen.getByPlaceholderText(/app name/i) as HTMLInputElement 174 - await fireEvent.input(input, { target: { value: 'MyApp' } }) 175 - await fireEvent.click(screen.getByRole('button', { name: /create/i })) 194 + expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument(); 195 + }); 196 + const input = screen.getByPlaceholderText( 197 + /app name/i, 198 + ) as HTMLInputElement; 199 + await fireEvent.input(input, { target: { value: "MyApp" } }); 200 + await fireEvent.click(screen.getByRole("button", { name: /create/i })); 176 201 await waitFor(() => { 177 - expect(screen.getByText(/app password created/i)).toBeInTheDocument() 178 - expect(screen.getByText('abcd-efgh-ijkl-mnop')).toBeInTheDocument() 179 - expect(screen.getByText(/name: myapp/i)).toBeInTheDocument() 180 - expect(input.value).toBe('') 181 - }) 182 - }) 183 - it('dismisses created password box when clicking Done', async () => { 184 - mockEndpoint('com.atproto.server.createAppPassword', () => 202 + expect(screen.getByText(/app password created/i)).toBeInTheDocument(); 203 + expect(screen.getByText("abcd-efgh-ijkl-mnop")).toBeInTheDocument(); 204 + expect(screen.getByText(/name: myapp/i)).toBeInTheDocument(); 205 + expect(input.value).toBe(""); 206 + }); 207 + }); 208 + it("dismisses created password box when clicking Done", async () => { 209 + mockEndpoint("com.atproto.server.createAppPassword", () => 185 210 jsonResponse({ 186 - name: 'Test', 187 - password: 'xxxx-xxxx-xxxx-xxxx', 211 + name: "Test", 212 + password: "xxxx-xxxx-xxxx-xxxx", 188 213 createdAt: new Date().toISOString(), 189 - }) 190 - ) 191 - render(AppPasswords) 214 + })); 215 + render(AppPasswords); 192 216 await waitFor(() => { 193 - expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument() 194 - }) 195 - await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'Test' } }) 196 - await fireEvent.click(screen.getByRole('button', { name: /create/i })) 217 + expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument(); 218 + }); 219 + await fireEvent.input(screen.getByPlaceholderText(/app name/i), { 220 + target: { value: "Test" }, 221 + }); 222 + await fireEvent.click(screen.getByRole("button", { name: /create/i })); 197 223 await waitFor(() => { 198 - expect(screen.getByText(/app password created/i)).toBeInTheDocument() 199 - }) 200 - await fireEvent.click(screen.getByRole('button', { name: /done/i })) 224 + expect(screen.getByText(/app password created/i)).toBeInTheDocument(); 225 + }); 226 + await fireEvent.click(screen.getByRole("button", { name: /done/i })); 201 227 await waitFor(() => { 202 - expect(screen.queryByText(/app password created/i)).not.toBeInTheDocument() 203 - }) 204 - }) 205 - it('shows error when creation fails', async () => { 206 - mockEndpoint('com.atproto.server.createAppPassword', () => 207 - errorResponse('InvalidRequest', 'Name already exists', 400) 208 - ) 209 - render(AppPasswords) 228 + expect(screen.queryByText(/app password created/i)).not 229 + .toBeInTheDocument(); 230 + }); 231 + }); 232 + it("shows error when creation fails", async () => { 233 + mockEndpoint( 234 + "com.atproto.server.createAppPassword", 235 + () => errorResponse("InvalidRequest", "Name already exists", 400), 236 + ); 237 + render(AppPasswords); 210 238 await waitFor(() => { 211 - expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument() 212 - }) 213 - await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'Duplicate' } }) 214 - await fireEvent.click(screen.getByRole('button', { name: /create/i })) 239 + expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument(); 240 + }); 241 + await fireEvent.input(screen.getByPlaceholderText(/app name/i), { 242 + target: { value: "Duplicate" }, 243 + }); 244 + await fireEvent.click(screen.getByRole("button", { name: /create/i })); 215 245 await waitFor(() => { 216 - expect(screen.getByText(/name already exists/i)).toBeInTheDocument() 217 - expect(screen.getByText(/name already exists/i)).toHaveClass('error') 218 - }) 219 - }) 220 - }) 221 - describe('revoke app password', () => { 222 - const testPassword = mockData.appPassword({ name: 'TestApp' }) 246 + expect(screen.getByText(/name already exists/i)).toBeInTheDocument(); 247 + expect(screen.getByText(/name already exists/i)).toHaveClass("error"); 248 + }); 249 + }); 250 + }); 251 + describe("revoke app password", () => { 252 + const testPassword = mockData.appPassword({ name: "TestApp" }); 223 253 beforeEach(() => { 224 - setupAuthenticatedUser() 225 - }) 226 - it('shows confirmation dialog before revoking', async () => { 227 - const confirmSpy = vi.fn(() => false) 228 - window.confirm = confirmSpy 229 - mockEndpoint('com.atproto.server.listAppPasswords', () => 230 - jsonResponse({ passwords: [testPassword] }) 231 - ) 232 - render(AppPasswords) 254 + setupAuthenticatedUser(); 255 + }); 256 + it("shows confirmation dialog before revoking", async () => { 257 + const confirmSpy = vi.fn(() => false); 258 + window.confirm = confirmSpy; 259 + mockEndpoint( 260 + "com.atproto.server.listAppPasswords", 261 + () => jsonResponse({ passwords: [testPassword] }), 262 + ); 263 + render(AppPasswords); 233 264 await waitFor(() => { 234 - expect(screen.getByText('TestApp')).toBeInTheDocument() 235 - }) 236 - await fireEvent.click(screen.getByRole('button', { name: /revoke/i })) 265 + expect(screen.getByText("TestApp")).toBeInTheDocument(); 266 + }); 267 + await fireEvent.click(screen.getByRole("button", { name: /revoke/i })); 237 268 expect(confirmSpy).toHaveBeenCalledWith( 238 - expect.stringContaining('TestApp') 239 - ) 240 - }) 241 - it('does not revoke when confirmation is cancelled', async () => { 242 - window.confirm = vi.fn(() => false) 243 - let revokeCalled = false 244 - mockEndpoint('com.atproto.server.listAppPasswords', () => 245 - jsonResponse({ passwords: [testPassword] }) 246 - ) 247 - mockEndpoint('com.atproto.server.revokeAppPassword', () => { 248 - revokeCalled = true 249 - return jsonResponse({}) 250 - }) 251 - render(AppPasswords) 269 + expect.stringContaining("TestApp"), 270 + ); 271 + }); 272 + it("does not revoke when confirmation is cancelled", async () => { 273 + window.confirm = vi.fn(() => false); 274 + let revokeCalled = false; 275 + mockEndpoint( 276 + "com.atproto.server.listAppPasswords", 277 + () => jsonResponse({ passwords: [testPassword] }), 278 + ); 279 + mockEndpoint("com.atproto.server.revokeAppPassword", () => { 280 + revokeCalled = true; 281 + return jsonResponse({}); 282 + }); 283 + render(AppPasswords); 252 284 await waitFor(() => { 253 - expect(screen.getByText('TestApp')).toBeInTheDocument() 254 - }) 255 - await fireEvent.click(screen.getByRole('button', { name: /revoke/i })) 256 - expect(revokeCalled).toBe(false) 257 - }) 258 - it('calls revokeAppPassword with correct name', async () => { 259 - window.confirm = vi.fn(() => true) 260 - let capturedName: string | null = null 261 - mockEndpoint('com.atproto.server.listAppPasswords', () => 262 - jsonResponse({ passwords: [testPassword] }) 263 - ) 264 - mockEndpoint('com.atproto.server.revokeAppPassword', (_url, options) => { 265 - const body = JSON.parse((options?.body as string) || '{}') 266 - capturedName = body.name 267 - return jsonResponse({}) 268 - }) 269 - render(AppPasswords) 285 + expect(screen.getByText("TestApp")).toBeInTheDocument(); 286 + }); 287 + await fireEvent.click(screen.getByRole("button", { name: /revoke/i })); 288 + expect(revokeCalled).toBe(false); 289 + }); 290 + it("calls revokeAppPassword with correct name", async () => { 291 + window.confirm = vi.fn(() => true); 292 + let capturedName: string | null = null; 293 + mockEndpoint( 294 + "com.atproto.server.listAppPasswords", 295 + () => jsonResponse({ passwords: [testPassword] }), 296 + ); 297 + mockEndpoint("com.atproto.server.revokeAppPassword", (_url, options) => { 298 + const body = JSON.parse((options?.body as string) || "{}"); 299 + capturedName = body.name; 300 + return jsonResponse({}); 301 + }); 302 + render(AppPasswords); 270 303 await waitFor(() => { 271 - expect(screen.getByText('TestApp')).toBeInTheDocument() 272 - }) 273 - await fireEvent.click(screen.getByRole('button', { name: /revoke/i })) 304 + expect(screen.getByText("TestApp")).toBeInTheDocument(); 305 + }); 306 + await fireEvent.click(screen.getByRole("button", { name: /revoke/i })); 274 307 await waitFor(() => { 275 - expect(capturedName).toBe('TestApp') 276 - }) 277 - }) 278 - it('shows loading state while revoking', async () => { 279 - window.confirm = vi.fn(() => true) 280 - mockEndpoint('com.atproto.server.listAppPasswords', () => 281 - jsonResponse({ passwords: [testPassword] }) 282 - ) 283 - mockEndpoint('com.atproto.server.revokeAppPassword', async () => { 284 - await new Promise(resolve => setTimeout(resolve, 100)) 285 - return jsonResponse({}) 286 - }) 287 - render(AppPasswords) 308 + expect(capturedName).toBe("TestApp"); 309 + }); 310 + }); 311 + it("shows loading state while revoking", async () => { 312 + window.confirm = vi.fn(() => true); 313 + mockEndpoint( 314 + "com.atproto.server.listAppPasswords", 315 + () => jsonResponse({ passwords: [testPassword] }), 316 + ); 317 + mockEndpoint("com.atproto.server.revokeAppPassword", async () => { 318 + await new Promise((resolve) => setTimeout(resolve, 100)); 319 + return jsonResponse({}); 320 + }); 321 + render(AppPasswords); 288 322 await waitFor(() => { 289 - expect(screen.getByText('TestApp')).toBeInTheDocument() 290 - }) 291 - await fireEvent.click(screen.getByRole('button', { name: /revoke/i })) 292 - expect(screen.getByRole('button', { name: /revoking/i })).toBeInTheDocument() 293 - expect(screen.getByRole('button', { name: /revoking/i })).toBeDisabled() 294 - }) 295 - it('reloads password list after successful revocation', async () => { 296 - window.confirm = vi.fn(() => true) 297 - let listCallCount = 0 298 - mockEndpoint('com.atproto.server.listAppPasswords', () => { 299 - listCallCount++ 323 + expect(screen.getByText("TestApp")).toBeInTheDocument(); 324 + }); 325 + await fireEvent.click(screen.getByRole("button", { name: /revoke/i })); 326 + expect(screen.getByRole("button", { name: /revoking/i })) 327 + .toBeInTheDocument(); 328 + expect(screen.getByRole("button", { name: /revoking/i })).toBeDisabled(); 329 + }); 330 + it("reloads password list after successful revocation", async () => { 331 + window.confirm = vi.fn(() => true); 332 + let listCallCount = 0; 333 + mockEndpoint("com.atproto.server.listAppPasswords", () => { 334 + listCallCount++; 300 335 if (listCallCount === 1) { 301 - return jsonResponse({ passwords: [testPassword] }) 336 + return jsonResponse({ passwords: [testPassword] }); 302 337 } 303 - return jsonResponse({ passwords: [] }) 304 - }) 305 - mockEndpoint('com.atproto.server.revokeAppPassword', () => 306 - jsonResponse({}) 307 - ) 308 - render(AppPasswords) 338 + return jsonResponse({ passwords: [] }); 339 + }); 340 + mockEndpoint( 341 + "com.atproto.server.revokeAppPassword", 342 + () => jsonResponse({}), 343 + ); 344 + render(AppPasswords); 309 345 await waitFor(() => { 310 - expect(screen.getByText('TestApp')).toBeInTheDocument() 311 - }) 312 - await fireEvent.click(screen.getByRole('button', { name: /revoke/i })) 346 + expect(screen.getByText("TestApp")).toBeInTheDocument(); 347 + }); 348 + await fireEvent.click(screen.getByRole("button", { name: /revoke/i })); 313 349 await waitFor(() => { 314 - expect(screen.queryByText('TestApp')).not.toBeInTheDocument() 315 - expect(screen.getByText(/no app passwords yet/i)).toBeInTheDocument() 316 - }) 317 - }) 318 - it('shows error when revocation fails', async () => { 319 - window.confirm = vi.fn(() => true) 320 - mockEndpoint('com.atproto.server.listAppPasswords', () => 321 - jsonResponse({ passwords: [testPassword] }) 322 - ) 323 - mockEndpoint('com.atproto.server.revokeAppPassword', () => 324 - errorResponse('InternalError', 'Server error', 500) 325 - ) 326 - render(AppPasswords) 350 + expect(screen.queryByText("TestApp")).not.toBeInTheDocument(); 351 + expect(screen.getByText(/no app passwords yet/i)).toBeInTheDocument(); 352 + }); 353 + }); 354 + it("shows error when revocation fails", async () => { 355 + window.confirm = vi.fn(() => true); 356 + mockEndpoint( 357 + "com.atproto.server.listAppPasswords", 358 + () => jsonResponse({ passwords: [testPassword] }), 359 + ); 360 + mockEndpoint( 361 + "com.atproto.server.revokeAppPassword", 362 + () => errorResponse("InternalError", "Server error", 500), 363 + ); 364 + render(AppPasswords); 327 365 await waitFor(() => { 328 - expect(screen.getByText('TestApp')).toBeInTheDocument() 329 - }) 330 - await fireEvent.click(screen.getByRole('button', { name: /revoke/i })) 366 + expect(screen.getByText("TestApp")).toBeInTheDocument(); 367 + }); 368 + await fireEvent.click(screen.getByRole("button", { name: /revoke/i })); 331 369 await waitFor(() => { 332 - expect(screen.getByText(/server error/i)).toBeInTheDocument() 333 - expect(screen.getByText(/server error/i)).toHaveClass('error') 334 - }) 335 - }) 336 - }) 337 - describe('error handling', () => { 370 + expect(screen.getByText(/server error/i)).toBeInTheDocument(); 371 + expect(screen.getByText(/server error/i)).toHaveClass("error"); 372 + }); 373 + }); 374 + }); 375 + describe("error handling", () => { 338 376 beforeEach(() => { 339 - setupAuthenticatedUser() 340 - }) 341 - it('shows error when loading passwords fails', async () => { 342 - mockEndpoint('com.atproto.server.listAppPasswords', () => 343 - errorResponse('InternalError', 'Database connection failed', 500) 344 - ) 345 - render(AppPasswords) 377 + setupAuthenticatedUser(); 378 + }); 379 + it("shows error when loading passwords fails", async () => { 380 + mockEndpoint( 381 + "com.atproto.server.listAppPasswords", 382 + () => errorResponse("InternalError", "Database connection failed", 500), 383 + ); 384 + render(AppPasswords); 346 385 await waitFor(() => { 347 - expect(screen.getByText(/database connection failed/i)).toBeInTheDocument() 348 - expect(screen.getByText(/database connection failed/i)).toHaveClass('error') 349 - }) 350 - }) 351 - }) 352 - }) 386 + expect(screen.getByText(/database connection failed/i)) 387 + .toBeInTheDocument(); 388 + expect(screen.getByText(/database connection failed/i)).toHaveClass( 389 + "error", 390 + ); 391 + }); 392 + }); 393 + }); 394 + });
+395 -305
frontend/src/tests/Comms.test.ts
··· 1 - import { describe, it, expect, beforeEach } from 'vitest' 2 - import { render, screen, fireEvent, waitFor } from '@testing-library/svelte' 3 - import Comms from '../routes/Comms.svelte' 1 + import { beforeEach, describe, expect, it } from "vitest"; 2 + import { fireEvent, render, screen, waitFor } from "@testing-library/svelte"; 3 + import Comms from "../routes/Comms.svelte"; 4 4 import { 5 - setupFetchMock, 6 - mockEndpoint, 5 + clearMocks, 6 + errorResponse, 7 7 jsonResponse, 8 - errorResponse, 9 8 mockData, 10 - clearMocks, 9 + mockEndpoint, 11 10 setupAuthenticatedUser, 11 + setupFetchMock, 12 12 setupUnauthenticatedUser, 13 - } from './mocks' 14 - describe('Comms', () => { 13 + } from "./mocks"; 14 + describe("Comms", () => { 15 15 beforeEach(() => { 16 - clearMocks() 17 - setupFetchMock() 18 - }) 19 - describe('authentication guard', () => { 20 - it('redirects to login when not authenticated', async () => { 21 - setupUnauthenticatedUser() 22 - render(Comms) 16 + clearMocks(); 17 + setupFetchMock(); 18 + }); 19 + describe("authentication guard", () => { 20 + it("redirects to login when not authenticated", async () => { 21 + setupUnauthenticatedUser(); 22 + render(Comms); 23 23 await waitFor(() => { 24 - expect(window.location.hash).toBe('#/login') 25 - }) 26 - }) 27 - }) 28 - describe('page structure', () => { 24 + expect(window.location.hash).toBe("#/login"); 25 + }); 26 + }); 27 + }); 28 + describe("page structure", () => { 29 29 beforeEach(() => { 30 - setupAuthenticatedUser() 31 - mockEndpoint('com.tranquil.account.getNotificationPrefs', () => 32 - jsonResponse(mockData.notificationPrefs()) 33 - ) 34 - }) 35 - it('displays all page elements and sections', async () => { 36 - render(Comms) 30 + setupAuthenticatedUser(); 31 + mockEndpoint( 32 + "com.tranquil.account.getNotificationPrefs", 33 + () => jsonResponse(mockData.notificationPrefs()), 34 + ); 35 + }); 36 + it("displays all page elements and sections", async () => { 37 + render(Comms); 37 38 await waitFor(() => { 38 - expect(screen.getByRole('heading', { name: /notification preferences/i, level: 1 })).toBeInTheDocument() 39 - expect(screen.getByRole('link', { name: /dashboard/i })).toHaveAttribute('href', '#/dashboard') 40 - expect(screen.getByText(/password resets/i)).toBeInTheDocument() 41 - expect(screen.getByRole('heading', { name: /preferred channel/i })).toBeInTheDocument() 42 - expect(screen.getByRole('heading', { name: /channel configuration/i })).toBeInTheDocument() 43 - }) 44 - }) 45 - }) 46 - describe('loading state', () => { 39 + expect( 40 + screen.getByRole("heading", { 41 + name: /notification preferences/i, 42 + level: 1, 43 + }), 44 + ).toBeInTheDocument(); 45 + expect(screen.getByRole("link", { name: /dashboard/i })) 46 + .toHaveAttribute("href", "#/dashboard"); 47 + expect(screen.getByText(/password resets/i)).toBeInTheDocument(); 48 + expect(screen.getByRole("heading", { name: /preferred channel/i })) 49 + .toBeInTheDocument(); 50 + expect(screen.getByRole("heading", { name: /channel configuration/i })) 51 + .toBeInTheDocument(); 52 + }); 53 + }); 54 + }); 55 + describe("loading state", () => { 47 56 beforeEach(() => { 48 - setupAuthenticatedUser() 49 - }) 50 - it('shows loading text while fetching preferences', async () => { 51 - mockEndpoint('com.tranquil.account.getNotificationPrefs', async () => { 52 - await new Promise(resolve => setTimeout(resolve, 100)) 53 - return jsonResponse(mockData.notificationPrefs()) 54 - }) 55 - render(Comms) 56 - expect(screen.getByText(/loading/i)).toBeInTheDocument() 57 - }) 58 - }) 59 - describe('channel options', () => { 57 + setupAuthenticatedUser(); 58 + }); 59 + it("shows loading text while fetching preferences", async () => { 60 + mockEndpoint("com.tranquil.account.getNotificationPrefs", async () => { 61 + await new Promise((resolve) => setTimeout(resolve, 100)); 62 + return jsonResponse(mockData.notificationPrefs()); 63 + }); 64 + render(Comms); 65 + expect(screen.getByText(/loading/i)).toBeInTheDocument(); 66 + }); 67 + }); 68 + describe("channel options", () => { 60 69 beforeEach(() => { 61 - setupAuthenticatedUser() 62 - }) 63 - it('displays all four channel options', async () => { 64 - mockEndpoint('com.tranquil.account.getNotificationPrefs', () => 65 - jsonResponse(mockData.notificationPrefs()) 66 - ) 67 - render(Comms) 70 + setupAuthenticatedUser(); 71 + }); 72 + it("displays all four channel options", async () => { 73 + mockEndpoint( 74 + "com.tranquil.account.getNotificationPrefs", 75 + () => jsonResponse(mockData.notificationPrefs()), 76 + ); 77 + render(Comms); 68 78 await waitFor(() => { 69 - expect(screen.getByRole('radio', { name: /email/i })).toBeInTheDocument() 70 - expect(screen.getByRole('radio', { name: /discord/i })).toBeInTheDocument() 71 - expect(screen.getByRole('radio', { name: /telegram/i })).toBeInTheDocument() 72 - expect(screen.getByRole('radio', { name: /signal/i })).toBeInTheDocument() 73 - }) 74 - }) 75 - it('email channel is always selectable', async () => { 76 - mockEndpoint('com.tranquil.account.getNotificationPrefs', () => 77 - jsonResponse(mockData.notificationPrefs()) 78 - ) 79 - render(Comms) 79 + expect(screen.getByRole("radio", { name: /email/i })) 80 + .toBeInTheDocument(); 81 + expect(screen.getByRole("radio", { name: /discord/i })) 82 + .toBeInTheDocument(); 83 + expect(screen.getByRole("radio", { name: /telegram/i })) 84 + .toBeInTheDocument(); 85 + expect(screen.getByRole("radio", { name: /signal/i })) 86 + .toBeInTheDocument(); 87 + }); 88 + }); 89 + it("email channel is always selectable", async () => { 90 + mockEndpoint( 91 + "com.tranquil.account.getNotificationPrefs", 92 + () => jsonResponse(mockData.notificationPrefs()), 93 + ); 94 + render(Comms); 80 95 await waitFor(() => { 81 - const emailRadio = screen.getByRole('radio', { name: /email/i }) 82 - expect(emailRadio).not.toBeDisabled() 83 - }) 84 - }) 85 - it('discord channel is disabled when not configured', async () => { 86 - mockEndpoint('com.tranquil.account.getNotificationPrefs', () => 87 - jsonResponse(mockData.notificationPrefs({ discordId: null })) 88 - ) 89 - render(Comms) 96 + const emailRadio = screen.getByRole("radio", { name: /email/i }); 97 + expect(emailRadio).not.toBeDisabled(); 98 + }); 99 + }); 100 + it("discord channel is disabled when not configured", async () => { 101 + mockEndpoint( 102 + "com.tranquil.account.getNotificationPrefs", 103 + () => jsonResponse(mockData.notificationPrefs({ discordId: null })), 104 + ); 105 + render(Comms); 90 106 await waitFor(() => { 91 - const discordRadio = screen.getByRole('radio', { name: /discord/i }) 92 - expect(discordRadio).toBeDisabled() 93 - }) 94 - }) 95 - it('discord channel is enabled when configured', async () => { 96 - mockEndpoint('com.tranquil.account.getNotificationPrefs', () => 97 - jsonResponse(mockData.notificationPrefs({ discordId: '123456789' })) 98 - ) 99 - render(Comms) 107 + const discordRadio = screen.getByRole("radio", { name: /discord/i }); 108 + expect(discordRadio).toBeDisabled(); 109 + }); 110 + }); 111 + it("discord channel is enabled when configured", async () => { 112 + mockEndpoint( 113 + "com.tranquil.account.getNotificationPrefs", 114 + () => 115 + jsonResponse(mockData.notificationPrefs({ discordId: "123456789" })), 116 + ); 117 + render(Comms); 100 118 await waitFor(() => { 101 - const discordRadio = screen.getByRole('radio', { name: /discord/i }) 102 - expect(discordRadio).not.toBeDisabled() 103 - }) 104 - }) 105 - it('shows hint for disabled channels', async () => { 106 - mockEndpoint('com.tranquil.account.getNotificationPrefs', () => 107 - jsonResponse(mockData.notificationPrefs()) 108 - ) 109 - render(Comms) 119 + const discordRadio = screen.getByRole("radio", { name: /discord/i }); 120 + expect(discordRadio).not.toBeDisabled(); 121 + }); 122 + }); 123 + it("shows hint for disabled channels", async () => { 124 + mockEndpoint( 125 + "com.tranquil.account.getNotificationPrefs", 126 + () => jsonResponse(mockData.notificationPrefs()), 127 + ); 128 + render(Comms); 110 129 await waitFor(() => { 111 - expect(screen.getAllByText(/configure below to enable/i).length).toBeGreaterThan(0) 112 - }) 113 - }) 114 - it('selects current preferred channel', async () => { 115 - mockEndpoint('com.tranquil.account.getNotificationPrefs', () => 116 - jsonResponse(mockData.notificationPrefs({ preferredChannel: 'email' })) 117 - ) 118 - render(Comms) 130 + expect(screen.getAllByText(/configure below to enable/i).length) 131 + .toBeGreaterThan(0); 132 + }); 133 + }); 134 + it("selects current preferred channel", async () => { 135 + mockEndpoint( 136 + "com.tranquil.account.getNotificationPrefs", 137 + () => 138 + jsonResponse( 139 + mockData.notificationPrefs({ preferredChannel: "email" }), 140 + ), 141 + ); 142 + render(Comms); 119 143 await waitFor(() => { 120 - const emailRadio = screen.getByRole('radio', { name: /email/i }) as HTMLInputElement 121 - expect(emailRadio.checked).toBe(true) 122 - }) 123 - }) 124 - }) 125 - describe('channel configuration', () => { 144 + const emailRadio = screen.getByRole("radio", { 145 + name: /email/i, 146 + }) as HTMLInputElement; 147 + expect(emailRadio.checked).toBe(true); 148 + }); 149 + }); 150 + }); 151 + describe("channel configuration", () => { 126 152 beforeEach(() => { 127 - setupAuthenticatedUser() 128 - }) 129 - it('displays email as readonly with current value', async () => { 130 - mockEndpoint('com.tranquil.account.getNotificationPrefs', () => 131 - jsonResponse(mockData.notificationPrefs()) 132 - ) 133 - render(Comms) 153 + setupAuthenticatedUser(); 154 + }); 155 + it("displays email as readonly with current value", async () => { 156 + mockEndpoint( 157 + "com.tranquil.account.getNotificationPrefs", 158 + () => jsonResponse(mockData.notificationPrefs()), 159 + ); 160 + render(Comms); 134 161 await waitFor(() => { 135 - const emailInput = screen.getByLabelText(/^email$/i) as HTMLInputElement 136 - expect(emailInput).toBeDisabled() 137 - expect(emailInput.value).toBe('test@example.com') 138 - }) 139 - }) 140 - it('displays all channel inputs with current values', async () => { 141 - mockEndpoint('com.tranquil.account.getNotificationPrefs', () => 142 - jsonResponse(mockData.notificationPrefs({ 143 - discordId: '123456789', 144 - telegramUsername: 'testuser', 145 - signalNumber: '+1234567890', 146 - })) 147 - ) 148 - render(Comms) 162 + const emailInput = screen.getByLabelText( 163 + /^email$/i, 164 + ) as HTMLInputElement; 165 + expect(emailInput).toBeDisabled(); 166 + expect(emailInput.value).toBe("test@example.com"); 167 + }); 168 + }); 169 + it("displays all channel inputs with current values", async () => { 170 + mockEndpoint( 171 + "com.tranquil.account.getNotificationPrefs", 172 + () => 173 + jsonResponse(mockData.notificationPrefs({ 174 + discordId: "123456789", 175 + telegramUsername: "testuser", 176 + signalNumber: "+1234567890", 177 + })), 178 + ); 179 + render(Comms); 149 180 await waitFor(() => { 150 - expect((screen.getByLabelText(/discord user id/i) as HTMLInputElement).value).toBe('123456789') 151 - expect((screen.getByLabelText(/telegram username/i) as HTMLInputElement).value).toBe('testuser') 152 - expect((screen.getByLabelText(/signal phone number/i) as HTMLInputElement).value).toBe('+1234567890') 153 - }) 154 - }) 155 - }) 156 - describe('verification status badges', () => { 181 + expect( 182 + (screen.getByLabelText(/discord user id/i) as HTMLInputElement).value, 183 + ).toBe("123456789"); 184 + expect( 185 + (screen.getByLabelText(/telegram username/i) as HTMLInputElement) 186 + .value, 187 + ).toBe("testuser"); 188 + expect( 189 + (screen.getByLabelText(/signal phone number/i) as HTMLInputElement) 190 + .value, 191 + ).toBe("+1234567890"); 192 + }); 193 + }); 194 + }); 195 + describe("verification status badges", () => { 157 196 beforeEach(() => { 158 - setupAuthenticatedUser() 159 - }) 160 - it('shows Primary badge for email', async () => { 161 - mockEndpoint('com.tranquil.account.getNotificationPrefs', () => 162 - jsonResponse(mockData.notificationPrefs()) 163 - ) 164 - render(Comms) 197 + setupAuthenticatedUser(); 198 + }); 199 + it("shows Primary badge for email", async () => { 200 + mockEndpoint( 201 + "com.tranquil.account.getNotificationPrefs", 202 + () => jsonResponse(mockData.notificationPrefs()), 203 + ); 204 + render(Comms); 165 205 await waitFor(() => { 166 - expect(screen.getByText('Primary')).toBeInTheDocument() 167 - }) 168 - }) 169 - it('shows Verified badge for verified discord', async () => { 170 - mockEndpoint('com.tranquil.account.getNotificationPrefs', () => 171 - jsonResponse(mockData.notificationPrefs({ 172 - discordId: '123456789', 173 - discordVerified: true, 174 - })) 175 - ) 176 - render(Comms) 206 + expect(screen.getByText("Primary")).toBeInTheDocument(); 207 + }); 208 + }); 209 + it("shows Verified badge for verified discord", async () => { 210 + mockEndpoint( 211 + "com.tranquil.account.getNotificationPrefs", 212 + () => 213 + jsonResponse(mockData.notificationPrefs({ 214 + discordId: "123456789", 215 + discordVerified: true, 216 + })), 217 + ); 218 + render(Comms); 177 219 await waitFor(() => { 178 - const verifiedBadges = screen.getAllByText('Verified') 179 - expect(verifiedBadges.length).toBeGreaterThan(0) 180 - }) 181 - }) 182 - it('shows Not verified badge for unverified discord', async () => { 183 - mockEndpoint('com.tranquil.account.getNotificationPrefs', () => 184 - jsonResponse(mockData.notificationPrefs({ 185 - discordId: '123456789', 186 - discordVerified: false, 187 - })) 188 - ) 189 - render(Comms) 220 + const verifiedBadges = screen.getAllByText("Verified"); 221 + expect(verifiedBadges.length).toBeGreaterThan(0); 222 + }); 223 + }); 224 + it("shows Not verified badge for unverified discord", async () => { 225 + mockEndpoint( 226 + "com.tranquil.account.getNotificationPrefs", 227 + () => 228 + jsonResponse(mockData.notificationPrefs({ 229 + discordId: "123456789", 230 + discordVerified: false, 231 + })), 232 + ); 233 + render(Comms); 190 234 await waitFor(() => { 191 - expect(screen.getByText('Not verified')).toBeInTheDocument() 192 - }) 193 - }) 194 - it('does not show badge when channel not configured', async () => { 195 - mockEndpoint('com.tranquil.account.getNotificationPrefs', () => 196 - jsonResponse(mockData.notificationPrefs()) 197 - ) 198 - render(Comms) 235 + expect(screen.getByText("Not verified")).toBeInTheDocument(); 236 + }); 237 + }); 238 + it("does not show badge when channel not configured", async () => { 239 + mockEndpoint( 240 + "com.tranquil.account.getNotificationPrefs", 241 + () => jsonResponse(mockData.notificationPrefs()), 242 + ); 243 + render(Comms); 199 244 await waitFor(() => { 200 - expect(screen.getByText('Primary')).toBeInTheDocument() 201 - expect(screen.queryByText('Not verified')).not.toBeInTheDocument() 202 - }) 203 - }) 204 - }) 205 - describe('save preferences', () => { 245 + expect(screen.getByText("Primary")).toBeInTheDocument(); 246 + expect(screen.queryByText("Not verified")).not.toBeInTheDocument(); 247 + }); 248 + }); 249 + }); 250 + describe("save preferences", () => { 206 251 beforeEach(() => { 207 - setupAuthenticatedUser() 208 - }) 209 - it('calls updateNotificationPrefs with correct data', async () => { 210 - let capturedBody: Record<string, unknown> | null = null 211 - mockEndpoint('com.tranquil.account.getNotificationPrefs', () => 212 - jsonResponse(mockData.notificationPrefs()) 213 - ) 214 - mockEndpoint('com.tranquil.account.updateNotificationPrefs', (_url, options) => { 215 - capturedBody = JSON.parse((options?.body as string) || '{}') 216 - return jsonResponse({ success: true }) 217 - }) 218 - render(Comms) 252 + setupAuthenticatedUser(); 253 + }); 254 + it("calls updateNotificationPrefs with correct data", async () => { 255 + let capturedBody: Record<string, unknown> | null = null; 256 + mockEndpoint( 257 + "com.tranquil.account.getNotificationPrefs", 258 + () => jsonResponse(mockData.notificationPrefs()), 259 + ); 260 + mockEndpoint( 261 + "com.tranquil.account.updateNotificationPrefs", 262 + (_url, options) => { 263 + capturedBody = JSON.parse((options?.body as string) || "{}"); 264 + return jsonResponse({ success: true }); 265 + }, 266 + ); 267 + render(Comms); 219 268 await waitFor(() => { 220 - expect(screen.getByLabelText(/discord user id/i)).toBeInTheDocument() 221 - }) 222 - await fireEvent.input(screen.getByLabelText(/discord user id/i), { target: { value: '999888777' } }) 223 - await fireEvent.click(screen.getByRole('button', { name: /save preferences/i })) 269 + expect(screen.getByLabelText(/discord user id/i)).toBeInTheDocument(); 270 + }); 271 + await fireEvent.input(screen.getByLabelText(/discord user id/i), { 272 + target: { value: "999888777" }, 273 + }); 274 + await fireEvent.click( 275 + screen.getByRole("button", { name: /save preferences/i }), 276 + ); 224 277 await waitFor(() => { 225 - expect(capturedBody).not.toBeNull() 226 - expect(capturedBody?.discordId).toBe('999888777') 227 - expect(capturedBody?.preferredChannel).toBe('email') 228 - }) 229 - }) 230 - it('shows loading state while saving', async () => { 231 - mockEndpoint('com.tranquil.account.getNotificationPrefs', () => 232 - jsonResponse(mockData.notificationPrefs()) 233 - ) 234 - mockEndpoint('com.tranquil.account.updateNotificationPrefs', async () => { 235 - await new Promise(resolve => setTimeout(resolve, 100)) 236 - return jsonResponse({ success: true }) 237 - }) 238 - render(Comms) 278 + expect(capturedBody).not.toBeNull(); 279 + expect(capturedBody?.discordId).toBe("999888777"); 280 + expect(capturedBody?.preferredChannel).toBe("email"); 281 + }); 282 + }); 283 + it("shows loading state while saving", async () => { 284 + mockEndpoint( 285 + "com.tranquil.account.getNotificationPrefs", 286 + () => jsonResponse(mockData.notificationPrefs()), 287 + ); 288 + mockEndpoint("com.tranquil.account.updateNotificationPrefs", async () => { 289 + await new Promise((resolve) => setTimeout(resolve, 100)); 290 + return jsonResponse({ success: true }); 291 + }); 292 + render(Comms); 239 293 await waitFor(() => { 240 - expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument() 241 - }) 242 - await fireEvent.click(screen.getByRole('button', { name: /save preferences/i })) 243 - expect(screen.getByRole('button', { name: /saving/i })).toBeInTheDocument() 244 - expect(screen.getByRole('button', { name: /saving/i })).toBeDisabled() 245 - }) 246 - it('shows success message after saving', async () => { 247 - mockEndpoint('com.tranquil.account.getNotificationPrefs', () => 248 - jsonResponse(mockData.notificationPrefs()) 249 - ) 250 - mockEndpoint('com.tranquil.account.updateNotificationPrefs', () => 251 - jsonResponse({ success: true }) 252 - ) 253 - render(Comms) 294 + expect(screen.getByRole("button", { name: /save preferences/i })) 295 + .toBeInTheDocument(); 296 + }); 297 + await fireEvent.click( 298 + screen.getByRole("button", { name: /save preferences/i }), 299 + ); 300 + expect(screen.getByRole("button", { name: /saving/i })) 301 + .toBeInTheDocument(); 302 + expect(screen.getByRole("button", { name: /saving/i })).toBeDisabled(); 303 + }); 304 + it("shows success message after saving", async () => { 305 + mockEndpoint( 306 + "com.tranquil.account.getNotificationPrefs", 307 + () => jsonResponse(mockData.notificationPrefs()), 308 + ); 309 + mockEndpoint( 310 + "com.tranquil.account.updateNotificationPrefs", 311 + () => jsonResponse({ success: true }), 312 + ); 313 + render(Comms); 254 314 await waitFor(() => { 255 - expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument() 256 - }) 257 - await fireEvent.click(screen.getByRole('button', { name: /save preferences/i })) 315 + expect(screen.getByRole("button", { name: /save preferences/i })) 316 + .toBeInTheDocument(); 317 + }); 318 + await fireEvent.click( 319 + screen.getByRole("button", { name: /save preferences/i }), 320 + ); 258 321 await waitFor(() => { 259 - expect(screen.getByText(/notification preferences saved/i)).toBeInTheDocument() 260 - }) 261 - }) 262 - it('shows error when save fails', async () => { 263 - mockEndpoint('com.tranquil.account.getNotificationPrefs', () => 264 - jsonResponse(mockData.notificationPrefs()) 265 - ) 266 - mockEndpoint('com.tranquil.account.updateNotificationPrefs', () => 267 - errorResponse('InvalidRequest', 'Invalid channel configuration', 400) 268 - ) 269 - render(Comms) 322 + expect(screen.getByText(/notification preferences saved/i)) 323 + .toBeInTheDocument(); 324 + }); 325 + }); 326 + it("shows error when save fails", async () => { 327 + mockEndpoint( 328 + "com.tranquil.account.getNotificationPrefs", 329 + () => jsonResponse(mockData.notificationPrefs()), 330 + ); 331 + mockEndpoint( 332 + "com.tranquil.account.updateNotificationPrefs", 333 + () => 334 + errorResponse("InvalidRequest", "Invalid channel configuration", 400), 335 + ); 336 + render(Comms); 270 337 await waitFor(() => { 271 - expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument() 272 - }) 273 - await fireEvent.click(screen.getByRole('button', { name: /save preferences/i })) 338 + expect(screen.getByRole("button", { name: /save preferences/i })) 339 + .toBeInTheDocument(); 340 + }); 341 + await fireEvent.click( 342 + screen.getByRole("button", { name: /save preferences/i }), 343 + ); 274 344 await waitFor(() => { 275 - expect(screen.getByText(/invalid channel configuration/i)).toBeInTheDocument() 276 - expect(screen.getByText(/invalid channel configuration/i).closest('.message')).toHaveClass('error') 277 - }) 278 - }) 279 - it('reloads preferences after successful save', async () => { 280 - let loadCount = 0 281 - mockEndpoint('com.tranquil.account.getNotificationPrefs', () => { 282 - loadCount++ 283 - return jsonResponse(mockData.notificationPrefs()) 284 - }) 285 - mockEndpoint('com.tranquil.account.updateNotificationPrefs', () => 286 - jsonResponse({ success: true }) 287 - ) 288 - render(Comms) 345 + expect(screen.getByText(/invalid channel configuration/i)) 346 + .toBeInTheDocument(); 347 + expect( 348 + screen.getByText(/invalid channel configuration/i).closest( 349 + ".message", 350 + ), 351 + ).toHaveClass("error"); 352 + }); 353 + }); 354 + it("reloads preferences after successful save", async () => { 355 + let loadCount = 0; 356 + mockEndpoint("com.tranquil.account.getNotificationPrefs", () => { 357 + loadCount++; 358 + return jsonResponse(mockData.notificationPrefs()); 359 + }); 360 + mockEndpoint( 361 + "com.tranquil.account.updateNotificationPrefs", 362 + () => jsonResponse({ success: true }), 363 + ); 364 + render(Comms); 289 365 await waitFor(() => { 290 - expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument() 291 - }) 292 - const initialLoadCount = loadCount 293 - await fireEvent.click(screen.getByRole('button', { name: /save preferences/i })) 366 + expect(screen.getByRole("button", { name: /save preferences/i })) 367 + .toBeInTheDocument(); 368 + }); 369 + const initialLoadCount = loadCount; 370 + await fireEvent.click( 371 + screen.getByRole("button", { name: /save preferences/i }), 372 + ); 294 373 await waitFor(() => { 295 - expect(loadCount).toBeGreaterThan(initialLoadCount) 296 - }) 297 - }) 298 - }) 299 - describe('channel selection interaction', () => { 374 + expect(loadCount).toBeGreaterThan(initialLoadCount); 375 + }); 376 + }); 377 + }); 378 + describe("channel selection interaction", () => { 300 379 beforeEach(() => { 301 - setupAuthenticatedUser() 302 - }) 303 - it('enables discord channel after entering discord ID', async () => { 304 - mockEndpoint('com.tranquil.account.getNotificationPrefs', () => 305 - jsonResponse(mockData.notificationPrefs()) 306 - ) 307 - render(Comms) 380 + setupAuthenticatedUser(); 381 + }); 382 + it("enables discord channel after entering discord ID", async () => { 383 + mockEndpoint( 384 + "com.tranquil.account.getNotificationPrefs", 385 + () => jsonResponse(mockData.notificationPrefs()), 386 + ); 387 + render(Comms); 308 388 await waitFor(() => { 309 - expect(screen.getByRole('radio', { name: /discord/i })).toBeDisabled() 310 - }) 311 - await fireEvent.input(screen.getByLabelText(/discord user id/i), { target: { value: '123456789' } }) 389 + expect(screen.getByRole("radio", { name: /discord/i })).toBeDisabled(); 390 + }); 391 + await fireEvent.input(screen.getByLabelText(/discord user id/i), { 392 + target: { value: "123456789" }, 393 + }); 312 394 await waitFor(() => { 313 - expect(screen.getByRole('radio', { name: /discord/i })).not.toBeDisabled() 314 - }) 315 - }) 316 - it('allows selecting a configured channel', async () => { 317 - mockEndpoint('com.tranquil.account.getNotificationPrefs', () => 318 - jsonResponse(mockData.notificationPrefs({ 319 - discordId: '123456789', 320 - discordVerified: true, 321 - })) 322 - ) 323 - render(Comms) 395 + expect(screen.getByRole("radio", { name: /discord/i })).not 396 + .toBeDisabled(); 397 + }); 398 + }); 399 + it("allows selecting a configured channel", async () => { 400 + mockEndpoint( 401 + "com.tranquil.account.getNotificationPrefs", 402 + () => 403 + jsonResponse(mockData.notificationPrefs({ 404 + discordId: "123456789", 405 + discordVerified: true, 406 + })), 407 + ); 408 + render(Comms); 324 409 await waitFor(() => { 325 - expect(screen.getByRole('radio', { name: /discord/i })).not.toBeDisabled() 326 - }) 327 - await fireEvent.click(screen.getByRole('radio', { name: /discord/i })) 328 - const discordRadio = screen.getByRole('radio', { name: /discord/i }) as HTMLInputElement 329 - expect(discordRadio.checked).toBe(true) 330 - }) 331 - }) 332 - describe('error handling', () => { 410 + expect(screen.getByRole("radio", { name: /discord/i })).not 411 + .toBeDisabled(); 412 + }); 413 + await fireEvent.click(screen.getByRole("radio", { name: /discord/i })); 414 + const discordRadio = screen.getByRole("radio", { 415 + name: /discord/i, 416 + }) as HTMLInputElement; 417 + expect(discordRadio.checked).toBe(true); 418 + }); 419 + }); 420 + describe("error handling", () => { 333 421 beforeEach(() => { 334 - setupAuthenticatedUser() 335 - }) 336 - it('shows error when loading preferences fails', async () => { 337 - mockEndpoint('com.tranquil.account.getNotificationPrefs', () => 338 - errorResponse('InternalError', 'Database connection failed', 500) 339 - ) 340 - render(Comms) 422 + setupAuthenticatedUser(); 423 + }); 424 + it("shows error when loading preferences fails", async () => { 425 + mockEndpoint( 426 + "com.tranquil.account.getNotificationPrefs", 427 + () => errorResponse("InternalError", "Database connection failed", 500), 428 + ); 429 + render(Comms); 341 430 await waitFor(() => { 342 - expect(screen.getByText(/database connection failed/i)).toBeInTheDocument() 343 - }) 344 - }) 345 - }) 346 - }) 431 + expect(screen.getByText(/database connection failed/i)) 432 + .toBeInTheDocument(); 433 + }); 434 + }); 435 + }); 436 + });
+96 -92
frontend/src/tests/Dashboard.test.ts
··· 1 - import { describe, it, expect, beforeEach } from 'vitest' 2 - import { render, screen, fireEvent, waitFor } from '@testing-library/svelte' 3 - import Dashboard from '../routes/Dashboard.svelte' 1 + import { beforeEach, describe, expect, it } from "vitest"; 2 + import { fireEvent, render, screen, waitFor } from "@testing-library/svelte"; 3 + import Dashboard from "../routes/Dashboard.svelte"; 4 4 import { 5 - setupFetchMock, 6 - mockEndpoint, 5 + clearMocks, 7 6 jsonResponse, 8 7 mockData, 9 - clearMocks, 8 + mockEndpoint, 10 9 setupAuthenticatedUser, 10 + setupFetchMock, 11 11 setupUnauthenticatedUser, 12 - } from './mocks' 13 - const STORAGE_KEY = 'tranquil_pds_session' 14 - describe('Dashboard', () => { 12 + } from "./mocks"; 13 + const STORAGE_KEY = "tranquil_pds_session"; 14 + describe("Dashboard", () => { 15 15 beforeEach(() => { 16 - clearMocks() 17 - setupFetchMock() 18 - }) 19 - describe('authentication guard', () => { 20 - it('redirects to login when not authenticated', async () => { 21 - setupUnauthenticatedUser() 22 - render(Dashboard) 16 + clearMocks(); 17 + setupFetchMock(); 18 + }); 19 + describe("authentication guard", () => { 20 + it("redirects to login when not authenticated", async () => { 21 + setupUnauthenticatedUser(); 22 + render(Dashboard); 23 23 await waitFor(() => { 24 - expect(window.location.hash).toBe('#/login') 25 - }) 26 - }) 27 - it('shows loading state while checking auth', () => { 28 - render(Dashboard) 29 - expect(screen.getByText(/loading/i)).toBeInTheDocument() 30 - }) 31 - }) 32 - describe('authenticated view', () => { 24 + expect(window.location.hash).toBe("#/login"); 25 + }); 26 + }); 27 + it("shows loading state while checking auth", () => { 28 + render(Dashboard); 29 + expect(screen.getByText(/loading/i)).toBeInTheDocument(); 30 + }); 31 + }); 32 + describe("authenticated view", () => { 33 33 beforeEach(() => { 34 - setupAuthenticatedUser() 35 - }) 36 - it('displays user account info and page structure', async () => { 37 - render(Dashboard) 34 + setupAuthenticatedUser(); 35 + }); 36 + it("displays user account info and page structure", async () => { 37 + render(Dashboard); 38 38 await waitFor(() => { 39 - expect(screen.getByRole('heading', { name: /dashboard/i })).toBeInTheDocument() 40 - expect(screen.getByRole('heading', { name: /account overview/i })).toBeInTheDocument() 41 - expect(screen.getByText(/@testuser\.test\.tranquil\.dev/)).toBeInTheDocument() 42 - expect(screen.getByText(/did:web:test\.tranquil\.dev:u:testuser/)).toBeInTheDocument() 43 - expect(screen.getByText('test@example.com')).toBeInTheDocument() 44 - expect(screen.getByText('Verified')).toBeInTheDocument() 45 - expect(screen.getByText('Verified')).toHaveClass('badge', 'success') 46 - }) 47 - }) 48 - it('displays unverified badge when email not confirmed', async () => { 49 - setupAuthenticatedUser({ emailConfirmed: false }) 50 - render(Dashboard) 39 + expect(screen.getByRole("heading", { name: /dashboard/i })) 40 + .toBeInTheDocument(); 41 + expect(screen.getByRole("heading", { name: /account overview/i })) 42 + .toBeInTheDocument(); 43 + expect(screen.getByText(/@testuser\.test\.tranquil\.dev/)) 44 + .toBeInTheDocument(); 45 + expect(screen.getByText(/did:web:test\.tranquil\.dev:u:testuser/)) 46 + .toBeInTheDocument(); 47 + expect(screen.getByText("test@example.com")).toBeInTheDocument(); 48 + expect(screen.getByText("Verified")).toBeInTheDocument(); 49 + expect(screen.getByText("Verified")).toHaveClass("badge", "success"); 50 + }); 51 + }); 52 + it("displays unverified badge when email not confirmed", async () => { 53 + setupAuthenticatedUser({ emailConfirmed: false }); 54 + render(Dashboard); 51 55 await waitFor(() => { 52 - expect(screen.getByText('Unverified')).toBeInTheDocument() 53 - expect(screen.getByText('Unverified')).toHaveClass('badge', 'warning') 54 - }) 55 - }) 56 - it('displays all navigation cards', async () => { 57 - render(Dashboard) 56 + expect(screen.getByText("Unverified")).toBeInTheDocument(); 57 + expect(screen.getByText("Unverified")).toHaveClass("badge", "warning"); 58 + }); 59 + }); 60 + it("displays all navigation cards", async () => { 61 + render(Dashboard); 58 62 await waitFor(() => { 59 63 const navCards = [ 60 - { name: /app passwords/i, href: '#/app-passwords' }, 61 - { name: /invite codes/i, href: '#/invite-codes' }, 62 - { name: /account settings/i, href: '#/settings' }, 63 - { name: /communication preferences/i, href: '#/comms' }, 64 - { name: /repository explorer/i, href: '#/repo' }, 65 - ] 64 + { name: /app passwords/i, href: "#/app-passwords" }, 65 + { name: /invite codes/i, href: "#/invite-codes" }, 66 + { name: /account settings/i, href: "#/settings" }, 67 + { name: /communication preferences/i, href: "#/comms" }, 68 + { name: /repository explorer/i, href: "#/repo" }, 69 + ]; 66 70 for (const { name, href } of navCards) { 67 - const card = screen.getByRole('link', { name }) 68 - expect(card).toBeInTheDocument() 69 - expect(card).toHaveAttribute('href', href) 71 + const card = screen.getByRole("link", { name }); 72 + expect(card).toBeInTheDocument(); 73 + expect(card).toHaveAttribute("href", href); 70 74 } 71 - }) 72 - }) 73 - }) 74 - describe('logout functionality', () => { 75 + }); 76 + }); 77 + }); 78 + describe("logout functionality", () => { 75 79 beforeEach(() => { 76 - setupAuthenticatedUser() 77 - localStorage.setItem(STORAGE_KEY, JSON.stringify(mockData.session())) 78 - mockEndpoint('com.atproto.server.deleteSession', () => 79 - jsonResponse({}) 80 - ) 81 - }) 82 - it('calls deleteSession and navigates to login on logout', async () => { 83 - let deleteSessionCalled = false 84 - mockEndpoint('com.atproto.server.deleteSession', () => { 85 - deleteSessionCalled = true 86 - return jsonResponse({}) 87 - }) 88 - render(Dashboard) 80 + setupAuthenticatedUser(); 81 + localStorage.setItem(STORAGE_KEY, JSON.stringify(mockData.session())); 82 + mockEndpoint("com.atproto.server.deleteSession", () => jsonResponse({})); 83 + }); 84 + it("calls deleteSession and navigates to login on logout", async () => { 85 + let deleteSessionCalled = false; 86 + mockEndpoint("com.atproto.server.deleteSession", () => { 87 + deleteSessionCalled = true; 88 + return jsonResponse({}); 89 + }); 90 + render(Dashboard); 89 91 await waitFor(() => { 90 - expect(screen.getByRole('button', { name: /sign out/i })).toBeInTheDocument() 91 - }) 92 - await fireEvent.click(screen.getByRole('button', { name: /sign out/i })) 92 + expect(screen.getByRole("button", { name: /sign out/i })) 93 + .toBeInTheDocument(); 94 + }); 95 + await fireEvent.click(screen.getByRole("button", { name: /sign out/i })); 93 96 await waitFor(() => { 94 - expect(deleteSessionCalled).toBe(true) 95 - expect(window.location.hash).toBe('#/login') 96 - }) 97 - }) 98 - it('clears session from localStorage after logout', async () => { 99 - const storedSession = localStorage.getItem(STORAGE_KEY) 100 - expect(storedSession).not.toBeNull() 101 - render(Dashboard) 97 + expect(deleteSessionCalled).toBe(true); 98 + expect(window.location.hash).toBe("#/login"); 99 + }); 100 + }); 101 + it("clears session from localStorage after logout", async () => { 102 + const storedSession = localStorage.getItem(STORAGE_KEY); 103 + expect(storedSession).not.toBeNull(); 104 + render(Dashboard); 102 105 await waitFor(() => { 103 - expect(screen.getByRole('button', { name: /sign out/i })).toBeInTheDocument() 104 - }) 105 - await fireEvent.click(screen.getByRole('button', { name: /sign out/i })) 106 + expect(screen.getByRole("button", { name: /sign out/i })) 107 + .toBeInTheDocument(); 108 + }); 109 + await fireEvent.click(screen.getByRole("button", { name: /sign out/i })); 106 110 await waitFor(() => { 107 - expect(localStorage.getItem(STORAGE_KEY)).toBeNull() 108 - }) 109 - }) 110 - }) 111 - }) 111 + expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); 112 + }); 113 + }); 114 + }); 115 + });
+159 -117
frontend/src/tests/Login.test.ts
··· 1 - import { describe, it, expect, beforeEach } from 'vitest' 2 - import { render, screen, fireEvent, waitFor } from '@testing-library/svelte' 3 - import Login from '../routes/Login.svelte' 1 + import { beforeEach, describe, expect, it } from "vitest"; 2 + import { fireEvent, render, screen, waitFor } from "@testing-library/svelte"; 3 + import Login from "../routes/Login.svelte"; 4 4 import { 5 - setupFetchMock, 6 - mockEndpoint, 7 - jsonResponse, 5 + clearMocks, 8 6 errorResponse, 7 + jsonResponse, 9 8 mockData, 10 - clearMocks, 11 - } from './mocks' 12 - describe('Login', () => { 9 + mockEndpoint, 10 + setupFetchMock, 11 + } from "./mocks"; 12 + describe("Login", () => { 13 13 beforeEach(() => { 14 - clearMocks() 15 - setupFetchMock() 16 - window.location.hash = '' 17 - }) 18 - describe('initial render', () => { 19 - it('renders login form with all elements and correct initial state', () => { 20 - render(Login) 21 - expect(screen.getByRole('heading', { name: /sign in/i })).toBeInTheDocument() 22 - expect(screen.getByLabelText(/handle or email/i)).toBeInTheDocument() 23 - expect(screen.getByLabelText(/password/i)).toBeInTheDocument() 24 - expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument() 25 - expect(screen.getByRole('button', { name: /sign in/i })).toBeDisabled() 26 - expect(screen.getByText(/don't have an account/i)).toBeInTheDocument() 27 - expect(screen.getByRole('link', { name: /create one/i })).toHaveAttribute('href', '#/register') 28 - }) 29 - }) 30 - describe('form validation', () => { 31 - it('enables submit button only when both fields are filled', async () => { 32 - render(Login) 33 - const identifierInput = screen.getByLabelText(/handle or email/i) 34 - const passwordInput = screen.getByLabelText(/password/i) 35 - const submitButton = screen.getByRole('button', { name: /sign in/i }) 36 - await fireEvent.input(identifierInput, { target: { value: 'testuser' } }) 37 - expect(submitButton).toBeDisabled() 38 - await fireEvent.input(identifierInput, { target: { value: '' } }) 39 - await fireEvent.input(passwordInput, { target: { value: 'password123' } }) 40 - expect(submitButton).toBeDisabled() 41 - await fireEvent.input(identifierInput, { target: { value: 'testuser' } }) 42 - expect(submitButton).not.toBeDisabled() 43 - }) 44 - }) 45 - describe('login submission', () => { 46 - it('calls createSession with correct credentials', async () => { 47 - let capturedBody: Record<string, string> | null = null 48 - mockEndpoint('com.atproto.server.createSession', (_url, options) => { 49 - capturedBody = JSON.parse((options?.body as string) || '{}') 50 - return jsonResponse(mockData.session()) 51 - }) 52 - render(Login) 53 - await fireEvent.input(screen.getByLabelText(/handle or email/i), { target: { value: 'testuser@example.com' } }) 54 - await fireEvent.input(screen.getByLabelText(/password/i), { target: { value: 'mypassword' } }) 55 - await fireEvent.click(screen.getByRole('button', { name: /sign in/i })) 14 + clearMocks(); 15 + setupFetchMock(); 16 + window.location.hash = ""; 17 + }); 18 + describe("initial render", () => { 19 + it("renders login form with all elements and correct initial state", () => { 20 + render(Login); 21 + expect(screen.getByRole("heading", { name: /sign in/i })) 22 + .toBeInTheDocument(); 23 + expect(screen.getByLabelText(/handle or email/i)).toBeInTheDocument(); 24 + expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); 25 + expect(screen.getByRole("button", { name: /sign in/i })) 26 + .toBeInTheDocument(); 27 + expect(screen.getByRole("button", { name: /sign in/i })).toBeDisabled(); 28 + expect(screen.getByText(/don't have an account/i)).toBeInTheDocument(); 29 + expect(screen.getByRole("link", { name: /create one/i })).toHaveAttribute( 30 + "href", 31 + "#/register", 32 + ); 33 + }); 34 + }); 35 + describe("form validation", () => { 36 + it("enables submit button only when both fields are filled", async () => { 37 + render(Login); 38 + const identifierInput = screen.getByLabelText(/handle or email/i); 39 + const passwordInput = screen.getByLabelText(/password/i); 40 + const submitButton = screen.getByRole("button", { name: /sign in/i }); 41 + await fireEvent.input(identifierInput, { target: { value: "testuser" } }); 42 + expect(submitButton).toBeDisabled(); 43 + await fireEvent.input(identifierInput, { target: { value: "" } }); 44 + await fireEvent.input(passwordInput, { 45 + target: { value: "password123" }, 46 + }); 47 + expect(submitButton).toBeDisabled(); 48 + await fireEvent.input(identifierInput, { target: { value: "testuser" } }); 49 + expect(submitButton).not.toBeDisabled(); 50 + }); 51 + }); 52 + describe("login submission", () => { 53 + it("calls createSession with correct credentials", async () => { 54 + let capturedBody: Record<string, string> | null = null; 55 + mockEndpoint("com.atproto.server.createSession", (_url, options) => { 56 + capturedBody = JSON.parse((options?.body as string) || "{}"); 57 + return jsonResponse(mockData.session()); 58 + }); 59 + render(Login); 60 + await fireEvent.input(screen.getByLabelText(/handle or email/i), { 61 + target: { value: "testuser@example.com" }, 62 + }); 63 + await fireEvent.input(screen.getByLabelText(/password/i), { 64 + target: { value: "mypassword" }, 65 + }); 66 + await fireEvent.click(screen.getByRole("button", { name: /sign in/i })); 56 67 await waitFor(() => { 57 68 expect(capturedBody).toEqual({ 58 - identifier: 'testuser@example.com', 59 - password: 'mypassword', 60 - }) 61 - }) 62 - }) 63 - it('shows styled error message on invalid credentials', async () => { 64 - mockEndpoint('com.atproto.server.createSession', () => 65 - errorResponse('AuthenticationRequired', 'Invalid identifier or password', 401) 66 - ) 67 - render(Login) 68 - await fireEvent.input(screen.getByLabelText(/handle or email/i), { target: { value: 'wronguser' } }) 69 - await fireEvent.input(screen.getByLabelText(/password/i), { target: { value: 'wrongpassword' } }) 70 - await fireEvent.click(screen.getByRole('button', { name: /sign in/i })) 69 + identifier: "testuser@example.com", 70 + password: "mypassword", 71 + }); 72 + }); 73 + }); 74 + it("shows styled error message on invalid credentials", async () => { 75 + mockEndpoint( 76 + "com.atproto.server.createSession", 77 + () => 78 + errorResponse( 79 + "AuthenticationRequired", 80 + "Invalid identifier or password", 81 + 401, 82 + ), 83 + ); 84 + render(Login); 85 + await fireEvent.input(screen.getByLabelText(/handle or email/i), { 86 + target: { value: "wronguser" }, 87 + }); 88 + await fireEvent.input(screen.getByLabelText(/password/i), { 89 + target: { value: "wrongpassword" }, 90 + }); 91 + await fireEvent.click(screen.getByRole("button", { name: /sign in/i })); 71 92 await waitFor(() => { 72 - const errorDiv = screen.getByText(/invalid identifier or password/i) 73 - expect(errorDiv).toBeInTheDocument() 74 - expect(errorDiv).toHaveClass('error') 75 - }) 76 - }) 77 - it('navigates to dashboard on successful login', async () => { 78 - mockEndpoint('com.atproto.server.createSession', () => 79 - jsonResponse(mockData.session()) 80 - ) 81 - render(Login) 82 - await fireEvent.input(screen.getByLabelText(/handle or email/i), { target: { value: 'test' } }) 83 - await fireEvent.input(screen.getByLabelText(/password/i), { target: { value: 'password' } }) 84 - await fireEvent.click(screen.getByRole('button', { name: /sign in/i })) 93 + const errorDiv = screen.getByText(/invalid identifier or password/i); 94 + expect(errorDiv).toBeInTheDocument(); 95 + expect(errorDiv).toHaveClass("error"); 96 + }); 97 + }); 98 + it("navigates to dashboard on successful login", async () => { 99 + mockEndpoint( 100 + "com.atproto.server.createSession", 101 + () => jsonResponse(mockData.session()), 102 + ); 103 + render(Login); 104 + await fireEvent.input(screen.getByLabelText(/handle or email/i), { 105 + target: { value: "test" }, 106 + }); 107 + await fireEvent.input(screen.getByLabelText(/password/i), { 108 + target: { value: "password" }, 109 + }); 110 + await fireEvent.click(screen.getByRole("button", { name: /sign in/i })); 85 111 await waitFor(() => { 86 - expect(window.location.hash).toBe('#/dashboard') 87 - }) 88 - }) 89 - }) 90 - describe('account verification flow', () => { 91 - it('shows verification form with all controls when account is not verified', async () => { 92 - mockEndpoint('com.atproto.server.createSession', () => ({ 112 + expect(window.location.hash).toBe("#/dashboard"); 113 + }); 114 + }); 115 + }); 116 + describe("account verification flow", () => { 117 + it("shows verification form with all controls when account is not verified", async () => { 118 + mockEndpoint("com.atproto.server.createSession", () => ({ 93 119 ok: false, 94 120 status: 401, 95 121 json: async () => ({ 96 - error: 'AccountNotVerified', 97 - message: 'Account not verified', 98 - did: 'did:web:test.tranquil.dev:u:testuser', 122 + error: "AccountNotVerified", 123 + message: "Account not verified", 124 + did: "did:web:test.tranquil.dev:u:testuser", 99 125 }), 100 - })) 101 - render(Login) 102 - await fireEvent.input(screen.getByLabelText(/handle or email/i), { target: { value: 'unverified@test.com' } }) 103 - await fireEvent.input(screen.getByLabelText(/password/i), { target: { value: 'password' } }) 104 - await fireEvent.click(screen.getByRole('button', { name: /sign in/i })) 126 + })); 127 + render(Login); 128 + await fireEvent.input(screen.getByLabelText(/handle or email/i), { 129 + target: { value: "unverified@test.com" }, 130 + }); 131 + await fireEvent.input(screen.getByLabelText(/password/i), { 132 + target: { value: "password" }, 133 + }); 134 + await fireEvent.click(screen.getByRole("button", { name: /sign in/i })); 105 135 await waitFor(() => { 106 - expect(screen.getByRole('heading', { name: /verify your account/i })).toBeInTheDocument() 107 - expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument() 108 - expect(screen.getByRole('button', { name: /resend code/i })).toBeInTheDocument() 109 - expect(screen.getByRole('button', { name: /back to login/i })).toBeInTheDocument() 110 - }) 111 - }) 112 - it('returns to login form when clicking back', async () => { 113 - mockEndpoint('com.atproto.server.createSession', () => ({ 136 + expect(screen.getByRole("heading", { name: /verify your account/i })) 137 + .toBeInTheDocument(); 138 + expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument(); 139 + expect(screen.getByRole("button", { name: /resend code/i })) 140 + .toBeInTheDocument(); 141 + expect(screen.getByRole("button", { name: /back to login/i })) 142 + .toBeInTheDocument(); 143 + }); 144 + }); 145 + it("returns to login form when clicking back", async () => { 146 + mockEndpoint("com.atproto.server.createSession", () => ({ 114 147 ok: false, 115 148 status: 401, 116 149 json: async () => ({ 117 - error: 'AccountNotVerified', 118 - message: 'Account not verified', 119 - did: 'did:web:test.tranquil.dev:u:testuser', 150 + error: "AccountNotVerified", 151 + message: "Account not verified", 152 + did: "did:web:test.tranquil.dev:u:testuser", 120 153 }), 121 - })) 122 - render(Login) 123 - await fireEvent.input(screen.getByLabelText(/handle or email/i), { target: { value: 'test' } }) 124 - await fireEvent.input(screen.getByLabelText(/password/i), { target: { value: 'password' } }) 125 - await fireEvent.click(screen.getByRole('button', { name: /sign in/i })) 154 + })); 155 + render(Login); 156 + await fireEvent.input(screen.getByLabelText(/handle or email/i), { 157 + target: { value: "test" }, 158 + }); 159 + await fireEvent.input(screen.getByLabelText(/password/i), { 160 + target: { value: "password" }, 161 + }); 162 + await fireEvent.click(screen.getByRole("button", { name: /sign in/i })); 126 163 await waitFor(() => { 127 - expect(screen.getByRole('button', { name: /back to login/i })).toBeInTheDocument() 128 - }) 129 - await fireEvent.click(screen.getByRole('button', { name: /back to login/i })) 164 + expect(screen.getByRole("button", { name: /back to login/i })) 165 + .toBeInTheDocument(); 166 + }); 167 + await fireEvent.click( 168 + screen.getByRole("button", { name: /back to login/i }), 169 + ); 130 170 await waitFor(() => { 131 - expect(screen.getByRole('heading', { name: /sign in/i })).toBeInTheDocument() 132 - expect(screen.queryByLabelText(/verification code/i)).not.toBeInTheDocument() 133 - }) 134 - }) 135 - }) 136 - }) 171 + expect(screen.getByRole("heading", { name: /sign in/i })) 172 + .toBeInTheDocument(); 173 + expect(screen.queryByLabelText(/verification code/i)).not 174 + .toBeInTheDocument(); 175 + }); 176 + }); 177 + }); 178 + });
+462 -333
frontend/src/tests/Settings.test.ts
··· 1 - import { describe, it, expect, beforeEach, vi } from 'vitest' 2 - import { render, screen, fireEvent, waitFor } from '@testing-library/svelte' 3 - import Settings from '../routes/Settings.svelte' 1 + import { beforeEach, describe, expect, it, vi } from "vitest"; 2 + import { fireEvent, render, screen, waitFor } from "@testing-library/svelte"; 3 + import Settings from "../routes/Settings.svelte"; 4 4 import { 5 - setupFetchMock, 6 - mockEndpoint, 7 - jsonResponse, 5 + clearMocks, 8 6 errorResponse, 9 - clearMocks, 7 + jsonResponse, 8 + mockEndpoint, 10 9 setupAuthenticatedUser, 10 + setupFetchMock, 11 11 setupUnauthenticatedUser, 12 - } from './mocks' 13 - describe('Settings', () => { 12 + } from "./mocks"; 13 + describe("Settings", () => { 14 14 beforeEach(() => { 15 - clearMocks() 16 - setupFetchMock() 17 - window.confirm = vi.fn(() => true) 18 - }) 19 - describe('authentication guard', () => { 20 - it('redirects to login when not authenticated', async () => { 21 - setupUnauthenticatedUser() 22 - render(Settings) 15 + clearMocks(); 16 + setupFetchMock(); 17 + window.confirm = vi.fn(() => true); 18 + }); 19 + describe("authentication guard", () => { 20 + it("redirects to login when not authenticated", async () => { 21 + setupUnauthenticatedUser(); 22 + render(Settings); 23 23 await waitFor(() => { 24 - expect(window.location.hash).toBe('#/login') 25 - }) 26 - }) 27 - }) 28 - describe('page structure', () => { 24 + expect(window.location.hash).toBe("#/login"); 25 + }); 26 + }); 27 + }); 28 + describe("page structure", () => { 29 29 beforeEach(() => { 30 - setupAuthenticatedUser() 31 - }) 32 - it('displays all page elements and sections', async () => { 33 - render(Settings) 30 + setupAuthenticatedUser(); 31 + }); 32 + it("displays all page elements and sections", async () => { 33 + render(Settings); 34 34 await waitFor(() => { 35 - expect(screen.getByRole('heading', { name: /account settings/i, level: 1 })).toBeInTheDocument() 36 - expect(screen.getByRole('link', { name: /dashboard/i })).toHaveAttribute('href', '#/dashboard') 37 - expect(screen.getByRole('heading', { name: /change email/i })).toBeInTheDocument() 38 - expect(screen.getByRole('heading', { name: /change handle/i })).toBeInTheDocument() 39 - expect(screen.getByRole('heading', { name: /delete account/i })).toBeInTheDocument() 40 - }) 41 - }) 42 - }) 43 - describe('email change', () => { 35 + expect( 36 + screen.getByRole("heading", { name: /account settings/i, level: 1 }), 37 + ).toBeInTheDocument(); 38 + expect(screen.getByRole("link", { name: /dashboard/i })) 39 + .toHaveAttribute("href", "#/dashboard"); 40 + expect(screen.getByRole("heading", { name: /change email/i })) 41 + .toBeInTheDocument(); 42 + expect(screen.getByRole("heading", { name: /change handle/i })) 43 + .toBeInTheDocument(); 44 + expect(screen.getByRole("heading", { name: /delete account/i })) 45 + .toBeInTheDocument(); 46 + }); 47 + }); 48 + }); 49 + describe("email change", () => { 44 50 beforeEach(() => { 45 - setupAuthenticatedUser() 46 - }) 47 - it('displays current email and input field', async () => { 48 - render(Settings) 51 + setupAuthenticatedUser(); 52 + }); 53 + it("displays current email and input field", async () => { 54 + render(Settings); 49 55 await waitFor(() => { 50 - expect(screen.getByText(/current: test@example.com/i)).toBeInTheDocument() 51 - expect(screen.getByLabelText(/new email/i)).toBeInTheDocument() 52 - }) 53 - }) 54 - it('calls requestEmailUpdate when submitting', async () => { 55 - let requestCalled = false 56 - mockEndpoint('com.atproto.server.requestEmailUpdate', () => { 57 - requestCalled = true 58 - return jsonResponse({ tokenRequired: true }) 59 - }) 60 - render(Settings) 56 + expect(screen.getByText(/current: test@example.com/i)) 57 + .toBeInTheDocument(); 58 + expect(screen.getByLabelText(/new email/i)).toBeInTheDocument(); 59 + }); 60 + }); 61 + it("calls requestEmailUpdate when submitting", async () => { 62 + let requestCalled = false; 63 + mockEndpoint("com.atproto.server.requestEmailUpdate", () => { 64 + requestCalled = true; 65 + return jsonResponse({ tokenRequired: true }); 66 + }); 67 + render(Settings); 61 68 await waitFor(() => { 62 - expect(screen.getByLabelText(/new email/i)).toBeInTheDocument() 63 - }) 64 - await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'newemail@example.com' } }) 65 - await fireEvent.click(screen.getByRole('button', { name: /change email/i })) 69 + expect(screen.getByLabelText(/new email/i)).toBeInTheDocument(); 70 + }); 71 + await fireEvent.input(screen.getByLabelText(/new email/i), { 72 + target: { value: "newemail@example.com" }, 73 + }); 74 + await fireEvent.click( 75 + screen.getByRole("button", { name: /change email/i }), 76 + ); 66 77 await waitFor(() => { 67 - expect(requestCalled).toBe(true) 68 - }) 69 - }) 70 - it('shows verification code input when token is required', async () => { 71 - mockEndpoint('com.atproto.server.requestEmailUpdate', () => 72 - jsonResponse({ tokenRequired: true }) 73 - ) 74 - render(Settings) 78 + expect(requestCalled).toBe(true); 79 + }); 80 + }); 81 + it("shows verification code input when token is required", async () => { 82 + mockEndpoint( 83 + "com.atproto.server.requestEmailUpdate", 84 + () => jsonResponse({ tokenRequired: true }), 85 + ); 86 + render(Settings); 75 87 await waitFor(() => { 76 - expect(screen.getByLabelText(/new email/i)).toBeInTheDocument() 77 - }) 78 - await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'newemail@example.com' } }) 79 - await fireEvent.click(screen.getByRole('button', { name: /change email/i })) 88 + expect(screen.getByLabelText(/new email/i)).toBeInTheDocument(); 89 + }); 90 + await fireEvent.input(screen.getByLabelText(/new email/i), { 91 + target: { value: "newemail@example.com" }, 92 + }); 93 + await fireEvent.click( 94 + screen.getByRole("button", { name: /change email/i }), 95 + ); 80 96 await waitFor(() => { 81 - expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument() 82 - expect(screen.getByRole('button', { name: /confirm email change/i })).toBeInTheDocument() 83 - }) 84 - }) 85 - it('calls updateEmail with token when confirming', async () => { 86 - let updateCalled = false 87 - let capturedBody: Record<string, string> | null = null 88 - mockEndpoint('com.atproto.server.requestEmailUpdate', () => 89 - jsonResponse({ tokenRequired: true }) 90 - ) 91 - mockEndpoint('com.atproto.server.updateEmail', (_url, options) => { 92 - updateCalled = true 93 - capturedBody = JSON.parse((options?.body as string) || '{}') 94 - return jsonResponse({}) 95 - }) 96 - render(Settings) 97 + expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument(); 98 + expect(screen.getByRole("button", { name: /confirm email change/i })) 99 + .toBeInTheDocument(); 100 + }); 101 + }); 102 + it("calls updateEmail with token when confirming", async () => { 103 + let updateCalled = false; 104 + let capturedBody: Record<string, string> | null = null; 105 + mockEndpoint( 106 + "com.atproto.server.requestEmailUpdate", 107 + () => jsonResponse({ tokenRequired: true }), 108 + ); 109 + mockEndpoint("com.atproto.server.updateEmail", (_url, options) => { 110 + updateCalled = true; 111 + capturedBody = JSON.parse((options?.body as string) || "{}"); 112 + return jsonResponse({}); 113 + }); 114 + render(Settings); 97 115 await waitFor(() => { 98 - expect(screen.getByLabelText(/new email/i)).toBeInTheDocument() 99 - }) 100 - await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'newemail@example.com' } }) 101 - await fireEvent.click(screen.getByRole('button', { name: /change email/i })) 116 + expect(screen.getByLabelText(/new email/i)).toBeInTheDocument(); 117 + }); 118 + await fireEvent.input(screen.getByLabelText(/new email/i), { 119 + target: { value: "newemail@example.com" }, 120 + }); 121 + await fireEvent.click( 122 + screen.getByRole("button", { name: /change email/i }), 123 + ); 102 124 await waitFor(() => { 103 - expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument() 104 - }) 105 - await fireEvent.input(screen.getByLabelText(/verification code/i), { target: { value: '123456' } }) 106 - await fireEvent.click(screen.getByRole('button', { name: /confirm email change/i })) 125 + expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument(); 126 + }); 127 + await fireEvent.input(screen.getByLabelText(/verification code/i), { 128 + target: { value: "123456" }, 129 + }); 130 + await fireEvent.click( 131 + screen.getByRole("button", { name: /confirm email change/i }), 132 + ); 107 133 await waitFor(() => { 108 - expect(updateCalled).toBe(true) 109 - expect(capturedBody?.email).toBe('newemail@example.com') 110 - expect(capturedBody?.token).toBe('123456') 111 - }) 112 - }) 113 - it('shows success message after email update', async () => { 114 - mockEndpoint('com.atproto.server.requestEmailUpdate', () => 115 - jsonResponse({ tokenRequired: true }) 116 - ) 117 - mockEndpoint('com.atproto.server.updateEmail', () => 118 - jsonResponse({}) 119 - ) 120 - render(Settings) 134 + expect(updateCalled).toBe(true); 135 + expect(capturedBody?.email).toBe("newemail@example.com"); 136 + expect(capturedBody?.token).toBe("123456"); 137 + }); 138 + }); 139 + it("shows success message after email update", async () => { 140 + mockEndpoint( 141 + "com.atproto.server.requestEmailUpdate", 142 + () => jsonResponse({ tokenRequired: true }), 143 + ); 144 + mockEndpoint("com.atproto.server.updateEmail", () => jsonResponse({})); 145 + render(Settings); 121 146 await waitFor(() => { 122 - expect(screen.getByLabelText(/new email/i)).toBeInTheDocument() 123 - }) 124 - await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'new@test.com' } }) 125 - await fireEvent.click(screen.getByRole('button', { name: /change email/i })) 147 + expect(screen.getByLabelText(/new email/i)).toBeInTheDocument(); 148 + }); 149 + await fireEvent.input(screen.getByLabelText(/new email/i), { 150 + target: { value: "new@test.com" }, 151 + }); 152 + await fireEvent.click( 153 + screen.getByRole("button", { name: /change email/i }), 154 + ); 126 155 await waitFor(() => { 127 - expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument() 128 - }) 129 - await fireEvent.input(screen.getByLabelText(/verification code/i), { target: { value: '123456' } }) 130 - await fireEvent.click(screen.getByRole('button', { name: /confirm email change/i })) 156 + expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument(); 157 + }); 158 + await fireEvent.input(screen.getByLabelText(/verification code/i), { 159 + target: { value: "123456" }, 160 + }); 161 + await fireEvent.click( 162 + screen.getByRole("button", { name: /confirm email change/i }), 163 + ); 131 164 await waitFor(() => { 132 - expect(screen.getByText(/email updated successfully/i)).toBeInTheDocument() 133 - }) 134 - }) 135 - it('shows cancel button to return to email form', async () => { 136 - mockEndpoint('com.atproto.server.requestEmailUpdate', () => 137 - jsonResponse({ tokenRequired: true }) 138 - ) 139 - render(Settings) 165 + expect(screen.getByText(/email updated successfully/i)) 166 + .toBeInTheDocument(); 167 + }); 168 + }); 169 + it("shows cancel button to return to email form", async () => { 170 + mockEndpoint( 171 + "com.atproto.server.requestEmailUpdate", 172 + () => jsonResponse({ tokenRequired: true }), 173 + ); 174 + render(Settings); 140 175 await waitFor(() => { 141 - expect(screen.getByLabelText(/new email/i)).toBeInTheDocument() 142 - }) 143 - await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'new@test.com' } }) 144 - await fireEvent.click(screen.getByRole('button', { name: /change email/i })) 176 + expect(screen.getByLabelText(/new email/i)).toBeInTheDocument(); 177 + }); 178 + await fireEvent.input(screen.getByLabelText(/new email/i), { 179 + target: { value: "new@test.com" }, 180 + }); 181 + await fireEvent.click( 182 + screen.getByRole("button", { name: /change email/i }), 183 + ); 145 184 await waitFor(() => { 146 - expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument() 147 - }) 148 - await fireEvent.click(screen.getByRole('button', { name: /cancel/i })) 185 + expect(screen.getByRole("button", { name: /cancel/i })) 186 + .toBeInTheDocument(); 187 + }); 188 + await fireEvent.click(screen.getByRole("button", { name: /cancel/i })); 149 189 await waitFor(() => { 150 - expect(screen.getByLabelText(/new email/i)).toBeInTheDocument() 151 - expect(screen.queryByLabelText(/verification code/i)).not.toBeInTheDocument() 152 - }) 153 - }) 154 - it('shows error when email update fails', async () => { 155 - mockEndpoint('com.atproto.server.requestEmailUpdate', () => 156 - errorResponse('InvalidEmail', 'Invalid email format', 400) 157 - ) 158 - render(Settings) 190 + expect(screen.getByLabelText(/new email/i)).toBeInTheDocument(); 191 + expect(screen.queryByLabelText(/verification code/i)).not 192 + .toBeInTheDocument(); 193 + }); 194 + }); 195 + it("shows error when email update fails", async () => { 196 + mockEndpoint( 197 + "com.atproto.server.requestEmailUpdate", 198 + () => errorResponse("InvalidEmail", "Invalid email format", 400), 199 + ); 200 + render(Settings); 159 201 await waitFor(() => { 160 - expect(screen.getByLabelText(/new email/i)).toBeInTheDocument() 161 - }) 162 - await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'invalid@test.com' } }) 202 + expect(screen.getByLabelText(/new email/i)).toBeInTheDocument(); 203 + }); 204 + await fireEvent.input(screen.getByLabelText(/new email/i), { 205 + target: { value: "invalid@test.com" }, 206 + }); 163 207 await waitFor(() => { 164 - expect(screen.getByRole('button', { name: /change email/i })).not.toBeDisabled() 165 - }) 166 - await fireEvent.click(screen.getByRole('button', { name: /change email/i })) 208 + expect(screen.getByRole("button", { name: /change email/i })).not 209 + .toBeDisabled(); 210 + }); 211 + await fireEvent.click( 212 + screen.getByRole("button", { name: /change email/i }), 213 + ); 167 214 await waitFor(() => { 168 - expect(screen.getByText(/invalid email format/i)).toBeInTheDocument() 169 - }) 170 - }) 171 - }) 172 - describe('handle change', () => { 215 + expect(screen.getByText(/invalid email format/i)).toBeInTheDocument(); 216 + }); 217 + }); 218 + }); 219 + describe("handle change", () => { 173 220 beforeEach(() => { 174 - setupAuthenticatedUser() 175 - }) 176 - it('displays current handle', async () => { 177 - render(Settings) 221 + setupAuthenticatedUser(); 222 + }); 223 + it("displays current handle", async () => { 224 + render(Settings); 178 225 await waitFor(() => { 179 - expect(screen.getByText(/current: @testuser\.test\.tranquil\.dev/i)).toBeInTheDocument() 180 - }) 181 - }) 182 - it('calls updateHandle with new handle', async () => { 183 - let capturedHandle: string | null = null 184 - mockEndpoint('com.atproto.identity.updateHandle', (_url, options) => { 185 - const body = JSON.parse((options?.body as string) || '{}') 186 - capturedHandle = body.handle 187 - return jsonResponse({}) 188 - }) 189 - render(Settings) 226 + expect(screen.getByText(/current: @testuser\.test\.tranquil\.dev/i)) 227 + .toBeInTheDocument(); 228 + }); 229 + }); 230 + it("calls updateHandle with new handle", async () => { 231 + let capturedHandle: string | null = null; 232 + mockEndpoint("com.atproto.identity.updateHandle", (_url, options) => { 233 + const body = JSON.parse((options?.body as string) || "{}"); 234 + capturedHandle = body.handle; 235 + return jsonResponse({}); 236 + }); 237 + render(Settings); 190 238 await waitFor(() => { 191 - expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument() 192 - }) 193 - await fireEvent.input(screen.getByLabelText(/new handle/i), { target: { value: 'newhandle.bsky.social' } }) 194 - await fireEvent.click(screen.getByRole('button', { name: /change handle/i })) 239 + expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument(); 240 + }); 241 + await fireEvent.input(screen.getByLabelText(/new handle/i), { 242 + target: { value: "newhandle.bsky.social" }, 243 + }); 244 + await fireEvent.click( 245 + screen.getByRole("button", { name: /change handle/i }), 246 + ); 195 247 await waitFor(() => { 196 - expect(capturedHandle).toBe('newhandle.bsky.social') 197 - }) 198 - }) 199 - it('shows success message after handle change', async () => { 200 - mockEndpoint('com.atproto.identity.updateHandle', () => 201 - jsonResponse({}) 202 - ) 203 - render(Settings) 248 + expect(capturedHandle).toBe("newhandle.bsky.social"); 249 + }); 250 + }); 251 + it("shows success message after handle change", async () => { 252 + mockEndpoint("com.atproto.identity.updateHandle", () => jsonResponse({})); 253 + render(Settings); 204 254 await waitFor(() => { 205 - expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument() 206 - }) 207 - await fireEvent.input(screen.getByLabelText(/new handle/i), { target: { value: 'newhandle' } }) 208 - await fireEvent.click(screen.getByRole('button', { name: /change handle/i })) 255 + expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument(); 256 + }); 257 + await fireEvent.input(screen.getByLabelText(/new handle/i), { 258 + target: { value: "newhandle" }, 259 + }); 260 + await fireEvent.click( 261 + screen.getByRole("button", { name: /change handle/i }), 262 + ); 209 263 await waitFor(() => { 210 - expect(screen.getByText(/handle updated successfully/i)).toBeInTheDocument() 211 - }) 212 - }) 213 - it('shows error when handle change fails', async () => { 214 - mockEndpoint('com.atproto.identity.updateHandle', () => 215 - errorResponse('HandleNotAvailable', 'Handle is already taken', 400) 216 - ) 217 - render(Settings) 264 + expect(screen.getByText(/handle updated successfully/i)) 265 + .toBeInTheDocument(); 266 + }); 267 + }); 268 + it("shows error when handle change fails", async () => { 269 + mockEndpoint( 270 + "com.atproto.identity.updateHandle", 271 + () => 272 + errorResponse("HandleNotAvailable", "Handle is already taken", 400), 273 + ); 274 + render(Settings); 218 275 await waitFor(() => { 219 - expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument() 220 - }) 221 - await fireEvent.input(screen.getByLabelText(/new handle/i), { target: { value: 'taken' } }) 222 - await fireEvent.click(screen.getByRole('button', { name: /change handle/i })) 276 + expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument(); 277 + }); 278 + await fireEvent.input(screen.getByLabelText(/new handle/i), { 279 + target: { value: "taken" }, 280 + }); 281 + await fireEvent.click( 282 + screen.getByRole("button", { name: /change handle/i }), 283 + ); 223 284 await waitFor(() => { 224 - expect(screen.getByText(/handle is already taken/i)).toBeInTheDocument() 225 - }) 226 - }) 227 - }) 228 - describe('account deletion', () => { 285 + expect(screen.getByText(/handle is already taken/i)) 286 + .toBeInTheDocument(); 287 + }); 288 + }); 289 + }); 290 + describe("account deletion", () => { 229 291 beforeEach(() => { 230 - setupAuthenticatedUser() 231 - mockEndpoint('com.atproto.server.deleteSession', () => 232 - jsonResponse({}) 233 - ) 234 - }) 235 - it('displays delete section with warning and request button', async () => { 236 - render(Settings) 292 + setupAuthenticatedUser(); 293 + mockEndpoint("com.atproto.server.deleteSession", () => jsonResponse({})); 294 + }); 295 + it("displays delete section with warning and request button", async () => { 296 + render(Settings); 237 297 await waitFor(() => { 238 - expect(screen.getByText(/this action is irreversible/i)).toBeInTheDocument() 239 - expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument() 240 - }) 241 - }) 242 - it('calls requestAccountDelete when clicking request', async () => { 243 - let requestCalled = false 244 - mockEndpoint('com.atproto.server.requestAccountDelete', () => { 245 - requestCalled = true 246 - return jsonResponse({}) 247 - }) 248 - render(Settings) 298 + expect(screen.getByText(/this action is irreversible/i)) 299 + .toBeInTheDocument(); 300 + expect( 301 + screen.getByRole("button", { name: /request account deletion/i }), 302 + ).toBeInTheDocument(); 303 + }); 304 + }); 305 + it("calls requestAccountDelete when clicking request", async () => { 306 + let requestCalled = false; 307 + mockEndpoint("com.atproto.server.requestAccountDelete", () => { 308 + requestCalled = true; 309 + return jsonResponse({}); 310 + }); 311 + render(Settings); 249 312 await waitFor(() => { 250 - expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument() 251 - }) 252 - await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i })) 313 + expect( 314 + screen.getByRole("button", { name: /request account deletion/i }), 315 + ).toBeInTheDocument(); 316 + }); 317 + await fireEvent.click( 318 + screen.getByRole("button", { name: /request account deletion/i }), 319 + ); 253 320 await waitFor(() => { 254 - expect(requestCalled).toBe(true) 255 - }) 256 - }) 257 - it('shows confirmation form after requesting deletion', async () => { 258 - mockEndpoint('com.atproto.server.requestAccountDelete', () => 259 - jsonResponse({}) 260 - ) 261 - render(Settings) 321 + expect(requestCalled).toBe(true); 322 + }); 323 + }); 324 + it("shows confirmation form after requesting deletion", async () => { 325 + mockEndpoint( 326 + "com.atproto.server.requestAccountDelete", 327 + () => jsonResponse({}), 328 + ); 329 + render(Settings); 262 330 await waitFor(() => { 263 - expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument() 264 - }) 265 - await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i })) 331 + expect( 332 + screen.getByRole("button", { name: /request account deletion/i }), 333 + ).toBeInTheDocument(); 334 + }); 335 + await fireEvent.click( 336 + screen.getByRole("button", { name: /request account deletion/i }), 337 + ); 266 338 await waitFor(() => { 267 - expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument() 268 - expect(screen.getByLabelText(/your password/i)).toBeInTheDocument() 269 - expect(screen.getByRole('button', { name: /permanently delete account/i })).toBeInTheDocument() 270 - }) 271 - }) 272 - it('shows confirmation dialog before final deletion', async () => { 273 - const confirmSpy = vi.fn(() => false) 274 - window.confirm = confirmSpy 275 - mockEndpoint('com.atproto.server.requestAccountDelete', () => 276 - jsonResponse({}) 277 - ) 278 - render(Settings) 339 + expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument(); 340 + expect(screen.getByLabelText(/your password/i)).toBeInTheDocument(); 341 + expect( 342 + screen.getByRole("button", { name: /permanently delete account/i }), 343 + ).toBeInTheDocument(); 344 + }); 345 + }); 346 + it("shows confirmation dialog before final deletion", async () => { 347 + const confirmSpy = vi.fn(() => false); 348 + window.confirm = confirmSpy; 349 + mockEndpoint( 350 + "com.atproto.server.requestAccountDelete", 351 + () => jsonResponse({}), 352 + ); 353 + render(Settings); 279 354 await waitFor(() => { 280 - expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument() 281 - }) 282 - await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i })) 355 + expect( 356 + screen.getByRole("button", { name: /request account deletion/i }), 357 + ).toBeInTheDocument(); 358 + }); 359 + await fireEvent.click( 360 + screen.getByRole("button", { name: /request account deletion/i }), 361 + ); 283 362 await waitFor(() => { 284 - expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument() 285 - }) 286 - await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: 'ABC123' } }) 287 - await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: 'password' } }) 288 - await fireEvent.click(screen.getByRole('button', { name: /permanently delete account/i })) 363 + expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument(); 364 + }); 365 + await fireEvent.input(screen.getByLabelText(/confirmation code/i), { 366 + target: { value: "ABC123" }, 367 + }); 368 + await fireEvent.input(screen.getByLabelText(/your password/i), { 369 + target: { value: "password" }, 370 + }); 371 + await fireEvent.click( 372 + screen.getByRole("button", { name: /permanently delete account/i }), 373 + ); 289 374 expect(confirmSpy).toHaveBeenCalledWith( 290 - expect.stringContaining('absolutely sure') 291 - ) 292 - }) 293 - it('calls deleteAccount with correct parameters', async () => { 294 - window.confirm = vi.fn(() => true) 295 - let capturedBody: Record<string, string> | null = null 296 - mockEndpoint('com.atproto.server.requestAccountDelete', () => 297 - jsonResponse({}) 298 - ) 299 - mockEndpoint('com.atproto.server.deleteAccount', (_url, options) => { 300 - capturedBody = JSON.parse((options?.body as string) || '{}') 301 - return jsonResponse({}) 302 - }) 303 - render(Settings) 375 + expect.stringContaining("absolutely sure"), 376 + ); 377 + }); 378 + it("calls deleteAccount with correct parameters", async () => { 379 + window.confirm = vi.fn(() => true); 380 + let capturedBody: Record<string, string> | null = null; 381 + mockEndpoint( 382 + "com.atproto.server.requestAccountDelete", 383 + () => jsonResponse({}), 384 + ); 385 + mockEndpoint("com.atproto.server.deleteAccount", (_url, options) => { 386 + capturedBody = JSON.parse((options?.body as string) || "{}"); 387 + return jsonResponse({}); 388 + }); 389 + render(Settings); 304 390 await waitFor(() => { 305 - expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument() 306 - }) 307 - await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i })) 391 + expect( 392 + screen.getByRole("button", { name: /request account deletion/i }), 393 + ).toBeInTheDocument(); 394 + }); 395 + await fireEvent.click( 396 + screen.getByRole("button", { name: /request account deletion/i }), 397 + ); 308 398 await waitFor(() => { 309 - expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument() 310 - }) 311 - await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: 'DEL123' } }) 312 - await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: 'mypassword' } }) 313 - await fireEvent.click(screen.getByRole('button', { name: /permanently delete account/i })) 399 + expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument(); 400 + }); 401 + await fireEvent.input(screen.getByLabelText(/confirmation code/i), { 402 + target: { value: "DEL123" }, 403 + }); 404 + await fireEvent.input(screen.getByLabelText(/your password/i), { 405 + target: { value: "mypassword" }, 406 + }); 407 + await fireEvent.click( 408 + screen.getByRole("button", { name: /permanently delete account/i }), 409 + ); 314 410 await waitFor(() => { 315 - expect(capturedBody?.token).toBe('DEL123') 316 - expect(capturedBody?.password).toBe('mypassword') 317 - expect(capturedBody?.did).toBe('did:web:test.tranquil.dev:u:testuser') 318 - }) 319 - }) 320 - it('navigates to login after successful deletion', async () => { 321 - window.confirm = vi.fn(() => true) 322 - mockEndpoint('com.atproto.server.requestAccountDelete', () => 323 - jsonResponse({}) 324 - ) 325 - mockEndpoint('com.atproto.server.deleteAccount', () => 326 - jsonResponse({}) 327 - ) 328 - render(Settings) 411 + expect(capturedBody?.token).toBe("DEL123"); 412 + expect(capturedBody?.password).toBe("mypassword"); 413 + expect(capturedBody?.did).toBe("did:web:test.tranquil.dev:u:testuser"); 414 + }); 415 + }); 416 + it("navigates to login after successful deletion", async () => { 417 + window.confirm = vi.fn(() => true); 418 + mockEndpoint( 419 + "com.atproto.server.requestAccountDelete", 420 + () => jsonResponse({}), 421 + ); 422 + mockEndpoint("com.atproto.server.deleteAccount", () => jsonResponse({})); 423 + render(Settings); 329 424 await waitFor(() => { 330 - expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument() 331 - }) 332 - await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i })) 425 + expect( 426 + screen.getByRole("button", { name: /request account deletion/i }), 427 + ).toBeInTheDocument(); 428 + }); 429 + await fireEvent.click( 430 + screen.getByRole("button", { name: /request account deletion/i }), 431 + ); 333 432 await waitFor(() => { 334 - expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument() 335 - }) 336 - await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: 'DEL123' } }) 337 - await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: 'password' } }) 338 - await fireEvent.click(screen.getByRole('button', { name: /permanently delete account/i })) 433 + expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument(); 434 + }); 435 + await fireEvent.input(screen.getByLabelText(/confirmation code/i), { 436 + target: { value: "DEL123" }, 437 + }); 438 + await fireEvent.input(screen.getByLabelText(/your password/i), { 439 + target: { value: "password" }, 440 + }); 441 + await fireEvent.click( 442 + screen.getByRole("button", { name: /permanently delete account/i }), 443 + ); 339 444 await waitFor(() => { 340 - expect(window.location.hash).toBe('#/login') 341 - }) 342 - }) 343 - it('shows cancel button to return to request state', async () => { 344 - mockEndpoint('com.atproto.server.requestAccountDelete', () => 345 - jsonResponse({}) 346 - ) 347 - render(Settings) 445 + expect(window.location.hash).toBe("#/login"); 446 + }); 447 + }); 448 + it("shows cancel button to return to request state", async () => { 449 + mockEndpoint( 450 + "com.atproto.server.requestAccountDelete", 451 + () => jsonResponse({}), 452 + ); 453 + render(Settings); 348 454 await waitFor(() => { 349 - expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument() 350 - }) 351 - await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i })) 455 + expect( 456 + screen.getByRole("button", { name: /request account deletion/i }), 457 + ).toBeInTheDocument(); 458 + }); 459 + await fireEvent.click( 460 + screen.getByRole("button", { name: /request account deletion/i }), 461 + ); 352 462 await waitFor(() => { 353 - const cancelButtons = screen.getAllByRole('button', { name: /cancel/i }) 354 - expect(cancelButtons.length).toBeGreaterThan(0) 355 - }) 356 - const deleteHeading = screen.getByRole('heading', { name: /delete account/i }) 357 - const deleteSection = deleteHeading.closest('section') 358 - const cancelButton = deleteSection?.querySelector('button.secondary') 463 + const cancelButtons = screen.getAllByRole("button", { 464 + name: /cancel/i, 465 + }); 466 + expect(cancelButtons.length).toBeGreaterThan(0); 467 + }); 468 + const deleteHeading = screen.getByRole("heading", { 469 + name: /delete account/i, 470 + }); 471 + const deleteSection = deleteHeading.closest("section"); 472 + const cancelButton = deleteSection?.querySelector("button.secondary"); 359 473 if (cancelButton) { 360 - await fireEvent.click(cancelButton) 474 + await fireEvent.click(cancelButton); 361 475 } 362 476 await waitFor(() => { 363 - expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument() 364 - }) 365 - }) 366 - it('shows error when deletion fails', async () => { 367 - window.confirm = vi.fn(() => true) 368 - mockEndpoint('com.atproto.server.requestAccountDelete', () => 369 - jsonResponse({}) 370 - ) 371 - mockEndpoint('com.atproto.server.deleteAccount', () => 372 - errorResponse('InvalidToken', 'Invalid confirmation code', 400) 373 - ) 374 - render(Settings) 477 + expect( 478 + screen.getByRole("button", { name: /request account deletion/i }), 479 + ).toBeInTheDocument(); 480 + }); 481 + }); 482 + it("shows error when deletion fails", async () => { 483 + window.confirm = vi.fn(() => true); 484 + mockEndpoint( 485 + "com.atproto.server.requestAccountDelete", 486 + () => jsonResponse({}), 487 + ); 488 + mockEndpoint( 489 + "com.atproto.server.deleteAccount", 490 + () => errorResponse("InvalidToken", "Invalid confirmation code", 400), 491 + ); 492 + render(Settings); 375 493 await waitFor(() => { 376 - expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument() 377 - }) 378 - await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i })) 494 + expect( 495 + screen.getByRole("button", { name: /request account deletion/i }), 496 + ).toBeInTheDocument(); 497 + }); 498 + await fireEvent.click( 499 + screen.getByRole("button", { name: /request account deletion/i }), 500 + ); 379 501 await waitFor(() => { 380 - expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument() 381 - }) 382 - await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: 'WRONG' } }) 383 - await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: 'password' } }) 384 - await fireEvent.click(screen.getByRole('button', { name: /permanently delete account/i })) 502 + expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument(); 503 + }); 504 + await fireEvent.input(screen.getByLabelText(/confirmation code/i), { 505 + target: { value: "WRONG" }, 506 + }); 507 + await fireEvent.input(screen.getByLabelText(/your password/i), { 508 + target: { value: "password" }, 509 + }); 510 + await fireEvent.click( 511 + screen.getByRole("button", { name: /permanently delete account/i }), 512 + ); 385 513 await waitFor(() => { 386 - expect(screen.getByText(/invalid confirmation code/i)).toBeInTheDocument() 387 - }) 388 - }) 389 - }) 390 - }) 514 + expect(screen.getByText(/invalid confirmation code/i)) 515 + .toBeInTheDocument(); 516 + }); 517 + }); 518 + }); 519 + });
+170 -139
frontend/src/tests/mocks.ts
··· 1 - import { vi } from 'vitest' 2 - import type { Session, AppPassword, InviteCode } from '../lib/api' 3 - import { _testSetState } from '../lib/auth.svelte' 1 + import { vi } from "vitest"; 2 + import type { AppPassword, InviteCode, Session } from "../lib/api"; 3 + import { _testSetState } from "../lib/auth.svelte"; 4 4 export interface MockResponse { 5 - ok: boolean 6 - status: number 7 - json: () => Promise<unknown> 5 + ok: boolean; 6 + status: number; 7 + json: () => Promise<unknown>; 8 8 } 9 - export type MockHandler = (url: string, options?: RequestInit) => MockResponse | Promise<MockResponse> 10 - const mockHandlers: Map<string, MockHandler> = new Map() 9 + export type MockHandler = ( 10 + url: string, 11 + options?: RequestInit, 12 + ) => MockResponse | Promise<MockResponse>; 13 + const mockHandlers: Map<string, MockHandler> = new Map(); 11 14 export function mockEndpoint(endpoint: string, handler: MockHandler): void { 12 - mockHandlers.set(endpoint, handler) 15 + mockHandlers.set(endpoint, handler); 13 16 } 14 17 export function mockEndpointOnce(endpoint: string, handler: MockHandler): void { 15 - const originalHandler = mockHandlers.get(endpoint) 18 + const originalHandler = mockHandlers.get(endpoint); 16 19 mockHandlers.set(endpoint, (url, options) => { 17 - mockHandlers.set(endpoint, originalHandler!) 18 - return handler(url, options) 19 - }) 20 + mockHandlers.set(endpoint, originalHandler!); 21 + return handler(url, options); 22 + }); 20 23 } 21 24 export function clearMocks(): void { 22 - mockHandlers.clear() 25 + mockHandlers.clear(); 23 26 } 24 27 function extractEndpoint(url: string): string { 25 - const match = url.match(/\/xrpc\/([^?]+)/) 26 - return match ? match[1] : url 28 + const match = url.match(/\/xrpc\/([^?]+)/); 29 + return match ? match[1] : url; 27 30 } 28 31 export function setupFetchMock(): void { 29 - global.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { 30 - const url = typeof input === 'string' ? input : input.toString() 31 - const endpoint = extractEndpoint(url) 32 - const handler = mockHandlers.get(endpoint) 33 - if (handler) { 34 - const result = await handler(url, init) 32 + global.fetch = vi.fn( 33 + async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => { 34 + const url = typeof input === "string" ? input : input.toString(); 35 + const endpoint = extractEndpoint(url); 36 + const handler = mockHandlers.get(endpoint); 37 + if (handler) { 38 + const result = await handler(url, init); 39 + return { 40 + ok: result.ok, 41 + status: result.status, 42 + json: result.json, 43 + text: async () => JSON.stringify(await result.json()), 44 + headers: new Headers(), 45 + redirected: false, 46 + statusText: result.ok ? "OK" : "Error", 47 + type: "basic", 48 + url, 49 + clone: () => ({ ...result }) as Response, 50 + body: null, 51 + bodyUsed: false, 52 + arrayBuffer: async () => new ArrayBuffer(0), 53 + blob: async () => new Blob(), 54 + formData: async () => new FormData(), 55 + } as Response; 56 + } 35 57 return { 36 - ok: result.ok, 37 - status: result.status, 38 - json: result.json, 39 - text: async () => JSON.stringify(await result.json()), 58 + ok: false, 59 + status: 404, 60 + json: async () => ({ 61 + error: "NotFound", 62 + message: `No mock for ${endpoint}`, 63 + }), 64 + text: async () => 65 + JSON.stringify({ 66 + error: "NotFound", 67 + message: `No mock for ${endpoint}`, 68 + }), 40 69 headers: new Headers(), 41 70 redirected: false, 42 - statusText: result.ok ? 'OK' : 'Error', 43 - type: 'basic', 71 + statusText: "Not Found", 72 + type: "basic", 44 73 url, 45 - clone: () => ({ ...result }) as Response, 74 + clone: function () { 75 + return this; 76 + }, 46 77 body: null, 47 78 bodyUsed: false, 48 79 arrayBuffer: async () => new ArrayBuffer(0), 49 80 blob: async () => new Blob(), 50 81 formData: async () => new FormData(), 51 - } as Response 52 - } 53 - return { 54 - ok: false, 55 - status: 404, 56 - json: async () => ({ error: 'NotFound', message: `No mock for ${endpoint}` }), 57 - text: async () => JSON.stringify({ error: 'NotFound', message: `No mock for ${endpoint}` }), 58 - headers: new Headers(), 59 - redirected: false, 60 - statusText: 'Not Found', 61 - type: 'basic', 62 - url, 63 - clone: function() { return this }, 64 - body: null, 65 - bodyUsed: false, 66 - arrayBuffer: async () => new ArrayBuffer(0), 67 - blob: async () => new Blob(), 68 - formData: async () => new FormData(), 69 - } as Response 70 - }) 82 + } as Response; 83 + }, 84 + ); 71 85 } 72 86 export function jsonResponse<T>(data: T, status = 200): MockResponse { 73 87 return { 74 88 ok: status >= 200 && status < 300, 75 89 status, 76 90 json: async () => data, 77 - } 91 + }; 78 92 } 79 - export function errorResponse(error: string, message: string, status = 400): MockResponse { 93 + export function errorResponse( 94 + error: string, 95 + message: string, 96 + status = 400, 97 + ): MockResponse { 80 98 return { 81 99 ok: false, 82 100 status, 83 101 json: async () => ({ error, message }), 84 - } 102 + }; 85 103 } 86 104 export const mockData = { 87 105 session: (overrides?: Partial<Session>): Session => ({ 88 - did: 'did:web:test.tranquil.dev:u:testuser', 89 - handle: 'testuser.test.tranquil.dev', 90 - email: 'test@example.com', 106 + did: "did:web:test.tranquil.dev:u:testuser", 107 + handle: "testuser.test.tranquil.dev", 108 + email: "test@example.com", 91 109 emailConfirmed: true, 92 - accessJwt: 'mock-access-jwt-token', 93 - refreshJwt: 'mock-refresh-jwt-token', 110 + accessJwt: "mock-access-jwt-token", 111 + refreshJwt: "mock-refresh-jwt-token", 94 112 ...overrides, 95 113 }), 96 114 appPassword: (overrides?: Partial<AppPassword>): AppPassword => ({ 97 - name: 'Test App', 115 + name: "Test App", 98 116 createdAt: new Date().toISOString(), 99 117 ...overrides, 100 118 }), 101 119 inviteCode: (overrides?: Partial<InviteCode>): InviteCode => ({ 102 - code: 'test-invite-123', 120 + code: "test-invite-123", 103 121 available: 1, 104 122 disabled: false, 105 - forAccount: 'did:web:test.tranquil.dev:u:testuser', 106 - createdBy: 'did:web:test.tranquil.dev:u:testuser', 123 + forAccount: "did:web:test.tranquil.dev:u:testuser", 124 + createdBy: "did:web:test.tranquil.dev:u:testuser", 107 125 createdAt: new Date().toISOString(), 108 126 uses: [], 109 127 ...overrides, 110 128 }), 111 129 notificationPrefs: (overrides?: Record<string, unknown>) => ({ 112 - preferredChannel: 'email', 113 - email: 'test@example.com', 130 + preferredChannel: "email", 131 + email: "test@example.com", 114 132 discordId: null, 115 133 discordVerified: false, 116 134 telegramUsername: null, ··· 120 138 ...overrides, 121 139 }), 122 140 describeServer: () => ({ 123 - availableUserDomains: ['test.tranquil.dev'], 141 + availableUserDomains: ["test.tranquil.dev"], 124 142 inviteCodeRequired: false, 125 143 links: { 126 - privacyPolicy: 'https://example.com/privacy', 127 - termsOfService: 'https://example.com/tos', 144 + privacyPolicy: "https://example.com/privacy", 145 + termsOfService: "https://example.com/tos", 128 146 }, 129 147 }), 130 148 describeRepo: (did: string) => ({ 131 - handle: 'testuser.test.tranquil.dev', 149 + handle: "testuser.test.tranquil.dev", 132 150 did, 133 151 didDoc: {}, 134 - collections: ['app.bsky.feed.post', 'app.bsky.feed.like', 'app.bsky.graph.follow'], 152 + collections: [ 153 + "app.bsky.feed.post", 154 + "app.bsky.feed.like", 155 + "app.bsky.graph.follow", 156 + ], 135 157 handleIsCorrect: true, 136 158 }), 137 - } 159 + }; 138 160 export function setupDefaultMocks(): void { 139 - setupFetchMock() 140 - mockEndpoint('com.atproto.server.getSession', () => 141 - jsonResponse(mockData.session()) 142 - ) 143 - mockEndpoint('com.atproto.server.createSession', (_url, options) => { 144 - const body = JSON.parse((options?.body as string) || '{}') 145 - if (body.identifier && body.password === 'correctpassword') { 146 - return jsonResponse(mockData.session({ handle: body.identifier.replace('@', '') })) 161 + setupFetchMock(); 162 + mockEndpoint( 163 + "com.atproto.server.getSession", 164 + () => jsonResponse(mockData.session()), 165 + ); 166 + mockEndpoint("com.atproto.server.createSession", (_url, options) => { 167 + const body = JSON.parse((options?.body as string) || "{}"); 168 + if (body.identifier && body.password === "correctpassword") { 169 + return jsonResponse( 170 + mockData.session({ handle: body.identifier.replace("@", "") }), 171 + ); 147 172 } 148 - return errorResponse('AuthenticationRequired', 'Invalid identifier or password', 401) 149 - }) 150 - mockEndpoint('com.atproto.server.refreshSession', () => 151 - jsonResponse(mockData.session()) 152 - ) 153 - mockEndpoint('com.atproto.server.deleteSession', () => 154 - jsonResponse({}) 155 - ) 156 - mockEndpoint('com.atproto.server.listAppPasswords', () => 157 - jsonResponse({ passwords: [mockData.appPassword()] }) 158 - ) 159 - mockEndpoint('com.atproto.server.createAppPassword', (_url, options) => { 160 - const body = JSON.parse((options?.body as string) || '{}') 173 + return errorResponse( 174 + "AuthenticationRequired", 175 + "Invalid identifier or password", 176 + 401, 177 + ); 178 + }); 179 + mockEndpoint( 180 + "com.atproto.server.refreshSession", 181 + () => jsonResponse(mockData.session()), 182 + ); 183 + mockEndpoint("com.atproto.server.deleteSession", () => jsonResponse({})); 184 + mockEndpoint( 185 + "com.atproto.server.listAppPasswords", 186 + () => jsonResponse({ passwords: [mockData.appPassword()] }), 187 + ); 188 + mockEndpoint("com.atproto.server.createAppPassword", (_url, options) => { 189 + const body = JSON.parse((options?.body as string) || "{}"); 161 190 return jsonResponse({ 162 191 name: body.name, 163 - password: 'xxxx-xxxx-xxxx-xxxx', 192 + password: "xxxx-xxxx-xxxx-xxxx", 164 193 createdAt: new Date().toISOString(), 165 - }) 166 - }) 167 - mockEndpoint('com.atproto.server.revokeAppPassword', () => 168 - jsonResponse({}) 169 - ) 170 - mockEndpoint('com.atproto.server.getAccountInviteCodes', () => 171 - jsonResponse({ codes: [mockData.inviteCode()] }) 172 - ) 173 - mockEndpoint('com.atproto.server.createInviteCode', () => 174 - jsonResponse({ code: 'new-invite-' + Date.now() }) 175 - ) 176 - mockEndpoint('com.tranquil.account.getNotificationPrefs', () => 177 - jsonResponse(mockData.notificationPrefs()) 178 - ) 179 - mockEndpoint('com.tranquil.account.updateNotificationPrefs', () => 180 - jsonResponse({ success: true }) 181 - ) 182 - mockEndpoint('com.atproto.server.requestEmailUpdate', () => 183 - jsonResponse({ tokenRequired: true }) 184 - ) 185 - mockEndpoint('com.atproto.server.updateEmail', () => 186 - jsonResponse({}) 187 - ) 188 - mockEndpoint('com.atproto.identity.updateHandle', () => 189 - jsonResponse({}) 190 - ) 191 - mockEndpoint('com.atproto.server.requestAccountDelete', () => 192 - jsonResponse({}) 193 - ) 194 - mockEndpoint('com.atproto.server.deleteAccount', () => 195 - jsonResponse({}) 196 - ) 197 - mockEndpoint('com.atproto.server.describeServer', () => 198 - jsonResponse(mockData.describeServer()) 199 - ) 200 - mockEndpoint('com.atproto.repo.describeRepo', (url) => { 201 - const params = new URLSearchParams(url.split('?')[1]) 202 - const repo = params.get('repo') || 'did:web:test' 203 - return jsonResponse(mockData.describeRepo(repo)) 204 - }) 205 - mockEndpoint('com.atproto.repo.listRecords', () => 206 - jsonResponse({ records: [] }) 207 - ) 194 + }); 195 + }); 196 + mockEndpoint("com.atproto.server.revokeAppPassword", () => jsonResponse({})); 197 + mockEndpoint( 198 + "com.atproto.server.getAccountInviteCodes", 199 + () => jsonResponse({ codes: [mockData.inviteCode()] }), 200 + ); 201 + mockEndpoint( 202 + "com.atproto.server.createInviteCode", 203 + () => jsonResponse({ code: "new-invite-" + Date.now() }), 204 + ); 205 + mockEndpoint( 206 + "com.tranquil.account.getNotificationPrefs", 207 + () => jsonResponse(mockData.notificationPrefs()), 208 + ); 209 + mockEndpoint( 210 + "com.tranquil.account.updateNotificationPrefs", 211 + () => jsonResponse({ success: true }), 212 + ); 213 + mockEndpoint( 214 + "com.atproto.server.requestEmailUpdate", 215 + () => jsonResponse({ tokenRequired: true }), 216 + ); 217 + mockEndpoint("com.atproto.server.updateEmail", () => jsonResponse({})); 218 + mockEndpoint("com.atproto.identity.updateHandle", () => jsonResponse({})); 219 + mockEndpoint( 220 + "com.atproto.server.requestAccountDelete", 221 + () => jsonResponse({}), 222 + ); 223 + mockEndpoint("com.atproto.server.deleteAccount", () => jsonResponse({})); 224 + mockEndpoint( 225 + "com.atproto.server.describeServer", 226 + () => jsonResponse(mockData.describeServer()), 227 + ); 228 + mockEndpoint("com.atproto.repo.describeRepo", (url) => { 229 + const params = new URLSearchParams(url.split("?")[1]); 230 + const repo = params.get("repo") || "did:web:test"; 231 + return jsonResponse(mockData.describeRepo(repo)); 232 + }); 233 + mockEndpoint( 234 + "com.atproto.repo.listRecords", 235 + () => jsonResponse({ records: [] }), 236 + ); 208 237 } 209 - export function setupAuthenticatedUser(sessionOverrides?: Partial<Session>): Session { 210 - const session = mockData.session(sessionOverrides) 238 + export function setupAuthenticatedUser( 239 + sessionOverrides?: Partial<Session>, 240 + ): Session { 241 + const session = mockData.session(sessionOverrides); 211 242 _testSetState({ 212 243 session, 213 244 loading: false, 214 245 error: null, 215 - }) 216 - return session 246 + }); 247 + return session; 217 248 } 218 249 export function setupUnauthenticatedUser(): void { 219 250 _testSetState({ 220 251 session: null, 221 252 loading: false, 222 253 error: null, 223 - }) 254 + }); 224 255 }
+22 -20
frontend/src/tests/setup.ts
··· 1 - import '@testing-library/jest-dom/vitest' 2 - import { vi, beforeEach, afterEach } from 'vitest' 3 - import { _testReset } from '../lib/auth.svelte' 1 + import "@testing-library/jest-dom/vitest"; 2 + import { afterEach, beforeEach, vi } from "vitest"; 3 + import { _testReset } from "../lib/auth.svelte"; 4 4 5 - let locationHash = '' 5 + let locationHash = ""; 6 6 7 - Object.defineProperty(window, 'location', { 7 + Object.defineProperty(window, "location", { 8 8 value: { 9 - get hash() { return locationHash }, 9 + get hash() { 10 + return locationHash; 11 + }, 10 12 set hash(value: string) { 11 - locationHash = value.startsWith('#') ? value : `#${value}` 13 + locationHash = value.startsWith("#") ? value : `#${value}`; 12 14 }, 13 - href: 'http://localhost:3000/', 14 - origin: 'http://localhost:3000', 15 - pathname: '/', 16 - search: '', 15 + href: "http://localhost:3000/", 16 + origin: "http://localhost:3000", 17 + pathname: "/", 18 + search: "", 17 19 assign: vi.fn(), 18 20 replace: vi.fn(), 19 21 reload: vi.fn(), 20 22 }, 21 23 writable: true, 22 24 configurable: true, 23 - }) 25 + }); 24 26 25 27 beforeEach(() => { 26 - vi.clearAllMocks() 27 - localStorage.clear() 28 - sessionStorage.clear() 29 - locationHash = '' 30 - _testReset() 31 - }) 28 + vi.clearAllMocks(); 29 + localStorage.clear(); 30 + sessionStorage.clear(); 31 + locationHash = ""; 32 + _testReset(); 33 + }); 32 34 33 35 afterEach(() => { 34 - vi.restoreAllMocks() 35 - }) 36 + vi.restoreAllMocks(); 37 + });
+55 -43
frontend/src/tests/utils.ts
··· 1 - import { render, type RenderResult } from '@testing-library/svelte' 2 - import { tick } from 'svelte' 3 - import type { ComponentType } from 'svelte' 1 + import { render, type RenderResult } from "@testing-library/svelte"; 2 + import { tick } from "svelte"; 3 + import type { ComponentType } from "svelte"; 4 4 5 5 export async function renderAndWait<T extends ComponentType>( 6 6 component: T, 7 - options?: Parameters<typeof render>[1] 7 + options?: Parameters<typeof render>[1], 8 8 ): Promise<RenderResult<T>> { 9 - const result = render(component, options) 10 - await tick() 11 - await new Promise(resolve => setTimeout(resolve, 0)) 12 - return result 9 + const result = render(component, options); 10 + await tick(); 11 + await new Promise((resolve) => setTimeout(resolve, 0)); 12 + return result; 13 13 } 14 14 15 15 export async function waitForElement( 16 16 queryFn: () => HTMLElement | null, 17 - timeout = 1000 17 + timeout = 1000, 18 18 ): Promise<HTMLElement> { 19 - const start = Date.now() 19 + const start = Date.now(); 20 20 while (Date.now() - start < timeout) { 21 - const element = queryFn() 22 - if (element) return element 23 - await new Promise(resolve => setTimeout(resolve, 10)) 21 + const element = queryFn(); 22 + if (element) return element; 23 + await new Promise((resolve) => setTimeout(resolve, 10)); 24 24 } 25 - throw new Error('Element not found within timeout') 25 + throw new Error("Element not found within timeout"); 26 26 } 27 27 28 28 export async function waitForElementToDisappear( 29 29 queryFn: () => HTMLElement | null, 30 - timeout = 1000 30 + timeout = 1000, 31 31 ): Promise<void> { 32 - const start = Date.now() 32 + const start = Date.now(); 33 33 while (Date.now() - start < timeout) { 34 - const element = queryFn() 35 - if (!element) return 36 - await new Promise(resolve => setTimeout(resolve, 10)) 34 + const element = queryFn(); 35 + if (!element) return; 36 + await new Promise((resolve) => setTimeout(resolve, 10)); 37 37 } 38 - throw new Error('Element still present after timeout') 38 + throw new Error("Element still present after timeout"); 39 39 } 40 40 41 41 export async function waitForText( 42 42 container: HTMLElement, 43 43 text: string | RegExp, 44 - timeout = 1000 44 + timeout = 1000, 45 45 ): Promise<void> { 46 - const start = Date.now() 46 + const start = Date.now(); 47 47 while (Date.now() - start < timeout) { 48 - const content = container.textContent || '' 49 - if (typeof text === 'string' ? content.includes(text) : text.test(content)) { 50 - return 48 + const content = container.textContent || ""; 49 + if ( 50 + typeof text === "string" ? content.includes(text) : text.test(content) 51 + ) { 52 + return; 51 53 } 52 - await new Promise(resolve => setTimeout(resolve, 10)) 54 + await new Promise((resolve) => setTimeout(resolve, 10)); 53 55 } 54 - throw new Error(`Text "${text}" not found within timeout`) 56 + throw new Error(`Text "${text}" not found within timeout`); 55 57 } 56 58 57 - export function mockLocalStorage(initialData: Record<string, string> = {}): void { 58 - const store: Record<string, string> = { ...initialData } 59 - Object.defineProperty(window, 'localStorage', { 59 + export function mockLocalStorage( 60 + initialData: Record<string, string> = {}, 61 + ): void { 62 + const store: Record<string, string> = { ...initialData }; 63 + Object.defineProperty(window, "localStorage", { 60 64 value: { 61 65 getItem: (key: string) => store[key] || null, 62 - setItem: (key: string, value: string) => { store[key] = value }, 63 - removeItem: (key: string) => { delete store[key] }, 64 - clear: () => { Object.keys(store).forEach(key => delete store[key]) }, 66 + setItem: (key: string, value: string) => { 67 + store[key] = value; 68 + }, 69 + removeItem: (key: string) => { 70 + delete store[key]; 71 + }, 72 + clear: () => { 73 + Object.keys(store).forEach((key) => delete store[key]); 74 + }, 65 75 key: (index: number) => Object.keys(store)[index] || null, 66 - get length() { return Object.keys(store).length }, 76 + get length() { 77 + return Object.keys(store).length; 78 + }, 67 79 }, 68 80 writable: true, 69 - }) 81 + }); 70 82 } 71 83 72 84 export function setAuthState(session: { 73 - did: string 74 - handle: string 75 - email?: string 76 - emailConfirmed?: boolean 77 - accessJwt: string 78 - refreshJwt: string 85 + did: string; 86 + handle: string; 87 + email?: string; 88 + emailConfirmed?: boolean; 89 + accessJwt: string; 90 + refreshJwt: string; 79 91 }): void { 80 - localStorage.setItem('session', JSON.stringify(session)) 92 + localStorage.setItem("session", JSON.stringify(session)); 81 93 } 82 94 83 95 export function clearAuthState(): void { 84 - localStorage.removeItem('session') 96 + localStorage.removeItem("session"); 85 97 }
+3 -3
frontend/svelte.config.js
··· 1 - import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' 2 - const isTest = process.env.VITEST === 'true' || process.env.VITEST === true 1 + import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; 2 + const isTest = process.env.VITEST === "true" || process.env.VITEST === true; 3 3 export default { 4 4 preprocess: isTest ? [] : vitePreprocess(), 5 - } 5 + };
+14 -14
frontend/vite.config.ts
··· 1 - import { defineConfig, loadEnv } from 'vite' 2 - import { svelte } from '@sveltejs/vite-plugin-svelte' 1 + import { defineConfig, loadEnv } from "vite"; 2 + import { svelte } from "@sveltejs/vite-plugin-svelte"; 3 3 4 4 export default defineConfig(({ mode }) => { 5 - const env = loadEnv(mode, process.cwd(), '') 6 - const target = env.VITE_API_URL || 'http://localhost:3000' 5 + const env = loadEnv(mode, process.cwd(), ""); 6 + const target = env.VITE_API_URL || "http://localhost:3000"; 7 7 8 8 return { 9 9 plugins: [svelte()], 10 10 build: { 11 - outDir: 'dist', 11 + outDir: "dist", 12 12 }, 13 13 server: { 14 14 port: 5173, 15 15 proxy: { 16 - '/xrpc': target, 17 - '/oauth': target, 18 - '/.well-known': target, 19 - '/health': target, 20 - '/u': target, 21 - } 22 - } 23 - } 24 - }) 16 + "/xrpc": target, 17 + "/oauth": target, 18 + "/.well-known": target, 19 + "/health": target, 20 + "/u": target, 21 + }, 22 + }, 23 + }; 24 + });
+8 -8
frontend/vitest.config.ts
··· 1 - import { defineConfig } from 'vitest/config' 2 - import { svelte } from '@sveltejs/vite-plugin-svelte' 1 + import { defineConfig } from "vitest/config"; 2 + import { svelte } from "@sveltejs/vite-plugin-svelte"; 3 3 export default defineConfig({ 4 4 plugins: [ 5 5 svelte({ ··· 7 7 }), 8 8 ], 9 9 resolve: { 10 - conditions: ['browser', 'development'], 10 + conditions: ["browser", "development"], 11 11 }, 12 12 test: { 13 - environment: 'jsdom', 13 + environment: "jsdom", 14 14 globals: true, 15 - setupFiles: ['./src/tests/setup.ts'], 16 - include: ['src/**/*.{test,spec}.{js,ts}'], 15 + setupFiles: ["./src/tests/setup.ts"], 16 + include: ["src/**/*.{test,spec}.{js,ts}"], 17 17 alias: { 18 - 'svelte': 'svelte', 18 + "svelte": "svelte", 19 19 }, 20 20 }, 21 - }) 21 + });
+1 -4
src/oauth/client.rs
··· 126 126 client_uri: None, 127 127 logo_uri: None, 128 128 redirect_uris, 129 - grant_types: vec![ 130 - "authorization_code".into(), 131 - "refresh_token".into(), 132 - ], 129 + grant_types: vec!["authorization_code".into(), "refresh_token".into()], 133 130 response_types: vec!["code".into()], 134 131 scope, 135 132 token_endpoint_auth_method: Some("none".into()),