mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
Merge pull request #180 from ArnasDon/feat/account-sharing-fe-correctness
feat(ui): front-end correctness pass (S1,S2,S6-S8,P8,P9)
This commit is contained in:
commit
4390b8b41f
@ -22,6 +22,7 @@ import { createClient } from "@/lib/supabase/client"
|
|||||||
import { useCan } from "@/hooks/use-can"
|
import { useCan } from "@/hooks/use-can"
|
||||||
import type { Automation } from "@/types"
|
import type { Automation } from "@/types"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { GatedButton } from "@/components/ui/gated-button"
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@ -56,9 +57,6 @@ const TEMPLATE_ICON: Record<TemplateSlug, typeof Zap> = {
|
|||||||
follow_up_reminder: PhoneCall,
|
follow_up_reminder: PhoneCall,
|
||||||
}
|
}
|
||||||
|
|
||||||
const READ_ONLY_TITLE =
|
|
||||||
"Read-only — your role can't create automations"
|
|
||||||
|
|
||||||
export default function AutomationsPage() {
|
export default function AutomationsPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const canCreate = useCan("send-messages")
|
const canCreate = useCan("send-messages")
|
||||||
@ -167,15 +165,15 @@ export default function AutomationsPage() {
|
|||||||
Build workflows that react to WhatsApp® events automatically.
|
Build workflows that react to WhatsApp® events automatically.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<GatedButton
|
||||||
|
canAct={canCreate}
|
||||||
|
gateReason="create automations"
|
||||||
onClick={() => router.push("/automations/new")}
|
onClick={() => router.push("/automations/new")}
|
||||||
disabled={!canCreate}
|
|
||||||
title={canCreate ? undefined : READ_ONLY_TITLE}
|
|
||||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Create Automation
|
Create Automation
|
||||||
</Button>
|
</GatedButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showTemplates && (
|
{showTemplates && (
|
||||||
|
|||||||
@ -15,14 +15,9 @@ import {
|
|||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import { Radio, Plus, Loader2 } from 'lucide-react';
|
import { Radio, Plus, Loader2 } from 'lucide-react';
|
||||||
import { useCan } from '@/hooks/use-can';
|
import { useCan } from '@/hooks/use-can';
|
||||||
|
import { GatedButton } from '@/components/ui/gated-button';
|
||||||
import { getBroadcastStatus } from '@/lib/broadcast-status';
|
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
|
* Poll cadence while any broadcast is sending. Kept modest so we don't
|
||||||
* beat on Supabase — the aggregate trigger in migration 003 keeps
|
* 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.
|
Send bulk messages to your contacts using approved templates.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<GatedButton
|
||||||
|
canAct={canCreate}
|
||||||
|
gateReason="create broadcasts"
|
||||||
onClick={() => router.push('/broadcasts/new')}
|
onClick={() => router.push('/broadcasts/new')}
|
||||||
disabled={!canCreate}
|
|
||||||
title={canCreate ? undefined : READ_ONLY_TITLE}
|
|
||||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
New Broadcast
|
New Broadcast
|
||||||
</Button>
|
</GatedButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{broadcasts.length === 0 ? (
|
{broadcasts.length === 0 ? (
|
||||||
@ -207,15 +202,15 @@ export default function BroadcastsPage() {
|
|||||||
<p className="mt-1 text-xs text-slate-400">
|
<p className="mt-1 text-xs text-slate-400">
|
||||||
Create your first broadcast to reach your contacts at scale.
|
Create your first broadcast to reach your contacts at scale.
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<GatedButton
|
||||||
|
canAct={canCreate}
|
||||||
|
gateReason="create broadcasts"
|
||||||
onClick={() => router.push('/broadcasts/new')}
|
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"
|
className="mt-4 bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
New Broadcast
|
New Broadcast
|
||||||
</Button>
|
</GatedButton>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto rounded-xl border border-slate-800 bg-slate-900">
|
<div className="overflow-x-auto rounded-xl border border-slate-800 bg-slate-900">
|
||||||
|
|||||||
@ -45,10 +45,9 @@ import { ContactForm } from '@/components/contacts/contact-form';
|
|||||||
import { ContactDetailView } from '@/components/contacts/contact-detail-view';
|
import { ContactDetailView } from '@/components/contacts/contact-detail-view';
|
||||||
import { ImportModal } from '@/components/contacts/import-modal';
|
import { ImportModal } from '@/components/contacts/import-modal';
|
||||||
import { useCan } from '@/hooks/use-can';
|
import { useCan } from '@/hooks/use-can';
|
||||||
|
import { GatedButton } from '@/components/ui/gated-button';
|
||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
const READ_ONLY_TITLE =
|
|
||||||
"Read-only — your role can't add or import contacts";
|
|
||||||
|
|
||||||
interface ContactWithTags extends Contact {
|
interface ContactWithTags extends Contact {
|
||||||
tags?: Tag[];
|
tags?: Tag[];
|
||||||
@ -220,25 +219,25 @@ export default function ContactsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<GatedButton
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
canAct={canEdit}
|
||||||
|
gateReason="add or import contacts"
|
||||||
onClick={() => setImportOpen(true)}
|
onClick={() => setImportOpen(true)}
|
||||||
disabled={!canEdit}
|
|
||||||
title={canEdit ? undefined : READ_ONLY_TITLE}
|
|
||||||
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||||
>
|
>
|
||||||
<Upload className="size-4" />
|
<Upload className="size-4" />
|
||||||
Import
|
Import
|
||||||
</Button>
|
</GatedButton>
|
||||||
<Button
|
<GatedButton
|
||||||
|
canAct={canEdit}
|
||||||
|
gateReason="add or import contacts"
|
||||||
onClick={openAddForm}
|
onClick={openAddForm}
|
||||||
disabled={!canEdit}
|
|
||||||
title={canEdit ? undefined : READ_ONLY_TITLE}
|
|
||||||
className="bg-primary hover:bg-primary/90 text-primary-foreground"
|
className="bg-primary hover:bg-primary/90 text-primary-foreground"
|
||||||
>
|
>
|
||||||
<Plus className="size-4" />
|
<Plus className="size-4" />
|
||||||
Add Contact
|
Add Contact
|
||||||
</Button>
|
</GatedButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import {
|
|||||||
|
|
||||||
import { useCan } from "@/hooks/use-can";
|
import { useCan } from "@/hooks/use-can";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { GatedButton } from "@/components/ui/gated-button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@ -80,8 +81,6 @@ const TEMPLATE_ICONS = {
|
|||||||
UserPlus,
|
UserPlus,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const READ_ONLY_TITLE = "Read-only — your role can't create flows";
|
|
||||||
|
|
||||||
export default function FlowsPage() {
|
export default function FlowsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const canCreate = useCan("send-messages");
|
const canCreate = useCan("send-messages");
|
||||||
@ -215,14 +214,14 @@ export default function FlowsPage() {
|
|||||||
menus, FAQs, and triage before a human steps in.
|
menus, FAQs, and triage before a human steps in.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<GatedButton
|
||||||
|
canAct={canCreate}
|
||||||
|
gateReason="create flows"
|
||||||
onClick={() => setCreateOpen(true)}
|
onClick={() => setCreateOpen(true)}
|
||||||
disabled={!canCreate}
|
|
||||||
title={canCreate ? undefined : READ_ONLY_TITLE}
|
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
New flow
|
New flow
|
||||||
</Button>
|
</GatedButton>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{flows.length === 0 ? (
|
{flows.length === 0 ? (
|
||||||
@ -343,15 +342,15 @@ function EmptyState({
|
|||||||
bot. Customers tap buttons; the bot routes them to the right answer (or
|
bot. Customers tap buttons; the bot routes them to the right answer (or
|
||||||
the right agent).
|
the right agent).
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<GatedButton
|
||||||
|
canAct={canCreate}
|
||||||
|
gateReason="create flows"
|
||||||
onClick={onCreate}
|
onClick={onCreate}
|
||||||
disabled={!canCreate}
|
|
||||||
title={canCreate ? undefined : READ_ONLY_TITLE}
|
|
||||||
className="mt-5"
|
className="mt-5"
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Create your first flow
|
Create your first flow
|
||||||
</Button>
|
</GatedButton>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,13 +27,12 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { GitBranch, Plus, ChevronDown, Settings } from "lucide-react";
|
import { GitBranch, Plus, ChevronDown, Settings } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useCan } from "@/hooks/use-can";
|
import { useCan } from "@/hooks/use-can";
|
||||||
|
import { GatedButton } from "@/components/ui/gated-button";
|
||||||
|
|
||||||
// Pipeline creation is admin-class (it's a settings-tier write
|
// Pipeline creation is admin-class (settings-tier write under
|
||||||
// under the new RLS), but deal creation is operational and only
|
// the new RLS); deal creation is operational and only requires
|
||||||
// requires agent+. The two CTAs gate on different capabilities.
|
// agent+. The two CTAs gate on different `useCan` capabilities,
|
||||||
const PIPELINE_READ_ONLY_TITLE =
|
// not on different copy.
|
||||||
"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.
|
// Spec-defined seed — name and color per the product spec.
|
||||||
const SPEC_DEFAULT_STAGES = [
|
const SPEC_DEFAULT_STAGES = [
|
||||||
@ -356,27 +355,26 @@ export default function PipelinesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<GatedButton
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
canAct={canEditSettings}
|
||||||
|
gateReason="create pipelines"
|
||||||
onClick={() => setNewPipelineOpen(true)}
|
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"
|
className="border-slate-700 bg-slate-900 text-slate-200 hover:bg-slate-800"
|
||||||
>
|
>
|
||||||
<Plus className="mr-1 h-4 w-4" />
|
<Plus className="mr-1 h-4 w-4" />
|
||||||
Add Pipeline
|
Add Pipeline
|
||||||
</Button>
|
</GatedButton>
|
||||||
<Button
|
<GatedButton
|
||||||
|
canAct={canCreateDeals}
|
||||||
|
gateReason="create deals"
|
||||||
|
disabled={!selectedPipelineId || stages.length === 0}
|
||||||
onClick={() => handleAddDeal()}
|
onClick={() => handleAddDeal()}
|
||||||
disabled={
|
|
||||||
!canCreateDeals || !selectedPipelineId || stages.length === 0
|
|
||||||
}
|
|
||||||
title={canCreateDeals ? undefined : DEAL_READ_ONLY_TITLE}
|
|
||||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
>
|
>
|
||||||
<Plus className="mr-1 h-4 w-4" />
|
<Plus className="mr-1 h-4 w-4" />
|
||||||
Add Deal
|
Add Deal
|
||||||
</Button>
|
</GatedButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -390,15 +388,15 @@ export default function PipelinesPage() {
|
|||||||
<p className="mt-2 text-sm text-slate-400">
|
<p className="mt-2 text-sm text-slate-400">
|
||||||
Create a pipeline to start tracking deals
|
Create a pipeline to start tracking deals
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<GatedButton
|
||||||
|
canAct={canEditSettings}
|
||||||
|
gateReason="create pipelines"
|
||||||
onClick={() => setNewPipelineOpen(true)}
|
onClick={() => setNewPipelineOpen(true)}
|
||||||
disabled={!canEditSettings}
|
|
||||||
title={canEditSettings ? undefined : PIPELINE_READ_ONLY_TITLE}
|
|
||||||
className="mt-4 bg-primary text-primary-foreground hover:bg-primary/90"
|
className="mt-4 bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
>
|
>
|
||||||
<Plus className="mr-1 h-4 w-4" />
|
<Plus className="mr-1 h-4 w-4" />
|
||||||
Create Pipeline
|
Create Pipeline
|
||||||
</Button>
|
</GatedButton>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -13,10 +13,28 @@
|
|||||||
// Styling matches the login / signup pages — centered card on a
|
// Styling matches the login / signup pages — centered card on a
|
||||||
// slate-950 background — so the join experience feels like a
|
// slate-950 background — so the join experience feels like a
|
||||||
// natural step in the auth funnel rather than a foreign page.
|
// 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 `<meta name="referrer" content="no-referrer">`.
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
|
import type { Metadata } from 'next';
|
||||||
import type { ReactNode } from 'react';
|
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 }) {
|
export default function JoinLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-slate-950 px-4">
|
<div className="flex min-h-screen items-center justify-center bg-slate-950 px-4">
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import { useState, useRef, useCallback, KeyboardEvent } from "react";
|
import { useState, useRef, useCallback, KeyboardEvent } from "react";
|
||||||
import { Send, LayoutTemplate } from "lucide-react";
|
import { Send, LayoutTemplate } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { GatedButton } from "@/components/ui/gated-button";
|
||||||
import { useCan } from "@/hooks/use-can";
|
import { useCan } from "@/hooks/use-can";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ReplyQuote } from "./reply-quote";
|
import { ReplyQuote } from "./reply-quote";
|
||||||
@ -111,16 +112,17 @@ export function MessageComposer({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-end gap-2">
|
<div className="flex items-end gap-2">
|
||||||
<Button
|
<GatedButton
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
canAct={!readOnly}
|
||||||
|
gateReason="send messages"
|
||||||
|
title={readOnly ? undefined : "Send template"}
|
||||||
className="h-9 w-9 shrink-0 p-0 text-slate-400 hover:text-white"
|
className="h-9 w-9 shrink-0 p-0 text-slate-400 hover:text-white"
|
||||||
onClick={onOpenTemplates}
|
onClick={onOpenTemplates}
|
||||||
disabled={readOnly}
|
|
||||||
title={readOnly ? "Read-only — your role can't send messages" : "Send template"}
|
|
||||||
>
|
>
|
||||||
<LayoutTemplate className="h-4 w-4" />
|
<LayoutTemplate className="h-4 w-4" />
|
||||||
</Button>
|
</GatedButton>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
ref={textareaRef}
|
ref={textareaRef}
|
||||||
@ -136,6 +138,9 @@ export function MessageComposer({
|
|||||||
}
|
}
|
||||||
disabled={sessionExpired || readOnly}
|
disabled={sessionExpired || readOnly}
|
||||||
rows={1}
|
rows={1}
|
||||||
|
// Textarea keeps its own inline title — the GatedButton
|
||||||
|
// wrapping pattern doesn't apply to non-button inputs.
|
||||||
|
// The placeholder text also surfaces the read-only state.
|
||||||
title={readOnly ? "Read-only — your role can't send messages" : undefined}
|
title={readOnly ? "Read-only — your role can't send messages" : undefined}
|
||||||
className={cn(
|
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",
|
"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",
|
||||||
@ -143,15 +148,16 @@ export function MessageComposer({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<GatedButton
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-9 w-9 shrink-0 bg-primary p-0 hover:bg-primary/90 disabled:opacity-40"
|
canAct={!readOnly}
|
||||||
disabled={!text.trim() || sessionExpired || sending || readOnly}
|
gateReason="send messages"
|
||||||
|
disabled={!text.trim() || sessionExpired || sending}
|
||||||
onClick={handleSend}
|
onClick={handleSend}
|
||||||
title={readOnly ? "Read-only — your role can't send messages" : undefined}
|
className="h-9 w-9 shrink-0 bg-primary p-0 hover:bg-primary/90 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
<Send className="h-4 w-4" />
|
<Send className="h-4 w-4" />
|
||||||
</Button>
|
</GatedButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hint sits outside the flex row so its height doesn't push
|
{/* Hint sits outside the flex row so its height doesn't push
|
||||||
|
|||||||
@ -70,11 +70,17 @@ const ACCOUNT_SHARING_FLAG = "account_sharing";
|
|||||||
|
|
||||||
export function Sidebar({ open = false, onClose }: SidebarProps) {
|
export function Sidebar({ open = false, onClose }: SidebarProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { profile, account, accountRole, signOut } = useAuth();
|
const { profile, profileLoading, account, accountRole, signOut } = useAuth();
|
||||||
const totalUnread = useTotalUnread();
|
const totalUnread = useTotalUnread();
|
||||||
const accountSharingEnabled = !!profile?.beta_features?.includes(
|
// Match the settings page's check: only treat the flag as enabled
|
||||||
ACCOUNT_SHARING_FLAG,
|
// 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,
|
// 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.
|
// so once they pick a destination the drawer should get out of the way.
|
||||||
|
|||||||
@ -62,6 +62,11 @@ const ROLE_DESCRIPTIONS: Record<InviteRole, string> = {
|
|||||||
viewer: 'Read-only access across every page. Cannot send or edit anything.',
|
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 {
|
interface CreatedInvite {
|
||||||
url: string;
|
url: string;
|
||||||
role: InviteRole;
|
role: InviteRole;
|
||||||
@ -92,6 +97,17 @@ export function InviteMemberDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreate() {
|
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);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/account/invitations', {
|
const res = await fetch('/api/account/invitations', {
|
||||||
@ -100,7 +116,7 @@ export function InviteMemberDialog({
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
role,
|
role,
|
||||||
expiresInDays: Number(expiry),
|
expiresInDays: Number(expiry),
|
||||||
label: label.trim() || undefined,
|
label: trimmedLabel || undefined,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -302,7 +318,7 @@ export function InviteMemberDialog({
|
|||||||
placeholder="e.g. Sara — support team"
|
placeholder="e.g. Sara — support team"
|
||||||
value={label}
|
value={label}
|
||||||
onChange={(e) => setLabel(e.target.value)}
|
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"
|
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-slate-500">
|
<p className="text-xs text-slate-500">
|
||||||
|
|||||||
@ -174,7 +174,16 @@ export function MembersTab() {
|
|||||||
|
|
||||||
async function handleRoleChange(member: Member, nextRole: AccountRole) {
|
async function handleRoleChange(member: Member, nextRole: AccountRole) {
|
||||||
if (member.role === nextRole) return;
|
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);
|
setPendingMemberAction(member.user_id);
|
||||||
|
setMembers((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.user_id === member.user_id ? { ...m, role: nextRole } : m,
|
||||||
|
),
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/account/members/${member.user_id}`, {
|
const res = await fetch(`/api/account/members/${member.user_id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@ -182,17 +191,28 @@ export function MembersTab() {
|
|||||||
body: JSON.stringify({ role: nextRole }),
|
body: JSON.stringify({ role: nextRole }),
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
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(() => ({}));
|
const payload = await res.json().catch(() => ({}));
|
||||||
toast.error(payload.error || 'Failed to update role');
|
toast.error(payload.error || 'Failed to update role');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.success(`Updated ${member.full_name || 'member'} to ${nextRole}`);
|
toast.success(`Updated ${member.full_name || 'member'} to ${nextRole}`);
|
||||||
|
} catch (err) {
|
||||||
|
// Same revert on network failure.
|
||||||
setMembers((prev) =>
|
setMembers((prev) =>
|
||||||
prev.map((m) =>
|
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);
|
console.error('[MembersTab] role change error:', err);
|
||||||
toast.error('Could not reach the server');
|
toast.error('Could not reach the server');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
101
src/components/ui/gated-button.tsx
Normal file
101
src/components/ui/gated-button.tsx
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// GatedButton — Button + role-gated "Read-only" tooltip helper.
|
||||||
|
//
|
||||||
|
// The wider problem this solves:
|
||||||
|
//
|
||||||
|
// A bare `<Button disabled title="Read-only — ...">` doesn't
|
||||||
|
// render a tooltip in Safari or older Firefox because those
|
||||||
|
// browsers don't fire mouse events on disabled form controls.
|
||||||
|
// Title attributes only render when the element receives a
|
||||||
|
// mouseover. The 9-PR multi-user series relied on this pattern
|
||||||
|
// for every "read-only for viewer" CTA across the app, which
|
||||||
|
// meant viewers on those browsers saw a silently-disabled
|
||||||
|
// button with no explanation.
|
||||||
|
//
|
||||||
|
// Wrapping the disabled button in a `<span title=...>` makes
|
||||||
|
// the tooltip target a non-disabled ancestor that does receive
|
||||||
|
// mouseover, so the tooltip renders everywhere. The span also
|
||||||
|
// serves as a single mounting point for `aria-label` /
|
||||||
|
// `aria-disabled` if a screen reader needs richer signalling
|
||||||
|
// later.
|
||||||
|
//
|
||||||
|
// The minor problem it also solves:
|
||||||
|
//
|
||||||
|
// Five list pages had near-identical
|
||||||
|
// `READ_ONLY_TITLE = "Read-only — your role can't ..."`
|
||||||
|
// constants. GatedButton takes a single `gateReason` prop
|
||||||
|
// and centralises the tooltip wording (with per-action
|
||||||
|
// defaults).
|
||||||
|
//
|
||||||
|
// Use it like:
|
||||||
|
//
|
||||||
|
// <GatedButton
|
||||||
|
// canAct={canCreate}
|
||||||
|
// gateReason="create broadcasts"
|
||||||
|
// onClick={() => router.push("/broadcasts/new")}
|
||||||
|
// >
|
||||||
|
// <Plus className="h-4 w-4" /> New Broadcast
|
||||||
|
// </GatedButton>
|
||||||
|
//
|
||||||
|
// `canAct` defaults to true so unrelated usages still work.
|
||||||
|
// When `canAct` is false, the button is `disabled` and the
|
||||||
|
// wrapping span gets a `title` of `"Read-only — your role
|
||||||
|
// can't ${gateReason}"`.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import type { ComponentProps, ReactNode } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface GatedButtonProps extends Omit<ComponentProps<typeof Button>, "title"> {
|
||||||
|
/** False → button is disabled and the wrapper span shows the
|
||||||
|
* "Read-only" tooltip. Defaults to `true` so a `<GatedButton>`
|
||||||
|
* without the prop is just a Button. */
|
||||||
|
canAct?: boolean;
|
||||||
|
/** Verb phrase that completes the sentence
|
||||||
|
* `"Read-only — your role can't <gateReason>"`. Provided
|
||||||
|
* per-call so each CTA can name what it does ("create flows",
|
||||||
|
* "send messages", "add contacts"). */
|
||||||
|
gateReason?: string;
|
||||||
|
/** Optional fallback title for the non-gated case. */
|
||||||
|
title?: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GatedButton({
|
||||||
|
canAct = true,
|
||||||
|
gateReason,
|
||||||
|
title,
|
||||||
|
disabled,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}: GatedButtonProps) {
|
||||||
|
const effectivelyDisabled = disabled || !canAct;
|
||||||
|
const tooltip = !canAct && gateReason
|
||||||
|
? `Read-only — your role can't ${gateReason}`
|
||||||
|
: title;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
// `inline-flex` so the span sizes to the button and doesn't
|
||||||
|
// collapse to zero width / break inline layouts. `title`
|
||||||
|
// here (not on the button) is what makes the tooltip work
|
||||||
|
// in Safari / older Firefox — those browsers don't fire
|
||||||
|
// mouseover on disabled buttons.
|
||||||
|
className={cn("inline-flex", !canAct && "cursor-not-allowed")}
|
||||||
|
title={tooltip}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
disabled={effectivelyDisabled}
|
||||||
|
className={className}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -54,5 +54,14 @@ export function useCan(action: CanAction): boolean {
|
|||||||
return canDeleteAccount(accountRole);
|
return canDeleteAccount(accountRole);
|
||||||
case "transfer-ownership":
|
case "transfer-ownership":
|
||||||
return canTransferOwnership(accountRole);
|
return canTransferOwnership(accountRole);
|
||||||
|
default: {
|
||||||
|
// Exhaustiveness check — adding a new `CanAction` without a
|
||||||
|
// case here fails the typecheck because TS narrows `action`
|
||||||
|
// to `never` in this branch. The runtime throw is unreachable
|
||||||
|
// for valid inputs; it only fires if someone bypasses the
|
||||||
|
// type system at the call site (e.g. with a wrong-typed cast).
|
||||||
|
const _exhaustive: never = action;
|
||||||
|
throw new Error(`Unknown CanAction: ${String(_exhaustive)}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user