Enable admin user removals and invitation UX polish
This commit is contained in:
parent
aa12ebfe0a
commit
05f5af5ba6
5 changed files with 288 additions and 17 deletions
|
|
@ -8,6 +8,7 @@ import { toast } from "sonner"
|
|||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
|
|
@ -110,6 +111,19 @@ function formatStatus(status: AdminInvite["status"]) {
|
|||
}
|
||||
}
|
||||
|
||||
function inviteStatusVariant(status: AdminInvite["status"]) {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return "secondary" as const
|
||||
case "accepted":
|
||||
return "default" as const
|
||||
case "revoked":
|
||||
return "destructive" as const
|
||||
default:
|
||||
return "outline" as const
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeInvite(invite: AdminInvite & { events?: unknown }): AdminInvite {
|
||||
const { events: unusedEvents, ...rest } = invite
|
||||
void unusedEvents
|
||||
|
|
@ -121,6 +135,11 @@ function coerceRole(role: AdminRole | string | null | undefined): RoleOption {
|
|||
return (ROLE_OPTIONS as readonly string[]).includes(candidate) ? (candidate as RoleOption) : "agent"
|
||||
}
|
||||
|
||||
function extractMachineId(email: string): string | null {
|
||||
const match = /^machine-(.+)@machines\.local$/i.exec(email.trim())
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, defaultTenantId }: Props) {
|
||||
const [users, setUsers] = useState<AdminUser[]>(initialUsers)
|
||||
const [invites, setInvites] = useState<AdminInvite[]>(initialInvites)
|
||||
|
|
@ -151,6 +170,17 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
const [isSavingUser, setIsSavingUser] = useState(false)
|
||||
const [isResettingPassword, setIsResettingPassword] = useState(false)
|
||||
const [passwordPreview, setPasswordPreview] = useState<string | null>(null)
|
||||
const [deleteUserId, setDeleteUserId] = useState<string | null>(null)
|
||||
const deleteTarget = useMemo(
|
||||
() => users.find((candidate) => candidate.id === deleteUserId) ?? null,
|
||||
[users, deleteUserId]
|
||||
)
|
||||
const [isDeletingUser, setIsDeletingUser] = useState(false)
|
||||
const [revokeDialogInviteId, setRevokeDialogInviteId] = useState<string | null>(null)
|
||||
const revokeCandidate = useMemo(
|
||||
() => invites.find((invite) => invite.id === revokeDialogInviteId) ?? null,
|
||||
[invites, revokeDialogInviteId]
|
||||
)
|
||||
|
||||
const normalizedRoles = useMemo<readonly AdminRole[]>(() => {
|
||||
return (roleOptions && roleOptions.length > 0 ? roleOptions : ROLE_OPTIONS) as readonly AdminRole[]
|
||||
|
|
@ -253,16 +283,15 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
.catch(() => toast.error("Não foi possível copiar o link"))
|
||||
}
|
||||
|
||||
async function handleRevoke(inviteId: string) {
|
||||
const invite = invites.find((item) => item.id === inviteId)
|
||||
if (!invite || invite.status !== "pending") return
|
||||
async function handleRevokeConfirmed() {
|
||||
if (!revokeCandidate || revokeCandidate.status !== "pending") {
|
||||
setRevokeDialogInviteId(null)
|
||||
return
|
||||
}
|
||||
|
||||
const confirmed = window.confirm("Deseja revogar este convite?")
|
||||
if (!confirmed) return
|
||||
|
||||
setRevokingId(inviteId)
|
||||
setRevokingId(revokeCandidate.id)
|
||||
try {
|
||||
const response = await fetch(`/api/admin/invites/${inviteId}`, {
|
||||
const response = await fetch(`/api/admin/invites/${revokeCandidate.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason: "Revogado manualmente" }),
|
||||
|
|
@ -282,6 +311,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
toast.error(message)
|
||||
} finally {
|
||||
setRevokingId(null)
|
||||
setRevokeDialogInviteId(null)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -398,6 +428,52 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
() => [{ id: NO_COMPANY_ID, name: "Sem empresa vinculada" }, ...companies],
|
||||
[companies]
|
||||
)
|
||||
async function handleDeleteUser() {
|
||||
if (!deleteTarget) return
|
||||
setIsDeletingUser(true)
|
||||
const isMachine = deleteTarget.role === "machine"
|
||||
|
||||
try {
|
||||
if (isMachine) {
|
||||
const machineId = extractMachineId(deleteTarget.email)
|
||||
if (!machineId) {
|
||||
throw new Error("Não foi possível identificar a máquina associada.")
|
||||
}
|
||||
const response = await fetch("/api/admin/machines/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ machineId }),
|
||||
credentials: "include",
|
||||
})
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
throw new Error(data.error ?? "Falha ao remover agente de máquina")
|
||||
}
|
||||
toast.success("Agente de máquina removido")
|
||||
} else {
|
||||
const response = await fetch(`/api/admin/users/${deleteTarget.id}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
})
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}))
|
||||
throw new Error(data.error ?? "Falha ao remover colaborador")
|
||||
}
|
||||
toast.success("Colaborador removido")
|
||||
}
|
||||
|
||||
setUsers((previous) => previous.filter((user) => user.id !== deleteTarget.id))
|
||||
if (editUserId === deleteTarget.id) {
|
||||
setEditUserId(null)
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Não foi possível remover o usuário"
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setIsDeletingUser(false)
|
||||
setDeleteUserId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -441,6 +517,14 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
<Button variant="outline" size="sm" onClick={() => setEditUserId(user.id)}>
|
||||
Editar
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-600 hover:bg-red-500/10"
|
||||
onClick={() => setDeleteUserId(user.id)}
|
||||
>
|
||||
Remover
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -525,9 +609,19 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
<td className="py-3 pr-4 text-neutral-600">{user.machinePersona ? user.machinePersona === "manager" ? "Gestor" : "Colaborador" : "—"}</td>
|
||||
<td className="py-3 pr-4 text-neutral-500">{formatDate(user.createdAt)}</td>
|
||||
<td className="py-3">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/admin/machines">Gerenciar</Link>
|
||||
</Button>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link href="/admin/machines">Gerenciar</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-600 hover:bg-red-500/10"
|
||||
onClick={() => setDeleteUserId(user.id)}
|
||||
>
|
||||
Remover acesso
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
|
@ -668,8 +762,8 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
<td className="py-3 pr-4 text-neutral-600">{formatDate(invite.expiresAt)}</td>
|
||||
<td className="py-3 pr-4">
|
||||
<Badge
|
||||
variant={invite.status === "pending" ? "secondary" : invite.status === "accepted" ? "default" : invite.status === "expired" ? "outline" : "destructive"}
|
||||
className="rounded-full px-3 py-1 text-[11px] uppercase tracking-wide"
|
||||
variant={inviteStatusVariant(invite.status)}
|
||||
className="rounded-full px-3 py-1 text-xs font-medium"
|
||||
>
|
||||
{formatStatus(invite.status)}
|
||||
</Badge>
|
||||
|
|
@ -683,8 +777,8 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-600 hover:bg-red-50"
|
||||
onClick={() => handleRevoke(invite.id)}
|
||||
className="text-red-600 transition-colors hover:bg-red-500/10"
|
||||
onClick={() => setRevokeDialogInviteId(invite.id)}
|
||||
disabled={revokingId === invite.id}
|
||||
>
|
||||
{revokingId === invite.id ? "Revogando..." : "Revogar"}
|
||||
|
|
@ -832,6 +926,93 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
) : null}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<Dialog
|
||||
open={deleteUserId !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleteUserId(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{deleteTarget?.role === "machine" ? "Remover agente de máquina" : "Remover colaborador"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{deleteTarget?.role === "machine"
|
||||
? "Revogar o acesso desconecta o aplicativo desktop e exige novo provisionamento."
|
||||
: "A remoção impede novos acessos, mas não afeta registros históricos de tickets."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 text-sm text-neutral-600">
|
||||
<p>
|
||||
Confirme a exclusão de <span className="font-medium text-neutral-900">{deleteTarget?.name || deleteTarget?.email}</span>.
|
||||
</p>
|
||||
{deleteTarget?.role === "machine" ? (
|
||||
<p>
|
||||
A máquina correspondente perderá imediatamente o token ativo e voltará para a tela de provisionamento.
|
||||
</p>
|
||||
) : (
|
||||
<p>Esse usuário não poderá mais acessar o painel até receber um novo convite.</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteUserId(null)} disabled={isDeletingUser}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDeleteUser} disabled={isDeletingUser}>
|
||||
{isDeletingUser
|
||||
? deleteTarget?.role === "machine"
|
||||
? "Removendo agente..."
|
||||
: "Removendo..."
|
||||
: deleteTarget?.role === "machine"
|
||||
? "Remover agente"
|
||||
: "Remover"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={revokeDialogInviteId !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setRevokeDialogInviteId(null)
|
||||
setRevokingId(null)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Revogar convite</DialogTitle>
|
||||
<DialogDescription>
|
||||
O link atual será invalidado imediatamente. Você pode gerar um novo convite a qualquer momento.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 text-sm text-neutral-600">
|
||||
<p>
|
||||
Deseja revogar o convite enviado para
|
||||
{" "}
|
||||
<span className="font-medium text-neutral-900">{revokeCandidate?.email}</span>?
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500">Ação irreversível — o convidado verá o link expirado.</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRevokeDialogInviteId(null)} disabled={revokingId !== null}>
|
||||
Manter convite
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleRevokeConfirmed}
|
||||
disabled={revokingId === revokeCandidate?.id}
|
||||
>
|
||||
{revokingId === revokeCandidate?.id ? "Revogando..." : "Revogar convite"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,16 @@ function statusVariant(status: InviteStatus) {
|
|||
return "outline"
|
||||
}
|
||||
|
||||
function roleLabel(role: RoleOption) {
|
||||
const labels: Record<RoleOption, string> = {
|
||||
admin: "Administrador",
|
||||
manager: "Gestor",
|
||||
agent: "Agente",
|
||||
collaborator: "Colaborador",
|
||||
}
|
||||
return labels[role] ?? role
|
||||
}
|
||||
|
||||
export function InviteAcceptForm({ invite }: { invite: InviteSummary }) {
|
||||
const router = useRouter()
|
||||
const [name, setName] = useState(invite.name ?? "")
|
||||
|
|
@ -105,7 +115,7 @@ export function InviteAcceptForm({ invite }: { invite: InviteSummary }) {
|
|||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<Badge variant={statusVariant(invite.status)} className="rounded-full px-3 py-1 text-xs uppercase tracking-wide">
|
||||
<Badge variant={statusVariant(invite.status)} className="rounded-full px-3 py-1 text-xs font-medium">
|
||||
{statusLabel(invite.status)}
|
||||
</Badge>
|
||||
<div className="space-y-1">
|
||||
|
|
@ -113,7 +123,7 @@ export function InviteAcceptForm({ invite }: { invite: InviteSummary }) {
|
|||
Convite direcionado para <span className="font-semibold text-neutral-900">{invite.email}</span>
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500">
|
||||
Papel previsto: <span className="uppercase text-neutral-700">{invite.role}</span> • Tenant: <span className="uppercase text-neutral-700">{invite.tenantId}</span>
|
||||
Acesso: <span className="text-neutral-700">{roleLabel(invite.role)}</span> • Espaço: <span className="text-neutral-700">{invite.tenantId}</span>
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500">Válido até {formattedExpiry}</p>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue