+12
-3
frontend/index.html
+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
-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
-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
-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
+3
-3
frontend/src/components/ui/Page.svelte
+6
-6
frontend/src/components/ui/index.ts
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+6
-6
frontend/src/main.ts
+11
-5
frontend/src/routes/Admin.svelte
+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
+1
-1
frontend/src/routes/AppPasswords.svelte
+274
-216
frontend/src/routes/Comms.svelte
+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
+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
+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
+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
+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
-
×
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">·</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
+
×
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">·</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
+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
+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
+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
+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
+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
+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')} →
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')} →
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
+1
-1
frontend/src/routes/Sessions.svelte
+45
-4
frontend/src/routes/Settings.svelte
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+3
-3
frontend/svelte.config.js
+14
-14
frontend/vite.config.ts
+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
+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
+1
-4
src/oauth/client.rs