diff --git a/ui/src/auth/CallbackPage.tsx b/ui/src/auth/CallbackPage.tsx
new file mode 100644
index 0000000..77aa7c1
--- /dev/null
+++ b/ui/src/auth/CallbackPage.tsx
@@ -0,0 +1,49 @@
+import { useEffect } from 'react';
+import { useNavigate } from 'react-router';
+import { useAuthStore } from './auth-store';
+import { Spinner } from '@cameleer/design-system';
+
+export function CallbackPage() {
+ const navigate = useNavigate();
+ const login = useAuthStore((s) => s.login);
+
+ useEffect(() => {
+ const params = new URLSearchParams(window.location.search);
+ const code = params.get('code');
+ if (!code) {
+ navigate('/login');
+ return;
+ }
+
+ const logtoEndpoint = import.meta.env.VITE_LOGTO_ENDPOINT || 'http://localhost:3001';
+ const clientId = import.meta.env.VITE_LOGTO_CLIENT_ID || '';
+ const redirectUri = `${window.location.origin}/callback`;
+
+ fetch(`${logtoEndpoint}/oidc/token`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({
+ grant_type: 'authorization_code',
+ code,
+ client_id: clientId,
+ redirect_uri: redirectUri,
+ }),
+ })
+ .then((r) => r.json())
+ .then((data) => {
+ if (data.access_token) {
+ login(data.access_token, data.refresh_token || '');
+ navigate('/');
+ } else {
+ navigate('/login');
+ }
+ })
+ .catch(() => navigate('/login'));
+ }, [login, navigate]);
+
+ return (
+
+
+
+ );
+}
diff --git a/ui/src/auth/LoginPage.tsx b/ui/src/auth/LoginPage.tsx
new file mode 100644
index 0000000..a418a29
--- /dev/null
+++ b/ui/src/auth/LoginPage.tsx
@@ -0,0 +1,29 @@
+import { Button } from '@cameleer/design-system';
+
+export function LoginPage() {
+ const logtoEndpoint = import.meta.env.VITE_LOGTO_ENDPOINT || 'http://localhost:3001';
+ const clientId = import.meta.env.VITE_LOGTO_CLIENT_ID || '';
+ const redirectUri = `${window.location.origin}/callback`;
+
+ const handleLogin = () => {
+ const params = new URLSearchParams({
+ client_id: clientId,
+ redirect_uri: redirectUri,
+ response_type: 'code',
+ scope: 'openid profile email offline_access',
+ });
+ window.location.href = `${logtoEndpoint}/oidc/auth?${params}`;
+ };
+
+ return (
+
+
+
Cameleer SaaS
+
+ Managed Apache Camel Runtime
+
+
+
+
+ );
+}
diff --git a/ui/src/auth/ProtectedRoute.tsx b/ui/src/auth/ProtectedRoute.tsx
new file mode 100644
index 0000000..3752246
--- /dev/null
+++ b/ui/src/auth/ProtectedRoute.tsx
@@ -0,0 +1,8 @@
+import { Navigate } from 'react-router';
+import { useAuthStore } from './auth-store';
+
+export function ProtectedRoute({ children }: { children: React.ReactNode }) {
+ const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
+ if (!isAuthenticated) return ;
+ return <>{children}>;
+}
diff --git a/ui/src/auth/auth-store.ts b/ui/src/auth/auth-store.ts
new file mode 100644
index 0000000..021a080
--- /dev/null
+++ b/ui/src/auth/auth-store.ts
@@ -0,0 +1,61 @@
+import { create } from 'zustand';
+
+interface AuthState {
+ accessToken: string | null;
+ refreshToken: string | null;
+ username: string | null;
+ roles: string[];
+ tenantId: string | null;
+ isAuthenticated: boolean;
+ login: (accessToken: string, refreshToken: string) => void;
+ logout: () => void;
+ loadFromStorage: () => void;
+}
+
+function parseJwt(token: string): Record {
+ try {
+ const base64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
+ return JSON.parse(atob(base64));
+ } catch {
+ return {};
+ }
+}
+
+export const useAuthStore = create((set) => ({
+ accessToken: null,
+ refreshToken: null,
+ username: null,
+ roles: [],
+ tenantId: null,
+ isAuthenticated: false,
+
+ login: (accessToken: string, refreshToken: string) => {
+ localStorage.setItem('cameleer-access-token', accessToken);
+ localStorage.setItem('cameleer-refresh-token', refreshToken);
+ const claims = parseJwt(accessToken);
+ const username = (claims.sub as string) || (claims.email as string) || 'user';
+ const roles = (claims.roles as string[]) || [];
+ const tenantId = (claims.organization_id as string) || null;
+ localStorage.setItem('cameleer-username', username);
+ set({ accessToken, refreshToken, username, roles, tenantId, isAuthenticated: true });
+ },
+
+ logout: () => {
+ localStorage.removeItem('cameleer-access-token');
+ localStorage.removeItem('cameleer-refresh-token');
+ localStorage.removeItem('cameleer-username');
+ set({ accessToken: null, refreshToken: null, username: null, roles: [], tenantId: null, isAuthenticated: false });
+ },
+
+ loadFromStorage: () => {
+ const accessToken = localStorage.getItem('cameleer-access-token');
+ const refreshToken = localStorage.getItem('cameleer-refresh-token');
+ const username = localStorage.getItem('cameleer-username');
+ if (accessToken) {
+ const claims = parseJwt(accessToken);
+ const roles = (claims.roles as string[]) || [];
+ const tenantId = (claims.organization_id as string) || null;
+ set({ accessToken, refreshToken, username, roles, tenantId, isAuthenticated: true });
+ }
+ },
+}));
diff --git a/ui/src/vite-env.d.ts b/ui/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/ui/src/vite-env.d.ts
@@ -0,0 +1 @@
+///