feat: refine admin access management
This commit is contained in:
parent
dded6d1927
commit
a69d37a672
9 changed files with 265 additions and 83 deletions
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue