mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
Merge pull request #179 from ArnasDon/feat/account-sharing-ui-blocking-fixes
feat(ui): user-facing fixes from multi-user review (M3, M4, S3)
This commit is contained in:
commit
d56c4176b5
@ -26,7 +26,14 @@ import { useCallback, useEffect, useState } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { CheckCircle, Loader2, MailX, ShieldCheck, UsersRound } from 'lucide-react';
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle,
|
||||||
|
Loader2,
|
||||||
|
MailX,
|
||||||
|
ShieldCheck,
|
||||||
|
UsersRound,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@ -36,6 +43,14 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
import { createClient } from '@/lib/supabase/client';
|
import { createClient } from '@/lib/supabase/client';
|
||||||
|
|
||||||
interface PeekOk {
|
interface PeekOk {
|
||||||
@ -87,6 +102,12 @@ export default function JoinPage() {
|
|||||||
undefined, // undefined = unknown / still loading; null = signed out
|
undefined, // undefined = unknown / still loading; null = signed out
|
||||||
);
|
);
|
||||||
const [accepting, setAccepting] = useState(false);
|
const [accepting, setAccepting] = useState(false);
|
||||||
|
// `redeem_invitation` returns 409 when the caller's current account
|
||||||
|
// has domain data, or they're already a member of a shared account.
|
||||||
|
// A transient toast wasn't enough — the user has no actionable next
|
||||||
|
// step. Surface a blocking modal that walks them through it.
|
||||||
|
const [conflictMessage, setConflictMessage] = useState<string | null>(null);
|
||||||
|
const [signingOut, setSigningOut] = useState(false);
|
||||||
|
|
||||||
// Fetch peek + auth state on mount. The peek endpoint is
|
// Fetch peek + auth state on mount. The peek endpoint is
|
||||||
// rate-limited per-IP (30/min) so double-mounting in React 19
|
// rate-limited per-IP (30/min) so double-mounting in React 19
|
||||||
@ -130,7 +151,19 @@ export default function JoinPage() {
|
|||||||
const payload = (await res.json().catch(() => ({}))) as {
|
const payload = (await res.json().catch(() => ({}))) as {
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
// 409 = caller already has data / is in another shared
|
||||||
|
// account. The redeem RPC's error message is descriptive
|
||||||
|
// enough to show directly; we open a modal so the user has
|
||||||
|
// a clear next-action (sign out → use different email)
|
||||||
|
// rather than a 3-second toast.
|
||||||
|
if (res.status === 409) {
|
||||||
|
setConflictMessage(
|
||||||
|
payload.error ||
|
||||||
|
'You are already in another account. Sign in with a different email to join this one.',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
toast.error(payload.error || 'Failed to accept invitation');
|
toast.error(payload.error || 'Failed to accept invitation');
|
||||||
|
}
|
||||||
setAccepting(false);
|
setAccepting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -145,6 +178,21 @@ export default function JoinPage() {
|
|||||||
}
|
}
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
|
const handleSignOutAndRetry = useCallback(async () => {
|
||||||
|
setSigningOut(true);
|
||||||
|
try {
|
||||||
|
await createClient().auth.signOut();
|
||||||
|
// Hard reload so the new auth state propagates everywhere
|
||||||
|
// (middleware, AuthProvider). Preserves the invite token in
|
||||||
|
// the URL so the rebuilt page renders the signed-out CTA path.
|
||||||
|
window.location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[join] sign-out error:', err);
|
||||||
|
toast.error('Could not sign out. Try refreshing the page.');
|
||||||
|
setSigningOut(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// ----- Loading state (peek pending OR auth not yet resolved) -----
|
// ----- Loading state (peek pending OR auth not yet resolved) -----
|
||||||
if (peek === null || authedUserId === undefined) {
|
if (peek === null || authedUserId === undefined) {
|
||||||
return (
|
return (
|
||||||
@ -220,6 +268,7 @@ export default function JoinPage() {
|
|||||||
// ----- Authed: show Accept button -----
|
// ----- Authed: show Accept button -----
|
||||||
if (authedUserId) {
|
if (authedUserId) {
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Card className="w-full max-w-md border-slate-800 bg-slate-900">
|
<Card className="w-full max-w-md border-slate-800 bg-slate-900">
|
||||||
{inviteHeader}
|
{inviteHeader}
|
||||||
<CardContent className="flex flex-col gap-3">
|
<CardContent className="flex flex-col gap-3">
|
||||||
@ -247,6 +296,62 @@ export default function JoinPage() {
|
|||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Conflict modal — opens when the redeem endpoint returns 409
|
||||||
|
(caller already in a shared account or has domain data).
|
||||||
|
Blocks the flow until the user picks a recovery action so
|
||||||
|
they aren't stuck retrying an inevitable failure. */}
|
||||||
|
<Dialog
|
||||||
|
open={conflictMessage !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setConflictMessage(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="bg-slate-900 border-slate-700 sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-white">
|
||||||
|
<AlertTriangle className="size-4 text-amber-400" />
|
||||||
|
Can't join {peek.account_name} with this account
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-slate-400">
|
||||||
|
{conflictMessage}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-2 py-2 text-xs text-slate-500">
|
||||||
|
<p>
|
||||||
|
To join{' '}
|
||||||
|
<span className="text-slate-300">{peek.account_name}</span>,
|
||||||
|
sign out and sign up again with a different email address.
|
||||||
|
The invite link stays valid as long as it hasn't
|
||||||
|
expired.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="bg-slate-900 border-slate-700">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setConflictMessage(null)}
|
||||||
|
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
Stay signed in
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSignOutAndRetry}
|
||||||
|
disabled={signingOut}
|
||||||
|
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
{signingOut ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Signing out…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Sign out & use a different email'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -36,6 +36,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
import { useAuth } from '@/hooks/use-auth';
|
||||||
|
|
||||||
type InviteRole = 'admin' | 'agent' | 'viewer';
|
type InviteRole = 'admin' | 'agent' | 'viewer';
|
||||||
|
|
||||||
@ -65,6 +66,9 @@ interface CreatedInvite {
|
|||||||
url: string;
|
url: string;
|
||||||
role: InviteRole;
|
role: InviteRole;
|
||||||
expiresInDays: number;
|
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({
|
export function InviteMemberDialog({
|
||||||
@ -72,6 +76,7 @@ export function InviteMemberDialog({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
onCreated,
|
onCreated,
|
||||||
}: InviteMemberDialogProps) {
|
}: InviteMemberDialogProps) {
|
||||||
|
const { account } = useAuth();
|
||||||
const [role, setRole] = useState<InviteRole>('agent');
|
const [role, setRole] = useState<InviteRole>('agent');
|
||||||
const [expiry, setExpiry] = useState<string>('7');
|
const [expiry, setExpiry] = useState<string>('7');
|
||||||
const [label, setLabel] = useState('');
|
const [label, setLabel] = useState('');
|
||||||
@ -110,7 +115,17 @@ export function InviteMemberDialog({
|
|||||||
expiresInDays: number;
|
expiresInDays: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
setResult({ url: data.url, role, expiresInDays: data.expiresInDays });
|
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();
|
onCreated();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[InviteMemberDialog] create error:', err);
|
console.error('[InviteMemberDialog] create error:', err);
|
||||||
@ -134,7 +149,12 @@ export function InviteMemberDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function whatsappShareUrl(url: string): string {
|
function whatsappShareUrl(url: string): string {
|
||||||
const message = `Join our wacrm account using this link (valid for ${result?.expiresInDays} days): ${url}`;
|
// 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 `https://wa.me/?text=${encodeURIComponent(message)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -287,8 +287,14 @@ export function MembersTab() {
|
|||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={member.user_id}
|
key={member.user_id}
|
||||||
className="flex items-center gap-4 px-4 py-3"
|
// Mobile: stack identity (avatar+name+email) above the
|
||||||
|
// role/remove actions so the role dropdown's fixed
|
||||||
|
// 128px width doesn't force the name into a 50-pixel
|
||||||
|
// truncation. Desktop (sm+): everything inline as
|
||||||
|
// before.
|
||||||
|
className="flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:gap-4"
|
||||||
>
|
>
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-4">
|
||||||
<Avatar className="size-9 shrink-0">
|
<Avatar className="size-9 shrink-0">
|
||||||
{member.avatar_url ? (
|
{member.avatar_url ? (
|
||||||
<AvatarImage
|
<AvatarImage
|
||||||
@ -320,11 +326,19 @@ export function MembersTab() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Joined date stays desktop-only. The mobile row's
|
||||||
|
vertical density makes the joined date noise. */}
|
||||||
<div className="hidden sm:block text-right text-xs text-slate-500">
|
<div className="hidden sm:block text-right text-xs text-slate-500">
|
||||||
Joined {fmtDate(member.joined_at)}
|
Joined {fmtDate(member.joined_at)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Actions cluster. On mobile this is its own row
|
||||||
|
below the identity block; on desktop it sits
|
||||||
|
inline. Items align to the start on mobile so the
|
||||||
|
role dropdown lines up under the avatar. */}
|
||||||
|
<div className="flex items-center gap-2 sm:gap-3">
|
||||||
{/* Role display / editor. Inline Select is admin+
|
{/* Role display / editor. Inline Select is admin+
|
||||||
only AND not allowed on the owner row (owner
|
only AND not allowed on the owner row (owner
|
||||||
changes go through transfer, which lands later). */}
|
changes go through transfer, which lands later). */}
|
||||||
@ -373,6 +387,7 @@ export function MembersTab() {
|
|||||||
<Trash2 className="size-4" />
|
<Trash2 className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user