mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Refactoring.
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { useState, useRef, useEffect, forwardRef } from "react"
|
||||
import { Search, X } from "lucide-react"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface IEntry {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface SearchInputProps {
|
||||
value?: IEntry
|
||||
onChange?: (value: IEntry) => void
|
||||
onSelect?: (value: IEntry) => void
|
||||
name?: string
|
||||
placeholder?: string
|
||||
entries?: IEntry[]
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
|
||||
(
|
||||
{
|
||||
value: controlledValue,
|
||||
onChange,
|
||||
onSelect,
|
||||
name,
|
||||
placeholder = "Search entries...",
|
||||
entries = [],
|
||||
disabled = false,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [internalValue, setInternalValue] = useState<IEntry | null>(null)
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [filteredEntries, setFilteredEntries] = useState<IEntry[]>([])
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||
const internalRef = useRef<HTMLInputElement>(null)
|
||||
const listRef = useRef<HTMLUListElement>(null)
|
||||
|
||||
const query = controlledValue?.label ?? internalValue?.label ?? ""
|
||||
const inputRef = (ref as React.RefObject<HTMLInputElement>) || internalRef
|
||||
|
||||
// Filter entries based on query
|
||||
useEffect(() => {
|
||||
if (query.trim()) {
|
||||
const filtered = entries.filter((entry) =>
|
||||
entry.label.toLowerCase().includes(query.toLowerCase()),
|
||||
)
|
||||
setFilteredEntries(filtered)
|
||||
setSelectedIndex(-1)
|
||||
} else {
|
||||
setFilteredEntries([])
|
||||
}
|
||||
}, [query, entries])
|
||||
|
||||
const handleValueChange = (newValue: IEntry | null) => {
|
||||
if (controlledValue === undefined) {
|
||||
setInternalValue(newValue)
|
||||
}
|
||||
if (newValue) {
|
||||
onChange?.(newValue)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (!isOpen || filteredEntries.length === 0) return
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) =>
|
||||
prev < filteredEntries.length - 1 ? prev + 1 : 0,
|
||||
)
|
||||
break
|
||||
case "ArrowUp":
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : filteredEntries.length - 1,
|
||||
)
|
||||
break
|
||||
case "Enter":
|
||||
e.preventDefault()
|
||||
if (selectedIndex >= 0) {
|
||||
handleSelect(filteredEntries[selectedIndex])
|
||||
}
|
||||
break
|
||||
case "Escape":
|
||||
setIsOpen(false)
|
||||
setSelectedIndex(-1)
|
||||
inputRef.current?.blur()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelect = (entry: IEntry) => {
|
||||
handleValueChange(entry)
|
||||
onSelect?.(entry)
|
||||
setIsOpen(false)
|
||||
setSelectedIndex(-1)
|
||||
inputRef.current?.blur()
|
||||
}
|
||||
|
||||
const clearSearch = () => {
|
||||
handleValueChange(null)
|
||||
setIsOpen(false)
|
||||
setSelectedIndex(-1)
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("relative w-full", className)}>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
{...props}
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
name={name}
|
||||
placeholder={placeholder}
|
||||
value={query}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
const newLabel = e.target.value
|
||||
handleValueChange({ value: newLabel, label: newLabel })
|
||||
}}
|
||||
onFocus={() => !disabled && setIsOpen(true)}
|
||||
onBlur={() => {
|
||||
// Delay closing to allow clicking on entries
|
||||
setTimeout(() => setIsOpen(false), 150)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="pl-10 pr-10"
|
||||
/>
|
||||
{query && !disabled && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearSearch}
|
||||
className="absolute right-1 top-1/2 h-7 w-7 -translate-y-1/2 p-0 hover:bg-muted"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results dropdown */}
|
||||
{isOpen && !disabled && filteredEntries.length > 0 && (
|
||||
<div className="absolute top-full z-50 w-full mt-1 bg-popover border rounded-md shadow-md">
|
||||
<ul ref={listRef} className="max-h-60 overflow-auto py-1" role="listbox">
|
||||
{filteredEntries.map((entry, index) => (
|
||||
<li
|
||||
key={entry.value}
|
||||
role="option"
|
||||
aria-selected={index === selectedIndex}
|
||||
className={cn(
|
||||
"px-3 py-2 text-sm cursor-pointer transition-colors",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
index === selectedIndex && "bg-accent text-accent-foreground",
|
||||
)}
|
||||
onClick={() => handleSelect(entry)}
|
||||
>
|
||||
{entry.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No results message */}
|
||||
{isOpen && !disabled && query && filteredEntries.length === 0 && (
|
||||
<div className="absolute top-full z-50 w-full mt-1 bg-popover border rounded-md shadow-md">
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||
No results found for "{query}"
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
SearchInput.displayName = "SearchInput"
|
||||
@@ -1,8 +1,66 @@
|
||||
"use client";
|
||||
// "use client";
|
||||
//
|
||||
// import { Button } from "@/components/ui/button";
|
||||
// import { ButtonHTMLAttributes } from "react";
|
||||
// import { Loader2 } from "lucide-react";
|
||||
//
|
||||
// export type VariantButton = {
|
||||
// secondary: string;
|
||||
// default: string;
|
||||
// outline: string;
|
||||
// ghost: string;
|
||||
// link: string;
|
||||
// destructive: string;
|
||||
// };
|
||||
// export type sizeButton = {
|
||||
// default: string;
|
||||
// icon: string;
|
||||
// sm: string;
|
||||
// lg: string;
|
||||
// };
|
||||
//
|
||||
// export type ButtonWithConfirmProps = {
|
||||
// icon?: any;
|
||||
// text: string;
|
||||
// variant?: keyof VariantButton;
|
||||
// className?: string;
|
||||
// onClick: () => void;
|
||||
// isPending?: boolean;
|
||||
// size: keyof sizeButton;
|
||||
// };
|
||||
//
|
||||
// export const ButtonWithLoading = ({
|
||||
// icon,
|
||||
// text,
|
||||
// variant,
|
||||
// className,
|
||||
// onClick,
|
||||
// isPending,
|
||||
// size,
|
||||
// ...props // catch all remaining props
|
||||
// }: ButtonWithConfirmProps & ButtonHTMLAttributes<HTMLButtonElement>) => {
|
||||
// return (
|
||||
// <Button
|
||||
// onClick={() => {
|
||||
// onClick();
|
||||
// }}
|
||||
// variant={variant ? variant : "default"}
|
||||
// className={className}
|
||||
// {...props} // forward the remaining props to the Button component
|
||||
// size={size || "default"}
|
||||
// >
|
||||
// {isPending && <Loader2 className="animate-spin mr-4" size={16} />}
|
||||
// {text}
|
||||
// <>{icon ? icon : null}</>
|
||||
// </Button>
|
||||
// );
|
||||
// };
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ButtonHTMLAttributes } from "react";
|
||||
'use client'
|
||||
|
||||
import { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export type VariantButton = {
|
||||
secondary: string;
|
||||
@@ -12,46 +70,46 @@ export type VariantButton = {
|
||||
link: string;
|
||||
destructive: string;
|
||||
};
|
||||
export type sizeButton = {
|
||||
|
||||
export type SizeButton = {
|
||||
default: string;
|
||||
icon: string;
|
||||
sm: string;
|
||||
lg: string;
|
||||
};
|
||||
|
||||
export type ButtonWithConfirmProps = {
|
||||
icon?: any;
|
||||
text: string;
|
||||
export type ButtonWithLoadingProps = {
|
||||
children?: string | ReactNode;
|
||||
icon?: ReactNode;
|
||||
variant?: keyof VariantButton;
|
||||
className?: string;
|
||||
onClick: () => void;
|
||||
onClick?: () => void;
|
||||
isPending?: boolean;
|
||||
size: keyof sizeButton;
|
||||
};
|
||||
size?: keyof SizeButton;
|
||||
} & ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
export const ButtonWithLoading = ({
|
||||
icon,
|
||||
text,
|
||||
variant,
|
||||
className,
|
||||
onClick,
|
||||
isPending,
|
||||
size,
|
||||
...props // catch all remaining props
|
||||
}: ButtonWithConfirmProps & ButtonHTMLAttributes<HTMLButtonElement>) => {
|
||||
icon,
|
||||
children,
|
||||
variant = "default",
|
||||
className,
|
||||
onClick,
|
||||
isPending,
|
||||
size = "default",
|
||||
...rest
|
||||
}: ButtonWithLoadingProps) => {
|
||||
return (
|
||||
<Button
|
||||
onClick={() => {
|
||||
onClick();
|
||||
}}
|
||||
variant={variant ? variant : "default"}
|
||||
onClick={() => onClick?.()}
|
||||
variant={variant}
|
||||
className={className}
|
||||
{...props} // forward the remaining props to the Button component
|
||||
size={size || "default"}
|
||||
size={size}
|
||||
{...rest}
|
||||
>
|
||||
{isPending && <Loader2 className="animate-spin mr-4" size={16} />}
|
||||
{text}
|
||||
{isPending && <Loader2 className="mr-2 animate-spin" size={16} />}
|
||||
{children && children}
|
||||
<>{icon ? icon : null}</>
|
||||
{/*{icon && <span className="ml-2">{icon}</span>}*/}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
|
||||
import {SettingsEmailTab} from "@/components/wrappers/dashboard/admin/admin-email-tab/settings-email-tab";
|
||||
import {SettingsStorageTab} from "@/components/wrappers/dashboard/admin/admin-storage-tab/settings-storage-tab";
|
||||
import {SettingsEmailTab} from "@/components/wrappers/dashboard/admin/tabs/admin-email-tab/settings-email-tab";
|
||||
import {SettingsStorageTab} from "@/components/wrappers/dashboard/admin/tabs/admin-storage-tab/settings-storage-tab";
|
||||
import {User, UserWithAccounts} from "@/db/schema/02_user";
|
||||
import {Setting} from "@/db/schema/01_setting";
|
||||
import {useEffect, useState} from "react";
|
||||
import {useRouter, useSearchParams} from "next/navigation";
|
||||
import {AdminUsersTable} from "@/components/wrappers/dashboard/admin/admin-user-tab/admin-user-table";
|
||||
import {AdminUsersTable} from "@/components/wrappers/dashboard/admin/tabs/admin-user-tab/admin-user-table";
|
||||
import {
|
||||
AdminOrganizationsTable
|
||||
} from "@/components/wrappers/dashboard/admin/tabs/admin-organizations-tab/admin-organizations-table";
|
||||
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||
|
||||
export type AdminTabsProps = {
|
||||
users: UserWithAccounts[];
|
||||
settings: Setting;
|
||||
organizations: OrganizationWithMembers[];
|
||||
};
|
||||
|
||||
export const AdminTabs = ({users, settings}: AdminTabsProps) => {
|
||||
export const AdminTabs = ({users, settings, organizations}: AdminTabsProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
@@ -35,6 +40,9 @@ export const AdminTabs = ({users, settings}: AdminTabsProps) => {
|
||||
<TabsTrigger className="w-full" value="users">
|
||||
Users
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className="w-full" value="organizations">
|
||||
Organizations
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className="w-full" value="email">
|
||||
Email
|
||||
</TabsTrigger>
|
||||
@@ -45,6 +53,9 @@ export const AdminTabs = ({users, settings}: AdminTabsProps) => {
|
||||
<TabsContent value="users">
|
||||
<AdminUsersTable users={users}/>
|
||||
</TabsContent>
|
||||
<TabsContent value="organizations">
|
||||
<AdminOrganizationsTable organizations={organizations}/>
|
||||
</TabsContent>
|
||||
<TabsContent value="email">
|
||||
<SettingsEmailTab settings={settings}/>
|
||||
</TabsContent>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client"
|
||||
|
||||
import {useState} from "react";
|
||||
import {Plus} from "lucide-react";
|
||||
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger
|
||||
} from "@/components/ui/dialog";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {AdminOrganizationForm} from "@/components/wrappers/dashboard/admin/organization/admin-organization-form";
|
||||
|
||||
type AdminOrganizationAddModalProps = {}
|
||||
|
||||
|
||||
export const AdminOrganizationAddModal = (props: AdminOrganizationAddModalProps) => {
|
||||
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus/> add
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>add organization</DialogTitle>
|
||||
<DialogDescription>
|
||||
your description
|
||||
</DialogDescription>
|
||||
<AdminOrganizationForm onSuccess={() => setOpen(false)}/>
|
||||
</DialogHeader>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ErrorContext } from "@better-fetch/fetch";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form";
|
||||
import { OrganizationSchema } from "@/components/wrappers/dashboard/admin/organization/organization.schema";
|
||||
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/lib/auth/auth-client";
|
||||
import { slugify } from "@/utils/slugify";
|
||||
|
||||
type AdminOrganizationFormProps = {
|
||||
onSuccess?: () => void;
|
||||
};
|
||||
|
||||
export const AdminOrganizationForm = ({ onSuccess }: AdminOrganizationFormProps) => {
|
||||
|
||||
const router = useRouter();
|
||||
const form = useZodForm({ schema: OrganizationSchema });
|
||||
|
||||
const mutationCreateOrganisation = useMutation({
|
||||
mutationFn: async ({ name }: OrganizationSchema) => {
|
||||
const slug = slugify(name);
|
||||
await authClient.organization.checkSlug(
|
||||
{
|
||||
slug: slug,
|
||||
},
|
||||
{
|
||||
onSuccess: async () => {
|
||||
await authClient.organization.create(
|
||||
{
|
||||
name: name,
|
||||
slug: slug,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("Organization created successfully.");
|
||||
router.refresh();
|
||||
onSuccess?.();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.error.message);
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
onError: (error: ErrorContext) => {
|
||||
toast.error(error.error.message);
|
||||
onSuccess?.();
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={async (values) => {
|
||||
await mutationCreateOrganisation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Name of your organization" {...field} value={field.value ?? ""} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex gap-4 justify-end">
|
||||
<ButtonWithLoading isPending={mutationCreateOrganisation.isPending}>Validate</ButtonWithLoading>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { AdminOrganizationList } from "@/components/wrappers/dashboard/admin/organization/admin-orgnization-list";
|
||||
import { AdminOrganizationAddModal } from "@/components/wrappers/dashboard/admin/organization/admin-organization-add-modal";
|
||||
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||
|
||||
type AdminOrganizationSectionProps = {
|
||||
organizations: OrganizationWithMembers[];
|
||||
};
|
||||
|
||||
export const AdminOrganizationSection = ({ organizations }: AdminOrganizationSectionProps) => {
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Add a new organization</CardTitle>
|
||||
<CardAction>
|
||||
<AdminOrganizationAddModal />
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AdminOrganizationList organizations={organizations} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client"
|
||||
import { DataTable } from "@/components/wrappers/common/table/data-table";
|
||||
import { organizationsListColumns } from "@/components/wrappers/dashboard/admin/organization/table-colums";
|
||||
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||
|
||||
type AdminOrganizationListProps = {
|
||||
organizations: OrganizationWithMembers[];
|
||||
};
|
||||
|
||||
export const AdminOrganizationList = ({ organizations }: AdminOrganizationListProps) => {
|
||||
return <DataTable columns={organizationsListColumns()} data={organizations} enablePagination={true} enableSelect={false} />;
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client"
|
||||
|
||||
import {useRouter} from "next/navigation";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {Trash2} from "lucide-react";
|
||||
import {toast} from "sonner";
|
||||
import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm";
|
||||
import {authClient} from "@/lib/auth/auth-client";
|
||||
import {deleteOrganizationAction} from "@/components/wrappers/dashboard/organization/organization.action";
|
||||
|
||||
export type ButtonDeleteFleetProps = {
|
||||
text?: string;
|
||||
organisationId: string
|
||||
};
|
||||
|
||||
export const ButtonDeleteOrganization = (props: ButtonDeleteFleetProps) => {
|
||||
|
||||
const router = useRouter();
|
||||
const {data: organizations, refetch} = authClient.useListOrganizations();
|
||||
|
||||
|
||||
const mutationDeleteOrganisation = useMutation({
|
||||
mutationFn: () => deleteOrganizationAction({id: props.organisationId}),
|
||||
onSuccess: async (result) => {
|
||||
if (result?.data?.success) {
|
||||
await authClient.organization.setActive({
|
||||
organizationSlug: "default",
|
||||
});
|
||||
toast.success("Organization deleted!");
|
||||
router.refresh()
|
||||
refetch()
|
||||
} else {
|
||||
toast.error("An error occurred.");
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error("network error:", error);
|
||||
toast.error(error?.message || "A network error occurred.");
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<ButtonWithConfirm
|
||||
title={props.text ? props.text : ""}
|
||||
description={"Are you sure you want to delete this organization?"}
|
||||
button={{
|
||||
main: {
|
||||
variant: "outline",
|
||||
icon: <Trash2 color="red"/>,
|
||||
},
|
||||
confirm: {
|
||||
className: "w-full",
|
||||
text: "Delete",
|
||||
icon: <Trash2/>,
|
||||
variant: "destructive",
|
||||
onClick: async () => {
|
||||
await mutationDeleteOrganisation.mutateAsync()
|
||||
},
|
||||
},
|
||||
cancel: {
|
||||
className: "w-full",
|
||||
text: "Cancel",
|
||||
icon: <Trash2/>,
|
||||
variant: "outline",
|
||||
},
|
||||
}}
|
||||
isPending={mutationDeleteOrganisation.isPending}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
"use server";
|
||||
|
||||
import { ServerActionResult } from "@/types/action-type";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/lib/auth/auth";
|
||||
import { MemberRoleType } from "@/types/common";
|
||||
import { Member } from "better-auth/plugins/organization";
|
||||
import {userAction} from "@/lib/safe-actions/actions";
|
||||
|
||||
export const addMemberOrganizationAction = userAction
|
||||
.schema(
|
||||
z.object({
|
||||
userId: z.string(),
|
||||
organizationId: z.string(),
|
||||
role: z.string(),
|
||||
})
|
||||
)
|
||||
.action(async ({ parsedInput }): Promise<ServerActionResult<Member | null>> => {
|
||||
try {
|
||||
const data = await auth.api.addMember({
|
||||
body: {
|
||||
userId: parsedInput.userId,
|
||||
role: parsedInput.role as MemberRoleType,
|
||||
organizationId: parsedInput.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: data,
|
||||
actionSuccess: {
|
||||
message: "Member added successfully",
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "An error occurred while addinng member",
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import {useRouter} from "next/navigation";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
|
||||
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
|
||||
import {
|
||||
AddMemberSchema,
|
||||
AddMemberSchemaType
|
||||
} from "@/components/wrappers/dashboard/admin/organization/organization.schema";
|
||||
import {SearchInput} from "@/components/ui/search-input";
|
||||
import {
|
||||
addMemberOrganizationAction
|
||||
} from "@/components/wrappers/dashboard/admin/organization/details/add-member.action";
|
||||
import {toast} from "sonner";
|
||||
import {OrganizationWithMembers, OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
|
||||
import {User} from "@/db/schema/02_user";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
|
||||
type OrganizationAddMemberFormProps = {
|
||||
onSuccessAction?: () => void;
|
||||
users: User[];
|
||||
organization: OrganizationWithMembersAndUsers;
|
||||
};
|
||||
|
||||
export const OrganizationAddMemberForm = ({onSuccessAction, users, organization}: OrganizationAddMemberFormProps) => {
|
||||
|
||||
const organizationMemberUserIds = organization.members.map((member) => member.user.id);
|
||||
const filteredUsers = users
|
||||
.filter((user) => !organizationMemberUserIds.includes(user.id))
|
||||
.map((user) => ({value: user.id, label: `${user.name} | ${user.email}`}));
|
||||
const router = useRouter();
|
||||
const form = useZodForm({schema: AddMemberSchema});
|
||||
|
||||
const mutationAddMemberOrganisation = useMutation({
|
||||
mutationFn: async (data: AddMemberSchemaType) => {
|
||||
console.log(data);
|
||||
const result = await addMemberOrganizationAction({
|
||||
userId: data.userId,
|
||||
organizationId: organization.id,
|
||||
role: "member",
|
||||
});
|
||||
console.log(result);
|
||||
toast.success("Member successfully added!");
|
||||
router.refresh();
|
||||
onSuccessAction?.();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message);
|
||||
onSuccessAction?.();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={async (values) => {
|
||||
await mutationAddMemberOrganisation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="userId"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>User</FormLabel>
|
||||
<FormControl>
|
||||
<SearchInput
|
||||
name="userId"
|
||||
placeholder="Enter a user email"
|
||||
entries={filteredUsers}
|
||||
onSelect={(entySelected: any) => {
|
||||
console.log("Form selection:", entySelected);
|
||||
field.onChange(entySelected.value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-4 justify-end">
|
||||
<ButtonWithLoading
|
||||
isPending={mutationAddMemberOrganisation.isPending}>Confirm</ButtonWithLoading>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { OrganizationAddMemberForm } from "@/components/wrappers/dashboard/admin/organization/details/organization-add-member-form";
|
||||
import { useState } from "react";
|
||||
import { UserPlus } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {OrganizationWithMembers, OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
|
||||
import {User} from "@/db/schema/02_user";
|
||||
|
||||
type OrganizationAddMemberModalProps = {
|
||||
users: User[];
|
||||
organization: OrganizationWithMembersAndUsers;
|
||||
};
|
||||
|
||||
export const OrganizationAddMemberModal = ({ users, organization }: OrganizationAddMemberModalProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<UserPlus className="w-4 h-4 mr-2" />
|
||||
Add member
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add member to your organization</DialogTitle>
|
||||
<DialogDescription>Select a user to add to your organization</DialogDescription>
|
||||
</DialogHeader>
|
||||
<OrganizationAddMemberForm users={users} organization={organization} onSuccessAction={() => setOpen(!open)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { authClient } from "@/lib/auth/auth-client";
|
||||
import { toast } from "sonner";
|
||||
import {MemberWithUser} from "@/db/schema/03_organization";
|
||||
|
||||
type OrganizationDeleteMemberModalProps = {
|
||||
open: boolean;
|
||||
member: MemberWithUser;
|
||||
onOpenChangeAction: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const OrganizationDeleteMemberModal = ({ member, open, onOpenChangeAction }: OrganizationDeleteMemberModalProps) => {
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
await authClient.organization.removeMember(
|
||||
{
|
||||
memberIdOrEmail: member.id,
|
||||
organizationId: member.organizationId,
|
||||
},
|
||||
{
|
||||
onSuccess: async (response) => {
|
||||
console.log(response);
|
||||
toast.success("Member successfully deleted!");
|
||||
onOpenChangeAction(false);
|
||||
router.refresh();
|
||||
},
|
||||
onError: async (error) => {
|
||||
console.log(error);
|
||||
toast.error("An error occurred while deleting member!");
|
||||
onOpenChangeAction(false);
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChangeAction}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure you want to delete {member.user.name } ?</AlertDialogTitle>
|
||||
<AlertDialogDescription>This action is irreversible: it will permanently delete this member’s data.</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<ButtonWithLoading onClick={async () => await mutation.mutateAsync()}>Validate</ButtonWithLoading>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar";
|
||||
import {Badge} from "@/components/ui/badge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {MoreHorizontal, Settings, Trash2} from "lucide-react";
|
||||
import {
|
||||
OrganizationDeleteMemberModal
|
||||
} from "@/components/wrappers/dashboard/admin/organization/details/organization-delete-member-modal";
|
||||
import {useState} from "react";
|
||||
import {authClient} from "@/lib/auth/auth-client";
|
||||
import {
|
||||
OrganizationMemberChangeRoleModal
|
||||
} from "@/components/wrappers/dashboard/admin/organization/details/organization-member-change-role";
|
||||
import {MemberWithUser, OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
|
||||
|
||||
type OrganizationMemberCardProps = {
|
||||
member: MemberWithUser;
|
||||
organization: OrganizationWithMembersAndUsers;
|
||||
};
|
||||
|
||||
export const OrganizationMemberCard = ({member, organization}: OrganizationMemberCardProps) => {
|
||||
|
||||
const [isModalDeleteOpen, setIsModalDeleteOpen] = useState(false);
|
||||
const [isModalRoleOpen, setIsModalRoleOpen] = useState(false);
|
||||
const {data: session, isPending, error} = authClient.useSession();
|
||||
|
||||
if (isPending || error) return null;
|
||||
const isCurrentUser = session?.user?.id === member.user.id;
|
||||
const isOwner = member?.role === "owner";
|
||||
|
||||
return (
|
||||
<div key={member.id}
|
||||
className="flex flex-col md:flex-row md:items-center justify-between p-4 border rounded-lg">
|
||||
<OrganizationDeleteMemberModal member={member} open={isModalDeleteOpen}
|
||||
onOpenChangeAction={setIsModalDeleteOpen}/>
|
||||
<OrganizationMemberChangeRoleModal member={member} open={isModalRoleOpen}
|
||||
onOpenChangeAction={setIsModalRoleOpen}/>
|
||||
<div className="flex items-center space-x-4">
|
||||
<Avatar>
|
||||
<AvatarImage src={member.user.image || ""} alt={member.user.name}/>
|
||||
<AvatarFallback>
|
||||
{member.user.name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="font-medium">{member.user.name}</div>
|
||||
<div className="text-sm text-muted-foreground">{member.user.email}</div>
|
||||
<div
|
||||
className="text-xs text-muted-foreground">Joined {new Date(member.createdAt).toLocaleDateString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 mt-4 md:mt-0">
|
||||
<Badge variant={getRoleBadgeVariant(member.role)}>{member.role}</Badge>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="w-4 h-4"/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => setIsModalRoleOpen(true)}>
|
||||
<Settings className="w-4 h-4 mr-2"/>
|
||||
Change role
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator/>
|
||||
<DropdownMenuItem onSelect={() => setIsModalDeleteOpen(true)} className="text-red-600">
|
||||
<Trash2 className="w-4 h-4 mr-2"/>
|
||||
Remove member
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getRoleBadgeVariant = (role: string) => {
|
||||
switch (role.toLowerCase()) {
|
||||
case "owner":
|
||||
return "default";
|
||||
case "admin":
|
||||
return "secondary";
|
||||
default:
|
||||
return "outline";
|
||||
}
|
||||
};
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import {useState} from "react";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {toast} from "sonner";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from "@/components/ui/dialog";
|
||||
import {authClient} from "@/lib/auth/auth-client";
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import {MemberRoleType} from "@/types/common";
|
||||
import {MemberWithUser} from "@/db/schema/03_organization";
|
||||
import {updateMemberRoleAction} from "@/components/wrappers/dashboard/settings/update-member.action";
|
||||
import {RoleSchemaMember} from "@/components/wrappers/dashboard/settings/member.schema";
|
||||
import {
|
||||
updateMemberRoleAdminAction
|
||||
} from "@/components/wrappers/dashboard/admin/organization/details/role-member.action";
|
||||
|
||||
type OrganizationMemberChangeRoleModalProps = {
|
||||
open: boolean;
|
||||
member: MemberWithUser;
|
||||
onOpenChangeAction: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const OrganizationMemberChangeRoleModal = (props: OrganizationMemberChangeRoleModalProps) => {
|
||||
const {member, open, onOpenChangeAction} = props;
|
||||
|
||||
const router = useRouter();
|
||||
const [role, setRole] = useState<MemberRoleType>(member.role as MemberRoleType);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
updateMemberRoleAdminAction({
|
||||
memberId: member.id,
|
||||
organizationId: member.organizationId,
|
||||
role: RoleSchemaMember.parse(role),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success("Member successfully updated");
|
||||
onOpenChangeAction(false);
|
||||
router.refresh();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.log(error);
|
||||
toast.error("An error occurred while updating member");
|
||||
onOpenChangeAction(false);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChangeAction}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Change the user’s role</DialogTitle>
|
||||
<DialogDescription>Modify the role of this user within your organization.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Select defaultValue={member.role ?? ""} onValueChange={(role) => setRole(role as MemberRoleType)}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Sélectionnez un rôle"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="member">Member</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="owner">Owner</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<DialogFooter>
|
||||
<div className="flex gap-4 justify-end">
|
||||
<ButtonWithLoading
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onOpenChangeAction(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</ButtonWithLoading>
|
||||
<ButtonWithLoading
|
||||
isPending={mutation.isPending}
|
||||
onClick={async () => {
|
||||
await mutation.mutateAsync();
|
||||
}}
|
||||
>
|
||||
Validate
|
||||
</ButtonWithLoading>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
"use server";
|
||||
import {userAction} from "@/lib/safe-actions/actions";
|
||||
import {z} from "zod";
|
||||
import {ServerActionResult} from "@/types/action-type";
|
||||
import {Member} from "better-auth/plugins";
|
||||
import {RoleSchemaMember} from "@/components/wrappers/dashboard/settings/member.schema";
|
||||
import {db as dbClient} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {and, eq} from "drizzle-orm";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
|
||||
|
||||
export const updateMemberRoleAdminAction = userAction.schema(
|
||||
z.object({
|
||||
memberId: z.string(),
|
||||
organizationId: z.string(),
|
||||
role: RoleSchemaMember,
|
||||
})
|
||||
).action(async ({parsedInput}): Promise<ServerActionResult<Member>> => {
|
||||
try {
|
||||
|
||||
const [updatedMember] = await dbClient
|
||||
.update(drizzleDb.schemas.member)
|
||||
.set(withUpdatedAt({
|
||||
role: parsedInput.role as string,
|
||||
}))
|
||||
.where(and(eq(drizzleDb.schemas.member.id, parsedInput.memberId), eq(drizzleDb.schemas.member.organizationId, parsedInput.organizationId)))
|
||||
.returning();
|
||||
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: updatedMember,
|
||||
actionSuccess: {
|
||||
message: "Member has been successfully updated.",
|
||||
messageParams: {},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Failed to update member role.",
|
||||
status: 500,
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
messageParams: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import {useRouter} from "next/navigation";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
|
||||
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
|
||||
import {
|
||||
UpdateOrganizationSchema,
|
||||
UpdateOrganizationSchemaType
|
||||
} from "@/components/wrappers/dashboard/admin/organization/organization.schema";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import {authClient} from "@/lib/auth/auth-client";
|
||||
import {toast} from "sonner";
|
||||
import {OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
|
||||
import {updateOrganizationAction} from "@/components/wrappers/dashboard/organization/organization.action";
|
||||
|
||||
type UpdateOrganizationFormProps = {
|
||||
onSuccessAction?: () => void;
|
||||
defaultValues: OrganizationWithMembersAndUsers;
|
||||
};
|
||||
|
||||
export const UpdateOrganizationForm = ({onSuccessAction, defaultValues}: UpdateOrganizationFormProps) => {
|
||||
|
||||
const router = useRouter();
|
||||
const {data: organizations, refetch} = authClient.useListOrganizations();
|
||||
|
||||
const isDefaultOrganization = defaultValues.slug == "default";
|
||||
|
||||
const form = useZodForm({
|
||||
schema: UpdateOrganizationSchema,
|
||||
defaultValues: defaultValues,
|
||||
disabled: isDefaultOrganization,
|
||||
});
|
||||
|
||||
|
||||
const mutationUpdateOrganisation = useMutation({
|
||||
mutationFn: ({name}: UpdateOrganizationSchemaType) => updateOrganizationAction({
|
||||
data: {
|
||||
name: name,
|
||||
users: [],
|
||||
slug: defaultValues.slug
|
||||
},
|
||||
organizationId: defaultValues.id,
|
||||
}),
|
||||
onSuccess: async (result) => {
|
||||
if (result?.data?.success) {
|
||||
toast.success("Organization updated successfully.");
|
||||
router.refresh();
|
||||
refetch()
|
||||
} else {
|
||||
// @ts-ignore
|
||||
const errorMsg = result?.data?.actionError?.message || result?.data?.actionError?.messageParams?.message || "Failed to update the organization.";
|
||||
toast.error(errorMsg);
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error("Mutation network error:", error);
|
||||
toast.error(error?.message || "A network error occurred.");
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={async (values) => {
|
||||
await mutationUpdateOrganisation.mutateAsync(values);
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({field}) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="" {...field} value={field.value ?? ""}/>
|
||||
</FormControl>
|
||||
<FormMessage/>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-4 justify-end">
|
||||
<ButtonWithLoading disabled={isDefaultOrganization} isPending={mutationUpdateOrganisation.isPending}>Validate</ButtonWithLoading>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import {Building2, Shield, Users} from "lucide-react";
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
|
||||
import {MemberWithUser, OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
|
||||
import {
|
||||
UpdateOrganizationForm
|
||||
} from "@/components/wrappers/dashboard/admin/organization/details/update-organization-form";
|
||||
import {
|
||||
OrganizationMemberCard
|
||||
} from "@/components/wrappers/dashboard/admin/organization/details/organization-member-card";
|
||||
import {useRouter, useSearchParams} from "next/navigation";
|
||||
import {useEffect, useState} from "react";
|
||||
import {capitalizeFirstLetter} from "@/utils/text";
|
||||
import {User} from "@/db/schema/02_user";
|
||||
import {
|
||||
OrganizationAddMemberModal
|
||||
} from "@/components/wrappers/dashboard/admin/organization/details/organization-add-member-modal";
|
||||
import {cn} from "@/lib/utils";
|
||||
|
||||
type OrganizationManagementProps = {
|
||||
organization: OrganizationWithMembersAndUsers;
|
||||
users: User[];
|
||||
};
|
||||
|
||||
export const OrganizationManagement = ({organization, users}: OrganizationManagementProps) => {
|
||||
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [tab, setTab] = useState<string>(() => searchParams.get("tab") ?? "members");
|
||||
|
||||
useEffect(() => {
|
||||
const newTab = searchParams.get("tab") ?? "members";
|
||||
setTab(newTab);
|
||||
}, [searchParams]);
|
||||
|
||||
const handleChangeTab = (value: string) => {
|
||||
router.push(`?tab=${value}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className=" space-y-8">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center justify-center w-12 h-12 dark:bg-gray-700 bg-gray-100 rounded-lg">
|
||||
<Building2 className="w-6 h-6 "/>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{capitalizeFirstLetter(organization.name)}</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 mt-3 md:mt-0">
|
||||
<OrganizationAddMemberModal organization={organization} users={users}/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Members</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground"/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{organization.members.length}</div>
|
||||
<p className="text-xs text-muted-foreground">Number of members</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Administrators</CardTitle>
|
||||
<Shield className="h-4 w-4 text-muted-foreground"/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
className="text-2xl font-bold">{organization.members.filter((m) => m.role === "admin" || m.role === "owner").length}</div>
|
||||
<p className="text-xs text-muted-foreground">With admin roles</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<Tabs className="space-y-6" value={tab} onValueChange={handleChangeTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="members">Members</TabsTrigger>
|
||||
<TabsTrigger value="settings">Settings</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="members" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Organization members</CardTitle>
|
||||
<CardDescription>Manage who has access to your organization and their
|
||||
roles.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{organization.members.map((member: MemberWithUser) => (
|
||||
<OrganizationMemberCard key={member.id} member={member}
|
||||
organization={organization}/>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="settings" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Settings</CardTitle>
|
||||
<CardDescription>Organization configuration settings.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<UpdateOrganizationForm defaultValues={organization}/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import {z} from "zod";
|
||||
|
||||
export const AddMemberSchema = z.object({
|
||||
userId: z.string().min(1, "Invalid field"),
|
||||
});
|
||||
|
||||
export const UpdateOrganizationSchema = z.object({
|
||||
name: z.string().min(5),
|
||||
});
|
||||
|
||||
export const OrganizationSchema = z.object({
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const OrganizationInvitationSchema = z.object({
|
||||
email: z.string(),
|
||||
invitedByUsername: z.string(),
|
||||
invitedByEmail: z.string(),
|
||||
teamName: z.string(),
|
||||
inviteLink: z.string()
|
||||
});
|
||||
|
||||
export type OrganizationInvitationType = z.infer<typeof OrganizationInvitationSchema>;
|
||||
export type OrganizationSchema = z.infer<typeof OrganizationSchema>;
|
||||
export type UpdateOrganizationSchemaType = z.infer<typeof UpdateOrganizationSchema>;
|
||||
export type AddMemberSchemaType = z.infer<typeof AddMemberSchema>;
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
import {ColumnDef} from "@tanstack/react-table";
|
||||
import {ButtonDeleteOrganization} from "@/components/wrappers/dashboard/admin/organization/button-delete-organization";
|
||||
import Link from "next/link";
|
||||
import {Settings} from "lucide-react";
|
||||
import {buttonVariants} from "@/components/ui/button";
|
||||
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||
|
||||
export function organizationsListColumns(): ColumnDef<OrganizationWithMembers>[] {
|
||||
|
||||
return [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Name",
|
||||
},
|
||||
{
|
||||
accessorKey: "members",
|
||||
header: "Members",
|
||||
cell: ({row}) => {
|
||||
const membersCount = row.original.members?.length;
|
||||
return <div className="flex items-center gap-3">{membersCount}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Actions",
|
||||
id: "actions",
|
||||
cell: ({row}) => {
|
||||
const isDefaultOrganization = row.original.slug == "default";
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
{!isDefaultOrganization && (
|
||||
<ButtonDeleteOrganization organisationId={row.original.id}/>
|
||||
)}
|
||||
<Link className={buttonVariants({variant: "outline"})}
|
||||
href={`admin/organization/${row.original.id}`}>
|
||||
<Settings/>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
import { z } from "zod";
|
||||
import { EmailFormSchema } from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form.schema";
|
||||
import { EmailFormSchema } from "@/components/wrappers/dashboard/admin/tabs/admin-email-tab/email-form/email-form.schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
+2
-2
@@ -19,11 +19,11 @@ import {TooltipProvider} from "@/components/ui/tooltip";
|
||||
import {
|
||||
EmailFormSchema,
|
||||
EmailFormType
|
||||
} from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form.schema";
|
||||
} from "@/components/wrappers/dashboard/admin/tabs/admin-email-tab/email-form/email-form.schema";
|
||||
import {PasswordInput} from "@/components/wrappers/auth/password-input/password-input";
|
||||
import {
|
||||
updateEmailSettingsAction
|
||||
} from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form.action";
|
||||
} from "@/components/wrappers/dashboard/admin/tabs/admin-email-tab/email-form/email-form.action";
|
||||
import {toast} from "sonner";
|
||||
import {useRouter} from "next/navigation";
|
||||
|
||||
+12
-13
@@ -1,13 +1,13 @@
|
||||
import { EmailForm } from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form";
|
||||
import { Send } from "lucide-react";
|
||||
import { ButtonWithLoading } from "@/components/wrappers/common/button/button-with-loading";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { sendEmail } from "@/utils/email-helper";
|
||||
import TestEmailSettings from "../../../../../../emails/TestEmailSettings";
|
||||
import { render } from "@react-email/render";
|
||||
import { toast } from "sonner";
|
||||
import {EmailForm} from "@/components/wrappers/dashboard/admin/tabs/admin-email-tab/email-form/email-form";
|
||||
import {Send} from "lucide-react";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {sendEmail} from "@/utils/email-helper";
|
||||
import {render} from "@react-email/render";
|
||||
import {toast} from "sonner";
|
||||
import {Setting} from "@/db/schema/01_setting";
|
||||
import {EmailFormType} from "@/components/wrappers/dashboard/admin/admin-email-tab/email-form/email-form.schema";
|
||||
import {EmailFormType} from "@/components/wrappers/dashboard/admin/tabs/admin-email-tab/email-form/email-form.schema";
|
||||
import TestEmailSettings from "../../../../../../../emails/TestEmailSettings";
|
||||
|
||||
export type SettingsEmailTabProps = {
|
||||
settings: Setting;
|
||||
@@ -47,14 +47,13 @@ export const SettingsEmailTab = (props: SettingsEmailTabProps) => {
|
||||
onClick={async () => {
|
||||
await handleSendMailTest();
|
||||
}}
|
||||
icon={<Send />}
|
||||
text="Send email test"
|
||||
icon={<Send/>}
|
||||
size="default"
|
||||
/>
|
||||
>Send email test</ButtonWithLoading>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-5">
|
||||
<EmailForm defaultValues={props.settings.smtpFrom ? props.settings as EmailFormType : undefined } />
|
||||
<EmailForm defaultValues={props.settings.smtpFrom ? props.settings as EmailFormType : undefined}/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
"use client"
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";
|
||||
import {OrganizationWithMembers} from "@/db/schema/03_organization";
|
||||
import {AdminOrganizationList} from "@/components/wrappers/dashboard/admin/organization/admin-orgnization-list";
|
||||
|
||||
export type AdminOrganizationsTableProps = {
|
||||
organizations: OrganizationWithMembers[];
|
||||
|
||||
};
|
||||
|
||||
export const AdminOrganizationsTable = (props: AdminOrganizationsTableProps) => {
|
||||
const {organizations} = props;
|
||||
return (
|
||||
<div className="flex flex-col gap-y-4 h-full py-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Active organizations</CardTitle>
|
||||
<CardDescription>Manage all system organizations</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AdminOrganizationList organizations={organizations} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
"use client"
|
||||
import {ColumnDef} from "@tanstack/react-table";
|
||||
import {Badge} from "@/components/ui/badge";
|
||||
import {updateUserAction} from "@/components/wrappers/dashboard/profile/user-form/user-form.action";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {toast} from "sonner";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {useState} from "react";
|
||||
import {Trash2} from "lucide-react";
|
||||
import {deleteUserAction} from "@/components/wrappers/dashboard/profile/button-delete-account/delete-account.action";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import {UserWithAccounts} from "@/db/schema/02_user";
|
||||
import {authClient, useSession} from "@/lib/auth/auth-client";
|
||||
import {formatFrenchDate} from "@/utils/date-formatting";
|
||||
import {providerSwitch} from "@/components/wrappers/common/provider-switch";
|
||||
import {ButtonDeleteUser} from "@/components/wrappers/dashboard/admin/tabs/admin-user-tab/button-delete-use";
|
||||
import {Organization} from "@/db/schema/03_organization";
|
||||
|
||||
export const organizationsColumnsAdmin: ColumnDef<Organization>[] = [
|
||||
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Name",
|
||||
},
|
||||
|
||||
// {
|
||||
// header: "Action",
|
||||
// id: "actions",
|
||||
// cell: ({row}) => {
|
||||
// const router = useRouter();
|
||||
// const {data: session, isPending} = useSession();
|
||||
// const isSuperAdmin = session?.user.role == "superadmin";
|
||||
//
|
||||
// return (
|
||||
// <ButtonDeleteUser
|
||||
// disabled={!isSuperAdmin || !session || session?.user.email === row.original.email}
|
||||
// userId={row.original.id}/>
|
||||
// );
|
||||
// },
|
||||
// },
|
||||
];
|
||||
+4
-6
@@ -2,7 +2,7 @@ import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert";
|
||||
import {Info, ShieldCheck} from "lucide-react";
|
||||
import {Switch} from "@/components/ui/switch";
|
||||
import {Label} from "@/components/ui/label";
|
||||
import {StorageS3Form} from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/storage-s3-form";
|
||||
import {StorageS3Form} from "@/components/wrappers/dashboard/admin/tabs/admin-storage-tab/storage-s3/storage-s3-form";
|
||||
import {useState} from "react";
|
||||
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
@@ -11,9 +11,9 @@ import {toast} from "sonner";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {
|
||||
updateStorageSettingsAction
|
||||
} from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/s3-form.action";
|
||||
} from "@/components/wrappers/dashboard/admin/tabs/admin-storage-tab/storage-s3/s3-form.action";
|
||||
import {Setting} from "@/db/schema/01_setting";
|
||||
import {S3FormType} from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/s3-form.schema";
|
||||
import {S3FormType} from "@/components/wrappers/dashboard/admin/tabs/admin-storage-tab/storage-s3/s3-form.schema";
|
||||
|
||||
export type SettingsStorageTabProps = {
|
||||
settings: Setting;
|
||||
@@ -93,9 +93,7 @@ export const SettingsStorageTab = (props: SettingsStorageTabProps) => {
|
||||
onClick={async () => {
|
||||
await mutation.mutateAsync();
|
||||
}}
|
||||
icon={<ShieldCheck/>}
|
||||
text="Test connexion"
|
||||
/>
|
||||
icon={<ShieldCheck/>}>Test connexion</ButtonWithLoading>
|
||||
</div>
|
||||
</div>
|
||||
{isSwitched && (
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
import { z } from "zod";
|
||||
import { db } from "@/db";
|
||||
import { S3FormSchema, StorageSwitchSchema } from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/s3-form.schema";
|
||||
import { S3FormSchema, StorageSwitchSchema } from "@/components/wrappers/dashboard/admin/tabs/admin-storage-tab/storage-s3/s3-form.schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {userAction} from "@/lib/safe-actions/actions";
|
||||
+2
-2
@@ -8,10 +8,10 @@ import { Button } from "@/components/ui/button";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
||||
import { S3FormSchema, S3FormType } from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/s3-form.schema";
|
||||
import { S3FormSchema, S3FormType } from "@/components/wrappers/dashboard/admin/tabs/admin-storage-tab/storage-s3/s3-form.schema";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { updateS3SettingsAction } from "@/components/wrappers/dashboard/admin/admin-storage-tab/storage-s3/s3-form.action";
|
||||
import { updateS3SettingsAction } from "@/components/wrappers/dashboard/admin/tabs/admin-storage-tab/storage-s3/s3-form.action";
|
||||
import {PasswordInput} from "@/components/wrappers/auth/password-input/password-input";
|
||||
|
||||
export type S3FormProps = {
|
||||
-1
@@ -66,7 +66,6 @@ export const accountsColumns: ColumnDef<{
|
||||
<div className="flex items-center gap-2">
|
||||
<ButtonWithLoading
|
||||
variant="outline"
|
||||
text=""
|
||||
disabled={row.original.provider === "credential" || table.getRowModel().rows.length <= 1}
|
||||
icon={<Unlink color="red" size={15}/>}
|
||||
onClick={async () => {
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import {UserWithAccounts} from "@/db/schema/02_user";
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card";
|
||||
import {DataTable} from "@/components/wrappers/common/table/data-table";
|
||||
import {usersColumnsAdmin} from "@/components/wrappers/dashboard/admin/admin-user-tab/columns-users";
|
||||
import {usersColumnsAdmin} from "@/components/wrappers/dashboard/admin/tabs/admin-user-tab/columns-users";
|
||||
|
||||
export type AdminUsersTableProps = {
|
||||
users: UserWithAccounts[];
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import {Trash2} from "lucide-react";
|
||||
import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm";
|
||||
import {useMutation} from "@tanstack/react-query";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {toast} from "sonner";
|
||||
import {deleteUserAction} from "@/components/wrappers/dashboard/profile/button-delete-account/delete-account.action";
|
||||
|
||||
export type ButtonDeleteUserProps = {
|
||||
userId: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const ButtonDeleteUser = (props: ButtonDeleteUserProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => deleteUserAction(props.userId),
|
||||
onSuccess: async () => {
|
||||
toast.success("User deleted successfully.");
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
<ButtonWithConfirm
|
||||
title={""}
|
||||
|
||||
description="Are you sure you want to remove this user? This action cannot be undone."
|
||||
button={{
|
||||
main: {
|
||||
disabled: !!props.disabled,
|
||||
text: "",
|
||||
variant: "outline",
|
||||
size: "sm",
|
||||
icon: <Trash2 color="red" size={15}/>,
|
||||
},
|
||||
confirm: {
|
||||
className: "w-full",
|
||||
text: "Delete",
|
||||
icon: <Trash2/>,
|
||||
variant: "destructive",
|
||||
onClick: () => {
|
||||
mutation.mutate()
|
||||
},
|
||||
},
|
||||
cancel: {
|
||||
className: "w-full",
|
||||
text: "Cancel",
|
||||
icon: <Trash2/>,
|
||||
variant: "outline",
|
||||
},
|
||||
}}
|
||||
isPending={mutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+4
-22
@@ -13,6 +13,7 @@ import {UserWithAccounts} from "@/db/schema/02_user";
|
||||
import {authClient, useSession} from "@/lib/auth/auth-client";
|
||||
import {formatFrenchDate} from "@/utils/date-formatting";
|
||||
import {providerSwitch} from "@/components/wrappers/common/provider-switch";
|
||||
import {ButtonDeleteUser} from "@/components/wrappers/dashboard/admin/tabs/admin-user-tab/button-delete-use";
|
||||
|
||||
export const usersColumnsAdmin: ColumnDef<UserWithAccounts>[] = [
|
||||
{
|
||||
@@ -98,29 +99,10 @@ export const usersColumnsAdmin: ColumnDef<UserWithAccounts>[] = [
|
||||
const {data: session, isPending} = useSession();
|
||||
const isSuperAdmin = session?.user.role == "superadmin";
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => deleteUserAction(row.original.id),
|
||||
onSuccess: async () => {
|
||||
toast.success("User deleted successfully.");
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<ButtonWithLoading
|
||||
disabled={!isSuperAdmin || !session || session?.user.email === row.original.email}
|
||||
variant="outline"
|
||||
text=""
|
||||
icon={<Trash2 color="red" size={15}/>}
|
||||
onClick={async () => {
|
||||
await mutation.mutateAsync();
|
||||
}}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
<ButtonDeleteUser
|
||||
disabled={!isSuperAdmin || !session || session?.user.email === row.original.email}
|
||||
userId={row.original.id}/>
|
||||
);
|
||||
},
|
||||
},
|
||||
-1
@@ -73,7 +73,6 @@ export const sessionsColumns: ColumnDef<Session>[] = [
|
||||
<ButtonWithLoading
|
||||
variant="outline"
|
||||
disabled={session?.session.id === row.original.id}
|
||||
text=""
|
||||
icon={<Unlink color="red" size={15} />}
|
||||
onClick={async () => {
|
||||
await mutation.mutateAsync();
|
||||
@@ -38,12 +38,11 @@ export const BackupButton = (props: BackupButtonProps) => {
|
||||
<ButtonWithLoading
|
||||
icon={<DatabaseZap/>}
|
||||
disabled={props.disable}
|
||||
text={isMobile ? "" : "Backup"}
|
||||
isPending={mutation.isPending}
|
||||
size={"default"}
|
||||
onClick={async () => {
|
||||
await HandleAction();
|
||||
}}
|
||||
/>
|
||||
>{isMobile ? "" : "Backup"}</ButtonWithLoading>
|
||||
);
|
||||
};
|
||||
|
||||
+1
-2
@@ -2,7 +2,6 @@
|
||||
import {ButtonWithConfirm} from "@/components/wrappers/common/button/button-with-confirm";
|
||||
import {deleteOrganizationAction} from "@/components/wrappers/dashboard/organization/organization.action";
|
||||
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";
|
||||
@@ -17,7 +16,7 @@ export const DeleteOrganizationButton = (props: DeleteOrganizationButtonProps) =
|
||||
const {data: organizations, refetch} = authClient.useListOrganizations();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => deleteOrganizationAction(props.organizationSlug),
|
||||
mutationFn: () => deleteOrganizationAction({slug: props.organizationSlug}),
|
||||
|
||||
onSuccess: async (result) => {
|
||||
if (result?.data?.success) {
|
||||
|
||||
@@ -86,8 +86,6 @@ export const OrganizationForm = (props: organizationFormProps) => {
|
||||
console.error("Mutation network error:", error);
|
||||
toast.error(error?.message || "A network error occurred.");
|
||||
},
|
||||
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -8,12 +8,11 @@ import {
|
||||
OrganizationFormSchema
|
||||
} from "@/components/wrappers/dashboard/organization/organization-form/organization-form.schema";
|
||||
import {db} from "@/db";
|
||||
import {and, eq, inArray} from "drizzle-orm";
|
||||
import {auth, checkSlugOrganization, createOrganization, deleteOrganization} from "@/lib/auth/auth";
|
||||
import {and, eq, inArray, or} from "drizzle-orm";
|
||||
import {auth, checkSlugOrganization, createOrganization} from "@/lib/auth/auth";
|
||||
import {slugify} from "@/utils/slugify";
|
||||
import {Organization} from "@/db/schema/03_organization";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {headers} from "next/headers";
|
||||
|
||||
export const createOrganizationAction = userAction.schema(OrganizationSchema).action(async ({parsedInput}): Promise<ServerActionResult<Organization>> => {
|
||||
try {
|
||||
@@ -84,7 +83,6 @@ export const updateOrganizationAction = userAction
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
if (!organization) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -113,61 +111,11 @@ export const updateOrganizationAction = userAction
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// await db
|
||||
// .insert(drizzleDb.schemas.member)
|
||||
// .values(
|
||||
// usersToAdd.map((userId) => ({
|
||||
// userId,
|
||||
// organizationId: organization.id,
|
||||
// role: "member",
|
||||
// }))
|
||||
// )
|
||||
// .execute();
|
||||
}
|
||||
|
||||
if (usersToRemove.length > 0) {
|
||||
await db.delete(drizzleDb.schemas.member).where(and(inArray(drizzleDb.schemas.member.userId, usersToRemove), eq(drizzleDb.schemas.member.organizationId, organization.id))).execute();
|
||||
// TODO : Do not delete, go permission error with better auth
|
||||
// for (const userToRemove of usersToRemove) {
|
||||
//
|
||||
// const memberToRemove = await db.query.member.findFirst({
|
||||
// where: and(eq(drizzleDb.schemas.member.userId, userToRemove), eq(drizzleDb.schemas.member.organizationId, organization.id)),
|
||||
// with: {
|
||||
// user: true
|
||||
// }
|
||||
// })
|
||||
// console.log(memberToRemove)
|
||||
//
|
||||
// if (memberToRemove) {
|
||||
// console.log("ici")
|
||||
// await auth.api.removeMember({
|
||||
// body: {
|
||||
// memberIdOrEmail: memberToRemove.user.email,
|
||||
// organizationId: organization.id,
|
||||
// },
|
||||
// headers: await headers()
|
||||
// });
|
||||
// }
|
||||
//
|
||||
// }
|
||||
|
||||
|
||||
}
|
||||
|
||||
// const updatedOrganization = await auth.api.updateOrganization({
|
||||
// body: {
|
||||
// data: {
|
||||
// name: parsedInput.data.name,
|
||||
// slug: parsedInput.data.slug,
|
||||
// },
|
||||
// organizationId: organization.id,
|
||||
// },
|
||||
// headers: await headers(),
|
||||
// });
|
||||
|
||||
|
||||
const updatedOrganization = await db
|
||||
.update(drizzleDb.schemas.organization)
|
||||
.set({
|
||||
@@ -200,11 +148,24 @@ export const updateOrganizationAction = userAction
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteOrganizationAction = userAction.schema(z.string()).action(
|
||||
export const deleteOrganizationAction = userAction.schema(
|
||||
z.object({
|
||||
id: z.string().optional(),
|
||||
slug: z.string().optional(),
|
||||
})
|
||||
).action(
|
||||
async ({parsedInput, ctx}): Promise<ServerActionResult<Organization>> => {
|
||||
try {
|
||||
const conditions = [];
|
||||
if (parsedInput.id) {
|
||||
conditions.push(eq(drizzleDb.schemas.organization.id, parsedInput.id));
|
||||
}
|
||||
if (parsedInput.slug) {
|
||||
conditions.push(eq(drizzleDb.schemas.organization.slug, parsedInput.slug));
|
||||
}
|
||||
|
||||
const org = await db.query.organization.findFirst({
|
||||
where: eq(drizzleDb.schemas.organization.slug, parsedInput),
|
||||
where: or(...conditions),
|
||||
});
|
||||
|
||||
if (!org) {
|
||||
@@ -221,8 +182,6 @@ export const deleteOrganizationAction = userAction.schema(z.string()).action(
|
||||
let deletedOrganization: Organization;
|
||||
|
||||
try {
|
||||
// TODO : Improve with better auth, always getting 403 error
|
||||
// deletedOrganization = await deleteOrganization(org.id) as Organization;
|
||||
[deletedOrganization] = await db
|
||||
.delete(drizzleDb.schemas.organization)
|
||||
.where(eq(drizzleDb.schemas.organization.id, org.id))
|
||||
|
||||
@@ -12,8 +12,8 @@ import { UserSchema, UserType } from "@/components/wrappers/dashboard/profile/us
|
||||
import { toast } from "sonner";
|
||||
import { updateUserAction } from "@/components/wrappers/dashboard/profile/user-form/user-form.action";
|
||||
import {DataTable} from "@/components/wrappers/common/table/data-table";
|
||||
import {sessionsColumns} from "@/components/wrappers/dashboard/admin/admin-user-tab/sessions/table-columns";
|
||||
import {accountsColumns} from "@/components/wrappers/dashboard/admin/admin-user-tab/accounts/table-columns";
|
||||
import {sessionsColumns} from "@/components/wrappers/dashboard/admin/tabs/admin-user-tab/sessions/table-columns";
|
||||
import {accountsColumns} from "@/components/wrappers/dashboard/admin/tabs/admin-user-tab/accounts/table-columns";
|
||||
import {Session} from "better-auth";
|
||||
|
||||
export type UserFormProps = {
|
||||
|
||||
@@ -117,7 +117,6 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ButtonWithLoading
|
||||
variant="outline"
|
||||
text="Actions"
|
||||
onClick={() => {
|
||||
|
||||
}}
|
||||
@@ -125,7 +124,7 @@ export const DatabaseBackupList = (props: DatabaseBackupListProps) => {
|
||||
icon={<MoreHorizontal/>}
|
||||
isPending={mutationDeleteBackups.isPending}
|
||||
size="sm"
|
||||
/>
|
||||
>Actions</ButtonWithLoading>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
|
||||
@@ -65,14 +65,13 @@ export const DatabaseRestoreList = (props: DatabaseRestoreListProps) => {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<ButtonWithLoading
|
||||
variant="outline"
|
||||
text="Actions"
|
||||
onClick={() => {
|
||||
}}
|
||||
disabled={rows.length === 0 || mutationDeleteRestorations.isPending}
|
||||
icon={<MoreHorizontal/>}
|
||||
isPending={mutationDeleteRestorations.isPending}
|
||||
size="sm"
|
||||
/>
|
||||
>Actions</ButtonWithLoading>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
|
||||
-1
@@ -10,7 +10,6 @@ export const EditButtonSettings= (props:EditButtonSettings) => {
|
||||
|
||||
const pathname = usePathname();
|
||||
|
||||
|
||||
return(
|
||||
<Link className={buttonVariants({variant: "outline"})}
|
||||
href={`${pathname}/edit/`}
|
||||
|
||||
@@ -8,8 +8,6 @@ import {RoleSchemaMember} from "@/components/wrappers/dashboard/settings/member.
|
||||
import {headers} from "next/headers";
|
||||
|
||||
|
||||
|
||||
|
||||
export const updateMemberRoleAction = userAction.schema(
|
||||
z.object({
|
||||
memberId: z.string(),
|
||||
@@ -18,7 +16,6 @@ export const updateMemberRoleAction = userAction.schema(
|
||||
})
|
||||
).action(async ({parsedInput}): Promise<ServerActionResult<Member>> => {
|
||||
try {
|
||||
console.log(parsedInput);
|
||||
const updatedMember = await auth.api.updateMemberRole({
|
||||
body: {
|
||||
role: parsedInput.role,
|
||||
@@ -27,7 +24,6 @@ export const updateMemberRoleAction = userAction.schema(
|
||||
},
|
||||
headers: await headers(),
|
||||
});
|
||||
console.log(updatedMember);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -1,54 +1,62 @@
|
||||
"use server";
|
||||
import {userAction} from "@/lib/safe-actions/actions";
|
||||
import { z } from "zod";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { mkdir, writeFile } from "fs/promises";
|
||||
import {z} from "zod";
|
||||
import {v4 as uuidv4} from "uuid";
|
||||
import {mkdir, writeFile} from "fs/promises";
|
||||
import path from "path";
|
||||
import { env } from "@/env.mjs";
|
||||
import { checkMinioAlive, createPublicBucket, saveFileInBucket } from "@/utils/s3-file-management";
|
||||
import {env} from "@/env.mjs";
|
||||
import {checkMinioAlive, saveFileInBucket} from "@/utils/s3-file-management";
|
||||
//@ts-ignore
|
||||
import { UploadedObjectInfo } from "minio/src/internal/type";
|
||||
import { getServerUrl } from "@/utils/get-server-url";
|
||||
import { db } from "@/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {UploadedObjectInfo} from "minio/src/internal/type";
|
||||
import {getServerUrl} from "@/utils/get-server-url";
|
||||
import {db} from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {Setting} from "@/db/schema/01_setting";
|
||||
import * as drizzleDb from "@/db";
|
||||
|
||||
export const uploadImageAction = userAction.schema(z.instanceof(FormData)).action(async ({ parsedInput: formData, ctx }) => {
|
||||
const file = formData.get("file") as File;
|
||||
const uuid = uuidv4();
|
||||
const fileFormat = file.name.split(".").slice(-1)[0];
|
||||
const fileName = uuid + "." + fileFormat;
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
|
||||
if (!settings) {
|
||||
throw new Error("System settings not found.");
|
||||
}
|
||||
const privateS3ImageDir = "images/";
|
||||
|
||||
let result: void | UploadedObjectInfo;
|
||||
const bucketName = "public-image-bucket";
|
||||
|
||||
// TODO : Do not delete
|
||||
// if (settings.storage === "local") {
|
||||
// result = await uploadLocal(fileName, buffer);
|
||||
// } else if (settings.storage === "s3") {
|
||||
// result = await uploadS3Compatible(bucketName, fileName, buffer);
|
||||
// }
|
||||
result = await uploadLocal(fileName, buffer);
|
||||
export const uploadImageAction = userAction
|
||||
.schema(z.instanceof(FormData))
|
||||
.action(async ({parsedInput: formData, ctx}) => {
|
||||
const file = formData.get("file") as File;
|
||||
const uuid = uuidv4();
|
||||
const fileFormat = file.name.split(".").pop();
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
const [settings] = await db
|
||||
.select()
|
||||
.from(drizzleDb.schemas.setting)
|
||||
.where(eq(drizzleDb.schemas.setting.name, "system"))
|
||||
.limit(1);
|
||||
|
||||
if (!settings) throw new Error("System settings not found.");
|
||||
|
||||
let fileName: string | null = null;
|
||||
let result: void | UploadedObjectInfo;
|
||||
|
||||
if (settings.storage === "local") {
|
||||
fileName = `${uuid}.${fileFormat}`;
|
||||
result = await uploadLocal(fileName, buffer);
|
||||
} else if (settings.storage === "s3") {
|
||||
fileName = `${privateS3ImageDir}${uuid}.${fileFormat}`;
|
||||
result = await uploadS3Compatible(env.S3_BUCKET_NAME ?? "", fileName, buffer);
|
||||
} else {
|
||||
throw new Error(`Unsupported storage type: ${settings.storage}`);
|
||||
}
|
||||
|
||||
const url = `${getServerUrl()}/api/${fileName}`
|
||||
return {data: {result, url}};
|
||||
});
|
||||
|
||||
const url = getUrl(fileName, settings, bucketName);
|
||||
console.log(url);
|
||||
return {
|
||||
data: { result: result, url: url },
|
||||
};
|
||||
});
|
||||
|
||||
async function uploadLocal(fileName: string, buffer: any) {
|
||||
const localDir = "private/uploads/images/";
|
||||
try {
|
||||
await mkdir(path.join(process.cwd(), localDir), { recursive: true });
|
||||
await mkdir(path.join(process.cwd(), localDir), {recursive: true});
|
||||
return await writeFile(path.join(process.cwd(), localDir + fileName), buffer);
|
||||
} catch (error) {
|
||||
console.log("Error occured ", error);
|
||||
@@ -57,7 +65,6 @@ async function uploadLocal(fileName: string, buffer: any) {
|
||||
}
|
||||
|
||||
async function uploadS3Compatible(bucketName: string, fileName: string, buffer: any) {
|
||||
await createPublicBucket({ bucketName });
|
||||
return await saveFileInBucket({
|
||||
bucketName,
|
||||
fileName,
|
||||
@@ -66,20 +73,11 @@ async function uploadS3Compatible(bucketName: string, fileName: string, buffer:
|
||||
}
|
||||
|
||||
function getUrl(fileName: string, settings: Setting, bucketName: string): string {
|
||||
if (env.NODE_ENV === "production") {
|
||||
if (settings.storage === "s3") {
|
||||
return `https://${env.S3_ENDPOINT}/${bucketName}/${fileName}`;
|
||||
} else if (settings.storage === "local") {
|
||||
const url = getServerUrl();
|
||||
return `${url}/api/images/${fileName}`;
|
||||
}
|
||||
} else {
|
||||
if (settings.storage === "s3") {
|
||||
return `http://localhost:${env.S3_PORT}/${bucketName}/${fileName}`;
|
||||
} else if (settings.storage === "local") {
|
||||
const url = getServerUrl();
|
||||
return `${url}/api/images/${fileName}`;
|
||||
}
|
||||
if (settings.storage === "s3") {
|
||||
return `https://${env.S3_ENDPOINT}/${bucketName}/${fileName}`;
|
||||
} else if (settings.storage === "local") {
|
||||
const url = getServerUrl();
|
||||
return `${url}/api/images/${fileName}`;
|
||||
}
|
||||
throw new Error("Invalid storage configuration");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { createAccessControl } from "better-auth/plugins/access";
|
||||
import { defaultStatements, adminAc } from "better-auth/plugins/admin/access";
|
||||
import { defaultStatements as orgDefaultStatements, adminAc as orgAdminAc, ownerAc as orgOwnerAc, memberAc as orgMemberAc } from "better-auth/plugins/organization/access";
|
||||
import {createAccessControl} from "better-auth/plugins/access";
|
||||
import {defaultStatements, adminAc} from "better-auth/plugins/admin/access";
|
||||
import {
|
||||
defaultStatements as orgDefaultStatements,
|
||||
adminAc as orgAdminAc,
|
||||
ownerAc as orgOwnerAc,
|
||||
memberAc as orgMemberAc
|
||||
} from "better-auth/plugins/organization/access";
|
||||
|
||||
const statement = {
|
||||
...defaultStatements,
|
||||
@@ -8,20 +13,20 @@ const statement = {
|
||||
project: ["create", "list", "update", "delete"],
|
||||
database: ["create", "list", "update", "delete", "backup"],
|
||||
agent: ["create", "list", "update", "delete"],
|
||||
|
||||
} as const;
|
||||
|
||||
const ac = createAccessControl(statement);
|
||||
|
||||
|
||||
|
||||
const superadmin = ac.newRole({
|
||||
project: ["create", "list", "update", "delete"],
|
||||
database: ["create", "list", "update", "delete"],
|
||||
agent: ["create", "list", "update", "delete"],
|
||||
...adminAc.statements,
|
||||
...orgMemberAc.statements,
|
||||
...orgAdminAc.statements,
|
||||
...orgOwnerAc.statements,
|
||||
...orgMemberAc.statements,
|
||||
});
|
||||
|
||||
const admin = ac.newRole({
|
||||
@@ -29,6 +34,8 @@ const admin = ac.newRole({
|
||||
database: ["create", "list", "update", "delete"],
|
||||
agent: ["create", "list", "update", "delete"],
|
||||
...adminAc.statements,
|
||||
...orgMemberAc.statements,
|
||||
...orgAdminAc.statements,
|
||||
});
|
||||
|
||||
const user = ac.newRole({
|
||||
@@ -48,18 +55,22 @@ const orgMember = ac.newRole({
|
||||
project: ["list"],
|
||||
database: ["list"],
|
||||
agent: ["list"],
|
||||
...orgMemberAc.statements,
|
||||
});
|
||||
|
||||
const orgAdmin = ac.newRole({
|
||||
project: ["create", "update"],
|
||||
...orgMemberAc.statements,
|
||||
...orgAdminAc.statements,
|
||||
});
|
||||
|
||||
const orgOwner = ac.newRole({
|
||||
project: ["create", "update", "delete"],
|
||||
...orgMemberAc.statements,
|
||||
...orgAdminAc.statements,
|
||||
...orgOwnerAc.statements,
|
||||
...orgMemberAc.statements
|
||||
|
||||
});
|
||||
|
||||
export { ac, admin, superadmin, user, pending, orgAdmin, orgMember, orgOwner };
|
||||
|
||||
export {ac, admin, superadmin, user, pending, orgAdmin, orgMember, orgOwner};
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export type MemberRole = "member" | "admin" | "owner";
|
||||
export type MemberRoleType = MemberRole | MemberRole[];
|
||||
@@ -0,0 +1,41 @@
|
||||
import {MemberWithUser, Organization, OrganizationWithMembersAndUsers} from "@/db/schema/03_organization";
|
||||
import {OrganizationMember} from "@/db/schema/04_member";
|
||||
import {OrganizationInvitation} from "@/db/schema/05_invitation";
|
||||
import {User} from "@/db/schema/02_user";
|
||||
|
||||
|
||||
export function buildOrganizationWithMembers(
|
||||
rows: {
|
||||
organization: Organization;
|
||||
member: OrganizationMember | null;
|
||||
invitation: OrganizationInvitation | null;
|
||||
user: User | null;
|
||||
}[]
|
||||
): OrganizationWithMembersAndUsers | null {
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const org = rows[0].organization;
|
||||
|
||||
|
||||
const invitations : OrganizationInvitation[] = rows
|
||||
.filter(r => r.invitation)
|
||||
.map(r => ({
|
||||
...r.invitation!,
|
||||
}));
|
||||
|
||||
const members: MemberWithUser[] = rows
|
||||
.filter(r => r.member && r.user)
|
||||
.map(r => ({
|
||||
...r.member!,
|
||||
user: r.user!,
|
||||
}));
|
||||
|
||||
return {
|
||||
...org,
|
||||
invitations,
|
||||
members,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import internal from "node:stream";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
import stream from "node:stream";
|
||||
|
||||
|
||||
async function getS3Client() {
|
||||
@@ -158,6 +159,20 @@ export async function saveFileInBucket({bucketName, fileName, file}: {
|
||||
// }
|
||||
// return true;
|
||||
// }
|
||||
|
||||
export async function getObjectFromClient({
|
||||
bucketName,
|
||||
fileName,
|
||||
}: {
|
||||
bucketName: string;
|
||||
fileName: string;
|
||||
}): Promise<stream.Readable> {
|
||||
const s3 = await getS3Client();
|
||||
return await s3.getObject(bucketName, fileName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export async function checkFileExistsInBucket({
|
||||
bucketName,
|
||||
fileName,
|
||||
|
||||
Reference in New Issue
Block a user