Restructure/frontend editor (#6404)

## Move editor under `frontend/editor/`

Pure restructure: `frontend/` becomes the workspace, `frontend/editor/`
holds
  the PDF editor. 1775 file renames + 40 wiring edits. No logic changes.

  ### Why

`frontend/` is currently the editor — its `src/`, `public/`,
`src-tauri/`,
  config files all sit at the root. Promoting `frontend/` to a
workspace and putting the editor in a sibling folder leaves room for
future
apps to drop in alongside it, sharing one `package.json` /
`node_modules` /
  lint config / Storybook.

  ### What moves

  frontend/
  ├── editor/                ← NEW: everything editor-specific
  │   ├── src/               ← was frontend/src/
  │   ├── public/            ← was frontend/public/
  │   ├── src-tauri/         ← was frontend/src-tauri/
│ ├── index.html, vite.config.ts, vitest.config.ts, playwright.config.ts
  │   ├── tsconfig*.json, tailwind.config.js, postcss.config.js
  │   ├── scripts/
  │   ├── .env, .env.desktop, .env.saas
  │   └── DeveloperGuide.md
├── package.json, package-lock.json, node_modules/ ← workspace install
  ├── eslint.config.mjs, .prettierrc, .prettierignore ← shared tooling
  ├── .gitignore
  └── README.md

  ### Wiring edits (40 files)

  - `.taskfiles/frontend.yml`, `desktop.yml`, `e2e.yml`
  - `build.gradle`, `app/core/build.gradle`
- `eslint.config.mjs`, `frontend/package.json`, `.gitignore`,
`.prettierignore`
  - `docker/frontend/Dockerfile`
  - 8 `.github/workflows/*.yml`, plus `.github/dependabot.yml`,
    `.github/config/.files.yaml`, `.github/labeler-config-srvaroa.yml`
  - `scripts/translations/**`
- Docs: `AGENTS.md`, `CLAUDE.md`, `ADDING_TOOLS.md`,
`DeveloperGuide.md`,
`WINDOWS_SIGNING.md`, `devGuide/HowToAddNewLanguage.md`,
`frontend/README.md`,
    `frontend/editor/DeveloperGuide.md`

Plus 3 renamed + edited: `editor/vite.config.ts` (env path +
node_modules
  walk-up), `editor/scripts/setup-env.mts` (renamed from `.ts` for
`import.meta.url`), `editor/scripts/build-provisioner.mjs` (resolve
src-tauri
  relative to script).

  ### Verification

  | Check | Result |
  |---|---|
  | `task frontend:typecheck:all` (6 variants) | exit 0 |
  | `task frontend:lint` (eslint + dpdm) | exit 0 |
  | `task frontend:format:check` | exit 0 |
  | `task frontend:test` | 657 tests pass, 50 files |
| `task frontend:build:{core,proprietary,saas,desktop,prototypes}` | all
green |
| `task desktop:build` | full Tauri pipeline →
`Stirling-PDF_2.11.0_x64_en-US.msi` |
  | `playwright test --list --project=stubbed` | 172 tests discovered |

`task desktop:build` exercises the heaviest path — Rust + WiX + MSI
bundle
against the moved `editor/src-tauri/`. If anything in the restructure
was
  wrong it wouldn't have built.

  ### Test plan

  - [ ] `frontend-validation.yml` green
  - [ ] `e2e-stubbed.yml` green
  - [ ] `tauri-build.yml` green on at least one platform
  - [ ] `check_toml.yml` runs on a translation-touching PR

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
Reece Browne
2026-05-22 13:40:34 +01:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 48027ee9d6
commit 0a50e765b7
1820 changed files with 300 additions and 226 deletions
@@ -0,0 +1,131 @@
.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;
}
}
@@ -0,0 +1,263 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, waitFor } from "@testing-library/react";
import { BrowserRouter } from "react-router-dom";
import AuthCallback from "@app/routes/AuthCallback";
import {
POST_LOGIN_REDIRECT_STORAGE_KEY,
springAuth,
} from "@app/auth/springAuthClient";
// Mock springAuth; keep the real redirect-path helpers.
vi.mock("@app/auth/springAuthClient", async () => {
const actual = await vi.importActual<
typeof import("@app/auth/springAuthClient")
>("@app/auth/springAuthClient");
return {
...actual,
springAuth: {
getSession: vi.fn(),
},
};
});
// Mock useNavigate
const mockNavigate = vi.fn();
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual("react-router-dom");
return {
...actual,
useNavigate: () => mockNavigate,
};
});
describe("AuthCallback", () => {
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
vi.clearAllMocks();
// Reset window.location.hash
window.location.hash = "";
});
it("should extract JWT from URL hash and validate it", async () => {
const mockToken = "oauth-jwt-token";
const mockUser = {
id: "123",
email: "[email protected]",
username: "oauthuser",
role: "USER",
};
// Set URL hash with access token
window.location.hash = `#access_token=${mockToken}`;
// Mock successful session validation
vi.mocked(springAuth.getSession).mockResolvedValueOnce({
data: {
session: {
user: mockUser,
access_token: mockToken,
expires_in: 3600,
expires_at: Date.now() + 3600000,
},
},
error: null,
});
const dispatchEventSpy = vi.spyOn(window, "dispatchEvent");
render(
<BrowserRouter>
<AuthCallback />
</BrowserRouter>,
);
await waitFor(() => {
// Verify JWT was stored
expect(localStorage.getItem("stirling_jwt")).toBe(mockToken);
// Verify jwt-available event was dispatched
expect(dispatchEventSpy).toHaveBeenCalledWith(
expect.objectContaining({ type: "jwt-available" }),
);
// Verify getSession was called to validate token
expect(springAuth.getSession).toHaveBeenCalled();
// Verify navigation to home
expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true });
});
});
it("should redirect to login when no access token in hash", async () => {
// No hash or empty hash
window.location.hash = "";
render(
<BrowserRouter>
<AuthCallback />
</BrowserRouter>,
);
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith("/login", {
replace: true,
state: { error: "OAuth login failed - no token received." },
});
expect(localStorage.getItem("stirling_jwt")).toBeNull();
});
});
it("should redirect to login when token validation fails", async () => {
const invalidToken = "invalid-oauth-token";
window.location.hash = `#access_token=${invalidToken}`;
// Mock failed session validation
vi.mocked(springAuth.getSession).mockResolvedValueOnce({
data: { session: null },
error: { message: "Invalid token" },
});
render(
<BrowserRouter>
<AuthCallback />
</BrowserRouter>,
);
await waitFor(() => {
// JWT should be stored initially
expect(localStorage.getItem("stirling_jwt")).toBeNull(); // Cleared after validation failure
// Verify redirect to login
expect(mockNavigate).toHaveBeenCalledWith("/login", {
replace: true,
state: { error: "OAuth login failed - invalid token." },
});
});
});
it("should handle errors gracefully", async () => {
const mockToken = "error-token";
window.location.hash = `#access_token=${mockToken}`;
// Mock getSession throwing error
vi.mocked(springAuth.getSession).mockRejectedValueOnce(
new Error("Network error"),
);
render(
<BrowserRouter>
<AuthCallback />
</BrowserRouter>,
);
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith("/login", {
replace: true,
state: { error: "OAuth login failed. Please try again." },
});
});
});
it("should navigate to the stored post-login path when one is present", async () => {
const mockToken = "oauth-jwt-token";
const mockUser = {
id: "123",
email: "[email protected]",
username: "oauthuser",
role: "USER",
};
window.location.hash = `#access_token=${mockToken}`;
sessionStorage.setItem(POST_LOGIN_REDIRECT_STORAGE_KEY, "/share/abc123");
vi.mocked(springAuth.getSession).mockResolvedValueOnce({
data: {
session: {
user: mockUser,
access_token: mockToken,
expires_in: 3600,
expires_at: Date.now() + 3600000,
},
},
error: null,
});
render(
<BrowserRouter>
<AuthCallback />
</BrowserRouter>,
);
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith("/share/abc123", {
replace: true,
});
});
// Stored path is single-use
expect(sessionStorage.getItem(POST_LOGIN_REDIRECT_STORAGE_KEY)).toBeNull();
});
it("should fall back to home when the stored post-login path is unsafe", async () => {
const mockToken = "oauth-jwt-token";
const mockUser = {
id: "123",
email: "[email protected]",
username: "oauthuser",
role: "USER",
};
window.location.hash = `#access_token=${mockToken}`;
// Protocol-relative URL — must not be followed
sessionStorage.setItem(POST_LOGIN_REDIRECT_STORAGE_KEY, "//evil.example");
vi.mocked(springAuth.getSession).mockResolvedValueOnce({
data: {
session: {
user: mockUser,
access_token: mockToken,
expires_in: 3600,
expires_at: Date.now() + 3600000,
},
},
error: null,
});
render(
<BrowserRouter>
<AuthCallback />
</BrowserRouter>,
);
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true });
});
expect(sessionStorage.getItem(POST_LOGIN_REDIRECT_STORAGE_KEY)).toBeNull();
});
it("should display loading state while processing", () => {
window.location.hash = "#access_token=processing-token";
vi.mocked(springAuth.getSession).mockImplementationOnce(
() =>
new Promise((resolve) =>
setTimeout(
() =>
resolve({
data: { session: null },
error: { message: "Token expired" },
}),
100,
),
),
);
const { getByText } = render(
<BrowserRouter>
<AuthCallback />
</BrowserRouter>,
);
expect(getByText("Completing authentication")).toBeInTheDocument();
});
});
@@ -0,0 +1,227 @@
import { useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import {
consumePostLoginRedirectPath,
springAuth,
} from "@app/auth/springAuthClient";
import { handleAuthCallbackSuccess } from "@app/extensions/authCallback";
import styles from "@app/routes/AuthCallback.module.css";
/**
* OAuth Callback Handler
*
* This component is rendered after OAuth providers (GitHub, Google, etc.) redirect back.
* The JWT is passed in the URL fragment (#access_token=...) by the Spring backend.
* We extract it, store in localStorage, and redirect to the home page.
*/
export default function AuthCallback() {
const navigate = useNavigate();
const processingRef = useRef(false);
// Log component lifecycle
useEffect(() => {
const mountId = Math.random().toString(36).substring(7);
console.log(`[AuthCallback:${mountId}] 🔵 Component mounted`);
return () => {
console.log(`[AuthCallback:${mountId}] 🔴 Component unmounting`);
};
}, []);
useEffect(() => {
const handleCallback = async () => {
const startTime = performance.now();
const executionId = Math.random().toString(36).substring(7);
console.log(
`[AuthCallback:${executionId}] ════════════════════════════════════`,
);
console.log(
`[AuthCallback:${executionId}] Starting authentication callback`,
);
console.log(`[AuthCallback:${executionId}] URL: ${window.location.href}`);
console.log(
`[AuthCallback:${executionId}] Hash: ${window.location.hash}`,
);
console.log(
`[AuthCallback:${executionId}] Document readyState: ${document.readyState}`,
);
if (
typeof window !== "undefined" &&
window.sessionStorage.getItem("stirling_sso_auto_login_logged_out") ===
"1"
) {
console.warn(
`[AuthCallback:${executionId}] ⚠️ Logout block active, skipping token processing`,
);
navigate("/login", {
replace: true,
state: { error: "You have been signed out. Please sign in again." },
});
return;
}
// Prevent double execution (React 18 Strict Mode + navigate dependency)
if (processingRef.current) {
console.warn(
`[AuthCallback:${executionId}] ⚠️ Already processing, skipping duplicate execution`,
);
console.warn(
`[AuthCallback:${executionId}] This is expected in React Strict Mode (development)`,
);
return;
}
processingRef.current = true;
try {
console.log(
`[AuthCallback:${executionId}] Step 1: Extracting token from URL fragment`,
);
// Extract JWT from URL fragment (#access_token=...)
const hash = window.location.hash.substring(1); // Remove '#'
const params = new URLSearchParams(hash);
const token = params.get("access_token");
if (!token) {
console.error(
`[AuthCallback:${executionId}] ❌ No access_token in URL fragment`,
);
navigate("/login", {
replace: true,
state: { error: "OAuth login failed - no token received." },
});
return;
}
console.log(
`[AuthCallback:${executionId}] ✓ Token extracted (length: ${token.length})`,
);
console.log(
`[AuthCallback:${executionId}] Step 2: Storing JWT in localStorage`,
);
// Store JWT in localStorage
localStorage.setItem("stirling_jwt", token);
console.log(
`[AuthCallback:${executionId}] ✓ JWT stored in localStorage`,
);
console.log(
`[AuthCallback:${executionId}] Step 3: Dispatching 'jwt-available' event`,
);
// Dispatch custom event for other components to react to JWT availability
window.dispatchEvent(new CustomEvent("jwt-available"));
console.log(`[AuthCallback:${executionId}] ✓ Event dispatched`);
console.log(
`[AuthCallback:${executionId}] Elapsed after jwt-available: ${(performance.now() - startTime).toFixed(2)}ms`,
);
console.log(
`[AuthCallback:${executionId}] Step 4: Validating token with backend`,
);
// Validate the token and load user info
// This calls /api/v1/auth/me with the JWT to get user details
const { data, error } = await springAuth.getSession();
if (error || !data.session) {
console.error(
`[AuthCallback:${executionId}] ❌ Failed to validate token:`,
error,
);
localStorage.removeItem("stirling_jwt");
navigate("/login", {
replace: true,
state: { error: "OAuth login failed - invalid token." },
});
return;
}
console.log(
`[AuthCallback:${executionId}] ✓ Token validated, user: ${data.session.user.username}`,
);
console.log(
`[AuthCallback:${executionId}] Step 5: Running platform-specific callback handlers`,
);
await handleAuthCallbackSuccess(token);
console.log(
`[AuthCallback:${executionId}] ✓ Callback handlers complete`,
);
console.log(
`[AuthCallback:${executionId}] Step 6: Waiting for context stabilization`,
);
// Wait for all context providers to process jwt-available event
// This prevents infinite render loop when coming from cross-domain SAML redirect
await new Promise((resolve) => setTimeout(resolve, 100));
console.log(
`[AuthCallback:${executionId}] Elapsed after stabilization wait: ${(performance.now() - startTime).toFixed(2)}ms`,
);
const target = consumePostLoginRedirectPath() ?? "/";
console.log(
`[AuthCallback:${executionId}] Step 7: Navigating to ${target}`,
);
navigate(target, { replace: true });
const duration = performance.now() - startTime;
console.log(
`[AuthCallback:${executionId}] ✓ Authentication complete (${duration.toFixed(2)}ms)`,
);
console.log(
`[AuthCallback:${executionId}] ════════════════════════════════════`,
);
} catch (error) {
const duration = performance.now() - startTime;
console.error(
`[AuthCallback:${executionId}] ════════════════════════════════════`,
);
console.error(
`[AuthCallback:${executionId}] ❌ FATAL ERROR during authentication`,
);
console.error(`[AuthCallback:${executionId}] Error:`, error);
console.error(
`[AuthCallback:${executionId}] Error name:`,
(error as Error)?.name,
);
console.error(
`[AuthCallback:${executionId}] Error message:`,
(error as Error)?.message,
);
console.error(
`[AuthCallback:${executionId}] Error stack:`,
(error as Error)?.stack,
);
console.error(
`[AuthCallback:${executionId}] Duration before failure: ${duration.toFixed(2)}ms`,
);
console.error(
`[AuthCallback:${executionId}] ════════════════════════════════════`,
);
navigate("/login", {
replace: true,
state: { error: "OAuth login failed. Please try again." },
});
}
};
handleCallback();
}, []); // 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}>Completing authentication</div>
<div className={styles.message}>
Please wait while we finish signing you in.
</div>
<div className={styles.loadingExtra}>
You can close this window once it completes.
</div>
</div>
</div>
);
}
@@ -0,0 +1,290 @@
import { useState, useEffect } from "react";
import { isAxiosError } from "axios";
import { useParams, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
Stack,
Text,
Paper,
Center,
Loader,
TextInput,
PasswordInput,
Anchor,
} from "@mantine/core";
import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
import AuthLayout from "@app/routes/authShared/AuthLayout";
import LoginHeader from "@app/routes/login/LoginHeader";
import ErrorMessage from "@app/routes/login/ErrorMessage";
import { BASE_PATH } from "@app/constants/app";
import apiClient from "@app/services/apiClient";
interface InviteData {
email: string | null;
role: string;
expiresAt: string;
emailRequired: boolean;
}
export default function InviteAccept() {
const { token } = useParams<{ token: string }>();
const navigate = useNavigate();
const { t } = useTranslation();
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [inviteData, setInviteData] = useState<InviteData | null>(null);
const [error, setError] = useState<string | null>(null);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const baseUrl = window.location.origin + BASE_PATH;
// Set document meta
useDocumentMeta({
title: `${t("invite.welcome", "Welcome to Stirling PDF")} - Stirling PDF`,
description: t(
"app.description",
"The Free Adobe Acrobat alternative (10M+ Downloads)",
),
ogTitle: `${t("invite.welcome", "Welcome to Stirling PDF")} - Stirling PDF`,
ogDescription: t(
"app.description",
"The Free Adobe Acrobat alternative (10M+ Downloads)",
),
ogImage: `${baseUrl}/og_images/home.png`,
ogUrl: `${window.location.origin}${window.location.pathname}`,
});
useEffect(() => {
if (!token) {
setError(t("invite.invalidToken", "Invalid invitation link"));
setLoading(false);
return;
}
validateToken();
}, [token]);
const validateToken = async () => {
try {
setLoading(true);
const response = await apiClient.get<InviteData>(
`/api/v1/invite/validate/${token}`,
{
suppressErrorToast: true,
},
);
setInviteData(response.data);
setError(null);
} catch (err: unknown) {
const errorMessage = isAxiosError(err)
? err.response?.data?.error || err.message
: (err instanceof Error ? err.message : undefined) ||
t("invite.validationError", "Failed to validate invitation link");
setError(errorMessage);
} finally {
setLoading(false);
}
};
const handleAccept = async (e: React.FormEvent) => {
e.preventDefault();
// Validate email if required
if (inviteData?.emailRequired) {
if (!email || email.trim().length === 0) {
setError(t("invite.emailRequired", "Email address is required"));
return;
}
if (!email.includes("@")) {
setError(t("invite.invalidEmail", "Invalid email address"));
return;
}
}
// Validate passwords
if (!password) {
setError(t("invite.passwordRequired", "Password is required"));
return;
}
if (password !== confirmPassword) {
setError(t("invite.passwordMismatch", "Passwords do not match"));
return;
}
try {
setSubmitting(true);
setError(null);
const formData = new FormData();
if (inviteData?.emailRequired) {
formData.append("email", email.trim().toLowerCase());
}
formData.append("password", password);
await apiClient.post(`/api/v1/invite/accept/${token}`, formData, {
suppressErrorToast: true,
});
// Success - redirect to login
navigate("/login?messageType=accountCreated");
} catch (err: unknown) {
const errorMessage = isAxiosError(err)
? err.response?.data?.error || err.message
: (err instanceof Error ? err.message : undefined) ||
t("invite.acceptError", "Failed to create account");
setError(errorMessage);
} finally {
setSubmitting(false);
}
};
if (loading) {
return (
<AuthLayout>
<LoginHeader
title={t("invite.validating", "Validating invitation...")}
/>
<Center py="xl">
<Loader size="md" />
</Center>
</AuthLayout>
);
}
if (error && !inviteData) {
return (
<AuthLayout>
<LoginHeader
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>
</AuthLayout>
);
}
return (
<AuthLayout>
<LoginHeader
title={t("invite.welcomeTitle", "You've been invited!")}
subtitle={t(
"invite.welcomeSubtitle",
"Complete your account setup to get started",
)}
/>
{inviteData && !inviteData.emailRequired && (
<Paper
withBorder
p="md"
mb="lg"
bg="blue.0"
style={{ borderColor: "var(--mantine-color-blue-3)" }}
>
<Stack gap="xs" align="center">
<Text
size="xs"
tt="uppercase"
c="dimmed"
fw={500}
style={{ letterSpacing: "0.05em" }}
>
{t("invite.accountFor", "Creating account for")}
</Text>
<Text size="lg" fw={600}>
{inviteData.email}
</Text>
<Text size="xs" c="dimmed">
{t("invite.linkExpires", "Link expires")}:{" "}
{new Date(inviteData.expiresAt).toLocaleDateString()} at{" "}
{new Date(inviteData.expiresAt).toLocaleTimeString()}
</Text>
</Stack>
</Paper>
)}
<ErrorMessage error={error} />
<form onSubmit={handleAccept}>
<Stack gap="md">
{inviteData?.emailRequired && (
<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"
/>
)}
<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>
</form>
<Center mt="md">
<Text size="sm" c="dimmed">
{t("invite.alreadyHaveAccount", "Already have an account?")}{" "}
<Anchor
component="button"
type="button"
onClick={() => navigate("/login")}
c="dark"
>
{t("invite.signIn", "Sign in")}
</Anchor>
</Text>
</Center>
</AuthLayout>
);
}
@@ -0,0 +1,177 @@
import { useEffect } from "react";
import { Navigate, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "@app/auth/UseSession";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import HomePage from "@app/pages/HomePage";
import { useBackendProbe } from "@app/hooks/useBackendProbe";
import AuthLayout from "@app/routes/authShared/AuthLayout";
import LoginHeader from "@app/routes/login/LoginHeader";
import { useTranslation } from "react-i18next";
/**
* Landing component - Smart router based on authentication status
*
* If login is disabled: Show HomePage directly (anonymous mode)
* If user is authenticated: Show HomePage
* If user is not authenticated: Show Login or redirect to /login
*/
export default function Landing() {
const { session, loading: authLoading } = useAuth();
const { config, loading: configLoading, refetch } = useAppConfig();
const backendProbe = useBackendProbe();
const location = useLocation();
const navigate = useNavigate();
const { t } = useTranslation();
const loading = authLoading || configLoading || backendProbe.loading;
// Debug: Track Landing component lifecycle
useEffect(() => {
const mountId = Math.random().toString(36).substring(7);
console.log(
`[Landing:${mountId}] 🔵 Component mounted at ${location.pathname}`,
);
console.log(`[Landing:${mountId}] Mount state:`, {
authLoading,
configLoading,
backendLoading: backendProbe.loading,
hasSession: !!session,
});
return () => {
console.log(`[Landing:${mountId}] 🔴 Component unmounting`);
};
}, [
location.pathname,
authLoading,
configLoading,
backendProbe.loading,
session,
]);
// Periodically probe while backend isn't up so the screen can auto-advance when it comes online
useEffect(() => {
if (backendProbe.status === "up" || backendProbe.loginDisabled) {
return;
}
const tick = async () => {
const result = await backendProbe.probe();
if (result.status === "up") {
await refetch();
if (result.loginDisabled) {
navigate("/", { replace: true });
}
}
};
const intervalId = window.setInterval(() => {
void tick();
}, 5000);
return () => window.clearInterval(intervalId);
}, [
backendProbe.status,
backendProbe.loginDisabled,
backendProbe.probe,
navigate,
refetch,
]);
useEffect(() => {
if (backendProbe.status === "up") {
void refetch();
}
}, [backendProbe.status, refetch]);
console.log("[Landing] ════════════════════════════════════");
console.log("[Landing] Render state:", {
pathname: location.pathname,
loading,
authLoading,
configLoading,
backendLoading: backendProbe.loading,
hasSession: !!session,
hasConfig: !!config,
loginEnabled: config?.enableLogin === true && !backendProbe.loginDisabled,
backendStatus: backendProbe.status,
timestamp: new Date().toISOString(),
});
console.log("[Landing] ════════════════════════════════════");
// Show loading while checking auth and config
if (loading) {
return (
<div
style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-3"></div>
<div className="text-gray-600">Loading...</div>
</div>
</div>
);
}
// If login is disabled, show app directly (anonymous mode)
if (config?.enableLogin === false || backendProbe.loginDisabled) {
console.debug("[Landing] Login disabled - showing app in anonymous mode");
return <HomePage />;
}
// If backend is not up yet and user is not authenticated, show a branded status screen
if (!session && backendProbe.status !== "up") {
const backendTitle = t("backendStartup.notFoundTitle", "Backend not found");
const handleRetry = async () => {
const result = await backendProbe.probe();
if (result.status === "up") {
await refetch();
navigate("/", { replace: true });
}
};
return (
<AuthLayout>
<LoginHeader title={backendTitle} />
<div
className="auth-section"
style={{
padding: "1.5rem",
marginTop: "1rem",
borderRadius: "0.75rem",
backgroundColor: "rgba(37, 99, 235, 0.08)",
border: "1px solid rgba(37, 99, 235, 0.2)",
}}
>
<p
style={{ margin: "0 0 0.75rem 0", color: "rgba(15, 23, 42, 0.8)" }}
>
{t("backendStartup.unreachable")}
</p>
<button
type="button"
onClick={handleRetry}
className="auth-cta-button px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold mt-5 border-0 cursor-pointer"
style={{ width: "fit-content" }}
>
{t("backendStartup.retry", "Retry")}
</button>
</div>
</AuthLayout>
);
}
// If we have a session, show the main app
// Note: First login password change is now handled by the onboarding flow
if (session) {
return <HomePage />;
}
// No session - redirect to login page
// This ensures the URL always shows /login when not authenticated
return config?.enableLogin === true && !backendProbe.loginDisabled ? (
<Navigate to="/login" replace state={{ from: location }} />
) : (
<HomePage />
);
}
@@ -0,0 +1,752 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { BrowserRouter, MemoryRouter } from "react-router-dom";
import { MantineProvider } from "@mantine/core";
import Login from "@app/routes/Login";
import { useAuth } from "@app/auth/UseSession";
import { springAuth } from "@app/auth/springAuthClient";
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
import apiClient from "@app/services/apiClient";
// Mock i18n to return fallback text
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, fallback?: string | Record<string, unknown>) => {
if (typeof fallback === "string") return fallback;
return key;
},
}),
initReactI18next: {
type: "3rdParty",
init: vi.fn(),
},
}));
// Mock i18n module to avoid initialization
vi.mock("@app/i18n", () => ({
updateSupportedLanguages: vi.fn(),
supportedLanguages: { "en-GB": "English" },
rtlLanguages: [],
default: {
language: "en-GB",
changeLanguage: vi.fn(),
options: {},
},
}));
// Mock useAuth hook
vi.mock("@app/auth/UseSession", () => ({
useAuth: vi.fn(),
}));
// Mock springAuth; keep the real redirect-path helpers.
vi.mock("@app/auth/springAuthClient", async () => {
const actual = await vi.importActual<
typeof import("@app/auth/springAuthClient")
>("@app/auth/springAuthClient");
return {
...actual,
springAuth: {
signInWithPassword: vi.fn(),
signInWithOAuth: vi.fn(),
},
};
});
// Mock useDocumentMeta
vi.mock("@app/hooks/useDocumentMeta", () => ({
useDocumentMeta: vi.fn(),
}));
// Mock apiClient for provider list
vi.mock("@app/services/apiClient", () => ({
default: {
get: vi.fn(),
post: vi.fn(),
},
}));
const mockNavigate = vi.fn();
const mockBackendProbeState = {
status: "up" as const,
loginDisabled: false,
loading: false,
};
const mockProbe = vi.fn().mockResolvedValue(mockBackendProbeState);
vi.mock("@app/hooks/useBackendProbe", () => ({
useBackendProbe: () => ({
...mockBackendProbeState,
probe: mockProbe,
}),
}));
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual("react-router-dom");
return {
...actual,
useNavigate: () => mockNavigate,
};
});
// Test wrapper with MantineProvider
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<MantineProvider>
<PreferencesProvider>{children}</PreferencesProvider>
</MantineProvider>
);
describe("Login", () => {
beforeEach(() => {
vi.clearAllMocks();
mockBackendProbeState.status = "up";
mockBackendProbeState.loginDisabled = false;
mockBackendProbeState.loading = false;
mockProbe.mockResolvedValue(mockBackendProbeState);
// Default auth state - not logged in
vi.mocked(useAuth).mockReturnValue({
session: null,
user: null,
loading: false,
error: null,
signOut: vi.fn(),
refreshSession: vi.fn(),
});
// Mock apiClient for login UI data
vi.mocked(apiClient.get).mockResolvedValue({
data: {
enableLogin: true,
providerList: {},
},
});
});
it("should render login form", async () => {
render(
<TestWrapper>
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>,
);
await waitFor(() => {
// Check for login form elements - use id since it's more reliable
const emailInput = document.getElementById("email");
expect(emailInput).toBeTruthy();
});
});
it("should redirect authenticated user to home", async () => {
const mockSession = {
user: {
id: "123",
email: "[email protected]",
username: "testuser",
role: "USER",
},
access_token: "mock-token",
expires_in: 3600,
};
vi.mocked(useAuth).mockReturnValue({
session: mockSession,
user: mockSession.user,
loading: false,
error: null,
signOut: vi.fn(),
refreshSession: vi.fn(),
});
render(
<TestWrapper>
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>,
);
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true });
});
});
it("should show loading state while auth is loading", () => {
vi.mocked(useAuth).mockReturnValue({
session: null,
user: null,
loading: true,
error: null,
signOut: vi.fn(),
refreshSession: vi.fn(),
});
render(
<TestWrapper>
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>,
);
// Component shouldn't redirect or show form while loading
expect(mockNavigate).not.toHaveBeenCalled();
});
it("should handle email/password login", async () => {
const user = userEvent.setup();
const mockUser = {
id: "123",
email: "[email protected]",
username: "[email protected]",
role: "USER",
};
const mockSession = {
user: mockUser,
access_token: "new-token",
expires_in: 3600,
};
vi.mocked(springAuth.signInWithPassword).mockResolvedValueOnce({
user: mockUser,
session: mockSession,
error: null,
});
render(
<TestWrapper>
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>,
);
// Wait for form to load
await waitFor(
() => {
const emailInput = document.getElementById("email");
expect(emailInput).toBeTruthy();
const passwordInput = document.getElementById("password");
expect(passwordInput).toBeTruthy();
},
{ timeout: 3000 },
);
// Fill in form using getElementById
const emailInput = document.getElementById("email") as HTMLInputElement;
const passwordInput = document.getElementById(
"password",
) as HTMLInputElement;
if (!emailInput || !passwordInput) {
throw new Error("Form inputs not found");
}
await user.type(emailInput, "[email protected]");
await user.type(passwordInput, "password123");
// Submit form - use a more flexible query
// Look for button with type="submit" in the form
const submitButton = await waitFor(
() => {
const buttons = screen.queryAllByRole("button");
const submitBtn = buttons.find(
(btn) => btn.getAttribute("type") === "submit",
);
if (!submitBtn) {
throw new Error("Submit button not found");
}
return submitBtn;
},
{ timeout: 5000 },
);
await user.click(submitButton);
await waitFor(() => {
expect(springAuth.signInWithPassword).toHaveBeenCalledWith({
email: "[email protected]",
password: "password123",
});
});
});
it("should use actual provider ID for OAuth login (authentik)", async () => {
const user = userEvent.setup();
// Mock provider list with authentik
vi.mocked(apiClient.get).mockResolvedValue({
data: {
enableLogin: true,
providerList: {
"/oauth2/authorization/authentik": "Authentik",
},
},
});
vi.mocked(springAuth.signInWithOAuth).mockResolvedValueOnce({
error: null,
});
render(
<TestWrapper>
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>,
);
// Wait for OAuth button to appear
await waitFor(
() => {
const button = screen.queryByText("Authentik");
expect(button).toBeTruthy();
},
{ timeout: 3000 },
);
const oauthButton = screen.getByText("Authentik");
await user.click(oauthButton);
await waitFor(() => {
// Should use full path directly, NOT map to 'oidc'
expect(springAuth.signInWithOAuth).toHaveBeenCalledWith({
provider: "/oauth2/authorization/authentik",
options: { redirectTo: "/auth/callback" },
});
});
});
it("should use actual provider ID for OAuth login (custom provider)", async () => {
const user = userEvent.setup();
// Mock provider list with custom provider 'mycompany'
vi.mocked(apiClient.get).mockResolvedValue({
data: {
enableLogin: true,
providerList: {
"/oauth2/authorization/mycompany": "My Company SSO",
},
},
});
vi.mocked(springAuth.signInWithOAuth).mockResolvedValueOnce({
error: null,
});
render(
<TestWrapper>
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>,
);
// Wait for OAuth button to appear (will show 'Mycompany' as label)
await waitFor(
() => {
const button = screen.queryByText("Mycompany");
expect(button).toBeTruthy();
},
{ timeout: 3000 },
);
const oauthButton = screen.getByText("Mycompany");
await user.click(oauthButton);
await waitFor(() => {
// Should use full path directly - this is the critical fix
// Previously it would map unknown providers to 'oidc'
expect(springAuth.signInWithOAuth).toHaveBeenCalledWith({
provider: "/oauth2/authorization/mycompany",
options: { redirectTo: "/auth/callback" },
});
});
});
it("should use oidc provider ID when explicitly configured", async () => {
const user = userEvent.setup();
// Mock provider list with 'oidc'
vi.mocked(apiClient.get).mockResolvedValue({
data: {
enableLogin: true,
providerList: {
"/oauth2/authorization/oidc": "OIDC",
},
},
});
vi.mocked(springAuth.signInWithOAuth).mockResolvedValueOnce({
error: null,
});
render(
<TestWrapper>
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>,
);
// Wait for OAuth button to appear
await waitFor(
() => {
const button = screen.queryByText("OIDC");
expect(button).toBeTruthy();
},
{ timeout: 3000 },
);
const oauthButton = screen.getByText("OIDC");
await user.click(oauthButton);
await waitFor(() => {
// Should use full path when explicitly configured
expect(springAuth.signInWithOAuth).toHaveBeenCalledWith({
provider: "/oauth2/authorization/oidc",
options: { redirectTo: "/auth/callback" },
});
});
});
it("should show error on failed login", async () => {
const user = userEvent.setup();
const errorMessage = "Invalid credentials";
vi.mocked(springAuth.signInWithPassword).mockResolvedValueOnce({
user: null,
session: null,
error: { message: errorMessage },
});
render(
<TestWrapper>
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>,
);
await waitFor(
() => {
const emailInput = document.getElementById("email");
const passwordInput = document.getElementById("password");
expect(emailInput).toBeTruthy();
expect(passwordInput).toBeTruthy();
},
{ timeout: 3000 },
);
const emailInput = document.getElementById("email") as HTMLInputElement;
const passwordInput = document.getElementById(
"password",
) as HTMLInputElement;
await user.type(emailInput, "[email protected]");
await user.type(passwordInput, "wrongpassword");
const submitButton = await waitFor(
() => {
const buttons = screen.queryAllByRole("button");
const submitBtn = buttons.find(
(btn) => btn.getAttribute("type") === "submit",
);
if (!submitBtn) {
throw new Error("Submit button not found");
}
return submitBtn;
},
{ timeout: 5000 },
);
await user.click(submitButton);
await waitFor(() => {
expect(screen.getByText(errorMessage)).toBeInTheDocument();
});
});
it("should validate empty email and password", async () => {
render(
<TestWrapper>
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>,
);
await waitFor(
() => {
expect(document.getElementById("email")).toBeTruthy();
},
{ timeout: 3000 },
);
// Find the submit button
const submitButton = await waitFor(
() => {
const buttons = screen.queryAllByRole("button");
const submitBtn = buttons.find(
(btn) => btn.getAttribute("type") === "submit",
);
if (!submitBtn) {
throw new Error("Submit button not found");
}
return submitBtn;
},
{ timeout: 5000 },
);
// Button should be disabled when email/password are empty
expect(submitButton).toBeDisabled();
// Verify sign in was not called
expect(springAuth.signInWithPassword).not.toHaveBeenCalled();
});
it("should display session expired message from URL param", () => {
render(
<TestWrapper>
<MemoryRouter initialEntries={["/login?expired=true"]}>
<Login />
</MemoryRouter>
</TestWrapper>,
);
expect(screen.getByText(/session.*expired/i)).toBeInTheDocument();
});
it("should display account created success message", () => {
render(
<TestWrapper>
<MemoryRouter initialEntries={["/login?messageType=accountCreated"]}>
<Login />
</MemoryRouter>
</TestWrapper>,
);
expect(screen.getByText(/account created/i)).toBeInTheDocument();
});
it("should prefill email from query param", () => {
const email = "[email protected]";
render(
<TestWrapper>
<MemoryRouter initialEntries={[`/login?email=${email}`]}>
<Login />
</MemoryRouter>
</TestWrapper>,
);
return waitFor(() => {
const emailInput = document.getElementById("email") as HTMLInputElement;
expect(emailInput.value).toBe(email);
});
});
it("should redirect to home when login disabled", async () => {
mockBackendProbeState.loginDisabled = true;
mockProbe.mockResolvedValueOnce({
status: "up",
loginDisabled: true,
loading: false,
});
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: {
enableLogin: false,
providerList: {},
},
});
render(
<TestWrapper>
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>,
);
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith("/", { replace: true });
});
});
it("should handle OAuth provider click", async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: {
enableLogin: true,
providerList: {
"/oauth2/authorization/github": "GitHub",
},
},
});
vi.mocked(springAuth.signInWithOAuth).mockResolvedValueOnce({
error: null,
});
render(
<TestWrapper>
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>,
);
await waitFor(() => {
const githubButton = screen.queryByText(/github/i);
if (githubButton) {
expect(githubButton).toBeInTheDocument();
}
});
// Since OAuth buttons might be dynamically rendered based on config,
// we just verify the mock is set up correctly
expect(springAuth.signInWithOAuth).toBeDefined();
});
it("should show email form by default when no SSO providers", async () => {
vi.mocked(apiClient.get).mockResolvedValueOnce({
data: {
enableLogin: true,
providerList: {}, // No providers
},
});
render(
<TestWrapper>
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>,
);
await waitFor(() => {
expect(document.getElementById("email")).toBeInTheDocument();
expect(document.getElementById("password")).toBeInTheDocument();
});
});
it("should disable submit button while signing in", async () => {
const user = userEvent.setup();
vi.mocked(springAuth.signInWithPassword).mockImplementationOnce(
() =>
new Promise((resolve) =>
setTimeout(
() =>
resolve({
user: null,
session: null,
error: { message: "Error" },
}),
100,
),
),
);
render(
<TestWrapper>
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>,
);
await waitFor(
() => {
const emailInput = document.getElementById("email");
const passwordInput = document.getElementById("password");
expect(emailInput).toBeTruthy();
expect(passwordInput).toBeTruthy();
},
{ timeout: 3000 },
);
const emailInput = document.getElementById("email") as HTMLInputElement;
const passwordInput = document.getElementById(
"password",
) as HTMLInputElement;
await user.type(emailInput, "[email protected]");
await user.type(passwordInput, "password123");
const submitButton = await waitFor(
() => {
const buttons = screen.queryAllByRole("button");
const submitBtn = buttons.find(
(btn) => btn.getAttribute("type") === "submit",
);
if (!submitBtn) {
throw new Error("Submit button not found");
}
return submitBtn;
},
{ timeout: 5000 },
);
await user.click(submitButton);
// Button should be disabled while signing in
expect(submitButton).toBeDisabled();
// Wait for completion
await waitFor(() => {
expect(submitButton).not.toBeDisabled();
});
});
it("should persist location.state.from.pathname before triggering SSO so the user returns to their original URL", async () => {
const user = userEvent.setup();
sessionStorage.clear();
vi.mocked(apiClient.get).mockResolvedValue({
data: {
enableLogin: true,
providerList: {
"/oauth2/authorization/authentik": "Authentik",
},
},
});
vi.mocked(springAuth.signInWithOAuth).mockResolvedValueOnce({
error: null,
});
render(
<TestWrapper>
<MemoryRouter
initialEntries={[
{
pathname: "/login",
state: { from: { pathname: "/share/abc123" } },
},
]}
>
<Login />
</MemoryRouter>
</TestWrapper>,
);
await waitFor(
() => {
expect(screen.getByText("Authentik")).toBeTruthy();
},
{ timeout: 3000 },
);
await user.click(screen.getByText("Authentik"));
await waitFor(() => {
expect(springAuth.signInWithOAuth).toHaveBeenCalled();
});
// Must be stashed before the cross-origin SSO redirect wipes location.state.
expect(sessionStorage.getItem("stirling_post_login_path")).toBe(
"/share/abc123",
);
});
});
@@ -0,0 +1,704 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
Navigate,
useLocation,
useNavigate,
useSearchParams,
} from "react-router-dom";
import { Text, Stack, Alert } from "@mantine/core";
import {
setPostLoginRedirectPath,
springAuth,
} from "@app/auth/springAuthClient";
import { useAuth } from "@app/auth/UseSession";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useTranslation } from "react-i18next";
import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
import AuthLayout from "@app/routes/authShared/AuthLayout";
import { useBackendProbe } from "@app/hooks/useBackendProbe";
import apiClient from "@app/services/apiClient";
import { BASE_PATH } from "@app/constants/app";
import { type OAuthProvider } from "@app/auth/oauthTypes";
import { updateSupportedLanguages } from "@app/i18n";
// Import login components
import LoginHeader from "@app/routes/login/LoginHeader";
import ErrorMessage from "@app/routes/login/ErrorMessage";
import EmailPasswordForm from "@app/routes/login/EmailPasswordForm";
import OAuthButtons, {
DEBUG_SHOW_ALL_PROVIDERS,
oauthProviderConfig,
} from "@app/routes/login/OAuthButtons";
import DividerWithText from "@app/components/shared/DividerWithText";
import LoggedInState from "@app/routes/login/LoggedInState";
export default function Login() {
const navigate = useNavigate();
const location = useLocation();
const [searchParams] = useSearchParams();
const { session, loading } = useAuth();
const resolveReturnPath = (): string | null => {
const fromState = (
location.state as { from?: { pathname?: string } } | null
)?.from?.pathname;
if (fromState) return fromState;
const fromQuery = searchParams.get("from");
if (!fromQuery) return null;
try {
return decodeURIComponent(fromQuery);
} catch {
return fromQuery;
}
};
const { refetch } = useAppConfig();
const { t } = useTranslation();
const [isSigningIn, setIsSigningIn] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [showEmailForm, setShowEmailForm] = useState(false);
const [email, setEmail] = useState(() => searchParams.get("email") ?? "");
const [password, setPassword] = useState("");
const [mfaCode, setMfaCode] = useState("");
const [requiresMfa, setRequiresMfa] = useState(false);
const [enabledProviders, setEnabledProviders] = useState<OAuthProvider[]>([]);
const [hasSSOProviders, setHasSSOProviders] = useState(false);
const [_enableLogin, setEnableLogin] = useState<boolean | null>(null);
const [loginMethod, setLoginMethod] = useState<string>("all");
const [ssoAutoLogin, setSsoAutoLogin] = useState(false);
const backendProbe = useBackendProbe();
const [isFirstTimeSetup, setIsFirstTimeSetup] = useState(false);
const [showDefaultCredentials, setShowDefaultCredentials] = useState(false);
const loginDisabled =
backendProbe.loginDisabled === true || _enableLogin === false;
const autoLoginAttempted = useRef(false);
const autoLoginErrorRecorded = useRef(false);
const isUserPassAllowed = loginMethod === "all" || loginMethod === "normal";
const isSsoOnlyMode = loginMethod !== "all" && loginMethod !== "normal";
const isSingleSsoOnly = !isUserPassAllowed && enabledProviders.length === 1;
const AUTO_LOGIN_ATTEMPTS_KEY = "stirling_sso_auto_login_attempts";
const AUTO_LOGIN_ERRORS_KEY = "stirling_sso_auto_login_errors";
const AUTO_LOGIN_LOGOUT_KEY = "stirling_sso_auto_login_logged_out";
const MAX_AUTO_LOGIN_ATTEMPTS = 2;
const MAX_AUTO_LOGIN_ERRORS = 1;
const readSessionNumber = (key: string) => {
if (typeof window === "undefined") {
return 0;
}
const raw = window.sessionStorage.getItem(key);
const value = Number(raw);
return Number.isFinite(value) ? value : 0;
};
const writeSessionNumber = (key: string, value: number) => {
if (typeof window === "undefined") {
return;
}
window.sessionStorage.setItem(key, String(value));
};
const hasLogoutBlock = () => {
if (typeof window === "undefined") {
return false;
}
return window.sessionStorage.getItem(AUTO_LOGIN_LOGOUT_KEY) === "1";
};
const clearLogoutBlock = () => {
if (typeof window === "undefined") {
return;
}
window.sessionStorage.removeItem(AUTO_LOGIN_LOGOUT_KEY);
};
const recordAutoLoginAttempt = () => {
const attempts = readSessionNumber(AUTO_LOGIN_ATTEMPTS_KEY);
writeSessionNumber(AUTO_LOGIN_ATTEMPTS_KEY, attempts + 1);
};
const recordAutoLoginError = () => {
const errors = readSessionNumber(AUTO_LOGIN_ERRORS_KEY);
writeSessionNumber(AUTO_LOGIN_ERRORS_KEY, errors + 1);
};
const errorFromState = (location.state as { error?: string } | null)?.error;
const errorFromQuery = useMemo(() => {
if (!searchParams) {
return null;
}
const errorParamKeys = [
"error",
"error_description",
"error_code",
"sso_error",
"oauth_error",
"saml_error",
"login_error",
];
for (const key of errorParamKeys) {
const value = searchParams.get(key);
if (value) {
return value;
}
}
for (const [key, value] of searchParams.entries()) {
if (key.toLowerCase().includes("error")) {
return value || "Single sign-on failed. Please try again.";
}
}
return null;
}, [searchParams]);
const hasSsoLoginError = Boolean(errorFromState || errorFromQuery);
// Periodically probe while backend isn't up so the screen can auto-advance when it comes online
useEffect(() => {
if (backendProbe.status === "up" || backendProbe.loginDisabled) {
return;
}
const tick = async () => {
const result = await backendProbe.probe();
if (result.status === "up") {
await refetch();
if (loginDisabled) {
navigate("/", { replace: true });
}
}
};
const intervalId = window.setInterval(() => {
void tick();
}, 5000);
return () => window.clearInterval(intervalId);
}, [
backendProbe.status,
backendProbe.loginDisabled,
backendProbe.probe,
refetch,
navigate,
loginDisabled,
]);
// Redirect immediately if user has valid session (JWT already validated by AuthProvider)
useEffect(() => {
if (!loading && session) {
const returnPath = resolveReturnPath();
console.debug("[Login] User already authenticated, redirecting to home", {
returnPath,
});
navigate(returnPath || "/", { replace: true });
}
}, [session, loading, navigate, location.state, searchParams]);
// If backend reports login is disabled, redirect to home (anonymous mode)
useEffect(() => {
if (backendProbe.loginDisabled) {
// Slight delay to allow state updates before redirecting
const id = setTimeout(() => navigate("/", { replace: true }), 0);
return () => clearTimeout(id);
}
}, [backendProbe.loginDisabled, navigate]);
useEffect(() => {
if (backendProbe.status === "up") {
void refetch();
}
}, [backendProbe.status, refetch]);
// Fetch enabled SSO providers and login config from backend
useEffect(() => {
const fetchProviders = async () => {
try {
const response = await apiClient.get(
"/api/v1/proprietary/ui-data/login",
);
const data = response.data;
// Check if login is disabled - if so, redirect to home
if (data.enableLogin === false) {
console.debug("[Login] Login disabled, redirecting to home");
navigate("/");
return;
}
setEnableLogin(data.enableLogin ?? true);
setSsoAutoLogin(Boolean(data.ssoAutoLogin));
// Set first-time setup flags
setIsFirstTimeSetup(data.firstTimeSetup ?? false);
setShowDefaultCredentials(data.showDefaultCredentials ?? false);
// Apply language configuration from server
if (data.languages || data.defaultLocale) {
updateSupportedLanguages(data.languages, data.defaultLocale);
}
// Use the full paths from providerList as provider identifiers
// The backend provides paths like "/oauth2/authorization/google" or "/saml2/authenticate/stirling"
// We'll use these full paths so the auth client knows where to redirect
const providerPaths = Object.keys(data.providerList || {});
setEnabledProviders(providerPaths);
setLoginMethod(data.loginMethod || "all");
} catch (err) {
console.error("[Login] Failed to fetch enabled providers:", err);
// Set default values on error to ensure UI remains functional
// Login method defaults to 'all' to show both SSO and email/password options
setEnableLogin(true);
setLoginMethod("all");
setEnabledProviders([]);
}
};
if (backendProbe.status === "up" || backendProbe.loginDisabled) {
fetchProviders();
}
}, [navigate, backendProbe.status, backendProbe.loginDisabled]);
// Update hasSSOProviders and showEmailForm when enabledProviders or loginMethod changes
useEffect(() => {
// In debug mode, check if any providers exist in the config
const hasProviders = DEBUG_SHOW_ALL_PROVIDERS
? Object.keys(oauthProviderConfig).length > 0
: enabledProviders.length > 0;
setHasSSOProviders(hasProviders);
// Check if username/password authentication is allowed
const isUserPassAllowed = loginMethod === "all" || loginMethod === "normal";
// Show email form if no SSO providers exist AND username/password is allowed
if (!hasProviders && isUserPassAllowed) {
setShowEmailForm(true);
} else if (!isUserPassAllowed) {
// Hide email form if username/password auth is not allowed
setShowEmailForm(false);
}
}, [enabledProviders, loginMethod]);
const signInWithProvider = async (provider: OAuthProvider) => {
try {
setIsSigningIn(true);
setError(null);
clearLogoutBlock();
// Don't overwrite a path already stashed by httpErrorHandler on a prior 401.
const returnPath = resolveReturnPath();
if (returnPath) {
setPostLoginRedirectPath(returnPath);
}
console.log(`[Login] Signing in with provider: ${provider}`);
// Redirect to Spring OAuth2 endpoint using the actual provider ID from backend
// The backend returns the correct registration ID (e.g., 'authentik', 'oidc', 'keycloak')
const { error } = await springAuth.signInWithOAuth({
provider: provider,
options: { redirectTo: `${BASE_PATH}/auth/callback` },
});
if (error) {
console.error(`[Login] ${provider} error:`, error);
setError(
t("login.failedToSignIn", { provider, message: error.message }) ||
`Failed to sign in with ${provider}`,
);
}
} catch (err) {
console.error(`[Login] Unexpected error:`, err);
setError(
t("login.unexpectedError", {
message: err instanceof Error ? err.message : "Unknown error",
}) || "An unexpected error occurred",
);
} finally {
setIsSigningIn(false);
}
};
// Auto-login to SSO when enabled and only one SSO option exists
useEffect(() => {
if (autoLoginAttempted.current) {
return;
}
const attempts = readSessionNumber(AUTO_LOGIN_ATTEMPTS_KEY);
const errors = readSessionNumber(AUTO_LOGIN_ERRORS_KEY);
const blockedByErrors = errors >= MAX_AUTO_LOGIN_ERRORS;
const blockedByAttempts = attempts >= MAX_AUTO_LOGIN_ATTEMPTS;
const blockedByLogout = hasLogoutBlock();
if (
!ssoAutoLogin ||
loginDisabled ||
loading ||
session ||
backendProbe.status !== "up"
) {
return;
}
if (
hasSsoLoginError ||
blockedByErrors ||
blockedByAttempts ||
blockedByLogout
) {
return;
}
if (isUserPassAllowed) {
return;
}
if (enabledProviders.length !== 1) {
return;
}
autoLoginAttempted.current = true;
recordAutoLoginAttempt();
void signInWithProvider(enabledProviders[0]);
}, [
ssoAutoLogin,
loginDisabled,
loading,
session,
backendProbe.status,
loginMethod,
enabledProviders,
signInWithProvider,
hasSsoLoginError,
]);
// Handle query params (email prefill, success messages, and session expiry)
useEffect(() => {
try {
const emailFromQuery = searchParams.get("email");
if (emailFromQuery) {
setEmail(emailFromQuery);
}
// Check if session expired (401 redirect)
const expired = searchParams.get("expired");
if (expired === "true") {
setError(
t(
"login.sessionExpired",
"Your session has expired. Please sign in again.",
),
);
}
const messageType = searchParams.get("messageType");
if (messageType) {
switch (messageType) {
case "accountCreated":
setSuccessMessage(
t(
"login.accountCreatedSuccess",
"Account created successfully! You can now sign in.",
),
);
break;
case "passwordChanged":
setSuccessMessage(
t(
"login.passwordChangedSuccess",
"Password changed successfully! Please sign in with your new password.",
),
);
break;
case "credsUpdated":
setSuccessMessage(
t(
"login.credentialsUpdated",
"Your credentials have been updated. Please sign in again.",
),
);
break;
}
}
if (errorFromState) {
setError(errorFromState);
} else if (errorFromQuery) {
setError(errorFromQuery);
}
if (hasSsoLoginError && !autoLoginErrorRecorded.current) {
recordAutoLoginError();
autoLoginErrorRecorded.current = true;
}
} catch (_) {
// ignore
}
}, [searchParams, t, errorFromState, errorFromQuery, hasSsoLoginError]);
const baseUrl = window.location.origin + BASE_PATH;
// Set document meta
useDocumentMeta({
title: `${t("login.title", "Sign in")} - Stirling PDF`,
description: t(
"app.description",
"The Free Adobe Acrobat alternative (10M+ Downloads)",
),
ogTitle: `${t("login.title", "Sign in")} - Stirling PDF`,
ogDescription: t(
"app.description",
"The Free Adobe Acrobat alternative (10M+ Downloads)",
),
ogImage: `${baseUrl}/og_images/home.png`,
ogUrl: `${window.location.origin}${window.location.pathname}`,
});
// If login is disabled, short-circuit to home (avoids rendering the form after retry)
if (loginDisabled) {
return <Navigate to="/" replace />;
}
// Show logged in state if authenticated
if (session && !loading) {
return <LoggedInState />;
}
// If backend isn't ready yet, show a lightweight status screen instead of the form
if (backendProbe.status !== "up" && !loginDisabled) {
const backendTitle = t("backendStartup.notFoundTitle", "Backend not found");
const handleRetry = async () => {
const result = await backendProbe.probe();
if (result.status === "up") {
await refetch();
navigate("/", { replace: true });
}
};
return (
<AuthLayout>
<LoginHeader title={backendTitle} />
<div
className="auth-section"
style={{
padding: "1.5rem",
marginTop: "1rem",
borderRadius: "0.75rem",
backgroundColor: "rgba(37, 99, 235, 0.08)",
border: "1px solid rgba(37, 99, 235, 0.2)",
}}
>
<p
style={{ margin: "0 0 0.75rem 0", color: "rgba(15, 23, 42, 0.8)" }}
>
{t("backendStartup.unreachable")}
</p>
<button
type="button"
onClick={handleRetry}
className="auth-cta-button px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold mt-5 border-0 cursor-pointer"
style={{ width: "fit-content" }}
>
{t("backendStartup.retry", "Retry")}
</button>
</div>
</AuthLayout>
);
}
const signInWithEmail = async () => {
if (!email || !password) {
setError(
t("login.pleaseEnterBoth") || "Please enter both email and password",
);
return;
}
if (requiresMfa && !mfaCode.trim()) {
setError(t("login.mfaRequired", "Two-factor code required"));
return;
}
try {
setIsSigningIn(true);
setError(null);
clearLogoutBlock();
console.log("[Login] Signing in with email:", email);
const { user, session, error } = await springAuth.signInWithPassword({
email: email.trim(),
password: password,
mfaCode: requiresMfa ? mfaCode.trim() : undefined,
});
if (error) {
console.error("[Login] Email sign in error:", error);
setError(error.message);
if (error.mfaRequired || error.code === "invalid_mfa_code") {
setRequiresMfa(true);
}
} else if (user && session) {
console.log("[Login] Email sign in successful");
clearLogoutBlock();
setRequiresMfa(false);
setMfaCode("");
// Auth state will update automatically and Landing will redirect to home
// No need to navigate manually here
}
} catch (err) {
console.error("[Login] Unexpected error:", err);
setError(
t("login.unexpectedError", {
message: err instanceof Error ? err.message : "Unknown error",
}) || "An unexpected error occurred",
);
} finally {
setIsSigningIn(false);
}
};
// Forgot password handler (currently unused, reserved for future implementation)
// const handleForgotPassword = () => {
// navigate('/auth/reset');
// };
return (
<AuthLayout>
<LoginHeader
title={isSingleSsoOnly ? "" : t("login.login") || "Sign in"}
centerOnly={isSingleSsoOnly}
/>
{/* Success message */}
{successMessage && (
<div
style={{
padding: "1rem",
marginBottom: "1rem",
backgroundColor: "rgba(34, 197, 94, 0.1)",
border: "1px solid rgba(34, 197, 94, 0.3)",
borderRadius: "0.5rem",
color: "#16a34a",
}}
>
<p style={{ margin: 0, fontSize: "0.875rem", textAlign: "center" }}>
{successMessage}
</p>
</div>
)}
<ErrorMessage error={error} />
{/* OAuth first */}
<OAuthButtons
onProviderClick={signInWithProvider}
isSubmitting={isSigningIn}
layout="vertical"
enabledProviders={enabledProviders}
ctaPrefix={
isSsoOnlyMode ? t("login.signInWith", "Sign in with") : undefined
}
styleVariant="light"
useNewStyle={isSsoOnlyMode}
/>
{/* Divider between OAuth and Email - only show if SSO is available and username/password is allowed */}
{hasSSOProviders && isUserPassAllowed && (
<DividerWithText
text={t("signup.or", "or")}
respondsToDarkMode={false}
opacity={0.4}
/>
)}
{/* Sign in with email button - only show if SSO providers exist and username/password is allowed */}
{hasSSOProviders && !showEmailForm && isUserPassAllowed && (
<div className="auth-section">
<button
type="button"
onClick={() => setShowEmailForm(true)}
disabled={isSigningIn}
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold mb-2 cursor-pointer border-0 disabled:opacity-50 disabled:cursor-not-allowed auth-cta-button"
>
{t("login.useEmailInstead", "Login with email")}
</button>
</div>
)}
{/* Email form - show by default if no SSO, or when button clicked, but ONLY if username/password is allowed */}
{showEmailForm && isUserPassAllowed && (
<div style={{ marginTop: hasSSOProviders ? "1rem" : "0" }}>
<EmailPasswordForm
email={email}
password={password}
setEmail={setEmail}
setPassword={setPassword}
mfaCode={mfaCode}
setMfaCode={setMfaCode}
showMfaField={requiresMfa || Boolean(mfaCode)}
requiresMfa={requiresMfa}
onSubmit={signInWithEmail}
isSubmitting={isSigningIn}
submitButtonText={
isSigningIn
? t("login.loggingIn") || "Signing in..."
: t("login.login") || "Sign in"
}
/>
</div>
)}
{/* Help section - only show on first-time setup with default credentials and username/password auth allowed */}
{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-always-dark)" }}
>
{t("login.defaultCredentials", "Default Login Credentials")}
</Text>
<Text
size="sm"
ta="center"
style={{ color: "var(--text-always-dark)" }}
>
<Text
component="span"
fw={600}
style={{ color: "var(--text-always-dark)" }}
>
{t("login.username", "Username")}:
</Text>{" "}
admin
</Text>
<Text
size="sm"
ta="center"
style={{ color: "var(--text-always-dark)" }}
>
<Text
component="span"
fw={600}
style={{ color: "var(--text-always-dark)" }}
>
{t("login.password", "Password")}:
</Text>{" "}
stirling
</Text>
<Text
size="xs"
ta="center"
mt="xs"
style={{ color: "var(--text-always-dark-muted)" }}
>
{t(
"login.changePasswordWarning",
"Please change your password after logging in for the first time",
)}
</Text>
</Stack>
</Alert>
)}
</AuthLayout>
);
}
@@ -0,0 +1,280 @@
import { useEffect, useMemo, useRef } from "react";
import { isAxiosError } from "axios";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import apiClient from "@app/services/apiClient";
import { useAuth } from "@app/auth/UseSession";
import { useFileActions } from "@app/contexts/FileContext";
import { useNavigationActions } from "@app/contexts/NavigationContext";
import { alert } from "@app/components/toast";
import type { StirlingFile } from "@app/types/fileContext";
import type { FileId } from "@app/types/file";
import { fileStorage } from "@app/services/fileStorage";
import {
getShareBundleEntryRootId,
isZipBundle,
loadShareBundleEntries,
parseContentDispositionFilename,
} from "@app/services/shareBundleUtils";
interface ShareLinkLoaderProps {
token: string;
}
interface ShareLinkMetadata {
shareToken?: string;
fileId?: number;
fileName?: string;
owner?: string | null;
ownedByCurrentUser?: boolean;
accessRole?: string | null;
expiresAt?: string;
}
export default function ShareLinkLoader({ token }: ShareLinkLoaderProps) {
const { actions } = useFileActions();
const { actions: navActions } = useNavigationActions();
const { user, loading: authLoading } = useAuth();
const navigate = useNavigate();
const { t } = useTranslation();
const handledTokenRef = useRef<string | null>(null);
const normalizedToken = useMemo(() => token.trim(), [token]);
const isAuthenticated = Boolean(user);
useEffect(() => {
if (!normalizedToken) {
return;
}
if (handledTokenRef.current === normalizedToken) {
return;
}
handledTokenRef.current = normalizedToken;
const abortController = new AbortController();
const { signal } = abortController;
const loadSharedFile = async () => {
try {
let shareMetadata: ShareLinkMetadata | null = null;
try {
const metadataResponse = await apiClient.get<ShareLinkMetadata>(
`/api/v1/storage/share-links/${normalizedToken}/metadata`,
{ suppressErrorToast: true, skipAuthRedirect: true, signal },
);
shareMetadata = metadataResponse.data;
} catch {
shareMetadata = null;
}
const response = await apiClient.get(
`/api/v1/storage/share-links/${normalizedToken}`,
{
responseType: "blob",
suppressErrorToast: true,
skipAuthRedirect: true,
signal,
},
);
if (signal.aborted) return;
const contentType =
(response.headers &&
(response.headers["content-type"] ||
response.headers["Content-Type"])) ||
"";
const disposition =
(response.headers &&
(response.headers["content-disposition"] ||
response.headers["Content-Disposition"])) ||
"";
const filename =
parseContentDispositionFilename(disposition) || "shared-file";
const blob = response.data as Blob;
const contentTypeValue = contentType || blob.type;
if (isZipBundle(contentTypeValue, filename)) {
const bundle = await loadShareBundleEntries(blob);
if (bundle) {
const { manifest, rootOrder, sortedEntries, files } = bundle;
const stirlingFiles = await actions.addFilesWithOptions(files, {
selectFiles: false,
autoUnzip: false,
skipAutoUnzip: false,
allowDuplicates: true,
});
if (signal.aborted) return;
const idMap = new Map<string, FileId>();
for (let i = 0; i < stirlingFiles.length; i += 1) {
idMap.set(sortedEntries[i].logicalId, stirlingFiles[i].fileId);
}
const rootIdMap = new Map<string, FileId>();
for (const rootLogicalId of rootOrder) {
const mappedId = idMap.get(rootLogicalId);
if (mappedId) {
rootIdMap.set(rootLogicalId, mappedId);
}
}
for (let i = 0; i < sortedEntries.length; i += 1) {
const entry = sortedEntries[i];
const newId = idMap.get(entry.logicalId);
if (!newId) continue;
const parentId = entry.parentLogicalId
? idMap.get(entry.parentLogicalId)
: undefined;
const rootId =
rootIdMap.get(getShareBundleEntryRootId(manifest, entry)) ||
idMap.get(manifest.rootLogicalId) ||
newId;
const sharedUpdates = {
remoteStorageId: shareMetadata?.fileId,
remoteOwnerUsername: shareMetadata?.owner ?? undefined,
remoteOwnedByCurrentUser: false,
remoteAccessRole: shareMetadata?.accessRole ?? undefined,
remoteSharedViaLink: true,
remoteHasShareLinks: false,
remoteShareToken: shareMetadata?.shareToken || normalizedToken,
};
actions.updateStirlingFileStub(newId, {
versionNumber: entry.versionNumber,
originalFileId: rootId,
parentFileId: parentId,
toolHistory: entry.toolHistory,
isLeaf: entry.isLeaf,
...sharedUpdates,
});
await fileStorage.updateFileMetadata(newId, {
versionNumber: entry.versionNumber,
originalFileId: rootId,
parentFileId: parentId,
toolHistory: entry.toolHistory,
isLeaf: entry.isLeaf,
...sharedUpdates,
});
}
const selectedIds: FileId[] = [];
for (const rootId of rootOrder) {
const rootEntries = sortedEntries.filter(
(entry) =>
getShareBundleEntryRootId(manifest, entry) === rootId,
);
const latestEntry = rootEntries[rootEntries.length - 1];
if (!latestEntry) {
continue;
}
const latestId = idMap.get(latestEntry.logicalId);
if (latestId) {
selectedIds.push(latestId);
}
}
if (selectedIds.length > 0) {
actions.setSelectedFiles(selectedIds);
}
navActions.setWorkbench("viewer");
navigate("/", { replace: true });
return;
}
}
const file = new File([blob], filename, {
type: contentTypeValue || blob.type,
});
const stirlingFiles = await actions.addFilesWithOptions([file], {
selectFiles: true,
autoUnzip: false,
skipAutoUnzip: false,
});
if (signal.aborted) return;
if (stirlingFiles.length > 0) {
const ids = stirlingFiles.map(
(stirlingFile: StirlingFile) => stirlingFile.fileId,
);
actions.setSelectedFiles(ids);
const sharedUpdates = {
remoteStorageId: shareMetadata?.fileId,
remoteOwnerUsername: shareMetadata?.owner ?? undefined,
remoteOwnedByCurrentUser: false,
remoteAccessRole: shareMetadata?.accessRole ?? undefined,
remoteSharedViaLink: true,
remoteHasShareLinks: false,
remoteShareToken: shareMetadata?.shareToken || normalizedToken,
};
for (const fileId of ids) {
actions.updateStirlingFileStub(fileId, sharedUpdates);
await fileStorage.updateFileMetadata(fileId, sharedUpdates);
}
}
navActions.setWorkbench("viewer");
navigate("/", { replace: true });
} catch (error: unknown) {
if (signal.aborted) return;
const status = isAxiosError(error) ? error.response?.status : undefined;
if (status === 401 || status === 403) {
if (!isAuthenticated && !authLoading) {
alert({
alertType: "warning",
title: t(
"storageShare.requiresLogin",
"This shared file requires login.",
),
expandable: false,
durationMs: 4000,
});
navigate("/login", {
replace: true,
state: { from: { pathname: `/share/${normalizedToken}` } },
});
return;
}
alert({
alertType: "warning",
title: t(
"storageShare.accessDenied",
"You do not have access to this shared file. Ask the owner to share it with you.",
),
expandable: false,
durationMs: 4500,
});
navigate("/", { replace: true });
} else if (status === 404 || status === 410) {
alert({
alertType: "error",
title: t("storageShare.expiredTitle", "Link expired"),
expandable: false,
durationMs: 4000,
});
navigate("/", { replace: true });
} else {
alert({
alertType: "error",
title: t("storageShare.loadFailed", "Unable to open shared file."),
expandable: false,
durationMs: 4000,
});
}
}
};
loadSharedFile();
return () => {
abortController.abort();
};
}, [
normalizedToken,
actions,
navActions,
navigate,
t,
isAuthenticated,
authLoading,
]);
return null;
}
@@ -0,0 +1,325 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { isAxiosError } from "axios";
import { useNavigate, useParams } from "react-router-dom";
import {
Alert,
Badge,
Button,
Group,
Loader,
Paper,
Stack,
Text,
Title,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download";
import LoginIcon from "@mui/icons-material/Login";
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import { useFileActions } from "@app/contexts/FileContext";
import { useNavigationActions } from "@app/contexts/NavigationContext";
import { alert } from "@app/components/toast";
import {
downloadShareLink,
fetchShareLinkMetadata,
importShareLinkToWorkbench,
ShareLinkMetadata,
} from "@app/services/shareLinkImport";
type ShareLinkStatus =
| "loading"
| "ready"
| "login"
| "forbidden"
| "notfound"
| "error";
export default function ShareLinkPage() {
const { token } = useParams<{ token: string }>();
const { actions } = useFileActions();
const { actions: navActions } = useNavigationActions();
const navigate = useNavigate();
const { t } = useTranslation();
const [status, setStatus] = useState<ShareLinkStatus>("loading");
const [metadata, setMetadata] = useState<ShareLinkMetadata | null>(null);
const [isWorking, setIsWorking] = useState(false);
const normalizedToken = useMemo(() => (token || "").trim(), [token]);
const shareRole = (metadata?.accessRole ?? "viewer").toLowerCase();
const hasReadAccess =
shareRole === "editor" ||
shareRole === "commenter" ||
shareRole === "viewer";
const canDownload = hasReadAccess;
const canOpen = hasReadAccess;
const loadMetadata = useCallback(async () => {
if (!normalizedToken) {
setStatus("notfound");
return;
}
setStatus("loading");
try {
const data = await fetchShareLinkMetadata(normalizedToken);
setMetadata(data);
setStatus("ready");
} catch (error: unknown) {
const statusCode = isAxiosError(error)
? error.response?.status
: undefined;
if (statusCode === 401) {
setStatus("login");
} else if (statusCode === 403) {
setStatus("forbidden");
} else if (statusCode === 404 || statusCode === 410) {
setStatus("notfound");
} else {
setStatus("error");
}
}
}, [normalizedToken]);
useEffect(() => {
void loadMetadata();
}, [loadMetadata]);
const handleLogin = useCallback(() => {
navigate("/login", {
replace: true,
state: { from: { pathname: `/share/${normalizedToken}` } },
});
}, [navigate, normalizedToken]);
const handleDownload = useCallback(async () => {
if (!normalizedToken || !canDownload) return;
setIsWorking(true);
try {
const { blob, filename } = await downloadShareLink(normalizedToken);
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename || "shared-file";
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
} catch (error: unknown) {
const statusCode = isAxiosError(error)
? error.response?.status
: undefined;
if (statusCode === 401) {
setStatus("login");
} else if (statusCode === 403) {
setStatus("forbidden");
} else if (statusCode === 404 || statusCode === 410) {
setStatus("notfound");
} else {
alert({
alertType: "error",
title: t(
"storageShare.downloadFailed",
"Unable to download this file.",
),
expandable: false,
durationMs: 3500,
});
}
} finally {
setIsWorking(false);
}
}, [canDownload, normalizedToken, t]);
const handleOpen = useCallback(async () => {
if (!normalizedToken || !canOpen) return;
setIsWorking(true);
try {
const selectedIds = await importShareLinkToWorkbench(
normalizedToken,
actions,
metadata,
);
if (selectedIds.length > 0) {
actions.setSelectedFiles(selectedIds);
}
navActions.setWorkbench("viewer");
navigate("/", { replace: true });
} catch (error: unknown) {
const statusCode = isAxiosError(error)
? error.response?.status
: undefined;
if (statusCode === 401) {
setStatus("login");
} else if (statusCode === 403) {
setStatus("forbidden");
} else if (statusCode === 404 || statusCode === 410) {
setStatus("notfound");
} else {
alert({
alertType: "error",
title: t("storageShare.loadFailed", "Unable to open shared file."),
expandable: false,
durationMs: 3500,
});
}
} finally {
setIsWorking(false);
}
}, [actions, canOpen, metadata, navActions, navigate, normalizedToken, t]);
const title =
metadata?.fileName || t("storageShare.titleDefault", "Shared file");
const ownerLabel =
metadata?.owner || t("storageShare.ownerUnknown", "Unknown");
return (
<div style={{ minHeight: "100%", padding: "2.5rem 1.5rem" }}>
<Paper
radius="lg"
p="xl"
withBorder
shadow="sm"
style={{ maxWidth: 720, margin: "0 auto" }}
>
<Stack gap="md">
<Group justify="space-between" align="center">
<Title order={3}>
{t("storageShare.shareHeading", "Shared file")}
</Title>
<Group gap="xs">
{metadata?.accessRole && (
<Badge variant="light" color="gray">
{shareRole === "editor"
? t("storageShare.roleEditor", "Editor")
: shareRole === "commenter"
? t("storageShare.roleCommenter", "Commenter")
: t("storageShare.roleViewer", "Viewer")}
</Badge>
)}
</Group>
</Group>
{status === "loading" && (
<Group justify="center" py="xl">
<Loader size="sm" />
<Text size="sm" c="dimmed">
{t("storageShare.loading", "Loading share link...")}
</Text>
</Group>
)}
{status === "ready" && (
<>
<Text size="lg" fw={600}>
{title}
</Text>
<Text size="sm" c="dimmed">
{t("storageShare.ownerLabel", "Owner")}: {ownerLabel}
</Text>
{metadata?.createdAt && (
<Text size="sm" c="dimmed">
{t("storageShare.createdAt", "Created")}{" "}
{new Date(metadata.createdAt).toLocaleString()}
</Text>
)}
<Group justify="flex-start" gap="sm" pt="sm">
<Button
leftSection={<OpenInNewIcon style={{ fontSize: 18 }} />}
onClick={handleOpen}
loading={isWorking}
disabled={!canOpen}
>
{t("storageShare.openInApp", "Open in Stirling PDF")}
</Button>
<Button
variant="light"
leftSection={<DownloadIcon style={{ fontSize: 18 }} />}
onClick={handleDownload}
loading={isWorking}
disabled={!canDownload}
>
{t("storageShare.download", "Download")}
</Button>
</Group>
{!canDownload && (
<Alert
mt="md"
color="yellow"
title={t("storageShare.accessLimitedTitle", "Limited access")}
>
{shareRole === "commenter"
? t(
"storageShare.accessLimitedCommenter",
"Comment access is coming soon. Ask the owner for editor access if you need to download.",
)
: t(
"storageShare.accessLimitedViewer",
"This link is view-only. Ask the owner for editor access if you need to download.",
)}
</Alert>
)}
</>
)}
{status === "login" && (
<Alert
color="blue"
title={t("storageShare.loginRequired", "Login required")}
>
<Text size="sm">
{t(
"storageShare.loginPrompt",
"Sign in to access this shared file.",
)}
</Text>
<Group mt="md">
<Button
leftSection={<LoginIcon style={{ fontSize: 18 }} />}
onClick={handleLogin}
>
{t("storageShare.goToLogin", "Go to login")}
</Button>
</Group>
</Alert>
)}
{status === "forbidden" && (
<Alert
color="red"
title={t("storageShare.accessDeniedTitle", "No access")}
>
{t(
"storageShare.accessDeniedBody",
"You do not have access to this file. Ask the owner to share it with you.",
)}
</Alert>
)}
{status === "notfound" && (
<Alert
color="red"
title={t("storageShare.expiredTitle", "Link expired")}
>
{t(
"storageShare.expiredBody",
"This share link is invalid or has expired.",
)}
</Alert>
)}
{status === "error" && (
<Alert
color="red"
title={t(
"storageShare.loadFailed",
"Unable to open shared file.",
)}
>
{t("storageShare.tryAgain", "Please try again later.")}
</Alert>
)}
</Stack>
</Paper>
</div>
);
}
@@ -0,0 +1,135 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
import { useAuth } from "@app/auth/UseSession";
import AuthLayout from "@app/routes/authShared/AuthLayout";
import "@app/routes/authShared/auth.css";
import { BASE_PATH } from "@app/constants/app";
// Import signup components
import LoginHeader from "@app/routes/login/LoginHeader";
import ErrorMessage from "@app/routes/login/ErrorMessage";
import DividerWithText from "@app/components/shared/DividerWithText";
import SignupForm from "@app/routes/signup/SignupForm";
import {
useSignupFormValidation,
SignupFieldErrors,
} from "@app/routes/signup/SignupFormValidation";
import { useAuthService } from "@app/routes/signup/AuthService";
export default function Signup() {
const navigate = useNavigate();
const { t } = useTranslation();
const { session, loading } = useAuth();
const [isSigningUp, setIsSigningUp] = useState(false);
const [error, setError] = useState<string | null>(null);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [fieldErrors, setFieldErrors] = useState<SignupFieldErrors>({});
// Redirect immediately if user has valid session (JWT already validated by AuthProvider)
useEffect(() => {
if (!loading && session) {
console.debug("[Signup] User already authenticated, redirecting to home");
navigate("/", { replace: true });
}
}, [session, loading, navigate]);
const baseUrl = window.location.origin + BASE_PATH;
// Set document meta
useDocumentMeta({
title: `${t("signup.title", "Create an account")} - Stirling PDF`,
description: t(
"app.description",
"The Free Adobe Acrobat alternative (10M+ Downloads)",
),
ogTitle: `${t("signup.title", "Create an account")} - Stirling PDF`,
ogDescription: t(
"app.description",
"The Free Adobe Acrobat alternative (10M+ Downloads)",
),
ogImage: `${baseUrl}/og_images/home.png`,
ogUrl: `${window.location.origin}${window.location.pathname}`,
});
const { validateSignupForm } = useSignupFormValidation();
const { signUp } = useAuthService();
const handleSignUp = async () => {
const validation = validateSignupForm(email, password, confirmPassword);
if (!validation.isValid) {
setError(validation.error);
setFieldErrors(validation.fieldErrors || {});
return;
}
try {
setIsSigningUp(true);
setError(null);
setFieldErrors({});
const result = await signUp(email, password, "");
if (result.user) {
// Show success message and redirect to login
setError(null);
setTimeout(() => navigate("/login"), 2000);
}
} catch (err) {
console.error("[Signup] Unexpected error:", err);
setError(
err instanceof Error
? err.message
: t("signup.unexpectedError", { message: "Unknown error" }),
);
} finally {
setIsSigningUp(false);
}
};
return (
<AuthLayout>
<LoginHeader
title={t("signup.title", "Create an account")}
subtitle={t("signup.subtitle", "Join Stirling PDF")}
/>
<ErrorMessage error={error} />
{/* Signup form - shown immediately */}
<SignupForm
email={email}
password={password}
confirmPassword={confirmPassword}
setEmail={setEmail}
setPassword={setPassword}
setConfirmPassword={setConfirmPassword}
onSubmit={handleSignUp}
isSubmitting={isSigningUp}
fieldErrors={fieldErrors}
showName={false}
showTerms={false}
/>
<DividerWithText
text={t("signup.or", "or")}
respondsToDarkMode={false}
opacity={0.4}
/>
{/* Bottom row - centered */}
<div style={{ textAlign: "center", margin: "0.5rem 0 0.25rem" }}>
<button
type="button"
onClick={() => navigate("/login")}
className="auth-link-black"
>
{t("login.logIn", "Log In")}
</button>
</div>
</AuthLayout>
);
}
@@ -0,0 +1,49 @@
.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;
}
.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;
}
.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 {
display: none; /* WebKit browsers (Chrome, Safari, Edge) */
}
.authContent {
max-width: 26.25rem; /* 420px */
width: 100%;
}
@@ -0,0 +1,95 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import LoginRightCarousel from "@app/components/shared/LoginRightCarousel";
import buildLoginSlides from "@app/components/shared/loginSlides";
import styles from "@app/routes/authShared/AuthLayout.module.css";
import { useLogoVariant } from "@app/hooks/useLogoVariant";
import Footer from "@app/components/shared/Footer";
interface AuthLayoutProps {
children: React.ReactNode;
}
export default function AuthLayout({ children }: AuthLayoutProps) {
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],
);
// Force light mode on auth pages
useEffect(() => {
const htmlElement = document.documentElement;
const previousColorScheme = htmlElement.getAttribute(
"data-mantine-color-scheme",
);
// Set light mode
htmlElement.setAttribute("data-mantine-color-scheme", "light");
// Cleanup: restore previous theme when leaving auth pages
return () => {
if (previousColorScheme) {
htmlElement.setAttribute(
"data-mantine-color-scheme",
previousColorScheme,
);
}
};
}, []);
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
style={{
position: "fixed",
bottom: 0,
left: 0,
right: 0,
width: "100%",
zIndex: 10,
}}
>
<Footer forceLightMode={true} />
</div>
</div>
);
}
@@ -0,0 +1,617 @@
.auth-fields {
display: flex;
flex-direction: column;
gap: 0.5rem; /* 8px */
margin-bottom: 0.75rem; /* 12px */
}
.auth-field {
display: flex;
flex-direction: column;
gap: 0.25rem; /* 4px */
}
.auth-label {
font-size: 0.875rem; /* 14px */
color: var(--auth-label-text-light-only);
font-weight: 500;
}
.auth-input {
width: 100%;
padding: 0.625rem 0.75rem; /* 10px 12px */
border: 1px solid var(--auth-input-border-light-only);
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);
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);
}
.auth-button {
width: 100%;
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);
font-size: 0.875rem; /* 14px */
font-weight: 600;
margin-bottom: 0.75rem; /* 12px */
cursor: pointer;
}
.auth-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.auth-toggle-wrapper {
text-align: center;
margin-bottom: 0.625rem; /* 10px */
}
.auth-toggle-link {
background: transparent;
border: 0;
color: var(--auth-label-text-light-only);
font-size: 0.875rem; /* 14px */
text-decoration: underline;
cursor: pointer;
}
.auth-toggle-link:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.auth-magic-row {
display: flex;
gap: 0.5rem; /* 8px */
margin-bottom: 0.75rem; /* 12px */
}
.auth-magic-row .auth-input {
flex: 1 1 auto;
}
.auth-magic-button {
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);
font-size: 0.875rem; /* 14px */
font-weight: 600;
white-space: nowrap;
cursor: pointer;
}
.auth-magic-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.auth-terms {
display: flex;
align-items: center;
gap: 0.5rem; /* 8px */
margin-bottom: 0.5rem; /* 8px */
}
.auth-checkbox {
width: 1rem; /* 16px */
height: 1rem; /* 16px */
accent-color: #af3434;
}
.auth-terms-label {
font-size: 0.75rem; /* 12px */
color: var(--auth-label-text-light-only);
}
.auth-terms-label a {
color: inherit;
text-decoration: underline;
}
.auth-confirm {
overflow: hidden;
transition:
max-height 240ms ease,
opacity 200ms ease;
}
/* OAuth Button Styles */
.oauth-container-icons {
display: flex;
margin-bottom: 0.625rem; /* 10px */
justify-content: space-between;
}
.oauth-container-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.75rem; /* 12px */
margin-bottom: 0.625rem; /* 10px */
}
.oauth-container-vertical {
display: flex;
flex-direction: column;
gap: 0.875rem; /* 14px */
align-items: stretch;
}
.oauth-container-vertical.oauth-container-single {
align-items: center;
}
.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);
cursor: pointer;
box-shadow: 0 0.125rem 0.375rem rgba(0, 0, 0, 0.04); /* 0 2px 6px */
display: flex;
align-items: center;
justify-content: center;
}
.oauth-button-icon:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.oauth-button-grid {
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);
cursor: pointer;
box-shadow: 0 0.125rem 0.375rem rgba(0, 0, 0, 0.04); /* 0 2px 6px */
display: flex;
align-items: center;
justify-content: center;
}
.oauth-button-grid:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.oauth-button-vertical {
width: 100%;
min-height: 3.5rem; /* 56px */
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.875rem 1.5rem; /* 14px 24px */
border: 1px solid var(--auth-input-border-light-only);
border-radius: 999px;
background-color: var(--auth-card-bg-light-only);
font-size: 1rem; /* 16px */
font-weight: 600;
color: var(--auth-text-primary-light-only);
cursor: pointer;
gap: 1rem; /* 16px */
font-family: inherit;
box-shadow: 0 0.125rem 0.5rem rgba(0, 0, 0, 0.08);
transition:
background-color 0.2s ease,
box-shadow 0.2s ease,
transform 0.2s ease,
border-color 0.2s ease;
}
.oauth-button-vertical:hover:not(:disabled) {
background-color: var(--hover-bg);
box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.12);
transform: translateY(-1px);
border-color: var(--border-default);
}
.oauth-button-vertical-tinted {
background: linear-gradient(
90deg,
color-mix(in srgb, var(--oauth-accent) 65%, #0f172a) 0 6px,
#0f172a 6px 100%
);
}
.oauth-button-vertical-tinted:hover:not(:disabled) {
background: linear-gradient(
90deg,
color-mix(in srgb, var(--oauth-accent) 75%, #111827) 0 6px,
#111827 6px 100%
);
}
.oauth-button-vertical-legacy {
min-height: 0;
justify-content: flex-start;
padding: 0.75rem 1rem; /* 12px 16px */
border: 1px solid #d1d5db;
border-radius: 0.75rem; /* 12px */
background-color: var(--auth-card-bg-light-only);
font-weight: 500;
color: var(--auth-text-primary-light-only);
box-shadow: none;
}
.oauth-button-vertical-legacy:hover:not(:disabled) {
background-color: #f3f4f6;
color: var(--auth-text-primary-light-only);
box-shadow: none;
}
.oauth-button-vertical-legacy .mantine-Button-inner,
.oauth-button-vertical-legacy .mantine-Button-label {
justify-content: flex-start;
}
.oauth-button-vertical-legacy .oauth-button-left {
gap: 0.75rem;
}
.oauth-button-vertical-legacy .oauth-icon-wrapper {
width: 1.25rem; /* 20px */
height: 1.25rem; /* 20px */
padding: 0;
background: transparent;
border: none;
border-radius: 0;
}
.oauth-button-vertical-legacy .oauth-button-text {
padding-bottom: 0;
}
.oauth-button-vertical-outline {
background: transparent;
border: 2px solid #0f172a;
color: #0f172a;
box-shadow: none;
}
.oauth-button-vertical-outline:hover:not(:disabled) {
background: #0f172a;
color: #f8fafc;
box-shadow: 0 0.35rem 0.9rem rgba(15, 23, 42, 0.2);
}
.oauth-button-vertical-light {
background: #f8fafc;
color: #0f172a;
border: 1px solid rgba(15, 23, 42, 0.12);
box-shadow: 0 0.25rem 0.75rem rgba(15, 23, 42, 0.12);
}
.oauth-button-vertical-light:hover:not(:disabled) {
background: #eef2f7;
color: #0f172a;
box-shadow: 0 0.35rem 0.9rem rgba(15, 23, 42, 0.16);
}
.oauth-button-vertical:disabled {
cursor: not-allowed;
opacity: 0.6;
box-shadow: none;
transform: none;
}
.oauth-button-vertical:focus-visible {
outline: 3px solid rgba(59, 130, 246, 0.5);
outline-offset: 2px;
}
/* Fix Mantine Button internal spans to not crop content */
.oauth-button-vertical .mantine-Button-inner {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
}
.oauth-button-vertical .mantine-Button-label {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
overflow: visible;
}
.oauth-icon-small {
width: 1.75rem; /* 28px */
height: 1.75rem; /* 28px */
display: block;
}
.oauth-icon-medium {
width: 1.75rem; /* 28px */
height: 1.75rem; /* 28px */
display: block;
}
.oauth-icon-tiny {
width: 1.25rem; /* 20px */
height: 1.25rem; /* 20px */
display: block;
flex-shrink: 0;
}
.oauth-container-vertical.oauth-container-single .oauth-button-vertical {
max-width: 26.25rem; /* 420px */
}
.oauth-button-left {
display: flex;
align-items: center;
gap: 0.875rem;
min-width: 0;
flex: 1 1 auto;
}
.oauth-button-text {
font-size: 1rem;
font-weight: 600;
color: inherit;
line-height: 1.2;
display: inline-flex;
align-items: center;
padding-bottom: 0.0625rem; /* 1px - prevent descender clipping */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.oauth-icon-wrapper {
width: 2.25rem; /* 36px */
height: 2.25rem; /* 36px */
border-radius: 0.75rem; /* 12px */
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-muted);
border: 1px solid var(--auth-input-border-light-only);
}
.oauth-button-vertical-tinted .oauth-icon-wrapper {
background: color-mix(
in srgb,
var(--oauth-accent) 20%,
rgba(255, 255, 255, 0.08)
);
border-color: color-mix(
in srgb,
var(--oauth-accent) 35%,
rgba(255, 255, 255, 0.2)
);
}
.oauth-button-vertical-outline .oauth-icon-wrapper,
.oauth-button-vertical-light .oauth-icon-wrapper {
background: rgba(15, 23, 42, 0.06);
border-color: rgba(15, 23, 42, 0.14);
}
.oauth-button-right {
display: flex;
align-items: center;
justify-content: center;
opacity: 0.85;
flex-shrink: 0;
color: inherit;
}
.oauth-arrow-icon {
width: 1.1rem; /* 18px */
height: 1.1rem; /* 18px */
display: block;
}
.sso-demo-block {
margin-bottom: 1.5rem;
}
.sso-demo-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
gap: 1.25rem;
}
.sso-demo-card {
border: 1px solid var(--auth-input-border-light-only);
border-radius: 1rem;
padding: 1rem;
background: var(--auth-card-bg-light-only);
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);
margin-bottom: 0.75rem;
letter-spacing: 0.01em;
text-transform: uppercase;
}
/* Login Header Styles */
.login-header {
margin-bottom: 1rem; /* 16px */
margin-top: 0.5rem; /* 8px */
}
.login-header-centered {
text-align: center;
margin-bottom: 1.5rem; /* 24px */
}
.login-header-centered .login-header-logos {
justify-content: center;
}
.login-header-centered .login-logo-text {
height: 2.5rem; /* 40px */
}
.login-header-logos {
display: flex;
align-items: center;
gap: 0.75rem; /* 12px */
margin-bottom: 1.25rem; /* 20px */
}
.login-logo-icon {
width: 2.5rem; /* 40px */
height: 2.5rem; /* 40px */
border-radius: 0.5rem; /* 8px */
}
.login-logo-text {
height: 2rem; /* 32px - increased from 24px */
}
.login-title {
font-size: 2rem; /* 32px */
font-weight: 800;
color: var(--auth-text-primary-light-only);
margin: 0 0 0.375rem; /* 0 0 6px */
}
.login-subtitle {
color: var(--auth-text-secondary-light-only);
font-size: 0.875rem; /* 14px */
margin: 0;
}
/* Navigation Link Styles */
.navigation-link-container {
text-align: center;
}
.navigation-link-button {
background: none;
border: none;
color: var(--auth-label-text-light-only);
font-size: 0.875rem; /* 14px */
cursor: pointer;
text-decoration: underline;
}
.navigation-link-button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
/* Message Styles */
.error-message {
padding: 1rem; /* 16px */
background-color: #fef2f2;
border: 1px solid #fecaca;
border-radius: 0.5rem; /* 8px */
margin-bottom: 1.5rem; /* 24px */
}
.error-message-text {
color: #dc2626;
font-size: 0.875rem; /* 14px */
margin: 0;
}
.success-message {
padding: 1rem; /* 16px */
background-color: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 0.5rem; /* 8px */
margin-bottom: 1.5rem; /* 24px */
}
.success-message-text {
color: #059669;
font-size: 0.875rem; /* 14px */
margin: 0;
}
/* Field-level error styles */
.auth-field-error {
color: #dc2626;
font-size: 0.6875rem; /* 11px */
margin-top: 0.125rem; /* 2px */
line-height: 1.1;
}
.auth-input-error {
border-color: #dc2626 !important;
}
.auth-input-error:focus {
border-color: #dc2626 !important;
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.1) !important;
}
/* Shared auth styles extracted from inline */
.auth-section {
margin: 0.75rem 0;
}
.auth-section-sm {
margin: 0.5rem 0;
}
.auth-bottom-row {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0.5rem 0 0.25rem;
}
.auth-bottom-right {
display: flex;
align-items: center;
justify-content: flex-end;
margin-top: 0.5rem;
}
.auth-link-black {
background: transparent;
border: 0;
padding: 0;
margin: 0;
text-decoration: underline;
cursor: pointer;
font-size: 0.875rem; /* 14px */
color: var(--auth-text-primary-light-only);
}
.auth-dot-black {
opacity: 0.5;
padding: 0 0.5rem;
color: var(--auth-text-primary-light-only);
}
/* Email login button - red CTA style matching SaaS version */
.auth-cta-button {
background-color: #af3434 !important;
color: white !important;
border: none !important;
font-weight: 600 !important;
}
.auth-cta-button:hover:not(:disabled) {
background-color: #9a2e2e !important;
}
.auth-cta-button:disabled {
background-color: #af3434 !important;
opacity: 0.6 !important;
}
@@ -0,0 +1,139 @@
import { useTranslation } from "react-i18next";
import "@app/routes/authShared/auth.css";
import { TextInput, PasswordInput, Button } from "@mantine/core";
// Force light mode styles for auth inputs
const authInputStyles = {
input: {
backgroundColor: "var(--auth-input-bg-light-only)",
color: "var(--auth-input-text-light-only)",
borderColor: "var(--auth-input-border-light-only)",
"&:focus": {
borderColor: "var(--auth-border-focus-light-only)",
},
},
label: {
color: "var(--auth-label-text-light-only)",
},
};
interface EmailPasswordFormProps {
email: string;
password: string;
setEmail: (email: string) => void;
setPassword: (password: string) => void;
mfaCode?: string;
setMfaCode?: (code: string) => void;
showMfaField?: boolean;
requiresMfa?: boolean;
onSubmit: () => void;
isSubmitting: boolean;
submitButtonText: string;
showPasswordField?: boolean;
fieldErrors?: {
email?: string;
password?: string;
mfaCode?: string;
};
}
export default function EmailPasswordForm({
email,
password,
setEmail,
setPassword,
mfaCode = "",
setMfaCode,
showMfaField = false,
requiresMfa = false,
onSubmit,
isSubmitting,
submitButtonText,
showPasswordField = true,
fieldErrors = {},
}: EmailPasswordFormProps) {
const { t } = useTranslation();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit();
};
return (
<form onSubmit={handleSubmit}>
<div className="auth-fields">
<div className="auth-field">
<TextInput
id="email"
label={t("login.username", "Username")}
type="text"
name="username"
autoComplete="username"
placeholder={t("login.enterUsername", "Enter username")}
value={email}
onChange={(e) => setEmail(e.target.value)}
error={fieldErrors.email}
classNames={{ label: "auth-label" }}
styles={authInputStyles}
autoFocus
/>
</div>
{showPasswordField && (
<div className="auth-field">
<PasswordInput
id="password"
label={t("login.password")}
name="current-password"
autoComplete="current-password"
placeholder={t("login.enterPassword")}
value={password}
onChange={(e) => setPassword(e.target.value)}
error={fieldErrors.password}
classNames={{ label: "auth-label" }}
styles={authInputStyles}
/>
</div>
)}
{showMfaField && (
<div className="auth-field">
<TextInput
id="mfaCode"
label={t("login.mfaCode", "Authentication code")}
type="text"
name="mfaCode"
autoComplete="one-time-code"
placeholder={t("login.enterMfaCode", "Enter 6-digit code")}
value={mfaCode}
inputMode="numeric"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setMfaCode?.(e.target.value.replace(/\D/g, "").slice(0, 6))
}
pattern="[0-9]*"
maxLength={6}
minLength={6}
error={fieldErrors.mfaCode}
classNames={{ label: "auth-label" }}
styles={authInputStyles}
/>
</div>
)}
</div>
<Button
type="submit"
disabled={
isSubmitting ||
!email ||
(showPasswordField && !password) ||
(requiresMfa && !mfaCode.trim())
}
className="auth-button"
fullWidth
loading={isSubmitting}
>
{submitButtonText}
</Button>
</form>
);
}
@@ -0,0 +1,13 @@
interface ErrorMessageProps {
error: string | null;
}
export default function ErrorMessage({ error }: ErrorMessageProps) {
if (!error) return null;
return (
<div className="error-message">
<p className="error-message-text">{error}</p>
</div>
);
}
@@ -0,0 +1,77 @@
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "@app/auth/UseSession";
import { useTranslation } from "react-i18next";
import { LogoIcon } from "@app/components/shared/LogoIcon";
export default function LoggedInState() {
const navigate = useNavigate();
const { user } = useAuth();
const { t } = useTranslation();
useEffect(() => {
const timer = setTimeout(() => {
navigate("/");
}, 2000);
return () => clearTimeout(timer);
}, [navigate]);
return (
<div
style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "#f3f4f6",
padding: "16px",
}}
>
<div
style={{
maxWidth: "400px",
width: "100%",
backgroundColor: "#ffffff",
borderRadius: "16px",
boxShadow: "0 10px 25px rgba(0, 0, 0, 0.1)",
padding: "32px",
}}
>
<div style={{ textAlign: "center", marginBottom: "24px" }}>
<div
style={{
marginBottom: "16px",
display: "flex",
justifyContent: "center",
}}
>
<LogoIcon
alt="Stirling PDF Logo"
style={{ width: "64px", height: "64px", objectFit: "contain" }}
/>
</div>
<h1
style={{
fontSize: "24px",
fontWeight: "bold",
color: "#059669",
marginBottom: "8px",
}}
>
{t("login.youAreLoggedIn")}
</h1>
<p style={{ color: "#6b7280", fontSize: "14px" }}>
{t("login.email")}: {user?.email}
</p>
</div>
<div style={{ textAlign: "center", marginTop: "16px" }}>
<p style={{ color: "#6b7280", fontSize: "14px" }}>
Redirecting to home...
</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,25 @@
import { Wordmark } from "@app/components/shared/Wordmark";
interface LoginHeaderProps {
title: string;
subtitle?: string;
centerOnly?: boolean;
}
export default function LoginHeader({
title,
subtitle,
centerOnly = false,
}: LoginHeaderProps) {
return (
<div
className={`login-header${centerOnly ? " login-header-centered" : ""}`}
>
<div className="login-header-logos">
<Wordmark alt="Stirling PDF" className="login-logo-text" />
</div>
{title && <h1 className="login-title">{title}</h1>}
{subtitle && <p className="login-subtitle">{subtitle}</p>}
</div>
);
}
@@ -0,0 +1,26 @@
import { Button } from "@mantine/core";
interface NavigationLinkProps {
onClick: () => void;
text: string;
isDisabled?: boolean;
}
export default function NavigationLink({
onClick,
text,
isDisabled = false,
}: NavigationLinkProps) {
return (
<div className="navigation-link-container">
<Button
onClick={onClick}
disabled={isDisabled}
className="navigation-link-button"
variant="subtle"
>
{text}
</Button>
</div>
);
}
@@ -0,0 +1,297 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MantineProvider } from "@mantine/core";
import OAuthButtons from "@app/routes/login/OAuthButtons";
// Mock i18n
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, fallback?: string) => fallback || key,
}),
}));
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<MantineProvider>{children}</MantineProvider>
);
describe("OAuthButtons", () => {
const mockOnProviderClick = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it("should render known providers with correct labels", () => {
const enabledProviders = ["google", "github", "authentik"];
render(
<TestWrapper>
<OAuthButtons
onProviderClick={mockOnProviderClick}
isSubmitting={false}
enabledProviders={enabledProviders}
/>
</TestWrapper>,
);
// Check that known providers are rendered with their labels
expect(screen.getByText("Google")).toBeTruthy();
expect(screen.getByText("GitHub")).toBeTruthy();
expect(screen.getByText("Authentik")).toBeTruthy();
});
it("should render unknown provider with capitalized label and generic icon", () => {
const enabledProviders = ["mycompany"];
render(
<TestWrapper>
<OAuthButtons
onProviderClick={mockOnProviderClick}
isSubmitting={false}
enabledProviders={enabledProviders}
/>
</TestWrapper>,
);
// Unknown provider should be capitalized
expect(screen.getByText("Mycompany")).toBeTruthy();
// Check that button has generic OIDC icon
const button = screen.getByText("Mycompany").closest("button");
expect(button).toBeTruthy();
const img = button?.querySelector("img");
expect(img?.src).toContain("oidc.svg");
});
it('should call onProviderClick with actual provider ID (not "oidc")', async () => {
const user = userEvent.setup();
const enabledProviders = ["mycompany"];
render(
<TestWrapper>
<OAuthButtons
onProviderClick={mockOnProviderClick}
isSubmitting={false}
enabledProviders={enabledProviders}
/>
</TestWrapper>,
);
const button = screen.getByText("Mycompany");
await user.click(button);
// Should use actual provider ID 'mycompany', NOT 'oidc'
expect(mockOnProviderClick).toHaveBeenCalledWith("mycompany");
});
it('should call onProviderClick with "authentik" when authentik is clicked', async () => {
const user = userEvent.setup();
const enabledProviders = ["authentik"];
render(
<TestWrapper>
<OAuthButtons
onProviderClick={mockOnProviderClick}
isSubmitting={false}
enabledProviders={enabledProviders}
/>
</TestWrapper>,
);
const button = screen.getByText("Authentik");
await user.click(button);
expect(mockOnProviderClick).toHaveBeenCalledWith("authentik");
});
it('should call onProviderClick with "oidc" when OIDC is explicitly configured', async () => {
const user = userEvent.setup();
const enabledProviders = ["oidc"];
render(
<TestWrapper>
<OAuthButtons
onProviderClick={mockOnProviderClick}
isSubmitting={false}
enabledProviders={enabledProviders}
/>
</TestWrapper>,
);
const button = screen.getByText("OIDC");
await user.click(button);
expect(mockOnProviderClick).toHaveBeenCalledWith("oidc");
});
it("should disable buttons when isSubmitting is true", () => {
const enabledProviders = ["google", "github"];
render(
<TestWrapper>
<OAuthButtons
onProviderClick={mockOnProviderClick}
isSubmitting={true}
enabledProviders={enabledProviders}
/>
</TestWrapper>,
);
const googleButton = screen
.getByText("Google")
.closest("button") as HTMLButtonElement;
const githubButton = screen
.getByText("GitHub")
.closest("button") as HTMLButtonElement;
expect(googleButton.disabled).toBe(true);
expect(githubButton.disabled).toBe(true);
});
it("should render nothing when no providers are enabled", () => {
const { container } = render(
<TestWrapper>
<OAuthButtons
onProviderClick={mockOnProviderClick}
isSubmitting={false}
enabledProviders={[]}
/>
</TestWrapper>,
);
// Should render null/nothing (excluding Mantine's style tags)
const hasContent = Array.from(container.children).some(
(child) => child.tagName.toLowerCase() !== "style",
);
expect(hasContent).toBe(false);
});
it("should render multiple unknown providers with correct IDs", async () => {
const user = userEvent.setup();
const enabledProviders = ["company1", "company2", "company3"];
render(
<TestWrapper>
<OAuthButtons
onProviderClick={mockOnProviderClick}
isSubmitting={false}
enabledProviders={enabledProviders}
/>
</TestWrapper>,
);
// All should be capitalized
expect(screen.getByText("Company1")).toBeTruthy();
expect(screen.getByText("Company2")).toBeTruthy();
expect(screen.getByText("Company3")).toBeTruthy();
// Click each and verify correct ID is passed
await user.click(screen.getByText("Company1"));
expect(mockOnProviderClick).toHaveBeenCalledWith("company1");
await user.click(screen.getByText("Company2"));
expect(mockOnProviderClick).toHaveBeenCalledWith("company2");
await user.click(screen.getByText("Company3"));
expect(mockOnProviderClick).toHaveBeenCalledWith("company3");
});
it("should use correct icon for known providers", () => {
const enabledProviders = ["google", "github", "authentik", "keycloak"];
render(
<TestWrapper>
<OAuthButtons
onProviderClick={mockOnProviderClick}
isSubmitting={false}
enabledProviders={enabledProviders}
/>
</TestWrapper>,
);
// Check that each known provider has its specific icon
const googleButton = screen.getByText("Google").closest("button");
expect(googleButton?.querySelector("img")?.src).toContain("google.svg");
const githubButton = screen.getByText("GitHub").closest("button");
expect(githubButton?.querySelector("img")?.src).toContain("github.svg");
const authentikButton = screen.getByText("Authentik").closest("button");
expect(authentikButton?.querySelector("img")?.src).toContain(
"authentik.svg",
);
const keycloakButton = screen.getByText("Keycloak").closest("button");
expect(keycloakButton?.querySelector("img")?.src).toContain("keycloak.svg");
});
it("should handle mixed known and unknown providers", async () => {
const user = userEvent.setup();
const enabledProviders = ["google", "mycompany", "authentik", "custom"];
render(
<TestWrapper>
<OAuthButtons
onProviderClick={mockOnProviderClick}
isSubmitting={false}
enabledProviders={enabledProviders}
/>
</TestWrapper>,
);
// Known providers with correct labels
expect(screen.getByText("Google")).toBeTruthy();
expect(screen.getByText("Authentik")).toBeTruthy();
// Unknown providers with capitalized labels
expect(screen.getByText("Mycompany")).toBeTruthy();
expect(screen.getByText("Custom")).toBeTruthy();
// Click each and verify IDs are preserved
await user.click(screen.getByText("Google"));
expect(mockOnProviderClick).toHaveBeenCalledWith("google");
await user.click(screen.getByText("Mycompany"));
expect(mockOnProviderClick).toHaveBeenCalledWith("mycompany");
await user.click(screen.getByText("Authentik"));
expect(mockOnProviderClick).toHaveBeenCalledWith("authentik");
await user.click(screen.getByText("Custom"));
expect(mockOnProviderClick).toHaveBeenCalledWith("custom");
});
it("should maintain provider ID consistency - critical for OAuth redirect", async () => {
const user = userEvent.setup();
// This test ensures the fix for GitHub issue #5141
// The provider ID used in the button click MUST match the backend registration ID
// Previously, unknown providers were mapped to 'oidc', breaking the OAuth flow
const enabledProviders = ["authentik", "okta", "auth0"];
render(
<TestWrapper>
<OAuthButtons
onProviderClick={mockOnProviderClick}
isSubmitting={false}
enabledProviders={enabledProviders}
/>
</TestWrapper>,
);
// Each provider should use its actual ID, not 'oidc'
await user.click(screen.getByText("Authentik"));
expect(mockOnProviderClick).toHaveBeenLastCalledWith("authentik");
await user.click(screen.getByText("Okta"));
expect(mockOnProviderClick).toHaveBeenLastCalledWith("okta");
await user.click(screen.getByText("Auth0"));
expect(mockOnProviderClick).toHaveBeenLastCalledWith("auth0");
// Verify none were called with 'oidc' instead of their actual ID
expect(mockOnProviderClick).not.toHaveBeenCalledWith("oidc");
});
});
@@ -0,0 +1,210 @@
import { useTranslation } from "react-i18next";
import { BASE_PATH } from "@app/constants/app";
import { type OAuthProvider } from "@app/auth/oauthTypes";
import { Button } from "@mantine/core";
// Debug flag to show all providers for UI testing
// Set to true to see all SSO options regardless of backend configuration
export const DEBUG_SHOW_ALL_PROVIDERS = false;
// OAuth provider configuration - maps provider ID to display info
// Known providers get custom icons; unknown providers use generic SSO icon
export const oauthProviderConfig: Record<
string,
{ label: string; file: string }
> = {
google: { label: "Google", file: "google.svg" },
github: { label: "GitHub", file: "github.svg" },
apple: { label: "Apple", file: "apple.svg" },
azure: { label: "Microsoft", file: "microsoft.svg" },
keycloak: { label: "Keycloak", file: "keycloak.svg" },
cloudron: { label: "Cloudron", file: "cloudron.svg" },
authentik: { label: "Authentik", file: "authentik.svg" },
oidc: { label: "OIDC", file: "oidc.svg" },
};
// Generic fallback for unknown providers
const GENERIC_PROVIDER_ICON = "oidc.svg";
interface OAuthButtonsProps {
onProviderClick: (provider: OAuthProvider) => void;
isSubmitting: boolean;
layout?: "vertical" | "grid" | "icons";
enabledProviders?: OAuthProvider[]; // List of full auth paths from backend (e.g., '/oauth2/authorization/google', '/saml2/authenticate/stirling')
ctaPrefix?: string;
styleVariant?: "neutral" | "tinted" | "outline" | "light";
demoMode?: boolean;
useNewStyle?: boolean;
}
export default function OAuthButtons({
onProviderClick,
isSubmitting,
layout = "vertical",
enabledProviders = [],
ctaPrefix,
styleVariant = "neutral",
demoMode = false,
useNewStyle = false,
}: OAuthButtonsProps) {
const { t } = useTranslation();
// Debug mode: show all providers for UI testing
const providersToShow = DEBUG_SHOW_ALL_PROVIDERS
? Object.keys(oauthProviderConfig)
: enabledProviders;
// Build provider list - extract provider ID from full path for display
const providers = providersToShow.map((pathOrId) => {
// Extract provider ID from full path (e.g., '/saml2/authenticate/stirling' -> 'stirling')
const providerId = pathOrId.split("/").pop() || pathOrId;
if (providerId in oauthProviderConfig) {
// Known provider - use predefined icon and label
return {
id: pathOrId, // Keep full path for redirect
providerId, // Store extracted ID for display lookup
...oauthProviderConfig[providerId],
};
}
// Unknown provider - use generic icon and capitalize ID for label
return {
id: pathOrId, // Keep full path for redirect
providerId, // Store extracted ID for display lookup
label: providerId.charAt(0).toUpperCase() + providerId.slice(1),
file: GENERIC_PROVIDER_ICON,
};
});
// If no providers are enabled, don't render anything
if (providers.length === 0) {
return null;
}
const isSingleProvider = providers.length === 1;
const isTinted = styleVariant === "tinted";
const isOutline = styleVariant === "outline";
const isLight = styleVariant === "light";
const accentMap: Record<string, string> = {
google: "#4285F4",
github: "#111827",
apple: "#111827",
azure: "#0078D4",
keycloak: "#2C2C2C",
cloudron: "#3B82F6",
authentik: "#FA7B17",
oidc: "#334155",
};
if (layout === "icons") {
return (
<div className="oauth-container-icons">
{providers.map((p) => (
<div
key={p.id}
title={`${t("login.signInWith", "Sign in with")} ${p.label}`}
>
<Button
onClick={() => onProviderClick(p.id)}
disabled={isSubmitting}
className="oauth-button-icon"
aria-label={`${t("login.signInWith", "Sign in with")} ${p.label}`}
variant="default"
>
<img
src={`${BASE_PATH}/Login/${p.file}`}
alt={p.label}
className="oauth-icon-small"
/>
</Button>
</div>
))}
</div>
);
}
if (layout === "grid") {
return (
<div className="oauth-container-grid">
{providers.map((p) => (
<div
key={p.id}
title={`${t("login.signInWith", "Sign in with")} ${p.label}`}
>
<Button
onClick={() => onProviderClick(p.id)}
disabled={isSubmitting}
className="oauth-button-grid"
aria-label={`${t("login.signInWith", "Sign in with")} ${p.label}`}
variant="default"
>
<img
src={`${BASE_PATH}/Login/${p.file}`}
alt={p.label}
className="oauth-icon-medium"
/>
</Button>
</div>
))}
</div>
);
}
return (
<div
className={`oauth-container-vertical${useNewStyle && isSingleProvider ? " oauth-container-single" : ""}`}
>
{providers.map((p) => (
<div
key={p.id}
title={`${t("login.signInWith", "Sign in with")} ${p.label}`}
>
<Button
onClick={() => onProviderClick(p.id)}
disabled={!demoMode && isSubmitting}
className={`oauth-button-vertical${useNewStyle && isSingleProvider ? " oauth-button-vertical-single" : ""}${!useNewStyle ? " oauth-button-vertical-legacy" : ""}${isTinted ? " oauth-button-vertical-tinted" : ""}${isOutline ? " oauth-button-vertical-outline" : ""}${isLight ? " oauth-button-vertical-light" : ""}`}
aria-label={`${t("login.signInWith", "Sign in with")} ${p.label}`}
variant="default"
style={
isTinted
? ({
"--oauth-accent": accentMap[p.providerId] || "#334155",
} as React.CSSProperties)
: undefined
}
>
<span className="oauth-button-left">
<span className="oauth-icon-wrapper">
<img
src={`${BASE_PATH}/Login/${p.file}`}
alt={p.label}
className="oauth-icon-tiny"
/>
</span>
<span className="oauth-button-text">
{ctaPrefix ? `${ctaPrefix} ${p.label}` : p.label}
</span>
</span>
{useNewStyle && isSingleProvider && (
<span className="oauth-button-right" aria-hidden="true">
<svg
className="oauth-arrow-icon"
viewBox="0 0 24 24"
fill="none"
>
<path
d="M5 12h12m0 0-5-5m5 5-5 5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
)}
</Button>
</div>
))}
</div>
);
}
@@ -0,0 +1,51 @@
import { springAuth } from "@app/auth/springAuthClient";
import { BASE_PATH } from "@app/constants/app";
export const useAuthService = () => {
const signUp = async (email: string, password: string, name: string) => {
console.log("[Signup] Creating account for:", email);
const { user, session, error } = await springAuth.signUp({
email: email.trim(),
password: password,
options: {
data: { full_name: name },
emailRedirectTo: `${BASE_PATH}/auth/callback`,
},
});
if (error) {
console.error("[Signup] Sign up error:", error);
throw new Error(error.message);
}
if (user) {
console.log("[Signup] Sign up successful:", user);
return {
user: user,
session: session,
requiresEmailConfirmation: user && !session,
};
}
throw new Error("Unknown error occurred during signup");
};
const signInWithProvider = async (
provider: "github" | "google" | "apple" | "azure",
) => {
const { error } = await springAuth.signInWithOAuth({
provider,
options: { redirectTo: `${BASE_PATH}/auth/callback` },
});
if (error) {
throw new Error(error.message);
}
};
return {
signUp,
signInWithProvider,
};
};
@@ -0,0 +1,170 @@
import { useEffect } from "react";
import "@app/routes/authShared/auth.css";
import { useTranslation } from "react-i18next";
import { Checkbox, TextInput, PasswordInput, Button } from "@mantine/core";
import { SignupFieldErrors } from "@app/routes/signup/SignupFormValidation";
interface SignupFormProps {
name?: string;
email: string;
password: string;
confirmPassword: string;
agree?: boolean;
setName?: (name: string) => void;
setEmail: (email: string) => void;
setPassword: (password: string) => void;
setConfirmPassword: (password: string) => void;
setAgree?: (agree: boolean) => void;
onSubmit: () => void;
isSubmitting: boolean;
fieldErrors?: SignupFieldErrors;
showName?: boolean;
showTerms?: boolean;
}
export default function SignupForm({
name = "",
email,
password,
confirmPassword,
agree = true,
setName,
setEmail,
setPassword,
setConfirmPassword,
setAgree,
onSubmit,
isSubmitting,
fieldErrors = {},
showName = false,
showTerms = false,
}: SignupFormProps) {
const { t } = useTranslation();
const showConfirm = password.length >= 4;
useEffect(() => {
if (!showConfirm && confirmPassword) {
setConfirmPassword("");
}
}, [showConfirm, confirmPassword, setConfirmPassword]);
return (
<>
<div className="auth-fields">
{showName && (
<div className="auth-field">
<TextInput
id="name"
label={t("signup.name")}
name="name"
autoComplete="name"
placeholder={t("signup.enterName")}
value={name}
onChange={(e) => setName?.(e.target.value)}
error={fieldErrors.name}
classNames={{ label: "auth-label" }}
/>
</div>
)}
<div className="auth-field">
<TextInput
id="email"
label={t("signup.email")}
type="email"
name="email"
autoComplete="email"
placeholder={t("signup.enterEmail")}
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && !isSubmitting && onSubmit()}
error={fieldErrors.email}
classNames={{ label: "auth-label" }}
/>
</div>
<div className="auth-field">
<PasswordInput
id="password"
label={t("signup.password")}
name="new-password"
autoComplete="new-password"
placeholder={t("signup.enterPassword")}
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && !isSubmitting && onSubmit()}
error={fieldErrors.password}
classNames={{ label: "auth-label" }}
/>
</div>
<div
aria-hidden={!showConfirm}
className="auth-confirm"
style={{
maxHeight: showConfirm ? 96 : 0,
opacity: showConfirm ? 1 : 0,
}}
>
<div className="auth-field">
<PasswordInput
id="confirmPassword"
label={t("signup.confirmPassword")}
name="new-password"
autoComplete="new-password"
placeholder={t("signup.confirmPasswordPlaceholder")}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
onKeyDown={(e) =>
e.key === "Enter" && !isSubmitting && onSubmit()
}
error={fieldErrors.confirmPassword}
classNames={{ label: "auth-label" }}
/>
</div>
</div>
</div>
{/* Terms - only show if showTerms is true */}
{showTerms && (
<div className="auth-terms">
<Checkbox
id="agree"
checked={agree}
onChange={(e) => setAgree?.(e.currentTarget.checked)}
className="auth-checkbox"
label={
<span className="auth-terms-label">
{t("legal.iAgreeToThe", "I agree to all of the")}{" "}
<a
href="https://www.stirlingpdf.com/terms"
target="_blank"
rel="noopener noreferrer"
>
{t("legal.terms", "Terms and Conditions")}
</a>
</span>
}
/>
</div>
)}
{/* Sign Up Button */}
<Button
onClick={onSubmit}
disabled={
isSubmitting ||
!email ||
!password ||
!confirmPassword ||
(showTerms && !agree)
}
className="auth-button"
fullWidth
loading={isSubmitting}
>
{isSubmitting ? t("signup.creatingAccount") : t("signup.signUp")}
</Button>
</>
);
}
@@ -0,0 +1,72 @@
import { useTranslation } from "react-i18next";
export interface SignupFieldErrors {
name?: string;
email?: string;
password?: string;
confirmPassword?: string;
}
export interface SignupValidationResult {
isValid: boolean;
error: string | null;
fieldErrors?: SignupFieldErrors;
}
export const useSignupFormValidation = () => {
const { t } = useTranslation();
const validateSignupForm = (
email: string,
password: string,
confirmPassword: string,
name?: string,
): SignupValidationResult => {
const fieldErrors: SignupFieldErrors = {};
// Validate name
if (name !== undefined && name !== null && !name.trim()) {
fieldErrors.name = t("signup.nameRequired", "Name is required");
}
// Validate email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!email) {
fieldErrors.email = t("signup.emailRequired", "Email is required");
} else if (!emailRegex.test(email)) {
fieldErrors.email = t("signup.invalidEmail");
}
// Validate password
if (!password) {
fieldErrors.password = t(
"signup.passwordRequired",
"Password is required",
);
} else if (password.length < 6) {
fieldErrors.password = t("signup.passwordTooShort");
}
// Validate confirm password
if (!confirmPassword) {
fieldErrors.confirmPassword = t(
"signup.confirmPasswordRequired",
"Please confirm your password",
);
} else if (password !== confirmPassword) {
fieldErrors.confirmPassword = t("signup.passwordsDoNotMatch");
}
const hasErrors = Object.keys(fieldErrors).length > 0;
return {
isValid: !hasErrors,
error: null, // Don't show generic error, field errors are more specific
fieldErrors: hasErrors ? fieldErrors : undefined,
};
};
return {
validateSignupForm,
};
};