mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-02 21:03:34 +03:00
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:
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user