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>