Fix various issues

This commit is contained in:
Arthur Belleville 2026-03-04 22:09:10 +01:00
parent 0c4e9c1301
commit 28d0b938fa
No known key found for this signature in database
8 changed files with 358 additions and 38 deletions

View file

@ -18,6 +18,79 @@ type PostTablo = Omit<TabloInsert, "owner_id" | "organization_id"> & {
const factory = createFactory<AuthEnv>();
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<typeof MiddlewareManager.getInstance>) =>
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 ? `<p>${introEmail}</p>` : ""}
});
});
const cancelPendingInvite = (
middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>
) =>
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);

View file

@ -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 && (
<div className="flex items-center -space-x-2 mr-2">
{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 (
<Avatar
key={member.id}
@ -273,6 +274,20 @@ export const TabloHeaderActions = ({ tablo, isAdmin }: TabloHeaderActionsProps)
{invite.invited_email}
</span>
</div>
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={() => cancelInvite({ tabloId: tablo.id, inviteId: invite.id })}
disabled={isCancellingInvite}
title="Retirer l'invitation"
>
{isCancellingInvite ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<X className="h-4 w-4 text-muted-foreground" />
)}
</Button>
</div>
))}
</div>
@ -287,8 +302,8 @@ export const TabloHeaderActions = ({ tablo, isAdmin }: TabloHeaderActionsProps)
</h4>
<div className="space-y-2 max-h-48 overflow-y-auto">
{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 (
<div
key={index}

View file

@ -2,7 +2,10 @@ import { UserTablo } from "@xtablo/shared/types/tablos.types";
import { Button } from "@xtablo/ui/components/button";
import { Users } from "lucide-react";
import { useState } from "react";
import { usePendingTabloInvitesByTablo } from "src/hooks/tablo_invites";
import {
useCancelTabloInvite,
usePendingTabloInvitesByTablo,
} from "src/hooks/tablo_invites";
import { useInviteUser } from "../hooks/invite";
import { useTabloMembers } from "../hooks/tablos";
import { useUser } from "../providers/UserStoreProvider";
@ -16,6 +19,8 @@ export const TabloMembersSection = ({ tablo, isAdmin }: TabloMembersSectionProps
const currentUser = useUser();
const { data: members } = useTabloMembers(tablo.id);
const { data: pendingInvites } = usePendingTabloInvitesByTablo(tablo.id);
const { mutate: cancelInvite, isPending: isCancellingInvite } =
useCancelTabloInvite();
const [inviteEmail, setInviteEmail] = useState("");
const { mutate: inviteUser, isPending: isInvitingUser } = useInviteUser();
@ -114,6 +119,16 @@ export const TabloMembersSection = ({ tablo, isAdmin }: TabloMembersSectionProps
</span>
<span className="text-xs text-muted-foreground ml-2">(En attente)</span>
</div>
<Button
size="sm"
variant="ghost"
onClick={() =>
cancelInvite({ tabloId: tablo.id, inviteId: invite.id })
}
disabled={isCancellingInvite}
>
Retirer
</Button>
</div>
))}
</div>

View file

@ -2,5 +2,6 @@ export interface TabloMember {
id: string;
name: string;
email: string;
avatar_url: string | null;
is_admin: boolean;
}

View file

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

View file

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

View file

@ -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}
</span>
</div>
<Button
size="sm"
variant="ghost"
onClick={() =>
cancelInvite({
tabloId: tabloId ?? "",
inviteId: invite.id,
})
}
disabled={isCancellingInvite || !tabloId}
title="Retirer l'invitation"
>
{isCancellingInvite ? "..." : "Retirer"}
</Button>
</div>
))}
</div>
@ -684,10 +705,11 @@ export const TabloDetailsPage = () => {
</h4>
<div className="space-y-2 max-h-48 overflow-y-auto">
{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 (
<div
key={member.id}
@ -728,10 +750,12 @@ function EtapesSection({
etapes,
tabloTasks,
tabloId,
isAdmin,
}: {
etapes: Etape[];
tabloTasks: KanbanTask[];
tabloId: string;
isAdmin: boolean;
}) {
const [expandedEtapes, setExpandedEtapes] = useState<Set<string>>(
new Set(etapes.map((e) => e.id)),
@ -739,8 +763,11 @@ function EtapesSection({
const [addingTaskToEtape, setAddingTaskToEtape] = useState<string | null>(
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<string, { label: string; color: string }> = {
todo: {
label: "À faire",
@ -788,23 +833,43 @@ function EtapesSection({
},
};
if (etapes.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-24 text-center">
<ListChecksIcon className="w-12 h-12 text-gray-300 dark:text-gray-600 mb-4" />
<p className="text-gray-500 dark:text-gray-400 text-lg font-medium">
Aucune étape
</p>
<p className="text-gray-400 dark:text-gray-500 text-sm mt-1">
Les étapes permettent de structurer votre projet en grandes phases
</p>
</div>
);
}
return (
<div className="space-y-4">
{etapes.map((etape, index) => {
{isAdmin && (
<div className="flex items-center gap-2">
<Input
value={newEtapeTitle}
onChange={(event) => setNewEtapeTitle(event.target.value)}
placeholder="Nom de la nouvelle étape..."
onKeyDown={(event) => {
if (event.key === "Enter") {
void handleAddEtape();
}
}}
className="h-9 sm:w-80"
/>
<Button
onClick={() => void handleAddEtape()}
disabled={isCreatingEtape || !newEtapeTitle.trim()}
>
<PlusIcon className="w-4 h-4" />
Ajouter une étape
</Button>
</div>
)}
{etapes.length === 0 ? (
<div className="flex flex-col items-center justify-center py-24 text-center">
<ListChecksIcon className="w-12 h-12 text-gray-300 dark:text-gray-600 mb-4" />
<p className="text-gray-500 dark:text-gray-400 text-lg font-medium">
Aucune étape
</p>
<p className="text-gray-400 dark:text-gray-500 text-sm mt-1">
Les étapes permettent de structurer votre projet en grandes phases
</p>
</div>
) : (
etapes.map((etape, index) => {
const childTasks = tabloTasks.filter(
(t) => t.parent_task_id === etape.id,
);
@ -1030,7 +1095,8 @@ function EtapesSection({
)}
</div>
);
})}
})
)}
</div>
);
}

View file

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