feat: add API client, wire login page to sidecar auth with demo mode fallback

This commit is contained in:
2026-03-18 07:22:19 +05:30
parent aff383cb6d
commit 4193b7545a
2 changed files with 121 additions and 2 deletions

83
src/lib/api-client.ts Normal file
View File

@@ -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<T>(query: string, variables?: Record<string, unknown>): Promise<T> {
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(),
};

View File

@@ -29,14 +29,37 @@ export const LoginPage = () => {
const [activeTab, setActiveTab] = useState<RoleTab>('executive'); const [activeTab, setActiveTab] = useState<RoleTab>('executive');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const handleTabChange = (tab: RoleTab) => { const handleTabChange = (tab: RoleTab) => {
setActiveTab(tab); setActiveTab(tab);
setRole(tab); setRole(tab);
setError(null);
}; };
const handleSubmit = (event: React.FormEvent) => { const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault(); 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(); login();
navigate('/'); navigate('/');
}; };
@@ -190,17 +213,30 @@ export const LoginPage = () => {
/> />
</div> </div>
{/* Error message */}
{error && (
<div className="mt-4 rounded-lg bg-error-primary p-3 text-sm text-error-primary">
{error}
</div>
)}
{/* Sign in button */} {/* Sign in button */}
<div className="mt-6"> <div className="mt-6">
<Button <Button
type="submit" type="submit"
size="lg" size="lg"
color="primary" color="primary"
isLoading={isLoading}
className="w-full rounded-xl py-3 font-semibold active:scale-[0.98] transition duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]" className="w-full rounded-xl py-3 font-semibold active:scale-[0.98] transition duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]"
> >
Sign in {email ? 'Sign in' : 'Demo Mode'}
</Button> </Button>
</div> </div>
{!email && (
<p className="mt-2 text-center text-xs text-quaternary">
Leave email empty for demo mode with mock data
</p>
)}
</form> </form>
</div> </div>
</div> </div>