Fix organization modal flows
This commit is contained in:
parent
a735c063ab
commit
2d965c524e
11 changed files with 418 additions and 49 deletions
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
89
apps/main/src/components/InviteOrganizationModal.tsx
Normal file
89
apps/main/src/components/InviteOrganizationModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue