feat(db): mesh data model — meshes, members, invites, audit log
- pgSchema "mesh" with 4 tables isolating the peer mesh domain - Enums: visibility, transport, tier, role - audit_log is metadata-only (E2E encryption enforced at broker/client) - Cascade on mesh delete, soft-delete via archivedAt/revokedAt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
290
apps/web/src/modules/admin/customers/data-table/columns.tsx
Normal file
290
apps/web/src/modules/admin/customers/data-table/columns.tsx
Normal 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,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
224
apps/web/src/modules/admin/customers/update-customer-plan.tsx
Normal file
224
apps/web/src/modules/admin/customers/update-customer-plan.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
29
apps/web/src/modules/admin/layout/details-list.tsx
Normal file
29
apps/web/src/modules/admin/layout/details-list.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
90
apps/web/src/modules/admin/layout/sidebar.tsx
Normal file
90
apps/web/src/modules/admin/layout/sidebar.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
301
apps/web/src/modules/admin/lib/api.ts
Normal file
301
apps/web/src/modules/admin/lib/api.ts
Normal 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;
|
||||
115
apps/web/src/modules/admin/organizations/data-table/columns.tsx
Normal file
115
apps/web/src/modules/admin/organizations/data-table/columns.tsx
Normal 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,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
208
apps/web/src/modules/admin/users/data-table/columns.tsx
Normal file
208
apps/web/src/modules/admin/users/data-table/columns.tsx
Normal 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,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
};
|
||||
119
apps/web/src/modules/admin/users/user/accounts/set-password.tsx
Normal file
119
apps/web/src/modules/admin/users/user/accounts/set-password.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
389
apps/web/src/modules/admin/users/user/actions/ban.tsx
Normal file
389
apps/web/src/modules/admin/users/user/actions/ban.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
113
apps/web/src/modules/admin/users/user/actions/delete.tsx
Normal file
113
apps/web/src/modules/admin/users/user/actions/delete.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
264
apps/web/src/modules/admin/users/user/details.tsx
Normal file
264
apps/web/src/modules/admin/users/user/details.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
83
apps/web/src/modules/admin/users/user/header.tsx
Normal file
83
apps/web/src/modules/admin/users/user/header.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
126
apps/web/src/modules/admin/users/user/sessions/sessions-list.tsx
Normal file
126
apps/web/src/modules/admin/users/user/sessions/sessions-list.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user