-
Notifications
You must be signed in to change notification settings - Fork 62
fix(opcua): align UI with design system #744
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c41bd98
f15868a
deabccb
84525cd
3b0a2d8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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' | ||
|
|
@@ -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(', ') | ||
|
|
@@ -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> | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 |
||
| <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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 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 |
||
| /> | ||
| <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'> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Provide an accessible name for the icon-only delete control.
Please add
aria-label; relying ontitlealone 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
🤖 Prompt for AI Agents