From 14cdbf457b663b8ed0e52cc2dfaa4de3e7f92f28 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Sun, 31 May 2026 19:02:14 +0300 Subject: [PATCH] feat(ui): role-aware gating sweep + account-name in sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 9 of the multi-user accounts series — the final piece. Visual polish so non-owners see consistent disabled states across the app and so every user always knows which account they're acting in. Sidebar - New account-name strip above the user dropdown, gated behind the `account_sharing` beta flag (same flag the Members tab from PR 6 reads). For solo users (the default) the account is named after them, so showing it would just duplicate the user name below — flag-gating keeps the original layout untouched for single-user installs. - When the caller's role is admin/agent/viewer (not owner), the role is surfaced as a small uppercase chip alongside the account name so they have an unambiguous signal of their permission level without opening Settings. - Nav items stay always-visible per the planning doc's "show-but-disable" decision — hiding nav rows by role would make the app feel broken. Inbox composer - Send button + Templates button + textarea are disabled for viewers with a "Read-only — viewers can browse but not reply" placeholder and tooltip. The existing session-expired branch still wins when it applies (session-expired is the more specific condition and surfaces template-only sending). List-page create buttons - broadcasts/page.tsx: "New Broadcast" CTA (header + empty state). - automations/page.tsx: "Create Automation" CTA. - flows/page.tsx: "New flow" CTA (header + empty-state). - contacts/page.tsx: "Add Contact" + "Import" CTAs. - pipelines/page.tsx: "Add Pipeline" (gates on edit-settings — pipelines are settings-class under the 017 RLS) and "Add Deal" (gates on send-messages). All use `useCan(...)` from PR 5. Disabled buttons get a tooltip explaining why (`title="Read-only — your role can't ..."`). Solo users see no visual change — single-owner accounts pass every capability check. Why show-but-disable, not hide - A viewer scanning the app should still see what's possible in the product, both to understand what they can ask for an upgrade for and to avoid the UI feeling silently broken ("the New button just disappeared?"). The tooltip carries the explanation cheaply. Verification - `npm run lint` → 0 errors (same 19 pre-existing warnings) - `npm run typecheck` → clean - `npm test` → 366/366 pass - `npm run build` → succeeds Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 15 ++++++++++++ src/app/(dashboard)/automations/page.tsx | 7 ++++++ src/app/(dashboard)/broadcasts/page.tsx | 12 +++++++++ src/app/(dashboard)/contacts/page.tsx | 8 ++++++ src/app/(dashboard)/flows/page.tsx | 30 ++++++++++++++++++++--- src/app/(dashboard)/pipelines/page.tsx | 19 +++++++++++++- src/components/inbox/message-composer.tsx | 25 +++++++++++++------ src/components/layout/sidebar.tsx | 27 +++++++++++++++++++- 8 files changed, 130 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 265b8cc..07547d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,21 @@ as the sole owner of their own account and sees identical data. - **The signup trigger (`handle_new_user`) now also creates a personal account** and links the new profile to it as `owner`. +### Changed + +- **Role-aware UI gating across the app.** The inbox composer's + send button + textarea, the "New broadcast / automation / flow" + buttons, the "Add pipeline / deal" buttons, and the "Add / + Import contact" buttons are now disabled-with-tooltip for + viewers (and for agents on settings-class actions). Choice: + show-but-disable rather than hide, so the UI never feels + silently broken to a teammate looking at a feature they don't + yet have permission for. +- **Sidebar surfaces the active account** above the user info + when the `account_sharing` beta flag is on. Solo users keep + the original layout (their account is named after them, so + duplicating it would just add visual noise). + ### Fixed - **Inbound WhatsApp messages now land in the shared inbox.** The diff --git a/src/app/(dashboard)/automations/page.tsx b/src/app/(dashboard)/automations/page.tsx index dadead5..ed861d3 100644 --- a/src/app/(dashboard)/automations/page.tsx +++ b/src/app/(dashboard)/automations/page.tsx @@ -19,6 +19,7 @@ import { } from "lucide-react" import { createClient } from "@/lib/supabase/client" +import { useCan } from "@/hooks/use-can" import type { Automation } from "@/types" import { Button } from "@/components/ui/button" import { Switch } from "@/components/ui/switch" @@ -55,8 +56,12 @@ 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") const [automations, setAutomations] = useState(null) const [error, setError] = useState(null) const [pendingDelete, setPendingDelete] = useState(null) @@ -164,6 +169,8 @@ export default function AutomationsPage() { {flows.length === 0 ? ( - setCreateOpen(true)} /> + setCreateOpen(true)} + canCreate={canCreate} + /> ) : (
{flows.map((flow) => ( @@ -312,7 +323,13 @@ export default function FlowsPage() { ); } -function EmptyState({ onCreate }: { onCreate: () => void }) { +function EmptyState({ + onCreate, + canCreate, +}: { + onCreate: () => void; + canCreate: boolean; +}) { return (
@@ -326,7 +343,12 @@ function EmptyState({ onCreate }: { onCreate: () => void }) { 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 7d0a539..24a95e2 100644 --- a/src/app/(dashboard)/pipelines/page.tsx +++ b/src/app/(dashboard)/pipelines/page.tsx @@ -26,6 +26,14 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { GitBranch, Plus, ChevronDown, Settings } from "lucide-react"; import { toast } from "sonner"; +import { useCan } from "@/hooks/use-can"; + +// 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"; // Spec-defined seed — name and color per the product spec. const SPEC_DEFAULT_STAGES = [ @@ -38,6 +46,8 @@ const SPEC_DEFAULT_STAGES = [ export default function PipelinesPage() { const supabase = createClient(); + const canEditSettings = useCan("edit-settings"); + const canCreateDeals = useCan("send-messages"); const [pipelines, setPipelines] = useState([]); const [selectedPipelineId, setSelectedPipelineId] = useState(""); @@ -349,6 +359,8 @@ export default function PipelinesPage() { @@ -121,23 +128,27 @@ export function MessageComposer({ onChange={handleChange} onKeyDown={handleKeyDown} placeholder={ - sessionExpired - ? "Session expired - use a template" - : "Type a message... (Shift+Enter for new line)" + readOnly + ? "Read-only — viewers can browse but not reply" + : sessionExpired + ? "Session expired - use a template" + : "Type a message... (Shift+Enter for new line)" } - disabled={sessionExpired} + disabled={sessionExpired || readOnly} rows={1} + title={readOnly ? "Read-only — your role can't send messages" : undefined} className={cn( "flex-1 resize-none rounded-xl border border-slate-700 bg-slate-800 px-4 py-2.5 text-sm text-white placeholder-slate-500 outline-none transition-colors focus:border-primary/50", - sessionExpired && "cursor-not-allowed opacity-50" + (sessionExpired || readOnly) && "cursor-not-allowed opacity-50" )} /> diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index d550745..316d4bd 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -17,6 +17,7 @@ import { Settings, LogOut, User, + UsersRound, X, } from "lucide-react"; import { @@ -63,10 +64,17 @@ interface SidebarProps { onClose?: () => void; } +// Same flag key the Members tab and Settings page use. Flip per +// profile via Supabase Studio to dogfood the multi-user surface. +const ACCOUNT_SHARING_FLAG = "account_sharing"; + export function Sidebar({ open = false, onClose }: SidebarProps) { const pathname = usePathname(); - const { profile, signOut } = useAuth(); + const { profile, account, accountRole, signOut } = useAuth(); const totalUnread = useTotalUnread(); + const accountSharingEnabled = !!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. @@ -216,6 +224,23 @@ export function Sidebar({ open = false, onClose }: SidebarProps) { {/* User section */}
+ {/* Account name display — only surfaced when the user is + opted into the account_sharing beta flag. For solo + users (the default) the account is named after them, + so showing it here would just duplicate the user name + below. Once the flag is on the user is at minimum + aware of which shared account they're acting in. */} + {accountSharingEnabled && account?.name ? ( +
+ + {account.name} + {accountRole && accountRole !== "owner" ? ( + + {accountRole} + + ) : null} +
+ ) : null}