From af776ea8ff3f79d566df271e7a55ac10fd2860b2 Mon Sep 17 00:00:00 2001 From: Jason Staack Date: Sat, 21 Mar 2026 19:58:02 -0500 Subject: [PATCH] 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) --- .../settings/CredentialProfilesPage.tsx | 530 ++++++++++++++++++ frontend/src/lib/api.ts | 69 +++ 2 files changed, 599 insertions(+) create mode 100644 frontend/src/components/settings/CredentialProfilesPage.tsx diff --git a/frontend/src/components/settings/CredentialProfilesPage.tsx b/frontend/src/components/settings/CredentialProfilesPage.tsx new file mode 100644 index 0000000..4c3f5f5 --- /dev/null +++ b/frontend/src/components/settings/CredentialProfilesPage.tsx @@ -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 = { + 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 ( +
+
+
+
{profile.name}
+ {profile.description && ( +
+ {profile.description} +
+ )} +
+
+
+ + {profile.device_count} device{profile.device_count !== 1 ? 's' : ''} + + {canModify && ( + <> + + + + )} +
+
+ ) +} + +// ─── 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( + null, + ) + const [form, setForm] = useState({ + 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) { + 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 ( +
+
+
+
+
+ ) + } + + // ─── Render ───────────────────────────────────────────────────────────── + + const credType = form.credential_type as CredentialType + const secLevel = (form.security_level ?? 'noAuthNoPriv') as SecurityLevel + + return ( +
+ {/* Header */} +
+
+

Credential Profiles

+

+ Manage shared credentials for device authentication +

+
+ {userCanWrite && ( + + )} +
+ + {/* RouterOS section */} +
+
+ +

RouterOS

+
+
+ {routerosProfiles.length === 0 ? ( +

+ No RouterOS credential profiles yet +

+ ) : ( + routerosProfiles.map((p) => ( + + )) + )} +
+
+ + {/* SNMP section */} +
+
+ +

SNMP

+
+
+ {snmpProfiles.length === 0 ? ( +

+ No SNMP credential profiles yet +

+ ) : ( + snmpProfiles.map((p) => ( + + )) + )} +
+
+ + {/* Create / Edit Dialog */} + !open && closeDialog()}> + + + + {editingProfile ? 'Edit Credential Profile' : 'New Credential Profile'} + + + +
+ {/* Credential type */} +
+ + +
+ + {/* Name */} +
+ + updateForm({ name: e.target.value })} + placeholder="e.g. monitoring-readonly" + className="mt-1" + /> +
+ + {/* Description */} +
+ + updateForm({ description: e.target.value })} + placeholder="Brief description of this profile" + className="mt-1" + /> +
+ + {/* RouterOS fields */} + {credType === 'routeros' && ( + <> +
+ + updateForm({ username: e.target.value })} + placeholder={editingProfile ? 'Leave blank to keep current' : 'admin'} + className="mt-1" + /> +
+
+ + updateForm({ password: e.target.value })} + placeholder={editingProfile ? 'Leave blank to keep current' : ''} + className="mt-1" + /> +
+ + )} + + {/* SNMP v2c fields */} + {credType === 'snmp_v2c' && ( +
+ + updateForm({ community: e.target.value })} + placeholder={editingProfile ? 'Leave blank to keep current' : 'public'} + className="mt-1" + /> +
+ )} + + {/* SNMP v3 fields */} + {credType === 'snmp_v3' && ( + <> +
+ + updateForm({ security_name: e.target.value })} + placeholder={ + editingProfile ? 'Leave blank to keep current' : 'snmpuser' + } + className="mt-1" + /> +
+ +
+ + +
+ + {/* Auth fields (authNoPriv or authPriv) */} + {(secLevel === 'authNoPriv' || secLevel === 'authPriv') && ( + <> +
+ + +
+
+ + + updateForm({ auth_passphrase: e.target.value }) + } + placeholder={ + editingProfile ? 'Leave blank to keep current' : '' + } + className="mt-1" + /> +
+ + )} + + {/* Privacy fields (authPriv only) */} + {secLevel === 'authPriv' && ( + <> +
+ + +
+
+ + + updateForm({ privacy_passphrase: e.target.value }) + } + placeholder={ + editingProfile ? 'Leave blank to keep current' : '' + } + className="mt-1" + /> +
+ + )} + + )} +
+ + + + + +
+
+
+ ) +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 4184d09..fa710d2 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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( + `/api/tenants/${tenantId}/credential-profiles`, + { params: credentialType ? { credential_type: credentialType } : undefined }, + ) + .then((r) => r.data), + + get: (tenantId: string, profileId: string) => + api + .get( + `/api/tenants/${tenantId}/credential-profiles/${profileId}`, + ) + .then((r) => r.data), + + create: (tenantId: string, data: CredentialProfileCreate) => + api + .post( + `/api/tenants/${tenantId}/credential-profiles`, + data, + ) + .then((r) => r.data), + + update: (tenantId: string, profileId: string, data: Partial) => + api + .put( + `/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), +}