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 { getSetupState, SETUP_STEP_NAMES, type SetupState } from '@/lib/setup-state';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import { useUiFlags } from '@/hooks/use-ui-flags';
|
||||
|
||||
// Dismissible banner shown across the top of authenticated pages when
|
||||
// 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)
|
||||
export const ResumeSetupBanner = () => {
|
||||
const { isAdmin } = useAuth();
|
||||
const { setupManaged } = useUiFlags();
|
||||
const [state, setState] = useState<SetupState | null>(null);
|
||||
const [dismissed, setDismissed] = useState(
|
||||
() => sessionStorage.getItem('helix_resume_setup_dismissed') === '1',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin || dismissed) return;
|
||||
if (!isAdmin || dismissed || setupManaged) return;
|
||||
getSetupState()
|
||||
.then(setState)
|
||||
.catch(() => {
|
||||
// Non-fatal — if setup-state isn't reachable, just
|
||||
// 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;
|
||||
if (incompleteCount === 0) return null;
|
||||
|
||||
@@ -10,27 +10,32 @@ type SectionCardProps = {
|
||||
description: string;
|
||||
icon: any;
|
||||
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;
|
||||
};
|
||||
|
||||
// Settings hub card. Each card represents one setup-able section (Branding,
|
||||
// Clinics, Doctors, Team, Telephony, AI, Widget, Rules) and links to its
|
||||
// dedicated page. The status badge mirrors the wizard's setup-state so an
|
||||
// admin can see at a glance which sections still need attention.
|
||||
// Clinics, Doctors, Team, Telephony, AI, Widget, Rules) and either links to
|
||||
// its dedicated page or triggers a parent-owned callback.
|
||||
export const SectionCard = ({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
iconColor = 'text-brand-primary',
|
||||
href,
|
||||
onClick,
|
||||
status = 'unknown',
|
||||
}: SectionCardProps) => {
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
className="group block rounded-xl border border-secondary bg-primary p-5 shadow-xs transition hover:border-brand hover:shadow-md"
|
||||
>
|
||||
const className = cx(
|
||||
'group block w-full text-left 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 gap-4">
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-secondary">
|
||||
@@ -62,6 +67,19 @@ export const SectionCard = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} className={className}>
|
||||
{body}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link to={href ?? '#'} className={className}>
|
||||
{body}
|
||||
</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;
|
||||
};
|
||||
19
src/main.tsx
19
src/main.tsx
@@ -5,16 +5,31 @@ import { AppShell } from "@/components/layout/app-shell";
|
||||
import { AuthGuard } from "@/components/layout/auth-guard";
|
||||
import { useAuth } from "@/providers/auth-provider";
|
||||
import { SetupWizardPage } from "@/pages/setup-wizard";
|
||||
import { useUiFlags } from "@/hooks/use-ui-flags";
|
||||
|
||||
const AdminSetupGuard = () => {
|
||||
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 { isAdmin } = useAuth();
|
||||
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 { NotFound } from "@/pages/not-found";
|
||||
import { AllLeadsPage } from "@/pages/all-leads";
|
||||
@@ -99,6 +114,7 @@ createRoot(document.getElementById("root")!).render(
|
||||
<Route path="/team-dashboard" element={<TeamDashboardPage />} />
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
<Route path="/integrations" element={<IntegrationsPage />} />
|
||||
<Route element={<RequireSelfServeSetup />}>
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/settings/team" element={<TeamSettingsPage />} />
|
||||
<Route path="/settings/clinics" element={<ClinicsPage />} />
|
||||
@@ -107,6 +123,7 @@ createRoot(document.getElementById("root")!).render(
|
||||
<Route path="/settings/ai" element={<AiSettingsPage />} />
|
||||
<Route path="/settings/widget" element={<WidgetSettingsPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
<Route path="/agent/:id" element={<AgentDetailPage />} />
|
||||
<Route path="/patient/:id" element={<Patient360Page />} />
|
||||
|
||||
@@ -12,6 +12,7 @@ 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();
|
||||
@@ -118,11 +119,13 @@ export const LoginPage = () => {
|
||||
|
||||
// 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 /.
|
||||
// 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 = await getSetupState();
|
||||
if (state.wizardRequired) {
|
||||
const [state, flags] = await Promise.all([getSetupState(), getUiFlags()]);
|
||||
if (state.wizardRequired && !flags.setupManaged) {
|
||||
navigate('/setup');
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user