mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
SaaS role-based login landing (team leads → Processor) (#6960)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+101
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Vendored
+3
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user