fix: restore standard tablo invite flow

This commit is contained in:
Arthur Belleville 2026-04-16 08:07:08 +02:00
parent 84d94c49e9
commit b1c1c595ab
No known key found for this signature in database
2 changed files with 223 additions and 107 deletions

View file

@ -1,4 +1,5 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../utils/testHelpers";
import { TabloDetailsPage } from "./tablo-details";
@ -195,4 +196,20 @@ describe("TabloDetailsPage overview layout", () => {
expect(screen.getByRole("button", { name: "Modifier la mise en page" })).toBeInTheDocument();
});
it("uses the standard email invite UI in the share dialog", async () => {
const user = userEvent.setup();
renderWithProviders(<TabloDetailsPage />, {
route: "/tablos/tablo-1",
path: "/tablos/:tabloId",
});
await user.click(screen.getByRole("button", { name: "Inviter" }));
expect(screen.getByText("Inviter un utilisateur")).toBeInTheDocument();
expect(screen.getByPlaceholderText("Email de l'utilisateur à inviter")).toBeInTheDocument();
expect(screen.queryByText("Accès client")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Envoyer le lien" })).not.toBeInTheDocument();
});
});

View file

@ -186,6 +186,9 @@ const TABS: {
{ id: "roadmap", label: "Roadmap", icon: MapIcon },
];
// Temporary rollback until the client portal invite flow is ready to be used again.
const USE_CLIENT_MAGIC_LINK_INVITES = false;
// ─── Page ─────────────────────────────────────────────────────────────────────
export const TabloDetailsPage = () => {
@ -202,6 +205,7 @@ export const TabloDetailsPage = () => {
);
const [showAllOverviewTasks, setShowAllOverviewTasks] = useState(false);
const [isShareDialogOpen, setIsShareDialogOpen] = useState(false);
const [inviteEmail, setInviteEmail] = useState("");
const [clientInviteEmail, setClientInviteEmail] = useState("");
const [isLayoutEditMode, setIsLayoutEditMode] = useState(false);
const [draggedOverviewBlock, setDraggedOverviewBlock] = useState<{
@ -255,6 +259,13 @@ export const TabloDetailsPage = () => {
(member) => !pendingInvites?.some((invite) => invite.invited_email === member.email)
);
const handleSendInvite = () => {
if (!tabloId || !inviteEmail.trim()) return;
inviteUser({ email: inviteEmail, tablo_id: tabloId });
setInviteEmail("");
};
const openTaskModal = (dueDate?: Date) => {
setTaskModalInitialDueDate(dueDate ? new Date(dueDate) : undefined);
setIsTaskModalOpen(true);
@ -1002,117 +1013,205 @@ export const TabloDetailsPage = () => {
{/* Separator */}
<div className="border-t border-border pt-4">
{/* Client Access Section */}
<div className="mb-3">
<h4 className="text-sm font-semibold text-foreground">Accès client</h4>
<p className="text-xs text-muted-foreground">
Invitez des clients externes via un lien magique
</p>
</div>
{/* Client Invite Input */}
<div className="flex flex-col sm:flex-row gap-2">
<Input
type="email"
value={clientInviteEmail}
onChange={(e) => setClientInviteEmail(e.target.value)}
placeholder="Email du client"
className="flex-1 min-h-[44px]"
/>
{isCreatingClientInvite ? (
<div className="flex justify-center items-center px-4">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary" />
{USE_CLIENT_MAGIC_LINK_INVITES ? (
<>
<div className="mb-3">
<h4 className="text-sm font-semibold text-foreground">Accès client</h4>
<p className="text-xs text-muted-foreground">
Invitez des clients externes via un lien magique
</p>
</div>
) : (
<Button
type="button"
onClick={() => {
if (tabloId && clientInviteEmail) {
createClientInvite(
{ tabloId, email: clientInviteEmail },
{ onSuccess: () => setClientInviteEmail("") }
);
}
}}
disabled={!isEmailValid(clientInviteEmail)}
>
Envoyer le lien
</Button>
)}
</div>
{/* Pending Client Invites */}
{pendingClientInvites && pendingClientInvites.length > 0 && (
<div className="mt-3">
<h4 className="text-sm font-semibold text-foreground mb-2">
Invitations client en attente ({pendingClientInvites.length})
</h4>
<div className="space-y-2 max-h-32 overflow-y-auto">
{pendingClientInvites.map((invite) => {
const daysUntilExpiry = Math.ceil(
(new Date(invite.expires_at).getTime() - Date.now()) / (1000 * 60 * 60 * 24)
);
const isExpiringSoon = daysUntilExpiry < 5;
return (
<div
key={invite.id}
className="flex items-center space-x-2 p-2 bg-blue-50 dark:bg-blue-950/20 rounded-lg border border-dashed border-blue-200 dark:border-blue-900/50"
>
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center text-blue-600 dark:text-blue-400 text-xs flex-shrink-0">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
<div className="flex flex-col sm:flex-row gap-2">
<Input
type="email"
value={clientInviteEmail}
onChange={(e) => setClientInviteEmail(e.target.value)}
placeholder="Email du client"
className="flex-1 min-h-[44px]"
/>
{isCreatingClientInvite ? (
<div className="flex justify-center items-center px-4">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary" />
</div>
) : (
<Button
type="button"
onClick={() => {
if (tabloId && clientInviteEmail) {
createClientInvite(
{ tabloId, email: clientInviteEmail },
{ onSuccess: () => setClientInviteEmail("") }
);
}
}}
disabled={!isEmailValid(clientInviteEmail)}
>
Envoyer le lien
</Button>
)}
</div>
{pendingClientInvites && pendingClientInvites.length > 0 && (
<div className="mt-3">
<h4 className="text-sm font-semibold text-foreground mb-2">
Invitations client en attente ({pendingClientInvites.length})
</h4>
<div className="space-y-2 max-h-32 overflow-y-auto">
{pendingClientInvites.map((invite) => {
const daysUntilExpiry = Math.ceil(
(new Date(invite.expires_at).getTime() - Date.now()) /
(1000 * 60 * 60 * 24)
);
const isExpiringSoon = daysUntilExpiry < 5;
return (
<div
key={invite.id}
className="flex items-center space-x-2 p-2 bg-blue-50 dark:bg-blue-950/20 rounded-lg border border-dashed border-blue-200 dark:border-blue-900/50"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
/>
</svg>
</div>
<div className="flex-1 min-w-0">
<span className="text-xs font-medium text-foreground truncate block">
{invite.invited_email}
</span>
<span
className={`text-xs ${
isExpiringSoon
? "text-orange-600 dark:text-orange-400 font-medium"
: "text-muted-foreground"
}`}
>
{isExpiringSoon && "⚠ "}
Expire dans {daysUntilExpiry} jour{daysUntilExpiry !== 1 ? "s" : ""}
</span>
</div>
{isExpiringSoon && (
<span className="text-xs px-1.5 py-0.5 bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 rounded font-medium flex-shrink-0">
Bientôt expiré
</span>
)}
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 flex-shrink-0"
onClick={() =>
cancelClientInvite({
tabloId: tabloId ?? "",
inviteId: invite.id,
})
}
disabled={isCancellingClientInvite || !tabloId}
title="Annuler l'invitation"
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/30 rounded-full flex items-center justify-center text-blue-600 dark:text-blue-400 text-xs flex-shrink-0">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
/>
</svg>
</div>
<div className="flex-1 min-w-0">
<span className="text-xs font-medium text-foreground truncate block">
{invite.invited_email}
</span>
<span
className={`text-xs ${
isExpiringSoon
? "text-orange-600 dark:text-orange-400 font-medium"
: "text-muted-foreground"
}`}
>
{isExpiringSoon && "⚠ "}
Expire dans {daysUntilExpiry} jour
{daysUntilExpiry !== 1 ? "s" : ""}
</span>
</div>
{isExpiringSoon && (
<span className="text-xs px-1.5 py-0.5 bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 rounded font-medium flex-shrink-0">
Bientôt expiré
</span>
)}
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 flex-shrink-0"
onClick={() =>
cancelClientInvite({
tabloId: tabloId ?? "",
inviteId: invite.id,
})
}
disabled={isCancellingClientInvite || !tabloId}
title="Annuler l'invitation"
>
<XIcon className="w-3.5 h-3.5" />
</Button>
</div>
);
})}
</div>
</div>
)}
</>
) : (
<>
<div className="mb-3">
<h4 className="text-sm font-semibold text-foreground">Inviter un utilisateur</h4>
<p className="text-xs text-muted-foreground">
Utilisez le système d'invitation standard par email pour le moment
</p>
</div>
<div className="flex flex-col sm:flex-row gap-2">
<Input
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="Email de l'utilisateur à inviter"
className="flex-1 min-h-[44px]"
/>
{isInvitingUser ? (
<div className="flex justify-center items-center px-4">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-primary" />
</div>
) : (
<Button
type="button"
onClick={handleSendInvite}
disabled={!isEmailValid(inviteEmail)}
>
Inviter
</Button>
)}
</div>
{pendingInvites && pendingInvites.length > 0 && (
<div className="mt-3">
<h4 className="text-sm font-semibold text-foreground mb-2">
Invitations en attente ({pendingInvites.length})
</h4>
<div className="space-y-2 max-h-32 overflow-y-auto">
{pendingInvites.map((invite) => (
<div
key={invite.id}
className="flex items-center space-x-2 p-2 bg-orange-50 dark:bg-orange-950/20 rounded-lg border border-dashed border-orange-200 dark:border-orange-900/50"
>
<XIcon className="w-3.5 h-3.5" />
</Button>
</div>
);
})}
</div>
</div>
<div className="w-8 h-8 bg-orange-100 dark:bg-orange-900/30 rounded-full flex items-center justify-center text-orange-600 dark:text-orange-400 text-xs flex-shrink-0">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
</div>
<div className="flex-1 min-w-0">
<span className="text-xs font-medium text-foreground truncate block">
{invite.invited_email}
</span>
<span className="text-xs text-muted-foreground">(En attente)</span>
</div>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 flex-shrink-0"
onClick={() =>
cancelInvite({
tabloId: tabloId ?? "",
inviteId: invite.id,
})
}
disabled={isCancellingInvite || !tabloId}
title="Retirer l'invitation"
>
<XIcon className="w-3.5 h-3.5" />
</Button>
</div>
))}
</div>
</div>
)}
</>
)}
</div>
</div>