Ctrl K
ComunidadGuías…

webapps/console/components/SignInOrUp/SignInOrUp.tsx

Rama newjitsu · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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 (
    <div className="flex items-center w-fit h-full space-x-2">
      <div className="aspect-square h-full">{branding.logo}</div>
      <div className="text-textDark h-4/6">{branding.wordmark}</div>
    </div>
  );
}

//TODO: detect that callbackUrl leads to /accept and show appropriate message
export const SigninLayout: React.FC<PropsWithChildren<{ title: ReactNode; footer?: ReactNode }>> = ({
  title,
  children,
  footer,
}) => {
  const router = useRouter();
  return (
    <div className="flex min-h-screen flex-col justify-center py-12 sm:px-6 lg:px-8">
      <div className={"flex flex-col items-center sm:justify-center grow"}>
        <div className="bg-backgroundLight p-8 border border-backgroundDark rounded-md shadow-sm mx-4">
          <div className="flex justify-center text-3xl font-header font-bold mb-4">{title}</div>
          {router.query.invite && (
            <div className="text text-textLight">Login to Jitsu to accept the invitation, or create an account</div>
          )}
          <div className="debug">{children}</div>
        </div>
        {footer && <div>{footer}</div>}
      </div>
    </div>
  );
};

export type SigninProps = {
  signup?: boolean;
};

export const SignInOrUp: React.FC<SigninProps> = ({ signup }) => {
  const queryStringCopy = useQueryStringCopy();
  const router = useRouter();
  const appConfig = useAppConfig();
  const firebaseSession = useFirebaseSession();
  const { analytics } = useJitsu();
  const [error, setError] = useState<string | null>(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<ReturnType<typeof useFirebaseSession>["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 (
    <>
      <SigninLayout
        footer={
          showSignupLink ? (
            <div className="text-center mt-4">
              New to {branding.productName}?{" "}
              <Link className="font-bold text-primary" href={`/signup${queryStringCopy}`}>
                Create an account!
              </Link>
            </div>
          ) : null
        }
        title={
          <div className="h-12 flex justify-center">
            <JitsuLogo />
          </div>
        }
      >
        {ssoProviders.length > 0 && (
          <OAuthButtons prefix={signup ? "Sign Up" : "Sign In"} providers={ssoProviders} onSSOLogin={handleSSOLogin} />
        )}
        {showEmailLogin && (
          <>
            {ssoProviders.length > 0 && (
              <div className="relative mt-6 mb-6">
                <div className="absolute inset-0 flex items-center">
                  <div className="w-full border-t border-gray-300" />
                </div>
                <div className="relative flex justify-center text-sm">
                  <span className="px-2 bg-backgroundLight text-gray-500">Or continue with email</span>
                </div>
              </div>
            )}
            <div className={ssoProviders.length === 0 ? "" : ""}>
              <EmailFirstLogin
                onPasswordLogin={async (email, password, type) => {
                  try {
                    await handlePasswordLogin(email, password, type);
                  } catch (e: any) {
                    setError(e.message || "Authentication failed");
                  }
                }}
                onSSOLogin={handleSSOLogin}
              />
            </div>
          </>
        )}
        {error && (
          <div className="mx-auto max-w-[350px] mt-4">
            <Alert type="error" message={error} closable onClose={() => setError(null)} />
          </div>
        )}

        {router.query.error && (
          <div className="mx-auto max-w-[350px] mt-4">
            <Alert type="error" message={`Authentication error: ${router.query.error}`} closable />
          </div>
        )}
      </SigninLayout>
      <Modal
        open={!!linkPrompt}
        title="Account already exists"
        onCancel={() => {
          setLinkPrompt(null);
          setLinkPassword("");
        }}
        footer={
          linkPrompt?.existingProvider === "password"
            ? [
                <Button
                  key="cancel"
                  onClick={() => {
                    setLinkPrompt(null);
                    setLinkPassword("");
                  }}
                >
                  Cancel
                </Button>,
                <Button key="confirm" type="primary" loading={linking} onClick={handleLinkWithPassword}>
                  Sign in and link
                </Button>,
              ]
            : [
                <Button key="cancel" onClick={() => setLinkPrompt(null)}>
                  Cancel
                </Button>,
                <Button key="confirm" type="primary" loading={linking} onClick={handleLinkConfirm}>
                  Sign in with {providerDisplayName(linkPrompt?.existingProvider || "")}
                </Button>,
              ]
        }
      >
        {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.
          <Alert type="error" message={error} closable onClose={() => setError(null)} className="mb-3" />
        )}
        {linkPrompt?.existingProvider === "password" ? (
          <>
            <p>
              An account for <strong>{linkPrompt?.email}</strong> already exists using email and password. Enter your
              password to sign in and link this provider.
            </p>
            <Input.Password
              placeholder="Password"
              value={linkPassword}
              onChange={e => setLinkPassword(e.target.value)}
              onPressEnter={handleLinkWithPassword}
              autoFocus
            />
          </>
        ) : (
          <p>
            An account for <strong>{linkPrompt?.email}</strong> already exists, signed in with{" "}
            {providerDisplayName(linkPrompt?.existingProvider || "")}. Sign in with that to link this account.
          </p>
        )}
      </Modal>
    </>
  );
};

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: ", "");
}