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>