mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
feat(ui): front-end correctness pass from multi-user review (S1,S2,S6-S8,P8,P9)
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`)
- `<Button disabled title="...">` doesn't render a tooltip in
Safari or older Firefox: those browsers don't fire mouse events
on disabled controls, so the title never surfaces. Viewers on
those browsers saw silently-disabled CTAs with no explanation.
- New `<GatedButton canAct={...} gateReason="...">` wraps the
Button in a non-disabled `<span>` that owns the `title`. The
span receives mouseover everywhere; the tooltip works.
- Also subsumes the duplicated `READ_ONLY_TITLE = "..."` constants
across 5 list pages. `gateReason` is the verb phrase that
completes "Read-only — your role can't <gateReason>".
- Migrated call sites: broadcasts / automations / flows / contacts
/ pipelines list pages + inbox composer (Send + Templates
buttons). The composer's textarea keeps its inline title since
the GatedButton pattern only applies to buttons.
S6 — Revert optimistic role-change on error (`members-tab.tsx`)
- Pre-fix: PATCH failure showed a toast and left the local
member-row state with the new role. Next interaction
(re-toggling to the same role) was a no-op via the
`member.role === nextRole` guard at the top, so the row
appeared stuck. Now we capture `previousRole` before the
optimistic flip and revert on both 4xx and network failure.
S7 — Sidebar flag check matches settings (`sidebar.tsx`)
- Pre-fix: sidebar's `accountSharingEnabled` didn't guard on
`profileLoading`, so the account-name strip briefly was hidden
during the initial profile fetch then popped in. Settings page
did this guard correctly. Now they match.
S8 — Referrer-Policy: no-referrer on /join (`join/layout.tsx`)
- The plaintext invite token lives in the URL path. Belt-and-
braces against any future external resource on the join page
leaking the token via the Referer header. Set via Next 16's
`metadata` export → `<meta name="referrer" content="no-referrer">`.
Also marks the route `noindex, nofollow` so a join URL can't end
up in search results.
P9 — Client-side label length validation (`invite-member-dialog.tsx`)
- Server caps `label` at 80 chars and returns 400 on overflow.
Add a `MAX_LABEL_LEN` constant on the client, point both the
`<Input maxLength>` and the pre-submit check at it. Catches the
paste-then-submit path where a paste lands an over-limit string
in state before the keystroke-level cap engages.
Verification
- `npm run lint` → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean (the new `never` cast catches typos)
- `npm test` → 366/366 pass
- `npm run build` → succeeds
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b4901c2aae
commit
cbcad71fda
@ -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<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")
|
||||
@ -167,15 +165,15 @@ export default function AutomationsPage() {
|
||||
Build workflows that react to WhatsApp® events automatically.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
<GatedButton
|
||||
canAct={canCreate}
|
||||
gateReason="create automations"
|
||||
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" />
|
||||
Create Automation
|
||||
</Button>
|
||||
</GatedButton>
|
||||
</div>
|
||||
|
||||
{showTemplates && (
|
||||
|
||||
@ -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.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
<GatedButton
|
||||
canAct={canCreate}
|
||||
gateReason="create broadcasts"
|
||||
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" />
|
||||
New Broadcast
|
||||
</Button>
|
||||
</GatedButton>
|
||||
</div>
|
||||
|
||||
{broadcasts.length === 0 ? (
|
||||
@ -207,15 +202,15 @@ export default function BroadcastsPage() {
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
Create your first broadcast to reach your contacts at scale.
|
||||
</p>
|
||||
<Button
|
||||
<GatedButton
|
||||
canAct={canCreate}
|
||||
gateReason="create broadcasts"
|
||||
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" />
|
||||
New Broadcast
|
||||
</Button>
|
||||
</GatedButton>
|
||||
</div>
|
||||
) : (
|
||||
<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 { 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() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
<GatedButton
|
||||
variant="outline"
|
||||
canAct={canEdit}
|
||||
gateReason="add or import contacts"
|
||||
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" />
|
||||
Import
|
||||
</Button>
|
||||
<Button
|
||||
</GatedButton>
|
||||
<GatedButton
|
||||
canAct={canEdit}
|
||||
gateReason="add or import contacts"
|
||||
onClick={openAddForm}
|
||||
disabled={!canEdit}
|
||||
title={canEdit ? undefined : READ_ONLY_TITLE}
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Add Contact
|
||||
</Button>
|
||||
</GatedButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -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.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
<GatedButton
|
||||
canAct={canCreate}
|
||||
gateReason="create flows"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
disabled={!canCreate}
|
||||
title={canCreate ? undefined : READ_ONLY_TITLE}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
New flow
|
||||
</Button>
|
||||
</GatedButton>
|
||||
</header>
|
||||
|
||||
{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).
|
||||
</p>
|
||||
<Button
|
||||
<GatedButton
|
||||
canAct={canCreate}
|
||||
gateReason="create flows"
|
||||
onClick={onCreate}
|
||||
disabled={!canCreate}
|
||||
title={canCreate ? undefined : READ_ONLY_TITLE}
|
||||
className="mt-5"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create your first flow
|
||||
</Button>
|
||||
</GatedButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
<GatedButton
|
||||
variant="outline"
|
||||
canAct={canEditSettings}
|
||||
gateReason="create pipelines"
|
||||
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" />
|
||||
Add Pipeline
|
||||
</Button>
|
||||
<Button
|
||||
</GatedButton>
|
||||
<GatedButton
|
||||
canAct={canCreateDeals}
|
||||
gateReason="create deals"
|
||||
disabled={!selectedPipelineId || stages.length === 0}
|
||||
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"
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Add Deal
|
||||
</Button>
|
||||
</GatedButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -390,15 +388,15 @@ export default function PipelinesPage() {
|
||||
<p className="mt-2 text-sm text-slate-400">
|
||||
Create a pipeline to start tracking deals
|
||||
</p>
|
||||
<Button
|
||||
<GatedButton
|
||||
canAct={canEditSettings}
|
||||
gateReason="create pipelines"
|
||||
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" />
|
||||
Create Pipeline
|
||||
</Button>
|
||||
</GatedButton>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@ -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 `<meta name="referrer" content="no-referrer">`.
|
||||
// ============================================================
|
||||
|
||||
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 (
|
||||
<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 { 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({
|
||||
)}
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<Button
|
||||
<GatedButton
|
||||
variant="ghost"
|
||||
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"
|
||||
onClick={onOpenTemplates}
|
||||
disabled={readOnly}
|
||||
title={readOnly ? "Read-only — your role can't send messages" : "Send template"}
|
||||
>
|
||||
<LayoutTemplate className="h-4 w-4" />
|
||||
</Button>
|
||||
</GatedButton>
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
@ -136,6 +138,9 @@ export function MessageComposer({
|
||||
}
|
||||
disabled={sessionExpired || readOnly}
|
||||
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}
|
||||
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",
|
||||
@ -143,15 +148,16 @@ export function MessageComposer({
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
<GatedButton
|
||||
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 || readOnly}
|
||||
canAct={!readOnly}
|
||||
gateReason="send messages"
|
||||
disabled={!text.trim() || sessionExpired || sending}
|
||||
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" />
|
||||
</Button>
|
||||
</GatedButton>
|
||||
</div>
|
||||
|
||||
{/* 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) {
|
||||
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.
|
||||
|
||||
@ -61,6 +61,11 @@ const ROLE_DESCRIPTIONS: Record<InviteRole, string> = {
|
||||
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;
|
||||
@ -87,6 +92,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', {
|
||||
@ -95,7 +111,7 @@ export function InviteMemberDialog({
|
||||
body: JSON.stringify({
|
||||
role,
|
||||
expiresInDays: Number(expiry),
|
||||
label: label.trim() || undefined,
|
||||
label: trimmedLabel || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
@ -282,7 +298,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"
|
||||
/>
|
||||
<p className="text-xs text-slate-500">
|
||||
|
||||
@ -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 {
|
||||
|
||||
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);
|
||||
case "transfer-ownership":
|
||||
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