this repo has no description
1<script lang="ts">
2 import { onMount } from 'svelte'
3 import { getAuthState } from '../lib/auth.svelte'
4 import { navigate, routes, getFullUrl } from '../lib/router.svelte'
5 import { api, ApiError, type VerificationMethod, type DidDocument } from '../lib/api'
6 import { _ } from '../lib/i18n'
7 import type { Session } from '../lib/types/api'
8 import { toast } from '../lib/toast.svelte'
9
10 const auth = $derived(getAuthState())
11
12 function getSession(): Session | null {
13 return auth.kind === 'authenticated' ? auth.session : null
14 }
15
16 function isLoading(): boolean {
17 return auth.kind === 'loading'
18 }
19
20 const session = $derived(getSession())
21 const authLoading = $derived(isLoading())
22
23 let loading = $state(true)
24 let saving = $state(false)
25 let didDocument = $state<DidDocument | null>(null)
26 let verificationMethods = $state<VerificationMethod[]>([])
27 let alsoKnownAs = $state<string[]>([])
28 let serviceEndpoint = $state('')
29 let newKeyId = $state('#atproto')
30 let newKeyPublic = $state('')
31 let newHandle = $state('')
32
33 $effect(() => {
34 if (!authLoading && !session) {
35 navigate(routes.login)
36 }
37 })
38
39 onMount(async () => {
40 if (!session) return
41 try {
42 didDocument = await api.getDidDocument(session.accessJwt)
43 verificationMethods = didDocument.verificationMethod.map(vm => ({
44 id: vm.id.replace(didDocument!.id, ''),
45 type: vm.type,
46 publicKeyMultibase: vm.publicKeyMultibase
47 }))
48 alsoKnownAs = [...didDocument.alsoKnownAs]
49 const pdsService = didDocument.service.find(s => s.id === '#atproto_pds')
50 serviceEndpoint = pdsService?.serviceEndpoint || ''
51 } catch (e) {
52 toast.error(e instanceof ApiError ? e.message : $_('didEditor.loadFailed'))
53 } finally {
54 loading = false
55 }
56 })
57
58 function addVerificationMethod() {
59 if (!newKeyId || !newKeyPublic) return
60 if (!newKeyPublic.startsWith('z')) {
61 toast.error($_('didEditor.invalidMultibase'))
62 return
63 }
64 verificationMethods = [...verificationMethods, {
65 id: newKeyId.startsWith('#') ? newKeyId : `#${newKeyId}`,
66 type: 'Multikey',
67 publicKeyMultibase: newKeyPublic
68 }]
69 newKeyId = '#atproto'
70 newKeyPublic = ''
71 }
72
73 function removeVerificationMethod(index: number) {
74 verificationMethods = verificationMethods.filter((_, i) => i !== index)
75 }
76
77 function addHandle() {
78 if (!newHandle) return
79 if (!newHandle.startsWith('at://')) {
80 toast.error($_('didEditor.invalidHandle'))
81 return
82 }
83 alsoKnownAs = [...alsoKnownAs, newHandle]
84 newHandle = ''
85 }
86
87 function removeHandle(index: number) {
88 alsoKnownAs = alsoKnownAs.filter((_, i) => i !== index)
89 }
90
91 async function handleSave() {
92 if (!session) return
93 saving = true
94 try {
95 await api.updateDidDocument(session.accessJwt, {
96 verificationMethods: verificationMethods.length > 0 ? verificationMethods : undefined,
97 alsoKnownAs: alsoKnownAs.length > 0 ? alsoKnownAs : undefined,
98 serviceEndpoint: serviceEndpoint || undefined
99 })
100 toast.success($_('didEditor.success'))
101 didDocument = await api.getDidDocument(session.accessJwt)
102 } catch (e) {
103 toast.error(e instanceof ApiError ? e.message : $_('didEditor.saveFailed'))
104 } finally {
105 saving = false
106 }
107 }
108</script>
109
110<div class="page">
111 <header>
112 <a href="/app/dashboard" class="back">{$_('common.backToDashboard')}</a>
113 <h1>{$_('didEditor.title')}</h1>
114 </header>
115
116 {#if loading}
117 <div class="skeleton-sections">
118 <div class="skeleton-section small"></div>
119 <div class="skeleton-section large"></div>
120 <div class="skeleton-section"></div>
121 <div class="skeleton-section"></div>
122 </div>
123 {:else}
124 <div class="help-section">
125 <h3>{$_('didEditor.helpTitle')}</h3>
126 <p>{$_('didEditor.helpText')}</p>
127 </div>
128
129 <section>
130 <h2>{$_('didEditor.preview')}</h2>
131 <pre class="did-preview">{JSON.stringify(didDocument, null, 2)}</pre>
132 </section>
133
134 <section>
135 <h2>{$_('didEditor.verificationMethods')}</h2>
136 <p class="description">{$_('didEditor.verificationMethodsDesc')}</p>
137
138 {#if verificationMethods.length > 0}
139 <ul class="key-list">
140 {#each verificationMethods as method, index}
141 <li class="key-item">
142 <div class="key-info">
143 <span class="key-id">{method.id}</span>
144 <span class="key-type">{method.type}</span>
145 <code class="key-value">{method.publicKeyMultibase}</code>
146 </div>
147 <button type="button" class="danger-link" onclick={() => removeVerificationMethod(index)}>
148 {$_('didEditor.removeKey')}
149 </button>
150 </li>
151 {/each}
152 </ul>
153 {:else}
154 <p class="empty-state">{$_('didEditor.noKeys')}</p>
155 {/if}
156
157 <div class="add-form">
158 <h4>{$_('didEditor.addKey')}</h4>
159 <div class="field-row">
160 <div class="field small">
161 <label for="key-id">{$_('didEditor.keyId')}</label>
162 <input
163 id="key-id"
164 type="text"
165 bind:value={newKeyId}
166 placeholder={$_('didEditor.keyIdPlaceholder')}
167 />
168 </div>
169 <div class="field large">
170 <label for="key-public">{$_('didEditor.publicKey')}</label>
171 <input
172 id="key-public"
173 type="text"
174 bind:value={newKeyPublic}
175 placeholder={$_('didEditor.publicKeyPlaceholder')}
176 />
177 </div>
178 <button type="button" class="add-btn" onclick={addVerificationMethod} disabled={!newKeyId || !newKeyPublic}>
179 {$_('didEditor.addKey')}
180 </button>
181 </div>
182 </div>
183 </section>
184
185 <section>
186 <h2>{$_('didEditor.alsoKnownAs')}</h2>
187 <p class="description">{$_('didEditor.alsoKnownAsDesc')}</p>
188
189 {#if alsoKnownAs.length > 0}
190 <ul class="handle-list">
191 {#each alsoKnownAs as handle, index}
192 <li class="handle-item">
193 <span>{handle}</span>
194 <button type="button" class="danger-link" onclick={() => removeHandle(index)}>
195 {$_('didEditor.removeHandle')}
196 </button>
197 </li>
198 {/each}
199 </ul>
200 {:else}
201 <p class="empty-state">{$_('didEditor.noHandles')}</p>
202 {/if}
203
204 <div class="add-form">
205 <div class="field-row">
206 <div class="field large">
207 <label for="new-handle">{$_('didEditor.handle')}</label>
208 <input
209 id="new-handle"
210 type="text"
211 bind:value={newHandle}
212 placeholder={$_('didEditor.handlePlaceholder')}
213 />
214 </div>
215 <button type="button" class="add-btn" onclick={addHandle} disabled={!newHandle}>
216 {$_('didEditor.addHandle')}
217 </button>
218 </div>
219 </div>
220 </section>
221
222 <section>
223 <h2>{$_('didEditor.serviceEndpoint')}</h2>
224 <p class="description">{$_('didEditor.serviceEndpointDesc')}</p>
225 <div class="field">
226 <label for="service-endpoint">{$_('didEditor.currentPds')}</label>
227 <input
228 id="service-endpoint"
229 type="url"
230 bind:value={serviceEndpoint}
231 placeholder="https://pds.example.com"
232 />
233 </div>
234 </section>
235
236 <div class="actions">
237 <button onclick={handleSave} disabled={saving}>
238 {saving ? $_('common.saving') : $_('common.save')}
239 </button>
240 </div>
241 {/if}
242</div>
243
244<style>
245 .page {
246 max-width: var(--width-lg);
247 margin: 0 auto;
248 padding: var(--space-7);
249 }
250
251 header {
252 margin-bottom: var(--space-7);
253 }
254
255 .back {
256 color: var(--text-secondary);
257 text-decoration: none;
258 font-size: var(--text-sm);
259 }
260
261 .back:hover {
262 color: var(--accent);
263 }
264
265 h1 {
266 margin: var(--space-2) 0 0 0;
267 }
268
269 .help-section {
270 background: var(--info-bg, #e0f2fe);
271 border: 1px solid var(--info-border, #7dd3fc);
272 border-radius: var(--radius-xl);
273 padding: var(--space-5) var(--space-6);
274 margin-bottom: var(--space-6);
275 }
276
277 .help-section h3 {
278 margin: 0 0 var(--space-2) 0;
279 color: var(--info-text, #0369a1);
280 font-size: var(--text-base);
281 }
282
283 .help-section p {
284 margin: 0;
285 color: var(--info-text, #0369a1);
286 font-size: var(--text-sm);
287 }
288
289 section {
290 padding: var(--space-6);
291 background: var(--bg-secondary);
292 border-radius: var(--radius-xl);
293 margin-bottom: var(--space-6);
294 }
295
296 section h2 {
297 margin: 0 0 var(--space-2) 0;
298 font-size: var(--text-lg);
299 }
300
301 .description {
302 color: var(--text-secondary);
303 font-size: var(--text-sm);
304 margin-bottom: var(--space-4);
305 }
306
307 .did-preview {
308 background: var(--bg-input);
309 padding: var(--space-4);
310 border-radius: var(--radius-md);
311 font-size: var(--text-xs);
312 overflow-x: auto;
313 white-space: pre-wrap;
314 word-break: break-all;
315 max-height: 300px;
316 overflow-y: auto;
317 }
318
319 .key-list, .handle-list {
320 list-style: none;
321 padding: 0;
322 margin: 0 0 var(--space-4) 0;
323 }
324
325 .key-item, .handle-item {
326 display: flex;
327 justify-content: space-between;
328 align-items: flex-start;
329 padding: var(--space-3) var(--space-4);
330 background: var(--bg-card);
331 border: 1px solid var(--border-color);
332 border-radius: var(--radius-md);
333 margin-bottom: var(--space-2);
334 gap: var(--space-4);
335 }
336
337 .key-info {
338 display: flex;
339 flex-direction: column;
340 gap: var(--space-1);
341 flex: 1;
342 min-width: 0;
343 }
344
345 .key-id {
346 font-weight: var(--font-medium);
347 font-size: var(--text-sm);
348 }
349
350 .key-type {
351 color: var(--text-secondary);
352 font-size: var(--text-xs);
353 }
354
355 .key-value {
356 font-size: var(--text-xs);
357 background: var(--bg-input);
358 padding: var(--space-1) var(--space-2);
359 border-radius: var(--radius-sm);
360 word-break: break-all;
361 }
362
363 .handle-item span {
364 font-family: ui-monospace, monospace;
365 font-size: var(--text-sm);
366 }
367
368 .danger-link {
369 background: none;
370 border: none;
371 color: var(--error-text);
372 cursor: pointer;
373 font-size: var(--text-xs);
374 padding: var(--space-1) var(--space-2);
375 white-space: nowrap;
376 }
377
378 .danger-link:hover {
379 text-decoration: underline;
380 }
381
382 .empty-state {
383 color: var(--text-muted);
384 font-size: var(--text-sm);
385 font-style: italic;
386 padding: var(--space-4);
387 text-align: center;
388 background: var(--bg-card);
389 border-radius: var(--radius-md);
390 margin-bottom: var(--space-4);
391 }
392
393 .add-form {
394 background: var(--bg-card);
395 border: 1px solid var(--border-color);
396 border-radius: var(--radius-lg);
397 padding: var(--space-4);
398 }
399
400 .add-form h4 {
401 margin: 0 0 var(--space-3) 0;
402 font-size: var(--text-sm);
403 color: var(--text-secondary);
404 }
405
406 .field-row {
407 display: flex;
408 gap: var(--space-3);
409 align-items: flex-end;
410 }
411
412 .field {
413 display: flex;
414 flex-direction: column;
415 gap: var(--space-1);
416 }
417
418 .field.small {
419 flex: 0 0 120px;
420 }
421
422 .field.large {
423 flex: 1;
424 }
425
426 .field label {
427 font-size: var(--text-xs);
428 color: var(--text-secondary);
429 }
430
431 .add-btn {
432 white-space: nowrap;
433 }
434
435 .actions {
436 display: flex;
437 gap: var(--space-3);
438 justify-content: flex-end;
439 margin-top: var(--space-6);
440 }
441
442 @media (max-width: 600px) {
443 .field-row {
444 flex-direction: column;
445 align-items: stretch;
446 }
447
448 .field.small, .field.large {
449 flex: none;
450 }
451
452 .add-btn {
453 width: 100%;
454 }
455 }
456
457 .skeleton-sections {
458 display: flex;
459 flex-direction: column;
460 gap: var(--space-6);
461 }
462
463 .skeleton-section {
464 height: 180px;
465 background: var(--bg-secondary);
466 border-radius: var(--radius-xl);
467 animation: skeleton-pulse 1.5s ease-in-out infinite;
468 }
469
470 .skeleton-section.small {
471 height: 80px;
472 }
473
474 .skeleton-section.large {
475 height: 250px;
476 }
477
478 @keyframes skeleton-pulse {
479 0%, 100% { opacity: 1; }
480 50% { opacity: 0.5; }
481 }
482</style>