+12
-3
frontend/index.html
+12
-3
frontend/index.html
···
6
6
<title>Tranquil PDS</title>
7
7
<link rel="preconnect" href="https://fonts.googleapis.com">
8
8
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
-
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
9
+
<link
10
+
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap"
11
+
rel="stylesheet"
12
+
>
10
13
<style>
11
-
html { background: #ffffff; }
12
-
@media (prefers-color-scheme: dark) { html { background: #0a0a0a; } }
14
+
html {
15
+
background: #f9fafa;
16
+
}
17
+
@media (prefers-color-scheme: dark) {
18
+
html {
19
+
background: #0a0c0c;
20
+
}
21
+
}
13
22
</style>
14
23
</head>
15
24
<body>
-650
frontend/mockups/01-article-style.html
-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
···
44
44
padding: var(--space-7);
45
45
}
46
46
47
-
.page-sm { max-width: var(--width-sm); }
48
-
.page-md { max-width: var(--width-md); }
49
-
.page-lg { max-width: var(--width-lg); }
47
+
.page-sm { max-width: var(--width-md); }
48
+
.page-md { max-width: var(--width-lg); }
49
+
.page-lg { max-width: var(--width-xl); }
50
50
51
51
header {
52
52
margin-bottom: var(--space-7);
+6
-6
frontend/src/components/ui/index.ts
+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'
1
+
const API_BASE = "/xrpc";
2
2
3
3
export class ApiError extends Error {
4
-
public did?: string
5
-
public reauthMethods?: string[]
6
-
constructor(public status: number, public error: string, message: string, did?: string, reauthMethods?: string[]) {
7
-
super(message)
8
-
this.name = 'ApiError'
9
-
this.did = did
10
-
this.reauthMethods = reauthMethods
4
+
public did?: string;
5
+
public reauthMethods?: string[];
6
+
constructor(
7
+
public status: number,
8
+
public error: string,
9
+
message: string,
10
+
did?: string,
11
+
reauthMethods?: string[],
12
+
) {
13
+
super(message);
14
+
this.name = "ApiError";
15
+
this.did = did;
16
+
this.reauthMethods = reauthMethods;
11
17
}
12
18
}
13
19
14
-
let tokenRefreshCallback: (() => Promise<string | null>) | null = null
20
+
let tokenRefreshCallback: (() => Promise<string | null>) | null = null;
15
21
16
-
export function setTokenRefreshCallback(callback: () => Promise<string | null>) {
17
-
tokenRefreshCallback = callback
22
+
export function setTokenRefreshCallback(
23
+
callback: () => Promise<string | null>,
24
+
) {
25
+
tokenRefreshCallback = callback;
18
26
}
19
27
20
28
async function xrpc<T>(method: string, options?: {
21
-
method?: 'GET' | 'POST'
22
-
params?: Record<string, string>
23
-
body?: unknown
24
-
token?: string
25
-
skipRetry?: boolean
29
+
method?: "GET" | "POST";
30
+
params?: Record<string, string>;
31
+
body?: unknown;
32
+
token?: string;
33
+
skipRetry?: boolean;
26
34
}): Promise<T> {
27
-
const { method: httpMethod = 'GET', params, body, token, skipRetry } = options ?? {}
28
-
let url = `${API_BASE}/${method}`
35
+
const { method: httpMethod = "GET", params, body, token, skipRetry } =
36
+
options ?? {};
37
+
let url = `${API_BASE}/${method}`;
29
38
if (params) {
30
-
const searchParams = new URLSearchParams(params)
31
-
url += `?${searchParams}`
39
+
const searchParams = new URLSearchParams(params);
40
+
url += `?${searchParams}`;
32
41
}
33
-
const headers: Record<string, string> = {}
42
+
const headers: Record<string, string> = {};
34
43
if (token) {
35
-
headers['Authorization'] = `Bearer ${token}`
44
+
headers["Authorization"] = `Bearer ${token}`;
36
45
}
37
46
if (body) {
38
-
headers['Content-Type'] = 'application/json'
47
+
headers["Content-Type"] = "application/json";
39
48
}
40
49
const res = await fetch(url, {
41
50
method: httpMethod,
42
51
headers,
43
52
body: body ? JSON.stringify(body) : undefined,
44
-
})
53
+
});
45
54
if (!res.ok) {
46
-
const err = await res.json().catch(() => ({ error: 'Unknown', message: res.statusText }))
47
-
if (res.status === 401 && err.error === 'AuthenticationFailed' && token && tokenRefreshCallback && !skipRetry) {
48
-
const newToken = await tokenRefreshCallback()
55
+
const err = await res.json().catch(() => ({
56
+
error: "Unknown",
57
+
message: res.statusText,
58
+
}));
59
+
if (
60
+
res.status === 401 && err.error === "AuthenticationFailed" && token &&
61
+
tokenRefreshCallback && !skipRetry
62
+
) {
63
+
const newToken = await tokenRefreshCallback();
49
64
if (newToken && newToken !== token) {
50
-
return xrpc(method, { ...options, token: newToken, skipRetry: true })
65
+
return xrpc(method, { ...options, token: newToken, skipRetry: true });
51
66
}
52
67
}
53
-
throw new ApiError(res.status, err.error, err.message, err.did, err.reauthMethods)
68
+
throw new ApiError(
69
+
res.status,
70
+
err.error,
71
+
err.message,
72
+
err.did,
73
+
err.reauthMethods,
74
+
);
54
75
}
55
-
return res.json()
76
+
return res.json();
56
77
}
57
78
58
79
export interface Session {
59
-
did: string
60
-
handle: string
61
-
email?: string
62
-
emailConfirmed?: boolean
63
-
preferredChannel?: string
64
-
preferredChannelVerified?: boolean
65
-
isAdmin?: boolean
66
-
active?: boolean
67
-
status?: 'active' | 'deactivated'
68
-
accessJwt: string
69
-
refreshJwt: string
80
+
did: string;
81
+
handle: string;
82
+
email?: string;
83
+
emailConfirmed?: boolean;
84
+
preferredChannel?: string;
85
+
preferredChannelVerified?: boolean;
86
+
isAdmin?: boolean;
87
+
active?: boolean;
88
+
status?: "active" | "deactivated";
89
+
accessJwt: string;
90
+
refreshJwt: string;
70
91
}
71
92
72
93
export interface AppPassword {
73
-
name: string
74
-
createdAt: string
94
+
name: string;
95
+
createdAt: string;
75
96
}
76
97
77
98
export interface InviteCode {
78
-
code: string
79
-
available: number
80
-
disabled: boolean
81
-
forAccount: string
82
-
createdBy: string
83
-
createdAt: string
84
-
uses: { usedBy: string; usedAt: string }[]
99
+
code: string;
100
+
available: number;
101
+
disabled: boolean;
102
+
forAccount: string;
103
+
createdBy: string;
104
+
createdAt: string;
105
+
uses: { usedBy: string; usedAt: string }[];
85
106
}
86
107
87
-
export type VerificationChannel = 'email' | 'discord' | 'telegram' | 'signal'
108
+
export type VerificationChannel = "email" | "discord" | "telegram" | "signal";
88
109
89
-
export type DidType = 'plc' | 'web' | 'web-external'
110
+
export type DidType = "plc" | "web" | "web-external";
90
111
91
112
export interface CreateAccountParams {
92
-
handle: string
93
-
email: string
94
-
password: string
95
-
inviteCode?: string
96
-
didType?: DidType
97
-
did?: string
98
-
signingKey?: string
99
-
verificationChannel?: VerificationChannel
100
-
discordId?: string
101
-
telegramUsername?: string
102
-
signalNumber?: string
113
+
handle: string;
114
+
email: string;
115
+
password: string;
116
+
inviteCode?: string;
117
+
didType?: DidType;
118
+
did?: string;
119
+
signingKey?: string;
120
+
verificationChannel?: VerificationChannel;
121
+
discordId?: string;
122
+
telegramUsername?: string;
123
+
signalNumber?: string;
103
124
}
104
125
105
126
export interface CreateAccountResult {
106
-
handle: string
107
-
did: string
108
-
verificationRequired: boolean
109
-
verificationChannel: string
127
+
handle: string;
128
+
did: string;
129
+
verificationRequired: boolean;
130
+
verificationChannel: string;
110
131
}
111
132
112
133
export interface ConfirmSignupResult {
113
-
accessJwt: string
114
-
refreshJwt: string
115
-
handle: string
116
-
did: string
117
-
email?: string
118
-
emailConfirmed?: boolean
119
-
preferredChannel?: string
120
-
preferredChannelVerified?: boolean
134
+
accessJwt: string;
135
+
refreshJwt: string;
136
+
handle: string;
137
+
did: string;
138
+
email?: string;
139
+
emailConfirmed?: boolean;
140
+
preferredChannel?: string;
141
+
preferredChannelVerified?: boolean;
121
142
}
122
143
123
144
export const api = {
124
-
async createAccount(params: CreateAccountParams, byodToken?: string): Promise<CreateAccountResult> {
125
-
const url = `${API_BASE}/com.atproto.server.createAccount`
126
-
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
145
+
async createAccount(
146
+
params: CreateAccountParams,
147
+
byodToken?: string,
148
+
): Promise<CreateAccountResult> {
149
+
const url = `${API_BASE}/com.atproto.server.createAccount`;
150
+
const headers: Record<string, string> = {
151
+
"Content-Type": "application/json",
152
+
};
127
153
if (byodToken) {
128
-
headers['Authorization'] = `Bearer ${byodToken}`
154
+
headers["Authorization"] = `Bearer ${byodToken}`;
129
155
}
130
156
const response = await fetch(url, {
131
-
method: 'POST',
157
+
method: "POST",
132
158
headers,
133
159
body: JSON.stringify({
134
160
handle: params.handle,
···
143
169
telegramUsername: params.telegramUsername,
144
170
signalNumber: params.signalNumber,
145
171
}),
146
-
})
147
-
const data = await response.json()
172
+
});
173
+
const data = await response.json();
148
174
if (!response.ok) {
149
-
throw new ApiError(data.error, data.message, response.status)
175
+
throw new ApiError(data.error, data.message, response.status);
150
176
}
151
-
return data
177
+
return data;
152
178
},
153
179
154
-
async confirmSignup(did: string, verificationCode: string): Promise<ConfirmSignupResult> {
155
-
return xrpc('com.atproto.server.confirmSignup', {
156
-
method: 'POST',
180
+
async confirmSignup(
181
+
did: string,
182
+
verificationCode: string,
183
+
): Promise<ConfirmSignupResult> {
184
+
return xrpc("com.atproto.server.confirmSignup", {
185
+
method: "POST",
157
186
body: { did, verificationCode },
158
-
})
187
+
});
159
188
},
160
189
161
190
async resendVerification(did: string): Promise<{ success: boolean }> {
162
-
return xrpc('com.atproto.server.resendVerification', {
163
-
method: 'POST',
191
+
return xrpc("com.atproto.server.resendVerification", {
192
+
method: "POST",
164
193
body: { did },
165
-
})
194
+
});
166
195
},
167
196
168
197
async createSession(identifier: string, password: string): Promise<Session> {
169
-
return xrpc('com.atproto.server.createSession', {
170
-
method: 'POST',
198
+
return xrpc("com.atproto.server.createSession", {
199
+
method: "POST",
171
200
body: { identifier, password },
172
-
})
201
+
});
173
202
},
174
203
175
204
async getSession(token: string): Promise<Session> {
176
-
return xrpc('com.atproto.server.getSession', { token })
205
+
return xrpc("com.atproto.server.getSession", { token });
177
206
},
178
207
179
208
async refreshSession(refreshJwt: string): Promise<Session> {
180
-
return xrpc('com.atproto.server.refreshSession', {
181
-
method: 'POST',
209
+
return xrpc("com.atproto.server.refreshSession", {
210
+
method: "POST",
182
211
token: refreshJwt,
183
-
})
212
+
});
184
213
},
185
214
186
215
async deleteSession(token: string): Promise<void> {
187
-
await xrpc('com.atproto.server.deleteSession', {
188
-
method: 'POST',
216
+
await xrpc("com.atproto.server.deleteSession", {
217
+
method: "POST",
189
218
token,
190
-
})
219
+
});
191
220
},
192
221
193
222
async listAppPasswords(token: string): Promise<{ passwords: AppPassword[] }> {
194
-
return xrpc('com.atproto.server.listAppPasswords', { token })
223
+
return xrpc("com.atproto.server.listAppPasswords", { token });
195
224
},
196
225
197
-
async createAppPassword(token: string, name: string): Promise<{ name: string; password: string; createdAt: string }> {
198
-
return xrpc('com.atproto.server.createAppPassword', {
199
-
method: 'POST',
226
+
async createAppPassword(
227
+
token: string,
228
+
name: string,
229
+
): Promise<{ name: string; password: string; createdAt: string }> {
230
+
return xrpc("com.atproto.server.createAppPassword", {
231
+
method: "POST",
200
232
token,
201
233
body: { name },
202
-
})
234
+
});
203
235
},
204
236
205
237
async revokeAppPassword(token: string, name: string): Promise<void> {
206
-
await xrpc('com.atproto.server.revokeAppPassword', {
207
-
method: 'POST',
238
+
await xrpc("com.atproto.server.revokeAppPassword", {
239
+
method: "POST",
208
240
token,
209
241
body: { name },
210
-
})
242
+
});
211
243
},
212
244
213
245
async getAccountInviteCodes(token: string): Promise<{ codes: InviteCode[] }> {
214
-
return xrpc('com.atproto.server.getAccountInviteCodes', { token })
246
+
return xrpc("com.atproto.server.getAccountInviteCodes", { token });
215
247
},
216
248
217
-
async createInviteCode(token: string, useCount: number = 1): Promise<{ code: string }> {
218
-
return xrpc('com.atproto.server.createInviteCode', {
219
-
method: 'POST',
249
+
async createInviteCode(
250
+
token: string,
251
+
useCount: number = 1,
252
+
): Promise<{ code: string }> {
253
+
return xrpc("com.atproto.server.createInviteCode", {
254
+
method: "POST",
220
255
token,
221
256
body: { useCount },
222
-
})
257
+
});
223
258
},
224
259
225
260
async requestPasswordReset(email: string): Promise<void> {
226
-
await xrpc('com.atproto.server.requestPasswordReset', {
227
-
method: 'POST',
261
+
await xrpc("com.atproto.server.requestPasswordReset", {
262
+
method: "POST",
228
263
body: { email },
229
-
})
264
+
});
230
265
},
231
266
232
267
async resetPassword(token: string, password: string): Promise<void> {
233
-
await xrpc('com.atproto.server.resetPassword', {
234
-
method: 'POST',
268
+
await xrpc("com.atproto.server.resetPassword", {
269
+
method: "POST",
235
270
body: { token, password },
236
-
})
271
+
});
237
272
},
238
273
239
-
async requestEmailUpdate(token: string, email: string): Promise<{ tokenRequired: boolean }> {
240
-
return xrpc('com.atproto.server.requestEmailUpdate', {
241
-
method: 'POST',
274
+
async requestEmailUpdate(
275
+
token: string,
276
+
email: string,
277
+
): Promise<{ tokenRequired: boolean }> {
278
+
return xrpc("com.atproto.server.requestEmailUpdate", {
279
+
method: "POST",
242
280
token,
243
281
body: { email },
244
-
})
282
+
});
245
283
},
246
284
247
-
async updateEmail(token: string, email: string, emailToken?: string): Promise<void> {
248
-
await xrpc('com.atproto.server.updateEmail', {
249
-
method: 'POST',
285
+
async updateEmail(
286
+
token: string,
287
+
email: string,
288
+
emailToken?: string,
289
+
): Promise<void> {
290
+
await xrpc("com.atproto.server.updateEmail", {
291
+
method: "POST",
250
292
token,
251
293
body: { email, token: emailToken },
252
-
})
294
+
});
253
295
},
254
296
255
297
async updateHandle(token: string, handle: string): Promise<void> {
256
-
await xrpc('com.atproto.identity.updateHandle', {
257
-
method: 'POST',
298
+
await xrpc("com.atproto.identity.updateHandle", {
299
+
method: "POST",
258
300
token,
259
301
body: { handle },
260
-
})
302
+
});
261
303
},
262
304
263
305
async requestAccountDelete(token: string): Promise<void> {
264
-
await xrpc('com.atproto.server.requestAccountDelete', {
265
-
method: 'POST',
306
+
await xrpc("com.atproto.server.requestAccountDelete", {
307
+
method: "POST",
266
308
token,
267
-
})
309
+
});
268
310
},
269
311
270
-
async deleteAccount(did: string, password: string, deleteToken: string): Promise<void> {
271
-
await xrpc('com.atproto.server.deleteAccount', {
272
-
method: 'POST',
312
+
async deleteAccount(
313
+
did: string,
314
+
password: string,
315
+
deleteToken: string,
316
+
): Promise<void> {
317
+
await xrpc("com.atproto.server.deleteAccount", {
318
+
method: "POST",
273
319
body: { did, password, token: deleteToken },
274
-
})
320
+
});
275
321
},
276
322
277
323
async describeServer(): Promise<{
278
-
availableUserDomains: string[]
279
-
inviteCodeRequired: boolean
280
-
links?: { privacyPolicy?: string; termsOfService?: string }
281
-
version?: string
282
-
availableCommsChannels?: string[]
324
+
availableUserDomains: string[];
325
+
inviteCodeRequired: boolean;
326
+
links?: { privacyPolicy?: string; termsOfService?: string };
327
+
version?: string;
328
+
availableCommsChannels?: string[];
283
329
}> {
284
-
return xrpc('com.atproto.server.describeServer')
330
+
return xrpc("com.atproto.server.describeServer");
285
331
},
286
332
287
333
async listRepos(limit?: number): Promise<{
288
-
repos: Array<{ did: string; head: string; rev: string }>
289
-
cursor?: string
334
+
repos: Array<{ did: string; head: string; rev: string }>;
335
+
cursor?: string;
290
336
}> {
291
-
const params: Record<string, string> = {}
292
-
if (limit) params.limit = String(limit)
293
-
return xrpc('com.atproto.sync.listRepos', { params })
337
+
const params: Record<string, string> = {};
338
+
if (limit) params.limit = String(limit);
339
+
return xrpc("com.atproto.sync.listRepos", { params });
294
340
},
295
341
296
342
async getNotificationPrefs(token: string): Promise<{
297
-
preferredChannel: string
298
-
email: string
299
-
discordId: string | null
300
-
discordVerified: boolean
301
-
telegramUsername: string | null
302
-
telegramVerified: boolean
303
-
signalNumber: string | null
304
-
signalVerified: boolean
343
+
preferredChannel: string;
344
+
email: string;
345
+
discordId: string | null;
346
+
discordVerified: boolean;
347
+
telegramUsername: string | null;
348
+
telegramVerified: boolean;
349
+
signalNumber: string | null;
350
+
signalVerified: boolean;
305
351
}> {
306
-
return xrpc('com.tranquil.account.getNotificationPrefs', { token })
352
+
return xrpc("com.tranquil.account.getNotificationPrefs", { token });
307
353
},
308
354
309
355
async updateNotificationPrefs(token: string, prefs: {
310
-
preferredChannel?: string
311
-
discordId?: string
312
-
telegramUsername?: string
313
-
signalNumber?: string
356
+
preferredChannel?: string;
357
+
discordId?: string;
358
+
telegramUsername?: string;
359
+
signalNumber?: string;
314
360
}): Promise<{ success: boolean }> {
315
-
return xrpc('com.tranquil.account.updateNotificationPrefs', {
316
-
method: 'POST',
361
+
return xrpc("com.tranquil.account.updateNotificationPrefs", {
362
+
method: "POST",
317
363
token,
318
364
body: prefs,
319
-
})
365
+
});
320
366
},
321
367
322
-
async confirmChannelVerification(token: string, channel: string, identifier: string, code: string): Promise<{ success: boolean }> {
323
-
return xrpc('com.tranquil.account.confirmChannelVerification', {
324
-
method: 'POST',
368
+
async confirmChannelVerification(
369
+
token: string,
370
+
channel: string,
371
+
identifier: string,
372
+
code: string,
373
+
): Promise<{ success: boolean }> {
374
+
return xrpc("com.tranquil.account.confirmChannelVerification", {
375
+
method: "POST",
325
376
token,
326
377
body: { channel, identifier, code },
327
-
})
378
+
});
328
379
},
329
380
330
381
async getNotificationHistory(token: string): Promise<{
331
382
notifications: Array<{
332
-
createdAt: string
333
-
channel: string
334
-
notificationType: string
335
-
status: string
336
-
subject: string | null
337
-
body: string
338
-
}>
383
+
createdAt: string;
384
+
channel: string;
385
+
notificationType: string;
386
+
status: string;
387
+
subject: string | null;
388
+
body: string;
389
+
}>;
339
390
}> {
340
-
return xrpc('com.tranquil.account.getNotificationHistory', { token })
391
+
return xrpc("com.tranquil.account.getNotificationHistory", { token });
341
392
},
342
393
343
394
async getServerStats(token: string): Promise<{
344
-
userCount: number
345
-
repoCount: number
346
-
recordCount: number
347
-
blobStorageBytes: number
395
+
userCount: number;
396
+
repoCount: number;
397
+
recordCount: number;
398
+
blobStorageBytes: number;
348
399
}> {
349
-
return xrpc('com.tranquil.admin.getServerStats', { token })
400
+
return xrpc("com.tranquil.admin.getServerStats", { token });
350
401
},
351
402
352
403
async getServerConfig(): Promise<{
353
-
serverName: string
354
-
primaryColor: string | null
355
-
primaryColorDark: string | null
356
-
secondaryColor: string | null
357
-
secondaryColorDark: string | null
358
-
logoCid: string | null
404
+
serverName: string;
405
+
primaryColor: string | null;
406
+
primaryColorDark: string | null;
407
+
secondaryColor: string | null;
408
+
secondaryColorDark: string | null;
409
+
logoCid: string | null;
359
410
}> {
360
-
return xrpc('com.tranquil.server.getConfig')
411
+
return xrpc("com.tranquil.server.getConfig");
361
412
},
362
413
363
414
async updateServerConfig(
364
415
token: string,
365
416
config: {
366
-
serverName?: string
367
-
primaryColor?: string
368
-
primaryColorDark?: string
369
-
secondaryColor?: string
370
-
secondaryColorDark?: string
371
-
logoCid?: string
372
-
}
417
+
serverName?: string;
418
+
primaryColor?: string;
419
+
primaryColorDark?: string;
420
+
secondaryColor?: string;
421
+
secondaryColorDark?: string;
422
+
logoCid?: string;
423
+
},
373
424
): Promise<{ success: boolean }> {
374
-
return xrpc('com.tranquil.admin.updateServerConfig', {
375
-
method: 'POST',
425
+
return xrpc("com.tranquil.admin.updateServerConfig", {
426
+
method: "POST",
376
427
token,
377
428
body: config,
378
-
})
429
+
});
379
430
},
380
431
381
-
async uploadBlob(token: string, file: File): Promise<{ blob: { $type: string; ref: { $link: string }; mimeType: string; size: number } }> {
382
-
const res = await fetch('/xrpc/com.atproto.repo.uploadBlob', {
383
-
method: 'POST',
432
+
async uploadBlob(
433
+
token: string,
434
+
file: File,
435
+
): Promise<
436
+
{
437
+
blob: {
438
+
$type: string;
439
+
ref: { $link: string };
440
+
mimeType: string;
441
+
size: number;
442
+
};
443
+
}
444
+
> {
445
+
const res = await fetch("/xrpc/com.atproto.repo.uploadBlob", {
446
+
method: "POST",
384
447
headers: {
385
-
'Authorization': `Bearer ${token}`,
386
-
'Content-Type': file.type,
448
+
"Authorization": `Bearer ${token}`,
449
+
"Content-Type": file.type,
387
450
},
388
451
body: file,
389
-
})
452
+
});
390
453
if (!res.ok) {
391
-
const err = await res.json().catch(() => ({ error: 'Unknown', message: res.statusText }))
392
-
throw new ApiError(res.status, err.error, err.message)
454
+
const err = await res.json().catch(() => ({
455
+
error: "Unknown",
456
+
message: res.statusText,
457
+
}));
458
+
throw new ApiError(res.status, err.error, err.message);
393
459
}
394
-
return res.json()
460
+
return res.json();
395
461
},
396
462
397
-
async changePassword(token: string, currentPassword: string, newPassword: string): Promise<void> {
398
-
await xrpc('com.tranquil.account.changePassword', {
399
-
method: 'POST',
463
+
async changePassword(
464
+
token: string,
465
+
currentPassword: string,
466
+
newPassword: string,
467
+
): Promise<void> {
468
+
await xrpc("com.tranquil.account.changePassword", {
469
+
method: "POST",
400
470
token,
401
471
body: { currentPassword, newPassword },
402
-
})
472
+
});
403
473
},
404
474
405
475
async removePassword(token: string): Promise<{ success: boolean }> {
406
-
return xrpc('com.tranquil.account.removePassword', {
407
-
method: 'POST',
476
+
return xrpc("com.tranquil.account.removePassword", {
477
+
method: "POST",
408
478
token,
409
-
})
479
+
});
410
480
},
411
481
412
482
async getPasswordStatus(token: string): Promise<{ hasPassword: boolean }> {
413
-
return xrpc('com.tranquil.account.getPasswordStatus', { token })
483
+
return xrpc("com.tranquil.account.getPasswordStatus", { token });
414
484
},
415
485
416
-
async getLegacyLoginPreference(token: string): Promise<{ allowLegacyLogin: boolean; hasMfa: boolean }> {
417
-
return xrpc('com.tranquil.account.getLegacyLoginPreference', { token })
486
+
async getLegacyLoginPreference(
487
+
token: string,
488
+
): Promise<{ allowLegacyLogin: boolean; hasMfa: boolean }> {
489
+
return xrpc("com.tranquil.account.getLegacyLoginPreference", { token });
418
490
},
419
491
420
-
async updateLegacyLoginPreference(token: string, allowLegacyLogin: boolean): Promise<{ allowLegacyLogin: boolean }> {
421
-
return xrpc('com.tranquil.account.updateLegacyLoginPreference', {
422
-
method: 'POST',
492
+
async updateLegacyLoginPreference(
493
+
token: string,
494
+
allowLegacyLogin: boolean,
495
+
): Promise<{ allowLegacyLogin: boolean }> {
496
+
return xrpc("com.tranquil.account.updateLegacyLoginPreference", {
497
+
method: "POST",
423
498
token,
424
499
body: { allowLegacyLogin },
425
-
})
500
+
});
426
501
},
427
502
428
-
async updateLocale(token: string, preferredLocale: string): Promise<{ preferredLocale: string }> {
429
-
return xrpc('com.tranquil.account.updateLocale', {
430
-
method: 'POST',
503
+
async updateLocale(
504
+
token: string,
505
+
preferredLocale: string,
506
+
): Promise<{ preferredLocale: string }> {
507
+
return xrpc("com.tranquil.account.updateLocale", {
508
+
method: "POST",
431
509
token,
432
510
body: { preferredLocale },
433
-
})
511
+
});
434
512
},
435
513
436
514
async listSessions(token: string): Promise<{
437
515
sessions: Array<{
438
-
id: string
439
-
sessionType: string
440
-
clientName: string | null
441
-
createdAt: string
442
-
expiresAt: string
443
-
isCurrent: boolean
444
-
}>
516
+
id: string;
517
+
sessionType: string;
518
+
clientName: string | null;
519
+
createdAt: string;
520
+
expiresAt: string;
521
+
isCurrent: boolean;
522
+
}>;
445
523
}> {
446
-
return xrpc('com.tranquil.account.listSessions', { token })
524
+
return xrpc("com.tranquil.account.listSessions", { token });
447
525
},
448
526
449
527
async revokeSession(token: string, sessionId: string): Promise<void> {
450
-
await xrpc('com.tranquil.account.revokeSession', {
451
-
method: 'POST',
528
+
await xrpc("com.tranquil.account.revokeSession", {
529
+
method: "POST",
452
530
token,
453
531
body: { sessionId },
454
-
})
532
+
});
455
533
},
456
534
457
535
async revokeAllSessions(token: string): Promise<{ revokedCount: number }> {
458
-
return xrpc('com.tranquil.account.revokeAllSessions', {
459
-
method: 'POST',
536
+
return xrpc("com.tranquil.account.revokeAllSessions", {
537
+
method: "POST",
460
538
token,
461
-
})
539
+
});
462
540
},
463
541
464
542
async searchAccounts(token: string, options?: {
465
-
handle?: string
466
-
cursor?: string
467
-
limit?: number
543
+
handle?: string;
544
+
cursor?: string;
545
+
limit?: number;
468
546
}): Promise<{
469
-
cursor?: string
547
+
cursor?: string;
470
548
accounts: Array<{
471
-
did: string
472
-
handle: string
473
-
email?: string
474
-
indexedAt: string
475
-
emailConfirmedAt?: string
476
-
deactivatedAt?: string
477
-
}>
549
+
did: string;
550
+
handle: string;
551
+
email?: string;
552
+
indexedAt: string;
553
+
emailConfirmedAt?: string;
554
+
deactivatedAt?: string;
555
+
}>;
478
556
}> {
479
-
const params: Record<string, string> = {}
480
-
if (options?.handle) params.handle = options.handle
481
-
if (options?.cursor) params.cursor = options.cursor
482
-
if (options?.limit) params.limit = String(options.limit)
483
-
return xrpc('com.atproto.admin.searchAccounts', { token, params })
557
+
const params: Record<string, string> = {};
558
+
if (options?.handle) params.handle = options.handle;
559
+
if (options?.cursor) params.cursor = options.cursor;
560
+
if (options?.limit) params.limit = String(options.limit);
561
+
return xrpc("com.atproto.admin.searchAccounts", { token, params });
484
562
},
485
563
486
564
async getInviteCodes(token: string, options?: {
487
-
sort?: 'recent' | 'usage'
488
-
cursor?: string
489
-
limit?: number
565
+
sort?: "recent" | "usage";
566
+
cursor?: string;
567
+
limit?: number;
490
568
}): Promise<{
491
-
cursor?: string
569
+
cursor?: string;
492
570
codes: Array<{
493
-
code: string
494
-
available: number
495
-
disabled: boolean
496
-
forAccount: string
497
-
createdBy: string
498
-
createdAt: string
499
-
uses: Array<{ usedBy: string; usedAt: string }>
500
-
}>
571
+
code: string;
572
+
available: number;
573
+
disabled: boolean;
574
+
forAccount: string;
575
+
createdBy: string;
576
+
createdAt: string;
577
+
uses: Array<{ usedBy: string; usedAt: string }>;
578
+
}>;
501
579
}> {
502
-
const params: Record<string, string> = {}
503
-
if (options?.sort) params.sort = options.sort
504
-
if (options?.cursor) params.cursor = options.cursor
505
-
if (options?.limit) params.limit = String(options.limit)
506
-
return xrpc('com.atproto.admin.getInviteCodes', { token, params })
580
+
const params: Record<string, string> = {};
581
+
if (options?.sort) params.sort = options.sort;
582
+
if (options?.cursor) params.cursor = options.cursor;
583
+
if (options?.limit) params.limit = String(options.limit);
584
+
return xrpc("com.atproto.admin.getInviteCodes", { token, params });
507
585
},
508
586
509
-
async disableInviteCodes(token: string, codes?: string[], accounts?: string[]): Promise<void> {
510
-
await xrpc('com.atproto.admin.disableInviteCodes', {
511
-
method: 'POST',
587
+
async disableInviteCodes(
588
+
token: string,
589
+
codes?: string[],
590
+
accounts?: string[],
591
+
): Promise<void> {
592
+
await xrpc("com.atproto.admin.disableInviteCodes", {
593
+
method: "POST",
512
594
token,
513
595
body: { codes, accounts },
514
-
})
596
+
});
515
597
},
516
598
517
599
async getAccountInfo(token: string, did: string): Promise<{
518
-
did: string
519
-
handle: string
520
-
email?: string
521
-
indexedAt: string
522
-
emailConfirmedAt?: string
523
-
invitesDisabled?: boolean
524
-
deactivatedAt?: string
600
+
did: string;
601
+
handle: string;
602
+
email?: string;
603
+
indexedAt: string;
604
+
emailConfirmedAt?: string;
605
+
invitesDisabled?: boolean;
606
+
deactivatedAt?: string;
525
607
}> {
526
-
return xrpc('com.atproto.admin.getAccountInfo', { token, params: { did } })
608
+
return xrpc("com.atproto.admin.getAccountInfo", { token, params: { did } });
527
609
},
528
610
529
611
async disableAccountInvites(token: string, account: string): Promise<void> {
530
-
await xrpc('com.atproto.admin.disableAccountInvites', {
531
-
method: 'POST',
612
+
await xrpc("com.atproto.admin.disableAccountInvites", {
613
+
method: "POST",
532
614
token,
533
615
body: { account },
534
-
})
616
+
});
535
617
},
536
618
537
619
async enableAccountInvites(token: string, account: string): Promise<void> {
538
-
await xrpc('com.atproto.admin.enableAccountInvites', {
539
-
method: 'POST',
620
+
await xrpc("com.atproto.admin.enableAccountInvites", {
621
+
method: "POST",
540
622
token,
541
623
body: { account },
542
-
})
624
+
});
543
625
},
544
626
545
627
async adminDeleteAccount(token: string, did: string): Promise<void> {
546
-
await xrpc('com.atproto.admin.deleteAccount', {
547
-
method: 'POST',
628
+
await xrpc("com.atproto.admin.deleteAccount", {
629
+
method: "POST",
548
630
token,
549
631
body: { did },
550
-
})
632
+
});
551
633
},
552
634
553
635
async describeRepo(token: string, repo: string): Promise<{
554
-
handle: string
555
-
did: string
556
-
didDoc: unknown
557
-
collections: string[]
558
-
handleIsCorrect: boolean
636
+
handle: string;
637
+
did: string;
638
+
didDoc: unknown;
639
+
collections: string[];
640
+
handleIsCorrect: boolean;
559
641
}> {
560
-
return xrpc('com.atproto.repo.describeRepo', {
642
+
return xrpc("com.atproto.repo.describeRepo", {
561
643
token,
562
644
params: { repo },
563
-
})
645
+
});
564
646
},
565
647
566
648
async listRecords(token: string, repo: string, collection: string, options?: {
567
-
limit?: number
568
-
cursor?: string
569
-
reverse?: boolean
649
+
limit?: number;
650
+
cursor?: string;
651
+
reverse?: boolean;
570
652
}): Promise<{
571
-
records: Array<{ uri: string; cid: string; value: unknown }>
572
-
cursor?: string
653
+
records: Array<{ uri: string; cid: string; value: unknown }>;
654
+
cursor?: string;
573
655
}> {
574
-
const params: Record<string, string> = { repo, collection }
575
-
if (options?.limit) params.limit = String(options.limit)
576
-
if (options?.cursor) params.cursor = options.cursor
577
-
if (options?.reverse) params.reverse = 'true'
578
-
return xrpc('com.atproto.repo.listRecords', { token, params })
656
+
const params: Record<string, string> = { repo, collection };
657
+
if (options?.limit) params.limit = String(options.limit);
658
+
if (options?.cursor) params.cursor = options.cursor;
659
+
if (options?.reverse) params.reverse = "true";
660
+
return xrpc("com.atproto.repo.listRecords", { token, params });
579
661
},
580
662
581
-
async getRecord(token: string, repo: string, collection: string, rkey: string): Promise<{
582
-
uri: string
583
-
cid: string
584
-
value: unknown
663
+
async getRecord(
664
+
token: string,
665
+
repo: string,
666
+
collection: string,
667
+
rkey: string,
668
+
): Promise<{
669
+
uri: string;
670
+
cid: string;
671
+
value: unknown;
585
672
}> {
586
-
return xrpc('com.atproto.repo.getRecord', {
673
+
return xrpc("com.atproto.repo.getRecord", {
587
674
token,
588
675
params: { repo, collection, rkey },
589
-
})
676
+
});
590
677
},
591
678
592
-
async createRecord(token: string, repo: string, collection: string, record: unknown, rkey?: string): Promise<{
593
-
uri: string
594
-
cid: string
679
+
async createRecord(
680
+
token: string,
681
+
repo: string,
682
+
collection: string,
683
+
record: unknown,
684
+
rkey?: string,
685
+
): Promise<{
686
+
uri: string;
687
+
cid: string;
595
688
}> {
596
-
return xrpc('com.atproto.repo.createRecord', {
597
-
method: 'POST',
689
+
return xrpc("com.atproto.repo.createRecord", {
690
+
method: "POST",
598
691
token,
599
692
body: { repo, collection, record, rkey },
600
-
})
693
+
});
601
694
},
602
695
603
-
async putRecord(token: string, repo: string, collection: string, rkey: string, record: unknown): Promise<{
604
-
uri: string
605
-
cid: string
696
+
async putRecord(
697
+
token: string,
698
+
repo: string,
699
+
collection: string,
700
+
rkey: string,
701
+
record: unknown,
702
+
): Promise<{
703
+
uri: string;
704
+
cid: string;
606
705
}> {
607
-
return xrpc('com.atproto.repo.putRecord', {
608
-
method: 'POST',
706
+
return xrpc("com.atproto.repo.putRecord", {
707
+
method: "POST",
609
708
token,
610
709
body: { repo, collection, rkey, record },
611
-
})
710
+
});
612
711
},
613
712
614
-
async deleteRecord(token: string, repo: string, collection: string, rkey: string): Promise<void> {
615
-
await xrpc('com.atproto.repo.deleteRecord', {
616
-
method: 'POST',
713
+
async deleteRecord(
714
+
token: string,
715
+
repo: string,
716
+
collection: string,
717
+
rkey: string,
718
+
): Promise<void> {
719
+
await xrpc("com.atproto.repo.deleteRecord", {
720
+
method: "POST",
617
721
token,
618
722
body: { repo, collection, rkey },
619
-
})
723
+
});
620
724
},
621
725
622
-
async getTotpStatus(token: string): Promise<{ enabled: boolean; hasBackupCodes: boolean }> {
623
-
return xrpc('com.atproto.server.getTotpStatus', { token })
726
+
async getTotpStatus(
727
+
token: string,
728
+
): Promise<{ enabled: boolean; hasBackupCodes: boolean }> {
729
+
return xrpc("com.atproto.server.getTotpStatus", { token });
624
730
},
625
731
626
-
async createTotpSecret(token: string): Promise<{ uri: string; qrBase64: string }> {
627
-
return xrpc('com.atproto.server.createTotpSecret', { method: 'POST', token })
732
+
async createTotpSecret(
733
+
token: string,
734
+
): Promise<{ uri: string; qrBase64: string }> {
735
+
return xrpc("com.atproto.server.createTotpSecret", {
736
+
method: "POST",
737
+
token,
738
+
});
628
739
},
629
740
630
-
async enableTotp(token: string, code: string): Promise<{ success: boolean; backupCodes: string[] }> {
631
-
return xrpc('com.atproto.server.enableTotp', {
632
-
method: 'POST',
741
+
async enableTotp(
742
+
token: string,
743
+
code: string,
744
+
): Promise<{ success: boolean; backupCodes: string[] }> {
745
+
return xrpc("com.atproto.server.enableTotp", {
746
+
method: "POST",
633
747
token,
634
748
body: { code },
635
-
})
749
+
});
636
750
},
637
751
638
-
async disableTotp(token: string, password: string, code: string): Promise<{ success: boolean }> {
639
-
return xrpc('com.atproto.server.disableTotp', {
640
-
method: 'POST',
752
+
async disableTotp(
753
+
token: string,
754
+
password: string,
755
+
code: string,
756
+
): Promise<{ success: boolean }> {
757
+
return xrpc("com.atproto.server.disableTotp", {
758
+
method: "POST",
641
759
token,
642
760
body: { password, code },
643
-
})
761
+
});
644
762
},
645
763
646
-
async regenerateBackupCodes(token: string, password: string, code: string): Promise<{ backupCodes: string[] }> {
647
-
return xrpc('com.atproto.server.regenerateBackupCodes', {
648
-
method: 'POST',
764
+
async regenerateBackupCodes(
765
+
token: string,
766
+
password: string,
767
+
code: string,
768
+
): Promise<{ backupCodes: string[] }> {
769
+
return xrpc("com.atproto.server.regenerateBackupCodes", {
770
+
method: "POST",
649
771
token,
650
772
body: { password, code },
651
-
})
773
+
});
652
774
},
653
775
654
-
async startPasskeyRegistration(token: string, friendlyName?: string): Promise<{ options: unknown }> {
655
-
return xrpc('com.atproto.server.startPasskeyRegistration', {
656
-
method: 'POST',
776
+
async startPasskeyRegistration(
777
+
token: string,
778
+
friendlyName?: string,
779
+
): Promise<{ options: unknown }> {
780
+
return xrpc("com.atproto.server.startPasskeyRegistration", {
781
+
method: "POST",
657
782
token,
658
783
body: { friendlyName },
659
-
})
784
+
});
660
785
},
661
786
662
-
async finishPasskeyRegistration(token: string, credential: unknown, friendlyName?: string): Promise<{ id: string; credentialId: string }> {
663
-
return xrpc('com.atproto.server.finishPasskeyRegistration', {
664
-
method: 'POST',
787
+
async finishPasskeyRegistration(
788
+
token: string,
789
+
credential: unknown,
790
+
friendlyName?: string,
791
+
): Promise<{ id: string; credentialId: string }> {
792
+
return xrpc("com.atproto.server.finishPasskeyRegistration", {
793
+
method: "POST",
665
794
token,
666
795
body: { credential, friendlyName },
667
-
})
796
+
});
668
797
},
669
798
670
799
async listPasskeys(token: string): Promise<{
671
800
passkeys: Array<{
672
-
id: string
673
-
credentialId: string
674
-
friendlyName: string | null
675
-
createdAt: string
676
-
lastUsed: string | null
677
-
}>
801
+
id: string;
802
+
credentialId: string;
803
+
friendlyName: string | null;
804
+
createdAt: string;
805
+
lastUsed: string | null;
806
+
}>;
678
807
}> {
679
-
return xrpc('com.atproto.server.listPasskeys', { token })
808
+
return xrpc("com.atproto.server.listPasskeys", { token });
680
809
},
681
810
682
811
async deletePasskey(token: string, id: string): Promise<void> {
683
-
await xrpc('com.atproto.server.deletePasskey', {
684
-
method: 'POST',
812
+
await xrpc("com.atproto.server.deletePasskey", {
813
+
method: "POST",
685
814
token,
686
815
body: { id },
687
-
})
816
+
});
688
817
},
689
818
690
-
async updatePasskey(token: string, id: string, friendlyName: string): Promise<void> {
691
-
await xrpc('com.atproto.server.updatePasskey', {
692
-
method: 'POST',
819
+
async updatePasskey(
820
+
token: string,
821
+
id: string,
822
+
friendlyName: string,
823
+
): Promise<void> {
824
+
await xrpc("com.atproto.server.updatePasskey", {
825
+
method: "POST",
693
826
token,
694
827
body: { id, friendlyName },
695
-
})
828
+
});
696
829
},
697
830
698
831
async listTrustedDevices(token: string): Promise<{
699
832
devices: Array<{
700
-
id: string
701
-
userAgent: string | null
702
-
friendlyName: string | null
703
-
trustedAt: string | null
704
-
trustedUntil: string | null
705
-
lastSeenAt: string
706
-
}>
833
+
id: string;
834
+
userAgent: string | null;
835
+
friendlyName: string | null;
836
+
trustedAt: string | null;
837
+
trustedUntil: string | null;
838
+
lastSeenAt: string;
839
+
}>;
707
840
}> {
708
-
return xrpc('com.tranquil.account.listTrustedDevices', { token })
841
+
return xrpc("com.tranquil.account.listTrustedDevices", { token });
709
842
},
710
843
711
-
async revokeTrustedDevice(token: string, deviceId: string): Promise<{ success: boolean }> {
712
-
return xrpc('com.tranquil.account.revokeTrustedDevice', {
713
-
method: 'POST',
844
+
async revokeTrustedDevice(
845
+
token: string,
846
+
deviceId: string,
847
+
): Promise<{ success: boolean }> {
848
+
return xrpc("com.tranquil.account.revokeTrustedDevice", {
849
+
method: "POST",
714
850
token,
715
851
body: { deviceId },
716
-
})
852
+
});
717
853
},
718
854
719
-
async updateTrustedDevice(token: string, deviceId: string, friendlyName: string): Promise<{ success: boolean }> {
720
-
return xrpc('com.tranquil.account.updateTrustedDevice', {
721
-
method: 'POST',
855
+
async updateTrustedDevice(
856
+
token: string,
857
+
deviceId: string,
858
+
friendlyName: string,
859
+
): Promise<{ success: boolean }> {
860
+
return xrpc("com.tranquil.account.updateTrustedDevice", {
861
+
method: "POST",
722
862
token,
723
863
body: { deviceId, friendlyName },
724
-
})
864
+
});
725
865
},
726
866
727
867
async getReauthStatus(token: string): Promise<{
728
-
requiresReauth: boolean
729
-
lastReauthAt: string | null
730
-
availableMethods: string[]
868
+
requiresReauth: boolean;
869
+
lastReauthAt: string | null;
870
+
availableMethods: string[];
731
871
}> {
732
-
return xrpc('com.tranquil.account.getReauthStatus', { token })
872
+
return xrpc("com.tranquil.account.getReauthStatus", { token });
733
873
},
734
874
735
-
async reauthPassword(token: string, password: string): Promise<{ success: boolean; reauthAt: string }> {
736
-
return xrpc('com.tranquil.account.reauthPassword', {
737
-
method: 'POST',
875
+
async reauthPassword(
876
+
token: string,
877
+
password: string,
878
+
): Promise<{ success: boolean; reauthAt: string }> {
879
+
return xrpc("com.tranquil.account.reauthPassword", {
880
+
method: "POST",
738
881
token,
739
882
body: { password },
740
-
})
883
+
});
741
884
},
742
885
743
-
async reauthTotp(token: string, code: string): Promise<{ success: boolean; reauthAt: string }> {
744
-
return xrpc('com.tranquil.account.reauthTotp', {
745
-
method: 'POST',
886
+
async reauthTotp(
887
+
token: string,
888
+
code: string,
889
+
): Promise<{ success: boolean; reauthAt: string }> {
890
+
return xrpc("com.tranquil.account.reauthTotp", {
891
+
method: "POST",
746
892
token,
747
893
body: { code },
748
-
})
894
+
});
749
895
},
750
896
751
897
async reauthPasskeyStart(token: string): Promise<{ options: unknown }> {
752
-
return xrpc('com.tranquil.account.reauthPasskeyStart', {
753
-
method: 'POST',
898
+
return xrpc("com.tranquil.account.reauthPasskeyStart", {
899
+
method: "POST",
754
900
token,
755
-
})
901
+
});
756
902
},
757
903
758
-
async reauthPasskeyFinish(token: string, credential: unknown): Promise<{ success: boolean; reauthAt: string }> {
759
-
return xrpc('com.tranquil.account.reauthPasskeyFinish', {
760
-
method: 'POST',
904
+
async reauthPasskeyFinish(
905
+
token: string,
906
+
credential: unknown,
907
+
): Promise<{ success: boolean; reauthAt: string }> {
908
+
return xrpc("com.tranquil.account.reauthPasskeyFinish", {
909
+
method: "POST",
761
910
token,
762
911
body: { credential },
763
-
})
912
+
});
764
913
},
765
914
766
915
async reserveSigningKey(did?: string): Promise<{ signingKey: string }> {
767
-
return xrpc('com.atproto.server.reserveSigningKey', {
768
-
method: 'POST',
916
+
return xrpc("com.atproto.server.reserveSigningKey", {
917
+
method: "POST",
769
918
body: { did },
770
-
})
919
+
});
771
920
},
772
921
773
922
async getRecommendedDidCredentials(token: string): Promise<{
774
-
rotationKeys?: string[]
775
-
alsoKnownAs?: string[]
776
-
verificationMethods?: { atproto?: string }
777
-
services?: { atproto_pds?: { type: string; endpoint: string } }
923
+
rotationKeys?: string[];
924
+
alsoKnownAs?: string[];
925
+
verificationMethods?: { atproto?: string };
926
+
services?: { atproto_pds?: { type: string; endpoint: string } };
778
927
}> {
779
-
return xrpc('com.atproto.identity.getRecommendedDidCredentials', { token })
928
+
return xrpc("com.atproto.identity.getRecommendedDidCredentials", { token });
780
929
},
781
930
782
931
async activateAccount(token: string): Promise<void> {
783
-
await xrpc('com.atproto.server.activateAccount', {
784
-
method: 'POST',
932
+
await xrpc("com.atproto.server.activateAccount", {
933
+
method: "POST",
785
934
token,
786
-
})
935
+
});
787
936
},
788
937
789
938
async createPasskeyAccount(params: {
790
-
handle: string
791
-
email?: string
792
-
inviteCode?: string
793
-
didType?: DidType
794
-
did?: string
795
-
signingKey?: string
796
-
verificationChannel?: VerificationChannel
797
-
discordId?: string
798
-
telegramUsername?: string
799
-
signalNumber?: string
939
+
handle: string;
940
+
email?: string;
941
+
inviteCode?: string;
942
+
didType?: DidType;
943
+
did?: string;
944
+
signingKey?: string;
945
+
verificationChannel?: VerificationChannel;
946
+
discordId?: string;
947
+
telegramUsername?: string;
948
+
signalNumber?: string;
800
949
}, byodToken?: string): Promise<{
801
-
did: string
802
-
handle: string
803
-
setupToken: string
804
-
setupExpiresAt: string
950
+
did: string;
951
+
handle: string;
952
+
setupToken: string;
953
+
setupExpiresAt: string;
805
954
}> {
806
-
const url = `${API_BASE}/com.tranquil.account.createPasskeyAccount`
955
+
const url = `${API_BASE}/com.tranquil.account.createPasskeyAccount`;
807
956
const headers: Record<string, string> = {
808
-
'Content-Type': 'application/json'
809
-
}
957
+
"Content-Type": "application/json",
958
+
};
810
959
if (byodToken) {
811
-
headers['Authorization'] = `Bearer ${byodToken}`
960
+
headers["Authorization"] = `Bearer ${byodToken}`;
812
961
}
813
962
const res = await fetch(url, {
814
-
method: 'POST',
963
+
method: "POST",
815
964
headers,
816
965
body: JSON.stringify(params),
817
-
})
966
+
});
818
967
if (!res.ok) {
819
-
const err = await res.json().catch(() => ({ error: 'Unknown', message: res.statusText }))
820
-
throw new ApiError(res.status, err.error, err.message)
968
+
const err = await res.json().catch(() => ({
969
+
error: "Unknown",
970
+
message: res.statusText,
971
+
}));
972
+
throw new ApiError(res.status, err.error, err.message);
821
973
}
822
-
return res.json()
974
+
return res.json();
823
975
},
824
976
825
-
async startPasskeyRegistrationForSetup(did: string, setupToken: string, friendlyName?: string): Promise<{ options: unknown }> {
826
-
return xrpc('com.tranquil.account.startPasskeyRegistrationForSetup', {
827
-
method: 'POST',
977
+
async startPasskeyRegistrationForSetup(
978
+
did: string,
979
+
setupToken: string,
980
+
friendlyName?: string,
981
+
): Promise<{ options: unknown }> {
982
+
return xrpc("com.tranquil.account.startPasskeyRegistrationForSetup", {
983
+
method: "POST",
828
984
body: { did, setupToken, friendlyName },
829
-
})
985
+
});
830
986
},
831
987
832
-
async completePasskeySetup(did: string, setupToken: string, passkeyCredential: unknown, passkeyFriendlyName?: string): Promise<{
833
-
did: string
834
-
handle: string
835
-
appPassword: string
836
-
appPasswordName: string
988
+
async completePasskeySetup(
989
+
did: string,
990
+
setupToken: string,
991
+
passkeyCredential: unknown,
992
+
passkeyFriendlyName?: string,
993
+
): Promise<{
994
+
did: string;
995
+
handle: string;
996
+
appPassword: string;
997
+
appPasswordName: string;
837
998
}> {
838
-
return xrpc('com.tranquil.account.completePasskeySetup', {
839
-
method: 'POST',
999
+
return xrpc("com.tranquil.account.completePasskeySetup", {
1000
+
method: "POST",
840
1001
body: { did, setupToken, passkeyCredential, passkeyFriendlyName },
841
-
})
1002
+
});
842
1003
},
843
1004
844
1005
async requestPasskeyRecovery(email: string): Promise<{ success: boolean }> {
845
-
return xrpc('com.tranquil.account.requestPasskeyRecovery', {
846
-
method: 'POST',
1006
+
return xrpc("com.tranquil.account.requestPasskeyRecovery", {
1007
+
method: "POST",
847
1008
body: { email },
848
-
})
1009
+
});
849
1010
},
850
1011
851
-
async recoverPasskeyAccount(did: string, recoveryToken: string, newPassword: string): Promise<{ success: boolean }> {
852
-
return xrpc('com.tranquil.account.recoverPasskeyAccount', {
853
-
method: 'POST',
1012
+
async recoverPasskeyAccount(
1013
+
did: string,
1014
+
recoveryToken: string,
1015
+
newPassword: string,
1016
+
): Promise<{ success: boolean }> {
1017
+
return xrpc("com.tranquil.account.recoverPasskeyAccount", {
1018
+
method: "POST",
854
1019
body: { did, recoveryToken, newPassword },
855
-
})
1020
+
});
856
1021
},
857
1022
858
-
async verifyMigrationEmail(token: string, email: string): Promise<{ success: boolean; did: string }> {
859
-
return xrpc('com.atproto.server.verifyMigrationEmail', {
860
-
method: 'POST',
1023
+
async verifyMigrationEmail(
1024
+
token: string,
1025
+
email: string,
1026
+
): Promise<{ success: boolean; did: string }> {
1027
+
return xrpc("com.atproto.server.verifyMigrationEmail", {
1028
+
method: "POST",
861
1029
body: { token, email },
862
-
})
1030
+
});
863
1031
},
864
1032
865
1033
async resendMigrationVerification(email: string): Promise<{ sent: boolean }> {
866
-
return xrpc('com.atproto.server.resendMigrationVerification', {
867
-
method: 'POST',
1034
+
return xrpc("com.atproto.server.resendMigrationVerification", {
1035
+
method: "POST",
868
1036
body: { email },
869
-
})
1037
+
});
870
1038
},
871
1039
872
-
async verifyToken(token: string, identifier: string, accessToken?: string): Promise<{
873
-
success: boolean
874
-
did: string
875
-
purpose: string
876
-
channel: string
1040
+
async verifyToken(
1041
+
token: string,
1042
+
identifier: string,
1043
+
accessToken?: string,
1044
+
): Promise<{
1045
+
success: boolean;
1046
+
did: string;
1047
+
purpose: string;
1048
+
channel: string;
877
1049
}> {
878
-
return xrpc('com.tranquil.account.verifyToken', {
879
-
method: 'POST',
1050
+
return xrpc("com.tranquil.account.verifyToken", {
1051
+
method: "POST",
880
1052
body: { token, identifier },
881
1053
token: accessToken,
882
-
})
1054
+
});
883
1055
},
884
-
}
1056
+
};
+234
-184
frontend/src/lib/auth.svelte.ts
+234
-184
frontend/src/lib/auth.svelte.ts
···
1
-
import { api, setTokenRefreshCallback, type Session, type CreateAccountParams, type CreateAccountResult, ApiError } from './api'
2
-
import { startOAuthLogin, handleOAuthCallback, checkForOAuthCallback, clearOAuthCallbackParams, refreshOAuthToken } from './oauth'
3
-
import { setLocale, type SupportedLocale } from './i18n'
1
+
import {
2
+
api,
3
+
ApiError,
4
+
type CreateAccountParams,
5
+
type CreateAccountResult,
6
+
type Session,
7
+
setTokenRefreshCallback,
8
+
} from "./api";
9
+
import {
10
+
checkForOAuthCallback,
11
+
clearOAuthCallbackParams,
12
+
handleOAuthCallback,
13
+
refreshOAuthToken,
14
+
startOAuthLogin,
15
+
} from "./oauth";
16
+
import { setLocale, type SupportedLocale } from "./i18n";
4
17
5
-
function applyLocaleFromSession(sessionInfo: { preferredLocale?: string | null }) {
18
+
function applyLocaleFromSession(
19
+
sessionInfo: { preferredLocale?: string | null },
20
+
) {
6
21
if (sessionInfo.preferredLocale) {
7
-
setLocale(sessionInfo.preferredLocale as SupportedLocale)
22
+
setLocale(sessionInfo.preferredLocale as SupportedLocale);
8
23
}
9
24
}
10
25
11
-
const STORAGE_KEY = 'tranquil_pds_session'
12
-
const ACCOUNTS_KEY = 'tranquil_pds_accounts'
26
+
const STORAGE_KEY = "tranquil_pds_session";
27
+
const ACCOUNTS_KEY = "tranquil_pds_accounts";
13
28
14
29
export interface SavedAccount {
15
-
did: string
16
-
handle: string
17
-
accessJwt: string
18
-
refreshJwt: string
30
+
did: string;
31
+
handle: string;
32
+
accessJwt: string;
33
+
refreshJwt: string;
19
34
}
20
35
21
36
interface AuthState {
22
-
session: Session | null
23
-
loading: boolean
24
-
error: string | null
25
-
savedAccounts: SavedAccount[]
37
+
session: Session | null;
38
+
loading: boolean;
39
+
error: string | null;
40
+
savedAccounts: SavedAccount[];
26
41
}
27
42
28
43
let state = $state<AuthState>({
···
30
45
loading: true,
31
46
error: null,
32
47
savedAccounts: [],
33
-
})
48
+
});
34
49
35
50
function saveSession(session: Session | null) {
36
51
if (session) {
37
-
localStorage.setItem(STORAGE_KEY, JSON.stringify(session))
52
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
38
53
} else {
39
-
localStorage.removeItem(STORAGE_KEY)
54
+
localStorage.removeItem(STORAGE_KEY);
40
55
}
41
56
}
42
57
43
58
function loadSession(): Session | null {
44
-
const stored = localStorage.getItem(STORAGE_KEY)
59
+
const stored = localStorage.getItem(STORAGE_KEY);
45
60
if (stored) {
46
61
try {
47
-
return JSON.parse(stored)
62
+
return JSON.parse(stored);
48
63
} catch {
49
-
return null
64
+
return null;
50
65
}
51
66
}
52
-
return null
67
+
return null;
53
68
}
54
69
55
70
function loadSavedAccounts(): SavedAccount[] {
56
-
const stored = localStorage.getItem(ACCOUNTS_KEY)
71
+
const stored = localStorage.getItem(ACCOUNTS_KEY);
57
72
if (stored) {
58
73
try {
59
-
return JSON.parse(stored)
74
+
return JSON.parse(stored);
60
75
} catch {
61
-
return []
76
+
return [];
62
77
}
63
78
}
64
-
return []
79
+
return [];
65
80
}
66
81
67
82
function saveSavedAccounts(accounts: SavedAccount[]) {
68
-
localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts))
83
+
localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts));
69
84
}
70
85
71
86
function addOrUpdateSavedAccount(session: Session) {
72
-
const accounts = loadSavedAccounts()
73
-
const existing = accounts.findIndex(a => a.did === session.did)
87
+
const accounts = loadSavedAccounts();
88
+
const existing = accounts.findIndex((a) => a.did === session.did);
74
89
const savedAccount: SavedAccount = {
75
90
did: session.did,
76
91
handle: session.handle,
77
92
accessJwt: session.accessJwt,
78
93
refreshJwt: session.refreshJwt,
79
-
}
94
+
};
80
95
if (existing >= 0) {
81
-
accounts[existing] = savedAccount
96
+
accounts[existing] = savedAccount;
82
97
} else {
83
-
accounts.push(savedAccount)
98
+
accounts.push(savedAccount);
84
99
}
85
-
saveSavedAccounts(accounts)
86
-
state.savedAccounts = accounts
100
+
saveSavedAccounts(accounts);
101
+
state.savedAccounts = accounts;
87
102
}
88
103
89
104
function removeSavedAccount(did: string) {
90
-
const accounts = loadSavedAccounts().filter(a => a.did !== did)
91
-
saveSavedAccounts(accounts)
92
-
state.savedAccounts = accounts
105
+
const accounts = loadSavedAccounts().filter((a) => a.did !== did);
106
+
saveSavedAccounts(accounts);
107
+
state.savedAccounts = accounts;
93
108
}
94
109
95
110
async function tryRefreshToken(): Promise<string | null> {
96
-
if (!state.session) return null
111
+
if (!state.session) return null;
97
112
try {
98
-
const tokens = await refreshOAuthToken(state.session.refreshJwt)
99
-
const sessionInfo = await api.getSession(tokens.access_token)
113
+
const tokens = await refreshOAuthToken(state.session.refreshJwt);
114
+
const sessionInfo = await api.getSession(tokens.access_token);
100
115
const session: Session = {
101
116
...sessionInfo,
102
117
accessJwt: tokens.access_token,
103
118
refreshJwt: tokens.refresh_token || state.session.refreshJwt,
104
-
}
105
-
state.session = session
106
-
saveSession(session)
107
-
addOrUpdateSavedAccount(session)
108
-
return session.accessJwt
119
+
};
120
+
state.session = session;
121
+
saveSession(session);
122
+
addOrUpdateSavedAccount(session);
123
+
return session.accessJwt;
109
124
} catch {
110
-
return null
125
+
return null;
111
126
}
112
127
}
113
128
114
129
export async function initAuth(): Promise<{ oauthLoginCompleted: boolean }> {
115
-
setTokenRefreshCallback(tryRefreshToken)
116
-
state.loading = true
117
-
state.error = null
118
-
state.savedAccounts = loadSavedAccounts()
130
+
setTokenRefreshCallback(tryRefreshToken);
131
+
state.loading = true;
132
+
state.error = null;
133
+
state.savedAccounts = loadSavedAccounts();
119
134
120
-
const oauthCallback = checkForOAuthCallback()
135
+
const oauthCallback = checkForOAuthCallback();
121
136
if (oauthCallback) {
122
-
clearOAuthCallbackParams()
137
+
clearOAuthCallbackParams();
123
138
try {
124
-
const tokens = await handleOAuthCallback(oauthCallback.code, oauthCallback.state)
125
-
const sessionInfo = await api.getSession(tokens.access_token)
139
+
const tokens = await handleOAuthCallback(
140
+
oauthCallback.code,
141
+
oauthCallback.state,
142
+
);
143
+
const sessionInfo = await api.getSession(tokens.access_token);
126
144
const session: Session = {
127
145
...sessionInfo,
128
146
accessJwt: tokens.access_token,
129
-
refreshJwt: tokens.refresh_token || '',
130
-
}
131
-
state.session = session
132
-
saveSession(session)
133
-
addOrUpdateSavedAccount(session)
134
-
applyLocaleFromSession(sessionInfo)
135
-
state.loading = false
136
-
return { oauthLoginCompleted: true }
147
+
refreshJwt: tokens.refresh_token || "",
148
+
};
149
+
state.session = session;
150
+
saveSession(session);
151
+
addOrUpdateSavedAccount(session);
152
+
applyLocaleFromSession(sessionInfo);
153
+
state.loading = false;
154
+
return { oauthLoginCompleted: true };
137
155
} catch (e) {
138
-
state.error = e instanceof Error ? e.message : 'OAuth login failed'
139
-
state.loading = false
140
-
return { oauthLoginCompleted: false }
156
+
state.error = e instanceof Error ? e.message : "OAuth login failed";
157
+
state.loading = false;
158
+
return { oauthLoginCompleted: false };
141
159
}
142
160
}
143
161
144
-
const stored = loadSession()
162
+
const stored = loadSession();
145
163
if (stored) {
146
164
try {
147
-
const sessionInfo = await api.getSession(stored.accessJwt)
148
-
state.session = { ...sessionInfo, accessJwt: stored.accessJwt, refreshJwt: stored.refreshJwt }
149
-
addOrUpdateSavedAccount(state.session)
150
-
applyLocaleFromSession(sessionInfo)
165
+
const sessionInfo = await api.getSession(stored.accessJwt);
166
+
state.session = {
167
+
...sessionInfo,
168
+
accessJwt: stored.accessJwt,
169
+
refreshJwt: stored.refreshJwt,
170
+
};
171
+
addOrUpdateSavedAccount(state.session);
172
+
applyLocaleFromSession(sessionInfo);
151
173
} catch (e) {
152
174
if (e instanceof ApiError && e.status === 401) {
153
175
try {
154
-
const tokens = await refreshOAuthToken(stored.refreshJwt)
155
-
const sessionInfo = await api.getSession(tokens.access_token)
176
+
const tokens = await refreshOAuthToken(stored.refreshJwt);
177
+
const sessionInfo = await api.getSession(tokens.access_token);
156
178
const session: Session = {
157
179
...sessionInfo,
158
180
accessJwt: tokens.access_token,
159
181
refreshJwt: tokens.refresh_token || stored.refreshJwt,
160
-
}
161
-
state.session = session
162
-
saveSession(session)
163
-
addOrUpdateSavedAccount(session)
164
-
applyLocaleFromSession(sessionInfo)
182
+
};
183
+
state.session = session;
184
+
saveSession(session);
185
+
addOrUpdateSavedAccount(session);
186
+
applyLocaleFromSession(sessionInfo);
165
187
} catch (refreshError) {
166
-
console.error('Token refresh failed during init:', refreshError)
167
-
saveSession(null)
168
-
state.session = null
188
+
console.error("Token refresh failed during init:", refreshError);
189
+
saveSession(null);
190
+
state.session = null;
169
191
}
170
192
} else {
171
-
console.error('Non-401 error during getSession:', e)
172
-
saveSession(null)
173
-
state.session = null
193
+
console.error("Non-401 error during getSession:", e);
194
+
saveSession(null);
195
+
state.session = null;
174
196
}
175
197
}
176
198
}
177
-
state.loading = false
178
-
return { oauthLoginCompleted: false }
199
+
state.loading = false;
200
+
return { oauthLoginCompleted: false };
179
201
}
180
202
181
-
export async function login(identifier: string, password: string): Promise<void> {
182
-
state.loading = true
183
-
state.error = null
203
+
export async function login(
204
+
identifier: string,
205
+
password: string,
206
+
): Promise<void> {
207
+
state.loading = true;
208
+
state.error = null;
184
209
try {
185
-
const session = await api.createSession(identifier, password)
186
-
state.session = session
187
-
saveSession(session)
188
-
addOrUpdateSavedAccount(session)
210
+
const session = await api.createSession(identifier, password);
211
+
state.session = session;
212
+
saveSession(session);
213
+
addOrUpdateSavedAccount(session);
189
214
} catch (e) {
190
215
if (e instanceof ApiError) {
191
-
state.error = e.message
216
+
state.error = e.message;
192
217
} else {
193
-
state.error = 'Login failed'
218
+
state.error = "Login failed";
194
219
}
195
-
throw e
220
+
throw e;
196
221
} finally {
197
-
state.loading = false
222
+
state.loading = false;
198
223
}
199
224
}
200
225
201
226
export async function loginWithOAuth(): Promise<void> {
202
-
state.loading = true
203
-
state.error = null
227
+
state.loading = true;
228
+
state.error = null;
204
229
try {
205
-
await startOAuthLogin()
230
+
await startOAuthLogin();
206
231
} catch (e) {
207
-
state.loading = false
208
-
state.error = e instanceof Error ? e.message : 'Failed to start OAuth login'
209
-
throw e
232
+
state.loading = false;
233
+
state.error = e instanceof Error
234
+
? e.message
235
+
: "Failed to start OAuth login";
236
+
throw e;
210
237
}
211
238
}
212
239
213
-
export async function register(params: CreateAccountParams): Promise<CreateAccountResult> {
240
+
export async function register(
241
+
params: CreateAccountParams,
242
+
): Promise<CreateAccountResult> {
214
243
try {
215
-
const result = await api.createAccount(params)
216
-
return result
244
+
const result = await api.createAccount(params);
245
+
return result;
217
246
} catch (e) {
218
247
if (e instanceof ApiError) {
219
-
state.error = e.message
248
+
state.error = e.message;
220
249
} else {
221
-
state.error = 'Registration failed'
250
+
state.error = "Registration failed";
222
251
}
223
-
throw e
252
+
throw e;
224
253
}
225
254
}
226
255
227
-
export async function confirmSignup(did: string, verificationCode: string): Promise<void> {
228
-
state.loading = true
229
-
state.error = null
256
+
export async function confirmSignup(
257
+
did: string,
258
+
verificationCode: string,
259
+
): Promise<void> {
260
+
state.loading = true;
261
+
state.error = null;
230
262
try {
231
-
const result = await api.confirmSignup(did, verificationCode)
263
+
const result = await api.confirmSignup(did, verificationCode);
232
264
const session: Session = {
233
265
did: result.did,
234
266
handle: result.handle,
···
238
270
emailConfirmed: result.emailConfirmed,
239
271
preferredChannel: result.preferredChannel,
240
272
preferredChannelVerified: result.preferredChannelVerified,
241
-
}
242
-
state.session = session
243
-
saveSession(session)
244
-
addOrUpdateSavedAccount(session)
273
+
};
274
+
state.session = session;
275
+
saveSession(session);
276
+
addOrUpdateSavedAccount(session);
245
277
} catch (e) {
246
278
if (e instanceof ApiError) {
247
-
state.error = e.message
279
+
state.error = e.message;
248
280
} else {
249
-
state.error = 'Verification failed'
281
+
state.error = "Verification failed";
250
282
}
251
-
throw e
283
+
throw e;
252
284
} finally {
253
-
state.loading = false
285
+
state.loading = false;
254
286
}
255
287
}
256
288
257
289
export async function resendVerification(did: string): Promise<void> {
258
290
try {
259
-
await api.resendVerification(did)
291
+
await api.resendVerification(did);
260
292
} catch (e) {
261
293
if (e instanceof ApiError) {
262
-
throw e
294
+
throw e;
263
295
}
264
-
throw new Error('Failed to resend verification code')
296
+
throw new Error("Failed to resend verification code");
265
297
}
266
298
}
267
299
268
-
export function setSession(session: { did: string; handle: string; accessJwt: string; refreshJwt: string }): void {
300
+
export function setSession(
301
+
session: {
302
+
did: string;
303
+
handle: string;
304
+
accessJwt: string;
305
+
refreshJwt: string;
306
+
},
307
+
): void {
269
308
const newSession: Session = {
270
309
did: session.did,
271
310
handle: session.handle,
272
311
accessJwt: session.accessJwt,
273
312
refreshJwt: session.refreshJwt,
274
-
}
275
-
state.session = newSession
276
-
saveSession(newSession)
277
-
addOrUpdateSavedAccount(newSession)
313
+
};
314
+
state.session = newSession;
315
+
saveSession(newSession);
316
+
addOrUpdateSavedAccount(newSession);
278
317
}
279
318
280
319
export async function logout(): Promise<void> {
281
320
if (state.session) {
282
321
try {
283
-
await api.deleteSession(state.session.accessJwt)
322
+
await api.deleteSession(state.session.accessJwt);
284
323
} catch {
285
324
// Ignore errors on logout
286
325
}
287
326
}
288
-
state.session = null
289
-
saveSession(null)
327
+
state.session = null;
328
+
saveSession(null);
290
329
}
291
330
292
331
export async function switchAccount(did: string): Promise<void> {
293
-
const account = state.savedAccounts.find(a => a.did === did)
332
+
const account = state.savedAccounts.find((a) => a.did === did);
294
333
if (!account) {
295
-
throw new Error('Account not found')
334
+
throw new Error("Account not found");
296
335
}
297
-
state.loading = true
298
-
state.error = null
336
+
state.loading = true;
337
+
state.error = null;
299
338
try {
300
-
const session = await api.getSession(account.accessJwt)
301
-
state.session = { ...session, accessJwt: account.accessJwt, refreshJwt: account.refreshJwt }
302
-
saveSession(state.session)
303
-
addOrUpdateSavedAccount(state.session)
339
+
const session = await api.getSession(account.accessJwt);
340
+
state.session = {
341
+
...session,
342
+
accessJwt: account.accessJwt,
343
+
refreshJwt: account.refreshJwt,
344
+
};
345
+
saveSession(state.session);
346
+
addOrUpdateSavedAccount(state.session);
304
347
} catch (e) {
305
348
if (e instanceof ApiError && e.status === 401) {
306
349
try {
307
-
const tokens = await refreshOAuthToken(account.refreshJwt)
308
-
const sessionInfo = await api.getSession(tokens.access_token)
350
+
const tokens = await refreshOAuthToken(account.refreshJwt);
351
+
const sessionInfo = await api.getSession(tokens.access_token);
309
352
const session: Session = {
310
353
...sessionInfo,
311
354
accessJwt: tokens.access_token,
312
355
refreshJwt: tokens.refresh_token || account.refreshJwt,
313
-
}
314
-
state.session = session
315
-
saveSession(session)
316
-
addOrUpdateSavedAccount(session)
356
+
};
357
+
state.session = session;
358
+
saveSession(session);
359
+
addOrUpdateSavedAccount(session);
317
360
} catch {
318
-
removeSavedAccount(did)
319
-
state.error = 'Session expired. Please log in again.'
320
-
throw new Error('Session expired')
361
+
removeSavedAccount(did);
362
+
state.error = "Session expired. Please log in again.";
363
+
throw new Error("Session expired");
321
364
}
322
365
} else {
323
-
state.error = 'Failed to switch account'
324
-
throw e
366
+
state.error = "Failed to switch account";
367
+
throw e;
325
368
}
326
369
} finally {
327
-
state.loading = false
370
+
state.loading = false;
328
371
}
329
372
}
330
373
331
374
export function forgetAccount(did: string): void {
332
-
removeSavedAccount(did)
375
+
removeSavedAccount(did);
333
376
}
334
377
335
378
export function getAuthState() {
336
-
return state
379
+
return state;
337
380
}
338
381
339
382
export async function refreshSession(): Promise<void> {
340
-
if (!state.session) return
383
+
if (!state.session) return;
341
384
try {
342
-
const sessionInfo = await api.getSession(state.session.accessJwt)
385
+
const sessionInfo = await api.getSession(state.session.accessJwt);
343
386
state.session = {
344
387
...sessionInfo,
345
388
accessJwt: state.session.accessJwt,
346
389
refreshJwt: state.session.refreshJwt,
347
-
}
348
-
saveSession(state.session)
349
-
addOrUpdateSavedAccount(state.session)
390
+
};
391
+
saveSession(state.session);
392
+
addOrUpdateSavedAccount(state.session);
350
393
} catch (e) {
351
-
console.error('Failed to refresh session:', e)
394
+
console.error("Failed to refresh session:", e);
352
395
}
353
396
}
354
397
355
398
export function getToken(): string | null {
356
-
return state.session?.accessJwt ?? null
399
+
return state.session?.accessJwt ?? null;
357
400
}
358
401
359
402
export async function getValidToken(): Promise<string | null> {
360
-
if (!state.session) return null
403
+
if (!state.session) return null;
361
404
try {
362
-
await api.getSession(state.session.accessJwt)
363
-
return state.session.accessJwt
405
+
await api.getSession(state.session.accessJwt);
406
+
return state.session.accessJwt;
364
407
} catch (e) {
365
408
if (e instanceof ApiError && e.status === 401) {
366
409
try {
367
-
const tokens = await refreshOAuthToken(state.session.refreshJwt)
368
-
const sessionInfo = await api.getSession(tokens.access_token)
410
+
const tokens = await refreshOAuthToken(state.session.refreshJwt);
411
+
const sessionInfo = await api.getSession(tokens.access_token);
369
412
const session: Session = {
370
413
...sessionInfo,
371
414
accessJwt: tokens.access_token,
372
415
refreshJwt: tokens.refresh_token || state.session.refreshJwt,
373
-
}
374
-
state.session = session
375
-
saveSession(session)
376
-
addOrUpdateSavedAccount(session)
377
-
return session.accessJwt
416
+
};
417
+
state.session = session;
418
+
saveSession(session);
419
+
addOrUpdateSavedAccount(session);
420
+
return session.accessJwt;
378
421
} catch {
379
-
return null
422
+
return null;
380
423
}
381
424
}
382
-
return null
425
+
return null;
383
426
}
384
427
}
385
428
386
429
export function isAuthenticated(): boolean {
387
-
return state.session !== null
430
+
return state.session !== null;
388
431
}
389
432
390
-
export function _testSetState(newState: { session: Session | null; loading: boolean; error: string | null; savedAccounts?: SavedAccount[] }) {
391
-
state.session = newState.session
392
-
state.loading = newState.loading
393
-
state.error = newState.error
394
-
state.savedAccounts = newState.savedAccounts ?? []
433
+
export function _testSetState(
434
+
newState: {
435
+
session: Session | null;
436
+
loading: boolean;
437
+
error: string | null;
438
+
savedAccounts?: SavedAccount[];
439
+
},
440
+
) {
441
+
state.session = newState.session;
442
+
state.loading = newState.loading;
443
+
state.error = newState.error;
444
+
state.savedAccounts = newState.savedAccounts ?? [];
395
445
}
396
446
397
447
export function _testReset() {
398
-
state.session = null
399
-
state.loading = true
400
-
state.error = null
401
-
state.savedAccounts = []
402
-
localStorage.removeItem(STORAGE_KEY)
403
-
localStorage.removeItem(ACCOUNTS_KEY)
448
+
state.session = null;
449
+
state.loading = true;
450
+
state.error = null;
451
+
state.savedAccounts = [];
452
+
localStorage.removeItem(STORAGE_KEY);
453
+
localStorage.removeItem(ACCOUNTS_KEY);
404
454
}
+48
-44
frontend/src/lib/crypto.ts
+48
-44
frontend/src/lib/crypto.ts
···
1
-
import * as secp from '@noble/secp256k1'
2
-
import { base58btc } from 'multiformats/bases/base58'
1
+
import * as secp from "@noble/secp256k1";
2
+
import { base58btc } from "multiformats/bases/base58";
3
3
4
-
const SECP256K1_MULTICODEC_PREFIX = new Uint8Array([0xe7, 0x01])
4
+
const SECP256K1_MULTICODEC_PREFIX = new Uint8Array([0xe7, 0x01]);
5
5
6
6
export interface Keypair {
7
-
privateKey: Uint8Array
8
-
publicKey: Uint8Array
9
-
publicKeyMultibase: string
10
-
publicKeyDidKey: string
7
+
privateKey: Uint8Array;
8
+
publicKey: Uint8Array;
9
+
publicKeyMultibase: string;
10
+
publicKeyDidKey: string;
11
11
}
12
12
13
13
export async function generateKeypair(): Promise<Keypair> {
14
-
const privateKey = secp.utils.randomPrivateKey()
15
-
const publicKey = secp.getPublicKey(privateKey, true)
14
+
const privateKey = secp.utils.randomPrivateKey();
15
+
const publicKey = secp.getPublicKey(privateKey, true);
16
16
17
-
const multicodecKey = new Uint8Array(SECP256K1_MULTICODEC_PREFIX.length + publicKey.length)
18
-
multicodecKey.set(SECP256K1_MULTICODEC_PREFIX, 0)
19
-
multicodecKey.set(publicKey, SECP256K1_MULTICODEC_PREFIX.length)
17
+
const multicodecKey = new Uint8Array(
18
+
SECP256K1_MULTICODEC_PREFIX.length + publicKey.length,
19
+
);
20
+
multicodecKey.set(SECP256K1_MULTICODEC_PREFIX, 0);
21
+
multicodecKey.set(publicKey, SECP256K1_MULTICODEC_PREFIX.length);
20
22
21
-
const publicKeyMultibase = base58btc.encode(multicodecKey)
22
-
const publicKeyDidKey = `did:key:${publicKeyMultibase}`
23
+
const publicKeyMultibase = base58btc.encode(multicodecKey);
24
+
const publicKeyDidKey = `did:key:${publicKeyMultibase}`;
23
25
24
26
return {
25
27
privateKey,
26
28
publicKey,
27
29
publicKeyMultibase,
28
30
publicKeyDidKey,
29
-
}
31
+
};
30
32
}
31
33
32
34
function base64UrlEncode(data: Uint8Array | string): string {
33
-
const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data
34
-
let binary = ''
35
+
const bytes = typeof data === "string"
36
+
? new TextEncoder().encode(data)
37
+
: data;
38
+
let binary = "";
35
39
for (let i = 0; i < bytes.length; i++) {
36
-
binary += String.fromCharCode(bytes[i])
40
+
binary += String.fromCharCode(bytes[i]);
37
41
}
38
-
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
42
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
39
43
}
40
44
41
45
export async function createServiceJwt(
42
46
privateKey: Uint8Array,
43
47
issuerDid: string,
44
48
audienceDid: string,
45
-
lxm: string
49
+
lxm: string,
46
50
): Promise<string> {
47
51
const header = {
48
-
alg: 'ES256K',
49
-
typ: 'JWT',
50
-
}
52
+
alg: "ES256K",
53
+
typ: "JWT",
54
+
};
51
55
52
-
const now = Math.floor(Date.now() / 1000)
56
+
const now = Math.floor(Date.now() / 1000);
53
57
const payload = {
54
58
iss: issuerDid,
55
59
sub: issuerDid,
···
57
61
exp: now + 180,
58
62
iat: now,
59
63
lxm: lxm,
60
-
}
64
+
};
61
65
62
-
const headerEncoded = base64UrlEncode(JSON.stringify(header))
63
-
const payloadEncoded = base64UrlEncode(JSON.stringify(payload))
64
-
const message = `${headerEncoded}.${payloadEncoded}`
66
+
const headerEncoded = base64UrlEncode(JSON.stringify(header));
67
+
const payloadEncoded = base64UrlEncode(JSON.stringify(payload));
68
+
const message = `${headerEncoded}.${payloadEncoded}`;
65
69
66
-
const msgBytes = new TextEncoder().encode(message)
67
-
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBytes)
68
-
const msgHash = new Uint8Array(hashBuffer)
69
-
const signature = await secp.signAsync(msgHash, privateKey)
70
-
const sigBytes = signature.toCompactRawBytes()
71
-
const signatureEncoded = base64UrlEncode(sigBytes)
70
+
const msgBytes = new TextEncoder().encode(message);
71
+
const hashBuffer = await crypto.subtle.digest("SHA-256", msgBytes);
72
+
const msgHash = new Uint8Array(hashBuffer);
73
+
const signature = await secp.signAsync(msgHash, privateKey);
74
+
const sigBytes = signature.toCompactRawBytes();
75
+
const signatureEncoded = base64UrlEncode(sigBytes);
72
76
73
-
return `${message}.${signatureEncoded}`
77
+
return `${message}.${signatureEncoded}`;
74
78
}
75
79
76
80
export function generateDidDocument(
77
81
did: string,
78
82
publicKeyMultibase: string,
79
83
handle: string,
80
-
pdsEndpoint: string
84
+
pdsEndpoint: string,
81
85
): object {
82
86
return {
83
-
'@context': [
84
-
'https://www.w3.org/ns/did/v1',
85
-
'https://w3id.org/security/multikey/v1',
86
-
'https://w3id.org/security/suites/secp256k1-2019/v1',
87
+
"@context": [
88
+
"https://www.w3.org/ns/did/v1",
89
+
"https://w3id.org/security/multikey/v1",
90
+
"https://w3id.org/security/suites/secp256k1-2019/v1",
87
91
],
88
92
id: did,
89
93
alsoKnownAs: [`at://${handle}`],
90
94
verificationMethod: [
91
95
{
92
96
id: `${did}#atproto`,
93
-
type: 'Multikey',
97
+
type: "Multikey",
94
98
controller: did,
95
99
publicKeyMultibase: publicKeyMultibase,
96
100
},
97
101
],
98
102
service: [
99
103
{
100
-
id: '#atproto_pds',
101
-
type: 'AtprotoPersonalDataServer',
104
+
id: "#atproto_pds",
105
+
type: "AtprotoPersonalDataServer",
102
106
serviceEndpoint: pdsEndpoint,
103
107
},
104
108
],
105
-
}
109
+
};
106
110
}
+12
-12
frontend/src/lib/date.ts
+12
-12
frontend/src/lib/date.ts
···
1
1
export function formatDate(dateStr: string): string {
2
-
const date = new Date(dateStr)
3
-
const year = date.getFullYear()
4
-
const month = String(date.getMonth() + 1).padStart(2, '0')
5
-
const day = String(date.getDate()).padStart(2, '0')
6
-
return `${year}-${month}-${day}`
2
+
const date = new Date(dateStr);
3
+
const year = date.getFullYear();
4
+
const month = String(date.getMonth() + 1).padStart(2, "0");
5
+
const day = String(date.getDate()).padStart(2, "0");
6
+
return `${year}-${month}-${day}`;
7
7
}
8
8
9
9
export function formatDateTime(dateStr: string): string {
10
-
const date = new Date(dateStr)
11
-
const year = date.getFullYear()
12
-
const month = String(date.getMonth() + 1).padStart(2, '0')
13
-
const day = String(date.getDate()).padStart(2, '0')
14
-
const hours = String(date.getHours()).padStart(2, '0')
15
-
const minutes = String(date.getMinutes()).padStart(2, '0')
16
-
return `${year}-${month}-${day} ${hours}:${minutes}`
10
+
const date = new Date(dateStr);
11
+
const year = date.getFullYear();
12
+
const month = String(date.getMonth() + 1).padStart(2, "0");
13
+
const day = String(date.getDate()).padStart(2, "0");
14
+
const hours = String(date.getHours()).padStart(2, "0");
15
+
const minutes = String(date.getMinutes()).padStart(2, "0");
16
+
return `${year}-${month}-${day} ${hours}:${minutes}`;
17
17
}
+31
-31
frontend/src/lib/i18n.ts
+31
-31
frontend/src/lib/i18n.ts
···
1
-
import { register, init, getLocaleFromNavigator, locale, _ } from 'svelte-i18n'
1
+
import { _, getLocaleFromNavigator, init, locale, register } from "svelte-i18n";
2
2
3
-
const LOCALE_STORAGE_KEY = 'tranquil-pds-locale'
3
+
const LOCALE_STORAGE_KEY = "tranquil-pds-locale";
4
4
5
-
const SUPPORTED_LOCALES = ['en', 'zh', 'ja', 'ko', 'sv', 'fi'] as const
6
-
export type SupportedLocale = typeof SUPPORTED_LOCALES[number]
5
+
const SUPPORTED_LOCALES = ["en", "zh", "ja", "ko", "sv", "fi"] as const;
6
+
export type SupportedLocale = typeof SUPPORTED_LOCALES[number];
7
7
8
8
export const localeNames: Record<SupportedLocale, string> = {
9
-
en: 'English',
10
-
zh: '中文',
11
-
ja: '日本語',
12
-
ko: '한국어',
13
-
sv: 'Svenska',
14
-
fi: 'Suomi'
15
-
}
9
+
en: "English",
10
+
zh: "中文",
11
+
ja: "日本語",
12
+
ko: "한국어",
13
+
sv: "Svenska",
14
+
fi: "Suomi",
15
+
};
16
16
17
-
register('en', () => import('../locales/en.json'))
18
-
register('zh', () => import('../locales/zh.json'))
19
-
register('ja', () => import('../locales/ja.json'))
20
-
register('ko', () => import('../locales/ko.json'))
21
-
register('sv', () => import('../locales/sv.json'))
22
-
register('fi', () => import('../locales/fi.json'))
17
+
register("en", () => import("../locales/en.json"));
18
+
register("zh", () => import("../locales/zh.json"));
19
+
register("ja", () => import("../locales/ja.json"));
20
+
register("ko", () => import("../locales/ko.json"));
21
+
register("sv", () => import("../locales/sv.json"));
22
+
register("fi", () => import("../locales/fi.json"));
23
23
24
24
function getInitialLocale(): string {
25
-
const stored = localStorage.getItem(LOCALE_STORAGE_KEY)
25
+
const stored = localStorage.getItem(LOCALE_STORAGE_KEY);
26
26
if (stored && SUPPORTED_LOCALES.includes(stored as SupportedLocale)) {
27
-
return stored
27
+
return stored;
28
28
}
29
29
30
-
const browserLocale = getLocaleFromNavigator()
30
+
const browserLocale = getLocaleFromNavigator();
31
31
if (browserLocale) {
32
-
const lang = browserLocale.split('-')[0]
32
+
const lang = browserLocale.split("-")[0];
33
33
if (SUPPORTED_LOCALES.includes(lang as SupportedLocale)) {
34
-
return lang
34
+
return lang;
35
35
}
36
36
}
37
37
38
-
return 'en'
38
+
return "en";
39
39
}
40
40
41
41
export function initI18n() {
42
42
init({
43
-
fallbackLocale: 'en',
44
-
initialLocale: getInitialLocale()
45
-
})
43
+
fallbackLocale: "en",
44
+
initialLocale: getInitialLocale(),
45
+
});
46
46
}
47
47
48
48
export function setLocale(newLocale: SupportedLocale) {
49
-
locale.set(newLocale)
50
-
localStorage.setItem(LOCALE_STORAGE_KEY, newLocale)
51
-
document.documentElement.lang = newLocale
49
+
locale.set(newLocale);
50
+
localStorage.setItem(LOCALE_STORAGE_KEY, newLocale);
51
+
document.documentElement.lang = newLocale;
52
52
}
53
53
54
54
export function getSupportedLocales(): SupportedLocale[] {
55
-
return [...SUPPORTED_LOCALES]
55
+
return [...SUPPORTED_LOCALES];
56
56
}
57
57
58
-
export { locale, _ }
58
+
export { _, locale };
+116
-91
frontend/src/lib/oauth.ts
+116
-91
frontend/src/lib/oauth.ts
···
1
-
const OAUTH_STATE_KEY = 'tranquil_pds_oauth_state'
2
-
const OAUTH_VERIFIER_KEY = 'tranquil_pds_oauth_verifier'
1
+
const OAUTH_STATE_KEY = "tranquil_pds_oauth_state";
2
+
const OAUTH_VERIFIER_KEY = "tranquil_pds_oauth_verifier";
3
3
const SCOPES = [
4
-
'atproto',
5
-
'repo:*?action=create',
6
-
'repo:*?action=update',
7
-
'repo:*?action=delete',
8
-
'blob:*/*',
9
-
].join(' ')
4
+
"atproto",
5
+
"repo:*?action=create",
6
+
"repo:*?action=update",
7
+
"repo:*?action=delete",
8
+
"blob:*/*",
9
+
].join(" ");
10
10
const CLIENT_ID = !(import.meta.env.DEV)
11
-
? `${window.location.origin}/oauth/client-metadata.json`
12
-
: `http://localhost/?scope=${SCOPES}`
13
-
const REDIRECT_URI = `${window.location.origin}/`
11
+
? `${window.location.origin}/oauth/client-metadata.json`
12
+
: `http://localhost/?scope=${SCOPES}`;
13
+
const REDIRECT_URI = `${window.location.origin}/`;
14
14
15
15
interface OAuthState {
16
-
state: string
17
-
codeVerifier: string
18
-
returnTo?: string
16
+
state: string;
17
+
codeVerifier: string;
18
+
returnTo?: string;
19
19
}
20
20
21
21
function generateRandomString(length: number): string {
22
-
const array = new Uint8Array(length)
23
-
crypto.getRandomValues(array)
24
-
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('')
22
+
const array = new Uint8Array(length);
23
+
crypto.getRandomValues(array);
24
+
return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join(
25
+
"",
26
+
);
25
27
}
26
28
27
29
async function sha256(plain: string): Promise<ArrayBuffer> {
28
-
const encoder = new TextEncoder()
29
-
const data = encoder.encode(plain)
30
-
return crypto.subtle.digest('SHA-256', data)
30
+
const encoder = new TextEncoder();
31
+
const data = encoder.encode(plain);
32
+
return crypto.subtle.digest("SHA-256", data);
31
33
}
32
34
33
35
function base64UrlEncode(buffer: ArrayBuffer): string {
34
-
const bytes = new Uint8Array(buffer)
35
-
let binary = ''
36
+
const bytes = new Uint8Array(buffer);
37
+
let binary = "";
36
38
for (const byte of bytes) {
37
-
binary += String.fromCharCode(byte)
39
+
binary += String.fromCharCode(byte);
38
40
}
39
-
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
41
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(
42
+
/=+$/,
43
+
"",
44
+
);
40
45
}
41
46
42
47
async function generateCodeChallenge(verifier: string): Promise<string> {
43
-
const hash = await sha256(verifier)
44
-
return base64UrlEncode(hash)
48
+
const hash = await sha256(verifier);
49
+
return base64UrlEncode(hash);
45
50
}
46
51
47
52
function generateState(): string {
48
-
return generateRandomString(32)
53
+
return generateRandomString(32);
49
54
}
50
55
51
56
function generateCodeVerifier(): string {
52
-
return generateRandomString(32)
57
+
return generateRandomString(32);
53
58
}
54
59
55
60
function saveOAuthState(state: OAuthState): void {
56
-
sessionStorage.setItem(OAUTH_STATE_KEY, state.state)
57
-
sessionStorage.setItem(OAUTH_VERIFIER_KEY, state.codeVerifier)
61
+
sessionStorage.setItem(OAUTH_STATE_KEY, state.state);
62
+
sessionStorage.setItem(OAUTH_VERIFIER_KEY, state.codeVerifier);
58
63
}
59
64
60
65
function getOAuthState(): OAuthState | null {
61
-
const state = sessionStorage.getItem(OAUTH_STATE_KEY)
62
-
const codeVerifier = sessionStorage.getItem(OAUTH_VERIFIER_KEY)
63
-
if (!state || !codeVerifier) return null
64
-
return { state, codeVerifier }
66
+
const state = sessionStorage.getItem(OAUTH_STATE_KEY);
67
+
const codeVerifier = sessionStorage.getItem(OAUTH_VERIFIER_KEY);
68
+
if (!state || !codeVerifier) return null;
69
+
return { state, codeVerifier };
65
70
}
66
71
67
72
function clearOAuthState(): void {
68
-
sessionStorage.removeItem(OAUTH_STATE_KEY)
69
-
sessionStorage.removeItem(OAUTH_VERIFIER_KEY)
73
+
sessionStorage.removeItem(OAUTH_STATE_KEY);
74
+
sessionStorage.removeItem(OAUTH_VERIFIER_KEY);
70
75
}
71
76
72
77
export async function startOAuthLogin(): Promise<void> {
73
-
const state = generateState()
74
-
const codeVerifier = generateCodeVerifier()
75
-
const codeChallenge = await generateCodeChallenge(codeVerifier)
78
+
const state = generateState();
79
+
const codeVerifier = generateCodeVerifier();
80
+
const codeChallenge = await generateCodeChallenge(codeVerifier);
76
81
77
-
saveOAuthState({ state, codeVerifier })
82
+
saveOAuthState({ state, codeVerifier });
78
83
79
-
const parResponse = await fetch('/oauth/par', {
80
-
method: 'POST',
81
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
84
+
const parResponse = await fetch("/oauth/par", {
85
+
method: "POST",
86
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
82
87
body: new URLSearchParams({
83
88
client_id: CLIENT_ID,
84
89
redirect_uri: REDIRECT_URI,
85
-
response_type: 'code',
90
+
response_type: "code",
86
91
scope: SCOPES,
87
92
state: state,
88
93
code_challenge: codeChallenge,
89
-
code_challenge_method: 'S256',
94
+
code_challenge_method: "S256",
90
95
}),
91
-
})
96
+
});
92
97
93
98
if (!parResponse.ok) {
94
-
const error = await parResponse.json().catch(() => ({ error: 'Unknown error' }))
95
-
throw new Error(error.error_description || error.error || 'Failed to start OAuth flow')
99
+
const error = await parResponse.json().catch(() => ({
100
+
error: "Unknown error",
101
+
}));
102
+
throw new Error(
103
+
error.error_description || error.error || "Failed to start OAuth flow",
104
+
);
96
105
}
97
106
98
-
const { request_uri } = await parResponse.json()
107
+
const { request_uri } = await parResponse.json();
99
108
100
-
const authorizeUrl = new URL('/oauth/authorize', window.location.origin)
101
-
authorizeUrl.searchParams.set('client_id', CLIENT_ID)
102
-
authorizeUrl.searchParams.set('request_uri', request_uri)
109
+
const authorizeUrl = new URL("/oauth/authorize", window.location.origin);
110
+
authorizeUrl.searchParams.set("client_id", CLIENT_ID);
111
+
authorizeUrl.searchParams.set("request_uri", request_uri);
103
112
104
-
window.location.href = authorizeUrl.toString()
113
+
window.location.href = authorizeUrl.toString();
105
114
}
106
115
107
116
export interface OAuthTokens {
108
-
access_token: string
109
-
refresh_token?: string
110
-
token_type: string
111
-
expires_in?: number
112
-
scope?: string
113
-
sub: string
117
+
access_token: string;
118
+
refresh_token?: string;
119
+
token_type: string;
120
+
expires_in?: number;
121
+
scope?: string;
122
+
sub: string;
114
123
}
115
124
116
-
export async function handleOAuthCallback(code: string, state: string): Promise<OAuthTokens> {
117
-
const savedState = getOAuthState()
125
+
export async function handleOAuthCallback(
126
+
code: string,
127
+
state: string,
128
+
): Promise<OAuthTokens> {
129
+
const savedState = getOAuthState();
118
130
if (!savedState) {
119
-
throw new Error('No OAuth state found. Please try logging in again.')
131
+
throw new Error("No OAuth state found. Please try logging in again.");
120
132
}
121
133
122
134
if (savedState.state !== state) {
123
-
clearOAuthState()
124
-
throw new Error('OAuth state mismatch. Please try logging in again.')
135
+
clearOAuthState();
136
+
throw new Error("OAuth state mismatch. Please try logging in again.");
125
137
}
126
138
127
-
const tokenResponse = await fetch('/oauth/token', {
128
-
method: 'POST',
129
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
139
+
const tokenResponse = await fetch("/oauth/token", {
140
+
method: "POST",
141
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
130
142
body: new URLSearchParams({
131
-
grant_type: 'authorization_code',
143
+
grant_type: "authorization_code",
132
144
client_id: CLIENT_ID,
133
145
code: code,
134
146
redirect_uri: REDIRECT_URI,
135
147
code_verifier: savedState.codeVerifier,
136
148
}),
137
-
})
149
+
});
138
150
139
-
clearOAuthState()
151
+
clearOAuthState();
140
152
141
153
if (!tokenResponse.ok) {
142
-
const error = await tokenResponse.json().catch(() => ({ error: 'Unknown error' }))
143
-
throw new Error(error.error_description || error.error || 'Failed to exchange code for tokens')
154
+
const error = await tokenResponse.json().catch(() => ({
155
+
error: "Unknown error",
156
+
}));
157
+
throw new Error(
158
+
error.error_description || error.error ||
159
+
"Failed to exchange code for tokens",
160
+
);
144
161
}
145
162
146
-
return tokenResponse.json()
163
+
return tokenResponse.json();
147
164
}
148
165
149
-
export async function refreshOAuthToken(refreshToken: string): Promise<OAuthTokens> {
150
-
const tokenResponse = await fetch('/oauth/token', {
151
-
method: 'POST',
152
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
166
+
export async function refreshOAuthToken(
167
+
refreshToken: string,
168
+
): Promise<OAuthTokens> {
169
+
const tokenResponse = await fetch("/oauth/token", {
170
+
method: "POST",
171
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
153
172
body: new URLSearchParams({
154
-
grant_type: 'refresh_token',
173
+
grant_type: "refresh_token",
155
174
client_id: CLIENT_ID,
156
175
refresh_token: refreshToken,
157
176
}),
158
-
})
177
+
});
159
178
160
179
if (!tokenResponse.ok) {
161
-
const error = await tokenResponse.json().catch(() => ({ error: 'Unknown error' }))
162
-
throw new Error(error.error_description || error.error || 'Failed to refresh token')
180
+
const error = await tokenResponse.json().catch(() => ({
181
+
error: "Unknown error",
182
+
}));
183
+
throw new Error(
184
+
error.error_description || error.error || "Failed to refresh token",
185
+
);
163
186
}
164
187
165
-
return tokenResponse.json()
188
+
return tokenResponse.json();
166
189
}
167
190
168
-
export function checkForOAuthCallback(): { code: string; state: string } | null {
169
-
const params = new URLSearchParams(window.location.search)
170
-
const code = params.get('code')
171
-
const state = params.get('state')
191
+
export function checkForOAuthCallback():
192
+
| { code: string; state: string }
193
+
| null {
194
+
const params = new URLSearchParams(window.location.search);
195
+
const code = params.get("code");
196
+
const state = params.get("state");
172
197
173
198
if (code && state) {
174
-
return { code, state }
199
+
return { code, state };
175
200
}
176
201
177
-
return null
202
+
return null;
178
203
}
179
204
180
205
export function clearOAuthCallbackParams(): void {
181
-
const url = new URL(window.location.href)
182
-
url.search = ''
183
-
window.history.replaceState({}, '', url.toString())
206
+
const url = new URL(window.location.href);
207
+
url.search = "";
208
+
window.history.replaceState({}, "", url.toString());
184
209
}
+200
-141
frontend/src/lib/registration/flow.svelte.ts
+200
-141
frontend/src/lib/registration/flow.svelte.ts
···
1
-
import { api, ApiError } from '../api'
2
-
import { generateKeypair, createServiceJwt, generateDidDocument } from '../crypto'
1
+
import { api, ApiError } from "../api";
2
+
import {
3
+
createServiceJwt,
4
+
generateDidDocument,
5
+
generateKeypair,
6
+
} from "../crypto";
3
7
import type {
8
+
AccountResult,
9
+
ExternalDidWebState,
10
+
RegistrationInfo,
4
11
RegistrationMode,
5
12
RegistrationStep,
6
-
RegistrationInfo,
7
-
ExternalDidWebState,
8
-
AccountResult,
9
13
SessionState,
10
-
} from './types'
14
+
} from "./types";
11
15
12
16
export interface RegistrationFlowState {
13
-
mode: RegistrationMode
14
-
step: RegistrationStep
15
-
info: RegistrationInfo
16
-
externalDidWeb: ExternalDidWebState
17
-
account: AccountResult | null
18
-
session: SessionState | null
19
-
error: string | null
20
-
submitting: boolean
21
-
pdsHostname: string
17
+
mode: RegistrationMode;
18
+
step: RegistrationStep;
19
+
info: RegistrationInfo;
20
+
externalDidWeb: ExternalDidWebState;
21
+
account: AccountResult | null;
22
+
session: SessionState | null;
23
+
error: string | null;
24
+
submitting: boolean;
25
+
pdsHostname: string;
22
26
}
23
27
24
-
export function createRegistrationFlow(mode: RegistrationMode, pdsHostname: string) {
28
+
export function createRegistrationFlow(
29
+
mode: RegistrationMode,
30
+
pdsHostname: string,
31
+
) {
25
32
let state = $state<RegistrationFlowState>({
26
33
mode,
27
-
step: 'info',
34
+
step: "info",
28
35
info: {
29
-
handle: '',
30
-
email: '',
31
-
password: '',
32
-
inviteCode: '',
33
-
didType: 'plc',
34
-
externalDid: '',
35
-
verificationChannel: 'email',
36
-
discordId: '',
37
-
telegramUsername: '',
38
-
signalNumber: '',
36
+
handle: "",
37
+
email: "",
38
+
password: "",
39
+
inviteCode: "",
40
+
didType: "plc",
41
+
externalDid: "",
42
+
verificationChannel: "email",
43
+
discordId: "",
44
+
telegramUsername: "",
45
+
signalNumber: "",
39
46
},
40
47
externalDidWeb: {
41
-
keyMode: 'reserved',
48
+
keyMode: "reserved",
42
49
},
43
50
account: null,
44
51
session: null,
45
52
error: null,
46
53
submitting: false,
47
54
pdsHostname,
48
-
})
55
+
});
49
56
50
57
function getPdsEndpoint(): string {
51
-
return `https://${state.pdsHostname}`
58
+
return `https://${state.pdsHostname}`;
52
59
}
53
60
54
61
function getPdsDid(): string {
55
-
return `did:web:${state.pdsHostname}`
62
+
return `did:web:${state.pdsHostname}`;
56
63
}
57
64
58
65
function getFullHandle(): string {
59
-
return `${state.info.handle.trim()}.${state.pdsHostname}`
66
+
return `${state.info.handle.trim()}.${state.pdsHostname}`;
60
67
}
61
68
62
69
function extractDomain(did: string): string {
63
-
return did.replace('did:web:', '').replace(/%3A/g, ':')
70
+
return did.replace("did:web:", "").replace(/%3A/g, ":");
64
71
}
65
72
66
73
function setError(err: unknown) {
67
74
if (err instanceof ApiError) {
68
-
state.error = err.message || 'An error occurred'
75
+
state.error = err.message || "An error occurred";
69
76
} else if (err instanceof Error) {
70
-
state.error = err.message || 'An error occurred'
77
+
state.error = err.message || "An error occurred";
71
78
} else {
72
-
state.error = 'An error occurred'
79
+
state.error = "An error occurred";
73
80
}
74
81
}
75
82
76
83
async function proceedFromInfo() {
77
-
state.error = null
78
-
if (state.info.didType === 'web-external') {
79
-
state.step = 'key-choice'
84
+
state.error = null;
85
+
if (state.info.didType === "web-external") {
86
+
state.step = "key-choice";
80
87
} else {
81
-
state.step = 'creating'
88
+
state.step = "creating";
82
89
}
83
90
}
84
91
85
-
async function selectKeyMode(keyMode: 'reserved' | 'byod') {
86
-
state.submitting = true
87
-
state.error = null
88
-
state.externalDidWeb.keyMode = keyMode
92
+
async function selectKeyMode(keyMode: "reserved" | "byod") {
93
+
state.submitting = true;
94
+
state.error = null;
95
+
state.externalDidWeb.keyMode = keyMode;
89
96
90
97
try {
91
-
let publicKeyMultibase: string
98
+
let publicKeyMultibase: string;
92
99
93
-
if (keyMode === 'reserved') {
94
-
const result = await api.reserveSigningKey(state.info.externalDid!.trim())
95
-
state.externalDidWeb.reservedSigningKey = result.signingKey
96
-
publicKeyMultibase = result.signingKey.replace('did:key:', '')
100
+
if (keyMode === "reserved") {
101
+
const result = await api.reserveSigningKey(
102
+
state.info.externalDid!.trim(),
103
+
);
104
+
state.externalDidWeb.reservedSigningKey = result.signingKey;
105
+
publicKeyMultibase = result.signingKey.replace("did:key:", "");
97
106
} else {
98
-
const keypair = await generateKeypair()
99
-
state.externalDidWeb.byodPrivateKey = keypair.privateKey
100
-
state.externalDidWeb.byodPublicKeyMultibase = keypair.publicKeyMultibase
101
-
publicKeyMultibase = keypair.publicKeyMultibase
107
+
const keypair = await generateKeypair();
108
+
state.externalDidWeb.byodPrivateKey = keypair.privateKey;
109
+
state.externalDidWeb.byodPublicKeyMultibase =
110
+
keypair.publicKeyMultibase;
111
+
publicKeyMultibase = keypair.publicKeyMultibase;
102
112
}
103
113
104
114
const didDoc = generateDidDocument(
105
115
state.info.externalDid!.trim(),
106
116
publicKeyMultibase,
107
117
getFullHandle(),
108
-
getPdsEndpoint()
109
-
)
110
-
state.externalDidWeb.initialDidDocument = JSON.stringify(didDoc, null, '\t')
111
-
state.step = 'initial-did-doc'
118
+
getPdsEndpoint(),
119
+
);
120
+
state.externalDidWeb.initialDidDocument = JSON.stringify(
121
+
didDoc,
122
+
null,
123
+
"\t",
124
+
);
125
+
state.step = "initial-did-doc";
112
126
} catch (err) {
113
-
setError(err)
127
+
setError(err);
114
128
} finally {
115
-
state.submitting = false
129
+
state.submitting = false;
116
130
}
117
131
}
118
132
119
133
async function confirmInitialDidDoc() {
120
-
state.step = 'creating'
134
+
state.step = "creating";
121
135
}
122
136
123
137
async function createPasswordAccount() {
124
-
state.submitting = true
125
-
state.error = null
138
+
state.submitting = true;
139
+
state.error = null;
126
140
127
141
try {
128
-
let byodToken: string | undefined
142
+
let byodToken: string | undefined;
129
143
130
-
if (state.info.didType === 'web-external' && state.externalDidWeb.keyMode === 'byod' && state.externalDidWeb.byodPrivateKey) {
144
+
if (
145
+
state.info.didType === "web-external" &&
146
+
state.externalDidWeb.keyMode === "byod" &&
147
+
state.externalDidWeb.byodPrivateKey
148
+
) {
131
149
byodToken = await createServiceJwt(
132
150
state.externalDidWeb.byodPrivateKey,
133
151
state.info.externalDid!.trim(),
134
152
getPdsDid(),
135
-
'com.atproto.server.createAccount'
136
-
)
153
+
"com.atproto.server.createAccount",
154
+
);
137
155
}
138
156
139
157
const result = await api.createAccount({
···
142
160
password: state.info.password!,
143
161
inviteCode: state.info.inviteCode?.trim() || undefined,
144
162
didType: state.info.didType,
145
-
did: state.info.didType === 'web-external' ? state.info.externalDid!.trim() : undefined,
146
-
signingKey: state.info.didType === 'web-external' && state.externalDidWeb.keyMode === 'reserved'
163
+
did: state.info.didType === "web-external"
164
+
? state.info.externalDid!.trim()
165
+
: undefined,
166
+
signingKey: state.info.didType === "web-external" &&
167
+
state.externalDidWeb.keyMode === "reserved"
147
168
? state.externalDidWeb.reservedSigningKey
148
169
: undefined,
149
170
verificationChannel: state.info.verificationChannel,
150
171
discordId: state.info.discordId?.trim() || undefined,
151
172
telegramUsername: state.info.telegramUsername?.trim() || undefined,
152
173
signalNumber: state.info.signalNumber?.trim() || undefined,
153
-
}, byodToken)
174
+
}, byodToken);
154
175
155
176
state.account = {
156
177
did: result.did,
157
178
handle: result.handle,
158
-
}
159
-
state.step = 'verify'
179
+
};
180
+
state.step = "verify";
160
181
} catch (err) {
161
-
setError(err)
182
+
setError(err);
162
183
} finally {
163
-
state.submitting = false
184
+
state.submitting = false;
164
185
}
165
186
}
166
187
167
188
async function createPasskeyAccount() {
168
-
state.submitting = true
169
-
state.error = null
189
+
state.submitting = true;
190
+
state.error = null;
170
191
171
192
try {
172
-
let byodToken: string | undefined
193
+
let byodToken: string | undefined;
173
194
174
-
if (state.info.didType === 'web-external' && state.externalDidWeb.keyMode === 'byod' && state.externalDidWeb.byodPrivateKey) {
195
+
if (
196
+
state.info.didType === "web-external" &&
197
+
state.externalDidWeb.keyMode === "byod" &&
198
+
state.externalDidWeb.byodPrivateKey
199
+
) {
175
200
byodToken = await createServiceJwt(
176
201
state.externalDidWeb.byodPrivateKey,
177
202
state.info.externalDid!.trim(),
178
203
getPdsDid(),
179
-
'com.atproto.server.createAccount'
180
-
)
204
+
"com.atproto.server.createAccount",
205
+
);
181
206
}
182
207
183
208
const result = await api.createPasskeyAccount({
···
185
210
email: state.info.email?.trim() || undefined,
186
211
inviteCode: state.info.inviteCode?.trim() || undefined,
187
212
didType: state.info.didType,
188
-
did: state.info.didType === 'web-external' ? state.info.externalDid!.trim() : undefined,
189
-
signingKey: state.info.didType === 'web-external' && state.externalDidWeb.keyMode === 'reserved'
213
+
did: state.info.didType === "web-external"
214
+
? state.info.externalDid!.trim()
215
+
: undefined,
216
+
signingKey: state.info.didType === "web-external" &&
217
+
state.externalDidWeb.keyMode === "reserved"
190
218
? state.externalDidWeb.reservedSigningKey
191
219
: undefined,
192
220
verificationChannel: state.info.verificationChannel,
193
221
discordId: state.info.discordId?.trim() || undefined,
194
222
telegramUsername: state.info.telegramUsername?.trim() || undefined,
195
223
signalNumber: state.info.signalNumber?.trim() || undefined,
196
-
}, byodToken)
224
+
}, byodToken);
197
225
198
226
state.account = {
199
227
did: result.did,
200
228
handle: result.handle,
201
229
setupToken: result.setupToken,
202
-
}
203
-
state.step = 'passkey'
230
+
};
231
+
state.step = "passkey";
204
232
} catch (err) {
205
-
setError(err)
233
+
setError(err);
206
234
} finally {
207
-
state.submitting = false
235
+
state.submitting = false;
208
236
}
209
237
}
210
238
211
239
function setPasskeyComplete(appPassword: string, appPasswordName: string) {
212
240
if (state.account) {
213
-
state.account.appPassword = appPassword
214
-
state.account.appPasswordName = appPasswordName
241
+
state.account.appPassword = appPassword;
242
+
state.account.appPasswordName = appPasswordName;
215
243
}
216
-
state.step = 'app-password'
244
+
state.step = "app-password";
217
245
}
218
246
219
247
function proceedFromAppPassword() {
220
-
state.step = 'verify'
248
+
state.step = "verify";
221
249
}
222
250
223
251
async function verifyAccount(code: string) {
224
-
state.submitting = true
225
-
state.error = null
252
+
state.submitting = true;
253
+
state.error = null;
226
254
227
255
try {
228
-
const confirmResult = await api.confirmSignup(state.account!.did, code.trim())
256
+
const confirmResult = await api.confirmSignup(
257
+
state.account!.did,
258
+
code.trim(),
259
+
);
229
260
230
-
if (state.info.didType === 'web-external') {
231
-
const password = state.mode === 'passkey' ? state.account!.appPassword! : state.info.password!
232
-
const session = await api.createSession(state.account!.did, password)
261
+
if (state.info.didType === "web-external") {
262
+
const password = state.mode === "passkey"
263
+
? state.account!.appPassword!
264
+
: state.info.password!;
265
+
const session = await api.createSession(state.account!.did, password);
233
266
state.session = {
234
267
accessJwt: session.accessJwt,
235
268
refreshJwt: session.refreshJwt,
236
-
}
269
+
};
237
270
238
-
if (state.externalDidWeb.keyMode === 'byod') {
239
-
const credentials = await api.getRecommendedDidCredentials(session.accessJwt)
240
-
const newPublicKeyMultibase = credentials.verificationMethods?.atproto?.replace('did:key:', '') || ''
271
+
if (state.externalDidWeb.keyMode === "byod") {
272
+
const credentials = await api.getRecommendedDidCredentials(
273
+
session.accessJwt,
274
+
);
275
+
const newPublicKeyMultibase =
276
+
credentials.verificationMethods?.atproto?.replace("did:key:", "") ||
277
+
"";
241
278
242
279
const didDoc = generateDidDocument(
243
280
state.info.externalDid!.trim(),
244
281
newPublicKeyMultibase,
245
282
state.account!.handle,
246
-
getPdsEndpoint()
247
-
)
248
-
state.externalDidWeb.updatedDidDocument = JSON.stringify(didDoc, null, '\t')
249
-
state.step = 'updated-did-doc'
283
+
getPdsEndpoint(),
284
+
);
285
+
state.externalDidWeb.updatedDidDocument = JSON.stringify(
286
+
didDoc,
287
+
null,
288
+
"\t",
289
+
);
290
+
state.step = "updated-did-doc";
250
291
} else {
251
-
await api.activateAccount(session.accessJwt)
252
-
await finalizeSession()
253
-
state.step = 'redirect-to-dashboard'
292
+
await api.activateAccount(session.accessJwt);
293
+
await finalizeSession();
294
+
state.step = "redirect-to-dashboard";
254
295
}
255
296
} else {
256
297
state.session = {
257
298
accessJwt: confirmResult.accessJwt,
258
299
refreshJwt: confirmResult.refreshJwt,
259
-
}
260
-
await finalizeSession()
261
-
state.step = 'redirect-to-dashboard'
300
+
};
301
+
await finalizeSession();
302
+
state.step = "redirect-to-dashboard";
262
303
}
263
304
} catch (err) {
264
-
setError(err)
305
+
setError(err);
265
306
} finally {
266
-
state.submitting = false
307
+
state.submitting = false;
267
308
}
268
309
}
269
310
270
311
async function activateAccount() {
271
-
state.submitting = true
272
-
state.error = null
312
+
state.submitting = true;
313
+
state.error = null;
273
314
274
315
try {
275
-
await api.activateAccount(state.session!.accessJwt)
276
-
await finalizeSession()
277
-
state.step = 'redirect-to-dashboard'
316
+
await api.activateAccount(state.session!.accessJwt);
317
+
await finalizeSession();
318
+
state.step = "redirect-to-dashboard";
278
319
} catch (err) {
279
-
setError(err)
320
+
setError(err);
280
321
} finally {
281
-
state.submitting = false
322
+
state.submitting = false;
282
323
}
283
324
}
284
325
285
326
function goBack() {
286
327
switch (state.step) {
287
-
case 'key-choice':
288
-
state.step = 'info'
289
-
break
290
-
case 'initial-did-doc':
291
-
state.step = 'key-choice'
292
-
break
293
-
case 'passkey':
294
-
state.step = state.info.didType === 'web-external' ? 'initial-did-doc' : 'info'
295
-
break
328
+
case "key-choice":
329
+
state.step = "info";
330
+
break;
331
+
case "initial-did-doc":
332
+
state.step = "key-choice";
333
+
break;
334
+
case "passkey":
335
+
state.step = state.info.didType === "web-external"
336
+
? "initial-did-doc"
337
+
: "info";
338
+
break;
296
339
}
297
340
}
298
341
299
342
async function finalizeSession() {
300
-
if (!state.session || !state.account) return
301
-
const { setSession } = await import('../auth.svelte')
343
+
if (!state.session || !state.account) return;
344
+
const { setSession } = await import("../auth.svelte");
302
345
setSession({
303
346
did: state.account.did,
304
347
handle: state.account.handle,
305
348
accessJwt: state.session.accessJwt,
306
349
refreshJwt: state.session.refreshJwt,
307
-
})
350
+
});
308
351
}
309
352
310
353
return {
311
-
get state() { return state },
312
-
get info() { return state.info },
313
-
get externalDidWeb() { return state.externalDidWeb },
314
-
get account() { return state.account },
315
-
get session() { return state.session },
354
+
get state() {
355
+
return state;
356
+
},
357
+
get info() {
358
+
return state.info;
359
+
},
360
+
get externalDidWeb() {
361
+
return state.externalDidWeb;
362
+
},
363
+
get account() {
364
+
return state.account;
365
+
},
366
+
get session() {
367
+
return state.session;
368
+
},
316
369
317
370
getPdsEndpoint,
318
371
getPdsDid,
···
331
384
finalizeSession,
332
385
goBack,
333
386
334
-
setError(msg: string) { state.error = msg },
335
-
clearError() { state.error = null },
336
-
setSubmitting(val: boolean) { state.submitting = val },
337
-
}
387
+
setError(msg: string) {
388
+
state.error = msg;
389
+
},
390
+
clearError() {
391
+
state.error = null;
392
+
},
393
+
setSubmitting(val: boolean) {
394
+
state.submitting = val;
395
+
},
396
+
};
338
397
}
339
398
340
-
export type RegistrationFlow = ReturnType<typeof createRegistrationFlow>
399
+
export type RegistrationFlow = ReturnType<typeof createRegistrationFlow>;
+6
-6
frontend/src/lib/registration/index.ts
+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'
1
+
import type { DidType, VerificationChannel } from "../api";
2
2
3
-
export type RegistrationMode = 'password' | 'passkey'
3
+
export type RegistrationMode = "password" | "passkey";
4
4
5
5
export type RegistrationStep =
6
-
| 'info'
7
-
| 'key-choice'
8
-
| 'initial-did-doc'
9
-
| 'creating'
10
-
| 'passkey'
11
-
| 'app-password'
12
-
| 'verify'
13
-
| 'updated-did-doc'
14
-
| 'activating'
15
-
| 'redirect-to-dashboard'
6
+
| "info"
7
+
| "key-choice"
8
+
| "initial-did-doc"
9
+
| "creating"
10
+
| "passkey"
11
+
| "app-password"
12
+
| "verify"
13
+
| "updated-did-doc"
14
+
| "activating"
15
+
| "redirect-to-dashboard";
16
16
17
17
export interface RegistrationInfo {
18
-
handle: string
19
-
email: string
20
-
password?: string
21
-
inviteCode?: string
22
-
didType: DidType
23
-
externalDid?: string
24
-
verificationChannel: VerificationChannel
25
-
discordId?: string
26
-
telegramUsername?: string
27
-
signalNumber?: string
18
+
handle: string;
19
+
email: string;
20
+
password?: string;
21
+
inviteCode?: string;
22
+
didType: DidType;
23
+
externalDid?: string;
24
+
verificationChannel: VerificationChannel;
25
+
discordId?: string;
26
+
telegramUsername?: string;
27
+
signalNumber?: string;
28
28
}
29
29
30
30
export interface ExternalDidWebState {
31
-
keyMode: 'reserved' | 'byod'
32
-
reservedSigningKey?: string
33
-
byodPrivateKey?: Uint8Array
34
-
byodPublicKeyMultibase?: string
35
-
initialDidDocument?: string
36
-
updatedDidDocument?: string
31
+
keyMode: "reserved" | "byod";
32
+
reservedSigningKey?: string;
33
+
byodPrivateKey?: Uint8Array;
34
+
byodPublicKeyMultibase?: string;
35
+
initialDidDocument?: string;
36
+
updatedDidDocument?: string;
37
37
}
38
38
39
39
export interface AccountResult {
40
-
did: string
41
-
handle: string
42
-
setupToken?: string
43
-
appPassword?: string
44
-
appPasswordName?: string
40
+
did: string;
41
+
handle: string;
42
+
setupToken?: string;
43
+
appPassword?: string;
44
+
appPasswordName?: string;
45
45
}
46
46
47
47
export interface SessionState {
48
-
accessJwt: string
49
-
refreshJwt: string
48
+
accessJwt: string;
49
+
refreshJwt: string;
50
50
}
+11
-9
frontend/src/lib/router.svelte.ts
+11
-9
frontend/src/lib/router.svelte.ts
···
1
-
let currentPath = $state(getPathWithoutQuery(window.location.hash.slice(1) || '/'))
1
+
let currentPath = $state(
2
+
getPathWithoutQuery(window.location.hash.slice(1) || "/"),
3
+
);
2
4
3
5
function getPathWithoutQuery(hash: string): string {
4
-
const queryIndex = hash.indexOf('?')
5
-
return queryIndex === -1 ? hash : hash.slice(0, queryIndex)
6
+
const queryIndex = hash.indexOf("?");
7
+
return queryIndex === -1 ? hash : hash.slice(0, queryIndex);
6
8
}
7
9
8
-
window.addEventListener('hashchange', () => {
9
-
currentPath = getPathWithoutQuery(window.location.hash.slice(1) || '/')
10
-
})
10
+
window.addEventListener("hashchange", () => {
11
+
currentPath = getPathWithoutQuery(window.location.hash.slice(1) || "/");
12
+
});
11
13
12
14
export function navigate(path: string) {
13
-
currentPath = path
14
-
window.location.hash = path
15
+
currentPath = path;
16
+
window.location.hash = path;
15
17
}
16
18
17
19
export function getCurrentPath() {
18
-
return currentPath
20
+
return currentPath;
19
21
}
+66
-58
frontend/src/lib/serverConfig.svelte.ts
+66
-58
frontend/src/lib/serverConfig.svelte.ts
···
1
-
import { api } from './api'
1
+
import { api } from "./api";
2
2
3
3
interface ServerConfigState {
4
-
serverName: string | null
5
-
primaryColor: string | null
6
-
primaryColorDark: string | null
7
-
secondaryColor: string | null
8
-
secondaryColorDark: string | null
9
-
hasLogo: boolean
10
-
loading: boolean
4
+
serverName: string | null;
5
+
primaryColor: string | null;
6
+
primaryColorDark: string | null;
7
+
secondaryColor: string | null;
8
+
secondaryColorDark: string | null;
9
+
hasLogo: boolean;
10
+
loading: boolean;
11
11
}
12
12
13
13
let state = $state<ServerConfigState>({
···
18
18
secondaryColorDark: null,
19
19
hasLogo: false,
20
20
loading: true,
21
-
})
21
+
});
22
22
23
-
let initialized = false
24
-
let darkModeQuery: MediaQueryList | null = null
23
+
let initialized = false;
24
+
let darkModeQuery: MediaQueryList | null = null;
25
25
26
26
function isDarkMode(): boolean {
27
-
return darkModeQuery?.matches ?? false
27
+
return darkModeQuery?.matches ?? false;
28
28
}
29
29
30
30
function applyColors() {
31
-
const root = document.documentElement
32
-
const dark = isDarkMode()
31
+
const root = document.documentElement;
32
+
const dark = isDarkMode();
33
33
34
34
if (dark) {
35
35
if (state.primaryColorDark) {
36
-
root.style.setProperty('--accent', state.primaryColorDark)
36
+
root.style.setProperty("--accent", state.primaryColorDark);
37
37
} else {
38
-
root.style.removeProperty('--accent')
38
+
root.style.removeProperty("--accent");
39
39
}
40
40
if (state.secondaryColorDark) {
41
-
root.style.setProperty('--secondary', state.secondaryColorDark)
41
+
root.style.setProperty("--secondary", state.secondaryColorDark);
42
42
} else {
43
-
root.style.removeProperty('--secondary')
43
+
root.style.removeProperty("--secondary");
44
44
}
45
45
} else {
46
46
if (state.primaryColor) {
47
-
root.style.setProperty('--accent', state.primaryColor)
47
+
root.style.setProperty("--accent", state.primaryColor);
48
48
} else {
49
-
root.style.removeProperty('--accent')
49
+
root.style.removeProperty("--accent");
50
50
}
51
51
if (state.secondaryColor) {
52
-
root.style.setProperty('--secondary', state.secondaryColor)
52
+
root.style.setProperty("--secondary", state.secondaryColor);
53
53
} else {
54
-
root.style.removeProperty('--secondary')
54
+
root.style.removeProperty("--secondary");
55
55
}
56
56
}
57
57
}
58
58
59
59
function setFavicon(hasLogo: boolean) {
60
-
let link = document.querySelector<HTMLLinkElement>("link[rel~='icon']")
60
+
let link = document.querySelector<HTMLLinkElement>("link[rel~='icon']");
61
61
if (hasLogo) {
62
62
if (!link) {
63
-
link = document.createElement('link')
64
-
link.rel = 'icon'
65
-
document.head.appendChild(link)
63
+
link = document.createElement("link");
64
+
link.rel = "icon";
65
+
document.head.appendChild(link);
66
66
}
67
-
link.href = '/logo'
67
+
link.href = "/logo";
68
68
} else if (link) {
69
-
link.remove()
69
+
link.remove();
70
70
}
71
71
}
72
72
73
73
export async function initServerConfig(): Promise<void> {
74
-
if (initialized) return
75
-
initialized = true
74
+
if (initialized) return;
75
+
initialized = true;
76
76
77
-
darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)')
78
-
darkModeQuery.addEventListener('change', applyColors)
77
+
darkModeQuery = window.matchMedia("(prefers-color-scheme: dark)");
78
+
darkModeQuery.addEventListener("change", applyColors);
79
79
80
80
try {
81
-
const config = await api.getServerConfig()
82
-
state.serverName = config.serverName
83
-
state.primaryColor = config.primaryColor
84
-
state.primaryColorDark = config.primaryColorDark
85
-
state.secondaryColor = config.secondaryColor
86
-
state.secondaryColorDark = config.secondaryColorDark
87
-
state.hasLogo = !!config.logoCid
88
-
document.title = config.serverName
89
-
applyColors()
90
-
setFavicon(state.hasLogo)
81
+
const config = await api.getServerConfig();
82
+
state.serverName = config.serverName;
83
+
state.primaryColor = config.primaryColor;
84
+
state.primaryColorDark = config.primaryColorDark;
85
+
state.secondaryColor = config.secondaryColor;
86
+
state.secondaryColorDark = config.secondaryColorDark;
87
+
state.hasLogo = !!config.logoCid;
88
+
document.title = config.serverName;
89
+
applyColors();
90
+
setFavicon(state.hasLogo);
91
91
} catch {
92
-
state.serverName = null
92
+
state.serverName = null;
93
93
} finally {
94
-
state.loading = false
94
+
state.loading = false;
95
95
}
96
96
}
97
97
98
98
export function getServerConfigState() {
99
-
return state
99
+
return state;
100
100
}
101
101
102
102
export function setServerName(name: string) {
103
-
state.serverName = name
104
-
document.title = name
103
+
state.serverName = name;
104
+
document.title = name;
105
105
}
106
106
107
107
export function setColors(colors: {
108
-
primaryColor?: string | null
109
-
primaryColorDark?: string | null
110
-
secondaryColor?: string | null
111
-
secondaryColorDark?: string | null
108
+
primaryColor?: string | null;
109
+
primaryColorDark?: string | null;
110
+
secondaryColor?: string | null;
111
+
secondaryColorDark?: string | null;
112
112
}) {
113
-
if (colors.primaryColor !== undefined) state.primaryColor = colors.primaryColor
114
-
if (colors.primaryColorDark !== undefined) state.primaryColorDark = colors.primaryColorDark
115
-
if (colors.secondaryColor !== undefined) state.secondaryColor = colors.secondaryColor
116
-
if (colors.secondaryColorDark !== undefined) state.secondaryColorDark = colors.secondaryColorDark
117
-
applyColors()
113
+
if (colors.primaryColor !== undefined) {
114
+
state.primaryColor = colors.primaryColor;
115
+
}
116
+
if (colors.primaryColorDark !== undefined) {
117
+
state.primaryColorDark = colors.primaryColorDark;
118
+
}
119
+
if (colors.secondaryColor !== undefined) {
120
+
state.secondaryColor = colors.secondaryColor;
121
+
}
122
+
if (colors.secondaryColorDark !== undefined) {
123
+
state.secondaryColorDark = colors.secondaryColorDark;
124
+
}
125
+
applyColors();
118
126
}
119
127
120
128
export function setHasLogo(hasLogo: boolean) {
121
-
state.hasLogo = hasLogo
122
-
setFavicon(hasLogo)
129
+
state.hasLogo = hasLogo;
130
+
setFavicon(hasLogo);
123
131
}
+41
-6
frontend/src/locales/en.json
+41
-6
frontend/src/locales/en.json
···
30
30
"lostPasskey": "Lost passkey?",
31
31
"noAccount": "Don't have an account?",
32
32
"createAccount": "Create account",
33
-
"removeAccount": "Remove from saved accounts"
33
+
"removeAccount": "Remove from saved accounts",
34
+
"infoSavedAccountsTitle": "Saved accounts",
35
+
"infoSavedAccountsDesc": "Click an account to sign in instantly. Your session tokens are stored securely in this browser.",
36
+
"infoNewAccountTitle": "New account",
37
+
"infoNewAccountDesc": "Use the sign-in button to add a different account. Click the × to remove saved accounts from this browser.",
38
+
"infoSecureSignInTitle": "Secure sign-in",
39
+
"infoSecureSignInDesc": "You'll be redirected to authenticate securely. If you have passkeys or two-factor authentication enabled, you'll be prompted for those too.",
40
+
"infoStaySignedInTitle": "Stay signed in",
41
+
"infoStaySignedInDesc": "After signing in, your account will be saved to this browser for quick access next time.",
42
+
"infoRecoveryTitle": "Account recovery",
43
+
"infoRecoveryDesc": "Lost your password or passkey? Use the recovery links below the sign-in button."
34
44
},
35
45
"verification": {
36
46
"title": "Verify Your Account",
···
47
57
"register": {
48
58
"title": "Create Account",
49
59
"subtitle": "Create a new account on this PDS",
60
+
"subtitleKeyChoice": "Choose how to set up your external did:web identity.",
61
+
"subtitleInitialDidDoc": "Upload your DID document to continue.",
62
+
"subtitleVerify": "Verify your {channel} to continue.",
63
+
"subtitleUpdatedDidDoc": "Update your DID document with the PDS signing key.",
64
+
"subtitleActivating": "Activating your account...",
65
+
"subtitleComplete": "Your account has been created successfully!",
66
+
"redirecting": "Redirecting to dashboard...",
67
+
"infoIdentityDesc": "Your identity determines how your account is identified across the ATProto network. Most users should choose the standard option.",
68
+
"infoContactDesc": "We'll use this to verify your account and send important notifications about your account security.",
69
+
"infoNextTitle": "What happens next?",
70
+
"infoNextDesc": "After creating your account, you'll verify your contact method and then you're ready to use any ATProto app with your new identity.",
50
71
"migrateTitle": "Already have a Bluesky account?",
51
72
"migrateDescription": "You can migrate your existing account to this PDS instead of creating a new one. Your followers, posts, and identity will come with you.",
52
73
"migrateLink": "Migrate with PDS Moover",
···
211
232
"messages": {
212
233
"emailCodeSent": "Verification code sent to your notification channel",
213
234
"emailUpdated": "Email updated successfully",
235
+
"emailUpdateFailed": "Failed to update email",
214
236
"handleUpdated": "Handle updated successfully",
237
+
"handleUpdateFailed": "Failed to update handle",
215
238
"passwordChanged": "Password changed successfully",
239
+
"passwordChangeFailed": "Failed to change password",
216
240
"passwordsMismatch": "Passwords do not match",
241
+
"passwordsDoNotMatch": "Passwords do not match",
217
242
"passwordLength": "Password must be at least 8 characters",
243
+
"passwordTooShort": "Password must be at least 8 characters",
218
244
"deletionCodeSent": "Deletion confirmation sent to your email",
245
+
"deletionConfirmationSent": "Deletion confirmation sent to your email",
246
+
"deletionRequestFailed": "Failed to request account deletion",
247
+
"deleteConfirmation": "Are you absolutely sure you want to delete your account? This cannot be undone.",
248
+
"deletionFailed": "Failed to delete account",
219
249
"repoExported": "Repository exported successfully",
250
+
"exportFailed": "Failed to export repository",
220
251
"confirmDelete": "Are you absolutely sure you want to delete your account? This cannot be undone."
221
252
}
222
253
},
···
362
393
"manageTrustedDevices": "Manage Trusted Devices",
363
394
"appCompatibility": "App Compatibility",
364
395
"enterPassword": "Enter your password",
396
+
"sessionExpired": "Session expired. Please log in again.",
365
397
"legacyLoginEnabled": "Legacy app login enabled",
366
398
"legacyLoginDisabled": "Legacy app login disabled - only OAuth apps can sign in",
367
399
"failedToUpdatePreference": "Failed to update preference",
···
421
453
"noRecords": "No records in this collection",
422
454
"recordDetails": "Record Details",
423
455
"rkey": "Record Key",
456
+
"uri": "URI",
424
457
"cid": "CID",
425
458
"value": "Value",
426
459
"deleteRecord": "Delete Record",
···
463
496
"themeColors": "Theme Colors",
464
497
"themeColorsHint": "Leave blank to use default colors.",
465
498
"primaryLight": "Primary (Light Mode)",
466
-
"primaryLightDefault": "#2c00ff (default)",
499
+
"colorDefault": "{color} (default)",
467
500
"primaryDark": "Primary (Dark Mode)",
468
-
"primaryDarkDefault": "#7b6bff (default)",
469
501
"secondaryLight": "Secondary (Light Mode)",
470
-
"secondaryLightDefault": "#ff2400 (default)",
471
502
"secondaryDark": "Secondary (Dark Mode)",
472
-
"secondaryDarkDefault": "#ff6b5b (default)",
473
503
"configSaved": "Server configuration saved",
474
504
"saving": "Saving...",
475
505
"saveConfig": "Save Configuration",
···
527
557
"rememberDevice": "Remember this device",
528
558
"passkeyHintChecking": "Checking passkey status...",
529
559
"passkeyHintAvailable": "Sign in with your passkey",
530
-
"passkeyHintNotAvailable": "No passkeys registered for this account"
560
+
"passkeyHintNotAvailable": "No passkeys registered for this account",
561
+
"passkeyHint": "Use your device's biometrics or security key",
562
+
"passwordPlaceholder": "Enter your password",
563
+
"usePasskey": "Use Passkey"
531
564
},
532
565
"consent": {
533
566
"title": "Authorize Application",
···
741
774
"didWebBYODHint": "Bring your own domain",
742
775
"didWebWarningTitle": "Important: Understand the trade-offs",
743
776
"didWebWarning1": "Permanent tie to this PDS:",
777
+
"didWebWarning1Detail": "Your identity will be {did}.",
744
778
"didWebWarning2": "No recovery mechanism:",
745
779
"didWebWarning2Detail": "Unlike did:plc, did:web has no rotation keys.",
746
780
"didWebWarning3": "We commit to you:",
···
785
819
"title": "Trusted Devices",
786
820
"backToSecurity": "← Security Settings",
787
821
"description": "Trusted devices can skip two-factor authentication when logging in. Trust is granted for 30 days and automatically extends when you use the device.",
822
+
"failedToLoad": "Failed to load trusted devices",
788
823
"noDevices": "No trusted devices yet.",
789
824
"noDevicesHint": "When you log in with two-factor authentication enabled, you can choose to trust the device for 30 days.",
790
825
"lastSeen": "Last seen:",
+97
-8
frontend/src/locales/fi.json
+97
-8
frontend/src/locales/fi.json
···
30
30
"lostPasskey": "Kadotitko pääsyavaimen?",
31
31
"noAccount": "Eikö sinulla ole tiliä?",
32
32
"createAccount": "Luo tili",
33
-
"removeAccount": "Poista tallennetuista tileistä"
33
+
"removeAccount": "Poista tallennetuista tileistä",
34
+
"infoSavedAccountsTitle": "Tallennetut tilit",
35
+
"infoSavedAccountsDesc": "Napsauta tiliä kirjautuaksesi heti. Istuntotunnuksesi on tallennettu turvallisesti tähän selaimeen.",
36
+
"infoNewAccountTitle": "Uusi tili",
37
+
"infoNewAccountDesc": "Käytä kirjautumispainiketta lisätäksesi toisen tilin. Napsauta × poistaaksesi tallennettuja tilejä.",
38
+
"infoSecureSignInTitle": "Turvallinen kirjautuminen",
39
+
"infoSecureSignInDesc": "Sinut ohjataan turvalliseen todennukseen. Jos sinulla on pääsyavaimia tai kaksivaiheinen tunnistautuminen käytössä, sinulta pyydetään myös ne.",
40
+
"infoStaySignedInTitle": "Pysy kirjautuneena",
41
+
"infoStaySignedInDesc": "Kirjautumisen jälkeen tilisi tallennetaan tähän selaimeen nopeaa pääsyä varten.",
42
+
"infoRecoveryTitle": "Tilin palautus",
43
+
"infoRecoveryDesc": "Kadotitko salasanasi tai pääsyavaimesi? Käytä palautuslinkkejä kirjautumispainikkeen alla."
34
44
},
35
45
"verification": {
36
46
"title": "Vahvista tilisi",
···
47
57
"register": {
48
58
"title": "Luo tili",
49
59
"subtitle": "Luo uusi tili tälle PDS:lle",
60
+
"subtitleKeyChoice": "Valitse, miten haluat määrittää ulkoisen did:web-identiteettisi.",
61
+
"subtitleInitialDidDoc": "Lataa DID-dokumenttisi jatkaaksesi.",
62
+
"subtitleVerify": "Vahvista {channel} jatkaaksesi.",
63
+
"subtitleUpdatedDidDoc": "Päivitä DID-dokumenttisi PDS-allekirjoitusavaimella.",
64
+
"subtitleActivating": "Aktivoidaan tiliäsi...",
65
+
"subtitleComplete": "Tilisi on luotu onnistuneesti!",
66
+
"redirecting": "Siirrytään kojelaudalle...",
67
+
"infoIdentityDesc": "Identiteettisi määrittää, miten tilisi tunnistetaan ATProto-verkossa. Useimpien käyttäjien tulisi valita vakiovaihtoehto.",
68
+
"infoContactDesc": "Käytämme tätä tilisi vahvistamiseen ja tärkeiden turvallisuusilmoitusten lähettämiseen.",
69
+
"infoNextTitle": "Mitä tapahtuu seuraavaksi?",
70
+
"infoNextDesc": "Tilin luomisen jälkeen vahvistat yhteysmenetelmäsi ja olet valmis käyttämään mitä tahansa ATProto-sovellusta uudella identiteetilläsi.",
50
71
"migrateTitle": "Onko sinulla jo Bluesky-tili?",
51
72
"migrateDescription": "Voit siirtää olemassa olevan tilisi tälle PDS:lle uuden luomisen sijaan. Seuraajasi, julkaisusi ja identiteettisi siirtyvät mukana.",
52
73
"migrateLink": "Siirrä PDS Mooverilla",
···
211
232
"messages": {
212
233
"emailCodeSent": "Vahvistuskoodi lähetetty ilmoituskanavallesi",
213
234
"emailUpdated": "Sähköposti päivitetty",
235
+
"emailUpdateFailed": "Sähköpostin päivitys epäonnistui",
214
236
"handleUpdated": "Käyttäjänimi päivitetty",
237
+
"handleUpdateFailed": "Käyttäjänimen päivitys epäonnistui",
215
238
"passwordChanged": "Salasana vaihdettu",
239
+
"passwordChangeFailed": "Salasanan vaihto epäonnistui",
216
240
"passwordsMismatch": "Salasanat eivät täsmää",
241
+
"passwordsDoNotMatch": "Salasanat eivät täsmää",
217
242
"passwordLength": "Salasanan on oltava vähintään 8 merkkiä",
243
+
"passwordTooShort": "Salasanan on oltava vähintään 8 merkkiä",
218
244
"deletionCodeSent": "Poistovahvistus lähetetty sähköpostiisi",
245
+
"deletionConfirmationSent": "Poistovahvistus lähetetty sähköpostiisi",
246
+
"deletionRequestFailed": "Tilin poistopyyntö epäonnistui",
247
+
"deleteConfirmation": "Oletko täysin varma, että haluat poistaa tilisi? Tätä ei voi perua.",
248
+
"deletionFailed": "Tilin poisto epäonnistui",
219
249
"repoExported": "Tietovarasto viety",
250
+
"exportFailed": "Tietovaraston vienti epäonnistui",
220
251
"confirmDelete": "Oletko täysin varma, että haluat poistaa tilisi? Tätä ei voi perua."
221
252
}
222
253
},
···
362
393
"manageTrustedDevices": "Hallitse luotettuja laitteita",
363
394
"appCompatibility": "Sovellusyhteensopivuus",
364
395
"enterPassword": "Syötä salasanasi",
396
+
"sessionExpired": "Istunto vanhentunut. Kirjaudu sisään uudelleen.",
365
397
"legacyLoginEnabled": "Vanhentuneiden sovellusten kirjautuminen käytössä",
366
398
"legacyLoginDisabled": "Vanhentuneiden sovellusten kirjautuminen poistettu käytöstä - vain OAuth-sovellukset voivat kirjautua",
367
399
"failedToUpdatePreference": "Asetuksen päivittäminen epäonnistui",
···
421
453
"noRecords": "Ei tietueita tässä kokoelmassa",
422
454
"recordDetails": "Tietueen tiedot",
423
455
"rkey": "Tietueavain",
456
+
"uri": "URI",
424
457
"cid": "CID",
425
458
"value": "Arvo",
426
459
"deleteRecord": "Poista tietue",
···
464
497
"themeColorsHint": "Jätä tyhjäksi käyttääksesi oletusvärejä.",
465
498
"primaryLight": "Ensisijainen (vaalea tila)",
466
499
"primaryDark": "Ensisijainen (tumma tila)",
467
-
"accentLight": "Korostus (vaalea tila)",
468
-
"accentDark": "Korostus (tumma tila)",
469
-
"faviconExample": "Favicon-esimerkki",
470
500
"configSaved": "Palvelinasetukset tallennettu",
471
501
"saving": "Tallennetaan...",
472
502
"saveConfig": "Tallenna asetukset",
···
508
538
"deleteConfirm": "Poista tili @{handle}? Tätä ei voi perua.",
509
539
"verified": "Vahvistettu",
510
540
"unverified": "Vahvistamaton",
511
-
"deactivated": "Poistettu käytöstä"
541
+
"deactivated": "Poistettu käytöstä",
542
+
"colorDefault": "{color} (oletus)",
543
+
"secondaryLight": "Toissijainen (vaalea tila)",
544
+
"secondaryDark": "Toissijainen (tumma tila)"
512
545
},
513
546
"oauth": {
514
547
"login": {
···
524
557
"rememberDevice": "Muista tämä laite",
525
558
"passkeyHintChecking": "Tarkistetaan pääsyavaimen tilaa...",
526
559
"passkeyHintAvailable": "Kirjaudu pääsyavaimellasi",
527
-
"passkeyHintNotAvailable": "Ei rekisteröityjä pääsyavaimia tälle tilille"
560
+
"passkeyHintNotAvailable": "Ei rekisteröityjä pääsyavaimia tälle tilille",
561
+
"passkeyHint": "Käytä laitteesi biometriikkaa tai suojausavainta",
562
+
"passwordPlaceholder": "Syötä salasanasi",
563
+
"usePasskey": "Käytä pääsyavainta"
528
564
},
529
565
"consent": {
530
566
"title": "Valtuuta sovellus",
···
740
776
"handleNoDots": "Käyttäjänimi ei voi sisältää pisteitä. Voit määrittää oman verkkotunnuksen tilin luomisen jälkeen.",
741
777
"passkeysNotSupported": "Pääsyavaimia ei tueta tässä selaimessa. Luo salasanapohjainen tili tai käytä selainta, joka tukee pääsyavaimia.",
742
778
"passkeyCancelled": "Pääsyavaimen luominen peruutettu",
743
-
"passkeyFailed": "Pääsyavaimen rekisteröinti epäonnistui"
744
-
}
779
+
"passkeyFailed": "Pääsyavaimen rekisteröinti epäonnistui",
780
+
"signalRequired": "Puhelinnumero vaaditaan Signal-vahvistukseen",
781
+
"inviteRequired": "Kutsukoodi vaaditaan",
782
+
"externalDidRequired": "Ulkoinen did:web vaaditaan",
783
+
"emailRequired": "Sähköposti vaaditaan sähköpostivahvistukseen",
784
+
"telegramRequired": "Telegram-käyttäjänimi vaaditaan Telegram-vahvistukseen",
785
+
"externalDidFormat": "Ulkoisen DID:n on alettava did:web:",
786
+
"discordRequired": "Discord-tunnus vaaditaan Discord-vahvistukseen"
787
+
},
788
+
"whyPasskeyBullet1": "Ei voi kalastella tai varastaa tietomurroissa",
789
+
"whyPasskeyBullet2": "Käyttää laitteistopohjaisia salausavaimia",
790
+
"whyPasskeyBullet3": "Vaatii biometrisen tunnistuksen tai laitteen PIN-koodin",
791
+
"whyPasskeyOnly": "Miksi vain pääsyavain?",
792
+
"whyPasskeyOnlyDesc": "Pääsyavaintilit ovat turvallisempia kuin salasanapohjaiset tilit, koska ne:",
793
+
"subtitleInitialDidDoc": "Lataa DID-dokumenttisi jatkaaksesi.",
794
+
"subtitleUpdatedDidDoc": "Päivitä DID-dokumenttisi PDS-allekirjoitusavaimella.",
795
+
"subtitleActivating": "Aktivoidaan tiliäsi...",
796
+
"subtitleComplete": "Tilisi on luotu onnistuneesti!",
797
+
"subtitleCreating": "Luodaan tiliäsi...",
798
+
"subtitleAppPassword": "Tallenna sovellussalasanasi kolmannen osapuolen sovelluksia varten.",
799
+
"creatingPasskey": "Luodaan pääsyavainta...",
800
+
"passkeyPrompt": "Napsauta alla olevaa painiketta luodaksesi pääsyavaimesi. Sinua pyydetään käyttämään:",
801
+
"passkeyPromptBullet1": "Touch ID tai Face ID",
802
+
"passkeyPromptBullet2": "Laitteesi PIN-koodi tai salasana",
803
+
"passkeyPromptBullet3": "Turva-avain (jos sinulla on sellainen)",
804
+
"identityType": "Identiteettityyppi",
805
+
"identityTypeHint": "Valitse, miten hajautettua identiteettiäsi hallitaan.",
806
+
"passkeyNameLabel": "Pääsyavaimen nimi (valinnainen)",
807
+
"passkeyNamePlaceholder": "esim. MacBook Touch ID",
808
+
"passkeyNameHint": "Ystävällinen nimi tämän pääsyavaimen tunnistamiseksi",
809
+
"createPasskey": "Luo pääsyavain",
810
+
"didPlcRecommended": "did:plc (Suositeltava)",
811
+
"didPlcHint": "Siirrettävä identiteetti, jota hallinnoi PLC Directory",
812
+
"didWeb": "did:web",
813
+
"didWebHint": "Tällä PDS:llä isännöity identiteetti (lue varoitus alla)",
814
+
"didWebBYOD": "did:web (BYOD)",
815
+
"didWebBYODHint": "Tuo oma verkkotunnuksesi",
816
+
"didWebWarningTitle": "Tärkeää: Ymmärrä kompromissit",
817
+
"didWebWarning1": "Pysyvä sidos tähän PDS:ään:",
818
+
"didWebWarning1Detail": "Identiteettisi {did} on sidottu tähän palvelimeen.",
819
+
"didWebWarning2": "Ei palautusmekanismia:",
820
+
"didWebWarning2Detail": "Toisin kuin did:plc, did:web ei sisällä kiertoavaimia.",
821
+
"didWebWarning3": "Sitoudumme sinulle:",
822
+
"didWebWarning3Detail": "Jos siirryt pois, jatkamme minimaalisen DID-dokumentin tarjoamista.",
823
+
"didWebWarning4": "Suositus:",
824
+
"didWebWarning4Detail": "Valitse did:plc, ellei sinulla ole erityistä syytä suosia did:web.",
825
+
"externalDidHint": "Sinun on tarjottava DID-dokumentti osoitteessa",
826
+
"continue": "Jatka",
827
+
"back": "Takaisin",
828
+
"loading": "Ladataan...",
829
+
"redirecting": "Ohjataan hallintapaneeliin...",
830
+
"handleDotWarning": "Mukautetut verkkotunnuskahvat voidaan määrittää tilin luomisen jälkeen.",
831
+
"wantTraditional": "Haluatko perinteisen salasanan?",
832
+
"registerWithPassword": "Rekisteröidy salasanalla"
745
833
},
746
834
"trustedDevices": {
747
835
"title": "Luotetut laitteet",
748
836
"backToSecurity": "← Turvallisuusasetukset",
749
837
"description": "Luotetut laitteet voivat ohittaa kaksivaiheisen tunnistautumisen kirjautuessaan. Luottamus myönnetään 30 päiväksi ja jatkuu automaattisesti, kun käytät laitetta.",
838
+
"failedToLoad": "Luotettujen laitteiden lataaminen epäonnistui",
750
839
"noDevices": "Ei vielä luotettuja laitteita.",
751
840
"noDevicesHint": "Kun kirjaudut sisään kaksivaiheisen tunnistautumisen ollessa käytössä, voit valita luottaa laitteeseen 30 päivää.",
752
841
"lastSeen": "Viimeksi nähty:",
+97
-8
frontend/src/locales/ja.json
+97
-8
frontend/src/locales/ja.json
···
30
30
"lostPasskey": "パスキーを紛失しましたか?",
31
31
"noAccount": "アカウントをお持ちでないですか?",
32
32
"createAccount": "アカウントを作成",
33
-
"removeAccount": "保存済みアカウントから削除"
33
+
"removeAccount": "保存済みアカウントから削除",
34
+
"infoSavedAccountsTitle": "保存済みアカウント",
35
+
"infoSavedAccountsDesc": "アカウントをクリックすると即座にサインインできます。セッショントークンはこのブラウザに安全に保存されています。",
36
+
"infoNewAccountTitle": "新規アカウント",
37
+
"infoNewAccountDesc": "サインインボタンで別のアカウントを追加できます。×をクリックすると保存済みアカウントを削除できます。",
38
+
"infoSecureSignInTitle": "安全なサインイン",
39
+
"infoSecureSignInDesc": "安全な認証のためにリダイレクトされます。パスキーや二要素認証が有効な場合は、それらも求められます。",
40
+
"infoStaySignedInTitle": "サインイン状態を維持",
41
+
"infoStaySignedInDesc": "サインイン後、アカウントはこのブラウザに保存され、次回から素早くアクセスできます。",
42
+
"infoRecoveryTitle": "アカウント復旧",
43
+
"infoRecoveryDesc": "パスワードやパスキーを紛失しましたか?サインインボタンの下の復旧リンクをご利用ください。"
34
44
},
35
45
"verification": {
36
46
"title": "アカウント確認",
···
47
57
"register": {
48
58
"title": "アカウント作成",
49
59
"subtitle": "この PDS で新規アカウントを作成",
60
+
"subtitleKeyChoice": "外部 did:web アイデンティティの設定方法を選択してください。",
61
+
"subtitleInitialDidDoc": "続行するには DID ドキュメントをアップロードしてください。",
62
+
"subtitleVerify": "続行するには{channel}を確認してください。",
63
+
"subtitleUpdatedDidDoc": "PDS 署名キーで DID ドキュメントを更新してください。",
64
+
"subtitleActivating": "アカウントを有効化しています...",
65
+
"subtitleComplete": "アカウントが正常に作成されました!",
66
+
"redirecting": "ダッシュボードへ移動中...",
67
+
"infoIdentityDesc": "アイデンティティは、ATProto ネットワーク上でアカウントがどのように識別されるかを決定します。ほとんどのユーザーは標準オプションを選択してください。",
68
+
"infoContactDesc": "この情報はアカウントの確認と、アカウントセキュリティに関する重要な通知の送信に使用されます。",
69
+
"infoNextTitle": "次のステップは?",
70
+
"infoNextDesc": "アカウント作成後、連絡方法を確認すると、新しいアイデンティティで任意の ATProto アプリを使用できます。",
50
71
"migrateTitle": "すでにBlueskyアカウントをお持ちですか?",
51
72
"migrateDescription": "新しいアカウントを作成する代わりに、既存のアカウントをこのPDSに移行できます。フォロワー、投稿、IDも一緒に移行されます。",
52
73
"migrateLink": "PDS Mooverで移行する",
···
211
232
"messages": {
212
233
"emailCodeSent": "通知チャンネルに確認コードを送信しました",
213
234
"emailUpdated": "メールを更新しました",
235
+
"emailUpdateFailed": "メールの更新に失敗しました",
214
236
"handleUpdated": "ハンドルを更新しました",
237
+
"handleUpdateFailed": "ハンドルの更新に失敗しました",
215
238
"passwordChanged": "パスワードを変更しました",
239
+
"passwordChangeFailed": "パスワードの変更に失敗しました",
216
240
"passwordsMismatch": "パスワードが一致しません",
241
+
"passwordsDoNotMatch": "パスワードが一致しません",
217
242
"passwordLength": "パスワードは8文字以上である必要があります",
243
+
"passwordTooShort": "パスワードは8文字以上である必要があります",
218
244
"deletionCodeSent": "削除確認をメールに送信しました",
245
+
"deletionConfirmationSent": "削除確認をメールに送信しました",
246
+
"deletionRequestFailed": "アカウント削除リクエストに失敗しました",
247
+
"deleteConfirmation": "本当にアカウントを削除しますか?この操作は取り消せません。",
248
+
"deletionFailed": "アカウントの削除に失敗しました",
219
249
"repoExported": "リポジトリをエクスポートしました",
250
+
"exportFailed": "リポジトリのエクスポートに失敗しました",
220
251
"confirmDelete": "本当にアカウントを削除しますか?この操作は取り消せません。"
221
252
}
222
253
},
···
362
393
"manageTrustedDevices": "信頼済みデバイスを管理",
363
394
"appCompatibility": "アプリ互換性",
364
395
"enterPassword": "パスワードを入力",
396
+
"sessionExpired": "セッションが期限切れです。再度ログインしてください。",
365
397
"legacyLoginEnabled": "レガシーアプリログインが有効",
366
398
"legacyLoginDisabled": "レガシーアプリログインが無効 - OAuth アプリのみサインイン可能",
367
399
"failedToUpdatePreference": "設定の更新に失敗しました",
···
421
453
"noRecords": "このコレクションにレコードはありません",
422
454
"recordDetails": "レコード詳細",
423
455
"rkey": "レコードキー",
456
+
"uri": "URI",
424
457
"cid": "CID",
425
458
"value": "値",
426
459
"deleteRecord": "レコードを削除",
···
464
497
"themeColorsHint": "デフォルトカラーを使用する場合は空白のままにしてください。",
465
498
"primaryLight": "プライマリ(ライトモード)",
466
499
"primaryDark": "プライマリ(ダークモード)",
467
-
"accentLight": "アクセント(ライトモード)",
468
-
"accentDark": "アクセント(ダークモード)",
469
-
"faviconExample": "ファビコン例",
470
500
"configSaved": "サーバー設定を保存しました",
471
501
"saving": "保存中...",
472
502
"saveConfig": "設定を保存",
···
508
538
"deleteConfirm": "アカウント @{handle} を削除しますか?この操作は取り消せません。",
509
539
"verified": "確認済み",
510
540
"unverified": "未確認",
511
-
"deactivated": "無効化"
541
+
"deactivated": "無効化",
542
+
"colorDefault": "{color}(デフォルト)",
543
+
"secondaryLight": "セカンダリ(ライトモード)",
544
+
"secondaryDark": "セカンダリ(ダークモード)"
512
545
},
513
546
"oauth": {
514
547
"login": {
···
524
557
"rememberDevice": "このデバイスを記憶する",
525
558
"passkeyHintChecking": "パスキーの状態を確認中...",
526
559
"passkeyHintAvailable": "パスキーでサインイン",
527
-
"passkeyHintNotAvailable": "このアカウントにはパスキーが登録されていません"
560
+
"passkeyHintNotAvailable": "このアカウントにはパスキーが登録されていません",
561
+
"passkeyHint": "デバイスの生体認証またはセキュリティキーを使用",
562
+
"passwordPlaceholder": "パスワードを入力",
563
+
"usePasskey": "パスキーを使用"
528
564
},
529
565
"consent": {
530
566
"title": "アプリを承認",
···
740
776
"handleNoDots": "ハンドルにドットは使用できません。アカウント作成後にカスタムドメインを設定できます。",
741
777
"passkeysNotSupported": "このブラウザではパスキーがサポートされていません。パスワードベースのアカウントを作成するか、パスキーをサポートするブラウザを使用してください。",
742
778
"passkeyCancelled": "パスキーの作成がキャンセルされました",
743
-
"passkeyFailed": "パスキーの登録に失敗しました"
744
-
}
779
+
"passkeyFailed": "パスキーの登録に失敗しました",
780
+
"signalRequired": "Signal認証には電話番号が必要です",
781
+
"inviteRequired": "招待コードが必要です",
782
+
"externalDidRequired": "外部did:webが必要です",
783
+
"emailRequired": "メール認証にはメールアドレスが必要です",
784
+
"telegramRequired": "Telegram認証にはTelegramユーザー名が必要です",
785
+
"externalDidFormat": "外部DIDはdid:web:で始まる必要があります",
786
+
"discordRequired": "Discord認証にはDiscord IDが必要です"
787
+
},
788
+
"whyPasskeyBullet1": "フィッシングやデータ侵害で盗まれない",
789
+
"whyPasskeyBullet2": "ハードウェア支援の暗号鍵を使用",
790
+
"whyPasskeyBullet3": "生体認証またはデバイスPINが必要",
791
+
"whyPasskeyOnly": "なぜパスキーのみ?",
792
+
"whyPasskeyOnlyDesc": "パスキーアカウントはパスワードベースのアカウントより安全です:",
793
+
"subtitleInitialDidDoc": "続行するにはDIDドキュメントをアップロードしてください。",
794
+
"subtitleUpdatedDidDoc": "PDS署名鍵でDIDドキュメントを更新してください。",
795
+
"subtitleActivating": "アカウントを有効化しています...",
796
+
"subtitleComplete": "アカウントが正常に作成されました!",
797
+
"subtitleCreating": "アカウントを作成しています...",
798
+
"subtitleAppPassword": "サードパーティアプリ用のアプリパスワードを保存してください。",
799
+
"creatingPasskey": "パスキーを作成中...",
800
+
"passkeyPrompt": "下のボタンをクリックしてパスキーを作成してください。以下の使用を求められます:",
801
+
"passkeyPromptBullet1": "Touch IDまたはFace ID",
802
+
"passkeyPromptBullet2": "デバイスのPINまたはパスワード",
803
+
"passkeyPromptBullet3": "セキュリティキー(お持ちの場合)",
804
+
"identityType": "アイデンティティタイプ",
805
+
"identityTypeHint": "分散型アイデンティティの管理方法を選択してください。",
806
+
"passkeyNameLabel": "パスキー名(任意)",
807
+
"passkeyNamePlaceholder": "例:MacBook Touch ID",
808
+
"passkeyNameHint": "このパスキーを識別するための名前",
809
+
"createPasskey": "パスキーを作成",
810
+
"didPlcRecommended": "did:plc(推奨)",
811
+
"didPlcHint": "PLC Directoryで管理されるポータブルなアイデンティティ",
812
+
"didWeb": "did:web",
813
+
"didWebHint": "このPDSでホストされるアイデンティティ(以下の警告を参照)",
814
+
"didWebBYOD": "did:web(BYOD)",
815
+
"didWebBYODHint": "独自ドメインを持ち込む",
816
+
"didWebWarningTitle": "重要:トレードオフを理解する",
817
+
"didWebWarning1": "このPDSへの永続的な紐付け:",
818
+
"didWebWarning1Detail": "あなたのアイデンティティ{did}はこのサーバーに紐付けられます。",
819
+
"didWebWarning2": "回復メカニズムなし:",
820
+
"didWebWarning2Detail": "did:plcと異なり、did:webにはローテーションキーがありません。",
821
+
"didWebWarning3": "私たちの約束:",
822
+
"didWebWarning3Detail": "移行後も最小限のDIDドキュメントを提供し続けます。",
823
+
"didWebWarning4": "推奨事項:",
824
+
"didWebWarning4Detail": "did:webを好む特別な理由がない限り、did:plcを選択してください。",
825
+
"externalDidHint": "以下の場所でDIDドキュメントを提供する必要があります",
826
+
"continue": "続行",
827
+
"back": "戻る",
828
+
"loading": "読み込み中...",
829
+
"redirecting": "ダッシュボードに移動中...",
830
+
"handleDotWarning": "カスタムドメインハンドルはアカウント作成後に設定できます。",
831
+
"wantTraditional": "従来のパスワードを使用しますか?",
832
+
"registerWithPassword": "パスワードで登録"
745
833
},
746
834
"trustedDevices": {
747
835
"title": "信頼済みデバイス",
748
836
"backToSecurity": "← セキュリティ設定",
749
837
"description": "信頼済みデバイスはログイン時に二要素認証をスキップできます。信頼は30日間有効で、デバイスを使用すると自動的に延長されます。",
838
+
"failedToLoad": "信頼済みデバイスの読み込みに失敗しました",
750
839
"noDevices": "信頼済みデバイスはまだありません。",
751
840
"noDevicesHint": "二要素認証を有効にしてログインする際に、デバイスを30日間信頼することを選択できます。",
752
841
"lastSeen": "最終使用:",
+97
-8
frontend/src/locales/ko.json
+97
-8
frontend/src/locales/ko.json
···
30
30
"lostPasskey": "패스키를 분실하셨나요?",
31
31
"noAccount": "계정이 없으신가요?",
32
32
"createAccount": "계정 만들기",
33
-
"removeAccount": "저장된 계정에서 삭제"
33
+
"removeAccount": "저장된 계정에서 삭제",
34
+
"infoSavedAccountsTitle": "저장된 계정",
35
+
"infoSavedAccountsDesc": "계정을 클릭하면 즉시 로그인할 수 있습니다. 세션 토큰은 이 브라우저에 안전하게 저장됩니다.",
36
+
"infoNewAccountTitle": "새 계정",
37
+
"infoNewAccountDesc": "로그인 버튼을 사용하여 다른 계정을 추가하세요. ×를 클릭하여 저장된 계정을 제거할 수 있습니다.",
38
+
"infoSecureSignInTitle": "안전한 로그인",
39
+
"infoSecureSignInDesc": "안전한 인증을 위해 리디렉션됩니다. 패스키나 2단계 인증이 활성화되어 있으면 해당 인증도 요청됩니다.",
40
+
"infoStaySignedInTitle": "로그인 유지",
41
+
"infoStaySignedInDesc": "로그인 후 계정이 이 브라우저에 저장되어 다음에 빠르게 접속할 수 있습니다.",
42
+
"infoRecoveryTitle": "계정 복구",
43
+
"infoRecoveryDesc": "비밀번호나 패스키를 분실하셨나요? 로그인 버튼 아래의 복구 링크를 사용하세요."
34
44
},
35
45
"verification": {
36
46
"title": "계정 인증",
···
47
57
"register": {
48
58
"title": "계정 만들기",
49
59
"subtitle": "이 PDS에 새 계정을 만듭니다",
60
+
"subtitleKeyChoice": "외부 did:web 신원을 설정하는 방법을 선택하세요.",
61
+
"subtitleInitialDidDoc": "계속하려면 DID 문서를 업로드하세요.",
62
+
"subtitleVerify": "계속하려면 {channel}을(를) 인증하세요.",
63
+
"subtitleUpdatedDidDoc": "PDS 서명 키로 DID 문서를 업데이트하세요.",
64
+
"subtitleActivating": "계정을 활성화하는 중...",
65
+
"subtitleComplete": "계정이 성공적으로 생성되었습니다!",
66
+
"redirecting": "대시보드로 이동 중...",
67
+
"infoIdentityDesc": "신원은 ATProto 네트워크에서 계정이 어떻게 식별되는지를 결정합니다. 대부분의 사용자는 표준 옵션을 선택해야 합니다.",
68
+
"infoContactDesc": "이 정보는 계정 인증과 계정 보안에 관한 중요한 알림을 보내는 데 사용됩니다.",
69
+
"infoNextTitle": "다음 단계는?",
70
+
"infoNextDesc": "계정 생성 후 연락 방법을 인증하면 새로운 신원으로 모든 ATProto 앱을 사용할 수 있습니다.",
50
71
"migrateTitle": "이미 Bluesky 계정이 있으신가요?",
51
72
"migrateDescription": "새 계정을 만드는 대신 기존 계정을 이 PDS로 마이그레이션할 수 있습니다. 팔로워, 게시물, ID가 함께 이전됩니다.",
52
73
"migrateLink": "PDS Moover로 마이그레이션",
···
211
232
"messages": {
212
233
"emailCodeSent": "알림 채널로 인증 코드를 보냈습니다",
213
234
"emailUpdated": "이메일이 업데이트되었습니다",
235
+
"emailUpdateFailed": "이메일 업데이트에 실패했습니다",
214
236
"handleUpdated": "핸들이 업데이트되었습니다",
237
+
"handleUpdateFailed": "핸들 업데이트에 실패했습니다",
215
238
"passwordChanged": "비밀번호가 변경되었습니다",
239
+
"passwordChangeFailed": "비밀번호 변경에 실패했습니다",
216
240
"passwordsMismatch": "비밀번호가 일치하지 않습니다",
241
+
"passwordsDoNotMatch": "비밀번호가 일치하지 않습니다",
217
242
"passwordLength": "비밀번호는 8자 이상이어야 합니다",
243
+
"passwordTooShort": "비밀번호는 8자 이상이어야 합니다",
218
244
"deletionCodeSent": "이메일로 삭제 확인을 보냈습니다",
245
+
"deletionConfirmationSent": "이메일로 삭제 확인을 보냈습니다",
246
+
"deletionRequestFailed": "계정 삭제 요청에 실패했습니다",
247
+
"deleteConfirmation": "정말로 계정을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
248
+
"deletionFailed": "계정 삭제에 실패했습니다",
219
249
"repoExported": "저장소를 내보냈습니다",
250
+
"exportFailed": "저장소 내보내기에 실패했습니다",
220
251
"confirmDelete": "정말로 계정을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
221
252
}
222
253
},
···
362
393
"manageTrustedDevices": "신뢰할 수 있는 기기 관리",
363
394
"appCompatibility": "앱 호환성",
364
395
"enterPassword": "비밀번호를 입력하세요",
396
+
"sessionExpired": "세션이 만료되었습니다. 다시 로그인하세요.",
365
397
"legacyLoginEnabled": "레거시 앱 로그인 활성화됨",
366
398
"legacyLoginDisabled": "레거시 앱 로그인 비활성화됨 - OAuth 앱만 로그인 가능",
367
399
"failedToUpdatePreference": "설정 업데이트에 실패했습니다",
···
421
453
"noRecords": "이 컬렉션에 레코드가 없습니다",
422
454
"recordDetails": "레코드 세부 정보",
423
455
"rkey": "레코드 키",
456
+
"uri": "URI",
424
457
"cid": "CID",
425
458
"value": "값",
426
459
"deleteRecord": "레코드 삭제",
···
464
497
"themeColorsHint": "기본 색상을 사용하려면 비워 두세요.",
465
498
"primaryLight": "기본 (라이트 모드)",
466
499
"primaryDark": "기본 (다크 모드)",
467
-
"accentLight": "강조 (라이트 모드)",
468
-
"accentDark": "강조 (다크 모드)",
469
-
"faviconExample": "파비콘 예시",
470
500
"configSaved": "서버 설정이 저장되었습니다",
471
501
"saving": "저장 중...",
472
502
"saveConfig": "설정 저장",
···
508
538
"deleteConfirm": "계정 @{handle}을(를) 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
509
539
"verified": "인증됨",
510
540
"unverified": "미인증",
511
-
"deactivated": "비활성화됨"
541
+
"deactivated": "비활성화됨",
542
+
"colorDefault": "{color} (기본값)",
543
+
"secondaryLight": "보조 (라이트 모드)",
544
+
"secondaryDark": "보조 (다크 모드)"
512
545
},
513
546
"oauth": {
514
547
"login": {
···
524
557
"rememberDevice": "이 기기 기억하기",
525
558
"passkeyHintChecking": "패스키 상태 확인 중...",
526
559
"passkeyHintAvailable": "패스키로 로그인",
527
-
"passkeyHintNotAvailable": "이 계정에 등록된 패스키가 없습니다"
560
+
"passkeyHintNotAvailable": "이 계정에 등록된 패스키가 없습니다",
561
+
"passkeyHint": "기기의 생체 인식 또는 보안 키 사용",
562
+
"passwordPlaceholder": "비밀번호 입력",
563
+
"usePasskey": "패스키 사용"
528
564
},
529
565
"consent": {
530
566
"title": "앱 승인",
···
740
776
"handleNoDots": "핸들에 점을 포함할 수 없습니다. 계정 생성 후 사용자 정의 도메인을 설정할 수 있습니다.",
741
777
"passkeysNotSupported": "이 브라우저에서 패스키가 지원되지 않습니다. 비밀번호 기반 계정을 만들거나 패스키를 지원하는 브라우저를 사용하세요.",
742
778
"passkeyCancelled": "패스키 생성이 취소되었습니다",
743
-
"passkeyFailed": "패스키 등록에 실패했습니다"
744
-
}
779
+
"passkeyFailed": "패스키 등록에 실패했습니다",
780
+
"signalRequired": "Signal 인증에는 전화번호가 필요합니다",
781
+
"inviteRequired": "초대 코드가 필요합니다",
782
+
"externalDidRequired": "외부 did:web이 필요합니다",
783
+
"emailRequired": "이메일 인증에는 이메일이 필요합니다",
784
+
"telegramRequired": "Telegram 인증에는 Telegram 사용자 이름이 필요합니다",
785
+
"externalDidFormat": "외부 DID는 did:web:으로 시작해야 합니다",
786
+
"discordRequired": "Discord 인증에는 Discord ID가 필요합니다"
787
+
},
788
+
"whyPasskeyBullet1": "피싱이나 데이터 유출로 도난당할 수 없음",
789
+
"whyPasskeyBullet2": "하드웨어 기반 암호화 키 사용",
790
+
"whyPasskeyBullet3": "생체 인식 또는 기기 PIN 필요",
791
+
"whyPasskeyOnly": "왜 패스키만 사용하나요?",
792
+
"whyPasskeyOnlyDesc": "패스키 계정은 비밀번호 기반 계정보다 안전합니다:",
793
+
"subtitleInitialDidDoc": "계속하려면 DID 문서를 업로드하세요.",
794
+
"subtitleUpdatedDidDoc": "PDS 서명 키로 DID 문서를 업데이트하세요.",
795
+
"subtitleActivating": "계정을 활성화하는 중...",
796
+
"subtitleComplete": "계정이 성공적으로 생성되었습니다!",
797
+
"subtitleCreating": "계정을 생성하는 중...",
798
+
"subtitleAppPassword": "서드파티 앱용 앱 비밀번호를 저장하세요.",
799
+
"creatingPasskey": "패스키 생성 중...",
800
+
"passkeyPrompt": "아래 버튼을 클릭하여 패스키를 생성하세요. 다음을 사용하라는 메시지가 표시됩니다:",
801
+
"passkeyPromptBullet1": "Touch ID 또는 Face ID",
802
+
"passkeyPromptBullet2": "기기 PIN 또는 비밀번호",
803
+
"passkeyPromptBullet3": "보안 키 (있는 경우)",
804
+
"identityType": "아이덴티티 유형",
805
+
"identityTypeHint": "분산 아이덴티티 관리 방법을 선택하세요.",
806
+
"passkeyNameLabel": "패스키 이름 (선택사항)",
807
+
"passkeyNamePlaceholder": "예: MacBook Touch ID",
808
+
"passkeyNameHint": "이 패스키를 식별할 수 있는 이름",
809
+
"createPasskey": "패스키 생성",
810
+
"didPlcRecommended": "did:plc (권장)",
811
+
"didPlcHint": "PLC Directory에서 관리하는 이동 가능한 아이덴티티",
812
+
"didWeb": "did:web",
813
+
"didWebHint": "이 PDS에서 호스팅되는 아이덴티티 (아래 경고 읽기)",
814
+
"didWebBYOD": "did:web (BYOD)",
815
+
"didWebBYODHint": "자체 도메인 사용",
816
+
"didWebWarningTitle": "중요: 장단점 이해하기",
817
+
"didWebWarning1": "이 PDS에 영구적으로 연결됨:",
818
+
"didWebWarning1Detail": "귀하의 아이덴티티 {did}는 이 서버에 연결됩니다.",
819
+
"didWebWarning2": "복구 메커니즘 없음:",
820
+
"didWebWarning2Detail": "did:plc와 달리 did:web에는 순환 키가 없습니다.",
821
+
"didWebWarning3": "우리의 약속:",
822
+
"didWebWarning3Detail": "마이그레이션하더라도 최소한의 DID 문서를 계속 제공합니다.",
823
+
"didWebWarning4": "권장 사항:",
824
+
"didWebWarning4Detail": "did:web을 선호할 특별한 이유가 없다면 did:plc를 선택하세요.",
825
+
"externalDidHint": "다음 위치에서 DID 문서를 제공해야 합니다",
826
+
"continue": "계속",
827
+
"back": "뒤로",
828
+
"loading": "로딩 중...",
829
+
"redirecting": "대시보드로 이동 중...",
830
+
"handleDotWarning": "사용자 정의 도메인 핸들은 계정 생성 후 설정할 수 있습니다.",
831
+
"wantTraditional": "기존 비밀번호를 원하시나요?",
832
+
"registerWithPassword": "비밀번호로 가입"
745
833
},
746
834
"trustedDevices": {
747
835
"title": "신뢰할 수 있는 기기",
748
836
"backToSecurity": "← 보안 설정",
749
837
"description": "신뢰할 수 있는 기기는 로그인 시 2단계 인증을 건너뛸 수 있습니다. 신뢰는 30일간 유효하며 기기를 사용할 때 자동으로 연장됩니다.",
838
+
"failedToLoad": "신뢰할 수 있는 기기를 불러오지 못했습니다",
750
839
"noDevices": "신뢰할 수 있는 기기가 아직 없습니다.",
751
840
"noDevicesHint": "2단계 인증이 활성화된 상태로 로그인할 때 기기를 30일간 신뢰하도록 선택할 수 있습니다.",
752
841
"lastSeen": "마지막 접속:",
+97
-8
frontend/src/locales/sv.json
+97
-8
frontend/src/locales/sv.json
···
30
30
"lostPasskey": "Tappat bort nyckeln?",
31
31
"noAccount": "Har du inget konto?",
32
32
"createAccount": "Skapa konto",
33
-
"removeAccount": "Ta bort från sparade konton"
33
+
"removeAccount": "Ta bort från sparade konton",
34
+
"infoSavedAccountsTitle": "Sparade konton",
35
+
"infoSavedAccountsDesc": "Klicka på ett konto för att logga in direkt. Dina sessionstoken lagras säkert i denna webbläsare.",
36
+
"infoNewAccountTitle": "Nytt konto",
37
+
"infoNewAccountDesc": "Använd inloggningsknappen för att lägga till ett annat konto. Klicka på × för att ta bort sparade konton.",
38
+
"infoSecureSignInTitle": "Säker inloggning",
39
+
"infoSecureSignInDesc": "Du omdirigeras för säker autentisering. Om du har aktiverat nycklar eller tvåfaktorsautentisering kommer du också att behöva ange dessa.",
40
+
"infoStaySignedInTitle": "Förbli inloggad",
41
+
"infoStaySignedInDesc": "Efter inloggning sparas ditt konto i denna webbläsare för snabb åtkomst nästa gång.",
42
+
"infoRecoveryTitle": "Kontoåterställning",
43
+
"infoRecoveryDesc": "Har du tappat bort ditt lösenord eller din nyckel? Använd återställningslänkarna under inloggningsknappen."
34
44
},
35
45
"verification": {
36
46
"title": "Verifiera ditt konto",
···
47
57
"register": {
48
58
"title": "Skapa konto",
49
59
"subtitle": "Skapa ett nytt konto på denna PDS",
60
+
"subtitleKeyChoice": "Välj hur du vill konfigurera din externa did:web-identitet.",
61
+
"subtitleInitialDidDoc": "Ladda upp ditt DID-dokument för att fortsätta.",
62
+
"subtitleVerify": "Verifiera din {channel} för att fortsätta.",
63
+
"subtitleUpdatedDidDoc": "Uppdatera ditt DID-dokument med PDS-signeringsnyckeln.",
64
+
"subtitleActivating": "Aktiverar ditt konto...",
65
+
"subtitleComplete": "Ditt konto har skapats!",
66
+
"redirecting": "Omdirigerar till kontrollpanelen...",
67
+
"infoIdentityDesc": "Din identitet avgör hur ditt konto identifieras i ATProto-nätverket. De flesta användare bör välja standardalternativet.",
68
+
"infoContactDesc": "Vi använder detta för att verifiera ditt konto och skicka viktiga meddelanden om din kontosäkerhet.",
69
+
"infoNextTitle": "Vad händer härnäst?",
70
+
"infoNextDesc": "Efter att du skapat ditt konto verifierar du din kontaktmetod och sedan är du redo att använda vilken ATProto-app som helst med din nya identitet.",
50
71
"migrateTitle": "Har du redan ett Bluesky-konto?",
51
72
"migrateDescription": "Du kan flytta ditt befintliga konto till denna PDS istället för att skapa ett nytt. Dina följare, inlägg och identitet följer med.",
52
73
"migrateLink": "Flytta med PDS Moover",
···
211
232
"messages": {
212
233
"emailCodeSent": "Verifieringskod skickad till din meddelandekanal",
213
234
"emailUpdated": "E-post uppdaterad",
235
+
"emailUpdateFailed": "Kunde inte uppdatera e-post",
214
236
"handleUpdated": "Användarnamn uppdaterat",
237
+
"handleUpdateFailed": "Kunde inte uppdatera användarnamn",
215
238
"passwordChanged": "Lösenord ändrat",
239
+
"passwordChangeFailed": "Kunde inte ändra lösenord",
216
240
"passwordsMismatch": "Lösenorden matchar inte",
241
+
"passwordsDoNotMatch": "Lösenorden matchar inte",
217
242
"passwordLength": "Lösenordet måste vara minst 8 tecken",
243
+
"passwordTooShort": "Lösenordet måste vara minst 8 tecken",
218
244
"deletionCodeSent": "Bekräftelse för radering skickad till din e-post",
245
+
"deletionConfirmationSent": "Bekräftelse för radering skickad till din e-post",
246
+
"deletionRequestFailed": "Kunde inte begära kontoradering",
247
+
"deleteConfirmation": "Är du helt säker på att du vill radera ditt konto? Detta kan inte ångras.",
248
+
"deletionFailed": "Kunde inte radera kontot",
219
249
"repoExported": "Arkiv exporterat",
250
+
"exportFailed": "Kunde inte exportera arkiv",
220
251
"confirmDelete": "Är du helt säker på att du vill radera ditt konto? Detta kan inte ångras."
221
252
}
222
253
},
···
362
393
"manageTrustedDevices": "Hantera betrodda enheter",
363
394
"appCompatibility": "Appkompatibilitet",
364
395
"enterPassword": "Ange ditt lösenord",
396
+
"sessionExpired": "Sessionen har gått ut. Logga in igen.",
365
397
"legacyLoginEnabled": "Föråldrad appinloggning aktiverad",
366
398
"legacyLoginDisabled": "Föråldrad appinloggning inaktiverad - endast OAuth-appar kan logga in",
367
399
"failedToUpdatePreference": "Kunde inte uppdatera inställning",
···
421
453
"noRecords": "Inga poster i denna samling",
422
454
"recordDetails": "Postdetaljer",
423
455
"rkey": "Postnyckel",
456
+
"uri": "URI",
424
457
"cid": "CID",
425
458
"value": "Värde",
426
459
"deleteRecord": "Radera post",
···
464
497
"themeColorsHint": "Lämna tomt för att använda standardfärger.",
465
498
"primaryLight": "Primär (ljust läge)",
466
499
"primaryDark": "Primär (mörkt läge)",
467
-
"accentLight": "Accent (ljust läge)",
468
-
"accentDark": "Accent (mörkt läge)",
469
-
"faviconExample": "Favicon-exempel",
470
500
"configSaved": "Serverkonfiguration sparad",
471
501
"saving": "Sparar...",
472
502
"saveConfig": "Spara konfiguration",
···
508
538
"deleteConfirm": "Radera konto @{handle}? Detta kan inte ångras.",
509
539
"verified": "Verifierad",
510
540
"unverified": "Ej verifierad",
511
-
"deactivated": "Inaktiverad"
541
+
"deactivated": "Inaktiverad",
542
+
"colorDefault": "{color} (standard)",
543
+
"secondaryLight": "Sekundär (Ljust läge)",
544
+
"secondaryDark": "Sekundär (Mörkt läge)"
512
545
},
513
546
"oauth": {
514
547
"login": {
···
524
557
"rememberDevice": "Kom ihåg denna enhet",
525
558
"passkeyHintChecking": "Kontrollerar nyckelstatus...",
526
559
"passkeyHintAvailable": "Logga in med din nyckel",
527
-
"passkeyHintNotAvailable": "Inga nycklar registrerade för detta konto"
560
+
"passkeyHintNotAvailable": "Inga nycklar registrerade för detta konto",
561
+
"passkeyHint": "Använd enhetens biometri eller säkerhetsnyckel",
562
+
"passwordPlaceholder": "Ange ditt lösenord",
563
+
"usePasskey": "Använd nyckel"
528
564
},
529
565
"consent": {
530
566
"title": "Auktorisera applikation",
···
740
776
"handleNoDots": "Användarnamn kan inte innehålla punkter. Du kan konfigurera ett eget domännamn efter att kontot skapats.",
741
777
"passkeysNotSupported": "Nycklar stöds inte i denna webbläsare. Skapa ett lösenordsbaserat konto eller använd en webbläsare som stöder nycklar.",
742
778
"passkeyCancelled": "Nyckelskapande avbröts",
743
-
"passkeyFailed": "Nyckelregistrering misslyckades"
744
-
}
779
+
"passkeyFailed": "Nyckelregistrering misslyckades",
780
+
"signalRequired": "Telefonnummer krävs för Signal-verifiering",
781
+
"inviteRequired": "Inbjudningskod krävs",
782
+
"externalDidRequired": "Extern did:web krävs",
783
+
"emailRequired": "E-post krävs för e-postverifiering",
784
+
"telegramRequired": "Telegram-användarnamn krävs för Telegram-verifiering",
785
+
"externalDidFormat": "Extern DID måste börja med did:web:",
786
+
"discordRequired": "Discord-ID krävs för Discord-verifiering"
787
+
},
788
+
"whyPasskeyBullet1": "Kan inte nätfiskas eller stjälas vid dataintrång",
789
+
"whyPasskeyBullet2": "Använder hårdvarubaserade kryptografiska nycklar",
790
+
"whyPasskeyBullet3": "Kräver din biometri eller enhets-PIN för att använda",
791
+
"whyPasskeyOnly": "Varför endast nyckel?",
792
+
"whyPasskeyOnlyDesc": "Nyckelkonton är säkrare än lösenordsbaserade konton eftersom de:",
793
+
"subtitleInitialDidDoc": "Ladda upp ditt DID-dokument för att fortsätta.",
794
+
"subtitleUpdatedDidDoc": "Uppdatera ditt DID-dokument med PDS-signeringsnyckeln.",
795
+
"subtitleActivating": "Aktiverar ditt konto...",
796
+
"subtitleComplete": "Ditt konto har skapats!",
797
+
"subtitleCreating": "Skapar ditt konto...",
798
+
"subtitleAppPassword": "Spara ditt applösenord för tredjepartsappar.",
799
+
"creatingPasskey": "Skapar nyckel...",
800
+
"passkeyPrompt": "Klicka på knappen nedan för att skapa din nyckel. Du kommer att uppmanas att använda:",
801
+
"passkeyPromptBullet1": "Touch ID eller Face ID",
802
+
"passkeyPromptBullet2": "Din enhets PIN-kod eller lösenord",
803
+
"passkeyPromptBullet3": "En säkerhetsnyckel (om du har en)",
804
+
"identityType": "Identitetstyp",
805
+
"identityTypeHint": "Välj hur din decentraliserade identitet ska hanteras.",
806
+
"passkeyNameLabel": "Nyckelnamn (valfritt)",
807
+
"passkeyNamePlaceholder": "t.ex. MacBook Touch ID",
808
+
"passkeyNameHint": "Ett vänligt namn för att identifiera denna nyckel",
809
+
"createPasskey": "Skapa nyckel",
810
+
"didPlcRecommended": "did:plc (Rekommenderas)",
811
+
"didPlcHint": "Portabel identitet som hanteras av PLC Directory",
812
+
"didWeb": "did:web",
813
+
"didWebHint": "Identitet som lagras på denna PDS (läs varningen nedan)",
814
+
"didWebBYOD": "did:web (BYOD)",
815
+
"didWebBYODHint": "Ta med din egen domän",
816
+
"didWebWarningTitle": "Viktigt: Förstå kompromisserna",
817
+
"didWebWarning1": "Permanent koppling till denna PDS:",
818
+
"didWebWarning1Detail": "Din identitet {did} är knuten till denna server.",
819
+
"didWebWarning2": "Ingen återställningsmekanism:",
820
+
"didWebWarning2Detail": "Till skillnad från did:plc har did:web inga rotationsnycklar.",
821
+
"didWebWarning3": "Vi förbinder oss till dig:",
822
+
"didWebWarning3Detail": "Om du migrerar bort kommer vi att fortsätta servera ett minimalt DID-dokument.",
823
+
"didWebWarning4": "Rekommendation:",
824
+
"didWebWarning4Detail": "Välj did:plc om du inte har en specifik anledning att föredra did:web.",
825
+
"externalDidHint": "Du behöver servera ett DID-dokument på",
826
+
"continue": "Fortsätt",
827
+
"back": "Tillbaka",
828
+
"loading": "Laddar...",
829
+
"redirecting": "Omdirigerar till instrumentpanelen...",
830
+
"handleDotWarning": "Egna domännamn kan konfigureras efter att kontot skapats.",
831
+
"wantTraditional": "Vill du ha ett traditionellt lösenord?",
832
+
"registerWithPassword": "Registrera med lösenord"
745
833
},
746
834
"trustedDevices": {
747
835
"title": "Betrodda enheter",
748
836
"backToSecurity": "← Säkerhetsinställningar",
749
837
"description": "Betrodda enheter kan hoppa över tvåfaktorsautentisering vid inloggning. Förtroende beviljas i 30 dagar och förlängs automatiskt när du använder enheten.",
838
+
"failedToLoad": "Kunde inte ladda betrodda enheter",
750
839
"noDevices": "Inga betrodda enheter ännu.",
751
840
"noDevicesHint": "När du loggar in med tvåfaktorsautentisering aktiverat kan du välja att lita på enheten i 30 dagar.",
752
841
"lastSeen": "Senast sedd:",
+40
-6
frontend/src/locales/zh.json
+40
-6
frontend/src/locales/zh.json
···
30
30
"lostPasskey": "丢失通行密钥?",
31
31
"noAccount": "还没有账户?",
32
32
"createAccount": "立即注册",
33
-
"removeAccount": "从已保存账户中移除"
33
+
"removeAccount": "从已保存账户中移除",
34
+
"infoSavedAccountsTitle": "已保存账户",
35
+
"infoSavedAccountsDesc": "点击账户即可快速登录。您的会话令牌安全存储在此浏览器中。",
36
+
"infoNewAccountTitle": "新账户",
37
+
"infoNewAccountDesc": "使用登录按钮添加其他账户。点击 × 可从此浏览器中移除已保存的账户。",
38
+
"infoSecureSignInTitle": "安全登录",
39
+
"infoSecureSignInDesc": "您将被重定向进行安全认证。如果您启用了通行密钥或双重身份验证,也会提示您进行验证。",
40
+
"infoStaySignedInTitle": "保持登录",
41
+
"infoStaySignedInDesc": "登录后,您的账户将保存在此浏览器中,方便下次快速访问。",
42
+
"infoRecoveryTitle": "账户恢复",
43
+
"infoRecoveryDesc": "忘记密码或丢失通行密钥?使用登录按钮下方的恢复链接。"
34
44
},
35
45
"verification": {
36
46
"title": "验证账户",
···
47
57
"register": {
48
58
"title": "创建账户",
49
59
"subtitle": "在此 PDS 上创建新账户",
60
+
"subtitleKeyChoice": "选择如何设置您的外部 did:web 身份。",
61
+
"subtitleInitialDidDoc": "上传您的 DID 文档以继续。",
62
+
"subtitleVerify": "验证您的{channel}以继续。",
63
+
"subtitleUpdatedDidDoc": "使用 PDS 签名密钥更新您的 DID 文档。",
64
+
"subtitleActivating": "正在激活您的账户...",
65
+
"subtitleComplete": "您的账户已成功创建!",
66
+
"redirecting": "正在跳转到控制台...",
67
+
"infoIdentityDesc": "您的身份决定了您的账户在 ATProto 网络中的识别方式。大多数用户应选择标准选项。",
68
+
"infoContactDesc": "我们将使用此信息验证您的账户并发送有关账户安全的重要通知。",
69
+
"infoNextTitle": "接下来会发生什么?",
70
+
"infoNextDesc": "创建账户后,您需要验证联系方式,然后即可使用任何 ATProto 应用程序。",
50
71
"migrateTitle": "已有 Bluesky 账户?",
51
72
"migrateDescription": "您可以将现有账户迁移到此 PDS,而无需创建新账户。您的关注者、帖子和身份都会一起迁移。",
52
73
"migrateLink": "使用 PDS Moover 迁移",
···
211
232
"messages": {
212
233
"emailCodeSent": "验证码已发送到您的通知渠道",
213
234
"emailUpdated": "邮箱更新成功",
235
+
"emailUpdateFailed": "邮箱更新失败",
214
236
"handleUpdated": "用户名更新成功",
237
+
"handleUpdateFailed": "用户名更新失败",
215
238
"passwordChanged": "密码更改成功",
239
+
"passwordChangeFailed": "密码更改失败",
216
240
"passwordsMismatch": "两次输入的密码不一致",
241
+
"passwordsDoNotMatch": "两次输入的密码不一致",
217
242
"passwordLength": "密码至少需要8位字符",
243
+
"passwordTooShort": "密码至少需要8位字符",
218
244
"deletionCodeSent": "删除确认码已发送到您的邮箱",
245
+
"deletionConfirmationSent": "删除确认码已发送到您的邮箱",
246
+
"deletionRequestFailed": "账户删除请求失败",
247
+
"deleteConfirmation": "您确定要删除账户吗?此操作无法撤销。",
248
+
"deletionFailed": "账户删除失败",
219
249
"repoExported": "数据导出成功",
250
+
"exportFailed": "数据导出失败",
220
251
"confirmDelete": "您确定要删除账户吗?此操作无法撤销。"
221
252
}
222
253
},
···
362
393
"manageTrustedDevices": "管理受信任设备",
363
394
"appCompatibility": "应用兼容性",
364
395
"enterPassword": "输入您的密码",
396
+
"sessionExpired": "会话已过期,请重新登录。",
365
397
"legacyLoginEnabled": "已启用传统应用登录",
366
398
"legacyLoginDisabled": "已禁用传统应用登录 - 仅 OAuth 应用可登录",
367
399
"failedToUpdatePreference": "更新偏好设置失败",
···
421
453
"noRecords": "此集合中暂无记录",
422
454
"recordDetails": "记录详情",
423
455
"rkey": "记录键",
456
+
"uri": "URI",
424
457
"cid": "CID",
425
458
"value": "值",
426
459
"deleteRecord": "删除记录",
···
463
496
"themeColors": "主题颜色",
464
497
"themeColorsHint": "留空使用默认颜色。",
465
498
"primaryLight": "主色(浅色模式)",
466
-
"primaryLightDefault": "#2c00ff(默认)",
499
+
"colorDefault": "{color}(默认)",
467
500
"primaryDark": "主色(深色模式)",
468
-
"primaryDarkDefault": "#7b6bff(默认)",
469
501
"secondaryLight": "副色(浅色模式)",
470
-
"secondaryLightDefault": "#ff2400(默认)",
471
502
"secondaryDark": "副色(深色模式)",
472
-
"secondaryDarkDefault": "#ff6b5b(默认)",
473
503
"configSaved": "服务器配置已保存",
474
504
"saving": "保存中...",
475
505
"saveConfig": "保存配置",
···
527
557
"rememberDevice": "记住此设备",
528
558
"passkeyHintChecking": "正在检查通行密钥状态...",
529
559
"passkeyHintAvailable": "使用您的通行密钥登录",
530
-
"passkeyHintNotAvailable": "此账户未注册通行密钥"
560
+
"passkeyHintNotAvailable": "此账户未注册通行密钥",
561
+
"passkeyHint": "使用设备的生物识别或安全密钥",
562
+
"passwordPlaceholder": "输入您的密码",
563
+
"usePasskey": "使用通行密钥"
531
564
},
532
565
"consent": {
533
566
"title": "授权应用",
···
785
818
"title": "受信任设备",
786
819
"backToSecurity": "← 安全设置",
787
820
"description": "受信任设备可以跳过双重身份验证。信任有效期为30天,使用设备时自动延长。",
821
+
"failedToLoad": "加载受信任设备失败",
788
822
"noDevices": "暂无受信任设备",
789
823
"noDevicesHint": "开启双重身份验证后登录时,可以选择信任设备30天。",
790
824
"lastSeen": "最后使用:",
+6
-6
frontend/src/main.ts
+6
-6
frontend/src/main.ts
···
1
-
import './styles/base.css'
2
-
import App from './App.svelte'
3
-
import { mount } from 'svelte'
1
+
import "./styles/base.css";
2
+
import App from "./App.svelte";
3
+
import { mount } from "svelte";
4
4
5
5
const app = mount(App, {
6
-
target: document.getElementById('app')!,
7
-
})
6
+
target: document.getElementById("app")!,
7
+
});
8
8
9
-
export default app
9
+
export default app;
+11
-5
frontend/src/routes/Admin.svelte
+11
-5
frontend/src/routes/Admin.svelte
···
6
6
import { _ } from '../lib/i18n'
7
7
import { formatDate, formatDateTime } from '../lib/date'
8
8
const auth = getAuthState()
9
+
const DEFAULT_COLORS = {
10
+
primaryLight: '#1A1D1D',
11
+
primaryDark: '#E6E8E8',
12
+
secondaryLight: '#1A1D1D',
13
+
secondaryDark: '#E6E8E8',
14
+
}
9
15
let loading = $state(true)
10
16
let error = $state<string | null>(null)
11
17
let stats = $state<{
···
364
370
type="text"
365
371
id="primaryColor"
366
372
bind:value={primaryColorInput}
367
-
placeholder={$_('admin.primaryLightDefault')}
373
+
placeholder={$_('admin.colorDefault', { values: { color: DEFAULT_COLORS.primaryLight } })}
368
374
disabled={serverConfigLoading}
369
375
/>
370
376
</div>
···
381
387
type="text"
382
388
id="primaryColorDark"
383
389
bind:value={primaryColorDarkInput}
384
-
placeholder={$_('admin.primaryDarkDefault')}
390
+
placeholder={$_('admin.colorDefault', { values: { color: DEFAULT_COLORS.primaryDark } })}
385
391
disabled={serverConfigLoading}
386
392
/>
387
393
</div>
···
398
404
type="text"
399
405
id="secondaryColor"
400
406
bind:value={secondaryColorInput}
401
-
placeholder={$_('admin.secondaryLightDefault')}
407
+
placeholder={$_('admin.colorDefault', { values: { color: DEFAULT_COLORS.secondaryLight } })}
402
408
disabled={serverConfigLoading}
403
409
/>
404
410
</div>
···
415
421
type="text"
416
422
id="secondaryColorDark"
417
423
bind:value={secondaryColorDarkInput}
418
-
placeholder={$_('admin.secondaryDarkDefault')}
424
+
placeholder={$_('admin.colorDefault', { values: { color: DEFAULT_COLORS.secondaryDark } })}
419
425
disabled={serverConfigLoading}
420
426
/>
421
427
</div>
···
646
652
{/if}
647
653
<style>
648
654
.page {
649
-
max-width: var(--width-lg);
655
+
max-width: var(--width-xl);
650
656
margin: 0 auto;
651
657
padding: var(--space-7);
652
658
}
+1
-1
frontend/src/routes/AppPasswords.svelte
+1
-1
frontend/src/routes/AppPasswords.svelte
+274
-216
frontend/src/routes/Comms.svelte
+274
-216
frontend/src/routes/Comms.svelte
···
22
22
let verificationCode = $state('')
23
23
let verificationError = $state<string | null>(null)
24
24
let verificationSuccess = $state<string | null>(null)
25
-
let historyLoading = $state(false)
25
+
let historyLoading = $state(true)
26
26
let historyError = $state<string | null>(null)
27
27
let messages = $state<Array<{
28
28
createdAt: string
···
32
32
subject: string | null
33
33
body: string
34
34
}>>([])
35
-
let showHistory = $state(false)
36
35
$effect(() => {
37
36
if (!auth.loading && !auth.session) {
38
37
navigate('/login')
···
41
40
$effect(() => {
42
41
if (auth.session) {
43
42
loadPrefs()
43
+
loadHistory()
44
44
}
45
45
})
46
46
async function loadPrefs() {
···
120
120
try {
121
121
const result = await api.getNotificationHistory(auth.session.accessJwt)
122
122
messages = result.notifications
123
-
showHistory = true
124
123
} catch (e) {
125
124
historyError = e instanceof ApiError ? e.message : 'Failed to load notification history'
126
125
} finally {
···
171
170
<header>
172
171
<a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a>
173
172
<h1>{$_('comms.title')}</h1>
173
+
<p class="description">{$_('comms.description')}</p>
174
174
</header>
175
-
<p class="description">
176
-
{$_('comms.description')}
177
-
</p>
175
+
178
176
{#if loading}
179
177
<p class="loading">{$_('common.loading')}</p>
180
178
{:else}
···
184
182
{#if success}
185
183
<div class="message success">{success}</div>
186
184
{/if}
187
-
<form onsubmit={handleSave}>
188
-
<section>
189
-
<h2>{$_('comms.preferredChannel')}</h2>
190
-
<p class="section-description">
191
-
{$_('comms.preferredChannelDescription')}
192
-
</p>
193
-
<div class="channel-options">
194
-
{#each channels as channelId}
195
-
<label class="channel-option" class:disabled={!canSelectChannel(channelId)} class:unavailable={!isChannelAvailableOnServer(channelId)}>
196
-
<input
197
-
type="radio"
198
-
name="preferredChannel"
199
-
value={channelId}
200
-
bind:group={preferredChannel}
201
-
disabled={!canSelectChannel(channelId) || saving}
202
-
/>
203
-
<div class="channel-info">
204
-
<span class="channel-name">{getChannelName(channelId)}</span>
205
-
<span class="channel-description">{getChannelDescription(channelId)}</span>
206
-
{#if !isChannelAvailableOnServer(channelId)}
207
-
<span class="channel-hint server-unavailable">{$_('comms.notConfiguredOnServer')}</span>
208
-
{:else if channelId !== 'email' && !canSelectChannel(channelId)}
209
-
<span class="channel-hint">{$_('comms.configureToEnable')}</span>
210
-
{/if}
185
+
186
+
<div class="split-layout">
187
+
<div class="main-column">
188
+
<form onsubmit={handleSave}>
189
+
<section>
190
+
<h2>{$_('comms.preferredChannel')}</h2>
191
+
<p class="section-description">{$_('comms.preferredChannelDescription')}</p>
192
+
<div class="channel-options">
193
+
{#each channels as channelId}
194
+
<label class="channel-option" class:disabled={!canSelectChannel(channelId)} class:unavailable={!isChannelAvailableOnServer(channelId)}>
195
+
<input
196
+
type="radio"
197
+
name="preferredChannel"
198
+
value={channelId}
199
+
bind:group={preferredChannel}
200
+
disabled={!canSelectChannel(channelId) || saving}
201
+
/>
202
+
<div class="channel-info">
203
+
<span class="channel-name">{getChannelName(channelId)}</span>
204
+
<span class="channel-description">{getChannelDescription(channelId)}</span>
205
+
{#if !isChannelAvailableOnServer(channelId)}
206
+
<span class="channel-hint server-unavailable">{$_('comms.notConfiguredOnServer')}</span>
207
+
{:else if channelId !== 'email' && !canSelectChannel(channelId)}
208
+
<span class="channel-hint">{$_('comms.configureToEnable')}</span>
209
+
{/if}
210
+
</div>
211
+
</label>
212
+
{/each}
213
+
</div>
214
+
</section>
215
+
216
+
<section>
217
+
<h2>{$_('comms.channelConfiguration')}</h2>
218
+
<div class="channel-config">
219
+
<div class="config-item">
220
+
<div class="config-header">
221
+
<label for="email">{$_('register.email')}</label>
222
+
<span class="status verified">{$_('comms.primary')}</span>
223
+
</div>
224
+
<input id="email" type="email" value={email} disabled class="readonly" />
225
+
<p class="config-hint">{$_('comms.emailManagedInSettings')}</p>
211
226
</div>
212
-
</label>
213
-
{/each}
214
-
</div>
215
-
</section>
216
-
<section>
217
-
<h2>{$_('comms.channelConfiguration')}</h2>
218
-
<div class="channel-config">
219
-
<div class="config-item">
220
-
<label for="email">{$_('register.email')}</label>
221
-
<div class="config-input">
222
-
<input
223
-
id="email"
224
-
type="email"
225
-
value={email}
226
-
disabled
227
-
class="readonly"
228
-
/>
229
-
<span class="status verified">{$_('comms.primary')}</span>
230
-
</div>
231
-
<p class="config-hint">{$_('comms.emailManagedInSettings')}</p>
232
-
</div>
233
-
<div class="config-item" class:unavailable={!isChannelAvailableOnServer('discord')}>
234
-
<label for="discord">{$_('register.discordId')}</label>
235
-
<div class="config-input">
236
-
<input
237
-
id="discord"
238
-
type="text"
239
-
bind:value={discordId}
240
-
placeholder={$_('register.discordIdPlaceholder')}
241
-
disabled={saving || !isChannelAvailableOnServer('discord')}
242
-
/>
243
-
{#if !isChannelAvailableOnServer('discord')}
244
-
<span class="status unavailable">{$_('comms.notConfiguredOnServer')}</span>
245
-
{:else if discordId}
246
-
{#if discordVerified}
247
-
<span class="status verified">{$_('comms.verified')}</span>
248
-
{:else}
249
-
<span class="status unverified">{$_('comms.notVerified')}</span>
250
-
<button type="button" class="verify-btn" onclick={() => verifyingChannel = 'discord'}>{$_('comms.verifyButton')}</button>
227
+
228
+
<div class="config-item" class:unavailable={!isChannelAvailableOnServer('discord')}>
229
+
<div class="config-header">
230
+
<label for="discord">{$_('register.discordId')}</label>
231
+
{#if !isChannelAvailableOnServer('discord')}
232
+
<span class="status unavailable">{$_('comms.notConfiguredOnServer')}</span>
233
+
{:else if discordId}
234
+
{#if discordVerified}
235
+
<span class="status verified">{$_('comms.verified')}</span>
236
+
{:else}
237
+
<span class="status unverified">{$_('comms.notVerified')}</span>
238
+
{/if}
239
+
{/if}
240
+
</div>
241
+
<div class="config-input">
242
+
<input
243
+
id="discord"
244
+
type="text"
245
+
bind:value={discordId}
246
+
placeholder={$_('register.discordIdPlaceholder')}
247
+
disabled={saving || !isChannelAvailableOnServer('discord')}
248
+
/>
249
+
{#if discordId && !discordVerified && isChannelAvailableOnServer('discord')}
250
+
<button type="button" class="verify-btn" onclick={() => verifyingChannel = 'discord'}>{$_('comms.verifyButton')}</button>
251
+
{/if}
252
+
</div>
253
+
<p class="config-hint">{$_('comms.discordIdHint')}</p>
254
+
{#if verifyingChannel === 'discord'}
255
+
<div class="verify-form">
256
+
<input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="6" />
257
+
<button type="button" onclick={() => handleVerify('discord')}>{$_('comms.submit')}</button>
258
+
<button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button>
259
+
</div>
251
260
{/if}
252
-
{/if}
253
-
</div>
254
-
<p class="config-hint">{$_('comms.discordIdHint')}</p>
255
-
{#if verifyingChannel === 'discord'}
256
-
<div class="verify-form">
257
-
<input
258
-
type="text"
259
-
bind:value={verificationCode}
260
-
placeholder={$_('comms.verifyCodePlaceholder')}
261
-
maxlength="6"
262
-
/>
263
-
<button type="button" onclick={() => handleVerify('discord')}>{$_('comms.submit')}</button>
264
-
<button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button>
265
261
</div>
266
-
{/if}
267
-
</div>
268
-
<div class="config-item" class:unavailable={!isChannelAvailableOnServer('telegram')}>
269
-
<label for="telegram">{$_('register.telegramUsername')}</label>
270
-
<div class="config-input">
271
-
<input
272
-
id="telegram"
273
-
type="text"
274
-
bind:value={telegramUsername}
275
-
placeholder={$_('register.telegramUsernamePlaceholder')}
276
-
disabled={saving || !isChannelAvailableOnServer('telegram')}
277
-
/>
278
-
{#if !isChannelAvailableOnServer('telegram')}
279
-
<span class="status unavailable">{$_('comms.notConfiguredOnServer')}</span>
280
-
{:else if telegramUsername}
281
-
{#if telegramVerified}
282
-
<span class="status verified">{$_('comms.verified')}</span>
283
-
{:else}
284
-
<span class="status unverified">{$_('comms.notVerified')}</span>
285
-
<button type="button" class="verify-btn" onclick={() => verifyingChannel = 'telegram'}>{$_('comms.verifyButton')}</button>
262
+
263
+
<div class="config-item" class:unavailable={!isChannelAvailableOnServer('telegram')}>
264
+
<div class="config-header">
265
+
<label for="telegram">{$_('register.telegramUsername')}</label>
266
+
{#if !isChannelAvailableOnServer('telegram')}
267
+
<span class="status unavailable">{$_('comms.notConfiguredOnServer')}</span>
268
+
{:else if telegramUsername}
269
+
{#if telegramVerified}
270
+
<span class="status verified">{$_('comms.verified')}</span>
271
+
{:else}
272
+
<span class="status unverified">{$_('comms.notVerified')}</span>
273
+
{/if}
274
+
{/if}
275
+
</div>
276
+
<div class="config-input">
277
+
<input
278
+
id="telegram"
279
+
type="text"
280
+
bind:value={telegramUsername}
281
+
placeholder={$_('register.telegramUsernamePlaceholder')}
282
+
disabled={saving || !isChannelAvailableOnServer('telegram')}
283
+
/>
284
+
{#if telegramUsername && !telegramVerified && isChannelAvailableOnServer('telegram')}
285
+
<button type="button" class="verify-btn" onclick={() => verifyingChannel = 'telegram'}>{$_('comms.verifyButton')}</button>
286
+
{/if}
287
+
</div>
288
+
<p class="config-hint">{$_('comms.telegramHint')}</p>
289
+
{#if verifyingChannel === 'telegram'}
290
+
<div class="verify-form">
291
+
<input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="6" />
292
+
<button type="button" onclick={() => handleVerify('telegram')}>{$_('comms.submit')}</button>
293
+
<button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button>
294
+
</div>
286
295
{/if}
287
-
{/if}
288
-
</div>
289
-
<p class="config-hint">{$_('comms.telegramHint')}</p>
290
-
{#if verifyingChannel === 'telegram'}
291
-
<div class="verify-form">
292
-
<input
293
-
type="text"
294
-
bind:value={verificationCode}
295
-
placeholder={$_('comms.verifyCodePlaceholder')}
296
-
maxlength="6"
297
-
/>
298
-
<button type="button" onclick={() => handleVerify('telegram')}>{$_('comms.submit')}</button>
299
-
<button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button>
300
296
</div>
301
-
{/if}
302
-
</div>
303
-
<div class="config-item" class:unavailable={!isChannelAvailableOnServer('signal')}>
304
-
<label for="signal">{$_('register.signalNumber')}</label>
305
-
<div class="config-input">
306
-
<input
307
-
id="signal"
308
-
type="tel"
309
-
bind:value={signalNumber}
310
-
placeholder={$_('register.signalNumberPlaceholder')}
311
-
disabled={saving || !isChannelAvailableOnServer('signal')}
312
-
/>
313
-
{#if !isChannelAvailableOnServer('signal')}
314
-
<span class="status unavailable">{$_('comms.notConfiguredOnServer')}</span>
315
-
{:else if signalNumber}
316
-
{#if signalVerified}
317
-
<span class="status verified">{$_('comms.verified')}</span>
318
-
{:else}
319
-
<span class="status unverified">{$_('comms.notVerified')}</span>
320
-
<button type="button" class="verify-btn" onclick={() => verifyingChannel = 'signal'}>{$_('comms.verifyButton')}</button>
297
+
298
+
<div class="config-item" class:unavailable={!isChannelAvailableOnServer('signal')}>
299
+
<div class="config-header">
300
+
<label for="signal">{$_('register.signalNumber')}</label>
301
+
{#if !isChannelAvailableOnServer('signal')}
302
+
<span class="status unavailable">{$_('comms.notConfiguredOnServer')}</span>
303
+
{:else if signalNumber}
304
+
{#if signalVerified}
305
+
<span class="status verified">{$_('comms.verified')}</span>
306
+
{:else}
307
+
<span class="status unverified">{$_('comms.notVerified')}</span>
308
+
{/if}
309
+
{/if}
310
+
</div>
311
+
<div class="config-input">
312
+
<input
313
+
id="signal"
314
+
type="tel"
315
+
bind:value={signalNumber}
316
+
placeholder={$_('register.signalNumberPlaceholder')}
317
+
disabled={saving || !isChannelAvailableOnServer('signal')}
318
+
/>
319
+
{#if signalNumber && !signalVerified && isChannelAvailableOnServer('signal')}
320
+
<button type="button" class="verify-btn" onclick={() => verifyingChannel = 'signal'}>{$_('comms.verifyButton')}</button>
321
+
{/if}
322
+
</div>
323
+
<p class="config-hint">{$_('comms.signalHint')}</p>
324
+
{#if verifyingChannel === 'signal'}
325
+
<div class="verify-form">
326
+
<input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="6" />
327
+
<button type="button" onclick={() => handleVerify('signal')}>{$_('comms.submit')}</button>
328
+
<button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button>
329
+
</div>
321
330
{/if}
322
-
{/if}
331
+
</div>
323
332
</div>
324
-
<p class="config-hint">{$_('comms.signalHint')}</p>
325
-
{#if verifyingChannel === 'signal'}
326
-
<div class="verify-form">
327
-
<input
328
-
type="text"
329
-
bind:value={verificationCode}
330
-
placeholder={$_('comms.verifyCodePlaceholder')}
331
-
maxlength="6"
332
-
/>
333
-
<button type="button" onclick={() => handleVerify('signal')}>{$_('comms.submit')}</button>
334
-
<button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button>
335
-
</div>
333
+
334
+
{#if verificationError}
335
+
<div class="message error" style="margin-top: 1rem">{verificationError}</div>
336
+
{/if}
337
+
{#if verificationSuccess}
338
+
<div class="message success" style="margin-top: 1rem">{verificationSuccess}</div>
336
339
{/if}
340
+
</section>
341
+
342
+
<div class="actions">
343
+
<button type="submit" disabled={saving}>
344
+
{saving ? $_('comms.saving') : $_('comms.savePreferences')}
345
+
</button>
337
346
</div>
338
-
</div>
339
-
{#if verificationError}
340
-
<div class="message error" style="margin-top: 1rem">{verificationError}</div>
341
-
{/if}
342
-
{#if verificationSuccess}
343
-
<div class="message success" style="margin-top: 1rem">{verificationSuccess}</div>
344
-
{/if}
345
-
</section>
346
-
<div class="actions">
347
-
<button type="submit" disabled={saving}>
348
-
{saving ? $_('comms.saving') : $_('comms.savePreferences')}
349
-
</button>
347
+
</form>
350
348
</div>
351
-
</form>
352
-
<section class="history-section">
353
-
<h2>{$_('comms.messageHistory')}</h2>
354
-
<p class="section-description">{$_('comms.historyDescription')}</p>
355
-
{#if !showHistory}
356
-
<button class="load-history" onclick={loadHistory} disabled={historyLoading}>
357
-
{historyLoading ? $_('common.loading') : $_('comms.loadHistory')}
358
-
</button>
359
-
{:else}
360
-
<button class="load-history" onclick={() => showHistory = false}>{$_('comms.hideHistory')}</button>
361
-
{#if historyError}
362
-
<div class="message error">{historyError}</div>
363
-
{:else if messages.length === 0}
364
-
<p class="no-messages">{$_('comms.noMessages')}</p>
365
-
{:else}
366
-
<div class="message-list">
367
-
{#each messages as msg}
368
-
<div class="message-item">
369
-
<div class="message-header">
370
-
<span class="message-type">{msg.notificationType}</span>
371
-
<span class="message-channel">{msg.channel}</span>
372
-
<span class="message-status" class:sent={msg.status === 'sent'} class:failed={msg.status === 'failed'}>{msg.status}</span>
349
+
350
+
<div class="side-column">
351
+
<section class="history-section">
352
+
<h2>{$_('comms.messageHistory')}</h2>
353
+
<p class="section-description">{$_('comms.historyDescription')}</p>
354
+
{#if historyLoading}
355
+
<div class="skeleton-list">
356
+
{#each [1, 2, 3] as _}
357
+
<div class="skeleton-item">
358
+
<div class="skeleton-header">
359
+
<div class="skeleton-line short"></div>
360
+
<div class="skeleton-line tiny"></div>
361
+
</div>
362
+
<div class="skeleton-line"></div>
363
+
<div class="skeleton-line medium"></div>
364
+
</div>
365
+
{/each}
366
+
</div>
367
+
{:else if historyError}
368
+
<div class="message error">{historyError}</div>
369
+
{:else if messages.length === 0}
370
+
<p class="no-messages">{$_('comms.noMessages')}</p>
371
+
{:else}
372
+
<div class="message-list">
373
+
{#each messages as msg}
374
+
<div class="message-item">
375
+
<div class="message-header">
376
+
<span class="message-type">{msg.notificationType}</span>
377
+
<span class="message-channel">{msg.channel}</span>
378
+
<span class="message-status" class:sent={msg.status === 'sent'} class:failed={msg.status === 'failed'}>{msg.status}</span>
379
+
</div>
380
+
{#if msg.subject}
381
+
<div class="message-subject">{msg.subject}</div>
382
+
{/if}
383
+
<div class="message-body">{msg.body}</div>
384
+
<div class="message-date">{formatDate(msg.createdAt)}</div>
373
385
</div>
374
-
{#if msg.subject}
375
-
<div class="message-subject">{msg.subject}</div>
376
-
{/if}
377
-
<div class="message-body">{msg.body}</div>
378
-
<div class="message-date">{formatDate(msg.createdAt)}</div>
379
-
</div>
380
-
{/each}
381
-
</div>
382
-
{/if}
383
-
{/if}
384
-
</section>
386
+
{/each}
387
+
</div>
388
+
{/if}
389
+
</section>
390
+
</div>
391
+
</div>
385
392
{/if}
386
393
</div>
387
394
<style>
388
395
.page {
389
-
max-width: var(--width-md);
396
+
max-width: var(--width-xl);
390
397
margin: 0 auto;
391
398
padding: var(--space-7);
392
399
}
393
400
394
401
header {
395
-
margin-bottom: var(--space-4);
402
+
margin-bottom: var(--space-6);
396
403
}
397
404
398
405
.back {
···
411
418
412
419
.description {
413
420
color: var(--text-secondary);
414
-
margin-bottom: var(--space-7);
421
+
margin: var(--space-2) 0 0 0;
415
422
}
416
423
417
424
.loading {
···
420
427
padding: var(--space-7);
421
428
}
422
429
430
+
.split-layout {
431
+
display: grid;
432
+
grid-template-columns: 1fr;
433
+
gap: var(--space-6);
434
+
}
435
+
436
+
@media (min-width: 900px) {
437
+
.split-layout {
438
+
grid-template-columns: 1.5fr 1fr;
439
+
align-items: start;
440
+
}
441
+
}
442
+
443
+
.main-column, .side-column {
444
+
min-width: 0;
445
+
}
446
+
423
447
section {
424
448
background: var(--bg-secondary);
425
449
padding: var(--space-6);
426
450
border-radius: var(--radius-xl);
427
451
margin-bottom: var(--space-6);
452
+
}
453
+
454
+
.side-column section {
455
+
margin-bottom: 0;
428
456
}
429
457
430
458
section h2 {
···
520
548
opacity: 0.6;
521
549
}
522
550
551
+
.config-header {
552
+
display: flex;
553
+
align-items: center;
554
+
justify-content: space-between;
555
+
gap: var(--space-3);
556
+
margin-bottom: var(--space-1);
557
+
}
558
+
523
559
.config-item label {
524
560
font-size: var(--text-sm);
525
561
font-weight: var(--font-medium);
···
533
569
534
570
.config-input input {
535
571
flex: 1;
572
+
min-width: 0;
536
573
}
537
574
538
-
.config-input input.readonly {
575
+
.config-item input.readonly {
539
576
background: var(--bg-input-disabled);
540
577
color: var(--text-secondary);
541
578
}
···
624
661
background: var(--bg-secondary);
625
662
}
626
663
627
-
.history-section {
628
-
background: var(--bg-secondary);
629
-
padding: var(--space-6);
630
-
border-radius: var(--radius-xl);
631
-
margin-top: var(--space-6);
632
-
}
633
-
634
664
.history-section h2 {
635
665
margin: 0 0 var(--space-2) 0;
636
666
font-size: var(--text-lg);
637
667
}
638
668
639
-
.load-history {
640
-
padding: var(--space-2) var(--space-4);
641
-
background: transparent;
669
+
.skeleton-list {
670
+
display: flex;
671
+
flex-direction: column;
672
+
gap: var(--space-3);
673
+
}
674
+
675
+
.skeleton-item {
676
+
background: var(--bg-card);
642
677
border: 1px solid var(--border-color);
643
678
border-radius: var(--radius-md);
644
-
cursor: pointer;
645
-
color: var(--text-primary);
646
-
margin-top: var(--space-2);
679
+
padding: var(--space-3);
680
+
}
681
+
682
+
.skeleton-header {
683
+
display: flex;
684
+
gap: var(--space-2);
685
+
margin-bottom: var(--space-2);
686
+
}
687
+
688
+
.skeleton-line {
689
+
height: 14px;
690
+
background: var(--bg-tertiary);
691
+
border-radius: var(--radius-sm);
692
+
animation: skeleton-pulse 1.5s ease-in-out infinite;
693
+
}
694
+
695
+
.skeleton-line.short {
696
+
width: 80px;
697
+
}
698
+
699
+
.skeleton-line.tiny {
700
+
width: 50px;
701
+
}
702
+
703
+
.skeleton-line.medium {
704
+
width: 60%;
647
705
}
648
706
649
-
.load-history:hover:not(:disabled) {
650
-
background: var(--bg-card);
651
-
border-color: var(--accent);
707
+
.skeleton-line:not(.short):not(.tiny):not(.medium) {
708
+
width: 100%;
709
+
margin-bottom: var(--space-1);
652
710
}
653
711
654
-
.load-history:disabled {
655
-
opacity: 0.6;
656
-
cursor: not-allowed;
712
+
@keyframes skeleton-pulse {
713
+
0%, 100% { opacity: 1; }
714
+
50% { opacity: 0.4; }
657
715
}
658
716
659
717
.no-messages {
+19
-5
frontend/src/routes/Dashboard.svelte
+19
-5
frontend/src/routes/Dashboard.svelte
···
2
2
import { getAuthState, logout, switchAccount } from '../lib/auth.svelte'
3
3
import { navigate } from '../lib/router.svelte'
4
4
import { _ } from '../lib/i18n'
5
+
import { api } from '../lib/api'
6
+
import { onMount } from 'svelte'
5
7
6
8
const auth = getAuthState()
7
9
let dropdownOpen = $state(false)
8
10
let switching = $state(false)
11
+
let inviteCodesEnabled = $state(false)
12
+
13
+
onMount(async () => {
14
+
try {
15
+
const serverInfo = await api.describeServer()
16
+
inviteCodesEnabled = serverInfo.inviteCodeRequired
17
+
} catch {
18
+
inviteCodesEnabled = false
19
+
}
20
+
})
9
21
10
22
$effect(() => {
11
23
if (!auth.loading && !auth.session) {
···
152
164
<h3>{$_('dashboard.navSessions')}</h3>
153
165
<p>{$_('dashboard.navSessionsDesc')}</p>
154
166
</a>
155
-
<a href="#/invite-codes" class="nav-card">
156
-
<h3>{$_('dashboard.navInviteCodes')}</h3>
157
-
<p>{$_('dashboard.navInviteCodesDesc')}</p>
158
-
</a>
167
+
{#if inviteCodesEnabled}
168
+
<a href="#/invite-codes" class="nav-card">
169
+
<h3>{$_('dashboard.navInviteCodes')}</h3>
170
+
<p>{$_('dashboard.navInviteCodesDesc')}</p>
171
+
</a>
172
+
{/if}
159
173
<a href="#/settings" class="nav-card">
160
174
<h3>{$_('dashboard.navSettings')}</h3>
161
175
<p>{$_('dashboard.navSettingsDesc')}</p>
···
186
200
187
201
<style>
188
202
.dashboard {
189
-
max-width: var(--width-lg);
203
+
max-width: var(--width-xl);
190
204
margin: 0 auto;
191
205
padding: var(--space-7);
192
206
}
+57
-3
frontend/src/routes/Home.svelte
+57
-3
frontend/src/routes/Home.svelte
···
13
13
let pdsVersion = $state<string | null>(null)
14
14
let userCount = $state<number | null>(null)
15
15
16
+
const heroWords = ['Bluesky', 'Tangled', 'Leaflet', 'ATProto']
17
+
const wordSpacing: Record<string, string> = {
18
+
'Bluesky': '0.01em',
19
+
'Tangled': '0.02em',
20
+
'Leaflet': '0.05em',
21
+
'ATProto': '0',
22
+
}
23
+
let currentWordIndex = $state(0)
24
+
let isTransitioning = $state(false)
25
+
let currentWord = $derived(heroWords[currentWordIndex])
26
+
let currentSpacing = $derived(wordSpacing[currentWord] || '0')
27
+
16
28
onMount(() => {
17
29
api.describeServer().then(info => {
18
30
if (info.availableUserDomains?.length) {
···
23
35
}
24
36
}).catch(() => {})
25
37
38
+
const baseDuration = 2000
39
+
let wordTimeout: ReturnType<typeof setTimeout>
40
+
41
+
function cycleWord() {
42
+
isTransitioning = true
43
+
setTimeout(() => {
44
+
currentWordIndex = (currentWordIndex + 1) % heroWords.length
45
+
isTransitioning = false
46
+
const duration = heroWords[currentWordIndex] === 'ATProto' ? baseDuration * 2 : baseDuration
47
+
wordTimeout = setTimeout(cycleWord, duration)
48
+
}, 100)
49
+
}
50
+
51
+
wordTimeout = setTimeout(cycleWord, baseDuration)
52
+
26
53
api.listRepos(1000).then(data => {
27
54
userCount = data.repos.length
28
55
}).catch(() => {})
···
75
102
return () => {
76
103
document.removeEventListener('mousemove', handleMouseMove)
77
104
cancelAnimationFrame(animationId)
105
+
clearTimeout(wordTimeout)
78
106
}
79
107
})
80
108
</script>
···
103
131
104
132
<div class="home">
105
133
<section class="hero">
106
-
<h1>A home for your ATProto account</h1>
134
+
<h1>A home for your <span class="cycling-word-container"><span class="cycling-word" class:transitioning={isTransitioning} style="letter-spacing: {currentSpacing}">{currentWord}</span></span> account</h1>
107
135
108
136
<p class="lede">Tranquil PDS is a Personal Data Server, the thing that stores your posts, profile, and keys. Bluesky runs one for you, but you can run your own.</p>
109
137
···
268
296
269
297
.user-count {
270
298
font-size: var(--text-sm);
271
-
color: rgba(255, 255, 255, 0.85);
299
+
color: var(--text-inverse);
300
+
opacity: 0.85;
272
301
padding: 4px 10px;
273
302
background: rgba(255, 255, 255, 0.15);
274
303
border-radius: var(--radius-md);
304
+
white-space: nowrap;
305
+
}
306
+
307
+
@media (prefers-color-scheme: dark) {
308
+
.user-count {
309
+
background: rgba(0, 0, 0, 0.15);
310
+
}
275
311
}
276
312
277
313
.nav-meta {
278
314
font-size: var(--text-sm);
279
-
color: rgba(255, 255, 255, 0.7);
315
+
color: var(--text-inverse);
316
+
opacity: 0.6;
280
317
letter-spacing: 0.05em;
281
318
}
282
319
···
300
337
line-height: var(--leading-tight);
301
338
margin-bottom: var(--space-6);
302
339
letter-spacing: -0.02em;
340
+
}
341
+
342
+
.cycling-word-container {
343
+
display: inline-block;
344
+
width: 3.9em;
345
+
text-align: left;
346
+
}
347
+
348
+
.cycling-word {
349
+
display: inline-block;
350
+
transition: opacity 0.1s ease, transform 0.1s ease;
351
+
}
352
+
353
+
.cycling-word.transitioning {
354
+
opacity: 0;
355
+
transform: scale(0.95);
303
356
}
304
357
305
358
.lede {
···
439
492
text-align: center;
440
493
}
441
494
495
+
.user-count,
442
496
.nav-meta {
443
497
display: none;
444
498
}
+18
-2
frontend/src/routes/InviteCodes.svelte
+18
-2
frontend/src/routes/InviteCodes.svelte
···
4
4
import { api, type InviteCode, ApiError } from '../lib/api'
5
5
import { _ } from '../lib/i18n'
6
6
import { formatDate } from '../lib/date'
7
+
import { onMount } from 'svelte'
8
+
7
9
const auth = getAuthState()
8
10
let codes = $state<InviteCode[]>([])
9
11
let loading = $state(true)
10
12
let error = $state<string | null>(null)
11
13
let creating = $state(false)
12
14
let createdCode = $state<string | null>(null)
15
+
let inviteCodesEnabled = $state<boolean | null>(null)
16
+
17
+
onMount(async () => {
18
+
try {
19
+
const serverInfo = await api.describeServer()
20
+
inviteCodesEnabled = serverInfo.inviteCodeRequired
21
+
if (!serverInfo.inviteCodeRequired) {
22
+
navigate('/dashboard')
23
+
}
24
+
} catch {
25
+
navigate('/dashboard')
26
+
}
27
+
})
28
+
13
29
$effect(() => {
14
30
if (!auth.loading && !auth.session) {
15
31
navigate('/login')
16
32
}
17
33
})
18
34
$effect(() => {
19
-
if (auth.session) {
35
+
if (auth.session && inviteCodesEnabled) {
20
36
loadCodes()
21
37
}
22
38
})
···
114
130
</div>
115
131
<style>
116
132
.page {
117
-
max-width: var(--width-md);
133
+
max-width: var(--width-lg);
118
134
margin: 0 auto;
119
135
padding: var(--space-7);
120
136
}
+105
-68
frontend/src/routes/Login.svelte
+105
-68
frontend/src/routes/Login.svelte
···
8
8
let verificationCode = $state('')
9
9
let resendingCode = $state(false)
10
10
let resendMessage = $state<string | null>(null)
11
-
let showNewLogin = $state(false)
11
+
let autoRedirectAttempted = $state(false)
12
12
const auth = getAuthState()
13
+
14
+
$effect(() => {
15
+
if (!auth.loading && !auth.error && auth.savedAccounts.length === 0 && !pendingVerification && !autoRedirectAttempted) {
16
+
autoRedirectAttempted = true
17
+
loginWithOAuth()
18
+
}
19
+
})
13
20
14
21
async function handleSwitchAccount(did: string) {
15
22
submitting = true
···
74
81
{/if}
75
82
76
83
{#if pendingVerification}
77
-
<h1>{$_('verification.title')}</h1>
78
-
<p class="subtitle">{$_('verification.subtitle')}</p>
84
+
<header class="page-header">
85
+
<h1>{$_('verification.title')}</h1>
86
+
<p class="subtitle">{$_('verification.subtitle')}</p>
87
+
</header>
79
88
80
89
{#if resendMessage}
81
90
<div class="message success">{resendMessage}</div>
···
109
118
</div>
110
119
</form>
111
120
112
-
{:else if auth.savedAccounts.length > 0 && !showNewLogin}
113
-
<h1>{$_('login.title')}</h1>
114
-
<p class="subtitle">{$_('login.chooseAccount')}</p>
121
+
{:else}
122
+
<header class="page-header">
123
+
<h1>{$_('login.title')}</h1>
124
+
<p class="subtitle">{auth.savedAccounts.length > 0 ? $_('login.chooseAccount') : $_('login.subtitle')}</p>
125
+
</header>
115
126
116
-
<div class="saved-accounts">
117
-
{#each auth.savedAccounts as account}
118
-
<div
119
-
class="account-item"
120
-
class:disabled={submitting}
121
-
role="button"
122
-
tabindex="0"
123
-
onclick={() => !submitting && handleSwitchAccount(account.did)}
124
-
onkeydown={(e) => e.key === 'Enter' && !submitting && handleSwitchAccount(account.did)}
125
-
>
126
-
<div class="account-info">
127
-
<span class="account-handle">@{account.handle}</span>
128
-
<span class="account-did">{account.did}</span>
127
+
<div class="split-layout sidebar-right">
128
+
<div class="main-section">
129
+
{#if auth.savedAccounts.length > 0}
130
+
<div class="saved-accounts">
131
+
{#each auth.savedAccounts as account}
132
+
<div
133
+
class="account-item"
134
+
class:disabled={submitting}
135
+
role="button"
136
+
tabindex="0"
137
+
onclick={() => !submitting && handleSwitchAccount(account.did)}
138
+
onkeydown={(e) => e.key === 'Enter' && !submitting && handleSwitchAccount(account.did)}
139
+
>
140
+
<div class="account-info">
141
+
<span class="account-handle">@{account.handle}</span>
142
+
<span class="account-did">{account.did}</span>
143
+
</div>
144
+
<button
145
+
type="button"
146
+
class="forget-btn"
147
+
onclick={(e) => handleForgetAccount(account.did, e)}
148
+
title={$_('login.removeAccount')}
149
+
>
150
+
×
151
+
</button>
152
+
</div>
153
+
{/each}
129
154
</div>
130
-
<button
131
-
type="button"
132
-
class="forget-btn"
133
-
onclick={(e) => handleForgetAccount(account.did, e)}
134
-
title={$_('login.removeAccount')}
135
-
>
136
-
×
137
-
</button>
138
-
</div>
139
-
{/each}
140
-
</div>
155
+
156
+
<p class="or-divider">{$_('login.signInToAnother')}</p>
157
+
{/if}
141
158
142
-
<button type="button" class="secondary full-width" onclick={() => showNewLogin = true}>
143
-
{$_('login.signInToAnother')}
144
-
</button>
159
+
<button type="button" class="oauth-btn" onclick={handleOAuthLogin} disabled={submitting || auth.loading}>
160
+
{submitting ? $_('login.redirecting') : $_('login.button')}
161
+
</button>
145
162
146
-
<p class="link-text">
147
-
{$_('login.noAccount')} <a href="#/register">{$_('login.createAccount')}</a>
148
-
</p>
163
+
<p class="forgot-links">
164
+
<a href="#/reset-password">{$_('login.forgotPassword')}</a>
165
+
<span class="separator">·</span>
166
+
<a href="#/request-passkey-recovery">{$_('login.lostPasskey')}</a>
167
+
</p>
149
168
150
-
{:else}
151
-
<h1>{$_('login.title')}</h1>
152
-
<p class="subtitle">{$_('login.subtitle')}</p>
169
+
<p class="link-text">
170
+
{$_('login.noAccount')} <a href="#/register">{$_('login.createAccount')}</a>
171
+
</p>
172
+
</div>
153
173
154
-
{#if auth.savedAccounts.length > 0}
155
-
<button type="button" class="tertiary back-btn" onclick={() => showNewLogin = false}>
156
-
{$_('login.backToSaved')}
157
-
</button>
158
-
{/if}
174
+
<aside class="info-panel">
175
+
{#if auth.savedAccounts.length > 0}
176
+
<h3>{$_('login.infoSavedAccountsTitle')}</h3>
177
+
<p>{$_('login.infoSavedAccountsDesc')}</p>
159
178
160
-
<button type="button" class="oauth-btn" onclick={handleOAuthLogin} disabled={submitting || auth.loading}>
161
-
{submitting ? $_('login.redirecting') : $_('login.button')}
162
-
</button>
179
+
<h3>{$_('login.infoNewAccountTitle')}</h3>
180
+
<p>{$_('login.infoNewAccountDesc')}</p>
181
+
{:else}
182
+
<h3>{$_('login.infoSecureSignInTitle')}</h3>
183
+
<p>{$_('login.infoSecureSignInDesc')}</p>
163
184
164
-
<p class="forgot-links">
165
-
<a href="#/reset-password">{$_('login.forgotPassword')}</a>
166
-
<span class="separator">·</span>
167
-
<a href="#/request-passkey-recovery">{$_('login.lostPasskey')}</a>
168
-
</p>
185
+
<h3>{$_('login.infoStaySignedInTitle')}</h3>
186
+
<p>{$_('login.infoStaySignedInDesc')}</p>
187
+
{/if}
169
188
170
-
<p class="link-text">
171
-
{$_('login.noAccount')} <a href="#/register">{$_('login.createAccount')}</a>
172
-
</p>
189
+
<h3>{$_('login.infoRecoveryTitle')}</h3>
190
+
<p>{$_('login.infoRecoveryDesc')}</p>
191
+
</aside>
192
+
</div>
173
193
{/if}
174
194
</div>
175
195
176
196
<style>
177
197
.login-page {
178
-
max-width: var(--width-sm);
198
+
max-width: var(--width-lg);
179
199
margin: var(--space-9) auto;
180
200
padding: var(--space-7);
201
+
}
202
+
203
+
.page-header {
204
+
margin-bottom: var(--space-6);
181
205
}
182
206
183
207
h1 {
···
186
210
187
211
.subtitle {
188
212
color: var(--text-secondary);
189
-
margin: 0 0 var(--space-7) 0;
213
+
margin: 0;
214
+
}
215
+
216
+
.main-section {
217
+
min-width: 0;
190
218
}
191
219
192
220
form {
193
221
display: flex;
194
222
flex-direction: column;
195
223
gap: var(--space-4);
224
+
max-width: var(--width-sm);
196
225
}
197
226
198
227
.actions {
···
202
231
margin-top: var(--space-3);
203
232
}
204
233
234
+
@media (min-width: 600px) {
235
+
.actions {
236
+
flex-direction: row;
237
+
}
238
+
239
+
.actions button {
240
+
flex: 1;
241
+
}
242
+
}
243
+
205
244
.oauth-btn {
206
245
width: 100%;
207
246
padding: var(--space-5);
···
209
248
}
210
249
211
250
.forgot-links {
212
-
text-align: center;
213
-
margin-top: var(--space-5);
251
+
margin-top: var(--space-4);
252
+
font-size: var(--text-sm);
214
253
color: var(--text-secondary);
215
254
}
216
255
···
223
262
}
224
263
225
264
.link-text {
226
-
text-align: center;
227
-
margin-top: var(--space-4);
265
+
margin-top: var(--space-6);
266
+
font-size: var(--text-sm);
228
267
color: var(--text-secondary);
229
268
}
230
269
···
297
336
color: var(--error-text);
298
337
}
299
338
300
-
.full-width {
301
-
width: 100%;
302
-
}
303
-
304
-
.back-btn {
305
-
margin-bottom: var(--space-5);
306
-
padding: 0;
339
+
.or-divider {
340
+
text-align: center;
341
+
color: var(--text-muted);
342
+
font-size: var(--text-sm);
343
+
margin: var(--space-5) 0;
307
344
}
308
345
</style>
+80
-46
frontend/src/routes/OAuthConsent.svelte
+80
-46
frontend/src/routes/OAuthConsent.svelte
···
167
167
</button>
168
168
</div>
169
169
{:else if consentData}
170
-
<div class="client-info">
171
-
{#if consentData.logo_uri}
172
-
<img src={consentData.logo_uri} alt="" class="client-logo" />
173
-
{/if}
174
-
<h1>{consentData.client_name || $_('oauth.consent.title')}</h1>
175
-
<p class="subtitle">{$_('oauth.consent.appWantsAccess', { values: { app: '' } })}</p>
176
-
{#if consentData.client_uri}
177
-
<a href={consentData.client_uri} target="_blank" rel="noopener noreferrer" class="client-link">
178
-
{consentData.client_uri}
179
-
</a>
180
-
{/if}
181
-
</div>
170
+
<div class="split-layout sidebar-left">
171
+
<div class="client-panel">
172
+
<div class="client-info">
173
+
{#if consentData.logo_uri}
174
+
<img src={consentData.logo_uri} alt="" class="client-logo" />
175
+
{/if}
176
+
<h1>{consentData.client_name || $_('oauth.consent.title')}</h1>
177
+
<p class="subtitle">{$_('oauth.consent.appWantsAccess', { values: { app: '' } })}</p>
178
+
{#if consentData.client_uri}
179
+
<a href={consentData.client_uri} target="_blank" rel="noopener noreferrer" class="client-link">
180
+
{consentData.client_uri}
181
+
</a>
182
+
{/if}
183
+
</div>
182
184
183
-
<div class="account-info">
184
-
<span class="label">{$_('oauth.consent.signingInAs')}</span>
185
-
<span class="did">{consentData.did}</span>
186
-
</div>
185
+
<div class="account-info">
186
+
<span class="label">{$_('oauth.consent.signingInAs')}</span>
187
+
<span class="did">{consentData.did}</span>
188
+
</div>
189
+
</div>
187
190
188
-
<div class="scopes-section">
189
-
<h2>{$_('oauth.consent.permissionsRequested')}</h2>
190
-
{#each Object.entries(scopeGroups) as [category, scopes]}
191
-
<div class="scope-group">
192
-
<h3 class="category-title">{category}</h3>
193
-
{#each scopes as scope}
194
-
<label class="scope-item" class:required={scope.required}>
195
-
<input
196
-
type="checkbox"
197
-
checked={scopeSelections[scope.scope]}
198
-
disabled={scope.required || submitting}
199
-
onchange={() => handleScopeToggle(scope.scope)}
200
-
/>
201
-
<div class="scope-info">
202
-
<span class="scope-name">{scope.display_name}</span>
203
-
<span class="scope-description">{scope.description}</span>
204
-
{#if scope.required}
205
-
<span class="required-badge">{$_('oauth.consent.required')}</span>
206
-
{/if}
207
-
</div>
208
-
</label>
191
+
<div class="permissions-panel">
192
+
<div class="scopes-section">
193
+
<h2>{$_('oauth.consent.permissionsRequested')}</h2>
194
+
{#each Object.entries(scopeGroups) as [category, scopes]}
195
+
<div class="scope-group">
196
+
<h3 class="category-title">{category}</h3>
197
+
{#each scopes as scope}
198
+
<label class="scope-item" class:required={scope.required}>
199
+
<input
200
+
type="checkbox"
201
+
checked={scopeSelections[scope.scope]}
202
+
disabled={scope.required || submitting}
203
+
onchange={() => handleScopeToggle(scope.scope)}
204
+
/>
205
+
<div class="scope-info">
206
+
<span class="scope-name">{scope.display_name}</span>
207
+
<span class="scope-description">{scope.description}</span>
208
+
{#if scope.required}
209
+
<span class="required-badge">{$_('oauth.consent.required')}</span>
210
+
{/if}
211
+
</div>
212
+
</label>
213
+
{/each}
214
+
</div>
209
215
{/each}
210
216
</div>
211
-
{/each}
212
-
</div>
213
217
214
-
<label class="remember-choice">
215
-
<input type="checkbox" bind:checked={rememberChoice} disabled={submitting} />
216
-
<span>{$_('oauth.consent.rememberChoiceLabel')}</span>
217
-
</label>
218
+
<label class="remember-choice">
219
+
<input type="checkbox" bind:checked={rememberChoice} disabled={submitting} />
220
+
<span>{$_('oauth.consent.rememberChoiceLabel')}</span>
221
+
</label>
222
+
</div>
223
+
</div>
218
224
219
225
<div class="actions">
220
226
<button type="button" class="deny-btn" onclick={handleDeny} disabled={submitting}>
···
229
235
230
236
<style>
231
237
.consent-container {
232
-
max-width: 480px;
238
+
max-width: var(--width-lg);
233
239
margin: var(--space-7) auto;
234
240
padding: var(--space-7);
235
241
}
···
244
250
245
251
.error-container {
246
252
text-align: center;
253
+
max-width: var(--width-sm);
254
+
margin: 0 auto;
247
255
}
248
256
249
257
.error {
···
255
263
margin-bottom: var(--space-4);
256
264
}
257
265
266
+
.client-panel {
267
+
display: flex;
268
+
flex-direction: column;
269
+
gap: var(--space-5);
270
+
}
271
+
272
+
.permissions-panel {
273
+
min-width: 0;
274
+
}
275
+
258
276
.client-info {
259
277
text-align: center;
260
-
margin-bottom: var(--space-6);
278
+
padding: var(--space-6);
279
+
background: var(--bg-secondary);
280
+
border-radius: var(--radius-xl);
281
+
}
282
+
283
+
@media (min-width: 800px) {
284
+
.client-info {
285
+
text-align: left;
286
+
}
261
287
}
262
288
263
289
.client-logo {
···
397
423
display: flex;
398
424
align-items: center;
399
425
gap: var(--space-2);
400
-
margin-bottom: var(--space-6);
426
+
margin-top: var(--space-5);
401
427
cursor: pointer;
402
428
color: var(--text-secondary);
403
429
font-size: var(--text-sm);
···
411
437
.actions {
412
438
display: flex;
413
439
gap: var(--space-4);
440
+
margin-top: var(--space-6);
441
+
}
442
+
443
+
@media (min-width: 800px) {
444
+
.actions {
445
+
max-width: 400px;
446
+
margin-left: auto;
447
+
}
414
448
}
415
449
416
450
.actions button {
+187
-80
frontend/src/routes/OAuthLogin.svelte
+187
-80
frontend/src/routes/OAuthLogin.svelte
···
315
315
</script>
316
316
317
317
<div class="oauth-login-container">
318
-
<h1>{$_('oauth.login.title')}</h1>
319
-
<p class="subtitle">
320
-
{#if clientName}
321
-
{$_('oauth.login.subtitle')} <strong>{clientName}</strong>
322
-
{:else}
323
-
{$_('oauth.login.subtitle')}
324
-
{/if}
325
-
</p>
318
+
<header class="page-header">
319
+
<h1>{$_('oauth.login.title')}</h1>
320
+
<p class="subtitle">
321
+
{#if clientName}
322
+
{$_('oauth.login.subtitle')} <strong>{clientName}</strong>
323
+
{:else}
324
+
{$_('oauth.login.subtitle')}
325
+
{/if}
326
+
</p>
327
+
</header>
326
328
327
329
{#if error}
328
330
<div class="error">{error}</div>
···
343
345
</div>
344
346
345
347
{#if passkeySupported && username.length >= 3}
346
-
<button
347
-
type="button"
348
-
class="passkey-btn"
349
-
class:passkey-unavailable={!hasPasskeys || checkingSecurityStatus || !securityStatusChecked}
350
-
onclick={handlePasskeyLogin}
351
-
disabled={submitting || !hasPasskeys || !username || checkingSecurityStatus || !securityStatusChecked}
352
-
title={checkingSecurityStatus ? $_('oauth.login.passkeyHintChecking') : hasPasskeys ? $_('oauth.login.passkeyHintAvailable') : $_('oauth.login.passkeyHintNotAvailable')}
353
-
>
354
-
<svg class="passkey-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
355
-
<path d="M15 7a4 4 0 1 0-8 0 4 4 0 0 0 8 0z" />
356
-
<path d="M17 17v4l3-2-3-2z" />
357
-
<path d="M12 11c-4 0-6 2-6 4v4h9" />
358
-
</svg>
359
-
<span class="passkey-text">
360
-
{#if submitting}
361
-
{$_('oauth.login.authenticating')}
362
-
{:else if checkingSecurityStatus || !securityStatusChecked}
363
-
{$_('oauth.login.checkingPasskey')}
364
-
{:else if hasPasskeys}
365
-
{$_('oauth.login.signInWithPasskey')}
366
-
{:else}
367
-
{$_('oauth.login.passkeyNotSetUp')}
368
-
{/if}
369
-
</span>
370
-
</button>
348
+
<div class="auth-methods">
349
+
<div class="passkey-method">
350
+
<h3>{$_('oauth.login.signInWithPasskey')}</h3>
351
+
<button
352
+
type="button"
353
+
class="passkey-btn"
354
+
class:passkey-unavailable={!hasPasskeys || checkingSecurityStatus || !securityStatusChecked}
355
+
onclick={handlePasskeyLogin}
356
+
disabled={submitting || !hasPasskeys || !username || checkingSecurityStatus || !securityStatusChecked}
357
+
title={checkingSecurityStatus ? $_('oauth.login.passkeyHintChecking') : hasPasskeys ? $_('oauth.login.passkeyHintAvailable') : $_('oauth.login.passkeyHintNotAvailable')}
358
+
>
359
+
<svg class="passkey-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
360
+
<path d="M15 7a4 4 0 1 0-8 0 4 4 0 0 0 8 0z" />
361
+
<path d="M17 17v4l3-2-3-2z" />
362
+
<path d="M12 11c-4 0-6 2-6 4v4h9" />
363
+
</svg>
364
+
<span class="passkey-text">
365
+
{#if submitting}
366
+
{$_('oauth.login.authenticating')}
367
+
{:else if checkingSecurityStatus || !securityStatusChecked}
368
+
{$_('oauth.login.checkingPasskey')}
369
+
{:else if hasPasskeys}
370
+
{$_('oauth.login.usePasskey')}
371
+
{:else}
372
+
{$_('oauth.login.passkeyNotSetUp')}
373
+
{/if}
374
+
</span>
375
+
</button>
376
+
<p class="method-hint">{$_('oauth.login.passkeyHint')}</p>
377
+
</div>
371
378
372
-
<div class="auth-divider">
373
-
<span>{$_('oauth.login.orUsePassword')}</span>
379
+
<div class="method-divider">
380
+
<span>{$_('oauth.login.orUsePassword')}</span>
381
+
</div>
382
+
383
+
<div class="password-method">
384
+
<h3>{$_('oauth.login.password')}</h3>
385
+
<div class="field">
386
+
<input
387
+
id="password"
388
+
type="password"
389
+
bind:value={password}
390
+
disabled={submitting}
391
+
required
392
+
autocomplete="current-password"
393
+
placeholder={$_('oauth.login.passwordPlaceholder')}
394
+
/>
395
+
</div>
396
+
397
+
<label class="remember-device">
398
+
<input type="checkbox" bind:checked={rememberDevice} disabled={submitting} />
399
+
<span>{$_('oauth.login.rememberDevice')}</span>
400
+
</label>
401
+
402
+
<button type="submit" class="submit-btn" disabled={submitting || !username || !password}>
403
+
{submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')}
404
+
</button>
405
+
</div>
374
406
</div>
375
-
{/if}
376
407
377
-
<div class="field">
378
-
<label for="password">{$_('oauth.login.password')}</label>
379
-
<input
380
-
id="password"
381
-
type="password"
382
-
bind:value={password}
383
-
disabled={submitting}
384
-
required
385
-
autocomplete="current-password"
386
-
/>
387
-
</div>
408
+
<div class="actions">
409
+
<button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}>
410
+
{$_('common.cancel')}
411
+
</button>
412
+
</div>
413
+
{:else}
414
+
<div class="field">
415
+
<label for="password">{$_('oauth.login.password')}</label>
416
+
<input
417
+
id="password"
418
+
type="password"
419
+
bind:value={password}
420
+
disabled={submitting}
421
+
required
422
+
autocomplete="current-password"
423
+
/>
424
+
</div>
388
425
389
-
<label class="remember-device">
390
-
<input type="checkbox" bind:checked={rememberDevice} disabled={submitting} />
391
-
<span>{$_('oauth.login.rememberDevice')}</span>
392
-
</label>
426
+
<label class="remember-device">
427
+
<input type="checkbox" bind:checked={rememberDevice} disabled={submitting} />
428
+
<span>{$_('oauth.login.rememberDevice')}</span>
429
+
</label>
393
430
394
-
<div class="actions">
395
-
<button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}>
396
-
{$_('common.cancel')}
397
-
</button>
398
-
<button type="submit" class="submit-btn" disabled={submitting || !username || !password}>
399
-
{submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')}
400
-
</button>
401
-
</div>
431
+
<div class="actions">
432
+
<button type="button" class="cancel-btn" onclick={handleCancel} disabled={submitting}>
433
+
{$_('common.cancel')}
434
+
</button>
435
+
<button type="submit" class="submit-btn" disabled={submitting || !username || !password}>
436
+
{submitting ? $_('oauth.login.signingIn') : $_('oauth.login.title')}
437
+
</button>
438
+
</div>
439
+
{/if}
402
440
</form>
403
441
404
442
<p class="help-links">
···
423
461
}
424
462
425
463
.oauth-login-container {
426
-
max-width: var(--width-sm);
464
+
max-width: var(--width-md);
427
465
margin: var(--space-9) auto;
428
466
padding: var(--space-7);
429
467
}
430
468
469
+
.page-header {
470
+
margin-bottom: var(--space-6);
471
+
}
472
+
431
473
h1 {
432
474
margin: 0 0 var(--space-2) 0;
433
475
}
434
476
435
477
.subtitle {
436
478
color: var(--text-secondary);
437
-
margin: 0 0 var(--space-7) 0;
479
+
margin: 0;
438
480
}
439
481
440
482
form {
···
443
485
gap: var(--space-4);
444
486
}
445
487
488
+
.auth-methods {
489
+
display: grid;
490
+
grid-template-columns: 1fr;
491
+
gap: var(--space-5);
492
+
margin-top: var(--space-4);
493
+
}
494
+
495
+
@media (min-width: 600px) {
496
+
.auth-methods {
497
+
grid-template-columns: 1fr auto 1fr;
498
+
align-items: start;
499
+
}
500
+
}
501
+
502
+
.passkey-method,
503
+
.password-method {
504
+
display: flex;
505
+
flex-direction: column;
506
+
gap: var(--space-4);
507
+
padding: var(--space-5);
508
+
background: var(--bg-secondary);
509
+
border-radius: var(--radius-xl);
510
+
}
511
+
512
+
.passkey-method h3,
513
+
.password-method h3 {
514
+
margin: 0;
515
+
font-size: var(--text-sm);
516
+
font-weight: var(--font-semibold);
517
+
color: var(--text-secondary);
518
+
text-transform: uppercase;
519
+
letter-spacing: 0.05em;
520
+
}
521
+
522
+
.method-hint {
523
+
margin: 0;
524
+
font-size: var(--text-xs);
525
+
color: var(--text-muted);
526
+
}
527
+
528
+
.method-divider {
529
+
display: flex;
530
+
align-items: center;
531
+
justify-content: center;
532
+
color: var(--text-muted);
533
+
font-size: var(--text-sm);
534
+
}
535
+
536
+
@media (min-width: 600px) {
537
+
.method-divider {
538
+
flex-direction: column;
539
+
padding: 0 var(--space-3);
540
+
}
541
+
542
+
.method-divider::before,
543
+
.method-divider::after {
544
+
content: '';
545
+
width: 1px;
546
+
height: var(--space-6);
547
+
background: var(--border-color);
548
+
}
549
+
550
+
.method-divider span {
551
+
writing-mode: vertical-rl;
552
+
text-orientation: mixed;
553
+
transform: rotate(180deg);
554
+
padding: var(--space-2) 0;
555
+
}
556
+
}
557
+
558
+
@media (max-width: 599px) {
559
+
.method-divider {
560
+
gap: var(--space-4);
561
+
}
562
+
563
+
.method-divider::before,
564
+
.method-divider::after {
565
+
content: '';
566
+
flex: 1;
567
+
height: 1px;
568
+
background: var(--border-color);
569
+
}
570
+
}
571
+
446
572
.field {
447
573
display: flex;
448
574
flex-direction: column;
···
534
660
background: var(--accent-hover);
535
661
}
536
662
537
-
.auth-divider {
538
-
display: flex;
539
-
align-items: center;
540
-
gap: var(--space-4);
541
-
margin: var(--space-2) 0;
542
-
}
543
-
544
-
.auth-divider::before,
545
-
.auth-divider::after {
546
-
content: '';
547
-
flex: 1;
548
-
height: 1px;
549
-
background: var(--border-color);
550
-
}
551
-
552
-
.auth-divider span {
553
-
color: var(--text-secondary);
554
-
font-size: var(--text-sm);
555
-
}
556
663
557
664
.passkey-btn {
558
665
display: flex;
+233
-215
frontend/src/routes/Register.svelte
+233
-215
frontend/src/routes/Register.svelte
···
142
142
if (!flow) return ''
143
143
switch (flow.state.step) {
144
144
case 'info': return $_('register.subtitle')
145
-
case 'key-choice': return 'Choose how to set up your external did:web identity.'
146
-
case 'initial-did-doc': return 'Upload your DID document to continue.'
145
+
case 'key-choice': return $_('register.subtitleKeyChoice')
146
+
case 'initial-did-doc': return $_('register.subtitleInitialDidDoc')
147
147
case 'creating': return $_('register.creating')
148
-
case 'verify': return `Verify your ${channelLabel(flow.info.verificationChannel)} to continue.`
149
-
case 'updated-did-doc': return 'Update your DID document with the PDS signing key.'
150
-
case 'activating': return 'Activating your account...'
151
-
case 'redirect-to-dashboard': return 'Your account has been created successfully!'
148
+
case 'verify': return $_('register.subtitleVerify', { values: { channel: channelLabel(flow.info.verificationChannel) } })
149
+
case 'updated-did-doc': return $_('register.subtitleUpdatedDidDoc')
150
+
case 'activating': return $_('register.subtitleActivating')
151
+
case 'redirect-to-dashboard': return $_('register.subtitleComplete')
152
152
default: return ''
153
153
}
154
154
}
155
155
</script>
156
156
157
157
<div class="register-page">
158
-
{#if flow?.state.step === 'info'}
158
+
<header class="page-header">
159
+
<h1>{$_('register.title')}</h1>
160
+
<p class="subtitle">{getSubtitle()}</p>
161
+
</header>
162
+
163
+
{#if flow?.state.error}
164
+
<div class="message error">{flow.state.error}</div>
165
+
{/if}
166
+
167
+
{#if loadingServerInfo || !flow}
168
+
<p class="loading">{$_('common.loading')}</p>
169
+
170
+
{:else if flow.state.step === 'info'}
159
171
<div class="migrate-callout">
160
172
<div class="migrate-icon">↗</div>
161
173
<div class="migrate-content">
···
166
178
</a>
167
179
</div>
168
180
</div>
169
-
{/if}
170
181
171
-
<h1>{$_('register.title')}</h1>
172
-
<p class="subtitle">{getSubtitle()}</p>
173
-
174
-
{#if flow?.state.error}
175
-
<div class="message error">{flow.state.error}</div>
176
-
{/if}
182
+
<div class="split-layout sidebar-right">
183
+
<div class="form-section">
184
+
<form onsubmit={handleInfoSubmit}>
185
+
<div class="field">
186
+
<label for="handle">{$_('register.handle')}</label>
187
+
<input
188
+
id="handle"
189
+
type="text"
190
+
bind:value={flow.info.handle}
191
+
placeholder={$_('register.handlePlaceholder')}
192
+
disabled={flow.state.submitting}
193
+
required
194
+
/>
195
+
{#if flow.info.handle.includes('.')}
196
+
<p class="hint warning">{$_('register.handleDotWarning')}</p>
197
+
{:else if fullHandle()}
198
+
<p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p>
199
+
{/if}
200
+
</div>
177
201
178
-
{#if loadingServerInfo || !flow}
179
-
<p class="loading">{$_('common.loading')}</p>
202
+
<div class="form-row">
203
+
<div class="field">
204
+
<label for="password">{$_('register.password')}</label>
205
+
<input
206
+
id="password"
207
+
type="password"
208
+
bind:value={flow.info.password}
209
+
placeholder={$_('register.passwordPlaceholder')}
210
+
disabled={flow.state.submitting}
211
+
required
212
+
minlength="8"
213
+
/>
214
+
</div>
180
215
181
-
{:else if flow.state.step === 'info'}
182
-
<form onsubmit={handleInfoSubmit}>
183
-
<div class="field">
184
-
<label for="handle">{$_('register.handle')}</label>
185
-
<input
186
-
id="handle"
187
-
type="text"
188
-
bind:value={flow.info.handle}
189
-
placeholder={$_('register.handlePlaceholder')}
190
-
disabled={flow.state.submitting}
191
-
required
192
-
/>
193
-
{#if flow.info.handle.includes('.')}
194
-
<p class="hint warning">{$_('register.handleDotWarning')}</p>
195
-
{:else if fullHandle()}
196
-
<p class="hint">{$_('register.handleHint', { values: { handle: fullHandle() } })}</p>
197
-
{/if}
198
-
</div>
216
+
<div class="field">
217
+
<label for="confirm-password">{$_('register.confirmPassword')}</label>
218
+
<input
219
+
id="confirm-password"
220
+
type="password"
221
+
bind:value={confirmPassword}
222
+
placeholder={$_('register.confirmPasswordPlaceholder')}
223
+
disabled={flow.state.submitting}
224
+
required
225
+
/>
226
+
</div>
227
+
</div>
199
228
200
-
<div class="field">
201
-
<label for="password">{$_('register.password')}</label>
202
-
<input
203
-
id="password"
204
-
type="password"
205
-
bind:value={flow.info.password}
206
-
placeholder={$_('register.passwordPlaceholder')}
207
-
disabled={flow.state.submitting}
208
-
required
209
-
minlength="8"
210
-
/>
211
-
</div>
229
+
<fieldset class="section-fieldset">
230
+
<legend>{$_('register.identityType')}</legend>
231
+
<div class="radio-group">
232
+
<label class="radio-label">
233
+
<input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} />
234
+
<span class="radio-content">
235
+
<strong>{$_('register.didPlc')}</strong> {$_('register.didPlcRecommended')}
236
+
<span class="radio-hint">{$_('register.didPlcHint')}</span>
237
+
</span>
238
+
</label>
212
239
213
-
<div class="field">
214
-
<label for="confirm-password">{$_('register.confirmPassword')}</label>
215
-
<input
216
-
id="confirm-password"
217
-
type="password"
218
-
bind:value={confirmPassword}
219
-
placeholder={$_('register.confirmPasswordPlaceholder')}
220
-
disabled={flow.state.submitting}
221
-
required
222
-
/>
223
-
</div>
240
+
<label class="radio-label">
241
+
<input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting} />
242
+
<span class="radio-content">
243
+
<strong>{$_('register.didWeb')}</strong>
244
+
<span class="radio-hint">{$_('register.didWebHint')}</span>
245
+
</span>
246
+
</label>
224
247
225
-
<fieldset class="section-fieldset">
226
-
<legend>{$_('register.identityType')}</legend>
227
-
<p class="section-hint">{$_('register.identityHint')}</p>
248
+
<label class="radio-label">
249
+
<input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} />
250
+
<span class="radio-content">
251
+
<strong>{$_('register.didWebBYOD')}</strong>
252
+
<span class="radio-hint">{$_('register.didWebBYODHint')}</span>
253
+
</span>
254
+
</label>
255
+
</div>
228
256
229
-
<div class="radio-group">
230
-
<label class="radio-label">
231
-
<input type="radio" name="didType" value="plc" bind:group={flow.info.didType} disabled={flow.state.submitting} />
232
-
<span class="radio-content">
233
-
<strong>{$_('register.didPlc')}</strong> {$_('register.didPlcRecommended')}
234
-
<span class="radio-hint">{$_('register.didPlcHint')}</span>
235
-
</span>
236
-
</label>
257
+
{#if flow.info.didType === 'web'}
258
+
<div class="warning-box">
259
+
<strong>{$_('register.didWebWarningTitle')}</strong>
260
+
<ul>
261
+
<li><strong>{$_('register.didWebWarning1')}</strong> {$_('register.didWebWarning1Detail', { values: { did: `did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}` } })}</li>
262
+
<li><strong>{$_('register.didWebWarning2')}</strong> {$_('register.didWebWarning2Detail')}</li>
263
+
<li><strong>{$_('register.didWebWarning3')}</strong> {$_('register.didWebWarning3Detail')}</li>
264
+
<li><strong>{$_('register.didWebWarning4')}</strong> {$_('register.didWebWarning4Detail')}</li>
265
+
</ul>
266
+
</div>
267
+
{/if}
237
268
238
-
<label class="radio-label">
239
-
<input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting} />
240
-
<span class="radio-content">
241
-
<strong>{$_('register.didWeb')}</strong>
242
-
<span class="radio-hint">{$_('register.didWebHint')}</span>
243
-
</span>
244
-
</label>
269
+
{#if flow.info.didType === 'web-external'}
270
+
<div class="field">
271
+
<label for="external-did">{$_('register.externalDid')}</label>
272
+
<input
273
+
id="external-did"
274
+
type="text"
275
+
bind:value={flow.info.externalDid}
276
+
placeholder={$_('register.externalDidPlaceholder')}
277
+
disabled={flow.state.submitting}
278
+
required
279
+
/>
280
+
<p class="hint">{$_('register.externalDidHint')}</p>
281
+
</div>
282
+
{/if}
283
+
</fieldset>
245
284
246
-
<label class="radio-label">
247
-
<input type="radio" name="didType" value="web-external" bind:group={flow.info.didType} disabled={flow.state.submitting} />
248
-
<span class="radio-content">
249
-
<strong>{$_('register.didWebBYOD')}</strong>
250
-
<span class="radio-hint">{$_('register.didWebBYODHint')}</span>
251
-
</span>
252
-
</label>
253
-
</div>
285
+
<fieldset class="section-fieldset">
286
+
<legend>{$_('register.contactMethod')}</legend>
287
+
<div class="contact-fields">
288
+
<div class="field">
289
+
<label for="verification-channel">{$_('register.verificationMethod')}</label>
290
+
<select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}>
291
+
<option value="email">{$_('register.email')}</option>
292
+
<option value="discord" disabled={!isChannelAvailable('discord')}>
293
+
{$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`}
294
+
</option>
295
+
<option value="telegram" disabled={!isChannelAvailable('telegram')}>
296
+
{$_('register.telegram')}{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`}
297
+
</option>
298
+
<option value="signal" disabled={!isChannelAvailable('signal')}>
299
+
{$_('register.signal')}{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`}
300
+
</option>
301
+
</select>
302
+
</div>
254
303
255
-
{#if flow.info.didType === 'web'}
256
-
<div class="warning-box">
257
-
<strong>{$_('register.didWebWarningTitle')}</strong>
258
-
<ul>
259
-
<li><strong>{$_('register.didWebWarning1')}</strong> {$_('register.didWebWarning1Detail', { values: { did: `did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}` } })}</li>
260
-
<li><strong>{$_('register.didWebWarning2')}</strong> {$_('register.didWebWarning2Detail')}</li>
261
-
<li><strong>{$_('register.didWebWarning3')}</strong> {$_('register.didWebWarning3Detail')}</li>
262
-
<li><strong>{$_('register.didWebWarning4')}</strong> {$_('register.didWebWarning4Detail')}</li>
263
-
</ul>
264
-
</div>
265
-
{/if}
304
+
{#if flow.info.verificationChannel === 'email'}
305
+
<div class="field">
306
+
<label for="email">{$_('register.emailAddress')}</label>
307
+
<input
308
+
id="email"
309
+
type="email"
310
+
bind:value={flow.info.email}
311
+
placeholder={$_('register.emailPlaceholder')}
312
+
disabled={flow.state.submitting}
313
+
required
314
+
/>
315
+
</div>
316
+
{:else if flow.info.verificationChannel === 'discord'}
317
+
<div class="field">
318
+
<label for="discord-id">{$_('register.discordId')}</label>
319
+
<input
320
+
id="discord-id"
321
+
type="text"
322
+
bind:value={flow.info.discordId}
323
+
placeholder={$_('register.discordIdPlaceholder')}
324
+
disabled={flow.state.submitting}
325
+
required
326
+
/>
327
+
<p class="hint">{$_('register.discordIdHint')}</p>
328
+
</div>
329
+
{:else if flow.info.verificationChannel === 'telegram'}
330
+
<div class="field">
331
+
<label for="telegram-username">{$_('register.telegramUsername')}</label>
332
+
<input
333
+
id="telegram-username"
334
+
type="text"
335
+
bind:value={flow.info.telegramUsername}
336
+
placeholder={$_('register.telegramUsernamePlaceholder')}
337
+
disabled={flow.state.submitting}
338
+
required
339
+
/>
340
+
</div>
341
+
{:else if flow.info.verificationChannel === 'signal'}
342
+
<div class="field">
343
+
<label for="signal-number">{$_('register.signalNumber')}</label>
344
+
<input
345
+
id="signal-number"
346
+
type="tel"
347
+
bind:value={flow.info.signalNumber}
348
+
placeholder={$_('register.signalNumberPlaceholder')}
349
+
disabled={flow.state.submitting}
350
+
required
351
+
/>
352
+
<p class="hint">{$_('register.signalNumberHint')}</p>
353
+
</div>
354
+
{/if}
355
+
</div>
356
+
</fieldset>
266
357
267
-
{#if flow.info.didType === 'web-external'}
268
-
<div class="field">
269
-
<label for="external-did">{$_('register.externalDid')}</label>
270
-
<input
271
-
id="external-did"
272
-
type="text"
273
-
bind:value={flow.info.externalDid}
274
-
placeholder={$_('register.externalDidPlaceholder')}
275
-
disabled={flow.state.submitting}
276
-
required
277
-
/>
278
-
<p class="hint">{$_('register.externalDidHint')}</p>
279
-
</div>
280
-
{/if}
281
-
</fieldset>
358
+
{#if serverInfo?.inviteCodeRequired}
359
+
<div class="field">
360
+
<label for="invite-code">{$_('register.inviteCode')} <span class="required">{$_('register.inviteCodeRequired')}</span></label>
361
+
<input
362
+
id="invite-code"
363
+
type="text"
364
+
bind:value={flow.info.inviteCode}
365
+
placeholder={$_('register.inviteCodePlaceholder')}
366
+
disabled={flow.state.submitting}
367
+
required
368
+
/>
369
+
</div>
370
+
{/if}
282
371
283
-
<fieldset class="section-fieldset">
284
-
<legend>{$_('register.contactMethod')}</legend>
285
-
<p class="section-hint">{$_('register.contactMethodHint')}</p>
372
+
<button type="submit" disabled={flow.state.submitting}>
373
+
{flow.state.submitting ? $_('register.creating') : $_('register.createButton')}
374
+
</button>
375
+
</form>
286
376
287
-
<div class="field">
288
-
<label for="verification-channel">{$_('register.verificationMethod')}</label>
289
-
<select id="verification-channel" bind:value={flow.info.verificationChannel} disabled={flow.state.submitting}>
290
-
<option value="email">{$_('register.email')}</option>
291
-
<option value="discord" disabled={!isChannelAvailable('discord')}>
292
-
{$_('register.discord')}{isChannelAvailable('discord') ? '' : ` (${$_('register.notConfigured')})`}
293
-
</option>
294
-
<option value="telegram" disabled={!isChannelAvailable('telegram')}>
295
-
{$_('register.telegram')}{isChannelAvailable('telegram') ? '' : ` (${$_('register.notConfigured')})`}
296
-
</option>
297
-
<option value="signal" disabled={!isChannelAvailable('signal')}>
298
-
{$_('register.signal')}{isChannelAvailable('signal') ? '' : ` (${$_('register.notConfigured')})`}
299
-
</option>
300
-
</select>
377
+
<div class="form-links">
378
+
<p class="link-text">
379
+
{$_('register.alreadyHaveAccount')} <a href="#/login">{$_('register.signIn')}</a>
380
+
</p>
381
+
<p class="link-text">
382
+
{$_('register.wantPasswordless')} <a href="#/register-passkey">{$_('register.createPasskeyAccount')}</a>
383
+
</p>
301
384
</div>
302
-
303
-
{#if flow.info.verificationChannel === 'email'}
304
-
<div class="field">
305
-
<label for="email">{$_('register.emailAddress')}</label>
306
-
<input
307
-
id="email"
308
-
type="email"
309
-
bind:value={flow.info.email}
310
-
placeholder={$_('register.emailPlaceholder')}
311
-
disabled={flow.state.submitting}
312
-
required
313
-
/>
314
-
</div>
315
-
{:else if flow.info.verificationChannel === 'discord'}
316
-
<div class="field">
317
-
<label for="discord-id">{$_('register.discordId')}</label>
318
-
<input
319
-
id="discord-id"
320
-
type="text"
321
-
bind:value={flow.info.discordId}
322
-
placeholder={$_('register.discordIdPlaceholder')}
323
-
disabled={flow.state.submitting}
324
-
required
325
-
/>
326
-
<p class="hint">{$_('register.discordIdHint')}</p>
327
-
</div>
328
-
{:else if flow.info.verificationChannel === 'telegram'}
329
-
<div class="field">
330
-
<label for="telegram-username">{$_('register.telegramUsername')}</label>
331
-
<input
332
-
id="telegram-username"
333
-
type="text"
334
-
bind:value={flow.info.telegramUsername}
335
-
placeholder={$_('register.telegramUsernamePlaceholder')}
336
-
disabled={flow.state.submitting}
337
-
required
338
-
/>
339
-
</div>
340
-
{:else if flow.info.verificationChannel === 'signal'}
341
-
<div class="field">
342
-
<label for="signal-number">{$_('register.signalNumber')}</label>
343
-
<input
344
-
id="signal-number"
345
-
type="tel"
346
-
bind:value={flow.info.signalNumber}
347
-
placeholder={$_('register.signalNumberPlaceholder')}
348
-
disabled={flow.state.submitting}
349
-
required
350
-
/>
351
-
<p class="hint">{$_('register.signalNumberHint')}</p>
352
-
</div>
353
-
{/if}
354
-
</fieldset>
385
+
</div>
355
386
356
-
{#if serverInfo?.inviteCodeRequired}
357
-
<div class="field">
358
-
<label for="invite-code">{$_('register.inviteCode')} <span class="required">{$_('register.inviteCodeRequired')}</span></label>
359
-
<input
360
-
id="invite-code"
361
-
type="text"
362
-
bind:value={flow.info.inviteCode}
363
-
placeholder={$_('register.inviteCodePlaceholder')}
364
-
disabled={flow.state.submitting}
365
-
required
366
-
/>
367
-
</div>
368
-
{/if}
387
+
<aside class="info-panel">
388
+
<h3>{$_('register.identityHint')}</h3>
389
+
<p>{$_('register.infoIdentityDesc')}</p>
369
390
370
-
<button type="submit" disabled={flow.state.submitting}>
371
-
{flow.state.submitting ? $_('register.creating') : $_('register.createButton')}
372
-
</button>
373
-
</form>
391
+
<h3>{$_('register.contactMethodHint')}</h3>
392
+
<p>{$_('register.infoContactDesc')}</p>
374
393
375
-
<p class="link-text">
376
-
{$_('register.alreadyHaveAccount')} <a href="#/login">{$_('register.signIn')}</a>
377
-
</p>
378
-
<p class="link-text">
379
-
{$_('register.wantPasswordless')} <a href="#/register-passkey">{$_('register.createPasskeyAccount')}</a>
380
-
</p>
394
+
<h3>{$_('register.infoNextTitle')}</h3>
395
+
<p>{$_('register.infoNextDesc')}</p>
396
+
</aside>
397
+
</div>
381
398
382
399
{:else if flow.state.step === 'key-choice'}
383
400
<KeyChoiceStep {flow} />
···
404
421
/>
405
422
406
423
{:else if flow.state.step === 'redirect-to-dashboard'}
407
-
<p class="loading">Redirecting to dashboard...</p>
424
+
<p class="loading">{$_('register.redirecting')}</p>
408
425
{/if}
409
426
</div>
410
427
411
428
<style>
412
429
.register-page {
413
-
max-width: var(--width-sm);
430
+
max-width: var(--width-lg);
414
431
margin: var(--space-9) auto;
415
432
padding: var(--space-7);
433
+
}
434
+
435
+
.page-header {
436
+
margin-bottom: var(--space-6);
437
+
}
438
+
439
+
.form-section {
440
+
min-width: 0;
441
+
}
442
+
443
+
.form-links {
444
+
margin-top: var(--space-6);
416
445
}
417
446
418
447
.migrate-callout {
···
481
510
482
511
.required {
483
512
color: var(--error-text);
484
-
}
485
-
486
-
.section-fieldset {
487
-
border: 1px solid var(--border-color);
488
-
border-radius: var(--radius-lg);
489
-
padding: var(--space-5);
490
-
}
491
-
492
-
.section-fieldset legend {
493
-
font-weight: var(--font-semibold);
494
-
padding: 0 var(--space-3);
495
513
}
496
514
497
515
.section-hint {
+1
-12
frontend/src/routes/RegisterPasskey.svelte
+1
-12
frontend/src/routes/RegisterPasskey.svelte
···
369
369
<div class="warning-box">
370
370
<strong>{$_('registerPasskey.didWebWarningTitle')}</strong>
371
371
<ul>
372
-
<li><strong>{$_('registerPasskey.didWebWarning1')}</strong> Your identity will be <code>did:web:yourhandle.{serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>.</li>
372
+
<li><strong>{$_('registerPasskey.didWebWarning1')}</strong> {@html $_('registerPasskey.didWebWarning1Detail', { values: { did: `<code>did:web:yourhandle.${serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>` } })}</li>
373
373
<li><strong>{$_('registerPasskey.didWebWarning2')}</strong> {$_('registerPasskey.didWebWarning2Detail')}</li>
374
374
<li><strong>{$_('registerPasskey.didWebWarning3')}</strong> {$_('registerPasskey.didWebWarning3Detail')}</li>
375
375
<li><strong>{$_('registerPasskey.didWebWarning4')}</strong> {$_('registerPasskey.didWebWarning4Detail')}</li>
···
542
542
543
543
.required {
544
544
color: var(--error-text);
545
-
}
546
-
547
-
.section-fieldset {
548
-
border: 1px solid var(--border-color);
549
-
border-radius: var(--radius-lg);
550
-
padding: var(--space-5);
551
-
}
552
-
553
-
.section-fieldset legend {
554
-
font-weight: var(--font-semibold);
555
-
padding: 0 var(--space-3);
556
545
}
557
546
558
547
.section-hint {
+59
-18
frontend/src/routes/RepoExplorer.svelte
+59
-18
frontend/src/routes/RepoExplorer.svelte
···
75
75
}
76
76
}
77
77
async function loadMoreRecords() {
78
-
if (!auth.session || !selectedCollection || !recordsCursor) return
78
+
if (!auth.session || !selectedCollection || !recordsCursor || loadingMore) return
79
79
loadingMore = true
80
80
try {
81
81
const result = await api.listRecords(auth.session.accessJwt, auth.session.did, selectedCollection, {
···
93
93
loadingMore = false
94
94
}
95
95
}
96
+
97
+
$effect(() => {
98
+
if (view === 'records' && recordsCursor && !loadingMore && !loading) {
99
+
loadMoreRecords()
100
+
}
101
+
})
96
102
async function selectRecord(record: { uri: string; cid: string; value: unknown; rkey: string }) {
97
103
selectedRecord = record
98
104
recordJson = JSON.stringify(record.value, null, 2)
···
371
377
</li>
372
378
{/each}
373
379
</ul>
374
-
{#if recordsCursor}
375
-
<div class="load-more">
376
-
<button onclick={loadMoreRecords} disabled={loadingMore}>
377
-
{loadingMore ? $_('common.loading') : $_('repoExplorer.loadMore')}
378
-
</button>
380
+
{#if loadingMore}
381
+
<div class="skeleton-records">
382
+
{#each [1, 2, 3] as _}
383
+
<div class="skeleton-record">
384
+
<div class="skeleton-record-header">
385
+
<div class="skeleton-line short"></div>
386
+
<div class="skeleton-line tiny"></div>
387
+
</div>
388
+
<div class="skeleton-preview"></div>
389
+
</div>
390
+
{/each}
379
391
</div>
380
392
{/if}
381
393
{/if}
···
383
395
<div class="record-detail">
384
396
<div class="record-meta">
385
397
<dl>
386
-
<dt>URI</dt>
398
+
<dt>{$_('repoExplorer.uri')}</dt>
387
399
<dd class="mono">{selectedRecord.uri}</dd>
388
-
<dt>CID</dt>
400
+
<dt>{$_('repoExplorer.cid')}</dt>
389
401
<dd class="mono">{selectedRecord.cid}</dd>
390
402
</dl>
391
403
</div>
···
463
475
</div>
464
476
<style>
465
477
.page {
466
-
max-width: var(--width-lg);
478
+
max-width: var(--width-xl);
467
479
margin: 0 auto;
468
480
padding: var(--space-7);
469
481
}
···
751
763
overflow: hidden;
752
764
}
753
765
754
-
.load-more {
755
-
text-align: center;
766
+
.skeleton-records {
767
+
display: flex;
768
+
flex-direction: column;
769
+
gap: var(--space-2);
770
+
margin-top: var(--space-2);
771
+
}
772
+
773
+
.skeleton-record {
756
774
padding: var(--space-4);
775
+
background: var(--bg-card);
776
+
border: 1px solid var(--border-color);
777
+
border-radius: var(--radius-md);
778
+
}
779
+
780
+
.skeleton-record-header {
781
+
display: flex;
782
+
justify-content: space-between;
783
+
margin-bottom: var(--space-2);
757
784
}
758
785
759
-
.load-more button {
760
-
padding: var(--space-2) var(--space-7);
786
+
.skeleton-line {
787
+
height: 14px;
788
+
background: var(--bg-tertiary);
789
+
border-radius: var(--radius-sm);
790
+
animation: skeleton-pulse 1.5s ease-in-out infinite;
791
+
}
792
+
793
+
.skeleton-line.short {
794
+
width: 120px;
795
+
}
796
+
797
+
.skeleton-line.tiny {
798
+
width: 80px;
799
+
}
800
+
801
+
.skeleton-preview {
802
+
height: 60px;
761
803
background: var(--bg-secondary);
762
-
border: 1px solid var(--border-color);
763
804
border-radius: var(--radius-md);
764
-
cursor: pointer;
765
-
color: var(--text-primary);
805
+
animation: skeleton-pulse 1.5s ease-in-out infinite;
766
806
}
767
807
768
-
.load-more button:hover:not(:disabled) {
769
-
background: var(--bg-card);
808
+
@keyframes skeleton-pulse {
809
+
0%, 100% { opacity: 1; }
810
+
50% { opacity: 0.4; }
770
811
}
771
812
772
813
.record-detail {
+25
-2
frontend/src/routes/Security.svelte
+25
-2
frontend/src/routes/Security.svelte
···
130
130
try {
131
131
const token = await getValidToken()
132
132
if (!token) {
133
-
showMessage('error', 'Session expired. Please log in again.')
133
+
showMessage('error', $_('security.sessionExpired'))
134
134
return
135
135
}
136
136
await api.removePassword(token)
···
414
414
{#if loading}
415
415
<div class="loading">{$_('common.loading')}</div>
416
416
{:else}
417
+
<div class="sections-grid">
417
418
<section>
418
419
<h2>{$_('security.totp')}</h2>
419
420
<p class="description">
···
725
726
{$_('security.manageTrustedDevices')} →
726
727
</a>
727
728
</section>
729
+
</div>
728
730
729
731
{#if hasMfa}
730
732
<section>
···
788
790
789
791
<style>
790
792
.page {
791
-
max-width: var(--width-md);
793
+
max-width: var(--width-lg);
792
794
margin: 0 auto;
793
795
padding: var(--space-7);
794
796
}
···
797
799
margin-bottom: var(--space-7);
798
800
}
799
801
802
+
.sections-grid {
803
+
display: flex;
804
+
flex-direction: column;
805
+
gap: var(--space-6);
806
+
margin-bottom: var(--space-6);
807
+
}
808
+
809
+
@media (min-width: 800px) {
810
+
.sections-grid {
811
+
columns: 2;
812
+
column-gap: var(--space-6);
813
+
display: block;
814
+
}
815
+
816
+
.sections-grid section {
817
+
break-inside: avoid;
818
+
margin-bottom: var(--space-6);
819
+
}
820
+
}
821
+
800
822
.back {
801
823
color: var(--text-secondary);
802
824
text-decoration: none;
···
822
844
background: var(--bg-secondary);
823
845
border-radius: var(--radius-xl);
824
846
margin-bottom: var(--space-6);
847
+
height: fit-content;
825
848
}
826
849
827
850
section h2 {
+1
-1
frontend/src/routes/Sessions.svelte
+1
-1
frontend/src/routes/Sessions.svelte
+45
-4
frontend/src/routes/Settings.svelte
+45
-4
frontend/src/routes/Settings.svelte
···
1
1
<script lang="ts">
2
+
import { onMount } from 'svelte'
2
3
import { getAuthState, logout, refreshSession } from '../lib/auth.svelte'
3
4
import { navigate } from '../lib/router.svelte'
4
5
import { api, ApiError } from '../lib/api'
5
6
import { locale, setLocale, getSupportedLocales, localeNames, _, type SupportedLocale } from '../lib/i18n'
6
7
const auth = getAuthState()
7
8
const supportedLocales = getSupportedLocales()
9
+
let pdsHostname = $state<string | null>(null)
10
+
11
+
onMount(() => {
12
+
api.describeServer().then(info => {
13
+
if (info.availableUserDomains?.length) {
14
+
pdsHostname = info.availableUserDomains[0]
15
+
}
16
+
}).catch(() => {})
17
+
})
8
18
let localeLoading = $state(false)
9
19
async function handleLocaleChange(newLocale: SupportedLocale) {
10
20
if (!auth.session) return
···
94
104
try {
95
105
const fullHandle = showBYOHandle
96
106
? newHandle
97
-
: `${newHandle}.${window.location.hostname}`
107
+
: `${newHandle}.${pdsHostname}`
98
108
await api.updateHandle(auth.session.accessJwt, fullHandle)
99
109
await refreshSession()
100
110
showMessage('success', $_('settings.messages.handleUpdated'))
···
201
211
{#if message}
202
212
<div class="message {message.type}">{message.text}</div>
203
213
{/if}
214
+
<div class="sections-grid">
204
215
<section>
205
216
<h2>{$_('settings.language')}</h2>
206
217
<p class="description">{$_('settings.languageDescription')}</p>
···
335
346
disabled={handleLoading}
336
347
required
337
348
/>
338
-
<span class="handle-suffix">.{window.location.hostname}</span>
349
+
<span class="handle-suffix">.{pdsHostname ?? '...'}</span>
339
350
</div>
340
351
</div>
341
-
<button type="submit" disabled={handleLoading || !newHandle}>
352
+
<button type="submit" disabled={handleLoading || !newHandle || !pdsHostname}>
342
353
{handleLoading ? $_('settings.updating') : $_('settings.changeHandleButton')}
343
354
</button>
344
355
</form>
···
393
404
{exportLoading ? $_('settings.exporting') : $_('settings.downloadRepo')}
394
405
</button>
395
406
</section>
407
+
</div>
396
408
<section class="danger-zone">
397
409
<h2>{$_('settings.deleteAccount')}</h2>
398
410
<p class="warning">{$_('settings.deleteWarning')}</p>
···
438
450
</div>
439
451
<style>
440
452
.page {
441
-
max-width: var(--width-md);
453
+
max-width: var(--width-lg);
442
454
margin: 0 auto;
443
455
padding: var(--space-7);
444
456
}
···
447
459
margin-bottom: var(--space-7);
448
460
}
449
461
462
+
.sections-grid {
463
+
display: flex;
464
+
flex-direction: column;
465
+
gap: var(--space-6);
466
+
}
467
+
468
+
@media (min-width: 800px) {
469
+
.sections-grid {
470
+
columns: 2;
471
+
column-gap: var(--space-6);
472
+
display: block;
473
+
}
474
+
475
+
.sections-grid section {
476
+
break-inside: avoid;
477
+
margin-bottom: var(--space-6);
478
+
}
479
+
}
480
+
450
481
.back {
451
482
color: var(--text-secondary);
452
483
text-decoration: none;
···
466
497
background: var(--bg-secondary);
467
498
border-radius: var(--radius-xl);
468
499
margin-bottom: var(--space-6);
500
+
height: fit-content;
501
+
}
502
+
503
+
.danger-zone {
504
+
margin-top: var(--space-6);
469
505
}
470
506
471
507
section h2 {
···
482
518
483
519
.language-select {
484
520
width: 100%;
521
+
}
522
+
523
+
form > button,
524
+
form > .actions {
525
+
margin-top: var(--space-4);
485
526
}
486
527
487
528
.actions {
+3
-3
frontend/src/routes/TrustedDevices.svelte
+3
-3
frontend/src/routes/TrustedDevices.svelte
···
40
40
const result = await api.listTrustedDevices(auth.session.accessJwt)
41
41
devices = result.devices
42
42
} catch {
43
-
showMessage('error', 'Failed to load trusted devices')
43
+
showMessage('error', $_('trustedDevices.failedToLoad'))
44
44
} finally {
45
45
loading = false
46
46
}
···
199
199
200
200
<style>
201
201
.page {
202
-
max-width: var(--width-md);
202
+
max-width: var(--width-lg);
203
203
margin: 0 auto;
204
-
padding: var(--space-7) var(--space-4);
204
+
padding: var(--space-7);
205
205
}
206
206
207
207
header {
+131
-27
frontend/src/styles/base.css
+131
-27
frontend/src/styles/base.css
···
1
-
@import './tokens.css';
1
+
@import "./tokens.css";
2
2
3
3
@property --accent {
4
-
syntax: '<color>';
4
+
syntax: "<color>";
5
5
inherits: true;
6
-
initial-value: #2c00ff;
6
+
initial-value: #1a1d1d;
7
7
}
8
8
9
9
@property --secondary {
10
-
syntax: '<color>';
10
+
syntax: "<color>";
11
11
inherits: true;
12
-
initial-value: #ff2400;
12
+
initial-value: #1a1d1d;
13
13
}
14
14
15
15
*,
···
20
20
21
21
body {
22
22
margin: 0;
23
-
font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, Monaco, monospace;
23
+
font-family:
24
+
"Space Grotesk", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
24
25
font-size: var(--text-base);
25
26
line-height: var(--leading-normal);
26
27
color: var(--text-primary);
···
35
36
line-height: var(--leading-tight);
36
37
}
37
38
38
-
h1 { font-size: var(--text-2xl); }
39
-
h2 { font-size: var(--text-xl); }
40
-
h3 { font-size: var(--text-lg); }
41
-
h4 { font-size: var(--text-base); }
39
+
h1 {
40
+
font-size: var(--text-2xl);
41
+
}
42
+
h2 {
43
+
font-size: var(--text-xl);
44
+
}
45
+
h3 {
46
+
font-size: var(--text-lg);
47
+
}
48
+
h4 {
49
+
font-size: var(--text-base);
50
+
}
42
51
43
52
p {
44
53
margin: 0;
···
70
79
border-radius: var(--radius-md);
71
80
background: var(--bg-input);
72
81
color: var(--text-primary);
73
-
transition: border-color var(--transition-normal), box-shadow var(--transition-normal);
82
+
transition:
83
+
border-color var(--transition-normal),
84
+
box-shadow var(--transition-normal);
74
85
width: 100%;
75
86
}
76
87
···
113
124
border: none;
114
125
border-radius: var(--radius-md);
115
126
cursor: pointer;
116
-
transition: background var(--transition-normal), border-color var(--transition-normal), opacity var(--transition-normal);
127
+
transition:
128
+
background var(--transition-normal),
129
+
border-color var(--transition-normal),
130
+
opacity var(--transition-normal);
117
131
background: var(--accent);
118
132
color: var(--text-inverse);
119
133
}
···
177
191
}
178
192
179
193
fieldset {
180
-
border: 1px solid var(--border-dark);
194
+
border: none;
195
+
border-left: 3px solid var(--accent);
181
196
border-radius: var(--radius-lg);
182
197
padding: var(--space-5);
198
+
padding-left: var(--space-6);
183
199
margin: 0;
200
+
background: var(--bg-secondary);
184
201
}
185
202
186
203
fieldset legend {
204
+
font-size: var(--text-xs);
187
205
font-weight: var(--font-semibold);
188
-
padding: 0 var(--space-3);
189
-
color: var(--text-primary);
206
+
text-transform: uppercase;
207
+
letter-spacing: 0.05em;
208
+
padding: 0;
209
+
margin-left: calc(-1 * var(--space-1));
210
+
margin-bottom: var(--space-3);
211
+
color: var(--text-secondary);
212
+
float: left;
213
+
width: 100%;
214
+
}
215
+
216
+
fieldset legend + * {
217
+
clear: both;
190
218
}
191
219
192
220
code {
193
-
font-family: inherit;
221
+
font-family: ui-monospace, "SF Mono", Menlo, Monaco, monospace;
194
222
font-size: 0.9em;
195
223
background: var(--bg-tertiary);
196
224
padding: var(--space-1) var(--space-2);
···
198
226
}
199
227
200
228
pre {
201
-
font-family: inherit;
229
+
font-family: ui-monospace, "SF Mono", Menlo, Monaco, monospace;
202
230
font-size: var(--text-sm);
203
231
background: var(--bg-tertiary);
204
232
padding: var(--space-4);
···
221
249
222
250
.field + .field {
223
251
margin-top: var(--space-5);
252
+
}
253
+
254
+
.form-row .field + .field {
255
+
margin-top: 0;
224
256
}
225
257
226
258
.hint {
···
307
339
}
308
340
309
341
.page {
310
-
max-width: var(--width-md);
342
+
max-width: var(--width-lg);
311
343
margin: 0 auto;
312
344
padding: var(--space-7);
313
345
}
314
346
315
347
.page-sm {
316
-
max-width: var(--width-sm);
348
+
max-width: var(--width-md);
317
349
margin: 0 auto;
318
350
padding: var(--space-7);
319
351
}
320
352
321
353
.page-lg {
322
-
max-width: var(--width-lg);
354
+
max-width: var(--width-xl);
323
355
margin: 0 auto;
324
356
padding: var(--space-7);
325
357
}
···
357
389
}
358
390
359
391
.mono {
360
-
font-family: inherit;
392
+
font-family: ui-monospace, "SF Mono", Menlo, Monaco, monospace;
361
393
}
362
394
363
-
.mt-4 { margin-top: var(--space-4); }
364
-
.mt-5 { margin-top: var(--space-5); }
365
-
.mt-6 { margin-top: var(--space-6); }
366
-
.mb-4 { margin-bottom: var(--space-4); }
367
-
.mb-5 { margin-bottom: var(--space-5); }
368
-
.mb-6 { margin-bottom: var(--space-6); }
395
+
.mt-4 {
396
+
margin-top: var(--space-4);
397
+
}
398
+
.mt-5 {
399
+
margin-top: var(--space-5);
400
+
}
401
+
.mt-6 {
402
+
margin-top: var(--space-6);
403
+
}
404
+
.mb-4 {
405
+
margin-bottom: var(--space-4);
406
+
}
407
+
.mb-5 {
408
+
margin-bottom: var(--space-5);
409
+
}
410
+
.mb-6 {
411
+
margin-bottom: var(--space-6);
412
+
}
413
+
414
+
.split-layout {
415
+
display: grid;
416
+
grid-template-columns: 1fr;
417
+
gap: var(--space-6);
418
+
}
419
+
420
+
@media (min-width: 800px) {
421
+
.split-layout {
422
+
grid-template-columns: 1fr 1fr;
423
+
}
424
+
.split-layout.sidebar-right {
425
+
grid-template-columns: 1.5fr 1fr;
426
+
}
427
+
.split-layout.sidebar-left {
428
+
grid-template-columns: 1fr 1.5fr;
429
+
}
430
+
}
431
+
432
+
.form-row {
433
+
display: grid;
434
+
grid-template-columns: 1fr;
435
+
gap: var(--space-4);
436
+
}
437
+
438
+
@media (min-width: 600px) {
439
+
.form-row {
440
+
grid-template-columns: repeat(2, 1fr);
441
+
}
442
+
.form-row.thirds {
443
+
grid-template-columns: repeat(3, 1fr);
444
+
}
445
+
}
446
+
447
+
.full-width {
448
+
grid-column: 1 / -1;
449
+
}
450
+
451
+
.info-panel {
452
+
background: var(--bg-secondary);
453
+
border-radius: var(--radius-xl);
454
+
padding: var(--space-6);
455
+
height: fit-content;
456
+
}
457
+
458
+
.info-panel h3 {
459
+
margin: 0 0 var(--space-3) 0;
460
+
font-size: var(--text-base);
461
+
font-weight: var(--font-semibold);
462
+
}
463
+
464
+
.info-panel p {
465
+
margin: 0 0 var(--space-4) 0;
466
+
font-size: var(--text-sm);
467
+
color: var(--text-secondary);
468
+
}
469
+
470
+
.info-panel p:last-child {
471
+
margin-bottom: 0;
472
+
}
+51
-51
frontend/src/styles/tokens.css
+51
-51
frontend/src/styles/tokens.css
···
33
33
--radius-lg: 6px;
34
34
--radius-xl: 8px;
35
35
36
-
--width-xs: 320px;
37
-
--width-sm: 400px;
38
-
--width-md: 600px;
39
-
--width-lg: 800px;
40
-
--width-xl: 1000px;
36
+
--width-xs: 360px;
37
+
--width-sm: 480px;
38
+
--width-md: 760px;
39
+
--width-lg: 960px;
40
+
--width-xl: 1100px;
41
41
42
42
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
43
43
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.1);
···
48
48
--transition-normal: 0.15s ease;
49
49
--transition-slow: 0.25s ease;
50
50
51
-
--bg-primary: #ffffff;
52
-
--bg-secondary: #f8f8fa;
53
-
--bg-tertiary: #f0f0f2;
51
+
--bg-primary: #f9fafa;
52
+
--bg-secondary: #f1f3f3;
53
+
--bg-tertiary: #e8ebeb;
54
54
--bg-card: #ffffff;
55
55
--bg-input: #ffffff;
56
-
--bg-input-disabled: #f8f8fa;
56
+
--bg-input-disabled: #f1f3f3;
57
57
58
-
--text-primary: #1a1a1a;
59
-
--text-secondary: #666666;
60
-
--text-muted: #999999;
58
+
--text-primary: #1a1d1d;
59
+
--text-secondary: #5a605f;
60
+
--text-muted: #8a8f8e;
61
61
--text-inverse: #ffffff;
62
62
63
-
--border-color: #e5e5e5;
64
-
--border-light: #f0f0f0;
65
-
--border-dark: #cccccc;
63
+
--border-color: #dce0df;
64
+
--border-light: #e8ebeb;
65
+
--border-dark: #c8cecc;
66
66
67
-
--accent: #2c00ff;
68
-
--accent-hover: #1a00a3;
69
-
--accent-muted: rgba(44, 0, 255, 0.08);
70
-
--accent-light: #4d33ff;
67
+
--accent: #1a1d1d;
68
+
--accent-hover: #2e3332;
69
+
--accent-muted: rgba(26, 29, 29, 0.06);
70
+
--accent-light: #3a403f;
71
71
72
-
--secondary: #ff2400;
73
-
--secondary-hover: #cc1d00;
74
-
--secondary-muted: rgba(255, 36, 0, 0.08);
72
+
--secondary: #1a1d1d;
73
+
--secondary-hover: #2e3332;
74
+
--secondary-muted: rgba(26, 29, 29, 0.06);
75
75
76
76
--success-bg: #dfd;
77
77
--success-border: #8c8;
···
90
90
91
91
@media (prefers-color-scheme: dark) {
92
92
:root {
93
-
--bg-primary: #0a0a0a;
94
-
--bg-secondary: #141414;
95
-
--bg-tertiary: #1a1a1a;
96
-
--bg-card: #141414;
97
-
--bg-input: #1a1a1a;
98
-
--bg-input-disabled: #141414;
93
+
--bg-primary: #0a0c0c;
94
+
--bg-secondary: #131616;
95
+
--bg-tertiary: #1a1d1d;
96
+
--bg-card: #131616;
97
+
--bg-input: #1a1d1d;
98
+
--bg-input-disabled: #131616;
99
99
100
-
--text-primary: #e8e8e8;
101
-
--text-secondary: #a0a0a0;
102
-
--text-muted: #666666;
103
-
--text-inverse: #0a0a0a;
100
+
--text-primary: #e6e8e8;
101
+
--text-secondary: #9ca1a0;
102
+
--text-muted: #686d6c;
103
+
--text-inverse: #0a0c0c;
104
104
105
-
--border-color: #2a2a2a;
106
-
--border-light: #222222;
107
-
--border-dark: #333333;
105
+
--border-color: #282c2b;
106
+
--border-light: #1f2322;
107
+
--border-dark: #343938;
108
108
109
-
--accent: #7b6bff;
110
-
--accent-hover: #9588ff;
111
-
--accent-muted: rgba(123, 107, 255, 0.2);
112
-
--accent-light: #9588ff;
109
+
--accent: #e6e8e8;
110
+
--accent-hover: #ffffff;
111
+
--accent-muted: rgba(230, 232, 232, 0.1);
112
+
--accent-light: #ffffff;
113
113
114
-
--secondary: #ff6b5b;
115
-
--secondary-hover: #ff8577;
116
-
--secondary-muted: rgba(255, 107, 91, 0.2);
114
+
--secondary: #e6e8e8;
115
+
--secondary-hover: #ffffff;
116
+
--secondary-muted: rgba(230, 232, 232, 0.1);
117
117
118
-
--success-bg: #1a3d1a;
119
-
--success-border: #2d5a2d;
120
-
--success-text: #7bc67b;
118
+
--success-bg: #0f1f1a;
119
+
--success-border: #1a3d2d;
120
+
--success-text: #7bc6a0;
121
121
122
-
--error-bg: #3d1a1a;
123
-
--error-border: #5a2d2d;
124
-
--error-text: #ff7b7b;
122
+
--error-bg: #1f0f0f;
123
+
--error-border: #3d1a1a;
124
+
--error-text: #ff8a8a;
125
125
126
-
--warning-bg: #3d3d1a;
127
-
--warning-border: #5a5a2d;
128
-
--warning-text: #c6c67b;
126
+
--warning-bg: #1f1a0f;
127
+
--warning-border: #3d351a;
128
+
--warning-text: #c6b87b;
129
129
}
130
130
}
+341
-299
frontend/src/tests/AppPasswords.test.ts
+341
-299
frontend/src/tests/AppPasswords.test.ts
···
1
-
import { describe, it, expect, beforeEach, vi } from 'vitest'
2
-
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'
3
-
import AppPasswords from '../routes/AppPasswords.svelte'
1
+
import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
import { fireEvent, render, screen, waitFor } from "@testing-library/svelte";
3
+
import AppPasswords from "../routes/AppPasswords.svelte";
4
4
import {
5
-
setupFetchMock,
6
-
mockEndpoint,
7
-
jsonResponse,
5
+
clearMocks,
8
6
errorResponse,
7
+
jsonResponse,
9
8
mockData,
10
-
clearMocks,
9
+
mockEndpoint,
11
10
setupAuthenticatedUser,
11
+
setupFetchMock,
12
12
setupUnauthenticatedUser,
13
-
} from './mocks'
14
-
describe('AppPasswords', () => {
13
+
} from "./mocks";
14
+
describe("AppPasswords", () => {
15
15
beforeEach(() => {
16
-
clearMocks()
17
-
setupFetchMock()
18
-
window.confirm = vi.fn(() => true)
19
-
})
20
-
describe('authentication guard', () => {
21
-
it('redirects to login when not authenticated', async () => {
22
-
setupUnauthenticatedUser()
23
-
render(AppPasswords)
16
+
clearMocks();
17
+
setupFetchMock();
18
+
window.confirm = vi.fn(() => true);
19
+
});
20
+
describe("authentication guard", () => {
21
+
it("redirects to login when not authenticated", async () => {
22
+
setupUnauthenticatedUser();
23
+
render(AppPasswords);
24
24
await waitFor(() => {
25
-
expect(window.location.hash).toBe('#/login')
26
-
})
27
-
})
28
-
})
29
-
describe('page structure', () => {
25
+
expect(window.location.hash).toBe("#/login");
26
+
});
27
+
});
28
+
});
29
+
describe("page structure", () => {
30
30
beforeEach(() => {
31
-
setupAuthenticatedUser()
32
-
mockEndpoint('com.atproto.server.listAppPasswords', () =>
33
-
jsonResponse({ passwords: [] })
34
-
)
35
-
})
36
-
it('displays all page elements', async () => {
37
-
render(AppPasswords)
31
+
setupAuthenticatedUser();
32
+
mockEndpoint(
33
+
"com.atproto.server.listAppPasswords",
34
+
() => jsonResponse({ passwords: [] }),
35
+
);
36
+
});
37
+
it("displays all page elements", async () => {
38
+
render(AppPasswords);
38
39
await waitFor(() => {
39
-
expect(screen.getByRole('heading', { name: /app passwords/i, level: 1 })).toBeInTheDocument()
40
-
expect(screen.getByRole('link', { name: /dashboard/i })).toHaveAttribute('href', '#/dashboard')
41
-
expect(screen.getByText(/third-party apps/i)).toBeInTheDocument()
42
-
})
43
-
})
44
-
})
45
-
describe('loading state', () => {
40
+
expect(
41
+
screen.getByRole("heading", { name: /app passwords/i, level: 1 }),
42
+
).toBeInTheDocument();
43
+
expect(screen.getByRole("link", { name: /dashboard/i }))
44
+
.toHaveAttribute("href", "#/dashboard");
45
+
expect(screen.getByText(/third-party apps/i)).toBeInTheDocument();
46
+
});
47
+
});
48
+
});
49
+
describe("loading state", () => {
46
50
beforeEach(() => {
47
-
setupAuthenticatedUser()
48
-
})
49
-
it('shows loading text while fetching passwords', async () => {
50
-
mockEndpoint('com.atproto.server.listAppPasswords', async () => {
51
-
await new Promise(resolve => setTimeout(resolve, 100))
52
-
return jsonResponse({ passwords: [] })
53
-
})
54
-
render(AppPasswords)
55
-
expect(screen.getByText(/loading/i)).toBeInTheDocument()
56
-
})
57
-
})
58
-
describe('empty state', () => {
51
+
setupAuthenticatedUser();
52
+
});
53
+
it("shows loading text while fetching passwords", async () => {
54
+
mockEndpoint("com.atproto.server.listAppPasswords", async () => {
55
+
await new Promise((resolve) => setTimeout(resolve, 100));
56
+
return jsonResponse({ passwords: [] });
57
+
});
58
+
render(AppPasswords);
59
+
expect(screen.getByText(/loading/i)).toBeInTheDocument();
60
+
});
61
+
});
62
+
describe("empty state", () => {
59
63
beforeEach(() => {
60
-
setupAuthenticatedUser()
61
-
mockEndpoint('com.atproto.server.listAppPasswords', () =>
62
-
jsonResponse({ passwords: [] })
63
-
)
64
-
})
65
-
it('shows empty message when no passwords exist', async () => {
66
-
render(AppPasswords)
64
+
setupAuthenticatedUser();
65
+
mockEndpoint(
66
+
"com.atproto.server.listAppPasswords",
67
+
() => jsonResponse({ passwords: [] }),
68
+
);
69
+
});
70
+
it("shows empty message when no passwords exist", async () => {
71
+
render(AppPasswords);
67
72
await waitFor(() => {
68
-
expect(screen.getByText(/no app passwords yet/i)).toBeInTheDocument()
69
-
})
70
-
})
71
-
})
72
-
describe('password list', () => {
73
+
expect(screen.getByText(/no app passwords yet/i)).toBeInTheDocument();
74
+
});
75
+
});
76
+
});
77
+
describe("password list", () => {
73
78
const testPasswords = [
74
-
mockData.appPassword({ name: 'Graysky', createdAt: '2024-01-15T10:00:00Z' }),
75
-
mockData.appPassword({ name: 'Skeets', createdAt: '2024-02-20T15:30:00Z' }),
76
-
]
79
+
mockData.appPassword({
80
+
name: "Graysky",
81
+
createdAt: "2024-01-15T10:00:00Z",
82
+
}),
83
+
mockData.appPassword({
84
+
name: "Skeets",
85
+
createdAt: "2024-02-20T15:30:00Z",
86
+
}),
87
+
];
77
88
beforeEach(() => {
78
-
setupAuthenticatedUser()
79
-
mockEndpoint('com.atproto.server.listAppPasswords', () =>
80
-
jsonResponse({ passwords: testPasswords })
81
-
)
82
-
})
83
-
it('displays all app passwords with dates and revoke buttons', async () => {
84
-
render(AppPasswords)
89
+
setupAuthenticatedUser();
90
+
mockEndpoint(
91
+
"com.atproto.server.listAppPasswords",
92
+
() => jsonResponse({ passwords: testPasswords }),
93
+
);
94
+
});
95
+
it("displays all app passwords with dates and revoke buttons", async () => {
96
+
render(AppPasswords);
85
97
await waitFor(() => {
86
-
expect(screen.getByText('Graysky')).toBeInTheDocument()
87
-
expect(screen.getByText('Skeets')).toBeInTheDocument()
88
-
expect(screen.getByText(/created.*1\/15\/2024/i)).toBeInTheDocument()
89
-
expect(screen.getByText(/created.*2\/20\/2024/i)).toBeInTheDocument()
90
-
expect(screen.getAllByRole('button', { name: /revoke/i })).toHaveLength(2)
91
-
})
92
-
})
93
-
})
94
-
describe('create app password', () => {
98
+
expect(screen.getByText("Graysky")).toBeInTheDocument();
99
+
expect(screen.getByText("Skeets")).toBeInTheDocument();
100
+
expect(screen.getByText(/created.*1\/15\/2024/i)).toBeInTheDocument();
101
+
expect(screen.getByText(/created.*2\/20\/2024/i)).toBeInTheDocument();
102
+
expect(screen.getAllByRole("button", { name: /revoke/i })).toHaveLength(
103
+
2,
104
+
);
105
+
});
106
+
});
107
+
});
108
+
describe("create app password", () => {
95
109
beforeEach(() => {
96
-
setupAuthenticatedUser()
97
-
mockEndpoint('com.atproto.server.listAppPasswords', () =>
98
-
jsonResponse({ passwords: [] })
99
-
)
100
-
})
101
-
it('displays create form with input and button', async () => {
102
-
render(AppPasswords)
110
+
setupAuthenticatedUser();
111
+
mockEndpoint(
112
+
"com.atproto.server.listAppPasswords",
113
+
() => jsonResponse({ passwords: [] }),
114
+
);
115
+
});
116
+
it("displays create form with input and button", async () => {
117
+
render(AppPasswords);
103
118
await waitFor(() => {
104
-
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
105
-
expect(screen.getByRole('button', { name: /create/i })).toBeInTheDocument()
106
-
})
107
-
})
108
-
it('disables create button when input is empty', async () => {
109
-
render(AppPasswords)
119
+
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument();
120
+
expect(screen.getByRole("button", { name: /create/i }))
121
+
.toBeInTheDocument();
122
+
});
123
+
});
124
+
it("disables create button when input is empty", async () => {
125
+
render(AppPasswords);
110
126
await waitFor(() => {
111
-
expect(screen.getByRole('button', { name: /create/i })).toBeDisabled()
112
-
})
113
-
})
114
-
it('enables create button when input has value', async () => {
115
-
render(AppPasswords)
127
+
expect(screen.getByRole("button", { name: /create/i })).toBeDisabled();
128
+
});
129
+
});
130
+
it("enables create button when input has value", async () => {
131
+
render(AppPasswords);
116
132
await waitFor(() => {
117
-
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
118
-
})
119
-
await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'My New App' } })
120
-
expect(screen.getByRole('button', { name: /create/i })).not.toBeDisabled()
121
-
})
122
-
it('calls createAppPassword with correct name', async () => {
123
-
let capturedName: string | null = null
124
-
mockEndpoint('com.atproto.server.createAppPassword', (_url, options) => {
125
-
const body = JSON.parse((options?.body as string) || '{}')
126
-
capturedName = body.name
133
+
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument();
134
+
});
135
+
await fireEvent.input(screen.getByPlaceholderText(/app name/i), {
136
+
target: { value: "My New App" },
137
+
});
138
+
expect(screen.getByRole("button", { name: /create/i })).not
139
+
.toBeDisabled();
140
+
});
141
+
it("calls createAppPassword with correct name", async () => {
142
+
let capturedName: string | null = null;
143
+
mockEndpoint("com.atproto.server.createAppPassword", (_url, options) => {
144
+
const body = JSON.parse((options?.body as string) || "{}");
145
+
capturedName = body.name;
127
146
return jsonResponse({
128
147
name: body.name,
129
-
password: 'xxxx-xxxx-xxxx-xxxx',
148
+
password: "xxxx-xxxx-xxxx-xxxx",
130
149
createdAt: new Date().toISOString(),
131
-
})
132
-
})
133
-
render(AppPasswords)
150
+
});
151
+
});
152
+
render(AppPasswords);
134
153
await waitFor(() => {
135
-
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
136
-
})
137
-
await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'Graysky' } })
138
-
await fireEvent.click(screen.getByRole('button', { name: /create/i }))
154
+
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument();
155
+
});
156
+
await fireEvent.input(screen.getByPlaceholderText(/app name/i), {
157
+
target: { value: "Graysky" },
158
+
});
159
+
await fireEvent.click(screen.getByRole("button", { name: /create/i }));
139
160
await waitFor(() => {
140
-
expect(capturedName).toBe('Graysky')
141
-
})
142
-
})
143
-
it('shows loading state while creating', async () => {
144
-
mockEndpoint('com.atproto.server.createAppPassword', async () => {
145
-
await new Promise(resolve => setTimeout(resolve, 100))
161
+
expect(capturedName).toBe("Graysky");
162
+
});
163
+
});
164
+
it("shows loading state while creating", async () => {
165
+
mockEndpoint("com.atproto.server.createAppPassword", async () => {
166
+
await new Promise((resolve) => setTimeout(resolve, 100));
146
167
return jsonResponse({
147
-
name: 'Test',
148
-
password: 'xxxx-xxxx-xxxx-xxxx',
168
+
name: "Test",
169
+
password: "xxxx-xxxx-xxxx-xxxx",
149
170
createdAt: new Date().toISOString(),
150
-
})
151
-
})
152
-
render(AppPasswords)
171
+
});
172
+
});
173
+
render(AppPasswords);
153
174
await waitFor(() => {
154
-
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
155
-
})
156
-
await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'Test' } })
157
-
await fireEvent.click(screen.getByRole('button', { name: /create/i }))
158
-
expect(screen.getByRole('button', { name: /creating/i })).toBeInTheDocument()
159
-
expect(screen.getByRole('button', { name: /creating/i })).toBeDisabled()
160
-
})
161
-
it('displays created password in success box and clears input', async () => {
162
-
mockEndpoint('com.atproto.server.createAppPassword', () =>
175
+
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument();
176
+
});
177
+
await fireEvent.input(screen.getByPlaceholderText(/app name/i), {
178
+
target: { value: "Test" },
179
+
});
180
+
await fireEvent.click(screen.getByRole("button", { name: /create/i }));
181
+
expect(screen.getByRole("button", { name: /creating/i }))
182
+
.toBeInTheDocument();
183
+
expect(screen.getByRole("button", { name: /creating/i })).toBeDisabled();
184
+
});
185
+
it("displays created password in success box and clears input", async () => {
186
+
mockEndpoint("com.atproto.server.createAppPassword", () =>
163
187
jsonResponse({
164
-
name: 'MyApp',
165
-
password: 'abcd-efgh-ijkl-mnop',
188
+
name: "MyApp",
189
+
password: "abcd-efgh-ijkl-mnop",
166
190
createdAt: new Date().toISOString(),
167
-
})
168
-
)
169
-
render(AppPasswords)
191
+
}));
192
+
render(AppPasswords);
170
193
await waitFor(() => {
171
-
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
172
-
})
173
-
const input = screen.getByPlaceholderText(/app name/i) as HTMLInputElement
174
-
await fireEvent.input(input, { target: { value: 'MyApp' } })
175
-
await fireEvent.click(screen.getByRole('button', { name: /create/i }))
194
+
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument();
195
+
});
196
+
const input = screen.getByPlaceholderText(
197
+
/app name/i,
198
+
) as HTMLInputElement;
199
+
await fireEvent.input(input, { target: { value: "MyApp" } });
200
+
await fireEvent.click(screen.getByRole("button", { name: /create/i }));
176
201
await waitFor(() => {
177
-
expect(screen.getByText(/app password created/i)).toBeInTheDocument()
178
-
expect(screen.getByText('abcd-efgh-ijkl-mnop')).toBeInTheDocument()
179
-
expect(screen.getByText(/name: myapp/i)).toBeInTheDocument()
180
-
expect(input.value).toBe('')
181
-
})
182
-
})
183
-
it('dismisses created password box when clicking Done', async () => {
184
-
mockEndpoint('com.atproto.server.createAppPassword', () =>
202
+
expect(screen.getByText(/app password created/i)).toBeInTheDocument();
203
+
expect(screen.getByText("abcd-efgh-ijkl-mnop")).toBeInTheDocument();
204
+
expect(screen.getByText(/name: myapp/i)).toBeInTheDocument();
205
+
expect(input.value).toBe("");
206
+
});
207
+
});
208
+
it("dismisses created password box when clicking Done", async () => {
209
+
mockEndpoint("com.atproto.server.createAppPassword", () =>
185
210
jsonResponse({
186
-
name: 'Test',
187
-
password: 'xxxx-xxxx-xxxx-xxxx',
211
+
name: "Test",
212
+
password: "xxxx-xxxx-xxxx-xxxx",
188
213
createdAt: new Date().toISOString(),
189
-
})
190
-
)
191
-
render(AppPasswords)
214
+
}));
215
+
render(AppPasswords);
192
216
await waitFor(() => {
193
-
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
194
-
})
195
-
await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'Test' } })
196
-
await fireEvent.click(screen.getByRole('button', { name: /create/i }))
217
+
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument();
218
+
});
219
+
await fireEvent.input(screen.getByPlaceholderText(/app name/i), {
220
+
target: { value: "Test" },
221
+
});
222
+
await fireEvent.click(screen.getByRole("button", { name: /create/i }));
197
223
await waitFor(() => {
198
-
expect(screen.getByText(/app password created/i)).toBeInTheDocument()
199
-
})
200
-
await fireEvent.click(screen.getByRole('button', { name: /done/i }))
224
+
expect(screen.getByText(/app password created/i)).toBeInTheDocument();
225
+
});
226
+
await fireEvent.click(screen.getByRole("button", { name: /done/i }));
201
227
await waitFor(() => {
202
-
expect(screen.queryByText(/app password created/i)).not.toBeInTheDocument()
203
-
})
204
-
})
205
-
it('shows error when creation fails', async () => {
206
-
mockEndpoint('com.atproto.server.createAppPassword', () =>
207
-
errorResponse('InvalidRequest', 'Name already exists', 400)
208
-
)
209
-
render(AppPasswords)
228
+
expect(screen.queryByText(/app password created/i)).not
229
+
.toBeInTheDocument();
230
+
});
231
+
});
232
+
it("shows error when creation fails", async () => {
233
+
mockEndpoint(
234
+
"com.atproto.server.createAppPassword",
235
+
() => errorResponse("InvalidRequest", "Name already exists", 400),
236
+
);
237
+
render(AppPasswords);
210
238
await waitFor(() => {
211
-
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument()
212
-
})
213
-
await fireEvent.input(screen.getByPlaceholderText(/app name/i), { target: { value: 'Duplicate' } })
214
-
await fireEvent.click(screen.getByRole('button', { name: /create/i }))
239
+
expect(screen.getByPlaceholderText(/app name/i)).toBeInTheDocument();
240
+
});
241
+
await fireEvent.input(screen.getByPlaceholderText(/app name/i), {
242
+
target: { value: "Duplicate" },
243
+
});
244
+
await fireEvent.click(screen.getByRole("button", { name: /create/i }));
215
245
await waitFor(() => {
216
-
expect(screen.getByText(/name already exists/i)).toBeInTheDocument()
217
-
expect(screen.getByText(/name already exists/i)).toHaveClass('error')
218
-
})
219
-
})
220
-
})
221
-
describe('revoke app password', () => {
222
-
const testPassword = mockData.appPassword({ name: 'TestApp' })
246
+
expect(screen.getByText(/name already exists/i)).toBeInTheDocument();
247
+
expect(screen.getByText(/name already exists/i)).toHaveClass("error");
248
+
});
249
+
});
250
+
});
251
+
describe("revoke app password", () => {
252
+
const testPassword = mockData.appPassword({ name: "TestApp" });
223
253
beforeEach(() => {
224
-
setupAuthenticatedUser()
225
-
})
226
-
it('shows confirmation dialog before revoking', async () => {
227
-
const confirmSpy = vi.fn(() => false)
228
-
window.confirm = confirmSpy
229
-
mockEndpoint('com.atproto.server.listAppPasswords', () =>
230
-
jsonResponse({ passwords: [testPassword] })
231
-
)
232
-
render(AppPasswords)
254
+
setupAuthenticatedUser();
255
+
});
256
+
it("shows confirmation dialog before revoking", async () => {
257
+
const confirmSpy = vi.fn(() => false);
258
+
window.confirm = confirmSpy;
259
+
mockEndpoint(
260
+
"com.atproto.server.listAppPasswords",
261
+
() => jsonResponse({ passwords: [testPassword] }),
262
+
);
263
+
render(AppPasswords);
233
264
await waitFor(() => {
234
-
expect(screen.getByText('TestApp')).toBeInTheDocument()
235
-
})
236
-
await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
265
+
expect(screen.getByText("TestApp")).toBeInTheDocument();
266
+
});
267
+
await fireEvent.click(screen.getByRole("button", { name: /revoke/i }));
237
268
expect(confirmSpy).toHaveBeenCalledWith(
238
-
expect.stringContaining('TestApp')
239
-
)
240
-
})
241
-
it('does not revoke when confirmation is cancelled', async () => {
242
-
window.confirm = vi.fn(() => false)
243
-
let revokeCalled = false
244
-
mockEndpoint('com.atproto.server.listAppPasswords', () =>
245
-
jsonResponse({ passwords: [testPassword] })
246
-
)
247
-
mockEndpoint('com.atproto.server.revokeAppPassword', () => {
248
-
revokeCalled = true
249
-
return jsonResponse({})
250
-
})
251
-
render(AppPasswords)
269
+
expect.stringContaining("TestApp"),
270
+
);
271
+
});
272
+
it("does not revoke when confirmation is cancelled", async () => {
273
+
window.confirm = vi.fn(() => false);
274
+
let revokeCalled = false;
275
+
mockEndpoint(
276
+
"com.atproto.server.listAppPasswords",
277
+
() => jsonResponse({ passwords: [testPassword] }),
278
+
);
279
+
mockEndpoint("com.atproto.server.revokeAppPassword", () => {
280
+
revokeCalled = true;
281
+
return jsonResponse({});
282
+
});
283
+
render(AppPasswords);
252
284
await waitFor(() => {
253
-
expect(screen.getByText('TestApp')).toBeInTheDocument()
254
-
})
255
-
await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
256
-
expect(revokeCalled).toBe(false)
257
-
})
258
-
it('calls revokeAppPassword with correct name', async () => {
259
-
window.confirm = vi.fn(() => true)
260
-
let capturedName: string | null = null
261
-
mockEndpoint('com.atproto.server.listAppPasswords', () =>
262
-
jsonResponse({ passwords: [testPassword] })
263
-
)
264
-
mockEndpoint('com.atproto.server.revokeAppPassword', (_url, options) => {
265
-
const body = JSON.parse((options?.body as string) || '{}')
266
-
capturedName = body.name
267
-
return jsonResponse({})
268
-
})
269
-
render(AppPasswords)
285
+
expect(screen.getByText("TestApp")).toBeInTheDocument();
286
+
});
287
+
await fireEvent.click(screen.getByRole("button", { name: /revoke/i }));
288
+
expect(revokeCalled).toBe(false);
289
+
});
290
+
it("calls revokeAppPassword with correct name", async () => {
291
+
window.confirm = vi.fn(() => true);
292
+
let capturedName: string | null = null;
293
+
mockEndpoint(
294
+
"com.atproto.server.listAppPasswords",
295
+
() => jsonResponse({ passwords: [testPassword] }),
296
+
);
297
+
mockEndpoint("com.atproto.server.revokeAppPassword", (_url, options) => {
298
+
const body = JSON.parse((options?.body as string) || "{}");
299
+
capturedName = body.name;
300
+
return jsonResponse({});
301
+
});
302
+
render(AppPasswords);
270
303
await waitFor(() => {
271
-
expect(screen.getByText('TestApp')).toBeInTheDocument()
272
-
})
273
-
await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
304
+
expect(screen.getByText("TestApp")).toBeInTheDocument();
305
+
});
306
+
await fireEvent.click(screen.getByRole("button", { name: /revoke/i }));
274
307
await waitFor(() => {
275
-
expect(capturedName).toBe('TestApp')
276
-
})
277
-
})
278
-
it('shows loading state while revoking', async () => {
279
-
window.confirm = vi.fn(() => true)
280
-
mockEndpoint('com.atproto.server.listAppPasswords', () =>
281
-
jsonResponse({ passwords: [testPassword] })
282
-
)
283
-
mockEndpoint('com.atproto.server.revokeAppPassword', async () => {
284
-
await new Promise(resolve => setTimeout(resolve, 100))
285
-
return jsonResponse({})
286
-
})
287
-
render(AppPasswords)
308
+
expect(capturedName).toBe("TestApp");
309
+
});
310
+
});
311
+
it("shows loading state while revoking", async () => {
312
+
window.confirm = vi.fn(() => true);
313
+
mockEndpoint(
314
+
"com.atproto.server.listAppPasswords",
315
+
() => jsonResponse({ passwords: [testPassword] }),
316
+
);
317
+
mockEndpoint("com.atproto.server.revokeAppPassword", async () => {
318
+
await new Promise((resolve) => setTimeout(resolve, 100));
319
+
return jsonResponse({});
320
+
});
321
+
render(AppPasswords);
288
322
await waitFor(() => {
289
-
expect(screen.getByText('TestApp')).toBeInTheDocument()
290
-
})
291
-
await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
292
-
expect(screen.getByRole('button', { name: /revoking/i })).toBeInTheDocument()
293
-
expect(screen.getByRole('button', { name: /revoking/i })).toBeDisabled()
294
-
})
295
-
it('reloads password list after successful revocation', async () => {
296
-
window.confirm = vi.fn(() => true)
297
-
let listCallCount = 0
298
-
mockEndpoint('com.atproto.server.listAppPasswords', () => {
299
-
listCallCount++
323
+
expect(screen.getByText("TestApp")).toBeInTheDocument();
324
+
});
325
+
await fireEvent.click(screen.getByRole("button", { name: /revoke/i }));
326
+
expect(screen.getByRole("button", { name: /revoking/i }))
327
+
.toBeInTheDocument();
328
+
expect(screen.getByRole("button", { name: /revoking/i })).toBeDisabled();
329
+
});
330
+
it("reloads password list after successful revocation", async () => {
331
+
window.confirm = vi.fn(() => true);
332
+
let listCallCount = 0;
333
+
mockEndpoint("com.atproto.server.listAppPasswords", () => {
334
+
listCallCount++;
300
335
if (listCallCount === 1) {
301
-
return jsonResponse({ passwords: [testPassword] })
336
+
return jsonResponse({ passwords: [testPassword] });
302
337
}
303
-
return jsonResponse({ passwords: [] })
304
-
})
305
-
mockEndpoint('com.atproto.server.revokeAppPassword', () =>
306
-
jsonResponse({})
307
-
)
308
-
render(AppPasswords)
338
+
return jsonResponse({ passwords: [] });
339
+
});
340
+
mockEndpoint(
341
+
"com.atproto.server.revokeAppPassword",
342
+
() => jsonResponse({}),
343
+
);
344
+
render(AppPasswords);
309
345
await waitFor(() => {
310
-
expect(screen.getByText('TestApp')).toBeInTheDocument()
311
-
})
312
-
await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
346
+
expect(screen.getByText("TestApp")).toBeInTheDocument();
347
+
});
348
+
await fireEvent.click(screen.getByRole("button", { name: /revoke/i }));
313
349
await waitFor(() => {
314
-
expect(screen.queryByText('TestApp')).not.toBeInTheDocument()
315
-
expect(screen.getByText(/no app passwords yet/i)).toBeInTheDocument()
316
-
})
317
-
})
318
-
it('shows error when revocation fails', async () => {
319
-
window.confirm = vi.fn(() => true)
320
-
mockEndpoint('com.atproto.server.listAppPasswords', () =>
321
-
jsonResponse({ passwords: [testPassword] })
322
-
)
323
-
mockEndpoint('com.atproto.server.revokeAppPassword', () =>
324
-
errorResponse('InternalError', 'Server error', 500)
325
-
)
326
-
render(AppPasswords)
350
+
expect(screen.queryByText("TestApp")).not.toBeInTheDocument();
351
+
expect(screen.getByText(/no app passwords yet/i)).toBeInTheDocument();
352
+
});
353
+
});
354
+
it("shows error when revocation fails", async () => {
355
+
window.confirm = vi.fn(() => true);
356
+
mockEndpoint(
357
+
"com.atproto.server.listAppPasswords",
358
+
() => jsonResponse({ passwords: [testPassword] }),
359
+
);
360
+
mockEndpoint(
361
+
"com.atproto.server.revokeAppPassword",
362
+
() => errorResponse("InternalError", "Server error", 500),
363
+
);
364
+
render(AppPasswords);
327
365
await waitFor(() => {
328
-
expect(screen.getByText('TestApp')).toBeInTheDocument()
329
-
})
330
-
await fireEvent.click(screen.getByRole('button', { name: /revoke/i }))
366
+
expect(screen.getByText("TestApp")).toBeInTheDocument();
367
+
});
368
+
await fireEvent.click(screen.getByRole("button", { name: /revoke/i }));
331
369
await waitFor(() => {
332
-
expect(screen.getByText(/server error/i)).toBeInTheDocument()
333
-
expect(screen.getByText(/server error/i)).toHaveClass('error')
334
-
})
335
-
})
336
-
})
337
-
describe('error handling', () => {
370
+
expect(screen.getByText(/server error/i)).toBeInTheDocument();
371
+
expect(screen.getByText(/server error/i)).toHaveClass("error");
372
+
});
373
+
});
374
+
});
375
+
describe("error handling", () => {
338
376
beforeEach(() => {
339
-
setupAuthenticatedUser()
340
-
})
341
-
it('shows error when loading passwords fails', async () => {
342
-
mockEndpoint('com.atproto.server.listAppPasswords', () =>
343
-
errorResponse('InternalError', 'Database connection failed', 500)
344
-
)
345
-
render(AppPasswords)
377
+
setupAuthenticatedUser();
378
+
});
379
+
it("shows error when loading passwords fails", async () => {
380
+
mockEndpoint(
381
+
"com.atproto.server.listAppPasswords",
382
+
() => errorResponse("InternalError", "Database connection failed", 500),
383
+
);
384
+
render(AppPasswords);
346
385
await waitFor(() => {
347
-
expect(screen.getByText(/database connection failed/i)).toBeInTheDocument()
348
-
expect(screen.getByText(/database connection failed/i)).toHaveClass('error')
349
-
})
350
-
})
351
-
})
352
-
})
386
+
expect(screen.getByText(/database connection failed/i))
387
+
.toBeInTheDocument();
388
+
expect(screen.getByText(/database connection failed/i)).toHaveClass(
389
+
"error",
390
+
);
391
+
});
392
+
});
393
+
});
394
+
});
+395
-305
frontend/src/tests/Comms.test.ts
+395
-305
frontend/src/tests/Comms.test.ts
···
1
-
import { describe, it, expect, beforeEach } from 'vitest'
2
-
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'
3
-
import Comms from '../routes/Comms.svelte'
1
+
import { beforeEach, describe, expect, it } from "vitest";
2
+
import { fireEvent, render, screen, waitFor } from "@testing-library/svelte";
3
+
import Comms from "../routes/Comms.svelte";
4
4
import {
5
-
setupFetchMock,
6
-
mockEndpoint,
5
+
clearMocks,
6
+
errorResponse,
7
7
jsonResponse,
8
-
errorResponse,
9
8
mockData,
10
-
clearMocks,
9
+
mockEndpoint,
11
10
setupAuthenticatedUser,
11
+
setupFetchMock,
12
12
setupUnauthenticatedUser,
13
-
} from './mocks'
14
-
describe('Comms', () => {
13
+
} from "./mocks";
14
+
describe("Comms", () => {
15
15
beforeEach(() => {
16
-
clearMocks()
17
-
setupFetchMock()
18
-
})
19
-
describe('authentication guard', () => {
20
-
it('redirects to login when not authenticated', async () => {
21
-
setupUnauthenticatedUser()
22
-
render(Comms)
16
+
clearMocks();
17
+
setupFetchMock();
18
+
});
19
+
describe("authentication guard", () => {
20
+
it("redirects to login when not authenticated", async () => {
21
+
setupUnauthenticatedUser();
22
+
render(Comms);
23
23
await waitFor(() => {
24
-
expect(window.location.hash).toBe('#/login')
25
-
})
26
-
})
27
-
})
28
-
describe('page structure', () => {
24
+
expect(window.location.hash).toBe("#/login");
25
+
});
26
+
});
27
+
});
28
+
describe("page structure", () => {
29
29
beforeEach(() => {
30
-
setupAuthenticatedUser()
31
-
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
32
-
jsonResponse(mockData.notificationPrefs())
33
-
)
34
-
})
35
-
it('displays all page elements and sections', async () => {
36
-
render(Comms)
30
+
setupAuthenticatedUser();
31
+
mockEndpoint(
32
+
"com.tranquil.account.getNotificationPrefs",
33
+
() => jsonResponse(mockData.notificationPrefs()),
34
+
);
35
+
});
36
+
it("displays all page elements and sections", async () => {
37
+
render(Comms);
37
38
await waitFor(() => {
38
-
expect(screen.getByRole('heading', { name: /notification preferences/i, level: 1 })).toBeInTheDocument()
39
-
expect(screen.getByRole('link', { name: /dashboard/i })).toHaveAttribute('href', '#/dashboard')
40
-
expect(screen.getByText(/password resets/i)).toBeInTheDocument()
41
-
expect(screen.getByRole('heading', { name: /preferred channel/i })).toBeInTheDocument()
42
-
expect(screen.getByRole('heading', { name: /channel configuration/i })).toBeInTheDocument()
43
-
})
44
-
})
45
-
})
46
-
describe('loading state', () => {
39
+
expect(
40
+
screen.getByRole("heading", {
41
+
name: /notification preferences/i,
42
+
level: 1,
43
+
}),
44
+
).toBeInTheDocument();
45
+
expect(screen.getByRole("link", { name: /dashboard/i }))
46
+
.toHaveAttribute("href", "#/dashboard");
47
+
expect(screen.getByText(/password resets/i)).toBeInTheDocument();
48
+
expect(screen.getByRole("heading", { name: /preferred channel/i }))
49
+
.toBeInTheDocument();
50
+
expect(screen.getByRole("heading", { name: /channel configuration/i }))
51
+
.toBeInTheDocument();
52
+
});
53
+
});
54
+
});
55
+
describe("loading state", () => {
47
56
beforeEach(() => {
48
-
setupAuthenticatedUser()
49
-
})
50
-
it('shows loading text while fetching preferences', async () => {
51
-
mockEndpoint('com.tranquil.account.getNotificationPrefs', async () => {
52
-
await new Promise(resolve => setTimeout(resolve, 100))
53
-
return jsonResponse(mockData.notificationPrefs())
54
-
})
55
-
render(Comms)
56
-
expect(screen.getByText(/loading/i)).toBeInTheDocument()
57
-
})
58
-
})
59
-
describe('channel options', () => {
57
+
setupAuthenticatedUser();
58
+
});
59
+
it("shows loading text while fetching preferences", async () => {
60
+
mockEndpoint("com.tranquil.account.getNotificationPrefs", async () => {
61
+
await new Promise((resolve) => setTimeout(resolve, 100));
62
+
return jsonResponse(mockData.notificationPrefs());
63
+
});
64
+
render(Comms);
65
+
expect(screen.getByText(/loading/i)).toBeInTheDocument();
66
+
});
67
+
});
68
+
describe("channel options", () => {
60
69
beforeEach(() => {
61
-
setupAuthenticatedUser()
62
-
})
63
-
it('displays all four channel options', async () => {
64
-
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
65
-
jsonResponse(mockData.notificationPrefs())
66
-
)
67
-
render(Comms)
70
+
setupAuthenticatedUser();
71
+
});
72
+
it("displays all four channel options", async () => {
73
+
mockEndpoint(
74
+
"com.tranquil.account.getNotificationPrefs",
75
+
() => jsonResponse(mockData.notificationPrefs()),
76
+
);
77
+
render(Comms);
68
78
await waitFor(() => {
69
-
expect(screen.getByRole('radio', { name: /email/i })).toBeInTheDocument()
70
-
expect(screen.getByRole('radio', { name: /discord/i })).toBeInTheDocument()
71
-
expect(screen.getByRole('radio', { name: /telegram/i })).toBeInTheDocument()
72
-
expect(screen.getByRole('radio', { name: /signal/i })).toBeInTheDocument()
73
-
})
74
-
})
75
-
it('email channel is always selectable', async () => {
76
-
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
77
-
jsonResponse(mockData.notificationPrefs())
78
-
)
79
-
render(Comms)
79
+
expect(screen.getByRole("radio", { name: /email/i }))
80
+
.toBeInTheDocument();
81
+
expect(screen.getByRole("radio", { name: /discord/i }))
82
+
.toBeInTheDocument();
83
+
expect(screen.getByRole("radio", { name: /telegram/i }))
84
+
.toBeInTheDocument();
85
+
expect(screen.getByRole("radio", { name: /signal/i }))
86
+
.toBeInTheDocument();
87
+
});
88
+
});
89
+
it("email channel is always selectable", async () => {
90
+
mockEndpoint(
91
+
"com.tranquil.account.getNotificationPrefs",
92
+
() => jsonResponse(mockData.notificationPrefs()),
93
+
);
94
+
render(Comms);
80
95
await waitFor(() => {
81
-
const emailRadio = screen.getByRole('radio', { name: /email/i })
82
-
expect(emailRadio).not.toBeDisabled()
83
-
})
84
-
})
85
-
it('discord channel is disabled when not configured', async () => {
86
-
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
87
-
jsonResponse(mockData.notificationPrefs({ discordId: null }))
88
-
)
89
-
render(Comms)
96
+
const emailRadio = screen.getByRole("radio", { name: /email/i });
97
+
expect(emailRadio).not.toBeDisabled();
98
+
});
99
+
});
100
+
it("discord channel is disabled when not configured", async () => {
101
+
mockEndpoint(
102
+
"com.tranquil.account.getNotificationPrefs",
103
+
() => jsonResponse(mockData.notificationPrefs({ discordId: null })),
104
+
);
105
+
render(Comms);
90
106
await waitFor(() => {
91
-
const discordRadio = screen.getByRole('radio', { name: /discord/i })
92
-
expect(discordRadio).toBeDisabled()
93
-
})
94
-
})
95
-
it('discord channel is enabled when configured', async () => {
96
-
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
97
-
jsonResponse(mockData.notificationPrefs({ discordId: '123456789' }))
98
-
)
99
-
render(Comms)
107
+
const discordRadio = screen.getByRole("radio", { name: /discord/i });
108
+
expect(discordRadio).toBeDisabled();
109
+
});
110
+
});
111
+
it("discord channel is enabled when configured", async () => {
112
+
mockEndpoint(
113
+
"com.tranquil.account.getNotificationPrefs",
114
+
() =>
115
+
jsonResponse(mockData.notificationPrefs({ discordId: "123456789" })),
116
+
);
117
+
render(Comms);
100
118
await waitFor(() => {
101
-
const discordRadio = screen.getByRole('radio', { name: /discord/i })
102
-
expect(discordRadio).not.toBeDisabled()
103
-
})
104
-
})
105
-
it('shows hint for disabled channels', async () => {
106
-
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
107
-
jsonResponse(mockData.notificationPrefs())
108
-
)
109
-
render(Comms)
119
+
const discordRadio = screen.getByRole("radio", { name: /discord/i });
120
+
expect(discordRadio).not.toBeDisabled();
121
+
});
122
+
});
123
+
it("shows hint for disabled channels", async () => {
124
+
mockEndpoint(
125
+
"com.tranquil.account.getNotificationPrefs",
126
+
() => jsonResponse(mockData.notificationPrefs()),
127
+
);
128
+
render(Comms);
110
129
await waitFor(() => {
111
-
expect(screen.getAllByText(/configure below to enable/i).length).toBeGreaterThan(0)
112
-
})
113
-
})
114
-
it('selects current preferred channel', async () => {
115
-
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
116
-
jsonResponse(mockData.notificationPrefs({ preferredChannel: 'email' }))
117
-
)
118
-
render(Comms)
130
+
expect(screen.getAllByText(/configure below to enable/i).length)
131
+
.toBeGreaterThan(0);
132
+
});
133
+
});
134
+
it("selects current preferred channel", async () => {
135
+
mockEndpoint(
136
+
"com.tranquil.account.getNotificationPrefs",
137
+
() =>
138
+
jsonResponse(
139
+
mockData.notificationPrefs({ preferredChannel: "email" }),
140
+
),
141
+
);
142
+
render(Comms);
119
143
await waitFor(() => {
120
-
const emailRadio = screen.getByRole('radio', { name: /email/i }) as HTMLInputElement
121
-
expect(emailRadio.checked).toBe(true)
122
-
})
123
-
})
124
-
})
125
-
describe('channel configuration', () => {
144
+
const emailRadio = screen.getByRole("radio", {
145
+
name: /email/i,
146
+
}) as HTMLInputElement;
147
+
expect(emailRadio.checked).toBe(true);
148
+
});
149
+
});
150
+
});
151
+
describe("channel configuration", () => {
126
152
beforeEach(() => {
127
-
setupAuthenticatedUser()
128
-
})
129
-
it('displays email as readonly with current value', async () => {
130
-
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
131
-
jsonResponse(mockData.notificationPrefs())
132
-
)
133
-
render(Comms)
153
+
setupAuthenticatedUser();
154
+
});
155
+
it("displays email as readonly with current value", async () => {
156
+
mockEndpoint(
157
+
"com.tranquil.account.getNotificationPrefs",
158
+
() => jsonResponse(mockData.notificationPrefs()),
159
+
);
160
+
render(Comms);
134
161
await waitFor(() => {
135
-
const emailInput = screen.getByLabelText(/^email$/i) as HTMLInputElement
136
-
expect(emailInput).toBeDisabled()
137
-
expect(emailInput.value).toBe('test@example.com')
138
-
})
139
-
})
140
-
it('displays all channel inputs with current values', async () => {
141
-
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
142
-
jsonResponse(mockData.notificationPrefs({
143
-
discordId: '123456789',
144
-
telegramUsername: 'testuser',
145
-
signalNumber: '+1234567890',
146
-
}))
147
-
)
148
-
render(Comms)
162
+
const emailInput = screen.getByLabelText(
163
+
/^email$/i,
164
+
) as HTMLInputElement;
165
+
expect(emailInput).toBeDisabled();
166
+
expect(emailInput.value).toBe("test@example.com");
167
+
});
168
+
});
169
+
it("displays all channel inputs with current values", async () => {
170
+
mockEndpoint(
171
+
"com.tranquil.account.getNotificationPrefs",
172
+
() =>
173
+
jsonResponse(mockData.notificationPrefs({
174
+
discordId: "123456789",
175
+
telegramUsername: "testuser",
176
+
signalNumber: "+1234567890",
177
+
})),
178
+
);
179
+
render(Comms);
149
180
await waitFor(() => {
150
-
expect((screen.getByLabelText(/discord user id/i) as HTMLInputElement).value).toBe('123456789')
151
-
expect((screen.getByLabelText(/telegram username/i) as HTMLInputElement).value).toBe('testuser')
152
-
expect((screen.getByLabelText(/signal phone number/i) as HTMLInputElement).value).toBe('+1234567890')
153
-
})
154
-
})
155
-
})
156
-
describe('verification status badges', () => {
181
+
expect(
182
+
(screen.getByLabelText(/discord user id/i) as HTMLInputElement).value,
183
+
).toBe("123456789");
184
+
expect(
185
+
(screen.getByLabelText(/telegram username/i) as HTMLInputElement)
186
+
.value,
187
+
).toBe("testuser");
188
+
expect(
189
+
(screen.getByLabelText(/signal phone number/i) as HTMLInputElement)
190
+
.value,
191
+
).toBe("+1234567890");
192
+
});
193
+
});
194
+
});
195
+
describe("verification status badges", () => {
157
196
beforeEach(() => {
158
-
setupAuthenticatedUser()
159
-
})
160
-
it('shows Primary badge for email', async () => {
161
-
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
162
-
jsonResponse(mockData.notificationPrefs())
163
-
)
164
-
render(Comms)
197
+
setupAuthenticatedUser();
198
+
});
199
+
it("shows Primary badge for email", async () => {
200
+
mockEndpoint(
201
+
"com.tranquil.account.getNotificationPrefs",
202
+
() => jsonResponse(mockData.notificationPrefs()),
203
+
);
204
+
render(Comms);
165
205
await waitFor(() => {
166
-
expect(screen.getByText('Primary')).toBeInTheDocument()
167
-
})
168
-
})
169
-
it('shows Verified badge for verified discord', async () => {
170
-
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
171
-
jsonResponse(mockData.notificationPrefs({
172
-
discordId: '123456789',
173
-
discordVerified: true,
174
-
}))
175
-
)
176
-
render(Comms)
206
+
expect(screen.getByText("Primary")).toBeInTheDocument();
207
+
});
208
+
});
209
+
it("shows Verified badge for verified discord", async () => {
210
+
mockEndpoint(
211
+
"com.tranquil.account.getNotificationPrefs",
212
+
() =>
213
+
jsonResponse(mockData.notificationPrefs({
214
+
discordId: "123456789",
215
+
discordVerified: true,
216
+
})),
217
+
);
218
+
render(Comms);
177
219
await waitFor(() => {
178
-
const verifiedBadges = screen.getAllByText('Verified')
179
-
expect(verifiedBadges.length).toBeGreaterThan(0)
180
-
})
181
-
})
182
-
it('shows Not verified badge for unverified discord', async () => {
183
-
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
184
-
jsonResponse(mockData.notificationPrefs({
185
-
discordId: '123456789',
186
-
discordVerified: false,
187
-
}))
188
-
)
189
-
render(Comms)
220
+
const verifiedBadges = screen.getAllByText("Verified");
221
+
expect(verifiedBadges.length).toBeGreaterThan(0);
222
+
});
223
+
});
224
+
it("shows Not verified badge for unverified discord", async () => {
225
+
mockEndpoint(
226
+
"com.tranquil.account.getNotificationPrefs",
227
+
() =>
228
+
jsonResponse(mockData.notificationPrefs({
229
+
discordId: "123456789",
230
+
discordVerified: false,
231
+
})),
232
+
);
233
+
render(Comms);
190
234
await waitFor(() => {
191
-
expect(screen.getByText('Not verified')).toBeInTheDocument()
192
-
})
193
-
})
194
-
it('does not show badge when channel not configured', async () => {
195
-
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
196
-
jsonResponse(mockData.notificationPrefs())
197
-
)
198
-
render(Comms)
235
+
expect(screen.getByText("Not verified")).toBeInTheDocument();
236
+
});
237
+
});
238
+
it("does not show badge when channel not configured", async () => {
239
+
mockEndpoint(
240
+
"com.tranquil.account.getNotificationPrefs",
241
+
() => jsonResponse(mockData.notificationPrefs()),
242
+
);
243
+
render(Comms);
199
244
await waitFor(() => {
200
-
expect(screen.getByText('Primary')).toBeInTheDocument()
201
-
expect(screen.queryByText('Not verified')).not.toBeInTheDocument()
202
-
})
203
-
})
204
-
})
205
-
describe('save preferences', () => {
245
+
expect(screen.getByText("Primary")).toBeInTheDocument();
246
+
expect(screen.queryByText("Not verified")).not.toBeInTheDocument();
247
+
});
248
+
});
249
+
});
250
+
describe("save preferences", () => {
206
251
beforeEach(() => {
207
-
setupAuthenticatedUser()
208
-
})
209
-
it('calls updateNotificationPrefs with correct data', async () => {
210
-
let capturedBody: Record<string, unknown> | null = null
211
-
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
212
-
jsonResponse(mockData.notificationPrefs())
213
-
)
214
-
mockEndpoint('com.tranquil.account.updateNotificationPrefs', (_url, options) => {
215
-
capturedBody = JSON.parse((options?.body as string) || '{}')
216
-
return jsonResponse({ success: true })
217
-
})
218
-
render(Comms)
252
+
setupAuthenticatedUser();
253
+
});
254
+
it("calls updateNotificationPrefs with correct data", async () => {
255
+
let capturedBody: Record<string, unknown> | null = null;
256
+
mockEndpoint(
257
+
"com.tranquil.account.getNotificationPrefs",
258
+
() => jsonResponse(mockData.notificationPrefs()),
259
+
);
260
+
mockEndpoint(
261
+
"com.tranquil.account.updateNotificationPrefs",
262
+
(_url, options) => {
263
+
capturedBody = JSON.parse((options?.body as string) || "{}");
264
+
return jsonResponse({ success: true });
265
+
},
266
+
);
267
+
render(Comms);
219
268
await waitFor(() => {
220
-
expect(screen.getByLabelText(/discord user id/i)).toBeInTheDocument()
221
-
})
222
-
await fireEvent.input(screen.getByLabelText(/discord user id/i), { target: { value: '999888777' } })
223
-
await fireEvent.click(screen.getByRole('button', { name: /save preferences/i }))
269
+
expect(screen.getByLabelText(/discord user id/i)).toBeInTheDocument();
270
+
});
271
+
await fireEvent.input(screen.getByLabelText(/discord user id/i), {
272
+
target: { value: "999888777" },
273
+
});
274
+
await fireEvent.click(
275
+
screen.getByRole("button", { name: /save preferences/i }),
276
+
);
224
277
await waitFor(() => {
225
-
expect(capturedBody).not.toBeNull()
226
-
expect(capturedBody?.discordId).toBe('999888777')
227
-
expect(capturedBody?.preferredChannel).toBe('email')
228
-
})
229
-
})
230
-
it('shows loading state while saving', async () => {
231
-
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
232
-
jsonResponse(mockData.notificationPrefs())
233
-
)
234
-
mockEndpoint('com.tranquil.account.updateNotificationPrefs', async () => {
235
-
await new Promise(resolve => setTimeout(resolve, 100))
236
-
return jsonResponse({ success: true })
237
-
})
238
-
render(Comms)
278
+
expect(capturedBody).not.toBeNull();
279
+
expect(capturedBody?.discordId).toBe("999888777");
280
+
expect(capturedBody?.preferredChannel).toBe("email");
281
+
});
282
+
});
283
+
it("shows loading state while saving", async () => {
284
+
mockEndpoint(
285
+
"com.tranquil.account.getNotificationPrefs",
286
+
() => jsonResponse(mockData.notificationPrefs()),
287
+
);
288
+
mockEndpoint("com.tranquil.account.updateNotificationPrefs", async () => {
289
+
await new Promise((resolve) => setTimeout(resolve, 100));
290
+
return jsonResponse({ success: true });
291
+
});
292
+
render(Comms);
239
293
await waitFor(() => {
240
-
expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument()
241
-
})
242
-
await fireEvent.click(screen.getByRole('button', { name: /save preferences/i }))
243
-
expect(screen.getByRole('button', { name: /saving/i })).toBeInTheDocument()
244
-
expect(screen.getByRole('button', { name: /saving/i })).toBeDisabled()
245
-
})
246
-
it('shows success message after saving', async () => {
247
-
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
248
-
jsonResponse(mockData.notificationPrefs())
249
-
)
250
-
mockEndpoint('com.tranquil.account.updateNotificationPrefs', () =>
251
-
jsonResponse({ success: true })
252
-
)
253
-
render(Comms)
294
+
expect(screen.getByRole("button", { name: /save preferences/i }))
295
+
.toBeInTheDocument();
296
+
});
297
+
await fireEvent.click(
298
+
screen.getByRole("button", { name: /save preferences/i }),
299
+
);
300
+
expect(screen.getByRole("button", { name: /saving/i }))
301
+
.toBeInTheDocument();
302
+
expect(screen.getByRole("button", { name: /saving/i })).toBeDisabled();
303
+
});
304
+
it("shows success message after saving", async () => {
305
+
mockEndpoint(
306
+
"com.tranquil.account.getNotificationPrefs",
307
+
() => jsonResponse(mockData.notificationPrefs()),
308
+
);
309
+
mockEndpoint(
310
+
"com.tranquil.account.updateNotificationPrefs",
311
+
() => jsonResponse({ success: true }),
312
+
);
313
+
render(Comms);
254
314
await waitFor(() => {
255
-
expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument()
256
-
})
257
-
await fireEvent.click(screen.getByRole('button', { name: /save preferences/i }))
315
+
expect(screen.getByRole("button", { name: /save preferences/i }))
316
+
.toBeInTheDocument();
317
+
});
318
+
await fireEvent.click(
319
+
screen.getByRole("button", { name: /save preferences/i }),
320
+
);
258
321
await waitFor(() => {
259
-
expect(screen.getByText(/notification preferences saved/i)).toBeInTheDocument()
260
-
})
261
-
})
262
-
it('shows error when save fails', async () => {
263
-
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
264
-
jsonResponse(mockData.notificationPrefs())
265
-
)
266
-
mockEndpoint('com.tranquil.account.updateNotificationPrefs', () =>
267
-
errorResponse('InvalidRequest', 'Invalid channel configuration', 400)
268
-
)
269
-
render(Comms)
322
+
expect(screen.getByText(/notification preferences saved/i))
323
+
.toBeInTheDocument();
324
+
});
325
+
});
326
+
it("shows error when save fails", async () => {
327
+
mockEndpoint(
328
+
"com.tranquil.account.getNotificationPrefs",
329
+
() => jsonResponse(mockData.notificationPrefs()),
330
+
);
331
+
mockEndpoint(
332
+
"com.tranquil.account.updateNotificationPrefs",
333
+
() =>
334
+
errorResponse("InvalidRequest", "Invalid channel configuration", 400),
335
+
);
336
+
render(Comms);
270
337
await waitFor(() => {
271
-
expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument()
272
-
})
273
-
await fireEvent.click(screen.getByRole('button', { name: /save preferences/i }))
338
+
expect(screen.getByRole("button", { name: /save preferences/i }))
339
+
.toBeInTheDocument();
340
+
});
341
+
await fireEvent.click(
342
+
screen.getByRole("button", { name: /save preferences/i }),
343
+
);
274
344
await waitFor(() => {
275
-
expect(screen.getByText(/invalid channel configuration/i)).toBeInTheDocument()
276
-
expect(screen.getByText(/invalid channel configuration/i).closest('.message')).toHaveClass('error')
277
-
})
278
-
})
279
-
it('reloads preferences after successful save', async () => {
280
-
let loadCount = 0
281
-
mockEndpoint('com.tranquil.account.getNotificationPrefs', () => {
282
-
loadCount++
283
-
return jsonResponse(mockData.notificationPrefs())
284
-
})
285
-
mockEndpoint('com.tranquil.account.updateNotificationPrefs', () =>
286
-
jsonResponse({ success: true })
287
-
)
288
-
render(Comms)
345
+
expect(screen.getByText(/invalid channel configuration/i))
346
+
.toBeInTheDocument();
347
+
expect(
348
+
screen.getByText(/invalid channel configuration/i).closest(
349
+
".message",
350
+
),
351
+
).toHaveClass("error");
352
+
});
353
+
});
354
+
it("reloads preferences after successful save", async () => {
355
+
let loadCount = 0;
356
+
mockEndpoint("com.tranquil.account.getNotificationPrefs", () => {
357
+
loadCount++;
358
+
return jsonResponse(mockData.notificationPrefs());
359
+
});
360
+
mockEndpoint(
361
+
"com.tranquil.account.updateNotificationPrefs",
362
+
() => jsonResponse({ success: true }),
363
+
);
364
+
render(Comms);
289
365
await waitFor(() => {
290
-
expect(screen.getByRole('button', { name: /save preferences/i })).toBeInTheDocument()
291
-
})
292
-
const initialLoadCount = loadCount
293
-
await fireEvent.click(screen.getByRole('button', { name: /save preferences/i }))
366
+
expect(screen.getByRole("button", { name: /save preferences/i }))
367
+
.toBeInTheDocument();
368
+
});
369
+
const initialLoadCount = loadCount;
370
+
await fireEvent.click(
371
+
screen.getByRole("button", { name: /save preferences/i }),
372
+
);
294
373
await waitFor(() => {
295
-
expect(loadCount).toBeGreaterThan(initialLoadCount)
296
-
})
297
-
})
298
-
})
299
-
describe('channel selection interaction', () => {
374
+
expect(loadCount).toBeGreaterThan(initialLoadCount);
375
+
});
376
+
});
377
+
});
378
+
describe("channel selection interaction", () => {
300
379
beforeEach(() => {
301
-
setupAuthenticatedUser()
302
-
})
303
-
it('enables discord channel after entering discord ID', async () => {
304
-
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
305
-
jsonResponse(mockData.notificationPrefs())
306
-
)
307
-
render(Comms)
380
+
setupAuthenticatedUser();
381
+
});
382
+
it("enables discord channel after entering discord ID", async () => {
383
+
mockEndpoint(
384
+
"com.tranquil.account.getNotificationPrefs",
385
+
() => jsonResponse(mockData.notificationPrefs()),
386
+
);
387
+
render(Comms);
308
388
await waitFor(() => {
309
-
expect(screen.getByRole('radio', { name: /discord/i })).toBeDisabled()
310
-
})
311
-
await fireEvent.input(screen.getByLabelText(/discord user id/i), { target: { value: '123456789' } })
389
+
expect(screen.getByRole("radio", { name: /discord/i })).toBeDisabled();
390
+
});
391
+
await fireEvent.input(screen.getByLabelText(/discord user id/i), {
392
+
target: { value: "123456789" },
393
+
});
312
394
await waitFor(() => {
313
-
expect(screen.getByRole('radio', { name: /discord/i })).not.toBeDisabled()
314
-
})
315
-
})
316
-
it('allows selecting a configured channel', async () => {
317
-
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
318
-
jsonResponse(mockData.notificationPrefs({
319
-
discordId: '123456789',
320
-
discordVerified: true,
321
-
}))
322
-
)
323
-
render(Comms)
395
+
expect(screen.getByRole("radio", { name: /discord/i })).not
396
+
.toBeDisabled();
397
+
});
398
+
});
399
+
it("allows selecting a configured channel", async () => {
400
+
mockEndpoint(
401
+
"com.tranquil.account.getNotificationPrefs",
402
+
() =>
403
+
jsonResponse(mockData.notificationPrefs({
404
+
discordId: "123456789",
405
+
discordVerified: true,
406
+
})),
407
+
);
408
+
render(Comms);
324
409
await waitFor(() => {
325
-
expect(screen.getByRole('radio', { name: /discord/i })).not.toBeDisabled()
326
-
})
327
-
await fireEvent.click(screen.getByRole('radio', { name: /discord/i }))
328
-
const discordRadio = screen.getByRole('radio', { name: /discord/i }) as HTMLInputElement
329
-
expect(discordRadio.checked).toBe(true)
330
-
})
331
-
})
332
-
describe('error handling', () => {
410
+
expect(screen.getByRole("radio", { name: /discord/i })).not
411
+
.toBeDisabled();
412
+
});
413
+
await fireEvent.click(screen.getByRole("radio", { name: /discord/i }));
414
+
const discordRadio = screen.getByRole("radio", {
415
+
name: /discord/i,
416
+
}) as HTMLInputElement;
417
+
expect(discordRadio.checked).toBe(true);
418
+
});
419
+
});
420
+
describe("error handling", () => {
333
421
beforeEach(() => {
334
-
setupAuthenticatedUser()
335
-
})
336
-
it('shows error when loading preferences fails', async () => {
337
-
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
338
-
errorResponse('InternalError', 'Database connection failed', 500)
339
-
)
340
-
render(Comms)
422
+
setupAuthenticatedUser();
423
+
});
424
+
it("shows error when loading preferences fails", async () => {
425
+
mockEndpoint(
426
+
"com.tranquil.account.getNotificationPrefs",
427
+
() => errorResponse("InternalError", "Database connection failed", 500),
428
+
);
429
+
render(Comms);
341
430
await waitFor(() => {
342
-
expect(screen.getByText(/database connection failed/i)).toBeInTheDocument()
343
-
})
344
-
})
345
-
})
346
-
})
431
+
expect(screen.getByText(/database connection failed/i))
432
+
.toBeInTheDocument();
433
+
});
434
+
});
435
+
});
436
+
});
+96
-92
frontend/src/tests/Dashboard.test.ts
+96
-92
frontend/src/tests/Dashboard.test.ts
···
1
-
import { describe, it, expect, beforeEach } from 'vitest'
2
-
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'
3
-
import Dashboard from '../routes/Dashboard.svelte'
1
+
import { beforeEach, describe, expect, it } from "vitest";
2
+
import { fireEvent, render, screen, waitFor } from "@testing-library/svelte";
3
+
import Dashboard from "../routes/Dashboard.svelte";
4
4
import {
5
-
setupFetchMock,
6
-
mockEndpoint,
5
+
clearMocks,
7
6
jsonResponse,
8
7
mockData,
9
-
clearMocks,
8
+
mockEndpoint,
10
9
setupAuthenticatedUser,
10
+
setupFetchMock,
11
11
setupUnauthenticatedUser,
12
-
} from './mocks'
13
-
const STORAGE_KEY = 'tranquil_pds_session'
14
-
describe('Dashboard', () => {
12
+
} from "./mocks";
13
+
const STORAGE_KEY = "tranquil_pds_session";
14
+
describe("Dashboard", () => {
15
15
beforeEach(() => {
16
-
clearMocks()
17
-
setupFetchMock()
18
-
})
19
-
describe('authentication guard', () => {
20
-
it('redirects to login when not authenticated', async () => {
21
-
setupUnauthenticatedUser()
22
-
render(Dashboard)
16
+
clearMocks();
17
+
setupFetchMock();
18
+
});
19
+
describe("authentication guard", () => {
20
+
it("redirects to login when not authenticated", async () => {
21
+
setupUnauthenticatedUser();
22
+
render(Dashboard);
23
23
await waitFor(() => {
24
-
expect(window.location.hash).toBe('#/login')
25
-
})
26
-
})
27
-
it('shows loading state while checking auth', () => {
28
-
render(Dashboard)
29
-
expect(screen.getByText(/loading/i)).toBeInTheDocument()
30
-
})
31
-
})
32
-
describe('authenticated view', () => {
24
+
expect(window.location.hash).toBe("#/login");
25
+
});
26
+
});
27
+
it("shows loading state while checking auth", () => {
28
+
render(Dashboard);
29
+
expect(screen.getByText(/loading/i)).toBeInTheDocument();
30
+
});
31
+
});
32
+
describe("authenticated view", () => {
33
33
beforeEach(() => {
34
-
setupAuthenticatedUser()
35
-
})
36
-
it('displays user account info and page structure', async () => {
37
-
render(Dashboard)
34
+
setupAuthenticatedUser();
35
+
});
36
+
it("displays user account info and page structure", async () => {
37
+
render(Dashboard);
38
38
await waitFor(() => {
39
-
expect(screen.getByRole('heading', { name: /dashboard/i })).toBeInTheDocument()
40
-
expect(screen.getByRole('heading', { name: /account overview/i })).toBeInTheDocument()
41
-
expect(screen.getByText(/@testuser\.test\.tranquil\.dev/)).toBeInTheDocument()
42
-
expect(screen.getByText(/did:web:test\.tranquil\.dev:u:testuser/)).toBeInTheDocument()
43
-
expect(screen.getByText('test@example.com')).toBeInTheDocument()
44
-
expect(screen.getByText('Verified')).toBeInTheDocument()
45
-
expect(screen.getByText('Verified')).toHaveClass('badge', 'success')
46
-
})
47
-
})
48
-
it('displays unverified badge when email not confirmed', async () => {
49
-
setupAuthenticatedUser({ emailConfirmed: false })
50
-
render(Dashboard)
39
+
expect(screen.getByRole("heading", { name: /dashboard/i }))
40
+
.toBeInTheDocument();
41
+
expect(screen.getByRole("heading", { name: /account overview/i }))
42
+
.toBeInTheDocument();
43
+
expect(screen.getByText(/@testuser\.test\.tranquil\.dev/))
44
+
.toBeInTheDocument();
45
+
expect(screen.getByText(/did:web:test\.tranquil\.dev:u:testuser/))
46
+
.toBeInTheDocument();
47
+
expect(screen.getByText("test@example.com")).toBeInTheDocument();
48
+
expect(screen.getByText("Verified")).toBeInTheDocument();
49
+
expect(screen.getByText("Verified")).toHaveClass("badge", "success");
50
+
});
51
+
});
52
+
it("displays unverified badge when email not confirmed", async () => {
53
+
setupAuthenticatedUser({ emailConfirmed: false });
54
+
render(Dashboard);
51
55
await waitFor(() => {
52
-
expect(screen.getByText('Unverified')).toBeInTheDocument()
53
-
expect(screen.getByText('Unverified')).toHaveClass('badge', 'warning')
54
-
})
55
-
})
56
-
it('displays all navigation cards', async () => {
57
-
render(Dashboard)
56
+
expect(screen.getByText("Unverified")).toBeInTheDocument();
57
+
expect(screen.getByText("Unverified")).toHaveClass("badge", "warning");
58
+
});
59
+
});
60
+
it("displays all navigation cards", async () => {
61
+
render(Dashboard);
58
62
await waitFor(() => {
59
63
const navCards = [
60
-
{ name: /app passwords/i, href: '#/app-passwords' },
61
-
{ name: /invite codes/i, href: '#/invite-codes' },
62
-
{ name: /account settings/i, href: '#/settings' },
63
-
{ name: /communication preferences/i, href: '#/comms' },
64
-
{ name: /repository explorer/i, href: '#/repo' },
65
-
]
64
+
{ name: /app passwords/i, href: "#/app-passwords" },
65
+
{ name: /invite codes/i, href: "#/invite-codes" },
66
+
{ name: /account settings/i, href: "#/settings" },
67
+
{ name: /communication preferences/i, href: "#/comms" },
68
+
{ name: /repository explorer/i, href: "#/repo" },
69
+
];
66
70
for (const { name, href } of navCards) {
67
-
const card = screen.getByRole('link', { name })
68
-
expect(card).toBeInTheDocument()
69
-
expect(card).toHaveAttribute('href', href)
71
+
const card = screen.getByRole("link", { name });
72
+
expect(card).toBeInTheDocument();
73
+
expect(card).toHaveAttribute("href", href);
70
74
}
71
-
})
72
-
})
73
-
})
74
-
describe('logout functionality', () => {
75
+
});
76
+
});
77
+
});
78
+
describe("logout functionality", () => {
75
79
beforeEach(() => {
76
-
setupAuthenticatedUser()
77
-
localStorage.setItem(STORAGE_KEY, JSON.stringify(mockData.session()))
78
-
mockEndpoint('com.atproto.server.deleteSession', () =>
79
-
jsonResponse({})
80
-
)
81
-
})
82
-
it('calls deleteSession and navigates to login on logout', async () => {
83
-
let deleteSessionCalled = false
84
-
mockEndpoint('com.atproto.server.deleteSession', () => {
85
-
deleteSessionCalled = true
86
-
return jsonResponse({})
87
-
})
88
-
render(Dashboard)
80
+
setupAuthenticatedUser();
81
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(mockData.session()));
82
+
mockEndpoint("com.atproto.server.deleteSession", () => jsonResponse({}));
83
+
});
84
+
it("calls deleteSession and navigates to login on logout", async () => {
85
+
let deleteSessionCalled = false;
86
+
mockEndpoint("com.atproto.server.deleteSession", () => {
87
+
deleteSessionCalled = true;
88
+
return jsonResponse({});
89
+
});
90
+
render(Dashboard);
89
91
await waitFor(() => {
90
-
expect(screen.getByRole('button', { name: /sign out/i })).toBeInTheDocument()
91
-
})
92
-
await fireEvent.click(screen.getByRole('button', { name: /sign out/i }))
92
+
expect(screen.getByRole("button", { name: /sign out/i }))
93
+
.toBeInTheDocument();
94
+
});
95
+
await fireEvent.click(screen.getByRole("button", { name: /sign out/i }));
93
96
await waitFor(() => {
94
-
expect(deleteSessionCalled).toBe(true)
95
-
expect(window.location.hash).toBe('#/login')
96
-
})
97
-
})
98
-
it('clears session from localStorage after logout', async () => {
99
-
const storedSession = localStorage.getItem(STORAGE_KEY)
100
-
expect(storedSession).not.toBeNull()
101
-
render(Dashboard)
97
+
expect(deleteSessionCalled).toBe(true);
98
+
expect(window.location.hash).toBe("#/login");
99
+
});
100
+
});
101
+
it("clears session from localStorage after logout", async () => {
102
+
const storedSession = localStorage.getItem(STORAGE_KEY);
103
+
expect(storedSession).not.toBeNull();
104
+
render(Dashboard);
102
105
await waitFor(() => {
103
-
expect(screen.getByRole('button', { name: /sign out/i })).toBeInTheDocument()
104
-
})
105
-
await fireEvent.click(screen.getByRole('button', { name: /sign out/i }))
106
+
expect(screen.getByRole("button", { name: /sign out/i }))
107
+
.toBeInTheDocument();
108
+
});
109
+
await fireEvent.click(screen.getByRole("button", { name: /sign out/i }));
106
110
await waitFor(() => {
107
-
expect(localStorage.getItem(STORAGE_KEY)).toBeNull()
108
-
})
109
-
})
110
-
})
111
-
})
111
+
expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
112
+
});
113
+
});
114
+
});
115
+
});
+159
-117
frontend/src/tests/Login.test.ts
+159
-117
frontend/src/tests/Login.test.ts
···
1
-
import { describe, it, expect, beforeEach } from 'vitest'
2
-
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'
3
-
import Login from '../routes/Login.svelte'
1
+
import { beforeEach, describe, expect, it } from "vitest";
2
+
import { fireEvent, render, screen, waitFor } from "@testing-library/svelte";
3
+
import Login from "../routes/Login.svelte";
4
4
import {
5
-
setupFetchMock,
6
-
mockEndpoint,
7
-
jsonResponse,
5
+
clearMocks,
8
6
errorResponse,
7
+
jsonResponse,
9
8
mockData,
10
-
clearMocks,
11
-
} from './mocks'
12
-
describe('Login', () => {
9
+
mockEndpoint,
10
+
setupFetchMock,
11
+
} from "./mocks";
12
+
describe("Login", () => {
13
13
beforeEach(() => {
14
-
clearMocks()
15
-
setupFetchMock()
16
-
window.location.hash = ''
17
-
})
18
-
describe('initial render', () => {
19
-
it('renders login form with all elements and correct initial state', () => {
20
-
render(Login)
21
-
expect(screen.getByRole('heading', { name: /sign in/i })).toBeInTheDocument()
22
-
expect(screen.getByLabelText(/handle or email/i)).toBeInTheDocument()
23
-
expect(screen.getByLabelText(/password/i)).toBeInTheDocument()
24
-
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument()
25
-
expect(screen.getByRole('button', { name: /sign in/i })).toBeDisabled()
26
-
expect(screen.getByText(/don't have an account/i)).toBeInTheDocument()
27
-
expect(screen.getByRole('link', { name: /create one/i })).toHaveAttribute('href', '#/register')
28
-
})
29
-
})
30
-
describe('form validation', () => {
31
-
it('enables submit button only when both fields are filled', async () => {
32
-
render(Login)
33
-
const identifierInput = screen.getByLabelText(/handle or email/i)
34
-
const passwordInput = screen.getByLabelText(/password/i)
35
-
const submitButton = screen.getByRole('button', { name: /sign in/i })
36
-
await fireEvent.input(identifierInput, { target: { value: 'testuser' } })
37
-
expect(submitButton).toBeDisabled()
38
-
await fireEvent.input(identifierInput, { target: { value: '' } })
39
-
await fireEvent.input(passwordInput, { target: { value: 'password123' } })
40
-
expect(submitButton).toBeDisabled()
41
-
await fireEvent.input(identifierInput, { target: { value: 'testuser' } })
42
-
expect(submitButton).not.toBeDisabled()
43
-
})
44
-
})
45
-
describe('login submission', () => {
46
-
it('calls createSession with correct credentials', async () => {
47
-
let capturedBody: Record<string, string> | null = null
48
-
mockEndpoint('com.atproto.server.createSession', (_url, options) => {
49
-
capturedBody = JSON.parse((options?.body as string) || '{}')
50
-
return jsonResponse(mockData.session())
51
-
})
52
-
render(Login)
53
-
await fireEvent.input(screen.getByLabelText(/handle or email/i), { target: { value: 'testuser@example.com' } })
54
-
await fireEvent.input(screen.getByLabelText(/password/i), { target: { value: 'mypassword' } })
55
-
await fireEvent.click(screen.getByRole('button', { name: /sign in/i }))
14
+
clearMocks();
15
+
setupFetchMock();
16
+
window.location.hash = "";
17
+
});
18
+
describe("initial render", () => {
19
+
it("renders login form with all elements and correct initial state", () => {
20
+
render(Login);
21
+
expect(screen.getByRole("heading", { name: /sign in/i }))
22
+
.toBeInTheDocument();
23
+
expect(screen.getByLabelText(/handle or email/i)).toBeInTheDocument();
24
+
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
25
+
expect(screen.getByRole("button", { name: /sign in/i }))
26
+
.toBeInTheDocument();
27
+
expect(screen.getByRole("button", { name: /sign in/i })).toBeDisabled();
28
+
expect(screen.getByText(/don't have an account/i)).toBeInTheDocument();
29
+
expect(screen.getByRole("link", { name: /create one/i })).toHaveAttribute(
30
+
"href",
31
+
"#/register",
32
+
);
33
+
});
34
+
});
35
+
describe("form validation", () => {
36
+
it("enables submit button only when both fields are filled", async () => {
37
+
render(Login);
38
+
const identifierInput = screen.getByLabelText(/handle or email/i);
39
+
const passwordInput = screen.getByLabelText(/password/i);
40
+
const submitButton = screen.getByRole("button", { name: /sign in/i });
41
+
await fireEvent.input(identifierInput, { target: { value: "testuser" } });
42
+
expect(submitButton).toBeDisabled();
43
+
await fireEvent.input(identifierInput, { target: { value: "" } });
44
+
await fireEvent.input(passwordInput, {
45
+
target: { value: "password123" },
46
+
});
47
+
expect(submitButton).toBeDisabled();
48
+
await fireEvent.input(identifierInput, { target: { value: "testuser" } });
49
+
expect(submitButton).not.toBeDisabled();
50
+
});
51
+
});
52
+
describe("login submission", () => {
53
+
it("calls createSession with correct credentials", async () => {
54
+
let capturedBody: Record<string, string> | null = null;
55
+
mockEndpoint("com.atproto.server.createSession", (_url, options) => {
56
+
capturedBody = JSON.parse((options?.body as string) || "{}");
57
+
return jsonResponse(mockData.session());
58
+
});
59
+
render(Login);
60
+
await fireEvent.input(screen.getByLabelText(/handle or email/i), {
61
+
target: { value: "testuser@example.com" },
62
+
});
63
+
await fireEvent.input(screen.getByLabelText(/password/i), {
64
+
target: { value: "mypassword" },
65
+
});
66
+
await fireEvent.click(screen.getByRole("button", { name: /sign in/i }));
56
67
await waitFor(() => {
57
68
expect(capturedBody).toEqual({
58
-
identifier: 'testuser@example.com',
59
-
password: 'mypassword',
60
-
})
61
-
})
62
-
})
63
-
it('shows styled error message on invalid credentials', async () => {
64
-
mockEndpoint('com.atproto.server.createSession', () =>
65
-
errorResponse('AuthenticationRequired', 'Invalid identifier or password', 401)
66
-
)
67
-
render(Login)
68
-
await fireEvent.input(screen.getByLabelText(/handle or email/i), { target: { value: 'wronguser' } })
69
-
await fireEvent.input(screen.getByLabelText(/password/i), { target: { value: 'wrongpassword' } })
70
-
await fireEvent.click(screen.getByRole('button', { name: /sign in/i }))
69
+
identifier: "testuser@example.com",
70
+
password: "mypassword",
71
+
});
72
+
});
73
+
});
74
+
it("shows styled error message on invalid credentials", async () => {
75
+
mockEndpoint(
76
+
"com.atproto.server.createSession",
77
+
() =>
78
+
errorResponse(
79
+
"AuthenticationRequired",
80
+
"Invalid identifier or password",
81
+
401,
82
+
),
83
+
);
84
+
render(Login);
85
+
await fireEvent.input(screen.getByLabelText(/handle or email/i), {
86
+
target: { value: "wronguser" },
87
+
});
88
+
await fireEvent.input(screen.getByLabelText(/password/i), {
89
+
target: { value: "wrongpassword" },
90
+
});
91
+
await fireEvent.click(screen.getByRole("button", { name: /sign in/i }));
71
92
await waitFor(() => {
72
-
const errorDiv = screen.getByText(/invalid identifier or password/i)
73
-
expect(errorDiv).toBeInTheDocument()
74
-
expect(errorDiv).toHaveClass('error')
75
-
})
76
-
})
77
-
it('navigates to dashboard on successful login', async () => {
78
-
mockEndpoint('com.atproto.server.createSession', () =>
79
-
jsonResponse(mockData.session())
80
-
)
81
-
render(Login)
82
-
await fireEvent.input(screen.getByLabelText(/handle or email/i), { target: { value: 'test' } })
83
-
await fireEvent.input(screen.getByLabelText(/password/i), { target: { value: 'password' } })
84
-
await fireEvent.click(screen.getByRole('button', { name: /sign in/i }))
93
+
const errorDiv = screen.getByText(/invalid identifier or password/i);
94
+
expect(errorDiv).toBeInTheDocument();
95
+
expect(errorDiv).toHaveClass("error");
96
+
});
97
+
});
98
+
it("navigates to dashboard on successful login", async () => {
99
+
mockEndpoint(
100
+
"com.atproto.server.createSession",
101
+
() => jsonResponse(mockData.session()),
102
+
);
103
+
render(Login);
104
+
await fireEvent.input(screen.getByLabelText(/handle or email/i), {
105
+
target: { value: "test" },
106
+
});
107
+
await fireEvent.input(screen.getByLabelText(/password/i), {
108
+
target: { value: "password" },
109
+
});
110
+
await fireEvent.click(screen.getByRole("button", { name: /sign in/i }));
85
111
await waitFor(() => {
86
-
expect(window.location.hash).toBe('#/dashboard')
87
-
})
88
-
})
89
-
})
90
-
describe('account verification flow', () => {
91
-
it('shows verification form with all controls when account is not verified', async () => {
92
-
mockEndpoint('com.atproto.server.createSession', () => ({
112
+
expect(window.location.hash).toBe("#/dashboard");
113
+
});
114
+
});
115
+
});
116
+
describe("account verification flow", () => {
117
+
it("shows verification form with all controls when account is not verified", async () => {
118
+
mockEndpoint("com.atproto.server.createSession", () => ({
93
119
ok: false,
94
120
status: 401,
95
121
json: async () => ({
96
-
error: 'AccountNotVerified',
97
-
message: 'Account not verified',
98
-
did: 'did:web:test.tranquil.dev:u:testuser',
122
+
error: "AccountNotVerified",
123
+
message: "Account not verified",
124
+
did: "did:web:test.tranquil.dev:u:testuser",
99
125
}),
100
-
}))
101
-
render(Login)
102
-
await fireEvent.input(screen.getByLabelText(/handle or email/i), { target: { value: 'unverified@test.com' } })
103
-
await fireEvent.input(screen.getByLabelText(/password/i), { target: { value: 'password' } })
104
-
await fireEvent.click(screen.getByRole('button', { name: /sign in/i }))
126
+
}));
127
+
render(Login);
128
+
await fireEvent.input(screen.getByLabelText(/handle or email/i), {
129
+
target: { value: "unverified@test.com" },
130
+
});
131
+
await fireEvent.input(screen.getByLabelText(/password/i), {
132
+
target: { value: "password" },
133
+
});
134
+
await fireEvent.click(screen.getByRole("button", { name: /sign in/i }));
105
135
await waitFor(() => {
106
-
expect(screen.getByRole('heading', { name: /verify your account/i })).toBeInTheDocument()
107
-
expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument()
108
-
expect(screen.getByRole('button', { name: /resend code/i })).toBeInTheDocument()
109
-
expect(screen.getByRole('button', { name: /back to login/i })).toBeInTheDocument()
110
-
})
111
-
})
112
-
it('returns to login form when clicking back', async () => {
113
-
mockEndpoint('com.atproto.server.createSession', () => ({
136
+
expect(screen.getByRole("heading", { name: /verify your account/i }))
137
+
.toBeInTheDocument();
138
+
expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument();
139
+
expect(screen.getByRole("button", { name: /resend code/i }))
140
+
.toBeInTheDocument();
141
+
expect(screen.getByRole("button", { name: /back to login/i }))
142
+
.toBeInTheDocument();
143
+
});
144
+
});
145
+
it("returns to login form when clicking back", async () => {
146
+
mockEndpoint("com.atproto.server.createSession", () => ({
114
147
ok: false,
115
148
status: 401,
116
149
json: async () => ({
117
-
error: 'AccountNotVerified',
118
-
message: 'Account not verified',
119
-
did: 'did:web:test.tranquil.dev:u:testuser',
150
+
error: "AccountNotVerified",
151
+
message: "Account not verified",
152
+
did: "did:web:test.tranquil.dev:u:testuser",
120
153
}),
121
-
}))
122
-
render(Login)
123
-
await fireEvent.input(screen.getByLabelText(/handle or email/i), { target: { value: 'test' } })
124
-
await fireEvent.input(screen.getByLabelText(/password/i), { target: { value: 'password' } })
125
-
await fireEvent.click(screen.getByRole('button', { name: /sign in/i }))
154
+
}));
155
+
render(Login);
156
+
await fireEvent.input(screen.getByLabelText(/handle or email/i), {
157
+
target: { value: "test" },
158
+
});
159
+
await fireEvent.input(screen.getByLabelText(/password/i), {
160
+
target: { value: "password" },
161
+
});
162
+
await fireEvent.click(screen.getByRole("button", { name: /sign in/i }));
126
163
await waitFor(() => {
127
-
expect(screen.getByRole('button', { name: /back to login/i })).toBeInTheDocument()
128
-
})
129
-
await fireEvent.click(screen.getByRole('button', { name: /back to login/i }))
164
+
expect(screen.getByRole("button", { name: /back to login/i }))
165
+
.toBeInTheDocument();
166
+
});
167
+
await fireEvent.click(
168
+
screen.getByRole("button", { name: /back to login/i }),
169
+
);
130
170
await waitFor(() => {
131
-
expect(screen.getByRole('heading', { name: /sign in/i })).toBeInTheDocument()
132
-
expect(screen.queryByLabelText(/verification code/i)).not.toBeInTheDocument()
133
-
})
134
-
})
135
-
})
136
-
})
171
+
expect(screen.getByRole("heading", { name: /sign in/i }))
172
+
.toBeInTheDocument();
173
+
expect(screen.queryByLabelText(/verification code/i)).not
174
+
.toBeInTheDocument();
175
+
});
176
+
});
177
+
});
178
+
});
+462
-333
frontend/src/tests/Settings.test.ts
+462
-333
frontend/src/tests/Settings.test.ts
···
1
-
import { describe, it, expect, beforeEach, vi } from 'vitest'
2
-
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'
3
-
import Settings from '../routes/Settings.svelte'
1
+
import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
import { fireEvent, render, screen, waitFor } from "@testing-library/svelte";
3
+
import Settings from "../routes/Settings.svelte";
4
4
import {
5
-
setupFetchMock,
6
-
mockEndpoint,
7
-
jsonResponse,
5
+
clearMocks,
8
6
errorResponse,
9
-
clearMocks,
7
+
jsonResponse,
8
+
mockEndpoint,
10
9
setupAuthenticatedUser,
10
+
setupFetchMock,
11
11
setupUnauthenticatedUser,
12
-
} from './mocks'
13
-
describe('Settings', () => {
12
+
} from "./mocks";
13
+
describe("Settings", () => {
14
14
beforeEach(() => {
15
-
clearMocks()
16
-
setupFetchMock()
17
-
window.confirm = vi.fn(() => true)
18
-
})
19
-
describe('authentication guard', () => {
20
-
it('redirects to login when not authenticated', async () => {
21
-
setupUnauthenticatedUser()
22
-
render(Settings)
15
+
clearMocks();
16
+
setupFetchMock();
17
+
window.confirm = vi.fn(() => true);
18
+
});
19
+
describe("authentication guard", () => {
20
+
it("redirects to login when not authenticated", async () => {
21
+
setupUnauthenticatedUser();
22
+
render(Settings);
23
23
await waitFor(() => {
24
-
expect(window.location.hash).toBe('#/login')
25
-
})
26
-
})
27
-
})
28
-
describe('page structure', () => {
24
+
expect(window.location.hash).toBe("#/login");
25
+
});
26
+
});
27
+
});
28
+
describe("page structure", () => {
29
29
beforeEach(() => {
30
-
setupAuthenticatedUser()
31
-
})
32
-
it('displays all page elements and sections', async () => {
33
-
render(Settings)
30
+
setupAuthenticatedUser();
31
+
});
32
+
it("displays all page elements and sections", async () => {
33
+
render(Settings);
34
34
await waitFor(() => {
35
-
expect(screen.getByRole('heading', { name: /account settings/i, level: 1 })).toBeInTheDocument()
36
-
expect(screen.getByRole('link', { name: /dashboard/i })).toHaveAttribute('href', '#/dashboard')
37
-
expect(screen.getByRole('heading', { name: /change email/i })).toBeInTheDocument()
38
-
expect(screen.getByRole('heading', { name: /change handle/i })).toBeInTheDocument()
39
-
expect(screen.getByRole('heading', { name: /delete account/i })).toBeInTheDocument()
40
-
})
41
-
})
42
-
})
43
-
describe('email change', () => {
35
+
expect(
36
+
screen.getByRole("heading", { name: /account settings/i, level: 1 }),
37
+
).toBeInTheDocument();
38
+
expect(screen.getByRole("link", { name: /dashboard/i }))
39
+
.toHaveAttribute("href", "#/dashboard");
40
+
expect(screen.getByRole("heading", { name: /change email/i }))
41
+
.toBeInTheDocument();
42
+
expect(screen.getByRole("heading", { name: /change handle/i }))
43
+
.toBeInTheDocument();
44
+
expect(screen.getByRole("heading", { name: /delete account/i }))
45
+
.toBeInTheDocument();
46
+
});
47
+
});
48
+
});
49
+
describe("email change", () => {
44
50
beforeEach(() => {
45
-
setupAuthenticatedUser()
46
-
})
47
-
it('displays current email and input field', async () => {
48
-
render(Settings)
51
+
setupAuthenticatedUser();
52
+
});
53
+
it("displays current email and input field", async () => {
54
+
render(Settings);
49
55
await waitFor(() => {
50
-
expect(screen.getByText(/current: test@example.com/i)).toBeInTheDocument()
51
-
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
52
-
})
53
-
})
54
-
it('calls requestEmailUpdate when submitting', async () => {
55
-
let requestCalled = false
56
-
mockEndpoint('com.atproto.server.requestEmailUpdate', () => {
57
-
requestCalled = true
58
-
return jsonResponse({ tokenRequired: true })
59
-
})
60
-
render(Settings)
56
+
expect(screen.getByText(/current: test@example.com/i))
57
+
.toBeInTheDocument();
58
+
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument();
59
+
});
60
+
});
61
+
it("calls requestEmailUpdate when submitting", async () => {
62
+
let requestCalled = false;
63
+
mockEndpoint("com.atproto.server.requestEmailUpdate", () => {
64
+
requestCalled = true;
65
+
return jsonResponse({ tokenRequired: true });
66
+
});
67
+
render(Settings);
61
68
await waitFor(() => {
62
-
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
63
-
})
64
-
await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'newemail@example.com' } })
65
-
await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
69
+
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument();
70
+
});
71
+
await fireEvent.input(screen.getByLabelText(/new email/i), {
72
+
target: { value: "newemail@example.com" },
73
+
});
74
+
await fireEvent.click(
75
+
screen.getByRole("button", { name: /change email/i }),
76
+
);
66
77
await waitFor(() => {
67
-
expect(requestCalled).toBe(true)
68
-
})
69
-
})
70
-
it('shows verification code input when token is required', async () => {
71
-
mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
72
-
jsonResponse({ tokenRequired: true })
73
-
)
74
-
render(Settings)
78
+
expect(requestCalled).toBe(true);
79
+
});
80
+
});
81
+
it("shows verification code input when token is required", async () => {
82
+
mockEndpoint(
83
+
"com.atproto.server.requestEmailUpdate",
84
+
() => jsonResponse({ tokenRequired: true }),
85
+
);
86
+
render(Settings);
75
87
await waitFor(() => {
76
-
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
77
-
})
78
-
await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'newemail@example.com' } })
79
-
await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
88
+
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument();
89
+
});
90
+
await fireEvent.input(screen.getByLabelText(/new email/i), {
91
+
target: { value: "newemail@example.com" },
92
+
});
93
+
await fireEvent.click(
94
+
screen.getByRole("button", { name: /change email/i }),
95
+
);
80
96
await waitFor(() => {
81
-
expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument()
82
-
expect(screen.getByRole('button', { name: /confirm email change/i })).toBeInTheDocument()
83
-
})
84
-
})
85
-
it('calls updateEmail with token when confirming', async () => {
86
-
let updateCalled = false
87
-
let capturedBody: Record<string, string> | null = null
88
-
mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
89
-
jsonResponse({ tokenRequired: true })
90
-
)
91
-
mockEndpoint('com.atproto.server.updateEmail', (_url, options) => {
92
-
updateCalled = true
93
-
capturedBody = JSON.parse((options?.body as string) || '{}')
94
-
return jsonResponse({})
95
-
})
96
-
render(Settings)
97
+
expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument();
98
+
expect(screen.getByRole("button", { name: /confirm email change/i }))
99
+
.toBeInTheDocument();
100
+
});
101
+
});
102
+
it("calls updateEmail with token when confirming", async () => {
103
+
let updateCalled = false;
104
+
let capturedBody: Record<string, string> | null = null;
105
+
mockEndpoint(
106
+
"com.atproto.server.requestEmailUpdate",
107
+
() => jsonResponse({ tokenRequired: true }),
108
+
);
109
+
mockEndpoint("com.atproto.server.updateEmail", (_url, options) => {
110
+
updateCalled = true;
111
+
capturedBody = JSON.parse((options?.body as string) || "{}");
112
+
return jsonResponse({});
113
+
});
114
+
render(Settings);
97
115
await waitFor(() => {
98
-
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
99
-
})
100
-
await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'newemail@example.com' } })
101
-
await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
116
+
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument();
117
+
});
118
+
await fireEvent.input(screen.getByLabelText(/new email/i), {
119
+
target: { value: "newemail@example.com" },
120
+
});
121
+
await fireEvent.click(
122
+
screen.getByRole("button", { name: /change email/i }),
123
+
);
102
124
await waitFor(() => {
103
-
expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument()
104
-
})
105
-
await fireEvent.input(screen.getByLabelText(/verification code/i), { target: { value: '123456' } })
106
-
await fireEvent.click(screen.getByRole('button', { name: /confirm email change/i }))
125
+
expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument();
126
+
});
127
+
await fireEvent.input(screen.getByLabelText(/verification code/i), {
128
+
target: { value: "123456" },
129
+
});
130
+
await fireEvent.click(
131
+
screen.getByRole("button", { name: /confirm email change/i }),
132
+
);
107
133
await waitFor(() => {
108
-
expect(updateCalled).toBe(true)
109
-
expect(capturedBody?.email).toBe('newemail@example.com')
110
-
expect(capturedBody?.token).toBe('123456')
111
-
})
112
-
})
113
-
it('shows success message after email update', async () => {
114
-
mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
115
-
jsonResponse({ tokenRequired: true })
116
-
)
117
-
mockEndpoint('com.atproto.server.updateEmail', () =>
118
-
jsonResponse({})
119
-
)
120
-
render(Settings)
134
+
expect(updateCalled).toBe(true);
135
+
expect(capturedBody?.email).toBe("newemail@example.com");
136
+
expect(capturedBody?.token).toBe("123456");
137
+
});
138
+
});
139
+
it("shows success message after email update", async () => {
140
+
mockEndpoint(
141
+
"com.atproto.server.requestEmailUpdate",
142
+
() => jsonResponse({ tokenRequired: true }),
143
+
);
144
+
mockEndpoint("com.atproto.server.updateEmail", () => jsonResponse({}));
145
+
render(Settings);
121
146
await waitFor(() => {
122
-
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
123
-
})
124
-
await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'new@test.com' } })
125
-
await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
147
+
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument();
148
+
});
149
+
await fireEvent.input(screen.getByLabelText(/new email/i), {
150
+
target: { value: "new@test.com" },
151
+
});
152
+
await fireEvent.click(
153
+
screen.getByRole("button", { name: /change email/i }),
154
+
);
126
155
await waitFor(() => {
127
-
expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument()
128
-
})
129
-
await fireEvent.input(screen.getByLabelText(/verification code/i), { target: { value: '123456' } })
130
-
await fireEvent.click(screen.getByRole('button', { name: /confirm email change/i }))
156
+
expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument();
157
+
});
158
+
await fireEvent.input(screen.getByLabelText(/verification code/i), {
159
+
target: { value: "123456" },
160
+
});
161
+
await fireEvent.click(
162
+
screen.getByRole("button", { name: /confirm email change/i }),
163
+
);
131
164
await waitFor(() => {
132
-
expect(screen.getByText(/email updated successfully/i)).toBeInTheDocument()
133
-
})
134
-
})
135
-
it('shows cancel button to return to email form', async () => {
136
-
mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
137
-
jsonResponse({ tokenRequired: true })
138
-
)
139
-
render(Settings)
165
+
expect(screen.getByText(/email updated successfully/i))
166
+
.toBeInTheDocument();
167
+
});
168
+
});
169
+
it("shows cancel button to return to email form", async () => {
170
+
mockEndpoint(
171
+
"com.atproto.server.requestEmailUpdate",
172
+
() => jsonResponse({ tokenRequired: true }),
173
+
);
174
+
render(Settings);
140
175
await waitFor(() => {
141
-
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
142
-
})
143
-
await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'new@test.com' } })
144
-
await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
176
+
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument();
177
+
});
178
+
await fireEvent.input(screen.getByLabelText(/new email/i), {
179
+
target: { value: "new@test.com" },
180
+
});
181
+
await fireEvent.click(
182
+
screen.getByRole("button", { name: /change email/i }),
183
+
);
145
184
await waitFor(() => {
146
-
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
147
-
})
148
-
await fireEvent.click(screen.getByRole('button', { name: /cancel/i }))
185
+
expect(screen.getByRole("button", { name: /cancel/i }))
186
+
.toBeInTheDocument();
187
+
});
188
+
await fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
149
189
await waitFor(() => {
150
-
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
151
-
expect(screen.queryByLabelText(/verification code/i)).not.toBeInTheDocument()
152
-
})
153
-
})
154
-
it('shows error when email update fails', async () => {
155
-
mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
156
-
errorResponse('InvalidEmail', 'Invalid email format', 400)
157
-
)
158
-
render(Settings)
190
+
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument();
191
+
expect(screen.queryByLabelText(/verification code/i)).not
192
+
.toBeInTheDocument();
193
+
});
194
+
});
195
+
it("shows error when email update fails", async () => {
196
+
mockEndpoint(
197
+
"com.atproto.server.requestEmailUpdate",
198
+
() => errorResponse("InvalidEmail", "Invalid email format", 400),
199
+
);
200
+
render(Settings);
159
201
await waitFor(() => {
160
-
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument()
161
-
})
162
-
await fireEvent.input(screen.getByLabelText(/new email/i), { target: { value: 'invalid@test.com' } })
202
+
expect(screen.getByLabelText(/new email/i)).toBeInTheDocument();
203
+
});
204
+
await fireEvent.input(screen.getByLabelText(/new email/i), {
205
+
target: { value: "invalid@test.com" },
206
+
});
163
207
await waitFor(() => {
164
-
expect(screen.getByRole('button', { name: /change email/i })).not.toBeDisabled()
165
-
})
166
-
await fireEvent.click(screen.getByRole('button', { name: /change email/i }))
208
+
expect(screen.getByRole("button", { name: /change email/i })).not
209
+
.toBeDisabled();
210
+
});
211
+
await fireEvent.click(
212
+
screen.getByRole("button", { name: /change email/i }),
213
+
);
167
214
await waitFor(() => {
168
-
expect(screen.getByText(/invalid email format/i)).toBeInTheDocument()
169
-
})
170
-
})
171
-
})
172
-
describe('handle change', () => {
215
+
expect(screen.getByText(/invalid email format/i)).toBeInTheDocument();
216
+
});
217
+
});
218
+
});
219
+
describe("handle change", () => {
173
220
beforeEach(() => {
174
-
setupAuthenticatedUser()
175
-
})
176
-
it('displays current handle', async () => {
177
-
render(Settings)
221
+
setupAuthenticatedUser();
222
+
});
223
+
it("displays current handle", async () => {
224
+
render(Settings);
178
225
await waitFor(() => {
179
-
expect(screen.getByText(/current: @testuser\.test\.tranquil\.dev/i)).toBeInTheDocument()
180
-
})
181
-
})
182
-
it('calls updateHandle with new handle', async () => {
183
-
let capturedHandle: string | null = null
184
-
mockEndpoint('com.atproto.identity.updateHandle', (_url, options) => {
185
-
const body = JSON.parse((options?.body as string) || '{}')
186
-
capturedHandle = body.handle
187
-
return jsonResponse({})
188
-
})
189
-
render(Settings)
226
+
expect(screen.getByText(/current: @testuser\.test\.tranquil\.dev/i))
227
+
.toBeInTheDocument();
228
+
});
229
+
});
230
+
it("calls updateHandle with new handle", async () => {
231
+
let capturedHandle: string | null = null;
232
+
mockEndpoint("com.atproto.identity.updateHandle", (_url, options) => {
233
+
const body = JSON.parse((options?.body as string) || "{}");
234
+
capturedHandle = body.handle;
235
+
return jsonResponse({});
236
+
});
237
+
render(Settings);
190
238
await waitFor(() => {
191
-
expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument()
192
-
})
193
-
await fireEvent.input(screen.getByLabelText(/new handle/i), { target: { value: 'newhandle.bsky.social' } })
194
-
await fireEvent.click(screen.getByRole('button', { name: /change handle/i }))
239
+
expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument();
240
+
});
241
+
await fireEvent.input(screen.getByLabelText(/new handle/i), {
242
+
target: { value: "newhandle.bsky.social" },
243
+
});
244
+
await fireEvent.click(
245
+
screen.getByRole("button", { name: /change handle/i }),
246
+
);
195
247
await waitFor(() => {
196
-
expect(capturedHandle).toBe('newhandle.bsky.social')
197
-
})
198
-
})
199
-
it('shows success message after handle change', async () => {
200
-
mockEndpoint('com.atproto.identity.updateHandle', () =>
201
-
jsonResponse({})
202
-
)
203
-
render(Settings)
248
+
expect(capturedHandle).toBe("newhandle.bsky.social");
249
+
});
250
+
});
251
+
it("shows success message after handle change", async () => {
252
+
mockEndpoint("com.atproto.identity.updateHandle", () => jsonResponse({}));
253
+
render(Settings);
204
254
await waitFor(() => {
205
-
expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument()
206
-
})
207
-
await fireEvent.input(screen.getByLabelText(/new handle/i), { target: { value: 'newhandle' } })
208
-
await fireEvent.click(screen.getByRole('button', { name: /change handle/i }))
255
+
expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument();
256
+
});
257
+
await fireEvent.input(screen.getByLabelText(/new handle/i), {
258
+
target: { value: "newhandle" },
259
+
});
260
+
await fireEvent.click(
261
+
screen.getByRole("button", { name: /change handle/i }),
262
+
);
209
263
await waitFor(() => {
210
-
expect(screen.getByText(/handle updated successfully/i)).toBeInTheDocument()
211
-
})
212
-
})
213
-
it('shows error when handle change fails', async () => {
214
-
mockEndpoint('com.atproto.identity.updateHandle', () =>
215
-
errorResponse('HandleNotAvailable', 'Handle is already taken', 400)
216
-
)
217
-
render(Settings)
264
+
expect(screen.getByText(/handle updated successfully/i))
265
+
.toBeInTheDocument();
266
+
});
267
+
});
268
+
it("shows error when handle change fails", async () => {
269
+
mockEndpoint(
270
+
"com.atproto.identity.updateHandle",
271
+
() =>
272
+
errorResponse("HandleNotAvailable", "Handle is already taken", 400),
273
+
);
274
+
render(Settings);
218
275
await waitFor(() => {
219
-
expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument()
220
-
})
221
-
await fireEvent.input(screen.getByLabelText(/new handle/i), { target: { value: 'taken' } })
222
-
await fireEvent.click(screen.getByRole('button', { name: /change handle/i }))
276
+
expect(screen.getByLabelText(/new handle/i)).toBeInTheDocument();
277
+
});
278
+
await fireEvent.input(screen.getByLabelText(/new handle/i), {
279
+
target: { value: "taken" },
280
+
});
281
+
await fireEvent.click(
282
+
screen.getByRole("button", { name: /change handle/i }),
283
+
);
223
284
await waitFor(() => {
224
-
expect(screen.getByText(/handle is already taken/i)).toBeInTheDocument()
225
-
})
226
-
})
227
-
})
228
-
describe('account deletion', () => {
285
+
expect(screen.getByText(/handle is already taken/i))
286
+
.toBeInTheDocument();
287
+
});
288
+
});
289
+
});
290
+
describe("account deletion", () => {
229
291
beforeEach(() => {
230
-
setupAuthenticatedUser()
231
-
mockEndpoint('com.atproto.server.deleteSession', () =>
232
-
jsonResponse({})
233
-
)
234
-
})
235
-
it('displays delete section with warning and request button', async () => {
236
-
render(Settings)
292
+
setupAuthenticatedUser();
293
+
mockEndpoint("com.atproto.server.deleteSession", () => jsonResponse({}));
294
+
});
295
+
it("displays delete section with warning and request button", async () => {
296
+
render(Settings);
237
297
await waitFor(() => {
238
-
expect(screen.getByText(/this action is irreversible/i)).toBeInTheDocument()
239
-
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
240
-
})
241
-
})
242
-
it('calls requestAccountDelete when clicking request', async () => {
243
-
let requestCalled = false
244
-
mockEndpoint('com.atproto.server.requestAccountDelete', () => {
245
-
requestCalled = true
246
-
return jsonResponse({})
247
-
})
248
-
render(Settings)
298
+
expect(screen.getByText(/this action is irreversible/i))
299
+
.toBeInTheDocument();
300
+
expect(
301
+
screen.getByRole("button", { name: /request account deletion/i }),
302
+
).toBeInTheDocument();
303
+
});
304
+
});
305
+
it("calls requestAccountDelete when clicking request", async () => {
306
+
let requestCalled = false;
307
+
mockEndpoint("com.atproto.server.requestAccountDelete", () => {
308
+
requestCalled = true;
309
+
return jsonResponse({});
310
+
});
311
+
render(Settings);
249
312
await waitFor(() => {
250
-
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
251
-
})
252
-
await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
313
+
expect(
314
+
screen.getByRole("button", { name: /request account deletion/i }),
315
+
).toBeInTheDocument();
316
+
});
317
+
await fireEvent.click(
318
+
screen.getByRole("button", { name: /request account deletion/i }),
319
+
);
253
320
await waitFor(() => {
254
-
expect(requestCalled).toBe(true)
255
-
})
256
-
})
257
-
it('shows confirmation form after requesting deletion', async () => {
258
-
mockEndpoint('com.atproto.server.requestAccountDelete', () =>
259
-
jsonResponse({})
260
-
)
261
-
render(Settings)
321
+
expect(requestCalled).toBe(true);
322
+
});
323
+
});
324
+
it("shows confirmation form after requesting deletion", async () => {
325
+
mockEndpoint(
326
+
"com.atproto.server.requestAccountDelete",
327
+
() => jsonResponse({}),
328
+
);
329
+
render(Settings);
262
330
await waitFor(() => {
263
-
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
264
-
})
265
-
await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
331
+
expect(
332
+
screen.getByRole("button", { name: /request account deletion/i }),
333
+
).toBeInTheDocument();
334
+
});
335
+
await fireEvent.click(
336
+
screen.getByRole("button", { name: /request account deletion/i }),
337
+
);
266
338
await waitFor(() => {
267
-
expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument()
268
-
expect(screen.getByLabelText(/your password/i)).toBeInTheDocument()
269
-
expect(screen.getByRole('button', { name: /permanently delete account/i })).toBeInTheDocument()
270
-
})
271
-
})
272
-
it('shows confirmation dialog before final deletion', async () => {
273
-
const confirmSpy = vi.fn(() => false)
274
-
window.confirm = confirmSpy
275
-
mockEndpoint('com.atproto.server.requestAccountDelete', () =>
276
-
jsonResponse({})
277
-
)
278
-
render(Settings)
339
+
expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument();
340
+
expect(screen.getByLabelText(/your password/i)).toBeInTheDocument();
341
+
expect(
342
+
screen.getByRole("button", { name: /permanently delete account/i }),
343
+
).toBeInTheDocument();
344
+
});
345
+
});
346
+
it("shows confirmation dialog before final deletion", async () => {
347
+
const confirmSpy = vi.fn(() => false);
348
+
window.confirm = confirmSpy;
349
+
mockEndpoint(
350
+
"com.atproto.server.requestAccountDelete",
351
+
() => jsonResponse({}),
352
+
);
353
+
render(Settings);
279
354
await waitFor(() => {
280
-
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
281
-
})
282
-
await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
355
+
expect(
356
+
screen.getByRole("button", { name: /request account deletion/i }),
357
+
).toBeInTheDocument();
358
+
});
359
+
await fireEvent.click(
360
+
screen.getByRole("button", { name: /request account deletion/i }),
361
+
);
283
362
await waitFor(() => {
284
-
expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument()
285
-
})
286
-
await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: 'ABC123' } })
287
-
await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: 'password' } })
288
-
await fireEvent.click(screen.getByRole('button', { name: /permanently delete account/i }))
363
+
expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument();
364
+
});
365
+
await fireEvent.input(screen.getByLabelText(/confirmation code/i), {
366
+
target: { value: "ABC123" },
367
+
});
368
+
await fireEvent.input(screen.getByLabelText(/your password/i), {
369
+
target: { value: "password" },
370
+
});
371
+
await fireEvent.click(
372
+
screen.getByRole("button", { name: /permanently delete account/i }),
373
+
);
289
374
expect(confirmSpy).toHaveBeenCalledWith(
290
-
expect.stringContaining('absolutely sure')
291
-
)
292
-
})
293
-
it('calls deleteAccount with correct parameters', async () => {
294
-
window.confirm = vi.fn(() => true)
295
-
let capturedBody: Record<string, string> | null = null
296
-
mockEndpoint('com.atproto.server.requestAccountDelete', () =>
297
-
jsonResponse({})
298
-
)
299
-
mockEndpoint('com.atproto.server.deleteAccount', (_url, options) => {
300
-
capturedBody = JSON.parse((options?.body as string) || '{}')
301
-
return jsonResponse({})
302
-
})
303
-
render(Settings)
375
+
expect.stringContaining("absolutely sure"),
376
+
);
377
+
});
378
+
it("calls deleteAccount with correct parameters", async () => {
379
+
window.confirm = vi.fn(() => true);
380
+
let capturedBody: Record<string, string> | null = null;
381
+
mockEndpoint(
382
+
"com.atproto.server.requestAccountDelete",
383
+
() => jsonResponse({}),
384
+
);
385
+
mockEndpoint("com.atproto.server.deleteAccount", (_url, options) => {
386
+
capturedBody = JSON.parse((options?.body as string) || "{}");
387
+
return jsonResponse({});
388
+
});
389
+
render(Settings);
304
390
await waitFor(() => {
305
-
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
306
-
})
307
-
await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
391
+
expect(
392
+
screen.getByRole("button", { name: /request account deletion/i }),
393
+
).toBeInTheDocument();
394
+
});
395
+
await fireEvent.click(
396
+
screen.getByRole("button", { name: /request account deletion/i }),
397
+
);
308
398
await waitFor(() => {
309
-
expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument()
310
-
})
311
-
await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: 'DEL123' } })
312
-
await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: 'mypassword' } })
313
-
await fireEvent.click(screen.getByRole('button', { name: /permanently delete account/i }))
399
+
expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument();
400
+
});
401
+
await fireEvent.input(screen.getByLabelText(/confirmation code/i), {
402
+
target: { value: "DEL123" },
403
+
});
404
+
await fireEvent.input(screen.getByLabelText(/your password/i), {
405
+
target: { value: "mypassword" },
406
+
});
407
+
await fireEvent.click(
408
+
screen.getByRole("button", { name: /permanently delete account/i }),
409
+
);
314
410
await waitFor(() => {
315
-
expect(capturedBody?.token).toBe('DEL123')
316
-
expect(capturedBody?.password).toBe('mypassword')
317
-
expect(capturedBody?.did).toBe('did:web:test.tranquil.dev:u:testuser')
318
-
})
319
-
})
320
-
it('navigates to login after successful deletion', async () => {
321
-
window.confirm = vi.fn(() => true)
322
-
mockEndpoint('com.atproto.server.requestAccountDelete', () =>
323
-
jsonResponse({})
324
-
)
325
-
mockEndpoint('com.atproto.server.deleteAccount', () =>
326
-
jsonResponse({})
327
-
)
328
-
render(Settings)
411
+
expect(capturedBody?.token).toBe("DEL123");
412
+
expect(capturedBody?.password).toBe("mypassword");
413
+
expect(capturedBody?.did).toBe("did:web:test.tranquil.dev:u:testuser");
414
+
});
415
+
});
416
+
it("navigates to login after successful deletion", async () => {
417
+
window.confirm = vi.fn(() => true);
418
+
mockEndpoint(
419
+
"com.atproto.server.requestAccountDelete",
420
+
() => jsonResponse({}),
421
+
);
422
+
mockEndpoint("com.atproto.server.deleteAccount", () => jsonResponse({}));
423
+
render(Settings);
329
424
await waitFor(() => {
330
-
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
331
-
})
332
-
await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
425
+
expect(
426
+
screen.getByRole("button", { name: /request account deletion/i }),
427
+
).toBeInTheDocument();
428
+
});
429
+
await fireEvent.click(
430
+
screen.getByRole("button", { name: /request account deletion/i }),
431
+
);
333
432
await waitFor(() => {
334
-
expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument()
335
-
})
336
-
await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: 'DEL123' } })
337
-
await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: 'password' } })
338
-
await fireEvent.click(screen.getByRole('button', { name: /permanently delete account/i }))
433
+
expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument();
434
+
});
435
+
await fireEvent.input(screen.getByLabelText(/confirmation code/i), {
436
+
target: { value: "DEL123" },
437
+
});
438
+
await fireEvent.input(screen.getByLabelText(/your password/i), {
439
+
target: { value: "password" },
440
+
});
441
+
await fireEvent.click(
442
+
screen.getByRole("button", { name: /permanently delete account/i }),
443
+
);
339
444
await waitFor(() => {
340
-
expect(window.location.hash).toBe('#/login')
341
-
})
342
-
})
343
-
it('shows cancel button to return to request state', async () => {
344
-
mockEndpoint('com.atproto.server.requestAccountDelete', () =>
345
-
jsonResponse({})
346
-
)
347
-
render(Settings)
445
+
expect(window.location.hash).toBe("#/login");
446
+
});
447
+
});
448
+
it("shows cancel button to return to request state", async () => {
449
+
mockEndpoint(
450
+
"com.atproto.server.requestAccountDelete",
451
+
() => jsonResponse({}),
452
+
);
453
+
render(Settings);
348
454
await waitFor(() => {
349
-
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
350
-
})
351
-
await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
455
+
expect(
456
+
screen.getByRole("button", { name: /request account deletion/i }),
457
+
).toBeInTheDocument();
458
+
});
459
+
await fireEvent.click(
460
+
screen.getByRole("button", { name: /request account deletion/i }),
461
+
);
352
462
await waitFor(() => {
353
-
const cancelButtons = screen.getAllByRole('button', { name: /cancel/i })
354
-
expect(cancelButtons.length).toBeGreaterThan(0)
355
-
})
356
-
const deleteHeading = screen.getByRole('heading', { name: /delete account/i })
357
-
const deleteSection = deleteHeading.closest('section')
358
-
const cancelButton = deleteSection?.querySelector('button.secondary')
463
+
const cancelButtons = screen.getAllByRole("button", {
464
+
name: /cancel/i,
465
+
});
466
+
expect(cancelButtons.length).toBeGreaterThan(0);
467
+
});
468
+
const deleteHeading = screen.getByRole("heading", {
469
+
name: /delete account/i,
470
+
});
471
+
const deleteSection = deleteHeading.closest("section");
472
+
const cancelButton = deleteSection?.querySelector("button.secondary");
359
473
if (cancelButton) {
360
-
await fireEvent.click(cancelButton)
474
+
await fireEvent.click(cancelButton);
361
475
}
362
476
await waitFor(() => {
363
-
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
364
-
})
365
-
})
366
-
it('shows error when deletion fails', async () => {
367
-
window.confirm = vi.fn(() => true)
368
-
mockEndpoint('com.atproto.server.requestAccountDelete', () =>
369
-
jsonResponse({})
370
-
)
371
-
mockEndpoint('com.atproto.server.deleteAccount', () =>
372
-
errorResponse('InvalidToken', 'Invalid confirmation code', 400)
373
-
)
374
-
render(Settings)
477
+
expect(
478
+
screen.getByRole("button", { name: /request account deletion/i }),
479
+
).toBeInTheDocument();
480
+
});
481
+
});
482
+
it("shows error when deletion fails", async () => {
483
+
window.confirm = vi.fn(() => true);
484
+
mockEndpoint(
485
+
"com.atproto.server.requestAccountDelete",
486
+
() => jsonResponse({}),
487
+
);
488
+
mockEndpoint(
489
+
"com.atproto.server.deleteAccount",
490
+
() => errorResponse("InvalidToken", "Invalid confirmation code", 400),
491
+
);
492
+
render(Settings);
375
493
await waitFor(() => {
376
-
expect(screen.getByRole('button', { name: /request account deletion/i })).toBeInTheDocument()
377
-
})
378
-
await fireEvent.click(screen.getByRole('button', { name: /request account deletion/i }))
494
+
expect(
495
+
screen.getByRole("button", { name: /request account deletion/i }),
496
+
).toBeInTheDocument();
497
+
});
498
+
await fireEvent.click(
499
+
screen.getByRole("button", { name: /request account deletion/i }),
500
+
);
379
501
await waitFor(() => {
380
-
expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument()
381
-
})
382
-
await fireEvent.input(screen.getByLabelText(/confirmation code/i), { target: { value: 'WRONG' } })
383
-
await fireEvent.input(screen.getByLabelText(/your password/i), { target: { value: 'password' } })
384
-
await fireEvent.click(screen.getByRole('button', { name: /permanently delete account/i }))
502
+
expect(screen.getByLabelText(/confirmation code/i)).toBeInTheDocument();
503
+
});
504
+
await fireEvent.input(screen.getByLabelText(/confirmation code/i), {
505
+
target: { value: "WRONG" },
506
+
});
507
+
await fireEvent.input(screen.getByLabelText(/your password/i), {
508
+
target: { value: "password" },
509
+
});
510
+
await fireEvent.click(
511
+
screen.getByRole("button", { name: /permanently delete account/i }),
512
+
);
385
513
await waitFor(() => {
386
-
expect(screen.getByText(/invalid confirmation code/i)).toBeInTheDocument()
387
-
})
388
-
})
389
-
})
390
-
})
514
+
expect(screen.getByText(/invalid confirmation code/i))
515
+
.toBeInTheDocument();
516
+
});
517
+
});
518
+
});
519
+
});
+170
-139
frontend/src/tests/mocks.ts
+170
-139
frontend/src/tests/mocks.ts
···
1
-
import { vi } from 'vitest'
2
-
import type { Session, AppPassword, InviteCode } from '../lib/api'
3
-
import { _testSetState } from '../lib/auth.svelte'
1
+
import { vi } from "vitest";
2
+
import type { AppPassword, InviteCode, Session } from "../lib/api";
3
+
import { _testSetState } from "../lib/auth.svelte";
4
4
export interface MockResponse {
5
-
ok: boolean
6
-
status: number
7
-
json: () => Promise<unknown>
5
+
ok: boolean;
6
+
status: number;
7
+
json: () => Promise<unknown>;
8
8
}
9
-
export type MockHandler = (url: string, options?: RequestInit) => MockResponse | Promise<MockResponse>
10
-
const mockHandlers: Map<string, MockHandler> = new Map()
9
+
export type MockHandler = (
10
+
url: string,
11
+
options?: RequestInit,
12
+
) => MockResponse | Promise<MockResponse>;
13
+
const mockHandlers: Map<string, MockHandler> = new Map();
11
14
export function mockEndpoint(endpoint: string, handler: MockHandler): void {
12
-
mockHandlers.set(endpoint, handler)
15
+
mockHandlers.set(endpoint, handler);
13
16
}
14
17
export function mockEndpointOnce(endpoint: string, handler: MockHandler): void {
15
-
const originalHandler = mockHandlers.get(endpoint)
18
+
const originalHandler = mockHandlers.get(endpoint);
16
19
mockHandlers.set(endpoint, (url, options) => {
17
-
mockHandlers.set(endpoint, originalHandler!)
18
-
return handler(url, options)
19
-
})
20
+
mockHandlers.set(endpoint, originalHandler!);
21
+
return handler(url, options);
22
+
});
20
23
}
21
24
export function clearMocks(): void {
22
-
mockHandlers.clear()
25
+
mockHandlers.clear();
23
26
}
24
27
function extractEndpoint(url: string): string {
25
-
const match = url.match(/\/xrpc\/([^?]+)/)
26
-
return match ? match[1] : url
28
+
const match = url.match(/\/xrpc\/([^?]+)/);
29
+
return match ? match[1] : url;
27
30
}
28
31
export function setupFetchMock(): void {
29
-
global.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
30
-
const url = typeof input === 'string' ? input : input.toString()
31
-
const endpoint = extractEndpoint(url)
32
-
const handler = mockHandlers.get(endpoint)
33
-
if (handler) {
34
-
const result = await handler(url, init)
32
+
global.fetch = vi.fn(
33
+
async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
34
+
const url = typeof input === "string" ? input : input.toString();
35
+
const endpoint = extractEndpoint(url);
36
+
const handler = mockHandlers.get(endpoint);
37
+
if (handler) {
38
+
const result = await handler(url, init);
39
+
return {
40
+
ok: result.ok,
41
+
status: result.status,
42
+
json: result.json,
43
+
text: async () => JSON.stringify(await result.json()),
44
+
headers: new Headers(),
45
+
redirected: false,
46
+
statusText: result.ok ? "OK" : "Error",
47
+
type: "basic",
48
+
url,
49
+
clone: () => ({ ...result }) as Response,
50
+
body: null,
51
+
bodyUsed: false,
52
+
arrayBuffer: async () => new ArrayBuffer(0),
53
+
blob: async () => new Blob(),
54
+
formData: async () => new FormData(),
55
+
} as Response;
56
+
}
35
57
return {
36
-
ok: result.ok,
37
-
status: result.status,
38
-
json: result.json,
39
-
text: async () => JSON.stringify(await result.json()),
58
+
ok: false,
59
+
status: 404,
60
+
json: async () => ({
61
+
error: "NotFound",
62
+
message: `No mock for ${endpoint}`,
63
+
}),
64
+
text: async () =>
65
+
JSON.stringify({
66
+
error: "NotFound",
67
+
message: `No mock for ${endpoint}`,
68
+
}),
40
69
headers: new Headers(),
41
70
redirected: false,
42
-
statusText: result.ok ? 'OK' : 'Error',
43
-
type: 'basic',
71
+
statusText: "Not Found",
72
+
type: "basic",
44
73
url,
45
-
clone: () => ({ ...result }) as Response,
74
+
clone: function () {
75
+
return this;
76
+
},
46
77
body: null,
47
78
bodyUsed: false,
48
79
arrayBuffer: async () => new ArrayBuffer(0),
49
80
blob: async () => new Blob(),
50
81
formData: async () => new FormData(),
51
-
} as Response
52
-
}
53
-
return {
54
-
ok: false,
55
-
status: 404,
56
-
json: async () => ({ error: 'NotFound', message: `No mock for ${endpoint}` }),
57
-
text: async () => JSON.stringify({ error: 'NotFound', message: `No mock for ${endpoint}` }),
58
-
headers: new Headers(),
59
-
redirected: false,
60
-
statusText: 'Not Found',
61
-
type: 'basic',
62
-
url,
63
-
clone: function() { return this },
64
-
body: null,
65
-
bodyUsed: false,
66
-
arrayBuffer: async () => new ArrayBuffer(0),
67
-
blob: async () => new Blob(),
68
-
formData: async () => new FormData(),
69
-
} as Response
70
-
})
82
+
} as Response;
83
+
},
84
+
);
71
85
}
72
86
export function jsonResponse<T>(data: T, status = 200): MockResponse {
73
87
return {
74
88
ok: status >= 200 && status < 300,
75
89
status,
76
90
json: async () => data,
77
-
}
91
+
};
78
92
}
79
-
export function errorResponse(error: string, message: string, status = 400): MockResponse {
93
+
export function errorResponse(
94
+
error: string,
95
+
message: string,
96
+
status = 400,
97
+
): MockResponse {
80
98
return {
81
99
ok: false,
82
100
status,
83
101
json: async () => ({ error, message }),
84
-
}
102
+
};
85
103
}
86
104
export const mockData = {
87
105
session: (overrides?: Partial<Session>): Session => ({
88
-
did: 'did:web:test.tranquil.dev:u:testuser',
89
-
handle: 'testuser.test.tranquil.dev',
90
-
email: 'test@example.com',
106
+
did: "did:web:test.tranquil.dev:u:testuser",
107
+
handle: "testuser.test.tranquil.dev",
108
+
email: "test@example.com",
91
109
emailConfirmed: true,
92
-
accessJwt: 'mock-access-jwt-token',
93
-
refreshJwt: 'mock-refresh-jwt-token',
110
+
accessJwt: "mock-access-jwt-token",
111
+
refreshJwt: "mock-refresh-jwt-token",
94
112
...overrides,
95
113
}),
96
114
appPassword: (overrides?: Partial<AppPassword>): AppPassword => ({
97
-
name: 'Test App',
115
+
name: "Test App",
98
116
createdAt: new Date().toISOString(),
99
117
...overrides,
100
118
}),
101
119
inviteCode: (overrides?: Partial<InviteCode>): InviteCode => ({
102
-
code: 'test-invite-123',
120
+
code: "test-invite-123",
103
121
available: 1,
104
122
disabled: false,
105
-
forAccount: 'did:web:test.tranquil.dev:u:testuser',
106
-
createdBy: 'did:web:test.tranquil.dev:u:testuser',
123
+
forAccount: "did:web:test.tranquil.dev:u:testuser",
124
+
createdBy: "did:web:test.tranquil.dev:u:testuser",
107
125
createdAt: new Date().toISOString(),
108
126
uses: [],
109
127
...overrides,
110
128
}),
111
129
notificationPrefs: (overrides?: Record<string, unknown>) => ({
112
-
preferredChannel: 'email',
113
-
email: 'test@example.com',
130
+
preferredChannel: "email",
131
+
email: "test@example.com",
114
132
discordId: null,
115
133
discordVerified: false,
116
134
telegramUsername: null,
···
120
138
...overrides,
121
139
}),
122
140
describeServer: () => ({
123
-
availableUserDomains: ['test.tranquil.dev'],
141
+
availableUserDomains: ["test.tranquil.dev"],
124
142
inviteCodeRequired: false,
125
143
links: {
126
-
privacyPolicy: 'https://example.com/privacy',
127
-
termsOfService: 'https://example.com/tos',
144
+
privacyPolicy: "https://example.com/privacy",
145
+
termsOfService: "https://example.com/tos",
128
146
},
129
147
}),
130
148
describeRepo: (did: string) => ({
131
-
handle: 'testuser.test.tranquil.dev',
149
+
handle: "testuser.test.tranquil.dev",
132
150
did,
133
151
didDoc: {},
134
-
collections: ['app.bsky.feed.post', 'app.bsky.feed.like', 'app.bsky.graph.follow'],
152
+
collections: [
153
+
"app.bsky.feed.post",
154
+
"app.bsky.feed.like",
155
+
"app.bsky.graph.follow",
156
+
],
135
157
handleIsCorrect: true,
136
158
}),
137
-
}
159
+
};
138
160
export function setupDefaultMocks(): void {
139
-
setupFetchMock()
140
-
mockEndpoint('com.atproto.server.getSession', () =>
141
-
jsonResponse(mockData.session())
142
-
)
143
-
mockEndpoint('com.atproto.server.createSession', (_url, options) => {
144
-
const body = JSON.parse((options?.body as string) || '{}')
145
-
if (body.identifier && body.password === 'correctpassword') {
146
-
return jsonResponse(mockData.session({ handle: body.identifier.replace('@', '') }))
161
+
setupFetchMock();
162
+
mockEndpoint(
163
+
"com.atproto.server.getSession",
164
+
() => jsonResponse(mockData.session()),
165
+
);
166
+
mockEndpoint("com.atproto.server.createSession", (_url, options) => {
167
+
const body = JSON.parse((options?.body as string) || "{}");
168
+
if (body.identifier && body.password === "correctpassword") {
169
+
return jsonResponse(
170
+
mockData.session({ handle: body.identifier.replace("@", "") }),
171
+
);
147
172
}
148
-
return errorResponse('AuthenticationRequired', 'Invalid identifier or password', 401)
149
-
})
150
-
mockEndpoint('com.atproto.server.refreshSession', () =>
151
-
jsonResponse(mockData.session())
152
-
)
153
-
mockEndpoint('com.atproto.server.deleteSession', () =>
154
-
jsonResponse({})
155
-
)
156
-
mockEndpoint('com.atproto.server.listAppPasswords', () =>
157
-
jsonResponse({ passwords: [mockData.appPassword()] })
158
-
)
159
-
mockEndpoint('com.atproto.server.createAppPassword', (_url, options) => {
160
-
const body = JSON.parse((options?.body as string) || '{}')
173
+
return errorResponse(
174
+
"AuthenticationRequired",
175
+
"Invalid identifier or password",
176
+
401,
177
+
);
178
+
});
179
+
mockEndpoint(
180
+
"com.atproto.server.refreshSession",
181
+
() => jsonResponse(mockData.session()),
182
+
);
183
+
mockEndpoint("com.atproto.server.deleteSession", () => jsonResponse({}));
184
+
mockEndpoint(
185
+
"com.atproto.server.listAppPasswords",
186
+
() => jsonResponse({ passwords: [mockData.appPassword()] }),
187
+
);
188
+
mockEndpoint("com.atproto.server.createAppPassword", (_url, options) => {
189
+
const body = JSON.parse((options?.body as string) || "{}");
161
190
return jsonResponse({
162
191
name: body.name,
163
-
password: 'xxxx-xxxx-xxxx-xxxx',
192
+
password: "xxxx-xxxx-xxxx-xxxx",
164
193
createdAt: new Date().toISOString(),
165
-
})
166
-
})
167
-
mockEndpoint('com.atproto.server.revokeAppPassword', () =>
168
-
jsonResponse({})
169
-
)
170
-
mockEndpoint('com.atproto.server.getAccountInviteCodes', () =>
171
-
jsonResponse({ codes: [mockData.inviteCode()] })
172
-
)
173
-
mockEndpoint('com.atproto.server.createInviteCode', () =>
174
-
jsonResponse({ code: 'new-invite-' + Date.now() })
175
-
)
176
-
mockEndpoint('com.tranquil.account.getNotificationPrefs', () =>
177
-
jsonResponse(mockData.notificationPrefs())
178
-
)
179
-
mockEndpoint('com.tranquil.account.updateNotificationPrefs', () =>
180
-
jsonResponse({ success: true })
181
-
)
182
-
mockEndpoint('com.atproto.server.requestEmailUpdate', () =>
183
-
jsonResponse({ tokenRequired: true })
184
-
)
185
-
mockEndpoint('com.atproto.server.updateEmail', () =>
186
-
jsonResponse({})
187
-
)
188
-
mockEndpoint('com.atproto.identity.updateHandle', () =>
189
-
jsonResponse({})
190
-
)
191
-
mockEndpoint('com.atproto.server.requestAccountDelete', () =>
192
-
jsonResponse({})
193
-
)
194
-
mockEndpoint('com.atproto.server.deleteAccount', () =>
195
-
jsonResponse({})
196
-
)
197
-
mockEndpoint('com.atproto.server.describeServer', () =>
198
-
jsonResponse(mockData.describeServer())
199
-
)
200
-
mockEndpoint('com.atproto.repo.describeRepo', (url) => {
201
-
const params = new URLSearchParams(url.split('?')[1])
202
-
const repo = params.get('repo') || 'did:web:test'
203
-
return jsonResponse(mockData.describeRepo(repo))
204
-
})
205
-
mockEndpoint('com.atproto.repo.listRecords', () =>
206
-
jsonResponse({ records: [] })
207
-
)
194
+
});
195
+
});
196
+
mockEndpoint("com.atproto.server.revokeAppPassword", () => jsonResponse({}));
197
+
mockEndpoint(
198
+
"com.atproto.server.getAccountInviteCodes",
199
+
() => jsonResponse({ codes: [mockData.inviteCode()] }),
200
+
);
201
+
mockEndpoint(
202
+
"com.atproto.server.createInviteCode",
203
+
() => jsonResponse({ code: "new-invite-" + Date.now() }),
204
+
);
205
+
mockEndpoint(
206
+
"com.tranquil.account.getNotificationPrefs",
207
+
() => jsonResponse(mockData.notificationPrefs()),
208
+
);
209
+
mockEndpoint(
210
+
"com.tranquil.account.updateNotificationPrefs",
211
+
() => jsonResponse({ success: true }),
212
+
);
213
+
mockEndpoint(
214
+
"com.atproto.server.requestEmailUpdate",
215
+
() => jsonResponse({ tokenRequired: true }),
216
+
);
217
+
mockEndpoint("com.atproto.server.updateEmail", () => jsonResponse({}));
218
+
mockEndpoint("com.atproto.identity.updateHandle", () => jsonResponse({}));
219
+
mockEndpoint(
220
+
"com.atproto.server.requestAccountDelete",
221
+
() => jsonResponse({}),
222
+
);
223
+
mockEndpoint("com.atproto.server.deleteAccount", () => jsonResponse({}));
224
+
mockEndpoint(
225
+
"com.atproto.server.describeServer",
226
+
() => jsonResponse(mockData.describeServer()),
227
+
);
228
+
mockEndpoint("com.atproto.repo.describeRepo", (url) => {
229
+
const params = new URLSearchParams(url.split("?")[1]);
230
+
const repo = params.get("repo") || "did:web:test";
231
+
return jsonResponse(mockData.describeRepo(repo));
232
+
});
233
+
mockEndpoint(
234
+
"com.atproto.repo.listRecords",
235
+
() => jsonResponse({ records: [] }),
236
+
);
208
237
}
209
-
export function setupAuthenticatedUser(sessionOverrides?: Partial<Session>): Session {
210
-
const session = mockData.session(sessionOverrides)
238
+
export function setupAuthenticatedUser(
239
+
sessionOverrides?: Partial<Session>,
240
+
): Session {
241
+
const session = mockData.session(sessionOverrides);
211
242
_testSetState({
212
243
session,
213
244
loading: false,
214
245
error: null,
215
-
})
216
-
return session
246
+
});
247
+
return session;
217
248
}
218
249
export function setupUnauthenticatedUser(): void {
219
250
_testSetState({
220
251
session: null,
221
252
loading: false,
222
253
error: null,
223
-
})
254
+
});
224
255
}
+22
-20
frontend/src/tests/setup.ts
+22
-20
frontend/src/tests/setup.ts
···
1
-
import '@testing-library/jest-dom/vitest'
2
-
import { vi, beforeEach, afterEach } from 'vitest'
3
-
import { _testReset } from '../lib/auth.svelte'
1
+
import "@testing-library/jest-dom/vitest";
2
+
import { afterEach, beforeEach, vi } from "vitest";
3
+
import { _testReset } from "../lib/auth.svelte";
4
4
5
-
let locationHash = ''
5
+
let locationHash = "";
6
6
7
-
Object.defineProperty(window, 'location', {
7
+
Object.defineProperty(window, "location", {
8
8
value: {
9
-
get hash() { return locationHash },
9
+
get hash() {
10
+
return locationHash;
11
+
},
10
12
set hash(value: string) {
11
-
locationHash = value.startsWith('#') ? value : `#${value}`
13
+
locationHash = value.startsWith("#") ? value : `#${value}`;
12
14
},
13
-
href: 'http://localhost:3000/',
14
-
origin: 'http://localhost:3000',
15
-
pathname: '/',
16
-
search: '',
15
+
href: "http://localhost:3000/",
16
+
origin: "http://localhost:3000",
17
+
pathname: "/",
18
+
search: "",
17
19
assign: vi.fn(),
18
20
replace: vi.fn(),
19
21
reload: vi.fn(),
20
22
},
21
23
writable: true,
22
24
configurable: true,
23
-
})
25
+
});
24
26
25
27
beforeEach(() => {
26
-
vi.clearAllMocks()
27
-
localStorage.clear()
28
-
sessionStorage.clear()
29
-
locationHash = ''
30
-
_testReset()
31
-
})
28
+
vi.clearAllMocks();
29
+
localStorage.clear();
30
+
sessionStorage.clear();
31
+
locationHash = "";
32
+
_testReset();
33
+
});
32
34
33
35
afterEach(() => {
34
-
vi.restoreAllMocks()
35
-
})
36
+
vi.restoreAllMocks();
37
+
});
+55
-43
frontend/src/tests/utils.ts
+55
-43
frontend/src/tests/utils.ts
···
1
-
import { render, type RenderResult } from '@testing-library/svelte'
2
-
import { tick } from 'svelte'
3
-
import type { ComponentType } from 'svelte'
1
+
import { render, type RenderResult } from "@testing-library/svelte";
2
+
import { tick } from "svelte";
3
+
import type { ComponentType } from "svelte";
4
4
5
5
export async function renderAndWait<T extends ComponentType>(
6
6
component: T,
7
-
options?: Parameters<typeof render>[1]
7
+
options?: Parameters<typeof render>[1],
8
8
): Promise<RenderResult<T>> {
9
-
const result = render(component, options)
10
-
await tick()
11
-
await new Promise(resolve => setTimeout(resolve, 0))
12
-
return result
9
+
const result = render(component, options);
10
+
await tick();
11
+
await new Promise((resolve) => setTimeout(resolve, 0));
12
+
return result;
13
13
}
14
14
15
15
export async function waitForElement(
16
16
queryFn: () => HTMLElement | null,
17
-
timeout = 1000
17
+
timeout = 1000,
18
18
): Promise<HTMLElement> {
19
-
const start = Date.now()
19
+
const start = Date.now();
20
20
while (Date.now() - start < timeout) {
21
-
const element = queryFn()
22
-
if (element) return element
23
-
await new Promise(resolve => setTimeout(resolve, 10))
21
+
const element = queryFn();
22
+
if (element) return element;
23
+
await new Promise((resolve) => setTimeout(resolve, 10));
24
24
}
25
-
throw new Error('Element not found within timeout')
25
+
throw new Error("Element not found within timeout");
26
26
}
27
27
28
28
export async function waitForElementToDisappear(
29
29
queryFn: () => HTMLElement | null,
30
-
timeout = 1000
30
+
timeout = 1000,
31
31
): Promise<void> {
32
-
const start = Date.now()
32
+
const start = Date.now();
33
33
while (Date.now() - start < timeout) {
34
-
const element = queryFn()
35
-
if (!element) return
36
-
await new Promise(resolve => setTimeout(resolve, 10))
34
+
const element = queryFn();
35
+
if (!element) return;
36
+
await new Promise((resolve) => setTimeout(resolve, 10));
37
37
}
38
-
throw new Error('Element still present after timeout')
38
+
throw new Error("Element still present after timeout");
39
39
}
40
40
41
41
export async function waitForText(
42
42
container: HTMLElement,
43
43
text: string | RegExp,
44
-
timeout = 1000
44
+
timeout = 1000,
45
45
): Promise<void> {
46
-
const start = Date.now()
46
+
const start = Date.now();
47
47
while (Date.now() - start < timeout) {
48
-
const content = container.textContent || ''
49
-
if (typeof text === 'string' ? content.includes(text) : text.test(content)) {
50
-
return
48
+
const content = container.textContent || "";
49
+
if (
50
+
typeof text === "string" ? content.includes(text) : text.test(content)
51
+
) {
52
+
return;
51
53
}
52
-
await new Promise(resolve => setTimeout(resolve, 10))
54
+
await new Promise((resolve) => setTimeout(resolve, 10));
53
55
}
54
-
throw new Error(`Text "${text}" not found within timeout`)
56
+
throw new Error(`Text "${text}" not found within timeout`);
55
57
}
56
58
57
-
export function mockLocalStorage(initialData: Record<string, string> = {}): void {
58
-
const store: Record<string, string> = { ...initialData }
59
-
Object.defineProperty(window, 'localStorage', {
59
+
export function mockLocalStorage(
60
+
initialData: Record<string, string> = {},
61
+
): void {
62
+
const store: Record<string, string> = { ...initialData };
63
+
Object.defineProperty(window, "localStorage", {
60
64
value: {
61
65
getItem: (key: string) => store[key] || null,
62
-
setItem: (key: string, value: string) => { store[key] = value },
63
-
removeItem: (key: string) => { delete store[key] },
64
-
clear: () => { Object.keys(store).forEach(key => delete store[key]) },
66
+
setItem: (key: string, value: string) => {
67
+
store[key] = value;
68
+
},
69
+
removeItem: (key: string) => {
70
+
delete store[key];
71
+
},
72
+
clear: () => {
73
+
Object.keys(store).forEach((key) => delete store[key]);
74
+
},
65
75
key: (index: number) => Object.keys(store)[index] || null,
66
-
get length() { return Object.keys(store).length },
76
+
get length() {
77
+
return Object.keys(store).length;
78
+
},
67
79
},
68
80
writable: true,
69
-
})
81
+
});
70
82
}
71
83
72
84
export function setAuthState(session: {
73
-
did: string
74
-
handle: string
75
-
email?: string
76
-
emailConfirmed?: boolean
77
-
accessJwt: string
78
-
refreshJwt: string
85
+
did: string;
86
+
handle: string;
87
+
email?: string;
88
+
emailConfirmed?: boolean;
89
+
accessJwt: string;
90
+
refreshJwt: string;
79
91
}): void {
80
-
localStorage.setItem('session', JSON.stringify(session))
92
+
localStorage.setItem("session", JSON.stringify(session));
81
93
}
82
94
83
95
export function clearAuthState(): void {
84
-
localStorage.removeItem('session')
96
+
localStorage.removeItem("session");
85
97
}
+3
-3
frontend/svelte.config.js
+3
-3
frontend/svelte.config.js
···
1
-
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
2
-
const isTest = process.env.VITEST === 'true' || process.env.VITEST === true
1
+
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
2
+
const isTest = process.env.VITEST === "true" || process.env.VITEST === true;
3
3
export default {
4
4
preprocess: isTest ? [] : vitePreprocess(),
5
-
}
5
+
};
+14
-14
frontend/vite.config.ts
+14
-14
frontend/vite.config.ts
···
1
-
import { defineConfig, loadEnv } from 'vite'
2
-
import { svelte } from '@sveltejs/vite-plugin-svelte'
1
+
import { defineConfig, loadEnv } from "vite";
2
+
import { svelte } from "@sveltejs/vite-plugin-svelte";
3
3
4
4
export default defineConfig(({ mode }) => {
5
-
const env = loadEnv(mode, process.cwd(), '')
6
-
const target = env.VITE_API_URL || 'http://localhost:3000'
5
+
const env = loadEnv(mode, process.cwd(), "");
6
+
const target = env.VITE_API_URL || "http://localhost:3000";
7
7
8
8
return {
9
9
plugins: [svelte()],
10
10
build: {
11
-
outDir: 'dist',
11
+
outDir: "dist",
12
12
},
13
13
server: {
14
14
port: 5173,
15
15
proxy: {
16
-
'/xrpc': target,
17
-
'/oauth': target,
18
-
'/.well-known': target,
19
-
'/health': target,
20
-
'/u': target,
21
-
}
22
-
}
23
-
}
24
-
})
16
+
"/xrpc": target,
17
+
"/oauth": target,
18
+
"/.well-known": target,
19
+
"/health": target,
20
+
"/u": target,
21
+
},
22
+
},
23
+
};
24
+
});
+8
-8
frontend/vitest.config.ts
+8
-8
frontend/vitest.config.ts
···
1
-
import { defineConfig } from 'vitest/config'
2
-
import { svelte } from '@sveltejs/vite-plugin-svelte'
1
+
import { defineConfig } from "vitest/config";
2
+
import { svelte } from "@sveltejs/vite-plugin-svelte";
3
3
export default defineConfig({
4
4
plugins: [
5
5
svelte({
···
7
7
}),
8
8
],
9
9
resolve: {
10
-
conditions: ['browser', 'development'],
10
+
conditions: ["browser", "development"],
11
11
},
12
12
test: {
13
-
environment: 'jsdom',
13
+
environment: "jsdom",
14
14
globals: true,
15
-
setupFiles: ['./src/tests/setup.ts'],
16
-
include: ['src/**/*.{test,spec}.{js,ts}'],
15
+
setupFiles: ["./src/tests/setup.ts"],
16
+
include: ["src/**/*.{test,spec}.{js,ts}"],
17
17
alias: {
18
-
'svelte': 'svelte',
18
+
"svelte": "svelte",
19
19
},
20
20
},
21
-
})
21
+
});
+1
-4
src/oauth/client.rs
+1
-4
src/oauth/client.rs
···
126
126
client_uri: None,
127
127
logo_uri: None,
128
128
redirect_uris,
129
-
grant_types: vec![
130
-
"authorization_code".into(),
131
-
"refresh_token".into(),
132
-
],
129
+
grant_types: vec!["authorization_code".into(), "refresh_token".into()],
133
130
response_types: vec!["code".into()],
134
131
scope,
135
132
token_endpoint_auth_method: Some("none".into()),