diff --git a/src/components/admin/admin-users-manager.tsx b/src/components/admin/admin-users-manager.tsx index 35f031f..53792f4 100644 --- a/src/components/admin/admin-users-manager.tsx +++ b/src/components/admin/admin-users-manager.tsx @@ -1320,7 +1320,7 @@ async function handleDeleteUser() {
{teamTotal === 0 ? "Nenhum registro" : `Mostrando ${teamStart}-${teamEnd} de ${teamTotal}`}
-
+
Itens por página Revogar selecionados
-
+
Itens por página { setPageSize(Number(v)); setPageIndex(0) }}> diff --git a/src/components/admin/users/admin-users-workspace.tsx b/src/components/admin/users/admin-users-workspace.tsx index 5cf5fc9..4cb6906 100644 --- a/src/components/admin/users/admin-users-workspace.tsx +++ b/src/components/admin/users/admin-users-workspace.tsx @@ -1,6 +1,6 @@ "use client" -import { useCallback, useMemo, useState, useTransition } from "react" +import { useCallback, useEffect, useMemo, useState, useTransition } from "react" import { format } from "date-fns" import { ptBR } from "date-fns/locale" import { Controller, FormProvider, useFieldArray, useForm } from "react-hook-form" @@ -32,6 +32,7 @@ import { } from "@/lib/schemas/company" import type { NormalizedCompany } from "@/server/company-service" import { cn } from "@/lib/utils" +import type { RoleOption } from "@/lib/authz" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" @@ -91,6 +92,18 @@ const ROLE_LABEL: Record = { COLLABORATOR: "Colaborador", } +const ROLE_TO_OPTION: Record = { + MANAGER: "manager", + COLLABORATOR: "collaborator", +} + +const ROLE_OPTIONS_DISPLAY: ReadonlyArray<{ value: AdminAccount["role"]; label: string }> = [ + { value: "MANAGER", label: "Gestor" }, + { value: "COLLABORATOR", label: "Colaborador" }, +] + +const NO_COMPANY_SELECT_VALUE = "__none__" + const NO_CONTACT_VALUE = "__none__" function createId(prefix: string) { @@ -137,6 +150,17 @@ function AccountsTable({ initialAccounts }: { initialAccounts: AdminAccount[] }) const [rowSelection, setRowSelection] = useState>({}) const [isPending, startTransition] = useTransition() const [deleteDialogIds, setDeleteDialogIds] = useState([]) + const [editAccountId, setEditAccountId] = useState(null) + const [editAuthUserId, setEditAuthUserId] = useState(null) + const [editForm, setEditForm] = useState({ + name: "", + email: "", + role: "COLLABORATOR" as AdminAccount["role"], + companyId: "", + }) + const [isSavingAccount, setIsSavingAccount] = useState(false) + const [isResettingPassword, setIsResettingPassword] = useState(false) + const [passwordPreview, setPasswordPreview] = useState(null) const filteredAccounts = useMemo(() => { const term = search.trim().toLowerCase() @@ -157,6 +181,11 @@ function AccountsTable({ initialAccounts }: { initialAccounts: AdminAccount[] }) [rowSelection] ) + const editAccount = useMemo( + () => (editAccountId ? accounts.find((account) => account.id === editAccountId) ?? null : null), + [accounts, editAccountId] + ) + const companies = useMemo(() => { const map = new Map() accounts.forEach((account) => { @@ -173,6 +202,131 @@ function AccountsTable({ initialAccounts }: { initialAccounts: AdminAccount[] }) const closeDeleteDialog = useCallback(() => setDeleteDialogIds([]), []) + const closeEditor = useCallback(() => { + setEditAccountId(null) + setEditAuthUserId(null) + setPasswordPreview(null) + }, []) + + useEffect(() => { + if (editAccount) { + setEditForm({ + name: editAccount.name, + email: editAccount.email, + role: editAccount.role, + companyId: editAccount.companyId ?? "", + }) + } + }, [editAccount]) + + const refreshAccounts = useCallback(async () => { + try { + const response = await fetch("/api/admin/users", { credentials: "include", cache: "no-store" }) + if (!response.ok) { + const payload = await response.json().catch(() => null) + throw new Error(payload?.error ?? "Não foi possível atualizar a lista de usuários.") + } + const data = (await response.json()) as { items: Array } + setAccounts( + data.items.map((item) => ({ + ...item, + name: item.name ?? item.email, + })), + ) + } catch (error) { + const message = error instanceof Error ? error.message : "Falha ao atualizar usuários." + toast.error(message) + } + }, []) + + const handleOpenEditor = useCallback((account: AdminAccount) => { + if (!account.authUserId) { + toast.error("Não foi possível localizar o perfil de acesso para este usuário.") + return + } + setEditAccountId(account.id) + setEditAuthUserId(account.authUserId) + setPasswordPreview(null) + }, []) + + const handleSaveAccount = useCallback( + async (event: React.FormEvent) => { + event.preventDefault() + if (!editAccount || !editAuthUserId) { + toast.error("Nenhum usuário selecionado.") + return + } + + const payload = { + name: editForm.name.trim(), + email: editForm.email.trim().toLowerCase(), + role: ROLE_TO_OPTION[editForm.role] ?? "collaborator", + tenantId: editAccount.tenantId, + companyId: editForm.companyId ? editForm.companyId : null, + } + + if (!payload.name) { + toast.error("Informe o nome do usuário.") + return + } + if (!payload.email || !payload.email.includes("@")) { + toast.error("Informe um e-mail válido.") + return + } + + setIsSavingAccount(true) + try { + const response = await fetch(`/api/admin/users/${editAuthUserId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify(payload), + }) + if (!response.ok) { + const data = await response.json().catch(() => null) + throw new Error(data?.error ?? "Não foi possível salvar as alterações.") + } + await refreshAccounts() + toast.success("Usuário atualizado com sucesso.") + closeEditor() + } catch (error) { + const message = error instanceof Error ? error.message : "Erro ao salvar usuário." + toast.error(message) + } finally { + setIsSavingAccount(false) + } + }, + [editAccount, editAuthUserId, editForm, refreshAccounts, closeEditor] + ) + + const handleResetPassword = useCallback(async () => { + if (!editAuthUserId) { + toast.error("Usuário inválido para gerar senha.") + return + } + setIsResettingPassword(true) + toast.loading("Gerando nova senha...", { id: "admin-users-reset-password" }) + try { + const response = await fetch(`/api/admin/users/${editAuthUserId}/reset-password`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + }) + if (!response.ok) { + const data = await response.json().catch(() => null) + throw new Error(data?.error ?? "Falha ao gerar senha temporária.") + } + const data = (await response.json()) as { temporaryPassword: string } + setPasswordPreview(data.temporaryPassword) + toast.success("Senha temporária criada com sucesso.", { id: "admin-users-reset-password" }) + } catch (error) { + const message = error instanceof Error ? error.message : "Erro ao gerar senha." + toast.error(message, { id: "admin-users-reset-password" }) + } finally { + setIsResettingPassword(false) + } + }, [editAuthUserId]) + const handleDelete = useCallback( (ids: string[]) => { if (ids.length === 0) return @@ -274,6 +428,7 @@ function AccountsTable({ initialAccounts }: { initialAccounts: AdminAccount[] }) Empresa Papel Último acesso + Ações Selecionar @@ -312,6 +467,29 @@ function AccountsTable({ initialAccounts }: { initialAccounts: AdminAccount[] }) {ROLE_LABEL[account.role]} {formatDate(account.lastSeenAt)} + +
+ + +
+
+ + (!open ? closeEditor() : null)}> + + + Editar usuário +

+ Atualize os dados cadastrais, papel e vínculo do colaborador. +

+
+ + {editAccount ? ( +
+
+

{editAccount.name}

+

{editAccount.email}

+
+ Essa seleção substitui o vínculo atual no portal do cliente. +
+
+ +
+
+ + setEditForm((prev) => ({ ...prev, name: event.target.value }))} + placeholder="Nome completo" + disabled={isSavingAccount} + required + /> +
+
+ + setEditForm((prev) => ({ ...prev, email: event.target.value }))} + placeholder="usuario@empresa.com" + disabled={isSavingAccount} + required + /> +
+
+ + +
+
+ + +
+
+ +
+
+
+

Gerar nova senha

+

+ Uma senha temporária será exibida abaixo. Compartilhe com o usuário e peça para trocá-la no primeiro acesso. +

+
+ +
+ {passwordPreview ? ( +
+ {passwordPreview} + +
+ ) : null} +
+ + + + + +
+ ) : ( +

Selecione um usuário para visualizar os detalhes.

+ )} +
+
) } diff --git a/src/components/ui/table-pagination.tsx b/src/components/ui/table-pagination.tsx index 6e0010f..b03bf21 100644 --- a/src/components/ui/table-pagination.tsx +++ b/src/components/ui/table-pagination.tsx @@ -102,7 +102,7 @@ export function TablePagination({
{pageSizeOptions.length > 0 ? ( -
+
{rowsPerPageLabel}