Slim down login/signup to a single centered form (#7033)

Replaces the two-column carousel login/signup with a single clean
centered form, uniform across proprietary, saas, desktop, and the portal
(all now route through the shared single-column `AuthShell`). Adds a
Storybook playground under **Auth → Auth Screens** (provider /
login-method controls) to iterate on it.

## Before / After

<img width="1600" height="920" alt="image"
src="https://github.com/user-attachments/assets/6e3f166f-40df-468e-825a-bc4c7880406c"
/>

<img width="2056" height="1000" alt="Screenshot 2026-07-14 at 6 11
11 PM"
src="https://github.com/user-attachments/assets/e015efc9-71e5-4b9b-b64a-5ba1dbf2fcec"
/>
This commit is contained in:
EthanHealy01
2026-07-16 23:02:49 +00:00
committed by GitHub
parent 6b2ab5a743
commit d271b8f357
36 changed files with 735 additions and 1305 deletions
@@ -4702,6 +4702,7 @@ login = "Login"
magicLinkSent = "Magic link sent to {{email}}! Check your email and click the link to sign in."
mfaCode = "Authentication Code"
mfaRequired = "Two-factor code required"
noAccount = "Don't have an account?"
or = "Or"
password = "Password"
passwordChangedSuccess = "Password changed successfully! Please sign in with your new password."
@@ -4726,21 +4727,6 @@ useMagicLink = "Use magic link instead"
username = "Username"
youAreLoggedIn = "You are logged in!"
[login.slides.edit]
alt = "Edit PDFs"
subtitle = "With over a dozen tools to help you redact, sign, read and manipulate PDFs, you will be sure to find what you are looking for."
title = "Edit PDFs to display/secure the information you want"
[login.slides.overview]
alt = "Stirling PDF overview"
subtitle = "A privacy-first cloud suite for PDFs that lets you convert, sign, redact, and manage documents, along with 50+ other powerful tools."
title = "Your one-stop-shop for all your PDF needs."
[login.slides.secure]
alt = "Secure PDFs"
subtitle = "Add passwords, redact content, and manage certificates with ease."
title = "Protect sensitive information in your PDFs"
[margin]
large = "Large"
medium = "Medium"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 717 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

+3 -5
View File
@@ -786,22 +786,20 @@
.wordmark-dark-only {
display: none;
}
[data-mantine-color-scheme="dark"] .wordmark-light-only {
[data-mantine-color-scheme="dark"] .wordmark {
display: none;
}
[data-mantine-color-scheme="dark"] .wordmark-dark-only {
display: block;
}
/* Theme-aware image display utilities: .theme-img-light-only shows in light
mode only, .theme-img-dark-only shows in dark mode only. */
.theme-img-light-only {
.theme-img {
display: inline;
}
.theme-img-dark-only {
display: none;
}
[data-mantine-color-scheme="dark"] .theme-img-light-only {
[data-mantine-color-scheme="dark"] .theme-img {
display: none;
}
[data-mantine-color-scheme="dark"] .theme-img-dark-only {
@@ -162,34 +162,4 @@ test.describe("1. Authentication and Login", () => {
});
});
});
test.describe("1.6 Login Page - Carousel/Slideshow", () => {
test("should navigate between carousel slides", async ({ page }) => {
// Carousel is hidden on small viewports (< 940px wide), ensure desktop size
await page.setViewportSize({ width: 1920, height: 1080 });
// Starting state: User is logged out; browser on /login
await page.goto("/login");
await page.waitForLoadState("domcontentloaded");
// Step 1: Verify slide indicator dots are present (carousel uses aria-label "Go to slide N")
const slideButtons = page.getByRole("button", { name: /Go to slide/i });
const count = await slideButtons.count();
test.skip(count === 0, "No carousel slides configured on this instance");
// Step 2: Click through slides
if (count >= 2) {
await slideButtons.nth(1).click();
await page.waitForTimeout(500);
}
if (count >= 3) {
await slideButtons.nth(2).click();
await page.waitForTimeout(500);
}
// Step 3: Click back to slide 1
await slideButtons.nth(0).click();
await page.waitForTimeout(500);
});
});
});
@@ -1,9 +1,5 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import LoginRightCarousel from "@app/auth/ui/LoginRightCarousel";
import buildLoginSlides from "@app/components/shared/loginSlides";
import styles from "@app/auth/ui/AuthShell.module.css";
import { useLogoVariant } from "@app/hooks/useLogoVariant";
import React from "react";
import { AuthShell } from "@app/auth/ui/AuthShell";
interface DesktopAuthLayoutProps {
children: React.ReactNode;
@@ -12,52 +8,5 @@ interface DesktopAuthLayoutProps {
export const DesktopAuthLayout: React.FC<DesktopAuthLayoutProps> = ({
children,
}) => {
const { t } = useTranslation();
const cardRef = useRef<HTMLDivElement | null>(null);
const [hideRightPanel, setHideRightPanel] = useState(false);
const logoVariant = useLogoVariant();
const imageSlides = useMemo(
() => buildLoginSlides(logoVariant, t),
[logoVariant, t],
);
useEffect(() => {
const update = () => {
// Use viewport to avoid hysteresis when the card is already in single-column mode
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const cardWidthIfTwoCols = Math.min(1180, viewportWidth * 0.96); // matches min(73.75rem, 96vw)
const columnWidth = cardWidthIfTwoCols / 2;
const tooNarrow = columnWidth < 470;
const tooShort = viewportHeight < 740;
setHideRightPanel(tooNarrow || tooShort);
};
update();
window.addEventListener("resize", update);
window.addEventListener("orientationchange", update);
return () => {
window.removeEventListener("resize", update);
window.removeEventListener("orientationchange", update);
};
}, []);
return (
<div className={styles.authContainer}>
<div
ref={cardRef}
className={`${styles.authCard} ${!hideRightPanel ? styles.authCardTwoColumns : ""}`}
>
<div className={styles.authLeftPanel}>
<div className={styles.authContent}>{children}</div>
</div>
{!hideRightPanel && (
<LoginRightCarousel
imageSlides={imageSlides}
initialSeconds={5}
slideSeconds={8}
/>
)}
</div>
</div>
);
return <AuthShell>{children}</AuthShell>;
};
@@ -17,12 +17,12 @@
own padding-block); horizontal stays in the shorthand. */
--sui-btn-py: 1rem; /* 16px (md) */
padding: 1rem 1rem; /* 16px */
border: 1px solid var(--auth-input-border-light-only);
border: 1px solid var(--auth-input-border);
border-radius: 0.75rem; /* 12px */
background-color: var(--auth-card-bg-light-only);
background-color: var(--auth-card-bg);
font-size: 1rem; /* 16px */
font-weight: 500;
color: var(--auth-text-primary-light-only);
color: var(--auth-text-primary);
cursor: pointer;
gap: 0.75rem; /* 12px */
font-family: inherit;
@@ -39,7 +39,7 @@
}
.oauth-button-vertical-desktop:focus-visible {
outline: 2px solid var(--auth-border-focus-light-only);
outline: 2px solid var(--auth-border-focus);
outline-offset: 2px;
}
@@ -1,8 +1,4 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { AuthShell } from "@app/auth/ui/AuthShell";
import LoginRightCarousel from "@app/auth/ui/LoginRightCarousel";
import { buildDefaultLoginSlides } from "@app/auth/ui/loginSlides";
import SpringLoginForm from "@app/auth/ui/SpringLoginForm";
import { useSpringLogin } from "@app/auth/ui/useSpringLogin";
import { withBasePath } from "@app/constants/app";
@@ -12,7 +8,7 @@ import loginHeader from "@app/assets/brand/modern-logo/LoginLightModeHeader.svg"
/**
* Full-screen login shown by the portal's auth gate. Renders the shared
* AuthShell + carousel with the Spring form/auth wiring from @app/auth/ui.
* AuthShell with the Spring form/auth wiring from @app/auth/ui.
*
* It follows the user's light/dark theme (AuthShell is theme-aware — the same
* screen the editor login uses; passing both logo variants keeps the header
@@ -20,23 +16,10 @@ import loginHeader from "@app/assets/brand/modern-logo/LoginLightModeHeader.svg"
* needs to collect credentials.
*/
export function LoginScreen() {
const { t } = useTranslation();
const login = useSpringLogin();
const slides = useMemo(
() => buildDefaultLoginSlides((key, fallback) => t(key, fallback)),
[t],
);
return (
<AuthShell
rightPanel={
<LoginRightCarousel
imageSlides={slides}
initialSeconds={5}
slideSeconds={8}
/>
}
>
<AuthShell>
<SpringLoginForm
state={login}
logoSrc={loginHeader}
@@ -0,0 +1,55 @@
import { Alert, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
/**
* First-time-setup notice showing the default admin credentials. Rendered
* beneath the login form when the backend reports a fresh install.
*/
export default function AuthDefaultCredentials() {
const { t } = useTranslation();
return (
<Alert color="blue" variant="light" radius="md" mt="xl">
<Stack gap="xs" align="center">
<Text
size="sm"
fw={600}
ta="center"
style={{ color: "var(--text-primary)" }}
>
{t("login.defaultCredentials", "Default Login Credentials")}
</Text>
<Text size="sm" ta="center" style={{ color: "var(--text-primary)" }}>
<Text
component="span"
fw={600}
style={{ color: "var(--text-primary)" }}
>
{t("login.username", "Username")}:
</Text>{" "}
admin
</Text>
<Text size="sm" ta="center" style={{ color: "var(--text-primary)" }}>
<Text
component="span"
fw={600}
style={{ color: "var(--text-primary)" }}
>
{t("login.password", "Password")}:
</Text>{" "}
stirling
</Text>
<Text
size="xs"
ta="center"
mt="xs"
style={{ color: "var(--text-muted)" }}
>
{t(
"login.changePasswordWarning",
"Please change your password after logging in for the first time",
)}
</Text>
</Stack>
</Alert>
);
}
@@ -0,0 +1,203 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react";
import { AuthShell } from "@app/auth/ui/AuthShell";
import SpringLoginForm from "@app/auth/ui/SpringLoginForm";
import AuthSignupPrompt from "@app/auth/ui/AuthSignupPrompt";
import AuthDefaultCredentials from "@app/auth/ui/AuthDefaultCredentials";
import type { SpringLoginState } from "@app/auth/ui/useSpringLogin";
import ErrorMessage from "@app/auth/ui/ErrorMessage";
import SignupForm from "@app/routes/signup/SignupForm";
import DividerWithText from "@app/components/shared/DividerWithText";
import { Button } from "@app/ui/Button";
import loginHeader from "@app/assets/brand/modern-logo/LoginLightModeHeader.svg";
import "@app/auth/ui/auth-theme.css";
import "@app/auth/ui/auth.css";
const darkLogo = "/modern-logo/LoginDarkModeHeader.svg";
/** Every provider the OAuth buttons know how to render, for stress testing. */
const ALL_PROVIDERS = [
"google",
"github",
"apple",
"azure",
"keycloak",
"cloudron",
"authentik",
"oidc",
];
type LoginMethod = "all" | "normal" | "oauth2";
interface LoginArgs {
/** OAuth providers to render (stress test the button stack here). */
providers: string[];
/** all = OAuth + email · normal = email only · oauth2 = SSO only. */
loginMethod: LoginMethod;
/** Show the "Don't have an account? Sign up" prompt below the form. */
showSignupPrompt: boolean;
/** Show the first-time-setup default admin credentials card. */
showDefaultCredentials: boolean;
}
/** Interactive SpringLoginState without the network/config fetch. */
function useFakeSpringLogin(
providers: string[],
loginMethod: LoginMethod,
): SpringLoginState {
// Prefill so the submit CTA renders in its enabled (filled) state.
const [email, setEmail] = useState("you@company.com");
const [password, setPassword] = useState("password");
const [mfaCode, setMfaCode] = useState("");
const isUserPassAllowed = loginMethod === "all" || loginMethod === "normal";
return {
email,
setEmail,
password,
setPassword,
mfaCode,
setMfaCode,
requiresMfa: false,
error: null,
setError: () => {},
isSubmitting: false,
providers,
loginMethod,
isUserPassAllowed,
hasProviders: providers.length > 0,
signInWithEmail: async () => {},
signInWithProvider: async () => {},
};
}
function LoginPreview({
providers,
loginMethod,
showSignupPrompt,
showDefaultCredentials,
}: LoginArgs) {
const login = useFakeSpringLogin(providers, loginMethod);
return (
<AuthShell>
<SpringLoginForm
state={login}
logoSrc={loginHeader}
logoDarkSrc={darkLogo}
showEmailForm={login.isUserPassAllowed}
footer={
<>
{showDefaultCredentials && <AuthDefaultCredentials />}
{showSignupPrompt && <AuthSignupPrompt onSignUp={() => {}} />}
</>
}
/>
</AuthShell>
);
}
function SignupPreview() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
return (
<AuthShell>
<div className="auth-logo-block">
<img
src={loginHeader}
alt="Stirling PDF"
className="auth-logo-header auth-logo-header--light"
/>
<img
src={darkLogo}
alt="Stirling PDF"
className="auth-logo-header auth-logo-header--dark"
/>
</div>
<ErrorMessage error={null} />
<SignupForm
email={email}
password={password}
confirmPassword={confirmPassword}
setEmail={setEmail}
setPassword={setPassword}
setConfirmPassword={setConfirmPassword}
onSubmit={() => {}}
isSubmitting={false}
/>
<DividerWithText text="or" respondsToDarkMode={false} opacity={0.4} />
<div style={{ textAlign: "center", margin: "0.5rem 0 0.25rem" }}>
<Button variant="tertiary" className="auth-link-black">
Log In
</Button>
</div>
</AuthShell>
);
}
/**
* The slim single-column auth screens: one narrow card centered on the page.
* Uses a fake login state so the full screen renders without a backend. The
* Login stories expose provider/login-method controls in the playground so the
* OAuth button stack can be stress tested with any number of providers.
*/
const meta: Meta<LoginArgs> = {
title: "Auth/Auth Screens",
parameters: { layout: "fullscreen" },
argTypes: {
providers: {
control: "check",
options: ALL_PROVIDERS,
description: "OAuth providers rendered above the email form",
},
loginMethod: {
control: "inline-radio",
options: ["all", "normal", "oauth2"],
description:
"all = OAuth + email · normal = email only · oauth2 = SSO only",
},
showSignupPrompt: {
control: "boolean",
description: "Show the sign-up prompt below the form",
},
showDefaultCredentials: {
control: "boolean",
description: "Show the first-time-setup default admin credentials card",
},
},
args: {
providers: ["google", "github", "oidc"],
loginMethod: "all",
showSignupPrompt: true,
showDefaultCredentials: false,
},
};
export default meta;
type Story = StoryObj<LoginArgs>;
export const Login: Story = {
render: (args) => <LoginPreview {...args} />,
};
export const AllProviders: Story = {
args: { providers: ALL_PROVIDERS },
render: (args) => <LoginPreview {...args} />,
};
export const LoginSsoOnly: Story = {
args: { loginMethod: "oauth2" },
render: (args) => <LoginPreview {...args} />,
};
export const LoginEmailOnly: Story = {
args: { providers: [] },
render: (args) => <LoginPreview {...args} />,
};
export const FirstTimeSetup: Story = {
args: { showDefaultCredentials: true },
render: (args) => <LoginPreview {...args} />,
};
export const Signup: Story = {
render: () => <SignupPreview />,
};
@@ -5,7 +5,7 @@
flex-direction: column;
align-items: center;
justify-content: center;
background-color: var(--auth-bg-color-light-only);
background-color: var(--auth-bg-color);
padding: 1.5rem 1.5rem 0;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
@@ -13,37 +13,23 @@
}
.authCard {
width: min(45rem, 96vw);
height: min(50.875rem, 96vh);
display: grid;
grid-template-columns: 1fr;
width: min(26rem, 96vw);
max-height: 96vh;
padding: 2.5rem 2rem;
display: flex;
justify-content: center;
background-color: var(--auth-card-bg);
border-radius: 1.25rem;
box-shadow: 0 1.25rem 3.75rem rgba(0, 0, 0, 0.12);
overflow: hidden;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
}
.authCardTwoColumns {
width: min(73.75rem, 96vw);
grid-template-columns: 1fr 1fr;
}
.authLeftPanel {
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
overflow: hidden;
min-height: 0;
height: 100%;
}
.authLeftPanel::-webkit-scrollbar {
.authCard::-webkit-scrollbar {
display: none; /* WebKit browsers (Chrome, Safari, Edge) */
}
.authContent {
max-width: 26.25rem; /* 420px */
max-width: 22rem; /* 352px */
width: 100%;
}
@@ -1,56 +1,22 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import type { ReactNode } from "react";
import styles from "@app/auth/ui/AuthShell.module.css";
export interface AuthShellProps {
children: ReactNode;
/** Optional panel shown beside the form on wide/tall viewports (the carousel). */
rightPanel?: ReactNode;
/** Optional fixed footer slot (the editor passes its legal/cookie footer). */
footer?: ReactNode;
}
/**
* The login card shell shared by the editor and the portal: a centered card
* that expands to two columns (form + right panel) on wide/tall viewports and
* collapses to a single column otherwise. Purely presentational - callers
* provide the form (children), the right panel, and an optional footer.
* The login card shell shared by the editor and the portal: a single narrow
* card centered on the screen. Purely presentational - callers provide the
* form (children) and an optional footer.
*/
export function AuthShell({ children, rightPanel, footer }: AuthShellProps) {
const cardRef = useRef<HTMLDivElement | null>(null);
const [hideRightPanel, setHideRightPanel] = useState(false);
useEffect(() => {
const update = () => {
// Use viewport to avoid hysteresis when the card is already single-column.
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const cardWidthIfTwoCols = Math.min(1180, viewportWidth * 0.96); // min(73.75rem, 96vw)
const columnWidth = cardWidthIfTwoCols / 2;
const tooNarrow = columnWidth < 470;
const tooShort = viewportHeight < 740;
setHideRightPanel(tooNarrow || tooShort);
};
update();
window.addEventListener("resize", update);
window.addEventListener("orientationchange", update);
return () => {
window.removeEventListener("resize", update);
window.removeEventListener("orientationchange", update);
};
}, []);
const showRightPanel = Boolean(rightPanel) && !hideRightPanel;
export function AuthShell({ children, footer }: AuthShellProps) {
return (
<div className={styles.authContainer}>
<div
ref={cardRef}
className={`${styles.authCard} ${showRightPanel ? styles.authCardTwoColumns : ""}`}
>
<div className={styles.authLeftPanel}>
<div className={styles.authContent}>{children}</div>
</div>
{showRightPanel && rightPanel}
<div className={styles.authCard}>
<div className={styles.authContent}>{children}</div>
</div>
{footer && (
<div
@@ -0,0 +1,30 @@
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui/Button";
import "@app/auth/ui/auth.css";
interface AuthSignupPromptProps {
/** Navigate to the signup screen. */
onSignUp: () => void;
}
/**
* "Don't have an account? Sign up" row shown beneath the login form. The prompt
* is muted; the action reads as a brand-coloured link.
*/
export default function AuthSignupPrompt({ onSignUp }: AuthSignupPromptProps) {
const { t } = useTranslation();
return (
<div className="auth-signup-prompt">
<span>{t("login.noAccount", "Don't have an account?")}</span>
<Button
type="button"
variant="quiet"
accent="brand"
onClick={onSignUp}
className="auth-signup-link"
>
{t("signup.signUp", "Sign up")}
</Button>
</div>
);
}
@@ -3,18 +3,20 @@ import { Button } from "@app/ui/Button";
import "@app/auth/ui/auth.css";
import { TextInput, PasswordInput } from "@mantine/core";
// Force light mode styles for auth inputs
const authInputStyles = {
// Theme-aware auth input colours (the --auth-* vars flip in dark mode via
// auth-theme.css). Exported so other auth screens (e.g. invite accept) render
// their Mantine inputs identically to login.
export const authInputStyles = {
input: {
backgroundColor: "var(--auth-input-bg-light-only)",
color: "var(--auth-input-text-light-only)",
borderColor: "var(--auth-input-border-light-only)",
backgroundColor: "var(--auth-input-bg)",
color: "var(--auth-input-text)",
borderColor: "var(--auth-input-border)",
"&:focus": {
borderColor: "var(--auth-border-focus-light-only)",
borderColor: "var(--auth-border-focus)",
},
},
label: {
color: "var(--auth-label-text-light-only)",
color: "var(--auth-label-text)",
},
};
@@ -130,7 +132,10 @@ export default function EmailPasswordForm({
(requiresMfa && !mfaCode.trim())
}
fullWidth
size="lg"
fontSize="sm"
loading={isSubmitting}
className="auth-submit"
// Stirling-red brand CTA; the brand accent sets the colour inline so the
// host app's Mantine primaryColor can't win (editor vs portal differ).
accent="brand"
@@ -1,218 +0,0 @@
import { memo, useEffect, useMemo, useRef, useState } from "react";
import { CarouselDots } from "@app/ui/CarouselDots";
import bgDefault from "@app/assets/login/LoginBackgroundPanel.png";
export type ImageSlide = {
src: string;
alt?: string;
cornerModelUrl?: string;
title?: string;
subtitle?: string;
followMouseTilt?: boolean;
tiltMaxDeg?: number;
};
function LoginRightCarousel({
imageSlides = [],
showBackground = true,
backgroundSrc = bgDefault,
initialSeconds = 5,
slideSeconds = 8,
}: {
imageSlides?: ImageSlide[];
showBackground?: boolean;
/** Background panel image; defaults to the bundled shared asset. */
backgroundSrc?: string;
initialSeconds?: number;
slideSeconds?: number;
}) {
const totalSlides = imageSlides.length;
const [index, setIndex] = useState(0);
const mouse = useRef({ x: 0, y: 0 });
const durationsMs = useMemo(() => {
if (imageSlides.length === 0) return [];
return imageSlides.map(
(_, i) =>
(i === 0 ? (initialSeconds ?? slideSeconds) : slideSeconds) * 1000,
);
}, [imageSlides, initialSeconds, slideSeconds]);
useEffect(() => {
if (totalSlides <= 1) return;
const timeout = setTimeout(
() => {
setIndex((i) => (i + 1) % totalSlides);
},
durationsMs[index] ?? slideSeconds * 1000,
);
return () => clearTimeout(timeout);
}, [index, totalSlides, durationsMs, slideSeconds]);
useEffect(() => {
const onMove = (e: MouseEvent) => {
mouse.current.x = (e.clientX / window.innerWidth) * 2 - 1;
mouse.current.y = (e.clientY / window.innerHeight) * 2 - 1;
};
window.addEventListener("mousemove", onMove);
return () => window.removeEventListener("mousemove", onMove);
}, []);
function TiltImage({
src,
alt,
enabled,
maxDeg = 6,
}: {
src: string;
alt?: string;
enabled: boolean;
maxDeg?: number;
}) {
const imgRef = useRef<HTMLImageElement | null>(null);
useEffect(() => {
const el = imgRef.current;
if (!el) return;
let raf = 0;
const tick = () => {
if (enabled) {
const rotY = (mouse.current.x || 0) * maxDeg;
const rotX = -(mouse.current.y || 0) * maxDeg;
el.style.transform = `translateY(-2rem) rotateX(${rotX.toFixed(2)}deg) rotateY(${rotY.toFixed(2)}deg)`;
} else {
el.style.transform = "translateY(-2rem)";
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [enabled, maxDeg]);
return (
<img
ref={imgRef}
src={src}
alt={alt ?? "Carousel slide"}
style={{
maxWidth: "86%",
maxHeight: "78%",
objectFit: "contain",
borderRadius: "18px",
background: "transparent",
transform: "translateY(-2rem)",
transition: "transform 80ms ease-out",
willChange: "transform",
transformOrigin: "50% 50%",
}}
/>
);
}
return (
<div
style={{
position: "relative",
overflow: "hidden",
width: "100%",
height: "100%",
}}
>
{showBackground && (
<img
src={backgroundSrc}
alt="Background panel"
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
)}
{/* Image slides */}
{imageSlides.map((s, idx) => (
<div
key={s.src}
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "opacity 600ms ease",
opacity: index === idx ? 1 : 0,
perspective: "900px",
}}
>
{(s.title || s.subtitle) && (
<div
style={{
position: "absolute",
bottom: 24 + 32,
left: 0,
right: 0,
textAlign: "center",
padding: "0 2rem",
width: "100%",
}}
>
{s.title && (
<div
style={{
fontSize: 20,
fontWeight: 800,
color: "#ffffff",
textShadow: "0 2px 6px rgba(0,0,0,0.25)",
marginBottom: 6,
}}
>
{s.title}
</div>
)}
{s.subtitle && (
<div
style={{
fontSize: 13,
color: "rgba(255,255,255,0.92)",
textShadow: "0 1px 4px rgba(0,0,0,0.25)",
}}
>
{s.subtitle}
</div>
)}
</div>
)}
<TiltImage
src={s.src}
alt={s.alt}
enabled={index === idx && !!s.followMouseTilt}
maxDeg={s.tiltMaxDeg ?? 6}
/>
</div>
))}
{/* Dot navigation */}
<CarouselDots
tone="onImage"
count={totalSlides}
activeIndex={index}
onSelect={setIndex}
label="Slides"
style={{
position: "absolute",
bottom: 16,
left: 0,
right: 0,
justifyContent: "center",
zIndex: 2,
}}
/>
</div>
);
}
export default memo(LoginRightCarousel);
@@ -120,21 +120,19 @@ export default function SpringLoginForm({
{beforeEmailForm}
{renderEmailForm && (
<div style={{ marginTop: hasProviders ? "1rem" : 0 }}>
<EmailPasswordForm
email={email}
password={password}
setEmail={setEmail}
setPassword={setPassword}
mfaCode={mfaCode}
setMfaCode={setMfaCode}
showMfaField={requiresMfa || Boolean(mfaCode)}
requiresMfa={requiresMfa}
onSubmit={signInWithEmail}
isSubmitting={isSubmitting}
submitButtonText={submitLabel}
/>
</div>
<EmailPasswordForm
email={email}
password={password}
setEmail={setEmail}
setPassword={setPassword}
mfaCode={mfaCode}
setMfaCode={setMfaCode}
showMfaField={requiresMfa || Boolean(mfaCode)}
requiresMfa={requiresMfa}
onSubmit={signInWithEmail}
isSubmitting={isSubmitting}
submitButtonText={submitLabel}
/>
)}
{footer}
@@ -2,25 +2,30 @@
:root {
/* Auth page colors (light mode) */
--auth-bg-color-light-only: var(--p-gray-100);
--auth-bg-color: var(--p-gray-100);
--auth-card-bg: #ffffff;
--auth-card-bg-light-only: #ffffff;
--auth-label-text-light-only: var(--p-gray-700);
--auth-input-border-light-only: var(--p-gray-300);
--auth-input-bg-light-only: #ffffff;
--auth-input-text-light-only: var(--p-gray-900);
--auth-border-focus-light-only: var(--p-blue-500);
--auth-focus-ring-light-only: color-mix(
--auth-label-text: var(--p-gray-700);
--auth-input-border: var(--p-gray-300);
--auth-input-bg: #ffffff;
--auth-input-text: var(--p-gray-900);
--auth-border-focus: var(--p-blue-500);
--auth-focus-ring: color-mix(in srgb, var(--p-blue-500) 10%, transparent);
--auth-button-bg: var(--p-red-600);
--auth-button-text: #ffffff;
--auth-magic-button-bg: var(--p-gray-200);
--auth-magic-button-text: var(--p-gray-700);
--auth-text-primary: var(--p-gray-900);
--auth-text-secondary: var(--p-gray-500);
--auth-error-bg: color-mix(in srgb, var(--p-red-500) 8%, transparent);
--auth-error-border: color-mix(in srgb, var(--p-red-500) 25%, transparent);
--auth-error-text: var(--p-red-600);
--auth-success-bg: color-mix(in srgb, var(--p-green-500) 10%, transparent);
--auth-success-border: color-mix(
in srgb,
var(--p-blue-500) 10%,
var(--p-green-500) 30%,
transparent
);
--auth-button-bg-light-only: var(--p-red-600);
--auth-button-text-light-only: #ffffff;
--auth-magic-button-bg-light-only: var(--p-gray-200);
--auth-magic-button-text-light-only: var(--p-gray-700);
--auth-text-primary-light-only: var(--p-gray-900);
--auth-text-secondary-light-only: var(--p-gray-500);
--auth-success-text: var(--p-green-600);
--text-divider-rule-rgb-light: 229, 231, 235;
--text-divider-label-rgb-light: 156, 163, 175;
--tool-subcategory-rule-color-light: var(--p-gray-200);
@@ -28,25 +33,30 @@
}
[data-mantine-color-scheme="dark"] {
--auth-bg-color-light-only: var(--bg-muted);
--auth-bg-color: var(--bg-muted);
--auth-card-bg: var(--bg-surface);
--auth-card-bg-light-only: var(--bg-surface);
--auth-label-text-light-only: var(--text-secondary);
--auth-input-border-light-only: var(--border-default);
--auth-input-bg-light-only: var(--bg-raised);
--auth-input-text-light-only: var(--text-primary);
--auth-border-focus-light-only: var(--p-blue-500);
--auth-focus-ring-light-only: color-mix(
--auth-label-text: var(--text-secondary);
--auth-input-border: var(--border-default);
--auth-input-bg: var(--bg-raised);
--auth-input-text: var(--text-primary);
--auth-border-focus: var(--p-blue-500);
--auth-focus-ring: color-mix(in srgb, var(--p-blue-500) 20%, transparent);
--auth-button-bg: var(--p-red-600);
--auth-button-text: #ffffff;
--auth-magic-button-bg: var(--bg-raised);
--auth-magic-button-text: var(--text-primary);
--auth-text-primary: var(--text-primary);
--auth-text-secondary: var(--text-secondary);
--auth-error-bg: color-mix(in srgb, var(--p-red-500) 12%, transparent);
--auth-error-border: color-mix(in srgb, var(--p-red-500) 35%, transparent);
--auth-error-text: var(--p-red-400);
--auth-success-bg: color-mix(in srgb, var(--p-green-500) 12%, transparent);
--auth-success-border: color-mix(
in srgb,
var(--p-blue-500) 20%,
var(--p-green-500) 35%,
transparent
);
--auth-button-bg-light-only: var(--p-red-600);
--auth-button-text-light-only: #ffffff;
--auth-magic-button-bg-light-only: var(--bg-raised);
--auth-magic-button-text-light-only: var(--text-primary);
--auth-text-primary-light-only: var(--text-primary);
--auth-text-secondary-light-only: var(--text-secondary);
--auth-success-text: var(--p-green-500);
--text-divider-rule-rgb-light: 28, 35, 64;
--text-divider-label-rgb-light: 91, 98, 128;
--tool-subcategory-rule-color-light: var(--border-default);
@@ -10,7 +10,10 @@
display: flex;
align-items: center;
gap: 0.75rem; /* 12px */
margin: 0.375rem 0 0.5rem; /* 6px 0 8px */
/* Center "or" between the last OAuth button and the input box: the larger top
margin balances the field label + input that sit below the smaller bottom
margin, so the gap to the button ≈ the gap to the input. */
margin: 2.25rem 0 0.75rem; /* 36px top, 12px bottom */
}
.auth-or-divider__rule {
@@ -33,24 +36,33 @@
.auth-label {
font-size: 0.875rem; /* 14px */
color: var(--auth-label-text-light-only);
color: var(--auth-label-text);
font-weight: 500;
}
/* Normalize the Mantine inputs used by the login/signup forms so they match the
OAuth buttons and submit CTA: same height, radius, and font size. */
.auth-fields .mantine-Input-input {
min-height: 2.625rem; /* 42px */
height: 2.625rem;
border-radius: 0.625rem; /* 10px */
font-size: 0.9375rem; /* 15px */
}
.auth-input {
width: 100%;
padding: 0.625rem 0.75rem; /* 10px 12px */
border: 1px solid var(--auth-input-border-light-only);
border: 1px solid var(--auth-input-border);
border-radius: 0.625rem; /* 10px */
font-size: 0.875rem; /* 14px */
background-color: var(--auth-input-bg-light-only);
color: var(--auth-input-text-light-only);
background-color: var(--auth-input-bg);
color: var(--auth-input-text);
outline: none;
}
.auth-input:focus {
border-color: var(--auth-border-focus-light-only);
box-shadow: 0 0 0 3px var(--auth-focus-ring-light-only);
border-color: var(--auth-border-focus);
box-shadow: 0 0 0 3px var(--auth-focus-ring);
}
.auth-button {
@@ -58,8 +70,8 @@
padding: 0.625rem 0.75rem; /* 10px 12px */
border: none;
border-radius: 0.625rem; /* 10px */
background-color: var(--auth-button-bg-light-only);
color: var(--auth-button-text-light-only);
background-color: var(--auth-button-bg);
color: var(--auth-button-text);
font-size: 0.875rem; /* 14px */
font-weight: 600;
margin-bottom: 0.75rem; /* 12px */
@@ -79,7 +91,7 @@
.auth-toggle-link {
background: transparent;
border: 0;
color: var(--auth-label-text-light-only);
color: var(--auth-label-text);
font-size: 0.875rem; /* 14px */
text-decoration: underline;
cursor: pointer;
@@ -104,8 +116,8 @@
padding: 0.875rem 1rem; /* 14px 16px */
border: none;
border-radius: 0.625rem; /* 10px */
background-color: var(--auth-magic-button-bg-light-only);
color: var(--auth-magic-button-text-light-only);
background-color: var(--auth-magic-button-bg);
color: var(--auth-magic-button-text);
font-size: 0.875rem; /* 14px */
font-weight: 600;
white-space: nowrap;
@@ -132,7 +144,7 @@
.auth-terms-label {
font-size: 0.75rem; /* 12px */
color: var(--auth-label-text-light-only);
color: var(--auth-label-text);
}
.auth-terms-label a {
@@ -182,15 +194,21 @@
min-height: 3.5rem; /* 56px */
}
.oauth-button-fullwidth.sui-btn.mantine-Button-root {
border-radius: 100px;
border-radius: 0.625rem; /* 10px */
min-height: 0;
}
/* Match the submit CTA's corners to the OAuth buttons and inputs (10px). */
.auth-submit.sui-btn.mantine-Button-root {
border-radius: 0.625rem; /* 10px */
}
.oauth-button-icon {
width: 3.75rem; /* 60px */
height: 3.75rem; /* 60px */
border-radius: 0.875rem; /* 14px */
border: 1px solid var(--auth-input-border-light-only);
background: var(--auth-card-bg-light-only);
border: 1px solid var(--auth-input-border);
background: var(--auth-card-bg);
cursor: pointer;
box-shadow: 0 0.125rem 0.375rem rgba(0, 0, 0, 0.04); /* 0 2px 6px */
display: flex;
@@ -207,8 +225,8 @@
width: 100%;
padding: 1rem; /* 16px */
border-radius: 0.875rem; /* 14px */
border: 1px solid var(--auth-input-border-light-only);
background: var(--auth-card-bg-light-only);
border: 1px solid var(--auth-input-border);
background: var(--auth-card-bg);
cursor: pointer;
box-shadow: 0 0.125rem 0.375rem rgba(0, 0, 0, 0.04); /* 0 2px 6px */
display: flex;
@@ -231,12 +249,12 @@
the Button's own padding-block; horizontal stays in the shorthand. */
--sui-btn-py: 1rem; /* 16px (md) */
padding: 1rem 1.5rem; /* 16px 24px */
border: 1px solid var(--auth-input-border-light-only);
border: 1px solid var(--auth-input-border);
border-radius: 999px;
background-color: var(--auth-card-bg-light-only);
background-color: var(--auth-card-bg);
font-size: 1rem; /* 16px */
font-weight: 600;
color: var(--auth-text-primary-light-only);
color: var(--auth-text-primary);
cursor: pointer;
gap: 1rem; /* 16px */
font-family: inherit;
@@ -277,15 +295,15 @@
padding: 0.75rem 1rem; /* 12px 16px */
border: 1px solid #d1d5db;
border-radius: 0.75rem; /* 12px */
background-color: var(--auth-card-bg-light-only);
background-color: var(--auth-card-bg);
font-weight: 500;
color: var(--auth-text-primary-light-only);
color: var(--auth-text-primary);
box-shadow: none;
}
.oauth-button-vertical-legacy:hover:not(:disabled) {
background-color: #f3f4f6;
color: var(--auth-text-primary-light-only);
color: var(--auth-text-primary);
box-shadow: none;
}
@@ -378,6 +396,12 @@
display: block;
}
/* Fullwidth OAuth rows use a smaller icon so button height matches the inputs. */
.oauth-button-fullwidth .oauth-icon-medium {
width: 1.25rem; /* 20px */
height: 1.25rem; /* 20px */
}
.oauth-icon-tiny {
width: 1.25rem; /* 20px */
height: 1.25rem; /* 20px */
@@ -418,7 +442,7 @@
align-items: center;
justify-content: center;
background: var(--bg-muted);
border: 1px solid var(--auth-input-border-light-only);
border: 1px solid var(--auth-input-border);
}
.oauth-button-vertical-tinted .oauth-icon-wrapper {
@@ -465,17 +489,17 @@
}
.sso-demo-card {
border: 1px solid var(--auth-input-border-light-only);
border: 1px solid var(--auth-input-border);
border-radius: 1rem;
padding: 1rem;
background: var(--auth-card-bg-light-only);
background: var(--auth-card-bg);
box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.06);
}
.sso-demo-title {
font-size: 0.875rem;
font-weight: 700;
color: var(--auth-text-primary-light-only);
color: var(--auth-text-primary);
margin-bottom: 0.75rem;
letter-spacing: 0.01em;
text-transform: uppercase;
@@ -520,12 +544,12 @@
.login-title {
font-size: 2rem; /* 32px */
font-weight: 800;
color: var(--auth-text-primary-light-only);
color: var(--auth-text-primary);
margin: 0 0 0.375rem; /* 0 0 6px */
}
.login-subtitle {
color: var(--auth-text-secondary-light-only);
color: var(--auth-text-secondary);
font-size: 0.875rem; /* 14px */
margin: 0;
}
@@ -538,7 +562,7 @@
.navigation-link-button {
background: none;
border: none;
color: var(--auth-label-text-light-only);
color: var(--auth-label-text);
font-size: 0.875rem; /* 14px */
cursor: pointer;
text-decoration: underline;
@@ -552,46 +576,46 @@
/* Message Styles */
.error-message {
padding: 1rem; /* 16px */
background-color: #fef2f2;
border: 1px solid #fecaca;
background-color: var(--auth-error-bg);
border: 1px solid var(--auth-error-border);
border-radius: 0.5rem; /* 8px */
margin-bottom: 1.5rem; /* 24px */
}
.error-message-text {
color: #dc2626;
color: var(--auth-error-text);
font-size: 0.875rem; /* 14px */
margin: 0;
}
.success-message {
padding: 1rem; /* 16px */
background-color: #f0fdf4;
border: 1px solid #bbf7d0;
background-color: var(--auth-success-bg);
border: 1px solid var(--auth-success-border);
border-radius: 0.5rem; /* 8px */
margin-bottom: 1.5rem; /* 24px */
}
.success-message-text {
color: #059669;
color: var(--auth-success-text);
font-size: 0.875rem; /* 14px */
margin: 0;
}
/* Field-level error styles */
.auth-field-error {
color: #dc2626;
color: var(--auth-error-text);
font-size: 0.6875rem; /* 11px */
margin-top: 0.125rem; /* 2px */
line-height: 1.1;
}
.auth-input-error {
border-color: #dc2626 !important;
border-color: var(--auth-error-text) !important;
}
.auth-input-error:focus {
border-color: #dc2626 !important;
border-color: var(--auth-error-text) !important;
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.1) !important;
}
@@ -626,13 +650,32 @@
text-decoration: underline;
cursor: pointer;
font-size: 0.875rem; /* 14px */
color: var(--auth-text-primary-light-only);
color: var(--auth-text-primary);
}
.auth-dot-black {
opacity: 0.5;
padding: 0 0.5rem;
color: var(--auth-text-primary-light-only);
color: var(--auth-text-primary);
}
/* "Don't have an account? Sign up" row beneath the login form. */
.auth-signup-prompt {
display: flex;
align-items: center;
justify-content: center;
gap: 0.375rem; /* 6px */
margin-top: 1.5rem; /* 24px */
font-size: 0.875rem; /* 14px */
color: var(--auth-label-text);
}
.auth-signup-link.sui-btn.mantine-Button-root {
padding: 0;
min-height: 0;
height: auto;
font-size: 0.875rem; /* 14px */
font-weight: 700;
}
/* Email login button - red CTA style matching SaaS version */
@@ -663,7 +706,7 @@
}
.auth-logo-header {
height: 8rem;
height: 6.5rem; /* 104px */
width: auto;
}
@@ -736,12 +779,12 @@
justify-content: center;
/* Vertical padding via the shared Button's --sui-btn-py (beats the Button's
own padding-block); horizontal stays in the shorthand. */
--sui-btn-py: 1rem; /* 16px (md) */
padding: 1rem 1rem; /* 16px */
--sui-btn-py: 0.625rem; /* 10px */
padding: 0.625rem 1rem; /* 10px 16px */
border: 1px solid #d1d5db;
border-radius: 100px;
border-radius: 0.625rem; /* 10px */
background-color: #ffffff;
font-size: 1rem;
font-size: 0.9375rem; /* 15px */
font-weight: 600;
color: #000000;
cursor: pointer;
@@ -811,50 +854,3 @@
flex-direction: column;
gap: 0.75rem;
}
.oauth-button-fullwidth {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
/* Vertical padding via the shared Button's --sui-btn-py (beats the Button's
own padding-block); horizontal stays in the shorthand. */
--sui-btn-py: 1rem; /* 16px (md) */
padding: 1rem 1rem; /* 16px */
border: 1px solid #d1d5db;
border-radius: 100px;
background-color: #ffffff;
font-size: 1rem;
font-weight: 600;
color: #000000;
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 {
cursor: not-allowed;
opacity: 0.6;
}
.oauth-button-fullwidth:hover:not(:disabled) {
background-color: #fafafa;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
[data-mantine-color-scheme="dark"] .oauth-button-fullwidth {
background-color: var(--bg-surface);
color: var(--text-primary);
border-color: var(--border-default);
box-shadow: none;
}
[data-mantine-color-scheme="dark"]
.oauth-button-fullwidth:hover:not(:disabled) {
background-color: var(--bg-raised);
box-shadow: none;
}
@@ -1,56 +0,0 @@
import { defaultTranslate, type AuthTranslate } from "@app/auth/types";
export type LoginSlideText = {
alt: string;
title: string;
subtitle: string;
};
/**
* Shared carousel copy (i18n-optional, English default). The editor and portal
* both build their slides from this so the text stays in sync; only the images
* differ (the editor swaps the hero for its logo-variant image). Order matches
* buildDefaultLoginSlides: [overview, edit, secure].
*
* Kept image-free on purpose: the editor imports this without dragging the
* bundled slide images into its build.
*/
export function loginSlideText(
translate: AuthTranslate = defaultTranslate,
): LoginSlideText[] {
return [
{
alt: translate("login.slides.overview.alt", "Stirling PDF overview"),
title: translate(
"login.slides.overview.title",
"Your one-stop-shop for all your PDF needs.",
),
subtitle: translate(
"login.slides.overview.subtitle",
"A privacy-first cloud suite for PDFs that lets you convert, sign, redact, and manage documents, along with 50+ other powerful tools.",
),
},
{
alt: translate("login.slides.edit.alt", "Edit PDFs"),
title: translate(
"login.slides.edit.title",
"Edit PDFs to display/secure the information you want",
),
subtitle: translate(
"login.slides.edit.subtitle",
"With over a dozen tools to help you redact, sign, read and manipulate PDFs, you will be sure to find what you are looking for.",
),
},
{
alt: translate("login.slides.secure.alt", "Secure PDFs"),
title: translate(
"login.slides.secure.title",
"Protect sensitive information in your PDFs",
),
subtitle: translate(
"login.slides.secure.subtitle",
"Add passwords, redact content, and manage certificates with ease.",
),
},
];
}
@@ -1,23 +0,0 @@
import { defaultTranslate, type AuthTranslate } from "@app/auth/types";
import type { ImageSlide } from "@app/auth/ui/LoginRightCarousel";
import { loginSlideText } from "@app/auth/ui/loginSlideText";
import firstPage from "@app/assets/login/Firstpage.png";
import addToPdf from "@app/assets/login/AddToPDF.png";
import securePdf from "@app/assets/login/SecurePDF.png";
const SLIDE_TILT = { followMouseTilt: true, tiltMaxDeg: 5 } as const;
/**
* Default login carousel slides using bundled images. The portal uses this set;
* the editor builds its own (logo-variant hero) from loginSlideText.
*/
export function buildDefaultLoginSlides(
translate: AuthTranslate = defaultTranslate,
): ImageSlide[] {
const text = loginSlideText(translate);
return [firstPage, addToPdf, securePdf].map((src, i) => ({
src,
...text[i],
...SLIDE_TILT,
}));
}
@@ -1,28 +0,0 @@
import { BASE_PATH } from "@app/constants/app";
import { getLogoFolder } from "@app/constants/logo";
import type { LogoVariant } from "@app/services/preferencesService";
import type { TFunction } from "i18next";
import { loginSlideText } from "@app/auth/ui/loginSlideText";
import type { ImageSlide } from "@app/auth/ui/LoginRightCarousel";
import addToPdf from "@app/assets/login/AddToPDF.png";
import securePdf from "@app/assets/login/SecurePDF.png";
const SLIDE_TILT = { followMouseTilt: true, tiltMaxDeg: 5 } as const;
/**
* Editor login carousel slides. Copy comes from the shared set (so the editor
* and portal carousels stay in sync) and the edit/secure images are the shared
* bundled assets; only the hero is the logo-variant image the editor serves
* from /public.
*/
export const buildLoginSlides = (
variant: LogoVariant | null | undefined,
t: TFunction,
): ImageSlide[] => {
const folder = getLogoFolder(variant);
const text = loginSlideText((key, fallback) => t(key, fallback));
const srcs = [`${BASE_PATH}/${folder}/Firstpage.png`, addToPdf, securePdf];
return srcs.map((src, i) => ({ src, ...text[i], ...SLIDE_TILT }));
};
export default buildLoginSlides;
@@ -1,131 +0,0 @@
.page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 50px 20px;
background: #f5f5f5;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
Arial, sans-serif;
}
.card {
background: #ffffff;
border-radius: 12px;
padding: 40px;
max-width: 420px;
width: 100%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
text-align: center;
color: #1a1a1a;
border: 1px solid #e5e7eb;
}
.icon {
font-size: 48px;
margin-bottom: 16px;
}
.iconSuccess {
color: #2e7d32;
}
.iconError {
color: #d32f2f;
}
.iconNeutral {
color: #4b5563;
}
.title {
font-size: 24px;
font-weight: 600;
margin-bottom: 12px;
color: #1a1a1a;
}
.message {
color: #666;
line-height: 1.6;
font-size: 15px;
}
.loadingExtra {
color: #6b7280;
margin-top: 12px;
font-size: 14px;
}
.errorBox {
margin-top: 20px;
background: #ffebee;
border: 1px solid #ffcdd2;
border-radius: 8px;
padding: 16px;
color: #c62828;
font-size: 14px;
line-height: 1.5;
word-break: break-word;
text-align: left;
}
@media (prefers-color-scheme: dark) {
.page {
background: #1a1a1a;
color: #e0e0e0;
}
.card {
background: #2d2d2d;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
color: #e5e7eb;
border-color: #374151;
}
.iconSuccess {
color: #66bb6a;
}
.iconError {
color: #ef5350;
}
.iconNeutral {
color: #9ca3af;
}
.title {
color: #f5f5f5;
}
.message,
.loadingExtra {
color: #b0b0b0;
}
.errorBox {
background: #3d2020;
border: 1px solid #5d3030;
color: #ef9a9a;
}
}
@media (max-width: 480px) {
.page {
padding: 20px 16px;
}
.card {
padding: 32px 24px;
}
.title {
font-size: 20px;
}
.icon {
font-size: 40px;
}
}
@@ -7,7 +7,11 @@ import {
} from "@app/auth/spring/springAuthClient";
import { markLoginLandingPending } from "@app/utils/loginLanding";
import { handleAuthCallbackSuccess } from "@app/extensions/authCallback";
import styles from "@app/routes/AuthCallback.module.css";
import { AuthShell } from "@app/auth/ui/AuthShell";
import { Spinner } from "@app/ui/Spinner";
import { withBasePath } from "@app/constants/app";
import "@app/auth/ui/auth.css";
import loginHeader from "@app/assets/brand/modern-logo/LoginLightModeHeader.svg";
import i18n from "@app/i18n";
/**
@@ -125,25 +129,39 @@ export default function AuthCallback() {
}, []); // Empty deps - only run once on mount. navigate is stable, processingRef prevents double execution
return (
<div className={styles.page}>
<div className={styles.card}>
<div className={`${styles.icon} ${styles.iconNeutral}`}>...</div>
<div className={styles.title}>
{t("auth.callback.completing", "Completing authentication")}
</div>
<div className={styles.message}>
{t(
"auth.callback.pleaseWait",
"Please wait while we finish signing you in.",
)}
</div>
<div className={styles.loadingExtra}>
{t(
"auth.callback.windowMayClose",
"You can close this window once it completes.",
)}
</div>
<AuthShell>
<div className="auth-logo-block">
<img
src={loginHeader}
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>
</div>
<h1 className="login-title" style={{ textAlign: "center" }}>
{t("auth.callback.completing", "Completing authentication")}
</h1>
<p className="login-subtitle" style={{ textAlign: "center" }}>
{t(
"auth.callback.pleaseWait",
"Please wait while we finish signing you in.",
)}
</p>
<div
style={{ display: "flex", justifyContent: "center", margin: "1rem 0" }}
>
<Spinner size="md" />
</div>
<p className="login-subtitle" style={{ textAlign: "center" }}>
{t(
"auth.callback.windowMayClose",
"You can close this window once it completes.",
)}
</p>
</AuthShell>
);
}
@@ -16,6 +16,8 @@ import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
import AuthLayout from "@app/routes/authShared/AuthLayout";
import LoginHeader from "@app/routes/login/LoginHeader";
import ErrorMessage from "@app/auth/ui/ErrorMessage";
import { authInputStyles } from "@app/auth/ui/EmailPasswordForm";
import "@app/auth/ui/auth.css";
import { BASE_PATH } from "@app/constants/app";
import apiClient from "@app/services/apiClient";
import { Button } from "@app/ui/Button";
@@ -162,15 +164,17 @@ export default function InviteAccept() {
title={t("invite.invalidInvitation", "Invalid Invitation")}
/>
<ErrorMessage error={error} />
<div className="auth-section">
<Button
type="button"
onClick={() => navigate("/login")}
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold cursor-pointer border-0 auth-cta-button"
>
{t("invite.goToLogin", "Go to Login")}
</Button>
</div>
<Button
type="button"
onClick={() => navigate("/login")}
fullWidth
size="lg"
fontSize="sm"
accent="brand"
className="auth-submit"
>
{t("invite.goToLogin", "Go to Login")}
</Button>
</AuthLayout>
);
}
@@ -186,13 +190,7 @@ export default function InviteAccept() {
/>
{inviteData && !inviteData.emailRequired && (
<Paper
withBorder
p="md"
mb="lg"
bg="blue.0"
style={{ borderColor: "var(--mantine-color-blue-3)" }}
>
<Paper withBorder p="md" mb="lg">
<Stack gap="xs" align="center">
<Text
size="xs"
@@ -218,58 +216,76 @@ export default function InviteAccept() {
<ErrorMessage error={error} />
<form onSubmit={handleAccept}>
<Stack gap="md">
<div className="auth-fields">
{inviteData?.emailRequired && (
<TextInput
label={t("invite.email", "Email address")}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
<div className="auth-field">
<TextInput
label={t("invite.email", "Email address")}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t(
"invite.emailPlaceholder",
"Enter your email address",
)}
disabled={submitting}
required
autoComplete="email"
classNames={{ label: "auth-label" }}
styles={authInputStyles}
/>
</div>
)}
<div className="auth-field">
<PasswordInput
label={t("invite.choosePassword", "Choose a password")}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t(
"invite.emailPlaceholder",
"Enter your email address",
"invite.passwordPlaceholder",
"Enter your password",
)}
disabled={submitting}
required
autoComplete="email"
autoComplete="new-password"
classNames={{ label: "auth-label" }}
styles={authInputStyles}
/>
)}
<PasswordInput
label={t("invite.choosePassword", "Choose a password")}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("invite.passwordPlaceholder", "Enter your password")}
disabled={submitting}
required
autoComplete="new-password"
/>
<PasswordInput
label={t("invite.confirmPassword", "Confirm password")}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder={t(
"invite.confirmPasswordPlaceholder",
"Re-enter your password",
)}
disabled={submitting}
required
autoComplete="new-password"
/>
<div className="auth-section">
<Button
type="submit"
disabled={submitting}
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold cursor-pointer border-0 disabled:opacity-50 disabled:cursor-not-allowed auth-cta-button"
>
{submitting
? t("invite.creating", "Creating Account...")
: t("invite.createAccount", "Create Account")}
</Button>
</div>
</Stack>
<div className="auth-field">
<PasswordInput
label={t("invite.confirmPassword", "Confirm password")}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder={t(
"invite.confirmPasswordPlaceholder",
"Re-enter your password",
)}
disabled={submitting}
required
autoComplete="new-password"
classNames={{ label: "auth-label" }}
styles={authInputStyles}
/>
</div>
</div>
<Button
type="submit"
disabled={submitting}
loading={submitting}
fullWidth
size="lg"
fontSize="sm"
accent="brand"
className="auth-submit"
>
{submitting
? t("invite.creating", "Creating Account...")
: t("invite.createAccount", "Create Account")}
</Button>
</form>
<Center mt="md">
@@ -5,7 +5,6 @@ import {
useNavigate,
useSearchParams,
} from "react-router-dom";
import { Text, Stack, Alert } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { setPostLoginRedirectPath } from "@app/auth/spring/springAuthClient";
import { markLoginLandingPending } from "@app/utils/loginLanding";
@@ -22,6 +21,8 @@ import {
oauthProviderConfig,
} from "@app/auth/ui/OAuthButtons";
import SpringLoginForm from "@app/auth/ui/SpringLoginForm";
import AuthSignupPrompt from "@app/auth/ui/AuthSignupPrompt";
import AuthDefaultCredentials from "@app/auth/ui/AuthDefaultCredentials";
import { useSpringLogin } from "@app/auth/ui/useSpringLogin";
import LoggedInState from "@app/routes/login/LoggedInState";
import loginHeader from "@app/assets/brand/modern-logo/LoginLightModeHeader.svg";
@@ -514,59 +515,14 @@ export default function Login() {
) : undefined
}
footer={
isFirstTimeSetup && showDefaultCredentials && isUserPassAllowed ? (
<Alert color="blue" variant="light" radius="md" mt="xl">
<Stack gap="xs" align="center">
<Text
size="sm"
fw={600}
ta="center"
style={{ color: "var(--text-primary)" }}
>
{t("login.defaultCredentials", "Default Login Credentials")}
</Text>
<Text
size="sm"
ta="center"
style={{ color: "var(--text-primary)" }}
>
<Text
component="span"
fw={600}
style={{ color: "var(--text-primary)" }}
>
{t("login.username", "Username")}:
</Text>{" "}
admin
</Text>
<Text
size="sm"
ta="center"
style={{ color: "var(--text-primary)" }}
>
<Text
component="span"
fw={600}
style={{ color: "var(--text-primary)" }}
>
{t("login.password", "Password")}:
</Text>{" "}
stirling
</Text>
<Text
size="xs"
ta="center"
mt="xs"
style={{ color: "var(--text-muted)" }}
>
{t(
"login.changePasswordWarning",
"Please change your password after logging in for the first time",
)}
</Text>
</Stack>
</Alert>
) : undefined
<>
{isFirstTimeSetup &&
showDefaultCredentials &&
isUserPassAllowed && <AuthDefaultCredentials />}
{isUserPassAllowed && (
<AuthSignupPrompt onSignUp={() => navigate("/signup")} />
)}
</>
}
/>
</AuthLayout>
@@ -1,9 +1,5 @@
import React, { useMemo } from "react";
import { useTranslation } from "react-i18next";
import React from "react";
import { AuthShell } from "@app/auth/ui/AuthShell";
import LoginRightCarousel from "@app/auth/ui/LoginRightCarousel";
import buildLoginSlides from "@app/components/shared/loginSlides";
import { useLogoVariant } from "@app/hooks/useLogoVariant";
import Footer from "@app/components/shared/Footer";
interface AuthLayoutProps {
@@ -11,30 +7,9 @@ interface AuthLayoutProps {
}
/**
* Editor login layout. The card shell + carousel now live in shared so the
* portal renders the identical screen; this wires the editor's logo-variant
* slides and legal/cookie footer into that shared shell.
* Editor login layout. The card shell lives in shared so the portal renders
* the identical screen; this wires the editor's legal/cookie footer into it.
*/
export default function AuthLayout({ children }: AuthLayoutProps) {
const { t } = useTranslation();
const logoVariant = useLogoVariant();
const imageSlides = useMemo(
() => buildLoginSlides(logoVariant, t),
[logoVariant, t],
);
return (
<AuthShell
rightPanel={
<LoginRightCarousel
imageSlides={imageSlides}
initialSeconds={5}
slideSeconds={8}
/>
}
footer={<Footer />}
>
{children}
</AuthShell>
);
return <AuthShell footer={<Footer />}>{children}</AuthShell>;
}
@@ -160,8 +160,11 @@ export default function SignupForm({
!confirmPassword ||
(showTerms && !agree)
}
className="auth-button"
className="auth-submit"
fullWidth
size="lg"
fontSize="sm"
accent="brand"
loading={isSubmitting}
>
{isSubmitting ? t("signup.creatingAccount") : t("signup.signUp")}
@@ -4,6 +4,11 @@ import { supabase } from "@app/auth/supabase";
import { Button } from "@app/ui/Button";
import { withBasePath } from "@app/constants/app";
import { markLoginLandingPending } from "@app/utils/loginLanding";
import { AuthShell } from "@app/auth/ui/AuthShell";
import ErrorMessage from "@app/auth/ui/ErrorMessage";
import { Spinner } from "@app/ui/Spinner";
import "@app/auth/ui/auth.css";
import loginHeader from "@app/assets/brand/modern-logo/LoginLightModeHeader.svg";
interface CallbackState {
status: "processing" | "success" | "error";
@@ -151,19 +156,6 @@ export default function AuthCallback() {
handleCallback();
}, [navigate]);
const getStatusColor = () => {
switch (state.status) {
case "processing":
return "text-blue-600";
case "success":
return "text-green-600";
case "error":
return "text-red-600";
default:
return "text-gray-600";
}
};
const getTitle = () => {
switch (state.status) {
case "processing":
@@ -178,64 +170,66 @@ export default function AuthCallback() {
};
return (
<div className="relative min-h-screen flex items-center justify-center overflow-hidden bg-gradient-to-br from-slate-50 via-white to-slate-100">
<div aria-hidden className="pointer-events-none absolute inset-0 -z-10">
<div className="absolute -top-32 -left-32 h-96 w-96 rounded-full bg-blue-200/40 blur-3xl"></div>
<div className="absolute -bottom-32 -right-32 h-96 w-96 rounded-full bg-emerald-200/40 blur-3xl"></div>
<AuthShell>
<div className="auth-logo-block">
<img
src={loginHeader}
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>
<div
className={`w-full max-w-md rounded-2xl bg-white/80 backdrop-blur shadow-xl p-8`}
>
<div className="text-center">
<img
src={withBasePath("/modern-logo/StirlingPDFLogoNoTextDark.svg")}
alt="Stirling PDF"
className="mx-auto mb-5 h-8 opacity-80"
/>
<h1 className="login-title" style={{ textAlign: "center" }}>
{getTitle()}
</h1>
<h1 className="text-2xl font-bold text-gray-900 mb-2">
{getTitle()}
</h1>
<p className={`text-base ${getStatusColor()}`}>{state.message}</p>
{state.status === "error" ? (
<ErrorMessage error={state.message} />
) : (
<p className="login-subtitle" style={{ textAlign: "center" }}>
{state.message}
</p>
)}
{state.status === "processing" && (
<div className="mt-6">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
<div className="mt-3 h-1 overflow-hidden rounded-full bg-slate-200">
<div className="h-full w-1/2 animate-pulse rounded-full bg-blue-500"></div>
</div>
</div>
)}
{/* Action button - only show if error */}
<div className="mt-6 flex items-center justify-center gap-3">
{(() => {
if (state.status === "error") {
return (
<Button
accent="danger"
onClick={() => navigate("/login", { replace: true })}
>
Back to login
</Button>
);
}
})()}
</div>
{import.meta.env.DEV && state.details && (
<details className="mt-6 text-left">
<summary className="cursor-pointer text-sm text-gray-500 hover:text-gray-700">
Debug Information
</summary>
<pre className="mt-2 p-3 bg-gray-100 rounded text-xs overflow-auto">
{JSON.stringify(state.details, null, 2)}
</pre>
</details>
)}
{state.status === "processing" && (
<div
style={{
display: "flex",
justifyContent: "center",
margin: "1rem 0",
}}
>
<Spinner size="md" />
</div>
</div>
</div>
)}
{state.status === "error" && (
<div className="auth-section">
<Button
accent="danger"
fullWidth
onClick={() => navigate("/login", { replace: true })}
>
Back to login
</Button>
</div>
)}
{import.meta.env.DEV && state.details && (
<details className="mt-6 text-left">
<summary className="cursor-pointer text-sm text-gray-500 hover:text-gray-700">
Debug Information
</summary>
<pre className="mt-2 p-3 bg-gray-100 rounded text-xs overflow-auto">
{JSON.stringify(state.details, null, 2)}
</pre>
</details>
)}
</AuthShell>
);
}
+1 -1
View File
@@ -269,7 +269,7 @@ export default function Login() {
};
return (
<AuthLayout isEmailFormExpanded={showEmailForm || showMagicLinkForm}>
<AuthLayout>
{/* Centered logo */}
<div className="auth-logo-block">
<img
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { List, Paper, Text } from "@mantine/core";
import { Button } from "@app/ui/Button";
import { useAuth } from "@app/auth/UseSession";
import { useTranslation } from "@app/hooks/useTranslation";
@@ -225,18 +226,12 @@ export default function OAuthConsent() {
return (
<AuthLayout>
{logoBlock}
<p
style={{
textAlign: "center",
marginBottom: "1.5rem",
color: "#374151",
}}
>
<Text ta="center" c="dimmed" mb="lg">
{t(
"oauthConsent.signInPrompt",
"Sign in to your Stirling PDF account to continue connecting the app.",
)}
</p>
</Text>
<Button
variant="secondary"
className="oauth-button-fullwidth"
@@ -252,11 +247,11 @@ export default function OAuthConsent() {
return (
<AuthLayout>
{logoBlock}
<p style={{ textAlign: "center", color: "#6b7280" }}>
<Text ta="center" c="dimmed">
{redirecting
? t("oauthConsent.redirecting", "Returning you to the app...")
: t("oauthConsent.loading", "Loading authorization request...")}
</p>
</Text>
</AuthLayout>
);
}
@@ -274,106 +269,57 @@ export default function OAuthConsent() {
<AuthLayout>
{logoBlock}
{/* AuthLayout forces light mode but text without explicit colors still
inherits dark-scheme values from the app CSS; pin them like the rest
of the auth pages do. */}
<h2
style={{
textAlign: "center",
fontSize: "1.25rem",
fontWeight: 700,
marginBottom: "0.5rem",
color: "#111827",
}}
>
<Text component="h2" ta="center" fw={700} fz="xl" mb="xs">
{t("oauthConsent.title", "Authorize access")}
</h2>
<p
style={{
textAlign: "center",
marginBottom: "1.5rem",
color: "#374151",
}}
>
</Text>
<Text ta="center" c="dimmed" mb="lg">
{t("oauthConsent.requesting", {
app: appName,
defaultValue: `${appName} wants to access your Stirling PDF account`,
})}
</p>
</Text>
{/* Be explicit about what connecting actually grants. The OAuth scopes
(openid/email) only cover identity; the real power is that the issued
token lets the app drive the MCP endpoint - i.e. run any Stirling PDF
tool as this user, audited as them and counted against their usage. */}
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: "0.5rem",
padding: "1rem 1.25rem",
marginBottom: "1.5rem",
background: "#ffffff",
}}
>
<p
style={{
fontSize: "0.875rem",
fontWeight: 600,
margin: "0 0 0.5rem",
color: "#111827",
}}
>
<Paper withBorder p="md" mb="lg">
<Text fw={600} fz="sm" mb="xs">
{t("oauthConsent.scopesIntro", {
app: appName,
defaultValue: `This will allow ${appName} to:`,
})}
</p>
<ul
style={{
margin: 0,
paddingLeft: "1.25rem",
display: "flex",
flexDirection: "column",
gap: "0.25rem",
}}
>
<li style={{ fontSize: "0.875rem", color: "#374151" }}>
</Text>
<List size="sm" spacing={4} c="dimmed">
<List.Item>
{t("oauthConsent.access.tools", {
app: appName,
defaultValue: `Use your Stirling PDF tools on your behalf - convert, edit, sign, secure and process your documents`,
})}
</li>
<li style={{ fontSize: "0.875rem", color: "#374151" }}>
</List.Item>
<List.Item>
{t("oauthConsent.access.actAsYou", {
app: appName,
defaultValue: `Act as you - everything ${appName} does runs under your account and counts towards your usage`,
})}
</li>
</List.Item>
{scopes.map((scope) => (
<li key={scope} style={{ fontSize: "0.875rem", color: "#374151" }}>
{scopeDescription(scope)}
</li>
<List.Item key={scope}>{scopeDescription(scope)}</List.Item>
))}
</ul>
</div>
</List>
</Paper>
<ErrorMessage error={error} />
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
<div className="oauth-container-fullwidth">
<Button
fullWidth
size="lg"
fontSize="sm"
accent="brand"
className="auth-submit"
disabled={deciding !== null}
onClick={() => decide("approve")}
style={{
width: "100%",
padding: "0.75rem",
borderRadius: "0.5rem",
border: "none",
background: "#000000",
color: "#ffffff",
fontWeight: 700,
fontSize: "1rem",
cursor: deciding ? "default" : "pointer",
opacity: deciding && deciding !== "approve" ? 0.6 : 1,
}}
>
{deciding === "approve"
? t("oauthConsent.approving", "Allowing...")
@@ -392,19 +338,12 @@ export default function OAuthConsent() {
</div>
{displayName && (
<p
style={{
textAlign: "center",
fontSize: "0.8125rem",
color: "#9ca3af",
marginTop: "1.25rem",
}}
>
<Text size="sm" c="dimmed" ta="center" mt="lg">
{t("oauthConsent.signedInAs", {
name: displayName,
defaultValue: `Signed in as ${displayName}`,
})}
</p>
</Text>
)}
</AuthLayout>
);
+1 -1
View File
@@ -182,7 +182,7 @@ export default function Signup() {
};
return (
<AuthLayout isEmailFormExpanded={showEmailForm}>
<AuthLayout>
{/* Centered logo */}
<div className="auth-logo-block">
<img
@@ -1,75 +0,0 @@
.authContainer {
position: relative;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: var(--auth-bg-color-light-only);
padding: 1.5rem 1.5rem 0;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
overflow: auto;
}
/* Main area above footer: keep card centered even with footer visible */
.authMain {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 95vh;
}
.authCard {
width: min(45rem, 96vw);
height: min(50.875rem, 96vh);
display: grid;
grid-template-columns: 1fr;
background-color: var(--auth-card-bg);
border-radius: 1.25rem;
box-shadow: 0 1.25rem 3.75rem rgba(0, 0, 0, 0.12);
overflow: hidden;
min-height: 0;
max-height: 96vh;
}
.authCardTwoColumns {
width: min(73.75rem, 96vw);
grid-template-columns: 1fr 1fr;
}
.authLeftPanel {
display: flex;
justify-content: center;
padding: 2rem;
min-height: 0;
height: 100%;
}
.authLeftPanelCentered {
align-items: center;
overflow: hidden;
}
.authLeftPanelScrollable {
align-items: flex-start;
overflow-y: auto;
overflow-x: hidden;
}
.authLeftPanel::-webkit-scrollbar {
display: none; /* WebKit browsers (Chrome, Safari, Edge) */
}
.authContent {
max-width: 26.25rem; /* 420px */
width: 100%;
display: flex;
flex-direction: column;
}
.authLeftPanelScrollable .authContent {
min-height: 100%;
}
@@ -1,87 +1,11 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import LoginRightCarousel from "@app/auth/ui/LoginRightCarousel";
import buildLoginSlides from "@app/components/shared/loginSlides";
import styles from "@app/routes/authShared/AuthLayout.module.css";
import { useLogoVariant } from "@app/hooks/useLogoVariant";
import { useIsOverflowing } from "@app/hooks/useIsOverflowing";
import React from "react";
import { AuthShell } from "@app/auth/ui/AuthShell";
import Footer from "@app/components/shared/Footer";
interface AuthLayoutProps {
children: React.ReactNode;
isEmailFormExpanded?: boolean;
}
export default function AuthLayout({
children,
isEmailFormExpanded = false,
}: AuthLayoutProps) {
const { t } = useTranslation();
const cardRef = useRef<HTMLDivElement | null>(null);
const leftPanelRef = useRef<HTMLDivElement | null>(null);
const [hideRightPanel, setHideRightPanel] = useState(false);
const logoVariant = useLogoVariant();
const imageSlides = useMemo(
() => buildLoginSlides(logoVariant, t),
[logoVariant, t],
);
const isOverflowing = useIsOverflowing(leftPanelRef);
// Use either overflow detection or email form expansion to determine scrollable state
const shouldBeScrollable = isOverflowing || isEmailFormExpanded;
useEffect(() => {
const update = () => {
// Use viewport to avoid hysteresis when the card is already in single-column mode
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const cardWidthIfTwoCols = Math.min(1180, viewportWidth * 0.96); // matches min(73.75rem, 96vw)
const columnWidth = cardWidthIfTwoCols / 2;
const tooNarrow = columnWidth < 470;
const tooShort = viewportHeight < 740;
setHideRightPanel(tooNarrow || tooShort);
};
update();
window.addEventListener("resize", update);
window.addEventListener("orientationchange", update);
return () => {
window.removeEventListener("resize", update);
window.removeEventListener("orientationchange", update);
};
}, []);
return (
<div className={styles.authContainer}>
<div className={styles.authMain}>
<div
ref={cardRef}
className={`${styles.authCard} ${!hideRightPanel ? styles.authCardTwoColumns : ""}`}
>
<div
ref={leftPanelRef}
className={`${styles.authLeftPanel} ${shouldBeScrollable ? styles.authLeftPanelScrollable : styles.authLeftPanelCentered}`}
>
<div className={styles.authContent}>{children}</div>
</div>
{!hideRightPanel && (
<LoginRightCarousel
imageSlides={imageSlides}
initialSeconds={5}
slideSeconds={8}
/>
)}
</div>
</div>
<div
style={{
width: "100vw",
marginTop: "auto",
marginLeft: "-1.5rem",
marginRight: "-1.5rem",
}}
>
<Footer analyticsEnabled />
</div>
</div>
);
export default function AuthLayout({ children }: AuthLayoutProps) {
return <AuthShell footer={<Footer analyticsEnabled />}>{children}</AuthShell>;
}
+34 -33
View File
@@ -24,7 +24,7 @@
--tool-subcategory-text-color-light: var(--p-gray-400);
--tool-subcategory-rule-color-light: var(--p-gray-200);
/* Auth color vars */
/* Auth color vars (light mode) */
--auth-input-bg: var(--p-gray-50);
--auth-input-border: var(--p-gray-200);
--auth-input-text: var(--p-gray-800);
@@ -33,26 +33,22 @@
--auth-button-text: #ffffff;
--auth-magic-button-bg: var(--p-red-600);
--auth-magic-button-text: #ffffff;
/* Light-only auth colors (no dark mode equivalents) used for login/signup */
--auth-input-bg-light-only: var(--p-gray-50);
--auth-input-border-light-only: var(--p-gray-200);
--auth-input-text-light-only: var(--p-gray-800);
--auth-label-text-light-only: var(--p-zinc-650);
--auth-button-bg-light-only: var(--p-red-600);
--auth-button-text-light-only: #ffffff;
--auth-magic-button-bg-light-only: var(--p-red-600);
--auth-magic-button-text-light-only: #ffffff;
--auth-bg-color-light-only: #ffffff;
--auth-card-bg-light-only: #ffffff;
--auth-text-primary-light-only: var(--p-zinc-650);
--auth-text-secondary-light-only: var(--p-gray-800);
--auth-border-focus-light-only: var(--p-gray-300);
--auth-focus-ring-light-only: color-mix(
--auth-bg-color: #ffffff;
--auth-card-bg: #ffffff;
--auth-text-primary: var(--p-zinc-650);
--auth-text-secondary: var(--p-gray-800);
--auth-border-focus: var(--p-gray-300);
--auth-focus-ring: color-mix(in srgb, var(--p-blue-500) 15%, transparent);
--auth-error-bg: color-mix(in srgb, var(--p-red-500) 8%, transparent);
--auth-error-border: color-mix(in srgb, var(--p-red-500) 25%, transparent);
--auth-error-text: var(--p-red-600);
--auth-success-bg: color-mix(in srgb, var(--p-green-500) 10%, transparent);
--auth-success-border: color-mix(
in srgb,
var(--p-blue-500) 15%,
var(--p-green-500) 30%,
transparent
);
--auth-success-text: var(--p-green-600);
/* App Config Modal colors (light mode) */
--modal-nav-bg: var(--p-gray-100);
@@ -123,25 +119,30 @@
--color-orange-400: var(--p-red-600);
/* Auth page colors (dark mode) — mirror proprietary so the auth card themes dark */
--auth-bg-color-light-only: var(--bg-muted);
--auth-bg-color: var(--bg-muted);
--auth-card-bg: var(--bg-surface);
--auth-card-bg-light-only: var(--bg-surface);
--auth-label-text-light-only: var(--text-secondary);
--auth-input-border-light-only: var(--border-default);
--auth-input-bg-light-only: var(--bg-raised);
--auth-input-text-light-only: var(--text-primary);
--auth-border-focus-light-only: var(--p-blue-500);
--auth-focus-ring-light-only: color-mix(
--auth-label-text: var(--text-secondary);
--auth-input-border: var(--border-default);
--auth-input-bg: var(--bg-raised);
--auth-input-text: var(--text-primary);
--auth-border-focus: var(--p-blue-500);
--auth-focus-ring: color-mix(in srgb, var(--p-blue-500) 20%, transparent);
--auth-button-bg: var(--p-red-600);
--auth-button-text: #ffffff;
--auth-magic-button-bg: var(--bg-raised);
--auth-magic-button-text: var(--text-primary);
--auth-text-primary: var(--text-primary);
--auth-text-secondary: var(--text-secondary);
--auth-error-bg: color-mix(in srgb, var(--p-red-500) 12%, transparent);
--auth-error-border: color-mix(in srgb, var(--p-red-500) 35%, transparent);
--auth-error-text: var(--p-red-400);
--auth-success-bg: color-mix(in srgb, var(--p-green-500) 12%, transparent);
--auth-success-border: color-mix(
in srgb,
var(--p-blue-500) 20%,
var(--p-green-500) 35%,
transparent
);
--auth-button-bg-light-only: var(--p-red-600);
--auth-button-text-light-only: #ffffff;
--auth-magic-button-bg-light-only: var(--bg-raised);
--auth-magic-button-text-light-only: var(--text-primary);
--auth-text-primary-light-only: var(--text-primary);
--auth-text-secondary-light-only: var(--text-secondary);
--auth-success-text: var(--p-green-500);
--text-divider-rule-rgb-light: 229, 231, 235;
--text-divider-label-rgb-light: 156, 163, 175;
--tool-subcategory-rule-color-light: var(--p-gray-200);