mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
feat: add API client, wire login page to sidecar auth with demo mode fallback
This commit is contained in:
83
src/lib/api-client.ts
Normal file
83
src/lib/api-client.ts
Normal 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(),
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user