feat(main): add client invite UI to share dialog

Adds three React Query hooks (usePendingClientInvites, useCreateClientInvite, useCancelClientInvite) and a new Client Access section in the share dialog with email input, pending invite list, expiry countdown, and orange warning badge for invites expiring in less than 5 days.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Arthur Belleville 2026-04-15 14:18:27 +02:00
parent 6379d8e2e2
commit 118b23bfb1
No known key found for this signature in database
2 changed files with 215 additions and 0 deletions

View file

@ -0,0 +1,89 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast, useSession } from "@xtablo/shared";
import { useAuthedApi } from "./auth";
type PendingClientInvite = {
id: number;
invited_email: string;
expires_at: string;
is_pending: boolean;
created_at: string;
};
export const usePendingClientInvites = (tabloId: string) => {
const api = useAuthedApi();
const { session } = useSession();
return useQuery({
queryKey: ["client-invites", tabloId],
queryFn: async () => {
const { data } = await api.get<PendingClientInvite[]>(
`/api/v1/client-invites/${tabloId}/pending`
);
return data;
},
enabled: !!tabloId && !!session,
});
};
export const useCreateClientInvite = () => {
const api = useAuthedApi();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ tabloId, email }: { tabloId: string; email: string }) => {
const { data } = await api.post<PendingClientInvite>(
`/api/v1/client-invites/${tabloId}`,
{ email }
);
return data;
},
onSuccess: (_data, { tabloId }) => {
queryClient.invalidateQueries({ queryKey: ["client-invites", tabloId] });
toast.add(
{
title: "Lien magique envoyé",
description: "L'invitation client a été envoyée avec succès",
type: "success",
},
{ timeout: 3000 }
);
},
onError: (error) => {
console.error("Error creating client invite:", error);
toast.add(
{
title: "Erreur",
description: "Impossible d'envoyer l'invitation client",
type: "error",
},
{ timeout: 5000 }
);
},
});
};
export const useCancelClientInvite = () => {
const api = useAuthedApi();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ tabloId, inviteId }: { tabloId: string; inviteId: number }) => {
await api.delete(`/api/v1/client-invites/${tabloId}/${inviteId}`);
},
onSuccess: (_data, { tabloId }) => {
queryClient.invalidateQueries({ queryKey: ["client-invites", tabloId] });
},
onError: (error) => {
console.error("Error cancelling client invite:", error);
toast.add(
{
title: "Erreur",
description: "Impossible d'annuler l'invitation client",
type: "error",
},
{ timeout: 5000 }
);
},
});
};

View file

@ -34,6 +34,7 @@ import {
Sun,
UserPlusIcon,
Waves,
XIcon,
Zap,
} from "lucide-react";
import { useEffect, useState } from "react";
@ -52,6 +53,11 @@ import { useInviteUser } from "../hooks/invite";
import { useTabloFileNames, useDownloadTabloFile, useUploadTabloFile, useDeleteTabloFile } from "../hooks/tablo_data";
import { useTabloFolders, useCreateTabloFolder, useUpdateTabloFolder, useDeleteTabloFolder } from "../hooks/tablo_folders";
import { useCancelTabloInvite, usePendingTabloInvitesByTablo } from "../hooks/tablo_invites";
import {
usePendingClientInvites,
useCreateClientInvite,
useCancelClientInvite,
} from "../hooks/client_invites";
import {
useTabloMembers,
useTabloOverviewLayout,
@ -187,6 +193,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<{
zone: "left" | "right";
@ -200,6 +207,9 @@ export const TabloDetailsPage = () => {
const { data: pendingInvites } = usePendingTabloInvitesByTablo(tabloId ?? "");
const { mutate: cancelInvite, isPending: isCancellingInvite } = useCancelTabloInvite();
const { mutate: inviteUser, isPending: isInvitingUser } = useInviteUser();
const { data: pendingClientInvites } = usePendingClientInvites(tabloId ?? "");
const { mutate: createClientInvite, isPending: isCreatingClientInvite } = useCreateClientInvite();
const { mutate: cancelClientInvite, isPending: isCancellingClientInvite } = useCancelClientInvite();
const { mutate: updateTask } = useUpdateTask();
const { mutate: updateTablo, mutateAsync: updateTabloAsync } = useUpdateTablo();
const { mutate: createTask } = useCreateTask();
@ -1032,6 +1042,122 @@ export const TabloDetailsPage = () => {
</div>
</div>
)}
{/* 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" />
</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"
>
<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>
</div>
</DialogContent>
</Dialog>