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

Reply via email to