changes to the login and signup, similar to in the saas repo (#6577)

Co-authored-by: Anthony Stirling <[email protected]>
This commit is contained in:
EthanHealy01
2026-06-10 13:49:27 +01:00
committed by GitHub
co-authored by Anthony Stirling
parent 3675db5907
commit f15e405759
12 changed files with 534 additions and 194 deletions
+196 -104
View File
@@ -7,17 +7,18 @@ import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
import AuthLayout from "@app/routes/authShared/AuthLayout";
import "@app/routes/authShared/auth.css";
import "@app/routes/authShared/saas-auth.css";
import GuestSignInButton from "@app/routes/authShared/GuestSignInButton";
import {
absoluteWithBasePath,
getBaseUrl,
withBasePath,
} from "@app/constants/app";
import LinkRoundedIcon from "@mui/icons-material/LinkRounded";
// Import login components
import LoginHeader from "@app/routes/login/LoginHeader";
import ErrorMessage from "@app/routes/login/ErrorMessage";
import EmailPasswordForm from "@app/routes/login/EmailPasswordForm";
import MagicLinkForm from "@app/routes/login/MagicLinkForm";
import OAuthButtons from "@app/routes/login/OAuthButtons";
import DividerWithText from "@app/components/shared/DividerWithText";
import LoggedInState from "@app/routes/login/LoggedInState";
import { absoluteWithBasePath, getBaseUrl } from "@app/constants/app";
export default function Login() {
const navigate = useNavigate();
@@ -25,11 +26,13 @@ export default function Login() {
const { t } = useTranslation();
const [isSigningIn, setIsSigningIn] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showMagicLink, setShowMagicLink] = useState(false);
const [showMagicLinkForm, setShowMagicLinkForm] = useState(false);
const [showEmailForm, setShowEmailForm] = useState(false);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [magicLinkEmail, setMagicLinkEmail] = useState("");
const [magicLinkSent, setMagicLinkSent] = useState(false);
// Prefill email from query param (e.g. after password reset)
useEffect(() => {
try {
@@ -37,6 +40,7 @@ export default function Login() {
const emailFromQuery = url.searchParams.get("email");
if (emailFromQuery) {
setEmail(emailFromQuery);
setShowEmailForm(true);
}
} catch (_) {
// ignore
@@ -174,12 +178,9 @@ export default function Login() {
setError(error.message);
} else {
setError(null);
alert(t("login.magicLinkSent", { email: magicLinkEmail }));
setMagicLinkEmail("");
setShowMagicLink(false);
setMagicLinkSent(true);
}
} catch (err) {
console.error("[Login] Unexpected error:", err);
setError(
t("login.unexpectedError", {
message: err instanceof Error ? err.message : "Unknown error",
@@ -190,10 +191,6 @@ export default function Login() {
}
};
const handleForgotPassword = () => {
navigate("/auth/reset");
};
const handleAnonymousSignIn = async () => {
try {
setIsSigningIn(true);
@@ -227,115 +224,210 @@ export default function Login() {
}
};
const toggleEmailForm = () => {
setShowEmailForm((v) => !v);
setShowMagicLinkForm(false);
setMagicLinkSent(false);
};
const toggleMagicLink = () => {
setShowMagicLinkForm((v) => !v);
setShowEmailForm(false);
setMagicLinkSent(false);
};
return (
<AuthLayout isEmailFormExpanded={showEmailForm}>
<LoginHeader
title={t("login.login")}
subtitle={t("login.subtitle", "Sign back in to Stirling PDF")}
/>
<AuthLayout isEmailFormExpanded={showEmailForm || showMagicLinkForm}>
{/* Centered logo */}
<div className="auth-logo-block">
<img
src={withBasePath("/modern-logo/LoginLightModeHeader.svg")}
alt="Stirling PDF"
className="auth-logo-header auth-logo-header--light"
/>
<img
src={withBasePath("/modern-logo/LoginDarkModeHeader.svg")}
alt="Stirling PDF"
className="auth-logo-header auth-logo-header--dark"
/>
</div>
<ErrorMessage error={error} />
{/* OAuth first */}
<OAuthButtons
onProviderClick={signInWithProvider}
isSubmitting={isSigningIn}
layout="fullwidth"
/>
{/* OAuth + magic link group — single flex column so gap is uniform */}
<div
style={{
display: "flex",
flexDirection: "column",
gap: "0.75rem",
marginBottom: "2.5rem",
}}
>
<OAuthButtons
onProviderClick={signInWithProvider}
isSubmitting={isSigningIn}
layout="fullwidth"
labelPrefix={`${t("login.signInWith", "Sign in with")} `}
/>
{/* Divider between OAuth and Email */}
<DividerWithText
text={t("signup.or", "or")}
respondsToDarkMode={false}
opacity={0.4}
/>
{/* Magic link button + its expandable form as one unit */}
<div>
<button
type="button"
disabled={isSigningIn}
onClick={toggleMagicLink}
className={`oauth-button-fullwidth auth-expandable-trigger ${showMagicLinkForm ? "auth-expandable-trigger--active" : ""}`}
>
<span className="oauth-btn-group">
<LinkRoundedIcon
style={{
width: "1.75rem",
height: "1.75rem",
marginRight: "0.5rem",
flexShrink: 0,
}}
/>
<span className="oauth-btn-label">
{t("login.useMagicLink", "Use magic link")}
</span>
</span>
</button>
{/* Sign in with email button (primary color to match signup CTA) */}
<div className="auth-section">
<div
className={`auth-expand-grid ${showMagicLinkForm ? "auth-expand-grid--open" : ""}`}
>
<div className="auth-expand-inner">
<div style={{ paddingTop: "0.25rem" }}>
{magicLinkSent ? (
<p
style={{
fontSize: "0.875rem",
color: "#059669",
margin: 0,
}}
>
{t("login.magicLinkSent", { email: magicLinkEmail })}
</p>
) : (
<div className="auth-magic-row">
<input
type="email"
placeholder={t(
"login.enterEmailForMagicLink",
"Enter your email",
)}
value={magicLinkEmail}
onChange={(e) => setMagicLinkEmail(e.target.value)}
onKeyDown={(e) =>
e.key === "Enter" &&
!isSigningIn &&
signInWithMagicLink()
}
className="auth-input"
/>
<button
onClick={signInWithMagicLink}
disabled={isSigningIn || !magicLinkEmail}
className="auth-magic-button"
>
{isSigningIn
? t("login.sending")
: t("login.sendMagicLink")}
</button>
</div>
)}
</div>
</div>
</div>
</div>
</div>
{/* Email & Password button */}
<button
type="button"
disabled={isSigningIn}
onClick={toggleEmailForm}
className={`oauth-button-fullwidth auth-expandable-trigger ${showEmailForm ? "auth-expandable-trigger--active" : ""}`}
style={{ marginBottom: "0.75rem" }}
>
<span className="oauth-btn-group">
<span className="auth-at-icon">@</span>
<span className="oauth-btn-label">{`${t("login.signInWith", "Sign in with")} email`}</span>
</span>
</button>
{/* Email form — animated expand */}
<div
className={`auth-expand-grid ${showEmailForm ? "auth-expand-grid--open" : ""}`}
>
<div className="auth-expand-inner">
<div style={{ paddingBottom: "0.5rem" }}>
<EmailPasswordForm
email={email}
password={password}
setEmail={setEmail}
setPassword={setPassword}
onSubmit={signInWithEmail}
isSubmitting={isSigningIn}
submitButtonText={
isSigningIn ? t("login.loggingIn") : t("login.login")
}
/>
<button
type="button"
onClick={() => navigate("/auth/reset")}
className="auth-link-black"
style={{ fontSize: "0.8125rem", marginTop: "0.25rem" }}
>
{t("login.forgotPassword", "Forgot your password?")}
</button>
</div>
</div>
</div>
{/* Skip */}
<div style={{ textAlign: "center", margin: "1rem 0" }}>
<button
type="button"
onClick={() => setShowEmailForm((v) => !v)}
onClick={handleAnonymousSignIn}
disabled={isSigningIn}
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold mb-2 cursor-pointer border-0 disabled:opacity-50 disabled:cursor-not-allowed auth-cta-button"
style={{
background: "none",
border: "none",
cursor: "pointer",
fontSize: "1rem",
fontWeight: 700,
color: "#000000",
}}
>
{t("login.useEmailInstead", "Sign in with email")}
{isSigningIn
? t("login.signingIn", "Signing in...")
: `${t("signup.skip", "Skip")}`}
</button>
</div>
{showEmailForm && (
<EmailPasswordForm
email={email}
password={password}
setEmail={setEmail}
setPassword={setPassword}
onSubmit={signInWithEmail}
isSubmitting={isSigningIn}
submitButtonText={
isSigningIn ? t("login.loggingIn") : t("login.login")
}
/>
)}
{showEmailForm && (
<div className="auth-section-sm">
<button
type="button"
onClick={handleForgotPassword}
className="auth-link-black"
>
{t("login.forgotPassword", "Forgot your password?")}
</button>
</div>
)}
{/* Divider then Guest */}
<DividerWithText
text={t("signup.or", "or")}
respondsToDarkMode={false}
opacity={0.4}
/>
<GuestSignInButton
onClick={handleAnonymousSignIn}
disabled={isSigningIn}
label={
isSigningIn
? t("login.signingIn", "Signing in...")
: t("login.signInAnonymously", "Sign in as a Guest")
}
/>
<div className="auth-bottom-row">
<button
type="button"
onClick={() => setShowMagicLink(true)}
className="auth-link-black"
>
{t("login.useMagicLink", "Sign in with magic link")}
</button>
{/* Bottom */}
<div
style={{
textAlign: "center",
marginTop: "auto",
paddingTop: "1rem",
}}
>
<button
type="button"
onClick={() => navigate("/signup")}
className="auth-link-black"
style={{
background: "none",
border: "none",
cursor: "pointer",
fontSize: "0.875rem",
color: "#9ca3af",
}}
>
{t("signup.signUp", "Sign up")}
{t("login.createAccount", "Create an account")}
</button>
</div>
{/* Magic link form renders on demand */}
{showMagicLink && (
<div style={{ marginTop: "0.5rem" }}>
<MagicLinkForm
showMagicLink={showMagicLink}
magicLinkEmail={magicLinkEmail}
setMagicLinkEmail={setMagicLinkEmail}
setShowMagicLink={setShowMagicLink}
onSubmit={signInWithMagicLink}
isSubmitting={isSigningIn}
/>
</div>
)}
</AuthLayout>
);
}
+84 -59
View File
@@ -4,18 +4,15 @@ import { signInAnonymously } from "@app/auth/supabase";
import { useAuth } from "@app/auth/UseSession";
import { useTranslation } from "@app/hooks/useTranslation";
import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
import { getBaseUrl } from "@app/constants/app";
import { getBaseUrl, withBasePath } from "@app/constants/app";
import AuthLayout from "@app/routes/authShared/AuthLayout";
import "@app/routes/authShared/auth.css";
import "@app/routes/authShared/saas-auth.css";
import GuestSignInButton from "@app/routes/authShared/GuestSignInButton";
import { alert } from "@app/components/toast";
// Import signup components
import LoginHeader from "@app/routes/login/LoginHeader";
import ErrorMessage from "@app/routes/login/ErrorMessage";
import OAuthButtons from "@app/routes/login/OAuthButtons";
import DividerWithText from "@app/components/shared/DividerWithText";
import SignupForm from "@app/routes/signup/SignupForm";
import {
useSignupFormValidation,
@@ -184,84 +181,112 @@ export default function Signup() {
return (
<AuthLayout isEmailFormExpanded={showEmailForm}>
<LoginHeader title={t("signup.title")} subtitle={t("signup.subtitle")} />
{/* Centered logo */}
<div className="auth-logo-block">
<img
src={withBasePath("/modern-logo/LoginLightModeHeader.svg")}
alt="Stirling PDF"
className="auth-logo-header auth-logo-header--light"
/>
<img
src={withBasePath("/modern-logo/LoginDarkModeHeader.svg")}
alt="Stirling PDF"
className="auth-logo-header auth-logo-header--dark"
/>
</div>
<ErrorMessage error={error} />
{/* OAuth first */}
<div style={{ marginBottom: "0.5rem" }}>
{/* OAuth providers */}
<div>
<OAuthButtons
onProviderClick={handleProviderSignIn}
isSubmitting={isSigningUp}
layout="fullwidth"
labelPrefix={`${t("signup.signUpWith", "Sign up with")} `}
/>
</div>
{/* Divider between OAuth and Email */}
<div style={{ margin: "0.5rem 0" }}>
<DividerWithText
text={t("signup.or", "or")}
respondsToDarkMode={false}
opacity={0.4}
/>
{/* Email & Password button */}
<button
type="button"
disabled={isSigningUp}
onClick={() => setShowEmailForm((v) => !v)}
className={`oauth-button-fullwidth auth-expandable-trigger ${showEmailForm ? "auth-expandable-trigger--active" : ""}`}
style={{ marginTop: "2.5rem", marginBottom: "0.75rem" }}
>
<span className="oauth-btn-group">
<span className="auth-at-icon">@</span>
<span className="oauth-btn-label">{`${t("signup.signUpWith", "Sign up with")} email`}</span>
</span>
</button>
{/* Email form — animated expand */}
<div
className={`auth-expand-grid ${showEmailForm ? "auth-expand-grid--open" : ""}`}
>
<div className="auth-expand-inner">
<div style={{ paddingBottom: "0.5rem" }}>
<SignupForm
name={name}
email={email}
password={password}
confirmPassword={confirmPassword}
agree={agree}
setName={setName}
setEmail={setEmail}
setPassword={setPassword}
setConfirmPassword={setConfirmPassword}
setAgree={setAgree}
onSubmit={handleSignUp}
isSubmitting={isSigningUp}
fieldErrors={fieldErrors}
/>
</div>
</div>
</div>
{/* Use Email Instead button (toggles email form) */}
<div className="auth-section">
{/* Skip */}
<div style={{ textAlign: "center", margin: "1rem 0" }}>
<button
type="button"
onClick={handleAnonymousSignIn}
disabled={isSigningUp}
onClick={() => setShowEmailForm((v) => !v)}
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold mb-2 cursor-pointer border-0 disabled:opacity-50 disabled:cursor-not-allowed auth-cta-button"
style={{
background: "none",
border: "none",
cursor: "pointer",
fontSize: "1rem",
fontWeight: 700,
color: "#000000",
}}
>
{t("signup.useEmailInstead", "Use Email Instead")}
{isSigningUp
? t("login.signingIn", "Signing in...")
: `${t("signup.skip", "Skip")}`}
</button>
</div>
{showEmailForm && (
<SignupForm
name={name}
email={email}
password={password}
confirmPassword={confirmPassword}
agree={agree}
setName={setName}
setEmail={setEmail}
setPassword={setPassword}
setConfirmPassword={setConfirmPassword}
setAgree={setAgree}
onSubmit={handleSignUp}
isSubmitting={isSigningUp}
fieldErrors={fieldErrors}
/>
)}
<div className="auth-section-sm">
<DividerWithText
text={t("signup.or", "or")}
respondsToDarkMode={false}
opacity={0.4}
/>
</div>
<GuestSignInButton
onClick={handleAnonymousSignIn}
disabled={isSigningUp}
label={
isSigningUp
? t("login.signingIn", "Signing in...")
: t("login.signInAnonymously", "Sign in as a Guest")
}
/>
{/* Bottom row */}
<div className="auth-bottom-right">
{/* Bottom */}
<div
style={{
textAlign: "center",
marginTop: "auto",
paddingTop: "1rem",
}}
>
<button
type="button"
onClick={() => navigate("/login")}
className="auth-link-black"
style={{
background: "none",
border: "none",
cursor: "pointer",
fontSize: "0.875rem",
color: "#9ca3af",
}}
>
{t("login.logIn", "Log In")}
{t("signup.alreadyHaveAccount", "I already have an account")}
</button>
</div>
</AuthLayout>
@@ -14,7 +14,7 @@
justify-content: center;
padding: 0.75rem 1rem;
border: 1px solid #d1d5db;
border-radius: 0.625rem;
border-radius: 100px;
background-color: #ffffff;
font-size: 1rem;
font-weight: 600;
@@ -22,6 +22,10 @@
cursor: pointer;
gap: 0.5rem;
box-shadow: 0 0.125rem 0.375rem rgba(0, 0, 0, 0.04);
transition:
background-color 150ms ease,
box-shadow 150ms ease,
border-color 150ms ease;
}
.oauth-button-fullwidth:disabled {
@@ -29,6 +33,11 @@
opacity: 0.6;
}
.oauth-button-fullwidth:hover:not(:disabled) {
background-color: #fafafa;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.auth-dropdown-wrapper {
position: relative;
}
@@ -72,3 +81,90 @@
color: #9c2f30;
border: 2px solid currentColor;
}
/* ── Logo block ─────────────────────────────────────────────────────── */
.auth-logo-block {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 2rem;
margin-top: 0.5rem;
animation: authFadeUp 0.4s ease both;
}
.auth-logo-header {
height: 8rem;
width: auto;
}
.auth-logo-header--dark {
display: none;
}
/* ── Page entrance animation ────────────────────────────────────────── */
@keyframes authFadeUp {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ── Expandable trigger button state ────────────────────────────────── */
.auth-expandable-trigger {
transition:
background-color 180ms ease,
box-shadow 180ms ease,
border-color 180ms ease;
}
.auth-expandable-trigger--active {
border-color: #af3434 !important;
box-shadow: 0 0 0 3px rgba(175, 52, 52, 0.12);
}
/* ── Animated expand/collapse via grid-template-rows ───────────────── */
.auth-expand-grid {
display: grid;
grid-template-rows: 0fr;
transition:
grid-template-rows 280ms cubic-bezier(0.4, 0, 0.2, 1),
opacity 220ms ease;
opacity: 0;
}
.auth-expand-grid--open {
grid-template-rows: 1fr;
opacity: 1;
}
.auth-expand-inner {
overflow: hidden;
min-height: 0;
}
/* ── Icon+label group — keeps icons vertically aligned across buttons ── */
.oauth-btn-group {
display: inline-flex;
align-items: center;
min-width: 12.5rem;
}
.oauth-btn-label {
flex: 1;
text-align: center;
}
/* ── Icon helpers inside oauth-button-fullwidth ─────────────────────── */
.auth-at-icon {
font-size: 1.25rem;
line-height: 1;
margin-right: 0.5rem;
display: inline-block;
width: 1.75rem;
text-align: center;
flex-shrink: 0;
}
@@ -17,6 +17,7 @@ interface OAuthButtonsProps {
isSubmitting: boolean;
layout?: "vertical" | "grid" | "icons" | "fullwidth";
enabledProviders?: string[]; // List of enabled provider IDs from backend
labelPrefix?: string;
}
export default function OAuthButtons({
@@ -24,6 +25,7 @@ export default function OAuthButtons({
isSubmitting,
layout = "vertical",
enabledProviders: _enabledProviders = [],
labelPrefix = "",
}: OAuthButtonsProps) {
const { t } = useTranslation();
@@ -92,12 +94,18 @@ export default function OAuthButtons({
className="oauth-button-fullwidth"
title={p.label}
>
<img
src={withBasePath(`/Login/${p.file}`)}
alt={p.label}
className={`oauth-icon-medium ${p.isDisabled ? "opacity-20" : ""}`}
/>
{p.label}
<span className="oauth-btn-group">
<img
src={withBasePath(`/Login/${p.file}`)}
alt={p.label}
className={`oauth-icon-medium ${p.isDisabled ? "opacity-20" : ""}`}
style={{ marginRight: "0.5rem", flexShrink: 0 }}
/>
<span className="oauth-btn-label">
{labelPrefix}
{p.label}
</span>
</span>
</button>
))}
</div>
@@ -35,7 +35,7 @@
--auth-label-text: #2b3230;
--auth-button-bg: #af3434;
--auth-button-text: #ffffff;
--auth-magic-button-bg: #8b5cf6;
--auth-magic-button-bg: #af3434;
--auth-magic-button-text: #ffffff;
/* Light-only auth colors (no dark mode equivalents) used for login/signup */
@@ -45,7 +45,7 @@
--auth-label-text-light-only: #2b3230;
--auth-button-bg-light-only: #af3434;
--auth-button-text-light-only: #ffffff;
--auth-magic-button-bg-light-only: #8b5cf6;
--auth-magic-button-bg-light-only: #af3434;
--auth-magic-button-text-light-only: #ffffff;
--auth-bg-color-light-only: #ffffff;
--auth-card-bg-light-only: #ffffff;