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

@ -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