feat: add LTI 1.3 Tool Consumer support with database migrations and API endpoints

- Implemented database migrations for lti_external_tools and lti_grade_passback_events tables in both cms-service and lms-service.
- Created API handlers for managing LTI tools including listing, creating, updating, and deleting tools.
- Added functionality for LTI grade passback with validation and signature verification.
- Developed frontend components for LTI tool management and display in course editor.

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-27 12:51:13 -04:00
parent f6a3f6aedf
commit fef731df72
16 changed files with 965 additions and 12 deletions
Generated
+2
View File
@@ -2221,6 +2221,8 @@ dependencies = [
"chrono",
"common",
"dotenvy",
"hex",
"hmac",
"http 1.4.0",
"jsonwebtoken",
"lettre",
+5 -5
View File
@@ -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.
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.
@@ -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);
+2
View File
@@ -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"
@@ -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);
@@ -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<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Deserialize)]
pub struct CreateLtiToolPayload {
pub name: String,
pub launch_url: String,
pub shared_secret: String,
pub enabled: Option<bool>,
pub config: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateLtiToolPayload {
pub name: Option<String>,
pub launch_url: Option<String>,
pub shared_secret: Option<String>,
pub enabled: Option<bool>,
pub config: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
pub struct LtiGradePassbackPayload {
pub user_id: Uuid,
pub lesson_id: Option<Uuid>,
pub score: f32,
pub max_score: Option<f32>,
pub status: Option<String>,
pub metadata: Option<serde_json::Value>,
}
#[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<Uuid>,
pub normalized_score: f32,
}
pub async fn list_course_lti_tools(
Org(org_ctx): Org,
State(pool): State<PgPool>,
Path(course_id): Path<Uuid>,
) -> Result<Json<Vec<LtiExternalTool>>, (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<PgPool>,
Path(course_id): Path<Uuid>,
Json(payload): Json<CreateLtiToolPayload>,
) -> Result<(StatusCode, Json<LtiExternalTool>), (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<PgPool>,
Path((course_id, tool_id)): Path<(Uuid, Uuid)>,
Json(payload): Json<UpdateLtiToolPayload>,
) -> Result<Json<LtiExternalTool>, (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<PgPool>,
Path((course_id, tool_id)): Path<(Uuid, Uuid)>,
) -> Result<StatusCode, (StatusCode, String)> {
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<PgPool>,
Path(tool_id): Path<Uuid>,
headers: HeaderMap,
Json(payload): Json<LtiGradePassbackPayload>,
) -> Result<Json<LtiGradePassbackResponse>, (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::<i64>().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<Sha256>;
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,
}))
}
+16
View File
@@ -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)
@@ -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 (
<LtiToolPlayer
title={block.title || 'Herramienta LTI'}
launchUrl={block.launch_url || block.url || ''}
/>
);
default:
return <div className="p-4 bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10 rounded-xl text-xs font-bold text-gray-600 dark:text-gray-500 uppercase tracking-widest">Tipo de Bloque Desconocido: {block.type}</div>;
}
@@ -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 (
<div className="rounded-xl border border-red-200 dark:border-red-900/40 bg-red-50 dark:bg-red-900/20 p-4 text-sm text-red-700 dark:text-red-300">
La herramienta LTI no tiene una URL segura (HTTPS) válida.
</div>
);
}
return (
<section className="rounded-2xl border border-black/10 dark:border-white/10 overflow-hidden bg-white dark:bg-black/20">
<header className="px-4 py-3 border-b border-black/5 dark:border-white/5 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-semibold">
<ShieldCheck className="w-4 h-4 text-emerald-500" />
{title || "Herramienta Externa"}
</div>
<a
href={launchUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-black/40 dark:text-white/40 hover:text-black/70 dark:hover:text-white/70 inline-flex items-center gap-1"
>
<ExternalLink className="w-3 h-3" />
Abrir en pestaña nueva
</a>
</header>
<iframe
src={launchUrl}
title={title || "Herramienta LTI"}
className="w-full"
style={{ minHeight: "560px", border: "none" }}
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
loading="lazy"
referrerPolicy="strict-origin-when-cross-origin"
/>
</section>
);
}
+1 -1
View File
@@ -184,7 +184,7 @@ export interface QuizQuestion {
export interface Block {
id: string;
type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer' | 'code' | 'hotspot' | 'memory-match' | 'document' | 'audio-response' | 'video_marker' | 'peer-review' | 'role-playing' | 'mermaid' | 'code-lab' | 'scorm' | 'plugin';
type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer' | 'code' | 'hotspot' | 'memory-match' | 'document' | 'audio-response' | 'video_marker' | 'peer-review' | 'role-playing' | 'mermaid' | 'code-lab' | 'scorm' | 'plugin' | 'lti-tool';
title: string;
content?: string;
url?: string;
@@ -3,7 +3,7 @@
import { useEffect, useState, useCallback } from "react";
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import { cmsApi, Lesson, Block, GradingCategory, LibraryBlock, Rubric, RubricLevel, RubricCriterion, LessonDependency, OrganizationExerciseSettings, getImageUrl, generateUUID } from '@/lib/api';
import { cmsApi, lmsApi, Lesson, Block, GradingCategory, LibraryBlock, Rubric, RubricLevel, RubricCriterion, LessonDependency, OrganizationExerciseSettings, LtiExternalTool, getImageUrl, generateUUID } from '@/lib/api';
import {
Layout,
CheckCircle2,
@@ -39,6 +39,7 @@ import RolePlayingBlock from "@/components/blocks/RolePlayingBlock";
import PeerReviewBlock from "@/components/blocks/PeerReviewBlock";
import MermaidBlock from "@/components/blocks/MermaidBlock";
import CodeLabBlock from "@/components/blocks/CodeLabBlock";
import LtiToolBlock from "@/components/blocks/LtiToolBlock";
import SaveToLibraryModal from "@/components/modals/SaveToLibraryModal";
import LibraryPanel from "@/components/LibraryPanel";
import Modal from "@/components/Modal";
@@ -95,6 +96,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
const [aiQuizContext, setAiQuizContext] = useState("");
const [aiQuizType, setAiQuizType] = useState("multiple-choice");
const [exerciseSettings, setExerciseSettings] = useState<OrganizationExerciseSettings>(defaultExerciseSettings);
const [ltiTools, setLtiTools] = useState<LtiExternalTool[]>([]);
const [editValue, setEditValue] = useState("");
@@ -105,12 +107,14 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
const loadData = async () => {
try {
// Use cmsApi for consistency
const [lessonData, orgExerciseSettings] = await Promise.all([
const [lessonData, orgExerciseSettings, courseLtiTools] = await Promise.all([
cmsApi.getLesson(params.lessonId),
cmsApi.getOrganizationExerciseSettings(),
lmsApi.listCourseLtiTools(params.id),
]);
setLesson(lessonData);
setExerciseSettings(orgExerciseSettings);
setLtiTools(courseLtiTools);
setSummary(lessonData.summary || "");
setIsGraded(lessonData.is_graded || false);
setSelectedCategoryId(lessonData.grading_category_id || "");
@@ -281,6 +285,12 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
objectives: "Objetivos...",
initial_message: "Hola, ¿cómo puedo ayudarte?"
}),
...(type === 'lti-tool' && {
title: "Herramienta LTI",
lti_tool_id: "",
launch_url: "",
url: "",
}),
};
setBlocks([...blocks, newBlock]);
};
@@ -1173,6 +1183,16 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
onChange={(updates) => updateBlock(block.id, updates)}
/>
)}
{block.type === 'lti-tool' && (
<LtiToolBlock
title={block.title}
lti_tool_id={block.lti_tool_id}
launch_url={block.launch_url || block.url}
availableTools={ltiTools}
editMode={editMode}
onChange={(updates) => updateBlock(block.id, updates)}
/>
)}
</div>
</div>
))}
@@ -1218,6 +1238,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
...(exerciseSettings.mermaid_enabled ? [{ type: 'mermaid', icon: '📊', label: 'Mermaid Diagram', color: 'indigo' }] : []),
...(exerciseSettings.role_playing_enabled ? [{ type: 'role-playing', icon: '🎭', label: 'Role-Playing AI', color: 'purple' }] : []),
...(exerciseSettings.code_lab_enabled ? [{ type: 'code-lab', icon: '💻', label: 'Code Lab', color: 'slate' }] : []),
{ type: 'lti-tool', icon: '🔗', label: 'LTI Tool', color: 'emerald' },
].map((item) => (
<button
key={item.type}
@@ -0,0 +1,203 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useParams } from "next/navigation";
import CourseEditorLayout from "@/components/CourseEditorLayout";
import {
lmsApi,
LtiExternalTool,
CreateLtiExternalToolPayload,
} from "@/lib/api";
import {
Link2,
Plus,
RefreshCw,
Trash2,
ToggleLeft,
ToggleRight,
ExternalLink,
} from "lucide-react";
export default function CourseLtiToolsPage() {
const { id } = useParams() as { id: string };
const [tools, setTools] = useState<LtiExternalTool[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [form, setForm] = useState<CreateLtiExternalToolPayload>({
name: "",
launch_url: "",
shared_secret: "",
enabled: true,
});
const loadTools = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await lmsApi.listCourseLtiTools(id);
setTools(data);
} catch (e) {
setError(e instanceof Error ? e.message : "No se pudieron cargar herramientas LTI");
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => {
void loadTools();
}, [loadTools]);
const createTool = async () => {
if (!form.name.trim() || !form.launch_url.trim() || !form.shared_secret.trim()) {
setError("Completa nombre, launch_url y shared_secret.");
return;
}
setSaving(true);
setError(null);
try {
const created = await lmsApi.createCourseLtiTool(id, form);
setTools((prev) => [...prev, created]);
setForm({ name: "", launch_url: "", shared_secret: "", enabled: true });
} catch (e) {
setError(e instanceof Error ? e.message : "No se pudo crear la herramienta");
} finally {
setSaving(false);
}
};
const toggleTool = async (tool: LtiExternalTool) => {
try {
const updated = await lmsApi.updateCourseLtiTool(id, tool.id, { enabled: !tool.enabled });
setTools((prev) => prev.map((t) => (t.id === tool.id ? updated : t)));
} catch (e) {
setError(e instanceof Error ? e.message : "No se pudo actualizar la herramienta");
}
};
const deleteTool = async (tool: LtiExternalTool) => {
const ok = confirm(`¿Eliminar la herramienta LTI "${tool.name}"?`);
if (!ok) return;
try {
await lmsApi.deleteCourseLtiTool(id, tool.id);
setTools((prev) => prev.filter((t) => t.id !== tool.id));
} catch (e) {
setError(e instanceof Error ? e.message : "No se pudo eliminar la herramienta");
}
};
return (
<CourseEditorLayout activeTab="lti-tools" pageTitle="Herramientas LTI" pageDescription="Configura laboratorios externos y su passback de notas.">
<div className="space-y-6">
<section className="rounded-2xl border border-black/10 dark:border-white/10 bg-white/60 dark:bg-white/5 p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold flex items-center gap-2">
<Plus className="w-4 h-4" />
Registrar Herramienta
</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<input
className="rounded-lg border border-black/10 dark:border-white/10 bg-transparent px-3 py-2 text-sm"
placeholder="Nombre"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
/>
<input
className="rounded-lg border border-black/10 dark:border-white/10 bg-transparent px-3 py-2 text-sm"
placeholder="https://tool.example/launch"
value={form.launch_url}
onChange={(e) => setForm((f) => ({ ...f, launch_url: e.target.value }))}
/>
<input
className="rounded-lg border border-black/10 dark:border-white/10 bg-transparent px-3 py-2 text-sm"
placeholder="Shared secret (>=16)"
value={form.shared_secret}
onChange={(e) => setForm((f) => ({ ...f, shared_secret: e.target.value }))}
/>
</div>
<div className="mt-3 flex items-center gap-3">
<button
onClick={() => void createTool()}
disabled={saving}
className="px-4 py-2 rounded-lg bg-black text-white dark:bg-white dark:text-black text-sm disabled:opacity-50"
>
{saving ? "Guardando..." : "Crear herramienta"}
</button>
<p className="text-xs text-black/50 dark:text-white/50">
Passback endpoint: <span className="font-mono">/lti/tools/{'{tool_id}'}/grade-passback</span>. Headers requeridos: <span className="font-mono">x-openccb-lti-timestamp</span> y <span className="font-mono">x-openccb-lti-signature</span> (HMAC-SHA256).
</p>
</div>
</section>
<section className="rounded-2xl border border-black/10 dark:border-white/10 bg-white/60 dark:bg-white/5 p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold flex items-center gap-2">
<Link2 className="w-4 h-4" />
Herramientas registradas
</h2>
<button
onClick={() => void loadTools()}
className="p-2 rounded-lg hover:bg-black/5 dark:hover:bg-white/10"
title="Recargar"
>
<RefreshCw className={`w-4 h-4 ${loading ? "animate-spin" : ""}`} />
</button>
</div>
{error && (
<div className="mb-3 text-sm text-red-600 dark:text-red-400">{error}</div>
)}
<div className="space-y-3">
{tools.map((tool) => (
<div
key={tool.id}
className="rounded-xl border border-black/10 dark:border-white/10 px-4 py-3 flex items-center justify-between gap-4"
>
<div className="min-w-0">
<div className="text-sm font-semibold truncate">{tool.name}</div>
<a
href={tool.launch_url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 dark:text-blue-400 hover:underline inline-flex items-center gap-1"
>
<ExternalLink className="w-3 h-3" />
{tool.launch_url}
</a>
<div className="text-[11px] text-black/50 dark:text-white/50 mt-1 font-mono">
tool_id: {tool.id}
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => void toggleTool(tool)}
className="p-2 rounded-lg hover:bg-black/5 dark:hover:bg-white/10"
title={tool.enabled ? "Deshabilitar" : "Habilitar"}
>
{tool.enabled ? (
<ToggleRight className="w-5 h-5 text-green-600" />
) : (
<ToggleLeft className="w-5 h-5 text-black/40 dark:text-white/40" />
)}
</button>
<button
onClick={() => void deleteTool(tool)}
className="p-2 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20"
title="Eliminar"
>
<Trash2 className="w-4 h-4 text-red-600" />
</button>
</div>
</div>
))}
{!loading && tools.length === 0 && (
<div className="text-sm text-black/50 dark:text-white/50">No hay herramientas LTI registradas para este curso.</div>
)}
</div>
</section>
</div>
</CourseEditorLayout>
);
}
@@ -6,7 +6,7 @@ import { useParams, useRouter } from "next/navigation";
import {
Layout, CheckCircle2, BarChart2, Settings, Folder,
GraduationCap, Megaphone, Users, Award, Video,
BookOpen, ShieldCheck, Radio, TrendingUp, ChevronLeft
BookOpen, ShieldCheck, Radio, TrendingUp, ChevronLeft, Link2
} from "lucide-react";
import { cmsApi, Course } from "@/lib/api";
@@ -25,7 +25,8 @@ type TabKey =
| "peer-reviews"
| "students"
| "sessions"
| "pedagogical";
| "pedagogical"
| "lti-tools";
interface CourseEditorLayoutProps {
children: React.ReactNode;
@@ -116,6 +117,7 @@ export default function CourseEditorLayout({
tabs: [
{ key: "analytics", label: "Analíticas", icon: BarChart2, href: `/courses/${id}/analytics` },
{ key: "pedagogical", label: "Análisis Pedagógico", icon: TrendingUp, href: `/courses/${id}/analytics/pedagogical` },
{ key: "lti-tools", label: "Herramientas LTI", icon: Link2, href: `/courses/${id}/lti-tools` },
{ key: "settings", label: "Configuración", icon: Settings, href: `/courses/${id}/settings` },
],
},
@@ -0,0 +1,100 @@
"use client";
import { LtiExternalTool } from "@/lib/api";
interface LtiToolBlockProps {
title?: string;
lti_tool_id?: string;
launch_url?: string;
availableTools: LtiExternalTool[];
editMode: boolean;
onChange: (updates: {
title?: string;
lti_tool_id?: string;
launch_url?: string;
url?: string;
}) => void;
}
export default function LtiToolBlock({
title,
lti_tool_id,
launch_url,
availableTools,
editMode,
onChange,
}: LtiToolBlockProps) {
const selectedTool = availableTools.find((t) => t.id === lti_tool_id);
if (!editMode) {
return (
<div className="rounded-xl border border-black/10 dark:border-white/10 bg-white dark:bg-white/5 p-4">
<h3 className="text-sm font-semibold mb-2">{title || "Herramienta LTI"}</h3>
<p className="text-xs text-black/60 dark:text-white/60 mb-2">
{selectedTool ? `Tool registrada: ${selectedTool.name}` : "Tool no seleccionada"}
</p>
<p className="text-xs font-mono text-blue-600 dark:text-blue-400 break-all">
{launch_url || "https://..."}
</p>
</div>
);
}
return (
<div className="rounded-xl border border-black/10 dark:border-white/10 bg-white dark:bg-white/5 p-4 space-y-4">
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-black/50 dark:text-white/50 mb-1">
Titulo del bloque
</label>
<input
value={title || ""}
onChange={(e) => onChange({ title: e.target.value })}
placeholder="Laboratorio Externo"
className="w-full rounded-lg border border-black/10 dark:border-white/10 bg-transparent px-3 py-2 text-sm"
/>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-black/50 dark:text-white/50 mb-1">
Herramienta LTI registrada
</label>
<select
value={lti_tool_id || ""}
onChange={(e) => {
const value = e.target.value;
const tool = availableTools.find((t) => t.id === value);
onChange({
lti_tool_id: value || undefined,
launch_url: tool?.launch_url,
url: tool?.launch_url,
...(tool && !title ? { title: tool.name } : {}),
});
}}
className="w-full rounded-lg border border-black/10 dark:border-white/10 bg-transparent px-3 py-2 text-sm"
>
<option value="">Selecciona una herramienta...</option>
{availableTools.map((tool) => (
<option key={tool.id} value={tool.id}>
{tool.name}
</option>
))}
</select>
<p className="mt-1 text-[11px] text-black/50 dark:text-white/50">
Si no aparece ninguna, primero crea la tool en la seccion Herramientas LTI del curso.
</p>
</div>
<div>
<label className="block text-xs font-semibold uppercase tracking-wider text-black/50 dark:text-white/50 mb-1">
Launch URL
</label>
<input
value={launch_url || ""}
onChange={(e) => onChange({ launch_url: e.target.value, url: e.target.value })}
placeholder="https://tool.example/launch"
className="w-full rounded-lg border border-black/10 dark:border-white/10 bg-transparent px-3 py-2 text-sm font-mono"
/>
</div>
</div>
);
}
+41 -1
View File
@@ -163,7 +163,7 @@ export interface QuizQuestion {
export interface Block {
id: string;
type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer' | 'document' | 'video_marker' | 'audio-response' | 'memory-match' | 'hotspot' | 'peer-review' | 'role-playing' | 'mermaid' | 'code-lab';
type: 'description' | 'media' | 'quiz' | 'fill-in-the-blanks' | 'matching' | 'ordering' | 'short-answer' | 'document' | 'video_marker' | 'audio-response' | 'memory-match' | 'hotspot' | 'peer-review' | 'role-playing' | 'mermaid' | 'code-lab' | 'lti-tool';
title?: string;
content?: string;
url?: string;
@@ -208,6 +208,9 @@ export interface Block {
initial_code?: string;
solution?: string;
test_cases?: { description: string; expected: string }[];
// LTI Tool fields
lti_tool_id?: string;
launch_url?: string;
}
export interface Lesson {
@@ -1828,6 +1831,16 @@ export const lmsApi = {
apiFetch(`/plugins/${id}`, { method: 'PUT', body: JSON.stringify(payload) }, true),
deletePlugin: (id: string): Promise<void> =>
apiFetch(`/plugins/${id}`, { method: 'DELETE' }, true),
// Fase 36: LTI 1.3 Tool Consumer
listCourseLtiTools: (courseId: string): Promise<LtiExternalTool[]> =>
apiFetch(`/courses/${courseId}/lti-tools`, {}, true),
createCourseLtiTool: (courseId: string, payload: CreateLtiExternalToolPayload): Promise<LtiExternalTool> =>
apiFetch(`/courses/${courseId}/lti-tools`, { method: 'POST', body: JSON.stringify(payload) }, true),
updateCourseLtiTool: (courseId: string, toolId: string, payload: UpdateLtiExternalToolPayload): Promise<LtiExternalTool> =>
apiFetch(`/courses/${courseId}/lti-tools/${toolId}`, { method: 'PUT', body: JSON.stringify(payload) }, true),
deleteCourseLtiTool: (courseId: string, toolId: string): Promise<void> =>
apiFetch(`/courses/${courseId}/lti-tools/${toolId}`, { method: 'DELETE' }, true),
};
export interface Meeting {
@@ -2004,6 +2017,33 @@ export interface UpdatePluginPayload {
enabled?: boolean;
}
// Fase 36: LTI 1.3 Tool Consumer
export interface LtiExternalTool {
id: string;
organization_id: string;
course_id: string;
name: string;
launch_url: string;
enabled: boolean;
config: Record<string, unknown>;
created_at: string;
updated_at: string;
}
export interface CreateLtiExternalToolPayload {
name: string;
launch_url: string;
shared_secret: string;
enabled?: boolean;
config?: Record<string, unknown>;
}
export interface UpdateLtiExternalToolPayload {
name?: string;
launch_url?: string;
shared_secret?: string;
enabled?: boolean;
config?: Record<string, unknown>;
}
export interface LtiDeepLinkingContentItem {
type: 'ltiResourceLink';
title?: string;
File diff suppressed because one or more lines are too long