webapps/console/components/SignInOrUp/SignInOrUp.tsx
Rama newjitsu · réplica de lectura del repositorio original.
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: ", "");
}