diff --git a/Cargo.lock b/Cargo.lock index 694c1df..1512309 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2221,6 +2221,8 @@ dependencies = [ "chrono", "common", "dotenvy", + "hex", + "hmac", "http 1.4.0", "jsonwebtoken", "lettre", diff --git a/roadmap.md b/roadmap.md index e18f1c6..a8804db 100644 --- a/roadmap.md +++ b/roadmap.md @@ -111,13 +111,13 @@ - [x] **OpenCCB Market**: Galer铆a en Studio (`/admin/plugins`) con toggle habilitado/deshabilitado, registro de nuevos plugins y tarjetas con estado visual. ### Fase 36: LTI 1.3 Tool Consumer 馃敆 -- [ ] **Consumo de herramientas externas**: Capacidad de embeber laboratorios externos (ej: MATLAB, Labster) dentro de OpenCCB. -- [ ] **Delegaci贸n de Calificaciones**: Recibir notas de herramientas externas y sincronizarlas con el Gradebook de OpenCCB. +- [x] **Consumo de herramientas externas**: MVP implementado con `lti_external_tools` por curso, gesti贸n en Studio (`/courses/[id]/lti-tools`) y bloque embebido `lti-tool` en Experience v铆a iframe sandbox. +- [x] **Delegaci贸n de Calificaciones**: MVP implementado con endpoint p煤blico `POST /lti/tools/{tool_id}/grade-passback`, validaci贸n por `x-openccb-lti-secret`, registro de eventos y sincronizaci贸n a `user_grades`. --- **Estado Actual**: Plataforma madura con IA generativa integrada, arquitectura Premium Single-Tenant, b煤squeda sem谩ntica y monetizaci贸n operativa. **Pr贸ximas Prioridades**: -1. Implementar **Ecosistema de Plugins** (Fase 35): tabla `course_plugins`, CRUD en Studio, renderizador seguro de Web Components en Experience. -2. Evaluar paso de polling a **WebSocket/SSE** en Pizarras Compartidas una vez validado uso real. -3. Extender el modelo colaborativo a **Edici贸n Multiusuario** de documentos. \ No newline at end of file +1. Endurecer seguridad de **passback LTI**: rotaci贸n de secretos, firma HMAC y/o OAuth2 AGS en lugar de shared secret plano. +2. A帽adir selector de **herramienta LTI** en editor de lecciones para generar bloques `lti-tool` sin JSON manual. +3. Evolucionar **Pizarras Compartidas** de polling a WebSocket/SSE tras validar carga en producci贸n. \ No newline at end of file diff --git a/services/cms-service/migrations/20260427000004_lti_tool_consumer.sql b/services/cms-service/migrations/20260427000004_lti_tool_consumer.sql new file mode 100644 index 0000000..46d4094 --- /dev/null +++ b/services/cms-service/migrations/20260427000004_lti_tool_consumer.sql @@ -0,0 +1,39 @@ +-- Fase 36: LTI 1.3 Tool Consumer (mirror migration) + +CREATE TABLE IF NOT EXISTS lti_external_tools ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + name TEXT NOT NULL, + launch_url TEXT NOT NULL, + shared_secret TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + config JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (organization_id, course_id, name) +); + +CREATE INDEX IF NOT EXISTS idx_lti_external_tools_org_course + ON lti_external_tools(organization_id, course_id); + +CREATE TABLE IF NOT EXISTS lti_grade_passback_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + tool_id UUID NOT NULL REFERENCES lti_external_tools(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + lesson_id UUID REFERENCES lessons(id) ON DELETE SET NULL, + raw_score FLOAT4 NOT NULL, + max_score FLOAT4 NOT NULL DEFAULT 1, + normalized_score FLOAT4 NOT NULL, + status TEXT, + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_lti_passback_tool_created + ON lti_grade_passback_events(tool_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_lti_passback_user_course + ON lti_grade_passback_events(user_id, course_id); diff --git a/services/lms-service/Cargo.toml b/services/lms-service/Cargo.toml index b4ae72d..9b1e9f8 100644 --- a/services/lms-service/Cargo.toml +++ b/services/lms-service/Cargo.toml @@ -30,5 +30,7 @@ mime_guess = "2.0" aws-config = "1" aws-sdk-s3 = "1" sha2.workspace = true +hmac.workspace = true +hex.workspace = true lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1", "tokio1-native-tls"] } rand = "0.8" diff --git a/services/lms-service/migrations/20260427000004_lti_tool_consumer.sql b/services/lms-service/migrations/20260427000004_lti_tool_consumer.sql new file mode 100644 index 0000000..d5fbe90 --- /dev/null +++ b/services/lms-service/migrations/20260427000004_lti_tool_consumer.sql @@ -0,0 +1,40 @@ +-- Fase 36: LTI 1.3 Tool Consumer +-- Registro de herramientas externas por curso + eventos de passback de notas + +CREATE TABLE IF NOT EXISTS lti_external_tools ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + name TEXT NOT NULL, + launch_url TEXT NOT NULL, + shared_secret TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + config JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (organization_id, course_id, name) +); + +CREATE INDEX IF NOT EXISTS idx_lti_external_tools_org_course + ON lti_external_tools(organization_id, course_id); + +CREATE TABLE IF NOT EXISTS lti_grade_passback_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + tool_id UUID NOT NULL REFERENCES lti_external_tools(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + lesson_id UUID REFERENCES lessons(id) ON DELETE SET NULL, + raw_score FLOAT4 NOT NULL, + max_score FLOAT4 NOT NULL DEFAULT 1, + normalized_score FLOAT4 NOT NULL, + status TEXT, + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_lti_passback_tool_created + ON lti_grade_passback_events(tool_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_lti_passback_user_course + ON lti_grade_passback_events(user_id, course_id); diff --git a/services/lms-service/src/handlers_lti_consumer.rs b/services/lms-service/src/handlers_lti_consumer.rs new file mode 100644 index 0000000..9e0caa0 --- /dev/null +++ b/services/lms-service/src/handlers_lti_consumer.rs @@ -0,0 +1,432 @@ +use axum::{ + Json, + extract::{Path, State}, + http::{HeaderMap, StatusCode}, +}; +use common::{ + auth::Claims, + middleware::Org, +}; +use serde::{Deserialize, Serialize}; +use sqlx::{PgPool, Row}; +use uuid::Uuid; +use hmac::{Hmac, Mac}; +use sha2::Sha256; + +#[derive(Debug, Serialize)] +pub struct LtiExternalTool { + pub id: Uuid, + pub organization_id: Uuid, + pub course_id: Uuid, + pub name: String, + pub launch_url: String, + pub enabled: bool, + pub config: serde_json::Value, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} + +#[derive(Debug, Deserialize)] +pub struct CreateLtiToolPayload { + pub name: String, + pub launch_url: String, + pub shared_secret: String, + pub enabled: Option, + pub config: Option, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateLtiToolPayload { + pub name: Option, + pub launch_url: Option, + pub shared_secret: Option, + pub enabled: Option, + pub config: Option, +} + +#[derive(Debug, Deserialize)] +pub struct LtiGradePassbackPayload { + pub user_id: Uuid, + pub lesson_id: Option, + pub score: f32, + pub max_score: Option, + pub status: Option, + pub metadata: Option, +} + +#[derive(Debug, Serialize)] +pub struct LtiGradePassbackResponse { + pub success: bool, + pub tool_id: Uuid, + pub user_id: Uuid, + pub course_id: Uuid, + pub lesson_id: Option, + pub normalized_score: f32, +} + +pub async fn list_course_lti_tools( + Org(org_ctx): Org, + State(pool): State, + Path(course_id): Path, +) -> Result>, (StatusCode, String)> { + let rows = sqlx::query( + r#" + SELECT id, organization_id, course_id, name, launch_url, enabled, config, created_at, updated_at + FROM lti_external_tools + WHERE organization_id = $1 AND course_id = $2 + ORDER BY created_at ASC + "#, + ) + .bind(org_ctx.id) + .bind(course_id) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + let tools = rows + .into_iter() + .map(|r| LtiExternalTool { + id: r.get("id"), + organization_id: r.get("organization_id"), + course_id: r.get("course_id"), + name: r.get("name"), + launch_url: r.get("launch_url"), + enabled: r.get("enabled"), + config: r.get("config"), + created_at: r.get("created_at"), + updated_at: r.get("updated_at"), + }) + .collect(); + + Ok(Json(tools)) +} + +pub async fn create_course_lti_tool( + Org(org_ctx): Org, + _claims: Claims, + State(pool): State, + Path(course_id): Path, + Json(payload): Json, +) -> Result<(StatusCode, Json), (StatusCode, String)> { + if !payload.launch_url.starts_with("https://") { + return Err(( + StatusCode::UNPROCESSABLE_ENTITY, + "launch_url debe usar HTTPS".to_string(), + )); + } + + if payload.shared_secret.trim().len() < 16 { + return Err(( + StatusCode::UNPROCESSABLE_ENTITY, + "shared_secret debe tener al menos 16 caracteres".to_string(), + )); + } + + let row = sqlx::query( + r#" + INSERT INTO lti_external_tools (organization_id, course_id, name, launch_url, shared_secret, enabled, config) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, organization_id, course_id, name, launch_url, enabled, config, created_at, updated_at + "#, + ) + .bind(org_ctx.id) + .bind(course_id) + .bind(&payload.name) + .bind(&payload.launch_url) + .bind(&payload.shared_secret) + .bind(payload.enabled.unwrap_or(true)) + .bind(payload.config.unwrap_or(serde_json::json!({}))) + .fetch_one(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + Ok(( + StatusCode::CREATED, + Json(LtiExternalTool { + id: row.get("id"), + organization_id: row.get("organization_id"), + course_id: row.get("course_id"), + name: row.get("name"), + launch_url: row.get("launch_url"), + enabled: row.get("enabled"), + config: row.get("config"), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + }), + )) +} + +pub async fn update_course_lti_tool( + Org(org_ctx): Org, + _claims: Claims, + State(pool): State, + Path((course_id, tool_id)): Path<(Uuid, Uuid)>, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + if let Some(url) = &payload.launch_url { + if !url.starts_with("https://") { + return Err(( + StatusCode::UNPROCESSABLE_ENTITY, + "launch_url debe usar HTTPS".to_string(), + )); + } + } + + if let Some(secret) = &payload.shared_secret { + if secret.trim().len() < 16 { + return Err(( + StatusCode::UNPROCESSABLE_ENTITY, + "shared_secret debe tener al menos 16 caracteres".to_string(), + )); + } + } + + let row = sqlx::query( + r#" + UPDATE lti_external_tools + SET + name = COALESCE($4, name), + launch_url = COALESCE($5, launch_url), + shared_secret = COALESCE($6, shared_secret), + enabled = COALESCE($7, enabled), + config = COALESCE($8, config), + updated_at = NOW() + WHERE id = $1 AND organization_id = $2 AND course_id = $3 + RETURNING id, organization_id, course_id, name, launch_url, enabled, config, created_at, updated_at + "#, + ) + .bind(tool_id) + .bind(org_ctx.id) + .bind(course_id) + .bind(payload.name) + .bind(payload.launch_url) + .bind(payload.shared_secret) + .bind(payload.enabled) + .bind(payload.config) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Herramienta LTI no encontrada".to_string()))?; + + Ok(Json(LtiExternalTool { + id: row.get("id"), + organization_id: row.get("organization_id"), + course_id: row.get("course_id"), + name: row.get("name"), + launch_url: row.get("launch_url"), + enabled: row.get("enabled"), + config: row.get("config"), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + })) +} + +pub async fn delete_course_lti_tool( + Org(org_ctx): Org, + _claims: Claims, + State(pool): State, + Path((course_id, tool_id)): Path<(Uuid, Uuid)>, +) -> Result { + let res = sqlx::query( + "DELETE FROM lti_external_tools WHERE id = $1 AND organization_id = $2 AND course_id = $3", + ) + .bind(tool_id) + .bind(org_ctx.id) + .bind(course_id) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if res.rows_affected() == 0 { + return Err((StatusCode::NOT_FOUND, "Herramienta LTI no encontrada".to_string())); + } + + Ok(StatusCode::NO_CONTENT) +} + +pub async fn lti_grade_passback( + State(pool): State, + Path(tool_id): Path, + headers: HeaderMap, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + let signature_hex = headers + .get("x-openccb-lti-signature") + .and_then(|h| h.to_str().ok()) + .ok_or((StatusCode::UNAUTHORIZED, "Falta header x-openccb-lti-signature".to_string()))?; + + let timestamp = headers + .get("x-openccb-lti-timestamp") + .and_then(|h| h.to_str().ok()) + .and_then(|s| s.parse::().ok()) + .ok_or((StatusCode::UNAUTHORIZED, "Falta header x-openccb-lti-timestamp v谩lido".to_string()))?; + + let now = chrono::Utc::now().timestamp(); + if (now - timestamp).abs() > 300 { + return Err(( + StatusCode::UNAUTHORIZED, + "Timestamp fuera de ventana permitida (5 minutos)".to_string(), + )); + } + + let tool_row = sqlx::query( + "SELECT organization_id, course_id, shared_secret, enabled FROM lti_external_tools WHERE id = $1", + ) + .bind(tool_id) + .fetch_optional(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .ok_or((StatusCode::NOT_FOUND, "Herramienta LTI no encontrada".to_string()))?; + + let organization_id: Uuid = tool_row.get("organization_id"); + let course_id: Uuid = tool_row.get("course_id"); + let shared_secret: String = tool_row.get("shared_secret"); + let enabled: bool = tool_row.get("enabled"); + + if !enabled { + return Err((StatusCode::FORBIDDEN, "La herramienta est谩 deshabilitada".to_string())); + } + + let max_score_for_sig = payload.max_score.unwrap_or(1.0).max(0.0001); + let lesson_marker = payload + .lesson_id + .map(|id| id.to_string()) + .unwrap_or_else(|| "-".to_string()); + let canonical = format!( + "{}:{}:{}:{}:{}:{}", + timestamp, + tool_id, + payload.user_id, + lesson_marker, + payload.score.to_bits(), + max_score_for_sig.to_bits(), + ); + + let provided_sig_bytes = hex::decode(signature_hex) + .map_err(|_| (StatusCode::UNAUTHORIZED, "Firma inv谩lida (hex)".to_string()))?; + + type HmacSha256 = Hmac; + let mut verifier = HmacSha256::new_from_slice(shared_secret.as_bytes()) + .map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Error interno de firma".to_string()))?; + verifier.update(canonical.as_bytes()); + + if verifier.verify_slice(&provided_sig_bytes).is_err() { + return Err((StatusCode::UNAUTHORIZED, "Firma de passback inv谩lida".to_string())); + } + + // Asegurar que el usuario existe y pertenece a la misma organizaci贸n + let user_exists: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM users WHERE id = $1 AND organization_id = $2)", + ) + .bind(payload.user_id) + .bind(organization_id) + .fetch_one(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if !user_exists { + return Err((StatusCode::UNPROCESSABLE_ENTITY, "user_id inv谩lido para esta organizaci贸n".to_string())); + } + + // Si viene lesson_id, validar que pertenece al curso + if let Some(lesson_id) = payload.lesson_id { + let lesson_ok: bool = sqlx::query_scalar( + r#" + SELECT EXISTS ( + SELECT 1 + FROM lessons l + JOIN modules m ON m.id = l.module_id + WHERE l.id = $1 + AND m.course_id = $2 + AND l.organization_id = $3 + ) + "#, + ) + .bind(lesson_id) + .bind(course_id) + .bind(organization_id) + .fetch_one(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + if !lesson_ok { + return Err((StatusCode::UNPROCESSABLE_ENTITY, "lesson_id no pertenece al curso".to_string())); + } + } + + let max_score = payload.max_score.unwrap_or(1.0).max(0.0001); + let mut normalized = payload.score / max_score; + if normalized.is_nan() || !normalized.is_finite() { + normalized = 0.0; + } + normalized = normalized.clamp(0.0, 1.0); + + // Persistir evento de passback para auditor铆a + let status_for_event = payload.status.clone(); + + sqlx::query( + r#" + INSERT INTO lti_grade_passback_events + (organization_id, tool_id, user_id, course_id, lesson_id, raw_score, max_score, normalized_score, status, metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + "#, + ) + .bind(organization_id) + .bind(tool_id) + .bind(payload.user_id) + .bind(course_id) + .bind(payload.lesson_id) + .bind(payload.score) + .bind(max_score) + .bind(normalized) + .bind(status_for_event) + .bind(payload.metadata.clone().unwrap_or(serde_json::json!({}))) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + + // Sincronizar con gradebook solo cuando hay lesson_id + if let Some(lesson_id) = payload.lesson_id { + let metadata = serde_json::json!({ + "lti_passback": { + "tool_id": tool_id, + "status": payload.status, + "raw_score": payload.score, + "max_score": max_score, + "normalized": normalized, + "at": chrono::Utc::now(), + "extra": payload.metadata + } + }); + + sqlx::query( + r#" + INSERT INTO user_grades (organization_id, user_id, course_id, lesson_id, score, metadata, attempts_count) + VALUES ($1, $2, $3, $4, $5, $6, 1) + ON CONFLICT (user_id, lesson_id) + DO UPDATE SET + score = EXCLUDED.score, + metadata = COALESCE(user_grades.metadata, '{}'::jsonb) || EXCLUDED.metadata, + attempts_count = user_grades.attempts_count + 1 + "#, + ) + .bind(organization_id) + .bind(payload.user_id) + .bind(course_id) + .bind(lesson_id) + .bind(normalized) + .bind(metadata) + .execute(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + } + + Ok(Json(LtiGradePassbackResponse { + success: true, + tool_id, + user_id: payload.user_id, + course_id, + lesson_id: payload.lesson_id, + normalized_score: normalized, + })) +} diff --git a/services/lms-service/src/main.rs b/services/lms-service/src/main.rs index 063ffb2..9086d21 100644 --- a/services/lms-service/src/main.rs +++ b/services/lms-service/src/main.rs @@ -2,6 +2,7 @@ mod db_util; mod handlers; mod handlers_announcements; mod handlers_pedagogical; +mod handlers_lti_consumer; mod handlers_email; mod handlers_scorm; mod handlers_search; @@ -198,6 +199,17 @@ async fn main() { // Aprendizaje en Vivo (Live Learning) .route("/courses/{id}/meetings", get(live::get_course_meetings).post(live::create_meeting)) .route("/courses/{id}/meetings/{meeting_id}", delete(live::delete_meeting)) + // LTI 1.3 Tool Consumer (Fase 36) + .route( + "/courses/{id}/lti-tools", + get(handlers_lti_consumer::list_course_lti_tools) + .post(handlers_lti_consumer::create_course_lti_tool), + ) + .route( + "/courses/{id}/lti-tools/{tool_id}", + put(handlers_lti_consumer::update_course_lti_tool) + .delete(handlers_lti_consumer::delete_course_lti_tool), + ) // Portafolio e insignias (Badges) .route("/profile/{user_id}", get(portfolio::get_public_profile)) .route("/my/badges", get(portfolio::get_my_badges)) @@ -438,6 +450,10 @@ async fn main() { ) .route("/lti/login", get(lti::lti_login_initiation)) .route("/lti/launch", post(lti::lti_launch)) + .route( + "/lti/tools/{tool_id}/grade-passback", + post(handlers_lti_consumer::lti_grade_passback), + ) .route("/lti/jwks", get(jwks::lti_jwks_handler)) .route("/lti/deep-linking/response", post(lti::lti_deep_linking_response)) .merge(protected_routes) diff --git a/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx b/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx index 5e32459..ebf370e 100644 --- a/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx +++ b/web/experience/src/app/courses/[id]/lessons/[lessonId]/page.tsx @@ -23,6 +23,7 @@ import PeerReviewPlayer from "@/components/blocks/PeerReviewPlayer"; import MermaidViewer from "@/components/blocks/MermaidViewer"; import ScormPlayer from "@/components/blocks/ScormPlayer"; import PluginBlock from "@/components/blocks/PluginBlock"; +import LtiToolPlayer from "@/components/blocks/LtiToolPlayer"; import InteractiveTranscript from "@/components/InteractiveTranscript"; import AITutor from "@/components/AITutor"; import LessonLockedView from "@/components/LessonLockedView"; @@ -542,6 +543,13 @@ export default function LessonPlayerPage({ params }: { params: { id: string, les launchUrl={block.launch_url || block.url || lesson.content_url || ""} /> ); + case 'lti-tool': + return ( + + ); default: return
Tipo de Bloque Desconocido: {block.type}
; } diff --git a/web/experience/src/components/blocks/LtiToolPlayer.tsx b/web/experience/src/components/blocks/LtiToolPlayer.tsx new file mode 100644 index 0000000..b60d242 --- /dev/null +++ b/web/experience/src/components/blocks/LtiToolPlayer.tsx @@ -0,0 +1,48 @@ +"use client"; + +import React from "react"; +import { ExternalLink, ShieldCheck } from "lucide-react"; + +interface LtiToolPlayerProps { + title: string; + launchUrl: string; +} + +export default function LtiToolPlayer({ title, launchUrl }: LtiToolPlayerProps) { + if (!launchUrl || !launchUrl.startsWith("https://")) { + return ( +
+ La herramienta LTI no tiene una URL segura (HTTPS) v谩lida. +
+ ); + } + + return ( +
+
+
+ + {title || "Herramienta Externa"} +
+ + + Abrir en pesta帽a nueva + +
+