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>