mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Merge pull request #177 from ArnasDon/feat/account-sharing-ui-gating
feat(ui): role-aware gating sweep + account-name in sidebar
This commit is contained in:
commit
bf2addc7c9
15
CHANGELOG.md
15
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
|
||||
|
||||
@ -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<TemplateSlug, typeof Zap> = {
|
||||
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<Automation[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [pendingDelete, setPendingDelete] = useState<Automation | null>(null)
|
||||
@ -164,6 +169,8 @@ export default function AutomationsPage() {
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => router.push("/automations/new")}
|
||||
disabled={!canCreate}
|
||||
title={canCreate ? undefined : READ_ONLY_TITLE}
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
|
||||
@ -14,8 +14,15 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Radio, Plus, Loader2 } from 'lucide-react';
|
||||
import { useCan } from '@/hooks/use-can';
|
||||
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
|
||||
@ -56,6 +63,7 @@ function RateCell({
|
||||
|
||||
export default function BroadcastsPage() {
|
||||
const router = useRouter();
|
||||
const canCreate = useCan('send-messages');
|
||||
const [broadcasts, setBroadcasts] = useState<Broadcast[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -183,6 +191,8 @@ export default function BroadcastsPage() {
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => router.push('/broadcasts/new')}
|
||||
disabled={!canCreate}
|
||||
title={canCreate ? undefined : READ_ONLY_TITLE}
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
@ -199,6 +209,8 @@ export default function BroadcastsPage() {
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => router.push('/broadcasts/new')}
|
||||
disabled={!canCreate}
|
||||
title={canCreate ? undefined : READ_ONLY_TITLE}
|
||||
className="mt-4 bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
|
||||
@ -44,8 +44,11 @@ import {
|
||||
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';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
const READ_ONLY_TITLE =
|
||||
"Read-only — your role can't add or import contacts";
|
||||
|
||||
interface ContactWithTags extends Contact {
|
||||
tags?: Tag[];
|
||||
@ -53,6 +56,7 @@ interface ContactWithTags extends Contact {
|
||||
|
||||
export default function ContactsPage() {
|
||||
const supabase = createClient();
|
||||
const canEdit = useCan('send-messages');
|
||||
|
||||
const [contacts, setContacts] = useState<ContactWithTags[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@ -219,6 +223,8 @@ export default function ContactsPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setImportOpen(true)}
|
||||
disabled={!canEdit}
|
||||
title={canEdit ? undefined : READ_ONLY_TITLE}
|
||||
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
@ -226,6 +232,8 @@ export default function ContactsPage() {
|
||||
</Button>
|
||||
<Button
|
||||
onClick={openAddForm}
|
||||
disabled={!canEdit}
|
||||
title={canEdit ? undefined : READ_ONLY_TITLE}
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
|
||||
@ -18,6 +18,7 @@ import {
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useCan } from "@/hooks/use-can";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@ -79,8 +80,11 @@ 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");
|
||||
const [flows, setFlows] = useState<FlowRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
@ -211,14 +215,21 @@ export default function FlowsPage() {
|
||||
menus, FAQs, and triage before a human steps in.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Button
|
||||
onClick={() => setCreateOpen(true)}
|
||||
disabled={!canCreate}
|
||||
title={canCreate ? undefined : READ_ONLY_TITLE}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
New flow
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{flows.length === 0 ? (
|
||||
<EmptyState onCreate={() => setCreateOpen(true)} />
|
||||
<EmptyState
|
||||
onCreate={() => setCreateOpen(true)}
|
||||
canCreate={canCreate}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{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 (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-slate-700 bg-slate-900/50 px-6 py-16 text-center">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-slate-800">
|
||||
@ -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).
|
||||
</p>
|
||||
<Button onClick={onCreate} className="mt-5">
|
||||
<Button
|
||||
onClick={onCreate}
|
||||
disabled={!canCreate}
|
||||
title={canCreate ? undefined : READ_ONLY_TITLE}
|
||||
className="mt-5"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create your first flow
|
||||
</Button>
|
||||
|
||||
@ -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<Pipeline[]>([]);
|
||||
const [selectedPipelineId, setSelectedPipelineId] = useState<string>("");
|
||||
@ -349,6 +359,8 @@ export default function PipelinesPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setNewPipelineOpen(true)}
|
||||
disabled={!canEditSettings}
|
||||
title={canEditSettings ? undefined : PIPELINE_READ_ONLY_TITLE}
|
||||
className="border-slate-700 bg-slate-900 text-slate-200 hover:bg-slate-800"
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
@ -356,7 +368,10 @@ export default function PipelinesPage() {
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleAddDeal()}
|
||||
disabled={!selectedPipelineId || stages.length === 0}
|
||||
disabled={
|
||||
!canCreateDeals || !selectedPipelineId || stages.length === 0
|
||||
}
|
||||
title={canCreateDeals ? undefined : DEAL_READ_ONLY_TITLE}
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
@ -377,6 +392,8 @@ export default function PipelinesPage() {
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setNewPipelineOpen(true)}
|
||||
disabled={!canEditSettings}
|
||||
title={canEditSettings ? undefined : PIPELINE_READ_ONLY_TITLE}
|
||||
className="mt-4 bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { useState, useRef, useCallback, KeyboardEvent } from "react";
|
||||
import { Send, LayoutTemplate } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useCan } from "@/hooks/use-can";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ReplyQuote } from "./reply-quote";
|
||||
|
||||
@ -33,6 +34,11 @@ export function MessageComposer({
|
||||
const [text, setText] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
// Viewers (read-only role) can browse the inbox but never send.
|
||||
// For solo users this is always true — single-owner accounts pass
|
||||
// every capability — so the disabled branch is a no-op there.
|
||||
const canSend = useCan("send-messages");
|
||||
const readOnly = !canSend;
|
||||
|
||||
const adjustHeight = useCallback(() => {
|
||||
const el = textareaRef.current;
|
||||
@ -110,7 +116,8 @@ export function MessageComposer({
|
||||
size="sm"
|
||||
className="h-9 w-9 shrink-0 p-0 text-slate-400 hover:text-white"
|
||||
onClick={onOpenTemplates}
|
||||
title="Send template"
|
||||
disabled={readOnly}
|
||||
title={readOnly ? "Read-only — your role can't send messages" : "Send template"}
|
||||
>
|
||||
<LayoutTemplate className="h-4 w-4" />
|
||||
</Button>
|
||||
@ -121,23 +128,27 @@ export function MessageComposer({
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
sessionExpired
|
||||
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"
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-9 w-9 shrink-0 bg-primary p-0 hover:bg-primary/90 disabled:opacity-40"
|
||||
disabled={!text.trim() || sessionExpired || sending}
|
||||
disabled={!text.trim() || sessionExpired || sending || readOnly}
|
||||
onClick={handleSend}
|
||||
title={readOnly ? "Read-only — your role can't send messages" : undefined}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@ -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 */}
|
||||
<div className="shrink-0 border-t border-slate-800 p-3">
|
||||
{/* 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 ? (
|
||||
<div className="mb-2 flex items-center gap-2 px-3 text-xs text-slate-500">
|
||||
<UsersRound className="size-3.5" />
|
||||
<span className="truncate">{account.name}</span>
|
||||
{accountRole && accountRole !== "owner" ? (
|
||||
<span className="ml-auto rounded-full border border-slate-700 bg-slate-800 px-1.5 py-0.5 text-[10px] uppercase tracking-wider text-slate-400">
|
||||
{accountRole}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors hover:bg-slate-800/60 focus:bg-slate-800/60 focus:outline-none data-popup-open:bg-slate-800/60">
|
||||
<Avatar className="size-8 shrink-0">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user