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