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,230 @@
"use client";
import { useRouter } from "next/navigation";
import { memo, useState } from "react";
import { PricingPlanType } from "@turbostarter/billing";
import { useTranslation } from "@turbostarter/i18n";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@turbostarter/ui-web/avatar";
import {
Command,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@turbostarter/ui-web/command";
import { Icons } from "@turbostarter/ui-web/icons";
import {
Popover,
PopoverTrigger,
PopoverContent,
PopoverPortal,
} from "@turbostarter/ui-web/popover";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@turbostarter/ui-web/sidebar";
import { pathsConfig } from "~/config/paths";
import { authClient } from "~/lib/auth/client";
import { useCustomer } from "~/modules/billing/hooks/use-customer";
import { TurboLink } from "~/modules/common/turbo-link";
import { CreateOrganizationModal } from "~/modules/organization/create-organization";
import { useActiveOrganization } from "./hooks/use-active-organization";
import type { User } from "@turbostarter/auth";
interface AccountSwitcherProps {
readonly user: User;
}
export const AccountSwitcher = memo<AccountSwitcherProps>(({ user }) => {
const { t } = useTranslation(["common", "auth", "organization"]);
const { isMobile } = useSidebar();
const router = useRouter();
const [open, setOpen] = useState(false);
const [createOrganizationOpen, setCreateOrganizationOpen] = useState(false);
const { data: customer } = useCustomer();
const { data: organizations } = authClient.useListOrganizations();
const { activeOrganization } = useActiveOrganization();
return (
<SidebarMenu>
<SidebarMenuItem>
<Popover open={open} onOpenChange={setOpen} modal>
<PopoverTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="size-8">
{activeOrganization ? (
<>
<AvatarImage
src={activeOrganization.logo ?? undefined}
alt={activeOrganization.name}
/>
<AvatarFallback>
<span className="text-muted-foreground text-sm uppercase">
{activeOrganization.name.charAt(0)}
</span>
</AvatarFallback>
</>
) : (
<>
<AvatarImage
src={user.image ?? undefined}
alt={user.name}
/>
<AvatarFallback>
<Icons.UserRound className="w-5" />
</AvatarFallback>
</>
)}
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">
{activeOrganization
? activeOrganization.name
: t("account.personal")}
</span>
<span className="text-muted-foreground truncate text-xs capitalize">
{(customer?.plan ?? PricingPlanType.FREE).toLowerCase()}
</span>
</div>
<Icons.ChevronsUpDown className="ml-auto" />
</SidebarMenuButton>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent
className="w-(--radix-popover-trigger-width) min-w-60 rounded-lg p-0"
align="start"
side={isMobile ? "bottom" : "right"}
sideOffset={4}
>
<Command
defaultValue={activeOrganization?.slug ?? "personal-account"}
>
<CommandInput placeholder={t("search.label")} autoFocus />
<CommandList>
<CommandGroup>
<CommandItem
value="personal-account"
className="p-2"
onSelect={() => {
setOpen(false);
router.replace(pathsConfig.dashboard.user.index);
}}
asChild
>
<TurboLink href={pathsConfig.dashboard.user.index}>
<Avatar className="size-6">
<AvatarImage
src={user.image ?? undefined}
alt={user.name}
/>
<AvatarFallback>
<Icons.UserRound className="size-3.5" />
</AvatarFallback>
</Avatar>
{t("account.personal")}
{!activeOrganization && (
<Icons.Check className="ml-auto" />
)}
</TurboLink>
</CommandItem>
</CommandGroup>
<CommandSeparator />
{organizations && organizations.length > 0 && (
<>
<CommandGroup
heading={`${t("organizations")} (${organizations.length})`}
>
{organizations.map((organization) => (
<CommandItem
value={organization.slug}
key={organization.id}
className="p-2"
asChild
onSelect={() => {
router.replace(
pathsConfig.dashboard.organization(
organization.slug,
).index,
);
}}
>
<TurboLink
href={
pathsConfig.dashboard.organization(
organization.slug,
).index
}
className="leading-tight"
>
<Avatar className="size-6">
<AvatarImage
src={organization.logo ?? undefined}
alt={organization.name}
/>
<AvatarFallback>
<span className="text-muted-foreground text-sm uppercase">
{organization.name.charAt(0)}
</span>
</AvatarFallback>
</Avatar>
{organization.name}
{activeOrganization?.slug ===
organization.slug && (
<Icons.Check className="ml-auto" />
)}
</TurboLink>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</>
)}
<CommandGroup forceMount>
<CommandItem
className="p-2"
onSelect={() => setCreateOrganizationOpen(true)}
>
<div className="flex size-6 items-center justify-center rounded-md border bg-transparent">
<Icons.Plus className="size-4" />
</div>
{t("create.cta")}
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</PopoverPortal>
</Popover>
<CreateOrganizationModal
open={createOrganizationOpen}
onOpenChange={setCreateOrganizationOpen}
/>
</SidebarMenuItem>
</SidebarMenu>
);
});
AccountSwitcher.displayName = "AccountSwitcher";

View File

@@ -0,0 +1,126 @@
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { createOrganizationSchema } from "@turbostarter/auth";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-web/button";
import {
Form,
FormControl,
FormLabel,
FormItem,
FormField,
FormDescription,
FormMessage,
} from "@turbostarter/ui-web/form";
import { Icons } from "@turbostarter/ui-web/icons";
import { Input } from "@turbostarter/ui-web/input";
import {
Modal,
ModalFooter,
ModalContent,
ModalHeader,
ModalDescription,
ModalTitle,
ModalClose,
ModalTrigger,
} from "@turbostarter/ui-web/modal";
import { pathsConfig } from "~/config/paths";
import { organization } from "./lib/api";
import type { CreateOrganizationPayload } from "@turbostarter/auth";
export const CreateOrganizationModal = ({
open,
onOpenChange,
children,
}: {
open?: boolean;
onOpenChange?: (open: boolean) => void;
children?: React.ReactNode;
}) => {
const { t } = useTranslation(["common", "organization"]);
const router = useRouter();
const form = useForm({
resolver: standardSchemaResolver(createOrganizationSchema),
defaultValues: {
name: "",
},
});
const getSlug = useMutation(organization.mutations.getSlug);
const create = useMutation({
...organization.mutations.create,
onSuccess: (_, variables) => {
router.replace(pathsConfig.dashboard.organization(variables.slug).index);
},
});
const createOrganization = async (data: CreateOrganizationPayload) => {
const { slug } = await getSlug.mutateAsync({
query: data,
});
await create.mutateAsync({
...data,
slug,
});
};
return (
<Modal open={open} onOpenChange={onOpenChange}>
{children && <ModalTrigger asChild>{children}</ModalTrigger>}
<ModalContent>
<ModalHeader>
<ModalTitle>{t("create.title")}</ModalTitle>
<ModalDescription className="whitespace-pre-line">
{t("create.description")}
</ModalDescription>
</ModalHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(createOrganization)}
className="md:space-y-6"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="px-4 py-2 md:px-0 md:py-0">
<FormLabel>{t("common:name")}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>{t("create.info")}</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<ModalFooter>
<ModalClose asChild>
<Button variant="outline" type="button">
{t("cancel")}
</Button>
</ModalClose>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? (
<Icons.Loader2 className="animate-spin" />
) : (
t("create.cta")
)}
</Button>
</ModalFooter>
</form>
</Form>
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,286 @@
"use client";
import * as React from "react";
import {
Area,
AreaChart as RechartsAreaChart,
CartesianGrid,
XAxis,
} from "recharts";
import { useTranslation } from "@turbostarter/i18n";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@turbostarter/ui-web/card";
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@turbostarter/ui-web/chart";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@turbostarter/ui-web/select";
import type { ChartConfig } from "@turbostarter/ui-web/chart";
const chartData = [
{ date: "2024-04-01", desktop: 222, mobile: 150 },
{ date: "2024-04-02", desktop: 97, mobile: 180 },
{ date: "2024-04-03", desktop: 167, mobile: 120 },
{ date: "2024-04-04", desktop: 242, mobile: 260 },
{ date: "2024-04-05", desktop: 373, mobile: 290 },
{ date: "2024-04-06", desktop: 301, mobile: 340 },
{ date: "2024-04-07", desktop: 245, mobile: 180 },
{ date: "2024-04-08", desktop: 409, mobile: 320 },
{ date: "2024-04-09", desktop: 59, mobile: 110 },
{ date: "2024-04-10", desktop: 261, mobile: 190 },
{ date: "2024-04-11", desktop: 327, mobile: 350 },
{ date: "2024-04-12", desktop: 292, mobile: 210 },
{ date: "2024-04-13", desktop: 342, mobile: 380 },
{ date: "2024-04-14", desktop: 137, mobile: 220 },
{ date: "2024-04-15", desktop: 120, mobile: 170 },
{ date: "2024-04-16", desktop: 138, mobile: 190 },
{ date: "2024-04-17", desktop: 446, mobile: 360 },
{ date: "2024-04-18", desktop: 364, mobile: 410 },
{ date: "2024-04-19", desktop: 243, mobile: 180 },
{ date: "2024-04-20", desktop: 89, mobile: 150 },
{ date: "2024-04-21", desktop: 137, mobile: 200 },
{ date: "2024-04-22", desktop: 224, mobile: 170 },
{ date: "2024-04-23", desktop: 138, mobile: 230 },
{ date: "2024-04-24", desktop: 387, mobile: 290 },
{ date: "2024-04-25", desktop: 215, mobile: 250 },
{ date: "2024-04-26", desktop: 75, mobile: 130 },
{ date: "2024-04-27", desktop: 383, mobile: 420 },
{ date: "2024-04-28", desktop: 122, mobile: 180 },
{ date: "2024-04-29", desktop: 315, mobile: 240 },
{ date: "2024-04-30", desktop: 454, mobile: 380 },
{ date: "2024-05-01", desktop: 165, mobile: 220 },
{ date: "2024-05-02", desktop: 293, mobile: 310 },
{ date: "2024-05-03", desktop: 247, mobile: 190 },
{ date: "2024-05-04", desktop: 385, mobile: 420 },
{ date: "2024-05-05", desktop: 481, mobile: 390 },
{ date: "2024-05-06", desktop: 498, mobile: 520 },
{ date: "2024-05-07", desktop: 388, mobile: 300 },
{ date: "2024-05-08", desktop: 149, mobile: 210 },
{ date: "2024-05-09", desktop: 227, mobile: 180 },
{ date: "2024-05-10", desktop: 293, mobile: 330 },
{ date: "2024-05-11", desktop: 335, mobile: 270 },
{ date: "2024-05-12", desktop: 197, mobile: 240 },
{ date: "2024-05-13", desktop: 197, mobile: 160 },
{ date: "2024-05-14", desktop: 448, mobile: 490 },
{ date: "2024-05-15", desktop: 473, mobile: 380 },
{ date: "2024-05-16", desktop: 338, mobile: 400 },
{ date: "2024-05-17", desktop: 499, mobile: 420 },
{ date: "2024-05-18", desktop: 315, mobile: 350 },
{ date: "2024-05-19", desktop: 235, mobile: 180 },
{ date: "2024-05-20", desktop: 177, mobile: 230 },
{ date: "2024-05-21", desktop: 82, mobile: 140 },
{ date: "2024-05-22", desktop: 81, mobile: 120 },
{ date: "2024-05-23", desktop: 252, mobile: 290 },
{ date: "2024-05-24", desktop: 294, mobile: 220 },
{ date: "2024-05-25", desktop: 201, mobile: 250 },
{ date: "2024-05-26", desktop: 213, mobile: 170 },
{ date: "2024-05-27", desktop: 420, mobile: 460 },
{ date: "2024-05-28", desktop: 233, mobile: 190 },
{ date: "2024-05-29", desktop: 78, mobile: 130 },
{ date: "2024-05-30", desktop: 340, mobile: 280 },
{ date: "2024-05-31", desktop: 178, mobile: 230 },
{ date: "2024-06-01", desktop: 178, mobile: 200 },
{ date: "2024-06-02", desktop: 470, mobile: 410 },
{ date: "2024-06-03", desktop: 103, mobile: 160 },
{ date: "2024-06-04", desktop: 439, mobile: 380 },
{ date: "2024-06-05", desktop: 88, mobile: 140 },
{ date: "2024-06-06", desktop: 294, mobile: 250 },
{ date: "2024-06-07", desktop: 323, mobile: 370 },
{ date: "2024-06-08", desktop: 385, mobile: 320 },
{ date: "2024-06-09", desktop: 438, mobile: 480 },
{ date: "2024-06-10", desktop: 155, mobile: 200 },
{ date: "2024-06-11", desktop: 92, mobile: 150 },
{ date: "2024-06-12", desktop: 492, mobile: 420 },
{ date: "2024-06-13", desktop: 81, mobile: 130 },
{ date: "2024-06-14", desktop: 426, mobile: 380 },
{ date: "2024-06-15", desktop: 307, mobile: 350 },
{ date: "2024-06-16", desktop: 371, mobile: 310 },
{ date: "2024-06-17", desktop: 475, mobile: 520 },
{ date: "2024-06-18", desktop: 107, mobile: 170 },
{ date: "2024-06-19", desktop: 341, mobile: 290 },
{ date: "2024-06-20", desktop: 408, mobile: 450 },
{ date: "2024-06-21", desktop: 169, mobile: 210 },
{ date: "2024-06-22", desktop: 317, mobile: 270 },
{ date: "2024-06-23", desktop: 480, mobile: 530 },
{ date: "2024-06-24", desktop: 132, mobile: 180 },
{ date: "2024-06-25", desktop: 141, mobile: 190 },
{ date: "2024-06-26", desktop: 434, mobile: 380 },
{ date: "2024-06-27", desktop: 448, mobile: 490 },
{ date: "2024-06-28", desktop: 149, mobile: 200 },
{ date: "2024-06-29", desktop: 103, mobile: 160 },
{ date: "2024-06-30", desktop: 446, mobile: 400 },
];
export const AreaChart = () => {
const { t, i18n } = useTranslation(["common", "dashboard"]);
const [timeRange, setTimeRange] = React.useState("90d");
const filteredData = chartData.filter((item) => {
const date = new Date(item.date);
const referenceDate = new Date("2024-06-30");
let daysToSubtract = 90;
if (timeRange === "30d") {
daysToSubtract = 30;
} else if (timeRange === "7d") {
daysToSubtract = 7;
}
const startDate = new Date(referenceDate);
startDate.setDate(startDate.getDate() - daysToSubtract);
return date >= startDate;
});
const chartConfig = {
desktop: {
label: t("desktop"),
color: "var(--chart-1)",
},
mobile: {
label: t("mobile"),
color: "var(--chart-2)",
},
} satisfies ChartConfig;
const options = [
{
value: "90d",
label: t("lastMonths", { count: 3 }),
},
{
value: "30d",
label: t("lastMonths", { count: 1 }),
},
{
value: "7d",
label: t("lastDays", { count: 7 }),
},
] as const;
return (
<Card>
<CardHeader className="flex items-center gap-2 space-y-0 border-b py-5 sm:flex-row">
<div className="grid flex-1 gap-0.5 text-center sm:text-left">
<CardTitle className="text-xl">{t("chart.area")}</CardTitle>
<CardDescription>{t("chart.showing")}</CardDescription>
</div>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger
className="w-[160px] rounded-lg sm:ml-auto"
aria-label="Select a value"
>
<SelectValue
placeholder={
options.find((option) => option.value === timeRange)?.label
}
/>
</SelectTrigger>
<SelectContent className="rounded-xl">
{options.map((option) => (
<SelectItem
key={option.value}
value={option.value}
className="rounded-lg"
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[250px] w-full"
>
<RechartsAreaChart data={filteredData}>
<defs>
<linearGradient id="fillDesktop" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-desktop)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-desktop)"
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient id="fillMobile" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-mobile)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-mobile)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={(value: string) => {
const date = new Date(value);
return date.toLocaleDateString(i18n.language, {
month: "short",
day: "numeric",
});
}}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(value: string) => {
return new Date(value).toLocaleDateString(i18n.language, {
month: "short",
day: "numeric",
});
}}
indicator="dot"
/>
}
/>
<Area
dataKey="mobile"
type="natural"
fill="url(#fillMobile)"
stroke="var(--color-mobile)"
stackId="a"
/>
<Area
dataKey="desktop"
type="natural"
fill="url(#fillDesktop)"
stroke="var(--color-desktop)"
stackId="a"
/>
<ChartLegend content={<ChartLegendContent />} />
</RechartsAreaChart>
</ChartContainer>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,124 @@
"use client";
import {
Bar,
BarChart as RechartsBarChart,
CartesianGrid,
Rectangle,
XAxis,
} from "recharts";
import { useTranslation } from "@turbostarter/i18n";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@turbostarter/ui-web/card";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@turbostarter/ui-web/chart";
import { Icons } from "@turbostarter/ui-web/icons";
import type { ChartConfig } from "@turbostarter/ui-web/chart";
const chartData = [
{ browser: "chrome", visitors: 187, fill: "var(--chart-1)" },
{ browser: "safari", visitors: 200, fill: "var(--chart-2)" },
{ browser: "firefox", visitors: 275, fill: "var(--chart-3)" },
{ browser: "edge", visitors: 173, fill: "var(--chart-4)" },
{ browser: "other", visitors: 90, fill: "var(--chart-5)" },
];
const chartConfig = {
chrome: {
label: "Chrome",
color: "var(--chart-1)",
},
safari: {
label: "Safari",
color: "var(--chart-2)",
},
firefox: {
label: "Firefox",
color: "var(--chart-3)",
},
edge: {
label: "Edge",
color: "var(--chart-4)",
},
other: {
label: "Opera",
color: "var(--chart-5)",
},
} satisfies ChartConfig;
export function BarChart() {
const { t } = useTranslation(["common", "dashboard"]);
return (
<Card>
<CardHeader className="space-y-0.5">
<CardTitle className="text-xl">{t("chart.bar")}</CardTitle>
<CardDescription>{t("chart.period")}</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer
config={{
visitors: {
label: t("visitors"),
},
...chartConfig,
}}
>
<RechartsBarChart accessibilityLayer data={chartData}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="browser"
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={(value) =>
chartConfig[value as keyof typeof chartConfig].label
}
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent hideLabel />}
/>
<Bar
dataKey="visitors"
strokeWidth={2}
radius={8}
activeIndex={2}
activeBar={({ ...props }) => {
return (
<Rectangle
{...props}
fillOpacity={0.8}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
stroke={props.payload.fill}
strokeDasharray={4}
strokeDashoffset={4}
/>
);
}}
/>
</RechartsBarChart>
</ChartContainer>
</CardContent>
<CardFooter className="flex-col items-start gap-2 text-sm">
<div className="flex gap-2 leading-none font-medium">
{t("chart.trending")} <Icons.TrendingUp className="h-4 w-4" />
</div>
<div className="text-muted-foreground leading-none">
{t("chart.showing")}
</div>
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,224 @@
"use client";
import * as React from "react";
import {
CartesianGrid,
Line,
LineChart as RechartsLineChart,
XAxis,
} from "recharts";
import { useTranslation } from "@turbostarter/i18n";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@turbostarter/ui-web/card";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@turbostarter/ui-web/chart";
import type { ChartConfig } from "@turbostarter/ui-web/chart";
const chartData = [
{ date: "2024-04-01", desktop: 222, mobile: 150 },
{ date: "2024-04-02", desktop: 97, mobile: 180 },
{ date: "2024-04-03", desktop: 167, mobile: 120 },
{ date: "2024-04-04", desktop: 242, mobile: 260 },
{ date: "2024-04-05", desktop: 373, mobile: 290 },
{ date: "2024-04-06", desktop: 301, mobile: 340 },
{ date: "2024-04-07", desktop: 245, mobile: 180 },
{ date: "2024-04-08", desktop: 409, mobile: 320 },
{ date: "2024-04-09", desktop: 59, mobile: 110 },
{ date: "2024-04-10", desktop: 261, mobile: 190 },
{ date: "2024-04-11", desktop: 327, mobile: 350 },
{ date: "2024-04-12", desktop: 292, mobile: 210 },
{ date: "2024-04-13", desktop: 342, mobile: 380 },
{ date: "2024-04-14", desktop: 137, mobile: 220 },
{ date: "2024-04-15", desktop: 120, mobile: 170 },
{ date: "2024-04-16", desktop: 138, mobile: 190 },
{ date: "2024-04-17", desktop: 446, mobile: 360 },
{ date: "2024-04-18", desktop: 364, mobile: 410 },
{ date: "2024-04-19", desktop: 243, mobile: 180 },
{ date: "2024-04-20", desktop: 89, mobile: 150 },
{ date: "2024-04-21", desktop: 137, mobile: 200 },
{ date: "2024-04-22", desktop: 224, mobile: 170 },
{ date: "2024-04-23", desktop: 138, mobile: 230 },
{ date: "2024-04-24", desktop: 387, mobile: 290 },
{ date: "2024-04-25", desktop: 215, mobile: 250 },
{ date: "2024-04-26", desktop: 75, mobile: 130 },
{ date: "2024-04-27", desktop: 383, mobile: 420 },
{ date: "2024-04-28", desktop: 122, mobile: 180 },
{ date: "2024-04-29", desktop: 315, mobile: 240 },
{ date: "2024-04-30", desktop: 454, mobile: 380 },
{ date: "2024-05-01", desktop: 165, mobile: 220 },
{ date: "2024-05-02", desktop: 293, mobile: 310 },
{ date: "2024-05-03", desktop: 247, mobile: 190 },
{ date: "2024-05-04", desktop: 385, mobile: 420 },
{ date: "2024-05-05", desktop: 481, mobile: 390 },
{ date: "2024-05-06", desktop: 498, mobile: 520 },
{ date: "2024-05-07", desktop: 388, mobile: 300 },
{ date: "2024-05-08", desktop: 149, mobile: 210 },
{ date: "2024-05-09", desktop: 227, mobile: 180 },
{ date: "2024-05-10", desktop: 293, mobile: 330 },
{ date: "2024-05-11", desktop: 335, mobile: 270 },
{ date: "2024-05-12", desktop: 197, mobile: 240 },
{ date: "2024-05-13", desktop: 197, mobile: 160 },
{ date: "2024-05-14", desktop: 448, mobile: 490 },
{ date: "2024-05-15", desktop: 473, mobile: 380 },
{ date: "2024-05-16", desktop: 338, mobile: 400 },
{ date: "2024-05-17", desktop: 499, mobile: 420 },
{ date: "2024-05-18", desktop: 315, mobile: 350 },
{ date: "2024-05-19", desktop: 235, mobile: 180 },
{ date: "2024-05-20", desktop: 177, mobile: 230 },
{ date: "2024-05-21", desktop: 82, mobile: 140 },
{ date: "2024-05-22", desktop: 81, mobile: 120 },
{ date: "2024-05-23", desktop: 252, mobile: 290 },
{ date: "2024-05-24", desktop: 294, mobile: 220 },
{ date: "2024-05-25", desktop: 201, mobile: 250 },
{ date: "2024-05-26", desktop: 213, mobile: 170 },
{ date: "2024-05-27", desktop: 420, mobile: 460 },
{ date: "2024-05-28", desktop: 233, mobile: 190 },
{ date: "2024-05-29", desktop: 78, mobile: 130 },
{ date: "2024-05-30", desktop: 340, mobile: 280 },
{ date: "2024-05-31", desktop: 178, mobile: 230 },
{ date: "2024-06-01", desktop: 178, mobile: 200 },
{ date: "2024-06-02", desktop: 470, mobile: 410 },
{ date: "2024-06-03", desktop: 103, mobile: 160 },
{ date: "2024-06-04", desktop: 439, mobile: 380 },
{ date: "2024-06-05", desktop: 88, mobile: 140 },
{ date: "2024-06-06", desktop: 294, mobile: 250 },
{ date: "2024-06-07", desktop: 323, mobile: 370 },
{ date: "2024-06-08", desktop: 385, mobile: 320 },
{ date: "2024-06-09", desktop: 438, mobile: 480 },
{ date: "2024-06-10", desktop: 155, mobile: 200 },
{ date: "2024-06-11", desktop: 92, mobile: 150 },
{ date: "2024-06-12", desktop: 492, mobile: 420 },
{ date: "2024-06-13", desktop: 81, mobile: 130 },
{ date: "2024-06-14", desktop: 426, mobile: 380 },
{ date: "2024-06-15", desktop: 307, mobile: 350 },
{ date: "2024-06-16", desktop: 371, mobile: 310 },
{ date: "2024-06-17", desktop: 475, mobile: 520 },
{ date: "2024-06-18", desktop: 107, mobile: 170 },
{ date: "2024-06-19", desktop: 341, mobile: 290 },
{ date: "2024-06-20", desktop: 408, mobile: 450 },
{ date: "2024-06-21", desktop: 169, mobile: 210 },
{ date: "2024-06-22", desktop: 317, mobile: 270 },
{ date: "2024-06-23", desktop: 480, mobile: 530 },
{ date: "2024-06-24", desktop: 132, mobile: 180 },
{ date: "2024-06-25", desktop: 141, mobile: 190 },
{ date: "2024-06-26", desktop: 434, mobile: 380 },
{ date: "2024-06-27", desktop: 448, mobile: 490 },
{ date: "2024-06-28", desktop: 149, mobile: 200 },
{ date: "2024-06-29", desktop: 103, mobile: 160 },
{ date: "2024-06-30", desktop: 446, mobile: 400 },
];
export const LineChart = () => {
const { t, i18n } = useTranslation(["common", "dashboard"]);
const [activeChart, setActiveChart] = React.useState<"desktop" | "mobile">(
"desktop",
);
const chartConfig = {
views: {
label: t("views"),
},
desktop: {
label: "desktop",
color: "var(--chart-1)",
},
mobile: {
label: "mobile",
color: "var(--chart-2)",
},
} satisfies ChartConfig;
const total = {
desktop: chartData.reduce((acc, curr) => acc + curr.desktop, 0),
mobile: chartData.reduce((acc, curr) => acc + curr.mobile, 0),
};
return (
<Card>
<CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0 sm:flex-row">
<div className="flex flex-1 flex-col justify-center gap-0.5 px-6 py-5 sm:py-6">
<CardTitle className="text-xl">{t("chart.line")}</CardTitle>
<CardDescription>{t("chart.showing")}</CardDescription>
</div>
<div className="flex">
{(["desktop", "mobile"] as const).map((key) => (
<button
key={key}
data-active={activeChart === key}
className="data-[active=true]:bg-muted/50 flex flex-1 flex-col justify-center gap-1 border-t px-6 py-4 text-left even:border-l sm:border-t-0 sm:border-l sm:px-8 sm:py-6"
onClick={() => setActiveChart(key)}
>
<span className="text-muted-foreground text-xs">{t(key)}</span>
<span className="text-lg leading-none font-bold sm:text-3xl">
{total[key].toLocaleString(i18n.language)}
</span>
</button>
))}
</div>
</CardHeader>
<CardContent className="px-2 sm:p-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[250px] w-full"
>
<RechartsLineChart
accessibilityLayer
data={chartData}
margin={{
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={(value: string) => {
const date = new Date(value);
return date.toLocaleDateString(i18n.language, {
month: "short",
day: "numeric",
});
}}
/>
<ChartTooltip
content={
<ChartTooltipContent
className="w-[150px]"
nameKey="views"
labelFormatter={(value: string) => {
return new Date(value).toLocaleDateString(i18n.language, {
month: "short",
day: "numeric",
year: "numeric",
});
}}
/>
}
/>
<Line
dataKey={activeChart}
type="monotone"
stroke={chartConfig[activeChart].color}
strokeWidth={2}
dot={false}
/>
</RechartsLineChart>
</ChartContainer>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,188 @@
"use client";
import dayjs from "dayjs";
import * as React from "react";
import { Label, Pie, PieChart as RechartsPieChart, Sector } from "recharts";
import { useTranslation } from "@turbostarter/i18n";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@turbostarter/ui-web/card";
import {
ChartContainer,
ChartStyle,
ChartTooltip,
ChartTooltipContent,
} from "@turbostarter/ui-web/chart";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@turbostarter/ui-web/select";
import type { ChartConfig } from "@turbostarter/ui-web/chart";
import type { PieSectorDataItem } from "recharts/types/polar/Pie";
const desktopData = [
{ month: "january", desktop: 186, fill: "var(--chart-1)" },
{ month: "february", desktop: 305, fill: "var(--chart-2)" },
{ month: "march", desktop: 237, fill: "var(--chart-3)" },
{ month: "april", desktop: 173, fill: "var(--chart-4)" },
{ month: "may", desktop: 209, fill: "var(--chart-5)" },
];
const chartConfig = {
january: {
label: dayjs().month(0).format("MMMM"),
color: "var(--chart-1)",
},
february: {
label: dayjs().month(1).format("MMMM"),
color: "var(--chart-2)",
},
march: {
label: dayjs().month(2).format("MMMM"),
color: "var(--chart-3)",
},
april: {
label: dayjs().month(3).format("MMMM"),
color: "var(--chart-4)",
},
may: {
label: dayjs().month(4).format("MMMM"),
color: "var(--chart-5)",
},
} satisfies ChartConfig;
export function PieChart() {
const { t, i18n } = useTranslation(["common", "dashboard"]);
const id = "pie-interactive";
const [activeMonth, setActiveMonth] = React.useState(
desktopData[0]?.month ?? "january",
);
const activeIndex = React.useMemo(
() => desktopData.findIndex((item) => item.month === activeMonth),
[activeMonth],
);
const months = desktopData.map((item) => item.month);
return (
<Card data-chart={id} className="flex flex-col">
<ChartStyle id={id} config={chartConfig} />
<CardHeader className="flex-row items-start space-y-0 pb-0">
<div className="grid gap-0.5">
<CardTitle className="text-xl">{t("chart.pie")}</CardTitle>
<CardDescription>{t("chart.period")}</CardDescription>
</div>
<Select value={activeMonth} onValueChange={setActiveMonth}>
<SelectTrigger
className="ml-auto w-[130px] rounded-lg pl-3"
aria-label={t("selectMonth")}
size="sm"
>
<SelectValue placeholder={t("selectMonth")} />
</SelectTrigger>
<SelectContent align="end" className="rounded-xl">
{months.map((key) => {
const config = chartConfig[key as keyof typeof chartConfig];
return (
<SelectItem
key={key}
value={key}
className="rounded-lg [&_span]:flex"
>
<div className="flex items-center gap-2 text-xs">
<span
className="flex h-3 w-3 shrink-0 rounded-sm"
style={{
backgroundColor:
"color" in config ? config.color : undefined,
}}
/>
{config.label}
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</CardHeader>
<CardContent className="flex flex-1 justify-center pb-0">
<ChartContainer
id={id}
config={chartConfig}
className="mx-auto aspect-square w-full max-w-[300px]"
>
<RechartsPieChart>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent hideLabel />}
/>
<Pie
data={desktopData}
dataKey="desktop"
nameKey="month"
innerRadius={60}
strokeWidth={5}
activeIndex={activeIndex}
activeShape={({
outerRadius = 0,
...props
}: PieSectorDataItem) => (
<g>
<Sector {...props} outerRadius={outerRadius + 10} />
<Sector
{...props}
outerRadius={outerRadius + 25}
innerRadius={outerRadius + 12}
/>
</g>
)}
>
<Label
content={({ viewBox }) => {
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
const data = desktopData[activeIndex];
if (!data) return null;
return (
<text
x={viewBox.cx}
y={viewBox.cy}
textAnchor="middle"
dominantBaseline="middle"
>
<tspan
x={viewBox.cx}
y={viewBox.cy}
className="fill-foreground text-3xl font-bold"
>
{data.desktop.toLocaleString(i18n.language)}
</tspan>
<tspan
x={viewBox.cx}
y={(viewBox.cy ?? 0) + 24}
className="fill-muted-foreground"
>
{t("visitors")}
</tspan>
</text>
);
}
}}
/>
</Pie>
</RechartsPieChart>
</ChartContainer>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,141 @@
"use client";
import dayjs from "dayjs";
import { TrendingUp } from "lucide-react";
import {
PolarAngleAxis,
PolarGrid,
Radar,
RadarChart as RechartsRadarChart,
} from "recharts";
import { useTranslation } from "@turbostarter/i18n";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@turbostarter/ui-web/card";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@turbostarter/ui-web/chart";
import type { ChartConfig } from "@turbostarter/ui-web/chart";
const chartData = [
{ month: dayjs().month(0).format("MMMM"), desktop: 186, mobile: 80 },
{ month: dayjs().month(1).format("MMMM"), desktop: 305, mobile: 200 },
{ month: dayjs().month(2).format("MMMM"), desktop: 237, mobile: 120 },
{ month: dayjs().month(3).format("MMMM"), desktop: 73, mobile: 190 },
{ month: dayjs().month(4).format("MMMM"), desktop: 209, mobile: 130 },
{ month: dayjs().month(5).format("MMMM"), desktop: 214, mobile: 140 },
];
export function RadarChart() {
const { t } = useTranslation(["common", "dashboard"]);
const chartConfig = {
desktop: {
label: t("desktop"),
color: "var(--chart-1)",
},
mobile: {
label: t("mobile"),
color: "var(--chart-4)",
},
} satisfies ChartConfig;
return (
<Card>
<CardHeader className="items-center space-y-0.5 pb-4">
<CardTitle className="text-xl">{t("chart.radar")}</CardTitle>
<CardDescription>{t("chart.period")}</CardDescription>
</CardHeader>
<CardContent className="pb-0">
<ChartContainer
config={chartConfig}
className="mx-auto aspect-square max-h-[250px]"
>
<RechartsRadarChart
data={chartData}
margin={{
top: 10,
right: 10,
bottom: 10,
left: 10,
}}
>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="line" />}
/>
<PolarAngleAxis
dataKey="month"
tick={({
x,
y,
textAnchor,
index,
...props
}: {
x: number;
y: number;
textAnchor: "start" | "end" | "middle" | "inherit" | undefined;
index: number;
props: unknown;
}) => {
const data = chartData[index]!;
return (
<text
x={x}
y={index === 0 ? y - 10 : y}
textAnchor={textAnchor}
fontSize={13}
fontWeight={500}
{...props}
>
<tspan className="fill-muted-foreground">
{data.desktop}
</tspan>
<tspan className="fill-muted-foreground">/</tspan>
<tspan className="fill-muted-foreground">
{data.mobile}
</tspan>
<tspan
x={x}
dy={"1rem"}
fontSize={12}
className="fill-muted-foreground"
>
{data.month}
</tspan>
</text>
);
}}
/>
<PolarGrid />
<Radar
dataKey="desktop"
fill={chartConfig.desktop.color}
fillOpacity={0.6}
/>
<Radar dataKey="mobile" fill={chartConfig.mobile.color} />
</RechartsRadarChart>
</ChartContainer>
</CardContent>
<CardFooter className="flex-col gap-2 text-sm">
<div className="flex items-center gap-2 leading-none font-medium">
{t("chart.trending")} <TrendingUp className="h-4 w-4" />
</div>
<div className="text-muted-foreground flex items-center gap-2 leading-none">
{t("chart.period")}
</div>
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,87 @@
"use client";
import { PolarGrid, RadialBar, RadialBarChart } from "recharts";
import { useTranslation } from "@turbostarter/i18n";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@turbostarter/ui-web/card";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@turbostarter/ui-web/chart";
import { Icons } from "@turbostarter/ui-web/icons";
import type { ChartConfig } from "@turbostarter/ui-web/chart";
const chartData = [
{ browser: "chrome", visitors: 275, fill: "var(--chart-1)" },
{ browser: "safari", visitors: 200, fill: "var(--chart-2)" },
{ browser: "firefox", visitors: 187, fill: "var(--chart-3)" },
{ browser: "edge", visitors: 173, fill: "var(--chart-4)" },
{ browser: "opera", visitors: 90, fill: "var(--chart-5)" },
];
const chartConfig = {
chrome: {
label: "Chrome",
color: "var(--chart-1)",
},
safari: {
label: "Safari",
color: "var(--chart-2)",
},
firefox: {
label: "Firefox",
color: "var(--chart-3)",
},
edge: {
label: "Edge",
color: "var(--chart-4)",
},
opera: {
label: "Opera",
color: "var(--chart-5)",
},
} satisfies ChartConfig;
export function RadialChart() {
const { t } = useTranslation(["common", "dashboard"]);
return (
<Card className="flex flex-col">
<CardHeader className="items-center space-y-0.5 pb-0">
<CardTitle className="text-xl">{t("chart.radial")}</CardTitle>
<CardDescription>{t("chart.period")}</CardDescription>
</CardHeader>
<CardContent className="flex-1 pb-0">
<ChartContainer
config={chartConfig}
className="mx-auto aspect-square max-h-[250px]"
>
<RadialBarChart data={chartData} innerRadius={30} outerRadius={100}>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent hideLabel nameKey="browser" />}
/>
<PolarGrid gridType="circle" />
<RadialBar dataKey="visitors" />
</RadialBarChart>
</ChartContainer>
</CardContent>
<CardFooter className="flex-col gap-2 text-sm">
<div className="flex items-center gap-2 leading-none font-medium">
{t("chart.trending")} <Icons.TrendingUp className="h-4 w-4" />
</div>
<div className="text-muted-foreground leading-none">
{t("chart.showing")}
</div>
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,107 @@
"use client";
import { TrendingUp } from "lucide-react";
import {
Label,
PolarGrid,
PolarRadiusAxis,
RadialBar,
RadialBarChart,
} from "recharts";
import { useTranslation } from "@turbostarter/i18n";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@turbostarter/ui-web/card";
import { ChartContainer } from "@turbostarter/ui-web/chart";
import type { ChartConfig } from "@turbostarter/ui-web/chart";
const chartData = [
{ browser: "safari", visitors: 1260, fill: "var(--chart-2)" },
];
const chartConfig = {
safari: {
label: "Safari",
color: "var(--chart-2)",
},
} satisfies ChartConfig;
export function ShapeChart() {
const { t, i18n } = useTranslation(["common", "dashboard"]);
return (
<Card className="flex flex-col">
<CardHeader className="items-center space-y-0.5 pb-0">
<CardTitle className="text-xl">{t("chart.shape")}</CardTitle>
<CardDescription>{t("chart.period")}</CardDescription>
</CardHeader>
<CardContent className="flex-1 pb-0">
<ChartContainer
config={chartConfig}
className="mx-auto aspect-square max-h-[250px]"
>
<RadialBarChart
data={chartData}
endAngle={100}
innerRadius={80}
outerRadius={140}
>
<PolarGrid
gridType="circle"
radialLines={false}
stroke="none"
className="first:fill-muted last:fill-background"
polarRadius={[86, 74]}
/>
<RadialBar dataKey="visitors" background />
<PolarRadiusAxis tick={false} tickLine={false} axisLine={false}>
<Label
content={({ viewBox }) => {
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
const data = chartData[0];
if (!data) return null;
return (
<text
x={viewBox.cx}
y={viewBox.cy}
textAnchor="middle"
dominantBaseline="middle"
>
<tspan
x={viewBox.cx}
y={viewBox.cy}
className="fill-foreground text-4xl font-bold"
>
{data.visitors.toLocaleString(i18n.language)}
</tspan>
<tspan
x={viewBox.cx}
y={(viewBox.cy ?? 0) + 24}
className="fill-muted-foreground"
>
{t("visitors")}
</tspan>
</text>
);
}
}}
/>
</PolarRadiusAxis>
</RadialBarChart>
</ChartContainer>
</CardContent>
<CardFooter className="flex-col gap-2 text-sm">
<div className="flex items-center gap-2 leading-none font-medium">
{t("chart.trending")} <TrendingUp className="h-4 w-4" />
</div>
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,116 @@
"use client";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useParams, usePathname } from "next/navigation";
import { useEffect, useMemo } from "react";
import { useDebounceCallback } from "@turbostarter/shared/hooks";
import { pathsConfig } from "~/config/paths";
import { authClient } from "~/lib/auth/client";
import { organization } from "../lib/api";
import type { MemberRole } from "@turbostarter/auth";
const DEBOUNCE_SET_ACTIVE_MS = 1000;
export const useActiveOrganization = () => {
const session = authClient.useSession();
const member = authClient.useActiveMember();
const pathname = usePathname();
const params = useParams();
const slug = params.organization?.toString();
const allowRefetch = useMemo(
() => !!(slug ?? pathname.startsWith(pathsConfig.dashboard.user.index)),
[pathname, slug],
);
const { data: activeOrganization, isLoading } = useQuery({
...organization.queries.get({ slug: slug ?? "" }),
enabled: !!slug,
});
const setActiveOrganization = useMutation({
...organization.mutations.setActive,
onSuccess: async () => {
await session.refetch();
},
});
const debouncedSetActiveOrganization = useDebounceCallback(
setActiveOrganization.mutate,
DEBOUNCE_SET_ACTIVE_MS,
);
const activeMember = useMemo(() => {
const data =
member.data ??
activeOrganization?.members.find(
(member) => member.userId === session.data?.user.id,
);
return data ? { ...data, role: data.role as MemberRole } : null;
}, [member.data, activeOrganization, session.data]);
const activeOrganizationId = activeOrganization?.id ?? null;
const memberOrganizationId = member.data?.organizationId ?? null;
const sessionActiveOrganizationId =
session.data?.session.activeOrganizationId ?? null;
const shouldRefetchMember = useMemo(() => {
return (
!member.isPending &&
!member.isRefetching &&
memberOrganizationId !== activeOrganizationId &&
allowRefetch
);
}, [
member.isPending,
member.isRefetching,
memberOrganizationId,
activeOrganizationId,
allowRefetch,
]);
useEffect(() => {
if (shouldRefetchMember) {
void member.refetch();
}
}, [member, shouldRefetchMember]);
const shouldUpdateActiveOrganization = useMemo(() => {
return (
!session.isPending &&
!!session.data &&
!(isLoading && !activeOrganizationId) &&
sessionActiveOrganizationId !== activeOrganizationId &&
allowRefetch
);
}, [
session.isPending,
session.data,
isLoading,
activeOrganizationId,
sessionActiveOrganizationId,
allowRefetch,
]);
useEffect(() => {
if (shouldUpdateActiveOrganization) {
debouncedSetActiveOrganization({
organizationId: activeOrganizationId,
});
}
}, [
shouldUpdateActiveOrganization,
activeOrganizationId,
debouncedSetActiveOrganization,
]);
return {
activeOrganization,
activeMember,
};
};

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

View File

@@ -0,0 +1,183 @@
import {
getInvitationsResponseSchema,
getMembersResponseSchema,
} from "@turbostarter/api/schema";
import { handle } from "@turbostarter/api/utils";
import { appConfig } from "~/config/app";
import { pathsConfig } from "~/config/paths";
import { api } from "~/lib/api/client";
import { authClient } from "~/lib/auth/client";
import type { InferRequestType } from "hono/client";
const KEY = "organizations";
const queries = {
get: (params: { slug: string } | { id: string }) => ({
queryKey: [KEY, params],
queryFn: () =>
authClient.organization.getFullOrganization({
query:
"id" in params
? { organizationId: params.id }
: { organizationSlug: params.slug },
fetchOptions: {
throw: true,
},
}),
}),
members: {
getIsOnlyOwner: ({ id }: { id: string }) => ({
queryKey: [...queries.get({ id }).queryKey, "members", "is-only-owner"],
queryFn: () =>
handle(api.organizations[":id"].members["is-only-owner"].$get)({
param: { id },
}),
}),
getAll: ({
id,
...query
}: InferRequestType<
(typeof api.organizations)[":id"]["members"]["$get"]
>["query"] & { id: string }) => ({
queryKey: [...queries.get({ id }).queryKey, "members", query],
queryFn: () =>
handle(api.organizations[":id"].members.$get, {
schema: getMembersResponseSchema,
})({
query,
param: {
id,
},
}),
}),
},
invitations: {
getAll: ({
id,
...query
}: InferRequestType<
(typeof api.organizations)[":id"]["invitations"]["$get"]
>["query"] & { id: string }) => ({
queryKey: [...queries.get({ id }).queryKey, "invitations", query],
queryFn: () =>
handle(api.organizations[":id"].invitations.$get, {
schema: getInvitationsResponseSchema,
})({
query,
param: {
id,
},
}),
}),
},
};
const mutations = {
getSlug: {
mutationKey: [KEY, "slug"],
mutationFn: (data: InferRequestType<typeof api.organizations.slug.$get>) =>
handle(api.organizations.slug.$get)(data),
},
setActive: {
mutationKey: [KEY, "active"],
mutationFn: (
params: Parameters<typeof authClient.organization.setActive>[0],
) => authClient.organization.setActive(params),
},
create: {
mutationKey: [KEY, "create"],
mutationFn: (
params: Parameters<typeof authClient.organization.create>[0],
) =>
authClient.organization.create({
...params,
fetchOptions: { throw: true },
}),
},
update: {
mutationKey: [KEY, "update"],
mutationFn: (
params: Parameters<typeof authClient.organization.update>[0],
) => authClient.organization.update(params),
},
delete: {
mutationKey: [KEY, "delete"],
mutationFn: (
params: Parameters<typeof authClient.organization.delete>[0],
) => authClient.organization.delete(params),
},
leave: {
mutationKey: [KEY, "members", "leave"],
mutationFn: (params: Parameters<typeof authClient.organization.leave>[0]) =>
authClient.organization.leave(params),
},
members: {
remove: {
mutationKey: [KEY, "members", "remove"],
mutationFn: (
params: Parameters<typeof authClient.organization.removeMember>[0],
) => authClient.organization.removeMember(params),
},
invite: {
mutationKey: [KEY, "members", "invite"],
mutationFn: (
params: Parameters<typeof authClient.organization.inviteMember>[0],
) =>
authClient.organization.inviteMember(params, {
headers: {
"x-url": new URL(pathsConfig.auth.join, appConfig.url).toString(),
},
}),
},
updateRole: {
mutationKey: [KEY, "members", "update-role"],
mutationFn: (
params: Parameters<typeof authClient.organization.updateMemberRole>[0],
) => authClient.organization.updateMemberRole(params),
},
},
invitations: {
accept: {
mutationKey: [KEY, "invitations", "accept"],
mutationFn: (
params: Parameters<typeof authClient.organization.acceptInvitation>[0],
) => authClient.organization.acceptInvitation(params),
},
reject: {
mutationKey: [KEY, "invitations", "reject"],
mutationFn: (
params: Parameters<typeof authClient.organization.rejectInvitation>[0],
) => authClient.organization.rejectInvitation(params),
},
cancel: {
mutationKey: [KEY, "invitations", "cancel"],
mutationFn: (
params: Parameters<typeof authClient.organization.cancelInvitation>[0],
) => authClient.organization.cancelInvitation(params),
},
resend: {
mutationKey: [KEY, "invitations", "resend"],
mutationFn: (
params: Parameters<typeof authClient.organization.inviteMember>[0],
) =>
authClient.organization.inviteMember(
{
...params,
resend: true,
},
{
headers: {
"x-url": new URL(pathsConfig.auth.join, appConfig.url).toString(),
},
},
),
},
},
};
export const organization = {
queries,
mutations,
} as const;

View File

@@ -0,0 +1,293 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { getAllRolesAtOrBelow, MemberRole } from "@turbostarter/auth";
import { useTranslation } from "@turbostarter/i18n";
import {
Avatar,
AvatarFallback,
AvatarImage,
} 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,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@turbostarter/ui-web/dropdown-menu";
import { Icons } from "@turbostarter/ui-web/icons";
import { pathsConfig } from "~/config/paths";
import { authClient } from "~/lib/auth/client";
import { organization } from "~/modules/organization/lib/api";
import { useActiveOrganization } from "../../hooks/use-active-organization";
import { UpdateMemberRoleModal } from "../update-member-role";
import type { ColumnDef } from "@tanstack/react-table";
import type { Member } from "@turbostarter/auth";
const Actions = ({ member }: { member: Member }) => {
const { t } = useTranslation(["common", "auth", "organization"]);
const router = useRouter();
const queryClient = useQueryClient();
const { data: session } = authClient.useSession();
const { refetch } = authClient.useListOrganizations();
const { activeMember } = useActiveOrganization();
const me = member.userId === session?.user.id;
const { data: isOnlyOwner } = useQuery({
...organization.queries.members.getIsOnlyOwner({
id: member.organizationId,
}),
enabled: me,
retry: false,
});
const hasDeletePermission =
authClient.organization.checkRolePermission({
permission: {
member: ["delete"],
},
role: activeMember?.role ?? MemberRole.MEMBER,
}) &&
getAllRolesAtOrBelow(activeMember?.role ?? MemberRole.MEMBER).includes(
member.role,
) &&
!(me && isOnlyOwner?.status);
const hasUpdatePermission =
authClient.organization.checkRolePermission({
permission: {
member: ["update"],
},
role: activeMember?.role ?? MemberRole.MEMBER,
}) &&
getAllRolesAtOrBelow(activeMember?.role ?? MemberRole.MEMBER).includes(
member.role,
) &&
!(me && isOnlyOwner?.status);
const removeMember = useMutation({
...organization.mutations.members.remove,
onSuccess: async () => {
await queryClient.invalidateQueries(
organization.queries.members.getAll({
id: member.organizationId,
}),
);
toast.success(t("members.remove.success"));
},
});
const leaveOrganization = useMutation({
...organization.mutations.leave,
onSuccess: () => {
void refetch();
router.replace(pathsConfig.dashboard.user.index);
toast.success(t("leave.success"));
},
});
const groups = [
hasUpdatePermission
? [
<UpdateMemberRoleModal
member={member}
key={`update-role-${member.id}`}
>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
{t("updateRole")}
</DropdownMenuItem>
</UpdateMemberRoleModal>,
]
: null,
[
hasDeletePermission
? me
? (() => {
const isPending =
leaveOrganization.isPending &&
leaveOrganization.variables.organizationId ===
member.organizationId;
return (
<DropdownMenuItem
variant="destructive"
onClick={() =>
leaveOrganization.mutate({
organizationId: member.organizationId,
})
}
disabled={isPending}
key={`leave-${member.id}`}
>
{t("common:leave")}
{isPending && (
<Icons.Loader2 className="ml-auto animate-spin text-current" />
)}
</DropdownMenuItem>
);
})()
: (() => {
const isPending =
removeMember.isPending &&
removeMember.variables.memberIdOrEmail === member.id &&
removeMember.variables.organizationId === member.organizationId;
return (
<DropdownMenuItem
variant="destructive"
onClick={() =>
removeMember.mutate({
memberIdOrEmail: member.id,
organizationId: member.organizationId,
})
}
disabled={isPending}
key={`remove-${member.id}`}
>
{t("common:remove")}
{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}>
<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<Member>[] => {
const { t, i18n } = useTranslation("common");
const { data: session } = authClient.useSession();
return [
{
id: "q",
accessorKey: "q",
meta: {
placeholder: `${t("searchPlaceholder")}`,
variant: "text",
},
enableHiding: false,
enableColumnFilter: true,
},
{
id: "user.name",
accessorKey: "user.name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("name")} />
),
cell: ({ row }) => {
return (
<div className="flex items-center gap-3">
<Avatar>
<AvatarImage
src={row.original.user.image ?? undefined}
alt={row.original.user.name}
/>
<AvatarFallback>
<Icons.UserRound className="w-5" />
</AvatarFallback>
</Avatar>
<div className="flex items-center gap-2">
<span className="truncate font-medium">
{row.original.user.name}
</span>
{row.original.userId === session?.user.id && (
<Badge variant="outline">{t("you")}</Badge>
)}
</div>
</div>
);
},
enableHiding: false,
},
{
id: "user.email",
accessorKey: "user.email",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("email")} />
),
meta: {
label: t("email"),
},
},
{
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: "createdAt",
accessorKey: "createdAt",
header: ({ column }) => (
<div className="ml-auto w-fit">
<DataTableColumnHeader column={column} title={t("joinedAt")} />
</div>
),
cell: ({ row }) => {
return (
<div className="ml-auto text-right">
{new Date(row.original.createdAt).toLocaleDateString(i18n.language)}
</div>
);
},
meta: {
label: t("joinedAt"),
variant: "dateRange",
},
enableColumnFilter: true,
},
{
id: "actions",
cell: ({ row }) => (
<div className="ml-auto w-fit">
<Actions member={row.original} />
</div>
),
enableHiding: false,
},
];
};

View File

@@ -0,0 +1,61 @@
"use client";
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 MembersDataTableProps {
readonly organizationId: string;
}
export const MembersDataTable = ({ organizationId }: MembersDataTableProps) => {
const columns = useColumns();
const { table, query } = useDataTable({
persistance: "local",
columns,
initialState: {
sorting: [
{
id: "user.name",
desc: false,
},
],
columnVisibility: {
q: false,
},
},
enableRowSelection: false,
query: ({ page, perPage, sorting, filters }) =>
organization.queries.members.getAll({
id: organizationId,
page: page.toString(),
perPage: perPage.toString(),
sort: JSON.stringify(sorting),
...pickBy(filters, Boolean),
}),
});
if (query.isLoading) {
return (
<DataTableSkeleton
columnCount={5}
filterCount={3}
cellWidths={["20rem", "10rem", "7rem", "4rem", "2.5rem"]}
shrinkZero
/>
);
}
return (
<div className="flex w-full flex-col gap-2">
<DataTableToolbar table={table} />
<DataTable table={table} />
</div>
);
};

View File

@@ -0,0 +1,241 @@
"use client";
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import {
getAllRolesAtOrBelow,
inviteMemberSchema,
MemberRole,
} from "@turbostarter/auth";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import { Button } from "@turbostarter/ui-web/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@turbostarter/ui-web/form";
import { Icons } from "@turbostarter/ui-web/icons";
import { Input } from "@turbostarter/ui-web/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@turbostarter/ui-web/select";
import { authClient } from "~/lib/auth/client";
import {
SettingsCard,
SettingsCardDescription,
SettingsCardContent,
SettingsCardFooter,
SettingsCardHeader,
SettingsCardTitle,
} from "~/modules/common/layout/dashboard/settings-card";
import { useActiveOrganization } from "~/modules/organization/hooks/use-active-organization";
import { organization } from "~/modules/organization/lib/api";
interface InviteMemberProps {
organizationId: string;
}
export const InviteMember = ({ organizationId }: InviteMemberProps) => {
const { t } = useTranslation(["common", "organization"]);
const queryClient = useQueryClient();
const { activeMember } = useActiveOrganization();
const schema = z.object({
invites: z.array(inviteMemberSchema).min(1),
});
const form = useForm({
resolver: standardSchemaResolver(schema),
defaultValues: {
invites: [{ email: "", role: MemberRole.MEMBER }],
},
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "invites",
});
const inviteMember = useMutation(organization.mutations.members.invite);
const onSubmit = async (data: z.infer<typeof schema>) => {
const results = await Promise.allSettled(
data.invites.map((invite) =>
inviteMember.mutateAsync({ ...invite, organizationId }),
),
);
const failedInvites = results
.map((result, idx) =>
result.status === "rejected" ? data.invites[idx] : null,
)
.filter(Boolean);
const successCount = data.invites.length - failedInvites.length;
if (successCount > 0) {
toast.success(t("members.invite.success", { count: successCount }));
}
if (failedInvites.length > 0) {
form.reset({ invites: failedInvites });
} else {
form.reset(undefined, { keepDefaultValues: true });
}
await queryClient.invalidateQueries(
organization.queries.invitations.getAll({ id: organizationId }),
);
};
const hasInvitePermission = authClient.organization.checkRolePermission({
permission: {
invitation: ["create"],
},
role: activeMember?.role ?? MemberRole.MEMBER,
});
return (
<SettingsCard disabled={!hasInvitePermission}>
<SettingsCardHeader>
<SettingsCardTitle>{t("members.invite.title")}</SettingsCardTitle>
<SettingsCardDescription>
{t("members.invite.description")}
</SettingsCardDescription>
</SettingsCardHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="mt-2">
<SettingsCardContent className="flex w-full flex-col gap-2">
{fields.map((field, index) => (
<div key={field.id} className="flex w-full gap-2">
<FormField
control={form.control}
name={`invites.${index}.email`}
render={({ field }) => (
<FormItem className="w-full">
<FormLabel className={cn({ "sr-only": index > 0 })}>
{t("email")}
</FormLabel>
<FormControl>
<Input
{...field}
type="email"
placeholder="jane@example.com"
disabled={
!hasInvitePermission || form.formState.isSubmitting
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`invites.${index}.role`}
render={({ field }) => (
<FormItem className="w-full">
<FormLabel className={cn({ "sr-only": index > 0 })}>
{t("role")}
</FormLabel>
<FormControl>
<div>
<Select
onValueChange={field.onChange}
value={field.value}
disabled={
!hasInvitePermission ||
form.formState.isSubmitting
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={t("member")} />
</SelectTrigger>
<SelectContent>
{getAllRolesAtOrBelow(
activeMember?.role ?? MemberRole.MEMBER,
).map((role) => (
<SelectItem key={role} value={role}>
{t(role)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{fields.length > 1 && (
<div
className={cn({
"translate-y-[1.35rem]": !index,
})}
>
<Button
type="button"
variant="outline"
size="icon"
className="gap-2"
disabled={
!hasInvitePermission || form.formState.isSubmitting
}
onClick={() => remove(index)}
>
<Icons.Trash className="size-4" />
<span className="sr-only"> {t("remove")}</span>
</Button>
</div>
)}
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => append({ email: "", role: MemberRole.MEMBER })}
className="mt-2 w-fit gap-2"
disabled={!hasInvitePermission || form.formState.isSubmitting}
>
<Icons.Plus className="size-4" /> {t("addMore")}
</Button>
</SettingsCardContent>
<SettingsCardFooter>
{hasInvitePermission ? (
<>
{t("members.invite.info")}
<Button disabled={form.formState.isSubmitting} size="sm">
{form.formState.isSubmitting ? (
<Icons.Loader2 className="size-4 animate-spin" />
) : (
t("invite")
)}
</Button>
</>
) : (
t("members.invite.missingPermission")
)}
</SettingsCardFooter>
</form>
</Form>
</SettingsCard>
);
};

View File

@@ -0,0 +1,163 @@
"use client";
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import {
getAllRolesAtOrBelow,
MemberRole,
updateMemberSchema,
} from "@turbostarter/auth";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-web/button";
import {
Form,
FormField,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
} from "@turbostarter/ui-web/form";
import { Icons } from "@turbostarter/ui-web/icons";
import {
Modal,
ModalTrigger,
ModalTitle,
ModalHeader,
ModalContent,
ModalDescription,
ModalClose,
ModalFooter,
} from "@turbostarter/ui-web/modal";
import {
Select,
SelectItem,
SelectValue,
SelectContent,
SelectTrigger,
} from "@turbostarter/ui-web/select";
import { organization } from "~/modules/organization/lib/api";
import { useActiveOrganization } from "../hooks/use-active-organization";
import type { Member } from "@turbostarter/auth";
interface UpdateMemberRoleModalProps {
readonly member: Member;
readonly children: React.ReactNode;
}
export const UpdateMemberRoleModal = ({
member,
children,
}: UpdateMemberRoleModalProps) => {
const { t } = useTranslation(["common", "organization"]);
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const { activeMember } = useActiveOrganization();
const form = useForm({
resolver: standardSchemaResolver(updateMemberSchema.pick({ role: true })),
defaultValues: {
role: member.role,
},
});
const updateMemberRole = useMutation({
...organization.mutations.members.updateRole,
onSuccess: async () => {
await queryClient.invalidateQueries(
organization.queries.members.getAll({ id: member.organizationId }),
);
toast.success(t("members.update.role.success"));
setOpen(false);
form.reset();
},
});
return (
<Modal open={open} onOpenChange={setOpen}>
<ModalTrigger asChild>{children}</ModalTrigger>
<ModalContent>
<ModalHeader>
<ModalTitle>
{t("members.update.role.title", {
name: member.user.name,
})}
</ModalTitle>
<ModalDescription>
{t("members.update.role.description")}
</ModalDescription>
</ModalHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit((data) =>
updateMemberRole.mutateAsync({
memberId: member.id,
role: data.role ?? MemberRole.MEMBER,
}),
)}
className="space-y-6"
>
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem className="px-4 md:px-0">
<FormLabel>{t("role")}</FormLabel>
<FormControl>
<div>
<Select
value={field.value}
onValueChange={field.onChange}
disabled={form.formState.isSubmitting}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={t("role")} />
</SelectTrigger>
<SelectContent>
{getAllRolesAtOrBelow(
activeMember?.role ?? MemberRole.MEMBER,
).map((role) => (
<SelectItem key={role} value={role}>
{t(role)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</FormControl>
<FormDescription>
{t("members.update.role.info")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<ModalFooter>
<ModalClose asChild>
<Button variant="outline" type="button">
{t("cancel")}
</Button>
</ModalClose>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? (
<Icons.Loader2 className="animate-spin" />
) : (
t("update")
)}
</Button>
</ModalFooter>
</form>
</Form>
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,81 @@
"use client";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@turbostarter/ui-web/avatar";
import { Button, buttonVariants } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
import { Skeleton } from "@turbostarter/ui-web/skeleton";
import { pathsConfig } from "~/config/paths";
import { authClient } from "~/lib/auth/client";
import { TurboLink } from "~/modules/common/turbo-link";
import { CreateOrganizationModal } from "./create-organization";
export const OrganizationPicker = () => {
const { data: organizations, isPending } = authClient.useListOrganizations();
const { t } = useTranslation("organization");
return (
<nav className="@container/picker w-full">
<ul
className="grid grid-cols-1 gap-4 @lg/picker:grid-cols-2 @2xl/picker:grid-cols-3"
aria-busy={isPending}
aria-live="polite"
>
{isPending &&
Array.from({ length: 2 }).map((_, index) => (
<li key={`skeleton-${index}`} aria-hidden="true">
<Skeleton className="h-36" />
</li>
))}
{organizations?.map((organization) => (
<li key={organization.id}>
<TurboLink
className={cn(
buttonVariants({ variant: "outline" }),
"text-muted-foreground h-36 w-full items-center justify-between gap-3 px-6 py-4 has-[>svg]:px-6",
)}
href={pathsConfig.dashboard.organization(organization.slug).index}
>
<div className="flex w-full min-w-0 flex-col items-start gap-3">
<Avatar className="size-16">
<AvatarImage src={organization.logo ?? undefined} alt="" />
<AvatarFallback>
<span className="text-muted-foreground text-xl uppercase">
{organization.name.charAt(0)}
</span>
</AvatarFallback>
</Avatar>
<span className="max-w-full truncate text-base">
{organization.name}
</span>
</div>
<Icons.ChevronRight className="mt-2 -mr-1 size-5 self-start" />
</TurboLink>
</li>
))}
<li>
<CreateOrganizationModal>
<Button
type="button"
variant="outline"
className="text-muted-foreground h-36 w-full flex-col gap-2 border-dashed"
aria-label={t("create.cta")}
>
<Icons.Plus className="size-8" strokeWidth={1.5} />
<span className="text-base">{t("create.cta")}</span>
</Button>
</CreateOrganizationModal>
</li>
</ul>
</nav>
);
};

View File

@@ -0,0 +1,133 @@
"use client";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { MemberRole } from "@turbostarter/auth";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
import {
Modal,
ModalClose,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
ModalTitle,
ModalTrigger,
} from "@turbostarter/ui-web/modal";
import { pathsConfig } from "~/config/paths";
import { authClient } from "~/lib/auth/client";
import {
SettingsCard,
SettingsCardHeader,
SettingsCardTitle,
SettingsCardDescription,
SettingsCardFooter,
} from "~/modules/common/layout/dashboard/settings-card";
import { organization } from "~/modules/organization/lib/api";
import { useActiveOrganization } from "../hooks/use-active-organization";
export const DeleteOrganization = ({
organizationId,
}: {
organizationId: string;
}) => {
const { t } = useTranslation("organization");
const { activeMember } = useActiveOrganization();
const { data: activeOrganization } = useQuery(
organization.queries.get({ id: organizationId }),
);
if (!activeOrganization) {
return null;
}
const hasDeletePermission = authClient.organization.checkRolePermission({
permission: {
organization: ["delete"],
},
role: activeMember?.role ?? MemberRole.MEMBER,
});
return (
<SettingsCard variant="destructive" disabled={!hasDeletePermission}>
<SettingsCardHeader>
<SettingsCardTitle>{t("delete.title")}</SettingsCardTitle>
<SettingsCardDescription>
{t("delete.description")}
</SettingsCardDescription>
</SettingsCardHeader>
<SettingsCardFooter>
{hasDeletePermission ? (
<ConfirmModal organizationId={activeOrganization.id}>
<Button size="sm" className="ml-auto" variant="destructive">
{t("delete.cta")}
</Button>
</ConfirmModal>
) : (
t("delete.missingPermission")
)}
</SettingsCardFooter>
</SettingsCard>
);
};
const ConfirmModal = ({
children,
organizationId,
}: {
children: React.ReactNode;
organizationId: string;
}) => {
const { t } = useTranslation(["common", "organization"]);
const router = useRouter();
const deleteOrganization = useMutation({
...organization.mutations.delete,
onSuccess: () => {
toast.success(t("delete.success"));
router.replace(pathsConfig.dashboard.user.index);
},
});
const handleDelete = () => {
deleteOrganization.mutate({ organizationId });
};
return (
<Modal>
<ModalTrigger asChild>{children}</ModalTrigger>
<ModalContent>
<ModalHeader>
<ModalTitle>{t("delete.title")}</ModalTitle>
<ModalDescription className="whitespace-pre-line">
{t("delete.disclaimer")}
</ModalDescription>
</ModalHeader>
<ModalFooter>
<ModalClose asChild>
<Button variant="outline">{t("cancel")}</Button>
</ModalClose>
<Button
onClick={handleDelete}
variant="destructive"
disabled={deleteOrganization.isPending}
>
{deleteOrganization.isPending ? (
<Icons.Loader2 className="animate-spin" />
) : (
t("common:delete")
)}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,128 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { MemberRole } from "@turbostarter/auth";
import { useTranslation } from "@turbostarter/i18n";
import { authClient } from "~/lib/auth/client";
import {
AvatarForm,
AvatarFormErrorMessage,
AvatarFormInput,
AvatarFormPreview,
AvatarFormRemoveButton,
} from "~/modules/common/avatar-form";
import {
SettingsCard,
SettingsCardDescription,
SettingsCardFooter,
SettingsCardHeader,
SettingsCardTitle,
} from "~/modules/common/layout/dashboard/settings-card";
import { organization } from "~/modules/organization/lib/api";
import { useActiveOrganization } from "../hooks/use-active-organization";
export const EditLogo = ({ organizationId }: { organizationId: string }) => {
const { t } = useTranslation(["common", "validation", "organization"]);
const { activeMember } = useActiveOrganization();
const queryClient = useQueryClient();
const { data: activeOrganization } = useQuery(
organization.queries.get({ id: organizationId }),
);
const updateOrganization = useMutation({
...organization.mutations.update,
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries(
organization.queries.get({ id: organizationId }),
),
]);
},
});
if (!activeOrganization) {
return null;
}
const hasUpdatePermission = authClient.organization.checkRolePermission({
permission: {
organization: ["update"],
},
role: activeMember?.role ?? MemberRole.MEMBER,
});
return (
<SettingsCard disabled={!hasUpdatePermission}>
<SettingsCardHeader className="block">
<AvatarForm
id={activeOrganization.id}
image={activeOrganization.logo}
update={(image) =>
updateOrganization.mutateAsync({
data: { logo: image ?? "" },
organizationId: activeOrganization.id,
})
}
>
<div className="relative float-right">
<AvatarFormInput
onUpload={async () => {
toast.success(t("logo.update.success"));
await Promise.all([
queryClient.invalidateQueries(
organization.queries.get({ id: activeOrganization.id }),
),
queryClient.invalidateQueries(
organization.queries.get({ slug: activeOrganization.slug }),
),
]);
}}
disabled={!hasUpdatePermission}
>
<AvatarFormPreview
fallback={
<span className="text-muted-foreground text-3xl uppercase">
{activeOrganization.name.charAt(0)}
</span>
}
/>
</AvatarFormInput>
{hasUpdatePermission && (
<AvatarFormRemoveButton
onRemove={async () => {
toast.success(t("logo.remove.success"));
await Promise.all([
queryClient.invalidateQueries(
organization.queries.get({ id: activeOrganization.id }),
),
queryClient.invalidateQueries(
organization.queries.get({
slug: activeOrganization.slug,
}),
),
]);
}}
/>
)}
</div>
<SettingsCardTitle>{t("common:logo")}</SettingsCardTitle>
<SettingsCardDescription className="py-1.5 whitespace-pre-line">
{t("logo.description")}
</SettingsCardDescription>
<AvatarFormErrorMessage className="mt-1" />
</AvatarForm>
</SettingsCardHeader>
<SettingsCardFooter>
{hasUpdatePermission ? t("logo.info") : t("logo.missingPermission")}
</SettingsCardFooter>
</SettingsCard>
);
};

View File

@@ -0,0 +1,142 @@
"use client";
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { MemberRole, updateOrganizationSchema } from "@turbostarter/auth";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-web/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@turbostarter/ui-web/form";
import { Icons } from "@turbostarter/ui-web/icons";
import { Input } from "@turbostarter/ui-web/input";
import { authClient } from "~/lib/auth/client";
import {
SettingsCard,
SettingsCardContent,
SettingsCardDescription,
SettingsCardFooter,
SettingsCardHeader,
SettingsCardTitle,
} from "~/modules/common/layout/dashboard/settings-card";
import { organization } from "~/modules/organization/lib/api";
import { onPromise } from "~/utils";
import { useActiveOrganization } from "../hooks/use-active-organization";
export const EditName = ({ organizationId }: { organizationId: string }) => {
const { t } = useTranslation(["common", "organization"]);
const { activeMember } = useActiveOrganization();
const queryClient = useQueryClient();
const { data: activeOrganization } = useQuery(
organization.queries.get({ id: organizationId }),
);
const form = useForm({
resolver: standardSchemaResolver(
updateOrganizationSchema.pick({ name: true }),
),
defaultValues: {
name: activeOrganization?.name,
},
});
const updateOrganization = useMutation({
...organization.mutations.update,
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries(
organization.queries.get({ id: organizationId }),
),
...(activeOrganization
? [
queryClient.invalidateQueries(
organization.queries.get({ slug: activeOrganization.slug }),
),
]
: []),
]);
toast.success(t("name.edit.success"));
},
});
if (!activeOrganization) {
return null;
}
const hasUpdatePermission = authClient.organization.checkRolePermission({
permission: {
organization: ["update"],
},
role: activeMember?.role ?? MemberRole.MEMBER,
});
return (
<SettingsCard disabled={!hasUpdatePermission}>
<SettingsCardHeader>
<SettingsCardTitle>{t("common:name")}</SettingsCardTitle>
<SettingsCardDescription>
{t("name.edit.description")}
</SettingsCardDescription>
</SettingsCardHeader>
<Form {...form}>
<form
onSubmit={onPromise(
form.handleSubmit((data) =>
updateOrganization.mutateAsync({ data, organizationId }),
),
)}
>
<SettingsCardContent>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
type="text"
disabled={
!hasUpdatePermission || form.formState.isSubmitting
}
className="max-w-xs"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsCardContent>
<SettingsCardFooter>
{hasUpdatePermission ? (
<>
{t("name.edit.info")}
<Button size="sm" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? (
<Icons.Loader2 className="size-4 animate-spin" />
) : (
t("common:save")
)}
</Button>
</>
) : (
t("name.edit.missingPermission")
)}
</SettingsCardFooter>
</form>
</Form>
</SettingsCard>
);
};

View File

@@ -0,0 +1,131 @@
"use client";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
import {
Modal,
ModalClose,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
ModalTitle,
ModalTrigger,
} from "@turbostarter/ui-web/modal";
import { pathsConfig } from "~/config/paths";
import { authClient } from "~/lib/auth/client";
import {
SettingsCard,
SettingsCardHeader,
SettingsCardTitle,
SettingsCardDescription,
SettingsCardFooter,
} from "~/modules/common/layout/dashboard/settings-card";
import { organization } from "~/modules/organization/lib/api";
export const LeaveOrganization = ({
organizationId,
}: {
organizationId: string;
}) => {
const { t } = useTranslation("organization");
const { data: activeOrganization } = useQuery(
organization.queries.get({ id: organizationId }),
);
const { data: isOnlyOwner } = useQuery({
...organization.queries.members.getIsOnlyOwner({ id: organizationId }),
retry: false,
});
const canLeave = !isOnlyOwner?.status;
if (!activeOrganization) {
return null;
}
return (
<SettingsCard variant="destructive" disabled={!canLeave}>
<SettingsCardHeader>
<SettingsCardTitle>{t("leave.title")}</SettingsCardTitle>
<SettingsCardDescription>
{t("leave.description")}
</SettingsCardDescription>
</SettingsCardHeader>
<SettingsCardFooter>
{canLeave ? (
<ConfirmLeaveModal organizationId={activeOrganization.id}>
<Button size="sm" className="ml-auto" variant="destructive">
{t("leave.cta")}
</Button>
</ConfirmLeaveModal>
) : (
t("leave.cannotLeaveAsOnlyOwner")
)}
</SettingsCardFooter>
</SettingsCard>
);
};
const ConfirmLeaveModal = ({
children,
organizationId,
}: {
children: React.ReactNode;
organizationId: string;
}) => {
const { t } = useTranslation(["common", "organization"]);
const router = useRouter();
const { refetch } = authClient.useListOrganizations();
const leaveOrganization = useMutation({
...organization.mutations.leave,
onSuccess: async () => {
await refetch();
toast.success(t("leave.success"));
router.replace(pathsConfig.dashboard.user.index);
},
});
const handleLeave = () => {
leaveOrganization.mutate({ organizationId });
};
return (
<Modal>
<ModalTrigger asChild>{children}</ModalTrigger>
<ModalContent>
<ModalHeader>
<ModalTitle>{t("leave.title")}</ModalTitle>
<ModalDescription className="whitespace-pre-line">
{t("leave.disclaimer")}
</ModalDescription>
</ModalHeader>
<ModalFooter>
<ModalClose asChild>
<Button variant="outline">{t("cancel")}</Button>
</ModalClose>
<Button
onClick={handleLeave}
variant="destructive"
disabled={leaveOrganization.isPending}
>
{leaveOrganization.isPending ? (
<Icons.Loader2 className="animate-spin" />
) : (
t("common:leave")
)}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};