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 <[email protected]> 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
