first
This commit is contained in:
commit
8538ab969a
434 changed files with 44051 additions and 0 deletions
37
apps/web/modules/saas/admin/component/EmailVerified.tsx
Normal file
37
apps/web/modules/saas/admin/component/EmailVerified.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@ui/components/tooltip";
|
||||
import { cn } from "@ui/lib";
|
||||
import { CheckIcon, ClockIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function EmailVerified({
|
||||
verified,
|
||||
className,
|
||||
}: {
|
||||
verified: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipContent>
|
||||
{verified
|
||||
? t("admin.users.emailVerified.verified")
|
||||
: t("admin.users.emailVerified.waiting")}
|
||||
</TooltipContent>
|
||||
<TooltipTrigger className={cn(className)}>
|
||||
{verified ? (
|
||||
<CheckIcon className="size-3 text-primary" />
|
||||
) : (
|
||||
<ClockIcon className="size-3" />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { adminOrganizationsQueryKey } from "@saas/admin/lib/api";
|
||||
import { getAdminPath } from "@saas/admin/lib/links";
|
||||
import { InviteMemberForm } from "@saas/organizations/components/InviteMemberForm";
|
||||
import { OrganizationMembersBlock } from "@saas/organizations/components/OrganizationMembersBlock";
|
||||
import {
|
||||
fullOrganizationQueryKey,
|
||||
useCreateOrganizationMutation,
|
||||
useFullOrganizationQuery,
|
||||
useUpdateOrganizationMutation,
|
||||
} from "@saas/organizations/lib/api";
|
||||
import { useRouter } from "@shared/hooks/router";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@ui/components/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@ui/components/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@ui/components/form";
|
||||
import { Input } from "@ui/components/input";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const organizationFormSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
});
|
||||
|
||||
type OrganizationFormValues = z.infer<typeof organizationFormSchema>;
|
||||
|
||||
export function OrganizationForm({
|
||||
organizationId,
|
||||
}: {
|
||||
organizationId: string;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
|
||||
const { data: organization } = useFullOrganizationQuery(organizationId);
|
||||
|
||||
const updateOrganizationMutation = useUpdateOrganizationMutation();
|
||||
const createOrganizationMutation = useCreateOrganizationMutation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const form = useForm<OrganizationFormValues>({
|
||||
resolver: zodResolver(organizationFormSchema),
|
||||
defaultValues: {
|
||||
name: organization?.name ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit(async ({ name }) => {
|
||||
try {
|
||||
const newOrganization = organization
|
||||
? await updateOrganizationMutation.mutateAsync({
|
||||
id: organization.id,
|
||||
name,
|
||||
updateSlug: organization.name !== name,
|
||||
})
|
||||
: await createOrganizationMutation.mutateAsync({
|
||||
name,
|
||||
});
|
||||
|
||||
if (!newOrganization) {
|
||||
throw new Error("Could not save organization");
|
||||
}
|
||||
|
||||
queryClient.setQueryData(
|
||||
fullOrganizationQueryKey(organizationId),
|
||||
newOrganization,
|
||||
);
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: adminOrganizationsQueryKey,
|
||||
});
|
||||
|
||||
toast.success(t("admin.organizations.form.notifications.success"));
|
||||
|
||||
if (!organization) {
|
||||
router.replace(
|
||||
getAdminPath(`/organizations/${newOrganization.id}`),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("admin.organizations.form.notifications.error"));
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{organization
|
||||
? t("admin.organizations.form.updateTitle")
|
||||
: t("admin.organizations.form.createTitle")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="grid grid-cols-1 gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("admin.organizations.form.name")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
loading={
|
||||
updateOrganizationMutation.isPending ||
|
||||
createOrganizationMutation.isPending
|
||||
}
|
||||
>
|
||||
{t("admin.organizations.form.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{organization && (
|
||||
<>
|
||||
<OrganizationMembersBlock
|
||||
organizationId={organization.id}
|
||||
/>
|
||||
<InviteMemberForm organizationId={organization.id} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,294 @@
|
|||
"use client";
|
||||
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import {
|
||||
adminOrganizationsQueryKey,
|
||||
useAdminOrganizationsQuery,
|
||||
} from "@saas/admin/lib/api";
|
||||
import { getAdminPath } from "@saas/admin/lib/links";
|
||||
import { OrganizationLogo } from "@saas/organizations/components/OrganizationLogo";
|
||||
import { useConfirmationAlert } from "@saas/shared/components/ConfirmationAlertProvider";
|
||||
import { Pagination } from "@saas/shared/components/Pagination";
|
||||
import { Spinner } from "@shared/components/Spinner";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { Button } from "@ui/components/button";
|
||||
import { Card } from "@ui/components/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@ui/components/dropdown-menu";
|
||||
import { Input } from "@ui/components/input";
|
||||
import { Table, TableBody, TableCell, TableRow } from "@ui/components/table";
|
||||
import { EditIcon, MoreVerticalIcon, PlusIcon, TrashIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { parseAsInteger, parseAsString, useQueryState } from "nuqs";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { withQuery } from "ufo";
|
||||
import { useDebounceValue } from "usehooks-ts";
|
||||
|
||||
const ITEMS_PER_PAGE = 10;
|
||||
|
||||
export function OrganizationList() {
|
||||
const t = useTranslations();
|
||||
const { confirm } = useConfirmationAlert();
|
||||
const queryClient = useQueryClient();
|
||||
const [currentPage, setCurrentPage] = useQueryState(
|
||||
"currentPage",
|
||||
parseAsInteger.withDefault(1),
|
||||
);
|
||||
const [searchTerm, setSearchTerm] = useQueryState(
|
||||
"query",
|
||||
parseAsString.withDefault(""),
|
||||
);
|
||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useDebounceValue(
|
||||
searchTerm,
|
||||
300,
|
||||
{
|
||||
leading: true,
|
||||
trailing: false,
|
||||
},
|
||||
);
|
||||
|
||||
const getPathWithBackToParemeter = (path: string) => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
return withQuery(path, {
|
||||
backTo: `${window.location.pathname}${searchParams.size ? `?${searchParams.toString()}` : ""}`,
|
||||
});
|
||||
};
|
||||
|
||||
const getOrganizationEditPath = (id: string) => {
|
||||
return getPathWithBackToParemeter(getAdminPath(`/organizations/${id}`));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setDebouncedSearchTerm(searchTerm);
|
||||
}, [searchTerm]);
|
||||
|
||||
const { data, isLoading } = useAdminOrganizationsQuery({
|
||||
itemsPerPage: ITEMS_PER_PAGE,
|
||||
currentPage,
|
||||
searchTerm: debouncedSearchTerm,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [debouncedSearchTerm]);
|
||||
|
||||
const deleteOrganization = async (id: string) => {
|
||||
toast.promise(
|
||||
async () => {
|
||||
const { error } = await authClient.organization.delete({
|
||||
organizationId: id,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
loading: t("admin.organizations.deleteOrganization.deleting"),
|
||||
success: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: adminOrganizationsQueryKey,
|
||||
});
|
||||
return t("admin.organizations.deleteOrganization.deleted");
|
||||
},
|
||||
error: t("admin.organizations.deleteOrganization.notDeleted"),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const columns: ColumnDef<
|
||||
NonNullable<typeof data>["organizations"][number]
|
||||
>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "user",
|
||||
header: "",
|
||||
accessorFn: (row) => row.name,
|
||||
cell: ({
|
||||
row: {
|
||||
original: { id, name, logo, membersCount },
|
||||
},
|
||||
}) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<OrganizationLogo name={name} logoUrl={logo} />
|
||||
<div className="leading-tight">
|
||||
<Link
|
||||
href={getOrganizationEditPath(id)}
|
||||
className="block font-bold"
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
<small>
|
||||
{t("admin.organizations.membersCount", {
|
||||
count: membersCount,
|
||||
})}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "actions",
|
||||
header: "",
|
||||
cell: ({
|
||||
row: {
|
||||
original: { id },
|
||||
},
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-row justify-end gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon" variant="ghost">
|
||||
<MoreVerticalIcon className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={getOrganizationEditPath(id)}
|
||||
className="flex items-center"
|
||||
>
|
||||
<EditIcon className="mr-2 size-4" />
|
||||
{t("admin.organizations.edit")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
confirm({
|
||||
title: t(
|
||||
"admin.organizations.confirmDelete.title",
|
||||
),
|
||||
message: t(
|
||||
"admin.organizations.confirmDelete.message",
|
||||
),
|
||||
confirmLabel: t(
|
||||
"admin.organizations.confirmDelete.confirm",
|
||||
),
|
||||
destructive: true,
|
||||
onConfirm: () =>
|
||||
deleteOrganization(id),
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="flex items-center text-destructive hover:text-destructive">
|
||||
<TrashIcon className="mr-2 size-4" />
|
||||
{t("admin.organizations.delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const organizations = useMemo(
|
||||
() => data?.organizations ?? [],
|
||||
[data?.organizations],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: organizations,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
manualPagination: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<div className="mb-4 flex items-center justify-between gap-6">
|
||||
<h2 className="font-semibold text-2xl">
|
||||
{t("admin.organizations.title")}
|
||||
</h2>
|
||||
|
||||
<Button asChild>
|
||||
<Link href={getAdminPath("/organizations/new")}>
|
||||
<PlusIcon className="mr-1.5 size-4" />
|
||||
{t("admin.organizations.create")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t("admin.organizations.search")}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={
|
||||
row.getIsSelected() && "selected"
|
||||
}
|
||||
className="group"
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className="py-2 group-first:rounded-t-md group-last:rounded-b-md"
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Spinner className="mr-2 size-4 text-primary" />
|
||||
{t("admin.organizations.loading")}
|
||||
</div>
|
||||
) : (
|
||||
<p>No results.</p>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{!!data?.total && data.total > ITEMS_PER_PAGE && (
|
||||
<Pagination
|
||||
className="mt-4"
|
||||
totalItems={data.total}
|
||||
itemsPerPage={ITEMS_PER_PAGE}
|
||||
currentPage={currentPage}
|
||||
onChangeCurrentPage={setCurrentPage}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
373
apps/web/modules/saas/admin/component/users/UserList.tsx
Normal file
373
apps/web/modules/saas/admin/component/users/UserList.tsx
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
"use client";
|
||||
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { adminUsersQueryKey, useAdminUsersQuery } from "@saas/admin/lib/api";
|
||||
import { useConfirmationAlert } from "@saas/shared/components/ConfirmationAlertProvider";
|
||||
import { Pagination } from "@saas/shared/components/Pagination";
|
||||
import { Spinner } from "@shared/components/Spinner";
|
||||
import { UserAvatar } from "@shared/components/UserAvatar";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { Button } from "@ui/components/button";
|
||||
import { Card } from "@ui/components/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@ui/components/dropdown-menu";
|
||||
import { Input } from "@ui/components/input";
|
||||
import { Table, TableBody, TableCell, TableRow } from "@ui/components/table";
|
||||
import {
|
||||
MoreVerticalIcon,
|
||||
Repeat1Icon,
|
||||
ShieldCheckIcon,
|
||||
ShieldXIcon,
|
||||
SquareUserRoundIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { parseAsInteger, parseAsString, useQueryState } from "nuqs";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useDebounceValue } from "usehooks-ts";
|
||||
import { EmailVerified } from "../EmailVerified";
|
||||
|
||||
const ITEMS_PER_PAGE = 10;
|
||||
|
||||
export function UserList() {
|
||||
const t = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const { confirm } = useConfirmationAlert();
|
||||
const [currentPage, setCurrentPage] = useQueryState(
|
||||
"currentPage",
|
||||
parseAsInteger.withDefault(1),
|
||||
);
|
||||
const [searchTerm, setSearchTerm] = useQueryState(
|
||||
"query",
|
||||
parseAsString.withDefault(""),
|
||||
);
|
||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useDebounceValue(
|
||||
searchTerm,
|
||||
300,
|
||||
{
|
||||
leading: true,
|
||||
trailing: false,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setDebouncedSearchTerm(searchTerm);
|
||||
}, [searchTerm]);
|
||||
|
||||
const { data, isLoading, refetch } = useAdminUsersQuery({
|
||||
itemsPerPage: ITEMS_PER_PAGE,
|
||||
currentPage,
|
||||
searchTerm: debouncedSearchTerm,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [debouncedSearchTerm]);
|
||||
|
||||
const impersonateUser = async (
|
||||
userId: string,
|
||||
{ name }: { name: string },
|
||||
) => {
|
||||
const toastId = toast.loading(
|
||||
t("admin.users.impersonation.impersonating", {
|
||||
name,
|
||||
}),
|
||||
);
|
||||
|
||||
await authClient.admin.impersonateUser({
|
||||
userId,
|
||||
});
|
||||
await refetch();
|
||||
toast.dismiss(toastId);
|
||||
window.location.href = new URL(
|
||||
"/app",
|
||||
window.location.origin,
|
||||
).toString();
|
||||
};
|
||||
|
||||
const deleteUser = async (id: string) => {
|
||||
toast.promise(
|
||||
async () => {
|
||||
const { error } = await authClient.admin.removeUser({
|
||||
userId: id,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
loading: t("admin.users.deleteUser.deleting"),
|
||||
success: () => {
|
||||
return t("admin.users.deleteUser.deleted");
|
||||
},
|
||||
error: t("admin.users.deleteUser.notDeleted"),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const resendVerificationMail = async (email: string) => {
|
||||
toast.promise(
|
||||
async () => {
|
||||
const { error } = await authClient.sendVerificationEmail({
|
||||
email,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
loading: t("admin.users.resendVerificationMail.submitting"),
|
||||
success: () => {
|
||||
return t("admin.users.resendVerificationMail.success");
|
||||
},
|
||||
error: t("admin.users.resendVerificationMail.error"),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const assignAdminRole = async (id: string) => {
|
||||
await authClient.admin.setRole({
|
||||
userId: id,
|
||||
role: "admin",
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: adminUsersQueryKey,
|
||||
});
|
||||
};
|
||||
|
||||
const removeAdminRole = async (id: string) => {
|
||||
await authClient.admin.setRole({
|
||||
userId: id,
|
||||
role: "user",
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: adminUsersQueryKey,
|
||||
});
|
||||
};
|
||||
|
||||
const columns: ColumnDef<
|
||||
NonNullable<
|
||||
Awaited<ReturnType<typeof authClient.admin.listUsers>>["data"]
|
||||
>["users"][number]
|
||||
>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "user",
|
||||
header: "",
|
||||
accessorFn: (row) => row.name,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<UserAvatar
|
||||
name={row.original.name ?? row.original.email}
|
||||
avatarUrl={row.original.image}
|
||||
/>
|
||||
<div className="leading-tight">
|
||||
<strong className="block">
|
||||
{row.original.name ?? row.original.email}
|
||||
</strong>
|
||||
<small className="flex items-center gap-1 text-foreground/60">
|
||||
<span className="block">
|
||||
{!!row.original.name && row.original.email}
|
||||
</span>
|
||||
<EmailVerified
|
||||
verified={row.original.emailVerified}
|
||||
/>
|
||||
<strong className="block">
|
||||
{row.original.role === "admin"
|
||||
? "Admin"
|
||||
: ""}
|
||||
</strong>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="flex flex-row justify-end gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon" variant="ghost">
|
||||
<MoreVerticalIcon className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
impersonateUser(row.original.id, {
|
||||
name: row.original.name ?? "",
|
||||
})
|
||||
}
|
||||
>
|
||||
<SquareUserRoundIcon className="mr-2 size-4" />
|
||||
{t("admin.users.impersonate")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
{!row.original.emailVerified && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
resendVerificationMail(
|
||||
row.original.email,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Repeat1Icon className="mr-2 size-4" />
|
||||
{t(
|
||||
"admin.users.resendVerificationMail.title",
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{row.original.role !== "admin" ? (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
assignAdminRole(row.original.id)
|
||||
}
|
||||
>
|
||||
<ShieldCheckIcon className="mr-2 size-4" />
|
||||
{t("admin.users.assignAdminRole")}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
removeAdminRole(row.original.id)
|
||||
}
|
||||
>
|
||||
<ShieldXIcon className="mr-2 size-4" />
|
||||
{t("admin.users.removeAdminRole")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
confirm({
|
||||
title: t(
|
||||
"admin.users.confirmDelete.title",
|
||||
),
|
||||
message: t(
|
||||
"admin.users.confirmDelete.message",
|
||||
),
|
||||
confirmLabel: t(
|
||||
"admin.users.confirmDelete.confirm",
|
||||
),
|
||||
destructive: true,
|
||||
onConfirm: () =>
|
||||
deleteUser(row.original.id),
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="flex items-center text-destructive hover:text-destructive">
|
||||
<TrashIcon className="mr-2 size-4" />
|
||||
{t("admin.users.delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const users = useMemo(() => data?.users ?? [], [data?.users]);
|
||||
|
||||
const table = useReactTable({
|
||||
data: users,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
manualPagination: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="p-6">
|
||||
<h2 className="mb-4 font-semibold text-2xl">
|
||||
{t("admin.users.title")}
|
||||
</h2>
|
||||
<Input
|
||||
type="search"
|
||||
placeholder={t("admin.users.search")}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={
|
||||
row.getIsSelected() && "selected"
|
||||
}
|
||||
className="group"
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className="py-2 group-first:rounded-t-md group-last:rounded-b-md"
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Spinner className="mr-2 size-4 text-primary" />
|
||||
{t("admin.users.loading")}
|
||||
</div>
|
||||
) : (
|
||||
<p>No results.</p>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{data?.total && data.total > ITEMS_PER_PAGE && (
|
||||
<Pagination
|
||||
className="mt-4"
|
||||
totalItems={data.total}
|
||||
itemsPerPage={ITEMS_PER_PAGE}
|
||||
currentPage={currentPage}
|
||||
onChangeCurrentPage={setCurrentPage}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
82
apps/web/modules/saas/admin/lib/api.ts
Normal file
82
apps/web/modules/saas/admin/lib/api.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { apiClient } from "@shared/lib/api-client";
|
||||
import { createQueryKeyWithParams } from "@shared/lib/query-client";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
|
||||
/*
|
||||
* Admin users
|
||||
*/
|
||||
type FetchAdminUsersParams = {
|
||||
itemsPerPage: number;
|
||||
currentPage: number;
|
||||
searchTerm: string;
|
||||
};
|
||||
|
||||
export const adminUsersQueryKey = ["admin", "users"];
|
||||
export const fetchAdminUsers = async ({
|
||||
itemsPerPage,
|
||||
currentPage,
|
||||
searchTerm,
|
||||
}: FetchAdminUsersParams) => {
|
||||
const response = await apiClient.admin.users.$get({
|
||||
query: {
|
||||
limit: itemsPerPage.toString(),
|
||||
offset: ((currentPage - 1) * itemsPerPage).toString(),
|
||||
query: searchTerm,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Could not fetch users");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
export const useAdminUsersQuery = (params: FetchAdminUsersParams) => {
|
||||
return useQuery({
|
||||
queryKey: createQueryKeyWithParams(adminUsersQueryKey, params),
|
||||
queryFn: () => fetchAdminUsers(params),
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
};
|
||||
|
||||
/*
|
||||
* Admin organizations
|
||||
*/
|
||||
type FetchAdminOrganizationsParams = {
|
||||
itemsPerPage: number;
|
||||
currentPage: number;
|
||||
searchTerm: string;
|
||||
};
|
||||
export const adminOrganizationsQueryKey = ["admin", "organizations"];
|
||||
export const fetchAdminOrganizations = async ({
|
||||
itemsPerPage,
|
||||
currentPage,
|
||||
searchTerm,
|
||||
}: FetchAdminOrganizationsParams) => {
|
||||
const response = await apiClient.admin.organizations.$get({
|
||||
query: {
|
||||
limit: itemsPerPage.toString(),
|
||||
offset: ((currentPage - 1) * itemsPerPage).toString(),
|
||||
query: searchTerm,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Could not fetch organizations");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
export const useAdminOrganizationsQuery = (
|
||||
params: FetchAdminOrganizationsParams,
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: createQueryKeyWithParams(adminOrganizationsQueryKey, params),
|
||||
queryFn: () => fetchAdminOrganizations(params),
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
};
|
||||
5
apps/web/modules/saas/admin/lib/links.ts
Normal file
5
apps/web/modules/saas/admin/lib/links.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { joinRelativeURL } from "ufo";
|
||||
|
||||
export function getAdminPath(path: string) {
|
||||
return joinRelativeURL("/app/admin", path);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue