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);
|
||||
}
|
||||
199
apps/web/modules/saas/ai/components/AiChat.tsx
Normal file
199
apps/web/modules/saas/ai/components/AiChat.tsx
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
aiChatListQueryKey,
|
||||
useAiChatListQuery,
|
||||
useAiChatQuery,
|
||||
useCreateAiChatMutation,
|
||||
} from "@saas/ai/lib/api";
|
||||
import { SidebarContentLayout } from "@saas/shared/components/SidebarContentLayout";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@ui/components/button";
|
||||
import { Textarea } from "@ui/components/textarea";
|
||||
import { cn } from "@ui/lib";
|
||||
import { type Message, useChat } from "ai/react";
|
||||
import { EllipsisIcon, PlusIcon, SendIcon } from "lucide-react";
|
||||
import { useFormatter } from "next-intl";
|
||||
import { useQueryState } from "nuqs";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
|
||||
export function AiChat({ organizationId }: { organizationId?: string }) {
|
||||
const formatter = useFormatter();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: chats, status: chatsStatus } =
|
||||
useAiChatListQuery(organizationId);
|
||||
const [chatId, setChatId] = useQueryState("chatId");
|
||||
const { data: currentChat } = useAiChatQuery(chatId ?? "new");
|
||||
const createChatMutation = useCreateAiChatMutation();
|
||||
const {
|
||||
messages,
|
||||
input,
|
||||
handleInputChange,
|
||||
handleSubmit,
|
||||
isLoading,
|
||||
setMessages,
|
||||
} = useChat({
|
||||
api: `/api/ai/chats/${chatId}/messages`,
|
||||
credentials: "include",
|
||||
initialMessages: [],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (currentChat?.messages?.length) {
|
||||
setMessages(currentChat.messages as unknown as Message[]);
|
||||
}
|
||||
}, [currentChat]);
|
||||
|
||||
const createNewChat = useCallback(async () => {
|
||||
const newChat = await createChatMutation.mutateAsync({
|
||||
organizationId,
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: aiChatListQueryKey(organizationId),
|
||||
});
|
||||
setChatId(newChat.id);
|
||||
}, [createChatMutation]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (chatId || chatsStatus !== "success") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (chats?.length) {
|
||||
setChatId(chats[0].id);
|
||||
} else {
|
||||
await createNewChat();
|
||||
setMessages([]);
|
||||
}
|
||||
})();
|
||||
}, [chatsStatus]);
|
||||
|
||||
const hasChat =
|
||||
chatsStatus === "success" && !!chats?.length && !!currentChat?.id;
|
||||
|
||||
const sortedChats = useMemo(() => {
|
||||
return (
|
||||
chats?.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() -
|
||||
new Date(a.createdAt).getTime(),
|
||||
) ?? []
|
||||
);
|
||||
}, [chats]);
|
||||
|
||||
return (
|
||||
<SidebarContentLayout
|
||||
sidebar={
|
||||
<div>
|
||||
<Button
|
||||
variant="light"
|
||||
size="sm"
|
||||
className="mb-4 flex w-full items-center gap-2"
|
||||
loading={createChatMutation.isPending}
|
||||
onClick={createNewChat}
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
New chat
|
||||
</Button>
|
||||
|
||||
{sortedChats.map((chat) => (
|
||||
<div className="relative" key={chat.id}>
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => setChatId(chat.id)}
|
||||
className={cn(
|
||||
"block h-auto w-full py-2 text-left text-foreground hover:no-underline",
|
||||
chat.id === chatId &&
|
||||
"bg-primary/10 font-bold text-primary",
|
||||
)}
|
||||
>
|
||||
<span className="w-full overflow-hidden">
|
||||
<span className="block truncate">
|
||||
{chat.title ??
|
||||
(chat.messages?.at(0) as any)
|
||||
?.content ??
|
||||
"Untitled chat"}
|
||||
</span>
|
||||
<small className="block font-normal">
|
||||
{formatter.dateTime(
|
||||
new Date(chat.createdAt),
|
||||
{
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
},
|
||||
)}
|
||||
</small>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="-mt-8 flex h-[calc(100vh-10rem)] flex-col">
|
||||
<div className="flex flex-1 flex-col gap-2 overflow-y-auto py-8">
|
||||
{messages.map((message, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex flex-col gap-2",
|
||||
message.role === "user"
|
||||
? "items-end"
|
||||
: "items-start",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex max-w-2xl items-center gap-2 whitespace-pre-wrap rounded-lg px-4 py-2 text-foreground",
|
||||
message.role === "user"
|
||||
? "bg-primary/10"
|
||||
: "bg-secondary/10",
|
||||
)}
|
||||
>
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="flex max-w-2xl items-center gap-2 rounded-lg bg-secondary/10 px-4 py-2 text-foreground">
|
||||
<EllipsisIcon className="size-6 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="relative shrink-0 rounded-lg border-none bg-card py-6 pr-14 pl-6 text-lg shadow-sm focus:outline-hidden focus-visible:ring-0"
|
||||
>
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={handleInputChange}
|
||||
disabled={!hasChat}
|
||||
placeholder="Chat with your AI..."
|
||||
className="min-h-8 rounded-none border-none bg-transparent p-0 focus:outline-hidden focus-visible:ring-0 shadow-none"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
variant="secondary"
|
||||
className="absolute right-3 bottom-3"
|
||||
disabled={!hasChat}
|
||||
>
|
||||
<SendIcon className="size-4" />
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</SidebarContentLayout>
|
||||
);
|
||||
}
|
||||
103
apps/web/modules/saas/ai/lib/api.ts
Normal file
103
apps/web/modules/saas/ai/lib/api.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { apiClient } from "@shared/lib/api-client";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
|
||||
export const aiChatListQueryKey = (organizationId?: string) =>
|
||||
organizationId
|
||||
? (["ai-chat-list", organizationId] as const)
|
||||
: (["ai-chat-list"] as const);
|
||||
export const useAiChatListQuery = (organizationId?: string) =>
|
||||
useQuery({
|
||||
queryKey: aiChatListQueryKey(organizationId),
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.ai.chats.$get({
|
||||
query: {
|
||||
organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch AI chat list");
|
||||
}
|
||||
|
||||
return response.json().then((data) => data.chats);
|
||||
},
|
||||
});
|
||||
|
||||
export const aiChatQueryKey = (id: string) => ["ai-chat", id];
|
||||
export const useAiChatQuery = (id: string) =>
|
||||
useQuery({
|
||||
queryKey: aiChatQueryKey(id),
|
||||
queryFn: async () => {
|
||||
if (id === "new") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await apiClient.ai.chats[":id"].$get({
|
||||
param: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch AI chat");
|
||||
}
|
||||
|
||||
return response.json().then((res) => res.chat);
|
||||
},
|
||||
});
|
||||
|
||||
export const useCreateAiChatMutation = () => {
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
title,
|
||||
organizationId,
|
||||
}: {
|
||||
title?: string;
|
||||
organizationId?: string;
|
||||
}) => {
|
||||
const response = await apiClient.ai.chats.$post({
|
||||
json: {
|
||||
title,
|
||||
organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to create AI chat");
|
||||
}
|
||||
|
||||
return response.json().then((res) => res.chat);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const updateAiChatMutation = () => {
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, title }: { id: string; title?: string }) => {
|
||||
const response = await apiClient.ai.chats[":id"].$put({
|
||||
param: { id },
|
||||
json: { title },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to update AI chat");
|
||||
}
|
||||
|
||||
return response.json().then((res) => res.chat);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteAiChatMutation = () => {
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const response = await apiClient.ai.chats[":id"].$delete({
|
||||
param: { id },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to delete AI chat");
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
135
apps/web/modules/saas/auth/components/ForgotPasswordForm.tsx
Normal file
135
apps/web/modules/saas/auth/components/ForgotPasswordForm.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@ui/components/alert";
|
||||
import { Button } from "@ui/components/button";
|
||||
import { Input } from "@ui/components/input";
|
||||
import { AlertTriangleIcon, ArrowLeftIcon, MailboxIcon } from "lucide-react";
|
||||
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { useAuthErrorMessages } from "@saas/auth/hooks/errors-messages";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@ui/components/form";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function ForgotPasswordForm() {
|
||||
const t = useTranslations();
|
||||
const { getAuthErrorMessage } = useAuthErrorMessages();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit(async ({ email }) => {
|
||||
try {
|
||||
const redirectTo = new URL(
|
||||
"/auth/reset-password",
|
||||
window.location.origin,
|
||||
).toString();
|
||||
|
||||
const { error } = await authClient.forgetPassword({
|
||||
email,
|
||||
redirectTo,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
} catch (e) {
|
||||
form.setError("root", {
|
||||
message: getAuthErrorMessage(
|
||||
e && typeof e === "object" && "code" in e
|
||||
? (e.code as string)
|
||||
: undefined,
|
||||
),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="font-bold text-xl md:text-2xl">
|
||||
{t("auth.forgotPassword.title")}
|
||||
</h1>
|
||||
<p className="mt-1 mb-6 text-foreground/60">
|
||||
{t("auth.forgotPassword.message")}{" "}
|
||||
</p>
|
||||
|
||||
{form.formState.isSubmitSuccessful ? (
|
||||
<Alert variant="success">
|
||||
<MailboxIcon />
|
||||
<AlertTitle>
|
||||
{t("auth.forgotPassword.hints.linkSent.title")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("auth.forgotPassword.hints.linkSent.message")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="flex flex-col items-stretch gap-4"
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{form.formState.errors.root && (
|
||||
<Alert variant="error">
|
||||
<AlertTriangleIcon />
|
||||
<AlertTitle>
|
||||
{form.formState.errors.root.message}
|
||||
</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("auth.forgotPassword.email")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button loading={form.formState.isSubmitting}>
|
||||
{t("auth.forgotPassword.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<Link href="/auth/login">
|
||||
<ArrowLeftIcon className="mr-1 inline size-4 align-middle" />
|
||||
{t("auth.forgotPassword.backToSignin")}
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
357
apps/web/modules/saas/auth/components/LoginForm.tsx
Normal file
357
apps/web/modules/saas/auth/components/LoginForm.tsx
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { config } from "@repo/config";
|
||||
import { useAuthErrorMessages } from "@saas/auth/hooks/errors-messages";
|
||||
import { sessionQueryKey } from "@saas/auth/lib/api";
|
||||
import { OrganizationInvitationAlert } from "@saas/organizations/components/OrganizationInvitationAlert";
|
||||
import { useRouter } from "@shared/hooks/router";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@ui/components/alert";
|
||||
import { Button } from "@ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from "@ui/components/form";
|
||||
import { Input } from "@ui/components/input";
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
ArrowRightIcon,
|
||||
EyeIcon,
|
||||
EyeOffIcon,
|
||||
KeyIcon,
|
||||
MailboxIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { SubmitHandler } from "react-hook-form";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { withQuery } from "ufo";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
type OAuthProvider,
|
||||
oAuthProviders,
|
||||
} from "../constants/oauth-providers";
|
||||
import { useSession } from "../hooks/use-session";
|
||||
import { LoginModeSwitch } from "./LoginModeSwitch";
|
||||
import { SocialSigninButton } from "./SocialSigninButton";
|
||||
|
||||
const formSchema = z.union([
|
||||
z.object({
|
||||
mode: z.literal("magic-link"),
|
||||
email: z.string().email(),
|
||||
}),
|
||||
z.object({
|
||||
mode: z.literal("password"),
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
}),
|
||||
]);
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function LoginForm() {
|
||||
const t = useTranslations();
|
||||
const { getAuthErrorMessage } = useAuthErrorMessages();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const searchParams = useSearchParams();
|
||||
const { user, loaded: sessionLoaded } = useSession();
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const invitationId = searchParams.get("invitationId");
|
||||
const email = searchParams.get("email");
|
||||
const redirectTo = searchParams.get("redirectTo");
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
email: email ?? "",
|
||||
password: "",
|
||||
mode: config.auth.enablePasswordLogin ? "password" : "magic-link",
|
||||
},
|
||||
});
|
||||
|
||||
const redirectPath = invitationId
|
||||
? `/app/organization-invitation/${invitationId}`
|
||||
: (redirectTo ?? config.auth.redirectAfterSignIn);
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionLoaded && user) {
|
||||
router.replace(redirectPath);
|
||||
}
|
||||
}, [user, sessionLoaded]);
|
||||
|
||||
const onSubmit: SubmitHandler<FormValues> = async (values) => {
|
||||
try {
|
||||
if (values.mode === "password") {
|
||||
const { data, error } = await authClient.signIn.email({
|
||||
...values,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if ((data as any).twoFactorRedirect) {
|
||||
router.replace(
|
||||
withQuery(
|
||||
"/auth/verify",
|
||||
Object.fromEntries(searchParams.entries()),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: sessionQueryKey,
|
||||
});
|
||||
|
||||
router.replace(redirectPath);
|
||||
} else {
|
||||
const { error } = await authClient.signIn.magicLink({
|
||||
...values,
|
||||
callbackURL: redirectPath,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
form.setError("root", {
|
||||
message: getAuthErrorMessage(
|
||||
e && typeof e === "object" && "code" in e
|
||||
? (e.code as string)
|
||||
: undefined,
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const signInWithPasskey = async () => {
|
||||
try {
|
||||
await authClient.signIn.passkey();
|
||||
|
||||
router.replace(redirectPath);
|
||||
} catch (e) {
|
||||
form.setError("root", {
|
||||
message: getAuthErrorMessage(
|
||||
e && typeof e === "object" && "code" in e
|
||||
? (e.code as string)
|
||||
: undefined,
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const signinMode = form.watch("mode");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="font-bold text-xl md:text-2xl">
|
||||
{t("auth.login.title")}
|
||||
</h1>
|
||||
<p className="mt-1 mb-6 text-foreground/60">
|
||||
{t("auth.login.subtitle")}
|
||||
</p>
|
||||
|
||||
{form.formState.isSubmitSuccessful &&
|
||||
signinMode === "magic-link" ? (
|
||||
<Alert variant="success">
|
||||
<MailboxIcon />
|
||||
<AlertTitle>
|
||||
{t("auth.login.hints.linkSent.title")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("auth.login.hints.linkSent.message")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
{invitationId && (
|
||||
<OrganizationInvitationAlert className="mb-6" />
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
{config.auth.enableMagicLink &&
|
||||
config.auth.enablePasswordLogin && (
|
||||
<LoginModeSwitch
|
||||
activeMode={signinMode}
|
||||
onChange={(mode) =>
|
||||
form.setValue(
|
||||
"mode",
|
||||
mode as typeof signinMode,
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{form.formState.isSubmitted &&
|
||||
form.formState.errors.root?.message && (
|
||||
<Alert variant="error">
|
||||
<AlertTriangleIcon />
|
||||
<AlertTitle>
|
||||
{form.formState.errors.root.message}
|
||||
</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("auth.signup.email")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{config.auth.enablePasswordLogin &&
|
||||
signinMode === "password" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex justify-between gap-4">
|
||||
<FormLabel>
|
||||
{t(
|
||||
"auth.signup.password",
|
||||
)}
|
||||
</FormLabel>
|
||||
|
||||
<Link
|
||||
href="/auth/forgot-password"
|
||||
className="text-foreground/60 text-xs"
|
||||
>
|
||||
{t(
|
||||
"auth.login.forgotPassword",
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={
|
||||
showPassword
|
||||
? "text"
|
||||
: "password"
|
||||
}
|
||||
className="pr-10"
|
||||
{...field}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setShowPassword(
|
||||
!showPassword,
|
||||
)
|
||||
}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-4 text-primary text-xl"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOffIcon className="size-4" />
|
||||
) : (
|
||||
<EyeIcon className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
{signinMode === "magic-link"
|
||||
? t("auth.login.sendMagicLink")
|
||||
: t("auth.login.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
{(config.auth.enablePasskeys ||
|
||||
(config.auth.enableSignup &&
|
||||
config.auth.enableSocialLogin)) && (
|
||||
<>
|
||||
<div className="relative my-6 h-4">
|
||||
<hr className="relative top-2" />
|
||||
<p className="-translate-x-1/2 absolute top-0 left-1/2 mx-auto inline-block h-4 bg-card px-2 text-center font-medium text-foreground/60 text-sm leading-tight">
|
||||
{t("auth.login.continueWith")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 items-stretch gap-2 sm:grid-cols-2">
|
||||
{config.auth.enableSignup &&
|
||||
config.auth.enableSocialLogin &&
|
||||
Object.keys(oAuthProviders).map(
|
||||
(providerId) => (
|
||||
<SocialSigninButton
|
||||
key={providerId}
|
||||
provider={
|
||||
providerId as OAuthProvider
|
||||
}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
{config.auth.enablePasskeys && (
|
||||
<Button
|
||||
variant="light"
|
||||
className="w-full sm:col-span-2"
|
||||
onClick={() => signInWithPasskey()}
|
||||
>
|
||||
<KeyIcon className="mr-1.5 size-4 text-primary" />
|
||||
{t("auth.login.loginWithPasskey")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{config.auth.enableSignup && (
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<span className="text-foreground/60">
|
||||
{t("auth.login.dontHaveAnAccount")}{" "}
|
||||
</span>
|
||||
<Link
|
||||
href={withQuery(
|
||||
"/auth/signup",
|
||||
Object.fromEntries(searchParams.entries()),
|
||||
)}
|
||||
>
|
||||
{t("auth.login.createAnAccount")}
|
||||
<ArrowRightIcon className="ml-1 inline size-4 align-middle" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
apps/web/modules/saas/auth/components/LoginModeSwitch.tsx
Normal file
28
apps/web/modules/saas/auth/components/LoginModeSwitch.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
"use client";
|
||||
|
||||
import { Tabs, TabsList, TabsTrigger } from "@ui/components/tabs";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function LoginModeSwitch({
|
||||
activeMode,
|
||||
onChange,
|
||||
className,
|
||||
}: {
|
||||
activeMode: "password" | "magic-link";
|
||||
onChange: (mode: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
return (
|
||||
<Tabs value={activeMode} onValueChange={onChange} className={className}>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="password" className="flex-1">
|
||||
{t("auth.login.modes.password")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="magic-link" className="flex-1">
|
||||
{t("auth.login.modes.magicLink")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
169
apps/web/modules/saas/auth/components/OtpForm.tsx
Normal file
169
apps/web/modules/saas/auth/components/OtpForm.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Alert, AlertTitle } from "@ui/components/alert";
|
||||
import { Button } from "@ui/components/button";
|
||||
import { AlertTriangleIcon, ArrowLeftIcon } from "lucide-react";
|
||||
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { config } from "@repo/config";
|
||||
import { useAuthErrorMessages } from "@saas/auth/hooks/errors-messages";
|
||||
import { useRouter } from "@shared/hooks/router";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@ui/components/form";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSeparator,
|
||||
InputOTPSlot,
|
||||
} from "@ui/components/input-otp";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
|
||||
const formSchema = z.object({
|
||||
code: z.string().min(6).max(6),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function OtpForm() {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const { getAuthErrorMessage } = useAuthErrorMessages();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const invitationId = searchParams.get("invitationId");
|
||||
const redirectTo = searchParams.get("redirectTo");
|
||||
|
||||
const redirectPath = invitationId
|
||||
? `/app/organization-invitation/${invitationId}`
|
||||
: (redirectTo ?? config.auth.redirectAfterSignIn);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
code: "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit(async ({ code }) => {
|
||||
try {
|
||||
const { error } = await authClient.twoFactor.verifyTotp({
|
||||
code,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
router.replace(redirectPath);
|
||||
} catch (e) {
|
||||
form.setError("root", {
|
||||
message: getAuthErrorMessage(
|
||||
e && typeof e === "object" && "code" in e
|
||||
? (e.code as string)
|
||||
: undefined,
|
||||
),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="font-bold text-xl md:text-2xl">
|
||||
{t("auth.verify.title")}
|
||||
</h1>
|
||||
<p className="mt-1 mb-4 text-foreground/60">
|
||||
{t("auth.verify.message")}
|
||||
</p>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="flex flex-col items-stretch gap-4"
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{form.formState.errors.root && (
|
||||
<Alert variant="error">
|
||||
<AlertTriangleIcon />
|
||||
<AlertTitle>
|
||||
{form.formState.errors.root.message}
|
||||
</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("auth.verify.code")}</FormLabel>
|
||||
<FormControl>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
{...field}
|
||||
autoComplete="one-time-code"
|
||||
onChange={(value) => {
|
||||
field.onChange(value);
|
||||
onSubmit();
|
||||
}}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot
|
||||
className="size-10 text-lg"
|
||||
index={0}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
className="size-10 text-lg"
|
||||
index={1}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
className="size-10 text-lg"
|
||||
index={2}
|
||||
/>
|
||||
</InputOTPGroup>
|
||||
<InputOTPSeparator className="opacity-40" />
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot
|
||||
className="size-10 text-lg"
|
||||
index={3}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
className="size-10 text-lg"
|
||||
index={4}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
className="size-10 text-lg"
|
||||
index={5}
|
||||
/>
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button loading={form.formState.isSubmitting}>
|
||||
{t("auth.verify.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<Link href="/auth/login">
|
||||
<ArrowLeftIcon className="mr-1 inline size-4 align-middle" />
|
||||
{t("auth.verify.backToSignin")}
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
139
apps/web/modules/saas/auth/components/ResetPasswordForm.tsx
Normal file
139
apps/web/modules/saas/auth/components/ResetPasswordForm.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Alert, AlertTitle } from "@ui/components/alert";
|
||||
import { Button } from "@ui/components/button";
|
||||
import { AlertTriangleIcon, ArrowLeftIcon, MailboxIcon } from "lucide-react";
|
||||
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { config } from "@repo/config";
|
||||
import { useAuthErrorMessages } from "@saas/auth/hooks/errors-messages";
|
||||
import { useSession } from "@saas/auth/hooks/use-session";
|
||||
import { useRouter } from "@shared/hooks/router";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@ui/components/form";
|
||||
import { PasswordInput } from "@ui/components/password-input";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
|
||||
const formSchema = z.object({
|
||||
password: z.string().min(8),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function ResetPasswordForm() {
|
||||
const t = useTranslations();
|
||||
const { user } = useSession();
|
||||
const router = useRouter();
|
||||
const { getAuthErrorMessage } = useAuthErrorMessages();
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get("token");
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit(async ({ password }) => {
|
||||
try {
|
||||
const { error } = await authClient.resetPassword({
|
||||
token: token ?? undefined,
|
||||
newPassword: password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (user) {
|
||||
router.push(config.auth.redirectAfterSignIn);
|
||||
}
|
||||
} catch (e) {
|
||||
form.setError("root", {
|
||||
message: getAuthErrorMessage(
|
||||
e && typeof e === "object" && "code" in e
|
||||
? (e.code as string)
|
||||
: undefined,
|
||||
),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="font-bold text-xl md:text-2xl">
|
||||
{t("auth.resetPassword.title")}
|
||||
</h1>
|
||||
<p className="mt-1 mb-6 text-foreground/60">
|
||||
{t("auth.resetPassword.message")}{" "}
|
||||
</p>
|
||||
|
||||
{form.formState.isSubmitSuccessful ? (
|
||||
<Alert variant="success">
|
||||
<MailboxIcon />
|
||||
<AlertTitle>
|
||||
{t("auth.resetPassword.hints.success")}
|
||||
</AlertTitle>
|
||||
</Alert>
|
||||
) : (
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="flex flex-col items-stretch gap-4"
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{form.formState.errors.root && (
|
||||
<Alert variant="error">
|
||||
<AlertTriangleIcon />
|
||||
<AlertTitle>
|
||||
{form.formState.errors.root.message}
|
||||
</AlertTitle>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("auth.resetPassword.newPassword")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
autoComplete="new-password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button loading={form.formState.isSubmitting}>
|
||||
{t("auth.resetPassword.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<Link href="/auth/login">
|
||||
<ArrowLeftIcon className="mr-1 inline size-4 align-middle" />
|
||||
{t("auth.resetPassword.backToSignin")}
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
51
apps/web/modules/saas/auth/components/SessionProvider.tsx
Normal file
51
apps/web/modules/saas/auth/components/SessionProvider.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
"use client";
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { sessionQueryKey, useSessionQuery } from "@saas/auth/lib/api";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { SessionContext } from "../lib/session-context";
|
||||
|
||||
export function SessionProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: session } = useSessionQuery();
|
||||
const [loaded, setLoaded] = useState(!!session);
|
||||
|
||||
useEffect(() => {
|
||||
if (session && !loaded) {
|
||||
setLoaded(true);
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
return (
|
||||
<SessionContext.Provider
|
||||
value={{
|
||||
loaded,
|
||||
session: session?.session ?? null,
|
||||
user: session?.user ?? null,
|
||||
reloadSession: async () => {
|
||||
const { data: newSession, error } =
|
||||
await authClient.getSession({
|
||||
query: {
|
||||
disableCookieCache: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(
|
||||
error.message || "Failed to fetch session",
|
||||
);
|
||||
}
|
||||
|
||||
queryClient.setQueryData(sessionQueryKey, () => newSession);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SessionContext.Provider>
|
||||
);
|
||||
}
|
||||
282
apps/web/modules/saas/auth/components/SignupForm.tsx
Normal file
282
apps/web/modules/saas/auth/components/SignupForm.tsx
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { config } from "@repo/config";
|
||||
import { useAuthErrorMessages } from "@saas/auth/hooks/errors-messages";
|
||||
import { OrganizationInvitationAlert } from "@saas/organizations/components/OrganizationInvitationAlert";
|
||||
import { useFormErrors } from "@shared/hooks/form-errors";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@ui/components/alert";
|
||||
import { Button } from "@ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@ui/components/form";
|
||||
import { Input } from "@ui/components/input";
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
ArrowRightIcon,
|
||||
EyeIcon,
|
||||
EyeOffIcon,
|
||||
MailboxIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import type { SubmitHandler } from "react-hook-form";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { withQuery } from "ufo";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
type OAuthProvider,
|
||||
oAuthProviders,
|
||||
} from "../constants/oauth-providers";
|
||||
import { SocialSigninButton } from "./SocialSigninButton";
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function SignupForm({ prefillEmail }: { prefillEmail?: string }) {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const { zodErrorMap } = useFormErrors();
|
||||
const { getAuthErrorMessage } = useAuthErrorMessages();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const invitationId = searchParams.get("invitationId");
|
||||
const email = searchParams.get("email");
|
||||
const redirectTo = searchParams.get("redirectTo");
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema, {
|
||||
errorMap: zodErrorMap,
|
||||
}),
|
||||
values: {
|
||||
name: "",
|
||||
email: prefillEmail ?? email ?? "",
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const invitationOnlyMode = !config.auth.enableSignup && invitationId;
|
||||
|
||||
const redirectPath = invitationId
|
||||
? `/app/organization-invitation/${invitationId}`
|
||||
: (redirectTo ?? config.auth.redirectAfterSignIn);
|
||||
|
||||
const onSubmit: SubmitHandler<FormValues> = async ({
|
||||
email,
|
||||
password,
|
||||
name,
|
||||
}) => {
|
||||
try {
|
||||
const { error } = await authClient.signUp.email({
|
||||
email,
|
||||
password,
|
||||
name,
|
||||
callbackURL: redirectPath,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (invitationOnlyMode) {
|
||||
const { error } =
|
||||
await authClient.organization.acceptInvitation({
|
||||
invitationId,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
router.push(config.auth.redirectAfterSignIn);
|
||||
}
|
||||
} catch (e) {
|
||||
form.setError("root", {
|
||||
message: getAuthErrorMessage(
|
||||
e && typeof e === "object" && "code" in e
|
||||
? (e.code as string)
|
||||
: undefined,
|
||||
),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="font-bold text-xl md:text-2xl">
|
||||
{t("auth.signup.title")}
|
||||
</h1>
|
||||
<p className="mt-1 mb-6 text-foreground/60">
|
||||
{t("auth.signup.message")}
|
||||
</p>
|
||||
|
||||
{form.formState.isSubmitSuccessful && !invitationOnlyMode ? (
|
||||
<Alert variant="success">
|
||||
<MailboxIcon />
|
||||
<AlertTitle>
|
||||
{t("auth.signup.hints.verifyEmail")}
|
||||
</AlertTitle>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
{invitationId && (
|
||||
<OrganizationInvitationAlert className="mb-6" />
|
||||
)}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="flex flex-col items-stretch gap-4"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
{form.formState.isSubmitted &&
|
||||
form.formState.errors.root && (
|
||||
<Alert variant="error">
|
||||
<AlertTriangleIcon />
|
||||
<AlertDescription>
|
||||
{form.formState.errors.root.message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("auth.signup.name")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("auth.signup.email")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
autoComplete="email"
|
||||
readOnly={!!prefillEmail}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("auth.signup.password")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={
|
||||
showPassword
|
||||
? "text"
|
||||
: "password"
|
||||
}
|
||||
className="pr-10"
|
||||
{...field}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setShowPassword(
|
||||
!showPassword,
|
||||
)
|
||||
}
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-4 text-primary text-xl"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOffIcon className="size-4" />
|
||||
) : (
|
||||
<EyeIcon className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button loading={form.formState.isSubmitting}>
|
||||
{t("auth.signup.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
{config.auth.enableSignup &&
|
||||
config.auth.enableSocialLogin && (
|
||||
<>
|
||||
<div className="relative my-6 h-4">
|
||||
<hr className="relative top-2" />
|
||||
<p className="-translate-x-1/2 absolute top-0 left-1/2 mx-auto inline-block h-4 bg-card px-2 text-center font-medium text-foreground/60 text-sm leading-tight">
|
||||
{t("auth.login.continueWith")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 items-stretch gap-2 sm:grid-cols-2">
|
||||
{Object.keys(oAuthProviders).map(
|
||||
(providerId) => (
|
||||
<SocialSigninButton
|
||||
key={providerId}
|
||||
provider={
|
||||
providerId as OAuthProvider
|
||||
}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-6 text-center text-sm">
|
||||
<span className="text-foreground/60">
|
||||
{t("auth.signup.alreadyHaveAccount")}{" "}
|
||||
</span>
|
||||
<Link
|
||||
href={withQuery(
|
||||
"/auth/login",
|
||||
Object.fromEntries(searchParams.entries()),
|
||||
)}
|
||||
>
|
||||
{t("auth.signup.signIn")}
|
||||
<ArrowRightIcon className="ml-1 inline size-4 align-middle" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
apps/web/modules/saas/auth/components/SocialSigninButton.tsx
Normal file
47
apps/web/modules/saas/auth/components/SocialSigninButton.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"use client";
|
||||
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { config } from "@repo/config";
|
||||
import { Button } from "@ui/components/button";
|
||||
import { useQueryState } from "nuqs";
|
||||
import { parseAsString } from "nuqs";
|
||||
import { oAuthProviders } from "../constants/oauth-providers";
|
||||
|
||||
export function SocialSigninButton({
|
||||
provider,
|
||||
className,
|
||||
}: {
|
||||
provider: keyof typeof oAuthProviders;
|
||||
className?: string;
|
||||
}) {
|
||||
const [invitationId] = useQueryState("invitationId", parseAsString);
|
||||
const providerData = oAuthProviders[provider];
|
||||
|
||||
const redirectPath = invitationId
|
||||
? `/app/organization-invitation/${invitationId}`
|
||||
: config.auth.redirectAfterSignIn;
|
||||
|
||||
const onSignin = () => {
|
||||
const callbackURL = new URL(redirectPath, window.location.origin);
|
||||
authClient.signIn.social({
|
||||
provider,
|
||||
callbackURL: callbackURL.toString(),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={() => onSignin()}
|
||||
variant="light"
|
||||
type="button"
|
||||
className={className}
|
||||
>
|
||||
{providerData.icon && (
|
||||
<i className="mr-2 text-primary">
|
||||
<providerData.icon className="size-4" />
|
||||
</i>
|
||||
)}
|
||||
{providerData.name}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
37
apps/web/modules/saas/auth/constants/oauth-providers.tsx
Normal file
37
apps/web/modules/saas/auth/constants/oauth-providers.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import type { JSXElementConstructor } from "react";
|
||||
|
||||
type IconProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const oAuthProviders = {
|
||||
vk: {
|
||||
name: "ВКонтакте",
|
||||
icon: ({ ...props }: IconProps) => (
|
||||
<svg width="20" height="20" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
|
||||
<path d="M0 48C0 25.3726 0 14.0589 7.02944 7.02944C14.0589 0 25.3726 0 48 0H52C74.6274 0 85.9411 0 92.9706 7.02944C100 14.0589 100 25.3726 100 48V52C100 74.6274 100 85.9411 92.9706 92.9706C85.9411 100 74.6274 100 52 100H48C25.3726 100 14.0589 100 7.02944 92.9706C0 85.9411 0 74.6274 0 52V48Z" fill="#0077FF"/>
|
||||
<path d="M53.2083 72.042C30.4167 72.042 17.4168 56.417 16.8751 30.417H28.2917C28.6667 49.5003 37.0833 57.5836 43.7499 59.2503V30.417H54.5002V46.8752C61.0836 46.1669 67.9994 38.667 70.3328 30.417H81.0831C79.2914 40.5837 71.7914 48.0836 66.458 51.1669C71.7914 53.6669 80.3335 60.2086 83.5835 72.042H71.7498C69.2081 64.1253 62.8752 58.0003 54.5002 57.1669V72.042H53.2083Z" fill="white"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
github: {
|
||||
name: "Github",
|
||||
icon: ({ ...props }: IconProps) => (
|
||||
<svg viewBox="0 0 496 512" {...props}>
|
||||
<title>Github</title>
|
||||
<path
|
||||
d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
} as const satisfies Record<
|
||||
string,
|
||||
{
|
||||
name: string;
|
||||
icon: JSXElementConstructor<React.SVGProps<SVGSVGElement>>;
|
||||
}
|
||||
>;
|
||||
|
||||
export type OAuthProvider = keyof typeof oAuthProviders;
|
||||
50
apps/web/modules/saas/auth/hooks/errors-messages.ts
Normal file
50
apps/web/modules/saas/auth/hooks/errors-messages.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import type { AuthClientErrorCodes } from "@repo/auth/client";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function useAuthErrorMessages() {
|
||||
const t = useTranslations();
|
||||
|
||||
const authErrorMessages: Partial<AuthClientErrorCodes> = {
|
||||
INVALID_EMAIL_OR_PASSWORD: t("auth.errors.invalidEmailOrPassword"),
|
||||
USER_NOT_FOUND: t("auth.errors.userNotFound"),
|
||||
FAILED_TO_CREATE_USER: t("auth.errors.failedToCreateUser"),
|
||||
FAILED_TO_CREATE_SESSION: t("auth.errors.failedToCreateSession"),
|
||||
FAILED_TO_UPDATE_USER: t("auth.errors.failedToUpdateUser"),
|
||||
FAILED_TO_GET_SESSION: t("auth.errors.failedToGetSession"),
|
||||
INVALID_PASSWORD: t("auth.errors.invalidPassword"),
|
||||
INVALID_EMAIL: t("auth.errors.invalidEmail"),
|
||||
INVALID_TOKEN: t("auth.errors.invalidToken"),
|
||||
CREDENTIAL_ACCOUNT_NOT_FOUND: t(
|
||||
"auth.errors.credentialAccountNotFound",
|
||||
),
|
||||
EMAIL_CAN_NOT_BE_UPDATED: t("auth.errors.emailCanNotBeUpdated"),
|
||||
EMAIL_NOT_VERIFIED: t("auth.errors.emailNotVerified"),
|
||||
FAILED_TO_GET_USER_INFO: t("auth.errors.failedToGetUserInfo"),
|
||||
ID_TOKEN_NOT_SUPPORTED: t("auth.errors.idTokenNotSupported"),
|
||||
PASSWORD_TOO_LONG: t("auth.errors.passwordTooLong"),
|
||||
PASSWORD_TOO_SHORT: t("auth.errors.passwordTooShort"),
|
||||
PROVIDER_NOT_FOUND: t("auth.errors.providerNotFound"),
|
||||
SOCIAL_ACCOUNT_ALREADY_LINKED: t(
|
||||
"auth.errors.socialAccountAlreadyLinked",
|
||||
),
|
||||
USER_EMAIL_NOT_FOUND: t("auth.errors.userEmailNotFound"),
|
||||
USER_ALREADY_EXISTS: t("auth.errors.userAlreadyExists"),
|
||||
INVALID_INVITATION: t("auth.errors.invalidInvitation"),
|
||||
SESSION_EXPIRED: t("auth.errors.sessionExpired"),
|
||||
FAILED_TO_UNLINK_LAST_ACCOUNT: t(
|
||||
"auth.errors.failedToUnlinkLastAccount",
|
||||
),
|
||||
ACCOUNT_NOT_FOUND: t("auth.errors.accountNotFound"),
|
||||
};
|
||||
|
||||
const getAuthErrorMessage = (errorCode: string | undefined) => {
|
||||
return (
|
||||
authErrorMessages[errorCode as keyof typeof authErrorMessages] ||
|
||||
t("auth.errors.unknown")
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
getAuthErrorMessage,
|
||||
};
|
||||
}
|
||||
12
apps/web/modules/saas/auth/hooks/use-session.ts
Normal file
12
apps/web/modules/saas/auth/hooks/use-session.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { useContext } from "react";
|
||||
import { SessionContext } from "../lib/session-context";
|
||||
|
||||
export const useSession = () => {
|
||||
const sessionContext = useContext(SessionContext);
|
||||
|
||||
if (sessionContext === undefined) {
|
||||
throw new Error("useSession must be used within SessionProvider");
|
||||
}
|
||||
|
||||
return sessionContext;
|
||||
};
|
||||
60
apps/web/modules/saas/auth/lib/api.ts
Normal file
60
apps/web/modules/saas/auth/lib/api.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { authClient } from "@repo/auth/client";
|
||||
import { config } from "@repo/config";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export const sessionQueryKey = ["user", "session"] as const;
|
||||
|
||||
export const useSessionQuery = () => {
|
||||
return useQuery({
|
||||
queryKey: sessionQueryKey,
|
||||
queryFn: async () => {
|
||||
const { data, error } = await authClient.getSession({
|
||||
query: {
|
||||
disableCookieCache: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message || "Failed to fetch session");
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
enabled: config.ui.saas.enabled,
|
||||
});
|
||||
};
|
||||
|
||||
export const userAccountQueryKey = ["user", "accounts"] as const;
|
||||
export const useUserAccountsQuery = () => {
|
||||
return useQuery({
|
||||
queryKey: userAccountQueryKey,
|
||||
queryFn: async () => {
|
||||
const { data, error } = await authClient.listAccounts();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const userPasskeyQueryKey = ["user", "passkeys"] as const;
|
||||
export const useUserPasskeysQuery = () => {
|
||||
return useQuery({
|
||||
queryKey: userPasskeyQueryKey,
|
||||
queryFn: async () => {
|
||||
const { data, error } = await authClient.passkey.listUserPasskeys();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
});
|
||||
};
|
||||
75
apps/web/modules/saas/auth/lib/server.ts
Normal file
75
apps/web/modules/saas/auth/lib/server.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import "server-only";
|
||||
import { auth } from "@repo/auth";
|
||||
import { getInvitationById } from "@repo/database";
|
||||
import { headers } from "next/headers";
|
||||
import { cache } from "react";
|
||||
|
||||
export const getSession = cache(async () => {
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
query: {
|
||||
disableCookieCache: true,
|
||||
},
|
||||
});
|
||||
|
||||
return session;
|
||||
});
|
||||
|
||||
export const getActiveOrganization = cache(async (slug: string) => {
|
||||
try {
|
||||
const activeOrganization = await auth.api.getFullOrganization({
|
||||
query: {
|
||||
organizationSlug: slug,
|
||||
},
|
||||
headers: await headers(),
|
||||
});
|
||||
|
||||
return activeOrganization;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
export const getOrganizationList = cache(async () => {
|
||||
try {
|
||||
const organizationList = await auth.api.listOrganizations({
|
||||
headers: await headers(),
|
||||
});
|
||||
|
||||
return organizationList;
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
export const getUserAccounts = cache(async () => {
|
||||
try {
|
||||
const userAccounts = await auth.api.listUserAccounts({
|
||||
headers: await headers(),
|
||||
});
|
||||
|
||||
return userAccounts;
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
export const getUserPasskeys = cache(async () => {
|
||||
try {
|
||||
const userPasskeys = await auth.api.listPasskeys({
|
||||
headers: await headers(),
|
||||
});
|
||||
|
||||
return userPasskeys;
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
export const getInvitation = cache(async (id: string) => {
|
||||
try {
|
||||
return await getInvitationById(id);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
12
apps/web/modules/saas/auth/lib/session-context.ts
Normal file
12
apps/web/modules/saas/auth/lib/session-context.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import type { Session } from "@repo/auth";
|
||||
import React from "react";
|
||||
|
||||
export const SessionContext = React.createContext<
|
||||
| {
|
||||
session: Session["session"] | null;
|
||||
user: Session["user"] | null;
|
||||
loaded: boolean;
|
||||
reloadSession: () => Promise<void>;
|
||||
}
|
||||
| undefined
|
||||
>(undefined);
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
"use client";
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { useRouter } from "@shared/hooks/router";
|
||||
import { clearCache } from "@shared/lib/cache";
|
||||
import { Progress } from "@ui/components/progress";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { withQuery } from "ufo";
|
||||
import { OnboardingStep1 } from "./OnboardingStep1";
|
||||
|
||||
export function OnboardingForm() {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const stepSearchParam = searchParams.get("step");
|
||||
const redirectTo = searchParams.get("redirectTo");
|
||||
const onboardingStep = stepSearchParam
|
||||
? Number.parseInt(stepSearchParam, 10)
|
||||
: 1;
|
||||
|
||||
const setStep = (step: number) => {
|
||||
router.replace(
|
||||
withQuery(window.location.search ?? "", {
|
||||
step,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const onCompleted = async () => {
|
||||
await authClient.updateUser({
|
||||
onboardingComplete: true,
|
||||
});
|
||||
|
||||
await clearCache();
|
||||
router.replace(redirectTo ?? "/app");
|
||||
};
|
||||
|
||||
const steps = [
|
||||
{
|
||||
component: <OnboardingStep1 onCompleted={() => onCompleted()} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="font-bold text-xl md:text-2xl">
|
||||
{t("onboarding.title")}
|
||||
</h1>
|
||||
<p className="mt-2 mb-6 text-foreground/60">
|
||||
{t("onboarding.message")}
|
||||
</p>
|
||||
|
||||
{steps.length > 1 && (
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<Progress
|
||||
value={(onboardingStep / steps.length) * 100}
|
||||
className="h-2"
|
||||
/>
|
||||
<span className="shrink-0 text-foreground/60 text-xs">
|
||||
{t("onboarding.step", {
|
||||
step: onboardingStep,
|
||||
total: steps.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{steps[onboardingStep - 1].component}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
apps/web/modules/saas/onboarding/components/OnboardingStep1.tsx
Normal file
115
apps/web/modules/saas/onboarding/components/OnboardingStep1.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { useSession } from "@saas/auth/hooks/use-session";
|
||||
import { UserAvatarUpload } from "@saas/settings/components/UserAvatarUpload";
|
||||
import { Button } from "@ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from "@ui/components/form";
|
||||
import { Input } from "@ui/components/input";
|
||||
import { ArrowRightIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect } from "react";
|
||||
import type { SubmitHandler } from "react-hook-form";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function OnboardingStep1({ onCompleted }: { onCompleted: () => void }) {
|
||||
const t = useTranslations();
|
||||
const { user } = useSession();
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: user?.name ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
form.setValue("name", user.name ?? "");
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const onSubmit: SubmitHandler<FormValues> = async ({ name }) => {
|
||||
form.clearErrors("root");
|
||||
|
||||
try {
|
||||
await authClient.updateUser({
|
||||
name,
|
||||
});
|
||||
|
||||
onCompleted();
|
||||
} catch (e) {
|
||||
form.setError("root", {
|
||||
type: "server",
|
||||
message: t("onboarding.notifications.accountSetupFailed"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="flex flex-col items-stretch gap-8"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("onboarding.account.name")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormItem className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<FormLabel>
|
||||
{t("onboarding.account.avatar")}
|
||||
</FormLabel>
|
||||
|
||||
<FormDescription>
|
||||
{t("onboarding.account.avatarDescription")}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<UserAvatarUpload
|
||||
onSuccess={() => {
|
||||
return;
|
||||
}}
|
||||
onError={() => {
|
||||
return;
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
{t("onboarding.continue")}
|
||||
<ArrowRightIcon className="ml-2 size-4" />
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
"use client";
|
||||
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { isOrganizationAdmin } from "@repo/auth/lib/helper";
|
||||
import { config } from "@repo/config";
|
||||
import { useSession } from "@saas/auth/hooks/use-session";
|
||||
import { sessionQueryKey } from "@saas/auth/lib/api";
|
||||
import {
|
||||
activeOrganizationQueryKey,
|
||||
useActiveOrganizationQuery,
|
||||
} from "@saas/organizations/lib/api";
|
||||
import { purchasesQueryKey } from "@saas/payments/lib/api";
|
||||
import { useRouter } from "@shared/hooks/router";
|
||||
import { apiClient } from "@shared/lib/api-client";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useParams } from "next/navigation";
|
||||
import nProgress from "nprogress";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { ActiveOrganizationContext } from "../lib/active-organization-context";
|
||||
|
||||
export function ActiveOrganizationProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { session, user } = useSession();
|
||||
const params = useParams();
|
||||
|
||||
const activeOrganizationSlug = params.organizationSlug as string;
|
||||
|
||||
const { data: activeOrganization } = useActiveOrganizationQuery(
|
||||
activeOrganizationSlug,
|
||||
{
|
||||
enabled: !!activeOrganizationSlug,
|
||||
},
|
||||
);
|
||||
|
||||
const refetchActiveOrganization = async () => {
|
||||
await queryClient.refetchQueries({
|
||||
queryKey: activeOrganizationQueryKey(activeOrganizationSlug),
|
||||
});
|
||||
};
|
||||
|
||||
const setActiveOrganization = async (organizationSlug: string | null) => {
|
||||
nProgress.start();
|
||||
const { data: newActiveOrganization } =
|
||||
await authClient.organization.setActive(
|
||||
organizationSlug
|
||||
? {
|
||||
organizationSlug,
|
||||
}
|
||||
: {
|
||||
organizationId: null,
|
||||
},
|
||||
);
|
||||
|
||||
if (!newActiveOrganization) {
|
||||
nProgress.done();
|
||||
return;
|
||||
}
|
||||
|
||||
await queryClient.setQueryData(
|
||||
activeOrganizationQueryKey(newActiveOrganization.slug),
|
||||
newActiveOrganization,
|
||||
);
|
||||
|
||||
if (config.organizations.enableBilling) {
|
||||
await queryClient.prefetchQuery({
|
||||
queryKey: purchasesQueryKey(newActiveOrganization.id),
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.payments.purchases.$get({
|
||||
query: {
|
||||
organizationId: newActiveOrganization.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch purchases");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await queryClient.setQueryData(sessionQueryKey, (data: any) => {
|
||||
return {
|
||||
...data,
|
||||
session: {
|
||||
...data?.session,
|
||||
activeOrganizationId: newActiveOrganization.id,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
router.push(`/app/${newActiveOrganization.slug}`);
|
||||
};
|
||||
|
||||
const [loaded, setLoaded] = useState(activeOrganization !== undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loaded && activeOrganization !== undefined) {
|
||||
setLoaded(true);
|
||||
}
|
||||
}, [activeOrganization]);
|
||||
|
||||
const activeOrganizationUserRole = activeOrganization?.members.find(
|
||||
(member) => member.userId === session?.userId,
|
||||
)?.role;
|
||||
|
||||
return (
|
||||
<ActiveOrganizationContext.Provider
|
||||
value={{
|
||||
loaded,
|
||||
activeOrganization: activeOrganization ?? null,
|
||||
activeOrganizationUserRole: activeOrganizationUserRole ?? null,
|
||||
isOrganizationAdmin:
|
||||
!!activeOrganization &&
|
||||
!!user &&
|
||||
isOrganizationAdmin(activeOrganization, user),
|
||||
setActiveOrganization,
|
||||
refetchActiveOrganization,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ActiveOrganizationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
"use client";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { useActiveOrganization } from "@saas/organizations/hooks/use-active-organization";
|
||||
import { organizationListQueryKey } from "@saas/organizations/lib/api";
|
||||
import { SettingsItem } from "@saas/shared/components/SettingsItem";
|
||||
import { useRouter } from "@shared/hooks/router";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@ui/components/button";
|
||||
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 formSchema = z.object({
|
||||
name: z.string().min(3),
|
||||
});
|
||||
|
||||
type FormSchema = z.infer<typeof formSchema>;
|
||||
|
||||
export function ChangeOrganizationNameForm() {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { activeOrganization } = useActiveOrganization();
|
||||
|
||||
const form = useForm<FormSchema>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: activeOrganization?.name ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit(async ({ name }) => {
|
||||
if (!activeOrganization) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { error } = await authClient.organization.update({
|
||||
organizationId: activeOrganization.id,
|
||||
data: {
|
||||
name,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
toast.success(
|
||||
t(
|
||||
"organizations.settings.notifications.organizationNameUpdated",
|
||||
),
|
||||
);
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: organizationListQueryKey,
|
||||
});
|
||||
router.refresh();
|
||||
} catch {
|
||||
toast.error(
|
||||
t(
|
||||
"organizations.settings.notifications.organizationNameNotUpdated",
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsItem title={t("organizations.settings.changeName.title")}>
|
||||
<form onSubmit={onSubmit}>
|
||||
<Input {...form.register("name")} />
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
!(
|
||||
form.formState.isValid &&
|
||||
form.formState.dirtyFields.name
|
||||
)
|
||||
}
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
{t("settings.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</SettingsItem>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useActiveOrganization } from "@saas/organizations/hooks/use-active-organization";
|
||||
import {
|
||||
organizationListQueryKey,
|
||||
useCreateOrganizationMutation,
|
||||
} from "@saas/organizations/lib/api";
|
||||
import { useRouter } from "@shared/hooks/router";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@ui/components/button";
|
||||
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 formSchema = z.object({
|
||||
name: z.string().min(3).max(32),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function CreateOrganizationForm({
|
||||
defaultName,
|
||||
}: {
|
||||
defaultName?: string;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { setActiveOrganization } = useActiveOrganization();
|
||||
const createOrganizationMutation = useCreateOrganizationMutation();
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: defaultName ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit(async ({ name }) => {
|
||||
try {
|
||||
const newOrganization =
|
||||
await createOrganizationMutation.mutateAsync({
|
||||
name,
|
||||
});
|
||||
|
||||
if (!newOrganization) {
|
||||
throw new Error("Failed to create organization");
|
||||
}
|
||||
|
||||
await setActiveOrganization(newOrganization.id);
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: organizationListQueryKey,
|
||||
});
|
||||
|
||||
router.replace(`/app/${newOrganization.slug}`);
|
||||
} catch (e) {
|
||||
toast.error(t("organizations.createForm.notifications.error"));
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<h1 className="font-bold text-xl md:text-2xl">
|
||||
{t("organizations.createForm.title")}
|
||||
</h1>
|
||||
<p className="mt-2 mb-6 text-foreground/60">
|
||||
{t("organizations.createForm.subtitle")}
|
||||
</p>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={onSubmit}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("organizations.createForm.name")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} autoComplete="email" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="mt-6 w-full"
|
||||
type="submit"
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
{t("organizations.createForm.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
"use client";
|
||||
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { useActiveOrganization } from "@saas/organizations/hooks/use-active-organization";
|
||||
import { useOrganizationListQuery } from "@saas/organizations/lib/api";
|
||||
import { useConfirmationAlert } from "@saas/shared/components/ConfirmationAlertProvider";
|
||||
import { SettingsItem } from "@saas/shared/components/SettingsItem";
|
||||
import { useRouter } from "@shared/hooks/router";
|
||||
import { Button } from "@ui/components/button";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function DeleteOrganizationForm() {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const { confirm } = useConfirmationAlert();
|
||||
const { refetch: reloadOrganizations } = useOrganizationListQuery();
|
||||
const { activeOrganization, setActiveOrganization } =
|
||||
useActiveOrganization();
|
||||
|
||||
if (!activeOrganization) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
confirm({
|
||||
title: t("organizations.settings.deleteOrganization.title"),
|
||||
message: t(
|
||||
"organizations.settings.deleteOrganization.confirmation",
|
||||
),
|
||||
destructive: true,
|
||||
onConfirm: async () => {
|
||||
const { data, error } = await authClient.organization.delete({
|
||||
organizationId: activeOrganization.id,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
t(
|
||||
"organizations.settings.notifications.organizationNotDeleted",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(
|
||||
t(
|
||||
"organizations.settings.notifications.organizationDeleted",
|
||||
),
|
||||
);
|
||||
await setActiveOrganization(null);
|
||||
await reloadOrganizations();
|
||||
router.replace("/app");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsItem
|
||||
danger
|
||||
title={t("organizations.settings.deleteOrganization.title")}
|
||||
description={t(
|
||||
"organizations.settings.deleteOrganization.description",
|
||||
)}
|
||||
>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button variant="error" onClick={handleDelete}>
|
||||
{t("organizations.settings.deleteOrganization.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsItem>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { OrganizationRoleSelect } from "@saas/organizations/components/OrganizationRoleSelect";
|
||||
import { fullOrganizationQueryKey } from "@saas/organizations/lib/api";
|
||||
import { SettingsItem } from "@saas/shared/components/SettingsItem";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from "@ui/components/form";
|
||||
import { Input } from "@ui/components/input";
|
||||
import {} from "@ui/components/select";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { SubmitHandler } from "react-hook-form";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.string().email(),
|
||||
role: z.enum(["member", "owner", "admin"]),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function InviteMemberForm({
|
||||
organizationId,
|
||||
}: {
|
||||
organizationId: string;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
role: "member",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit: SubmitHandler<FormValues> = async (values) => {
|
||||
try {
|
||||
const { error } = await authClient.organization.inviteMember({
|
||||
...values,
|
||||
organizationId,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
form.reset();
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: fullOrganizationQueryKey(organizationId),
|
||||
});
|
||||
|
||||
toast.success(
|
||||
t(
|
||||
"organizations.settings.members.inviteMember.notifications.success.title",
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
toast.error(
|
||||
t(
|
||||
"organizations.settings.members.inviteMember.notifications.error.title",
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsItem
|
||||
title={t("organizations.settings.members.inviteMember.title")}
|
||||
description={t(
|
||||
"organizations.settings.members.inviteMember.description",
|
||||
)}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="@container"
|
||||
>
|
||||
<div className="flex @md:flex-row flex-col gap-2">
|
||||
<div className="flex-1">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"organizations.settings.members.inviteMember.email",
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="role"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"organizations.settings.members.inviteMember.role",
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<OrganizationRoleSelect
|
||||
value={field.value}
|
||||
onSelect={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
{t(
|
||||
"organizations.settings.members.inviteMember.submit",
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsItem>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { Alert, AlertDescription, AlertTitle } from "@ui/components/alert";
|
||||
import { MailCheckIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function OrganizationInvitationAlert({
|
||||
className,
|
||||
}: { className?: string }) {
|
||||
const t = useTranslations();
|
||||
return (
|
||||
<Alert variant="primary" className={className}>
|
||||
<MailCheckIcon />
|
||||
<AlertTitle>{t("organizations.invitationAlert.title")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("organizations.invitationAlert.description")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
"use client";
|
||||
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { OrganizationLogo } from "@saas/organizations/components/OrganizationLogo";
|
||||
import { organizationListQueryKey } from "@saas/organizations/lib/api";
|
||||
import { useRouter } from "@shared/hooks/router";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@ui/components/button";
|
||||
import { CheckIcon, XIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
|
||||
export function OrganizationInvitationModal({
|
||||
invitationId,
|
||||
organizationName,
|
||||
organizationSlug,
|
||||
logoUrl,
|
||||
}: {
|
||||
invitationId: string;
|
||||
organizationName: string;
|
||||
organizationSlug: string;
|
||||
logoUrl?: string;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [submitting, setSubmitting] = useState<false | "accept" | "reject">(
|
||||
false,
|
||||
);
|
||||
|
||||
const onSelectAnswer = async (accept: boolean) => {
|
||||
setSubmitting(accept ? "accept" : "reject");
|
||||
try {
|
||||
if (accept) {
|
||||
const { error } =
|
||||
await authClient.organization.acceptInvitation({
|
||||
invitationId,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: organizationListQueryKey,
|
||||
});
|
||||
|
||||
router.replace(`/app/${organizationSlug}`);
|
||||
} else {
|
||||
const { error } =
|
||||
await authClient.organization.rejectInvitation({
|
||||
invitationId,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
router.replace("/app");
|
||||
}
|
||||
} catch (error) {
|
||||
// TODO: handle error
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="font-bold text-xl md:text-2xl">
|
||||
{t("organizations.invitationModal.title")}
|
||||
</h1>
|
||||
<p className="mt-1 mb-6 text-foreground/60">
|
||||
{t("organizations.invitationModal.description", {
|
||||
organizationName,
|
||||
})}
|
||||
</p>
|
||||
|
||||
<div className="mb-6 flex items-center gap-3 rounded-lg border p-2">
|
||||
<OrganizationLogo
|
||||
name={organizationName}
|
||||
logoUrl={logoUrl}
|
||||
className="size-12"
|
||||
/>
|
||||
<div>
|
||||
<strong className="font-medium text-lg">
|
||||
{organizationName}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="flex-1"
|
||||
variant="light"
|
||||
onClick={() => onSelectAnswer(false)}
|
||||
disabled={!!submitting}
|
||||
loading={submitting === "reject"}
|
||||
>
|
||||
<XIcon className="mr-1.5 size-4" />
|
||||
{t("organizations.invitationModal.decline")}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={() => onSelectAnswer(true)}
|
||||
disabled={!!submitting}
|
||||
loading={submitting === "accept"}
|
||||
>
|
||||
<CheckIcon className="mr-1.5 size-4" />
|
||||
{t("organizations.invitationModal.accept")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,237 @@
|
|||
"use client";
|
||||
|
||||
import type { ActiveOrganization } from "@repo/auth";
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { isOrganizationAdmin } from "@repo/auth/lib/helper";
|
||||
import { useSession } from "@saas/auth/hooks/use-session";
|
||||
import {
|
||||
fullOrganizationQueryKey,
|
||||
useFullOrganizationQuery,
|
||||
} from "@saas/organizations/lib/api";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { Button } from "@ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@ui/components/dropdown-menu";
|
||||
import { Table, TableBody, TableCell, TableRow } from "@ui/components/table";
|
||||
import { cn } from "@ui/lib";
|
||||
import {
|
||||
CheckIcon,
|
||||
ClockIcon,
|
||||
MailXIcon,
|
||||
MoreVerticalIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useFormatter, useTranslations } from "next-intl";
|
||||
import { useMemo } from "react";
|
||||
import { OrganizationRoleSelect } from "./OrganizationRoleSelect";
|
||||
|
||||
import { toast } from "sonner";
|
||||
export function OrganizationInvitationsList({
|
||||
organizationId,
|
||||
}: {
|
||||
organizationId: string;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useSession();
|
||||
const formatter = useFormatter();
|
||||
const { data: organization } = useFullOrganizationQuery(organizationId);
|
||||
|
||||
const canUserEditInvitations = isOrganizationAdmin(organization, user);
|
||||
|
||||
const invitations = useMemo(
|
||||
() =>
|
||||
organization?.invitations
|
||||
?.filter((invitation) => invitation.status === "pending")
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.expiresAt).getTime() -
|
||||
new Date(b.expiresAt).getTime(),
|
||||
),
|
||||
[organization?.invitations],
|
||||
);
|
||||
|
||||
const revokeInvitation = (invitationId: string) => {
|
||||
toast.promise(
|
||||
async () => {
|
||||
const { error } =
|
||||
await authClient.organization.cancelInvitation({
|
||||
invitationId,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
loading: t(
|
||||
"organizations.settings.members.notifications.revokeInvitation.loading.description",
|
||||
),
|
||||
success: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: fullOrganizationQueryKey(organizationId),
|
||||
});
|
||||
return t(
|
||||
"organizations.settings.members.notifications.revokeInvitation.success.description",
|
||||
);
|
||||
},
|
||||
error: t(
|
||||
"organizations.settings.members.notifications.revokeInvitation.error.description",
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const columns: ColumnDef<
|
||||
NonNullable<ActiveOrganization["invitations"]>[number]
|
||||
>[] = [
|
||||
{
|
||||
accessorKey: "email",
|
||||
accessorFn: (row) => row.email,
|
||||
cell: ({ row }) => {
|
||||
const InvitationStatusIcon = {
|
||||
pending: ClockIcon,
|
||||
accepted: CheckIcon,
|
||||
rejected: XIcon,
|
||||
canceled: XIcon,
|
||||
}[row.original.status];
|
||||
return (
|
||||
<div className="leading-normal">
|
||||
<strong
|
||||
className={cn("block", {
|
||||
"opacity-50":
|
||||
row.original.status === "canceled",
|
||||
})}
|
||||
>
|
||||
{row.original.email}
|
||||
</strong>
|
||||
<small className="flex flex-wrap gap-1 text-foreground/60">
|
||||
<span className="flex items-center gap-0.5">
|
||||
<InvitationStatusIcon className="size-3" />
|
||||
{t(
|
||||
`organizations.settings.members.invitations.invitationStatus.${row.original.status}`,
|
||||
)}
|
||||
</span>
|
||||
<span>-</span>
|
||||
<span>
|
||||
{t(
|
||||
"organizations.settings.members.invitations.expiresAt",
|
||||
{
|
||||
date: formatter.dateTime(
|
||||
new Date(row.original.expiresAt),
|
||||
{
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
},
|
||||
),
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
</small>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "actions",
|
||||
cell: ({ row }) => {
|
||||
const isPending = row.original.status === "pending";
|
||||
const isAccepted = row.original.status === "accepted";
|
||||
|
||||
return (
|
||||
<div className="flex flex-row justify-end gap-2">
|
||||
<OrganizationRoleSelect
|
||||
value={row.original.role}
|
||||
disabled
|
||||
onSelect={() => {
|
||||
return;
|
||||
}}
|
||||
/>
|
||||
|
||||
{canUserEditInvitations && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon" variant="ghost">
|
||||
<MoreVerticalIcon className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
disabled={!isPending}
|
||||
onClick={() =>
|
||||
revokeInvitation(row.original.id)
|
||||
}
|
||||
>
|
||||
<MailXIcon className="mr-2 size-4" />
|
||||
{t(
|
||||
"organizations.settings.members.invitations.revoke",
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: invitations ?? [],
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<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"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{t(
|
||||
"organizations.settings.members.invitations.empty",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
"use client";
|
||||
|
||||
import { config } from "@repo/config";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar";
|
||||
import BoringAvatar from "boring-avatars";
|
||||
import { forwardRef, useMemo } from "react";
|
||||
import { useIsClient } from "usehooks-ts";
|
||||
|
||||
export const OrganizationLogo = forwardRef<
|
||||
HTMLSpanElement,
|
||||
{
|
||||
name: string;
|
||||
logoUrl?: string | null;
|
||||
className?: string;
|
||||
}
|
||||
>(({ name, logoUrl, className }, ref) => {
|
||||
const isClient = useIsClient();
|
||||
const avatarColors = useMemo(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const styles = getComputedStyle(window.document.documentElement);
|
||||
return [
|
||||
styles.getPropertyValue("--color-primary"),
|
||||
styles.getPropertyValue("--color-accent"),
|
||||
styles.getPropertyValue("--color-highlight"),
|
||||
];
|
||||
}, []);
|
||||
|
||||
const logoSrc = useMemo(
|
||||
() =>
|
||||
logoUrl
|
||||
? logoUrl.startsWith("http")
|
||||
? logoUrl
|
||||
: `/image-proxy/${config.storage.bucketNames.avatars}/${logoUrl}`
|
||||
: undefined,
|
||||
[logoUrl],
|
||||
);
|
||||
|
||||
if (!isClient) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Avatar ref={ref} className={className}>
|
||||
<AvatarImage src={logoSrc} />
|
||||
<AvatarFallback>
|
||||
<BoringAvatar
|
||||
size={96}
|
||||
name={name}
|
||||
variant="sunset"
|
||||
colors={avatarColors}
|
||||
square
|
||||
/>
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
});
|
||||
|
||||
OrganizationLogo.displayName = "OrganizationLogo";
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
"use client";
|
||||
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { config } from "@repo/config";
|
||||
import { useActiveOrganization } from "@saas/organizations/hooks/use-active-organization";
|
||||
import { organizationListQueryKey } from "@saas/organizations/lib/api";
|
||||
import { SettingsItem } from "@saas/shared/components/SettingsItem";
|
||||
import { useSignedUploadUrlMutation } from "@saas/shared/lib/api";
|
||||
import { Spinner } from "@shared/components/Spinner";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { toast } from "sonner";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import { CropImageDialog } from "../../settings/components/CropImageDialog";
|
||||
import { OrganizationLogo } from "./OrganizationLogo";
|
||||
|
||||
export function OrganizationLogoForm() {
|
||||
const t = useTranslations();
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [cropDialogOpen, setCropDialogOpen] = useState(false);
|
||||
const [image, setImage] = useState<File | null>(null);
|
||||
const { activeOrganization, refetchActiveOrganization } =
|
||||
useActiveOrganization();
|
||||
const queryClient = useQueryClient();
|
||||
const getSignedUploadUrlMutation = useSignedUploadUrlMutation();
|
||||
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
onDrop: (acceptedFiles) => {
|
||||
setImage(acceptedFiles[0]);
|
||||
setCropDialogOpen(true);
|
||||
},
|
||||
accept: {
|
||||
"image/png": [".png"],
|
||||
"image/jpeg": [".jpg", ".jpeg"],
|
||||
},
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
if (!activeOrganization) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onCrop = async (croppedImageData: Blob | null) => {
|
||||
if (!croppedImageData) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const path = `${activeOrganization.id}-${uuid()}.png`;
|
||||
const { signedUrl } = await getSignedUploadUrlMutation.mutateAsync({
|
||||
path,
|
||||
bucket: config.storage.bucketNames.avatars,
|
||||
});
|
||||
|
||||
const response = await fetch(signedUrl, {
|
||||
method: "PUT",
|
||||
body: croppedImageData,
|
||||
headers: {
|
||||
"Content-Type": "image/png",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to upload image");
|
||||
}
|
||||
|
||||
const { error } = await authClient.organization.update({
|
||||
organizationId: activeOrganization.id,
|
||||
data: {
|
||||
logo: path,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
toast.success(t("settings.account.avatar.notifications.success"));
|
||||
|
||||
refetchActiveOrganization();
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: organizationListQueryKey,
|
||||
});
|
||||
} catch (e) {
|
||||
toast.error(t("settings.account.avatar.notifications.error"));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsItem
|
||||
title={t("organizations.settings.logo.title")}
|
||||
description={t("organizations.settings.logo.description")}
|
||||
>
|
||||
<div className="relative size-24 rounded-full" {...getRootProps()}>
|
||||
<input {...getInputProps()} />
|
||||
<OrganizationLogo
|
||||
className="size-24 cursor-pointer text-xl"
|
||||
logoUrl={activeOrganization.logo}
|
||||
name={activeOrganization.name ?? ""}
|
||||
/>
|
||||
|
||||
{uploading && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-card/90">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CropImageDialog
|
||||
image={image}
|
||||
open={cropDialogOpen}
|
||||
onOpenChange={setCropDialogOpen}
|
||||
onCrop={onCrop}
|
||||
/>
|
||||
</SettingsItem>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
"use client";
|
||||
import { SettingsItem } from "@saas/shared/components/SettingsItem";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@ui/components/tabs";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { OrganizationInvitationsList } from "./OrganizationInvitationsList";
|
||||
import { OrganizationMembersList } from "./OrganizationMembersList";
|
||||
|
||||
export function OrganizationMembersBlock({
|
||||
organizationId,
|
||||
}: {
|
||||
organizationId: string;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const [activeTab, setActiveTab] = useState("members");
|
||||
|
||||
return (
|
||||
<SettingsItem
|
||||
title={t("organizations.settings.members.title")}
|
||||
description={t("organizations.settings.members.description")}
|
||||
>
|
||||
<Tabs value={activeTab} onValueChange={(tab) => setActiveTab(tab)}>
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="members">
|
||||
{t("organizations.settings.members.activeMembers")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="invitations">
|
||||
{t("organizations.settings.members.pendingInvitations")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="members">
|
||||
<OrganizationMembersList organizationId={organizationId} />
|
||||
</TabsContent>
|
||||
<TabsContent value="invitations">
|
||||
<OrganizationInvitationsList
|
||||
organizationId={organizationId}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</SettingsItem>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,273 @@
|
|||
"use client";
|
||||
import type { OrganizationMemberRole } from "@repo/auth";
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { isOrganizationAdmin } from "@repo/auth/lib/helper";
|
||||
import { useSession } from "@saas/auth/hooks/use-session";
|
||||
import { useOrganizationMemberRoles } from "@saas/organizations/hooks/member-roles";
|
||||
import {
|
||||
fullOrganizationQueryKey,
|
||||
useFullOrganizationQuery,
|
||||
} from "@saas/organizations/lib/api";
|
||||
import { UserAvatar } from "@shared/components/UserAvatar";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
SortingState,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { Button } from "@ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@ui/components/dropdown-menu";
|
||||
import { Table, TableBody, TableCell, TableRow } from "@ui/components/table";
|
||||
import { LogOutIcon, MoreVerticalIcon, TrashIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { OrganizationRoleSelect } from "./OrganizationRoleSelect";
|
||||
|
||||
export function OrganizationMembersList({
|
||||
organizationId,
|
||||
}: {
|
||||
organizationId: string;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useSession();
|
||||
const { data: organization } = useFullOrganizationQuery(organizationId);
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const memberRoles = useOrganizationMemberRoles();
|
||||
|
||||
const userIsOrganizationAdmin = isOrganizationAdmin(organization, user);
|
||||
|
||||
const updateMemberRole = async (
|
||||
memberId: string,
|
||||
role: OrganizationMemberRole,
|
||||
) => {
|
||||
toast.promise(
|
||||
async () => {
|
||||
await authClient.organization.updateMemberRole({
|
||||
memberId,
|
||||
role,
|
||||
organizationId,
|
||||
});
|
||||
},
|
||||
{
|
||||
loading: t(
|
||||
"organizations.settings.members.notifications.updateMembership.loading.description",
|
||||
),
|
||||
success: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: fullOrganizationQueryKey(organizationId),
|
||||
});
|
||||
|
||||
return t(
|
||||
"organizations.settings.members.notifications.updateMembership.success.description",
|
||||
);
|
||||
},
|
||||
error: t(
|
||||
"organizations.settings.members.notifications.updateMembership.error.description",
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const removeMember = async (memberId: string) => {
|
||||
toast.promise(
|
||||
async () => {
|
||||
await authClient.organization.removeMember({
|
||||
memberIdOrEmail: memberId,
|
||||
organizationId,
|
||||
});
|
||||
},
|
||||
{
|
||||
loading: t(
|
||||
"organizations.settings.members.notifications.removeMember.loading.description",
|
||||
),
|
||||
success: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: fullOrganizationQueryKey(organizationId),
|
||||
});
|
||||
|
||||
return t(
|
||||
"organizations.settings.members.notifications.removeMember.success.description",
|
||||
);
|
||||
},
|
||||
error: t(
|
||||
"organizations.settings.members.notifications.removeMember.error.description",
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const columns: ColumnDef<
|
||||
NonNullable<typeof organization>["members"][number]
|
||||
>[] = [
|
||||
{
|
||||
accessorKey: "user",
|
||||
header: "",
|
||||
accessorFn: (row) => row.user,
|
||||
cell: ({ row }) =>
|
||||
row.original.user ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<UserAvatar
|
||||
name={
|
||||
row.original.user.name ??
|
||||
row.original.user.email
|
||||
}
|
||||
avatarUrl={row.original.user?.image}
|
||||
/>
|
||||
<div>
|
||||
<strong className="block">
|
||||
{row.original.user.name}
|
||||
</strong>
|
||||
<small className="text-foreground/60">
|
||||
{row.original.user.email}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
accessorKey: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="flex flex-row justify-end gap-2">
|
||||
{userIsOrganizationAdmin ? (
|
||||
<>
|
||||
<OrganizationRoleSelect
|
||||
value={row.original.role}
|
||||
onSelect={async (value) =>
|
||||
updateMemberRole(row.original.id, value)
|
||||
}
|
||||
disabled={
|
||||
!userIsOrganizationAdmin ||
|
||||
row.original.role === "owner"
|
||||
}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon" variant="ghost">
|
||||
<MoreVerticalIcon className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{row.original.userId !== user?.id && (
|
||||
<DropdownMenuItem
|
||||
disabled={
|
||||
!isOrganizationAdmin(
|
||||
organization,
|
||||
user,
|
||||
)
|
||||
}
|
||||
className="text-destructive"
|
||||
onClick={async () =>
|
||||
removeMember(
|
||||
row.original.id,
|
||||
)
|
||||
}
|
||||
>
|
||||
<TrashIcon className="mr-2 size-4" />
|
||||
{t(
|
||||
"organizations.settings.members.removeMember",
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{row.original.userId === user?.id && (
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={async () =>
|
||||
removeMember(
|
||||
row.original.id,
|
||||
)
|
||||
}
|
||||
>
|
||||
<LogOutIcon className="mr-2 size-4" />
|
||||
{t(
|
||||
"organizations.settings.members.leaveOrganization",
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
) : (
|
||||
<span className="font-medium text-foreground/60 text-sm">
|
||||
{
|
||||
memberRoles[
|
||||
row.original
|
||||
.role as keyof typeof memberRoles
|
||||
]
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: organization?.members ?? [],
|
||||
columns,
|
||||
manualPagination: true,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<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"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import type { OrganizationMemberRole } from "@repo/auth";
|
||||
import { useOrganizationMemberRoles } from "@saas/organizations/hooks/member-roles";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@ui/components/select";
|
||||
|
||||
export function OrganizationRoleSelect({
|
||||
value,
|
||||
onSelect,
|
||||
disabled,
|
||||
}: {
|
||||
value: OrganizationMemberRole;
|
||||
onSelect: (value: OrganizationMemberRole) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const organizationMemberRoles = useOrganizationMemberRoles();
|
||||
|
||||
const roleOptions = Object.entries(organizationMemberRoles).map(
|
||||
([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<Select value={value} onValueChange={onSelect} disabled={disabled}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roleOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
"use client";
|
||||
import { config } from "@repo/config";
|
||||
import { useSession } from "@saas/auth/hooks/use-session";
|
||||
import { useActiveOrganization } from "@saas/organizations/hooks/use-active-organization";
|
||||
import { useOrganizationListQuery } from "@saas/organizations/lib/api";
|
||||
import { ActivePlanBadge } from "@saas/payments/components/ActivePlanBadge";
|
||||
import { UserAvatar } from "@shared/components/UserAvatar";
|
||||
import { useRouter } from "@shared/hooks/router";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@ui/components/dropdown-menu";
|
||||
import { ChevronsUpDownIcon, PlusIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { OrganizationLogo } from "./OrganizationLogo";
|
||||
|
||||
export function OrganzationSelect({ className }: { className?: string }) {
|
||||
const t = useTranslations();
|
||||
const { user } = useSession();
|
||||
const router = useRouter();
|
||||
const { activeOrganization, setActiveOrganization } =
|
||||
useActiveOrganization();
|
||||
const { data: allOrganizations } = useOrganizationListQuery();
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex w-full items-center justify-between gap-2 rounded-md border p-2 text-left outline-none focus-visible:bg-primary/10 focus-visible:ring-none">
|
||||
<div className="flex flex-1 items-center justify-start gap-2 text-sm overflow-hidden">
|
||||
{activeOrganization ? (
|
||||
<>
|
||||
<OrganizationLogo
|
||||
name={activeOrganization.name}
|
||||
logoUrl={activeOrganization.logo}
|
||||
className="hidden size-6 sm:block"
|
||||
/>
|
||||
<span className="block flex-1 truncate">
|
||||
{activeOrganization.name}
|
||||
</span>
|
||||
{config.organizations.enableBilling && (
|
||||
<ActivePlanBadge
|
||||
organizationId={activeOrganization.id}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserAvatar
|
||||
className="hidden size-6 sm:block"
|
||||
name={user.name ?? ""}
|
||||
avatarUrl={user.image}
|
||||
/>
|
||||
<span className="block truncate">
|
||||
{t(
|
||||
"organizations.organizationSelect.personalAccount",
|
||||
)}
|
||||
</span>
|
||||
{config.users.enableBilling && (
|
||||
<ActivePlanBadge />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChevronsUpDownIcon className="block size-4 opacity-50" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-full">
|
||||
{!config.organizations.requireOrganization && (
|
||||
<>
|
||||
<DropdownMenuRadioGroup
|
||||
value={activeOrganization?.id ?? user.id}
|
||||
onValueChange={(value: string) => {
|
||||
if (value === user.id) {
|
||||
router.replace("/app");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuLabel className="text-foreground/60 text-xs">
|
||||
{t(
|
||||
"organizations.organizationSelect.personalAccount",
|
||||
)}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuRadioItem
|
||||
value={user.id}
|
||||
className="flex cursor-pointer items-center justify-center gap-2 pl-3"
|
||||
>
|
||||
<div className="flex flex-1 items-center justify-start gap-2">
|
||||
<UserAvatar
|
||||
className="size-8"
|
||||
name={user.name ?? ""}
|
||||
avatarUrl={user.image}
|
||||
/>
|
||||
{user.name}
|
||||
</div>
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuRadioGroup
|
||||
value={activeOrganization?.slug}
|
||||
onValueChange={(organizationSlug: string) =>
|
||||
setActiveOrganization(organizationSlug)
|
||||
}
|
||||
>
|
||||
<DropdownMenuLabel className="text-foreground/60 text-xs">
|
||||
{t(
|
||||
"organizations.organizationSelect.organizations",
|
||||
)}
|
||||
</DropdownMenuLabel>
|
||||
{allOrganizations?.map((organization) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={organization.slug}
|
||||
value={organization.slug}
|
||||
className="flex cursor-pointer items-center justify-center gap-2 pl-3"
|
||||
>
|
||||
<div className="flex flex-1 items-center justify-start gap-2">
|
||||
<OrganizationLogo
|
||||
className="size-8"
|
||||
name={organization.name}
|
||||
logoUrl={organization.logo}
|
||||
/>
|
||||
{organization.name}
|
||||
</div>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
|
||||
{config.organizations.enableUsersToCreateOrganizations && (
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
asChild
|
||||
className="text-primary! cursor-pointer text-sm"
|
||||
>
|
||||
<Link href="/app/new-organization">
|
||||
<PlusIcon className="mr-2 size-6 rounded-md bg-primary/20 p-1" />
|
||||
{t(
|
||||
"organizations.organizationSelect.createNewOrganization",
|
||||
)}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
"use client";
|
||||
import { StatsTile } from "@saas/start/components/StatsTile";
|
||||
import { Card } from "@ui/components/card";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function OrganizationStart() {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<div className="@container">
|
||||
<div className="grid @2xl:grid-cols-3 gap-4">
|
||||
<StatsTile
|
||||
title="New clients"
|
||||
value={344}
|
||||
valueFormat="number"
|
||||
trend={0.12}
|
||||
/>
|
||||
<StatsTile
|
||||
title="Revenue"
|
||||
value={5243}
|
||||
valueFormat="currency"
|
||||
trend={0.6}
|
||||
/>
|
||||
<StatsTile
|
||||
title="Churn"
|
||||
value={0.03}
|
||||
valueFormat="percentage"
|
||||
trend={-0.3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card className="mt-6">
|
||||
<div className="flex h-64 items-center justify-center p-8 text-foreground/60">
|
||||
Place your content here...
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
"use client";
|
||||
|
||||
import { config } from "@repo/config";
|
||||
import { OrganizationLogo } from "@saas/organizations/components/OrganizationLogo";
|
||||
import { useActiveOrganization } from "@saas/organizations/hooks/use-active-organization";
|
||||
import { useOrganizationListQuery } from "@saas/organizations/lib/api";
|
||||
import { Card } from "@ui/components/card";
|
||||
import { ChevronRightIcon, PlusCircleIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
|
||||
export function OrganizationsGrid() {
|
||||
const t = useTranslations();
|
||||
const { setActiveOrganization } = useActiveOrganization();
|
||||
const { data: allOrganizations } = useOrganizationListQuery();
|
||||
|
||||
return (
|
||||
<div className="@container">
|
||||
<h2 className="mb-2 font-semibold text-lg">
|
||||
{t("organizations.organizationsGrid.title")}
|
||||
</h2>
|
||||
<div className="grid @2xl:grid-cols-3 @lg:grid-cols-2 grid-cols-1 gap-4">
|
||||
{allOrganizations?.map((organization) => (
|
||||
<Card
|
||||
key={organization.id}
|
||||
className="flex cursor-pointer items-center gap-4 overflow-hidden p-4"
|
||||
onClick={() => setActiveOrganization(organization.slug)}
|
||||
>
|
||||
<OrganizationLogo
|
||||
name={organization.name}
|
||||
logoUrl={organization.logo}
|
||||
className="size-12"
|
||||
/>
|
||||
<span className="flex items-center gap-1 text-base leading-tight">
|
||||
<span className="block font-medium">
|
||||
{organization.name}
|
||||
</span>
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</span>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{config.organizations.enableUsersToCreateOrganizations && (
|
||||
<Link
|
||||
href="/app/new-organization"
|
||||
className="flex h-full items-center justify-center gap-2 rounded-2xl bg-primary/5 p-4 text-primary transition-colors duration-150 hover:bg-primary/10"
|
||||
>
|
||||
<PlusCircleIcon />
|
||||
<span className="font-medium text-sm">
|
||||
{t(
|
||||
"organizations.organizationsGrid.createNewOrganization",
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
apps/web/modules/saas/organizations/hooks/member-roles.ts
Normal file
12
apps/web/modules/saas/organizations/hooks/member-roles.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import type { OrganizationMemberRole } from "@repo/auth";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function useOrganizationMemberRoles() {
|
||||
const t = useTranslations();
|
||||
|
||||
return {
|
||||
member: t("organizations.roles.member"),
|
||||
owner: t("organizations.roles.owner"),
|
||||
admin: t("organizations.roles.admin"),
|
||||
} satisfies Record<OrganizationMemberRole, string>;
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { useContext } from "react";
|
||||
import { ActiveOrganizationContext } from "../lib/active-organization-context";
|
||||
|
||||
export const useActiveOrganization = () => {
|
||||
const activeOrganizationContext = useContext(ActiveOrganizationContext);
|
||||
|
||||
type ActiveOrganizationContextType = NonNullable<
|
||||
typeof activeOrganizationContext
|
||||
>;
|
||||
|
||||
if (activeOrganizationContext === undefined) {
|
||||
return {
|
||||
activeOrganization: null,
|
||||
setActiveOrganization: () => Promise.resolve(),
|
||||
refetchActiveOrganization: () => Promise.resolve(),
|
||||
activeOrganizationUserRole: null,
|
||||
isOrganizationAdmin: false,
|
||||
loaded: true,
|
||||
} satisfies ActiveOrganizationContextType;
|
||||
}
|
||||
|
||||
return activeOrganizationContext;
|
||||
};
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import type { ActiveOrganization } from "@repo/auth";
|
||||
import React from "react";
|
||||
|
||||
export const ActiveOrganizationContext = React.createContext<
|
||||
| {
|
||||
activeOrganization: ActiveOrganization | null;
|
||||
activeOrganizationUserRole:
|
||||
| ActiveOrganization["members"][number]["role"]
|
||||
| null;
|
||||
isOrganizationAdmin: boolean;
|
||||
loaded: boolean;
|
||||
setActiveOrganization: (
|
||||
organizationId: string | null,
|
||||
) => Promise<void>;
|
||||
refetchActiveOrganization: () => Promise<void>;
|
||||
}
|
||||
| undefined
|
||||
>(undefined);
|
||||
159
apps/web/modules/saas/organizations/lib/api.ts
Normal file
159
apps/web/modules/saas/organizations/lib/api.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import type { OrganizationMetadata } from "@repo/auth";
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { apiClient } from "@shared/lib/api-client";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
|
||||
export const organizationListQueryKey = ["user", "organizations"] as const;
|
||||
export const useOrganizationListQuery = () => {
|
||||
return useQuery({
|
||||
queryKey: organizationListQueryKey,
|
||||
queryFn: async () => {
|
||||
const { data, error } = await authClient.organization.list();
|
||||
|
||||
if (error) {
|
||||
throw new Error(
|
||||
error.message || "Failed to fetch organizations",
|
||||
);
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const activeOrganizationQueryKey = (slug: string) =>
|
||||
["user", "activeOrganization", slug] as const;
|
||||
export const useActiveOrganizationQuery = (
|
||||
slug: string,
|
||||
options?: {
|
||||
enabled?: boolean;
|
||||
},
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: activeOrganizationQueryKey(slug),
|
||||
queryFn: async () => {
|
||||
const { data, error } =
|
||||
await authClient.organization.getFullOrganization({
|
||||
query: {
|
||||
organizationSlug: slug,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(
|
||||
error.message || "Failed to fetch active organization",
|
||||
);
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
enabled: options?.enabled,
|
||||
});
|
||||
};
|
||||
|
||||
export const fullOrganizationQueryKey = (id: string) =>
|
||||
["fullOrganization", id] as const;
|
||||
export const useFullOrganizationQuery = (id: string) => {
|
||||
return useQuery({
|
||||
queryKey: fullOrganizationQueryKey(id),
|
||||
queryFn: async () => {
|
||||
const { data, error } =
|
||||
await authClient.organization.getFullOrganization({
|
||||
query: {
|
||||
organizationId: id,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(
|
||||
error.message || "Failed to fetch full organization",
|
||||
);
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const generateOrganizationSlug = async (name: string) => {
|
||||
const response = await apiClient.organizations["generate-slug"].$get({
|
||||
query: {
|
||||
name,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to generate organization slug");
|
||||
}
|
||||
|
||||
const { slug } = await response.json();
|
||||
|
||||
return slug;
|
||||
};
|
||||
|
||||
/*
|
||||
* Create organization
|
||||
*/
|
||||
export const createOrganizationMutationKey = ["create-organization"] as const;
|
||||
export const useCreateOrganizationMutation = () => {
|
||||
return useMutation({
|
||||
mutationKey: createOrganizationMutationKey,
|
||||
mutationFn: async ({
|
||||
name,
|
||||
metadata,
|
||||
}: {
|
||||
name: string;
|
||||
metadata?: OrganizationMetadata;
|
||||
}) => {
|
||||
const { error, data } = await authClient.organization.create({
|
||||
name,
|
||||
slug: await generateOrganizationSlug(name),
|
||||
metadata,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/*
|
||||
* Update organization
|
||||
*/
|
||||
export const updateOrganizationMutationKey = ["update-organization"] as const;
|
||||
export const useUpdateOrganizationMutation = () => {
|
||||
return useMutation({
|
||||
mutationKey: updateOrganizationMutationKey,
|
||||
mutationFn: async ({
|
||||
id,
|
||||
name,
|
||||
metadata,
|
||||
updateSlug,
|
||||
}: {
|
||||
id: string;
|
||||
name: string;
|
||||
metadata?: OrganizationMetadata;
|
||||
updateSlug?: boolean;
|
||||
}) => {
|
||||
const { error, data } = await authClient.organization.update({
|
||||
organizationId: id,
|
||||
data: {
|
||||
name,
|
||||
slug: updateSlug
|
||||
? await generateOrganizationSlug(name)
|
||||
: undefined,
|
||||
metadata,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
});
|
||||
};
|
||||
110
apps/web/modules/saas/payments/components/ActivePlan.tsx
Normal file
110
apps/web/modules/saas/payments/components/ActivePlan.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
"use client";
|
||||
|
||||
import { usePlanData } from "@saas/payments/hooks/plan-data";
|
||||
import { usePurchases } from "@saas/payments/hooks/purchases";
|
||||
import { SettingsItem } from "@saas/shared/components/SettingsItem";
|
||||
import {} from "@ui/components/table";
|
||||
import { BadgeCheckIcon, CheckIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { CustomerPortalButton } from "../../settings/components/CustomerPortalButton";
|
||||
import { SubscriptionStatusBadge } from "../../settings/components/SubscriptionStatusBadge";
|
||||
|
||||
export function ActivePlan({
|
||||
organizationId,
|
||||
}: {
|
||||
organizationId?: string;
|
||||
seats?: number;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const { planData } = usePlanData();
|
||||
const { activePlan } = usePurchases(organizationId);
|
||||
|
||||
if (!activePlan) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activePlanData = planData[activePlan.id as keyof typeof planData];
|
||||
|
||||
if (!activePlanData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const price = "price" in activePlan ? activePlan.price : null;
|
||||
|
||||
return (
|
||||
<SettingsItem title={t("settings.billing.activePlan.title")}>
|
||||
<div className="rounded-lg border p-4">
|
||||
<div className="">
|
||||
<div className="flex items-center gap-2">
|
||||
<BadgeCheckIcon className="size-6 text-primary" />
|
||||
<h4 className="font-bold text-lg text-primary">
|
||||
<span>{activePlanData.title}</span>
|
||||
</h4>
|
||||
{activePlan.status && (
|
||||
<SubscriptionStatusBadge
|
||||
status={activePlan.status}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!!activePlanData.features?.length && (
|
||||
<ul className="mt-2 grid list-none gap-2 text-sm">
|
||||
{activePlanData.features.map((feature, key) => (
|
||||
<li
|
||||
key={key}
|
||||
className="flex items-center justify-start"
|
||||
>
|
||||
<CheckIcon className="mr-2 size-4 text-primary" />
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{price && (
|
||||
<strong
|
||||
className="mt-2 block font-medium text-2xl lg:text-3xl"
|
||||
data-test="price-table-plan-price"
|
||||
>
|
||||
{Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: price.currency,
|
||||
minimumFractionDigits: 0,
|
||||
}).format(price.amount)}
|
||||
{"interval" in price && (
|
||||
<span className="font-normal text-xs opacity-60">
|
||||
{" / "}
|
||||
{price.interval === "month"
|
||||
? t("pricing.month", {
|
||||
count: price.intervalCount ?? 1,
|
||||
})
|
||||
: t("pricing.year", {
|
||||
count: price.intervalCount ?? 1,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{organizationId &&
|
||||
"seatBased" in price &&
|
||||
price.seatBased && (
|
||||
<span className="font-normal text-xs opacity-60">
|
||||
{" / "}
|
||||
{t("pricing.perSeat")}
|
||||
</span>
|
||||
)}
|
||||
</strong>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{"purchaseId" in activePlan && activePlan.purchaseId && (
|
||||
<div className="mt-4 flex justify-end">
|
||||
<div className="flex w-full flex-col flex-wrap gap-2 md:flex-row">
|
||||
<CustomerPortalButton
|
||||
purchaseId={activePlan.purchaseId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsItem>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
"use client";
|
||||
|
||||
import { usePlanData } from "@saas/payments/hooks/plan-data";
|
||||
import { usePurchases } from "@saas/payments/hooks/purchases";
|
||||
import { Badge } from "@ui/components/badge";
|
||||
|
||||
export function ActivePlanBadge({
|
||||
organizationId,
|
||||
}: {
|
||||
organizationId?: string;
|
||||
}) {
|
||||
const { planData } = usePlanData();
|
||||
const { activePlan } = usePurchases(organizationId);
|
||||
|
||||
if (!activePlan) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activePlanData = planData[activePlan.id as keyof typeof planData];
|
||||
|
||||
if (!activePlanData) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Badge
|
||||
status="info"
|
||||
className="flex items-center gap-1 px-1.5 text-xs normal-case"
|
||||
>
|
||||
{activePlanData.title}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
29
apps/web/modules/saas/payments/components/ChangePlan.tsx
Normal file
29
apps/web/modules/saas/payments/components/ChangePlan.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"use client";
|
||||
import { PricingTable } from "@saas/payments/components/PricingTable";
|
||||
import { SettingsItem } from "@saas/shared/components/SettingsItem";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function ChangePlan({
|
||||
organizationId,
|
||||
userId,
|
||||
activePlanId,
|
||||
}: {
|
||||
organizationId?: string;
|
||||
userId?: string;
|
||||
activePlanId?: string;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<SettingsItem
|
||||
title={t("settings.billing.changePlan.title")}
|
||||
description={t("settings.billing.changePlan.description")}
|
||||
>
|
||||
<PricingTable
|
||||
organizationId={organizationId}
|
||||
userId={userId}
|
||||
activePlanId={activePlanId}
|
||||
/>
|
||||
</SettingsItem>
|
||||
);
|
||||
}
|
||||
300
apps/web/modules/saas/payments/components/PricingTable.tsx
Normal file
300
apps/web/modules/saas/payments/components/PricingTable.tsx
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
"use client";
|
||||
import { LocaleLink } from "@i18n/routing";
|
||||
import { type Config, config } from "@repo/config";
|
||||
import { usePlanData } from "@saas/payments/hooks/plan-data";
|
||||
import { useCreateCheckoutLinkMutation } from "@saas/payments/lib/api";
|
||||
import type { PlanId } from "@saas/payments/types";
|
||||
import { useLocaleCurrency } from "@shared/hooks/locale-currency";
|
||||
import { useRouter } from "@shared/hooks/router";
|
||||
import { Button } from "@ui/components/button";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@ui/components/tabs";
|
||||
import { cn } from "@ui/lib";
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
BadgePercentIcon,
|
||||
CheckIcon,
|
||||
PhoneIcon,
|
||||
StarIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
|
||||
const plans = config.payments.plans as Config["payments"]["plans"];
|
||||
|
||||
export function PricingTable({
|
||||
className,
|
||||
userId,
|
||||
organizationId,
|
||||
activePlanId,
|
||||
}: {
|
||||
className?: string;
|
||||
userId?: string;
|
||||
organizationId?: string;
|
||||
activePlanId?: string;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const localeCurrency = useLocaleCurrency();
|
||||
const [loading, setLoading] = useState<PlanId | false>(false);
|
||||
const [interval, setInterval] = useState<"month" | "year">("month");
|
||||
|
||||
const { planData } = usePlanData();
|
||||
|
||||
const createCheckoutLinkMutation = useCreateCheckoutLinkMutation();
|
||||
|
||||
const onSelectPlan = async (planId: PlanId, productId?: string) => {
|
||||
if (!(userId || organizationId)) {
|
||||
router.push("/auth/signup");
|
||||
return;
|
||||
}
|
||||
|
||||
const plan = plans[planId];
|
||||
const price = plan.prices?.find(
|
||||
(price) => price.productId === productId,
|
||||
);
|
||||
|
||||
if (!price) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(planId);
|
||||
|
||||
try {
|
||||
const { checkoutLink } =
|
||||
await createCheckoutLinkMutation.mutateAsync({
|
||||
type:
|
||||
price.type === "one-time" ? "one-time" : "subscription",
|
||||
productId: price.productId,
|
||||
organizationId,
|
||||
redirectUrl: window.location.href,
|
||||
});
|
||||
|
||||
window.location.href = checkoutLink;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredPlans = Object.entries(plans).filter(
|
||||
([planId]) =>
|
||||
planId !== activePlanId && (!activePlanId || planId !== "free"),
|
||||
);
|
||||
|
||||
const hasSubscriptions = filteredPlans.some(([_, plan]) =>
|
||||
plan.prices?.some((price) => price.type === "recurring"),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("@container", className)}>
|
||||
{hasSubscriptions && (
|
||||
<div className="mb-6 flex @xl:justify-center">
|
||||
<Tabs
|
||||
value={interval}
|
||||
onValueChange={(value) =>
|
||||
setInterval(value as typeof interval)
|
||||
}
|
||||
data-test="price-table-interval-tabs"
|
||||
>
|
||||
<TabsList className="border-foreground/10">
|
||||
<TabsTrigger value="month">
|
||||
{t("pricing.monthly")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="year">
|
||||
{t("pricing.yearly")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn("grid grid-cols-1 gap-4", {
|
||||
"@xl:grid-cols-2": filteredPlans.length >= 2,
|
||||
"@3xl:grid-cols-3": filteredPlans.length >= 3,
|
||||
"@4xl:grid-cols-4": filteredPlans.length >= 4,
|
||||
})}
|
||||
>
|
||||
{filteredPlans
|
||||
.filter(([planId]) => planId !== activePlanId)
|
||||
.map(([planId, plan]) => {
|
||||
const { isFree, isEnterprise, prices, recommended } =
|
||||
plan;
|
||||
const { title, description, features } =
|
||||
planData[planId as keyof typeof planData];
|
||||
|
||||
let price = prices?.find(
|
||||
(price) =>
|
||||
!price.hidden &&
|
||||
(price.type === "one-time" ||
|
||||
price.interval === interval) &&
|
||||
price.currency === localeCurrency,
|
||||
);
|
||||
|
||||
if (isFree) {
|
||||
price = {
|
||||
amount: 0,
|
||||
currency: localeCurrency,
|
||||
interval,
|
||||
productId: "",
|
||||
type: "recurring",
|
||||
};
|
||||
}
|
||||
|
||||
if (!(price || isEnterprise)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={planId}
|
||||
className={cn("rounded-3xl border p-6", {
|
||||
"border-2 border-primary": recommended,
|
||||
})}
|
||||
data-test="price-table-plan"
|
||||
>
|
||||
<div className="flex h-full flex-col justify-between gap-4">
|
||||
<div>
|
||||
{recommended && (
|
||||
<div className="-mt-9 flex justify-center">
|
||||
<div className="mb-2 flex h-6 w-auto items-center gap-1.5 rounded-full bg-primary px-2 py-1 font-semibold text-primary-foreground text-xs">
|
||||
<StarIcon className="size-3" />
|
||||
{t("pricing.recommended")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<h3
|
||||
className={cn(
|
||||
"my-0 font-semibold text-2xl",
|
||||
{
|
||||
"font-bold text-primary":
|
||||
recommended,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
{description && (
|
||||
<div className="prose mt-2 text-foreground/60 text-sm">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!!features?.length && (
|
||||
<ul className="mt-4 grid list-none gap-2 text-sm">
|
||||
{features.map(
|
||||
(feature, key) => (
|
||||
<li
|
||||
key={key}
|
||||
className="flex items-center justify-start"
|
||||
>
|
||||
<CheckIcon className="mr-2 size-4 text-primary" />
|
||||
<span>
|
||||
{feature}
|
||||
</span>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{price &&
|
||||
"trialPeriodDays" in price &&
|
||||
price.trialPeriodDays && (
|
||||
<div className="mt-4 flex items-center justify-start font-medium text-primary text-sm opacity-80">
|
||||
<BadgePercentIcon className="mr-2 size-4" />
|
||||
{t("pricing.trialPeriod", {
|
||||
days: price.trialPeriodDays,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{price && (
|
||||
<strong
|
||||
className="block font-medium text-2xl lg:text-3xl"
|
||||
data-test="price-table-plan-price"
|
||||
>
|
||||
{Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: price.currency,
|
||||
minimumFractionDigits: 0,
|
||||
}).format(price.amount)}
|
||||
{"interval" in price && (
|
||||
<span className="font-normal text-xs opacity-60">
|
||||
{" / "}
|
||||
{interval === "month"
|
||||
? t(
|
||||
"pricing.month",
|
||||
{
|
||||
count:
|
||||
price.intervalCount ??
|
||||
1,
|
||||
},
|
||||
)
|
||||
: t(
|
||||
"pricing.year",
|
||||
{
|
||||
count:
|
||||
price.intervalCount ??
|
||||
1,
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{organizationId &&
|
||||
"seatBased" in price &&
|
||||
price.seatBased && (
|
||||
<span className="font-normal text-xs opacity-60">
|
||||
{" / "}
|
||||
{t(
|
||||
"pricing.perSeat",
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</strong>
|
||||
)}
|
||||
|
||||
{isEnterprise ? (
|
||||
<Button
|
||||
className="mt-4 w-full"
|
||||
variant="light"
|
||||
asChild
|
||||
>
|
||||
<LocaleLink href="/contact">
|
||||
<PhoneIcon className="mr-2 size-4" />
|
||||
{t("pricing.contactSales")}
|
||||
</LocaleLink>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="mt-4 w-full"
|
||||
variant={
|
||||
recommended
|
||||
? "primary"
|
||||
: "secondary"
|
||||
}
|
||||
onClick={() =>
|
||||
onSelectPlan(
|
||||
planId as PlanId,
|
||||
price?.productId,
|
||||
)
|
||||
}
|
||||
loading={loading === planId}
|
||||
>
|
||||
{userId || organizationId
|
||||
? t("pricing.choosePlan")
|
||||
: t("pricing.getStarted")}
|
||||
<ArrowRightIcon className="ml-2 size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
apps/web/modules/saas/payments/hooks/plan-data.tsx
Normal file
53
apps/web/modules/saas/payments/hooks/plan-data.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import type { config } from "@repo/config";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type ProductReferenceId = keyof (typeof config)["payments"]["plans"];
|
||||
|
||||
export function usePlanData() {
|
||||
const t = useTranslations();
|
||||
|
||||
const planData: Record<
|
||||
ProductReferenceId,
|
||||
{
|
||||
title: string;
|
||||
description: ReactNode;
|
||||
features: ReactNode[];
|
||||
}
|
||||
> = {
|
||||
free: {
|
||||
title: t("pricing.products.free.title"),
|
||||
description: t("pricing.products.free.description"),
|
||||
features: [
|
||||
t("pricing.products.free.features.anotherFeature"),
|
||||
t("pricing.products.free.features.limitedSupport"),
|
||||
],
|
||||
},
|
||||
pro: {
|
||||
title: t("pricing.products.pro.title"),
|
||||
description: t("pricing.products.pro.description"),
|
||||
features: [
|
||||
t("pricing.products.pro.features.anotherFeature"),
|
||||
t("pricing.products.pro.features.fullSupport"),
|
||||
],
|
||||
},
|
||||
enterprise: {
|
||||
title: t("pricing.products.enterprise.title"),
|
||||
description: t("pricing.products.enterprise.description"),
|
||||
features: [
|
||||
t("pricing.products.enterprise.features.unlimitedProjects"),
|
||||
t("pricing.products.enterprise.features.enterpriseSupport"),
|
||||
],
|
||||
},
|
||||
lifetime: {
|
||||
title: t("pricing.products.lifetime.title"),
|
||||
description: t("pricing.products.lifetime.description"),
|
||||
features: [
|
||||
t("pricing.products.lifetime.features.noRecurringCosts"),
|
||||
t("pricing.products.lifetime.features.extendSupport"),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
return { planData };
|
||||
}
|
||||
20
apps/web/modules/saas/payments/hooks/purchases.tsx
Normal file
20
apps/web/modules/saas/payments/hooks/purchases.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { type Config, config } from "@repo/config";
|
||||
import { createPurchasesHelper } from "@repo/payments/lib/helper";
|
||||
import { usePurchasesQuery } from "@saas/payments/lib/api";
|
||||
|
||||
const plans = config.payments.plans as Config["payments"]["plans"];
|
||||
|
||||
export const usePurchases = (organizationId?: string) => {
|
||||
const { data: purchases } = usePurchasesQuery(organizationId);
|
||||
|
||||
const { activePlan, hasSubscription, hasPurchase } = createPurchasesHelper(
|
||||
purchases ?? [],
|
||||
);
|
||||
|
||||
return { purchases, activePlan, hasSubscription, hasPurchase };
|
||||
};
|
||||
|
||||
export const useUserPurchases = () => usePurchases();
|
||||
|
||||
export const useOrganizationPurchases = (organizationId: string) =>
|
||||
usePurchases(organizationId);
|
||||
80
apps/web/modules/saas/payments/lib/api.ts
Normal file
80
apps/web/modules/saas/payments/lib/api.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { apiClient } from "@shared/lib/api-client";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type { InferRequestType } from "hono";
|
||||
|
||||
export const createCustomerPortalLinkMutationKey = [
|
||||
"create-customer-portal-link",
|
||||
] as const;
|
||||
export const useCreateCustomerPortalLinkMutation = () => {
|
||||
return useMutation({
|
||||
mutationKey: createCustomerPortalLinkMutationKey,
|
||||
mutationFn: async (
|
||||
query: InferRequestType<
|
||||
(typeof apiClient.payments)["create-customer-portal-link"]["$post"]
|
||||
>["query"],
|
||||
) => {
|
||||
const response = await apiClient.payments[
|
||||
"create-customer-portal-link"
|
||||
].$post({
|
||||
query,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to create customer portal link");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const createCheckoutLinkMutationKey = ["create-checkout-link"] as const;
|
||||
export const useCreateCheckoutLinkMutation = () => {
|
||||
return useMutation({
|
||||
mutationKey: createCheckoutLinkMutationKey,
|
||||
mutationFn: async (
|
||||
query: InferRequestType<
|
||||
(typeof apiClient.payments)["create-checkout-link"]["$post"]
|
||||
>["query"],
|
||||
) => {
|
||||
const response = await apiClient.payments[
|
||||
"create-checkout-link"
|
||||
].$post({
|
||||
query,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to create checkout link");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const purchasesQueryKey = (organizationId?: string) =>
|
||||
organizationId
|
||||
? ["organization", "purchases", organizationId]
|
||||
: (["user", "purchases"] as const);
|
||||
export const usePurchasesQuery = (
|
||||
organizationId?: string,
|
||||
options?: { enabled?: boolean },
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: purchasesQueryKey(organizationId),
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.payments.purchases.$get({
|
||||
query: {
|
||||
organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch purchases");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
...options,
|
||||
});
|
||||
};
|
||||
16
apps/web/modules/saas/payments/lib/purchases-context.ts
Normal file
16
apps/web/modules/saas/payments/lib/purchases-context.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { PlanId } from "@saas/payments/types";
|
||||
import React from "react";
|
||||
|
||||
export const PurchasesContext = React.createContext<
|
||||
| {
|
||||
activeSubscription: {
|
||||
id: string;
|
||||
purchaseId?: string;
|
||||
} | null;
|
||||
loaded: boolean;
|
||||
hasSubscription: (planIds?: PlanId[] | PlanId) => boolean;
|
||||
refetchPurchases: () => Promise<void>;
|
||||
hasPurchase: (planId: PlanId) => boolean;
|
||||
}
|
||||
| undefined
|
||||
>(undefined);
|
||||
17
apps/web/modules/saas/payments/lib/server.ts
Normal file
17
apps/web/modules/saas/payments/lib/server.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { getServerApiClient } from "@shared/lib/server";
|
||||
import { cache } from "react";
|
||||
|
||||
export const getPurchases = cache(async (organizationId?: string) => {
|
||||
const apiClient = await getServerApiClient();
|
||||
const response = await apiClient.payments.purchases.$get({
|
||||
query: {
|
||||
organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch purchases");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
});
|
||||
3
apps/web/modules/saas/payments/types.ts
Normal file
3
apps/web/modules/saas/payments/types.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import type { config } from "@repo/config";
|
||||
|
||||
export type PlanId = keyof typeof config.payments.plans;
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
"use client";
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { useSession } from "@saas/auth/hooks/use-session";
|
||||
import { SettingsItem } from "@saas/shared/components/SettingsItem";
|
||||
import { useRouter } from "@shared/hooks/router";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@ui/components/button";
|
||||
import { Skeleton } from "@ui/components/skeleton";
|
||||
import { ComputerIcon, XIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function ActiveSessionsBlock() {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { session: currentSession } = useSession();
|
||||
|
||||
const { data: sessions, isPending } = useQuery({
|
||||
queryKey: ["active-sessions"],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await authClient.listSessions();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const revokeSession = (token: string) => {
|
||||
authClient.revokeSession(
|
||||
{
|
||||
token,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
t(
|
||||
"settings.account.security.activeSessions.notifications.revokeSession.success",
|
||||
),
|
||||
);
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["active-sessions"],
|
||||
});
|
||||
|
||||
router.refresh();
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsItem
|
||||
title={t("settings.account.security.activeSessions.title")}
|
||||
description={t(
|
||||
"settings.account.security.activeSessions.description",
|
||||
)}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{isPending ? (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="size-6 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<Skeleton className="mb-0.5 h-4 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
<Skeleton className="size-9 shrink-0" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
sessions?.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className="flex justify-between gap-4"
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<ComputerIcon className="size-6 shrink-0 text-primary/50" />
|
||||
<div>
|
||||
<strong className="block text-sm">
|
||||
{session.id === currentSession?.id
|
||||
? t(
|
||||
"settings.account.security.activeSessions.currentSession",
|
||||
)
|
||||
: session.ipAddress}
|
||||
</strong>
|
||||
<small className="block text-foreground/60 text-xs leading-tight">
|
||||
{session.userAgent}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="light"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
onClick={() => revokeSession(session.token)}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</SettingsItem>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { useSession } from "@saas/auth/hooks/use-session";
|
||||
import { SettingsItem } from "@saas/shared/components/SettingsItem";
|
||||
import { Button } from "@ui/components/button";
|
||||
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 formSchema = z.object({
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
type FormSchema = z.infer<typeof formSchema>;
|
||||
|
||||
export function ChangeEmailForm() {
|
||||
const { user, reloadSession } = useSession();
|
||||
const t = useTranslations();
|
||||
|
||||
const form = useForm<FormSchema>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
email: user?.email ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit(async ({ email }) => {
|
||||
const { error } = await authClient.changeEmail({
|
||||
newEmail: email,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(t("settings.account.changeEmail.notifications.error"));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t("settings.account.changeEmail.notifications.success"));
|
||||
|
||||
reloadSession();
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsItem
|
||||
title={t("settings.account.changeEmail.title")}
|
||||
description={t("settings.account.changeEmail.description")}
|
||||
>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}}
|
||||
>
|
||||
<Input type="email" {...form.register("email")} />
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
loading={form.formState.isSubmitting}
|
||||
disabled={
|
||||
!(
|
||||
form.formState.isValid &&
|
||||
form.formState.dirtyFields.email
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("settings.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</SettingsItem>
|
||||
);
|
||||
}
|
||||
72
apps/web/modules/saas/settings/components/ChangeNameForm.tsx
Normal file
72
apps/web/modules/saas/settings/components/ChangeNameForm.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { useSession } from "@saas/auth/hooks/use-session";
|
||||
import { SettingsItem } from "@saas/shared/components/SettingsItem";
|
||||
import { Button } from "@ui/components/button";
|
||||
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 formSchema = z.object({
|
||||
name: z.string().min(3),
|
||||
});
|
||||
|
||||
type FormSchema = z.infer<typeof formSchema>;
|
||||
|
||||
export function ChangeNameForm() {
|
||||
const { user, reloadSession } = useSession();
|
||||
const t = useTranslations();
|
||||
|
||||
const form = useForm<FormSchema>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: user?.name ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit(async ({ name }) => {
|
||||
const { error } = await authClient.updateUser({
|
||||
name,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(t("settings.account.changeName.notifications.error"));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t("settings.account.changeName.notifications.success"));
|
||||
|
||||
reloadSession();
|
||||
|
||||
form.reset({
|
||||
name,
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsItem title={t("settings.account.changeName.title")}>
|
||||
<form onSubmit={onSubmit}>
|
||||
<Input type="text" {...form.register("name")} />
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
loading={form.formState.isSubmitting}
|
||||
disabled={
|
||||
!(
|
||||
form.formState.isValid &&
|
||||
form.formState.dirtyFields.name
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("settings.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</SettingsItem>
|
||||
);
|
||||
}
|
||||
130
apps/web/modules/saas/settings/components/ChangePassword.tsx
Normal file
130
apps/web/modules/saas/settings/components/ChangePassword.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"use client";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { SettingsItem } from "@saas/shared/components/SettingsItem";
|
||||
import { useRouter } from "@shared/hooks/router";
|
||||
import { Button } from "@ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@ui/components/form";
|
||||
import { PasswordInput } from "@ui/components/password-input";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const formSchema = z.object({
|
||||
currentPassword: z.string().min(1),
|
||||
newPassword: z.string().min(8),
|
||||
});
|
||||
|
||||
export function ChangePasswordForm() {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit(async (values) => {
|
||||
const { error } = await authClient.changePassword({
|
||||
...values,
|
||||
revokeOtherSessions: true,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
t(
|
||||
"settings.account.security.changePassword.notifications.error",
|
||||
),
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(
|
||||
t("settings.account.security.changePassword.notifications.success"),
|
||||
);
|
||||
form.reset({});
|
||||
router.refresh();
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsItem
|
||||
title={t("settings.account.security.changePassword.title")}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="currentPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"settings.account.security.changePassword.currentPassword",
|
||||
)}
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
autoComplete="current-password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="newPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"settings.account.security.changePassword.newPassword",
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
autoComplete="new-password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
loading={form.formState.isSubmitting}
|
||||
disabled={
|
||||
!(
|
||||
form.formState.isValid &&
|
||||
Object.keys(form.formState.dirtyFields)
|
||||
.length
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("settings.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsItem>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
"use client";
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import {
|
||||
type OAuthProvider,
|
||||
oAuthProviders,
|
||||
} from "@saas/auth/constants/oauth-providers";
|
||||
import { useUserAccountsQuery } from "@saas/auth/lib/api";
|
||||
import { SettingsItem } from "@saas/shared/components/SettingsItem";
|
||||
import { Button } from "@ui/components/button";
|
||||
import { Skeleton } from "@ui/components/skeleton";
|
||||
import { CheckCircle2Icon, LinkIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function ConnectedAccountsBlock() {
|
||||
const t = useTranslations();
|
||||
|
||||
const { data, isPending } = useUserAccountsQuery();
|
||||
|
||||
const isProviderLinked = (provider: OAuthProvider) =>
|
||||
data?.some((account) => account.provider === provider);
|
||||
|
||||
const linkProvider = (provider: OAuthProvider) => {
|
||||
const callbackURL = window.location.href;
|
||||
if (!isProviderLinked(provider)) {
|
||||
authClient.linkSocial({
|
||||
provider,
|
||||
callbackURL,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsItem
|
||||
title={t("settings.account.security.connectedAccounts.title")}
|
||||
>
|
||||
<div className="grid grid-cols-1 divide-y">
|
||||
{Object.entries(oAuthProviders).map(
|
||||
([provider, providerData]) => {
|
||||
const isLinked = isProviderLinked(
|
||||
provider as OAuthProvider,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={provider}
|
||||
className="flex h-14 items-center justify-between gap-2 py-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<providerData.icon className="size-4 text-primary/50" />
|
||||
<span className="text-sm">
|
||||
{providerData.name}
|
||||
</span>
|
||||
</div>
|
||||
{isPending ? (
|
||||
<Skeleton className="h-10 w-28" />
|
||||
) : isLinked ? (
|
||||
<CheckCircle2Icon className="size-6 text-success" />
|
||||
) : (
|
||||
<Button
|
||||
variant={isLinked ? "outline" : "light"}
|
||||
onClick={() =>
|
||||
linkProvider(
|
||||
provider as OAuthProvider,
|
||||
)
|
||||
}
|
||||
>
|
||||
<LinkIcon className="mr-1.5 size-4" />
|
||||
<span>
|
||||
{t(
|
||||
"settings.account.security.connectedAccounts.connect",
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</SettingsItem>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
"use client";
|
||||
|
||||
import { Button } from "@ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@ui/components/dialog";
|
||||
import { useMemo, useRef } from "react";
|
||||
import type { ReactCropperElement } from "react-cropper";
|
||||
import Cropper from "react-cropper";
|
||||
|
||||
export function CropImageDialog({
|
||||
image,
|
||||
open,
|
||||
onOpenChange,
|
||||
onCrop,
|
||||
}: {
|
||||
image: File | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCrop: (croppedImage: Blob | null) => void;
|
||||
}) {
|
||||
const cropperRef = useRef<ReactCropperElement>(null);
|
||||
|
||||
const getCroppedImage = async () => {
|
||||
const cropper = cropperRef.current?.cropper;
|
||||
|
||||
const imageBlob = await new Promise<Blob | null>((resolve) => {
|
||||
cropper
|
||||
?.getCroppedCanvas({
|
||||
maxWidth: 256,
|
||||
maxHeight: 256,
|
||||
})
|
||||
.toBlob(resolve);
|
||||
});
|
||||
|
||||
return imageBlob;
|
||||
};
|
||||
|
||||
const imageSrc = useMemo(
|
||||
() => image && URL.createObjectURL(image),
|
||||
[image],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle />
|
||||
</DialogHeader>
|
||||
<div>
|
||||
{imageSrc && (
|
||||
<Cropper
|
||||
src={imageSrc}
|
||||
style={{ width: "100%" }}
|
||||
initialAspectRatio={1}
|
||||
aspectRatio={1}
|
||||
guides={true}
|
||||
ref={cropperRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
onCrop(await getCroppedImage());
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
"use client";
|
||||
|
||||
import { useCreateCustomerPortalLinkMutation } from "@saas/payments/lib/api";
|
||||
import { Button } from "@ui/components/button";
|
||||
import { CreditCardIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function CustomerPortalButton({ purchaseId }: { purchaseId: string }) {
|
||||
const t = useTranslations();
|
||||
const createCustomerPortalMutation = useCreateCustomerPortalLinkMutation();
|
||||
|
||||
const createCustomerPortal = async () => {
|
||||
try {
|
||||
const { customerPortalLink } =
|
||||
await createCustomerPortalMutation.mutateAsync({
|
||||
purchaseId,
|
||||
redirectUrl: window.location.href,
|
||||
});
|
||||
|
||||
window.location.href = customerPortalLink;
|
||||
} catch {
|
||||
toast.error(
|
||||
t(
|
||||
"settings.billing.createCustomerPortal.notifications.error.title",
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="light"
|
||||
size="sm"
|
||||
onClick={() => createCustomerPortal()}
|
||||
loading={createCustomerPortalMutation.isPending}
|
||||
>
|
||||
<CreditCardIcon className="mr-2 size-4" />
|
||||
{t("settings.billing.createCustomerPortal.label")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
"use client";
|
||||
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { useSession } from "@saas/auth/hooks/use-session";
|
||||
import { useConfirmationAlert } from "@saas/shared/components/ConfirmationAlertProvider";
|
||||
import { SettingsItem } from "@saas/shared/components/SettingsItem";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {} from "@ui/components/alert-dialog";
|
||||
import { Button } from "@ui/components/button";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function DeleteAccountForm() {
|
||||
const t = useTranslations();
|
||||
const { reloadSession } = useSession();
|
||||
const { confirm } = useConfirmationAlert();
|
||||
|
||||
const deleteUserMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { error } = await authClient.deleteUser({});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
t("settings.account.deleteAccount.notifications.success"),
|
||||
);
|
||||
reloadSession();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(
|
||||
t("settings.account.deleteAccount.notifications.error"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const confirmDelete = () => {
|
||||
confirm({
|
||||
title: t("settings.account.deleteAccount.title"),
|
||||
message: t("settings.account.deleteAccount.confirmation"),
|
||||
onConfirm: async () => {
|
||||
await deleteUserMutation.mutateAsync();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsItem
|
||||
danger
|
||||
title={t("settings.account.deleteAccount.title")}
|
||||
description={t("settings.account.deleteAccount.description")}
|
||||
>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button variant="error" onClick={() => confirmDelete()}>
|
||||
{t("settings.account.deleteAccount.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsItem>
|
||||
</>
|
||||
);
|
||||
}
|
||||
114
apps/web/modules/saas/settings/components/PasskeysBlock.tsx
Normal file
114
apps/web/modules/saas/settings/components/PasskeysBlock.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"use client";
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { useUserPasskeysQuery } from "@saas/auth/lib/api";
|
||||
import { SettingsItem } from "@saas/shared/components/SettingsItem";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@ui/components/button";
|
||||
import { Skeleton } from "@ui/components/skeleton";
|
||||
import { KeyIcon, PlusIcon, TrashIcon } from "lucide-react";
|
||||
import { useFormatter, useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function PasskeysBlock() {
|
||||
const t = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const formatter = useFormatter();
|
||||
|
||||
const { data: passkeys, isPending } = useUserPasskeysQuery();
|
||||
|
||||
const addPasskey = async () => {
|
||||
await authClient.passkey.addPasskey({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["passkeys"] });
|
||||
toast.success(
|
||||
t(
|
||||
"settings.account.security.passkeys.notifications.addPasskey.success.title",
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(
|
||||
t(
|
||||
"settings.account.security.passkeys.notifications.addPasskey.error.title",
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const deletePasskey = (id: string) => {
|
||||
toast.promise(
|
||||
async () => {
|
||||
await authClient.passkey.deletePasskey({
|
||||
id,
|
||||
});
|
||||
},
|
||||
{
|
||||
loading: t(
|
||||
"settings.account.security.passkeys.notifications.deletePasskey.loading.title",
|
||||
),
|
||||
success: t(
|
||||
"settings.account.security.passkeys.notifications.deletePasskey.success.title",
|
||||
),
|
||||
error: t(
|
||||
"settings.account.security.passkeys.notifications.deletePasskey.error.title",
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsItem
|
||||
title={t("settings.account.security.passkeys.title")}
|
||||
description={t("settings.account.security.passkeys.description")}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{isPending ? (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="size-6 shrink-0" />
|
||||
<div className="flex-1">
|
||||
<Skeleton className="mb-0.5 h-4 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
<Skeleton className="size-9 shrink-0" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
passkeys?.map((passkey) => (
|
||||
<div key={passkey.id} className="flex gap-2">
|
||||
<KeyIcon className="size-6 shrink-0 text-primary/50" />
|
||||
<div className="flex-1">
|
||||
<strong className="block text-sm">
|
||||
{passkey.deviceType} {passkey.name}
|
||||
</strong>
|
||||
<small className="block text-foreground/60 text-xs leading-tight">
|
||||
{formatter.dateTime(
|
||||
new Date(passkey.createdAt),
|
||||
)}
|
||||
</small>
|
||||
</div>
|
||||
<Button
|
||||
variant="light"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
onClick={() => deletePasskey(passkey.id)}
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
<div className="flex justify-start">
|
||||
<Button variant="light" onClick={addPasskey}>
|
||||
<PlusIcon className="mr-1.5 size-4" />
|
||||
Add passkey
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsItem>
|
||||
);
|
||||
}
|
||||
61
apps/web/modules/saas/settings/components/SetPassword.tsx
Normal file
61
apps/web/modules/saas/settings/components/SetPassword.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"use client";
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { useSession } from "@saas/auth/hooks/use-session";
|
||||
import { SettingsItem } from "@saas/shared/components/SettingsItem";
|
||||
import { Button } from "@ui/components/button";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function SetPasswordForm() {
|
||||
const t = useTranslations();
|
||||
const { user } = useSession();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
|
||||
await authClient.forgetPassword(
|
||||
{
|
||||
email: user.email,
|
||||
redirectTo: `${window.location.origin}/auth/reset-password`,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
t(
|
||||
"settings.account.security.setPassword.notifications.success",
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(
|
||||
t(
|
||||
"settings.account.security.setPassword.notifications.error",
|
||||
),
|
||||
);
|
||||
},
|
||||
onResponse: () => {
|
||||
setSubmitting(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsItem
|
||||
title={t("settings.account.security.setPassword.title")}
|
||||
description={t("settings.account.security.setPassword.description")}
|
||||
>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" loading={submitting} onClick={onSubmit}>
|
||||
{t("settings.account.security.setPassword.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsItem>
|
||||
);
|
||||
}
|
||||
61
apps/web/modules/saas/settings/components/SettingsMenu.tsx
Normal file
61
apps/web/modules/saas/settings/components/SettingsMenu.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"use client";
|
||||
|
||||
import { cn } from "@ui/lib";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function SettingsMenu({
|
||||
menuItems,
|
||||
}: {
|
||||
menuItems: {
|
||||
title: string;
|
||||
avatar: ReactNode;
|
||||
items: {
|
||||
title: string;
|
||||
href: string;
|
||||
icon?: ReactNode;
|
||||
}[];
|
||||
}[];
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const isActiveMenuItem = (href: string) => pathname.includes(href);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{menuItems.map((item, i) => (
|
||||
<div key={i}>
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
{item.avatar}
|
||||
<h2 className="font-semibold text-foreground/60 text-xs">
|
||||
{item.title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<ul className="mt-2 flex list-none flex-row gap-6 lg:mt-4 lg:flex-col lg:gap-2">
|
||||
{item.items.map((subitem, k) => (
|
||||
<li key={k}>
|
||||
<Link
|
||||
href={subitem.href}
|
||||
className={cn(
|
||||
"lg:-ml-0.5 flex items-center gap-2 border-b-2 py-1.5 text-sm lg:border-b-0 lg:border-l-2 lg:pl-2",
|
||||
isActiveMenuItem(subitem.href)
|
||||
? "border-primary font-bold"
|
||||
: "border-transparent",
|
||||
)}
|
||||
data-active={isActiveMenuItem(subitem.href)}
|
||||
>
|
||||
<span className="shrink-0">
|
||||
{subitem.icon}
|
||||
</span>
|
||||
<span>{subitem.title}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
"use client";
|
||||
|
||||
import type { BadgeProps } from "@ui/components/badge";
|
||||
import { Badge } from "@ui/components/badge";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function SubscriptionStatusBadge({
|
||||
status,
|
||||
}: {
|
||||
status: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
|
||||
const badgeLabels: Record<string, string> = {
|
||||
active: t("settings.billing.activePlan.status.active"),
|
||||
canceled: t("settings.billing.activePlan.status.canceled"),
|
||||
expired: t("settings.billing.activePlan.status.expired"),
|
||||
incomplete: t("settings.billing.activePlan.status.incomplete"),
|
||||
past_due: t("settings.billing.activePlan.status.past_due"),
|
||||
paused: t("settings.billing.activePlan.status.paused"),
|
||||
trialing: t("settings.billing.activePlan.status.trialing"),
|
||||
unpaid: t("settings.billing.activePlan.status.unpaid"),
|
||||
};
|
||||
|
||||
const badgeColors: Record<string, BadgeProps["status"]> = {
|
||||
active: "success",
|
||||
canceled: "error",
|
||||
expired: "error",
|
||||
incomplete: "warning",
|
||||
past_due: "warning",
|
||||
paused: "warning",
|
||||
trialing: "info",
|
||||
unpaid: "error",
|
||||
};
|
||||
|
||||
return <Badge status={badgeColors[status]}>{badgeLabels[status]}</Badge>;
|
||||
}
|
||||
290
apps/web/modules/saas/settings/components/TwoFactorBlock.tsx
Normal file
290
apps/web/modules/saas/settings/components/TwoFactorBlock.tsx
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
"use client";
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { useSession } from "@saas/auth/hooks/use-session";
|
||||
import { useUserAccountsQuery } from "@saas/auth/lib/api";
|
||||
import { SettingsItem } from "@saas/shared/components/SettingsItem";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Button } from "@ui/components/button";
|
||||
import { Card } from "@ui/components/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@ui/components/dialog";
|
||||
import { FormItem } from "@ui/components/form";
|
||||
import { Input } from "@ui/components/input";
|
||||
import { Label } from "@ui/components/label";
|
||||
import { PasswordInput } from "@ui/components/password-input";
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
CheckIcon,
|
||||
ShieldCheckIcon,
|
||||
TabletSmartphoneIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import QRCode from "react-qr-code";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function TwoFactorBlock() {
|
||||
const t = useTranslations();
|
||||
const { user, reloadSession } = useSession();
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [dialogView, setDialogView] = useState<"password" | "totp-url">(
|
||||
"password",
|
||||
);
|
||||
const [totpURI, setTotpURI] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [totpCode, setTotpCode] = useState("");
|
||||
|
||||
const { data: accounts } = useUserAccountsQuery();
|
||||
|
||||
useEffect(() => {
|
||||
setPassword("");
|
||||
}, [dialogOpen]);
|
||||
|
||||
const totpURISecret = useMemo(() => {
|
||||
if (!totpURI) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = new URL(totpURI);
|
||||
return url.searchParams.get("secret") || null;
|
||||
}, [totpURI]);
|
||||
|
||||
const verifyPassword = async () => {
|
||||
setDialogView("password");
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const enableTwoFactorMutation = useMutation({
|
||||
mutationKey: ["enableTwoFactor"],
|
||||
mutationFn: async () => {
|
||||
const { data, error } = await authClient.twoFactor.enable({
|
||||
password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
setTotpURI(data.totpURI);
|
||||
setDialogView("totp-url");
|
||||
},
|
||||
|
||||
onError: () => {
|
||||
toast.error(
|
||||
t(
|
||||
"settings.account.security.twoFactor.notifications.enable.error.title",
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const disableTwoFactorMutation = useMutation({
|
||||
mutationKey: ["disableTwoFactor"],
|
||||
mutationFn: async () => {
|
||||
const { data, error } = await authClient.twoFactor.disable({
|
||||
password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
setDialogOpen(false);
|
||||
|
||||
toast.success(
|
||||
t(
|
||||
"settings.account.security.twoFactor.notifications.disable.success.title",
|
||||
),
|
||||
);
|
||||
|
||||
reloadSession();
|
||||
},
|
||||
|
||||
onError: () => {
|
||||
toast.error(
|
||||
t(
|
||||
"settings.account.security.twoFactor.notifications.enable.error.title",
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const verifyTwoFactorMutation = useMutation({
|
||||
mutationKey: ["verifyTwoFactor"],
|
||||
mutationFn: async () => {
|
||||
const { error } = await authClient.twoFactor.verifyTotp({
|
||||
code: totpCode,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
toast.success(
|
||||
t(
|
||||
"settings.account.security.twoFactor.notifications.verify.success.title",
|
||||
),
|
||||
);
|
||||
|
||||
reloadSession();
|
||||
setDialogOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (user?.twoFactorEnabled) {
|
||||
disableTwoFactorMutation.mutate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (dialogView === "password") {
|
||||
enableTwoFactorMutation.mutate();
|
||||
return;
|
||||
}
|
||||
|
||||
verifyTwoFactorMutation.mutate();
|
||||
};
|
||||
|
||||
if (!accounts?.some((account) => account.provider === "credential")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsItem
|
||||
title={t("settings.account.security.twoFactor.title")}
|
||||
description={t("settings.account.security.twoFactor.description")}
|
||||
>
|
||||
{user?.twoFactorEnabled ? (
|
||||
<div className="flex items-start flex-col gap-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ShieldCheckIcon className="size-6 text-green-500" />
|
||||
<p className="text-sm text-foreground">
|
||||
{t("settings.account.security.twoFactor.enabled")}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="light" onClick={verifyPassword}>
|
||||
<XIcon className="mr-1.5 size-4" />
|
||||
{t("settings.account.security.twoFactor.disable")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-start">
|
||||
<Button variant="light" onClick={verifyPassword}>
|
||||
<TabletSmartphoneIcon className="mr-1.5 size-4" />
|
||||
{t("settings.account.security.twoFactor.enable")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{dialogView === "password"
|
||||
? t(
|
||||
"settings.account.security.twoFactor.dialog.password.title",
|
||||
)
|
||||
: t(
|
||||
"settings.account.security.twoFactor.dialog.totpUrl.title",
|
||||
)}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{dialogView === "password" ? (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<p className="text-sm text-foreground/60">
|
||||
{t(
|
||||
"settings.account.security.twoFactor.dialog.password.description",
|
||||
)}
|
||||
</p>
|
||||
|
||||
<FormItem>
|
||||
<Label className="block">
|
||||
{t(
|
||||
"settings.account.security.twoFactor.dialog.password.label",
|
||||
)}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
value={password}
|
||||
onChange={(value) => setPassword(value)}
|
||||
/>
|
||||
</FormItem>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
loading={
|
||||
enableTwoFactorMutation.isPending ||
|
||||
disableTwoFactorMutation.isPending
|
||||
}
|
||||
>
|
||||
{t("common.actions.continue")}
|
||||
<ArrowRightIcon className="ml-1.5 size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<p className="text-sm text-foreground/60">
|
||||
{t(
|
||||
"settings.account.security.twoFactor.dialog.totpUrl.description",
|
||||
)}
|
||||
</p>
|
||||
<Card className="flex flex-col items-center gap-4 p-6">
|
||||
<QRCode value={totpURI} />
|
||||
|
||||
{totpURISecret && (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
{totpURISecret}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<hr />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<FormItem>
|
||||
<Label className="block">
|
||||
{t(
|
||||
"settings.account.security.twoFactor.dialog.totpUrl.code",
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
value={totpCode}
|
||||
onChange={(e) =>
|
||||
setTotpCode(e.target.value)
|
||||
}
|
||||
/>
|
||||
</FormItem>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
loading={verifyTwoFactorMutation.isPending}
|
||||
>
|
||||
<CheckIcon className="mr-1.5 size-4" />
|
||||
{t("common.actions.verify")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</SettingsItem>
|
||||
);
|
||||
}
|
||||
29
apps/web/modules/saas/settings/components/UserAvatarForm.tsx
Normal file
29
apps/web/modules/saas/settings/components/UserAvatarForm.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"use client";
|
||||
import { SettingsItem } from "@saas/shared/components/SettingsItem";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
import { UserAvatarUpload } from "./UserAvatarUpload";
|
||||
|
||||
export function UserAvatarForm() {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<SettingsItem
|
||||
title={t("settings.account.avatar.title")}
|
||||
description={t("settings.account.avatar.description")}
|
||||
>
|
||||
<UserAvatarUpload
|
||||
onSuccess={() => {
|
||||
toast.success(
|
||||
t("settings.account.avatar.notifications.success"),
|
||||
);
|
||||
}}
|
||||
onError={() => {
|
||||
toast.error(
|
||||
t("settings.account.avatar.notifications.error"),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
);
|
||||
}
|
||||
111
apps/web/modules/saas/settings/components/UserAvatarUpload.tsx
Normal file
111
apps/web/modules/saas/settings/components/UserAvatarUpload.tsx
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
"use client";
|
||||
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { config } from "@repo/config";
|
||||
import { useSession } from "@saas/auth/hooks/use-session";
|
||||
import { useSignedUploadUrlMutation } from "@saas/shared/lib/api";
|
||||
import { Spinner } from "@shared/components/Spinner";
|
||||
import { UserAvatar } from "@shared/components/UserAvatar";
|
||||
import { useState } from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import { CropImageDialog } from "./CropImageDialog";
|
||||
|
||||
export function UserAvatarUpload({
|
||||
onSuccess,
|
||||
onError,
|
||||
}: {
|
||||
onSuccess: () => void;
|
||||
onError: () => void;
|
||||
}) {
|
||||
const { user, reloadSession } = useSession();
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [cropDialogOpen, setCropDialogOpen] = useState(false);
|
||||
const [image, setImage] = useState<File | null>(null);
|
||||
|
||||
const getSignedUploadUrlMutation = useSignedUploadUrlMutation();
|
||||
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
onDrop: (acceptedFiles) => {
|
||||
setImage(acceptedFiles[0]);
|
||||
setCropDialogOpen(true);
|
||||
},
|
||||
accept: {
|
||||
"image/png": [".png"],
|
||||
"image/jpeg": [".jpg", ".jpeg"],
|
||||
},
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
const onCrop = async (croppedImageData: Blob | null) => {
|
||||
if (!croppedImageData) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const path = `${user.id}-${uuid()}.png`;
|
||||
const { signedUrl } = await getSignedUploadUrlMutation.mutateAsync({
|
||||
path,
|
||||
bucket: config.storage.bucketNames.avatars,
|
||||
});
|
||||
|
||||
const response = await fetch(signedUrl, {
|
||||
method: "PUT",
|
||||
body: croppedImageData,
|
||||
headers: {
|
||||
"Content-Type": "image/png",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to upload image");
|
||||
}
|
||||
|
||||
const { error } = await authClient.updateUser({
|
||||
image: path,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await reloadSession();
|
||||
|
||||
onSuccess();
|
||||
} catch (e) {
|
||||
onError();
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative size-24 rounded-full" {...getRootProps()}>
|
||||
<input {...getInputProps()} />
|
||||
<UserAvatar
|
||||
className="size-24 cursor-pointer text-xl"
|
||||
avatarUrl={user.image}
|
||||
name={user.name ?? ""}
|
||||
/>
|
||||
|
||||
{uploading && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-card/90">
|
||||
<Spinner className="size-6" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CropImageDialog
|
||||
image={image}
|
||||
open={cropDialogOpen}
|
||||
onOpenChange={setCropDialogOpen}
|
||||
onCrop={onCrop}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
"use client";
|
||||
|
||||
import { updateLocale } from "@i18n/lib/update-locale";
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { config } from "@repo/config";
|
||||
import type { Locale } from "@repo/i18n";
|
||||
import { SettingsItem } from "@saas/shared/components/SettingsItem";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@ui/components/select";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const { locales } = config.i18n;
|
||||
|
||||
export function UserLanguageForm() {
|
||||
const currentLocale = useLocale();
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const [locale, setLocale] = useState<Locale | undefined>(
|
||||
currentLocale as Locale,
|
||||
);
|
||||
|
||||
const updateLocaleMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!locale) {
|
||||
return;
|
||||
}
|
||||
|
||||
await authClient.updateUser({
|
||||
locale,
|
||||
});
|
||||
await updateLocale(locale);
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
const saveLocale = async () => {
|
||||
try {
|
||||
await updateLocaleMutation.mutateAsync();
|
||||
|
||||
toast.success(t("settings.account.language.notifications.success"));
|
||||
} catch {
|
||||
toast.error(t("settings.account.language.notifications.error"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsItem
|
||||
title={t("settings.account.language.title")}
|
||||
description={t("settings.account.language.description")}
|
||||
>
|
||||
<Select
|
||||
value={locale}
|
||||
onValueChange={(value) => {
|
||||
setLocale(value as Locale);
|
||||
saveLocale();
|
||||
}}
|
||||
disabled={updateLocaleMutation.isPending}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(locales).map(([key, value]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{value.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsItem>
|
||||
);
|
||||
}
|
||||
33
apps/web/modules/saas/shared/components/AppWrapper.tsx
Normal file
33
apps/web/modules/saas/shared/components/AppWrapper.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { config } from "@repo/config";
|
||||
import { NavBar } from "@saas/shared/components/NavBar";
|
||||
import { cn } from "@ui/lib";
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
export function AppWrapper({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-[radial-gradient(farthest-corner_at_0%_0%,color-mix(in_oklch,var(--color-primary),transparent_95%)_0%,var(--color-background)_50%)] dark:bg-[radial-gradient(farthest-corner_at_0%_0%,color-mix(in_oklch,var(--color-primary),transparent_90%)_0%,var(--color-background)_50%)]",
|
||||
[config.ui.saas.useSidebarLayout ? "" : ""],
|
||||
)}
|
||||
>
|
||||
<NavBar />
|
||||
<div
|
||||
className={cn(" md:pr-4 py-4 flex", [
|
||||
config.ui.saas.useSidebarLayout
|
||||
? "min-h-[calc(100vh)] md:ml-[280px]"
|
||||
: "",
|
||||
])}
|
||||
>
|
||||
<main
|
||||
className={cn(
|
||||
"py-6 border rounded-2xl bg-card px-4 md:p-8 min-h-full w-full",
|
||||
[config.ui.saas.useSidebarLayout ? "" : ""],
|
||||
)}
|
||||
>
|
||||
<div className="container px-0">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
apps/web/modules/saas/shared/components/AuthWrapper.tsx
Normal file
49
apps/web/modules/saas/shared/components/AuthWrapper.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { config } from "@repo/config";
|
||||
import { Footer } from "@saas/shared/components/Footer";
|
||||
import { ColorModeToggle } from "@shared/components/ColorModeToggle";
|
||||
import { LocaleSwitch } from "@shared/components/LocaleSwitch";
|
||||
import { Logo } from "@shared/components/Logo";
|
||||
import { cn } from "@ui/lib";
|
||||
import Link from "next/link";
|
||||
import { type PropsWithChildren, Suspense } from "react";
|
||||
|
||||
export function AuthWrapper({
|
||||
children,
|
||||
contentClass,
|
||||
}: PropsWithChildren<{ contentClass?: string }>) {
|
||||
return (
|
||||
<div className="flex min-h-screen w-full py-6">
|
||||
<div className="flex w-full flex-col items-center justify-between gap-8">
|
||||
<div className="container">
|
||||
<div className="flex items-center justify-between">
|
||||
<Link href="/" className="block">
|
||||
<Logo />
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{config.i18n.enabled && (
|
||||
<Suspense>
|
||||
<LocaleSwitch withLocaleInUrl={false} />
|
||||
</Suspense>
|
||||
)}
|
||||
<ColorModeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container flex justify-center">
|
||||
<main
|
||||
className={cn(
|
||||
"w-full max-w-md rounded-3xl bg-card p-6 border lg:p-8",
|
||||
contentClass,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@ui/components/alert-dialog";
|
||||
import { Button } from "@ui/components/button";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
type PropsWithChildren,
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
type ConfirmOptions = {
|
||||
title: string;
|
||||
message?: string;
|
||||
cancelLabel?: string;
|
||||
confirmLabel?: string;
|
||||
destructive?: boolean;
|
||||
onConfirm: () => Promise<void> | void;
|
||||
};
|
||||
const ConfirmationAlertContext = createContext<{
|
||||
confirm: (options: ConfirmOptions) => void;
|
||||
}>({
|
||||
confirm: async () => false,
|
||||
});
|
||||
|
||||
export function ConfirmationAlertProvider({ children }: PropsWithChildren) {
|
||||
const t = useTranslations();
|
||||
const [confirmOptions, setConfirmOptions] = useState<ConfirmOptions | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const confirm = (options: ConfirmOptions) => {
|
||||
setConfirmOptions(options);
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmationAlertContext.Provider value={{ confirm }}>
|
||||
{children}
|
||||
|
||||
<AlertDialog
|
||||
open={!!confirmOptions}
|
||||
onOpenChange={(open) =>
|
||||
setConfirmOptions(open ? confirmOptions : null)
|
||||
}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{confirmOptions?.title}
|
||||
</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogDescription>
|
||||
{confirmOptions?.message}
|
||||
</AlertDialogDescription>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{confirmOptions?.cancelLabel ??
|
||||
t("common.confirmation.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<Button
|
||||
variant={
|
||||
confirmOptions?.destructive
|
||||
? "error"
|
||||
: "primary"
|
||||
}
|
||||
onClick={async () => {
|
||||
await confirmOptions?.onConfirm();
|
||||
setConfirmOptions(null);
|
||||
}}
|
||||
>
|
||||
{confirmOptions?.confirmLabel ??
|
||||
t("common.confirmation.confirm")}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</ConfirmationAlertContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useConfirmationAlert = () => {
|
||||
const context = useContext(ConfirmationAlertContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useConfirmationAlert must be used within a ConfirmationAlertProvider",
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
20
apps/web/modules/saas/shared/components/Footer.tsx
Normal file
20
apps/web/modules/saas/shared/components/Footer.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { LocaleLink } from "@i18n/routing";
|
||||
import { cn } from "@ui/lib";
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer
|
||||
className={cn(
|
||||
"container max-w-6xl py-6 text-center text-foreground/60 text-xs",
|
||||
)}
|
||||
>
|
||||
<span>
|
||||
<a href="https://supastarter.dev">Built with supastarter</a>
|
||||
</span>
|
||||
<span className="opacity-50"> | </span>
|
||||
<LocaleLink href="/legal/privacy-policy">Privacy policy</LocaleLink>
|
||||
<span className="opacity-50"> | </span>
|
||||
<LocaleLink href="/legal/terms">Terms and conditions</LocaleLink>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
189
apps/web/modules/saas/shared/components/NavBar.tsx
Normal file
189
apps/web/modules/saas/shared/components/NavBar.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
"use client";
|
||||
import { config } from "@repo/config";
|
||||
import { useSession } from "@saas/auth/hooks/use-session";
|
||||
import { useActiveOrganization } from "@saas/organizations/hooks/use-active-organization";
|
||||
import { UserMenu } from "@saas/shared/components/UserMenu";
|
||||
import { Logo } from "@shared/components/Logo";
|
||||
import { cn } from "@ui/lib";
|
||||
import {
|
||||
BotMessageSquareIcon,
|
||||
ChevronRightIcon,
|
||||
HomeIcon,
|
||||
SettingsIcon,
|
||||
UserCog2Icon,
|
||||
UserCogIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { OrganzationSelect } from "../../organizations/components/OrganizationSelect";
|
||||
|
||||
export function NavBar() {
|
||||
const t = useTranslations();
|
||||
const pathname = usePathname();
|
||||
const { user } = useSession();
|
||||
const { activeOrganization } = useActiveOrganization();
|
||||
|
||||
const { useSidebarLayout } = config.ui.saas;
|
||||
|
||||
const basePath = activeOrganization
|
||||
? `/app/${activeOrganization.slug}`
|
||||
: "/app";
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
label: t("app.menu.start"),
|
||||
href: basePath,
|
||||
icon: HomeIcon,
|
||||
isActive: pathname === basePath,
|
||||
},
|
||||
{
|
||||
label: t("app.menu.aiChatbot"),
|
||||
href: activeOrganization
|
||||
? `/app/${activeOrganization.slug}/chatbot`
|
||||
: "/app/chatbot",
|
||||
icon: BotMessageSquareIcon,
|
||||
isActive: pathname.includes("/chatbot"),
|
||||
},
|
||||
...(activeOrganization
|
||||
? [
|
||||
{
|
||||
label: t("app.menu.organizationSettings"),
|
||||
href: `${basePath}/settings`,
|
||||
icon: SettingsIcon,
|
||||
isActive: pathname.startsWith(`${basePath}/settings/`),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: t("app.menu.accountSettings"),
|
||||
href: "/app/settings",
|
||||
icon: UserCog2Icon,
|
||||
isActive: pathname.startsWith("/app/settings/"),
|
||||
},
|
||||
...(user?.role === "admin"
|
||||
? [
|
||||
{
|
||||
label: t("app.menu.admin"),
|
||||
href: "/app/admin",
|
||||
icon: UserCogIcon,
|
||||
isActive: pathname.startsWith("/app/admin/"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<nav
|
||||
className={cn("w-full", {
|
||||
"w-full md:fixed md:top-0 md:left-0 md:h-full md:w-[280px]":
|
||||
useSidebarLayout,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={cn("container max-w-6xl py-4", {
|
||||
"container max-w-6xl py-4 md:flex md:h-full md:flex-col md:px-6 md:pt-6 md:pb-0":
|
||||
useSidebarLayout,
|
||||
})}
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div
|
||||
className={cn("flex items-center gap-4 md:gap-2", {
|
||||
"md:flex md:w-full md:flex-col md:items-stretch md:align-stretch":
|
||||
useSidebarLayout,
|
||||
})}
|
||||
>
|
||||
<Link href="/app" className="block">
|
||||
<Logo />
|
||||
</Link>
|
||||
|
||||
{config.organizations.enable &&
|
||||
!config.organizations.hideOrganization && (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
"hidden opacity-30 md:block",
|
||||
{
|
||||
"md:hidden": useSidebarLayout,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</span>
|
||||
|
||||
<OrganzationSelect
|
||||
className={cn({
|
||||
"md:-mx-2 md:mt-2":
|
||||
useSidebarLayout,
|
||||
})}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"mr-0 ml-auto flex items-center justify-end gap-4",
|
||||
{
|
||||
"md:hidden": useSidebarLayout,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<UserMenu />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul
|
||||
className={cn(
|
||||
"no-scrollbar -mx-4 -mb-4 mt-6 flex list-none items-center justify-start gap-4 overflow-x-auto px-4 text-sm",
|
||||
{
|
||||
"md:mx-0 md:my-4 md:flex md:flex-col md:items-stretch md:gap-1 md:px-0":
|
||||
useSidebarLayout,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{menuItems.map((menuItem) => (
|
||||
<li key={menuItem.href}>
|
||||
<Link
|
||||
href={menuItem.href}
|
||||
className={cn(
|
||||
"flex items-center gap-2 whitespace-nowrap border-b-2 px-1 pb-3",
|
||||
[
|
||||
menuItem.isActive
|
||||
? "border-primary font-bold"
|
||||
: "border-transparent",
|
||||
],
|
||||
{
|
||||
"md:-mx-6 md:border-b-0 md:border-l-2 md:px-6 md:py-2":
|
||||
useSidebarLayout,
|
||||
},
|
||||
)}
|
||||
prefetch
|
||||
>
|
||||
<menuItem.icon
|
||||
className={`size-4 shrink-0 ${
|
||||
menuItem.isActive
|
||||
? "text-primary"
|
||||
: "opacity-50"
|
||||
}`}
|
||||
/>
|
||||
<span>{menuItem.label}</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"-mx-4 md:-mx-6 mt-auto mb-0 hidden p-4 md:p-4",
|
||||
{
|
||||
"md:block": useSidebarLayout,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<UserMenu showUserName />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
16
apps/web/modules/saas/shared/components/PageHeader.tsx
Normal file
16
apps/web/modules/saas/shared/components/PageHeader.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
"use client";
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
subtitle,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<h2 className="font-bold text-2xl lg:text-3xl">{title}</h2>
|
||||
<p className="mt-1 opacity-60">{subtitle}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
apps/web/modules/saas/shared/components/Pagination.tsx
Normal file
53
apps/web/modules/saas/shared/components/Pagination.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { Button } from "@ui/components/button";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
|
||||
export type PaginatioProps = {
|
||||
className?: string;
|
||||
totalItems: number;
|
||||
itemsPerPage: number;
|
||||
currentPage: number;
|
||||
onChangeCurrentPage: (page: number) => void;
|
||||
};
|
||||
|
||||
const Pagination = ({
|
||||
currentPage,
|
||||
totalItems,
|
||||
itemsPerPage,
|
||||
className,
|
||||
onChangeCurrentPage,
|
||||
}: PaginatioProps) => {
|
||||
const numberOfPages = Math.ceil(totalItems / itemsPerPage);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={currentPage === 1}
|
||||
onClick={() => onChangeCurrentPage(currentPage - 1)}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</Button>
|
||||
<span className="text-gray-500 text-sm">
|
||||
{currentPage * itemsPerPage - itemsPerPage + 1} -{" "}
|
||||
{currentPage * itemsPerPage > totalItems
|
||||
? totalItems
|
||||
: currentPage * itemsPerPage}{" "}
|
||||
of {totalItems}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={currentPage === numberOfPages}
|
||||
onClick={() => onChangeCurrentPage(currentPage + 1)}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Pagination.displayName = "Pagination";
|
||||
|
||||
export { Pagination };
|
||||
37
apps/web/modules/saas/shared/components/SettingsItem.tsx
Normal file
37
apps/web/modules/saas/shared/components/SettingsItem.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { Card } from "@ui/components/card";
|
||||
import { cn } from "@ui/lib";
|
||||
import type { PropsWithChildren, ReactNode } from "react";
|
||||
|
||||
export function SettingsItem({
|
||||
children,
|
||||
title,
|
||||
description,
|
||||
danger,
|
||||
}: PropsWithChildren<{
|
||||
title: string | ReactNode;
|
||||
description?: string | ReactNode;
|
||||
danger?: boolean;
|
||||
}>) {
|
||||
return (
|
||||
<Card className="@container p-4 rounded-2xl border md:p-6">
|
||||
<div className="grid @-xl:grid-cols-[min(100%/3,280px)_auto] grid-cols-1 @xl:gap-8 gap-4">
|
||||
<div className="flex shrink-0 flex-col gap-1.5">
|
||||
<h3
|
||||
className={cn(
|
||||
"m-0 font-semibold leading-tight",
|
||||
danger && "text-destructive",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
{description && (
|
||||
<p className="m-0 text-foreground/60 text-xs">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
13
apps/web/modules/saas/shared/components/SettingsList.tsx
Normal file
13
apps/web/modules/saas/shared/components/SettingsList.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import type { PropsWithChildren } from "react";
|
||||
|
||||
export function SettingsList({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<div className="@container flex flex-col gap-4">
|
||||
{Array.isArray(children)
|
||||
? children.filter(Boolean).map((child, i) => {
|
||||
return <div key={`settings-item-${i}`}>{child}</div>;
|
||||
})
|
||||
: children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import type { ReactNode } from "react";
|
||||
|
||||
export function SidebarContentLayout({
|
||||
children,
|
||||
sidebar,
|
||||
}: { children: React.ReactNode; sidebar: ReactNode }) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="flex flex-col items-start gap-4 lg:flex-row lg:gap-8">
|
||||
<div className="top-4 w-full lg:sticky lg:max-w-[180px]">
|
||||
{sidebar}
|
||||
</div>
|
||||
|
||||
<div className="w-full flex-1">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
apps/web/modules/saas/shared/components/TabGroup.tsx
Normal file
36
apps/web/modules/saas/shared/components/TabGroup.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useSelectedLayoutSegment } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export function TabGroup({
|
||||
items,
|
||||
className,
|
||||
}: {
|
||||
items: { label: string; href: string; segment: string }[];
|
||||
className?: string;
|
||||
}) {
|
||||
const selectedSegment = useSelectedLayoutSegment();
|
||||
const activeItem = useMemo(() => {
|
||||
return items.find((item) => item.segment === selectedSegment);
|
||||
}, [items, selectedSegment]);
|
||||
|
||||
return (
|
||||
<div className={` flex border-b-2 ${className}`}>
|
||||
{items.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`-mb-0.5 block border-b-2 px-6 py-3 ${
|
||||
item === activeItem
|
||||
? "border-primary font-bold"
|
||||
: "border-transparent"
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
176
apps/web/modules/saas/shared/components/UserMenu.tsx
Normal file
176
apps/web/modules/saas/shared/components/UserMenu.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
"use client";
|
||||
|
||||
import { DropdownMenuSub } from "@radix-ui/react-dropdown-menu";
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { config } from "@repo/config";
|
||||
import { useSession } from "@saas/auth/hooks/use-session";
|
||||
import { UserAvatar } from "@shared/components/UserAvatar";
|
||||
import { clearCache } from "@shared/lib/cache";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@ui/components/dropdown-menu";
|
||||
import {
|
||||
BookIcon,
|
||||
HardDriveIcon,
|
||||
HomeIcon,
|
||||
LogOutIcon,
|
||||
MoonIcon,
|
||||
MoreVerticalIcon,
|
||||
SettingsIcon,
|
||||
SunIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTheme } from "next-themes";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
export function UserMenu({ showUserName }: { showUserName?: boolean }) {
|
||||
const t = useTranslations();
|
||||
const { user } = useSession();
|
||||
const { setTheme: setCurrentTheme, theme: currentTheme } = useTheme();
|
||||
const [theme, setTheme] = useState<string>(currentTheme ?? "system");
|
||||
|
||||
const colorModeOptions = [
|
||||
{
|
||||
value: "system",
|
||||
label: "System",
|
||||
icon: HardDriveIcon,
|
||||
},
|
||||
{
|
||||
value: "light",
|
||||
label: "Light",
|
||||
icon: SunIcon,
|
||||
},
|
||||
{
|
||||
value: "dark",
|
||||
label: "Dark",
|
||||
icon: MoonIcon,
|
||||
},
|
||||
];
|
||||
|
||||
const onLogout = () => {
|
||||
authClient.signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: async () => {
|
||||
await clearCache();
|
||||
window.location.href = new URL(
|
||||
config.auth.redirectAfterLogout,
|
||||
window.location.origin,
|
||||
).toString();
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { name, email, image } = user;
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex cursor-pointer w-full items-center justify-between gap-2 rounded-lg outline-hidden focus-visible:ring-2 focus-visible:ring-primary md:w-[100%+1rem] md:px-2 md:py-1.5 md:hover:bg-primary/5"
|
||||
aria-label="User menu"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<UserAvatar name={name ?? ""} avatarUrl={image} />
|
||||
{showUserName && (
|
||||
<span className="text-left leading-tight">
|
||||
<span className="font-medium text-sm">
|
||||
{name}
|
||||
</span>
|
||||
<span className="block text-xs opacity-70">
|
||||
{email}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{showUserName && <MoreVerticalIcon className="size-4" />}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>
|
||||
{name}
|
||||
<span className="block font-normal text-xs opacity-70">
|
||||
{email}
|
||||
</span>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{/* Color mode selection */}
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<SunIcon className="mr-2 size-4" />
|
||||
{t("app.userMenu.colorMode")}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuRadioGroup
|
||||
value={theme}
|
||||
onValueChange={(value) => {
|
||||
setTheme(value);
|
||||
setCurrentTheme(value);
|
||||
}}
|
||||
>
|
||||
{colorModeOptions.map((option) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
>
|
||||
<option.icon className="mr-2 size-4 opacity-50" />
|
||||
{option.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/app/settings/general">
|
||||
<SettingsIcon className="mr-2 size-4" />
|
||||
{t("app.userMenu.accountSettings")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<a href="https://supastarter.dev/docs/nextjs">
|
||||
<BookIcon className="mr-2 size-4" />
|
||||
{t("app.userMenu.documentation")}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/">
|
||||
<HomeIcon className="mr-2 size-4" />
|
||||
{t("app.userMenu.home")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem onClick={onLogout}>
|
||||
<LogOutIcon className="mr-2 size-4" />
|
||||
{t("app.userMenu.logout")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
27
apps/web/modules/saas/shared/lib/api.ts
Normal file
27
apps/web/modules/saas/shared/lib/api.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { apiClient } from "@shared/lib/api-client";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import type { InferRequestType } from "hono";
|
||||
|
||||
export const signedUploadUrlMutationKey = ["signed-upload-url"];
|
||||
export const useSignedUploadUrlMutation = () => {
|
||||
return useMutation({
|
||||
mutationKey: signedUploadUrlMutationKey,
|
||||
mutationFn: async (
|
||||
query: InferRequestType<
|
||||
(typeof apiClient.uploads)["signed-upload-url"]["$post"]
|
||||
>["query"],
|
||||
) => {
|
||||
const response = await apiClient.uploads["signed-upload-url"].$post(
|
||||
{
|
||||
query,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to get signed upload url");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
};
|
||||
3
apps/web/modules/saas/shared/lib/state.ts
Normal file
3
apps/web/modules/saas/shared/lib/state.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { atom } from "jotai";
|
||||
|
||||
export const sidebarExpanded = atom<boolean>(false);
|
||||
23
apps/web/modules/saas/start/UserStart.tsx
Normal file
23
apps/web/modules/saas/start/UserStart.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
"use client";
|
||||
import { config } from "@repo/config";
|
||||
import { useSession } from "@saas/auth/hooks/use-session";
|
||||
import { OrganizationsGrid } from "@saas/organizations/components/OrganizationsGrid";
|
||||
import { Card } from "@ui/components/card";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function UserStart() {
|
||||
const t = useTranslations();
|
||||
const { user } = useSession();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{config.organizations.enable && <OrganizationsGrid />}
|
||||
|
||||
<Card className="mt-6">
|
||||
<div className="flex h-64 items-center justify-center p-8 text-foreground/60">
|
||||
Place your content here...
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
apps/web/modules/saas/start/components/StatsTile.tsx
Normal file
77
apps/web/modules/saas/start/components/StatsTile.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"use client";
|
||||
|
||||
import { useLocaleCurrency } from "@shared/hooks/locale-currency";
|
||||
import { Badge } from "@ui/components/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@ui/components/card";
|
||||
import { useFormatter } from "next-intl";
|
||||
import { useMemo } from "react";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
value: number;
|
||||
valueFormat: "currency" | "number" | "percentage";
|
||||
context?: string;
|
||||
icon?: React.ReactNode;
|
||||
trend?: number;
|
||||
};
|
||||
|
||||
export function StatsTile({
|
||||
title,
|
||||
value,
|
||||
context,
|
||||
trend,
|
||||
valueFormat,
|
||||
}: Props) {
|
||||
const format = useFormatter();
|
||||
const localeCurrency = useLocaleCurrency();
|
||||
|
||||
const formattedValue = useMemo(() => {
|
||||
// format currency
|
||||
if (valueFormat === "currency") {
|
||||
return format.number(value, {
|
||||
style: "currency",
|
||||
currency: localeCurrency,
|
||||
});
|
||||
}
|
||||
// format percentage
|
||||
if (valueFormat === "percentage") {
|
||||
return format.number(value, {
|
||||
style: "percent",
|
||||
});
|
||||
}
|
||||
// format default number
|
||||
return format.number(value);
|
||||
}, [value, valueFormat, format, localeCurrency]);
|
||||
|
||||
const formattedTrend = useMemo(() => {
|
||||
if (!trend) {
|
||||
return null;
|
||||
}
|
||||
return `${trend >= 0 ? "+" : ""}${format.number(trend, {
|
||||
style: "percent",
|
||||
})}`;
|
||||
}, [trend, format]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-foreground/60 text-sm">
|
||||
{title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<strong className="font-bold text-2xl">
|
||||
{formattedValue}
|
||||
{context && <small>{context}</small>}
|
||||
</strong>
|
||||
{trend && (
|
||||
<Badge status={trend > 0 ? "success" : "error"}>
|
||||
{formattedTrend}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue