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,218 @@
/* eslint-disable i18next/no-literal-string */
"use client";
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { Button } from "@turbostarter/ui-web/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@turbostarter/ui-web/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@turbostarter/ui-web/form";
import { Input } from "@turbostarter/ui-web/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@turbostarter/ui-web/select";
import { Textarea } from "@turbostarter/ui-web/textarea";
import { api } from "~/lib/api/client";
const formSchema = z.object({
action: z.enum(["set", "add", "deduct"]),
amount: z.coerce.number().int().positive("Amount must be positive"),
reason: z.string().max(500).optional(),
});
interface CreditsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
customer: {
id: string;
credits: number;
user?: { name: string | null } | null;
};
}
export function CreditsDialog({
open,
onOpenChange,
customer,
}: CreditsDialogProps) {
const queryClient = useQueryClient();
const form = useForm({
resolver: standardSchemaResolver(formSchema),
defaultValues: {
action: "add" as const,
amount: 100,
reason: "",
},
});
const mutation = useMutation({
mutationFn: async (data: z.output<typeof formSchema>) => {
const res = await api.admin.customers[":id"].credits.$patch({
param: { id: customer.id },
json: data,
});
if (!res.ok) throw new Error("Failed to update credits");
return res.json();
},
onSuccess: (result) => {
toast.success(
`Credits updated: ${result.previousBalance}${result.newBalance}`,
);
void queryClient.invalidateQueries({ queryKey: ["admin", "customers"] });
onOpenChange(false);
form.reset();
},
onError: () => {
toast.error("Failed to update credits");
},
});
const watchAction = form.watch("action");
const watchAmount = form.watch("amount");
const previewBalance = () => {
const amount = Number(watchAmount) || 0;
switch (watchAction) {
case "set":
return amount;
case "add":
return customer.credits + amount;
case "deduct":
return Math.max(0, customer.credits - amount);
}
};
const handleSubmit = (values: z.output<typeof formSchema>) => {
mutation.mutate(values);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Manage Credits</DialogTitle>
<DialogDescription>
{customer.user?.name ?? "Customer"} - Current balance:{" "}
<strong>{customer.credits}</strong>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={(e) => {
e.preventDefault();
void form.handleSubmit(handleSubmit)(e);
}}
>
<div className="space-y-4 py-4">
<FormField
control={form.control}
name="action"
render={({ field }) => (
<FormItem>
<FormLabel>Action</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="add">Add credits</SelectItem>
<SelectItem value="deduct">Deduct credits</SelectItem>
<SelectItem value="set">Set to exact amount</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="amount"
render={({ field }) => (
<FormItem>
<FormLabel>Amount</FormLabel>
<FormControl>
<Input
type="number"
min={1}
placeholder="100"
{...field}
value={field.value as number}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="reason"
render={({ field }) => (
<FormItem>
<FormLabel>Reason (optional)</FormLabel>
<FormControl>
<Textarea
placeholder="Support credit, promo, etc."
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="rounded-md bg-muted p-3 text-sm">
<span className="text-muted-foreground">New balance: </span>
<strong>{previewBalance()}</strong>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "Updating..." : "Update Credits"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,290 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
import { BillingStatus, config } from "@turbostarter/billing";
import { isKey, 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,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} 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 { admin } from "~/modules/admin/lib/api";
import { TurboLink } from "~/modules/common/turbo-link";
import { CreditsDialog } from "../components/credits-dialog";
import { invalidateCustomers } from "../server/invalidate";
import { UpdateCustomerPlanModal } from "../update-customer-plan";
import type { ColumnDef } from "@tanstack/react-table";
import type { User } from "@turbostarter/auth";
import type { Customer } from "@turbostarter/billing";
export const CustomerActions = ({
customer,
}: {
customer: Customer & { user: Pick<User, "name">; credits: number };
}) => {
const { t } = useTranslation(["common", "admin", "billing"]);
const queryClient = useQueryClient();
const [creditsDialogOpen, setCreditsDialogOpen] = useState(false);
const deleteCustomer = useMutation({
...admin.mutations.customers.delete,
onSuccess: async () => {
await invalidateCustomers();
await queryClient.invalidateQueries(
admin.queries.users.getPlans({
id: customer.userId,
}),
);
toast.success(t("customers.customer.delete.success"));
},
});
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<span className="sr-only">{t("actions")}</span>
<Icons.Ellipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<UpdateCustomerPlanModal
customer={customer}
key={`update-plan-${customer.id}`}
>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
{t("updatePlan")}
</DropdownMenuItem>
</UpdateCustomerPlanModal>
<DropdownMenuItem onSelect={() => setCreditsDialogOpen(true)}>
<Icons.HandCoins className="mr-2 size-4" />
{t("manageCredits", { defaultValue: "Manage Credits" })}
</DropdownMenuItem>
<DropdownMenuSeparator />
{(() => {
const isPending =
deleteCustomer.isPending &&
deleteCustomer.variables.id === customer.id;
return (
<DropdownMenuItem
variant="destructive"
onClick={() =>
deleteCustomer.mutate({
id: customer.id,
})
}
disabled={isPending}
key={`remove-${customer.id}`}
>
{t("delete")}
{isPending && (
<Icons.Loader2 className="ml-auto animate-spin text-current" />
)}
</DropdownMenuItem>
);
})()}
</DropdownMenuContent>
</DropdownMenu>
<CreditsDialog
open={creditsDialogOpen}
onOpenChange={setCreditsDialogOpen}
customer={customer}
/>
</>
);
};
export const useColumns = (): ColumnDef<
Customer & { user: Pick<User, "name" | "image">; credits: number }
>[] => {
const { t, i18n } = useTranslation(["common", "billing"]);
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 (
<TurboLink
href={pathsConfig.admin.users.user(row.original.userId)}
className="group 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="group-hover:text-primary truncate font-medium underline underline-offset-4">
{row.original.user.name}
</span>
{row.original.userId === session?.user.id && (
<Badge variant="outline">{t("you")}</Badge>
)}
</div>
</TurboLink>
);
},
enableHiding: false,
},
{
id: "customerId",
accessorKey: "customerId",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("id")} />
),
meta: {
label: t("id"),
},
},
{
id: "plan",
accessorKey: "plan",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("common:plan")} />
),
cell: ({ row }) => {
const plan = config.plans.find((plan) => plan.id === row.original.plan);
if (!plan) {
return <span>-</span>;
}
return (
<Badge variant="outline">
{isKey(plan.name, i18n, "billing") ? t(plan.name) : plan.name}
</Badge>
);
},
meta: {
label: t("common:plan"),
variant: "multiSelect",
options: Object.values(config.plans).map((plan) => ({
label: isKey(plan.name, i18n, "billing") ? t(plan.name) : plan.name,
value: plan.id,
})),
},
enableColumnFilter: true,
},
{
id: "status",
accessorKey: "status",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("common:status")} />
),
cell: ({ row }) => {
const statusKey = `status.${row.original.status?.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase())}`;
if (!row.original.status) {
return <span>-</span>;
}
return (
<Badge variant="secondary">
{isKey(statusKey, i18n, "billing") ? t(statusKey) : statusKey}
</Badge>
);
},
meta: {
label: t("common:status"),
variant: "multiSelect",
options: Object.values(BillingStatus).map((status) => {
const statusKey = `status.${status.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase())}`;
return {
label: isKey(statusKey, i18n, "billing") ? t(statusKey) : statusKey,
value: status,
};
}),
},
enableColumnFilter: true,
},
{
id: "credits",
accessorKey: "credits",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Credits" />
),
cell: ({ row }) => {
return (
<div className="flex items-center gap-1.5">
<Icons.HandCoins className="size-4 text-muted-foreground" />
<span className="font-medium tabular-nums">
{row.original.credits.toLocaleString()}
</span>
</div>
);
},
meta: {
label: "Credits",
},
},
{
id: "createdAt",
accessorKey: "createdAt",
header: ({ column }) => (
<div className="ml-auto w-fit">
<DataTableColumnHeader column={column} title={t("createdAt")} />
</div>
),
cell: ({ row }) => {
return (
<div className="ml-auto text-right">
{row.original.createdAt.toLocaleDateString(i18n.language)}
</div>
);
},
meta: {
label: t("createdAt"),
variant: "dateRange",
},
enableColumnFilter: true,
},
{
id: "actions",
cell: ({ row }) => (
<div className="ml-auto w-fit">
<CustomerActions customer={row.original} />
</div>
),
enableHiding: false,
},
];
};

View File

@@ -0,0 +1,53 @@
"use client";
import { use } from "react";
import { DataTable } from "@turbostarter/ui-web/data-table/data-table";
import { DataTableToolbar } from "@turbostarter/ui-web/data-table/data-table-toolbar";
import { useDataTable } from "~/modules/common/hooks/use-data-table";
import { useColumns } from "./columns";
import type { GetCustomersResponse } from "@turbostarter/api/schema";
interface CustomersDataTableProps {
readonly promise: Promise<Awaited<GetCustomersResponse>>;
readonly perPage: number;
}
export const CustomersDataTable = ({
promise,
perPage,
}: CustomersDataTableProps) => {
const columns = useColumns();
const { data, total } = use(promise);
const { table } = useDataTable({
persistance: "searchParams",
data,
columns,
pageCount: Math.ceil(total / perPage),
initialState: {
sorting: [
{
id: "user.name",
desc: false,
},
],
columnVisibility: {
q: false,
},
},
shallow: false,
clearOnDefault: true,
enableRowSelection: false,
});
return (
<div className="flex w-full flex-col gap-2">
<DataTableToolbar table={table} />
<DataTable table={table} />
</div>
);
};

View File

@@ -0,0 +1,9 @@
"use server";
import { revalidatePath } from "next/cache";
import { pathsConfig } from "~/config/paths";
// eslint-disable-next-line @typescript-eslint/require-await
export const invalidateCustomers = async () =>
revalidatePath(pathsConfig.admin.customers.index);

View File

@@ -0,0 +1,224 @@
"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 { updateCustomerInputSchema } from "@turbostarter/api/schema";
import { BillingStatus, config } from "@turbostarter/billing";
import { isKey, useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-web/button";
import {
Form,
FormControl,
FormField,
FormDescription,
FormItem,
FormLabel,
FormMessage,
} from "@turbostarter/ui-web/form";
import { Icons } from "@turbostarter/ui-web/icons";
import {
Modal,
ModalClose,
ModalContent,
ModalDescription,
ModalFooter,
ModalHeader,
ModalTitle,
ModalTrigger,
} from "@turbostarter/ui-web/modal";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@turbostarter/ui-web/select";
import { admin } from "~/modules/admin/lib/api";
import { invalidateCustomers } from "./server/invalidate";
import type { UpdateCustomerInput } from "@turbostarter/api/schema";
import type { User } from "@turbostarter/auth";
import type { Customer } from "@turbostarter/billing";
import type { UseFormReturn } from "react-hook-form";
interface UpdateCustomerPlanModalProps {
readonly customer: Customer & { user: Pick<User, "name"> };
readonly children: React.ReactNode;
}
const UpdateCustomerPlanForm = ({
form,
onSubmit,
}: {
form: UseFormReturn<Pick<UpdateCustomerInput, "plan" | "status">>;
onSubmit: (
data: Pick<UpdateCustomerInput, "plan" | "status">,
) => void | Promise<void>;
}) => {
const { t, i18n } = useTranslation(["common", "admin", "billing"]);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="plan"
render={({ field }) => (
<FormItem>
<FormLabel>{t("common:plan")}</FormLabel>
<FormControl>
<div>
<Select
value={field.value ?? undefined}
onValueChange={field.onChange}
disabled={form.formState.isSubmitting}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={t("common:plan")} />
</SelectTrigger>
<SelectContent>
{config.plans.map((plan) => (
<SelectItem key={plan.id} value={plan.id}>
{isKey(plan.name, i18n, "billing")
? t(plan.name)
: plan.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</FormControl>
<FormDescription>
{t("customers.customer.updatePlan.plan.info")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>{t("common:status")}</FormLabel>
<FormControl>
<div>
<Select
value={field.value ?? undefined}
onValueChange={field.onChange}
disabled={form.formState.isSubmitting}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={t("common:status")} />
</SelectTrigger>
<SelectContent>
{Object.values(BillingStatus).map((status) => {
const statusKey = `status.${status
.toLowerCase()
.replace(/_([a-z])/g, (_, letter: string) =>
letter.toUpperCase(),
)}`;
return (
<SelectItem key={status} value={status}>
{isKey(statusKey, i18n, "billing")
? t(statusKey)
: statusKey}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
</FormControl>
<FormDescription>
{t("customers.customer.updatePlan.status.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>
);
};
export const UpdateCustomerPlanModal = ({
customer,
children,
}: UpdateCustomerPlanModalProps) => {
const { t } = useTranslation(["common", "admin", "billing"]);
const [open, setOpen] = useState(false);
const queryClient = useQueryClient();
const form = useForm({
resolver: standardSchemaResolver(
updateCustomerInputSchema.pick({ plan: true, status: true }),
),
defaultValues: {
plan: customer.plan ?? undefined,
status: customer.status ?? undefined,
},
});
const updateCustomer = useMutation({
...admin.mutations.customers.update,
onSuccess: async () => {
await invalidateCustomers();
await queryClient.invalidateQueries(
admin.queries.users.getPlans({
id: customer.userId,
}),
);
toast.success(t("customers.customer.updatePlan.success"));
setOpen(false);
form.reset();
},
});
const onSubmit = async (
data: Pick<UpdateCustomerInput, "plan" | "status">,
) => {
await updateCustomer.mutateAsync({ id: customer.id, ...data });
};
return (
<Modal open={open} onOpenChange={setOpen}>
<ModalTrigger asChild>{children}</ModalTrigger>
<ModalContent>
<ModalHeader>
<ModalTitle>
{t("customers.customer.updatePlan.title", {
name: customer.user.name,
})}
</ModalTitle>
<ModalDescription>
{t("customers.customer.updatePlan.description")}
</ModalDescription>
</ModalHeader>
<UpdateCustomerPlanForm form={form} onSubmit={onSubmit} />
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,29 @@
import { cn } from "@turbostarter/ui";
export const DetailsList = ({
className,
...props
}: React.ComponentProps<"ul">) => {
return (
<ul
className={cn(
"xl:*:px- -m-px grid grid-cols-1 gap-y-8 @lg/details:grid-cols-2 @lg/details:gap-y-0 @3xl/details:grid-cols-3",
className,
)}
{...props}
/>
);
};
export const DetailsListItem = ({
className,
...props
}: React.ComponentProps<"li">) => (
<li
className={cn(
"px-1 @lg/details:border @lg/details:px-6 @lg/details:py-8 @3xl/details:px-8 @3xl/details:py-10 [&:not(:first-child)]:-ms-px [&:not(:first-child)]:-mt-px",
className,
)}
{...props}
/>
);

View File

@@ -0,0 +1,90 @@
import { memo } from "react";
import { isKey } from "@turbostarter/i18n";
import { getTranslation } from "@turbostarter/i18n/server";
import { Icons } from "@turbostarter/ui-web/icons";
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenuItem,
SidebarRail,
} from "@turbostarter/ui-web/sidebar";
import { SidebarMenu } from "@turbostarter/ui-web/sidebar";
import {
SidebarContent,
SidebarFooter,
SidebarHeader,
} from "@turbostarter/ui-web/sidebar";
import { Sidebar } from "@turbostarter/ui-web/sidebar";
import { pathsConfig } from "~/config/paths";
import { SidebarLink } from "~/modules/common/layout/dashboard/sidebar-link";
import { TurboLink } from "~/modules/common/turbo-link";
import { UserNavigation } from "~/modules/user/user-navigation";
import type { User } from "@turbostarter/auth";
import type { Icon } from "@turbostarter/ui-web/icons";
interface AdminSidebarProps {
readonly user: User;
readonly menu: {
label: string;
items: {
title: string;
href: string;
icon: Icon;
}[];
}[];
}
export const AdminSidebar = memo<AdminSidebarProps>(async ({ user, menu }) => {
const { t, i18n } = await getTranslation({ ns: "common" });
return (
<Sidebar collapsible="icon">
<SidebarHeader>
<TurboLink
href={pathsConfig.index}
className="flex items-center gap-3 p-2 transition-[padding] group-data-[collapsible=icon]:p-0.5"
>
<Icons.Logo className="text-primary h-8 transition-[width,height]" />
<Icons.LogoText className="text-foreground h-4 group-data-[collapsible=icon]:hidden" />
</TurboLink>
</SidebarHeader>
<SidebarContent>
{menu.map((group) => (
<SidebarGroup key={group.label}>
<SidebarGroupLabel className="uppercase">
{isKey(group.label, i18n, "common")
? t(group.label)
: group.label}
</SidebarGroupLabel>
<SidebarMenu>
{group.items.map((item) => {
const title = isKey(item.title, i18n, "common")
? t(item.title)
: item.title;
return (
<SidebarMenuItem key={item.href}>
<SidebarLink href={item.href} tooltip={title}>
<item.icon />
<span>{title}</span>
</SidebarLink>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroup>
))}
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<UserNavigation user={user} />
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
<SidebarRail />
</Sidebar>
);
});

View File

@@ -0,0 +1,301 @@
import {
getInvitationsResponseSchema,
getMembersResponseSchema,
getOrganizationResponseSchema,
getUserAccountsResponseSchema,
getUserInvitationsResponseSchema,
getUserMembershipsResponseSchema,
getUserPlansResponseSchema,
} from "@turbostarter/api/schema";
import { handle } from "@turbostarter/api/utils";
import { api } from "~/lib/api/client";
import { authClient } from "~/lib/auth/client";
import type { User } from "@turbostarter/auth";
import type { InferRequestType } from "hono/client";
const KEY = "admin";
const queries = {
users: {
get: ({ id }: { id: string }) => ({
queryKey: [KEY, "users", id],
queryFn: () =>
authClient.admin.getUser({
query: { id },
fetchOptions: { throw: true },
}) as Promise<User>,
}),
getAccounts: ({
id,
...query
}: InferRequestType<
(typeof api.admin.users)[":id"]["accounts"]["$get"]
>["query"] & { id: string }) => ({
queryKey: [KEY, "users", id, "accounts", query],
queryFn: () =>
handle(api.admin.users[":id"].accounts.$get, {
schema: getUserAccountsResponseSchema,
})({
query,
param: {
id,
},
}),
}),
getPlans: ({
id,
...query
}: InferRequestType<
(typeof api.admin.users)[":id"]["plans"]["$get"]
>["query"] & { id: string }) => ({
queryKey: [KEY, "users", id, "plans", query],
queryFn: () =>
handle(api.admin.users[":id"].plans.$get, {
schema: getUserPlansResponseSchema,
})({ query, param: { id } }),
}),
getMemberships: ({
id,
...query
}: InferRequestType<
(typeof api.admin.users)[":id"]["memberships"]["$get"]
>["query"] & { id: string }) => ({
queryKey: [KEY, "users", id, "memberships", query],
queryFn: () =>
handle(api.admin.users[":id"].memberships.$get, {
schema: getUserMembershipsResponseSchema,
})({ query, param: { id } }),
}),
getInvitations: ({
id,
...query
}: InferRequestType<
(typeof api.admin.users)[":id"]["invitations"]["$get"]
>["query"] & { id: string }) => ({
queryKey: [KEY, "users", id, "invitations", query],
queryFn: () =>
handle(api.admin.users[":id"].invitations.$get, {
schema: getUserInvitationsResponseSchema,
})({ query, param: { id } }),
}),
getSessions: ({ id }: { id: string }) => ({
queryKey: [KEY, "users", id, "sessions"],
queryFn: () =>
authClient.admin.listUserSessions({
userId: id,
fetchOptions: { throw: true },
}),
}),
},
organizations: {
get: ({ id }: { id: string }) => ({
queryKey: [KEY, "organizations", id],
queryFn: () =>
handle(api.admin.organizations[":id"].$get, {
schema: getOrganizationResponseSchema,
})({
param: { id },
}),
}),
getMembers: ({
id,
...query
}: InferRequestType<
(typeof api.admin.organizations)[":id"]["members"]["$get"]
>["query"] & { id: string }) => ({
queryKey: [
...queries.organizations.get({ id }).queryKey,
"members",
query,
],
queryFn: () =>
handle(api.admin.organizations[":id"].members.$get, {
schema: getMembersResponseSchema,
})({
query,
param: {
id,
},
}),
}),
getInvitations: ({
id,
...query
}: InferRequestType<
(typeof api.admin.organizations)[":id"]["invitations"]["$get"]
>["query"] & { id: string }) => ({
queryKey: [
...queries.organizations.get({ id }).queryKey,
"invitations",
query,
],
queryFn: () =>
handle(api.admin.organizations[":id"].invitations.$get, {
schema: getInvitationsResponseSchema,
})({ query, param: { id } }),
}),
},
};
const mutations = {
users: {
ban: {
mutationKey: [KEY, "users", "ban"],
mutationFn: (params: Parameters<typeof authClient.admin.banUser>[0]) =>
authClient.admin.banUser(params),
},
unban: {
mutationKey: [KEY, "users", "unban"],
mutationFn: (params: Parameters<typeof authClient.admin.unbanUser>[0]) =>
authClient.admin.unbanUser(params),
},
delete: {
mutationKey: [KEY, "users", "delete"],
mutationFn: (params: Parameters<typeof authClient.admin.removeUser>[0]) =>
authClient.admin.removeUser(params),
},
impersonate: {
mutationKey: [KEY, "users", "impersonate"],
mutationFn: (
params: Parameters<typeof authClient.admin.impersonateUser>[0],
) => authClient.admin.impersonateUser(params),
},
stopImpersonating: {
mutationKey: [KEY, "users", "impersonate", "stop"],
mutationFn: () => authClient.admin.stopImpersonating(),
},
update: {
mutationKey: [KEY, "users", "update"],
mutationFn: (params: Parameters<typeof authClient.admin.updateUser>[0]) =>
authClient.admin.updateUser(params),
},
setPassword: {
mutationKey: [KEY, "users", "setPassword"],
mutationFn: (
params: Parameters<typeof authClient.admin.setUserPassword>[0],
) => authClient.admin.setUserPassword(params),
},
accounts: {
delete: {
mutationKey: [KEY, "users", "accounts", "delete"],
mutationFn: (
param: InferRequestType<
(typeof api.admin.users)[":id"]["accounts"][":accountId"]["$delete"]
>["param"],
) =>
handle(api.admin.users[":id"].accounts[":accountId"].$delete)({
param,
}),
},
},
sessions: {
revoke: {
mutationKey: [KEY, "users", "sessions", "revoke"],
mutationFn: (
params: Parameters<typeof authClient.admin.revokeUserSession>[0],
) => authClient.admin.revokeUserSession(params),
},
revokeAll: {
mutationKey: [KEY, "users", "sessions", "revokeAll"],
mutationFn: (
params: Parameters<typeof authClient.admin.revokeUserSessions>[0],
) => authClient.admin.revokeUserSessions(params),
},
},
},
organizations: {
delete: {
mutationKey: [KEY, "organizations", "delete"],
mutationFn: (
param: InferRequestType<
(typeof api.admin.organizations)[":id"]["$delete"]
>["param"],
) =>
handle(api.admin.organizations[":id"].$delete)({
param,
}),
},
update: {
mutationKey: [KEY, "organizations", "update"],
mutationFn: ({
id,
...json
}: InferRequestType<
(typeof api.admin.organizations)[":id"]["$patch"]
>["json"] & { id: string }) =>
handle(api.admin.organizations[":id"].$patch)({ param: { id }, json }),
},
members: {
remove: {
mutationKey: [KEY, "organizations", "members", "remove"],
mutationFn: (
param: InferRequestType<
(typeof api.admin.organizations)[":id"]["members"][":memberId"]["$delete"]
>["param"],
) =>
handle(api.admin.organizations[":id"].members[":memberId"].$delete)({
param,
}),
},
update: {
mutationKey: [KEY, "organizations", "members", "update"],
mutationFn: ({
id,
memberId,
...json
}: InferRequestType<
(typeof api.admin.organizations)[":id"]["members"][":memberId"]["$patch"]
>["json"] & { id: string; memberId: string }) =>
handle(api.admin.organizations[":id"].members[":memberId"].$patch)({
param: { id, memberId },
json,
}),
},
},
invitations: {
delete: {
mutationKey: [KEY, "organizations", "invitations", "delete"],
mutationFn: (
param: InferRequestType<
(typeof api.admin.organizations)[":id"]["invitations"][":invitationId"]["$delete"]
>["param"],
) =>
handle(
api.admin.organizations[":id"].invitations[":invitationId"].$delete,
)({
param,
}),
},
},
},
customers: {
delete: {
mutationKey: [KEY, "customers", "delete"],
mutationFn: (
param: InferRequestType<
(typeof api.admin.customers)[":id"]["$delete"]
>["param"],
) => handle(api.admin.customers[":id"].$delete)({ param }),
},
update: {
mutationKey: [KEY, "customers", "update"],
mutationFn: ({
id,
...json
}: InferRequestType<
(typeof api.admin.customers)[":id"]["$patch"]
>["json"] & { id: string }) =>
handle(api.admin.customers[":id"].$patch)({
param: { id },
json,
}),
},
},
};
export const admin = {
queries,
mutations,
} as const;

View File

@@ -0,0 +1,115 @@
"use client";
import { useTranslation } from "@turbostarter/i18n";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@turbostarter/ui-web/avatar";
import { DataTableColumnHeader } from "@turbostarter/ui-web/data-table/data-table-column-header";
import { pathsConfig } from "~/config/paths";
import { TurboLink } from "~/modules/common/turbo-link";
import type { ColumnDef } from "@tanstack/react-table";
import type { GetOrganizationsResponse } from "@turbostarter/api/schema";
type Row = GetOrganizationsResponse["data"][number];
export const useColumns = (options?: {
max?: { members: number };
}): ColumnDef<Row>[] => {
const { t, i18n } = useTranslation("common");
return [
{
id: "q",
accessorKey: "q",
meta: {
placeholder: `${t("searchPlaceholder")}`,
variant: "text",
},
enableHiding: false,
enableColumnFilter: true,
},
{
id: "name",
accessorKey: "name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("name")} />
),
cell: ({ row }) => {
return (
<TurboLink
href={pathsConfig.admin.organizations.organization(row.original.id)}
className="group flex items-center gap-3"
>
<Avatar>
<AvatarImage
src={row.original.logo ?? undefined}
alt={row.original.name}
/>
<AvatarFallback>
<span className="text-muted-foreground text-sm uppercase">
{row.original.name.charAt(0)}
</span>
</AvatarFallback>
</Avatar>
<span className="group-hover:text-primary truncate font-medium underline underline-offset-4">
{row.original.name}
</span>
</TurboLink>
);
},
enableHiding: false,
},
{
id: "slug",
accessorKey: "slug",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("slug")} />
),
cell: ({ row }) => <span>/{row.original.slug}</span>,
meta: {
label: "Slug",
},
},
{
id: "members",
accessorKey: "members",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("members")} />
),
cell: ({ row }) => {
return <span>{row.original.members}</span>;
},
meta: {
label: t("members"),
variant: "range",
range: [0, options?.max?.members ?? 100],
},
enableColumnFilter: true,
},
{
id: "createdAt",
accessorKey: "createdAt",
header: ({ column }) => (
<div className="ml-auto w-fit">
<DataTableColumnHeader column={column} title={t("createdAt")} />
</div>
),
cell: ({ row }) => {
return (
<div className="ml-auto text-right">
{row.original.createdAt.toLocaleDateString(i18n.language)}
</div>
);
},
meta: {
label: t("createdAt"),
variant: "dateRange",
},
enableColumnFilter: true,
},
];
};

View File

@@ -0,0 +1,53 @@
"use client";
import { use } from "react";
import { DataTable } from "@turbostarter/ui-web/data-table/data-table";
import { DataTableToolbar } from "@turbostarter/ui-web/data-table/data-table-toolbar";
import { useDataTable } from "~/modules/common/hooks/use-data-table";
import { useColumns } from "./columns";
import type { GetOrganizationsResponse } from "@turbostarter/api/schema";
interface OrganizationsDataTableProps {
readonly promise: Promise<GetOrganizationsResponse>;
readonly perPage: number;
}
export const OrganizationsDataTable = ({
promise,
perPage,
}: OrganizationsDataTableProps) => {
const { data, total, max } = use<GetOrganizationsResponse>(promise);
const columns = useColumns({ max });
const { table } = useDataTable({
persistance: "searchParams",
data,
columns,
pageCount: Math.ceil(total / perPage),
initialState: {
sorting: [
{
id: "name",
desc: false,
},
],
columnVisibility: {
q: false,
},
},
shallow: false,
clearOnDefault: true,
enableRowSelection: false,
});
return (
<div className="flex w-full flex-col gap-2">
<DataTableToolbar table={table} />
<DataTable table={table} />
</div>
);
};

View File

@@ -0,0 +1,118 @@
import { useMutation, useMutationState, 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,
ModalTrigger,
ModalContent,
ModalHeader,
ModalTitle,
ModalDescription,
ModalFooter,
ModalClose,
} from "@turbostarter/ui-web/modal";
import { admin } from "~/modules/admin/lib/api";
interface DeleteProps {
readonly id: string;
}
export const Delete = ({ id }: DeleteProps) => {
const { t } = useTranslation("common");
const [mutation] = useMutationState({
filters: {
mutationKey: admin.mutations.organizations.delete.mutationKey,
},
select: (mutation) => ({
status: mutation.state.status,
variables: mutation.state.variables as { id: string },
}),
});
const isPending =
mutation?.status === "pending" && mutation.variables.id === id;
return (
<ConfirmModal organizationId={id}>
<Button variant="destructive" disabled={isPending}>
{isPending ? (
<Icons.Loader2 className="animate-spin" />
) : (
<Icons.Trash />
)}
{t("delete")}
</Button>
</ConfirmModal>
);
};
const ConfirmModal = ({
children,
organizationId,
}: {
children: React.ReactNode;
organizationId: string;
}) => {
const { t } = useTranslation(["common", "admin"]);
const router = useRouter();
const { data: organization } = useQuery(
admin.queries.organizations.get({ id: organizationId }),
);
const deleteOrganization = useMutation({
...admin.mutations.organizations.delete,
onSuccess: () => {
toast.success(t("organizations.organization.delete.success"));
router.back();
},
});
const handleDelete = () => {
deleteOrganization.mutate({ id: organizationId });
};
const isPending =
deleteOrganization.isPending &&
deleteOrganization.variables.id === organizationId;
return (
<Modal>
<ModalTrigger asChild>{children}</ModalTrigger>
<ModalContent>
<ModalHeader>
<ModalTitle>
{t("organizations.organization.delete.title", {
name: organization?.name,
})}
</ModalTitle>
<ModalDescription className="whitespace-pre-line">
{t("organizations.organization.delete.disclaimer")}
</ModalDescription>
</ModalHeader>
<ModalFooter>
<ModalClose asChild>
<Button variant="outline">{t("cancel")}</Button>
</ModalClose>
<Button
onClick={handleDelete}
variant="destructive"
disabled={isPending}
>
{isPending ? (
<Icons.Loader2 className="animate-spin" />
) : (
t("delete")
)}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,231 @@
"use client";
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { updateOrganizationSchema } from "@turbostarter/auth";
import { useTranslation } from "@turbostarter/i18n";
import { useDebounceCallback } from "@turbostarter/shared/hooks";
import {
Form,
FormField,
FormItem,
FormLabel,
FormControl,
FormMessage,
} from "@turbostarter/ui-web/form";
import { Icons } from "@turbostarter/ui-web/icons";
import { Input } from "@turbostarter/ui-web/input";
import {
DetailsList,
DetailsListItem,
} from "~/modules/admin/layout/details-list";
import { admin } from "~/modules/admin/lib/api";
import type { UpdateOrganizationPayload } from "@turbostarter/auth";
const Name = ({ id }: { id: string }) => {
const { t } = useTranslation(["common", "admin"]);
const queryClient = useQueryClient();
const { data: organization } = useQuery(
admin.queries.organizations.get({ id }),
);
const update = useMutation({
...admin.mutations.organizations.update,
onSuccess: async () => {
await queryClient.invalidateQueries(
admin.queries.organizations.get({ id }),
);
toast.success(
t("organizations.organization.details.name.update.success"),
);
},
});
const form = useForm({
resolver: standardSchemaResolver(
updateOrganizationSchema.pick({ name: true }),
),
defaultValues: {
name: organization?.name,
},
});
const onSubmit = async (data: Pick<UpdateOrganizationPayload, "name">) => {
await update.mutateAsync({ id, ...data });
};
const debouncedOnSubmit = useDebounceCallback(
form.handleSubmit(onSubmit),
2000,
{
trailing: true,
leading: false,
},
);
return (
<Form {...form}>
<form>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-1.5">
<FormLabel>{t("name")}</FormLabel>
{form.formState.isSubmitting && (
<Icons.Loader2 className="size-3 animate-spin" />
)}
</div>
<FormControl>
<Input
{...field}
onChange={(e) => {
field.onChange(e.target.value);
void debouncedOnSubmit();
}}
disabled={form.formState.isSubmitting}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
);
};
const Slug = ({ id }: { id: string }) => {
const { t } = useTranslation(["common", "admin"]);
const queryClient = useQueryClient();
const { data: organization } = useQuery(
admin.queries.organizations.get({ id }),
);
const update = useMutation({
...admin.mutations.organizations.update,
onSuccess: async () => {
await queryClient.invalidateQueries(
admin.queries.organizations.get({ id }),
);
toast.success(
t("organizations.organization.details.slug.update.success"),
);
},
});
const form = useForm({
resolver: standardSchemaResolver(
updateOrganizationSchema.pick({ slug: true }),
),
defaultValues: {
slug: organization?.slug ?? "",
},
});
const onSubmit = async (data: Pick<UpdateOrganizationPayload, "slug">) => {
await update.mutateAsync({ id, ...data });
};
const debouncedOnSubmit = useDebounceCallback(
form.handleSubmit(onSubmit),
2000,
{
trailing: true,
leading: false,
},
);
return (
<Form {...form}>
<form>
<FormField
control={form.control}
name="slug"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-1.5">
<FormLabel>{t("slug")}</FormLabel>
{form.formState.isSubmitting && (
<Icons.Loader2 className="size-3 animate-spin" />
)}
</div>
<FormControl>
<div className="relative">
<Input
className="peer ps-6"
{...field}
onChange={(e) => {
field.onChange(e.target.value);
void debouncedOnSubmit();
}}
disabled={form.formState.isSubmitting}
/>
<div className="text-muted-foreground/80 pointer-events-none absolute inset-y-0 start-0 flex items-center justify-center ps-3 text-sm peer-disabled:opacity-50">
/
</div>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
);
};
interface OrganizationDetailsProps {
readonly id: string;
}
export const OrganizationDetails = ({ id }: OrganizationDetailsProps) => {
const { t, i18n } = useTranslation(["common", "admin"]);
const { data: organization } = useQuery(
admin.queries.organizations.get({ id }),
);
const details = [
{
id: "name",
component: <Name id={id} />,
visible: true,
},
{
id: "slug",
component: <Slug id={id} />,
visible: true,
},
{
id: "createdAt",
component: (
<div className="flex flex-col items-start gap-3">
<span className="text-sm font-medium">{t("createdAt")}</span>
<span>{organization?.createdAt.toLocaleString(i18n.language)}</span>
</div>
),
visible: true,
},
];
return (
<section className="@container/details w-full overflow-hidden">
<DetailsList>
{details
.filter((detail) => detail.visible)
.map((detail) => (
<DetailsListItem key={detail.id}>
{detail.component}
</DetailsListItem>
))}
</DetailsList>
</section>
);
};

View File

@@ -0,0 +1,59 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@turbostarter/ui-web/avatar";
import { admin } from "~/modules/admin/lib/api";
import {
DashboardHeader,
DashboardHeaderTitle,
DashboardHeaderDescription,
} from "~/modules/common/layout/dashboard/header";
import { Delete } from "./actions/delete";
interface OrganizationHeaderProps {
readonly id: string;
}
export const OrganizationHeader = ({ id }: OrganizationHeaderProps) => {
const { data: organization } = useQuery(
admin.queries.organizations.get({ id }),
);
return (
<DashboardHeader>
<div className="flex min-w-0 items-center gap-4">
<Avatar className="size-12">
<AvatarImage
src={organization?.logo ?? undefined}
alt={organization?.name ?? ""}
/>
<AvatarFallback>
<span className="text-muted-foreground text-lg uppercase">
{organization?.name.charAt(0)}
</span>
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<DashboardHeaderTitle className="truncate">
{organization?.name}
</DashboardHeaderTitle>
<div className="flex items-center gap-2">
<DashboardHeaderDescription>
/{organization?.slug}
</DashboardHeaderDescription>
</div>
</div>
</div>
<Delete id={id} />
</DashboardHeader>
);
};

View File

@@ -0,0 +1,193 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import dayjs from "dayjs";
import { toast } from "sonner";
import { 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,
} from "@turbostarter/ui-web/dropdown-menu";
import { Icons } from "@turbostarter/ui-web/icons";
import { admin } from "~/modules/admin/lib/api";
import type { ColumnDef } from "@tanstack/react-table";
import type { Invitation } from "@turbostarter/auth";
export const InvitationActions = ({
invitation,
}: {
invitation: Invitation;
}) => {
const { t } = useTranslation(["common", "organization"]);
const queryClient = useQueryClient();
const cancelInvitation = useMutation({
...admin.mutations.organizations.invitations.delete,
onSuccess: async () => {
await queryClient.invalidateQueries(
admin.queries.organizations.getInvitations({
id: invitation.organizationId,
}),
);
toast.success(t("invitations.cancel.success"));
},
});
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<span className="sr-only">{t("actions")}</span>
<Icons.Ellipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
{(() => {
const isPending =
cancelInvitation.isPending &&
cancelInvitation.variables.invitationId === invitation.id;
return (
<DropdownMenuItem
variant="destructive"
onClick={() =>
cancelInvitation.mutate({
id: invitation.organizationId,
invitationId: invitation.id,
})
}
disabled={isPending}
key={`cancel-${invitation.id}`}
>
{t("cancel")}
{isPending && (
<Icons.Loader2 className="ml-auto animate-spin text-current" />
)}
</DropdownMenuItem>
);
})()}
</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">
<InvitationActions invitation={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 { admin } from "~/modules/admin/lib/api";
import { useDataTable } from "~/modules/common/hooks/use-data-table";
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,
},
],
},
enableRowSelection: false,
query: ({ page, perPage, sorting, filters }) =>
admin.queries.organizations.getInvitations({
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 w-full flex-col gap-2">
<DataTableToolbar table={table} />
<DataTable table={table} />
</div>
);
};

View File

@@ -0,0 +1,209 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { 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 { admin } from "~/modules/admin/lib/api";
import { TurboLink } from "~/modules/common/turbo-link";
import { UpdateMemberRoleModal } from "../update-member-role";
import type { ColumnDef } from "@tanstack/react-table";
import type { Member } from "@turbostarter/auth";
export const MemberActions = ({ member }: { member: Member }) => {
const { t } = useTranslation(["common", "organization", "auth"]);
const queryClient = useQueryClient();
const removeMember = useMutation({
...admin.mutations.organizations.members.remove,
onSuccess: async () => {
await queryClient.invalidateQueries(
admin.queries.organizations.getMembers({
id: member.organizationId,
}),
);
await queryClient.invalidateQueries(
admin.queries.users.getMemberships({
id: member.userId,
}),
);
toast.success(t("members.remove.success"));
},
});
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<span className="sr-only">{t("actions")}</span>
<Icons.Ellipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<UpdateMemberRoleModal member={member} key={`update-role-${member.id}`}>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
{t("updateRole")}
</DropdownMenuItem>
</UpdateMemberRoleModal>
<DropdownMenuSeparator />
{(() => {
const isPending =
removeMember.isPending &&
removeMember.variables.memberId === member.id;
return (
<DropdownMenuItem
variant="destructive"
onClick={() =>
removeMember.mutate({
id: member.organizationId,
memberId: member.id,
})
}
disabled={isPending}
key={`remove-${member.id}`}
>
{t("remove")}
{isPending && (
<Icons.Loader2 className="ml-auto animate-spin text-current" />
)}
</DropdownMenuItem>
);
})()}
</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 (
<TurboLink
href={pathsConfig.admin.users.user(row.original.userId)}
className="group 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="group-hover:text-primary truncate font-medium underline underline-offset-4">
{row.original.user.name}
</span>
{row.original.userId === session?.user.id && (
<Badge variant="outline">{t("you")}</Badge>
)}
</div>
</TurboLink>
);
},
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">
<MemberActions member={row.original} />
</div>
),
enableHiding: false,
},
];
};

View File

@@ -0,0 +1,62 @@
"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 { admin } from "~/modules/admin/lib/api";
import { useDataTable } from "~/modules/common/hooks/use-data-table";
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 }) =>
admin.queries.organizations.getMembers({
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,161 @@
"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 { 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 { admin } from "~/modules/admin/lib/api";
import type { Member, UpdateMemberPayload } from "@turbostarter/auth";
interface UpdateMemberRoleModalProps {
readonly member: Member;
readonly children: React.ReactNode;
}
export const UpdateMemberRoleModal = ({
member,
children,
}: UpdateMemberRoleModalProps) => {
const { t } = useTranslation(["common", "admin", "organization"]);
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const updateMember = useMutation({
...admin.mutations.organizations.members.update,
onSuccess: async () => {
await queryClient.invalidateQueries(
admin.queries.organizations.getMembers({
id: member.organizationId,
}),
);
await queryClient.invalidateQueries(
admin.queries.users.getMemberships({
id: member.userId,
}),
);
toast.success(t("members.update.role.success"));
setOpen(false);
form.reset();
},
});
const form = useForm({
resolver: standardSchemaResolver(updateMemberSchema.pick({ role: true })),
defaultValues: {
role: member.role,
},
});
const onSubmit = async (data: Pick<UpdateMemberPayload, "role">) => {
await updateMember.mutateAsync({
id: member.organizationId,
memberId: member.id,
...data,
});
};
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(onSubmit)} 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>
{Object.values(MemberRole).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,208 @@
import { UserRole } from "@turbostarter/auth";
import { isKey, useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@turbostarter/ui-web/avatar";
import { Badge } from "@turbostarter/ui-web/badge";
import { DataTableColumnHeader } from "@turbostarter/ui-web/data-table/data-table-column-header";
import { Icons } from "@turbostarter/ui-web/icons";
import { pathsConfig } from "~/config/paths";
import { authClient } from "~/lib/auth/client";
import { TurboLink } from "~/modules/common/turbo-link";
import type { ColumnDef } from "@tanstack/react-table";
import type { User } from "@turbostarter/auth";
export const useColumns = (): ColumnDef<User>[] => {
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: "name",
accessorKey: "name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("name")} />
),
cell: ({ row }) => {
return (
<TurboLink
href={pathsConfig.admin.users.user(row.original.id)}
className="group flex items-center gap-3"
>
<Avatar>
<AvatarImage
src={row.original.image ?? undefined}
alt={row.original.name}
/>
<AvatarFallback>
<Icons.UserRound className="w-5" />
</AvatarFallback>
</Avatar>
<div className="flex items-center gap-2">
<span className="group-hover:text-primary truncate font-medium underline underline-offset-4">
{row.original.name}
</span>
{row.original.id === session?.user.id && (
<Badge variant="outline">{t("you")}</Badge>
)}
</div>
</TurboLink>
);
},
enableHiding: false,
},
{
id: "email",
accessorKey: "email",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("email")} />
),
cell: ({ row }) => {
return (
<div className="flex items-center gap-2">
{row.original.email}
<Badge
className={cn({
"bg-success/15 text-success hover:bg-success/25":
row.original.emailVerified,
"bg-destructive/15 text-destructive hover:bg-destructive/25":
!row.original.emailVerified,
})}
>
{row.original.emailVerified ? t("verified") : t("unverified")}
</Badge>
</div>
);
},
meta: {
label: t("email"),
},
},
{
id: "role",
accessorKey: "role",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("role")} />
),
cell: ({ row }) => {
if (!row.original.role) {
return <Badge variant="outline">{t(UserRole.USER)}</Badge>;
}
return (
<div className="flex items-center gap-1">
{row.original.role.split(",").map((role) => (
<Badge variant="outline" key={role}>
{isKey(role, i18n, "common") ? t(role) : role}
</Badge>
))}
</div>
);
},
meta: {
label: t("role"),
variant: "multiSelect",
options: Object.values(UserRole).map((role) => ({
label: t(role),
value: role,
})),
},
enableColumnFilter: true,
},
{
id: "twoFactorEnabled",
accessorKey: "twoFactorEnabled",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={"2FA"} />
),
cell: ({ row }) => {
return row.original.twoFactorEnabled ? (
<>
<span className="sr-only">{t("enabled")}</span>
<Icons.Check className="size-4" />
</>
) : (
<>
<span className="sr-only">{t("disabled")}</span>
<Icons.X className="size-4" />
</>
);
},
meta: {
label: "2FA",
variant: "multiSelect",
options: [
{ label: t("enabled"), value: "true" },
{ label: t("disabled"), value: "false" },
],
},
enableColumnFilter: true,
},
{
id: "banned",
accessorKey: "banned",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("banned")} />
),
cell: ({ row }) => {
return row.original.banned ? (
<>
<span className="sr-only">{t("banned")}</span>
<Icons.Check className="size-4" />
</>
) : (
<>
<span className="sr-only">{t("notBanned")}</span>
<Icons.X className="size-4" />
</>
);
},
meta: {
label: t("banned"),
variant: "multiSelect",
options: [
{ label: t("banned"), value: "true" },
{ label: t("notBanned"), value: "false" },
],
},
enableColumnFilter: true,
},
{
id: "createdAt",
accessorKey: "createdAt",
header: ({ column }) => (
<div className="ml-auto w-fit">
<DataTableColumnHeader column={column} title={t("createdAt")} />
</div>
),
cell: ({ row }) => {
return (
<div className="ml-auto text-right">
{row.original.createdAt.toLocaleDateString(i18n.language)}
</div>
);
},
meta: {
label: t("createdAt"),
variant: "dateRange",
},
enableColumnFilter: true,
},
];
};

View File

@@ -0,0 +1,50 @@
"use client";
import { use } from "react";
import { DataTable } from "@turbostarter/ui-web/data-table/data-table";
import { DataTableToolbar } from "@turbostarter/ui-web/data-table/data-table-toolbar";
import { useDataTable } from "~/modules/common/hooks/use-data-table";
import { useColumns } from "./columns";
import type { GetUsersResponse } from "@turbostarter/api/schema";
interface UsersDataTableProps {
readonly promise: Promise<Awaited<GetUsersResponse>>;
readonly perPage: number;
}
export const UsersDataTable = ({ promise, perPage }: UsersDataTableProps) => {
const columns = useColumns();
const { data, total } = use(promise);
const { table } = useDataTable({
persistance: "searchParams",
data,
columns,
pageCount: Math.ceil(total / perPage),
initialState: {
sorting: [
{
id: "name",
desc: false,
},
],
columnVisibility: {
q: false,
},
},
shallow: false,
clearOnDefault: true,
enableRowSelection: false,
});
return (
<div className="flex w-full flex-col gap-2">
<DataTableToolbar table={table} />
<DataTable table={table} />
</div>
);
};

View File

@@ -0,0 +1,60 @@
"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 { admin } from "~/modules/admin/lib/api";
import { useDataTable } from "~/modules/common/hooks/use-data-table";
import { useColumns } from "./columns";
interface AccountsDataTableProps {
readonly id: string;
}
export const AccountsDataTable = ({ id }: AccountsDataTableProps) => {
const columns = useColumns();
const { table, query } = useDataTable({
persistance: "local",
columns,
initialState: {
sorting: [
{
id: "providerId",
desc: false,
},
],
},
enableRowSelection: false,
query: ({ page, perPage, sorting, filters }) =>
admin.queries.users.getAccounts({
id,
page: page.toString(),
perPage: perPage.toString(),
sort: JSON.stringify(sorting),
...pickBy(filters, Boolean),
}),
});
if (query.isLoading) {
return (
<DataTableSkeleton
columnCount={3}
filterCount={3}
cellWidths={["15rem", "10rem", "10rem"]}
rowCount={3}
shrinkZero
/>
);
}
return (
<div className="flex w-full flex-col gap-2">
<DataTableToolbar table={table} />
<DataTable table={table} />
</div>
);
};

View File

@@ -0,0 +1,207 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { SocialProvider } from "@turbostarter/auth";
import { isKey, useTranslation } from "@turbostarter/i18n";
import { capitalize } from "@turbostarter/shared/utils";
import { Button } from "@turbostarter/ui-web/button";
import { DataTableColumnHeader } from "@turbostarter/ui-web/data-table/data-table-column-header";
import {
DropdownMenu,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuSeparator,
} from "@turbostarter/ui-web/dropdown-menu";
import { Icons } from "@turbostarter/ui-web/icons";
import { admin } from "~/modules/admin/lib/api";
import { SetPasswordModal } from "~/modules/admin/users/user/accounts/set-password";
import { SocialIcons } from "~/modules/auth/form/social-providers";
import type { ColumnDef } from "@tanstack/react-table";
import type { GetUserAccountsResponse } from "@turbostarter/api/schema";
export const AccountActions = ({
account,
}: {
account: GetUserAccountsResponse["data"][number];
}) => {
const { t } = useTranslation(["common", "auth", "admin"]);
const queryClient = useQueryClient();
const deleteAccount = useMutation({
...admin.mutations.users.accounts.delete,
onSuccess: async () => {
await queryClient.invalidateQueries(
admin.queries.users.getAccounts({
id: account.userId,
}),
);
toast.success(t("users.user.accounts.delete.success"));
},
});
const groups = [
account.providerId === "credential"
? [
<SetPasswordModal id={account.userId} key="set-password">
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
{t("account.password.update.cta")}
</DropdownMenuItem>
</SetPasswordModal>,
]
: null,
[
[
(() => {
const isPending =
deleteAccount.isPending &&
deleteAccount.variables.accountId === account.id;
return (
<DropdownMenuItem
variant="destructive"
key="delete"
onSelect={() =>
deleteAccount.mutate({
accountId: account.id,
id: account.userId,
})
}
>
{t("delete")}
{isPending && (
<Icons.Loader2 className="ml-auto animate-spin text-current" />
)}
</DropdownMenuItem>
);
})(),
],
],
].filter((group) => group?.filter(Boolean).length);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<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<
GetUserAccountsResponse["data"][number]
>[] => {
const { t, i18n } = useTranslation("common");
return [
{
id: "providerId",
accessorKey: "providerId",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("provider")} />
),
cell: ({ row }) => {
return (
<div className="flex items-center gap-2">
{row.original.providerId in SocialIcons ? (
(() => {
const Icon =
SocialIcons[row.original.providerId as SocialProvider];
return (
<>
<Icon className="size-4" />
<span className="capitalize">
{row.original.providerId}
</span>
</>
);
})()
) : (
<>
<Icons.Lock className="size-4" />
<span>{t("credential")}</span>
</>
)}
</div>
);
},
meta: {
label: t("provider"),
variant: "multiSelect",
options: ["credential", ...Object.values(SocialProvider)].map(
(provider) => ({
label: isKey(provider, i18n, "common")
? t(provider)
: capitalize(provider),
value: provider,
}),
),
},
enableColumnFilter: true,
enableHiding: false,
},
{
id: "createdAt",
accessorKey: "createdAt",
header: ({ column }) => (
<div className="ml-auto w-fit">
<DataTableColumnHeader column={column} title={t("createdAt")} />
</div>
),
cell: ({ row }) => {
return (
<div className="ml-auto text-right">
{new Date(row.original.createdAt).toLocaleString(i18n.language)}
</div>
);
},
meta: {
label: t("createdAt"),
variant: "dateRange",
},
enableColumnFilter: true,
},
{
id: "updatedAt",
accessorKey: "updatedAt",
header: ({ column }) => (
<div className="ml-auto w-fit">
<DataTableColumnHeader column={column} title={t("updatedAt")} />
</div>
),
cell: ({ row }) => {
return (
<div className="ml-auto text-right">
{new Date(row.original.createdAt).toLocaleString(i18n.language)}
</div>
);
},
meta: {
label: t("updatedAt"),
variant: "dateRange",
},
enableColumnFilter: true,
},
{
id: "actions",
cell: ({ row }) => (
<div className="ml-auto w-fit">
<AccountActions account={row.original} />
</div>
),
enableHiding: false,
},
];
};

View File

@@ -0,0 +1,119 @@
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { passwordSchema } 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 { PasswordInput } from "@turbostarter/ui-web/input";
import {
Modal,
ModalContent,
ModalHeader,
ModalTitle,
ModalDescription,
ModalFooter,
ModalClose,
ModalTrigger,
} from "@turbostarter/ui-web/modal";
import { admin } from "~/modules/admin/lib/api";
interface SetPasswordModalProps {
readonly id: string;
readonly children: React.ReactNode;
}
export const SetPasswordModal = ({ id, children }: SetPasswordModalProps) => {
const { t } = useTranslation(["common", "auth", "admin"]);
const [open, setOpen] = useState(false);
const { data: user } = useQuery(admin.queries.users.get({ id }));
const form = useForm({
resolver: standardSchemaResolver(passwordSchema),
});
const setPassword = useMutation({
...admin.mutations.users.setPassword,
onSuccess: () => {
toast.success(t("users.user.accounts.password.update.success"));
setOpen(false);
form.reset();
},
});
return (
<Modal open={open} onOpenChange={setOpen}>
<ModalTrigger asChild>{children}</ModalTrigger>
<ModalContent>
<ModalHeader>
<ModalTitle>
{t("users.user.accounts.password.update.title", {
name: user?.name,
})}
</ModalTitle>
<ModalDescription>
{t("users.user.accounts.password.update.description")}
</ModalDescription>
</ModalHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit((data) =>
setPassword.mutateAsync({
userId: id,
newPassword: data.password,
}),
)}
className="space-y-6"
>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem className="px-4 md:px-0">
<FormLabel>{t("newPassword")}</FormLabel>
<FormControl>
<PasswordInput {...field} />
</FormControl>
<FormDescription>
{t("users.user.accounts.password.update.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,389 @@
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema";
import {
useMutation,
useMutationState,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import dayjs from "dayjs";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { banUserSchema } from "@turbostarter/auth";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import { Button } from "@turbostarter/ui-web/button";
import { Calendar } from "@turbostarter/ui-web/calendar";
import {
Form,
FormField,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
} from "@turbostarter/ui-web/form";
import { Icons } from "@turbostarter/ui-web/icons";
import {
Modal,
ModalTrigger,
ModalContent,
ModalHeader,
ModalTitle,
ModalDescription,
ModalFooter,
ModalClose,
ModalBody,
} from "@turbostarter/ui-web/modal";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@turbostarter/ui-web/popover";
import { ScrollArea, ScrollBar } from "@turbostarter/ui-web/scroll-area";
import { Textarea } from "@turbostarter/ui-web/textarea";
import { admin } from "~/modules/admin/lib/api";
import type { BanUserPayload } from "@turbostarter/auth";
import type { ComponentProps } from "react";
interface BanProps {
readonly id: string;
}
export const Ban = ({ id }: BanProps) => {
const { data: user } = useQuery(admin.queries.users.get({ id }));
if (user?.banned) {
return <Unban userId={id} />;
}
return (
<BanModal id={id}>
<BanTrigger userId={id} />
</BanModal>
);
};
const Unban = ({
userId,
...props
}: ComponentProps<typeof Button> & { userId: string }) => {
const { t } = useTranslation(["common", "admin"]);
const queryClient = useQueryClient();
const unban = useMutation({
...admin.mutations.users.unban,
onSuccess: async () => {
await queryClient.invalidateQueries(
admin.queries.users.get({ id: userId }),
);
toast.success(t("users.user.unban.success"));
},
});
const isPending = unban.isPending && unban.variables.userId === userId;
return (
<Button
variant="outline"
disabled={isPending}
onClick={() => unban.mutate({ userId })}
{...props}
>
{isPending ? <Icons.Loader2 className="animate-spin" /> : <Icons.Undo2 />}
{t("unban")}
</Button>
);
};
const BanTrigger = ({
userId,
...props
}: ComponentProps<typeof Button> & { userId: string }) => {
const { t } = useTranslation("common");
const [mutation] = useMutationState({
filters: {
mutationKey: admin.mutations.users.ban.mutationKey,
},
select: (mutation) => ({
status: mutation.state.status,
variables: mutation.state.variables as { userId: string },
}),
});
const isPending =
mutation?.status === "pending" && mutation.variables.userId === userId;
return (
<Button variant="outline" disabled={isPending} {...props}>
{isPending ? <Icons.Loader2 className="animate-spin" /> : <Icons.Ban />}
{t("ban")}
</Button>
);
};
const BanModal = ({
id,
children,
}: {
id: string;
children: React.ReactNode;
}) => {
const { t, i18n } = useTranslation(["common", "admin"]);
const { data: user } = useQuery(admin.queries.users.get({ id }));
const queryClient = useQueryClient();
const form = useForm({
resolver: standardSchemaResolver(banUserSchema),
});
const ban = useMutation({
...admin.mutations.users.ban,
onSuccess: async () => {
await queryClient.invalidateQueries(admin.queries.users.get({ id }));
toast.success(t("users.user.ban.success"));
},
});
const onSubmit = ({ expiresIn, reason }: BanUserPayload) => {
ban.mutate({
userId: id,
banReason: reason,
banExpiresIn: expiresIn
? Math.max(0, dayjs(expiresIn).diff(dayjs(), "second"))
: undefined,
});
};
const isPending = ban.isPending && ban.variables.userId === id;
function handleDateSelect(date: Date | undefined) {
if (date) {
form.setValue("expiresIn", date);
}
}
function handleTimeChange(type: "hour" | "minute" | "ampm", value: string) {
const currentDate = form.getValues("expiresIn") ?? new Date();
const newDate = new Date(currentDate);
if (type === "hour") {
const hour = parseInt(value, 10);
newDate.setHours(newDate.getHours() >= 12 ? hour + 12 : hour);
} else if (type === "minute") {
newDate.setMinutes(parseInt(value, 10));
} else {
const hours = newDate.getHours();
if (value === "AM" && hours >= 12) {
newDate.setHours(hours - 12);
} else if (value === "PM" && hours < 12) {
newDate.setHours(hours + 12);
}
}
form.setValue("expiresIn", newDate);
}
return (
<Modal>
<ModalTrigger asChild>{children}</ModalTrigger>
<ModalContent>
<ModalHeader>
<ModalTitle>
{t("ban")} {user?.name}
</ModalTitle>
<ModalDescription>{t("users.user.ban.description")}</ModalDescription>
</ModalHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="md:space-y-6">
<ModalBody>
<div className="flex flex-col gap-4 py-2 md:py-0">
<FormField
control={form.control}
name="reason"
render={({ field }) => (
<FormItem>
<div className="flex justify-between">
<FormLabel>{t("reason")}</FormLabel>
<span className="text-muted-foreground text-xs">
{field.value?.length ?? 0}/1000
</span>
</div>
<FormControl>
<Textarea {...field} />
</FormControl>
<FormDescription>
{t("users.user.ban.form.reason.info")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="expiresIn"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>{t("expiresIn")}</FormLabel>
<div>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
type="button"
variant={"outline"}
className={cn(
"w-full pl-3 text-left font-normal",
!field.value && "text-muted-foreground",
)}
>
{field.value
? field.value.toLocaleString(i18n.language)
: t("never")}
<Icons.Calendar className="ml-auto h-4 w-4 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<div className="sm:flex">
<Calendar
mode="single"
selected={field.value}
onSelect={handleDateSelect}
disabled={(date) =>
dayjs(date).isBefore(
dayjs().add(1, "day").startOf("day"),
)
}
captionLayout="dropdown"
/>
<div className="flex flex-col divide-y sm:h-[300px] sm:flex-row sm:divide-x sm:divide-y-0">
<ScrollArea className="w-64 sm:w-auto">
<div className="flex p-2 sm:flex-col">
{Array.from({ length: 12 }, (_, i) => i + 1)
.reverse()
.map((hour) => (
<Button
key={hour}
type="button"
size="icon"
variant={
field.value &&
field.value.getHours() % 12 ===
hour % 12
? "default"
: "ghost"
}
className="aspect-square shrink-0 sm:w-full"
onClick={() =>
handleTimeChange(
"hour",
hour.toString(),
)
}
>
{hour}
</Button>
))}
</div>
<ScrollBar
orientation="horizontal"
className="sm:hidden"
/>
</ScrollArea>
<ScrollArea className="w-64 sm:w-auto">
<div className="flex p-2 sm:flex-col">
{Array.from(
{ length: 12 },
(_, i) => i * 5,
).map((minute) => (
<Button
key={minute}
size="icon"
type="button"
variant={
field.value &&
field.value.getMinutes() === minute
? "default"
: "ghost"
}
className="aspect-square shrink-0 sm:w-full"
onClick={() =>
handleTimeChange(
"minute",
minute.toString(),
)
}
>
{minute.toString().padStart(2, "0")}
</Button>
))}
</div>
<ScrollBar
orientation="horizontal"
className="sm:hidden"
/>
</ScrollArea>
<ScrollArea className="">
<div className="flex p-2 sm:flex-col">
{["AM", "PM"].map((ampm) => (
<Button
key={ampm}
size="icon"
type="button"
variant={
field.value &&
((ampm === "AM" &&
field.value.getHours() < 12) ||
(ampm === "PM" &&
field.value.getHours() >= 12))
? "default"
: "ghost"
}
className="aspect-square shrink-0 sm:w-full"
onClick={() =>
handleTimeChange("ampm", ampm)
}
>
{ampm}
</Button>
))}
</div>
</ScrollArea>
</div>
</div>
</PopoverContent>
</Popover>
</div>
<FormDescription>
{t("users.user.ban.form.expiresIn.info")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
</ModalBody>
<ModalFooter>
<ModalClose asChild>
<Button variant="outline" type="button">
{t("cancel")}
</Button>
</ModalClose>
<Button type="submit" disabled={isPending}>
{isPending ? (
<Icons.Loader2 className="animate-spin" />
) : (
t("ban")
)}
</Button>
</ModalFooter>
</form>
</Form>
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,113 @@
import { useMutation, useMutationState, 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,
ModalTrigger,
ModalContent,
ModalHeader,
ModalTitle,
ModalDescription,
ModalFooter,
ModalClose,
} from "@turbostarter/ui-web/modal";
import { admin } from "~/modules/admin/lib/api";
interface DeleteProps {
readonly id: string;
}
export const Delete = ({ id }: DeleteProps) => {
const { t } = useTranslation("common");
const [mutation] = useMutationState({
filters: {
mutationKey: admin.mutations.users.delete.mutationKey,
},
select: (mutation) => ({
status: mutation.state.status,
variables: mutation.state.variables as { userId: string },
}),
});
const isPending =
mutation?.status === "pending" && mutation.variables.userId === id;
return (
<ConfirmModal userId={id}>
<Button variant="destructive" disabled={isPending}>
{isPending ? (
<Icons.Loader2 className="animate-spin" />
) : (
<Icons.Trash />
)}
{t("delete")}
</Button>
</ConfirmModal>
);
};
const ConfirmModal = ({
children,
userId,
}: {
children: React.ReactNode;
userId: string;
}) => {
const { t } = useTranslation(["common", "admin"]);
const router = useRouter();
const { data: user } = useQuery(admin.queries.users.get({ id: userId }));
const deleteUser = useMutation({
...admin.mutations.users.delete,
onSuccess: () => {
toast.success(t("users.user.delete.success"));
router.back();
},
});
const handleDelete = () => {
deleteUser.mutate({ userId });
};
const isPending =
deleteUser.isPending && deleteUser.variables.userId === userId;
return (
<Modal>
<ModalTrigger asChild>{children}</ModalTrigger>
<ModalContent>
<ModalHeader>
<ModalTitle>
{t("users.user.delete.title", { name: user?.name })}
</ModalTitle>
<ModalDescription className="whitespace-pre-line">
{t("users.user.delete.disclaimer")}
</ModalDescription>
</ModalHeader>
<ModalFooter>
<ModalClose asChild>
<Button variant="outline">{t("cancel")}</Button>
</ModalClose>
<Button
onClick={handleDelete}
variant="destructive"
disabled={isPending}
>
{isPending ? (
<Icons.Loader2 className="animate-spin" />
) : (
t("delete")
)}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,41 @@
import { useMutation } from "@tanstack/react-query";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
import { pathsConfig } from "~/config/paths";
import { admin } from "~/modules/admin/lib/api";
interface ImpersonateProps {
readonly id: string;
}
export const Impersonate = ({ id }: ImpersonateProps) => {
const { t } = useTranslation("common");
const impersonate = useMutation({
...admin.mutations.users.impersonate,
onSuccess: () => {
window.open(pathsConfig.index, "_blank");
},
});
const isPending =
impersonate.isPending && impersonate.variables.userId === id;
return (
<Button
variant="secondary"
onClick={() => impersonate.mutate({ userId: id })}
disabled={isPending}
>
{isPending ? (
<Icons.Loader2 className="animate-spin" />
) : (
<Icons.VenetianMask />
)}
{t("impersonate")}
</Button>
);
};

View File

@@ -0,0 +1,264 @@
"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 { updateUserSchema, UserRole } from "@turbostarter/auth";
import { useTranslation } from "@turbostarter/i18n";
import { useDebounceCallback } from "@turbostarter/shared/hooks";
import {
Form,
FormField,
FormItem,
FormControl,
FormLabel,
FormMessage,
} from "@turbostarter/ui-web/form";
import { Icons } from "@turbostarter/ui-web/icons";
import { Input } from "@turbostarter/ui-web/input";
import {
Select,
SelectValue,
SelectTrigger,
SelectContent,
SelectItem,
SelectPortal,
} from "@turbostarter/ui-web/select";
import {
DetailsList,
DetailsListItem,
} from "~/modules/admin/layout/details-list";
import { admin } from "~/modules/admin/lib/api";
const Role = ({ id }: { id: string }) => {
const { t } = useTranslation(["common", "admin"]);
const queryClient = useQueryClient();
const { data: user } = useQuery(admin.queries.users.get({ id }));
const form = useForm({
resolver: standardSchemaResolver(updateUserSchema.pick({ role: true })),
defaultValues: {
role: user?.role as UserRole,
},
});
const updateUser = useMutation({
...admin.mutations.users.update,
onSuccess: async () => {
await queryClient.invalidateQueries(admin.queries.users.get({ id }));
toast.success(t("users.user.details.role.update.success"));
},
});
return (
<Form {...form}>
<form>
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-1.5">
<FormLabel>{t("role")}</FormLabel>
{form.formState.isSubmitting && (
<Icons.Loader2 className="size-3 animate-spin" />
)}
</div>
<FormControl>
<div>
<Select
value={field.value}
onValueChange={(value) => {
field.onChange(value);
void form.handleSubmit((data) =>
updateUser.mutateAsync({
data,
userId: id,
}),
)();
}}
disabled={form.formState.isSubmitting}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={t("role")} />
</SelectTrigger>
<SelectPortal>
<SelectContent>
{Object.values(UserRole).map((role) => (
<SelectItem key={role} value={role}>
{t(role)}
</SelectItem>
))}
</SelectContent>
</SelectPortal>
</Select>
</div>
</FormControl>
</FormItem>
)}
/>
</form>
</Form>
);
};
const Name = ({ id }: { id: string }) => {
const { t } = useTranslation(["common", "admin"]);
const queryClient = useQueryClient();
const { data: user } = useQuery(admin.queries.users.get({ id }));
const form = useForm({
resolver: standardSchemaResolver(updateUserSchema.pick({ name: true })),
defaultValues: {
name: user?.name,
},
});
const updateUser = useMutation({
...admin.mutations.users.update,
onSuccess: async () => {
await queryClient.invalidateQueries(admin.queries.users.get({ id }));
toast.success(t("users.user.details.name.update.success"));
},
});
const debouncedOnSubmit = useDebounceCallback(
form.handleSubmit((data) =>
updateUser.mutateAsync({
data,
userId: id,
}),
),
2000,
{
trailing: true,
leading: false,
},
);
return (
<Form {...form}>
<form>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-1.5">
<FormLabel>{t("name")}</FormLabel>
{form.formState.isSubmitting && (
<Icons.Loader2 className="size-3 animate-spin" />
)}
</div>
<FormControl>
<Input
{...field}
onChange={(e) => {
field.onChange(e.target.value);
void debouncedOnSubmit();
}}
disabled={form.formState.isSubmitting}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
);
};
interface UserDetailsProps {
readonly id: string;
}
export const UserDetails = ({ id }: UserDetailsProps) => {
const { t, i18n } = useTranslation(["common", "auth"]);
const { data: user } = useQuery(admin.queries.users.get({ id }));
const details = [
{
id: "name",
component: <Name id={id} />,
visible: true,
},
{
id: "role",
component: <Role id={id} />,
visible: true,
},
{
id: "twoFactorEnabled",
component: (
<div className="flex flex-col items-start gap-3">
<span className="text-sm font-medium">{t("two-factor")}</span>
<span>{user?.twoFactorEnabled ? t("enabled") : t("disabled")}</span>
</div>
),
visible: true,
},
{
id: "createdAt",
component: (
<div className="flex flex-col items-start gap-3">
<span className="text-sm font-medium">{t("createdAt")}</span>
<span>{user?.createdAt.toLocaleString(i18n.language)}</span>
</div>
),
visible: true,
},
{
id: "updatedAt",
component: (
<div className="flex flex-col items-start gap-3">
<span className="text-sm font-medium">{t("updatedAt")}</span>
<span>{user?.updatedAt.toLocaleString(i18n.language)}</span>
</div>
),
visible: true,
},
{
id: "banExpires",
component: (
<div className="flex flex-col items-start gap-3">
<span className="text-sm font-medium">{t("banExpiresIn")}</span>
{user?.banExpires ? (
<span>{user.banExpires.toLocaleString(i18n.language)}</span>
) : (
<span>{t("never")}</span>
)}
</div>
),
visible: !!user?.banned,
},
{
id: "banReason",
component: (
<div className="flex flex-col items-start gap-3">
<span className="text-sm font-medium">{t("banReason")}</span>
<p>{user?.banReason}</p>
</div>
),
visible: !!user?.banned,
},
];
return (
<section className="@container/details w-full overflow-hidden">
<DetailsList>
{details
.filter((detail) => detail.visible)
.map((detail) => (
<DetailsListItem key={detail.id}>
{detail.component}
</DetailsListItem>
))}
</DetailsList>
</section>
);
};

View File

@@ -0,0 +1,83 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@turbostarter/ui-web/avatar";
import { Badge } from "@turbostarter/ui-web/badge";
import { Icons } from "@turbostarter/ui-web/icons";
import { authClient } from "~/lib/auth/client";
import { admin } from "~/modules/admin/lib/api";
import {
DashboardHeader,
DashboardHeaderTitle,
DashboardHeaderDescription,
} from "~/modules/common/layout/dashboard/header";
import { Ban } from "./actions/ban";
import { Delete } from "./actions/delete";
import { Impersonate } from "./actions/impersonate";
interface UserHeaderProps {
readonly id: string;
}
export const UserHeader = ({ id }: UserHeaderProps) => {
const { t } = useTranslation("common");
const session = authClient.useSession();
const { data: user } = useQuery(admin.queries.users.get({ id }));
return (
<DashboardHeader>
<div className="flex min-w-0 items-center gap-4">
<Avatar className="size-12">
<AvatarImage src={user?.image ?? undefined} alt={user?.name ?? ""} />
<AvatarFallback>
<Icons.UserRound className="w-7" />
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<div className="flex items-center gap-2">
<DashboardHeaderTitle className="truncate">
{user?.name}
</DashboardHeaderTitle>
{user?.isAnonymous && (
<Badge variant="outline">{t("anonymous")}</Badge>
)}
{user?.id === session.data?.user.id && (
<Badge variant="outline">{t("you")}</Badge>
)}
</div>
<div className="flex min-w-0 items-center gap-2">
<DashboardHeaderDescription>
{user?.email}
</DashboardHeaderDescription>
<Badge
className={cn({
"bg-success/15 text-success hover:bg-success/25":
user?.emailVerified,
"bg-destructive/15 text-destructive hover:bg-destructive/25":
!user?.emailVerified,
})}
>
{user?.emailVerified ? t("verified") : t("unverified")}
</Badge>
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Impersonate id={id} />
<Ban id={id} />
<Delete id={id} />
</div>
</DashboardHeader>
);
};

View File

@@ -0,0 +1,71 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
import { pathsConfig } from "~/config/paths";
import { authClient } from "~/lib/auth/client";
import { admin } from "~/modules/admin/lib/api";
export const ImpersonatingBanner = () => {
const { t } = useTranslation("common");
const session = authClient.useSession();
const router = useRouter();
const userId = session.data?.user.id;
const isImpersonating = !!session.data?.session.impersonatedBy;
const stop = useMutation({
...admin.mutations.users.stopImpersonating,
onSuccess: async () => {
await session.refetch();
router.replace(
userId
? pathsConfig.admin.users.user(userId)
: pathsConfig.admin.users.index,
);
},
});
useEffect(() => {
if (isImpersonating) {
document.body.style.setProperty("--banner-height", "2.5rem");
document.body.style.paddingTop = "2.5rem";
} else {
document.body.style.setProperty("--banner-height", "0");
document.body.style.paddingTop = "0";
}
}, [isImpersonating]);
if (!isImpersonating) {
return null;
}
return (
<div className="bg-primary fixed top-0 left-0 z-50 flex h-10 w-full items-center justify-center gap-2 px-4">
<span className="text-primary-foreground truncate text-sm">
{t("impersonating")}{" "}
<span className="font-medium">{session.data?.user.email}</span>
</span>
<Button
variant="outline"
onClick={() => stop.mutate()}
disabled={stop.isPending}
size="sm"
className="h-6 px-2 text-xs"
>
{stop.isPending ? (
<Icons.Loader2 className="size-3 animate-spin" />
) : (
"End"
)}
</Button>
</div>
);
};

View File

@@ -0,0 +1,141 @@
import dayjs from "dayjs";
import { InvitationStatus, MemberRole } from "@turbostarter/auth";
import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui";
import {
Avatar,
AvatarImage,
AvatarFallback,
} from "@turbostarter/ui-web/avatar";
import { Badge } from "@turbostarter/ui-web/badge";
import { DataTableColumnHeader } from "@turbostarter/ui-web/data-table/data-table-column-header";
import { pathsConfig } from "~/config/paths";
import { InvitationActions } from "~/modules/admin/organizations/organization/invitations/data-table/columns";
import { TurboLink } from "~/modules/common/turbo-link";
import type { ColumnDef } from "@tanstack/react-table";
import type { GetUserInvitationsResponse } from "@turbostarter/api/schema";
export const useColumns = (): ColumnDef<
GetUserInvitationsResponse["data"][number]
>[] => {
const { t, i18n } = useTranslation("common");
return [
{
id: "organization.name",
accessorKey: "organization.name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("organization")} />
),
cell: ({ row }) => {
return (
<TurboLink
href={pathsConfig.admin.organizations.organization(
row.original.organization.id,
)}
className="group flex items-center gap-3"
>
<Avatar>
<AvatarImage
src={row.original.organization.logo ?? undefined}
alt={row.original.organization.name}
/>
<AvatarFallback>
<span className="text-muted-foreground text-sm uppercase">
{row.original.organization.name.charAt(0)}
</span>
</AvatarFallback>
</Avatar>
<span className="group-hover:text-primary truncate font-medium underline underline-offset-4">
{row.original.organization.name}
</span>
</TurboLink>
);
},
enableHiding: false,
},
{
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">
<InvitationActions invitation={row.original} />
</div>
),
enableHiding: false,
},
];
};

View File

@@ -0,0 +1,59 @@
"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 { admin } from "~/modules/admin/lib/api";
import { useDataTable } from "~/modules/common/hooks/use-data-table";
import { useColumns } from "./columns";
interface InvitationsDataTableProps {
readonly userId: string;
}
export const InvitationsDataTable = ({ userId }: InvitationsDataTableProps) => {
const columns = useColumns();
const { table, query } = useDataTable({
persistance: "local",
columns,
initialState: {
sorting: [
{
id: "organization.name",
desc: false,
},
],
},
enableRowSelection: false,
query: ({ page, perPage, sorting, filters }) =>
admin.queries.users.getInvitations({
id: userId,
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 w-full flex-col gap-2">
<DataTableToolbar table={table} />
<DataTable table={table} />
</div>
);
};

View File

@@ -0,0 +1,109 @@
"use client";
import { 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 { DataTableColumnHeader } from "@turbostarter/ui-web/data-table/data-table-column-header";
import { pathsConfig } from "~/config/paths";
import { MemberActions } from "~/modules/admin/organizations/organization/members/data-table/columns";
import { TurboLink } from "~/modules/common/turbo-link";
import type { ColumnDef } from "@tanstack/react-table";
import type { GetUserMembershipsResponse } from "@turbostarter/api/schema";
export const useColumns = (): ColumnDef<
GetUserMembershipsResponse["data"][number]
>[] => {
const { t, i18n } = useTranslation("common");
return [
{
id: "organization.name",
accessorKey: "organization.name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("organization")} />
),
cell: ({ row }) => {
return (
<TurboLink
href={pathsConfig.admin.organizations.organization(
row.original.organizationId,
)}
className="group flex items-center gap-3"
>
<Avatar>
<AvatarImage
src={row.original.organization.logo ?? undefined}
alt={row.original.organization.name}
/>
<AvatarFallback>
<span className="text-muted-foreground text-sm uppercase">
{row.original.organization.name.charAt(0)}
</span>
</AvatarFallback>
</Avatar>
<span className="group-hover:text-primary truncate font-medium underline underline-offset-4">
{row.original.organization.name}
</span>
</TurboLink>
);
},
enableHiding: false,
},
{
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">
<MemberActions member={row.original} />
</div>
),
enableHiding: false,
},
];
};

View File

@@ -0,0 +1,59 @@
"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 { admin } from "~/modules/admin/lib/api";
import { useDataTable } from "~/modules/common/hooks/use-data-table";
import { useColumns } from "./columns";
interface MembershipsDataTableProps {
readonly userId: string;
}
export const MembershipsDataTable = ({ userId }: MembershipsDataTableProps) => {
const columns = useColumns();
const { table, query } = useDataTable({
persistance: "local",
columns,
initialState: {
sorting: [
{
id: "organization.name",
desc: false,
},
],
},
enableRowSelection: false,
query: ({ page, perPage, sorting, filters }) =>
admin.queries.users.getMemberships({
id: userId,
page: page.toString(),
perPage: perPage.toString(),
sort: JSON.stringify(sorting),
...pickBy(filters, Boolean),
}),
});
if (query.isLoading) {
return (
<DataTableSkeleton
columnCount={4}
filterCount={3}
cellWidths={["20rem", "10rem", "7rem", "4rem"]}
shrinkZero
/>
);
}
return (
<div className="flex w-full flex-col gap-2">
<DataTableToolbar table={table} />
<DataTable table={table} />
</div>
);
};

View File

@@ -0,0 +1,120 @@
import { BillingStatus, config } from "@turbostarter/billing";
import { isKey, useTranslation } from "@turbostarter/i18n";
import { Badge } from "@turbostarter/ui-web/badge";
import { DataTableColumnHeader } from "@turbostarter/ui-web/data-table/data-table-column-header";
import { CustomerActions } from "~/modules/admin/customers/data-table/columns";
import type { ColumnDef } from "@tanstack/react-table";
import type { User } from "@turbostarter/auth";
import type { Customer } from "@turbostarter/billing";
export const useColumns = (): ColumnDef<
Customer & { user: Pick<User, "name"> }
>[] => {
const { t, i18n } = useTranslation(["common", "billing"]);
return [
{
id: "customerId",
accessorKey: "customerId",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("id")} />
),
meta: {
label: t("id"),
},
},
{
id: "plan",
accessorKey: "plan",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("common:plan")} />
),
cell: ({ row }) => {
const plan = config.plans.find((plan) => plan.id === row.original.plan);
if (!plan) {
return <span>-</span>;
}
return (
<Badge variant="outline">
{isKey(plan.name, i18n, "billing") ? t(plan.name) : plan.name}
</Badge>
);
},
meta: {
label: t("common:plan"),
variant: "multiSelect",
options: Object.values(config.plans).map((plan) => ({
label: isKey(plan.name, i18n, "billing") ? t(plan.name) : plan.name,
value: plan.id,
})),
},
enableColumnFilter: true,
},
{
id: "status",
accessorKey: "status",
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("common:status")} />
),
cell: ({ row }) => {
const statusKey = `status.${row.original.status?.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase())}`;
if (!row.original.status) {
return <span>-</span>;
}
return (
<Badge variant="secondary">
{isKey(statusKey, i18n, "billing") ? t(statusKey) : statusKey}
</Badge>
);
},
meta: {
label: t("common:status"),
variant: "multiSelect",
options: Object.values(BillingStatus).map((status) => {
const statusKey = `status.${status.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase())}`;
return {
label: isKey(statusKey, i18n, "billing") ? t(statusKey) : statusKey,
value: status,
};
}),
},
enableColumnFilter: true,
},
{
id: "createdAt",
accessorKey: "createdAt",
header: ({ column }) => (
<div className="ml-auto w-fit">
<DataTableColumnHeader column={column} title={t("createdAt")} />
</div>
),
cell: ({ row }) => {
return (
<div className="ml-auto text-right">
{row.original.createdAt.toLocaleDateString(i18n.language)}
</div>
);
},
meta: {
label: t("createdAt"),
variant: "dateRange",
},
enableColumnFilter: true,
},
{
id: "actions",
cell: ({ row }) => (
<div className="ml-auto w-fit">
<CustomerActions customer={row.original} />
</div>
),
enableHiding: false,
},
];
};

View File

@@ -0,0 +1,59 @@
"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 { admin } from "~/modules/admin/lib/api";
import { useDataTable } from "~/modules/common/hooks/use-data-table";
import { useColumns } from "./columns";
interface PlansDataTableProps {
readonly userId: string;
}
export const PlansDataTable = ({ userId }: PlansDataTableProps) => {
const columns = useColumns();
const { table, query } = useDataTable({
persistance: "local",
columns,
initialState: {
sorting: [
{
id: "plan",
desc: false,
},
],
},
enableRowSelection: false,
query: ({ page, perPage, sorting, filters }) =>
admin.queries.users.getPlans({
id: userId,
page: page.toString(),
perPage: perPage.toString(),
sort: JSON.stringify(sorting),
...pickBy(filters, Boolean),
}),
});
if (query.isLoading) {
return (
<DataTableSkeleton
columnCount={4}
filterCount={3}
cellWidths={["20rem", "10rem", "7rem", "4rem"]}
shrinkZero
/>
);
}
return (
<div className="flex w-full flex-col gap-2">
<DataTableToolbar table={table} />
<DataTable table={table} />
</div>
);
};

View File

@@ -0,0 +1,126 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useTranslation } from "@turbostarter/i18n";
import { Button } from "@turbostarter/ui-web/button";
import { Icons } from "@turbostarter/ui-web/icons";
import { Skeleton } from "@turbostarter/ui-web/skeleton";
import { admin } from "~/modules/admin/lib/api";
interface SessionsListProps {
readonly id: string;
}
export const SessionsList = ({ id }: SessionsListProps) => {
const { t } = useTranslation(["common", "auth", "admin"]);
const queryClient = useQueryClient();
const { data: sessions, isLoading } = useQuery(
admin.queries.users.getSessions({ id }),
);
const revoke = useMutation({
...admin.mutations.users.sessions.revoke,
onSuccess: async () => {
await queryClient.invalidateQueries(
admin.queries.users.getSessions({ id }),
);
toast.success(t("account.sessions.revoke.success"));
},
});
const revokeAll = useMutation({
...admin.mutations.users.sessions.revokeAll,
onSuccess: async () => {
await queryClient.invalidateQueries(
admin.queries.users.getSessions({ id }),
);
toast.success(t("users.user.sessions.revokeAll.success"));
},
});
return (
<section className="flex w-full flex-col gap-4">
<header className="flex items-center justify-between gap-2">
<h3 className="text-xl font-semibold tracking-tight">
{t("sessions")}
</h3>
{sessions?.sessions.length && sessions.sessions.length > 0 ? (
<Button
variant="outline"
size="sm"
onClick={() => revokeAll.mutate({ userId: id })}
disabled={revokeAll.isPending && revokeAll.variables.userId === id}
>
{revokeAll.isPending && revokeAll.variables.userId === id ? (
<Icons.Loader2 className="size-4 animate-spin" />
) : (
<Icons.Trash className="size-4" />
)}
{t("revokeAll")}
</Button>
) : null}
</header>
{isLoading ? (
<div className="flex flex-col gap-4">
<Skeleton className="h-16" />
<Skeleton className="h-16" />
</div>
) : sessions?.sessions.length && sessions.sessions.length > 0 ? (
<ul className="overflow-hidden rounded-md border">
{sessions.sessions.map((session) => {
return (
<li
key={session.id}
className="flex min-w-0 items-center gap-3 border-b px-4 py-3 last:border-b-0"
>
<Icons.MonitorSmartphone className="size-6 shrink-0" />
<div className="mr-auto grid grid-cols-1">
<span className="truncate text-sm font-medium capitalize">
{session.ipAddress}
</span>
<span className="text-muted-foreground truncate text-xs">
{session.userAgent}
</span>
</div>
<Button
variant="ghost"
size="icon"
disabled={
revoke.isPending &&
revoke.variables.sessionToken === session.token
}
onClick={() => revoke.mutate({ sessionToken: session.token })}
className="shrink-0"
>
<span className="sr-only">
{t("account.sessions.revoke.cta")}
</span>
{revoke.isPending &&
revoke.variables.sessionToken === session.token ? (
<Icons.Loader2 className="size-4 animate-spin" />
) : (
<Icons.Trash className="size-4" />
)}
</Button>
</li>
);
})}
</ul>
) : (
<div className="flex items-center justify-center rounded-md border border-dashed p-14">
<p className="text-center text-sm">
{t("account.sessions.noSessions")}
</p>
</div>
)}
</section>
);
};