From 00c28e642b4c1d6b6b865748df5e3743ae993174 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Wed, 15 Apr 2026 18:56:19 +0530 Subject: [PATCH] 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) --- src/components/setup/resume-setup-banner.tsx | 8 ++-- src/components/setup/section-card.tsx | 36 ++++++++++---- src/hooks/use-ui-flags.ts | 50 ++++++++++++++++++++ src/main.tsx | 33 +++++++++---- src/pages/login.tsx | 11 +++-- 5 files changed, 114 insertions(+), 24 deletions(-) create mode 100644 src/hooks/use-ui-flags.ts diff --git a/src/components/setup/resume-setup-banner.tsx b/src/components/setup/resume-setup-banner.tsx index 43d2576..04c933b 100644 --- a/src/components/setup/resume-setup-banner.tsx +++ b/src/components/setup/resume-setup-banner.tsx @@ -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(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; diff --git a/src/components/setup/section-card.tsx b/src/components/setup/section-card.tsx index 5bc9346..d8cb672 100644 --- a/src/components/setup/section-card.tsx +++ b/src/components/setup/section-card.tsx @@ -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 ( - + 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 = ( + <>
@@ -62,6 +67,19 @@ export const SectionCard = ({ )}
)} + + ); + + if (onClick) { + return ( + + ); + } + return ( + + {body} ); }; diff --git a/src/hooks/use-ui-flags.ts b/src/hooks/use-ui-flags.ts new file mode 100644 index 0000000..f88d8ba --- /dev/null +++ b/src/hooks/use-ui-flags.ts @@ -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 | null = null; + +export const getUiFlags = (): Promise => fetchFlags(); + +const fetchFlags = (): Promise => { + if (cachedFlags) return Promise.resolve(cachedFlags); + if (inflight) return inflight; + inflight = apiClient + .get('/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(cachedFlags ?? DEFAULT_FLAGS); + useEffect(() => { + if (cachedFlags) { + setFlags(cachedFlags); + return; + } + fetchFlags().then(setFlags); + }, []); + return flags; +}; diff --git a/src/main.tsx b/src/main.tsx index 2e352e6..fb68952 100644 --- a/src/main.tsx +++ b/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 ? : ; + const { setupManaged } = useUiFlags(); + if (!isAdmin) return ; + // 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 ; + return ; }; const RequireAdmin = () => { const { isAdmin } = useAuth(); return isAdmin ? : ; }; + +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 ? : ; +}; import { RoleRouter } from "@/components/layout/role-router"; import { NotFound } from "@/pages/not-found"; import { AllLeadsPage } from "@/pages/all-leads"; @@ -99,13 +114,15 @@ createRoot(document.getElementById("root")!).render( } /> } /> } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> diff --git a/src/pages/login.tsx b/src/pages/login.tsx index 86c966d..1814e31 100644 --- a/src/pages/login.tsx +++ b/src/pages/login.tsx @@ -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; }