Files
helix-engage/src/pages/login.tsx
saridsa2 c1b636cb6d feat(onboarding/phase-2): settings hub, setup wizard shell, first-run redirect
Phase 2 of hospital onboarding & self-service plan
(docs/superpowers/plans/2026-04-06-hospital-onboarding-self-service.md
checked in here for the helix-engage repo).

Frontend foundations for the staff-portal Settings hub and 6-step setup
wizard. Backend was Phase 1 (helix-engage-server commit).

New shared components (src/components/setup/):
- wizard-shell.tsx — fullscreen layout with left step navigator, progress
  bar, and Skip-for-now affordance
- wizard-step.tsx — single-step wrapper with Mark Complete + Prev/Next/
  Finish navigation, completion badge
- section-card.tsx — Settings hub card with title/description/icon, links
  to a section page, optional status badge mirroring setup-state

New pages:
- pages/setup-wizard.tsx — top-level /setup route, fullscreen (no AppShell),
  loads setup-state from sidecar, renders the active step. Each step has a
  placeholder body for now; Phase 5 swaps placeholders for real form
  components from the matching settings pages. Already functional end-to-end:
  Mark Complete writes to PUT /api/config/setup-state/steps/<step>, Skip
  posts to /dismiss, Finish navigates to /.
- pages/team-settings.tsx — moved the existing workspace member listing out
  of the old monolithic settings.tsx into its own /settings/team route. No
  functional change; Phase 3 will add the invite form + role editor here.
- pages/settings-placeholder.tsx — generic "Coming in Phase X" stub used by
  routes for clinics, doctors, telephony, ai, widget until those pages land.

Modified pages:
- pages/settings.tsx — rewritten as the Settings hub (the new /settings
  route). Renders SectionCards in 3 groups (Hospital identity, Care
  delivery, Channels & automation) with completion badges sourced from
  /api/config/setup-state. The hub links to existing pages (/branding,
  /rules) and to placeholder pages for the not-yet-built sections.
- pages/login.tsx — after successful login, calls getSetupState() and
  redirects to /setup if wizardRequired. Failures fall through to / so an
  older sidecar without the setup-state endpoint still works.
- components/layout/sidebar.tsx — collapsed the Configuration group
  (Rules Engine + Branding standalone entries) into the single Settings
  entry that opens the hub. Removes the IconSlidersUp import that's no
  longer used.

New types and helpers (src/lib/setup-state.ts):
- SetupState / SetupStepName / SetupStepStatus types mirroring the sidecar
  shape
- SETUP_STEP_NAMES constant + SETUP_STEP_LABELS map (title + description
  per step) — single source of truth used by the wizard, hub, and any
  future surface that wants to render step metadata
- getSetupState / markSetupStepComplete / markSetupStepIncomplete /
  dismissSetupWizard / resetSetupState helpers wrapping the api-client

Other:
- lib/api-client.ts — added apiClient.put() helper for the setup-state
  step update mutations (PUT was the only verb missing from the existing
  get/post/graphql helpers)
- main.tsx — registered new routes:
    /setup                       (fullscreen, no AppShell)
    /settings                    (the hub, replaces old settings.tsx)
    /settings/team               (moved member listing)
    /settings/clinics            (placeholder, Phase 3)
    /settings/doctors            (placeholder, Phase 3)
    /settings/telephony          (placeholder, Phase 4)
    /settings/ai                 (placeholder, Phase 4)
    /settings/widget             (placeholder, Phase 4)

Tested via npx tsc --noEmit and npm run build (clean, only pre-existing
chunk-size and dynamic-import warnings unrelated to this change).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:13:25 +05:30

246 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';
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. Failures
// are non-blocking — we always have a fallback to /.
try {
const state = await getSetupState();
if (state.wizardRequired) {
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>
);
};