This commit is contained in:
 Илья Шалонин 2025-10-29 13:43:43 +05:00
commit 8538ab969a
434 changed files with 44051 additions and 0 deletions

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

View file

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

View file

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

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

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

View file

@ -0,0 +1,5 @@
import { joinRelativeURL } from "ufo";
export function getAdminPath(path: string) {
return joinRelativeURL("/app/admin", path);
}

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

View 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");
}
},
});
};

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

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

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

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

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

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

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

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

View 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;

View 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,
};
}

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

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

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

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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";

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

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

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

View file

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

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

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

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

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

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

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

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

View file

@ -0,0 +1,3 @@
import type { config } from "@repo/config";
export type PlanId = keyof typeof config.payments.plans;

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

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

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

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

View file

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

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

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

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

View file

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

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

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

View file

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

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

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

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

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

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

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

View file

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

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

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

View 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();
},
});
};

View file

@ -0,0 +1,3 @@
import { atom } from "jotai";
export const sidebarExpanded = atom<boolean>(false);

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

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