Huge step ahead for invites
This commit is contained in:
parent
ac59788868
commit
0fd0253685
11 changed files with 205 additions and 23 deletions
|
|
@ -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,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
51
apps/main/src/hooks/tablo_invites.ts
Normal file
51
apps/main/src/hooks/tablo_invites.ts
Normal 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,
|
||||
});
|
||||
};
|
||||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
9
sql/31_add_rls_for_tablo_invites.sql
Normal file
9
sql/31_add_rls_for_tablo_invites.sql
Normal 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
|
||||
);
|
||||
|
||||
|
||||
14
sql/31_add_unique_constraint_to_tablo_access.sql
Normal file
14
sql/31_add_unique_constraint_to_tablo_access.sql
Normal 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);
|
||||
|
||||
18
sql/32_add_unique_constraint_to_tablo_invites.sql
Normal file
18
sql/32_add_unique_constraint_to_tablo_invites.sql
Normal 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);
|
||||
|
||||
Loading…
Reference in a new issue