Skip to content
32 changes: 32 additions & 0 deletions src/backend/shared/firmware/__tests__/runtime-version-gate.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,42 @@
import {
describeIncompatibleRuntime,
isStrucppCompatibleRuntime,
isUserManagementCapableRuntime,
MIN_STRUCPP_RUNTIME_VERSION,
MIN_USER_MANAGEMENT_RUNTIME_VERSION,
parseRuntimeVersion,
} from '../runtime-version-gate'

describe('isUserManagementCapableRuntime', () => {
it('is exposed with the documented minimum version', () => {
expect(MIN_USER_MANAGEMENT_RUNTIME_VERSION).toBe('4.1.9')
})

it('accepts v4.1.9 and newer', () => {
expect(isUserManagementCapableRuntime('v4.1.9')).toBe(true)
expect(isUserManagementCapableRuntime('4.1.10')).toBe(true)
expect(isUserManagementCapableRuntime('v4.2.0')).toBe(true)
expect(isUserManagementCapableRuntime('v5.0.0')).toBe(true)
})

it('accepts a pre-release on the target patch', () => {
expect(isUserManagementCapableRuntime('v4.1.9-rc.1')).toBe(true)
})

it('rejects versions older than 4.1.9', () => {
expect(isUserManagementCapableRuntime('v4.1.8')).toBe(false)
expect(isUserManagementCapableRuntime('v4.0.9')).toBe(false)
expect(isUserManagementCapableRuntime('v3.9.9')).toBe(false)
})

it('rejects unparseable / legacy version strings', () => {
expect(isUserManagementCapableRuntime('v4')).toBe(false)
expect(isUserManagementCapableRuntime('dev')).toBe(false)
expect(isUserManagementCapableRuntime(null)).toBe(false)
expect(isUserManagementCapableRuntime(undefined)).toBe(false)
})
})

describe('parseRuntimeVersion', () => {
it('parses tagged release versions (with and without leading v)', () => {
expect(parseRuntimeVersion('v4.1.0')).toEqual({ major: 4, minor: 1, patch: 0 })
Expand Down
20 changes: 20 additions & 0 deletions src/backend/shared/firmware/runtime-version-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,26 @@ export function isStrucppCompatibleRuntime(raw: string | null | undefined): bool
return v.minor >= 1
}

/** Minimum runtime version that ships the user-management API
* (roles, whoami, unified update-user, delete/last-admin guards). */
export const MIN_USER_MANAGEMENT_RUNTIME_VERSION = '4.1.9'

/**
* Returns true iff the runtime version string represents a runtime
* that ships the user-management API (≥ 4.1.9). Older runtimes lack
* `whoami` / `update-user` and the RBAC guards, so the editor hides
* the User Management screen for them. Pre-release tags on the target
* patch (e.g. `v4.1.9-rc.1`) count as capable, matching the strucpp
* gate's treatment of the rc lineage.
*/
export function isUserManagementCapableRuntime(raw: string | null | undefined): boolean {
const v = parseRuntimeVersion(raw)
if (!v) return false
if (v.major !== 4) return v.major > 4
if (v.minor !== 1) return v.minor > 1
return v.patch >= 9
}

/**
* Human-readable explanation suitable for surfacing as an error
* when the gate rejects a runtime. The reported version (or
Expand Down
38 changes: 38 additions & 0 deletions src/frontend/assets/icons/project/Users.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { ComponentProps } from 'react'

import { cn } from '../../../utils/cn'

type IUsersIconProps = ComponentProps<'svg'> & {
size?: 'sm' | 'md' | 'lg'
}

const sizeClasses = {
sm: 'w-5 h-5',
md: 'w-6 h-6',
lg: 'w-12 h-12',
}

export const UsersIcon = (props: IUsersIconProps) => {
const { className, size = 'sm', ...res } = props
return (
<svg
role='button'
viewBox='0 0 28 28'
fill='none'
xmlns='http://www.w3.org/2000/svg'
className={cn(`${sizeClasses[size]}`, className)}
{...res}
Comment on lines +18 to +24

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not expose the decorative SVG as a button.

role='button' gives this SVG button semantics, but it has no accessible name, focusability, or keyboard handler; activation is handled by the parent tree item. Remove the role and mark the icon decorative, or move button semantics to the interactive leaf control.

🤖 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/assets/icons/project/Users.tsx` around lines 18 - 24, Update the
SVG element in the Users icon component to remove its role='button' semantics
and mark it as decorative for assistive technologies; preserve button behavior
on the parent tree item or other interactive leaf control.

>
<circle cx='11' cy='9' r='4' fill='#023C97' />
<path
d='M4 22C4 18.134 7.13401 15 11 15C14.866 15 18 18.134 18 22V23C18 23.5523 17.5523 24 17 24H5C4.44772 24 4 23.5523 4 23V22Z'
fill='#023C97'
/>
<circle cx='20' cy='10.5' r='3' fill='#B4D0FE' />
<path
d='M19 16C22.3137 16 25 18.6863 25 22V22.5C25 23.3284 24.3284 24 23.5 24H20.5C20.7761 24 21 23.7761 21 23.5V22C21 19.9954 20.2159 18.1738 18.9385 16.8262C19.0252 16.8154 19.1123 16.8079 19.2 16.8038C19.1327 16.5411 19.0693 16.2734 19 16Z'
fill='#B4D0FE'
/>
</svg>
)
}
6 changes: 6 additions & 0 deletions src/frontend/components/_atoms/tab/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { GitCompare } from 'lucide-react'
import type React from 'react'
import { ComponentPropsWithoutRef, useCallback } from 'react'
Expand All @@ -21,6 +21,7 @@
import { SFCIcon } from '../../../assets/icons/project/SFC'
import { STIcon } from '../../../assets/icons/project/ST'
import { StructureIcon } from '../../../assets/icons/project/Structure'
import { UsersIcon } from '../../../assets/icons/project/Users'
import { useOpenPLCStore } from '../../../store'
import type { TabsProps } from '../../../store/slices/tabs'
import { cn } from '../../../utils/cn'
Expand Down Expand Up @@ -56,6 +57,7 @@
'ethercat-device': <DeviceTransferIcon className='h-4 w-4 flex-shrink-0' />,
'library-manager': <LibraryIcon className='h-4 w-4 flex-shrink-0' />,
'library-manifest': <LibraryManifestIcon className='h-4 w-4 flex-shrink-0' />,
'user-management': <UsersIcon className='h-4 w-4 flex-shrink-0' />,
'diff-viewer': <GitCompare className='h-4 w-4 flex-shrink-0 text-[#0464FB]' />,
}

Expand Down Expand Up @@ -87,6 +89,7 @@
| 'ethercat-device'
| 'library-manager'
| 'library-manifest'
| 'user-management'
| 'diff-viewer' = 'il'

if (fileDerivation?.type === 'data-type' || fileDerivation?.type === 'device') {
Expand Down Expand Up @@ -123,6 +126,9 @@
if (fileDerivation?.type === 'library-manifest') {
languageOrDerivation = 'library-manifest'
}
if (fileDerivation?.type === 'user-management') {
languageOrDerivation = 'user-management'
}
if (fileDerivation?.type === 'diff-viewer') {
languageOrDerivation = 'diff-viewer'
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
/* eslint-disable @typescript-eslint/no-misused-promises */
import type { TimingStats } from '@root/middleware/shared/ports/types'
import { useCapabilities, useDevice, useRuntime } from '@root/middleware/shared/providers/platform-context'
Expand Down Expand Up @@ -60,6 +60,7 @@
const setRuntimeIpAddress = useOpenPLCStore((state) => state.deviceActions.setRuntimeIpAddress)
const setRuntimeConnectionStatus = useOpenPLCStore((state) => state.deviceActions.setRuntimeConnectionStatus)
const setRuntimeJwtToken = useOpenPLCStore((state) => state.deviceActions.setRuntimeJwtToken)
const setRuntimeVersion = useOpenPLCStore((state) => state.deviceActions.setRuntimeVersion)
const openModal = useOpenPLCStore((state) => state.modalActions.openModal)
const plcStatus = useOpenPLCStore((state): RuntimeConnection['plcStatus'] => state.runtimeConnection.plcStatus)
const timingStats = useOpenPLCStore((state): TimingStats | null => state.runtimeConnection.timingStats)
Expand Down Expand Up @@ -365,6 +366,10 @@
return
}

// Remember the runtime version so version-gated UI (e.g. User
// Management) can react to it for the lifetime of the connection.
setRuntimeVersion(result.runtimeVersion ?? null)
Comment on lines +369 to +371

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Clear runtimeVersion when the connection attempt is abandoned.

Line [371] persists a version before login/connection validation completes. The handler’s disconnect, error, and cancel paths only update status/token, so the store can retain a non-null version while disconnected or errored, contrary to RuntimeConnection.runtimeVersion’s documented contract in src/frontend/store/slices/device/types.ts Lines [67-70]. Reset it on teardown/failure, ideally through one cleanup path.

🤖 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/device/configuration/board.tsx
around lines 369 - 371, Clear the runtime version during all abandoned
connection paths in the handler containing setRuntimeVersion, including
disconnect, error, and cancel teardown. Consolidate cleanup where possible so
status, token, and runtimeVersion are reset together, ensuring
RuntimeConnection.runtimeVersion is null whenever the connection is disconnected
or failed.


// Validate runtime version matches the selected board target
const versionValidation = validateRuntimeVersion(deviceBoard, result.runtimeVersion)

Expand Down
Loading
Loading