import type { SocialProvider } from "better-auth/social-providers"; import { useSearchParams } from "next/navigation"; import { zodResolver } from "@hookform/resolvers/zod"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { useQuery } from "@tanstack/react-query"; import { env } from "next-runtime-env"; import { useEffect, useMemo, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { FaApple, FaDiscord, FaDropbox, FaFacebook, FaGithub, FaGitlab, FaGoogle, FaLinkedin, FaMicrosoft, FaOpenid, FaReddit, FaSpotify, FaTiktok, FaTwitch, FaTwitter, FaVk, } from "react-icons/fa"; import { SiRoblox, SiZoom } from "react-icons/si"; import { TbBrandKick } from "react-icons/tb"; import { z } from "zod"; import { authClient } from "@kan/auth/client"; import Button from "~/components/Button"; import Input from "~/components/Input"; import { usePopup } from "~/providers/popup"; type AuthProvider = SocialProvider | "oidc"; interface FormValues { name?: string; email: string; password?: string; } interface AuthProps { setIsMagicLinkSent: (value: boolean, recipient: string) => void; isSignUp?: boolean; } const EmailSchema = z.object({ name: z.string().optional(), email: z.string().email(), password: z.string().optional(), }); const availableSocialProviders = { google: { id: "google", name: "Google", icon: FaGoogle, }, github: { id: "github", name: "GitHub", icon: FaGithub, }, discord: { id: "discord", name: "Discord", icon: FaDiscord, }, apple: { id: "apple", name: "Apple", icon: FaApple, }, microsoft: { id: "microsoft", name: "Microsoft", icon: FaMicrosoft, }, facebook: { id: "facebook", name: "Facebook", icon: FaFacebook, }, spotify: { id: "spotify", name: "Spotify", icon: FaSpotify, }, twitch: { id: "twitch", name: "Twitch", icon: FaTwitch, }, twitter: { id: "twitter", name: "Twitter", icon: FaTwitter, }, dropbox: { id: "dropbox", name: "Dropbox", icon: FaDropbox, }, linkedin: { id: "linkedin", name: "LinkedIn", icon: FaLinkedin, }, gitlab: { id: "gitlab", name: "GitLab", icon: FaGitlab, }, tiktok: { id: "tiktok", name: "TikTok", icon: FaTiktok, }, reddit: { id: "reddit", name: "Reddit", icon: FaReddit, }, roblox: { id: "roblox", name: "Roblox", icon: SiRoblox, }, vk: { id: "vk", name: "VK", icon: FaVk, }, kick: { id: "kick", name: "Kick", icon: TbBrandKick, }, zoom: { id: "zoom", name: "Zoom", icon: SiZoom, }, oidc: { id: "oidc", name: "OIDC", icon: FaOpenid, }, }; export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) { const [isCloudEnv, setIsCloudEnv] = useState(false); const [isLoginWithProviderPending, setIsLoginWithProviderPending] = useState(null); const [isCredentialsEnabled, setIsCredentialsEnabled] = useState(false); const [isEmailSendingEnabled, setIsEmailSendingEnabled] = useState(false); const [isLoginWithEmailPending, setIsLoginWithEmailPending] = useState(false); const [loginError, setLoginError] = useState(null); const { showPopup } = usePopup(); const oidcProviderName = "OIDC"; const passwordRef = useRef(null); const redirect = useSearchParams().get("next"); const callbackURL = redirect ?? "/boards"; // Safely get environment variables on client side to avoid hydration mismatch useEffect(() => { const credentialsAllowed = env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true"; const emailSendingEnabled = env("NEXT_PUBLIC_DISABLE_EMAIL")?.toLowerCase() !== "true"; const isCloudEnv = env("NEXT_PUBLIC_KAN_ENV") === "cloud"; setIsCloudEnv(isCloudEnv); setIsEmailSendingEnabled(emailSendingEnabled); setIsCredentialsEnabled(credentialsAllowed); }, []); const { register, handleSubmit, formState: { errors }, watch, } = useForm({ resolver: zodResolver(EmailSchema), }); const { data: socialProviders } = useQuery({ queryKey: ["social_providers"], queryFn: () => authClient.getSocialProviders(), }); const handleLoginWithEmail = async ( email: string, password?: string | null, name?: string, ) => { setIsLoginWithEmailPending(true); setLoginError(null); if (password) { if (isSignUp && name) { await authClient.signUp.email( { name, email, password, callbackURL, }, { onSuccess: () => showPopup({ header: t`Success`, message: t`You have been signed up successfully.`, icon: "success", }), onError: ({ error }) => setLoginError(error.message), }, ); } else { await authClient.signIn.email( { email, password, callbackURL, }, { onSuccess: () => showPopup({ header: t`Success`, message: t`You have been logged in successfully.`, icon: "success", }), onError: ({ error }) => setLoginError(error.message), }, ); } } else { // Only allow magic link if email sending is enabled and not in sign up mode if (isCloudEnv || (isEmailSendingEnabled && !isSignUp)) { await authClient.signIn.magicLink( { email, callbackURL, }, { onSuccess: () => setIsMagicLinkSent(true, email), onError: ({ error }) => setLoginError(error.message), }, ); } else { // Provide a clear error feedback when password omitted but magic link unavailable setLoginError( isSignUp ? t`Password is required to sign up.` : t`Password is required to login.`, ); } } setIsLoginWithEmailPending(false); }; const handleLoginWithProvider = async (provider: AuthProvider) => { setIsLoginWithProviderPending(provider); setLoginError(null); let error; if (provider === "oidc") { // Use oauth2 signin for OIDC provider const result = await authClient.signIn.oauth2({ providerId: "oidc", callbackURL, }); error = result.error; } else { // Use social signin for traditional social providers const result = await authClient.signIn.social({ provider, callbackURL, }); error = result.error; } setIsLoginWithProviderPending(null); if (error) { setLoginError( t`Failed to login with ${provider.at(0)?.toUpperCase() + provider.slice(1)}. Please try again.`, ); } }; const onSubmit = async (values: FormValues) => { // Treat empty password string as undefined to trigger magic link path const sanitizedPassword = values.password?.trim() ? values.password : undefined; await handleLoginWithEmail(values.email, sanitizedPassword, values.name); }; const password = watch("password"); const isMagicLinkAvailable = useMemo(() => { return isCloudEnv || (isEmailSendingEnabled && !isSignUp); }, [isCloudEnv, isEmailSendingEnabled, isSignUp]); // Determine if we should operate in magic link mode for current form state (login only) const isMagicLinkMode = useMemo(() => { // Magic link only viable when email sending enabled AND not sign up. if (!isEmailSendingEnabled || isSignUp) return false; // If credentials disabled we always default to magic link. if (!isCredentialsEnabled) return true; // Credentials enabled: user chooses magic link by leaving password blank. return !password; }, [isEmailSendingEnabled, isSignUp, isCredentialsEnabled, password]); // Auto-focus password field when an error indicates it's required useEffect(() => { if (!isCredentialsEnabled) return; // Focus when: sign up and missing password; login error requiring password; validation error on password. const pwdEmpty = (password ?? "").length === 0; let needsPassword = false; if (isSignUp && pwdEmpty) { needsPassword = true; } else if (loginError?.toLowerCase().includes("password")) { needsPassword = true; } else if (errors.password) { needsPassword = true; } if (needsPassword && passwordRef.current) { passwordRef.current.focus(); } }, [isSignUp, password, loginError, errors.password, isCredentialsEnabled]); return (
{socialProviders?.length !== 0 && (
{Object.entries(availableSocialProviders).map(([key, provider]) => { if (!socialProviders?.includes(key)) { return null; } return ( ); })}
)}
{!isCredentialsEnabled && socialProviders?.length === 0 && (
{t`No authentication methods are currently available`}
)} {!isCredentialsEnabled && socialProviders?.length !== 0 && (
{t`or`}
)}
{isSignUp && isCredentialsEnabled && (
{errors.name && (

{t`Please enter a valid name`}

)}
)} {(isCredentialsEnabled || isMagicLinkAvailable) && ( <>
{errors.email && (

{t`Please enter a valid email address`}

)}
{isCredentialsEnabled && (
{errors.password && (

{errors.password.message ?? t`Please enter a valid password`}

)}
)} )} {loginError && (

{loginError}

)}
{(isCredentialsEnabled || isMagicLinkAvailable) && (
)}
); }