SaaS role-based login landing (team leads → Processor) (#6960)

This commit is contained in:
Anthony Stirling
2026-07-10 15:23:00 +01:00
committed by GitHub
parent ce6abe6e23
commit d06a367b87
19 changed files with 853 additions and 12 deletions
+9
View File
@@ -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
@@ -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"
@@ -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,
+2
View File
@@ -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={
<AppProviders>
<AppLayout>
<LoginLandingRedirect />
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
@@ -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 <div data-testid="pathname">{useLocation().pathname}</div>;
}
function renderAt(pathname = "/", strict = false) {
const tree = (
<MemoryRouter initialEntries={[pathname]}>
<LoginLandingRedirect />
<LocationProbe />
</MemoryRouter>
);
return render(strict ? <StrictMode>{tree}</StrictMode> : 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("/");
});
});
@@ -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 (
<div
style={{
position: "fixed",
inset: 0,
zIndex: Z_INDEX_SIGN_IN_MODAL,
background: "var(--bg-surface)",
}}
>
<LoadingFallback />
</div>
);
}
return null;
}
export default LoginLandingRedirect;
@@ -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<typeof GeneralSection>;
/**
* 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 (
<Stack gap="lg">
<GeneralSection {...props} />
<LoginLandingSetting />
</Stack>
);
}
@@ -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(
<MantineProvider>
<LoginLandingSetting />
</MantineProvider>,
);
}
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();
});
});
@@ -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 (
<Paper withBorder p="md" radius="md">
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "1rem",
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t("settings.general.loginLanding.title", "After signing in")}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
"settings.general.loginLanding.description",
"Choose which app opens when you sign in.",
)}
</Text>
</div>
<SegmentedControl
value={preferences.loginLandingView}
onChange={(val: string) =>
updatePreference("loginLandingView", val as LoginLandingView)
}
options={[
{
label: t("settings.general.loginLanding.processor", "Processor"),
value: "processor",
},
{
label: t("settings.general.loginLanding.editor", "Editor"),
value: "editor",
},
]}
/>
</div>
</Paper>
);
}
export default LoginLandingSetting;
@@ -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: <GeneralSection /> }
? { ...item, component: <GeneralWithLoginLanding /> }
: item,
);
@@ -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}`,
);
@@ -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)
@@ -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>): 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);
});
});
@@ -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<boolean> {
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<LandingTeam[]>("/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
}
}
+2
View File
@@ -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() {
>
<AppLayout>
<NonAuthBootstraps />
<LoginLandingRedirect />
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
@@ -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: <GeneralSection hideUpdateSection hideAdminBanner />,
component: (
<GeneralWithLoginLanding hideUpdateSection hideAdminBanner />
),
}
: item,
),
@@ -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);
+5 -1
View File
@@ -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);
+3
View File
@@ -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;