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:
2026-04-15 18:56:19 +05:30
parent 196a18fe1a
commit 00c28e642b
5 changed files with 114 additions and 24 deletions

View File

@@ -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;

View File

@@ -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
View 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;
};

View File

@@ -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 />} />

View File

@@ -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;
}