this repo has no description
1<script lang="ts">
2 import { getAuthState, logout, refreshSession } from '../lib/auth.svelte'
3 import { navigate } from '../lib/router.svelte'
4 import { api, ApiError } from '../lib/api'
5 import { locale, setLocale, getSupportedLocales, localeNames, _, type SupportedLocale } from '../lib/i18n'
6 const auth = getAuthState()
7 const supportedLocales = getSupportedLocales()
8 let localeLoading = $state(false)
9 async function handleLocaleChange(newLocale: SupportedLocale) {
10 if (!auth.session) return
11 setLocale(newLocale)
12 localeLoading = true
13 try {
14 await api.updateLocale(auth.session.accessJwt, newLocale)
15 } catch (e) {
16 console.error('Failed to save locale preference:', e)
17 } finally {
18 localeLoading = false
19 }
20 }
21 let message = $state<{ type: 'success' | 'error'; text: string } | null>(null)
22 let emailLoading = $state(false)
23 let newEmail = $state('')
24 let emailToken = $state('')
25 let emailTokenRequired = $state(false)
26 let handleLoading = $state(false)
27 let newHandle = $state('')
28 let deleteLoading = $state(false)
29 let deletePassword = $state('')
30 let deleteToken = $state('')
31 let deleteTokenSent = $state(false)
32 let exportLoading = $state(false)
33 let passwordLoading = $state(false)
34 let currentPassword = $state('')
35 let newPassword = $state('')
36 let confirmNewPassword = $state('')
37 let showBYOHandle = $state(false)
38 $effect(() => {
39 if (!auth.loading && !auth.session) {
40 navigate('/login')
41 }
42 })
43 function showMessage(type: 'success' | 'error', text: string) {
44 message = { type, text }
45 setTimeout(() => {
46 if (message?.text === text) message = null
47 }, 5000)
48 }
49 async function handleRequestEmailUpdate(e: Event) {
50 e.preventDefault()
51 if (!auth.session || !newEmail) return
52 emailLoading = true
53 message = null
54 try {
55 const result = await api.requestEmailUpdate(auth.session.accessJwt, newEmail)
56 emailTokenRequired = result.tokenRequired
57 if (emailTokenRequired) {
58 showMessage('success', $_('settings.messages.verificationCodeSent'))
59 } else {
60 await api.updateEmail(auth.session.accessJwt, newEmail)
61 await refreshSession()
62 showMessage('success', $_('settings.messages.emailUpdated'))
63 newEmail = ''
64 }
65 } catch (e) {
66 showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.emailUpdateFailed'))
67 } finally {
68 emailLoading = false
69 }
70 }
71 async function handleConfirmEmailUpdate(e: Event) {
72 e.preventDefault()
73 if (!auth.session || !newEmail || !emailToken) return
74 emailLoading = true
75 message = null
76 try {
77 await api.updateEmail(auth.session.accessJwt, newEmail, emailToken)
78 await refreshSession()
79 showMessage('success', $_('settings.messages.emailUpdated'))
80 newEmail = ''
81 emailToken = ''
82 emailTokenRequired = false
83 } catch (e) {
84 showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.emailUpdateFailed'))
85 } finally {
86 emailLoading = false
87 }
88 }
89 async function handleUpdateHandle(e: Event) {
90 e.preventDefault()
91 if (!auth.session || !newHandle) return
92 handleLoading = true
93 message = null
94 try {
95 const fullHandle = showBYOHandle
96 ? newHandle
97 : `${newHandle}.${window.location.hostname}`
98 await api.updateHandle(auth.session.accessJwt, fullHandle)
99 await refreshSession()
100 showMessage('success', $_('settings.messages.handleUpdated'))
101 newHandle = ''
102 } catch (e) {
103 showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.handleUpdateFailed'))
104 } finally {
105 handleLoading = false
106 }
107 }
108 async function handleRequestDelete() {
109 if (!auth.session) return
110 deleteLoading = true
111 message = null
112 try {
113 await api.requestAccountDelete(auth.session.accessJwt)
114 deleteTokenSent = true
115 showMessage('success', $_('settings.messages.deletionConfirmationSent'))
116 } catch (e) {
117 showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.deletionRequestFailed'))
118 } finally {
119 deleteLoading = false
120 }
121 }
122 async function handleConfirmDelete(e: Event) {
123 e.preventDefault()
124 if (!auth.session || !deletePassword || !deleteToken) return
125 if (!confirm($_('settings.messages.deleteConfirmation'))) {
126 return
127 }
128 deleteLoading = true
129 message = null
130 try {
131 await api.deleteAccount(auth.session.did, deletePassword, deleteToken)
132 await logout()
133 navigate('/login')
134 } catch (e) {
135 showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.deletionFailed'))
136 } finally {
137 deleteLoading = false
138 }
139 }
140 async function handleExportRepo() {
141 if (!auth.session) return
142 exportLoading = true
143 message = null
144 try {
145 const response = await fetch(`/xrpc/com.atproto.sync.getRepo?did=${encodeURIComponent(auth.session.did)}`, {
146 headers: {
147 'Authorization': `Bearer ${auth.session.accessJwt}`
148 }
149 })
150 if (!response.ok) {
151 const err = await response.json().catch(() => ({ message: 'Export failed' }))
152 throw new Error(err.message || 'Export failed')
153 }
154 const blob = await response.blob()
155 const url = URL.createObjectURL(blob)
156 const a = document.createElement('a')
157 a.href = url
158 a.download = `${auth.session.handle}-repo.car`
159 document.body.appendChild(a)
160 a.click()
161 document.body.removeChild(a)
162 URL.revokeObjectURL(url)
163 showMessage('success', $_('settings.messages.repoExported'))
164 } catch (e) {
165 showMessage('error', e instanceof Error ? e.message : $_('settings.messages.exportFailed'))
166 } finally {
167 exportLoading = false
168 }
169 }
170 async function handleChangePassword(e: Event) {
171 e.preventDefault()
172 if (!auth.session || !currentPassword || !newPassword || !confirmNewPassword) return
173 if (newPassword !== confirmNewPassword) {
174 showMessage('error', $_('settings.messages.passwordsDoNotMatch'))
175 return
176 }
177 if (newPassword.length < 8) {
178 showMessage('error', $_('settings.messages.passwordTooShort'))
179 return
180 }
181 passwordLoading = true
182 message = null
183 try {
184 await api.changePassword(auth.session.accessJwt, currentPassword, newPassword)
185 showMessage('success', $_('settings.messages.passwordChanged'))
186 currentPassword = ''
187 newPassword = ''
188 confirmNewPassword = ''
189 } catch (e) {
190 showMessage('error', e instanceof ApiError ? e.message : $_('settings.messages.passwordChangeFailed'))
191 } finally {
192 passwordLoading = false
193 }
194 }
195</script>
196<div class="page">
197 <header>
198 <a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a>
199 <h1>{$_('settings.title')}</h1>
200 </header>
201 {#if message}
202 <div class="message {message.type}">{message.text}</div>
203 {/if}
204 <section>
205 <h2>{$_('settings.language')}</h2>
206 <p class="description">{$_('settings.languageDescription')}</p>
207 <select
208 class="language-select"
209 value={$locale}
210 disabled={localeLoading}
211 onchange={(e) => handleLocaleChange(e.currentTarget.value as SupportedLocale)}
212 >
213 {#each supportedLocales as loc}
214 <option value={loc}>{localeNames[loc]}</option>
215 {/each}
216 </select>
217 </section>
218 <section>
219 <h2>{$_('settings.changeEmail')}</h2>
220 {#if auth.session?.email}
221 <p class="current">{$_('settings.currentEmail', { values: { email: auth.session.email } })}</p>
222 {/if}
223 {#if emailTokenRequired}
224 <form onsubmit={handleConfirmEmailUpdate}>
225 <div class="field">
226 <label for="email-token">{$_('settings.verificationCode')}</label>
227 <input
228 id="email-token"
229 type="text"
230 bind:value={emailToken}
231 placeholder={$_('settings.verificationCodePlaceholder')}
232 disabled={emailLoading}
233 required
234 />
235 </div>
236 <div class="actions">
237 <button type="submit" disabled={emailLoading || !emailToken}>
238 {emailLoading ? $_('settings.updating') : $_('settings.confirmEmailChange')}
239 </button>
240 <button type="button" class="secondary" onclick={() => { emailTokenRequired = false; emailToken = '' }}>
241 {$_('common.cancel')}
242 </button>
243 </div>
244 </form>
245 {:else}
246 <form onsubmit={handleRequestEmailUpdate}>
247 <div class="field">
248 <label for="new-email">{$_('settings.newEmail')}</label>
249 <input
250 id="new-email"
251 type="email"
252 bind:value={newEmail}
253 placeholder={$_('settings.newEmailPlaceholder')}
254 disabled={emailLoading}
255 required
256 />
257 </div>
258 <button type="submit" disabled={emailLoading || !newEmail}>
259 {emailLoading ? $_('settings.requesting') : $_('settings.changeEmailButton')}
260 </button>
261 </form>
262 {/if}
263 </section>
264 <section>
265 <h2>{$_('settings.changeHandle')}</h2>
266 {#if auth.session}
267 <p class="current">{$_('settings.currentHandle', { values: { handle: auth.session.handle } })}</p>
268 {/if}
269 <div class="tabs">
270 <button
271 type="button"
272 class="tab"
273 class:active={!showBYOHandle}
274 onclick={() => showBYOHandle = false}
275 >
276 {$_('settings.pdsHandle')}
277 </button>
278 <button
279 type="button"
280 class="tab"
281 class:active={showBYOHandle}
282 onclick={() => showBYOHandle = true}
283 >
284 {$_('settings.customDomain')}
285 </button>
286 </div>
287 {#if showBYOHandle}
288 <div class="byo-handle">
289 <p class="description">{$_('settings.customDomainDescription')}</p>
290 {#if auth.session}
291 <div class="verification-info">
292 <h3>{$_('settings.setupInstructions')}</h3>
293 <p>{$_('settings.setupMethodsIntro')}</p>
294 <div class="method">
295 <h4>{$_('settings.dnsMethod')}</h4>
296 <p>{$_('settings.dnsMethodDesc')}</p>
297 <code class="record">_atproto.{newHandle || 'yourdomain.com'} TXT "did={auth.session.did}"</code>
298 </div>
299 <div class="method">
300 <h4>{$_('settings.httpMethod')}</h4>
301 <p>{$_('settings.httpMethodDesc')}</p>
302 <code class="record">https://{newHandle || 'yourdomain.com'}/.well-known/atproto-did</code>
303 <p>{$_('settings.httpMethodContent')}</p>
304 <code class="record">{auth.session.did}</code>
305 </div>
306 </div>
307 {/if}
308 <form onsubmit={handleUpdateHandle}>
309 <div class="field">
310 <label for="new-handle-byo">{$_('settings.yourDomain')}</label>
311 <input
312 id="new-handle-byo"
313 type="text"
314 bind:value={newHandle}
315 placeholder={$_('settings.yourDomainPlaceholder')}
316 disabled={handleLoading}
317 required
318 />
319 </div>
320 <button type="submit" disabled={handleLoading || !newHandle}>
321 {handleLoading ? $_('settings.verifying') : $_('settings.verifyAndUpdate')}
322 </button>
323 </form>
324 </div>
325 {:else}
326 <form onsubmit={handleUpdateHandle}>
327 <div class="field">
328 <label for="new-handle">{$_('settings.newHandle')}</label>
329 <div class="handle-input-wrapper">
330 <input
331 id="new-handle"
332 type="text"
333 bind:value={newHandle}
334 placeholder={$_('settings.newHandlePlaceholder')}
335 disabled={handleLoading}
336 required
337 />
338 <span class="handle-suffix">.{window.location.hostname}</span>
339 </div>
340 </div>
341 <button type="submit" disabled={handleLoading || !newHandle}>
342 {handleLoading ? $_('settings.updating') : $_('settings.changeHandleButton')}
343 </button>
344 </form>
345 {/if}
346 </section>
347 <section>
348 <h2>{$_('settings.changePassword')}</h2>
349 <form onsubmit={handleChangePassword}>
350 <div class="field">
351 <label for="current-password">{$_('settings.currentPassword')}</label>
352 <input
353 id="current-password"
354 type="password"
355 bind:value={currentPassword}
356 placeholder={$_('settings.currentPasswordPlaceholder')}
357 disabled={passwordLoading}
358 required
359 />
360 </div>
361 <div class="field">
362 <label for="new-password">{$_('settings.newPassword')}</label>
363 <input
364 id="new-password"
365 type="password"
366 bind:value={newPassword}
367 placeholder={$_('settings.newPasswordPlaceholder')}
368 disabled={passwordLoading}
369 required
370 minlength="8"
371 />
372 </div>
373 <div class="field">
374 <label for="confirm-new-password">{$_('settings.confirmNewPassword')}</label>
375 <input
376 id="confirm-new-password"
377 type="password"
378 bind:value={confirmNewPassword}
379 placeholder={$_('settings.confirmNewPasswordPlaceholder')}
380 disabled={passwordLoading}
381 required
382 />
383 </div>
384 <button type="submit" disabled={passwordLoading || !currentPassword || !newPassword || !confirmNewPassword}>
385 {passwordLoading ? $_('settings.changing') : $_('settings.changePasswordButton')}
386 </button>
387 </form>
388 </section>
389 <section>
390 <h2>{$_('settings.exportData')}</h2>
391 <p class="description">{$_('settings.exportDataDescription')}</p>
392 <button onclick={handleExportRepo} disabled={exportLoading}>
393 {exportLoading ? $_('settings.exporting') : $_('settings.downloadRepo')}
394 </button>
395 </section>
396 <section class="danger-zone">
397 <h2>{$_('settings.deleteAccount')}</h2>
398 <p class="warning">{$_('settings.deleteWarning')}</p>
399 {#if deleteTokenSent}
400 <form onsubmit={handleConfirmDelete}>
401 <div class="field">
402 <label for="delete-token">{$_('settings.confirmationCode')}</label>
403 <input
404 id="delete-token"
405 type="text"
406 bind:value={deleteToken}
407 placeholder={$_('settings.confirmationCodePlaceholder')}
408 disabled={deleteLoading}
409 required
410 />
411 </div>
412 <div class="field">
413 <label for="delete-password">{$_('settings.yourPassword')}</label>
414 <input
415 id="delete-password"
416 type="password"
417 bind:value={deletePassword}
418 placeholder={$_('settings.yourPasswordPlaceholder')}
419 disabled={deleteLoading}
420 required
421 />
422 </div>
423 <div class="actions">
424 <button type="submit" class="danger" disabled={deleteLoading || !deleteToken || !deletePassword}>
425 {deleteLoading ? $_('settings.deleting') : $_('settings.permanentlyDelete')}
426 </button>
427 <button type="button" class="secondary" onclick={() => { deleteTokenSent = false; deleteToken = ''; deletePassword = '' }}>
428 {$_('common.cancel')}
429 </button>
430 </div>
431 </form>
432 {:else}
433 <button class="danger" onclick={handleRequestDelete} disabled={deleteLoading}>
434 {deleteLoading ? $_('settings.requesting') : $_('settings.requestDeletion')}
435 </button>
436 {/if}
437 </section>
438</div>
439<style>
440 .page {
441 max-width: var(--width-md);
442 margin: 0 auto;
443 padding: var(--space-7);
444 }
445
446 header {
447 margin-bottom: var(--space-7);
448 }
449
450 .back {
451 color: var(--text-secondary);
452 text-decoration: none;
453 font-size: var(--text-sm);
454 }
455
456 .back:hover {
457 color: var(--accent);
458 }
459
460 h1 {
461 margin: var(--space-2) 0 0 0;
462 }
463
464 section {
465 padding: var(--space-6);
466 background: var(--bg-secondary);
467 border-radius: var(--radius-xl);
468 margin-bottom: var(--space-6);
469 }
470
471 section h2 {
472 margin: 0 0 var(--space-2) 0;
473 font-size: var(--text-lg);
474 }
475
476 .current,
477 .description {
478 color: var(--text-secondary);
479 font-size: var(--text-sm);
480 margin-bottom: var(--space-4);
481 }
482
483 .language-select {
484 width: 100%;
485 }
486
487 .actions {
488 display: flex;
489 gap: var(--space-2);
490 }
491
492 .danger-zone {
493 background: var(--error-bg);
494 border: 1px solid var(--error-border);
495 }
496
497 .danger-zone h2 {
498 color: var(--error-text);
499 }
500
501 .warning {
502 color: var(--error-text);
503 font-size: var(--text-sm);
504 margin-bottom: var(--space-4);
505 }
506
507 .tabs {
508 display: flex;
509 gap: var(--space-1);
510 margin-bottom: var(--space-4);
511 }
512
513 .tab {
514 flex: 1;
515 padding: var(--space-2) var(--space-4);
516 background: transparent;
517 border: 1px solid var(--border-color);
518 cursor: pointer;
519 font-size: var(--text-sm);
520 color: var(--text-secondary);
521 }
522
523 .tab:first-child {
524 border-radius: var(--radius-md) 0 0 var(--radius-md);
525 }
526
527 .tab:last-child {
528 border-radius: 0 var(--radius-md) var(--radius-md) 0;
529 }
530
531 .tab.active {
532 background: var(--accent);
533 border-color: var(--accent);
534 color: var(--text-inverse);
535 }
536
537 .tab:hover:not(.active) {
538 background: var(--bg-card);
539 }
540
541 .byo-handle .description {
542 margin-bottom: var(--space-4);
543 }
544
545 .verification-info {
546 background: var(--bg-card);
547 border: 1px solid var(--border-color);
548 border-radius: var(--radius-lg);
549 padding: var(--space-4);
550 margin-bottom: var(--space-4);
551 }
552
553 .verification-info h3 {
554 margin: 0 0 var(--space-2) 0;
555 font-size: var(--text-base);
556 }
557
558 .verification-info h4 {
559 margin: var(--space-3) 0 var(--space-1) 0;
560 font-size: var(--text-sm);
561 color: var(--text-secondary);
562 }
563
564 .verification-info p {
565 margin: var(--space-1) 0;
566 font-size: var(--text-xs);
567 color: var(--text-secondary);
568 }
569
570 .method {
571 margin-top: var(--space-3);
572 padding-top: var(--space-3);
573 border-top: 1px solid var(--border-color);
574 }
575
576 .method:first-of-type {
577 margin-top: var(--space-2);
578 padding-top: 0;
579 border-top: none;
580 }
581
582 code.record {
583 display: block;
584 background: var(--bg-input);
585 padding: var(--space-2);
586 border-radius: var(--radius-md);
587 font-size: var(--text-xs);
588 word-break: break-all;
589 margin: var(--space-1) 0;
590 }
591
592 .handle-input-wrapper {
593 display: flex;
594 align-items: center;
595 background: var(--bg-input);
596 border: 1px solid var(--border-color);
597 border-radius: var(--radius-md);
598 overflow: hidden;
599 }
600
601 .handle-input-wrapper input {
602 flex: 1;
603 border: none;
604 border-radius: 0;
605 background: transparent;
606 min-width: 0;
607 }
608
609 .handle-input-wrapper input:focus {
610 outline: none;
611 box-shadow: none;
612 }
613
614 .handle-input-wrapper:focus-within {
615 border-color: var(--accent);
616 box-shadow: 0 0 0 2px var(--accent-muted);
617 }
618
619 .handle-suffix {
620 padding: 0 var(--space-3);
621 color: var(--text-secondary);
622 font-size: var(--text-sm);
623 white-space: nowrap;
624 border-left: 1px solid var(--border-color);
625 background: var(--bg-card);
626 }
627</style>