From e645b31b32bcae0b9c52e6a78272155ea05477be Mon Sep 17 00:00:00 2001 From: Jonathan de Jong Date: Tue, 26 Aug 2025 07:53:20 +0200 Subject: [PATCH 0001/1022] change gitea permissions to new instances (#1832) --- apps/dokploy/utils/gitea-utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dokploy/utils/gitea-utils.ts b/apps/dokploy/utils/gitea-utils.ts index ab7b82dcc..6099aaa45 100644 --- a/apps/dokploy/utils/gitea-utils.ts +++ b/apps/dokploy/utils/gitea-utils.ts @@ -22,7 +22,7 @@ export const getGiteaOAuthUrl = ( } const redirectUri = `${baseUrl}/api/providers/gitea/callback`; - const scopes = "repo repo:status read:user read:org"; + const scopes = "read:repository read:user read:organization"; return `${giteaUrl}/login/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent( redirectUri, From 544408886eac37e5ba14880b9483a40a0ff9a7eb Mon Sep 17 00:00:00 2001 From: Vlad Vladov Date: Wed, 3 Sep 2025 02:01:14 +0300 Subject: [PATCH 0002/1022] feat(permissions): Add multiple admins capability --- .../settings/users/add-invitation.tsx | 1 + apps/dokploy/components/layouts/side.tsx | 64 ++++++++++++++----- apps/dokploy/components/layouts/user-nav.tsx | 10 ++- apps/dokploy/pages/dashboard/schedules.tsx | 2 +- .../pages/dashboard/settings/profile.tsx | 4 +- .../server/api/routers/organization.ts | 6 +- apps/dokploy/server/api/routers/settings.ts | 8 +-- apps/dokploy/server/api/routers/stripe.ts | 16 +++-- apps/dokploy/server/api/routers/user.ts | 6 +- apps/dokploy/server/api/trpc.ts | 12 +++- 10 files changed, 90 insertions(+), 39 deletions(-) diff --git a/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx b/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx index 6e0384554..e778f2e96 100644 --- a/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx +++ b/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx @@ -158,6 +158,7 @@ export const AddInvitation = () => { Member + Admin diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index d1d4ae273..b4806c4fc 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -156,7 +156,8 @@ const MENU: Menu = { url: "/dashboard/schedules", icon: Clock, // Only enabled in non-cloud environments - isEnabled: ({ isCloud, auth }) => !isCloud && auth?.role === "owner", + isEnabled: ({ isCloud, auth }) => + !isCloud && (auth?.role === "owner" || auth?.role === "admin"), }, { isSingle: true, @@ -166,7 +167,9 @@ const MENU: Menu = { // Only enabled for admins and users with access to Traefik files in non-cloud environments isEnabled: ({ auth, isCloud }) => !!( - (auth?.role === "owner" || auth?.canAccessToTraefikFiles) && + (auth?.role === "owner" || + auth?.role === "admin" || + auth?.canAccessToTraefikFiles) && !isCloud ), }, @@ -177,7 +180,12 @@ const MENU: Menu = { icon: BlocksIcon, // Only enabled for admins and users with access to Docker in non-cloud environments isEnabled: ({ auth, isCloud }) => - !!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud), + !!( + (auth?.role === "owner" || + auth?.role === "admin" || + auth?.canAccessToDocker) && + !isCloud + ), }, { isSingle: true, @@ -186,7 +194,12 @@ const MENU: Menu = { icon: PieChart, // Only enabled for admins and users with access to Docker in non-cloud environments isEnabled: ({ auth, isCloud }) => - !!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud), + !!( + (auth?.role === "owner" || + auth?.role === "admin" || + auth?.canAccessToDocker) && + !isCloud + ), }, { isSingle: true, @@ -195,7 +208,12 @@ const MENU: Menu = { icon: Forward, // Only enabled for admins and users with access to Docker in non-cloud environments isEnabled: ({ auth, isCloud }) => - !!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud), + !!( + (auth?.role === "owner" || + auth?.role === "admin" || + auth?.canAccessToDocker) && + !isCloud + ), }, // Legacy unused menu, adjusted to the new structure @@ -262,7 +280,8 @@ const MENU: Menu = { url: "/dashboard/settings/server", icon: Activity, // Only enabled for admins in non-cloud environments - isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && !isCloud), + isEnabled: ({ auth, isCloud }) => + !!((auth?.role === "owner" || auth?.role === "admin") && !isCloud), }, { isSingle: true, @@ -276,7 +295,8 @@ const MENU: Menu = { url: "/dashboard/settings/servers", icon: Server, // Only enabled for admins - isEnabled: ({ auth }) => !!(auth?.role === "owner"), + isEnabled: ({ auth }) => + !!(auth?.role === "owner" || auth?.role === "admin"), }, { isSingle: true, @@ -284,7 +304,8 @@ const MENU: Menu = { icon: Users, url: "/dashboard/settings/users", // Only enabled for admins - isEnabled: ({ auth }) => !!(auth?.role === "owner"), + isEnabled: ({ auth }) => + !!(auth?.role === "owner" || auth?.role === "admin"), }, { isSingle: true, @@ -300,7 +321,8 @@ const MENU: Menu = { icon: BotIcon, url: "/dashboard/settings/ai", isSingle: true, - isEnabled: ({ auth }) => !!(auth?.role === "owner"), + isEnabled: ({ auth }) => + !!(auth?.role === "owner" || auth?.role === "admin"), }, { isSingle: true, @@ -317,7 +339,8 @@ const MENU: Menu = { url: "/dashboard/settings/registry", icon: Package, // Only enabled for admins - isEnabled: ({ auth }) => !!(auth?.role === "owner"), + isEnabled: ({ auth }) => + !!(auth?.role === "owner" || auth?.role === "admin"), }, { isSingle: true, @@ -325,7 +348,8 @@ const MENU: Menu = { url: "/dashboard/settings/destinations", icon: Database, // Only enabled for admins - isEnabled: ({ auth }) => !!(auth?.role === "owner"), + isEnabled: ({ auth }) => + !!(auth?.role === "owner" || auth?.role === "admin"), }, { @@ -334,7 +358,8 @@ const MENU: Menu = { url: "/dashboard/settings/certificates", icon: ShieldCheck, // Only enabled for admins - isEnabled: ({ auth }) => !!(auth?.role === "owner"), + isEnabled: ({ auth }) => + !!(auth?.role === "owner" || auth?.role === "admin"), }, { isSingle: true, @@ -342,7 +367,8 @@ const MENU: Menu = { url: "/dashboard/settings/cluster", icon: Boxes, // Only enabled for admins in non-cloud environments - isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && !isCloud), + isEnabled: ({ auth, isCloud }) => + !!((auth?.role === "owner" || auth?.role === "admin") && !isCloud), }, { isSingle: true, @@ -350,7 +376,8 @@ const MENU: Menu = { url: "/dashboard/settings/notifications", icon: Bell, // Only enabled for admins - isEnabled: ({ auth }) => !!(auth?.role === "owner"), + isEnabled: ({ auth }) => + !!(auth?.role === "owner" || auth?.role === "admin"), }, { isSingle: true, @@ -358,7 +385,8 @@ const MENU: Menu = { url: "/dashboard/settings/billing", icon: CreditCard, // Only enabled for admins in cloud environments - isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && isCloud), + isEnabled: ({ auth, isCloud }) => + !!((auth?.role === "owner" || auth?.role === "admin") && isCloud), }, ], @@ -654,7 +682,9 @@ function SidebarLogo() { )} ))} - {(user?.role === "owner" || isCloud) && ( + {(user?.role === "owner" || + user?.role === "admin" || + isCloud) && ( <> @@ -1018,7 +1048,7 @@ export default function Page({ children }: Props) { - {!isCloud && auth?.role === "owner" && ( + {!isCloud && (auth?.role === "owner" || auth?.role === "admin") && ( diff --git a/apps/dokploy/components/layouts/user-nav.tsx b/apps/dokploy/components/layouts/user-nav.tsx index e476a5f50..07605f08c 100644 --- a/apps/dokploy/components/layouts/user-nav.tsx +++ b/apps/dokploy/components/layouts/user-nav.tsx @@ -101,7 +101,9 @@ export const UserNav = () => { > Monitoring - {(data?.role === "owner" || data?.canAccessToTraefikFiles) && ( + {(data?.role === "owner" || + data?.role === "admin" || + data?.canAccessToTraefikFiles) && ( { @@ -111,7 +113,9 @@ export const UserNav = () => { Traefik )} - {(data?.role === "owner" || data?.canAccessToDocker) && ( + {(data?.role === "owner" || + data?.role === "admin" || + data?.canAccessToDocker) && ( { @@ -125,7 +129,7 @@ export const UserNav = () => { )} ) : ( - data?.role === "owner" && ( + (data?.role === "owner" || data?.role === "admin") && ( { diff --git a/apps/dokploy/pages/dashboard/schedules.tsx b/apps/dokploy/pages/dashboard/schedules.tsx index 17d04b29a..113b079e8 100644 --- a/apps/dokploy/pages/dashboard/schedules.tsx +++ b/apps/dokploy/pages/dashboard/schedules.tsx @@ -40,7 +40,7 @@ export async function getServerSideProps( }; } const { user } = await validateRequest(ctx.req); - if (!user || user.role !== "owner") { + if (!user || (user.role !== "owner" && user.role !== "admin")) { return { redirect: { permanent: true, diff --git a/apps/dokploy/pages/dashboard/settings/profile.tsx b/apps/dokploy/pages/dashboard/settings/profile.tsx index 90cc345e4..34f8126e4 100644 --- a/apps/dokploy/pages/dashboard/settings/profile.tsx +++ b/apps/dokploy/pages/dashboard/settings/profile.tsx @@ -18,7 +18,9 @@ const Page = () => {
- {(data?.canAccessToAPI || data?.role === "owner") && } + {(data?.canAccessToAPI || + data?.role === "owner" || + data?.role === "admin") && } {/* {isCloud && } */}
diff --git a/apps/dokploy/server/api/routers/organization.ts b/apps/dokploy/server/api/routers/organization.ts index a015310d1..0f3d1c82e 100644 --- a/apps/dokploy/server/api/routers/organization.ts +++ b/apps/dokploy/server/api/routers/organization.ts @@ -15,7 +15,7 @@ export const organizationRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { - if (ctx.user.role !== "owner" && !IS_CLOUD) { + if (ctx.user.role !== "owner" && ctx.user.role !== "admin" && !IS_CLOUD) { throw new TRPCError({ code: "FORBIDDEN", message: "Only the organization owner can create an organization", @@ -86,7 +86,7 @@ export const organizationRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { - if (ctx.user.role !== "owner" && !IS_CLOUD) { + if (ctx.user.role !== "owner" && ctx.user.role !== "admin" && !IS_CLOUD) { throw new TRPCError({ code: "FORBIDDEN", message: "Only the organization owner can update it", @@ -109,7 +109,7 @@ export const organizationRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { - if (ctx.user.role !== "owner" && !IS_CLOUD) { + if (ctx.user.role !== "owner" && ctx.user.role !== "admin" && !IS_CLOUD) { throw new TRPCError({ code: "FORBIDDEN", message: "Only the organization owner can delete it", diff --git a/apps/dokploy/server/api/routers/settings.ts b/apps/dokploy/server/api/routers/settings.ts index 02678b990..dc084a735 100644 --- a/apps/dokploy/server/api/routers/settings.ts +++ b/apps/dokploy/server/api/routers/settings.ts @@ -201,7 +201,7 @@ export const settingsRouter = createTRPCRouter({ if (IS_CLOUD) { return true; } - await updateUser(ctx.user.id, { + await updateUser(ctx.user.ownerId, { sshPrivateKey: input.sshPrivateKey, }); @@ -213,7 +213,7 @@ export const settingsRouter = createTRPCRouter({ if (IS_CLOUD) { return true; } - const user = await updateUser(ctx.user.id, { + const user = await updateUser(ctx.user.ownerId, { host: input.host, ...(input.letsEncryptEmail && { letsEncryptEmail: input.letsEncryptEmail, @@ -240,7 +240,7 @@ export const settingsRouter = createTRPCRouter({ if (IS_CLOUD) { return true; } - await updateUser(ctx.user.id, { + await updateUser(ctx.user.ownerId, { sshPrivateKey: null, }); return true; @@ -300,7 +300,7 @@ export const settingsRouter = createTRPCRouter({ } } } else if (!IS_CLOUD) { - const userUpdated = await updateUser(ctx.user.id, { + const userUpdated = await updateUser(ctx.user.ownerId, { enableDockerCleanup: input.enableDockerCleanup, }); diff --git a/apps/dokploy/server/api/routers/stripe.ts b/apps/dokploy/server/api/routers/stripe.ts index 288924436..d2a000324 100644 --- a/apps/dokploy/server/api/routers/stripe.ts +++ b/apps/dokploy/server/api/routers/stripe.ts @@ -56,15 +56,16 @@ export const stripeRouter = createTRPCRouter({ }); const items = getStripeItems(input.serverQuantity, input.isAnnual); - const user = await findUserById(ctx.user.id); + // Always operate on the organization owner's Stripe customer + const owner = await findUserById(ctx.user.ownerId); - let stripeCustomerId = user.stripeCustomerId; + let stripeCustomerId = owner.stripeCustomerId; if (stripeCustomerId) { const customer = await stripe.customers.retrieve(stripeCustomerId); if (customer.deleted) { - await updateUser(user.id, { + await updateUser(owner.id, { stripeCustomerId: null, }); stripeCustomerId = null; @@ -78,7 +79,7 @@ export const stripeRouter = createTRPCRouter({ customer: stripeCustomerId, }), metadata: { - adminId: user.id, + adminId: owner.id, }, allow_promotion_codes: true, success_url: `${WEBSITE_URL}/dashboard/settings/servers?success=true`, @@ -88,15 +89,16 @@ export const stripeRouter = createTRPCRouter({ return { sessionId: session.id }; }), createCustomerPortalSession: adminProcedure.mutation(async ({ ctx }) => { - const user = await findUserById(ctx.user.id); + // Use the organization's owner account for billing portal + const owner = await findUserById(ctx.user.ownerId); - if (!user.stripeCustomerId) { + if (!owner.stripeCustomerId) { throw new TRPCError({ code: "BAD_REQUEST", message: "Stripe Customer ID not found", }); } - const stripeCustomerId = user.stripeCustomerId; + const stripeCustomerId = owner.stripeCustomerId; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: "2024-09-30.acacia", diff --git a/apps/dokploy/server/api/routers/user.ts b/apps/dokploy/server/api/routers/user.ts index 2e7c7a0c5..fb113f566 100644 --- a/apps/dokploy/server/api/routers/user.ts +++ b/apps/dokploy/server/api/routers/user.ts @@ -86,7 +86,11 @@ export const userRouter = createTRPCRouter({ // Allow access if: // 1. User is requesting their own information // 2. User has owner role (admin permissions) AND user is in the same organization - if (memberResult.userId !== ctx.user.id && ctx.user.role !== "owner") { + if ( + memberResult.userId !== ctx.user.id && + ctx.user.role !== "owner" && + ctx.user.role !== "admin" + ) { throw new TRPCError({ code: "UNAUTHORIZED", message: "You are not authorized to access this user", diff --git a/apps/dokploy/server/api/trpc.ts b/apps/dokploy/server/api/trpc.ts index c99f9104d..7b4e2e3f0 100644 --- a/apps/dokploy/server/api/trpc.ts +++ b/apps/dokploy/server/api/trpc.ts @@ -183,7 +183,11 @@ export const uploadProcedure = async (opts: any) => { }; export const cliProcedure = t.procedure.use(({ ctx, next }) => { - if (!ctx.session || !ctx.user || ctx.user.role !== "owner") { + if ( + !ctx.session || + !ctx.user || + (ctx.user.role !== "owner" && ctx.user.role !== "admin") + ) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ @@ -197,7 +201,11 @@ export const cliProcedure = t.procedure.use(({ ctx, next }) => { }); export const adminProcedure = t.procedure.use(({ ctx, next }) => { - if (!ctx.session || !ctx.user || ctx.user.role !== "owner") { + if ( + !ctx.session || + !ctx.user || + (ctx.user.role !== "owner" && ctx.user.role !== "admin") + ) { throw new TRPCError({ code: "UNAUTHORIZED" }); } return next({ From 95bf60ac75729f023bdd3c0a71ea8649cbdb7d77 Mon Sep 17 00:00:00 2001 From: Vlad Vladov Date: Wed, 3 Sep 2025 02:20:28 +0300 Subject: [PATCH 0003/1022] fix(template): space for correct checkbox displaying --- .github/pull_request_template.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 0b849afc0..d45c3dac0 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -6,9 +6,9 @@ Please describe in a short paragraph what this PR is about. Before submitting this PR, please make sure that: -- [] You created a dedicated branch based on the `canary` branch. -- [] You have read the suggestions in the CONTRIBUTING.md file https://github.com/Dokploy/dokploy/blob/canary/CONTRIBUTING.md#pull-request -- [] You have tested this PR in your local instance. +- [ ] You created a dedicated branch based on the `canary` branch. +- [ ] You have read the suggestions in the CONTRIBUTING.md file https://github.com/Dokploy/dokploy/blob/canary/CONTRIBUTING.md#pull-request +- [ ] You have tested this PR in your local instance. ## Issues related (if applicable) From a47a5f3b9ef32d2949d63566dee5f6db16d14b47 Mon Sep 17 00:00:00 2001 From: Vlad Vladov Date: Wed, 3 Sep 2025 16:36:22 +0300 Subject: [PATCH 0004/1022] feat(permissions): Forbid admins to delete themselves and add protections to the route --- .../dashboard/settings/users/show-users.tsx | 157 +++++++++--------- .../server/api/routers/organization.ts | 40 +++++ apps/dokploy/server/api/routers/user.ts | 43 ++++- 3 files changed, 160 insertions(+), 80 deletions(-) diff --git a/apps/dokploy/components/dashboard/settings/users/show-users.tsx b/apps/dokploy/components/dashboard/settings/users/show-users.tsx index 51d8704a3..e2eab13d1 100644 --- a/apps/dokploy/components/dashboard/settings/users/show-users.tsx +++ b/apps/dokploy/components/dashboard/settings/users/show-users.tsx @@ -35,7 +35,10 @@ export const ShowUsers = () => { const { data: isCloud } = api.settings.isCloud.useQuery(); const { data, isLoading, refetch } = api.user.all.useQuery(); const { mutateAsync } = api.user.remove.useMutation(); + const { mutateAsync: removeMember } = + api.organization.removeMember.useMutation(); const utils = api.useUtils(); + const { data: session } = authClient.useSession(); return (
@@ -134,55 +137,14 @@ export const ShowUsers = () => { {member.role !== "owner" && ( <> - {!isCloud && ( - { - await mutateAsync({ - userId: member.user.id, - }) - .then(() => { - toast.success( - "User deleted successfully", - ); - refetch(); - }) - .catch(() => { - toast.error( - "Error deleting destination", - ); - }); - }} - > - - e.preventDefault() - } - > - Delete User - - - )} - - { - if (!isCloud) { - const orgCount = - await utils.user.checkUserOrganizations.fetch( - { - userId: member.user.id, - }, - ); - - console.log(orgCount); - - if (orgCount === 1) { + {!isCloud && + member.user.id !== + session?.user?.id && ( + { await mutateAsync({ userId: member.user.id, }) @@ -192,41 +154,78 @@ export const ShowUsers = () => { ); refetch(); }) - .catch(() => { + .catch((err) => { toast.error( - "Error deleting user", + err?.message || + "Error deleting user", ); }); - return; + }} + > + + e.preventDefault() + } + > + Delete User + + + )} + + {!( + member.role === "admin" && + member.user.id === session?.user?.id + ) && ( + { + try { + if (!isCloud) { + const orgCount = + await utils.user.checkUserOrganizations.fetch( + { + userId: member.user.id, + }, + ); + if (orgCount === 1) { + await mutateAsync({ + userId: member.user.id, + }); + toast.success( + "User deleted successfully", + ); + refetch(); + return; + } + } + await removeMember({ + memberId: member.id, + }); + toast.success( + "User unlinked successfully", + ); + refetch(); + } catch (error: any) { + toast.error( + error?.message || + "Error unlinking user", + ); } - } - - const { error } = - await authClient.organization.removeMember( - { - memberIdOrEmail: member.id, - }, - ); - - if (!error) { - toast.success( - "User unlinked successfully", - ); - refetch(); - } else { - toast.error( - "Error unlinking user", - ); - } - }} - > - e.preventDefault()} + }} > - Unlink User - - + + e.preventDefault() + } + > + Unlink User + + + )} )} diff --git a/apps/dokploy/server/api/routers/organization.ts b/apps/dokploy/server/api/routers/organization.ts index 0f3d1c82e..dc815d150 100644 --- a/apps/dokploy/server/api/routers/organization.ts +++ b/apps/dokploy/server/api/routers/organization.ts @@ -184,4 +184,44 @@ export const organizationRouter = createTRPCRouter({ .delete(invitation) .where(eq(invitation.id, input.invitationId)); }), + removeMember: adminProcedure + .input(z.object({ memberId: z.string() })) + .mutation(async ({ ctx, input }) => { + // Fetch the target member within the active organization + const target = await db.query.member.findFirst({ + where: eq(member.id, input.memberId), + with: { user: true }, + }); + + if (!target) { + throw new TRPCError({ code: "NOT_FOUND", message: "Member not found" }); + } + + if (target.organizationId !== ctx.session.activeOrganizationId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You are not allowed to remove this member", + }); + } + + // Disallow removing the organization owner + if (target.role === "owner") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You cannot unlink the organization owner", + }); + } + + // Admin self-protection: an admin cannot unlink themselves + if (target.role === "admin" && target.userId === ctx.user.id) { + throw new TRPCError({ + code: "FORBIDDEN", + message: + "Admins cannot unlink themselves. Ask the owner or another admin.", + }); + } + + await db.delete(member).where(eq(member.id, input.memberId)); + return true; + }), }); diff --git a/apps/dokploy/server/api/routers/user.ts b/apps/dokploy/server/api/routers/user.ts index fb113f566..9072f56c7 100644 --- a/apps/dokploy/server/api/routers/user.ts +++ b/apps/dokploy/server/api/routers/user.ts @@ -217,10 +217,51 @@ export const userRouter = createTRPCRouter({ userId: z.string(), }), ) - .mutation(async ({ input }) => { + .mutation(async ({ input, ctx }) => { if (IS_CLOUD) { return true; } + + // Ensure the acting user has admin privileges in the active organization + if (ctx.user.role !== "owner" && ctx.user.role !== "admin") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Only owners or admins can delete users", + }); + } + + // Fetch target member within the active organization + const targetMember = await db.query.member.findFirst({ + where: and( + eq(member.userId, input.userId), + eq(member.organizationId, ctx.session?.activeOrganizationId || ""), + ), + }); + + if (!targetMember) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Target user is not a member of this organization", + }); + } + + // Never allow deleting the organization owner via this endpoint + if (targetMember.role === "owner") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You cannot delete the organization owner", + }); + } + + // Admin self-protection: an admin cannot delete themselves + if (targetMember.role === "admin" && input.userId === ctx.user.id) { + throw new TRPCError({ + code: "FORBIDDEN", + message: + "Admins cannot delete themselves. Ask the owner or another admin.", + }); + } + return await removeUserById(input.userId); }), assignPermissions: adminProcedure From 178ccb3f45938fc405991895d3cd9161e66ae630 Mon Sep 17 00:00:00 2001 From: Vlad Vladov Date: Wed, 3 Sep 2025 16:46:55 +0300 Subject: [PATCH 0005/1022] feat(ui): Improve UI for admins and owners - Make 3 dots unclickable if there no available actions for an user. - Remove "Add permissions" for admins because they have same permissions as owner --- .../dashboard/settings/users/show-users.tsx | 231 ++++++++++-------- 1 file changed, 124 insertions(+), 107 deletions(-) diff --git a/apps/dokploy/components/dashboard/settings/users/show-users.tsx b/apps/dokploy/components/dashboard/settings/users/show-users.tsx index e2eab13d1..e0b3425b6 100644 --- a/apps/dokploy/components/dashboard/settings/users/show-users.tsx +++ b/apps/dokploy/components/dashboard/settings/users/show-users.tsx @@ -86,6 +86,21 @@ export const ShowUsers = () => { {data?.map((member) => { + const canEditPermissions = member.role === "member"; + const canDelete = + member.role !== "owner" && + !isCloud && + member.user.id !== session?.user?.id; + const canUnlink = + member.role !== "owner" && + !( + member.role === "admin" && + member.user.id === session?.user?.id + ); + + const hasAnyAction = + canEditPermissions || canDelete || canUnlink; + return ( @@ -114,122 +129,124 @@ export const ShowUsers = () => { - - - - - - - Actions - + {hasAnyAction ? ( + + + + + + + Actions + - {member.role !== "owner" && ( - - )} + {canEditPermissions && ( + + )} - {member.role !== "owner" && ( - <> - {!isCloud && - member.user.id !== - session?.user?.id && ( - { - await mutateAsync({ - userId: member.user.id, - }) - .then(() => { - toast.success( - "User deleted successfully", - ); - refetch(); - }) - .catch((err) => { - toast.error( - err?.message || - "Error deleting user", - ); - }); - }} - > - - e.preventDefault() - } - > - Delete User - - - )} - - {!( - member.role === "admin" && - member.user.id === session?.user?.id - ) && ( - { - try { - if (!isCloud) { - const orgCount = - await utils.user.checkUserOrganizations.fetch( - { - userId: member.user.id, - }, - ); - if (orgCount === 1) { - await mutateAsync({ - userId: member.user.id, - }); - toast.success( - "User deleted successfully", - ); - refetch(); - return; - } - } - await removeMember({ - memberId: member.id, - }); + {canDelete && ( + { + await mutateAsync({ + userId: member.user.id, + }) + .then(() => { toast.success( - "User unlinked successfully", + "User deleted successfully", ); refetch(); - } catch (error: any) { + }) + .catch((err) => { toast.error( - error?.message || - "Error unlinking user", + err?.message || + "Error deleting user", ); - } - }} + }); + }} + > + e.preventDefault()} > - - e.preventDefault() + Delete User + + + )} + + {canUnlink && ( + { + try { + if (!isCloud) { + const orgCount = + await utils.user.checkUserOrganizations.fetch( + { + userId: member.user.id, + }, + ); + if (orgCount === 1) { + await mutateAsync({ + userId: member.user.id, + }); + toast.success( + "User deleted successfully", + ); + refetch(); + return; + } } - > - Unlink User - - - )} - - )} - - + await removeMember({ + memberId: member.id, + }); + toast.success( + "User unlinked successfully", + ); + refetch(); + } catch (error: any) { + toast.error( + error?.message || + "Error unlinking user", + ); + } + }} + > + e.preventDefault()} + > + Unlink User + + + )} + + + ) : ( + + )} ); From 6da122eab7d5f86fc6ef4c7b7cdcf970e811fc67 Mon Sep 17 00:00:00 2001 From: Vlad Vladov Date: Wed, 3 Sep 2025 17:57:44 +0300 Subject: [PATCH 0006/1022] feat(tags): Add support for tags from Github Packages --- .../pages/api/deploy/[refreshToken].ts | 46 +++++++++++++++---- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/apps/dokploy/pages/api/deploy/[refreshToken].ts b/apps/dokploy/pages/api/deploy/[refreshToken].ts index 3e515b182..22fabb39d 100644 --- a/apps/dokploy/pages/api/deploy/[refreshToken].ts +++ b/apps/dokploy/pages/api/deploy/[refreshToken].ts @@ -43,17 +43,19 @@ export default async function handler( if (sourceType === "docker") { const applicationDockerTag = extractImageTag(application.dockerImage); - const webhookDockerTag = extractImageTagFromRequest( + const webhookDockerTags = extractImageTagFromRequest( req.headers, req.body, ); - if ( + const isMismatch = applicationDockerTag && - webhookDockerTag && - webhookDockerTag !== applicationDockerTag - ) { + webhookDockerTags && + webhookDockerTags.length > 0 && + !webhookDockerTags.includes(applicationDockerTag); + + if (isMismatch) { res.status(301).json({ - message: `Application Image Tag (${applicationDockerTag}) doesn't match request event payload Image Tag (${webhookDockerTag}).`, + message: `Application Image Tag (${applicationDockerTag}) doesn't match request event payload Image Tag(s) (${webhookDockerTags.join(", ")}).`, }); return; } @@ -236,10 +238,38 @@ function extractImageTag(dockerImage: string | null) { export const extractImageTagFromRequest = ( headers: any, body: any, -): string | null => { +): string[] | null => { if (headers["user-agent"]?.includes("Go-http-client")) { if (body.push_data && body.repository) { - return body.push_data.tag; + return [body.push_data.tag] as string[]; + } + } + // GitHub Packages: package or registry_package events (container tags) + // See: https://docs.github.com/en/webhooks/webhook-events-and-payloads#package + const githubEvent = headers["x-github-event"]; + + if (githubEvent === "package" || githubEvent === "registry_package") { + const pkg = body?.package ?? body?.registry_package?.package ?? null; + const packageVersion = + body?.package_version ?? body?.registry_package?.package_version ?? null; + const packageType = pkg?.package_type; + + if (packageType === "container" && packageVersion) { + const tags = + packageVersion?.metadata?.container?.tags ?? + packageVersion?.container?.tags ?? + null; + if (Array.isArray(tags) && tags.length > 0) { + return tags as string[]; + } + const singleTag = + packageVersion?.metadata?.container?.tag ?? + packageVersion?.metadata?.tag ?? + packageVersion?.tag ?? + null; + if (typeof singleTag === "string") { + return [singleTag] as string[]; + } } } return null; From 4b1f359cb6c7533eaf7c1a07b84a0060b7fdd949 Mon Sep 17 00:00:00 2001 From: Oliver Geneser Date: Sat, 13 Sep 2025 10:11:43 +0200 Subject: [PATCH 0007/1022] feat: add libsql database --- README.md | 12 +- .../cluster/modify-swarm-settings.tsx | 38 +- .../cluster/show-cluster-settings.tsx | 38 +- .../application/advanced/show-resources.tsx | 40 +- .../advanced/volumes/add-volumes.tsx | 12 +- .../advanced/volumes/show-volumes.tsx | 18 +- .../advanced/volumes/update-volume.tsx | 14 +- .../dashboard/compose/delete-service.tsx | 3 + .../show-external-libsql-credentials.tsx | 258 +++++++++++ .../libsql/general/show-general-libsql.tsx | 268 +++++++++++ .../show-internal-libsql-credentials.tsx | 92 ++++ .../dashboard/libsql/update-libsql.tsx | 163 +++++++ .../postgres/advanced/show-custom-command.tsx | 3 + .../dashboard/project/add-database.tsx | 199 +++++--- .../dashboard/project/duplicate-project.tsx | 11 +- .../components/dashboard/projects/show.tsx | 17 +- .../dashboard/shared/rebuild-database.tsx | 12 +- .../show-database-advanced-settings.tsx | 2 +- .../components/icons/data-tools-icons.tsx | 55 +++ apps/dokploy/hooks/use-keyboard-nav.tsx | 14 +- .../environment/[environmentId].tsx | 318 +++++++------ .../services/libsql/[libsqlId].tsx | 361 +++++++++++++++ apps/dokploy/server/api/root.ts | 64 +-- .../dokploy/server/api/routers/environment.ts | 9 +- apps/dokploy/server/api/routers/libsql.ts | 437 ++++++++++++++++++ apps/dokploy/server/api/routers/mariadb.ts | 2 +- apps/dokploy/server/api/routers/mongo.ts | 2 +- apps/dokploy/server/api/routers/project.ts | 176 ++++--- packages/server/src/db/schema/application.ts | 1 - packages/server/src/db/schema/compose.ts | 1 - packages/server/src/db/schema/environment.ts | 12 +- packages/server/src/db/schema/index.ts | 1 + packages/server/src/db/schema/libsql.ts | 222 +++++++++ packages/server/src/db/schema/mount.ts | 42 +- packages/server/src/db/schema/redis.ts | 1 - packages/server/src/db/schema/server.ts | 18 +- packages/server/src/db/schema/shared.ts | 2 + .../server/src/db/schema/volume-backups.ts | 8 + packages/server/src/index.ts | 2 +- packages/server/src/services/environment.ts | 10 +- packages/server/src/services/libsql.ts | 160 +++++++ packages/server/src/services/mount.ts | 79 ++-- packages/server/src/services/project.ts | 8 +- packages/server/src/services/server.ts | 8 +- .../server/src/services/volume-backups.ts | 11 +- packages/server/src/utils/backups/mariadb.ts | 2 +- packages/server/src/utils/backups/mongo.ts | 2 +- packages/server/src/utils/backups/mysql.ts | 2 +- packages/server/src/utils/backups/postgres.ts | 2 +- packages/server/src/utils/databases/libsql.ts | 138 ++++++ .../server/src/utils/databases/rebuild.ts | 50 +- packages/server/src/utils/docker/utils.ts | 2 + .../server/src/utils/volume-backups/utils.ts | 6 +- 53 files changed, 2942 insertions(+), 486 deletions(-) create mode 100644 apps/dokploy/components/dashboard/libsql/general/show-external-libsql-credentials.tsx create mode 100644 apps/dokploy/components/dashboard/libsql/general/show-general-libsql.tsx create mode 100644 apps/dokploy/components/dashboard/libsql/general/show-internal-libsql-credentials.tsx create mode 100644 apps/dokploy/components/dashboard/libsql/update-libsql.tsx create mode 100644 apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/libsql/[libsqlId].tsx create mode 100644 apps/dokploy/server/api/routers/libsql.ts create mode 100644 packages/server/src/db/schema/libsql.ts create mode 100644 packages/server/src/services/libsql.ts create mode 100644 packages/server/src/utils/databases/libsql.ts diff --git a/README.md b/README.md index 8faf22a35..3c9bfd68b 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,6 @@

- -
Special thanks to:
@@ -22,20 +20,19 @@ ### [Tuple, the premier screen sharing app for developers](https://tuple.app/dokploy) + [Available for MacOS & Windows](https://tuple.app/dokploy)
- Dokploy is a free, self-hostable Platform as a Service (PaaS) that simplifies the deployment and management of applications and databases. - ## ✨ Features Dokploy includes multiple features to make your life easier. - **Applications**: Deploy any type of application (Node.js, PHP, Python, Go, Ruby, etc.). -- **Databases**: Create and manage databases with support for MySQL, PostgreSQL, MongoDB, MariaDB, and Redis. +- **Databases**: Create and manage databases with support for MySQL, PostgreSQL, MongoDB, MariaDB, libsql, and Redis. - **Backups**: Automate backups for databases to an external storage destination. - **Docker Compose**: Native support for Docker Compose to manage complex applications. - **Multi Node**: Scale applications to multiple nodes using Docker Swarm to manage the cluster. @@ -105,9 +102,10 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
- Cloudblast.io +Cloudblast.io + +Synexa - Synexa
### Community Backers 🤝 diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/modify-swarm-settings.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/modify-swarm-settings.tsx index 9e10f43ec..c8a4616ca 100644 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/modify-swarm-settings.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/modify-swarm-settings.tsx @@ -182,32 +182,41 @@ type AddSwarmSettings = z.infer; interface Props { id: string; - type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; + type: + | "application" + | "libsql" + | "mariadb" + | "mongo" + | "mysql" + | "postgres" + | "redis"; } export const AddSwarmSettings = ({ id, type }: Props) => { const queryMap = { + application: () => + api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), + libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }), + mariadb: () => + api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), + mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), + mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), postgres: () => api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }), redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }), - mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), - mariadb: () => - api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), - application: () => - api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), - mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), }; const { data, refetch } = queryMap[type] ? queryMap[type]() : api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }); const mutationMap = { + application: () => api.application.update.useMutation(), + libsql: () => api.libsql.update.useMutation(), + mariadb: () => api.mariadb.update.useMutation(), + mongo: () => api.mongo.update.useMutation(), + mysql: () => api.mysql.update.useMutation(), postgres: () => api.postgres.update.useMutation(), redis: () => api.redis.update.useMutation(), - mysql: () => api.mysql.update.useMutation(), - mariadb: () => api.mariadb.update.useMutation(), - application: () => api.application.update.useMutation(), - mongo: () => api.mongo.update.useMutation(), }; const { mutateAsync, isError, error, isLoading } = mutationMap[type] @@ -262,11 +271,12 @@ export const AddSwarmSettings = ({ id, type }: Props) => { const onSubmit = async (data: AddSwarmSettings) => { await mutateAsync({ applicationId: id || "", - postgresId: id || "", - redisId: id || "", - mysqlId: id || "", + libsqlId: id || "", mariadbId: id || "", mongoId: id || "", + mysqlId: id || "", + postgresId: id || "", + redisId: id || "", healthCheckSwarm: data.healthCheckSwarm, restartPolicySwarm: data.restartPolicySwarm, placementSwarm: data.placementSwarm, diff --git a/apps/dokploy/components/dashboard/application/advanced/cluster/show-cluster-settings.tsx b/apps/dokploy/components/dashboard/application/advanced/cluster/show-cluster-settings.tsx index a3bc8079a..962666fa9 100644 --- a/apps/dokploy/components/dashboard/application/advanced/cluster/show-cluster-settings.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/cluster/show-cluster-settings.tsx @@ -37,7 +37,14 @@ import { AddSwarmSettings } from "./modify-swarm-settings"; interface Props { id: string; - type: "postgres" | "mariadb" | "mongo" | "mysql" | "redis" | "application"; + type: + | "application" + | "libsql" + | "mariadb" + | "mongo" + | "mysql" + | "postgres" + | "redis"; } const AddRedirectchema = z.object({ @@ -49,15 +56,16 @@ type AddCommand = z.infer; export const ShowClusterSettings = ({ id, type }: Props) => { const queryMap = { + application: () => + api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), + libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }), + mariadb: () => + api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), + mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), + mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), postgres: () => api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }), redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }), - mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), - mariadb: () => - api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), - application: () => - api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), - mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), }; const { data, refetch } = queryMap[type] ? queryMap[type]() @@ -65,12 +73,13 @@ export const ShowClusterSettings = ({ id, type }: Props) => { const { data: registries } = api.registry.all.useQuery(); const mutationMap = { + application: () => api.application.update.useMutation(), + libsql: () => api.libsql.update.useMutation(), + mariadb: () => api.mariadb.update.useMutation(), + mongo: () => api.mongo.update.useMutation(), + mysql: () => api.mysql.update.useMutation(), postgres: () => api.postgres.update.useMutation(), redis: () => api.redis.update.useMutation(), - mysql: () => api.mysql.update.useMutation(), - mariadb: () => api.mariadb.update.useMutation(), - application: () => api.application.update.useMutation(), - mongo: () => api.mongo.update.useMutation(), }; const { mutateAsync, isLoading } = mutationMap[type] @@ -105,11 +114,12 @@ export const ShowClusterSettings = ({ id, type }: Props) => { const onSubmit = async (data: AddCommand) => { await mutateAsync({ applicationId: id || "", - postgresId: id || "", - redisId: id || "", - mysqlId: id || "", + libsqlId: id || "", mariadbId: id || "", mongoId: id || "", + mysqlId: id || "", + postgresId: id || "", + redisId: id || "", ...(type === "application" ? { registryId: diff --git a/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx b/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx index 25040067b..7f69760c5 100644 --- a/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/show-resources.tsx @@ -38,12 +38,13 @@ const addResourcesSchema = z.object({ }); export type ServiceType = - | "postgres" - | "mongo" - | "redis" - | "mysql" + | "application" + | "libsql" | "mariadb" - | "application"; + | "mongo" + | "mysql" + | "postgres" + | "redis"; interface Props { id: string; @@ -53,27 +54,29 @@ interface Props { type AddResources = z.infer; export const ShowResources = ({ id, type }: Props) => { const queryMap = { + application: () => + api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), + libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }), + mariadb: () => + api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), + mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), + mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), postgres: () => api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }), redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }), - mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), - mariadb: () => - api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), - application: () => - api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), - mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), }; const { data, refetch } = queryMap[type] ? queryMap[type]() : api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }); const mutationMap = { + application: () => api.application.update.useMutation(), + libsql: () => api.libsql.update.useMutation(), + mariadb: () => api.mariadb.update.useMutation(), + mongo: () => api.mongo.update.useMutation(), + mysql: () => api.mysql.update.useMutation(), postgres: () => api.postgres.update.useMutation(), redis: () => api.redis.update.useMutation(), - mysql: () => api.mysql.update.useMutation(), - mariadb: () => api.mariadb.update.useMutation(), - application: () => api.application.update.useMutation(), - mongo: () => api.mongo.update.useMutation(), }; const { mutateAsync, isLoading } = mutationMap[type] @@ -103,12 +106,13 @@ export const ShowResources = ({ id, type }: Props) => { const onSubmit = async (formData: AddResources) => { await mutateAsync({ + applicationId: id || "", + libsqlId: id || "", + mariadbId: id || "", mongoId: id || "", + mysqlId: id || "", postgresId: id || "", redisId: id || "", - mysqlId: id || "", - mariadbId: id || "", - applicationId: id || "", cpuLimit: formData.cpuLimit || null, cpuReservation: formData.cpuReservation || null, memoryLimit: formData.memoryLimit || null, diff --git a/apps/dokploy/components/dashboard/application/advanced/volumes/add-volumes.tsx b/apps/dokploy/components/dashboard/application/advanced/volumes/add-volumes.tsx index 00be8a1e1..a94db5153 100644 --- a/apps/dokploy/components/dashboard/application/advanced/volumes/add-volumes.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/volumes/add-volumes.tsx @@ -34,13 +34,13 @@ interface Props { serviceId: string; serviceType: | "application" - | "postgres" - | "redis" - | "mongo" - | "redis" - | "mysql" + | "compose" + | "libsql" | "mariadb" - | "compose"; + | "mongo" + | "mysql" + | "postgres" + | "redis"; refetch: () => void; children?: React.ReactNode; } diff --git a/apps/dokploy/components/dashboard/application/advanced/volumes/show-volumes.tsx b/apps/dokploy/components/dashboard/application/advanced/volumes/show-volumes.tsx index d3803c42a..38737bf16 100644 --- a/apps/dokploy/components/dashboard/application/advanced/volumes/show-volumes.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/volumes/show-volumes.tsx @@ -22,23 +22,25 @@ interface Props { export const ShowVolumes = ({ id, type }: Props) => { const queryMap = { + application: () => + api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), + compose: () => + api.compose.one.useQuery({ composeId: id }, { enabled: !!id }), + libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }), + mariadb: () => + api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), + mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), + mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), postgres: () => api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }), redis: () => api.redis.one.useQuery({ redisId: id }, { enabled: !!id }), - mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), - mariadb: () => - api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), - application: () => - api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), - mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), - compose: () => - api.compose.one.useQuery({ composeId: id }, { enabled: !!id }), }; const { data, refetch } = queryMap[type] ? queryMap[type]() : api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }); const { mutateAsync: deleteVolume, isLoading: isRemoving } = api.mounts.remove.useMutation(); + return ( diff --git a/apps/dokploy/components/dashboard/application/advanced/volumes/update-volume.tsx b/apps/dokploy/components/dashboard/application/advanced/volumes/update-volume.tsx index 38d02ec90..a75292b07 100644 --- a/apps/dokploy/components/dashboard/application/advanced/volumes/update-volume.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/volumes/update-volume.tsx @@ -61,13 +61,13 @@ interface Props { refetch: () => void; serviceType: | "application" - | "postgres" - | "redis" - | "mongo" - | "redis" - | "mysql" + | "compose" + | "libsql" | "mariadb" - | "compose"; + | "mongo" + | "mysql" + | "postgres" + | "redis"; } export const UpdateVolume = ({ @@ -247,7 +247,7 @@ export const UpdateVolume = ({ control={form.control} name="content" render={({ field }) => ( - + Content diff --git a/apps/dokploy/components/dashboard/compose/delete-service.tsx b/apps/dokploy/components/dashboard/compose/delete-service.tsx index e75aad5e5..c1d03dede 100644 --- a/apps/dokploy/components/dashboard/compose/delete-service.tsx +++ b/apps/dokploy/components/dashboard/compose/delete-service.tsx @@ -55,6 +55,7 @@ export const DeleteService = ({ id, type }: Props) => { mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }), mariadb: () => api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }), + libsql: () => api.libsql.one.useQuery({ libsqlId: id }, { enabled: !!id }), application: () => api.application.one.useQuery({ applicationId: id }, { enabled: !!id }), mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), @@ -70,6 +71,7 @@ export const DeleteService = ({ id, type }: Props) => { redis: () => api.redis.remove.useMutation(), mysql: () => api.mysql.remove.useMutation(), mariadb: () => api.mariadb.remove.useMutation(), + libsql: () => api.libsql.remove.useMutation(), application: () => api.application.delete.useMutation(), mongo: () => api.mongo.remove.useMutation(), compose: () => api.compose.delete.useMutation(), @@ -96,6 +98,7 @@ export const DeleteService = ({ id, type }: Props) => { redisId: id || "", mysqlId: id || "", mariadbId: id || "", + libsqlId: id || "", applicationId: id || "", composeId: id || "", deleteVolumes, diff --git a/apps/dokploy/components/dashboard/libsql/general/show-external-libsql-credentials.tsx b/apps/dokploy/components/dashboard/libsql/general/show-external-libsql-credentials.tsx new file mode 100644 index 000000000..562f8271e --- /dev/null +++ b/apps/dokploy/components/dashboard/libsql/general/show-external-libsql-credentials.tsx @@ -0,0 +1,258 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { api } from "@/utils/api"; + +const createDockerProviderSchema = (sqldNode?: string) => + z + .object({ + externalPort: z.preprocess((a) => { + if (a !== null) { + const parsed = Number.parseInt(z.string().parse(a), 10); + return Number.isNaN(parsed) ? null : parsed; + } + return null; + }, z + .number() + .gte(0, "Range must be 0 - 65535") + .lte(65535, "Range must be 0 - 65535") + .nullable()), + externalGRPCPort: z.preprocess((a) => { + if (a !== null) { + const parsed = Number.parseInt(z.string().parse(a), 10); + return Number.isNaN(parsed) ? null : parsed; + } + return null; + }, z + .number() + .gte(0, "Range must be 0 - 65535") + .lte(65535, "Range must be 0 - 65535") + .nullable()), + }) + .superRefine((data, ctx) => { + if (data.externalPort === null && data.externalGRPCPort === null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Either externalPort or externalGRPCPort must be provided.", + path: ["externalPort", "externalGRPCPort"], + }); + } + if (sqldNode === "replica" && data.externalGRPCPort !== null) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "externalGRPCPort cannot be set when sqldNode is 'replica'", + path: ["externalGRPCPort"], + }); + } + }); + +interface Props { + libsqlId: string; +} +export const ShowExternalLibsqlCredentials = ({ libsqlId }: Props) => { + const { data: ip } = api.settings.getIp.useQuery(); + const { data, refetch } = api.libsql.one.useQuery({ libsqlId }); + const { mutateAsync, isLoading } = api.libsql.saveExternalPorts.useMutation(); + const [connectionUrl, setConnectionUrl] = useState(""); + const [connectionGRPCUrl, setGRPCConnectionUrl] = useState(""); + const getIp = data?.server?.ipAddress || ip; + + const DockerProviderSchema = createDockerProviderSchema(data?.sqldNode); + type DockerProvider = z.infer; + + const form = useForm({ + defaultValues: {}, + resolver: zodResolver(DockerProviderSchema), + }); + + useEffect(() => { + const fieldsToUpdate: Partial = {}; + + if (data?.externalGRPCPort !== undefined) { + fieldsToUpdate.externalGRPCPort = data.externalGRPCPort; + } + + if (data?.externalPort !== undefined) { + fieldsToUpdate.externalPort = data.externalPort; + } + + if (Object.keys(fieldsToUpdate).length > 0) { + form.reset(fieldsToUpdate); + } + }, [form.reset, data, form]); + + const onSubmit = async (values: DockerProvider) => { + await mutateAsync({ + externalPort: values.externalPort, + externalGRPCPort: values.externalGRPCPort, + libsqlId, + }) + .then(async () => { + toast.success("External port/ports updated"); + await refetch(); + }) + .catch(() => { + toast.error("Error saving the external port/ports"); + }); + }; + + useEffect(() => { + const buildConnectionUrl = () => { + const port = form.watch("externalPort") || data?.externalPort; + + return `https://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}`; + }; + + setConnectionUrl(buildConnectionUrl()); + + const buildGRPCConnectionUrl = () => { + if (data?.sqldNode === "replica") return ""; + const port = form.watch("externalGRPCPort") || data?.externalGRPCPort; + + return `https://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}`; + }; + + setGRPCConnectionUrl(buildGRPCConnectionUrl()); + }, [ + data?.appName, + data?.externalGRPCPort, + data?.databasePassword, + form, + data?.databaseUser, + getIp, + ]); + + return ( + <> +
+ + + External Credentials + + In order to make the database reachable through the internet, you + must set a port and ensure that the port is not being used by + another application or database + + + + {!getIp && ( + + You need to set an IP address in your{" "} + + {data?.serverId + ? "Remote Servers -> Server -> Edit Server -> Update IP Address" + : "Web Server -> Server -> Update Server IP"} + {" "} + to fix the database url connection. + + )} +
+ +
+
+ { + return ( + + External Port (Internet) + + + + + + ); + }} + /> +
+ {!!data?.externalPort && ( +
+ + +
+ )} + {data?.sqldNode !== "replica" && ( + <> +
+ { + return ( + + + External GRPC Port (Internet) + + + + + + + ); + }} + /> +
+ {!!data?.externalGRPCPort && ( +
+ + +
+ )} + + )} +
+ +
+ +
+
+ +
+
+
+ + ); +}; diff --git a/apps/dokploy/components/dashboard/libsql/general/show-general-libsql.tsx b/apps/dokploy/components/dashboard/libsql/general/show-general-libsql.tsx new file mode 100644 index 000000000..f905d0d77 --- /dev/null +++ b/apps/dokploy/components/dashboard/libsql/general/show-general-libsql.tsx @@ -0,0 +1,268 @@ +import * as TooltipPrimitive from "@radix-ui/react-tooltip"; +import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { DrawerLogs } from "@/components/shared/drawer-logs"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { api } from "@/utils/api"; +import { type LogLine, parseLogs } from "../../docker/logs/utils"; +import { DockerTerminalModal } from "../../settings/web-server/docker-terminal-modal"; + +interface Props { + libsqlId: string; +} + +export const ShowGeneralLibsql = ({ libsqlId }: Props) => { + const { data, refetch } = api.libsql.one.useQuery( + { + libsqlId, + }, + { enabled: !!libsqlId }, + ); + + const { mutateAsync: reload, isLoading: isReloading } = + api.libsql.reload.useMutation(); + + const { mutateAsync: start, isLoading: isStarting } = + api.libsql.start.useMutation(); + + const { mutateAsync: stop, isLoading: isStopping } = + api.libsql.stop.useMutation(); + + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const [filteredLogs, setFilteredLogs] = useState([]); + const [isDeploying, setIsDeploying] = useState(false); + api.libsql.deployWithLogs.useSubscription( + { + libsqlId: libsqlId, + }, + { + enabled: isDeploying, + onData(log) { + if (!isDrawerOpen) { + setIsDrawerOpen(true); + } + + if (log === "Deployment completed successfully!") { + setIsDeploying(false); + } + const parsedLogs = parseLogs(log); + setFilteredLogs((prev) => [...prev, ...parsedLogs]); + }, + onError(error) { + console.error("Deployment logs error:", error); + setIsDeploying(false); + }, + }, + ); + + return ( + <> +
+ + + Deploy Settings + + + + { + setIsDeploying(true); + await new Promise((resolve) => setTimeout(resolve, 1000)); + refetch(); + }} + > + + + + + { + await reload({ + libsqlId: libsqlId, + appName: data?.appName || "", + }) + .then(() => { + toast.success("Libsql reloaded successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error reloading Libsql"); + }); + }} + > + + + + {data?.applicationStatus === "idle" ? ( + + { + await start({ + libsqlId: libsqlId, + }) + .then(() => { + toast.success("Libsql started successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error starting Libsql"); + }); + }} + > + + + + ) : ( + + { + await stop({ + libsqlId: libsqlId, + }) + .then(() => { + toast.success("Libsql stopped successfully"); + refetch(); + }) + .catch(() => { + toast.error("Error stopping Libsql"); + }); + }} + > + + + + )} + + + + + + { + setIsDrawerOpen(false); + setFilteredLogs([]); + setIsDeploying(false); + refetch(); + }} + filteredLogs={filteredLogs} + /> +
+ + ); +}; diff --git a/apps/dokploy/components/dashboard/libsql/general/show-internal-libsql-credentials.tsx b/apps/dokploy/components/dashboard/libsql/general/show-internal-libsql-credentials.tsx new file mode 100644 index 000000000..9a5612528 --- /dev/null +++ b/apps/dokploy/components/dashboard/libsql/general/show-internal-libsql-credentials.tsx @@ -0,0 +1,92 @@ +import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { api } from "@/utils/api"; + +interface Props { + libsqlId: string; +} +export const ShowInternalLibsqlCredentials = ({ libsqlId }: Props) => { + const { data } = api.libsql.one.useQuery({ libsqlId }); + return ( + <> +
+ + + Internal Credentials + + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+ +
+
+ + +
+
+ + +
+
+
+ + +
+ +
+ + +
+
+ + +
+
+
+
+
+ + ); +}; diff --git a/apps/dokploy/components/dashboard/libsql/update-libsql.tsx b/apps/dokploy/components/dashboard/libsql/update-libsql.tsx new file mode 100644 index 000000000..2e0904957 --- /dev/null +++ b/apps/dokploy/components/dashboard/libsql/update-libsql.tsx @@ -0,0 +1,163 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { PenBoxIcon } from "lucide-react"; +import { useEffect } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { api } from "@/utils/api"; + +const updateLibsqlSchema = z.object({ + name: z.string().min(1, { + message: "Name is required", + }), + description: z.string().optional(), +}); + +type UpdateLibsql = z.infer; + +interface Props { + libsqlId: string; +} + +export const UpdateLibsql = ({ libsqlId }: Props) => { + const utils = api.useUtils(); + const { mutateAsync, error, isError, isLoading } = + api.libsql.update.useMutation(); + const { data } = api.libsql.one.useQuery( + { + libsqlId, + }, + { + enabled: !!libsqlId, + }, + ); + const form = useForm({ + defaultValues: { + description: data?.description ?? "", + name: data?.name ?? "", + }, + resolver: zodResolver(updateLibsqlSchema), + }); + useEffect(() => { + if (data) { + form.reset({ + description: data.description ?? "", + name: data.name, + }); + } + }, [data, form, form.reset]); + + const onSubmit = async (formData: UpdateLibsql) => { + await mutateAsync({ + name: formData.name, + libsqlId: libsqlId, + description: formData.description || "", + }) + .then(() => { + toast.success("Libsql updated successfully"); + utils.libsql.one.invalidate({ + libsqlId: libsqlId, + }); + }) + .catch(() => { + toast.error("Error updating the Libsql"); + }) + .finally(() => {}); + }; + + return ( + + + + + + + Modify Libsql + Update the Libsql data + + {isError && {error?.message}} + +
+
+
+ + ( + + Name + + + + + + + )} + /> + ( + + Description + +