fix: restore standard tablo invite flow
This commit is contained in:
parent
84d94c49e9
commit
b1c1c595ab
2 changed files with 223 additions and 107 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue