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 @@ +///