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