+ ) : undefined
+ }
+ footer={
+ isFirstTimeSetup && showDefaultCredentials && isUserPassAllowed ? (
+
+
+
+ {t("login.defaultCredentials", "Default Login Credentials")}
+
+
+
+ {t("login.username", "Username")}:
+ {" "}
+ admin
+
+
+
+ {t("login.password", "Password")}:
+ {" "}
+ stirling
+
+
+ {t(
+ "login.changePasswordWarning",
+ "Please change your password after logging in for the first time",
+ )}
+
+
+
+ ) : undefined
+ }
/>
-
- {/* Divider between OAuth and Email - only show if SSO is available and username/password is allowed */}
- {hasSSOProviders && isUserPassAllowed && (
-
- )}
-
- {/* Sign in with email button - only show if SSO providers exist and username/password is allowed */}
- {hasSSOProviders && !showEmailForm && isUserPassAllowed && (
-
-
-
- )}
-
- {/* Email form - show by default if no SSO, or when button clicked, but ONLY if username/password is allowed */}
- {showEmailForm && isUserPassAllowed && (
-
-
-
- )}
-
- {/* Help section - only show on first-time setup with default credentials and username/password auth allowed */}
- {isFirstTimeSetup && showDefaultCredentials && isUserPassAllowed && (
-
-
-
- {t("login.defaultCredentials", "Default Login Credentials")}
-
-
-
- {t("login.username", "Username")}:
- {" "}
- admin
-
-
-
- {t("login.password", "Password")}:
- {" "}
- stirling
-
-
- {t(
- "login.changePasswordWarning",
- "Please change your password after logging in for the first time",
- )}
-
-
-
- )}
);
}
diff --git a/frontend/editor/src/proprietary/routes/Signup.tsx b/frontend/editor/src/proprietary/routes/Signup.tsx
index 9773106c45..49481c9ce3 100644
--- a/frontend/editor/src/proprietary/routes/Signup.tsx
+++ b/frontend/editor/src/proprietary/routes/Signup.tsx
@@ -4,11 +4,11 @@ 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 "@shared/auth/ui/auth.css";
import { BASE_PATH, withBasePath } from "@app/constants/app";
// Import signup components
-import ErrorMessage from "@app/routes/login/ErrorMessage";
+import ErrorMessage from "@shared/auth/ui/ErrorMessage";
import DividerWithText from "@app/components/shared/DividerWithText";
import SignupForm from "@app/routes/signup/SignupForm";
import {
@@ -16,6 +16,7 @@ import {
SignupFieldErrors,
} from "@app/routes/signup/SignupFormValidation";
import { useAuthService } from "@app/routes/signup/AuthService";
+import loginHeader from "@shared/assets/login/LoginLightModeHeader.svg";
export default function Signup() {
const navigate = useNavigate();
@@ -93,7 +94,7 @@ export default function Signup() {
diff --git a/frontend/editor/src/proprietary/routes/authShared/AuthLayout.tsx b/frontend/editor/src/proprietary/routes/authShared/AuthLayout.tsx
index c674ed6a1a..2b3006fafb 100644
--- a/frontend/editor/src/proprietary/routes/authShared/AuthLayout.tsx
+++ b/frontend/editor/src/proprietary/routes/authShared/AuthLayout.tsx
@@ -1,8 +1,8 @@
-import React, { useEffect, useMemo, useRef, useState } from "react";
+import React, { useMemo } from "react";
import { useTranslation } from "react-i18next";
-import LoginRightCarousel from "@app/components/shared/LoginRightCarousel";
+import { AuthShell } from "@shared/auth/ui/AuthShell";
+import LoginRightCarousel from "@shared/auth/ui/LoginRightCarousel";
import buildLoginSlides from "@app/components/shared/loginSlides";
-import styles from "@app/routes/authShared/AuthLayout.module.css";
import { useLogoVariant } from "@app/hooks/useLogoVariant";
import Footer from "@app/components/shared/Footer";
@@ -10,65 +10,31 @@ interface AuthLayoutProps {
children: React.ReactNode;
}
+/**
+ * Editor login layout. The card shell + carousel now live in shared so the
+ * portal renders the identical screen; this wires the editor's logo-variant
+ * slides and legal/cookie footer into that shared shell.
+ */
export default function AuthLayout({ children }: AuthLayoutProps) {
const { t } = useTranslation();
- const cardRef = useRef(null);
- const [hideRightPanel, setHideRightPanel] = useState(false);
const logoVariant = useLogoVariant();
const imageSlides = useMemo(
() => buildLoginSlides(logoVariant, t),
[logoVariant, t],
);
- useEffect(() => {
- const update = () => {
- // Use viewport to avoid hysteresis when the card is already in single-column mode
- const viewportWidth = window.innerWidth;
- const viewportHeight = window.innerHeight;
- const cardWidthIfTwoCols = Math.min(1180, viewportWidth * 0.96); // matches min(73.75rem, 96vw)
- const columnWidth = cardWidthIfTwoCols / 2;
- const tooNarrow = columnWidth < 470;
- const tooShort = viewportHeight < 740;
- setHideRightPanel(tooNarrow || tooShort);
- };
- update();
- window.addEventListener("resize", update);
- window.addEventListener("orientationchange", update);
- return () => {
- window.removeEventListener("resize", update);
- window.removeEventListener("orientationchange", update);
- };
- }, []);
-
return (
-
-
-
-
{children}
-
- {!hideRightPanel && (
-
- )}
-
-
-
-
-
+
+ }
+ footer={}
+ >
+ {children}
+
);
}
diff --git a/frontend/editor/src/proprietary/routes/login/OAuthButtons.test.tsx b/frontend/editor/src/proprietary/routes/login/OAuthButtons.test.tsx
index 1c49552c3f..8a7d9aad98 100644
--- a/frontend/editor/src/proprietary/routes/login/OAuthButtons.test.tsx
+++ b/frontend/editor/src/proprietary/routes/login/OAuthButtons.test.tsx
@@ -2,7 +2,7 @@ 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";
+import OAuthButtons from "@shared/auth/ui/OAuthButtons";
// Mock i18n
vi.mock("react-i18next", () => ({
@@ -42,7 +42,10 @@ describe("OAuthButtons", () => {
});
it("should render unknown provider with capitalized label and generic icon", () => {
- const enabledProviders = ["mycompany"];
+ // Render the unknown provider alongside oidc so we can assert the unknown
+ // one falls back to the same (generic OIDC) icon. Icons are bundled assets,
+ // so we compare resolved srcs rather than filenames.
+ const enabledProviders = ["mycompany", "oidc"];
render(
@@ -57,11 +60,17 @@ describe("OAuthButtons", () => {
// 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");
+ // Unknown provider falls back to the generic OIDC icon
+ const mycompanyImg = screen
+ .getByText("Mycompany")
+ .closest("button")
+ ?.querySelector("img");
+ const oidcImg = screen
+ .getByText("OIDC")
+ .closest("button")
+ ?.querySelector("img");
+ expect(mycompanyImg?.src).toBeTruthy();
+ expect(mycompanyImg?.src).toBe(oidcImg?.src);
});
it('should call onProviderClick with actual provider ID (not "oidc")', async () => {
@@ -210,20 +219,19 @@ describe("OAuthButtons", () => {
,
);
- // 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");
+ // Each known provider renders an icon, and the icons are distinct. Icons
+ // are bundled assets (data URI / hashed URL), so assert distinctness rather
+ // than matching filenames.
+ const srcOf = (label: string) =>
+ screen.getByText(label).closest("button")?.querySelector("img")?.src;
+ const srcs = [
+ srcOf("Google"),
+ srcOf("GitHub"),
+ srcOf("Authentik"),
+ srcOf("Keycloak"),
+ ];
+ srcs.forEach((src) => expect(src).toBeTruthy());
+ expect(new Set(srcs).size).toBe(4);
});
it("should handle mixed known and unknown providers", async () => {
diff --git a/frontend/editor/src/proprietary/routes/signup/AuthService.ts b/frontend/editor/src/proprietary/routes/signup/AuthService.ts
index 17158b4084..4c4e8e400b 100644
--- a/frontend/editor/src/proprietary/routes/signup/AuthService.ts
+++ b/frontend/editor/src/proprietary/routes/signup/AuthService.ts
@@ -1,4 +1,4 @@
-import { springAuth } from "@app/auth/springAuthClient";
+import { springAuth } from "@shared/auth/spring/springAuthClient";
import { BASE_PATH } from "@app/constants/app";
export const useAuthService = () => {
diff --git a/frontend/editor/src/proprietary/routes/signup/SignupForm.tsx b/frontend/editor/src/proprietary/routes/signup/SignupForm.tsx
index d12c54be53..e51323c072 100644
--- a/frontend/editor/src/proprietary/routes/signup/SignupForm.tsx
+++ b/frontend/editor/src/proprietary/routes/signup/SignupForm.tsx
@@ -1,5 +1,5 @@
import { useEffect } from "react";
-import "@app/routes/authShared/auth.css";
+import "@shared/auth/ui/auth.css";
import { useTranslation } from "react-i18next";
import { Checkbox, TextInput, PasswordInput, Button } from "@mantine/core";
import { SignupFieldErrors } from "@app/routes/signup/SignupFormValidation";
diff --git a/frontend/editor/src/prototypes/App.tsx b/frontend/editor/src/prototypes/App.tsx
index 0b42532686..191b22dfd0 100644
--- a/frontend/editor/src/prototypes/App.tsx
+++ b/frontend/editor/src/prototypes/App.tsx
@@ -18,7 +18,7 @@ import Onboarding from "@app/components/onboarding/Onboarding";
import "@app/styles/tailwind.css";
import "@app/styles/cookieconsent.css";
import "@app/styles/index.css";
-import "@app/styles/auth-theme.css";
+import "@shared/auth/ui/auth-theme.css";
// Import file ID debugging helpers (development only)
import "@app/utils/fileIdSafety";
diff --git a/frontend/editor/src/saas/components/shared/config/configSections/Overview.tsx b/frontend/editor/src/saas/components/shared/config/configSections/Overview.tsx
index f169c890a7..69685e2e26 100644
--- a/frontend/editor/src/saas/components/shared/config/configSections/Overview.tsx
+++ b/frontend/editor/src/saas/components/shared/config/configSections/Overview.tsx
@@ -21,7 +21,7 @@ import {
linkOAuthIdentity,
supabase,
} from "@app/auth/supabase";
-import { BASE_PATH } from "@app/constants/app";
+import { oauthIconUrl } from "@shared/auth/ui/oauthIcons";
import { oauthProviders } from "@app/constants/authProviders";
import { Tooltip } from "@app/components/shared/Tooltip";
import { absoluteWithBasePath } from "@app/constants/app";
@@ -564,7 +564,7 @@ const Overview: React.FC = ({ onLogoutClick }) => {
size="sm"
leftSection={
diff --git a/frontend/editor/src/saas/routes/Login.tsx b/frontend/editor/src/saas/routes/Login.tsx
index e57a46468f..de98ccf482 100644
--- a/frontend/editor/src/saas/routes/Login.tsx
+++ b/frontend/editor/src/saas/routes/Login.tsx
@@ -5,7 +5,7 @@ import { useAuth } from "@app/auth/UseSession";
import { useTranslation } from "@app/hooks/useTranslation";
import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
import AuthLayout from "@app/routes/authShared/AuthLayout";
-import "@app/routes/authShared/auth.css";
+import "@shared/auth/ui/auth.css";
import "@app/routes/authShared/saas-auth.css";
import {
absoluteWithBasePath,
@@ -15,10 +15,11 @@ import {
import LinkRoundedIcon from "@mui/icons-material/LinkRounded";
// Import login components
-import ErrorMessage from "@app/routes/login/ErrorMessage";
+import ErrorMessage from "@shared/auth/ui/ErrorMessage";
import EmailPasswordForm from "@app/routes/login/EmailPasswordForm";
import OAuthButtons from "@app/routes/login/OAuthButtons";
import LoggedInState from "@app/routes/login/LoggedInState";
+import loginHeader from "@shared/assets/login/LoginLightModeHeader.svg";
export default function Login() {
const navigate = useNavigate();
@@ -267,7 +268,7 @@ export default function Login() {
{/* Centered logo */}
diff --git a/frontend/editor/src/saas/routes/OAuthConsent.tsx b/frontend/editor/src/saas/routes/OAuthConsent.tsx
index 0113edf62f..c02edfa934 100644
--- a/frontend/editor/src/saas/routes/OAuthConsent.tsx
+++ b/frontend/editor/src/saas/routes/OAuthConsent.tsx
@@ -4,10 +4,11 @@ import { useAuth } from "@app/auth/UseSession";
import { useTranslation } from "@app/hooks/useTranslation";
import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
import AuthLayout from "@app/routes/authShared/AuthLayout";
-import "@app/routes/authShared/auth.css";
+import "@shared/auth/ui/auth.css";
import "@app/routes/authShared/saas-auth.css";
import { withBasePath } from "@app/constants/app";
-import ErrorMessage from "@app/routes/login/ErrorMessage";
+import ErrorMessage from "@shared/auth/ui/ErrorMessage";
+import loginHeader from "@shared/assets/login/LoginLightModeHeader.svg";
/**
* OAuth 2.1 consent screen for the Supabase OAuth server (used by MCP clients
@@ -190,7 +191,7 @@ export default function OAuthConsent() {
const logoBlock = (
diff --git a/frontend/editor/src/saas/routes/ResetPassword.tsx b/frontend/editor/src/saas/routes/ResetPassword.tsx
index 93a5eb4a29..12c50c0d78 100644
--- a/frontend/editor/src/saas/routes/ResetPassword.tsx
+++ b/frontend/editor/src/saas/routes/ResetPassword.tsx
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import AuthLayout from "@app/routes/authShared/AuthLayout";
import LoginHeader from "@app/routes/login/LoginHeader";
-import ErrorMessage from "@app/routes/login/ErrorMessage";
+import ErrorMessage from "@shared/auth/ui/ErrorMessage";
import SuccessMessage from "@app/routes/login/SuccessMessage";
import EmailPasswordForm from "@app/routes/login/EmailPasswordForm";
import NavigationLink from "@app/routes/login/NavigationLink";
diff --git a/frontend/editor/src/saas/routes/Signup.tsx b/frontend/editor/src/saas/routes/Signup.tsx
index 724bf455a5..7327e04eb3 100644
--- a/frontend/editor/src/saas/routes/Signup.tsx
+++ b/frontend/editor/src/saas/routes/Signup.tsx
@@ -6,12 +6,12 @@ import { useTranslation } from "@app/hooks/useTranslation";
import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
import { getBaseUrl, withBasePath } from "@app/constants/app";
import AuthLayout from "@app/routes/authShared/AuthLayout";
-import "@app/routes/authShared/auth.css";
+import "@shared/auth/ui/auth.css";
import "@app/routes/authShared/saas-auth.css";
import { alert } from "@app/components/toast";
// Import signup components
-import ErrorMessage from "@app/routes/login/ErrorMessage";
+import ErrorMessage from "@shared/auth/ui/ErrorMessage";
import OAuthButtons from "@app/routes/login/OAuthButtons";
import SignupForm from "@app/routes/signup/SignupForm";
import {
@@ -19,6 +19,7 @@ import {
SignupFieldErrors,
} from "@app/routes/signup/SignupFormValidation";
import { useAuthService } from "@app/routes/signup/AuthService";
+import loginHeader from "@shared/assets/login/LoginLightModeHeader.svg";
export default function Signup() {
const navigate = useNavigate();
@@ -184,7 +185,7 @@ export default function Signup() {
{/* Centered logo */}
diff --git a/frontend/editor/src/saas/routes/authShared/AuthLayout.tsx b/frontend/editor/src/saas/routes/authShared/AuthLayout.tsx
index 4e905f94e3..75c6b1e7f9 100644
--- a/frontend/editor/src/saas/routes/authShared/AuthLayout.tsx
+++ b/frontend/editor/src/saas/routes/authShared/AuthLayout.tsx
@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
-import LoginRightCarousel from "@app/components/shared/LoginRightCarousel";
+import LoginRightCarousel from "@shared/auth/ui/LoginRightCarousel";
import buildLoginSlides from "@app/components/shared/loginSlides";
import styles from "@app/routes/authShared/AuthLayout.module.css";
import { useLogoVariant } from "@app/hooks/useLogoVariant";
diff --git a/frontend/editor/src/saas/routes/authShared/GuestSignInButton.tsx b/frontend/editor/src/saas/routes/authShared/GuestSignInButton.tsx
index 34493116c7..c28a6dc486 100644
--- a/frontend/editor/src/saas/routes/authShared/GuestSignInButton.tsx
+++ b/frontend/editor/src/saas/routes/authShared/GuestSignInButton.tsx
@@ -1,5 +1,5 @@
import React from "react";
-import "@app/routes/authShared/auth.css";
+import "@shared/auth/ui/auth.css";
import "@app/routes/authShared/saas-auth.css";
interface GuestSignInButtonProps {
diff --git a/frontend/editor/src/saas/routes/login/EmailPasswordForm.tsx b/frontend/editor/src/saas/routes/login/EmailPasswordForm.tsx
index c14a2d5178..92210d5c37 100644
--- a/frontend/editor/src/saas/routes/login/EmailPasswordForm.tsx
+++ b/frontend/editor/src/saas/routes/login/EmailPasswordForm.tsx
@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next";
-import "@app/routes/authShared/auth.css";
+import "@shared/auth/ui/auth.css";
import "@app/routes/authShared/saas-auth.css";
interface EmailPasswordFormProps {
diff --git a/frontend/editor/src/saas/routes/login/MagicLinkForm.tsx b/frontend/editor/src/saas/routes/login/MagicLinkForm.tsx
index 0ed0e913a2..df9e35b840 100644
--- a/frontend/editor/src/saas/routes/login/MagicLinkForm.tsx
+++ b/frontend/editor/src/saas/routes/login/MagicLinkForm.tsx
@@ -1,5 +1,5 @@
import { useTranslation } from "@app/hooks/useTranslation";
-import "@app/routes/authShared/auth.css";
+import "@shared/auth/ui/auth.css";
import "@app/routes/authShared/saas-auth.css";
interface MagicLinkFormProps {
diff --git a/frontend/editor/src/saas/routes/login/OAuthButtons.tsx b/frontend/editor/src/saas/routes/login/OAuthButtons.tsx
index 717f96b9a5..d7143e7557 100644
--- a/frontend/editor/src/saas/routes/login/OAuthButtons.tsx
+++ b/frontend/editor/src/saas/routes/login/OAuthButtons.tsx
@@ -1,7 +1,7 @@
import { oauthProviders } from "@app/constants/authProviders";
import { useTranslation } from "@app/hooks/useTranslation";
import { Tooltip } from "@app/components/shared/Tooltip";
-import { withBasePath } from "@app/constants/app";
+import { oauthIconUrl } from "@shared/auth/ui/oauthIcons";
// Exports for compatibility with proprietary code
export const DEBUG_SHOW_ALL_PROVIDERS = false;
@@ -45,7 +45,7 @@ export default function OAuthButtons({
aria-label={`${t("login.signInWith", "Sign in with")} ${p.label}`}
>
@@ -72,7 +72,7 @@ export default function OAuthButtons({
aria-label={`${t("login.signInWith", "Sign in with")} ${p.label}`}
>
@@ -96,7 +96,7 @@ export default function OAuthButtons({
>
diff --git a/frontend/portal/.env b/frontend/portal/.env
new file mode 100644
index 0000000000..52374a4aa2
--- /dev/null
+++ b/frontend/portal/.env
@@ -0,0 +1,12 @@
+# Portal environment (committed). Machine-specific overrides go in .env.local.
+
+# Where the "Editor" app switcher / non-admin redirect points. "/" is correct
+# for production (backend serves the editor at the root, same origin as the
+# portal). In dev, override in .env.local with your running editor's URL
+# (e.g. VITE_EDITOR_URL=http://localhost:5173/).
+VITE_EDITOR_URL=/
+
+# Force MSW mocks on ("true") or off ("false"). Empty = default (on in dev, off
+# in production builds). The single-origin proxy task sets this to "false" so the
+# portal uses the real backend.
+VITE_PORTAL_MOCKS=
diff --git a/frontend/portal/public/locales/en-US/translation.toml b/frontend/portal/public/locales/en-US/translation.toml
index 11d80a4d79..4488dd6726 100644
--- a/frontend/portal/public/locales/en-US/translation.toml
+++ b/frontend/portal/public/locales/en-US/translation.toml
@@ -37,6 +37,9 @@ darkMode = "Dark mode"
lightMode = "Light mode"
search = "Search"
searchPlaceholder = "Searchβ¦"
+accountMenu = "Account menu"
+accountFallback = "Account"
+signOut = "Sign out"
[shell.sidebar]
brandSuffix = "Stirling Processor"
@@ -1737,3 +1740,43 @@ defaultLabel = "Docs processed Β· last 30 days"
delta = "{{pct}}% vs prior 30d"
docsValue = "{{value}} docs"
srAnnounce = "{{date}}: {{value}} docs"
+
+# Login screen - keys used by the shared auth UI (@shared/auth/ui/*), which the
+# portal renders identically to the editor. Values mirror the editor's en-US locale.
+[login]
+enterMfaCode = "Enter 6-digit code"
+enterPassword = "Enter your password"
+enterUsername = "Enter username"
+failedToSignIn = "Failed to sign in with {{provider}}: {{message}}"
+loggingIn = "Logging In..."
+login = "Login"
+mfaCode = "Authentication Code"
+mfaRequired = "Two-factor code required"
+password = "Password"
+pleaseEnterBoth = "Please enter both email and password"
+signInWith = "Sign in with"
+unexpectedError = "Unexpected error: {{message}}"
+username = "Username"
+
+[login.slides.edit]
+alt = "Edit PDFs"
+subtitle = "With over a dozen tools to help you redact, sign, read and manipulate PDFs, you will be sure to find what you are looking for."
+title = "Edit PDFs to display/secure the information you want"
+
+[login.slides.overview]
+alt = "Stirling PDF overview"
+subtitle = "A privacy-first cloud suite for PDFs that lets you convert, sign, redact, and manage documents, along with 50+ other powerful tools."
+title = "Your one-stop-shop for all your PDF needs."
+
+[login.slides.secure]
+alt = "Secure PDFs"
+subtitle = "Add passwords, redact content, and manage certificates with ease."
+title = "Protect sensitive information in your PDFs"
+
+[signup]
+or = "or"
+
+# Auth gate (portal is admin-only): transient states shown by AuthGate.
+[auth]
+loading = "Loading"
+redirectingToEditor = "Redirecting to the editor..."
diff --git a/frontend/portal/src/App.tsx b/frontend/portal/src/App.tsx
index b01beade82..c40029d0dc 100644
--- a/frontend/portal/src/App.tsx
+++ b/frontend/portal/src/App.tsx
@@ -1,11 +1,13 @@
import { useEffect, type ReactNode } from "react";
import { BrowserRouter } from "react-router-dom";
import { MantineProvider } from "@mantine/core";
+import { AuthProvider } from "@shared/auth";
import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext";
import { TierProvider } from "@portal/contexts/TierContext";
import { UIProvider, useUI } from "@portal/contexts/UIContext";
import { mantineTheme } from "@portal/theme/mantineTheme";
import { AppShell } from "@portal/components/AppShell";
+import { AuthGate } from "@portal/components/AuthGate";
import { AssistantButton } from "@portal/components/AssistantButton";
import { AssistantPanel } from "@portal/components/AssistantPanel";
import { SearchModal } from "@portal/components/SearchModal";
@@ -60,23 +62,33 @@ function SettingsHost() {
}
export function App() {
+ // Honour the Vite base path so the portal routes correctly when served under a
+ // subpath (e.g. "/portal" behind the single-origin proxy). BASE_URL is "./"
+ // for a standalone build, which isn't a valid router basename, so only pass it
+ // when it's an absolute subpath.
+ const baseUrl = import.meta.env.BASE_URL;
+ const basename = baseUrl.startsWith("/") ? baseUrl : undefined;
return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
diff --git a/frontend/portal/src/api/http.ts b/frontend/portal/src/api/http.ts
index 7b4f351700..29e99dde7c 100644
--- a/frontend/portal/src/api/http.ts
+++ b/frontend/portal/src/api/http.ts
@@ -5,7 +5,12 @@
* In dev and Storybook those requests are intercepted by the MSW handlers in
* `mocks/` and answered with fixture data; pointing at a real backend is just
* a matter of not registering MSW. Consumers don't change either way.
+ *
+ * The shared `stirling_jwt` bearer token (set by the auth gate, and shared
+ * same-origin with the editor) is attached automatically so portal data calls
+ * are authenticated once real backend endpoints exist.
*/
+import { getStoredToken } from "@shared/auth";
export interface HttpRequestOptions {
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
@@ -26,6 +31,11 @@ export class HttpError extends Error {
}
}
+function authHeader(): Record {
+ const token = getStoredToken();
+ return token ? { Authorization: `Bearer ${token}` } : {};
+}
+
/**
* Thin JSON fetch wrapper used by every api module. In dev/Storybook the
* request is served by MSW; against a real backend it hits the network.
@@ -41,6 +51,7 @@ export async function httpJson(
...(options.body !== undefined
? { "Content-Type": "application/json" }
: {}),
+ ...authHeader(),
...options.headers,
},
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
diff --git a/frontend/portal/src/auth/editorUrl.ts b/frontend/portal/src/auth/editorUrl.ts
new file mode 100644
index 0000000000..2cf77c75b1
--- /dev/null
+++ b/frontend/portal/src/auth/editorUrl.ts
@@ -0,0 +1,10 @@
+/**
+ * Where to send users to reach the editor app (app switcher, and the auth gate
+ * bouncing non-admins out).
+ *
+ * Sourced from VITE_EDITOR_URL so it's configurable per deploy rather than
+ * hardcoded. The committed default is "/" (production serves the editor at the
+ * root on the same origin as the portal). For dev cross-app navigation to a
+ * separately-running editor, set VITE_EDITOR_URL in portal/.env.local.
+ */
+export const EDITOR_URL = import.meta.env.VITE_EDITOR_URL;
diff --git a/frontend/portal/src/components/AuthGate.tsx b/frontend/portal/src/components/AuthGate.tsx
new file mode 100644
index 0000000000..774b269c9f
--- /dev/null
+++ b/frontend/portal/src/components/AuthGate.tsx
@@ -0,0 +1,61 @@
+import { type ReactNode } from "react";
+import { useTranslation } from "react-i18next";
+import { RequireAdmin } from "@shared/auth";
+import { Spinner } from "@shared/components";
+import { LoginScreen } from "@portal/components/LoginScreen";
+import { EDITOR_URL } from "@portal/auth/editorUrl";
+
+/**
+ * Module-level so the reference is stable across renders (RequireAdmin runs it
+ * from an effect). The portal is admin-only today; authenticated non-admins are
+ * bounced to the editor rather than shown an access-denied page.
+ */
+function redirectToEditor(): void {
+ window.location.href = EDITOR_URL;
+}
+
+function FullScreenMessage({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * Gates the whole portal behind an authenticated admin session:
+ * - loading -> spinner
+ * - signed out -> login screen
+ * - signed in, not admin -> redirect to the editor
+ * - signed in admin -> the portal
+ */
+export function AuthGate({ children }: { children: ReactNode }) {
+ const { t } = useTranslation();
+ return (
+ }
+ onForbidden={redirectToEditor}
+ loading={
+
+
+
+ }
+ forbidden={
+
+ {t("auth.redirectingToEditor", "Redirecting to the editor...")}
+
+ }
+ >
+ {children}
+
+ );
+}
diff --git a/frontend/portal/src/components/Header.tsx b/frontend/portal/src/components/Header.tsx
index 9278cbd407..59b1a72625 100644
--- a/frontend/portal/src/components/Header.tsx
+++ b/frontend/portal/src/components/Header.tsx
@@ -1,5 +1,6 @@
import { useTranslation } from "react-i18next";
import { Avatar, Dropdown } from "@shared/components";
+import { useAuth } from "@shared/auth";
import { useTheme } from "@portal/contexts/ThemeContext";
import { useTier, TIER_INFO, type Tier } from "@portal/contexts/TierContext";
import { useView } from "@portal/contexts/ViewContext";
@@ -76,6 +77,32 @@ function TierSwitcher() {
);
}
+function UserMenu() {
+ const { t } = useTranslation();
+ const { displayName, signOut } = useAuth();
+ const name = displayName ?? t("shell.header.accountFallback", "Account");
+ return (
+
+
+
+
+
+ {name}
+ void signOut()}>
+ {t("shell.header.signOut", "Sign out")}
+
+
+
+ );
+}
+
export function Header() {
const { activeView } = useView();
const { openSearch } = useUI();
@@ -108,7 +135,7 @@ export function Header() {
-
+
);
diff --git a/frontend/portal/src/components/LoginScreen.tsx b/frontend/portal/src/components/LoginScreen.tsx
new file mode 100644
index 0000000000..67692b3c3c
--- /dev/null
+++ b/frontend/portal/src/components/LoginScreen.tsx
@@ -0,0 +1,49 @@
+import { useEffect, useMemo } from "react";
+import { useTranslation } from "react-i18next";
+import { AuthShell } from "@shared/auth/ui/AuthShell";
+import LoginRightCarousel from "@shared/auth/ui/LoginRightCarousel";
+import { buildDefaultLoginSlides } from "@shared/auth/ui/loginSlides";
+import SpringLoginForm from "@shared/auth/ui/SpringLoginForm";
+import { useSpringLogin } from "@shared/auth/ui/useSpringLogin";
+import "@shared/auth/ui/auth-theme.css";
+import "@shared/auth/ui/auth.css";
+import loginHeader from "@shared/assets/login/LoginLightModeHeader.svg";
+
+/**
+ * Full-screen login shown by the portal's auth gate. Renders the same screen as
+ * the editor: the shared AuthShell + carousel, with the form body and Spring
+ * auth wiring from @shared/auth/ui (SpringLoginForm + useSpringLogin). The gate
+ * handles "already logged in", so this only needs to collect credentials.
+ */
+export function LoginScreen() {
+ const { t } = useTranslation();
+ const login = useSpringLogin();
+ const slides = useMemo(
+ () => buildDefaultLoginSlides((key, fallback) => t(key, fallback)),
+ [t],
+ );
+
+ // Auth pages render in light mode (the shared screen uses light-only tokens).
+ useEffect(() => {
+ const html = document.documentElement;
+ const previous = html.getAttribute("data-mantine-color-scheme");
+ html.setAttribute("data-mantine-color-scheme", "light");
+ return () => {
+ if (previous) html.setAttribute("data-mantine-color-scheme", previous);
+ };
+ }, []);
+
+ return (
+
+ }
+ >
+
+
+ );
+}
diff --git a/frontend/portal/src/components/Sidebar.tsx b/frontend/portal/src/components/Sidebar.tsx
index b1b70eae14..ac5b042b92 100644
--- a/frontend/portal/src/components/Sidebar.tsx
+++ b/frontend/portal/src/components/Sidebar.tsx
@@ -6,6 +6,7 @@ import { useTheme } from "@portal/contexts/ThemeContext";
import { useUI } from "@portal/contexts/UIContext";
import { useAsync } from "@portal/hooks/useAsync";
import { fetchHomeKpis, type KpiEntry } from "@portal/api/home";
+import { EDITOR_URL } from "@portal/auth/editorUrl";
import markLight from "@shared/assets/stirling-mark-light.svg";
import markDark from "@shared/assets/stirling-mark-dark.svg";
import {
@@ -24,11 +25,6 @@ import {
} from "@portal/components/icons";
import "@portal/components/Sidebar.css";
-// The editor is a separate Vite app with no shared shell, so switching apps is
-// a hard navigation β the editor's dev server in dev, the site root in prod.
-// A standalone portal deploy can gate this behind a configured editor URL.
-const EDITOR_URL = import.meta.env.DEV ? "http://localhost:5180/" : "/";
-
interface NavEntry {
id: ViewId;
icon: React.ReactNode;
diff --git a/frontend/portal/src/mocks/auth.ts b/frontend/portal/src/mocks/auth.ts
new file mode 100644
index 0000000000..5bfe874251
--- /dev/null
+++ b/frontend/portal/src/mocks/auth.ts
@@ -0,0 +1,52 @@
+/**
+ * Mock auth fixtures for the portal's design-prototype mode.
+ *
+ * When mocks are enabled (the default in dev), the portal seeds the mock token
+ * below and the MSW auth handlers answer /api/v1/auth/* with this admin, so the
+ * real auth gate + provider resolve to a signed-in admin without a backend.
+ * When mocks are off (production), real auth against the Spring backend applies.
+ */
+
+export interface MockUser {
+ id: string;
+ email: string;
+ username: string;
+ role: string;
+ enabled: boolean;
+ authenticationType: string;
+}
+
+export const MOCK_ADMIN: MockUser = {
+ id: "mock-admin",
+ email: "admin@stirling.local",
+ username: "admin",
+ role: "ROLE_ADMIN",
+ enabled: true,
+ authenticationType: "WEB",
+};
+
+const base64Url = (value: object): string =>
+ btoa(JSON.stringify(value))
+ .replace(/=/g, "")
+ .replace(/\+/g, "-")
+ .replace(/\//g, "_");
+
+/** A decodable (but unsigned) JWT so the client's exp/iat handling stays quiet. */
+function buildMockToken(): string {
+ const nowSeconds = Math.floor(Date.now() / 1000);
+ const header = base64Url({ alg: "none", typ: "JWT" });
+ const payload = base64Url({
+ sub: MOCK_ADMIN.id,
+ role: MOCK_ADMIN.role,
+ iat: nowSeconds,
+ exp: nowSeconds + 86400,
+ });
+ return `${header}.${payload}.mock-signature`;
+}
+
+export const MOCK_TOKEN = buildMockToken();
+
+export const MOCK_SESSION = {
+ access_token: MOCK_TOKEN,
+ expires_in: 86400,
+};
diff --git a/frontend/portal/src/mocks/browser.ts b/frontend/portal/src/mocks/browser.ts
index c69f3f85b5..aa4607f174 100644
--- a/frontend/portal/src/mocks/browser.ts
+++ b/frontend/portal/src/mocks/browser.ts
@@ -1,10 +1,27 @@
import { setupWorker } from "msw/browser";
+import { JWT_STORAGE_KEY } from "@shared/auth";
import { handlers } from "@portal/mocks/handlers";
+import { MOCK_TOKEN } from "@portal/mocks/auth";
export const worker = setupWorker(...handlers);
let workerStarted = false;
+/**
+ * Seed the shared auth token so the auth gate resolves to the mock admin in
+ * design-prototype mode (no backend needed). Only ever runs when mocks are on;
+ * real deployments leave the token untouched and authenticate for real.
+ */
+function seedMockAuthToken(): void {
+ try {
+ if (typeof localStorage !== "undefined") {
+ localStorage.setItem(JWT_STORAGE_KEY, MOCK_TOKEN);
+ }
+ } catch {
+ // localStorage unavailable - the gate will simply show the login screen.
+ }
+}
+
/**
* Start the MSW worker. Idempotent β calling repeatedly is safe.
*
@@ -14,6 +31,7 @@ let workerStarted = false;
*/
export async function startMockWorker(): Promise {
if (workerStarted) return;
+ seedMockAuthToken();
await worker.start({
onUnhandledRequest: "bypass",
serviceWorker: { url: "/mockServiceWorker.js" },
diff --git a/frontend/portal/src/mocks/handlers/auth.ts b/frontend/portal/src/mocks/handlers/auth.ts
new file mode 100644
index 0000000000..66777ef5ec
--- /dev/null
+++ b/frontend/portal/src/mocks/handlers/auth.ts
@@ -0,0 +1,38 @@
+import { http, HttpResponse, delay } from "msw";
+import { MOCK_ADMIN, MOCK_SESSION } from "@portal/mocks/auth";
+
+/**
+ * Mock auth endpoints mirroring the Spring backend's contract. In mock mode the
+ * portal seeds a token, so GET /api/v1/auth/me resolves to an admin and the
+ * gate lets the dashboards through without a real backend.
+ */
+export const authHandlers = [
+ http.get("/api/v1/auth/me", async () => {
+ await delay(60);
+ return HttpResponse.json({ user: MOCK_ADMIN });
+ }),
+
+ http.post("/api/v1/auth/login", async () => {
+ await delay(120);
+ return HttpResponse.json({ user: MOCK_ADMIN, session: MOCK_SESSION });
+ }),
+
+ http.post("/api/v1/auth/refresh", async () => {
+ await delay(60);
+ return HttpResponse.json({ user: MOCK_ADMIN, session: MOCK_SESSION });
+ }),
+
+ http.post("/api/v1/auth/logout", async () => {
+ await delay(40);
+ return HttpResponse.json({ message: "Logged out successfully" });
+ }),
+
+ http.get("/api/v1/proprietary/ui-data/login", async () => {
+ await delay(40);
+ return HttpResponse.json({
+ enableLogin: true,
+ loginMethod: "all",
+ providerList: {},
+ });
+ }),
+];
diff --git a/frontend/portal/src/mocks/handlers/index.ts b/frontend/portal/src/mocks/handlers/index.ts
index 43948b9788..2d263cc4d0 100644
--- a/frontend/portal/src/mocks/handlers/index.ts
+++ b/frontend/portal/src/mocks/handlers/index.ts
@@ -1,4 +1,5 @@
import { assistantHandlers } from "@portal/mocks/handlers/assistant";
+import { authHandlers } from "@portal/mocks/handlers/auth";
import { homeHandlers } from "@portal/mocks/handlers/home";
import { notificationsHandlers } from "@portal/mocks/handlers/notifications";
import { opsHandlers } from "@portal/mocks/handlers/ops";
@@ -17,6 +18,7 @@ import { sdkComponentsHandlers } from "@portal/mocks/handlers/sdkComponents";
import { editorDeployHandlers } from "@portal/mocks/handlers/editorDeploy";
export const handlers = [
+ ...authHandlers,
...homeHandlers,
...opsHandlers,
...notificationsHandlers,
diff --git a/frontend/portal/src/mocks/preference.ts b/frontend/portal/src/mocks/preference.ts
index 466a62e659..ebd134c7e9 100644
--- a/frontend/portal/src/mocks/preference.ts
+++ b/frontend/portal/src/mocks/preference.ts
@@ -11,9 +11,17 @@ const STORAGE_KEY = "stirling.portal.mocks-enabled";
export function readMocksPreference(): boolean {
if (typeof window === "undefined") return false;
+ // An explicit user toggle (persisted) always wins.
const stored = window.localStorage.getItem(STORAGE_KEY);
if (stored === "true") return true;
if (stored === "false") return false;
+ // Build-time default: VITE_PORTAL_MOCKS forces mocks on/off. The single-origin
+ // proxy sets it false so the portal hits the real backend (otherwise the dev
+ // mock worker would seed a fake token over the shared real one). Falls back to
+ // on-in-dev, off-in-production.
+ const envDefault = import.meta.env.VITE_PORTAL_MOCKS;
+ if (envDefault === "true") return true;
+ if (envDefault === "false") return false;
return import.meta.env.DEV;
}
diff --git a/frontend/portal/src/vite-env.d.ts b/frontend/portal/src/vite-env.d.ts
new file mode 100644
index 0000000000..6da12835bf
--- /dev/null
+++ b/frontend/portal/src/vite-env.d.ts
@@ -0,0 +1,12 @@
+///
+
+interface ImportMetaEnv {
+ /** URL of the editor app (app switcher + non-admin redirect). See portal/.env. */
+ readonly VITE_EDITOR_URL: string;
+ /** Force MSW mocks on/off ("true"/"false"); empty falls back to dev default. */
+ readonly VITE_PORTAL_MOCKS: string;
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv;
+}
diff --git a/frontend/portal/vite.config.ts b/frontend/portal/vite.config.ts
index 2db925a7b3..b28b1e2168 100644
--- a/frontend/portal/vite.config.ts
+++ b/frontend/portal/vite.config.ts
@@ -22,6 +22,23 @@ export default defineConfig(async ({ mode }) => {
// Load .env files relative to this config, regardless of where invoked from.
const env = loadEnv(mode, import.meta.dirname, "");
+ // Backend proxy so the portal shares the editor's origin for auth: with mocks
+ // off, /api/v1/auth/* (and OAuth/SAML redirects) reach the Spring backend and
+ // the same stirling_jwt token works across both apps. Mirrors the editor's
+ // proxy; override the target via BACKEND_URL.
+ const backendUrl = process.env.BACKEND_URL || "http://localhost:8080";
+ const backendProxy = {
+ target: backendUrl,
+ changeOrigin: true,
+ secure: false,
+ xfwd: true,
+ };
+ const backendProxyConfig = {
+ "/api": backendProxy,
+ "/oauth2": backendProxy,
+ "/saml2": backendProxy,
+ };
+
return {
plugins: [
react(),
@@ -50,11 +67,13 @@ export default defineConfig(async ({ mode }) => {
fs: {
allow: [resolve(import.meta.dirname, "..")],
},
+ proxy: backendProxyConfig,
},
preview: {
host: true,
port: 5173,
strictPort: true,
+ proxy: backendProxyConfig,
},
build: {
outDir: "../dist-portal",
diff --git a/frontend/scripts/dev-origin-proxy.ts b/frontend/scripts/dev-origin-proxy.ts
new file mode 100644
index 0000000000..34ebf1f8a7
--- /dev/null
+++ b/frontend/scripts/dev-origin-proxy.ts
@@ -0,0 +1,246 @@
+/**
+ * Single-origin dev server for testing unified auth locally.
+ *
+ * The editor and portal store their session as a `stirling_jwt` token in
+ * localStorage, which the browser scopes per origin. On separate dev ports they
+ * can't share it; this server fronts both apps plus the backend on ONE origin
+ * so a token from one app is automatically seen by the other - mirroring the
+ * real same-origin production topology.
+ *
+ * Routing (single port):
+ * /api, /oauth2, /saml2, /v1/api-docs -> reverse-proxy to the backend
+ * /portal, /portal/* -> the portal app
+ * everything else -> the editor app (SPA)
+ *
+ * Each app is served either from a production build (static files) or from a
+ * running Vite dev server, chosen per app:
+ * - EDITOR_DEV_URL / PORTAL_DEV_URL set -> reverse-proxy to that dev server.
+ * - otherwise serve EDITOR_DIST / PORTAL_DIST as static files (SPA fallback).
+ *
+ * Vite HMR websockets connect straight to each dev server's own port, so live
+ * mode keeps hot reload without this proxy needing to multiplex HMR sockets.
+ *
+ * Config via env: PORT, BACKEND_URL, EDITOR_DEV_URL, PORTAL_DEV_URL,
+ * EDITOR_DIST, PORTAL_DIST.
+ */
+import http from "node:http";
+import https from "node:https";
+import { createReadStream } from "node:fs";
+import { stat } from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import type { Duplex } from "node:stream";
+
+const here = path.dirname(fileURLToPath(import.meta.url));
+const FRONTEND = path.resolve(here, "..");
+
+const PORT = Number(process.env.PORT || 3000);
+const BACKEND_URL = process.env.BACKEND_URL || "http://localhost:8080";
+const EDITOR_DEV_URL = process.env.EDITOR_DEV_URL || "";
+const PORTAL_DEV_URL = process.env.PORTAL_DEV_URL || "";
+const EDITOR_DIST = path.resolve(
+ process.env.EDITOR_DIST || path.join(FRONTEND, "editor", "dist"),
+);
+const PORTAL_DIST = path.resolve(
+ process.env.PORTAL_DIST || path.join(FRONTEND, "dist-portal"),
+);
+
+const backend = new URL(BACKEND_URL);
+const editorDev = EDITOR_DEV_URL ? new URL(EDITOR_DEV_URL) : null;
+const portalDev = PORTAL_DEV_URL ? new URL(PORTAL_DEV_URL) : null;
+
+// Paths owned by the backend (mirrors the editor/portal Vite dev proxies).
+const API_PREFIXES = ["/api", "/oauth2", "/saml2", "/v1/api-docs"];
+
+const MIME: Record = {
+ ".html": "text/html; charset=utf-8",
+ ".js": "text/javascript",
+ ".mjs": "text/javascript",
+ ".css": "text/css",
+ ".json": "application/json",
+ ".svg": "image/svg+xml",
+ ".png": "image/png",
+ ".jpg": "image/jpeg",
+ ".jpeg": "image/jpeg",
+ ".gif": "image/gif",
+ ".webp": "image/webp",
+ ".ico": "image/x-icon",
+ ".woff": "font/woff",
+ ".woff2": "font/woff2",
+ ".ttf": "font/ttf",
+ ".wasm": "application/wasm",
+ ".map": "application/json",
+ ".txt": "text/plain; charset=utf-8",
+ ".toml": "text/plain; charset=utf-8",
+};
+
+function isApiPath(pathname: string): boolean {
+ return API_PREFIXES.some(
+ (p) => pathname === p || pathname.startsWith(`${p}/`),
+ );
+}
+
+function clientFor(target: URL): typeof http | typeof https {
+ return target.protocol === "https:" ? https : http;
+}
+
+function proxyHttp(
+ req: http.IncomingMessage,
+ res: http.ServerResponse,
+ target: URL,
+ label: string,
+): void {
+ const upstream = clientFor(target).request(
+ {
+ protocol: target.protocol,
+ hostname: target.hostname,
+ port: target.port,
+ path: req.url,
+ method: req.method,
+ headers: { ...req.headers, host: target.host },
+ },
+ (upRes) => {
+ res.writeHead(upRes.statusCode || 502, upRes.headers);
+ upRes.pipe(res);
+ },
+ );
+ upstream.on("error", (err: Error) => {
+ res.writeHead(502, { "Content-Type": "text/plain" });
+ res.end(`${label} not reachable at ${target.origin}: ${err.message}`);
+ });
+ req.pipe(upstream);
+}
+
+async function serveStatic(
+ distDir: string,
+ urlPath: string,
+ res: http.ServerResponse,
+): Promise {
+ const pathname = decodeURIComponent((urlPath.split("?")[0] || "/").trim());
+ const hasExtension = path.extname(pathname) !== "";
+ // Routes (no file extension) and "/" fall back to index.html so the SPA
+ // router can take over; real assets resolve to their file.
+ const candidate = hasExtension
+ ? path.join(distDir, pathname)
+ : path.join(distDir, "index.html");
+ const resolved = path.resolve(candidate);
+
+ // Path-traversal guard.
+ if (resolved !== distDir && !resolved.startsWith(distDir + path.sep)) {
+ res.writeHead(403, { "Content-Type": "text/plain" });
+ res.end("Forbidden");
+ return;
+ }
+
+ try {
+ const info = await stat(resolved);
+ if (!info.isFile()) throw new Error("not a file");
+ } catch {
+ res.writeHead(404, { "Content-Type": "text/plain" });
+ res.end(
+ hasExtension
+ ? "Not found"
+ : `index.html not found in ${distDir}. Did the build run? (task dev:portal:proxy builds first)`,
+ );
+ return;
+ }
+
+ const ext = path.extname(resolved).toLowerCase();
+ res.writeHead(200, {
+ "Content-Type": MIME[ext] || "application/octet-stream",
+ "Cache-Control": "no-store",
+ });
+ createReadStream(resolved).pipe(res);
+}
+
+const server = http.createServer((req, res) => {
+ const pathname = (req.url || "/").split("?")[0];
+
+ if (isApiPath(pathname)) {
+ proxyHttp(req, res, backend, "Backend");
+ return;
+ }
+ if (pathname === "/portal" || pathname.startsWith("/portal/")) {
+ if (portalDev) {
+ // Dev server is served under the /portal base, so keep the full path.
+ proxyHttp(req, res, portalDev, "Portal dev server");
+ } else {
+ const rest = (req.url || "").slice("/portal".length) || "/";
+ void serveStatic(PORTAL_DIST, rest, res);
+ }
+ return;
+ }
+ if (editorDev) {
+ proxyHttp(req, res, editorDev, "Editor dev server");
+ } else {
+ void serveStatic(EDITOR_DIST, req.url || "/", res);
+ }
+});
+
+// Forward websocket upgrades to the matching upstream. Backend streaming
+// endpoints need /api; the dev servers' HMR sockets connect directly to their
+// own ports, but routing them here too keeps things working if a browser sends
+// them through the proxy.
+function proxyUpgrade(
+ req: http.IncomingMessage,
+ socket: Duplex,
+ head: Buffer,
+ target: URL,
+): void {
+ const upstream = clientFor(target).request({
+ protocol: target.protocol,
+ hostname: target.hostname,
+ port: target.port,
+ path: req.url,
+ method: req.method,
+ headers: { ...req.headers, host: target.host },
+ });
+ upstream.on("upgrade", (upRes, upSocket, upHead) => {
+ const headerLines = Object.entries(upRes.headers).map(
+ ([k, v]) => `${k}: ${v as string}`,
+ );
+ socket.write(
+ `HTTP/1.1 101 Switching Protocols\r\n${headerLines.join("\r\n")}\r\n\r\n`,
+ );
+ if (upHead && upHead.length) upSocket.unshift(upHead);
+ if (head && head.length) upSocket.write(head);
+ upSocket.pipe(socket);
+ socket.pipe(upSocket);
+ upSocket.on("error", () => socket.destroy());
+ socket.on("error", () => upSocket.destroy());
+ });
+ upstream.on("error", () => socket.destroy());
+ upstream.end();
+}
+
+server.on("upgrade", (req, socket, head) => {
+ const pathname = (req.url || "/").split("?")[0];
+ if (isApiPath(pathname)) {
+ proxyUpgrade(req, socket, head, backend);
+ } else if (
+ portalDev &&
+ (pathname === "/portal" || pathname.startsWith("/portal/"))
+ ) {
+ proxyUpgrade(req, socket, head, portalDev);
+ } else if (editorDev) {
+ proxyUpgrade(req, socket, head, editorDev);
+ } else {
+ socket.destroy();
+ }
+});
+
+server.listen(PORT, () => {
+ const editorSrc = editorDev ? `${editorDev.origin} (dev)` : EDITOR_DIST;
+ const portalSrc = portalDev ? `${portalDev.origin} (dev)` : PORTAL_DIST;
+ console.log("");
+ console.log(" Unified-auth single-origin server");
+ console.log(` βΆ open http://localhost:${PORT}/ (editor)`);
+ console.log(` βΆ open http://localhost:${PORT}/portal (portal)`);
+ console.log(` βΆ backend ${BACKEND_URL} (proxying /api, /oauth2, /saml2)`);
+ console.log(` βΆ editor ${editorSrc}`);
+ console.log(` βΆ portal ${portalSrc}`);
+ console.log(
+ " Log into one, open the other - the stirling_jwt token is shared (same origin).",
+ );
+ console.log("");
+});
diff --git a/frontend/scripts/tsconfig.json b/frontend/scripts/tsconfig.json
new file mode 100644
index 0000000000..b5f1184159
--- /dev/null
+++ b/frontend/scripts/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "es2022",
+ "lib": ["es2022"],
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "types": ["node"],
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "noEmit": true
+ },
+ "include": ["./**/*.ts", "./**/*.mts"]
+}
diff --git a/frontend/editor/public/Login/AddToPDF.png b/frontend/shared/assets/login/AddToPDF.png
similarity index 100%
rename from frontend/editor/public/Login/AddToPDF.png
rename to frontend/shared/assets/login/AddToPDF.png
diff --git a/frontend/editor/public/Login/Firstpage.png b/frontend/shared/assets/login/Firstpage.png
similarity index 100%
rename from frontend/editor/public/Login/Firstpage.png
rename to frontend/shared/assets/login/Firstpage.png
diff --git a/frontend/editor/public/Login/LoginBackgroundPanel.png b/frontend/shared/assets/login/LoginBackgroundPanel.png
similarity index 100%
rename from frontend/editor/public/Login/LoginBackgroundPanel.png
rename to frontend/shared/assets/login/LoginBackgroundPanel.png
diff --git a/frontend/editor/public/modern-logo/LoginLightModeHeader.svg b/frontend/shared/assets/login/LoginLightModeHeader.svg
similarity index 100%
rename from frontend/editor/public/modern-logo/LoginLightModeHeader.svg
rename to frontend/shared/assets/login/LoginLightModeHeader.svg
diff --git a/frontend/editor/public/Login/SecurePDF.png b/frontend/shared/assets/login/SecurePDF.png
similarity index 100%
rename from frontend/editor/public/Login/SecurePDF.png
rename to frontend/shared/assets/login/SecurePDF.png
diff --git a/frontend/editor/public/Login/apple.svg b/frontend/shared/assets/login/apple.svg
similarity index 100%
rename from frontend/editor/public/Login/apple.svg
rename to frontend/shared/assets/login/apple.svg
diff --git a/frontend/editor/public/Login/authentik.svg b/frontend/shared/assets/login/authentik.svg
similarity index 100%
rename from frontend/editor/public/Login/authentik.svg
rename to frontend/shared/assets/login/authentik.svg
diff --git a/frontend/editor/public/Login/cloudron.svg b/frontend/shared/assets/login/cloudron.svg
similarity index 100%
rename from frontend/editor/public/Login/cloudron.svg
rename to frontend/shared/assets/login/cloudron.svg
diff --git a/frontend/editor/public/Login/github.svg b/frontend/shared/assets/login/github.svg
similarity index 100%
rename from frontend/editor/public/Login/github.svg
rename to frontend/shared/assets/login/github.svg
diff --git a/frontend/editor/public/Login/google.svg b/frontend/shared/assets/login/google.svg
similarity index 100%
rename from frontend/editor/public/Login/google.svg
rename to frontend/shared/assets/login/google.svg
diff --git a/frontend/editor/public/Login/keycloak.svg b/frontend/shared/assets/login/keycloak.svg
similarity index 100%
rename from frontend/editor/public/Login/keycloak.svg
rename to frontend/shared/assets/login/keycloak.svg
diff --git a/frontend/editor/public/Login/microsoft.svg b/frontend/shared/assets/login/microsoft.svg
similarity index 100%
rename from frontend/editor/public/Login/microsoft.svg
rename to frontend/shared/assets/login/microsoft.svg
diff --git a/frontend/editor/public/Login/oidc.svg b/frontend/shared/assets/login/oidc.svg
similarity index 100%
rename from frontend/editor/public/Login/oidc.svg
rename to frontend/shared/assets/login/oidc.svg
diff --git a/frontend/shared/auth/AuthProvider.tsx b/frontend/shared/auth/AuthProvider.tsx
new file mode 100644
index 0000000000..0898babe0e
--- /dev/null
+++ b/frontend/shared/auth/AuthProvider.tsx
@@ -0,0 +1,43 @@
+/**
+ * Unified auth provider. Selects the Spring (self-hosted JWT) or Supabase
+ * (cloud) backend by `mode` and feeds the single shared AuthContext, so
+ * consumers read `useAuth()` identically either way.
+ */
+import { lazy, Suspense, type ReactNode } from "react";
+import { SpringAuthProvider } from "@shared/auth/spring/UseSession";
+import { type AuthMode, type AuthTranslate } from "@shared/auth/types";
+
+// Lazy so Spring-mode hosts (e.g. the portal) don't bundle @supabase/supabase-js
+// they never use; only loaded when mode="supabase".
+const SupabaseAuthProvider = lazy(() =>
+ import("@shared/auth/supabase/UseSession").then((m) => ({
+ default: m.SupabaseAuthProvider,
+ })),
+);
+
+export interface AuthProviderProps {
+ children: ReactNode;
+ /** Which backend to authenticate against. Defaults to "spring". */
+ mode?: AuthMode;
+ /** Optional i18n translate for user-facing copy (defaults to English). */
+ translate?: AuthTranslate;
+}
+
+export function AuthProvider({
+ children,
+ mode = "spring",
+ translate,
+}: AuthProviderProps) {
+ if (mode === "supabase") {
+ return (
+
+
+ {children}
+
+
+ );
+ }
+ return (
+ {children}
+ );
+}
diff --git a/frontend/shared/auth/config.ts b/frontend/shared/auth/config.ts
new file mode 100644
index 0000000000..5004d0e314
--- /dev/null
+++ b/frontend/shared/auth/config.ts
@@ -0,0 +1,54 @@
+/**
+ * Dependency injection seam for the shared Spring auth engine.
+ *
+ * The engine is created at import time (it starts a session-monitoring timer),
+ * so configuration is read lazily through {@link getSpringAuthConfig}. Hosts
+ * call {@link configureSpringAuth} once at startup:
+ *
+ * - Editor: injects its `@app/services/apiClient` plus a platform bridge built
+ * from its per-flavor `@app/extensions/*` seams, so desktop/saas behaviour is
+ * unchanged.
+ * - Portal: relies on the web defaults below (same-origin transport + no-op
+ * platform bridge).
+ */
+import type { AxiosInstance } from "axios";
+import { createDefaultHttpClient } from "@shared/auth/httpClient";
+import {
+ defaultPlatformBridge,
+ type PlatformBridge,
+} from "@shared/auth/spring/platformBridge";
+
+export interface SpringAuthConfig {
+ /** Axios instance used for all /api/v1/auth + /api/v1/user calls. */
+ http: AxiosInstance;
+ /** App base path (subpath deploys); used to build OAuth redirect targets. */
+ basePath: string;
+ /** Platform seam (web no-op by default; desktop injects Tauri behaviour). */
+ platform: PlatformBridge;
+}
+
+let config: SpringAuthConfig | null = null;
+
+export function getSpringAuthConfig(): SpringAuthConfig {
+ if (!config) {
+ config = {
+ http: createDefaultHttpClient(),
+ basePath: "",
+ platform: defaultPlatformBridge,
+ };
+ }
+ return config;
+}
+
+/**
+ * Configure the shared Spring auth engine. Any field left undefined keeps its
+ * current (or default) value, so hosts can configure incrementally.
+ */
+export function configureSpringAuth(partial: Partial): void {
+ const current = getSpringAuthConfig();
+ config = {
+ http: partial.http ?? current.http,
+ basePath: partial.basePath ?? current.basePath,
+ platform: partial.platform ?? current.platform,
+ };
+}
diff --git a/frontend/shared/auth/context.ts b/frontend/shared/auth/context.ts
new file mode 100644
index 0000000000..fdc751996b
--- /dev/null
+++ b/frontend/shared/auth/context.ts
@@ -0,0 +1,25 @@
+/**
+ * The single React context backing `useAuth()`. Both the Spring and Supabase
+ * providers write to this same context so consumers read a unified value
+ * regardless of which backend authenticated the user.
+ */
+import { createContext, useContext } from "react";
+import type { AuthContextValue } from "@shared/auth/types";
+
+export const AuthContext = createContext({
+ session: null,
+ user: null,
+ displayName: null,
+ isAnonymous: false,
+ isAdmin: false,
+ role: null,
+ loading: true,
+ error: null,
+ signOut: async () => {},
+ refreshSession: async () => {},
+});
+
+/** Access the current auth state. Must be used within an AuthProvider. */
+export function useAuth(): AuthContextValue {
+ return useContext(AuthContext);
+}
diff --git a/frontend/shared/auth/guards/RequireAdmin.tsx b/frontend/shared/auth/guards/RequireAdmin.tsx
new file mode 100644
index 0000000000..7f1c05cb70
--- /dev/null
+++ b/frontend/shared/auth/guards/RequireAdmin.tsx
@@ -0,0 +1,42 @@
+/**
+ * Gate that renders its children only for an authenticated admin.
+ *
+ * - Still loading -> `loading`
+ * - Signed out -> `fallback` (login screen)
+ * - Signed in but not admin -> calls `onForbidden` (e.g. redirect to the
+ * editor) and renders `forbidden` in the meantime
+ */
+import { useEffect, type ReactNode } from "react";
+import { useAuth } from "@shared/auth/context";
+
+export interface RequireAdminProps {
+ children: ReactNode;
+ /** Rendered when there is no session (e.g. the login panel). */
+ fallback: ReactNode;
+ /** Invoked once when an authenticated non-admin is detected. */
+ onForbidden: () => void;
+ /** Rendered while the session is still resolving. */
+ loading?: ReactNode;
+ /** Rendered for an authenticated non-admin (while `onForbidden` runs). */
+ forbidden?: ReactNode;
+}
+
+export function RequireAdmin({
+ children,
+ fallback,
+ onForbidden,
+ loading = null,
+ forbidden = null,
+}: RequireAdminProps) {
+ const { session, loading: isLoading, isAdmin } = useAuth();
+
+ const shouldRedirect = !isLoading && !!session && !isAdmin;
+ useEffect(() => {
+ if (shouldRedirect) onForbidden();
+ }, [shouldRedirect, onForbidden]);
+
+ if (isLoading) return <>{loading}>;
+ if (!session) return <>{fallback}>;
+ if (!isAdmin) return <>{forbidden}>;
+ return <>{children}>;
+}
diff --git a/frontend/shared/auth/guards/RequireAuth.tsx b/frontend/shared/auth/guards/RequireAuth.tsx
new file mode 100644
index 0000000000..825b443fe5
--- /dev/null
+++ b/frontend/shared/auth/guards/RequireAuth.tsx
@@ -0,0 +1,26 @@
+/**
+ * Gate that renders its children only for an authenticated session. While auth
+ * is initialising it renders `loading`; when signed out it renders `fallback`
+ * (typically a login screen).
+ */
+import { type ReactNode } from "react";
+import { useAuth } from "@shared/auth/context";
+
+export interface RequireAuthProps {
+ children: ReactNode;
+ /** Rendered when there is no session (e.g. the login panel). */
+ fallback: ReactNode;
+ /** Rendered while the session is still resolving. */
+ loading?: ReactNode;
+}
+
+export function RequireAuth({
+ children,
+ fallback,
+ loading = null,
+}: RequireAuthProps) {
+ const { session, loading: isLoading } = useAuth();
+ if (isLoading) return <>{loading}>;
+ if (!session) return <>{fallback}>;
+ return <>{children}>;
+}
diff --git a/frontend/shared/auth/httpClient.ts b/frontend/shared/auth/httpClient.ts
new file mode 100644
index 0000000000..aeb9cb5c41
--- /dev/null
+++ b/frontend/shared/auth/httpClient.ts
@@ -0,0 +1,70 @@
+/**
+ * Default HTTP transport for the shared auth engine.
+ *
+ * The editor injects its own richer axios instance (with platform routing,
+ * error toasts, credit headers, ...) via {@link configureSpringAuth}. Apps that
+ * don't have one - notably the portal - fall back to this minimal client, which
+ * attaches the `stirling_jwt` bearer token so the portal and editor share a
+ * single same-origin session.
+ */
+import axios, { type AxiosInstance } from "axios";
+
+/** localStorage key holding the Spring JWT. Shared so portal + editor agree. */
+export const JWT_STORAGE_KEY = "stirling_jwt";
+
+export function getStoredToken(): string | null {
+ try {
+ if (typeof localStorage === "undefined") return null;
+ return localStorage.getItem(JWT_STORAGE_KEY);
+ } catch {
+ return null;
+ }
+}
+
+export function setStoredToken(token: string): void {
+ try {
+ if (typeof localStorage !== "undefined") {
+ localStorage.setItem(JWT_STORAGE_KEY, token);
+ }
+ } catch {
+ // localStorage unavailable (private mode) - fail open
+ }
+}
+
+export function clearStoredToken(): void {
+ try {
+ if (typeof localStorage !== "undefined") {
+ localStorage.removeItem(JWT_STORAGE_KEY);
+ }
+ } catch {
+ // ignore
+ }
+}
+
+/**
+ * Create the fallback transport. `baseURL` defaults to "/" so it targets the
+ * same origin that served the SPA - the backend serves both portal and editor,
+ * so the cookie/token domain is shared.
+ */
+export function createDefaultHttpClient(baseURL = "/"): AxiosInstance {
+ const client = axios.create({
+ baseURL,
+ responseType: "json",
+ withCredentials: true,
+ });
+
+ client.interceptors.request.use((config) => {
+ const token = getStoredToken();
+ if (token) {
+ config.headers = config.headers ?? {};
+ // Respect an explicit Authorization header (e.g. /auth/me passes the
+ // candidate token directly); only fill it in when absent.
+ if (!config.headers.Authorization) {
+ config.headers.Authorization = `Bearer ${token}`;
+ }
+ }
+ return config;
+ });
+
+ return client;
+}
diff --git a/frontend/shared/auth/index.ts b/frontend/shared/auth/index.ts
new file mode 100644
index 0000000000..4cb9599c48
--- /dev/null
+++ b/frontend/shared/auth/index.ts
@@ -0,0 +1,75 @@
+/**
+ * Shared, provider-agnostic auth used by both the editor and the portal.
+ *
+ * Hosts configure a backend once at startup:
+ * - Spring: configureSpringAuth({ http, basePath, platform }) then render
+ * .
+ * - Supabase: configureSupabase({ url, key }) then render
+ * .
+ *
+ * Consumers read state via useAuth(); guards (RequireAuth/RequireAdmin) gate UI.
+ */
+
+// Contract + helpers
+export * from "@shared/auth/types";
+export { isAdminRole } from "@shared/auth/roles";
+export { AuthContext, useAuth } from "@shared/auth/context";
+
+// Unified provider + guards
+export {
+ AuthProvider,
+ type AuthProviderProps,
+} from "@shared/auth/AuthProvider";
+export {
+ RequireAuth,
+ type RequireAuthProps,
+} from "@shared/auth/guards/RequireAuth";
+export {
+ RequireAdmin,
+ type RequireAdminProps,
+} from "@shared/auth/guards/RequireAdmin";
+
+// Spring backend
+export {
+ configureSpringAuth,
+ getSpringAuthConfig,
+ type SpringAuthConfig,
+} from "@shared/auth/config";
+export {
+ type PlatformBridge,
+ type PlatformSessionUser,
+ defaultPlatformBridge,
+} from "@shared/auth/spring/platformBridge";
+export {
+ createDefaultHttpClient,
+ getStoredToken,
+ setStoredToken,
+ clearStoredToken,
+ JWT_STORAGE_KEY,
+} from "@shared/auth/httpClient";
+export {
+ SpringAuthProvider,
+ deriveDisplayName,
+} from "@shared/auth/spring/UseSession";
+export {
+ springAuth,
+ setPostLoginRedirectPath,
+ consumePostLoginRedirectPath,
+ isSafePostLoginRedirect,
+ POST_LOGIN_REDIRECT_STORAGE_KEY,
+ getCurrentUser,
+ isUserAnonymous,
+ createAnonymousUser,
+ createAnonymousSession,
+} from "@shared/auth/spring/springAuthClient";
+export type { OAuthProvider } from "@shared/auth/spring/oauthTypes";
+
+// Supabase backend is intentionally NOT re-exported here: doing so would pull
+// @supabase/supabase-js into every barrel consumer (e.g. the portal in Spring
+// mode). Supabase-mode hosts import directly from the subpath:
+// import { configureSupabase } from "@shared/auth/supabase/supabaseClient";
+// The unified lazy-loads the provider on demand.
+
+// Login UI components live under @shared/auth/ui/* and are imported directly
+// (default exports), e.g. `import OAuthButtons from "@shared/auth/ui/OAuthButtons"`.
+// They use react-i18next, so hosts must initialise i18next.
diff --git a/frontend/shared/auth/roles.ts b/frontend/shared/auth/roles.ts
new file mode 100644
index 0000000000..f0cd153972
--- /dev/null
+++ b/frontend/shared/auth/roles.ts
@@ -0,0 +1,22 @@
+/**
+ * Role helpers shared across apps.
+ *
+ * The Spring backend serialises authorities into a single string (e.g.
+ * "ROLE_ADMIN" or a space/comma separated list). Supabase carries roles in
+ * app_metadata. Both normalise through {@link isAdminRole}.
+ */
+
+const ADMIN_TOKENS = new Set(["ADMIN", "ROLE_ADMIN"]);
+
+/**
+ * True when the supplied role string grants admin access. Tolerates a single
+ * role ("ROLE_ADMIN"), a space/comma separated list, and casing differences.
+ */
+export function isAdminRole(role: string | null | undefined): boolean {
+ if (!role) return false;
+ return role
+ .toUpperCase()
+ .split(/[\s,]+/)
+ .filter(Boolean)
+ .some((token) => ADMIN_TOKENS.has(token));
+}
diff --git a/frontend/shared/auth/spring/UseSession.tsx b/frontend/shared/auth/spring/UseSession.tsx
new file mode 100644
index 0000000000..3c76fbf0c6
--- /dev/null
+++ b/frontend/shared/auth/spring/UseSession.tsx
@@ -0,0 +1,206 @@
+import { useEffect, useState, useCallback, type ReactNode } from "react";
+import { springAuth } from "@shared/auth/spring/springAuthClient";
+import { getSpringAuthConfig } from "@shared/auth/config";
+import { isAdminRole } from "@shared/auth/roles";
+import { AuthContext } from "@shared/auth/context";
+import {
+ defaultTranslate,
+ type AuthContextValue,
+ type AuthChangeEvent,
+ type AuthError,
+ type AuthSession,
+ type AuthUser,
+ type AuthTranslate,
+} from "@shared/auth/types";
+
+/**
+ * Strip the configured base path so route comparisons work under subpath
+ * deploys. Mirrors the editor's `stripBasePath` but reads the injected base.
+ */
+function stripBasePath(pathname: string): string {
+ const base = getSpringAuthConfig().basePath;
+ if (!base) return pathname;
+ if (pathname === base) return "/";
+ if (pathname.startsWith(`${base}/`)) return pathname.slice(base.length);
+ return pathname;
+}
+
+/**
+ * Derive a display name from the Spring user. Anonymous users get the
+ * (optionally localised) "User" placeholder; returns null only when there is
+ * no user object at all so consumers can pick their own fallback.
+ */
+export function deriveDisplayName(
+ user: AuthUser | null | undefined,
+ translate: AuthTranslate = defaultTranslate,
+): string | null {
+ if (!user) return null;
+ if (user.is_anonymous) return translate("auth.displayName.user", "User");
+ return user.username || user.email || null;
+}
+
+export interface SpringAuthProviderProps {
+ children: ReactNode;
+ /**
+ * Optional translate function for user-facing copy. The editor passes one
+ * backed by i18next; the portal omits it and gets English fallbacks.
+ */
+ translate?: AuthTranslate;
+}
+
+/**
+ * Auth Provider Component
+ *
+ * Manages authentication state and provides it to the app. Integrates with the
+ * Spring Security + JWT backend via the shared engine.
+ */
+export function SpringAuthProvider({
+ children,
+ translate = defaultTranslate,
+}: SpringAuthProviderProps) {
+ const [session, setSession] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ // Debug: Track state transitions
+ useEffect(() => {
+ console.log("[Auth] State changed:", {
+ loading,
+ hasSession: !!session,
+ hasError: !!error,
+ userId: session?.user?.id,
+ timestamp: new Date().toISOString(),
+ });
+ }, [loading, session, error]);
+
+ const refreshSession = useCallback(async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const { data, error } = await springAuth.refreshSession();
+
+ if (error) {
+ console.error("[Auth] Session refresh error:", error);
+ setError(error);
+ setSession(null);
+ } else {
+ setSession(data.session);
+ }
+ } catch (err) {
+ console.error("[Auth] Unexpected error during session refresh:", err);
+ setError(err as AuthError);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const signOut = useCallback(async () => {
+ try {
+ setError(null);
+ const { error } = await springAuth.signOut();
+
+ // Always clear the in-memory session: springAuth.signOut() removes the
+ // local token and platform user_info even when the backend POST fails,
+ // so the user is effectively signed out either way.
+ setSession(null);
+
+ if (error) {
+ console.error("[Auth] Sign out error:", error);
+ setError(error);
+ }
+ } catch (err) {
+ console.error("[Auth] Unexpected error during sign out:", err);
+ setSession(null);
+ setError(err as AuthError);
+ }
+ }, []);
+
+ useEffect(() => {
+ let mounted = true;
+
+ const initializeAuth = async () => {
+ try {
+ // Clear any platform-specific cached auth on login page init.
+ if (
+ typeof window !== "undefined" &&
+ stripBasePath(window.location.pathname).startsWith("/login")
+ ) {
+ await getSpringAuthConfig().platform.clearPlatformAuthOnLoginInit();
+ }
+
+ const { data, error } = await springAuth.getSession();
+
+ if (!mounted) return;
+
+ if (error) {
+ console.error("[Auth] Initial session error:", error);
+ setError(error);
+ } else {
+ setSession(data.session);
+ }
+ } catch (err) {
+ console.error(
+ "[Auth] Unexpected error during auth initialization:",
+ err,
+ );
+ if (mounted) {
+ setError(err as AuthError);
+ }
+ } finally {
+ if (mounted) {
+ setLoading(false);
+ }
+ }
+ };
+
+ initializeAuth();
+
+ // Listen for jwt-available event (triggered by desktop auth or AuthCallback)
+ const handleJwtAvailable = () => {
+ setLoading(true); // Prevent unstable renders during auth state transition
+ setError(null);
+ void initializeAuth();
+ };
+
+ window.addEventListener("jwt-available", handleJwtAvailable);
+
+ // Subscribe to auth state changes
+ const {
+ data: { subscription },
+ } = springAuth.onAuthStateChange(
+ async (_event: AuthChangeEvent, newSession: AuthSession | null) => {
+ if (!mounted) return;
+ // Schedule state update on the next tick to match the previous behaviour.
+ setTimeout(() => {
+ if (mounted) {
+ setSession(newSession);
+ setError(null);
+ }
+ }, 0);
+ },
+ );
+
+ return () => {
+ mounted = false;
+ window.removeEventListener("jwt-available", handleJwtAvailable);
+ subscription.unsubscribe();
+ };
+ // Run once on mount: the provider owns its own subscription lifecycle.
+ }, []);
+
+ const user = session?.user ?? null;
+ const value: AuthContextValue = {
+ session,
+ user,
+ displayName: deriveDisplayName(user, translate),
+ isAnonymous: user?.is_anonymous === true,
+ isAdmin: isAdminRole(user?.role),
+ role: user?.role ?? null,
+ loading,
+ error,
+ signOut,
+ refreshSession,
+ };
+
+ return {children};
+}
diff --git a/frontend/editor/src/proprietary/auth/oauthStorage.ts b/frontend/shared/auth/spring/oauthStorage.ts
similarity index 100%
rename from frontend/editor/src/proprietary/auth/oauthStorage.ts
rename to frontend/shared/auth/spring/oauthStorage.ts
diff --git a/frontend/editor/src/proprietary/auth/oauthTypes.ts b/frontend/shared/auth/spring/oauthTypes.ts
similarity index 100%
rename from frontend/editor/src/proprietary/auth/oauthTypes.ts
rename to frontend/shared/auth/spring/oauthTypes.ts
diff --git a/frontend/shared/auth/spring/platformBridge.ts b/frontend/shared/auth/spring/platformBridge.ts
new file mode 100644
index 0000000000..c357d00303
--- /dev/null
+++ b/frontend/shared/auth/spring/platformBridge.ts
@@ -0,0 +1,70 @@
+/**
+ * Platform seam for the Spring auth client.
+ *
+ * The client itself is platform-agnostic: it never inspects the JWT directly
+ * or touches Tauri/desktop storage. Each host wires its own behaviour through
+ * this bridge. The web default (used by the portal and the editor's web builds)
+ * is a no-op set that mirrors the editor's previous `@app/extensions/*`
+ * defaults exactly; the editor's desktop build injects a Tauri-backed bridge.
+ */
+
+/**
+ * Resolved identity for the current session, as understood by the platform
+ * layer that owns the underlying token format. Treated as opaque by the client.
+ */
+export interface PlatformSessionUser {
+ username: string;
+ email?: string;
+ /** True for anonymous/guest sessions (e.g. Supabase anonymous sign-in). */
+ is_anonymous?: boolean;
+}
+
+export interface PlatformBridge {
+ /** Clear platform-specific cached auth after sign-out (e.g. Tauri store). */
+ clearPlatformAuthAfterSignOut(): Promise;
+ /** Clear platform-specific cached auth when the login page initialises. */
+ clearPlatformAuthOnLoginInit(): Promise;
+ /** Whether the active backend is a desktop SaaS gateway (Supabase-managed). */
+ isDesktopSaaSAuthMode(): Promise;
+ /** Whether the active backend exposes /api/v1/auth/logout. */
+ shouldCallBackendLogout(): Promise;
+ /** Resolve the current user from platform storage (desktop only). */
+ getPlatformSessionUser(): Promise;
+ /** Refresh the session through the platform layer (desktop only). */
+ refreshPlatformSession(): Promise;
+ /** Persist the token to platform-specific storage (Tauri store). */
+ savePlatformToken(token: string): Promise;
+ /** Begin an OAuth navigation; return true if the platform handled it. */
+ startOAuthNavigation(redirectUrl: string): Promise;
+}
+
+/**
+ * Web defaults - byte-for-byte equivalent to the editor's previous
+ * proprietary/extensions defaults so web behaviour is unchanged.
+ */
+export const defaultPlatformBridge: PlatformBridge = {
+ async clearPlatformAuthAfterSignOut() {
+ // no-op for web builds
+ },
+ async clearPlatformAuthOnLoginInit() {
+ // no-op for web builds
+ },
+ async isDesktopSaaSAuthMode() {
+ return false;
+ },
+ async shouldCallBackendLogout() {
+ return true;
+ },
+ async getPlatformSessionUser() {
+ return null;
+ },
+ async refreshPlatformSession() {
+ return false;
+ },
+ async savePlatformToken() {
+ // Web mode: token already saved to localStorage in the auth client.
+ },
+ async startOAuthNavigation() {
+ return false;
+ },
+};
diff --git a/frontend/editor/src/proprietary/auth/springAuthClient.ts b/frontend/shared/auth/spring/springAuthClient.ts
similarity index 90%
rename from frontend/editor/src/proprietary/auth/springAuthClient.ts
rename to frontend/shared/auth/spring/springAuthClient.ts
index 5d75605e37..885e070d21 100644
--- a/frontend/editor/src/proprietary/auth/springAuthClient.ts
+++ b/frontend/shared/auth/spring/springAuthClient.ts
@@ -1,26 +1,40 @@
/**
- * Spring Auth Client
+ * Spring Auth Client (shared engine)
*
- * This client integrates with the Spring Security + JWT backend.
+ * Integrates with the Spring Security + JWT backend.
* - Uses localStorage for JWT storage (sent via Authorization header)
* - JWT validation handled server-side
* - No email confirmation flow (auto-confirmed on registration)
+ *
+ * This is the platform-agnostic engine. The HTTP transport, base path and
+ * platform-specific behaviour are injected via `@shared/auth/config` so the
+ * same code backs the editor (which injects its apiClient + desktop bridge)
+ * and the portal (web defaults).
*/
-import apiClient from "@app/services/apiClient";
-import { AxiosError } from "axios";
-import { BASE_PATH } from "@app/constants/app";
-import { type OAuthProvider } from "@app/auth/oauthTypes";
-import { resetOAuthState } from "@app/auth/oauthStorage";
-import { clearPlatformAuthAfterSignOut } from "@app/extensions/authSessionCleanup";
-import {
- getPlatformSessionUser,
- isDesktopSaaSAuthMode,
- refreshPlatformSession,
- savePlatformToken,
- shouldCallBackendLogout,
-} from "@app/extensions/platformSessionBridge";
-import { startOAuthNavigation } from "@app/extensions/oauthNavigation";
+import { AxiosError, type AxiosRequestConfig } from "axios";
+import { getSpringAuthConfig } from "@shared/auth/config";
+import { type OAuthProvider } from "@shared/auth/spring/oauthTypes";
+import { resetOAuthState } from "@shared/auth/spring/oauthStorage";
+import type {
+ AuthUser as User,
+ AuthSession as Session,
+ AuthError,
+ AuthResponse,
+ AuthChangeEvent,
+} from "@shared/auth/types";
+
+export type { User, Session, AuthError, AuthResponse, AuthChangeEvent };
+
+/** Axios config plus the editor's custom request flags (ignored by the portal). */
+type AuthRequestConfig = AxiosRequestConfig & {
+ suppressErrorToast?: boolean;
+ skipAuthRedirect?: boolean;
+};
+
+const http = () => getSpringAuthConfig().http;
+const platform = () => getSpringAuthConfig().platform;
+const basePath = () => getSpringAuthConfig().basePath;
function getHttpStatus(error: unknown): number | undefined {
if (error instanceof AxiosError) {
@@ -52,13 +66,16 @@ function getErrorMessage(error: unknown, fallback: string): string {
const OAUTH_REDIRECT_COOKIE = "stirling_redirect_path";
const OAUTH_REDIRECT_COOKIE_MAX_AGE = 60 * 5; // 5 minutes
-const DEFAULT_REDIRECT_PATH = `${BASE_PATH || ""}/auth/callback`;
+
+function defaultRedirectPath(): string {
+ return `${basePath() || ""}/auth/callback`;
+}
export const POST_LOGIN_REDIRECT_STORAGE_KEY = "stirling_post_login_path";
function normalizeRedirectPath(target?: string): string {
if (!target || typeof target !== "string") {
- return DEFAULT_REDIRECT_PATH;
+ return defaultRedirectPath();
}
try {
@@ -69,7 +86,7 @@ function normalizeRedirectPath(target?: string): string {
} catch {
const trimmed = target.trim();
if (!trimmed) {
- return DEFAULT_REDIRECT_PATH;
+ return defaultRedirectPath();
}
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
}
@@ -112,7 +129,7 @@ export function setPostLoginRedirectPath(
window.sessionStorage.removeItem(POST_LOGIN_REDIRECT_STORAGE_KEY);
}
} catch (_error) {
- // sessionStorage unavailable (private mode) β fail open
+ // sessionStorage unavailable (private mode): fail open
}
}
@@ -129,45 +146,6 @@ export function consumePostLoginRedirectPath(): string | null {
}
}
-// Auth types
-export interface User {
- id: string;
- email: string;
- username: string;
- role: string;
- enabled?: boolean;
- is_anonymous?: boolean;
- isFirstLogin?: boolean;
- authenticationType?: string;
- app_metadata?: Record;
-}
-
-export interface Session {
- user: User;
- access_token: string;
- expires_in: number;
- expires_at?: number;
-}
-
-export interface AuthError {
- message: string;
- status?: number;
- code?: string;
- mfaRequired?: boolean;
-}
-
-export interface AuthResponse {
- user: User | null;
- session: Session | null;
- error: AuthError | null;
-}
-
-export type AuthChangeEvent =
- | "SIGNED_IN"
- | "SIGNED_OUT"
- | "TOKEN_REFRESHED"
- | "USER_UPDATED";
-
type AuthChangeCallback = (
event: AuthChangeEvent,
session: Session | null,
@@ -175,7 +153,7 @@ type AuthChangeCallback = (
class SpringAuthClient {
private listeners: AuthChangeCallback[] = [];
- private sessionCheckInterval: NodeJS.Timeout | null = null;
+ private sessionCheckInterval: ReturnType | null = null;
// Adaptive intervals - calculated based on actual JWT token lifetime
// Defaults for initial startup (will be recalculated on first token)
@@ -321,10 +299,10 @@ class SpringAuthClient {
return { data: { session: null }, error: null };
}
- if (await isDesktopSaaSAuthMode()) {
+ if (await platform().isDesktopSaaSAuthMode()) {
let tokenExpiry = this.getTokenExpiry(token);
if (tokenExpiry.expiresIn <= this.DESKTOP_SAAS_REFRESH_EARLY_SECONDS) {
- const refreshed = await refreshPlatformSession();
+ const refreshed = await platform().refreshPlatformSession();
if (!refreshed) {
localStorage.removeItem("stirling_jwt");
return { data: { session: null }, error: null };
@@ -345,7 +323,7 @@ class SpringAuthClient {
return { data: { session: null }, error: null };
}
- const platformUser = await getPlatformSessionUser();
+ const platformUser = await platform().getPlatformSessionUser();
const session: Session = {
user: {
@@ -372,14 +350,15 @@ class SpringAuthClient {
// Verify with backend
// Note: We pass the token explicitly here, overriding the interceptor's default
// console.debug('[SpringAuth] getSession: Verifying JWT with /api/v1/auth/me');
- const response = await apiClient.get("/api/v1/auth/me", {
+ const meConfig: AuthRequestConfig = {
headers: {
Authorization: `Bearer ${token}`,
},
suppressErrorToast: true, // Suppress global error handler (we handle errors locally)
// Session bootstrap should not trigger global 401 refresh/redirect loops.
skipAuthRedirect: true,
- });
+ };
+ const response = await http().get("/api/v1/auth/me", meConfig);
// console.debug('[SpringAuth] /me response status:', response.status);
const data = response.data;
@@ -428,7 +407,7 @@ class SpringAuthClient {
mfaCode?: string;
}): Promise {
try {
- const response = await apiClient.post(
+ const response = await http().post(
"/api/v1/auth/login",
{
username: credentials.email,
@@ -448,7 +427,7 @@ class SpringAuthClient {
// console.log('[SpringAuth] JWT stored in localStorage');
// Sync token to platform-specific storage (Tauri store for desktop)
- await savePlatformToken(token);
+ await platform().savePlatformToken(token);
// Calculate adaptive monitoring intervals based on token lifetime
this.calculateAdaptiveIntervals(token);
@@ -504,7 +483,7 @@ class SpringAuthClient {
options?: { data?: { full_name?: string }; emailRedirectTo?: string };
}): Promise {
try {
- const response = await apiClient.post(
+ const response = await http().post(
"/api/v1/user/register",
{
username: credentials.email,
@@ -548,7 +527,7 @@ class SpringAuthClient {
// Use the full path provided by the backend
// This supports both OAuth2 (/oauth2/authorization/...) and SAML2 (/saml2/authenticate/...)
const redirectUrl = params.provider;
- const handled = await startOAuthNavigation(redirectUrl);
+ const handled = await platform().startOAuthNavigation(redirectUrl);
if (handled) {
return { error: null };
}
@@ -584,8 +563,8 @@ class SpringAuthClient {
// `/api/v1/auth/logout` (Supabase manages session lifecycle); POSTing
// there returns 500 and pollutes error toasts even though the local
// cleanup below succeeds.
- if (await shouldCallBackendLogout()) {
- const response = await apiClient.post("/api/v1/auth/logout", null, {
+ if (await platform().shouldCallBackendLogout()) {
+ const response = await http().post("/api/v1/auth/logout", null, {
headers: {
"X-XSRF-TOKEN": this.getCsrfToken() || "",
},
@@ -628,7 +607,7 @@ class SpringAuthClient {
}
try {
- await clearPlatformAuthAfterSignOut();
+ await platform().clearPlatformAuthAfterSignOut();
} catch (cleanupError) {
console.warn(
"[SpringAuth] Failed to run platform auth cleanup",
@@ -645,7 +624,7 @@ class SpringAuthClient {
// Still remove token even if backend call fails
localStorage.removeItem("stirling_jwt");
try {
- await clearPlatformAuthAfterSignOut();
+ await platform().clearPlatformAuthAfterSignOut();
} catch (cleanupError) {
console.warn(
"[SpringAuth] Failed to run platform auth cleanup after error",
@@ -672,8 +651,8 @@ class SpringAuthClient {
error: AuthError | null;
}> {
try {
- if (await isDesktopSaaSAuthMode()) {
- const refreshed = await refreshPlatformSession();
+ if (await platform().isDesktopSaaSAuthMode()) {
+ const refreshed = await platform().refreshPlatformSession();
if (!refreshed) {
localStorage.removeItem("stirling_jwt");
return {
@@ -702,13 +681,18 @@ class SpringAuthClient {
return { data, error: null };
}
- const response = await apiClient.post("/api/v1/auth/refresh", null, {
+ const refreshConfig: AuthRequestConfig = {
headers: {
"X-XSRF-TOKEN": this.getCsrfToken() || "",
},
withCredentials: true,
suppressErrorToast: true, // Suppress global error handler (we handle errors locally)
- });
+ };
+ const response = await http().post(
+ "/api/v1/auth/refresh",
+ null,
+ refreshConfig,
+ );
const data = response.data;
const token = data.session.access_token;
@@ -717,7 +701,7 @@ class SpringAuthClient {
localStorage.setItem("stirling_jwt", token);
// Sync token to platform-specific storage (Tauri store for desktop)
- await savePlatformToken(token);
+ await platform().savePlatformToken(token);
// Calculate adaptive monitoring intervals based on token lifetime
this.calculateAdaptiveIntervals(token);
diff --git a/frontend/shared/auth/supabase/UseSession.tsx b/frontend/shared/auth/supabase/UseSession.tsx
new file mode 100644
index 0000000000..51ad3da326
--- /dev/null
+++ b/frontend/shared/auth/supabase/UseSession.tsx
@@ -0,0 +1,159 @@
+/**
+ * Basic Supabase-backed auth provider feeding the unified AuthContext.
+ *
+ * This is the portable provider used by the shared unified auth (e.g. the
+ * portal in Supabase mode). It deliberately does NOT carry the editor saas
+ * build's extras (pro status, profile pictures, teams) - those remain in the
+ * editor's saas layer. It maps a Supabase session onto the provider-agnostic
+ * AuthUser/AuthSession shapes and exposes the same useAuth() contract as the
+ * Spring provider.
+ */
+import { useCallback, useEffect, useState, type ReactNode } from "react";
+import type {
+ Session as SbSession,
+ User as SbUser,
+} from "@supabase/supabase-js";
+import { getSupabaseClient } from "@shared/auth/supabase/supabaseClient";
+import { AuthContext } from "@shared/auth/context";
+import { isAdminRole } from "@shared/auth/roles";
+import {
+ defaultTranslate,
+ type AuthContextValue,
+ type AuthError,
+ type AuthSession,
+ type AuthUser,
+ type AuthTranslate,
+} from "@shared/auth/types";
+
+function readRole(user: SbUser): string {
+ const appRole = (user.app_metadata as { role?: unknown } | undefined)?.role;
+ if (typeof appRole === "string") return appRole;
+ return "USER";
+}
+
+function mapUser(user: SbUser): AuthUser {
+ const metadata = user.user_metadata as
+ | { full_name?: string; name?: string; username?: string }
+ | undefined;
+ return {
+ id: user.id,
+ email: user.email ?? "",
+ username:
+ metadata?.username ||
+ metadata?.full_name ||
+ metadata?.name ||
+ user.email ||
+ "",
+ role: readRole(user),
+ is_anonymous: user.is_anonymous,
+ app_metadata: user.app_metadata as Record,
+ };
+}
+
+function mapSession(session: SbSession | null): AuthSession | null {
+ if (!session) return null;
+ return {
+ user: mapUser(session.user),
+ access_token: session.access_token,
+ expires_in: session.expires_in,
+ expires_at: session.expires_at ? session.expires_at * 1000 : undefined,
+ };
+}
+
+function deriveDisplayName(
+ user: AuthUser | null,
+ translate: AuthTranslate,
+): string | null {
+ if (!user) return null;
+ if (user.is_anonymous) return translate("auth.displayName.guest", "Guest");
+ return user.username || user.email || null;
+}
+
+export interface SupabaseAuthProviderProps {
+ children: ReactNode;
+ translate?: AuthTranslate;
+}
+
+export function SupabaseAuthProvider({
+ children,
+ translate = defaultTranslate,
+}: SupabaseAuthProviderProps) {
+ const [session, setSession] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const refreshSession = useCallback(async () => {
+ const supabase = getSupabaseClient();
+ if (!supabase) return;
+ setLoading(true);
+ const { data, error } = await supabase.auth.refreshSession();
+ if (error) {
+ setError({ message: error.message });
+ setSession(null);
+ } else {
+ setSession(mapSession(data.session));
+ }
+ setLoading(false);
+ }, []);
+
+ const signOut = useCallback(async () => {
+ const supabase = getSupabaseClient();
+ setSession(null);
+ if (!supabase) return;
+ const { error } = await supabase.auth.signOut();
+ if (error) setError({ message: error.message });
+ }, []);
+
+ useEffect(() => {
+ const supabase = getSupabaseClient();
+ if (!supabase) {
+ // Supabase mode requested but not configured - settle into a signed-out
+ // state instead of hanging on "loading".
+ setLoading(false);
+ return;
+ }
+
+ let mounted = true;
+ supabase.auth
+ .getSession()
+ .then(({ data }) => {
+ if (!mounted) return;
+ setSession(mapSession(data.session));
+ })
+ .catch((e: unknown) => {
+ if (mounted) setError({ message: String(e) });
+ })
+ .finally(() => {
+ if (mounted) setLoading(false);
+ });
+
+ const {
+ data: { subscription },
+ } = supabase.auth.onAuthStateChange((_event, newSession) => {
+ if (!mounted) return;
+ setSession(mapSession(newSession));
+ setError(null);
+ });
+
+ return () => {
+ mounted = false;
+ subscription.unsubscribe();
+ };
+ }, []);
+
+ const user = session?.user ?? null;
+ const value: AuthContextValue = {
+ session,
+ user,
+ displayName: deriveDisplayName(user, translate),
+ isAnonymous: user?.is_anonymous === true,
+ isAdmin: isAdminRole(user?.role),
+ role: user?.role ?? null,
+ loading,
+ error,
+ signOut,
+ refreshSession,
+ };
+
+ return {children};
+}
diff --git a/frontend/shared/auth/supabase/supabaseClient.ts b/frontend/shared/auth/supabase/supabaseClient.ts
new file mode 100644
index 0000000000..be85a80c11
--- /dev/null
+++ b/frontend/shared/auth/supabase/supabaseClient.ts
@@ -0,0 +1,66 @@
+/**
+ * Shared Supabase client.
+ *
+ * The editor's saas build keeps its own richer client (profile pictures, pro
+ * status, teams). This module is the portable Supabase path used by the shared
+ * unified auth provider - notably so the portal can run in Supabase mode
+ * against a hosted backend. The client is created lazily via
+ * {@link configureSupabase}; until then {@link getSupabaseClient} returns null,
+ * so hosts that never configure it (e.g. the portal in Spring mode) don't pull
+ * Supabase into their session at all.
+ */
+import { createClient, type SupabaseClient } from "@supabase/supabase-js";
+
+export interface SupabaseAuthOptions {
+ persistSession?: boolean;
+ autoRefreshToken?: boolean;
+ detectSessionInUrl?: boolean;
+}
+
+export interface SupabaseConfig {
+ url: string;
+ key: string;
+ authOptions?: SupabaseAuthOptions;
+}
+
+let client: SupabaseClient | null = null;
+
+/** Create (or replace) the shared Supabase client. Returns the instance. */
+export function configureSupabase(config: SupabaseConfig): SupabaseClient {
+ client = createClient(config.url, config.key, {
+ auth: {
+ persistSession: config.authOptions?.persistSession ?? true,
+ autoRefreshToken: config.authOptions?.autoRefreshToken ?? true,
+ detectSessionInUrl: config.authOptions?.detectSessionInUrl ?? true,
+ },
+ });
+ return client;
+}
+
+/** The configured Supabase client, or null if not configured. */
+export function getSupabaseClient(): SupabaseClient | null {
+ return client;
+}
+
+/** Anonymous (guest) sign-in. Throws if Supabase is not configured. */
+export async function signInAnonymously() {
+ const supabase = getSupabaseClient();
+ if (!supabase) {
+ throw new Error("Supabase is not configured");
+ }
+ return supabase.auth.signInAnonymously();
+}
+
+export const isUserAnonymous = (user: { is_anonymous?: boolean } | null) => {
+ return user?.is_anonymous === true;
+};
+
+/** Fetch the current Supabase user, or null when unauthenticated/unconfigured. */
+export async function getCurrentUser() {
+ const supabase = getSupabaseClient();
+ if (!supabase) return null;
+ const {
+ data: { user },
+ } = await supabase.auth.getUser();
+ return user;
+}
diff --git a/frontend/shared/auth/types.ts b/frontend/shared/auth/types.ts
new file mode 100644
index 0000000000..777805681d
--- /dev/null
+++ b/frontend/shared/auth/types.ts
@@ -0,0 +1,79 @@
+/**
+ * Provider-agnostic auth model shared by the editor and the portal.
+ *
+ * Both the Spring (self-hosted JWT) and Supabase (cloud) backends are mapped
+ * onto these shapes so consumers can read `useAuth()` without knowing which
+ * backend authenticated the user.
+ */
+
+export interface AuthUser {
+ id: string;
+ email: string;
+ username: string;
+ /** Backend role string, e.g. "ROLE_ADMIN" / "USER". */
+ role: string;
+ enabled?: boolean;
+ is_anonymous?: boolean;
+ isFirstLogin?: boolean;
+ authenticationType?: string;
+ app_metadata?: Record;
+}
+
+export interface AuthSession {
+ user: AuthUser;
+ access_token: string;
+ expires_in: number;
+ expires_at?: number;
+}
+
+export interface AuthError {
+ message: string;
+ status?: number;
+ code?: string;
+ mfaRequired?: boolean;
+}
+
+export interface AuthResponse {
+ user: AuthUser | null;
+ session: AuthSession | null;
+ error: AuthError | null;
+}
+
+export type AuthChangeEvent =
+ | "SIGNED_IN"
+ | "SIGNED_OUT"
+ | "TOKEN_REFRESHED"
+ | "USER_UPDATED";
+
+/**
+ * The unified value exposed by `useAuth()` regardless of backend. The editor's
+ * existing consumers destructure session/user/displayName/isAnonymous/loading/
+ * error/signOut/refreshSession; `role` and `isAdmin` are additive and drive the
+ * portal's admin gate.
+ */
+export interface AuthContextValue {
+ session: AuthSession | null;
+ user: AuthUser | null;
+ displayName: string | null;
+ isAnonymous: boolean;
+ /** True when the current user holds an admin role. */
+ isAdmin: boolean;
+ /** Raw backend role string, or null when signed out. */
+ role: string | null;
+ loading: boolean;
+ error: AuthError | null;
+ signOut: () => Promise;
+ refreshSession: () => Promise;
+}
+
+/** Which backend the shared auth provider talks to. */
+export type AuthMode = "spring" | "supabase";
+
+/**
+ * Translate hook for user-facing auth copy. Apps with i18n (the editor) pass a
+ * function backed by their `t`; apps without it (the portal) omit it and get
+ * the English fallback.
+ */
+export type AuthTranslate = (key: string, fallback: string) => string;
+
+export const defaultTranslate: AuthTranslate = (_key, fallback) => fallback;
diff --git a/frontend/editor/src/proprietary/routes/authShared/AuthLayout.module.css b/frontend/shared/auth/ui/AuthShell.module.css
similarity index 100%
rename from frontend/editor/src/proprietary/routes/authShared/AuthLayout.module.css
rename to frontend/shared/auth/ui/AuthShell.module.css
diff --git a/frontend/shared/auth/ui/AuthShell.tsx b/frontend/shared/auth/ui/AuthShell.tsx
new file mode 100644
index 0000000000..d28c64382b
--- /dev/null
+++ b/frontend/shared/auth/ui/AuthShell.tsx
@@ -0,0 +1,73 @@
+import { useEffect, useRef, useState, type ReactNode } from "react";
+import styles from "@shared/auth/ui/AuthShell.module.css";
+
+export interface AuthShellProps {
+ children: ReactNode;
+ /** Optional panel shown beside the form on wide/tall viewports (the carousel). */
+ rightPanel?: ReactNode;
+ /** Optional fixed footer slot (the editor passes its legal/cookie footer). */
+ footer?: ReactNode;
+}
+
+/**
+ * The login card shell shared by the editor and the portal: a centered card
+ * that expands to two columns (form + right panel) on wide/tall viewports and
+ * collapses to a single column otherwise. Purely presentational - callers
+ * provide the form (children), the right panel, and an optional footer.
+ */
+export function AuthShell({ children, rightPanel, footer }: AuthShellProps) {
+ const cardRef = useRef(null);
+ const [hideRightPanel, setHideRightPanel] = useState(false);
+
+ useEffect(() => {
+ const update = () => {
+ // Use viewport to avoid hysteresis when the card is already single-column.
+ const viewportWidth = window.innerWidth;
+ const viewportHeight = window.innerHeight;
+ const cardWidthIfTwoCols = Math.min(1180, viewportWidth * 0.96); // min(73.75rem, 96vw)
+ const columnWidth = cardWidthIfTwoCols / 2;
+ const tooNarrow = columnWidth < 470;
+ const tooShort = viewportHeight < 740;
+ setHideRightPanel(tooNarrow || tooShort);
+ };
+ update();
+ window.addEventListener("resize", update);
+ window.addEventListener("orientationchange", update);
+ return () => {
+ window.removeEventListener("resize", update);
+ window.removeEventListener("orientationchange", update);
+ };
+ }, []);
+
+ const showRightPanel = Boolean(rightPanel) && !hideRightPanel;
+
+ return (
+
+
+
+
{children}
+
+ {showRightPanel && rightPanel}
+
+ {footer && (
+
+ {footer}
+
+ )}
+
+ );
+}
+
+export default AuthShell;
diff --git a/frontend/editor/src/proprietary/routes/login/EmailPasswordForm.tsx b/frontend/shared/auth/ui/EmailPasswordForm.tsx
similarity index 84%
rename from frontend/editor/src/proprietary/routes/login/EmailPasswordForm.tsx
rename to frontend/shared/auth/ui/EmailPasswordForm.tsx
index 2cb6750ab3..c20fde34b5 100644
--- a/frontend/editor/src/proprietary/routes/login/EmailPasswordForm.tsx
+++ b/frontend/shared/auth/ui/EmailPasswordForm.tsx
@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next";
-import "@app/routes/authShared/auth.css";
+import "@shared/auth/ui/auth.css";
import { TextInput, PasswordInput, Button } from "@mantine/core";
// Force light mode styles for auth inputs
@@ -83,10 +83,10 @@ export default function EmailPasswordForm({
setPassword(e.target.value)}
error={fieldErrors.password}
@@ -99,7 +99,7 @@ export default function EmailPasswordForm({
{submitButtonText}
diff --git a/frontend/editor/src/proprietary/routes/login/ErrorMessage.tsx b/frontend/shared/auth/ui/ErrorMessage.tsx
similarity index 100%
rename from frontend/editor/src/proprietary/routes/login/ErrorMessage.tsx
rename to frontend/shared/auth/ui/ErrorMessage.tsx
diff --git a/frontend/editor/src/proprietary/components/shared/LoginRightCarousel.tsx b/frontend/shared/auth/ui/LoginRightCarousel.tsx
similarity index 95%
rename from frontend/editor/src/proprietary/components/shared/LoginRightCarousel.tsx
rename to frontend/shared/auth/ui/LoginRightCarousel.tsx
index 1759e09af1..5d764333f1 100644
--- a/frontend/editor/src/proprietary/components/shared/LoginRightCarousel.tsx
+++ b/frontend/shared/auth/ui/LoginRightCarousel.tsx
@@ -1,7 +1,7 @@
import { memo, useEffect, useMemo, useRef, useState } from "react";
-import { BASE_PATH } from "@app/constants/app";
+import bgDefault from "@shared/assets/login/LoginBackgroundPanel.png";
-type ImageSlide = {
+export type ImageSlide = {
src: string;
alt?: string;
cornerModelUrl?: string;
@@ -14,11 +14,14 @@ type ImageSlide = {
function LoginRightCarousel({
imageSlides = [],
showBackground = true,
+ backgroundSrc = bgDefault,
initialSeconds = 5,
slideSeconds = 8,
}: {
imageSlides?: ImageSlide[];
showBackground?: boolean;
+ /** Background panel image; defaults to the bundled shared asset. */
+ backgroundSrc?: string;
initialSeconds?: number;
slideSeconds?: number;
}) {
@@ -117,7 +120,7 @@ function LoginRightCarousel({
>
{showBackground && (
void;
@@ -112,7 +114,7 @@ export default function OAuthButtons({
variant="default"
>
@@ -139,7 +141,7 @@ export default function OAuthButtons({
variant="default"
>
@@ -176,7 +178,7 @@ export default function OAuthButtons({
diff --git a/frontend/shared/auth/ui/SpringLoginForm.tsx b/frontend/shared/auth/ui/SpringLoginForm.tsx
new file mode 100644
index 0000000000..985af89e3e
--- /dev/null
+++ b/frontend/shared/auth/ui/SpringLoginForm.tsx
@@ -0,0 +1,144 @@
+import type { ReactNode } from "react";
+import { useTranslation } from "react-i18next";
+import ErrorMessage from "@shared/auth/ui/ErrorMessage";
+import EmailPasswordForm from "@shared/auth/ui/EmailPasswordForm";
+import OAuthButtons from "@shared/auth/ui/OAuthButtons";
+import type { SpringLoginState } from "@shared/auth/ui/useSpringLogin";
+
+interface SpringLoginFormProps {
+ /** Login state + handlers, from useSpringLogin. */
+ state: SpringLoginState;
+ /** Light-mode logo source. */
+ logoSrc: string;
+ /** Optional dark-mode logo source (the editor swaps logos by colour scheme). */
+ logoDarkSrc?: string;
+ logoAlt?: string;
+ /** OAuth CTA prefix, e.g. "Sign in with" (editor SSO-only mode). */
+ oauthCtaPrefix?: string;
+ /** Editor SSO-only button styling. */
+ oauthUseNewStyle?: boolean;
+ /**
+ * Whether to render the email/password form. Defaults to
+ * state.isUserPassAllowed (the portal always shows it); the editor toggles it
+ * when SSO providers are present.
+ */
+ showEmailForm?: boolean;
+ /** Optional override for the submit button label. */
+ submitButtonText?: string;
+ /** Slot rendered above the error message (editor: success banner). */
+ aboveError?: ReactNode;
+ /** Slot rendered between the divider and the email form (editor: toggle). */
+ beforeEmailForm?: ReactNode;
+ /** Slot rendered after the form (editor: first-time-setup credentials hint). */
+ footer?: ReactNode;
+}
+
+/**
+ * The shared Spring login form body: logo, error, OAuth buttons, divider, and
+ * the email/password form. Rendered by both the editor and the portal inside
+ * their own auth shells; state and handlers come from useSpringLogin.
+ */
+export default function SpringLoginForm({
+ state,
+ logoSrc,
+ logoDarkSrc,
+ logoAlt = "Stirling PDF",
+ oauthCtaPrefix,
+ oauthUseNewStyle,
+ showEmailForm,
+ submitButtonText,
+ aboveError,
+ beforeEmailForm,
+ footer,
+}: SpringLoginFormProps) {
+ const { t } = useTranslation();
+ const {
+ error,
+ providers,
+ hasProviders,
+ isUserPassAllowed,
+ isSubmitting,
+ email,
+ password,
+ setEmail,
+ setPassword,
+ mfaCode,
+ setMfaCode,
+ requiresMfa,
+ signInWithEmail,
+ signInWithProvider,
+ } = state;
+
+ const renderEmailForm =
+ (showEmailForm ?? isUserPassAllowed) && isUserPassAllowed;
+ const submitLabel =
+ submitButtonText ??
+ (isSubmitting
+ ? t("login.loggingIn", "Logging In...")
+ : t("login.login", "Login"));
+
+ return (
+ <>
+