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:
parent
6379d8e2e2
commit
118b23bfb1
2 changed files with 215 additions and 0 deletions
89
apps/main/src/hooks/client_invites.ts
Normal file
89
apps/main/src/hooks/client_invites.ts
Normal 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 }
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue