From 1816bad1baf4348321310205b4a3def6870d58c1 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Mon, 22 Jun 2026 17:22:36 +0100 Subject: [PATCH] Unified auth for portal and editor (#6725) # Description of Changes Refactor frontend auth to the shared folder and hook it up to both the portal and editor so they share the same system. Also adds various tasks to help run the portal, including `task dev:portal` to spawn the portal with the backend, and `task dev:portal:proxy` to spawn the editor, portal and backend, and a reverse proxy (at localhost:3000) to allow you to use both at once to simulate how this will actually be deployed, allowing you to check whether the seamless transition between the two actually works. --- .taskfiles/backend.yml | 6 +- .taskfiles/frontend.yml | 52 +- Taskfile.yml | 91 ++++ frontend/editor/public/Login/azure.svg | 6 - .../config/configSections/ProviderCard.tsx | 5 +- .../configSections/providerDefinitions.ts | 5 +- .../SetupWizard/DesktopAuthLayout.tsx | 4 +- .../SetupWizard/DesktopOAuthButtons.tsx | 6 +- .../SetupWizard/SaaSLoginScreen.tsx | 6 +- .../SetupWizard/SaaSSignupScreen.tsx | 4 +- .../components/SetupWizard/SelfHostedLink.tsx | 2 +- .../SetupWizard/SelfHostedLoginScreen.tsx | 6 +- .../SetupWizard/ServerSelectionScreen.tsx | 4 +- .../desktop/components/SetupWizard/index.tsx | 2 +- frontend/editor/src/proprietary/App.tsx | 2 +- .../src/proprietary/auth/UseSession.test.ts | 2 +- .../src/proprietary/auth/UseSession.tsx | 349 +----------- .../proprietary/auth/configureSpringAuth.ts | 44 ++ .../proprietary/auth/springAuthClient.test.ts | 5 +- .../components/shared/loginSlides.ts | 71 +-- .../proprietary/routes/AuthCallback.test.tsx | 8 +- .../src/proprietary/routes/AuthCallback.tsx | 2 +- .../src/proprietary/routes/InviteAccept.tsx | 2 +- .../src/proprietary/routes/Login.test.tsx | 21 +- .../editor/src/proprietary/routes/Login.tsx | 495 ++++++------------ .../editor/src/proprietary/routes/Signup.tsx | 7 +- .../routes/authShared/AuthLayout.tsx | 74 +-- .../routes/login/OAuthButtons.test.tsx | 50 +- .../proprietary/routes/signup/AuthService.ts | 2 +- .../proprietary/routes/signup/SignupForm.tsx | 2 +- frontend/editor/src/prototypes/App.tsx | 2 +- .../shared/config/configSections/Overview.tsx | 4 +- frontend/editor/src/saas/routes/Login.tsx | 7 +- .../editor/src/saas/routes/OAuthConsent.tsx | 7 +- .../editor/src/saas/routes/ResetPassword.tsx | 2 +- frontend/editor/src/saas/routes/Signup.tsx | 7 +- .../src/saas/routes/authShared/AuthLayout.tsx | 2 +- .../routes/authShared/GuestSignInButton.tsx | 2 +- .../saas/routes/login/EmailPasswordForm.tsx | 2 +- .../src/saas/routes/login/MagicLinkForm.tsx | 2 +- .../src/saas/routes/login/OAuthButtons.tsx | 10 +- frontend/portal/.env | 12 + .../public/locales/en-US/translation.toml | 43 ++ frontend/portal/src/App.tsx | 40 +- frontend/portal/src/api/http.ts | 11 + frontend/portal/src/auth/editorUrl.ts | 10 + frontend/portal/src/components/AuthGate.tsx | 61 +++ frontend/portal/src/components/Header.tsx | 29 +- .../portal/src/components/LoginScreen.tsx | 49 ++ frontend/portal/src/components/Sidebar.tsx | 6 +- frontend/portal/src/mocks/auth.ts | 52 ++ frontend/portal/src/mocks/browser.ts | 18 + frontend/portal/src/mocks/handlers/auth.ts | 38 ++ frontend/portal/src/mocks/handlers/index.ts | 2 + frontend/portal/src/mocks/preference.ts | 8 + frontend/portal/src/vite-env.d.ts | 12 + frontend/portal/vite.config.ts | 19 + frontend/scripts/dev-origin-proxy.ts | 246 +++++++++ frontend/scripts/tsconfig.json | 15 + .../assets/login}/AddToPDF.png | Bin .../assets/login}/Firstpage.png | Bin .../assets/login}/LoginBackgroundPanel.png | Bin .../assets/login}/LoginLightModeHeader.svg | 0 .../assets/login}/SecurePDF.png | Bin .../Login => shared/assets/login}/apple.svg | 0 .../assets/login}/authentik.svg | 0 .../assets/login}/cloudron.svg | 0 .../Login => shared/assets/login}/github.svg | 0 .../Login => shared/assets/login}/google.svg | 0 .../assets/login}/keycloak.svg | 0 .../assets/login}/microsoft.svg | 0 .../Login => shared/assets/login}/oidc.svg | 0 frontend/shared/auth/AuthProvider.tsx | 43 ++ frontend/shared/auth/config.ts | 54 ++ frontend/shared/auth/context.ts | 25 + frontend/shared/auth/guards/RequireAdmin.tsx | 42 ++ frontend/shared/auth/guards/RequireAuth.tsx | 26 + frontend/shared/auth/httpClient.ts | 70 +++ frontend/shared/auth/index.ts | 75 +++ frontend/shared/auth/roles.ts | 22 + frontend/shared/auth/spring/UseSession.tsx | 206 ++++++++ .../auth/spring}/oauthStorage.ts | 0 .../auth => shared/auth/spring}/oauthTypes.ts | 0 frontend/shared/auth/spring/platformBridge.ts | 70 +++ .../auth/spring}/springAuthClient.ts | 140 +++-- frontend/shared/auth/supabase/UseSession.tsx | 159 ++++++ .../shared/auth/supabase/supabaseClient.ts | 66 +++ frontend/shared/auth/types.ts | 79 +++ .../auth/ui/AuthShell.module.css} | 0 frontend/shared/auth/ui/AuthShell.tsx | 73 +++ .../auth/ui}/EmailPasswordForm.tsx | 17 +- .../login => shared/auth/ui}/ErrorMessage.tsx | 0 .../auth/ui}/LoginRightCarousel.tsx | 9 +- .../login => shared/auth/ui}/OAuthButtons.tsx | 16 +- frontend/shared/auth/ui/SpringLoginForm.tsx | 144 +++++ .../styles => shared/auth/ui}/auth-theme.css | 0 .../authShared => shared/auth/ui}/auth.css | 20 + frontend/shared/auth/ui/loginSlideText.ts | 56 ++ frontend/shared/auth/ui/loginSlides.ts | 23 + frontend/shared/auth/ui/oauthIcons.ts | 35 ++ frontend/shared/auth/ui/useSpringLogin.ts | 216 ++++++++ frontend/shared/i18n/translationAudit.ts | 54 +- frontend/shared/vite-env.d.ts | 14 +- 103 files changed, 2830 insertions(+), 977 deletions(-) delete mode 100644 frontend/editor/public/Login/azure.svg create mode 100644 frontend/editor/src/proprietary/auth/configureSpringAuth.ts create mode 100644 frontend/portal/.env create mode 100644 frontend/portal/src/auth/editorUrl.ts create mode 100644 frontend/portal/src/components/AuthGate.tsx create mode 100644 frontend/portal/src/components/LoginScreen.tsx create mode 100644 frontend/portal/src/mocks/auth.ts create mode 100644 frontend/portal/src/mocks/handlers/auth.ts create mode 100644 frontend/portal/src/vite-env.d.ts create mode 100644 frontend/scripts/dev-origin-proxy.ts create mode 100644 frontend/scripts/tsconfig.json rename frontend/{editor/public/Login => shared/assets/login}/AddToPDF.png (100%) rename frontend/{editor/public/Login => shared/assets/login}/Firstpage.png (100%) rename frontend/{editor/public/Login => shared/assets/login}/LoginBackgroundPanel.png (100%) rename frontend/{editor/public/modern-logo => shared/assets/login}/LoginLightModeHeader.svg (100%) rename frontend/{editor/public/Login => shared/assets/login}/SecurePDF.png (100%) rename frontend/{editor/public/Login => shared/assets/login}/apple.svg (100%) rename frontend/{editor/public/Login => shared/assets/login}/authentik.svg (100%) rename frontend/{editor/public/Login => shared/assets/login}/cloudron.svg (100%) rename frontend/{editor/public/Login => shared/assets/login}/github.svg (100%) rename frontend/{editor/public/Login => shared/assets/login}/google.svg (100%) rename frontend/{editor/public/Login => shared/assets/login}/keycloak.svg (100%) rename frontend/{editor/public/Login => shared/assets/login}/microsoft.svg (100%) rename frontend/{editor/public/Login => shared/assets/login}/oidc.svg (100%) create mode 100644 frontend/shared/auth/AuthProvider.tsx create mode 100644 frontend/shared/auth/config.ts create mode 100644 frontend/shared/auth/context.ts create mode 100644 frontend/shared/auth/guards/RequireAdmin.tsx create mode 100644 frontend/shared/auth/guards/RequireAuth.tsx create mode 100644 frontend/shared/auth/httpClient.ts create mode 100644 frontend/shared/auth/index.ts create mode 100644 frontend/shared/auth/roles.ts create mode 100644 frontend/shared/auth/spring/UseSession.tsx rename frontend/{editor/src/proprietary/auth => shared/auth/spring}/oauthStorage.ts (100%) rename frontend/{editor/src/proprietary/auth => shared/auth/spring}/oauthTypes.ts (100%) create mode 100644 frontend/shared/auth/spring/platformBridge.ts rename frontend/{editor/src/proprietary/auth => shared/auth/spring}/springAuthClient.ts (90%) create mode 100644 frontend/shared/auth/supabase/UseSession.tsx create mode 100644 frontend/shared/auth/supabase/supabaseClient.ts create mode 100644 frontend/shared/auth/types.ts rename frontend/{editor/src/proprietary/routes/authShared/AuthLayout.module.css => shared/auth/ui/AuthShell.module.css} (100%) create mode 100644 frontend/shared/auth/ui/AuthShell.tsx rename frontend/{editor/src/proprietary/routes/login => shared/auth/ui}/EmailPasswordForm.tsx (84%) rename frontend/{editor/src/proprietary/routes/login => shared/auth/ui}/ErrorMessage.tsx (100%) rename frontend/{editor/src/proprietary/components/shared => shared/auth/ui}/LoginRightCarousel.tsx (95%) rename frontend/{editor/src/proprietary/routes/login => shared/auth/ui}/OAuthButtons.tsx (94%) create mode 100644 frontend/shared/auth/ui/SpringLoginForm.tsx rename frontend/{editor/src/proprietary/styles => shared/auth/ui}/auth-theme.css (100%) rename frontend/{editor/src/proprietary/routes/authShared => shared/auth/ui}/auth.css (96%) create mode 100644 frontend/shared/auth/ui/loginSlideText.ts create mode 100644 frontend/shared/auth/ui/loginSlides.ts create mode 100644 frontend/shared/auth/ui/oauthIcons.ts create mode 100644 frontend/shared/auth/ui/useSpringLogin.ts diff --git a/.taskfiles/backend.yml b/.taskfiles/backend.yml index 8a7f6c622c..51ae93dc07 100644 --- a/.taskfiles/backend.yml +++ b/.taskfiles/backend.yml @@ -25,6 +25,7 @@ tasks: AIENGINE_URL: '{{.AIENGINE_URL}}' AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}' AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}' + SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN}}' dev:proprietary: desc: "Start backend dev server in proprietary mode" @@ -34,12 +35,13 @@ tasks: AIENGINE_URL: '{{.AIENGINE_URL | default ""}}' AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}' AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}' + SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN | default ""}}' env: SERVER_PORT: '{{.PORT}}' cmds: - - cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"' + - cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"' platforms: [windows] - - cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}./gradlew :stirling-pdf:bootRun' + - cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}./gradlew :stirling-pdf:bootRun' platforms: [linux, darwin] dev:bundled: diff --git a/.taskfiles/frontend.yml b/.taskfiles/frontend.yml index 472e38729f..bcc7c07362 100644 --- a/.taskfiles/frontend.yml +++ b/.taskfiles/frontend.yml @@ -130,9 +130,34 @@ tasks: dev:portal: desc: "Start developer portal dev server" + ignore_error: true deps: [install] + vars: + PORT: '{{.PORT | default "5173"}}' + BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}' + EDITOR_URL: '{{.EDITOR_URL | default ""}}' + OPEN: '{{.OPEN | default ""}}' + SUBPATH: '{{.SUBPATH | default ""}}' + MOCKS: '{{.MOCKS | default ""}}' + env: + BACKEND_URL: '{{.BACKEND_URL}}' cmds: - - npx vite portal --port {{.PORT | default "5173"}}{{if .OPEN}} --open{{end}} + - '{{if .SUBPATH}}RUN_SUBPATH={{.SUBPATH}} {{end}}{{if .MOCKS}}VITE_PORTAL_MOCKS={{.MOCKS}} {{end}}{{if .EDITOR_URL}}VITE_EDITOR_URL={{.EDITOR_URL}} {{end}}npx vite portal --port {{.PORT}}{{if .OPEN}} --open{{end}}' + + dev:portal:proxy:serve: + internal: true + vars: + PORT: '{{.PORT | default "3000"}}' + BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}' + EDITOR_DEV_URL: '{{.EDITOR_DEV_URL | default ""}}' + PORTAL_DEV_URL: '{{.PORTAL_DEV_URL | default ""}}' + env: + PORT: '{{.PORT}}' + BACKEND_URL: '{{.BACKEND_URL}}' + EDITOR_DEV_URL: '{{.EDITOR_DEV_URL}}' + PORTAL_DEV_URL: '{{.PORTAL_DEV_URL}}' + cmds: + - npx tsx scripts/dev-origin-proxy.ts # ============================================================ # Build @@ -153,8 +178,10 @@ tasks: build:proprietary: desc: "Build for proprietary mode" deps: [prepare] + vars: + PREVIEW: '{{.PREVIEW | default ""}}' cmds: - - npx vite build editor --mode proprietary + - '{{if .PREVIEW}}VITE_BUILD_FOR_PREVIEW=1 {{end}}npx vite build editor --mode proprietary' build:saas: desc: "Build for SaaS mode" @@ -181,8 +208,26 @@ tasks: build:portal: desc: "Build developer portal" deps: [install] + vars: + SUBPATH: '{{.SUBPATH | default ""}}' cmds: - - npx vite build portal + - '{{if .SUBPATH}}RUN_SUBPATH={{.SUBPATH}} {{end}}npx vite build portal' + + preview:portal:proxy: + desc: "Build + serve editor + portal behind one origin (prod-like auth testing)" + deps: [prepare] + vars: + PORT: '{{.PORT | default "3000"}}' + BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}' + env: + PORT: '{{.PORT}}' + BACKEND_URL: '{{.BACKEND_URL}}' + cmds: + - task: build:proprietary + vars: { PREVIEW: '1' } + - task: build:portal + vars: { SUBPATH: portal } + - npx tsx scripts/dev-origin-proxy.ts storybook: desc: "Start Storybook dev server" @@ -288,6 +333,7 @@ tasks: desc: "Typecheck scripts" deps: [prepare] cmds: + - npx tsc --noEmit --project scripts/tsconfig.json - npx tsc --noEmit --project editor/scripts/tsconfig.json typecheck:prototypes: diff --git a/Taskfile.yml b/Taskfile.yml index ac4160182a..2c776f7ad1 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -78,6 +78,80 @@ tasks: BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' OPEN: "true" + dev:portal: + desc: "Start backend + developer portal concurrently on free ports" + vars: + PORTS: + sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173{{end}}' + BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}' + PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}' + deps: + - task: backend:dev + vars: + PORT: '{{.BACKEND_PORT}}' + SECURITY_ENABLELOGIN: "true" + - task: frontend:dev:portal + vars: + PORT: '{{.PORTAL_PORT}}' + BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' + OPEN: "true" + + dev:portal:all: + desc: "Start backend + developer portal + editor concurrently on free ports" + vars: + PORTS: + sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5174{{end}}' + BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}' + PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}' + EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}' + deps: + - task: backend:dev + vars: + PORT: '{{.BACKEND_PORT}}' + SECURITY_ENABLELOGIN: "true" + - task: frontend:dev:portal + vars: + PORT: '{{.PORTAL_PORT}}' + BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' + # Point the portal's "Editor" app switcher at the editor we spawn here. + EDITOR_URL: 'http://localhost:{{.EDITOR_PORT}}/' + OPEN: "true" + - task: frontend:dev + vars: + PORT: '{{.EDITOR_PORT}}' + BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' + + dev:portal:proxy: + desc: "Editor + portal on ONE origin + backend via live dev servers (shared-token login)" + vars: + PORTS: + sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 3000 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 3000 5173 5174{{end}}' + BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}' + PROXY_PORT: '{{index (splitList "\n" .PORTS) 1}}' + EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}' + PORTAL_PORT: '{{index (splitList "\n" .PORTS) 3}}' + deps: + - task: backend:dev + vars: + PORT: '{{.BACKEND_PORT}}' + SECURITY_ENABLELOGIN: "true" + - task: frontend:dev:proprietary + vars: + PORT: '{{.EDITOR_PORT}}' + BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' + - task: frontend:dev:portal + vars: + PORT: '{{.PORTAL_PORT}}' + BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' + SUBPATH: portal + MOCKS: 'false' + - task: frontend:dev:portal:proxy:serve + vars: + PORT: '{{.PROXY_PORT}}' + BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' + EDITOR_DEV_URL: 'http://localhost:{{.EDITOR_PORT}}' + PORTAL_DEV_URL: 'http://localhost:{{.PORTAL_PORT}}' + dev:saas: desc: "Start SaaS backend + frontend concurrently on free ports" cmds: @@ -124,6 +198,23 @@ tasks: - task: backend:build - task: frontend:build + preview:portal:proxy: + desc: "Build + serve editor + portal on ONE origin + backend (prod-like auth test)" + vars: + PORTS: + sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 3000{{else}}{{.FIND_FREE_PORT_SH}} 8080 3000{{end}}' + BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}' + PROXY_PORT: '{{index (splitList "\n" .PORTS) 1}}' + deps: + - task: backend:dev + vars: + PORT: '{{.BACKEND_PORT}}' + SECURITY_ENABLELOGIN: "true" + - task: frontend:preview:portal:proxy + vars: + PORT: '{{.PROXY_PORT}}' + BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}' + # ============================================================ # Test # ============================================================ diff --git a/frontend/editor/public/Login/azure.svg b/frontend/editor/public/Login/azure.svg deleted file mode 100644 index fc1130cbb2..0000000000 --- a/frontend/editor/public/Login/azure.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/frontend/editor/src/core/components/shared/config/configSections/ProviderCard.tsx b/frontend/editor/src/core/components/shared/config/configSections/ProviderCard.tsx index ca6744ccc9..bb157558ec 100644 --- a/frontend/editor/src/core/components/shared/config/configSections/ProviderCard.tsx +++ b/frontend/editor/src/core/components/shared/config/configSections/ProviderCard.tsx @@ -191,8 +191,9 @@ export default function ProviderCard({ }; const renderProviderIcon = () => { - // If icon starts with '/', it's a path to an SVG file - if (provider.icon.startsWith("/")) { + // Image source: an absolute/relative path, a data: URI (small bundled SVGs + // are inlined), or a full URL. Iconify names ("key-rounded") use LocalIcon. + if (/^(\/|\.\.?\/|data:|blob:|https?:)/.test(provider.icon)) { return ( { return { id: "google", name: "Google", - icon: "/Login/google.svg", + icon: oauthIconUrl("google.svg"), type: "oauth2", scope: t("provider.oauth2.google.scope", "Sign-in authentication"), documentationUrl: @@ -86,7 +87,7 @@ const useGitHubProvider = (): Provider => { return { id: "github", name: "GitHub", - icon: "/Login/github.svg", + icon: oauthIconUrl("github.svg"), type: "oauth2", scope: t("provider.oauth2.github.scope", "Sign-in authentication"), documentationUrl: diff --git a/frontend/editor/src/desktop/components/SetupWizard/DesktopAuthLayout.tsx b/frontend/editor/src/desktop/components/SetupWizard/DesktopAuthLayout.tsx index 70876ca3ef..ad783e9f76 100644 --- a/frontend/editor/src/desktop/components/SetupWizard/DesktopAuthLayout.tsx +++ b/frontend/editor/src/desktop/components/SetupWizard/DesktopAuthLayout.tsx @@ -1,8 +1,8 @@ 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 styles from "@shared/auth/ui/AuthShell.module.css"; import { useLogoVariant } from "@app/hooks/useLogoVariant"; interface DesktopAuthLayoutProps { diff --git a/frontend/editor/src/desktop/components/SetupWizard/DesktopOAuthButtons.tsx b/frontend/editor/src/desktop/components/SetupWizard/DesktopOAuthButtons.tsx index 02d4d2008d..c2da65845b 100644 --- a/frontend/editor/src/desktop/components/SetupWizard/DesktopOAuthButtons.tsx +++ b/frontend/editor/src/desktop/components/SetupWizard/DesktopOAuthButtons.tsx @@ -2,7 +2,7 @@ import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import { authService, UserInfo } from "@app/services/authService"; import { buildOAuthCallbackHtml } from "@app/utils/oauthCallbackHtml"; -import { BASE_PATH } from "@app/constants/app"; +import { oauthIconUrl } from "@shared/auth/ui/oauthIcons"; import { STIRLING_SAAS_URL } from "@app/constants/connection"; import "@app/components/SetupWizard/desktopOAuth.css"; @@ -159,7 +159,9 @@ export const DesktopOAuthButtons: React.FC = ({ {label} diff --git a/frontend/editor/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx b/frontend/editor/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx index 3c02362b76..7f679052a3 100644 --- a/frontend/editor/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx +++ b/frontend/editor/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx @@ -1,13 +1,13 @@ import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import LoginHeader from "@app/routes/login/LoginHeader"; -import ErrorMessage from "@app/routes/login/ErrorMessage"; -import EmailPasswordForm from "@app/routes/login/EmailPasswordForm"; +import ErrorMessage from "@shared/auth/ui/ErrorMessage"; +import EmailPasswordForm from "@shared/auth/ui/EmailPasswordForm"; import DividerWithText from "@app/components/shared/DividerWithText"; import { DesktopOAuthButtons } from "@app/components/SetupWizard/DesktopOAuthButtons"; import { SelfHostedLink } from "@app/components/SetupWizard/SelfHostedLink"; import { UserInfo } from "@app/services/authService"; -import "@app/routes/authShared/auth.css"; +import "@shared/auth/ui/auth.css"; interface SaaSLoginScreenProps { serverUrl: string; diff --git a/frontend/editor/src/desktop/components/SetupWizard/SaaSSignupScreen.tsx b/frontend/editor/src/desktop/components/SetupWizard/SaaSSignupScreen.tsx index e13406f40c..bcc7bf88c7 100644 --- a/frontend/editor/src/desktop/components/SetupWizard/SaaSSignupScreen.tsx +++ b/frontend/editor/src/desktop/components/SetupWizard/SaaSSignupScreen.tsx @@ -1,14 +1,14 @@ import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import LoginHeader from "@app/routes/login/LoginHeader"; -import ErrorMessage from "@app/routes/login/ErrorMessage"; +import ErrorMessage from "@shared/auth/ui/ErrorMessage"; import SignupForm from "@app/routes/signup/SignupForm"; import { useSignupFormValidation, SignupFieldErrors, } from "@app/routes/signup/SignupFormValidation"; import { authService } from "@app/services/authService"; -import "@app/routes/authShared/auth.css"; +import "@shared/auth/ui/auth.css"; interface SaaSSignupScreenProps { loading: boolean; diff --git a/frontend/editor/src/desktop/components/SetupWizard/SelfHostedLink.tsx b/frontend/editor/src/desktop/components/SetupWizard/SelfHostedLink.tsx index 1ef652a310..739c02516d 100644 --- a/frontend/editor/src/desktop/components/SetupWizard/SelfHostedLink.tsx +++ b/frontend/editor/src/desktop/components/SetupWizard/SelfHostedLink.tsx @@ -1,6 +1,6 @@ import React from "react"; import { useTranslation } from "react-i18next"; -import "@app/routes/authShared/auth.css"; +import "@shared/auth/ui/auth.css"; interface SelfHostedLinkProps { onClick: () => void; diff --git a/frontend/editor/src/desktop/components/SetupWizard/SelfHostedLoginScreen.tsx b/frontend/editor/src/desktop/components/SetupWizard/SelfHostedLoginScreen.tsx index db4ca7cc51..ff2d743809 100644 --- a/frontend/editor/src/desktop/components/SetupWizard/SelfHostedLoginScreen.tsx +++ b/frontend/editor/src/desktop/components/SetupWizard/SelfHostedLoginScreen.tsx @@ -2,13 +2,13 @@ import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import { Text } from "@mantine/core"; import LoginHeader from "@app/routes/login/LoginHeader"; -import ErrorMessage from "@app/routes/login/ErrorMessage"; -import EmailPasswordForm from "@app/routes/login/EmailPasswordForm"; +import ErrorMessage from "@shared/auth/ui/ErrorMessage"; +import EmailPasswordForm from "@shared/auth/ui/EmailPasswordForm"; import DividerWithText from "@app/components/shared/DividerWithText"; import { DesktopOAuthButtons } from "@app/components/SetupWizard/DesktopOAuthButtons"; import { UserInfo } from "@app/services/authService"; import { SSOProviderConfig } from "@app/services/connectionModeService"; -import "@app/routes/authShared/auth.css"; +import "@shared/auth/ui/auth.css"; interface SelfHostedLoginScreenProps { serverUrl: string; diff --git a/frontend/editor/src/desktop/components/SetupWizard/ServerSelectionScreen.tsx b/frontend/editor/src/desktop/components/SetupWizard/ServerSelectionScreen.tsx index 289fbf5c32..7ddf7b3cb2 100644 --- a/frontend/editor/src/desktop/components/SetupWizard/ServerSelectionScreen.tsx +++ b/frontend/editor/src/desktop/components/SetupWizard/ServerSelectionScreen.tsx @@ -1,10 +1,10 @@ import React from "react"; import { useTranslation } from "react-i18next"; import LoginHeader from "@app/routes/login/LoginHeader"; -import ErrorMessage from "@app/routes/login/ErrorMessage"; +import ErrorMessage from "@shared/auth/ui/ErrorMessage"; import { ServerSelection } from "@app/components/SetupWizard/ServerSelection"; import { ServerConfig } from "@app/services/connectionModeService"; -import "@app/routes/authShared/auth.css"; +import "@shared/auth/ui/auth.css"; interface ServerSelectionScreenProps { onSelect: (config: ServerConfig) => void; diff --git a/frontend/editor/src/desktop/components/SetupWizard/index.tsx b/frontend/editor/src/desktop/components/SetupWizard/index.tsx index a3a2595131..e218f06e0c 100644 --- a/frontend/editor/src/desktop/components/SetupWizard/index.tsx +++ b/frontend/editor/src/desktop/components/SetupWizard/index.tsx @@ -19,7 +19,7 @@ import { import { tauriBackendService } from "@app/services/tauriBackendService"; import { STIRLING_SAAS_URL } from "@app/constants/connection"; import { listen } from "@tauri-apps/api/event"; -import "@app/routes/authShared/auth.css"; +import "@shared/auth/ui/auth.css"; import { DisabledButtonWithTooltip } from "@app/components/shared/DisabledButtonWithTooltip"; enum SetupStep { diff --git a/frontend/editor/src/proprietary/App.tsx b/frontend/editor/src/proprietary/App.tsx index 8373391b5a..4f231e81c6 100644 --- a/frontend/editor/src/proprietary/App.tsx +++ b/frontend/editor/src/proprietary/App.tsx @@ -21,7 +21,7 @@ import { WATCHED_FOLDERS_ENABLED } from "@app/constants/featureFlags"; 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/proprietary/auth/UseSession.test.ts b/frontend/editor/src/proprietary/auth/UseSession.test.ts index 86285dd6ac..eb4dc4bba0 100644 --- a/frontend/editor/src/proprietary/auth/UseSession.test.ts +++ b/frontend/editor/src/proprietary/auth/UseSession.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import type { TFunction } from "i18next"; -import type { User } from "@app/auth/springAuthClient"; +import type { User } from "@shared/auth/spring/springAuthClient"; import { deriveDisplayName } from "@app/auth/UseSession"; // Stub t() that returns the fallback string. The real i18next instance diff --git a/frontend/editor/src/proprietary/auth/UseSession.tsx b/frontend/editor/src/proprietary/auth/UseSession.tsx index 00532ff629..4908ec61c5 100644 --- a/frontend/editor/src/proprietary/auth/UseSession.tsx +++ b/frontend/editor/src/proprietary/auth/UseSession.tsx @@ -1,341 +1,50 @@ -import { - createContext, - useContext, - useEffect, - useState, - ReactNode, - useCallback, -} from "react"; +import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; -import { springAuth } from "@app/auth/springAuthClient"; -import { clearPlatformAuthOnLoginInit } from "@app/extensions/authSessionCleanup"; -import { stripBasePath } from "@app/constants/app"; -import type { - Session, - User, - AuthError, - AuthChangeEvent, -} from "@app/auth/springAuthClient"; +import { + SpringAuthProvider, + deriveDisplayName as deriveDisplayNameShared, +} from "@shared/auth/spring/UseSession"; +import { useAuth as useSharedAuth } from "@shared/auth/context"; +import type { AuthUser } from "@shared/auth/types"; +// Side-effect import: wires the editor's transport + platform seams into the +// shared Spring engine before AppProviders mounts the provider below. +import "@app/auth/configureSpringAuth"; + +export type { AuthUser as User } from "@shared/auth/types"; /** - * Auth Context Type - * Simplified version without SaaS-specific features (credits, subscriptions) - */ -interface AuthContextType { - session: Session | null; - user: User | null; - /** - * Human-readable name to show in the UI for the current session. - * - A real identity (username/email) when the user is signed in. - * - The localised "User" placeholder for anonymous sessions - * (proprietary's chosen label - see deriveDisplayName). - * - null only when there is no user object at all (signed-out), so - * consumers can fall back to whatever makes sense. - */ - displayName: string | null; - /** Whether the current session is an anonymous (login-disabled) one. */ - isAnonymous: boolean; - loading: boolean; - error: AuthError | null; - signOut: () => Promise; - refreshSession: () => Promise; -} - -/** - * Derive a display name from the Spring user. Anonymous users get the - * localised "User" placeholder (proprietary's chosen label for unsigned-in - * sessions); returns null only when there is no user object at all so - * consumers can pick their own fallback. - * - * Exported for unit testing. + * Editor display-name helper. Keeps the i18next `TFunction` signature the + * editor's components and tests rely on, delegating to the shared + * implementation for the actual logic (anonymous placeholder vs username/email). */ export function deriveDisplayName( - user: User | null | undefined, + user: AuthUser | null | undefined, t: TFunction, ): string | null { - if (!user) return null; - if (user.is_anonymous) return t("auth.displayName.user", "User"); - return user.username || user.email || null; + return deriveDisplayNameShared(user, (key, fallback) => t(key, fallback)); } -const AuthContext = createContext({ - session: null, - user: null, - displayName: null, - isAnonymous: false, - loading: true, - error: null, - signOut: async () => {}, - refreshSession: async () => {}, -}); - /** - * Auth Provider Component - * - * Manages authentication state and provides it to the entire app. - * Integrates with Spring Security + JWT backend. + * Auth Provider for the editor. Wraps the shared Spring provider and feeds it an + * i18next-backed translate function so the localised "User" placeholder still + * works for anonymous sessions. */ export function AuthProvider({ children }: { children: ReactNode }) { - 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]); - - /** - * Refresh current session - */ - const refreshSession = useCallback(async () => { - try { - setLoading(true); - setError(null); - console.debug("[Auth] refreshSession: start", { - path: window.location.pathname, - }); - console.debug("[Auth] Refreshing session..."); - - const { data, error } = await springAuth.refreshSession(); - - if (error) { - console.error("[Auth] Session refresh error:", error); - setError(error); - setSession(null); - } else { - console.debug("[Auth] Session refreshed successfully"); - setSession(data.session); - } - } catch (err) { - console.error("[Auth] Unexpected error during session refresh:", err); - setError(err as AuthError); - } finally { - console.debug("[Auth] refreshSession: done", { hasSession: !!session }); - setLoading(false); - } - }, []); - - /** - * Sign out user - */ - const signOut = useCallback(async () => { - try { - setError(null); - console.debug("[Auth] Signing out..."); - - 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. Leaving session - // populated on error would mean the UI keeps the old user's badge until - // a manual reload (the SIGNED_OUT notifyListeners call also covers this - // path now, but clearing here is defence in depth). - setSession(null); - - if (error) { - console.error("[Auth] Sign out error:", error); - setError(error); - } else { - console.debug("[Auth] Signed out successfully"); - } - } catch (err) { - console.error("[Auth] Unexpected error during sign out:", err); - setSession(null); - setError(err as AuthError); - } - }, []); - - /** - * Initialize auth on mount - */ - useEffect(() => { - let mounted = true; - const mountId = Math.random().toString(36).substring(7); - console.log(`[Auth:${mountId}] πŸ”΅ AuthProvider mounted`); - - const initializeAuth = async () => { - try { - console.debug(`[Auth:${mountId}] Initializing auth...`); - console.debug( - `[Auth:${mountId}] Path: ${window.location.pathname} Search: ${window.location.search}`, - ); - // Clear any platform-specific cached auth on login page init. - if ( - typeof window !== "undefined" && - stripBasePath(window.location.pathname).startsWith("/login") - ) { - await clearPlatformAuthOnLoginInit(); - } - - // Skip config check entirely - let the app handle login state - // The config will be fetched by useAppConfig when needed - const { data, error } = await springAuth.getSession(); - - if (!mounted) return; - - if (error) { - console.error("[Auth] Initial session error:", error); - setError(error); - } else { - console.debug("[Auth] Initial session loaded:", { - hasSession: !!data.session, - userId: data.session?.user?.id, - email: data.session?.user?.email, - }); - setSession(data.session); - } - } catch (err) { - console.error( - "[Auth] Unexpected error during auth initialization:", - err, - ); - if (mounted) { - setError(err as AuthError); - } - } finally { - console.debug( - `[Auth:${mountId}] Initialize auth complete. mounted=${mounted}`, - ); - if (mounted) { - setLoading(false); - } - } - }; - - initializeAuth(); - - // Listen for jwt-available event (triggered by desktop auth or other sources) - const handleJwtAvailable = () => { - console.log(`[Auth:${mountId}] ════════════════════════════════════`); - console.log(`[Auth:${mountId}] πŸ”„ JWT available event received`); - console.log( - `[Auth:${mountId}] Current state: loading=${loading}, hasSession=${!!session}`, - ); - console.log( - `[Auth:${mountId}] Setting loading=true to stabilize auth state`, - ); - setLoading(true); // Prevent unstable renders during auth state transition - setError(null); - console.log(`[Auth:${mountId}] Refreshing session...`); - void initializeAuth(); - }; - - window.addEventListener("jwt-available", handleJwtAvailable); - - // Subscribe to auth state changes - const { - data: { subscription }, - } = springAuth.onAuthStateChange( - async (event: AuthChangeEvent, newSession: Session | null) => { - if (!mounted) { - console.log( - `[Auth:${mountId}] ⚠️ Auth state change ignored (unmounted): ${event}`, - ); - return; - } - - console.log(`[Auth:${mountId}] ════════════════════════════════════`); - console.log(`[Auth:${mountId}] πŸ“’ Auth state change event: ${event}`); - console.log(`[Auth:${mountId}] Has session: ${!!newSession}`); - console.log( - `[Auth:${mountId}] User: ${newSession?.user?.email || "none"}`, - ); - console.log(`[Auth:${mountId}] Timestamp: ${new Date().toISOString()}`); - - // Schedule state update - setTimeout(() => { - if (mounted) { - console.log( - `[Auth:${mountId}] Applying session update (event: ${event})`, - ); - setSession(newSession); - setError(null); - - // Handle specific events - if (event === "SIGNED_OUT") { - console.log( - `[Auth:${mountId}] βœ“ User signed out, session cleared`, - ); - } else if (event === "SIGNED_IN") { - console.log(`[Auth:${mountId}] βœ“ User signed in successfully`); - } else if (event === "TOKEN_REFRESHED") { - console.log(`[Auth:${mountId}] βœ“ Token refreshed`); - } else if (event === "USER_UPDATED") { - console.log(`[Auth:${mountId}] βœ“ User updated`); - } - } else { - console.log( - `[Auth:${mountId}] ⚠️ Session update skipped (unmounted during timeout)`, - ); - } - }, 0); - }, - ); - - return () => { - console.log(`[Auth:${mountId}] πŸ”΄ AuthProvider unmounting`); - mounted = false; - window.removeEventListener("jwt-available", handleJwtAvailable); - subscription.unsubscribe(); - }; - }, []); - const { t } = useTranslation(); - const user = session?.user ?? null; - const value: AuthContextType = { - session, - user, - displayName: deriveDisplayName(user, t), - isAnonymous: user?.is_anonymous === true, - loading, - error, - signOut, - refreshSession, - }; - - return {children}; + return ( + t(key, fallback)}> + {children} + + ); } -/** - * Hook to access auth context - * Must be used within AuthProvider - */ +/** Hook to access auth context. Must be used within AuthProvider. */ export function useAuth() { - const context = useContext(AuthContext); - - if (context === undefined) { - throw new Error("useAuth must be used within an AuthProvider"); - } - - return context; + return useSharedAuth(); } -/** - * Debug hook to expose auth state for debugging - * Can be used in development to monitor auth state - */ +/** Debug alias kept for backwards compatibility with existing callers. */ export function useAuthDebug() { - const auth = useAuth(); - - useEffect(() => { - console.debug("[Auth Debug] Current auth state:", { - hasSession: !!auth.session, - hasUser: !!auth.user, - loading: auth.loading, - hasError: !!auth.error, - userId: auth.user?.id, - email: auth.user?.email, - }); - }, [auth.session, auth.user, auth.loading, auth.error]); - - return auth; + return useSharedAuth(); } diff --git a/frontend/editor/src/proprietary/auth/configureSpringAuth.ts b/frontend/editor/src/proprietary/auth/configureSpringAuth.ts new file mode 100644 index 0000000000..7998d58921 --- /dev/null +++ b/frontend/editor/src/proprietary/auth/configureSpringAuth.ts @@ -0,0 +1,44 @@ +/** + * Wires the editor's transport + platform seams into the shared Spring auth + * engine. Import this module for its side effect (it configures the engine on + * load) before any auth call runs - AppProviders does so via UseSession. + * + * The `@app/*` imports resolve per build flavor: proprietary/web gets the no-op + * web defaults, the desktop build gets the Tauri-backed implementations. So the + * desktop and web auth behaviour is unchanged by the move to the shared engine. + */ +import type { AxiosInstance } from "axios"; +import apiClient from "@app/services/apiClient"; +import { BASE_PATH } from "@app/constants/app"; +import { configureSpringAuth } from "@shared/auth/config"; +import { + clearPlatformAuthAfterSignOut, + clearPlatformAuthOnLoginInit, +} from "@app/extensions/authSessionCleanup"; +import { + getPlatformSessionUser, + isDesktopSaaSAuthMode, + refreshPlatformSession, + savePlatformToken, + shouldCallBackendLogout, +} from "@app/extensions/platformSessionBridge"; +import { startOAuthNavigation } from "@app/extensions/oauthNavigation"; + +configureSpringAuth({ + // The desktop build resolves @app/services/apiClient to a TauriHttpClient, + // which is API-compatible with axios but not nominally an AxiosInstance - + // matches the existing `as unknown as AxiosInstance` bridge in + // desktop/services/apiClient.ts. Harmless no-op for the web (axios) build. + http: apiClient as unknown as AxiosInstance, + basePath: BASE_PATH, + platform: { + clearPlatformAuthAfterSignOut, + clearPlatformAuthOnLoginInit, + isDesktopSaaSAuthMode, + shouldCallBackendLogout, + getPlatformSessionUser, + refreshPlatformSession, + savePlatformToken, + startOAuthNavigation, + }, +}); diff --git a/frontend/editor/src/proprietary/auth/springAuthClient.test.ts b/frontend/editor/src/proprietary/auth/springAuthClient.test.ts index d64ac49476..5eab014103 100644 --- a/frontend/editor/src/proprietary/auth/springAuthClient.test.ts +++ b/frontend/editor/src/proprietary/auth/springAuthClient.test.ts @@ -5,9 +5,12 @@ import { POST_LOGIN_REDIRECT_STORAGE_KEY, setPostLoginRedirectPath, springAuth, -} from "@app/auth/springAuthClient"; +} from "@shared/auth/spring/springAuthClient"; import { startOAuthNavigation } from "@app/extensions/oauthNavigation"; import apiClient from "@app/services/apiClient"; +// Side-effect: configures the shared Spring engine with the (mocked) apiClient +// + oauthNavigation seam, so springAuth routes through the mocks below. +import "@app/auth/configureSpringAuth"; import { allowConsole, expectConsole } from "@app/tests/failOnConsole"; import { AxiosError, diff --git a/frontend/editor/src/proprietary/components/shared/loginSlides.ts b/frontend/editor/src/proprietary/components/shared/loginSlides.ts index da333eff10..e181c1a538 100644 --- a/frontend/editor/src/proprietary/components/shared/loginSlides.ts +++ b/frontend/editor/src/proprietary/components/shared/loginSlides.ts @@ -2,68 +2,27 @@ import { BASE_PATH } from "@app/constants/app"; import { getLogoFolder } from "@app/constants/logo"; import type { LogoVariant } from "@app/services/preferencesService"; import type { TFunction } from "i18next"; +import { loginSlideText } from "@shared/auth/ui/loginSlideText"; +import type { ImageSlide } from "@shared/auth/ui/LoginRightCarousel"; +import addToPdf from "@shared/assets/login/AddToPDF.png"; +import securePdf from "@shared/assets/login/SecurePDF.png"; -export type LoginCarouselSlide = { - src: string; - alt?: string; - title?: string; - subtitle?: string; - cornerModelUrl?: string; - followMouseTilt?: boolean; - tiltMaxDeg?: number; -}; +const SLIDE_TILT = { followMouseTilt: true, tiltMaxDeg: 5 } as const; +/** + * Editor login carousel slides. Copy comes from the shared set (so the editor + * and portal carousels stay in sync) and the edit/secure images are the shared + * bundled assets; only the hero is the logo-variant image the editor serves + * from /public. + */ export const buildLoginSlides = ( variant: LogoVariant | null | undefined, t: TFunction, -): LoginCarouselSlide[] => { +): ImageSlide[] => { const folder = getLogoFolder(variant); - const heroImage = `${BASE_PATH}/${folder}/Firstpage.png`; - - return [ - { - src: heroImage, - alt: t("login.slides.overview.alt", "Stirling PDF overview"), - title: t( - "login.slides.overview.title", - "Your one-stop-shop for all your PDF needs.", - ), - subtitle: t( - "login.slides.overview.subtitle", - "A privacy-first cloud suite for PDFs that lets you convert, sign, redact, and manage documents, along with 50+ other powerful tools.", - ), - followMouseTilt: true, - tiltMaxDeg: 5, - }, - { - src: `${BASE_PATH}/Login/AddToPDF.png`, - alt: t("login.slides.edit.alt", "Edit PDFs"), - title: t( - "login.slides.edit.title", - "Edit PDFs to display/secure the information you want", - ), - subtitle: t( - "login.slides.edit.subtitle", - "With over a dozen tools to help you redact, sign, read and manipulate PDFs, you will be sure to find what you are looking for.", - ), - followMouseTilt: true, - tiltMaxDeg: 5, - }, - { - src: `${BASE_PATH}/Login/SecurePDF.png`, - alt: t("login.slides.secure.alt", "Secure PDFs"), - title: t( - "login.slides.secure.title", - "Protect sensitive information in your PDFs", - ), - subtitle: t( - "login.slides.secure.subtitle", - "Add passwords, redact content, and manage certificates with ease.", - ), - followMouseTilt: true, - tiltMaxDeg: 5, - }, - ]; + const text = loginSlideText((key, fallback) => t(key, fallback)); + const srcs = [`${BASE_PATH}/${folder}/Firstpage.png`, addToPdf, securePdf]; + return srcs.map((src, i) => ({ src, ...text[i], ...SLIDE_TILT })); }; export default buildLoginSlides; diff --git a/frontend/editor/src/proprietary/routes/AuthCallback.test.tsx b/frontend/editor/src/proprietary/routes/AuthCallback.test.tsx index 4491358381..79dc50cbaa 100644 --- a/frontend/editor/src/proprietary/routes/AuthCallback.test.tsx +++ b/frontend/editor/src/proprietary/routes/AuthCallback.test.tsx @@ -5,14 +5,14 @@ import AuthCallback from "@app/routes/AuthCallback"; import { POST_LOGIN_REDIRECT_STORAGE_KEY, springAuth, -} from "@app/auth/springAuthClient"; +} from "@shared/auth/spring/springAuthClient"; import { expectConsole } from "@app/tests/failOnConsole"; // Mock springAuth; keep the real redirect-path helpers. -vi.mock("@app/auth/springAuthClient", async () => { +vi.mock("@shared/auth/spring/springAuthClient", async () => { const actual = await vi.importActual< - typeof import("@app/auth/springAuthClient") - >("@app/auth/springAuthClient"); + typeof import("@shared/auth/spring/springAuthClient") + >("@shared/auth/spring/springAuthClient"); return { ...actual, springAuth: { diff --git a/frontend/editor/src/proprietary/routes/AuthCallback.tsx b/frontend/editor/src/proprietary/routes/AuthCallback.tsx index fd2754ee88..21a5ea5cb2 100644 --- a/frontend/editor/src/proprietary/routes/AuthCallback.tsx +++ b/frontend/editor/src/proprietary/routes/AuthCallback.tsx @@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom"; import { consumePostLoginRedirectPath, springAuth, -} from "@app/auth/springAuthClient"; +} from "@shared/auth/spring/springAuthClient"; import { handleAuthCallbackSuccess } from "@app/extensions/authCallback"; import styles from "@app/routes/AuthCallback.module.css"; diff --git a/frontend/editor/src/proprietary/routes/InviteAccept.tsx b/frontend/editor/src/proprietary/routes/InviteAccept.tsx index a5a0af9c14..af9c6ba2ba 100644 --- a/frontend/editor/src/proprietary/routes/InviteAccept.tsx +++ b/frontend/editor/src/proprietary/routes/InviteAccept.tsx @@ -15,7 +15,7 @@ import { import { useDocumentMeta } from "@app/hooks/useDocumentMeta"; import AuthLayout from "@app/routes/authShared/AuthLayout"; import LoginHeader from "@app/routes/login/LoginHeader"; -import ErrorMessage from "@app/routes/login/ErrorMessage"; +import ErrorMessage from "@shared/auth/ui/ErrorMessage"; import { BASE_PATH } from "@app/constants/app"; import apiClient from "@app/services/apiClient"; diff --git a/frontend/editor/src/proprietary/routes/Login.test.tsx b/frontend/editor/src/proprietary/routes/Login.test.tsx index ea3c8cfbd4..c83b9c500c 100644 --- a/frontend/editor/src/proprietary/routes/Login.test.tsx +++ b/frontend/editor/src/proprietary/routes/Login.test.tsx @@ -5,9 +5,11 @@ import { BrowserRouter, MemoryRouter } from "react-router-dom"; import { MantineProvider } from "@mantine/core"; import Login from "@app/routes/Login"; import { useAuth } from "@app/auth/UseSession"; -import { springAuth } from "@app/auth/springAuthClient"; +import { springAuth } from "@shared/auth/spring/springAuthClient"; import { PreferencesProvider } from "@app/contexts/PreferencesContext"; import apiClient from "@app/services/apiClient"; +import { configureSpringAuth } from "@shared/auth/config"; +import type { AxiosInstance } from "axios"; // Mock i18n to return fallback text vi.mock("react-i18next", () => ({ @@ -41,10 +43,10 @@ vi.mock("@app/auth/UseSession", () => ({ })); // Mock springAuth; keep the real redirect-path helpers. -vi.mock("@app/auth/springAuthClient", async () => { +vi.mock("@shared/auth/spring/springAuthClient", async () => { const actual = await vi.importActual< - typeof import("@app/auth/springAuthClient") - >("@app/auth/springAuthClient"); + typeof import("@shared/auth/spring/springAuthClient") + >("@shared/auth/spring/springAuthClient"); return { ...actual, springAuth: { @@ -111,6 +113,8 @@ describe("Login", () => { user: null, displayName: null, isAnonymous: false, + isAdmin: false, + role: null, loading: false, error: null, signOut: vi.fn(), @@ -124,6 +128,11 @@ describe("Login", () => { providerList: {}, }, }); + + // The shared login hook reads getSpringAuthConfig().http; in the real app, + // startup points that at apiClient. Mirror that here so the mocked apiClient + // serves the login-ui-data fetch. + configureSpringAuth({ http: apiClient as unknown as AxiosInstance }); }); it("should render login form", async () => { @@ -159,6 +168,8 @@ describe("Login", () => { user: mockSession.user, displayName: mockSession.user.username, isAnonymous: false, + isAdmin: false, + role: mockSession.user.role, loading: false, error: null, signOut: vi.fn(), @@ -184,6 +195,8 @@ describe("Login", () => { user: null, displayName: null, isAnonymous: false, + isAdmin: false, + role: null, loading: true, error: null, signOut: vi.fn(), diff --git a/frontend/editor/src/proprietary/routes/Login.tsx b/frontend/editor/src/proprietary/routes/Login.tsx index c002aee1fd..86bedb1934 100644 --- a/frontend/editor/src/proprietary/routes/Login.tsx +++ b/frontend/editor/src/proprietary/routes/Login.tsx @@ -6,30 +6,23 @@ import { useSearchParams, } from "react-router-dom"; import { Text, Stack, Alert } from "@mantine/core"; -import { - setPostLoginRedirectPath, - springAuth, -} from "@app/auth/springAuthClient"; +import { setPostLoginRedirectPath } from "@shared/auth/spring/springAuthClient"; import { useAuth } from "@app/auth/UseSession"; import { useAppConfig } from "@app/contexts/AppConfigContext"; import { useTranslation } from "react-i18next"; import { useDocumentMeta } from "@app/hooks/useDocumentMeta"; import AuthLayout from "@app/routes/authShared/AuthLayout"; import { useBackendProbe } from "@app/hooks/useBackendProbe"; -import apiClient from "@app/services/apiClient"; import { BASE_PATH, withBasePath } from "@app/constants/app"; -import { type OAuthProvider } from "@app/auth/oauthTypes"; import { updateSupportedLanguages } from "@app/i18n"; - -// Import login components -import ErrorMessage from "@app/routes/login/ErrorMessage"; -import EmailPasswordForm from "@app/routes/login/EmailPasswordForm"; -import OAuthButtons, { +import { DEBUG_SHOW_ALL_PROVIDERS, oauthProviderConfig, -} from "@app/routes/login/OAuthButtons"; -import DividerWithText from "@app/components/shared/DividerWithText"; +} from "@shared/auth/ui/OAuthButtons"; +import SpringLoginForm from "@shared/auth/ui/SpringLoginForm"; +import { useSpringLogin } from "@shared/auth/ui/useSpringLogin"; import LoggedInState from "@app/routes/login/LoggedInState"; +import loginHeader from "@shared/assets/login/LoginLightModeHeader.svg"; export default function Login() { const navigate = useNavigate(); @@ -51,19 +44,11 @@ export default function Login() { }; const { refetch } = useAppConfig(); const { t } = useTranslation(); - const [isSigningIn, setIsSigningIn] = useState(false); - const [error, setError] = useState(null); const [successMessage, setSuccessMessage] = useState(null); const [showEmailForm, setShowEmailForm] = useState(false); - const [email, setEmail] = useState(() => searchParams.get("email") ?? ""); - const [password, setPassword] = useState(""); - const [mfaCode, setMfaCode] = useState(""); - const [requiresMfa, setRequiresMfa] = useState(false); - const [enabledProviders, setEnabledProviders] = useState([]); - const [hasSSOProviders, setHasSSOProviders] = useState(false); const [_enableLogin, setEnableLogin] = useState(null); - const [loginMethod, setLoginMethod] = useState("all"); const [ssoAutoLogin, setSsoAutoLogin] = useState(false); + const [hasSSOProviders, setHasSSOProviders] = useState(false); const backendProbe = useBackendProbe(); const [isFirstTimeSetup, setIsFirstTimeSetup] = useState(false); const [showDefaultCredentials, setShowDefaultCredentials] = useState(false); @@ -71,8 +56,6 @@ export default function Login() { backendProbe.loginDisabled === true || _enableLogin === false; const autoLoginAttempted = useRef(false); const autoLoginErrorRecorded = useRef(false); - const isUserPassAllowed = loginMethod === "all" || loginMethod === "normal"; - const isSsoOnlyMode = loginMethod !== "all" && loginMethod !== "normal"; const AUTO_LOGIN_ATTEMPTS_KEY = "stirling_sso_auto_login_attempts"; const AUTO_LOGIN_ERRORS_KEY = "stirling_sso_auto_login_errors"; @@ -150,6 +133,40 @@ export default function Login() { const hasSsoLoginError = Boolean(errorFromState || errorFromQuery); + // Shared login state + sign-in handlers + provider fetch. Editor-specific + // behaviour (auto-login, redirects, first-time setup) is layered on here. + const login = useSpringLogin({ + ready: backendProbe.status === "up" || backendProbe.loginDisabled, + redirectTo: `${BASE_PATH}/auth/callback`, + onSignInStart: clearLogoutBlock, + onBeforeOAuth: () => { + // Don't overwrite a path already stashed by httpErrorHandler on a 401. + const returnPath = resolveReturnPath(); + if (returnPath) { + setPostLoginRedirectPath(returnPath); + } + }, + onConfigLoaded: (data) => { + // If login is disabled, redirect to home (anonymous mode) + if (data.enableLogin === false) { + console.debug("[Login] Login disabled, redirecting to home"); + navigate("/"); + return; + } + setEnableLogin(data.enableLogin ?? true); + setSsoAutoLogin(Boolean(data.ssoAutoLogin)); + setIsFirstTimeSetup(data.firstTimeSetup ?? false); + setShowDefaultCredentials(data.showDefaultCredentials ?? false); + // Apply language configuration from server + if (data.languages || data.defaultLocale) { + updateSupportedLanguages(data.languages, data.defaultLocale); + } + }, + }); + + const isUserPassAllowed = login.isUserPassAllowed; + const isSsoOnlyMode = !login.isUserPassAllowed; + // Periodically probe while backend isn't up so the screen can auto-advance when it comes online useEffect(() => { if (backendProbe.status === "up" || backendProbe.loginDisabled) { @@ -203,113 +220,26 @@ export default function Login() { } }, [backendProbe.status, refetch]); - // Fetch enabled SSO providers and login config from backend - useEffect(() => { - const fetchProviders = async () => { - try { - const response = await apiClient.get( - "/api/v1/proprietary/ui-data/login", - ); - const data = response.data; - - // Check if login is disabled - if so, redirect to home - if (data.enableLogin === false) { - console.debug("[Login] Login disabled, redirecting to home"); - navigate("/"); - return; - } - - setEnableLogin(data.enableLogin ?? true); - setSsoAutoLogin(Boolean(data.ssoAutoLogin)); - - // Set first-time setup flags - setIsFirstTimeSetup(data.firstTimeSetup ?? false); - setShowDefaultCredentials(data.showDefaultCredentials ?? false); - - // Apply language configuration from server - if (data.languages || data.defaultLocale) { - updateSupportedLanguages(data.languages, data.defaultLocale); - } - - // Use the full paths from providerList as provider identifiers - // The backend provides paths like "/oauth2/authorization/google" or "/saml2/authenticate/stirling" - // We'll use these full paths so the auth client knows where to redirect - const providerPaths = Object.keys(data.providerList || {}); - - setEnabledProviders(providerPaths); - setLoginMethod(data.loginMethod || "all"); - } catch (err) { - console.error("[Login] Failed to fetch enabled providers:", err); - // Set default values on error to ensure UI remains functional - // Login method defaults to 'all' to show both SSO and email/password options - setEnableLogin(true); - setLoginMethod("all"); - setEnabledProviders([]); - } - }; - - if (backendProbe.status === "up" || backendProbe.loginDisabled) { - fetchProviders(); - } - }, [navigate, backendProbe.status, backendProbe.loginDisabled]); - - // Update hasSSOProviders and showEmailForm when enabledProviders or loginMethod changes + // Update hasSSOProviders and showEmailForm when providers or loginMethod change useEffect(() => { // In debug mode, check if any providers exist in the config const hasProviders = DEBUG_SHOW_ALL_PROVIDERS ? Object.keys(oauthProviderConfig).length > 0 - : enabledProviders.length > 0; + : login.providers.length > 0; setHasSSOProviders(hasProviders); // Check if username/password authentication is allowed - const isUserPassAllowed = loginMethod === "all" || loginMethod === "normal"; + const userPassAllowed = + login.loginMethod === "all" || login.loginMethod === "normal"; // Show email form if no SSO providers exist AND username/password is allowed - if (!hasProviders && isUserPassAllowed) { + if (!hasProviders && userPassAllowed) { setShowEmailForm(true); - } else if (!isUserPassAllowed) { + } else if (!userPassAllowed) { // Hide email form if username/password auth is not allowed setShowEmailForm(false); } - }, [enabledProviders, loginMethod]); - - const signInWithProvider = async (provider: OAuthProvider) => { - try { - setIsSigningIn(true); - setError(null); - clearLogoutBlock(); - - // Don't overwrite a path already stashed by httpErrorHandler on a prior 401. - const returnPath = resolveReturnPath(); - if (returnPath) { - setPostLoginRedirectPath(returnPath); - } - - // Redirect to Spring OAuth2 endpoint using the actual provider ID from backend - // The backend returns the correct registration ID (e.g., 'authentik', 'oidc', 'keycloak') - const { error } = await springAuth.signInWithOAuth({ - provider: provider, - options: { redirectTo: `${BASE_PATH}/auth/callback` }, - }); - - if (error) { - console.error(`[Login] ${provider} error:`, error); - setError( - t("login.failedToSignIn", { provider, message: error.message }) || - `Failed to sign in with ${provider}`, - ); - } - } catch (err) { - console.error(`[Login] Unexpected error:`, err); - setError( - t("login.unexpectedError", { - message: err instanceof Error ? err.message : "Unknown error", - }) || "An unexpected error occurred", - ); - } finally { - setIsSigningIn(false); - } - }; + }, [login.providers, login.loginMethod]); // Auto-login to SSO when enabled and only one SSO option exists useEffect(() => { @@ -342,26 +272,27 @@ export default function Login() { return; } - if (isUserPassAllowed) { + if (login.isUserPassAllowed) { return; } - if (enabledProviders.length !== 1) { + if (login.providers.length !== 1) { return; } autoLoginAttempted.current = true; recordAutoLoginAttempt(); - void signInWithProvider(enabledProviders[0]); + void login.signInWithProvider(login.providers[0]); }, [ ssoAutoLogin, loginDisabled, loading, session, backendProbe.status, - loginMethod, - enabledProviders, - signInWithProvider, + login.loginMethod, + login.providers, + login.signInWithProvider, + login.isUserPassAllowed, hasSsoLoginError, ]); @@ -370,13 +301,13 @@ export default function Login() { try { const emailFromQuery = searchParams.get("email"); if (emailFromQuery) { - setEmail(emailFromQuery); + login.setEmail(emailFromQuery); } // Check if session expired (401 redirect) const expired = searchParams.get("expired"); if (expired === "true") { - setError( + login.setError( t( "login.sessionExpired", "Your session has expired. Please sign in again.", @@ -415,9 +346,9 @@ export default function Login() { } if (errorFromState) { - setError(errorFromState); + login.setError(errorFromState); } else if (errorFromQuery) { - setError(errorFromQuery); + login.setError(errorFromQuery); } if (hasSsoLoginError && !autoLoginErrorRecorded.current) { @@ -427,7 +358,15 @@ export default function Login() { } catch (_) { // ignore } - }, [searchParams, t, errorFromState, errorFromQuery, hasSsoLoginError]); + }, [ + searchParams, + t, + errorFromState, + errorFromQuery, + hasSsoLoginError, + login.setEmail, + login.setError, + ]); const baseUrl = window.location.origin + BASE_PATH; @@ -470,7 +409,7 @@ export default function Login() {
Stirling PDF @@ -509,207 +448,111 @@ export default function Login() { ); } - const signInWithEmail = async () => { - if (!email || !password) { - setError( - t("login.pleaseEnterBoth") || "Please enter both email and password", - ); - return; - } - - if (requiresMfa && !mfaCode.trim()) { - setError(t("login.mfaRequired", "Two-factor code required")); - return; - } - - try { - setIsSigningIn(true); - setError(null); - clearLogoutBlock(); - - const { user, session, error } = await springAuth.signInWithPassword({ - email: email.trim(), - password: password, - mfaCode: requiresMfa ? mfaCode.trim() : undefined, - }); - - if (error) { - setError(error.message); - if (error.mfaRequired || error.code === "invalid_mfa_code") { - setRequiresMfa(true); - } - } else if (user && session) { - clearLogoutBlock(); - setRequiresMfa(false); - setMfaCode(""); - // Auth state will update automatically and Landing will redirect to home - // No need to navigate manually here - } - } catch (err) { - console.error("[Login] Unexpected error:", err); - setError( - t("login.unexpectedError", { - message: err instanceof Error ? err.message : "Unknown error", - }) || "An unexpected error occurred", - ); - } finally { - setIsSigningIn(false); - } - }; - - // Forgot password handler (currently unused, reserved for future implementation) - // const handleForgotPassword = () => { - // navigate('/auth/reset'); - // }; - return ( -
- Stirling PDF - Stirling PDF -
- - {/* Success message */} - {successMessage && ( -
-

- {successMessage} -

-
- )} - - - - {/* OAuth first */} - +

+ {successMessage} +

+
+ ) : undefined + } + beforeEmailForm={ + hasSSOProviders && !showEmailForm && isUserPassAllowed ? ( +
+ +
+ ) : 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() {
Stirling PDF 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={