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:
49
ui/src/auth/CallbackPage.tsx
Normal file
49
ui/src/auth/CallbackPage.tsx
Normal 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
29
ui/src/auth/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
ui/src/auth/ProtectedRoute.tsx
Normal file
8
ui/src/auth/ProtectedRoute.tsx
Normal 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
61
ui/src/auth/auth-store.ts
Normal 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
1
ui/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user