diff --git a/src/app/(dashboard)/automations/page.tsx b/src/app/(dashboard)/automations/page.tsx
index ed861d3..ac5eaad 100644
--- a/src/app/(dashboard)/automations/page.tsx
+++ b/src/app/(dashboard)/automations/page.tsx
@@ -22,6 +22,7 @@ import { createClient } from "@/lib/supabase/client"
import { useCan } from "@/hooks/use-can"
import type { Automation } from "@/types"
import { Button } from "@/components/ui/button"
+import { GatedButton } from "@/components/ui/gated-button"
import { Switch } from "@/components/ui/switch"
import {
DropdownMenu,
@@ -56,9 +57,6 @@ const TEMPLATE_ICON: Record = {
follow_up_reminder: PhoneCall,
}
-const READ_ONLY_TITLE =
- "Read-only — your role can't create automations"
-
export default function AutomationsPage() {
const router = useRouter()
const canCreate = useCan("send-messages")
@@ -167,15 +165,15 @@ export default function AutomationsPage() {
Build workflows that react to WhatsApp® events automatically.
-
+
{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({
)}
-
+
-
+
{/* Hint sits outside the flex row so its height doesn't push
diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx
index 316d4bd..86340bb 100644
--- a/src/components/layout/sidebar.tsx
+++ b/src/components/layout/sidebar.tsx
@@ -70,11 +70,17 @@ const ACCOUNT_SHARING_FLAG = "account_sharing";
export function Sidebar({ open = false, onClose }: SidebarProps) {
const pathname = usePathname();
- const { profile, account, accountRole, signOut } = useAuth();
+ const { profile, profileLoading, account, accountRole, signOut } = useAuth();
const totalUnread = useTotalUnread();
- const accountSharingEnabled = !!profile?.beta_features?.includes(
- ACCOUNT_SHARING_FLAG,
- );
+ // Match the settings page's check: only treat the flag as enabled
+ // once the profile has finished loading. Without this, the strip
+ // would briefly flash absent during the initial profile fetch
+ // (when `profile` is null and the boolean coerces to false), then
+ // pop in once the row resolves — visible as a layout jump in the
+ // sidebar footer.
+ const accountSharingEnabled =
+ !profileLoading &&
+ !!profile?.beta_features?.includes(ACCOUNT_SHARING_FLAG);
// Close the drawer when route changes — users opened it to navigate,
// so once they pick a destination the drawer should get out of the way.
diff --git a/src/components/settings/invite-member-dialog.tsx b/src/components/settings/invite-member-dialog.tsx
index 5bf2887..9c4adbd 100644
--- a/src/components/settings/invite-member-dialog.tsx
+++ b/src/components/settings/invite-member-dialog.tsx
@@ -62,6 +62,11 @@ const ROLE_DESCRIPTIONS: Record = {
viewer: 'Read-only access across every page. Cannot send or edit anything.',
};
+// Server caps label at 80 chars (see src/app/api/account/invitations/route.ts).
+// Mirror it on the client so we short-circuit before the round-trip
+// rather than letting the user submit and bounce off a 400.
+const MAX_LABEL_LEN = 80;
+
interface CreatedInvite {
url: string;
role: InviteRole;
@@ -92,6 +97,17 @@ export function InviteMemberDialog({
}
async function handleCreate() {
+ // Mirror the server's max-length check so we don't ship an
+ // obviously-too-long label across the wire just to bounce off
+ // a 400. The Input also has a `maxLength={MAX_LABEL_LEN}` cap
+ // but a paste can land an over-limit string into state before
+ // the limit kicks in on the next keystroke — this is the safety
+ // net for that path.
+ const trimmedLabel = label.trim();
+ if (trimmedLabel.length > MAX_LABEL_LEN) {
+ toast.error(`Label must be ${MAX_LABEL_LEN} characters or fewer`);
+ return;
+ }
setSubmitting(true);
try {
const res = await fetch('/api/account/invitations', {
@@ -100,7 +116,7 @@ export function InviteMemberDialog({
body: JSON.stringify({
role,
expiresInDays: Number(expiry),
- label: label.trim() || undefined,
+ label: trimmedLabel || undefined,
}),
});
@@ -302,7 +318,7 @@ export function InviteMemberDialog({
placeholder="e.g. Sara — support team"
value={label}
onChange={(e) => setLabel(e.target.value)}
- maxLength={80}
+ maxLength={MAX_LABEL_LEN}
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
/>
diff --git a/src/components/settings/members-tab.tsx b/src/components/settings/members-tab.tsx
index 8fd69e6..6f18608 100644
--- a/src/components/settings/members-tab.tsx
+++ b/src/components/settings/members-tab.tsx
@@ -174,7 +174,16 @@ export function MembersTab() {
async function handleRoleChange(member: Member, nextRole: AccountRole) {
if (member.role === nextRole) return;
+ // Optimistic update — flip the dropdown immediately so the UI
+ // feels snappy. If the server PATCH fails we revert below so
+ // the dropdown doesn't lie about the persisted state.
+ const previousRole = member.role;
setPendingMemberAction(member.user_id);
+ setMembers((prev) =>
+ prev.map((m) =>
+ m.user_id === member.user_id ? { ...m, role: nextRole } : m,
+ ),
+ );
try {
const res = await fetch(`/api/account/members/${member.user_id}`, {
method: 'PATCH',
@@ -182,17 +191,28 @@ export function MembersTab() {
body: JSON.stringify({ role: nextRole }),
});
if (!res.ok) {
+ // Revert the optimistic flip. The toast on its own wasn't
+ // enough — the dropdown was left showing the new role
+ // forever, so the next interaction operated on a wrong
+ // baseline (re-trying the same change would no-op via the
+ // `member.role === nextRole` guard at the top).
+ setMembers((prev) =>
+ prev.map((m) =>
+ m.user_id === member.user_id ? { ...m, role: previousRole } : m,
+ ),
+ );
const payload = await res.json().catch(() => ({}));
toast.error(payload.error || 'Failed to update role');
return;
}
toast.success(`Updated ${member.full_name || 'member'} to ${nextRole}`);
+ } catch (err) {
+ // Same revert on network failure.
setMembers((prev) =>
prev.map((m) =>
- m.user_id === member.user_id ? { ...m, role: nextRole } : m,
+ m.user_id === member.user_id ? { ...m, role: previousRole } : m,
),
);
- } catch (err) {
console.error('[MembersTab] role change error:', err);
toast.error('Could not reach the server');
} finally {
diff --git a/src/components/ui/gated-button.tsx b/src/components/ui/gated-button.tsx
new file mode 100644
index 0000000..d856e6b
--- /dev/null
+++ b/src/components/ui/gated-button.tsx
@@ -0,0 +1,101 @@
+"use client";
+
+// ============================================================
+// GatedButton — Button + role-gated "Read-only" tooltip helper.
+//
+// The wider problem this solves:
+//
+// A bare `