From 5d8b7b9b997e22c14821f45ca50c55cf5c82e7a6 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Mon, 9 Feb 2026 02:21:20 -0600 Subject: [PATCH 1/4] feat(dokploy): implement linking account feature for social providers - Added a new component for linking Google and GitHub accounts to user profiles. - Integrated account linking functionality with the authentication client, allowing users to link and unlink their social accounts. - Updated the profile settings page to conditionally display the linking account component based on cloud settings. - Enhanced error handling and loading states for a better user experience. --- .../linking-account/linking-account.tsx | 247 ++++++++++++++++++ .../pages/dashboard/settings/profile.tsx | 8 +- packages/server/src/lib/auth.ts | 11 + 3 files changed, 262 insertions(+), 4 deletions(-) create mode 100644 apps/dokploy/components/dashboard/settings/linking-account/linking-account.tsx diff --git a/apps/dokploy/components/dashboard/settings/linking-account/linking-account.tsx b/apps/dokploy/components/dashboard/settings/linking-account/linking-account.tsx new file mode 100644 index 000000000..cb448709a --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/linking-account/linking-account.tsx @@ -0,0 +1,247 @@ +"use client"; + +import { Link2, Loader2, Unlink } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { authClient } from "@/lib/auth-client"; + +const LINKING_CALLBACK_URL = "/dashboard/settings/profile"; + +const TRUSTED_PROVIDERS = ["google", "github"] as const; +type SocialProvider = (typeof TRUSTED_PROVIDERS)[number]; + +type AccountItem = { + providerId: string; + accountId?: string; +}; + +function providerLabel(providerId: string): string { + return providerId.charAt(0).toUpperCase() + providerId.slice(1); +} + +export function LinkingAccount() { + const [accounts, setAccounts] = useState([]); + const [accountsLoading, setAccountsLoading] = useState(true); + const [linkingProvider, setLinkingProvider] = useState( + null, + ); + const [unlinkingProviderId, setUnlinkingProviderId] = useState( + null, + ); + + const fetchAccounts = useCallback(async () => { + setAccountsLoading(true); + try { + const { data } = await authClient.listAccounts(); + console.log(data); + const list = Array.isArray(data) + ? data + : ((data && typeof data === "object" && "accounts" in data + ? (data as { accounts?: AccountItem[] }).accounts + : null) ?? []); + console.log(list); + setAccounts(Array.isArray(list) ? list : []); + } catch { + setAccounts([]); + } finally { + setAccountsLoading(false); + } + }, []); + + useEffect(() => { + fetchAccounts(); + }, [fetchAccounts]); + + const linkedProviderIds = new Set(accounts.map((a) => a.providerId)); + const socialAccounts = accounts.filter((a) => + TRUSTED_PROVIDERS.includes(a.providerId as SocialProvider), + ); + + const handleLinkSocial = async (provider: SocialProvider) => { + setLinkingProvider(provider); + try { + const { error } = await authClient.linkSocial({ + provider, + callbackURL: LINKING_CALLBACK_URL, + }); + if (error) { + toast.error(error.message ?? "Failed to link account"); + setLinkingProvider(null); + return; + } + } catch (err) { + toast.error( + "Failed to link account", + err instanceof Error ? { description: err.message } : undefined, + ); + setLinkingProvider(null); + } + }; + + const handleUnlink = async (providerId: string, accountId?: string) => { + setUnlinkingProviderId(providerId); + try { + const { error } = await authClient.unlinkAccount({ + providerId, + ...(accountId && { accountId }), + }); + if (error) { + toast.error(error.message ?? "Failed to unlink account"); + return; + } + toast.success("Account unlinked"); + await fetchAccounts(); + } catch (err) { + toast.error( + "Failed to unlink account", + err instanceof Error ? { description: err.message } : undefined, + ); + } finally { + setUnlinkingProviderId(null); + } + }; + + const canUnlink = accounts.length > 1; + + return ( + +
+ +
+
+ + + Linking account + + + Link your Google or GitHub account to sign in with them. + +
+
+
+ + {/* Linked accounts */} +
+

Linked accounts

+ {accountsLoading ? ( +
+ + Loading... +
+ ) : socialAccounts.length === 0 ? ( +

+ No social accounts linked yet. +

+ ) : ( +
    + {socialAccounts.map((acc) => ( +
  • + + {providerLabel(acc.providerId)} + + {canUnlink && ( + + )} +
  • + ))} +
+ )} +
+ +

+ Click a provider below to link it to your account. You will be + redirected to complete the flow. +

+
+ {!linkedProviderIds.has("google") && ( + + )} + {!linkedProviderIds.has("github") && ( + + )} +
+
+
+
+ ); +} diff --git a/apps/dokploy/pages/dashboard/settings/profile.tsx b/apps/dokploy/pages/dashboard/settings/profile.tsx index 34f8126e4..7e0ccdc83 100644 --- a/apps/dokploy/pages/dashboard/settings/profile.tsx +++ b/apps/dokploy/pages/dashboard/settings/profile.tsx @@ -4,6 +4,7 @@ import type { GetServerSidePropsContext } from "next"; import type { ReactElement } from "react"; import superjson from "superjson"; import { ShowApiKeys } from "@/components/dashboard/settings/api/show-api-keys"; +import { LinkingAccount } from "@/components/dashboard/settings/linking-account/linking-account"; import { ProfileForm } from "@/components/dashboard/settings/profile/profile-form"; import { DashboardLayout } from "@/components/layouts/dashboard-layout"; import { appRouter } from "@/server/api/root"; @@ -12,17 +13,16 @@ import { getLocale, serverSideTranslations } from "@/utils/i18n"; const Page = () => { const { data } = api.user.get.useQuery(); + const { data: isCloud } = api.settings.isCloud.useQuery(); - // const { data: isCloud } = api.settings.isCloud.useQuery(); return (
-
+
+ {isCloud && } {(data?.canAccessToAPI || data?.role === "owner" || data?.role === "admin") && } - - {/* {isCloud && } */}
); diff --git a/packages/server/src/lib/auth.ts b/packages/server/src/lib/auth.ts index b6d52febb..3d993e692 100644 --- a/packages/server/src/lib/auth.ts +++ b/packages/server/src/lib/auth.ts @@ -43,6 +43,17 @@ const { handler, api } = betterAuth({ }, } : {}), + ...(IS_CLOUD + ? { + account: { + accountLinking: { + enabled: true, + trustedProviders: ["github", "google"], + allowDifferentEmails: true, + }, + }, + } + : {}), appName: "Dokploy", socialProviders: { github: { From d348ad5556c5e6446e092b360ee356cfdc7de2d0 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Mon, 9 Feb 2026 02:21:37 -0600 Subject: [PATCH 2/4] fix(dokploy): remove console logs from linking account component - Eliminated unnecessary console log statements in the LinkingAccount component to clean up the code and improve performance. - Ensured that the account listing functionality remains intact while enhancing code readability. --- .../dashboard/settings/linking-account/linking-account.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/dokploy/components/dashboard/settings/linking-account/linking-account.tsx b/apps/dokploy/components/dashboard/settings/linking-account/linking-account.tsx index cb448709a..dcfa0b04f 100644 --- a/apps/dokploy/components/dashboard/settings/linking-account/linking-account.tsx +++ b/apps/dokploy/components/dashboard/settings/linking-account/linking-account.tsx @@ -41,13 +41,11 @@ export function LinkingAccount() { setAccountsLoading(true); try { const { data } = await authClient.listAccounts(); - console.log(data); const list = Array.isArray(data) ? data : ((data && typeof data === "object" && "accounts" in data ? (data as { accounts?: AccountItem[] }).accounts : null) ?? []); - console.log(list); setAccounts(Array.isArray(list) ? list : []); } catch { setAccounts([]); From 21a6657e005c2c2b39d94a270f7cb686134c2222 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 9 Feb 2026 08:33:23 +0000 Subject: [PATCH 3/4] [autofix.ci] apply automated fixes --- apps/dokploy/server/utils/enterprise.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/dokploy/server/utils/enterprise.ts b/apps/dokploy/server/utils/enterprise.ts index d6beeedf8..bd151f4d1 100644 --- a/apps/dokploy/server/utils/enterprise.ts +++ b/apps/dokploy/server/utils/enterprise.ts @@ -8,7 +8,9 @@ function isNetworkError(error: unknown): boolean { if (error.message === "fetch failed") return true; const cause = (error as Error & { cause?: { code?: string } }).cause; const code = cause?.code; - return code === "ECONNREFUSED" || code === "ENOTFOUND" || code === "ETIMEDOUT"; + return ( + code === "ECONNREFUSED" || code === "ENOTFOUND" || code === "ETIMEDOUT" + ); } return false; } From b391abfd5c8465fe7d55bee355facec56efa68e2 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Mon, 9 Feb 2026 02:42:15 -0600 Subject: [PATCH 4/4] feat(dokploy): add product IDs for monthly and annual subscriptions in Stripe integration - Introduced PRODUCT_MONTHLY_ID and PRODUCT_ANNUAL_ID constants to manage subscription product IDs. - Updated the Stripe API call to fetch only the specified subscription products, enhancing performance and clarity in product management. --- apps/dokploy/server/api/routers/stripe.ts | 8 +++++++- apps/dokploy/server/utils/stripe.ts | 7 +++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/dokploy/server/api/routers/stripe.ts b/apps/dokploy/server/api/routers/stripe.ts index be1e94d4a..412c9e3e8 100644 --- a/apps/dokploy/server/api/routers/stripe.ts +++ b/apps/dokploy/server/api/routers/stripe.ts @@ -7,7 +7,12 @@ import { import { TRPCError } from "@trpc/server"; import Stripe from "stripe"; import { z } from "zod"; -import { getStripeItems, WEBSITE_URL } from "@/server/utils/stripe"; +import { + getStripeItems, + PRODUCT_ANNUAL_ID, + PRODUCT_MONTHLY_ID, + WEBSITE_URL, +} from "@/server/utils/stripe"; import { adminProcedure, createTRPCRouter } from "../trpc"; export const stripeRouter = createTRPCRouter({ @@ -22,6 +27,7 @@ export const stripeRouter = createTRPCRouter({ const products = await stripe.products.list({ expand: ["data.default_price"], active: true, + ids: [PRODUCT_MONTHLY_ID, PRODUCT_ANNUAL_ID], }); if (!stripeCustomerId) { diff --git a/apps/dokploy/server/utils/stripe.ts b/apps/dokploy/server/utils/stripe.ts index 9e3e751a4..8d1aebb29 100644 --- a/apps/dokploy/server/utils/stripe.ts +++ b/apps/dokploy/server/utils/stripe.ts @@ -3,9 +3,12 @@ export const WEBSITE_URL = ? "http://localhost:3000" : process.env.SITE_URL; -const BASE_PRICE_MONTHLY_ID = process.env.BASE_PRICE_MONTHLY_ID!; // $4.00 +export const BASE_PRICE_MONTHLY_ID = process.env.BASE_PRICE_MONTHLY_ID!; // $4.00 -const BASE_ANNUAL_MONTHLY_ID = process.env.BASE_ANNUAL_MONTHLY_ID!; // $7.99 +export const BASE_ANNUAL_MONTHLY_ID = process.env.BASE_ANNUAL_MONTHLY_ID!; // $7.99 + +export const PRODUCT_MONTHLY_ID = process.env.PRODUCT_MONTHLY_ID!; +export const PRODUCT_ANNUAL_ID = process.env.PRODUCT_ANNUAL_ID!; export const getStripeItems = (serverQuantity: number, isAnnual: boolean) => { const items = [];