Microservice to bring 2FA to self hosted PDSes

persistence sessions

+1955 -696
+437 -181
html_templates/admin/account_detail.hbs
··· 42 42 --table-stripe: rgba(255, 255, 255, 0.02); 43 43 } 44 44 45 - * { margin: 0; padding: 0; box-sizing: border-box; } 45 + * { 46 + margin: 0; 47 + padding: 0; 48 + box-sizing: border-box; 49 + } 46 50 47 51 body { 48 52 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; ··· 52 56 -webkit-font-smoothing: antialiased; 53 57 } 54 58 55 - .layout { display: flex; min-height: 100vh; } 59 + .layout { 60 + display: flex; 61 + min-height: 100vh; 62 + } 63 + 64 + .sidebar { 65 + width: 220px; 66 + background: var(--bg-primary-color); 67 + border-right: 1px solid var(--border-color); 68 + padding: 20px 0; 69 + position: fixed; 70 + top: 0; 71 + left: 0; 72 + bottom: 0; 73 + overflow-y: auto; 74 + display: flex; 75 + flex-direction: column; 76 + } 77 + 78 + .sidebar-title { 79 + font-size: 0.8125rem; 80 + font-weight: 700; 81 + padding: 0 20px; 82 + margin-bottom: 4px; 83 + white-space: nowrap; 84 + overflow: hidden; 85 + text-overflow: ellipsis; 86 + } 87 + 88 + .sidebar-subtitle { 89 + font-size: 0.6875rem; 90 + color: var(--secondary-color); 91 + padding: 0 20px; 92 + margin-bottom: 20px; 93 + } 94 + 95 + .sidebar nav { 96 + flex: 1; 97 + } 98 + 99 + .sidebar nav a { 100 + display: block; 101 + padding: 8px 20px; 102 + font-size: 0.8125rem; 103 + color: var(--secondary-color); 104 + text-decoration: none; 105 + transition: background 0.1s, color 0.1s; 106 + } 107 + 108 + .sidebar nav a:hover { 109 + background: var(--bg-secondary-color); 110 + color: var(--primary-color); 111 + } 112 + 113 + .sidebar nav a.active { 114 + color: var(--brand-color); 115 + font-weight: 500; 116 + } 117 + 118 + .sidebar-footer { 119 + padding: 16px 20px 0; 120 + border-top: 1px solid var(--border-color); 121 + margin-top: 16px; 122 + } 123 + 124 + .sidebar-footer .session-info { 125 + font-size: 0.75rem; 126 + color: var(--secondary-color); 127 + margin-bottom: 8px; 128 + } 129 + 130 + .sidebar-footer form { 131 + display: inline; 132 + } 133 + 134 + .sidebar-footer button { 135 + background: none; 136 + border: none; 137 + font-size: 0.75rem; 138 + color: var(--secondary-color); 139 + cursor: pointer; 140 + padding: 0; 141 + text-decoration: underline; 142 + } 143 + 144 + .sidebar-footer button:hover { 145 + color: var(--primary-color); 146 + } 147 + 148 + .main { 149 + margin-left: 220px; 150 + flex: 1; 151 + padding: 32px; 152 + max-width: 960px; 153 + } 154 + 155 + .page-title { 156 + font-size: 1.5rem; 157 + font-weight: 700; 158 + margin-bottom: 4px; 159 + } 160 + 161 + .page-subtitle { 162 + font-size: 0.8125rem; 163 + color: var(--secondary-color); 164 + font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; 165 + margin-bottom: 24px; 166 + word-break: break-all; 167 + } 168 + 169 + .flash-success { 170 + background: rgba(22, 163, 74, 0.1); 171 + color: var(--success-color); 172 + border: 1px solid rgba(22, 163, 74, 0.2); 173 + border-radius: 8px; 174 + padding: 10px 14px; 175 + font-size: 0.875rem; 176 + margin-bottom: 20px; 177 + } 178 + 179 + .flash-error { 180 + background: rgba(220, 38, 38, 0.1); 181 + color: var(--danger-color); 182 + border: 1px solid rgba(220, 38, 38, 0.2); 183 + border-radius: 8px; 184 + padding: 10px 14px; 185 + font-size: 0.875rem; 186 + margin-bottom: 20px; 187 + } 188 + 189 + .detail-section { 190 + background: var(--bg-primary-color); 191 + border: 1px solid var(--border-color); 192 + border-radius: 10px; 193 + padding: 20px; 194 + margin-bottom: 16px; 195 + } 196 + 197 + .detail-section h3 { 198 + font-size: 0.875rem; 199 + font-weight: 600; 200 + margin-bottom: 12px; 201 + } 202 + 203 + .detail-row { 204 + display: flex; 205 + justify-content: space-between; 206 + align-items: center; 207 + padding: 8px 0; 208 + font-size: 0.8125rem; 209 + border-bottom: 1px solid var(--border-color); 210 + } 56 211 57 - .sidebar { width: 220px; background: var(--bg-primary-color); border-right: 1px solid var(--border-color); padding: 20px 0; position: fixed; top: 0; left: 0; bottom: 0; overflow-y: auto; display: flex; flex-direction: column; } 58 - .sidebar-title { font-size: 0.8125rem; font-weight: 700; padding: 0 20px; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 59 - .sidebar-subtitle { font-size: 0.6875rem; color: var(--secondary-color); padding: 0 20px; margin-bottom: 20px; } 60 - .sidebar nav { flex: 1; } 61 - .sidebar nav a { display: block; padding: 8px 20px; font-size: 0.8125rem; color: var(--secondary-color); text-decoration: none; transition: background 0.1s, color 0.1s; } 62 - .sidebar nav a:hover { background: var(--bg-secondary-color); color: var(--primary-color); } 63 - .sidebar nav a.active { color: var(--brand-color); font-weight: 500; } 64 - .sidebar-footer { padding: 16px 20px 0; border-top: 1px solid var(--border-color); margin-top: 16px; } 65 - .sidebar-footer .session-info { font-size: 0.75rem; color: var(--secondary-color); margin-bottom: 8px; } 66 - .sidebar-footer form { display: inline; } 67 - .sidebar-footer button { background: none; border: none; font-size: 0.75rem; color: var(--secondary-color); cursor: pointer; padding: 0; text-decoration: underline; } 68 - .sidebar-footer button:hover { color: var(--primary-color); } 212 + .detail-row:last-child { 213 + border-bottom: none; 214 + } 69 215 70 - .main { margin-left: 220px; flex: 1; padding: 32px; max-width: 960px; } 71 - .page-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 4px; } 72 - .page-subtitle { font-size: 0.8125rem; color: var(--secondary-color); font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; margin-bottom: 24px; word-break: break-all; } 216 + .detail-row .label { 217 + color: var(--secondary-color); 218 + flex-shrink: 0; 219 + } 73 220 74 - .flash-success { background: rgba(22, 163, 74, 0.1); color: var(--success-color); border: 1px solid rgba(22, 163, 74, 0.2); border-radius: 8px; padding: 10px 14px; font-size: 0.875rem; margin-bottom: 20px; } 75 - .flash-error { background: rgba(220, 38, 38, 0.1); color: var(--danger-color); border: 1px solid rgba(220, 38, 38, 0.2); border-radius: 8px; padding: 10px 14px; font-size: 0.875rem; margin-bottom: 20px; } 221 + .detail-row .value { 222 + font-weight: 500; 223 + word-break: break-all; 224 + text-align: right; 225 + max-width: 65%; 226 + } 76 227 77 - .detail-section { background: var(--bg-primary-color); border: 1px solid var(--border-color); border-radius: 10px; padding: 20px; margin-bottom: 16px; } 78 - .detail-section h3 { font-size: 0.875rem; font-weight: 600; margin-bottom: 12px; } 79 - .detail-row { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; font-size: 0.8125rem; border-bottom: 1px solid var(--border-color); } 80 - .detail-row:last-child { border-bottom: none; } 81 - .detail-row .label { color: var(--secondary-color); flex-shrink: 0; } 82 - .detail-row .value { font-weight: 500; word-break: break-all; text-align: right; max-width: 65%; } 228 + .badge { 229 + display: inline-block; 230 + padding: 2px 8px; 231 + border-radius: 4px; 232 + font-size: 0.75rem; 233 + font-weight: 500; 234 + } 83 235 84 - .badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: 500; } 85 - .badge-success { background: rgba(22, 163, 74, 0.1); color: var(--success-color); } 86 - .badge-danger { background: rgba(220, 38, 38, 0.1); color: var(--danger-color); } 87 - .badge-warning { background: rgba(234, 179, 8, 0.1); color: var(--warning-color); } 236 + .badge-success { 237 + background: rgba(22, 163, 74, 0.1); 238 + color: var(--success-color); 239 + } 88 240 89 - .actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; } 90 - .actions form { display: inline; } 241 + .badge-danger { 242 + background: rgba(220, 38, 38, 0.1); 243 + color: var(--danger-color); 244 + } 91 245 92 - .btn { display: inline-flex; align-items: center; justify-content: center; padding: 8px 16px; font-size: 0.8125rem; font-weight: 500; border: 1px solid var(--border-color); border-radius: 8px; cursor: pointer; transition: opacity 0.15s; text-decoration: none; background: var(--bg-primary-color); color: var(--primary-color); } 93 - .btn:hover { opacity: 0.85; } 94 - .btn-primary { background: var(--brand-color); color: #fff; border-color: var(--brand-color); } 95 - .btn-danger { background: var(--danger-color); color: #fff; border-color: var(--danger-color); } 96 - .btn-warning { background: var(--warning-color); color: #000; border-color: var(--warning-color); } 246 + .badge-warning { 247 + background: rgba(234, 179, 8, 0.1); 248 + color: var(--warning-color); 249 + } 97 250 98 - .password-box { background: rgba(22, 163, 74, 0.08); border: 1px solid rgba(22, 163, 74, 0.2); border-radius: 10px; padding: 16px 20px; margin-bottom: 16px; } 99 - .password-box .pw-label { font-size: 0.75rem; font-weight: 600; color: var(--success-color); margin-bottom: 6px; } 100 - .password-box .pw-value { font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; font-size: 1rem; font-weight: 600; user-select: all; } 251 + .actions { 252 + display: flex; 253 + flex-wrap: wrap; 254 + gap: 8px; 255 + margin-top: 8px; 256 + } 101 257 102 - .back-link { display: inline-block; color: var(--brand-color); text-decoration: none; font-size: 0.8125rem; margin-bottom: 16px; } 103 - .back-link:hover { text-decoration: underline; } 258 + .actions form { 259 + display: inline; 260 + } 261 + 262 + .btn { 263 + display: inline-flex; 264 + align-items: center; 265 + justify-content: center; 266 + padding: 8px 16px; 267 + font-size: 0.8125rem; 268 + font-weight: 500; 269 + border: 1px solid var(--border-color); 270 + border-radius: 8px; 271 + cursor: pointer; 272 + transition: opacity 0.15s; 273 + text-decoration: none; 274 + background: var(--bg-primary-color); 275 + color: var(--primary-color); 276 + } 277 + 278 + .btn:hover { 279 + opacity: 0.85; 280 + } 281 + 282 + .btn-primary { 283 + background: var(--brand-color); 284 + color: #fff; 285 + border-color: var(--brand-color); 286 + } 287 + 288 + .btn-danger { 289 + background: var(--danger-color); 290 + color: #fff; 291 + border-color: var(--danger-color); 292 + } 293 + 294 + .btn-warning { 295 + background: var(--warning-color); 296 + color: #000; 297 + border-color: var(--warning-color); 298 + } 104 299 105 - .collection-list { max-height: 200px; overflow-y: auto; padding: 8px 0; } 106 - .collection-item { font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.75rem; padding: 4px 0; color: var(--secondary-color); border-bottom: 1px solid var(--border-color); } 107 - .collection-item:last-child { border-bottom: none; } 300 + .password-box { 301 + background: rgba(22, 163, 74, 0.08); 302 + border: 1px solid rgba(22, 163, 74, 0.2); 303 + border-radius: 10px; 304 + padding: 16px 20px; 305 + margin-bottom: 16px; 306 + } 108 307 109 - .threat-sig { font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.75rem; padding: 4px 0; color: var(--secondary-color); } 308 + .password-box .pw-label { 309 + font-size: 0.75rem; 310 + font-weight: 600; 311 + color: var(--success-color); 312 + margin-bottom: 6px; 313 + } 314 + 315 + .password-box .pw-value { 316 + font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; 317 + font-size: 1rem; 318 + font-weight: 600; 319 + user-select: all; 320 + } 321 + 322 + .back-link { 323 + display: inline-block; 324 + color: var(--brand-color); 325 + text-decoration: none; 326 + font-size: 0.8125rem; 327 + margin-bottom: 16px; 328 + } 329 + 330 + .back-link:hover { 331 + text-decoration: underline; 332 + } 333 + 334 + .collection-list { 335 + max-height: 200px; 336 + overflow-y: auto; 337 + padding: 8px 0; 338 + } 339 + 340 + .collection-item { 341 + font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; 342 + font-size: 0.75rem; 343 + padding: 4px 0; 344 + color: var(--secondary-color); 345 + border-bottom: 1px solid var(--border-color); 346 + } 347 + 348 + .collection-item:last-child { 349 + border-bottom: none; 350 + } 351 + 352 + .threat-sig { 353 + font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; 354 + font-size: 0.75rem; 355 + padding: 4px 0; 356 + color: var(--secondary-color); 357 + } 110 358 111 359 @media (max-width: 768px) { 112 - .sidebar { display: none; } 113 - .main { margin-left: 0; } 360 + .sidebar { 361 + display: none; 362 + } 363 + 364 + .main { 365 + margin-left: 0; 366 + } 114 367 } 115 368 </style> 116 369 </head> 117 370 <body> 118 - <div class="layout"> 119 - <aside class="sidebar"> 120 - <div class="sidebar-title">{{pds_hostname}}</div> 121 - <div class="sidebar-subtitle">Admin Portal</div> 122 - <nav> 123 - <a href="/admin/">Dashboard</a> 124 - {{#if can_view_accounts}} 371 + <div class="layout"> 372 + <aside class="sidebar"> 373 + <div class="sidebar-title">{{pds_hostname}}</div> 374 + <div class="sidebar-subtitle">Admin Portal</div> 375 + <nav> 376 + <a href="/admin/dashboard">Dashboard</a> 377 + {{#if can_view_accounts}} 125 378 <a href="/admin/accounts" class="active">Accounts</a> 126 - {{/if}} 127 - {{#if can_manage_invites}} 379 + {{/if}} 380 + {{#if can_manage_invites}} 128 381 <a href="/admin/invite-codes">Invite Codes</a> 129 - {{/if}} 130 - {{#if can_create_account}} 382 + {{/if}} 383 + {{#if can_create_account}} 131 384 <a href="/admin/create-account">Create Account</a> 132 - {{/if}} 133 - {{#if can_request_crawl}} 385 + {{/if}} 386 + {{#if can_request_crawl}} 134 387 <a href="/admin/request-crawl">Request Crawl</a> 135 - {{/if}} 136 - </nav> 137 - <div class="sidebar-footer"> 138 - <div class="session-info">Signed in as {{handle}}</div> 139 - <form method="POST" action="/admin/logout"> 140 - <button type="submit">Sign out</button> 141 - </form> 142 - </div> 143 - </aside> 388 + {{/if}} 389 + </nav> 390 + <div class="sidebar-footer"> 391 + <div class="session-info">Signed in as {{handle}}</div> 392 + <form method="POST" action="/admin/logout"> 393 + <button type="submit">Sign out</button> 394 + </form> 395 + </div> 396 + </aside> 144 397 145 - <main class="main"> 146 - {{#if flash_success}} 398 + <main class="main"> 399 + {{#if flash_success}} 147 400 <div class="flash-success">{{flash_success}}</div> 148 - {{/if}} 149 - {{#if flash_error}} 401 + {{/if}} 402 + {{#if flash_error}} 150 403 <div class="flash-error">{{flash_error}}</div> 151 - {{/if}} 404 + {{/if}} 152 405 153 - <a href="/admin/accounts" class="back-link">&larr; Back to Accounts</a> 406 + <a href="/admin/accounts" class="back-link">&larr; Back to Accounts</a> 154 407 155 - <h1 class="page-title">{{account.handle}}</h1> 156 - <div class="page-subtitle">{{account.did}}</div> 408 + <h1 class="page-title">{{account.handle}}</h1> 409 + <div class="page-subtitle">{{account.did}}</div> 157 410 158 - {{#if new_password}} 411 + {{#if new_password}} 159 412 <div class="password-box"> 160 413 <div class="pw-label">New Password (copy now -- it will not be shown again)</div> 161 414 <div class="pw-value">{{new_password}}</div> 162 415 </div> 163 - {{/if}} 416 + {{/if}} 164 417 165 - <div class="detail-section"> 166 - <h3>Account Information</h3> 167 - <div class="detail-row"> 168 - <span class="label">Handle</span> 169 - <span class="value">{{account.handle}}</span> 170 - </div> 171 - <div class="detail-row"> 172 - <span class="label">DID</span> 173 - <span class="value">{{account.did}}</span> 174 - </div> 175 - <div class="detail-row"> 176 - <span class="label">Email</span> 177 - <span class="value">{{account.email}}</span> 178 - </div> 179 - {{#if account.indexedAt}} 418 + <div class="detail-section"> 419 + <h3>Account Information</h3> 420 + <div class="detail-row"> 421 + <span class="label">Handle</span> 422 + <span class="value">{{account.handle}}</span> 423 + </div> 424 + <div class="detail-row"> 425 + <span class="label">DID</span> 426 + <span class="value">{{account.did}}</span> 427 + </div> 428 + <div class="detail-row"> 429 + <span class="label">Email</span> 430 + <span class="value">{{account.email}}</span> 431 + </div> 432 + {{#if account.indexedAt}} 180 433 <div class="detail-row"> 181 434 <span class="label">Indexed At</span> 182 435 <span class="value">{{account.indexedAt}}</span> 183 436 </div> 184 - {{/if}} 185 - {{#if handle_resolution_checked}} 437 + {{/if}} 438 + {{#if handle_resolution_checked}} 186 439 <div class="detail-row"> 187 440 <span class="label">Handle Resolution</span> 188 441 <span class="value"> 189 442 {{#if handle_is_correct}} 190 - <span class="badge badge-success">Valid</span> 443 + <span class="badge badge-success">Valid</span> 191 444 {{else}} 192 - <span class="badge badge-danger">Failed</span> 445 + <span class="badge badge-danger">Failed</span> 193 446 {{/if}} 194 447 </span> 195 448 </div> 196 - {{/if}} 197 - </div> 449 + {{/if}} 450 + </div> 198 451 199 - <div class="detail-section"> 200 - <h3>Status</h3> 201 - <div class="detail-row"> 202 - <span class="label">Takedown</span> 203 - <span class="value"> 204 - {{#if is_taken_down}} 452 + <div class="detail-section"> 453 + <h3>Status</h3> 454 + <div class="detail-row"> 455 + <span class="label">Takedown</span> 456 + <span class="value"> 457 + {{#if is_taken_down}} 205 458 <span class="badge badge-danger">Taken Down</span> 206 - {{else}} 459 + {{else}} 207 460 <span class="badge badge-success">Active</span> 208 - {{/if}} 209 - </span> 210 - </div> 211 - {{#if takedown_ref}} 461 + {{/if}} 462 + </span> 463 + </div> 464 + {{#if takedown_ref}} 212 465 <div class="detail-row"> 213 466 <span class="label">Takedown Reference</span> 214 467 <span class="value">{{takedown_ref}}</span> 215 468 </div> 216 - {{/if}} 217 - <div class="detail-row"> 218 - <span class="label">Email Confirmed</span> 219 - <span class="value"> 220 - {{#if account.emailConfirmedAt}} 469 + {{/if}} 470 + <div class="detail-row"> 471 + <span class="label">Email Confirmed</span> 472 + <span class="value"> 473 + {{#if account.emailConfirmedAt}} 221 474 <span class="badge badge-success">Confirmed</span> 222 - {{else}} 475 + {{else}} 223 476 <span class="badge badge-warning">Unconfirmed</span> 224 - {{/if}} 225 - </span> 226 - </div> 227 - <div class="detail-row"> 228 - <span class="label">Deactivated</span> 229 - <span class="value"> 230 - {{#if account.deactivatedAt}} 477 + {{/if}} 478 + </span> 479 + </div> 480 + <div class="detail-row"> 481 + <span class="label">Deactivated</span> 482 + <span class="value"> 483 + {{#if account.deactivatedAt}} 231 484 <span class="badge badge-warning">{{account.deactivatedAt}}</span> 232 - {{else}} 485 + {{else}} 233 486 <span class="badge badge-success">No</span> 234 - {{/if}} 235 - </span> 236 - </div> 487 + {{/if}} 488 + </span> 237 489 </div> 490 + </div> 238 491 239 - {{#if repo_status_checked}} 492 + {{#if repo_status_checked}} 240 493 <div class="detail-section"> 241 494 <h3>Repo Status</h3> 242 495 <div class="detail-row"> 243 496 <span class="label">Active</span> 244 497 <span class="value"> 245 498 {{#if repo_active}} 246 - <span class="badge badge-success">Active</span> 499 + <span class="badge badge-success">Active</span> 247 500 {{else}} 248 - <span class="badge badge-danger">Inactive</span> 501 + <span class="badge badge-danger">Inactive</span> 249 502 {{/if}} 250 503 </span> 251 504 </div> 252 505 {{#if repo_status_reason}} 253 - <div class="detail-row"> 254 - <span class="label">Status Reason</span> 255 - <span class="value">{{repo_status_reason}}</span> 256 - </div> 506 + <div class="detail-row"> 507 + <span class="label">Status Reason</span> 508 + <span class="value">{{repo_status_reason}}</span> 509 + </div> 257 510 {{/if}} 258 511 {{#if repo_rev}} 259 - <div class="detail-row"> 260 - <span class="label">Revision</span> 261 - <span class="value" style="font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.75rem;">{{repo_rev}}</span> 262 - </div> 512 + <div class="detail-row"> 513 + <span class="label">Revision</span> 514 + <span class="value" 515 + style="font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.75rem;">{{repo_rev}}</span> 516 + </div> 263 517 {{/if}} 264 518 </div> 265 - {{/if}} 519 + {{/if}} 266 520 267 - <div class="detail-section"> 268 - <h3>Invite Information</h3> 269 - {{#if account.invitedBy}} 521 + <div class="detail-section"> 522 + <h3>Invite Information</h3> 523 + {{#if account.invitedBy}} 270 524 <div class="detail-row"> 271 525 <span class="label">Invited By</span> 272 526 <span class="value">{{account.invitedBy}}</span> 273 527 </div> 274 - {{/if}} 275 - <div class="detail-row"> 276 - <span class="label">Invites Disabled</span> 277 - <span class="value"> 278 - {{#if account.invitesDisabled}} 528 + {{/if}} 529 + <div class="detail-row"> 530 + <span class="label">Invites Disabled</span> 531 + <span class="value"> 532 + {{#if account.invitesDisabled}} 279 533 <span class="badge badge-danger">Yes</span> 280 - {{else}} 534 + {{else}} 281 535 <span class="badge badge-success">No</span> 282 - {{/if}} 283 - </span> 284 - </div> 285 - {{#if account.inviteNote}} 536 + {{/if}} 537 + </span> 538 + </div> 539 + {{#if account.inviteNote}} 286 540 <div class="detail-row"> 287 541 <span class="label">Invite Note</span> 288 542 <span class="value">{{account.inviteNote}}</span> 289 543 </div> 290 - {{/if}} 291 - </div> 544 + {{/if}} 545 + </div> 292 546 293 - {{#if collections}} 547 + {{#if collections}} 294 548 <div class="detail-section"> 295 549 <h3>Collections</h3> 296 550 <div class="collection-list"> 297 551 {{#each collections}} 298 - <div class="collection-item">{{this}}</div> 552 + <div class="collection-item">{{this}}</div> 299 553 {{/each}} 300 554 </div> 301 555 </div> 302 - {{/if}} 556 + {{/if}} 303 557 304 - {{#if threat_signatures}} 558 + {{#if threat_signatures}} 305 559 <div class="detail-section"> 306 560 <h3>Threat Signatures</h3> 307 561 {{#each threat_signatures}} 308 - <div class="threat-sig">{{this.property}}: {{this.value}}</div> 562 + <div class="threat-sig">{{this.property}}: {{this.value}}</div> 309 563 {{/each}} 310 564 </div> 311 - {{/if}} 565 + {{/if}} 312 566 313 - <div class="detail-section"> 314 - <h3>Actions</h3> 315 - <div class="actions"> 316 - {{#if can_manage_takedowns}} 317 - {{#if is_taken_down}} 567 + <div class="detail-section"> 568 + <h3>Actions</h3> 569 + <div class="actions"> 570 + {{#if can_manage_takedowns}} 571 + {{#if is_taken_down}} 318 572 <form method="POST" action="/admin/accounts/{{account.did}}/untakedown"> 319 573 <button type="submit" class="btn btn-primary">Remove Takedown</button> 320 574 </form> 321 - {{else}} 575 + {{else}} 322 576 <form method="POST" action="/admin/accounts/{{account.did}}/takedown"> 323 577 <button type="submit" class="btn btn-warning">Takedown Account</button> 324 578 </form> 325 - {{/if}} 326 579 {{/if}} 580 + {{/if}} 327 581 328 - {{#if can_reset_password}} 329 - <form method="POST" action="/admin/accounts/{{account.did}}/reset-password" onsubmit="return confirm('Are you sure you want to reset this account password? The current password will be invalidated.');"> 582 + {{#if can_reset_password}} 583 + <form method="POST" action="/admin/accounts/{{account.did}}/reset-password" 584 + onsubmit="return confirm('Are you sure you want to reset this account password? The current password will be invalidated.');"> 330 585 <button type="submit" class="btn">Reset Password</button> 331 586 </form> 332 - {{/if}} 587 + {{/if}} 333 588 334 - {{#if can_manage_invites}} 335 - {{#if account.invitesDisabled}} 589 + {{#if can_manage_invites}} 590 + {{#if account.invitesDisabled}} 336 591 <form method="POST" action="/admin/accounts/{{account.did}}/enable-invites"> 337 592 <button type="submit" class="btn">Enable Invites</button> 338 593 </form> 339 - {{else}} 594 + {{else}} 340 595 <form method="POST" action="/admin/accounts/{{account.did}}/disable-invites"> 341 596 <button type="submit" class="btn">Disable Invites</button> 342 597 </form> 343 - {{/if}} 344 598 {{/if}} 599 + {{/if}} 345 600 346 - {{#if can_delete_account}} 347 - <form method="POST" action="/admin/accounts/{{account.did}}/delete" onsubmit="return confirm('PERMANENTLY DELETE this account? This action cannot be undone.');"> 601 + {{#if can_delete_account}} 602 + <form method="POST" action="/admin/accounts/{{account.did}}/delete" 603 + onsubmit="return confirm('PERMANENTLY DELETE this account? This action cannot be undone.');"> 348 604 <button type="submit" class="btn btn-danger">Delete Account</button> 349 605 </form> 350 - {{/if}} 351 - </div> 606 + {{/if}} 352 607 </div> 353 - </main> 354 - </div> 608 + </div> 609 + </main> 610 + </div> 355 611 356 612 </body> 357 613 </html>
+269 -83
html_templates/admin/accounts.hbs
··· 42 42 --table-stripe: rgba(255, 255, 255, 0.02); 43 43 } 44 44 45 - * { margin: 0; padding: 0; box-sizing: border-box; } 45 + * { 46 + margin: 0; 47 + padding: 0; 48 + box-sizing: border-box; 49 + } 46 50 47 51 body { 48 52 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; ··· 52 56 -webkit-font-smoothing: antialiased; 53 57 } 54 58 55 - .layout { display: flex; min-height: 100vh; } 59 + .layout { 60 + display: flex; 61 + min-height: 100vh; 62 + } 56 63 57 64 .sidebar { 58 65 width: 220px; ··· 60 67 border-right: 1px solid var(--border-color); 61 68 padding: 20px 0; 62 69 position: fixed; 63 - top: 0; left: 0; bottom: 0; 70 + top: 0; 71 + left: 0; 72 + bottom: 0; 64 73 overflow-y: auto; 65 74 display: flex; 66 75 flex-direction: column; 67 76 } 68 77 69 - .sidebar-title { font-size: 0.8125rem; font-weight: 700; padding: 0 20px; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 70 - .sidebar-subtitle { font-size: 0.6875rem; color: var(--secondary-color); padding: 0 20px; margin-bottom: 20px; } 71 - .sidebar nav { flex: 1; } 72 - .sidebar nav a { display: block; padding: 8px 20px; font-size: 0.8125rem; color: var(--secondary-color); text-decoration: none; transition: background 0.1s, color 0.1s; } 73 - .sidebar nav a:hover { background: var(--bg-secondary-color); color: var(--primary-color); } 74 - .sidebar nav a.active { color: var(--brand-color); font-weight: 500; } 75 - .sidebar-footer { padding: 16px 20px 0; border-top: 1px solid var(--border-color); margin-top: 16px; } 76 - .sidebar-footer .session-info { font-size: 0.75rem; color: var(--secondary-color); margin-bottom: 8px; } 77 - .sidebar-footer form { display: inline; } 78 - .sidebar-footer button { background: none; border: none; font-size: 0.75rem; color: var(--secondary-color); cursor: pointer; padding: 0; text-decoration: underline; } 79 - .sidebar-footer button:hover { color: var(--primary-color); } 78 + .sidebar-title { 79 + font-size: 0.8125rem; 80 + font-weight: 700; 81 + padding: 0 20px; 82 + margin-bottom: 4px; 83 + white-space: nowrap; 84 + overflow: hidden; 85 + text-overflow: ellipsis; 86 + } 80 87 81 - .main { margin-left: 220px; flex: 1; padding: 32px; max-width: 960px; } 82 - .page-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 24px; } 88 + .sidebar-subtitle { 89 + font-size: 0.6875rem; 90 + color: var(--secondary-color); 91 + padding: 0 20px; 92 + margin-bottom: 20px; 93 + } 83 94 84 - .flash-success { background: rgba(22, 163, 74, 0.1); color: var(--success-color); border: 1px solid rgba(22, 163, 74, 0.2); border-radius: 8px; padding: 10px 14px; font-size: 0.875rem; margin-bottom: 20px; } 85 - .flash-error { background: rgba(220, 38, 38, 0.1); color: var(--danger-color); border: 1px solid rgba(220, 38, 38, 0.2); border-radius: 8px; padding: 10px 14px; font-size: 0.875rem; margin-bottom: 20px; } 95 + .sidebar nav { 96 + flex: 1; 97 + } 86 98 87 - .search-form { display: flex; gap: 8px; margin-bottom: 24px; } 88 - .search-form input { flex: 1; padding: 10px 12px; font-size: 0.875rem; border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-primary-color); color: var(--primary-color); outline: none; } 89 - .search-form input:focus { border-color: var(--brand-color); } 90 - .btn { display: inline-flex; align-items: center; justify-content: center; padding: 10px 20px; font-size: 0.875rem; font-weight: 500; border: none; border-radius: 8px; cursor: pointer; transition: opacity 0.15s; text-decoration: none; } 91 - .btn:hover { opacity: 0.85; } 92 - .btn-primary { background: var(--brand-color); color: #fff; } 99 + .sidebar nav a { 100 + display: block; 101 + padding: 8px 20px; 102 + font-size: 0.8125rem; 103 + color: var(--secondary-color); 104 + text-decoration: none; 105 + transition: background 0.1s, color 0.1s; 106 + } 93 107 94 - .table-container { background: var(--bg-primary-color); border: 1px solid var(--border-color); border-radius: 10px; overflow: hidden; } 95 - table { width: 100%; border-collapse: collapse; } 96 - thead th { text-align: left; padding: 12px 16px; font-size: 0.75rem; font-weight: 600; color: var(--secondary-color); text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid var(--border-color); } 97 - tbody tr { border-bottom: 1px solid var(--border-color); } 98 - tbody tr:last-child { border-bottom: none; } 99 - tbody tr:nth-child(even) { background: var(--table-stripe); } 100 - tbody td { padding: 10px 16px; font-size: 0.8125rem; } 101 - tbody td a { color: var(--brand-color); text-decoration: none; } 102 - tbody td a:hover { text-decoration: underline; } 103 - .empty-state { text-align: center; padding: 40px 20px; color: var(--secondary-color); font-size: 0.875rem; } 104 - .did-cell { font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.75rem; color: var(--secondary-color); } 108 + .sidebar nav a:hover { 109 + background: var(--bg-secondary-color); 110 + color: var(--primary-color); 111 + } 112 + 113 + .sidebar nav a.active { 114 + color: var(--brand-color); 115 + font-weight: 500; 116 + } 117 + 118 + .sidebar-footer { 119 + padding: 16px 20px 0; 120 + border-top: 1px solid var(--border-color); 121 + margin-top: 16px; 122 + } 123 + 124 + .sidebar-footer .session-info { 125 + font-size: 0.75rem; 126 + color: var(--secondary-color); 127 + margin-bottom: 8px; 128 + } 129 + 130 + .sidebar-footer form { 131 + display: inline; 132 + } 133 + 134 + .sidebar-footer button { 135 + background: none; 136 + border: none; 137 + font-size: 0.75rem; 138 + color: var(--secondary-color); 139 + cursor: pointer; 140 + padding: 0; 141 + text-decoration: underline; 142 + } 143 + 144 + .sidebar-footer button:hover { 145 + color: var(--primary-color); 146 + } 147 + 148 + .main { 149 + margin-left: 220px; 150 + flex: 1; 151 + padding: 32px; 152 + max-width: 960px; 153 + } 154 + 155 + .page-title { 156 + font-size: 1.5rem; 157 + font-weight: 700; 158 + margin-bottom: 24px; 159 + } 160 + 161 + .flash-success { 162 + background: rgba(22, 163, 74, 0.1); 163 + color: var(--success-color); 164 + border: 1px solid rgba(22, 163, 74, 0.2); 165 + border-radius: 8px; 166 + padding: 10px 14px; 167 + font-size: 0.875rem; 168 + margin-bottom: 20px; 169 + } 170 + 171 + .flash-error { 172 + background: rgba(220, 38, 38, 0.1); 173 + color: var(--danger-color); 174 + border: 1px solid rgba(220, 38, 38, 0.2); 175 + border-radius: 8px; 176 + padding: 10px 14px; 177 + font-size: 0.875rem; 178 + margin-bottom: 20px; 179 + } 180 + 181 + .search-form { 182 + display: flex; 183 + gap: 8px; 184 + margin-bottom: 24px; 185 + } 186 + 187 + .search-form input { 188 + flex: 1; 189 + padding: 10px 12px; 190 + font-size: 0.875rem; 191 + border: 1px solid var(--border-color); 192 + border-radius: 8px; 193 + background: var(--bg-primary-color); 194 + color: var(--primary-color); 195 + outline: none; 196 + } 197 + 198 + .search-form input:focus { 199 + border-color: var(--brand-color); 200 + } 201 + 202 + .btn { 203 + display: inline-flex; 204 + align-items: center; 205 + justify-content: center; 206 + padding: 10px 20px; 207 + font-size: 0.875rem; 208 + font-weight: 500; 209 + border: none; 210 + border-radius: 8px; 211 + cursor: pointer; 212 + transition: opacity 0.15s; 213 + text-decoration: none; 214 + } 215 + 216 + .btn:hover { 217 + opacity: 0.85; 218 + } 219 + 220 + .btn-primary { 221 + background: var(--brand-color); 222 + color: #fff; 223 + } 224 + 225 + .table-container { 226 + background: var(--bg-primary-color); 227 + border: 1px solid var(--border-color); 228 + border-radius: 10px; 229 + overflow: hidden; 230 + } 231 + 232 + table { 233 + width: 100%; 234 + border-collapse: collapse; 235 + } 236 + 237 + thead th { 238 + text-align: left; 239 + padding: 12px 16px; 240 + font-size: 0.75rem; 241 + font-weight: 600; 242 + color: var(--secondary-color); 243 + text-transform: uppercase; 244 + letter-spacing: 0.5px; 245 + border-bottom: 1px solid var(--border-color); 246 + } 247 + 248 + tbody tr { 249 + border-bottom: 1px solid var(--border-color); 250 + } 251 + 252 + tbody tr:last-child { 253 + border-bottom: none; 254 + } 255 + 256 + tbody tr:nth-child(even) { 257 + background: var(--table-stripe); 258 + } 259 + 260 + tbody td { 261 + padding: 10px 16px; 262 + font-size: 0.8125rem; 263 + } 264 + 265 + tbody td a { 266 + color: var(--brand-color); 267 + text-decoration: none; 268 + } 269 + 270 + tbody td a:hover { 271 + text-decoration: underline; 272 + } 273 + 274 + .empty-state { 275 + text-align: center; 276 + padding: 40px 20px; 277 + color: var(--secondary-color); 278 + font-size: 0.875rem; 279 + } 280 + 281 + .did-cell { 282 + font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; 283 + font-size: 0.75rem; 284 + color: var(--secondary-color); 285 + } 105 286 106 287 @media (max-width: 768px) { 107 - .sidebar { display: none; } 108 - .main { margin-left: 0; } 288 + .sidebar { 289 + display: none; 290 + } 291 + 292 + .main { 293 + margin-left: 0; 294 + } 109 295 } 110 296 </style> 111 297 </head> 112 298 <body> 113 - <div class="layout"> 114 - <aside class="sidebar"> 115 - <div class="sidebar-title">{{pds_hostname}}</div> 116 - <div class="sidebar-subtitle">Admin Portal</div> 117 - <nav> 118 - <a href="/admin/">Dashboard</a> 119 - {{#if can_view_accounts}} 299 + <div class="layout"> 300 + <aside class="sidebar"> 301 + <div class="sidebar-title">{{pds_hostname}}</div> 302 + <div class="sidebar-subtitle">Admin Portal</div> 303 + <nav> 304 + <a href="/admin/dashboard">Dashboard</a> 305 + {{#if can_view_accounts}} 120 306 <a href="/admin/accounts" class="active">Accounts</a> 121 - {{/if}} 122 - {{#if can_manage_invites}} 307 + {{/if}} 308 + {{#if can_manage_invites}} 123 309 <a href="/admin/invite-codes">Invite Codes</a> 124 - {{/if}} 125 - {{#if can_create_account}} 310 + {{/if}} 311 + {{#if can_create_account}} 126 312 <a href="/admin/create-account">Create Account</a> 127 - {{/if}} 128 - {{#if can_request_crawl}} 313 + {{/if}} 314 + {{#if can_request_crawl}} 129 315 <a href="/admin/request-crawl">Request Crawl</a> 130 - {{/if}} 131 - </nav> 132 - <div class="sidebar-footer"> 133 - <div class="session-info">Signed in as {{handle}}</div> 134 - <form method="POST" action="/admin/logout"> 135 - <button type="submit">Sign out</button> 136 - </form> 137 - </div> 138 - </aside> 316 + {{/if}} 317 + </nav> 318 + <div class="sidebar-footer"> 319 + <div class="session-info">Signed in as {{handle}}</div> 320 + <form method="POST" action="/admin/logout"> 321 + <button type="submit">Sign out</button> 322 + </form> 323 + </div> 324 + </aside> 139 325 140 - <main class="main"> 141 - {{#if flash_success}} 326 + <main class="main"> 327 + {{#if flash_success}} 142 328 <div class="flash-success">{{flash_success}}</div> 143 - {{/if}} 144 - {{#if flash_error}} 329 + {{/if}} 330 + {{#if flash_error}} 145 331 <div class="flash-error">{{flash_error}}</div> 146 - {{/if}} 332 + {{/if}} 147 333 148 - <h1 class="page-title">Accounts</h1> 334 + <h1 class="page-title">Accounts</h1> 149 335 150 - <form class="search-form" method="GET" action="/admin/search"> 151 - <input type="text" name="q" placeholder="Search by email..." value="{{search_query}}" /> 152 - <button type="submit" class="btn btn-primary">Search</button> 153 - </form> 336 + <form class="search-form" method="GET" action="/admin/search"> 337 + <input type="text" name="q" placeholder="Search by email..." value="{{search_query}}"/> 338 + <button type="submit" class="btn btn-primary">Search</button> 339 + </form> 154 340 155 - {{#if accounts}} 341 + {{#if accounts}} 156 342 <div class="table-container"> 157 343 <table> 158 344 <thead> 159 - <tr> 160 - <th>Handle</th> 161 - <th>DID</th> 162 - <th>Email</th> 163 - </tr> 345 + <tr> 346 + <th>Handle</th> 347 + <th>DID</th> 348 + <th>Email</th> 349 + </tr> 164 350 </thead> 165 351 <tbody> 166 - {{#each accounts}} 352 + {{#each accounts}} 167 353 <tr> 168 354 <td><a href="/admin/accounts/{{this.did}}">{{this.handle}}</a></td> 169 355 <td class="did-cell">{{this.did}}</td> 170 356 <td>{{this.email}}</td> 171 357 </tr> 172 - {{/each}} 358 + {{/each}} 173 359 </tbody> 174 360 </table> 175 361 </div> 176 - {{else}} 362 + {{else}} 177 363 <div class="empty-state"> 178 364 {{#if search_query}} 179 - No accounts matching "{{search_query}}" 365 + No accounts matching "{{search_query}}" 180 366 {{else}} 181 - No accounts found 367 + No accounts found 182 368 {{/if}} 183 369 </div> 184 - {{/if}} 185 - </main> 186 - </div> 370 + {{/if}} 371 + </main> 372 + </div> 187 373 </body> 188 374 </html>
+317 -86
html_templates/admin/create_account.hbs
··· 42 42 --table-stripe: rgba(255, 255, 255, 0.02); 43 43 } 44 44 45 - * { margin: 0; padding: 0; box-sizing: border-box; } 45 + * { 46 + margin: 0; 47 + padding: 0; 48 + box-sizing: border-box; 49 + } 46 50 47 51 body { 48 52 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; ··· 52 56 -webkit-font-smoothing: antialiased; 53 57 } 54 58 55 - .layout { display: flex; min-height: 100vh; } 59 + .layout { 60 + display: flex; 61 + min-height: 100vh; 62 + } 63 + 64 + .sidebar { 65 + width: 220px; 66 + background: var(--bg-primary-color); 67 + border-right: 1px solid var(--border-color); 68 + padding: 20px 0; 69 + position: fixed; 70 + top: 0; 71 + left: 0; 72 + bottom: 0; 73 + overflow-y: auto; 74 + display: flex; 75 + flex-direction: column; 76 + } 77 + 78 + .sidebar-title { 79 + font-size: 0.8125rem; 80 + font-weight: 700; 81 + padding: 0 20px; 82 + margin-bottom: 4px; 83 + white-space: nowrap; 84 + overflow: hidden; 85 + text-overflow: ellipsis; 86 + } 87 + 88 + .sidebar-subtitle { 89 + font-size: 0.6875rem; 90 + color: var(--secondary-color); 91 + padding: 0 20px; 92 + margin-bottom: 20px; 93 + } 94 + 95 + .sidebar nav { 96 + flex: 1; 97 + } 98 + 99 + .sidebar nav a { 100 + display: block; 101 + padding: 8px 20px; 102 + font-size: 0.8125rem; 103 + color: var(--secondary-color); 104 + text-decoration: none; 105 + transition: background 0.1s, color 0.1s; 106 + } 107 + 108 + .sidebar nav a:hover { 109 + background: var(--bg-secondary-color); 110 + color: var(--primary-color); 111 + } 112 + 113 + .sidebar nav a.active { 114 + color: var(--brand-color); 115 + font-weight: 500; 116 + } 56 117 57 - .sidebar { width: 220px; background: var(--bg-primary-color); border-right: 1px solid var(--border-color); padding: 20px 0; position: fixed; top: 0; left: 0; bottom: 0; overflow-y: auto; display: flex; flex-direction: column; } 58 - .sidebar-title { font-size: 0.8125rem; font-weight: 700; padding: 0 20px; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 59 - .sidebar-subtitle { font-size: 0.6875rem; color: var(--secondary-color); padding: 0 20px; margin-bottom: 20px; } 60 - .sidebar nav { flex: 1; } 61 - .sidebar nav a { display: block; padding: 8px 20px; font-size: 0.8125rem; color: var(--secondary-color); text-decoration: none; transition: background 0.1s, color 0.1s; } 62 - .sidebar nav a:hover { background: var(--bg-secondary-color); color: var(--primary-color); } 63 - .sidebar nav a.active { color: var(--brand-color); font-weight: 500; } 64 - .sidebar-footer { padding: 16px 20px 0; border-top: 1px solid var(--border-color); margin-top: 16px; } 65 - .sidebar-footer .session-info { font-size: 0.75rem; color: var(--secondary-color); margin-bottom: 8px; } 66 - .sidebar-footer form { display: inline; } 67 - .sidebar-footer button { background: none; border: none; font-size: 0.75rem; color: var(--secondary-color); cursor: pointer; padding: 0; text-decoration: underline; } 68 - .sidebar-footer button:hover { color: var(--primary-color); } 118 + .sidebar-footer { 119 + padding: 16px 20px 0; 120 + border-top: 1px solid var(--border-color); 121 + margin-top: 16px; 122 + } 123 + 124 + .sidebar-footer .session-info { 125 + font-size: 0.75rem; 126 + color: var(--secondary-color); 127 + margin-bottom: 8px; 128 + } 129 + 130 + .sidebar-footer form { 131 + display: inline; 132 + } 133 + 134 + .sidebar-footer button { 135 + background: none; 136 + border: none; 137 + font-size: 0.75rem; 138 + color: var(--secondary-color); 139 + cursor: pointer; 140 + padding: 0; 141 + text-decoration: underline; 142 + } 143 + 144 + .sidebar-footer button:hover { 145 + color: var(--primary-color); 146 + } 147 + 148 + .main { 149 + margin-left: 220px; 150 + flex: 1; 151 + padding: 32px; 152 + max-width: 960px; 153 + } 154 + 155 + .page-title { 156 + font-size: 1.5rem; 157 + font-weight: 700; 158 + margin-bottom: 24px; 159 + } 160 + 161 + .flash-success { 162 + background: rgba(22, 163, 74, 0.1); 163 + color: var(--success-color); 164 + border: 1px solid rgba(22, 163, 74, 0.2); 165 + border-radius: 8px; 166 + padding: 10px 14px; 167 + font-size: 0.875rem; 168 + margin-bottom: 20px; 169 + } 170 + 171 + .flash-error { 172 + background: rgba(220, 38, 38, 0.1); 173 + color: var(--danger-color); 174 + border: 1px solid rgba(220, 38, 38, 0.2); 175 + border-radius: 8px; 176 + padding: 10px 14px; 177 + font-size: 0.875rem; 178 + margin-bottom: 20px; 179 + } 180 + 181 + .form-card { 182 + background: var(--bg-primary-color); 183 + border: 1px solid var(--border-color); 184 + border-radius: 10px; 185 + padding: 24px; 186 + max-width: 480px; 187 + } 188 + 189 + .form-group { 190 + margin-bottom: 16px; 191 + } 192 + 193 + .form-group label { 194 + display: block; 195 + font-size: 0.8125rem; 196 + font-weight: 500; 197 + margin-bottom: 6px; 198 + color: var(--primary-color); 199 + } 200 + 201 + .form-group input { 202 + width: 100%; 203 + padding: 10px 12px; 204 + font-size: 0.875rem; 205 + border: 1px solid var(--border-color); 206 + border-radius: 8px; 207 + background: var(--bg-primary-color); 208 + color: var(--primary-color); 209 + outline: none; 210 + transition: border-color 0.15s; 211 + } 212 + 213 + .form-group input:focus { 214 + border-color: var(--brand-color); 215 + } 216 + 217 + .form-group .hint { 218 + font-size: 0.75rem; 219 + color: var(--secondary-color); 220 + margin-top: 4px; 221 + } 222 + 223 + .btn { 224 + display: inline-flex; 225 + align-items: center; 226 + justify-content: center; 227 + padding: 10px 20px; 228 + font-size: 0.875rem; 229 + font-weight: 500; 230 + border: none; 231 + border-radius: 8px; 232 + cursor: pointer; 233 + transition: opacity 0.15s; 234 + text-decoration: none; 235 + } 236 + 237 + .btn:hover { 238 + opacity: 0.85; 239 + } 240 + 241 + .btn-primary { 242 + background: var(--brand-color); 243 + color: #fff; 244 + } 245 + 246 + .success-card { 247 + background: var(--bg-primary-color); 248 + border: 1px solid var(--border-color); 249 + border-radius: 10px; 250 + padding: 24px; 251 + max-width: 480px; 252 + } 69 253 70 - .main { margin-left: 220px; flex: 1; padding: 32px; max-width: 960px; } 71 - .page-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 24px; } 254 + .success-card h3 { 255 + font-size: 1rem; 256 + font-weight: 600; 257 + color: var(--success-color); 258 + margin-bottom: 16px; 259 + } 72 260 73 - .flash-success { background: rgba(22, 163, 74, 0.1); color: var(--success-color); border: 1px solid rgba(22, 163, 74, 0.2); border-radius: 8px; padding: 10px 14px; font-size: 0.875rem; margin-bottom: 20px; } 74 - .flash-error { background: rgba(220, 38, 38, 0.1); color: var(--danger-color); border: 1px solid rgba(220, 38, 38, 0.2); border-radius: 8px; padding: 10px 14px; font-size: 0.875rem; margin-bottom: 20px; } 261 + .detail-row { 262 + display: flex; 263 + justify-content: space-between; 264 + align-items: center; 265 + padding: 8px 0; 266 + font-size: 0.8125rem; 267 + border-bottom: 1px solid var(--border-color); 268 + } 75 269 76 - .form-card { background: var(--bg-primary-color); border: 1px solid var(--border-color); border-radius: 10px; padding: 24px; max-width: 480px; } 77 - .form-group { margin-bottom: 16px; } 78 - .form-group label { display: block; font-size: 0.8125rem; font-weight: 500; margin-bottom: 6px; color: var(--primary-color); } 79 - .form-group input { width: 100%; padding: 10px 12px; font-size: 0.875rem; border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-primary-color); color: var(--primary-color); outline: none; transition: border-color 0.15s; } 80 - .form-group input:focus { border-color: var(--brand-color); } 81 - .form-group .hint { font-size: 0.75rem; color: var(--secondary-color); margin-top: 4px; } 270 + .detail-row:last-child { 271 + border-bottom: none; 272 + } 82 273 83 - .btn { display: inline-flex; align-items: center; justify-content: center; padding: 10px 20px; font-size: 0.875rem; font-weight: 500; border: none; border-radius: 8px; cursor: pointer; transition: opacity 0.15s; text-decoration: none; } 84 - .btn:hover { opacity: 0.85; } 85 - .btn-primary { background: var(--brand-color); color: #fff; } 274 + .detail-row .label { 275 + color: var(--secondary-color); 276 + } 86 277 87 - .success-card { background: var(--bg-primary-color); border: 1px solid var(--border-color); border-radius: 10px; padding: 24px; max-width: 480px; } 88 - .success-card h3 { font-size: 1rem; font-weight: 600; color: var(--success-color); margin-bottom: 16px; } 278 + .detail-row .value { 279 + font-weight: 500; 280 + word-break: break-all; 281 + text-align: right; 282 + max-width: 65%; 283 + display: flex; 284 + align-items: center; 285 + gap: 6px; 286 + } 89 287 90 - .detail-row { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; font-size: 0.8125rem; border-bottom: 1px solid var(--border-color); } 91 - .detail-row:last-child { border-bottom: none; } 92 - .detail-row .label { color: var(--secondary-color); } 93 - .detail-row .value { font-weight: 500; word-break: break-all; text-align: right; max-width: 65%; display: flex; align-items: center; gap: 6px; } 288 + .password-highlight { 289 + font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; 290 + background: rgba(22, 163, 74, 0.08); 291 + padding: 2px 6px; 292 + border-radius: 4px; 293 + user-select: all; 294 + } 94 295 95 - .password-highlight { font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; background: rgba(22, 163, 74, 0.08); padding: 2px 6px; border-radius: 4px; user-select: all; } 296 + .copy-btn { 297 + background: none; 298 + border: 1px solid var(--border-color); 299 + border-radius: 4px; 300 + padding: 2px 6px; 301 + font-size: 0.6875rem; 302 + cursor: pointer; 303 + color: var(--secondary-color); 304 + transition: color 0.15s, border-color 0.15s; 305 + white-space: nowrap; 306 + } 96 307 97 - .copy-btn { background: none; border: 1px solid var(--border-color); border-radius: 4px; padding: 2px 6px; font-size: 0.6875rem; cursor: pointer; color: var(--secondary-color); transition: color 0.15s, border-color 0.15s; white-space: nowrap; } 98 - .copy-btn:hover { color: var(--primary-color); border-color: var(--primary-color); } 308 + .copy-btn:hover { 309 + color: var(--primary-color); 310 + border-color: var(--primary-color); 311 + } 99 312 100 313 @media (max-width: 768px) { 101 - .sidebar { display: none; } 102 - .main { margin-left: 0; } 314 + .sidebar { 315 + display: none; 316 + } 317 + 318 + .main { 319 + margin-left: 0; 320 + } 103 321 } 104 322 </style> 105 323 </head> 106 324 <body> 107 - <div class="layout"> 108 - <aside class="sidebar"> 109 - <div class="sidebar-title">{{pds_hostname}}</div> 110 - <div class="sidebar-subtitle">Admin Portal</div> 111 - <nav> 112 - <a href="/admin/">Dashboard</a> 113 - {{#if can_view_accounts}} 325 + <div class="layout"> 326 + <aside class="sidebar"> 327 + <div class="sidebar-title">{{pds_hostname}}</div> 328 + <div class="sidebar-subtitle">Admin Portal</div> 329 + <nav> 330 + <a href="/admin/dashboard">Dashboard</a> 331 + {{#if can_view_accounts}} 114 332 <a href="/admin/accounts">Accounts</a> 115 - {{/if}} 116 - {{#if can_manage_invites}} 333 + {{/if}} 334 + {{#if can_manage_invites}} 117 335 <a href="/admin/invite-codes">Invite Codes</a> 118 - {{/if}} 119 - {{#if can_create_account}} 336 + {{/if}} 337 + {{#if can_create_account}} 120 338 <a href="/admin/create-account" class="active">Create Account</a> 121 - {{/if}} 122 - {{#if can_request_crawl}} 339 + {{/if}} 340 + {{#if can_request_crawl}} 123 341 <a href="/admin/request-crawl">Request Crawl</a> 124 - {{/if}} 125 - </nav> 126 - <div class="sidebar-footer"> 127 - <div class="session-info">Signed in as {{handle}}</div> 128 - <form method="POST" action="/admin/logout"> 129 - <button type="submit">Sign out</button> 130 - </form> 131 - </div> 132 - </aside> 342 + {{/if}} 343 + </nav> 344 + <div class="sidebar-footer"> 345 + <div class="session-info">Signed in as {{handle}}</div> 346 + <form method="POST" action="/admin/logout"> 347 + <button type="submit">Sign out</button> 348 + </form> 349 + </div> 350 + </aside> 133 351 134 - <main class="main"> 135 - {{#if flash_success}} 352 + <main class="main"> 353 + {{#if flash_success}} 136 354 <div class="flash-success">{{flash_success}}</div> 137 - {{/if}} 138 - {{#if flash_error}} 355 + {{/if}} 356 + {{#if flash_error}} 139 357 <div class="flash-error">{{flash_error}}</div> 140 - {{/if}} 358 + {{/if}} 141 359 142 - <h1 class="page-title">Create Account</h1> 360 + <h1 class="page-title">Create Account</h1> 143 361 144 - {{#if created}} 362 + {{#if created}} 145 363 <div class="success-card"> 146 364 <h3>Account Created Successfully</h3> 147 365 <div class="detail-row"> 148 366 <span class="label">DID</span> 149 - <span class="value">{{created.did}} <button class="copy-btn" onclick="copyToClipboard('{{created.did}}', this)">Copy</button></span> 367 + <span class="value">{{created.did}} 368 + <button class="copy-btn" onclick="copyToClipboard('{{created.did}}', this)">Copy</button></span> 150 369 </div> 151 370 <div class="detail-row"> 152 371 <span class="label">Handle</span> 153 - <span class="value">{{created.handle}} <button class="copy-btn" onclick="copyToClipboard('{{created.handle}}', this)">Copy</button></span> 372 + <span class="value">{{created.handle}} 373 + <button class="copy-btn" 374 + onclick="copyToClipboard('{{created.handle}}', this)">Copy</button></span> 154 375 </div> 155 376 <div class="detail-row"> 156 377 <span class="label">Email</span> 157 - <span class="value">{{created.email}} <button class="copy-btn" onclick="copyToClipboard('{{created.email}}', this)">Copy</button></span> 378 + <span class="value">{{created.email}} 379 + <button class="copy-btn" 380 + onclick="copyToClipboard('{{created.email}}', this)">Copy</button></span> 158 381 </div> 159 382 <div class="detail-row"> 160 383 <span class="label">Password</span> 161 - <span class="value"><span class="password-highlight">{{created.password}}</span> <button class="copy-btn" onclick="copyToClipboard('{{created.password}}', this)">Copy</button></span> 384 + <span class="value"><span class="password-highlight">{{created.password}}</span> <button 385 + class="copy-btn" 386 + onclick="copyToClipboard('{{created.password}}', this)">Copy</button></span> 162 387 </div> 163 388 {{#if created.inviteCode}} 164 - <div class="detail-row"> 165 - <span class="label">Invite Code Used</span> 166 - <span class="value">{{created.inviteCode}} <button class="copy-btn" onclick="copyToClipboard('{{created.inviteCode}}', this)">Copy</button></span> 167 - </div> 389 + <div class="detail-row"> 390 + <span class="label">Invite Code Used</span> 391 + <span class="value">{{created.inviteCode}} 392 + <button class="copy-btn" 393 + onclick="copyToClipboard('{{created.inviteCode}}', this)">Copy</button></span> 394 + </div> 168 395 {{/if}} 169 396 </div> 170 - {{else}} 397 + {{else}} 171 398 <div class="form-card"> 172 399 <form method="POST" action="/admin/create-account"> 173 400 <div class="form-group"> 174 401 <label for="email">Email</label> 175 - <input type="email" id="email" name="email" placeholder="user@example.com" required /> 402 + <input type="email" id="email" name="email" placeholder="user@example.com" required/> 176 403 </div> 177 404 <div class="form-group"> 178 405 <label for="handle">Handle</label> 179 - <input type="text" id="handle" name="handle" placeholder="user.example.com" required /> 406 + <input type="text" id="handle" name="handle" placeholder="user.example.com" required/> 180 407 <div class="hint">Must be a valid handle for this PDS</div> 181 408 </div> 182 409 <button type="submit" class="btn btn-primary">Create Account</button> 183 410 </form> 184 411 </div> 185 - {{/if}} 186 - </main> 187 - </div> 412 + {{/if}} 413 + </main> 414 + </div> 188 415 189 - <script> 416 + <script> 190 417 function copyToClipboard(text, btn) { 191 418 if (navigator.clipboard && navigator.clipboard.writeText) { 192 - navigator.clipboard.writeText(text).then(function() { 419 + navigator.clipboard.writeText(text).then(function () { 193 420 var orig = btn.textContent; 194 421 btn.textContent = 'Copied'; 195 - setTimeout(function() { btn.textContent = orig; }, 1500); 422 + setTimeout(function () { 423 + btn.textContent = orig; 424 + }, 1500); 196 425 }); 197 426 } else { 198 427 var el = document.createElement('textarea'); ··· 205 434 document.body.removeChild(el); 206 435 var orig = btn.textContent; 207 436 btn.textContent = 'Copied'; 208 - setTimeout(function() { btn.textContent = orig; }, 1500); 437 + setTimeout(function () { 438 + btn.textContent = orig; 439 + }, 1500); 209 440 } 210 441 } 211 - </script> 442 + </script> 212 443 </body> 213 444 </html>
+91 -78
html_templates/admin/dashboard.hbs
··· 42 42 --table-stripe: rgba(255, 255, 255, 0.02); 43 43 } 44 44 45 - * { margin: 0; padding: 0; box-sizing: border-box; } 45 + * { 46 + margin: 0; 47 + padding: 0; 48 + box-sizing: border-box; 49 + } 46 50 47 51 body { 48 52 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; ··· 202 206 font-weight: 600; 203 207 } 204 208 205 - .card-value.success { color: var(--success-color); } 206 - .card-value.danger { color: var(--danger-color); } 209 + .card-value.success { 210 + color: var(--success-color); 211 + } 212 + 213 + .card-value.danger { 214 + color: var(--danger-color); 215 + } 207 216 208 217 .detail-section { 209 218 background: var(--bg-primary-color); ··· 255 264 .sidebar { 256 265 display: none; 257 266 } 267 + 258 268 .main { 259 269 margin-left: 0; 260 270 } ··· 262 272 </style> 263 273 </head> 264 274 <body> 265 - <div class="layout"> 266 - <aside class="sidebar"> 267 - <div class="sidebar-title">{{pds_hostname}}</div> 268 - <div class="sidebar-subtitle">Admin Portal</div> 269 - <nav> 270 - <a href="/admin/" class="active">Dashboard</a> 271 - {{#if can_view_accounts}} 275 + <div class="layout"> 276 + <aside class="sidebar"> 277 + <div class="sidebar-title">{{pds_hostname}}</div> 278 + <div class="sidebar-subtitle">Admin Portal</div> 279 + <nav> 280 + <a href="/admin/dashboard" class="active">Dashboard</a> 281 + {{#if can_view_accounts}} 272 282 <a href="/admin/accounts">Accounts</a> 273 - {{/if}} 274 - {{#if can_manage_invites}} 283 + {{/if}} 284 + {{#if can_manage_invites}} 275 285 <a href="/admin/invite-codes">Invite Codes</a> 276 - {{/if}} 277 - {{#if can_create_account}} 286 + {{/if}} 287 + {{#if can_create_account}} 278 288 <a href="/admin/create-account">Create Account</a> 279 - {{/if}} 280 - {{#if can_request_crawl}} 289 + {{/if}} 290 + {{#if can_request_crawl}} 281 291 <a href="/admin/request-crawl">Request Crawl</a> 282 - {{/if}} 283 - </nav> 284 - <div class="sidebar-footer"> 285 - <div class="session-info">Signed in as {{handle}}</div> 286 - <form method="POST" action="/admin/logout"> 287 - <button type="submit">Sign out</button> 288 - </form> 289 - </div> 290 - </aside> 292 + {{/if}} 293 + </nav> 294 + <div class="sidebar-footer"> 295 + <div class="session-info">Signed in as {{handle}}</div> 296 + <form method="POST" action="/admin/logout"> 297 + <button type="submit">Sign out</button> 298 + </form> 299 + </div> 300 + </aside> 291 301 292 - <main class="main"> 293 - {{#if flash_success}} 302 + <main class="main"> 303 + {{#if flash_success}} 294 304 <div class="flash-success">{{flash_success}}</div> 295 - {{/if}} 296 - {{#if flash_error}} 305 + {{/if}} 306 + {{#if flash_error}} 297 307 <div class="flash-error">{{flash_error}}</div> 298 - {{/if}} 308 + {{/if}} 299 309 300 - <h1 class="page-title">Dashboard</h1> 310 + <h1 class="page-title">Dashboard</h1> 301 311 302 - <div class="cards"> 303 - <div class="card"> 304 - <div class="card-label">PDS Version</div> 305 - <div class="card-value">{{version}}</div> 306 - </div> 307 - {{#if can_view_accounts}} 312 + <div class="cards"> 313 + <div class="card"> 314 + <div class="card-label">PDS Version</div> 315 + <div class="card-value">{{version}}</div> 316 + </div> 317 + {{#if can_view_accounts}} 308 318 <div class="card"> 309 319 <div class="card-label">Total Accounts</div> 310 320 <div class="card-value">{{account_count}}</div> 311 321 </div> 312 - {{/if}} 313 - <div class="card"> 314 - <div class="card-label">Invite Code Required</div> 315 - <div class="card-value">{{#if invite_code_required}}Yes{{else}}No{{/if}}</div> 316 - </div> 317 - <div class="card"> 318 - <div class="card-label">Phone Verification</div> 319 - <div class="card-value">{{#if phone_verification_required}}Required{{else}}Not Required{{/if}}</div> 320 - </div> 322 + {{/if}} 323 + <div class="card"> 324 + <div class="card-label">Invite Code Required</div> 325 + <div class="card-value">{{#if invite_code_required}}Yes{{else}}No{{/if}}</div> 326 + </div> 327 + <div class="card"> 328 + <div class="card-label">Phone Verification</div> 329 + <div class="card-value">{{#if phone_verification_required}}Required{{else}}Not Required{{/if}}</div> 321 330 </div> 331 + </div> 322 332 323 - <div class="detail-section"> 324 - <h3>Server Information</h3> 325 - <div class="detail-row"> 326 - <span class="label">Server DID</span> 327 - <span class="value">{{server_did}}</span> 328 - </div> 329 - <div class="detail-row"> 330 - <span class="label">Available Domains</span> 331 - <span class="value">{{available_domains}}</span> 332 - </div> 333 - {{#if contact_email}} 333 + <div class="detail-section"> 334 + <h3>Server Information</h3> 335 + <div class="detail-row"> 336 + <span class="label">Server DID</span> 337 + <span class="value">{{server_did}}</span> 338 + </div> 339 + <div class="detail-row"> 340 + <span class="label">Available Domains</span> 341 + <span class="value">{{available_domains}}</span> 342 + </div> 343 + {{#if contact_email}} 334 344 <div class="detail-row"> 335 345 <span class="label">Contact Email</span> 336 346 <span class="value">{{contact_email}}</span> 337 347 </div> 338 - {{/if}} 339 - </div> 348 + {{/if}} 349 + </div> 340 350 341 - {{#if privacy_policy}} 351 + {{#if privacy_policy}} 342 352 <div class="detail-section"> 343 353 <h3>Server Links</h3> 344 354 {{#if terms_of_service}} 345 - <div class="detail-row"> 346 - <span class="label">Terms of Service</span> 347 - <span class="value"><a href="{{terms_of_service}}" target="_blank" rel="noopener">{{terms_of_service}}</a></span> 348 - </div> 355 + <div class="detail-row"> 356 + <span class="label">Terms of Service</span> 357 + <span class="value"><a href="{{terms_of_service}}" target="_blank" 358 + rel="noopener">{{terms_of_service}}</a></span> 359 + </div> 349 360 {{/if}} 350 361 {{#if privacy_policy}} 351 - <div class="detail-row"> 352 - <span class="label">Privacy Policy</span> 353 - <span class="value"><a href="{{privacy_policy}}" target="_blank" rel="noopener">{{privacy_policy}}</a></span> 354 - </div> 362 + <div class="detail-row"> 363 + <span class="label">Privacy Policy</span> 364 + <span class="value"><a href="{{privacy_policy}}" target="_blank" 365 + rel="noopener">{{privacy_policy}}</a></span> 366 + </div> 355 367 {{/if}} 356 368 </div> 357 - {{else}} 369 + {{else}} 358 370 {{#if terms_of_service}} 359 - <div class="detail-section"> 360 - <h3>Server Links</h3> 361 - <div class="detail-row"> 362 - <span class="label">Terms of Service</span> 363 - <span class="value"><a href="{{terms_of_service}}" target="_blank" rel="noopener">{{terms_of_service}}</a></span> 371 + <div class="detail-section"> 372 + <h3>Server Links</h3> 373 + <div class="detail-row"> 374 + <span class="label">Terms of Service</span> 375 + <span class="value"><a href="{{terms_of_service}}" target="_blank" 376 + rel="noopener">{{terms_of_service}}</a></span> 377 + </div> 364 378 </div> 365 - </div> 366 - {{/if}} 367 379 {{/if}} 368 - </main> 369 - </div> 380 + {{/if}} 381 + </main> 382 + </div> 370 383 </body> 371 384 </html>
+363 -110
html_templates/admin/invite_codes.hbs
··· 42 42 --table-stripe: rgba(255, 255, 255, 0.02); 43 43 } 44 44 45 - * { margin: 0; padding: 0; box-sizing: border-box; } 45 + * { 46 + margin: 0; 47 + padding: 0; 48 + box-sizing: border-box; 49 + } 46 50 47 51 body { 48 52 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; ··· 52 56 -webkit-font-smoothing: antialiased; 53 57 } 54 58 55 - .layout { display: flex; min-height: 100vh; } 59 + .layout { 60 + display: flex; 61 + min-height: 100vh; 62 + } 63 + 64 + .sidebar { 65 + width: 220px; 66 + background: var(--bg-primary-color); 67 + border-right: 1px solid var(--border-color); 68 + padding: 20px 0; 69 + position: fixed; 70 + top: 0; 71 + left: 0; 72 + bottom: 0; 73 + overflow-y: auto; 74 + display: flex; 75 + flex-direction: column; 76 + } 77 + 78 + .sidebar-title { 79 + font-size: 0.8125rem; 80 + font-weight: 700; 81 + padding: 0 20px; 82 + margin-bottom: 4px; 83 + white-space: nowrap; 84 + overflow: hidden; 85 + text-overflow: ellipsis; 86 + } 87 + 88 + .sidebar-subtitle { 89 + font-size: 0.6875rem; 90 + color: var(--secondary-color); 91 + padding: 0 20px; 92 + margin-bottom: 20px; 93 + } 94 + 95 + .sidebar nav { 96 + flex: 1; 97 + } 98 + 99 + .sidebar nav a { 100 + display: block; 101 + padding: 8px 20px; 102 + font-size: 0.8125rem; 103 + color: var(--secondary-color); 104 + text-decoration: none; 105 + transition: background 0.1s, color 0.1s; 106 + } 107 + 108 + .sidebar nav a:hover { 109 + background: var(--bg-secondary-color); 110 + color: var(--primary-color); 111 + } 112 + 113 + .sidebar nav a.active { 114 + color: var(--brand-color); 115 + font-weight: 500; 116 + } 117 + 118 + .sidebar-footer { 119 + padding: 16px 20px 0; 120 + border-top: 1px solid var(--border-color); 121 + margin-top: 16px; 122 + } 123 + 124 + .sidebar-footer .session-info { 125 + font-size: 0.75rem; 126 + color: var(--secondary-color); 127 + margin-bottom: 8px; 128 + } 129 + 130 + .sidebar-footer form { 131 + display: inline; 132 + } 133 + 134 + .sidebar-footer button { 135 + background: none; 136 + border: none; 137 + font-size: 0.75rem; 138 + color: var(--secondary-color); 139 + cursor: pointer; 140 + padding: 0; 141 + text-decoration: underline; 142 + } 143 + 144 + .sidebar-footer button:hover { 145 + color: var(--primary-color); 146 + } 147 + 148 + .main { 149 + margin-left: 220px; 150 + flex: 1; 151 + padding: 32px; 152 + max-width: 960px; 153 + } 154 + 155 + .page-title { 156 + font-size: 1.5rem; 157 + font-weight: 700; 158 + margin-bottom: 24px; 159 + } 160 + 161 + .flash-success { 162 + background: rgba(22, 163, 74, 0.1); 163 + color: var(--success-color); 164 + border: 1px solid rgba(22, 163, 74, 0.2); 165 + border-radius: 8px; 166 + padding: 10px 14px; 167 + font-size: 0.875rem; 168 + margin-bottom: 20px; 169 + } 170 + 171 + .flash-error { 172 + background: rgba(220, 38, 38, 0.1); 173 + color: var(--danger-color); 174 + border: 1px solid rgba(220, 38, 38, 0.2); 175 + border-radius: 8px; 176 + padding: 10px 14px; 177 + font-size: 0.875rem; 178 + margin-bottom: 20px; 179 + } 180 + 181 + .create-form { 182 + display: flex; 183 + gap: 8px; 184 + align-items: flex-end; 185 + margin-bottom: 24px; 186 + } 187 + 188 + .create-form .form-group { 189 + display: flex; 190 + flex-direction: column; 191 + gap: 4px; 192 + } 193 + 194 + .create-form label { 195 + font-size: 0.75rem; 196 + font-weight: 500; 197 + color: var(--secondary-color); 198 + } 199 + 200 + .create-form input[type="number"] { 201 + padding: 10px 12px; 202 + font-size: 0.875rem; 203 + border: 1px solid var(--border-color); 204 + border-radius: 8px; 205 + background: var(--bg-primary-color); 206 + color: var(--primary-color); 207 + outline: none; 208 + width: 120px; 209 + } 210 + 211 + .create-form input[type="number"]:focus { 212 + border-color: var(--brand-color); 213 + } 214 + 215 + .btn { 216 + display: inline-flex; 217 + align-items: center; 218 + justify-content: center; 219 + padding: 10px 20px; 220 + font-size: 0.875rem; 221 + font-weight: 500; 222 + border: none; 223 + border-radius: 8px; 224 + cursor: pointer; 225 + transition: opacity 0.15s; 226 + text-decoration: none; 227 + } 228 + 229 + .btn:hover { 230 + opacity: 0.85; 231 + } 232 + 233 + .btn-primary { 234 + background: var(--brand-color); 235 + color: #fff; 236 + } 237 + 238 + .btn-small { 239 + padding: 6px 12px; 240 + font-size: 0.75rem; 241 + } 242 + 243 + .btn-outline-danger { 244 + background: transparent; 245 + color: var(--danger-color); 246 + border: 1px solid var(--danger-color); 247 + } 248 + 249 + .code-box { 250 + background: rgba(22, 163, 74, 0.08); 251 + border: 1px solid rgba(22, 163, 74, 0.2); 252 + border-radius: 10px; 253 + padding: 16px 20px; 254 + margin-bottom: 24px; 255 + } 256 + 257 + .code-box .code-label { 258 + font-size: 0.75rem; 259 + font-weight: 600; 260 + color: var(--success-color); 261 + margin-bottom: 6px; 262 + } 263 + 264 + .code-box .code-value { 265 + font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; 266 + font-size: 1rem; 267 + font-weight: 600; 268 + user-select: all; 269 + } 270 + 271 + .table-container { 272 + background: var(--bg-primary-color); 273 + border: 1px solid var(--border-color); 274 + border-radius: 10px; 275 + overflow: hidden; 276 + } 277 + 278 + table { 279 + width: 100%; 280 + border-collapse: collapse; 281 + } 282 + 283 + thead th { 284 + text-align: left; 285 + padding: 12px 16px; 286 + font-size: 0.75rem; 287 + font-weight: 600; 288 + color: var(--secondary-color); 289 + text-transform: uppercase; 290 + letter-spacing: 0.5px; 291 + border-bottom: 1px solid var(--border-color); 292 + } 56 293 57 - .sidebar { width: 220px; background: var(--bg-primary-color); border-right: 1px solid var(--border-color); padding: 20px 0; position: fixed; top: 0; left: 0; bottom: 0; overflow-y: auto; display: flex; flex-direction: column; } 58 - .sidebar-title { font-size: 0.8125rem; font-weight: 700; padding: 0 20px; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 59 - .sidebar-subtitle { font-size: 0.6875rem; color: var(--secondary-color); padding: 0 20px; margin-bottom: 20px; } 60 - .sidebar nav { flex: 1; } 61 - .sidebar nav a { display: block; padding: 8px 20px; font-size: 0.8125rem; color: var(--secondary-color); text-decoration: none; transition: background 0.1s, color 0.1s; } 62 - .sidebar nav a:hover { background: var(--bg-secondary-color); color: var(--primary-color); } 63 - .sidebar nav a.active { color: var(--brand-color); font-weight: 500; } 64 - .sidebar-footer { padding: 16px 20px 0; border-top: 1px solid var(--border-color); margin-top: 16px; } 65 - .sidebar-footer .session-info { font-size: 0.75rem; color: var(--secondary-color); margin-bottom: 8px; } 66 - .sidebar-footer form { display: inline; } 67 - .sidebar-footer button { background: none; border: none; font-size: 0.75rem; color: var(--secondary-color); cursor: pointer; padding: 0; text-decoration: underline; } 68 - .sidebar-footer button:hover { color: var(--primary-color); } 294 + tbody tr { 295 + border-bottom: 1px solid var(--border-color); 296 + } 69 297 70 - .main { margin-left: 220px; flex: 1; padding: 32px; max-width: 960px; } 71 - .page-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 24px; } 298 + tbody tr:last-child { 299 + border-bottom: none; 300 + } 72 301 73 - .flash-success { background: rgba(22, 163, 74, 0.1); color: var(--success-color); border: 1px solid rgba(22, 163, 74, 0.2); border-radius: 8px; padding: 10px 14px; font-size: 0.875rem; margin-bottom: 20px; } 74 - .flash-error { background: rgba(220, 38, 38, 0.1); color: var(--danger-color); border: 1px solid rgba(220, 38, 38, 0.2); border-radius: 8px; padding: 10px 14px; font-size: 0.875rem; margin-bottom: 20px; } 302 + tbody tr:nth-child(even) { 303 + background: var(--table-stripe); 304 + } 75 305 76 - .create-form { display: flex; gap: 8px; align-items: flex-end; margin-bottom: 24px; } 77 - .create-form .form-group { display: flex; flex-direction: column; gap: 4px; } 78 - .create-form label { font-size: 0.75rem; font-weight: 500; color: var(--secondary-color); } 79 - .create-form input[type="number"] { padding: 10px 12px; font-size: 0.875rem; border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-primary-color); color: var(--primary-color); outline: none; width: 120px; } 80 - .create-form input[type="number"]:focus { border-color: var(--brand-color); } 306 + tbody td { 307 + padding: 10px 16px; 308 + font-size: 0.8125rem; 309 + } 81 310 82 - .btn { display: inline-flex; align-items: center; justify-content: center; padding: 10px 20px; font-size: 0.875rem; font-weight: 500; border: none; border-radius: 8px; cursor: pointer; transition: opacity 0.15s; text-decoration: none; } 83 - .btn:hover { opacity: 0.85; } 84 - .btn-primary { background: var(--brand-color); color: #fff; } 85 - .btn-small { padding: 6px 12px; font-size: 0.75rem; } 86 - .btn-outline-danger { background: transparent; color: var(--danger-color); border: 1px solid var(--danger-color); } 311 + .code-cell { 312 + font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; 313 + font-size: 0.75rem; 314 + } 87 315 88 - .code-box { background: rgba(22, 163, 74, 0.08); border: 1px solid rgba(22, 163, 74, 0.2); border-radius: 10px; padding: 16px 20px; margin-bottom: 24px; } 89 - .code-box .code-label { font-size: 0.75rem; font-weight: 600; color: var(--success-color); margin-bottom: 6px; } 90 - .code-box .code-value { font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; font-size: 1rem; font-weight: 600; user-select: all; } 316 + .badge { 317 + display: inline-block; 318 + padding: 2px 8px; 319 + border-radius: 4px; 320 + font-size: 0.75rem; 321 + font-weight: 500; 322 + } 91 323 92 - .table-container { background: var(--bg-primary-color); border: 1px solid var(--border-color); border-radius: 10px; overflow: hidden; } 93 - table { width: 100%; border-collapse: collapse; } 94 - thead th { text-align: left; padding: 12px 16px; font-size: 0.75rem; font-weight: 600; color: var(--secondary-color); text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid var(--border-color); } 95 - tbody tr { border-bottom: 1px solid var(--border-color); } 96 - tbody tr:last-child { border-bottom: none; } 97 - tbody tr:nth-child(even) { background: var(--table-stripe); } 98 - tbody td { padding: 10px 16px; font-size: 0.8125rem; } 99 - .code-cell { font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.75rem; } 324 + .badge-success { 325 + background: rgba(22, 163, 74, 0.1); 326 + color: var(--success-color); 327 + } 100 328 101 - .badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: 500; } 102 - .badge-success { background: rgba(22, 163, 74, 0.1); color: var(--success-color); } 103 - .badge-danger { background: rgba(220, 38, 38, 0.1); color: var(--danger-color); } 329 + .badge-danger { 330 + background: rgba(220, 38, 38, 0.1); 331 + color: var(--danger-color); 332 + } 104 333 105 - .empty-state { text-align: center; padding: 40px 20px; color: var(--secondary-color); font-size: 0.875rem; } 334 + .empty-state { 335 + text-align: center; 336 + padding: 40px 20px; 337 + color: var(--secondary-color); 338 + font-size: 0.875rem; 339 + } 340 + 341 + .load-more { 342 + text-align: center; 343 + padding: 16px; 344 + } 345 + 346 + .load-more a { 347 + color: var(--brand-color); 348 + text-decoration: none; 349 + font-size: 0.875rem; 350 + font-weight: 500; 351 + } 106 352 107 - .load-more { text-align: center; padding: 16px; } 108 - .load-more a { color: var(--brand-color); text-decoration: none; font-size: 0.875rem; font-weight: 500; } 109 - .load-more a:hover { text-decoration: underline; } 353 + .load-more a:hover { 354 + text-decoration: underline; 355 + } 110 356 111 357 @media (max-width: 768px) { 112 - .sidebar { display: none; } 113 - .main { margin-left: 0; } 358 + .sidebar { 359 + display: none; 360 + } 361 + 362 + .main { 363 + margin-left: 0; 364 + } 114 365 } 115 366 </style> 116 367 </head> 117 368 <body> 118 - <div class="layout"> 119 - <aside class="sidebar"> 120 - <div class="sidebar-title">{{pds_hostname}}</div> 121 - <div class="sidebar-subtitle">Admin Portal</div> 122 - <nav> 123 - <a href="/admin/">Dashboard</a> 124 - {{#if can_view_accounts}} 369 + <div class="layout"> 370 + <aside class="sidebar"> 371 + <div class="sidebar-title">{{pds_hostname}}</div> 372 + <div class="sidebar-subtitle">Admin Portal</div> 373 + <nav> 374 + <a href="/admin/dashboard">Dashboard</a> 375 + {{#if can_view_accounts}} 125 376 <a href="/admin/accounts">Accounts</a> 126 - {{/if}} 127 - {{#if can_manage_invites}} 377 + {{/if}} 378 + {{#if can_manage_invites}} 128 379 <a href="/admin/invite-codes" class="active">Invite Codes</a> 129 - {{/if}} 130 - {{#if can_create_account}} 380 + {{/if}} 381 + {{#if can_create_account}} 131 382 <a href="/admin/create-account">Create Account</a> 132 - {{/if}} 133 - {{#if can_request_crawl}} 383 + {{/if}} 384 + {{#if can_request_crawl}} 134 385 <a href="/admin/request-crawl">Request Crawl</a> 135 - {{/if}} 136 - </nav> 137 - <div class="sidebar-footer"> 138 - <div class="session-info">Signed in as {{handle}}</div> 139 - <form method="POST" action="/admin/logout"> 140 - <button type="submit">Sign out</button> 141 - </form> 142 - </div> 143 - </aside> 386 + {{/if}} 387 + </nav> 388 + <div class="sidebar-footer"> 389 + <div class="session-info">Signed in as {{handle}}</div> 390 + <form method="POST" action="/admin/logout"> 391 + <button type="submit">Sign out</button> 392 + </form> 393 + </div> 394 + </aside> 144 395 145 - <main class="main"> 146 - {{#if flash_success}} 396 + <main class="main"> 397 + {{#if flash_success}} 147 398 <div class="flash-success">{{flash_success}}</div> 148 - {{/if}} 149 - {{#if flash_error}} 399 + {{/if}} 400 + {{#if flash_error}} 150 401 <div class="flash-error">{{flash_error}}</div> 151 - {{/if}} 402 + {{/if}} 152 403 153 - <h1 class="page-title">Invite Codes</h1> 404 + <h1 class="page-title">Invite Codes</h1> 154 405 155 - {{#if can_create_invite}} 406 + {{#if can_create_invite}} 156 407 <form class="create-form" method="POST" action="/admin/invite-codes/create"> 157 408 <div class="form-group"> 158 409 <label for="use_count">Max Uses</label> 159 - <input type="number" id="use_count" name="use_count" value="1" min="1" max="100" /> 410 + <input type="number" id="use_count" name="use_count" value="1" min="1" max="100"/> 160 411 </div> 161 412 <button type="submit" class="btn btn-primary">Create Invite Code</button> 162 413 </form> 163 - {{/if}} 414 + {{/if}} 164 415 165 - {{#if new_code}} 416 + {{#if new_code}} 166 417 <div class="code-box"> 167 418 <div class="code-label">New Invite Code Created</div> 168 419 <div class="code-value">{{new_code}}</div> 169 420 </div> 170 - {{/if}} 421 + {{/if}} 171 422 172 - {{#if codes}} 423 + {{#if codes}} 173 424 <div class="table-container"> 174 425 <table> 175 426 <thead> 176 - <tr> 177 - <th>Code</th> 178 - <th>Remaining / Total</th> 179 - <th>Status</th> 180 - <th>Created By</th> 181 - <th>Created At</th> 182 - {{#if can_manage_invites}} 427 + <tr> 428 + <th>Code</th> 429 + <th>Remaining / Total</th> 430 + <th>Status</th> 431 + <th>Created By</th> 432 + <th>Created At</th> 433 + {{#if can_manage_invites}} 183 434 <th></th> 184 - {{/if}} 185 - </tr> 435 + {{/if}} 436 + </tr> 186 437 </thead> 187 438 <tbody> 188 - {{#each codes}} 439 + {{#each codes}} 189 440 <tr> 190 441 <td class="code-cell">{{this.code}}</td> 191 442 <td>{{this.remaining}} / {{this.available}}</td> 192 443 <td> 193 444 {{#if this.disabled}} 194 - <span class="badge badge-danger">Disabled</span> 445 + <span class="badge badge-danger">Disabled</span> 195 446 {{else}} 196 - <span class="badge badge-success">Active</span> 447 + <span class="badge badge-success">Active</span> 197 448 {{/if}} 198 449 </td> 199 450 <td>{{this.createdBy}}</td> 200 451 <td>{{this.createdAt}}</td> 201 452 {{#if ../can_manage_invites}} 202 - <td> 203 - {{#unless this.disabled}} 204 - <form method="POST" action="/admin/invite-codes/disable" style="display:inline;"> 205 - <input type="hidden" name="codes" value="{{this.code}}" /> 206 - <button type="submit" class="btn btn-small btn-outline-danger">Disable</button> 207 - </form> 208 - {{/unless}} 209 - </td> 453 + <td> 454 + {{#unless this.disabled}} 455 + <form method="POST" action="/admin/invite-codes/disable" 456 + style="display:inline;"> 457 + <input type="hidden" name="codes" value="{{this.code}}"/> 458 + <button type="submit" class="btn btn-small btn-outline-danger">Disable 459 + </button> 460 + </form> 461 + {{/unless}} 462 + </td> 210 463 {{/if}} 211 464 </tr> 212 - {{/each}} 465 + {{/each}} 213 466 </tbody> 214 467 </table> 215 468 </div> 216 469 {{#if has_more}} 217 - <div class="load-more"> 218 - <a href="/admin/invite-codes?cursor={{next_cursor}}">Load More</a> 219 - </div> 470 + <div class="load-more"> 471 + <a href="/admin/invite-codes?cursor={{next_cursor}}">Load More</a> 472 + </div> 220 473 {{/if}} 221 - {{else}} 474 + {{else}} 222 475 <div class="empty-state">No invite codes found</div> 223 - {{/if}} 224 - </main> 225 - </div> 476 + {{/if}} 477 + </main> 478 + </div> 226 479 </body> 227 480 </html>
+241 -71
html_templates/admin/request_crawl.hbs
··· 42 42 --table-stripe: rgba(255, 255, 255, 0.02); 43 43 } 44 44 45 - * { margin: 0; padding: 0; box-sizing: border-box; } 45 + * { 46 + margin: 0; 47 + padding: 0; 48 + box-sizing: border-box; 49 + } 46 50 47 51 body { 48 52 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; ··· 52 56 -webkit-font-smoothing: antialiased; 53 57 } 54 58 55 - .layout { display: flex; min-height: 100vh; } 59 + .layout { 60 + display: flex; 61 + min-height: 100vh; 62 + } 56 63 57 - .sidebar { width: 220px; background: var(--bg-primary-color); border-right: 1px solid var(--border-color); padding: 20px 0; position: fixed; top: 0; left: 0; bottom: 0; overflow-y: auto; display: flex; flex-direction: column; } 58 - .sidebar-title { font-size: 0.8125rem; font-weight: 700; padding: 0 20px; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 59 - .sidebar-subtitle { font-size: 0.6875rem; color: var(--secondary-color); padding: 0 20px; margin-bottom: 20px; } 60 - .sidebar nav { flex: 1; } 61 - .sidebar nav a { display: block; padding: 8px 20px; font-size: 0.8125rem; color: var(--secondary-color); text-decoration: none; transition: background 0.1s, color 0.1s; } 62 - .sidebar nav a:hover { background: var(--bg-secondary-color); color: var(--primary-color); } 63 - .sidebar nav a.active { color: var(--brand-color); font-weight: 500; } 64 - .sidebar-footer { padding: 16px 20px 0; border-top: 1px solid var(--border-color); margin-top: 16px; } 65 - .sidebar-footer .session-info { font-size: 0.75rem; color: var(--secondary-color); margin-bottom: 8px; } 66 - .sidebar-footer form { display: inline; } 67 - .sidebar-footer button { background: none; border: none; font-size: 0.75rem; color: var(--secondary-color); cursor: pointer; padding: 0; text-decoration: underline; } 68 - .sidebar-footer button:hover { color: var(--primary-color); } 64 + .sidebar { 65 + width: 220px; 66 + background: var(--bg-primary-color); 67 + border-right: 1px solid var(--border-color); 68 + padding: 20px 0; 69 + position: fixed; 70 + top: 0; 71 + left: 0; 72 + bottom: 0; 73 + overflow-y: auto; 74 + display: flex; 75 + flex-direction: column; 76 + } 69 77 70 - .main { margin-left: 220px; flex: 1; padding: 32px; max-width: 960px; } 71 - .page-title { font-size: 1.5rem; font-weight: 700; margin-bottom: 8px; } 72 - .page-description { font-size: 0.875rem; color: var(--secondary-color); margin-bottom: 24px; } 78 + .sidebar-title { 79 + font-size: 0.8125rem; 80 + font-weight: 700; 81 + padding: 0 20px; 82 + margin-bottom: 4px; 83 + white-space: nowrap; 84 + overflow: hidden; 85 + text-overflow: ellipsis; 86 + } 73 87 74 - .flash-success { background: rgba(22, 163, 74, 0.1); color: var(--success-color); border: 1px solid rgba(22, 163, 74, 0.2); border-radius: 8px; padding: 10px 14px; font-size: 0.875rem; margin-bottom: 20px; } 75 - .flash-error { background: rgba(220, 38, 38, 0.1); color: var(--danger-color); border: 1px solid rgba(220, 38, 38, 0.2); border-radius: 8px; padding: 10px 14px; font-size: 0.875rem; margin-bottom: 20px; } 88 + .sidebar-subtitle { 89 + font-size: 0.6875rem; 90 + color: var(--secondary-color); 91 + padding: 0 20px; 92 + margin-bottom: 20px; 93 + } 76 94 77 - .form-card { background: var(--bg-primary-color); border: 1px solid var(--border-color); border-radius: 10px; padding: 24px; max-width: 480px; } 78 - .form-group { margin-bottom: 16px; } 79 - .form-group label { display: block; font-size: 0.8125rem; font-weight: 500; margin-bottom: 6px; color: var(--primary-color); } 80 - .form-group input { width: 100%; padding: 10px 12px; font-size: 0.875rem; border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-primary-color); color: var(--primary-color); outline: none; transition: border-color 0.15s; } 81 - .form-group input:focus { border-color: var(--brand-color); } 82 - .form-group .hint { font-size: 0.75rem; color: var(--secondary-color); margin-top: 4px; } 95 + .sidebar nav { 96 + flex: 1; 97 + } 83 98 84 - .btn { display: inline-flex; align-items: center; justify-content: center; padding: 10px 20px; font-size: 0.875rem; font-weight: 500; border: none; border-radius: 8px; cursor: pointer; transition: opacity 0.15s; text-decoration: none; } 85 - .btn:hover { opacity: 0.85; } 86 - .btn-primary { background: var(--brand-color); color: #fff; } 99 + .sidebar nav a { 100 + display: block; 101 + padding: 8px 20px; 102 + font-size: 0.8125rem; 103 + color: var(--secondary-color); 104 + text-decoration: none; 105 + transition: background 0.1s, color 0.1s; 106 + } 107 + 108 + .sidebar nav a:hover { 109 + background: var(--bg-secondary-color); 110 + color: var(--primary-color); 111 + } 112 + 113 + .sidebar nav a.active { 114 + color: var(--brand-color); 115 + font-weight: 500; 116 + } 117 + 118 + .sidebar-footer { 119 + padding: 16px 20px 0; 120 + border-top: 1px solid var(--border-color); 121 + margin-top: 16px; 122 + } 123 + 124 + .sidebar-footer .session-info { 125 + font-size: 0.75rem; 126 + color: var(--secondary-color); 127 + margin-bottom: 8px; 128 + } 129 + 130 + .sidebar-footer form { 131 + display: inline; 132 + } 133 + 134 + .sidebar-footer button { 135 + background: none; 136 + border: none; 137 + font-size: 0.75rem; 138 + color: var(--secondary-color); 139 + cursor: pointer; 140 + padding: 0; 141 + text-decoration: underline; 142 + } 143 + 144 + .sidebar-footer button:hover { 145 + color: var(--primary-color); 146 + } 147 + 148 + .main { 149 + margin-left: 220px; 150 + flex: 1; 151 + padding: 32px; 152 + max-width: 960px; 153 + } 154 + 155 + .page-title { 156 + font-size: 1.5rem; 157 + font-weight: 700; 158 + margin-bottom: 8px; 159 + } 160 + 161 + .page-description { 162 + font-size: 0.875rem; 163 + color: var(--secondary-color); 164 + margin-bottom: 24px; 165 + } 166 + 167 + .flash-success { 168 + background: rgba(22, 163, 74, 0.1); 169 + color: var(--success-color); 170 + border: 1px solid rgba(22, 163, 74, 0.2); 171 + border-radius: 8px; 172 + padding: 10px 14px; 173 + font-size: 0.875rem; 174 + margin-bottom: 20px; 175 + } 176 + 177 + .flash-error { 178 + background: rgba(220, 38, 38, 0.1); 179 + color: var(--danger-color); 180 + border: 1px solid rgba(220, 38, 38, 0.2); 181 + border-radius: 8px; 182 + padding: 10px 14px; 183 + font-size: 0.875rem; 184 + margin-bottom: 20px; 185 + } 186 + 187 + .form-card { 188 + background: var(--bg-primary-color); 189 + border: 1px solid var(--border-color); 190 + border-radius: 10px; 191 + padding: 24px; 192 + max-width: 480px; 193 + } 194 + 195 + .form-group { 196 + margin-bottom: 16px; 197 + } 198 + 199 + .form-group label { 200 + display: block; 201 + font-size: 0.8125rem; 202 + font-weight: 500; 203 + margin-bottom: 6px; 204 + color: var(--primary-color); 205 + } 206 + 207 + .form-group input { 208 + width: 100%; 209 + padding: 10px 12px; 210 + font-size: 0.875rem; 211 + border: 1px solid var(--border-color); 212 + border-radius: 8px; 213 + background: var(--bg-primary-color); 214 + color: var(--primary-color); 215 + outline: none; 216 + transition: border-color 0.15s; 217 + } 218 + 219 + .form-group input:focus { 220 + border-color: var(--brand-color); 221 + } 222 + 223 + .form-group .hint { 224 + font-size: 0.75rem; 225 + color: var(--secondary-color); 226 + margin-top: 4px; 227 + } 228 + 229 + .btn { 230 + display: inline-flex; 231 + align-items: center; 232 + justify-content: center; 233 + padding: 10px 20px; 234 + font-size: 0.875rem; 235 + font-weight: 500; 236 + border: none; 237 + border-radius: 8px; 238 + cursor: pointer; 239 + transition: opacity 0.15s; 240 + text-decoration: none; 241 + } 242 + 243 + .btn:hover { 244 + opacity: 0.85; 245 + } 246 + 247 + .btn-primary { 248 + background: var(--brand-color); 249 + color: #fff; 250 + } 87 251 88 252 @media (max-width: 768px) { 89 - .sidebar { display: none; } 90 - .main { margin-left: 0; } 253 + .sidebar { 254 + display: none; 255 + } 256 + 257 + .main { 258 + margin-left: 0; 259 + } 91 260 } 92 261 </style> 93 262 </head> 94 263 <body> 95 - <div class="layout"> 96 - <aside class="sidebar"> 97 - <div class="sidebar-title">{{pds_hostname}}</div> 98 - <div class="sidebar-subtitle">Admin Portal</div> 99 - <nav> 100 - <a href="/admin/">Dashboard</a> 101 - {{#if can_view_accounts}} 264 + <div class="layout"> 265 + <aside class="sidebar"> 266 + <div class="sidebar-title">{{pds_hostname}}</div> 267 + <div class="sidebar-subtitle">Admin Portal</div> 268 + <nav> 269 + <a href="/admin/dashboard">Dashboard</a> 270 + {{#if can_view_accounts}} 102 271 <a href="/admin/accounts">Accounts</a> 103 - {{/if}} 104 - {{#if can_manage_invites}} 272 + {{/if}} 273 + {{#if can_manage_invites}} 105 274 <a href="/admin/invite-codes">Invite Codes</a> 106 - {{/if}} 107 - {{#if can_create_account}} 275 + {{/if}} 276 + {{#if can_create_account}} 108 277 <a href="/admin/create-account">Create Account</a> 109 - {{/if}} 110 - {{#if can_request_crawl}} 278 + {{/if}} 279 + {{#if can_request_crawl}} 111 280 <a href="/admin/request-crawl" class="active">Request Crawl</a> 112 - {{/if}} 113 - </nav> 114 - <div class="sidebar-footer"> 115 - <div class="session-info">Signed in as {{handle}}</div> 116 - <form method="POST" action="/admin/logout"> 117 - <button type="submit">Sign out</button> 118 - </form> 119 - </div> 120 - </aside> 281 + {{/if}} 282 + </nav> 283 + <div class="sidebar-footer"> 284 + <div class="session-info">Signed in as {{handle}}</div> 285 + <form method="POST" action="/admin/logout"> 286 + <button type="submit">Sign out</button> 287 + </form> 288 + </div> 289 + </aside> 121 290 122 - <main class="main"> 123 - {{#if flash_success}} 291 + <main class="main"> 292 + {{#if flash_success}} 124 293 <div class="flash-success">{{flash_success}}</div> 125 - {{/if}} 126 - {{#if flash_error}} 294 + {{/if}} 295 + {{#if flash_error}} 127 296 <div class="flash-error">{{flash_error}}</div> 128 - {{/if}} 297 + {{/if}} 129 298 130 - <h1 class="page-title">Request Crawl</h1> 131 - <p class="page-description">Request a relay to crawl this PDS. This sends your PDS hostname to the relay so it can discover and index your content.</p> 299 + <h1 class="page-title">Request Crawl</h1> 300 + <p class="page-description">Request a relay to crawl this PDS. This sends your PDS hostname to the relay so it 301 + can discover and index your content.</p> 132 302 133 - <div class="form-card"> 134 - <form method="POST" action="/admin/request-crawl"> 135 - <div class="form-group"> 136 - <label for="relay_host">Relay Host</label> 137 - <input type="text" id="relay_host" name="relay_host" value="{{default_relay}}" required /> 138 - <div class="hint">The relay hostname to send the crawl request to (e.g., bsky.network)</div> 139 - </div> 140 - <button type="submit" class="btn btn-primary">Request Crawl</button> 141 - </form> 142 - </div> 143 - </main> 144 - </div> 303 + <div class="form-card"> 304 + <form method="POST" action="/admin/request-crawl"> 305 + <div class="form-group"> 306 + <label for="relay_host">Relay Host</label> 307 + <input type="text" id="relay_host" name="relay_host" value="{{default_relay}}" required/> 308 + <div class="hint">The relay hostname to send the crawl request to (e.g., bsky.network)</div> 309 + </div> 310 + <button type="submit" class="btn btn-primary">Request Crawl</button> 311 + </form> 312 + </div> 313 + </main> 314 + </div> 145 315 </body> 146 316 </html>
+18
migrations/20260303000000_oauth_auth_store.sql
··· 1 + -- OAuth client session storage (replaces in-memory MemoryAuthStore) 2 + CREATE TABLE IF NOT EXISTS oauth_client_sessions ( 3 + session_key VARCHAR NOT NULL PRIMARY KEY, 4 + did VARCHAR NOT NULL, 5 + session_id VARCHAR NOT NULL, 6 + data TEXT NOT NULL, 7 + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 8 + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 9 + ); 10 + 11 + CREATE INDEX IF NOT EXISTS idx_oauth_client_sessions_did ON oauth_client_sessions(did); 12 + 13 + -- OAuth authorization request storage (transient, keyed by state token) 14 + CREATE TABLE IF NOT EXISTS oauth_auth_requests ( 15 + state VARCHAR NOT NULL PRIMARY KEY, 16 + data TEXT NOT NULL, 17 + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP 18 + );
+7 -13
src/admin/mod.rs
··· 4 4 pub mod rbac; 5 5 pub mod routes; 6 6 pub mod session; 7 + pub mod store; 7 8 8 9 use axum::{Router, middleware as ax_middleware, routing::get, routing::post}; 9 10 ··· 15 16 pub fn router(state: AppState) -> Router<AppState> { 16 17 // Routes that do NOT require authentication 17 18 let public_routes = Router::new() 19 + .route("/", get(routes::dashboard)) 18 20 .route("/login", get(oauth::get_login).post(oauth::post_login)) 19 21 .route("/oauth/callback", get(oauth::oauth_callback)) 20 22 .route("/client-metadata.json", get(oauth::client_metadata_json)); 21 23 22 24 // Routes that DO require authentication (via admin_auth middleware) 23 25 let protected_routes = Router::new() 24 - .route("/", get(routes::dashboard)) 26 + .route("/dashboard", get(routes::dashboard)) 25 27 .route("/accounts", get(routes::accounts_list)) 26 28 .route("/accounts/{did}", get(routes::account_detail)) 27 - .route( 28 - "/accounts/{did}/takedown", 29 - post(routes::takedown_account), 30 - ) 29 + .route("/accounts/{did}/takedown", post(routes::takedown_account)) 31 30 .route( 32 31 "/accounts/{did}/untakedown", 33 32 post(routes::untakedown_account), 34 33 ) 35 - .route( 36 - "/accounts/{did}/delete", 37 - post(routes::delete_account), 38 - ) 34 + .route("/accounts/{did}/delete", post(routes::delete_account)) 39 35 .route( 40 36 "/accounts/{did}/reset-password", 41 37 post(routes::reset_password), ··· 50 46 ) 51 47 .route("/invite-codes", get(routes::invite_codes_list)) 52 48 .route("/invite-codes/create", post(routes::create_invite_code)) 53 - .route( 54 - "/invite-codes/disable", 55 - post(routes::disable_invite_codes), 56 - ) 49 + .route("/invite-codes/disable", post(routes::disable_invite_codes)) 57 50 .route( 58 51 "/create-account", 59 52 get(routes::get_create_account).post(routes::post_create_account), ··· 64 57 get(routes::get_request_crawl).post(routes::post_request_crawl), 65 58 ) 66 59 .route("/logout", post(routes::logout)) 60 + .fallback(get(routes::dashboard)) 67 61 .layer(ax_middleware::from_fn_with_state( 68 62 state.clone(), 69 63 middleware::admin_auth_middleware,
+11 -15
src/admin/oauth.rs
··· 1 + use super::session; 1 2 use crate::AppState; 3 + use crate::admin::store::SqlAuthStore; 2 4 use axum::{ 3 5 extract::{Query, State}, 4 6 http::StatusCode, 5 7 response::{Html, IntoResponse, Redirect, Response}, 6 8 }; 7 9 use axum_extra::extract::cookie::{Cookie, SameSite, SignedCookieJar}; 8 - use base64::Engine as _; 9 10 use jacquard_identity::JacquardResolver; 11 + use jacquard_oauth::session::ClientSessionData; 10 12 use jacquard_oauth::{ 11 13 atproto::{AtprotoClientMetadata, GrantType}, 12 - authstore::MemoryAuthStore, 13 14 client::OAuthClient, 14 - keyset::Keyset, 15 15 session::ClientData, 16 16 types::{AuthorizeOptions, CallbackParams}, 17 17 }; 18 - use jose_jwk::Jwk; 19 18 use serde::Deserialize; 19 + use sqlx::SqlitePool; 20 20 use tracing::log; 21 21 22 - use super::session; 23 - 24 22 /// Type alias for the concrete OAuthClient we use. 25 - pub type AdminOAuthClient = OAuthClient<JacquardResolver, MemoryAuthStore>; 23 + pub type AdminOAuthClient = OAuthClient<JacquardResolver, SqlAuthStore>; 26 24 27 25 /// Initialize the OAuth client for admin portal authentication. 28 - pub fn init_oauth_client(pds_hostname: &str) -> Result<AdminOAuthClient, anyhow::Error> { 26 + pub fn init_oauth_client( 27 + pds_hostname: &str, 28 + pool: SqlitePool, 29 + ) -> Result<AdminOAuthClient, anyhow::Error> { 29 30 // Build client metadata 30 31 let client_id = format!("https://{}/admin/client-metadata.json", pds_hostname) 31 32 .parse() ··· 47 48 ); 48 49 49 50 let client_data = ClientData::new(None, config); 50 - let store = MemoryAuthStore::new(); 51 + let store = SqlAuthStore::new(pool); 51 52 let client = OAuthClient::new(store, client_data); 52 53 53 54 Ok(client) ··· 55 56 56 57 /// GET /admin/client-metadata.json — Serves the OAuth client metadata. 57 58 pub async fn client_metadata_json(State(state): State<AppState>) -> Response { 58 - let oauth_client: &AdminOAuthClient = match &state.admin_oauth_client { 59 - Some(client) => client, 60 - None => return StatusCode::NOT_FOUND.into_response(), 61 - }; 62 - 63 59 let pds_hostname = &state.app_config.pds_hostname; 64 60 let client_id = format!("https://{}/admin/client-metadata.json", pds_hostname); 65 61 let redirect_uri = format!("https://{}/admin/oauth/callback", pds_hostname); ··· 240 236 241 237 let updated_jar = jar.add(cookie); 242 238 243 - (updated_jar, Redirect::to("/admin/")).into_response() 239 + (updated_jar, Redirect::to("/admin/dashboard")).into_response() 244 240 } 245 241 246 242 fn render_error(state: &AppState, title: &str, message: &str) -> Response {
+18 -48
src/admin/routes.rs
··· 67 67 // ─── Helper functions ──────────────────────────────────────────────────────── 68 68 69 69 fn admin_password(state: &AppState) -> &str { 70 - state 71 - .app_config 72 - .pds_admin_password 73 - .as_deref() 74 - .unwrap_or("") 70 + state.app_config.pds_admin_password.as_deref().unwrap_or("") 75 71 } 76 72 77 73 fn pds_url(state: &AppState) -> &str { ··· 126 122 "can_create_invite".into(), 127 123 permissions.can_create_invite.into(), 128 124 ); 129 - obj.insert( 130 - "can_send_email".into(), 131 - permissions.can_send_email.into(), 132 - ); 125 + obj.insert("can_send_email".into(), permissions.can_send_email.into()); 133 126 obj.insert( 134 127 "can_request_crawl".into(), 135 128 permissions.can_request_crawl.into(), ··· 140 133 let mut url = base_path.to_string(); 141 134 let mut sep = '?'; 142 135 if let Some(msg) = success { 143 - url.push_str(&format!("{}flash_success={}", sep, urlencoding::encode(msg))); 136 + url.push_str(&format!( 137 + "{}flash_success={}", 138 + sep, 139 + urlencoding::encode(msg) 140 + )); 144 141 sep = '&'; 145 142 } 146 143 if let Some(msg) = error { ··· 160 157 161 158 // ─── Route handlers ────────────────────────────────────────────────────────── 162 159 163 - /// GET /admin/ — Dashboard 160 + /// GET /admin/dashboard — Dashboard 164 161 pub async fn dashboard( 165 162 State(state): State<AppState>, 166 163 Extension(session): Extension<AdminSession>, ··· 370 367 Query(params): Query<AccountDetailParams>, 371 368 ) -> Response { 372 369 if !permissions.can_view_accounts { 373 - return flash_redirect("/admin/", None, Some("Access denied")); 370 + return flash_redirect("/admin/dashboard", None, Some("Access denied")); 374 371 } 375 372 376 373 let pds = pds_url(&state); ··· 451 448 452 449 // Gap 2: extract repo status data 453 450 let repo_status = repo_status_res.ok(); 454 - let repo_active = repo_status 455 - .as_ref() 456 - .and_then(|r| r["active"].as_bool()); 451 + let repo_active = repo_status.as_ref().and_then(|r| r["active"].as_bool()); 457 452 let repo_status_reason = repo_status 458 453 .as_ref() 459 454 .and_then(|r| r["status"].as_str()) ··· 500 495 } 501 496 502 497 // Threat signatures 503 - if threat_signatures.is_array() 504 - && !threat_signatures.as_array().unwrap().is_empty() 505 - { 498 + if threat_signatures.is_array() && !threat_signatures.as_array().unwrap().is_empty() { 506 499 data["threat_signatures"] = threat_signatures; 507 500 } 508 501 ··· 858 851 .map(|code| { 859 852 let mut c = code.clone(); 860 853 let available = c["available"].as_i64().unwrap_or(0); 861 - let used_count = c["uses"] 862 - .as_array() 863 - .map(|u| u.len() as i64) 864 - .unwrap_or(0); 854 + let used_count = c["uses"].as_array().map(|u| u.len() as i64).unwrap_or(0); 865 855 let remaining = (available - used_count).max(0); 866 856 c["used_count"] = used_count.into(); 867 857 c["remaining"] = remaining.into(); ··· 976 966 ) 977 967 .await 978 968 { 979 - Ok(()) => flash_redirect( 980 - "/admin/invite-codes", 981 - Some("Invite codes disabled"), 982 - None, 983 - ), 984 - Err(e) => flash_redirect( 985 - "/admin/invite-codes", 986 - None, 987 - Some(&format!("Failed: {}", e)), 988 - ), 969 + Ok(()) => flash_redirect("/admin/invite-codes", Some("Invite codes disabled"), None), 970 + Err(e) => flash_redirect("/admin/invite-codes", None, Some(&format!("Failed: {}", e))), 989 971 } 990 972 } 991 973 ··· 1061 1043 } 1062 1044 }; 1063 1045 1064 - let invite_code = invite_res["code"] 1065 - .as_str() 1066 - .unwrap_or("") 1067 - .to_string(); 1046 + let invite_code = invite_res["code"].as_str().unwrap_or("").to_string(); 1068 1047 1069 1048 // Step 2: Create account 1070 1049 let account_password = generate_random_password(); ··· 1223 1202 "hostname": pds_hostname, 1224 1203 }); 1225 1204 1226 - match pds_proxy::public_xrpc_post( 1227 - &relay_base, 1228 - "com.atproto.sync.requestCrawl", 1229 - &body, 1230 - ) 1231 - .await 1232 - { 1205 + match pds_proxy::public_xrpc_post(&relay_base, "com.atproto.sync.requestCrawl", &body).await { 1233 1206 Ok(()) => flash_redirect( 1234 1207 "/admin/request-crawl", 1235 1208 Some(&format!( ··· 1247 1220 } 1248 1221 1249 1222 /// POST /admin/logout — Clear session and redirect to login 1250 - pub async fn logout( 1251 - State(state): State<AppState>, 1252 - jar: SignedCookieJar, 1253 - ) -> Response { 1223 + pub async fn logout(State(state): State<AppState>, jar: SignedCookieJar) -> Response { 1254 1224 if let Some(cookie) = jar.get("__gatekeeper_admin_session") { 1255 1225 let session_id = cookie.value().to_string(); 1256 1226 let _ = session::delete_session(&state.pds_gatekeeper_pool, &session_id).await; 1257 1227 } 1258 1228 1259 1229 let mut removal = Cookie::build("__gatekeeper_admin_session") 1260 - .path("/admin/") 1230 + .path("/admin/dashboard") 1261 1231 .build(); 1262 1232 removal.make_removal(); 1263 1233
+154
src/admin/store.rs
··· 1 + use jacquard_common::IntoStatic; 2 + use jacquard_common::session::SessionStoreError; 3 + use jacquard_common::types::did::Did; 4 + use jacquard_oauth::authstore::ClientAuthStore; 5 + use jacquard_oauth::session::{AuthRequestData, ClientSessionData}; 6 + use sqlx::SqlitePool; 7 + 8 + fn sqlx_to_session_err(e: sqlx::Error) -> SessionStoreError { 9 + SessionStoreError::Other(Box::new(e)) 10 + } 11 + 12 + #[derive(Clone)] 13 + pub struct SqlAuthStore { 14 + pool: SqlitePool, 15 + } 16 + 17 + impl SqlAuthStore { 18 + pub fn new(pool: SqlitePool) -> Self { 19 + Self { pool } 20 + } 21 + } 22 + 23 + impl ClientAuthStore for SqlAuthStore { 24 + async fn get_session( 25 + &self, 26 + did: &Did<'_>, 27 + session_id: &str, 28 + ) -> Result<Option<ClientSessionData<'_>>, SessionStoreError> { 29 + let key = format!("{}_{}", did, session_id); 30 + 31 + let row: Option<(String,)> = 32 + sqlx::query_as("SELECT data FROM oauth_client_sessions WHERE session_key = ?") 33 + .bind(&key) 34 + .fetch_optional(&self.pool) 35 + .await 36 + .map_err(sqlx_to_session_err)?; 37 + 38 + match row { 39 + Some((json_data,)) => { 40 + let session: ClientSessionData<'_> = serde_json::from_str(&json_data)?; 41 + Ok(Some(session.into_static())) 42 + } 43 + None => Ok(None), 44 + } 45 + } 46 + 47 + async fn upsert_session( 48 + &self, 49 + session: ClientSessionData<'_>, 50 + ) -> Result<(), SessionStoreError> { 51 + let static_session = session.into_static(); 52 + let did = static_session.account_did.to_string(); 53 + let session_id = static_session.session_id.to_string(); 54 + let key = format!("{}_{}", did, session_id); 55 + let json_data = serde_json::to_string(&static_session)?; 56 + let now = chrono::Utc::now().to_rfc3339(); 57 + 58 + sqlx::query( 59 + "INSERT INTO oauth_client_sessions (session_key, did, session_id, data, created_at, updated_at) 60 + VALUES (?, ?, ?, ?, ?, ?) 61 + ON CONFLICT(session_key) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at", 62 + ) 63 + .bind(&key) 64 + .bind(&did) 65 + .bind(&session_id) 66 + .bind(&json_data) 67 + .bind(&now) 68 + .bind(&now) 69 + .execute(&self.pool) 70 + .await 71 + .map_err(sqlx_to_session_err)?; 72 + 73 + Ok(()) 74 + } 75 + 76 + async fn delete_session( 77 + &self, 78 + did: &Did<'_>, 79 + session_id: &str, 80 + ) -> Result<(), SessionStoreError> { 81 + let key = format!("{}_{}", did, session_id); 82 + 83 + sqlx::query("DELETE FROM oauth_client_sessions WHERE session_key = ?") 84 + .bind(&key) 85 + .execute(&self.pool) 86 + .await 87 + .map_err(sqlx_to_session_err)?; 88 + 89 + Ok(()) 90 + } 91 + 92 + async fn get_auth_req_info( 93 + &self, 94 + state: &str, 95 + ) -> Result<Option<AuthRequestData<'_>>, SessionStoreError> { 96 + let row: Option<(String,)> = 97 + sqlx::query_as("SELECT data FROM oauth_auth_requests WHERE state = ?") 98 + .bind(state) 99 + .fetch_optional(&self.pool) 100 + .await 101 + .map_err(sqlx_to_session_err)?; 102 + 103 + match row { 104 + Some((json_data,)) => { 105 + let auth_req: AuthRequestData<'_> = serde_json::from_str(&json_data)?; 106 + Ok(Some(auth_req.into_static())) 107 + } 108 + None => Ok(None), 109 + } 110 + } 111 + 112 + async fn save_auth_req_info( 113 + &self, 114 + auth_req_info: &AuthRequestData<'_>, 115 + ) -> Result<(), SessionStoreError> { 116 + let static_info = auth_req_info.clone().into_static(); 117 + let state = static_info.state.to_string(); 118 + let json_data = serde_json::to_string(&static_info)?; 119 + let now = chrono::Utc::now().to_rfc3339(); 120 + 121 + sqlx::query("INSERT INTO oauth_auth_requests (state, data, created_at) VALUES (?, ?, ?)") 122 + .bind(&state) 123 + .bind(&json_data) 124 + .bind(&now) 125 + .execute(&self.pool) 126 + .await 127 + .map_err(sqlx_to_session_err)?; 128 + 129 + Ok(()) 130 + } 131 + 132 + async fn delete_auth_req_info(&self, state: &str) -> Result<(), SessionStoreError> { 133 + sqlx::query("DELETE FROM oauth_auth_requests WHERE state = ?") 134 + .bind(state) 135 + .execute(&self.pool) 136 + .await 137 + .map_err(sqlx_to_session_err)?; 138 + 139 + Ok(()) 140 + } 141 + } 142 + 143 + /// Delete auth requests older than the given number of minutes. 144 + pub async fn cleanup_stale_auth_requests( 145 + pool: &SqlitePool, 146 + max_age_minutes: i64, 147 + ) -> Result<u64, sqlx::Error> { 148 + let cutoff = (chrono::Utc::now() - chrono::Duration::minutes(max_age_minutes)).to_rfc3339(); 149 + let result = sqlx::query("DELETE FROM oauth_auth_requests WHERE created_at < ?") 150 + .bind(&cutoff) 151 + .execute(pool) 152 + .await?; 153 + Ok(result.rows_affected()) 154 + }
+29 -11
src/main.rs
··· 307 307 let app_config = AppConfig::new(); 308 308 309 309 // Admin portal setup (opt-in via GATEKEEPER_ADMIN_RBAC_CONFIG) 310 - let admin_rbac_config = env::var("GATEKEEPER_ADMIN_RBAC_CONFIG") 311 - .ok() 312 - .map(|path| { 313 - let config = admin::rbac::RbacConfig::load_from_file(&path) 314 - .unwrap_or_else(|e| panic!("Failed to load RBAC config from {}: {}", path, e)); 315 - log::info!("Loaded admin RBAC config from {} ({} members)", path, config.members.len()); 316 - Arc::new(config) 317 - }); 310 + let admin_rbac_config = env::var("GATEKEEPER_ADMIN_RBAC_CONFIG").ok().map(|path| { 311 + let config = admin::rbac::RbacConfig::load_from_file(&path) 312 + .unwrap_or_else(|e| panic!("Failed to load RBAC config from {}: {}", path, e)); 313 + log::info!( 314 + "Loaded admin RBAC config from {} ({} members)", 315 + path, 316 + config.members.len() 317 + ); 318 + Arc::new(config) 319 + }); 318 320 319 321 let admin_oauth_client = if admin_rbac_config.is_some() { 320 - match admin::oauth::init_oauth_client(&app_config.pds_hostname) { 322 + match admin::oauth::init_oauth_client(&app_config.pds_hostname, pds_gatekeeper_pool.clone()) 323 + { 321 324 Ok(client) => { 322 - log::info!("Admin OAuth client initialized for {}", app_config.pds_hostname); 325 + log::info!( 326 + "Admin OAuth client initialized for {}", 327 + app_config.pds_hostname 328 + ); 323 329 Some(Arc::new(client)) 324 330 } 325 331 Err(e) => { 326 - log::error!("Failed to initialize admin OAuth client: {}. Admin portal will be disabled.", e); 332 + log::error!( 333 + "Failed to initialize admin OAuth client: {}. Admin portal will be disabled.", 334 + e 335 + ); 327 336 None 328 337 } 329 338 } ··· 470 479 // Background cleanup for admin sessions 471 480 let cleanup_pool = state.pds_gatekeeper_pool.clone(); 472 481 let admin_enabled = state.admin_rbac_config.is_some(); 482 + let admin_session_ttl_in_mins = state.app_config.admin_session_ttl_hours * 60; 473 483 tokio::spawn(async move { 474 484 let mut interval = tokio::time::interval(Duration::from_secs(300)); 475 485 loop { ··· 477 487 if admin_enabled { 478 488 if let Err(e) = admin::session::cleanup_expired_sessions(&cleanup_pool).await { 479 489 tracing::error!("Failed to cleanup expired admin sessions: {}", e); 490 + } 491 + if let Err(e) = admin::store::cleanup_stale_auth_requests( 492 + &cleanup_pool, 493 + admin_session_ttl_in_mins as i64, 494 + ) 495 + .await 496 + { 497 + tracing::error!("Failed to cleanup stale OAuth auth requests: {}", e); 480 498 } 481 499 } 482 500 }