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,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import copy from "copy-to-clipboard";
import {
Table,
TableBody,
@@ -132,19 +133,22 @@ export const ShowInvitations = () => {
Actions
</DropdownMenuLabel>
{/* <DropdownMenuItem
className="w-full cursor-pointer"
onSelect={(e) => {
copy(
`${origin}/invitation?token=${user.user.token}`,
);
toast.success(
"Invitation Copied to clipboard",
);
}}
>
Copy Invitation
</DropdownMenuItem> */}
{invitation.status === "pending" && (
<DropdownMenuItem
className="w-full cursor-pointer"
onSelect={(e) => {
copy(
`${origin}/invitation?token=${invitation.id}`,
);
toast.success(
"Invitation Copied to clipboard",
);
}}
>
Copy Invitation
</DropdownMenuItem>
)}
{invitation.status === "pending" && (
<DropdownMenuItem
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 { Button } from "@/components/ui/button";
import {
@@ -23,15 +24,14 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
import copy from "copy-to-clipboard";
import { format } from "date-fns";
import { MoreHorizontal, Users } from "lucide-react";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
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 = () => {
const { data: isCloud } = api.settings.isCloud.useQuery();
@@ -167,8 +167,7 @@ export const ShowUsers = () => {
const { error } =
await authClient.organization.removeMember(
{
memberIdOrEmail:
member.user.id,
memberIdOrEmail: member.id,
},
);

View File

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

View File

@@ -32,14 +32,7 @@ export const UserNav = () => {
const router = useRouter();
const { data } = api.auth.get.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 { mutateAsync } = api.auth.logout.useMutation();
@@ -99,7 +92,8 @@ export const UserNav = () => {
>
Monitoring
</DropdownMenuItem>
{(data?.role === "owner" || user?.canAccessToTraefikFiles) && (
{(data?.role === "owner" ||
data?.user?.canAccessToTraefikFiles) && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
@@ -109,7 +103,7 @@ export const UserNav = () => {
Traefik
</DropdownMenuItem>
)}
{(data?.role === "owner" || user?.canAccessToDocker) && (
{(data?.role === "owner" || data?.user?.canAccessToDocker) && (
<DropdownMenuItem
className="cursor-pointer"
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,
"tag": "0074_lowly_jack_power",
"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 { user, session } = await validateRequest(req);
console.log("user", user, session);
if (!user || user.role === "member") {
return {
redirect: {

View File

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

View File

@@ -9,7 +9,7 @@ import {
import {
IS_CLOUD,
createInvitation,
findUserByAuthId,
findOrganizationById,
findUserById,
getUserByToken,
removeUserById,
@@ -98,21 +98,20 @@ export const adminRouter = createTRPCRouter({
try {
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({
code: "UNAUTHORIZED",
message: "You are not allowed to assign permissions",
});
}
await updateUser(user.id, {
...input,
});
// await db
// .update(users)
// .set({
// ...input,
// })
// .where(eq(users.userId, input.userId));
} catch (error) {
throw error;
}

View File

@@ -1,29 +1,43 @@
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 { desc, eq } from "drizzle-orm";
import { and, desc, eq, exists } from "drizzle-orm";
import { nanoid } from "nanoid";
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({
create: adminProcedure
create: protectedProcedure
.input(
z.object({
name: z.string(),
}),
)
.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
.insert(organization)
.values({
...input,
slug: nanoid(),
createdAt: new Date(),
ownerId: ctx.user.ownerId,
ownerId: ctx.user.id,
})
.returning()
.then((res) => res[0]);
console.log("result", result);
if (!result) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
@@ -39,13 +53,24 @@ export const organizationRouter = createTRPCRouter({
});
return result;
}),
all: adminProcedure.query(async ({ ctx }) => {
return await db.query.organization.findMany({
where: eq(organization.ownerId, ctx.user.ownerId),
orderBy: [desc(organization.createdAt)],
all: protectedProcedure.query(async ({ ctx }) => {
const memberResult = await db.query.organization.findMany({
where: (organization) =>
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(
z.object({
organizationId: z.string(),
@@ -56,7 +81,7 @@ export const organizationRouter = createTRPCRouter({
where: eq(organization.id, input.organizationId),
});
}),
update: adminProcedure
update: protectedProcedure
.input(
z.object({
organizationId: z.string(),
@@ -64,6 +89,12 @@ export const organizationRouter = createTRPCRouter({
}),
)
.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
.update(organization)
.set({ name: input.name })
@@ -71,16 +102,41 @@ export const organizationRouter = createTRPCRouter({
.returning();
return result[0];
}),
delete: adminProcedure
delete: protectedProcedure
.input(
z.object({
organizationId: z.string(),
}),
)
.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
.delete(organization)
.where(eq(organization.id, input.organizationId));
return result;
}),
allInvitations: adminProcedure.query(async ({ ctx }) => {
@@ -89,4 +145,13 @@ export const organizationRouter = createTRPCRouter({
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 {
IS_CLOUD,
findUserByAuthId,
findUserById,
IS_CLOUD,
removeUserById,
updateUser,
verify2FA,

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ import { db } from "@dokploy/server/db";
import {
account,
type apiCreateUserInvitation,
invitation,
member,
organization,
users_temp,
@@ -64,6 +65,13 @@ export const findUserById = async (userId: string) => {
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>) => {
const user = await db
.update(users_temp)
@@ -106,24 +114,34 @@ export const isAdminPresent = async () => {
};
export const getUserByToken = async (token: string) => {
const user = await db.query.users_temp.findFirst({
where: eq(users_temp.token, token),
const user = await db.query.invitation.findFirst({
where: eq(invitation.id, token),
columns: {
id: true,
email: true,
token: true,
isRegistered: true,
status: true,
expiresAt: true,
role: true,
inviterId: true,
},
});
if (!user) {
throw new TRPCError({
code: "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 {
...user,
isExpired: user.isRegistered,
...rest,
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));
return new TextDecoder().decode(chacha.decrypt(dataAsBytes));
};
export const migrateExistingSecret = async (
existingBase32Secret: string,
encryptionKey: string,
) => {
try {
// 1. Primero asegurarnos que el secreto base32 tenga el padding correcto
let paddedSecret = existingBase32Secret;
while (paddedSecret.length % 8 !== 0) {
paddedSecret += "=";
}
// export const migrateExistingSecret = async (
// existingBase32Secret: string,
// encryptionKey: string,
// ) => {
// try {
// // 1. Primero asegurarnos que el secreto base32 tenga el padding correcto
// let paddedSecret = existingBase32Secret;
// while (paddedSecret.length % 8 !== 0) {
// paddedSecret += "=";
// }
// 2. Decodificar el base32 a bytes usando hi-base32
const bytes = encode.decode.asBytes(paddedSecret.toUpperCase());
// // 2. Decodificar el base32 a bytes usando hi-base32
// const bytes = encode.decode.asBytes(paddedSecret.toUpperCase());
// 3. Convertir los bytes a hex
const hexSecret = Buffer.from(bytes).toString("hex");
// // 3. Convertir los bytes a hex
// const hexSecret = Buffer.from(bytes).toString("hex");
// 4. Encriptar el secreto hex usando Better Auth
const encryptedSecret = await symmetricEncrypt({
key: encryptionKey,
data: hexSecret,
});
// // 4. Encriptar el secreto hex usando Better Auth
// const encryptedSecret = await symmetricEncrypt({
// key: encryptionKey,
// data: hexSecret,
// });
// 5. Crear TOTP con el secreto original para validación
const originalTotp = new TOTP({
issuer: "Dokploy",
label: "migration-test",
algorithm: "SHA1",
digits: 6,
secret: existingBase32Secret,
});
// // 5. Crear TOTP con el secreto original para validación
// const originalTotp = new TOTP({
// issuer: "Dokploy",
// label: "migration-test",
// algorithm: "SHA1",
// digits: 6,
// secret: existingBase32Secret,
// });
// 6. Generar un código de prueba con el secreto original
const testCode = originalTotp.generate();
// // 6. Generar un código de prueba con el secreto original
// const testCode = originalTotp.generate();
// 7. Validar que el código funcione con el secreto original
const isValid = originalTotp.validate({ token: testCode }) !== null;
// // 7. Validar que el código funcione con el secreto original
// const isValid = originalTotp.validate({ token: testCode }) !== null;
return {
originalSecret: existingBase32Secret,
hexSecret,
encryptedSecret, // Este es el valor que debes guardar en la base de datos
isValid,
testCode,
secretLength: hexSecret.length,
};
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
console.error("Error durante la migración:", errorMessage);
throw new Error(`Error al migrar el secreto: ${errorMessage}`);
}
};
// return {
// originalSecret: existingBase32Secret,
// hexSecret,
// encryptedSecret, // Este es el valor que debes guardar en la base de datos
// isValid,
// testCode,
// secretLength: hexSecret.length,
// };
// } catch (error: unknown) {
// const errorMessage =
// error instanceof Error ? error.message : "Unknown error";
// console.error("Error durante la migración:", errorMessage);
// throw new Error(`Error al migrar el secreto: ${errorMessage}`);
// }
// };
// // Ejemplo de uso con el secreto de prueba
// const testMigration = await migrateExistingSecret(
// "46JMUCG4NJ3CIU6LQAIVFWUW",
// process.env.BETTER_AUTH_SECRET || "your-encryption-key",
// );
// console.log("\nPrueba de migración:");
// console.log("Secreto original (base32):", testMigration.originalSecret);
// console.log("Secreto convertido (hex):", testMigration.hexSecret);
// console.log("Secreto encriptado:", testMigration.encryptedSecret);
// console.log("Longitud del secreto hex:", testMigration.secretLength);
// console.log("¿Conversión válida?:", testMigration.isValid);
// console.log("Código de prueba:", testMigration.testCode);
const secret = "46JMUCG4NJ3CIU6LQAIVFWUW";
const isValid = createOTP(secret, {
digits: 6,
period: 30,
}).verify("123456");
// // // Ejemplo de uso con el secreto de prueba
// // const testMigration = await migrateExistingSecret(
// // "46JMUCG4NJ3CIU6LQAIVFWUW",
// // process.env.BETTER_AUTH_SECRET || "your-encryption-key",
// // );
// // console.log("\nPrueba de migración:");
// // console.log("Secreto original (base32):", testMigration.originalSecret);
// // console.log("Secreto convertido (hex):", testMigration.hexSecret);
// // console.log("Secreto encriptado:", testMigration.encryptedSecret);
// // console.log("Longitud del secreto hex:", testMigration.secretLength);
// // console.log("¿Conversión válida?:", testMigration.isValid);
// // console.log("Código de prueba:", testMigration.testCode);
// const secret = "46JMUCG4NJ3CIU6LQAIVFWUW";
// const isValid = createOTP(secret, {
// digits: 6,
// period: 30,
// }).verify("123456");
console.log(isValid.then((isValid) => console.log(isValid)));
// console.log(isValid.then((isValid) => console.log(isValid)));