@@ -115,98 +105,101 @@ export const SecurityProfilesTab = ({ config, serverName, onConfigChange }: Secu
- {config.securityProfiles.map((profile) => (
-
- {/* Header row with toggle, name, and actions */}
-
-
- {/* Enable Toggle */}
-
-
- {/* Profile Details */}
-
-
- Policy: {profile.securityPolicy}
- {' | '}
- Mode: {profile.securityMode}
-
-
- Authentication: {formatAuthMethods(profile.authMethods)}
-
-
- {getProfileDescription(profile)}
-
-
- {/* Warning for insecure profiles */}
- {profile.securityPolicy === 'None' && profile.enabled && (
-
-
- Warning: No encryption or authentication. Use only for development/testing.
-
-
- )}
-
-
- ))}
-
+ {/* Security Profiles Table */}
+ {config.securityProfiles.length > 0 ? (
+
+
+
+
+ |
+ Enabled
+ |
+ Name |
+
+ Policy
+ |
+ Mode |
+
+ Authentication
+ |
+
+ Actions
+ |
+
+
+
+ {config.securityProfiles.map((profile) => {
+ const isLastEnabled = profile.enabled && config.securityProfiles.filter((p) => p.enabled).length <= 1
+ return (
+
+ |
+
+ |
+ {profile.name} |
+
+ {profile.securityPolicy}
+ |
+ {profile.securityMode} |
+
+ {formatAuthMethods(profile.authMethods)}
+ |
+
+
+
+
+
+ |
+
+ )
+ })}
+
+
+
+ ) : (
+
+ No security profiles configured. Add a profile to allow clients to connect.
+
+ )}
{/* Note about certificates */}
diff --git a/src/frontend/components/_features/[workspace]/editor/server/opcua-server/components/selected-variables-list.tsx b/src/frontend/components/_features/[workspace]/editor/server/opcua-server/components/selected-variables-list.tsx
index f74dc9a6d..0493f3381 100644
--- a/src/frontend/components/_features/[workspace]/editor/server/opcua-server/components/selected-variables-list.tsx
+++ b/src/frontend/components/_features/[workspace]/editor/server/opcua-server/components/selected-variables-list.tsx
@@ -6,31 +6,18 @@ interface SelectedVariablesListProps {
onRemove: (nodeId: string) => void
}
-// Get icon component for node type
-const NodeTypeIcon = ({ nodeType }: { nodeType: OpcUaNodeConfig['nodeType'] }) => {
- switch (nodeType) {
- case 'structure':
- return (
-
- S
-
- )
- case 'array':
- return (
-
- []
-
- )
- case 'variable':
- default:
- return (
-
- V
-
- )
- }
+const NODE_TYPE_LABEL: Record, string> = {
+ structure: 'S',
+ array: '[]',
+ variable: 'V',
}
+const NodeTypeIcon = ({ nodeType }: { nodeType: OpcUaNodeConfig['nodeType'] }) => (
+
+ {NODE_TYPE_LABEL[nodeType] ?? 'V'}
+
+)
+
// Format permissions for display
const formatPermissions = (permissions: OpcUaNodeConfig['permissions']): string => {
return `V:${permissions.viewer} O:${permissions.operator} E:${permissions.engineer}`
@@ -83,7 +70,7 @@ export const SelectedVariablesList = ({ nodes, onEdit, onRemove }: SelectedVaria
diff --git a/src/frontend/components/_features/[workspace]/editor/server/opcua-server/components/user-modal.tsx b/src/frontend/components/_features/[workspace]/editor/server/opcua-server/components/user-modal.tsx
index 533d199a1..1636f1659 100644
--- a/src/frontend/components/_features/[workspace]/editor/server/opcua-server/components/user-modal.tsx
+++ b/src/frontend/components/_features/[workspace]/editor/server/opcua-server/components/user-modal.tsx
@@ -121,62 +121,65 @@ export const UserModal = ({
)
}, [trustedCertificates, usedCertificateIds, existingUser])
- // Validation rules
- const validationErrors = useMemo(() => {
- const errors: string[] = []
-
- if (authType === 'password') {
- // Username validation
- if (!username.trim()) {
- errors.push('Username is required')
- } else if (username.length > 64) {
- errors.push('Username must be 64 characters or less')
- } else {
- // Check for duplicate username (case insensitive), excluding current user when editing
- const isDuplicate = existingUsernames.some(
- (existingName) =>
- existingName.toLowerCase() === username.trim().toLowerCase() &&
- (!existingUser || existingUser.username?.toLowerCase() !== existingName.toLowerCase()),
- )
- if (isDuplicate) {
- errors.push('A user with this username already exists')
- }
- }
-
- // Password validation (only required for new users or when changing password)
- if (!isEditing || password) {
- if (!password) {
- errors.push('Password is required')
- } else if (password.length < 4) {
- errors.push('Password must be at least 4 characters')
- } else if (password !== confirmPassword) {
- errors.push('Passwords do not match')
- }
- }
- } else {
- // Certificate validation
- if (!certificateId) {
- errors.push('Please select a certificate')
- }
- if (availableCertificates.length === 0 && !existingUser?.certificateId) {
- errors.push('No trusted certificates available. Add certificates in the Certificates tab first.')
- }
+ // Per-field validation. Each error string surfaces inline under its
+ // corresponding input rather than in a bottom validation card — same
+ // pattern the password-mismatch label uses.
+ //
+ // The mismatch and "passwords-not-yet-confirmed" gates live in their
+ // own flags below since they have different visibility rules from
+ // the field-error pattern (mismatch is only shown once the user has
+ // typed in confirmPassword; missing confirmPassword silently
+ // disables Save without a label).
+
+ const usernameError = useMemo(() => {
+ if (authType !== 'password') return null
+ if (!username.trim()) return 'Username is required'
+ if (username.length > 64) return 'Username must be 64 characters or less'
+ const isDuplicate = existingUsernames.some(
+ (existingName) =>
+ existingName.toLowerCase() === username.trim().toLowerCase() &&
+ (!existingUser || existingUser.username?.toLowerCase() !== existingName.toLowerCase()),
+ )
+ if (isDuplicate) return 'A user with this username already exists'
+ return null
+ }, [authType, username, existingUsernames, existingUser])
+
+ const passwordError = useMemo(() => {
+ if (authType !== 'password') return null
+ // Editing path: an empty password means "keep existing" — not an error.
+ if (isEditing && !password) return null
+ if (!password) return 'Password is required'
+ if (password.length < 4) return 'Password must be at least 4 characters'
+ return null
+ }, [authType, password, isEditing])
+
+ const certificateError = useMemo(() => {
+ if (authType !== 'certificate') return null
+ if (availableCertificates.length === 0 && !existingUser?.certificateId) {
+ // The certificate-auth section already renders this banner inline
+ // when there are no trusted certs. Skip surfacing it as a duplicate
+ // field error.
+ return null
}
-
- return errors
- }, [
- authType,
- username,
- password,
- confirmPassword,
- certificateId,
- existingUsernames,
- existingUser,
- isEditing,
- availableCertificates,
- ])
-
- const isValid = validationErrors.length === 0
+ if (!certificateId) return 'Please select a certificate'
+ return null
+ }, [authType, certificateId, availableCertificates, existingUser])
+
+ // Password mismatch handling has two flavours:
+ // - `passwordsMismatchVisible`: drives the inline red label under the
+ // Confirm Password input. Only true once the user has typed something
+ // in confirmPassword, so the error doesn't flicker while they're still
+ // filling out the form.
+ // - `passwordsOk`: gates the Save button. Requires both fields to be
+ // filled AND match when a password is being set (new user, or editing
+ // user with non-empty password). Empty confirmPassword keeps Save
+ // disabled silently — no error is shown until the user types into the
+ // confirm field.
+ const isSettingPassword = authType === 'password' && (!isEditing || password.length > 0)
+ const passwordsMismatchVisible = isSettingPassword && confirmPassword.length > 0 && password !== confirmPassword
+ const passwordsOk = !isSettingPassword || (confirmPassword.length > 0 && password === confirmPassword)
+
+ const isValid = !usernameError && !passwordError && !certificateError && passwordsOk
// Handle save
const handleSave = useCallback(async () => {
@@ -266,6 +269,9 @@ export const UserModal = ({
maxLength={64}
className={inputStyles}
/>
+ {usernameError && (
+ {usernameError}
+ )}
{/* Password */}
@@ -306,6 +312,9 @@ export const UserModal = ({
)}