Allow staff access to admin UI with scoped permissions
This commit is contained in:
parent
d6956cd99d
commit
cf31158a9e
11 changed files with 155 additions and 52 deletions
|
|
@ -1,7 +1,7 @@
|
|||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { useEffect, useMemo, useState, useTransition } from "react"
|
||||
import { useCallback, useEffect, useMemo, useState, useTransition } from "react"
|
||||
|
||||
import { toast } from "sonner"
|
||||
|
||||
|
|
@ -67,6 +67,7 @@ type Props = {
|
|||
initialInvites: AdminInvite[]
|
||||
roleOptions: readonly AdminRole[]
|
||||
defaultTenantId: string
|
||||
viewerRole: string
|
||||
}
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
|
|
@ -140,6 +141,11 @@ function extractMachineId(email: string): string | null {
|
|||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
function isRestrictedRole(role?: string | null) {
|
||||
const normalized = (role ?? "").toLowerCase()
|
||||
return normalized === "admin" || normalized === "agent"
|
||||
}
|
||||
|
||||
function canReactivateInvite(invite: AdminInvite): boolean {
|
||||
if (invite.status !== "revoked" || !invite.revokedAt) return false
|
||||
const revokedDate = new Date(invite.revokedAt)
|
||||
|
|
@ -147,7 +153,7 @@ function canReactivateInvite(invite: AdminInvite): boolean {
|
|||
return revokedDate.getTime() > limit
|
||||
}
|
||||
|
||||
export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, defaultTenantId }: Props) {
|
||||
export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, defaultTenantId, viewerRole }: Props) {
|
||||
const [users, setUsers] = useState<AdminUser[]>(initialUsers)
|
||||
const [invites, setInvites] = useState<AdminInvite[]>(initialInvites)
|
||||
const [companies, setCompanies] = useState<CompanyOption[]>([])
|
||||
|
|
@ -189,6 +195,10 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
() => invites.find((invite) => invite.id === revokeDialogInviteId) ?? null,
|
||||
[invites, revokeDialogInviteId]
|
||||
)
|
||||
const viewerRoleNormalized = viewerRole?.toLowerCase?.() ?? "agent"
|
||||
const viewerIsAdmin = viewerRoleNormalized === "admin"
|
||||
const canManageUser = useCallback((role?: string | null) => viewerIsAdmin || !isRestrictedRole(role), [viewerIsAdmin])
|
||||
const canManageInvite = useCallback((role: RoleOption) => viewerIsAdmin || !["admin", "agent"].includes(role), [viewerIsAdmin])
|
||||
|
||||
const normalizedRoles = useMemo<readonly AdminRole[]>(() => {
|
||||
return (roleOptions && roleOptions.length > 0 ? roleOptions : ROLE_OPTIONS) as readonly AdminRole[]
|
||||
|
|
@ -197,10 +207,11 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
const unique = new Set<RoleOption>()
|
||||
normalizedRoles.forEach((roleOption) => {
|
||||
const coerced = coerceRole(roleOption)
|
||||
if (!viewerIsAdmin && isRestrictedRole(coerced)) return
|
||||
unique.add(coerced)
|
||||
})
|
||||
return Array.from(unique)
|
||||
}, [normalizedRoles])
|
||||
}, [normalizedRoles, viewerIsAdmin])
|
||||
const teamUsers = useMemo(() => users.filter((user) => user.role !== "machine"), [users])
|
||||
const machineUsers = useMemo(() => users.filter((user) => user.role === "machine"), [users])
|
||||
|
||||
|
|
@ -297,6 +308,12 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
return
|
||||
}
|
||||
|
||||
if (!canManageInvite(revokeCandidate.role)) {
|
||||
toast.error("Você não pode revogar convites deste papel")
|
||||
setRevokeDialogInviteId(null)
|
||||
return
|
||||
}
|
||||
|
||||
setRevokingId(revokeCandidate.id)
|
||||
try {
|
||||
const response = await fetch(`/api/admin/invites/${revokeCandidate.id}`, {
|
||||
|
|
@ -325,6 +342,10 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
|
||||
async function handleReactivate(invite: AdminInvite) {
|
||||
if (!canReactivateInvite(invite)) return
|
||||
if (!canManageInvite(invite.role)) {
|
||||
toast.error("Você não pode reativar convites deste papel")
|
||||
return
|
||||
}
|
||||
setReactivatingId(invite.id)
|
||||
try {
|
||||
const response = await fetch(`/api/admin/invites/${invite.id}`, {
|
||||
|
|
@ -433,6 +454,10 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
|
||||
async function handleResetPassword() {
|
||||
if (!editUser) return
|
||||
if (!canManageUser(editUser.role)) {
|
||||
toast.error("Você não pode gerar senha para este usuário")
|
||||
return
|
||||
}
|
||||
setIsResettingPassword(true)
|
||||
toast.loading("Gerando nova senha...", { id: "reset-password" })
|
||||
try {
|
||||
|
|
@ -457,12 +482,18 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
}
|
||||
|
||||
const isMachineEditing = editUser?.role === "machine"
|
||||
const editingRestricted = editUser ? !canManageUser(editUser.role) : false
|
||||
const companyOptions = useMemo(
|
||||
() => [{ id: NO_COMPANY_ID, name: "Sem empresa vinculada" }, ...companies],
|
||||
[companies]
|
||||
)
|
||||
async function handleDeleteUser() {
|
||||
async function handleDeleteUser() {
|
||||
if (!deleteTarget) return
|
||||
if (!canManageUser(deleteTarget.role)) {
|
||||
toast.error("Você não pode remover esse usuário")
|
||||
setDeleteUserId(null)
|
||||
return
|
||||
}
|
||||
setIsDeletingUser(true)
|
||||
const isMachine = deleteTarget.role === "machine"
|
||||
|
||||
|
|
@ -547,14 +578,26 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
<td className="py-3 pr-4 text-neutral-500">{formatDate(user.createdAt)}</td>
|
||||
<td className="py-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setEditUserId(user.id)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!canManageUser(user.role)}
|
||||
onClick={() => {
|
||||
if (!canManageUser(user.role)) return
|
||||
setEditUserId(user.id)
|
||||
}}
|
||||
>
|
||||
Editar
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-600 hover:bg-red-500/10"
|
||||
onClick={() => setDeleteUserId(user.id)}
|
||||
disabled={!canManageUser(user.role)}
|
||||
onClick={() => {
|
||||
if (!canManageUser(user.role)) return
|
||||
setDeleteUserId(user.id)
|
||||
}}
|
||||
>
|
||||
Remover
|
||||
</Button>
|
||||
|
|
@ -806,7 +849,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
<Button variant="outline" size="sm" onClick={() => handleCopy(invite.inviteUrl)}>
|
||||
Copiar link
|
||||
</Button>
|
||||
{invite.status === "pending" ? (
|
||||
{invite.status === "pending" && canManageInvite(invite.role) ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
|
@ -817,7 +860,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
{revokingId === invite.id ? "Revogando..." : "Revogar"}
|
||||
</Button>
|
||||
) : null}
|
||||
{invite.status === "revoked" && canReactivateInvite(invite) ? (
|
||||
{invite.status === "revoked" && canReactivateInvite(invite) && canManageInvite(invite.role) ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
|
@ -855,6 +898,11 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
|
||||
{editUser ? (
|
||||
<form onSubmit={handleSaveUser} className="space-y-6">
|
||||
{editingRestricted ? (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-700">
|
||||
Você pode visualizar este perfil, mas apenas administradores podem alterá-lo.
|
||||
</div>
|
||||
) : null}
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Nome</Label>
|
||||
|
|
@ -862,7 +910,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
value={editForm.name}
|
||||
onChange={(event) => setEditForm((prev) => ({ ...prev, name: event.target.value }))}
|
||||
placeholder="Nome completo"
|
||||
disabled={isSavingUser || isMachineEditing}
|
||||
disabled={isSavingUser || isMachineEditing || editingRestricted}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -872,7 +920,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
value={editForm.email}
|
||||
onChange={(event) => setEditForm((prev) => ({ ...prev, email: event.target.value }))}
|
||||
type="email"
|
||||
disabled={isSavingUser || isMachineEditing}
|
||||
disabled={isSavingUser || isMachineEditing || editingRestricted}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -881,7 +929,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
<Select
|
||||
value={editForm.role}
|
||||
onValueChange={(value) => setEditForm((prev) => ({ ...prev, role: value as RoleOption }))}
|
||||
disabled={isSavingUser || isMachineEditing}
|
||||
disabled={isSavingUser || isMachineEditing || editingRestricted}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione" />
|
||||
|
|
@ -901,7 +949,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
value={editForm.tenantId}
|
||||
onChange={(event) => setEditForm((prev) => ({ ...prev, tenantId: event.target.value }))}
|
||||
placeholder="tenant-atlas"
|
||||
disabled={isSavingUser || isMachineEditing}
|
||||
disabled={isSavingUser || isMachineEditing || editingRestricted}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
|
|
@ -911,7 +959,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
onValueChange={(value) =>
|
||||
setEditForm((prev) => ({ ...prev, companyId: value === NO_COMPANY_ID ? "" : value }))
|
||||
}
|
||||
disabled={isSavingUser}
|
||||
disabled={isSavingUser || editingRestricted}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione" />
|
||||
|
|
@ -937,11 +985,11 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
<p className="font-medium text-neutral-900">Gerar nova senha</p>
|
||||
<p className="text-xs text-neutral-500">Uma senha temporária será exibida abaixo. Compartilhe com o usuário e peça para trocá-la no primeiro acesso.</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={handleResetPassword} disabled={isResettingPassword}>
|
||||
{isResettingPassword ? "Gerando..." : "Gerar senha"}
|
||||
</Button>
|
||||
</div>
|
||||
{passwordPreview ? (
|
||||
<Button type="button" variant="outline" onClick={handleResetPassword} disabled={isResettingPassword}>
|
||||
{isResettingPassword ? "Gerando..." : "Gerar senha"}
|
||||
</Button>
|
||||
</div>
|
||||
{passwordPreview ? (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3 rounded-md border border-slate-300 bg-white px-3 py-2">
|
||||
<code className="text-sm font-semibold text-neutral-900">{passwordPreview}</code>
|
||||
<Button
|
||||
|
|
@ -962,7 +1010,7 @@ export function AdminUsersManager({ initialUsers, initialInvites, roleOptions, d
|
|||
<Button type="button" variant="outline" onClick={() => setEditUserId(null)} disabled={isSavingUser}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSavingUser || isMachineEditing} className="sm:ml-auto">
|
||||
<Button type="submit" disabled={isSavingUser || isMachineEditing || editingRestricted} className="sm:ml-auto">
|
||||
{isSavingUser ? "Salvando..." : "Salvar alterações"}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue