this repo has no description
1<script lang="ts"> 2 import { getAuthState, logout } from '../lib/auth.svelte' 3 import { navigate } from '../lib/router.svelte' 4 import { api, ApiError } from '../lib/api' 5 const auth = getAuthState() 6 let message = $state<{ type: 'success' | 'error'; text: string } | null>(null) 7 let emailLoading = $state(false) 8 let newEmail = $state('') 9 let emailToken = $state('') 10 let emailTokenRequired = $state(false) 11 let handleLoading = $state(false) 12 let newHandle = $state('') 13 let deleteLoading = $state(false) 14 let deletePassword = $state('') 15 let deleteToken = $state('') 16 let deleteTokenSent = $state(false) 17 let exportLoading = $state(false) 18 let passwordLoading = $state(false) 19 let currentPassword = $state('') 20 let newPassword = $state('') 21 let confirmNewPassword = $state('') 22 let showBYOHandle = $state(false) 23 $effect(() => { 24 if (!auth.loading && !auth.session) { 25 navigate('/login') 26 } 27 }) 28 function showMessage(type: 'success' | 'error', text: string) { 29 message = { type, text } 30 setTimeout(() => { 31 if (message?.text === text) message = null 32 }, 5000) 33 } 34 async function handleRequestEmailUpdate(e: Event) { 35 e.preventDefault() 36 if (!auth.session || !newEmail) return 37 emailLoading = true 38 message = null 39 try { 40 const result = await api.requestEmailUpdate(auth.session.accessJwt, newEmail) 41 emailTokenRequired = result.tokenRequired 42 if (emailTokenRequired) { 43 showMessage('success', 'Verification code sent to your current email') 44 } else { 45 await api.updateEmail(auth.session.accessJwt, newEmail) 46 showMessage('success', 'Email updated successfully') 47 newEmail = '' 48 } 49 } catch (e) { 50 showMessage('error', e instanceof ApiError ? e.message : 'Failed to update email') 51 } finally { 52 emailLoading = false 53 } 54 } 55 async function handleConfirmEmailUpdate(e: Event) { 56 e.preventDefault() 57 if (!auth.session || !newEmail || !emailToken) return 58 emailLoading = true 59 message = null 60 try { 61 await api.updateEmail(auth.session.accessJwt, newEmail, emailToken) 62 showMessage('success', 'Email updated successfully') 63 newEmail = '' 64 emailToken = '' 65 emailTokenRequired = false 66 } catch (e) { 67 showMessage('error', e instanceof ApiError ? e.message : 'Failed to update email') 68 } finally { 69 emailLoading = false 70 } 71 } 72 async function handleUpdateHandle(e: Event) { 73 e.preventDefault() 74 if (!auth.session || !newHandle) return 75 handleLoading = true 76 message = null 77 try { 78 await api.updateHandle(auth.session.accessJwt, newHandle) 79 showMessage('success', 'Handle updated successfully') 80 newHandle = '' 81 } catch (e) { 82 showMessage('error', e instanceof ApiError ? e.message : 'Failed to update handle') 83 } finally { 84 handleLoading = false 85 } 86 } 87 async function handleRequestDelete() { 88 if (!auth.session) return 89 deleteLoading = true 90 message = null 91 try { 92 await api.requestAccountDelete(auth.session.accessJwt) 93 deleteTokenSent = true 94 showMessage('success', 'Deletion confirmation sent to your email') 95 } catch (e) { 96 showMessage('error', e instanceof ApiError ? e.message : 'Failed to request deletion') 97 } finally { 98 deleteLoading = false 99 } 100 } 101 async function handleConfirmDelete(e: Event) { 102 e.preventDefault() 103 if (!auth.session || !deletePassword || !deleteToken) return 104 if (!confirm('Are you absolutely sure you want to delete your account? This cannot be undone.')) { 105 return 106 } 107 deleteLoading = true 108 message = null 109 try { 110 await api.deleteAccount(auth.session.did, deletePassword, deleteToken) 111 await logout() 112 navigate('/login') 113 } catch (e) { 114 showMessage('error', e instanceof ApiError ? e.message : 'Failed to delete account') 115 } finally { 116 deleteLoading = false 117 } 118 } 119 async function handleExportRepo() { 120 if (!auth.session) return 121 exportLoading = true 122 message = null 123 try { 124 const response = await fetch(`/xrpc/com.atproto.sync.getRepo?did=${encodeURIComponent(auth.session.did)}`, { 125 headers: { 126 'Authorization': `Bearer ${auth.session.accessJwt}` 127 } 128 }) 129 if (!response.ok) { 130 const err = await response.json().catch(() => ({ message: 'Export failed' })) 131 throw new Error(err.message || 'Export failed') 132 } 133 const blob = await response.blob() 134 const url = URL.createObjectURL(blob) 135 const a = document.createElement('a') 136 a.href = url 137 a.download = `${auth.session.handle}-repo.car` 138 document.body.appendChild(a) 139 a.click() 140 document.body.removeChild(a) 141 URL.revokeObjectURL(url) 142 showMessage('success', 'Repository exported successfully') 143 } catch (e) { 144 showMessage('error', e instanceof Error ? e.message : 'Failed to export repository') 145 } finally { 146 exportLoading = false 147 } 148 } 149 async function handleChangePassword(e: Event) { 150 e.preventDefault() 151 if (!auth.session || !currentPassword || !newPassword || !confirmNewPassword) return 152 if (newPassword !== confirmNewPassword) { 153 showMessage('error', 'Passwords do not match') 154 return 155 } 156 if (newPassword.length < 8) { 157 showMessage('error', 'Password must be at least 8 characters') 158 return 159 } 160 passwordLoading = true 161 message = null 162 try { 163 await api.changePassword(auth.session.accessJwt, currentPassword, newPassword) 164 showMessage('success', 'Password changed successfully') 165 currentPassword = '' 166 newPassword = '' 167 confirmNewPassword = '' 168 } catch (e) { 169 showMessage('error', e instanceof ApiError ? e.message : 'Failed to change password') 170 } finally { 171 passwordLoading = false 172 } 173 } 174</script> 175<div class="page"> 176 <header> 177 <a href="#/dashboard" class="back">&larr; Dashboard</a> 178 <h1>Account Settings</h1> 179 </header> 180 {#if message} 181 <div class="message {message.type}">{message.text}</div> 182 {/if} 183 <section> 184 <h2>Change Email</h2> 185 {#if auth.session?.email} 186 <p class="current">Current: {auth.session.email}</p> 187 {/if} 188 {#if emailTokenRequired} 189 <form onsubmit={handleConfirmEmailUpdate}> 190 <div class="field"> 191 <label for="email-token">Verification Code</label> 192 <input 193 id="email-token" 194 type="text" 195 bind:value={emailToken} 196 placeholder="Enter code from email" 197 disabled={emailLoading} 198 required 199 /> 200 </div> 201 <div class="actions"> 202 <button type="submit" disabled={emailLoading || !emailToken}> 203 {emailLoading ? 'Updating...' : 'Confirm Email Change'} 204 </button> 205 <button type="button" class="secondary" onclick={() => { emailTokenRequired = false; emailToken = '' }}> 206 Cancel 207 </button> 208 </div> 209 </form> 210 {:else} 211 <form onsubmit={handleRequestEmailUpdate}> 212 <div class="field"> 213 <label for="new-email">New Email</label> 214 <input 215 id="new-email" 216 type="email" 217 bind:value={newEmail} 218 placeholder="new@example.com" 219 disabled={emailLoading} 220 required 221 /> 222 </div> 223 <button type="submit" disabled={emailLoading || !newEmail}> 224 {emailLoading ? 'Requesting...' : 'Change Email'} 225 </button> 226 </form> 227 {/if} 228 </section> 229 <section> 230 <h2>Change Handle</h2> 231 {#if auth.session} 232 <p class="current">Current: @{auth.session.handle}</p> 233 {/if} 234 <div class="tabs"> 235 <button 236 type="button" 237 class="tab" 238 class:active={!showBYOHandle} 239 onclick={() => showBYOHandle = false} 240 > 241 PDS Handle 242 </button> 243 <button 244 type="button" 245 class="tab" 246 class:active={showBYOHandle} 247 onclick={() => showBYOHandle = true} 248 > 249 Custom Domain 250 </button> 251 </div> 252 {#if showBYOHandle} 253 <div class="byo-handle"> 254 <p class="description">Use your own domain as your handle. You need to verify domain ownership first.</p> 255 {#if auth.session} 256 <div class="verification-info"> 257 <h3>Setup Instructions</h3> 258 <p>Choose one of these verification methods:</p> 259 <div class="method"> 260 <h4>Option 1: DNS TXT Record (Recommended)</h4> 261 <p>Add this TXT record to your domain:</p> 262 <code class="record">_atproto.{newHandle || 'yourdomain.com'} TXT "did={auth.session.did}"</code> 263 </div> 264 <div class="method"> 265 <h4>Option 2: HTTP Well-Known File</h4> 266 <p>Serve your DID at this URL:</p> 267 <code class="record">https://{newHandle || 'yourdomain.com'}/.well-known/atproto-did</code> 268 <p>The file should contain only:</p> 269 <code class="record">{auth.session.did}</code> 270 </div> 271 </div> 272 {/if} 273 <form onsubmit={handleUpdateHandle}> 274 <div class="field"> 275 <label for="new-handle-byo">Your Domain</label> 276 <input 277 id="new-handle-byo" 278 type="text" 279 bind:value={newHandle} 280 placeholder="example.com" 281 disabled={handleLoading} 282 required 283 /> 284 </div> 285 <button type="submit" disabled={handleLoading || !newHandle}> 286 {handleLoading ? 'Verifying...' : 'Verify & Update Handle'} 287 </button> 288 </form> 289 </div> 290 {:else} 291 <form onsubmit={handleUpdateHandle}> 292 <div class="field"> 293 <label for="new-handle">New Handle</label> 294 <input 295 id="new-handle" 296 type="text" 297 bind:value={newHandle} 298 placeholder="yourhandle" 299 disabled={handleLoading} 300 required 301 /> 302 </div> 303 <button type="submit" disabled={handleLoading || !newHandle}> 304 {handleLoading ? 'Updating...' : 'Change Handle'} 305 </button> 306 </form> 307 {/if} 308 </section> 309 <section> 310 <h2>Change Password</h2> 311 <form onsubmit={handleChangePassword}> 312 <div class="field"> 313 <label for="current-password">Current Password</label> 314 <input 315 id="current-password" 316 type="password" 317 bind:value={currentPassword} 318 placeholder="Enter current password" 319 disabled={passwordLoading} 320 required 321 /> 322 </div> 323 <div class="field"> 324 <label for="new-password">New Password</label> 325 <input 326 id="new-password" 327 type="password" 328 bind:value={newPassword} 329 placeholder="At least 8 characters" 330 disabled={passwordLoading} 331 required 332 minlength="8" 333 /> 334 </div> 335 <div class="field"> 336 <label for="confirm-new-password">Confirm New Password</label> 337 <input 338 id="confirm-new-password" 339 type="password" 340 bind:value={confirmNewPassword} 341 placeholder="Confirm new password" 342 disabled={passwordLoading} 343 required 344 /> 345 </div> 346 <button type="submit" disabled={passwordLoading || !currentPassword || !newPassword || !confirmNewPassword}> 347 {passwordLoading ? 'Changing...' : 'Change Password'} 348 </button> 349 </form> 350 </section> 351 <section> 352 <h2>Export Data</h2> 353 <p class="description">Download your entire repository as a CAR (Content Addressable Archive) file. This includes all your posts, likes, follows, and other data.</p> 354 <button onclick={handleExportRepo} disabled={exportLoading}> 355 {exportLoading ? 'Exporting...' : 'Download Repository'} 356 </button> 357 </section> 358 <section class="danger-zone"> 359 <h2>Delete Account</h2> 360 <p class="warning">This action is irreversible. All your data will be permanently deleted.</p> 361 {#if deleteTokenSent} 362 <form onsubmit={handleConfirmDelete}> 363 <div class="field"> 364 <label for="delete-token">Confirmation Code (from email)</label> 365 <input 366 id="delete-token" 367 type="text" 368 bind:value={deleteToken} 369 placeholder="Enter confirmation code" 370 disabled={deleteLoading} 371 required 372 /> 373 </div> 374 <div class="field"> 375 <label for="delete-password">Your Password</label> 376 <input 377 id="delete-password" 378 type="password" 379 bind:value={deletePassword} 380 placeholder="Enter your password" 381 disabled={deleteLoading} 382 required 383 /> 384 </div> 385 <div class="actions"> 386 <button type="submit" class="danger" disabled={deleteLoading || !deleteToken || !deletePassword}> 387 {deleteLoading ? 'Deleting...' : 'Permanently Delete Account'} 388 </button> 389 <button type="button" class="secondary" onclick={() => { deleteTokenSent = false; deleteToken = ''; deletePassword = '' }}> 390 Cancel 391 </button> 392 </div> 393 </form> 394 {:else} 395 <button class="danger" onclick={handleRequestDelete} disabled={deleteLoading}> 396 {deleteLoading ? 'Requesting...' : 'Request Account Deletion'} 397 </button> 398 {/if} 399 </section> 400</div> 401<style> 402 .page { 403 max-width: 600px; 404 margin: 0 auto; 405 padding: 2rem; 406 } 407 header { 408 margin-bottom: 2rem; 409 } 410 .back { 411 color: var(--text-secondary); 412 text-decoration: none; 413 font-size: 0.875rem; 414 } 415 .back:hover { 416 color: var(--accent); 417 } 418 h1 { 419 margin: 0.5rem 0 0 0; 420 } 421 .message { 422 padding: 0.75rem; 423 border-radius: 4px; 424 margin-bottom: 1rem; 425 } 426 .message.success { 427 background: var(--success-bg); 428 border: 1px solid var(--success-border); 429 color: var(--success-text); 430 } 431 .message.error { 432 background: var(--error-bg); 433 border: 1px solid var(--error-border); 434 color: var(--error-text); 435 } 436 section { 437 padding: 1.5rem; 438 background: var(--bg-secondary); 439 border-radius: 8px; 440 margin-bottom: 1.5rem; 441 } 442 section h2 { 443 margin: 0 0 0.5rem 0; 444 font-size: 1.125rem; 445 } 446 .current, .description { 447 color: var(--text-secondary); 448 font-size: 0.875rem; 449 margin-bottom: 1rem; 450 } 451 .field { 452 margin-bottom: 1rem; 453 } 454 label { 455 display: block; 456 font-size: 0.875rem; 457 font-weight: 500; 458 margin-bottom: 0.25rem; 459 } 460 input { 461 width: 100%; 462 padding: 0.75rem; 463 border: 1px solid var(--border-color-light); 464 border-radius: 4px; 465 font-size: 1rem; 466 box-sizing: border-box; 467 background: var(--bg-input); 468 color: var(--text-primary); 469 } 470 input:focus { 471 outline: none; 472 border-color: var(--accent); 473 } 474 button { 475 padding: 0.75rem 1.5rem; 476 background: var(--accent); 477 color: white; 478 border: none; 479 border-radius: 4px; 480 cursor: pointer; 481 font-size: 1rem; 482 } 483 button:hover:not(:disabled) { 484 background: var(--accent-hover); 485 } 486 button:disabled { 487 opacity: 0.6; 488 cursor: not-allowed; 489 } 490 button.secondary { 491 background: transparent; 492 color: var(--text-secondary); 493 border: 1px solid var(--border-color-light); 494 } 495 button.secondary:hover:not(:disabled) { 496 background: var(--bg-secondary); 497 } 498 button.danger { 499 background: var(--error-text); 500 } 501 button.danger:hover:not(:disabled) { 502 background: #900; 503 } 504 .actions { 505 display: flex; 506 gap: 0.5rem; 507 } 508 .danger-zone { 509 background: var(--error-bg); 510 border: 1px solid var(--error-border); 511 } 512 .danger-zone h2 { 513 color: var(--error-text); 514 } 515 .warning { 516 color: var(--error-text); 517 font-size: 0.875rem; 518 margin-bottom: 1rem; 519 } 520 .tabs { 521 display: flex; 522 gap: 0.25rem; 523 margin-bottom: 1rem; 524 } 525 .tab { 526 flex: 1; 527 padding: 0.5rem 1rem; 528 background: transparent; 529 border: 1px solid var(--border-color-light); 530 cursor: pointer; 531 font-size: 0.875rem; 532 color: var(--text-secondary); 533 } 534 .tab:first-child { 535 border-radius: 4px 0 0 4px; 536 } 537 .tab:last-child { 538 border-radius: 0 4px 4px 0; 539 } 540 .tab.active { 541 background: var(--accent); 542 border-color: var(--accent); 543 color: white; 544 } 545 .tab:hover:not(.active) { 546 background: var(--bg-card); 547 } 548 .byo-handle .description { 549 margin-bottom: 1rem; 550 } 551 .verification-info { 552 background: var(--bg-card); 553 border: 1px solid var(--border-color-light); 554 border-radius: 6px; 555 padding: 1rem; 556 margin-bottom: 1rem; 557 } 558 .verification-info h3 { 559 margin: 0 0 0.5rem 0; 560 font-size: 1rem; 561 } 562 .verification-info h4 { 563 margin: 0.75rem 0 0.25rem 0; 564 font-size: 0.875rem; 565 color: var(--text-secondary); 566 } 567 .verification-info p { 568 margin: 0.25rem 0; 569 font-size: 0.8rem; 570 color: var(--text-secondary); 571 } 572 .method { 573 margin-top: 0.75rem; 574 padding-top: 0.75rem; 575 border-top: 1px solid var(--border-color-light); 576 } 577 .method:first-of-type { 578 margin-top: 0.5rem; 579 padding-top: 0; 580 border-top: none; 581 } 582 code.record { 583 display: block; 584 background: var(--bg-input); 585 padding: 0.5rem; 586 border-radius: 4px; 587 font-size: 0.75rem; 588 word-break: break-all; 589 margin: 0.25rem 0; 590 } 591</style>