From c07ca055728e6b5235f3aac1f9d88b6f589a553f Mon Sep 17 00:00:00 2001 From: Nurfog Date: Wed, 8 Apr 2026 17:40:29 -0400 Subject: [PATCH] feat(users): add delete user functionality and confirmation modal feat(assets): implement S3 proxy for private asset access --- services/cms-service/src/handlers.rs | 49 +++++++++++ services/cms-service/src/handlers_assets.rs | 46 +++++++++- services/cms-service/src/main.rs | 6 +- services/lms-service/src/handlers.rs | 10 ++- .../components/blocks/AudioResponsePlayer.tsx | 7 +- web/experience/src/lib/api.ts | 1 + web/studio/src/app/admin/users/page.tsx | 86 +++++++++++++++++-- web/studio/src/lib/api.ts | 57 +++++++++++- 8 files changed, 245 insertions(+), 17 deletions(-) diff --git a/services/cms-service/src/handlers.rs b/services/cms-service/src/handlers.rs index 3de5e5e..3f9b625 100644 --- a/services/cms-service/src/handlers.rs +++ b/services/cms-service/src/handlers.rs @@ -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, + Path(id): Path, +) -> Result { + 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. diff --git a/services/cms-service/src/handlers_assets.rs b/services/cms-service/src/handlers_assets.rs index e040cb5..fca97f2 100644 --- a/services/cms-service/src/handlers_assets.rs +++ b/services/cms-service/src/handlers_assets.rs @@ -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>, +) -> Result { + 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, (StatusCode, String)> { if let Some((bucket, key)) = parse_s3_storage_path(storage_path) { let settings = get_s3_settings().ok_or(( diff --git a/services/cms-service/src/main.rs b/services/cms-service/src/main.rs index 9efa063..02f2e8d 100644 --- a/services/cms-service/src/main.rs +++ b/services/cms-service/src/main.rs @@ -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")) diff --git a/services/lms-service/src/handlers.rs b/services/lms-service/src/handlers.rs index 1b8553c..089e9d4 100644 --- a/services/lms-service/src/handlers.rs +++ b/services/lms-service/src/handlers.rs @@ -574,6 +574,8 @@ pub struct AudioGradingResponse { pub score: i32, pub found_keywords: Vec, pub feedback: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub transcript: Option, } #[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. \ diff --git a/web/experience/src/components/blocks/AudioResponsePlayer.tsx b/web/experience/src/components/blocks/AudioResponsePlayer.tsx index 5c30afa..355881d 100644 --- a/web/experience/src/components/blocks/AudioResponsePlayer.tsx +++ b/web/experience/src/components/blocks/AudioResponsePlayer.tsx @@ -215,6 +215,9 @@ export default function AudioResponsePlayer({ blockId, recordingTime ); + if (result.transcript) { + setTranscript(result.transcript); + } setEvaluation({ score: result.score, foundKeywords: result.found_keywords, @@ -223,7 +226,7 @@ export default function AudioResponsePlayer({ setSubmitted(true); if (onComplete) { - onComplete(result.score, transcript); + onComplete(result.score, result.transcript || transcript); } } catch (err: any) { console.error("Evaluation failed", err); @@ -365,7 +368,7 @@ export default function AudioResponsePlayer({ )} - {audioBlob && transcript && ( + {audioBlob && !isRecording && ( +
+ + {u.id !== currentUser?.id && ( + + )} +
))} @@ -273,6 +306,41 @@ export default function UsersPage() { )} + {/* Delete Confirmation Modal */} + {deleteConfirm && ( +
+
+
+
+ +
+
+

Eliminar usuario

+

+ ¿Confirmas que deseas eliminar a {deleteConfirm.full_name || deleteConfirm.email}? Esta acción no se puede deshacer. +

+
+
+ + +
+
+
+
+ )} + {/* Create User Modal */} {isModalOpen && (
diff --git a/web/studio/src/lib/api.ts b/web/studio/src/lib/api.ts index 980839a..f6afce4 100644 --- a/web/studio/src/lib/api.ts +++ b/web/studio/src/lib/api.ts @@ -54,7 +54,61 @@ export function generateUUID(): string { export const getImageUrl = (path?: string) => { if (!path) return ''; - if (path.startsWith('http')) return path; + if (path.startsWith('http')) { + // If backend persisted direct S3 public URL but bucket is private, + // proxy it through CMS to avoid AccessDenied. + try { + const parsed = new URL(path); + const host = parsed.hostname; + const isAwsS3 = host.includes('.s3.') || host.endsWith('.amazonaws.com'); + if (isAwsS3) { + const key = parsed.pathname.replace(/^\//, ''); + let bucket = ''; + + // virtual-host style: .s3..amazonaws.com + if (host.includes('.s3.')) { + bucket = host.split('.s3.')[0]; + } else { + // path-style: s3..amazonaws.com// + const [first, ...rest] = key.split('/'); + if (first && rest.length) { + bucket = first; + const normalizedKey = rest.join('/'); + return `${API_BASE_URL}/api/assets/s3-proxy/${encodeURIComponent(bucket)}/${normalizedKey}`; + } + } + + if (bucket && key) { + return `${API_BASE_URL}/api/assets/s3-proxy/${encodeURIComponent(bucket)}/${key}`; + } + } + } catch { + // If URL parsing fails, fallback to original path behavior below. + } + + return path; + } + + // Handle S3 storage URIs persisted in DB: s3://bucket/key -> https://bucket.s3.amazonaws.com/key + if (path.startsWith('s3://')) { + const withoutScheme = path.slice(5); + const firstSlash = withoutScheme.indexOf('/'); + if (firstSlash > 0) { + const bucket = withoutScheme.slice(0, firstSlash); + const key = withoutScheme.slice(firstSlash + 1); + if (bucket && key) { + return `${API_BASE_URL}/api/assets/s3-proxy/${encodeURIComponent(bucket)}/${key}`; + } + } + } + + // Handle plain object keys (e.g. org//shared/assets/file.ext) + // when a CDN/base URL is configured for public object access. + const s3PublicBase = process.env.NEXT_PUBLIC_S3_PUBLIC_BASE_URL; + if (s3PublicBase && /^org\/.+/.test(path)) { + return `${s3PublicBase.replace(/\/$/, '')}/${path.replace(/^\//, '')}`; + } + // Map uploads to assets if backend stores relative paths // The main.rs serves "uploads" dir at "/assets" route let cleanPath = path; @@ -941,6 +995,7 @@ export const cmsApi = { getAllUsers: (): Promise => apiFetch('/users'), createUser: (data: UserCreatePayload): Promise => apiFetch('/users', { method: 'POST', body: JSON.stringify(data) }), updateUser: (id: string, payload: { role?: string, organization_id?: string, full_name?: string, avatar_url?: string, bio?: string, language?: string }): Promise => apiFetch(`/users/${id}`, { method: 'PUT', body: JSON.stringify(payload) }), + deleteUser: (id: string): Promise => apiFetch(`/users/${id}`, { method: 'DELETE' }), // Webhooks getWebhooks: (): Promise => apiFetch('/webhooks'),