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>