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 sidebar-right">
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 section {
416 background: var(--bg-secondary);
417 padding: var(--space-6);
418 border-radius: var(--radius-xl);
419 margin-bottom: var(--space-6);
420 }
421
422 .side-column section {
423 margin-bottom: 0;
424 }
425
426 section h2 {
427 margin: 0 0 var(--space-2) 0;
428 font-size: var(--text-lg);
429 }
430
431 .section-description {
432 color: var(--text-secondary);
433 font-size: var(--text-sm);
434 margin: 0 0 var(--space-4) 0;
435 }
436
437 .channel-options {
438 display: flex;
439 flex-direction: column;
440 gap: var(--space-2);
441 }
442
443 .channel-option {
444 display: flex;
445 align-items: flex-start;
446 gap: var(--space-3);
447 padding: var(--space-3);
448 background: var(--bg-card);
449 border: 1px solid var(--border-color);
450 border-radius: var(--radius-md);
451 cursor: pointer;
452 transition: border-color var(--transition-fast);
453 }
454
455 .channel-option:hover:not(.disabled) {
456 border-color: var(--accent);
457 }
458
459 .channel-option.disabled {
460 opacity: 0.6;
461 cursor: not-allowed;
462 }
463
464 .channel-option.unavailable {
465 opacity: 0.5;
466 background: var(--bg-input-disabled);
467 }
468
469 .channel-option input[type="radio"] {
470 flex-shrink: 0;
471 width: 16px;
472 height: 16px;
473 margin-top: 2px;
474 }
475
476 .channel-info {
477 flex: 1;
478 min-width: 0;
479 display: flex;
480 flex-direction: column;
481 gap: 2px;
482 }
483
484 .channel-name {
485 font-weight: var(--font-medium);
486 }
487
488 .channel-description {
489 font-size: var(--text-sm);
490 color: var(--text-secondary);
491 }
492
493 .channel-hint {
494 font-size: var(--text-xs);
495 color: var(--text-muted);
496 font-style: italic;
497 }
498
499 .channel-hint.server-unavailable {
500 color: var(--warning-text);
501 }
502
503 .channel-config {
504 display: flex;
505 flex-direction: column;
506 gap: var(--space-5);
507 }
508
509 .config-item {
510 display: flex;
511 flex-direction: column;
512 gap: var(--space-1);
513 }
514
515 .config-item.unavailable {
516 opacity: 0.6;
517 }
518
519 .config-header {
520 display: flex;
521 align-items: center;
522 justify-content: space-between;
523 gap: var(--space-3);
524 margin-bottom: var(--space-1);
525 }
526
527 .config-item label {
528 font-size: var(--text-sm);
529 font-weight: var(--font-medium);
530 }
531
532 .config-input {
533 display: flex;
534 align-items: center;
535 gap: var(--space-2);
536 }
537
538 .config-input input {
539 flex: 1;
540 min-width: 0;
541 }
542
543 .config-item input.readonly {
544 background: var(--bg-input-disabled);
545 color: var(--text-secondary);
546 }
547
548 .status {
549 padding: var(--space-1) var(--space-2);
550 border-radius: var(--radius-md);
551 font-size: var(--text-xs);
552 white-space: nowrap;
553 }
554
555 .status.verified {
556 background: var(--success-bg);
557 color: var(--success-text);
558 }
559
560 .status.unverified {
561 background: var(--warning-bg);
562 color: var(--warning-text);
563 }
564
565 .status.unavailable {
566 background: var(--bg-input-disabled);
567 color: var(--text-muted);
568 }
569
570 .config-hint {
571 font-size: var(--text-xs);
572 color: var(--text-secondary);
573 margin: 0;
574 }
575
576 .actions {
577 display: flex;
578 justify-content: flex-end;
579 }
580
581 .verify-btn {
582 padding: var(--space-1) var(--space-2);
583 background: var(--accent);
584 color: var(--text-inverse);
585 border: none;
586 border-radius: var(--radius-md);
587 font-size: var(--text-xs);
588 cursor: pointer;
589 }
590
591 .verify-btn:hover {
592 background: var(--accent-hover);
593 }
594
595 .verify-form {
596 display: flex;
597 gap: var(--space-2);
598 margin-top: var(--space-2);
599 align-items: center;
600 }
601
602 .verify-form input {
603 padding: var(--space-2);
604 font-size: var(--text-sm);
605 width: 150px;
606 }
607
608 .verify-form button {
609 padding: var(--space-2) var(--space-3);
610 background: var(--accent);
611 color: var(--text-inverse);
612 border: none;
613 border-radius: var(--radius-md);
614 font-size: var(--text-sm);
615 cursor: pointer;
616 }
617
618 .verify-form button:hover {
619 background: var(--accent-hover);
620 }
621
622 .verify-form button.cancel {
623 background: transparent;
624 border: 1px solid var(--border-color);
625 color: var(--text-secondary);
626 }
627
628 .verify-form button.cancel:hover {
629 background: var(--bg-secondary);
630 }
631
632 .history-section h2 {
633 margin: 0 0 var(--space-2) 0;
634 font-size: var(--text-lg);
635 }
636
637 .skeleton-list {
638 display: flex;
639 flex-direction: column;
640 gap: var(--space-3);
641 }
642
643 .skeleton-item {
644 background: var(--bg-card);
645 border: 1px solid var(--border-color);
646 border-radius: var(--radius-md);
647 padding: var(--space-3);
648 }
649
650 .skeleton-header {
651 display: flex;
652 gap: var(--space-2);
653 margin-bottom: var(--space-2);
654 }
655
656 .skeleton-line {
657 height: 14px;
658 background: var(--bg-tertiary);
659 border-radius: var(--radius-sm);
660 animation: skeleton-pulse 1.5s ease-in-out infinite;
661 }
662
663 .skeleton-line.short {
664 width: 80px;
665 }
666
667 .skeleton-line.tiny {
668 width: 50px;
669 }
670
671 .skeleton-line.medium {
672 width: 60%;
673 }
674
675 .skeleton-line:not(.short):not(.tiny):not(.medium) {
676 width: 100%;
677 margin-bottom: var(--space-1);
678 }
679
680 @keyframes skeleton-pulse {
681 0%, 100% { opacity: 1; }
682 50% { opacity: 0.4; }
683 }
684
685 .no-messages {
686 color: var(--text-secondary);
687 font-style: italic;
688 margin-top: var(--space-4);
689 }
690
691 .message-list {
692 display: flex;
693 flex-direction: column;
694 gap: var(--space-3);
695 margin-top: var(--space-4);
696 }
697
698 .message-item {
699 background: var(--bg-card);
700 border: 1px solid var(--border-color);
701 border-radius: var(--radius-md);
702 padding: var(--space-3);
703 }
704
705 .message-header {
706 display: flex;
707 gap: var(--space-2);
708 margin-bottom: var(--space-2);
709 flex-wrap: wrap;
710 align-items: center;
711 }
712
713 .message-type {
714 font-weight: var(--font-medium);
715 font-size: var(--text-sm);
716 }
717
718 .message-channel {
719 font-size: var(--text-xs);
720 padding: var(--space-1) var(--space-2);
721 background: var(--bg-secondary);
722 border-radius: var(--radius-md);
723 color: var(--text-secondary);
724 }
725
726 .message-status {
727 font-size: var(--text-xs);
728 padding: var(--space-1) var(--space-2);
729 border-radius: var(--radius-md);
730 margin-left: auto;
731 }
732
733 .message-status.sent {
734 background: var(--success-bg);
735 color: var(--success-text);
736 }
737
738 .message-status.failed {
739 background: var(--error-bg);
740 color: var(--error-text);
741 }
742
743 .message-subject {
744 font-weight: var(--font-medium);
745 font-size: var(--text-sm);
746 margin-bottom: var(--space-1);
747 }
748
749 .message-body {
750 font-size: var(--text-sm);
751 color: var(--text-secondary);
752 white-space: pre-wrap;
753 word-break: break-word;
754 }
755
756 .message-date {
757 font-size: var(--text-xs);
758 color: var(--text-muted);
759 margin-top: var(--space-2);
760 }
761
762 .skeleton-sections {
763 display: flex;
764 flex-direction: column;
765 gap: var(--space-6);
766 }
767
768 .skeleton-section {
769 height: 180px;
770 background: var(--bg-secondary);
771 border-radius: var(--radius-xl);
772 animation: skeleton-pulse 1.5s ease-in-out infinite;
773 }
774
775 @keyframes skeleton-pulse {
776 0%, 100% { opacity: 1; }
777 50% { opacity: 0.5; }
778 }
779</style>