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