e.stopPropagation()}>
@@ -204,10 +239,38 @@ export const DoorSetupWizard: React.FC = ({
Lock Provider
+ {/* Detected Z-Wave Locks Quick-Pick */}
+ {isWebSocketMode && detectedLockNodes.length > 0 && (
+
- {lockDiscovered.length > 0 && (
+ {lockDiscovered.length > 0 && !isWebSocketMode && (
+ {/* Detected Z-Wave Keypads Quick-Pick */}
+ {isWebSocketMode && detectedKeypadNodes.length > 0 && (
+
- {keypadDiscovered.length > 0 && keypadProviderType !== 'None' && (
+ {keypadDiscovered.length > 0 && keypadProviderType !== 'None' && !isWebSocketMode && (
Auto-detected:
{keypadDiscovered.map((item) => (
diff --git a/src/CodeMaster.UI/src/components/logs/LiveEventFeed.tsx b/src/CodeMaster.UI/src/components/logs/LiveEventFeed.tsx
index 71563a0..a6974e3 100644
--- a/src/CodeMaster.UI/src/components/logs/LiveEventFeed.tsx
+++ b/src/CodeMaster.UI/src/components/logs/LiveEventFeed.tsx
@@ -15,13 +15,24 @@ import { useDoorStore } from '../../stores/useDoorStore';
import type { AccessEventType, AccessMethod } from '../../types';
export const LiveEventFeed: React.FC = () => {
- const { logs, filter, isLoading, isLive, fetchLogs, setFilter, toggleLive } = useAuditStore();
+ const { logs, filter, isLoading, isLive, fetchLogs, setFilter, toggleLive, startLiveStream, stopLiveStream } = useAuditStore();
const { doors } = useDoorStore();
useEffect(() => {
fetchLogs();
}, [fetchLogs]);
+ useEffect(() => {
+ if (isLive) {
+ startLiveStream();
+ } else {
+ stopLiveStream();
+ }
+ return () => {
+ stopLiveStream();
+ };
+ }, [isLive, startLiveStream, stopLiveStream]);
+
const getEventBadge = (eventType: AccessEventType) => {
switch (eventType) {
case 'Unlocked':
diff --git a/src/CodeMaster.UI/src/components/settings/SettingsView.test.tsx b/src/CodeMaster.UI/src/components/settings/SettingsView.test.tsx
index 9c173e5..e498811 100644
--- a/src/CodeMaster.UI/src/components/settings/SettingsView.test.tsx
+++ b/src/CodeMaster.UI/src/components/settings/SettingsView.test.tsx
@@ -1,10 +1,63 @@
-import { describe, it, expect, afterEach } from 'vitest';
-import { render, screen, cleanup } from '@testing-library/react';
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
import { SettingsView } from './SettingsView';
+import { useSettingsStore, DEFAULT_SETTINGS } from '../../stores/useSettingsStore';
+import { apiClient } from '../../api/apiClient';
describe('SettingsView component', () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ vi.spyOn(apiClient.settings, 'get').mockResolvedValue({
+ settings: { ...DEFAULT_SETTINGS },
+ transports: [
+ {
+ transportId: 'zwave_ws',
+ displayName: 'Z-Wave JS WebSocket',
+ status: 'Connected',
+ isConnected: true,
+ details: 'Node 39 (Allegion BE469ZP), Node 40 (Ring Keypad v2)',
+ },
+ {
+ transportId: 'mqtt_broker',
+ displayName: 'Mosquitto MQTT',
+ status: 'Connected',
+ isConnected: true,
+ details: '10.0.0.10:8100',
+ },
+ ],
+ });
+
+ useSettingsStore.setState({
+ settings: { ...DEFAULT_SETTINGS },
+ transports: [
+ {
+ transportId: 'zwave_ws',
+ displayName: 'Z-Wave JS WebSocket',
+ status: 'Connected',
+ isConnected: true,
+ details: 'Node 39 (Allegion BE469ZP), Node 40 (Ring Keypad v2)',
+ },
+ {
+ transportId: 'mqtt_broker',
+ displayName: 'Mosquitto MQTT',
+ status: 'Connected',
+ isConnected: true,
+ details: '10.0.0.10:8100',
+ },
+ ],
+ detectedNodes: [],
+ lastTestResult: null,
+ isLoading: false,
+ isSaving: false,
+ isTesting: false,
+ error: null,
+ saveSuccessMessage: null,
+ });
+ });
+
afterEach(() => {
cleanup();
+ vi.restoreAllMocks();
});
it('renders system settings, Ingress context, and MCP tools', () => {
@@ -17,4 +70,101 @@ describe('SettingsView component', () => {
expect(screen.getByText(/codemaster__unlock_door/i)).toBeDefined();
expect(screen.getByText(/Hardware Security Architecture/i)).toBeDefined();
});
+
+ it('allows toggling between WebSocket and MQTT modes', async () => {
+ render(
);
+
+ // Initially in WebSocket mode
+ expect(screen.getByLabelText(/Z-Wave JS WebSocket Server URL/i)).toBeDefined();
+
+ // Click MQTT toggle button
+ const mqttToggle = screen.getByRole('radio', { name: /MQTT \(Mosquitto\)/i });
+ fireEvent.click(mqttToggle);
+
+ await waitFor(() => {
+ expect(screen.getByLabelText(/Z-Wave MQTT Topic Prefix/i)).toBeDefined();
+ });
+
+ // Switch back to WebSocket
+ const wsToggle = screen.getByRole('radio', { name: /WebSocket \(Direct\)/i });
+ fireEvent.click(wsToggle);
+
+ await waitFor(() => {
+ expect(screen.getByLabelText(/Z-Wave JS WebSocket Server URL/i)).toBeDefined();
+ });
+ });
+
+ it('tests WebSocket connection and renders detected nodes on success', async () => {
+ vi.spyOn(apiClient.settings, 'testConnection').mockResolvedValue({
+ success: true,
+ latencyMs: 42,
+ driverVersion: '15.15.3',
+ serverVersion: '3.2.1',
+ nodeCount: 30,
+ detectedNodes: [
+ { nodeId: 39, name: 'Front Door Lock', deviceType: 'lock', model: 'Allegion BE469ZP' },
+ { nodeId: 40, name: 'Laundry Room Keypad', deviceType: 'keypad', model: 'Ring 4AK1SZ' },
+ ],
+ message: 'Connected',
+ });
+
+ render(
);
+
+ const testBtn = screen.getByRole('button', { name: /Test Connection/i });
+ fireEvent.click(testBtn);
+
+ await waitFor(() => {
+ expect(apiClient.settings.testConnection).toHaveBeenCalled();
+ expect(screen.getByText(/Latency: 42ms/i)).toBeDefined();
+ expect(screen.getByText(/15.15.3/)).toBeDefined();
+ expect(screen.getByText(/3.2.1/)).toBeDefined();
+ expect(screen.getByRole('cell', { name: /Node 39/i })).toBeDefined();
+ expect(screen.getByRole('cell', { name: /Front Door Lock/i })).toBeDefined();
+ expect(screen.getByRole('cell', { name: /Node 40/i })).toBeDefined();
+ expect(screen.getByRole('cell', { name: /Laundry Room Keypad/i })).toBeDefined();
+ });
+ });
+
+ it('renders error banner when connection test fails', async () => {
+ vi.spyOn(apiClient.settings, 'testConnection').mockRejectedValue(
+ new Error('WebSocket connection refused at ws://10.0.0.10:8106')
+ );
+
+ render(
);
+
+ const testBtn = screen.getByRole('button', { name: /Test Connection/i });
+ fireEvent.click(testBtn);
+
+ await waitFor(() => {
+ expect(screen.getByText(/Connection Failed/i)).toBeDefined();
+ expect(
+ screen.getAllByText(/WebSocket connection refused at ws:\/\/10.0.0.10:8106/i).length
+ ).toBeGreaterThanOrEqual(1);
+ });
+ });
+
+
+ it('saves settings when clicking Save & Apply', async () => {
+ vi.spyOn(apiClient.settings, 'update').mockResolvedValue({
+ success: true,
+ message: 'Settings updated successfully',
+ });
+
+ render(
);
+
+ const hostInput = screen.getByLabelText(/Broker Host/i);
+ fireEvent.change(hostInput, { target: { value: '10.0.0.15' } });
+
+ const saveBtn = screen.getByRole('button', { name: /Save & Apply/i });
+ fireEvent.click(saveBtn);
+
+ await waitFor(() => {
+ expect(apiClient.settings.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ mqttHost: '10.0.0.15',
+ })
+ );
+ expect(screen.getByText(/Settings updated successfully/i)).toBeDefined();
+ });
+ });
});
diff --git a/src/CodeMaster.UI/src/components/settings/SettingsView.tsx b/src/CodeMaster.UI/src/components/settings/SettingsView.tsx
index 895d2d5..4597d74 100644
--- a/src/CodeMaster.UI/src/components/settings/SettingsView.tsx
+++ b/src/CodeMaster.UI/src/components/settings/SettingsView.tsx
@@ -1,16 +1,436 @@
-import { Server, Radio, Shield } from 'lucide-react';
+import React, { useEffect } from 'react';
+import {
+ Server,
+ Radio,
+ Shield,
+ Wifi,
+ Cpu,
+ Bell,
+ Save,
+ RefreshCw,
+ CheckCircle2,
+ AlertCircle,
+ Layers,
+} from 'lucide-react';
import { getBasePath } from '../../api/apiClient';
+import { useSettingsStore } from '../../stores/useSettingsStore';
export const SettingsView: React.FC = () => {
const basePath = getBasePath();
+ const {
+ settings,
+ transports,
+ lastTestResult,
+ isSaving,
+ isTesting,
+ error,
+ saveSuccessMessage,
+ fetchSettings,
+ updateSettings,
+ saveSettings,
+ testConnection,
+ } = useSettingsStore();
+
+ useEffect(() => {
+ fetchSettings();
+ }, [fetchSettings]);
+
+ const zWaveTransportInfo = transports.find(
+ (t) => t.transportId === 'zwave_ws' || t.transportId.toLowerCase().includes('zwave')
+ );
+ const mqttTransportInfo = transports.find(
+ (t) => t.transportId === 'mqtt_broker' || t.transportId.toLowerCase().includes('mqtt')
+ );
+
+ const handleSave = async (e: React.FormEvent) => {
+ e.preventDefault();
+ await saveSettings();
+ };
+
+ const handleTestConnection = async () => {
+ await testConnection();
+ };
return (
-
-
-
System & Connectivity Settings
+
+ {/* Header */}
+
+
+
System & Connectivity Settings
+
+ Review integration endpoints, Home Assistant Ingress bindings, and Model Context Protocol (MCP) configuration.
+
+
+
+
+ {isSaving ? (
+ <>
+ Saving...
+ >
+ ) : (
+ <>
+ Save & Apply
+ >
+ )}
+
+
+
+ {/* Global Feedback Banners */}
+ {saveSuccessMessage && (
+
+
+ {saveSuccessMessage}
+
+ )}
+
+ {error && (
+
+ )}
+
+ {/* Z-Wave Driver & Hardware Transport */}
+
+
+
+
+
Z-Wave Transport & Driver
+
+
+ {zWaveTransportInfo && (
+
+ {zWaveTransportInfo.isConnected ? 'Connected' : zWaveTransportInfo.status}
+
+ )}
+
+
- Review integration endpoints, Home Assistant Ingress bindings, and Model Context Protocol (MCP) configuration.
+ Configure how CodeMaster communicates with physical Z-Wave locks, keypads, and scene controllers.
+
+ {/* Segmented Radio Toggle */}
+
+
+ Transport Protocol Mode
+
+
+ updateSettings({ zWaveTransportType: 'WebSocket' })}
+ style={{
+ fontSize: '0.85rem',
+ padding: '0.4rem 0.9rem',
+ border: 'none',
+ minHeight: '34px',
+ }}
+ >
+ WebSocket (Direct)
+
+ updateSettings({ zWaveTransportType: 'Mqtt' })}
+ style={{
+ fontSize: '0.85rem',
+ padding: '0.4rem 0.9rem',
+ border: 'none',
+ minHeight: '34px',
+ }}
+ >
+ MQTT (Mosquitto)
+
+
+
+
+ {/* WebSocket Configuration */}
+ {settings.zWaveTransportType === 'WebSocket' ? (
+
+
+
+ Z-Wave JS WebSocket Server URL
+
+
+ updateSettings({ zWaveWebSocketUrl: e.target.value })}
+ placeholder="ws://10.0.0.10:8106"
+ style={{ flex: 1 }}
+ />
+
+ {isTesting ? (
+ <>
+ Testing...
+ >
+ ) : (
+ 'Test Connection'
+ )}
+
+
+
+ Direct low-latency WebSocket connection to Z-Wave JS Server (e.g. ws://10.0.0.10:8106).
+
+
+
+ {/* Test Connection Results Card */}
+ {lastTestResult && (
+
+
+
+ {lastTestResult.success ? (
+
+ ) : (
+
+ )}
+
+ {lastTestResult.success ? 'WebSocket Connected Successfully' : 'Connection Failed'}
+
+
+
+ {lastTestResult.success && lastTestResult.latencyMs !== undefined && (
+
+ Latency: {lastTestResult.latencyMs}ms
+
+ )}
+
+
+ {lastTestResult.success && (
+
+ {lastTestResult.serverVersion && Server: v{lastTestResult.serverVersion} }
+ {lastTestResult.driverVersion && Driver: v{lastTestResult.driverVersion} }
+ {lastTestResult.nodeCount !== undefined && Nodes: {lastTestResult.nodeCount} }
+
+ )}
+
+ {/* Detected Hardware Nodes Table */}
+ {lastTestResult.success && lastTestResult.detectedNodes && lastTestResult.detectedNodes.length > 0 && (
+
+
+ Discovered Access Devices:
+
+
+
+
+
+ Node
+ Device Name
+ Type
+ Hardware Model
+
+
+
+ {lastTestResult.detectedNodes.map((node) => (
+
+ Node {node.nodeId}
+ {node.name}
+
+
+ {node.deviceType}
+
+
+ {node.model}
+
+ ))}
+
+
+
+
+ )}
+
+ {!lastTestResult.success && (
+
+ {lastTestResult.message}
+
+ )}
+
+ )}
+
+ ) : (
+ /* MQTT Prefix Configuration */
+
+
+ Z-Wave MQTT Topic Prefix
+
+ updateSettings({ zWaveMqttPrefix: e.target.value })}
+ placeholder="zwave"
+ />
+
+ MQTT topic prefix published by Z-Wave JS UI (e.g. 'zwave').
+
+
+ )}
+
+
+ {/* Mosquitto MQTT Broker Configuration */}
+
+
+
+
+
Mosquitto MQTT Broker
+
+
+ {mqttTransportInfo && (
+
+ {mqttTransportInfo.isConnected ? 'Connected' : mqttTransportInfo.status}
+
+ )}
+
+
+
+ Core pub/sub pipeline for Ring keypads, Zigbee contact sensors, and decoupled event dispatching.
+
+
+
+
+
+ {/* Apprise Notifications Card */}
+
+
+
+
Apprise Notification Dispatcher
+
+
+
+ Dispatches real-time alerts for manual unlocks, auto-lock failures, lock jams, and duress PIN triggers across 80+ notification services (Discord, Telegram, Pushover, Email).
+
+
+
+
+ Apprise Endpoint URL / Service Strings
+
+ updateSettings({ appriseUrl: e.target.value })}
+ placeholder="e.g. discord://webhook_id/token, pfall://apikey"
+ />
+
+ Multiple services can be comma-delimited or point to an Apprise microservice container.
+
+
{/* Integration Status Cards */}
@@ -75,6 +495,42 @@ export const SettingsView: React.FC = () => {
CodeMaster never stores plain-text PINs on disk. All credential values are encrypted using AES-256-GCM (keyed from host machine secrets or DPAPI) and hashed via SHA-256 for rapid lookup.
+
+ {/* Active Transports Summary */}
+ {transports.length > 0 && (
+
+
+
+
Active Transport Subsystems
+
+
+
+ {transports.map((t) => (
+
+
+ {t.displayName}
+
+ {t.status}
+
+
+ {t.details && (
+
+ {t.details}
+
+ )}
+
+ ))}
+
+
+ )}
);
};
diff --git a/src/CodeMaster.UI/src/components/users/UserManagement.test.tsx b/src/CodeMaster.UI/src/components/users/UserManagement.test.tsx
index 936cacd..0ea7629 100644
--- a/src/CodeMaster.UI/src/components/users/UserManagement.test.tsx
+++ b/src/CodeMaster.UI/src/components/users/UserManagement.test.tsx
@@ -49,4 +49,54 @@ describe('UserManagement component', () => {
name: 'New Contractor',
}));
});
+
+ it('displays error banner inside modal when submitting an invalid short PIN', async () => {
+ useUserStore.setState({ users: mockUsers });
+ const setPinSpy = vi.spyOn(useUserStore.getState(), 'setUserPin');
+
+ render(
);
+
+ // Open PIN modal
+ const assignBtn = screen.getAllByRole('button', { name: /Assign PIN/i })[0];
+ fireEvent.click(assignBtn);
+
+ expect(screen.getByText('Set PIN & Schedule')).toBeDefined();
+
+ // Enter invalid short PIN (2 digits)
+ const pinInput = screen.getByPlaceholderText('••••');
+ fireEvent.change(pinInput, { target: { value: '12' } });
+
+ const submitBtn = screen.getByRole('button', { name: /Save & Sync Keypads/i });
+ fireEvent.submit(submitBtn.closest('form')!);
+
+ // Error banner should appear inside the modal
+ expect(screen.getByRole('alert')).toBeDefined();
+ expect(screen.getByText(/PIN must be between 4 and 8 numeric digits/i)).toBeDefined();
+
+ // Should NOT call the API
+ expect(setPinSpy).not.toHaveBeenCalled();
+ });
+
+ it('displays backend error message inside modal when API call fails', async () => {
+ useUserStore.setState({ users: mockUsers });
+ vi.spyOn(useUserStore.getState(), 'setUserPin').mockRejectedValue(
+ new Error('API Error [400] Bad Request: {"error":"PIN must be between 4 and 8 numeric digits (0-9)."}')
+ );
+
+ render(
);
+
+ const assignBtn = screen.getAllByRole('button', { name: /Assign PIN/i })[0];
+ fireEvent.click(assignBtn);
+
+ const pinInput = screen.getByPlaceholderText('••••');
+ fireEvent.change(pinInput, { target: { value: '1234' } });
+
+ const submitBtn = screen.getByRole('button', { name: /Save & Sync Keypads/i });
+ fireEvent.submit(submitBtn.closest('form')!);
+
+ // Modal stays open and displays the parsed backend error
+ await screen.findByRole('alert');
+ expect(screen.getByText('PIN must be between 4 and 8 numeric digits (0-9).')).toBeDefined();
+ expect(screen.getByText('Set PIN & Schedule')).toBeDefined();
+ });
});
diff --git a/src/CodeMaster.UI/src/components/users/UserManagement.tsx b/src/CodeMaster.UI/src/components/users/UserManagement.tsx
index 6369d54..e5c2883 100644
--- a/src/CodeMaster.UI/src/components/users/UserManagement.tsx
+++ b/src/CodeMaster.UI/src/components/users/UserManagement.tsx
@@ -1,5 +1,5 @@
import { useState } from 'react';
-import { UserPlus, Key, Trash2, X, Check } from 'lucide-react';
+import { UserPlus, Key, Trash2, X, Check, AlertCircle } from 'lucide-react';
import { useUserStore } from '../../stores/useUserStore';
import { ScheduleEditor } from './ScheduleEditor';
import type { User, UserRoleType, AccessPolicy } from '../../types';
@@ -22,6 +22,7 @@ export const UserManagement: React.FC = () => {
const [pinModalUser, setPinModalUser] = useState
(null);
const [pinCode, setPinCode] = useState('');
const [pinLabel, setPinLabel] = useState('Front Keypad PIN');
+ const [pinModalError, setPinModalError] = useState(null);
const [policyData, setPolicyData] = useState>({
name: 'Standard Access',
scheduleType: 'Always',
@@ -31,6 +32,18 @@ export const UserManagement: React.FC = () => {
const [statusMessage, setStatusMessage] = useState(null);
+ const openPinModal = (user: User) => {
+ setPinModalUser(user);
+ setPinModalError(null);
+ setPinCode('');
+ };
+
+ const closePinModal = () => {
+ setPinModalUser(null);
+ setPinModalError(null);
+ setPinCode('');
+ };
+
const handleCreateUser = async (e: React.FormEvent) => {
e.preventDefault();
if (!newUserName.trim()) return;
@@ -49,18 +62,27 @@ export const UserManagement: React.FC = () => {
const handleSavePinAndSchedule = async (e: React.FormEvent) => {
e.preventDefault();
- if (!pinModalUser || !pinCode.trim()) return;
+ if (!pinModalUser) return;
+
+ const cleanPin = pinCode.trim();
+ if (cleanPin.length < 4 || cleanPin.length > 8 || !/^\d+$/.test(cleanPin)) {
+ setPinModalError('PIN must be between 4 and 8 numeric digits (0-9).');
+ return;
+ }
try {
- await setUserPin(pinModalUser.id, pinCode.trim(), pinLabel);
+ await setUserPin(pinModalUser.id, cleanPin, pinLabel);
await savePolicy(pinModalUser.id, policyData);
setPinModalUser(null);
setPinCode('');
+ setPinModalError(null);
setStatusMessage(`PIN saved and synced for ${pinModalUser.name}`);
setTimeout(() => setStatusMessage(null), 3000);
- } catch {
- setStatusMessage('Failed to save PIN');
+ } catch (err: unknown) {
+ const msg = err instanceof Error ? err.message : 'Failed to save PIN';
+ const match = msg.match(/"error"\s*:\s*"([^"]+)"/);
+ setPinModalError(match ? match[1] : msg);
}
};
@@ -164,7 +186,7 @@ export const UserManagement: React.FC = () => {
setPinModalUser(user)}
+ onClick={() => openPinModal(user)}
style={{ flex: 1, minHeight: '40px', fontSize: '0.85rem' }}
>
{pinCred ? 'Change PIN / Schedule' : 'Assign PIN'}
@@ -229,7 +251,7 @@ export const UserManagement: React.FC = () => {
{/* PIN & Schedule Modal */}
{pinModalUser && (
- setPinModalUser(null)} role="dialog" aria-modal="true">
+
e.stopPropagation()} style={{ maxWidth: '580px' }}>
@@ -241,7 +263,7 @@ export const UserManagement: React.FC = () => {
setPinModalUser(null)}
+ onClick={closePinModal}
style={{ padding: '0.3rem', minHeight: '32px' }}
aria-label="Close dialog"
>
@@ -249,6 +271,27 @@ export const UserManagement: React.FC = () => {
+ {pinModalError && (
+
+ )}
+