mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
feat(tenant): hide setup/settings surfaces when HELIX_SETUP_MANAGED
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)
This commit is contained in:
@@ -4,6 +4,7 @@ import { faCircleInfo, faXmark, faArrowRight } from '@fortawesome/pro-duotone-sv
|
|||||||
import { Button } from '@/components/base/buttons/button';
|
import { Button } from '@/components/base/buttons/button';
|
||||||
import { getSetupState, SETUP_STEP_NAMES, type SetupState } from '@/lib/setup-state';
|
import { getSetupState, SETUP_STEP_NAMES, type SetupState } from '@/lib/setup-state';
|
||||||
import { useAuth } from '@/providers/auth-provider';
|
import { useAuth } from '@/providers/auth-provider';
|
||||||
|
import { useUiFlags } from '@/hooks/use-ui-flags';
|
||||||
|
|
||||||
// Dismissible banner shown across the top of authenticated pages when
|
// Dismissible banner shown across the top of authenticated pages when
|
||||||
// the hospital workspace has incomplete setup steps AND the admin has
|
// the hospital workspace has incomplete setup steps AND the admin has
|
||||||
@@ -19,22 +20,23 @@ import { useAuth } from '@/providers/auth-provider';
|
|||||||
// - Not dismissed in the current browser session (resets on reload)
|
// - Not dismissed in the current browser session (resets on reload)
|
||||||
export const ResumeSetupBanner = () => {
|
export const ResumeSetupBanner = () => {
|
||||||
const { isAdmin } = useAuth();
|
const { isAdmin } = useAuth();
|
||||||
|
const { setupManaged } = useUiFlags();
|
||||||
const [state, setState] = useState<SetupState | null>(null);
|
const [state, setState] = useState<SetupState | null>(null);
|
||||||
const [dismissed, setDismissed] = useState(
|
const [dismissed, setDismissed] = useState(
|
||||||
() => sessionStorage.getItem('helix_resume_setup_dismissed') === '1',
|
() => sessionStorage.getItem('helix_resume_setup_dismissed') === '1',
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAdmin || dismissed) return;
|
if (!isAdmin || dismissed || setupManaged) return;
|
||||||
getSetupState()
|
getSetupState()
|
||||||
.then(setState)
|
.then(setState)
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Non-fatal — if setup-state isn't reachable, just
|
// Non-fatal — if setup-state isn't reachable, just
|
||||||
// skip the banner. The wizard still works.
|
// skip the banner. The wizard still works.
|
||||||
});
|
});
|
||||||
}, [isAdmin, dismissed]);
|
}, [isAdmin, dismissed, setupManaged]);
|
||||||
|
|
||||||
if (!isAdmin || !state || dismissed) return null;
|
if (!isAdmin || !state || dismissed || setupManaged) return null;
|
||||||
|
|
||||||
const incompleteCount = SETUP_STEP_NAMES.filter((s) => !state.steps[s].completed).length;
|
const incompleteCount = SETUP_STEP_NAMES.filter((s) => !state.steps[s].completed).length;
|
||||||
if (incompleteCount === 0) return null;
|
if (incompleteCount === 0) return null;
|
||||||
|
|||||||
@@ -10,27 +10,32 @@ type SectionCardProps = {
|
|||||||
description: string;
|
description: string;
|
||||||
icon: any;
|
icon: any;
|
||||||
iconColor?: string;
|
iconColor?: string;
|
||||||
href: string;
|
// Either navigate (href) OR intercept the click (onClick). When onClick
|
||||||
|
// is provided, href is ignored and the card renders as a button. Used
|
||||||
|
// while self-serve setup is disabled — all clicks go through a
|
||||||
|
// "contact product team" modal in settings.tsx.
|
||||||
|
href?: string;
|
||||||
|
onClick?: () => void;
|
||||||
status?: SectionStatus;
|
status?: SectionStatus;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Settings hub card. Each card represents one setup-able section (Branding,
|
// Settings hub card. Each card represents one setup-able section (Branding,
|
||||||
// Clinics, Doctors, Team, Telephony, AI, Widget, Rules) and links to its
|
// Clinics, Doctors, Team, Telephony, AI, Widget, Rules) and either links to
|
||||||
// dedicated page. The status badge mirrors the wizard's setup-state so an
|
// its dedicated page or triggers a parent-owned callback.
|
||||||
// admin can see at a glance which sections still need attention.
|
|
||||||
export const SectionCard = ({
|
export const SectionCard = ({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
icon,
|
icon,
|
||||||
iconColor = 'text-brand-primary',
|
iconColor = 'text-brand-primary',
|
||||||
href,
|
href,
|
||||||
|
onClick,
|
||||||
status = 'unknown',
|
status = 'unknown',
|
||||||
}: SectionCardProps) => {
|
}: SectionCardProps) => {
|
||||||
return (
|
const className = cx(
|
||||||
<Link
|
'group block w-full text-left rounded-xl border border-secondary bg-primary p-5 shadow-xs transition hover:border-brand hover:shadow-md',
|
||||||
to={href}
|
);
|
||||||
className="group block rounded-xl border border-secondary bg-primary p-5 shadow-xs transition hover:border-brand hover:shadow-md"
|
const body = (
|
||||||
>
|
<>
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-secondary">
|
<div className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-secondary">
|
||||||
@@ -62,6 +67,19 @@ export const SectionCard = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (onClick) {
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={onClick} className={className}>
|
||||||
|
{body}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Link to={href ?? '#'} className={className}>
|
||||||
|
{body}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
50
src/hooks/use-ui-flags.ts
Normal file
50
src/hooks/use-ui-flags.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
|
||||||
|
// Per-tenant UI flags the sidecar controls via env vars. Read once at
|
||||||
|
// app mount; cached in module scope so every consumer gets the same
|
||||||
|
// snapshot without re-fetching. Safe defaults when the sidecar doesn't
|
||||||
|
// respond (all flags off) so the UI stays functional.
|
||||||
|
export type UiFlags = {
|
||||||
|
setupManaged: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_FLAGS: UiFlags = {
|
||||||
|
setupManaged: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let cachedFlags: UiFlags | null = null;
|
||||||
|
let inflight: Promise<UiFlags> | null = null;
|
||||||
|
|
||||||
|
export const getUiFlags = (): Promise<UiFlags> => fetchFlags();
|
||||||
|
|
||||||
|
const fetchFlags = (): Promise<UiFlags> => {
|
||||||
|
if (cachedFlags) return Promise.resolve(cachedFlags);
|
||||||
|
if (inflight) return inflight;
|
||||||
|
inflight = apiClient
|
||||||
|
.get<UiFlags>('/api/config/ui-flags', { silent: true })
|
||||||
|
.then((res) => {
|
||||||
|
cachedFlags = { ...DEFAULT_FLAGS, ...res };
|
||||||
|
return cachedFlags;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
cachedFlags = { ...DEFAULT_FLAGS };
|
||||||
|
return cachedFlags;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
inflight = null;
|
||||||
|
});
|
||||||
|
return inflight;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUiFlags = (): UiFlags => {
|
||||||
|
const [flags, setFlags] = useState<UiFlags>(cachedFlags ?? DEFAULT_FLAGS);
|
||||||
|
useEffect(() => {
|
||||||
|
if (cachedFlags) {
|
||||||
|
setFlags(cachedFlags);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetchFlags().then(setFlags);
|
||||||
|
}, []);
|
||||||
|
return flags;
|
||||||
|
};
|
||||||
33
src/main.tsx
33
src/main.tsx
@@ -5,16 +5,31 @@ import { AppShell } from "@/components/layout/app-shell";
|
|||||||
import { AuthGuard } from "@/components/layout/auth-guard";
|
import { AuthGuard } from "@/components/layout/auth-guard";
|
||||||
import { useAuth } from "@/providers/auth-provider";
|
import { useAuth } from "@/providers/auth-provider";
|
||||||
import { SetupWizardPage } from "@/pages/setup-wizard";
|
import { SetupWizardPage } from "@/pages/setup-wizard";
|
||||||
|
import { useUiFlags } from "@/hooks/use-ui-flags";
|
||||||
|
|
||||||
const AdminSetupGuard = () => {
|
const AdminSetupGuard = () => {
|
||||||
const { isAdmin } = useAuth();
|
const { isAdmin } = useAuth();
|
||||||
return isAdmin ? <SetupWizardPage /> : <Navigate to="/" replace />;
|
const { setupManaged } = useUiFlags();
|
||||||
|
if (!isAdmin) return <Navigate to="/" replace />;
|
||||||
|
// When setup is managed by the product team for this tenant, there's
|
||||||
|
// nothing for an admin to do in the wizard — bounce them to the
|
||||||
|
// dashboard instead of rendering a dead-end page.
|
||||||
|
if (setupManaged) return <Navigate to="/" replace />;
|
||||||
|
return <SetupWizardPage />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const RequireAdmin = () => {
|
const RequireAdmin = () => {
|
||||||
const { isAdmin } = useAuth();
|
const { isAdmin } = useAuth();
|
||||||
return isAdmin ? <Outlet /> : <Navigate to="/" replace />;
|
return isAdmin ? <Outlet /> : <Navigate to="/" replace />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const RequireSelfServeSetup = () => {
|
||||||
|
const { setupManaged } = useUiFlags();
|
||||||
|
// Blocks /settings/* when the tenant's setup is product-team managed.
|
||||||
|
// Sidebar already hides the nav entry, but this catches stray bookmarks
|
||||||
|
// and deep links.
|
||||||
|
return setupManaged ? <Navigate to="/" replace /> : <Outlet />;
|
||||||
|
};
|
||||||
import { RoleRouter } from "@/components/layout/role-router";
|
import { RoleRouter } from "@/components/layout/role-router";
|
||||||
import { NotFound } from "@/pages/not-found";
|
import { NotFound } from "@/pages/not-found";
|
||||||
import { AllLeadsPage } from "@/pages/all-leads";
|
import { AllLeadsPage } from "@/pages/all-leads";
|
||||||
@@ -99,13 +114,15 @@ createRoot(document.getElementById("root")!).render(
|
|||||||
<Route path="/team-dashboard" element={<TeamDashboardPage />} />
|
<Route path="/team-dashboard" element={<TeamDashboardPage />} />
|
||||||
<Route path="/reports" element={<ReportsPage />} />
|
<Route path="/reports" element={<ReportsPage />} />
|
||||||
<Route path="/integrations" element={<IntegrationsPage />} />
|
<Route path="/integrations" element={<IntegrationsPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route element={<RequireSelfServeSetup />}>
|
||||||
<Route path="/settings/team" element={<TeamSettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/settings/clinics" element={<ClinicsPage />} />
|
<Route path="/settings/team" element={<TeamSettingsPage />} />
|
||||||
<Route path="/settings/doctors" element={<DoctorsPage />} />
|
<Route path="/settings/clinics" element={<ClinicsPage />} />
|
||||||
<Route path="/settings/telephony" element={<TelephonySettingsPage />} />
|
<Route path="/settings/doctors" element={<DoctorsPage />} />
|
||||||
<Route path="/settings/ai" element={<AiSettingsPage />} />
|
<Route path="/settings/telephony" element={<TelephonySettingsPage />} />
|
||||||
<Route path="/settings/widget" element={<WidgetSettingsPage />} />
|
<Route path="/settings/ai" element={<AiSettingsPage />} />
|
||||||
|
<Route path="/settings/widget" element={<WidgetSettingsPage />} />
|
||||||
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="/agent/:id" element={<AgentDetailPage />} />
|
<Route path="/agent/:id" element={<AgentDetailPage />} />
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { MaintOtpModal } from '@/components/modals/maint-otp-modal';
|
|||||||
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
|
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
|
||||||
import { useThemeTokens } from '@/providers/theme-token-provider';
|
import { useThemeTokens } from '@/providers/theme-token-provider';
|
||||||
import { getSetupState } from '@/lib/setup-state';
|
import { getSetupState } from '@/lib/setup-state';
|
||||||
|
import { getUiFlags } from '@/hooks/use-ui-flags';
|
||||||
|
|
||||||
export const LoginPage = () => {
|
export const LoginPage = () => {
|
||||||
const { loginWithUser } = useAuth();
|
const { loginWithUser } = useAuth();
|
||||||
@@ -118,11 +119,13 @@ export const LoginPage = () => {
|
|||||||
|
|
||||||
// First-run detection: if the workspace's setup is incomplete and
|
// First-run detection: if the workspace's setup is incomplete and
|
||||||
// the wizard hasn't been dismissed, route the admin to /setup so
|
// the wizard hasn't been dismissed, route the admin to /setup so
|
||||||
// they finish onboarding before reaching the dashboard. Failures
|
// they finish onboarding before reaching the dashboard. Skip when
|
||||||
// are non-blocking — we always have a fallback to /.
|
// 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 {
|
try {
|
||||||
const state = await getSetupState();
|
const [state, flags] = await Promise.all([getSetupState(), getUiFlags()]);
|
||||||
if (state.wizardRequired) {
|
if (state.wizardRequired && !flags.setupManaged) {
|
||||||
navigate('/setup');
|
navigate('/setup');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user