feat: improve account management and welcome emails

This commit is contained in:
Cauê Faleiros
2026-09-08 11:47:33 -03:00
parent 7b2d25aabb
commit 03a6491989
8 changed files with 148 additions and 11 deletions

View File

@@ -20,6 +20,11 @@ JWT_SESSION_TTL=7d
INVITATION_TTL_HOURS=168 INVITATION_TTL_HOURS=168
PASSWORD_RESET_TTL_HOURS=24 PASSWORD_RESET_TTL_HOURS=24
AUDIT_LOG_PAGE_SIZE=50 AUDIT_LOG_PAGE_SIZE=50
# Optional welcome e-mails sent when a student creates their own account.
# Create an API key and verify this sender in Resend before setting both values.
RESEND_API_KEY=
EMAIL_FROM=Compor HUB <noreply@your-domain.com>
SUPERADMIN_NAME=Compor HUB Superadmin SUPERADMIN_NAME=Compor HUB Superadmin
# Bunny Stream. Set all three in Portainer to enable instructor uploads and # Bunny Stream. Set all three in Portainer to enable instructor uploads and

View File

@@ -0,0 +1,51 @@
import React, { useEffect, useState } from 'react';
import { Copy, Download, KeyRound, Loader2, Pencil, Plus, Trash2, UserCheck, UserX, X } from 'lucide-react';
import { ManagedUser } from '../services/api';
const roleLabel: Record<ManagedUser['role'], string> = { admin: 'Superadmin', instructor: 'Instrutor', student: 'Aluno' };
const inputClass = 'w-full rounded-xl border border-white/10 bg-zinc-900 px-3 py-2.5 text-sm text-white outline-none focus:border-orange-500/70 focus:ring-2 focus:ring-orange-500/15';
type Props = {
users: ManagedUser[];
isLoading: boolean;
query: string;
setQuery: (value: string) => void;
accessLink: string;
inviteEmail: string;
setInviteEmail: (value: string) => void;
updatingUserId: string | null;
currentUserId?: string;
onInvite: (event: React.FormEvent) => void;
onUpdateUser: (account: ManagedUser, update: Partial<Pick<ManagedUser, 'name' | 'email' | 'role' | 'isActive'>>) => void;
onDeleteUser: (account: ManagedUser) => void;
onResetPassword: (account: ManagedUser) => void;
onExport: () => void;
};
export const UserManagementPanel: React.FC<Props> = ({ users, isLoading, query, setQuery, accessLink, inviteEmail, setInviteEmail, updatingUserId, currentUserId, onInvite, onUpdateUser, onDeleteUser, onResetPassword, onExport }) => {
const [editing, setEditing] = useState<ManagedUser | null>(null);
const [draft, setDraft] = useState({ name: '', email: '', role: 'student' as ManagedUser['role'], isActive: true });
const isSelf = editing?.id === currentUserId;
useEffect(() => {
if (editing) setDraft({ name: editing.name, email: editing.email, role: editing.role, isActive: editing.isActive });
}, [editing]);
const save = (event: React.FormEvent) => {
event.preventDefault();
if (!editing) return;
onUpdateUser(editing, isSelf ? { name: draft.name, email: draft.email } : draft);
setEditing(null);
};
return <>
<section className="mb-6 overflow-hidden rounded-2xl border border-orange-500/20 bg-gradient-to-r from-orange-500/10 via-zinc-950/80 to-zinc-950/80 p-5 sm:p-6">
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between"><div><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Acesso de instrutores</p><h2 className="mt-2 text-xl font-bold text-white">Convidar instrutor</h2><p className="mt-1 max-w-xl text-sm text-gray-400">Alunos criam a própria conta na tela de entrada — sem aprovação manual — e recebem a mensagem de boas-vindas quando o e-mail estiver configurado.</p></div><form onSubmit={onInvite} className="flex w-full max-w-xl gap-2"><input required type="email" value={inviteEmail} onChange={(event) => setInviteEmail(event.target.value)} placeholder="E-mail do instrutor" className={inputClass} /><button className="inline-flex shrink-0 items-center gap-2 rounded-xl bg-orange-500 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-orange-600"><Plus className="h-4 w-4" /> Gerar convite</button></form></div>
{accessLink && <div className="mt-5 flex flex-col gap-2 rounded-xl border border-white/10 bg-black/25 p-3 sm:flex-row"><input readOnly value={accessLink} className="min-w-0 flex-1 bg-transparent px-1 text-xs text-gray-300 outline-none" /><button type="button" onClick={() => void navigator.clipboard.writeText(accessLink)} className="inline-flex items-center justify-center gap-2 rounded-lg bg-white/10 px-3 py-2 text-xs font-semibold text-white hover:bg-white/15"><Copy className="h-3.5 w-3.5" /> Copiar link</button></div>}
</section>
<section className="overflow-hidden rounded-2xl border border-white/10 bg-zinc-950/80"><div className="flex flex-col gap-4 border-b border-white/10 p-5 sm:flex-row sm:items-center sm:justify-between"><div><h2 className="text-lg font-bold text-white">Pessoas e acessos</h2><p className="mt-1 text-sm text-gray-500">Edite dados, função e acesso sem precisar sair do painel.</p></div><div className="flex gap-2"><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Buscar por nome ou e-mail" className="w-full min-w-0 rounded-xl border border-white/10 bg-zinc-900 px-3 py-2.5 text-sm text-white sm:w-72" /><button onClick={onExport} className="rounded-xl bg-white/10 px-3 text-sm text-white hover:bg-white/15" title="Exportar CSV"><Download className="h-4 w-4" /></button></div></div>
{isLoading ? <div className="flex h-56 items-center justify-center"><Loader2 className="h-7 w-7 animate-spin text-orange-400" /></div> : <div className="overflow-x-auto"><table className="w-full min-w-[850px] text-left text-sm"><thead className="bg-white/[0.03] text-xs uppercase tracking-wider text-gray-500"><tr><th className="px-5 py-4">Pessoa</th><th className="px-5 py-4">Acesso</th><th className="px-5 py-4">Status</th><th className="px-5 py-4">Cadastro</th><th className="px-5 py-4 text-right">Ações</th></tr></thead><tbody className="divide-y divide-white/5">{users.map((account) => { const busy = updatingUserId === account.id; const current = account.id === currentUserId; return <tr key={account.id} className="text-gray-300"><td className="px-5 py-4"><p className="font-semibold text-white">{account.name}</p><p className="mt-1 text-xs text-gray-500">{account.email}</p></td><td className="px-5 py-4"><span className="rounded-full bg-white/5 px-2.5 py-1 text-xs font-semibold text-gray-300">{roleLabel[account.role]}</span></td><td className="px-5 py-4"><span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold ${account.isActive ? 'bg-emerald-500/15 text-emerald-400' : 'bg-red-500/15 text-red-300'}`}>{account.isActive ? <UserCheck className="h-3.5 w-3.5" /> : <UserX className="h-3.5 w-3.5" />}{account.isActive ? 'Ativo' : 'Desativado'}</span></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 justify-end gap-2"><button disabled={busy} onClick={() => setEditing(account)} className="inline-flex items-center gap-1.5 rounded-lg border border-white/10 px-3 py-2 text-xs font-semibold text-gray-200 hover:bg-white/10 disabled:opacity-50"><Pencil className="h-3.5 w-3.5" /> Editar</button><button disabled={busy || current} onClick={() => onResetPassword(account)} className="rounded-lg border border-white/10 p-2 text-orange-300 hover:bg-orange-500/10 disabled:opacity-40" title="Gerar redefinição de senha"><KeyRound className="h-4 w-4" /></button><button disabled={busy || current} onClick={() => onDeleteUser(account)} className="rounded-lg border border-red-500/20 p-2 text-red-400 hover:bg-red-500/10 disabled:opacity-40" title={current ? 'Você não pode excluir a própria conta' : 'Excluir usuário'}><Trash2 className="h-4 w-4" /></button></div></td></tr>; })}</tbody></table></div>}
</section>
{editing && <div className="fixed inset-0 z-[110] flex items-center justify-center p-4"><button type="button" className="absolute inset-0 bg-black/75 backdrop-blur-sm" onClick={() => setEditing(null)} aria-label="Fechar" /><form onSubmit={save} className="relative w-full max-w-lg overflow-hidden rounded-2xl border border-white/10 bg-zinc-950 shadow-2xl"><div className="flex items-start justify-between border-b border-white/10 p-5"><div><p className="text-xs font-bold uppercase tracking-[0.18em] text-orange-400">Editar acesso</p><h2 className="mt-1 text-xl font-bold text-white">{editing.name}</h2></div><button type="button" onClick={() => setEditing(null)} className="rounded-lg p-2 text-gray-400 hover:bg-white/10 hover:text-white"><X className="h-5 w-5" /></button></div><div className="space-y-4 p-5"><label className="block"><span className="mb-1.5 block text-xs font-semibold uppercase tracking-wider text-gray-500">Nome</span><input value={draft.name} onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))} className={inputClass} required /></label><label className="block"><span className="mb-1.5 block text-xs font-semibold uppercase tracking-wider text-gray-500">E-mail</span><input type="email" value={draft.email} onChange={(event) => setDraft((current) => ({ ...current, email: event.target.value }))} className={inputClass} required /></label>{!isSelf && <><label className="block"><span className="mb-1.5 block text-xs font-semibold uppercase tracking-wider text-gray-500">Função</span><select value={draft.role} onChange={(event) => setDraft((current) => ({ ...current, role: event.target.value as ManagedUser['role'] }))} className={inputClass}><option value="student">Aluno</option><option value="instructor">Instrutor</option><option value="admin">Superadmin</option></select></label><label className="flex cursor-pointer items-start gap-3 rounded-xl border border-white/10 bg-white/[0.03] p-4"><input type="checkbox" checked={draft.isActive} onChange={(event) => setDraft((current) => ({ ...current, isActive: event.target.checked }))} className="mt-0.5 h-4 w-4 accent-orange-500" /><span><span className="block text-sm font-semibold text-white">Conta ativa</span><span className="mt-1 block text-xs text-gray-500">Desative para bloquear o login sem apagar os dados.</span></span></label></>}</div><div className="flex justify-end gap-3 border-t border-white/10 p-5"><button type="button" onClick={() => setEditing(null)} className="rounded-xl px-4 py-2.5 text-sm font-semibold text-gray-300 hover:bg-white/10">Cancelar</button><button className="rounded-xl bg-orange-500 px-5 py-2.5 text-sm font-semibold text-white hover:bg-orange-600">Salvar alterações</button></div></form></div>}
</>;
};

View File

@@ -5,6 +5,7 @@ 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';
import { CourseCoverImage } from '../components/CourseCoverImage'; import { CourseCoverImage } from '../components/CourseCoverImage';
import { UserManagementPanel } from '../components/UserManagementPanel';
const roleLabel: Record<ManagedUser['role'], string> = { admin: 'Superadmin', instructor: 'Instrutor', student: 'Aluno' }; const roleLabel: Record<ManagedUser['role'], string> = { admin: 'Superadmin', instructor: 'Instrutor', student: 'Aluno' };
type CourseOption = { id: string; title: string; status: string; category: string; coverImageUrl: string | null }; type CourseOption = { id: string; title: string; status: string; category: string; coverImageUrl: string | null };
@@ -19,7 +20,7 @@ export const SuperAdmin: React.FC = () => {
const [categories, setCategories] = useState<Array<{ id: string; name: string; position: number; isActive: boolean }>>([]); const [categories, setCategories] = useState<Array<{ id: string; name: string; position: number; isActive: boolean }>>([]);
const [homeConfiguration, setHomeConfiguration] = useState<HomeConfiguration | null>(null); const [paths, setPaths] = useState<LearningPath[]>([]); const [homeConfiguration, setHomeConfiguration] = useState<HomeConfiguration | null>(null); const [paths, setPaths] = useState<LearningPath[]>([]);
const [isLoading, setIsLoading] = useState(true); const [updatingUserId, setUpdatingUserId] = useState<string | null>(null); const [query, setQuery] = useState(''); const [isLoading, setIsLoading] = useState(true); const [updatingUserId, setUpdatingUserId] = useState<string | null>(null); const [query, setQuery] = useState('');
const [newCategory, setNewCategory] = useState(''); const [inviteEmail, setInviteEmail] = useState(''); const [inviteRole, setInviteRole] = useState<'student' | 'instructor'>('student'); const [accessLink, setAccessLink] = useState(''); const [newCategory, setNewCategory] = useState(''); const [inviteEmail, setInviteEmail] = useState(''); const [accessLink, setAccessLink] = useState('');
const [selectedUser, setSelectedUser] = useState<ManagedUserDetail | null>(null); const [loadingDetailId, setLoadingDetailId] = useState<string | null>(null); const [selectedUser, setSelectedUser] = useState<ManagedUserDetail | null>(null); const [loadingDetailId, setLoadingDetailId] = useState<string | null>(null);
const [editingPath, setEditingPath] = useState<LearningPath | null>(null); const [pathInput, setPathInput] = useState<LearningPathInput>(emptyPathInput); const [isPathEditorOpen, setIsPathEditorOpen] = useState(false); const [isSavingPath, setIsSavingPath] = useState(false); const [isUploadingCover, setIsUploadingCover] = useState(false); const coverInputRef = useRef<HTMLInputElement>(null); const [editingPath, setEditingPath] = useState<LearningPath | null>(null); const [pathInput, setPathInput] = useState<LearningPathInput>(emptyPathInput); const [isPathEditorOpen, setIsPathEditorOpen] = useState(false); const [isSavingPath, setIsSavingPath] = useState(false); const [isUploadingCover, setIsUploadingCover] = useState(false); const coverInputRef = useRef<HTMLInputElement>(null);
@@ -28,9 +29,10 @@ export const SuperAdmin: React.FC = () => {
const filteredUsers = useMemo(() => { const normalized = query.trim().toLowerCase(); return normalized ? users.filter((account) => account.name.toLowerCase().includes(normalized) || account.email.toLowerCase().includes(normalized)) : users; }, [query, users]); const filteredUsers = useMemo(() => { const normalized = query.trim().toLowerCase(); return normalized ? users.filter((account) => account.name.toLowerCase().includes(normalized) || account.email.toLowerCase().includes(normalized)) : users; }, [query, users]);
const homeCourses = useMemo(() => { if (!homeConfiguration) return []; const order = new Map<string, number>(homeConfiguration.courseOrder.map((id, index): [string, number] => [id, index])); return homeConfiguration.courses.filter((course) => course.status === 'published').sort((a, b) => (order.get(a.id) ?? 999_999) - (order.get(b.id) ?? 999_999) || a.title.localeCompare(b.title)); }, [homeConfiguration]); const homeCourses = useMemo(() => { if (!homeConfiguration) return []; const order = new Map<string, number>(homeConfiguration.courseOrder.map((id, index): [string, number] => [id, index])); return homeConfiguration.courses.filter((course) => course.status === 'published').sort((a, b) => (order.get(a.id) ?? 999_999) - (order.get(b.id) ?? 999_999) || a.title.localeCompare(b.title)); }, [homeConfiguration]);
const activeUsers = users.filter((account) => account.isActive).length; const instructorUsers = users.filter((account) => account.role === 'instructor' && account.isActive).length; const activeUsers = users.filter((account) => account.isActive).length; const instructorUsers = users.filter((account) => account.role === 'instructor' && account.isActive).length;
const updateUser = async (account: ManagedUser, update: Partial<Pick<ManagedUser, 'role' | 'isActive'>>) => { setUpdatingUserId(account.id); try { const response = await adminApi.updateUser(account.id, update); setUsers((current) => current.map((item) => item.id === account.id ? response.data : item)); showToast('Usuário atualizado.', 'success'); } catch { showToast('Não foi possível atualizar este usuário.', 'error'); } finally { setUpdatingUserId(null); } }; const updateUser = async (account: ManagedUser, update: Partial<Pick<ManagedUser, 'name' | 'email' | 'role' | 'isActive'>>) => { setUpdatingUserId(account.id); try { const response = await adminApi.updateUser(account.id, update); setUsers((current) => current.map((item) => item.id === account.id ? response.data : item)); showToast('Usuário atualizado.', 'success'); } catch { showToast('Não foi possível atualizar este usuário.', 'error'); } finally { setUpdatingUserId(null); } };
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 createInvite = async (event: React.FormEvent) => { event.preventDefault(); try { const response = await adminApi.invite(inviteEmail); setAccessLink(response.data.inviteUrl); setInviteEmail(''); showToast('Convite de instrutor 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 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 deleteUser = async (account: ManagedUser) => { if (!window.confirm(`Excluir “${account.name}”? O histórico de aprendizado e comentários serão removidos. Cursos de instrutor serão preservados e transferidos para você.`)) return; setUpdatingUserId(account.id); try { await adminApi.deleteUser(account.id); setUsers((current) => current.filter((item) => item.id !== account.id)); setSelectedUser(null); showToast('Usuário excluído.', 'success'); } catch { showToast('Não foi possível excluir este usuário.', 'error'); } finally { setUpdatingUserId(null); } };
const createCategory = async (event: React.FormEvent) => { event.preventDefault(); if (!newCategory.trim()) return; try { const response = await adminApi.createCategory(newCategory.trim()); setCategories((current) => [...current, response.data].sort((a, b) => a.position - b.position || a.name.localeCompare(b.name))); setNewCategory(''); showToast('Categoria criada.', 'success'); } catch { showToast('Não foi possível criar esta categoria. Ela pode já existir.', 'error'); } }; const createCategory = async (event: React.FormEvent) => { event.preventDefault(); if (!newCategory.trim()) return; try { const response = await adminApi.createCategory(newCategory.trim()); setCategories((current) => [...current, response.data].sort((a, b) => a.position - b.position || a.name.localeCompare(b.name))); setNewCategory(''); showToast('Categoria criada.', 'success'); } catch { showToast('Não foi possível criar esta categoria. Ela pode já existir.', 'error'); } };
const toggleCategory = async (category: { id: string; name: string; position: number; isActive: boolean }) => { try { const response = await adminApi.updateCategory(category.id, { name: category.name, isActive: !category.isActive }); setCategories((current) => current.map((item) => item.id === category.id ? response.data : item)); } catch { showToast('Não foi possível atualizar a categoria.', 'error'); } }; const toggleCategory = async (category: { id: string; name: string; position: number; isActive: boolean }) => { try { const response = await adminApi.updateCategory(category.id, { name: category.name, isActive: !category.isActive }); setCategories((current) => current.map((item) => item.id === category.id ? response.data : item)); } catch { showToast('Não foi possível atualizar a categoria.', 'error'); } };
const saveHomeConfiguration = async () => { if (!homeConfiguration) return; try { await adminApi.updateHomeConfiguration({ featuredCourseId: homeConfiguration.featuredCourseId, courseOrder: homeConfiguration.courseOrder, defaultCoverImageUrl: homeConfiguration.defaultCoverImageUrl || null }); showToast('Página inicial atualizada.', 'success'); } catch { showToast('Não foi possível salvar a configuração inicial.', 'error'); } }; const saveHomeConfiguration = async () => { if (!homeConfiguration) return; try { await adminApi.updateHomeConfiguration({ featuredCourseId: homeConfiguration.featuredCourseId, courseOrder: homeConfiguration.courseOrder, defaultCoverImageUrl: homeConfiguration.defaultCoverImageUrl || null }); showToast('Página inicial atualizada.', 'success'); } catch { showToast('Não foi possível salvar a configuração inicial.', 'error'); } };
@@ -52,7 +54,7 @@ export const SuperAdmin: React.FC = () => {
{activeTab === 'courses' && <CoursesPanel courses={homeConfiguration?.courses || []} onManage={() => navigate('/gerenciar')} />} {activeTab === 'courses' && <CoursesPanel courses={homeConfiguration?.courses || []} onManage={() => navigate('/gerenciar')} />}
{activeTab === 'trails' && <TrailManager trails={categories} newTrail={newCategory} setNewTrail={setNewCategory} onCreate={createCategory} onToggle={toggleCategory} />} {activeTab === 'trails' && <TrailManager trails={categories} newTrail={newCategory} setNewTrail={setNewCategory} onCreate={createCategory} onToggle={toggleCategory} />}
{activeTab === 'home' && <><BannerManager courses={homeConfiguration?.courses || []} /><HomepageConfigurationPanel homeConfiguration={homeConfiguration} homeCourses={homeCourses} setHomeConfiguration={setHomeConfiguration} onMoveHomeCourse={moveHomeCourse} onSaveHome={saveHomeConfiguration} /></>} {activeTab === 'home' && <><BannerManager courses={homeConfiguration?.courses || []} /><HomepageConfigurationPanel homeConfiguration={homeConfiguration} homeCourses={homeCourses} setHomeConfiguration={setHomeConfiguration} onMoveHomeCourse={moveHomeCourse} onSaveHome={saveHomeConfiguration} /></>}
{activeTab === 'access' && <PeoplePanel users={filteredUsers} isLoading={isLoading} query={query} setQuery={setQuery} accessLink={accessLink} inviteEmail={inviteEmail} setInviteEmail={setInviteEmail} inviteRole={inviteRole} setInviteRole={setInviteRole} selectedUser={selectedUser} setSelectedUser={setSelectedUser} updatingUserId={updatingUserId} loadingDetailId={loadingDetailId} currentUserId={user?.id} onInvite={createInvite} onUpdateUser={updateUser} onShowDetail={showUserDetail} onResetPassword={resetPassword} onExport={exportUsers} />} {activeTab === 'access' && <UserManagementPanel users={filteredUsers} isLoading={isLoading} query={query} setQuery={setQuery} accessLink={accessLink} inviteEmail={inviteEmail} setInviteEmail={setInviteEmail} updatingUserId={updatingUserId} currentUserId={user?.id} onInvite={createInvite} onUpdateUser={updateUser} onDeleteUser={deleteUser} onResetPassword={resetPassword} onExport={exportUsers} />}
{isPathEditorOpen && <PathEditor input={pathInput} setInput={setPathInput} courses={homeConfiguration?.courses || []} selectedCourses={selectedPathCourses} editing={Boolean(editingPath)} isSaving={isSavingPath} isUploadingCover={isUploadingCover} coverInputRef={coverInputRef} onClose={closePathEditor} onSave={savePath} onUpload={uploadPathCover} onToggleCourse={togglePathCourse} onMoveCourse={movePathCourse} />} {isPathEditorOpen && <PathEditor input={pathInput} setInput={setPathInput} courses={homeConfiguration?.courses || []} selectedCourses={selectedPathCourses} editing={Boolean(editingPath)} isSaving={isSavingPath} isUploadingCover={isUploadingCover} coverInputRef={coverInputRef} onClose={closePathEditor} onSave={savePath} onUpload={uploadPathCover} onToggleCourse={togglePathCourse} onMoveCourse={movePathCourse} />}
</main>; </main>;
}; };

View File

@@ -19,6 +19,8 @@ const environmentSchema = z.object({
INVITATION_TTL_HOURS: z.coerce.number().int().min(1).max(24 * 90).default(24 * 7), INVITATION_TTL_HOURS: z.coerce.number().int().min(1).max(24 * 90).default(24 * 7),
PASSWORD_RESET_TTL_HOURS: z.coerce.number().int().min(1).max(24 * 30).default(24), PASSWORD_RESET_TTL_HOURS: z.coerce.number().int().min(1).max(24 * 30).default(24),
AUDIT_LOG_PAGE_SIZE: z.coerce.number().int().min(10).max(500).default(50), AUDIT_LOG_PAGE_SIZE: z.coerce.number().int().min(10).max(500).default(50),
RESEND_API_KEY: optionalEnvironmentValue(z.string().min(20)),
EMAIL_FROM: optionalEnvironmentValue(z.string().trim().min(3).max(320)),
BUNNY_STREAM_LIBRARY_ID: optionalEnvironmentValue(z.coerce.number().int().positive()), BUNNY_STREAM_LIBRARY_ID: optionalEnvironmentValue(z.coerce.number().int().positive()),
BUNNY_STREAM_API_KEY: optionalEnvironmentValue(z.string().min(20)), BUNNY_STREAM_API_KEY: optionalEnvironmentValue(z.string().min(20)),
BUNNY_EMBED_TOKEN_KEY: optionalEnvironmentValue(z.string().min(20)), BUNNY_EMBED_TOKEN_KEY: optionalEnvironmentValue(z.string().min(20)),
@@ -58,3 +60,7 @@ const bunnyStorageConfigurationValues = [
if (bunnyStorageConfigurationValues.some(Boolean) && !bunnyStorageConfigurationValues.every(Boolean)) { if (bunnyStorageConfigurationValues.some(Boolean) && !bunnyStorageConfigurationValues.every(Boolean)) {
throw new Error('BUNNY_STORAGE_ZONE, BUNNY_STORAGE_PASSWORD, BUNNY_STORAGE_ENDPOINT, and BUNNY_STORAGE_CDN_HOST must be configured together.'); throw new Error('BUNNY_STORAGE_ZONE, BUNNY_STORAGE_PASSWORD, BUNNY_STORAGE_ENDPOINT, and BUNNY_STORAGE_CDN_HOST must be configured together.');
} }
if (Boolean(config.RESEND_API_KEY) !== Boolean(config.EMAIL_FROM)) {
throw new Error('RESEND_API_KEY and EMAIL_FROM must be configured together.');
}

View File

@@ -11,12 +11,14 @@ const userParamsSchema = z.object({
}); });
const updateUserSchema = z.object({ const updateUserSchema = z.object({
name: z.string().trim().min(2).max(120).optional(),
email: z.string().email().transform((email) => email.toLowerCase()).optional(),
role: z.enum(['student', 'instructor', 'admin']).optional(), role: z.enum(['student', 'instructor', 'admin']).optional(),
isActive: z.boolean().optional(), isActive: z.boolean().optional(),
}).refine((input) => input.role !== undefined || input.isActive !== undefined, { }).refine((input) => input.name !== undefined || input.email !== undefined || 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') }); const invitationSchema = z.object({ email: z.string().email().transform((email) => email.toLowerCase()), role: z.literal('instructor').default('instructor') });
const categorySchema = z.object({ name: z.string().trim().min(1).max(120), isActive: z.boolean().optional(), position: z.number().int().min(0).max(10_000).optional() }); const categorySchema = z.object({ name: z.string().trim().min(1).max(120), isActive: z.boolean().optional(), position: z.number().int().min(0).max(10_000).optional() });
const categoryParamsSchema = z.object({ categoryId: z.string().uuid() }); const categoryParamsSchema = z.object({ categoryId: z.string().uuid() });
const homeConfigurationSchema = z.object({ const homeConfigurationSchema = z.object({
@@ -373,18 +375,61 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
if (userId === request.user.id && (input.role !== undefined && input.role !== 'admin' || input.isActive === false)) { if (userId === request.user.id && (input.role !== undefined && input.role !== 'admin' || input.isActive === false)) {
return reply.code(400).send({ error: 'You cannot remove your own superadmin access' }); return reply.code(400).send({ error: 'You cannot remove your own superadmin access' });
} }
if (input.email) {
const duplicate = await pool.query('select 1 from users where email = $1 and id <> $2', [input.email, userId]);
if (duplicate.rowCount) return reply.code(409).send({ error: 'This e-mail is already used by another account' });
}
const result = await pool.query( const result = await pool.query(
`update users `update users
set role = coalesce($2::user_role, role), set display_name = coalesce($2, display_name),
is_active = coalesce($3, is_active) email = coalesce($3, email),
role = coalesce($4::user_role, role),
is_active = coalesce($5, is_active)
where id = $1 where id = $1
returning id, email, display_name as name, role, is_active as "isActive", created_at as "createdAt"`, returning id, email, display_name as name, role, is_active as "isActive", created_at as "createdAt"`,
[userId, input.role ?? null, input.isActive ?? null], [userId, input.name ?? null, input.email ?? null, input.role ?? null, input.isActive ?? null],
); );
const account = result.rows[0]; const account = result.rows[0];
if (!account) return reply.code(404).send({ error: 'User not found' }); if (!account) return reply.code(404).send({ error: 'User not found' });
await recordAudit({ actorId: request.user.id, action: 'user.updated', subjectType: 'user', subjectId: userId, metadata: input, ipAddress: request.ip }); await recordAudit({ actorId: request.user.id, action: 'user.updated', subjectType: 'user', subjectId: userId, metadata: input, ipAddress: request.ip });
return { data: account }; return { data: account };
}); });
app.delete('/users/:userId', adminAccess, async (request, reply) => {
const { userId } = userParamsSchema.parse(request.params);
if (userId === request.user.id) return reply.code(400).send({ error: 'You cannot delete your own superadmin account' });
const client = await pool.connect();
try {
await client.query('begin');
const account = await client.query<{ id: string; email: string; role: 'student' | 'instructor' | 'admin' }>('select id, email, role from users where id = $1 for update', [userId]);
if (!account.rows[0]) {
await client.query('rollback');
return reply.code(404).send({ error: 'User not found' });
}
if (account.rows[0].role === 'admin') {
const admins = await client.query<{ count: string }>("select count(*) from users where role = 'admin' and is_active");
if (Number(admins.rows[0].count) <= 1) {
await client.query('rollback');
return reply.code(400).send({ error: 'The last active superadmin cannot be deleted' });
}
}
// Courses remain available. Their ownership is transferred to the admin
// performing the deletion instead of deleting lesson and Bunny media.
await client.query('update courses set instructor_id = $2 where instructor_id = $1', [userId, request.user.id]);
await client.query('delete from comment_replies where author_id = $1', [userId]);
await client.query('delete from comments where author_id = $1', [userId]);
await client.query('delete from account_access_tokens where created_by = $1 or email = $2', [userId, account.rows[0].email]);
await client.query('delete from users where id = $1', [userId]);
await client.query('commit');
await recordAudit({ actorId: request.user.id, action: 'user.deleted', subjectType: 'user', subjectId: userId, metadata: { email: account.rows[0].email, role: account.rows[0].role }, ipAddress: request.ip });
return reply.code(204).send();
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
});
}; };

View File

@@ -5,6 +5,7 @@ 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';
import { config } from '../config.js'; import { config } from '../config.js';
import { sendWelcomeEmail } from '../services/email.js';
const credentialsSchema = z.object({ const credentialsSchema = z.object({
email: z.string().email().transform((email) => email.toLowerCase()), email: z.string().email().transform((email) => email.toLowerCase()),
@@ -130,6 +131,12 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
); );
const user = serializeUser(result.rows[0]); const user = serializeUser(result.rows[0]);
const token = await reply.jwtSign(user, { expiresIn: config.JWT_SESSION_TTL }); const token = await reply.jwtSign(user, { expiresIn: config.JWT_SESSION_TTL });
try {
const sent = await sendWelcomeEmail({ name: user.name, email: user.email });
if (!sent && config.RESEND_API_KEY) request.log.warn({ email: user.email }, 'Welcome email was rejected by the provider');
} catch (error) {
request.log.error(error, 'Welcome email could not be sent');
}
return reply.code(201).send({ token, user }); return reply.code(201).send({ token, user });
} catch (error: unknown) { } catch (error: unknown) {
if (typeof error === 'object' && error && 'code' in error && error.code === '23505') { if (typeof error === 'object' && error && 'code' in error && error.code === '23505') {

View File

@@ -0,0 +1,20 @@
import { config } from '../config.js';
type WelcomeRecipient = { name: string; email: string };
export async function sendWelcomeEmail(recipient: WelcomeRecipient) {
if (!config.RESEND_API_KEY || !config.EMAIL_FROM) return false;
const escapedName = recipient.name.replace(/[&<>"']/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }[character] || character));
const response = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: { Authorization: `Bearer ${config.RESEND_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
from: config.EMAIL_FROM,
to: [recipient.email],
subject: 'Bem-vindo(a) ao Compor HUB',
html: `<main style="font-family:Arial,sans-serif;color:#18181b;line-height:1.55"><h1>Bem-vindo(a), ${escapedName}!</h1><p>Sua conta no <strong>Compor HUB</strong> já está pronta.</p><p>Agora você pode acessar cursos, acompanhar suas aulas e baixar materiais complementares.</p><p><a href="${config.FRONTEND_ORIGIN}" style="display:inline-block;background:#f97316;color:#fff;padding:12px 18px;border-radius:8px;text-decoration:none;font-weight:700">Acessar Compor HUB</a></p></main>`,
}),
});
return response.ok;
}

View File

@@ -178,18 +178,19 @@ export const adminApi = {
async listUsers() { async listUsers() {
return apiRequest<{ data: ManagedUser[] }>('/admin/users'); return apiRequest<{ data: ManagedUser[] }>('/admin/users');
}, },
async updateUser(userId: string, update: Partial<Pick<ManagedUser, 'role' | 'isActive'>>) { async updateUser(userId: string, update: Partial<Pick<ManagedUser, 'name' | 'email' | 'role' | 'isActive'>>) {
return apiRequest<{ data: ManagedUser }>(`/admin/users/${userId}`, { return apiRequest<{ data: ManagedUser }>(`/admin/users/${userId}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(update), body: JSON.stringify(update),
}); });
}, },
async invite(email: string, role: 'student' | 'instructor') { async invite(email: string, role: 'instructor' = 'instructor') {
return apiRequest<{ data: { inviteUrl: string } }>('/admin/invitations', { method: 'POST', body: JSON.stringify({ email, role }) }); return apiRequest<{ data: { inviteUrl: string } }>('/admin/invitations', { method: 'POST', body: JSON.stringify({ email, role }) });
}, },
async passwordReset(userId: string) { async passwordReset(userId: string) {
return apiRequest<{ data: { resetUrl: string } }>(`/admin/users/${userId}/password-reset`, { method: 'POST' }); return apiRequest<{ data: { resetUrl: string } }>(`/admin/users/${userId}/password-reset`, { method: 'POST' });
}, },
async deleteUser(userId: string) { return apiRequest<void>(`/admin/users/${userId}`, { method: 'DELETE' }); },
async userDetail(userId: string) { return apiRequest<{ data: ManagedUserDetail }>(`/admin/users/${userId}`); }, async userDetail(userId: string) { return apiRequest<{ data: ManagedUserDetail }>(`/admin/users/${userId}`); },
async auditLog() { return apiRequest<{ data: AuditEntry[] }>('/admin/audit-log'); }, async auditLog() { return apiRequest<{ data: AuditEntry[] }>('/admin/audit-log'); },
async categories() { return apiRequest<{ data: Array<{ id: string; name: string; position: number; isActive: boolean }> }>('/admin/categories'); }, async categories() { return apiRequest<{ data: Array<{ id: string; name: string; position: number; isActive: boolean }> }>('/admin/categories'); },