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