admin / Synapse-Cortex
publicSelf Hosted ITSM Tool with RBAC/Tenanting and MFA
Synapse-Cortex / Synapse-Cortexv2 / frontend / src / ticket / CredentialLinkSelect.tsx
8569 B · main
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 | import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useEffect, useState } from 'react' import { ApiError } from '../api/client' import { credentialsApi } from '../api/credentials' import { ticketsApi } from '../api/tickets' import type { TicketOut } from '../api/types' const NEW_CREDENTIAL_VALUE = '__new__' /** Masked credential dropdown for linking a host credential to a ticket by * reference - the secret itself is never fetched, displayed, or stored here * after submission; only credentialsApi.list()'s masked_hint (username@host) * is shown, matching CredentialOut's deliberate omission of the secret. * * When the ticket has a linked asset, a credential vaulted for that same * asset (CredentialVaultEntry.asset_id) is auto-linked the moment one * exists - the agent only ever enters it once per asset. */ export function CredentialLinkSelect({ ticket }: { ticket: TicketOut }) { const queryClient = useQueryClient() const { data: credentials = [] } = useQuery({ queryKey: ['credentials'], queryFn: credentialsApi.list }) const [showNew, setShowNew] = useState(false) const [label, setLabel] = useState('') const [username, setUsername] = useState('') const [host, setHost] = useState('') const [port, setPort] = useState(22) const [secret, setSecret] = useState('') const [error, setError] = useState<string | null>(null) const linkMutation = useMutation({ mutationFn: (credential_ref_id: string) => ticketsApi.update(ticket.id, { credential_ref_id }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['ticket', ticket.id] }), onError: (err) => setError(err instanceof ApiError ? err.message : 'Failed to link credential.'), }) const assetMatchedCredential = ticket.asset_id ? credentials.find((c) => c.asset_id === ticket.asset_id) : undefined // Auto-link the asset's own credential the moment one exists and nothing // is linked yet - covers both "someone already vaulted it for a prior // ticket on this asset" and "it was just vaulted below, moments ago". useEffect(() => { if (!ticket.credential_ref_id && assetMatchedCredential && !linkMutation.isPending) { linkMutation.mutate(assetMatchedCredential.id) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [ticket.credential_ref_id, assetMatchedCredential?.id]) const assetLinkable = Boolean(ticket.asset_id && ticket.asset_ip_address) && !assetMatchedCredential const createMutation = useMutation({ mutationFn: () => credentialsApi.create( assetLinkable ? { label: label || ticket.asset_ip_address!, username, port, secret, asset_id: ticket.asset_id! } : { label, username, host, port, secret }, ), onSuccess: async (created) => { await queryClient.invalidateQueries({ queryKey: ['credentials'] }) setShowNew(false) setLabel('') setUsername('') setHost('') setPort(22) setSecret('') linkMutation.mutate(created.id) }, onError: (err) => setError(err instanceof ApiError ? err.message : 'Failed to vault credential.'), }) const selected = credentials.find((c) => c.id === ticket.credential_ref_id) const canSubmitNew = assetLinkable ? username && secret : label && username && host && secret return ( <div> <div className="grid grid-cols-3 gap-2 mb-2 text-[10px]"> <div> <div className="text-cortex-muted uppercase tracking-wider">IP Address</div> <div className="font-mono text-chrome">{ticket.asset_ip_address ?? '—'}</div> </div> <div> <div className="text-cortex-muted uppercase tracking-wider">Username</div> <div className="font-mono text-chrome">{ticket.credential_username ?? '—'}</div> </div> <div> <div className="text-cortex-muted uppercase tracking-wider">Password</div> <div className={`font-mono ${ticket.credential_configured ? 'text-accent-green' : 'text-cortex-muted'}`}> {ticket.credential_configured ? '•••••••• (configured)' : 'Not set'} </div> </div> </div> <label className="block text-[10px] text-cortex-muted mb-1 uppercase tracking-wider">Linked Credential</label> {error && <div className="text-xs text-accent-red mb-1">{error}</div>} <select value={ticket.credential_ref_id ?? ''} onChange={(e) => { if (e.target.value === NEW_CREDENTIAL_VALUE) { setShowNew(true) } else if (e.target.value) { setError(null) linkMutation.mutate(e.target.value) } }} className="w-full bg-cortex-panel2 border border-cortex-border rounded px-3 py-2 text-sm font-mono focus:outline-none focus:border-accent-blue" > <option value="">— none linked —</option> {credentials.map((c) => ( <option key={c.id} value={c.id}> {c.label} ({c.masked_hint}) </option> ))} <option value={NEW_CREDENTIAL_VALUE}>+ Vault new credential...</option> </select> {selected && <div className="text-[10px] text-cortex-muted mt-1 font-mono">Currently linked: {selected.masked_hint}</div>} {ticket.asset_id && !ticket.asset_ip_address && ( <div className="text-[10px] text-accent-orange mt-1"> This asset has no recorded IP address yet, so a credential can't be auto-matched to it - vault one manually below. </div> )} {showNew && ( <div className="mt-3 border border-cortex-border bg-cortex-panel2 rounded-md p-3 space-y-2"> {assetLinkable && ( <div className="text-[9px] text-cortex-muted leading-tight"> This credential will be vaulted for this ticket's asset ({ticket.asset_ip_address}) - the host is taken from the asset's recorded IP, and future tickets on the same asset will auto-match it. </div> )} {!assetLinkable && ( <input placeholder="Label" value={label} onChange={(e) => setLabel(e.target.value)} className="w-full bg-cortex-panel border border-cortex-border rounded px-2 py-1.5 text-xs focus:outline-none focus:border-accent-blue" /> )} <div className="flex gap-2"> <input placeholder="Username" value={username} onChange={(e) => setUsername(e.target.value)} className="flex-1 bg-cortex-panel border border-cortex-border rounded px-2 py-1.5 text-xs focus:outline-none focus:border-accent-blue" /> {!assetLinkable && ( <input placeholder="Host" value={host} onChange={(e) => setHost(e.target.value)} className="flex-1 bg-cortex-panel border border-cortex-border rounded px-2 py-1.5 text-xs focus:outline-none focus:border-accent-blue" /> )} <input type="number" placeholder="Port" value={port} onChange={(e) => setPort(Number(e.target.value))} className="w-16 bg-cortex-panel border border-cortex-border rounded px-2 py-1.5 text-xs focus:outline-none focus:border-accent-blue" /> </div> <input type="password" placeholder="Secret (password/key)" value={secret} onChange={(e) => setSecret(e.target.value)} className="w-full bg-cortex-panel border border-cortex-border rounded px-2 py-1.5 text-xs focus:outline-none focus:border-accent-blue" /> <div className="flex gap-2"> <button disabled={!canSubmitNew || createMutation.isPending} onClick={() => { setError(null) createMutation.mutate() }} className="bg-accent-blue text-cortex-bg font-display font-bold uppercase tracking-wider text-xs px-3 py-1.5 rounded hover:bg-accent-blue-bright transition disabled:opacity-50" > {createMutation.isPending ? 'Vaulting...' : 'Vault & Link'} </button> <button onClick={() => setShowNew(false)} className="text-xs uppercase text-cortex-muted hover:text-chrome transition" > Cancel </button> </div> </div> )} </div> ) } |