From 53e01f21e1cac49fb915c84cd68e46d1916ebda8 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Wed, 22 Jul 2026 17:59:59 -0400 Subject: [PATCH 1/6] feat: runtime User Management screen with RBAC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "User Management" screen under the project tree's Device folder, shown only while connected to a runtime. Lets an admin list, create, edit, and delete runtime accounts; a normal user can edit only its own account. - Shared frontend (editor + web, byte-identical): new `plc-user-management` editor variant, gated Device-tree leaf + Users icon, and a single-table screen with per-row edit/delete icons (OPC-UA style). - Reusable RuntimeUserModal (create | edit | bootstrap). The first-user dialog is refactored to use it. Edit mode shows a masked password placeholder and only sends a password when the field is actually edited (dirty-tracked) — an untouched form never resets a password. Editing your own password requires the current password. - RuntimePort gains listUsers / whoAmI / updateUser / deleteUser and a role on createUser; editor adapter + IPC channels implement them. create-user is sent unauthenticated for first-user bootstrap and authenticated (admin) afterwards. - UI role-gates actions (admin sees create/delete + role selector); the runtime remains the real authorization boundary. - Tests: adapter methods (100%), tabs factory, and the modal's dirty-password / current-password rules. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/frontend/assets/icons/project/Users.tsx | 38 +++ .../editor/user-management/index.tsx | 259 ++++++++++++++++ .../_molecules/project-tree/index.tsx | 5 +- .../_organisms/explorer/project.tsx | 19 ++ .../__tests__/runtime-user-modal.test.tsx | 158 ++++++++++ .../modals/runtime-create-user-modal.tsx | 189 +++--------- .../_organisms/modals/runtime-user-modal.tsx | 292 ++++++++++++++++++ src/frontend/screens/workspace-screen.tsx | 2 + .../store/__tests__/tabs-utils.test.ts | 7 + src/frontend/store/slices/editor/types.ts | 8 + src/frontend/store/slices/tabs/types.ts | 1 + src/frontend/store/slices/tabs/utils.ts | 8 + src/frontend/store/slices/workspace/types.ts | 1 + src/main/modules/ipc/main.ts | 137 +++++++- src/main/modules/ipc/renderer.ts | 20 +- .../editor/__tests__/runtime-adapter.test.ts | 102 +++++- .../adapters/editor/runtime-adapter.ts | 41 ++- src/middleware/shared/ports/runtime-port.ts | 52 ++++ 18 files changed, 1178 insertions(+), 161 deletions(-) create mode 100644 src/frontend/assets/icons/project/Users.tsx create mode 100644 src/frontend/components/_features/[workspace]/editor/user-management/index.tsx create mode 100644 src/frontend/components/_organisms/modals/__tests__/runtime-user-modal.test.tsx create mode 100644 src/frontend/components/_organisms/modals/runtime-user-modal.tsx 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/_features/[workspace]/editor/user-management/index.tsx b/src/frontend/components/_features/[workspace]/editor/user-management/index.tsx new file mode 100644 index 000000000..111cc3c1b --- /dev/null +++ b/src/frontend/components/_features/[workspace]/editor/user-management/index.tsx @@ -0,0 +1,259 @@ +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 [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 { + setUsers(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 result = await runtime.updateUser(editTarget.user.id, params) + if (!result.success) return result.error || 'Failed to update user' + 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 + + 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) && ( + + )} + {canDeleteRow(user) && ( + + )} +
+
+ {users.length === 0 &&

No users found.

} +
+ )} + + {/* Create modal (admin only) */} + {isAdmin && ( + + )} + + {/* Edit modal */} + {editTarget && ( + { + if (!open) setEditTarget(null) + }} + mode='edit' + title={editTarget.isSelf ? 'Edit your account' : `Edit user — ${editTarget.user.username}`} + submitLabel='Save' + initialUsername={editTarget.user.username} + initialRole={editTarget.user.role} + // Only admins can change roles, and never their own (prevents self-lockout); + // the runtime enforces this too. + showRole={isAdmin && !editTarget.isSelf} + requireCurrentPassword={editTarget.isSelf} + onSubmit={handleEdit} + /> + )} + + {/* Delete confirmation */} + { + if (!open) setDeleteTarget(null) + }} + > + + Delete user +

+ "{deleteTarget?.username}" will no longer be able to log in to the runtime. This cannot be undone. +

+
+ + +
+
+
+
+ ) +} + +export { UserManagementEditor } diff --git a/src/frontend/components/_molecules/project-tree/index.tsx b/src/frontend/components/_molecules/project-tree/index.tsx index 73fb7f2bf..a78f114c2 100644 --- a/src/frontend/components/_molecules/project-tree/index.tsx +++ b/src/frontend/components/_molecules/project-tree/index.tsx @@ -30,6 +30,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 { WorkspaceProjectTreeLeafType } from '../../../store/slices/workspace/types' import { cn } from '../../../utils/cn' @@ -451,6 +452,7 @@ type IProjectTreeLeafProps = ComponentPropsWithoutRef<'li'> & { | 'ethercatDevice' | 'softMotionDrive' | 'libraryManifest' + | 'userManagement' leafType: WorkspaceProjectTreeLeafType label?: string busName?: string @@ -484,6 +486,7 @@ const LeafSources = { // render the same glyph — the manifest is the user's entry point // into a library project, so it earns a dedicated mark. libraryManifest: { LeafIcon: LibraryManifestIcon }, + userManagement: { LeafIcon: UsersIcon }, } const ProjectTreeLeaf = ({ leafLang, @@ -763,7 +766,7 @@ const ProjectTreeLeaf = ({ )} - {leafLang === 'devPin' || leafLang === 'devConfig' ? null : ( + {leafLang === 'devPin' || leafLang === 'devConfig' || leafLang === 'userManagement' ? null : ( { // endpoint requires edit access, so gate the affordance here too. const canEdit = useOpenPLCStore((s) => s.workspace.canEdit) + // Runtime User Management is only meaningful while connected to a runtime + // (it reads/writes the runtime's account list over the authenticated API). + const runtimeConnected = useOpenPLCStore((s) => s.runtimeConnection.connectionStatus === 'connected') + // Per-project-type capability matrix — drives which branches // render. Library projects only show Functions / Function Blocks / // Data Types plus the manifest tab; Programs / Resource / Devices / @@ -374,6 +378,21 @@ const Project = () => { } /> )} + {runtimeConnected && ( + + handleCreateTab({ + name: 'User Management', + path: `/device/user-management`, + elementType: { type: 'user-management' }, + }) + } + /> + )} )} diff --git a/src/frontend/components/_organisms/modals/__tests__/runtime-user-modal.test.tsx b/src/frontend/components/_organisms/modals/__tests__/runtime-user-modal.test.tsx new file mode 100644 index 000000000..671733d87 --- /dev/null +++ b/src/frontend/components/_organisms/modals/__tests__/runtime-user-modal.test.tsx @@ -0,0 +1,158 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' + +import { RuntimeUserModal, type RuntimeUserModalSubmit } from '../runtime-user-modal' + +// Plain typed closures (instead of vi.fn generics) so the same test file is +// type-correct under both the editor's jest and the web's vitest runner. +let submitCalls: RuntimeUserModalSubmit[] +let submitReturn: string | null +let openChanges: boolean[] + +const onSubmit = (values: RuntimeUserModalSubmit): Promise => { + submitCalls.push(values) + return Promise.resolve(submitReturn) +} +const onOpenChange = (open: boolean) => { + openChanges.push(open) +} + +beforeEach(() => { + submitCalls = [] + submitReturn = null + openChanges = [] +}) + +describe('RuntimeUserModal — password dirty tracking', () => { + it('does NOT report a password change when the password field is untouched (edit mode)', async () => { + render( + , + ) + + // Change only the username; leave the pre-filled password placeholder alone. + fireEvent.change(screen.getByPlaceholderText('Enter username'), { target: { value: 'bobby' } }) + fireEvent.click(screen.getByText('Save')) + + await waitFor(() => expect(submitCalls).toHaveLength(1)) + expect(submitCalls[0].usernameChanged).toBe(true) + expect(submitCalls[0].passwordChanged).toBe(false) + expect(submitCalls[0].password).toBeUndefined() + }) + + it('reports a password change once the field is edited (edit mode)', async () => { + render( + , + ) + + const pass = screen.getByPlaceholderText('Enter password') + const confirm = screen.getByPlaceholderText('Confirm password') + // Focus clears the masked placeholder from both fields, then type a new one. + fireEvent.focus(pass) + fireEvent.change(pass, { target: { value: 'new-secret' } }) + fireEvent.change(confirm, { target: { value: 'new-secret' } }) + fireEvent.click(screen.getByText('Save')) + + await waitFor(() => expect(submitCalls).toHaveLength(1)) + expect(submitCalls[0].passwordChanged).toBe(true) + expect(submitCalls[0].password).toBe('new-secret') + }) + + it('blocks submit when nothing changed (edit mode)', async () => { + render( + , + ) + fireEvent.click(screen.getByText('Save')) + await screen.findByText('No changes to save') + expect(submitCalls).toHaveLength(0) + }) + + it('requires the current password to change your own password (self edit)', async () => { + render( + , + ) + + const pass = screen.getByPlaceholderText('Enter password') + fireEvent.focus(pass) + fireEvent.change(pass, { target: { value: 'new-secret' } }) + fireEvent.change(screen.getByPlaceholderText('Confirm password'), { target: { value: 'new-secret' } }) + fireEvent.click(screen.getByText('Save')) + + await screen.findByText('Your current password is required to change the password') + expect(submitCalls).toHaveLength(0) + }) + + it('rejects mismatched passwords', async () => { + render( + , + ) + fireEvent.change(screen.getByPlaceholderText('Enter username'), { target: { value: 'bob' } }) + fireEvent.change(screen.getByPlaceholderText('Enter password'), { target: { value: 'aaa' } }) + fireEvent.change(screen.getByPlaceholderText('Confirm password'), { target: { value: 'bbb' } }) + fireEvent.click(screen.getByText('Create')) + + await screen.findByText('Passwords do not match') + expect(submitCalls).toHaveLength(0) + }) + + it('creates a user with the required fields and closes on success (create mode)', async () => { + render( + , + ) + fireEvent.change(screen.getByPlaceholderText('Enter username'), { target: { value: 'bob' } }) + fireEvent.change(screen.getByPlaceholderText('Enter password'), { target: { value: 'secret' } }) + fireEvent.change(screen.getByPlaceholderText('Confirm password'), { target: { value: 'secret' } }) + fireEvent.click(screen.getByText('Create')) + + await waitFor(() => expect(submitCalls).toHaveLength(1)) + expect(submitCalls[0]).toMatchObject({ username: 'bob', password: 'secret', role: 'user', passwordChanged: true }) + await waitFor(() => expect(openChanges).toContain(false)) + }) +}) diff --git a/src/frontend/components/_organisms/modals/runtime-create-user-modal.tsx b/src/frontend/components/_organisms/modals/runtime-create-user-modal.tsx index 9585bc2c5..d1eae9087 100644 --- a/src/frontend/components/_organisms/modals/runtime-create-user-modal.tsx +++ b/src/frontend/components/_organisms/modals/runtime-create-user-modal.tsx @@ -1,174 +1,59 @@ -import { useState } from 'react' - import { useRuntime } from '../../../../middleware/shared/providers' import { useOpenPLCStore } from '../../../store' import { getErrorMessage } from '../../../utils/get-error-message' -import { Label } from '../../_atoms/label' -import { Modal, ModalContent, ModalTitle } from '../../_molecules/modal' - +import { RuntimeUserModal, type RuntimeUserModalSubmit } from './runtime-user-modal' + +/** + * First-user bootstrap dialog: shown when connecting to a runtime that has no + * accounts yet. Reuses the shared RuntimeUserModal form and, on success, also + * logs in as the new user and marks the connection as established (the runtime + * always makes this first account an admin). + */ const RuntimeCreateUserModal = () => { - const { modals, modalActions, deviceActions, runtimeConnection } = useOpenPLCStore() + const { modals, modalActions, deviceActions } = useOpenPLCStore() const runtime = useRuntime() - const [username, setUsername] = useState('') - const [password, setPassword] = useState('') - const [confirmPassword, setConfirmPassword] = useState('') - const [error, setError] = useState('') - const [isLoading, setIsLoading] = useState(false) const isOpen = modals['runtime-create-user']?.open || false - // Build a stable unique suffix for input ids to prevent browser autofill - const deviceId = runtimeConnection.selectedDevice?.deviceId || 'default' - - const handleCreateUser = async () => { - setError('') - - if (!username || !password) { - setError('Username and password are required') - return - } - - if (password !== confirmPassword) { - setError('Passwords do not match') - return - } - - setIsLoading(true) + const handleSubmit = async ({ username, password }: RuntimeUserModalSubmit): Promise => { + if (!password) return 'Password is required' try { const result = await runtime.createUser({ username, password }) - - if (result.success) { - const loginResult = await runtime.login({ username, password }) - if (loginResult.success && loginResult.accessToken) { - deviceActions.setRuntimeJwtToken(loginResult.accessToken) - deviceActions.setRuntimeConnectionStatus('connected') - deviceActions.setStoredCredentials({ username, password }) - modalActions.closeModal() - setUsername('') - setPassword('') - setConfirmPassword('') - } else { - setError('User created but login failed: ' + (loginResult.error || 'Unknown error')) - } - } else { - setError('Failed to create user: ' + (result.error || 'Unknown error')) + if (!result.success) { + return 'Failed to create user: ' + (result.error || 'Unknown error') } + const loginResult = await runtime.login({ username, password }) + if (loginResult.success && loginResult.accessToken) { + deviceActions.setRuntimeJwtToken(loginResult.accessToken) + deviceActions.setRuntimeConnectionStatus('connected') + deviceActions.setStoredCredentials({ username, password }) + return null + } + return 'User created but login failed: ' + (loginResult.error || 'Unknown error') } catch (err) { - setError('Error: ' + getErrorMessage(err)) - } finally { - setIsLoading(false) + return 'Error: ' + getErrorMessage(err) } } - const handleCancel = () => { - modalActions.closeModal() - deviceActions.setRuntimeConnectionStatus('disconnected') - setUsername('') - setPassword('') - setConfirmPassword('') - setError('') + const handleOpenChange = (open: boolean) => { + if (!open) { + // Cancelling the first-user setup abandons the connection attempt. + if (isOpen) deviceActions.setRuntimeConnectionStatus('disconnected') + modalActions.closeModal() + } + modalActions.onOpenChange('runtime-create-user', open) } return ( - { - if (!open) { - handleCancel() - } - modalActions.onOpenChange('runtime-create-user', open) - }} - > - - Create First User - -

- This OpenPLC Runtime has no users registered. Please create the first user account. -

- -
{ - e.preventDefault() - void handleCreateUser() - }} - className='flex w-full flex-col gap-4' - > -
- - setUsername(e.target.value)} - placeholder='Enter username' - className='w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-850 outline-none focus:border-brand dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300' - disabled={isLoading} - /> -
- -
- - setPassword(e.target.value)} - placeholder='Enter password' - className='w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-850 outline-none focus:border-brand dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300' - disabled={isLoading} - /> -
- -
- - setConfirmPassword(e.target.value)} - placeholder='Confirm password' - className='w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-850 outline-none focus:border-brand dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300' - disabled={isLoading} - /> -
- - {error &&

{error}

} - -
- - -
-
-
-
+ onOpenChange={handleOpenChange} + mode='bootstrap' + title='Create First User' + description='This OpenPLC Runtime has no users registered. Please create the first user account.' + submitLabel='Create User' + onSubmit={handleSubmit} + /> ) } diff --git a/src/frontend/components/_organisms/modals/runtime-user-modal.tsx b/src/frontend/components/_organisms/modals/runtime-user-modal.tsx new file mode 100644 index 000000000..1bef6f7b5 --- /dev/null +++ b/src/frontend/components/_organisms/modals/runtime-user-modal.tsx @@ -0,0 +1,292 @@ +import { useEffect, useState } from 'react' + +import type { RuntimeUserRole } from '../../../../middleware/shared/ports/runtime-port' +import { Label } from '../../_atoms/label' +import { Modal, ModalContent, ModalTitle } from '../../_molecules/modal' + +/** + * Normalized result of the form. `*Changed` flags let the caller send only the + * fields the user actually touched — critically, `password` is present ONLY + * when the password field was genuinely edited, so an untouched form never + * resets a password. + */ +export interface RuntimeUserModalSubmit { + username: string + role?: RuntimeUserRole + password?: string + currentPassword?: string + usernameChanged: boolean + passwordChanged: boolean + roleChanged: boolean +} + +interface RuntimeUserModalProps { + open: boolean + onOpenChange: (open: boolean) => void + /** bootstrap = first-user setup, create = admin adding a user, edit = modify existing. */ + mode: 'bootstrap' | 'create' | 'edit' + title: string + description?: string + submitLabel: string + /** Prefill (edit mode). */ + initialUsername?: string + initialRole?: RuntimeUserRole + /** Show the Role selector (admin managing accounts). */ + showRole?: boolean + /** Editing your OWN account: a password change must be confirmed with the + * current password (blocks a stolen session from silently resetting it). */ + requireCurrentPassword?: boolean + /** Returns an error message to display, or null on success (which closes the modal). */ + onSubmit: (values: RuntimeUserModalSubmit) => Promise +} + +// Eight bullets shown in a password field in edit mode so it *looks* populated +// without ever holding (or submitting) a real password. Cleared on first edit. +const PASSWORD_PLACEHOLDER = '••••••••' + +const inputClass = + 'w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-850 outline-none focus:border-brand dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300' + +const RuntimeUserModal = (props: RuntimeUserModalProps) => { + const { + open, + onOpenChange, + mode, + title, + description, + submitLabel, + initialUsername = '', + initialRole = 'user', + showRole = false, + requireCurrentPassword = false, + onSubmit, + } = props + + const isEdit = mode === 'edit' + + const [username, setUsername] = useState(initialUsername) + const [role, setRole] = useState(initialRole) + // In edit mode the password fields start as a masked placeholder; a real + // change is only registered once the user focuses and types (passwordTouched). + const [password, setPassword] = useState(isEdit ? PASSWORD_PLACEHOLDER : '') + const [confirmPassword, setConfirmPassword] = useState(isEdit ? PASSWORD_PLACEHOLDER : '') + const [currentPassword, setCurrentPassword] = useState('') + const [passwordTouched, setPasswordTouched] = useState(false) + const [error, setError] = useState('') + const [isLoading, setIsLoading] = useState(false) + + // Reset every field when the modal (re)opens, so a reused instance never + // leaks state between the account it was last opened for and the next. + useEffect(() => { + if (open) { + setUsername(initialUsername) + setRole(initialRole) + setPassword(isEdit ? PASSWORD_PLACEHOLDER : '') + setConfirmPassword(isEdit ? PASSWORD_PLACEHOLDER : '') + setCurrentPassword('') + setPasswordTouched(false) + setError('') + setIsLoading(false) + } + }, [open, initialUsername, initialRole, isEdit]) + + // First interaction with the password fields (edit mode) clears the masked + // placeholder from BOTH inputs so the placeholder can never be submitted. + const beginPasswordEdit = () => { + if (isEdit && !passwordTouched) { + setPassword('') + setConfirmPassword('') + setPasswordTouched(true) + } + } + + const handleSubmit = async () => { + setError('') + + // A password counts as changed only when actually edited to a non-empty value. + const passwordChanged = isEdit ? passwordTouched && password.length > 0 : true + const usernameTrimmed = username.trim() + const usernameChanged = isEdit ? usernameTrimmed !== initialUsername : true + const roleChanged = showRole ? role !== initialRole : false + + if (!usernameTrimmed) { + setError('Username is required') + return + } + + if (passwordChanged) { + if (!password) { + setError('Password is required') + return + } + if (password !== confirmPassword) { + setError('Passwords do not match') + return + } + if (requireCurrentPassword && !currentPassword) { + setError('Your current password is required to change the password') + return + } + } + + if (isEdit && !usernameChanged && !passwordChanged && !roleChanged) { + setError('No changes to save') + return + } + + setIsLoading(true) + try { + const result = await onSubmit({ + username: usernameTrimmed, + role: showRole ? role : undefined, + password: passwordChanged ? password : undefined, + currentPassword: passwordChanged && requireCurrentPassword ? currentPassword : undefined, + usernameChanged, + passwordChanged, + roleChanged, + }) + if (result) { + setError(result) + } else { + onOpenChange(false) + } + } finally { + setIsLoading(false) + } + } + + return ( + + + {title} + + {description && ( +

{description}

+ )} + +
{ + e.preventDefault() + void handleSubmit() + }} + className='flex w-full flex-col gap-4' + > +
+ + setUsername(e.target.value)} + placeholder='Enter username' + className={inputClass} + disabled={isLoading} + /> +
+ + {showRole && ( +
+ + +
+ )} + + {isEdit && requireCurrentPassword && passwordTouched && ( +
+ + setCurrentPassword(e.target.value)} + placeholder='Enter your current password' + className={inputClass} + disabled={isLoading} + /> +
+ )} + +
+ + setPassword(e.target.value)} + placeholder='Enter password' + className={inputClass} + disabled={isLoading} + /> +
+ +
+ + setConfirmPassword(e.target.value)} + placeholder='Confirm password' + className={inputClass} + disabled={isLoading} + /> +
+ + {error &&

{error}

} + +
+ + +
+
+
+
+ ) +} + +export { RuntimeUserModal } diff --git a/src/frontend/screens/workspace-screen.tsx b/src/frontend/screens/workspace-screen.tsx index f8d94ec97..8bad1b9f9 100644 --- a/src/frontend/screens/workspace-screen.tsx +++ b/src/frontend/screens/workspace-screen.tsx @@ -29,6 +29,7 @@ import { ResourcesEditor } from '../components/_features/[workspace]/editor/reso import { ModbusServerEditor } from '../components/_features/[workspace]/editor/server/modbus-server' import { OpcUaServerEditor } from '../components/_features/[workspace]/editor/server/opcua-server' import { S7CommServerEditor } from '../components/_features/[workspace]/editor/server/s7comm-server' +import { UserManagementEditor } from '../components/_features/[workspace]/editor/user-management' import { VendorScreenEditor } from '../components/_features/[workspace]/editor/vendor-screen' import { Search } from '../components/_features/[workspace]/search' import { SourceControlPanel } from '../components/_features/[workspace]/source-control' @@ -580,6 +581,7 @@ const WorkspaceScreen = () => { {editor['type'] === 'plc-vendor-screen' && } {editor['type'] === 'plc-package-manager' && } {editor['type'] === 'plc-library-manager' && } + {editor['type'] === 'plc-user-management' && } {editor['type'] === 'plc-library-manifest' && } {editor['type'] === 'diff-viewer' && } diff --git a/src/frontend/store/__tests__/tabs-utils.test.ts b/src/frontend/store/__tests__/tabs-utils.test.ts index 96806562e..085be585b 100644 --- a/src/frontend/store/__tests__/tabs-utils.test.ts +++ b/src/frontend/store/__tests__/tabs-utils.test.ts @@ -291,6 +291,13 @@ describe('tabs/utils', () => { expect(result.type).toBe('plc-server') }) + it('creates editor from user-management tab', () => { + const tab: TabsProps = { name: 'User Management', elementType: { type: 'user-management' } } + const result = CreateEditorObjectFromTab(tab) + expect(result.type).toBe('plc-user-management') + expect(result.meta.name).toBe('User Management') + }) + it('creates editor from diff-viewer tab', () => { const tab: TabsProps = { name: 'Diff: devices/configuration.json', diff --git a/src/frontend/store/slices/editor/types.ts b/src/frontend/store/slices/editor/types.ts index dc146aba0..42ad52b75 100644 --- a/src/frontend/store/slices/editor/types.ts +++ b/src/frontend/store/slices/editor/types.ts @@ -180,6 +180,14 @@ export type EditorModel = EditorModelBase & name: string } } + | { + /** Runtime User Management screen. A device-scoped singleton shown + * under the Device tree branch while connected to a runtime. */ + type: 'plc-user-management' + meta: { + name: string + } + } | { /** The Library Project's manifest tab — Monaco-wrapped * `library.json` at the project root. Only ever opened diff --git a/src/frontend/store/slices/tabs/types.ts b/src/frontend/store/slices/tabs/types.ts index 53608ad8c..5c3b3bced 100644 --- a/src/frontend/store/slices/tabs/types.ts +++ b/src/frontend/store/slices/tabs/types.ts @@ -18,6 +18,7 @@ export type TabsProps = { | { type: 'package-manager' } | { type: 'library-manager' } | { type: 'library-manifest' } + | { type: 'user-management' } | { type: 'ethercat-device'; busName: string; deviceId: string } | { type: 'diff-viewer'; filePath: string } configuration?: Record diff --git a/src/frontend/store/slices/tabs/utils.ts b/src/frontend/store/slices/tabs/utils.ts index 5e60d201d..bb04f5098 100644 --- a/src/frontend/store/slices/tabs/utils.ts +++ b/src/frontend/store/slices/tabs/utils.ts @@ -127,6 +127,11 @@ const CreateLibraryManagerEditor = (name = 'Library Manager'): EditorModel => ({ meta: { name }, }) +const CreateUserManagementEditor = (name = 'User Management'): EditorModel => ({ + type: 'plc-user-management', + meta: { name }, +}) + /** Canonical tab name + factory for the Library Project's manifest * editor. Display label (also the file-slice key the dirty * tracker + save flow look up under); intentionally NOT the on- @@ -177,6 +182,8 @@ const CreateEditorObjectFromTab = (tab: TabsProps): EditorModel => { return CreateLibraryManagerEditor(name) case 'library-manifest': return CreateLibraryManifestEditor(name) + case 'user-management': + return CreateUserManagementEditor(name) case 'diff-viewer': return CreateDiffViewerEditor(name, elementType.filePath) } @@ -196,6 +203,7 @@ export { CreateRemoteDeviceEditor, CreateResourceEditor, CreateServerEditor, + CreateUserManagementEditor, CreateVendorScreenEditor, LIBRARY_MANIFEST_TAB_NAME, } diff --git a/src/frontend/store/slices/workspace/types.ts b/src/frontend/store/slices/workspace/types.ts index a9e12ef62..17b0f4c64 100644 --- a/src/frontend/store/slices/workspace/types.ts +++ b/src/frontend/store/slices/workspace/types.ts @@ -39,6 +39,7 @@ export type WorkspaceProjectTreeLeafType = | 'package-manager' | 'library-manager' | 'library-manifest' + | 'user-management' | 'ethercat-device' | null diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index 3f12df229..561cbb9d2 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -21,6 +21,7 @@ import type { ListPublicLibrariesArgs, ListPublicLibrariesResponse, } from '@root/middleware/shared/ports/public-catalog-types' +import type { RuntimeUser, RuntimeUserRole, UpdateUserParams } from '@root/middleware/shared/ports/runtime-port' import { createRuntimeTokenManager } from '@root/middleware/shared/runtime-auth/runtime-token-manager' import { CreatePouFileProps } from '@root/types/IPC/pou-service' import { CreateProjectFileProps } from '@root/types/IPC/project-service' @@ -214,22 +215,80 @@ class MainProcessBridge implements MainIpcModule { ipAddress: string, username: string, password: string, + role?: RuntimeUserRole, ) => { try { + // `role` is only honoured by the runtime for authenticated (admin) creation; + // the unauthenticated first-user bootstrap always becomes an admin regardless. + const body: { username: string; password: string; role?: RuntimeUserRole } = { username, password } + if (role) body.role = role + const payload = JSON.stringify(body) + + // First-user bootstrap runs before any login (no token yet) and the + // runtime allows it unauthenticated. Once a session exists this is an + // admin adding an account, which the runtime requires to be authenticated + // — route it through the token authority so an expired token is refreshed. + if (this.tokens.hasToken()) { + const res = await this.makeRuntimeApiPostRequest(ipAddress, '/api/create-user', payload, () => undefined) + return res.success ? { success: true } : { success: false, error: res.error } + } + const res = await this.httpRequest({ method: 'POST', url: this.runtimeUrl(ipAddress, '/api/create-user'), - body: JSON.stringify({ username, password, role: 'user' }), + body: payload, }) - if (res.statusCode === 201) { - return { success: true } - } + if (res.statusCode === 201) return { success: true } return { success: false, error: res.data } } catch (error) { return { success: false, error: getErrorMessage(error) } } } + handleRuntimeListUsers = async (_event: IpcMainInvokeEvent, ipAddress: string) => { + const res = await this.makeRuntimeApiRequest( + ipAddress, + '/api/get-users-info', + (data) => JSON.parse(data) as RuntimeUser[], + ) + return res.success ? { success: true, users: res.data } : { success: false, error: res.error } + } + + handleRuntimeWhoAmI = async (_event: IpcMainInvokeEvent, ipAddress: string) => { + const res = await this.makeRuntimeApiRequest( + ipAddress, + '/api/whoami', + (data) => JSON.parse(data) as RuntimeUser, + ) + return res.success ? { success: true, user: res.data } : { success: false, error: res.error } + } + + handleRuntimeUpdateUser = async ( + _event: IpcMainInvokeEvent, + ipAddress: string, + userId: number, + params: UpdateUserParams, + ) => { + // The runtime expects snake_case `current_password`; only send provided fields. + const body: Record = {} + if (params.username !== undefined) body.username = params.username + if (params.password !== undefined) body.password = params.password + if (params.currentPassword !== undefined) body.current_password = params.currentPassword + if (params.role !== undefined) body.role = params.role + const res = await this.makeRuntimeApiMutation( + 'PUT', + ipAddress, + `/api/update-user/${userId}`, + JSON.stringify(body), + ) + return res.success ? { success: true } : { success: false, error: res.error } + } + + handleRuntimeDeleteUser = async (_event: IpcMainInvokeEvent, ipAddress: string, userId: number) => { + const res = await this.makeRuntimeApiMutation('DELETE', ipAddress, `/api/delete-user/${userId}`) + return res.success ? { success: true } : { success: false, error: res.error } + } + private async performAuthentication( ipAddress: string, username: string, @@ -402,6 +461,72 @@ class MainProcessBridge implements MainIpcModule { .then(stripStatus) } + /** + * Authenticated PUT/DELETE against the runtime API, going through the token + * authority. Unlike the GET/POST helpers this retries only on 401 (a genuine + * expired token): the user-management endpoints use 403 as a legitimate + * business response (e.g. "current password incorrect", "admin required"), + * so retrying on 403 would trigger a pointless re-authentication. Any 2xx is + * success; the raw body is returned so callers can surface error messages. + */ + private makeRuntimeApiMutation( + method: 'PUT' | 'DELETE', + ipAddress: string, + endpoint: string, + body?: string, + ): Promise<{ success: true; data: string } | { success: false; error: string }> { + type R = { success: true; data: string } | { success: false; error: string; statusCode?: number } + + const doRequest = (token: string): Promise => + new Promise((resolve) => { + const headers: Record = { Authorization: `Bearer ${token}` } + if (body !== undefined) { + headers['Content-Type'] = 'application/json' + headers['Content-Length'] = Buffer.byteLength(body) + } + const req = https.request( + { + hostname: ipAddress, + port: this.RUNTIME_API_PORT, + path: endpoint, + method, + headers, + ...getRuntimeHttpsOptions(), + }, + (res: IncomingMessage) => { + let data = '' + res.on('data', (chunk: Buffer) => { + data += chunk.toString() + }) + res.on('end', () => { + const statusCode = res.statusCode ?? 0 + if (statusCode >= 200 && statusCode < 300) { + resolve({ success: true, data }) + } else { + resolve({ success: false, error: data || `Unexpected status: ${statusCode}`, statusCode }) + } + }) + }, + ) + req.setTimeout(this.RUNTIME_CONNECTION_TIMEOUT_MS, () => { + req.destroy() + resolve({ success: false, error: 'Connection timeout' }) + }) + req.on('error', (error: Error) => { + resolve({ success: false, error: error.message }) + }) + if (body !== undefined) req.write(body) + req.end() + }) + + return this.tokens + .withAuth( + (token) => doRequest(token), + (r) => !r.success && r.statusCode === 401, + ) + .then((r) => (r.success ? { success: true, data: r.data } : { success: false, error: r.error })) + } + /** * Upload a compiled program (multipart) to the runtime, going through the * token authority so an expired token is transparently refreshed and the @@ -903,6 +1028,10 @@ class MainProcessBridge implements MainIpcModule { // ===================== RUNTIME API ===================== this.registerHandle('runtime:get-users-info', this.handleRuntimeGetUsersInfo) this.registerHandle('runtime:create-user', this.handleRuntimeCreateUser) + this.registerHandle('runtime:list-users', this.handleRuntimeListUsers) + this.registerHandle('runtime:whoami', this.handleRuntimeWhoAmI) + this.registerHandle('runtime:update-user', this.handleRuntimeUpdateUser) + this.registerHandle('runtime:delete-user', this.handleRuntimeDeleteUser) this.registerHandle('runtime:login', this.handleRuntimeLogin) this.registerHandle('runtime:get-status', this.handleRuntimeGetStatus) this.registerHandle('runtime:start-plc', this.handleRuntimeStartPlc) diff --git a/src/main/modules/ipc/renderer.ts b/src/main/modules/ipc/renderer.ts index 67393124e..3fb915a2f 100644 --- a/src/main/modules/ipc/renderer.ts +++ b/src/main/modules/ipc/renderer.ts @@ -15,6 +15,12 @@ import type { ListPublicLibrariesArgs, ListPublicLibrariesResponse, } from '@root/middleware/shared/ports/public-catalog-types' +import type { + ListUsersResult, + RuntimeUserRole, + UpdateUserParams, + WhoAmIResult, +} from '@root/middleware/shared/ports/runtime-port' import type { PLCProjectData } from '@root/middleware/shared/ports/types' import { CreatePouFileProps, PouServiceResponse } from '@root/types/IPC/pou-service' import { CreateProjectFileProps, IProjectServiceResponse } from '@root/types/IPC/project-service' @@ -455,8 +461,20 @@ const rendererProcessBridge = { ipAddress: string, username: string, password: string, + role?: RuntimeUserRole, + ): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke('runtime:create-user', ipAddress, username, password, role), + runtimeListUsers: (ipAddress: string): Promise => + ipcRenderer.invoke('runtime:list-users', ipAddress), + runtimeWhoAmI: (ipAddress: string): Promise => ipcRenderer.invoke('runtime:whoami', ipAddress), + runtimeUpdateUser: ( + ipAddress: string, + userId: number, + params: UpdateUserParams, ): Promise<{ success: boolean; error?: string }> => - ipcRenderer.invoke('runtime:create-user', ipAddress, username, password), + ipcRenderer.invoke('runtime:update-user', ipAddress, userId, params), + runtimeDeleteUser: (ipAddress: string, userId: number): Promise<{ success: boolean; error?: string }> => + ipcRenderer.invoke('runtime:delete-user', ipAddress, userId), runtimeLogin: ( ipAddress: string, username: string, diff --git a/src/middleware/adapters/editor/__tests__/runtime-adapter.test.ts b/src/middleware/adapters/editor/__tests__/runtime-adapter.test.ts index 6090ac0eb..d8c47cb74 100644 --- a/src/middleware/adapters/editor/__tests__/runtime-adapter.test.ts +++ b/src/middleware/adapters/editor/__tests__/runtime-adapter.test.ts @@ -11,6 +11,13 @@ beforeEach(() => { runtimeLogin: jest.fn().mockResolvedValue({ success: true, accessToken: 'jwt-token-123' }), runtimeCreateUser: jest.fn().mockResolvedValue({ success: true }), runtimeGetUsersInfo: jest.fn().mockResolvedValue({ hasUsers: true, runtimeVersion: '4.0.0' }), + runtimeListUsers: jest.fn().mockResolvedValue({ + success: true, + users: [{ id: 1, username: 'admin', role: 'admin' }], + }), + runtimeWhoAmI: jest.fn().mockResolvedValue({ success: true, user: { id: 1, username: 'admin', role: 'admin' } }), + runtimeUpdateUser: jest.fn().mockResolvedValue({ success: true }), + runtimeDeleteUser: jest.fn().mockResolvedValue({ success: true }), runtimeGetStatus: jest.fn().mockResolvedValue({ success: true, status: 'RUNNING' }), runtimeStartPlc: jest.fn().mockResolvedValue({ success: true }), runtimeStopPlc: jest.fn().mockResolvedValue({ success: true }), @@ -83,13 +90,19 @@ describe('login', () => { // --------------------------------------------------------------------------- describe('createUser', () => { - it('delegates to bridge with IP and credentials', async () => { + it('delegates to bridge with IP and credentials (no role forwards undefined)', async () => { const result = await adapter.createUser({ username: 'newuser', password: 'pass123' }) - expect(window.bridge.runtimeCreateUser).toHaveBeenCalledWith('192.168.1.100', 'newuser', 'pass123') + expect(window.bridge.runtimeCreateUser).toHaveBeenCalledWith('192.168.1.100', 'newuser', 'pass123', undefined) expect(result).toEqual({ success: true }) }) + it('forwards the role when provided', async () => { + await adapter.createUser({ username: 'newuser', password: 'pass123', role: 'admin' }) + + expect(window.bridge.runtimeCreateUser).toHaveBeenCalledWith('192.168.1.100', 'newuser', 'pass123', 'admin') + }) + it('returns error when no IP configured', async () => { mockIpAddress = '' const result = await adapter.createUser({ username: 'newuser', password: 'pass123' }) @@ -105,6 +118,91 @@ describe('createUser', () => { }) }) +// --------------------------------------------------------------------------- +// listUsers / whoAmI / updateUser / deleteUser +// --------------------------------------------------------------------------- + +describe('listUsers', () => { + it('delegates to bridge with IP', async () => { + const result = await adapter.listUsers() + expect(window.bridge.runtimeListUsers).toHaveBeenCalledWith('192.168.1.100') + expect(result).toEqual({ success: true, users: [{ id: 1, username: 'admin', role: 'admin' }] }) + }) + + it('returns error when no IP configured', async () => { + mockIpAddress = '' + const result = await adapter.listUsers() + expect(result).toEqual({ success: false, error: 'No runtime IP address configured' }) + }) + + it('catches bridge errors', async () => { + ;(window.bridge.runtimeListUsers as jest.Mock).mockRejectedValue(new Error('list failed')) + const result = await adapter.listUsers() + expect(result).toEqual({ success: false, error: 'list failed' }) + }) +}) + +describe('whoAmI', () => { + it('delegates to bridge with IP', async () => { + const result = await adapter.whoAmI() + expect(window.bridge.runtimeWhoAmI).toHaveBeenCalledWith('192.168.1.100') + expect(result).toEqual({ success: true, user: { id: 1, username: 'admin', role: 'admin' } }) + }) + + it('returns error when no IP configured', async () => { + mockIpAddress = '' + const result = await adapter.whoAmI() + expect(result).toEqual({ success: false, error: 'No runtime IP address configured' }) + }) + + it('catches bridge errors', async () => { + ;(window.bridge.runtimeWhoAmI as jest.Mock).mockRejectedValue(new Error('whoami failed')) + const result = await adapter.whoAmI() + expect(result).toEqual({ success: false, error: 'whoami failed' }) + }) +}) + +describe('updateUser', () => { + it('delegates to bridge with IP, id and params', async () => { + const params = { username: 'bobby', password: 'np', currentPassword: 'op', role: 'user' as const } + const result = await adapter.updateUser(7, params) + expect(window.bridge.runtimeUpdateUser).toHaveBeenCalledWith('192.168.1.100', 7, params) + expect(result).toEqual({ success: true }) + }) + + it('returns error when no IP configured', async () => { + mockIpAddress = '' + const result = await adapter.updateUser(7, { username: 'x' }) + expect(result).toEqual({ success: false, error: 'No runtime IP address configured' }) + }) + + it('catches bridge errors', async () => { + ;(window.bridge.runtimeUpdateUser as jest.Mock).mockRejectedValue(new Error('update failed')) + const result = await adapter.updateUser(7, { username: 'x' }) + expect(result).toEqual({ success: false, error: 'update failed' }) + }) +}) + +describe('deleteUser', () => { + it('delegates to bridge with IP and id', async () => { + const result = await adapter.deleteUser(9) + expect(window.bridge.runtimeDeleteUser).toHaveBeenCalledWith('192.168.1.100', 9) + expect(result).toEqual({ success: true }) + }) + + it('returns error when no IP configured', async () => { + mockIpAddress = '' + const result = await adapter.deleteUser(9) + expect(result).toEqual({ success: false, error: 'No runtime IP address configured' }) + }) + + it('catches bridge errors', async () => { + ;(window.bridge.runtimeDeleteUser as jest.Mock).mockRejectedValue(new Error('delete failed')) + const result = await adapter.deleteUser(9) + expect(result).toEqual({ success: false, error: 'delete failed' }) + }) +}) + // --------------------------------------------------------------------------- // getUsersInfo // --------------------------------------------------------------------------- diff --git a/src/middleware/adapters/editor/runtime-adapter.ts b/src/middleware/adapters/editor/runtime-adapter.ts index c2e953f84..711c211c9 100644 --- a/src/middleware/adapters/editor/runtime-adapter.ts +++ b/src/middleware/adapters/editor/runtime-adapter.ts @@ -21,12 +21,15 @@ import type { DiscoverDevicesOptions, DiscoverDevicesResult, DiscoveredRuntimeDevice, + ListUsersResult, LoginParams, LoginResult, RuntimeLogsResult, RuntimePort, RuntimeStatusResult, + UpdateUserParams, UsersInfoResult, + WhoAmIResult, } from '../../shared/ports/runtime-port' import type { SerialPort, Unsubscribe } from '../../shared/ports/types' @@ -62,7 +65,7 @@ export function createEditorRuntimeAdapter(getIpAddress: () => string): RuntimeP async createUser(params) { try { const ip = requireIp() - return await window.bridge.runtimeCreateUser(ip, params.username, params.password) + return await window.bridge.runtimeCreateUser(ip, params.username, params.password, params.role) } catch (err) { return { success: false, error: getErrorMessage(err) } } @@ -77,6 +80,42 @@ export function createEditorRuntimeAdapter(getIpAddress: () => string): RuntimeP } }, + async listUsers(): Promise { + try { + const ip = requireIp() + return await window.bridge.runtimeListUsers(ip) + } catch (err) { + return { success: false, error: getErrorMessage(err) } + } + }, + + async whoAmI(): Promise { + try { + const ip = requireIp() + return await window.bridge.runtimeWhoAmI(ip) + } catch (err) { + return { success: false, error: getErrorMessage(err) } + } + }, + + async updateUser(userId: number, params: UpdateUserParams) { + try { + const ip = requireIp() + return await window.bridge.runtimeUpdateUser(ip, userId, params) + } catch (err) { + return { success: false, error: getErrorMessage(err) } + } + }, + + async deleteUser(userId: number) { + try { + const ip = requireIp() + return await window.bridge.runtimeDeleteUser(ip, userId) + } catch (err) { + return { success: false, error: getErrorMessage(err) } + } + }, + async getStatus(includeStats?: boolean): Promise { try { const ip = requireIp() diff --git a/src/middleware/shared/ports/runtime-port.ts b/src/middleware/shared/ports/runtime-port.ts index e48ebefa8..12d3e51b1 100644 --- a/src/middleware/shared/ports/runtime-port.ts +++ b/src/middleware/shared/ports/runtime-port.ts @@ -61,9 +61,18 @@ export interface LoginResult { error?: string } +/** + * RBAC role for a runtime account. `admin` may manage every account; + * `user` may edit only its own account and cannot create/delete users. + */ +export type RuntimeUserRole = 'admin' | 'user' + export interface CreateUserParams { username: string password: string + /** Role for the new account. Ignored for the unauthenticated first-user + * bootstrap (the runtime always makes the first user an admin). */ + role?: RuntimeUserRole } export interface UsersInfoResult { @@ -72,6 +81,37 @@ export interface UsersInfoResult { error?: string } +/** A user account as reported by the runtime. */ +export interface RuntimeUser { + id: number + username: string + role: RuntimeUserRole +} + +export interface ListUsersResult { + success: boolean + users?: RuntimeUser[] + error?: string +} + +export interface WhoAmIResult { + success: boolean + user?: RuntimeUser + error?: string +} + +/** + * Fields to change on an existing account. Only the provided fields are + * applied. `currentPassword` is required by the runtime when changing your + * OWN password (not when an admin resets another user's password). + */ +export interface UpdateUserParams { + username?: string + password?: string + currentPassword?: string + role?: RuntimeUserRole +} + export interface RuntimeStatusResult { success: boolean status?: PlcStatus | (string & {}) @@ -133,6 +173,18 @@ export interface RuntimePort { /** Check if the runtime has users and get its version. */ getUsersInfo(): Promise + /** List all user accounts on the runtime (requires authentication). */ + listUsers(): Promise + + /** Return the currently authenticated account (id, username, role). */ + whoAmI(): Promise + + /** Update an account's username, password and/or role. */ + updateUser(userId: number, params: UpdateUserParams): Promise<{ success: boolean; error?: string }> + + /** Delete an account by id (admin only; cannot delete your own account). */ + deleteUser(userId: number): Promise<{ success: boolean; error?: string }> + /** Get current PLC runtime status with optional timing statistics. */ getStatus(includeStats?: boolean): Promise From 68b3021e56a80f3f76892bc14acc69c04dbf6863 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 23 Jul 2026 14:37:08 -0400 Subject: [PATCH 2/6] fix(user-management): connect after first user, version gate, UX fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback on the User Management feature: 1. First-user setup now stays connected — the bootstrap dialog no longer tears down the just-established session when it closes on success. 4. Authenticated create-user accepts the runtime's 201 (was treated as an error, so the dialog showed the raw response and stayed open). 5. The self-edit "Current password" field now renders last (after Confirm password) and only once the password is actually being changed, so it doesn't shove the field the user just clicked. 7. Gate the User Management tree leaf on runtime version ≥ v4.1.9 (isUserManagementCapableRuntime); the connected runtime version is now stored in runtimeConnection and set on connect. Also: edit-icon tooltip now reads "Edit user" (icon no longer swallows the button title), "New User" label centered, and changing your own password signs you out to force a fresh login with the new credentials. Tests: version-gate helper, device-slice runtimeVersion, existing modal and adapter suites updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/runtime-version-gate.test.ts | 32 +++++++++++++++ .../shared/firmware/runtime-version-gate.ts | 20 +++++++++ .../editor/device/configuration/board.tsx | 5 +++ .../editor/user-management/index.tsx | 28 +++++++++++-- .../_organisms/explorer/project.tsx | 8 +++- .../modals/runtime-create-user-modal.tsx | 10 +++-- .../_organisms/modals/runtime-user-modal.tsx | 41 ++++++++++--------- .../store/__tests__/device-slice.test.ts | 20 +++++++++ .../store/__tests__/device-types.test.ts | 3 ++ src/frontend/store/slices/device/slice.ts | 10 +++++ src/frontend/store/slices/device/types.ts | 5 +++ src/frontend/utils/device.ts | 6 ++- src/main/modules/ipc/main.ts | 7 ++-- 13 files changed, 163 insertions(+), 32 deletions(-) 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/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 index 111cc3c1b..7c23d5af6 100644 --- a/src/frontend/components/_features/[workspace]/editor/user-management/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/user-management/index.tsx @@ -15,6 +15,8 @@ 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) @@ -69,8 +71,24 @@ const UserManagementEditor = () => { 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 @@ -115,10 +133,10 @@ const UserManagementEditor = () => { )} @@ -162,7 +180,9 @@ const UserManagementEditor = () => { onClick={() => setEditTarget({ user, isSelf })} className='flex h-7 w-7 items-center justify-center rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-850' > - + {/* pointer-events-none so the button's `title` tooltip wins + over the icon SVG's own ("Pencil Icon"). */} + <PencilIcon className='pointer-events-none h-4 w-4' /> </button> )} {canDeleteRow(user) && ( @@ -172,7 +192,7 @@ const UserManagementEditor = () => { onClick={() => setDeleteTarget(user)} className='flex h-7 w-7 items-center justify-center rounded-md text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950' > - <TrashCanIcon className='h-4 w-4 stroke-current' /> + <TrashCanIcon className='pointer-events-none h-4 w-4 stroke-current' /> </button> )} </div> diff --git a/src/frontend/components/_organisms/explorer/project.tsx b/src/frontend/components/_organisms/explorer/project.tsx index 484ae5736..82aa9982e 100644 --- a/src/frontend/components/_organisms/explorer/project.tsx +++ b/src/frontend/components/_organisms/explorer/project.tsx @@ -7,6 +7,7 @@ import { useOpenPLCStore } from '../../../store' import { extractSearchQuery } from '../../../store/slices/search/utils' import type { TabsProps } from '../../../store/slices/tabs' import { CreateEditorObjectFromTab, LIBRARY_MANIFEST_TAB_NAME } from '../../../store/slices/tabs/utils' +import { isUserManagementCapableRuntime } from '../../../utils/device' import { useToast } from '../../_features/[app]/toast/use-toast' import { CreatePLCElement } from '../../_features/[workspace]/create-element' import { @@ -57,8 +58,11 @@ const Project = () => { const canEdit = useOpenPLCStore((s) => s.workspace.canEdit) // Runtime User Management is only meaningful while connected to a runtime - // (it reads/writes the runtime's account list over the authenticated API). + // (it reads/writes the runtime's account list over the authenticated API) + // AND only exists on runtimes ≥ v4.1.9 — older runtimes lack the endpoints. const runtimeConnected = useOpenPLCStore((s) => s.runtimeConnection.connectionStatus === 'connected') + const runtimeVersion = useOpenPLCStore((s) => s.runtimeConnection.runtimeVersion) + const showUserManagement = runtimeConnected && isUserManagementCapableRuntime(runtimeVersion) // Per-project-type capability matrix — drives which branches // render. Library projects only show Functions / Function Blocks / @@ -378,7 +382,7 @@ const Project = () => { } /> )} - {runtimeConnected && ( + {showUserManagement && ( <ProjectTreeLeaf key='User Management' leafLang='userManagement' diff --git a/src/frontend/components/_organisms/modals/runtime-create-user-modal.tsx b/src/frontend/components/_organisms/modals/runtime-create-user-modal.tsx index d1eae9087..afa138fa1 100644 --- a/src/frontend/components/_organisms/modals/runtime-create-user-modal.tsx +++ b/src/frontend/components/_organisms/modals/runtime-create-user-modal.tsx @@ -10,7 +10,7 @@ import { RuntimeUserModal, type RuntimeUserModalSubmit } from './runtime-user-mo * always makes this first account an admin). */ const RuntimeCreateUserModal = () => { - const { modals, modalActions, deviceActions } = useOpenPLCStore() + const { modals, modalActions, deviceActions, runtimeConnection } = useOpenPLCStore() const runtime = useRuntime() const isOpen = modals['runtime-create-user']?.open || false @@ -37,8 +37,12 @@ const RuntimeCreateUserModal = () => { const handleOpenChange = (open: boolean) => { if (!open) { - // Cancelling the first-user setup abandons the connection attempt. - if (isOpen) deviceActions.setRuntimeConnectionStatus('disconnected') + // On success `handleSubmit` has already logged in and set the status to + // 'connected'; closing then must NOT tear that down. Only a genuine + // cancel (still connecting/disconnected) abandons the connection attempt. + if (isOpen && runtimeConnection.connectionStatus !== 'connected') { + deviceActions.setRuntimeConnectionStatus('disconnected') + } modalActions.closeModal() } modalActions.onOpenChange('runtime-create-user', open) diff --git a/src/frontend/components/_organisms/modals/runtime-user-modal.tsx b/src/frontend/components/_organisms/modals/runtime-user-modal.tsx index 1bef6f7b5..098ac7571 100644 --- a/src/frontend/components/_organisms/modals/runtime-user-modal.tsx +++ b/src/frontend/components/_organisms/modals/runtime-user-modal.tsx @@ -209,25 +209,6 @@ const RuntimeUserModal = (props: RuntimeUserModalProps) => { </div> )} - {isEdit && requireCurrentPassword && passwordTouched && ( - <div> - <Label htmlFor='runtime-user-current-pass' className='mb-2 block text-sm'> - Current password - </Label> - <input - id='runtime-user-current-pass' - name='runtime-user-current-pass' - type='password' - autoComplete='off' - value={currentPassword} - onChange={(e) => setCurrentPassword(e.target.value)} - placeholder='Enter your current password' - className={inputClass} - disabled={isLoading} - /> - </div> - )} - <div> <Label htmlFor='runtime-user-pass' className='mb-2 block text-sm'> {isEdit ? 'New password' : 'Password'} @@ -264,6 +245,28 @@ const RuntimeUserModal = (props: RuntimeUserModalProps) => { /> </div> + {/* Rendered last, and only once the password is actually being + changed, so adding it doesn't shove the field the user just + clicked (the new-password field) down the form. */} + {isEdit && requireCurrentPassword && passwordTouched && ( + <div> + <Label htmlFor='runtime-user-current-pass' className='mb-2 block text-sm'> + Current password + </Label> + <input + id='runtime-user-current-pass' + name='runtime-user-current-pass' + type='password' + autoComplete='off' + value={currentPassword} + onChange={(e) => setCurrentPassword(e.target.value)} + placeholder='Enter your current password' + className={inputClass} + disabled={isLoading} + /> + </div> + )} + {error && <p className='text-sm text-red-600 dark:text-red-400'>{error}</p>} <div className='mt-2 flex gap-3'> diff --git a/src/frontend/store/__tests__/device-slice.test.ts b/src/frontend/store/__tests__/device-slice.test.ts index 6c3d34140..d92bc5ee4 100644 --- a/src/frontend/store/__tests__/device-slice.test.ts +++ b/src/frontend/store/__tests__/device-slice.test.ts @@ -1219,6 +1219,24 @@ describe('createDeviceSlice', () => { }) }) + // ----------------------------------------------------------------------- + // setRuntimeVersion + // ----------------------------------------------------------------------- + describe('setRuntimeVersion', () => { + it('stores the runtime version', () => { + const store = makeStore() + store.getState().deviceActions.setRuntimeVersion('v4.1.9') + expect(store.getState().runtimeConnection.runtimeVersion).toBe('v4.1.9') + }) + + it('clears the runtime version with null', () => { + const store = makeStore() + store.getState().deviceActions.setRuntimeVersion('v4.1.9') + store.getState().deviceActions.setRuntimeVersion(null) + expect(store.getState().runtimeConnection.runtimeVersion).toBeNull() + }) + }) + // ----------------------------------------------------------------------- // setPlcRuntimeStatus // ----------------------------------------------------------------------- @@ -1401,6 +1419,7 @@ describe('createDeviceSlice', () => { store.getState().deviceActions.setIncludeTimingStatsInPolling(true) store.getState().deviceActions.setEthercatStatus(makeEthercatStatus()) store.getState().deviceActions.setIncludeEthercatStatsInPolling(true) + store.getState().deviceActions.setRuntimeVersion('v4.1.9') store.getState().deviceActions.clearRuntimeConnection() const rc = store.getState().runtimeConnection @@ -1408,6 +1427,7 @@ describe('createDeviceSlice', () => { expect(rc.connectionStatus).toBe('disconnected') expect(rc.plcStatus).toBeNull() expect(rc.ipAddress).toBeNull() + expect(rc.runtimeVersion).toBeNull() expect(rc.selectedDevice).toBeNull() expect(rc.storedCredentials).toBeNull() expect(rc.timingStats).toBeNull() diff --git a/src/frontend/store/__tests__/device-types.test.ts b/src/frontend/store/__tests__/device-types.test.ts index a1ed67098..41de632aa 100644 --- a/src/frontend/store/__tests__/device-types.test.ts +++ b/src/frontend/store/__tests__/device-types.test.ts @@ -96,6 +96,7 @@ describe('Device slice types', () => { connectionStatus: 'disconnected', plcStatus: null, ipAddress: null, + runtimeVersion: null, selectedDevice: null, storedCredentials: null, timingStats: null, @@ -137,6 +138,7 @@ describe('Device slice types', () => { connectionStatus: 'connected', plcStatus: 'RUNNING', ipAddress: '192.168.1.1', + runtimeVersion: 'v4.1.9', selectedDevice: { orchestratorId: 'o', orchestratorAgentId: 'a', @@ -177,6 +179,7 @@ describe('Device slice types', () => { connectionStatus: 'disconnected', plcStatus: null, ipAddress: null, + runtimeVersion: null, selectedDevice: null, storedCredentials: null, timingStats: null, diff --git a/src/frontend/store/slices/device/slice.ts b/src/frontend/store/slices/device/slice.ts index 6206d7b26..3740ab094 100644 --- a/src/frontend/store/slices/device/slice.ts +++ b/src/frontend/store/slices/device/slice.ts @@ -51,6 +51,7 @@ const createDeviceSlice: StateCreator<DeviceSliceRoot, [], [], DeviceSlice> = (s connectionStatus: 'disconnected', plcStatus: null, ipAddress: null, + runtimeVersion: null, selectedDevice: null, storedCredentials: null, timingStats: null, @@ -118,6 +119,7 @@ const createDeviceSlice: StateCreator<DeviceSliceRoot, [], [], DeviceSlice> = (s runtimeConnection.connectionStatus = 'disconnected' runtimeConnection.plcStatus = null runtimeConnection.ipAddress = null + runtimeConnection.runtimeVersion = null runtimeConnection.selectedDevice = null runtimeConnection.storedCredentials = null runtimeConnection.timingStats = null @@ -445,6 +447,13 @@ const createDeviceSlice: StateCreator<DeviceSliceRoot, [], [], DeviceSlice> = (s }), ) }, + setRuntimeVersion: (version): void => { + setState( + produce(({ runtimeConnection }: DeviceSlice) => { + runtimeConnection.runtimeVersion = version + }), + ) + }, setPlcRuntimeStatus: (status): void => { setState( produce(({ runtimeConnection }: DeviceSlice) => { @@ -508,6 +517,7 @@ const createDeviceSlice: StateCreator<DeviceSliceRoot, [], [], DeviceSlice> = (s runtimeConnection.connectionStatus = 'disconnected' runtimeConnection.plcStatus = null runtimeConnection.ipAddress = null + runtimeConnection.runtimeVersion = null runtimeConnection.selectedDevice = null runtimeConnection.storedCredentials = null runtimeConnection.timingStats = null diff --git a/src/frontend/store/slices/device/types.ts b/src/frontend/store/slices/device/types.ts index 6275ae351..5a035b325 100644 --- a/src/frontend/store/slices/device/types.ts +++ b/src/frontend/store/slices/device/types.ts @@ -64,6 +64,10 @@ export type RuntimeConnection = { connectionStatus: ConnectionStatus plcStatus: PlcStatus | null ipAddress: string | null + /** Version string reported by the connected runtime (from + * get-users-info / the X-OpenPLC-Runtime-Version header), or null + * when unknown. Gates version-dependent UI like User Management. */ + runtimeVersion: string | null selectedDevice: SelectedDevice | null storedCredentials: StoredCredentials | null timingStats: TimingStats | null @@ -144,6 +148,7 @@ export type DeviceActions = { setRuntimeIpAddress: (ipAddress: string) => void setRuntimeJwtToken: (token: string | null) => void setRuntimeConnectionStatus: (status: ConnectionStatus) => void + setRuntimeVersion: (version: string | null) => void setPlcRuntimeStatus: (status: PlcStatus | null) => void setSelectedDevice: (device: SelectedDevice | null) => void setStoredCredentials: (credentials: StoredCredentials | null) => void diff --git a/src/frontend/utils/device.ts b/src/frontend/utils/device.ts index 16d5d7187..9ab92049d 100644 --- a/src/frontend/utils/device.ts +++ b/src/frontend/utils/device.ts @@ -11,9 +11,13 @@ * cleaner "does this target support <feature>?". */ -import { parseRuntimeVersion } from '@root/backend/shared/firmware/runtime-version-gate' +import { isUserManagementCapableRuntime, parseRuntimeVersion } from '@root/backend/shared/firmware/runtime-version-gate' import { type BoardInfoLike, resolveTargetCapabilities } from '@root/middleware/shared/utils/target-capabilities' +// Re-exported so component/store layers (which may not import backend-shared +// directly) can gate UI on runtime capability through the utils layer. +export { isUserManagementCapableRuntime } + /** * Minimal board info shape used by device utility functions. * Compatible with both BoardInfo from ports and store slice types. diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index 561cbb9d2..cd63382d2 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -227,9 +227,10 @@ class MainProcessBridge implements MainIpcModule { // First-user bootstrap runs before any login (no token yet) and the // runtime allows it unauthenticated. Once a session exists this is an // admin adding an account, which the runtime requires to be authenticated - // — route it through the token authority so an expired token is refreshed. + // — route it through the token authority (mutation helper accepts the + // runtime's 201 Created and refreshes an expired token). if (this.tokens.hasToken()) { - const res = await this.makeRuntimeApiPostRequest(ipAddress, '/api/create-user', payload, () => undefined) + const res = await this.makeRuntimeApiMutation('POST', ipAddress, '/api/create-user', payload) return res.success ? { success: true } : { success: false, error: res.error } } @@ -470,7 +471,7 @@ class MainProcessBridge implements MainIpcModule { * success; the raw body is returned so callers can surface error messages. */ private makeRuntimeApiMutation( - method: 'PUT' | 'DELETE', + method: 'POST' | 'PUT' | 'DELETE', ipAddress: string, endpoint: string, body?: string, From 66db0d80e8b94376fcee2c4d3e0b60d2e84d8247 Mon Sep 17 00:00:00 2001 From: Thiago Alves <thiagoralves@gmail.com> Date: Thu, 23 Jul 2026 14:45:13 -0400 Subject: [PATCH 3/6] fix(user-management): keep connection after first-user creation The bootstrap dialog was still disconnecting on the success close. Reading runtimeConnection.connectionStatus in the close handler was stale: handleSubmit sets it to 'connected', but RuntimeUserModal then calls onOpenChange(false) synchronously before a re-render, so the handler saw the old 'connecting' value and reverted to 'disconnected'. Track success with a ref instead (set in handleSubmit, reset when the dialog opens) so the close handler reliably distinguishes a successful connect from a genuine cancel. Restores the pre-refactor behavior where the success path closed via a controlled prop change that never triggered the cancel logic. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../modals/runtime-create-user-modal.tsx | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/frontend/components/_organisms/modals/runtime-create-user-modal.tsx b/src/frontend/components/_organisms/modals/runtime-create-user-modal.tsx index afa138fa1..ed78e2098 100644 --- a/src/frontend/components/_organisms/modals/runtime-create-user-modal.tsx +++ b/src/frontend/components/_organisms/modals/runtime-create-user-modal.tsx @@ -1,3 +1,5 @@ +import { useEffect, useRef } from 'react' + import { useRuntime } from '../../../../middleware/shared/providers' import { useOpenPLCStore } from '../../../store' import { getErrorMessage } from '../../../utils/get-error-message' @@ -10,11 +12,22 @@ import { RuntimeUserModal, type RuntimeUserModalSubmit } from './runtime-user-mo * always makes this first account an admin). */ const RuntimeCreateUserModal = () => { - const { modals, modalActions, deviceActions, runtimeConnection } = useOpenPLCStore() + const { modals, modalActions, deviceActions } = useOpenPLCStore() const runtime = useRuntime() const isOpen = modals['runtime-create-user']?.open || false + // Tracks whether this run successfully created + logged in. A ref (not the + // store's connectionStatus) because handleSubmit sets the status and the + // modal then calls onOpenChange(false) synchronously — before a re-render — + // so reading connectionStatus from the render closure would be stale and the + // close handler would wrongly tear down the just-established connection. + const succeededRef = useRef(false) + + useEffect(() => { + if (isOpen) succeededRef.current = false + }, [isOpen]) + const handleSubmit = async ({ username, password }: RuntimeUserModalSubmit): Promise<string | null> => { if (!password) return 'Password is required' try { @@ -27,6 +40,7 @@ const RuntimeCreateUserModal = () => { deviceActions.setRuntimeJwtToken(loginResult.accessToken) deviceActions.setRuntimeConnectionStatus('connected') deviceActions.setStoredCredentials({ username, password }) + succeededRef.current = true return null } return 'User created but login failed: ' + (loginResult.error || 'Unknown error') @@ -37,10 +51,9 @@ const RuntimeCreateUserModal = () => { const handleOpenChange = (open: boolean) => { if (!open) { - // On success `handleSubmit` has already logged in and set the status to - // 'connected'; closing then must NOT tear that down. Only a genuine - // cancel (still connecting/disconnected) abandons the connection attempt. - if (isOpen && runtimeConnection.connectionStatus !== 'connected') { + // Only a genuine cancel (no successful login this run) abandons the + // connection attempt; a success close must keep the 'connected' status. + if (isOpen && !succeededRef.current) { deviceActions.setRuntimeConnectionStatus('disconnected') } modalActions.closeModal() From 8cc96f6ec767f2c2617327bb544c933866545955 Mon Sep 17 00:00:00 2001 From: Thiago Alves <thiagoralves@gmail.com> Date: Thu, 23 Jul 2026 15:54:02 -0400 Subject: [PATCH 4/6] fix(user-management): center New User button + no crash when logged out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 3: the PlusIcon strokes `inherit` with no stroke color set, so it rendered invisibly — its empty box pushed the "New User" label off-center. Give it `stroke-current` (white on the brand button) so it shows as a proper "+ New User". Bug 6 follow-up: changing your own password signs you out, but the User Management tab stayed open and any action then crashed with "users.map is not a function". Two guards: - When not connected, render a neutral placeholder instead of the table and actions (which would hit the runtime unauthenticated). - listUsers coerces a non-array payload to [] (the runtime returns an existence-only {"msg":"Users found"} object when the token is invalid), in the editor adapter and the screen, so the table can never receive a non-array. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../editor/user-management/index.tsx | 27 ++++++++++++++++--- src/main/modules/ipc/main.ts | 12 +++++---- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/frontend/components/_features/[workspace]/editor/user-management/index.tsx b/src/frontend/components/_features/[workspace]/editor/user-management/index.tsx index 7c23d5af6..c8a69c09f 100644 --- a/src/frontend/components/_features/[workspace]/editor/user-management/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/user-management/index.tsx @@ -38,7 +38,10 @@ const UserManagementEditor = () => { setLoadError(listResult.error || 'Failed to load users') setUsers([]) } else { - setUsers(listResult.users ?? []) + // 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) @@ -111,6 +114,20 @@ const UserManagementEditor = () => { 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 ( + <div className='flex h-full w-full select-none flex-col items-center justify-center gap-2 p-8 text-center'> + <h2 className='text-lg font-semibold text-neutral-1000 dark:text-white'>User Management</h2> + <p className='text-sm text-neutral-500 dark:text-neutral-400'> + You are not connected to a runtime. Connect to the runtime to manage its users. + </p> + </div> + ) + } + return ( <div className='flex h-full w-full select-none flex-col overflow-auto p-8'> <div className='mb-6 flex items-start justify-between'> @@ -133,10 +150,12 @@ const UserManagementEditor = () => { <button type='button' onClick={() => setCreateOpen(true)} - className='flex h-9 items-center justify-center gap-2 rounded-md bg-brand px-3 text-sm font-medium leading-none text-white hover:bg-brand-medium-dark' + className='flex h-9 items-center justify-center gap-2 rounded-md bg-brand px-3 text-sm font-medium text-white hover:bg-brand-medium-dark' > - <PlusIcon className='h-4 w-4' /> - <span>New User</span> + {/* PlusIcon strokes `inherit`; without a stroke color it renders + invisibly and its empty box pushed the label off-center. */} + <PlusIcon className='h-4 w-4 stroke-current' /> + <span className='leading-none'>New User</span> </button> )} </div> diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index cd63382d2..3e892772a 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -247,11 +247,13 @@ class MainProcessBridge implements MainIpcModule { } handleRuntimeListUsers = async (_event: IpcMainInvokeEvent, ipAddress: string) => { - const res = await this.makeRuntimeApiRequest<RuntimeUser[]>( - ipAddress, - '/api/get-users-info', - (data) => JSON.parse(data) as RuntimeUser[], - ) + const res = await this.makeRuntimeApiRequest<RuntimeUser[]>(ipAddress, '/api/get-users-info', (data) => { + // With a valid admin token this is a user array; without one the runtime + // answers the existence-only {"msg":"Users found"} object — coerce that + // (or any non-array) to an empty list so the caller never gets a non-array. + const parsed: unknown = JSON.parse(data) + return Array.isArray(parsed) ? (parsed as RuntimeUser[]) : [] + }) return res.success ? { success: true, users: res.data } : { success: false, error: res.error } } From f482aad5adcc027679badabc692817abb9620e71 Mon Sep 17 00:00:00 2001 From: Thiago Alves <thiagoralves@gmail.com> Date: Thu, 23 Jul 2026 18:25:09 -0400 Subject: [PATCH 5/6] fix(user-management): use the Users icon on the tab (not the IL default) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tab atom maps fileDerivation.type to an icon and defaults to the IL icon when the type isn't handled — so the User Management tab showed the IL glyph. Add a 'user-management' case using the same UsersIcon as the project-tree leaf. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- src/frontend/components/_atoms/tab/index.tsx | 6 ++++++ 1 file changed, 6 insertions(+) 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<string, React.ReactNode> = { '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]' />, } @@ -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' } From 526e470b3ee2e572c763e5b2350a7be167ec2325 Mon Sep 17 00:00:00 2001 From: Thiago Alves <thiagoralves@gmail.com> Date: Thu, 23 Jul 2026 18:40:08 -0400 Subject: [PATCH 6/6] style: prettier formatting for user-management screen + ipc main Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../[workspace]/editor/user-management/index.tsx | 10 +++++----- src/main/modules/ipc/main.ts | 7 +------ 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/src/frontend/components/_features/[workspace]/editor/user-management/index.tsx b/src/frontend/components/_features/[workspace]/editor/user-management/index.tsx index c8a69c09f..69730f843 100644 --- a/src/frontend/components/_features/[workspace]/editor/user-management/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/user-management/index.tsx @@ -4,7 +4,10 @@ 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 { + 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' @@ -181,10 +184,7 @@ const UserManagementEditor = () => { {users.map((user) => { const isSelf = user.id === currentUser?.id return ( - <tr - key={user.id} - className='border-b border-neutral-100 last:border-b-0 dark:border-neutral-850' - > + <tr key={user.id} className='border-b border-neutral-100 last:border-b-0 dark:border-neutral-850'> <td className='px-4 py-2 text-neutral-850 dark:text-neutral-200'> {user.username} {isSelf && <span className='ml-2 text-xs text-neutral-400'>(you)</span>} diff --git a/src/main/modules/ipc/main.ts b/src/main/modules/ipc/main.ts index 3e892772a..e0f8cc87b 100644 --- a/src/main/modules/ipc/main.ts +++ b/src/main/modules/ipc/main.ts @@ -278,12 +278,7 @@ class MainProcessBridge implements MainIpcModule { if (params.password !== undefined) body.password = params.password if (params.currentPassword !== undefined) body.current_password = params.currentPassword if (params.role !== undefined) body.role = params.role - const res = await this.makeRuntimeApiMutation( - 'PUT', - ipAddress, - `/api/update-user/${userId}`, - JSON.stringify(body), - ) + const res = await this.makeRuntimeApiMutation('PUT', ipAddress, `/api/update-user/${userId}`, JSON.stringify(body)) return res.success ? { success: true } : { success: false, error: res.error } }