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:
Alejandro Gutiérrez
2026-04-04 21:19:32 +01:00
commit d3163a5bff
1384 changed files with 314925 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>
</>
);
};

View File

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