import { branding } from "../../lib/branding"; import React, { PropsWithChildren, ReactNode, useState } from "react"; import Link from "next/link"; import { useQueryStringCopy } from "./use-query-string-copy"; import { useRouter } from "next/router"; import { EmailFirstLogin } from "./EmailFirstLogin"; import { OAuthButtons } from "./OAuthButtons"; import { signIn } from "next-auth/react"; import { useFirebaseSession } from "../../lib/firebase-client"; import { useJitsu } from "@jitsu/jitsu-react"; import { useAppConfig } from "../../lib/context"; import { safeRedirect } from "../../lib/auth-redirect"; import { Alert, Button, Input, Modal } from "antd"; export type AuthType = | "firebase-password" | "firebase-google" | "firebase-github" | "nextauth-github" | "nextauth-credentials" | "nextauth-oidc" | "dynamic-oidc" | "none"; function providerDisplayName(providerId: string): string { if (providerId === "google.com") return "Google"; if (providerId === "github.com") return "GitHub"; if (providerId === "password") return "email and password"; return providerId; } function JitsuLogo() { return (
{branding.logo}
{branding.wordmark}
); } //TODO: detect that callbackUrl leads to /accept and show appropriate message export const SigninLayout: React.FC> = ({ title, children, footer, }) => { const router = useRouter(); return (
{title}
{router.query.invite && (
Login to Jitsu to accept the invitation, or create an account
)}
{children}
{footer &&
{footer}
}
); }; export type SigninProps = { signup?: boolean; }; export const SignInOrUp: React.FC = ({ signup }) => { const queryStringCopy = useQueryStringCopy(); const router = useRouter(); const appConfig = useAppConfig(); const firebaseSession = useFirebaseSession(); const { analytics } = useJitsu(); const [error, setError] = useState(null); // JITSU-244: set when signInWith reports the email already has an account // under a different provider. Drives the "sign in with X to link Y" prompt. const [linkPrompt, setLinkPrompt] = useState<{ email: string; existingProvider: string; pendingCredential: Parameters["linkPendingCredential"]>[0]; } | null>(null); const [linking, setLinking] = useState(false); // Only used when linkPrompt.existingProvider === "password" — the modal // collects the password itself (see handleLinkWithPassword) rather than // relying on the page's own email/password form, which sits behind the // modal's mask and can't be interacted with while it's open. const [linkPassword, setLinkPassword] = useState(""); const callbackUrl = (router.query.callbackUrl as string) || "/"; // Determine available auth methods const hasFirebase = !!appConfig.auth?.firebasePublic; const hasDynamicOidc = !!appConfig.auth?.dynamicOidc; const showSignupLink = !signup && !appConfig.disableSignup; // Build the list of available OAuth providers const ssoProviders: Array<"firebase-google" | "firebase-github" | "nextauth-github" | "nextauth-oidc"> = []; if (hasFirebase) { ssoProviders.push("firebase-google", "firebase-github"); } else { if (appConfig.auth?.nextauth?.github) { ssoProviders.push("nextauth-github"); } if (appConfig.auth?.nextauth?.oidc) { ssoProviders.push("nextauth-oidc"); } } const showEmailLogin = hasFirebase || hasDynamicOidc || (appConfig.auth?.nextauth?.credentials && !signup); const handlePasswordLogin = async (email: string, password: string, type: AuthType) => { try { if (type === "firebase-password") { const success = await firebaseSession!.signIn(email, password); if (!success) { setError("Invalid email or password"); return; } const result = await firebaseSession!.resolveUser(undefined, { recordLogin: true }).user; if (!result) { setError("Sign in failed"); return; } if (result.status === "personal-email-rejected") { // JITSU-70: the server refused the personal-email account and deleted // it server-side. Clear the stale client session. setError(result.message); await firebaseSession!.signOut(); return; } if (result.status === "email-not-verified") { // Hand off to the route gate (FirebaseAuthorizer), which renders the // verification screen. safeRedirect(router, callbackUrl); return; } // `login` is tracked server-side (fb-auth/audit-login) — see telemetry.trackAuthEvent. safeRedirect(router, callbackUrl); } else if (type === "nextauth-credentials") { const result = await signIn("credentials", { username: email, password, redirect: false, callbackUrl, }); if (result?.error) { setError(result.error); } if (result?.ok) { safeRedirect(router, callbackUrl); } else { setError("Invalid email or password"); } } else { setError("Unsupported authentication method"); } } catch (e: any) { if (type === "firebase-password") { setError(handleFirebaseError(e)); await analytics.track("login_error", { email, type: "password", loginProvider: "firebase/email", message: e?.message || "Unknown error", }); } else { setError(e.message || "Authentication failed"); } } }; const handleSSOLogin = async (provider: AuthType, providerId: string, loginHint?: string) => { try { switch (provider) { case "firebase-google": case "firebase-github": { const result = await firebaseSession!.signInWith( provider === "firebase-google" ? "google.com" : "github.com" ); if (result.status === "personal-email-rejected") { // Server refused a personal-email signup and deleted the account — // clear the stale client session. setError(result.message); await firebaseSession!.signOut(); return; } if (result.status === "account-exists-different-credential") { // JITSU-244: can't link silently — ask the user to re-auth with // whichever provider the email is already on. Clear any stale // page-level error so it doesn't appear to come from this flow. setError(null); setLinkPrompt({ email: result.email, existingProvider: result.existingProviders[0], pendingCredential: result.pendingCredential, }); return; } // `login` is tracked server-side (fb-auth/audit-login) — see telemetry.trackAuthEvent. // authenticated, or email-not-verified (the route gate handles that) safeRedirect(router, callbackUrl); break; } case "nextauth-github": await signIn("github", { callbackUrl }, { ...(loginHint ? { login_hint: loginHint } : {}), prompt: "login" }); break; case "nextauth-oidc": await signIn("oidc", { callbackUrl }, { ...(loginHint ? { login_hint: loginHint } : {}), prompt: "login" }); break; case "dynamic-oidc": window.location.href = `/api/auth/dynamic-oidc/authorize?providerId=${providerId}&callbackUrl=${encodeURIComponent( callbackUrl )}${loginHint ? `&loginHint=${encodeURIComponent(loginHint)}` : ""}`; break; default: setError(`Unsupported authentication provider: ${provider}`); } } catch (e: any) { if (provider.startsWith("firebase-")) { setError(handleFirebaseError(e)); await analytics.track("login_error", { type: "social", loginProvider: `firebase/${provider}`, message: e?.message || "Unknown error", }); } else { setError(e.message || "Authentication failed"); } } }; // JITSU-244: completes the account-linking prompt — re-authenticates with // the existing provider, then links the credential the first attempt // produced. linkPrompt must already be set (the button that calls this is // only rendered while it is). const handleLinkConfirm = async () => { if (!linkPrompt) return; setLinking(true); try { const reauth = await firebaseSession!.signInWith(linkPrompt.existingProvider); if (reauth.status !== "authenticated" && reauth.status !== "email-not-verified") { // personal-email-rejected or (defensively) another linking prompt — // neither is expected here since this provider already has an // account; surface whatever message is available and bail. setError((reauth as any).message || "Sign in failed"); return; } const result = await firebaseSession!.linkPendingCredential(linkPrompt.pendingCredential); if (result.status !== "authenticated" && result.status !== "email-not-verified") { setError((result as any).message || "Linking failed"); return; } setLinkPrompt(null); safeRedirect(router, callbackUrl); } catch (e: any) { setError(handleFirebaseError(e)); } finally { setLinking(false); } }; // JITSU-244: the password-account counterpart to handleLinkConfirm. Can't // reuse the page's own email/password form for this — it's rendered behind // the modal's mask and the user can't reach it while the modal is open (the // earlier version relied on closing the modal first, which cleared // linkPrompt before the form submission could see it — caught in review). // So this collects the password itself and completes sign-in + link // without ever closing the modal in between. const handleLinkWithPassword = async () => { if (!linkPrompt) return; setLinking(true); try { const success = await firebaseSession!.signIn(linkPrompt.email, linkPassword); if (!success) { setError("Invalid email or password"); return; } // Mirrors the plain password-login path below: signIn() itself doesn't // record the login event (see its own comment) — resolveUser({ // recordLogin: true }) is what actually fires it, once internalId is // resolvable. Skipping this call was a real gap: linking via password // silently missed the audit/telemetry login event every other path fires. await firebaseSession!.resolveUser(undefined, { recordLogin: true }).user; const result = await firebaseSession!.linkPendingCredential(linkPrompt.pendingCredential); if (result.status !== "authenticated" && result.status !== "email-not-verified") { setError((result as any).message || "Linking failed"); return; } setLinkPrompt(null); setLinkPassword(""); safeRedirect(router, callbackUrl); } catch (e: any) { setError(handleFirebaseError(e)); } finally { setLinking(false); } }; return ( <> New to {branding.productName}?{" "} Create an account! ) : null } title={
} > {ssoProviders.length > 0 && ( )} {showEmailLogin && ( <> {ssoProviders.length > 0 && (
Or continue with email
)}
{ try { await handlePasswordLogin(email, password, type); } catch (e: any) { setError(e.message || "Authentication failed"); } }} onSSOLogin={handleSSOLogin} />
)} {error && (
setError(null)} />
)} {router.query.error && (
)} { setLinkPrompt(null); setLinkPassword(""); }} footer={ linkPrompt?.existingProvider === "password" ? [ , , ] : [ , , ] } > {error && ( // Ildar: the page-level error Alert below renders behind this // modal's mask, invisible while the modal is open — wrong // password / linking failures showed no feedback at all. Mirror // it here too. setError(null)} className="mb-3" /> )} {linkPrompt?.existingProvider === "password" ? ( <>

An account for {linkPrompt?.email} already exists using email and password. Enter your password to sign in and link this provider.

setLinkPassword(e.target.value)} onPressEnter={handleLinkWithPassword} autoFocus /> ) : (

An account for {linkPrompt?.email} already exists, signed in with{" "} {providerDisplayName(linkPrompt?.existingProvider || "")}. Sign in with that to link this account.

)}
); }; export function handleFirebaseError(error: any): string { const code = error?.code; if (code === "auth/account-exists-with-different-credential") { const email = error?.customData?.email; return `The account ${ email ? `for '${email}'` : "" } exists, but different sign in method has been used to create this account. Please try to sign in with another method.`; } else if (code === "auth/popup-closed-by-user") { return "Auth popup was closed by user. Please try again."; } else if (code === "auth/invalid-email") { return "Invalid email or password"; } else if (code === "auth/wrong-password") { return "Invalid email or password"; } else if (code === "auth/user-not-found") { return "Invalid email or password"; } else if (code === "auth/invalid-credential" || code === "auth/invalid-login-credentials") { // JITSU-019: with Firebase email-enumeration protection enabled, a failed // email/password sign-in returns this generic code instead of // auth/wrong-password or auth/user-not-found. return "Invalid email or password"; } else if (code === "auth/too-many-requests") { return "Too many signin attempts. Try later, or reset password now"; } return (error?.message || "Unknown auth error").replace("Firebase: ", ""); }