feat: integrar credenciais rustdesk aos acessos remotos

This commit is contained in:
Esdras Renan 2025-11-07 15:39:36 -03:00
parent 4079f67fcb
commit 07d631de40
5 changed files with 243 additions and 5 deletions

View file

@ -1991,6 +1991,8 @@ type RemoteAccessEntry = {
provider: string
identifier: string
url: string | null
username: string | null
password: string | null
notes: string | null
lastVerifiedAt: number | null
metadata: Record<string, unknown> | null
@ -2032,6 +2034,8 @@ function normalizeRemoteAccessEntry(raw: unknown): RemoteAccessEntry | null {
provider: "Remoto",
identifier: isUrl ? trimmed : trimmed,
url: isUrl ? trimmed : null,
username: null,
password: null,
notes: null,
lastVerifiedAt: null,
metadata: null,
@ -2059,6 +2063,19 @@ function normalizeRemoteAccessEntry(raw: unknown): RemoteAccessEntry | null {
null
const resolvedIdentifier = identifier ?? url ?? "Acesso remoto"
const notes = coerceString(record.notes) ?? coerceString(record.note) ?? coerceString(record.description) ?? coerceString(record.obs) ?? null
const username =
coerceString((record as Record<string, unknown>).username) ??
coerceString((record as Record<string, unknown>).user) ??
coerceString((record as Record<string, unknown>).login) ??
coerceString((record as Record<string, unknown>).email) ??
coerceString((record as Record<string, unknown>).account) ??
null
const password =
coerceString((record as Record<string, unknown>).password) ??
coerceString((record as Record<string, unknown>).pass) ??
coerceString((record as Record<string, unknown>).secret) ??
coerceString((record as Record<string, unknown>).pin) ??
null
const timestamp =
coerceNumber(record.lastVerifiedAt) ??
coerceNumber(record.verifiedAt) ??
@ -2075,6 +2092,8 @@ function normalizeRemoteAccessEntry(raw: unknown): RemoteAccessEntry | null {
provider,
identifier: resolvedIdentifier,
url,
username,
password,
notes,
lastVerifiedAt: timestamp,
metadata,
@ -2105,12 +2124,14 @@ export const updateRemoteAccess = mutation({
provider: v.optional(v.string()),
identifier: v.optional(v.string()),
url: v.optional(v.string()),
username: v.optional(v.string()),
password: v.optional(v.string()),
notes: v.optional(v.string()),
action: v.optional(v.string()),
entryId: v.optional(v.string()),
clear: v.optional(v.boolean()),
},
handler: async (ctx, { machineId, actorId, provider, identifier, url, notes, action, entryId, clear }) => {
handler: async (ctx, { machineId, actorId, provider, identifier, url, username, password, notes, action, entryId, clear }) => {
const machine = await ctx.db.get(machineId)
if (!machine) {
throw new ConvexError("Dispositivo não encontrada")
@ -2192,6 +2213,8 @@ export const updateRemoteAccess = mutation({
}
const cleanedNotes = notes?.trim() ? notes.trim() : null
const cleanedUsername = username?.trim() ? username.trim() : null
const cleanedPassword = password?.trim() ? password.trim() : null
const lastVerifiedAt = Date.now()
const targetEntryId =
coerceString(entryId) ??
@ -2205,12 +2228,16 @@ export const updateRemoteAccess = mutation({
provider: trimmedProvider,
identifier: trimmedIdentifier,
url: normalizedUrl,
username: cleanedUsername,
password: cleanedPassword,
notes: cleanedNotes,
lastVerifiedAt,
metadata: {
provider: trimmedProvider,
identifier: trimmedIdentifier,
url: normalizedUrl,
username: cleanedUsername,
password: cleanedPassword,
notes: cleanedNotes,
lastVerifiedAt,
},

View file

@ -13,6 +13,8 @@ const schema = z.object({
provider: z.string().optional(),
identifier: z.string().optional(),
url: z.string().optional(),
username: z.string().optional(),
password: z.string().optional(),
notes: z.string().optional(),
entryId: z.string().optional(),
action: z.enum(["save", "upsert", "clear", "delete", "remove"]).optional(),
@ -106,6 +108,8 @@ export async function POST(request: Request) {
if (provider) mutationArgs.provider = provider
if (identifier) mutationArgs.identifier = identifier
if (normalizedUrl !== undefined) mutationArgs.url = normalizedUrl
mutationArgs.username = (parsed.username ?? "").trim()
mutationArgs.password = (parsed.password ?? "").trim()
if (notes.length) mutationArgs.notes = notes
}

View file

@ -26,6 +26,9 @@ import {
RotateCcw,
AlertTriangle,
Key,
Eye,
EyeOff,
MonitorSmartphone,
Globe,
Apple,
Terminal,
@ -323,6 +326,8 @@ type DeviceRemoteAccessEntry = {
clientId: string
provider: string | null
identifier: string | null
username: string | null
password: string | null
url: string | null
notes: string | null
lastVerifiedAt: number | null
@ -332,6 +337,8 @@ type DeviceRemoteAccessEntry = {
export type DeviceRemoteAccess = {
provider: string | null
identifier: string | null
username: string | null
password: string | null
url: string | null
notes: string | null
lastVerifiedAt: number | null
@ -411,6 +418,8 @@ function normalizeDeviceRemoteAccessEntry(raw: unknown): DeviceRemoteAccessEntry
const identifier =
readString(record, "identifier", "code", "id", "accessId") ??
readString(record, "value", "label")
const username = readString(record, "username", "user", "login", "email", "account") ?? null
const password = readString(record, "password", "pass", "secret", "pin") ?? null
const url = readString(record, "url", "link", "remoteUrl", "console", "viewer") ?? null
const notes = readString(record, "notes", "note", "description", "obs") ?? null
const timestampCandidate =
@ -423,6 +432,8 @@ function normalizeDeviceRemoteAccessEntry(raw: unknown): DeviceRemoteAccessEntry
clientId: id ?? createRemoteAccessClientId(),
provider,
identifier: identifier ?? url ?? null,
username,
password,
url,
notes,
lastVerifiedAt,
@ -433,8 +444,8 @@ function normalizeDeviceRemoteAccessEntry(raw: unknown): DeviceRemoteAccessEntry
export function normalizeDeviceRemoteAccess(raw: unknown): DeviceRemoteAccess | null {
const entry = normalizeDeviceRemoteAccessEntry(raw)
if (!entry) return null
const { provider, identifier, url, notes, lastVerifiedAt, metadata } = entry
return { provider, identifier, url, notes, lastVerifiedAt, metadata }
const { provider, identifier, username, password, url, notes, lastVerifiedAt, metadata } = entry
return { provider, identifier, username, password, url, notes, lastVerifiedAt, metadata }
}
export function normalizeDeviceRemoteAccessList(raw: unknown): DeviceRemoteAccessEntry[] {
@ -464,6 +475,15 @@ const REMOTE_ACCESS_METADATA_IGNORED_KEYS = new Set([
"code",
"id",
"accessId",
"username",
"user",
"login",
"email",
"account",
"password",
"pass",
"secret",
"pin",
"url",
"link",
"remoteUrl",
@ -515,6 +535,25 @@ function readText(record: Record<string, unknown>, ...keys: string[]): string |
return undefined
}
function isRustDeskAccess(entry: DeviceRemoteAccessEntry | null | undefined) {
if (!entry) return false
const provider = (entry.provider ?? entry.metadata?.provider ?? "").toString().toLowerCase()
if (provider.includes("rustdesk")) return true
const url = (entry.url ?? entry.metadata?.url ?? "").toString().toLowerCase()
return url.includes("rustdesk")
}
function buildRustDeskUri(entry: DeviceRemoteAccessEntry) {
const identifier = (entry.identifier ?? "").replace(/\s+/g, "")
if (!identifier) return null
const params = new URLSearchParams()
if (entry.password) {
params.set("password", entry.password)
}
const query = params.toString()
return `rustdesk://${encodeURIComponent(identifier)}${query ? `?${query}` : ""}`
}
function parseWindowsInstallDate(value: unknown): Date | null {
if (!value) return null
if (value instanceof Date) return value
@ -2999,9 +3038,12 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
)
const [remoteAccessCustomProvider, setRemoteAccessCustomProvider] = useState("")
const [remoteAccessIdentifierInput, setRemoteAccessIdentifierInput] = useState("")
const [remoteAccessUsernameInput, setRemoteAccessUsernameInput] = useState("")
const [remoteAccessPasswordInput, setRemoteAccessPasswordInput] = useState("")
const [remoteAccessUrlInput, setRemoteAccessUrlInput] = useState("")
const [remoteAccessNotesInput, setRemoteAccessNotesInput] = useState("")
const [remoteAccessSaving, setRemoteAccessSaving] = useState(false)
const [visibleRemoteSecrets, setVisibleRemoteSecrets] = useState<Record<string, boolean>>({})
const editingRemoteAccess = useMemo(
() => remoteAccessEntries.find((entry) => entry.clientId === editingRemoteAccessClientId) ?? null,
[editingRemoteAccessClientId, remoteAccessEntries]
@ -3070,6 +3112,8 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
setRemoteAccessCustomProvider(providerName ?? "")
}
setRemoteAccessIdentifierInput(editingRemoteAccess?.identifier ?? "")
setRemoteAccessUsernameInput(editingRemoteAccess?.username ?? "")
setRemoteAccessPasswordInput(editingRemoteAccess?.password ?? "")
setRemoteAccessUrlInput(editingRemoteAccess?.url ?? "")
setRemoteAccessNotesInput(editingRemoteAccess?.notes ?? "")
}, [remoteAccessDialog, editingRemoteAccess])
@ -3080,6 +3124,8 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
setRemoteAccessProviderOption(REMOTE_ACCESS_PROVIDERS[0].value)
setRemoteAccessCustomProvider("")
setRemoteAccessIdentifierInput("")
setRemoteAccessUsernameInput("")
setRemoteAccessPasswordInput("")
setRemoteAccessUrlInput("")
setRemoteAccessNotesInput("")
}
@ -3087,6 +3133,7 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
useEffect(() => {
setShowAllWindowsSoftware(false)
setVisibleRemoteSecrets({})
}, [device?.id])
const displayedWindowsSoftware = useMemo(
@ -3149,6 +3196,8 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
return
}
const username = remoteAccessUsernameInput.trim()
const password = remoteAccessPasswordInput.trim()
let normalizedUrl: string | undefined
const rawUrl = remoteAccessUrlInput.trim()
if (rawUrl.length > 0) {
@ -3191,6 +3240,8 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
deviceId: device.id,
provider: providerName,
identifier,
username,
password,
url: normalizedUrl,
notes: notes.length ? notes : undefined,
action: "upsert",
@ -3220,6 +3271,8 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
remoteAccessProviderOption,
remoteAccessCustomProvider,
remoteAccessIdentifierInput,
remoteAccessUsernameInput,
remoteAccessPasswordInput,
remoteAccessUrlInput,
remoteAccessNotesInput,
editingRemoteAccess,
@ -3337,6 +3390,41 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
}
}, [])
const handleCopyRemoteCredential = useCallback(async (value: string | null | undefined, label: string) => {
if (!value) return
try {
await navigator.clipboard.writeText(value)
toast.success(`${label} copiado.`)
} catch (error) {
console.error(error)
toast.error(`Não foi possível copiar ${label.toLowerCase()}.`)
}
}, [])
const toggleRemoteSecret = useCallback((clientId: string) => {
setVisibleRemoteSecrets((prev) => ({ ...prev, [clientId]: !prev[clientId] }))
}, [])
const handleRustDeskConnect = useCallback((entry: DeviceRemoteAccessEntry) => {
if (!entry) return
const link = buildRustDeskUri(entry)
if (!link) {
toast.error("Não foi possível montar o link do RustDesk (ID ou senha ausentes).")
return
}
if (typeof window === "undefined") {
toast.error("A conexão direta só funciona no navegador.")
return
}
try {
window.location.href = link
toast.success("Abrindo o RustDesk...")
} catch (error) {
console.error(error)
toast.error("Não foi possível acionar o RustDesk neste dispositivo.")
}
}, [])
// Exportação individual (colunas personalizadas)
const [isSingleExportOpen, setIsSingleExportOpen] = useState(false)
const [singleExporting, setSingleExporting] = useState(false)
@ -3804,6 +3892,8 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
entry.lastVerifiedAt && Number.isFinite(entry.lastVerifiedAt)
? new Date(entry.lastVerifiedAt)
: null
const isRustDesk = isRustDeskAccess(entry)
const secretVisible = Boolean(visibleRemoteSecrets[entry.clientId])
return (
<div key={entry.clientId} className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-xs sm:text-sm text-slate-700">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
@ -3828,6 +3918,51 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
</Button>
) : null}
</div>
{entry.username || entry.password ? (
<div className="flex flex-col gap-1">
{entry.username ? (
<div className="inline-flex flex-wrap items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-slate-500">Usuário</span>
<code className="rounded-md border border-slate-200 bg-white px-2 py-0.5 font-mono text-xs text-slate-700">
{entry.username}
</code>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 border border-transparent px-2 text-slate-600 hover:border-slate-300 hover:bg-slate-100 hover:text-slate-900"
onClick={() => handleCopyRemoteCredential(entry.username, "Usuário do acesso remoto")}
>
<ClipboardCopy className="size-3.5" /> Copiar
</Button>
</div>
) : null}
{entry.password ? (
<div className="inline-flex flex-wrap items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-slate-500">Senha</span>
<code className="rounded-md border border-slate-200 bg-white px-2 py-0.5 font-mono text-xs text-slate-700">
{secretVisible ? entry.password : "••••••••"}
</code>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 border border-transparent px-2 text-slate-600 hover:border-slate-300 hover:bg-slate-100 hover:text-slate-900"
onClick={() => toggleRemoteSecret(entry.clientId)}
>
{secretVisible ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
{secretVisible ? "Ocultar" : "Mostrar"}
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 border border-transparent px-2 text-slate-600 hover:border-slate-300 hover:bg-slate-100 hover:text-slate-900"
onClick={() => handleCopyRemoteCredential(entry.password, "Senha do acesso remoto")}
>
<ClipboardCopy className="size-3.5" /> Copiar
</Button>
</div>
) : null}
</div>
) : null}
{entry.url ? (
<a
href={entry.url}
@ -3838,6 +3973,16 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
Abrir console remoto
</a>
) : null}
{isRustDesk && (entry.identifier || entry.password) ? (
<Button
variant="secondary"
size="sm"
className="mt-1 inline-flex items-center gap-2 bg-white/80 text-slate-800 hover:bg-white"
onClick={() => handleRustDeskConnect(entry)}
>
<MonitorSmartphone className="size-4" /> Conectar via RustDesk
</Button>
) : null}
{entry.notes ? (
<p className="whitespace-pre-wrap text-[11px] text-slate-600">{entry.notes}</p>
) : null}
@ -4120,6 +4265,26 @@ export function DeviceDetails({ device }: DeviceDetailsProps) {
required
/>
</div>
<div className="grid gap-2">
<label className="text-sm font-medium">Usuário (opcional)</label>
<Input
value={remoteAccessUsernameInput}
onChange={(event) => setRemoteAccessUsernameInput(event.target.value)}
placeholder="Ex: suporte@cliente.com"
/>
</div>
<div className="grid gap-2">
<label className="text-sm font-medium">Senha / PIN (opcional)</label>
<Input
type="password"
value={remoteAccessPasswordInput}
onChange={(event) => setRemoteAccessPasswordInput(event.target.value)}
placeholder="Senha permanente do RustDesk ou PIN"
/>
<p className="text-xs text-muted-foreground">
Esse valor ficará disponível para os administradores do painel. Limpe o campo para remover a senha salva.
</p>
</div>
<div className="grid gap-2">
<label className="text-sm font-medium">Link (opcional)</label>
<Input

View file

@ -359,8 +359,19 @@ const PHYSICAL_DISK_COLUMN_WIDTHS = [22, 32, 18, 16, 16, 22, 16] as const
const NETWORK_HEADERS = ["Hostname", "Interface", "MAC", "IP", "Origem"] as const
const NETWORK_COLUMN_WIDTHS = [22, 28, 22, 24, 18] as const
const REMOTE_ACCESS_HEADERS = ["Hostname", "Provedor", "Identificador", "URL", "Notas", "Última verificação", "Origem", "Metadados"] as const
const REMOTE_ACCESS_COLUMN_WIDTHS = [22, 22, 24, 36, 28, 22, 16, 40] as const
const REMOTE_ACCESS_HEADERS = [
"Hostname",
"Provedor",
"Identificador",
"Usuário",
"Senha",
"URL",
"Notas",
"Última verificação",
"Origem",
"Metadados",
] as const
const REMOTE_ACCESS_COLUMN_WIDTHS = [22, 22, 24, 24, 20, 32, 28, 22, 16, 36] as const
const SERVICE_HEADERS = ["Hostname", "Nome", "Exibição", "Status", "Origem"] as const
const SERVICE_COLUMN_WIDTHS = [22, 28, 36, 18, 18] as const
@ -756,6 +767,8 @@ function buildRemoteAccessRows(machines: MachineInventoryRecord[]): WorksheetRow
machine.hostname,
entry.provider ?? "—",
entry.identifier ?? "—",
entry.username ?? "—",
entry.password ?? "—",
entry.url ?? "—",
entry.notes ?? "—",
entry.lastVerifiedAt ? formatDateTime(entry.lastVerifiedAt) ?? "—" : "—",
@ -1229,6 +1242,8 @@ function stringifyMetadata(metadata: Record<string, unknown> | null | undefined)
type RemoteAccessNormalized = {
provider: string | null
identifier: string | null
username: string | null
password: string | null
url: string | null
notes: string | null
lastVerifiedAt: number | null
@ -1249,6 +1264,8 @@ function normalizeRemoteAccessEntry(
return {
provider: providerHint ?? null,
identifier: isUrl ? null : trimmed,
username: null,
password: null,
url: isUrl ? trimmed : null,
notes: null,
lastVerifiedAt: null,
@ -1273,6 +1290,19 @@ function normalizeRemoteAccessEntry(
ensureString(record["value"]) ??
ensureString(record["label"]) ??
null
const username =
ensureString(record["username"]) ??
ensureString(record["user"]) ??
ensureString(record["login"]) ??
ensureString(record["email"]) ??
ensureString(record["account"]) ??
null
const password =
ensureString(record["password"]) ??
ensureString(record["pass"]) ??
ensureString(record["secret"]) ??
ensureString(record["pin"]) ??
null
const url =
ensureString(record["url"]) ??
ensureString(record["link"]) ??
@ -1296,6 +1326,8 @@ function normalizeRemoteAccessEntry(
return {
provider,
identifier,
username,
password,
url,
notes,
lastVerifiedAt,

View file

@ -13,6 +13,8 @@ describe("normalizeDeviceRemoteAccess", () => {
expect(result).toEqual({
provider: null,
identifier: "PC-001",
username: null,
password: null,
url: null,
notes: null,
lastVerifiedAt: null,
@ -25,6 +27,8 @@ describe("normalizeDeviceRemoteAccess", () => {
expect(result).toEqual({
provider: null,
identifier: null,
username: null,
password: null,
url: "https://remote.example.com/session/123",
notes: null,
lastVerifiedAt: null,
@ -39,12 +43,16 @@ describe("normalizeDeviceRemoteAccess", () => {
code: "123-456-789",
remoteUrl: "https://anydesk.com/session/123",
note: "Suporte avançado",
user: "admin",
secret: "S3nh@",
verifiedAt: timestamp,
extraTag: "vip",
})
expect(result).toEqual({
provider: "AnyDesk",
identifier: "123-456-789",
username: "admin",
password: "S3nh@",
url: "https://anydesk.com/session/123",
notes: "Suporte avançado",
lastVerifiedAt: timestamp,
@ -53,6 +61,8 @@ describe("normalizeDeviceRemoteAccess", () => {
code: "123-456-789",
remoteUrl: "https://anydesk.com/session/123",
note: "Suporte avançado",
user: "admin",
secret: "S3nh@",
verifiedAt: timestamp,
extraTag: "vip",
},