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 <span class="date">{$_('common.created')} {formatDate(pw.createdAt)}</span>
177 </span>
178 </div>
179 <button
180 class="revoke"
181 onclick={() => handleRevoke(pw.name)}
182 disabled={revoking === pw.name}
183 >
184 {revoking === pw.name ? $_('appPasswords.revoking') : $_('appPasswords.revoke')}
185 </button>
186 </li>
187 {/each}
188 </ul>
189 {/if}
190 </section>
191</div>
192<style>
193 .page {
194 max-width: var(--width-lg);
195 margin: 0 auto;
196 padding: var(--space-7);
197 }
198
199 header {
200 margin-bottom: var(--space-4);
201 }
202
203 .back {
204 color: var(--text-secondary);
205 text-decoration: none;
206 font-size: var(--text-sm);
207 }
208
209 .back:hover {
210 color: var(--accent);
211 }
212
213 h1 {
214 margin: var(--space-2) 0 0 0;
215 }
216
217 .description {
218 color: var(--text-secondary);
219 margin-bottom: var(--space-7);
220 }
221
222 .error {
223 padding: var(--space-3);
224 background: var(--error-bg);
225 border: 1px solid var(--error-border);
226 border-radius: var(--radius-md);
227 color: var(--error-text);
228 margin-bottom: var(--space-4);
229 }
230
231 .created-password {
232 display: flex;
233 flex-direction: column;
234 gap: var(--space-4);
235 padding: var(--space-6);
236 background: var(--bg-secondary);
237 border: 1px solid var(--border-color);
238 border-radius: var(--radius-xl);
239 margin-bottom: var(--space-7);
240 }
241
242 .warning-box {
243 padding: var(--space-5);
244 background: var(--warning-bg);
245 border: 1px solid var(--warning-border);
246 border-radius: var(--radius-lg);
247 font-size: var(--text-sm);
248 }
249
250 .warning-box strong {
251 display: block;
252 margin-bottom: var(--space-2);
253 color: var(--warning-text);
254 }
255
256 .warning-box p {
257 margin: 0;
258 color: var(--warning-text);
259 }
260
261 .password-display {
262 background: var(--bg-card);
263 border: 2px solid var(--accent);
264 border-radius: var(--radius-xl);
265 padding: var(--space-6);
266 text-align: center;
267 }
268
269 .password-label {
270 font-size: var(--text-sm);
271 color: var(--text-secondary);
272 margin-bottom: var(--space-4);
273 }
274
275 .password-code {
276 display: block;
277 font-size: var(--text-xl);
278 font-family: ui-monospace, monospace;
279 letter-spacing: 0.1em;
280 padding: var(--space-5);
281 background: var(--bg-input);
282 border-radius: var(--radius-md);
283 margin-bottom: var(--space-4);
284 user-select: all;
285 word-break: break-all;
286 }
287
288 .copy-btn {
289 padding: var(--space-3) var(--space-5);
290 font-size: var(--text-sm);
291 }
292
293 .checkbox-label {
294 display: flex;
295 align-items: center;
296 gap: var(--space-3);
297 cursor: pointer;
298 font-weight: var(--font-normal);
299 }
300
301 .checkbox-label input[type="checkbox"] {
302 width: auto;
303 padding: 0;
304 }
305
306 section {
307 margin-bottom: var(--space-7);
308 }
309
310 section h2 {
311 font-size: var(--text-lg);
312 margin: 0 0 var(--space-4) 0;
313 }
314
315 .create-section form {
316 display: flex;
317 flex-direction: column;
318 gap: var(--space-4);
319 }
320
321 .create-section form > input {
322 flex: 1;
323 }
324
325 .create-section form > button {
326 align-self: flex-start;
327 }
328
329 .scope-selector {
330 display: flex;
331 flex-direction: column;
332 gap: var(--space-2);
333 }
334
335 .scope-label {
336 font-size: var(--text-sm);
337 color: var(--text-secondary);
338 }
339
340 .scope-buttons {
341 display: flex;
342 flex-wrap: wrap;
343 gap: var(--space-2);
344 }
345
346 .scope-btn {
347 padding: var(--space-2) var(--space-4);
348 background: var(--bg-secondary);
349 border: 1px solid var(--border-color);
350 border-radius: var(--radius-md);
351 color: var(--text-primary);
352 cursor: pointer;
353 font-size: var(--text-sm);
354 transition: all 0.15s ease;
355 }
356
357 .scope-btn:hover:not(:disabled) {
358 background: var(--bg-hover);
359 border-color: var(--accent);
360 }
361
362 .scope-btn.selected {
363 background: var(--accent);
364 border-color: var(--accent);
365 color: var(--text-inverse);
366 }
367
368 .scope-btn:disabled {
369 opacity: 0.6;
370 cursor: not-allowed;
371 }
372
373 .password-list {
374 list-style: none;
375 padding: 0;
376 margin: 0;
377 }
378
379 .password-list li {
380 display: flex;
381 justify-content: space-between;
382 align-items: center;
383 padding: var(--space-4);
384 border: 1px solid var(--border-color);
385 border-radius: var(--radius-md);
386 margin-bottom: var(--space-2);
387 background: var(--bg-card);
388 }
389
390 .password-info {
391 display: flex;
392 flex-direction: column;
393 gap: var(--space-1);
394 }
395
396 .name {
397 font-weight: var(--font-medium);
398 }
399
400 .meta {
401 display: flex;
402 align-items: center;
403 gap: var(--space-3);
404 }
405
406 .scope-badge {
407 font-size: var(--text-xs);
408 padding: var(--space-1) var(--space-2);
409 background: var(--bg-secondary);
410 border: 1px solid var(--border-color);
411 border-radius: var(--radius-sm);
412 color: var(--text-secondary);
413 }
414
415 .scope-badge.full {
416 background: var(--success-bg);
417 border-color: var(--success-border);
418 color: var(--success-text);
419 }
420
421 .date {
422 font-size: var(--text-sm);
423 color: var(--text-secondary);
424 }
425
426 .revoke {
427 padding: var(--space-2) var(--space-4);
428 background: transparent;
429 border: 1px solid var(--error-text);
430 border-radius: var(--radius-md);
431 color: var(--error-text);
432 cursor: pointer;
433 }
434
435 .revoke:hover:not(:disabled) {
436 background: var(--error-bg);
437 }
438
439 .revoke:disabled {
440 opacity: 0.6;
441 cursor: not-allowed;
442 }
443
444 .empty {
445 color: var(--text-secondary);
446 text-align: center;
447 padding: var(--space-7);
448 }
449</style>