this repo has no description
1<script lang="ts">
2 import { getAuthState, refreshSession } from '../lib/auth.svelte'
3 import { navigate, routes, getFullUrl } from '../lib/router.svelte'
4 import { api, ApiError } from '../lib/api'
5 import { _ } from '../lib/i18n'
6 import { formatDateTime } from '../lib/date'
7 import type { Session } from '../lib/types/api'
8 import { toast } from '../lib/toast.svelte'
9
10 const auth = $derived(getAuthState())
11
12 function getSession(): Session | null {
13 return auth.kind === 'authenticated' ? auth.session : null
14 }
15
16 function isLoading(): boolean {
17 return auth.kind === 'loading'
18 }
19
20 const session = $derived(getSession())
21 const authLoading = $derived(isLoading())
22 let loading = $state(true)
23 let saving = $state(false)
24 let preferredChannel = $state('email')
25 let availableCommsChannels = $state<string[]>(['email'])
26 let email = $state('')
27 let discordId = $state('')
28 let discordVerified = $state(false)
29 let telegramUsername = $state('')
30 let telegramVerified = $state(false)
31 let signalNumber = $state('')
32 let signalVerified = $state(false)
33 let verifyingChannel = $state<string | null>(null)
34 let verificationCode = $state('')
35 let historyLoading = $state(true)
36 let messages = $state<Array<{
37 createdAt: string
38 channel: string
39 notificationType: string
40 status: string
41 subject: string | null
42 body: string
43 }>>([])
44 $effect(() => {
45 if (!authLoading && !session) {
46 navigate(routes.login)
47 }
48 })
49 $effect(() => {
50 if (session) {
51 loadPrefs()
52 loadHistory()
53 }
54 })
55 async function loadPrefs() {
56 if (!session) return
57 loading = true
58 try {
59 const [prefs, serverInfo] = await Promise.all([
60 api.getNotificationPrefs(session.accessJwt),
61 api.describeServer()
62 ])
63 preferredChannel = prefs.preferredChannel
64 email = prefs.email
65 discordId = prefs.discordId ?? ''
66 discordVerified = prefs.discordVerified
67 telegramUsername = prefs.telegramUsername ?? ''
68 telegramVerified = prefs.telegramVerified
69 signalNumber = prefs.signalNumber ?? ''
70 signalVerified = prefs.signalVerified
71 availableCommsChannels = serverInfo.availableCommsChannels ?? ['email']
72 } catch (e) {
73 toast.error(e instanceof ApiError ? e.message : $_('comms.failedToLoad'))
74 } finally {
75 loading = false
76 }
77 }
78 async function handleSave(e: Event) {
79 e.preventDefault()
80 if (!session) return
81 saving = true
82 try {
83 await api.updateNotificationPrefs(session.accessJwt, {
84 preferredChannel,
85 discordId: discordId || undefined,
86 telegramUsername: telegramUsername || undefined,
87 signalNumber: signalNumber || undefined,
88 })
89 await refreshSession()
90 toast.success($_('comms.preferencesSaved'))
91 await loadPrefs()
92 } catch (e) {
93 toast.error(e instanceof ApiError ? e.message : $_('comms.failedToSave'))
94 } finally {
95 saving = false
96 }
97 }
98 async function handleVerify(channel: string) {
99 if (!session || !verificationCode) return
100
101 let identifier = ''
102 switch (channel) {
103 case 'discord': identifier = discordId; break
104 case 'telegram': identifier = telegramUsername; break
105 case 'signal': identifier = signalNumber; break
106 }
107 if (!identifier) return
108
109 try {
110 await api.confirmChannelVerification(session.accessJwt, channel, identifier, verificationCode)
111 await refreshSession()
112 toast.success($_('comms.verifiedSuccess', { values: { channel } }))
113 verificationCode = ''
114 verifyingChannel = null
115 await loadPrefs()
116 } catch (e) {
117 toast.error(e instanceof ApiError ? e.message : $_('comms.failedToVerify'))
118 }
119 }
120 async function loadHistory() {
121 if (!session) return
122 historyLoading = true
123 try {
124 const result = await api.getNotificationHistory(session.accessJwt)
125 messages = result.notifications
126 } catch (e) {
127 toast.error(e instanceof ApiError ? e.message : $_('comms.failedToLoadHistory'))
128 } finally {
129 historyLoading = false
130 }
131 }
132 function formatDate(dateStr: string): string {
133 return formatDateTime(dateStr)
134 }
135 const channels = ['email', 'discord', 'telegram', 'signal']
136 function getChannelName(id: string): string {
137 switch (id) {
138 case 'email': return $_('register.email')
139 case 'discord': return $_('register.discord')
140 case 'telegram': return $_('register.telegram')
141 case 'signal': return $_('register.signal')
142 default: return id
143 }
144 }
145 function getChannelDescription(id: string): string {
146 switch (id) {
147 case 'email': return $_('comms.emailVia')
148 case 'discord': return $_('comms.discordVia')
149 case 'telegram': return $_('comms.telegramVia')
150 case 'signal': return $_('comms.signalVia')
151 default: return ''
152 }
153 }
154 function isChannelAvailableOnServer(channelId: string): boolean {
155 return availableCommsChannels.includes(channelId)
156 }
157 function canSelectChannel(channelId: string): boolean {
158 if (!isChannelAvailableOnServer(channelId)) return false
159 if (channelId === 'email') return true
160 if (channelId === 'discord') return !!discordId
161 if (channelId === 'telegram') return !!telegramUsername
162 if (channelId === 'signal') return !!signalNumber
163 return false
164 }
165 function needsVerification(channelId: string): boolean {
166 if (channelId === 'discord') return !!discordId && !discordVerified
167 if (channelId === 'telegram') return !!telegramUsername && !telegramVerified
168 if (channelId === 'signal') return !!signalNumber && !signalVerified
169 return false
170 }
171</script>
172<div class="page">
173 <header>
174 <a href={getFullUrl(routes.dashboard)} class="back">{$_('common.backToDashboard')}</a>
175 <h1>{$_('comms.title')}</h1>
176 <p class="description">{$_('comms.description')}</p>
177 </header>
178
179 {#if loading}
180 <div class="skeleton-sections">
181 <div class="skeleton-section"></div>
182 <div class="skeleton-section"></div>
183 </div>
184 {:else}
185 <div class="split-layout">
186 <div class="main-column">
187 <form onsubmit={handleSave}>
188 <section>
189 <h2>{$_('comms.preferredChannel')}</h2>
190 <p class="section-description">{$_('comms.preferredChannelDescription')}</p>
191 <div class="channel-options">
192 {#each channels as channelId}
193 <label class="channel-option" class:disabled={!canSelectChannel(channelId)} class:unavailable={!isChannelAvailableOnServer(channelId)}>
194 <input
195 type="radio"
196 name="preferredChannel"
197 value={channelId}
198 bind:group={preferredChannel}
199 disabled={!canSelectChannel(channelId) || saving}
200 />
201 <div class="channel-info">
202 <span class="channel-name">{getChannelName(channelId)}</span>
203 <span class="channel-description">{getChannelDescription(channelId)}</span>
204 {#if !isChannelAvailableOnServer(channelId)}
205 <span class="channel-hint server-unavailable">{$_('comms.notConfiguredOnServer')}</span>
206 {:else if channelId !== 'email' && !canSelectChannel(channelId)}
207 <span class="channel-hint">{$_('comms.configureToEnable')}</span>
208 {/if}
209 </div>
210 </label>
211 {/each}
212 </div>
213 </section>
214
215 <section>
216 <h2>{$_('comms.channelConfiguration')}</h2>
217 <div class="channel-config">
218 <div class="config-item">
219 <div class="config-header">
220 <label for="email">{$_('register.email')}</label>
221 <span class="status verified">{$_('comms.primary')}</span>
222 </div>
223 <input id="email" type="email" value={email} disabled class="readonly" />
224 <p class="config-hint">{$_('comms.emailManagedInSettings')}</p>
225 </div>
226
227 <div class="config-item" class:unavailable={!isChannelAvailableOnServer('discord')}>
228 <div class="config-header">
229 <label for="discord">{$_('register.discordId')}</label>
230 {#if !isChannelAvailableOnServer('discord')}
231 <span class="status unavailable">{$_('comms.notConfiguredOnServer')}</span>
232 {:else if discordId}
233 {#if discordVerified}
234 <span class="status verified">{$_('comms.verified')}</span>
235 {:else}
236 <span class="status unverified">{$_('comms.notVerified')}</span>
237 {/if}
238 {/if}
239 </div>
240 <div class="config-input">
241 <input
242 id="discord"
243 type="text"
244 bind:value={discordId}
245 placeholder={$_('register.discordIdPlaceholder')}
246 disabled={saving || !isChannelAvailableOnServer('discord')}
247 />
248 {#if discordId && !discordVerified && isChannelAvailableOnServer('discord')}
249 <button type="button" class="verify-btn" onclick={() => verifyingChannel = 'discord'}>{$_('comms.verifyButton')}</button>
250 {/if}
251 </div>
252 <p class="config-hint">{$_('comms.discordIdHint')}</p>
253 {#if verifyingChannel === 'discord'}
254 <div class="verify-form">
255 <input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="6" />
256 <button type="button" onclick={() => handleVerify('discord')}>{$_('comms.submit')}</button>
257 <button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button>
258 </div>
259 {/if}
260 </div>
261
262 <div class="config-item" class:unavailable={!isChannelAvailableOnServer('telegram')}>
263 <div class="config-header">
264 <label for="telegram">{$_('register.telegramUsername')}</label>
265 {#if !isChannelAvailableOnServer('telegram')}
266 <span class="status unavailable">{$_('comms.notConfiguredOnServer')}</span>
267 {:else if telegramUsername}
268 {#if telegramVerified}
269 <span class="status verified">{$_('comms.verified')}</span>
270 {:else}
271 <span class="status unverified">{$_('comms.notVerified')}</span>
272 {/if}
273 {/if}
274 </div>
275 <div class="config-input">
276 <input
277 id="telegram"
278 type="text"
279 bind:value={telegramUsername}
280 placeholder={$_('register.telegramUsernamePlaceholder')}
281 disabled={saving || !isChannelAvailableOnServer('telegram')}
282 />
283 {#if telegramUsername && !telegramVerified && isChannelAvailableOnServer('telegram')}
284 <button type="button" class="verify-btn" onclick={() => verifyingChannel = 'telegram'}>{$_('comms.verifyButton')}</button>
285 {/if}
286 </div>
287 <p class="config-hint">{$_('comms.telegramHint')}</p>
288 {#if verifyingChannel === 'telegram'}
289 <div class="verify-form">
290 <input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="6" />
291 <button type="button" onclick={() => handleVerify('telegram')}>{$_('comms.submit')}</button>
292 <button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button>
293 </div>
294 {/if}
295 </div>
296
297 <div class="config-item" class:unavailable={!isChannelAvailableOnServer('signal')}>
298 <div class="config-header">
299 <label for="signal">{$_('register.signalNumber')}</label>
300 {#if !isChannelAvailableOnServer('signal')}
301 <span class="status unavailable">{$_('comms.notConfiguredOnServer')}</span>
302 {:else if signalNumber}
303 {#if signalVerified}
304 <span class="status verified">{$_('comms.verified')}</span>
305 {:else}
306 <span class="status unverified">{$_('comms.notVerified')}</span>
307 {/if}
308 {/if}
309 </div>
310 <div class="config-input">
311 <input
312 id="signal"
313 type="tel"
314 bind:value={signalNumber}
315 placeholder={$_('register.signalNumberPlaceholder')}
316 disabled={saving || !isChannelAvailableOnServer('signal')}
317 />
318 {#if signalNumber && !signalVerified && isChannelAvailableOnServer('signal')}
319 <button type="button" class="verify-btn" onclick={() => verifyingChannel = 'signal'}>{$_('comms.verifyButton')}</button>
320 {/if}
321 </div>
322 <p class="config-hint">{$_('comms.signalHint')}</p>
323 {#if verifyingChannel === 'signal'}
324 <div class="verify-form">
325 <input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="6" />
326 <button type="button" onclick={() => handleVerify('signal')}>{$_('comms.submit')}</button>
327 <button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button>
328 </div>
329 {/if}
330 </div>
331 </div>
332
333 </section>
334
335 <div class="actions">
336 <button type="submit" disabled={saving}>
337 {saving ? $_('common.saving') : $_('comms.savePreferences')}
338 </button>
339 </div>
340 </form>
341 </div>
342
343 <div class="side-column">
344 <section class="history-section">
345 <h2>{$_('comms.messageHistory')}</h2>
346 <p class="section-description">{$_('comms.historyDescription')}</p>
347 {#if historyLoading}
348 <div class="skeleton-list">
349 {#each [1, 2, 3] as _}
350 <div class="skeleton-item">
351 <div class="skeleton-header">
352 <div class="skeleton-line short"></div>
353 <div class="skeleton-line tiny"></div>
354 </div>
355 <div class="skeleton-line"></div>
356 <div class="skeleton-line medium"></div>
357 </div>
358 {/each}
359 </div>
360 {:else if messages.length === 0}
361 <p class="no-messages">{$_('comms.noMessages')}</p>
362 {:else}
363 <div class="message-list">
364 {#each messages as msg}
365 <div class="message-item">
366 <div class="message-header">
367 <span class="message-type">{msg.notificationType}</span>
368 <span class="message-channel">{msg.channel}</span>
369 <span class="message-status" class:sent={msg.status === 'sent'} class:failed={msg.status === 'failed'}>{msg.status}</span>
370 </div>
371 {#if msg.subject}
372 <div class="message-subject">{msg.subject}</div>
373 {/if}
374 <div class="message-body">{msg.body}</div>
375 <div class="message-date">{formatDate(msg.createdAt)}</div>
376 </div>
377 {/each}
378 </div>
379 {/if}
380 </section>
381 </div>
382 </div>
383 {/if}
384</div>
385<style>
386 .page {
387 max-width: var(--width-xl);
388 margin: 0 auto;
389 padding: var(--space-7);
390 }
391
392 header {
393 margin-bottom: var(--space-6);
394 }
395
396 .back {
397 color: var(--text-secondary);
398 text-decoration: none;
399 font-size: var(--text-sm);
400 }
401
402 .back:hover {
403 color: var(--accent);
404 }
405
406 h1 {
407 margin: var(--space-2) 0 0 0;
408 }
409
410 .description {
411 color: var(--text-secondary);
412 margin: var(--space-2) 0 0 0;
413 }
414
415 .split-layout {
416 display: grid;
417 grid-template-columns: 1fr;
418 gap: var(--space-6);
419 }
420
421 @media (min-width: 900px) {
422 .split-layout {
423 grid-template-columns: 1.5fr 1fr;
424 align-items: start;
425 }
426 }
427
428 .main-column, .side-column {
429 min-width: 0;
430 }
431
432 section {
433 background: var(--bg-secondary);
434 padding: var(--space-6);
435 border-radius: var(--radius-xl);
436 margin-bottom: var(--space-6);
437 }
438
439 .side-column section {
440 margin-bottom: 0;
441 }
442
443 section h2 {
444 margin: 0 0 var(--space-2) 0;
445 font-size: var(--text-lg);
446 }
447
448 .section-description {
449 color: var(--text-secondary);
450 font-size: var(--text-sm);
451 margin: 0 0 var(--space-4) 0;
452 }
453
454 .channel-options {
455 display: flex;
456 flex-direction: column;
457 gap: var(--space-2);
458 }
459
460 .channel-option {
461 display: flex;
462 align-items: flex-start;
463 gap: var(--space-3);
464 padding: var(--space-3);
465 background: var(--bg-card);
466 border: 1px solid var(--border-color);
467 border-radius: var(--radius-md);
468 cursor: pointer;
469 transition: border-color var(--transition-fast);
470 }
471
472 .channel-option:hover:not(.disabled) {
473 border-color: var(--accent);
474 }
475
476 .channel-option.disabled {
477 opacity: 0.6;
478 cursor: not-allowed;
479 }
480
481 .channel-option.unavailable {
482 opacity: 0.5;
483 background: var(--bg-input-disabled);
484 }
485
486 .channel-option input[type="radio"] {
487 flex-shrink: 0;
488 width: 16px;
489 height: 16px;
490 margin-top: 2px;
491 }
492
493 .channel-info {
494 flex: 1;
495 min-width: 0;
496 display: flex;
497 flex-direction: column;
498 gap: 2px;
499 }
500
501 .channel-name {
502 font-weight: var(--font-medium);
503 }
504
505 .channel-description {
506 font-size: var(--text-sm);
507 color: var(--text-secondary);
508 }
509
510 .channel-hint {
511 font-size: var(--text-xs);
512 color: var(--text-muted);
513 font-style: italic;
514 }
515
516 .channel-hint.server-unavailable {
517 color: var(--warning-text);
518 }
519
520 .channel-config {
521 display: flex;
522 flex-direction: column;
523 gap: var(--space-5);
524 }
525
526 .config-item {
527 display: flex;
528 flex-direction: column;
529 gap: var(--space-1);
530 }
531
532 .config-item.unavailable {
533 opacity: 0.6;
534 }
535
536 .config-header {
537 display: flex;
538 align-items: center;
539 justify-content: space-between;
540 gap: var(--space-3);
541 margin-bottom: var(--space-1);
542 }
543
544 .config-item label {
545 font-size: var(--text-sm);
546 font-weight: var(--font-medium);
547 }
548
549 .config-input {
550 display: flex;
551 align-items: center;
552 gap: var(--space-2);
553 }
554
555 .config-input input {
556 flex: 1;
557 min-width: 0;
558 }
559
560 .config-item input.readonly {
561 background: var(--bg-input-disabled);
562 color: var(--text-secondary);
563 }
564
565 .status {
566 padding: var(--space-1) var(--space-2);
567 border-radius: var(--radius-md);
568 font-size: var(--text-xs);
569 white-space: nowrap;
570 }
571
572 .status.verified {
573 background: var(--success-bg);
574 color: var(--success-text);
575 }
576
577 .status.unverified {
578 background: var(--warning-bg);
579 color: var(--warning-text);
580 }
581
582 .status.unavailable {
583 background: var(--bg-input-disabled);
584 color: var(--text-muted);
585 }
586
587 .config-hint {
588 font-size: var(--text-xs);
589 color: var(--text-secondary);
590 margin: 0;
591 }
592
593 .actions {
594 display: flex;
595 justify-content: flex-end;
596 }
597
598 .verify-btn {
599 padding: var(--space-1) var(--space-2);
600 background: var(--accent);
601 color: var(--text-inverse);
602 border: none;
603 border-radius: var(--radius-md);
604 font-size: var(--text-xs);
605 cursor: pointer;
606 }
607
608 .verify-btn:hover {
609 background: var(--accent-hover);
610 }
611
612 .verify-form {
613 display: flex;
614 gap: var(--space-2);
615 margin-top: var(--space-2);
616 align-items: center;
617 }
618
619 .verify-form input {
620 padding: var(--space-2);
621 font-size: var(--text-sm);
622 width: 150px;
623 }
624
625 .verify-form button {
626 padding: var(--space-2) var(--space-3);
627 background: var(--accent);
628 color: var(--text-inverse);
629 border: none;
630 border-radius: var(--radius-md);
631 font-size: var(--text-sm);
632 cursor: pointer;
633 }
634
635 .verify-form button:hover {
636 background: var(--accent-hover);
637 }
638
639 .verify-form button.cancel {
640 background: transparent;
641 border: 1px solid var(--border-color);
642 color: var(--text-secondary);
643 }
644
645 .verify-form button.cancel:hover {
646 background: var(--bg-secondary);
647 }
648
649 .history-section h2 {
650 margin: 0 0 var(--space-2) 0;
651 font-size: var(--text-lg);
652 }
653
654 .skeleton-list {
655 display: flex;
656 flex-direction: column;
657 gap: var(--space-3);
658 }
659
660 .skeleton-item {
661 background: var(--bg-card);
662 border: 1px solid var(--border-color);
663 border-radius: var(--radius-md);
664 padding: var(--space-3);
665 }
666
667 .skeleton-header {
668 display: flex;
669 gap: var(--space-2);
670 margin-bottom: var(--space-2);
671 }
672
673 .skeleton-line {
674 height: 14px;
675 background: var(--bg-tertiary);
676 border-radius: var(--radius-sm);
677 animation: skeleton-pulse 1.5s ease-in-out infinite;
678 }
679
680 .skeleton-line.short {
681 width: 80px;
682 }
683
684 .skeleton-line.tiny {
685 width: 50px;
686 }
687
688 .skeleton-line.medium {
689 width: 60%;
690 }
691
692 .skeleton-line:not(.short):not(.tiny):not(.medium) {
693 width: 100%;
694 margin-bottom: var(--space-1);
695 }
696
697 @keyframes skeleton-pulse {
698 0%, 100% { opacity: 1; }
699 50% { opacity: 0.4; }
700 }
701
702 .no-messages {
703 color: var(--text-secondary);
704 font-style: italic;
705 margin-top: var(--space-4);
706 }
707
708 .message-list {
709 display: flex;
710 flex-direction: column;
711 gap: var(--space-3);
712 margin-top: var(--space-4);
713 }
714
715 .message-item {
716 background: var(--bg-card);
717 border: 1px solid var(--border-color);
718 border-radius: var(--radius-md);
719 padding: var(--space-3);
720 }
721
722 .message-header {
723 display: flex;
724 gap: var(--space-2);
725 margin-bottom: var(--space-2);
726 flex-wrap: wrap;
727 align-items: center;
728 }
729
730 .message-type {
731 font-weight: var(--font-medium);
732 font-size: var(--text-sm);
733 }
734
735 .message-channel {
736 font-size: var(--text-xs);
737 padding: var(--space-1) var(--space-2);
738 background: var(--bg-secondary);
739 border-radius: var(--radius-md);
740 color: var(--text-secondary);
741 }
742
743 .message-status {
744 font-size: var(--text-xs);
745 padding: var(--space-1) var(--space-2);
746 border-radius: var(--radius-md);
747 margin-left: auto;
748 }
749
750 .message-status.sent {
751 background: var(--success-bg);
752 color: var(--success-text);
753 }
754
755 .message-status.failed {
756 background: var(--error-bg);
757 color: var(--error-text);
758 }
759
760 .message-subject {
761 font-weight: var(--font-medium);
762 font-size: var(--text-sm);
763 margin-bottom: var(--space-1);
764 }
765
766 .message-body {
767 font-size: var(--text-sm);
768 color: var(--text-secondary);
769 white-space: pre-wrap;
770 word-break: break-word;
771 }
772
773 .message-date {
774 font-size: var(--text-xs);
775 color: var(--text-muted);
776 margin-top: var(--space-2);
777 }
778
779 .skeleton-sections {
780 display: flex;
781 flex-direction: column;
782 gap: var(--space-6);
783 }
784
785 .skeleton-section {
786 height: 180px;
787 background: var(--bg-secondary);
788 border-radius: var(--radius-xl);
789 animation: skeleton-pulse 1.5s ease-in-out infinite;
790 }
791
792 @keyframes skeleton-pulse {
793 0%, 100% { opacity: 1; }
794 50% { opacity: 0.5; }
795 }
796</style>