this repo has no description
at main 12 kB view raw
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>