Fix various issues
This commit is contained in:
parent
0c4e9c1301
commit
28d0b938fa
8 changed files with 358 additions and 38 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -2,5 +2,6 @@ export interface TabloMember {
|
|||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatar_url: string | null;
|
||||
is_admin: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
);
|
||||
Loading…
Reference in a new issue