wacrm/src/components/settings/invite-member-dialog.tsx
Arnas Donauskas 4aa329b527
feat(theme): add light/dark mode toggle (#243)
Introduce a light/dark MODE axis orthogonal to the existing accent
themes. Neutral surface tokens now live in html[data-mode="light|dark"]
blocks; accent tokens (--primary*, --ring, --chart-1, --sidebar-primary*)
stay in html[data-theme] blocks, so any accent works in either mode.

- globals.css: split neutrals (mode) from accent (theme); add light
  palette; dark values unchanged (dark stays the default).
- lib/themes.ts: Mode type, DEFAULT_MODE=dark, MODE_STORAGE_KEY, isMode.
- use-theme.tsx: provider owns mode/setMode/toggleMode + cross-tab sync.
- layout.tsx: boot script applies persisted mode before first paint
  (no flash); colorScheme "dark light".
- mode-toggle.tsx (header sun/moon) + themed-toaster.tsx (mode-aware).
- appearance-panel.tsx: light/dark control + updated copy.
- Tokenize hardcoded neutral colors (slate/white/gray) -> semantic
  tokens across the app so every surface flips with mode. Flow canvas
  and message-bubble timestamps made mode-aware too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:29:46 +03:00

368 lines
13 KiB
TypeScript

'use client';
// ============================================================
// InviteMemberDialog
//
// Two-step modal:
// 1. Form — role + expiry + optional label → POST creates the invite.
// 2. Result — the share URL, returned ONCE. Copy-to-clipboard, plus a
// "Send via WhatsApp" deep link that pre-fills wa.me with
// a friendly message containing the URL.
//
// The plaintext token is server-stored only as a SHA-256 hash, so once
// the result step is dismissed the link is gone forever — the dialog
// shouts this in copy.
// ============================================================
import { useState } from 'react';
import { toast } from 'sonner';
import { Copy, Loader2, MessageCircle, Sparkles } from 'lucide-react';
import { Button, buttonVariants } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useAuth } from '@/hooks/use-auth';
type InviteRole = 'admin' | 'agent' | 'viewer';
interface InviteMemberDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Called after a successful create so the parent re-fetches the
* pending-invitations list. */
onCreated: () => void;
}
const EXPIRY_OPTIONS: { value: string; label: string }[] = [
{ value: '1', label: '1 day' },
{ value: '7', label: '7 days' },
{ value: '30', label: '30 days' },
];
const ROLE_DESCRIPTIONS: Record<InviteRole, string> = {
admin:
'Can invite teammates, manage settings, send messages, and edit data.',
agent:
'Can use the inbox, contacts, broadcasts, automations, and flows. No settings or member access.',
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;
expiresInDays: number;
/** Snapshotted at creation time so a later account rename can't
* retroactively change the wa.me message text on the result step. */
accountName: string;
}
export function InviteMemberDialog({
open,
onOpenChange,
onCreated,
}: InviteMemberDialogProps) {
const { account } = useAuth();
const [role, setRole] = useState<InviteRole>('agent');
const [expiry, setExpiry] = useState<string>('7');
const [label, setLabel] = useState('');
const [submitting, setSubmitting] = useState(false);
const [result, setResult] = useState<CreatedInvite | null>(null);
function reset() {
setRole('agent');
setExpiry('7');
setLabel('');
setResult(null);
setSubmitting(false);
}
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', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
role,
expiresInDays: Number(expiry),
label: trimmedLabel || undefined,
}),
});
if (!res.ok) {
const payload = await res.json().catch(() => ({}));
toast.error(payload.error || 'Failed to create invitation');
return;
}
const data = (await res.json()) as {
url: string;
expiresInDays: number;
};
setResult({
url: data.url,
role,
expiresInDays: data.expiresInDays,
// Snapshot the account name into the result so the wa.me
// share message has team context. Falls back to a generic
// string if `account` hasn't loaded yet (shouldn't happen
// — the dialog requires admin+ which requires a loaded
// profile — but stay safe).
accountName: account?.name ?? 'our wacrm account',
});
onCreated();
} catch (err) {
console.error('[InviteMemberDialog] create error:', err);
toast.error('Could not reach the server. Try again?');
} finally {
setSubmitting(false);
}
}
async function copyToClipboard() {
if (!result) return;
try {
await navigator.clipboard.writeText(result.url);
toast.success('Invite link copied');
} catch {
// Most likely "not in a secure context" — happens on http://
// local IPs. Surface the link in the toast so the admin can
// hand-copy it.
toast.error('Clipboard blocked — copy the link manually');
}
}
function whatsappShareUrl(url: string): string {
// Include the account name so the recipient knows which team
// they're being invited to before clicking through. This matters
// for users in multi-team contexts where "our wacrm account"
// wouldn't be enough to disambiguate.
const accountName = result?.accountName ?? 'our wacrm account';
const message = `Join ${accountName} on wacrm using this link (valid for ${result?.expiresInDays} days): ${url}`;
return `https://wa.me/?text=${encodeURIComponent(message)}`;
}
return (
<Dialog
open={open}
onOpenChange={(next) => {
// Reset state when the dialog closes — both for cancel and
// for dismissal after a successful create. The plaintext URL
// is intentionally NOT preserved across opens.
if (!next) reset();
onOpenChange(next);
}}
>
<DialogContent className="bg-popover border-border sm:max-w-md">
{result ? (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-popover-foreground">
<Sparkles className="size-4 text-primary" />
Invite created
</DialogTitle>
<DialogDescription className="text-muted-foreground">
Share this link with your new teammate. They&apos;ll be able
to sign up (or sign in) and join the account as{' '}
<span className="font-medium text-muted-foreground">{result.role}</span>
. The link is valid for{' '}
<span className="font-medium text-muted-foreground">
{result.expiresInDays} day{result.expiresInDays === 1 ? '' : 's'}
</span>
.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-2">
<Label className="text-muted-foreground">Invite link</Label>
<div className="flex gap-2">
<Input
readOnly
value={result.url}
className="bg-muted border-border text-foreground font-mono text-xs"
onFocus={(e) => e.currentTarget.select()}
/>
<Button
type="button"
onClick={copyToClipboard}
className="bg-primary hover:bg-primary/90 text-primary-foreground shrink-0"
>
<Copy className="size-4" />
Copy
</Button>
</div>
{/* Higher-contrast amber than the original 10% / amber-200.
Reviewed against slate-900 to meet WCAG AAA for body
text (target ratio 7:1). Border bumped to /50, bg to
/15, foreground promoted to amber-100 for the strong
intro, amber-200 for the body. */}
<div className="rounded-md border border-amber-500/50 bg-amber-500/15 px-3 py-2 text-xs text-amber-200">
<strong className="font-semibold text-amber-100">
Save this link now.
</strong>{' '}
We never store the plaintext — once you close this dialog
the URL is gone. To re-share, revoke this invite and create
a new one.
</div>
{/* Anchor styled with `buttonVariants` rather than wrapping
in <Button asChild>. The wacrm Button is the Base UI
ButtonPrimitive — it has no Radix-style asChild slot.
Direct anchor preserves right-click "Open in new tab"
behaviour too. */}
<a
href={whatsappShareUrl(result.url)}
target="_blank"
rel="noreferrer noopener"
className={buttonVariants({
variant: 'outline',
className:
'w-full border-border text-muted-foreground hover:bg-muted',
})}
>
<MessageCircle className="size-4" />
Send via WhatsApp
</a>
</div>
<DialogFooter className="bg-popover border-border">
<Button
onClick={() => onOpenChange(false)}
className="bg-primary hover:bg-primary/90 text-primary-foreground"
>
Done
</Button>
</DialogFooter>
</>
) : (
<>
<DialogHeader>
<DialogTitle className="text-popover-foreground">Invite a teammate</DialogTitle>
<DialogDescription className="text-muted-foreground">
Generate a one-time invite link. Share it via WhatsApp,
Slack, or any channel you like — no email service required.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label className="text-muted-foreground">Role</Label>
<Select
value={role}
onValueChange={(v) => v && setRole(v as InviteRole)}
>
<SelectTrigger className="w-full bg-muted border-border text-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="agent">Agent</SelectItem>
<SelectItem value="viewer">Viewer</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{ROLE_DESCRIPTIONS[role]}
</p>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">Link valid for</Label>
<Select
value={expiry}
onValueChange={(v) => v && setExpiry(v)}
>
<SelectTrigger className="w-full bg-muted border-border text-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent>
{EXPIRY_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">
Label{' '}
<span className="text-xs text-muted-foreground">(optional)</span>
</Label>
<Input
placeholder="e.g. Sara — support team"
value={label}
onChange={(e) => setLabel(e.target.value)}
maxLength={MAX_LABEL_LEN}
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
<p className="text-xs text-muted-foreground">
Helps you remember who you sent the link to in the pending
list below.
</p>
</div>
</div>
<DialogFooter className="bg-popover border-border">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="border-border text-muted-foreground hover:bg-muted"
>
Cancel
</Button>
<Button
onClick={handleCreate}
disabled={submitting}
className="bg-primary hover:bg-primary/90 text-primary-foreground"
>
{submitting ? (
<>
<Loader2 className="size-4 animate-spin" />
Creating...
</>
) : (
'Generate link'
)}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
}