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