forked from
tranquil.farm/tranquil-pds
Our Personal Data Server from scratch!
35 kB
1208 lines
1<script lang="ts">
2 import { onMount } from 'svelte'
3 import { getAuthState, logout, refreshSession } from '../lib/auth.svelte'
4 import { navigate, routes, getFullUrl } from '../lib/router.svelte'
5 import { api, ApiError } from '../lib/api'
6 import { locale, setLocale, getSupportedLocales, localeNames, _, type SupportedLocale } from '../lib/i18n'
7 import { isOk } from '../lib/types/result'
8 import { unsafeAsHandle } from '../lib/types/branded'
9 import type { Session } from '../lib/types/api'
10 import { toast } from '../lib/toast.svelte'
11 import ReauthModal from '../components/ReauthModal.svelte'
12
13 const auth = $derived(getAuthState())
14 const supportedLocales = getSupportedLocales()
15 let pdsHostname = $state<string | null>(null)
16
17 function getSession(): Session | null {
18 return auth.kind === 'authenticated' ? auth.session : null
19 }
20
21 function isLoading(): boolean {
22 return auth.kind === 'loading'
23 }
24
25 const session = $derived(getSession())
26 const loading = $derived(isLoading())
27
28 onMount(() => {
29 api.describeServer().then(info => {
30 if (info.availableUserDomains?.length) {
31 pdsHostname = info.availableUserDomains[0]
32 }
33 }).catch(() => {})
34
35 return () => {
36 stopEmailPolling()
37 }
38 })
39
40 let localeLoading = $state(false)
41 async function handleLocaleChange(newLocale: SupportedLocale) {
42 if (!session) return
43 setLocale(newLocale)
44 localeLoading = true
45 try {
46 await api.updateLocale(session.accessJwt, newLocale)
47 } catch (e) {
48 console.error('Failed to save locale preference:', e)
49 } finally {
50 localeLoading = false
51 }
52 }
53
54 let emailLoading = $state(false)
55 let newEmail = $state('')
56 let emailToken = $state('')
57 let emailTokenRequired = $state(false)
58 let emailUpdateAuthorized = $state(false)
59 let emailPollingInterval = $state<ReturnType<typeof setInterval> | null>(null)
60 let newEmailInUse = $state(false)
61
62 async function checkNewEmailInUse() {
63 if (!newEmail.trim() || !newEmail.includes('@')) {
64 newEmailInUse = false
65 return
66 }
67 try {
68 const result = await api.checkEmailInUse(newEmail.trim())
69 newEmailInUse = result.inUse
70 } catch {
71 newEmailInUse = false
72 }
73 }
74 let handleLoading = $state(false)
75 let newHandle = $state('')
76 let deleteLoading = $state(false)
77 let deletePassword = $state('')
78 let deleteToken = $state('')
79 let deleteTokenSent = $state(false)
80 let exportLoading = $state(false)
81 let exportBlobsLoading = $state(false)
82 let passwordLoading = $state(false)
83 let currentPassword = $state('')
84 let newPassword = $state('')
85 let confirmNewPassword = $state('')
86 let showBYOHandle = $state(false)
87 let hasPassword = $state(true)
88 let passwordStatusLoading = $state(true)
89 let setPasswordLoading = $state(false)
90 let showReauthModal = $state(false)
91 let reauthMethods = $state<string[]>(['passkey'])
92 let pendingAction = $state<(() => Promise<void>) | null>(null)
93
94 $effect(() => {
95 if (!loading && !session) {
96 navigate(routes.login)
97 }
98 })
99
100 $effect(() => {
101 if (session) {
102 loadPasswordStatus()
103 }
104 })
105
106 async function loadPasswordStatus() {
107 if (!session) return
108 passwordStatusLoading = true
109 try {
110 const status = await api.getPasswordStatus(session.accessJwt)
111 hasPassword = status.hasPassword
112 } catch {
113 hasPassword = true
114 } finally {
115 passwordStatusLoading = false
116 }
117 }
118
119 async function handleRequestEmailUpdate() {
120 if (!session || !newEmail.trim()) return
121 emailLoading = true
122 try {
123 const result = await api.requestEmailUpdate(session.accessJwt, newEmail.trim())
124 emailTokenRequired = result.tokenRequired
125 if (emailTokenRequired) {
126 toast.success($_('settings.messages.emailCodeSentToCurrent'))
127 startEmailPolling()
128 } else {
129 emailTokenRequired = true
130 }
131 } catch (e) {
132 toast.error(e instanceof ApiError ? e.message : $_('settings.messages.emailUpdateFailed'))
133 } finally {
134 emailLoading = false
135 }
136 }
137
138 function startEmailPolling() {
139 if (emailPollingInterval) return
140 emailPollingInterval = setInterval(async () => {
141 if (!session) return
142 try {
143 const status = await api.checkEmailUpdateStatus(session.accessJwt)
144 if (status.authorized) {
145 emailUpdateAuthorized = true
146 stopEmailPolling()
147 await completeAuthorizedEmailUpdate()
148 }
149 } catch {
150 }
151 }, 3000)
152 }
153
154 function stopEmailPolling() {
155 if (emailPollingInterval) {
156 clearInterval(emailPollingInterval)
157 emailPollingInterval = null
158 }
159 }
160
161 async function completeAuthorizedEmailUpdate() {
162 if (!session || !newEmail.trim()) return
163 emailLoading = true
164 try {
165 await api.updateEmail(session.accessJwt, newEmail.trim())
166 await refreshSession()
167 toast.success($_('settings.messages.emailUpdated'))
168 newEmail = ''
169 emailToken = ''
170 emailTokenRequired = false
171 emailUpdateAuthorized = false
172 } catch (e) {
173 toast.error(e instanceof ApiError ? e.message : $_('settings.messages.emailUpdateFailed'))
174 } finally {
175 emailLoading = false
176 }
177 }
178
179 async function handleConfirmEmailUpdate(e: Event) {
180 e.preventDefault()
181 if (!session || !newEmail || !emailToken) return
182 emailLoading = true
183 try {
184 await api.updateEmail(session.accessJwt, newEmail, emailToken)
185 await refreshSession()
186 toast.success($_('settings.messages.emailUpdated'))
187 newEmail = ''
188 emailToken = ''
189 emailTokenRequired = false
190 } catch (e) {
191 toast.error(e instanceof ApiError ? e.message : $_('settings.messages.emailUpdateFailed'))
192 } finally {
193 emailLoading = false
194 }
195 }
196
197 async function handleUpdateHandle(e: Event) {
198 e.preventDefault()
199 if (!session || !newHandle) return
200 handleLoading = true
201 try {
202 const fullHandle = showBYOHandle
203 ? newHandle
204 : `${newHandle}.${pdsHostname}`
205 await api.updateHandle(session.accessJwt, unsafeAsHandle(fullHandle))
206 await refreshSession()
207 toast.success($_('settings.messages.handleUpdated'))
208 newHandle = ''
209 } catch (e) {
210 toast.error(e instanceof ApiError ? e.message : $_('settings.messages.handleUpdateFailed'))
211 } finally {
212 handleLoading = false
213 }
214 }
215
216 async function handleRequestDelete() {
217 if (!session) return
218 deleteLoading = true
219 try {
220 await api.requestAccountDelete(session.accessJwt)
221 deleteTokenSent = true
222 toast.success($_('settings.messages.deletionConfirmationSent'))
223 } catch (e) {
224 toast.error(e instanceof ApiError ? e.message : $_('settings.messages.deletionRequestFailed'))
225 } finally {
226 deleteLoading = false
227 }
228 }
229
230 async function handleConfirmDelete(e: Event) {
231 e.preventDefault()
232 if (!session || !deletePassword || !deleteToken) return
233 if (!confirm($_('settings.messages.deleteConfirmation'))) {
234 return
235 }
236 deleteLoading = true
237 try {
238 await api.deleteAccount(session.did, deletePassword, deleteToken)
239 await logout()
240 navigate(routes.login)
241 } catch (e) {
242 toast.error(e instanceof ApiError ? e.message : $_('settings.messages.deletionFailed'))
243 } finally {
244 deleteLoading = false
245 }
246 }
247
248 async function handleExportRepo() {
249 if (!session) return
250 exportLoading = true
251 try {
252 const response = await fetch(`/xrpc/com.atproto.sync.getRepo?did=${encodeURIComponent(session.did)}`, {
253 headers: {
254 'Authorization': `Bearer ${session.accessJwt}`
255 }
256 })
257 if (!response.ok) {
258 const err = await response.json().catch(() => ({ message: 'Export failed' }))
259 throw new Error(err.message || 'Export failed')
260 }
261 const blob = await response.blob()
262 const url = URL.createObjectURL(blob)
263 const a = document.createElement('a')
264 a.href = url
265 a.download = `${session.handle}-repo.car`
266 document.body.appendChild(a)
267 a.click()
268 document.body.removeChild(a)
269 URL.revokeObjectURL(url)
270 toast.success($_('settings.messages.repoExported'))
271 } catch (e) {
272 toast.error(e instanceof Error ? e.message : $_('settings.messages.exportFailed'))
273 } finally {
274 exportLoading = false
275 }
276 }
277
278 async function handleExportBlobs() {
279 if (!session) return
280 exportBlobsLoading = true
281 try {
282 const response = await fetch('/xrpc/_backup.exportBlobs', {
283 headers: {
284 'Authorization': `Bearer ${session.accessJwt}`
285 }
286 })
287 if (!response.ok) {
288 const err = await response.json().catch(() => ({ message: 'Export failed' }))
289 throw new Error(err.message || 'Export failed')
290 }
291 const blob = await response.blob()
292 if (blob.size === 0) {
293 toast.success($_('settings.messages.noBlobsToExport'))
294 return
295 }
296 const url = URL.createObjectURL(blob)
297 const a = document.createElement('a')
298 a.href = url
299 a.download = `${session.handle}-blobs.zip`
300 document.body.appendChild(a)
301 a.click()
302 document.body.removeChild(a)
303 URL.revokeObjectURL(url)
304 toast.success($_('settings.messages.blobsExported'))
305 } catch (e) {
306 toast.error(e instanceof Error ? e.message : $_('settings.messages.exportFailed'))
307 } finally {
308 exportBlobsLoading = false
309 }
310 }
311
312 interface BackupInfo {
313 id: string
314 repoRev: string
315 repoRootCid: string
316 blockCount: number
317 sizeBytes: number
318 createdAt: string
319 }
320 let backups = $state<BackupInfo[]>([])
321 let backupEnabled = $state(true)
322 let backupsLoading = $state(false)
323 let createBackupLoading = $state(false)
324 let restoreFile = $state<File | null>(null)
325 let restoreLoading = $state(false)
326
327 async function loadBackups() {
328 if (!session) return
329 backupsLoading = true
330 try {
331 const result = await api.listBackups(session.accessJwt)
332 backups = result.backups
333 backupEnabled = result.backupEnabled
334 } catch (e) {
335 console.error('Failed to load backups:', e)
336 } finally {
337 backupsLoading = false
338 }
339 }
340
341 onMount(() => {
342 loadBackups()
343 })
344
345 async function handleToggleBackup() {
346 if (!session) return
347 const newEnabled = !backupEnabled
348 backupsLoading = true
349 try {
350 await api.setBackupEnabled(session.accessJwt, newEnabled)
351 backupEnabled = newEnabled
352 toast.success(newEnabled ? $_('settings.backups.enabled') : $_('settings.backups.disabled'))
353 } catch (e) {
354 toast.error(e instanceof ApiError ? e.message : $_('settings.backups.toggleFailed'))
355 } finally {
356 backupsLoading = false
357 }
358 }
359
360 async function handleCreateBackup() {
361 if (!session) return
362 createBackupLoading = true
363 try {
364 await api.createBackup(session.accessJwt)
365 await loadBackups()
366 toast.success($_('settings.backups.created'))
367 } catch (e) {
368 toast.error(e instanceof ApiError ? e.message : $_('settings.backups.createFailed'))
369 } finally {
370 createBackupLoading = false
371 }
372 }
373
374 async function handleDownloadBackup(id: string, rev: string) {
375 if (!session) return
376 try {
377 const blob = await api.getBackup(session.accessJwt, id)
378 const url = URL.createObjectURL(blob)
379 const a = document.createElement('a')
380 a.href = url
381 a.download = `${session.handle}-${rev}.car`
382 document.body.appendChild(a)
383 a.click()
384 document.body.removeChild(a)
385 URL.revokeObjectURL(url)
386 } catch (e) {
387 toast.error(e instanceof ApiError ? e.message : $_('settings.backups.downloadFailed'))
388 }
389 }
390
391 async function handleDeleteBackup(id: string) {
392 if (!session) return
393 try {
394 await api.deleteBackup(session.accessJwt, id)
395 await loadBackups()
396 toast.success($_('settings.backups.deleted'))
397 } catch (e) {
398 toast.error(e instanceof ApiError ? e.message : $_('settings.backups.deleteFailed'))
399 }
400 }
401
402 function handleFileSelect(e: Event) {
403 const input = e.target as HTMLInputElement
404 if (input.files && input.files.length > 0) {
405 restoreFile = input.files[0]
406 }
407 }
408
409 async function handleRestore() {
410 if (!session || !restoreFile) return
411 restoreLoading = true
412 try {
413 const buffer = await restoreFile.arrayBuffer()
414 const car = new Uint8Array(buffer)
415 await api.importRepo(session.accessJwt, car)
416 toast.success($_('settings.backups.restored'))
417 restoreFile = null
418 } catch (e) {
419 toast.error(e instanceof ApiError ? e.message : $_('settings.backups.restoreFailed'))
420 } finally {
421 restoreLoading = false
422 }
423 }
424
425 function formatBytes(bytes: number): string {
426 if (bytes < 1024) return `${bytes} B`
427 if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
428 return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
429 }
430
431 function formatDate(iso: string): string {
432 return new Date(iso).toLocaleDateString(undefined, {
433 year: 'numeric',
434 month: 'short',
435 day: 'numeric',
436 hour: '2-digit',
437 minute: '2-digit'
438 })
439 }
440
441 async function handleChangePassword(e: Event) {
442 e.preventDefault()
443 if (!session || !currentPassword || !newPassword || !confirmNewPassword) return
444 if (newPassword !== confirmNewPassword) {
445 toast.error($_('settings.messages.passwordsDoNotMatch'))
446 return
447 }
448 if (newPassword.length < 8) {
449 toast.error($_('settings.messages.passwordTooShort'))
450 return
451 }
452 passwordLoading = true
453 try {
454 await api.changePassword(session.accessJwt, currentPassword, newPassword)
455 toast.success($_('settings.messages.passwordChanged'))
456 currentPassword = ''
457 newPassword = ''
458 confirmNewPassword = ''
459 } catch (e) {
460 toast.error(e instanceof ApiError ? e.message : $_('settings.messages.passwordChangeFailed'))
461 } finally {
462 passwordLoading = false
463 }
464 }
465
466 async function handleSetPassword(e: Event) {
467 e.preventDefault()
468 if (!session || !newPassword || !confirmNewPassword) return
469 if (newPassword !== confirmNewPassword) {
470 toast.error($_('settings.messages.passwordsDoNotMatch'))
471 return
472 }
473 if (newPassword.length < 8) {
474 toast.error($_('settings.messages.passwordTooShort'))
475 return
476 }
477 setPasswordLoading = true
478 try {
479 await api.setPassword(session.accessJwt, newPassword)
480 toast.success($_('settings.messages.passwordSet'))
481 hasPassword = true
482 newPassword = ''
483 confirmNewPassword = ''
484 } catch (e) {
485 if (e instanceof ApiError) {
486 if (e.error === 'ReauthRequired') {
487 reauthMethods = e.reauthMethods || ['passkey']
488 pendingAction = () => handleSetPassword(new Event('submit'))
489 showReauthModal = true
490 } else {
491 toast.error(e.message)
492 }
493 } else {
494 toast.error($_('settings.messages.passwordSetFailed'))
495 }
496 } finally {
497 setPasswordLoading = false
498 }
499 }
500
501 function handleReauthSuccess() {
502 if (pendingAction) {
503 pendingAction()
504 pendingAction = null
505 }
506 }
507
508 function handleReauthCancel() {
509 pendingAction = null
510 }
511</script>
512<div class="page">
513 <header>
514 <a href={getFullUrl(routes.dashboard)} class="back">{$_('common.backToDashboard')}</a>
515 <h1>{$_('settings.title')}</h1>
516 </header>
517 <div class="sections-grid">
518 <section>
519 <h2>{$_('settings.language')}</h2>
520 <p class="description">{$_('settings.languageDescription')}</p>
521 <select
522 class="language-select"
523 value={$locale}
524 disabled={localeLoading}
525 onchange={(e) => handleLocaleChange(e.currentTarget.value as SupportedLocale)}
526 >
527 {#each supportedLocales as loc}
528 <option value={loc}>{localeNames[loc]}</option>
529 {/each}
530 </select>
531 </section>
532 <section>
533 <h2>{$_('settings.changeEmail')}</h2>
534 {#if session?.email}
535 <p class="current">{$_('settings.currentEmail', { values: { email: session.email } })}</p>
536 {/if}
537 {#if emailTokenRequired}
538 <form onsubmit={handleConfirmEmailUpdate}>
539 {#if emailUpdateAuthorized}
540 <p class="hint success">{$_('settings.emailUpdateAuthorized')}</p>
541 {:else}
542 <div class="field">
543 <label for="email-token">{$_('settings.verificationCode')}</label>
544 <input
545 id="email-token"
546 type="text"
547 bind:value={emailToken}
548 placeholder={$_('settings.verificationCodePlaceholder')}
549 disabled={emailLoading}
550 />
551 <p class="hint">{$_('settings.emailTokenHint')}</p>
552 </div>
553 {/if}
554 <div class="field">
555 <label for="new-email">{$_('settings.newEmail')}</label>
556 <input
557 id="new-email"
558 type="email"
559 bind:value={newEmail}
560 onblur={checkNewEmailInUse}
561 placeholder={$_('settings.newEmailPlaceholder')}
562 disabled={emailLoading || emailUpdateAuthorized}
563 required
564 />
565 {#if newEmailInUse}
566 <p class="hint warning">{$_('settings.emailInUseWarning')}</p>
567 {/if}
568 </div>
569 <div class="actions">
570 <button type="submit" disabled={emailLoading || (!emailToken && !emailUpdateAuthorized) || !newEmail}>
571 {emailLoading ? $_('settings.updating') : $_('settings.confirmEmailChange')}
572 </button>
573 <button type="button" class="secondary" onclick={() => { emailTokenRequired = false; emailToken = ''; newEmail = ''; emailUpdateAuthorized = false; stopEmailPolling() }}>
574 {$_('common.cancel')}
575 </button>
576 </div>
577 </form>
578 {:else}
579 <form onsubmit={(e) => { e.preventDefault(); handleRequestEmailUpdate() }}>
580 <div class="field">
581 <label for="new-email">{$_('settings.newEmail')}</label>
582 <input
583 id="new-email"
584 type="email"
585 bind:value={newEmail}
586 onblur={checkNewEmailInUse}
587 placeholder={$_('settings.newEmailPlaceholder')}
588 disabled={emailLoading}
589 required
590 />
591 {#if newEmailInUse}
592 <p class="hint warning">{$_('settings.emailInUseWarning')}</p>
593 {/if}
594 </div>
595 <button type="submit" disabled={emailLoading || !newEmail.trim()}>
596 {emailLoading ? $_('settings.requesting') : $_('settings.changeEmailButton')}
597 </button>
598 </form>
599 {/if}
600 </section>
601 <section>
602 <h2>{$_('settings.changeHandle')}</h2>
603 {#if session}
604 <p class="current">{$_('settings.currentHandle', { values: { handle: session.handle } })}</p>
605 {/if}
606 <div class="tabs">
607 <button
608 type="button"
609 class="tab"
610 class:active={!showBYOHandle}
611 onclick={() => showBYOHandle = false}
612 >
613 {$_('settings.pdsHandle')}
614 </button>
615 <button
616 type="button"
617 class="tab"
618 class:active={showBYOHandle}
619 onclick={() => showBYOHandle = true}
620 >
621 {$_('settings.customDomain')}
622 </button>
623 </div>
624 {#if showBYOHandle}
625 <div class="byo-handle">
626 <p class="description">{$_('settings.customDomainDescription')}</p>
627 {#if session}
628 <div class="verification-info">
629 <h3>{$_('settings.setupInstructions')}</h3>
630 <p>{$_('settings.setupMethodsIntro')}</p>
631 <div class="method">
632 <h4>{$_('settings.dnsMethod')}</h4>
633 <p>{$_('settings.dnsMethodDesc')}</p>
634 <code class="record">_atproto.{newHandle || 'yourdomain.com'} TXT "did={session.did}"</code>
635 </div>
636 <div class="method">
637 <h4>{$_('settings.httpMethod')}</h4>
638 <p>{$_('settings.httpMethodDesc')}</p>
639 <code class="record">https://{newHandle || 'yourdomain.com'}/.well-known/atproto-did</code>
640 <p>{$_('settings.httpMethodContent')}</p>
641 <code class="record">{session.did}</code>
642 </div>
643 </div>
644 {/if}
645 <form onsubmit={handleUpdateHandle}>
646 <div class="field">
647 <label for="new-handle-byo">{$_('settings.yourDomain')}</label>
648 <input
649 id="new-handle-byo"
650 type="text"
651 bind:value={newHandle}
652 placeholder={$_('settings.yourDomainPlaceholder')}
653 disabled={handleLoading}
654 required
655 />
656 </div>
657 <button type="submit" disabled={handleLoading || !newHandle}>
658 {handleLoading ? $_('common.verifying') : $_('settings.verifyAndUpdate')}
659 </button>
660 </form>
661 </div>
662 {:else}
663 <form onsubmit={handleUpdateHandle}>
664 <div class="field">
665 <label for="new-handle">{$_('settings.newHandle')}</label>
666 <div class="handle-input-wrapper">
667 <input
668 id="new-handle"
669 type="text"
670 bind:value={newHandle}
671 placeholder={$_('settings.newHandlePlaceholder')}
672 disabled={handleLoading}
673 required
674 />
675 <span class="handle-suffix">.{pdsHostname ?? '...'}</span>
676 </div>
677 </div>
678 <button type="submit" disabled={handleLoading || !newHandle || !pdsHostname}>
679 {handleLoading ? $_('settings.updating') : $_('settings.changeHandleButton')}
680 </button>
681 </form>
682 {/if}
683 </section>
684 {#if !passwordStatusLoading}
685 {#if hasPassword}
686 <section>
687 <h2>{$_('settings.changePassword')}</h2>
688 <form onsubmit={handleChangePassword}>
689 <div class="field">
690 <label for="current-password">{$_('settings.currentPassword')}</label>
691 <input
692 id="current-password"
693 type="password"
694 bind:value={currentPassword}
695 placeholder={$_('settings.currentPasswordPlaceholder')}
696 disabled={passwordLoading}
697 required
698 />
699 </div>
700 <div class="field">
701 <label for="new-password">{$_('settings.newPassword')}</label>
702 <input
703 id="new-password"
704 type="password"
705 bind:value={newPassword}
706 placeholder={$_('settings.newPasswordPlaceholder')}
707 disabled={passwordLoading}
708 required
709 minlength="8"
710 />
711 </div>
712 <div class="field">
713 <label for="confirm-new-password">{$_('settings.confirmNewPassword')}</label>
714 <input
715 id="confirm-new-password"
716 type="password"
717 bind:value={confirmNewPassword}
718 placeholder={$_('settings.confirmNewPasswordPlaceholder')}
719 disabled={passwordLoading}
720 required
721 />
722 </div>
723 <button type="submit" disabled={passwordLoading || !currentPassword || !newPassword || !confirmNewPassword}>
724 {passwordLoading ? $_('settings.changing') : $_('settings.changePasswordButton')}
725 </button>
726 </form>
727 </section>
728 {:else}
729 <section>
730 <h2>{$_('settings.setPassword')}</h2>
731 <p class="description">{$_('settings.setPasswordDescription')}</p>
732 <form onsubmit={handleSetPassword}>
733 <div class="field">
734 <label for="set-new-password">{$_('settings.newPassword')}</label>
735 <input
736 id="set-new-password"
737 type="password"
738 bind:value={newPassword}
739 placeholder={$_('settings.newPasswordPlaceholder')}
740 disabled={setPasswordLoading}
741 required
742 minlength="8"
743 />
744 </div>
745 <div class="field">
746 <label for="set-confirm-password">{$_('settings.confirmNewPassword')}</label>
747 <input
748 id="set-confirm-password"
749 type="password"
750 bind:value={confirmNewPassword}
751 placeholder={$_('settings.confirmNewPasswordPlaceholder')}
752 disabled={setPasswordLoading}
753 required
754 />
755 </div>
756 <button type="submit" disabled={setPasswordLoading || !newPassword || !confirmNewPassword}>
757 {setPasswordLoading ? $_('settings.setting') : $_('settings.setPasswordButton')}
758 </button>
759 </form>
760 </section>
761 {/if}
762 {/if}
763 <section>
764 <h2>{$_('settings.exportData')}</h2>
765 <p class="description">{$_('settings.exportDataDescription')}</p>
766 <div class="export-buttons">
767 <button onclick={handleExportRepo} disabled={exportLoading}>
768 {exportLoading ? $_('settings.exporting') : $_('settings.downloadRepo')}
769 </button>
770 <button onclick={handleExportBlobs} disabled={exportBlobsLoading} class="secondary">
771 {exportBlobsLoading ? $_('settings.exporting') : $_('settings.downloadBlobs')}
772 </button>
773 </div>
774 </section>
775 <section class="backups-section">
776 <h2>{$_('settings.backups.title')}</h2>
777 <p class="description">{$_('settings.backups.description')}</p>
778
779 <label class="checkbox-label">
780 <input type="checkbox" checked={backupEnabled} onchange={handleToggleBackup} disabled={backupsLoading} />
781 <span>{$_('settings.backups.enableAutomatic')}</span>
782 </label>
783
784 {#if !backupsLoading && backups.length > 0}
785 <ul class="backup-list">
786 {#each backups as backup}
787 <li class="backup-item">
788 <div class="backup-info">
789 <span class="backup-date">{formatDate(backup.createdAt)}</span>
790 <span class="backup-size">{formatBytes(backup.sizeBytes)}</span>
791 <span class="backup-blocks">{backup.blockCount} {$_('settings.backups.blocks')}</span>
792 </div>
793 <div class="backup-actions">
794 <button class="small" onclick={() => handleDownloadBackup(backup.id, backup.repoRev)}>
795 {$_('settings.backups.download')}
796 </button>
797 <button class="small danger" onclick={() => handleDeleteBackup(backup.id)}>
798 {$_('settings.backups.delete')}
799 </button>
800 </div>
801 </li>
802 {/each}
803 </ul>
804 {:else}
805 <p class="empty">{$_('settings.backups.noBackups')}</p>
806 {/if}
807
808 <button onclick={handleCreateBackup} disabled={createBackupLoading || !backupEnabled}>
809 {createBackupLoading ? $_('common.creating') : $_('settings.backups.createNow')}
810 </button>
811 </section>
812 <section class="restore-section">
813 <h2>{$_('settings.backups.restoreTitle')}</h2>
814 <p class="description">{$_('settings.backups.restoreDescription')}</p>
815
816 <div class="field">
817 <label for="restore-file">{$_('settings.backups.selectFile')}</label>
818 <input
819 id="restore-file"
820 type="file"
821 accept=".car"
822 onchange={handleFileSelect}
823 disabled={restoreLoading}
824 />
825 </div>
826
827 {#if restoreFile}
828 <div class="restore-preview">
829 <p>{$_('settings.backups.selectedFile')}: {restoreFile.name} ({formatBytes(restoreFile.size)})</p>
830 <button onclick={handleRestore} disabled={restoreLoading} class="danger">
831 {restoreLoading ? $_('settings.backups.restoring') : $_('settings.backups.restore')}
832 </button>
833 </div>
834 {/if}
835 </section>
836 </div>
837 <section class="danger-zone">
838 <h2>{$_('settings.deleteAccount')}</h2>
839 <p class="warning">{$_('settings.deleteWarning')}</p>
840 {#if deleteTokenSent}
841 <form onsubmit={handleConfirmDelete}>
842 <div class="field">
843 <label for="delete-token">{$_('settings.confirmationCode')}</label>
844 <input
845 id="delete-token"
846 type="text"
847 bind:value={deleteToken}
848 placeholder={$_('settings.confirmationCodePlaceholder')}
849 disabled={deleteLoading}
850 required
851 />
852 </div>
853 <div class="field">
854 <label for="delete-password">{$_('settings.yourPassword')}</label>
855 <input
856 id="delete-password"
857 type="password"
858 bind:value={deletePassword}
859 placeholder={$_('settings.yourPasswordPlaceholder')}
860 disabled={deleteLoading}
861 required
862 />
863 </div>
864 <div class="actions">
865 <button type="submit" class="danger" disabled={deleteLoading || !deleteToken || !deletePassword}>
866 {deleteLoading ? $_('settings.deleting') : $_('settings.permanentlyDelete')}
867 </button>
868 <button type="button" class="secondary" onclick={() => { deleteTokenSent = false; deleteToken = ''; deletePassword = '' }}>
869 {$_('common.cancel')}
870 </button>
871 </div>
872 </form>
873 {:else}
874 <button class="danger" onclick={handleRequestDelete} disabled={deleteLoading}>
875 {deleteLoading ? $_('settings.requesting') : $_('settings.requestDeletion')}
876 </button>
877 {/if}
878 </section>
879</div>
880
881{#if showReauthModal && session}
882 <ReauthModal
883 bind:show={showReauthModal}
884 availableMethods={reauthMethods}
885 onSuccess={handleReauthSuccess}
886 onCancel={handleReauthCancel}
887 />
888{/if}
889<style>
890 .page {
891 max-width: var(--width-lg);
892 margin: 0 auto;
893 padding: var(--space-7);
894 }
895
896 header {
897 margin-bottom: var(--space-7);
898 }
899
900 .sections-grid {
901 display: flex;
902 flex-direction: column;
903 gap: var(--space-6);
904 }
905
906 @media (min-width: 800px) {
907 .sections-grid {
908 columns: 2;
909 column-gap: var(--space-6);
910 display: block;
911 }
912
913 .sections-grid section {
914 break-inside: avoid;
915 margin-bottom: var(--space-6);
916 }
917 }
918
919 .back {
920 color: var(--text-secondary);
921 text-decoration: none;
922 font-size: var(--text-sm);
923 }
924
925 .back:hover {
926 color: var(--accent);
927 }
928
929 h1 {
930 margin: var(--space-2) 0 0 0;
931 }
932
933 section {
934 padding: var(--space-6);
935 background: var(--bg-secondary);
936 border-radius: var(--radius-xl);
937 margin-bottom: var(--space-6);
938 height: fit-content;
939 }
940
941 .danger-zone {
942 margin-top: var(--space-6);
943 }
944
945 section h2 {
946 margin: 0 0 var(--space-2) 0;
947 font-size: var(--text-lg);
948 }
949
950 .current,
951 .description {
952 color: var(--text-secondary);
953 font-size: var(--text-sm);
954 margin-bottom: var(--space-4);
955 }
956
957 .language-select {
958 width: 100%;
959 }
960
961 form > button,
962 form > .actions {
963 margin-top: var(--space-4);
964 }
965
966 .actions {
967 display: flex;
968 gap: var(--space-2);
969 }
970
971 .danger-zone {
972 background: var(--error-bg);
973 border: 1px solid var(--error-border);
974 }
975
976 .danger-zone h2 {
977 color: var(--error-text);
978 }
979
980 .warning {
981 color: var(--error-text);
982 font-size: var(--text-sm);
983 margin-bottom: var(--space-4);
984 }
985
986 .tabs {
987 display: flex;
988 gap: var(--space-1);
989 margin-bottom: var(--space-4);
990 }
991
992 .tab {
993 flex: 1;
994 padding: var(--space-2) var(--space-4);
995 background: transparent;
996 border: 1px solid var(--border-color);
997 cursor: pointer;
998 font-size: var(--text-sm);
999 color: var(--text-secondary);
1000 }
1001
1002 .tab:first-child {
1003 border-radius: var(--radius-md) 0 0 var(--radius-md);
1004 }
1005
1006 .tab:last-child {
1007 border-radius: 0 var(--radius-md) var(--radius-md) 0;
1008 }
1009
1010 .tab.active {
1011 background: var(--accent);
1012 border-color: var(--accent);
1013 color: var(--text-inverse);
1014 }
1015
1016 .tab:hover:not(.active) {
1017 background: var(--bg-card);
1018 }
1019
1020 .byo-handle .description {
1021 margin-bottom: var(--space-4);
1022 }
1023
1024 .verification-info {
1025 background: var(--bg-card);
1026 border: 1px solid var(--border-color);
1027 border-radius: var(--radius-lg);
1028 padding: var(--space-4);
1029 margin-bottom: var(--space-4);
1030 }
1031
1032 .verification-info h3 {
1033 margin: 0 0 var(--space-2) 0;
1034 font-size: var(--text-base);
1035 }
1036
1037 .verification-info h4 {
1038 margin: var(--space-3) 0 var(--space-1) 0;
1039 font-size: var(--text-sm);
1040 color: var(--text-secondary);
1041 }
1042
1043 .verification-info p {
1044 margin: var(--space-1) 0;
1045 font-size: var(--text-xs);
1046 color: var(--text-secondary);
1047 }
1048
1049 .method {
1050 margin-top: var(--space-3);
1051 padding-top: var(--space-3);
1052 border-top: 1px solid var(--border-color);
1053 }
1054
1055 .method:first-of-type {
1056 margin-top: var(--space-2);
1057 padding-top: 0;
1058 border-top: none;
1059 }
1060
1061 code.record {
1062 display: block;
1063 background: var(--bg-input);
1064 padding: var(--space-2);
1065 border-radius: var(--radius-md);
1066 font-size: var(--text-xs);
1067 word-break: break-all;
1068 margin: var(--space-1) 0;
1069 }
1070
1071 .handle-input-wrapper {
1072 display: flex;
1073 align-items: center;
1074 background: var(--bg-input);
1075 border: 1px solid var(--border-color);
1076 border-radius: var(--radius-md);
1077 overflow: hidden;
1078 }
1079
1080 .handle-input-wrapper input {
1081 flex: 1;
1082 border: none;
1083 border-radius: 0;
1084 background: transparent;
1085 min-width: 0;
1086 }
1087
1088 .handle-input-wrapper input:focus {
1089 outline: none;
1090 box-shadow: none;
1091 }
1092
1093 .handle-input-wrapper:focus-within {
1094 border-color: var(--accent);
1095 box-shadow: 0 0 0 2px var(--accent-muted);
1096 }
1097
1098 .handle-suffix {
1099 padding: 0 var(--space-3);
1100 color: var(--text-secondary);
1101 font-size: var(--text-sm);
1102 white-space: nowrap;
1103 border-left: 1px solid var(--border-color);
1104 background: var(--bg-card);
1105 }
1106
1107 .checkbox-label {
1108 display: flex;
1109 align-items: center;
1110 gap: var(--space-2);
1111 cursor: pointer;
1112 margin-bottom: var(--space-4);
1113 }
1114
1115 .checkbox-label input[type="checkbox"] {
1116 width: 18px;
1117 height: 18px;
1118 cursor: pointer;
1119 }
1120
1121 .backup-list {
1122 list-style: none;
1123 padding: 0;
1124 margin: 0 0 var(--space-4) 0;
1125 display: flex;
1126 flex-direction: column;
1127 gap: var(--space-2);
1128 }
1129
1130 .backup-item {
1131 display: flex;
1132 justify-content: space-between;
1133 align-items: center;
1134 padding: var(--space-3);
1135 background: var(--bg-card);
1136 border: 1px solid var(--border-color);
1137 border-radius: var(--radius-md);
1138 gap: var(--space-4);
1139 }
1140
1141 .backup-info {
1142 display: flex;
1143 gap: var(--space-4);
1144 font-size: var(--text-sm);
1145 flex-wrap: wrap;
1146 }
1147
1148 .backup-date {
1149 font-weight: 500;
1150 }
1151
1152 .backup-size,
1153 .backup-blocks {
1154 color: var(--text-secondary);
1155 }
1156
1157 .backup-actions {
1158 display: flex;
1159 gap: var(--space-2);
1160 flex-shrink: 0;
1161 }
1162
1163 button.small {
1164 padding: var(--space-1) var(--space-2);
1165 font-size: var(--text-xs);
1166 }
1167
1168 .empty {
1169 color: var(--text-secondary);
1170 font-size: var(--text-sm);
1171 margin-bottom: var(--space-4);
1172 }
1173
1174 .restore-preview {
1175 background: var(--bg-card);
1176 border: 1px solid var(--border-color);
1177 border-radius: var(--radius-md);
1178 padding: var(--space-4);
1179 margin-top: var(--space-3);
1180 }
1181
1182 .restore-preview p {
1183 margin: 0 0 var(--space-3) 0;
1184 font-size: var(--text-sm);
1185 }
1186
1187 .export-buttons {
1188 display: flex;
1189 gap: var(--space-2);
1190 flex-wrap: wrap;
1191 }
1192
1193 @media (max-width: 640px) {
1194 .backup-item {
1195 flex-direction: column;
1196 align-items: flex-start;
1197 }
1198
1199 .backup-actions {
1200 width: 100%;
1201 margin-top: var(--space-2);
1202 }
1203
1204 .backup-actions button {
1205 flex: 1;
1206 }
1207 }
1208</style>