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