mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
Ramaiah's product team owns their setup; end-user admins shouldn't see a dead-end Settings nav + Resume Setup banner. Flag is read from /api/config/ui-flags at app boot. - use-ui-flags: module-scoped cache + useUiFlags hook + getUiFlags helper for non-component callers - main.tsx: /setup redirects when managed; RequireSelfServeSetup guard blocks /settings/* - resume-setup-banner: suppressed when managed - login.tsx: skip first-run /setup redirect when managed - settings.tsx: remove orphan popup-modal scaffolding left over from an earlier 'contact product team' approach - section-card: support onClick-or-href (kept for future use)
249 lines
11 KiB
TypeScript
249 lines
11 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { useNavigate } from 'react-router';
|
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
import { faEye, faEyeSlash } from '@fortawesome/pro-duotone-svg-icons';
|
|
import { useAuth } from '@/providers/auth-provider';
|
|
import { useData } from '@/providers/data-provider';
|
|
import { Button } from '@/components/base/buttons/button';
|
|
import { SocialButton } from '@/components/base/buttons/social-button';
|
|
import { Checkbox } from '@/components/base/checkbox/checkbox';
|
|
import { Input } from '@/components/base/input/input';
|
|
import { MaintOtpModal } from '@/components/modals/maint-otp-modal';
|
|
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
|
|
import { useThemeTokens } from '@/providers/theme-token-provider';
|
|
import { getSetupState } from '@/lib/setup-state';
|
|
import { getUiFlags } from '@/hooks/use-ui-flags';
|
|
|
|
export const LoginPage = () => {
|
|
const { loginWithUser } = useAuth();
|
|
const { refresh } = useData();
|
|
const navigate = useNavigate();
|
|
const { isOpen, activeAction, close } = useMaintShortcuts();
|
|
const { tokens } = useThemeTokens();
|
|
|
|
// Load website widget on login page.
|
|
//
|
|
// Config comes from the admin-editable sidecar endpoint (not env vars)
|
|
// so it can be toggled / rotated / moved at runtime. The widget renders
|
|
// only when all of enabled + embed.loginPage + key are set.
|
|
//
|
|
// `url` may be empty (default for fresh configs) — in that case we fall
|
|
// back to the same origin the app is talking to for its API, since in
|
|
// practice the sidecar serves /widget.js alongside /api/*.
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
const apiUrl = import.meta.env.VITE_API_URL ?? '';
|
|
if (!apiUrl) return;
|
|
|
|
fetch(`${apiUrl}/api/config/widget`)
|
|
.then(r => (r.ok ? r.json() : null))
|
|
.then((cfg: { enabled?: boolean; key?: string; url?: string; embed?: { loginPage?: boolean } } | null) => {
|
|
if (cancelled || !cfg) return;
|
|
if (!cfg.enabled || !cfg.embed?.loginPage || !cfg.key) return;
|
|
if (document.getElementById('helix-widget-script')) return;
|
|
|
|
const host = cfg.url && cfg.url.length > 0 ? cfg.url : apiUrl;
|
|
const script = document.createElement('script');
|
|
script.id = 'helix-widget-script';
|
|
script.src = `${host}/widget.js`;
|
|
script.setAttribute('data-key', cfg.key);
|
|
document.body.appendChild(script);
|
|
})
|
|
.catch(err => {
|
|
// Never block login on a widget config failure.
|
|
console.warn('[widget] config fetch failed', err);
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
document.getElementById('helix-widget-script')?.remove();
|
|
document.getElementById('helix-widget-host')?.remove();
|
|
};
|
|
}, []);
|
|
|
|
const saved = localStorage.getItem('helix_remember');
|
|
const savedCreds = saved ? JSON.parse(saved) : null;
|
|
|
|
const [email, setEmail] = useState(savedCreds?.email ?? '');
|
|
const [password, setPassword] = useState(savedCreds?.password ?? '');
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
const [rememberMe, setRememberMe] = useState(!!savedCreds);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
const handleSubmit = async (event: React.FormEvent) => {
|
|
event.preventDefault();
|
|
setError(null);
|
|
|
|
if (!email || !password) {
|
|
setError('Email and password are required');
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
try {
|
|
const { apiClient } = await import('@/lib/api-client');
|
|
const response = await apiClient.login(email, password);
|
|
|
|
// Build user from sidecar response
|
|
const u = response.user;
|
|
const firstName = u?.firstName ?? '';
|
|
const lastName = u?.lastName ?? '';
|
|
const name = `${firstName} ${lastName}`.trim() || email;
|
|
const initials = `${firstName[0] ?? ''}${lastName[0] ?? ''}`.toUpperCase() || email[0].toUpperCase();
|
|
|
|
if (rememberMe) {
|
|
localStorage.setItem('helix_remember', JSON.stringify({ email, password }));
|
|
} else {
|
|
localStorage.removeItem('helix_remember');
|
|
}
|
|
|
|
// Store agent config for SIP provider (CC agents only)
|
|
if ((response as any).agentConfig) {
|
|
localStorage.setItem('helix_agent_config', JSON.stringify((response as any).agentConfig));
|
|
} else {
|
|
localStorage.removeItem('helix_agent_config');
|
|
}
|
|
|
|
loginWithUser({
|
|
id: u?.id,
|
|
name,
|
|
initials,
|
|
role: (u?.role ?? 'executive') as 'executive' | 'admin' | 'cc-agent',
|
|
email: u?.email ?? email,
|
|
avatarUrl: u?.avatarUrl,
|
|
platformRoles: u?.platformRoles,
|
|
});
|
|
|
|
refresh();
|
|
|
|
// First-run detection: if the workspace's setup is incomplete and
|
|
// the wizard hasn't been dismissed, route the admin to /setup so
|
|
// they finish onboarding before reaching the dashboard. Skip when
|
|
// the tenant's setup is product-team managed — there's nothing
|
|
// for the admin to do in the wizard. Failures are non-blocking —
|
|
// we always have a fallback to /.
|
|
try {
|
|
const [state, flags] = await Promise.all([getSetupState(), getUiFlags()]);
|
|
if (state.wizardRequired && !flags.setupManaged) {
|
|
navigate('/setup');
|
|
return;
|
|
}
|
|
} catch {
|
|
// Setup state endpoint may be unreachable on older sidecars —
|
|
// proceed to the normal landing page.
|
|
}
|
|
|
|
navigate('/');
|
|
} catch (err: any) {
|
|
setError(err.message);
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleGoogleSignIn = () => {
|
|
setError('Google sign-in not yet configured');
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen bg-brand-section flex flex-col items-center justify-center p-4">
|
|
{/* Login Card */}
|
|
<div className="w-full max-w-[420px] bg-primary rounded-xl shadow-xl p-8">
|
|
{/* Logo */}
|
|
<div className="flex flex-col items-center mb-8">
|
|
<img src={tokens.brand.logo} alt={tokens.brand.name} className="size-12 rounded-xl mb-3" />
|
|
<h1 className="text-display-xs font-bold text-primary font-display">{tokens.login.title}</h1>
|
|
<p className="text-sm text-tertiary mt-1">{tokens.login.subtitle}</p>
|
|
</div>
|
|
|
|
{/* Google sign-in */}
|
|
{tokens.login.showGoogleSignIn && <SocialButton
|
|
social="google"
|
|
size="lg"
|
|
theme="gray"
|
|
type="button"
|
|
onClick={handleGoogleSignIn}
|
|
className="w-full rounded-xl py-3 border-2 border-secondary font-semibold hover:bg-secondary transition duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]"
|
|
>
|
|
Sign in with Google
|
|
</SocialButton>}
|
|
|
|
{/* Divider */}
|
|
{tokens.login.showGoogleSignIn && <div className="mt-5 mb-5 flex items-center gap-3">
|
|
<div className="flex-1 h-px bg-secondary" />
|
|
<span className="text-xs font-semibold text-quaternary tracking-wider uppercase">or continue with</span>
|
|
<div className="flex-1 h-px bg-secondary" />
|
|
</div>}
|
|
|
|
{/* Form */}
|
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4" noValidate>
|
|
{error && (
|
|
<div className="rounded-lg bg-error-secondary p-3 text-sm text-error-primary">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<Input
|
|
label="Email"
|
|
type="email"
|
|
placeholder="you@globalhospital.com"
|
|
value={email}
|
|
onChange={(value) => setEmail(value)}
|
|
size="md"
|
|
/>
|
|
|
|
<div className="relative">
|
|
<Input
|
|
label="Password"
|
|
type={showPassword ? 'text' : 'password'}
|
|
placeholder="Enter your password"
|
|
value={password}
|
|
onChange={(value) => setPassword(value)}
|
|
size="md"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowPassword(!showPassword)}
|
|
className="absolute right-3 top-[38px] text-fg-quaternary hover:text-fg-secondary transition duration-100 ease-linear"
|
|
tabIndex={-1}
|
|
>
|
|
<FontAwesomeIcon icon={showPassword ? faEyeSlash : faEye} className="size-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<Checkbox
|
|
label="Remember me"
|
|
size="sm"
|
|
isSelected={rememberMe}
|
|
onChange={setRememberMe}
|
|
/>
|
|
{tokens.login.showForgotPassword && <button
|
|
type="button"
|
|
className="text-sm font-semibold text-brand-secondary hover:text-brand-secondary_hover transition duration-100 ease-linear"
|
|
onClick={() => setError('Password reset is not yet configured. Contact your administrator.')}
|
|
>
|
|
Forgot password?
|
|
</button>}
|
|
</div>
|
|
|
|
<Button
|
|
type="submit"
|
|
size="lg"
|
|
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)]"
|
|
>
|
|
Sign in
|
|
</Button>
|
|
</form>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<a href={tokens.login.poweredBy.url} target="_blank" rel="noopener noreferrer" className="mt-6 text-xs text-primary_on-brand opacity-60 hover:opacity-90 transition duration-100 ease-linear">{tokens.login.poweredBy.label}</a>
|
|
|
|
<MaintOtpModal isOpen={isOpen} onOpenChange={(open) => !open && close()} action={activeAction} />
|
|
</div>
|
|
);
|
|
};
|