From 28d0b938faf380b9885f4d59edbce977b0d3f0de Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Wed, 4 Mar 2026 22:09:10 +0100 Subject: [PATCH] Fix various issues --- apps/api/src/routers/tablo.ts | 175 +++++++++++++++++- .../src/components/TabloHeaderActions.tsx | 27 ++- .../src/components/TabloMembersSection.tsx | 17 +- apps/main/src/components/kanban/types.ts | 1 + apps/main/src/hooks/tablo_invites.ts | 38 +++- apps/main/src/hooks/tablos.ts | 8 +- apps/main/src/pages/tablo-details.tsx | 108 ++++++++--- ..._allow_shared_tablo_profile_visibility.sql | 22 +++ 8 files changed, 358 insertions(+), 38 deletions(-) create mode 100644 supabase/migrations/20260304233000_allow_shared_tablo_profile_visibility.sql diff --git a/apps/api/src/routers/tablo.ts b/apps/api/src/routers/tablo.ts index 6152593..4a12e77 100644 --- a/apps/api/src/routers/tablo.ts +++ b/apps/api/src/routers/tablo.ts @@ -18,6 +18,79 @@ type PostTablo = Omit & { const factory = createFactory(); +const isAlreadyMemberError = (error: unknown): boolean => { + if (!error) return false; + const message = (error instanceof Error ? error.message : String(error)).toLowerCase(); + return ( + message.includes("already a member") || + message.includes("already member") || + message.includes("member already exists") + ); +}; + +const upsertStreamUserFromProfile = async ( + supabase: AuthEnv["Variables"]["supabase"], + streamServerClient: AuthEnv["Variables"]["streamServerClient"], + userId: string +) => { + const { data: profile } = await supabase.from("profiles").select("name").eq("id", userId).maybeSingle(); + + await streamServerClient.upsertUser({ + id: userId, + name: profile?.name ?? "", + language: "fr", + }); +}; + +const ensureTabloChannelMember = async ( + supabase: AuthEnv["Variables"]["supabase"], + streamServerClient: AuthEnv["Variables"]["streamServerClient"], + tabloId: string, + userId: string +) => { + const channel = streamServerClient.channel("messaging", tabloId); + + try { + await channel.addMembers([userId]); + return; + } catch (error) { + if (isAlreadyMemberError(error)) { + return; + } + } + + const { data: tablo } = await supabase + .from("tablos") + .select("name, owner_id") + .eq("id", tabloId) + .maybeSingle(); + + const { data: accessRows } = await supabase + .from("tablo_access") + .select("user_id") + .eq("tablo_id", tabloId) + .eq("is_active", true); + + const members = Array.from(new Set((accessRows || []).map((row) => row.user_id).concat(userId))); + + const channelToCreate = streamServerClient.channel("messaging", tabloId, { + // @ts-ignore + name: tablo?.name ?? "Tablo", + created_by_id: tablo?.owner_id ?? userId, + members, + }); + + try { + await channelToCreate.create(); + } catch (error) { + if (isAlreadyMemberError(error)) { + return; + } + + await channel.addMembers([userId]); + } +}; + const createTablo = (middlewareManager: ReturnType) => factory.createHandlers(middlewareManager.regularUserCheck, verifyTabloLimitForUser, async (c) => { const user = c.get("user"); @@ -274,6 +347,13 @@ const inviteToTablo = ( return c.json({ error: tabloAccessError.message }, 500); } + try { + await ensureTabloChannelMember(supabase, streamServerClient, tabloId, result.userId); + } catch (streamError) { + console.error("error adding temporary invited user to channel", streamError); + return c.json({ error: "Failed to sync chat access for invited user" }, 500); + } + return c.json({ message: "User created and invite sent successfully", }); @@ -320,6 +400,77 @@ ${introEmail ? `

${introEmail}

` : ""} }); }); +const cancelPendingInvite = ( + middlewareManager: ReturnType +) => + factory.createHandlers(middlewareManager.regularUserCheck, checkTabloAdmin, async (c) => { + const supabase = c.get("supabase"); + const streamServerClient = c.get("streamServerClient"); + const tabloId = c.req.param("tabloId"); + const inviteId = Number(c.req.param("inviteId")); + + if (!Number.isInteger(inviteId) || inviteId <= 0) { + return c.json({ error: "Invalid invite id" }, 400); + } + + const { data: invite, error: inviteError } = await supabase + .from("tablo_invites") + .select("id, invited_email, is_pending") + .eq("id", inviteId) + .eq("tablo_id", tabloId) + .maybeSingle(); + + if (inviteError) { + return c.json({ error: inviteError.message }, 500); + } + + if (!invite) { + return c.json({ error: "Invite not found" }, 404); + } + + if (!invite.is_pending) { + return c.json({ error: "Invite is no longer pending" }, 400); + } + + const { error: cancelError } = await supabase + .from("tablo_invites") + .update({ is_pending: false }) + .eq("id", inviteId) + .eq("tablo_id", tabloId); + + if (cancelError) { + return c.json({ error: cancelError.message }, 500); + } + + const { data: invitedProfile } = await supabase + .from("profiles") + .select("id, is_temporary") + .eq("email", invite.invited_email) + .maybeSingle(); + + // Temporary invitees are pre-added to tablo_access. Revoke this access when invite is cancelled. + if (invitedProfile?.id && invitedProfile.is_temporary) { + const { error: revokeAccessError } = await supabase + .from("tablo_access") + .update({ is_active: false }) + .eq("tablo_id", tabloId) + .eq("user_id", invitedProfile.id); + + if (revokeAccessError) { + return c.json({ error: revokeAccessError.message }, 500); + } + + try { + const channel = streamServerClient.channel("messaging", tabloId); + await channel.removeMembers([invitedProfile.id]); + } catch (error) { + console.error("error removing cancelled invitee from channel", error); + } + } + + return c.json({ message: "Invite cancelled successfully" }); + }); + const joinTablo = factory.createHandlers(async (c) => { const { token } = await c.req.json(); @@ -346,6 +497,13 @@ const joinTablo = factory.createHandlers(async (c) => { const { id: invite_id, tablo_id, invited_by } = inviteData; + try { + await upsertStreamUserFromProfile(supabase, streamServerClient, joiner.id); + } catch (error) { + console.error("error upserting joining user to stream", error); + return c.json({ error: "Failed to provision chat user" }, 500); + } + const { error: tabloAccessError } = await supabase.from("tablo_access").insert({ tablo_id, user_id: joiner.id, @@ -359,22 +517,20 @@ const joinTablo = factory.createHandlers(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); + // If user already has access, continue to sync invite + chat membership. + if (tabloAccessError.code !== "23505") { + return c.json({ error: tabloAccessError.message }, 500); } - - return c.json({ error: tabloAccessError.message }, 500); } // Mark invite as accepted instead of deleting (maintains audit trail) await supabase.from("tablo_invites").update({ is_pending: false }).eq("id", invite_id); try { - const channel = streamServerClient.channel("messaging", tablo_id); - await channel.addMembers([joiner.id]); + await ensureTabloChannelMember(supabase, streamServerClient, tablo_id, joiner.id); } catch (error) { console.error("error adding member to channel", error); + return c.json({ error: "Failed to sync chat access for this tablo" }, 500); } return c.json({ tablo_id }); @@ -401,7 +557,7 @@ const getTabloMembers = factory.createHandlers(async (c) => { const { data, error } = await supabase .from("tablo_access") - .select("is_admin, profiles(id, name, email)") + .select("is_admin, profiles(id, name, email, avatar_url)") .eq("tablo_id", tablo_id) .eq("is_active", true); @@ -411,6 +567,7 @@ const getTabloMembers = factory.createHandlers(async (c) => { id: string; name: string; email: string; + avatar_url: string | null; }; }[]; @@ -423,6 +580,7 @@ const getTabloMembers = factory.createHandlers(async (c) => { ...member.profiles, is_admin: member.is_admin, email: member.profiles.email, + avatar_url: member.profiles.avatar_url, })), }); }); @@ -555,6 +713,7 @@ export const getTabloRouter = (config: AppConfig) => { tabloRouter.patch("/update", ...updateTablo(middlewareManager)); tabloRouter.delete("/delete", ...deleteTablo); tabloRouter.post("/invite/:tabloId", ...inviteToTablo(config, middlewareManager)); + tabloRouter.delete("/invite/:tabloId/:inviteId", ...cancelPendingInvite(middlewareManager)); tabloRouter.post("/join", ...joinTablo); tabloRouter.get("/members/:tablo_id", ...getTabloMembers); tabloRouter.post("/leave", ...leaveTablo); diff --git a/apps/main/src/components/TabloHeaderActions.tsx b/apps/main/src/components/TabloHeaderActions.tsx index 90d5c72..0160717 100644 --- a/apps/main/src/components/TabloHeaderActions.tsx +++ b/apps/main/src/components/TabloHeaderActions.tsx @@ -11,12 +11,12 @@ import { } from "@xtablo/ui/components/dialog"; import { Input } from "@xtablo/ui/components/input"; import { Popover, PopoverContent, PopoverTrigger } from "@xtablo/ui/components/popover"; -import { Settings, Share2 } from "lucide-react"; +import { Loader2, Settings, Share2, X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { ClickOutside } from "./ClickOutside"; import { ImageColorPicker } from "./ImageColorPicker"; import { useInviteUser } from "../hooks/invite"; -import { usePendingTabloInvitesByTablo } from "../hooks/tablo_invites"; +import { useCancelTabloInvite, usePendingTabloInvitesByTablo } from "../hooks/tablo_invites"; import { useTabloMembers, useUpdateTablo } from "../hooks/tablos"; import { useUser } from "../providers/UserStoreProvider"; @@ -42,6 +42,7 @@ export const TabloHeaderActions = ({ tablo, isAdmin }: TabloHeaderActionsProps) // Fetch members and invites for share dialog const { data: members } = useTabloMembers(tablo?.id || ""); const { data: pendingInvites } = usePendingTabloInvitesByTablo(tablo?.id || ""); + const { mutate: cancelInvite, isPending: isCancellingInvite } = useCancelTabloInvite(); const { mutate: inviteUser, isPending: isInvitingUser } = useInviteUser(); useEffect(() => { @@ -109,8 +110,8 @@ export const TabloHeaderActions = ({ tablo, isAdmin }: TabloHeaderActionsProps) {filteredMembers && filteredMembers.length > 0 && (
{filteredMembers.slice(0, 3).map((member) => { - const isCurrentUser = member.id === currentUser.id; - const avatarUrl = isCurrentUser ? currentUser.avatar_url : null; + const avatarUrl = + member.avatar_url ?? (member.id === currentUser.id ? currentUser.avatar_url : null); return (
+ ))} @@ -287,8 +302,8 @@ export const TabloHeaderActions = ({ tablo, isAdmin }: TabloHeaderActionsProps)
{filteredMembers.map((member, index) => { - const isCurrentUser = member.id === currentUser.id; - const avatarUrl = isCurrentUser ? currentUser.avatar_url : null; + const avatarUrl = + member.avatar_url ?? (member.id === currentUser.id ? currentUser.avatar_url : null); return (
(En attente)
+
))} diff --git a/apps/main/src/components/kanban/types.ts b/apps/main/src/components/kanban/types.ts index ca521ff..1d32465 100644 --- a/apps/main/src/components/kanban/types.ts +++ b/apps/main/src/components/kanban/types.ts @@ -2,5 +2,6 @@ export interface TabloMember { id: string; name: string; email: string; + avatar_url: string | null; is_admin: boolean; } diff --git a/apps/main/src/hooks/tablo_invites.ts b/apps/main/src/hooks/tablo_invites.ts index 6445c8c..c6d26bf 100644 --- a/apps/main/src/hooks/tablo_invites.ts +++ b/apps/main/src/hooks/tablo_invites.ts @@ -1,7 +1,9 @@ -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "@xtablo/shared"; import { Database } from "@xtablo/shared/types/database.types"; import { supabase } from "../lib/supabase"; import { useUser } from "../providers/UserStoreProvider"; +import { useAuthedApi } from "./auth"; type TabloInvite = Database["public"]["Tables"]["tablo_invites"]["Row"]; @@ -49,3 +51,37 @@ export const usePendingTabloInvitesByTablo = (tabloId: string) => { enabled: !!user.id && !!tabloId, }); }; + +export const useCancelTabloInvite = () => { + const api = useAuthedApi(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ tabloId, inviteId }: { tabloId: string; inviteId: number }) => { + await api.delete(`/api/v1/tablos/invite/${tabloId}/${inviteId}`); + }, + onSuccess: (_data, { tabloId }) => { + queryClient.invalidateQueries({ queryKey: ["tablo-invites", tabloId] }); + queryClient.invalidateQueries({ queryKey: ["tablo-members", tabloId] }); + toast.add( + { + title: "Invitation retirée", + description: "L'invitation en attente a été supprimée", + type: "success", + }, + { timeout: 3000 } + ); + }, + onError: (error) => { + console.error("Error cancelling invite:", error); + toast.add( + { + title: "Erreur", + description: "Impossible de retirer l'invitation", + type: "error", + }, + { timeout: 5000 } + ); + }, + }); +}; diff --git a/apps/main/src/hooks/tablos.ts b/apps/main/src/hooks/tablos.ts index a359b07..d2c1eaf 100644 --- a/apps/main/src/hooks/tablos.ts +++ b/apps/main/src/hooks/tablos.ts @@ -52,7 +52,13 @@ export const useTabloMembers = (tabloId: string) => { queryKey: ["tablo-members", tabloId], queryFn: async () => { const { data } = await api.get<{ - members: { id: string; name: string; is_admin: boolean; email: string }[]; + members: { + id: string; + name: string; + is_admin: boolean; + email: string; + avatar_url: string | null; + }[]; }>(`/api/v1/tablos/members/${tabloId}`); return data.members; }, diff --git a/apps/main/src/pages/tablo-details.tsx b/apps/main/src/pages/tablo-details.tsx index 3b22ceb..7a4183c 100644 --- a/apps/main/src/pages/tablo-details.tsx +++ b/apps/main/src/pages/tablo-details.tsx @@ -57,10 +57,14 @@ import { TabloFilesSection } from "../components/TabloFilesSection"; import { TabloTasksSection } from "../components/TabloTasksSection"; import { useInviteUser } from "../hooks/invite"; import { useTabloFileNames } from "../hooks/tablo_data"; -import { usePendingTabloInvitesByTablo } from "../hooks/tablo_invites"; +import { + useCancelTabloInvite, + usePendingTabloInvitesByTablo, +} from "../hooks/tablo_invites"; import { useTabloMembers, useTablosList } from "../hooks/tablos"; import { useAllTasks, + useCreateEtape, useCreateTask, useTabloEtapes, useUpdateTask, @@ -181,6 +185,8 @@ export const TabloDetailsPage = () => { const currentUser = useUser(); const { data: members } = useTabloMembers(tabloId ?? ""); const { data: pendingInvites } = usePendingTabloInvitesByTablo(tabloId ?? ""); + const { mutate: cancelInvite, isPending: isCancellingInvite } = + useCancelTabloInvite(); const { mutate: inviteUser, isPending: isInvitingUser } = useInviteUser(); const isEmailValid = (email: string): boolean => { @@ -580,6 +586,7 @@ export const TabloDetailsPage = () => { etapes={etapes} tabloTasks={tabloTasks} tabloId={tabloId ?? ""} + isAdmin={isAdmin} /> )} @@ -670,6 +677,20 @@ export const TabloDetailsPage = () => { {invite.invited_email} + ))} @@ -684,10 +705,11 @@ export const TabloDetailsPage = () => {
{filteredMembers.map((member) => { - const isCurrentUser = member.id === currentUser.id; - const avatarUrl = isCurrentUser - ? currentUser.avatar_url - : null; + const avatarUrl = + member.avatar_url ?? + (member.id === currentUser.id + ? currentUser.avatar_url + : null); return (
>( new Set(etapes.map((e) => e.id)), @@ -739,8 +763,11 @@ function EtapesSection({ const [addingTaskToEtape, setAddingTaskToEtape] = useState( null, ); + const [newEtapeTitle, setNewEtapeTitle] = useState(""); const [newTaskTitle, setNewTaskTitle] = useState(""); const { mutate: createTask } = useCreateTask(); + const { mutateAsync: createEtape, isPending: isCreatingEtape } = + useCreateEtape(); const toggleEtape = (id: string) => { setExpandedEtapes((prev) => { @@ -766,6 +793,24 @@ function EtapesSection({ setAddingTaskToEtape(null); }; + const handleAddEtape = async () => { + const title = newEtapeTitle.trim(); + if (!title || !tabloId) { + return; + } + + const nextPosition = + etapes.reduce((max, etape) => Math.max(max, etape.position), -1) + 1; + + await createEtape({ + tabloId, + title, + position: nextPosition, + }); + + setNewEtapeTitle(""); + }; + const statusConfig: Record = { todo: { label: "À faire", @@ -788,23 +833,43 @@ function EtapesSection({ }, }; - if (etapes.length === 0) { - return ( -
- -

- Aucune étape -

-

- Les étapes permettent de structurer votre projet en grandes phases -

-
- ); - } - return (
- {etapes.map((etape, index) => { + {isAdmin && ( +
+ setNewEtapeTitle(event.target.value)} + placeholder="Nom de la nouvelle étape..." + onKeyDown={(event) => { + if (event.key === "Enter") { + void handleAddEtape(); + } + }} + className="h-9 sm:w-80" + /> + +
+ )} + + {etapes.length === 0 ? ( +
+ +

+ Aucune étape +

+

+ Les étapes permettent de structurer votre projet en grandes phases +

+
+ ) : ( + etapes.map((etape, index) => { const childTasks = tabloTasks.filter( (t) => t.parent_task_id === etape.id, ); @@ -1030,7 +1095,8 @@ function EtapesSection({ )}
); - })} + }) + )}
); } diff --git a/supabase/migrations/20260304233000_allow_shared_tablo_profile_visibility.sql b/supabase/migrations/20260304233000_allow_shared_tablo_profile_visibility.sql new file mode 100644 index 0000000..000a9e1 --- /dev/null +++ b/supabase/migrations/20260304233000_allow_shared_tablo_profile_visibility.sql @@ -0,0 +1,22 @@ +-- Allow users to read profiles of collaborators that share at least one active tablo. +-- This unblocks assignee avatars/names in tasks_with_assignee while keeping profile +-- visibility scoped to collaboration relationships. + +DROP POLICY IF EXISTS "Users can view shared tablo member profiles" ON public.profiles; + +CREATE POLICY "Users can view shared tablo member profiles" + ON public.profiles + FOR SELECT + TO authenticated + USING ( + EXISTS ( + SELECT 1 + FROM public.tablo_access viewer_access + JOIN public.tablo_access member_access + ON member_access.tablo_id = viewer_access.tablo_id + WHERE viewer_access.user_id = auth.uid() + AND viewer_access.is_active = TRUE + AND member_access.user_id = profiles.id + AND member_access.is_active = TRUE + ) + );