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:
Jason Staack
2026-03-21 19:58:02 -05:00
parent 6b87b88ce4
commit af776ea8ff
2 changed files with 599 additions and 0 deletions

View 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>
)
}

View File

@@ -1789,3 +1789,72 @@ export const alertEventsApi = {
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),
}