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..dcfa0b04f --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/linking-account/linking-account.tsx @@ -0,0 +1,245 @@ +"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(); + const list = Array.isArray(data) + ? data + : ((data && typeof data === "object" && "accounts" in data + ? (data as { accounts?: AccountItem[] }).accounts + : null) ?? []); + 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/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/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; } 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 = []; 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: {