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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user