From cbcad71fda0025aa9f484b91fade532aeb9ad251 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Sun, 31 May 2026 19:33:28 +0300 Subject: [PATCH] feat(ui): front-end correctness pass from multi-user review (S1,S2,S6-S8,P8,P9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR C of the post-review fix series. Bundles six related front-end correctness / portability fixes. No behavior change for happy-path users — these are belt-and-braces against edge cases. S1 — useCan exhaustiveness (`src/hooks/use-can.ts`) - Add a `default` branch with `const _: never = action` so adding a new `CanAction` without a case fails the typecheck instead of silently returning `undefined` at runtime. S2 + P8 — GatedButton helper (`src/components/ui/gated-button.tsx`) - ` + {showTemplates && ( diff --git a/src/app/(dashboard)/broadcasts/page.tsx b/src/app/(dashboard)/broadcasts/page.tsx index 0b8ca47..2583dd2 100644 --- a/src/app/(dashboard)/broadcasts/page.tsx +++ b/src/app/(dashboard)/broadcasts/page.tsx @@ -15,14 +15,9 @@ import { } from '@/components/ui/table'; import { Radio, Plus, Loader2 } from 'lucide-react'; import { useCan } from '@/hooks/use-can'; +import { GatedButton } from '@/components/ui/gated-button'; import { getBroadcastStatus } from '@/lib/broadcast-status'; -// Tooltip copy reused across both "New broadcast" buttons on this -// page. Centralised so a wording change is a one-line diff and the -// two CTAs can't drift apart. -const READ_ONLY_TITLE = - "Read-only — your role can't create broadcasts"; - /** * Poll cadence while any broadcast is sending. Kept modest so we don't * beat on Supabase — the aggregate trigger in migration 003 keeps @@ -189,15 +184,15 @@ export default function BroadcastsPage() { Send bulk messages to your contacts using approved templates.

- + {broadcasts.length === 0 ? ( @@ -207,15 +202,15 @@ export default function BroadcastsPage() {

Create your first broadcast to reach your contacts at scale.

- + ) : (
diff --git a/src/app/(dashboard)/contacts/page.tsx b/src/app/(dashboard)/contacts/page.tsx index 08e4640..eb1450e 100644 --- a/src/app/(dashboard)/contacts/page.tsx +++ b/src/app/(dashboard)/contacts/page.tsx @@ -45,10 +45,9 @@ import { ContactForm } from '@/components/contacts/contact-form'; import { ContactDetailView } from '@/components/contacts/contact-detail-view'; import { ImportModal } from '@/components/contacts/import-modal'; import { useCan } from '@/hooks/use-can'; +import { GatedButton } from '@/components/ui/gated-button'; const PAGE_SIZE = 25; -const READ_ONLY_TITLE = - "Read-only — your role can't add or import contacts"; interface ContactWithTags extends Contact { tags?: Tag[]; @@ -220,25 +219,25 @@ export default function ContactsPage() {

- - +
diff --git a/src/app/(dashboard)/flows/page.tsx b/src/app/(dashboard)/flows/page.tsx index c865a69..8143d37 100644 --- a/src/app/(dashboard)/flows/page.tsx +++ b/src/app/(dashboard)/flows/page.tsx @@ -20,6 +20,7 @@ import { import { useCan } from "@/hooks/use-can"; import { Button } from "@/components/ui/button"; +import { GatedButton } from "@/components/ui/gated-button"; import { Dialog, DialogContent, @@ -80,8 +81,6 @@ const TEMPLATE_ICONS = { UserPlus, } as const; -const READ_ONLY_TITLE = "Read-only — your role can't create flows"; - export default function FlowsPage() { const router = useRouter(); const canCreate = useCan("send-messages"); @@ -215,14 +214,14 @@ export default function FlowsPage() { menus, FAQs, and triage before a human steps in.

- + {flows.length === 0 ? ( @@ -343,15 +342,15 @@ function EmptyState({ bot. Customers tap buttons; the bot routes them to the right answer (or the right agent).

- + ); } diff --git a/src/app/(dashboard)/pipelines/page.tsx b/src/app/(dashboard)/pipelines/page.tsx index 24a95e2..534aa8a 100644 --- a/src/app/(dashboard)/pipelines/page.tsx +++ b/src/app/(dashboard)/pipelines/page.tsx @@ -27,13 +27,12 @@ import { Label } from "@/components/ui/label"; import { GitBranch, Plus, ChevronDown, Settings } from "lucide-react"; import { toast } from "sonner"; import { useCan } from "@/hooks/use-can"; +import { GatedButton } from "@/components/ui/gated-button"; -// Pipeline creation is admin-class (it's a settings-tier write -// under the new RLS), but deal creation is operational and only -// requires agent+. The two CTAs gate on different capabilities. -const PIPELINE_READ_ONLY_TITLE = - "Read-only — your role can't create pipelines"; -const DEAL_READ_ONLY_TITLE = "Read-only — your role can't create deals"; +// Pipeline creation is admin-class (settings-tier write under +// the new RLS); deal creation is operational and only requires +// agent+. The two CTAs gate on different `useCan` capabilities, +// not on different copy. // Spec-defined seed — name and color per the product spec. const SPEC_DEFAULT_STAGES = [ @@ -356,27 +355,26 @@ export default function PipelinesPage() {
- - +
@@ -390,15 +388,15 @@ export default function PipelinesPage() {

Create a pipeline to start tracking deals

- + ) : ( <> diff --git a/src/app/join/layout.tsx b/src/app/join/layout.tsx index 24b9660..7fb9796 100644 --- a/src/app/join/layout.tsx +++ b/src/app/join/layout.tsx @@ -13,10 +13,28 @@ // Styling matches the login / signup pages — centered card on a // slate-950 background — so the join experience feels like a // natural step in the auth funnel rather than a foreign page. +// +// Referrer-Policy: no-referrer +// The plaintext invite token lives in the URL path. Without +// this header, any externally-loaded resource (third-party +// font, CDN script, image) would receive the full join URL in +// its `Referer` header. The /join page doesn't currently load +// anything external, but `Referrer-Policy: no-referrer` is a +// cheap belt-and-braces guard against future regressions +// accidentally leaking tokens. Per Next.js 16's `metadata` +// export, this surfaces as ``. // ============================================================ +import type { Metadata } from 'next'; import type { ReactNode } from 'react'; +export const metadata: Metadata = { + referrer: 'no-referrer', + // Belt-and-braces against an invite URL ending up in search + // results if a join page is ever crawled. + robots: { index: false, follow: false }, +}; + export default function JoinLayout({ children }: { children: ReactNode }) { return (
diff --git a/src/components/inbox/message-composer.tsx b/src/components/inbox/message-composer.tsx index 385c64e..96382b2 100644 --- a/src/components/inbox/message-composer.tsx +++ b/src/components/inbox/message-composer.tsx @@ -3,6 +3,7 @@ import { useState, useRef, useCallback, KeyboardEvent } from "react"; import { Send, LayoutTemplate } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { GatedButton } from "@/components/ui/gated-button"; import { useCan } from "@/hooks/use-can"; import { cn } from "@/lib/utils"; import { ReplyQuote } from "./reply-quote"; @@ -111,16 +112,17 @@ export function MessageComposer({ )}
- +