diff --git a/src/backend/shared/firmware/__tests__/runtime-version-gate.test.ts b/src/backend/shared/firmware/__tests__/runtime-version-gate.test.ts index 1686f416a..3f8733187 100644 --- a/src/backend/shared/firmware/__tests__/runtime-version-gate.test.ts +++ b/src/backend/shared/firmware/__tests__/runtime-version-gate.test.ts @@ -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 }) diff --git a/src/backend/shared/firmware/runtime-version-gate.ts b/src/backend/shared/firmware/runtime-version-gate.ts index ca96ca6ca..179010a1a 100644 --- a/src/backend/shared/firmware/runtime-version-gate.ts +++ b/src/backend/shared/firmware/runtime-version-gate.ts @@ -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 diff --git a/src/frontend/assets/icons/project/Users.tsx b/src/frontend/assets/icons/project/Users.tsx new file mode 100644 index 000000000..18ea4c13f --- /dev/null +++ b/src/frontend/assets/icons/project/Users.tsx @@ -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 ( + + + + + + + ) +} diff --git a/src/frontend/components/_atoms/tab/index.tsx b/src/frontend/components/_atoms/tab/index.tsx index d40d6473b..43affc54d 100644 --- a/src/frontend/components/_atoms/tab/index.tsx +++ b/src/frontend/components/_atoms/tab/index.tsx @@ -21,6 +21,7 @@ import { ServerIcon } from '../../../assets/icons/project/Server' 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' @@ -56,6 +57,7 @@ const TabIcons: Record = { 'ethercat-device': , 'library-manager': , 'library-manifest': , + 'user-management': , 'diff-viewer': , } @@ -87,6 +89,7 @@ const Tab = (props: ITabProps) => { | 'ethercat-device' | 'library-manager' | 'library-manifest' + | 'user-management' | 'diff-viewer' = 'il' if (fileDerivation?.type === 'data-type' || fileDerivation?.type === 'device') { @@ -123,6 +126,9 @@ const Tab = (props: ITabProps) => { if (fileDerivation?.type === 'library-manifest') { languageOrDerivation = 'library-manifest' } + if (fileDerivation?.type === 'user-management') { + languageOrDerivation = 'user-management' + } if (fileDerivation?.type === 'diff-viewer') { languageOrDerivation = 'diff-viewer' } diff --git a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx index 193e09453..ca74ca98e 100644 --- a/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx +++ b/src/frontend/components/_features/[workspace]/editor/device/configuration/board.tsx @@ -60,6 +60,7 @@ const Board = memo(function () { 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) @@ -365,6 +366,10 @@ const Board = memo(function () { 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) + // Validate runtime version matches the selected board target const versionValidation = validateRuntimeVersion(deviceBoard, result.runtimeVersion) diff --git a/src/frontend/components/_features/[workspace]/editor/user-management/index.tsx b/src/frontend/components/_features/[workspace]/editor/user-management/index.tsx new file mode 100644 index 000000000..69730f843 --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/user-management/index.tsx @@ -0,0 +1,298 @@ +import { PencilIcon } from '@root/frontend/assets/icons/interface/Pencil' +import { PlusIcon } from '@root/frontend/assets/icons/interface/Plus' +import { RefreshIcon } from '@root/frontend/assets/icons/interface/Refresh' +import { TrashCanIcon } from '@root/frontend/assets/icons/interface/TrashCan' +import { toast } from '@root/frontend/components/_features/[app]/toast/use-toast' +import { Modal, ModalContent, ModalTitle } from '@root/frontend/components/_molecules/modal' +import { + RuntimeUserModal, + type RuntimeUserModalSubmit, +} from '@root/frontend/components/_organisms/modals/runtime-user-modal' +import { useOpenPLCStore } from '@root/frontend/store' +import type { RuntimeUser, UpdateUserParams } from '@root/middleware/shared/ports/runtime-port' +import { useRuntime } from '@root/middleware/shared/providers' +import { useCallback, useEffect, useState } from 'react' + +type EditTarget = { user: RuntimeUser; isSelf: boolean } + +const UserManagementEditor = () => { + const runtime = useRuntime() + const connectionStatus = useOpenPLCStore((s) => s.runtimeConnection.connectionStatus) + const setRuntimeConnectionStatus = useOpenPLCStore((s) => s.deviceActions.setRuntimeConnectionStatus) + const setRuntimeJwtToken = useOpenPLCStore((s) => s.deviceActions.setRuntimeJwtToken) + + const [users, setUsers] = useState([]) + const [currentUser, setCurrentUser] = useState(null) + const [loading, setLoading] = useState(true) + const [loadError, setLoadError] = useState(null) + + const [createOpen, setCreateOpen] = useState(false) + const [editTarget, setEditTarget] = useState(null) + const [deleteTarget, setDeleteTarget] = useState(null) + const [deleting, setDeleting] = useState(false) + + const isAdmin = currentUser?.role === 'admin' + + const refresh = useCallback(async () => { + setLoading(true) + setLoadError(null) + const [listResult, meResult] = await Promise.all([runtime.listUsers(), runtime.whoAmI()]) + if (!listResult.success) { + setLoadError(listResult.error || 'Failed to load users') + setUsers([]) + } else { + // Guard against a non-array payload (e.g. the runtime's existence-only + // {"msg":"Users found"} reply when the token is no longer valid), which + // would otherwise crash the table on `users.map`. + setUsers(Array.isArray(listResult.users) ? listResult.users : []) + } + if (meResult.success && meResult.user) { + setCurrentUser(meResult.user) + } + setLoading(false) + }, [runtime]) + + useEffect(() => { + // Reload whenever the screen mounts or the connection is (re)established. + if (connectionStatus === 'connected') { + void refresh() + } + }, [connectionStatus, refresh]) + + const handleCreate = async ({ username, password, role }: RuntimeUserModalSubmit): Promise => { + if (!password) return 'Password is required' + const result = await runtime.createUser({ username, password, role }) + if (!result.success) return result.error || 'Failed to create user' + toast({ title: 'User created', description: `"${username}" was created.`, variant: 'default' }) + void refresh() + return null + } + + const handleEdit = async (values: RuntimeUserModalSubmit): Promise => { + if (!editTarget) return 'No user selected' + const params: UpdateUserParams = {} + if (values.usernameChanged) params.username = values.username + if (values.passwordChanged) { + params.password = values.password + if (values.currentPassword) params.currentPassword = values.currentPassword + } + if (values.roleChanged) params.role = values.role + const changingOwnPassword = editTarget.isSelf && values.passwordChanged + const result = await runtime.updateUser(editTarget.user.id, params) + if (!result.success) return result.error || 'Failed to update user' + + if (changingOwnPassword) { + // The runtime invalidates your token when you change your own password, + // so drop the local session and force a fresh login with the new one. + await runtime.clearCredentials() + setRuntimeJwtToken(null) + setRuntimeConnectionStatus('disconnected') + toast({ + title: 'Password changed', + description: 'You have been signed out. Reconnect with your new password.', + variant: 'default', + }) + return null + } + + toast({ title: 'User updated', description: `"${values.username}" was updated.`, variant: 'default' }) + void refresh() + return null + } + + const handleDelete = async () => { + if (!deleteTarget) return + setDeleting(true) + const result = await runtime.deleteUser(deleteTarget.id) + setDeleting(false) + if (!result.success) { + toast({ title: 'Delete failed', description: result.error || 'Failed to delete user', variant: 'fail' }) + return + } + toast({ title: 'User deleted', description: `"${deleteTarget.username}" was deleted.`, variant: 'default' }) + setDeleteTarget(null) + void refresh() + } + + const canEditRow = (user: RuntimeUser) => isAdmin || user.id === currentUser?.id + const canDeleteRow = (user: RuntimeUser) => isAdmin && user.id !== currentUser?.id + + // When not connected (e.g. after changing your own password signs you out), + // show a neutral placeholder instead of the table + actions — those would + // hit the runtime unauthenticated and, worse, could render a non-array list. + if (connectionStatus !== 'connected') { + return ( +
+

User Management

+

+ You are not connected to a runtime. Connect to the runtime to manage its users. +

+
+ ) + } + + return ( +
+
+
+

User Management

+

+ Manage the accounts that can log in to this runtime. +

+
+
+ + {isAdmin && ( + + )} +
+
+ + {loading ? ( +

Loading users…

+ ) : loadError ? ( +

{loadError}

+ ) : ( +
+ + + + + + + + + + {users.map((user) => { + const isSelf = user.id === currentUser?.id + return ( + + + +
UsernameRole + Actions +
+ {user.username} + {isSelf && (you)} + {user.role} +
+ {canEditRow(user) && ( +