From 03a64919890056849c352cdaa524c95c69f52114 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Tue, 8 Sep 2026 11:47:33 -0300 Subject: [PATCH] feat: improve account management and welcome emails --- .env.example | 5 +++ components/UserManagementPanel.tsx | 51 +++++++++++++++++++++++++++ pages/SuperAdmin.tsx | 10 +++--- server/src/config.ts | 6 ++++ server/src/routes/admin.ts | 55 +++++++++++++++++++++++++++--- server/src/routes/auth.ts | 7 ++++ server/src/services/email.ts | 20 +++++++++++ services/api.ts | 5 +-- 8 files changed, 148 insertions(+), 11 deletions(-) create mode 100644 components/UserManagementPanel.tsx create mode 100644 server/src/services/email.ts diff --git a/.env.example b/.env.example index 29c820d..2f06eb5 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,11 @@ JWT_SESSION_TTL=7d INVITATION_TTL_HOURS=168 PASSWORD_RESET_TTL_HOURS=24 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 SUPERADMIN_NAME=Compor HUB Superadmin # Bunny Stream. Set all three in Portainer to enable instructor uploads and diff --git a/components/UserManagementPanel.tsx b/components/UserManagementPanel.tsx new file mode 100644 index 0000000..5d77251 --- /dev/null +++ b/components/UserManagementPanel.tsx @@ -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 = { 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>) => void; + onDeleteUser: (account: ManagedUser) => void; + onResetPassword: (account: ManagedUser) => void; + onExport: () => void; +}; + +export const UserManagementPanel: React.FC = ({ users, isLoading, query, setQuery, accessLink, inviteEmail, setInviteEmail, updatingUserId, currentUserId, onInvite, onUpdateUser, onDeleteUser, onResetPassword, onExport }) => { + const [editing, setEditing] = useState(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 <> +
+

Acesso de instrutores

Convidar instrutor

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.

setInviteEmail(event.target.value)} placeholder="E-mail do instrutor" className={inputClass} />
+ {accessLink &&
} +
+

Pessoas e acessos

Edite dados, função e acesso sem precisar sair do painel.

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" />
+ {isLoading ?
:
{users.map((account) => { const busy = updatingUserId === account.id; const current = account.id === currentUserId; return ; })}
PessoaAcessoStatusCadastroAções

{account.name}

{account.email}

{roleLabel[account.role]}{account.isActive ? : }{account.isActive ? 'Ativo' : 'Desativado'}{new Intl.DateTimeFormat('pt-BR').format(new Date(account.createdAt))}
} +
+ {editing &&
{!isSelf && <>}
} + ; +}; diff --git a/pages/SuperAdmin.tsx b/pages/SuperAdmin.tsx index 0e25d78..642c5ee 100644 --- a/pages/SuperAdmin.tsx +++ b/pages/SuperAdmin.tsx @@ -5,6 +5,7 @@ import { useAuth } from '../context/AuthContext'; import { useToast } from '../context/ToastContext'; import { useNavigate } from 'react-router-dom'; import { CourseCoverImage } from '../components/CourseCoverImage'; +import { UserManagementPanel } from '../components/UserManagementPanel'; const roleLabel: Record = { admin: 'Superadmin', instructor: 'Instrutor', student: 'Aluno' }; 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>([]); const [homeConfiguration, setHomeConfiguration] = useState(null); const [paths, setPaths] = useState([]); const [isLoading, setIsLoading] = useState(true); const [updatingUserId, setUpdatingUserId] = useState(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(null); const [loadingDetailId, setLoadingDetailId] = useState(null); const [editingPath, setEditingPath] = useState(null); const [pathInput, setPathInput] = useState(emptyPathInput); const [isPathEditorOpen, setIsPathEditorOpen] = useState(false); const [isSavingPath, setIsSavingPath] = useState(false); const [isUploadingCover, setIsUploadingCover] = useState(false); const coverInputRef = useRef(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 homeCourses = useMemo(() => { if (!homeConfiguration) return []; const order = new Map(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 updateUser = async (account: ManagedUser, update: Partial>) => { 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 updateUser = async (account: ManagedUser, update: Partial>) => { 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); 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 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 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'); } }; @@ -52,7 +54,7 @@ export const SuperAdmin: React.FC = () => { {activeTab === 'courses' && navigate('/gerenciar')} />} {activeTab === 'trails' && } {activeTab === 'home' && <>} - {activeTab === 'access' && } + {activeTab === 'access' && } {isPathEditorOpen && } ; }; diff --git a/server/src/config.ts b/server/src/config.ts index 9dca014..5d6098f 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -19,6 +19,8 @@ const environmentSchema = z.object({ 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), 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_API_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)) { 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.'); +} diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index 28c975d..ab1d4d4 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -11,12 +11,14 @@ const userParamsSchema = 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(), 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', }); -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 categoryParamsSchema = z.object({ categoryId: z.string().uuid() }); 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)) { 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( `update users - set role = coalesce($2::user_role, role), - is_active = coalesce($3, is_active) + set display_name = coalesce($2, display_name), + email = coalesce($3, email), + role = coalesce($4::user_role, role), + is_active = coalesce($5, is_active) where id = $1 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]; 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 }); 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(); + } + }); }; diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 178ba24..604e535 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -5,6 +5,7 @@ import { hashToken } from '../auth/account-tokens.js'; import type { AuthUser } from '../auth/plugin.js'; import { pool } from '../db/pool.js'; import { config } from '../config.js'; +import { sendWelcomeEmail } from '../services/email.js'; const credentialsSchema = z.object({ 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 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 }); } catch (error: unknown) { if (typeof error === 'object' && error && 'code' in error && error.code === '23505') { diff --git a/server/src/services/email.ts b/server/src/services/email.ts new file mode 100644 index 0000000..a6f6699 --- /dev/null +++ b/server/src/services/email.ts @@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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: `

Bem-vindo(a), ${escapedName}!

Sua conta no Compor HUB já está pronta.

Agora você pode acessar cursos, acompanhar suas aulas e baixar materiais complementares.

Acessar Compor HUB

`, + }), + }); + return response.ok; +} diff --git a/services/api.ts b/services/api.ts index ff88c0f..9834f23 100644 --- a/services/api.ts +++ b/services/api.ts @@ -178,18 +178,19 @@ export const adminApi = { async listUsers() { return apiRequest<{ data: ManagedUser[] }>('/admin/users'); }, - async updateUser(userId: string, update: Partial>) { + async updateUser(userId: string, update: Partial>) { return apiRequest<{ data: ManagedUser }>(`/admin/users/${userId}`, { method: 'PATCH', 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 }) }); }, async passwordReset(userId: string) { return apiRequest<{ data: { resetUrl: string } }>(`/admin/users/${userId}/password-reset`, { method: 'POST' }); }, + async deleteUser(userId: string) { return apiRequest(`/admin/users/${userId}`, { method: 'DELETE' }); }, async userDetail(userId: string) { return apiRequest<{ data: ManagedUserDetail }>(`/admin/users/${userId}`); }, 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'); },