feat: add organizations and members

This commit is contained in:
Mauricio Siu
2025-02-17 02:48:42 -06:00
parent c7d47a6003
commit b73e4102dd
17 changed files with 5385 additions and 329 deletions

View File

@@ -14,6 +14,7 @@ import {
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import copy from "copy-to-clipboard";
import { import {
Table, Table,
TableBody, TableBody,
@@ -132,19 +133,22 @@ export const ShowInvitations = () => {
Actions Actions
</DropdownMenuLabel> </DropdownMenuLabel>
{/* <DropdownMenuItem {invitation.status === "pending" && (
className="w-full cursor-pointer" <DropdownMenuItem
onSelect={(e) => { className="w-full cursor-pointer"
copy( onSelect={(e) => {
`${origin}/invitation?token=${user.user.token}`, copy(
); `${origin}/invitation?token=${invitation.id}`,
toast.success( );
"Invitation Copied to clipboard", toast.success(
); "Invitation Copied to clipboard",
}} );
> }}
Copy Invitation >
</DropdownMenuItem> */} Copy Invitation
</DropdownMenuItem>
)}
{invitation.status === "pending" && ( {invitation.status === "pending" && (
<DropdownMenuItem <DropdownMenuItem
className="w-full cursor-pointer" className="w-full cursor-pointer"

View File

@@ -1,3 +1,4 @@
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -23,15 +24,14 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import copy from "copy-to-clipboard"; import copy from "copy-to-clipboard";
import { format } from "date-fns"; import { format } from "date-fns";
import { MoreHorizontal, Users } from "lucide-react"; import { MoreHorizontal, Users } from "lucide-react";
import { Loader2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { AddUserPermissions } from "./add-permissions"; import { AddUserPermissions } from "./add-permissions";
import { DialogAction } from "@/components/shared/dialog-action";
import { Loader2 } from "lucide-react";
import { authClient } from "@/lib/auth-client";
export const ShowUsers = () => { export const ShowUsers = () => {
const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery();
@@ -167,8 +167,7 @@ export const ShowUsers = () => {
const { error } = const { error } =
await authClient.organization.removeMember( await authClient.organization.removeMember(
{ {
memberIdOrEmail: memberIdOrEmail: member.id,
member.user.id,
}, },
); );

View File

@@ -78,7 +78,6 @@ import { UserNav } from "./user-nav";
// The types of the queries we are going to use // The types of the queries we are going to use
type AuthQueryOutput = inferRouterOutputs<AppRouter>["auth"]["get"]; type AuthQueryOutput = inferRouterOutputs<AppRouter>["auth"]["get"];
type UserQueryOutput = inferRouterOutputs<AppRouter>["user"]["byAuthId"];
type SingleNavItem = { type SingleNavItem = {
isSingle?: true; isSingle?: true;
@@ -87,7 +86,6 @@ type SingleNavItem = {
icon?: LucideIcon; icon?: LucideIcon;
isEnabled?: (opts: { isEnabled?: (opts: {
auth?: AuthQueryOutput; auth?: AuthQueryOutput;
user?: UserQueryOutput;
isCloud: boolean; isCloud: boolean;
}) => boolean; }) => boolean;
}; };
@@ -105,7 +103,6 @@ type NavItem =
items: SingleNavItem[]; items: SingleNavItem[];
isEnabled?: (opts: { isEnabled?: (opts: {
auth?: AuthQueryOutput; auth?: AuthQueryOutput;
user?: UserQueryOutput;
isCloud: boolean; isCloud: boolean;
}) => boolean; }) => boolean;
}; };
@@ -118,7 +115,6 @@ type ExternalLink = {
icon: React.ComponentType<{ className?: string }>; icon: React.ComponentType<{ className?: string }>;
isEnabled?: (opts: { isEnabled?: (opts: {
auth?: AuthQueryOutput; auth?: AuthQueryOutput;
user?: UserQueryOutput;
isCloud: boolean; isCloud: boolean;
}) => boolean; }) => boolean;
}; };
@@ -149,7 +145,7 @@ const MENU: Menu = {
url: "/dashboard/monitoring", url: "/dashboard/monitoring",
icon: BarChartHorizontalBigIcon, icon: BarChartHorizontalBigIcon,
// Only enabled in non-cloud environments // Only enabled in non-cloud environments
isEnabled: ({ auth, user, isCloud }) => !isCloud, isEnabled: ({ auth, isCloud }) => !isCloud,
}, },
{ {
isSingle: true, isSingle: true,
@@ -157,9 +153,9 @@ const MENU: Menu = {
url: "/dashboard/traefik", url: "/dashboard/traefik",
icon: GalleryVerticalEnd, icon: GalleryVerticalEnd,
// Only enabled for admins and users with access to Traefik files in non-cloud environments // Only enabled for admins and users with access to Traefik files in non-cloud environments
isEnabled: ({ auth, user, isCloud }) => isEnabled: ({ auth, isCloud }) =>
!!( !!(
(auth?.role === "owner" || user?.canAccessToTraefikFiles) && (auth?.role === "owner" || auth?.user?.canAccessToTraefikFiles) &&
!isCloud !isCloud
), ),
}, },
@@ -169,8 +165,11 @@ const MENU: Menu = {
url: "/dashboard/docker", url: "/dashboard/docker",
icon: BlocksIcon, icon: BlocksIcon,
// Only enabled for admins and users with access to Docker in non-cloud environments // Only enabled for admins and users with access to Docker in non-cloud environments
isEnabled: ({ auth, user, isCloud }) => isEnabled: ({ auth, isCloud }) =>
!!((auth?.role === "owner" || user?.canAccessToDocker) && !isCloud), !!(
(auth?.role === "owner" || auth?.user?.canAccessToDocker) &&
!isCloud
),
}, },
{ {
isSingle: true, isSingle: true,
@@ -178,8 +177,11 @@ const MENU: Menu = {
url: "/dashboard/swarm", url: "/dashboard/swarm",
icon: PieChart, icon: PieChart,
// Only enabled for admins and users with access to Docker in non-cloud environments // Only enabled for admins and users with access to Docker in non-cloud environments
isEnabled: ({ auth, user, isCloud }) => isEnabled: ({ auth, isCloud }) =>
!!((auth?.role === "owner" || user?.canAccessToDocker) && !isCloud), !!(
(auth?.role === "owner" || auth?.user?.canAccessToDocker) &&
!isCloud
),
}, },
{ {
isSingle: true, isSingle: true,
@@ -187,8 +189,11 @@ const MENU: Menu = {
url: "/dashboard/requests", url: "/dashboard/requests",
icon: Forward, icon: Forward,
// Only enabled for admins and users with access to Docker in non-cloud environments // Only enabled for admins and users with access to Docker in non-cloud environments
isEnabled: ({ auth, user, isCloud }) => isEnabled: ({ auth, isCloud }) =>
!!((auth?.role === "owner" || user?.canAccessToDocker) && !isCloud), !!(
(auth?.role === "owner" || auth?.user?.canAccessToDocker) &&
!isCloud
),
}, },
// Legacy unused menu, adjusted to the new structure // Legacy unused menu, adjusted to the new structure
@@ -255,8 +260,7 @@ const MENU: Menu = {
url: "/dashboard/settings/server", url: "/dashboard/settings/server",
icon: Activity, icon: Activity,
// Only enabled for admins in non-cloud environments // Only enabled for admins in non-cloud environments
isEnabled: ({ auth, user, isCloud }) => isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && !isCloud),
!!(auth?.role === "owner" && !isCloud),
}, },
{ {
isSingle: true, isSingle: true,
@@ -270,7 +274,7 @@ const MENU: Menu = {
url: "/dashboard/settings/servers", url: "/dashboard/settings/servers",
icon: Server, icon: Server,
// Only enabled for admins // Only enabled for admins
isEnabled: ({ auth, user, isCloud }) => !!(auth?.role === "owner"), isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner"),
}, },
{ {
isSingle: true, isSingle: true,
@@ -278,7 +282,7 @@ const MENU: Menu = {
icon: Users, icon: Users,
url: "/dashboard/settings/users", url: "/dashboard/settings/users",
// Only enabled for admins // Only enabled for admins
isEnabled: ({ auth, user, isCloud }) => !!(auth?.role === "owner"), isEnabled: ({ auth }) => !!(auth?.role === "owner"),
}, },
{ {
isSingle: true, isSingle: true,
@@ -286,8 +290,8 @@ const MENU: Menu = {
icon: KeyRound, icon: KeyRound,
url: "/dashboard/settings/ssh-keys", url: "/dashboard/settings/ssh-keys",
// Only enabled for admins and users with access to SSH keys // Only enabled for admins and users with access to SSH keys
isEnabled: ({ auth, user }) => isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || user?.canAccessToSSHKeys), !!(auth?.role === "owner" || auth?.user?.canAccessToSSHKeys),
}, },
{ {
isSingle: true, isSingle: true,
@@ -295,8 +299,8 @@ const MENU: Menu = {
url: "/dashboard/settings/git-providers", url: "/dashboard/settings/git-providers",
icon: GitBranch, icon: GitBranch,
// Only enabled for admins and users with access to Git providers // Only enabled for admins and users with access to Git providers
isEnabled: ({ auth, user }) => isEnabled: ({ auth }) =>
!!(auth?.role === "owner" || user?.canAccessToGitProviders), !!(auth?.role === "owner" || auth?.user?.canAccessToGitProviders),
}, },
{ {
isSingle: true, isSingle: true,
@@ -304,7 +308,7 @@ const MENU: Menu = {
url: "/dashboard/settings/registry", url: "/dashboard/settings/registry",
icon: Package, icon: Package,
// Only enabled for admins // Only enabled for admins
isEnabled: ({ auth, user, isCloud }) => !!(auth?.role === "owner"), isEnabled: ({ auth }) => !!(auth?.role === "owner"),
}, },
{ {
isSingle: true, isSingle: true,
@@ -312,7 +316,7 @@ const MENU: Menu = {
url: "/dashboard/settings/destinations", url: "/dashboard/settings/destinations",
icon: Database, icon: Database,
// Only enabled for admins // Only enabled for admins
isEnabled: ({ auth, user, isCloud }) => !!(auth?.role === "owner"), isEnabled: ({ auth }) => !!(auth?.role === "owner"),
}, },
{ {
@@ -321,7 +325,7 @@ const MENU: Menu = {
url: "/dashboard/settings/certificates", url: "/dashboard/settings/certificates",
icon: ShieldCheck, icon: ShieldCheck,
// Only enabled for admins // Only enabled for admins
isEnabled: ({ auth, user, isCloud }) => !!(auth?.role === "owner"), isEnabled: ({ auth }) => !!(auth?.role === "owner"),
}, },
{ {
isSingle: true, isSingle: true,
@@ -329,8 +333,7 @@ const MENU: Menu = {
url: "/dashboard/settings/cluster", url: "/dashboard/settings/cluster",
icon: Boxes, icon: Boxes,
// Only enabled for admins in non-cloud environments // Only enabled for admins in non-cloud environments
isEnabled: ({ auth, user, isCloud }) => isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && !isCloud),
!!(auth?.role === "owner" && !isCloud),
}, },
{ {
isSingle: true, isSingle: true,
@@ -338,7 +341,7 @@ const MENU: Menu = {
url: "/dashboard/settings/notifications", url: "/dashboard/settings/notifications",
icon: Bell, icon: Bell,
// Only enabled for admins // Only enabled for admins
isEnabled: ({ auth, user, isCloud }) => !!(auth?.role === "owner"), isEnabled: ({ auth }) => !!(auth?.role === "owner"),
}, },
{ {
isSingle: true, isSingle: true,
@@ -346,8 +349,7 @@ const MENU: Menu = {
url: "/dashboard/settings/billing", url: "/dashboard/settings/billing",
icon: CreditCard, icon: CreditCard,
// Only enabled for admins in cloud environments // Only enabled for admins in cloud environments
isEnabled: ({ auth, user, isCloud }) => isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && isCloud),
!!(auth?.role === "owner" && isCloud),
}, },
], ],
@@ -383,7 +385,6 @@ const MENU: Menu = {
*/ */
function createMenuForAuthUser(opts: { function createMenuForAuthUser(opts: {
auth?: AuthQueryOutput; auth?: AuthQueryOutput;
user?: UserQueryOutput;
isCloud: boolean; isCloud: boolean;
}): Menu { }): Menu {
return { return {
@@ -394,7 +395,6 @@ function createMenuForAuthUser(opts: {
? true ? true
: item.isEnabled({ : item.isEnabled({
auth: opts.auth, auth: opts.auth,
user: opts.user,
isCloud: opts.isCloud, isCloud: opts.isCloud,
}), }),
), ),
@@ -405,7 +405,6 @@ function createMenuForAuthUser(opts: {
? true ? true
: item.isEnabled({ : item.isEnabled({
auth: opts.auth, auth: opts.auth,
user: opts.user,
isCloud: opts.isCloud, isCloud: opts.isCloud,
}), }),
), ),
@@ -416,7 +415,6 @@ function createMenuForAuthUser(opts: {
? true ? true
: item.isEnabled({ : item.isEnabled({
auth: opts.auth, auth: opts.auth,
user: opts.user,
isCloud: opts.isCloud, isCloud: opts.isCloud,
}), }),
), ),
@@ -525,10 +523,12 @@ const data = {
], ],
}; };
const teams = data.teams;
function SidebarLogo() { function SidebarLogo() {
const { state } = useSidebar(); const { state } = useSidebar();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: user } = api.auth.get.useQuery();
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery(); const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
const { data: session } = authClient.useSession();
const { const {
data: organizations, data: organizations,
refetch, refetch,
@@ -617,42 +617,51 @@ function SidebarLogo() {
/> />
</div> </div>
{org.name} {org.name}
{/* <DropdownMenuShortcut>⌘{index + 1}</DropdownMenuShortcut> */}
</DropdownMenuItem> </DropdownMenuItem>
{/* <DropdownMenuSeparator /> */} {(org.ownerId === session?.user?.id || isCloud) && (
<div className="flex flex-row gap-2"> <div className="flex items-center gap-2">
<AddOrganization organizationId={org.id} /> <AddOrganization organizationId={org.id} />
<DialogAction <DialogAction
title="Delete Organization" title="Delete Organization"
description="Are you sure you want to delete this organization?" description="Are you sure you want to delete this organization?"
type="destructive" type="destructive"
onClick={async () => { onClick={async () => {
await deleteOrganization({ await deleteOrganization({
organizationId: org.id, organizationId: org.id,
})
.then(() => {
refetch();
toast.success("Port deleted successfully");
}) })
.catch(() => { .then(() => {
toast.error("Error deleting port"); refetch();
}); toast.success(
}} "Organization deleted successfully",
> );
<Button })
variant="ghost" .catch((error) => {
size="icon" toast.error(
className="group hover:bg-red-500/10 " error?.message ||
isLoading={isRemoving} "Error deleting organization",
);
});
}}
> >
<Trash2 className="size-4 text-primary group-hover:text-red-500" /> <Button
</Button> variant="ghost"
</DialogAction> size="icon"
</div> className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</div>
)}
</div> </div>
))} ))}
<DropdownMenuSeparator /> {!isCloud && user?.role === "owner" && (
<AddOrganization /> <>
<DropdownMenuSeparator />
<AddOrganization />
</>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</SidebarMenuItem> </SidebarMenuItem>
@@ -706,14 +715,6 @@ export default function Page({ children }: Props) {
const pathname = usePathname(); const pathname = usePathname();
const currentPath = router.pathname; const currentPath = router.pathname;
const { data: auth } = api.auth.get.useQuery(); const { data: auth } = api.auth.get.useQuery();
const { data: user } = api.user.byAuthId.useQuery(
{
authId: auth?.id || "",
},
{
enabled: !!auth?.id && auth?.role === "member",
},
);
const includesProjects = pathname?.includes("/dashboard/project"); const includesProjects = pathname?.includes("/dashboard/project");
const { data: isCloud, isLoading } = api.settings.isCloud.useQuery(); const { data: isCloud, isLoading } = api.settings.isCloud.useQuery();
@@ -722,7 +723,7 @@ export default function Page({ children }: Props) {
home: filteredHome, home: filteredHome,
settings: filteredSettings, settings: filteredSettings,
help, help,
} = createMenuForAuthUser({ auth, user, isCloud: !!isCloud }); } = createMenuForAuthUser({ auth, isCloud: !!isCloud });
const activeItem = findActiveNavItem( const activeItem = findActiveNavItem(
[...filteredHome, ...filteredSettings], [...filteredHome, ...filteredSettings],

View File

@@ -32,14 +32,7 @@ export const UserNav = () => {
const router = useRouter(); const router = useRouter();
const { data } = api.auth.get.useQuery(); const { data } = api.auth.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: user } = api.user.byAuthId.useQuery(
{
authId: data?.id || "",
},
{
enabled: !!data?.id && data?.role === "member",
},
);
const { locale, setLocale } = useLocale(); const { locale, setLocale } = useLocale();
// const { mutateAsync } = api.auth.logout.useMutation(); // const { mutateAsync } = api.auth.logout.useMutation();
@@ -99,7 +92,8 @@ export const UserNav = () => {
> >
Monitoring Monitoring
</DropdownMenuItem> </DropdownMenuItem>
{(data?.role === "owner" || user?.canAccessToTraefikFiles) && ( {(data?.role === "owner" ||
data?.user?.canAccessToTraefikFiles) && (
<DropdownMenuItem <DropdownMenuItem
className="cursor-pointer" className="cursor-pointer"
onClick={() => { onClick={() => {
@@ -109,7 +103,7 @@ export const UserNav = () => {
Traefik Traefik
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{(data?.role === "owner" || user?.canAccessToDocker) && ( {(data?.role === "owner" || data?.user?.canAccessToDocker) && (
<DropdownMenuItem <DropdownMenuItem
className="cursor-pointer" className="cursor-pointer"
onClick={() => { onClick={() => {

View File

@@ -0,0 +1,3 @@
ALTER TABLE "session_temp" DROP CONSTRAINT "session_temp_user_id_user_temp_id_fk";
--> statement-breakpoint
ALTER TABLE "session_temp" ADD CONSTRAINT "session_temp_user_id_user_temp_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user_temp"("id") ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load Diff

View File

@@ -526,6 +526,13 @@
"when": 1739773539709, "when": 1739773539709,
"tag": "0074_lowly_jack_power", "tag": "0074_lowly_jack_power",
"breakpoints": true "breakpoints": true
},
{
"idx": 75,
"version": "7",
"when": 1739781534192,
"tag": "0075_heavy_metal_master",
"breakpoints": true
} }
] ]
} }

View File

@@ -28,6 +28,8 @@ export async function getServerSideProps(
) { ) {
const { req, res } = ctx; const { req, res } = ctx;
const { user, session } = await validateRequest(req); const { user, session } = await validateRequest(req);
console.log("user", user, session);
if (!user || user.role === "member") { if (!user || user.role === "member") {
return { return {
redirect: { redirect: {

View File

@@ -1,4 +1,5 @@
import { OnboardingLayout } from "@/components/layouts/onboarding-layout"; import { OnboardingLayout } from "@/components/layouts/onboarding-layout";
import { AlertBlock } from "@/components/shared/alert-block";
import { Logo } from "@/components/shared/logo"; import { Logo } from "@/components/shared/logo";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -16,21 +17,24 @@ import {
FormMessage, FormMessage,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { IS_CLOUD, getUserByToken } from "@dokploy/server"; import { IS_CLOUD, getUserByToken } from "@dokploy/server";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { AlertTriangle } from "lucide-react"; import { AlertCircle, AlertTriangle } from "lucide-react";
import type { GetServerSidePropsContext } from "next"; import type { GetServerSidePropsContext } from "next";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { type ReactElement, useEffect } from "react"; import { type ReactElement, useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import superjson from "superjson";
import { z } from "zod"; import { z } from "zod";
const registerSchema = z const registerSchema = z
.object({ .object({
name: z.string().min(1, {
message: "Name is required",
}),
email: z email: z
.string() .string()
.min(1, { .min(1, {
@@ -39,7 +43,6 @@ const registerSchema = z
.email({ .email({
message: "Email must be a valid email", message: "Email must be a valid email",
}), }),
password: z password: z
.string() .string()
.min(1, { .min(1, {
@@ -72,9 +75,15 @@ interface Props {
token: string; token: string;
invitation: Awaited<ReturnType<typeof getUserByToken>>; invitation: Awaited<ReturnType<typeof getUserByToken>>;
isCloud: boolean; isCloud: boolean;
userAlreadyExists: boolean;
} }
const Invitation = ({ token, invitation, isCloud }: Props) => { const Invitation = ({
token,
invitation,
isCloud,
userAlreadyExists,
}: Props) => {
const router = useRouter(); const router = useRouter();
const { data } = api.admin.getUserByToken.useQuery( const { data } = api.admin.getUserByToken.useQuery(
{ {
@@ -91,6 +100,7 @@ const Invitation = ({ token, invitation, isCloud }: Props) => {
const form = useForm<Register>({ const form = useForm<Register>({
defaultValues: { defaultValues: {
name: "",
email: "", email: "",
password: "", password: "",
confirmPassword: "", confirmPassword: "",
@@ -109,20 +119,32 @@ const Invitation = ({ token, invitation, isCloud }: Props) => {
}, [form, form.reset, form.formState.isSubmitSuccessful, data]); }, [form, form.reset, form.formState.isSubmitSuccessful, data]);
const onSubmit = async (values: Register) => { const onSubmit = async (values: Register) => {
await mutateAsync({ try {
id: data?.id, const { data, error } = await authClient.signUp.email({
password: values.password, email: values.email,
token: token, password: values.password,
}) name: values.name,
.then(() => { fetchOptions: {
toast.success("User registered successfuly", { headers: {
description: "x-dokploy-token": token,
"Please check your inbox or spam folder to confirm your account.", },
duration: 100000, },
}); });
router.push("/dashboard/projects");
}) if (error) {
.catch((e) => e); toast.error(error.message);
return;
}
const result = await authClient.organization.acceptInvitation({
invitationId: token,
});
toast.success("Account created successfully");
router.push("/dashboard/projects");
} catch (error) {
toast.error("An error occurred while creating your account");
}
}; };
return ( return (
@@ -139,114 +161,155 @@ const Invitation = ({ token, invitation, isCloud }: Props) => {
</Link> </Link>
Invitation Invitation
</CardTitle> </CardTitle>
<CardDescription> {userAlreadyExists ? (
Fill the form below to create your account <div className="flex flex-col gap-4 justify-center items-center">
</CardDescription> <AlertBlock type="success">
<div className="w-full"> <div className="flex flex-col gap-2">
<div className="p-3" /> <span className="font-medium">Valid Invitation!</span>
<span className="text-sm text-green-600 dark:text-green-400">
We detected that you already have an account with this
email. Please sign in to accept the invitation.
</span>
</div>
</AlertBlock>
{isError && ( <Button asChild variant="default" className="w-full">
<div className="mx-5 my-2 flex flex-row items-center gap-2 rounded-lg bg-red-50 p-2 dark:bg-red-950"> <Link href="/">Sign In</Link>
<AlertTriangle className="text-red-600 dark:text-red-400" /> </Button>
<span className="text-sm text-red-600 dark:text-red-400"> </div>
{error?.message} ) : (
</span> <>
</div> <CardDescription>
)} Fill the form below to create your account
</CardDescription>
<div className="w-full">
<div className="p-3" />
<CardContent className="p-0"> {isError && (
<Form {...form}> <div className="mx-5 my-2 flex flex-row items-center gap-2 rounded-lg bg-red-50 p-2 dark:bg-red-950">
<form <AlertTriangle className="text-red-600 dark:text-red-400" />
onSubmit={form.handleSubmit(onSubmit)} <span className="text-sm text-red-600 dark:text-red-400">
className="grid gap-4" {error?.message}
> </span>
<div className="space-y-4"> </div>
<FormField )}
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input disabled placeholder="Email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField <CardContent className="p-0">
control={form.control} <Form {...form}>
name="confirmPassword" <form
render={({ field }) => ( onSubmit={form.handleSubmit(onSubmit)}
<FormItem> className="grid gap-4"
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Confirm Password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
isLoading={form.formState.isSubmitting}
className="w-full"
> >
Register <div className="space-y-4">
</Button> <FormField
</div> control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
placeholder="Enter your name"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
disabled
placeholder="Email"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="mt-4 text-sm flex flex-row justify-between gap-2 w-full"> <FormField
{isCloud && ( control={form.control}
<> name="confirmPassword"
<Link render={({ field }) => (
className="hover:underline text-muted-foreground" <FormItem>
href="/" <FormLabel>Confirm Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Confirm Password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
isLoading={form.formState.isSubmitting}
className="w-full"
> >
Login Register
</Link> </Button>
<Link </div>
className="hover:underline text-muted-foreground"
href="/send-reset-password" <div className="mt-4 text-sm flex flex-row justify-between gap-2 w-full">
> {isCloud && (
Lost your password? <>
</Link> <Link
</> className="hover:underline text-muted-foreground"
)} href="/"
</div> >
</form> Login
</Form> </Link>
</CardContent> <Link
</div> className="hover:underline text-muted-foreground"
href="/send-reset-password"
>
Lost your password?
</Link>
</>
)}
</div>
</form>
</Form>
</CardContent>
</div>
</>
)}
</div> </div>
</div> </div>
</div> </div>
); );
}; };
// http://localhost:3000/invitation?token=CZK4BLrUdMa32RVkAdZiLsPDdvnPiAgZ
// /f7af93acc1a99eae864972ab4c92fee089f0d83473d415ede8e821e5dbabe79c
export default Invitation; export default Invitation;
Invitation.getLayout = (page: ReactElement) => { Invitation.getLayout = (page: ReactElement) => {
return <OnboardingLayout>{page}</OnboardingLayout>; return <OnboardingLayout>{page}</OnboardingLayout>;
@@ -268,7 +331,17 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
try { try {
const invitation = await getUserByToken(token); const invitation = await getUserByToken(token);
console.log("invitation", invitation);
if (invitation.userAlreadyExists) {
return {
props: {
isCloud: IS_CLOUD,
token: token,
invitation: invitation,
userAlreadyExists: true,
},
};
}
if (invitation.isExpired) { if (invitation.isExpired) {
return { return {
@@ -287,6 +360,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
}, },
}; };
} catch (error) { } catch (error) {
console.log("error", error);
return { return {
redirect: { redirect: {
permanent: true, permanent: true,

View File

@@ -9,7 +9,7 @@ import {
import { import {
IS_CLOUD, IS_CLOUD,
createInvitation, createInvitation,
findUserByAuthId, findOrganizationById,
findUserById, findUserById,
getUserByToken, getUserByToken,
removeUserById, removeUserById,
@@ -98,21 +98,20 @@ export const adminRouter = createTRPCRouter({
try { try {
const user = await findUserById(input.id); const user = await findUserById(input.id);
if (user.id !== ctx.user.ownerId) { const organization = await findOrganizationById(
ctx.session?.activeOrganizationId || "",
);
if (organization?.ownerId !== ctx.user.ownerId) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "You are not allowed to assign permissions", message: "You are not allowed to assign permissions",
}); });
} }
await updateUser(user.id, { await updateUser(user.id, {
...input, ...input,
}); });
// await db
// .update(users)
// .set({
// ...input,
// })
// .where(eq(users.userId, input.userId));
} catch (error) { } catch (error) {
throw error; throw error;
} }

View File

@@ -1,29 +1,43 @@
import { db } from "@/server/db"; import { db } from "@/server/db";
import { invitation, member, organization } from "@/server/db/schema"; import {
invitation,
member,
organization,
users_temp,
} from "@/server/db/schema";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { desc, eq } from "drizzle-orm"; import { and, desc, eq, exists } from "drizzle-orm";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { z } from "zod"; import { z } from "zod";
import { adminProcedure, createTRPCRouter } from "../trpc"; import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc";
import { auth, IS_CLOUD } from "@dokploy/server/index";
export const organizationRouter = createTRPCRouter({ export const organizationRouter = createTRPCRouter({
create: adminProcedure create: protectedProcedure
.input( .input(
z.object({ z.object({
name: z.string(), name: z.string(),
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
if (ctx.user.rol !== "owner" && !IS_CLOUD) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only the organization owner can create an organization",
});
}
const result = await db const result = await db
.insert(organization) .insert(organization)
.values({ .values({
...input, ...input,
slug: nanoid(), slug: nanoid(),
createdAt: new Date(), createdAt: new Date(),
ownerId: ctx.user.ownerId, ownerId: ctx.user.id,
}) })
.returning() .returning()
.then((res) => res[0]); .then((res) => res[0]);
console.log("result", result);
if (!result) { if (!result) {
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
@@ -39,13 +53,24 @@ export const organizationRouter = createTRPCRouter({
}); });
return result; return result;
}), }),
all: adminProcedure.query(async ({ ctx }) => { all: protectedProcedure.query(async ({ ctx }) => {
return await db.query.organization.findMany({ const memberResult = await db.query.organization.findMany({
where: eq(organization.ownerId, ctx.user.ownerId), where: (organization) =>
orderBy: [desc(organization.createdAt)], exists(
db
.select()
.from(member)
.where(
and(
eq(member.organizationId, organization.id),
eq(member.userId, ctx.user.id),
),
),
),
}); });
return memberResult;
}), }),
one: adminProcedure one: protectedProcedure
.input( .input(
z.object({ z.object({
organizationId: z.string(), organizationId: z.string(),
@@ -56,7 +81,7 @@ export const organizationRouter = createTRPCRouter({
where: eq(organization.id, input.organizationId), where: eq(organization.id, input.organizationId),
}); });
}), }),
update: adminProcedure update: protectedProcedure
.input( .input(
z.object({ z.object({
organizationId: z.string(), organizationId: z.string(),
@@ -64,6 +89,12 @@ export const organizationRouter = createTRPCRouter({
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
if (ctx.user.rol !== "owner" && !IS_CLOUD) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only the organization owner can update it",
});
}
const result = await db const result = await db
.update(organization) .update(organization)
.set({ name: input.name }) .set({ name: input.name })
@@ -71,16 +102,41 @@ export const organizationRouter = createTRPCRouter({
.returning(); .returning();
return result[0]; return result[0];
}), }),
delete: adminProcedure delete: protectedProcedure
.input( .input(
z.object({ z.object({
organizationId: z.string(), organizationId: z.string(),
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
if (ctx.user.rol !== "owner" && !IS_CLOUD) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only the organization owner can delete it",
});
}
const org = await db.query.organization.findFirst({
where: eq(organization.id, input.organizationId),
});
if (!org) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Organization not found",
});
}
if (org.ownerId !== ctx.user.id) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only the organization owner can delete it",
});
}
const result = await db const result = await db
.delete(organization) .delete(organization)
.where(eq(organization.id, input.organizationId)); .where(eq(organization.id, input.organizationId));
return result; return result;
}), }),
allInvitations: adminProcedure.query(async ({ ctx }) => { allInvitations: adminProcedure.query(async ({ ctx }) => {
@@ -89,4 +145,13 @@ export const organizationRouter = createTRPCRouter({
orderBy: [desc(invitation.status)], orderBy: [desc(invitation.status)],
}); });
}), }),
acceptInvitation: adminProcedure
.input(z.object({ invitationId: z.string() }))
.mutation(async ({ ctx, input }) => {
const result = await auth.api.acceptInvitation({
invitationId: input.invitationId,
});
return result;
}),
}); });

View File

@@ -1,8 +1,8 @@
import { apiFindOneUser, apiFindOneUserByAuth } from "@/server/db/schema"; import { apiFindOneUser, apiFindOneUserByAuth } from "@/server/db/schema";
import { import {
IS_CLOUD,
findUserByAuthId, findUserByAuthId,
findUserById, findUserById,
IS_CLOUD,
removeUserById, removeUserById,
updateUser, updateUser,
verify2FA, verify2FA,

View File

@@ -71,6 +71,7 @@ export const organizationRelations = relations(
}), }),
servers: many(server), servers: many(server),
projects: many(projects), projects: many(projects),
members: many(member),
}), }),
); );

View File

@@ -12,7 +12,7 @@ export const session = pgTable("session_temp", {
userAgent: text("user_agent"), userAgent: text("user_agent"),
userId: text("user_id") userId: text("user_id")
.notNull() .notNull()
.references(() => users_temp.id), .references(() => users_temp.id, { onDelete: "cascade" }),
impersonatedBy: text("impersonated_by"), impersonatedBy: text("impersonated_by"),
activeOrganizationId: text("active_organization_id"), activeOrganizationId: text("active_organization_id"),
}); });

View File

@@ -7,7 +7,7 @@ import {
organization, organization,
twoFactor, twoFactor,
} from "better-auth/plugins"; } from "better-auth/plugins";
import { desc, eq } from "drizzle-orm"; import { and, desc, eq } from "drizzle-orm";
import { db } from "../db"; import { db } from "../db";
import * as schema from "../db/schema"; import * as schema from "../db/schema";
@@ -43,22 +43,25 @@ export const auth = betterAuth({
after: createAuthMiddleware(async (ctx) => { after: createAuthMiddleware(async (ctx) => {
if (ctx.path.startsWith("/sign-up")) { if (ctx.path.startsWith("/sign-up")) {
const newSession = ctx.context.newSession; const newSession = ctx.context.newSession;
const organization = await db if (ctx.headers?.get("x-dokploy-token")) {
.insert(schema.organization) } else {
.values({ const organization = await db
name: "My Organization", .insert(schema.organization)
ownerId: newSession?.user?.id || "", .values({
createdAt: new Date(), name: "My Organization",
}) ownerId: newSession?.user?.id || "",
.returning() createdAt: new Date(),
.then((res) => res[0]); })
.returning()
.then((res) => res[0]);
await db.insert(schema.member).values({ await db.insert(schema.member).values({
userId: newSession?.user?.id || "", userId: newSession?.user?.id || "",
organizationId: organization?.id || "", organizationId: organization?.id || "",
role: "owner", role: "owner",
createdAt: new Date(), createdAt: new Date(),
}); });
}
} }
}), }),
}, },
@@ -89,11 +92,13 @@ export const auth = betterAuth({
additionalFields: { additionalFields: {
role: { role: {
type: "string", type: "string",
required: true, // required: true,
input: false,
}, },
ownerId: { ownerId: {
type: "string", type: "string",
required: true, // required: true,
input: false,
}, },
}, },
}, },
@@ -133,7 +138,13 @@ export const validateRequest = async (request: IncomingMessage) => {
if (session?.user) { if (session?.user) {
const member = await db.query.member.findFirst({ const member = await db.query.member.findFirst({
where: eq(schema.member.userId, session.user.id), where: and(
eq(schema.member.userId, session.user.id),
eq(
schema.member.organizationId,
session.session.activeOrganizationId || "",
),
),
with: { with: {
organization: true, organization: true,
}, },

View File

@@ -3,6 +3,7 @@ import { db } from "@dokploy/server/db";
import { import {
account, account,
type apiCreateUserInvitation, type apiCreateUserInvitation,
invitation,
member, member,
organization, organization,
users_temp, users_temp,
@@ -64,6 +65,13 @@ export const findUserById = async (userId: string) => {
return user; return user;
}; };
export const findOrganizationById = async (organizationId: string) => {
const organizationResult = await db.query.organization.findFirst({
where: eq(organization.id, organizationId),
});
return organizationResult;
};
export const updateUser = async (userId: string, userData: Partial<User>) => { export const updateUser = async (userId: string, userData: Partial<User>) => {
const user = await db const user = await db
.update(users_temp) .update(users_temp)
@@ -106,24 +114,34 @@ export const isAdminPresent = async () => {
}; };
export const getUserByToken = async (token: string) => { export const getUserByToken = async (token: string) => {
const user = await db.query.users_temp.findFirst({ const user = await db.query.invitation.findFirst({
where: eq(users_temp.token, token), where: eq(invitation.id, token),
columns: { columns: {
id: true, id: true,
email: true, email: true,
token: true, status: true,
isRegistered: true, expiresAt: true,
role: true,
inviterId: true,
}, },
}); });
if (!user) { if (!user) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
message: "Invitation not found", message: "Invitation not found",
}); });
} }
const userAlreadyExists = await db.query.users_temp.findFirst({
where: eq(users_temp.email, user?.email || ""),
});
const { expiresAt, ...rest } = user;
return { return {
...user, ...rest,
isExpired: user.isRegistered, isExpired: user.expiresAt < new Date(),
userAlreadyExists: !!userAlreadyExists,
}; };
}; };

View File

@@ -137,76 +137,76 @@ export const symmetricDecrypt = async ({ key, data }) => {
const chacha = managedNonce(xchacha20poly1305)(new Uint8Array(keyAsBytes)); const chacha = managedNonce(xchacha20poly1305)(new Uint8Array(keyAsBytes));
return new TextDecoder().decode(chacha.decrypt(dataAsBytes)); return new TextDecoder().decode(chacha.decrypt(dataAsBytes));
}; };
export const migrateExistingSecret = async ( // export const migrateExistingSecret = async (
existingBase32Secret: string, // existingBase32Secret: string,
encryptionKey: string, // encryptionKey: string,
) => { // ) => {
try { // try {
// 1. Primero asegurarnos que el secreto base32 tenga el padding correcto // // 1. Primero asegurarnos que el secreto base32 tenga el padding correcto
let paddedSecret = existingBase32Secret; // let paddedSecret = existingBase32Secret;
while (paddedSecret.length % 8 !== 0) { // while (paddedSecret.length % 8 !== 0) {
paddedSecret += "="; // paddedSecret += "=";
} // }
// 2. Decodificar el base32 a bytes usando hi-base32 // // 2. Decodificar el base32 a bytes usando hi-base32
const bytes = encode.decode.asBytes(paddedSecret.toUpperCase()); // const bytes = encode.decode.asBytes(paddedSecret.toUpperCase());
// 3. Convertir los bytes a hex // // 3. Convertir los bytes a hex
const hexSecret = Buffer.from(bytes).toString("hex"); // const hexSecret = Buffer.from(bytes).toString("hex");
// 4. Encriptar el secreto hex usando Better Auth // // 4. Encriptar el secreto hex usando Better Auth
const encryptedSecret = await symmetricEncrypt({ // const encryptedSecret = await symmetricEncrypt({
key: encryptionKey, // key: encryptionKey,
data: hexSecret, // data: hexSecret,
}); // });
// 5. Crear TOTP con el secreto original para validación // // 5. Crear TOTP con el secreto original para validación
const originalTotp = new TOTP({ // const originalTotp = new TOTP({
issuer: "Dokploy", // issuer: "Dokploy",
label: "migration-test", // label: "migration-test",
algorithm: "SHA1", // algorithm: "SHA1",
digits: 6, // digits: 6,
secret: existingBase32Secret, // secret: existingBase32Secret,
}); // });
// 6. Generar un código de prueba con el secreto original // // 6. Generar un código de prueba con el secreto original
const testCode = originalTotp.generate(); // const testCode = originalTotp.generate();
// 7. Validar que el código funcione con el secreto original // // 7. Validar que el código funcione con el secreto original
const isValid = originalTotp.validate({ token: testCode }) !== null; // const isValid = originalTotp.validate({ token: testCode }) !== null;
return { // return {
originalSecret: existingBase32Secret, // originalSecret: existingBase32Secret,
hexSecret, // hexSecret,
encryptedSecret, // Este es el valor que debes guardar en la base de datos // encryptedSecret, // Este es el valor que debes guardar en la base de datos
isValid, // isValid,
testCode, // testCode,
secretLength: hexSecret.length, // secretLength: hexSecret.length,
}; // };
} catch (error: unknown) { // } catch (error: unknown) {
const errorMessage = // const errorMessage =
error instanceof Error ? error.message : "Unknown error"; // error instanceof Error ? error.message : "Unknown error";
console.error("Error durante la migración:", errorMessage); // console.error("Error durante la migración:", errorMessage);
throw new Error(`Error al migrar el secreto: ${errorMessage}`); // throw new Error(`Error al migrar el secreto: ${errorMessage}`);
} // }
}; // };
// // Ejemplo de uso con el secreto de prueba // // // Ejemplo de uso con el secreto de prueba
// const testMigration = await migrateExistingSecret( // // const testMigration = await migrateExistingSecret(
// "46JMUCG4NJ3CIU6LQAIVFWUW", // // "46JMUCG4NJ3CIU6LQAIVFWUW",
// process.env.BETTER_AUTH_SECRET || "your-encryption-key", // // process.env.BETTER_AUTH_SECRET || "your-encryption-key",
// ); // // );
// console.log("\nPrueba de migración:"); // // console.log("\nPrueba de migración:");
// console.log("Secreto original (base32):", testMigration.originalSecret); // // console.log("Secreto original (base32):", testMigration.originalSecret);
// console.log("Secreto convertido (hex):", testMigration.hexSecret); // // console.log("Secreto convertido (hex):", testMigration.hexSecret);
// console.log("Secreto encriptado:", testMigration.encryptedSecret); // // console.log("Secreto encriptado:", testMigration.encryptedSecret);
// console.log("Longitud del secreto hex:", testMigration.secretLength); // // console.log("Longitud del secreto hex:", testMigration.secretLength);
// console.log("¿Conversión válida?:", testMigration.isValid); // // console.log("¿Conversión válida?:", testMigration.isValid);
// console.log("Código de prueba:", testMigration.testCode); // // console.log("Código de prueba:", testMigration.testCode);
const secret = "46JMUCG4NJ3CIU6LQAIVFWUW"; // const secret = "46JMUCG4NJ3CIU6LQAIVFWUW";
const isValid = createOTP(secret, { // const isValid = createOTP(secret, {
digits: 6, // digits: 6,
period: 30, // period: 30,
}).verify("123456"); // }).verify("123456");
console.log(isValid.then((isValid) => console.log(isValid))); // console.log(isValid.then((isValid) => console.log(isValid)));