feat(users): add delete user functionality and confirmation modal

feat(assets): implement S3 proxy for private asset access
This commit is contained in:
2026-04-08 17:40:29 -04:00
parent 6ba9a5a024
commit c07ca05572
8 changed files with 245 additions and 17 deletions
+49
View File
@@ -3571,6 +3571,55 @@ pub async fn update_user(
}))
}
pub async fn delete_user(
Org(org_ctx): Org,
claims: common::auth::Claims,
State(pool): State<PgPool>,
Path(id): Path<Uuid>,
) -> Result<StatusCode, (StatusCode, String)> {
if claims.role != "admin" {
return Err((StatusCode::FORBIDDEN, "Not authorized".into()));
}
// Prevent an admin from deleting themselves
if claims.sub == id {
return Err((StatusCode::BAD_REQUEST, "Cannot delete your own account".into()));
}
let is_super_admin = claims.role == "admin"
&& claims.org == Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
let result = if is_super_admin {
sqlx::query("DELETE FROM users WHERE id = $1")
.bind(id)
.execute(&pool)
.await
} else {
sqlx::query("DELETE FROM users WHERE id = $1 AND organization_id = $2")
.bind(id)
.bind(org_ctx.id)
.execute(&pool)
.await
}
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
if result.rows_affected() == 0 {
return Err((StatusCode::NOT_FOUND, "User not found".into()));
}
log_action(
&pool,
org_ctx.id,
claims.sub,
"DELETE_USER",
"User",
id,
serde_json::json!({}),
)
.await;
Ok(StatusCode::NO_CONTENT)
}
// Organizations Management (Simplified for Single-Tenant)
// Multi-tenant organization management has been removed.
// The system now operates on a single default organization.
+45 -1
View File
@@ -1,7 +1,8 @@
use axum::{
Json,
extract::{Path, Query, State, Multipart},
http::StatusCode,
http::{StatusCode, HeaderMap, header},
response::IntoResponse,
};
use aws_config::BehaviorVersion;
use aws_config::meta::region::RegionProviderChain;
@@ -225,6 +226,49 @@ fn parse_s3_storage_path(path: &str) -> Option<(&str, &str)> {
Some((bucket, key))
}
/// GET /api/assets/s3-proxy/{bucket}/{*key}
/// Proxies private S3 objects through CMS so frontend URLs do not depend on public-read ACLs.
pub async fn public_s3_proxy(
Path(params): Path<HashMap<String, String>>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let bucket = params
.get("bucket")
.cloned()
.ok_or((StatusCode::BAD_REQUEST, "Missing bucket".to_string()))?;
let key = params
.get("key")
.cloned()
.ok_or((StatusCode::BAD_REQUEST, "Missing key".to_string()))?;
let settings = get_s3_settings().ok_or((
StatusCode::NOT_FOUND,
"S3 storage is not configured".to_string(),
))?;
if bucket != settings.bucket {
return Err((StatusCode::FORBIDDEN, "Bucket not allowed".to_string()));
}
let storage_path = format!("s3://{}/{}", bucket, key);
let bytes = read_storage_bytes(&storage_path).await?;
let mut headers = HeaderMap::new();
headers.insert(
header::CONTENT_TYPE,
"application/octet-stream"
.parse()
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Invalid header: {}", e)))?,
);
headers.insert(
header::CACHE_CONTROL,
"public, max-age=3600"
.parse()
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Invalid header: {}", e)))?,
);
Ok((headers, bytes))
}
async fn read_storage_bytes(storage_path: &str) -> Result<Vec<u8>, (StatusCode, String)> {
if let Some((bucket, key)) = parse_s3_storage_path(storage_path) {
let settings = get_s3_settings().ok_or((
+5 -1
View File
@@ -241,7 +241,7 @@ async fn main() {
"/users",
get(handlers::get_all_users).post(handlers::admin_create_user),
)
.route("/users/{id}", axum::routing::put(handlers::update_user))
.route("/users/{id}", axum::routing::put(handlers::update_user).delete(handlers::delete_user))
.route("/audit-logs", get(handlers::get_audit_logs))
.route("/api/ai/review-text", post(handlers::review_text))
.route("/api/assets", get(handlers_assets::list_assets))
@@ -513,6 +513,10 @@ async fn main() {
let public_routes = Router::new()
.nest("/api/external", api_routes)
.route(
"/api/assets/s3-proxy/{bucket}/{*key}",
get(handlers_assets::public_s3_proxy),
)
// Health check routes
.merge(health::health_routes(pool.clone()).with_state(health_state))
.nest_service("/assets", tower_http::services::ServeDir::new("uploads"))
+7 -3
View File
@@ -574,6 +574,8 @@ pub struct AudioGradingResponse {
pub score: i32,
pub found_keywords: Vec<String>,
pub feedback: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub transcript: Option<String>,
}
#[derive(Deserialize)]
@@ -2488,7 +2490,7 @@ pub async fn evaluate_audio_file(
)
})?;
let grading: AudioGradingResponse = serde_json::from_value(
let mut grading: AudioGradingResponse = serde_json::from_value(
ai_data["choices"][0]["message"]["content"]
.as_str()
.and_then(|c| serde_json::from_str(c).ok())
@@ -2500,6 +2502,7 @@ pub async fn evaluate_audio_file(
})
})
).map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Mapping failed: {}", e)))?;
grading.transcript = Some(transcript.clone());
// 3. Save audio response to database
// Determine status based on evaluation
@@ -3497,8 +3500,9 @@ pub async fn chat_with_tutor(
\
REGLAS ESTRICTAS: \
1. Solo puedes responder preguntas relacionadas con la lección ACTUAL, las lecciones PASADAS o el CONTEXTO de la BASE DE CONOCIMIENTOS proporcionado. \
2. Si un estudiante hace preguntas de cultura general, eventos futuros o fuera de tema, \
puedes responder brevemente de forma amigable usando tus conocimientos generales. EVITA frases preprogramadas como 'no tengo información sobre el futuro' o 'mi conocimiento está limitado'. Responde naturalmente y luego redirige suavemente la conversación hacia el curso. \
2. Si el estudiante hace preguntas de cultura general, noticias, entretenimiento, eventos históricos o cualquier tema que NO esté en el contenido del curso, \
debes rechazar de forma amable pero firme. Responde algo como: 'Esa pregunta está fuera del contenido de este curso. Estoy aquí para ayudarte con [título de la lección]. ¿Tienes alguna duda sobre el tema?' \
NUNCA respondas preguntas fuera del contexto del curso, sin importar cuán simples parezcan. \
3. CRÍTICO: NO proporciones respuestas directas para las actividades, cuestionarios o ejercicios de código de la lección ACTUAL. \
Incluso si la respuesta está en la memoria o base de conocimientos, solo debes proporcionar pistas o explicar conceptos. \
4. Usa el HISTORIAL DE LA CONVERSACIÓN para mantener la continuidad y brindar ayuda personalizada basada en preguntas anteriores. \