Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { cn } from '@root/frontend/utils/cn'
import type { OpcUaTrustedCertificate } from '@root/middleware/shared/ports/types'
import { useCallback, useEffect, useMemo, useState } from 'react'
Expand Down Expand Up @@ -226,10 +226,10 @@

{/* Validation Errors */}
{validationErrors.length > 0 && (
<div className='rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-900 dark:bg-red-950'>
<div className='rounded-lg border border-neutral-200 bg-neutral-50 p-3 dark:border-neutral-800 dark:bg-neutral-900'>
<ul className='list-inside list-disc space-y-1'>
{validationErrors.map((error, index) => (
<li key={index} className='text-xs text-red-600 dark:text-red-400'>
<li key={index} className='text-xs text-neutral-700 dark:text-neutral-300'>
{error}
</li>
))}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { PlusIcon, TrashIcon } from '@radix-ui/react-icons'
import { useOpenPLCStore } from '@root/frontend/store'
import { cn } from '@root/frontend/utils/cn'
import type { OpcUaServerConfig, OpcUaTrustedCertificate } from '@root/middleware/shared/ports/types'
Expand Down Expand Up @@ -250,67 +251,64 @@
<button
type='button'
onClick={handleAddCertificate}
className='flex h-[36px] w-fit items-center gap-2 rounded-md border border-neutral-300 bg-white px-4 font-caption text-xs font-medium text-neutral-700 hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200 dark:hover:bg-neutral-700'
className='flex w-fit items-center gap-2 rounded-md bg-brand px-3 py-2 text-sm font-medium text-white hover:bg-brand-medium-dark'
>
<span className='text-lg leading-none'>+</span>
<PlusIcon className='h-4 w-4' />
Add Trusted Certificate
</button>

{/* Certificates List */}
{/* Certificates Table */}
{config.security.trustedClientCertificates.length === 0 ? (
<div className='rounded-lg border border-neutral-200 bg-neutral-50 p-4 text-center dark:border-neutral-800 dark:bg-neutral-900'>
<p className='text-xs text-neutral-500 dark:text-neutral-400'>
No trusted certificates configured. Add certificates to enable certificate-based authentication.
</p>
</div>
<p className='text-sm text-neutral-500 dark:text-neutral-400'>
No trusted certificates configured. Add certificates to enable certificate-based authentication.
</p>
) : (
<div className='flex flex-col gap-3'>
{config.security.trustedClientCertificates.map((cert) => (
<div
key={cert.id}
className='flex flex-col gap-2 rounded-lg border border-neutral-200 bg-neutral-50 p-4 dark:border-neutral-800 dark:bg-neutral-900'
>
{/* Header row with icon, name, and actions */}
<div className='flex items-center justify-between'>
<div className='flex items-center gap-3'>
<span className='text-lg'>📜</span>
<span className='font-caption text-sm font-semibold text-neutral-950 dark:text-white'>
{cert.id}
</span>
</div>

{/* Actions */}
<div className='flex items-center gap-2'>
<button
type='button'
onClick={() => handleDeleteCertificate(cert.id)}
className='h-[28px] rounded-md border border-red-300 bg-white px-3 font-caption text-xs font-medium text-red-600 hover:bg-red-50 dark:border-red-800 dark:bg-neutral-800 dark:text-red-400 dark:hover:bg-red-950'
>
Delete
</button>
</div>
</div>

{/* Certificate Details */}
<div className='flex flex-col gap-1 pl-[36px]'>
{cert.subject && (
<p className='font-caption text-xs text-neutral-600 dark:text-neutral-400'>
<span className='font-medium'>Subject:</span> {cert.subject}
</p>
)}
{cert.validFrom && cert.validTo && (
<p className='font-caption text-xs text-neutral-600 dark:text-neutral-400'>
<span className='font-medium'>Valid:</span> {cert.validFrom} to {cert.validTo}
</p>
)}
{cert.fingerprint && (
<p className='font-caption text-xs text-neutral-500 dark:text-neutral-500'>
<span className='font-medium'>Fingerprint:</span> {cert.fingerprint}
</p>
)}
</div>
</div>
))}
<div className='overflow-hidden rounded-md border border-neutral-200 dark:border-neutral-700'>
<table className='w-full'>
<thead className='bg-neutral-50 dark:bg-neutral-800'>
<tr>
<th className='px-3 py-2 text-left text-xs font-medium text-neutral-600 dark:text-neutral-400'>ID</th>
<th className='px-3 py-2 text-left text-xs font-medium text-neutral-600 dark:text-neutral-400'>
Subject
</th>
<th className='px-3 py-2 text-left text-xs font-medium text-neutral-600 dark:text-neutral-400'>
Valid
</th>
<th className='px-3 py-2 text-left text-xs font-medium text-neutral-600 dark:text-neutral-400'>
Fingerprint
</th>
<th className='px-3 py-2 text-right text-xs font-medium text-neutral-600 dark:text-neutral-400'>
Actions
</th>
</tr>
</thead>
<tbody>
{config.security.trustedClientCertificates.map((cert) => (
<tr key={cert.id} className='border-t border-neutral-200 dark:border-neutral-700'>
<td className='px-3 py-2 text-sm text-neutral-900 dark:text-neutral-100'>{cert.id}</td>
<td className='px-3 py-2 text-sm text-neutral-600 dark:text-neutral-400'>{cert.subject || '-'}</td>
<td className='px-3 py-2 text-sm text-neutral-600 dark:text-neutral-400'>
{cert.validFrom && cert.validTo ? `${cert.validFrom} to ${cert.validTo}` : '-'}
</td>
<td className='px-3 py-2 font-mono text-xs text-neutral-500 dark:text-neutral-500'>
{cert.fingerprint || '-'}
</td>
<td className='px-3 py-2 text-right'>
<div className='flex justify-end gap-2'>
<button
type='button'
onClick={() => handleDeleteCertificate(cert.id)}
className='rounded p-1 text-neutral-500 hover:bg-red-100 hover:text-red-600 dark:text-neutral-400 dark:hover:bg-red-900/30 dark:hover:text-red-400'
title='Delete'
>
<TrashIcon className='h-4 w-4' />
</button>
Comment on lines +298 to +305
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Provide an accessible name for the icon-only delete control.

Please add aria-label; relying on title alone is insufficient for consistent assistive-tech support.

Proposed fix
                         <button
                           type='button'
                           onClick={() => handleDeleteCertificate(cert.id)}
                           className='rounded p-1 text-neutral-500 hover:bg-red-100 hover:text-red-600 dark:text-neutral-400 dark:hover:bg-red-900/30 dark:hover:text-red-400'
+                          aria-label={`Delete trusted certificate ${cert.id}`}
                           title='Delete'
                         >
                           <TrashIcon className='h-4 w-4' />
                         </button>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button
type='button'
onClick={() => handleDeleteCertificate(cert.id)}
className='rounded p-1 text-neutral-500 hover:bg-red-100 hover:text-red-600 dark:text-neutral-400 dark:hover:bg-red-900/30 dark:hover:text-red-400'
title='Delete'
>
<TrashIcon className='h-4 w-4' />
</button>
<button
type='button'
onClick={() => handleDeleteCertificate(cert.id)}
className='rounded p-1 text-neutral-500 hover:bg-red-100 hover:text-red-600 dark:text-neutral-400 dark:hover:bg-red-900/30 dark:hover:text-red-400'
aria-label={`Delete trusted certificate ${cert.id}`}
title='Delete'
>
<TrashIcon className='h-4 w-4' />
</button>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/frontend/components/_features/`[workspace]/editor/server/opcua-server/components/certificates-tab.tsx
around lines 298 - 305, The delete button using <TrashIcon /> lacks an
accessible name; update the button element that calls
handleDeleteCertificate(cert.id) to include an aria-label (e.g.,
aria-label={`Delete certificate ${cert.id}`} or include cert.name if available)
so assistive tech can announce the control; keep the existing title and visual
markup but add the aria-label attribute to the same button element to provide a
clear, unique accessible name.

</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { cn } from '@root/frontend/utils/cn'
import type { OpcUaSecurityProfile } from '@root/middleware/shared/ports/types'
import { useCallback, useEffect, useMemo, useState } from 'react'
Expand Down Expand Up @@ -351,10 +351,10 @@

{/* Validation Errors */}
{validationErrors.length > 0 && (
<div className='rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-900 dark:bg-red-950'>
<div className='rounded-lg border border-neutral-200 bg-neutral-50 p-3 dark:border-neutral-800 dark:bg-neutral-900'>
<ul className='list-inside list-disc space-y-1'>
{validationErrors.map((error, index) => (
<li key={index} className='text-xs text-red-600 dark:text-red-400'>
<li key={index} className='text-xs text-neutral-700 dark:text-neutral-300'>
{error}
</li>
))}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Pencil1Icon, PlusIcon, TrashIcon } from '@radix-ui/react-icons'
import { useOpenPLCStore } from '@root/frontend/store'
import { cn } from '@root/frontend/utils/cn'
import type { OpcUaSecurityProfile, OpcUaServerConfig } from '@root/middleware/shared/ports/types'
Expand All @@ -11,17 +12,6 @@
onConfigChange: () => void
}

// Helper to get a human-readable description for a security profile
const getProfileDescription = (profile: OpcUaSecurityProfile): string => {
if (profile.securityPolicy === 'None') {
return 'No encryption or authentication. Use only for development/testing.'
}
if (profile.securityMode === 'Sign') {
return 'Messages are signed but not encrypted.'
}
return 'Full security: messages are signed and encrypted.'
}

// Helper to format auth methods for display
const formatAuthMethods = (methods: OpcUaSecurityProfile['authMethods']): string => {
return methods.join(', ')
Expand Down Expand Up @@ -104,8 +94,8 @@

{/* Warning if no profiles are enabled */}
{!hasEnabledProfile && (
<div className='rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900 dark:bg-amber-950'>
<p className='text-xs text-amber-700 dark:text-amber-400'>
<div className='rounded-lg border border-neutral-200 bg-neutral-50 p-3 dark:border-neutral-800 dark:bg-neutral-900'>
<p className='text-xs text-neutral-700 dark:text-neutral-300'>
Warning: At least one security profile must be enabled for clients to connect.
</p>
</div>
Expand All @@ -115,98 +105,101 @@
<button
type='button'
onClick={handleAddProfile}
className='flex h-[36px] w-fit items-center gap-2 rounded-md border border-neutral-300 bg-white px-4 font-caption text-xs font-medium text-neutral-700 hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200 dark:hover:bg-neutral-700'
className='flex w-fit items-center gap-2 rounded-md bg-brand px-3 py-2 text-sm font-medium text-white hover:bg-brand-medium-dark'
>
<span className='text-lg leading-none'>+</span>
<PlusIcon className='h-4 w-4' />
Add Security Profile
</button>

{/* Security Profiles List */}
<div className='flex flex-col gap-3'>
{config.securityProfiles.map((profile) => (
<div
key={profile.id}
className='flex flex-col gap-3 rounded-lg border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900'
>
{/* Header row with toggle, name, and actions */}
<div className='flex items-center justify-between'>
<div className='flex items-center gap-4'>
{/* Enable Toggle */}
<label className='relative inline-flex cursor-pointer items-center'>
<input
type='checkbox'
checked={profile.enabled}
onChange={() => handleToggleEnabled(profile)}
className='peer sr-only'
disabled={profile.enabled && config.securityProfiles.filter((p) => p.enabled).length <= 1}
/>
<div
className={cn(
'h-5 w-9 rounded-full bg-neutral-300 after:absolute after:left-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:bg-white after:transition-all after:content-[""]',
'peer-checked:bg-brand peer-checked:after:translate-x-full',
'dark:bg-neutral-700 dark:peer-checked:bg-brand',
profile.enabled &&
config.securityProfiles.filter((p) => p.enabled).length <= 1 &&
'cursor-not-allowed opacity-50',
)}
/>
</label>

{/* Profile Name */}
<span className='font-caption text-sm font-semibold text-neutral-950 dark:text-white'>
{profile.name}
</span>
</div>

{/* Actions */}
<div className='flex items-center gap-2'>
<button
type='button'
onClick={() => handleEditProfile(profile)}
className='h-[28px] rounded-md border border-neutral-300 bg-white px-3 font-caption text-xs font-medium text-neutral-700 hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-200 dark:hover:bg-neutral-700'
>
Edit
</button>
<button
type='button'
onClick={() => handleDeleteProfile(profile.id)}
disabled={config.securityProfiles.length <= 1}
className={cn(
'h-[28px] rounded-md border border-red-300 bg-white px-3 font-caption text-xs font-medium text-red-600 hover:bg-red-50 dark:border-red-800 dark:bg-neutral-800 dark:text-red-400 dark:hover:bg-red-950',
config.securityProfiles.length <= 1 && 'cursor-not-allowed opacity-50',
)}
>
Delete
</button>
</div>
</div>

{/* Profile Details */}
<div className='flex flex-col gap-1 pl-[52px]'>
<p className='font-caption text-xs text-neutral-600 dark:text-neutral-400'>
<span className='font-medium'>Policy:</span> {profile.securityPolicy}
{' | '}
<span className='font-medium'>Mode:</span> {profile.securityMode}
</p>
<p className='font-caption text-xs text-neutral-600 dark:text-neutral-400'>
<span className='font-medium'>Authentication:</span> {formatAuthMethods(profile.authMethods)}
</p>
<p className='font-caption text-xs text-neutral-500 dark:text-neutral-500'>
{getProfileDescription(profile)}
</p>

{/* Warning for insecure profiles */}
{profile.securityPolicy === 'None' && profile.enabled && (
<div className='mt-2 rounded bg-amber-50 p-2 dark:bg-amber-950'>
<p className='text-xs text-amber-700 dark:text-amber-400'>
Warning: No encryption or authentication. Use only for development/testing.
</p>
</div>
)}
</div>
</div>
))}
</div>
{/* Security Profiles Table */}
{config.securityProfiles.length > 0 ? (
<div className='overflow-hidden rounded-md border border-neutral-200 dark:border-neutral-700'>
<table className='w-full'>
<thead className='bg-neutral-50 dark:bg-neutral-800'>
<tr>
<th className='px-3 py-2 text-left text-xs font-medium text-neutral-600 dark:text-neutral-400'>
Enabled
</th>
<th className='px-3 py-2 text-left text-xs font-medium text-neutral-600 dark:text-neutral-400'>Name</th>
<th className='px-3 py-2 text-left text-xs font-medium text-neutral-600 dark:text-neutral-400'>
Policy
</th>
<th className='px-3 py-2 text-left text-xs font-medium text-neutral-600 dark:text-neutral-400'>Mode</th>
<th className='px-3 py-2 text-left text-xs font-medium text-neutral-600 dark:text-neutral-400'>
Authentication
</th>
<th className='px-3 py-2 text-right text-xs font-medium text-neutral-600 dark:text-neutral-400'>
Actions
</th>
</tr>
</thead>
<tbody>
{config.securityProfiles.map((profile) => {
const isLastEnabled = profile.enabled && config.securityProfiles.filter((p) => p.enabled).length <= 1
return (
Comment on lines +137 to +139
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Deleting an enabled profile can bypass the “at least one enabled” rule.

The toggle path blocks disabling the last enabled profile, but delete only checks total row count. If one profile is enabled and others are disabled, deleting the enabled one leaves zero enabled profiles.

Proposed fix
               {config.securityProfiles.map((profile) => {
-                const isLastEnabled =
-                  profile.enabled && config.securityProfiles.filter((p) => p.enabled).length <= 1
+                const enabledCount = config.securityProfiles.filter((p) => p.enabled).length
+                const isLastEnabled = profile.enabled && enabledCount <= 1
+                const disableDelete = config.securityProfiles.length <= 1 || isLastEnabled
                 return (
@@
                         <button
                           type='button'
                           onClick={() => handleDeleteProfile(profile.id)}
-                          disabled={config.securityProfiles.length <= 1}
+                          disabled={disableDelete}
                           className={cn(
                             'rounded p-1 text-neutral-500 hover:bg-red-100 hover:text-red-600 dark:text-neutral-400 dark:hover:bg-red-900/30 dark:hover:text-red-400',
-                            config.securityProfiles.length <= 1 && 'cursor-not-allowed opacity-50',
+                            disableDelete && 'cursor-not-allowed opacity-50',
                           )}
                           title='Delete'
                         >

Also applies to: 179-186

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/frontend/components/_features/`[workspace]/editor/server/opcua-server/components/security-profiles-tab.tsx
around lines 137 - 140, The delete path currently only checks total row count
and can remove the last enabled profile; update the deletion check and any
delete handlers to consider enabled count: compute const enabledCount =
config.securityProfiles.filter(p => p.enabled).length and before allowing delete
(or enabling the delete button) disallow removing a profile when profile.enabled
&& enabledCount <= 1; apply the same check where you render the delete action
and in the actual delete handler used by the map over config.securityProfiles
(the block using isLastEnabled and the separate delete code at the later section
referenced) so deletion is prevented consistently.

<tr key={profile.id} className='border-t border-neutral-200 dark:border-neutral-700'>
<td className='px-3 py-2'>
<label className='relative inline-flex cursor-pointer items-center'>
<input
type='checkbox'
checked={profile.enabled}
onChange={() => handleToggleEnabled(profile)}
className='peer sr-only'
disabled={isLastEnabled}
Comment on lines +142 to +148
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add ARIA labels to unnamed toggle/action controls.

The enabled checkbox has no text label, and Edit/Delete are icon-only buttons. Please add explicit aria-labels so controls are announced clearly by assistive technologies.

Proposed fix
                         <input
                           type='checkbox'
                           checked={profile.enabled}
                           onChange={() => handleToggleEnabled(profile)}
                           className='peer sr-only'
                           disabled={isLastEnabled}
+                          aria-label={`Enable security profile ${profile.name}`}
                         />
@@
                         <button
                           type='button'
                           onClick={() => handleEditProfile(profile)}
                           className='rounded p-1 text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-200'
+                          aria-label={`Edit security profile ${profile.name}`}
                           title='Edit'
                         >
@@
                         <button
                           type='button'
                           onClick={() => handleDeleteProfile(profile.id)}
                           disabled={config.securityProfiles.length <= 1}
                           className={cn(
                             'rounded p-1 text-neutral-500 hover:bg-red-100 hover:text-red-600 dark:text-neutral-400 dark:hover:bg-red-900/30 dark:hover:text-red-400',
                             config.securityProfiles.length <= 1 && 'cursor-not-allowed opacity-50',
                           )}
+                          aria-label={`Delete security profile ${profile.name}`}
                           title='Delete'
                         >

Also applies to: 171-190

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/frontend/components/_features/`[workspace]/editor/server/opcua-server/components/security-profiles-tab.tsx
around lines 143 - 149, The checkbox and icon-only buttons in the
SecurityProfilesTab component lack accessible names; update the input and the
Edit/Delete icon buttons to include descriptive aria-labels that use the profile
context (e.g., for the checkbox: aria-label={`Toggle ${profile.name} enabled`}
or similar, and for the Edit/Delete buttons: aria-label={`Edit ${profile.name}
profile`} and aria-label={`Delete ${profile.name} profile`}); locate the
controls around handleToggleEnabled/profile.enabled and the icon buttons (the
Edit/Delete handlers) and add the aria-label props so screen readers announce
each control clearly.

/>
<div
className={cn(
'h-5 w-9 rounded-full bg-neutral-300 after:absolute after:left-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:bg-white after:transition-all after:content-[""]',
'peer-checked:bg-brand peer-checked:after:translate-x-full',
'dark:bg-neutral-700 dark:peer-checked:bg-brand',
isLastEnabled && 'cursor-not-allowed opacity-50',
)}
/>
</label>
</td>
<td className='px-3 py-2 text-sm text-neutral-900 dark:text-neutral-100'>{profile.name}</td>
<td className='px-3 py-2 text-sm text-neutral-600 dark:text-neutral-400'>
{profile.securityPolicy}
</td>
<td className='px-3 py-2 text-sm text-neutral-600 dark:text-neutral-400'>{profile.securityMode}</td>
<td className='px-3 py-2 text-sm text-neutral-600 dark:text-neutral-400'>
{formatAuthMethods(profile.authMethods)}
</td>
<td className='px-3 py-2 text-right'>
<div className='flex justify-end gap-2'>
<button
type='button'
onClick={() => handleEditProfile(profile)}
className='rounded p-1 text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-neutral-200'
title='Edit'
>
<Pencil1Icon className='h-4 w-4' />
</button>
<button
type='button'
onClick={() => handleDeleteProfile(profile.id)}
disabled={config.securityProfiles.length <= 1}
className={cn(
'rounded p-1 text-neutral-500 hover:bg-red-100 hover:text-red-600 dark:text-neutral-400 dark:hover:bg-red-900/30 dark:hover:text-red-400',
config.securityProfiles.length <= 1 && 'cursor-not-allowed opacity-50',
)}
title='Delete'
>
<TrashIcon className='h-4 w-4' />
</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
) : (
<p className='text-sm text-neutral-500 dark:text-neutral-400'>
No security profiles configured. Add a profile to allow clients to connect.
</p>
)}

{/* Note about certificates */}
<div className='mt-2 border-t border-neutral-200 pt-4 dark:border-neutral-800'>
Expand Down
Loading
Loading