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: {