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={