feat: expand admin and instructor tools
This commit is contained in:
3
App.tsx
3
App.tsx
@@ -10,6 +10,7 @@ import { AuthProvider, useAuth } from './context/AuthContext';
|
|||||||
import { ToastProvider } from './context/ToastContext';
|
import { ToastProvider } from './context/ToastContext';
|
||||||
import { Course, UserRole } from './types';
|
import { Course, UserRole } from './types';
|
||||||
import { SuperAdmin } from './pages/SuperAdmin';
|
import { SuperAdmin } from './pages/SuperAdmin';
|
||||||
|
import { AccessTokenPage } from './pages/AccessTokenPage';
|
||||||
|
|
||||||
// Protected Route Component
|
// Protected Route Component
|
||||||
const ProtectedRoute: React.FC<{ children: React.ReactNode; allowedRoles?: UserRole[] }> = ({ children, allowedRoles }) => {
|
const ProtectedRoute: React.FC<{ children: React.ReactNode; allowedRoles?: UserRole[] }> = ({ children, allowedRoles }) => {
|
||||||
@@ -90,6 +91,8 @@ function AppContent() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route path="/invite" element={<AccessTokenPage mode="invite" />} />
|
||||||
|
<Route path="/reset-password" element={<AccessTokenPage mode="reset" />} />
|
||||||
|
|
||||||
{/* Catch all redirect */}
|
{/* Catch all redirect */}
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
24
pages/AccessTokenPage.tsx
Normal file
24
pages/AccessTokenPage.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { authApi, saveSession } from '../services/api';
|
||||||
|
|
||||||
|
export const AccessTokenPage: React.FC<{ mode: 'invite' | 'reset' }> = ({ mode }) => {
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const token = new URLSearchParams(location.search).get('token') || '';
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const submit = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault(); setSaving(true); setError('');
|
||||||
|
try {
|
||||||
|
if (mode === 'invite') {
|
||||||
|
const session = await authApi.acceptInvitation(token, name, password);
|
||||||
|
saveSession(session); navigate(session.user.role === 'admin' ? '/admin' : session.user.role === 'instructor' ? '/gerenciar' : '/');
|
||||||
|
} else { await authApi.resetPassword(token, password); navigate('/'); }
|
||||||
|
} catch { setError('Este link é inválido, expirou ou não pôde ser usado.'); }
|
||||||
|
finally { setSaving(false); }
|
||||||
|
};
|
||||||
|
return <main className="min-h-screen pt-32 px-6 flex justify-center"><form onSubmit={submit} className="w-full max-w-md rounded-2xl border border-white/10 bg-zinc-950 p-7 space-y-4"><h1 className="text-2xl font-bold">{mode === 'invite' ? 'Criar seu acesso' : 'Redefinir senha'}</h1>{mode === 'invite' && <input required value={name} onChange={(event) => setName(event.target.value)} placeholder="Seu nome" className="w-full rounded-xl bg-zinc-900 p-3" />}<input required minLength={12} type="password" value={password} onChange={(event) => setPassword(event.target.value)} placeholder="Nova senha (mínimo 12 caracteres)" className="w-full rounded-xl bg-zinc-900 p-3" />{error && <p className="text-sm text-red-400">{error}</p>}<button disabled={!token || saving} className="w-full rounded-xl bg-orange-500 p-3 font-semibold">{saving ? 'Salvando...' : mode === 'invite' ? 'Criar conta' : 'Redefinir senha'}</button></form></main>;
|
||||||
|
};
|
||||||
@@ -27,9 +27,10 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Course, Comment, Attachment, Lesson, AttachmentType } from '../types';
|
import { Course, Comment, Attachment, Lesson, AttachmentType } from '../types';
|
||||||
import { useToast } from '../context/ToastContext';
|
import { useToast } from '../context/ToastContext';
|
||||||
import { getManagedCourses, saveCourse, deleteCourse, getComments, saveComment } from '../services/db';
|
import { getManagedCourses, saveCourse, deleteCourse, getComments, replyToComment, moderateComment } from '../services/db';
|
||||||
import { CATEGORIES } from '../constants';
|
import { CATEGORIES } from '../constants';
|
||||||
import { getAttachmentIcon, getAttachmentBadge } from '../components/MaterialCard';
|
import { getAttachmentIcon, getAttachmentBadge } from '../components/MaterialCard';
|
||||||
|
import { instructorApi, InstructorAnalytics } from '../services/api';
|
||||||
|
|
||||||
// 1. Create/Edit Course Modal
|
// 1. Create/Edit Course Modal
|
||||||
const CourseModal: React.FC<{
|
const CourseModal: React.FC<{
|
||||||
@@ -525,6 +526,7 @@ export const ManageCourses: React.FC = () => {
|
|||||||
const [allComments, setAllComments] = useState<{comment: Comment, courseTitle: string}[]>([]);
|
const [allComments, setAllComments] = useState<{comment: Comment, courseTitle: string}[]>([]);
|
||||||
const [replyingTo, setReplyingTo] = useState<string | null>(null);
|
const [replyingTo, setReplyingTo] = useState<string | null>(null);
|
||||||
const [replyText, setReplyText] = useState('');
|
const [replyText, setReplyText] = useState('');
|
||||||
|
const [analytics, setAnalytics] = useState<InstructorAnalytics | null>(null);
|
||||||
|
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
|
|
||||||
@@ -532,6 +534,8 @@ export const ManageCourses: React.FC = () => {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await getManagedCourses();
|
const data = await getManagedCourses();
|
||||||
setCourses(data);
|
setCourses(data);
|
||||||
|
const analyticsResponse = await instructorApi.analytics();
|
||||||
|
setAnalytics(analyticsResponse.data);
|
||||||
|
|
||||||
// Load all comments
|
// Load all comments
|
||||||
const commentsAccumulator: {comment: Comment, courseTitle: string}[] = [];
|
const commentsAccumulator: {comment: Comment, courseTitle: string}[] = [];
|
||||||
@@ -578,12 +582,7 @@ export const ManageCourses: React.FC = () => {
|
|||||||
|
|
||||||
const target = allComments.find(c => c.comment.id === commentId);
|
const target = allComments.find(c => c.comment.id === commentId);
|
||||||
if (target) {
|
if (target) {
|
||||||
const updatedComment: Comment = {
|
await replyToComment(commentId, replyText);
|
||||||
...target.comment,
|
|
||||||
reply: replyText,
|
|
||||||
replyDate: new Date().toLocaleDateString('pt-BR')
|
|
||||||
};
|
|
||||||
await saveComment(updatedComment);
|
|
||||||
showToast('Resposta enviada com sucesso!', 'success');
|
showToast('Resposta enviada com sucesso!', 'success');
|
||||||
setReplyingTo(null);
|
setReplyingTo(null);
|
||||||
setReplyText('');
|
setReplyText('');
|
||||||
@@ -591,8 +590,17 @@ export const ManageCourses: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleModerateComment = async (commentId: string) => {
|
||||||
|
if (!window.confirm('Remover este comentário permanentemente?')) return;
|
||||||
|
await moderateComment(commentId);
|
||||||
|
showToast('Comentário removido.', 'success');
|
||||||
|
loadData();
|
||||||
|
};
|
||||||
|
|
||||||
// Metrics
|
// Metrics
|
||||||
const totalViews = courses.reduce((acc, c) => acc + (c.views || 0), 0);
|
const totalLearners = analytics?.learners ?? 0;
|
||||||
|
const completedLessons = analytics?.completedLessons ?? 0;
|
||||||
|
const totalQuestions = analytics?.comments ?? allComments.length;
|
||||||
const totalLessons = courses.reduce((acc, c) => acc + c.lessons.length, 0);
|
const totalLessons = courses.reduce((acc, c) => acc + c.lessons.length, 0);
|
||||||
const totalMaterials = courses.reduce((acc, c) => {
|
const totalMaterials = courses.reduce((acc, c) => {
|
||||||
const globalCount = c.attachments?.length || 0;
|
const globalCount = c.attachments?.length || 0;
|
||||||
@@ -631,7 +639,7 @@ export const ManageCourses: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Metrics Row */}
|
{/* Metrics Row */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 my-8">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-6 gap-4 my-8">
|
||||||
<div className="bg-zinc-900/60 border border-white/10 p-5 rounded-[8px] shadow-xl">
|
<div className="bg-zinc-900/60 border border-white/10 p-5 rounded-[8px] shadow-xl">
|
||||||
<span className="text-caption text-gray-400 font-semibold uppercase tracking-caption">Total de Cursos</span>
|
<span className="text-caption text-gray-400 font-semibold uppercase tracking-caption">Total de Cursos</span>
|
||||||
<p className="font-display text-subheading md:text-heading-sm font-bold text-white mt-1">{courses.length}</p>
|
<p className="font-display text-subheading md:text-heading-sm font-bold text-white mt-1">{courses.length}</p>
|
||||||
@@ -645,8 +653,16 @@ export const ManageCourses: React.FC = () => {
|
|||||||
<p className="font-display text-subheading md:text-heading-sm font-bold text-emerald-400 mt-1">{totalMaterials}</p>
|
<p className="font-display text-subheading md:text-heading-sm font-bold text-emerald-400 mt-1">{totalMaterials}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-zinc-900/60 border border-white/10 p-5 rounded-[8px] shadow-xl">
|
<div className="bg-zinc-900/60 border border-white/10 p-5 rounded-[8px] shadow-xl">
|
||||||
<span className="text-caption text-gray-400 font-semibold uppercase tracking-caption">Visualizações Acumuladas</span>
|
<span className="text-caption text-gray-400 font-semibold uppercase tracking-caption">Alunos com Progresso</span>
|
||||||
<p className="font-display text-subheading md:text-heading-sm font-bold text-white mt-1">{totalViews.toLocaleString('pt-BR')}</p>
|
<p className="font-display text-subheading md:text-heading-sm font-bold text-white mt-1">{totalLearners.toLocaleString('pt-BR')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-zinc-900/60 border border-white/10 p-5 rounded-[8px] shadow-xl">
|
||||||
|
<span className="text-caption text-gray-400 font-semibold uppercase tracking-caption">Aulas Concluídas</span>
|
||||||
|
<p className="font-display text-subheading md:text-heading-sm font-bold text-orange-400 mt-1">{completedLessons.toLocaleString('pt-BR')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-zinc-900/60 border border-white/10 p-5 rounded-[8px] shadow-xl">
|
||||||
|
<span className="text-caption text-gray-400 font-semibold uppercase tracking-caption">Dúvidas Recebidas</span>
|
||||||
|
<p className="font-display text-subheading md:text-heading-sm font-bold text-white mt-1">{totalQuestions.toLocaleString('pt-BR')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -810,6 +826,7 @@ export const ManageCourses: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<button onClick={() => handleModerateComment(comment.id)} className="text-xs text-red-400 hover:text-red-300 font-semibold">Remover comentário</button>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { Loader2, RefreshCw, ShieldCheck, UserCheck, UserX, Users } from 'lucide-react';
|
import { Loader2, RefreshCw, ShieldCheck, UserCheck, UserX, Users } from 'lucide-react';
|
||||||
import { adminApi, ManagedUser } from '../services/api';
|
import { adminApi, ManagedUser, ManagedUserDetail, PlatformAnalytics } from '../services/api';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { useToast } from '../context/ToastContext';
|
import { useToast } from '../context/ToastContext';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
@@ -19,12 +19,20 @@ export const SuperAdmin: React.FC = () => {
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [updatingUserId, setUpdatingUserId] = useState<string | null>(null);
|
const [updatingUserId, setUpdatingUserId] = useState<string | null>(null);
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
|
const [analytics, setAnalytics] = useState<PlatformAnalytics | null>(null);
|
||||||
|
const [inviteEmail, setInviteEmail] = useState('');
|
||||||
|
const [inviteRole, setInviteRole] = useState<'student' | 'instructor'>('student');
|
||||||
|
const [accessLink, setAccessLink] = useState('');
|
||||||
|
const [selectedUser, setSelectedUser] = useState<ManagedUserDetail | null>(null);
|
||||||
|
const [loadingDetailId, setLoadingDetailId] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadUsers = async () => {
|
const loadUsers = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await adminApi.listUsers();
|
const response = await adminApi.listUsers();
|
||||||
setUsers(response.data);
|
setUsers(response.data);
|
||||||
|
const dashboard = await adminApi.dashboard();
|
||||||
|
setAnalytics(dashboard.data);
|
||||||
} catch {
|
} catch {
|
||||||
showToast('Não foi possível carregar os usuários.', 'error');
|
showToast('Não foi possível carregar os usuários.', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -59,6 +67,28 @@ export const SuperAdmin: React.FC = () => {
|
|||||||
|
|
||||||
const activeUsers = users.filter((account) => account.isActive).length;
|
const activeUsers = users.filter((account) => account.isActive).length;
|
||||||
const instructorUsers = users.filter((account) => account.role === 'instructor' && account.isActive).length;
|
const instructorUsers = users.filter((account) => account.role === 'instructor' && account.isActive).length;
|
||||||
|
const createInvite = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
try {
|
||||||
|
const response = await adminApi.invite(inviteEmail, inviteRole);
|
||||||
|
setAccessLink(response.data.inviteUrl); setInviteEmail(''); showToast('Link de convite criado.', 'success');
|
||||||
|
} catch { showToast('Não foi possível criar o convite.', 'error'); }
|
||||||
|
};
|
||||||
|
const resetPassword = async (account: ManagedUser) => {
|
||||||
|
try { const response = await adminApi.passwordReset(account.id); setAccessLink(response.data.resetUrl); showToast('Link de redefinição criado.', 'success'); }
|
||||||
|
catch { showToast('Não foi possível criar o link.', 'error'); }
|
||||||
|
};
|
||||||
|
const showUserDetail = async (account: ManagedUser) => {
|
||||||
|
setLoadingDetailId(account.id);
|
||||||
|
try {
|
||||||
|
const response = await adminApi.userDetail(account.id);
|
||||||
|
setSelectedUser(response.data);
|
||||||
|
} catch {
|
||||||
|
showToast('Não foi possível carregar os detalhes do usuário.', 'error');
|
||||||
|
} finally {
|
||||||
|
setLoadingDetailId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen pt-28 pb-20 px-6 md:px-12 max-w-[1440px] mx-auto">
|
<main className="min-h-screen pt-28 pb-20 px-6 md:px-12 max-w-[1440px] mx-auto">
|
||||||
@@ -86,12 +116,42 @@ export const SuperAdmin: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-8">
|
<div className="grid grid-cols-1 sm:grid-cols-3 lg:grid-cols-5 gap-4 mb-8">
|
||||||
<StatCard icon={<Users className="w-5 h-5" />} label="Usuários cadastrados" value={users.length} />
|
<StatCard icon={<Users className="w-5 h-5" />} label="Usuários cadastrados" value={analytics?.totalUsers ?? users.length} />
|
||||||
<StatCard icon={<UserCheck className="w-5 h-5" />} label="Contas ativas" value={activeUsers} />
|
<StatCard icon={<UserCheck className="w-5 h-5" />} label="Contas ativas" value={analytics?.activeUsers ?? activeUsers} />
|
||||||
<StatCard icon={<ShieldCheck className="w-5 h-5" />} label="Instrutores ativos" value={instructorUsers} />
|
<StatCard icon={<ShieldCheck className="w-5 h-5" />} label="Instrutores ativos" value={instructorUsers} />
|
||||||
|
<StatCard icon={<Users className="w-5 h-5" />} label="Cursos publicados" value={analytics?.publishedCourses ?? 0} />
|
||||||
|
<StatCard icon={<UserCheck className="w-5 h-5" />} label="Aulas concluídas" value={analytics?.completedLessons ?? 0} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section className="rounded-2xl border border-white/10 bg-zinc-950/80 p-5 mb-8">
|
||||||
|
<h2 className="text-lg font-bold text-white mb-4">Convidar usuário</h2>
|
||||||
|
<form onSubmit={createInvite} className="flex flex-col md:flex-row gap-3">
|
||||||
|
<input required type="email" value={inviteEmail} onChange={(event) => setInviteEmail(event.target.value)} placeholder="E-mail do convidado" className="flex-1 rounded-xl bg-zinc-900 border border-white/10 px-4 py-2.5 text-sm text-white" />
|
||||||
|
<select value={inviteRole} onChange={(event) => setInviteRole(event.target.value as 'student' | 'instructor')} className="rounded-xl bg-zinc-900 border border-white/10 px-4 py-2.5 text-sm text-white"><option value="student">Aluno</option><option value="instructor">Instrutor</option></select>
|
||||||
|
<button className="rounded-xl bg-orange-500 hover:bg-orange-600 px-5 py-2.5 text-sm font-semibold text-white">Gerar convite</button>
|
||||||
|
</form>
|
||||||
|
{accessLink && <div className="mt-4 flex gap-2"><input readOnly value={accessLink} className="flex-1 rounded-xl bg-black border border-white/10 px-3 py-2 text-xs text-gray-300" /><button onClick={() => navigator.clipboard.writeText(accessLink)} className="rounded-xl bg-white/10 px-4 text-xs font-semibold">Copiar</button></div>}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{selectedUser && (
|
||||||
|
<section className="rounded-2xl border border-orange-500/30 bg-zinc-950/80 p-5 mb-8">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wider text-orange-400 font-semibold">Detalhes do usuário</p>
|
||||||
|
<h2 className="text-lg font-bold text-white mt-1">{selectedUser.name}</h2>
|
||||||
|
<p className="text-sm text-gray-400">{selectedUser.email} · {roleLabel[selectedUser.role]}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setSelectedUser(null)} className="text-xs text-gray-400 hover:text-white">Fechar</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mt-5 text-sm">
|
||||||
|
<DetailMetric label="Aulas concluídas" value={selectedUser.completedLessons.toString()} />
|
||||||
|
<DetailMetric label="Última atividade" value={selectedUser.lastLearningAt ? new Intl.DateTimeFormat('pt-BR', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(selectedUser.lastLearningAt)) : 'Sem atividade'} />
|
||||||
|
<DetailMetric label="Status" value={selectedUser.isActive ? 'Ativo' : 'Desativado'} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<section className="rounded-2xl border border-white/10 bg-zinc-950/80 overflow-hidden">
|
<section className="rounded-2xl border border-white/10 bg-zinc-950/80 overflow-hidden">
|
||||||
<div className="p-5 border-b border-white/10 flex flex-col sm:flex-row gap-4 sm:items-center sm:justify-between">
|
<div className="p-5 border-b border-white/10 flex flex-col sm:flex-row gap-4 sm:items-center sm:justify-between">
|
||||||
<h2 className="text-lg font-bold text-white">Usuários</h2>
|
<h2 className="text-lg font-bold text-white">Usuários</h2>
|
||||||
@@ -116,6 +176,7 @@ export const SuperAdmin: React.FC = () => {
|
|||||||
<th className="px-5 py-4">Perfil</th>
|
<th className="px-5 py-4">Perfil</th>
|
||||||
<th className="px-5 py-4">Status</th>
|
<th className="px-5 py-4">Status</th>
|
||||||
<th className="px-5 py-4">Cadastro</th>
|
<th className="px-5 py-4">Cadastro</th>
|
||||||
|
<th className="px-5 py-4">Ações</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-white/5">
|
<tbody className="divide-y divide-white/5">
|
||||||
@@ -151,6 +212,12 @@ export const SuperAdmin: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-4 text-xs text-gray-500">{new Intl.DateTimeFormat('pt-BR').format(new Date(account.createdAt))}</td>
|
<td className="px-5 py-4 text-xs text-gray-500">{new Intl.DateTimeFormat('pt-BR').format(new Date(account.createdAt))}</td>
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button disabled={loadingDetailId === account.id} onClick={() => void showUserDetail(account)} className="text-xs font-semibold text-gray-300 hover:text-white disabled:opacity-50">{loadingDetailId === account.id ? 'Carregando...' : 'Detalhes'}</button>
|
||||||
|
<button disabled={isUpdating} onClick={() => void resetPassword(account)} className="text-xs font-semibold text-orange-400 hover:text-orange-300">Redefinir senha</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -169,3 +236,10 @@ const StatCard: React.FC<{ icon: React.ReactNode; label: string; value: number }
|
|||||||
<div className="text-3xl font-bold text-white">{value}</div>
|
<div className="text-3xl font-bold text-white">{value}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const DetailMetric: React.FC<{ label: string; value: string }> = ({ label, value }) => (
|
||||||
|
<div className="rounded-xl bg-white/[0.03] px-4 py-3">
|
||||||
|
<div className="text-xs text-gray-500">{label}</div>
|
||||||
|
<div className="text-sm font-semibold text-white mt-1">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|||||||
15
server/migrations/004_account_access_tokens.sql
Normal file
15
server/migrations/004_account_access_tokens.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
create type account_token_purpose as enum ('invitation', 'password_reset');
|
||||||
|
|
||||||
|
create table account_access_tokens (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
email text not null,
|
||||||
|
role user_role not null default 'student',
|
||||||
|
purpose account_token_purpose not null,
|
||||||
|
token_hash text not null unique,
|
||||||
|
expires_at timestamptz not null,
|
||||||
|
used_at timestamptz,
|
||||||
|
created_by uuid not null references users(id),
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create index account_access_tokens_lookup_index on account_access_tokens (token_hash) where used_at is null;
|
||||||
@@ -15,7 +15,7 @@ export function buildApp() {
|
|||||||
|
|
||||||
app.register(cors, {
|
app.register(cors, {
|
||||||
origin: config.FRONTEND_ORIGIN,
|
origin: config.FRONTEND_ORIGIN,
|
||||||
methods: ['GET', 'POST', 'PATCH', 'DELETE'],
|
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
||||||
});
|
});
|
||||||
|
|
||||||
app.setErrorHandler((error, _request, reply) => {
|
app.setErrorHandler((error, _request, reply) => {
|
||||||
|
|||||||
4
server/src/auth/account-tokens.ts
Normal file
4
server/src/auth/account-tokens.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
|
|
||||||
|
export const createRawToken = () => randomBytes(32).toString('base64url');
|
||||||
|
export const hashToken = (token: string) => createHash('sha256').update(token).digest('hex');
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { FastifyPluginAsync } from 'fastify';
|
import type { FastifyPluginAsync } from 'fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { pool } from '../db/pool.js';
|
import { pool } from '../db/pool.js';
|
||||||
|
import { createRawToken, hashToken } from '../auth/account-tokens.js';
|
||||||
|
import { config } from '../config.js';
|
||||||
|
|
||||||
const userParamsSchema = z.object({
|
const userParamsSchema = z.object({
|
||||||
userId: z.string().uuid(),
|
userId: z.string().uuid(),
|
||||||
@@ -12,6 +14,7 @@ const updateUserSchema = z.object({
|
|||||||
}).refine((input) => input.role !== undefined || input.isActive !== undefined, {
|
}).refine((input) => input.role !== undefined || input.isActive !== undefined, {
|
||||||
message: 'Provide at least one field to update',
|
message: 'Provide at least one field to update',
|
||||||
});
|
});
|
||||||
|
const invitationSchema = z.object({ email: z.string().email().transform((email) => email.toLowerCase()), role: z.enum(['student', 'instructor']).default('student') });
|
||||||
|
|
||||||
export const adminRoutes: FastifyPluginAsync = async (app) => {
|
export const adminRoutes: FastifyPluginAsync = async (app) => {
|
||||||
const adminAccess = { preHandler: app.requireRoles(['admin']) };
|
const adminAccess = { preHandler: app.requireRoles(['admin']) };
|
||||||
@@ -26,6 +29,56 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
return { data: result.rows };
|
return { data: result.rows };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/dashboard', adminAccess, async () => {
|
||||||
|
const result = await pool.query(
|
||||||
|
`select
|
||||||
|
(select count(*)::int from users) as "totalUsers",
|
||||||
|
(select count(*)::int from users where is_active) as "activeUsers",
|
||||||
|
(select count(*)::int from courses where status = 'published') as "publishedCourses",
|
||||||
|
(select count(*)::int from lesson_progress where completed_at is not null) as "completedLessons",
|
||||||
|
(select count(*)::int from comments) as "comments"`,
|
||||||
|
);
|
||||||
|
return { data: result.rows[0] };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/users/:userId', adminAccess, async (request, reply) => {
|
||||||
|
const { userId } = userParamsSchema.parse(request.params);
|
||||||
|
const result = await pool.query(
|
||||||
|
`select u.id, u.email, u.display_name as name, u.role, u.is_active as "isActive", u.created_at as "createdAt",
|
||||||
|
(select count(*)::int from lesson_progress lp where lp.user_id = u.id and lp.completed_at is not null) as "completedLessons",
|
||||||
|
(select max(lp.updated_at) from lesson_progress lp where lp.user_id = u.id) as "lastLearningAt"
|
||||||
|
from users u where u.id = $1`, [userId],
|
||||||
|
);
|
||||||
|
if (!result.rows[0]) return reply.code(404).send({ error: 'User not found' });
|
||||||
|
return { data: result.rows[0] };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/invitations', adminAccess, async (request, reply) => {
|
||||||
|
const input = invitationSchema.parse(request.body);
|
||||||
|
const existing = await pool.query('select 1 from users where email = $1', [input.email]);
|
||||||
|
if (existing.rowCount) return reply.code(409).send({ error: 'This email already has an account' });
|
||||||
|
const rawToken = createRawToken();
|
||||||
|
await pool.query(
|
||||||
|
`insert into account_access_tokens (email, role, purpose, token_hash, expires_at, created_by)
|
||||||
|
values ($1, $2::user_role, 'invitation', $3, now() + interval '7 days', $4)`,
|
||||||
|
[input.email, input.role, hashToken(rawToken), request.user.id],
|
||||||
|
);
|
||||||
|
return reply.code(201).send({ data: { inviteUrl: `${config.FRONTEND_ORIGIN}/#/invite?token=${rawToken}` } });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/users/:userId/password-reset', adminAccess, async (request, reply) => {
|
||||||
|
const { userId } = userParamsSchema.parse(request.params);
|
||||||
|
const account = await pool.query<{ email: string; role: 'student' | 'instructor' | 'admin' }>('select email, role from users where id = $1', [userId]);
|
||||||
|
if (!account.rows[0]) return reply.code(404).send({ error: 'User not found' });
|
||||||
|
const rawToken = createRawToken();
|
||||||
|
await pool.query(
|
||||||
|
`insert into account_access_tokens (email, role, purpose, token_hash, expires_at, created_by)
|
||||||
|
values ($1, $2::user_role, 'password_reset', $3, now() + interval '1 day', $4)`,
|
||||||
|
[account.rows[0].email, account.rows[0].role, hashToken(rawToken), request.user.id],
|
||||||
|
);
|
||||||
|
return { data: { resetUrl: `${config.FRONTEND_ORIGIN}/#/reset-password?token=${rawToken}` } };
|
||||||
|
});
|
||||||
|
|
||||||
app.patch('/users/:userId', adminAccess, async (request, reply) => {
|
app.patch('/users/:userId', adminAccess, async (request, reply) => {
|
||||||
const { userId } = userParamsSchema.parse(request.params);
|
const { userId } = userParamsSchema.parse(request.params);
|
||||||
const input = updateUserSchema.parse(request.body);
|
const input = updateUserSchema.parse(request.body);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { FastifyPluginAsync } from 'fastify';
|
import type { FastifyPluginAsync } from 'fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { hashPassword, verifyPassword } from '../auth/passwords.js';
|
import { hashPassword, verifyPassword } from '../auth/passwords.js';
|
||||||
|
import { hashToken } from '../auth/account-tokens.js';
|
||||||
import type { AuthUser } from '../auth/plugin.js';
|
import type { AuthUser } from '../auth/plugin.js';
|
||||||
import { pool } from '../db/pool.js';
|
import { pool } from '../db/pool.js';
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ const credentialsSchema = z.object({
|
|||||||
const registerSchema = credentialsSchema.extend({
|
const registerSchema = credentialsSchema.extend({
|
||||||
name: z.string().trim().min(2).max(120),
|
name: z.string().trim().min(2).max(120),
|
||||||
});
|
});
|
||||||
|
const tokenPasswordSchema = z.object({ token: z.string().min(20), password: z.string().min(12).max(200), name: z.string().trim().min(2).max(120).optional() });
|
||||||
|
|
||||||
type UserRow = {
|
type UserRow = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -30,6 +32,60 @@ const serializeUser = (user: UserRow): AuthUser => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const authRoutes: FastifyPluginAsync = async (app) => {
|
export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||||
|
app.post('/accept-invitation', async (request, reply) => {
|
||||||
|
const input = tokenPasswordSchema.extend({ name: z.string().trim().min(2).max(120) }).parse(request.body);
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('begin');
|
||||||
|
const token = await client.query<{ email: string; role: AuthUser['role'] }>(
|
||||||
|
`update account_access_tokens set used_at = now()
|
||||||
|
where token_hash = $1 and purpose = 'invitation'::account_token_purpose and used_at is null and expires_at > now()
|
||||||
|
returning email, role`, [hashToken(input.token)],
|
||||||
|
);
|
||||||
|
if (!token.rows[0]) {
|
||||||
|
await client.query('rollback');
|
||||||
|
return reply.code(400).send({ error: 'This invitation is invalid or expired' });
|
||||||
|
}
|
||||||
|
const result = await client.query<UserRow>(
|
||||||
|
`insert into users (email, password_hash, display_name, role) values ($1, $2, $3, $4)
|
||||||
|
returning id, email, display_name, role, password_hash, is_active`,
|
||||||
|
[token.rows[0].email, await hashPassword(input.password), input.name, token.rows[0].role],
|
||||||
|
);
|
||||||
|
await client.query('commit');
|
||||||
|
const user = serializeUser(result.rows[0]);
|
||||||
|
return reply.code(201).send({ token: await reply.jwtSign(user, { expiresIn: '7d' }), user });
|
||||||
|
} catch {
|
||||||
|
await client.query('rollback');
|
||||||
|
return reply.code(409).send({ error: 'This invitation email already has an account' });
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/reset-password', async (request, reply) => {
|
||||||
|
const input = tokenPasswordSchema.parse(request.body);
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('begin');
|
||||||
|
const token = await client.query<{ email: string }>(
|
||||||
|
`update account_access_tokens set used_at = now()
|
||||||
|
where token_hash = $1 and purpose = 'password_reset'::account_token_purpose and used_at is null and expires_at > now()
|
||||||
|
returning email`, [hashToken(input.token)],
|
||||||
|
);
|
||||||
|
if (!token.rows[0]) {
|
||||||
|
await client.query('rollback');
|
||||||
|
return reply.code(400).send({ error: 'This reset link is invalid or expired' });
|
||||||
|
}
|
||||||
|
await client.query('update users set password_hash = $2 where email = $1', [token.rows[0].email, await hashPassword(input.password)]);
|
||||||
|
await client.query('commit');
|
||||||
|
return reply.code(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('rollback');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
app.post('/register', async (request, reply) => {
|
app.post('/register', async (request, reply) => {
|
||||||
const input = registerSchema.parse(request.body);
|
const input = registerSchema.parse(request.body);
|
||||||
const passwordHash = await hashPassword(input.password);
|
const passwordHash = await hashPassword(input.password);
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ const commentSchema = z.object({
|
|||||||
lessonId: z.string().uuid().nullable().optional(),
|
lessonId: z.string().uuid().nullable().optional(),
|
||||||
text: z.string().trim().min(1).max(4000),
|
text: z.string().trim().min(1).max(4000),
|
||||||
});
|
});
|
||||||
|
const commentParamsSchema = z.object({ commentId: z.string().uuid() });
|
||||||
|
const replySchema = z.object({ text: z.string().trim().min(1).max(4000) });
|
||||||
|
|
||||||
const ensurePublishedCourse = async (courseId: string) => {
|
const ensurePublishedCourse = async (courseId: string) => {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
@@ -29,6 +31,16 @@ const ensurePublishedCourse = async (courseId: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const learningRoutes: FastifyPluginAsync = async (app) => {
|
export const learningRoutes: FastifyPluginAsync = async (app) => {
|
||||||
|
const canModerateComment = async (commentId: string, user: { id: string; role: string }) => {
|
||||||
|
const result = await pool.query<{ instructor_id: string }>(
|
||||||
|
`select c.instructor_id
|
||||||
|
from comments cm join courses c on c.id = cm.course_id
|
||||||
|
where cm.id = $1`,
|
||||||
|
[commentId],
|
||||||
|
);
|
||||||
|
const comment = result.rows[0];
|
||||||
|
return Boolean(comment && (user.role === 'admin' || comment.instructor_id === user.id));
|
||||||
|
};
|
||||||
app.get('/courses/:courseId/progress', { preHandler: app.authenticate }, async (request, reply) => {
|
app.get('/courses/:courseId/progress', { preHandler: app.authenticate }, async (request, reply) => {
|
||||||
const { courseId } = courseParamsSchema.parse(request.params);
|
const { courseId } = courseParamsSchema.parse(request.params);
|
||||||
if (!(await ensurePublishedCourse(courseId))) {
|
if (!(await ensurePublishedCourse(courseId))) {
|
||||||
@@ -123,4 +135,24 @@ export const learningRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
);
|
);
|
||||||
return reply.code(201).send({ data: result.rows[0] });
|
return reply.code(201).send({ data: result.rows[0] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.put('/comments/:commentId/reply', { preHandler: app.requireRoles(['instructor', 'admin']) }, async (request, reply) => {
|
||||||
|
const { commentId } = commentParamsSchema.parse(request.params);
|
||||||
|
const input = replySchema.parse(request.body);
|
||||||
|
if (!(await canModerateComment(commentId, request.user))) return reply.code(403).send({ error: 'You cannot reply to this comment' });
|
||||||
|
await pool.query(
|
||||||
|
`insert into comment_replies (comment_id, author_id, body)
|
||||||
|
values ($1, $2, $3)
|
||||||
|
on conflict (comment_id) do update set author_id = excluded.author_id, body = excluded.body`,
|
||||||
|
[commentId, request.user.id, input.text],
|
||||||
|
);
|
||||||
|
return { data: { id: commentId } };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/comments/:commentId', { preHandler: app.requireRoles(['instructor', 'admin']) }, async (request, reply) => {
|
||||||
|
const { commentId } = commentParamsSchema.parse(request.params);
|
||||||
|
if (!(await canModerateComment(commentId, request.user))) return reply.code(403).send({ error: 'You cannot moderate this comment' });
|
||||||
|
await pool.query('delete from comments where id = $1', [commentId]);
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const assetSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const lessonSchema = z.object({
|
const lessonSchema = z.object({
|
||||||
|
id: z.string().uuid().optional(),
|
||||||
title: z.string().trim().min(1).max(255),
|
title: z.string().trim().min(1).max(255),
|
||||||
description: z.string().max(5000).default(''),
|
description: z.string().max(5000).default(''),
|
||||||
durationSeconds: z.number().int().nonnegative().nullable().optional(),
|
durationSeconds: z.number().int().nonnegative().nullable().optional(),
|
||||||
@@ -79,20 +80,52 @@ async function insertAssets(client: PoolClient, parent: { courseId?: string; les
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function replaceCourseContents(client: PoolClient, courseId: string, input: CourseInput) {
|
async function replaceCourseContents(client: PoolClient, courseId: string, input: CourseInput) {
|
||||||
// This replacement strategy is safe before student progress exists. The next
|
const existingLessons = await client.query<{ id: string }>('select id from lessons where course_id = $1', [courseId]);
|
||||||
// iteration will switch to per-lesson updates to preserve historical progress.
|
const existingIds = new Set(existingLessons.rows.map((lesson) => lesson.id));
|
||||||
await client.query('delete from lessons where course_id = $1', [courseId]);
|
const submittedIds = new Set(input.lessons.flatMap((lesson) => lesson.id ? [lesson.id] : []));
|
||||||
|
const unknownLessonId = [...submittedIds].find((lessonId) => !existingIds.has(lessonId));
|
||||||
|
if (unknownLessonId) throw new Error('A lesson being edited does not belong to this course.');
|
||||||
|
|
||||||
|
const removedLessonIds = [...existingIds].filter((lessonId) => !submittedIds.has(lessonId));
|
||||||
|
if (removedLessonIds.length > 0) {
|
||||||
|
const usage = await client.query<{ id: string }>(
|
||||||
|
`select l.id
|
||||||
|
from lessons l
|
||||||
|
where l.id = any($1::uuid[])
|
||||||
|
and (exists (select 1 from lesson_progress lp where lp.lesson_id = l.id)
|
||||||
|
or exists (select 1 from comments c where c.lesson_id = l.id))`,
|
||||||
|
[removedLessonIds],
|
||||||
|
);
|
||||||
|
if (usage.rowCount) {
|
||||||
|
throw new Error('A lesson with student progress or comments cannot be removed. Keep it or archive the course instead.');
|
||||||
|
}
|
||||||
|
await client.query('delete from lessons where id = any($1::uuid[])', [removedLessonIds]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move existing positions away first so drag/reordering cannot violate the
|
||||||
|
// unique (course_id, position) constraint while updates are applied.
|
||||||
|
await client.query('update lessons set position = position + 10000 where course_id = $1', [courseId]);
|
||||||
await client.query('delete from assets where course_id = $1', [courseId]);
|
await client.query('delete from assets where course_id = $1', [courseId]);
|
||||||
|
|
||||||
await insertAssets(client, { courseId }, input.assets);
|
await insertAssets(client, { courseId }, input.assets);
|
||||||
for (const [index, lesson] of input.lessons.entries()) {
|
for (const [index, lesson] of input.lessons.entries()) {
|
||||||
const lessonResult = await client.query<{ id: string }>(
|
const lessonId = lesson.id ?? (await client.query<{ id: string }>(
|
||||||
`insert into lessons (course_id, title, description, position, duration_seconds, access_level)
|
`insert into lessons (course_id, title, description, position, duration_seconds, access_level)
|
||||||
values ($1, $2, $3, $4, $5, $6)
|
values ($1, $2, $3, $4, $5, $6)
|
||||||
returning id`,
|
returning id`,
|
||||||
[courseId, lesson.title, lesson.description, index + 1, lesson.durationSeconds ?? null, lesson.accessLevel],
|
[courseId, lesson.title, lesson.description, index + 1, lesson.durationSeconds ?? null, lesson.accessLevel],
|
||||||
);
|
)).rows[0].id;
|
||||||
const lessonId = lessonResult.rows[0].id;
|
|
||||||
|
if (lesson.id) {
|
||||||
|
await client.query(
|
||||||
|
`update lessons
|
||||||
|
set title = $2, description = $3, position = $4, duration_seconds = $5, access_level = $6
|
||||||
|
where id = $1`,
|
||||||
|
[lessonId, lesson.title, lesson.description, index + 1, lesson.durationSeconds ?? null, lesson.accessLevel],
|
||||||
|
);
|
||||||
|
await client.query('delete from lesson_media where lesson_id = $1', [lessonId]);
|
||||||
|
await client.query('delete from assets where lesson_id = $1', [lessonId]);
|
||||||
|
}
|
||||||
|
|
||||||
for (const media of lesson.media) {
|
for (const media of lesson.media) {
|
||||||
await client.query(
|
await client.query(
|
||||||
@@ -127,6 +160,25 @@ export const manageCourseRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
return { data: result.rows };
|
return { data: result.rows };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/analytics', manageAccess, async (request) => {
|
||||||
|
const isAdmin = request.user.role === 'admin';
|
||||||
|
const result = await pool.query(
|
||||||
|
`select
|
||||||
|
count(distinct c.id)::int as "courses",
|
||||||
|
count(distinct l.id)::int as "lessons",
|
||||||
|
count(distinct lp.user_id)::int as "learners",
|
||||||
|
count(distinct lp.lesson_id) filter (where lp.completed_at is not null)::int as "completedLessons",
|
||||||
|
count(distinct cm.id)::int as "comments"
|
||||||
|
from courses c
|
||||||
|
left join lessons l on l.course_id = c.id
|
||||||
|
left join lesson_progress lp on lp.lesson_id = l.id
|
||||||
|
left join comments cm on cm.course_id = c.id
|
||||||
|
where c.status <> 'archived' and ($1::boolean or c.instructor_id = $2)`,
|
||||||
|
[isAdmin, request.user.id],
|
||||||
|
);
|
||||||
|
return { data: result.rows[0] };
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/', manageAccess, async (request, reply) => {
|
app.post('/', manageAccess, async (request, reply) => {
|
||||||
const input = courseSchema.parse(request.body);
|
const input = courseSchema.parse(request.body);
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ export interface ManagedUser {
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
export interface ManagedUserDetail extends ManagedUser {
|
||||||
|
completedLessons: number;
|
||||||
|
lastLearningAt: string | null;
|
||||||
|
}
|
||||||
|
export interface PlatformAnalytics { totalUsers: number; activeUsers: number; publishedCourses: number; completedLessons: number; comments: number; }
|
||||||
|
export interface InstructorAnalytics { courses: number; lessons: number; learners: number; completedLessons: number; comments: number; }
|
||||||
|
|
||||||
interface Session {
|
interface Session {
|
||||||
token: string;
|
token: string;
|
||||||
@@ -75,9 +81,16 @@ export const authApi = {
|
|||||||
async me() {
|
async me() {
|
||||||
return apiRequest<{ user: ApiUser }>('/auth/me');
|
return apiRequest<{ user: ApiUser }>('/auth/me');
|
||||||
},
|
},
|
||||||
|
async acceptInvitation(token: string, name: string, password: string) {
|
||||||
|
return apiRequest<Session>('/auth/accept-invitation', { method: 'POST', body: JSON.stringify({ token, name, password }) });
|
||||||
|
},
|
||||||
|
async resetPassword(token: string, password: string) {
|
||||||
|
return apiRequest<void>('/auth/reset-password', { method: 'POST', body: JSON.stringify({ token, password }) });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const adminApi = {
|
export const adminApi = {
|
||||||
|
async dashboard() { return apiRequest<{ data: PlatformAnalytics }>('/admin/dashboard'); },
|
||||||
async listUsers() {
|
async listUsers() {
|
||||||
return apiRequest<{ data: ManagedUser[] }>('/admin/users');
|
return apiRequest<{ data: ManagedUser[] }>('/admin/users');
|
||||||
},
|
},
|
||||||
@@ -87,4 +100,15 @@ export const adminApi = {
|
|||||||
body: JSON.stringify(update),
|
body: JSON.stringify(update),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
async invite(email: string, role: 'student' | 'instructor') {
|
||||||
|
return apiRequest<{ data: { inviteUrl: string } }>('/admin/invitations', { method: 'POST', body: JSON.stringify({ email, role }) });
|
||||||
|
},
|
||||||
|
async passwordReset(userId: string) {
|
||||||
|
return apiRequest<{ data: { resetUrl: string } }>(`/admin/users/${userId}/password-reset`, { method: 'POST' });
|
||||||
|
},
|
||||||
|
async userDetail(userId: string) { return apiRequest<{ data: ManagedUserDetail }>(`/admin/users/${userId}`); },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const instructorApi = {
|
||||||
|
async analytics() { return apiRequest<{ data: InstructorAnalytics }>('/manage/courses/analytics'); },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ const toCoursePayload = (course: Course) => ({
|
|||||||
status: 'published' as const,
|
status: 'published' as const,
|
||||||
assets: (course.attachments || []).filter((attachment) => attachment.url && attachment.url !== '#').map(toAssetPayload),
|
assets: (course.attachments || []).filter((attachment) => attachment.url && attachment.url !== '#').map(toAssetPayload),
|
||||||
lessons: course.lessons.map((lesson) => ({
|
lessons: course.lessons.map((lesson) => ({
|
||||||
|
id: /^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(lesson.id) ? lesson.id : undefined,
|
||||||
title: lesson.title,
|
title: lesson.title,
|
||||||
description: lesson.description || '',
|
description: lesson.description || '',
|
||||||
durationSeconds: toSeconds(lesson.duration),
|
durationSeconds: toSeconds(lesson.duration),
|
||||||
@@ -182,6 +183,14 @@ export const saveComment = async (comment: Pick<Comment, 'courseId' | 'lessonId'
|
|||||||
return toComment(response.data);
|
return toComment(response.data);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const replyToComment = async (commentId: string, text: string): Promise<void> => {
|
||||||
|
await apiRequest(`/comments/${commentId}/reply`, { method: 'PUT', body: JSON.stringify({ text }) });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const moderateComment = async (commentId: string): Promise<void> => {
|
||||||
|
await apiRequest(`/comments/${commentId}`, { method: 'DELETE' });
|
||||||
|
};
|
||||||
|
|
||||||
export const getCompletedLessonIds = async (courseId: string): Promise<string[]> => {
|
export const getCompletedLessonIds = async (courseId: string): Promise<string[]> => {
|
||||||
const response = await apiRequest<{ data: Array<{ lessonId: string; completedAt: string | null }> }>(`/courses/${courseId}/progress`);
|
const response = await apiRequest<{ data: Array<{ lessonId: string; completedAt: string | null }> }>(`/courses/${courseId}/progress`);
|
||||||
return response.data.filter((progress) => progress.completedAt).map((progress) => progress.lessonId);
|
return response.data.filter((progress) => progress.completedAt).map((progress) => progress.lessonId);
|
||||||
|
|||||||
Reference in New Issue
Block a user