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