Microservice to bring 2FA to self hosted PDSes

wip

+86 -12
+12
html_templates/admin/accounts.hbs
··· 36 36 <th>Handle</th> 37 37 <th>DID</th> 38 38 <th>Email</th> 39 + <th>Status</th> 39 40 </tr> 40 41 </thead> 41 42 <tbody> ··· 44 45 <td><a href="/admin/accounts/{{this.did}}">{{this.handle}}</a></td> 45 46 <td class="did-cell">{{this.did}}</td> 46 47 <td>{{this.email}}</td> 48 + <td> 49 + {{#if (or this.is_taken_down (eq this.status "takedown"))}} 50 + <span class="badge badge-danger">Taken Down</span> 51 + {{/if}} 52 + {{#if (or this.deactivatedAt (eq this.status "deactivated"))}} 53 + <span class="badge badge-warning">Deactivated</span> 54 + {{/if}} 55 + {{#if (and (not this.is_taken_down) (not (eq this.status "takedown")) (not this.deactivatedAt) (not (eq this.status "deactivated")))}} 56 + <span class="badge badge-success">Active</span> 57 + {{/if}} 58 + </td> 47 59 </tr> 48 60 {{/each}} 49 61 </tbody>
+74 -12
src/admin/routes.rs
··· 1 + use super::middleware::{AdminPermissions, AdminSession}; 2 + use super::pds_proxy; 3 + use super::session; 1 4 use crate::AppState; 2 5 use axum::{ 3 6 extract::{Extension, Path, Query, State}, ··· 9 12 use jacquard_common::types::handle::Handle; 10 13 use jacquard_identity::resolver::IdentityResolver; 11 14 use serde::Deserialize; 12 - 13 - use super::middleware::{AdminPermissions, AdminSession}; 14 - use super::pds_proxy; 15 - use super::session; 15 + use tracing::log; 16 16 17 17 // ─── Query parameter types ─────────────────────────────────────────────────── 18 18 ··· 293 293 Query(params): Query<AccountsParams>, 294 294 ) -> Response { 295 295 if !permissions.can_view_accounts { 296 - return flash_redirect("/admin/", None, Some("Access denied")); 296 + return flash_redirect("/admin/dashboard", None, Some("Access denied")); 297 297 } 298 298 299 299 let pds = pds_url(&state); 300 300 let password = admin_password(&state); 301 301 302 - // Build query params for listRepos with pagination 303 302 let limit = 5; 304 303 // Yeah I know this looks bad, but I'd like to have the limit in one spot instead of two 305 304 let limit_as_string = limit.to_string(); ··· 332 331 // Extract the next-page cursor from the response 333 332 let next_cursor = repos["cursor"].as_str().map(|s| s.to_string()); 334 333 335 - let dids: Vec<String> = repos["repos"] 334 + let repo_infos: std::collections::HashMap<String, (bool, Option<String>)> = repos["repos"] 336 335 .as_array() 337 336 .map(|arr| { 338 337 arr.iter() 339 - .filter_map(|r| r["did"].as_str().map(|s| s.to_string())) 338 + .filter_map(|r| { 339 + let did = r["did"].as_str()?.to_string(); 340 + let active = r["active"].as_bool().unwrap_or(true); 341 + let status = r["status"].as_str().map(|s| s.to_string()); 342 + Some((did, (active, status))) 343 + }) 340 344 .collect() 341 345 }) 342 346 .unwrap_or_default(); 343 347 344 - let accounts: serde_json::Value = if !dids.is_empty() { 348 + let dids: Vec<String> = repo_infos.keys().cloned().collect(); 349 + 350 + let accounts_raw: serde_json::Value = if !dids.is_empty() { 345 351 let did_params: Vec<(&str, &str)> = dids.iter().map(|d| ("dids", d.as_str())).collect(); 346 352 match pds_proxy::admin_xrpc_get::<serde_json::Value>( 347 353 pds, ··· 360 366 } else { 361 367 serde_json::json!([]) 362 368 }; 369 + log::debug!("Accounts: {:?}", accounts_raw); 370 + 371 + let accounts: Vec<serde_json::Value> = if let Some(arr) = accounts_raw.as_array() { 372 + arr.iter() 373 + .map(|account| { 374 + let mut a = account.clone(); 375 + let did = a["did"].as_str().unwrap_or_default(); 363 376 364 - let account_count = accounts.as_array().map(|a| a.len()).unwrap_or(0); 377 + if let Some((active, status)) = repo_infos.get(did) { 378 + // Map active/status to is_taken_down 379 + 380 + let is_taken_down = status.as_deref() == Some("takendown"); 381 + a["is_taken_down"] = is_taken_down.into(); 382 + 383 + // Also ensure we have the status field if it was missing in AccountView 384 + if a["status"].is_null() { 385 + if let Some(s) = status { 386 + a["status"] = s.clone().into(); 387 + } 388 + } 389 + } 390 + a 391 + }) 392 + .collect() 393 + } else { 394 + Vec::new() 395 + }; 396 + 397 + let account_count = accounts.len(); 365 398 366 399 let mut data = serde_json::json!({ 367 400 "accounts": accounts, ··· 1178 1211 1179 1212 let query = params.q.unwrap_or_default(); 1180 1213 1181 - let accounts: serde_json::Value = if !query.is_empty() { 1214 + let accounts_raw: serde_json::Value = if !query.is_empty() { 1182 1215 // Bug 7 fix: use "email" parameter per the lexicon spec 1183 1216 match pds_proxy::admin_xrpc_get::<serde_json::Value>( 1184 1217 pds_url(&state), ··· 1198 1231 serde_json::json!([]) 1199 1232 }; 1200 1233 1201 - let account_count = accounts.as_array().map(|a| a.len()).unwrap_or(0); 1234 + let pds = pds_url(&state); 1235 + let password = admin_password(&state); 1236 + 1237 + let accounts: Vec<serde_json::Value> = if let Some(arr) = accounts_raw.as_array() { 1238 + let mut processed = Vec::new(); 1239 + for account in arr { 1240 + let mut a = account.clone(); 1241 + let did = a["did"].as_str().unwrap_or_default(); 1242 + let status_res = pds_proxy::admin_xrpc_get::<serde_json::Value>( 1243 + pds, 1244 + password, 1245 + "com.atproto.admin.getSubjectStatus", 1246 + &[("did", did)], 1247 + ) 1248 + .await; 1249 + 1250 + let is_taken_down = status_res 1251 + .ok() 1252 + .and_then(|s| s["takedown"]["applied"].as_bool()) 1253 + .unwrap_or(false); 1254 + 1255 + a["is_taken_down"] = is_taken_down.into(); 1256 + processed.push(a); 1257 + } 1258 + processed 1259 + } else { 1260 + Vec::new() 1261 + }; 1262 + 1263 + let account_count = accounts.len(); 1202 1264 1203 1265 let mut data = serde_json::json!({ 1204 1266 "accounts": accounts,