Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 174 additions & 0 deletions app/admin/integrations/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
'use client';

import { useEffect, useState } from 'react';
import { listLMSProviders } from '@/lib/lms/registry';

interface Integration {
id: string;
providerId: string;
name: string;
enabled: boolean;
createdAt: string;
}

export default function IntegrationsPage() {
const [integrations, setIntegrations] = useState<Integration[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [providerId, setProviderId] = useState('moodle');
const [name, setName] = useState('');
const [config, setConfig] = useState('{}');
const [syncing, setSyncing] = useState(false);
const [syncMsg, setSyncMsg] = useState('');

const providers = listLMSProviders();

useEffect(() => {
fetch('/api/admin/integrations')
.then((r) => r.json())
.then((data) => setIntegrations(data.integrations || []))
.finally(() => setLoading(false));
}, []);

const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
let parsedConfig: unknown;
try {
parsedConfig = JSON.parse(config);
} catch {
alert('Config inválido (debe ser JSON)');
return;
}
const res = await fetch('/api/admin/integrations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ providerId, name, config: parsedConfig }),
});
if (res.ok) {
const data = await res.json();
setIntegrations([...integrations, data.integration]);
setShowForm(false);
setName('');
setConfig('{}');
}
};

const handleSync = async () => {
setSyncing(true);
setSyncMsg('');
const res = await fetch('/api/lti/grades', { method: 'POST' });
const data = await res.json();
setSyncing(false);
if (data.result) {
setSyncMsg(
`Sync: ${data.result.succeeded}/${data.result.total} OK, ${data.result.failed} fallaron.`,
);
} else {
setSyncMsg(data.error || 'Error al sincronizar');
}
};

return (
<div className="max-w-4xl mx-auto p-6">
<header className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold">Integraciones LMS</h1>
<div className="flex gap-2">
<button
onClick={handleSync}
disabled={syncing}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium disabled:opacity-50"
>
{syncing ? 'Sincronizando...' : 'Sincronizar calificaciones'}
</button>
<button
onClick={() => setShowForm(!showForm)}
className="px-4 py-2 bg-violet-600 hover:bg-violet-700 text-white rounded-md text-sm font-medium"
>
{showForm ? 'Cancelar' : '+ Nueva integración'}
</button>
</div>
</header>

{syncMsg && (
<div className="mb-4 p-3 text-sm rounded-md bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300">
{syncMsg}
</div>
)}

{showForm && (
<form
onSubmit={handleCreate}
className="mb-8 p-6 bg-white dark:bg-gray-800 rounded-xl shadow-sm space-y-4"
>
<div>
<label className="block text-sm font-medium mb-1">Proveedor</label>
<select
value={providerId}
onChange={(e) => setProviderId(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md"
>
{providers.map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({p.type})
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Nombre</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
required
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">
Config (JSON)
<span className="text-xs text-gray-500 ml-2">
ej: {'{'}"baseUrl":"https://moodle.example","clientId":"abc"{'}'}
</span>
</label>
<textarea
value={config}
onChange={(e) => setConfig(e.target.value)}
rows={6}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md font-mono text-xs"
/>
</div>
<button
type="submit"
className="px-4 py-2 bg-violet-600 hover:bg-violet-700 text-white rounded-md text-sm font-medium"
>
Crear
</button>
</form>
)}

{loading ? (
<div>Cargando...</div>
) : integrations.length === 0 ? (
<div className="p-12 text-center text-gray-500 border-2 border-dashed rounded-xl">
Sin integraciones configuradas.
</div>
) : (
<ul className="space-y-2">
{integrations.map((i) => (
<li
key={i.id}
className="flex items-center justify-between p-4 bg-white dark:bg-gray-800 rounded-lg shadow-sm"
>
<div>
<div className="font-medium">{i.name}</div>
<div className="text-xs text-gray-500">
{i.providerId} · {i.enabled ? 'activa' : 'desactivada'}
</div>
</div>
</li>
))}
</ul>
)}
</div>
);
}
65 changes: 65 additions & 0 deletions app/admin/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import Link from 'next/link';
import { redirect } from 'next/navigation';
import { auth } from '@/lib/auth/auth';

export const metadata = {
title: 'Administración · OpenMAIC',
};

export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const session = await auth();
if (!session?.user) {
redirect('/auth/signin?callbackUrl=/admin');
}
if (session.user.role !== 'ADMIN') {
return (
<div className="max-w-2xl mx-auto p-8">
<h1 className="text-2xl font-bold mb-4">403 — Acceso denegado</h1>
<p className="text-gray-600 dark:text-gray-400">
Solo los administradores pueden acceder a esta sección.
</p>
<Link href="/" className="mt-4 inline-block text-violet-600 hover:underline">
Volver al inicio
</Link>
</div>
);
}

const navItems = [
{ href: '/admin', label: 'Panel' },
{ href: '/admin/providers', label: 'Proveedores de IA' },
{ href: '/admin/integrations', label: 'Integraciones LMS' },
{ href: '/admin/users', label: 'Usuarios' },
{ href: '/admin/settings', label: 'Configuración' },
];

return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
<header className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800">
<div className="max-w-6xl mx-auto px-6 py-3 flex items-center gap-6">
<Link href="/" className="font-bold text-gray-900 dark:text-gray-100">
OpenMAIC
</Link>
<span className="text-xs font-medium px-2 py-0.5 rounded bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300">
Admin
</span>
<nav className="flex gap-4 text-sm">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className="text-gray-600 dark:text-gray-400 hover:text-violet-600 dark:hover:text-violet-400"
>
{item.label}
</Link>
))}
</nav>
<div className="ml-auto text-xs text-gray-500">
{session.user.email} · {session.user.role}
</div>
</div>
</header>
<main>{children}</main>
</div>
);
}
74 changes: 74 additions & 0 deletions app/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import Link from 'next/link';
import { Cog, Plug, Users, BookOpen, Sliders } from 'lucide-react';

export const metadata = {
title: 'Panel de administración · OpenMAIC',
};

const cards = [
{
href: '/admin/providers',
title: 'Proveedores de IA',
description: 'Configura claves de OpenAI, Anthropic y otros proveedores de modelos.',
icon: Sliders,
},
{
href: '/admin/integrations',
title: 'Integraciones LMS',
description: 'Conecta con Moodle, Odoo, Dolibarr y sincroniza calificaciones.',
icon: Plug,
},
{
href: '/admin/users',
title: 'Usuarios y roles',
description: 'Administra cuentas, asigna roles de profesor, estudiante o administrador.',
icon: Users,
},
{
href: '/admin/settings',
title: 'Configuración general',
description: 'Ajustes globales del sistema, idioma predeterminado y políticas.',
icon: Cog,
},
{
href: '/courses',
title: 'Cursos completos',
description: 'Ver y editar cursos, módulos y capítulos generados.',
icon: BookOpen,
},
];

export default function AdminDashboardPage() {
return (
<div className="max-w-6xl mx-auto p-6">
<header className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100">
Panel de administración
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Gestiona la configuración, integraciones y usuarios de tu instancia de OpenMAIC.
</p>
</header>

<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{cards.map(({ href, title, description, icon: Icon }) => (
<Link
key={href}
href={href}
className="block p-5 bg-white dark:bg-gray-800 rounded-xl shadow-sm hover:shadow-md transition-shadow border border-gray-200 dark:border-gray-700"
>
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-violet-100 dark:bg-violet-900/30 text-violet-600 dark:text-violet-400">
<Icon className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 dark:text-gray-100">{title}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">{description}</p>
</div>
</div>
</Link>
))}
</div>
</div>
);
}
41 changes: 41 additions & 0 deletions app/admin/providers/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { redirect } from 'next/navigation';
import { auth } from '@/lib/auth/auth';
import { AdminProvidersPanel } from '@/components/admin/providers-panel';

export const metadata = {
title: 'Configuración de proveedores · OpenMAIC Admin',
};

export default async function AdminProvidersPage() {
const session = await auth();
if (!session?.user) {
redirect('/auth/signin?callbackUrl=/admin/providers');
}
if (session.user.role !== 'ADMIN') {
return (
<div className="max-w-2xl mx-auto p-8">
<h1 className="text-2xl font-bold mb-4">403 — Acceso denegado</h1>
<p className="text-gray-600 dark:text-gray-400">
Solo los administradores pueden configurar proveedores. Contacta al administrador
del sistema si necesitas acceso.
</p>
</div>
);
}

return (
<div className="max-w-5xl mx-auto p-6">
<header className="mb-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
Configuración de proveedores
</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Estos cambios se persisten en la base de datos y sobrescriben los valores por defecto
de <code>server-providers.yml</code>. Las variables de entorno siguen teniendo
prioridad máxima.
</p>
</header>
<AdminProvidersPanel />
</div>
);
}
Loading