- credentialProfilesApi.list now unwraps {profiles: [...]} wrapper
- CredentialProfilesPage handles both array and wrapped responses
- Renamed privacy_protocol → priv_protocol to match backend schema
- Renamed privacy_passphrase → priv_passphrase to match backend
- Renamed security_name → username (SNMPv3 uses username field)
- Removed security_name from CredentialProfileCreate type
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
531 lines
19 KiB
TypeScript
531 lines
19 KiB
TypeScript
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 = 'no_auth_no_priv' | 'auth_no_priv' | 'auth_priv'
|
|
|
|
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: 'no_auth_no_priv', label: 'No Auth, No Privacy' },
|
|
{ value: 'auth_no_priv', label: 'Auth, No Privacy' },
|
|
{ value: 'auth_priv', label: 'Auth and Privacy' },
|
|
]
|
|
|
|
const AUTH_PROTOCOLS = ['SHA256', 'SHA384', 'SHA512'] as const
|
|
const PRIVACY_PROTOCOLS = ['AES128', '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 = Array.isArray(data) ? data : (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 ?? 'no_auth_no_priv') 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.username ?? ''}
|
|
onChange={(e) => updateForm({ username: 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 ?? 'no_auth_no_priv'}
|
|
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 (auth_no_priv or auth_priv) */}
|
|
{(secLevel === 'auth_no_priv' || secLevel === 'auth_priv') && (
|
|
<>
|
|
<div>
|
|
<Label className="text-xs">Auth Protocol</Label>
|
|
<Select
|
|
value={form.auth_protocol ?? 'SHA256'}
|
|
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 (auth_priv only) */}
|
|
{secLevel === 'auth_priv' && (
|
|
<>
|
|
<div>
|
|
<Label className="text-xs">Privacy Protocol</Label>
|
|
<Select
|
|
value={form.priv_protocol ?? 'AES128'}
|
|
onValueChange={(v) => updateForm({ priv_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.priv_passphrase ?? ''}
|
|
onChange={(e) =>
|
|
updateForm({ priv_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>
|
|
)
|
|
}
|