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: `
-
${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.
`,
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);
+