You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by ma...@apache.org on 2026/08/25 15:19:48 UTC
(camel-karavan) 23/42: Karavan-app UI Page Access
This is an automated email from the ASF dual-hosted git repository.
mgubaidullin pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel-karavan.git
commit adb882c9e82b279ce8fd366b5ca29d7441bb15d0
Author: Marat Gubaidullin <ma...@talismancloud.io>
AuthorDate: Mon Aug 24 17:56:05 2026 -0400
Karavan-app UI Page Access
---
.../main/webui/src/ui/page-access/AccessPage.css | 0
.../main/webui/src/ui/page-access/AccessPage.tsx | 132 +++++++++++++++
.../webui/src/ui/page-access/PasswordModal.tsx | 143 ++++++++++++++++
.../main/webui/src/ui/page-access/UserModal.tsx | 156 ++++++++++++++++++
.../src/ui/page-access/profile/ChangePassword.tsx | 144 +++++++++++++++++
.../src/ui/page-access/profile/UserProfile.tsx | 110 +++++++++++++
.../src/ui/page-access/profile/UserProfileTab.css | 15 ++
.../src/ui/page-access/profile/UserProfileTab.tsx | 18 +++
.../webui/src/ui/page-access/roles/RoleModal.tsx | 112 +++++++++++++
.../webui/src/ui/page-access/roles/RolesTable.tsx | 60 +++++++
.../src/ui/page-access/roles/RolesTableRow.tsx | 90 +++++++++++
.../src/ui/page-access/sessions/SessionTable.tsx | 56 +++++++
.../ui/page-access/sessions/SessionTableRow.tsx | 88 ++++++++++
.../webui/src/ui/page-access/users/UsersTable.tsx | 62 +++++++
.../src/ui/page-access/users/UsersTableRow.tsx | 180 +++++++++++++++++++++
15 files changed, 1366 insertions(+)
diff --git a/karavan-app/src/main/webui/src/ui/page-access/AccessPage.css b/karavan-app/src/main/webui/src/ui/page-access/AccessPage.css
new file mode 100644
index 00000000..e69de29b
diff --git a/karavan-app/src/main/webui/src/ui/page-access/AccessPage.tsx b/karavan-app/src/main/webui/src/ui/page-access/AccessPage.tsx
new file mode 100644
index 00000000..e2eee314
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/page-access/AccessPage.tsx
@@ -0,0 +1,132 @@
+import React, {useEffect, useState} from 'react';
+import {Button, capitalize, Content, Tab, Tabs, TabTitleText, TextInputGroup, TextInputGroupMain, TextInputGroupUtilities,} from '@patternfly/react-core';
+import {useAccessStore} from "@stores/AccessStore";
+import {PlusIcon, SearchIcon, SyncAltIcon, TimesIcon} from "@patternfly/react-icons";
+import {RightPanel} from "@shared/ui/RightPanel";
+import {UsersTable} from "./users/UsersTable";
+import {ErrorBoundaryWrapper} from "@designer/ErrorBoundaryWrapper";
+import {RolesTable} from "./roles/RolesTable";
+import {UserModal} from "./UserModal";
+import {RoleModal} from "./roles/RoleModal";
+import {UserProfileTab} from "./profile/UserProfileTab";
+import {getCurrentUser} from "@api/auth/AuthApi";
+import {PLATFORM_ADMIN} from "@models/AccessModels";
+import {PasswordModal} from "./PasswordModal";
+import {SessionTable} from "./sessions/SessionTable";
+import {TabProps} from "@patternfly/react-core/src/components/Tabs/Tab";
+
+export const AccessPage = () => {
+
+ const adminMenus: (string | number)[] = ['profile', 'users', 'roles', 'sessions'];
+ const userMenus: (string | number)[] = ['profile'];
+ const {
+ showUserModal, setShowUserModal, setFilter, filter, setCurrentUser, showRoleModal,
+ setShowRoleModal, showPasswordModal, showTokenModal, setShowTokenModal, refreshAccess
+ } = useAccessStore();
+ const [activeItem, setActiveItem] = useState<string | number>(userMenus.at(0)!);
+
+ const onNavSelect = (event: React.MouseEvent<HTMLElement, MouseEvent>, eventKey: TabProps['eventKey']) => {
+ setActiveItem(eventKey);
+ };
+
+ useEffect(() => {
+ refreshAccess();
+ }, []);
+
+ function searchInput() {
+ return (
+ <TextInputGroup className="search" style={{width: '300px'}}>
+ <TextInputGroupMain
+ value={filter}
+ placeholder='Search'
+ type="text"
+ autoComplete={"off"}
+ autoFocus={true}
+ icon={<SearchIcon/>}
+ onChange={(_event, value) => {
+ setFilter(value);
+ }}
+ aria-label="text input example"
+ />
+ <TextInputGroupUtilities>
+ <Button variant="plain" onClick={_ => {
+ setFilter('');
+ }}>
+ <TimesIcon aria-hidden={true}/>
+ </Button>
+ </TextInputGroupUtilities>
+ </TextInputGroup>
+ )
+ }
+
+ function tools() {
+ const showAddButton = ["users", "roles", "tokens"].includes(activeItem?.toString())
+ return (<div className="project-files-toolbar" style={{justifyContent: "flex-end"}}>
+ <Button icon={<SyncAltIcon/>}
+ variant={"link"}
+ onClick={e => refreshAccess()}
+ />
+ {searchInput()}
+ {showAddButton &&
+ <Button className="dev-action-button"
+ icon={<PlusIcon/>}
+ onClick={e => {
+ setCurrentUser(undefined)
+ if (activeItem === "users") {
+ setShowUserModal(true)
+ } else if (activeItem === "roles") {
+ setShowRoleModal(true)
+ } else if (activeItem === "tokens") {
+ setShowTokenModal(true)
+ }
+ }}
+ >Add</Button>
+ }
+ </div>);
+ }
+
+ function title() {
+ return (
+ <Content component="h2">Access Control</Content>
+ );
+ }
+
+ function getNavigation() {
+ return (
+ <Tabs onSelect={onNavSelect} isNav activeKey={activeItem}>
+ {(getCurrentUser()?.roles?.includes(PLATFORM_ADMIN) ? adminMenus : userMenus)
+ .filter(m => []).map((item, i) =>
+ <Tab
+ key={item}
+ eventKey={item}
+ title={<TabTitleText>{capitalize(item?.toString())}</TabTitleText>}
+ />
+ )}
+ </Tabs>
+ )
+ }
+
+ return (
+ <RightPanel
+ title={title()}
+ toolsStart={getNavigation()}
+ tools={undefined}
+ mainPanel={
+ <div className="right-panel-card">
+ <ErrorBoundaryWrapper key='info' onError={error => console.error(error)}>
+ <div style={{display: 'flex', flexDirection: 'column', height: '100%'}}>
+ {activeItem !== 'profile' && tools()}
+ {activeItem === 'profile' && <UserProfileTab/>}
+ {activeItem === 'users' && <UsersTable/>}
+ {activeItem === 'roles' && <RolesTable/>}
+ {activeItem === 'sessions' && <SessionTable/>}
+ {showUserModal && <UserModal/>}
+ {showRoleModal && <RoleModal/>}
+ {showPasswordModal && <PasswordModal/>}
+ </div>
+ </ErrorBoundaryWrapper>
+ </div>
+ }
+ />
+ )
+}
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/page-access/PasswordModal.tsx b/karavan-app/src/main/webui/src/ui/page-access/PasswordModal.tsx
new file mode 100644
index 00000000..e788ee8f
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/page-access/PasswordModal.tsx
@@ -0,0 +1,143 @@
+import React, {useEffect} from 'react';
+import {
+ Alert,
+ Button,
+ Content,
+ Form,
+ FormAlert,
+ FormGroup,
+ FormHelperText,
+ HelperText,
+ HelperTextItem,
+ Modal,
+ ModalBody,
+ ModalFooter,
+ ModalHeader,
+ ModalVariant,
+} from '@patternfly/react-core';
+import {SubmitHandler, useForm} from "react-hook-form";
+import {AxiosResponse} from "axios";
+import {useAccessStore} from "@stores/AccessStore";
+import {useFormUtil} from "@utils/useFormUtil";
+import {EventBus} from "@designer/utils/EventBus";
+import {shallow} from "zustand/shallow";
+import {AccessPassword} from "@models/AccessModels";
+import {AccessApi} from "@api/AccessApi";
+
+
+export function PasswordModal() {
+
+ const [showPasswordModal, setShowPasswordModal, currentUser]
+ = useAccessStore((s) => [s.showPasswordModal, s.setShowPasswordModal, s.currentUser], shallow);
+ const [isReset, setReset] = React.useState(false);
+ const [backendError, setBackendError] = React.useState<string>();
+ const formContext = useForm<AccessPassword>({mode: "all"});
+ const {getPasswordField} = useFormUtil(formContext);
+ const {
+ formState: {errors},
+ handleSubmit,
+ reset,
+ trigger
+ } = formContext;
+
+ useEffect(() => {
+ reset(new AccessPassword());
+ setBackendError(undefined);
+ setReset(true);
+ }, [reset]);
+
+ useEffect(() => {
+ isReset && trigger();
+ }, [trigger, isReset]);
+
+ const onSubmit: SubmitHandler<AccessPassword> = (data) => {
+ if (currentUser?.username)
+ AccessApi.setPassword(currentUser.username, data).then(value => after(value))
+ }
+
+ function after(result: [boolean, AxiosResponse | any]) {
+ if (result[0]) {
+ onSuccess();
+ } else {
+ const res = result[1]
+ const data = res?.response?.data;
+ const error = data && data !== '' ? data : res?.message;
+ setBackendError(error);
+ }
+ }
+
+ function onSuccess() {
+ const message = `Password successfully updated`;
+ EventBus.sendAlert("Success", message, "success");
+ closeModal()
+ }
+
+ function arePasswordsEqual() {
+ const pwd1 = formContext.getValues('password');
+ const pwd2 = formContext.getValues('password2');
+ return pwd1 === pwd2;
+ }
+
+ function canNotSubmit() {
+ return Object.getOwnPropertyNames(errors).length > 0 || !arePasswordsEqual();
+ }
+
+ function getPasswordError() {
+ if (!arePasswordsEqual()) {
+ return (<FormGroup>
+ <FormHelperText>
+ <HelperText>
+ <HelperTextItem variant={'error'}>
+ Passwords should be equal!
+ </HelperTextItem>
+ </HelperText>
+ </FormHelperText>
+ </FormGroup>)
+ }
+ }
+
+ function closeModal() {
+ setShowPasswordModal(false)
+ }
+
+ function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>): void {
+ if (event.key === 'Enter') {
+ handleSubmit(onSubmit)()
+ }
+ }
+
+ return (
+ <Modal
+ variant={ModalVariant.small}
+ isOpen={showPasswordModal}
+ onClose={closeModal}
+ onKeyDown={onKeyDown}
+ >
+ <ModalHeader>
+ <Content component='h2'>{`Change password for user: ${currentUser?.username}`}</Content>
+ </ModalHeader>
+ <ModalBody>
+ <Form isHorizontal={true} autoComplete="off">
+ {getPasswordField('currentPassword', 'Your Password', {})}
+ {getPasswordField('password', 'User Password', {})}
+ {getPasswordField('password2', 'Retype Password', {})}
+ {getPasswordError()}
+ {backendError &&
+ <FormAlert>
+ <Alert variant="danger" title={backendError} aria-live="polite" isInline/>
+ </FormAlert>
+ }
+ </Form>
+ </ModalBody>
+ <ModalFooter>
+ <Button key="confirm" variant="primary"
+ onClick={handleSubmit(onSubmit)}
+ isDisabled={canNotSubmit()}
+ >
+ Save
+ </Button>
+ <Button key="cancel" variant="secondary" onClick={closeModal}>Cancel</Button>
+ </ModalFooter>
+ </Modal>
+ )
+}
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/page-access/UserModal.tsx b/karavan-app/src/main/webui/src/ui/page-access/UserModal.tsx
new file mode 100644
index 00000000..9b5f252b
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/page-access/UserModal.tsx
@@ -0,0 +1,156 @@
+import React, {useEffect} from 'react';
+import {Alert, Button, Content, Form, FormAlert, FormGroup, Modal, ModalBody, ModalFooter, ModalHeader, ModalVariant, TextInput,} from '@patternfly/react-core';
+import {SubmitHandler, useForm} from "react-hook-form";
+import {AxiosResponse} from "axios";
+import {useAccessStore} from "@stores/AccessStore";
+import {useFormUtil} from "@utils/useFormUtil";
+import {EventBus} from "@designer/utils/EventBus";
+import {AccessApi} from "@api/AccessApi";
+import {getCurrentUser} from "@api/auth/AuthApi";
+import {AccessUser} from "@models/AccessModels";
+
+
+export function UserModal() {
+
+ const {showUserModal, setShowUserModal, currentUser, users, refreshAccess} = useAccessStore();
+ const [isReset, setReset] = React.useState(false);
+ const [backendError, setBackendError] = React.useState<string>();
+ const formContext = useForm<AccessUser>({mode: "all"});
+ const {getTextField} = useFormUtil(formContext);
+ const {
+ formState: {errors},
+ handleSubmit,
+ reset,
+ trigger
+ } = formContext;
+
+ useEffect(() => {
+ if (isNewUser()) {
+ reset(new AccessUser());
+ } else {
+ reset(currentUser);
+ }
+ setBackendError(undefined);
+ setReset(true);
+ }, [reset]);
+
+ useEffect(() => {
+ isReset && trigger();
+ }, [trigger, isReset]);
+
+ function closeModal() {
+ setShowUserModal(false)
+ }
+
+ const onSubmit: SubmitHandler<AccessUser> = (data) => {
+ if (isNewUser()) {
+ AccessApi.postUser(data).then((res) => after(res))
+ } else {
+ AccessApi.putUser(data).then((res) => after(res))
+ }
+ }
+
+ function after(result: [boolean, AxiosResponse | any]) {
+ const res = result[1];
+ if (result[0]) {
+ onSuccess(res.data);
+ } else {
+ const data = res?.response?.data;
+ const error = data && data !== '' ? data : res?.message;
+ setBackendError(error);
+ }
+ }
+
+ function onSuccess(user: AccessUser) {
+ const message = `User ${user.username} successfully ` + (isNewUser() ? "created" : "updated");
+ EventBus.sendAlert("Success", message, "success");
+ closeModal();
+ if (isNewUser()) {
+ refreshAccess();
+ } else {
+ if (getCurrentUser()?.username !== user.username) {
+ refreshAccess();
+ }
+ }
+ }
+
+ function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>): void {
+ if (event.key === 'Enter') {
+ handleSubmit(onSubmit)()
+ }
+ }
+
+ function isValidUsername(input: string): boolean {
+ const pattern = /^[a-z][a-z0-9-]*$/;
+ return pattern.test(input);
+ }
+
+ function isValidEmail(input: string): boolean {
+ const pattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
+ return pattern.test(input);
+ }
+
+
+ function canNotSubmit() {
+ return Object.getOwnPropertyNames(errors).length > 0;
+ }
+
+ function isNewUser() {
+ return currentUser === undefined;
+ }
+
+ function itsMe() {
+ return currentUser?.username === getCurrentUser()?.username;
+ }
+
+ return (
+ <Modal
+ variant={ModalVariant.small}
+ isOpen={showUserModal}
+ onClose={closeModal}
+ onKeyDown={onKeyDown}
+ >
+ <ModalHeader>
+ <Content component='h2'>{isNewUser() ? "Add user" : "Update user"}</Content>
+ </ModalHeader>
+ <ModalBody>
+ <Form isHorizontal={true} autoComplete="off">
+ {!isNewUser() &&
+ <FormGroup label="Username" fieldId='username' isRequired>
+ <TextInput className="text-field" type="text" id='username' value={currentUser?.username} isDisabled/>
+ </FormGroup>
+ }
+ {isNewUser() && getTextField('username', 'Username', {
+ regex: v => isValidUsername(v) || 'Only lowercase characters, numbers and dashes allowed',
+ length: v => v.length > 2 || 'Username should be longer that 2 characters',
+ name: v => !isNewUser() || !users.map(u => u.username).includes(v) || "User already exists!s",
+ })}
+ {getTextField('firstName', 'First Name', {
+ length: v => v.length > 0 || 'First name should not be empty',
+ })}
+ {getTextField('lastName', 'Last Name', {
+ length: v => v.length > 0 || 'Last name should not be empty',
+ })}
+ {getTextField('email', 'Email', {
+ length: v => v.length > 0 || 'Last name should not be empty',
+ email: v => isValidEmail(v) || 'Invalid email'
+ }, 'email')}
+ {backendError &&
+ <FormAlert>
+ <Alert variant="danger" title={backendError} aria-live="polite" isInline/>
+ </FormAlert>
+ }
+ </Form>
+ </ModalBody>
+ <ModalFooter>
+ <Button key="confirm" variant="primary"
+ onClick={handleSubmit(onSubmit)}
+ isDisabled={canNotSubmit()}
+ >
+ Save
+ </Button>
+ <Button key="cancel" variant="secondary" onClick={closeModal}>Cancel</Button>
+ </ModalFooter>
+ </Modal>
+ )
+}
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/page-access/profile/ChangePassword.tsx b/karavan-app/src/main/webui/src/ui/page-access/profile/ChangePassword.tsx
new file mode 100644
index 00000000..342203ea
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/page-access/profile/ChangePassword.tsx
@@ -0,0 +1,144 @@
+import React, {useEffect, useState} from 'react';
+import {
+ Alert,
+ Button,
+ Card,
+ CardBody,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+ Divider,
+ ExpandableSection,
+ Form,
+ FormAlert,
+ FormGroup,
+ FormHelperText,
+ HelperText,
+ HelperTextItem,
+} from '@patternfly/react-core';
+import {SubmitHandler, useForm} from "react-hook-form";
+import {AxiosResponse} from "axios";
+import {AccessPassword} from "@models/AccessModels";
+import {useFormUtil} from "@utils/useFormUtil";
+import {EventBus} from "@designer/utils/EventBus";
+import {AuthApi} from "@api/auth/AuthApi";
+
+
+export function ChangePassword() {
+
+ const [isReset, setReset] = React.useState(false);
+ const [backendError, setBackendError] = React.useState<string>();
+ const [isExpanded, setIsExpanded] = useState(false);
+ const onToggle = (_event: React.MouseEvent, isExpanded: boolean) => {
+ setIsExpanded(isExpanded);
+ };
+ const formContext = useForm<AccessPassword>({mode: "all"});
+ const {getPasswordField} = useFormUtil(formContext);
+ const {
+ formState: {errors},
+ handleSubmit,
+ reset,
+ trigger
+ } = formContext;
+
+ useEffect(() => {
+ reset(new AccessPassword());
+ setBackendError(undefined);
+ setReset(true);
+ }, [reset]);
+
+ useEffect(() => {
+ isReset && trigger();
+ }, [trigger, isReset]);
+
+ const onSubmit: SubmitHandler<AccessPassword> = (data) => {
+ AuthApi.setPassword(data, after)
+ }
+
+ function after(result: boolean, res: AxiosResponse<AccessPassword> | any) {
+ if (result) {
+ onSuccess();
+ setIsExpanded(false);
+ } else {
+ const data = res?.response?.data;
+ const error = data && data !== '' ? data : res?.message;
+ setBackendError(error);
+ }
+ }
+
+ function onSuccess() {
+ const message = `Password successfully updated`;
+ EventBus.sendAlert("Success", message, "success");
+ }
+
+ function arePasswordsEqual() {
+ const pwd1 = formContext.getValues('password');
+ const pwd2 = formContext.getValues('password2');
+ return pwd1 === pwd2;
+ }
+
+ function canNotSubmit() {
+ return Object.getOwnPropertyNames(errors).length > 0 || !arePasswordsEqual();
+ }
+
+ function getPasswordError() {
+ if (!arePasswordsEqual()) {
+ return (<FormGroup>
+ <FormHelperText>
+ <HelperText>
+ <HelperTextItem variant={'error'}>
+ Passwords should be equal!
+ </HelperTextItem>
+ </HelperText>
+ </FormHelperText>
+ </FormGroup>)
+ }
+ }
+
+ return (
+ <Card isCompact>
+ <CardHeader
+ actions={{
+ hasNoOffset: false,
+ actions: [
+ <ExpandableSection
+ key={"expandable-section"}
+ toggleText={isExpanded ? 'Hide' : 'Change'}
+ onToggle={onToggle}
+ isExpanded={isExpanded}
+ >
+ </ExpandableSection>
+ ]
+ }}
+ >
+ <CardTitle>Password</CardTitle>
+ </CardHeader>
+ {isExpanded && <Divider/>}
+ {isExpanded &&
+ <>
+ <CardBody>
+ <Form isHorizontal={true} autoComplete="off">
+ {getPasswordField('currentPassword', 'Current Password', {})}
+ {getPasswordField('password', 'Password', {})}
+ {getPasswordField('password2', 'Retype Password', {})}
+ {getPasswordError()}
+ {backendError &&
+ <FormAlert>
+ <Alert variant="danger" title={backendError} aria-live="polite" isInline/>
+ </FormAlert>
+ }
+ </Form>
+ </CardBody>
+ <CardFooter style={{display: 'flex', justifyContent: 'flex-end'}}>
+ <Button key="confirm" variant="primary"
+ onClick={handleSubmit(onSubmit)}
+ isDisabled={canNotSubmit()}
+ >
+ Save
+ </Button>
+ </CardFooter>
+ </>
+ }
+ </Card>
+ )
+}
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/page-access/profile/UserProfile.tsx b/karavan-app/src/main/webui/src/ui/page-access/profile/UserProfile.tsx
new file mode 100644
index 00000000..680bcddb
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/page-access/profile/UserProfile.tsx
@@ -0,0 +1,110 @@
+import React, {useEffect} from 'react';
+import {Alert, Button, Card, CardBody, CardFooter, CardHeader, Content, Divider, Form, FormAlert, FormGroup, TextInput,} from '@patternfly/react-core';
+import {SubmitHandler, useForm} from "react-hook-form";
+import {AxiosResponse} from "axios";
+import {AccessUser} from "@models/AccessModels";
+import {useFormUtil} from "@utils/useFormUtil";
+import {EventBus} from "@designer/utils/EventBus";
+import {AccessApi} from "@api/AccessApi";
+import {AuthApi, getCurrentUser} from "@api/auth/AuthApi";
+
+
+function UserProfile() {
+
+ const [isReset, setReset] = React.useState(false);
+ const [backendError, setBackendError] = React.useState<string>();
+ const formContext = useForm<AccessUser>({mode: "all"});
+ const {getTextField, getPasswordField} = useFormUtil(formContext);
+ const {
+ formState: {errors},
+ handleSubmit,
+ reset,
+ trigger
+ } = formContext;
+
+ useEffect(() => {
+ AuthApi.getMe(user => {
+ })
+ }, []);
+
+ useEffect(() => {
+ const user = getCurrentUser();
+ if (user) reset(user);
+ setBackendError(undefined);
+ setReset(true);
+ }, [reset]);
+
+ useEffect(() => {
+ isReset && trigger();
+ }, [trigger, isReset]);
+
+ const onSubmit: SubmitHandler<AccessUser> = (data) => {
+ AccessApi.putUser(data).then((res) => after(res))
+ }
+
+ function after(result: [boolean, AxiosResponse | any]) {
+ const res = result[1];
+ if (result[0]) {
+ onSuccess(res.data);
+ } else {
+ const data = res?.response?.data;
+ const error = data && data !== '' ? data : res?.message;
+ setBackendError(error);
+ }
+ }
+
+ function onSuccess(user: AccessUser) {
+ const message = `User ${user.username} successfully updated`;
+ EventBus.sendAlert("Success", message, "success");
+ }
+
+ function isValidEmail(input: string): boolean {
+ const pattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
+ return pattern.test(input);
+ }
+
+ function canNotSubmit() {
+ return Object.getOwnPropertyNames(errors).length > 0;
+ }
+
+ return (
+ <Card isCompact>
+ <CardHeader>
+ <Content component='h2'>User</Content>
+ </CardHeader>
+ <Divider/>
+ <CardBody>
+ <Form isHorizontal={true} autoComplete="off">
+ <FormGroup label="Username" fieldId='username' isRequired>
+ <TextInput className="text-field" type="text" id='username' value={getCurrentUser()?.username} isDisabled/>
+ </FormGroup>
+ {getTextField('firstName', 'First Name', {
+ length: v => v.length > 0 || 'First name should not be empty',
+ })}
+ {getTextField('lastName', 'Last Name', {
+ length: v => v.length > 0 || 'Last name should not be empty',
+ })}
+ {getTextField('email', 'Email', {
+ length: v => v.length > 0 || 'Last name should not be empty',
+ email: v => isValidEmail(v) || 'Invalid email'
+ }, 'email')}
+ {backendError &&
+ <FormAlert>
+ <Alert variant="danger" title={backendError} aria-live="polite" isInline/>
+ </FormAlert>
+ }
+ </Form>
+ </CardBody>
+ <CardFooter style={{display: 'flex', justifyContent: 'flex-end'}}>
+ <Button key="confirm" variant="primary"
+ onClick={handleSubmit(onSubmit)}
+ isDisabled={canNotSubmit()}
+ >
+ Save
+ </Button>
+ </CardFooter>
+ </Card>
+ )
+}
+
+export default UserProfile
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/page-access/profile/UserProfileTab.css b/karavan-app/src/main/webui/src/ui/page-access/profile/UserProfileTab.css
new file mode 100644
index 00000000..33ca7a79
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/page-access/profile/UserProfileTab.css
@@ -0,0 +1,15 @@
+.user-profile-tab-wrapper {
+ display: flex;
+ flex-direction: column;
+ overflow: auto;
+ .user-profile-tab {
+ padding: 16px;
+ overflow: auto;
+ .user-profile-tab-panels {
+ display: flex;
+ flex-direction: column;
+ justify-content: start;
+ gap: 16px;
+ }
+ }
+}
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/page-access/profile/UserProfileTab.tsx b/karavan-app/src/main/webui/src/ui/page-access/profile/UserProfileTab.tsx
new file mode 100644
index 00000000..b978b535
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/page-access/profile/UserProfileTab.tsx
@@ -0,0 +1,18 @@
+import React from 'react';
+import UserProfile from "./UserProfile";
+import {ChangePassword} from "./ChangePassword";
+import "./UserProfileTab.css"
+
+export function UserProfileTab() {
+
+ return (
+ <div className={"user-profile-tab-wrapper"}>
+ <div className={"user-profile-tab"}>
+ <div className={"user-profile-tab-panels"}>
+ <UserProfile/>
+ <ChangePassword/>
+ </div>
+ </div>
+ </div>
+ )
+}
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/page-access/roles/RoleModal.tsx b/karavan-app/src/main/webui/src/ui/page-access/roles/RoleModal.tsx
new file mode 100644
index 00000000..d6c1fef3
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/page-access/roles/RoleModal.tsx
@@ -0,0 +1,112 @@
+import React, {useEffect} from 'react';
+import {Alert, Button, Content, Form, FormAlert, Modal, ModalBody, ModalFooter, ModalHeader, ModalVariant,} from '@patternfly/react-core';
+import {SubmitHandler, useForm} from "react-hook-form";
+import {AxiosResponse} from "axios";
+import {AccessRole} from "@models/AccessModels";
+import {useAccessStore} from "@stores/AccessStore";
+import {useFormUtil} from "@utils/useFormUtil";
+import {EventBus} from "@designer/utils/EventBus";
+import {AccessApi} from "@api/AccessApi";
+
+export function RoleModal() {
+
+ const {showRoleModal, setShowRoleModal, roles, refreshAccess} = useAccessStore();
+ const [isReset, setReset] = React.useState(false);
+ const [backendError, setBackendError] = React.useState<string>();
+ const formContext = useForm<AccessRole>({mode: "all"});
+ const {getTextField} = useFormUtil(formContext);
+ const {
+ formState: {errors},
+ handleSubmit,
+ reset,
+ trigger
+ } = formContext;
+
+ useEffect(() => {
+ reset(new AccessRole());
+ setBackendError(undefined);
+ setReset(true);
+ }, [reset]);
+
+ useEffect(() => {
+ isReset && trigger();
+ }, [trigger, isReset]);
+
+ function closeModal() {
+ setShowRoleModal(false)
+ }
+
+ const onSubmit: SubmitHandler<AccessRole> = (data) => {
+ AccessApi.postRole(data).then(value => after(value))
+ }
+
+ function after(result: [boolean, AxiosResponse | any]) {
+ const res = result[1];
+ if (result[0]) {
+ onSuccess(res.data);
+ } else {
+ const data = res?.response?.data;
+ const error = data && data !== '' ? data : res?.message;
+ setBackendError(error);
+ }
+ }
+
+ function onSuccess(role: AccessRole) {
+ const message = `Role ${role.name} successfully created`;
+ EventBus.sendAlert("Success", message, "success");
+ closeModal();
+ refreshAccess();
+ }
+
+ function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>): void {
+ if (event.key === 'Enter') {
+ handleSubmit(onSubmit)()
+ }
+ }
+
+ function isValidName(input: string): boolean {
+ return !roles.map(r => r.name).includes(input);
+ }
+
+ function canNotSubmit() {
+ return Object.getOwnPropertyNames(errors).length > 0;
+ }
+
+ return (
+ <Modal
+ variant={ModalVariant.small}
+ isOpen={showRoleModal}
+ onClose={closeModal}
+ onKeyDown={onKeyDown}
+ >
+ <ModalHeader>
+ <Content component='h2'>Add role</Content>
+ </ModalHeader>
+ <ModalBody>
+ <Form isHorizontal={true} autoComplete="off">
+ {getTextField('name', 'Name', {
+ length: v => v.length > 0 || 'Name should not be empty',
+ name: v => isValidName(v) || "Role already exists!s",
+ })}
+ {getTextField('description', 'Description', {
+ length: v => v.length > 0 || 'Description should not be empty',
+ })}
+ {backendError &&
+ <FormAlert>
+ <Alert variant="danger" title={backendError} aria-live="polite" isInline/>
+ </FormAlert>
+ }
+ </Form>
+ </ModalBody>
+ <ModalFooter>
+ <Button key="confirm" variant="primary"
+ onClick={handleSubmit(onSubmit)}
+ isDisabled={canNotSubmit()}
+ >
+ Save
+ </Button>
+ <Button key="cancel" variant="secondary" onClick={closeModal}>Cancel</Button>
+ </ModalFooter>
+ </Modal>
+ )
+}
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/page-access/roles/RolesTable.tsx b/karavan-app/src/main/webui/src/ui/page-access/roles/RolesTable.tsx
new file mode 100644
index 00000000..10a00645
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/page-access/roles/RolesTable.tsx
@@ -0,0 +1,60 @@
+import React, {useState} from 'react';
+import {Bullseye, EmptyState, EmptyStateVariant, Spinner} from '@patternfly/react-core';
+import '../AccessPage.css';
+import {InnerScrollContainer, OuterScrollContainer, Table, TableVariant, Tbody, Td, Th, Thead, Tr} from '@patternfly/react-table';
+import {SearchIcon} from "@patternfly/react-icons";
+import {shallow} from "zustand/shallow";
+import {useAccessStore} from "@stores/AccessStore";
+import {AccessRole} from "@models/AccessModels";
+import {RolesTableRow} from "./RolesTableRow";
+
+export function RolesTable() {
+
+ const [roles, filter] = useAccessStore((s) => [s.roles, s.filter], shallow);
+ const [loading] = useState<boolean>(true);
+
+ function getEmptyState() {
+ return (
+ <Tbody>
+ <Tr>
+ <Td colSpan={8}>
+ <Bullseye>
+ {loading && <Spinner className="progress-stepper" diameter="80px" aria-label="Loading..."/>}
+ {!loading &&
+ <EmptyState variant={EmptyStateVariant.sm} titleText="No results found" icon={SearchIcon} headingLevel="h2"/>
+ }
+ </Bullseye>
+ </Td>
+ </Tr>
+ </Tbody>
+ )
+ }
+
+ const conts = roles.filter(role =>
+ role.name?.toLowerCase().includes(filter)
+ || role.name?.toLowerCase().includes(filter)
+ || role.name?.toLowerCase().includes(filter)
+ ).sort((a, b) => a.name.localeCompare(b.name));
+ return (
+ <OuterScrollContainer>
+ <InnerScrollContainer>
+ <Table aria-label="Projects" variant={TableVariant.compact} isStickyHeader>
+ <Thead>
+ <Tr>
+ <Th key='type' modifier={'fitContent'}>Type</Th>
+ <Th key='name'>Name</Th>
+ <Th key='decription'>Description</Th>
+ <Th key='users'>Users</Th>
+ <Th key='action' screenReaderText='pass'></Th>
+ </Tr>
+ </Thead>
+ {conts?.map((role: AccessRole, index: number) => (
+ <RolesTableRow key={role.name} index={index} role={role}/>
+ ))}
+ {conts?.length === 0 && getEmptyState()}
+ </Table>
+ </InnerScrollContainer>
+ </OuterScrollContainer>
+ )
+
+}
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/page-access/roles/RolesTableRow.tsx b/karavan-app/src/main/webui/src/ui/page-access/roles/RolesTableRow.tsx
new file mode 100644
index 00000000..bd19f9ea
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/page-access/roles/RolesTableRow.tsx
@@ -0,0 +1,90 @@
+import React, {useState} from 'react';
+import {Button, capitalize, Content, Label} from '@patternfly/react-core';
+import {Tbody, Td, Tr} from "@patternfly/react-table";
+import {AccessRole, PLATFORM_ADMIN, PLATFORM_DEVELOPER, PLATFORM_USER} from "@models/AccessModels";
+import {useAccessStore} from "@stores/AccessStore";
+import {ShieldAltIcon, TimesIcon, UsersIcon} from "@patternfly/react-icons";
+import {ModalConfirmation} from "@shared/ui/ModalConfirmation";
+import {AccessApi} from "@api/AccessApi";
+
+interface Props {
+ index: number
+ role: AccessRole
+}
+
+export function RolesTableRow(props: Props) {
+
+ const {users, refreshAccess} = useAccessStore();
+ const [showConfirmation, setShowConfirmation] = useState<boolean>(false);
+ const [command, setCommand] = useState<'create' | 'delete' >();
+
+ const {role} = props;
+
+ function executeAction() {
+ if (command === 'delete') {
+ AccessApi.deleteRole(role.name).then(_ => refreshAccess());
+ }
+ setShowConfirmation(false);
+ }
+
+ function getConfirmationText() {
+ if (command === 'delete') {
+ return (
+ <div style={{ display: 'flex', flexDirection: 'row', gap: '4px', alignItems: 'center' }}>
+ <Label color='red'>{capitalize('' + command)}</Label>
+ {" role "}
+ {<Label color='blue'>{role.name}</Label>}
+ {" ?"}
+ </div>
+ )
+ }
+ }
+
+ const usersWithRole = users.filter(user => user.roles?.includes(role.name))
+ const isBuildInRole = [PLATFORM_DEVELOPER, PLATFORM_USER, PLATFORM_ADMIN].includes(role?.name);
+ const canBeDeleted = !isBuildInRole && usersWithRole.length === 0;
+ return (
+ <Tbody>
+ <Tr key={role.name} style={{verticalAlign: 'middle'}}>
+ <Td>{isBuildInRole ? <ShieldAltIcon/> : <UsersIcon/>}</Td>
+ <Td>{role.name}</Td>
+ <Td>{role.description}</Td>
+ <Td modifier='fitContent'>
+ <div style={{display: 'flex', flexDirection: 'column', gap: '4px'}}>
+ {usersWithRole.map(user => {
+ return (
+ <Content key={user.username}>{user.username}</Content>
+ )
+ })}
+ </div>
+ </Td>
+
+ <Td isActionCell>
+ <Button className="dev-action-button"
+ isDisabled={!canBeDeleted}
+ variant={"plain"}
+ icon={<TimesIcon/>}
+ style={{padding: '6px', marginLeft: '6px'}}
+ onClick={() => {
+ setCommand('delete')
+ setShowConfirmation(true);
+ }}/>
+ </Td>
+ </Tr>
+ {showConfirmation &&
+ <ModalConfirmation
+ isOpen={showConfirmation}
+ message={getConfirmationText()}
+ btnConfirm='Confirm'
+ btnConfirmVariant='danger'
+ onConfirm={() => {
+ setCommand(undefined);
+ setShowConfirmation(false);
+ executeAction();
+ }}
+ onCancel={() => setShowConfirmation(false)}
+ />
+ }
+ </Tbody>
+ )
+}
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/page-access/sessions/SessionTable.tsx b/karavan-app/src/main/webui/src/ui/page-access/sessions/SessionTable.tsx
new file mode 100644
index 00000000..2ac439d0
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/page-access/sessions/SessionTable.tsx
@@ -0,0 +1,56 @@
+import React, {useState} from 'react';
+import {Bullseye, EmptyState, EmptyStateVariant, Spinner} from '@patternfly/react-core';
+import '../AccessPage.css';
+import {InnerScrollContainer, OuterScrollContainer, Table, TableVariant, Tbody, Td, Th, Thead, Tr} from '@patternfly/react-table';
+import {SearchIcon} from "@patternfly/react-icons";
+import {shallow} from "zustand/shallow";
+import {useAccessStore} from "@stores/AccessStore";
+import {SessionInfo} from "@models/AccessModels";
+import {SessionTableRow} from "./SessionTableRow";
+
+export function SessionTable() {
+
+ const [sessions, filter] = useAccessStore((s) => [s.sessions, s.filter], shallow);
+ const [loading] = useState<boolean>(true);
+
+ function getEmptyState() {
+ return (
+ <Tbody>
+ <Tr>
+ <Td colSpan={8}>
+ <Bullseye>
+ {loading && <Spinner className="progress-stepper" diameter="80px" aria-label="Loading..."/>}
+ {!loading &&
+ <EmptyState variant={EmptyStateVariant.sm} titleText="No results found" icon={SearchIcon} headingLevel="h2"/>
+ }
+ </Bullseye>
+ </Td>
+ </Tr>
+ </Tbody>
+ )
+ }
+
+ const conts = sessions.filter(session => session.username?.toLowerCase().includes(filter)
+ ).sort((a, b) => a.username.localeCompare(b.username));
+ return (
+ <OuterScrollContainer>
+ <InnerScrollContainer>
+ <Table aria-label="Projects" variant={TableVariant.compact} isStickyHeader>
+ <Thead>
+ <Tr>
+ <Th key='name'>Name</Th>
+ <Th key='created'>Created At</Th>
+ <Th key='expired'>Expired At</Th>
+ <Th key='action' screenReaderText='pass'></Th>
+ </Tr>
+ </Thead>
+ {conts?.map((session: SessionInfo, index: number) => (
+ <SessionTableRow key={session.username + "-" + index} index={index} session={session}/>
+ ))}
+ {conts?.length === 0 && getEmptyState()}
+ </Table>
+ </InnerScrollContainer>
+ </OuterScrollContainer>
+ )
+
+}
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/page-access/sessions/SessionTableRow.tsx b/karavan-app/src/main/webui/src/ui/page-access/sessions/SessionTableRow.tsx
new file mode 100644
index 00000000..b3a53eb8
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/page-access/sessions/SessionTableRow.tsx
@@ -0,0 +1,88 @@
+import React, {useState} from 'react';
+import {Button, capitalize, Label} from '@patternfly/react-core';
+import {Tbody, Td, Tr} from "@patternfly/react-table";
+import {SessionInfo} from "@models/AccessModels";
+import {TimesIcon} from "@patternfly/react-icons";
+import {ModalConfirmation} from "@shared/ui/ModalConfirmation";
+import {AccessApi} from "@api/AccessApi";
+import {useAccessStore} from "@stores/AccessStore";
+import timeAgo from "@shared/timeAgo";
+
+interface Props {
+ index: number
+ session: SessionInfo
+}
+
+export function SessionTableRow(props: Props) {
+
+ const {refreshAccess} = useAccessStore();
+ const [showConfirmation, setShowConfirmation] = useState<boolean>(false);
+ const [command, setCommand] = useState<'create' | 'delete' >();
+ const {session, index} = props;
+ const isExpired = session.expiredAt < new Date().getTime();
+
+
+ function executeAction() {
+ if (command === 'delete') {
+ AccessApi.deleteSession(session.username).then(_ => refreshAccess());
+ }
+ setShowConfirmation(false);
+ }
+
+ function getConfirmationText() {
+ if (command === 'delete') {
+ return (
+ <div style={{ display: 'flex', flexDirection: 'row', gap: '4px', alignItems: 'center' }}>
+ <Label color='red'>{capitalize('' + command)}</Label>
+ {" session for "}
+ {<Label color='blue'>{session.username}</Label>}
+ {" ?"}
+ </div>
+ )
+ }
+ }
+
+ return (
+ <Tbody>
+ <Tr key={index} style={{verticalAlign: 'middle'}}>
+ <Td>{session.username}</Td>
+ <Td>
+ <div style={{display: 'flex', flexDirection: 'row', gap: 32, alignItems: 'center'}}>
+ {session.createdAt && new Date(session.createdAt).toISOString()}
+ <Label color={isExpired ? 'red' : 'blue'}>{timeAgo.format(new Date(session.createdAt))}</Label>
+ </div>
+ </Td>
+ <Td>
+ <div style={{display: 'flex', flexDirection: 'row', gap: 32, alignItems: 'center'}}>
+ {session.expiredAt && new Date(session.expiredAt).toISOString()}
+ <Label color={isExpired ? 'red' : 'blue'}>{timeAgo.format(new Date(session.expiredAt))}</Label>
+ </div>
+ </Td>
+ <Td isActionCell>
+ <Button className="dev-action-button"
+ variant={"plain"}
+ icon={<TimesIcon/>}
+ style={{padding: '6px', marginLeft: '6px'}}
+ onClick={() => {
+ setCommand('delete')
+ setShowConfirmation(true);
+ }}/>
+ </Td>
+ </Tr>
+ {showConfirmation &&
+ <ModalConfirmation
+ isOpen={showConfirmation}
+ message={getConfirmationText()}
+ btnConfirm='Confirm'
+ btnConfirmVariant='danger'
+ onConfirm={() => {
+ setCommand(undefined);
+ setShowConfirmation(false);
+ executeAction();
+ }}
+ onCancel={() => setShowConfirmation(false)}
+ />
+ }
+ </Tbody>
+ )
+}
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/page-access/users/UsersTable.tsx b/karavan-app/src/main/webui/src/ui/page-access/users/UsersTable.tsx
new file mode 100644
index 00000000..852770ed
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/page-access/users/UsersTable.tsx
@@ -0,0 +1,62 @@
+import React, {useState} from 'react';
+import {Bullseye, EmptyState, EmptyStateVariant, Spinner} from '@patternfly/react-core';
+import '../AccessPage.css';
+import {InnerScrollContainer, OuterScrollContainer, Table, TableVariant, Tbody, Td, Th, Thead, Tr} from '@patternfly/react-table';
+import {SearchIcon} from "@patternfly/react-icons";
+import {shallow} from "zustand/shallow";
+import {UsersTableRow} from "./UsersTableRow";
+import {useAccessStore} from "@stores/AccessStore";
+import {AccessUser} from "@models/AccessModels";
+
+export function UsersTable() {
+
+ const [users, filter] = useAccessStore((s) => [s.users, s.filter], shallow);
+ const [loading] = useState<boolean>(true);
+
+ function getEmptyState() {
+ return (
+ <Tbody>
+ <Tr>
+ <Td colSpan={8}>
+ <Bullseye>
+ {loading && <Spinner className="progress-stepper" diameter="80px" aria-label="Loading..."/>}
+ {!loading &&
+ <EmptyState variant={EmptyStateVariant.sm} titleText="No results found" icon={SearchIcon} headingLevel="h2"/>
+ }
+ </Bullseye>
+ </Td>
+ </Tr>
+ </Tbody>
+ )
+ }
+
+ const conts = users.filter(user =>
+ user.username?.toLowerCase().includes(filter)
+ || user.firstName?.toLowerCase().includes(filter)
+ || user.lastName?.toLowerCase().includes(filter)
+ ).sort((a, b) => a.username.localeCompare(b.username));
+ return (
+ <OuterScrollContainer>
+ <InnerScrollContainer>
+ <Table aria-label="Projects" variant={TableVariant.compact} isStickyHeader>
+ <Thead>
+ <Tr>
+ <Th key='username'>Username</Th>
+ <Th key='firstName'>First Name</Th>
+ <Th key='lastName'>Last Name</Th>
+ <Th key='email'>Email</Th>
+ <Th key='roles'>Roles</Th>
+ <Th key='status'>Status</Th>
+ <Th key='action' screenReaderText='pass'></Th>
+ </Tr>
+ </Thead>
+ {conts?.map((user: AccessUser, index: number) => (
+ <UsersTableRow key={user.username} index={index} user={user}/>
+ ))}
+ {conts?.length === 0 && getEmptyState()}
+ </Table>
+ </InnerScrollContainer>
+ </OuterScrollContainer>
+ )
+
+}
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/ui/page-access/users/UsersTableRow.tsx b/karavan-app/src/main/webui/src/ui/page-access/users/UsersTableRow.tsx
new file mode 100644
index 00000000..349c8c21
--- /dev/null
+++ b/karavan-app/src/main/webui/src/ui/page-access/users/UsersTableRow.tsx
@@ -0,0 +1,180 @@
+import React, {useState} from 'react';
+import {Button, capitalize, Label, Switch} from '@patternfly/react-core';
+import {Tbody, Td, Tr} from "@patternfly/react-table";
+import {AccessUser} from "@models/AccessModels";
+import {useAccessStore} from "@stores/AccessStore";
+import {AccessApi} from "@api/AccessApi";
+import {PauseIcon, PlayIcon, TimesIcon, UserSecretIcon} from "@patternfly/react-icons";
+import {ModalConfirmation} from "@shared/ui/ModalConfirmation";
+
+interface Props {
+ index: number
+ user: AccessUser
+}
+
+export function UsersTableRow(props: Props) {
+
+ const {setShowUserModal, setCurrentUser, roles, setShowPasswordModal, refreshAccess} = useAccessStore();
+ const [showConfirmation, setShowConfirmation] = useState<boolean>(false);
+ const [command, setCommand] = useState<'activate' | 'inactivate' | 'delete' | 'add' | 'remove'>();
+ const [role, setRole] = useState<string>();
+
+ const user = props.user;
+
+ function executeAction() {
+ if (command === 'delete') {
+ AccessApi.deleteUser(user.username).then(value => refreshAccess());
+ } else if (command && ['activate', 'inactivate'].includes(command)) {
+ const status = command === 'inactivate' ? 'INACTIVE' : 'ACTIVE';
+ AccessApi.setUserStatus(user, status).then(value => refreshAccess());
+ } else if (command && ['add', 'remove'].includes(command)) {
+ AccessApi.setUserRole(user, role, command).then(value => refreshAccess());
+ }
+ setShowConfirmation(false);
+ }
+
+ function getConfirmationText() {
+ if (command === 'delete') {
+ return (
+ <div style={{ display: 'flex', flexDirection: 'row', gap: '4px', alignItems: 'center' }}>
+ <Label color='red'>{capitalize('' + command)}</Label>
+ {" user "}
+ {<Label color='blue'>{user.username}</Label>}
+ {" ?"}
+ </div>
+ )
+ } else if (command === 'inactivate') {
+ return (
+ <div style={{ display: 'flex', flexDirection: 'row', gap: '4px', alignItems: 'center' }}>
+ <Label color='red'>{capitalize('' + command)}</Label>
+ {" user "}
+ {<Label color='blue'>{user.username}</Label>}
+ {" ?"}
+ </div>
+ )
+ } else if (command === 'activate') {
+ return (
+ <div style={{ display: 'flex', flexDirection: 'row', gap: '4px', alignItems: 'center' }}>
+ <Label color='green'>{capitalize('' + command)}</Label>
+ {" user "}
+ {<Label color='blue'>{user.username}</Label>}
+ {" ?"}
+ </div>
+ )
+ } else if (command === 'remove') {
+ return (
+ <div style={{ display: 'flex', flexDirection: 'row', gap: '4px', alignItems: 'center' }}>
+ <Label color='red'>{capitalize('' + command)}</Label>
+ {" user "}
+ {<Label color='blue'>{user.username}</Label>}
+ {" from role "}
+ {<Label color='blue'>{role}</Label>}
+ {" ?"}
+ </div>
+ )
+ } else if (command === 'add') {
+ return (
+ <div style={{ display: 'flex', flexDirection: 'row', gap: '4px', alignItems: 'center' }}>
+ <Label color='green'>{capitalize('' + command)}</Label>
+ {" user "}
+ {<Label color='blue'>{user.username}</Label>}
+ {" to role "}
+ {<Label color='blue'>{role}</Label>}
+ {" ?"}
+ </div>
+ )
+ }
+ }
+
+ const deactivatable = !['admin', 'platform'].includes(user?.username) && user.status !== 'DELETED';
+ const notDeletable = ['admin', 'platform'].includes(user?.username) || user.status === 'DELETED';
+ const notEditable = ['admin', 'platform'].includes(user?.username) || user.status !== 'ACTIVE'
+ return (
+ <Tbody>
+ <Tr key={user.username} style={{verticalAlign: 'middle'}}>
+ <Td>
+ <Button variant='link' style={{padding: '6px'}} onClick={() => {
+ setCurrentUser(user)
+ setShowUserModal(true)
+ }}>
+ {user.username}
+ </Button>
+ </Td>
+ <Td>{user.firstName}</Td>
+ <Td>{user.lastName}</Td>
+ <Td>{user.email}</Td>
+ <Td modifier='fitContent'>
+ <div style={{display: 'flex', flexDirection: 'column', gap: '4px'}}>
+ {roles.map(role => {
+ const isChecked = user.roles.includes(role.name)
+ return (
+ <Switch
+ key={`${user.username}-${role.name}`}
+ id={`${user.username}-${role.name}`}
+ label={role.name}
+ className='switch-role'
+ isChecked={isChecked}
+ ouiaId={'admin'}
+ isDisabled={notEditable}
+ onClick={(_) => {
+ setRole(role.name);
+ setCommand(isChecked ? 'remove' : 'add')
+ setShowConfirmation(true);
+ }}
+ />
+ )
+ })}
+ </div>
+ </Td>
+ <Td modifier='fitContent'>
+ <Label color={user.status === 'ACTIVE' ? 'green' : (user.status === 'DELETED' ? "red" : 'grey')}>{user.status}</Label>
+ </Td>
+ <Td isActionCell>
+ <div style={{display: 'flex', flexDirection: 'row', gap: '4px', justifyContent: 'flex-end'}}>
+ {deactivatable && <Button className="dev-action-button"
+ variant={"link"}
+ icon={user.status === 'INACTIVE' ? <PlayIcon/> : <PauseIcon/>}
+ style={{padding: '6px', marginLeft: '6px'}}
+ onClick={() => {
+ setCommand(user.status === 'INACTIVE' ? 'activate' : 'inactivate')
+ setShowConfirmation(true);
+ }}/>}
+ <Button className="dev-action-button"
+ isDisabled={notDeletable}
+ variant={"plain"}
+ icon={<TimesIcon/>}
+ style={{padding: '6px', marginLeft: '6px'}}
+ onClick={() => {
+ setCommand('delete')
+ setShowConfirmation(true);
+ }}/>
+ <Button className="dev-action-button"
+ isDisabled={notDeletable}
+ variant={"plain"}
+ icon={<UserSecretIcon color={notDeletable ? 'var(--pf-t--global--icon--color--disabled)' : 'var(--pf-t--global--icon--color--status--danger--default)'}/>}
+ style={{padding: '6px', marginLeft: '6px'}}
+ onClick={() => {
+ setCurrentUser(user)
+ setShowPasswordModal(true);
+ }}/>
+ </div>
+ </Td>
+ </Tr>
+ {showConfirmation &&
+ <ModalConfirmation
+ isOpen={showConfirmation}
+ message={getConfirmationText()}
+ btnConfirm='Confirm'
+ btnConfirmVariant='danger'
+ onConfirm={() => {
+ setCommand(undefined);
+ setRole(undefined);
+ setShowConfirmation(false);
+ executeAction();
+ }}
+ onCancel={() => setShowConfirmation(false)}
+ />
+ }
+ </Tbody>
+ )
+}
\ No newline at end of file