Fix organization modal flows

This commit is contained in:
Arthur Belleville 2026-03-08 22:44:02 +01:00
parent a735c063ab
commit 2d965c524e
No known key found for this signature in database
11 changed files with 418 additions and 49 deletions

View file

@ -662,6 +662,141 @@ const inviteToOrganization = factory.createHandlers(async (c) => {
});
});
const removeOrganizationMember = factory.createHandlers(async (c) => {
const user = c.get("user");
const supabase = c.get("supabase");
const streamServerClient = c.get("streamServerClient");
const memberId = c.req.param("memberId");
if (!memberId) {
return c.json({ error: "Member id is required" }, 400);
}
const { data: actorProfile, error: actorProfileError } = await supabase
.from("profiles")
.select("organization_id, is_temporary")
.eq("id", user.id)
.single();
if (actorProfileError || !actorProfile?.organization_id) {
return c.json({ error: "Failed to resolve your organization" }, 500);
}
if (actorProfile.is_temporary) {
return c.json({ error: "Temporary users cannot manage organization members" }, 403);
}
const organizationId = actorProfile.organization_id;
const { data: billingState, error: billingError } = await getOrganizationBillingState(
supabase,
organizationId
);
if (billingError || !billingState) {
return c.json({ error: "Failed to resolve organization billing state" }, 500);
}
if (billingState.owner_user_id !== user.id) {
return c.json({ error: "Only the organization creator can remove members" }, 403);
}
if (memberId === billingState.owner_user_id) {
return c.json({ error: "The organization creator cannot be removed" }, 400);
}
const { data: memberProfile, error: memberProfileError } = await supabase
.from("profiles")
.select("id, email, name, first_name, last_name, organization_id")
.eq("id", memberId)
.maybeSingle();
if (memberProfileError) {
return c.json({ error: memberProfileError.message }, 500);
}
if (!memberProfile || memberProfile.organization_id !== organizationId) {
return c.json({ error: "Member not found in your organization" }, 404);
}
const baseName =
[memberProfile.first_name, memberProfile.last_name].filter(Boolean).join(" ").trim() ||
memberProfile.name?.trim() ||
memberProfile.email?.split("@")[0]?.trim() ||
"Personal";
const { data: newOrganization, error: newOrganizationError } = await supabase
.from("organizations")
.insert({ name: `${baseName}'s Workspace` })
.select("id")
.single();
if (newOrganizationError || !newOrganization) {
return c.json({ error: "Failed to create a workspace for this member" }, 500);
}
const { error: assignError } = await supabase
.from("profiles")
.update({ organization_id: newOrganization.id })
.eq("id", memberId);
if (assignError) {
return c.json({ error: "Failed to remove member from organization" }, 500);
}
const { error: transferOwnershipError } = await supabase
.from("tablos")
.update({ owner_id: user.id })
.eq("organization_id", organizationId)
.eq("owner_id", memberId);
if (transferOwnershipError) {
return c.json({ error: "Failed to transfer ownership for member tablos" }, 500);
}
const { data: organizationTablos, error: tablosError } = await supabase
.from("tablos")
.select("id")
.eq("organization_id", organizationId);
if (tablosError) {
return c.json({ error: "Failed to synchronize organization access" }, 500);
}
const tabloIds = (organizationTablos || []).map((tablo) => tablo.id);
if (tabloIds.length > 0) {
const { error: removeAccessError } = await supabase
.from("tablo_access")
.delete()
.eq("user_id", memberId)
.in("tablo_id", tabloIds);
if (removeAccessError) {
return c.json({ error: "Failed to revoke member tablo permissions" }, 500);
}
for (const tabloId of tabloIds) {
try {
const channel = streamServerClient.channel("messaging", tabloId);
await channel.removeMembers([memberId]);
} catch (error) {
console.error("Failed to remove organization member from Stream channel:", error);
}
}
}
const { error: inviteCleanupError } = await supabase
.from("organization_invites")
.delete()
.eq("organization_id", organizationId)
.eq("invited_user_id", memberId);
if (inviteCleanupError && !isMissingRelationError(inviteCleanupError.code)) {
console.error("Failed to clean organization invite history:", inviteCleanupError);
}
return c.json({ message: "Member removed successfully" });
});
export const getUserRouter = () => {
const userRouter = new Hono();
@ -673,6 +808,7 @@ export const getUserRouter = () => {
userRouter.get("/organization", ...getOrganization);
userRouter.patch("/organization", ...updateOrganization);
userRouter.post("/organization/invite", ...inviteToOrganization);
userRouter.delete("/organization/members/:memberId", ...removeOrganizationMember);
return userRouter;
};

View file

@ -5,12 +5,14 @@ import { ActionCard } from "./ActionCard";
export interface DashboardActionCardsProps {
onCreateProject?: () => void;
onCreateTask?: () => void;
onInviteTeam?: () => void;
onSendMessage?: () => void;
}
export function DashboardActionCards({
onCreateProject,
onCreateTask,
onInviteTeam,
onSendMessage,
}: DashboardActionCardsProps) {
const { t } = useTranslation("pages");
@ -35,8 +37,7 @@ export function DashboardActionCards({
icon={<UserPlus className="w-6 h-6" />}
label={t("dashboard.actionCards.inviteTeam.label")}
description={t("dashboard.actionCards.inviteTeam.description")}
disabled
badge="Bientôt"
onClick={onInviteTeam}
/>
<ActionCard

View file

@ -0,0 +1,89 @@
import { Button } from "@xtablo/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@xtablo/ui/components/dialog";
import { Input } from "@xtablo/ui/components/input";
import { Label } from "@xtablo/ui/components/label";
import { Loader2Icon, UserPlus } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useInviteOrganizationUser } from "../hooks/organization";
const isEmailValid = (email: string) => /\S+@\S+\.\S+/.test(email.trim());
export function InviteOrganizationModal({
isOpen,
onOpenChange,
}: {
isOpen: boolean;
onOpenChange: (nextOpen: boolean) => void;
}) {
const { t } = useTranslation(["settings", "common"]);
const { mutate: inviteOrganizationUser, isPending } = useInviteOrganizationUser();
const [inviteEmail, setInviteEmail] = useState("");
const close = () => {
setInviteEmail("");
onOpenChange(false);
};
const submit = () => {
const email = inviteEmail.trim();
if (!isEmailValid(email)) {
return;
}
inviteOrganizationUser(email, {
onSuccess: () => {
close();
},
});
};
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<UserPlus className="w-5 h-5" />
{t("settings:teamInvite.title")}
</DialogTitle>
<DialogDescription>{t("settings:teamInvite.description")}</DialogDescription>
</DialogHeader>
<div className="space-y-2 py-2">
<Label htmlFor="inviteOrganizationEmail">{t("settings:teamInvite.emailLabel")}</Label>
<Input
id="inviteOrganizationEmail"
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder={t("settings:teamInvite.emailPlaceholder")}
/>
<p className="text-xs text-muted-foreground">{t("settings:teamInvite.hint")}</p>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={close} disabled={isPending}>
{t("common:buttons.cancel")}
</Button>
<Button onClick={submit} disabled={isPending || !isEmailValid(inviteEmail)}>
{isPending ? (
<>
<Loader2Icon className="w-4 h-4 mr-1 animate-spin" />
{t("settings:teamInvite.inviting")}
</>
) : (
t("settings:teamInvite.invite")
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -1,7 +1,7 @@
// shadcn components
import { cn } from "@xtablo/shared/lib/cn.ts";
import { Avatar, AvatarBadge, AvatarFallback, AvatarImage } from "@xtablo/ui/components/avatar";
import { Avatar, AvatarFallback, AvatarImage } from "@xtablo/ui/components/avatar";
import { Button } from "@xtablo/ui/components/button";
import {
DropdownMenu,
@ -13,8 +13,6 @@ import {
import { TypographyLarge, TypographyMuted } from "@xtablo/ui/components/typography";
import { cva, type VariantProps } from "class-variance-authority";
import {
CalendarIcon,
Circle,
Compass,
ConstructionIcon,
CreditCard,
@ -38,6 +36,7 @@ import {
SquareKanban,
Star,
Sun,
UserMinus,
Waves,
Zap,
} from "lucide-react";
@ -47,7 +46,7 @@ import { useTranslation } from "react-i18next";
import { Link as RouterLink, useLocation } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { useLogout } from "../hooks/auth";
import { useOrganization } from "../hooks/organization";
import { useOrganization, useRemoveOrganizationMember } from "../hooks/organization";
import { useCreateCheckoutSession } from "../hooks/stripe";
import { useTablosList } from "../hooks/tablos";
import { isProd, isStaging } from "../lib/env";
@ -92,7 +91,28 @@ function NavLink({ isActive, children }: NavLinkProps) {
export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) {
const user = useUser();
const { mutate: logout } = useLogout();
const { data: organizationData } = useOrganization();
const { mutate: removeOrganizationMember, isPending: isRemovingMember } =
useRemoveOrganizationMember();
const { t } = useTranslation("navigation");
const members = organizationData?.members ?? [];
const canRemoveMembers = organizationData?.is_billing_owner ?? false;
const getDisplayName = (input: {
first_name?: string | null;
last_name?: string | null;
name?: string | null;
email?: string | null;
}) => {
const combined = [input.first_name, input.last_name].filter(Boolean).join(" ").trim();
if (combined) {
return combined;
}
if (input.name) {
return input.name;
}
return input.email || t("organizationMenu.unknownUser", "Utilisateur");
};
const MenuSeparator = () => {
return <DropdownMenuSeparator className="bg-gray-300 dark:bg-gray-500!" />;
@ -135,7 +155,7 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="User menu"
aria-label="Organization menu"
variant="ghost"
className={twMerge(
"flex items-center justify-start hover:bg-navbar-darker w-full h-auto pl-2 py-1.5 gap-1",
@ -143,16 +163,20 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) {
)}
>
<Avatar className="size-7">
<AvatarImage src={user.avatar_url ?? undefined} alt="Avatar" />
<AvatarFallback>{user.name?.charAt(0).toUpperCase()}</AvatarFallback>
<AvatarFallback>
{organizationData?.organization?.name?.charAt(0).toUpperCase() ?? "O"}
</AvatarFallback>
</Avatar>
{!isCollapsed && (
<div className="flex flex-col items-start">
<TypographyMuted className="text-gray-700 dark:text-gray-300/90 transition-all duration-300 ml-1 truncate font-medium overflow-hidden text-ellipsis">
{user.first_name} {user.last_name}
{organizationData?.organization?.name ||
t("organizationMenu.title", "Organisation")}
</TypographyMuted>
<TypographyMuted className="text-gray-500 dark:text-gray-400/90 transition-all duration-300 ml-1 text-xs truncate overflow-hidden text-ellipsis">
{user.email}
{t("organizationMenu.memberCount", {
count: organizationData?.organization?.member_count ?? 0,
})}
</TypographyMuted>
</div>
)}
@ -164,34 +188,73 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) {
align="end"
sideOffset={-8}
>
<div className="flex gap-2 p-1">
<Avatar className="size-8">
<AvatarImage src={user.avatar_url ?? undefined} alt={user.name ?? "User avatar"} />
<AvatarFallback className="bg-gray-300 dark:bg-gray-700 text-gray-700 dark:text-white">
{user.name?.charAt(0).toUpperCase()}
</AvatarFallback>
<AvatarBadge className="size-3">
<Circle className="text-emerald-600 fill-current size-2" aria-label="Available" />
</AvatarBadge>
</Avatar>
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
<TypographyMuted className="font-bold text-gray-800 dark:text-gray-100 text-sm truncate">
{user.name}
</TypographyMuted>
<TypographyMuted className="text-gray-500 dark:text-gray-300 text-xs truncate">
{user.email}
</TypographyMuted>
</div>
<div className="flex flex-col gap-0.5 min-w-0 flex-1 px-2 py-1">
<TypographyMuted className="font-bold text-gray-800 dark:text-gray-100 text-sm truncate">
{organizationData?.organization?.name || t("organizationMenu.title", "Organisation")}
</TypographyMuted>
<TypographyMuted className="text-gray-500 dark:text-gray-300 text-xs truncate">
{t("organizationMenu.memberCount", {
count: organizationData?.organization?.member_count ?? 0,
})}
</TypographyMuted>
</div>
<MenuSeparator />
<MenuDropdownItem
icon={<LogOutIcon className="w-5 h-5" aria-hidden="true" />}
label={t("userMenu.logout")}
variant="destructive"
onClick={logout}
/>
<div className="max-h-64 overflow-y-auto px-2 py-1 space-y-2">
<p className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">
{t("organizationMenu.members", "Membres")}
</p>
{members.length === 0 ? (
<p className="text-xs text-muted-foreground">
{t("organizationMenu.noMembers", "Aucun membre")}
</p>
) : (
members.map((member) => (
<div key={member.id} className="flex items-center justify-between gap-2">
<div className="min-w-0 flex items-center gap-2">
<Avatar className="size-6">
<AvatarImage
src={member.avatar_url ?? undefined}
alt={member.name ?? "Avatar"}
/>
<AvatarFallback>
{(member.name || member.email || "U").charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="text-xs font-medium truncate">
{getDisplayName({
first_name: member.first_name,
last_name: member.last_name,
name: member.name,
email: member.email,
})}
</p>
<p className="text-xs text-muted-foreground truncate">{member.email}</p>
</div>
</div>
{canRemoveMembers && member.id !== user.id && (
<Button
variant="ghost"
size="icon"
className="size-7 text-red-500 hover:text-red-600"
disabled={isRemovingMember}
onClick={() => {
const confirmed = window.confirm(
t("organizationMenu.removeConfirm", "Retirer ce membre de l'organisation ?")
);
if (!confirmed) return;
removeOrganizationMember(member.id);
}}
>
<UserMinus className="w-4 h-4" />
</Button>
)}
</div>
))
)}
</div>
<MenuSeparator />
@ -203,13 +266,12 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) {
/>
</RouterLink>
<RouterLink to="/availabilities">
<MenuDropdownItem
icon={<CalendarIcon className="w-5 h-5" aria-hidden="true" />}
label={t("userMenu.availabilities")}
variant="default"
/>
</RouterLink>
<MenuDropdownItem
icon={<LogOutIcon className="w-5 h-5" aria-hidden="true" />}
label={t("userMenu.logout")}
variant="destructive"
onClick={logout}
/>
<MenuSeparator />
<div className="flex flex-row my-2 ml-1 items-center">

View file

@ -116,3 +116,30 @@ export const useInviteOrganizationUser = () => {
},
});
};
export const useRemoveOrganizationMember = () => {
const api = useAuthedApi();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (memberId: string) => {
const { data } = await api.delete(`/api/v1/users/organization/members/${memberId}`);
return data;
},
onSuccess: () => {
toast.add({
title: "Membre retiré",
description: "Le membre a été retiré de votre organisation",
type: "success",
});
queryClient.invalidateQueries({ queryKey: ["organization"] });
},
onError: (error: Error) => {
toast.add({
title: "Erreur",
description: error.message || "Impossible de retirer ce membre",
type: "error",
});
},
});
};

View file

@ -19,6 +19,15 @@
"availabilities": "Availabilities",
"logout": "Sign out"
},
"organizationMenu": {
"title": "Organization",
"members": "Members",
"memberCount_one": "{{count}} member",
"memberCount_other": "{{count}} members",
"noMembers": "No members",
"unknownUser": "User",
"removeConfirm": "Remove this member from the organization?"
},
"notifications": {
"title": "Notifications",
"markAllRead": "Mark all read",

View file

@ -56,6 +56,8 @@
"membersTitle": "Other organization members",
"noOtherMembers": "No other members in your organization yet.",
"joinedOn": "Joined on {{date}}",
"remove": "Remove",
"removeConfirm": "Remove this member from the organization?",
"unknownDate": "unknown date",
"unknownUser": "Unknown user"
},

View file

@ -19,6 +19,15 @@
"availabilities": "Disponibilités",
"logout": "Se déconnecter"
},
"organizationMenu": {
"title": "Organisation",
"members": "Membres",
"memberCount_one": "{{count}} membre",
"memberCount_other": "{{count}} membres",
"noMembers": "Aucun membre",
"unknownUser": "Utilisateur",
"removeConfirm": "Retirer ce membre de l'organisation ?"
},
"notifications": {
"title": "Notifications",
"markAllRead": "Tout marquer comme lu",

View file

@ -56,6 +56,8 @@
"membersTitle": "Autres membres de l'organisation",
"noOtherMembers": "Aucun autre membre dans votre organisation pour le moment.",
"joinedOn": "A rejoint le {{date}}",
"remove": "Retirer",
"removeConfirm": "Retirer ce membre de l'organisation ?",
"unknownDate": "date inconnue",
"unknownUser": "Utilisateur inconnu"
},

View file

@ -31,6 +31,7 @@ import { useIntroduction } from "../hooks/intros";
import {
useInviteOrganizationUser,
useOrganization,
useRemoveOrganizationMember,
useUpdateOrganization,
} from "../hooks/organization";
import { useRemoveAvatar, useUpdateProfile, useUploadAvatar } from "../hooks/profile";
@ -55,6 +56,8 @@ export default function SettingsPage() {
useUpdateOrganization();
const { mutate: inviteOrganizationUser, isPending: inviteOrganizationUserPending } =
useInviteOrganizationUser();
const { mutate: removeOrganizationMember, isPending: removeOrganizationMemberPending } =
useRemoveOrganizationMember();
const [firstName, setFirstName] = useState(user?.first_name || "");
const [lastName, setLastName] = useState(user?.last_name || "");
@ -74,6 +77,7 @@ export default function SettingsPage() {
const organizationMembers = (organizationData?.members || []).filter(
(member) => member.id !== user?.id
);
const canManageMembers = organizationData?.is_billing_owner ?? false;
const getDisplayName = (input: {
first_name?: string | null;
@ -484,13 +488,34 @@ export default function SettingsPage() {
</p>
<p className="text-xs text-muted-foreground truncate">{member.email}</p>
</div>
<p className="text-xs text-muted-foreground shrink-0 ml-3">
{t("settings:teamInvite.joinedOn", {
date: member.created_at
? formatDate(member.created_at)
: t("settings:teamInvite.unknownDate"),
})}
</p>
<div className="flex items-center gap-2 ml-3">
<p className="text-xs text-muted-foreground shrink-0">
{t("settings:teamInvite.joinedOn", {
date: member.created_at
? formatDate(member.created_at)
: t("settings:teamInvite.unknownDate"),
})}
</p>
{canManageMembers && (
<Button
variant="outline"
size="sm"
disabled={removeOrganizationMemberPending}
onClick={() => {
const confirmed = window.confirm(
t(
"settings:teamInvite.removeConfirm",
"Retirer ce membre de l'organisation ?"
)
);
if (!confirmed) return;
removeOrganizationMember(member.id);
}}
>
{t("settings:teamInvite.remove", "Retirer")}
</Button>
)}
</div>
</div>
))}
</div>

View file

@ -39,6 +39,7 @@ import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router-dom";
import { DashboardActionCards } from "src/components/DashboardActionCards";
import { DashboardTaskList } from "src/components/DashboardTaskList";
import { InviteOrganizationModal } from "src/components/InviteOrganizationModal";
import { TaskModal } from "src/components/kanban/TaskModal";
import { ProjectCardList } from "src/components/ProjectCardList";
import { useCanCreateTablo, useCreateTablo, useDeleteTablo, useTablosList } from "../hooks/tablos";
@ -93,6 +94,7 @@ export const TabloPage = () => {
} | null>(null);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
const [isInviteTeamModalOpen, setIsInviteTeamModalOpen] = useState(false);
const [deletingTablo, setDeletingTablo] = useState<UserTablo | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [filterType] = useState<"all" | "todo" | "inProgress" | "done">("all");
@ -636,6 +638,7 @@ export const TabloPage = () => {
<DashboardActionCards
onCreateProject={openCreateModal}
onCreateTask={() => setIsTaskModalOpen(true)}
onInviteTeam={() => setIsInviteTeamModalOpen(true)}
onSendMessage={() => navigate("/chat")}
/>
@ -702,6 +705,10 @@ export const TabloPage = () => {
allowTabloSelection={true}
initialStatus="todo"
/>
<InviteOrganizationModal
isOpen={isInviteTeamModalOpen}
onOpenChange={setIsInviteTeamModalOpen}
/>
</div>
);
};