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
@@ -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>
);
}