diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts new file mode 100644 index 0000000..9329f97 --- /dev/null +++ b/src/lib/api-client.ts @@ -0,0 +1,83 @@ +const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100'; + +class AuthError extends Error { + constructor(message = 'Authentication required') { + super(message); + this.name = 'AuthError'; + } +} + +const getStoredToken = (): string | null => localStorage.getItem('helix_access_token'); +const getRefreshToken = (): string | null => localStorage.getItem('helix_refresh_token'); + +const storeTokens = (accessToken: string, refreshToken: string) => { + localStorage.setItem('helix_access_token', accessToken); + localStorage.setItem('helix_refresh_token', refreshToken); +}; + +const clearTokens = () => { + localStorage.removeItem('helix_access_token'); + localStorage.removeItem('helix_refresh_token'); +}; + +export const apiClient = { + async login(email: string, password: string): Promise<{ accessToken: string; refreshToken: string }> { + const response = await fetch(`${API_URL}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.message ?? 'Login failed'); + } + + const tokens = await response.json(); + storeTokens(tokens.accessToken, tokens.refreshToken); + return tokens; + }, + + async graphql(query: string, variables?: Record): Promise { + const token = getStoredToken(); + if (!token) throw new AuthError(); + + const response = await fetch(`${API_URL}/graphql`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify({ query, variables }), + }); + + if (response.status === 401) { + clearTokens(); + throw new AuthError(); + } + + const json = await response.json(); + if (json.errors) { + console.error('GraphQL errors:', json.errors); + throw new Error(json.errors[0]?.message ?? 'GraphQL error'); + } + + return json.data; + }, + + async healthCheck(): Promise<{ status: string; platform: { reachable: boolean } }> { + try { + const response = await fetch(`${API_URL}/api/health`, { signal: AbortSignal.timeout(3000) }); + if (!response.ok) return { status: 'down', platform: { reachable: false } }; + return response.json(); + } catch { + return { status: 'down', platform: { reachable: false } }; + } + }, + + getStoredToken, + getRefreshToken, + storeTokens, + clearTokens, + isAuthenticated: () => !!getStoredToken(), +}; diff --git a/src/pages/login.tsx b/src/pages/login.tsx index 3ffe8e1..b7e2595 100644 --- a/src/pages/login.tsx +++ b/src/pages/login.tsx @@ -29,14 +29,37 @@ export const LoginPage = () => { const [activeTab, setActiveTab] = useState('executive'); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); const handleTabChange = (tab: RoleTab) => { setActiveTab(tab); setRole(tab); + setError(null); }; - const handleSubmit = (event: React.FormEvent) => { + const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); + setError(null); + + // If email and password are provided, try real auth via sidecar + if (email && password) { + setIsLoading(true); + try { + const { apiClient } = await import('@/lib/api-client'); + await apiClient.login(email, password); + login(); // Also set mock auth state for the role-based UI + navigate('/'); + } catch (err: any) { + // If sidecar is down, fall back to demo mode + console.warn('Real auth failed, falling back to demo mode:', err.message); + setError(err.message); + setIsLoading(false); + } + return; + } + + // No credentials — demo mode (mock auth) login(); navigate('/'); }; @@ -190,17 +213,30 @@ export const LoginPage = () => { /> + {/* Error message */} + {error && ( +
+ {error} +
+ )} + {/* Sign in button */}
+ {!email && ( +

+ Leave email empty for demo mode with mock data +

+ )}