import { zodResolver } from "@hookform/resolvers/zod"; import { useSession } from "next-auth/react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useState, useEffect } from "react"; import { useForm } from "react-hook-form"; import { Toaster } from "sonner"; import { z } from "zod"; import AppNotInstalledMessage from "@calcom/app-store/_components/AppNotInstalledMessage"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; import { Button } from "@calcom/ui/components/button"; import { Icon } from "@calcom/ui/components/icon"; import { showToast } from "@calcom/ui/components/toast"; import KeyField from "../../components/KeyInput"; import { btcpayCredentialKeysSchema } from "../../lib/btcpayCredentialKeysSchema"; export type IBTCPaySetupProps = z.infer; export default function BTCPaySetup(props: IBTCPaySetupProps) { const params = useCompatSearchParams(); if (params?.get("callback") === "true") { return ; } return ; } enum BTCPayOAuthError { Declined = "declined", Unknown = "unknown", } function BTCPaySetupCallback() { const [error, setError] = useState(null); const searchParams = useCompatSearchParams(); useEffect(() => { if (!searchParams) { return; } if (!window.opener) { setError("Something went wrong. Opener not available. Please contact support"); return; } const code = searchParams?.get("code"); const error = searchParams?.get("error"); if (!code) { setError(BTCPayOAuthError.Declined); } if (error) { setError(error); return; } window.opener.postMessage({ type: "btcpayserver:oauth:success", payload: { code }, }); window.close(); }, [searchParams]); return (
{error &&

Authorization failed: {error}

} {!error &&

Connecting...

}
); } function BTCPaySetupPage(props: IBTCPaySetupProps) { const router = useRouter(); const { t } = useLocale(); const session = useSession(); const [loading, setLoading] = useState(false); const [validating, setValidating] = useState(false); const [updatable, setUpdatable] = useState(false); const [keyData, setKeyData] = useState< | { storeId: string; serverUrl: string; apiKey: string; webhookSecret: string; } | undefined >(); const settingsSchema = z.object({ storeId: z.string().trim(), serverUrl: z.string().trim(), apiKey: z.string().trim(), webhookSecret: z.string().optional(), }); const integrations = trpc.viewer.apps.integrations.useQuery({ variant: "payment", appId: "btcpayserver" }); const [btcPayPaymentAppCredentials] = integrations.data?.items || []; const [credentialId] = btcPayPaymentAppCredentials?.userCredentialIds || [-1]; const showContent = !!integrations.data && integrations.isSuccess && !!credentialId; const saveKeysMutation = trpc.viewer.apps.updateAppCredentials.useMutation({ onSuccess: () => { showToast(t("keys_have_been_saved"), "success"); router.push("/event-types"); }, onError: (error) => { showToast(error.message, "error"); }, }); const deleteMutation = trpc.viewer.credentials.delete.useMutation({ onSuccess: () => { router.push("/apps/btcpayserver"); }, onError: () => { showToast(t("error_removing_app"), "error"); }, }); const { register, handleSubmit, formState: { errors }, watch, reset, } = useForm>({ reValidateMode: "onChange", resolver: zodResolver(settingsSchema), }); useEffect(() => { const _keyData = { storeId: props?.storeId || "", serverUrl: props?.serverUrl || "", apiKey: props?.apiKey || "", webhookSecret: props?.webhookSecret || "", }; setKeyData(_keyData); }, [props]); useEffect(() => { const subscription = watch((value) => { const { serverUrl, storeId, apiKey, webhookSecret } = value; if ( serverUrl && storeId && apiKey && (keyData?.serverUrl !== serverUrl || keyData?.storeId !== storeId || keyData?.apiKey !== apiKey) ) { setUpdatable(true); } else { setUpdatable(false); } }); return () => subscription.unsubscribe(); }, [watch, keyData]); const configureBTCPayWebhook = async (data: z.infer) => { setValidating(true); const specificEvents = ["InvoiceSettled", "InvoiceProcessing"]; const serverUrl = data.serverUrl.endsWith("/") ? data.serverUrl.slice(0, -1) : data.serverUrl; const endpoint = `${serverUrl}/api/v1/stores/${data.storeId}/webhooks`; const webhookUrl = `${WEBAPP_URL}/api/integrations/btcpayserver/webhook`; const requestBody = { enabled: true, automaticRedelivery: false, url: webhookUrl, authorizedEvents: { everything: false, specificEvents: specificEvents, }, secret: null, }; try { const response = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `token ${data.apiKey}`, }, body: JSON.stringify(requestBody), }); if (!response.ok) { const errorBody = await response.text(); showToast(`Failed to configure webhook: ${errorBody}`, "error"); return false; } const webhookResponse = await response.json(); saveKeysMutation.mutate({ credentialId, key: btcpayCredentialKeysSchema.parse({ ...data, webhookSecret: webhookResponse.secret, }), }); return true; } catch (error) { if (error instanceof Error) { showToast(error.message || "Failed to configure BTCPay webhook", "error"); } else { showToast("An unknown error occurred while configuring BTCPay webhook", "error"); } return false; } finally { setValidating(false); } }; const onSubmit = handleSubmit(async (data) => { if (loading) return; setLoading(true); try { const isValid = await configureBTCPayWebhook(data); if (!isValid) { setLoading(false); return; } } catch (error: unknown) { let message = ""; if (error instanceof Error) { message = error.message; } showToast(message, "error"); } finally { setLoading(false); } }); const onCancel = () => { deleteMutation.mutate({ id: credentialId }); }; const btcpayIcon = ( <> BTCPay Server Icon ); if (session.status === "loading") return <>; if (integrations.isPending) { return
; } const isNewCredential = !props.serverUrl && !props.storeId && !props.webhookSecret && !props.apiKey; const webhookUri = `${WEBAPP_URL}/api/integrations/btcpayserver/webhook`; return ( <>
{showContent ? (

BTCPay Server Information

{errors.serverUrl && (

{errors.serverUrl?.message}

)}
{errors.storeId && (

{errors.storeId?.message}

)}
{errors.apiKey && (

{errors.apiKey?.message}

)}
{isNewCredential ? ( <> ) : ( <> )}
) : ( )}
); }