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