this repo has no description

Frontend style updates

lewis ed3d8331 d4dc177f

+12 -3
frontend/index.html
··· 6 <title>Tranquil PDS</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&display=swap" rel="stylesheet"> 10 <style> 11 - html { background: #ffffff; } 12 - @media (prefers-color-scheme: dark) { html { background: #0a0a0a; } } 13 </style> 14 </head> 15 <body>
··· 6 <title>Tranquil PDS</title> 7 <link rel="preconnect" href="https://fonts.googleapis.com"> 8 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> 9 + <link 10 + href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" 11 + rel="stylesheet" 12 + > 13 <style> 14 + html { 15 + background: #f9fafa; 16 + } 17 + @media (prefers-color-scheme: dark) { 18 + html { 19 + background: #0a0c0c; 20 + } 21 + } 22 </style> 23 </head> 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 padding: var(--space-7); 45 } 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); } 50 51 header { 52 margin-bottom: var(--space-7);
··· 44 padding: var(--space-7); 45 } 46 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 51 header { 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' 2 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 11 } 12 } 13 14 - let tokenRefreshCallback: (() => Promise<string | null>) | null = null 15 16 - export function setTokenRefreshCallback(callback: () => Promise<string | null>) { 17 - tokenRefreshCallback = callback 18 } 19 20 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 26 }): Promise<T> { 27 - const { method: httpMethod = 'GET', params, body, token, skipRetry } = options ?? {} 28 - let url = `${API_BASE}/${method}` 29 if (params) { 30 - const searchParams = new URLSearchParams(params) 31 - url += `?${searchParams}` 32 } 33 - const headers: Record<string, string> = {} 34 if (token) { 35 - headers['Authorization'] = `Bearer ${token}` 36 } 37 if (body) { 38 - headers['Content-Type'] = 'application/json' 39 } 40 const res = await fetch(url, { 41 method: httpMethod, 42 headers, 43 body: body ? JSON.stringify(body) : undefined, 44 - }) 45 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() 49 if (newToken && newToken !== token) { 50 - return xrpc(method, { ...options, token: newToken, skipRetry: true }) 51 } 52 } 53 - throw new ApiError(res.status, err.error, err.message, err.did, err.reauthMethods) 54 } 55 - return res.json() 56 } 57 58 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 70 } 71 72 export interface AppPassword { 73 - name: string 74 - createdAt: string 75 } 76 77 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 }[] 85 } 86 87 - export type VerificationChannel = 'email' | 'discord' | 'telegram' | 'signal' 88 89 - export type DidType = 'plc' | 'web' | 'web-external' 90 91 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 103 } 104 105 export interface CreateAccountResult { 106 - handle: string 107 - did: string 108 - verificationRequired: boolean 109 - verificationChannel: string 110 } 111 112 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 121 } 122 123 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' } 127 if (byodToken) { 128 - headers['Authorization'] = `Bearer ${byodToken}` 129 } 130 const response = await fetch(url, { 131 - method: 'POST', 132 headers, 133 body: JSON.stringify({ 134 handle: params.handle, ··· 143 telegramUsername: params.telegramUsername, 144 signalNumber: params.signalNumber, 145 }), 146 - }) 147 - const data = await response.json() 148 if (!response.ok) { 149 - throw new ApiError(data.error, data.message, response.status) 150 } 151 - return data 152 }, 153 154 - async confirmSignup(did: string, verificationCode: string): Promise<ConfirmSignupResult> { 155 - return xrpc('com.atproto.server.confirmSignup', { 156 - method: 'POST', 157 body: { did, verificationCode }, 158 - }) 159 }, 160 161 async resendVerification(did: string): Promise<{ success: boolean }> { 162 - return xrpc('com.atproto.server.resendVerification', { 163 - method: 'POST', 164 body: { did }, 165 - }) 166 }, 167 168 async createSession(identifier: string, password: string): Promise<Session> { 169 - return xrpc('com.atproto.server.createSession', { 170 - method: 'POST', 171 body: { identifier, password }, 172 - }) 173 }, 174 175 async getSession(token: string): Promise<Session> { 176 - return xrpc('com.atproto.server.getSession', { token }) 177 }, 178 179 async refreshSession(refreshJwt: string): Promise<Session> { 180 - return xrpc('com.atproto.server.refreshSession', { 181 - method: 'POST', 182 token: refreshJwt, 183 - }) 184 }, 185 186 async deleteSession(token: string): Promise<void> { 187 - await xrpc('com.atproto.server.deleteSession', { 188 - method: 'POST', 189 token, 190 - }) 191 }, 192 193 async listAppPasswords(token: string): Promise<{ passwords: AppPassword[] }> { 194 - return xrpc('com.atproto.server.listAppPasswords', { token }) 195 }, 196 197 - async createAppPassword(token: string, name: string): Promise<{ name: string; password: string; createdAt: string }> { 198 - return xrpc('com.atproto.server.createAppPassword', { 199 - method: 'POST', 200 token, 201 body: { name }, 202 - }) 203 }, 204 205 async revokeAppPassword(token: string, name: string): Promise<void> { 206 - await xrpc('com.atproto.server.revokeAppPassword', { 207 - method: 'POST', 208 token, 209 body: { name }, 210 - }) 211 }, 212 213 async getAccountInviteCodes(token: string): Promise<{ codes: InviteCode[] }> { 214 - return xrpc('com.atproto.server.getAccountInviteCodes', { token }) 215 }, 216 217 - async createInviteCode(token: string, useCount: number = 1): Promise<{ code: string }> { 218 - return xrpc('com.atproto.server.createInviteCode', { 219 - method: 'POST', 220 token, 221 body: { useCount }, 222 - }) 223 }, 224 225 async requestPasswordReset(email: string): Promise<void> { 226 - await xrpc('com.atproto.server.requestPasswordReset', { 227 - method: 'POST', 228 body: { email }, 229 - }) 230 }, 231 232 async resetPassword(token: string, password: string): Promise<void> { 233 - await xrpc('com.atproto.server.resetPassword', { 234 - method: 'POST', 235 body: { token, password }, 236 - }) 237 }, 238 239 - async requestEmailUpdate(token: string, email: string): Promise<{ tokenRequired: boolean }> { 240 - return xrpc('com.atproto.server.requestEmailUpdate', { 241 - method: 'POST', 242 token, 243 body: { email }, 244 - }) 245 }, 246 247 - async updateEmail(token: string, email: string, emailToken?: string): Promise<void> { 248 - await xrpc('com.atproto.server.updateEmail', { 249 - method: 'POST', 250 token, 251 body: { email, token: emailToken }, 252 - }) 253 }, 254 255 async updateHandle(token: string, handle: string): Promise<void> { 256 - await xrpc('com.atproto.identity.updateHandle', { 257 - method: 'POST', 258 token, 259 body: { handle }, 260 - }) 261 }, 262 263 async requestAccountDelete(token: string): Promise<void> { 264 - await xrpc('com.atproto.server.requestAccountDelete', { 265 - method: 'POST', 266 token, 267 - }) 268 }, 269 270 - async deleteAccount(did: string, password: string, deleteToken: string): Promise<void> { 271 - await xrpc('com.atproto.server.deleteAccount', { 272 - method: 'POST', 273 body: { did, password, token: deleteToken }, 274 - }) 275 }, 276 277 async describeServer(): Promise<{ 278 - availableUserDomains: string[] 279 - inviteCodeRequired: boolean 280 - links?: { privacyPolicy?: string; termsOfService?: string } 281 - version?: string 282 - availableCommsChannels?: string[] 283 }> { 284 - return xrpc('com.atproto.server.describeServer') 285 }, 286 287 async listRepos(limit?: number): Promise<{ 288 - repos: Array<{ did: string; head: string; rev: string }> 289 - cursor?: string 290 }> { 291 - const params: Record<string, string> = {} 292 - if (limit) params.limit = String(limit) 293 - return xrpc('com.atproto.sync.listRepos', { params }) 294 }, 295 296 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 305 }> { 306 - return xrpc('com.tranquil.account.getNotificationPrefs', { token }) 307 }, 308 309 async updateNotificationPrefs(token: string, prefs: { 310 - preferredChannel?: string 311 - discordId?: string 312 - telegramUsername?: string 313 - signalNumber?: string 314 }): Promise<{ success: boolean }> { 315 - return xrpc('com.tranquil.account.updateNotificationPrefs', { 316 - method: 'POST', 317 token, 318 body: prefs, 319 - }) 320 }, 321 322 - async confirmChannelVerification(token: string, channel: string, identifier: string, code: string): Promise<{ success: boolean }> { 323 - return xrpc('com.tranquil.account.confirmChannelVerification', { 324 - method: 'POST', 325 token, 326 body: { channel, identifier, code }, 327 - }) 328 }, 329 330 async getNotificationHistory(token: string): Promise<{ 331 notifications: Array<{ 332 - createdAt: string 333 - channel: string 334 - notificationType: string 335 - status: string 336 - subject: string | null 337 - body: string 338 - }> 339 }> { 340 - return xrpc('com.tranquil.account.getNotificationHistory', { token }) 341 }, 342 343 async getServerStats(token: string): Promise<{ 344 - userCount: number 345 - repoCount: number 346 - recordCount: number 347 - blobStorageBytes: number 348 }> { 349 - return xrpc('com.tranquil.admin.getServerStats', { token }) 350 }, 351 352 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 359 }> { 360 - return xrpc('com.tranquil.server.getConfig') 361 }, 362 363 async updateServerConfig( 364 token: string, 365 config: { 366 - serverName?: string 367 - primaryColor?: string 368 - primaryColorDark?: string 369 - secondaryColor?: string 370 - secondaryColorDark?: string 371 - logoCid?: string 372 - } 373 ): Promise<{ success: boolean }> { 374 - return xrpc('com.tranquil.admin.updateServerConfig', { 375 - method: 'POST', 376 token, 377 body: config, 378 - }) 379 }, 380 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', 384 headers: { 385 - 'Authorization': `Bearer ${token}`, 386 - 'Content-Type': file.type, 387 }, 388 body: file, 389 - }) 390 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) 393 } 394 - return res.json() 395 }, 396 397 - async changePassword(token: string, currentPassword: string, newPassword: string): Promise<void> { 398 - await xrpc('com.tranquil.account.changePassword', { 399 - method: 'POST', 400 token, 401 body: { currentPassword, newPassword }, 402 - }) 403 }, 404 405 async removePassword(token: string): Promise<{ success: boolean }> { 406 - return xrpc('com.tranquil.account.removePassword', { 407 - method: 'POST', 408 token, 409 - }) 410 }, 411 412 async getPasswordStatus(token: string): Promise<{ hasPassword: boolean }> { 413 - return xrpc('com.tranquil.account.getPasswordStatus', { token }) 414 }, 415 416 - async getLegacyLoginPreference(token: string): Promise<{ allowLegacyLogin: boolean; hasMfa: boolean }> { 417 - return xrpc('com.tranquil.account.getLegacyLoginPreference', { token }) 418 }, 419 420 - async updateLegacyLoginPreference(token: string, allowLegacyLogin: boolean): Promise<{ allowLegacyLogin: boolean }> { 421 - return xrpc('com.tranquil.account.updateLegacyLoginPreference', { 422 - method: 'POST', 423 token, 424 body: { allowLegacyLogin }, 425 - }) 426 }, 427 428 - async updateLocale(token: string, preferredLocale: string): Promise<{ preferredLocale: string }> { 429 - return xrpc('com.tranquil.account.updateLocale', { 430 - method: 'POST', 431 token, 432 body: { preferredLocale }, 433 - }) 434 }, 435 436 async listSessions(token: string): Promise<{ 437 sessions: Array<{ 438 - id: string 439 - sessionType: string 440 - clientName: string | null 441 - createdAt: string 442 - expiresAt: string 443 - isCurrent: boolean 444 - }> 445 }> { 446 - return xrpc('com.tranquil.account.listSessions', { token }) 447 }, 448 449 async revokeSession(token: string, sessionId: string): Promise<void> { 450 - await xrpc('com.tranquil.account.revokeSession', { 451 - method: 'POST', 452 token, 453 body: { sessionId }, 454 - }) 455 }, 456 457 async revokeAllSessions(token: string): Promise<{ revokedCount: number }> { 458 - return xrpc('com.tranquil.account.revokeAllSessions', { 459 - method: 'POST', 460 token, 461 - }) 462 }, 463 464 async searchAccounts(token: string, options?: { 465 - handle?: string 466 - cursor?: string 467 - limit?: number 468 }): Promise<{ 469 - cursor?: string 470 accounts: Array<{ 471 - did: string 472 - handle: string 473 - email?: string 474 - indexedAt: string 475 - emailConfirmedAt?: string 476 - deactivatedAt?: string 477 - }> 478 }> { 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 }) 484 }, 485 486 async getInviteCodes(token: string, options?: { 487 - sort?: 'recent' | 'usage' 488 - cursor?: string 489 - limit?: number 490 }): Promise<{ 491 - cursor?: string 492 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 - }> 501 }> { 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 }) 507 }, 508 509 - async disableInviteCodes(token: string, codes?: string[], accounts?: string[]): Promise<void> { 510 - await xrpc('com.atproto.admin.disableInviteCodes', { 511 - method: 'POST', 512 token, 513 body: { codes, accounts }, 514 - }) 515 }, 516 517 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 525 }> { 526 - return xrpc('com.atproto.admin.getAccountInfo', { token, params: { did } }) 527 }, 528 529 async disableAccountInvites(token: string, account: string): Promise<void> { 530 - await xrpc('com.atproto.admin.disableAccountInvites', { 531 - method: 'POST', 532 token, 533 body: { account }, 534 - }) 535 }, 536 537 async enableAccountInvites(token: string, account: string): Promise<void> { 538 - await xrpc('com.atproto.admin.enableAccountInvites', { 539 - method: 'POST', 540 token, 541 body: { account }, 542 - }) 543 }, 544 545 async adminDeleteAccount(token: string, did: string): Promise<void> { 546 - await xrpc('com.atproto.admin.deleteAccount', { 547 - method: 'POST', 548 token, 549 body: { did }, 550 - }) 551 }, 552 553 async describeRepo(token: string, repo: string): Promise<{ 554 - handle: string 555 - did: string 556 - didDoc: unknown 557 - collections: string[] 558 - handleIsCorrect: boolean 559 }> { 560 - return xrpc('com.atproto.repo.describeRepo', { 561 token, 562 params: { repo }, 563 - }) 564 }, 565 566 async listRecords(token: string, repo: string, collection: string, options?: { 567 - limit?: number 568 - cursor?: string 569 - reverse?: boolean 570 }): Promise<{ 571 - records: Array<{ uri: string; cid: string; value: unknown }> 572 - cursor?: string 573 }> { 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 }) 579 }, 580 581 - async getRecord(token: string, repo: string, collection: string, rkey: string): Promise<{ 582 - uri: string 583 - cid: string 584 - value: unknown 585 }> { 586 - return xrpc('com.atproto.repo.getRecord', { 587 token, 588 params: { repo, collection, rkey }, 589 - }) 590 }, 591 592 - async createRecord(token: string, repo: string, collection: string, record: unknown, rkey?: string): Promise<{ 593 - uri: string 594 - cid: string 595 }> { 596 - return xrpc('com.atproto.repo.createRecord', { 597 - method: 'POST', 598 token, 599 body: { repo, collection, record, rkey }, 600 - }) 601 }, 602 603 - async putRecord(token: string, repo: string, collection: string, rkey: string, record: unknown): Promise<{ 604 - uri: string 605 - cid: string 606 }> { 607 - return xrpc('com.atproto.repo.putRecord', { 608 - method: 'POST', 609 token, 610 body: { repo, collection, rkey, record }, 611 - }) 612 }, 613 614 - async deleteRecord(token: string, repo: string, collection: string, rkey: string): Promise<void> { 615 - await xrpc('com.atproto.repo.deleteRecord', { 616 - method: 'POST', 617 token, 618 body: { repo, collection, rkey }, 619 - }) 620 }, 621 622 - async getTotpStatus(token: string): Promise<{ enabled: boolean; hasBackupCodes: boolean }> { 623 - return xrpc('com.atproto.server.getTotpStatus', { token }) 624 }, 625 626 - async createTotpSecret(token: string): Promise<{ uri: string; qrBase64: string }> { 627 - return xrpc('com.atproto.server.createTotpSecret', { method: 'POST', token }) 628 }, 629 630 - async enableTotp(token: string, code: string): Promise<{ success: boolean; backupCodes: string[] }> { 631 - return xrpc('com.atproto.server.enableTotp', { 632 - method: 'POST', 633 token, 634 body: { code }, 635 - }) 636 }, 637 638 - async disableTotp(token: string, password: string, code: string): Promise<{ success: boolean }> { 639 - return xrpc('com.atproto.server.disableTotp', { 640 - method: 'POST', 641 token, 642 body: { password, code }, 643 - }) 644 }, 645 646 - async regenerateBackupCodes(token: string, password: string, code: string): Promise<{ backupCodes: string[] }> { 647 - return xrpc('com.atproto.server.regenerateBackupCodes', { 648 - method: 'POST', 649 token, 650 body: { password, code }, 651 - }) 652 }, 653 654 - async startPasskeyRegistration(token: string, friendlyName?: string): Promise<{ options: unknown }> { 655 - return xrpc('com.atproto.server.startPasskeyRegistration', { 656 - method: 'POST', 657 token, 658 body: { friendlyName }, 659 - }) 660 }, 661 662 - async finishPasskeyRegistration(token: string, credential: unknown, friendlyName?: string): Promise<{ id: string; credentialId: string }> { 663 - return xrpc('com.atproto.server.finishPasskeyRegistration', { 664 - method: 'POST', 665 token, 666 body: { credential, friendlyName }, 667 - }) 668 }, 669 670 async listPasskeys(token: string): Promise<{ 671 passkeys: Array<{ 672 - id: string 673 - credentialId: string 674 - friendlyName: string | null 675 - createdAt: string 676 - lastUsed: string | null 677 - }> 678 }> { 679 - return xrpc('com.atproto.server.listPasskeys', { token }) 680 }, 681 682 async deletePasskey(token: string, id: string): Promise<void> { 683 - await xrpc('com.atproto.server.deletePasskey', { 684 - method: 'POST', 685 token, 686 body: { id }, 687 - }) 688 }, 689 690 - async updatePasskey(token: string, id: string, friendlyName: string): Promise<void> { 691 - await xrpc('com.atproto.server.updatePasskey', { 692 - method: 'POST', 693 token, 694 body: { id, friendlyName }, 695 - }) 696 }, 697 698 async listTrustedDevices(token: string): Promise<{ 699 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 - }> 707 }> { 708 - return xrpc('com.tranquil.account.listTrustedDevices', { token }) 709 }, 710 711 - async revokeTrustedDevice(token: string, deviceId: string): Promise<{ success: boolean }> { 712 - return xrpc('com.tranquil.account.revokeTrustedDevice', { 713 - method: 'POST', 714 token, 715 body: { deviceId }, 716 - }) 717 }, 718 719 - async updateTrustedDevice(token: string, deviceId: string, friendlyName: string): Promise<{ success: boolean }> { 720 - return xrpc('com.tranquil.account.updateTrustedDevice', { 721 - method: 'POST', 722 token, 723 body: { deviceId, friendlyName }, 724 - }) 725 }, 726 727 async getReauthStatus(token: string): Promise<{ 728 - requiresReauth: boolean 729 - lastReauthAt: string | null 730 - availableMethods: string[] 731 }> { 732 - return xrpc('com.tranquil.account.getReauthStatus', { token }) 733 }, 734 735 - async reauthPassword(token: string, password: string): Promise<{ success: boolean; reauthAt: string }> { 736 - return xrpc('com.tranquil.account.reauthPassword', { 737 - method: 'POST', 738 token, 739 body: { password }, 740 - }) 741 }, 742 743 - async reauthTotp(token: string, code: string): Promise<{ success: boolean; reauthAt: string }> { 744 - return xrpc('com.tranquil.account.reauthTotp', { 745 - method: 'POST', 746 token, 747 body: { code }, 748 - }) 749 }, 750 751 async reauthPasskeyStart(token: string): Promise<{ options: unknown }> { 752 - return xrpc('com.tranquil.account.reauthPasskeyStart', { 753 - method: 'POST', 754 token, 755 - }) 756 }, 757 758 - async reauthPasskeyFinish(token: string, credential: unknown): Promise<{ success: boolean; reauthAt: string }> { 759 - return xrpc('com.tranquil.account.reauthPasskeyFinish', { 760 - method: 'POST', 761 token, 762 body: { credential }, 763 - }) 764 }, 765 766 async reserveSigningKey(did?: string): Promise<{ signingKey: string }> { 767 - return xrpc('com.atproto.server.reserveSigningKey', { 768 - method: 'POST', 769 body: { did }, 770 - }) 771 }, 772 773 async getRecommendedDidCredentials(token: string): Promise<{ 774 - rotationKeys?: string[] 775 - alsoKnownAs?: string[] 776 - verificationMethods?: { atproto?: string } 777 - services?: { atproto_pds?: { type: string; endpoint: string } } 778 }> { 779 - return xrpc('com.atproto.identity.getRecommendedDidCredentials', { token }) 780 }, 781 782 async activateAccount(token: string): Promise<void> { 783 - await xrpc('com.atproto.server.activateAccount', { 784 - method: 'POST', 785 token, 786 - }) 787 }, 788 789 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 800 }, byodToken?: string): Promise<{ 801 - did: string 802 - handle: string 803 - setupToken: string 804 - setupExpiresAt: string 805 }> { 806 - const url = `${API_BASE}/com.tranquil.account.createPasskeyAccount` 807 const headers: Record<string, string> = { 808 - 'Content-Type': 'application/json' 809 - } 810 if (byodToken) { 811 - headers['Authorization'] = `Bearer ${byodToken}` 812 } 813 const res = await fetch(url, { 814 - method: 'POST', 815 headers, 816 body: JSON.stringify(params), 817 - }) 818 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) 821 } 822 - return res.json() 823 }, 824 825 - async startPasskeyRegistrationForSetup(did: string, setupToken: string, friendlyName?: string): Promise<{ options: unknown }> { 826 - return xrpc('com.tranquil.account.startPasskeyRegistrationForSetup', { 827 - method: 'POST', 828 body: { did, setupToken, friendlyName }, 829 - }) 830 }, 831 832 - async completePasskeySetup(did: string, setupToken: string, passkeyCredential: unknown, passkeyFriendlyName?: string): Promise<{ 833 - did: string 834 - handle: string 835 - appPassword: string 836 - appPasswordName: string 837 }> { 838 - return xrpc('com.tranquil.account.completePasskeySetup', { 839 - method: 'POST', 840 body: { did, setupToken, passkeyCredential, passkeyFriendlyName }, 841 - }) 842 }, 843 844 async requestPasskeyRecovery(email: string): Promise<{ success: boolean }> { 845 - return xrpc('com.tranquil.account.requestPasskeyRecovery', { 846 - method: 'POST', 847 body: { email }, 848 - }) 849 }, 850 851 - async recoverPasskeyAccount(did: string, recoveryToken: string, newPassword: string): Promise<{ success: boolean }> { 852 - return xrpc('com.tranquil.account.recoverPasskeyAccount', { 853 - method: 'POST', 854 body: { did, recoveryToken, newPassword }, 855 - }) 856 }, 857 858 - async verifyMigrationEmail(token: string, email: string): Promise<{ success: boolean; did: string }> { 859 - return xrpc('com.atproto.server.verifyMigrationEmail', { 860 - method: 'POST', 861 body: { token, email }, 862 - }) 863 }, 864 865 async resendMigrationVerification(email: string): Promise<{ sent: boolean }> { 866 - return xrpc('com.atproto.server.resendMigrationVerification', { 867 - method: 'POST', 868 body: { email }, 869 - }) 870 }, 871 872 - async verifyToken(token: string, identifier: string, accessToken?: string): Promise<{ 873 - success: boolean 874 - did: string 875 - purpose: string 876 - channel: string 877 }> { 878 - return xrpc('com.tranquil.account.verifyToken', { 879 - method: 'POST', 880 body: { token, identifier }, 881 token: accessToken, 882 - }) 883 }, 884 - }
··· 1 + const API_BASE = "/xrpc"; 2 3 export class ApiError extends Error { 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; 17 } 18 } 19 20 + let tokenRefreshCallback: (() => Promise<string | null>) | null = null; 21 22 + export function setTokenRefreshCallback( 23 + callback: () => Promise<string | null>, 24 + ) { 25 + tokenRefreshCallback = callback; 26 } 27 28 async function xrpc<T>(method: string, options?: { 29 + method?: "GET" | "POST"; 30 + params?: Record<string, string>; 31 + body?: unknown; 32 + token?: string; 33 + skipRetry?: boolean; 34 }): Promise<T> { 35 + const { method: httpMethod = "GET", params, body, token, skipRetry } = 36 + options ?? {}; 37 + let url = `${API_BASE}/${method}`; 38 if (params) { 39 + const searchParams = new URLSearchParams(params); 40 + url += `?${searchParams}`; 41 } 42 + const headers: Record<string, string> = {}; 43 if (token) { 44 + headers["Authorization"] = `Bearer ${token}`; 45 } 46 if (body) { 47 + headers["Content-Type"] = "application/json"; 48 } 49 const res = await fetch(url, { 50 method: httpMethod, 51 headers, 52 body: body ? JSON.stringify(body) : undefined, 53 + }); 54 if (!res.ok) { 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(); 64 if (newToken && newToken !== token) { 65 + return xrpc(method, { ...options, token: newToken, skipRetry: true }); 66 } 67 } 68 + throw new ApiError( 69 + res.status, 70 + err.error, 71 + err.message, 72 + err.did, 73 + err.reauthMethods, 74 + ); 75 } 76 + return res.json(); 77 } 78 79 export interface Session { 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; 91 } 92 93 export interface AppPassword { 94 + name: string; 95 + createdAt: string; 96 } 97 98 export interface InviteCode { 99 + code: string; 100 + available: number; 101 + disabled: boolean; 102 + forAccount: string; 103 + createdBy: string; 104 + createdAt: string; 105 + uses: { usedBy: string; usedAt: string }[]; 106 } 107 108 + export type VerificationChannel = "email" | "discord" | "telegram" | "signal"; 109 110 + export type DidType = "plc" | "web" | "web-external"; 111 112 export interface CreateAccountParams { 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; 124 } 125 126 export interface CreateAccountResult { 127 + handle: string; 128 + did: string; 129 + verificationRequired: boolean; 130 + verificationChannel: string; 131 } 132 133 export interface ConfirmSignupResult { 134 + accessJwt: string; 135 + refreshJwt: string; 136 + handle: string; 137 + did: string; 138 + email?: string; 139 + emailConfirmed?: boolean; 140 + preferredChannel?: string; 141 + preferredChannelVerified?: boolean; 142 } 143 144 export const api = { 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 + }; 153 if (byodToken) { 154 + headers["Authorization"] = `Bearer ${byodToken}`; 155 } 156 const response = await fetch(url, { 157 + method: "POST", 158 headers, 159 body: JSON.stringify({ 160 handle: params.handle, ··· 169 telegramUsername: params.telegramUsername, 170 signalNumber: params.signalNumber, 171 }), 172 + }); 173 + const data = await response.json(); 174 if (!response.ok) { 175 + throw new ApiError(data.error, data.message, response.status); 176 } 177 + return data; 178 }, 179 180 + async confirmSignup( 181 + did: string, 182 + verificationCode: string, 183 + ): Promise<ConfirmSignupResult> { 184 + return xrpc("com.atproto.server.confirmSignup", { 185 + method: "POST", 186 body: { did, verificationCode }, 187 + }); 188 }, 189 190 async resendVerification(did: string): Promise<{ success: boolean }> { 191 + return xrpc("com.atproto.server.resendVerification", { 192 + method: "POST", 193 body: { did }, 194 + }); 195 }, 196 197 async createSession(identifier: string, password: string): Promise<Session> { 198 + return xrpc("com.atproto.server.createSession", { 199 + method: "POST", 200 body: { identifier, password }, 201 + }); 202 }, 203 204 async getSession(token: string): Promise<Session> { 205 + return xrpc("com.atproto.server.getSession", { token }); 206 }, 207 208 async refreshSession(refreshJwt: string): Promise<Session> { 209 + return xrpc("com.atproto.server.refreshSession", { 210 + method: "POST", 211 token: refreshJwt, 212 + }); 213 }, 214 215 async deleteSession(token: string): Promise<void> { 216 + await xrpc("com.atproto.server.deleteSession", { 217 + method: "POST", 218 token, 219 + }); 220 }, 221 222 async listAppPasswords(token: string): Promise<{ passwords: AppPassword[] }> { 223 + return xrpc("com.atproto.server.listAppPasswords", { token }); 224 }, 225 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", 232 token, 233 body: { name }, 234 + }); 235 }, 236 237 async revokeAppPassword(token: string, name: string): Promise<void> { 238 + await xrpc("com.atproto.server.revokeAppPassword", { 239 + method: "POST", 240 token, 241 body: { name }, 242 + }); 243 }, 244 245 async getAccountInviteCodes(token: string): Promise<{ codes: InviteCode[] }> { 246 + return xrpc("com.atproto.server.getAccountInviteCodes", { token }); 247 }, 248 249 + async createInviteCode( 250 + token: string, 251 + useCount: number = 1, 252 + ): Promise<{ code: string }> { 253 + return xrpc("com.atproto.server.createInviteCode", { 254 + method: "POST", 255 token, 256 body: { useCount }, 257 + }); 258 }, 259 260 async requestPasswordReset(email: string): Promise<void> { 261 + await xrpc("com.atproto.server.requestPasswordReset", { 262 + method: "POST", 263 body: { email }, 264 + }); 265 }, 266 267 async resetPassword(token: string, password: string): Promise<void> { 268 + await xrpc("com.atproto.server.resetPassword", { 269 + method: "POST", 270 body: { token, password }, 271 + }); 272 }, 273 274 + async requestEmailUpdate( 275 + token: string, 276 + email: string, 277 + ): Promise<{ tokenRequired: boolean }> { 278 + return xrpc("com.atproto.server.requestEmailUpdate", { 279 + method: "POST", 280 token, 281 body: { email }, 282 + }); 283 }, 284 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", 292 token, 293 body: { email, token: emailToken }, 294 + }); 295 }, 296 297 async updateHandle(token: string, handle: string): Promise<void> { 298 + await xrpc("com.atproto.identity.updateHandle", { 299 + method: "POST", 300 token, 301 body: { handle }, 302 + }); 303 }, 304 305 async requestAccountDelete(token: string): Promise<void> { 306 + await xrpc("com.atproto.server.requestAccountDelete", { 307 + method: "POST", 308 token, 309 + }); 310 }, 311 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", 319 body: { did, password, token: deleteToken }, 320 + }); 321 }, 322 323 async describeServer(): Promise<{ 324 + availableUserDomains: string[]; 325 + inviteCodeRequired: boolean; 326 + links?: { privacyPolicy?: string; termsOfService?: string }; 327 + version?: string; 328 + availableCommsChannels?: string[]; 329 }> { 330 + return xrpc("com.atproto.server.describeServer"); 331 }, 332 333 async listRepos(limit?: number): Promise<{ 334 + repos: Array<{ did: string; head: string; rev: string }>; 335 + cursor?: string; 336 }> { 337 + const params: Record<string, string> = {}; 338 + if (limit) params.limit = String(limit); 339 + return xrpc("com.atproto.sync.listRepos", { params }); 340 }, 341 342 async getNotificationPrefs(token: string): Promise<{ 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; 351 }> { 352 + return xrpc("com.tranquil.account.getNotificationPrefs", { token }); 353 }, 354 355 async updateNotificationPrefs(token: string, prefs: { 356 + preferredChannel?: string; 357 + discordId?: string; 358 + telegramUsername?: string; 359 + signalNumber?: string; 360 }): Promise<{ success: boolean }> { 361 + return xrpc("com.tranquil.account.updateNotificationPrefs", { 362 + method: "POST", 363 token, 364 body: prefs, 365 + }); 366 }, 367 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", 376 token, 377 body: { channel, identifier, code }, 378 + }); 379 }, 380 381 async getNotificationHistory(token: string): Promise<{ 382 notifications: Array<{ 383 + createdAt: string; 384 + channel: string; 385 + notificationType: string; 386 + status: string; 387 + subject: string | null; 388 + body: string; 389 + }>; 390 }> { 391 + return xrpc("com.tranquil.account.getNotificationHistory", { token }); 392 }, 393 394 async getServerStats(token: string): Promise<{ 395 + userCount: number; 396 + repoCount: number; 397 + recordCount: number; 398 + blobStorageBytes: number; 399 }> { 400 + return xrpc("com.tranquil.admin.getServerStats", { token }); 401 }, 402 403 async getServerConfig(): Promise<{ 404 + serverName: string; 405 + primaryColor: string | null; 406 + primaryColorDark: string | null; 407 + secondaryColor: string | null; 408 + secondaryColorDark: string | null; 409 + logoCid: string | null; 410 }> { 411 + return xrpc("com.tranquil.server.getConfig"); 412 }, 413 414 async updateServerConfig( 415 token: string, 416 config: { 417 + serverName?: string; 418 + primaryColor?: string; 419 + primaryColorDark?: string; 420 + secondaryColor?: string; 421 + secondaryColorDark?: string; 422 + logoCid?: string; 423 + }, 424 ): Promise<{ success: boolean }> { 425 + return xrpc("com.tranquil.admin.updateServerConfig", { 426 + method: "POST", 427 token, 428 body: config, 429 + }); 430 }, 431 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", 447 headers: { 448 + "Authorization": `Bearer ${token}`, 449 + "Content-Type": file.type, 450 }, 451 body: file, 452 + }); 453 if (!res.ok) { 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); 459 } 460 + return res.json(); 461 }, 462 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", 470 token, 471 body: { currentPassword, newPassword }, 472 + }); 473 }, 474 475 async removePassword(token: string): Promise<{ success: boolean }> { 476 + return xrpc("com.tranquil.account.removePassword", { 477 + method: "POST", 478 token, 479 + }); 480 }, 481 482 async getPasswordStatus(token: string): Promise<{ hasPassword: boolean }> { 483 + return xrpc("com.tranquil.account.getPasswordStatus", { token }); 484 }, 485 486 + async getLegacyLoginPreference( 487 + token: string, 488 + ): Promise<{ allowLegacyLogin: boolean; hasMfa: boolean }> { 489 + return xrpc("com.tranquil.account.getLegacyLoginPreference", { token }); 490 }, 491 492 + async updateLegacyLoginPreference( 493 + token: string, 494 + allowLegacyLogin: boolean, 495 + ): Promise<{ allowLegacyLogin: boolean }> { 496 + return xrpc("com.tranquil.account.updateLegacyLoginPreference", { 497 + method: "POST", 498 token, 499 body: { allowLegacyLogin }, 500 + }); 501 }, 502 503 + async updateLocale( 504 + token: string, 505 + preferredLocale: string, 506 + ): Promise<{ preferredLocale: string }> { 507 + return xrpc("com.tranquil.account.updateLocale", { 508 + method: "POST", 509 token, 510 body: { preferredLocale }, 511 + }); 512 }, 513 514 async listSessions(token: string): Promise<{ 515 sessions: Array<{ 516 + id: string; 517 + sessionType: string; 518 + clientName: string | null; 519 + createdAt: string; 520 + expiresAt: string; 521 + isCurrent: boolean; 522 + }>; 523 }> { 524 + return xrpc("com.tranquil.account.listSessions", { token }); 525 }, 526 527 async revokeSession(token: string, sessionId: string): Promise<void> { 528 + await xrpc("com.tranquil.account.revokeSession", { 529 + method: "POST", 530 token, 531 body: { sessionId }, 532 + }); 533 }, 534 535 async revokeAllSessions(token: string): Promise<{ revokedCount: number }> { 536 + return xrpc("com.tranquil.account.revokeAllSessions", { 537 + method: "POST", 538 token, 539 + }); 540 }, 541 542 async searchAccounts(token: string, options?: { 543 + handle?: string; 544 + cursor?: string; 545 + limit?: number; 546 }): Promise<{ 547 + cursor?: string; 548 accounts: Array<{ 549 + did: string; 550 + handle: string; 551 + email?: string; 552 + indexedAt: string; 553 + emailConfirmedAt?: string; 554 + deactivatedAt?: string; 555 + }>; 556 }> { 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 }); 562 }, 563 564 async getInviteCodes(token: string, options?: { 565 + sort?: "recent" | "usage"; 566 + cursor?: string; 567 + limit?: number; 568 }): Promise<{ 569 + cursor?: string; 570 codes: Array<{ 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 + }>; 579 }> { 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 }); 585 }, 586 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", 594 token, 595 body: { codes, accounts }, 596 + }); 597 }, 598 599 async getAccountInfo(token: string, did: string): Promise<{ 600 + did: string; 601 + handle: string; 602 + email?: string; 603 + indexedAt: string; 604 + emailConfirmedAt?: string; 605 + invitesDisabled?: boolean; 606 + deactivatedAt?: string; 607 }> { 608 + return xrpc("com.atproto.admin.getAccountInfo", { token, params: { did } }); 609 }, 610 611 async disableAccountInvites(token: string, account: string): Promise<void> { 612 + await xrpc("com.atproto.admin.disableAccountInvites", { 613 + method: "POST", 614 token, 615 body: { account }, 616 + }); 617 }, 618 619 async enableAccountInvites(token: string, account: string): Promise<void> { 620 + await xrpc("com.atproto.admin.enableAccountInvites", { 621 + method: "POST", 622 token, 623 body: { account }, 624 + }); 625 }, 626 627 async adminDeleteAccount(token: string, did: string): Promise<void> { 628 + await xrpc("com.atproto.admin.deleteAccount", { 629 + method: "POST", 630 token, 631 body: { did }, 632 + }); 633 }, 634 635 async describeRepo(token: string, repo: string): Promise<{ 636 + handle: string; 637 + did: string; 638 + didDoc: unknown; 639 + collections: string[]; 640 + handleIsCorrect: boolean; 641 }> { 642 + return xrpc("com.atproto.repo.describeRepo", { 643 token, 644 params: { repo }, 645 + }); 646 }, 647 648 async listRecords(token: string, repo: string, collection: string, options?: { 649 + limit?: number; 650 + cursor?: string; 651 + reverse?: boolean; 652 }): Promise<{ 653 + records: Array<{ uri: string; cid: string; value: unknown }>; 654 + cursor?: string; 655 }> { 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 }); 661 }, 662 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; 672 }> { 673 + return xrpc("com.atproto.repo.getRecord", { 674 token, 675 params: { repo, collection, rkey }, 676 + }); 677 }, 678 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; 688 }> { 689 + return xrpc("com.atproto.repo.createRecord", { 690 + method: "POST", 691 token, 692 body: { repo, collection, record, rkey }, 693 + }); 694 }, 695 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; 705 }> { 706 + return xrpc("com.atproto.repo.putRecord", { 707 + method: "POST", 708 token, 709 body: { repo, collection, rkey, record }, 710 + }); 711 }, 712 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", 721 token, 722 body: { repo, collection, rkey }, 723 + }); 724 }, 725 726 + async getTotpStatus( 727 + token: string, 728 + ): Promise<{ enabled: boolean; hasBackupCodes: boolean }> { 729 + return xrpc("com.atproto.server.getTotpStatus", { token }); 730 }, 731 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 + }); 739 }, 740 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", 747 token, 748 body: { code }, 749 + }); 750 }, 751 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", 759 token, 760 body: { password, code }, 761 + }); 762 }, 763 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", 771 token, 772 body: { password, code }, 773 + }); 774 }, 775 776 + async startPasskeyRegistration( 777 + token: string, 778 + friendlyName?: string, 779 + ): Promise<{ options: unknown }> { 780 + return xrpc("com.atproto.server.startPasskeyRegistration", { 781 + method: "POST", 782 token, 783 body: { friendlyName }, 784 + }); 785 }, 786 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", 794 token, 795 body: { credential, friendlyName }, 796 + }); 797 }, 798 799 async listPasskeys(token: string): Promise<{ 800 passkeys: Array<{ 801 + id: string; 802 + credentialId: string; 803 + friendlyName: string | null; 804 + createdAt: string; 805 + lastUsed: string | null; 806 + }>; 807 }> { 808 + return xrpc("com.atproto.server.listPasskeys", { token }); 809 }, 810 811 async deletePasskey(token: string, id: string): Promise<void> { 812 + await xrpc("com.atproto.server.deletePasskey", { 813 + method: "POST", 814 token, 815 body: { id }, 816 + }); 817 }, 818 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", 826 token, 827 body: { id, friendlyName }, 828 + }); 829 }, 830 831 async listTrustedDevices(token: string): Promise<{ 832 devices: Array<{ 833 + id: string; 834 + userAgent: string | null; 835 + friendlyName: string | null; 836 + trustedAt: string | null; 837 + trustedUntil: string | null; 838 + lastSeenAt: string; 839 + }>; 840 }> { 841 + return xrpc("com.tranquil.account.listTrustedDevices", { token }); 842 }, 843 844 + async revokeTrustedDevice( 845 + token: string, 846 + deviceId: string, 847 + ): Promise<{ success: boolean }> { 848 + return xrpc("com.tranquil.account.revokeTrustedDevice", { 849 + method: "POST", 850 token, 851 body: { deviceId }, 852 + }); 853 }, 854 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", 862 token, 863 body: { deviceId, friendlyName }, 864 + }); 865 }, 866 867 async getReauthStatus(token: string): Promise<{ 868 + requiresReauth: boolean; 869 + lastReauthAt: string | null; 870 + availableMethods: string[]; 871 }> { 872 + return xrpc("com.tranquil.account.getReauthStatus", { token }); 873 }, 874 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", 881 token, 882 body: { password }, 883 + }); 884 }, 885 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", 892 token, 893 body: { code }, 894 + }); 895 }, 896 897 async reauthPasskeyStart(token: string): Promise<{ options: unknown }> { 898 + return xrpc("com.tranquil.account.reauthPasskeyStart", { 899 + method: "POST", 900 token, 901 + }); 902 }, 903 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", 910 token, 911 body: { credential }, 912 + }); 913 }, 914 915 async reserveSigningKey(did?: string): Promise<{ signingKey: string }> { 916 + return xrpc("com.atproto.server.reserveSigningKey", { 917 + method: "POST", 918 body: { did }, 919 + }); 920 }, 921 922 async getRecommendedDidCredentials(token: string): Promise<{ 923 + rotationKeys?: string[]; 924 + alsoKnownAs?: string[]; 925 + verificationMethods?: { atproto?: string }; 926 + services?: { atproto_pds?: { type: string; endpoint: string } }; 927 }> { 928 + return xrpc("com.atproto.identity.getRecommendedDidCredentials", { token }); 929 }, 930 931 async activateAccount(token: string): Promise<void> { 932 + await xrpc("com.atproto.server.activateAccount", { 933 + method: "POST", 934 token, 935 + }); 936 }, 937 938 async createPasskeyAccount(params: { 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; 949 }, byodToken?: string): Promise<{ 950 + did: string; 951 + handle: string; 952 + setupToken: string; 953 + setupExpiresAt: string; 954 }> { 955 + const url = `${API_BASE}/com.tranquil.account.createPasskeyAccount`; 956 const headers: Record<string, string> = { 957 + "Content-Type": "application/json", 958 + }; 959 if (byodToken) { 960 + headers["Authorization"] = `Bearer ${byodToken}`; 961 } 962 const res = await fetch(url, { 963 + method: "POST", 964 headers, 965 body: JSON.stringify(params), 966 + }); 967 if (!res.ok) { 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); 973 } 974 + return res.json(); 975 }, 976 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", 984 body: { did, setupToken, friendlyName }, 985 + }); 986 }, 987 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; 998 }> { 999 + return xrpc("com.tranquil.account.completePasskeySetup", { 1000 + method: "POST", 1001 body: { did, setupToken, passkeyCredential, passkeyFriendlyName }, 1002 + }); 1003 }, 1004 1005 async requestPasskeyRecovery(email: string): Promise<{ success: boolean }> { 1006 + return xrpc("com.tranquil.account.requestPasskeyRecovery", { 1007 + method: "POST", 1008 body: { email }, 1009 + }); 1010 }, 1011 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", 1019 body: { did, recoveryToken, newPassword }, 1020 + }); 1021 }, 1022 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", 1029 body: { token, email }, 1030 + }); 1031 }, 1032 1033 async resendMigrationVerification(email: string): Promise<{ sent: boolean }> { 1034 + return xrpc("com.atproto.server.resendMigrationVerification", { 1035 + method: "POST", 1036 body: { email }, 1037 + }); 1038 }, 1039 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; 1049 }> { 1050 + return xrpc("com.tranquil.account.verifyToken", { 1051 + method: "POST", 1052 body: { token, identifier }, 1053 token: accessToken, 1054 + }); 1055 }, 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' 4 5 - function applyLocaleFromSession(sessionInfo: { preferredLocale?: string | null }) { 6 if (sessionInfo.preferredLocale) { 7 - setLocale(sessionInfo.preferredLocale as SupportedLocale) 8 } 9 } 10 11 - const STORAGE_KEY = 'tranquil_pds_session' 12 - const ACCOUNTS_KEY = 'tranquil_pds_accounts' 13 14 export interface SavedAccount { 15 - did: string 16 - handle: string 17 - accessJwt: string 18 - refreshJwt: string 19 } 20 21 interface AuthState { 22 - session: Session | null 23 - loading: boolean 24 - error: string | null 25 - savedAccounts: SavedAccount[] 26 } 27 28 let state = $state<AuthState>({ ··· 30 loading: true, 31 error: null, 32 savedAccounts: [], 33 - }) 34 35 function saveSession(session: Session | null) { 36 if (session) { 37 - localStorage.setItem(STORAGE_KEY, JSON.stringify(session)) 38 } else { 39 - localStorage.removeItem(STORAGE_KEY) 40 } 41 } 42 43 function loadSession(): Session | null { 44 - const stored = localStorage.getItem(STORAGE_KEY) 45 if (stored) { 46 try { 47 - return JSON.parse(stored) 48 } catch { 49 - return null 50 } 51 } 52 - return null 53 } 54 55 function loadSavedAccounts(): SavedAccount[] { 56 - const stored = localStorage.getItem(ACCOUNTS_KEY) 57 if (stored) { 58 try { 59 - return JSON.parse(stored) 60 } catch { 61 - return [] 62 } 63 } 64 - return [] 65 } 66 67 function saveSavedAccounts(accounts: SavedAccount[]) { 68 - localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts)) 69 } 70 71 function addOrUpdateSavedAccount(session: Session) { 72 - const accounts = loadSavedAccounts() 73 - const existing = accounts.findIndex(a => a.did === session.did) 74 const savedAccount: SavedAccount = { 75 did: session.did, 76 handle: session.handle, 77 accessJwt: session.accessJwt, 78 refreshJwt: session.refreshJwt, 79 - } 80 if (existing >= 0) { 81 - accounts[existing] = savedAccount 82 } else { 83 - accounts.push(savedAccount) 84 } 85 - saveSavedAccounts(accounts) 86 - state.savedAccounts = accounts 87 } 88 89 function removeSavedAccount(did: string) { 90 - const accounts = loadSavedAccounts().filter(a => a.did !== did) 91 - saveSavedAccounts(accounts) 92 - state.savedAccounts = accounts 93 } 94 95 async function tryRefreshToken(): Promise<string | null> { 96 - if (!state.session) return null 97 try { 98 - const tokens = await refreshOAuthToken(state.session.refreshJwt) 99 - const sessionInfo = await api.getSession(tokens.access_token) 100 const session: Session = { 101 ...sessionInfo, 102 accessJwt: tokens.access_token, 103 refreshJwt: tokens.refresh_token || state.session.refreshJwt, 104 - } 105 - state.session = session 106 - saveSession(session) 107 - addOrUpdateSavedAccount(session) 108 - return session.accessJwt 109 } catch { 110 - return null 111 } 112 } 113 114 export async function initAuth(): Promise<{ oauthLoginCompleted: boolean }> { 115 - setTokenRefreshCallback(tryRefreshToken) 116 - state.loading = true 117 - state.error = null 118 - state.savedAccounts = loadSavedAccounts() 119 120 - const oauthCallback = checkForOAuthCallback() 121 if (oauthCallback) { 122 - clearOAuthCallbackParams() 123 try { 124 - const tokens = await handleOAuthCallback(oauthCallback.code, oauthCallback.state) 125 - const sessionInfo = await api.getSession(tokens.access_token) 126 const session: Session = { 127 ...sessionInfo, 128 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 } 137 } catch (e) { 138 - state.error = e instanceof Error ? e.message : 'OAuth login failed' 139 - state.loading = false 140 - return { oauthLoginCompleted: false } 141 } 142 } 143 144 - const stored = loadSession() 145 if (stored) { 146 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) 151 } catch (e) { 152 if (e instanceof ApiError && e.status === 401) { 153 try { 154 - const tokens = await refreshOAuthToken(stored.refreshJwt) 155 - const sessionInfo = await api.getSession(tokens.access_token) 156 const session: Session = { 157 ...sessionInfo, 158 accessJwt: tokens.access_token, 159 refreshJwt: tokens.refresh_token || stored.refreshJwt, 160 - } 161 - state.session = session 162 - saveSession(session) 163 - addOrUpdateSavedAccount(session) 164 - applyLocaleFromSession(sessionInfo) 165 } catch (refreshError) { 166 - console.error('Token refresh failed during init:', refreshError) 167 - saveSession(null) 168 - state.session = null 169 } 170 } else { 171 - console.error('Non-401 error during getSession:', e) 172 - saveSession(null) 173 - state.session = null 174 } 175 } 176 } 177 - state.loading = false 178 - return { oauthLoginCompleted: false } 179 } 180 181 - export async function login(identifier: string, password: string): Promise<void> { 182 - state.loading = true 183 - state.error = null 184 try { 185 - const session = await api.createSession(identifier, password) 186 - state.session = session 187 - saveSession(session) 188 - addOrUpdateSavedAccount(session) 189 } catch (e) { 190 if (e instanceof ApiError) { 191 - state.error = e.message 192 } else { 193 - state.error = 'Login failed' 194 } 195 - throw e 196 } finally { 197 - state.loading = false 198 } 199 } 200 201 export async function loginWithOAuth(): Promise<void> { 202 - state.loading = true 203 - state.error = null 204 try { 205 - await startOAuthLogin() 206 } catch (e) { 207 - state.loading = false 208 - state.error = e instanceof Error ? e.message : 'Failed to start OAuth login' 209 - throw e 210 } 211 } 212 213 - export async function register(params: CreateAccountParams): Promise<CreateAccountResult> { 214 try { 215 - const result = await api.createAccount(params) 216 - return result 217 } catch (e) { 218 if (e instanceof ApiError) { 219 - state.error = e.message 220 } else { 221 - state.error = 'Registration failed' 222 } 223 - throw e 224 } 225 } 226 227 - export async function confirmSignup(did: string, verificationCode: string): Promise<void> { 228 - state.loading = true 229 - state.error = null 230 try { 231 - const result = await api.confirmSignup(did, verificationCode) 232 const session: Session = { 233 did: result.did, 234 handle: result.handle, ··· 238 emailConfirmed: result.emailConfirmed, 239 preferredChannel: result.preferredChannel, 240 preferredChannelVerified: result.preferredChannelVerified, 241 - } 242 - state.session = session 243 - saveSession(session) 244 - addOrUpdateSavedAccount(session) 245 } catch (e) { 246 if (e instanceof ApiError) { 247 - state.error = e.message 248 } else { 249 - state.error = 'Verification failed' 250 } 251 - throw e 252 } finally { 253 - state.loading = false 254 } 255 } 256 257 export async function resendVerification(did: string): Promise<void> { 258 try { 259 - await api.resendVerification(did) 260 } catch (e) { 261 if (e instanceof ApiError) { 262 - throw e 263 } 264 - throw new Error('Failed to resend verification code') 265 } 266 } 267 268 - export function setSession(session: { did: string; handle: string; accessJwt: string; refreshJwt: string }): void { 269 const newSession: Session = { 270 did: session.did, 271 handle: session.handle, 272 accessJwt: session.accessJwt, 273 refreshJwt: session.refreshJwt, 274 - } 275 - state.session = newSession 276 - saveSession(newSession) 277 - addOrUpdateSavedAccount(newSession) 278 } 279 280 export async function logout(): Promise<void> { 281 if (state.session) { 282 try { 283 - await api.deleteSession(state.session.accessJwt) 284 } catch { 285 // Ignore errors on logout 286 } 287 } 288 - state.session = null 289 - saveSession(null) 290 } 291 292 export async function switchAccount(did: string): Promise<void> { 293 - const account = state.savedAccounts.find(a => a.did === did) 294 if (!account) { 295 - throw new Error('Account not found') 296 } 297 - state.loading = true 298 - state.error = null 299 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) 304 } catch (e) { 305 if (e instanceof ApiError && e.status === 401) { 306 try { 307 - const tokens = await refreshOAuthToken(account.refreshJwt) 308 - const sessionInfo = await api.getSession(tokens.access_token) 309 const session: Session = { 310 ...sessionInfo, 311 accessJwt: tokens.access_token, 312 refreshJwt: tokens.refresh_token || account.refreshJwt, 313 - } 314 - state.session = session 315 - saveSession(session) 316 - addOrUpdateSavedAccount(session) 317 } catch { 318 - removeSavedAccount(did) 319 - state.error = 'Session expired. Please log in again.' 320 - throw new Error('Session expired') 321 } 322 } else { 323 - state.error = 'Failed to switch account' 324 - throw e 325 } 326 } finally { 327 - state.loading = false 328 } 329 } 330 331 export function forgetAccount(did: string): void { 332 - removeSavedAccount(did) 333 } 334 335 export function getAuthState() { 336 - return state 337 } 338 339 export async function refreshSession(): Promise<void> { 340 - if (!state.session) return 341 try { 342 - const sessionInfo = await api.getSession(state.session.accessJwt) 343 state.session = { 344 ...sessionInfo, 345 accessJwt: state.session.accessJwt, 346 refreshJwt: state.session.refreshJwt, 347 - } 348 - saveSession(state.session) 349 - addOrUpdateSavedAccount(state.session) 350 } catch (e) { 351 - console.error('Failed to refresh session:', e) 352 } 353 } 354 355 export function getToken(): string | null { 356 - return state.session?.accessJwt ?? null 357 } 358 359 export async function getValidToken(): Promise<string | null> { 360 - if (!state.session) return null 361 try { 362 - await api.getSession(state.session.accessJwt) 363 - return state.session.accessJwt 364 } catch (e) { 365 if (e instanceof ApiError && e.status === 401) { 366 try { 367 - const tokens = await refreshOAuthToken(state.session.refreshJwt) 368 - const sessionInfo = await api.getSession(tokens.access_token) 369 const session: Session = { 370 ...sessionInfo, 371 accessJwt: tokens.access_token, 372 refreshJwt: tokens.refresh_token || state.session.refreshJwt, 373 - } 374 - state.session = session 375 - saveSession(session) 376 - addOrUpdateSavedAccount(session) 377 - return session.accessJwt 378 } catch { 379 - return null 380 } 381 } 382 - return null 383 } 384 } 385 386 export function isAuthenticated(): boolean { 387 - return state.session !== null 388 } 389 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 ?? [] 395 } 396 397 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) 404 }
··· 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"; 17 18 + function applyLocaleFromSession( 19 + sessionInfo: { preferredLocale?: string | null }, 20 + ) { 21 if (sessionInfo.preferredLocale) { 22 + setLocale(sessionInfo.preferredLocale as SupportedLocale); 23 } 24 } 25 26 + const STORAGE_KEY = "tranquil_pds_session"; 27 + const ACCOUNTS_KEY = "tranquil_pds_accounts"; 28 29 export interface SavedAccount { 30 + did: string; 31 + handle: string; 32 + accessJwt: string; 33 + refreshJwt: string; 34 } 35 36 interface AuthState { 37 + session: Session | null; 38 + loading: boolean; 39 + error: string | null; 40 + savedAccounts: SavedAccount[]; 41 } 42 43 let state = $state<AuthState>({ ··· 45 loading: true, 46 error: null, 47 savedAccounts: [], 48 + }); 49 50 function saveSession(session: Session | null) { 51 if (session) { 52 + localStorage.setItem(STORAGE_KEY, JSON.stringify(session)); 53 } else { 54 + localStorage.removeItem(STORAGE_KEY); 55 } 56 } 57 58 function loadSession(): Session | null { 59 + const stored = localStorage.getItem(STORAGE_KEY); 60 if (stored) { 61 try { 62 + return JSON.parse(stored); 63 } catch { 64 + return null; 65 } 66 } 67 + return null; 68 } 69 70 function loadSavedAccounts(): SavedAccount[] { 71 + const stored = localStorage.getItem(ACCOUNTS_KEY); 72 if (stored) { 73 try { 74 + return JSON.parse(stored); 75 } catch { 76 + return []; 77 } 78 } 79 + return []; 80 } 81 82 function saveSavedAccounts(accounts: SavedAccount[]) { 83 + localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts)); 84 } 85 86 function addOrUpdateSavedAccount(session: Session) { 87 + const accounts = loadSavedAccounts(); 88 + const existing = accounts.findIndex((a) => a.did === session.did); 89 const savedAccount: SavedAccount = { 90 did: session.did, 91 handle: session.handle, 92 accessJwt: session.accessJwt, 93 refreshJwt: session.refreshJwt, 94 + }; 95 if (existing >= 0) { 96 + accounts[existing] = savedAccount; 97 } else { 98 + accounts.push(savedAccount); 99 } 100 + saveSavedAccounts(accounts); 101 + state.savedAccounts = accounts; 102 } 103 104 function removeSavedAccount(did: string) { 105 + const accounts = loadSavedAccounts().filter((a) => a.did !== did); 106 + saveSavedAccounts(accounts); 107 + state.savedAccounts = accounts; 108 } 109 110 async function tryRefreshToken(): Promise<string | null> { 111 + if (!state.session) return null; 112 try { 113 + const tokens = await refreshOAuthToken(state.session.refreshJwt); 114 + const sessionInfo = await api.getSession(tokens.access_token); 115 const session: Session = { 116 ...sessionInfo, 117 accessJwt: tokens.access_token, 118 refreshJwt: tokens.refresh_token || state.session.refreshJwt, 119 + }; 120 + state.session = session; 121 + saveSession(session); 122 + addOrUpdateSavedAccount(session); 123 + return session.accessJwt; 124 } catch { 125 + return null; 126 } 127 } 128 129 export async function initAuth(): Promise<{ oauthLoginCompleted: boolean }> { 130 + setTokenRefreshCallback(tryRefreshToken); 131 + state.loading = true; 132 + state.error = null; 133 + state.savedAccounts = loadSavedAccounts(); 134 135 + const oauthCallback = checkForOAuthCallback(); 136 if (oauthCallback) { 137 + clearOAuthCallbackParams(); 138 try { 139 + const tokens = await handleOAuthCallback( 140 + oauthCallback.code, 141 + oauthCallback.state, 142 + ); 143 + const sessionInfo = await api.getSession(tokens.access_token); 144 const session: Session = { 145 ...sessionInfo, 146 accessJwt: tokens.access_token, 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 }; 155 } catch (e) { 156 + state.error = e instanceof Error ? e.message : "OAuth login failed"; 157 + state.loading = false; 158 + return { oauthLoginCompleted: false }; 159 } 160 } 161 162 + const stored = loadSession(); 163 if (stored) { 164 try { 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); 173 } catch (e) { 174 if (e instanceof ApiError && e.status === 401) { 175 try { 176 + const tokens = await refreshOAuthToken(stored.refreshJwt); 177 + const sessionInfo = await api.getSession(tokens.access_token); 178 const session: Session = { 179 ...sessionInfo, 180 accessJwt: tokens.access_token, 181 refreshJwt: tokens.refresh_token || stored.refreshJwt, 182 + }; 183 + state.session = session; 184 + saveSession(session); 185 + addOrUpdateSavedAccount(session); 186 + applyLocaleFromSession(sessionInfo); 187 } catch (refreshError) { 188 + console.error("Token refresh failed during init:", refreshError); 189 + saveSession(null); 190 + state.session = null; 191 } 192 } else { 193 + console.error("Non-401 error during getSession:", e); 194 + saveSession(null); 195 + state.session = null; 196 } 197 } 198 } 199 + state.loading = false; 200 + return { oauthLoginCompleted: false }; 201 } 202 203 + export async function login( 204 + identifier: string, 205 + password: string, 206 + ): Promise<void> { 207 + state.loading = true; 208 + state.error = null; 209 try { 210 + const session = await api.createSession(identifier, password); 211 + state.session = session; 212 + saveSession(session); 213 + addOrUpdateSavedAccount(session); 214 } catch (e) { 215 if (e instanceof ApiError) { 216 + state.error = e.message; 217 } else { 218 + state.error = "Login failed"; 219 } 220 + throw e; 221 } finally { 222 + state.loading = false; 223 } 224 } 225 226 export async function loginWithOAuth(): Promise<void> { 227 + state.loading = true; 228 + state.error = null; 229 try { 230 + await startOAuthLogin(); 231 } catch (e) { 232 + state.loading = false; 233 + state.error = e instanceof Error 234 + ? e.message 235 + : "Failed to start OAuth login"; 236 + throw e; 237 } 238 } 239 240 + export async function register( 241 + params: CreateAccountParams, 242 + ): Promise<CreateAccountResult> { 243 try { 244 + const result = await api.createAccount(params); 245 + return result; 246 } catch (e) { 247 if (e instanceof ApiError) { 248 + state.error = e.message; 249 } else { 250 + state.error = "Registration failed"; 251 } 252 + throw e; 253 } 254 } 255 256 + export async function confirmSignup( 257 + did: string, 258 + verificationCode: string, 259 + ): Promise<void> { 260 + state.loading = true; 261 + state.error = null; 262 try { 263 + const result = await api.confirmSignup(did, verificationCode); 264 const session: Session = { 265 did: result.did, 266 handle: result.handle, ··· 270 emailConfirmed: result.emailConfirmed, 271 preferredChannel: result.preferredChannel, 272 preferredChannelVerified: result.preferredChannelVerified, 273 + }; 274 + state.session = session; 275 + saveSession(session); 276 + addOrUpdateSavedAccount(session); 277 } catch (e) { 278 if (e instanceof ApiError) { 279 + state.error = e.message; 280 } else { 281 + state.error = "Verification failed"; 282 } 283 + throw e; 284 } finally { 285 + state.loading = false; 286 } 287 } 288 289 export async function resendVerification(did: string): Promise<void> { 290 try { 291 + await api.resendVerification(did); 292 } catch (e) { 293 if (e instanceof ApiError) { 294 + throw e; 295 } 296 + throw new Error("Failed to resend verification code"); 297 } 298 } 299 300 + export function setSession( 301 + session: { 302 + did: string; 303 + handle: string; 304 + accessJwt: string; 305 + refreshJwt: string; 306 + }, 307 + ): void { 308 const newSession: Session = { 309 did: session.did, 310 handle: session.handle, 311 accessJwt: session.accessJwt, 312 refreshJwt: session.refreshJwt, 313 + }; 314 + state.session = newSession; 315 + saveSession(newSession); 316 + addOrUpdateSavedAccount(newSession); 317 } 318 319 export async function logout(): Promise<void> { 320 if (state.session) { 321 try { 322 + await api.deleteSession(state.session.accessJwt); 323 } catch { 324 // Ignore errors on logout 325 } 326 } 327 + state.session = null; 328 + saveSession(null); 329 } 330 331 export async function switchAccount(did: string): Promise<void> { 332 + const account = state.savedAccounts.find((a) => a.did === did); 333 if (!account) { 334 + throw new Error("Account not found"); 335 } 336 + state.loading = true; 337 + state.error = null; 338 try { 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); 347 } catch (e) { 348 if (e instanceof ApiError && e.status === 401) { 349 try { 350 + const tokens = await refreshOAuthToken(account.refreshJwt); 351 + const sessionInfo = await api.getSession(tokens.access_token); 352 const session: Session = { 353 ...sessionInfo, 354 accessJwt: tokens.access_token, 355 refreshJwt: tokens.refresh_token || account.refreshJwt, 356 + }; 357 + state.session = session; 358 + saveSession(session); 359 + addOrUpdateSavedAccount(session); 360 } catch { 361 + removeSavedAccount(did); 362 + state.error = "Session expired. Please log in again."; 363 + throw new Error("Session expired"); 364 } 365 } else { 366 + state.error = "Failed to switch account"; 367 + throw e; 368 } 369 } finally { 370 + state.loading = false; 371 } 372 } 373 374 export function forgetAccount(did: string): void { 375 + removeSavedAccount(did); 376 } 377 378 export function getAuthState() { 379 + return state; 380 } 381 382 export async function refreshSession(): Promise<void> { 383 + if (!state.session) return; 384 try { 385 + const sessionInfo = await api.getSession(state.session.accessJwt); 386 state.session = { 387 ...sessionInfo, 388 accessJwt: state.session.accessJwt, 389 refreshJwt: state.session.refreshJwt, 390 + }; 391 + saveSession(state.session); 392 + addOrUpdateSavedAccount(state.session); 393 } catch (e) { 394 + console.error("Failed to refresh session:", e); 395 } 396 } 397 398 export function getToken(): string | null { 399 + return state.session?.accessJwt ?? null; 400 } 401 402 export async function getValidToken(): Promise<string | null> { 403 + if (!state.session) return null; 404 try { 405 + await api.getSession(state.session.accessJwt); 406 + return state.session.accessJwt; 407 } catch (e) { 408 if (e instanceof ApiError && e.status === 401) { 409 try { 410 + const tokens = await refreshOAuthToken(state.session.refreshJwt); 411 + const sessionInfo = await api.getSession(tokens.access_token); 412 const session: Session = { 413 ...sessionInfo, 414 accessJwt: tokens.access_token, 415 refreshJwt: tokens.refresh_token || state.session.refreshJwt, 416 + }; 417 + state.session = session; 418 + saveSession(session); 419 + addOrUpdateSavedAccount(session); 420 + return session.accessJwt; 421 } catch { 422 + return null; 423 } 424 } 425 + return null; 426 } 427 } 428 429 export function isAuthenticated(): boolean { 430 + return state.session !== null; 431 } 432 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 ?? []; 445 } 446 447 export function _testReset() { 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); 454 }
+48 -44
frontend/src/lib/crypto.ts
··· 1 - import * as secp from '@noble/secp256k1' 2 - import { base58btc } from 'multiformats/bases/base58' 3 4 - const SECP256K1_MULTICODEC_PREFIX = new Uint8Array([0xe7, 0x01]) 5 6 export interface Keypair { 7 - privateKey: Uint8Array 8 - publicKey: Uint8Array 9 - publicKeyMultibase: string 10 - publicKeyDidKey: string 11 } 12 13 export async function generateKeypair(): Promise<Keypair> { 14 - const privateKey = secp.utils.randomPrivateKey() 15 - const publicKey = secp.getPublicKey(privateKey, true) 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) 20 21 - const publicKeyMultibase = base58btc.encode(multicodecKey) 22 - const publicKeyDidKey = `did:key:${publicKeyMultibase}` 23 24 return { 25 privateKey, 26 publicKey, 27 publicKeyMultibase, 28 publicKeyDidKey, 29 - } 30 } 31 32 function base64UrlEncode(data: Uint8Array | string): string { 33 - const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data 34 - let binary = '' 35 for (let i = 0; i < bytes.length; i++) { 36 - binary += String.fromCharCode(bytes[i]) 37 } 38 - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') 39 } 40 41 export async function createServiceJwt( 42 privateKey: Uint8Array, 43 issuerDid: string, 44 audienceDid: string, 45 - lxm: string 46 ): Promise<string> { 47 const header = { 48 - alg: 'ES256K', 49 - typ: 'JWT', 50 - } 51 52 - const now = Math.floor(Date.now() / 1000) 53 const payload = { 54 iss: issuerDid, 55 sub: issuerDid, ··· 57 exp: now + 180, 58 iat: now, 59 lxm: lxm, 60 - } 61 62 - const headerEncoded = base64UrlEncode(JSON.stringify(header)) 63 - const payloadEncoded = base64UrlEncode(JSON.stringify(payload)) 64 - const message = `${headerEncoded}.${payloadEncoded}` 65 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) 72 73 - return `${message}.${signatureEncoded}` 74 } 75 76 export function generateDidDocument( 77 did: string, 78 publicKeyMultibase: string, 79 handle: string, 80 - pdsEndpoint: string 81 ): object { 82 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 ], 88 id: did, 89 alsoKnownAs: [`at://${handle}`], 90 verificationMethod: [ 91 { 92 id: `${did}#atproto`, 93 - type: 'Multikey', 94 controller: did, 95 publicKeyMultibase: publicKeyMultibase, 96 }, 97 ], 98 service: [ 99 { 100 - id: '#atproto_pds', 101 - type: 'AtprotoPersonalDataServer', 102 serviceEndpoint: pdsEndpoint, 103 }, 104 ], 105 - } 106 }
··· 1 + import * as secp from "@noble/secp256k1"; 2 + import { base58btc } from "multiformats/bases/base58"; 3 4 + const SECP256K1_MULTICODEC_PREFIX = new Uint8Array([0xe7, 0x01]); 5 6 export interface Keypair { 7 + privateKey: Uint8Array; 8 + publicKey: Uint8Array; 9 + publicKeyMultibase: string; 10 + publicKeyDidKey: string; 11 } 12 13 export async function generateKeypair(): Promise<Keypair> { 14 + const privateKey = secp.utils.randomPrivateKey(); 15 + const publicKey = secp.getPublicKey(privateKey, true); 16 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); 22 23 + const publicKeyMultibase = base58btc.encode(multicodecKey); 24 + const publicKeyDidKey = `did:key:${publicKeyMultibase}`; 25 26 return { 27 privateKey, 28 publicKey, 29 publicKeyMultibase, 30 publicKeyDidKey, 31 + }; 32 } 33 34 function base64UrlEncode(data: Uint8Array | string): string { 35 + const bytes = typeof data === "string" 36 + ? new TextEncoder().encode(data) 37 + : data; 38 + let binary = ""; 39 for (let i = 0; i < bytes.length; i++) { 40 + binary += String.fromCharCode(bytes[i]); 41 } 42 + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); 43 } 44 45 export async function createServiceJwt( 46 privateKey: Uint8Array, 47 issuerDid: string, 48 audienceDid: string, 49 + lxm: string, 50 ): Promise<string> { 51 const header = { 52 + alg: "ES256K", 53 + typ: "JWT", 54 + }; 55 56 + const now = Math.floor(Date.now() / 1000); 57 const payload = { 58 iss: issuerDid, 59 sub: issuerDid, ··· 61 exp: now + 180, 62 iat: now, 63 lxm: lxm, 64 + }; 65 66 + const headerEncoded = base64UrlEncode(JSON.stringify(header)); 67 + const payloadEncoded = base64UrlEncode(JSON.stringify(payload)); 68 + const message = `${headerEncoded}.${payloadEncoded}`; 69 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); 76 77 + return `${message}.${signatureEncoded}`; 78 } 79 80 export function generateDidDocument( 81 did: string, 82 publicKeyMultibase: string, 83 handle: string, 84 + pdsEndpoint: string, 85 ): object { 86 return { 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", 91 ], 92 id: did, 93 alsoKnownAs: [`at://${handle}`], 94 verificationMethod: [ 95 { 96 id: `${did}#atproto`, 97 + type: "Multikey", 98 controller: did, 99 publicKeyMultibase: publicKeyMultibase, 100 }, 101 ], 102 service: [ 103 { 104 + id: "#atproto_pds", 105 + type: "AtprotoPersonalDataServer", 106 serviceEndpoint: pdsEndpoint, 107 }, 108 ], 109 + }; 110 }
+12 -12
frontend/src/lib/date.ts
··· 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}` 7 } 8 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}` 17 }
··· 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}`; 7 } 8 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}`; 17 }
+31 -31
frontend/src/lib/i18n.ts
··· 1 - import { register, init, getLocaleFromNavigator, locale, _ } from 'svelte-i18n' 2 3 - const LOCALE_STORAGE_KEY = 'tranquil-pds-locale' 4 5 - const SUPPORTED_LOCALES = ['en', 'zh', 'ja', 'ko', 'sv', 'fi'] as const 6 - export type SupportedLocale = typeof SUPPORTED_LOCALES[number] 7 8 export const localeNames: Record<SupportedLocale, string> = { 9 - en: 'English', 10 - zh: '中文', 11 - ja: '日本語', 12 - ko: '한국어', 13 - sv: 'Svenska', 14 - fi: 'Suomi' 15 - } 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')) 23 24 function getInitialLocale(): string { 25 - const stored = localStorage.getItem(LOCALE_STORAGE_KEY) 26 if (stored && SUPPORTED_LOCALES.includes(stored as SupportedLocale)) { 27 - return stored 28 } 29 30 - const browserLocale = getLocaleFromNavigator() 31 if (browserLocale) { 32 - const lang = browserLocale.split('-')[0] 33 if (SUPPORTED_LOCALES.includes(lang as SupportedLocale)) { 34 - return lang 35 } 36 } 37 38 - return 'en' 39 } 40 41 export function initI18n() { 42 init({ 43 - fallbackLocale: 'en', 44 - initialLocale: getInitialLocale() 45 - }) 46 } 47 48 export function setLocale(newLocale: SupportedLocale) { 49 - locale.set(newLocale) 50 - localStorage.setItem(LOCALE_STORAGE_KEY, newLocale) 51 - document.documentElement.lang = newLocale 52 } 53 54 export function getSupportedLocales(): SupportedLocale[] { 55 - return [...SUPPORTED_LOCALES] 56 } 57 58 - export { locale, _ }
··· 1 + import { _, getLocaleFromNavigator, init, locale, register } from "svelte-i18n"; 2 3 + const LOCALE_STORAGE_KEY = "tranquil-pds-locale"; 4 5 + const SUPPORTED_LOCALES = ["en", "zh", "ja", "ko", "sv", "fi"] as const; 6 + export type SupportedLocale = typeof SUPPORTED_LOCALES[number]; 7 8 export const localeNames: Record<SupportedLocale, string> = { 9 + en: "English", 10 + zh: "中文", 11 + ja: "日本語", 12 + ko: "한국어", 13 + sv: "Svenska", 14 + fi: "Suomi", 15 + }; 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")); 23 24 function getInitialLocale(): string { 25 + const stored = localStorage.getItem(LOCALE_STORAGE_KEY); 26 if (stored && SUPPORTED_LOCALES.includes(stored as SupportedLocale)) { 27 + return stored; 28 } 29 30 + const browserLocale = getLocaleFromNavigator(); 31 if (browserLocale) { 32 + const lang = browserLocale.split("-")[0]; 33 if (SUPPORTED_LOCALES.includes(lang as SupportedLocale)) { 34 + return lang; 35 } 36 } 37 38 + return "en"; 39 } 40 41 export function initI18n() { 42 init({ 43 + fallbackLocale: "en", 44 + initialLocale: getInitialLocale(), 45 + }); 46 } 47 48 export function setLocale(newLocale: SupportedLocale) { 49 + locale.set(newLocale); 50 + localStorage.setItem(LOCALE_STORAGE_KEY, newLocale); 51 + document.documentElement.lang = newLocale; 52 } 53 54 export function getSupportedLocales(): SupportedLocale[] { 55 + return [...SUPPORTED_LOCALES]; 56 } 57 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' 3 const SCOPES = [ 4 - 'atproto', 5 - 'repo:*?action=create', 6 - 'repo:*?action=update', 7 - 'repo:*?action=delete', 8 - 'blob:*/*', 9 - ].join(' ') 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}/` 14 15 interface OAuthState { 16 - state: string 17 - codeVerifier: string 18 - returnTo?: string 19 } 20 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('') 25 } 26 27 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) 31 } 32 33 function base64UrlEncode(buffer: ArrayBuffer): string { 34 - const bytes = new Uint8Array(buffer) 35 - let binary = '' 36 for (const byte of bytes) { 37 - binary += String.fromCharCode(byte) 38 } 39 - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') 40 } 41 42 async function generateCodeChallenge(verifier: string): Promise<string> { 43 - const hash = await sha256(verifier) 44 - return base64UrlEncode(hash) 45 } 46 47 function generateState(): string { 48 - return generateRandomString(32) 49 } 50 51 function generateCodeVerifier(): string { 52 - return generateRandomString(32) 53 } 54 55 function saveOAuthState(state: OAuthState): void { 56 - sessionStorage.setItem(OAUTH_STATE_KEY, state.state) 57 - sessionStorage.setItem(OAUTH_VERIFIER_KEY, state.codeVerifier) 58 } 59 60 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 } 65 } 66 67 function clearOAuthState(): void { 68 - sessionStorage.removeItem(OAUTH_STATE_KEY) 69 - sessionStorage.removeItem(OAUTH_VERIFIER_KEY) 70 } 71 72 export async function startOAuthLogin(): Promise<void> { 73 - const state = generateState() 74 - const codeVerifier = generateCodeVerifier() 75 - const codeChallenge = await generateCodeChallenge(codeVerifier) 76 77 - saveOAuthState({ state, codeVerifier }) 78 79 - const parResponse = await fetch('/oauth/par', { 80 - method: 'POST', 81 - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 82 body: new URLSearchParams({ 83 client_id: CLIENT_ID, 84 redirect_uri: REDIRECT_URI, 85 - response_type: 'code', 86 scope: SCOPES, 87 state: state, 88 code_challenge: codeChallenge, 89 - code_challenge_method: 'S256', 90 }), 91 - }) 92 93 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') 96 } 97 98 - const { request_uri } = await parResponse.json() 99 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) 103 104 - window.location.href = authorizeUrl.toString() 105 } 106 107 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 114 } 115 116 - export async function handleOAuthCallback(code: string, state: string): Promise<OAuthTokens> { 117 - const savedState = getOAuthState() 118 if (!savedState) { 119 - throw new Error('No OAuth state found. Please try logging in again.') 120 } 121 122 if (savedState.state !== state) { 123 - clearOAuthState() 124 - throw new Error('OAuth state mismatch. Please try logging in again.') 125 } 126 127 - const tokenResponse = await fetch('/oauth/token', { 128 - method: 'POST', 129 - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 130 body: new URLSearchParams({ 131 - grant_type: 'authorization_code', 132 client_id: CLIENT_ID, 133 code: code, 134 redirect_uri: REDIRECT_URI, 135 code_verifier: savedState.codeVerifier, 136 }), 137 - }) 138 139 - clearOAuthState() 140 141 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') 144 } 145 146 - return tokenResponse.json() 147 } 148 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' }, 153 body: new URLSearchParams({ 154 - grant_type: 'refresh_token', 155 client_id: CLIENT_ID, 156 refresh_token: refreshToken, 157 }), 158 - }) 159 160 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') 163 } 164 165 - return tokenResponse.json() 166 } 167 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') 172 173 if (code && state) { 174 - return { code, state } 175 } 176 177 - return null 178 } 179 180 export function clearOAuthCallbackParams(): void { 181 - const url = new URL(window.location.href) 182 - url.search = '' 183 - window.history.replaceState({}, '', url.toString()) 184 }
··· 1 + const OAUTH_STATE_KEY = "tranquil_pds_oauth_state"; 2 + const OAUTH_VERIFIER_KEY = "tranquil_pds_oauth_verifier"; 3 const SCOPES = [ 4 + "atproto", 5 + "repo:*?action=create", 6 + "repo:*?action=update", 7 + "repo:*?action=delete", 8 + "blob:*/*", 9 + ].join(" "); 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}/`; 14 15 interface OAuthState { 16 + state: string; 17 + codeVerifier: string; 18 + returnTo?: string; 19 } 20 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( 25 + "", 26 + ); 27 } 28 29 async function sha256(plain: string): Promise<ArrayBuffer> { 30 + const encoder = new TextEncoder(); 31 + const data = encoder.encode(plain); 32 + return crypto.subtle.digest("SHA-256", data); 33 } 34 35 function base64UrlEncode(buffer: ArrayBuffer): string { 36 + const bytes = new Uint8Array(buffer); 37 + let binary = ""; 38 for (const byte of bytes) { 39 + binary += String.fromCharCode(byte); 40 } 41 + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace( 42 + /=+$/, 43 + "", 44 + ); 45 } 46 47 async function generateCodeChallenge(verifier: string): Promise<string> { 48 + const hash = await sha256(verifier); 49 + return base64UrlEncode(hash); 50 } 51 52 function generateState(): string { 53 + return generateRandomString(32); 54 } 55 56 function generateCodeVerifier(): string { 57 + return generateRandomString(32); 58 } 59 60 function saveOAuthState(state: OAuthState): void { 61 + sessionStorage.setItem(OAUTH_STATE_KEY, state.state); 62 + sessionStorage.setItem(OAUTH_VERIFIER_KEY, state.codeVerifier); 63 } 64 65 function getOAuthState(): OAuthState | null { 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 }; 70 } 71 72 function clearOAuthState(): void { 73 + sessionStorage.removeItem(OAUTH_STATE_KEY); 74 + sessionStorage.removeItem(OAUTH_VERIFIER_KEY); 75 } 76 77 export async function startOAuthLogin(): Promise<void> { 78 + const state = generateState(); 79 + const codeVerifier = generateCodeVerifier(); 80 + const codeChallenge = await generateCodeChallenge(codeVerifier); 81 82 + saveOAuthState({ state, codeVerifier }); 83 84 + const parResponse = await fetch("/oauth/par", { 85 + method: "POST", 86 + headers: { "Content-Type": "application/x-www-form-urlencoded" }, 87 body: new URLSearchParams({ 88 client_id: CLIENT_ID, 89 redirect_uri: REDIRECT_URI, 90 + response_type: "code", 91 scope: SCOPES, 92 state: state, 93 code_challenge: codeChallenge, 94 + code_challenge_method: "S256", 95 }), 96 + }); 97 98 if (!parResponse.ok) { 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 + ); 105 } 106 107 + const { request_uri } = await parResponse.json(); 108 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); 112 113 + window.location.href = authorizeUrl.toString(); 114 } 115 116 export interface OAuthTokens { 117 + access_token: string; 118 + refresh_token?: string; 119 + token_type: string; 120 + expires_in?: number; 121 + scope?: string; 122 + sub: string; 123 } 124 125 + export async function handleOAuthCallback( 126 + code: string, 127 + state: string, 128 + ): Promise<OAuthTokens> { 129 + const savedState = getOAuthState(); 130 if (!savedState) { 131 + throw new Error("No OAuth state found. Please try logging in again."); 132 } 133 134 if (savedState.state !== state) { 135 + clearOAuthState(); 136 + throw new Error("OAuth state mismatch. Please try logging in again."); 137 } 138 139 + const tokenResponse = await fetch("/oauth/token", { 140 + method: "POST", 141 + headers: { "Content-Type": "application/x-www-form-urlencoded" }, 142 body: new URLSearchParams({ 143 + grant_type: "authorization_code", 144 client_id: CLIENT_ID, 145 code: code, 146 redirect_uri: REDIRECT_URI, 147 code_verifier: savedState.codeVerifier, 148 }), 149 + }); 150 151 + clearOAuthState(); 152 153 if (!tokenResponse.ok) { 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 + ); 161 } 162 163 + return tokenResponse.json(); 164 } 165 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" }, 172 body: new URLSearchParams({ 173 + grant_type: "refresh_token", 174 client_id: CLIENT_ID, 175 refresh_token: refreshToken, 176 }), 177 + }); 178 179 if (!tokenResponse.ok) { 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 + ); 186 } 187 188 + return tokenResponse.json(); 189 } 190 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"); 197 198 if (code && state) { 199 + return { code, state }; 200 } 201 202 + return null; 203 } 204 205 export function clearOAuthCallbackParams(): void { 206 + const url = new URL(window.location.href); 207 + url.search = ""; 208 + window.history.replaceState({}, "", url.toString()); 209 }
+200 -141
frontend/src/lib/registration/flow.svelte.ts
··· 1 - import { api, ApiError } from '../api' 2 - import { generateKeypair, createServiceJwt, generateDidDocument } from '../crypto' 3 import type { 4 RegistrationMode, 5 RegistrationStep, 6 - RegistrationInfo, 7 - ExternalDidWebState, 8 - AccountResult, 9 SessionState, 10 - } from './types' 11 12 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 22 } 23 24 - export function createRegistrationFlow(mode: RegistrationMode, pdsHostname: string) { 25 let state = $state<RegistrationFlowState>({ 26 mode, 27 - step: 'info', 28 info: { 29 - handle: '', 30 - email: '', 31 - password: '', 32 - inviteCode: '', 33 - didType: 'plc', 34 - externalDid: '', 35 - verificationChannel: 'email', 36 - discordId: '', 37 - telegramUsername: '', 38 - signalNumber: '', 39 }, 40 externalDidWeb: { 41 - keyMode: 'reserved', 42 }, 43 account: null, 44 session: null, 45 error: null, 46 submitting: false, 47 pdsHostname, 48 - }) 49 50 function getPdsEndpoint(): string { 51 - return `https://${state.pdsHostname}` 52 } 53 54 function getPdsDid(): string { 55 - return `did:web:${state.pdsHostname}` 56 } 57 58 function getFullHandle(): string { 59 - return `${state.info.handle.trim()}.${state.pdsHostname}` 60 } 61 62 function extractDomain(did: string): string { 63 - return did.replace('did:web:', '').replace(/%3A/g, ':') 64 } 65 66 function setError(err: unknown) { 67 if (err instanceof ApiError) { 68 - state.error = err.message || 'An error occurred' 69 } else if (err instanceof Error) { 70 - state.error = err.message || 'An error occurred' 71 } else { 72 - state.error = 'An error occurred' 73 } 74 } 75 76 async function proceedFromInfo() { 77 - state.error = null 78 - if (state.info.didType === 'web-external') { 79 - state.step = 'key-choice' 80 } else { 81 - state.step = 'creating' 82 } 83 } 84 85 - async function selectKeyMode(keyMode: 'reserved' | 'byod') { 86 - state.submitting = true 87 - state.error = null 88 - state.externalDidWeb.keyMode = keyMode 89 90 try { 91 - let publicKeyMultibase: string 92 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:', '') 97 } else { 98 - const keypair = await generateKeypair() 99 - state.externalDidWeb.byodPrivateKey = keypair.privateKey 100 - state.externalDidWeb.byodPublicKeyMultibase = keypair.publicKeyMultibase 101 - publicKeyMultibase = keypair.publicKeyMultibase 102 } 103 104 const didDoc = generateDidDocument( 105 state.info.externalDid!.trim(), 106 publicKeyMultibase, 107 getFullHandle(), 108 - getPdsEndpoint() 109 - ) 110 - state.externalDidWeb.initialDidDocument = JSON.stringify(didDoc, null, '\t') 111 - state.step = 'initial-did-doc' 112 } catch (err) { 113 - setError(err) 114 } finally { 115 - state.submitting = false 116 } 117 } 118 119 async function confirmInitialDidDoc() { 120 - state.step = 'creating' 121 } 122 123 async function createPasswordAccount() { 124 - state.submitting = true 125 - state.error = null 126 127 try { 128 - let byodToken: string | undefined 129 130 - if (state.info.didType === 'web-external' && state.externalDidWeb.keyMode === 'byod' && state.externalDidWeb.byodPrivateKey) { 131 byodToken = await createServiceJwt( 132 state.externalDidWeb.byodPrivateKey, 133 state.info.externalDid!.trim(), 134 getPdsDid(), 135 - 'com.atproto.server.createAccount' 136 - ) 137 } 138 139 const result = await api.createAccount({ ··· 142 password: state.info.password!, 143 inviteCode: state.info.inviteCode?.trim() || undefined, 144 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' 147 ? state.externalDidWeb.reservedSigningKey 148 : undefined, 149 verificationChannel: state.info.verificationChannel, 150 discordId: state.info.discordId?.trim() || undefined, 151 telegramUsername: state.info.telegramUsername?.trim() || undefined, 152 signalNumber: state.info.signalNumber?.trim() || undefined, 153 - }, byodToken) 154 155 state.account = { 156 did: result.did, 157 handle: result.handle, 158 - } 159 - state.step = 'verify' 160 } catch (err) { 161 - setError(err) 162 } finally { 163 - state.submitting = false 164 } 165 } 166 167 async function createPasskeyAccount() { 168 - state.submitting = true 169 - state.error = null 170 171 try { 172 - let byodToken: string | undefined 173 174 - if (state.info.didType === 'web-external' && state.externalDidWeb.keyMode === 'byod' && state.externalDidWeb.byodPrivateKey) { 175 byodToken = await createServiceJwt( 176 state.externalDidWeb.byodPrivateKey, 177 state.info.externalDid!.trim(), 178 getPdsDid(), 179 - 'com.atproto.server.createAccount' 180 - ) 181 } 182 183 const result = await api.createPasskeyAccount({ ··· 185 email: state.info.email?.trim() || undefined, 186 inviteCode: state.info.inviteCode?.trim() || undefined, 187 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' 190 ? state.externalDidWeb.reservedSigningKey 191 : undefined, 192 verificationChannel: state.info.verificationChannel, 193 discordId: state.info.discordId?.trim() || undefined, 194 telegramUsername: state.info.telegramUsername?.trim() || undefined, 195 signalNumber: state.info.signalNumber?.trim() || undefined, 196 - }, byodToken) 197 198 state.account = { 199 did: result.did, 200 handle: result.handle, 201 setupToken: result.setupToken, 202 - } 203 - state.step = 'passkey' 204 } catch (err) { 205 - setError(err) 206 } finally { 207 - state.submitting = false 208 } 209 } 210 211 function setPasskeyComplete(appPassword: string, appPasswordName: string) { 212 if (state.account) { 213 - state.account.appPassword = appPassword 214 - state.account.appPasswordName = appPasswordName 215 } 216 - state.step = 'app-password' 217 } 218 219 function proceedFromAppPassword() { 220 - state.step = 'verify' 221 } 222 223 async function verifyAccount(code: string) { 224 - state.submitting = true 225 - state.error = null 226 227 try { 228 - const confirmResult = await api.confirmSignup(state.account!.did, code.trim()) 229 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) 233 state.session = { 234 accessJwt: session.accessJwt, 235 refreshJwt: session.refreshJwt, 236 - } 237 238 - if (state.externalDidWeb.keyMode === 'byod') { 239 - const credentials = await api.getRecommendedDidCredentials(session.accessJwt) 240 - const newPublicKeyMultibase = credentials.verificationMethods?.atproto?.replace('did:key:', '') || '' 241 242 const didDoc = generateDidDocument( 243 state.info.externalDid!.trim(), 244 newPublicKeyMultibase, 245 state.account!.handle, 246 - getPdsEndpoint() 247 - ) 248 - state.externalDidWeb.updatedDidDocument = JSON.stringify(didDoc, null, '\t') 249 - state.step = 'updated-did-doc' 250 } else { 251 - await api.activateAccount(session.accessJwt) 252 - await finalizeSession() 253 - state.step = 'redirect-to-dashboard' 254 } 255 } else { 256 state.session = { 257 accessJwt: confirmResult.accessJwt, 258 refreshJwt: confirmResult.refreshJwt, 259 - } 260 - await finalizeSession() 261 - state.step = 'redirect-to-dashboard' 262 } 263 } catch (err) { 264 - setError(err) 265 } finally { 266 - state.submitting = false 267 } 268 } 269 270 async function activateAccount() { 271 - state.submitting = true 272 - state.error = null 273 274 try { 275 - await api.activateAccount(state.session!.accessJwt) 276 - await finalizeSession() 277 - state.step = 'redirect-to-dashboard' 278 } catch (err) { 279 - setError(err) 280 } finally { 281 - state.submitting = false 282 } 283 } 284 285 function goBack() { 286 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 296 } 297 } 298 299 async function finalizeSession() { 300 - if (!state.session || !state.account) return 301 - const { setSession } = await import('../auth.svelte') 302 setSession({ 303 did: state.account.did, 304 handle: state.account.handle, 305 accessJwt: state.session.accessJwt, 306 refreshJwt: state.session.refreshJwt, 307 - }) 308 } 309 310 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 }, 316 317 getPdsEndpoint, 318 getPdsDid, ··· 331 finalizeSession, 332 goBack, 333 334 - setError(msg: string) { state.error = msg }, 335 - clearError() { state.error = null }, 336 - setSubmitting(val: boolean) { state.submitting = val }, 337 - } 338 } 339 340 - export type RegistrationFlow = ReturnType<typeof createRegistrationFlow>
··· 1 + import { api, ApiError } from "../api"; 2 + import { 3 + createServiceJwt, 4 + generateDidDocument, 5 + generateKeypair, 6 + } from "../crypto"; 7 import type { 8 + AccountResult, 9 + ExternalDidWebState, 10 + RegistrationInfo, 11 RegistrationMode, 12 RegistrationStep, 13 SessionState, 14 + } from "./types"; 15 16 export interface RegistrationFlowState { 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; 26 } 27 28 + export function createRegistrationFlow( 29 + mode: RegistrationMode, 30 + pdsHostname: string, 31 + ) { 32 let state = $state<RegistrationFlowState>({ 33 mode, 34 + step: "info", 35 info: { 36 + handle: "", 37 + email: "", 38 + password: "", 39 + inviteCode: "", 40 + didType: "plc", 41 + externalDid: "", 42 + verificationChannel: "email", 43 + discordId: "", 44 + telegramUsername: "", 45 + signalNumber: "", 46 }, 47 externalDidWeb: { 48 + keyMode: "reserved", 49 }, 50 account: null, 51 session: null, 52 error: null, 53 submitting: false, 54 pdsHostname, 55 + }); 56 57 function getPdsEndpoint(): string { 58 + return `https://${state.pdsHostname}`; 59 } 60 61 function getPdsDid(): string { 62 + return `did:web:${state.pdsHostname}`; 63 } 64 65 function getFullHandle(): string { 66 + return `${state.info.handle.trim()}.${state.pdsHostname}`; 67 } 68 69 function extractDomain(did: string): string { 70 + return did.replace("did:web:", "").replace(/%3A/g, ":"); 71 } 72 73 function setError(err: unknown) { 74 if (err instanceof ApiError) { 75 + state.error = err.message || "An error occurred"; 76 } else if (err instanceof Error) { 77 + state.error = err.message || "An error occurred"; 78 } else { 79 + state.error = "An error occurred"; 80 } 81 } 82 83 async function proceedFromInfo() { 84 + state.error = null; 85 + if (state.info.didType === "web-external") { 86 + state.step = "key-choice"; 87 } else { 88 + state.step = "creating"; 89 } 90 } 91 92 + async function selectKeyMode(keyMode: "reserved" | "byod") { 93 + state.submitting = true; 94 + state.error = null; 95 + state.externalDidWeb.keyMode = keyMode; 96 97 try { 98 + let publicKeyMultibase: string; 99 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:", ""); 106 } else { 107 + const keypair = await generateKeypair(); 108 + state.externalDidWeb.byodPrivateKey = keypair.privateKey; 109 + state.externalDidWeb.byodPublicKeyMultibase = 110 + keypair.publicKeyMultibase; 111 + publicKeyMultibase = keypair.publicKeyMultibase; 112 } 113 114 const didDoc = generateDidDocument( 115 state.info.externalDid!.trim(), 116 publicKeyMultibase, 117 getFullHandle(), 118 + getPdsEndpoint(), 119 + ); 120 + state.externalDidWeb.initialDidDocument = JSON.stringify( 121 + didDoc, 122 + null, 123 + "\t", 124 + ); 125 + state.step = "initial-did-doc"; 126 } catch (err) { 127 + setError(err); 128 } finally { 129 + state.submitting = false; 130 } 131 } 132 133 async function confirmInitialDidDoc() { 134 + state.step = "creating"; 135 } 136 137 async function createPasswordAccount() { 138 + state.submitting = true; 139 + state.error = null; 140 141 try { 142 + let byodToken: string | undefined; 143 144 + if ( 145 + state.info.didType === "web-external" && 146 + state.externalDidWeb.keyMode === "byod" && 147 + state.externalDidWeb.byodPrivateKey 148 + ) { 149 byodToken = await createServiceJwt( 150 state.externalDidWeb.byodPrivateKey, 151 state.info.externalDid!.trim(), 152 getPdsDid(), 153 + "com.atproto.server.createAccount", 154 + ); 155 } 156 157 const result = await api.createAccount({ ··· 160 password: state.info.password!, 161 inviteCode: state.info.inviteCode?.trim() || undefined, 162 didType: state.info.didType, 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" 168 ? state.externalDidWeb.reservedSigningKey 169 : undefined, 170 verificationChannel: state.info.verificationChannel, 171 discordId: state.info.discordId?.trim() || undefined, 172 telegramUsername: state.info.telegramUsername?.trim() || undefined, 173 signalNumber: state.info.signalNumber?.trim() || undefined, 174 + }, byodToken); 175 176 state.account = { 177 did: result.did, 178 handle: result.handle, 179 + }; 180 + state.step = "verify"; 181 } catch (err) { 182 + setError(err); 183 } finally { 184 + state.submitting = false; 185 } 186 } 187 188 async function createPasskeyAccount() { 189 + state.submitting = true; 190 + state.error = null; 191 192 try { 193 + let byodToken: string | undefined; 194 195 + if ( 196 + state.info.didType === "web-external" && 197 + state.externalDidWeb.keyMode === "byod" && 198 + state.externalDidWeb.byodPrivateKey 199 + ) { 200 byodToken = await createServiceJwt( 201 state.externalDidWeb.byodPrivateKey, 202 state.info.externalDid!.trim(), 203 getPdsDid(), 204 + "com.atproto.server.createAccount", 205 + ); 206 } 207 208 const result = await api.createPasskeyAccount({ ··· 210 email: state.info.email?.trim() || undefined, 211 inviteCode: state.info.inviteCode?.trim() || undefined, 212 didType: state.info.didType, 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" 218 ? state.externalDidWeb.reservedSigningKey 219 : undefined, 220 verificationChannel: state.info.verificationChannel, 221 discordId: state.info.discordId?.trim() || undefined, 222 telegramUsername: state.info.telegramUsername?.trim() || undefined, 223 signalNumber: state.info.signalNumber?.trim() || undefined, 224 + }, byodToken); 225 226 state.account = { 227 did: result.did, 228 handle: result.handle, 229 setupToken: result.setupToken, 230 + }; 231 + state.step = "passkey"; 232 } catch (err) { 233 + setError(err); 234 } finally { 235 + state.submitting = false; 236 } 237 } 238 239 function setPasskeyComplete(appPassword: string, appPasswordName: string) { 240 if (state.account) { 241 + state.account.appPassword = appPassword; 242 + state.account.appPasswordName = appPasswordName; 243 } 244 + state.step = "app-password"; 245 } 246 247 function proceedFromAppPassword() { 248 + state.step = "verify"; 249 } 250 251 async function verifyAccount(code: string) { 252 + state.submitting = true; 253 + state.error = null; 254 255 try { 256 + const confirmResult = await api.confirmSignup( 257 + state.account!.did, 258 + code.trim(), 259 + ); 260 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); 266 state.session = { 267 accessJwt: session.accessJwt, 268 refreshJwt: session.refreshJwt, 269 + }; 270 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 + ""; 278 279 const didDoc = generateDidDocument( 280 state.info.externalDid!.trim(), 281 newPublicKeyMultibase, 282 state.account!.handle, 283 + getPdsEndpoint(), 284 + ); 285 + state.externalDidWeb.updatedDidDocument = JSON.stringify( 286 + didDoc, 287 + null, 288 + "\t", 289 + ); 290 + state.step = "updated-did-doc"; 291 } else { 292 + await api.activateAccount(session.accessJwt); 293 + await finalizeSession(); 294 + state.step = "redirect-to-dashboard"; 295 } 296 } else { 297 state.session = { 298 accessJwt: confirmResult.accessJwt, 299 refreshJwt: confirmResult.refreshJwt, 300 + }; 301 + await finalizeSession(); 302 + state.step = "redirect-to-dashboard"; 303 } 304 } catch (err) { 305 + setError(err); 306 } finally { 307 + state.submitting = false; 308 } 309 } 310 311 async function activateAccount() { 312 + state.submitting = true; 313 + state.error = null; 314 315 try { 316 + await api.activateAccount(state.session!.accessJwt); 317 + await finalizeSession(); 318 + state.step = "redirect-to-dashboard"; 319 } catch (err) { 320 + setError(err); 321 } finally { 322 + state.submitting = false; 323 } 324 } 325 326 function goBack() { 327 switch (state.step) { 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; 339 } 340 } 341 342 async function finalizeSession() { 343 + if (!state.session || !state.account) return; 344 + const { setSession } = await import("../auth.svelte"); 345 setSession({ 346 did: state.account.did, 347 handle: state.account.handle, 348 accessJwt: state.session.accessJwt, 349 refreshJwt: state.session.refreshJwt, 350 + }); 351 } 352 353 return { 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 + }, 369 370 getPdsEndpoint, 371 getPdsDid, ··· 384 finalizeSession, 385 goBack, 386 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 + }; 397 } 398 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' 2 3 - export type RegistrationMode = 'password' | 'passkey' 4 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' 16 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 28 } 29 30 export interface ExternalDidWebState { 31 - keyMode: 'reserved' | 'byod' 32 - reservedSigningKey?: string 33 - byodPrivateKey?: Uint8Array 34 - byodPublicKeyMultibase?: string 35 - initialDidDocument?: string 36 - updatedDidDocument?: string 37 } 38 39 export interface AccountResult { 40 - did: string 41 - handle: string 42 - setupToken?: string 43 - appPassword?: string 44 - appPasswordName?: string 45 } 46 47 export interface SessionState { 48 - accessJwt: string 49 - refreshJwt: string 50 }
··· 1 + import type { DidType, VerificationChannel } from "../api"; 2 3 + export type RegistrationMode = "password" | "passkey"; 4 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"; 16 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; 28 } 29 30 export interface ExternalDidWebState { 31 + keyMode: "reserved" | "byod"; 32 + reservedSigningKey?: string; 33 + byodPrivateKey?: Uint8Array; 34 + byodPublicKeyMultibase?: string; 35 + initialDidDocument?: string; 36 + updatedDidDocument?: string; 37 } 38 39 export interface AccountResult { 40 + did: string; 41 + handle: string; 42 + setupToken?: string; 43 + appPassword?: string; 44 + appPasswordName?: string; 45 } 46 47 export interface SessionState { 48 + accessJwt: string; 49 + refreshJwt: string; 50 }
+11 -9
frontend/src/lib/router.svelte.ts
··· 1 - let currentPath = $state(getPathWithoutQuery(window.location.hash.slice(1) || '/')) 2 3 function getPathWithoutQuery(hash: string): string { 4 - const queryIndex = hash.indexOf('?') 5 - return queryIndex === -1 ? hash : hash.slice(0, queryIndex) 6 } 7 8 - window.addEventListener('hashchange', () => { 9 - currentPath = getPathWithoutQuery(window.location.hash.slice(1) || '/') 10 - }) 11 12 export function navigate(path: string) { 13 - currentPath = path 14 - window.location.hash = path 15 } 16 17 export function getCurrentPath() { 18 - return currentPath 19 }
··· 1 + let currentPath = $state( 2 + getPathWithoutQuery(window.location.hash.slice(1) || "/"), 3 + ); 4 5 function getPathWithoutQuery(hash: string): string { 6 + const queryIndex = hash.indexOf("?"); 7 + return queryIndex === -1 ? hash : hash.slice(0, queryIndex); 8 } 9 10 + window.addEventListener("hashchange", () => { 11 + currentPath = getPathWithoutQuery(window.location.hash.slice(1) || "/"); 12 + }); 13 14 export function navigate(path: string) { 15 + currentPath = path; 16 + window.location.hash = path; 17 } 18 19 export function getCurrentPath() { 20 + return currentPath; 21 }
+66 -58
frontend/src/lib/serverConfig.svelte.ts
··· 1 - import { api } from './api' 2 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 11 } 12 13 let state = $state<ServerConfigState>({ ··· 18 secondaryColorDark: null, 19 hasLogo: false, 20 loading: true, 21 - }) 22 23 - let initialized = false 24 - let darkModeQuery: MediaQueryList | null = null 25 26 function isDarkMode(): boolean { 27 - return darkModeQuery?.matches ?? false 28 } 29 30 function applyColors() { 31 - const root = document.documentElement 32 - const dark = isDarkMode() 33 34 if (dark) { 35 if (state.primaryColorDark) { 36 - root.style.setProperty('--accent', state.primaryColorDark) 37 } else { 38 - root.style.removeProperty('--accent') 39 } 40 if (state.secondaryColorDark) { 41 - root.style.setProperty('--secondary', state.secondaryColorDark) 42 } else { 43 - root.style.removeProperty('--secondary') 44 } 45 } else { 46 if (state.primaryColor) { 47 - root.style.setProperty('--accent', state.primaryColor) 48 } else { 49 - root.style.removeProperty('--accent') 50 } 51 if (state.secondaryColor) { 52 - root.style.setProperty('--secondary', state.secondaryColor) 53 } else { 54 - root.style.removeProperty('--secondary') 55 } 56 } 57 } 58 59 function setFavicon(hasLogo: boolean) { 60 - let link = document.querySelector<HTMLLinkElement>("link[rel~='icon']") 61 if (hasLogo) { 62 if (!link) { 63 - link = document.createElement('link') 64 - link.rel = 'icon' 65 - document.head.appendChild(link) 66 } 67 - link.href = '/logo' 68 } else if (link) { 69 - link.remove() 70 } 71 } 72 73 export async function initServerConfig(): Promise<void> { 74 - if (initialized) return 75 - initialized = true 76 77 - darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)') 78 - darkModeQuery.addEventListener('change', applyColors) 79 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) 91 } catch { 92 - state.serverName = null 93 } finally { 94 - state.loading = false 95 } 96 } 97 98 export function getServerConfigState() { 99 - return state 100 } 101 102 export function setServerName(name: string) { 103 - state.serverName = name 104 - document.title = name 105 } 106 107 export function setColors(colors: { 108 - primaryColor?: string | null 109 - primaryColorDark?: string | null 110 - secondaryColor?: string | null 111 - secondaryColorDark?: string | null 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() 118 } 119 120 export function setHasLogo(hasLogo: boolean) { 121 - state.hasLogo = hasLogo 122 - setFavicon(hasLogo) 123 }
··· 1 + import { api } from "./api"; 2 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; 11 } 12 13 let state = $state<ServerConfigState>({ ··· 18 secondaryColorDark: null, 19 hasLogo: false, 20 loading: true, 21 + }); 22 23 + let initialized = false; 24 + let darkModeQuery: MediaQueryList | null = null; 25 26 function isDarkMode(): boolean { 27 + return darkModeQuery?.matches ?? false; 28 } 29 30 function applyColors() { 31 + const root = document.documentElement; 32 + const dark = isDarkMode(); 33 34 if (dark) { 35 if (state.primaryColorDark) { 36 + root.style.setProperty("--accent", state.primaryColorDark); 37 } else { 38 + root.style.removeProperty("--accent"); 39 } 40 if (state.secondaryColorDark) { 41 + root.style.setProperty("--secondary", state.secondaryColorDark); 42 } else { 43 + root.style.removeProperty("--secondary"); 44 } 45 } else { 46 if (state.primaryColor) { 47 + root.style.setProperty("--accent", state.primaryColor); 48 } else { 49 + root.style.removeProperty("--accent"); 50 } 51 if (state.secondaryColor) { 52 + root.style.setProperty("--secondary", state.secondaryColor); 53 } else { 54 + root.style.removeProperty("--secondary"); 55 } 56 } 57 } 58 59 function setFavicon(hasLogo: boolean) { 60 + let link = document.querySelector<HTMLLinkElement>("link[rel~='icon']"); 61 if (hasLogo) { 62 if (!link) { 63 + link = document.createElement("link"); 64 + link.rel = "icon"; 65 + document.head.appendChild(link); 66 } 67 + link.href = "/logo"; 68 } else if (link) { 69 + link.remove(); 70 } 71 } 72 73 export async function initServerConfig(): Promise<void> { 74 + if (initialized) return; 75 + initialized = true; 76 77 + darkModeQuery = window.matchMedia("(prefers-color-scheme: dark)"); 78 + darkModeQuery.addEventListener("change", applyColors); 79 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); 91 } catch { 92 + state.serverName = null; 93 } finally { 94 + state.loading = false; 95 } 96 } 97 98 export function getServerConfigState() { 99 + return state; 100 } 101 102 export function setServerName(name: string) { 103 + state.serverName = name; 104 + document.title = name; 105 } 106 107 export function setColors(colors: { 108 + primaryColor?: string | null; 109 + primaryColorDark?: string | null; 110 + secondaryColor?: string | null; 111 + secondaryColorDark?: string | null; 112 }) { 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(); 126 } 127 128 export function setHasLogo(hasLogo: boolean) { 129 + state.hasLogo = hasLogo; 130 + setFavicon(hasLogo); 131 }
+41 -6
frontend/src/locales/en.json
··· 30 "lostPasskey": "Lost passkey?", 31 "noAccount": "Don't have an account?", 32 "createAccount": "Create account", 33 - "removeAccount": "Remove from saved accounts" 34 }, 35 "verification": { 36 "title": "Verify Your Account", ··· 47 "register": { 48 "title": "Create Account", 49 "subtitle": "Create a new account on this PDS", 50 "migrateTitle": "Already have a Bluesky account?", 51 "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 "migrateLink": "Migrate with PDS Moover", ··· 211 "messages": { 212 "emailCodeSent": "Verification code sent to your notification channel", 213 "emailUpdated": "Email updated successfully", 214 "handleUpdated": "Handle updated successfully", 215 "passwordChanged": "Password changed successfully", 216 "passwordsMismatch": "Passwords do not match", 217 "passwordLength": "Password must be at least 8 characters", 218 "deletionCodeSent": "Deletion confirmation sent to your email", 219 "repoExported": "Repository exported successfully", 220 "confirmDelete": "Are you absolutely sure you want to delete your account? This cannot be undone." 221 } 222 }, ··· 362 "manageTrustedDevices": "Manage Trusted Devices", 363 "appCompatibility": "App Compatibility", 364 "enterPassword": "Enter your password", 365 "legacyLoginEnabled": "Legacy app login enabled", 366 "legacyLoginDisabled": "Legacy app login disabled - only OAuth apps can sign in", 367 "failedToUpdatePreference": "Failed to update preference", ··· 421 "noRecords": "No records in this collection", 422 "recordDetails": "Record Details", 423 "rkey": "Record Key", 424 "cid": "CID", 425 "value": "Value", 426 "deleteRecord": "Delete Record", ··· 463 "themeColors": "Theme Colors", 464 "themeColorsHint": "Leave blank to use default colors.", 465 "primaryLight": "Primary (Light Mode)", 466 - "primaryLightDefault": "#2c00ff (default)", 467 "primaryDark": "Primary (Dark Mode)", 468 - "primaryDarkDefault": "#7b6bff (default)", 469 "secondaryLight": "Secondary (Light Mode)", 470 - "secondaryLightDefault": "#ff2400 (default)", 471 "secondaryDark": "Secondary (Dark Mode)", 472 - "secondaryDarkDefault": "#ff6b5b (default)", 473 "configSaved": "Server configuration saved", 474 "saving": "Saving...", 475 "saveConfig": "Save Configuration", ··· 527 "rememberDevice": "Remember this device", 528 "passkeyHintChecking": "Checking passkey status...", 529 "passkeyHintAvailable": "Sign in with your passkey", 530 - "passkeyHintNotAvailable": "No passkeys registered for this account" 531 }, 532 "consent": { 533 "title": "Authorize Application", ··· 741 "didWebBYODHint": "Bring your own domain", 742 "didWebWarningTitle": "Important: Understand the trade-offs", 743 "didWebWarning1": "Permanent tie to this PDS:", 744 "didWebWarning2": "No recovery mechanism:", 745 "didWebWarning2Detail": "Unlike did:plc, did:web has no rotation keys.", 746 "didWebWarning3": "We commit to you:", ··· 785 "title": "Trusted Devices", 786 "backToSecurity": "← Security Settings", 787 "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.", 788 "noDevices": "No trusted devices yet.", 789 "noDevicesHint": "When you log in with two-factor authentication enabled, you can choose to trust the device for 30 days.", 790 "lastSeen": "Last seen:",
··· 30 "lostPasskey": "Lost passkey?", 31 "noAccount": "Don't have an account?", 32 "createAccount": "Create account", 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." 44 }, 45 "verification": { 46 "title": "Verify Your Account", ··· 57 "register": { 58 "title": "Create Account", 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.", 71 "migrateTitle": "Already have a Bluesky account?", 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.", 73 "migrateLink": "Migrate with PDS Moover", ··· 232 "messages": { 233 "emailCodeSent": "Verification code sent to your notification channel", 234 "emailUpdated": "Email updated successfully", 235 + "emailUpdateFailed": "Failed to update email", 236 "handleUpdated": "Handle updated successfully", 237 + "handleUpdateFailed": "Failed to update handle", 238 "passwordChanged": "Password changed successfully", 239 + "passwordChangeFailed": "Failed to change password", 240 "passwordsMismatch": "Passwords do not match", 241 + "passwordsDoNotMatch": "Passwords do not match", 242 "passwordLength": "Password must be at least 8 characters", 243 + "passwordTooShort": "Password must be at least 8 characters", 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", 249 "repoExported": "Repository exported successfully", 250 + "exportFailed": "Failed to export repository", 251 "confirmDelete": "Are you absolutely sure you want to delete your account? This cannot be undone." 252 } 253 }, ··· 393 "manageTrustedDevices": "Manage Trusted Devices", 394 "appCompatibility": "App Compatibility", 395 "enterPassword": "Enter your password", 396 + "sessionExpired": "Session expired. Please log in again.", 397 "legacyLoginEnabled": "Legacy app login enabled", 398 "legacyLoginDisabled": "Legacy app login disabled - only OAuth apps can sign in", 399 "failedToUpdatePreference": "Failed to update preference", ··· 453 "noRecords": "No records in this collection", 454 "recordDetails": "Record Details", 455 "rkey": "Record Key", 456 + "uri": "URI", 457 "cid": "CID", 458 "value": "Value", 459 "deleteRecord": "Delete Record", ··· 496 "themeColors": "Theme Colors", 497 "themeColorsHint": "Leave blank to use default colors.", 498 "primaryLight": "Primary (Light Mode)", 499 + "colorDefault": "{color} (default)", 500 "primaryDark": "Primary (Dark Mode)", 501 "secondaryLight": "Secondary (Light Mode)", 502 "secondaryDark": "Secondary (Dark Mode)", 503 "configSaved": "Server configuration saved", 504 "saving": "Saving...", 505 "saveConfig": "Save Configuration", ··· 557 "rememberDevice": "Remember this device", 558 "passkeyHintChecking": "Checking passkey status...", 559 "passkeyHintAvailable": "Sign in with your passkey", 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" 564 }, 565 "consent": { 566 "title": "Authorize Application", ··· 774 "didWebBYODHint": "Bring your own domain", 775 "didWebWarningTitle": "Important: Understand the trade-offs", 776 "didWebWarning1": "Permanent tie to this PDS:", 777 + "didWebWarning1Detail": "Your identity will be {did}.", 778 "didWebWarning2": "No recovery mechanism:", 779 "didWebWarning2Detail": "Unlike did:plc, did:web has no rotation keys.", 780 "didWebWarning3": "We commit to you:", ··· 819 "title": "Trusted Devices", 820 "backToSecurity": "← Security Settings", 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", 823 "noDevices": "No trusted devices yet.", 824 "noDevicesHint": "When you log in with two-factor authentication enabled, you can choose to trust the device for 30 days.", 825 "lastSeen": "Last seen:",
+97 -8
frontend/src/locales/fi.json
··· 30 "lostPasskey": "Kadotitko pääsyavaimen?", 31 "noAccount": "Eikö sinulla ole tiliä?", 32 "createAccount": "Luo tili", 33 - "removeAccount": "Poista tallennetuista tileistä" 34 }, 35 "verification": { 36 "title": "Vahvista tilisi", ··· 47 "register": { 48 "title": "Luo tili", 49 "subtitle": "Luo uusi tili tälle PDS:lle", 50 "migrateTitle": "Onko sinulla jo Bluesky-tili?", 51 "migrateDescription": "Voit siirtää olemassa olevan tilisi tälle PDS:lle uuden luomisen sijaan. Seuraajasi, julkaisusi ja identiteettisi siirtyvät mukana.", 52 "migrateLink": "Siirrä PDS Mooverilla", ··· 211 "messages": { 212 "emailCodeSent": "Vahvistuskoodi lähetetty ilmoituskanavallesi", 213 "emailUpdated": "Sähköposti päivitetty", 214 "handleUpdated": "Käyttäjänimi päivitetty", 215 "passwordChanged": "Salasana vaihdettu", 216 "passwordsMismatch": "Salasanat eivät täsmää", 217 "passwordLength": "Salasanan on oltava vähintään 8 merkkiä", 218 "deletionCodeSent": "Poistovahvistus lähetetty sähköpostiisi", 219 "repoExported": "Tietovarasto viety", 220 "confirmDelete": "Oletko täysin varma, että haluat poistaa tilisi? Tätä ei voi perua." 221 } 222 }, ··· 362 "manageTrustedDevices": "Hallitse luotettuja laitteita", 363 "appCompatibility": "Sovellusyhteensopivuus", 364 "enterPassword": "Syötä salasanasi", 365 "legacyLoginEnabled": "Vanhentuneiden sovellusten kirjautuminen käytössä", 366 "legacyLoginDisabled": "Vanhentuneiden sovellusten kirjautuminen poistettu käytöstä - vain OAuth-sovellukset voivat kirjautua", 367 "failedToUpdatePreference": "Asetuksen päivittäminen epäonnistui", ··· 421 "noRecords": "Ei tietueita tässä kokoelmassa", 422 "recordDetails": "Tietueen tiedot", 423 "rkey": "Tietueavain", 424 "cid": "CID", 425 "value": "Arvo", 426 "deleteRecord": "Poista tietue", ··· 464 "themeColorsHint": "Jätä tyhjäksi käyttääksesi oletusvärejä.", 465 "primaryLight": "Ensisijainen (vaalea tila)", 466 "primaryDark": "Ensisijainen (tumma tila)", 467 - "accentLight": "Korostus (vaalea tila)", 468 - "accentDark": "Korostus (tumma tila)", 469 - "faviconExample": "Favicon-esimerkki", 470 "configSaved": "Palvelinasetukset tallennettu", 471 "saving": "Tallennetaan...", 472 "saveConfig": "Tallenna asetukset", ··· 508 "deleteConfirm": "Poista tili @{handle}? Tätä ei voi perua.", 509 "verified": "Vahvistettu", 510 "unverified": "Vahvistamaton", 511 - "deactivated": "Poistettu käytöstä" 512 }, 513 "oauth": { 514 "login": { ··· 524 "rememberDevice": "Muista tämä laite", 525 "passkeyHintChecking": "Tarkistetaan pääsyavaimen tilaa...", 526 "passkeyHintAvailable": "Kirjaudu pääsyavaimellasi", 527 - "passkeyHintNotAvailable": "Ei rekisteröityjä pääsyavaimia tälle tilille" 528 }, 529 "consent": { 530 "title": "Valtuuta sovellus", ··· 740 "handleNoDots": "Käyttäjänimi ei voi sisältää pisteitä. Voit määrittää oman verkkotunnuksen tilin luomisen jälkeen.", 741 "passkeysNotSupported": "Pääsyavaimia ei tueta tässä selaimessa. Luo salasanapohjainen tili tai käytä selainta, joka tukee pääsyavaimia.", 742 "passkeyCancelled": "Pääsyavaimen luominen peruutettu", 743 - "passkeyFailed": "Pääsyavaimen rekisteröinti epäonnistui" 744 - } 745 }, 746 "trustedDevices": { 747 "title": "Luotetut laitteet", 748 "backToSecurity": "← Turvallisuusasetukset", 749 "description": "Luotetut laitteet voivat ohittaa kaksivaiheisen tunnistautumisen kirjautuessaan. Luottamus myönnetään 30 päiväksi ja jatkuu automaattisesti, kun käytät laitetta.", 750 "noDevices": "Ei vielä luotettuja laitteita.", 751 "noDevicesHint": "Kun kirjaudut sisään kaksivaiheisen tunnistautumisen ollessa käytössä, voit valita luottaa laitteeseen 30 päivää.", 752 "lastSeen": "Viimeksi nähty:",
··· 30 "lostPasskey": "Kadotitko pääsyavaimen?", 31 "noAccount": "Eikö sinulla ole tiliä?", 32 "createAccount": "Luo tili", 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." 44 }, 45 "verification": { 46 "title": "Vahvista tilisi", ··· 57 "register": { 58 "title": "Luo tili", 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.", 71 "migrateTitle": "Onko sinulla jo Bluesky-tili?", 72 "migrateDescription": "Voit siirtää olemassa olevan tilisi tälle PDS:lle uuden luomisen sijaan. Seuraajasi, julkaisusi ja identiteettisi siirtyvät mukana.", 73 "migrateLink": "Siirrä PDS Mooverilla", ··· 232 "messages": { 233 "emailCodeSent": "Vahvistuskoodi lähetetty ilmoituskanavallesi", 234 "emailUpdated": "Sähköposti päivitetty", 235 + "emailUpdateFailed": "Sähköpostin päivitys epäonnistui", 236 "handleUpdated": "Käyttäjänimi päivitetty", 237 + "handleUpdateFailed": "Käyttäjänimen päivitys epäonnistui", 238 "passwordChanged": "Salasana vaihdettu", 239 + "passwordChangeFailed": "Salasanan vaihto epäonnistui", 240 "passwordsMismatch": "Salasanat eivät täsmää", 241 + "passwordsDoNotMatch": "Salasanat eivät täsmää", 242 "passwordLength": "Salasanan on oltava vähintään 8 merkkiä", 243 + "passwordTooShort": "Salasanan on oltava vähintään 8 merkkiä", 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", 249 "repoExported": "Tietovarasto viety", 250 + "exportFailed": "Tietovaraston vienti epäonnistui", 251 "confirmDelete": "Oletko täysin varma, että haluat poistaa tilisi? Tätä ei voi perua." 252 } 253 }, ··· 393 "manageTrustedDevices": "Hallitse luotettuja laitteita", 394 "appCompatibility": "Sovellusyhteensopivuus", 395 "enterPassword": "Syötä salasanasi", 396 + "sessionExpired": "Istunto vanhentunut. Kirjaudu sisään uudelleen.", 397 "legacyLoginEnabled": "Vanhentuneiden sovellusten kirjautuminen käytössä", 398 "legacyLoginDisabled": "Vanhentuneiden sovellusten kirjautuminen poistettu käytöstä - vain OAuth-sovellukset voivat kirjautua", 399 "failedToUpdatePreference": "Asetuksen päivittäminen epäonnistui", ··· 453 "noRecords": "Ei tietueita tässä kokoelmassa", 454 "recordDetails": "Tietueen tiedot", 455 "rkey": "Tietueavain", 456 + "uri": "URI", 457 "cid": "CID", 458 "value": "Arvo", 459 "deleteRecord": "Poista tietue", ··· 497 "themeColorsHint": "Jätä tyhjäksi käyttääksesi oletusvärejä.", 498 "primaryLight": "Ensisijainen (vaalea tila)", 499 "primaryDark": "Ensisijainen (tumma tila)", 500 "configSaved": "Palvelinasetukset tallennettu", 501 "saving": "Tallennetaan...", 502 "saveConfig": "Tallenna asetukset", ··· 538 "deleteConfirm": "Poista tili @{handle}? Tätä ei voi perua.", 539 "verified": "Vahvistettu", 540 "unverified": "Vahvistamaton", 541 + "deactivated": "Poistettu käytöstä", 542 + "colorDefault": "{color} (oletus)", 543 + "secondaryLight": "Toissijainen (vaalea tila)", 544 + "secondaryDark": "Toissijainen (tumma tila)" 545 }, 546 "oauth": { 547 "login": { ··· 557 "rememberDevice": "Muista tämä laite", 558 "passkeyHintChecking": "Tarkistetaan pääsyavaimen tilaa...", 559 "passkeyHintAvailable": "Kirjaudu pääsyavaimellasi", 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" 564 }, 565 "consent": { 566 "title": "Valtuuta sovellus", ··· 776 "handleNoDots": "Käyttäjänimi ei voi sisältää pisteitä. Voit määrittää oman verkkotunnuksen tilin luomisen jälkeen.", 777 "passkeysNotSupported": "Pääsyavaimia ei tueta tässä selaimessa. Luo salasanapohjainen tili tai käytä selainta, joka tukee pääsyavaimia.", 778 "passkeyCancelled": "Pääsyavaimen luominen peruutettu", 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" 833 }, 834 "trustedDevices": { 835 "title": "Luotetut laitteet", 836 "backToSecurity": "← Turvallisuusasetukset", 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", 839 "noDevices": "Ei vielä luotettuja laitteita.", 840 "noDevicesHint": "Kun kirjaudut sisään kaksivaiheisen tunnistautumisen ollessa käytössä, voit valita luottaa laitteeseen 30 päivää.", 841 "lastSeen": "Viimeksi nähty:",
+97 -8
frontend/src/locales/ja.json
··· 30 "lostPasskey": "パスキーを紛失しましたか?", 31 "noAccount": "アカウントをお持ちでないですか?", 32 "createAccount": "アカウントを作成", 33 - "removeAccount": "保存済みアカウントから削除" 34 }, 35 "verification": { 36 "title": "アカウント確認", ··· 47 "register": { 48 "title": "アカウント作成", 49 "subtitle": "この PDS で新規アカウントを作成", 50 "migrateTitle": "すでにBlueskyアカウントをお持ちですか?", 51 "migrateDescription": "新しいアカウントを作成する代わりに、既存のアカウントをこのPDSに移行できます。フォロワー、投稿、IDも一緒に移行されます。", 52 "migrateLink": "PDS Mooverで移行する", ··· 211 "messages": { 212 "emailCodeSent": "通知チャンネルに確認コードを送信しました", 213 "emailUpdated": "メールを更新しました", 214 "handleUpdated": "ハンドルを更新しました", 215 "passwordChanged": "パスワードを変更しました", 216 "passwordsMismatch": "パスワードが一致しません", 217 "passwordLength": "パスワードは8文字以上である必要があります", 218 "deletionCodeSent": "削除確認をメールに送信しました", 219 "repoExported": "リポジトリをエクスポートしました", 220 "confirmDelete": "本当にアカウントを削除しますか?この操作は取り消せません。" 221 } 222 }, ··· 362 "manageTrustedDevices": "信頼済みデバイスを管理", 363 "appCompatibility": "アプリ互換性", 364 "enterPassword": "パスワードを入力", 365 "legacyLoginEnabled": "レガシーアプリログインが有効", 366 "legacyLoginDisabled": "レガシーアプリログインが無効 - OAuth アプリのみサインイン可能", 367 "failedToUpdatePreference": "設定の更新に失敗しました", ··· 421 "noRecords": "このコレクションにレコードはありません", 422 "recordDetails": "レコード詳細", 423 "rkey": "レコードキー", 424 "cid": "CID", 425 "value": "値", 426 "deleteRecord": "レコードを削除", ··· 464 "themeColorsHint": "デフォルトカラーを使用する場合は空白のままにしてください。", 465 "primaryLight": "プライマリ(ライトモード)", 466 "primaryDark": "プライマリ(ダークモード)", 467 - "accentLight": "アクセント(ライトモード)", 468 - "accentDark": "アクセント(ダークモード)", 469 - "faviconExample": "ファビコン例", 470 "configSaved": "サーバー設定を保存しました", 471 "saving": "保存中...", 472 "saveConfig": "設定を保存", ··· 508 "deleteConfirm": "アカウント @{handle} を削除しますか?この操作は取り消せません。", 509 "verified": "確認済み", 510 "unverified": "未確認", 511 - "deactivated": "無効化" 512 }, 513 "oauth": { 514 "login": { ··· 524 "rememberDevice": "このデバイスを記憶する", 525 "passkeyHintChecking": "パスキーの状態を確認中...", 526 "passkeyHintAvailable": "パスキーでサインイン", 527 - "passkeyHintNotAvailable": "このアカウントにはパスキーが登録されていません" 528 }, 529 "consent": { 530 "title": "アプリを承認", ··· 740 "handleNoDots": "ハンドルにドットは使用できません。アカウント作成後にカスタムドメインを設定できます。", 741 "passkeysNotSupported": "このブラウザではパスキーがサポートされていません。パスワードベースのアカウントを作成するか、パスキーをサポートするブラウザを使用してください。", 742 "passkeyCancelled": "パスキーの作成がキャンセルされました", 743 - "passkeyFailed": "パスキーの登録に失敗しました" 744 - } 745 }, 746 "trustedDevices": { 747 "title": "信頼済みデバイス", 748 "backToSecurity": "← セキュリティ設定", 749 "description": "信頼済みデバイスはログイン時に二要素認証をスキップできます。信頼は30日間有効で、デバイスを使用すると自動的に延長されます。", 750 "noDevices": "信頼済みデバイスはまだありません。", 751 "noDevicesHint": "二要素認証を有効にしてログインする際に、デバイスを30日間信頼することを選択できます。", 752 "lastSeen": "最終使用:",
··· 30 "lostPasskey": "パスキーを紛失しましたか?", 31 "noAccount": "アカウントをお持ちでないですか?", 32 "createAccount": "アカウントを作成", 33 + "removeAccount": "保存済みアカウントから削除", 34 + "infoSavedAccountsTitle": "保存済みアカウント", 35 + "infoSavedAccountsDesc": "アカウントをクリックすると即座にサインインできます。セッショントークンはこのブラウザに安全に保存されています。", 36 + "infoNewAccountTitle": "新規アカウント", 37 + "infoNewAccountDesc": "サインインボタンで別のアカウントを追加できます。×をクリックすると保存済みアカウントを削除できます。", 38 + "infoSecureSignInTitle": "安全なサインイン", 39 + "infoSecureSignInDesc": "安全な認証のためにリダイレクトされます。パスキーや二要素認証が有効な場合は、それらも求められます。", 40 + "infoStaySignedInTitle": "サインイン状態を維持", 41 + "infoStaySignedInDesc": "サインイン後、アカウントはこのブラウザに保存され、次回から素早くアクセスできます。", 42 + "infoRecoveryTitle": "アカウント復旧", 43 + "infoRecoveryDesc": "パスワードやパスキーを紛失しましたか?サインインボタンの下の復旧リンクをご利用ください。" 44 }, 45 "verification": { 46 "title": "アカウント確認", ··· 57 "register": { 58 "title": "アカウント作成", 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 アプリを使用できます。", 71 "migrateTitle": "すでにBlueskyアカウントをお持ちですか?", 72 "migrateDescription": "新しいアカウントを作成する代わりに、既存のアカウントをこのPDSに移行できます。フォロワー、投稿、IDも一緒に移行されます。", 73 "migrateLink": "PDS Mooverで移行する", ··· 232 "messages": { 233 "emailCodeSent": "通知チャンネルに確認コードを送信しました", 234 "emailUpdated": "メールを更新しました", 235 + "emailUpdateFailed": "メールの更新に失敗しました", 236 "handleUpdated": "ハンドルを更新しました", 237 + "handleUpdateFailed": "ハンドルの更新に失敗しました", 238 "passwordChanged": "パスワードを変更しました", 239 + "passwordChangeFailed": "パスワードの変更に失敗しました", 240 "passwordsMismatch": "パスワードが一致しません", 241 + "passwordsDoNotMatch": "パスワードが一致しません", 242 "passwordLength": "パスワードは8文字以上である必要があります", 243 + "passwordTooShort": "パスワードは8文字以上である必要があります", 244 "deletionCodeSent": "削除確認をメールに送信しました", 245 + "deletionConfirmationSent": "削除確認をメールに送信しました", 246 + "deletionRequestFailed": "アカウント削除リクエストに失敗しました", 247 + "deleteConfirmation": "本当にアカウントを削除しますか?この操作は取り消せません。", 248 + "deletionFailed": "アカウントの削除に失敗しました", 249 "repoExported": "リポジトリをエクスポートしました", 250 + "exportFailed": "リポジトリのエクスポートに失敗しました", 251 "confirmDelete": "本当にアカウントを削除しますか?この操作は取り消せません。" 252 } 253 }, ··· 393 "manageTrustedDevices": "信頼済みデバイスを管理", 394 "appCompatibility": "アプリ互換性", 395 "enterPassword": "パスワードを入力", 396 + "sessionExpired": "セッションが期限切れです。再度ログインしてください。", 397 "legacyLoginEnabled": "レガシーアプリログインが有効", 398 "legacyLoginDisabled": "レガシーアプリログインが無効 - OAuth アプリのみサインイン可能", 399 "failedToUpdatePreference": "設定の更新に失敗しました", ··· 453 "noRecords": "このコレクションにレコードはありません", 454 "recordDetails": "レコード詳細", 455 "rkey": "レコードキー", 456 + "uri": "URI", 457 "cid": "CID", 458 "value": "値", 459 "deleteRecord": "レコードを削除", ··· 497 "themeColorsHint": "デフォルトカラーを使用する場合は空白のままにしてください。", 498 "primaryLight": "プライマリ(ライトモード)", 499 "primaryDark": "プライマリ(ダークモード)", 500 "configSaved": "サーバー設定を保存しました", 501 "saving": "保存中...", 502 "saveConfig": "設定を保存", ··· 538 "deleteConfirm": "アカウント @{handle} を削除しますか?この操作は取り消せません。", 539 "verified": "確認済み", 540 "unverified": "未確認", 541 + "deactivated": "無効化", 542 + "colorDefault": "{color}(デフォルト)", 543 + "secondaryLight": "セカンダリ(ライトモード)", 544 + "secondaryDark": "セカンダリ(ダークモード)" 545 }, 546 "oauth": { 547 "login": { ··· 557 "rememberDevice": "このデバイスを記憶する", 558 "passkeyHintChecking": "パスキーの状態を確認中...", 559 "passkeyHintAvailable": "パスキーでサインイン", 560 + "passkeyHintNotAvailable": "このアカウントにはパスキーが登録されていません", 561 + "passkeyHint": "デバイスの生体認証またはセキュリティキーを使用", 562 + "passwordPlaceholder": "パスワードを入力", 563 + "usePasskey": "パスキーを使用" 564 }, 565 "consent": { 566 "title": "アプリを承認", ··· 776 "handleNoDots": "ハンドルにドットは使用できません。アカウント作成後にカスタムドメインを設定できます。", 777 "passkeysNotSupported": "このブラウザではパスキーがサポートされていません。パスワードベースのアカウントを作成するか、パスキーをサポートするブラウザを使用してください。", 778 "passkeyCancelled": "パスキーの作成がキャンセルされました", 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": "パスワードで登録" 833 }, 834 "trustedDevices": { 835 "title": "信頼済みデバイス", 836 "backToSecurity": "← セキュリティ設定", 837 "description": "信頼済みデバイスはログイン時に二要素認証をスキップできます。信頼は30日間有効で、デバイスを使用すると自動的に延長されます。", 838 + "failedToLoad": "信頼済みデバイスの読み込みに失敗しました", 839 "noDevices": "信頼済みデバイスはまだありません。", 840 "noDevicesHint": "二要素認証を有効にしてログインする際に、デバイスを30日間信頼することを選択できます。", 841 "lastSeen": "最終使用:",
+97 -8
frontend/src/locales/ko.json
··· 30 "lostPasskey": "패스키를 분실하셨나요?", 31 "noAccount": "계정이 없으신가요?", 32 "createAccount": "계정 만들기", 33 - "removeAccount": "저장된 계정에서 삭제" 34 }, 35 "verification": { 36 "title": "계정 인증", ··· 47 "register": { 48 "title": "계정 만들기", 49 "subtitle": "이 PDS에 새 계정을 만듭니다", 50 "migrateTitle": "이미 Bluesky 계정이 있으신가요?", 51 "migrateDescription": "새 계정을 만드는 대신 기존 계정을 이 PDS로 마이그레이션할 수 있습니다. 팔로워, 게시물, ID가 함께 이전됩니다.", 52 "migrateLink": "PDS Moover로 마이그레이션", ··· 211 "messages": { 212 "emailCodeSent": "알림 채널로 인증 코드를 보냈습니다", 213 "emailUpdated": "이메일이 업데이트되었습니다", 214 "handleUpdated": "핸들이 업데이트되었습니다", 215 "passwordChanged": "비밀번호가 변경되었습니다", 216 "passwordsMismatch": "비밀번호가 일치하지 않습니다", 217 "passwordLength": "비밀번호는 8자 이상이어야 합니다", 218 "deletionCodeSent": "이메일로 삭제 확인을 보냈습니다", 219 "repoExported": "저장소를 내보냈습니다", 220 "confirmDelete": "정말로 계정을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다." 221 } 222 }, ··· 362 "manageTrustedDevices": "신뢰할 수 있는 기기 관리", 363 "appCompatibility": "앱 호환성", 364 "enterPassword": "비밀번호를 입력하세요", 365 "legacyLoginEnabled": "레거시 앱 로그인 활성화됨", 366 "legacyLoginDisabled": "레거시 앱 로그인 비활성화됨 - OAuth 앱만 로그인 가능", 367 "failedToUpdatePreference": "설정 업데이트에 실패했습니다", ··· 421 "noRecords": "이 컬렉션에 레코드가 없습니다", 422 "recordDetails": "레코드 세부 정보", 423 "rkey": "레코드 키", 424 "cid": "CID", 425 "value": "값", 426 "deleteRecord": "레코드 삭제", ··· 464 "themeColorsHint": "기본 색상을 사용하려면 비워 두세요.", 465 "primaryLight": "기본 (라이트 모드)", 466 "primaryDark": "기본 (다크 모드)", 467 - "accentLight": "강조 (라이트 모드)", 468 - "accentDark": "강조 (다크 모드)", 469 - "faviconExample": "파비콘 예시", 470 "configSaved": "서버 설정이 저장되었습니다", 471 "saving": "저장 중...", 472 "saveConfig": "설정 저장", ··· 508 "deleteConfirm": "계정 @{handle}을(를) 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", 509 "verified": "인증됨", 510 "unverified": "미인증", 511 - "deactivated": "비활성화됨" 512 }, 513 "oauth": { 514 "login": { ··· 524 "rememberDevice": "이 기기 기억하기", 525 "passkeyHintChecking": "패스키 상태 확인 중...", 526 "passkeyHintAvailable": "패스키로 로그인", 527 - "passkeyHintNotAvailable": "이 계정에 등록된 패스키가 없습니다" 528 }, 529 "consent": { 530 "title": "앱 승인", ··· 740 "handleNoDots": "핸들에 점을 포함할 수 없습니다. 계정 생성 후 사용자 정의 도메인을 설정할 수 있습니다.", 741 "passkeysNotSupported": "이 브라우저에서 패스키가 지원되지 않습니다. 비밀번호 기반 계정을 만들거나 패스키를 지원하는 브라우저를 사용하세요.", 742 "passkeyCancelled": "패스키 생성이 취소되었습니다", 743 - "passkeyFailed": "패스키 등록에 실패했습니다" 744 - } 745 }, 746 "trustedDevices": { 747 "title": "신뢰할 수 있는 기기", 748 "backToSecurity": "← 보안 설정", 749 "description": "신뢰할 수 있는 기기는 로그인 시 2단계 인증을 건너뛸 수 있습니다. 신뢰는 30일간 유효하며 기기를 사용할 때 자동으로 연장됩니다.", 750 "noDevices": "신뢰할 수 있는 기기가 아직 없습니다.", 751 "noDevicesHint": "2단계 인증이 활성화된 상태로 로그인할 때 기기를 30일간 신뢰하도록 선택할 수 있습니다.", 752 "lastSeen": "마지막 접속:",
··· 30 "lostPasskey": "패스키를 분실하셨나요?", 31 "noAccount": "계정이 없으신가요?", 32 "createAccount": "계정 만들기", 33 + "removeAccount": "저장된 계정에서 삭제", 34 + "infoSavedAccountsTitle": "저장된 계정", 35 + "infoSavedAccountsDesc": "계정을 클릭하면 즉시 로그인할 수 있습니다. 세션 토큰은 이 브라우저에 안전하게 저장됩니다.", 36 + "infoNewAccountTitle": "새 계정", 37 + "infoNewAccountDesc": "로그인 버튼을 사용하여 다른 계정을 추가하세요. ×를 클릭하여 저장된 계정을 제거할 수 있습니다.", 38 + "infoSecureSignInTitle": "안전한 로그인", 39 + "infoSecureSignInDesc": "안전한 인증을 위해 리디렉션됩니다. 패스키나 2단계 인증이 활성화되어 있으면 해당 인증도 요청됩니다.", 40 + "infoStaySignedInTitle": "로그인 유지", 41 + "infoStaySignedInDesc": "로그인 후 계정이 이 브라우저에 저장되어 다음에 빠르게 접속할 수 있습니다.", 42 + "infoRecoveryTitle": "계정 복구", 43 + "infoRecoveryDesc": "비밀번호나 패스키를 분실하셨나요? 로그인 버튼 아래의 복구 링크를 사용하세요." 44 }, 45 "verification": { 46 "title": "계정 인증", ··· 57 "register": { 58 "title": "계정 만들기", 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 앱을 사용할 수 있습니다.", 71 "migrateTitle": "이미 Bluesky 계정이 있으신가요?", 72 "migrateDescription": "새 계정을 만드는 대신 기존 계정을 이 PDS로 마이그레이션할 수 있습니다. 팔로워, 게시물, ID가 함께 이전됩니다.", 73 "migrateLink": "PDS Moover로 마이그레이션", ··· 232 "messages": { 233 "emailCodeSent": "알림 채널로 인증 코드를 보냈습니다", 234 "emailUpdated": "이메일이 업데이트되었습니다", 235 + "emailUpdateFailed": "이메일 업데이트에 실패했습니다", 236 "handleUpdated": "핸들이 업데이트되었습니다", 237 + "handleUpdateFailed": "핸들 업데이트에 실패했습니다", 238 "passwordChanged": "비밀번호가 변경되었습니다", 239 + "passwordChangeFailed": "비밀번호 변경에 실패했습니다", 240 "passwordsMismatch": "비밀번호가 일치하지 않습니다", 241 + "passwordsDoNotMatch": "비밀번호가 일치하지 않습니다", 242 "passwordLength": "비밀번호는 8자 이상이어야 합니다", 243 + "passwordTooShort": "비밀번호는 8자 이상이어야 합니다", 244 "deletionCodeSent": "이메일로 삭제 확인을 보냈습니다", 245 + "deletionConfirmationSent": "이메일로 삭제 확인을 보냈습니다", 246 + "deletionRequestFailed": "계정 삭제 요청에 실패했습니다", 247 + "deleteConfirmation": "정말로 계정을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", 248 + "deletionFailed": "계정 삭제에 실패했습니다", 249 "repoExported": "저장소를 내보냈습니다", 250 + "exportFailed": "저장소 내보내기에 실패했습니다", 251 "confirmDelete": "정말로 계정을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다." 252 } 253 }, ··· 393 "manageTrustedDevices": "신뢰할 수 있는 기기 관리", 394 "appCompatibility": "앱 호환성", 395 "enterPassword": "비밀번호를 입력하세요", 396 + "sessionExpired": "세션이 만료되었습니다. 다시 로그인하세요.", 397 "legacyLoginEnabled": "레거시 앱 로그인 활성화됨", 398 "legacyLoginDisabled": "레거시 앱 로그인 비활성화됨 - OAuth 앱만 로그인 가능", 399 "failedToUpdatePreference": "설정 업데이트에 실패했습니다", ··· 453 "noRecords": "이 컬렉션에 레코드가 없습니다", 454 "recordDetails": "레코드 세부 정보", 455 "rkey": "레코드 키", 456 + "uri": "URI", 457 "cid": "CID", 458 "value": "값", 459 "deleteRecord": "레코드 삭제", ··· 497 "themeColorsHint": "기본 색상을 사용하려면 비워 두세요.", 498 "primaryLight": "기본 (라이트 모드)", 499 "primaryDark": "기본 (다크 모드)", 500 "configSaved": "서버 설정이 저장되었습니다", 501 "saving": "저장 중...", 502 "saveConfig": "설정 저장", ··· 538 "deleteConfirm": "계정 @{handle}을(를) 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", 539 "verified": "인증됨", 540 "unverified": "미인증", 541 + "deactivated": "비활성화됨", 542 + "colorDefault": "{color} (기본값)", 543 + "secondaryLight": "보조 (라이트 모드)", 544 + "secondaryDark": "보조 (다크 모드)" 545 }, 546 "oauth": { 547 "login": { ··· 557 "rememberDevice": "이 기기 기억하기", 558 "passkeyHintChecking": "패스키 상태 확인 중...", 559 "passkeyHintAvailable": "패스키로 로그인", 560 + "passkeyHintNotAvailable": "이 계정에 등록된 패스키가 없습니다", 561 + "passkeyHint": "기기의 생체 인식 또는 보안 키 사용", 562 + "passwordPlaceholder": "비밀번호 입력", 563 + "usePasskey": "패스키 사용" 564 }, 565 "consent": { 566 "title": "앱 승인", ··· 776 "handleNoDots": "핸들에 점을 포함할 수 없습니다. 계정 생성 후 사용자 정의 도메인을 설정할 수 있습니다.", 777 "passkeysNotSupported": "이 브라우저에서 패스키가 지원되지 않습니다. 비밀번호 기반 계정을 만들거나 패스키를 지원하는 브라우저를 사용하세요.", 778 "passkeyCancelled": "패스키 생성이 취소되었습니다", 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": "비밀번호로 가입" 833 }, 834 "trustedDevices": { 835 "title": "신뢰할 수 있는 기기", 836 "backToSecurity": "← 보안 설정", 837 "description": "신뢰할 수 있는 기기는 로그인 시 2단계 인증을 건너뛸 수 있습니다. 신뢰는 30일간 유효하며 기기를 사용할 때 자동으로 연장됩니다.", 838 + "failedToLoad": "신뢰할 수 있는 기기를 불러오지 못했습니다", 839 "noDevices": "신뢰할 수 있는 기기가 아직 없습니다.", 840 "noDevicesHint": "2단계 인증이 활성화된 상태로 로그인할 때 기기를 30일간 신뢰하도록 선택할 수 있습니다.", 841 "lastSeen": "마지막 접속:",
+97 -8
frontend/src/locales/sv.json
··· 30 "lostPasskey": "Tappat bort nyckeln?", 31 "noAccount": "Har du inget konto?", 32 "createAccount": "Skapa konto", 33 - "removeAccount": "Ta bort från sparade konton" 34 }, 35 "verification": { 36 "title": "Verifiera ditt konto", ··· 47 "register": { 48 "title": "Skapa konto", 49 "subtitle": "Skapa ett nytt konto på denna PDS", 50 "migrateTitle": "Har du redan ett Bluesky-konto?", 51 "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 "migrateLink": "Flytta med PDS Moover", ··· 211 "messages": { 212 "emailCodeSent": "Verifieringskod skickad till din meddelandekanal", 213 "emailUpdated": "E-post uppdaterad", 214 "handleUpdated": "Användarnamn uppdaterat", 215 "passwordChanged": "Lösenord ändrat", 216 "passwordsMismatch": "Lösenorden matchar inte", 217 "passwordLength": "Lösenordet måste vara minst 8 tecken", 218 "deletionCodeSent": "Bekräftelse för radering skickad till din e-post", 219 "repoExported": "Arkiv exporterat", 220 "confirmDelete": "Är du helt säker på att du vill radera ditt konto? Detta kan inte ångras." 221 } 222 }, ··· 362 "manageTrustedDevices": "Hantera betrodda enheter", 363 "appCompatibility": "Appkompatibilitet", 364 "enterPassword": "Ange ditt lösenord", 365 "legacyLoginEnabled": "Föråldrad appinloggning aktiverad", 366 "legacyLoginDisabled": "Föråldrad appinloggning inaktiverad - endast OAuth-appar kan logga in", 367 "failedToUpdatePreference": "Kunde inte uppdatera inställning", ··· 421 "noRecords": "Inga poster i denna samling", 422 "recordDetails": "Postdetaljer", 423 "rkey": "Postnyckel", 424 "cid": "CID", 425 "value": "Värde", 426 "deleteRecord": "Radera post", ··· 464 "themeColorsHint": "Lämna tomt för att använda standardfärger.", 465 "primaryLight": "Primär (ljust läge)", 466 "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 "configSaved": "Serverkonfiguration sparad", 471 "saving": "Sparar...", 472 "saveConfig": "Spara konfiguration", ··· 508 "deleteConfirm": "Radera konto @{handle}? Detta kan inte ångras.", 509 "verified": "Verifierad", 510 "unverified": "Ej verifierad", 511 - "deactivated": "Inaktiverad" 512 }, 513 "oauth": { 514 "login": { ··· 524 "rememberDevice": "Kom ihåg denna enhet", 525 "passkeyHintChecking": "Kontrollerar nyckelstatus...", 526 "passkeyHintAvailable": "Logga in med din nyckel", 527 - "passkeyHintNotAvailable": "Inga nycklar registrerade för detta konto" 528 }, 529 "consent": { 530 "title": "Auktorisera applikation", ··· 740 "handleNoDots": "Användarnamn kan inte innehålla punkter. Du kan konfigurera ett eget domännamn efter att kontot skapats.", 741 "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 "passkeyCancelled": "Nyckelskapande avbröts", 743 - "passkeyFailed": "Nyckelregistrering misslyckades" 744 - } 745 }, 746 "trustedDevices": { 747 "title": "Betrodda enheter", 748 "backToSecurity": "← Säkerhetsinställningar", 749 "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.", 750 "noDevices": "Inga betrodda enheter ännu.", 751 "noDevicesHint": "När du loggar in med tvåfaktorsautentisering aktiverat kan du välja att lita på enheten i 30 dagar.", 752 "lastSeen": "Senast sedd:",
··· 30 "lostPasskey": "Tappat bort nyckeln?", 31 "noAccount": "Har du inget konto?", 32 "createAccount": "Skapa konto", 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." 44 }, 45 "verification": { 46 "title": "Verifiera ditt konto", ··· 57 "register": { 58 "title": "Skapa konto", 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.", 71 "migrateTitle": "Har du redan ett Bluesky-konto?", 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.", 73 "migrateLink": "Flytta med PDS Moover", ··· 232 "messages": { 233 "emailCodeSent": "Verifieringskod skickad till din meddelandekanal", 234 "emailUpdated": "E-post uppdaterad", 235 + "emailUpdateFailed": "Kunde inte uppdatera e-post", 236 "handleUpdated": "Användarnamn uppdaterat", 237 + "handleUpdateFailed": "Kunde inte uppdatera användarnamn", 238 "passwordChanged": "Lösenord ändrat", 239 + "passwordChangeFailed": "Kunde inte ändra lösenord", 240 "passwordsMismatch": "Lösenorden matchar inte", 241 + "passwordsDoNotMatch": "Lösenorden matchar inte", 242 "passwordLength": "Lösenordet måste vara minst 8 tecken", 243 + "passwordTooShort": "Lösenordet måste vara minst 8 tecken", 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", 249 "repoExported": "Arkiv exporterat", 250 + "exportFailed": "Kunde inte exportera arkiv", 251 "confirmDelete": "Är du helt säker på att du vill radera ditt konto? Detta kan inte ångras." 252 } 253 }, ··· 393 "manageTrustedDevices": "Hantera betrodda enheter", 394 "appCompatibility": "Appkompatibilitet", 395 "enterPassword": "Ange ditt lösenord", 396 + "sessionExpired": "Sessionen har gått ut. Logga in igen.", 397 "legacyLoginEnabled": "Föråldrad appinloggning aktiverad", 398 "legacyLoginDisabled": "Föråldrad appinloggning inaktiverad - endast OAuth-appar kan logga in", 399 "failedToUpdatePreference": "Kunde inte uppdatera inställning", ··· 453 "noRecords": "Inga poster i denna samling", 454 "recordDetails": "Postdetaljer", 455 "rkey": "Postnyckel", 456 + "uri": "URI", 457 "cid": "CID", 458 "value": "Värde", 459 "deleteRecord": "Radera post", ··· 497 "themeColorsHint": "Lämna tomt för att använda standardfärger.", 498 "primaryLight": "Primär (ljust läge)", 499 "primaryDark": "Primär (mörkt läge)", 500 "configSaved": "Serverkonfiguration sparad", 501 "saving": "Sparar...", 502 "saveConfig": "Spara konfiguration", ··· 538 "deleteConfirm": "Radera konto @{handle}? Detta kan inte ångras.", 539 "verified": "Verifierad", 540 "unverified": "Ej verifierad", 541 + "deactivated": "Inaktiverad", 542 + "colorDefault": "{color} (standard)", 543 + "secondaryLight": "Sekundär (Ljust läge)", 544 + "secondaryDark": "Sekundär (Mörkt läge)" 545 }, 546 "oauth": { 547 "login": { ··· 557 "rememberDevice": "Kom ihåg denna enhet", 558 "passkeyHintChecking": "Kontrollerar nyckelstatus...", 559 "passkeyHintAvailable": "Logga in med din nyckel", 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" 564 }, 565 "consent": { 566 "title": "Auktorisera applikation", ··· 776 "handleNoDots": "Användarnamn kan inte innehålla punkter. Du kan konfigurera ett eget domännamn efter att kontot skapats.", 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.", 778 "passkeyCancelled": "Nyckelskapande avbröts", 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" 833 }, 834 "trustedDevices": { 835 "title": "Betrodda enheter", 836 "backToSecurity": "← Säkerhetsinställningar", 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", 839 "noDevices": "Inga betrodda enheter ännu.", 840 "noDevicesHint": "När du loggar in med tvåfaktorsautentisering aktiverat kan du välja att lita på enheten i 30 dagar.", 841 "lastSeen": "Senast sedd:",
+40 -6
frontend/src/locales/zh.json
··· 30 "lostPasskey": "丢失通行密钥?", 31 "noAccount": "还没有账户?", 32 "createAccount": "立即注册", 33 - "removeAccount": "从已保存账户中移除" 34 }, 35 "verification": { 36 "title": "验证账户", ··· 47 "register": { 48 "title": "创建账户", 49 "subtitle": "在此 PDS 上创建新账户", 50 "migrateTitle": "已有 Bluesky 账户?", 51 "migrateDescription": "您可以将现有账户迁移到此 PDS,而无需创建新账户。您的关注者、帖子和身份都会一起迁移。", 52 "migrateLink": "使用 PDS Moover 迁移", ··· 211 "messages": { 212 "emailCodeSent": "验证码已发送到您的通知渠道", 213 "emailUpdated": "邮箱更新成功", 214 "handleUpdated": "用户名更新成功", 215 "passwordChanged": "密码更改成功", 216 "passwordsMismatch": "两次输入的密码不一致", 217 "passwordLength": "密码至少需要8位字符", 218 "deletionCodeSent": "删除确认码已发送到您的邮箱", 219 "repoExported": "数据导出成功", 220 "confirmDelete": "您确定要删除账户吗?此操作无法撤销。" 221 } 222 }, ··· 362 "manageTrustedDevices": "管理受信任设备", 363 "appCompatibility": "应用兼容性", 364 "enterPassword": "输入您的密码", 365 "legacyLoginEnabled": "已启用传统应用登录", 366 "legacyLoginDisabled": "已禁用传统应用登录 - 仅 OAuth 应用可登录", 367 "failedToUpdatePreference": "更新偏好设置失败", ··· 421 "noRecords": "此集合中暂无记录", 422 "recordDetails": "记录详情", 423 "rkey": "记录键", 424 "cid": "CID", 425 "value": "值", 426 "deleteRecord": "删除记录", ··· 463 "themeColors": "主题颜色", 464 "themeColorsHint": "留空使用默认颜色。", 465 "primaryLight": "主色(浅色模式)", 466 - "primaryLightDefault": "#2c00ff(默认)", 467 "primaryDark": "主色(深色模式)", 468 - "primaryDarkDefault": "#7b6bff(默认)", 469 "secondaryLight": "副色(浅色模式)", 470 - "secondaryLightDefault": "#ff2400(默认)", 471 "secondaryDark": "副色(深色模式)", 472 - "secondaryDarkDefault": "#ff6b5b(默认)", 473 "configSaved": "服务器配置已保存", 474 "saving": "保存中...", 475 "saveConfig": "保存配置", ··· 527 "rememberDevice": "记住此设备", 528 "passkeyHintChecking": "正在检查通行密钥状态...", 529 "passkeyHintAvailable": "使用您的通行密钥登录", 530 - "passkeyHintNotAvailable": "此账户未注册通行密钥" 531 }, 532 "consent": { 533 "title": "授权应用", ··· 785 "title": "受信任设备", 786 "backToSecurity": "← 安全设置", 787 "description": "受信任设备可以跳过双重身份验证。信任有效期为30天,使用设备时自动延长。", 788 "noDevices": "暂无受信任设备", 789 "noDevicesHint": "开启双重身份验证后登录时,可以选择信任设备30天。", 790 "lastSeen": "最后使用:",
··· 30 "lostPasskey": "丢失通行密钥?", 31 "noAccount": "还没有账户?", 32 "createAccount": "立即注册", 33 + "removeAccount": "从已保存账户中移除", 34 + "infoSavedAccountsTitle": "已保存账户", 35 + "infoSavedAccountsDesc": "点击账户即可快速登录。您的会话令牌安全存储在此浏览器中。", 36 + "infoNewAccountTitle": "新账户", 37 + "infoNewAccountDesc": "使用登录按钮添加其他账户。点击 × 可从此浏览器中移除已保存的账户。", 38 + "infoSecureSignInTitle": "安全登录", 39 + "infoSecureSignInDesc": "您将被重定向进行安全认证。如果您启用了通行密钥或双重身份验证,也会提示您进行验证。", 40 + "infoStaySignedInTitle": "保持登录", 41 + "infoStaySignedInDesc": "登录后,您的账户将保存在此浏览器中,方便下次快速访问。", 42 + "infoRecoveryTitle": "账户恢复", 43 + "infoRecoveryDesc": "忘记密码或丢失通行密钥?使用登录按钮下方的恢复链接。" 44 }, 45 "verification": { 46 "title": "验证账户", ··· 57 "register": { 58 "title": "创建账户", 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 应用程序。", 71 "migrateTitle": "已有 Bluesky 账户?", 72 "migrateDescription": "您可以将现有账户迁移到此 PDS,而无需创建新账户。您的关注者、帖子和身份都会一起迁移。", 73 "migrateLink": "使用 PDS Moover 迁移", ··· 232 "messages": { 233 "emailCodeSent": "验证码已发送到您的通知渠道", 234 "emailUpdated": "邮箱更新成功", 235 + "emailUpdateFailed": "邮箱更新失败", 236 "handleUpdated": "用户名更新成功", 237 + "handleUpdateFailed": "用户名更新失败", 238 "passwordChanged": "密码更改成功", 239 + "passwordChangeFailed": "密码更改失败", 240 "passwordsMismatch": "两次输入的密码不一致", 241 + "passwordsDoNotMatch": "两次输入的密码不一致", 242 "passwordLength": "密码至少需要8位字符", 243 + "passwordTooShort": "密码至少需要8位字符", 244 "deletionCodeSent": "删除确认码已发送到您的邮箱", 245 + "deletionConfirmationSent": "删除确认码已发送到您的邮箱", 246 + "deletionRequestFailed": "账户删除请求失败", 247 + "deleteConfirmation": "您确定要删除账户吗?此操作无法撤销。", 248 + "deletionFailed": "账户删除失败", 249 "repoExported": "数据导出成功", 250 + "exportFailed": "数据导出失败", 251 "confirmDelete": "您确定要删除账户吗?此操作无法撤销。" 252 } 253 }, ··· 393 "manageTrustedDevices": "管理受信任设备", 394 "appCompatibility": "应用兼容性", 395 "enterPassword": "输入您的密码", 396 + "sessionExpired": "会话已过期,请重新登录。", 397 "legacyLoginEnabled": "已启用传统应用登录", 398 "legacyLoginDisabled": "已禁用传统应用登录 - 仅 OAuth 应用可登录", 399 "failedToUpdatePreference": "更新偏好设置失败", ··· 453 "noRecords": "此集合中暂无记录", 454 "recordDetails": "记录详情", 455 "rkey": "记录键", 456 + "uri": "URI", 457 "cid": "CID", 458 "value": "值", 459 "deleteRecord": "删除记录", ··· 496 "themeColors": "主题颜色", 497 "themeColorsHint": "留空使用默认颜色。", 498 "primaryLight": "主色(浅色模式)", 499 + "colorDefault": "{color}(默认)", 500 "primaryDark": "主色(深色模式)", 501 "secondaryLight": "副色(浅色模式)", 502 "secondaryDark": "副色(深色模式)", 503 "configSaved": "服务器配置已保存", 504 "saving": "保存中...", 505 "saveConfig": "保存配置", ··· 557 "rememberDevice": "记住此设备", 558 "passkeyHintChecking": "正在检查通行密钥状态...", 559 "passkeyHintAvailable": "使用您的通行密钥登录", 560 + "passkeyHintNotAvailable": "此账户未注册通行密钥", 561 + "passkeyHint": "使用设备的生物识别或安全密钥", 562 + "passwordPlaceholder": "输入您的密码", 563 + "usePasskey": "使用通行密钥" 564 }, 565 "consent": { 566 "title": "授权应用", ··· 818 "title": "受信任设备", 819 "backToSecurity": "← 安全设置", 820 "description": "受信任设备可以跳过双重身份验证。信任有效期为30天,使用设备时自动延长。", 821 + "failedToLoad": "加载受信任设备失败", 822 "noDevices": "暂无受信任设备", 823 "noDevicesHint": "开启双重身份验证后登录时,可以选择信任设备30天。", 824 "lastSeen": "最后使用:",
+6 -6
frontend/src/main.ts
··· 1 - import './styles/base.css' 2 - import App from './App.svelte' 3 - import { mount } from 'svelte' 4 5 const app = mount(App, { 6 - target: document.getElementById('app')!, 7 - }) 8 9 - export default app
··· 1 + import "./styles/base.css"; 2 + import App from "./App.svelte"; 3 + import { mount } from "svelte"; 4 5 const app = mount(App, { 6 + target: document.getElementById("app")!, 7 + }); 8 9 + export default app;
+11 -5
frontend/src/routes/Admin.svelte
··· 6 import { _ } from '../lib/i18n' 7 import { formatDate, formatDateTime } from '../lib/date' 8 const auth = getAuthState() 9 let loading = $state(true) 10 let error = $state<string | null>(null) 11 let stats = $state<{ ··· 364 type="text" 365 id="primaryColor" 366 bind:value={primaryColorInput} 367 - placeholder={$_('admin.primaryLightDefault')} 368 disabled={serverConfigLoading} 369 /> 370 </div> ··· 381 type="text" 382 id="primaryColorDark" 383 bind:value={primaryColorDarkInput} 384 - placeholder={$_('admin.primaryDarkDefault')} 385 disabled={serverConfigLoading} 386 /> 387 </div> ··· 398 type="text" 399 id="secondaryColor" 400 bind:value={secondaryColorInput} 401 - placeholder={$_('admin.secondaryLightDefault')} 402 disabled={serverConfigLoading} 403 /> 404 </div> ··· 415 type="text" 416 id="secondaryColorDark" 417 bind:value={secondaryColorDarkInput} 418 - placeholder={$_('admin.secondaryDarkDefault')} 419 disabled={serverConfigLoading} 420 /> 421 </div> ··· 646 {/if} 647 <style> 648 .page { 649 - max-width: var(--width-lg); 650 margin: 0 auto; 651 padding: var(--space-7); 652 }
··· 6 import { _ } from '../lib/i18n' 7 import { formatDate, formatDateTime } from '../lib/date' 8 const auth = getAuthState() 9 + const DEFAULT_COLORS = { 10 + primaryLight: '#1A1D1D', 11 + primaryDark: '#E6E8E8', 12 + secondaryLight: '#1A1D1D', 13 + secondaryDark: '#E6E8E8', 14 + } 15 let loading = $state(true) 16 let error = $state<string | null>(null) 17 let stats = $state<{ ··· 370 type="text" 371 id="primaryColor" 372 bind:value={primaryColorInput} 373 + placeholder={$_('admin.colorDefault', { values: { color: DEFAULT_COLORS.primaryLight } })} 374 disabled={serverConfigLoading} 375 /> 376 </div> ··· 387 type="text" 388 id="primaryColorDark" 389 bind:value={primaryColorDarkInput} 390 + placeholder={$_('admin.colorDefault', { values: { color: DEFAULT_COLORS.primaryDark } })} 391 disabled={serverConfigLoading} 392 /> 393 </div> ··· 404 type="text" 405 id="secondaryColor" 406 bind:value={secondaryColorInput} 407 + placeholder={$_('admin.colorDefault', { values: { color: DEFAULT_COLORS.secondaryLight } })} 408 disabled={serverConfigLoading} 409 /> 410 </div> ··· 421 type="text" 422 id="secondaryColorDark" 423 bind:value={secondaryColorDarkInput} 424 + placeholder={$_('admin.colorDefault', { values: { color: DEFAULT_COLORS.secondaryDark } })} 425 disabled={serverConfigLoading} 426 /> 427 </div> ··· 652 {/if} 653 <style> 654 .page { 655 + max-width: var(--width-xl); 656 margin: 0 auto; 657 padding: var(--space-7); 658 }
+1 -1
frontend/src/routes/AppPasswords.svelte
··· 156 </div> 157 <style> 158 .page { 159 - max-width: var(--width-md); 160 margin: 0 auto; 161 padding: var(--space-7); 162 }
··· 156 </div> 157 <style> 158 .page { 159 + max-width: var(--width-lg); 160 margin: 0 auto; 161 padding: var(--space-7); 162 }
+274 -216
frontend/src/routes/Comms.svelte
··· 22 let verificationCode = $state('') 23 let verificationError = $state<string | null>(null) 24 let verificationSuccess = $state<string | null>(null) 25 - let historyLoading = $state(false) 26 let historyError = $state<string | null>(null) 27 let messages = $state<Array<{ 28 createdAt: string ··· 32 subject: string | null 33 body: string 34 }>>([]) 35 - let showHistory = $state(false) 36 $effect(() => { 37 if (!auth.loading && !auth.session) { 38 navigate('/login') ··· 41 $effect(() => { 42 if (auth.session) { 43 loadPrefs() 44 } 45 }) 46 async function loadPrefs() { ··· 120 try { 121 const result = await api.getNotificationHistory(auth.session.accessJwt) 122 messages = result.notifications 123 - showHistory = true 124 } catch (e) { 125 historyError = e instanceof ApiError ? e.message : 'Failed to load notification history' 126 } finally { ··· 171 <header> 172 <a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a> 173 <h1>{$_('comms.title')}</h1> 174 </header> 175 - <p class="description"> 176 - {$_('comms.description')} 177 - </p> 178 {#if loading} 179 <p class="loading">{$_('common.loading')}</p> 180 {:else} ··· 184 {#if success} 185 <div class="message success">{success}</div> 186 {/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} 211 </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> 251 {/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 </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> 286 {/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 </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> 321 {/if} 322 - {/if} 323 </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> 336 {/if} 337 </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> 350 </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> 373 </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> 385 {/if} 386 </div> 387 <style> 388 .page { 389 - max-width: var(--width-md); 390 margin: 0 auto; 391 padding: var(--space-7); 392 } 393 394 header { 395 - margin-bottom: var(--space-4); 396 } 397 398 .back { ··· 411 412 .description { 413 color: var(--text-secondary); 414 - margin-bottom: var(--space-7); 415 } 416 417 .loading { ··· 420 padding: var(--space-7); 421 } 422 423 section { 424 background: var(--bg-secondary); 425 padding: var(--space-6); 426 border-radius: var(--radius-xl); 427 margin-bottom: var(--space-6); 428 } 429 430 section h2 { ··· 520 opacity: 0.6; 521 } 522 523 .config-item label { 524 font-size: var(--text-sm); 525 font-weight: var(--font-medium); ··· 533 534 .config-input input { 535 flex: 1; 536 } 537 538 - .config-input input.readonly { 539 background: var(--bg-input-disabled); 540 color: var(--text-secondary); 541 } ··· 624 background: var(--bg-secondary); 625 } 626 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 .history-section h2 { 635 margin: 0 0 var(--space-2) 0; 636 font-size: var(--text-lg); 637 } 638 639 - .load-history { 640 - padding: var(--space-2) var(--space-4); 641 - background: transparent; 642 border: 1px solid var(--border-color); 643 border-radius: var(--radius-md); 644 - cursor: pointer; 645 - color: var(--text-primary); 646 - margin-top: var(--space-2); 647 } 648 649 - .load-history:hover:not(:disabled) { 650 - background: var(--bg-card); 651 - border-color: var(--accent); 652 } 653 654 - .load-history:disabled { 655 - opacity: 0.6; 656 - cursor: not-allowed; 657 } 658 659 .no-messages {
··· 22 let verificationCode = $state('') 23 let verificationError = $state<string | null>(null) 24 let verificationSuccess = $state<string | null>(null) 25 + let historyLoading = $state(true) 26 let historyError = $state<string | null>(null) 27 let messages = $state<Array<{ 28 createdAt: string ··· 32 subject: string | null 33 body: string 34 }>>([]) 35 $effect(() => { 36 if (!auth.loading && !auth.session) { 37 navigate('/login') ··· 40 $effect(() => { 41 if (auth.session) { 42 loadPrefs() 43 + loadHistory() 44 } 45 }) 46 async function loadPrefs() { ··· 120 try { 121 const result = await api.getNotificationHistory(auth.session.accessJwt) 122 messages = result.notifications 123 } catch (e) { 124 historyError = e instanceof ApiError ? e.message : 'Failed to load notification history' 125 } finally { ··· 170 <header> 171 <a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a> 172 <h1>{$_('comms.title')}</h1> 173 + <p class="description">{$_('comms.description')}</p> 174 </header> 175 + 176 {#if loading} 177 <p class="loading">{$_('common.loading')}</p> 178 {:else} ··· 182 {#if success} 183 <div class="message success">{success}</div> 184 {/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> 226 </div> 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> 260 {/if} 261 </div> 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> 295 {/if} 296 </div> 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> 330 {/if} 331 + </div> 332 </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> 339 {/if} 340 + </section> 341 + 342 + <div class="actions"> 343 + <button type="submit" disabled={saving}> 344 + {saving ? $_('comms.saving') : $_('comms.savePreferences')} 345 + </button> 346 </div> 347 + </form> 348 </div> 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> 385 </div> 386 + {/each} 387 + </div> 388 + {/if} 389 + </section> 390 + </div> 391 + </div> 392 {/if} 393 </div> 394 <style> 395 .page { 396 + max-width: var(--width-xl); 397 margin: 0 auto; 398 padding: var(--space-7); 399 } 400 401 header { 402 + margin-bottom: var(--space-6); 403 } 404 405 .back { ··· 418 419 .description { 420 color: var(--text-secondary); 421 + margin: var(--space-2) 0 0 0; 422 } 423 424 .loading { ··· 427 padding: var(--space-7); 428 } 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 + 447 section { 448 background: var(--bg-secondary); 449 padding: var(--space-6); 450 border-radius: var(--radius-xl); 451 margin-bottom: var(--space-6); 452 + } 453 + 454 + .side-column section { 455 + margin-bottom: 0; 456 } 457 458 section h2 { ··· 548 opacity: 0.6; 549 } 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 + 559 .config-item label { 560 font-size: var(--text-sm); 561 font-weight: var(--font-medium); ··· 569 570 .config-input input { 571 flex: 1; 572 + min-width: 0; 573 } 574 575 + .config-item input.readonly { 576 background: var(--bg-input-disabled); 577 color: var(--text-secondary); 578 } ··· 661 background: var(--bg-secondary); 662 } 663 664 .history-section h2 { 665 margin: 0 0 var(--space-2) 0; 666 font-size: var(--text-lg); 667 } 668 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); 677 border: 1px solid var(--border-color); 678 border-radius: var(--radius-md); 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%; 705 } 706 707 + .skeleton-line:not(.short):not(.tiny):not(.medium) { 708 + width: 100%; 709 + margin-bottom: var(--space-1); 710 } 711 712 + @keyframes skeleton-pulse { 713 + 0%, 100% { opacity: 1; } 714 + 50% { opacity: 0.4; } 715 } 716 717 .no-messages {
+19 -5
frontend/src/routes/Dashboard.svelte
··· 2 import { getAuthState, logout, switchAccount } from '../lib/auth.svelte' 3 import { navigate } from '../lib/router.svelte' 4 import { _ } from '../lib/i18n' 5 6 const auth = getAuthState() 7 let dropdownOpen = $state(false) 8 let switching = $state(false) 9 10 $effect(() => { 11 if (!auth.loading && !auth.session) { ··· 152 <h3>{$_('dashboard.navSessions')}</h3> 153 <p>{$_('dashboard.navSessionsDesc')}</p> 154 </a> 155 - <a href="#/invite-codes" class="nav-card"> 156 - <h3>{$_('dashboard.navInviteCodes')}</h3> 157 - <p>{$_('dashboard.navInviteCodesDesc')}</p> 158 - </a> 159 <a href="#/settings" class="nav-card"> 160 <h3>{$_('dashboard.navSettings')}</h3> 161 <p>{$_('dashboard.navSettingsDesc')}</p> ··· 186 187 <style> 188 .dashboard { 189 - max-width: var(--width-lg); 190 margin: 0 auto; 191 padding: var(--space-7); 192 }
··· 2 import { getAuthState, logout, switchAccount } from '../lib/auth.svelte' 3 import { navigate } from '../lib/router.svelte' 4 import { _ } from '../lib/i18n' 5 + import { api } from '../lib/api' 6 + import { onMount } from 'svelte' 7 8 const auth = getAuthState() 9 let dropdownOpen = $state(false) 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 + }) 21 22 $effect(() => { 23 if (!auth.loading && !auth.session) { ··· 164 <h3>{$_('dashboard.navSessions')}</h3> 165 <p>{$_('dashboard.navSessionsDesc')}</p> 166 </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} 173 <a href="#/settings" class="nav-card"> 174 <h3>{$_('dashboard.navSettings')}</h3> 175 <p>{$_('dashboard.navSettingsDesc')}</p> ··· 200 201 <style> 202 .dashboard { 203 + max-width: var(--width-xl); 204 margin: 0 auto; 205 padding: var(--space-7); 206 }
+57 -3
frontend/src/routes/Home.svelte
··· 13 let pdsVersion = $state<string | null>(null) 14 let userCount = $state<number | null>(null) 15 16 onMount(() => { 17 api.describeServer().then(info => { 18 if (info.availableUserDomains?.length) { ··· 23 } 24 }).catch(() => {}) 25 26 api.listRepos(1000).then(data => { 27 userCount = data.repos.length 28 }).catch(() => {}) ··· 75 return () => { 76 document.removeEventListener('mousemove', handleMouseMove) 77 cancelAnimationFrame(animationId) 78 } 79 }) 80 </script> ··· 103 104 <div class="home"> 105 <section class="hero"> 106 - <h1>A home for your ATProto account</h1> 107 108 <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 ··· 268 269 .user-count { 270 font-size: var(--text-sm); 271 - color: rgba(255, 255, 255, 0.85); 272 padding: 4px 10px; 273 background: rgba(255, 255, 255, 0.15); 274 border-radius: var(--radius-md); 275 } 276 277 .nav-meta { 278 font-size: var(--text-sm); 279 - color: rgba(255, 255, 255, 0.7); 280 letter-spacing: 0.05em; 281 } 282 ··· 300 line-height: var(--leading-tight); 301 margin-bottom: var(--space-6); 302 letter-spacing: -0.02em; 303 } 304 305 .lede { ··· 439 text-align: center; 440 } 441 442 .nav-meta { 443 display: none; 444 }
··· 13 let pdsVersion = $state<string | null>(null) 14 let userCount = $state<number | null>(null) 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 + 28 onMount(() => { 29 api.describeServer().then(info => { 30 if (info.availableUserDomains?.length) { ··· 35 } 36 }).catch(() => {}) 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 + 53 api.listRepos(1000).then(data => { 54 userCount = data.repos.length 55 }).catch(() => {}) ··· 102 return () => { 103 document.removeEventListener('mousemove', handleMouseMove) 104 cancelAnimationFrame(animationId) 105 + clearTimeout(wordTimeout) 106 } 107 }) 108 </script> ··· 131 132 <div class="home"> 133 <section class="hero"> 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> 135 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> 137 ··· 296 297 .user-count { 298 font-size: var(--text-sm); 299 + color: var(--text-inverse); 300 + opacity: 0.85; 301 padding: 4px 10px; 302 background: rgba(255, 255, 255, 0.15); 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 + } 311 } 312 313 .nav-meta { 314 font-size: var(--text-sm); 315 + color: var(--text-inverse); 316 + opacity: 0.6; 317 letter-spacing: 0.05em; 318 } 319 ··· 337 line-height: var(--leading-tight); 338 margin-bottom: var(--space-6); 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); 356 } 357 358 .lede { ··· 492 text-align: center; 493 } 494 495 + .user-count, 496 .nav-meta { 497 display: none; 498 }
+18 -2
frontend/src/routes/InviteCodes.svelte
··· 4 import { api, type InviteCode, ApiError } from '../lib/api' 5 import { _ } from '../lib/i18n' 6 import { formatDate } from '../lib/date' 7 const auth = getAuthState() 8 let codes = $state<InviteCode[]>([]) 9 let loading = $state(true) 10 let error = $state<string | null>(null) 11 let creating = $state(false) 12 let createdCode = $state<string | null>(null) 13 $effect(() => { 14 if (!auth.loading && !auth.session) { 15 navigate('/login') 16 } 17 }) 18 $effect(() => { 19 - if (auth.session) { 20 loadCodes() 21 } 22 }) ··· 114 </div> 115 <style> 116 .page { 117 - max-width: var(--width-md); 118 margin: 0 auto; 119 padding: var(--space-7); 120 }
··· 4 import { api, type InviteCode, ApiError } from '../lib/api' 5 import { _ } from '../lib/i18n' 6 import { formatDate } from '../lib/date' 7 + import { onMount } from 'svelte' 8 + 9 const auth = getAuthState() 10 let codes = $state<InviteCode[]>([]) 11 let loading = $state(true) 12 let error = $state<string | null>(null) 13 let creating = $state(false) 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 + 29 $effect(() => { 30 if (!auth.loading && !auth.session) { 31 navigate('/login') 32 } 33 }) 34 $effect(() => { 35 + if (auth.session && inviteCodesEnabled) { 36 loadCodes() 37 } 38 }) ··· 130 </div> 131 <style> 132 .page { 133 + max-width: var(--width-lg); 134 margin: 0 auto; 135 padding: var(--space-7); 136 }
+105 -68
frontend/src/routes/Login.svelte
··· 8 let verificationCode = $state('') 9 let resendingCode = $state(false) 10 let resendMessage = $state<string | null>(null) 11 - let showNewLogin = $state(false) 12 const auth = getAuthState() 13 14 async function handleSwitchAccount(did: string) { 15 submitting = true ··· 74 {/if} 75 76 {#if pendingVerification} 77 - <h1>{$_('verification.title')}</h1> 78 - <p class="subtitle">{$_('verification.subtitle')}</p> 79 80 {#if resendMessage} 81 <div class="message success">{resendMessage}</div> ··· 109 </div> 110 </form> 111 112 - {:else if auth.savedAccounts.length > 0 && !showNewLogin} 113 - <h1>{$_('login.title')}</h1> 114 - <p class="subtitle">{$_('login.chooseAccount')}</p> 115 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> 129 </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> 141 142 - <button type="button" class="secondary full-width" onclick={() => showNewLogin = true}> 143 - {$_('login.signInToAnother')} 144 - </button> 145 146 - <p class="link-text"> 147 - {$_('login.noAccount')} <a href="#/register">{$_('login.createAccount')}</a> 148 - </p> 149 150 - {:else} 151 - <h1>{$_('login.title')}</h1> 152 - <p class="subtitle">{$_('login.subtitle')}</p> 153 154 - {#if auth.savedAccounts.length > 0} 155 - <button type="button" class="tertiary back-btn" onclick={() => showNewLogin = false}> 156 - {$_('login.backToSaved')} 157 - </button> 158 - {/if} 159 160 - <button type="button" class="oauth-btn" onclick={handleOAuthLogin} disabled={submitting || auth.loading}> 161 - {submitting ? $_('login.redirecting') : $_('login.button')} 162 - </button> 163 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> 169 170 - <p class="link-text"> 171 - {$_('login.noAccount')} <a href="#/register">{$_('login.createAccount')}</a> 172 - </p> 173 {/if} 174 </div> 175 176 <style> 177 .login-page { 178 - max-width: var(--width-sm); 179 margin: var(--space-9) auto; 180 padding: var(--space-7); 181 } 182 183 h1 { ··· 186 187 .subtitle { 188 color: var(--text-secondary); 189 - margin: 0 0 var(--space-7) 0; 190 } 191 192 form { 193 display: flex; 194 flex-direction: column; 195 gap: var(--space-4); 196 } 197 198 .actions { ··· 202 margin-top: var(--space-3); 203 } 204 205 .oauth-btn { 206 width: 100%; 207 padding: var(--space-5); ··· 209 } 210 211 .forgot-links { 212 - text-align: center; 213 - margin-top: var(--space-5); 214 color: var(--text-secondary); 215 } 216 ··· 223 } 224 225 .link-text { 226 - text-align: center; 227 - margin-top: var(--space-4); 228 color: var(--text-secondary); 229 } 230 ··· 297 color: var(--error-text); 298 } 299 300 - .full-width { 301 - width: 100%; 302 - } 303 - 304 - .back-btn { 305 - margin-bottom: var(--space-5); 306 - padding: 0; 307 } 308 </style>
··· 8 let verificationCode = $state('') 9 let resendingCode = $state(false) 10 let resendMessage = $state<string | null>(null) 11 + let autoRedirectAttempted = $state(false) 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 + }) 20 21 async function handleSwitchAccount(did: string) { 22 submitting = true ··· 81 {/if} 82 83 {#if pendingVerification} 84 + <header class="page-header"> 85 + <h1>{$_('verification.title')}</h1> 86 + <p class="subtitle">{$_('verification.subtitle')}</p> 87 + </header> 88 89 {#if resendMessage} 90 <div class="message success">{resendMessage}</div> ··· 118 </div> 119 </form> 120 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> 126 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} 154 </div> 155 + 156 + <p class="or-divider">{$_('login.signInToAnother')}</p> 157 + {/if} 158 159 + <button type="button" class="oauth-btn" onclick={handleOAuthLogin} disabled={submitting || auth.loading}> 160 + {submitting ? $_('login.redirecting') : $_('login.button')} 161 + </button> 162 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> 168 169 + <p class="link-text"> 170 + {$_('login.noAccount')} <a href="#/register">{$_('login.createAccount')}</a> 171 + </p> 172 + </div> 173 174 + <aside class="info-panel"> 175 + {#if auth.savedAccounts.length > 0} 176 + <h3>{$_('login.infoSavedAccountsTitle')}</h3> 177 + <p>{$_('login.infoSavedAccountsDesc')}</p> 178 179 + <h3>{$_('login.infoNewAccountTitle')}</h3> 180 + <p>{$_('login.infoNewAccountDesc')}</p> 181 + {:else} 182 + <h3>{$_('login.infoSecureSignInTitle')}</h3> 183 + <p>{$_('login.infoSecureSignInDesc')}</p> 184 185 + <h3>{$_('login.infoStaySignedInTitle')}</h3> 186 + <p>{$_('login.infoStaySignedInDesc')}</p> 187 + {/if} 188 189 + <h3>{$_('login.infoRecoveryTitle')}</h3> 190 + <p>{$_('login.infoRecoveryDesc')}</p> 191 + </aside> 192 + </div> 193 {/if} 194 </div> 195 196 <style> 197 .login-page { 198 + max-width: var(--width-lg); 199 margin: var(--space-9) auto; 200 padding: var(--space-7); 201 + } 202 + 203 + .page-header { 204 + margin-bottom: var(--space-6); 205 } 206 207 h1 { ··· 210 211 .subtitle { 212 color: var(--text-secondary); 213 + margin: 0; 214 + } 215 + 216 + .main-section { 217 + min-width: 0; 218 } 219 220 form { 221 display: flex; 222 flex-direction: column; 223 gap: var(--space-4); 224 + max-width: var(--width-sm); 225 } 226 227 .actions { ··· 231 margin-top: var(--space-3); 232 } 233 234 + @media (min-width: 600px) { 235 + .actions { 236 + flex-direction: row; 237 + } 238 + 239 + .actions button { 240 + flex: 1; 241 + } 242 + } 243 + 244 .oauth-btn { 245 width: 100%; 246 padding: var(--space-5); ··· 248 } 249 250 .forgot-links { 251 + margin-top: var(--space-4); 252 + font-size: var(--text-sm); 253 color: var(--text-secondary); 254 } 255 ··· 262 } 263 264 .link-text { 265 + margin-top: var(--space-6); 266 + font-size: var(--text-sm); 267 color: var(--text-secondary); 268 } 269 ··· 336 color: var(--error-text); 337 } 338 339 + .or-divider { 340 + text-align: center; 341 + color: var(--text-muted); 342 + font-size: var(--text-sm); 343 + margin: var(--space-5) 0; 344 } 345 </style>
+80 -46
frontend/src/routes/OAuthConsent.svelte
··· 167 </button> 168 </div> 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> 182 183 - <div class="account-info"> 184 - <span class="label">{$_('oauth.consent.signingInAs')}</span> 185 - <span class="did">{consentData.did}</span> 186 - </div> 187 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> 209 {/each} 210 </div> 211 - {/each} 212 - </div> 213 214 - <label class="remember-choice"> 215 - <input type="checkbox" bind:checked={rememberChoice} disabled={submitting} /> 216 - <span>{$_('oauth.consent.rememberChoiceLabel')}</span> 217 - </label> 218 219 <div class="actions"> 220 <button type="button" class="deny-btn" onclick={handleDeny} disabled={submitting}> ··· 229 230 <style> 231 .consent-container { 232 - max-width: 480px; 233 margin: var(--space-7) auto; 234 padding: var(--space-7); 235 } ··· 244 245 .error-container { 246 text-align: center; 247 } 248 249 .error { ··· 255 margin-bottom: var(--space-4); 256 } 257 258 .client-info { 259 text-align: center; 260 - margin-bottom: var(--space-6); 261 } 262 263 .client-logo { ··· 397 display: flex; 398 align-items: center; 399 gap: var(--space-2); 400 - margin-bottom: var(--space-6); 401 cursor: pointer; 402 color: var(--text-secondary); 403 font-size: var(--text-sm); ··· 411 .actions { 412 display: flex; 413 gap: var(--space-4); 414 } 415 416 .actions button {
··· 167 </button> 168 </div> 169 {:else if consentData} 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> 184 185 + <div class="account-info"> 186 + <span class="label">{$_('oauth.consent.signingInAs')}</span> 187 + <span class="did">{consentData.did}</span> 188 + </div> 189 + </div> 190 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> 215 {/each} 216 </div> 217 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> 224 225 <div class="actions"> 226 <button type="button" class="deny-btn" onclick={handleDeny} disabled={submitting}> ··· 235 236 <style> 237 .consent-container { 238 + max-width: var(--width-lg); 239 margin: var(--space-7) auto; 240 padding: var(--space-7); 241 } ··· 250 251 .error-container { 252 text-align: center; 253 + max-width: var(--width-sm); 254 + margin: 0 auto; 255 } 256 257 .error { ··· 263 margin-bottom: var(--space-4); 264 } 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 + 276 .client-info { 277 text-align: center; 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 + } 287 } 288 289 .client-logo { ··· 423 display: flex; 424 align-items: center; 425 gap: var(--space-2); 426 + margin-top: var(--space-5); 427 cursor: pointer; 428 color: var(--text-secondary); 429 font-size: var(--text-sm); ··· 437 .actions { 438 display: flex; 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 + } 448 } 449 450 .actions button {
+187 -80
frontend/src/routes/OAuthLogin.svelte
··· 315 </script> 316 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> 326 327 {#if error} 328 <div class="error">{error}</div> ··· 343 </div> 344 345 {#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> 371 372 - <div class="auth-divider"> 373 - <span>{$_('oauth.login.orUsePassword')}</span> 374 </div> 375 - {/if} 376 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> 388 389 - <label class="remember-device"> 390 - <input type="checkbox" bind:checked={rememberDevice} disabled={submitting} /> 391 - <span>{$_('oauth.login.rememberDevice')}</span> 392 - </label> 393 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> 402 </form> 403 404 <p class="help-links"> ··· 423 } 424 425 .oauth-login-container { 426 - max-width: var(--width-sm); 427 margin: var(--space-9) auto; 428 padding: var(--space-7); 429 } 430 431 h1 { 432 margin: 0 0 var(--space-2) 0; 433 } 434 435 .subtitle { 436 color: var(--text-secondary); 437 - margin: 0 0 var(--space-7) 0; 438 } 439 440 form { ··· 443 gap: var(--space-4); 444 } 445 446 .field { 447 display: flex; 448 flex-direction: column; ··· 534 background: var(--accent-hover); 535 } 536 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 557 .passkey-btn { 558 display: flex;
··· 315 </script> 316 317 <div class="oauth-login-container"> 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> 328 329 {#if error} 330 <div class="error">{error}</div> ··· 345 </div> 346 347 {#if passkeySupported && username.length >= 3} 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> 378 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> 406 </div> 407 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> 425 426 + <label class="remember-device"> 427 + <input type="checkbox" bind:checked={rememberDevice} disabled={submitting} /> 428 + <span>{$_('oauth.login.rememberDevice')}</span> 429 + </label> 430 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} 440 </form> 441 442 <p class="help-links"> ··· 461 } 462 463 .oauth-login-container { 464 + max-width: var(--width-md); 465 margin: var(--space-9) auto; 466 padding: var(--space-7); 467 } 468 469 + .page-header { 470 + margin-bottom: var(--space-6); 471 + } 472 + 473 h1 { 474 margin: 0 0 var(--space-2) 0; 475 } 476 477 .subtitle { 478 color: var(--text-secondary); 479 + margin: 0; 480 } 481 482 form { ··· 485 gap: var(--space-4); 486 } 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 + 572 .field { 573 display: flex; 574 flex-direction: column; ··· 660 background: var(--accent-hover); 661 } 662 663 664 .passkey-btn { 665 display: flex;
+233 -215
frontend/src/routes/Register.svelte
··· 142 if (!flow) return '' 143 switch (flow.state.step) { 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.' 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!' 152 default: return '' 153 } 154 } 155 </script> 156 157 <div class="register-page"> 158 - {#if flow?.state.step === 'info'} 159 <div class="migrate-callout"> 160 <div class="migrate-icon">↗</div> 161 <div class="migrate-content"> ··· 166 </a> 167 </div> 168 </div> 169 - {/if} 170 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} 177 178 - {#if loadingServerInfo || !flow} 179 - <p class="loading">{$_('common.loading')}</p> 180 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> 199 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> 212 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> 224 225 - <fieldset class="section-fieldset"> 226 - <legend>{$_('register.identityType')}</legend> 227 - <p class="section-hint">{$_('register.identityHint')}</p> 228 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> 237 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> 245 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> 254 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} 266 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> 282 283 - <fieldset class="section-fieldset"> 284 - <legend>{$_('register.contactMethod')}</legend> 285 - <p class="section-hint">{$_('register.contactMethodHint')}</p> 286 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> 301 </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> 355 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} 369 370 - <button type="submit" disabled={flow.state.submitting}> 371 - {flow.state.submitting ? $_('register.creating') : $_('register.createButton')} 372 - </button> 373 - </form> 374 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> 381 382 {:else if flow.state.step === 'key-choice'} 383 <KeyChoiceStep {flow} /> ··· 404 /> 405 406 {:else if flow.state.step === 'redirect-to-dashboard'} 407 - <p class="loading">Redirecting to dashboard...</p> 408 {/if} 409 </div> 410 411 <style> 412 .register-page { 413 - max-width: var(--width-sm); 414 margin: var(--space-9) auto; 415 padding: var(--space-7); 416 } 417 418 .migrate-callout { ··· 481 482 .required { 483 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 } 496 497 .section-hint {
··· 142 if (!flow) return '' 143 switch (flow.state.step) { 144 case 'info': return $_('register.subtitle') 145 + case 'key-choice': return $_('register.subtitleKeyChoice') 146 + case 'initial-did-doc': return $_('register.subtitleInitialDidDoc') 147 case 'creating': return $_('register.creating') 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 default: return '' 153 } 154 } 155 </script> 156 157 <div class="register-page"> 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'} 171 <div class="migrate-callout"> 172 <div class="migrate-icon">↗</div> 173 <div class="migrate-content"> ··· 178 </a> 179 </div> 180 </div> 181 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> 201 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> 215 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> 228 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> 239 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> 247 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> 256 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} 268 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> 284 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> 303 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> 357 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} 371 372 + <button type="submit" disabled={flow.state.submitting}> 373 + {flow.state.submitting ? $_('register.creating') : $_('register.createButton')} 374 + </button> 375 + </form> 376 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> 384 </div> 385 + </div> 386 387 + <aside class="info-panel"> 388 + <h3>{$_('register.identityHint')}</h3> 389 + <p>{$_('register.infoIdentityDesc')}</p> 390 391 + <h3>{$_('register.contactMethodHint')}</h3> 392 + <p>{$_('register.infoContactDesc')}</p> 393 394 + <h3>{$_('register.infoNextTitle')}</h3> 395 + <p>{$_('register.infoNextDesc')}</p> 396 + </aside> 397 + </div> 398 399 {:else if flow.state.step === 'key-choice'} 400 <KeyChoiceStep {flow} /> ··· 421 /> 422 423 {:else if flow.state.step === 'redirect-to-dashboard'} 424 + <p class="loading">{$_('register.redirecting')}</p> 425 {/if} 426 </div> 427 428 <style> 429 .register-page { 430 + max-width: var(--width-lg); 431 margin: var(--space-9) auto; 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); 445 } 446 447 .migrate-callout { ··· 510 511 .required { 512 color: var(--error-text); 513 } 514 515 .section-hint {
+1 -12
frontend/src/routes/RegisterPasskey.svelte
··· 369 <div class="warning-box"> 370 <strong>{$_('registerPasskey.didWebWarningTitle')}</strong> 371 <ul> 372 - <li><strong>{$_('registerPasskey.didWebWarning1')}</strong> Your identity will be <code>did:web:yourhandle.{serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>.</li> 373 <li><strong>{$_('registerPasskey.didWebWarning2')}</strong> {$_('registerPasskey.didWebWarning2Detail')}</li> 374 <li><strong>{$_('registerPasskey.didWebWarning3')}</strong> {$_('registerPasskey.didWebWarning3Detail')}</li> 375 <li><strong>{$_('registerPasskey.didWebWarning4')}</strong> {$_('registerPasskey.didWebWarning4Detail')}</li> ··· 542 543 .required { 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 } 557 558 .section-hint {
··· 369 <div class="warning-box"> 370 <strong>{$_('registerPasskey.didWebWarningTitle')}</strong> 371 <ul> 372 + <li><strong>{$_('registerPasskey.didWebWarning1')}</strong> {@html $_('registerPasskey.didWebWarning1Detail', { values: { did: `<code>did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>` } })}</li> 373 <li><strong>{$_('registerPasskey.didWebWarning2')}</strong> {$_('registerPasskey.didWebWarning2Detail')}</li> 374 <li><strong>{$_('registerPasskey.didWebWarning3')}</strong> {$_('registerPasskey.didWebWarning3Detail')}</li> 375 <li><strong>{$_('registerPasskey.didWebWarning4')}</strong> {$_('registerPasskey.didWebWarning4Detail')}</li> ··· 542 543 .required { 544 color: var(--error-text); 545 } 546 547 .section-hint {
+59 -18
frontend/src/routes/RepoExplorer.svelte
··· 75 } 76 } 77 async function loadMoreRecords() { 78 - if (!auth.session || !selectedCollection || !recordsCursor) return 79 loadingMore = true 80 try { 81 const result = await api.listRecords(auth.session.accessJwt, auth.session.did, selectedCollection, { ··· 93 loadingMore = false 94 } 95 } 96 async function selectRecord(record: { uri: string; cid: string; value: unknown; rkey: string }) { 97 selectedRecord = record 98 recordJson = JSON.stringify(record.value, null, 2) ··· 371 </li> 372 {/each} 373 </ul> 374 - {#if recordsCursor} 375 - <div class="load-more"> 376 - <button onclick={loadMoreRecords} disabled={loadingMore}> 377 - {loadingMore ? $_('common.loading') : $_('repoExplorer.loadMore')} 378 - </button> 379 </div> 380 {/if} 381 {/if} ··· 383 <div class="record-detail"> 384 <div class="record-meta"> 385 <dl> 386 - <dt>URI</dt> 387 <dd class="mono">{selectedRecord.uri}</dd> 388 - <dt>CID</dt> 389 <dd class="mono">{selectedRecord.cid}</dd> 390 </dl> 391 </div> ··· 463 </div> 464 <style> 465 .page { 466 - max-width: var(--width-lg); 467 margin: 0 auto; 468 padding: var(--space-7); 469 } ··· 751 overflow: hidden; 752 } 753 754 - .load-more { 755 - text-align: center; 756 padding: var(--space-4); 757 } 758 759 - .load-more button { 760 - padding: var(--space-2) var(--space-7); 761 background: var(--bg-secondary); 762 - border: 1px solid var(--border-color); 763 border-radius: var(--radius-md); 764 - cursor: pointer; 765 - color: var(--text-primary); 766 } 767 768 - .load-more button:hover:not(:disabled) { 769 - background: var(--bg-card); 770 } 771 772 .record-detail {
··· 75 } 76 } 77 async function loadMoreRecords() { 78 + if (!auth.session || !selectedCollection || !recordsCursor || loadingMore) return 79 loadingMore = true 80 try { 81 const result = await api.listRecords(auth.session.accessJwt, auth.session.did, selectedCollection, { ··· 93 loadingMore = false 94 } 95 } 96 + 97 + $effect(() => { 98 + if (view === 'records' && recordsCursor && !loadingMore && !loading) { 99 + loadMoreRecords() 100 + } 101 + }) 102 async function selectRecord(record: { uri: string; cid: string; value: unknown; rkey: string }) { 103 selectedRecord = record 104 recordJson = JSON.stringify(record.value, null, 2) ··· 377 </li> 378 {/each} 379 </ul> 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} 391 </div> 392 {/if} 393 {/if} ··· 395 <div class="record-detail"> 396 <div class="record-meta"> 397 <dl> 398 + <dt>{$_('repoExplorer.uri')}</dt> 399 <dd class="mono">{selectedRecord.uri}</dd> 400 + <dt>{$_('repoExplorer.cid')}</dt> 401 <dd class="mono">{selectedRecord.cid}</dd> 402 </dl> 403 </div> ··· 475 </div> 476 <style> 477 .page { 478 + max-width: var(--width-xl); 479 margin: 0 auto; 480 padding: var(--space-7); 481 } ··· 763 overflow: hidden; 764 } 765 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 { 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); 784 } 785 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; 803 background: var(--bg-secondary); 804 border-radius: var(--radius-md); 805 + animation: skeleton-pulse 1.5s ease-in-out infinite; 806 } 807 808 + @keyframes skeleton-pulse { 809 + 0%, 100% { opacity: 1; } 810 + 50% { opacity: 0.4; } 811 } 812 813 .record-detail {
+25 -2
frontend/src/routes/Security.svelte
··· 130 try { 131 const token = await getValidToken() 132 if (!token) { 133 - showMessage('error', 'Session expired. Please log in again.') 134 return 135 } 136 await api.removePassword(token) ··· 414 {#if loading} 415 <div class="loading">{$_('common.loading')}</div> 416 {:else} 417 <section> 418 <h2>{$_('security.totp')}</h2> 419 <p class="description"> ··· 725 {$_('security.manageTrustedDevices')} &rarr; 726 </a> 727 </section> 728 729 {#if hasMfa} 730 <section> ··· 788 789 <style> 790 .page { 791 - max-width: var(--width-md); 792 margin: 0 auto; 793 padding: var(--space-7); 794 } ··· 797 margin-bottom: var(--space-7); 798 } 799 800 .back { 801 color: var(--text-secondary); 802 text-decoration: none; ··· 822 background: var(--bg-secondary); 823 border-radius: var(--radius-xl); 824 margin-bottom: var(--space-6); 825 } 826 827 section h2 {
··· 130 try { 131 const token = await getValidToken() 132 if (!token) { 133 + showMessage('error', $_('security.sessionExpired')) 134 return 135 } 136 await api.removePassword(token) ··· 414 {#if loading} 415 <div class="loading">{$_('common.loading')}</div> 416 {:else} 417 + <div class="sections-grid"> 418 <section> 419 <h2>{$_('security.totp')}</h2> 420 <p class="description"> ··· 726 {$_('security.manageTrustedDevices')} &rarr; 727 </a> 728 </section> 729 + </div> 730 731 {#if hasMfa} 732 <section> ··· 790 791 <style> 792 .page { 793 + max-width: var(--width-lg); 794 margin: 0 auto; 795 padding: var(--space-7); 796 } ··· 799 margin-bottom: var(--space-7); 800 } 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 + 822 .back { 823 color: var(--text-secondary); 824 text-decoration: none; ··· 844 background: var(--bg-secondary); 845 border-radius: var(--radius-xl); 846 margin-bottom: var(--space-6); 847 + height: fit-content; 848 } 849 850 section h2 {
+1 -1
frontend/src/routes/Sessions.svelte
··· 149 </div> 150 <style> 151 .page { 152 - max-width: var(--width-md); 153 margin: 0 auto; 154 padding: var(--space-7); 155 }
··· 149 </div> 150 <style> 151 .page { 152 + max-width: var(--width-lg); 153 margin: 0 auto; 154 padding: var(--space-7); 155 }
+45 -4
frontend/src/routes/Settings.svelte
··· 1 <script lang="ts"> 2 import { getAuthState, logout, refreshSession } from '../lib/auth.svelte' 3 import { navigate } from '../lib/router.svelte' 4 import { api, ApiError } from '../lib/api' 5 import { locale, setLocale, getSupportedLocales, localeNames, _, type SupportedLocale } from '../lib/i18n' 6 const auth = getAuthState() 7 const supportedLocales = getSupportedLocales() 8 let localeLoading = $state(false) 9 async function handleLocaleChange(newLocale: SupportedLocale) { 10 if (!auth.session) return ··· 94 try { 95 const fullHandle = showBYOHandle 96 ? newHandle 97 - : `${newHandle}.${window.location.hostname}` 98 await api.updateHandle(auth.session.accessJwt, fullHandle) 99 await refreshSession() 100 showMessage('success', $_('settings.messages.handleUpdated')) ··· 201 {#if message} 202 <div class="message {message.type}">{message.text}</div> 203 {/if} 204 <section> 205 <h2>{$_('settings.language')}</h2> 206 <p class="description">{$_('settings.languageDescription')}</p> ··· 335 disabled={handleLoading} 336 required 337 /> 338 - <span class="handle-suffix">.{window.location.hostname}</span> 339 </div> 340 </div> 341 - <button type="submit" disabled={handleLoading || !newHandle}> 342 {handleLoading ? $_('settings.updating') : $_('settings.changeHandleButton')} 343 </button> 344 </form> ··· 393 {exportLoading ? $_('settings.exporting') : $_('settings.downloadRepo')} 394 </button> 395 </section> 396 <section class="danger-zone"> 397 <h2>{$_('settings.deleteAccount')}</h2> 398 <p class="warning">{$_('settings.deleteWarning')}</p> ··· 438 </div> 439 <style> 440 .page { 441 - max-width: var(--width-md); 442 margin: 0 auto; 443 padding: var(--space-7); 444 } ··· 447 margin-bottom: var(--space-7); 448 } 449 450 .back { 451 color: var(--text-secondary); 452 text-decoration: none; ··· 466 background: var(--bg-secondary); 467 border-radius: var(--radius-xl); 468 margin-bottom: var(--space-6); 469 } 470 471 section h2 { ··· 482 483 .language-select { 484 width: 100%; 485 } 486 487 .actions {
··· 1 <script lang="ts"> 2 + import { onMount } from 'svelte' 3 import { getAuthState, logout, refreshSession } from '../lib/auth.svelte' 4 import { navigate } from '../lib/router.svelte' 5 import { api, ApiError } from '../lib/api' 6 import { locale, setLocale, getSupportedLocales, localeNames, _, type SupportedLocale } from '../lib/i18n' 7 const auth = getAuthState() 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 + }) 18 let localeLoading = $state(false) 19 async function handleLocaleChange(newLocale: SupportedLocale) { 20 if (!auth.session) return ··· 104 try { 105 const fullHandle = showBYOHandle 106 ? newHandle 107 + : `${newHandle}.${pdsHostname}` 108 await api.updateHandle(auth.session.accessJwt, fullHandle) 109 await refreshSession() 110 showMessage('success', $_('settings.messages.handleUpdated')) ··· 211 {#if message} 212 <div class="message {message.type}">{message.text}</div> 213 {/if} 214 + <div class="sections-grid"> 215 <section> 216 <h2>{$_('settings.language')}</h2> 217 <p class="description">{$_('settings.languageDescription')}</p> ··· 346 disabled={handleLoading} 347 required 348 /> 349 + <span class="handle-suffix">.{pdsHostname ?? '...'}</span> 350 </div> 351 </div> 352 + <button type="submit" disabled={handleLoading || !newHandle || !pdsHostname}> 353 {handleLoading ? $_('settings.updating') : $_('settings.changeHandleButton')} 354 </button> 355 </form> ··· 404 {exportLoading ? $_('settings.exporting') : $_('settings.downloadRepo')} 405 </button> 406 </section> 407 + </div> 408 <section class="danger-zone"> 409 <h2>{$_('settings.deleteAccount')}</h2> 410 <p class="warning">{$_('settings.deleteWarning')}</p> ··· 450 </div> 451 <style> 452 .page { 453 + max-width: var(--width-lg); 454 margin: 0 auto; 455 padding: var(--space-7); 456 } ··· 459 margin-bottom: var(--space-7); 460 } 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 + 481 .back { 482 color: var(--text-secondary); 483 text-decoration: none; ··· 497 background: var(--bg-secondary); 498 border-radius: var(--radius-xl); 499 margin-bottom: var(--space-6); 500 + height: fit-content; 501 + } 502 + 503 + .danger-zone { 504 + margin-top: var(--space-6); 505 } 506 507 section h2 { ··· 518 519 .language-select { 520 width: 100%; 521 + } 522 + 523 + form > button, 524 + form > .actions { 525 + margin-top: var(--space-4); 526 } 527 528 .actions {
+3 -3
frontend/src/routes/TrustedDevices.svelte
··· 40 const result = await api.listTrustedDevices(auth.session.accessJwt) 41 devices = result.devices 42 } catch { 43 - showMessage('error', 'Failed to load trusted devices') 44 } finally { 45 loading = false 46 } ··· 199 200 <style> 201 .page { 202 - max-width: var(--width-md); 203 margin: 0 auto; 204 - padding: var(--space-7) var(--space-4); 205 } 206 207 header {
··· 40 const result = await api.listTrustedDevices(auth.session.accessJwt) 41 devices = result.devices 42 } catch { 43 + showMessage('error', $_('trustedDevices.failedToLoad')) 44 } finally { 45 loading = false 46 } ··· 199 200 <style> 201 .page { 202 + max-width: var(--width-lg); 203 margin: 0 auto; 204 + padding: var(--space-7); 205 } 206 207 header {
+131 -27
frontend/src/styles/base.css
··· 1 - @import './tokens.css'; 2 3 @property --accent { 4 - syntax: '<color>'; 5 inherits: true; 6 - initial-value: #2c00ff; 7 } 8 9 @property --secondary { 10 - syntax: '<color>'; 11 inherits: true; 12 - initial-value: #ff2400; 13 } 14 15 *, ··· 20 21 body { 22 margin: 0; 23 - font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Monaco, monospace; 24 font-size: var(--text-base); 25 line-height: var(--leading-normal); 26 color: var(--text-primary); ··· 35 line-height: var(--leading-tight); 36 } 37 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); } 42 43 p { 44 margin: 0; ··· 70 border-radius: var(--radius-md); 71 background: var(--bg-input); 72 color: var(--text-primary); 73 - transition: border-color var(--transition-normal), box-shadow var(--transition-normal); 74 width: 100%; 75 } 76 ··· 113 border: none; 114 border-radius: var(--radius-md); 115 cursor: pointer; 116 - transition: background var(--transition-normal), border-color var(--transition-normal), opacity var(--transition-normal); 117 background: var(--accent); 118 color: var(--text-inverse); 119 } ··· 177 } 178 179 fieldset { 180 - border: 1px solid var(--border-dark); 181 border-radius: var(--radius-lg); 182 padding: var(--space-5); 183 margin: 0; 184 } 185 186 fieldset legend { 187 font-weight: var(--font-semibold); 188 - padding: 0 var(--space-3); 189 - color: var(--text-primary); 190 } 191 192 code { 193 - font-family: inherit; 194 font-size: 0.9em; 195 background: var(--bg-tertiary); 196 padding: var(--space-1) var(--space-2); ··· 198 } 199 200 pre { 201 - font-family: inherit; 202 font-size: var(--text-sm); 203 background: var(--bg-tertiary); 204 padding: var(--space-4); ··· 221 222 .field + .field { 223 margin-top: var(--space-5); 224 } 225 226 .hint { ··· 307 } 308 309 .page { 310 - max-width: var(--width-md); 311 margin: 0 auto; 312 padding: var(--space-7); 313 } 314 315 .page-sm { 316 - max-width: var(--width-sm); 317 margin: 0 auto; 318 padding: var(--space-7); 319 } 320 321 .page-lg { 322 - max-width: var(--width-lg); 323 margin: 0 auto; 324 padding: var(--space-7); 325 } ··· 357 } 358 359 .mono { 360 - font-family: inherit; 361 } 362 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); }
··· 1 + @import "./tokens.css"; 2 3 @property --accent { 4 + syntax: "<color>"; 5 inherits: true; 6 + initial-value: #1a1d1d; 7 } 8 9 @property --secondary { 10 + syntax: "<color>"; 11 inherits: true; 12 + initial-value: #1a1d1d; 13 } 14 15 *, ··· 20 21 body { 22 margin: 0; 23 + font-family: 24 + "Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; 25 font-size: var(--text-base); 26 line-height: var(--leading-normal); 27 color: var(--text-primary); ··· 36 line-height: var(--leading-tight); 37 } 38 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 + } 51 52 p { 53 margin: 0; ··· 79 border-radius: var(--radius-md); 80 background: var(--bg-input); 81 color: var(--text-primary); 82 + transition: 83 + border-color var(--transition-normal), 84 + box-shadow var(--transition-normal); 85 width: 100%; 86 } 87 ··· 124 border: none; 125 border-radius: var(--radius-md); 126 cursor: pointer; 127 + transition: 128 + background var(--transition-normal), 129 + border-color var(--transition-normal), 130 + opacity var(--transition-normal); 131 background: var(--accent); 132 color: var(--text-inverse); 133 } ··· 191 } 192 193 fieldset { 194 + border: none; 195 + border-left: 3px solid var(--accent); 196 border-radius: var(--radius-lg); 197 padding: var(--space-5); 198 + padding-left: var(--space-6); 199 margin: 0; 200 + background: var(--bg-secondary); 201 } 202 203 fieldset legend { 204 + font-size: var(--text-xs); 205 font-weight: var(--font-semibold); 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; 218 } 219 220 code { 221 + font-family: ui-monospace, "SF Mono", Menlo, Monaco, monospace; 222 font-size: 0.9em; 223 background: var(--bg-tertiary); 224 padding: var(--space-1) var(--space-2); ··· 226 } 227 228 pre { 229 + font-family: ui-monospace, "SF Mono", Menlo, Monaco, monospace; 230 font-size: var(--text-sm); 231 background: var(--bg-tertiary); 232 padding: var(--space-4); ··· 249 250 .field + .field { 251 margin-top: var(--space-5); 252 + } 253 + 254 + .form-row .field + .field { 255 + margin-top: 0; 256 } 257 258 .hint { ··· 339 } 340 341 .page { 342 + max-width: var(--width-lg); 343 margin: 0 auto; 344 padding: var(--space-7); 345 } 346 347 .page-sm { 348 + max-width: var(--width-md); 349 margin: 0 auto; 350 padding: var(--space-7); 351 } 352 353 .page-lg { 354 + max-width: var(--width-xl); 355 margin: 0 auto; 356 padding: var(--space-7); 357 } ··· 389 } 390 391 .mono { 392 + font-family: ui-monospace, "SF Mono", Menlo, Monaco, monospace; 393 } 394 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 --radius-lg: 6px; 34 --radius-xl: 8px; 35 36 - --width-xs: 320px; 37 - --width-sm: 400px; 38 - --width-md: 600px; 39 - --width-lg: 800px; 40 - --width-xl: 1000px; 41 42 --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); 43 --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.1); ··· 48 --transition-normal: 0.15s ease; 49 --transition-slow: 0.25s ease; 50 51 - --bg-primary: #ffffff; 52 - --bg-secondary: #f8f8fa; 53 - --bg-tertiary: #f0f0f2; 54 --bg-card: #ffffff; 55 --bg-input: #ffffff; 56 - --bg-input-disabled: #f8f8fa; 57 58 - --text-primary: #1a1a1a; 59 - --text-secondary: #666666; 60 - --text-muted: #999999; 61 --text-inverse: #ffffff; 62 63 - --border-color: #e5e5e5; 64 - --border-light: #f0f0f0; 65 - --border-dark: #cccccc; 66 67 - --accent: #2c00ff; 68 - --accent-hover: #1a00a3; 69 - --accent-muted: rgba(44, 0, 255, 0.08); 70 - --accent-light: #4d33ff; 71 72 - --secondary: #ff2400; 73 - --secondary-hover: #cc1d00; 74 - --secondary-muted: rgba(255, 36, 0, 0.08); 75 76 --success-bg: #dfd; 77 --success-border: #8c8; ··· 90 91 @media (prefers-color-scheme: dark) { 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; 99 100 - --text-primary: #e8e8e8; 101 - --text-secondary: #a0a0a0; 102 - --text-muted: #666666; 103 - --text-inverse: #0a0a0a; 104 105 - --border-color: #2a2a2a; 106 - --border-light: #222222; 107 - --border-dark: #333333; 108 109 - --accent: #7b6bff; 110 - --accent-hover: #9588ff; 111 - --accent-muted: rgba(123, 107, 255, 0.2); 112 - --accent-light: #9588ff; 113 114 - --secondary: #ff6b5b; 115 - --secondary-hover: #ff8577; 116 - --secondary-muted: rgba(255, 107, 91, 0.2); 117 118 - --success-bg: #1a3d1a; 119 - --success-border: #2d5a2d; 120 - --success-text: #7bc67b; 121 122 - --error-bg: #3d1a1a; 123 - --error-border: #5a2d2d; 124 - --error-text: #ff7b7b; 125 126 - --warning-bg: #3d3d1a; 127 - --warning-border: #5a5a2d; 128 - --warning-text: #c6c67b; 129 } 130 }
··· 33 --radius-lg: 6px; 34 --radius-xl: 8px; 35 36 + --width-xs: 360px; 37 + --width-sm: 480px; 38 + --width-md: 760px; 39 + --width-lg: 960px; 40 + --width-xl: 1100px; 41 42 --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); 43 --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.1); ··· 48 --transition-normal: 0.15s ease; 49 --transition-slow: 0.25s ease; 50 51 + --bg-primary: #f9fafa; 52 + --bg-secondary: #f1f3f3; 53 + --bg-tertiary: #e8ebeb; 54 --bg-card: #ffffff; 55 --bg-input: #ffffff; 56 + --bg-input-disabled: #f1f3f3; 57 58 + --text-primary: #1a1d1d; 59 + --text-secondary: #5a605f; 60 + --text-muted: #8a8f8e; 61 --text-inverse: #ffffff; 62 63 + --border-color: #dce0df; 64 + --border-light: #e8ebeb; 65 + --border-dark: #c8cecc; 66 67 + --accent: #1a1d1d; 68 + --accent-hover: #2e3332; 69 + --accent-muted: rgba(26, 29, 29, 0.06); 70 + --accent-light: #3a403f; 71 72 + --secondary: #1a1d1d; 73 + --secondary-hover: #2e3332; 74 + --secondary-muted: rgba(26, 29, 29, 0.06); 75 76 --success-bg: #dfd; 77 --success-border: #8c8; ··· 90 91 @media (prefers-color-scheme: dark) { 92 :root { 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 100 + --text-primary: #e6e8e8; 101 + --text-secondary: #9ca1a0; 102 + --text-muted: #686d6c; 103 + --text-inverse: #0a0c0c; 104 105 + --border-color: #282c2b; 106 + --border-light: #1f2322; 107 + --border-dark: #343938; 108 109 + --accent: #e6e8e8; 110 + --accent-hover: #ffffff; 111 + --accent-muted: rgba(230, 232, 232, 0.1); 112 + --accent-light: #ffffff; 113 114 + --secondary: #e6e8e8; 115 + --secondary-hover: #ffffff; 116 + --secondary-muted: rgba(230, 232, 232, 0.1); 117 118 + --success-bg: #0f1f1a; 119 + --success-border: #1a3d2d; 120 + --success-text: #7bc6a0; 121 122 + --error-bg: #1f0f0f; 123 + --error-border: #3d1a1a; 124 + --error-text: #ff8a8a; 125 126 + --warning-bg: #1f1a0f; 127 + --warning-border: #3d351a; 128 + --warning-text: #c6b87b; 129 } 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' 4 import { 5 - setupFetchMock, 6 - mockEndpoint, 7 - jsonResponse, 8 errorResponse, 9 mockData, 10 - clearMocks, 11 setupAuthenticatedUser, 12 setupUnauthenticatedUser, 13 - } from './mocks' 14 - describe('AppPasswords', () => { 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) 24 await waitFor(() => { 25 - expect(window.location.hash).toBe('#/login') 26 - }) 27 - }) 28 - }) 29 - describe('page structure', () => { 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) 38 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', () => { 46 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', () => { 59 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) 67 await waitFor(() => { 68 - expect(screen.getByText(/no app passwords yet/i)).toBeInTheDocument() 69 - }) 70 - }) 71 - }) 72 - describe('password list', () => { 73 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 - ] 77 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) 85 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', () => { 95 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) 103 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) 110 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) 116 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 127 return jsonResponse({ 128 name: body.name, 129 - password: 'xxxx-xxxx-xxxx-xxxx', 130 createdAt: new Date().toISOString(), 131 - }) 132 - }) 133 - render(AppPasswords) 134 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 })) 139 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)) 146 return jsonResponse({ 147 - name: 'Test', 148 - password: 'xxxx-xxxx-xxxx-xxxx', 149 createdAt: new Date().toISOString(), 150 - }) 151 - }) 152 - render(AppPasswords) 153 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', () => 163 jsonResponse({ 164 - name: 'MyApp', 165 - password: 'abcd-efgh-ijkl-mnop', 166 createdAt: new Date().toISOString(), 167 - }) 168 - ) 169 - render(AppPasswords) 170 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 })) 176 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', () => 185 jsonResponse({ 186 - name: 'Test', 187 - password: 'xxxx-xxxx-xxxx-xxxx', 188 createdAt: new Date().toISOString(), 189 - }) 190 - ) 191 - render(AppPasswords) 192 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 })) 197 await waitFor(() => { 198 - expect(screen.getByText(/app password created/i)).toBeInTheDocument() 199 - }) 200 - await fireEvent.click(screen.getByRole('button', { name: /done/i })) 201 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) 210 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 })) 215 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' }) 223 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) 233 await waitFor(() => { 234 - expect(screen.getByText('TestApp')).toBeInTheDocument() 235 - }) 236 - await fireEvent.click(screen.getByRole('button', { name: /revoke/i })) 237 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) 252 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) 270 await waitFor(() => { 271 - expect(screen.getByText('TestApp')).toBeInTheDocument() 272 - }) 273 - await fireEvent.click(screen.getByRole('button', { name: /revoke/i })) 274 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) 288 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++ 300 if (listCallCount === 1) { 301 - return jsonResponse({ passwords: [testPassword] }) 302 } 303 - return jsonResponse({ passwords: [] }) 304 - }) 305 - mockEndpoint('com.atproto.server.revokeAppPassword', () => 306 - jsonResponse({}) 307 - ) 308 - render(AppPasswords) 309 await waitFor(() => { 310 - expect(screen.getByText('TestApp')).toBeInTheDocument() 311 - }) 312 - await fireEvent.click(screen.getByRole('button', { name: /revoke/i })) 313 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) 327 await waitFor(() => { 328 - expect(screen.getByText('TestApp')).toBeInTheDocument() 329 - }) 330 - await fireEvent.click(screen.getByRole('button', { name: /revoke/i })) 331 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', () => { 338 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) 346 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 - })
··· 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 import { 5 + clearMocks, 6 errorResponse, 7 + jsonResponse, 8 mockData, 9 + mockEndpoint, 10 setupAuthenticatedUser, 11 + setupFetchMock, 12 setupUnauthenticatedUser, 13 + } from "./mocks"; 14 + describe("AppPasswords", () => { 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); 24 await waitFor(() => { 25 + expect(window.location.hash).toBe("#/login"); 26 + }); 27 + }); 28 + }); 29 + describe("page structure", () => { 30 beforeEach(() => { 31 + setupAuthenticatedUser(); 32 + mockEndpoint( 33 + "com.atproto.server.listAppPasswords", 34 + () => jsonResponse({ passwords: [] }), 35 + ); 36 + }); 37 + it("displays all page elements", async () => { 38 + render(AppPasswords); 39 await waitFor(() => { 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", () => { 50 beforeEach(() => { 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", () => { 63 beforeEach(() => { 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); 72 await waitFor(() => { 73 + expect(screen.getByText(/no app passwords yet/i)).toBeInTheDocument(); 74 + }); 75 + }); 76 + }); 77 + describe("password list", () => { 78 const testPasswords = [ 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 + ]; 88 beforeEach(() => { 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); 97 await waitFor(() => { 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", () => { 109 beforeEach(() => { 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); 118 await waitFor(() => { 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); 126 await waitFor(() => { 127 + expect(screen.getByRole("button", { name: /create/i })).toBeDisabled(); 128 + }); 129 + }); 130 + it("enables create button when input has value", async () => { 131 + render(AppPasswords); 132 await waitFor(() => { 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; 146 return jsonResponse({ 147 name: body.name, 148 + password: "xxxx-xxxx-xxxx-xxxx", 149 createdAt: new Date().toISOString(), 150 + }); 151 + }); 152 + render(AppPasswords); 153 await waitFor(() => { 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 })); 160 await waitFor(() => { 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)); 167 return jsonResponse({ 168 + name: "Test", 169 + password: "xxxx-xxxx-xxxx-xxxx", 170 createdAt: new Date().toISOString(), 171 + }); 172 + }); 173 + render(AppPasswords); 174 await waitFor(() => { 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", () => 187 jsonResponse({ 188 + name: "MyApp", 189 + password: "abcd-efgh-ijkl-mnop", 190 createdAt: new Date().toISOString(), 191 + })); 192 + render(AppPasswords); 193 await waitFor(() => { 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 })); 201 await waitFor(() => { 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", () => 210 jsonResponse({ 211 + name: "Test", 212 + password: "xxxx-xxxx-xxxx-xxxx", 213 createdAt: new Date().toISOString(), 214 + })); 215 + render(AppPasswords); 216 await waitFor(() => { 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 })); 223 await waitFor(() => { 224 + expect(screen.getByText(/app password created/i)).toBeInTheDocument(); 225 + }); 226 + await fireEvent.click(screen.getByRole("button", { name: /done/i })); 227 await waitFor(() => { 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); 238 await waitFor(() => { 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 })); 245 await waitFor(() => { 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" }); 253 beforeEach(() => { 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); 264 await waitFor(() => { 265 + expect(screen.getByText("TestApp")).toBeInTheDocument(); 266 + }); 267 + await fireEvent.click(screen.getByRole("button", { name: /revoke/i })); 268 expect(confirmSpy).toHaveBeenCalledWith( 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); 284 await waitFor(() => { 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); 303 await waitFor(() => { 304 + expect(screen.getByText("TestApp")).toBeInTheDocument(); 305 + }); 306 + await fireEvent.click(screen.getByRole("button", { name: /revoke/i })); 307 await waitFor(() => { 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); 322 await waitFor(() => { 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++; 335 if (listCallCount === 1) { 336 + return jsonResponse({ passwords: [testPassword] }); 337 } 338 + return jsonResponse({ passwords: [] }); 339 + }); 340 + mockEndpoint( 341 + "com.atproto.server.revokeAppPassword", 342 + () => jsonResponse({}), 343 + ); 344 + render(AppPasswords); 345 await waitFor(() => { 346 + expect(screen.getByText("TestApp")).toBeInTheDocument(); 347 + }); 348 + await fireEvent.click(screen.getByRole("button", { name: /revoke/i })); 349 await waitFor(() => { 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); 365 await waitFor(() => { 366 + expect(screen.getByText("TestApp")).toBeInTheDocument(); 367 + }); 368 + await fireEvent.click(screen.getByRole("button", { name: /revoke/i })); 369 await waitFor(() => { 370 + expect(screen.getByText(/server error/i)).toBeInTheDocument(); 371 + expect(screen.getByText(/server error/i)).toHaveClass("error"); 372 + }); 373 + }); 374 + }); 375 + describe("error handling", () => { 376 beforeEach(() => { 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); 385 await waitFor(() => { 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' 4 import { 5 - setupFetchMock, 6 - mockEndpoint, 7 jsonResponse, 8 - errorResponse, 9 mockData, 10 - clearMocks, 11 setupAuthenticatedUser, 12 setupUnauthenticatedUser, 13 - } from './mocks' 14 - describe('Comms', () => { 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) 23 await waitFor(() => { 24 - expect(window.location.hash).toBe('#/login') 25 - }) 26 - }) 27 - }) 28 - describe('page structure', () => { 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) 37 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', () => { 47 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', () => { 60 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) 68 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) 80 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) 90 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) 100 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) 110 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) 119 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', () => { 126 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) 134 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) 149 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', () => { 157 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) 165 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) 177 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) 190 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) 199 await waitFor(() => { 200 - expect(screen.getByText('Primary')).toBeInTheDocument() 201 - expect(screen.queryByText('Not verified')).not.toBeInTheDocument() 202 - }) 203 - }) 204 - }) 205 - describe('save preferences', () => { 206 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) 219 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 })) 224 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) 239 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) 254 await waitFor(() => { 255 - expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument() 256 - }) 257 - await fireEvent.click(screen.getByRole('button', { name: /save preferences/i })) 258 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) 270 await waitFor(() => { 271 - expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument() 272 - }) 273 - await fireEvent.click(screen.getByRole('button', { name: /save preferences/i })) 274 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) 289 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 })) 294 await waitFor(() => { 295 - expect(loadCount).toBeGreaterThan(initialLoadCount) 296 - }) 297 - }) 298 - }) 299 - describe('channel selection interaction', () => { 300 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) 308 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' } }) 312 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) 324 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', () => { 333 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) 341 await waitFor(() => { 342 - expect(screen.getByText(/database connection failed/i)).toBeInTheDocument() 343 - }) 344 - }) 345 - }) 346 - })
··· 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 import { 5 + clearMocks, 6 + errorResponse, 7 jsonResponse, 8 mockData, 9 + mockEndpoint, 10 setupAuthenticatedUser, 11 + setupFetchMock, 12 setupUnauthenticatedUser, 13 + } from "./mocks"; 14 + describe("Comms", () => { 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); 23 await waitFor(() => { 24 + expect(window.location.hash).toBe("#/login"); 25 + }); 26 + }); 27 + }); 28 + describe("page structure", () => { 29 beforeEach(() => { 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); 38 await waitFor(() => { 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", () => { 56 beforeEach(() => { 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", () => { 69 beforeEach(() => { 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); 78 await waitFor(() => { 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); 95 await waitFor(() => { 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); 106 await waitFor(() => { 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); 118 await waitFor(() => { 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); 129 await waitFor(() => { 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); 143 await waitFor(() => { 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", () => { 152 beforeEach(() => { 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); 161 await waitFor(() => { 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); 180 await waitFor(() => { 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", () => { 196 beforeEach(() => { 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); 205 await waitFor(() => { 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); 219 await waitFor(() => { 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); 234 await waitFor(() => { 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); 244 await waitFor(() => { 245 + expect(screen.getByText("Primary")).toBeInTheDocument(); 246 + expect(screen.queryByText("Not verified")).not.toBeInTheDocument(); 247 + }); 248 + }); 249 + }); 250 + describe("save preferences", () => { 251 beforeEach(() => { 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); 268 await waitFor(() => { 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 + ); 277 await waitFor(() => { 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); 293 await waitFor(() => { 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); 314 await waitFor(() => { 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 + ); 321 await waitFor(() => { 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); 337 await waitFor(() => { 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 + ); 344 await waitFor(() => { 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); 365 await waitFor(() => { 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 + ); 373 await waitFor(() => { 374 + expect(loadCount).toBeGreaterThan(initialLoadCount); 375 + }); 376 + }); 377 + }); 378 + describe("channel selection interaction", () => { 379 beforeEach(() => { 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); 388 await waitFor(() => { 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 + }); 394 await waitFor(() => { 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); 409 await waitFor(() => { 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", () => { 421 beforeEach(() => { 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); 430 await waitFor(() => { 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' 4 import { 5 - setupFetchMock, 6 - mockEndpoint, 7 jsonResponse, 8 mockData, 9 - clearMocks, 10 setupAuthenticatedUser, 11 setupUnauthenticatedUser, 12 - } from './mocks' 13 - const STORAGE_KEY = 'tranquil_pds_session' 14 - describe('Dashboard', () => { 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) 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', () => { 33 beforeEach(() => { 34 - setupAuthenticatedUser() 35 - }) 36 - it('displays user account info and page structure', async () => { 37 - render(Dashboard) 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) 51 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) 58 await waitFor(() => { 59 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 - ] 66 for (const { name, href } of navCards) { 67 - const card = screen.getByRole('link', { name }) 68 - expect(card).toBeInTheDocument() 69 - expect(card).toHaveAttribute('href', href) 70 } 71 - }) 72 - }) 73 - }) 74 - describe('logout functionality', () => { 75 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) 89 await waitFor(() => { 90 - expect(screen.getByRole('button', { name: /sign out/i })).toBeInTheDocument() 91 - }) 92 - await fireEvent.click(screen.getByRole('button', { name: /sign out/i })) 93 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) 102 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 await waitFor(() => { 107 - expect(localStorage.getItem(STORAGE_KEY)).toBeNull() 108 - }) 109 - }) 110 - }) 111 - })
··· 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 import { 5 + clearMocks, 6 jsonResponse, 7 mockData, 8 + mockEndpoint, 9 setupAuthenticatedUser, 10 + setupFetchMock, 11 setupUnauthenticatedUser, 12 + } from "./mocks"; 13 + const STORAGE_KEY = "tranquil_pds_session"; 14 + describe("Dashboard", () => { 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); 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", () => { 33 beforeEach(() => { 34 + setupAuthenticatedUser(); 35 + }); 36 + it("displays user account info and page structure", async () => { 37 + render(Dashboard); 38 await waitFor(() => { 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); 55 await waitFor(() => { 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); 62 await waitFor(() => { 63 const navCards = [ 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 + ]; 70 for (const { name, href } of navCards) { 71 + const card = screen.getByRole("link", { name }); 72 + expect(card).toBeInTheDocument(); 73 + expect(card).toHaveAttribute("href", href); 74 } 75 + }); 76 + }); 77 + }); 78 + describe("logout functionality", () => { 79 beforeEach(() => { 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); 91 await waitFor(() => { 92 + expect(screen.getByRole("button", { name: /sign out/i })) 93 + .toBeInTheDocument(); 94 + }); 95 + await fireEvent.click(screen.getByRole("button", { name: /sign out/i })); 96 await waitFor(() => { 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); 105 await waitFor(() => { 106 + expect(screen.getByRole("button", { name: /sign out/i })) 107 + .toBeInTheDocument(); 108 + }); 109 + await fireEvent.click(screen.getByRole("button", { name: /sign out/i })); 110 await waitFor(() => { 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' 4 import { 5 - setupFetchMock, 6 - mockEndpoint, 7 - jsonResponse, 8 errorResponse, 9 mockData, 10 - clearMocks, 11 - } from './mocks' 12 - describe('Login', () => { 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 })) 56 await waitFor(() => { 57 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 })) 71 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 })) 85 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', () => ({ 93 ok: false, 94 status: 401, 95 json: async () => ({ 96 - error: 'AccountNotVerified', 97 - message: 'Account not verified', 98 - did: 'did:web:test.tranquil.dev:u:testuser', 99 }), 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 })) 105 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', () => ({ 114 ok: false, 115 status: 401, 116 json: async () => ({ 117 - error: 'AccountNotVerified', 118 - message: 'Account not verified', 119 - did: 'did:web:test.tranquil.dev:u:testuser', 120 }), 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 })) 126 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 })) 130 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 - })
··· 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 import { 5 + clearMocks, 6 errorResponse, 7 + jsonResponse, 8 mockData, 9 + mockEndpoint, 10 + setupFetchMock, 11 + } from "./mocks"; 12 + describe("Login", () => { 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 })) 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 })); 67 await waitFor(() => { 68 expect(capturedBody).toEqual({ 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 })); 92 await waitFor(() => { 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 })); 111 await waitFor(() => { 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", () => ({ 119 ok: false, 120 status: 401, 121 json: async () => ({ 122 + error: "AccountNotVerified", 123 + message: "Account not verified", 124 + did: "did:web:test.tranquil.dev:u:testuser", 125 }), 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 })); 135 await waitFor(() => { 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", () => ({ 147 ok: false, 148 status: 401, 149 json: async () => ({ 150 + error: "AccountNotVerified", 151 + message: "Account not verified", 152 + did: "did:web:test.tranquil.dev:u:testuser", 153 }), 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 })); 163 await waitFor(() => { 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 + ); 170 await waitFor(() => { 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' 4 import { 5 - setupFetchMock, 6 - mockEndpoint, 7 - jsonResponse, 8 errorResponse, 9 - clearMocks, 10 setupAuthenticatedUser, 11 setupUnauthenticatedUser, 12 - } from './mocks' 13 - describe('Settings', () => { 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) 23 await waitFor(() => { 24 - expect(window.location.hash).toBe('#/login') 25 - }) 26 - }) 27 - }) 28 - describe('page structure', () => { 29 beforeEach(() => { 30 - setupAuthenticatedUser() 31 - }) 32 - it('displays all page elements and sections', async () => { 33 - render(Settings) 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', () => { 44 beforeEach(() => { 45 - setupAuthenticatedUser() 46 - }) 47 - it('displays current email and input field', async () => { 48 - render(Settings) 49 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) 61 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 })) 66 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) 75 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 })) 80 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 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 })) 102 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 })) 107 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) 121 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 })) 126 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 })) 131 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) 140 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 })) 145 await waitFor(() => { 146 - expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument() 147 - }) 148 - await fireEvent.click(screen.getByRole('button', { name: /cancel/i })) 149 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) 159 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' } }) 163 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 })) 167 await waitFor(() => { 168 - expect(screen.getByText(/invalid email format/i)).toBeInTheDocument() 169 - }) 170 - }) 171 - }) 172 - describe('handle change', () => { 173 beforeEach(() => { 174 - setupAuthenticatedUser() 175 - }) 176 - it('displays current handle', async () => { 177 - render(Settings) 178 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) 190 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 })) 195 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) 204 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 })) 209 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) 218 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 })) 223 await waitFor(() => { 224 - expect(screen.getByText(/handle is already taken/i)).toBeInTheDocument() 225 - }) 226 - }) 227 - }) 228 - describe('account deletion', () => { 229 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) 237 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) 249 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 })) 253 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) 262 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 })) 266 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) 279 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 })) 283 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 })) 289 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) 304 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 })) 308 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 })) 314 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) 329 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 })) 333 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 })) 339 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) 348 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 })) 352 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') 359 if (cancelButton) { 360 - await fireEvent.click(cancelButton) 361 } 362 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) 375 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 })) 379 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 })) 385 await waitFor(() => { 386 - expect(screen.getByText(/invalid confirmation code/i)).toBeInTheDocument() 387 - }) 388 - }) 389 - }) 390 - })
··· 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 import { 5 + clearMocks, 6 errorResponse, 7 + jsonResponse, 8 + mockEndpoint, 9 setupAuthenticatedUser, 10 + setupFetchMock, 11 setupUnauthenticatedUser, 12 + } from "./mocks"; 13 + describe("Settings", () => { 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); 23 await waitFor(() => { 24 + expect(window.location.hash).toBe("#/login"); 25 + }); 26 + }); 27 + }); 28 + describe("page structure", () => { 29 beforeEach(() => { 30 + setupAuthenticatedUser(); 31 + }); 32 + it("displays all page elements and sections", async () => { 33 + render(Settings); 34 await waitFor(() => { 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", () => { 50 beforeEach(() => { 51 + setupAuthenticatedUser(); 52 + }); 53 + it("displays current email and input field", async () => { 54 + render(Settings); 55 await waitFor(() => { 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); 68 await waitFor(() => { 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 + ); 77 await waitFor(() => { 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); 87 await waitFor(() => { 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 + ); 96 await waitFor(() => { 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); 115 await waitFor(() => { 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 + ); 124 await waitFor(() => { 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 + ); 133 await waitFor(() => { 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); 146 await waitFor(() => { 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 + ); 155 await waitFor(() => { 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 + ); 164 await waitFor(() => { 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); 175 await waitFor(() => { 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 + ); 184 await waitFor(() => { 185 + expect(screen.getByRole("button", { name: /cancel/i })) 186 + .toBeInTheDocument(); 187 + }); 188 + await fireEvent.click(screen.getByRole("button", { name: /cancel/i })); 189 await waitFor(() => { 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); 201 await waitFor(() => { 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 + }); 207 await waitFor(() => { 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 + ); 214 await waitFor(() => { 215 + expect(screen.getByText(/invalid email format/i)).toBeInTheDocument(); 216 + }); 217 + }); 218 + }); 219 + describe("handle change", () => { 220 beforeEach(() => { 221 + setupAuthenticatedUser(); 222 + }); 223 + it("displays current handle", async () => { 224 + render(Settings); 225 await waitFor(() => { 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); 238 await waitFor(() => { 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 + ); 247 await waitFor(() => { 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); 254 await waitFor(() => { 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 + ); 263 await waitFor(() => { 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); 275 await waitFor(() => { 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 + ); 284 await waitFor(() => { 285 + expect(screen.getByText(/handle is already taken/i)) 286 + .toBeInTheDocument(); 287 + }); 288 + }); 289 + }); 290 + describe("account deletion", () => { 291 beforeEach(() => { 292 + setupAuthenticatedUser(); 293 + mockEndpoint("com.atproto.server.deleteSession", () => jsonResponse({})); 294 + }); 295 + it("displays delete section with warning and request button", async () => { 296 + render(Settings); 297 await waitFor(() => { 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); 312 await waitFor(() => { 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 + ); 320 await waitFor(() => { 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); 330 await waitFor(() => { 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 + ); 338 await waitFor(() => { 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); 354 await waitFor(() => { 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 + ); 362 await waitFor(() => { 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 + ); 374 expect(confirmSpy).toHaveBeenCalledWith( 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); 390 await waitFor(() => { 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 + ); 398 await waitFor(() => { 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 + ); 410 await waitFor(() => { 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); 424 await waitFor(() => { 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 + ); 432 await waitFor(() => { 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 + ); 444 await waitFor(() => { 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); 454 await waitFor(() => { 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 + ); 462 await waitFor(() => { 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"); 473 if (cancelButton) { 474 + await fireEvent.click(cancelButton); 475 } 476 await waitFor(() => { 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); 493 await waitFor(() => { 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 + ); 501 await waitFor(() => { 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 + ); 513 await waitFor(() => { 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' 4 export interface MockResponse { 5 - ok: boolean 6 - status: number 7 - json: () => Promise<unknown> 8 } 9 - export type MockHandler = (url: string, options?: RequestInit) => MockResponse | Promise<MockResponse> 10 - const mockHandlers: Map<string, MockHandler> = new Map() 11 export function mockEndpoint(endpoint: string, handler: MockHandler): void { 12 - mockHandlers.set(endpoint, handler) 13 } 14 export function mockEndpointOnce(endpoint: string, handler: MockHandler): void { 15 - const originalHandler = mockHandlers.get(endpoint) 16 mockHandlers.set(endpoint, (url, options) => { 17 - mockHandlers.set(endpoint, originalHandler!) 18 - return handler(url, options) 19 - }) 20 } 21 export function clearMocks(): void { 22 - mockHandlers.clear() 23 } 24 function extractEndpoint(url: string): string { 25 - const match = url.match(/\/xrpc\/([^?]+)/) 26 - return match ? match[1] : url 27 } 28 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) 35 return { 36 - ok: result.ok, 37 - status: result.status, 38 - json: result.json, 39 - text: async () => JSON.stringify(await result.json()), 40 headers: new Headers(), 41 redirected: false, 42 - statusText: result.ok ? 'OK' : 'Error', 43 - type: 'basic', 44 url, 45 - clone: () => ({ ...result }) as Response, 46 body: null, 47 bodyUsed: false, 48 arrayBuffer: async () => new ArrayBuffer(0), 49 blob: async () => new Blob(), 50 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 - }) 71 } 72 export function jsonResponse<T>(data: T, status = 200): MockResponse { 73 return { 74 ok: status >= 200 && status < 300, 75 status, 76 json: async () => data, 77 - } 78 } 79 - export function errorResponse(error: string, message: string, status = 400): MockResponse { 80 return { 81 ok: false, 82 status, 83 json: async () => ({ error, message }), 84 - } 85 } 86 export const mockData = { 87 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', 91 emailConfirmed: true, 92 - accessJwt: 'mock-access-jwt-token', 93 - refreshJwt: 'mock-refresh-jwt-token', 94 ...overrides, 95 }), 96 appPassword: (overrides?: Partial<AppPassword>): AppPassword => ({ 97 - name: 'Test App', 98 createdAt: new Date().toISOString(), 99 ...overrides, 100 }), 101 inviteCode: (overrides?: Partial<InviteCode>): InviteCode => ({ 102 - code: 'test-invite-123', 103 available: 1, 104 disabled: false, 105 - forAccount: 'did:web:test.tranquil.dev:u:testuser', 106 - createdBy: 'did:web:test.tranquil.dev:u:testuser', 107 createdAt: new Date().toISOString(), 108 uses: [], 109 ...overrides, 110 }), 111 notificationPrefs: (overrides?: Record<string, unknown>) => ({ 112 - preferredChannel: 'email', 113 - email: 'test@example.com', 114 discordId: null, 115 discordVerified: false, 116 telegramUsername: null, ··· 120 ...overrides, 121 }), 122 describeServer: () => ({ 123 - availableUserDomains: ['test.tranquil.dev'], 124 inviteCodeRequired: false, 125 links: { 126 - privacyPolicy: 'https://example.com/privacy', 127 - termsOfService: 'https://example.com/tos', 128 }, 129 }), 130 describeRepo: (did: string) => ({ 131 - handle: 'testuser.test.tranquil.dev', 132 did, 133 didDoc: {}, 134 - collections: ['app.bsky.feed.post', 'app.bsky.feed.like', 'app.bsky.graph.follow'], 135 handleIsCorrect: true, 136 }), 137 - } 138 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('@', '') })) 147 } 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) || '{}') 161 return jsonResponse({ 162 name: body.name, 163 - password: 'xxxx-xxxx-xxxx-xxxx', 164 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 - ) 208 } 209 - export function setupAuthenticatedUser(sessionOverrides?: Partial<Session>): Session { 210 - const session = mockData.session(sessionOverrides) 211 _testSetState({ 212 session, 213 loading: false, 214 error: null, 215 - }) 216 - return session 217 } 218 export function setupUnauthenticatedUser(): void { 219 _testSetState({ 220 session: null, 221 loading: false, 222 error: null, 223 - }) 224 }
··· 1 + import { vi } from "vitest"; 2 + import type { AppPassword, InviteCode, Session } from "../lib/api"; 3 + import { _testSetState } from "../lib/auth.svelte"; 4 export interface MockResponse { 5 + ok: boolean; 6 + status: number; 7 + json: () => Promise<unknown>; 8 } 9 + export type MockHandler = ( 10 + url: string, 11 + options?: RequestInit, 12 + ) => MockResponse | Promise<MockResponse>; 13 + const mockHandlers: Map<string, MockHandler> = new Map(); 14 export function mockEndpoint(endpoint: string, handler: MockHandler): void { 15 + mockHandlers.set(endpoint, handler); 16 } 17 export function mockEndpointOnce(endpoint: string, handler: MockHandler): void { 18 + const originalHandler = mockHandlers.get(endpoint); 19 mockHandlers.set(endpoint, (url, options) => { 20 + mockHandlers.set(endpoint, originalHandler!); 21 + return handler(url, options); 22 + }); 23 } 24 export function clearMocks(): void { 25 + mockHandlers.clear(); 26 } 27 function extractEndpoint(url: string): string { 28 + const match = url.match(/\/xrpc\/([^?]+)/); 29 + return match ? match[1] : url; 30 } 31 export function setupFetchMock(): void { 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 + } 57 return { 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 + }), 69 headers: new Headers(), 70 redirected: false, 71 + statusText: "Not Found", 72 + type: "basic", 73 url, 74 + clone: function () { 75 + return this; 76 + }, 77 body: null, 78 bodyUsed: false, 79 arrayBuffer: async () => new ArrayBuffer(0), 80 blob: async () => new Blob(), 81 formData: async () => new FormData(), 82 + } as Response; 83 + }, 84 + ); 85 } 86 export function jsonResponse<T>(data: T, status = 200): MockResponse { 87 return { 88 ok: status >= 200 && status < 300, 89 status, 90 json: async () => data, 91 + }; 92 } 93 + export function errorResponse( 94 + error: string, 95 + message: string, 96 + status = 400, 97 + ): MockResponse { 98 return { 99 ok: false, 100 status, 101 json: async () => ({ error, message }), 102 + }; 103 } 104 export const mockData = { 105 session: (overrides?: Partial<Session>): Session => ({ 106 + did: "did:web:test.tranquil.dev:u:testuser", 107 + handle: "testuser.test.tranquil.dev", 108 + email: "test@example.com", 109 emailConfirmed: true, 110 + accessJwt: "mock-access-jwt-token", 111 + refreshJwt: "mock-refresh-jwt-token", 112 ...overrides, 113 }), 114 appPassword: (overrides?: Partial<AppPassword>): AppPassword => ({ 115 + name: "Test App", 116 createdAt: new Date().toISOString(), 117 ...overrides, 118 }), 119 inviteCode: (overrides?: Partial<InviteCode>): InviteCode => ({ 120 + code: "test-invite-123", 121 available: 1, 122 disabled: false, 123 + forAccount: "did:web:test.tranquil.dev:u:testuser", 124 + createdBy: "did:web:test.tranquil.dev:u:testuser", 125 createdAt: new Date().toISOString(), 126 uses: [], 127 ...overrides, 128 }), 129 notificationPrefs: (overrides?: Record<string, unknown>) => ({ 130 + preferredChannel: "email", 131 + email: "test@example.com", 132 discordId: null, 133 discordVerified: false, 134 telegramUsername: null, ··· 138 ...overrides, 139 }), 140 describeServer: () => ({ 141 + availableUserDomains: ["test.tranquil.dev"], 142 inviteCodeRequired: false, 143 links: { 144 + privacyPolicy: "https://example.com/privacy", 145 + termsOfService: "https://example.com/tos", 146 }, 147 }), 148 describeRepo: (did: string) => ({ 149 + handle: "testuser.test.tranquil.dev", 150 did, 151 didDoc: {}, 152 + collections: [ 153 + "app.bsky.feed.post", 154 + "app.bsky.feed.like", 155 + "app.bsky.graph.follow", 156 + ], 157 handleIsCorrect: true, 158 }), 159 + }; 160 export function setupDefaultMocks(): void { 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 + ); 172 } 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) || "{}"); 190 return jsonResponse({ 191 name: body.name, 192 + password: "xxxx-xxxx-xxxx-xxxx", 193 createdAt: new Date().toISOString(), 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 + ); 237 } 238 + export function setupAuthenticatedUser( 239 + sessionOverrides?: Partial<Session>, 240 + ): Session { 241 + const session = mockData.session(sessionOverrides); 242 _testSetState({ 243 session, 244 loading: false, 245 error: null, 246 + }); 247 + return session; 248 } 249 export function setupUnauthenticatedUser(): void { 250 _testSetState({ 251 session: null, 252 loading: false, 253 error: null, 254 + }); 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' 4 5 - let locationHash = '' 6 7 - Object.defineProperty(window, 'location', { 8 value: { 9 - get hash() { return locationHash }, 10 set hash(value: string) { 11 - locationHash = value.startsWith('#') ? value : `#${value}` 12 }, 13 - href: 'http://localhost:3000/', 14 - origin: 'http://localhost:3000', 15 - pathname: '/', 16 - search: '', 17 assign: vi.fn(), 18 replace: vi.fn(), 19 reload: vi.fn(), 20 }, 21 writable: true, 22 configurable: true, 23 - }) 24 25 beforeEach(() => { 26 - vi.clearAllMocks() 27 - localStorage.clear() 28 - sessionStorage.clear() 29 - locationHash = '' 30 - _testReset() 31 - }) 32 33 afterEach(() => { 34 - vi.restoreAllMocks() 35 - })
··· 1 + import "@testing-library/jest-dom/vitest"; 2 + import { afterEach, beforeEach, vi } from "vitest"; 3 + import { _testReset } from "../lib/auth.svelte"; 4 5 + let locationHash = ""; 6 7 + Object.defineProperty(window, "location", { 8 value: { 9 + get hash() { 10 + return locationHash; 11 + }, 12 set hash(value: string) { 13 + locationHash = value.startsWith("#") ? value : `#${value}`; 14 }, 15 + href: "http://localhost:3000/", 16 + origin: "http://localhost:3000", 17 + pathname: "/", 18 + search: "", 19 assign: vi.fn(), 20 replace: vi.fn(), 21 reload: vi.fn(), 22 }, 23 writable: true, 24 configurable: true, 25 + }); 26 27 beforeEach(() => { 28 + vi.clearAllMocks(); 29 + localStorage.clear(); 30 + sessionStorage.clear(); 31 + locationHash = ""; 32 + _testReset(); 33 + }); 34 35 afterEach(() => { 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' 4 5 export async function renderAndWait<T extends ComponentType>( 6 component: T, 7 - options?: Parameters<typeof render>[1] 8 ): Promise<RenderResult<T>> { 9 - const result = render(component, options) 10 - await tick() 11 - await new Promise(resolve => setTimeout(resolve, 0)) 12 - return result 13 } 14 15 export async function waitForElement( 16 queryFn: () => HTMLElement | null, 17 - timeout = 1000 18 ): Promise<HTMLElement> { 19 - const start = Date.now() 20 while (Date.now() - start < timeout) { 21 - const element = queryFn() 22 - if (element) return element 23 - await new Promise(resolve => setTimeout(resolve, 10)) 24 } 25 - throw new Error('Element not found within timeout') 26 } 27 28 export async function waitForElementToDisappear( 29 queryFn: () => HTMLElement | null, 30 - timeout = 1000 31 ): Promise<void> { 32 - const start = Date.now() 33 while (Date.now() - start < timeout) { 34 - const element = queryFn() 35 - if (!element) return 36 - await new Promise(resolve => setTimeout(resolve, 10)) 37 } 38 - throw new Error('Element still present after timeout') 39 } 40 41 export async function waitForText( 42 container: HTMLElement, 43 text: string | RegExp, 44 - timeout = 1000 45 ): Promise<void> { 46 - const start = Date.now() 47 while (Date.now() - start < timeout) { 48 - const content = container.textContent || '' 49 - if (typeof text === 'string' ? content.includes(text) : text.test(content)) { 50 - return 51 } 52 - await new Promise(resolve => setTimeout(resolve, 10)) 53 } 54 - throw new Error(`Text "${text}" not found within timeout`) 55 } 56 57 - export function mockLocalStorage(initialData: Record<string, string> = {}): void { 58 - const store: Record<string, string> = { ...initialData } 59 - Object.defineProperty(window, 'localStorage', { 60 value: { 61 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]) }, 65 key: (index: number) => Object.keys(store)[index] || null, 66 - get length() { return Object.keys(store).length }, 67 }, 68 writable: true, 69 - }) 70 } 71 72 export function setAuthState(session: { 73 - did: string 74 - handle: string 75 - email?: string 76 - emailConfirmed?: boolean 77 - accessJwt: string 78 - refreshJwt: string 79 }): void { 80 - localStorage.setItem('session', JSON.stringify(session)) 81 } 82 83 export function clearAuthState(): void { 84 - localStorage.removeItem('session') 85 }
··· 1 + import { render, type RenderResult } from "@testing-library/svelte"; 2 + import { tick } from "svelte"; 3 + import type { ComponentType } from "svelte"; 4 5 export async function renderAndWait<T extends ComponentType>( 6 component: T, 7 + options?: Parameters<typeof render>[1], 8 ): Promise<RenderResult<T>> { 9 + const result = render(component, options); 10 + await tick(); 11 + await new Promise((resolve) => setTimeout(resolve, 0)); 12 + return result; 13 } 14 15 export async function waitForElement( 16 queryFn: () => HTMLElement | null, 17 + timeout = 1000, 18 ): Promise<HTMLElement> { 19 + const start = Date.now(); 20 while (Date.now() - start < timeout) { 21 + const element = queryFn(); 22 + if (element) return element; 23 + await new Promise((resolve) => setTimeout(resolve, 10)); 24 } 25 + throw new Error("Element not found within timeout"); 26 } 27 28 export async function waitForElementToDisappear( 29 queryFn: () => HTMLElement | null, 30 + timeout = 1000, 31 ): Promise<void> { 32 + const start = Date.now(); 33 while (Date.now() - start < timeout) { 34 + const element = queryFn(); 35 + if (!element) return; 36 + await new Promise((resolve) => setTimeout(resolve, 10)); 37 } 38 + throw new Error("Element still present after timeout"); 39 } 40 41 export async function waitForText( 42 container: HTMLElement, 43 text: string | RegExp, 44 + timeout = 1000, 45 ): Promise<void> { 46 + const start = Date.now(); 47 while (Date.now() - start < timeout) { 48 + const content = container.textContent || ""; 49 + if ( 50 + typeof text === "string" ? content.includes(text) : text.test(content) 51 + ) { 52 + return; 53 } 54 + await new Promise((resolve) => setTimeout(resolve, 10)); 55 } 56 + throw new Error(`Text "${text}" not found within timeout`); 57 } 58 59 + export function mockLocalStorage( 60 + initialData: Record<string, string> = {}, 61 + ): void { 62 + const store: Record<string, string> = { ...initialData }; 63 + Object.defineProperty(window, "localStorage", { 64 value: { 65 getItem: (key: string) => store[key] || null, 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 + }, 75 key: (index: number) => Object.keys(store)[index] || null, 76 + get length() { 77 + return Object.keys(store).length; 78 + }, 79 }, 80 writable: true, 81 + }); 82 } 83 84 export function setAuthState(session: { 85 + did: string; 86 + handle: string; 87 + email?: string; 88 + emailConfirmed?: boolean; 89 + accessJwt: string; 90 + refreshJwt: string; 91 }): void { 92 + localStorage.setItem("session", JSON.stringify(session)); 93 } 94 95 export function clearAuthState(): void { 96 + localStorage.removeItem("session"); 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 3 export default { 4 preprocess: isTest ? [] : vitePreprocess(), 5 - }
··· 1 + import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; 2 + const isTest = process.env.VITEST === "true" || process.env.VITEST === true; 3 export default { 4 preprocess: isTest ? [] : vitePreprocess(), 5 + };
+14 -14
frontend/vite.config.ts
··· 1 - import { defineConfig, loadEnv } from 'vite' 2 - import { svelte } from '@sveltejs/vite-plugin-svelte' 3 4 export default defineConfig(({ mode }) => { 5 - const env = loadEnv(mode, process.cwd(), '') 6 - const target = env.VITE_API_URL || 'http://localhost:3000' 7 8 return { 9 plugins: [svelte()], 10 build: { 11 - outDir: 'dist', 12 }, 13 server: { 14 port: 5173, 15 proxy: { 16 - '/xrpc': target, 17 - '/oauth': target, 18 - '/.well-known': target, 19 - '/health': target, 20 - '/u': target, 21 - } 22 - } 23 - } 24 - })
··· 1 + import { defineConfig, loadEnv } from "vite"; 2 + import { svelte } from "@sveltejs/vite-plugin-svelte"; 3 4 export default defineConfig(({ mode }) => { 5 + const env = loadEnv(mode, process.cwd(), ""); 6 + const target = env.VITE_API_URL || "http://localhost:3000"; 7 8 return { 9 plugins: [svelte()], 10 build: { 11 + outDir: "dist", 12 }, 13 server: { 14 port: 5173, 15 proxy: { 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' 3 export default defineConfig({ 4 plugins: [ 5 svelte({ ··· 7 }), 8 ], 9 resolve: { 10 - conditions: ['browser', 'development'], 11 }, 12 test: { 13 - environment: 'jsdom', 14 globals: true, 15 - setupFiles: ['./src/tests/setup.ts'], 16 - include: ['src/**/*.{test,spec}.{js,ts}'], 17 alias: { 18 - 'svelte': 'svelte', 19 }, 20 }, 21 - })
··· 1 + import { defineConfig } from "vitest/config"; 2 + import { svelte } from "@sveltejs/vite-plugin-svelte"; 3 export default defineConfig({ 4 plugins: [ 5 svelte({ ··· 7 }), 8 ], 9 resolve: { 10 + conditions: ["browser", "development"], 11 }, 12 test: { 13 + environment: "jsdom", 14 globals: true, 15 + setupFiles: ["./src/tests/setup.ts"], 16 + include: ["src/**/*.{test,spec}.{js,ts}"], 17 alias: { 18 + "svelte": "svelte", 19 }, 20 }, 21 + });
+1 -4
src/oauth/client.rs
··· 126 client_uri: None, 127 logo_uri: None, 128 redirect_uris, 129 - grant_types: vec![ 130 - "authorization_code".into(), 131 - "refresh_token".into(), 132 - ], 133 response_types: vec!["code".into()], 134 scope, 135 token_endpoint_auth_method: Some("none".into()),
··· 126 client_uri: None, 127 logo_uri: None, 128 redirect_uris, 129 + grant_types: vec!["authorization_code".into(), "refresh_token".into()], 130 response_types: vec!["code".into()], 131 scope, 132 token_endpoint_auth_method: Some("none".into()),