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