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 $effect(() => { 23 if (!auth.loading && !auth.session) { 24 navigate('/login') 25 } 26 }) 27 function showMessage(type: 'success' | 'error', text: string) { 28 message = { type, text } 29 setTimeout(() => { 30 if (message?.text === text) message = null 31 }, 5000) 32 } 33 async function handleRequestEmailUpdate(e: Event) { 34 e.preventDefault() 35 if (!auth.session || !newEmail) return 36 emailLoading = true 37 message = null 38 try { 39 const result = await api.requestEmailUpdate(auth.session.accessJwt) 40 emailTokenRequired = result.tokenRequired 41 if (emailTokenRequired) { 42 showMessage('success', 'Verification code sent to your current email') 43 } else { 44 await api.updateEmail(auth.session.accessJwt, newEmail) 45 showMessage('success', 'Email updated successfully') 46 newEmail = '' 47 } 48 } catch (e) { 49 showMessage('error', e instanceof ApiError ? e.message : 'Failed to update email') 50 } finally { 51 emailLoading = false 52 } 53 } 54 async function handleConfirmEmailUpdate(e: Event) { 55 e.preventDefault() 56 if (!auth.session || !newEmail || !emailToken) return 57 emailLoading = true 58 message = null 59 try { 60 await api.updateEmail(auth.session.accessJwt, newEmail, emailToken) 61 showMessage('success', 'Email updated successfully') 62 newEmail = '' 63 emailToken = '' 64 emailTokenRequired = false 65 } catch (e) { 66 showMessage('error', e instanceof ApiError ? e.message : 'Failed to update email') 67 } finally { 68 emailLoading = false 69 } 70 } 71 async function handleUpdateHandle(e: Event) { 72 e.preventDefault() 73 if (!auth.session || !newHandle) return 74 handleLoading = true 75 message = null 76 try { 77 await api.updateHandle(auth.session.accessJwt, newHandle) 78 showMessage('success', 'Handle updated successfully') 79 newHandle = '' 80 } catch (e) { 81 showMessage('error', e instanceof ApiError ? e.message : 'Failed to update handle') 82 } finally { 83 handleLoading = false 84 } 85 } 86 async function handleRequestDelete() { 87 if (!auth.session) return 88 deleteLoading = true 89 message = null 90 try { 91 await api.requestAccountDelete(auth.session.accessJwt) 92 deleteTokenSent = true 93 showMessage('success', 'Deletion confirmation sent to your email') 94 } catch (e) { 95 showMessage('error', e instanceof ApiError ? e.message : 'Failed to request deletion') 96 } finally { 97 deleteLoading = false 98 } 99 } 100 async function handleConfirmDelete(e: Event) { 101 e.preventDefault() 102 if (!auth.session || !deletePassword || !deleteToken) return 103 if (!confirm('Are you absolutely sure you want to delete your account? This cannot be undone.')) { 104 return 105 } 106 deleteLoading = true 107 message = null 108 try { 109 await api.deleteAccount(auth.session.did, deletePassword, deleteToken) 110 await logout() 111 navigate('/login') 112 } catch (e) { 113 showMessage('error', e instanceof ApiError ? e.message : 'Failed to delete account') 114 } finally { 115 deleteLoading = false 116 } 117 } 118 async function handleExportRepo() { 119 if (!auth.session) return 120 exportLoading = true 121 message = null 122 try { 123 const response = await fetch(`/xrpc/com.atproto.sync.getRepo?did=${encodeURIComponent(auth.session.did)}`, { 124 headers: { 125 'Authorization': `Bearer ${auth.session.accessJwt}` 126 } 127 }) 128 if (!response.ok) { 129 const err = await response.json().catch(() => ({ message: 'Export failed' })) 130 throw new Error(err.message || 'Export failed') 131 } 132 const blob = await response.blob() 133 const url = URL.createObjectURL(blob) 134 const a = document.createElement('a') 135 a.href = url 136 a.download = `${auth.session.handle}-repo.car` 137 document.body.appendChild(a) 138 a.click() 139 document.body.removeChild(a) 140 URL.revokeObjectURL(url) 141 showMessage('success', 'Repository exported successfully') 142 } catch (e) { 143 showMessage('error', e instanceof Error ? e.message : 'Failed to export repository') 144 } finally { 145 exportLoading = false 146 } 147 } 148 async function handleChangePassword(e: Event) { 149 e.preventDefault() 150 if (!auth.session || !currentPassword || !newPassword || !confirmNewPassword) return 151 if (newPassword !== confirmNewPassword) { 152 showMessage('error', 'Passwords do not match') 153 return 154 } 155 if (newPassword.length < 8) { 156 showMessage('error', 'Password must be at least 8 characters') 157 return 158 } 159 passwordLoading = true 160 message = null 161 try { 162 await api.changePassword(auth.session.accessJwt, currentPassword, newPassword) 163 showMessage('success', 'Password changed successfully') 164 currentPassword = '' 165 newPassword = '' 166 confirmNewPassword = '' 167 } catch (e) { 168 showMessage('error', e instanceof ApiError ? e.message : 'Failed to change password') 169 } finally { 170 passwordLoading = false 171 } 172 } 173</script> 174<div class="page"> 175 <header> 176 <a href="#/dashboard" class="back">&larr; Dashboard</a> 177 <h1>Account Settings</h1> 178 </header> 179 {#if message} 180 <div class="message {message.type}">{message.text}</div> 181 {/if} 182 <section> 183 <h2>Change Email</h2> 184 {#if auth.session?.email} 185 <p class="current">Current: {auth.session.email}</p> 186 {/if} 187 {#if emailTokenRequired} 188 <form onsubmit={handleConfirmEmailUpdate}> 189 <div class="field"> 190 <label for="email-token">Verification Code</label> 191 <input 192 id="email-token" 193 type="text" 194 bind:value={emailToken} 195 placeholder="Enter code from email" 196 disabled={emailLoading} 197 required 198 /> 199 </div> 200 <div class="actions"> 201 <button type="submit" disabled={emailLoading || !emailToken}> 202 {emailLoading ? 'Updating...' : 'Confirm Email Change'} 203 </button> 204 <button type="button" class="secondary" onclick={() => { emailTokenRequired = false; emailToken = '' }}> 205 Cancel 206 </button> 207 </div> 208 </form> 209 {:else} 210 <form onsubmit={handleRequestEmailUpdate}> 211 <div class="field"> 212 <label for="new-email">New Email</label> 213 <input 214 id="new-email" 215 type="email" 216 bind:value={newEmail} 217 placeholder="new@example.com" 218 disabled={emailLoading} 219 required 220 /> 221 </div> 222 <button type="submit" disabled={emailLoading || !newEmail}> 223 {emailLoading ? 'Requesting...' : 'Change Email'} 224 </button> 225 </form> 226 {/if} 227 </section> 228 <section> 229 <h2>Change Handle</h2> 230 {#if auth.session} 231 <p class="current">Current: @{auth.session.handle}</p> 232 {/if} 233 <form onsubmit={handleUpdateHandle}> 234 <div class="field"> 235 <label for="new-handle">New Handle</label> 236 <input 237 id="new-handle" 238 type="text" 239 bind:value={newHandle} 240 placeholder="newhandle.bsky.social" 241 disabled={handleLoading} 242 required 243 /> 244 </div> 245 <button type="submit" disabled={handleLoading || !newHandle}> 246 {handleLoading ? 'Updating...' : 'Change Handle'} 247 </button> 248 </form> 249 </section> 250 <section> 251 <h2>Change Password</h2> 252 <form onsubmit={handleChangePassword}> 253 <div class="field"> 254 <label for="current-password">Current Password</label> 255 <input 256 id="current-password" 257 type="password" 258 bind:value={currentPassword} 259 placeholder="Enter current password" 260 disabled={passwordLoading} 261 required 262 /> 263 </div> 264 <div class="field"> 265 <label for="new-password">New Password</label> 266 <input 267 id="new-password" 268 type="password" 269 bind:value={newPassword} 270 placeholder="At least 8 characters" 271 disabled={passwordLoading} 272 required 273 minlength="8" 274 /> 275 </div> 276 <div class="field"> 277 <label for="confirm-new-password">Confirm New Password</label> 278 <input 279 id="confirm-new-password" 280 type="password" 281 bind:value={confirmNewPassword} 282 placeholder="Confirm new password" 283 disabled={passwordLoading} 284 required 285 /> 286 </div> 287 <button type="submit" disabled={passwordLoading || !currentPassword || !newPassword || !confirmNewPassword}> 288 {passwordLoading ? 'Changing...' : 'Change Password'} 289 </button> 290 </form> 291 </section> 292 <section> 293 <h2>Export Data</h2> 294 <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> 295 <button onclick={handleExportRepo} disabled={exportLoading}> 296 {exportLoading ? 'Exporting...' : 'Download Repository'} 297 </button> 298 </section> 299 <section class="danger-zone"> 300 <h2>Delete Account</h2> 301 <p class="warning">This action is irreversible. All your data will be permanently deleted.</p> 302 {#if deleteTokenSent} 303 <form onsubmit={handleConfirmDelete}> 304 <div class="field"> 305 <label for="delete-token">Confirmation Code (from email)</label> 306 <input 307 id="delete-token" 308 type="text" 309 bind:value={deleteToken} 310 placeholder="Enter confirmation code" 311 disabled={deleteLoading} 312 required 313 /> 314 </div> 315 <div class="field"> 316 <label for="delete-password">Your Password</label> 317 <input 318 id="delete-password" 319 type="password" 320 bind:value={deletePassword} 321 placeholder="Enter your password" 322 disabled={deleteLoading} 323 required 324 /> 325 </div> 326 <div class="actions"> 327 <button type="submit" class="danger" disabled={deleteLoading || !deleteToken || !deletePassword}> 328 {deleteLoading ? 'Deleting...' : 'Permanently Delete Account'} 329 </button> 330 <button type="button" class="secondary" onclick={() => { deleteTokenSent = false; deleteToken = ''; deletePassword = '' }}> 331 Cancel 332 </button> 333 </div> 334 </form> 335 {:else} 336 <button class="danger" onclick={handleRequestDelete} disabled={deleteLoading}> 337 {deleteLoading ? 'Requesting...' : 'Request Account Deletion'} 338 </button> 339 {/if} 340 </section> 341</div> 342<style> 343 .page { 344 max-width: 600px; 345 margin: 0 auto; 346 padding: 2rem; 347 } 348 header { 349 margin-bottom: 2rem; 350 } 351 .back { 352 color: var(--text-secondary); 353 text-decoration: none; 354 font-size: 0.875rem; 355 } 356 .back:hover { 357 color: var(--accent); 358 } 359 h1 { 360 margin: 0.5rem 0 0 0; 361 } 362 .message { 363 padding: 0.75rem; 364 border-radius: 4px; 365 margin-bottom: 1rem; 366 } 367 .message.success { 368 background: var(--success-bg); 369 border: 1px solid var(--success-border); 370 color: var(--success-text); 371 } 372 .message.error { 373 background: var(--error-bg); 374 border: 1px solid var(--error-border); 375 color: var(--error-text); 376 } 377 section { 378 padding: 1.5rem; 379 background: var(--bg-secondary); 380 border-radius: 8px; 381 margin-bottom: 1.5rem; 382 } 383 section h2 { 384 margin: 0 0 0.5rem 0; 385 font-size: 1.125rem; 386 } 387 .current, .description { 388 color: var(--text-secondary); 389 font-size: 0.875rem; 390 margin-bottom: 1rem; 391 } 392 .field { 393 margin-bottom: 1rem; 394 } 395 label { 396 display: block; 397 font-size: 0.875rem; 398 font-weight: 500; 399 margin-bottom: 0.25rem; 400 } 401 input { 402 width: 100%; 403 padding: 0.75rem; 404 border: 1px solid var(--border-color-light); 405 border-radius: 4px; 406 font-size: 1rem; 407 box-sizing: border-box; 408 background: var(--bg-input); 409 color: var(--text-primary); 410 } 411 input:focus { 412 outline: none; 413 border-color: var(--accent); 414 } 415 button { 416 padding: 0.75rem 1.5rem; 417 background: var(--accent); 418 color: white; 419 border: none; 420 border-radius: 4px; 421 cursor: pointer; 422 font-size: 1rem; 423 } 424 button:hover:not(:disabled) { 425 background: var(--accent-hover); 426 } 427 button:disabled { 428 opacity: 0.6; 429 cursor: not-allowed; 430 } 431 button.secondary { 432 background: transparent; 433 color: var(--text-secondary); 434 border: 1px solid var(--border-color-light); 435 } 436 button.secondary:hover:not(:disabled) { 437 background: var(--bg-secondary); 438 } 439 button.danger { 440 background: var(--error-text); 441 } 442 button.danger:hover:not(:disabled) { 443 background: #900; 444 } 445 .actions { 446 display: flex; 447 gap: 0.5rem; 448 } 449 .danger-zone { 450 background: var(--error-bg); 451 border: 1px solid var(--error-border); 452 } 453 .danger-zone h2 { 454 color: var(--error-text); 455 } 456 .warning { 457 color: var(--error-text); 458 font-size: 0.875rem; 459 margin-bottom: 1rem; 460 } 461</style>