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