this repo has no description
1<script lang="ts"> 2 import { onMount } from 'svelte' 3 import { getAuthState, logout, refreshSession } from '../lib/auth.svelte' 4 import { navigate } from '../lib/router.svelte' 5 import { api, ApiError } from '../lib/api' 6 import { locale, setLocale, getSupportedLocales, localeNames, _, type SupportedLocale } from '../lib/i18n' 7 const auth = getAuthState() 8 const supportedLocales = getSupportedLocales() 9 let pdsHostname = $state<string | null>(null) 10 11 onMount(() => { 12 api.describeServer().then(info => { 13 if (info.availableUserDomains?.length) { 14 pdsHostname = info.availableUserDomains[0] 15 } 16 }).catch(() => {}) 17 }) 18 let localeLoading = $state(false) 19 async function handleLocaleChange(newLocale: SupportedLocale) { 20 if (!auth.session) return 21 setLocale(newLocale) 22 localeLoading = true 23 try { 24 await api.updateLocale(auth.session.accessJwt, newLocale) 25 } catch (e) { 26 console.error('Failed to save locale preference:', e) 27 } finally { 28 localeLoading = false 29 } 30 } 31 let message = $state<{ type: 'success' | 'error'; text: string } | null>(null) 32 let emailLoading = $state(false) 33 let newEmail = $state('') 34 let emailToken = $state('') 35 let emailTokenRequired = $state(false) 36 let handleLoading = $state(false) 37 let newHandle = $state('') 38 let deleteLoading = $state(false) 39 let deletePassword = $state('') 40 let deleteToken = $state('') 41 let deleteTokenSent = $state(false) 42 let exportLoading = $state(false) 43 let passwordLoading = $state(false) 44 let currentPassword = $state('') 45 let newPassword = $state('') 46 let confirmNewPassword = $state('') 47 let showBYOHandle = $state(false) 48 $effect(() => { 49 if (!auth.loading && !auth.session) { 50 navigate('/login') 51 } 52 }) 53 function showMessage(type: 'success' | 'error', text: string) { 54 message = { type, text } 55 setTimeout(() => { 56 if (message?.text === text) message = null 57 }, 5000) 58 } 59 async function handleRequestEmailUpdate() { 60 if (!auth.session) return 61 emailLoading = true 62 message = null 63 try { 64 const result = await api.requestEmailUpdate(auth.session.accessJwt) 65 emailTokenRequired = result.tokenRequired 66 if (emailTokenRequired) { 67 showMessage('success', $_('settings.messages.emailCodeSentToCurrent')) 68 } else { 69 emailTokenRequired = true 70 } 71 } catch (e) { 72 showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.emailUpdateFailed')) 73 } finally { 74 emailLoading = false 75 } 76 } 77 async function handleConfirmEmailUpdate(e: Event) { 78 e.preventDefault() 79 if (!auth.session || !newEmail || !emailToken) return 80 emailLoading = true 81 message = null 82 try { 83 await api.updateEmail(auth.session.accessJwt, newEmail, emailToken) 84 await refreshSession() 85 showMessage('success', $_('settings.messages.emailUpdated')) 86 newEmail = '' 87 emailToken = '' 88 emailTokenRequired = false 89 } catch (e) { 90 showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.emailUpdateFailed')) 91 } finally { 92 emailLoading = false 93 } 94 } 95 async function handleUpdateHandle(e: Event) { 96 e.preventDefault() 97 if (!auth.session || !newHandle) return 98 handleLoading = true 99 message = null 100 try { 101 const fullHandle = showBYOHandle 102 ? newHandle 103 : `${newHandle}.${pdsHostname}` 104 await api.updateHandle(auth.session.accessJwt, fullHandle) 105 await refreshSession() 106 showMessage('success', $_('settings.messages.handleUpdated')) 107 newHandle = '' 108 } catch (e) { 109 showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.handleUpdateFailed')) 110 } finally { 111 handleLoading = false 112 } 113 } 114 async function handleRequestDelete() { 115 if (!auth.session) return 116 deleteLoading = true 117 message = null 118 try { 119 await api.requestAccountDelete(auth.session.accessJwt) 120 deleteTokenSent = true 121 showMessage('success', $_('settings.messages.deletionConfirmationSent')) 122 } catch (e) { 123 showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.deletionRequestFailed')) 124 } finally { 125 deleteLoading = false 126 } 127 } 128 async function handleConfirmDelete(e: Event) { 129 e.preventDefault() 130 if (!auth.session || !deletePassword || !deleteToken) return 131 if (!confirm($_('settings.messages.deleteConfirmation'))) { 132 return 133 } 134 deleteLoading = true 135 message = null 136 try { 137 await api.deleteAccount(auth.session.did, deletePassword, deleteToken) 138 await logout() 139 navigate('/login') 140 } catch (e) { 141 showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.deletionFailed')) 142 } finally { 143 deleteLoading = false 144 } 145 } 146 async function handleExportRepo() { 147 if (!auth.session) return 148 exportLoading = true 149 message = null 150 try { 151 const response = await fetch(`/xrpc/com.atproto.sync.getRepo?did=${encodeURIComponent(auth.session.did)}`, { 152 headers: { 153 'Authorization': `Bearer ${auth.session.accessJwt}` 154 } 155 }) 156 if (!response.ok) { 157 const err = await response.json().catch(() => ({ message: 'Export failed' })) 158 throw new Error(err.message || 'Export failed') 159 } 160 const blob = await response.blob() 161 const url = URL.createObjectURL(blob) 162 const a = document.createElement('a') 163 a.href = url 164 a.download = `${auth.session.handle}-repo.car` 165 document.body.appendChild(a) 166 a.click() 167 document.body.removeChild(a) 168 URL.revokeObjectURL(url) 169 showMessage('success', $_('settings.messages.repoExported')) 170 } catch (e) { 171 showMessage('error', e instanceof Error ? e.message : $_('settings.messages.exportFailed')) 172 } finally { 173 exportLoading = false 174 } 175 } 176 async function handleChangePassword(e: Event) { 177 e.preventDefault() 178 if (!auth.session || !currentPassword || !newPassword || !confirmNewPassword) return 179 if (newPassword !== confirmNewPassword) { 180 showMessage('error', $_('settings.messages.passwordsDoNotMatch')) 181 return 182 } 183 if (newPassword.length < 8) { 184 showMessage('error', $_('settings.messages.passwordTooShort')) 185 return 186 } 187 passwordLoading = true 188 message = null 189 try { 190 await api.changePassword(auth.session.accessJwt, currentPassword, newPassword) 191 showMessage('success', $_('settings.messages.passwordChanged')) 192 currentPassword = '' 193 newPassword = '' 194 confirmNewPassword = '' 195 } catch (e) { 196 showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.passwordChangeFailed')) 197 } finally { 198 passwordLoading = false 199 } 200 } 201</script> 202<div class="page"> 203 <header> 204 <a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a> 205 <h1>{$_('settings.title')}</h1> 206 </header> 207 {#if message} 208 <div class="message {message.type}">{message.text}</div> 209 {/if} 210 <div class="sections-grid"> 211 <section> 212 <h2>{$_('settings.language')}</h2> 213 <p class="description">{$_('settings.languageDescription')}</p> 214 <select 215 class="language-select" 216 value={$locale} 217 disabled={localeLoading} 218 onchange={(e) => handleLocaleChange(e.currentTarget.value as SupportedLocale)} 219 > 220 {#each supportedLocales as loc} 221 <option value={loc}>{localeNames[loc]}</option> 222 {/each} 223 </select> 224 </section> 225 <section> 226 <h2>{$_('settings.changeEmail')}</h2> 227 {#if auth.session?.email} 228 <p class="current">{$_('settings.currentEmail', { values: { email: auth.session.email } })}</p> 229 {/if} 230 {#if emailTokenRequired} 231 <form onsubmit={handleConfirmEmailUpdate}> 232 <div class="field"> 233 <label for="email-token">{$_('settings.verificationCode')}</label> 234 <input 235 id="email-token" 236 type="text" 237 bind:value={emailToken} 238 placeholder={$_('settings.verificationCodePlaceholder')} 239 disabled={emailLoading} 240 required 241 /> 242 </div> 243 <div class="field"> 244 <label for="new-email">{$_('settings.newEmail')}</label> 245 <input 246 id="new-email" 247 type="email" 248 bind:value={newEmail} 249 placeholder={$_('settings.newEmailPlaceholder')} 250 disabled={emailLoading} 251 required 252 /> 253 </div> 254 <div class="actions"> 255 <button type="submit" disabled={emailLoading || !emailToken || !newEmail}> 256 {emailLoading ? $_('settings.updating') : $_('settings.confirmEmailChange')} 257 </button> 258 <button type="button" class="secondary" onclick={() => { emailTokenRequired = false; emailToken = ''; newEmail = '' }}> 259 {$_('common.cancel')} 260 </button> 261 </div> 262 </form> 263 {:else} 264 <button onclick={handleRequestEmailUpdate} disabled={emailLoading}> 265 {emailLoading ? $_('settings.requesting') : $_('settings.changeEmailButton')} 266 </button> 267 {/if} 268 </section> 269 <section> 270 <h2>{$_('settings.changeHandle')}</h2> 271 {#if auth.session} 272 <p class="current">{$_('settings.currentHandle', { values: { handle: auth.session.handle } })}</p> 273 {/if} 274 <div class="tabs"> 275 <button 276 type="button" 277 class="tab" 278 class:active={!showBYOHandle} 279 onclick={() => showBYOHandle = false} 280 > 281 {$_('settings.pdsHandle')} 282 </button> 283 <button 284 type="button" 285 class="tab" 286 class:active={showBYOHandle} 287 onclick={() => showBYOHandle = true} 288 > 289 {$_('settings.customDomain')} 290 </button> 291 </div> 292 {#if showBYOHandle} 293 <div class="byo-handle"> 294 <p class="description">{$_('settings.customDomainDescription')}</p> 295 {#if auth.session} 296 <div class="verification-info"> 297 <h3>{$_('settings.setupInstructions')}</h3> 298 <p>{$_('settings.setupMethodsIntro')}</p> 299 <div class="method"> 300 <h4>{$_('settings.dnsMethod')}</h4> 301 <p>{$_('settings.dnsMethodDesc')}</p> 302 <code class="record">_atproto.{newHandle || 'yourdomain.com'} TXT "did={auth.session.did}"</code> 303 </div> 304 <div class="method"> 305 <h4>{$_('settings.httpMethod')}</h4> 306 <p>{$_('settings.httpMethodDesc')}</p> 307 <code class="record">https://{newHandle || 'yourdomain.com'}/.well-known/atproto-did</code> 308 <p>{$_('settings.httpMethodContent')}</p> 309 <code class="record">{auth.session.did}</code> 310 </div> 311 </div> 312 {/if} 313 <form onsubmit={handleUpdateHandle}> 314 <div class="field"> 315 <label for="new-handle-byo">{$_('settings.yourDomain')}</label> 316 <input 317 id="new-handle-byo" 318 type="text" 319 bind:value={newHandle} 320 placeholder={$_('settings.yourDomainPlaceholder')} 321 disabled={handleLoading} 322 required 323 /> 324 </div> 325 <button type="submit" disabled={handleLoading || !newHandle}> 326 {handleLoading ? $_('settings.verifying') : $_('settings.verifyAndUpdate')} 327 </button> 328 </form> 329 </div> 330 {:else} 331 <form onsubmit={handleUpdateHandle}> 332 <div class="field"> 333 <label for="new-handle">{$_('settings.newHandle')}</label> 334 <div class="handle-input-wrapper"> 335 <input 336 id="new-handle" 337 type="text" 338 bind:value={newHandle} 339 placeholder={$_('settings.newHandlePlaceholder')} 340 disabled={handleLoading} 341 required 342 /> 343 <span class="handle-suffix">.{pdsHostname ?? '...'}</span> 344 </div> 345 </div> 346 <button type="submit" disabled={handleLoading || !newHandle || !pdsHostname}> 347 {handleLoading ? $_('settings.updating') : $_('settings.changeHandleButton')} 348 </button> 349 </form> 350 {/if} 351 </section> 352 <section> 353 <h2>{$_('settings.changePassword')}</h2> 354 <form onsubmit={handleChangePassword}> 355 <div class="field"> 356 <label for="current-password">{$_('settings.currentPassword')}</label> 357 <input 358 id="current-password" 359 type="password" 360 bind:value={currentPassword} 361 placeholder={$_('settings.currentPasswordPlaceholder')} 362 disabled={passwordLoading} 363 required 364 /> 365 </div> 366 <div class="field"> 367 <label for="new-password">{$_('settings.newPassword')}</label> 368 <input 369 id="new-password" 370 type="password" 371 bind:value={newPassword} 372 placeholder={$_('settings.newPasswordPlaceholder')} 373 disabled={passwordLoading} 374 required 375 minlength="8" 376 /> 377 </div> 378 <div class="field"> 379 <label for="confirm-new-password">{$_('settings.confirmNewPassword')}</label> 380 <input 381 id="confirm-new-password" 382 type="password" 383 bind:value={confirmNewPassword} 384 placeholder={$_('settings.confirmNewPasswordPlaceholder')} 385 disabled={passwordLoading} 386 required 387 /> 388 </div> 389 <button type="submit" disabled={passwordLoading || !currentPassword || !newPassword || !confirmNewPassword}> 390 {passwordLoading ? $_('settings.changing') : $_('settings.changePasswordButton')} 391 </button> 392 </form> 393 </section> 394 <section> 395 <h2>{$_('settings.exportData')}</h2> 396 <p class="description">{$_('settings.exportDataDescription')}</p> 397 <button onclick={handleExportRepo} disabled={exportLoading}> 398 {exportLoading ? $_('settings.exporting') : $_('settings.downloadRepo')} 399 </button> 400 </section> 401 </div> 402 <section class="danger-zone"> 403 <h2>{$_('settings.deleteAccount')}</h2> 404 <p class="warning">{$_('settings.deleteWarning')}</p> 405 {#if deleteTokenSent} 406 <form onsubmit={handleConfirmDelete}> 407 <div class="field"> 408 <label for="delete-token">{$_('settings.confirmationCode')}</label> 409 <input 410 id="delete-token" 411 type="text" 412 bind:value={deleteToken} 413 placeholder={$_('settings.confirmationCodePlaceholder')} 414 disabled={deleteLoading} 415 required 416 /> 417 </div> 418 <div class="field"> 419 <label for="delete-password">{$_('settings.yourPassword')}</label> 420 <input 421 id="delete-password" 422 type="password" 423 bind:value={deletePassword} 424 placeholder={$_('settings.yourPasswordPlaceholder')} 425 disabled={deleteLoading} 426 required 427 /> 428 </div> 429 <div class="actions"> 430 <button type="submit" class="danger" disabled={deleteLoading || !deleteToken || !deletePassword}> 431 {deleteLoading ? $_('settings.deleting') : $_('settings.permanentlyDelete')} 432 </button> 433 <button type="button" class="secondary" onclick={() => { deleteTokenSent = false; deleteToken = ''; deletePassword = '' }}> 434 {$_('common.cancel')} 435 </button> 436 </div> 437 </form> 438 {:else} 439 <button class="danger" onclick={handleRequestDelete} disabled={deleteLoading}> 440 {deleteLoading ? $_('settings.requesting') : $_('settings.requestDeletion')} 441 </button> 442 {/if} 443 </section> 444</div> 445<style> 446 .page { 447 max-width: var(--width-lg); 448 margin: 0 auto; 449 padding: var(--space-7); 450 } 451 452 header { 453 margin-bottom: var(--space-7); 454 } 455 456 .sections-grid { 457 display: flex; 458 flex-direction: column; 459 gap: var(--space-6); 460 } 461 462 @media (min-width: 800px) { 463 .sections-grid { 464 columns: 2; 465 column-gap: var(--space-6); 466 display: block; 467 } 468 469 .sections-grid section { 470 break-inside: avoid; 471 margin-bottom: var(--space-6); 472 } 473 } 474 475 .back { 476 color: var(--text-secondary); 477 text-decoration: none; 478 font-size: var(--text-sm); 479 } 480 481 .back:hover { 482 color: var(--accent); 483 } 484 485 h1 { 486 margin: var(--space-2) 0 0 0; 487 } 488 489 section { 490 padding: var(--space-6); 491 background: var(--bg-secondary); 492 border-radius: var(--radius-xl); 493 margin-bottom: var(--space-6); 494 height: fit-content; 495 } 496 497 .danger-zone { 498 margin-top: var(--space-6); 499 } 500 501 section h2 { 502 margin: 0 0 var(--space-2) 0; 503 font-size: var(--text-lg); 504 } 505 506 .current, 507 .description { 508 color: var(--text-secondary); 509 font-size: var(--text-sm); 510 margin-bottom: var(--space-4); 511 } 512 513 .language-select { 514 width: 100%; 515 } 516 517 form > button, 518 form > .actions { 519 margin-top: var(--space-4); 520 } 521 522 .actions { 523 display: flex; 524 gap: var(--space-2); 525 } 526 527 .danger-zone { 528 background: var(--error-bg); 529 border: 1px solid var(--error-border); 530 } 531 532 .danger-zone h2 { 533 color: var(--error-text); 534 } 535 536 .warning { 537 color: var(--error-text); 538 font-size: var(--text-sm); 539 margin-bottom: var(--space-4); 540 } 541 542 .tabs { 543 display: flex; 544 gap: var(--space-1); 545 margin-bottom: var(--space-4); 546 } 547 548 .tab { 549 flex: 1; 550 padding: var(--space-2) var(--space-4); 551 background: transparent; 552 border: 1px solid var(--border-color); 553 cursor: pointer; 554 font-size: var(--text-sm); 555 color: var(--text-secondary); 556 } 557 558 .tab:first-child { 559 border-radius: var(--radius-md) 0 0 var(--radius-md); 560 } 561 562 .tab:last-child { 563 border-radius: 0 var(--radius-md) var(--radius-md) 0; 564 } 565 566 .tab.active { 567 background: var(--accent); 568 border-color: var(--accent); 569 color: var(--text-inverse); 570 } 571 572 .tab:hover:not(.active) { 573 background: var(--bg-card); 574 } 575 576 .byo-handle .description { 577 margin-bottom: var(--space-4); 578 } 579 580 .verification-info { 581 background: var(--bg-card); 582 border: 1px solid var(--border-color); 583 border-radius: var(--radius-lg); 584 padding: var(--space-4); 585 margin-bottom: var(--space-4); 586 } 587 588 .verification-info h3 { 589 margin: 0 0 var(--space-2) 0; 590 font-size: var(--text-base); 591 } 592 593 .verification-info h4 { 594 margin: var(--space-3) 0 var(--space-1) 0; 595 font-size: var(--text-sm); 596 color: var(--text-secondary); 597 } 598 599 .verification-info p { 600 margin: var(--space-1) 0; 601 font-size: var(--text-xs); 602 color: var(--text-secondary); 603 } 604 605 .method { 606 margin-top: var(--space-3); 607 padding-top: var(--space-3); 608 border-top: 1px solid var(--border-color); 609 } 610 611 .method:first-of-type { 612 margin-top: var(--space-2); 613 padding-top: 0; 614 border-top: none; 615 } 616 617 code.record { 618 display: block; 619 background: var(--bg-input); 620 padding: var(--space-2); 621 border-radius: var(--radius-md); 622 font-size: var(--text-xs); 623 word-break: break-all; 624 margin: var(--space-1) 0; 625 } 626 627 .handle-input-wrapper { 628 display: flex; 629 align-items: center; 630 background: var(--bg-input); 631 border: 1px solid var(--border-color); 632 border-radius: var(--radius-md); 633 overflow: hidden; 634 } 635 636 .handle-input-wrapper input { 637 flex: 1; 638 border: none; 639 border-radius: 0; 640 background: transparent; 641 min-width: 0; 642 } 643 644 .handle-input-wrapper input:focus { 645 outline: none; 646 box-shadow: none; 647 } 648 649 .handle-input-wrapper:focus-within { 650 border-color: var(--accent); 651 box-shadow: 0 0 0 2px var(--accent-muted); 652 } 653 654 .handle-suffix { 655 padding: 0 var(--space-3); 656 color: var(--text-secondary); 657 font-size: var(--text-sm); 658 white-space: nowrap; 659 border-left: 1px solid var(--border-color); 660 background: var(--bg-card); 661 } 662</style>