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 .created-password {
238 display: flex;
239 flex-direction: column;
240 gap: var(--space-4);
241 padding: var(--space-6);
242 background: var(--bg-secondary);
243 border: 1px solid var(--border-color);
244 border-radius: var(--radius-xl);
245 margin-bottom: var(--space-7);
246 }
247
248 .warning-box {
249 padding: var(--space-5);
250 background: var(--warning-bg);
251 border: 1px solid var(--warning-border);
252 border-radius: var(--radius-lg);
253 font-size: var(--text-sm);
254 }
255
256 .warning-box strong {
257 display: block;
258 margin-bottom: var(--space-2);
259 color: var(--warning-text);
260 }
261
262 .warning-box p {
263 margin: 0;
264 color: var(--warning-text);
265 }
266
267 .password-display {
268 background: var(--bg-card);
269 border: 2px solid var(--accent);
270 border-radius: var(--radius-xl);
271 padding: var(--space-6);
272 text-align: center;
273 }
274
275 .password-label {
276 font-size: var(--text-sm);
277 color: var(--text-secondary);
278 margin-bottom: var(--space-4);
279 }
280
281 .password-code {
282 display: block;
283 font-size: var(--text-xl);
284 font-family: ui-monospace, monospace;
285 letter-spacing: 0.1em;
286 padding: var(--space-5);
287 background: var(--bg-input);
288 border-radius: var(--radius-md);
289 margin-bottom: var(--space-4);
290 user-select: all;
291 word-break: break-all;
292 }
293
294 .copy-btn {
295 padding: var(--space-3) var(--space-5);
296 font-size: var(--text-sm);
297 }
298
299 .checkbox-label {
300 display: flex;
301 align-items: center;
302 gap: var(--space-3);
303 cursor: pointer;
304 font-weight: var(--font-normal);
305 }
306
307 .checkbox-label input[type="checkbox"] {
308 width: auto;
309 padding: 0;
310 }
311
312 section {
313 margin-bottom: var(--space-7);
314 }
315
316 section h2 {
317 font-size: var(--text-lg);
318 margin: 0 0 var(--space-4) 0;
319 }
320
321 .create-section form {
322 display: flex;
323 flex-direction: column;
324 gap: var(--space-4);
325 }
326
327 .create-section form > input {
328 flex: 1;
329 }
330
331 .create-section form > button {
332 align-self: flex-start;
333 }
334
335 .scope-selector {
336 display: flex;
337 flex-direction: column;
338 gap: var(--space-2);
339 }
340
341 .scope-label {
342 font-size: var(--text-sm);
343 color: var(--text-secondary);
344 }
345
346 .scope-buttons {
347 display: flex;
348 flex-wrap: wrap;
349 gap: var(--space-2);
350 }
351
352 .scope-btn {
353 padding: var(--space-2) var(--space-4);
354 background: var(--bg-secondary);
355 border: 1px solid var(--border-color);
356 border-radius: var(--radius-md);
357 color: var(--text-primary);
358 cursor: pointer;
359 font-size: var(--text-sm);
360 transition: all 0.15s ease;
361 }
362
363 .scope-btn:hover:not(:disabled) {
364 background: var(--bg-hover);
365 border-color: var(--accent);
366 }
367
368 .scope-btn.selected {
369 background: var(--accent);
370 border-color: var(--accent);
371 color: var(--text-inverse);
372 }
373
374 .scope-btn:disabled {
375 opacity: 0.6;
376 cursor: not-allowed;
377 }
378
379 .password-list {
380 list-style: none;
381 padding: 0;
382 margin: 0;
383 }
384
385 .password-list li {
386 display: flex;
387 justify-content: space-between;
388 align-items: center;
389 padding: var(--space-4);
390 border: 1px solid var(--border-color);
391 border-radius: var(--radius-md);
392 margin-bottom: var(--space-2);
393 background: var(--bg-card);
394 }
395
396 .password-info {
397 display: flex;
398 flex-direction: column;
399 gap: var(--space-1);
400 }
401
402 .name {
403 font-weight: var(--font-medium);
404 }
405
406 .meta {
407 display: flex;
408 align-items: center;
409 gap: var(--space-3);
410 }
411
412 .scope-badge {
413 font-size: var(--text-xs);
414 padding: var(--space-1) var(--space-2);
415 background: var(--bg-secondary);
416 border: 1px solid var(--border-color);
417 border-radius: var(--radius-sm);
418 color: var(--text-secondary);
419 }
420
421 .scope-badge.full {
422 background: var(--success-bg);
423 border-color: var(--success-border);
424 color: var(--success-text);
425 }
426
427 .controller-badge {
428 font-size: var(--text-xs);
429 padding: var(--space-1) var(--space-2);
430 background: var(--info-bg, #e3f2fd);
431 border: 1px solid var(--info-border, #90caf9);
432 border-radius: var(--radius-sm);
433 color: var(--info-text, #1565c0);
434 cursor: help;
435 }
436
437 .date {
438 font-size: var(--text-sm);
439 color: var(--text-secondary);
440 }
441
442 .revoke {
443 padding: var(--space-2) var(--space-4);
444 background: transparent;
445 border: 1px solid var(--error-text);
446 border-radius: var(--radius-md);
447 color: var(--error-text);
448 cursor: pointer;
449 }
450
451 .revoke:hover:not(:disabled) {
452 background: var(--error-bg);
453 }
454
455 .revoke:disabled {
456 opacity: 0.6;
457 cursor: not-allowed;
458 }
459
460 .empty {
461 color: var(--text-secondary);
462 text-align: center;
463 padding: var(--space-7);
464 }
465
466 .skeleton-item {
467 height: 60px;
468 background: var(--bg-tertiary);
469 animation: skeleton-pulse 1.5s ease-in-out infinite;
470 }
471
472 @keyframes skeleton-pulse {
473 0%, 100% { opacity: 1; }
474 50% { opacity: 0.5; }
475 }
476</style>