feat(db): mesh data model — meshes, members, invites, audit log
- pgSchema "mesh" with 4 tables isolating the peer mesh domain - Enums: visibility, transport, tier, role - audit_log is metadata-only (E2E encryption enforced at broker/client) - Cascade on mesh delete, soft-delete via archivedAt/revokedAt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import dayjs from "dayjs";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import {
|
||||
getAllRolesAtOrBelow,
|
||||
InvitationStatus,
|
||||
MemberRole,
|
||||
} from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Avatar, AvatarFallback } from "@turbostarter/ui-web/avatar";
|
||||
import { Badge } from "@turbostarter/ui-web/badge";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { DataTableColumnHeader } from "@turbostarter/ui-web/data-table/data-table-column-header";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
} from "@turbostarter/ui-web/dropdown-menu";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
import { organization } from "~/modules/organization/lib/api";
|
||||
|
||||
import { useActiveOrganization } from "../../hooks/use-active-organization";
|
||||
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import type { Invitation } from "@turbostarter/auth";
|
||||
|
||||
const Actions = ({ invitation }: { invitation: Invitation }) => {
|
||||
const { t } = useTranslation(["common", "organization"]);
|
||||
const queryClient = useQueryClient();
|
||||
const { activeMember } = useActiveOrganization();
|
||||
|
||||
const resendInvitation = useMutation({
|
||||
...organization.mutations.invitations.resend,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries(
|
||||
organization.queries.invitations.getAll({
|
||||
id: invitation.organizationId,
|
||||
}),
|
||||
);
|
||||
toast.success(t("invitations.resend.success"));
|
||||
},
|
||||
});
|
||||
|
||||
const cancelInvitation = useMutation({
|
||||
...organization.mutations.invitations.cancel,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries(
|
||||
organization.queries.invitations.getAll({
|
||||
id: invitation.organizationId,
|
||||
}),
|
||||
);
|
||||
toast.success(t("invitations.cancel.success"));
|
||||
},
|
||||
});
|
||||
|
||||
const hasInvitePermission =
|
||||
authClient.organization.checkRolePermission({
|
||||
permission: {
|
||||
invitation: ["create"],
|
||||
},
|
||||
role: activeMember?.role ?? MemberRole.MEMBER,
|
||||
}) &&
|
||||
getAllRolesAtOrBelow(activeMember?.role ?? MemberRole.MEMBER).includes(
|
||||
invitation.role,
|
||||
);
|
||||
|
||||
const hasCancelPermission =
|
||||
authClient.organization.checkRolePermission({
|
||||
permission: {
|
||||
invitation: ["cancel"],
|
||||
},
|
||||
role: activeMember?.role ?? MemberRole.MEMBER,
|
||||
}) &&
|
||||
getAllRolesAtOrBelow(activeMember?.role ?? MemberRole.MEMBER).includes(
|
||||
invitation.role,
|
||||
);
|
||||
|
||||
const groups = [
|
||||
hasInvitePermission
|
||||
? [
|
||||
(() => {
|
||||
const isPending =
|
||||
resendInvitation.isPending &&
|
||||
resendInvitation.variables.email === invitation.email &&
|
||||
resendInvitation.variables.organizationId ===
|
||||
invitation.organizationId;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={() => resendInvitation.mutate(invitation)}
|
||||
disabled={isPending}
|
||||
key={`resend-${invitation.id}`}
|
||||
>
|
||||
{t("resend")}
|
||||
{isPending && (
|
||||
<Icons.Loader2 className="ml-auto animate-spin text-current" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})(),
|
||||
]
|
||||
: null,
|
||||
hasCancelPermission
|
||||
? [
|
||||
(() => {
|
||||
const isPending =
|
||||
cancelInvitation.isPending &&
|
||||
cancelInvitation.variables.invitationId === invitation.id;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() =>
|
||||
cancelInvitation.mutate({
|
||||
invitationId: invitation.id,
|
||||
})
|
||||
}
|
||||
disabled={isPending}
|
||||
key={`cancel-${invitation.id}`}
|
||||
>
|
||||
{t("cancel")}
|
||||
{isPending && (
|
||||
<Icons.Loader2 className="ml-auto animate-spin text-current" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})(),
|
||||
]
|
||||
: null,
|
||||
].filter((group) => group?.filter(Boolean).length);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" disabled={groups.length <= 0}>
|
||||
<span className="sr-only">{t("actions")}</span>
|
||||
<Icons.Ellipsis className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
{groups.flatMap((group, idx, array) =>
|
||||
idx < array.length - 1
|
||||
? [group, <DropdownMenuSeparator key={`sep-${idx}`} />]
|
||||
: [group],
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export const useColumns = (): ColumnDef<Invitation>[] => {
|
||||
const { t, i18n } = useTranslation("common");
|
||||
|
||||
return [
|
||||
{
|
||||
id: "email",
|
||||
accessorKey: "email",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t("email")} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar>
|
||||
<AvatarFallback>
|
||||
<Icons.UserRound className="w-4" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate font-medium">{row.original.email}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
enableHiding: false,
|
||||
meta: {
|
||||
placeholder: `${t("searchPlaceholder")}`,
|
||||
variant: "text",
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "role",
|
||||
accessorKey: "role",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t("role")} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return <Badge variant="outline">{t(row.original.role)}</Badge>;
|
||||
},
|
||||
meta: {
|
||||
label: t("role"),
|
||||
variant: "multiSelect",
|
||||
options: Object.values(MemberRole).map((role) => ({
|
||||
label: t(role),
|
||||
value: role,
|
||||
})),
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
accessorKey: "status",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t("status")} />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return <Badge variant="secondary">{t(row.original.status)}</Badge>;
|
||||
},
|
||||
meta: {
|
||||
label: t("status"),
|
||||
variant: "multiSelect",
|
||||
options: Object.values(InvitationStatus).map((status) => ({
|
||||
label: t(status),
|
||||
value: status,
|
||||
})),
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "expiresAt",
|
||||
accessorKey: "expiresAt",
|
||||
header: ({ column }) => (
|
||||
<div className="ml-auto w-fit">
|
||||
<DataTableColumnHeader column={column} title={t("expiresAt")} />
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn("ml-auto text-right", {
|
||||
"text-destructive": dayjs(row.original.expiresAt).isBefore(
|
||||
dayjs(),
|
||||
),
|
||||
})}
|
||||
>
|
||||
{new Date(row.original.expiresAt).toLocaleString(i18n.language, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
year: "numeric",
|
||||
month: "numeric",
|
||||
day: "2-digit",
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: {
|
||||
label: t("expiresAt"),
|
||||
variant: "dateRange",
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<div className="ml-auto w-fit">
|
||||
<Actions invitation={row.original} />
|
||||
</div>
|
||||
),
|
||||
enableHiding: false,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { InvitationStatus } from "@turbostarter/auth";
|
||||
import { pickBy } from "@turbostarter/shared/utils";
|
||||
import { DataTable } from "@turbostarter/ui-web/data-table/data-table";
|
||||
import { DataTableSkeleton } from "@turbostarter/ui-web/data-table/data-table-skeleton";
|
||||
import { DataTableToolbar } from "@turbostarter/ui-web/data-table/data-table-toolbar";
|
||||
|
||||
import { useDataTable } from "~/modules/common/hooks/use-data-table";
|
||||
import { organization } from "~/modules/organization/lib/api";
|
||||
|
||||
import { useColumns } from "./columns";
|
||||
|
||||
interface InvitationsDataTableProps {
|
||||
readonly organizationId: string;
|
||||
}
|
||||
|
||||
export const InvitationsDataTable = ({
|
||||
organizationId,
|
||||
}: InvitationsDataTableProps) => {
|
||||
const columns = useColumns();
|
||||
|
||||
const { table, query } = useDataTable({
|
||||
persistance: "local",
|
||||
columns,
|
||||
initialState: {
|
||||
sorting: [
|
||||
{
|
||||
id: "expiresAt",
|
||||
desc: true,
|
||||
},
|
||||
],
|
||||
columnFilters: [
|
||||
{
|
||||
id: "status",
|
||||
value: [InvitationStatus.PENDING],
|
||||
},
|
||||
],
|
||||
},
|
||||
enableRowSelection: false,
|
||||
query: ({ page, perPage, sorting, filters }) =>
|
||||
organization.queries.invitations.getAll({
|
||||
id: organizationId,
|
||||
page: page.toString(),
|
||||
perPage: perPage.toString(),
|
||||
sort: JSON.stringify(sorting),
|
||||
...pickBy(filters, Boolean),
|
||||
}),
|
||||
});
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<DataTableSkeleton
|
||||
columnCount={5}
|
||||
filterCount={4}
|
||||
cellWidths={["20rem", "5rem", "5rem", "10rem", "2.5rem"]}
|
||||
shrinkZero
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<DataTableToolbar table={table} />
|
||||
<DataTable table={table} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Trans } from "@turbostarter/i18n";
|
||||
import { getTranslation } from "@turbostarter/i18n/server";
|
||||
import { buttonVariants } from "@turbostarter/ui-web/button";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { AuthHeader } from "~/modules/auth/layout/header";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
|
||||
interface InvitationEmailMismatchProps {
|
||||
readonly invitationId: string;
|
||||
readonly email: string;
|
||||
}
|
||||
|
||||
export const InvitationEmailMismatch = async ({
|
||||
invitationId,
|
||||
email,
|
||||
}: InvitationEmailMismatchProps) => {
|
||||
const { t } = await getTranslation({ ns: ["organization"] });
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
searchParams.set("invitationId", invitationId);
|
||||
searchParams.set("email", email);
|
||||
searchParams.set(
|
||||
"redirectTo",
|
||||
`${pathsConfig.auth.join}?${searchParams.toString()}`,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthHeader
|
||||
title={t("invitations.emailMismatch.title")}
|
||||
description={
|
||||
<Trans
|
||||
i18nKey="invitations.emailMismatch.description"
|
||||
ns="organization"
|
||||
values={{ email }}
|
||||
components={{ bold: <strong /> }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<TurboLink
|
||||
href={`${pathsConfig.auth.login}?${searchParams.toString()}`}
|
||||
className={buttonVariants({ size: "lg" })}
|
||||
>
|
||||
{t("invitations.emailMismatch.cta", { email })}
|
||||
</TurboLink>
|
||||
|
||||
<TurboLink
|
||||
href={pathsConfig.dashboard.user.index}
|
||||
className={buttonVariants({ variant: "outline", size: "lg" })}
|
||||
>
|
||||
{t("invitations.emailMismatch.skip")}
|
||||
</TurboLink>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { getTranslation } from "@turbostarter/i18n/server";
|
||||
import { buttonVariants } from "@turbostarter/ui-web/button";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { AuthHeader } from "~/modules/auth/layout/header";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
|
||||
export const InvitationExpired = async () => {
|
||||
const { t } = await getTranslation({ ns: ["organization"] });
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthHeader
|
||||
title={t("invitations.expired.title")}
|
||||
description={t("invitations.expired.description")}
|
||||
/>
|
||||
<TurboLink
|
||||
href={pathsConfig.dashboard.user.index}
|
||||
className={buttonVariants({
|
||||
variant: "outline",
|
||||
size: "lg",
|
||||
})}
|
||||
>
|
||||
{t("invitations.expired.cta")}
|
||||
</TurboLink>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
} from "@turbostarter/ui-web/avatar";
|
||||
import { Badge } from "@turbostarter/ui-web/badge";
|
||||
import { Card } from "@turbostarter/ui-web/card";
|
||||
|
||||
import type { Invitation } from "@turbostarter/auth";
|
||||
|
||||
interface InvitationSummaryCardProps {
|
||||
readonly invitation: Invitation;
|
||||
readonly organization: {
|
||||
slug: string | null;
|
||||
name: string;
|
||||
logo: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export const InvitationSummaryCard = ({
|
||||
invitation,
|
||||
organization,
|
||||
}: InvitationSummaryCardProps) => {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
return (
|
||||
<Card className="flex w-full items-center gap-4 p-4">
|
||||
<Avatar className="size-10">
|
||||
<AvatarImage
|
||||
src={organization.logo ?? undefined}
|
||||
alt={organization.name}
|
||||
/>
|
||||
<AvatarFallback>
|
||||
<span className="text-muted-foreground text-xl uppercase">
|
||||
{organization.name.charAt(0)}
|
||||
</span>
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex w-full min-w-0 flex-col text-sm">
|
||||
<span className="truncate font-medium">{organization.name}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{t("expires")} {dayjs(invitation.expiresAt).fromNow()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Badge variant="outline" className="ml-auto">
|
||||
{t(invitation.role)}
|
||||
</Badge>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
119
apps/web/src/modules/organization/invitations/invitation.tsx
Normal file
119
apps/web/src/modules/organization/invitations/invitation.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { Trans, useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { AuthHeader } from "~/modules/auth/layout/header";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
import { organization } from "~/modules/organization/lib/api";
|
||||
import { user } from "~/modules/user/lib/api";
|
||||
|
||||
import { InvitationSummaryCard } from "./invitation-summary-card";
|
||||
|
||||
import type { Invitation as InvitationType } from "@turbostarter/auth";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface InvitationProps {
|
||||
readonly invitation: InvitationType & {
|
||||
inviterEmail: string;
|
||||
};
|
||||
readonly organization: {
|
||||
slug: string | null;
|
||||
name: string;
|
||||
logo: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export const Invitation = (props: InvitationProps) => {
|
||||
const { t } = useTranslation(["common", "organization"]);
|
||||
const router = useRouter();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const acceptInvitation = useMutation({
|
||||
...organization.mutations.invitations.accept,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries(user.queries.invitations.getAll);
|
||||
router.replace(
|
||||
pathsConfig.dashboard.organization(props.organization.slug ?? "").index,
|
||||
);
|
||||
},
|
||||
});
|
||||
const rejectInvitation = useMutation({
|
||||
...organization.mutations.invitations.reject,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries(user.queries.invitations.getAll);
|
||||
router.replace(pathsConfig.dashboard.user.index);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<AuthHeader
|
||||
title={t("invitations.invitation.title", {
|
||||
organizationName: props.organization.name,
|
||||
})}
|
||||
description={
|
||||
<Trans
|
||||
i18nKey="invitations.invitation.description"
|
||||
ns="organization"
|
||||
values={{
|
||||
inviterEmail: props.invitation.inviterEmail,
|
||||
organizationName: props.organization.name,
|
||||
}}
|
||||
components={{ bold: <strong /> }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<InvitationSummaryCard
|
||||
invitation={props.invitation}
|
||||
organization={props.organization}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={rejectInvitation.isPending || acceptInvitation.isPending}
|
||||
onClick={() =>
|
||||
rejectInvitation.mutate({ invitationId: props.invitation.id })
|
||||
}
|
||||
>
|
||||
{rejectInvitation.isPending ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<Icons.X />
|
||||
)}
|
||||
{t("reject")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() =>
|
||||
acceptInvitation.mutate({ invitationId: props.invitation.id })
|
||||
}
|
||||
disabled={rejectInvitation.isPending || acceptInvitation.isPending}
|
||||
>
|
||||
{acceptInvitation.isPending ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<Icons.Check />
|
||||
)}
|
||||
{t("accept")}
|
||||
</Button>
|
||||
</div>
|
||||
<TurboLink
|
||||
href={pathsConfig.dashboard.user.index}
|
||||
className="text-muted-foreground hover:text-primary self-center text-sm font-medium underline underline-offset-4"
|
||||
>
|
||||
{t("invitations.invitation.skip")}
|
||||
</TurboLink>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import dayjs from "dayjs";
|
||||
import duration from "dayjs/plugin/duration";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
import { InvitationStatus } from "@turbostarter/auth";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@turbostarter/ui-web/alert";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalDescription,
|
||||
ModalHeader,
|
||||
ModalTitle,
|
||||
ModalTrigger,
|
||||
} from "@turbostarter/ui-web/modal";
|
||||
import { Skeleton } from "@turbostarter/ui-web/skeleton";
|
||||
|
||||
import { api } from "~/lib/api/client";
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
import { organization } from "~/modules/organization/lib/api";
|
||||
import { user } from "~/modules/user/lib/api";
|
||||
|
||||
import { InvitationSummaryCard } from "../invitation-summary-card";
|
||||
|
||||
import type { Invitation } from "@turbostarter/auth";
|
||||
|
||||
dayjs.extend(duration);
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export const UserOrganizationInvitationsBanner = () => {
|
||||
const { t } = useTranslation(["organization", "common"]);
|
||||
|
||||
const { data } = useQuery(user.queries.invitations.getAll);
|
||||
const pendingInvitations = data?.filter(
|
||||
(invitation) =>
|
||||
invitation.status === InvitationStatus.PENDING &&
|
||||
dayjs(invitation.expiresAt).isAfter(dayjs()),
|
||||
);
|
||||
|
||||
if (!pendingInvitations?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<UserOrganizationInvitationsListModal invitations={pendingInvitations}>
|
||||
<button className="ring-offset-background focus-visible:ring-ring w-full cursor-pointer rounded-md focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none">
|
||||
<Alert
|
||||
variant="primary"
|
||||
className="hover:bg-primary/10 flex flex-wrap items-center justify-between gap-4 transition-colors"
|
||||
>
|
||||
<div className="flex flex-col items-start gap-y-0.5">
|
||||
<AlertTitle>
|
||||
{t("invitations.user.banner.title", {
|
||||
count: pendingInvitations.length,
|
||||
})}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("invitations.user.banner.description")}
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</Alert>
|
||||
</button>
|
||||
</UserOrganizationInvitationsListModal>
|
||||
);
|
||||
};
|
||||
|
||||
const UserOrganizationInvitationsListModalItem = ({
|
||||
invitation,
|
||||
onSuccess,
|
||||
}: {
|
||||
invitation: Invitation;
|
||||
onSuccess?: () => void;
|
||||
}) => {
|
||||
const { t } = useTranslation(["common", "organization"]);
|
||||
const queryClient = useQueryClient();
|
||||
const { refetch } = authClient.useListOrganizations();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
...organization.queries.get({ id: invitation.organizationId }),
|
||||
queryFn: () =>
|
||||
handle(api.organizations[":id"].$get)({
|
||||
param: { id: invitation.organizationId },
|
||||
}),
|
||||
});
|
||||
|
||||
const acceptInvitation = useMutation({
|
||||
...organization.mutations.invitations.accept,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries(user.queries.invitations.getAll);
|
||||
toast.success(
|
||||
t("invitations.accept.success", "", {
|
||||
organization: data?.organization?.name ?? "",
|
||||
}),
|
||||
);
|
||||
await refetch();
|
||||
onSuccess?.();
|
||||
},
|
||||
});
|
||||
const rejectInvitation = useMutation({
|
||||
...organization.mutations.invitations.reject,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries(user.queries.invitations.getAll);
|
||||
toast.success(
|
||||
t("invitations.reject.success", "", {
|
||||
organization: data?.organization?.name ?? "",
|
||||
}),
|
||||
);
|
||||
onSuccess?.();
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className="h-19 w-full" />;
|
||||
}
|
||||
|
||||
if (!data?.organization) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="flex items-stretch gap-2">
|
||||
<InvitationSummaryCard
|
||||
invitation={invitation}
|
||||
organization={data.organization}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
rejectInvitation.mutate({ invitationId: invitation.id })
|
||||
}
|
||||
disabled={rejectInvitation.isPending || acceptInvitation.isPending}
|
||||
>
|
||||
{rejectInvitation.isPending ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<Icons.X />
|
||||
)}
|
||||
{t("reject")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
acceptInvitation.mutate({ invitationId: invitation.id })
|
||||
}
|
||||
disabled={rejectInvitation.isPending || acceptInvitation.isPending}
|
||||
>
|
||||
{acceptInvitation.isPending ? (
|
||||
<Icons.Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<Icons.Check />
|
||||
)}
|
||||
{t("accept")}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
export const UserOrganizationInvitationsListModal = ({
|
||||
children,
|
||||
invitations,
|
||||
}: {
|
||||
invitations: Invitation[];
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const { t } = useTranslation("organization");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={setOpen}>
|
||||
<ModalTrigger asChild>{children}</ModalTrigger>
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<ModalTitle>{t("invitations.user.list.title")}</ModalTitle>
|
||||
<ModalDescription>
|
||||
{t("invitations.user.list.description")}
|
||||
</ModalDescription>
|
||||
</ModalHeader>
|
||||
|
||||
<ul className="flex flex-col gap-4">
|
||||
{invitations.map((invitation) => (
|
||||
<UserOrganizationInvitationsListModalItem
|
||||
key={invitation.id}
|
||||
invitation={invitation}
|
||||
{...(invitations.length === 1
|
||||
? { onSuccess: () => setOpen(false) }
|
||||
: {})}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user