From d06a367b87f2ebcf4d5cc7ed53543878272e8662 Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:23:00 +0100 Subject: [PATCH] =?UTF-8?q?SaaS=20role-based=20login=20landing=20(team=20l?= =?UTF-8?q?eads=20=E2=86=92=20Processor)=20(#6960)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/editor/.env.saas | 9 + .../public/locales/en-US/translation.toml | 6 + .../src/core/services/preferencesService.ts | 7 + frontend/editor/src/proprietary/App.tsx | 2 + .../components/LoginLandingRedirect.test.tsx | 180 ++++++++++++++++++ .../components/LoginLandingRedirect.tsx | 120 ++++++++++++ .../shared/config/GeneralWithLoginLanding.tsx | 19 ++ .../config/LoginLandingSetting.test.tsx | 101 ++++++++++ .../shared/config/LoginLandingSetting.tsx | 84 ++++++++ .../shared/config/configNavSections.tsx | 4 +- .../src/proprietary/routes/AuthCallback.tsx | 4 + .../editor/src/proprietary/routes/Login.tsx | 25 ++- .../proprietary/utils/loginLanding.test.ts | 150 +++++++++++++++ .../src/proprietary/utils/loginLanding.ts | 126 ++++++++++++ frontend/editor/src/saas/App.tsx | 2 + .../shared/config/saasConfigNavSections.tsx | 5 +- .../editor/src/saas/routes/AuthCallback.tsx | 12 +- frontend/editor/src/saas/routes/Login.tsx | 6 +- frontend/editor/vite-env.d.ts | 3 + 19 files changed, 853 insertions(+), 12 deletions(-) create mode 100644 frontend/editor/src/proprietary/components/LoginLandingRedirect.test.tsx create mode 100644 frontend/editor/src/proprietary/components/LoginLandingRedirect.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/GeneralWithLoginLanding.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/LoginLandingSetting.test.tsx create mode 100644 frontend/editor/src/proprietary/components/shared/config/LoginLandingSetting.tsx create mode 100644 frontend/editor/src/proprietary/utils/loginLanding.test.ts create mode 100644 frontend/editor/src/proprietary/utils/loginLanding.ts diff --git a/frontend/editor/.env.saas b/frontend/editor/.env.saas index ee182fe99c..0314c59fa4 100644 --- a/frontend/editor/.env.saas +++ b/frontend/editor/.env.saas @@ -10,3 +10,12 @@ VITE_USERBACK_TOKEN= # Dev-only auth bypass for localhost. Subpath comes from RUN_SUBPATH (build-time). VITE_DEV_BYPASS_AUTH=false + +# Login landing mode - soft-release flag for the processor (portal): +# dynamic = role-based landing: team leads -> processor, members -> editor, +# with a per-user override in Settings > General. Default. +# editor = every user lands on the editor after login. The processor stays +# reachable via the app switcher, but no one is auto-routed to it and +# the per-user landing setting is hidden. Soft-release escape hatch. +# Set to `editor` (here or via the build env) to hold the processor back. +VITE_LOGIN_LANDING_MODE=dynamic diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 2244b6acdc..d9ed967d62 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -8937,6 +8937,12 @@ intro = "Enable user authentication, team management, and workspace features for learnMore = "Learn more in documentation" title = "For System Administrators" +[settings.general.loginLanding] +description = "Choose which app opens when you sign in to Stirling Cloud." +editor = "Editor" +processor = "Processor" +title = "After signing in" + [settings.general.mode] fullscreen = "Fullscreen" sidebar = "Sidebar" diff --git a/frontend/editor/src/core/services/preferencesService.ts b/frontend/editor/src/core/services/preferencesService.ts index bba1eadc22..a378136665 100644 --- a/frontend/editor/src/core/services/preferencesService.ts +++ b/frontend/editor/src/core/services/preferencesService.ts @@ -21,12 +21,18 @@ export type ViewerZoomSetting = | "150" | "200"; +// SaaS-only: which app a team lead lands on after signing in. Members can't +// reach the processor, so this never applies to them. +export type LoginLandingView = "processor" | "editor"; + export interface UserPreferences { autoUnzip: boolean; autoUnzipFileLimit: number; defaultToolPanelMode: ToolPanelMode; defaultStartupView: StartupView; defaultViewerZoom: ViewerZoomSetting; + // SaaS-only: team lead's post-login landing app (processor vs editor). + loginLandingView: LoginLandingView; theme: ThemeMode; toolPanelModePromptSeen: boolean; hasSelectedToolPanelMode: boolean; @@ -46,6 +52,7 @@ export const DEFAULT_PREFERENCES: UserPreferences = { defaultToolPanelMode: DEFAULT_TOOL_PANEL_MODE, defaultStartupView: "tools", defaultViewerZoom: "auto", + loginLandingView: "processor", theme: "system", toolPanelModePromptSeen: false, hasSelectedToolPanelMode: false, diff --git a/frontend/editor/src/proprietary/App.tsx b/frontend/editor/src/proprietary/App.tsx index 969f143959..e98064ebc8 100644 --- a/frontend/editor/src/proprietary/App.tsx +++ b/frontend/editor/src/proprietary/App.tsx @@ -17,6 +17,7 @@ import Onboarding from "@app/components/onboarding/Onboarding"; import WatchedFoldersRegistration from "@app/components/watchedFolders/WatchedFoldersRegistration"; import { WATCHED_FOLDERS_ENABLED } from "@app/constants/featureFlags"; import { getAdminRouteExtensions } from "@app/routes/adminRouteExtensions"; +import { LoginLandingRedirect } from "@app/components/LoginLandingRedirect"; // Import global styles import "@app/styles/tailwind.css"; @@ -78,6 +79,7 @@ export default function App() { element={ + } /> } /> diff --git a/frontend/editor/src/proprietary/components/LoginLandingRedirect.test.tsx b/frontend/editor/src/proprietary/components/LoginLandingRedirect.test.tsx new file mode 100644 index 0000000000..dd121ac77a --- /dev/null +++ b/frontend/editor/src/proprietary/components/LoginLandingRedirect.test.tsx @@ -0,0 +1,180 @@ +import { StrictMode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter, useLocation } from "react-router-dom"; + +// Mutable holders driven per-test; read at call time by the mocks below. +const h = vi.hoisted(() => ({ + auth: { session: null as unknown, isAnonymous: false }, + prefs: { loginLandingView: "processor" as "processor" | "editor" }, + get: vi.fn(), +})); + +vi.mock("@app/services/apiClient", () => ({ default: { get: h.get } })); +vi.mock("@app/auth/UseSession", () => ({ useAuth: () => h.auth })); +vi.mock("@app/contexts/PreferencesContext", () => ({ + usePreferences: () => ({ preferences: h.prefs, updatePreference: vi.fn() }), +})); + +import { LoginLandingRedirect } from "@app/components/LoginLandingRedirect"; +import { + hasLoginLandingPending, + markLoginLandingPending, +} from "@app/utils/loginLanding"; + +function httpError(status: number) { + return Object.assign(new Error("http"), { response: { status } }); +} + +// Configure the two backend endpoints. teamMy === "404" simulates self-hosted. +function backend(opts: { + role: string; + portalAccess: boolean; + teamMy: unknown[] | "404"; +}) { + h.get.mockImplementation((url: string) => { + if (url === "/api/v1/auth/me") { + return Promise.resolve({ + data: { user: { role: opts.role, portalAccess: opts.portalAccess } }, + }); + } + if (url === "/api/v1/team/my") { + return opts.teamMy === "404" + ? Promise.reject(httpError(404)) + : Promise.resolve({ data: opts.teamMy }); + } + return Promise.resolve({ data: {} }); + }); +} + +function LocationProbe() { + return
{useLocation().pathname}
; +} + +function renderAt(pathname = "/", strict = false) { + const tree = ( + + + + + ); + return render(strict ? {tree} : tree); +} + +const SIGNED_IN = { session: { user: { id: "u1" } }, isAnonymous: false }; + +beforeEach(() => { + window.sessionStorage.clear(); + vi.stubEnv("VITE_INCLUDE_PORTAL", "true"); + vi.stubEnv("VITE_LOGIN_LANDING_MODE", "dynamic"); + h.auth = { ...SIGNED_IN }; + h.prefs = { loginLandingView: "processor" }; + h.get.mockReset(); +}); + +afterEach(() => vi.unstubAllEnvs()); + +describe("LoginLandingRedirect", () => { + it("self-hosted admin (no /team/my, portalAccess) → processor", async () => { + backend({ role: "ROLE_ADMIN", portalAccess: true, teamMy: "404" }); + markLoginLandingPending(); + renderAt("/"); + await waitFor(() => + expect(screen.getByTestId("pathname").textContent).toBe("/processor"), + ); + expect(hasLoginLandingPending()).toBe(false); + }); + + it("self-hosted member (no /team/my, no portalAccess) → editor", async () => { + backend({ role: "USER", portalAccess: false, teamMy: "404" }); + markLoginLandingPending(); + renderAt("/"); + await waitFor(() => expect(hasLoginLandingPending()).toBe(false)); + expect(screen.getByTestId("pathname").textContent).toBe("/"); + }); + + it("saas real team lead → processor", async () => { + backend({ + role: "USER", + portalAccess: true, + teamMy: [{ isLeader: true, isPersonal: false }], + }); + markLoginLandingPending(); + renderAt("/"); + await waitFor(() => + expect(screen.getByTestId("pathname").textContent).toBe("/processor"), + ); + }); + + it("saas member → editor", async () => { + backend({ + role: "USER", + portalAccess: true, + teamMy: [ + { isLeader: true, isPersonal: true }, + { isLeader: false, isPersonal: false }, + ], + }); + markLoginLandingPending(); + renderAt("/"); + await waitFor(() => expect(hasLoginLandingPending()).toBe(false)); + expect(screen.getByTestId("pathname").textContent).toBe("/"); + }); + + it("still redirects under StrictMode double-invoke", async () => { + backend({ role: "ROLE_ADMIN", portalAccess: true, teamMy: "404" }); + markLoginLandingPending(); + renderAt("/", true); + await waitFor(() => + expect(screen.getByTestId("pathname").textContent).toBe("/processor"), + ); + }); + + it("does not fetch when a user opted into the editor", async () => { + backend({ role: "ROLE_ADMIN", portalAccess: true, teamMy: "404" }); + h.prefs = { loginLandingView: "editor" }; + markLoginLandingPending(); + renderAt("/"); + await Promise.resolve(); + expect(h.get).not.toHaveBeenCalled(); + expect(screen.getByTestId("pathname").textContent).toBe("/"); + expect(hasLoginLandingPending()).toBe(false); + }); + + it("does nothing in editor mode (soft release)", async () => { + vi.stubEnv("VITE_LOGIN_LANDING_MODE", "editor"); + backend({ role: "ROLE_ADMIN", portalAccess: true, teamMy: "404" }); + markLoginLandingPending(); + renderAt("/"); + await Promise.resolve(); + expect(h.get).not.toHaveBeenCalled(); + expect(screen.getByTestId("pathname").textContent).toBe("/"); + }); + + it("does nothing without the fresh-login flag", async () => { + backend({ role: "ROLE_ADMIN", portalAccess: true, teamMy: "404" }); + renderAt("/"); + await Promise.resolve(); + expect(h.get).not.toHaveBeenCalled(); + expect(screen.getByTestId("pathname").textContent).toBe("/"); + }); + + it("waits on auth routes and keeps the flag", async () => { + backend({ role: "ROLE_ADMIN", portalAccess: true, teamMy: "404" }); + markLoginLandingPending(); + renderAt("/login"); + await Promise.resolve(); + expect(h.get).not.toHaveBeenCalled(); + expect(hasLoginLandingPending()).toBe(true); + }); + + it("ignores anonymous sessions", async () => { + h.auth = { session: { user: { id: "anon" } }, isAnonymous: true }; + backend({ role: "ROLE_ADMIN", portalAccess: true, teamMy: "404" }); + markLoginLandingPending(); + renderAt("/"); + await Promise.resolve(); + expect(h.get).not.toHaveBeenCalled(); + expect(screen.getByTestId("pathname").textContent).toBe("/"); + }); +}); diff --git a/frontend/editor/src/proprietary/components/LoginLandingRedirect.tsx b/frontend/editor/src/proprietary/components/LoginLandingRedirect.tsx new file mode 100644 index 0000000000..82dd48ed5a --- /dev/null +++ b/frontend/editor/src/proprietary/components/LoginLandingRedirect.tsx @@ -0,0 +1,120 @@ +import { useEffect, useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { useAuth } from "@app/auth/UseSession"; +import { usePreferences } from "@app/contexts/PreferencesContext"; +import { isAuthRoute } from "@app/constants/routes"; +import { PORTAL_BASENAME } from "@app/routes/portalBasename"; +import { LoadingFallback } from "@app/components/shared/LoadingFallback"; +import { Z_INDEX_SIGN_IN_MODAL } from "@app/styles/zIndex"; +import { + consumeLoginLandingPending, + fetchLandsOnProcessor, + hasLoginLandingPending, + isPortalAvailable, + loginLandingMode, +} from "@app/utils/loginLanding"; + +/** + * On a fresh sign-in (any flavor), sends processor users to the processor and + * everyone else to the editor. Fires once per login, guarded by a sessionStorage + * flag set at login and consumed only once the destination is decided, so it + * never hijacks later in-session navigation. Gated by the VITE_LOGIN_LANDING_MODE + * soft-release flag ("dynamic" to enable). + * + * The decision (see fetchLandsOnProcessor) is driven by the shared /api/v1/auth/me + * so self-hosted and SaaS share one code path. While the lookup is in flight for + * a would-be processor user, a full-screen loader is shown so the editor never + * flashes before the redirect resolves. + * + * Mounted once (in AppProviders) for every flavor; not on the portal route-set, + * which is a separate top-level route. + */ +export function LoginLandingRedirect() { + const navigate = useNavigate(); + const location = useLocation(); + const { session, isAnonymous } = useAuth(); + const { preferences } = usePreferences(); + // A settled, non-anonymous session. Depend on this boolean rather than the + // session object so the effect - and its in-flight lookup - is not torn down + // by the identity churn of setSession() firing on every auth event. + const isSignedIn = !!session && !isAnonymous; + const landingView = preferences.loginLandingView; + const [resolving, setResolving] = useState(false); + + // One-time config log so a live instance reveals the silent build gates + // (soft-release mode off, or portal not bundled) even before any login. + useEffect(() => { + console.debug("[login-landing] config", { + mode: loginLandingMode(), + portalAvailable: isPortalAvailable(), + basename: PORTAL_BASENAME, + }); + }, []); + + useEffect(() => { + // Soft-release flag: outside "dynamic" nobody is auto-routed to the processor. + if (loginLandingMode() !== "dynamic") return; + // The fresh-login flag is the single source of truth for "once per login". It + // is consumed only at the decision below, so a re-run before then just retries + // (StrictMode double-invoke, or a dependency change mid-lookup) instead of + // dropping the redirect with the flag already spent. + if (!hasLoginLandingPending()) return; + console.debug("[login-landing] pending", { + isSignedIn, + onAuthRoute: isAuthRoute(location.pathname), + portalAvailable: isPortalAvailable(), + landingView, + path: location.pathname, + }); + if (!isSignedIn) return; + // Let the normal post-login navigation settle off the auth pages first. + if (isAuthRoute(location.pathname)) return; + + let active = true; + const settle = (goToProcessor: boolean) => { + // Ignore a stale attempt cancelled by a re-run; the live run will decide. + if (!active) return; + consumeLoginLandingPending(); + setResolving(false); + if (goToProcessor) navigate(PORTAL_BASENAME, { replace: true }); + }; + + // A user who chose "editor" opts out, and no processor to route to - + // decide synchronously, no lookup needed. + if (landingView === "editor" || !isPortalAvailable()) { + settle(false); + return; + } + + setResolving(true); + void fetchLandsOnProcessor().then((goToProcessor) => { + console.debug("[login-landing] decision", { goToProcessor }); + settle(goToProcessor); + }); + + return () => { + active = false; + setResolving(false); + }; + }, [isSignedIn, landingView, location.pathname, navigate]); + + // Cover the editor while a would-be-processor lookup resolves, so a lead never + // sees the editor flash before being sent to the processor. + if (resolving) { + return ( +
+ +
+ ); + } + return null; +} + +export default LoginLandingRedirect; diff --git a/frontend/editor/src/proprietary/components/shared/config/GeneralWithLoginLanding.tsx b/frontend/editor/src/proprietary/components/shared/config/GeneralWithLoginLanding.tsx new file mode 100644 index 0000000000..ad04cce95e --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/GeneralWithLoginLanding.tsx @@ -0,0 +1,19 @@ +import type { ComponentProps } from "react"; +import { Stack } from "@mantine/core"; +import GeneralSection from "@app/components/shared/config/configSections/GeneralSection"; +import { LoginLandingSetting } from "@app/components/shared/config/LoginLandingSetting"; + +type GeneralSectionProps = ComponentProps; + +/** + * Core General settings plus the shared login-landing control. Used by every + * flavor's config nav so the setting is not duplicated per flavor. + */ +export default function GeneralWithLoginLanding(props: GeneralSectionProps) { + return ( + + + + + ); +} diff --git a/frontend/editor/src/proprietary/components/shared/config/LoginLandingSetting.test.tsx b/frontend/editor/src/proprietary/components/shared/config/LoginLandingSetting.test.tsx new file mode 100644 index 0000000000..31d45c0d45 --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/LoginLandingSetting.test.tsx @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; + +const h = vi.hoisted(() => ({ + prefs: { loginLandingView: "processor" as "processor" | "editor" }, + update: vi.fn(), + get: vi.fn(), +})); + +vi.mock("@app/services/apiClient", () => ({ default: { get: h.get } })); +vi.mock("@app/contexts/PreferencesContext", () => ({ + usePreferences: () => ({ preferences: h.prefs, updatePreference: h.update }), +})); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (_key: string, fallback?: string) => fallback ?? _key, + i18n: { changeLanguage: vi.fn() }, + }), +})); + +import { LoginLandingSetting } from "@app/components/shared/config/LoginLandingSetting"; + +function httpError(status: number) { + return Object.assign(new Error("http"), { response: { status } }); +} + +// Self-hosted admin (portalAccess true, no /team/my) → eligible. +function eligibleBackend() { + h.get.mockImplementation((url: string) => { + if (url === "/api/v1/auth/me") { + return Promise.resolve({ + data: { user: { role: "ROLE_ADMIN", portalAccess: true } }, + }); + } + return Promise.reject(httpError(404)); + }); +} + +// Self-hosted member (no portalAccess) → not eligible. +function memberBackend() { + h.get.mockImplementation((url: string) => { + if (url === "/api/v1/auth/me") { + return Promise.resolve({ + data: { user: { role: "USER", portalAccess: false } }, + }); + } + return Promise.reject(httpError(404)); + }); +} + +function renderSetting() { + return render( + + + , + ); +} + +beforeEach(() => { + vi.stubEnv("VITE_INCLUDE_PORTAL", "true"); + vi.stubEnv("VITE_LOGIN_LANDING_MODE", "dynamic"); + h.prefs = { loginLandingView: "processor" }; + h.update.mockReset(); + h.get.mockReset(); +}); + +afterEach(() => vi.unstubAllEnvs()); + +describe("LoginLandingSetting", () => { + it("shows the control for a processor user", async () => { + eligibleBackend(); + renderSetting(); + expect(await screen.findByText("After signing in")).toBeInTheDocument(); + expect(screen.getByText("Processor")).toBeInTheDocument(); + expect(screen.getByText("Editor")).toBeInTheDocument(); + }); + + it("renders nothing for a member", async () => { + memberBackend(); + renderSetting(); + await waitFor(() => expect(h.get).toHaveBeenCalled()); + await Promise.resolve(); + expect(screen.queryByText("After signing in")).not.toBeInTheDocument(); + }); + + it("renders nothing in editor mode (soft release)", () => { + vi.stubEnv("VITE_LOGIN_LANDING_MODE", "editor"); + eligibleBackend(); + renderSetting(); + expect(screen.queryByText("After signing in")).not.toBeInTheDocument(); + }); + + it("renders nothing when the portal is not bundled", () => { + vi.stubEnv("VITE_INCLUDE_PORTAL", ""); + vi.stubEnv("DEV", false); + eligibleBackend(); + renderSetting(); + expect(screen.queryByText("After signing in")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/editor/src/proprietary/components/shared/config/LoginLandingSetting.tsx b/frontend/editor/src/proprietary/components/shared/config/LoginLandingSetting.tsx new file mode 100644 index 0000000000..e4c510458d --- /dev/null +++ b/frontend/editor/src/proprietary/components/shared/config/LoginLandingSetting.tsx @@ -0,0 +1,84 @@ +import { useEffect, useState } from "react"; +import { Paper, Text } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { SegmentedControl } from "@app/ui/SegmentedControl"; +import { usePreferences } from "@app/contexts/PreferencesContext"; +import type { LoginLandingView } from "@app/services/preferencesService"; +import { + fetchLandsOnProcessor, + isPortalAvailable, + loginLandingMode, +} from "@app/utils/loginLanding"; + +/** + * Processor-user preference: where to land after signing in (processor vs + * editor). Shown only to users who default to the processor (see + * fetchLandsOnProcessor); hidden for members and solo users. Shared by all + * flavors. + */ +export function LoginLandingSetting() { + const { t } = useTranslation(); + const { preferences, updatePreference } = usePreferences(); + const [eligible, setEligible] = useState(false); + + // Only look up eligibility when the control could actually show; skip the + // request entirely in soft-release / no-portal builds. + const active = loginLandingMode() === "dynamic" && isPortalAvailable(); + useEffect(() => { + if (!active) return; + let cancelled = false; + void fetchLandsOnProcessor().then((v) => { + if (!cancelled) setEligible(v); + }); + return () => { + cancelled = true; + }; + }, [active]); + + if (!active || !eligible) { + return null; + } + + return ( + +
+
+ + {t("settings.general.loginLanding.title", "After signing in")} + + + {t( + "settings.general.loginLanding.description", + "Choose which app opens when you sign in.", + )} + +
+ + updatePreference("loginLandingView", val as LoginLandingView) + } + options={[ + { + label: t("settings.general.loginLanding.processor", "Processor"), + value: "processor", + }, + { + label: t("settings.general.loginLanding.editor", "Editor"), + value: "editor", + }, + ]} + /> +
+
+ ); +} + +export default LoginLandingSetting; diff --git a/frontend/editor/src/proprietary/components/shared/config/configNavSections.tsx b/frontend/editor/src/proprietary/components/shared/config/configNavSections.tsx index 23856929a5..f9c19f8a7b 100644 --- a/frontend/editor/src/proprietary/components/shared/config/configNavSections.tsx +++ b/frontend/editor/src/proprietary/components/shared/config/configNavSections.tsx @@ -22,7 +22,7 @@ import AdminUsageSection from "@app/components/shared/config/configSections/Admi import AdminStorageSharingSection from "@app/components/shared/config/configSections/AdminStorageSharingSection"; import ApiKeys from "@app/components/shared/config/configSections/ApiKeys"; import AccountSection from "@app/components/shared/config/configSections/AccountSection"; -import GeneralSection from "@app/components/shared/config/configSections/GeneralSection"; +import GeneralWithLoginLanding from "@app/components/shared/config/GeneralWithLoginLanding"; /** * Hook version of proprietary config nav sections with proper i18n support @@ -50,7 +50,7 @@ export const useConfigNavSections = ( if (preferencesSection) { preferencesSection.items = preferencesSection.items.map((item) => item.key === "general" - ? { ...item, component: } + ? { ...item, component: } : item, ); diff --git a/frontend/editor/src/proprietary/routes/AuthCallback.tsx b/frontend/editor/src/proprietary/routes/AuthCallback.tsx index 928e277728..f0e90f5ed0 100644 --- a/frontend/editor/src/proprietary/routes/AuthCallback.tsx +++ b/frontend/editor/src/proprietary/routes/AuthCallback.tsx @@ -4,6 +4,7 @@ import { consumePostLoginRedirectPath, springAuth, } from "@app/auth/spring/springAuthClient"; +import { markLoginLandingPending } from "@app/utils/loginLanding"; import { handleAuthCallbackSuccess } from "@app/extensions/authCallback"; import styles from "@app/routes/AuthCallback.module.css"; @@ -78,6 +79,9 @@ export default function AuthCallback() { await new Promise((resolve) => setTimeout(resolve, 100)); const target = consumePostLoginRedirectPath() ?? "/"; + // Fresh OAuth/SSO login with no explicit destination: let the role-based + // landing route processor users. + if (target === "/") markLoginLandingPending(); console.info( `[AuthCallback] Authenticated ${data.session.user.username} in ${elapsed()}, navigating to ${target}`, ); diff --git a/frontend/editor/src/proprietary/routes/Login.tsx b/frontend/editor/src/proprietary/routes/Login.tsx index 05737d9ba7..a59072d5f4 100644 --- a/frontend/editor/src/proprietary/routes/Login.tsx +++ b/frontend/editor/src/proprietary/routes/Login.tsx @@ -8,6 +8,7 @@ import { import { Text, Stack, Alert } from "@mantine/core"; import { Button } from "@app/ui/Button"; import { setPostLoginRedirectPath } from "@app/auth/spring/springAuthClient"; +import { markLoginLandingPending } from "@app/utils/loginLanding"; import { useAuth } from "@app/auth/UseSession"; import { useAppConfig } from "@app/contexts/AppConfigContext"; import { useTranslation } from "react-i18next"; @@ -57,6 +58,9 @@ export default function Login() { backendProbe.loginDisabled === true || _enableLogin === false; const autoLoginAttempted = useRef(false); const autoLoginErrorRecorded = useRef(false); + // True once we've observed a signed-out state on this page, so we can tell a + // fresh login (arrived signed-out, then signed in) from an already-authed visit. + const sawSignedOutRef = useRef(false); const AUTO_LOGIN_ATTEMPTS_KEY = "stirling_sso_auto_login_attempts"; const AUTO_LOGIN_ERRORS_KEY = "stirling_sso_auto_login_errors"; @@ -197,13 +201,22 @@ export default function Login() { // Redirect immediately if user has valid session (JWT already validated by AuthProvider) useEffect(() => { - if (!loading && session) { - const returnPath = resolveReturnPath(); - console.debug("[Login] User already authenticated, redirecting to home", { - returnPath, - }); - navigate(returnPath || "/", { replace: true }); + if (loading) return; + if (!session) { + sawSignedOutRef.current = true; + return; } + const returnPath = resolveReturnPath(); + // Fresh form login (we were signed out on this page) with no explicit + // destination: let the role-based landing route processor users. An + // already-authed visit to /login never sets the flag. + if (sawSignedOutRef.current && !returnPath) { + markLoginLandingPending(); + } + console.debug("[Login] User already authenticated, redirecting to home", { + returnPath, + }); + navigate(returnPath || "/", { replace: true }); }, [session, loading, navigate, location.state, searchParams]); // If backend reports login is disabled, redirect to home (anonymous mode) diff --git a/frontend/editor/src/proprietary/utils/loginLanding.test.ts b/frontend/editor/src/proprietary/utils/loginLanding.test.ts new file mode 100644 index 0000000000..43a33e1a0d --- /dev/null +++ b/frontend/editor/src/proprietary/utils/loginLanding.test.ts @@ -0,0 +1,150 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const h = vi.hoisted(() => ({ get: vi.fn() })); +vi.mock("@app/services/apiClient", () => ({ default: { get: h.get } })); + +import { + LOGIN_LANDING_PENDING_KEY, + consumeLoginLandingPending, + fetchLandsOnProcessor, + hasLoginLandingPending, + isPortalAvailable, + leadsRealTeam, + loginLandingMode, + markLoginLandingPending, + type LandingTeam, +} from "@app/utils/loginLanding"; + +function team(o: Partial): LandingTeam { + return { isLeader: false, isPersonal: false, ...o }; +} + +// Axios-error-shaped plain object (not an Error instance) so the harness's +// uncaught-Error tracking doesn't flag the rejection that fetchLandsOnProcessor +// deliberately catches. +function httpError(status: number) { + return { isAxiosError: true, message: "http", response: { status } }; +} + +function mockMe(role: string | null, portalAccess: boolean) { + return { data: { user: { role, portalAccess } } }; +} + +describe("leadsRealTeam", () => { + it("is true only for a non-personal led team", () => { + expect(leadsRealTeam([team({ isLeader: true, isPersonal: false })])).toBe( + true, + ); + expect(leadsRealTeam([team({ isLeader: false, isPersonal: false })])).toBe( + false, + ); + expect(leadsRealTeam([team({ isLeader: true, isPersonal: true })])).toBe( + false, + ); + expect(leadsRealTeam([])).toBe(false); + }); +}); + +describe("login-landing pending flag", () => { + beforeEach(() => window.sessionStorage.clear()); + + it("marks, peeks, and consumes once", () => { + expect(hasLoginLandingPending()).toBe(false); + markLoginLandingPending(); + expect(window.sessionStorage.getItem(LOGIN_LANDING_PENDING_KEY)).toBe("1"); + expect(hasLoginLandingPending()).toBe(true); + expect(consumeLoginLandingPending()).toBe(true); + expect(hasLoginLandingPending()).toBe(false); + expect(consumeLoginLandingPending()).toBe(false); + }); +}); + +describe("loginLandingMode", () => { + afterEach(() => vi.unstubAllEnvs()); + + it("defaults to dynamic unless explicitly 'editor'", () => { + vi.stubEnv("VITE_LOGIN_LANDING_MODE", ""); + expect(loginLandingMode()).toBe("dynamic"); + vi.stubEnv("VITE_LOGIN_LANDING_MODE", "editor"); + expect(loginLandingMode()).toBe("editor"); + vi.stubEnv("VITE_LOGIN_LANDING_MODE", "whatever"); + expect(loginLandingMode()).toBe("dynamic"); + }); +}); + +describe("isPortalAvailable", () => { + afterEach(() => vi.unstubAllEnvs()); + + it("is true when VITE_INCLUDE_PORTAL is set", () => { + vi.stubEnv("VITE_INCLUDE_PORTAL", "true"); + expect(isPortalAvailable()).toBe(true); + }); +}); + +describe("fetchLandsOnProcessor", () => { + beforeEach(() => h.get.mockReset()); + + // fetchLandsOnProcessor calls /me first, then /team/my. mockRejectedValueOnce + // is vitest's rejection helper (tracks the rejection so it isn't flagged). + it("self-hosted (no /team/my): uses portalAccess = true", async () => { + h.get + .mockResolvedValueOnce(mockMe("USER", true)) + .mockRejectedValueOnce(httpError(404)); + expect(await fetchLandsOnProcessor()).toBe(true); + }); + + it("self-hosted (no /team/my): portalAccess false → editor", async () => { + h.get + .mockResolvedValueOnce(mockMe("USER", false)) + .mockRejectedValueOnce(httpError(404)); + expect(await fetchLandsOnProcessor()).toBe(false); + }); + + it("saas: admin → processor even with only a personal team", async () => { + h.get.mockImplementation((url: string) => { + if (url === "/api/v1/auth/me") + return Promise.resolve(mockMe("ROLE_ADMIN", true)); + return Promise.resolve({ + data: [team({ isLeader: true, isPersonal: true })], + }); + }); + expect(await fetchLandsOnProcessor()).toBe(true); + }); + + it("saas: non-admin real lead → processor", async () => { + h.get.mockImplementation((url: string) => { + if (url === "/api/v1/auth/me") + return Promise.resolve(mockMe("USER", true)); + return Promise.resolve({ + data: [team({ isLeader: true, isPersonal: false })], + }); + }); + expect(await fetchLandsOnProcessor()).toBe(true); + }); + + it("saas: member → editor (ignores polluted portalAccess)", async () => { + h.get.mockImplementation((url: string) => { + if (url === "/api/v1/auth/me") + return Promise.resolve(mockMe("USER", true)); + return Promise.resolve({ + data: [ + team({ isLeader: true, isPersonal: true }), + team({ isLeader: false, isPersonal: false }), + ], + }); + }); + expect(await fetchLandsOnProcessor()).toBe(false); + }); + + it("editor when /me fails", async () => { + h.get.mockRejectedValueOnce(httpError(401)); + expect(await fetchLandsOnProcessor()).toBe(false); + }); + + it("editor when /team/my fails with a non-404 (ambiguous)", async () => { + h.get + .mockResolvedValueOnce(mockMe("USER", true)) + .mockRejectedValueOnce(httpError(500)); + expect(await fetchLandsOnProcessor()).toBe(false); + }); +}); diff --git a/frontend/editor/src/proprietary/utils/loginLanding.ts b/frontend/editor/src/proprietary/utils/loginLanding.ts new file mode 100644 index 0000000000..ee36e4715e --- /dev/null +++ b/frontend/editor/src/proprietary/utils/loginLanding.ts @@ -0,0 +1,126 @@ +import { isAdminRole } from "@app/auth/roles"; +import apiClient from "@app/services/apiClient"; + +/** + * Role-based login landing, shared by every flavor (self-hosted + SaaS). + * + * On a fresh sign-in, users who can use the processor (portal) land there; + * everyone else lands on the editor. The decision is driven by the shared + * `/api/v1/auth/me` endpoint so there is a single code path for all flavors: + * + * - Self-hosted: `portalAccess` from `/me` (admin, ACL grant, or team owner) is + * the clean signal - there are no personal teams, and `/api/v1/team/my` does + * not exist (404). + * - SaaS: every user leads their own personal team, so `portalAccess`/`teamLead` + * are true for everyone and useless. SaaS additionally exposes + * `/api/v1/team/my`, so there we require admin, or leadership of a NON-personal + * team, which excludes members and solo/personal users. + */ + +// sessionStorage flag set at a genuine fresh login and consumed once when the +// user lands, so the redirect never hijacks later in-session navigation (e.g. +// switching back to the editor from the processor). +export const LOGIN_LANDING_PENDING_KEY = "stirling_login_landing_pending"; + +export type LoginLandingMode = "editor" | "dynamic"; + +/** Minimal shape of a `/api/v1/team/my` row that the decision needs. */ +export interface LandingTeam { + isLeader: boolean; + isPersonal: boolean; +} + +/** A user "leads a team" (→ processor) only if they lead a non-personal team. */ +export function leadsRealTeam(teams: LandingTeam[]): boolean { + return teams.some((team) => team.isLeader && !team.isPersonal); +} + +// The processor/portal route-set is only bundled in some builds (mirrors +// adminRouteExtensions); redirecting to it otherwise would 404 to the editor. +export function isPortalAvailable(): boolean { + return import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV; +} + +/** + * Build flag (VITE_LOGIN_LANDING_MODE) gating the whole role-based landing: + * - "dynamic" (default): processor users land on the processor, everyone else on + * the editor, with a per-user override in Settings. + * - "editor": everyone lands on the editor - the soft-release escape hatch. The + * processor is still reachable via the app switcher, but no one is auto-routed + * to it and the per-user setting stays hidden. + */ +export function loginLandingMode(): LoginLandingMode { + return import.meta.env.VITE_LOGIN_LANDING_MODE === "editor" + ? "editor" + : "dynamic"; +} + +/** Flag a genuine fresh login so the landing redirect fires exactly once. */ +export function markLoginLandingPending(): void { + try { + window.sessionStorage.setItem(LOGIN_LANDING_PENDING_KEY, "1"); + } catch { + // sessionStorage unavailable (private mode / SSR): skip the one-time redirect. + } +} + +/** Whether a fresh-login redirect is still pending (non-destructive peek). */ +export function hasLoginLandingPending(): boolean { + try { + return window.sessionStorage.getItem(LOGIN_LANDING_PENDING_KEY) === "1"; + } catch { + return false; + } +} + +/** Clear the pending flag; returns whether it was set. */ +export function consumeLoginLandingPending(): boolean { + try { + const pending = + window.sessionStorage.getItem(LOGIN_LANDING_PENDING_KEY) === "1"; + if (pending) window.sessionStorage.removeItem(LOGIN_LANDING_PENDING_KEY); + return pending; + } catch { + return false; + } +} + +interface MeUser { + role?: string; + portalAccess?: boolean; +} + +/** + * Whether the signed-in user should land on the processor. One decision for all + * flavors: fetch the shared `/api/v1/auth/me`, then branch on whether + * `/api/v1/team/my` exists (SaaS) or 404s (self-hosted). Any failure defaults to + * the editor (safe). Best-effort - callers treat a thrown/false result as editor. + */ +export async function fetchLandsOnProcessor(): Promise { + let user: MeUser | undefined; + try { + const me = await apiClient.get<{ user?: MeUser }>("/api/v1/auth/me", { + suppressErrorToast: true, + }); + user = me.data?.user; + } catch { + return false; // not authenticated / unreachable → stay on the editor + } + if (!user) return false; + + try { + const teams = await apiClient.get("/api/v1/team/my", { + suppressErrorToast: true, + }); + // SaaS: precise per-team data lets us exclude personal-team-only "leaders". + return isAdminRole(user.role) || leadsRealTeam(teams.data ?? []); + } catch (e) { + const status = (e as { response?: { status?: number } })?.response?.status; + if (status === 404) { + // Self-hosted: no /team/my. portalAccess (admin / grant / team owner) is + // the clean signal there. + return user.portalAccess === true; + } + return false; // ambiguous lookup failure → stay on the editor + } +} diff --git a/frontend/editor/src/saas/App.tsx b/frontend/editor/src/saas/App.tsx index 59de137d32..1da92bd190 100644 --- a/frontend/editor/src/saas/App.tsx +++ b/frontend/editor/src/saas/App.tsx @@ -21,6 +21,7 @@ import { getAdminRouteExtensions } from "@app/routes/adminRouteExtensions"; import OnboardingBootstrap from "@app/components/OnboardingBootstrap"; import SignupRequiredBootstrap from "@app/components/SignupRequiredBootstrap"; import UsageLimitModalHost from "@app/components/UsageLimitModalHost"; +import { LoginLandingRedirect } from "@app/components/LoginLandingRedirect"; // Import global styles import "@app/styles/tailwind.css"; @@ -94,6 +95,7 @@ export default function App() { > + } /> } /> diff --git a/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx b/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx index deea98d2bc..d3165c77a7 100644 --- a/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx +++ b/frontend/editor/src/saas/components/shared/config/saasConfigNavSections.tsx @@ -6,6 +6,7 @@ import { } from "@core/components/shared/config/configNavSections"; import HotkeysSection from "@app/components/shared/config/configSections/HotkeysSection"; import GeneralSection from "@app/components/shared/config/configSections/GeneralSection"; +import GeneralWithLoginLanding from "@app/components/shared/config/GeneralWithLoginLanding"; import PasswordSecurity from "@app/components/shared/config/configSections/PasswordSecurity"; import ApiKeys from "@app/components/shared/config/configSections/ApiKeys"; import McpSection from "@app/components/shared/config/configSections/McpSection"; @@ -210,7 +211,9 @@ export function createSaasConfigNavSections( item.key === "general" ? { ...item, - component: , + component: ( + + ), } : item, ), diff --git a/frontend/editor/src/saas/routes/AuthCallback.tsx b/frontend/editor/src/saas/routes/AuthCallback.tsx index ee16d8ce36..eeeac1ff33 100644 --- a/frontend/editor/src/saas/routes/AuthCallback.tsx +++ b/frontend/editor/src/saas/routes/AuthCallback.tsx @@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom"; import { supabase } from "@app/auth/supabase"; import { Button } from "@app/ui/Button"; import { withBasePath } from "@app/constants/app"; +import { markLoginLandingPending } from "@app/utils/loginLanding"; interface CallbackState { status: "processing" | "success" | "error"; @@ -122,10 +123,17 @@ export default function AuthCallback() { } } - // Redirect to the intended destination - const destination = next.startsWith("/") ? next : "/"; + // Redirect to the intended destination. Reject protocol-relative + // "//host" values (same guard as Login's `next`) so a crafted callback + // URL can't bounce the user off-origin after sign-in. + const destination = + next.startsWith("/") && !next.startsWith("//") ? next : "/"; console.log("[Auth Callback Debug] Redirecting to:", destination); + // Fresh OAuth / magic-link login with no explicit destination: let the + // role-based landing redirect route team leads to the processor. + if (destination === "/") markLoginLandingPending(); + setTimeout(() => navigate(destination, { replace: true }), 1500); } catch (err) { console.error("[Auth Callback Debug] Unexpected error:", err); diff --git a/frontend/editor/src/saas/routes/Login.tsx b/frontend/editor/src/saas/routes/Login.tsx index dcf7b454a2..5a8e135450 100644 --- a/frontend/editor/src/saas/routes/Login.tsx +++ b/frontend/editor/src/saas/routes/Login.tsx @@ -20,6 +20,7 @@ import ErrorMessage from "@app/auth/ui/ErrorMessage"; import EmailPasswordForm from "@app/routes/login/EmailPasswordForm"; import OAuthButtons from "@app/routes/login/OAuthButtons"; import LoggedInState from "@app/routes/login/LoggedInState"; +import { markLoginLandingPending } from "@app/utils/loginLanding"; import loginHeader from "@app/assets/brand/modern-logo/LoginLightModeHeader.svg"; export default function Login() { @@ -166,7 +167,10 @@ export default function Login() { setError(error.message); } else if (data.user) { console.log("[Login] Email sign in successful"); - // User will be redirected by the auth state change + // Fresh login with no explicit destination: let the role-based landing + // redirect route team leads to the processor. User is redirected by the + // auth state change. + if (!nextPath) markLoginLandingPending(); } } catch (err) { console.error("[Login] Unexpected error]:", err); diff --git a/frontend/editor/vite-env.d.ts b/frontend/editor/vite-env.d.ts index f690cf1b3e..e9fb24e496 100644 --- a/frontend/editor/vite-env.d.ts +++ b/frontend/editor/vite-env.d.ts @@ -17,6 +17,9 @@ interface ImportMetaEnv { // SaaS only (.env.saas) readonly VITE_USERBACK_TOKEN: string; readonly VITE_DEV_BYPASS_AUTH: string; + /** Role-based login landing: default "dynamic" (team leads → processor, + * members → editor); set to "editor" to keep everyone on the editor. */ + readonly VITE_LOGIN_LANDING_MODE: string; // Desktop only (.env.desktop) readonly VITE_DESKTOP_BACKEND_URL: string;