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>