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>