Microservice to bring 2FA to self hosted PDSes

accounts pagination

+68 -6
+12
html_templates/admin/accounts.hbs
··· 49 </tbody> 50 </table> 51 </div> 52 {{else}} 53 <div class="empty-state"> 54 {{#if search_query}}
··· 49 </tbody> 50 </table> 51 </div> 52 + 53 + {{#if (or has_prev has_next)}} 54 + <div class="pagination"> 55 + {{#if has_prev}} 56 + <a href="{{prev_url}}" class="btn btn-small">&larr; Previous</a> 57 + {{/if}} 58 + <span class="pagination-info">Showing {{account_count}} accounts</span> 59 + {{#if has_next}} 60 + <a href="{{next_url}}" class="btn btn-small">Next &rarr;</a> 61 + {{/if}} 62 + </div> 63 + {{/if}} 64 {{else}} 65 <div class="empty-state"> 66 {{#if search_query}}
+43 -6
src/admin/routes.rs
··· 38 } 39 40 #[derive(Debug, Deserialize)] 41 pub struct InviteCodesParams { 42 pub cursor: Option<String>, 43 pub flash_success: Option<String>, ··· 278 render_template(&state, "admin/dashboard.hbs", data) 279 } 280 281 - /// GET /admin/accounts — Account list 282 pub async fn accounts_list( 283 State(state): State<AppState>, 284 Extension(session): Extension<AdminSession>, 285 Extension(permissions): Extension<AdminPermissions>, 286 - Query(flash): Query<FlashParams>, 287 ) -> Response { 288 if !permissions.can_view_accounts { 289 return flash_redirect("/admin/", None, Some("Access denied")); ··· 292 let pds = pds_url(&state); 293 let password = admin_password(&state); 294 295 - // Get all repos first 296 let repos = match pds_proxy::public_xrpc_get::<serde_json::Value>( 297 pds, 298 "com.atproto.sync.listRepos", 299 - &[("limit", "1000")], 300 ) 301 .await 302 { ··· 311 } 312 }; 313 314 let dids: Vec<String> = repos["repos"] 315 .as_array() 316 .map(|arr| { ··· 349 "active_page": "accounts", 350 }); 351 352 - if let Some(msg) = flash.flash_success { 353 data["flash_success"] = msg.into(); 354 } 355 - if let Some(msg) = flash.flash_error { 356 data["flash_error"] = msg.into(); 357 } 358
··· 38 } 39 40 #[derive(Debug, Deserialize)] 41 + pub struct AccountsParams { 42 + pub cursor: Option<String>, 43 + pub flash_success: Option<String>, 44 + pub flash_error: Option<String>, 45 + } 46 + 47 + #[derive(Debug, Deserialize)] 48 pub struct InviteCodesParams { 49 pub cursor: Option<String>, 50 pub flash_success: Option<String>, ··· 285 render_template(&state, "admin/dashboard.hbs", data) 286 } 287 288 + /// GET /admin/accounts — Account list (paginated, 100 per page) 289 pub async fn accounts_list( 290 State(state): State<AppState>, 291 Extension(session): Extension<AdminSession>, 292 Extension(permissions): Extension<AdminPermissions>, 293 + Query(params): Query<AccountsParams>, 294 ) -> Response { 295 if !permissions.can_view_accounts { 296 return flash_redirect("/admin/", None, Some("Access denied")); ··· 299 let pds = pds_url(&state); 300 let password = admin_password(&state); 301 302 + // Build query params for listRepos with pagination 303 + let limit = 5; 304 + // Yeah I know this looks bad, but I'd like to have the limit in one spot instead of two 305 + let limit_as_string = limit.to_string(); 306 + let limit_as_str = limit_as_string.as_str(); 307 + let mut query_params: Vec<(&str, &str)> = vec![("limit", limit_as_str)]; 308 + let cursor_val; 309 + if let Some(ref c) = params.cursor { 310 + cursor_val = c.clone(); 311 + query_params.push(("cursor", &cursor_val)); 312 + } 313 + 314 let repos = match pds_proxy::public_xrpc_get::<serde_json::Value>( 315 pds, 316 "com.atproto.sync.listRepos", 317 + &query_params, 318 ) 319 .await 320 { ··· 329 } 330 }; 331 332 + // Extract the next-page cursor from the response 333 + let next_cursor = repos["cursor"].as_str().map(|s| s.to_string()); 334 + 335 let dids: Vec<String> = repos["repos"] 336 .as_array() 337 .map(|arr| { ··· 370 "active_page": "accounts", 371 }); 372 373 + // Pagination: "Next" link 374 + if let Some(ref cursor) = next_cursor { 375 + // If the count returned is not the same as the limit, then we are at the end of the list 376 + if dids.len() == limit { 377 + data["has_next"] = true.into(); 378 + let next_url = format!("/admin/accounts?cursor={}", urlencoding::encode(cursor)); 379 + data["next_url"] = next_url.into(); 380 + } 381 + } 382 + 383 + // Pagination: "Previous" link (visible when not on page 1) 384 + if params.cursor.is_some() { 385 + data["has_prev"] = true.into(); 386 + data["prev_url"] = "/admin/accounts".to_string().into(); 387 + } 388 + 389 + if let Some(msg) = params.flash_success { 390 data["flash_success"] = msg.into(); 391 } 392 + if let Some(msg) = params.flash_error { 393 data["flash_error"] = msg.into(); 394 } 395
+13
static/css/admin.css
··· 499 text-decoration: underline; 500 } 501 502 /* --- Dashboard: Cards --------------------------------------- */ 503 504 .cards {
··· 499 text-decoration: underline; 500 } 501 502 + .pagination { 503 + display: flex; 504 + align-items: center; 505 + justify-content: center; 506 + gap: 16px; 507 + padding: 16px 0; 508 + } 509 + 510 + .pagination-info { 511 + font-size: 0.8125rem; 512 + color: var(--secondary-color); 513 + } 514 + 515 /* --- Dashboard: Cards --------------------------------------- */ 516 517 .cards {