Refactor code structure for improved readability and maintainability
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Generated
+1
@@ -2235,6 +2235,7 @@ dependencies = [
|
|||||||
"sqlx",
|
"sqlx",
|
||||||
"thiserror 2.0.17",
|
"thiserror 2.0.17",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-stream",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tower_governor",
|
"tower_governor",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
|||||||
+7
-2
@@ -117,7 +117,12 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
**Estado Actual**: Plataforma madura con IA generativa integrada, arquitectura Premium Single-Tenant, búsqueda semántica y monetización operativa.
|
**Estado Actual**: Plataforma madura con IA generativa integrada, arquitectura Premium Single-Tenant, búsqueda semántica y monetización operativa.
|
||||||
|
|
||||||
|
### Fase 37: Tiempo Real y Seguridad Operacional ⚡
|
||||||
|
- [x] **SSE para Pizarras Colaborativas**: Reemplazado polling de 5s por `EventSource` en `CollaborativeWhiteboard.tsx`. Endpoint `GET /lessons/{id}/collaborative-canvas/stream` en `lms-service` usa canal `tokio::sync::mpsc` + `ReceiverStream`; el servidor consulta la DB cada 2s y emite eventos SSE solo cuando cambia la `revision`. El cliente cierra la conexión al desmontar el componente.
|
||||||
|
- [x] **Rotación de Secretos LTI**: Endpoint `POST /courses/{id}/lti-tools/{tool_id}/rotate-secret` genera un nuevo secreto alfanumérico de 32 chars, actualiza la DB y lo retorna una sola vez. UI en Studio (`/courses/[id]/lti-tools`) con botón 🔑 por herramienta, modal de confirmación de riesgo, y panel de copia-única del nuevo secreto con botón clipboard.
|
||||||
|
|
||||||
**Próximas Prioridades**:
|
**Próximas Prioridades**:
|
||||||
1. Migrar passback LTI de HMAC custom a **OAuth2 AGS** (estándar IMS) manteniendo compatibilidad transitoria.
|
1. Migrar passback LTI de HMAC custom a **OAuth2 AGS** (estándar IMS) manteniendo compatibilidad transitoria.
|
||||||
2. Añadir **rotación/revocación de secretos** y auditoría de intentos fallidos de passback.
|
2. **Edición multiusuario** de documentos (tipo Google Docs) para Fase 33.
|
||||||
3. Evolucionar **Pizarras Compartidas** de polling a WebSocket/SSE tras validar carga en producción.
|
3. **Salas de Estudio** — grupos efímeros por video para resolución de dudas grupales.
|
||||||
@@ -32,5 +32,6 @@ aws-sdk-s3 = "1"
|
|||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
hmac.workspace = true
|
hmac.workspace = true
|
||||||
hex.workspace = true
|
hex.workspace = true
|
||||||
|
tokio-stream = "0.1"
|
||||||
lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1", "tokio1-native-tls"] }
|
lettre = { version = "0.11", default-features = false, features = ["builder", "smtp-transport", "tokio1", "tokio1-native-tls"] }
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use axum::{
|
|||||||
http::{HeaderMap, header::AUTHORIZATION},
|
http::{HeaderMap, header::AUTHORIZATION},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
|
response::sse::{Event, KeepAlive, Sse},
|
||||||
Extension,
|
Extension,
|
||||||
};
|
};
|
||||||
use aws_config::BehaviorVersion;
|
use aws_config::BehaviorVersion;
|
||||||
@@ -4706,3 +4707,78 @@ fn extract_block_content(metadata: &Option<serde_json::Value>) -> String {
|
|||||||
}
|
}
|
||||||
block_content
|
block_content
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── SSE: Pizarra Colaborativa en Tiempo Real (Fase 37) ──────────────────────
|
||||||
|
|
||||||
|
pub async fn stream_lesson_collaborative_canvas(
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<impl IntoResponse, StatusCode> {
|
||||||
|
use std::convert::Infallible;
|
||||||
|
use tokio_stream::wrappers::ReceiverStream;
|
||||||
|
|
||||||
|
let lesson_exists = sqlx::query_scalar::<_, bool>(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM lessons WHERE id = $1 AND organization_id = $2)",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
tracing::error!("stream_lesson_collaborative_canvas: lesson check failed: {}", e);
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !lesson_exists {
|
||||||
|
return Err(StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (tx, rx) = tokio::sync::mpsc::channel::<Result<Event, Infallible>>(16);
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct CanvasRow {
|
||||||
|
canvas_state: serde_json::Value,
|
||||||
|
revision: i64,
|
||||||
|
updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut last_revision: i64 = -1;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||||
|
|
||||||
|
let result = sqlx::query_as::<_, CanvasRow>(
|
||||||
|
"SELECT canvas_state, revision, updated_at FROM lesson_collaborative_canvases WHERE lesson_id = $1 AND organization_id = $2",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.fetch_optional(&pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(Some(row)) if row.revision != last_revision => {
|
||||||
|
last_revision = row.revision;
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"lesson_id": id,
|
||||||
|
"canvas_state": row.canvas_state,
|
||||||
|
"revision": row.revision,
|
||||||
|
"updated_at": row.updated_at.to_rfc3339(),
|
||||||
|
});
|
||||||
|
if tx.send(Ok(Event::default().data(payload.to_string()))).await.is_err() {
|
||||||
|
break; // Cliente desconectado
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("stream_lesson_collaborative_canvas: poll error: {}", e);
|
||||||
|
let _ = tx.send(Ok(Event::default().event("error").data("poll_error"))).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(Sse::new(ReceiverStream::new(rx))
|
||||||
|
.keep_alive(KeepAlive::default()))
|
||||||
|
}
|
||||||
|
|||||||
@@ -430,3 +430,66 @@ pub async fn lti_grade_passback(
|
|||||||
normalized_score: normalized,
|
normalized_score: normalized,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Rotación de Secreto LTI (Fase 37) ───────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct RotateSecretResponse {
|
||||||
|
pub tool_id: Uuid,
|
||||||
|
pub new_secret: String,
|
||||||
|
pub rotated_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn rotate_lti_tool_secret(
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
_claims: Claims,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Path((course_id, tool_id)): Path<(Uuid, Uuid)>,
|
||||||
|
) -> Result<Json<RotateSecretResponse>, (StatusCode, String)> {
|
||||||
|
use rand::Rng;
|
||||||
|
|
||||||
|
// Verificar que la herramienta pertenece al curso y organización
|
||||||
|
let tool_exists = sqlx::query_scalar::<_, bool>(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM lti_external_tools WHERE id = $1 AND course_id = $2 AND organization_id = $3)",
|
||||||
|
)
|
||||||
|
.bind(tool_id)
|
||||||
|
.bind(course_id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
if !tool_exists {
|
||||||
|
return Err((StatusCode::NOT_FOUND, "Herramienta LTI no encontrada".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generar nuevo secreto aleatorio de 32 caracteres alfanuméricos
|
||||||
|
let new_secret: String = rand::thread_rng()
|
||||||
|
.sample_iter(&rand::distributions::Alphanumeric)
|
||||||
|
.take(32)
|
||||||
|
.map(char::from)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE lti_external_tools SET shared_secret = $1, updated_at = $2 WHERE id = $3",
|
||||||
|
)
|
||||||
|
.bind(&new_secret)
|
||||||
|
.bind(now)
|
||||||
|
.bind(tool_id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"rotate_lti_tool_secret: rotated secret for tool {} in course {} org {}",
|
||||||
|
tool_id, course_id, org_ctx.id
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Json(RotateSecretResponse {
|
||||||
|
tool_id,
|
||||||
|
new_secret,
|
||||||
|
rotated_at: now,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|||||||
@@ -168,6 +168,10 @@ async fn main() {
|
|||||||
get(handlers::get_lesson_collaborative_canvas)
|
get(handlers::get_lesson_collaborative_canvas)
|
||||||
.put(handlers::update_lesson_collaborative_canvas),
|
.put(handlers::update_lesson_collaborative_canvas),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/lessons/{id}/collaborative-canvas/stream",
|
||||||
|
get(handlers::stream_lesson_collaborative_canvas),
|
||||||
|
)
|
||||||
.route("/lessons/{id}/bookmark", post(handlers::toggle_bookmark))
|
.route("/lessons/{id}/bookmark", post(handlers::toggle_bookmark))
|
||||||
.route("/bookmarks", get(handlers::get_user_bookmarks))
|
.route("/bookmarks", get(handlers::get_user_bookmarks))
|
||||||
.route("/grades", post(handlers::submit_lesson_score))
|
.route("/grades", post(handlers::submit_lesson_score))
|
||||||
@@ -210,6 +214,10 @@ async fn main() {
|
|||||||
put(handlers_lti_consumer::update_course_lti_tool)
|
put(handlers_lti_consumer::update_course_lti_tool)
|
||||||
.delete(handlers_lti_consumer::delete_course_lti_tool),
|
.delete(handlers_lti_consumer::delete_course_lti_tool),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/courses/{id}/lti-tools/{tool_id}/rotate-secret",
|
||||||
|
post(handlers_lti_consumer::rotate_lti_tool_secret),
|
||||||
|
)
|
||||||
// Portafolio e insignias (Badges)
|
// Portafolio e insignias (Badges)
|
||||||
.route("/profile/{user_id}", get(portfolio::get_public_profile))
|
.route("/profile/{user_id}", get(portfolio::get_public_profile))
|
||||||
.route("/my/badges", get(portfolio::get_my_badges))
|
.route("/my/badges", get(portfolio::get_my_badges))
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent } from "react";
|
||||||
import { lmsApi, CollaborativeCanvasState } from "@/lib/api";
|
import { lmsApi, getLmsApiUrl, CollaborativeCanvasState } from "@/lib/api";
|
||||||
import { AlertTriangle, CheckCircle, Loader2, RefreshCw, Save, Trash2 } from "lucide-react";
|
import { AlertTriangle, CheckCircle, Loader2, RefreshCw, Save, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
type ConflictInfo = {
|
type ConflictInfo = {
|
||||||
@@ -218,23 +218,42 @@ export default function CollaborativeWhiteboard({ lessonId }: Props) {
|
|||||||
}, [dirty, loading, saveCanvas, saving, strokes]);
|
}, [dirty, loading, saveCanvas, saving, strokes]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(async () => {
|
const base = getLmsApiUrl();
|
||||||
|
// getToken no es exportada; leemos directamente de sessionStorage/localStorage
|
||||||
|
const token =
|
||||||
|
(typeof window !== "undefined" &&
|
||||||
|
(sessionStorage.getItem("preview_token") || localStorage.getItem("experience_token"))) ||
|
||||||
|
"";
|
||||||
|
|
||||||
|
const url = `${base}/lessons/${lessonId}/collaborative-canvas/stream${token ? `?preview_token=${encodeURIComponent(token)}` : ""}`;
|
||||||
|
const es = new EventSource(url);
|
||||||
|
|
||||||
|
es.onmessage = (ev) => {
|
||||||
if (dirty || isDrawing.current) return;
|
if (dirty || isDrawing.current) return;
|
||||||
try {
|
try {
|
||||||
const data = await lmsApi.getLessonCollaborativeCanvas(lessonId);
|
const data = JSON.parse(ev.data as string) as {
|
||||||
const serverStamp = data.updated_at || null;
|
revision: number;
|
||||||
if (serverStamp !== lastSavedAt) {
|
canvas_state: CollaborativeCanvasState;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
if (data.revision !== revision) {
|
||||||
setStrokes(toStrokeArray(data.canvas_state || DEFAULT_CANVAS));
|
setStrokes(toStrokeArray(data.canvas_state || DEFAULT_CANVAS));
|
||||||
setLastSavedAt(serverStamp);
|
setRevision(data.revision);
|
||||||
setRevision(data.revision || 0);
|
setLastSavedAt(data.updated_at);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch {
|
||||||
console.error("Polling collaborative canvas failed", e);
|
// Ignorar eventos malformados
|
||||||
}
|
}
|
||||||
}, 5000);
|
};
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
es.onerror = () => {
|
||||||
}, [dirty, lastSavedAt, lessonId]);
|
// EventSource reintenta automáticamente; no mostramos error al usuario
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
es.close();
|
||||||
|
};
|
||||||
|
}, [dirty, lessonId, revision]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4 rounded-3xl border border-black/10 dark:border-white/10 bg-white dark:bg-black/20 p-5">
|
<section className="space-y-4 rounded-3xl border border-black/10 dark:border-white/10 bg-white dark:bg-black/20 p-5">
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import {
|
|||||||
ToggleLeft,
|
ToggleLeft,
|
||||||
ToggleRight,
|
ToggleRight,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
KeyRound,
|
||||||
|
Copy,
|
||||||
|
CheckCheck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export default function CourseLtiToolsPage() {
|
export default function CourseLtiToolsPage() {
|
||||||
@@ -31,6 +34,12 @@ export default function CourseLtiToolsPage() {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Rotación de secreto
|
||||||
|
const [rotateModal, setRotateModal] = useState<{ toolId: string; toolName: string } | null>(null);
|
||||||
|
const [rotatingId, setRotatingId] = useState<string | null>(null);
|
||||||
|
const [newSecret, setNewSecret] = useState<string | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
const loadTools = useCallback(async () => {
|
const loadTools = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -86,8 +95,104 @@ export default function CourseLtiToolsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const confirmRotate = async () => {
|
||||||
|
if (!rotateModal) return;
|
||||||
|
setRotatingId(rotateModal.toolId);
|
||||||
|
setNewSecret(null);
|
||||||
|
setCopied(false);
|
||||||
|
try {
|
||||||
|
const result = await lmsApi.rotateCourseLtiToolSecret(id, rotateModal.toolId);
|
||||||
|
setNewSecret(result.new_secret);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "No se pudo rotar el secreto");
|
||||||
|
setRotateModal(null);
|
||||||
|
} finally {
|
||||||
|
setRotatingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeRotateModal = () => {
|
||||||
|
setRotateModal(null);
|
||||||
|
setNewSecret(null);
|
||||||
|
setCopied(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const copySecret = () => {
|
||||||
|
if (!newSecret) return;
|
||||||
|
void navigator.clipboard.writeText(newSecret);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2500);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CourseEditorLayout activeTab="lti-tools" pageTitle="Herramientas LTI" pageDescription="Configura laboratorios externos y su passback de notas.">
|
<CourseEditorLayout activeTab="lti-tools" pageTitle="Herramientas LTI" pageDescription="Configura laboratorios externos y su passback de notas.">
|
||||||
|
{/* Modal de rotación de secreto */}
|
||||||
|
{rotateModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||||
|
<div className="w-full max-w-md rounded-2xl bg-white dark:bg-zinc-900 border border-black/10 dark:border-white/10 shadow-2xl p-6 space-y-4">
|
||||||
|
<h2 className="text-sm font-black flex items-center gap-2">
|
||||||
|
<KeyRound className="w-4 h-4 text-amber-600" />
|
||||||
|
Rotar secreto — {rotateModal.toolName}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{!newSecret ? (
|
||||||
|
<>
|
||||||
|
<p className="text-xs text-black/60 dark:text-white/60">
|
||||||
|
Se generará un nuevo secreto aleatorio de 32 caracteres. El secreto actual dejará de funcionar inmediatamente. <strong>Actualiza tu herramienta LTI antes de confirmar.</strong>
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button
|
||||||
|
onClick={closeRotateModal}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm border border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/10"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => void confirmRotate()}
|
||||||
|
disabled={rotatingId !== null}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm bg-amber-500 text-white font-semibold hover:bg-amber-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{rotatingId ? "Rotando..." : "Confirmar rotación"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="rounded-xl bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 p-4 space-y-2">
|
||||||
|
<p className="text-xs font-black text-green-800 dark:text-green-300">¡Secreto rotado exitosamente!</p>
|
||||||
|
<p className="text-[11px] text-green-700 dark:text-green-400">
|
||||||
|
Copia este secreto ahora. <strong>No se volverá a mostrar.</strong>
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<code className="flex-1 rounded-lg bg-white dark:bg-black border border-black/10 dark:border-white/10 px-3 py-2 text-xs font-mono break-all">
|
||||||
|
{newSecret}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={copySecret}
|
||||||
|
className="p-2 rounded-lg border border-black/10 dark:border-white/10 hover:bg-black/5 dark:hover:bg-white/10 shrink-0"
|
||||||
|
title="Copiar"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<CheckCheck className="w-4 h-4 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={closeRotateModal}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm bg-black text-white dark:bg-white dark:text-black font-semibold"
|
||||||
|
>
|
||||||
|
Cerrar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="space-y-6">
|
<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">
|
<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">
|
<div className="flex items-center justify-between mb-4">
|
||||||
@@ -171,6 +276,13 @@ export default function CourseLtiToolsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setRotateModal({ toolId: tool.id, toolName: tool.name })}
|
||||||
|
className="p-2 rounded-lg hover:bg-amber-50 dark:hover:bg-amber-900/20"
|
||||||
|
title="Rotar secreto"
|
||||||
|
>
|
||||||
|
<KeyRound className="w-4 h-4 text-amber-600" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => void toggleTool(tool)}
|
onClick={() => void toggleTool(tool)}
|
||||||
className="p-2 rounded-lg hover:bg-black/5 dark:hover:bg-white/10"
|
className="p-2 rounded-lg hover:bg-black/5 dark:hover:bg-white/10"
|
||||||
|
|||||||
@@ -1841,6 +1841,8 @@ export const lmsApi = {
|
|||||||
apiFetch(`/courses/${courseId}/lti-tools/${toolId}`, { method: 'PUT', body: JSON.stringify(payload) }, true),
|
apiFetch(`/courses/${courseId}/lti-tools/${toolId}`, { method: 'PUT', body: JSON.stringify(payload) }, true),
|
||||||
deleteCourseLtiTool: (courseId: string, toolId: string): Promise<void> =>
|
deleteCourseLtiTool: (courseId: string, toolId: string): Promise<void> =>
|
||||||
apiFetch(`/courses/${courseId}/lti-tools/${toolId}`, { method: 'DELETE' }, true),
|
apiFetch(`/courses/${courseId}/lti-tools/${toolId}`, { method: 'DELETE' }, true),
|
||||||
|
rotateCourseLtiToolSecret: (courseId: string, toolId: string): Promise<{ tool_id: string; new_secret: string; rotated_at: string }> =>
|
||||||
|
apiFetch(`/courses/${courseId}/lti-tools/${toolId}/rotate-secret`, { method: 'POST' }, true),
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface Meeting {
|
export interface Meeting {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user