mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-17 19:55:06 +02:00
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:
co-authored by
Claude Opus 4.7
parent
48027ee9d6
commit
0a50e765b7
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user