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:
Arnas Donauskas 2026-05-31 18:36:39 +02:00 committed by GitHub
commit 4390b8b41f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 243 additions and 78 deletions

View File

@ -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 && (

View File

@ -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">

View File

@ -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>

View File

@ -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>
);
}

View File

@ -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>
) : (
<>

View File

@ -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">

View File

@ -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

View File

@ -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.

View File

@ -62,6 +62,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;
@ -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"
/>
<p className="text-xs text-slate-500">

View File

@ -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 {

View 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>
);
}

View File

@ -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)}`);
}
}
}