this repo has no description
1<script lang="ts"> 2 import { getAuthState, refreshSession } from '../lib/auth.svelte' 3 import { navigate, routes, getFullUrl } from '../lib/router.svelte' 4 import { api, ApiError } from '../lib/api' 5 import { _ } from '../lib/i18n' 6 import { formatDateTime } from '../lib/date' 7 import type { Session } from '../lib/types/api' 8 import { toast } from '../lib/toast.svelte' 9 10 const auth = $derived(getAuthState()) 11 12 function getSession(): Session | null { 13 return auth.kind === 'authenticated' ? auth.session : null 14 } 15 16 function isLoading(): boolean { 17 return auth.kind === 'loading' 18 } 19 20 const session = $derived(getSession()) 21 const authLoading = $derived(isLoading()) 22 let loading = $state(true) 23 let saving = $state(false) 24 let preferredChannel = $state('email') 25 let availableCommsChannels = $state<string[]>(['email']) 26 let email = $state('') 27 let discordId = $state('') 28 let discordVerified = $state(false) 29 let telegramUsername = $state('') 30 let telegramVerified = $state(false) 31 let signalNumber = $state('') 32 let signalVerified = $state(false) 33 let verifyingChannel = $state<string | null>(null) 34 let verificationCode = $state('') 35 let historyLoading = $state(true) 36 let messages = $state<Array<{ 37 createdAt: string 38 channel: string 39 notificationType: string 40 status: string 41 subject: string | null 42 body: string 43 }>>([]) 44 $effect(() => { 45 if (!authLoading && !session) { 46 navigate(routes.login) 47 } 48 }) 49 $effect(() => { 50 if (session) { 51 loadPrefs() 52 loadHistory() 53 } 54 }) 55 async function loadPrefs() { 56 if (!session) return 57 loading = true 58 try { 59 const [prefs, serverInfo] = await Promise.all([ 60 api.getNotificationPrefs(session.accessJwt), 61 api.describeServer() 62 ]) 63 preferredChannel = prefs.preferredChannel 64 email = prefs.email 65 discordId = prefs.discordId ?? '' 66 discordVerified = prefs.discordVerified 67 telegramUsername = prefs.telegramUsername ?? '' 68 telegramVerified = prefs.telegramVerified 69 signalNumber = prefs.signalNumber ?? '' 70 signalVerified = prefs.signalVerified 71 availableCommsChannels = serverInfo.availableCommsChannels ?? ['email'] 72 } catch (e) { 73 toast.error(e instanceof ApiError ? e.message : $_('comms.failedToLoad')) 74 } finally { 75 loading = false 76 } 77 } 78 async function handleSave(e: Event) { 79 e.preventDefault() 80 if (!session) return 81 saving = true 82 try { 83 await api.updateNotificationPrefs(session.accessJwt, { 84 preferredChannel, 85 discordId: discordId || undefined, 86 telegramUsername: telegramUsername || undefined, 87 signalNumber: signalNumber || undefined, 88 }) 89 await refreshSession() 90 toast.success($_('comms.preferencesSaved')) 91 await loadPrefs() 92 } catch (e) { 93 toast.error(e instanceof ApiError ? e.message : $_('comms.failedToSave')) 94 } finally { 95 saving = false 96 } 97 } 98 async function handleVerify(channel: string) { 99 if (!session || !verificationCode) return 100 101 let identifier = '' 102 switch (channel) { 103 case 'discord': identifier = discordId; break 104 case 'telegram': identifier = telegramUsername; break 105 case 'signal': identifier = signalNumber; break 106 } 107 if (!identifier) return 108 109 try { 110 await api.confirmChannelVerification(session.accessJwt, channel, identifier, verificationCode) 111 await refreshSession() 112 toast.success($_('comms.verifiedSuccess', { values: { channel } })) 113 verificationCode = '' 114 verifyingChannel = null 115 await loadPrefs() 116 } catch (e) { 117 toast.error(e instanceof ApiError ? e.message : $_('comms.failedToVerify')) 118 } 119 } 120 async function loadHistory() { 121 if (!session) return 122 historyLoading = true 123 try { 124 const result = await api.getNotificationHistory(session.accessJwt) 125 messages = result.notifications 126 } catch (e) { 127 toast.error(e instanceof ApiError ? e.message : $_('comms.failedToLoadHistory')) 128 } finally { 129 historyLoading = false 130 } 131 } 132 function formatDate(dateStr: string): string { 133 return formatDateTime(dateStr) 134 } 135 const channels = ['email', 'discord', 'telegram', 'signal'] 136 function getChannelName(id: string): string { 137 switch (id) { 138 case 'email': return $_('register.email') 139 case 'discord': return $_('register.discord') 140 case 'telegram': return $_('register.telegram') 141 case 'signal': return $_('register.signal') 142 default: return id 143 } 144 } 145 function getChannelDescription(id: string): string { 146 switch (id) { 147 case 'email': return $_('comms.emailVia') 148 case 'discord': return $_('comms.discordVia') 149 case 'telegram': return $_('comms.telegramVia') 150 case 'signal': return $_('comms.signalVia') 151 default: return '' 152 } 153 } 154 function isChannelAvailableOnServer(channelId: string): boolean { 155 return availableCommsChannels.includes(channelId) 156 } 157 function canSelectChannel(channelId: string): boolean { 158 if (!isChannelAvailableOnServer(channelId)) return false 159 if (channelId === 'email') return true 160 if (channelId === 'discord') return !!discordId 161 if (channelId === 'telegram') return !!telegramUsername 162 if (channelId === 'signal') return !!signalNumber 163 return false 164 } 165 function needsVerification(channelId: string): boolean { 166 if (channelId === 'discord') return !!discordId && !discordVerified 167 if (channelId === 'telegram') return !!telegramUsername && !telegramVerified 168 if (channelId === 'signal') return !!signalNumber && !signalVerified 169 return false 170 } 171</script> 172<div class="page"> 173 <header> 174 <a href={getFullUrl(routes.dashboard)} class="back">{$_('common.backToDashboard')}</a> 175 <h1>{$_('comms.title')}</h1> 176 <p class="description">{$_('comms.description')}</p> 177 </header> 178 179 {#if loading} 180 <div class="skeleton-sections"> 181 <div class="skeleton-section"></div> 182 <div class="skeleton-section"></div> 183 </div> 184 {:else} 185 <div class="split-layout"> 186 <div class="main-column"> 187 <form onsubmit={handleSave}> 188 <section> 189 <h2>{$_('comms.preferredChannel')}</h2> 190 <p class="section-description">{$_('comms.preferredChannelDescription')}</p> 191 <div class="channel-options"> 192 {#each channels as channelId} 193 <label class="channel-option" class:disabled={!canSelectChannel(channelId)} class:unavailable={!isChannelAvailableOnServer(channelId)}> 194 <input 195 type="radio" 196 name="preferredChannel" 197 value={channelId} 198 bind:group={preferredChannel} 199 disabled={!canSelectChannel(channelId) || saving} 200 /> 201 <div class="channel-info"> 202 <span class="channel-name">{getChannelName(channelId)}</span> 203 <span class="channel-description">{getChannelDescription(channelId)}</span> 204 {#if !isChannelAvailableOnServer(channelId)} 205 <span class="channel-hint server-unavailable">{$_('comms.notConfiguredOnServer')}</span> 206 {:else if channelId !== 'email' && !canSelectChannel(channelId)} 207 <span class="channel-hint">{$_('comms.configureToEnable')}</span> 208 {/if} 209 </div> 210 </label> 211 {/each} 212 </div> 213 </section> 214 215 <section> 216 <h2>{$_('comms.channelConfiguration')}</h2> 217 <div class="channel-config"> 218 <div class="config-item"> 219 <div class="config-header"> 220 <label for="email">{$_('register.email')}</label> 221 <span class="status verified">{$_('comms.primary')}</span> 222 </div> 223 <input id="email" type="email" value={email} disabled class="readonly" /> 224 <p class="config-hint">{$_('comms.emailManagedInSettings')}</p> 225 </div> 226 227 <div class="config-item" class:unavailable={!isChannelAvailableOnServer('discord')}> 228 <div class="config-header"> 229 <label for="discord">{$_('register.discordId')}</label> 230 {#if !isChannelAvailableOnServer('discord')} 231 <span class="status unavailable">{$_('comms.notConfiguredOnServer')}</span> 232 {:else if discordId} 233 {#if discordVerified} 234 <span class="status verified">{$_('comms.verified')}</span> 235 {:else} 236 <span class="status unverified">{$_('comms.notVerified')}</span> 237 {/if} 238 {/if} 239 </div> 240 <div class="config-input"> 241 <input 242 id="discord" 243 type="text" 244 bind:value={discordId} 245 placeholder={$_('register.discordIdPlaceholder')} 246 disabled={saving || !isChannelAvailableOnServer('discord')} 247 /> 248 {#if discordId && !discordVerified && isChannelAvailableOnServer('discord')} 249 <button type="button" class="verify-btn" onclick={() => verifyingChannel = 'discord'}>{$_('comms.verifyButton')}</button> 250 {/if} 251 </div> 252 <p class="config-hint">{$_('comms.discordIdHint')}</p> 253 {#if verifyingChannel === 'discord'} 254 <div class="verify-form"> 255 <input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="6" /> 256 <button type="button" onclick={() => handleVerify('discord')}>{$_('comms.submit')}</button> 257 <button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button> 258 </div> 259 {/if} 260 </div> 261 262 <div class="config-item" class:unavailable={!isChannelAvailableOnServer('telegram')}> 263 <div class="config-header"> 264 <label for="telegram">{$_('register.telegramUsername')}</label> 265 {#if !isChannelAvailableOnServer('telegram')} 266 <span class="status unavailable">{$_('comms.notConfiguredOnServer')}</span> 267 {:else if telegramUsername} 268 {#if telegramVerified} 269 <span class="status verified">{$_('comms.verified')}</span> 270 {:else} 271 <span class="status unverified">{$_('comms.notVerified')}</span> 272 {/if} 273 {/if} 274 </div> 275 <div class="config-input"> 276 <input 277 id="telegram" 278 type="text" 279 bind:value={telegramUsername} 280 placeholder={$_('register.telegramUsernamePlaceholder')} 281 disabled={saving || !isChannelAvailableOnServer('telegram')} 282 /> 283 {#if telegramUsername && !telegramVerified && isChannelAvailableOnServer('telegram')} 284 <button type="button" class="verify-btn" onclick={() => verifyingChannel = 'telegram'}>{$_('comms.verifyButton')}</button> 285 {/if} 286 </div> 287 <p class="config-hint">{$_('comms.telegramHint')}</p> 288 {#if verifyingChannel === 'telegram'} 289 <div class="verify-form"> 290 <input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="6" /> 291 <button type="button" onclick={() => handleVerify('telegram')}>{$_('comms.submit')}</button> 292 <button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button> 293 </div> 294 {/if} 295 </div> 296 297 <div class="config-item" class:unavailable={!isChannelAvailableOnServer('signal')}> 298 <div class="config-header"> 299 <label for="signal">{$_('register.signalNumber')}</label> 300 {#if !isChannelAvailableOnServer('signal')} 301 <span class="status unavailable">{$_('comms.notConfiguredOnServer')}</span> 302 {:else if signalNumber} 303 {#if signalVerified} 304 <span class="status verified">{$_('comms.verified')}</span> 305 {:else} 306 <span class="status unverified">{$_('comms.notVerified')}</span> 307 {/if} 308 {/if} 309 </div> 310 <div class="config-input"> 311 <input 312 id="signal" 313 type="tel" 314 bind:value={signalNumber} 315 placeholder={$_('register.signalNumberPlaceholder')} 316 disabled={saving || !isChannelAvailableOnServer('signal')} 317 /> 318 {#if signalNumber && !signalVerified && isChannelAvailableOnServer('signal')} 319 <button type="button" class="verify-btn" onclick={() => verifyingChannel = 'signal'}>{$_('comms.verifyButton')}</button> 320 {/if} 321 </div> 322 <p class="config-hint">{$_('comms.signalHint')}</p> 323 {#if verifyingChannel === 'signal'} 324 <div class="verify-form"> 325 <input type="text" bind:value={verificationCode} placeholder={$_('comms.verifyCodePlaceholder')} maxlength="6" /> 326 <button type="button" onclick={() => handleVerify('signal')}>{$_('comms.submit')}</button> 327 <button type="button" class="cancel" onclick={() => { verifyingChannel = null; verificationCode = '' }}>{$_('common.cancel')}</button> 328 </div> 329 {/if} 330 </div> 331 </div> 332 333 </section> 334 335 <div class="actions"> 336 <button type="submit" disabled={saving}> 337 {saving ? $_('common.saving') : $_('comms.savePreferences')} 338 </button> 339 </div> 340 </form> 341 </div> 342 343 <div class="side-column"> 344 <section class="history-section"> 345 <h2>{$_('comms.messageHistory')}</h2> 346 <p class="section-description">{$_('comms.historyDescription')}</p> 347 {#if historyLoading} 348 <div class="skeleton-list"> 349 {#each [1, 2, 3] as _} 350 <div class="skeleton-item"> 351 <div class="skeleton-header"> 352 <div class="skeleton-line short"></div> 353 <div class="skeleton-line tiny"></div> 354 </div> 355 <div class="skeleton-line"></div> 356 <div class="skeleton-line medium"></div> 357 </div> 358 {/each} 359 </div> 360 {:else if messages.length === 0} 361 <p class="no-messages">{$_('comms.noMessages')}</p> 362 {:else} 363 <div class="message-list"> 364 {#each messages as msg} 365 <div class="message-item"> 366 <div class="message-header"> 367 <span class="message-type">{msg.notificationType}</span> 368 <span class="message-channel">{msg.channel}</span> 369 <span class="message-status" class:sent={msg.status === 'sent'} class:failed={msg.status === 'failed'}>{msg.status}</span> 370 </div> 371 {#if msg.subject} 372 <div class="message-subject">{msg.subject}</div> 373 {/if} 374 <div class="message-body">{msg.body}</div> 375 <div class="message-date">{formatDate(msg.createdAt)}</div> 376 </div> 377 {/each} 378 </div> 379 {/if} 380 </section> 381 </div> 382 </div> 383 {/if} 384</div> 385<style> 386 .page { 387 max-width: var(--width-xl); 388 margin: 0 auto; 389 padding: var(--space-7); 390 } 391 392 header { 393 margin-bottom: var(--space-6); 394 } 395 396 .back { 397 color: var(--text-secondary); 398 text-decoration: none; 399 font-size: var(--text-sm); 400 } 401 402 .back:hover { 403 color: var(--accent); 404 } 405 406 h1 { 407 margin: var(--space-2) 0 0 0; 408 } 409 410 .description { 411 color: var(--text-secondary); 412 margin: var(--space-2) 0 0 0; 413 } 414 415 .split-layout { 416 display: grid; 417 grid-template-columns: 1fr; 418 gap: var(--space-6); 419 } 420 421 @media (min-width: 900px) { 422 .split-layout { 423 grid-template-columns: 1.5fr 1fr; 424 align-items: start; 425 } 426 } 427 428 .main-column, .side-column { 429 min-width: 0; 430 } 431 432 section { 433 background: var(--bg-secondary); 434 padding: var(--space-6); 435 border-radius: var(--radius-xl); 436 margin-bottom: var(--space-6); 437 } 438 439 .side-column section { 440 margin-bottom: 0; 441 } 442 443 section h2 { 444 margin: 0 0 var(--space-2) 0; 445 font-size: var(--text-lg); 446 } 447 448 .section-description { 449 color: var(--text-secondary); 450 font-size: var(--text-sm); 451 margin: 0 0 var(--space-4) 0; 452 } 453 454 .channel-options { 455 display: flex; 456 flex-direction: column; 457 gap: var(--space-2); 458 } 459 460 .channel-option { 461 display: flex; 462 align-items: flex-start; 463 gap: var(--space-3); 464 padding: var(--space-3); 465 background: var(--bg-card); 466 border: 1px solid var(--border-color); 467 border-radius: var(--radius-md); 468 cursor: pointer; 469 transition: border-color var(--transition-fast); 470 } 471 472 .channel-option:hover:not(.disabled) { 473 border-color: var(--accent); 474 } 475 476 .channel-option.disabled { 477 opacity: 0.6; 478 cursor: not-allowed; 479 } 480 481 .channel-option.unavailable { 482 opacity: 0.5; 483 background: var(--bg-input-disabled); 484 } 485 486 .channel-option input[type="radio"] { 487 flex-shrink: 0; 488 width: 16px; 489 height: 16px; 490 margin-top: 2px; 491 } 492 493 .channel-info { 494 flex: 1; 495 min-width: 0; 496 display: flex; 497 flex-direction: column; 498 gap: 2px; 499 } 500 501 .channel-name { 502 font-weight: var(--font-medium); 503 } 504 505 .channel-description { 506 font-size: var(--text-sm); 507 color: var(--text-secondary); 508 } 509 510 .channel-hint { 511 font-size: var(--text-xs); 512 color: var(--text-muted); 513 font-style: italic; 514 } 515 516 .channel-hint.server-unavailable { 517 color: var(--warning-text); 518 } 519 520 .channel-config { 521 display: flex; 522 flex-direction: column; 523 gap: var(--space-5); 524 } 525 526 .config-item { 527 display: flex; 528 flex-direction: column; 529 gap: var(--space-1); 530 } 531 532 .config-item.unavailable { 533 opacity: 0.6; 534 } 535 536 .config-header { 537 display: flex; 538 align-items: center; 539 justify-content: space-between; 540 gap: var(--space-3); 541 margin-bottom: var(--space-1); 542 } 543 544 .config-item label { 545 font-size: var(--text-sm); 546 font-weight: var(--font-medium); 547 } 548 549 .config-input { 550 display: flex; 551 align-items: center; 552 gap: var(--space-2); 553 } 554 555 .config-input input { 556 flex: 1; 557 min-width: 0; 558 } 559 560 .config-item input.readonly { 561 background: var(--bg-input-disabled); 562 color: var(--text-secondary); 563 } 564 565 .status { 566 padding: var(--space-1) var(--space-2); 567 border-radius: var(--radius-md); 568 font-size: var(--text-xs); 569 white-space: nowrap; 570 } 571 572 .status.verified { 573 background: var(--success-bg); 574 color: var(--success-text); 575 } 576 577 .status.unverified { 578 background: var(--warning-bg); 579 color: var(--warning-text); 580 } 581 582 .status.unavailable { 583 background: var(--bg-input-disabled); 584 color: var(--text-muted); 585 } 586 587 .config-hint { 588 font-size: var(--text-xs); 589 color: var(--text-secondary); 590 margin: 0; 591 } 592 593 .actions { 594 display: flex; 595 justify-content: flex-end; 596 } 597 598 .verify-btn { 599 padding: var(--space-1) var(--space-2); 600 background: var(--accent); 601 color: var(--text-inverse); 602 border: none; 603 border-radius: var(--radius-md); 604 font-size: var(--text-xs); 605 cursor: pointer; 606 } 607 608 .verify-btn:hover { 609 background: var(--accent-hover); 610 } 611 612 .verify-form { 613 display: flex; 614 gap: var(--space-2); 615 margin-top: var(--space-2); 616 align-items: center; 617 } 618 619 .verify-form input { 620 padding: var(--space-2); 621 font-size: var(--text-sm); 622 width: 150px; 623 } 624 625 .verify-form button { 626 padding: var(--space-2) var(--space-3); 627 background: var(--accent); 628 color: var(--text-inverse); 629 border: none; 630 border-radius: var(--radius-md); 631 font-size: var(--text-sm); 632 cursor: pointer; 633 } 634 635 .verify-form button:hover { 636 background: var(--accent-hover); 637 } 638 639 .verify-form button.cancel { 640 background: transparent; 641 border: 1px solid var(--border-color); 642 color: var(--text-secondary); 643 } 644 645 .verify-form button.cancel:hover { 646 background: var(--bg-secondary); 647 } 648 649 .history-section h2 { 650 margin: 0 0 var(--space-2) 0; 651 font-size: var(--text-lg); 652 } 653 654 .skeleton-list { 655 display: flex; 656 flex-direction: column; 657 gap: var(--space-3); 658 } 659 660 .skeleton-item { 661 background: var(--bg-card); 662 border: 1px solid var(--border-color); 663 border-radius: var(--radius-md); 664 padding: var(--space-3); 665 } 666 667 .skeleton-header { 668 display: flex; 669 gap: var(--space-2); 670 margin-bottom: var(--space-2); 671 } 672 673 .skeleton-line { 674 height: 14px; 675 background: var(--bg-tertiary); 676 border-radius: var(--radius-sm); 677 animation: skeleton-pulse 1.5s ease-in-out infinite; 678 } 679 680 .skeleton-line.short { 681 width: 80px; 682 } 683 684 .skeleton-line.tiny { 685 width: 50px; 686 } 687 688 .skeleton-line.medium { 689 width: 60%; 690 } 691 692 .skeleton-line:not(.short):not(.tiny):not(.medium) { 693 width: 100%; 694 margin-bottom: var(--space-1); 695 } 696 697 @keyframes skeleton-pulse { 698 0%, 100% { opacity: 1; } 699 50% { opacity: 0.4; } 700 } 701 702 .no-messages { 703 color: var(--text-secondary); 704 font-style: italic; 705 margin-top: var(--space-4); 706 } 707 708 .message-list { 709 display: flex; 710 flex-direction: column; 711 gap: var(--space-3); 712 margin-top: var(--space-4); 713 } 714 715 .message-item { 716 background: var(--bg-card); 717 border: 1px solid var(--border-color); 718 border-radius: var(--radius-md); 719 padding: var(--space-3); 720 } 721 722 .message-header { 723 display: flex; 724 gap: var(--space-2); 725 margin-bottom: var(--space-2); 726 flex-wrap: wrap; 727 align-items: center; 728 } 729 730 .message-type { 731 font-weight: var(--font-medium); 732 font-size: var(--text-sm); 733 } 734 735 .message-channel { 736 font-size: var(--text-xs); 737 padding: var(--space-1) var(--space-2); 738 background: var(--bg-secondary); 739 border-radius: var(--radius-md); 740 color: var(--text-secondary); 741 } 742 743 .message-status { 744 font-size: var(--text-xs); 745 padding: var(--space-1) var(--space-2); 746 border-radius: var(--radius-md); 747 margin-left: auto; 748 } 749 750 .message-status.sent { 751 background: var(--success-bg); 752 color: var(--success-text); 753 } 754 755 .message-status.failed { 756 background: var(--error-bg); 757 color: var(--error-text); 758 } 759 760 .message-subject { 761 font-weight: var(--font-medium); 762 font-size: var(--text-sm); 763 margin-bottom: var(--space-1); 764 } 765 766 .message-body { 767 font-size: var(--text-sm); 768 color: var(--text-secondary); 769 white-space: pre-wrap; 770 word-break: break-word; 771 } 772 773 .message-date { 774 font-size: var(--text-xs); 775 color: var(--text-muted); 776 margin-top: var(--space-2); 777 } 778 779 .skeleton-sections { 780 display: flex; 781 flex-direction: column; 782 gap: var(--space-6); 783 } 784 785 .skeleton-section { 786 height: 180px; 787 background: var(--bg-secondary); 788 border-radius: var(--radius-xl); 789 animation: skeleton-pulse 1.5s ease-in-out infinite; 790 } 791 792 @keyframes skeleton-pulse { 793 0%, 100% { opacity: 1; } 794 50% { opacity: 0.5; } 795 } 796</style>