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">← 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>