Working on the Organization side.

This commit is contained in:
charlesgauthereau
2025-07-26 14:43:59 +02:00
parent f7cd169090
commit f8f599e4dd
27 changed files with 685 additions and 119 deletions
@@ -0,0 +1,41 @@
import {
Sidebar,
SidebarContent,
SidebarHeader,
SidebarMenu,
SidebarMenuItem
} from "@/components/ui/sidebar";
import {SidebarLogo} from "@/components/wrappers/dashboard/common/sidebar/logo-sidebar";
import {SidebarMenuCustomMain} from "@/components/wrappers/dashboard/common/sidebar/menu-sidebar-main";
import {SideBarFooterCredit} from "@/components/wrappers/dashboard/common/sidebar/side-bar-footer-credit";
import {LoggedInButton} from "@/components/wrappers/dashboard/loggedInButton/LoggedInButton";
import {OrganizationCombobox} from "@/components/wrappers/dashboard/organization/organization-combobox";
export function AppSidebar() {
return (
<Sidebar collapsible="icon">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarLogo/>
</SidebarMenuItem>
<SidebarMenuItem>
<OrganizationCombobox/>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarMenuCustomMain/>
</SidebarContent>
<SidebarMenu>
<SidebarMenuItem>
<LoggedInButton />
</SidebarMenuItem>
</SidebarMenu>
<SideBarFooterCredit />
</Sidebar>
);
}
@@ -0,0 +1,51 @@
"use client"
import { useSidebar } from "@/components/ui/sidebar";
import Link from "next/link";
import { env } from "@/env.mjs";
import { useTheme } from "next-themes";
import Image from "next/image";
import { useEffect, useState } from "react";
export const SidebarLogo = () => {
const { state, isMobile } = useSidebar();
const { resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
const imageTheme = resolvedTheme === "dark" ? "/images/logo-white.png" : "/images/logo-black.png";
return (
<div className="ml-1 flex items-center justify-center">
<Link href={"/"}>
{state === 'collapsed' && !isMobile ? (
<Image
loading="eager"
src={"/images/logo.png"}
alt={`Logo ${env.NEXT_PUBLIC_PROJECT_NAME}`}
className="h-10 w-10 object-contain"
height={10}
width={10}
/>
) : (
<div className="m-4">
<Image
loading="eager"
src={imageTheme}
alt={`Logo ${env.NEXT_PUBLIC_PROJECT_NAME}`}
className="object-contain"
width={190}
height={90}
/>
</div>
)}
</Link>
</div>
);
};
@@ -0,0 +1,56 @@
"use client"
import {Home, Settings, Target, Users, Pickaxe, Euro, BanknoteX, Layers, ChartArea, ShieldHalf} from "lucide-react";
import {SidebarGroupItem, SidebarMenuCustomBase} from "@/components/wrappers/dashboard/common/sidebar/menu-sidebar";
import {authClient, useSession} from "@/lib/auth/auth-client";
export const SidebarMenuCustomMain = () => {
const BASE_URL = `/dashboard`;
const { data: activeOrganization } = authClient.useActiveOrganization();
const { data: organizations } = authClient.useListOrganizations();
const {data: session, isPending, error} = authClient.useSession();
const member = authClient.useActiveMember();
if (isPending) return null;
if (error || !session) {
return null;
}
const groupContent: SidebarGroupItem["group_content"] = [
{ title: "Dashboard", url: "/home", icon: Home },
{ title: "Projects", url: "/projects", icon: Layers, details:true },
{ title: "Statistics", url: "/statistics", icon: ChartArea },
];
if (activeOrganization && (member?.data?.role === "admin" || member?.data?.role === "owner")) {
groupContent.push({ title: "Settings", url: "/settings", icon: Settings, details:true });
}
const items: SidebarGroupItem[] = [
{
label: "Application",
type: "list",
group_content: groupContent,
},
];
if (session?.user.role == "admin" || session?.user.role == "superadmin") {
items.push({
label: "Administration",
type: "list",
group_content: [
{title: "Agents", url: "/agents", icon: ShieldHalf, details: true},
{title: "Administration panel", url: "/admin", icon: Settings, details: true},
]
})
}
return <SidebarMenuCustomBase baseUrl={BASE_URL} items={items}/>;
};
@@ -0,0 +1,208 @@
"use client";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem
} from "@/components/ui/sidebar";
import {
ChevronDown,
MoreHorizontal
} from "lucide-react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger
} from "@radix-ui/react-collapsible";
import React, { useEffect, useState } from "react";
import Link from "next/link";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu";
import { usePathname } from "next/navigation";
export type SidebarItem = {
title: string;
url: string;
redirect?: boolean;
not_from_base_url?: boolean;
icon: React.ElementType;
dropdown?: SidebarItem[];
submenu?: SidebarItem[];
details?: boolean;
};
export type SidebarGroupItem = {
label: string;
type: "list" | "collapse";
group_content: SidebarItem[];
};
type SidebarMenuCustomBaseProps = {
baseUrl: string;
items: SidebarGroupItem[];
};
export const SidebarMenuCustomBase = ({ baseUrl, items }: SidebarMenuCustomBaseProps) => {
const pathname = usePathname();
const [activeItem, setActiveItem] = useState("");
useEffect(() => {
const normalize = (url: string) => url.split("?")[0].replace(/\/$/, "");
const findActiveUrl = () => {
const normalizedPathname = normalize(pathname);
console.log(normalizedPathname);
for (const group of items) {
for (const item of group.group_content) {
const itemUrl = normalize(item.url);
const fullUrl = item.not_from_base_url
? itemUrl
: normalize(`${baseUrl}${itemUrl}`);
if (
normalizedPathname === fullUrl ||
(item.details && normalizedPathname.startsWith(`${fullUrl}/`))
) {
return itemUrl;
}
if (item.submenu) {
for (const subItem of item.submenu) {
const subItemUrl = normalize(subItem.url);
const subFullUrl = normalize(`${baseUrl}${subItemUrl}`);
if (
normalizedPathname === subFullUrl ||
(subItem.details && normalizedPathname.startsWith(`${subFullUrl}/`))
) {
return subItemUrl;
}
}
}
}
}
return "";
};
const activeUrl = findActiveUrl();
setActiveItem(activeUrl);
}, [pathname, baseUrl, items]);
return (
<SidebarMenu>
{items.map((group, index) => (
<Collapsible key={index} defaultOpen disabled={group.type === "list"}>
<SidebarGroup>
<SidebarGroupLabel asChild>
<CollapsibleTrigger>
{group.label ?? "Missing label"}
{group.type === "collapse" && (
<ChevronDown className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-180" />
)}
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent>
{group.group_content.map((item, index) => (
<SidebarMenuItem key={index} className="mb-1">
<SidebarMenuButton asChild>
<Link
href={
item.redirect || item.not_from_base_url
? item.url
: `${baseUrl}${item.url}`
}
target={item.redirect ? "_blank" : ""}
className={cn(
buttonVariants({
size: "lg",
variant: activeItem === item.url ? "secondary" : "ghost"
}),
"justify-start p-0",
"hover:bg-gray-700"
)}
onClick={() => setActiveItem(item.url)}
>
<item.icon />
<span>{item.title}</span>
</Link>
</SidebarMenuButton>
{item.submenu && (
<SidebarMenuSub>
{item.submenu.map((sub, idx) => (
<SidebarMenuSubItem key={idx}>
<SidebarMenuSubButton asChild>
<Link
href={`${baseUrl}${sub.url}`}
className={cn(
buttonVariants({
size: "lg",
variant: activeItem === sub.url ? "secondary" : "ghost"
}),
"justify-start p-0",
"hover:bg-gray-700"
)}
onClick={() => setActiveItem(sub.url)}
>
<sub.icon />
{sub.title}
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
)}
{item.dropdown && (
<SidebarMenuAction>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<MoreHorizontal />
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start">
{item.dropdown.map((dropdown, idx) => (
<DropdownMenuItem key={idx} asChild>
<Link
href={
dropdown.redirect || dropdown.not_from_base_url
? dropdown.url
: `${baseUrl}${dropdown.url}`
}
className="justify-start p-0"
target={dropdown.redirect ? "_blank" : ""}
onClick={() => setActiveItem(dropdown.url)}
>
<span>{dropdown.title}</span>
</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuAction>
)}
</SidebarMenuItem>
))}
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
))}
</SidebarMenu>
);
};
@@ -0,0 +1,19 @@
"use client";
import { useSidebar } from "@/components/ui/sidebar";
import { env } from "@/env.mjs";
export type SideBarFooterCreditProps = {};
export const SideBarFooterCredit = (props: SideBarFooterCreditProps) => {
const { state } = useSidebar();
return (
<>
{state === "expanded" && (
<div className="text-center">
<h1 className="text-[10px]">Portabase Community Edition v{env.NEXT_PUBLIC_PROJECT_VERSION}</h1>
</div>
)}
</>
);
};
@@ -0,0 +1,41 @@
"use client";
import {useSidebar} from "@/components/ui/sidebar";
import {useTheme} from "next-themes";
import Image from "next/image";
import Link from "next/link";
export type SideBarLogoProps = {};
export const SideBarLogo = (props: SideBarLogoProps) => {
const {state, isMobile} = useSidebar();
const {resolvedTheme} = useTheme();
return state === "collapsed" && !isMobile ? (
<Link href={"/dashboard"}>
<div className="flex items-center justify-center">
<Image
src={"/images/logo/picto.png"}
alt="Logo"
width={32}
height={32}
loading="eager"
className="object-contain"
/>
</div>
</Link>
) : (
<Link href={"/dashboard"}>
<div className="w-full h-auto p-4 flex items-center justify-center">
<Image
loading="eager"
src={"/images/logo/logo.png"}
alt="Logo"
width={190}
height={190}
className="object-contain"
/>
</div>
</Link>
);
};
@@ -5,6 +5,7 @@ import { useMutation } from "@tanstack/react-query";
import { setCurrentOrganizationSlug } from "@/features/dashboard/organization-cookie";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import {authClient} from "@/lib/auth/auth-client";
export type DeleteOrganizationButtonProps = {
organizationSlug: string;
@@ -14,15 +15,26 @@ export const DeleteOrganizationButton = (props: DeleteOrganizationButtonProps) =
const router = useRouter();
const mutation = useMutation({
mutationFn: () => deleteOrganizationAction(props.organizationSlug),
onSuccess: async (result) => {
if (result.data.success) {
await setCurrentOrganizationSlug("default");
if (result?.data?.success) {
await authClient.organization.setActive({
organizationSlug: "default",
});
router.push("/");
toast.success(result.data.actionSuccess.message);
toast.success(result.data.actionSuccess?.message || "Organization deleted.");
} else {
toast.error(result.data.actionError.message);
// @ts-ignore
const errorMsg = result?.data?.actionError?.message || result?.data?.actionError?.messageParams?.message || "Failed to delete the organization.";
toast.error(errorMsg);
}
},
onError: (error: any) => {
console.error("Mutation network error:", error);
toast.error(error?.message || "A network error occurred.");
},
});
return (
@@ -9,34 +9,35 @@ import { useMutation } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { MultiSelect } from "@/components/wrappers/common/multiselect/multi-select";
import { OrganizationFormSchema, OrganizationFormType } from "@/components/wrappers/dashboard/organization/OrganizationForm/organization-form.schema";
import { updateOrganizationAction } from "@/components/wrappers/dashboard/organization/organization.action";
import { toast } from "sonner";
import {Member, Organization} from "better-auth/plugins";
import {MemberWithUser, OrganizationWithMembers} from "@/db/schema/02_organization";
export type organizationFormProps = {
defaultValues?: Organization;
members: Member[];
defaultValues?: OrganizationWithMembers;
members: MemberWithUser[];
};
export const OrganizationForm = (props: organizationFormProps) => {
const router = useRouter();
const isCreate = !Boolean(props.defaultValues);
const formatUsersList = (members: Member[]) => {
const formatUsersList = (members: MemberWithUser[]) => {
return members.map((member) => ({
value: member.id,
value: member.user.id,
label: `${member.user.name} | ${member.user.email}`,
}));
};
const formatDefaultUsers = (members: OrganizationFormType["members"]): string[] => {
console.log(members);
const formatDefaultUsers = (members: MemberWithUser[]): string[] => {
console.log("ici",members);
return members.map((member) => member.userId);
};
const formattedDefaultValues = {
...props.defaultValues,
users: !isCreate ? formatDefaultUsers(props.defaultValues?.members) : [],
name: props.defaultValues?.name,
slug: props.defaultValues?.slug,
users: !isCreate ? formatDefaultUsers(props.defaultValues?.members as MemberWithUser[]) : [],
};
const form = useZodForm({
@@ -59,6 +60,8 @@ export const OrganizationForm = (props: organizationFormProps) => {
},
});
console.log(formatUsersList(props.members))
return (
<Card>
<CardHeader></CardHeader>
@@ -110,13 +113,13 @@ export const OrganizationForm = (props: organizationFormProps) => {
name="users"
render={({ field }) => (
<FormItem>
<FormLabel>Databases</FormLabel>
<FormLabel>Users</FormLabel>
<FormControl>
<MultiSelect
options={formatUsersList(props.members)}
onValueChange={field.onChange}
defaultValue={field.value ?? []}
placeholder="Select databases"
placeholder="Select users"
variant="inverted"
animation={2}
// maxCount={100}
@@ -11,6 +11,7 @@ import {createOrganizationAction} from "@/components/wrappers/dashboard/organiza
import {useRouter} from "next/navigation";
import {useState} from "react";
import {authClient} from "@/lib/auth/auth-client";
import {toast} from "sonner";
export type createOrganizationModalProps = {
open: boolean;
@@ -35,15 +36,19 @@ export function CreateOrganizationModal({open, onOpenChange, onSuccess}: createO
const result = await createOrganizationAction(values);
if (result && result.data) {
if (result.data.success && result.data.value) {
onOpenChange(false);
await authClient.organization.setActive({organizationSlug: result.data.value.slug});
onSuccess?.();
router.replace(`/dashboard/home`);
// router.refresh();
}
if (result?.data?.success && result.data.value) {
onOpenChange(false);
await authClient.organization.setActive({organizationSlug: result.data.value.slug});
onSuccess?.();
toast.success(result.data.actionSuccess?.message || "Organization Created.");
// router.push("/");
router.replace(`/dashboard/home`);
} else {
// @ts-ignore
const errorMsg = result?.data?.actionError?.message || result?.data?.actionError?.messageParams?.message || "Failed to create the organization.";
toast.error(errorMsg);
}
},
});
@@ -77,26 +82,26 @@ export function CreateOrganizationModal({open, onOpenChange, onSuccess}: createO
</FormItem>
)}
/>
<FormField
control={form.control}
name="slug"
defaultValue=""
render={({field}) => (
<FormItem>
<FormLabel>Slug</FormLabel>
<FormControl>
<Input
{...field}
onChange={(e) => {
const value = e.target.value.replaceAll(" ", "-").toLowerCase();
field.onChange(value);
}}
/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
{/*<FormField*/}
{/* control={form.control}*/}
{/* name="slug"*/}
{/* defaultValue=""*/}
{/* render={({field}) => (*/}
{/* <FormItem>*/}
{/* <FormLabel>Slug</FormLabel>*/}
{/* <FormControl>*/}
{/* <Input*/}
{/* {...field}*/}
{/* onChange={(e) => {*/}
{/* const value = e.target.value.replaceAll(" ", "-").toLowerCase();*/}
{/* field.onChange(value);*/}
{/* }}*/}
{/* />*/}
{/* </FormControl>*/}
{/* <FormMessage/>*/}
{/* </FormItem>*/}
{/* )}*/}
{/*/>*/}
<DialogFooter>
<div className="flex items-center justify-between w-full">
<Button type="submit">Create</Button>
@@ -7,13 +7,16 @@ import { z } from "zod";
import { v4 as uuidv4 } from "uuid";
import { OrganizationFormSchema } from "@/components/wrappers/dashboard/organization/OrganizationForm/organization-form.schema";
import { db } from "@/db";
import { Organization, organization as drizzleOrganization, organizationMember as drizzleOrganizationMember } from "@/db/schema";
import { eq } from "drizzle-orm";
import { checkSlugOrganization, createOrganization } from "@/lib/auth/auth";
import {checkSlugOrganization, createOrganization, deleteOrganization} from "@/lib/auth/auth";
import {slugify} from "@/utils/slugify";
import {Organization} from "@/db/schema/02_organization";
import * as drizzleDb from "@/db";
export const createOrganizationAction = userAction.schema(OrganizationSchema).action(async ({ parsedInput }): Promise<ServerActionResult<Organization>> => {
try {
if (!checkSlugOrganization(parsedInput.slug)) {
const slug = slugify(parsedInput.name);
if (!await checkSlugOrganization(slug)) {
return {
success: false,
actionError: {
@@ -24,14 +27,29 @@ export const createOrganizationAction = userAction.schema(OrganizationSchema).ac
};
}
const organization = await createOrganization(parsedInput.name, parsedInput.slug);
let createdOrganization: Organization;
try {
createdOrganization = await createOrganization(parsedInput.name, slug) as unknown as Organization;
} catch (authError: any) {
console.error("Auth deletion failed:", authError);
return {
success: false,
actionError: {
message: authError.message || "Authentication service error.",
status: authError.status || 500,
cause: "auth_error",
messageParams: { message: authError.message },
},
};
}
return {
success: true,
value: organization!,
value: createdOrganization,
actionSuccess: {
message: "Organization has been successfully created.",
messageParams: { organizationId: organization!.id },
messageParams: { organizationId: createdOrganization!.id },
},
};
} catch (error) {
@@ -58,7 +76,7 @@ export const updateOrganizationAction = userAction
try {
const newUserList = parsedInput.data.users;
const organization = await db.select().from(organization).where(eq(organization.id, parsedInput.organizationId)).execute();
const organization = await db.select().from(drizzleDb.schemas.organization).where(eq(drizzleDb.schemas.organization.id, parsedInput.organizationId)).execute();
if (organization.length === 0) {
throw new Error("Organization not found.");
@@ -70,7 +88,7 @@ export const updateOrganizationAction = userAction
if (usersToAdd.length > 0) {
await db
.insert(userOrganization)
.insert(drizzleDb.schemas.organization)
.values(
usersToAdd.map((userId) => ({
userId,
@@ -117,44 +135,60 @@ export const updateOrganizationAction = userAction
}
});
export const deleteOrganizationAction = userAction.schema(z.string()).action(async ({ parsedInput, ctx }): Promise<ServerActionResult<Organization>> => {
try {
const uuid = uuidv4();
const organization = await db.select().from(organization).where(eq(organization.slug, parsedInput)).execute();
export const deleteOrganizationAction = userAction.schema(z.string()).action(
async ({ parsedInput, ctx }): Promise<ServerActionResult<Organization>> => {
try {
const org = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.slug, parsedInput),
});
if (organization.length === 0) {
throw new Error("Organization not found.");
if (!org) {
return {
success: false,
actionError: {
message: "Organization not found.",
status: 404,
cause: "not_found",
},
};
}
let deletedOrganization: Organization;
try {
deletedOrganization = await deleteOrganization(org.id) as Organization;
} catch (authError: any) {
console.error("Auth deletion failed:", authError);
return {
success: false,
actionError: {
message: authError.message || "Authentication service error.",
status: authError.status || 500,
cause: "auth_error",
messageParams: { message: authError.message },
},
};
}
return {
success: true,
value: deletedOrganization,
actionSuccess: {
message: "Organization has been successfully deleted.",
messageParams: { organizationId: deletedOrganization.id },
},
};
} catch (error) {
console.error("Unexpected error in deleteOrganizationAction:", error);
return {
success: false,
actionError: {
message: "Failed to delete organization due to a server error.",
status: 500,
cause: "server_error",
messageParams: { message: "Internal server error while deleting the organization" },
},
};
}
const updatedOrganization = await db
.update(organization)
.set({
name: `${organization[0].name}-${uuid}`,
slug: `${organization[0].slug}-${uuid}`,
deleted: true,
})
.where(eq(organization.id, organization[0].id))
.returning()
.execute();
return {
success: true,
value: updatedOrganization[0],
actionSuccess: {
message: "Organization has been successfully deleted.",
messageParams: { organizationId: updatedOrganization[0].id },
},
};
} catch (error) {
console.error("Error deleting organization:", error);
return {
success: false,
actionError: {
message: "Failed to delete organization.",
status: 500,
cause: error.message ?? "Unknown error",
messageParams: { message: "Error deleting the organization" },
},
};
}
});
);
@@ -2,11 +2,11 @@ import { z } from "zod";
export const OrganizationSchema = z.object({
name: z.string().min(5, "Name must be at least 5 characters long").max(40, "Name must be at most 40 characters long"),
slug: z
.string()
.regex(/^[a-zA-Z0-9_-]*$/, "Slug can only contain letters, numbers, underscores, and hyphens")
.min(5, "Slug must be at least 5 characters long")
.max(20, "Slug must be at most 20 characters long"),
// slug: z
// .string()
// .regex(/^[a-zA-Z0-9_-]*$/, "Slug can only contain letters, numbers, underscores, and hyphens")
// .min(5, "Slug must be at least 5 characters long")
// .max(20, "Slug must be at most 20 characters long"),
});
export type OrganizationSchema = z.infer<typeof OrganizationSchema>;
@@ -23,9 +23,9 @@ export async function AppSidebar() {
return notFound();
}
const organization = await getOrganization({organizationId: member.organizationId});
// const organization = await getOrganization({organizationId: member.organizationId});
console.log("organization", organization);
// console.log("organization", organization);
return (
+15 -2
View File
@@ -3,8 +3,9 @@ import { relations } from "drizzle-orm";
import { project } from "./05_project";
import { createSelectSchema } from "drizzle-zod";
import { z } from "zod";
import {invitation} from "@/db/schema/04_invitation";
import {member} from "@/db/schema/03_member";
import {invitation, OrganizationInvitation} from "@/db/schema/04_invitation";
import {member, OrganizationMember} from "@/db/schema/03_member";
import {User} from "@/db/schema/01_user";
export const organization = pgTable("organization", {
id: uuid("id").defaultRandom().primaryKey(),
@@ -28,3 +29,15 @@ export const organizationRelations = relations(organization, ({ many }) => ({
export const organizationSchema = createSelectSchema(organization);
export type Organization = z.infer<typeof organizationSchema>;
export type OrganizationWithMembers = Organization & {
members: OrganizationMember[];
};
export type MemberWithUser = OrganizationMember & {
user: User;
};
export type OrganizationWithMembersAndUsers = Organization & {
members: MemberWithUser[];
invitations: OrganizationInvitation[];
};
+82 -14
View File
@@ -1,13 +1,14 @@
import {betterAuth} from "better-auth";
import {drizzleAdapter} from "better-auth/adapters/drizzle";
import * as drizzleDb from "@/db";
import {db} from "@/db";
import {env} from "@/env.mjs";
import {nextCookies} from "better-auth/next-js";
import {admin as adminPlugin, openAPI, organization} from "better-auth/plugins";
import {ac, admin, orgAdmin, orgMember, orgOwner, pending, superadmin, user} from "@/lib/auth/permissions";
import * as drizzleDb from "@/db";
import {headers} from "next/headers";
import {count, eq} from "drizzle-orm";
import {OrganizationWithMembers, OrganizationWithMembersAndUsers} from "@/db/schema/02_organization";
export const auth = betterAuth({
database: drizzleAdapter(db, {
@@ -40,9 +41,9 @@ export const auth = betterAuth({
organization({
ac,
roles: {
orgOwner,
orgAdmin,
orgMember,
owner: orgOwner,
admin: orgAdmin,
member: orgMember,
},
}),
adminPlugin({
@@ -98,7 +99,7 @@ export const auth = betterAuth({
await db.insert(drizzleDb.schemas.member).values({
userId: user.id,
organizationId: defaultOrg.id,
role: "owner",
role: "orgOwner",
});
} else {
console.warn("Default organization not found. Cannot assign member.");
@@ -277,13 +278,39 @@ export const unlinkAccount = async (provider: string, account: string) => {
// return null;
// }
// };
// export const getOrganization = async ({
// organizationId,
// organizationSlug,
// }: {
// organizationId?: string;
// organizationSlug?: string;
// } = {}) => {
// const query =
// organizationId != null
// ? {organizationId}
// : organizationSlug != null
// ? {organizationSlug}
// : undefined;
//
// console.log(query);
//
// try {
// return await auth.api.getFullOrganization({
// headers: await headers(),
// ...(query ? {query} : {}),
// });
// } catch (e) {
// console.error(e);
// return null;
// }
// };
export const getOrganization = async ({
organizationId,
organizationSlug,
}: {
organizationId?: string;
organizationSlug?: string;
} = {}) => {
} = {}): Promise<OrganizationWithMembersAndUsers | null> => {
const query =
organizationId != null
? { organizationId }
@@ -291,13 +318,13 @@ export const getOrganization = async ({
? { organizationSlug }
: undefined;
console.log(query);
try {
return await auth.api.getFullOrganization({
const response = await auth.api.getFullOrganization({
headers: await headers(),
...(query ? { query } : {}),
});
return response as OrganizationWithMembersAndUsers;
} catch (e) {
console.error(e);
return null;
@@ -328,22 +355,63 @@ export const getLastOrganizationOrFirst = async (userId: string) => {
return null;
}
};
export const createOrganization = async (name: string, slug: string) => {
try {
const organization = await auth.api.createOrganization({
headers: await headers(),
return await auth.api.createOrganization({
headers: await headers(),
body: {
name,
slug,
},
});
} catch (e: any) {
const errorMessage = e?.response?.data?.message || e?.message || "Unknown auth error";
const status = e?.response?.status || 500;
return organization;
} catch (e) {
console.log("err", e);
console.error("Auth API createOrganization error:", {
message: errorMessage,
status,
raw: e,
});
throw {
name: "AuthCreateOrganizationError",
message: errorMessage,
status,
cause: e,
};
}
};
export const deleteOrganization = async (organizationId: string) => {
try {
return await auth.api.deleteOrganization({
body: {
organizationId,
},
headers: await headers(),
});
} catch (e: any) {
const errorMessage = e?.response?.data?.message || e?.message || "Unknown auth error";
const status = e?.response?.status || 500;
console.error("Auth API deleteOrganization error:", {
message: errorMessage,
status,
raw: e,
});
throw {
name: "AuthDeleteOrganizationError",
message: errorMessage,
status,
cause: e,
};
}
};
export const checkSlugOrganization = async (slug: string) => {
try {
const {status} = await auth.api.checkOrganizationSlug({
+3 -1
View File
@@ -1,6 +1,6 @@
import { createAccessControl } from "better-auth/plugins/access";
import { defaultStatements, adminAc } from "better-auth/plugins/admin/access";
import { defaultStatements as orgDefaultStatements, adminAc as orgAdminAc } from "better-auth/plugins/organization/access";
import { defaultStatements as orgDefaultStatements, adminAc as orgAdminAc, ownerAc as orgOwnerAc } from "better-auth/plugins/organization/access";
const statement = {
...defaultStatements,
@@ -18,6 +18,7 @@ const superadmin = ac.newRole({
agent: ["create", "list", "update", "delete"],
...adminAc.statements,
...orgAdminAc.statements,
...orgOwnerAc.statements,
});
const admin = ac.newRole({
@@ -54,6 +55,7 @@ const orgAdmin = ac.newRole({
const orgOwner = ac.newRole({
project: ["create", "update", "delete"],
...orgAdminAc.statements,
...orgOwnerAc.statements,
});
export { ac, admin, superadmin, user, pending, orgAdmin, orgMember, orgOwner };
+14
View File
@@ -0,0 +1,14 @@
export const slugify = (text: string) => {
return text
.toString()
.normalize('NFKD') // Normalize accents
.replace(/[\u0300-\u036f]/g, '') // Remove diacritics
.toLowerCase()
.trim()
.replace(/\_/g, '-') // _ → dash
.replace(/\s+/g, '-') // spaces → dash
.replace(/[^\w\-]+/g, '') // Remove non-word chars
.replace(/\-\-+/g, '-') // multiple dashes → one
.replace(/^-+/, '') // Remove leading dash
.replace(/-+$/, ''); // Remove trailing dash
};