this repo has no description
1<script lang="ts">
2 import { getAuthState } from '../lib/auth.svelte'
3 import { navigate, routes, getFullUrl } from '../lib/router.svelte'
4 import { api, type AppPassword, ApiError } from '../lib/api'
5 import { _ } from '../lib/i18n'
6 import { formatDate } from '../lib/date'
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 let passwords = $state<AppPassword[]>([])
23 let loading = $state(true)
24 let newPasswordName = $state('')
25 let selectedScope = $state<string | null>(null)
26 let creating = $state(false)
27 let createdPassword = $state<{ name: string; password: string } | null>(null)
28 let passwordCopied = $state(false)
29 let passwordAcknowledged = $state(false)
30 let revoking = $state<string | null>(null)
31
32 const SCOPE_PRESETS = [
33 { id: 'full', label: 'appPasswords.scopeFull', scopes: null },
34 { id: 'readonly', label: 'appPasswords.scopeReadOnly', scopes: 'rpc:app.bsky.*?aud=* rpc:chat.bsky.*?aud=* account:status?action=read' },
35 { id: 'post', label: 'appPasswords.scopePostOnly', scopes: 'repo:app.bsky.feed.post?action=create blob:*/*' },
36 ]
37
38 function getScopeLabel(scopes: string | null | undefined): string {
39 if (!scopes) return $_('appPasswords.scopeFull')
40 const preset = SCOPE_PRESETS.find(p => p.scopes === scopes)
41 if (preset) return $_(preset.label)
42 return $_('appPasswords.scopeCustom')
43 }
44 $effect(() => {
45 if (!authLoading && !session) {
46 navigate(routes.login)
47 }
48 })
49 $effect(() => {
50 if (session) {
51 loadPasswords()
52 }
53 })
54 async function loadPasswords() {
55 if (!session) return
56 loading = true
57 try {
58 const result = await api.listAppPasswords(session.accessJwt)
59 passwords = result.passwords
60 } catch (e) {
61 toast.error(e instanceof ApiError ? e.message : $_('appPasswords.failedToLoad'))
62 } finally {
63 loading = false
64 }
65 }
66 async function handleCreate(e: Event) {
67 e.preventDefault()
68 if (!session || !newPasswordName.trim()) return
69 creating = true
70 try {
71 const scopeValue = selectedScope === null ? undefined : selectedScope
72 const result = await api.createAppPassword(session.accessJwt, newPasswordName.trim(), scopeValue ?? undefined)
73 createdPassword = { name: result.name, password: result.password }
74 newPasswordName = ''
75 selectedScope = null
76 await loadPasswords()
77 } catch (e) {
78 toast.error(e instanceof ApiError ? e.message : $_('appPasswords.failedToCreate'))
79 } finally {
80 creating = false
81 }
82 }
83 async function handleRevoke(name: string) {
84 if (!session) return
85 if (!confirm($_('appPasswords.revokeConfirm', { values: { name } }))) {
86 return
87 }
88 revoking = name
89 try {
90 await api.revokeAppPassword(session.accessJwt, name)
91 await loadPasswords()
92 toast.success($_('appPasswords.passwordRevoked'))
93 } catch (e) {
94 toast.error(e instanceof ApiError ? e.message : $_('appPasswords.failedToRevoke'))
95 } finally {
96 revoking = null
97 }
98 }
99 function copyPassword() {
100 if (createdPassword) {
101 navigator.clipboard.writeText(createdPassword.password)
102 passwordCopied = true
103 }
104 }
105 function dismissCreated() {
106 createdPassword = null
107 passwordCopied = false
108 passwordAcknowledged = false
109 }
110</script>
111<div class="page">
112 <header>
113 <a href={getFullUrl(routes.dashboard)} class="back">{$_('common.backToDashboard')}</a>
114 <h1>{$_('appPasswords.title')}</h1>
115 </header>
116 <p class="description">
117 {$_('appPasswords.description')}
118 </p>
119 {#if createdPassword}
120 <div class="created-password">
121 <div class="warning-box">
122 <strong>{$_('appPasswords.saveWarningTitle')}</strong>
123 <p>{$_('appPasswords.saveWarningMessage')}</p>
124 </div>
125 <div class="password-display">
126 <div class="password-label">{$_('common.name')}: <strong>{createdPassword.name}</strong></div>
127 <code class="password-code">{createdPassword.password}</code>
128 <button type="button" class="copy-btn" onclick={copyPassword}>
129 {passwordCopied ? $_('common.copied') : $_('common.copyToClipboard')}
130 </button>
131 </div>
132 <label class="checkbox-label">
133 <input type="checkbox" bind:checked={passwordAcknowledged} />
134 <span>{$_('appPasswords.acknowledgeLabel')}</span>
135 </label>
136 <button onclick={dismissCreated} disabled={!passwordAcknowledged}>{$_('common.done')}</button>
137 </div>
138 {/if}
139 <section class="create-section">
140 <h2>{$_('appPasswords.createNew')}</h2>
141 <form onsubmit={handleCreate}>
142 <input
143 type="text"
144 bind:value={newPasswordName}
145 placeholder={$_('appPasswords.appNamePlaceholder')}
146 disabled={creating}
147 required
148 />
149 <div class="scope-selector" role="group" aria-label={$_('appPasswords.permissions')}>
150 <span class="scope-label">{$_('appPasswords.permissions')}:</span>
151 <div class="scope-buttons">
152 {#each SCOPE_PRESETS as preset}
153 <button
154 type="button"
155 class="scope-btn"
156 class:selected={selectedScope === preset.scopes}
157 onclick={() => selectedScope = preset.scopes}
158 disabled={creating}
159 >
160 {$_(preset.label)}
161 </button>
162 {/each}
163 </div>
164 </div>
165 <button type="submit" disabled={creating || !newPasswordName.trim()}>
166 {creating ? $_('common.creating') : $_('common.create')}
167 </button>
168 </form>
169 </section>
170 <section class="list-section">
171 <h2>{$_('appPasswords.yourPasswords')}</h2>
172 {#if loading}
173 <ul class="password-list">
174 {#each Array(2) as _}
175 <li class="skeleton-item"></li>
176 {/each}
177 </ul>
178 {:else if passwords.length === 0}
179 <p class="empty">{$_('appPasswords.noPasswords')}</p>
180 {:else}
181 <ul class="password-list">
182 {#each passwords as pw}
183 <li>
184 <div class="password-info">
185 <span class="name">{pw.name}</span>
186 <span class="meta">
187 <span class="scope-badge" class:full={!pw.scopes}>{getScopeLabel(pw.scopes)}</span>
188 {#if pw.createdByController}
189 <span class="controller-badge" title={pw.createdByController}>{$_('appPasswords.byController')}</span>
190 {/if}
191 <span class="date">{$_('common.created')} {formatDate(pw.createdAt)}</span>
192 </span>
193 </div>
194 <button
195 class="revoke"
196 onclick={() => handleRevoke(pw.name)}
197 disabled={revoking === pw.name}
198 >
199 {revoking === pw.name ? $_('appPasswords.revoking') : $_('appPasswords.revoke')}
200 </button>
201 </li>
202 {/each}
203 </ul>
204 {/if}
205 </section>
206</div>
207<style>
208 .page {
209 max-width: var(--width-lg);
210 margin: 0 auto;
211 padding: var(--space-7);
212 }
213
214 header {
215 margin-bottom: var(--space-4);
216 }
217
218 .back {
219 color: var(--text-secondary);
220 text-decoration: none;
221 font-size: var(--text-sm);
222 }
223
224 .back:hover {
225 color: var(--accent);
226 }
227
228 h1 {
229 margin: var(--space-2) 0 0 0;
230 }
231
232 .description {
233 color: var(--text-secondary);
234 margin-bottom: var(--space-7);
235 }
236
237 .error {
238 padding: var(--space-3);
239 background: var(--error-bg);
240 border: 1px solid var(--error-border);
241 border-radius: var(--radius-md);
242 color: var(--error-text);
243 margin-bottom: var(--space-4);
244 }
245
246 .created-password {
247 display: flex;
248 flex-direction: column;
249 gap: var(--space-4);
250 padding: var(--space-6);
251 background: var(--bg-secondary);
252 border: 1px solid var(--border-color);
253 border-radius: var(--radius-xl);
254 margin-bottom: var(--space-7);
255 }
256
257 .warning-box {
258 padding: var(--space-5);
259 background: var(--warning-bg);
260 border: 1px solid var(--warning-border);
261 border-radius: var(--radius-lg);
262 font-size: var(--text-sm);
263 }
264
265 .warning-box strong {
266 display: block;
267 margin-bottom: var(--space-2);
268 color: var(--warning-text);
269 }
270
271 .warning-box p {
272 margin: 0;
273 color: var(--warning-text);
274 }
275
276 .password-display {
277 background: var(--bg-card);
278 border: 2px solid var(--accent);
279 border-radius: var(--radius-xl);
280 padding: var(--space-6);
281 text-align: center;
282 }
283
284 .password-label {
285 font-size: var(--text-sm);
286 color: var(--text-secondary);
287 margin-bottom: var(--space-4);
288 }
289
290 .password-code {
291 display: block;
292 font-size: var(--text-xl);
293 font-family: ui-monospace, monospace;
294 letter-spacing: 0.1em;
295 padding: var(--space-5);
296 background: var(--bg-input);
297 border-radius: var(--radius-md);
298 margin-bottom: var(--space-4);
299 user-select: all;
300 word-break: break-all;
301 }
302
303 .copy-btn {
304 padding: var(--space-3) var(--space-5);
305 font-size: var(--text-sm);
306 }
307
308 .checkbox-label {
309 display: flex;
310 align-items: center;
311 gap: var(--space-3);
312 cursor: pointer;
313 font-weight: var(--font-normal);
314 }
315
316 .checkbox-label input[type="checkbox"] {
317 width: auto;
318 padding: 0;
319 }
320
321 section {
322 margin-bottom: var(--space-7);
323 }
324
325 section h2 {
326 font-size: var(--text-lg);
327 margin: 0 0 var(--space-4) 0;
328 }
329
330 .create-section form {
331 display: flex;
332 flex-direction: column;
333 gap: var(--space-4);
334 }
335
336 .create-section form > input {
337 flex: 1;
338 }
339
340 .create-section form > button {
341 align-self: flex-start;
342 }
343
344 .scope-selector {
345 display: flex;
346 flex-direction: column;
347 gap: var(--space-2);
348 }
349
350 .scope-label {
351 font-size: var(--text-sm);
352 color: var(--text-secondary);
353 }
354
355 .scope-buttons {
356 display: flex;
357 flex-wrap: wrap;
358 gap: var(--space-2);
359 }
360
361 .scope-btn {
362 padding: var(--space-2) var(--space-4);
363 background: var(--bg-secondary);
364 border: 1px solid var(--border-color);
365 border-radius: var(--radius-md);
366 color: var(--text-primary);
367 cursor: pointer;
368 font-size: var(--text-sm);
369 transition: all 0.15s ease;
370 }
371
372 .scope-btn:hover:not(:disabled) {
373 background: var(--bg-hover);
374 border-color: var(--accent);
375 }
376
377 .scope-btn.selected {
378 background: var(--accent);
379 border-color: var(--accent);
380 color: var(--text-inverse);
381 }
382
383 .scope-btn:disabled {
384 opacity: 0.6;
385 cursor: not-allowed;
386 }
387
388 .password-list {
389 list-style: none;
390 padding: 0;
391 margin: 0;
392 }
393
394 .password-list li {
395 display: flex;
396 justify-content: space-between;
397 align-items: center;
398 padding: var(--space-4);
399 border: 1px solid var(--border-color);
400 border-radius: var(--radius-md);
401 margin-bottom: var(--space-2);
402 background: var(--bg-card);
403 }
404
405 .password-info {
406 display: flex;
407 flex-direction: column;
408 gap: var(--space-1);
409 }
410
411 .name {
412 font-weight: var(--font-medium);
413 }
414
415 .meta {
416 display: flex;
417 align-items: center;
418 gap: var(--space-3);
419 }
420
421 .scope-badge {
422 font-size: var(--text-xs);
423 padding: var(--space-1) var(--space-2);
424 background: var(--bg-secondary);
425 border: 1px solid var(--border-color);
426 border-radius: var(--radius-sm);
427 color: var(--text-secondary);
428 }
429
430 .scope-badge.full {
431 background: var(--success-bg);
432 border-color: var(--success-border);
433 color: var(--success-text);
434 }
435
436 .controller-badge {
437 font-size: var(--text-xs);
438 padding: var(--space-1) var(--space-2);
439 background: var(--info-bg, #e3f2fd);
440 border: 1px solid var(--info-border, #90caf9);
441 border-radius: var(--radius-sm);
442 color: var(--info-text, #1565c0);
443 cursor: help;
444 }
445
446 .date {
447 font-size: var(--text-sm);
448 color: var(--text-secondary);
449 }
450
451 .revoke {
452 padding: var(--space-2) var(--space-4);
453 background: transparent;
454 border: 1px solid var(--error-text);
455 border-radius: var(--radius-md);
456 color: var(--error-text);
457 cursor: pointer;
458 }
459
460 .revoke:hover:not(:disabled) {
461 background: var(--error-bg);
462 }
463
464 .revoke:disabled {
465 opacity: 0.6;
466 cursor: not-allowed;
467 }
468
469 .empty {
470 color: var(--text-secondary);
471 text-align: center;
472 padding: var(--space-7);
473 }
474
475 .skeleton-item {
476 height: 60px;
477 background: var(--bg-tertiary);
478 animation: skeleton-pulse 1.5s ease-in-out infinite;
479 }
480
481 @keyframes skeleton-pulse {
482 0%, 100% { opacity: 1; }
483 50% { opacity: 0.5; }
484 }
485</style>