first
This commit is contained in:
commit
8538ab969a
434 changed files with 44051 additions and 0 deletions
|
|
@ -0,0 +1,130 @@
|
|||
"use client";
|
||||
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { isOrganizationAdmin } from "@repo/auth/lib/helper";
|
||||
import { config } from "@repo/config";
|
||||
import { useSession } from "@saas/auth/hooks/use-session";
|
||||
import { sessionQueryKey } from "@saas/auth/lib/api";
|
||||
import {
|
||||
activeOrganizationQueryKey,
|
||||
useActiveOrganizationQuery,
|
||||
} from "@saas/organizations/lib/api";
|
||||
import { purchasesQueryKey } from "@saas/payments/lib/api";
|
||||
import { useRouter } from "@shared/hooks/router";
|
||||
import { apiClient } from "@shared/lib/api-client";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useParams } from "next/navigation";
|
||||
import nProgress from "nprogress";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { ActiveOrganizationContext } from "../lib/active-organization-context";
|
||||
|
||||
export function ActiveOrganizationProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { session, user } = useSession();
|
||||
const params = useParams();
|
||||
|
||||
const activeOrganizationSlug = params.organizationSlug as string;
|
||||
|
||||
const { data: activeOrganization } = useActiveOrganizationQuery(
|
||||
activeOrganizationSlug,
|
||||
{
|
||||
enabled: !!activeOrganizationSlug,
|
||||
},
|
||||
);
|
||||
|
||||
const refetchActiveOrganization = async () => {
|
||||
await queryClient.refetchQueries({
|
||||
queryKey: activeOrganizationQueryKey(activeOrganizationSlug),
|
||||
});
|
||||
};
|
||||
|
||||
const setActiveOrganization = async (organizationSlug: string | null) => {
|
||||
nProgress.start();
|
||||
const { data: newActiveOrganization } =
|
||||
await authClient.organization.setActive(
|
||||
organizationSlug
|
||||
? {
|
||||
organizationSlug,
|
||||
}
|
||||
: {
|
||||
organizationId: null,
|
||||
},
|
||||
);
|
||||
|
||||
if (!newActiveOrganization) {
|
||||
nProgress.done();
|
||||
return;
|
||||
}
|
||||
|
||||
await queryClient.setQueryData(
|
||||
activeOrganizationQueryKey(newActiveOrganization.slug),
|
||||
newActiveOrganization,
|
||||
);
|
||||
|
||||
if (config.organizations.enableBilling) {
|
||||
await queryClient.prefetchQuery({
|
||||
queryKey: purchasesQueryKey(newActiveOrganization.id),
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.payments.purchases.$get({
|
||||
query: {
|
||||
organizationId: newActiveOrganization.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch purchases");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await queryClient.setQueryData(sessionQueryKey, (data: any) => {
|
||||
return {
|
||||
...data,
|
||||
session: {
|
||||
...data?.session,
|
||||
activeOrganizationId: newActiveOrganization.id,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
router.push(`/app/${newActiveOrganization.slug}`);
|
||||
};
|
||||
|
||||
const [loaded, setLoaded] = useState(activeOrganization !== undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loaded && activeOrganization !== undefined) {
|
||||
setLoaded(true);
|
||||
}
|
||||
}, [activeOrganization]);
|
||||
|
||||
const activeOrganizationUserRole = activeOrganization?.members.find(
|
||||
(member) => member.userId === session?.userId,
|
||||
)?.role;
|
||||
|
||||
return (
|
||||
<ActiveOrganizationContext.Provider
|
||||
value={{
|
||||
loaded,
|
||||
activeOrganization: activeOrganization ?? null,
|
||||
activeOrganizationUserRole: activeOrganizationUserRole ?? null,
|
||||
isOrganizationAdmin:
|
||||
!!activeOrganization &&
|
||||
!!user &&
|
||||
isOrganizationAdmin(activeOrganization, user),
|
||||
setActiveOrganization,
|
||||
refetchActiveOrganization,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ActiveOrganizationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
"use client";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { useActiveOrganization } from "@saas/organizations/hooks/use-active-organization";
|
||||
import { organizationListQueryKey } from "@saas/organizations/lib/api";
|
||||
import { SettingsItem } from "@saas/shared/components/SettingsItem";
|
||||
import { useRouter } from "@shared/hooks/router";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@ui/components/button";
|
||||
import { Input } from "@ui/components/input";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(3),
|
||||
});
|
||||
|
||||
type FormSchema = z.infer<typeof formSchema>;
|
||||
|
||||
export function ChangeOrganizationNameForm() {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { activeOrganization } = useActiveOrganization();
|
||||
|
||||
const form = useForm<FormSchema>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: activeOrganization?.name ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit(async ({ name }) => {
|
||||
if (!activeOrganization) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { error } = await authClient.organization.update({
|
||||
organizationId: activeOrganization.id,
|
||||
data: {
|
||||
name,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
toast.success(
|
||||
t(
|
||||
"organizations.settings.notifications.organizationNameUpdated",
|
||||
),
|
||||
);
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: organizationListQueryKey,
|
||||
});
|
||||
router.refresh();
|
||||
} catch {
|
||||
toast.error(
|
||||
t(
|
||||
"organizations.settings.notifications.organizationNameNotUpdated",
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<SettingsItem title={t("organizations.settings.changeName.title")}>
|
||||
<form onSubmit={onSubmit}>
|
||||
<Input {...form.register("name")} />
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
!(
|
||||
form.formState.isValid &&
|
||||
form.formState.dirtyFields.name
|
||||
)
|
||||
}
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
{t("settings.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</SettingsItem>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useActiveOrganization } from "@saas/organizations/hooks/use-active-organization";
|
||||
import {
|
||||
organizationListQueryKey,
|
||||
useCreateOrganizationMutation,
|
||||
} from "@saas/organizations/lib/api";
|
||||
import { useRouter } from "@shared/hooks/router";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@ui/components/form";
|
||||
import { Input } from "@ui/components/input";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(3).max(32),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function CreateOrganizationForm({
|
||||
defaultName,
|
||||
}: {
|
||||
defaultName?: string;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { setActiveOrganization } = useActiveOrganization();
|
||||
const createOrganizationMutation = useCreateOrganizationMutation();
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: defaultName ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit(async ({ name }) => {
|
||||
try {
|
||||
const newOrganization =
|
||||
await createOrganizationMutation.mutateAsync({
|
||||
name,
|
||||
});
|
||||
|
||||
if (!newOrganization) {
|
||||
throw new Error("Failed to create organization");
|
||||
}
|
||||
|
||||
await setActiveOrganization(newOrganization.id);
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: organizationListQueryKey,
|
||||
});
|
||||
|
||||
router.replace(`/app/${newOrganization.slug}`);
|
||||
} catch (e) {
|
||||
toast.error(t("organizations.createForm.notifications.error"));
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-md">
|
||||
<h1 className="font-bold text-xl md:text-2xl">
|
||||
{t("organizations.createForm.title")}
|
||||
</h1>
|
||||
<p className="mt-2 mb-6 text-foreground/60">
|
||||
{t("organizations.createForm.subtitle")}
|
||||
</p>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={onSubmit}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("organizations.createForm.name")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} autoComplete="email" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="mt-6 w-full"
|
||||
type="submit"
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
{t("organizations.createForm.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
"use client";
|
||||
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { useActiveOrganization } from "@saas/organizations/hooks/use-active-organization";
|
||||
import { useOrganizationListQuery } from "@saas/organizations/lib/api";
|
||||
import { useConfirmationAlert } from "@saas/shared/components/ConfirmationAlertProvider";
|
||||
import { SettingsItem } from "@saas/shared/components/SettingsItem";
|
||||
import { useRouter } from "@shared/hooks/router";
|
||||
import { Button } from "@ui/components/button";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function DeleteOrganizationForm() {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const { confirm } = useConfirmationAlert();
|
||||
const { refetch: reloadOrganizations } = useOrganizationListQuery();
|
||||
const { activeOrganization, setActiveOrganization } =
|
||||
useActiveOrganization();
|
||||
|
||||
if (!activeOrganization) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
confirm({
|
||||
title: t("organizations.settings.deleteOrganization.title"),
|
||||
message: t(
|
||||
"organizations.settings.deleteOrganization.confirmation",
|
||||
),
|
||||
destructive: true,
|
||||
onConfirm: async () => {
|
||||
const { data, error } = await authClient.organization.delete({
|
||||
organizationId: activeOrganization.id,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error(
|
||||
t(
|
||||
"organizations.settings.notifications.organizationNotDeleted",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(
|
||||
t(
|
||||
"organizations.settings.notifications.organizationDeleted",
|
||||
),
|
||||
);
|
||||
await setActiveOrganization(null);
|
||||
await reloadOrganizations();
|
||||
router.replace("/app");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsItem
|
||||
danger
|
||||
title={t("organizations.settings.deleteOrganization.title")}
|
||||
description={t(
|
||||
"organizations.settings.deleteOrganization.description",
|
||||
)}
|
||||
>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button variant="error" onClick={handleDelete}>
|
||||
{t("organizations.settings.deleteOrganization.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsItem>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { OrganizationRoleSelect } from "@saas/organizations/components/OrganizationRoleSelect";
|
||||
import { fullOrganizationQueryKey } from "@saas/organizations/lib/api";
|
||||
import { SettingsItem } from "@saas/shared/components/SettingsItem";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@ui/components/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from "@ui/components/form";
|
||||
import { Input } from "@ui/components/input";
|
||||
import {} from "@ui/components/select";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { SubmitHandler } from "react-hook-form";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.string().email(),
|
||||
role: z.enum(["member", "owner", "admin"]),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function InviteMemberForm({
|
||||
organizationId,
|
||||
}: {
|
||||
organizationId: string;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
role: "member",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit: SubmitHandler<FormValues> = async (values) => {
|
||||
try {
|
||||
const { error } = await authClient.organization.inviteMember({
|
||||
...values,
|
||||
organizationId,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
form.reset();
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: fullOrganizationQueryKey(organizationId),
|
||||
});
|
||||
|
||||
toast.success(
|
||||
t(
|
||||
"organizations.settings.members.inviteMember.notifications.success.title",
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
toast.error(
|
||||
t(
|
||||
"organizations.settings.members.inviteMember.notifications.error.title",
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsItem
|
||||
title={t("organizations.settings.members.inviteMember.title")}
|
||||
description={t(
|
||||
"organizations.settings.members.inviteMember.description",
|
||||
)}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="@container"
|
||||
>
|
||||
<div className="flex @md:flex-row flex-col gap-2">
|
||||
<div className="flex-1">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"organizations.settings.members.inviteMember.email",
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="role"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"organizations.settings.members.inviteMember.role",
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<OrganizationRoleSelect
|
||||
value={field.value}
|
||||
onSelect={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
{t(
|
||||
"organizations.settings.members.inviteMember.submit",
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsItem>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { Alert, AlertDescription, AlertTitle } from "@ui/components/alert";
|
||||
import { MailCheckIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function OrganizationInvitationAlert({
|
||||
className,
|
||||
}: { className?: string }) {
|
||||
const t = useTranslations();
|
||||
return (
|
||||
<Alert variant="primary" className={className}>
|
||||
<MailCheckIcon />
|
||||
<AlertTitle>{t("organizations.invitationAlert.title")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("organizations.invitationAlert.description")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
"use client";
|
||||
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { OrganizationLogo } from "@saas/organizations/components/OrganizationLogo";
|
||||
import { organizationListQueryKey } from "@saas/organizations/lib/api";
|
||||
import { useRouter } from "@shared/hooks/router";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@ui/components/button";
|
||||
import { CheckIcon, XIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
|
||||
export function OrganizationInvitationModal({
|
||||
invitationId,
|
||||
organizationName,
|
||||
organizationSlug,
|
||||
logoUrl,
|
||||
}: {
|
||||
invitationId: string;
|
||||
organizationName: string;
|
||||
organizationSlug: string;
|
||||
logoUrl?: string;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [submitting, setSubmitting] = useState<false | "accept" | "reject">(
|
||||
false,
|
||||
);
|
||||
|
||||
const onSelectAnswer = async (accept: boolean) => {
|
||||
setSubmitting(accept ? "accept" : "reject");
|
||||
try {
|
||||
if (accept) {
|
||||
const { error } =
|
||||
await authClient.organization.acceptInvitation({
|
||||
invitationId,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: organizationListQueryKey,
|
||||
});
|
||||
|
||||
router.replace(`/app/${organizationSlug}`);
|
||||
} else {
|
||||
const { error } =
|
||||
await authClient.organization.rejectInvitation({
|
||||
invitationId,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
router.replace("/app");
|
||||
}
|
||||
} catch (error) {
|
||||
// TODO: handle error
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="font-bold text-xl md:text-2xl">
|
||||
{t("organizations.invitationModal.title")}
|
||||
</h1>
|
||||
<p className="mt-1 mb-6 text-foreground/60">
|
||||
{t("organizations.invitationModal.description", {
|
||||
organizationName,
|
||||
})}
|
||||
</p>
|
||||
|
||||
<div className="mb-6 flex items-center gap-3 rounded-lg border p-2">
|
||||
<OrganizationLogo
|
||||
name={organizationName}
|
||||
logoUrl={logoUrl}
|
||||
className="size-12"
|
||||
/>
|
||||
<div>
|
||||
<strong className="font-medium text-lg">
|
||||
{organizationName}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="flex-1"
|
||||
variant="light"
|
||||
onClick={() => onSelectAnswer(false)}
|
||||
disabled={!!submitting}
|
||||
loading={submitting === "reject"}
|
||||
>
|
||||
<XIcon className="mr-1.5 size-4" />
|
||||
{t("organizations.invitationModal.decline")}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={() => onSelectAnswer(true)}
|
||||
disabled={!!submitting}
|
||||
loading={submitting === "accept"}
|
||||
>
|
||||
<CheckIcon className="mr-1.5 size-4" />
|
||||
{t("organizations.invitationModal.accept")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,237 @@
|
|||
"use client";
|
||||
|
||||
import type { ActiveOrganization } from "@repo/auth";
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { isOrganizationAdmin } from "@repo/auth/lib/helper";
|
||||
import { useSession } from "@saas/auth/hooks/use-session";
|
||||
import {
|
||||
fullOrganizationQueryKey,
|
||||
useFullOrganizationQuery,
|
||||
} from "@saas/organizations/lib/api";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { Button } from "@ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@ui/components/dropdown-menu";
|
||||
import { Table, TableBody, TableCell, TableRow } from "@ui/components/table";
|
||||
import { cn } from "@ui/lib";
|
||||
import {
|
||||
CheckIcon,
|
||||
ClockIcon,
|
||||
MailXIcon,
|
||||
MoreVerticalIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useFormatter, useTranslations } from "next-intl";
|
||||
import { useMemo } from "react";
|
||||
import { OrganizationRoleSelect } from "./OrganizationRoleSelect";
|
||||
|
||||
import { toast } from "sonner";
|
||||
export function OrganizationInvitationsList({
|
||||
organizationId,
|
||||
}: {
|
||||
organizationId: string;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useSession();
|
||||
const formatter = useFormatter();
|
||||
const { data: organization } = useFullOrganizationQuery(organizationId);
|
||||
|
||||
const canUserEditInvitations = isOrganizationAdmin(organization, user);
|
||||
|
||||
const invitations = useMemo(
|
||||
() =>
|
||||
organization?.invitations
|
||||
?.filter((invitation) => invitation.status === "pending")
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.expiresAt).getTime() -
|
||||
new Date(b.expiresAt).getTime(),
|
||||
),
|
||||
[organization?.invitations],
|
||||
);
|
||||
|
||||
const revokeInvitation = (invitationId: string) => {
|
||||
toast.promise(
|
||||
async () => {
|
||||
const { error } =
|
||||
await authClient.organization.cancelInvitation({
|
||||
invitationId,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
loading: t(
|
||||
"organizations.settings.members.notifications.revokeInvitation.loading.description",
|
||||
),
|
||||
success: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: fullOrganizationQueryKey(organizationId),
|
||||
});
|
||||
return t(
|
||||
"organizations.settings.members.notifications.revokeInvitation.success.description",
|
||||
);
|
||||
},
|
||||
error: t(
|
||||
"organizations.settings.members.notifications.revokeInvitation.error.description",
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const columns: ColumnDef<
|
||||
NonNullable<ActiveOrganization["invitations"]>[number]
|
||||
>[] = [
|
||||
{
|
||||
accessorKey: "email",
|
||||
accessorFn: (row) => row.email,
|
||||
cell: ({ row }) => {
|
||||
const InvitationStatusIcon = {
|
||||
pending: ClockIcon,
|
||||
accepted: CheckIcon,
|
||||
rejected: XIcon,
|
||||
canceled: XIcon,
|
||||
}[row.original.status];
|
||||
return (
|
||||
<div className="leading-normal">
|
||||
<strong
|
||||
className={cn("block", {
|
||||
"opacity-50":
|
||||
row.original.status === "canceled",
|
||||
})}
|
||||
>
|
||||
{row.original.email}
|
||||
</strong>
|
||||
<small className="flex flex-wrap gap-1 text-foreground/60">
|
||||
<span className="flex items-center gap-0.5">
|
||||
<InvitationStatusIcon className="size-3" />
|
||||
{t(
|
||||
`organizations.settings.members.invitations.invitationStatus.${row.original.status}`,
|
||||
)}
|
||||
</span>
|
||||
<span>-</span>
|
||||
<span>
|
||||
{t(
|
||||
"organizations.settings.members.invitations.expiresAt",
|
||||
{
|
||||
date: formatter.dateTime(
|
||||
new Date(row.original.expiresAt),
|
||||
{
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
},
|
||||
),
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
</small>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "actions",
|
||||
cell: ({ row }) => {
|
||||
const isPending = row.original.status === "pending";
|
||||
const isAccepted = row.original.status === "accepted";
|
||||
|
||||
return (
|
||||
<div className="flex flex-row justify-end gap-2">
|
||||
<OrganizationRoleSelect
|
||||
value={row.original.role}
|
||||
disabled
|
||||
onSelect={() => {
|
||||
return;
|
||||
}}
|
||||
/>
|
||||
|
||||
{canUserEditInvitations && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon" variant="ghost">
|
||||
<MoreVerticalIcon className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
disabled={!isPending}
|
||||
onClick={() =>
|
||||
revokeInvitation(row.original.id)
|
||||
}
|
||||
>
|
||||
<MailXIcon className="mr-2 size-4" />
|
||||
{t(
|
||||
"organizations.settings.members.invitations.revoke",
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: invitations ?? [],
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{t(
|
||||
"organizations.settings.members.invitations.empty",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
"use client";
|
||||
|
||||
import { config } from "@repo/config";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar";
|
||||
import BoringAvatar from "boring-avatars";
|
||||
import { forwardRef, useMemo } from "react";
|
||||
import { useIsClient } from "usehooks-ts";
|
||||
|
||||
export const OrganizationLogo = forwardRef<
|
||||
HTMLSpanElement,
|
||||
{
|
||||
name: string;
|
||||
logoUrl?: string | null;
|
||||
className?: string;
|
||||
}
|
||||
>(({ name, logoUrl, className }, ref) => {
|
||||
const isClient = useIsClient();
|
||||
const avatarColors = useMemo(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const styles = getComputedStyle(window.document.documentElement);
|
||||
return [
|
||||
styles.getPropertyValue("--color-primary"),
|
||||
styles.getPropertyValue("--color-accent"),
|
||||
styles.getPropertyValue("--color-highlight"),
|
||||
];
|
||||
}, []);
|
||||
|
||||
const logoSrc = useMemo(
|
||||
() =>
|
||||
logoUrl
|
||||
? logoUrl.startsWith("http")
|
||||
? logoUrl
|
||||
: `/image-proxy/${config.storage.bucketNames.avatars}/${logoUrl}`
|
||||
: undefined,
|
||||
[logoUrl],
|
||||
);
|
||||
|
||||
if (!isClient) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Avatar ref={ref} className={className}>
|
||||
<AvatarImage src={logoSrc} />
|
||||
<AvatarFallback>
|
||||
<BoringAvatar
|
||||
size={96}
|
||||
name={name}
|
||||
variant="sunset"
|
||||
colors={avatarColors}
|
||||
square
|
||||
/>
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
});
|
||||
|
||||
OrganizationLogo.displayName = "OrganizationLogo";
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
"use client";
|
||||
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { config } from "@repo/config";
|
||||
import { useActiveOrganization } from "@saas/organizations/hooks/use-active-organization";
|
||||
import { organizationListQueryKey } from "@saas/organizations/lib/api";
|
||||
import { SettingsItem } from "@saas/shared/components/SettingsItem";
|
||||
import { useSignedUploadUrlMutation } from "@saas/shared/lib/api";
|
||||
import { Spinner } from "@shared/components/Spinner";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { toast } from "sonner";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import { CropImageDialog } from "../../settings/components/CropImageDialog";
|
||||
import { OrganizationLogo } from "./OrganizationLogo";
|
||||
|
||||
export function OrganizationLogoForm() {
|
||||
const t = useTranslations();
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [cropDialogOpen, setCropDialogOpen] = useState(false);
|
||||
const [image, setImage] = useState<File | null>(null);
|
||||
const { activeOrganization, refetchActiveOrganization } =
|
||||
useActiveOrganization();
|
||||
const queryClient = useQueryClient();
|
||||
const getSignedUploadUrlMutation = useSignedUploadUrlMutation();
|
||||
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
onDrop: (acceptedFiles) => {
|
||||
setImage(acceptedFiles[0]);
|
||||
setCropDialogOpen(true);
|
||||
},
|
||||
accept: {
|
||||
"image/png": [".png"],
|
||||
"image/jpeg": [".jpg", ".jpeg"],
|
||||
},
|
||||
multiple: false,
|
||||
});
|
||||
|
||||
if (!activeOrganization) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onCrop = async (croppedImageData: Blob | null) => {
|
||||
if (!croppedImageData) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const path = `${activeOrganization.id}-${uuid()}.png`;
|
||||
const { signedUrl } = await getSignedUploadUrlMutation.mutateAsync({
|
||||
path,
|
||||
bucket: config.storage.bucketNames.avatars,
|
||||
});
|
||||
|
||||
const response = await fetch(signedUrl, {
|
||||
method: "PUT",
|
||||
body: croppedImageData,
|
||||
headers: {
|
||||
"Content-Type": "image/png",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to upload image");
|
||||
}
|
||||
|
||||
const { error } = await authClient.organization.update({
|
||||
organizationId: activeOrganization.id,
|
||||
data: {
|
||||
logo: path,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
toast.success(t("settings.account.avatar.notifications.success"));
|
||||
|
||||
refetchActiveOrganization();
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: organizationListQueryKey,
|
||||
});
|
||||
} catch (e) {
|
||||
toast.error(t("settings.account.avatar.notifications.error"));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsItem
|
||||
title={t("organizations.settings.logo.title")}
|
||||
description={t("organizations.settings.logo.description")}
|
||||
>
|
||||
<div className="relative size-24 rounded-full" {...getRootProps()}>
|
||||
<input {...getInputProps()} />
|
||||
<OrganizationLogo
|
||||
className="size-24 cursor-pointer text-xl"
|
||||
logoUrl={activeOrganization.logo}
|
||||
name={activeOrganization.name ?? ""}
|
||||
/>
|
||||
|
||||
{uploading && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-card/90">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CropImageDialog
|
||||
image={image}
|
||||
open={cropDialogOpen}
|
||||
onOpenChange={setCropDialogOpen}
|
||||
onCrop={onCrop}
|
||||
/>
|
||||
</SettingsItem>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
"use client";
|
||||
import { SettingsItem } from "@saas/shared/components/SettingsItem";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@ui/components/tabs";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { OrganizationInvitationsList } from "./OrganizationInvitationsList";
|
||||
import { OrganizationMembersList } from "./OrganizationMembersList";
|
||||
|
||||
export function OrganizationMembersBlock({
|
||||
organizationId,
|
||||
}: {
|
||||
organizationId: string;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const [activeTab, setActiveTab] = useState("members");
|
||||
|
||||
return (
|
||||
<SettingsItem
|
||||
title={t("organizations.settings.members.title")}
|
||||
description={t("organizations.settings.members.description")}
|
||||
>
|
||||
<Tabs value={activeTab} onValueChange={(tab) => setActiveTab(tab)}>
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="members">
|
||||
{t("organizations.settings.members.activeMembers")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="invitations">
|
||||
{t("organizations.settings.members.pendingInvitations")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="members">
|
||||
<OrganizationMembersList organizationId={organizationId} />
|
||||
</TabsContent>
|
||||
<TabsContent value="invitations">
|
||||
<OrganizationInvitationsList
|
||||
organizationId={organizationId}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</SettingsItem>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,273 @@
|
|||
"use client";
|
||||
import type { OrganizationMemberRole } from "@repo/auth";
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { isOrganizationAdmin } from "@repo/auth/lib/helper";
|
||||
import { useSession } from "@saas/auth/hooks/use-session";
|
||||
import { useOrganizationMemberRoles } from "@saas/organizations/hooks/member-roles";
|
||||
import {
|
||||
fullOrganizationQueryKey,
|
||||
useFullOrganizationQuery,
|
||||
} from "@saas/organizations/lib/api";
|
||||
import { UserAvatar } from "@shared/components/UserAvatar";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
SortingState,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { Button } from "@ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@ui/components/dropdown-menu";
|
||||
import { Table, TableBody, TableCell, TableRow } from "@ui/components/table";
|
||||
import { LogOutIcon, MoreVerticalIcon, TrashIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { OrganizationRoleSelect } from "./OrganizationRoleSelect";
|
||||
|
||||
export function OrganizationMembersList({
|
||||
organizationId,
|
||||
}: {
|
||||
organizationId: string;
|
||||
}) {
|
||||
const t = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useSession();
|
||||
const { data: organization } = useFullOrganizationQuery(organizationId);
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const memberRoles = useOrganizationMemberRoles();
|
||||
|
||||
const userIsOrganizationAdmin = isOrganizationAdmin(organization, user);
|
||||
|
||||
const updateMemberRole = async (
|
||||
memberId: string,
|
||||
role: OrganizationMemberRole,
|
||||
) => {
|
||||
toast.promise(
|
||||
async () => {
|
||||
await authClient.organization.updateMemberRole({
|
||||
memberId,
|
||||
role,
|
||||
organizationId,
|
||||
});
|
||||
},
|
||||
{
|
||||
loading: t(
|
||||
"organizations.settings.members.notifications.updateMembership.loading.description",
|
||||
),
|
||||
success: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: fullOrganizationQueryKey(organizationId),
|
||||
});
|
||||
|
||||
return t(
|
||||
"organizations.settings.members.notifications.updateMembership.success.description",
|
||||
);
|
||||
},
|
||||
error: t(
|
||||
"organizations.settings.members.notifications.updateMembership.error.description",
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const removeMember = async (memberId: string) => {
|
||||
toast.promise(
|
||||
async () => {
|
||||
await authClient.organization.removeMember({
|
||||
memberIdOrEmail: memberId,
|
||||
organizationId,
|
||||
});
|
||||
},
|
||||
{
|
||||
loading: t(
|
||||
"organizations.settings.members.notifications.removeMember.loading.description",
|
||||
),
|
||||
success: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: fullOrganizationQueryKey(organizationId),
|
||||
});
|
||||
|
||||
return t(
|
||||
"organizations.settings.members.notifications.removeMember.success.description",
|
||||
);
|
||||
},
|
||||
error: t(
|
||||
"organizations.settings.members.notifications.removeMember.error.description",
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const columns: ColumnDef<
|
||||
NonNullable<typeof organization>["members"][number]
|
||||
>[] = [
|
||||
{
|
||||
accessorKey: "user",
|
||||
header: "",
|
||||
accessorFn: (row) => row.user,
|
||||
cell: ({ row }) =>
|
||||
row.original.user ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<UserAvatar
|
||||
name={
|
||||
row.original.user.name ??
|
||||
row.original.user.email
|
||||
}
|
||||
avatarUrl={row.original.user?.image}
|
||||
/>
|
||||
<div>
|
||||
<strong className="block">
|
||||
{row.original.user.name}
|
||||
</strong>
|
||||
<small className="text-foreground/60">
|
||||
{row.original.user.email}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
accessorKey: "actions",
|
||||
header: "",
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="flex flex-row justify-end gap-2">
|
||||
{userIsOrganizationAdmin ? (
|
||||
<>
|
||||
<OrganizationRoleSelect
|
||||
value={row.original.role}
|
||||
onSelect={async (value) =>
|
||||
updateMemberRole(row.original.id, value)
|
||||
}
|
||||
disabled={
|
||||
!userIsOrganizationAdmin ||
|
||||
row.original.role === "owner"
|
||||
}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="icon" variant="ghost">
|
||||
<MoreVerticalIcon className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{row.original.userId !== user?.id && (
|
||||
<DropdownMenuItem
|
||||
disabled={
|
||||
!isOrganizationAdmin(
|
||||
organization,
|
||||
user,
|
||||
)
|
||||
}
|
||||
className="text-destructive"
|
||||
onClick={async () =>
|
||||
removeMember(
|
||||
row.original.id,
|
||||
)
|
||||
}
|
||||
>
|
||||
<TrashIcon className="mr-2 size-4" />
|
||||
{t(
|
||||
"organizations.settings.members.removeMember",
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{row.original.userId === user?.id && (
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={async () =>
|
||||
removeMember(
|
||||
row.original.id,
|
||||
)
|
||||
}
|
||||
>
|
||||
<LogOutIcon className="mr-2 size-4" />
|
||||
{t(
|
||||
"organizations.settings.members.leaveOrganization",
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
) : (
|
||||
<span className="font-medium text-foreground/60 text-sm">
|
||||
{
|
||||
memberRoles[
|
||||
row.original
|
||||
.role as keyof typeof memberRoles
|
||||
]
|
||||
}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: organization?.members ?? [],
|
||||
columns,
|
||||
manualPagination: true,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import type { OrganizationMemberRole } from "@repo/auth";
|
||||
import { useOrganizationMemberRoles } from "@saas/organizations/hooks/member-roles";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@ui/components/select";
|
||||
|
||||
export function OrganizationRoleSelect({
|
||||
value,
|
||||
onSelect,
|
||||
disabled,
|
||||
}: {
|
||||
value: OrganizationMemberRole;
|
||||
onSelect: (value: OrganizationMemberRole) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const organizationMemberRoles = useOrganizationMemberRoles();
|
||||
|
||||
const roleOptions = Object.entries(organizationMemberRoles).map(
|
||||
([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<Select value={value} onValueChange={onSelect} disabled={disabled}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roleOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
"use client";
|
||||
import { config } from "@repo/config";
|
||||
import { useSession } from "@saas/auth/hooks/use-session";
|
||||
import { useActiveOrganization } from "@saas/organizations/hooks/use-active-organization";
|
||||
import { useOrganizationListQuery } from "@saas/organizations/lib/api";
|
||||
import { ActivePlanBadge } from "@saas/payments/components/ActivePlanBadge";
|
||||
import { UserAvatar } from "@shared/components/UserAvatar";
|
||||
import { useRouter } from "@shared/hooks/router";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@ui/components/dropdown-menu";
|
||||
import { ChevronsUpDownIcon, PlusIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import { OrganizationLogo } from "./OrganizationLogo";
|
||||
|
||||
export function OrganzationSelect({ className }: { className?: string }) {
|
||||
const t = useTranslations();
|
||||
const { user } = useSession();
|
||||
const router = useRouter();
|
||||
const { activeOrganization, setActiveOrganization } =
|
||||
useActiveOrganization();
|
||||
const { data: allOrganizations } = useOrganizationListQuery();
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex w-full items-center justify-between gap-2 rounded-md border p-2 text-left outline-none focus-visible:bg-primary/10 focus-visible:ring-none">
|
||||
<div className="flex flex-1 items-center justify-start gap-2 text-sm overflow-hidden">
|
||||
{activeOrganization ? (
|
||||
<>
|
||||
<OrganizationLogo
|
||||
name={activeOrganization.name}
|
||||
logoUrl={activeOrganization.logo}
|
||||
className="hidden size-6 sm:block"
|
||||
/>
|
||||
<span className="block flex-1 truncate">
|
||||
{activeOrganization.name}
|
||||
</span>
|
||||
{config.organizations.enableBilling && (
|
||||
<ActivePlanBadge
|
||||
organizationId={activeOrganization.id}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserAvatar
|
||||
className="hidden size-6 sm:block"
|
||||
name={user.name ?? ""}
|
||||
avatarUrl={user.image}
|
||||
/>
|
||||
<span className="block truncate">
|
||||
{t(
|
||||
"organizations.organizationSelect.personalAccount",
|
||||
)}
|
||||
</span>
|
||||
{config.users.enableBilling && (
|
||||
<ActivePlanBadge />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ChevronsUpDownIcon className="block size-4 opacity-50" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-full">
|
||||
{!config.organizations.requireOrganization && (
|
||||
<>
|
||||
<DropdownMenuRadioGroup
|
||||
value={activeOrganization?.id ?? user.id}
|
||||
onValueChange={(value: string) => {
|
||||
if (value === user.id) {
|
||||
router.replace("/app");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DropdownMenuLabel className="text-foreground/60 text-xs">
|
||||
{t(
|
||||
"organizations.organizationSelect.personalAccount",
|
||||
)}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuRadioItem
|
||||
value={user.id}
|
||||
className="flex cursor-pointer items-center justify-center gap-2 pl-3"
|
||||
>
|
||||
<div className="flex flex-1 items-center justify-start gap-2">
|
||||
<UserAvatar
|
||||
className="size-8"
|
||||
name={user.name ?? ""}
|
||||
avatarUrl={user.image}
|
||||
/>
|
||||
{user.name}
|
||||
</div>
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuRadioGroup
|
||||
value={activeOrganization?.slug}
|
||||
onValueChange={(organizationSlug: string) =>
|
||||
setActiveOrganization(organizationSlug)
|
||||
}
|
||||
>
|
||||
<DropdownMenuLabel className="text-foreground/60 text-xs">
|
||||
{t(
|
||||
"organizations.organizationSelect.organizations",
|
||||
)}
|
||||
</DropdownMenuLabel>
|
||||
{allOrganizations?.map((organization) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={organization.slug}
|
||||
value={organization.slug}
|
||||
className="flex cursor-pointer items-center justify-center gap-2 pl-3"
|
||||
>
|
||||
<div className="flex flex-1 items-center justify-start gap-2">
|
||||
<OrganizationLogo
|
||||
className="size-8"
|
||||
name={organization.name}
|
||||
logoUrl={organization.logo}
|
||||
/>
|
||||
{organization.name}
|
||||
</div>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
|
||||
{config.organizations.enableUsersToCreateOrganizations && (
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
asChild
|
||||
className="text-primary! cursor-pointer text-sm"
|
||||
>
|
||||
<Link href="/app/new-organization">
|
||||
<PlusIcon className="mr-2 size-6 rounded-md bg-primary/20 p-1" />
|
||||
{t(
|
||||
"organizations.organizationSelect.createNewOrganization",
|
||||
)}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
"use client";
|
||||
import { StatsTile } from "@saas/start/components/StatsTile";
|
||||
import { Card } from "@ui/components/card";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function OrganizationStart() {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<div className="@container">
|
||||
<div className="grid @2xl:grid-cols-3 gap-4">
|
||||
<StatsTile
|
||||
title="New clients"
|
||||
value={344}
|
||||
valueFormat="number"
|
||||
trend={0.12}
|
||||
/>
|
||||
<StatsTile
|
||||
title="Revenue"
|
||||
value={5243}
|
||||
valueFormat="currency"
|
||||
trend={0.6}
|
||||
/>
|
||||
<StatsTile
|
||||
title="Churn"
|
||||
value={0.03}
|
||||
valueFormat="percentage"
|
||||
trend={-0.3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card className="mt-6">
|
||||
<div className="flex h-64 items-center justify-center p-8 text-foreground/60">
|
||||
Place your content here...
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
"use client";
|
||||
|
||||
import { config } from "@repo/config";
|
||||
import { OrganizationLogo } from "@saas/organizations/components/OrganizationLogo";
|
||||
import { useActiveOrganization } from "@saas/organizations/hooks/use-active-organization";
|
||||
import { useOrganizationListQuery } from "@saas/organizations/lib/api";
|
||||
import { Card } from "@ui/components/card";
|
||||
import { ChevronRightIcon, PlusCircleIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
|
||||
export function OrganizationsGrid() {
|
||||
const t = useTranslations();
|
||||
const { setActiveOrganization } = useActiveOrganization();
|
||||
const { data: allOrganizations } = useOrganizationListQuery();
|
||||
|
||||
return (
|
||||
<div className="@container">
|
||||
<h2 className="mb-2 font-semibold text-lg">
|
||||
{t("organizations.organizationsGrid.title")}
|
||||
</h2>
|
||||
<div className="grid @2xl:grid-cols-3 @lg:grid-cols-2 grid-cols-1 gap-4">
|
||||
{allOrganizations?.map((organization) => (
|
||||
<Card
|
||||
key={organization.id}
|
||||
className="flex cursor-pointer items-center gap-4 overflow-hidden p-4"
|
||||
onClick={() => setActiveOrganization(organization.slug)}
|
||||
>
|
||||
<OrganizationLogo
|
||||
name={organization.name}
|
||||
logoUrl={organization.logo}
|
||||
className="size-12"
|
||||
/>
|
||||
<span className="flex items-center gap-1 text-base leading-tight">
|
||||
<span className="block font-medium">
|
||||
{organization.name}
|
||||
</span>
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</span>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{config.organizations.enableUsersToCreateOrganizations && (
|
||||
<Link
|
||||
href="/app/new-organization"
|
||||
className="flex h-full items-center justify-center gap-2 rounded-2xl bg-primary/5 p-4 text-primary transition-colors duration-150 hover:bg-primary/10"
|
||||
>
|
||||
<PlusCircleIcon />
|
||||
<span className="font-medium text-sm">
|
||||
{t(
|
||||
"organizations.organizationsGrid.createNewOrganization",
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
apps/web/modules/saas/organizations/hooks/member-roles.ts
Normal file
12
apps/web/modules/saas/organizations/hooks/member-roles.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import type { OrganizationMemberRole } from "@repo/auth";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function useOrganizationMemberRoles() {
|
||||
const t = useTranslations();
|
||||
|
||||
return {
|
||||
member: t("organizations.roles.member"),
|
||||
owner: t("organizations.roles.owner"),
|
||||
admin: t("organizations.roles.admin"),
|
||||
} satisfies Record<OrganizationMemberRole, string>;
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { useContext } from "react";
|
||||
import { ActiveOrganizationContext } from "../lib/active-organization-context";
|
||||
|
||||
export const useActiveOrganization = () => {
|
||||
const activeOrganizationContext = useContext(ActiveOrganizationContext);
|
||||
|
||||
type ActiveOrganizationContextType = NonNullable<
|
||||
typeof activeOrganizationContext
|
||||
>;
|
||||
|
||||
if (activeOrganizationContext === undefined) {
|
||||
return {
|
||||
activeOrganization: null,
|
||||
setActiveOrganization: () => Promise.resolve(),
|
||||
refetchActiveOrganization: () => Promise.resolve(),
|
||||
activeOrganizationUserRole: null,
|
||||
isOrganizationAdmin: false,
|
||||
loaded: true,
|
||||
} satisfies ActiveOrganizationContextType;
|
||||
}
|
||||
|
||||
return activeOrganizationContext;
|
||||
};
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import type { ActiveOrganization } from "@repo/auth";
|
||||
import React from "react";
|
||||
|
||||
export const ActiveOrganizationContext = React.createContext<
|
||||
| {
|
||||
activeOrganization: ActiveOrganization | null;
|
||||
activeOrganizationUserRole:
|
||||
| ActiveOrganization["members"][number]["role"]
|
||||
| null;
|
||||
isOrganizationAdmin: boolean;
|
||||
loaded: boolean;
|
||||
setActiveOrganization: (
|
||||
organizationId: string | null,
|
||||
) => Promise<void>;
|
||||
refetchActiveOrganization: () => Promise<void>;
|
||||
}
|
||||
| undefined
|
||||
>(undefined);
|
||||
159
apps/web/modules/saas/organizations/lib/api.ts
Normal file
159
apps/web/modules/saas/organizations/lib/api.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import type { OrganizationMetadata } from "@repo/auth";
|
||||
import { authClient } from "@repo/auth/client";
|
||||
import { apiClient } from "@shared/lib/api-client";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
|
||||
export const organizationListQueryKey = ["user", "organizations"] as const;
|
||||
export const useOrganizationListQuery = () => {
|
||||
return useQuery({
|
||||
queryKey: organizationListQueryKey,
|
||||
queryFn: async () => {
|
||||
const { data, error } = await authClient.organization.list();
|
||||
|
||||
if (error) {
|
||||
throw new Error(
|
||||
error.message || "Failed to fetch organizations",
|
||||
);
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const activeOrganizationQueryKey = (slug: string) =>
|
||||
["user", "activeOrganization", slug] as const;
|
||||
export const useActiveOrganizationQuery = (
|
||||
slug: string,
|
||||
options?: {
|
||||
enabled?: boolean;
|
||||
},
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: activeOrganizationQueryKey(slug),
|
||||
queryFn: async () => {
|
||||
const { data, error } =
|
||||
await authClient.organization.getFullOrganization({
|
||||
query: {
|
||||
organizationSlug: slug,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(
|
||||
error.message || "Failed to fetch active organization",
|
||||
);
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
enabled: options?.enabled,
|
||||
});
|
||||
};
|
||||
|
||||
export const fullOrganizationQueryKey = (id: string) =>
|
||||
["fullOrganization", id] as const;
|
||||
export const useFullOrganizationQuery = (id: string) => {
|
||||
return useQuery({
|
||||
queryKey: fullOrganizationQueryKey(id),
|
||||
queryFn: async () => {
|
||||
const { data, error } =
|
||||
await authClient.organization.getFullOrganization({
|
||||
query: {
|
||||
organizationId: id,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(
|
||||
error.message || "Failed to fetch full organization",
|
||||
);
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const generateOrganizationSlug = async (name: string) => {
|
||||
const response = await apiClient.organizations["generate-slug"].$get({
|
||||
query: {
|
||||
name,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to generate organization slug");
|
||||
}
|
||||
|
||||
const { slug } = await response.json();
|
||||
|
||||
return slug;
|
||||
};
|
||||
|
||||
/*
|
||||
* Create organization
|
||||
*/
|
||||
export const createOrganizationMutationKey = ["create-organization"] as const;
|
||||
export const useCreateOrganizationMutation = () => {
|
||||
return useMutation({
|
||||
mutationKey: createOrganizationMutationKey,
|
||||
mutationFn: async ({
|
||||
name,
|
||||
metadata,
|
||||
}: {
|
||||
name: string;
|
||||
metadata?: OrganizationMetadata;
|
||||
}) => {
|
||||
const { error, data } = await authClient.organization.create({
|
||||
name,
|
||||
slug: await generateOrganizationSlug(name),
|
||||
metadata,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/*
|
||||
* Update organization
|
||||
*/
|
||||
export const updateOrganizationMutationKey = ["update-organization"] as const;
|
||||
export const useUpdateOrganizationMutation = () => {
|
||||
return useMutation({
|
||||
mutationKey: updateOrganizationMutationKey,
|
||||
mutationFn: async ({
|
||||
id,
|
||||
name,
|
||||
metadata,
|
||||
updateSlug,
|
||||
}: {
|
||||
id: string;
|
||||
name: string;
|
||||
metadata?: OrganizationMetadata;
|
||||
updateSlug?: boolean;
|
||||
}) => {
|
||||
const { error, data } = await authClient.organization.update({
|
||||
organizationId: id,
|
||||
data: {
|
||||
name,
|
||||
slug: updateSlug
|
||||
? await generateOrganizationSlug(name)
|
||||
: undefined,
|
||||
metadata,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
});
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue