feat(19-03): add credential profiles page and API types
- Create CredentialProfilesPage component with full CRUD for RouterOS and SNMP profiles - Add credentialProfilesApi types and client to api.ts (blocking dependency from 19-01) - Profile list grouped by type with device count, edit, and delete actions - Create/edit dialog with conditional fields per credential type - SNMPv3 form shows auth/privacy fields based on security_level selection - Delete confirmation with 409 error handling for linked devices Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
530
frontend/src/components/settings/CredentialProfilesPage.tsx
Normal file
530
frontend/src/components/settings/CredentialProfilesPage.tsx
Normal file
@@ -0,0 +1,530 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Plus, Pencil, Trash2, KeyRound, Shield } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
credentialProfilesApi,
|
||||||
|
type CredentialProfileResponse,
|
||||||
|
type CredentialProfileCreate,
|
||||||
|
} from '@/lib/api'
|
||||||
|
import { useAuth, canWrite } from '@/lib/auth'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
|
|
||||||
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface CredentialProfilesPageProps {
|
||||||
|
tenantId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type CredentialType = 'routeros' | 'snmp_v2c' | 'snmp_v3'
|
||||||
|
type SecurityLevel = 'noAuthNoPriv' | 'authNoPriv' | 'authPriv'
|
||||||
|
|
||||||
|
const CREDENTIAL_TYPE_LABELS: Record<CredentialType, string> = {
|
||||||
|
routeros: 'RouterOS',
|
||||||
|
snmp_v2c: 'SNMP v2c',
|
||||||
|
snmp_v3: 'SNMP v3',
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECURITY_LEVELS: { value: SecurityLevel; label: string }[] = [
|
||||||
|
{ value: 'noAuthNoPriv', label: 'No Auth, No Privacy' },
|
||||||
|
{ value: 'authNoPriv', label: 'Auth, No Privacy' },
|
||||||
|
{ value: 'authPriv', label: 'Auth + Privacy' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const AUTH_PROTOCOLS = ['MD5', 'SHA', 'SHA256'] as const
|
||||||
|
const PRIVACY_PROTOCOLS = ['DES', 'AES', 'AES256'] as const
|
||||||
|
|
||||||
|
// ─── Profile Card ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ProfileCard({
|
||||||
|
profile,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
canModify,
|
||||||
|
}: {
|
||||||
|
profile: CredentialProfileResponse
|
||||||
|
onEdit: (profile: CredentialProfileResponse) => void
|
||||||
|
onDelete: (profileId: string) => void
|
||||||
|
canModify: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between py-2 px-3 rounded-sm border border-border bg-panel">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-text-primary">{profile.name}</div>
|
||||||
|
{profile.description && (
|
||||||
|
<div className="text-xs text-text-muted truncate max-w-[300px]">
|
||||||
|
{profile.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 flex-shrink-0">
|
||||||
|
<span className="text-xs text-text-muted">
|
||||||
|
{profile.device_count} device{profile.device_count !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
{canModify && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={() => onEdit(profile)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 text-error"
|
||||||
|
onClick={() => onDelete(profile.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Component ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function CredentialProfilesPage({ tenantId }: CredentialProfilesPageProps) {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const userCanWrite = canWrite(user)
|
||||||
|
|
||||||
|
// ─── Data query ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['credential-profiles', tenantId],
|
||||||
|
queryFn: () => credentialProfilesApi.list(tenantId),
|
||||||
|
enabled: !!tenantId,
|
||||||
|
})
|
||||||
|
|
||||||
|
const profiles = data?.profiles ?? []
|
||||||
|
const routerosProfiles = profiles.filter((p) => p.credential_type === 'routeros')
|
||||||
|
const snmpProfiles = profiles.filter((p) => p.credential_type.startsWith('snmp_'))
|
||||||
|
|
||||||
|
// ─── Dialog state ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [editingProfile, setEditingProfile] = useState<CredentialProfileResponse | null>(
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
const [form, setForm] = useState<CredentialProfileCreate>({
|
||||||
|
name: '',
|
||||||
|
credential_type: 'routeros',
|
||||||
|
})
|
||||||
|
|
||||||
|
function closeDialog() {
|
||||||
|
setDialogOpen(false)
|
||||||
|
setEditingProfile(null)
|
||||||
|
setForm({ name: '', credential_type: 'routeros' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateDialog() {
|
||||||
|
setEditingProfile(null)
|
||||||
|
setForm({ name: '', credential_type: 'routeros' })
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEdit(profile: CredentialProfileResponse) {
|
||||||
|
setEditingProfile(profile)
|
||||||
|
setForm({
|
||||||
|
name: profile.name,
|
||||||
|
description: profile.description ?? '',
|
||||||
|
credential_type: profile.credential_type,
|
||||||
|
})
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateForm(updates: Partial<CredentialProfileCreate>) {
|
||||||
|
setForm((prev) => ({ ...prev, ...updates }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Mutations ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: (data: CredentialProfileCreate) =>
|
||||||
|
editingProfile
|
||||||
|
? credentialProfilesApi.update(tenantId, editingProfile.id, data)
|
||||||
|
: credentialProfilesApi.create(tenantId, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['credential-profiles'] })
|
||||||
|
toast.success(editingProfile ? 'Profile updated' : 'Profile created')
|
||||||
|
closeDialog()
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
const detail =
|
||||||
|
(err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ??
|
||||||
|
'Failed to save profile'
|
||||||
|
toast.error(detail)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (profileId: string) =>
|
||||||
|
credentialProfilesApi.delete(tenantId, profileId),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['credential-profiles'] })
|
||||||
|
toast.success('Profile deleted')
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
const detail =
|
||||||
|
(err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ??
|
||||||
|
'Cannot delete profile'
|
||||||
|
toast.error(detail)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleDelete(profileId: string) {
|
||||||
|
if (
|
||||||
|
confirm(
|
||||||
|
"Delete this credential profile? Devices using it will keep their current credentials but won't receive future updates.",
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
deleteMutation.mutate(profileId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
// Strip empty string fields so we don't send blanks to the API
|
||||||
|
const payload: CredentialProfileCreate = { ...form }
|
||||||
|
for (const key of Object.keys(payload) as (keyof CredentialProfileCreate)[]) {
|
||||||
|
if (payload[key] === '') {
|
||||||
|
delete payload[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Always send name and credential_type
|
||||||
|
payload.name = form.name
|
||||||
|
payload.credential_type = form.credential_type
|
||||||
|
saveMutation.mutate(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Loading state ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-2xl">
|
||||||
|
<div className="h-8 w-48 bg-elevated/50 rounded animate-pulse" />
|
||||||
|
<div className="h-24 bg-elevated/50 rounded animate-pulse" />
|
||||||
|
<div className="h-24 bg-elevated/50 rounded animate-pulse" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Render ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const credType = form.credential_type as CredentialType
|
||||||
|
const secLevel = (form.security_level ?? 'noAuthNoPriv') as SecurityLevel
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-2xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold">Credential Profiles</h1>
|
||||||
|
<p className="text-sm text-text-muted mt-0.5">
|
||||||
|
Manage shared credentials for device authentication
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{userCanWrite && (
|
||||||
|
<Button size="sm" onClick={openCreateDialog}>
|
||||||
|
<Plus className="h-3.5 w-3.5" /> New Profile
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RouterOS section */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<KeyRound className="h-4 w-4 text-text-muted" />
|
||||||
|
<h2 className="text-sm font-medium text-text-secondary">RouterOS</h2>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{routerosProfiles.length === 0 ? (
|
||||||
|
<p className="text-xs text-text-muted py-2">
|
||||||
|
No RouterOS credential profiles yet
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
routerosProfiles.map((p) => (
|
||||||
|
<ProfileCard
|
||||||
|
key={p.id}
|
||||||
|
profile={p}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
canModify={userCanWrite}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SNMP section */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Shield className="h-4 w-4 text-text-muted" />
|
||||||
|
<h2 className="text-sm font-medium text-text-secondary">SNMP</h2>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{snmpProfiles.length === 0 ? (
|
||||||
|
<p className="text-xs text-text-muted py-2">
|
||||||
|
No SNMP credential profiles yet
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
snmpProfiles.map((p) => (
|
||||||
|
<ProfileCard
|
||||||
|
key={p.id}
|
||||||
|
profile={p}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
canModify={userCanWrite}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create / Edit Dialog */}
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={(open) => !open && closeDialog()}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{editingProfile ? 'Edit Credential Profile' : 'New Credential Profile'}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
{/* Credential type */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Credential Type</Label>
|
||||||
|
<Select
|
||||||
|
value={form.credential_type}
|
||||||
|
onValueChange={(v) => updateForm({ credential_type: v })}
|
||||||
|
disabled={!!editingProfile}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="routeros">RouterOS</SelectItem>
|
||||||
|
<SelectItem value="snmp_v2c">SNMP v2c</SelectItem>
|
||||||
|
<SelectItem value="snmp_v3">SNMP v3</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Profile Name</Label>
|
||||||
|
<Input
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => updateForm({ name: e.target.value })}
|
||||||
|
placeholder="e.g. monitoring-readonly"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">
|
||||||
|
Description{' '}
|
||||||
|
<span className="text-text-muted font-normal">(optional)</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={form.description ?? ''}
|
||||||
|
onChange={(e) => updateForm({ description: e.target.value })}
|
||||||
|
placeholder="Brief description of this profile"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RouterOS fields */}
|
||||||
|
{credType === 'routeros' && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Username</Label>
|
||||||
|
<Input
|
||||||
|
value={form.username ?? ''}
|
||||||
|
onChange={(e) => updateForm({ username: e.target.value })}
|
||||||
|
placeholder={editingProfile ? 'Leave blank to keep current' : 'admin'}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Password</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={form.password ?? ''}
|
||||||
|
onChange={(e) => updateForm({ password: e.target.value })}
|
||||||
|
placeholder={editingProfile ? 'Leave blank to keep current' : ''}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* SNMP v2c fields */}
|
||||||
|
{credType === 'snmp_v2c' && (
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Community String</Label>
|
||||||
|
<Input
|
||||||
|
value={form.community ?? ''}
|
||||||
|
onChange={(e) => updateForm({ community: e.target.value })}
|
||||||
|
placeholder={editingProfile ? 'Leave blank to keep current' : 'public'}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* SNMP v3 fields */}
|
||||||
|
{credType === 'snmp_v3' && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Security Name</Label>
|
||||||
|
<Input
|
||||||
|
value={form.security_name ?? ''}
|
||||||
|
onChange={(e) => updateForm({ security_name: e.target.value })}
|
||||||
|
placeholder={
|
||||||
|
editingProfile ? 'Leave blank to keep current' : 'snmpuser'
|
||||||
|
}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Security Level</Label>
|
||||||
|
<Select
|
||||||
|
value={form.security_level ?? 'noAuthNoPriv'}
|
||||||
|
onValueChange={(v) => updateForm({ security_level: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SECURITY_LEVELS.map((sl) => (
|
||||||
|
<SelectItem key={sl.value} value={sl.value}>
|
||||||
|
{sl.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auth fields (authNoPriv or authPriv) */}
|
||||||
|
{(secLevel === 'authNoPriv' || secLevel === 'authPriv') && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Auth Protocol</Label>
|
||||||
|
<Select
|
||||||
|
value={form.auth_protocol ?? 'SHA'}
|
||||||
|
onValueChange={(v) => updateForm({ auth_protocol: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{AUTH_PROTOCOLS.map((p) => (
|
||||||
|
<SelectItem key={p} value={p}>
|
||||||
|
{p}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Auth Passphrase</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={form.auth_passphrase ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateForm({ auth_passphrase: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder={
|
||||||
|
editingProfile ? 'Leave blank to keep current' : ''
|
||||||
|
}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Privacy fields (authPriv only) */}
|
||||||
|
{secLevel === 'authPriv' && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Privacy Protocol</Label>
|
||||||
|
<Select
|
||||||
|
value={form.privacy_protocol ?? 'AES'}
|
||||||
|
onValueChange={(v) => updateForm({ privacy_protocol: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PRIVACY_PROTOCOLS.map((p) => (
|
||||||
|
<SelectItem key={p} value={p}>
|
||||||
|
{p}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">Privacy Passphrase</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={form.privacy_passphrase ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateForm({ privacy_passphrase: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder={
|
||||||
|
editingProfile ? 'Leave blank to keep current' : ''
|
||||||
|
}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={closeDialog}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!form.name.trim() || saveMutation.isPending}
|
||||||
|
>
|
||||||
|
{saveMutation.isPending
|
||||||
|
? 'Saving...'
|
||||||
|
: editingProfile
|
||||||
|
? 'Update Profile'
|
||||||
|
: 'Create Profile'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1789,3 +1789,72 @@ export const alertEventsApi = {
|
|||||||
return data.count
|
return data.count
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Credential Profiles ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CredentialProfileResponse {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string | null
|
||||||
|
credential_type: string // "routeros" | "snmp_v2c" | "snmp_v3"
|
||||||
|
device_count: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CredentialProfileListResponse {
|
||||||
|
profiles: CredentialProfileResponse[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CredentialProfileCreate {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
credential_type: string
|
||||||
|
username?: string
|
||||||
|
password?: string
|
||||||
|
community?: string
|
||||||
|
security_level?: string
|
||||||
|
auth_protocol?: string
|
||||||
|
auth_passphrase?: string
|
||||||
|
privacy_protocol?: string
|
||||||
|
privacy_passphrase?: string
|
||||||
|
security_name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const credentialProfilesApi = {
|
||||||
|
list: (tenantId: string, credentialType?: string) =>
|
||||||
|
api
|
||||||
|
.get<CredentialProfileListResponse>(
|
||||||
|
`/api/tenants/${tenantId}/credential-profiles`,
|
||||||
|
{ params: credentialType ? { credential_type: credentialType } : undefined },
|
||||||
|
)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
get: (tenantId: string, profileId: string) =>
|
||||||
|
api
|
||||||
|
.get<CredentialProfileResponse>(
|
||||||
|
`/api/tenants/${tenantId}/credential-profiles/${profileId}`,
|
||||||
|
)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
create: (tenantId: string, data: CredentialProfileCreate) =>
|
||||||
|
api
|
||||||
|
.post<CredentialProfileResponse>(
|
||||||
|
`/api/tenants/${tenantId}/credential-profiles`,
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
update: (tenantId: string, profileId: string, data: Partial<CredentialProfileCreate>) =>
|
||||||
|
api
|
||||||
|
.put<CredentialProfileResponse>(
|
||||||
|
`/api/tenants/${tenantId}/credential-profiles/${profileId}`,
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
delete: (tenantId: string, profileId: string) =>
|
||||||
|
api
|
||||||
|
.delete(`/api/tenants/${tenantId}/credential-profiles/${profileId}`)
|
||||||
|
.then((r) => r.data),
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user