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">&larr; 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>