feat: refine admin access management

This commit is contained in:
Esdras Renan 2025-10-18 01:32:19 -03:00
parent dded6d1927
commit a69d37a672
9 changed files with 265 additions and 83 deletions

View file

@ -22,6 +22,7 @@ import {
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { ROLE_OPTIONS, type RoleOption } from "@/lib/authz"
import { canReactivateInvite as canReactivateInvitePolicy } from "@/lib/invite-policies"
type AdminRole = RoleOption | "machine"
const NO_COMPANY_ID = "__none__"
@ -84,6 +85,20 @@ function formatRole(role: string) {
return ROLE_LABELS[key] ?? role
}
function formatMachinePersona(persona: string | null | undefined) {
const normalized = persona?.toLowerCase?.() ?? ""
if (normalized === "manager") return "Gestor"
if (normalized === "collaborator") return "Colaborador"
return "Sem persona"
}
function machinePersonaBadgeVariant(persona: string | null | undefined) {
const normalized = persona?.toLowerCase?.() ?? ""
if (normalized === "manager") return "secondary" as const
if (normalized === "collaborator") return "outline" as const
return "outline" as const
}
function formatTenantLabel(tenantId: string, defaultTenantId: string) {
if (!tenantId) return "Principal"
if (tenantId === defaultTenantId) return "Principal"
@ -147,13 +162,6 @@ function isRestrictedRole(role?: string | null) {
return normalized === "admin" || normalized === "agent"
}
function canReactivateInvite(invite: AdminInvite): boolean {
if (invite.status !== "revoked" || !invite.revokedAt) return false
const revokedDate = new Date(invite.revokedAt)
const limit = Date.now() - 7 * 24 * 60 * 60 * 1000
return revokedDate.getTime() > limit
}
export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, defaultTenantId, viewerRole }: Props) {
const [users, setUsers] = useState<AdminUser[]>(initialUsers)
const [invites, setInvites] = useState<AdminInvite[]>(initialInvites)
@ -220,6 +228,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
const [teamSearch, setTeamSearch] = useState("")
const [teamRoleFilter, setTeamRoleFilter] = useState<"all" | RoleOption>("all")
const [machineSearch, setMachineSearch] = useState("")
const [machinePersonaFilter, setMachinePersonaFilter] = useState<"all" | "manager" | "collaborator" | "unassigned">("all")
const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [createForm, setCreateForm] = useState({
name: "",
@ -250,15 +259,21 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
const filteredMachineUsers = useMemo(() => {
const term = machineSearch.trim().toLowerCase()
if (!term) return machineUsers
return machineUsers.filter((user) => {
const persona = (user.machinePersona ?? "unassigned").toLowerCase()
if (machinePersonaFilter !== "all") {
if (machinePersonaFilter === "unassigned" && persona !== "unassigned") return false
if (machinePersonaFilter !== "unassigned" && persona !== machinePersonaFilter) return false
}
if (!term) return true
return (
(user.name ?? "").toLowerCase().includes(term) ||
user.email.toLowerCase().includes(term) ||
(user.machinePersona ?? "").toLowerCase().includes(term)
persona.includes(term) ||
(extractMachineId(user.email) ?? "").toLowerCase().includes(term)
)
})
}, [machineUsers, machineSearch])
}, [machineUsers, machinePersonaFilter, machineSearch])
useEffect(() => {
void (async () => {
@ -394,7 +409,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
}
async function handleReactivate(invite: AdminInvite) {
if (!canReactivateInvite(invite)) return
if (!canReactivateInvitePolicy(invite)) return
if (!canManageInvite(invite.role)) {
toast.error("Você não pode reativar convites deste papel")
return
@ -883,16 +898,41 @@ async function handleDeleteUser() {
</div>
</div>
</div>
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex flex-col gap-3 rounded-xl border border-slate-200 bg-white p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
<div className="relative w-full sm:max-w-xs">
<IconSearch className="text-muted-foreground pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2" />
<Input
value={machineSearch}
onChange={(event) => setMachineSearch(event.target.value)}
placeholder="Buscar por hostname ou e-mail técnico"
placeholder="Buscar por hostname, e-mail ou persona"
className="h-9 pl-9"
/>
</div>
<div className="flex flex-wrap items-center gap-3">
<Select value={machinePersonaFilter} onValueChange={(value) => setMachinePersonaFilter(value as typeof machinePersonaFilter)}>
<SelectTrigger className="h-9 w-full sm:w-56">
<SelectValue placeholder="Todas as personas" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas as personas</SelectItem>
<SelectItem value="manager">Gestor</SelectItem>
<SelectItem value="collaborator">Colaborador</SelectItem>
<SelectItem value="unassigned">Sem persona</SelectItem>
</SelectContent>
</Select>
{(machineSearch.trim().length > 0 || machinePersonaFilter !== "all") ? (
<Button
variant="ghost"
size="sm"
onClick={() => {
setMachineSearch("")
setMachinePersonaFilter("all")
}}
>
Limpar filtros
</Button>
) : null}
</div>
</div>
<Card>
<CardHeader>
@ -917,7 +957,15 @@ async function handleDeleteUser() {
<tr key={user.id} className="hover:bg-slate-50">
<td className="py-3 pr-4 font-medium text-neutral-800">{user.name || "Máquina"}</td>
<td className="py-3 pr-4 text-neutral-600">{user.email}</td>
<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-600">
{user.machinePersona ? (
<Badge variant={machinePersonaBadgeVariant(user.machinePersona)} className="rounded-full px-3 py-1 text-xs font-medium">
{formatMachinePersona(user.machinePersona)}
</Badge>
) : (
<span className="text-neutral-500">Sem persona</span>
)}
</td>
<td className="py-3 pr-4 text-neutral-500">{formatDate(user.createdAt)}</td>
<td className="py-3">
<div className="flex flex-wrap gap-2">
@ -964,9 +1012,9 @@ async function handleDeleteUser() {
<CardContent>
<form
onSubmit={handleInviteSubmit}
className="grid gap-4 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_160px_160px_160px_auto]"
className="grid gap-4 md:grid-cols-2 xl:grid-cols-[minmax(0,2.4fr)_minmax(0,2fr)_minmax(0,1.2fr)_minmax(0,1.6fr)_minmax(0,1.2fr)_auto]"
>
<div className="grid gap-2">
<div className="grid gap-2 md:col-span-2 xl:col-auto">
<Label htmlFor="invite-email">E-mail corporativo</Label>
<Input
id="invite-email"
@ -979,7 +1027,7 @@ async function handleDeleteUser() {
autoComplete="off"
/>
</div>
<div className="grid gap-2">
<div className="grid gap-2 md:col-span-2 xl:col-auto">
<Label htmlFor="invite-name">Nome</Label>
<Input
id="invite-name"
@ -989,10 +1037,10 @@ async function handleDeleteUser() {
autoComplete="off"
/>
</div>
<div className="grid gap-2">
<div className="grid gap-2 md:col-span-1 xl:col-auto">
<Label>Papel</Label>
<Select value={role} onValueChange={(value) => setRole(value as RoleOption)}>
<SelectTrigger id="invite-role" className="h-9">
<SelectTrigger id="invite-role" className="h-9 w-full">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
@ -1004,22 +1052,23 @@ async function handleDeleteUser() {
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<div className="grid gap-2 md:col-span-2 xl:col-auto">
<Label htmlFor="invite-tenant">Espaço (ID interno)</Label>
<Input
id="invite-tenant"
value={tenantId}
onChange={(event) => setTenantId(event.target.value)}
placeholder="ex.: principal"
className="w-full"
/>
<p className="text-xs text-neutral-500">
Use este campo apenas se trabalhar com múltiplos espaços de clientes. Caso contrário, mantenha o valor padrão.
</p>
</div>
<div className="grid gap-2">
<div className="grid gap-2 md:col-span-1 xl:col-auto">
<Label>Expira em</Label>
<Select value={expiresInDays} onValueChange={setExpiresInDays}>
<SelectTrigger id="invite-expiration">
<SelectTrigger id="invite-expiration" className="w-full">
<SelectValue placeholder="7 dias" />
</SelectTrigger>
<SelectContent>
@ -1029,7 +1078,7 @@ async function handleDeleteUser() {
</SelectContent>
</Select>
</div>
<div className="flex items-end">
<div className="flex items-end md:col-span-2 xl:col-auto xl:justify-end">
<Button type="submit" disabled={isPending} className="w-full">
{isPending ? "Gerando..." : "Gerar convite"}
</Button>
@ -1101,7 +1150,7 @@ async function handleDeleteUser() {
{revokingId === invite.id ? "Revogando..." : "Revogar"}
</Button>
) : null}
{invite.status === "revoked" && canReactivateInvite(invite) && canManageInvite(invite.role) ? (
{invite.status === "revoked" && canReactivateInvitePolicy(invite) && canManageInvite(invite.role) ? (
<Button
variant="outline"
size="sm"