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 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-04 21:48:56 +02:00
parent 146dbccc6e
commit e33818cc74
5 changed files with 148 additions and 0 deletions

View File

@@ -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 (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
<Spinner />
</div>
);
}

29
ui/src/auth/LoginPage.tsx Normal file
View File

@@ -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 (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
<div style={{ textAlign: 'center' }}>
<h1>Cameleer SaaS</h1>
<p style={{ marginBottom: '2rem', color: 'var(--color-text-secondary)' }}>
Managed Apache Camel Runtime
</p>
<Button onClick={handleLogin}>Sign in with Logto</Button>
</div>
</div>
);
}

View File

@@ -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 <Navigate to="/login" replace />;
return <>{children}</>;
}

61
ui/src/auth/auth-store.ts Normal file
View File

@@ -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<string, unknown> {
try {
const base64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
return JSON.parse(atob(base64));
} catch {
return {};
}
}
export const useAuthStore = create<AuthState>((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 });
}
},
}));

1
ui/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />