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>