diff --git a/app/(customer)/dashboard/(organization)/settings/edit/page.tsx b/app/(customer)/dashboard/(organization)/settings/edit/page.tsx index f9878fa2..30a58d88 100644 --- a/app/(customer)/dashboard/(organization)/settings/edit/page.tsx +++ b/app/(customer)/dashboard/(organization)/settings/edit/page.tsx @@ -4,13 +4,12 @@ import {notFound} from "next/navigation"; import {OrganizationForm} from "@/components/wrappers/dashboard/organization/OrganizationForm/OrganizationForm"; import {getOrganization} from "@/lib/auth/auth"; - export default async function RoutePage(props: PageParams<{ }>) { const organization = await getOrganization({}); - if (!organization) { + if (!organization || organization.slug == "default") { notFound(); } diff --git a/app/(customer)/dashboard/layout.tsx b/app/(customer)/dashboard/layout.tsx index 28ada23e..159898ba 100644 --- a/app/(customer)/dashboard/layout.tsx +++ b/app/(customer)/dashboard/layout.tsx @@ -2,7 +2,7 @@ import React from "react"; import {redirect} from "next/navigation"; import {SidebarInset, SidebarProvider} from "@/components/ui/sidebar"; -import {AppSidebar} from "@/components/wrappers/dashboard/sideBar/app-sidebar"; +import {AppSidebar} from "@/components/wrappers/dashboard/common/sidebar/app-sidebar"; import {Header} from "@/features/layout/Header"; import {currentUser} from "@/lib/auth/current-user"; diff --git a/app/(landing)/page.tsx b/app/(landing)/page.tsx index 76ce4fa4..32b0ff1c 100644 --- a/app/(landing)/page.tsx +++ b/app/(landing)/page.tsx @@ -6,7 +6,7 @@ export default async function Index() { const user = await currentUser(); if (user) { const currentOrganizationSlug = await getCurrentOrganizationSlug(); - redirect(`/dashboard/${currentOrganizationSlug}/projects`); + redirect(`/dashboard/home`); } redirect("/login"); //Do not delete diff --git a/public/background.jpg b/public/background.jpg deleted file mode 100644 index adb016af..00000000 Binary files a/public/background.jpg and /dev/null differ diff --git a/public/PostgreSQL.png b/public/images/PostgreSQL.png similarity index 100% rename from public/PostgreSQL.png rename to public/images/PostgreSQL.png diff --git a/public/images/logo-black.png b/public/images/logo-black.png new file mode 100644 index 00000000..7a8d257c Binary files /dev/null and b/public/images/logo-black.png differ diff --git a/public/images/logo-white.png b/public/images/logo-white.png new file mode 100644 index 00000000..500fd48d Binary files /dev/null and b/public/images/logo-white.png differ diff --git a/public/logo.png b/public/images/logo.png similarity index 100% rename from public/logo.png rename to public/images/logo.png diff --git a/public/logo-inverse-title-white.png b/public/logo-inverse-title-white.png deleted file mode 100644 index ca5f8363..00000000 Binary files a/public/logo-inverse-title-white.png and /dev/null differ diff --git a/public/logo-title-black.png b/public/logo-title-black.png deleted file mode 100644 index a3b13a95..00000000 Binary files a/public/logo-title-black.png and /dev/null differ diff --git a/public/logo-title-white.png b/public/logo-title-white.png deleted file mode 100644 index c9c8388d..00000000 Binary files a/public/logo-title-white.png and /dev/null differ diff --git a/src/components/wrappers/dashboard/common/sidebar/app-sidebar.tsx b/src/components/wrappers/dashboard/common/sidebar/app-sidebar.tsx new file mode 100644 index 00000000..ccc1c39d --- /dev/null +++ b/src/components/wrappers/dashboard/common/sidebar/app-sidebar.tsx @@ -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 ( + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/components/wrappers/dashboard/common/sidebar/logo-sidebar.tsx b/src/components/wrappers/dashboard/common/sidebar/logo-sidebar.tsx new file mode 100644 index 00000000..0734b73e --- /dev/null +++ b/src/components/wrappers/dashboard/common/sidebar/logo-sidebar.tsx @@ -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 ( +
+ + {state === 'collapsed' && !isMobile ? ( + {`Logo + ) : ( +
+ {`Logo +
+ )} + +
+ ); +}; diff --git a/src/components/wrappers/dashboard/common/sidebar/menu-sidebar-main.tsx b/src/components/wrappers/dashboard/common/sidebar/menu-sidebar-main.tsx new file mode 100644 index 00000000..c2dc325d --- /dev/null +++ b/src/components/wrappers/dashboard/common/sidebar/menu-sidebar-main.tsx @@ -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 ; +}; diff --git a/src/components/wrappers/dashboard/common/sidebar/menu-sidebar.tsx b/src/components/wrappers/dashboard/common/sidebar/menu-sidebar.tsx new file mode 100644 index 00000000..140079dd --- /dev/null +++ b/src/components/wrappers/dashboard/common/sidebar/menu-sidebar.tsx @@ -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 ( + + {items.map((group, index) => ( + + + + + {group.label ?? "Missing label"} + {group.type === "collapse" && ( + + )} + + + + + {group.group_content.map((item, index) => ( + + + setActiveItem(item.url)} + > + + {item.title} + + + + {item.submenu && ( + + {item.submenu.map((sub, idx) => ( + + + setActiveItem(sub.url)} + > + + {sub.title} + + + + ))} + + )} + + {item.dropdown && ( + + + + + + + {item.dropdown.map((dropdown, idx) => ( + + setActiveItem(dropdown.url)} + > + {dropdown.title} + + + ))} + + + + )} + + ))} + + + + + ))} + + ); +}; diff --git a/src/components/wrappers/dashboard/common/sidebar/side-bar-footer-credit.tsx b/src/components/wrappers/dashboard/common/sidebar/side-bar-footer-credit.tsx new file mode 100644 index 00000000..67180f7e --- /dev/null +++ b/src/components/wrappers/dashboard/common/sidebar/side-bar-footer-credit.tsx @@ -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" && ( +
+

Portabase Community Edition v{env.NEXT_PUBLIC_PROJECT_VERSION}

+
+ )} + + ); +}; diff --git a/src/components/wrappers/dashboard/common/sidebar/side-bar-logo.tsx b/src/components/wrappers/dashboard/common/sidebar/side-bar-logo.tsx new file mode 100644 index 00000000..4ea2b312 --- /dev/null +++ b/src/components/wrappers/dashboard/common/sidebar/side-bar-logo.tsx @@ -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 ? ( + + +
+ Logo +
+ + ) : ( + + +
+ Logo +
+ + ); +}; diff --git a/src/components/wrappers/dashboard/organization/DeleteOrganization/DeleteOrganizationButton.tsx b/src/components/wrappers/dashboard/organization/DeleteOrganization/DeleteOrganizationButton.tsx index 1c3002a0..aa706fce 100644 --- a/src/components/wrappers/dashboard/organization/DeleteOrganization/DeleteOrganizationButton.tsx +++ b/src/components/wrappers/dashboard/organization/DeleteOrganization/DeleteOrganizationButton.tsx @@ -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 ( diff --git a/src/components/wrappers/dashboard/organization/OrganizationForm/OrganizationForm.tsx b/src/components/wrappers/dashboard/organization/OrganizationForm/OrganizationForm.tsx index 0d443abc..5b30c6b5 100644 --- a/src/components/wrappers/dashboard/organization/OrganizationForm/OrganizationForm.tsx +++ b/src/components/wrappers/dashboard/organization/OrganizationForm/OrganizationForm.tsx @@ -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 ( @@ -110,13 +113,13 @@ export const OrganizationForm = (props: organizationFormProps) => { name="users" render={({ field }) => ( - Databases + Users )} /> - ( - - Slug - - { - const value = e.target.value.replaceAll(" ", "-").toLowerCase(); - field.onChange(value); - }} - /> - - - - )} - /> + {/* (*/} + {/* */} + {/* Slug*/} + {/* */} + {/* {*/} + {/* const value = e.target.value.replaceAll(" ", "-").toLowerCase();*/} + {/* field.onChange(value);*/} + {/* }}*/} + {/* />*/} + {/* */} + {/* */} + {/* */} + {/* )}*/} + {/*/>*/}
diff --git a/src/components/wrappers/dashboard/organization/organization.action.ts b/src/components/wrappers/dashboard/organization/organization.action.ts index 5a9b587b..10aad8db 100644 --- a/src/components/wrappers/dashboard/organization/organization.action.ts +++ b/src/components/wrappers/dashboard/organization/organization.action.ts @@ -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> => { 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> => { - 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> => { + 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" }, - }, - }; } -}); +); diff --git a/src/components/wrappers/dashboard/organization/organization.schema.ts b/src/components/wrappers/dashboard/organization/organization.schema.ts index 328cdd52..6e2db975 100644 --- a/src/components/wrappers/dashboard/organization/organization.schema.ts +++ b/src/components/wrappers/dashboard/organization/organization.schema.ts @@ -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; diff --git a/src/components/wrappers/dashboard/sideBar/app-sidebar.tsx b/src/components/wrappers/dashboard/sideBar/app-sidebar.tsx index 938af7e8..455ced0b 100644 --- a/src/components/wrappers/dashboard/sideBar/app-sidebar.tsx +++ b/src/components/wrappers/dashboard/sideBar/app-sidebar.tsx @@ -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 ( diff --git a/src/db/schema/02_organization.ts b/src/db/schema/02_organization.ts index 0fdc3a9c..cc6ad586 100644 --- a/src/db/schema/02_organization.ts +++ b/src/db/schema/02_organization.ts @@ -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; +export type OrganizationWithMembers = Organization & { + members: OrganizationMember[]; +}; + +export type MemberWithUser = OrganizationMember & { + user: User; +}; + +export type OrganizationWithMembersAndUsers = Organization & { + members: MemberWithUser[]; + invitations: OrganizationInvitation[]; +}; \ No newline at end of file diff --git a/src/lib/auth/auth.ts b/src/lib/auth/auth.ts index 32f370d5..eadbc7aa 100644 --- a/src/lib/auth/auth.ts +++ b/src/lib/auth/auth.ts @@ -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 => { 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({ diff --git a/src/lib/auth/permissions.ts b/src/lib/auth/permissions.ts index be29bdb2..a8b5be7c 100644 --- a/src/lib/auth/permissions.ts +++ b/src/lib/auth/permissions.ts @@ -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 }; diff --git a/src/utils/slugify.ts b/src/utils/slugify.ts new file mode 100644 index 00000000..ac669d60 --- /dev/null +++ b/src/utils/slugify.ts @@ -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 +}; \ No newline at end of file