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 59d56a12dba5a4d4b4e6d5a5a89c545bdac015a8 Author: Marat Gubaidullin <[email protected]> AuthorDate: Mon Aug 24 17:59:27 2026 -0400 Karavan-app UI Login --- .../src/main/webui/src/ui/login/LoginPage.css | 355 +++++++++++++++++++++ .../src/main/webui/src/ui/login/LoginPage.tsx | 242 ++++++++++++++ .../src/main/webui/src/ui/login/OrbitLines.tsx | 51 +++ .../src/main/webui/src/ui/login/UserPopupOidc.tsx | 50 +++ 4 files changed, 698 insertions(+) diff --git a/karavan-app/src/main/webui/src/ui/login/LoginPage.css b/karavan-app/src/main/webui/src/ui/login/LoginPage.css new file mode 100644 index 00000000..9fe9b474 --- /dev/null +++ b/karavan-app/src/main/webui/src/ui/login/LoginPage.css @@ -0,0 +1,355 @@ +.login-page-container { + display: flex; + flex-direction: row; + justify-content: space-evenly; + align-items: center; + height: 100vh; + width: 100vw; + overflow: hidden; +} + +/* --- LEFT PANEL: Branding --- */ +.brand-panel { + width: 35%; + height: 100vh; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background: var(--pf-t--color--gray--95); + gap: 48px; +} + +.brand-content { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + width: 100%; + + .brand-name { + display: flex; + flex-direction: column; + justify-content: center; + gap: 20px; + + .brand-logo-container { + display: flex; + flex-direction: row; + align-items: center; + gap: 12px; + + .logo { + height: 40px; + width: 40px; + } + .platform-name-text { + font-size: 24px; + font-weight: 700; + letter-spacing: 1px; + } + } + .gradient-text-blue { + background: linear-gradient(to right, #4394e5, #92c5f9); + + -webkit-background-clip: text; /* Safari / Chrome */ + background-clip: text; + color: transparent; + } + .gradient-text-blue-gold { + background: linear-gradient(to right, #fda422, #ec7a08); + + -webkit-background-clip: text; /* Safari / Chrome */ + background-clip: text; + color: transparent; + } + .gradient-text-gold { + background: linear-gradient(to right, #ec7a08, #db5b04); + + -webkit-background-clip: text; /* Safari / Chrome */ + background-clip: text; + color: transparent; + } + + .tagline1 { + font-weight: 800; + font-size: 56px; + line-height: 1.1; + text-align: center; + } + + .tagline2 { + font-weight: 400; + line-height: 1.5; + color: #b9dafc; /* Tblue-200 for subtitle */ + font-size: 20px; + letter-spacing: 0.5px; + margin-top: 16px; + text-align: center; + } + } +} + +.solar-content { + display: flex; + flex-direction: column; + justify-content: center; + gap: 8px; + /* Container that holds both the center logo and the ring */ + .solar-system { + position: relative; + width: 350px; /* Width of the whole system */ + height: 350px; + margin: 0 auto; + display: flex; + flex-direction: column; + justify-content: flex-end; + } +} + +.orbit-lines-svg { + position: absolute; + top: 0; + left: 0; + transform: translate(-50%, -50%); + width: 350px; /* Must match SVG viewBox width */ + height: 350px; /* Must match SVG viewBox height */ + z-index: 1; /* Lowest level, behind icons */ + pointer-events: none; /* Let clicks pass through to the logo */ + + /* OPTIONAL: Rotate the gradient slowly for extra "flash" */ + animation: slowSpin 20s linear infinite; +} + +/* Ensure your icons are on top */ +.orbit-ring { + z-index: 5; +} + +/* 1. THE STATIC CENTER LOGO */ +.static-sun { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); /* Perfectly centered */ + width: 100px; + height: 100px; + z-index: 10; /* Ensures it sits ON TOP of the ring lines */ + + /* Optional: A nice glow behind the main logo */ + background: radial-gradient( + circle, + rgba(236, 122, 8, 0.4) 0%, + rgba(236, 122, 8, 0) 60% + ); + border-radius: 50%; + display: flex; + justify-content: center; + align-items: center; +} + +.static-sun a { + pointer-events: auto; + width: 48px; /* Your actual logo size */ + height: 48px; + display: flex; + justify-content: center; + align-items: center; + transition: transform 0.3s ease, filter 0.3s ease; + &:hover { + transform: scale(1.1); + } +} + +.static-sun .logo { + width: 100%; /* Adjust to fit inside the glow */ + height: auto; + filter: none; +} + +/* 1. The Ring spins clockwise */ +.orbit-ring { + width: 100%; + height: 100%; + position: absolute; + animation: slowSpin 60s linear infinite; +} + +/* 2. The Item is placed on the ring (Static Position) */ +.orbit-item { + position: absolute; + top: 50%; + left: 50%; + width: 32px; /* Adjusted size */ + height: 32px; + margin-top: -16px; + margin-left: -16px; + + /* Logic: Rotate to angle -> Move out -> Rotate back to upright */ + transform: + rotate(var(--angle)) + translate(var(--radius)) + rotate(calc(var(--angle) * -1)); +} + +/* 3. The Counter-Rotator spins Counter-Clockwise + This cancels out the ring's rotation so the icon always faces North */ +.counter-rotator { + width: 100%; + height: 100%; + animation: slowSpinReverse 60s linear infinite; +} + +/* 4. The Image handles ONLY the look and hover scale */ +.counter-rotator img, +.counter-rotator svg { + display: block; + width: 100%; + height: 100%; + object-fit: contain; + + /* Look */ + /*filter: grayscale(100%);*/ + transition: transform 0.3s ease, filter 0.3s ease; +} + +/* Hover Effect: Scale the image, NOT the rotating wrapper */ +.counter-rotator:hover img, +.counter-rotator:hover svg { + filter: none; + transform: scale(1.2); +} + +/* --- Keyframes --- */ +@keyframes slowSpin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +@keyframes slowSpinReverse { + /* Must match the ring's speed but in REVERSE */ + from { transform: rotate(360deg); } + to { transform: rotate(0deg); } +} + +.anchor-line { + width: 1px; + height: 50px; /* Length of line */ + background: linear-gradient(to bottom, rgba(255, 255, 255, 0.1) 10%, rgba(236, 122, 8, 1) 50%, rgba(255, 255, 255, 0.1) 100%); + margin: 0 auto 8px auto; /* Center it and add space below */ +} + +.brand-footer { + opacity: 0.6; + font-size: 0.85rem; + letter-spacing: 1px; + text-transform: uppercase; + text-align: center; + color: var(--pf-t--color--blue--10); + padding-bottom: 60px; +} + +/* --- RIGHT PANEL: Minimal Form --- */ +.login-form-panel { + width: 65%; + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; +} + +.form-wrapper { + width: 100%; + max-width: 400px; + padding: 20px; + + .login { + &::before { + position: absolute; + inset: 0; + pointer-events: none; + content: ""; + border-color: var(--pf-t--color--blue--60); + border-style: var(--pf-v6-c-card--BorderStyle); + border-width: var(--pf-v6-c-card--BorderWidth); + border-radius: inherit; + } + + .login-header { + color: var(--pf-t--global--text--color--subtle); + } + + .environment-dev { + background-color: var(--pf-t--global--text--color--brand--default); + } + .environment-default { + background-color: var(--pf-t--global--icon--color--status--warning--default); + } + .environment-prod { + background-color: var(--pf-t--global--icon--color--status--danger--default); + } + } + + .pf-v6-c-form-control, .pf-v6-c-text-input-group { + /*background-color: transparent;*/ + } + + .pf-v6-c-card__header, .pf-v6-c-card__title, .pf-v6-c-card__body { + padding-block-end: var(--pf-t--global--spacer--xl); + } + + .pf-v6-c-card__footer { + padding-block-end: var(--pf-t--global--spacer--md); + } + + .platform-version { + color: var(--pf-t--global--text--color--subtle); + font-size: var(--pf-t--global--font--size--xs); + } + + .button { + width: 100%; + } + + .button-disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.powered-by-logo { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + + + .logo, .icon{ + width: 32px; + height: 32px; + } + .openapi-logo { + height: 36px; + } + .asyncapi-logo { + height: 32px; + } + .json-schema-logo { + height: 28px; + } + .groovy-logo { + height: 32px; + } + .jib-logo { + height: 32px; + } + .infinispan-logo { + height: 30px; + } + .jkube-logo { + height: 32px; + } + .patternfly-logo { + height: 28px; + } +} diff --git a/karavan-app/src/main/webui/src/ui/login/LoginPage.tsx b/karavan-app/src/main/webui/src/ui/login/LoginPage.tsx new file mode 100644 index 00000000..764a8d90 --- /dev/null +++ b/karavan-app/src/main/webui/src/ui/login/LoginPage.tsx @@ -0,0 +1,242 @@ +import React, {useContext} from 'react'; +import { + Alert, + Button, + Card, + CardBody, + CardFooter, + CardHeader, + Content, + Form, + FormAlert, + FormGroup, + TextInput, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities +} from '@patternfly/react-core'; +import './LoginPage.css' +import {EyeIcon, EyeSlashIcon} from '@patternfly/react-icons'; +import {AuthContext} from "@api/auth/AuthProvider"; +import {AuthApi} from "@api/auth/AuthApi"; +import {CamelIcon, KaravanIcon, OpenApiIcon} from "@designer/icons/KaravanIcons"; +import {SvgIcon} from "@shared/icons/SvgIcon"; +import jibLogo from '@shared/icons/jib.png'; +import {PlatformNameForLogin, PlatformVersion} from "@shared/ui/PlatformLogos"; +import PlatformLogo from "@compass/navigation/PlatformLogo"; +import OrbitLines from "./OrbitLines"; +import {useReadinessStore} from "@stores/ReadinessStore"; + +export const LoginPage: React.FunctionComponent = () => { + + const [username, setUsername] = React.useState(''); + const [password, setPassword] = React.useState(''); + const [passwordHidden, setPasswordHidden] = React.useState(true); + const [showError, setShowError] = React.useState(false); + const [error, setError] = React.useState(''); + const {reload} = useContext(AuthContext); + const { readiness } = useReadinessStore(); + + function onLoginButtonClick(event: any) { + if (!getButtonDisabled()) { + event.preventDefault(); + AuthApi.login(username, password, (ok, res) => { + if (!ok) { + setError(res?.response?.data); + setShowError(true); + } else { + setError(''); + setShowError(false); + reload(); + } + }) + } + } + + function getButtonDisabled(): boolean { + return (username?.length < 3 || password?.length < 3); + } + + function onKeyDown(event: React.KeyboardEvent<HTMLFormElement>): void { + event.stopPropagation(); + if (event.key === 'Enter') { + event.preventDefault(); + onLoginButtonClick(event); + } + } + + + function getLogos() { + return [ + <div className="powered-by-logo counter-rotator"> + <a href="https://github.com/apache/camel-karavan" target="_blank">{KaravanIcon("logo")}</a> + </div>, + <div className="powered-by-logo counter-rotator"> + <a href="https://www.openapis.org/" target="_blank"><OpenApiIcon className={"logo"}/></a> + </div>, + <div className="powered-by-logo counter-rotator"> + <a href="https://www.asyncapi.com/" target="_blank">{SvgIcon({icon: 'asyncapi', className: 'asyncapi-logo'})}</a> + </div>, + <div className="powered-by-logo counter-rotator"> + <a href="https://json-schema.org/" target="_blank"> + {SvgIcon({icon: 'json-schema-dark', className: 'json-schema-logo'})} + </a> + </div>, + // <div className="powered-by-logo counter-rotator"> + // <a href="https://groovy-lang.org/" target="_blank">{SvgIcon({icon: 'groovy', className: 'groovy-logo'})}</a> + // </div>, + <div className="powered-by-logo counter-rotator"> + <a href="https://github.com/GoogleContainerTools/jib" target="_blank"> + <img src={jibLogo} alt="Logo" className="jib-logo"/> + </a> + </div>, + <div className="powered-by-logo counter-rotator"> + <a href="https://camel.apache.org/" target="_blank">{CamelIcon()}</a> + </div>, + <div className="powered-by-logo counter-rotator"> + <a href="https://www.patternfly.org/" target={'_blank'}> + {SvgIcon({icon: 'patternfly', className: 'patternfly-logo'})} + </a> + </div>, + <div className="powered-by-logo counter-rotator"> + <a href="https://eclipse.dev/jkube/" target="_blank"> + {SvgIcon({icon: 'jkube', className: 'jkube-logo'})} + </a> + </div>, + <div className="powered-by-logo counter-rotator"> + <a href="https://www.keycloak.org/" target="_blank"> + {SvgIcon({icon: 'keycloak', className: 'patternfly-logo'})} + </a> + </div> + ]; + } + + const LOGOS = getLogos(); + + function getLoginForm() { + return ( + <Form onKeyDown={onKeyDown}> + <FormGroup fieldId="username"> + <TextInput className="text-field" + type="text" + id="username" + name="username" + value={username} + placeholder={"Username"} + onChange={(_, value) => setUsername(value)}/> + </FormGroup> + <FormGroup fieldId="password"> + <TextInputGroup> + <TextInputGroupMain className="text-field" + type={passwordHidden ? "password" : 'text'} + id="password" + name="password" + value={password} + placeholder={"Password"} + onChange={(_, value) => setPassword(value)} + /> + <TextInputGroupUtilities> + <Button + variant="plain" + onClick={() => setPasswordHidden(!passwordHidden)} + aria-label={passwordHidden ? 'Show password' : 'Hide password'} + > + {passwordHidden ? <EyeIcon/> : <EyeSlashIcon/>} + </Button> + </TextInputGroupUtilities> + </TextInputGroup> + </FormGroup> + {showError && ( + <FormAlert> + <Alert variant="danger" title={<div>{error?.toString()}</div>} aria-live="polite" isInline/> + </FormAlert> + )} + </Form> + ) + } + + + function getRightSide() { + const buttonClassName = getButtonDisabled() ? "button button-disabled" : "button"; + return ( + <div className="login-form-panel dark-form"> + <div className="form-wrapper"> + <Card className="login" isLarge> + <CardHeader> + <div style={{display: "flex", flexDirection: 'row', justifyContent: 'space-between', alignItems: "center"}}> + <Content component='h3' className="login-header">Login</Content> + <PlatformVersion environment={readiness?.environment}/> + </div> + </CardHeader> + <CardBody> + {getLoginForm()} + </CardBody> + <CardFooter style={{ textAlign: "center" }}> + <Button variant="primary" + className={buttonClassName} + onClick={onLoginButtonClick} + > + Access Platform + </Button> + </CardFooter> + </Card> + {/*<DarkModeToggle/>*/} + </div> + </div> + ) + } + + function getLeftSide() { + return ( + <div className="brand-panel"> + <div className="brand-content"> + <div className="brand-name"> + <div className="brand-logo-container"> + {PlatformLogo("logo")} + <span className="platform-name-text">{PlatformNameForLogin()}</span> + </div> + <div> + <div className="tagline1 gradient-text-blue">Accelerate</div> + <div className="tagline1 gradient-text-blue-gold">Integration</div> + <div className="tagline1 gradient-text-gold">Development</div> + </div> + <Content component='p' className="tagline2">Unified Design and Runtime for <br/> APIs • Events • Data Pipelines</Content> + </div> + </div> + <div className="solar-content"> + <div className="solar-system"> + <OrbitLines /> + <div className="static-sun"> + <a href="https://camel.apache.org" target="_blank"> + {PlatformLogo("logo")} + </a> + </div> + <div className="orbit-ring"> + {LOGOS.map((logo, index) => { + const total = LOGOS.length; + const angle = (360 / total) * index; + // Increased radius slightly to make room for the center logo + const radius = 150; + const style = {'--angle': `${angle}deg`, '--radius': `${radius}px`,} as React.CSSProperties; + return ( + <div key={index} className="orbit-item" style={style}> + {logo} + </div> + ); + })} + </div> + <div className="anchor-line"></div> + <p className="brand-footer">Powered by</p> + </div> + </div> + </div> + ) + } + + return ( + <div className="login-page-container"> + {getLeftSide()} + {getRightSide()} + </div> + ) +} \ No newline at end of file diff --git a/karavan-app/src/main/webui/src/ui/login/OrbitLines.tsx b/karavan-app/src/main/webui/src/ui/login/OrbitLines.tsx new file mode 100644 index 00000000..5e3322b9 --- /dev/null +++ b/karavan-app/src/main/webui/src/ui/login/OrbitLines.tsx @@ -0,0 +1,51 @@ +import React from 'react'; + +const OrbitLines = () => { + return ( + <svg + className="orbit-lines-svg" + viewBox="0 0 400 400" + xmlns="http://www.w3.org/2000/svg" + > + <defs> + <linearGradient id="orbit-grad" x1="0%" y1="0%" x2="100%" y2="100%"> + <stop offset="0%" stopColor="#ec7a08" stopOpacity="1" /> + <stop offset="50%" stopColor="#f97316" stopOpacity="0.5" /> + <stop offset="100%" stopColor="#0066cc" stopOpacity="1" /> + </linearGradient> + + {/* The Glow Effect */} + <filter id="glow"> + <feGaussianBlur stdDeviation="2.5" result="coloredBlur" /> + <feMerge> + <feMergeNode in="coloredBlur" /> + <feMergeNode in="SourceGraphic" /> + </feMerge> + </filter> + </defs> + + {/* Outer Ring (Matches your icon radius of 150px) */} + <circle + cx="200" cy="200" r="170" + fill="none" + stroke="url(#orbit-grad)" + strokeWidth="2" + filter="url(#glow)" + opacity="1" + /> + + {/* Inner Decorative Ring (Smaller, thinner) */} + <circle + cx="200" cy="200" r="130" + fill="none" + stroke="url(#orbit-grad)" + strokeWidth="1" + strokeDasharray="10 10" /* Makes it dashed/techy */ + filter="url(#glow)" + opacity="0.5" + /> + </svg> + ); +}; + +export default OrbitLines; \ No newline at end of file diff --git a/karavan-app/src/main/webui/src/ui/login/UserPopupOidc.tsx b/karavan-app/src/main/webui/src/ui/login/UserPopupOidc.tsx new file mode 100644 index 00000000..f540d6e8 --- /dev/null +++ b/karavan-app/src/main/webui/src/ui/login/UserPopupOidc.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import {Badge, Content, ContentVariants, DescriptionList, DescriptionListDescription, DescriptionListGroup, DescriptionListTerm, Flex, Popover,} from '@patternfly/react-core'; +import {UserIcon} from "@patternfly/react-icons"; +import {shallow} from "zustand/shallow"; +import {useAccessStore} from "@stores/AccessStore"; +import {getCurrentUser} from "@api/auth/AuthApi"; + +export function UserPopupOidc() { + + const [setShowUserModal, showUserModal] = useAccessStore((s) => [s.setShowUserModal, s.showUserModal], shallow); + + return ( + <Popover + hasAutoWidth + aria-label="Current user" + position={"right-end"} + hideOnOutsideClick={false} + isVisible={showUserModal} + shouldClose={(_event, tip) => setShowUserModal(false)} + shouldOpen={(_event, tip) => setShowUserModal(true)} + headerContent={ + <Content> + <Content component={ContentVariants.h3}>Profile</Content> + </Content> + } + bodyContent={ + <DescriptionList isHorizontal className='description-align-center'> + <DescriptionListGroup> + <DescriptionListTerm>UserName:</DescriptionListTerm> + <DescriptionListDescription>{getCurrentUser()?.username}</DescriptionListDescription> + </DescriptionListGroup> + <DescriptionListGroup> + <DescriptionListTerm>Roles:</DescriptionListTerm> + <DescriptionListDescription> + <Flex direction={{default: "column"}} gap={{default: "gapXs"}}> + {getCurrentUser()?.roles && Array.isArray(getCurrentUser()?.roles) + && getCurrentUser()?.roles.map((role: string) => <Badge key={role} id={role} + isRead>{role}</Badge>)} + </Flex> + </DescriptionListDescription> + </DescriptionListGroup> + </DescriptionList> + } + > + <div style={{alignSelf: 'center'}}> + <UserIcon className="avatar"/> + </div> + </Popover> +) +} \ No newline at end of file
