this repo has no description
1<script lang="ts">
2 import { getAuthState, logout } from '../lib/auth.svelte'
3 import { navigate } from '../lib/router.svelte'
4 import { api, ApiError } from '../lib/api'
5 const auth = getAuthState()
6 let message = $state<{ type: 'success' | 'error'; text: string } | null>(null)
7 let emailLoading = $state(false)
8 let newEmail = $state('')
9 let emailToken = $state('')
10 let emailTokenRequired = $state(false)
11 let handleLoading = $state(false)
12 let newHandle = $state('')
13 let deleteLoading = $state(false)
14 let deletePassword = $state('')
15 let deleteToken = $state('')
16 let deleteTokenSent = $state(false)
17 let exportLoading = $state(false)
18 let passwordLoading = $state(false)
19 let currentPassword = $state('')
20 let newPassword = $state('')
21 let confirmNewPassword = $state('')
22 $effect(() => {
23 if (!auth.loading && !auth.session) {
24 navigate('/login')
25 }
26 })
27 function showMessage(type: 'success' | 'error', text: string) {
28 message = { type, text }
29 setTimeout(() => {
30 if (message?.text === text) message = null
31 }, 5000)
32 }
33 async function handleRequestEmailUpdate(e: Event) {
34 e.preventDefault()
35 if (!auth.session || !newEmail) return
36 emailLoading = true
37 message = null
38 try {
39 const result = await api.requestEmailUpdate(auth.session.accessJwt)
40 emailTokenRequired = result.tokenRequired
41 if (emailTokenRequired) {
42 showMessage('success', 'Verification code sent to your current email')
43 } else {
44 await api.updateEmail(auth.session.accessJwt, newEmail)
45 showMessage('success', 'Email updated successfully')
46 newEmail = ''
47 }
48 } catch (e) {
49 showMessage('error', e instanceof ApiError ? e.message : 'Failed to update email')
50 } finally {
51 emailLoading = false
52 }
53 }
54 async function handleConfirmEmailUpdate(e: Event) {
55 e.preventDefault()
56 if (!auth.session || !newEmail || !emailToken) return
57 emailLoading = true
58 message = null
59 try {
60 await api.updateEmail(auth.session.accessJwt, newEmail, emailToken)
61 showMessage('success', 'Email updated successfully')
62 newEmail = ''
63 emailToken = ''
64 emailTokenRequired = false
65 } catch (e) {
66 showMessage('error', e instanceof ApiError ? e.message : 'Failed to update email')
67 } finally {
68 emailLoading = false
69 }
70 }
71 async function handleUpdateHandle(e: Event) {
72 e.preventDefault()
73 if (!auth.session || !newHandle) return
74 handleLoading = true
75 message = null
76 try {
77 await api.updateHandle(auth.session.accessJwt, newHandle)
78 showMessage('success', 'Handle updated successfully')
79 newHandle = ''
80 } catch (e) {
81 showMessage('error', e instanceof ApiError ? e.message : 'Failed to update handle')
82 } finally {
83 handleLoading = false
84 }
85 }
86 async function handleRequestDelete() {
87 if (!auth.session) return
88 deleteLoading = true
89 message = null
90 try {
91 await api.requestAccountDelete(auth.session.accessJwt)
92 deleteTokenSent = true
93 showMessage('success', 'Deletion confirmation sent to your email')
94 } catch (e) {
95 showMessage('error', e instanceof ApiError ? e.message : 'Failed to request deletion')
96 } finally {
97 deleteLoading = false
98 }
99 }
100 async function handleConfirmDelete(e: Event) {
101 e.preventDefault()
102 if (!auth.session || !deletePassword || !deleteToken) return
103 if (!confirm('Are you absolutely sure you want to delete your account? This cannot be undone.')) {
104 return
105 }
106 deleteLoading = true
107 message = null
108 try {
109 await api.deleteAccount(auth.session.did, deletePassword, deleteToken)
110 await logout()
111 navigate('/login')
112 } catch (e) {
113 showMessage('error', e instanceof ApiError ? e.message : 'Failed to delete account')
114 } finally {
115 deleteLoading = false
116 }
117 }
118 async function handleExportRepo() {
119 if (!auth.session) return
120 exportLoading = true
121 message = null
122 try {
123 const response = await fetch(`/xrpc/com.atproto.sync.getRepo?did=${encodeURIComponent(auth.session.did)}`, {
124 headers: {
125 'Authorization': `Bearer ${auth.session.accessJwt}`
126 }
127 })
128 if (!response.ok) {
129 const err = await response.json().catch(() => ({ message: 'Export failed' }))
130 throw new Error(err.message || 'Export failed')
131 }
132 const blob = await response.blob()
133 const url = URL.createObjectURL(blob)
134 const a = document.createElement('a')
135 a.href = url
136 a.download = `${auth.session.handle}-repo.car`
137 document.body.appendChild(a)
138 a.click()
139 document.body.removeChild(a)
140 URL.revokeObjectURL(url)
141 showMessage('success', 'Repository exported successfully')
142 } catch (e) {
143 showMessage('error', e instanceof Error ? e.message : 'Failed to export repository')
144 } finally {
145 exportLoading = false
146 }
147 }
148 async function handleChangePassword(e: Event) {
149 e.preventDefault()
150 if (!auth.session || !currentPassword || !newPassword || !confirmNewPassword) return
151 if (newPassword !== confirmNewPassword) {
152 showMessage('error', 'Passwords do not match')
153 return
154 }
155 if (newPassword.length < 8) {
156 showMessage('error', 'Password must be at least 8 characters')
157 return
158 }
159 passwordLoading = true
160 message = null
161 try {
162 await api.changePassword(auth.session.accessJwt, currentPassword, newPassword)
163 showMessage('success', 'Password changed successfully')
164 currentPassword = ''
165 newPassword = ''
166 confirmNewPassword = ''
167 } catch (e) {
168 showMessage('error', e instanceof ApiError ? e.message : 'Failed to change password')
169 } finally {
170 passwordLoading = false
171 }
172 }
173</script>
174<div class="page">
175 <header>
176 <a href="#/dashboard" class="back">← Dashboard</a>
177 <h1>Account Settings</h1>
178 </header>
179 {#if message}
180 <div class="message {message.type}">{message.text}</div>
181 {/if}
182 <section>
183 <h2>Change Email</h2>
184 {#if auth.session?.email}
185 <p class="current">Current: {auth.session.email}</p>
186 {/if}
187 {#if emailTokenRequired}
188 <form onsubmit={handleConfirmEmailUpdate}>
189 <div class="field">
190 <label for="email-token">Verification Code</label>
191 <input
192 id="email-token"
193 type="text"
194 bind:value={emailToken}
195 placeholder="Enter code from email"
196 disabled={emailLoading}
197 required
198 />
199 </div>
200 <div class="actions">
201 <button type="submit" disabled={emailLoading || !emailToken}>
202 {emailLoading ? 'Updating...' : 'Confirm Email Change'}
203 </button>
204 <button type="button" class="secondary" onclick={() => { emailTokenRequired = false; emailToken = '' }}>
205 Cancel
206 </button>
207 </div>
208 </form>
209 {:else}
210 <form onsubmit={handleRequestEmailUpdate}>
211 <div class="field">
212 <label for="new-email">New Email</label>
213 <input
214 id="new-email"
215 type="email"
216 bind:value={newEmail}
217 placeholder="new@example.com"
218 disabled={emailLoading}
219 required
220 />
221 </div>
222 <button type="submit" disabled={emailLoading || !newEmail}>
223 {emailLoading ? 'Requesting...' : 'Change Email'}
224 </button>
225 </form>
226 {/if}
227 </section>
228 <section>
229 <h2>Change Handle</h2>
230 {#if auth.session}
231 <p class="current">Current: @{auth.session.handle}</p>
232 {/if}
233 <form onsubmit={handleUpdateHandle}>
234 <div class="field">
235 <label for="new-handle">New Handle</label>
236 <input
237 id="new-handle"
238 type="text"
239 bind:value={newHandle}
240 placeholder="newhandle.bsky.social"
241 disabled={handleLoading}
242 required
243 />
244 </div>
245 <button type="submit" disabled={handleLoading || !newHandle}>
246 {handleLoading ? 'Updating...' : 'Change Handle'}
247 </button>
248 </form>
249 </section>
250 <section>
251 <h2>Change Password</h2>
252 <form onsubmit={handleChangePassword}>
253 <div class="field">
254 <label for="current-password">Current Password</label>
255 <input
256 id="current-password"
257 type="password"
258 bind:value={currentPassword}
259 placeholder="Enter current password"
260 disabled={passwordLoading}
261 required
262 />
263 </div>
264 <div class="field">
265 <label for="new-password">New Password</label>
266 <input
267 id="new-password"
268 type="password"
269 bind:value={newPassword}
270 placeholder="At least 8 characters"
271 disabled={passwordLoading}
272 required
273 minlength="8"
274 />
275 </div>
276 <div class="field">
277 <label for="confirm-new-password">Confirm New Password</label>
278 <input
279 id="confirm-new-password"
280 type="password"
281 bind:value={confirmNewPassword}
282 placeholder="Confirm new password"
283 disabled={passwordLoading}
284 required
285 />
286 </div>
287 <button type="submit" disabled={passwordLoading || !currentPassword || !newPassword || !confirmNewPassword}>
288 {passwordLoading ? 'Changing...' : 'Change Password'}
289 </button>
290 </form>
291 </section>
292 <section>
293 <h2>Export Data</h2>
294 <p class="description">Download your entire repository as a CAR (Content Addressable Archive) file. This includes all your posts, likes, follows, and other data.</p>
295 <button onclick={handleExportRepo} disabled={exportLoading}>
296 {exportLoading ? 'Exporting...' : 'Download Repository'}
297 </button>
298 </section>
299 <section class="danger-zone">
300 <h2>Delete Account</h2>
301 <p class="warning">This action is irreversible. All your data will be permanently deleted.</p>
302 {#if deleteTokenSent}
303 <form onsubmit={handleConfirmDelete}>
304 <div class="field">
305 <label for="delete-token">Confirmation Code (from email)</label>
306 <input
307 id="delete-token"
308 type="text"
309 bind:value={deleteToken}
310 placeholder="Enter confirmation code"
311 disabled={deleteLoading}
312 required
313 />
314 </div>
315 <div class="field">
316 <label for="delete-password">Your Password</label>
317 <input
318 id="delete-password"
319 type="password"
320 bind:value={deletePassword}
321 placeholder="Enter your password"
322 disabled={deleteLoading}
323 required
324 />
325 </div>
326 <div class="actions">
327 <button type="submit" class="danger" disabled={deleteLoading || !deleteToken || !deletePassword}>
328 {deleteLoading ? 'Deleting...' : 'Permanently Delete Account'}
329 </button>
330 <button type="button" class="secondary" onclick={() => { deleteTokenSent = false; deleteToken = ''; deletePassword = '' }}>
331 Cancel
332 </button>
333 </div>
334 </form>
335 {:else}
336 <button class="danger" onclick={handleRequestDelete} disabled={deleteLoading}>
337 {deleteLoading ? 'Requesting...' : 'Request Account Deletion'}
338 </button>
339 {/if}
340 </section>
341</div>
342<style>
343 .page {
344 max-width: 600px;
345 margin: 0 auto;
346 padding: 2rem;
347 }
348 header {
349 margin-bottom: 2rem;
350 }
351 .back {
352 color: var(--text-secondary);
353 text-decoration: none;
354 font-size: 0.875rem;
355 }
356 .back:hover {
357 color: var(--accent);
358 }
359 h1 {
360 margin: 0.5rem 0 0 0;
361 }
362 .message {
363 padding: 0.75rem;
364 border-radius: 4px;
365 margin-bottom: 1rem;
366 }
367 .message.success {
368 background: var(--success-bg);
369 border: 1px solid var(--success-border);
370 color: var(--success-text);
371 }
372 .message.error {
373 background: var(--error-bg);
374 border: 1px solid var(--error-border);
375 color: var(--error-text);
376 }
377 section {
378 padding: 1.5rem;
379 background: var(--bg-secondary);
380 border-radius: 8px;
381 margin-bottom: 1.5rem;
382 }
383 section h2 {
384 margin: 0 0 0.5rem 0;
385 font-size: 1.125rem;
386 }
387 .current, .description {
388 color: var(--text-secondary);
389 font-size: 0.875rem;
390 margin-bottom: 1rem;
391 }
392 .field {
393 margin-bottom: 1rem;
394 }
395 label {
396 display: block;
397 font-size: 0.875rem;
398 font-weight: 500;
399 margin-bottom: 0.25rem;
400 }
401 input {
402 width: 100%;
403 padding: 0.75rem;
404 border: 1px solid var(--border-color-light);
405 border-radius: 4px;
406 font-size: 1rem;
407 box-sizing: border-box;
408 background: var(--bg-input);
409 color: var(--text-primary);
410 }
411 input:focus {
412 outline: none;
413 border-color: var(--accent);
414 }
415 button {
416 padding: 0.75rem 1.5rem;
417 background: var(--accent);
418 color: white;
419 border: none;
420 border-radius: 4px;
421 cursor: pointer;
422 font-size: 1rem;
423 }
424 button:hover:not(:disabled) {
425 background: var(--accent-hover);
426 }
427 button:disabled {
428 opacity: 0.6;
429 cursor: not-allowed;
430 }
431 button.secondary {
432 background: transparent;
433 color: var(--text-secondary);
434 border: 1px solid var(--border-color-light);
435 }
436 button.secondary:hover:not(:disabled) {
437 background: var(--bg-secondary);
438 }
439 button.danger {
440 background: var(--error-text);
441 }
442 button.danger:hover:not(:disabled) {
443 background: #900;
444 }
445 .actions {
446 display: flex;
447 gap: 0.5rem;
448 }
449 .danger-zone {
450 background: var(--error-bg);
451 border: 1px solid var(--error-border);
452 }
453 .danger-zone h2 {
454 color: var(--error-text);
455 }
456 .warning {
457 color: var(--error-text);
458 font-size: 0.875rem;
459 margin-bottom: 1rem;
460 }
461</style>