From e33818cc7465222d921ea90806a9fba9e9c7521b Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sat, 4 Apr 2026 21:48:56 +0200 Subject: [PATCH] feat: add auth store, login, callback, and protected route Adds Zustand auth store with JWT parsing (sub, roles, organization_id), Logto OIDC login page, authorization code callback handler, and ProtectedRoute guard. Also adds vite-env.d.ts for import.meta.env types. Co-Authored-By: Claude Sonnet 4.6 --- ui/src/auth/CallbackPage.tsx | 49 +++++++++++++++++++++++++++ ui/src/auth/LoginPage.tsx | 29 ++++++++++++++++ ui/src/auth/ProtectedRoute.tsx | 8 +++++ ui/src/auth/auth-store.ts | 61 ++++++++++++++++++++++++++++++++++ ui/src/vite-env.d.ts | 1 + 5 files changed, 148 insertions(+) create mode 100644 ui/src/auth/CallbackPage.tsx create mode 100644 ui/src/auth/LoginPage.tsx create mode 100644 ui/src/auth/ProtectedRoute.tsx create mode 100644 ui/src/auth/auth-store.ts create mode 100644 ui/src/vite-env.d.ts 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 @@ +///