diff --git a/src/app/(dashboard)/flows/[id]/page.tsx b/src/app/(dashboard)/flows/[id]/page.tsx index 3a8de76..fd57c2a 100644 --- a/src/app/(dashboard)/flows/[id]/page.tsx +++ b/src/app/(dashboard)/flows/[id]/page.tsx @@ -5,8 +5,6 @@ import { useParams, useRouter } from "next/navigation"; import { Loader2 } from "lucide-react"; import { toast } from "sonner"; -import { useAuth } from "@/hooks/use-auth"; -import { isFlowsEnabled } from "@/lib/flows/feature-flag"; import { FlowBuilder } from "@/components/flows/flow-builder"; import type { FlowRow, FlowNodeRow } from "@/lib/flows/types"; @@ -17,31 +15,21 @@ import type { FlowRow, FlowNodeRow } from "@/lib/flows/types"; * ``. Owns the loading/error state so the builder can * focus purely on editing. * - * Beta gate: client-side bounce for the snappy redirect, server-side - * 404 on the API as the real security boundary. + * Open to every authenticated user — the beta gate that previously + * 404'd non-beta accounts was removed in PR #134. The API still + * 404s on a flow id the caller doesn't own (RLS), which becomes the + * "Flow not found" state below. */ export default function FlowEditorPage() { const router = useRouter(); const params = useParams<{ id: string }>(); - const { profile, loading: authLoading, profileLoading } = useAuth(); const [flow, setFlow] = useState(null); const [nodes, setNodes] = useState([]); const [loading, setLoading] = useState(true); const [notFound, setNotFound] = useState(false); - const allowed = isFlowsEnabled(profile); - useEffect(() => { - // Wait for BOTH session and profile — see the comment on the same - // gate in /flows/page.tsx. Without `profileLoading`, navigating - // here from the list shows the `{ loading: false, profile: null }` - // window and would bounce a legitimate beta user. - if (authLoading || profileLoading) return; - if (!allowed) { - router.replace("/dashboard"); - return; - } if (!params.id) return; let cancelled = false; (async () => { @@ -72,9 +60,9 @@ export default function FlowEditorPage() { return () => { cancelled = true; }; - }, [allowed, authLoading, profileLoading, params.id, router]); + }, [params.id]); - if (authLoading || profileLoading || (allowed && loading)) { + if (loading) { return (
diff --git a/src/app/(dashboard)/flows/[id]/runs/page.tsx b/src/app/(dashboard)/flows/[id]/runs/page.tsx index 97ec341..572335f 100644 --- a/src/app/(dashboard)/flows/[id]/runs/page.tsx +++ b/src/app/(dashboard)/flows/[id]/runs/page.tsx @@ -17,8 +17,6 @@ import { import { toast } from "sonner"; import { format, formatDistanceToNow } from "date-fns"; -import { useAuth } from "@/hooks/use-auth"; -import { isFlowsEnabled } from "@/lib/flows/feature-flag"; import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; @@ -98,7 +96,6 @@ const STATUS_META: Record< export default function FlowRunsPage() { const router = useRouter(); const params = useParams<{ id: string }>(); - const { profile, loading: authLoading, profileLoading } = useAuth(); const [flow, setFlow] = useState<{ id: string; name: string } | null>(null); const [runs, setRuns] = useState([]); @@ -107,15 +104,7 @@ export default function FlowRunsPage() { const [expanded, setExpanded] = useState>(new Set()); const [notFound, setNotFound] = useState(false); - const allowed = isFlowsEnabled(profile); - useEffect(() => { - // Wait for BOTH session and profile — see /flows/page.tsx. - if (authLoading || profileLoading) return; - if (!allowed) { - router.replace("/dashboard"); - return; - } if (!params.id) return; let cancelled = false; (async () => { @@ -148,7 +137,7 @@ export default function FlowRunsPage() { return () => { cancelled = true; }; - }, [allowed, authLoading, profileLoading, params.id, router]); + }, [params.id]); function toggle(runId: string) { setExpanded((prev) => { @@ -159,7 +148,7 @@ export default function FlowRunsPage() { }); } - if (authLoading || profileLoading || (allowed && loading)) { + if (loading) { return (
diff --git a/src/app/(dashboard)/flows/page.tsx b/src/app/(dashboard)/flows/page.tsx index 0476901..40d891b 100644 --- a/src/app/(dashboard)/flows/page.tsx +++ b/src/app/(dashboard)/flows/page.tsx @@ -18,8 +18,6 @@ import { FileText, } from "lucide-react"; -import { useAuth } from "@/hooks/use-auth"; -import { isFlowsEnabled } from "@/lib/flows/feature-flag"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -36,10 +34,9 @@ import { cn } from "@/lib/utils"; /** * Flows list page. * - * Gated client-side on `isFlowsEnabled(profile)` AND server-side by - * every /api/flows endpoint returning 404 to non-beta accounts. The - * client gate hides the page from non-beta users who land here via - * URL-typing; the server gate is the real security boundary. + * Open to every authenticated user. Flows is in soft-GA — the "Beta" + * chip in the header is the only remaining signal that the surface + * is new. The previous per-account beta gate was removed in PR #134. */ interface FlowRow { @@ -84,7 +81,6 @@ const TEMPLATE_ICONS = { export default function FlowsPage() { const router = useRouter(); - const { profile, loading: authLoading, profileLoading } = useAuth(); const [flows, setFlows] = useState([]); const [loading, setLoading] = useState(true); const [createOpen, setCreateOpen] = useState(false); @@ -92,20 +88,7 @@ export default function FlowsPage() { const [creating, setCreating] = useState(false); const [templates, setTemplates] = useState([]); - const flowsAccessAllowed = isFlowsEnabled(profile); - useEffect(() => { - // Wait for BOTH the session and the profile row. Session loads - // fast (cookie read) but profile crosses the network — during the - // gap `profile` is null and `isFlowsEnabled(null)` is false, which - // would bounce a legitimate beta user back to /dashboard on every - // navigation here. - if (authLoading || profileLoading) return; - if (!flowsAccessAllowed) { - // Bounce non-beta users — the API would 404 every call anyway. - router.replace("/dashboard"); - return; - } let cancelled = false; (async () => { try { @@ -138,7 +121,7 @@ export default function FlowsPage() { return () => { cancelled = true; }; - }, [authLoading, profileLoading, flowsAccessAllowed, router]); + }, []); async function handleCreate() { if (!newName.trim()) return; @@ -205,7 +188,7 @@ export default function FlowsPage() { } } - if (authLoading || profileLoading || (flowsAccessAllowed && loading)) { + if (loading) { return (
@@ -217,7 +200,12 @@ export default function FlowsPage() {
-

Flows

+
+

Flows

+ + Beta + +

Build branching, button-driven WhatsApp conversations. Useful for menus, FAQs, and triage before a human steps in. diff --git a/src/app/api/flows/[id]/activate/route.ts b/src/app/api/flows/[id]/activate/route.ts index 43a7ed0..a2915c1 100644 --- a/src/app/api/flows/[id]/activate/route.ts +++ b/src/app/api/flows/[id]/activate/route.ts @@ -1,7 +1,6 @@ import { NextResponse } from 'next/server' import { createClient } from '@/lib/supabase/server' import { supabaseAdmin } from '@/lib/flows/admin-client' -import { isFlowsEnabled } from '@/lib/flows/feature-flag' import { validateFlowForActivation } from '@/lib/flows/validate' /** @@ -31,14 +30,6 @@ export async function POST( if (!user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { data: profile } = await supabase - .from('profiles') - .select('beta_features') - .eq('user_id', user.id) - .maybeSingle() - if (!isFlowsEnabled(profile as { beta_features?: string[] | null } | null)) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } const body = (await request.json().catch(() => null)) as | { status?: 'draft' | 'active' | 'archived' } diff --git a/src/app/api/flows/[id]/route.ts b/src/app/api/flows/[id]/route.ts index 2b9d05b..5119dca 100644 --- a/src/app/api/flows/[id]/route.ts +++ b/src/app/api/flows/[id]/route.ts @@ -1,7 +1,6 @@ import { NextResponse } from 'next/server' import { createClient } from '@/lib/supabase/server' import { supabaseAdmin } from '@/lib/flows/admin-client' -import { isFlowsEnabled } from '@/lib/flows/feature-flag' /** * GET /api/flows/[id] — fetch one flow with its nodes. @@ -13,10 +12,12 @@ import { isFlowsEnabled } from '@/lib/flows/feature-flag' * DELETE /api/flows/[id] — hard delete (RLS+CASCADE clean up nodes, * runs, events). * - * All three are 404 for non-beta accounts. + * All three require a signed-in caller who owns the flow. Flows is in + * soft-GA — the beta gate that previously 404'd non-beta accounts is + * gone; the "Beta" label in the UI is the only remaining signal. */ -async function requireFlowsBetaAndOwnership( +async function requireOwnership( flowId: string, ): Promise< | { @@ -33,14 +34,6 @@ async function requireFlowsBetaAndOwnership( if (!user) { return { ok: false, status: 401, body: { error: 'Unauthorized' } } } - const { data: profile } = await supabase - .from('profiles') - .select('beta_features') - .eq('user_id', user.id) - .maybeSingle() - if (!isFlowsEnabled(profile as { beta_features?: string[] | null } | null)) { - return { ok: false, status: 404, body: { error: 'Not found' } } - } // RLS scopes this to the caller — a flow owned by another user // returns null (404 below). const { data: flow } = await supabase @@ -59,7 +52,7 @@ export async function GET( context: { params: Promise<{ id: string }> }, ) { const { id } = await context.params - const guard = await requireFlowsBetaAndOwnership(id) + const guard = await requireOwnership(id) if (!guard.ok) return NextResponse.json(guard.body, { status: guard.status }) const { supabase } = guard @@ -98,7 +91,7 @@ export async function PUT( context: { params: Promise<{ id: string }> }, ) { const { id } = await context.params - const guard = await requireFlowsBetaAndOwnership(id) + const guard = await requireOwnership(id) if (!guard.ok) return NextResponse.json(guard.body, { status: guard.status }) const body = (await request.json().catch(() => null)) as PutBody | null @@ -184,7 +177,7 @@ export async function DELETE( context: { params: Promise<{ id: string }> }, ) { const { id } = await context.params - const guard = await requireFlowsBetaAndOwnership(id) + const guard = await requireOwnership(id) if (!guard.ok) return NextResponse.json(guard.body, { status: guard.status }) // CASCADE on flow_nodes / flow_runs / flow_run_events handles the diff --git a/src/app/api/flows/[id]/runs/route.ts b/src/app/api/flows/[id]/runs/route.ts index e8bfb4d..daa3946 100644 --- a/src/app/api/flows/[id]/runs/route.ts +++ b/src/app/api/flows/[id]/runs/route.ts @@ -1,6 +1,5 @@ import { NextResponse } from 'next/server' import { createClient } from '@/lib/supabase/server' -import { isFlowsEnabled } from '@/lib/flows/feature-flag' /** * GET /api/flows/[id]/runs @@ -30,14 +29,6 @@ export async function GET( if (!user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { data: profile } = await supabase - .from('profiles') - .select('beta_features') - .eq('user_id', user.id) - .maybeSingle() - if (!isFlowsEnabled(profile as { beta_features?: string[] | null } | null)) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } // Confirm flow exists + caller owns it (RLS does this) before doing // the run query — gives us a clean 404 instead of empty array. diff --git a/src/app/api/flows/route.ts b/src/app/api/flows/route.ts index 4e05f13..3b77c38 100644 --- a/src/app/api/flows/route.ts +++ b/src/app/api/flows/route.ts @@ -1,19 +1,19 @@ import { NextResponse } from 'next/server' import { createClient } from '@/lib/supabase/server' import { supabaseAdmin } from '@/lib/flows/admin-client' -import { isFlowsEnabled } from '@/lib/flows/feature-flag' import { getFlowTemplate } from '@/lib/flows/templates' /** * GET /api/flows — list the caller's flows. * POST /api/flows — create a new (draft) flow. * - * Both endpoints gate on the per-account Flows beta flag. Non-beta - * accounts get a 404 so the UI / a curious user can't discover the - * surface ahead of GA. + * Available to every authenticated user. The previous per-account + * beta gate was removed when Flows went to soft-GA; the UI still + * shows a "Beta" label so users know the surface is young, but the + * routes themselves are open. */ -async function requireFlowsBeta(): Promise< +async function requireUser(): Promise< | { ok: true; userId: string; supabase: Awaited> } | { ok: false; status: number; body: { error: string } } > { @@ -24,23 +24,11 @@ async function requireFlowsBeta(): Promise< if (!user) { return { ok: false, status: 401, body: { error: 'Unauthorized' } } } - const { data: profile } = await supabase - .from('profiles') - .select('beta_features') - .eq('user_id', user.id) - .maybeSingle() - if ( - !isFlowsEnabled(profile as { beta_features?: string[] | null } | null) - ) { - // 404 (not 403) so the route looks like it doesn't exist to - // non-beta accounts — keeps the UI invisible. - return { ok: false, status: 404, body: { error: 'Not found' } } - } return { ok: true, userId: user.id, supabase } } export async function GET() { - const guard = await requireFlowsBeta() + const guard = await requireUser() if (!guard.ok) { return NextResponse.json(guard.body, { status: guard.status }) } @@ -57,7 +45,7 @@ export async function GET() { } export async function POST(request: Request) { - const guard = await requireFlowsBeta() + const guard = await requireUser() if (!guard.ok) { return NextResponse.json(guard.body, { status: guard.status }) } diff --git a/src/app/api/flows/templates/route.ts b/src/app/api/flows/templates/route.ts index ca1c1d8..a5dec8f 100644 --- a/src/app/api/flows/templates/route.ts +++ b/src/app/api/flows/templates/route.ts @@ -1,6 +1,5 @@ import { NextResponse } from 'next/server' import { createClient } from '@/lib/supabase/server' -import { isFlowsEnabled } from '@/lib/flows/feature-flag' import { listFlowTemplates } from '@/lib/flows/templates' /** @@ -11,8 +10,7 @@ import { listFlowTemplates } from '@/lib/flows/templates' * without bundling the full template payloads client-side. Bodies * are fetched only on actual clone via POST /api/flows. * - * 404 to non-beta accounts (matches the rest of the /api/flows - * surface). + * Available to any signed-in user. Flows is in soft-GA. */ export async function GET() { const supabase = await createClient() @@ -22,14 +20,6 @@ export async function GET() { if (!user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { data: profile } = await supabase - .from('profiles') - .select('beta_features') - .eq('user_id', user.id) - .maybeSingle() - if (!isFlowsEnabled(profile as { beta_features?: string[] | null } | null)) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } // Shallow shape so the client gallery doesn't have to know about // the full node tree. const templates = listFlowTemplates().map((t) => ({ diff --git a/src/app/api/whatsapp/send/route.ts b/src/app/api/whatsapp/send/route.ts index adfaecd..45fb03b 100644 --- a/src/app/api/whatsapp/send/route.ts +++ b/src/app/api/whatsapp/send/route.ts @@ -3,7 +3,6 @@ import { createClient } from '@/lib/supabase/server' import { sendTextMessage, sendTemplateMessage } from '@/lib/whatsapp/meta-api' import { decrypt, encrypt, isLegacyFormat } from '@/lib/whatsapp/encryption' import { supabaseAdmin } from '@/lib/flows/admin-client' -import { isFlowsEnabled } from '@/lib/flows/feature-flag' import { sanitizePhoneForMeta, isValidE164, @@ -285,35 +284,28 @@ export async function POST(request: Request) { .eq('id', conversation_id) // Pause any active Flow run for this contact — the agent stepping - // in is the strongest "yield, human is here" signal. Gated on the - // beta flag to avoid an extra DB write for non-beta accounts; for - // those, no run can exist anyway. See PR #2 plan for why we pause - // (not end): preserves diagnostic state + lets the agent or the - // 24h timeout sweep cleanly resolve the run later. + // in is the strongest "yield, human is here" signal. See PR #2 + // plan for why we pause (not end): preserves diagnostic state + + // lets the agent or the 24h timeout sweep cleanly resolve the + // run later. For accounts with no active runs the UPDATE matches + // zero rows — cheap and harmless. try { - const { data: profile } = await supabase - .from('profiles') - .select('beta_features') + const { error: pauseErr } = await supabaseAdmin() + .from('flow_runs') + .update({ + status: 'paused_by_agent', + ended_at: new Date().toISOString(), + end_reason: 'agent_replied', + }) .eq('user_id', user.id) - .maybeSingle() - if (isFlowsEnabled(profile as { beta_features?: string[] | null } | null)) { - const { error: pauseErr } = await supabaseAdmin() - .from('flow_runs') - .update({ - status: 'paused_by_agent', - ended_at: new Date().toISOString(), - end_reason: 'agent_replied', - }) - .eq('user_id', user.id) - .eq('contact_id', contact.id) - .eq('status', 'active') - if (pauseErr) { - // Best-effort — log + continue. The agent's message already - // landed at Meta; don't fail the response over a bookkeeping - // miss. Worst case: a stale active run gets caught by the - // stale-run cron sweep within 24h. - console.error('[flows] pause-on-agent-send failed:', pauseErr.message) - } + .eq('contact_id', contact.id) + .eq('status', 'active') + if (pauseErr) { + // Best-effort — log + continue. The agent's message already + // landed at Meta; don't fail the response over a bookkeeping + // miss. Worst case: a stale active run gets caught by the + // stale-run cron sweep within 24h. + console.error('[flows] pause-on-agent-send failed:', pauseErr.message) } } catch (err) { console.error( diff --git a/src/app/api/whatsapp/webhook/route.ts b/src/app/api/whatsapp/webhook/route.ts index 2b6b3dc..b4cdcd8 100644 --- a/src/app/api/whatsapp/webhook/route.ts +++ b/src/app/api/whatsapp/webhook/route.ts @@ -6,7 +6,6 @@ import { normalizePhone, phonesMatch } from '@/lib/whatsapp/phone-utils' import { verifyMetaWebhookSignature } from '@/lib/whatsapp/webhook-signature' import { runAutomationsForTrigger } from '@/lib/automations/engine' import { dispatchInboundToFlows } from '@/lib/flows/engine' -import { isFlowsEnabled } from '@/lib/flows/feature-flag' // Lazy-initialized to avoid build-time crash when env vars are missing // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -579,15 +578,11 @@ async function processMessage( // ============================================================ // Flow runner dispatch. // - // Gated on the per-account beta flag (see PR #113). For accounts - // that haven't opted in, the runner never executes and the webhook - // behaves identically to before — automations fire as today. - // - // For opted-in accounts: if the runner consumes the message (it - // either advanced an active run or started a new one), we suppress - // the `new_message_received` + `keyword_match` automation triggers - // for this inbound. Customer is navigating the bot menu, not - // sending a fresh trigger word that should fork into automations. + // If the runner consumes the message (it either advanced an active + // run or started a new one), we suppress the `new_message_received` + // + `keyword_match` automation triggers for this inbound. Customer + // is navigating the bot menu, not sending a fresh trigger word + // that should fork into automations. // // The relationship-level triggers (`new_contact_created`, // `first_inbound_message`) still fire even when consumed — those @@ -595,31 +590,30 @@ async function processMessage( // // Awaited (not fire-and-forget) because we need the `consumed` // result before deciding whether to dispatch automations. The - // runner has its own try/catch and never throws. + // runner has its own try/catch and never throws. Accounts with + // no active flows take the runner's early-exit "no_match" path + // basically for free (one indexed SELECT for the active run). // ============================================================ - let flowConsumed = false - if (await isProfileFlowsEnabled(userId)) { - const result = await dispatchInboundToFlows({ - userId, - contactId: contactRecord.id, - conversationId: conversation.id, - message: - interactiveReplyId - ? { - kind: 'interactive_reply', - reply_id: interactiveReplyId, - reply_title: contentText ?? '', - meta_message_id: message.id, - } - : { - kind: 'text', - text: contentText ?? message.text?.body ?? '', - meta_message_id: message.id, - }, - isFirstInboundMessage, - }) - flowConsumed = result.consumed - } + const flowResult = await dispatchInboundToFlows({ + userId, + contactId: contactRecord.id, + conversationId: conversation.id, + message: + interactiveReplyId + ? { + kind: 'interactive_reply', + reply_id: interactiveReplyId, + reply_title: contentText ?? '', + meta_message_id: message.id, + } + : { + kind: 'text', + text: contentText ?? message.text?.body ?? '', + meta_message_id: message.id, + }, + isFirstInboundMessage, + }) + const flowConsumed = flowResult.consumed // Fire any automations that react to this webhook event. All dispatches // run here (not earlier) so the contact, conversation, and inbound @@ -659,36 +653,6 @@ async function processMessage( } } -/** - * Reads the calling user's `profiles.beta_features` and asks whether - * the Flows beta is enabled for them. Best-effort: a DB failure reads - * as "not enabled" so a transient outage can't accidentally turn the - * feature on for a non-beta account. - * - * Result not cached across webhooks — Meta webhooks are infrequent - * (≪ 1 RPS for a single tenant) and the query is a single indexed - * row lookup. Caching introduces staleness when the owner toggles - * their opt-in via SQL. - */ -async function isProfileFlowsEnabled(userId: string): Promise { - try { - const { data } = await supabaseAdmin() - .from('profiles') - .select('beta_features') - .eq('user_id', userId) - .maybeSingle() - return isFlowsEnabled( - data as { beta_features?: string[] | null } | null, - ) - } catch (err) { - console.error( - '[flows] isProfileFlowsEnabled lookup failed:', - err instanceof Error ? err.message : err, - ) - return false - } -} - async function parseMessageContent( message: WhatsAppMessage, accessToken: string diff --git a/src/components/inbox/message-bubble.tsx b/src/components/inbox/message-bubble.tsx index f6237a1..0dc6df1 100644 --- a/src/components/inbox/message-bubble.tsx +++ b/src/components/inbox/message-bubble.tsx @@ -17,8 +17,6 @@ import { import { format } from "date-fns"; import { ReplyQuote } from "./reply-quote"; import { MessageReactions } from "./message-reactions"; -import { useAuth } from "@/hooks/use-auth"; -import { isFlowsEnabled } from "@/lib/flows/feature-flag"; interface MessageBubbleProps { message: Message; @@ -119,14 +117,6 @@ function MediaImage({ url, alt }: { url: string; alt: string }) { } function MessageContent({ message }: { message: Message }) { - // Used to gate the interactive-reply bubble style below. The webhook - // only ever inserts `content_type='interactive'` rows when our runner - // sent an interactive message first — and the runner itself is behind - // the same beta gate (forthcoming PRs) — so this gate is belt-and- - // suspenders against any stray row that slipped in via manual SQL, - // a misconfigured import, or a future bug. Non-beta accounts fall - // back to plain text. - const { profile } = useAuth(); switch (message.content_type) { case "text": return ( @@ -224,18 +214,6 @@ function MessageContent({ message }: { message: Message }) { ); case "interactive": { - // Defensive gate — non-beta accounts render the tapped option as - // plain text. See the comment at the top of MessageContent for why - // this can't be reached in normal operation; this branch exists so - // that if a stray interactive row ever appears for a non-beta - // account, they don't see UI for a feature they haven't opted into. - if (!isFlowsEnabled(profile)) { - return ( -

- {message.content_text || "[Interactive reply]"} -

- ); - } // Customer tapped a reply button or list row on a message the bot // sent. We show the tapped option's title (already in content_text, // set by parseMessageContent in the webhook) with a small affordance diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 7f49fcd..d550745 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -6,7 +6,6 @@ import { useEffect } from "react"; import { cn } from "@/lib/utils"; import { useAuth } from "@/hooks/use-auth"; import { useTotalUnread } from "@/hooks/use-total-unread"; -import { isFlowsEnabled } from "@/lib/flows/feature-flag"; import { LayoutDashboard, MessageSquare, @@ -38,11 +37,10 @@ interface NavItem { label: string; icon: typeof LayoutDashboard; /** - * If set, only included in the rendered nav when the predicate - * returns true. Used to gate beta-only entries (e.g. /flows) on - * `profiles.beta_features`. + * When true, the nav row renders a small "Beta" chip after the label. + * Purely informational — doesn't affect routing or access. */ - betaKey?: string; + beta?: boolean; } const navItems: NavItem[] = [ @@ -52,10 +50,7 @@ const navItems: NavItem[] = [ { href: "/pipelines", label: "Pipelines", icon: GitBranch }, { href: "/broadcasts", label: "Broadcasts", icon: Radio }, { href: "/automations", label: "Automations", icon: Zap }, - // Flows is gated on the per-account beta flag; the entry is invisible - // for accounts that haven't opted in. Server-side route guards make - // this a UX choice, not a security one — the API + pages 404 anyway. - { href: "/flows", label: "Flows", icon: Workflow, betaKey: "flows" }, + { href: "/flows", label: "Flows", icon: Workflow, beta: true }, ]; const bottomNavItems = [ @@ -150,13 +145,6 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {