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 = { 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 (
{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 = 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( 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 ?? 'no_auth_no_priv') 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({ username: e.target.value })} placeholder={ editingProfile ? 'Leave blank to keep current' : 'snmpuser' } className="mt-1" />
{/* Auth fields (auth_no_priv or auth_priv) */} {(secLevel === 'auth_no_priv' || secLevel === 'auth_priv') && ( <>
updateForm({ auth_passphrase: e.target.value }) } placeholder={ editingProfile ? 'Leave blank to keep current' : '' } className="mt-1" />
)} {/* Privacy fields (auth_priv only) */} {secLevel === 'auth_priv' && ( <>
updateForm({ priv_passphrase: e.target.value }) } placeholder={ editingProfile ? 'Leave blank to keep current' : '' } className="mt-1" />
)} )}
) }