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>