feat: expand admin companies and users modules
This commit is contained in:
parent
a043b1203c
commit
2e3b46a7b5
31 changed files with 5626 additions and 2003 deletions
|
|
@ -224,6 +224,15 @@ type MachineInventory = {
|
|||
collaborator?: { email?: string; name?: string; role?: string }
|
||||
}
|
||||
|
||||
type MachineRemoteAccess = {
|
||||
provider: string | null
|
||||
identifier: string | null
|
||||
url: string | null
|
||||
notes: string | null
|
||||
lastVerifiedAt: number | null
|
||||
metadata: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
function collectInitials(name: string): string {
|
||||
const words = name.split(/\s+/).filter(Boolean)
|
||||
if (words.length === 0) return "?"
|
||||
|
|
@ -267,6 +276,59 @@ function readNumber(record: Record<string, unknown>, ...keys: string[]): number
|
|||
return undefined
|
||||
}
|
||||
|
||||
export function normalizeMachineRemoteAccess(raw: unknown): MachineRemoteAccess | null {
|
||||
if (!raw) return null
|
||||
if (typeof raw === "string") {
|
||||
const trimmed = raw.trim()
|
||||
if (!trimmed) return null
|
||||
const isUrl = /^https?:\/\//i.test(trimmed)
|
||||
return {
|
||||
provider: null,
|
||||
identifier: isUrl ? null : trimmed,
|
||||
url: isUrl ? trimmed : null,
|
||||
notes: null,
|
||||
lastVerifiedAt: null,
|
||||
metadata: null,
|
||||
}
|
||||
}
|
||||
const record = toRecord(raw)
|
||||
if (!record) return null
|
||||
const provider = readString(record, "provider", "tool", "vendor", "name") ?? null
|
||||
const identifier = readString(record, "identifier", "code", "id", "accessId") ?? null
|
||||
const url = readString(record, "url", "link", "remoteUrl", "console", "viewer") ?? null
|
||||
const notes = readString(record, "notes", "note", "description", "obs") ?? null
|
||||
const timestampCandidate =
|
||||
readNumber(record, "lastVerifiedAt", "verifiedAt", "checkedAt", "updatedAt") ??
|
||||
parseDateish(record["lastVerifiedAt"] ?? record["verifiedAt"] ?? record["checkedAt"] ?? record["updatedAt"])
|
||||
const lastVerifiedAt = timestampCandidate instanceof Date ? timestampCandidate.getTime() : timestampCandidate ?? null
|
||||
return {
|
||||
provider,
|
||||
identifier,
|
||||
url,
|
||||
notes,
|
||||
lastVerifiedAt,
|
||||
metadata: record,
|
||||
}
|
||||
}
|
||||
|
||||
function formatRemoteAccessMetadataKey(key: string) {
|
||||
return key
|
||||
.replace(/[_.-]+/g, " ")
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase())
|
||||
}
|
||||
|
||||
function formatRemoteAccessMetadataValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return ""
|
||||
if (typeof value === "string") return value
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value)
|
||||
if (value instanceof Date) return formatAbsoluteDateTime(value)
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
function readText(record: Record<string, unknown>, ...keys: string[]): string | undefined {
|
||||
const stringValue = readString(record, ...keys)
|
||||
if (stringValue) return stringValue
|
||||
|
|
@ -663,15 +725,23 @@ export type MachinesQueryItem = {
|
|||
postureAlerts?: Array<Record<string, unknown>> | null
|
||||
lastPostureAt?: number | null
|
||||
linkedUsers?: Array<{ id: string; email: string; name: string }>
|
||||
remoteAccess: MachineRemoteAccess | null
|
||||
}
|
||||
|
||||
export function normalizeMachineItem(raw: Record<string, unknown>): MachinesQueryItem {
|
||||
const base = raw as MachinesQueryItem
|
||||
return {
|
||||
...base,
|
||||
remoteAccess: normalizeMachineRemoteAccess(raw["remoteAccess"]) ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function useMachinesQuery(tenantId: string): MachinesQueryItem[] {
|
||||
return (
|
||||
(useQuery(api.machines.listByTenant, {
|
||||
tenantId,
|
||||
includeMetadata: true,
|
||||
}) ?? []) as MachinesQueryItem[]
|
||||
)
|
||||
const result = useQuery(api.machines.listByTenant, {
|
||||
tenantId,
|
||||
includeMetadata: true,
|
||||
}) as Array<Record<string, unknown>> | undefined
|
||||
return useMemo(() => (result ?? []).map((item) => normalizeMachineItem(item)), [result])
|
||||
}
|
||||
|
||||
const DEFAULT_OFFLINE_THRESHOLD_MS = 10 * 60 * 1000
|
||||
|
|
@ -975,11 +1045,11 @@ function OsIcon({ osName }: { osName?: string | null }) {
|
|||
return <Monitor className="size-4 text-black" />
|
||||
}
|
||||
|
||||
export function AdminMachinesOverview({ tenantId }: { tenantId: string }) {
|
||||
export function AdminMachinesOverview({ tenantId, initialCompanyFilterSlug = "all" }: { tenantId: string; initialCompanyFilterSlug?: string }) {
|
||||
const machines = useMachinesQuery(tenantId)
|
||||
const [q, setQ] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all")
|
||||
const [companyFilterSlug, setCompanyFilterSlug] = useState<string>("all")
|
||||
const [companyFilterSlug, setCompanyFilterSlug] = useState<string>(initialCompanyFilterSlug)
|
||||
const [companySearch, setCompanySearch] = useState<string>("")
|
||||
const [onlyAlerts, setOnlyAlerts] = useState<boolean>(false)
|
||||
const { convexUserId } = useAuth()
|
||||
|
|
@ -1599,6 +1669,53 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
|
||||
const personaLabel = collaborator?.role === "manager" ? "Gestor" : "Colaborador"
|
||||
|
||||
const remoteAccess = machine?.remoteAccess ?? null
|
||||
const remoteAccessMetadataEntries = useMemo(() => {
|
||||
if (!remoteAccess?.metadata) return [] as Array<[string, unknown]>
|
||||
const knownKeys = new Set([
|
||||
"provider",
|
||||
"tool",
|
||||
"vendor",
|
||||
"name",
|
||||
"identifier",
|
||||
"code",
|
||||
"id",
|
||||
"accessId",
|
||||
"url",
|
||||
"link",
|
||||
"remoteUrl",
|
||||
"console",
|
||||
"viewer",
|
||||
"notes",
|
||||
"note",
|
||||
"description",
|
||||
"obs",
|
||||
"lastVerifiedAt",
|
||||
"verifiedAt",
|
||||
"checkedAt",
|
||||
"updatedAt",
|
||||
])
|
||||
return Object.entries(remoteAccess.metadata)
|
||||
.filter(([key, value]) => {
|
||||
if (knownKeys.has(key)) return false
|
||||
if (value === null || value === undefined) return false
|
||||
if (typeof value === "string" && value.trim().length === 0) return false
|
||||
return true
|
||||
})
|
||||
}, [remoteAccess])
|
||||
const remoteAccessLastVerifiedDate = useMemo(() => {
|
||||
if (!remoteAccess?.lastVerifiedAt) return null
|
||||
const date = new Date(remoteAccess.lastVerifiedAt)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}, [remoteAccess?.lastVerifiedAt])
|
||||
const hasRemoteAccess = Boolean(
|
||||
remoteAccess?.identifier ||
|
||||
remoteAccess?.url ||
|
||||
remoteAccess?.notes ||
|
||||
remoteAccess?.provider ||
|
||||
remoteAccessMetadataEntries.length > 0
|
||||
)
|
||||
|
||||
const summaryChips = useMemo(() => {
|
||||
const chips: Array<{ key: string; label: string; value: string; icon: ReactNode; tone?: "warning" | "muted" }> = []
|
||||
const osName = osNameDisplay || "Sistema desconhecido"
|
||||
|
|
@ -1652,8 +1769,18 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
icon: <ShieldCheck className="size-4 text-neutral-500" />,
|
||||
})
|
||||
}
|
||||
if (remoteAccess && (remoteAccess.identifier || remoteAccess.url)) {
|
||||
const value = remoteAccess.identifier ?? remoteAccess.url ?? "—"
|
||||
const label = remoteAccess.provider ? `Acesso (${remoteAccess.provider})` : "Acesso remoto"
|
||||
chips.push({
|
||||
key: "remote-access",
|
||||
label,
|
||||
value,
|
||||
icon: <Key className="size-4 text-neutral-500" />,
|
||||
})
|
||||
}
|
||||
return chips
|
||||
}, [osNameDisplay, machine?.osVersion, machine?.architecture, windowsVersionLabel, windowsBuildLabel, windowsActivationStatus, primaryGpu, collaborator?.email, collaborator?.name, personaLabel, machine?.osName])
|
||||
}, [osNameDisplay, machine?.osVersion, machine?.architecture, windowsVersionLabel, windowsBuildLabel, windowsActivationStatus, primaryGpu, collaborator?.email, collaborator?.name, personaLabel, machine?.osName, remoteAccess])
|
||||
|
||||
const companyName = (() => {
|
||||
if (!companies || !machine?.companySlug) return machine?.companySlug ?? null
|
||||
|
|
@ -1782,6 +1909,17 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
}
|
||||
}
|
||||
|
||||
const handleCopyRemoteIdentifier = useCallback(async () => {
|
||||
if (!remoteAccess?.identifier) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(remoteAccess.identifier)
|
||||
toast.success("Identificador de acesso remoto copiado.")
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
toast.error("Não foi possível copiar o identificador.")
|
||||
}
|
||||
}, [remoteAccess?.identifier])
|
||||
|
||||
return (
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader className="gap-1">
|
||||
|
|
@ -1861,6 +1999,61 @@ export function MachineDetails({ machine }: MachineDetailsProps) {
|
|||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{hasRemoteAccess ? (
|
||||
<div className="rounded-lg border border-indigo-100 bg-indigo-50/60 px-4 py-3 text-xs sm:text-sm text-slate-700">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] font-semibold uppercase tracking-wide text-indigo-600">
|
||||
<Key className="size-4" />
|
||||
Acesso remoto
|
||||
{remoteAccess?.provider ? (
|
||||
<Badge variant="outline" className="border-indigo-200 bg-white text-[11px] font-semibold text-indigo-700">
|
||||
{remoteAccess.provider}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{remoteAccess?.identifier ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold text-slate-900">{remoteAccess.identifier}</span>
|
||||
<Button variant="ghost" size="sm" className="h-7 gap-2 px-2" onClick={handleCopyRemoteIdentifier}>
|
||||
<ClipboardCopy className="size-3.5" /> Copiar ID
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{remoteAccess?.url ? (
|
||||
<a
|
||||
href={remoteAccess.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-2 text-indigo-700 underline-offset-4 hover:underline"
|
||||
>
|
||||
Abrir console remoto
|
||||
</a>
|
||||
) : null}
|
||||
{remoteAccess?.notes ? (
|
||||
<p className="text-[11px] text-slate-600">{remoteAccess.notes}</p>
|
||||
) : null}
|
||||
{remoteAccessLastVerifiedDate ? (
|
||||
<p className="text-[11px] text-slate-500">
|
||||
Atualizado {formatRelativeTime(remoteAccessLastVerifiedDate)}
|
||||
{" "}
|
||||
<span className="text-slate-400">({formatAbsoluteDateTime(remoteAccessLastVerifiedDate)})</span>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{remoteAccessMetadataEntries.length ? (
|
||||
<div className="mt-3 grid gap-2 text-[11px] text-slate-600 sm:grid-cols-2">
|
||||
{remoteAccessMetadataEntries.map(([key, value]) => (
|
||||
<div key={key} className="flex items-center justify-between gap-3 rounded-md border border-indigo-100 bg-white px-2 py-1">
|
||||
<span className="font-semibold text-slate-700">{formatRemoteAccessMetadataKey(key)}</span>
|
||||
<span className="truncate text-right text-slate-600">{formatRemoteAccessMetadataValue(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue