Microservice to bring 2FA to self hosted PDSes

feat(admin): fix 7 bugs, add RBAC enforcement, request-crawl, and polish

- Bug fixes: sidebar handle display, takedown status, password reset
display, create account success card, invite code counts, disable
invite field name, search uses email param per lexicon spec
- RBAC: gate Accounts link and dashboard account count behind
can_view_accounts permission
- Feature: request-crawl page (com.atproto.sync.requestCrawl to relay)
- Feature: dashboard shows phone verification, server links, contact
- Feature: account detail shows repo status, handle resolution,
collections, threat signatures, takedown reference
- Polish: invite code pagination with cursor, copy-to-clipboard buttons
- Add jacquard-api dependency, remove hand-rolled response structs

authored by

Clinton Bowen and committed by baileytownsend.dev 6d50b8de a4067ce1

+905 -1101
+1
Cargo.lock
··· 2934 2934 "hex", 2935 2935 "html-escape", 2936 2936 "hyper-util", 2937 + "jacquard-api", 2937 2938 "jacquard-common", 2938 2939 "jacquard-identity", 2939 2940 "jacquard-oauth",
+1
Cargo.toml
··· 30 30 anyhow = "1.0.100" 31 31 chrono = { version = "0.4.42", features = ["default", "serde"] } 32 32 sha2 = "0.10" 33 + jacquard-api = { version = "0.9.5", features = ["com_atproto"] } 33 34 jacquard-common = "0.9.5" 34 35 jacquard-identity = "0.9.5" 35 36 multibase = "0.9.2"
+1
examples/admin_rbac.yaml
··· 24 24 - "com.atproto.server.createInviteCode" 25 25 - "com.atproto.server.createInviteCodes" 26 26 - "com.atproto.server.createAccount" 27 + - "com.atproto.sync.requestCrawl" 27 28 28 29 moderator: 29 30 description: "Content moderation — view accounts, manage takedowns and subject status"
+112 -245
html_templates/admin/account_detail.hbs
··· 54 54 55 55 .layout { display: flex; min-height: 100vh; } 56 56 57 - .sidebar { 58 - width: 220px; 59 - background: var(--bg-primary-color); 60 - border-right: 1px solid var(--border-color); 61 - padding: 20px 0; 62 - position: fixed; 63 - top: 0; left: 0; bottom: 0; 64 - overflow-y: auto; 65 - display: flex; 66 - flex-direction: column; 67 - } 68 - 69 - .sidebar-title { 70 - font-size: 0.8125rem; 71 - font-weight: 700; 72 - padding: 0 20px; 73 - margin-bottom: 4px; 74 - white-space: nowrap; 75 - overflow: hidden; 76 - text-overflow: ellipsis; 77 - } 78 - 79 - .sidebar-subtitle { 80 - font-size: 0.6875rem; 81 - color: var(--secondary-color); 82 - padding: 0 20px; 83 - margin-bottom: 20px; 84 - } 85 - 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; } 86 60 .sidebar nav { flex: 1; } 87 - 88 - .sidebar nav a { 89 - display: block; 90 - padding: 8px 20px; 91 - font-size: 0.8125rem; 92 - color: var(--secondary-color); 93 - text-decoration: none; 94 - transition: background 0.1s, color 0.1s; 95 - } 96 - 97 - .sidebar nav a:hover { 98 - background: var(--bg-secondary-color); 99 - color: var(--primary-color); 100 - } 101 - 102 - .sidebar nav a.active { 103 - color: var(--brand-color); 104 - font-weight: 500; 105 - } 106 - 107 - .sidebar-footer { 108 - padding: 16px 20px 0; 109 - border-top: 1px solid var(--border-color); 110 - margin-top: 16px; 111 - } 112 - 113 - .sidebar-footer .session-info { 114 - font-size: 0.75rem; 115 - color: var(--secondary-color); 116 - margin-bottom: 8px; 117 - } 118 - 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; } 119 66 .sidebar-footer form { display: inline; } 120 - 121 - .sidebar-footer button { 122 - background: none; 123 - border: none; 124 - font-size: 0.75rem; 125 - color: var(--secondary-color); 126 - cursor: pointer; 127 - padding: 0; 128 - text-decoration: underline; 129 - } 130 - 67 + .sidebar-footer button { background: none; border: none; font-size: 0.75rem; color: var(--secondary-color); cursor: pointer; padding: 0; text-decoration: underline; } 131 68 .sidebar-footer button:hover { color: var(--primary-color); } 132 69 133 - .main { 134 - margin-left: 220px; 135 - flex: 1; 136 - padding: 32px; 137 - max-width: 960px; 138 - } 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; } 139 73 140 - .page-title { 141 - font-size: 1.5rem; 142 - font-weight: 700; 143 - margin-bottom: 4px; 144 - } 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; } 145 76 146 - .page-subtitle { 147 - font-size: 0.8125rem; 148 - color: var(--secondary-color); 149 - font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; 150 - margin-bottom: 24px; 151 - word-break: break-all; 152 - } 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%; } 153 83 154 - .flash-success { 155 - background: rgba(22, 163, 74, 0.1); 156 - color: var(--success-color); 157 - border: 1px solid rgba(22, 163, 74, 0.2); 158 - border-radius: 8px; 159 - padding: 10px 14px; 160 - font-size: 0.875rem; 161 - margin-bottom: 20px; 162 - } 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); } 163 88 164 - .flash-error { 165 - background: rgba(220, 38, 38, 0.1); 166 - color: var(--danger-color); 167 - border: 1px solid rgba(220, 38, 38, 0.2); 168 - border-radius: 8px; 169 - padding: 10px 14px; 170 - font-size: 0.875rem; 171 - margin-bottom: 20px; 172 - } 89 + .actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; } 90 + .actions form { display: inline; } 173 91 174 - .detail-section { 175 - background: var(--bg-primary-color); 176 - border: 1px solid var(--border-color); 177 - border-radius: 10px; 178 - padding: 20px; 179 - margin-bottom: 16px; 180 - } 181 - 182 - .detail-section h3 { 183 - font-size: 0.875rem; 184 - font-weight: 600; 185 - margin-bottom: 12px; 186 - } 187 - 188 - .detail-row { 189 - display: flex; 190 - justify-content: space-between; 191 - align-items: center; 192 - padding: 8px 0; 193 - font-size: 0.8125rem; 194 - border-bottom: 1px solid var(--border-color); 195 - } 196 - 197 - .detail-row:last-child { 198 - border-bottom: none; 199 - } 200 - 201 - .detail-row .label { 202 - color: var(--secondary-color); 203 - flex-shrink: 0; 204 - } 205 - 206 - .detail-row .value { 207 - font-weight: 500; 208 - word-break: break-all; 209 - text-align: right; 210 - max-width: 65%; 211 - } 212 - 213 - .badge { 214 - display: inline-block; 215 - padding: 2px 8px; 216 - border-radius: 4px; 217 - font-size: 0.75rem; 218 - font-weight: 500; 219 - } 220 - 221 - .badge-success { 222 - background: rgba(22, 163, 74, 0.1); 223 - color: var(--success-color); 224 - } 225 - 226 - .badge-danger { 227 - background: rgba(220, 38, 38, 0.1); 228 - color: var(--danger-color); 229 - } 230 - 231 - .badge-warning { 232 - background: rgba(234, 179, 8, 0.1); 233 - color: var(--warning-color); 234 - } 235 - 236 - .actions { 237 - display: flex; 238 - flex-wrap: wrap; 239 - gap: 8px; 240 - margin-top: 8px; 241 - } 242 - 243 - .actions form { 244 - display: inline; 245 - } 246 - 247 - .btn { 248 - display: inline-flex; 249 - align-items: center; 250 - justify-content: center; 251 - padding: 8px 16px; 252 - font-size: 0.8125rem; 253 - font-weight: 500; 254 - border: 1px solid var(--border-color); 255 - border-radius: 8px; 256 - cursor: pointer; 257 - transition: opacity 0.15s; 258 - text-decoration: none; 259 - background: var(--bg-primary-color); 260 - color: var(--primary-color); 261 - } 262 - 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); } 263 93 .btn:hover { opacity: 0.85; } 264 - 265 - .btn-primary { 266 - background: var(--brand-color); 267 - color: #fff; 268 - border-color: var(--brand-color); 269 - } 270 - 271 - .btn-danger { 272 - background: var(--danger-color); 273 - color: #fff; 274 - border-color: var(--danger-color); 275 - } 276 - 277 - .btn-warning { 278 - background: var(--warning-color); 279 - color: #000; 280 - border-color: var(--warning-color); 281 - } 282 - 283 - .password-box { 284 - background: rgba(22, 163, 74, 0.08); 285 - border: 1px solid rgba(22, 163, 74, 0.2); 286 - border-radius: 10px; 287 - padding: 16px 20px; 288 - margin-bottom: 16px; 289 - } 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); } 290 97 291 - .password-box .pw-label { 292 - font-size: 0.75rem; 293 - font-weight: 600; 294 - color: var(--success-color); 295 - margin-bottom: 6px; 296 - } 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; } 297 101 298 - .password-box .pw-value { 299 - font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; 300 - font-size: 1rem; 301 - font-weight: 600; 302 - user-select: all; 303 - } 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; } 304 104 305 - .back-link { 306 - display: inline-block; 307 - color: var(--brand-color); 308 - text-decoration: none; 309 - font-size: 0.8125rem; 310 - margin-bottom: 16px; 311 - } 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; } 312 108 313 - .back-link:hover { text-decoration: underline; } 109 + .threat-sig { font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.75rem; padding: 4px 0; color: var(--secondary-color); } 314 110 315 111 @media (max-width: 768px) { 316 112 .sidebar { display: none; } ··· 325 121 <div class="sidebar-subtitle">Admin Portal</div> 326 122 <nav> 327 123 <a href="/admin/">Dashboard</a> 124 + {{#if can_view_accounts}} 328 125 <a href="/admin/accounts" class="active">Accounts</a> 126 + {{/if}} 329 127 {{#if can_manage_invites}} 330 128 <a href="/admin/invite-codes">Invite Codes</a> 331 129 {{/if}} 332 130 {{#if can_create_account}} 333 131 <a href="/admin/create-account">Create Account</a> 132 + {{/if}} 133 + {{#if can_request_crawl}} 134 + <a href="/admin/request-crawl">Request Crawl</a> 334 135 {{/if}} 335 136 </nav> 336 137 <div class="sidebar-footer"> ··· 381 182 <span class="value">{{account.indexedAt}}</span> 382 183 </div> 383 184 {{/if}} 185 + {{#if handle_resolution_checked}} 186 + <div class="detail-row"> 187 + <span class="label">Handle Resolution</span> 188 + <span class="value"> 189 + {{#if handle_is_correct}} 190 + <span class="badge badge-success">Valid</span> 191 + {{else}} 192 + <span class="badge badge-danger">Failed</span> 193 + {{/if}} 194 + </span> 195 + </div> 196 + {{/if}} 384 197 </div> 385 198 386 199 <div class="detail-section"> ··· 388 201 <div class="detail-row"> 389 202 <span class="label">Takedown</span> 390 203 <span class="value"> 391 - {{#if account.takendown}} 204 + {{#if is_taken_down}} 392 205 <span class="badge badge-danger">Taken Down</span> 393 206 {{else}} 394 207 <span class="badge badge-success">Active</span> 395 208 {{/if}} 396 209 </span> 397 210 </div> 211 + {{#if takedown_ref}} 212 + <div class="detail-row"> 213 + <span class="label">Takedown Reference</span> 214 + <span class="value">{{takedown_ref}}</span> 215 + </div> 216 + {{/if}} 398 217 <div class="detail-row"> 399 218 <span class="label">Email Confirmed</span> 400 219 <span class="value"> ··· 417 236 </div> 418 237 </div> 419 238 239 + {{#if repo_status_checked}} 240 + <div class="detail-section"> 241 + <h3>Repo Status</h3> 242 + <div class="detail-row"> 243 + <span class="label">Active</span> 244 + <span class="value"> 245 + {{#if repo_active}} 246 + <span class="badge badge-success">Active</span> 247 + {{else}} 248 + <span class="badge badge-danger">Inactive</span> 249 + {{/if}} 250 + </span> 251 + </div> 252 + {{#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> 257 + {{/if}} 258 + {{#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> 263 + {{/if}} 264 + </div> 265 + {{/if}} 266 + 420 267 <div class="detail-section"> 421 268 <h3>Invite Information</h3> 422 269 {{#if account.invitedBy}} ··· 443 290 {{/if}} 444 291 </div> 445 292 293 + {{#if collections}} 294 + <div class="detail-section"> 295 + <h3>Collections</h3> 296 + <div class="collection-list"> 297 + {{#each collections}} 298 + <div class="collection-item">{{this}}</div> 299 + {{/each}} 300 + </div> 301 + </div> 302 + {{/if}} 303 + 304 + {{#if threat_signatures}} 305 + <div class="detail-section"> 306 + <h3>Threat Signatures</h3> 307 + {{#each threat_signatures}} 308 + <div class="threat-sig">{{this.property}}: {{this.value}}</div> 309 + {{/each}} 310 + </div> 311 + {{/if}} 312 + 446 313 <div class="detail-section"> 447 314 <h3>Actions</h3> 448 315 <div class="actions"> 449 316 {{#if can_manage_takedowns}} 450 - {{#if account.takendown}} 317 + {{#if is_taken_down}} 451 318 <form method="POST" action="/admin/accounts/{{account.did}}/untakedown"> 452 319 <button type="submit" class="btn btn-primary">Remove Takedown</button> 453 320 </form>
+36 -195
html_templates/admin/accounts.hbs
··· 66 66 flex-direction: column; 67 67 } 68 68 69 - .sidebar-title { 70 - font-size: 0.8125rem; 71 - font-weight: 700; 72 - padding: 0 20px; 73 - margin-bottom: 4px; 74 - white-space: nowrap; 75 - overflow: hidden; 76 - text-overflow: ellipsis; 77 - } 78 - 79 - .sidebar-subtitle { 80 - font-size: 0.6875rem; 81 - color: var(--secondary-color); 82 - padding: 0 20px; 83 - margin-bottom: 20px; 84 - } 85 - 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; } 86 71 .sidebar nav { flex: 1; } 87 - 88 - .sidebar nav a { 89 - display: block; 90 - padding: 8px 20px; 91 - font-size: 0.8125rem; 92 - color: var(--secondary-color); 93 - text-decoration: none; 94 - transition: background 0.1s, color 0.1s; 95 - } 96 - 97 - .sidebar nav a:hover { 98 - background: var(--bg-secondary-color); 99 - color: var(--primary-color); 100 - } 101 - 102 - .sidebar nav a.active { 103 - color: var(--brand-color); 104 - font-weight: 500; 105 - } 106 - 107 - .sidebar-footer { 108 - padding: 16px 20px 0; 109 - border-top: 1px solid var(--border-color); 110 - margin-top: 16px; 111 - } 112 - 113 - .sidebar-footer .session-info { 114 - font-size: 0.75rem; 115 - color: var(--secondary-color); 116 - margin-bottom: 8px; 117 - } 118 - 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; } 119 77 .sidebar-footer form { display: inline; } 120 - 121 - .sidebar-footer button { 122 - background: none; 123 - border: none; 124 - font-size: 0.75rem; 125 - color: var(--secondary-color); 126 - cursor: pointer; 127 - padding: 0; 128 - text-decoration: underline; 129 - } 130 - 78 + .sidebar-footer button { background: none; border: none; font-size: 0.75rem; color: var(--secondary-color); cursor: pointer; padding: 0; text-decoration: underline; } 131 79 .sidebar-footer button:hover { color: var(--primary-color); } 132 80 133 - .main { 134 - margin-left: 220px; 135 - flex: 1; 136 - padding: 32px; 137 - max-width: 960px; 138 - } 139 - 140 - .page-title { 141 - font-size: 1.5rem; 142 - font-weight: 700; 143 - margin-bottom: 24px; 144 - } 145 - 146 - .flash-success { 147 - background: rgba(22, 163, 74, 0.1); 148 - color: var(--success-color); 149 - border: 1px solid rgba(22, 163, 74, 0.2); 150 - border-radius: 8px; 151 - padding: 10px 14px; 152 - font-size: 0.875rem; 153 - margin-bottom: 20px; 154 - } 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; } 155 83 156 - .flash-error { 157 - background: rgba(220, 38, 38, 0.1); 158 - color: var(--danger-color); 159 - border: 1px solid rgba(220, 38, 38, 0.2); 160 - border-radius: 8px; 161 - padding: 10px 14px; 162 - font-size: 0.875rem; 163 - margin-bottom: 20px; 164 - } 165 - 166 - .search-form { 167 - display: flex; 168 - gap: 8px; 169 - margin-bottom: 24px; 170 - } 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; } 171 86 172 - .search-form input { 173 - flex: 1; 174 - padding: 10px 12px; 175 - font-size: 0.875rem; 176 - border: 1px solid var(--border-color); 177 - border-radius: 8px; 178 - background: var(--bg-primary-color); 179 - color: var(--primary-color); 180 - outline: none; 181 - } 182 - 183 - .search-form input:focus { 184 - border-color: var(--brand-color); 185 - } 186 - 187 - .btn { 188 - display: inline-flex; 189 - align-items: center; 190 - justify-content: center; 191 - padding: 10px 20px; 192 - font-size: 0.875rem; 193 - font-weight: 500; 194 - border: none; 195 - border-radius: 8px; 196 - cursor: pointer; 197 - transition: opacity 0.15s; 198 - text-decoration: none; 199 - } 200 - 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; } 201 91 .btn:hover { opacity: 0.85; } 202 - 203 - .btn-primary { 204 - background: var(--brand-color); 205 - color: #fff; 206 - } 92 + .btn-primary { background: var(--brand-color); color: #fff; } 207 93 208 - .table-container { 209 - background: var(--bg-primary-color); 210 - border: 1px solid var(--border-color); 211 - border-radius: 10px; 212 - overflow: hidden; 213 - } 214 - 215 - table { 216 - width: 100%; 217 - border-collapse: collapse; 218 - } 219 - 220 - thead th { 221 - text-align: left; 222 - padding: 12px 16px; 223 - font-size: 0.75rem; 224 - font-weight: 600; 225 - color: var(--secondary-color); 226 - text-transform: uppercase; 227 - letter-spacing: 0.5px; 228 - border-bottom: 1px solid var(--border-color); 229 - } 230 - 231 - tbody tr { 232 - border-bottom: 1px solid var(--border-color); 233 - } 234 - 235 - tbody tr:last-child { 236 - border-bottom: none; 237 - } 238 - 239 - tbody tr:nth-child(even) { 240 - background: var(--table-stripe); 241 - } 242 - 243 - tbody td { 244 - padding: 10px 16px; 245 - font-size: 0.8125rem; 246 - } 247 - 248 - tbody td a { 249 - color: var(--brand-color); 250 - text-decoration: none; 251 - } 252 - 253 - tbody td a:hover { 254 - text-decoration: underline; 255 - } 256 - 257 - .empty-state { 258 - text-align: center; 259 - padding: 40px 20px; 260 - color: var(--secondary-color); 261 - font-size: 0.875rem; 262 - } 263 - 264 - .did-cell { 265 - font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; 266 - font-size: 0.75rem; 267 - color: var(--secondary-color); 268 - } 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); } 269 105 270 106 @media (max-width: 768px) { 271 107 .sidebar { display: none; } ··· 280 116 <div class="sidebar-subtitle">Admin Portal</div> 281 117 <nav> 282 118 <a href="/admin/">Dashboard</a> 119 + {{#if can_view_accounts}} 283 120 <a href="/admin/accounts" class="active">Accounts</a> 121 + {{/if}} 284 122 {{#if can_manage_invites}} 285 123 <a href="/admin/invite-codes">Invite Codes</a> 286 124 {{/if}} 287 125 {{#if can_create_account}} 288 126 <a href="/admin/create-account">Create Account</a> 289 127 {{/if}} 128 + {{#if can_request_crawl}} 129 + <a href="/admin/request-crawl">Request Crawl</a> 130 + {{/if}} 290 131 </nav> 291 132 <div class="sidebar-footer"> 292 133 <div class="session-info">Signed in as {{handle}}</div> ··· 307 148 <h1 class="page-title">Accounts</h1> 308 149 309 150 <form class="search-form" method="GET" action="/admin/search"> 310 - <input type="text" name="q" placeholder="Search by handle or DID..." value="{{query}}" /> 151 + <input type="text" name="q" placeholder="Search by email..." value="{{search_query}}" /> 311 152 <button type="submit" class="btn btn-primary">Search</button> 312 153 </form> 313 154 ··· 334 175 </div> 335 176 {{else}} 336 177 <div class="empty-state"> 337 - {{#if query}} 338 - No accounts matching "{{query}}" 178 + {{#if search_query}} 179 + No accounts matching "{{search_query}}" 339 180 {{else}} 340 181 No accounts found 341 182 {{/if}}
+64 -210
html_templates/admin/create_account.hbs
··· 54 54 55 55 .layout { display: flex; min-height: 100vh; } 56 56 57 - .sidebar { 58 - width: 220px; 59 - background: var(--bg-primary-color); 60 - border-right: 1px solid var(--border-color); 61 - padding: 20px 0; 62 - position: fixed; 63 - top: 0; left: 0; bottom: 0; 64 - overflow-y: auto; 65 - display: flex; 66 - flex-direction: column; 67 - } 68 - 69 - .sidebar-title { 70 - font-size: 0.8125rem; 71 - font-weight: 700; 72 - padding: 0 20px; 73 - margin-bottom: 4px; 74 - white-space: nowrap; 75 - overflow: hidden; 76 - text-overflow: ellipsis; 77 - } 78 - 79 - .sidebar-subtitle { 80 - font-size: 0.6875rem; 81 - color: var(--secondary-color); 82 - padding: 0 20px; 83 - margin-bottom: 20px; 84 - } 85 - 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; } 86 60 .sidebar nav { flex: 1; } 87 - 88 - .sidebar nav a { 89 - display: block; 90 - padding: 8px 20px; 91 - font-size: 0.8125rem; 92 - color: var(--secondary-color); 93 - text-decoration: none; 94 - transition: background 0.1s, color 0.1s; 95 - } 96 - 97 - .sidebar nav a:hover { 98 - background: var(--bg-secondary-color); 99 - color: var(--primary-color); 100 - } 101 - 102 - .sidebar nav a.active { 103 - color: var(--brand-color); 104 - font-weight: 500; 105 - } 106 - 107 - .sidebar-footer { 108 - padding: 16px 20px 0; 109 - border-top: 1px solid var(--border-color); 110 - margin-top: 16px; 111 - } 112 - 113 - .sidebar-footer .session-info { 114 - font-size: 0.75rem; 115 - color: var(--secondary-color); 116 - margin-bottom: 8px; 117 - } 118 - 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; } 119 66 .sidebar-footer form { display: inline; } 120 - 121 - .sidebar-footer button { 122 - background: none; 123 - border: none; 124 - font-size: 0.75rem; 125 - color: var(--secondary-color); 126 - cursor: pointer; 127 - padding: 0; 128 - text-decoration: underline; 129 - } 130 - 67 + .sidebar-footer button { background: none; border: none; font-size: 0.75rem; color: var(--secondary-color); cursor: pointer; padding: 0; text-decoration: underline; } 131 68 .sidebar-footer button:hover { color: var(--primary-color); } 132 69 133 - .main { 134 - margin-left: 220px; 135 - flex: 1; 136 - padding: 32px; 137 - max-width: 960px; 138 - } 139 - 140 - .page-title { 141 - font-size: 1.5rem; 142 - font-weight: 700; 143 - margin-bottom: 24px; 144 - } 145 - 146 - .flash-success { 147 - background: rgba(22, 163, 74, 0.1); 148 - color: var(--success-color); 149 - border: 1px solid rgba(22, 163, 74, 0.2); 150 - border-radius: 8px; 151 - padding: 10px 14px; 152 - font-size: 0.875rem; 153 - margin-bottom: 20px; 154 - } 155 - 156 - .flash-error { 157 - background: rgba(220, 38, 38, 0.1); 158 - color: var(--danger-color); 159 - border: 1px solid rgba(220, 38, 38, 0.2); 160 - border-radius: 8px; 161 - padding: 10px 14px; 162 - font-size: 0.875rem; 163 - margin-bottom: 20px; 164 - } 165 - 166 - .form-card { 167 - background: var(--bg-primary-color); 168 - border: 1px solid var(--border-color); 169 - border-radius: 10px; 170 - padding: 24px; 171 - max-width: 480px; 172 - } 173 - 174 - .form-group { 175 - margin-bottom: 16px; 176 - } 177 - 178 - .form-group label { 179 - display: block; 180 - font-size: 0.8125rem; 181 - font-weight: 500; 182 - margin-bottom: 6px; 183 - color: var(--primary-color); 184 - } 185 - 186 - .form-group input { 187 - width: 100%; 188 - padding: 10px 12px; 189 - font-size: 0.875rem; 190 - border: 1px solid var(--border-color); 191 - border-radius: 8px; 192 - background: var(--bg-primary-color); 193 - color: var(--primary-color); 194 - outline: none; 195 - transition: border-color 0.15s; 196 - } 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; } 197 72 198 - .form-group input:focus { 199 - border-color: var(--brand-color); 200 - } 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; } 201 75 202 - .form-group .hint { 203 - font-size: 0.75rem; 204 - color: var(--secondary-color); 205 - margin-top: 4px; 206 - } 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; } 207 82 208 - .btn { 209 - display: inline-flex; 210 - align-items: center; 211 - justify-content: center; 212 - padding: 10px 20px; 213 - font-size: 0.875rem; 214 - font-weight: 500; 215 - border: none; 216 - border-radius: 8px; 217 - cursor: pointer; 218 - transition: opacity 0.15s; 219 - text-decoration: none; 220 - } 221 - 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; } 222 84 .btn:hover { opacity: 0.85; } 85 + .btn-primary { background: var(--brand-color); color: #fff; } 223 86 224 - .btn-primary { 225 - background: var(--brand-color); 226 - color: #fff; 227 - } 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; } 228 89 229 - .success-card { 230 - background: var(--bg-primary-color); 231 - border: 1px solid var(--border-color); 232 - border-radius: 10px; 233 - padding: 24px; 234 - max-width: 480px; 235 - } 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; } 236 94 237 - .success-card h3 { 238 - font-size: 1rem; 239 - font-weight: 600; 240 - color: var(--success-color); 241 - margin-bottom: 16px; 242 - } 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; } 243 96 244 - .detail-row { 245 - display: flex; 246 - justify-content: space-between; 247 - padding: 8px 0; 248 - font-size: 0.8125rem; 249 - border-bottom: 1px solid var(--border-color); 250 - } 251 - 252 - .detail-row:last-child { 253 - border-bottom: none; 254 - } 255 - 256 - .detail-row .label { 257 - color: var(--secondary-color); 258 - } 259 - 260 - .detail-row .value { 261 - font-weight: 500; 262 - word-break: break-all; 263 - text-align: right; 264 - max-width: 65%; 265 - } 266 - 267 - .password-highlight { 268 - font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; 269 - background: rgba(22, 163, 74, 0.08); 270 - padding: 2px 6px; 271 - border-radius: 4px; 272 - user-select: all; 273 - } 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); } 274 99 275 100 @media (max-width: 768px) { 276 101 .sidebar { display: none; } ··· 285 110 <div class="sidebar-subtitle">Admin Portal</div> 286 111 <nav> 287 112 <a href="/admin/">Dashboard</a> 113 + {{#if can_view_accounts}} 288 114 <a href="/admin/accounts">Accounts</a> 115 + {{/if}} 289 116 {{#if can_manage_invites}} 290 117 <a href="/admin/invite-codes">Invite Codes</a> 291 118 {{/if}} 292 119 {{#if can_create_account}} 293 120 <a href="/admin/create-account" class="active">Create Account</a> 121 + {{/if}} 122 + {{#if can_request_crawl}} 123 + <a href="/admin/request-crawl">Request Crawl</a> 294 124 {{/if}} 295 125 </nav> 296 126 <div class="sidebar-footer"> ··· 316 146 <h3>Account Created Successfully</h3> 317 147 <div class="detail-row"> 318 148 <span class="label">DID</span> 319 - <span class="value">{{created.did}}</span> 149 + <span class="value">{{created.did}} <button class="copy-btn" onclick="copyToClipboard('{{created.did}}', this)">Copy</button></span> 320 150 </div> 321 151 <div class="detail-row"> 322 152 <span class="label">Handle</span> 323 - <span class="value">{{created.handle}}</span> 153 + <span class="value">{{created.handle}} <button class="copy-btn" onclick="copyToClipboard('{{created.handle}}', this)">Copy</button></span> 324 154 </div> 325 155 <div class="detail-row"> 326 156 <span class="label">Email</span> 327 - <span class="value">{{created.email}}</span> 157 + <span class="value">{{created.email}} <button class="copy-btn" onclick="copyToClipboard('{{created.email}}', this)">Copy</button></span> 328 158 </div> 329 159 <div class="detail-row"> 330 160 <span class="label">Password</span> 331 - <span class="value"><span class="password-highlight">{{created.password}}</span></span> 161 + <span class="value"><span class="password-highlight">{{created.password}}</span> <button class="copy-btn" onclick="copyToClipboard('{{created.password}}', this)">Copy</button></span> 332 162 </div> 333 163 {{#if created.inviteCode}} 334 164 <div class="detail-row"> 335 165 <span class="label">Invite Code Used</span> 336 - <span class="value">{{created.inviteCode}}</span> 166 + <span class="value">{{created.inviteCode}} <button class="copy-btn" onclick="copyToClipboard('{{created.inviteCode}}', this)">Copy</button></span> 337 167 </div> 338 168 {{/if}} 339 169 </div> ··· 355 185 {{/if}} 356 186 </main> 357 187 </div> 188 + 189 + <script> 190 + function copyToClipboard(text, btn) { 191 + if (navigator.clipboard && navigator.clipboard.writeText) { 192 + navigator.clipboard.writeText(text).then(function() { 193 + var orig = btn.textContent; 194 + btn.textContent = 'Copied'; 195 + setTimeout(function() { btn.textContent = orig; }, 1500); 196 + }); 197 + } else { 198 + var el = document.createElement('textarea'); 199 + el.value = text; 200 + el.style.position = 'fixed'; 201 + el.style.opacity = '0'; 202 + document.body.appendChild(el); 203 + el.select(); 204 + document.execCommand('copy'); 205 + document.body.removeChild(el); 206 + var orig = btn.textContent; 207 + btn.textContent = 'Copied'; 208 + setTimeout(function() { btn.textContent = orig; }, 1500); 209 + } 210 + } 211 + </script> 358 212 </body> 359 213 </html>
+48
html_templates/admin/dashboard.hbs
··· 242 242 max-width: 60%; 243 243 } 244 244 245 + .detail-row .value a { 246 + color: var(--brand-color); 247 + text-decoration: none; 248 + } 249 + 250 + .detail-row .value a:hover { 251 + text-decoration: underline; 252 + } 253 + 245 254 @media (max-width: 768px) { 246 255 .sidebar { 247 256 display: none; ··· 259 268 <div class="sidebar-subtitle">Admin Portal</div> 260 269 <nav> 261 270 <a href="/admin/" class="active">Dashboard</a> 271 + {{#if can_view_accounts}} 262 272 <a href="/admin/accounts">Accounts</a> 273 + {{/if}} 263 274 {{#if can_manage_invites}} 264 275 <a href="/admin/invite-codes">Invite Codes</a> 265 276 {{/if}} 266 277 {{#if can_create_account}} 267 278 <a href="/admin/create-account">Create Account</a> 268 279 {{/if}} 280 + {{#if can_request_crawl}} 281 + <a href="/admin/request-crawl">Request Crawl</a> 282 + {{/if}} 269 283 </nav> 270 284 <div class="sidebar-footer"> 271 285 <div class="session-info">Signed in as {{handle}}</div> ··· 290 304 <div class="card-label">PDS Version</div> 291 305 <div class="card-value">{{version}}</div> 292 306 </div> 307 + {{#if can_view_accounts}} 293 308 <div class="card"> 294 309 <div class="card-label">Total Accounts</div> 295 310 <div class="card-value">{{account_count}}</div> 296 311 </div> 312 + {{/if}} 297 313 <div class="card"> 298 314 <div class="card-label">Invite Code Required</div> 299 315 <div class="card-value">{{#if invite_code_required}}Yes{{else}}No{{/if}}</div> 300 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> 301 321 </div> 302 322 303 323 <div class="detail-section"> ··· 317 337 </div> 318 338 {{/if}} 319 339 </div> 340 + 341 + {{#if privacy_policy}} 342 + <div class="detail-section"> 343 + <h3>Server Links</h3> 344 + {{#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> 349 + {{/if}} 350 + {{#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> 355 + {{/if}} 356 + </div> 357 + {{else}} 358 + {{#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> 364 + </div> 365 + </div> 366 + {{/if}} 367 + {{/if}} 320 368 </main> 321 369 </div> 322 370 </body>
+53 -256
html_templates/admin/invite_codes.hbs
··· 54 54 55 55 .layout { display: flex; min-height: 100vh; } 56 56 57 - .sidebar { 58 - width: 220px; 59 - background: var(--bg-primary-color); 60 - border-right: 1px solid var(--border-color); 61 - padding: 20px 0; 62 - position: fixed; 63 - top: 0; left: 0; bottom: 0; 64 - overflow-y: auto; 65 - display: flex; 66 - flex-direction: column; 67 - } 68 - 69 - .sidebar-title { 70 - font-size: 0.8125rem; 71 - font-weight: 700; 72 - padding: 0 20px; 73 - margin-bottom: 4px; 74 - white-space: nowrap; 75 - overflow: hidden; 76 - text-overflow: ellipsis; 77 - } 78 - 79 - .sidebar-subtitle { 80 - font-size: 0.6875rem; 81 - color: var(--secondary-color); 82 - padding: 0 20px; 83 - margin-bottom: 20px; 84 - } 85 - 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; } 86 60 .sidebar nav { flex: 1; } 87 - 88 - .sidebar nav a { 89 - display: block; 90 - padding: 8px 20px; 91 - font-size: 0.8125rem; 92 - color: var(--secondary-color); 93 - text-decoration: none; 94 - transition: background 0.1s, color 0.1s; 95 - } 96 - 97 - .sidebar nav a:hover { 98 - background: var(--bg-secondary-color); 99 - color: var(--primary-color); 100 - } 101 - 102 - .sidebar nav a.active { 103 - color: var(--brand-color); 104 - font-weight: 500; 105 - } 106 - 107 - .sidebar-footer { 108 - padding: 16px 20px 0; 109 - border-top: 1px solid var(--border-color); 110 - margin-top: 16px; 111 - } 112 - 113 - .sidebar-footer .session-info { 114 - font-size: 0.75rem; 115 - color: var(--secondary-color); 116 - margin-bottom: 8px; 117 - } 118 - 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; } 119 66 .sidebar-footer form { display: inline; } 120 - 121 - .sidebar-footer button { 122 - background: none; 123 - border: none; 124 - font-size: 0.75rem; 125 - color: var(--secondary-color); 126 - cursor: pointer; 127 - padding: 0; 128 - text-decoration: underline; 129 - } 130 - 67 + .sidebar-footer button { background: none; border: none; font-size: 0.75rem; color: var(--secondary-color); cursor: pointer; padding: 0; text-decoration: underline; } 131 68 .sidebar-footer button:hover { color: var(--primary-color); } 132 69 133 - .main { 134 - margin-left: 220px; 135 - flex: 1; 136 - padding: 32px; 137 - max-width: 960px; 138 - } 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; } 139 72 140 - .page-title { 141 - font-size: 1.5rem; 142 - font-weight: 700; 143 - margin-bottom: 24px; 144 - } 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; } 145 75 146 - .flash-success { 147 - background: rgba(22, 163, 74, 0.1); 148 - color: var(--success-color); 149 - border: 1px solid rgba(22, 163, 74, 0.2); 150 - border-radius: 8px; 151 - padding: 10px 14px; 152 - font-size: 0.875rem; 153 - margin-bottom: 20px; 154 - } 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); } 155 81 156 - .flash-error { 157 - background: rgba(220, 38, 38, 0.1); 158 - color: var(--danger-color); 159 - border: 1px solid rgba(220, 38, 38, 0.2); 160 - border-radius: 8px; 161 - padding: 10px 14px; 162 - font-size: 0.875rem; 163 - margin-bottom: 20px; 164 - } 165 - 166 - .create-form { 167 - display: flex; 168 - gap: 8px; 169 - align-items: flex-end; 170 - margin-bottom: 24px; 171 - } 172 - 173 - .create-form .form-group { 174 - display: flex; 175 - flex-direction: column; 176 - gap: 4px; 177 - } 178 - 179 - .create-form label { 180 - font-size: 0.75rem; 181 - font-weight: 500; 182 - color: var(--secondary-color); 183 - } 184 - 185 - .create-form input[type="number"] { 186 - padding: 10px 12px; 187 - font-size: 0.875rem; 188 - border: 1px solid var(--border-color); 189 - border-radius: 8px; 190 - background: var(--bg-primary-color); 191 - color: var(--primary-color); 192 - outline: none; 193 - width: 120px; 194 - } 195 - 196 - .create-form input[type="number"]:focus { 197 - border-color: var(--brand-color); 198 - } 199 - 200 - .btn { 201 - display: inline-flex; 202 - align-items: center; 203 - justify-content: center; 204 - padding: 10px 20px; 205 - font-size: 0.875rem; 206 - font-weight: 500; 207 - border: none; 208 - border-radius: 8px; 209 - cursor: pointer; 210 - transition: opacity 0.15s; 211 - text-decoration: none; 212 - } 213 - 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; } 214 83 .btn:hover { opacity: 0.85; } 215 - 216 - .btn-primary { 217 - background: var(--brand-color); 218 - color: #fff; 219 - } 220 - 221 - .btn-small { 222 - padding: 6px 12px; 223 - font-size: 0.75rem; 224 - } 225 - 226 - .btn-outline-danger { 227 - background: transparent; 228 - color: var(--danger-color); 229 - border: 1px solid var(--danger-color); 230 - } 231 - 232 - .code-box { 233 - background: rgba(22, 163, 74, 0.08); 234 - border: 1px solid rgba(22, 163, 74, 0.2); 235 - border-radius: 10px; 236 - padding: 16px 20px; 237 - margin-bottom: 24px; 238 - } 239 - 240 - .code-box .code-label { 241 - font-size: 0.75rem; 242 - font-weight: 600; 243 - color: var(--success-color); 244 - margin-bottom: 6px; 245 - } 246 - 247 - .code-box .code-value { 248 - font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; 249 - font-size: 1rem; 250 - font-weight: 600; 251 - user-select: all; 252 - } 253 - 254 - .table-container { 255 - background: var(--bg-primary-color); 256 - border: 1px solid var(--border-color); 257 - border-radius: 10px; 258 - overflow: hidden; 259 - } 260 - 261 - table { 262 - width: 100%; 263 - border-collapse: collapse; 264 - } 265 - 266 - thead th { 267 - text-align: left; 268 - padding: 12px 16px; 269 - font-size: 0.75rem; 270 - font-weight: 600; 271 - color: var(--secondary-color); 272 - text-transform: uppercase; 273 - letter-spacing: 0.5px; 274 - border-bottom: 1px solid var(--border-color); 275 - } 276 - 277 - tbody tr { 278 - border-bottom: 1px solid var(--border-color); 279 - } 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); } 280 87 281 - tbody tr:last-child { 282 - border-bottom: none; 283 - } 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; } 284 91 285 - tbody tr:nth-child(even) { 286 - background: var(--table-stripe); 287 - } 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; } 288 100 289 - tbody td { 290 - padding: 10px 16px; 291 - font-size: 0.8125rem; 292 - } 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); } 293 104 294 - .code-cell { 295 - font-family: 'SF Mono', SFMono-Regular, Menlo, Consolas, monospace; 296 - font-size: 0.75rem; 297 - } 105 + .empty-state { text-align: center; padding: 40px 20px; color: var(--secondary-color); font-size: 0.875rem; } 298 106 299 - .badge { 300 - display: inline-block; 301 - padding: 2px 8px; 302 - border-radius: 4px; 303 - font-size: 0.75rem; 304 - font-weight: 500; 305 - } 306 - 307 - .badge-success { 308 - background: rgba(22, 163, 74, 0.1); 309 - color: var(--success-color); 310 - } 311 - 312 - .badge-danger { 313 - background: rgba(220, 38, 38, 0.1); 314 - color: var(--danger-color); 315 - } 316 - 317 - .empty-state { 318 - text-align: center; 319 - padding: 40px 20px; 320 - color: var(--secondary-color); 321 - font-size: 0.875rem; 322 - } 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; } 323 110 324 111 @media (max-width: 768px) { 325 112 .sidebar { display: none; } ··· 334 121 <div class="sidebar-subtitle">Admin Portal</div> 335 122 <nav> 336 123 <a href="/admin/">Dashboard</a> 124 + {{#if can_view_accounts}} 337 125 <a href="/admin/accounts">Accounts</a> 126 + {{/if}} 338 127 {{#if can_manage_invites}} 339 128 <a href="/admin/invite-codes" class="active">Invite Codes</a> 340 129 {{/if}} 341 130 {{#if can_create_account}} 342 131 <a href="/admin/create-account">Create Account</a> 132 + {{/if}} 133 + {{#if can_request_crawl}} 134 + <a href="/admin/request-crawl">Request Crawl</a> 343 135 {{/if}} 344 136 </nav> 345 137 <div class="sidebar-footer"> ··· 383 175 <thead> 384 176 <tr> 385 177 <th>Code</th> 386 - <th>Available / Used</th> 178 + <th>Remaining / Total</th> 387 179 <th>Status</th> 388 180 <th>Created By</th> 389 181 <th>Created At</th> ··· 396 188 {{#each codes}} 397 189 <tr> 398 190 <td class="code-cell">{{this.code}}</td> 399 - <td>{{this.available}} / {{this.uses}}</td> 191 + <td>{{this.remaining}} / {{this.available}}</td> 400 192 <td> 401 193 {{#if this.disabled}} 402 194 <span class="badge badge-danger">Disabled</span> ··· 410 202 <td> 411 203 {{#unless this.disabled}} 412 204 <form method="POST" action="/admin/invite-codes/disable" style="display:inline;"> 413 - <input type="hidden" name="code" value="{{this.code}}" /> 205 + <input type="hidden" name="codes" value="{{this.code}}" /> 414 206 <button type="submit" class="btn btn-small btn-outline-danger">Disable</button> 415 207 </form> 416 208 {{/unless}} ··· 421 213 </tbody> 422 214 </table> 423 215 </div> 216 + {{#if has_more}} 217 + <div class="load-more"> 218 + <a href="/admin/invite-codes?cursor={{next_cursor}}">Load More</a> 219 + </div> 220 + {{/if}} 424 221 {{else}} 425 222 <div class="empty-state">No invite codes found</div> 426 223 {{/if}}
+146
html_templates/admin/request_crawl.hbs
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8"/> 5 + <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, viewport-fit=cover"/> 6 + <meta name="referrer" content="origin-when-cross-origin"/> 7 + <title>Request Crawl - {{pds_hostname}}</title> 8 + <style> 9 + :root, 10 + :root.light-mode { 11 + --brand-color: rgb(16, 131, 254); 12 + --primary-color: rgb(7, 10, 13); 13 + --secondary-color: rgb(66, 86, 108); 14 + --bg-primary-color: rgb(255, 255, 255); 15 + --bg-secondary-color: rgb(240, 242, 245); 16 + --border-color: rgb(220, 225, 230); 17 + --danger-color: rgb(220, 38, 38); 18 + --success-color: rgb(22, 163, 74); 19 + --warning-color: rgb(234, 179, 8); 20 + --table-stripe: rgba(0, 0, 0, 0.02); 21 + } 22 + 23 + @media (prefers-color-scheme: dark) { 24 + :root { 25 + --brand-color: rgb(16, 131, 254); 26 + --primary-color: rgb(255, 255, 255); 27 + --secondary-color: rgb(133, 152, 173); 28 + --bg-primary-color: rgb(7, 10, 13); 29 + --bg-secondary-color: rgb(13, 18, 23); 30 + --border-color: rgb(40, 45, 55); 31 + --table-stripe: rgba(255, 255, 255, 0.02); 32 + } 33 + } 34 + 35 + :root.dark-mode { 36 + --brand-color: rgb(16, 131, 254); 37 + --primary-color: rgb(255, 255, 255); 38 + --secondary-color: rgb(133, 152, 173); 39 + --bg-primary-color: rgb(7, 10, 13); 40 + --bg-secondary-color: rgb(13, 18, 23); 41 + --border-color: rgb(40, 45, 55); 42 + --table-stripe: rgba(255, 255, 255, 0.02); 43 + } 44 + 45 + * { margin: 0; padding: 0; box-sizing: border-box; } 46 + 47 + body { 48 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; 49 + background: var(--bg-secondary-color); 50 + color: var(--primary-color); 51 + text-rendering: optimizeLegibility; 52 + -webkit-font-smoothing: antialiased; 53 + } 54 + 55 + .layout { display: flex; min-height: 100vh; } 56 + 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); } 69 + 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; } 73 + 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; } 76 + 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; } 83 + 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; } 87 + 88 + @media (max-width: 768px) { 89 + .sidebar { display: none; } 90 + .main { margin-left: 0; } 91 + } 92 + </style> 93 + </head> 94 + <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}} 102 + <a href="/admin/accounts">Accounts</a> 103 + {{/if}} 104 + {{#if can_manage_invites}} 105 + <a href="/admin/invite-codes">Invite Codes</a> 106 + {{/if}} 107 + {{#if can_create_account}} 108 + <a href="/admin/create-account">Create Account</a> 109 + {{/if}} 110 + {{#if can_request_crawl}} 111 + <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> 121 + 122 + <main class="main"> 123 + {{#if flash_success}} 124 + <div class="flash-success">{{flash_success}}</div> 125 + {{/if}} 126 + {{#if flash_error}} 127 + <div class="flash-error">{{flash_error}}</div> 128 + {{/if}} 129 + 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> 132 + 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> 145 + </body> 146 + </html>
+3
src/admin/middleware.rs
··· 30 30 pub can_manage_invites: bool, 31 31 pub can_create_invite: bool, 32 32 pub can_send_email: bool, 33 + pub can_request_crawl: bool, 33 34 } 34 35 35 36 impl AdminPermissions { ··· 51 52 can_create_invite: rbac 52 53 .can_access_endpoint(did, "com.atproto.server.createInviteCode"), 53 54 can_send_email: rbac.can_access_endpoint(did, "com.atproto.admin.sendEmail"), 55 + can_request_crawl: rbac 56 + .can_access_endpoint(did, "com.atproto.sync.requestCrawl"), 54 57 } 55 58 } 56 59 }
+4
src/admin/mod.rs
··· 59 59 get(routes::get_create_account).post(routes::post_create_account), 60 60 ) 61 61 .route("/search", get(routes::search_accounts)) 62 + .route( 63 + "/request-crawl", 64 + get(routes::get_request_crawl).post(routes::post_request_crawl), 65 + ) 62 66 .route("/logout", post(routes::logout)) 63 67 .layer(ax_middleware::from_fn_with_state( 64 68 state.clone(),
+45
src/admin/pds_proxy.rs
··· 204 204 .map_err(|e| AdminProxyError::ParseError(e.to_string())) 205 205 } 206 206 207 + /// Make an unauthenticated POST request to an arbitrary base URL (e.g., relay). 208 + pub async fn public_xrpc_post<T: Serialize>( 209 + base_url: &str, 210 + endpoint: &str, 211 + body: &T, 212 + ) -> Result<(), AdminProxyError> { 213 + let url = format!( 214 + "{}/xrpc/{}", 215 + base_url.trim_end_matches('/'), 216 + endpoint 217 + ); 218 + let client = reqwest::Client::new(); 219 + let resp = client 220 + .post(&url) 221 + .json(body) 222 + .send() 223 + .await 224 + .map_err(|e| AdminProxyError::RequestFailed(e.to_string()))?; 225 + 226 + if !resp.status().is_success() { 227 + let status = resp.status().as_u16(); 228 + let body = resp.text().await.unwrap_or_default(); 229 + if let Ok(err_json) = serde_json::from_str::<serde_json::Value>(&body) { 230 + return Err(AdminProxyError::PdsError { 231 + status, 232 + error: err_json["error"] 233 + .as_str() 234 + .unwrap_or("Unknown") 235 + .to_string(), 236 + message: err_json["message"] 237 + .as_str() 238 + .unwrap_or(&body) 239 + .to_string(), 240 + }); 241 + } 242 + return Err(AdminProxyError::PdsError { 243 + status, 244 + error: "Unknown".to_string(), 245 + message: body, 246 + }); 247 + } 248 + 249 + Ok(()) 250 + } 251 + 207 252 /// Make an unauthenticated GET request with JSON response parsing. 208 253 pub async fn public_xrpc_get<R: DeserializeOwned>( 209 254 pds_base_url: &str,
+391 -195
src/admin/routes.rs
··· 4 4 response::{Html, IntoResponse, Redirect, Response}, 5 5 }; 6 6 use axum_extra::extract::cookie::{Cookie, SignedCookieJar}; 7 - use serde::{Deserialize, Serialize}; 7 + use serde::Deserialize; 8 8 9 9 use crate::AppState; 10 10 ··· 12 12 use super::pds_proxy; 13 13 use super::session; 14 14 15 - // ─── Response types from PDS API ───────────────────────────────────────────── 16 - 17 - #[derive(Debug, Deserialize, Serialize)] 18 - pub struct HealthResponse { 19 - pub version: Option<String>, 20 - } 21 - 22 - #[derive(Debug, Deserialize, Serialize)] 23 - #[serde(rename_all = "camelCase")] 24 - pub struct DescribeServerResponse { 25 - pub did: Option<String>, 26 - #[serde(default)] 27 - pub available_user_domains: Vec<String>, 28 - pub invite_code_required: Option<bool>, 29 - pub phone_verification_required: Option<bool>, 30 - } 31 - 32 - #[derive(Debug, Deserialize, Serialize)] 33 - pub struct ListReposResponse { 34 - #[serde(default)] 35 - pub repos: Vec<RepoInfo>, 36 - pub cursor: Option<String>, 37 - } 38 - 39 - #[derive(Debug, Deserialize, Serialize)] 40 - pub struct RepoInfo { 41 - pub did: String, 42 - pub head: Option<String>, 43 - } 44 - 45 - #[derive(Debug, Deserialize, Serialize)] 46 - #[serde(rename_all = "camelCase")] 47 - pub struct AccountInfo { 48 - pub did: String, 49 - pub handle: String, 50 - pub email: Option<String>, 51 - pub indexed_at: Option<String>, 52 - pub invited_by: Option<InviteCodeRef>, 53 - #[serde(default)] 54 - pub invites: Vec<InviteCodeInfo>, 55 - pub invites_disabled: Option<bool>, 56 - pub email_confirmed_at: Option<String>, 57 - pub deactivated_at: Option<String>, 58 - #[serde(default)] 59 - pub related_records: Vec<serde_json::Value>, 60 - pub invite_note: Option<String>, 61 - } 15 + // ─── Query parameter types ─────────────────────────────────────────────────── 62 16 63 - #[derive(Debug, Deserialize, Serialize)] 64 - pub struct InviteCodeRef { 65 - pub code: Option<String>, 17 + #[derive(Debug, Deserialize)] 18 + pub struct FlashParams { 19 + pub flash_success: Option<String>, 20 + pub flash_error: Option<String>, 66 21 } 67 22 68 - #[derive(Debug, Deserialize, Serialize)] 69 - #[serde(rename_all = "camelCase")] 70 - pub struct GetAccountInfosResponse { 71 - #[serde(default)] 72 - pub infos: Vec<AccountInfo>, 73 - } 74 - 75 - #[derive(Debug, Deserialize, Serialize)] 76 - #[serde(rename_all = "camelCase")] 77 - pub struct SubjectStatusResponse { 78 - pub subject: Option<serde_json::Value>, 79 - pub takedown: Option<StatusAttr>, 23 + #[derive(Debug, Deserialize)] 24 + pub struct AccountDetailParams { 25 + pub flash_success: Option<String>, 26 + pub flash_error: Option<String>, 27 + pub new_password: Option<String>, 80 28 } 81 29 82 - #[derive(Debug, Deserialize, Serialize)] 83 - pub struct StatusAttr { 84 - pub applied: bool, 85 - pub r#ref: Option<String>, 86 - } 87 - 88 - #[derive(Debug, Deserialize, Serialize)] 89 - #[serde(rename_all = "camelCase")] 90 - pub struct InviteCodeInfo { 91 - pub code: String, 92 - pub available: Option<i64>, 93 - pub disabled: Option<bool>, 94 - pub for_account: Option<String>, 95 - pub created_by: Option<String>, 96 - pub created_at: Option<String>, 97 - #[serde(default)] 98 - pub uses: Vec<InviteCodeUse>, 99 - } 100 - 101 - #[derive(Debug, Deserialize, Serialize)] 102 - #[serde(rename_all = "camelCase")] 103 - pub struct InviteCodeUse { 104 - pub used_by: String, 105 - pub used_at: Option<String>, 106 - } 107 - 108 - #[derive(Debug, Deserialize, Serialize)] 109 - pub struct GetInviteCodesResponse { 110 - #[serde(default)] 111 - pub codes: Vec<InviteCodeInfo>, 112 - pub cursor: Option<String>, 113 - } 114 - 115 - #[derive(Debug, Deserialize, Serialize)] 116 - pub struct CreateInviteCodeResponse { 117 - pub code: String, 118 - } 119 - 120 - #[derive(Debug, Deserialize, Serialize)] 121 - #[serde(rename_all = "camelCase")] 122 - pub struct SearchAccountsResponse { 123 - #[serde(default)] 124 - pub accounts: Vec<AccountInfo>, 125 - pub cursor: Option<String>, 126 - } 127 - 128 - #[derive(Debug, Deserialize, Serialize)] 129 - #[serde(rename_all = "camelCase")] 130 - pub struct CreateAccountResponse { 131 - pub did: String, 132 - pub handle: String, 133 - pub access_jwt: Option<String>, 134 - pub refresh_jwt: Option<String>, 135 - } 136 - 137 - // ─── Query parameter types ─────────────────────────────────────────────────── 138 - 139 30 #[derive(Debug, Deserialize)] 140 - pub struct FlashParams { 31 + pub struct SearchParams { 32 + pub q: Option<String>, 141 33 pub flash_success: Option<String>, 142 34 pub flash_error: Option<String>, 143 35 } 144 36 145 37 #[derive(Debug, Deserialize)] 146 - pub struct SearchParams { 147 - pub q: Option<String>, 38 + pub struct InviteCodesParams { 39 + pub cursor: Option<String>, 148 40 pub flash_success: Option<String>, 149 41 pub flash_error: Option<String>, 150 42 } ··· 167 59 pub handle: String, 168 60 } 169 61 62 + #[derive(Debug, Deserialize)] 63 + pub struct RequestCrawlForm { 64 + pub relay_host: String, 65 + } 66 + 170 67 // ─── Helper functions ──────────────────────────────────────────────────────── 171 68 172 69 fn admin_password(state: &AppState) -> &str { ··· 198 95 permissions: &AdminPermissions, 199 96 ) { 200 97 let obj = data.as_object_mut().unwrap(); 201 - obj.insert("admin_handle".into(), session.handle.clone().into()); 98 + // Bug 1 fix: templates reference {{handle}}, not {{admin_handle}} 99 + obj.insert("handle".into(), session.handle.clone().into()); 202 100 obj.insert("admin_did".into(), session.did.clone().into()); 203 101 obj.insert( 204 102 "can_view_accounts".into(), ··· 232 130 "can_send_email".into(), 233 131 permissions.can_send_email.into(), 234 132 ); 133 + obj.insert( 134 + "can_request_crawl".into(), 135 + permissions.can_request_crawl.into(), 136 + ); 235 137 } 236 138 237 139 fn flash_redirect(base_path: &str, success: Option<&str>, error: Option<&str>) -> Response { ··· 267 169 ) -> Response { 268 170 let pds = pds_url(&state); 269 171 270 - // Fetch health, server description, and repo count in parallel 172 + // Fetch health and server description in parallel 271 173 let health_fut = pds_proxy::get_text(pds, "xrpc/_health"); 272 - let desc_fut = 273 - pds_proxy::public_xrpc_get::<DescribeServerResponse>(pds, "com.atproto.server.describeServer", &[]); 274 - let repos_fut = 275 - pds_proxy::public_xrpc_get::<ListReposResponse>(pds, "com.atproto.sync.listRepos", &[("limit", "1")]); 174 + let desc_fut = pds_proxy::public_xrpc_get::<serde_json::Value>( 175 + pds, 176 + "com.atproto.server.describeServer", 177 + &[], 178 + ); 276 179 277 - let (health_res, desc_res, _repos_res) = tokio::join!(health_fut, desc_fut, repos_fut); 180 + let (health_res, desc_res) = tokio::join!(health_fut, desc_fut); 278 181 279 182 let version = health_res 280 183 .ok() 281 184 .and_then(|t| { 282 - serde_json::from_str::<HealthResponse>(&t) 185 + serde_json::from_str::<serde_json::Value>(&t) 283 186 .ok() 284 - .and_then(|h| h.version) 187 + .and_then(|h| h["version"].as_str().map(|s| s.to_string())) 285 188 }) 286 189 .unwrap_or_else(|| "Unknown".to_string()); 287 190 288 191 let desc = desc_res.ok(); 289 192 290 - // For account count, we fetch all repos 291 - let account_count_fut = 292 - pds_proxy::public_xrpc_get::<ListReposResponse>(pds, "com.atproto.sync.listRepos", &[("limit", "1000")]); 293 - let account_count = account_count_fut 193 + // Conditionally fetch account count only if admin can view accounts 194 + let account_count = if permissions.can_view_accounts { 195 + pds_proxy::public_xrpc_get::<serde_json::Value>( 196 + pds, 197 + "com.atproto.sync.listRepos", 198 + &[("limit", "1000")], 199 + ) 294 200 .await 295 - .map(|r| r.repos.len()) 296 - .unwrap_or(0); 201 + .ok() 202 + .and_then(|r| r["repos"].as_array().map(|a| a.len())) 203 + .unwrap_or(0) 204 + } else { 205 + 0 206 + }; 207 + 208 + // Extract describeServer fields (Gap 1) 209 + let server_did = desc 210 + .as_ref() 211 + .and_then(|d| d["did"].as_str()) 212 + .unwrap_or_default() 213 + .to_string(); 214 + let available_domains: Vec<String> = desc 215 + .as_ref() 216 + .and_then(|d| d["availableUserDomains"].as_array()) 217 + .map(|arr| { 218 + arr.iter() 219 + .filter_map(|v| v.as_str().map(|s| s.to_string())) 220 + .collect() 221 + }) 222 + .unwrap_or_default(); 223 + let invite_code_required = desc 224 + .as_ref() 225 + .and_then(|d| d["inviteCodeRequired"].as_bool()) 226 + .unwrap_or(false); 227 + let phone_verification_required = desc 228 + .as_ref() 229 + .and_then(|d| d["phoneVerificationRequired"].as_bool()) 230 + .unwrap_or(false); 231 + 232 + // Gap 1: server links 233 + let privacy_policy = desc 234 + .as_ref() 235 + .and_then(|d| d["links"]["privacyPolicy"].as_str()) 236 + .map(|s| s.to_string()); 237 + let terms_of_service = desc 238 + .as_ref() 239 + .and_then(|d| d["links"]["termsOfService"].as_str()) 240 + .map(|s| s.to_string()); 241 + 242 + // Gap 1: contact email 243 + let contact_email = desc 244 + .as_ref() 245 + .and_then(|d| d["contact"]["email"].as_str()) 246 + .map(|s| s.to_string()); 297 247 298 248 let mut data = serde_json::json!({ 299 249 "version": version, 300 250 "account_count": account_count, 301 - "server_did": desc.as_ref().and_then(|d| d.did.clone()).unwrap_or_default(), 302 - "available_domains": desc.as_ref().map(|d| d.available_user_domains.clone()).unwrap_or_default(), 303 - "invite_code_required": desc.as_ref().and_then(|d| d.invite_code_required).unwrap_or(false), 251 + "server_did": server_did, 252 + "available_domains": available_domains, 253 + "invite_code_required": invite_code_required, 254 + "phone_verification_required": phone_verification_required, 304 255 "pds_hostname": state.app_config.pds_hostname, 305 256 "active_page": "dashboard", 306 257 }); 307 258 259 + if let Some(url) = privacy_policy { 260 + data["privacy_policy"] = url.into(); 261 + } 262 + if let Some(url) = terms_of_service { 263 + data["terms_of_service"] = url.into(); 264 + } 265 + if let Some(email) = contact_email { 266 + data["contact_email"] = email.into(); 267 + } 268 + 308 269 if let Some(msg) = flash.flash_success { 309 270 data["flash_success"] = msg.into(); 310 271 } ··· 332 293 let password = admin_password(&state); 333 294 334 295 // Get all repos first 335 - let repos = match pds_proxy::public_xrpc_get::<ListReposResponse>( 296 + let repos = match pds_proxy::public_xrpc_get::<serde_json::Value>( 336 297 pds, 337 298 "com.atproto.sync.listRepos", 338 299 &[("limit", "1000")], ··· 342 303 Ok(r) => r, 343 304 Err(e) => { 344 305 tracing::error!("Failed to list repos: {}", e); 345 - return flash_redirect("/admin/", None, Some(&format!("Failed to list accounts: {}", e))); 306 + return flash_redirect( 307 + "/admin/", 308 + None, 309 + Some(&format!("Failed to list accounts: {}", e)), 310 + ); 346 311 } 347 312 }; 348 313 349 - let dids: Vec<String> = repos.repos.iter().map(|r| r.did.clone()).collect(); 314 + let dids: Vec<String> = repos["repos"] 315 + .as_array() 316 + .map(|arr| { 317 + arr.iter() 318 + .filter_map(|r| r["did"].as_str().map(|s| s.to_string())) 319 + .collect() 320 + }) 321 + .unwrap_or_default(); 350 322 351 - let accounts = if !dids.is_empty() { 352 - // Build query params for getAccountInfos 323 + let accounts: serde_json::Value = if !dids.is_empty() { 353 324 let did_params: Vec<(&str, &str)> = dids.iter().map(|d| ("dids", d.as_str())).collect(); 354 - match pds_proxy::admin_xrpc_get::<GetAccountInfosResponse>( 325 + match pds_proxy::admin_xrpc_get::<serde_json::Value>( 355 326 pds, 356 327 password, 357 328 "com.atproto.admin.getAccountInfos", ··· 359 330 ) 360 331 .await 361 332 { 362 - Ok(res) => res.infos, 333 + Ok(res) => res["infos"].clone(), 363 334 Err(e) => { 364 335 tracing::error!("Failed to get account infos: {}", e); 365 - vec![] 336 + serde_json::json!([]) 366 337 } 367 338 } 368 339 } else { 369 - vec![] 340 + serde_json::json!([]) 370 341 }; 371 342 343 + let account_count = accounts.as_array().map(|a| a.len()).unwrap_or(0); 344 + 372 345 let mut data = serde_json::json!({ 373 346 "accounts": accounts, 374 - "account_count": accounts.len(), 347 + "account_count": account_count, 375 348 "pds_hostname": state.app_config.pds_hostname, 376 349 "active_page": "accounts", 377 350 }); ··· 394 367 Extension(session): Extension<AdminSession>, 395 368 Extension(permissions): Extension<AdminPermissions>, 396 369 Path(did): Path<String>, 397 - Query(flash): Query<FlashParams>, 370 + Query(params): Query<AccountDetailParams>, 398 371 ) -> Response { 399 372 if !permissions.can_view_accounts { 400 373 return flash_redirect("/admin/", None, Some("Access denied")); ··· 403 376 let pds = pds_url(&state); 404 377 let password = admin_password(&state); 405 378 379 + // Fetch account info, subject status, repo description, and repo status in parallel 406 380 let did_param: Vec<(&str, &str)> = vec![("did", did.as_str())]; 407 - let account_fut = pds_proxy::admin_xrpc_get::<AccountInfo>( 381 + let account_fut = pds_proxy::admin_xrpc_get::<serde_json::Value>( 408 382 pds, 409 383 password, 410 384 "com.atproto.admin.getAccountInfo", ··· 412 386 ); 413 387 414 388 let did_param2: Vec<(&str, &str)> = vec![("did", did.as_str())]; 415 - let status_fut = pds_proxy::admin_xrpc_get::<SubjectStatusResponse>( 389 + let status_fut = pds_proxy::admin_xrpc_get::<serde_json::Value>( 416 390 pds, 417 391 password, 418 392 "com.atproto.admin.getSubjectStatus", 419 393 &did_param2, 420 394 ); 421 395 422 - let (account_res, status_res) = tokio::join!(account_fut, status_fut); 396 + // Gap 2: fetch repo description (public, no auth needed) 397 + let repo_param: Vec<(&str, &str)> = vec![("repo", did.as_str())]; 398 + let describe_repo_fut = pds_proxy::public_xrpc_get::<serde_json::Value>( 399 + pds, 400 + "com.atproto.repo.describeRepo", 401 + &repo_param, 402 + ); 403 + 404 + // Gap 2: fetch repo status (public, no auth needed) 405 + let did_param3: Vec<(&str, &str)> = vec![("did", did.as_str())]; 406 + let repo_status_fut = pds_proxy::public_xrpc_get::<serde_json::Value>( 407 + pds, 408 + "com.atproto.sync.getRepoStatus", 409 + &did_param3, 410 + ); 411 + 412 + let (account_res, status_res, describe_repo_res, repo_status_res) = 413 + tokio::join!(account_fut, status_fut, describe_repo_fut, repo_status_fut); 423 414 424 415 let account = match account_res { 425 416 Ok(a) => a, ··· 433 424 } 434 425 }; 435 426 436 - let takedown = status_res 437 - .ok() 438 - .and_then(|s| s.takedown) 439 - .map(|t| t.applied) 427 + let status_val = status_res.ok(); 428 + let takedown_applied = status_val 429 + .as_ref() 430 + .and_then(|s| s["takedown"]["applied"].as_bool()) 440 431 .unwrap_or(false); 432 + let takedown_ref = status_val 433 + .as_ref() 434 + .and_then(|s| s["takedown"]["ref"].as_str()) 435 + .map(|s| s.to_string()); 436 + 437 + // Gap 2: extract repo description data 438 + let describe_repo = describe_repo_res.ok(); 439 + let handle_is_correct = describe_repo 440 + .as_ref() 441 + .and_then(|d| d["handleIsCorrect"].as_bool()); 442 + let collections: Vec<String> = describe_repo 443 + .as_ref() 444 + .and_then(|d| d["collections"].as_array()) 445 + .map(|arr| { 446 + arr.iter() 447 + .filter_map(|v| v.as_str().map(|s| s.to_string())) 448 + .collect() 449 + }) 450 + .unwrap_or_default(); 451 + 452 + // Gap 2: extract repo status data 453 + let repo_status = repo_status_res.ok(); 454 + let repo_active = repo_status 455 + .as_ref() 456 + .and_then(|r| r["active"].as_bool()); 457 + let repo_status_reason = repo_status 458 + .as_ref() 459 + .and_then(|r| r["status"].as_str()) 460 + .map(|s| s.to_string()); 461 + let repo_rev = repo_status 462 + .as_ref() 463 + .and_then(|r| r["rev"].as_str()) 464 + .map(|s| s.to_string()); 465 + 466 + // Extract threat signatures from account view 467 + let threat_signatures = account["threatSignatures"].clone(); 441 468 442 469 let mut data = serde_json::json!({ 443 470 "account": account, 444 - "is_taken_down": takedown, 471 + "is_taken_down": takedown_applied, 445 472 "pds_hostname": state.app_config.pds_hostname, 446 473 "active_page": "accounts", 447 474 }); 448 475 449 - if let Some(msg) = flash.flash_success { 476 + // Gap 2: add repo description data 477 + if let Some(correct) = handle_is_correct { 478 + data["handle_is_correct"] = correct.into(); 479 + data["handle_resolution_checked"] = true.into(); 480 + } 481 + if !collections.is_empty() { 482 + data["collections"] = serde_json::json!(collections); 483 + } 484 + 485 + // Gap 2: add repo status data 486 + if let Some(active) = repo_active { 487 + data["repo_active"] = active.into(); 488 + data["repo_status_checked"] = true.into(); 489 + } 490 + if let Some(reason) = repo_status_reason { 491 + data["repo_status_reason"] = reason.into(); 492 + } 493 + if let Some(rev) = repo_rev { 494 + data["repo_rev"] = rev.into(); 495 + } 496 + 497 + // Takedown reference 498 + if let Some(tref) = takedown_ref { 499 + data["takedown_ref"] = tref.into(); 500 + } 501 + 502 + // Threat signatures 503 + if threat_signatures.is_array() 504 + && !threat_signatures.as_array().unwrap().is_empty() 505 + { 506 + data["threat_signatures"] = threat_signatures; 507 + } 508 + 509 + // Bug 3 fix: pass new_password from query params into template data 510 + if let Some(pw) = params.new_password { 511 + data["new_password"] = pw.into(); 512 + } 513 + 514 + if let Some(msg) = params.flash_success { 450 515 data["flash_success"] = msg.into(); 451 516 } 452 - if let Some(msg) = flash.flash_error { 517 + if let Some(msg) = params.flash_error { 453 518 data["flash_error"] = msg.into(); 454 519 } 455 520 ··· 640 705 .await 641 706 { 642 707 Ok(()) => { 643 - // Redirect with the new password as a flash param 708 + // Bug 3 fix: redirect with new_password param so account_detail can display it 644 709 let encoded = urlencoding::encode(&new_password); 645 710 Redirect::to(&format!( 646 711 "/admin/accounts/{}?flash_success=Password+reset+successfully&new_password={}", ··· 742 807 } 743 808 } 744 809 745 - /// GET /admin/invite-codes — List invite codes 810 + /// GET /admin/invite-codes — List invite codes (with pagination) 746 811 pub async fn invite_codes_list( 747 812 State(state): State<AppState>, 748 813 Extension(session): Extension<AdminSession>, 749 814 Extension(permissions): Extension<AdminPermissions>, 750 - Query(flash): Query<FlashParams>, 815 + Query(params): Query<InviteCodesParams>, 751 816 ) -> Response { 752 817 let rbac = match &state.admin_rbac_config { 753 818 Some(r) => r, ··· 758 823 return flash_redirect("/admin/", None, Some("Access denied")); 759 824 } 760 825 761 - let codes = match pds_proxy::admin_xrpc_get::<GetInviteCodesResponse>( 826 + // Phase 4: pagination support with cursor 827 + let mut query_params = vec![("limit", "100"), ("sort", "recent")]; 828 + let cursor_val; 829 + if let Some(ref c) = params.cursor { 830 + cursor_val = c.clone(); 831 + query_params.push(("cursor", &cursor_val)); 832 + } 833 + 834 + let response = pds_proxy::admin_xrpc_get::<serde_json::Value>( 762 835 pds_url(&state), 763 836 admin_password(&state), 764 837 "com.atproto.admin.getInviteCodes", 765 - &[("limit", "100"), ("sort", "recent")], 838 + &query_params, 766 839 ) 767 - .await 768 - { 769 - Ok(res) => res.codes, 840 + .await; 841 + 842 + let (codes_raw, next_cursor) = match response { 843 + Ok(res) => { 844 + let codes = res["codes"].clone(); 845 + let cursor = res["cursor"].as_str().map(|s| s.to_string()); 846 + (codes, cursor) 847 + } 770 848 Err(e) => { 771 849 tracing::error!("Failed to get invite codes: {}", e); 772 - vec![] 850 + (serde_json::json!([]), None) 773 851 } 774 852 }; 775 853 854 + // Bug 5 fix: post-process codes to compute used_count and remaining 855 + let codes = if let Some(arr) = codes_raw.as_array() { 856 + let processed: Vec<serde_json::Value> = arr 857 + .iter() 858 + .map(|code| { 859 + let mut c = code.clone(); 860 + 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); 865 + let remaining = (available - used_count).max(0); 866 + c["used_count"] = used_count.into(); 867 + c["remaining"] = remaining.into(); 868 + c 869 + }) 870 + .collect(); 871 + serde_json::json!(processed) 872 + } else { 873 + serde_json::json!([]) 874 + }; 875 + 776 876 let mut data = serde_json::json!({ 777 877 "codes": codes, 778 878 "pds_hostname": state.app_config.pds_hostname, 779 879 "active_page": "invite_codes", 780 880 }); 781 881 782 - if let Some(msg) = flash.flash_success { 882 + // Phase 4: pagination 883 + if let Some(cursor) = next_cursor { 884 + data["next_cursor"] = cursor.into(); 885 + data["has_more"] = true.into(); 886 + } 887 + 888 + if let Some(msg) = params.flash_success { 783 889 data["flash_success"] = msg.into(); 784 890 } 785 - if let Some(msg) = flash.flash_error { 891 + if let Some(msg) = params.flash_error { 786 892 data["flash_error"] = msg.into(); 787 893 } 788 894 ··· 810 916 let use_count = form.use_count.unwrap_or(1).max(1); 811 917 let body = serde_json::json!({ "useCount": use_count }); 812 918 813 - match pds_proxy::admin_xrpc_post::<_, CreateInviteCodeResponse>( 919 + match pds_proxy::admin_xrpc_post::<_, serde_json::Value>( 814 920 pds_url(&state), 815 921 admin_password(&state), 816 922 "com.atproto.server.createInviteCode", ··· 818 924 ) 819 925 .await 820 926 { 821 - Ok(res) => flash_redirect( 822 - "/admin/invite-codes", 823 - Some(&format!("Invite code created: {}", res.code)), 824 - None, 825 - ), 927 + Ok(res) => { 928 + let code = res["code"].as_str().unwrap_or("unknown"); 929 + flash_redirect( 930 + "/admin/invite-codes", 931 + Some(&format!("Invite code created: {}", code)), 932 + None, 933 + ) 934 + } 826 935 Err(e) => flash_redirect( 827 936 "/admin/invite-codes", 828 937 None, ··· 934 1043 935 1044 // Step 1: Create invite code 936 1045 let invite_body = serde_json::json!({ "useCount": 1 }); 937 - let invite_res = match pds_proxy::admin_xrpc_post::<_, CreateInviteCodeResponse>( 1046 + let invite_res = match pds_proxy::admin_xrpc_post::<_, serde_json::Value>( 938 1047 pds, 939 1048 password_str, 940 1049 "com.atproto.server.createInviteCode", ··· 952 1061 } 953 1062 }; 954 1063 1064 + let invite_code = invite_res["code"] 1065 + .as_str() 1066 + .unwrap_or("") 1067 + .to_string(); 1068 + 955 1069 // Step 2: Create account 956 1070 let account_password = generate_random_password(); 957 1071 let account_body = serde_json::json!({ 958 1072 "email": form.email, 959 1073 "handle": form.handle, 960 1074 "password": account_password, 961 - "inviteCode": invite_res.code, 1075 + "inviteCode": invite_code, 962 1076 }); 963 1077 964 - match pds_proxy::admin_xrpc_post::<_, CreateAccountResponse>( 1078 + match pds_proxy::admin_xrpc_post::<_, serde_json::Value>( 965 1079 pds, 966 1080 password_str, 967 1081 "com.atproto.server.createAccount", ··· 970 1084 .await 971 1085 { 972 1086 Ok(res) => { 1087 + // Bug 4 fix: nest data under "created" object so template can use 1088 + // {{created.did}}, {{created.handle}}, etc. 973 1089 let mut data = serde_json::json!({ 974 1090 "pds_hostname": state.app_config.pds_hostname, 975 1091 "active_page": "create_account", 976 - "created": true, 977 - "created_did": res.did, 978 - "created_handle": res.handle, 979 - "created_email": form.email, 980 - "created_password": account_password, 981 - "created_invite_code": invite_res.code, 1092 + "created": { 1093 + "did": res["did"].as_str().unwrap_or(""), 1094 + "handle": res["handle"].as_str().unwrap_or(""), 1095 + "email": form.email, 1096 + "password": account_password, 1097 + "inviteCode": invite_code, 1098 + }, 982 1099 }); 983 1100 984 1101 inject_nav_data(&mut data, &session, &permissions); ··· 994 1111 } 995 1112 996 1113 /// GET /admin/search — Search accounts 1114 + /// Bug 7 fix: per lexicon com.atproto.admin.searchAccounts, the search param is "email" not "query" 997 1115 pub async fn search_accounts( 998 1116 State(state): State<AppState>, 999 1117 Extension(session): Extension<AdminSession>, ··· 1011 1129 1012 1130 let query = params.q.unwrap_or_default(); 1013 1131 1014 - let accounts = if !query.is_empty() { 1015 - match pds_proxy::admin_xrpc_get::<SearchAccountsResponse>( 1132 + let accounts: serde_json::Value = if !query.is_empty() { 1133 + // Bug 7 fix: use "email" parameter per the lexicon spec 1134 + match pds_proxy::admin_xrpc_get::<serde_json::Value>( 1016 1135 pds_url(&state), 1017 1136 admin_password(&state), 1018 1137 "com.atproto.admin.searchAccounts", 1019 - &[("query", &query)], 1138 + &[("email", &query)], 1020 1139 ) 1021 1140 .await 1022 1141 { 1023 - Ok(res) => res.accounts, 1142 + Ok(res) => res["accounts"].clone(), 1024 1143 Err(e) => { 1025 1144 tracing::error!("Search failed: {}", e); 1026 - vec![] 1145 + serde_json::json!([]) 1027 1146 } 1028 1147 } 1029 1148 } else { 1030 - vec![] 1149 + serde_json::json!([]) 1031 1150 }; 1032 1151 1152 + let account_count = accounts.as_array().map(|a| a.len()).unwrap_or(0); 1153 + 1033 1154 let mut data = serde_json::json!({ 1034 1155 "accounts": accounts, 1035 - "account_count": accounts.len(), 1156 + "account_count": account_count, 1036 1157 "search_query": query, 1037 1158 "pds_hostname": state.app_config.pds_hostname, 1038 1159 "active_page": "accounts", ··· 1048 1169 inject_nav_data(&mut data, &session, &permissions); 1049 1170 1050 1171 render_template(&state, "admin/accounts.hbs", data) 1172 + } 1173 + 1174 + /// GET /admin/request-crawl — Request Crawl form (Gap 3) 1175 + pub async fn get_request_crawl( 1176 + State(state): State<AppState>, 1177 + Extension(session): Extension<AdminSession>, 1178 + Extension(permissions): Extension<AdminPermissions>, 1179 + Query(flash): Query<FlashParams>, 1180 + ) -> Response { 1181 + if !permissions.can_request_crawl { 1182 + return flash_redirect("/admin/", None, Some("Access denied")); 1183 + } 1184 + 1185 + let mut data = serde_json::json!({ 1186 + "pds_hostname": state.app_config.pds_hostname, 1187 + "active_page": "request_crawl", 1188 + "default_relay": "bsky.network", 1189 + }); 1190 + 1191 + if let Some(msg) = flash.flash_success { 1192 + data["flash_success"] = msg.into(); 1193 + } 1194 + if let Some(msg) = flash.flash_error { 1195 + data["flash_error"] = msg.into(); 1196 + } 1197 + 1198 + inject_nav_data(&mut data, &session, &permissions); 1199 + 1200 + render_template(&state, "admin/request_crawl.hbs", data) 1201 + } 1202 + 1203 + /// POST /admin/request-crawl — Submit crawl request to relay (Gap 3) 1204 + pub async fn post_request_crawl( 1205 + State(state): State<AppState>, 1206 + Extension(session): Extension<AdminSession>, 1207 + Extension(_permissions): Extension<AdminPermissions>, 1208 + axum::extract::Form(form): axum::extract::Form<RequestCrawlForm>, 1209 + ) -> Response { 1210 + let rbac = match &state.admin_rbac_config { 1211 + Some(r) => r, 1212 + None => return StatusCode::NOT_FOUND.into_response(), 1213 + }; 1214 + 1215 + if !rbac.can_access_endpoint(&session.did, "com.atproto.sync.requestCrawl") { 1216 + return flash_redirect("/admin/request-crawl", None, Some("Access denied")); 1217 + } 1218 + 1219 + let relay_base = format!("https://{}", form.relay_host.trim_end_matches('/')); 1220 + let pds_hostname = &state.app_config.pds_hostname; 1221 + 1222 + let body = serde_json::json!({ 1223 + "hostname": pds_hostname, 1224 + }); 1225 + 1226 + match pds_proxy::public_xrpc_post( 1227 + &relay_base, 1228 + "com.atproto.sync.requestCrawl", 1229 + &body, 1230 + ) 1231 + .await 1232 + { 1233 + Ok(()) => flash_redirect( 1234 + "/admin/request-crawl", 1235 + Some(&format!( 1236 + "Crawl requested from {} for {}", 1237 + form.relay_host, pds_hostname 1238 + )), 1239 + None, 1240 + ), 1241 + Err(e) => flash_redirect( 1242 + "/admin/request-crawl", 1243 + None, 1244 + Some(&format!("Request crawl failed: {}", e)), 1245 + ), 1246 + } 1051 1247 } 1052 1248 1053 1249 /// POST /admin/logout — Clear session and redirect to login