this repo has no description
1<script lang="ts">
2 import { getAuthState } from '../lib/auth.svelte'
3 import { navigate } from '../lib/router.svelte'
4 import { api, ApiError } from '../lib/api'
5 const auth = getAuthState()
6 let loading = $state(true)
7 let saving = $state(false)
8 let error = $state<string | null>(null)
9 let success = $state<string | null>(null)
10 let preferredChannel = $state('email')
11 let email = $state('')
12 let discordId = $state('')
13 let discordVerified = $state(false)
14 let telegramUsername = $state('')
15 let telegramVerified = $state(false)
16 let signalNumber = $state('')
17 let signalVerified = $state(false)
18 let verifyingChannel = $state<string | null>(null)
19 let verificationCode = $state('')
20 let verificationError = $state<string | null>(null)
21 let verificationSuccess = $state<string | null>(null)
22 let historyLoading = $state(false)
23 let historyError = $state<string | null>(null)
24 let notifications = $state<Array<{
25 createdAt: string
26 channel: string
27 notificationType: string
28 status: string
29 subject: string | null
30 body: string
31 }>>([])
32 let showHistory = $state(false)
33 $effect(() => {
34 if (!auth.loading && !auth.session) {
35 navigate('/login')
36 }
37 })
38 $effect(() => {
39 if (auth.session) {
40 loadPrefs()
41 }
42 })
43 async function loadPrefs() {
44 if (!auth.session) return
45 loading = true
46 error = null
47 try {
48 const prefs = await api.getNotificationPrefs(auth.session.accessJwt)
49 preferredChannel = prefs.preferredChannel
50 email = prefs.email
51 discordId = prefs.discordId ?? ''
52 discordVerified = prefs.discordVerified
53 telegramUsername = prefs.telegramUsername ?? ''
54 telegramVerified = prefs.telegramVerified
55 signalNumber = prefs.signalNumber ?? ''
56 signalVerified = prefs.signalVerified
57 } catch (e) {
58 error = e instanceof ApiError ? e.message : 'Failed to load notification preferences'
59 } finally {
60 loading = false
61 }
62 }
63 async function handleSave(e: Event) {
64 e.preventDefault()
65 if (!auth.session) return
66 saving = true
67 error = null
68 success = null
69 try {
70 await api.updateNotificationPrefs(auth.session.accessJwt, {
71 preferredChannel,
72 discordId: discordId || undefined,
73 telegramUsername: telegramUsername || undefined,
74 signalNumber: signalNumber || undefined,
75 })
76 success = 'Notification preferences saved'
77 await loadPrefs()
78 } catch (e) {
79 error = e instanceof ApiError ? e.message : 'Failed to save preferences'
80 } finally {
81 saving = false
82 }
83 }
84 async function handleVerify(channel: string) {
85 if (!auth.session || !verificationCode) return
86 verificationError = null
87 verificationSuccess = null
88 try {
89 await api.confirmChannelVerification(auth.session.accessJwt, channel, verificationCode)
90 verificationSuccess = `${channel} verified successfully`
91 verificationCode = ''
92 verifyingChannel = null
93 await loadPrefs()
94 } catch (e) {
95 verificationError = e instanceof ApiError ? e.message : 'Failed to verify channel'
96 }
97 }
98 async function loadHistory() {
99 if (!auth.session) return
100 historyLoading = true
101 historyError = null
102 try {
103 const result = await api.getNotificationHistory(auth.session.accessJwt)
104 notifications = result.notifications
105 showHistory = true
106 } catch (e) {
107 historyError = e instanceof ApiError ? e.message : 'Failed to load notification history'
108 } finally {
109 historyLoading = false
110 }
111 }
112 function formatDate(dateStr: string): string {
113 return new Date(dateStr).toLocaleString()
114 }
115 const channels = [
116 { id: 'email', name: 'Email', description: 'Receive notifications via email' },
117 { id: 'discord', name: 'Discord', description: 'Receive notifications via Discord DM' },
118 { id: 'telegram', name: 'Telegram', description: 'Receive notifications via Telegram' },
119 { id: 'signal', name: 'Signal', description: 'Receive notifications via Signal' },
120 ]
121 function canSelectChannel(channelId: string): boolean {
122 if (channelId === 'email') return true
123 if (channelId === 'discord') return !!discordId
124 if (channelId === 'telegram') return !!telegramUsername
125 if (channelId === 'signal') return !!signalNumber
126 return false
127 }
128 function needsVerification(channelId: string): boolean {
129 if (channelId === 'discord') return !!discordId && !discordVerified
130 if (channelId === 'telegram') return !!telegramUsername && !telegramVerified
131 if (channelId === 'signal') return !!signalNumber && !signalVerified
132 return false
133 }
134</script>
135<div class="page">
136 <header>
137 <a href="#/dashboard" class="back">← Dashboard</a>
138 <h1>Notification Preferences</h1>
139 </header>
140 <p class="description">
141 Choose how you want to receive important notifications like password resets,
142 security alerts, and account updates.
143 </p>
144 {#if loading}
145 <p class="loading">Loading...</p>
146 {:else}
147 {#if error}
148 <div class="message error">{error}</div>
149 {/if}
150 {#if success}
151 <div class="message success">{success}</div>
152 {/if}
153 <form onsubmit={handleSave}>
154 <section>
155 <h2>Preferred Channel</h2>
156 <p class="section-description">
157 Select your preferred way to receive notifications. You must configure a channel before you can select it.
158 </p>
159 <div class="channel-options">
160 {#each channels as channel}
161 <label class="channel-option" class:disabled={!canSelectChannel(channel.id)}>
162 <input
163 type="radio"
164 name="preferredChannel"
165 value={channel.id}
166 bind:group={preferredChannel}
167 disabled={!canSelectChannel(channel.id) || saving}
168 />
169 <div class="channel-info">
170 <span class="channel-name">{channel.name}</span>
171 <span class="channel-description">{channel.description}</span>
172 {#if channel.id !== 'email' && !canSelectChannel(channel.id)}
173 <span class="channel-hint">Configure below to enable</span>
174 {/if}
175 </div>
176 </label>
177 {/each}
178 </div>
179 </section>
180 <section>
181 <h2>Channel Configuration</h2>
182 <div class="channel-config">
183 <div class="config-item">
184 <label for="email">Email</label>
185 <div class="config-input">
186 <input
187 id="email"
188 type="email"
189 value={email}
190 disabled
191 class="readonly"
192 />
193 <span class="status verified">Primary</span>
194 </div>
195 <p class="config-hint">Your email is managed in Account Settings</p>
196 </div>
197 <div class="config-item">
198 <label for="discord">Discord User ID</label>
199 <div class="config-input">
200 <input
201 id="discord"
202 type="text"
203 bind:value={discordId}
204 placeholder="e.g., 123456789012345678"
205 disabled={saving}
206 />
207 {#if discordId}
208 {#if discordVerified}
209 <span class="status verified">Verified</span>
210 {:else}
211 <span class="status unverified">Not verified</span>
212 <button type="button" class="verify-btn" onclick={() => verifyingChannel = 'discord'}>Verify</button>
213 {/if}
214 {/if}
215 </div>
216 <p class="config-hint">Your Discord user ID (not username). Enable Developer Mode in Discord to copy it.</p>
217 {#if verifyingChannel === 'discord'}
218 <div class="verify-form">
219 <input
220 type="text"
221 bind:value={verificationCode}
222 placeholder="Enter verification code"
223 maxlength="6"
224 />
225 <button type="button" onclick={() => handleVerify('discord')}>Submit</button>
226 <button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>Cancel</button>
227 </div>
228 {/if}
229 </div>
230 <div class="config-item">
231 <label for="telegram">Telegram Username</label>
232 <div class="config-input">
233 <input
234 id="telegram"
235 type="text"
236 bind:value={telegramUsername}
237 placeholder="e.g., username"
238 disabled={saving}
239 />
240 {#if telegramUsername}
241 {#if telegramVerified}
242 <span class="status verified">Verified</span>
243 {:else}
244 <span class="status unverified">Not verified</span>
245 <button type="button" class="verify-btn" onclick={() => verifyingChannel = 'telegram'}>Verify</button>
246 {/if}
247 {/if}
248 </div>
249 <p class="config-hint">Your Telegram username without the @ symbol</p>
250 {#if verifyingChannel === 'telegram'}
251 <div class="verify-form">
252 <input
253 type="text"
254 bind:value={verificationCode}
255 placeholder="Enter verification code"
256 maxlength="6"
257 />
258 <button type="button" onclick={() => handleVerify('telegram')}>Submit</button>
259 <button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>Cancel</button>
260 </div>
261 {/if}
262 </div>
263 <div class="config-item">
264 <label for="signal">Signal Phone Number</label>
265 <div class="config-input">
266 <input
267 id="signal"
268 type="tel"
269 bind:value={signalNumber}
270 placeholder="e.g., +1234567890"
271 disabled={saving}
272 />
273 {#if signalNumber}
274 {#if signalVerified}
275 <span class="status verified">Verified</span>
276 {:else}
277 <span class="status unverified">Not verified</span>
278 <button type="button" class="verify-btn" onclick={() => verifyingChannel = 'signal'}>Verify</button>
279 {/if}
280 {/if}
281 </div>
282 <p class="config-hint">Your Signal phone number with country code</p>
283 {#if verifyingChannel === 'signal'}
284 <div class="verify-form">
285 <input
286 type="text"
287 bind:value={verificationCode}
288 placeholder="Enter verification code"
289 maxlength="6"
290 />
291 <button type="button" onclick={() => handleVerify('signal')}>Submit</button>
292 <button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>Cancel</button>
293 </div>
294 {/if}
295 </div>
296 </div>
297 {#if verificationError}
298 <div class="message error" style="margin-top: 1rem">{verificationError}</div>
299 {/if}
300 {#if verificationSuccess}
301 <div class="message success" style="margin-top: 1rem">{verificationSuccess}</div>
302 {/if}
303 </section>
304 <div class="actions">
305 <button type="submit" disabled={saving}>
306 {saving ? 'Saving...' : 'Save Preferences'}
307 </button>
308 </div>
309 </form>
310 <section class="history-section">
311 <h2>Notification History</h2>
312 <p class="section-description">View recent notifications sent to your account.</p>
313 {#if !showHistory}
314 <button class="load-history" onclick={loadHistory} disabled={historyLoading}>
315 {historyLoading ? 'Loading...' : 'Load History'}
316 </button>
317 {:else}
318 <button class="load-history" onclick={() => showHistory = false}>Hide History</button>
319 {#if historyError}
320 <div class="message error">{historyError}</div>
321 {:else if notifications.length === 0}
322 <p class="no-notifications">No notifications found.</p>
323 {:else}
324 <div class="notification-list">
325 {#each notifications as notification}
326 <div class="notification-item">
327 <div class="notification-header">
328 <span class="notification-type">{notification.notificationType}</span>
329 <span class="notification-channel">{notification.channel}</span>
330 <span class="notification-status" class:sent={notification.status === 'sent'} class:failed={notification.status === 'failed'}>{notification.status}</span>
331 </div>
332 {#if notification.subject}
333 <div class="notification-subject">{notification.subject}</div>
334 {/if}
335 <div class="notification-body">{notification.body}</div>
336 <div class="notification-date">{formatDate(notification.createdAt)}</div>
337 </div>
338 {/each}
339 </div>
340 {/if}
341 {/if}
342 </section>
343 {/if}
344</div>
345<style>
346 .page {
347 max-width: 600px;
348 margin: 0 auto;
349 padding: 2rem;
350 }
351 header {
352 margin-bottom: 1rem;
353 }
354 .back {
355 color: var(--text-secondary);
356 text-decoration: none;
357 font-size: 0.875rem;
358 }
359 .back:hover {
360 color: var(--accent);
361 }
362 h1 {
363 margin: 0.5rem 0 0 0;
364 }
365 .description {
366 color: var(--text-secondary);
367 margin-bottom: 2rem;
368 }
369 .loading {
370 text-align: center;
371 color: var(--text-secondary);
372 padding: 2rem;
373 }
374 .message {
375 padding: 0.75rem;
376 border-radius: 4px;
377 margin-bottom: 1rem;
378 }
379 .message.error {
380 background: var(--error-bg);
381 border: 1px solid var(--error-border);
382 color: var(--error-text);
383 }
384 .message.success {
385 background: var(--success-bg);
386 border: 1px solid var(--success-border);
387 color: var(--success-text);
388 }
389 section {
390 background: var(--bg-secondary);
391 padding: 1.5rem;
392 border-radius: 8px;
393 margin-bottom: 1.5rem;
394 }
395 section h2 {
396 margin: 0 0 0.5rem 0;
397 font-size: 1.125rem;
398 }
399 .section-description {
400 color: var(--text-secondary);
401 font-size: 0.875rem;
402 margin: 0 0 1rem 0;
403 }
404 .channel-options {
405 display: flex;
406 flex-direction: column;
407 gap: 0.5rem;
408 }
409 .channel-option {
410 display: flex;
411 align-items: flex-start;
412 gap: 0.75rem;
413 padding: 0.75rem;
414 background: var(--bg-card);
415 border: 1px solid var(--border-color);
416 border-radius: 4px;
417 cursor: pointer;
418 transition: border-color 0.15s;
419 }
420 .channel-option:hover:not(.disabled) {
421 border-color: var(--accent);
422 }
423 .channel-option.disabled {
424 opacity: 0.6;
425 cursor: not-allowed;
426 }
427 .channel-option input {
428 margin-top: 0.25rem;
429 }
430 .channel-info {
431 display: flex;
432 flex-direction: column;
433 gap: 0.125rem;
434 }
435 .channel-name {
436 font-weight: 500;
437 }
438 .channel-description {
439 font-size: 0.875rem;
440 color: var(--text-secondary);
441 }
442 .channel-hint {
443 font-size: 0.75rem;
444 color: var(--text-muted);
445 font-style: italic;
446 }
447 .channel-config {
448 display: flex;
449 flex-direction: column;
450 gap: 1.25rem;
451 }
452 .config-item {
453 display: flex;
454 flex-direction: column;
455 gap: 0.25rem;
456 }
457 .config-item label {
458 font-size: 0.875rem;
459 font-weight: 500;
460 }
461 .config-input {
462 display: flex;
463 align-items: center;
464 gap: 0.5rem;
465 }
466 .config-input input {
467 flex: 1;
468 padding: 0.75rem;
469 border: 1px solid var(--border-color-light);
470 border-radius: 4px;
471 font-size: 1rem;
472 background: var(--bg-input);
473 color: var(--text-primary);
474 }
475 .config-input input:focus {
476 outline: none;
477 border-color: var(--accent);
478 }
479 .config-input input.readonly {
480 background: var(--bg-input-disabled);
481 color: var(--text-secondary);
482 }
483 .status {
484 padding: 0.25rem 0.5rem;
485 border-radius: 4px;
486 font-size: 0.75rem;
487 white-space: nowrap;
488 }
489 .status.verified {
490 background: var(--success-bg);
491 color: var(--success-text);
492 }
493 .status.unverified {
494 background: var(--warning-bg);
495 color: var(--warning-text);
496 }
497 .config-hint {
498 font-size: 0.75rem;
499 color: var(--text-secondary);
500 margin: 0;
501 }
502 .actions {
503 display: flex;
504 justify-content: flex-end;
505 }
506 .actions button {
507 padding: 0.75rem 2rem;
508 background: var(--accent);
509 color: white;
510 border: none;
511 border-radius: 4px;
512 font-size: 1rem;
513 cursor: pointer;
514 }
515 .actions button:hover:not(:disabled) {
516 background: var(--accent-hover);
517 }
518 .actions button:disabled {
519 opacity: 0.6;
520 cursor: not-allowed;
521 }
522 .verify-btn {
523 padding: 0.25rem 0.5rem;
524 background: var(--accent);
525 color: white;
526 border: none;
527 border-radius: 4px;
528 font-size: 0.75rem;
529 cursor: pointer;
530 }
531 .verify-btn:hover {
532 background: var(--accent-hover);
533 }
534 .verify-form {
535 display: flex;
536 gap: 0.5rem;
537 margin-top: 0.5rem;
538 align-items: center;
539 }
540 .verify-form input {
541 padding: 0.5rem;
542 border: 1px solid var(--border-color-light);
543 border-radius: 4px;
544 font-size: 0.875rem;
545 width: 150px;
546 background: var(--bg-input);
547 color: var(--text-primary);
548 }
549 .verify-form button {
550 padding: 0.5rem 0.75rem;
551 background: var(--accent);
552 color: white;
553 border: none;
554 border-radius: 4px;
555 font-size: 0.875rem;
556 cursor: pointer;
557 }
558 .verify-form button:hover {
559 background: var(--accent-hover);
560 }
561 .verify-form button.cancel {
562 background: transparent;
563 border: 1px solid var(--border-color);
564 color: var(--text-secondary);
565 }
566 .verify-form button.cancel:hover {
567 background: var(--bg-secondary);
568 }
569 .history-section {
570 background: var(--bg-secondary);
571 padding: 1.5rem;
572 border-radius: 8px;
573 margin-top: 1.5rem;
574 }
575 .history-section h2 {
576 margin: 0 0 0.5rem 0;
577 font-size: 1.125rem;
578 }
579 .load-history {
580 padding: 0.5rem 1rem;
581 background: transparent;
582 border: 1px solid var(--border-color);
583 border-radius: 4px;
584 cursor: pointer;
585 color: var(--text-primary);
586 margin-top: 0.5rem;
587 }
588 .load-history:hover:not(:disabled) {
589 background: var(--bg-card);
590 border-color: var(--accent);
591 }
592 .load-history:disabled {
593 opacity: 0.6;
594 cursor: not-allowed;
595 }
596 .no-notifications {
597 color: var(--text-secondary);
598 font-style: italic;
599 margin-top: 1rem;
600 }
601 .notification-list {
602 display: flex;
603 flex-direction: column;
604 gap: 0.75rem;
605 margin-top: 1rem;
606 }
607 .notification-item {
608 background: var(--bg-card);
609 border: 1px solid var(--border-color);
610 border-radius: 4px;
611 padding: 0.75rem;
612 }
613 .notification-header {
614 display: flex;
615 gap: 0.5rem;
616 margin-bottom: 0.5rem;
617 flex-wrap: wrap;
618 align-items: center;
619 }
620 .notification-type {
621 font-weight: 500;
622 font-size: 0.875rem;
623 }
624 .notification-channel {
625 font-size: 0.75rem;
626 padding: 0.125rem 0.375rem;
627 background: var(--bg-secondary);
628 border-radius: 4px;
629 color: var(--text-secondary);
630 }
631 .notification-status {
632 font-size: 0.75rem;
633 padding: 0.125rem 0.375rem;
634 border-radius: 4px;
635 margin-left: auto;
636 }
637 .notification-status.sent {
638 background: var(--success-bg);
639 color: var(--success-text);
640 }
641 .notification-status.failed {
642 background: var(--error-bg);
643 color: var(--error-text);
644 }
645 .notification-subject {
646 font-weight: 500;
647 font-size: 0.875rem;
648 margin-bottom: 0.25rem;
649 }
650 .notification-body {
651 font-size: 0.875rem;
652 color: var(--text-secondary);
653 white-space: pre-wrap;
654 word-break: break-word;
655 }
656 .notification-date {
657 font-size: 0.75rem;
658 color: var(--text-muted);
659 margin-top: 0.5rem;
660 }
661</style>