diff --git a/api/src/tablo.ts b/api/src/tablo.ts index 1a4352a..a45c1ac 100644 --- a/api/src/tablo.ts +++ b/api/src/tablo.ts @@ -367,6 +367,10 @@ tabloRouter.post("/invite/:tabloId", regularUserCheckMiddleware, checkTabloAdmin const { tabloId } = c.req.param(); const { email: recipientmail } = await c.req.json(); + if (sender.email === recipientmail) { + return c.json({ error: "You cannot invite yourself" }, 400); + } + // Get tablo name const { data: tablo, error: tabloError } = await supabase .from("tablos") @@ -459,7 +463,7 @@ tabloRouter.post("/invite/:tabloId", regularUserCheckMiddleware, checkTabloAdmin to: recipientmail, subject: "Vous avez été invité sur XTablo", html: ` -

Bonjour !

+

Bonjour !

${sender.email} vous a invité à rejoindre XTablo.

@@ -472,6 +476,13 @@ tabloRouter.post("/invite/:tabloId", regularUserCheckMiddleware, checkTabloAdmin

Veuillez cliquer sur le lien ci-dessous pour accepter l'invitation et configurer votre mot de passe.

Accepter et se connecter

+ +

Important : Pour des raisons de sécurité, nous vous recommandons fortement de changer ce mot de passe temporaire lors de votre première connexion.

+ +

+ Cordialement,
+ L'équipe XTablo +

`, }); @@ -480,20 +491,39 @@ tabloRouter.post("/invite/:tabloId", regularUserCheckMiddleware, checkTabloAdmin }); } + // Check if the user already has access to the tablo + const { data: existingAccess, error: existingAccessError } = await supabase + .from("tablo_access") + .select("id") + .eq("tablo_id", tabloId) + .eq("user_id", recipientUser.id) + .single(); + + if (existingAccessError) { + return c.json({ error: existingAccessError.message }, 500); + } + + if (existingAccess) { + return c.json({ message: "User already has access to this tablo" }, 400); + } + // Let the user know that they have been invited to the tablo await transporter.sendMail({ from: `${sender.email} via XTablo `, to: recipientmail, subject: "Vous avez été invité à un tablo", html: ` - ${introEmail ? `

${introEmail}

` : ""} -

Cliquez sur ce lien pour accepter l'invitation.

-
-

Cordialement.

+${introEmail ? `

${introEmail}

` : ""} +

Cliquez sur ce lien pour accepter l'invitation.

+
+

+ Cordialement,
+ L'équipe XTablo +

`, }); @@ -540,6 +570,12 @@ tabloRouter.post("/join", async (c) => { if (tabloAccessError) { console.error("tabloAccessError", tabloAccessError); + + // Check if it's a conflict error (user already has access) + if (tabloAccessError.code === "23505") { + return c.json({ error: "User already has access to this tablo" }, 409); + } + return c.json({ error: tabloAccessError.message }, 500); } @@ -577,7 +613,7 @@ tabloRouter.get("/members/:tablo_id", async (c) => { const { data, error } = await supabase .from("tablo_access") - .select("is_admin, profiles(id, name)") + .select("is_admin, profiles(id, name, email)") .eq("tablo_id", tablo_id) .eq("is_active", true); @@ -586,6 +622,7 @@ tabloRouter.get("/members/:tablo_id", async (c) => { profiles: { id: string; name: string; + email: string; }; }[]; @@ -597,6 +634,7 @@ tabloRouter.get("/members/:tablo_id", async (c) => { members: rows.map((member) => ({ ...member.profiles, is_admin: member.is_admin, + email: member.profiles.email, })), }); }); diff --git a/api/src/user.ts b/api/src/user.ts index 4cf86a3..85dcea4 100644 --- a/api/src/user.ts +++ b/api/src/user.ts @@ -125,7 +125,7 @@ L'équipe XTablo`,

Important : Pour des raisons de sécurité, nous vous recommandons fortement de changer ce mot de passe temporaire lors de votre première connexion.

- Se connecter à XTablo diff --git a/apps/main/src/components/TabloSettingsSection.tsx b/apps/main/src/components/TabloSettingsSection.tsx index 7adc359..e512c16 100644 --- a/apps/main/src/components/TabloSettingsSection.tsx +++ b/apps/main/src/components/TabloSettingsSection.tsx @@ -7,6 +7,7 @@ import { useUser } from "../providers/UserStoreProvider"; import { ClickOutside } from "./ClickOutside"; import { ImageColorPicker } from "./ImageColorPicker"; import { StatusPicker } from "./StatusPicker"; +import { usePendingTabloInvitesByTablo } from "src/hooks/tablo_invites"; type StatusType = "todo" | "in_progress" | "done"; @@ -23,12 +24,17 @@ export const TabloSettingsSection = ({ tablo, isAdmin, onEdit }: TabloSettingsSe const [creationMode, setCreationMode] = useState<"image" | "color">("color"); const [selectedColor, setSelectedColor] = useState(tablo.color || "bg-blue-500"); const { data: members } = useTabloMembers(tablo.id); + const { data: pendingInvites } = usePendingTabloInvitesByTablo(tablo.id); const [inviteEmail, setInviteEmail] = useState(""); const { mutate: inviteUser, isPending: isInvitingUser } = useInviteUser(); const nameInputRef = useRef(null); + const filteredMembers = members?.filter( + (member) => !pendingInvites?.some((invite) => invite.invited_email === member.email) + ); + useEffect(() => { setEditData(tablo); setSelectedColor(tablo.color || "bg-blue-500"); @@ -212,6 +218,47 @@ export const TabloSettingsSection = ({ tablo, isAdmin, onEdit }: TabloSettingsSe )} + {pendingInvites && pendingInvites.length > 0 && ( +

+

+ Invitations en attente + + ({pendingInvites.length}) + +

+ +
+ {pendingInvites.map((invite) => ( +
+
+ + + +
+
+ + {invite.invited_email} + + (En attente) +
+
+ ))} +
+
+ )} )} @@ -219,16 +266,16 @@ export const TabloSettingsSection = ({ tablo, isAdmin, onEdit }: TabloSettingsSe

Membres - {members && ( + {filteredMembers && ( - ({members.length}) + ({filteredMembers.length}) )}

- {members && members.length > 0 ? ( - members.map((member, index) => ( + {filteredMembers && filteredMembers.length > 0 ? ( + filteredMembers.map((member, index) => (
{member.name.charAt(0).toUpperCase()} @@ -252,6 +299,8 @@ export const TabloSettingsSection = ({ tablo, isAdmin, onEdit }: TabloSettingsSe )}
+ + {/* Pending Invites */}
); }; diff --git a/apps/main/src/hooks/invite.ts b/apps/main/src/hooks/invite.ts index 3abf2e9..a6dd2ca 100644 --- a/apps/main/src/hooks/invite.ts +++ b/apps/main/src/hooks/invite.ts @@ -1,9 +1,10 @@ -import { useMutation } from "@tanstack/react-query"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "@xtablo/shared"; import { useAuthedApi } from "./auth"; // Invite user by email export const useInviteUser = () => { + const queryClient = useQueryClient(); const api = useAuthedApi(); const { mutate, isPending } = useMutation({ mutationFn: async ({ email, tablo_id }: { email: string; tablo_id: string }) => { @@ -12,7 +13,7 @@ export const useInviteUser = () => { }); return data; }, - onSuccess: () => { + onSuccess: (_, { tablo_id }) => { toast.add( { title: "Invitation envoyée avec succès", @@ -23,6 +24,8 @@ export const useInviteUser = () => { timeout: 2000, } ); + queryClient.invalidateQueries({ queryKey: ["tablo-members", tablo_id] }); + queryClient.invalidateQueries({ queryKey: ["tablo-invites", tablo_id] }); }, }); return { mutate, isPending }; diff --git a/apps/main/src/hooks/tablo_invites.ts b/apps/main/src/hooks/tablo_invites.ts new file mode 100644 index 0000000..6445c8c --- /dev/null +++ b/apps/main/src/hooks/tablo_invites.ts @@ -0,0 +1,51 @@ +import { useQuery } from "@tanstack/react-query"; +import { Database } from "@xtablo/shared/types/database.types"; +import { supabase } from "../lib/supabase"; +import { useUser } from "../providers/UserStoreProvider"; + +type TabloInvite = Database["public"]["Tables"]["tablo_invites"]["Row"]; + +// Fetch all pending invites created by the current user +// export const usePendingTabloInvites = () => { +// const user = useUser(); + +// return useQuery({ +// queryKey: ["tablo-invites", "pending", user.id], +// queryFn: async () => { +// const { data, error } = await supabase +// .from("tablo_invites") +// .select("*") +// .eq("invited_by", user.id) +// .eq("is_pending", true) +// .order("created_at", { ascending: false }); + +// if (error) throw error; + +// return data as TabloInvite[]; +// }, +// enabled: !!user.id, +// }); +// }; + +// Fetch pending invites for a specific tablo +export const usePendingTabloInvitesByTablo = (tabloId: string) => { + const user = useUser(); + + return useQuery({ + queryKey: ["tablo-invites", tabloId], + queryFn: async () => { + const { data, error } = await supabase + .from("tablo_invites") + .select("*") + .eq("invited_by", user.id) + .eq("tablo_id", tabloId) + .eq("is_pending", true) + .order("created_at", { ascending: false }); + + if (error) throw error; + + return data as TabloInvite[]; + }, + enabled: !!user.id && !!tabloId, + }); +}; diff --git a/apps/main/src/hooks/tablos.ts b/apps/main/src/hooks/tablos.ts index ce3df78..845f60d 100644 --- a/apps/main/src/hooks/tablos.ts +++ b/apps/main/src/hooks/tablos.ts @@ -50,7 +50,7 @@ export const useTabloMembers = (tabloId: string) => { queryKey: ["tablo-members", tabloId], queryFn: async () => { const { data } = await api.get<{ - members: { id: string; name: string; is_admin: boolean }[]; + members: { id: string; name: string; is_admin: boolean; email: string }[]; }>(`/api/v1/tablos/members/${tabloId}`); return data.members; }, diff --git a/sql/10_create_tablo_access_table.sql b/sql/10_create_tablo_access_table.sql index c0a2d78..6880162 100644 --- a/sql/10_create_tablo_access_table.sql +++ b/sql/10_create_tablo_access_table.sql @@ -12,10 +12,6 @@ CREATE TABLE IF NOT EXISTS tablo_access ( CONSTRAINT fk_tablo_access_tablo_id FOREIGN KEY (tablo_id) REFERENCES tablos(id) ON DELETE CASCADE, - -- Unique constraint to prevent duplicate access records - CONSTRAINT unique_tablo_access - UNIQUE (tablo_id, user_id) - -- Foreign key constraint to users table (auth.users) CONSTRAINT fk_tablo_access_user_id FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE, diff --git a/sql/30_new_trigger_on_login.sql b/sql/30_new_trigger_on_login.sql index f700cd7..d47dfed 100644 --- a/sql/30_new_trigger_on_login.sql +++ b/sql/30_new_trigger_on_login.sql @@ -32,6 +32,9 @@ CREATE TRIGGER trigger_on_last_signed_in CREATE OR REPLACE FUNCTION public.update_tablo_invites_on_login() RETURNS TRIGGER AS $$ BEGIN + IF (NEW.last_sign_in_at IS NULL OR NEW.last_sign_in_at = OLD.last_sign_in_at) THEN + RETURN NULL; + ELSE -- Check if the user is temporary and update pending invites UPDATE public.tablo_invites SET is_pending = FALSE @@ -42,7 +45,8 @@ CREATE OR REPLACE FUNCTION public.update_tablo_invites_on_login() WHERE id = (NEW.id)::uuid AND is_temporary = TRUE ); - RETURN NEW; + RETURN NEW; + END IF; END; $$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/sql/31_add_rls_for_tablo_invites.sql b/sql/31_add_rls_for_tablo_invites.sql new file mode 100644 index 0000000..4cc15e0 --- /dev/null +++ b/sql/31_add_rls_for_tablo_invites.sql @@ -0,0 +1,9 @@ +-- Add RLS policy for tablo_invites table +-- Allow authenticated users to view pending invites they created +CREATE POLICY "Users can view their own pending invites" ON tablo_invites + FOR SELECT USING ( + invited_by = auth.uid() + AND is_pending = TRUE + ); + + diff --git a/sql/31_add_unique_constraint_to_tablo_access.sql b/sql/31_add_unique_constraint_to_tablo_access.sql new file mode 100644 index 0000000..d58d27e --- /dev/null +++ b/sql/31_add_unique_constraint_to_tablo_access.sql @@ -0,0 +1,14 @@ +-- Remove duplicate records from tablo_access table +-- Keep only the earliest record (lowest id) for each (tablo_id, user_id) combination +DELETE FROM tablo_access +WHERE id NOT IN ( + SELECT MIN(id) + FROM tablo_access + GROUP BY tablo_id, user_id +); + +-- Add unique constraint to prevent duplicate access records +ALTER TABLE tablo_access +ADD CONSTRAINT unique_tablo_access + UNIQUE (tablo_id, user_id); + diff --git a/sql/32_add_unique_constraint_to_tablo_invites.sql b/sql/32_add_unique_constraint_to_tablo_invites.sql new file mode 100644 index 0000000..79f982c --- /dev/null +++ b/sql/32_add_unique_constraint_to_tablo_invites.sql @@ -0,0 +1,18 @@ +-- Remove duplicate records from tablo_invites table +-- Keep only the earliest record (lowest id) for each (tablo_id, invited_email) combination +DELETE FROM tablo_invites +WHERE id NOT IN ( + SELECT MIN(id) + FROM tablo_invites + GROUP BY tablo_id, invited_email +); + +-- Drop existing constraint if it exists (to avoid errors) +ALTER TABLE tablo_invites +DROP CONSTRAINT IF EXISTS unique_tablo_invitation; + +-- Add unique constraint to prevent duplicate invitations +ALTER TABLE tablo_invites +ADD CONSTRAINT unique_tablo_invitation + UNIQUE (tablo_id, invited_email); +