Huge step ahead for invites

This commit is contained in:
Arthur Belleville 2025-10-28 14:26:24 +01:00
parent ac59788868
commit 0fd0253685
No known key found for this signature in database
11 changed files with 205 additions and 23 deletions

View file

@ -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: `
<p>Bonjour !</p>
<p>Bonjour !</p>
<p>${sender.email} vous a invité à rejoindre XTablo.</p>
@ -472,6 +476,13 @@ tabloRouter.post("/invite/:tabloId", regularUserCheckMiddleware, checkTabloAdmin
<p>Veuillez cliquer sur le lien ci-dessous pour accepter l'invitation et configurer votre mot de passe.</p>
<p><a href="${config.XTABLO_URL}/login" style="display: inline-block; padding: 12px 24px; background-color: #007bff; color: white; text-decoration: none; border-radius: 4px; font-weight: bold;">Accepter et se connecter</a></p>
<p style="color: #d9534f; margin-bottom: 20px;"><strong>Important :</strong> Pour des raisons de sécurité, nous vous recommandons fortement de changer ce mot de passe temporaire lors de votre première connexion.</p>
<p style="color: #666; font-size: 14px; margin-top: 30px;">
Cordialement,<br>
L'équipe XTablo
</p>
`,
});
@ -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 <noreply@xtablo.com>`,
to: recipientmail,
subject: "Vous avez été invité à un tablo",
html: `
${introEmail ? `<p>${introEmail}</p>` : ""}
<p>Cliquez sur <a href="${
config.XTABLO_URL
}/join-tablo?tablo_name=${encodeURIComponent(tablo.name)}&token=${encodeURIComponent(
token
)}">ce lien</a> pour accepter l'invitation.</p>
<br>
<p>Cordialement.</p>
${introEmail ? `<p>${introEmail}</p>` : ""}
<p>Cliquez sur <a href="${
config.XTABLO_URL
}/join-tablo?tablo_name=${encodeURIComponent(tablo.name)}&token=${encodeURIComponent(
token
)}">ce lien</a> pour accepter l'invitation.</p>
<br>
<p style="color: #666; font-size: 14px; margin-top: 30px;">
Cordialement,<br>
L'équipe XTablo
</p>
`,
});
@ -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,
})),
});
});

View file

@ -125,7 +125,7 @@ L'équipe XTablo`,
<p style="color: #d9534f; margin-bottom: 20px;"><strong>Important :</strong> Pour des raisons de sécurité, nous vous recommandons fortement de changer ce mot de passe temporaire lors de votre première connexion.</p>
<p>
<a href="${process.env.FRONTEND_URL || "https://app.tablo.fr"}"
<a href="${process.env.FRONTEND_URL || "https://app.tablo.com"}"
style="background-color: #007bff; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;">
Se connecter à XTablo
</a>

View file

@ -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<HTMLInputElement>(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
)}
</div>
</div>
{pendingInvites && pendingInvites.length > 0 && (
<div className="bg-card rounded-lg border border-border p-6">
<h3 className="text-lg font-semibold text-foreground mb-4">
Invitations en attente
<span className="ml-2 text-sm font-normal text-muted-foreground">
({pendingInvites.length})
</span>
</h3>
<div className="space-y-2">
{pendingInvites.map((invite) => (
<div
key={invite.id}
className="flex items-center space-x-3 p-3 bg-orange-50 dark:bg-orange-950/20 rounded-lg border border-dashed border-orange-200 dark:border-orange-900/50"
>
<div className="w-10 h-10 bg-orange-100 dark:bg-orange-900/30 rounded-full flex items-center justify-center text-orange-600 dark:text-orange-400 text-sm font-medium">
<svg
className="w-5 h-5"
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">
<span className="text-sm font-medium text-foreground">
{invite.invited_email}
</span>
<span className="text-xs text-muted-foreground ml-2">(En attente)</span>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
@ -219,16 +266,16 @@ export const TabloSettingsSection = ({ tablo, isAdmin, onEdit }: TabloSettingsSe
<div className="bg-card rounded-lg border border-border p-6">
<h3 className="text-lg font-semibold text-foreground mb-4">
Membres
{members && (
{filteredMembers && (
<span className="ml-2 text-sm font-normal text-muted-foreground">
({members.length})
({filteredMembers.length})
</span>
)}
</h3>
<div className="space-y-2">
{members && members.length > 0 ? (
members.map((member, index) => (
{filteredMembers && filteredMembers.length > 0 ? (
filteredMembers.map((member, index) => (
<div key={index} className="flex items-center space-x-3 p-3 bg-muted rounded-lg">
<div className="w-10 h-10 bg-primary rounded-full flex items-center justify-center text-primary-foreground text-sm font-medium">
{member.name.charAt(0).toUpperCase()}
@ -252,6 +299,8 @@ export const TabloSettingsSection = ({ tablo, isAdmin, onEdit }: TabloSettingsSe
)}
</div>
</div>
{/* Pending Invites */}
</div>
);
};

View file

@ -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 };

View file

@ -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,
});
};

View file

@ -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;
},

View file

@ -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,

View file

@ -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;

View file

@ -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
);

View file

@ -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);

View file

@ -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);