Unified auth for portal and editor (#6725)
# Description of Changes Refactor frontend auth to the shared folder and hook it up to both the portal and editor so they share the same system. Also adds various tasks to help run the portal, including `task dev:portal` to spawn the portal with the backend, and `task dev:portal:proxy` to spawn the editor, portal and backend, and a reverse proxy (at localhost:3000) to allow you to use both at once to simulate how this will actually be deployed, allowing you to check whether the seamless transition between the two actually works.
@@ -25,6 +25,7 @@ tasks:
|
||||
AIENGINE_URL: '{{.AIENGINE_URL}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS}}'
|
||||
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN}}'
|
||||
|
||||
dev:proprietary:
|
||||
desc: "Start backend dev server in proprietary mode"
|
||||
@@ -34,12 +35,13 @@ tasks:
|
||||
AIENGINE_URL: '{{.AIENGINE_URL | default ""}}'
|
||||
AIENGINE_ENABLED: '{{.AIENGINE_ENABLED | default "false"}}'
|
||||
AIENGINE_TIMEOUTSECONDS: '{{.AIENGINE_TIMEOUTSECONDS | default "120"}}'
|
||||
SECURITY_ENABLELOGIN: '{{.SECURITY_ENABLELOGIN | default ""}}'
|
||||
env:
|
||||
SERVER_PORT: '{{.PORT}}'
|
||||
cmds:
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}cmd /c ".\gradlew.bat :stirling-pdf:bootRun"'
|
||||
platforms: [windows]
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}./gradlew :stirling-pdf:bootRun'
|
||||
- cmd: '{{if .AIENGINE_URL}}AIENGINE_URL={{.AIENGINE_URL}} AIENGINE_ENABLED={{.AIENGINE_ENABLED}} AIENGINE_TIMEOUTSECONDS={{.AIENGINE_TIMEOUTSECONDS}} {{end}}{{if .SECURITY_ENABLELOGIN}}SECURITY_ENABLELOGIN={{.SECURITY_ENABLELOGIN}} {{end}}./gradlew :stirling-pdf:bootRun'
|
||||
platforms: [linux, darwin]
|
||||
|
||||
dev:bundled:
|
||||
|
||||
@@ -130,9 +130,34 @@ tasks:
|
||||
|
||||
dev:portal:
|
||||
desc: "Start developer portal dev server"
|
||||
ignore_error: true
|
||||
deps: [install]
|
||||
vars:
|
||||
PORT: '{{.PORT | default "5173"}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
|
||||
EDITOR_URL: '{{.EDITOR_URL | default ""}}'
|
||||
OPEN: '{{.OPEN | default ""}}'
|
||||
SUBPATH: '{{.SUBPATH | default ""}}'
|
||||
MOCKS: '{{.MOCKS | default ""}}'
|
||||
env:
|
||||
BACKEND_URL: '{{.BACKEND_URL}}'
|
||||
cmds:
|
||||
- npx vite portal --port {{.PORT | default "5173"}}{{if .OPEN}} --open{{end}}
|
||||
- '{{if .SUBPATH}}RUN_SUBPATH={{.SUBPATH}} {{end}}{{if .MOCKS}}VITE_PORTAL_MOCKS={{.MOCKS}} {{end}}{{if .EDITOR_URL}}VITE_EDITOR_URL={{.EDITOR_URL}} {{end}}npx vite portal --port {{.PORT}}{{if .OPEN}} --open{{end}}'
|
||||
|
||||
dev:portal:proxy:serve:
|
||||
internal: true
|
||||
vars:
|
||||
PORT: '{{.PORT | default "3000"}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
|
||||
EDITOR_DEV_URL: '{{.EDITOR_DEV_URL | default ""}}'
|
||||
PORTAL_DEV_URL: '{{.PORTAL_DEV_URL | default ""}}'
|
||||
env:
|
||||
PORT: '{{.PORT}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL}}'
|
||||
EDITOR_DEV_URL: '{{.EDITOR_DEV_URL}}'
|
||||
PORTAL_DEV_URL: '{{.PORTAL_DEV_URL}}'
|
||||
cmds:
|
||||
- npx tsx scripts/dev-origin-proxy.ts
|
||||
|
||||
# ============================================================
|
||||
# Build
|
||||
@@ -153,8 +178,10 @@ tasks:
|
||||
build:proprietary:
|
||||
desc: "Build for proprietary mode"
|
||||
deps: [prepare]
|
||||
vars:
|
||||
PREVIEW: '{{.PREVIEW | default ""}}'
|
||||
cmds:
|
||||
- npx vite build editor --mode proprietary
|
||||
- '{{if .PREVIEW}}VITE_BUILD_FOR_PREVIEW=1 {{end}}npx vite build editor --mode proprietary'
|
||||
|
||||
build:saas:
|
||||
desc: "Build for SaaS mode"
|
||||
@@ -181,8 +208,26 @@ tasks:
|
||||
build:portal:
|
||||
desc: "Build developer portal"
|
||||
deps: [install]
|
||||
vars:
|
||||
SUBPATH: '{{.SUBPATH | default ""}}'
|
||||
cmds:
|
||||
- npx vite build portal
|
||||
- '{{if .SUBPATH}}RUN_SUBPATH={{.SUBPATH}} {{end}}npx vite build portal'
|
||||
|
||||
preview:portal:proxy:
|
||||
desc: "Build + serve editor + portal behind one origin (prod-like auth testing)"
|
||||
deps: [prepare]
|
||||
vars:
|
||||
PORT: '{{.PORT | default "3000"}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL | default "http://localhost:8080"}}'
|
||||
env:
|
||||
PORT: '{{.PORT}}'
|
||||
BACKEND_URL: '{{.BACKEND_URL}}'
|
||||
cmds:
|
||||
- task: build:proprietary
|
||||
vars: { PREVIEW: '1' }
|
||||
- task: build:portal
|
||||
vars: { SUBPATH: portal }
|
||||
- npx tsx scripts/dev-origin-proxy.ts
|
||||
|
||||
storybook:
|
||||
desc: "Start Storybook dev server"
|
||||
@@ -288,6 +333,7 @@ tasks:
|
||||
desc: "Typecheck scripts"
|
||||
deps: [prepare]
|
||||
cmds:
|
||||
- npx tsc --noEmit --project scripts/tsconfig.json
|
||||
- npx tsc --noEmit --project editor/scripts/tsconfig.json
|
||||
|
||||
typecheck:prototypes:
|
||||
|
||||
@@ -78,6 +78,80 @@ tasks:
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
OPEN: "true"
|
||||
|
||||
dev:portal:
|
||||
desc: "Start backend + developer portal concurrently on free ports"
|
||||
vars:
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173{{end}}'
|
||||
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
deps:
|
||||
- task: backend:dev
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
- task: frontend:dev:portal
|
||||
vars:
|
||||
PORT: '{{.PORTAL_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
OPEN: "true"
|
||||
|
||||
dev:portal:all:
|
||||
desc: "Start backend + developer portal + editor concurrently on free ports"
|
||||
vars:
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 5173 5174{{end}}'
|
||||
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}'
|
||||
deps:
|
||||
- task: backend:dev
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
- task: frontend:dev:portal
|
||||
vars:
|
||||
PORT: '{{.PORTAL_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
# Point the portal's "Editor" app switcher at the editor we spawn here.
|
||||
EDITOR_URL: 'http://localhost:{{.EDITOR_PORT}}/'
|
||||
OPEN: "true"
|
||||
- task: frontend:dev
|
||||
vars:
|
||||
PORT: '{{.EDITOR_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
|
||||
dev:portal:proxy:
|
||||
desc: "Editor + portal on ONE origin + backend via live dev servers (shared-token login)"
|
||||
vars:
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 3000 5173 5174{{else}}{{.FIND_FREE_PORT_SH}} 8080 3000 5173 5174{{end}}'
|
||||
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
PROXY_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
EDITOR_PORT: '{{index (splitList "\n" .PORTS) 2}}'
|
||||
PORTAL_PORT: '{{index (splitList "\n" .PORTS) 3}}'
|
||||
deps:
|
||||
- task: backend:dev
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
- task: frontend:dev:proprietary
|
||||
vars:
|
||||
PORT: '{{.EDITOR_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
- task: frontend:dev:portal
|
||||
vars:
|
||||
PORT: '{{.PORTAL_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
SUBPATH: portal
|
||||
MOCKS: 'false'
|
||||
- task: frontend:dev:portal:proxy:serve
|
||||
vars:
|
||||
PORT: '{{.PROXY_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
EDITOR_DEV_URL: 'http://localhost:{{.EDITOR_PORT}}'
|
||||
PORTAL_DEV_URL: 'http://localhost:{{.PORTAL_PORT}}'
|
||||
|
||||
dev:saas:
|
||||
desc: "Start SaaS backend + frontend concurrently on free ports"
|
||||
cmds:
|
||||
@@ -124,6 +198,23 @@ tasks:
|
||||
- task: backend:build
|
||||
- task: frontend:build
|
||||
|
||||
preview:portal:proxy:
|
||||
desc: "Build + serve editor + portal on ONE origin + backend (prod-like auth test)"
|
||||
vars:
|
||||
PORTS:
|
||||
sh: '{{if eq OS "windows"}}{{.FIND_FREE_PORT_PS}} 8080 3000{{else}}{{.FIND_FREE_PORT_SH}} 8080 3000{{end}}'
|
||||
BACKEND_PORT: '{{index (splitList "\n" .PORTS) 0}}'
|
||||
PROXY_PORT: '{{index (splitList "\n" .PORTS) 1}}'
|
||||
deps:
|
||||
- task: backend:dev
|
||||
vars:
|
||||
PORT: '{{.BACKEND_PORT}}'
|
||||
SECURITY_ENABLELOGIN: "true"
|
||||
- task: frontend:preview:portal:proxy
|
||||
vars:
|
||||
PORT: '{{.PROXY_PORT}}'
|
||||
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
|
||||
|
||||
# ============================================================
|
||||
# Test
|
||||
# ============================================================
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 23 23" fill="none">
|
||||
<path d="M0 0h10.5v10.5H0V0z" fill="#F25022"/>
|
||||
<path d="M12.5 0H23v10.5H12.5V0z" fill="#7FBA00"/>
|
||||
<path d="M0 12.5h10.5V23H0V12.5z" fill="#00A4EF"/>
|
||||
<path d="M12.5 12.5H23V23H12.5V12.5z" fill="#FFB900"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 292 B |
@@ -191,8 +191,9 @@ export default function ProviderCard({
|
||||
};
|
||||
|
||||
const renderProviderIcon = () => {
|
||||
// If icon starts with '/', it's a path to an SVG file
|
||||
if (provider.icon.startsWith("/")) {
|
||||
// Image source: an absolute/relative path, a data: URI (small bundled SVGs
|
||||
// are inlined), or a full URL. Iconify names ("key-rounded") use LocalIcon.
|
||||
if (/^(\/|\.\.?\/|data:|blob:|https?:)/.test(provider.icon)) {
|
||||
return (
|
||||
<img
|
||||
src={provider.icon}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { oauthIconUrl } from "@shared/auth/ui/oauthIcons";
|
||||
|
||||
export type ProviderType = "oauth2" | "saml2" | "telegram" | "googledrive";
|
||||
|
||||
@@ -28,7 +29,7 @@ const useGoogleProvider = (): Provider => {
|
||||
return {
|
||||
id: "google",
|
||||
name: "Google",
|
||||
icon: "/Login/google.svg",
|
||||
icon: oauthIconUrl("google.svg"),
|
||||
type: "oauth2",
|
||||
scope: t("provider.oauth2.google.scope", "Sign-in authentication"),
|
||||
documentationUrl:
|
||||
@@ -86,7 +87,7 @@ const useGitHubProvider = (): Provider => {
|
||||
return {
|
||||
id: "github",
|
||||
name: "GitHub",
|
||||
icon: "/Login/github.svg",
|
||||
icon: oauthIconUrl("github.svg"),
|
||||
type: "oauth2",
|
||||
scope: t("provider.oauth2.github.scope", "Sign-in authentication"),
|
||||
documentationUrl:
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LoginRightCarousel from "@app/components/shared/LoginRightCarousel";
|
||||
import LoginRightCarousel from "@shared/auth/ui/LoginRightCarousel";
|
||||
import buildLoginSlides from "@app/components/shared/loginSlides";
|
||||
import styles from "@app/routes/authShared/AuthLayout.module.css";
|
||||
import styles from "@shared/auth/ui/AuthShell.module.css";
|
||||
import { useLogoVariant } from "@app/hooks/useLogoVariant";
|
||||
|
||||
interface DesktopAuthLayoutProps {
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { authService, UserInfo } from "@app/services/authService";
|
||||
import { buildOAuthCallbackHtml } from "@app/utils/oauthCallbackHtml";
|
||||
import { BASE_PATH } from "@app/constants/app";
|
||||
import { oauthIconUrl } from "@shared/auth/ui/oauthIcons";
|
||||
import { STIRLING_SAAS_URL } from "@app/constants/connection";
|
||||
import "@app/components/SetupWizard/desktopOAuth.css";
|
||||
|
||||
@@ -159,7 +159,9 @@ export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
|
||||
<span className="oauth-button-left-desktop">
|
||||
<span className="oauth-icon-wrapper-desktop">
|
||||
<img
|
||||
src={`${BASE_PATH}/Login/${iconConfig?.file || GENERIC_PROVIDER_ICON}`}
|
||||
src={oauthIconUrl(
|
||||
iconConfig?.file || GENERIC_PROVIDER_ICON,
|
||||
)}
|
||||
alt={label}
|
||||
className="oauth-icon-tiny-desktop"
|
||||
/>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LoginHeader from "@app/routes/login/LoginHeader";
|
||||
import ErrorMessage from "@app/routes/login/ErrorMessage";
|
||||
import EmailPasswordForm from "@app/routes/login/EmailPasswordForm";
|
||||
import ErrorMessage from "@shared/auth/ui/ErrorMessage";
|
||||
import EmailPasswordForm from "@shared/auth/ui/EmailPasswordForm";
|
||||
import DividerWithText from "@app/components/shared/DividerWithText";
|
||||
import { DesktopOAuthButtons } from "@app/components/SetupWizard/DesktopOAuthButtons";
|
||||
import { SelfHostedLink } from "@app/components/SetupWizard/SelfHostedLink";
|
||||
import { UserInfo } from "@app/services/authService";
|
||||
import "@app/routes/authShared/auth.css";
|
||||
import "@shared/auth/ui/auth.css";
|
||||
|
||||
interface SaaSLoginScreenProps {
|
||||
serverUrl: string;
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LoginHeader from "@app/routes/login/LoginHeader";
|
||||
import ErrorMessage from "@app/routes/login/ErrorMessage";
|
||||
import ErrorMessage from "@shared/auth/ui/ErrorMessage";
|
||||
import SignupForm from "@app/routes/signup/SignupForm";
|
||||
import {
|
||||
useSignupFormValidation,
|
||||
SignupFieldErrors,
|
||||
} from "@app/routes/signup/SignupFormValidation";
|
||||
import { authService } from "@app/services/authService";
|
||||
import "@app/routes/authShared/auth.css";
|
||||
import "@shared/auth/ui/auth.css";
|
||||
|
||||
interface SaaSSignupScreenProps {
|
||||
loading: boolean;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import "@app/routes/authShared/auth.css";
|
||||
import "@shared/auth/ui/auth.css";
|
||||
|
||||
interface SelfHostedLinkProps {
|
||||
onClick: () => void;
|
||||
|
||||
@@ -2,13 +2,13 @@ import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text } from "@mantine/core";
|
||||
import LoginHeader from "@app/routes/login/LoginHeader";
|
||||
import ErrorMessage from "@app/routes/login/ErrorMessage";
|
||||
import EmailPasswordForm from "@app/routes/login/EmailPasswordForm";
|
||||
import ErrorMessage from "@shared/auth/ui/ErrorMessage";
|
||||
import EmailPasswordForm from "@shared/auth/ui/EmailPasswordForm";
|
||||
import DividerWithText from "@app/components/shared/DividerWithText";
|
||||
import { DesktopOAuthButtons } from "@app/components/SetupWizard/DesktopOAuthButtons";
|
||||
import { UserInfo } from "@app/services/authService";
|
||||
import { SSOProviderConfig } from "@app/services/connectionModeService";
|
||||
import "@app/routes/authShared/auth.css";
|
||||
import "@shared/auth/ui/auth.css";
|
||||
|
||||
interface SelfHostedLoginScreenProps {
|
||||
serverUrl: string;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LoginHeader from "@app/routes/login/LoginHeader";
|
||||
import ErrorMessage from "@app/routes/login/ErrorMessage";
|
||||
import ErrorMessage from "@shared/auth/ui/ErrorMessage";
|
||||
import { ServerSelection } from "@app/components/SetupWizard/ServerSelection";
|
||||
import { ServerConfig } from "@app/services/connectionModeService";
|
||||
import "@app/routes/authShared/auth.css";
|
||||
import "@shared/auth/ui/auth.css";
|
||||
|
||||
interface ServerSelectionScreenProps {
|
||||
onSelect: (config: ServerConfig) => void;
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import { tauriBackendService } from "@app/services/tauriBackendService";
|
||||
import { STIRLING_SAAS_URL } from "@app/constants/connection";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import "@app/routes/authShared/auth.css";
|
||||
import "@shared/auth/ui/auth.css";
|
||||
import { DisabledButtonWithTooltip } from "@app/components/shared/DisabledButtonWithTooltip";
|
||||
|
||||
enum SetupStep {
|
||||
|
||||
@@ -21,7 +21,7 @@ import { WATCHED_FOLDERS_ENABLED } from "@app/constants/featureFlags";
|
||||
import "@app/styles/tailwind.css";
|
||||
import "@app/styles/cookieconsent.css";
|
||||
import "@app/styles/index.css";
|
||||
import "@app/styles/auth-theme.css";
|
||||
import "@shared/auth/ui/auth-theme.css";
|
||||
|
||||
// Import file ID debugging helpers (development only)
|
||||
import "@app/utils/fileIdSafety";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { TFunction } from "i18next";
|
||||
import type { User } from "@app/auth/springAuthClient";
|
||||
import type { User } from "@shared/auth/spring/springAuthClient";
|
||||
import { deriveDisplayName } from "@app/auth/UseSession";
|
||||
|
||||
// Stub t() that returns the fallback string. The real i18next instance
|
||||
|
||||
@@ -1,341 +1,50 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { springAuth } from "@app/auth/springAuthClient";
|
||||
import { clearPlatformAuthOnLoginInit } from "@app/extensions/authSessionCleanup";
|
||||
import { stripBasePath } from "@app/constants/app";
|
||||
import type {
|
||||
Session,
|
||||
User,
|
||||
AuthError,
|
||||
AuthChangeEvent,
|
||||
} from "@app/auth/springAuthClient";
|
||||
import {
|
||||
SpringAuthProvider,
|
||||
deriveDisplayName as deriveDisplayNameShared,
|
||||
} from "@shared/auth/spring/UseSession";
|
||||
import { useAuth as useSharedAuth } from "@shared/auth/context";
|
||||
import type { AuthUser } from "@shared/auth/types";
|
||||
// Side-effect import: wires the editor's transport + platform seams into the
|
||||
// shared Spring engine before AppProviders mounts the provider below.
|
||||
import "@app/auth/configureSpringAuth";
|
||||
|
||||
export type { AuthUser as User } from "@shared/auth/types";
|
||||
|
||||
/**
|
||||
* Auth Context Type
|
||||
* Simplified version without SaaS-specific features (credits, subscriptions)
|
||||
*/
|
||||
interface AuthContextType {
|
||||
session: Session | null;
|
||||
user: User | null;
|
||||
/**
|
||||
* Human-readable name to show in the UI for the current session.
|
||||
* - A real identity (username/email) when the user is signed in.
|
||||
* - The localised "User" placeholder for anonymous sessions
|
||||
* (proprietary's chosen label - see deriveDisplayName).
|
||||
* - null only when there is no user object at all (signed-out), so
|
||||
* consumers can fall back to whatever makes sense.
|
||||
*/
|
||||
displayName: string | null;
|
||||
/** Whether the current session is an anonymous (login-disabled) one. */
|
||||
isAnonymous: boolean;
|
||||
loading: boolean;
|
||||
error: AuthError | null;
|
||||
signOut: () => Promise<void>;
|
||||
refreshSession: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a display name from the Spring user. Anonymous users get the
|
||||
* localised "User" placeholder (proprietary's chosen label for unsigned-in
|
||||
* sessions); returns null only when there is no user object at all so
|
||||
* consumers can pick their own fallback.
|
||||
*
|
||||
* Exported for unit testing.
|
||||
* Editor display-name helper. Keeps the i18next `TFunction` signature the
|
||||
* editor's components and tests rely on, delegating to the shared
|
||||
* implementation for the actual logic (anonymous placeholder vs username/email).
|
||||
*/
|
||||
export function deriveDisplayName(
|
||||
user: User | null | undefined,
|
||||
user: AuthUser | null | undefined,
|
||||
t: TFunction,
|
||||
): string | null {
|
||||
if (!user) return null;
|
||||
if (user.is_anonymous) return t("auth.displayName.user", "User");
|
||||
return user.username || user.email || null;
|
||||
return deriveDisplayNameShared(user, (key, fallback) => t(key, fallback));
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType>({
|
||||
session: null,
|
||||
user: null,
|
||||
displayName: null,
|
||||
isAnonymous: false,
|
||||
loading: true,
|
||||
error: null,
|
||||
signOut: async () => {},
|
||||
refreshSession: async () => {},
|
||||
});
|
||||
|
||||
/**
|
||||
* Auth Provider Component
|
||||
*
|
||||
* Manages authentication state and provides it to the entire app.
|
||||
* Integrates with Spring Security + JWT backend.
|
||||
* Auth Provider for the editor. Wraps the shared Spring provider and feeds it an
|
||||
* i18next-backed translate function so the localised "User" placeholder still
|
||||
* works for anonymous sessions.
|
||||
*/
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<AuthError | null>(null);
|
||||
|
||||
// Debug: Track state transitions
|
||||
useEffect(() => {
|
||||
console.log("[Auth] State changed:", {
|
||||
loading,
|
||||
hasSession: !!session,
|
||||
hasError: !!error,
|
||||
userId: session?.user?.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}, [loading, session, error]);
|
||||
|
||||
/**
|
||||
* Refresh current session
|
||||
*/
|
||||
const refreshSession = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
console.debug("[Auth] refreshSession: start", {
|
||||
path: window.location.pathname,
|
||||
});
|
||||
console.debug("[Auth] Refreshing session...");
|
||||
|
||||
const { data, error } = await springAuth.refreshSession();
|
||||
|
||||
if (error) {
|
||||
console.error("[Auth] Session refresh error:", error);
|
||||
setError(error);
|
||||
setSession(null);
|
||||
} else {
|
||||
console.debug("[Auth] Session refreshed successfully");
|
||||
setSession(data.session);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Auth] Unexpected error during session refresh:", err);
|
||||
setError(err as AuthError);
|
||||
} finally {
|
||||
console.debug("[Auth] refreshSession: done", { hasSession: !!session });
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Sign out user
|
||||
*/
|
||||
const signOut = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
console.debug("[Auth] Signing out...");
|
||||
|
||||
const { error } = await springAuth.signOut();
|
||||
|
||||
// Always clear the in-memory session: springAuth.signOut() removes the
|
||||
// local token and platform user_info even when the backend POST fails,
|
||||
// so the user is effectively signed out either way. Leaving session
|
||||
// populated on error would mean the UI keeps the old user's badge until
|
||||
// a manual reload (the SIGNED_OUT notifyListeners call also covers this
|
||||
// path now, but clearing here is defence in depth).
|
||||
setSession(null);
|
||||
|
||||
if (error) {
|
||||
console.error("[Auth] Sign out error:", error);
|
||||
setError(error);
|
||||
} else {
|
||||
console.debug("[Auth] Signed out successfully");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Auth] Unexpected error during sign out:", err);
|
||||
setSession(null);
|
||||
setError(err as AuthError);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Initialize auth on mount
|
||||
*/
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const mountId = Math.random().toString(36).substring(7);
|
||||
console.log(`[Auth:${mountId}] 🔵 AuthProvider mounted`);
|
||||
|
||||
const initializeAuth = async () => {
|
||||
try {
|
||||
console.debug(`[Auth:${mountId}] Initializing auth...`);
|
||||
console.debug(
|
||||
`[Auth:${mountId}] Path: ${window.location.pathname} Search: ${window.location.search}`,
|
||||
);
|
||||
// Clear any platform-specific cached auth on login page init.
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
stripBasePath(window.location.pathname).startsWith("/login")
|
||||
) {
|
||||
await clearPlatformAuthOnLoginInit();
|
||||
}
|
||||
|
||||
// Skip config check entirely - let the app handle login state
|
||||
// The config will be fetched by useAppConfig when needed
|
||||
const { data, error } = await springAuth.getSession();
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (error) {
|
||||
console.error("[Auth] Initial session error:", error);
|
||||
setError(error);
|
||||
} else {
|
||||
console.debug("[Auth] Initial session loaded:", {
|
||||
hasSession: !!data.session,
|
||||
userId: data.session?.user?.id,
|
||||
email: data.session?.user?.email,
|
||||
});
|
||||
setSession(data.session);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[Auth] Unexpected error during auth initialization:",
|
||||
err,
|
||||
);
|
||||
if (mounted) {
|
||||
setError(err as AuthError);
|
||||
}
|
||||
} finally {
|
||||
console.debug(
|
||||
`[Auth:${mountId}] Initialize auth complete. mounted=${mounted}`,
|
||||
);
|
||||
if (mounted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
initializeAuth();
|
||||
|
||||
// Listen for jwt-available event (triggered by desktop auth or other sources)
|
||||
const handleJwtAvailable = () => {
|
||||
console.log(`[Auth:${mountId}] ════════════════════════════════════`);
|
||||
console.log(`[Auth:${mountId}] 🔄 JWT available event received`);
|
||||
console.log(
|
||||
`[Auth:${mountId}] Current state: loading=${loading}, hasSession=${!!session}`,
|
||||
);
|
||||
console.log(
|
||||
`[Auth:${mountId}] Setting loading=true to stabilize auth state`,
|
||||
);
|
||||
setLoading(true); // Prevent unstable renders during auth state transition
|
||||
setError(null);
|
||||
console.log(`[Auth:${mountId}] Refreshing session...`);
|
||||
void initializeAuth();
|
||||
};
|
||||
|
||||
window.addEventListener("jwt-available", handleJwtAvailable);
|
||||
|
||||
// Subscribe to auth state changes
|
||||
const {
|
||||
data: { subscription },
|
||||
} = springAuth.onAuthStateChange(
|
||||
async (event: AuthChangeEvent, newSession: Session | null) => {
|
||||
if (!mounted) {
|
||||
console.log(
|
||||
`[Auth:${mountId}] ⚠️ Auth state change ignored (unmounted): ${event}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Auth:${mountId}] ════════════════════════════════════`);
|
||||
console.log(`[Auth:${mountId}] 📢 Auth state change event: ${event}`);
|
||||
console.log(`[Auth:${mountId}] Has session: ${!!newSession}`);
|
||||
console.log(
|
||||
`[Auth:${mountId}] User: ${newSession?.user?.email || "none"}`,
|
||||
);
|
||||
console.log(`[Auth:${mountId}] Timestamp: ${new Date().toISOString()}`);
|
||||
|
||||
// Schedule state update
|
||||
setTimeout(() => {
|
||||
if (mounted) {
|
||||
console.log(
|
||||
`[Auth:${mountId}] Applying session update (event: ${event})`,
|
||||
);
|
||||
setSession(newSession);
|
||||
setError(null);
|
||||
|
||||
// Handle specific events
|
||||
if (event === "SIGNED_OUT") {
|
||||
console.log(
|
||||
`[Auth:${mountId}] ✓ User signed out, session cleared`,
|
||||
);
|
||||
} else if (event === "SIGNED_IN") {
|
||||
console.log(`[Auth:${mountId}] ✓ User signed in successfully`);
|
||||
} else if (event === "TOKEN_REFRESHED") {
|
||||
console.log(`[Auth:${mountId}] ✓ Token refreshed`);
|
||||
} else if (event === "USER_UPDATED") {
|
||||
console.log(`[Auth:${mountId}] ✓ User updated`);
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
`[Auth:${mountId}] ⚠️ Session update skipped (unmounted during timeout)`,
|
||||
);
|
||||
}
|
||||
}, 0);
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
console.log(`[Auth:${mountId}] 🔴 AuthProvider unmounting`);
|
||||
mounted = false;
|
||||
window.removeEventListener("jwt-available", handleJwtAvailable);
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const user = session?.user ?? null;
|
||||
const value: AuthContextType = {
|
||||
session,
|
||||
user,
|
||||
displayName: deriveDisplayName(user, t),
|
||||
isAnonymous: user?.is_anonymous === true,
|
||||
loading,
|
||||
error,
|
||||
signOut,
|
||||
refreshSession,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
return (
|
||||
<SpringAuthProvider translate={(key, fallback) => t(key, fallback)}>
|
||||
{children}
|
||||
</SpringAuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access auth context
|
||||
* Must be used within AuthProvider
|
||||
*/
|
||||
/** Hook to access auth context. Must be used within AuthProvider. */
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
return useSharedAuth();
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug hook to expose auth state for debugging
|
||||
* Can be used in development to monitor auth state
|
||||
*/
|
||||
/** Debug alias kept for backwards compatibility with existing callers. */
|
||||
export function useAuthDebug() {
|
||||
const auth = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
console.debug("[Auth Debug] Current auth state:", {
|
||||
hasSession: !!auth.session,
|
||||
hasUser: !!auth.user,
|
||||
loading: auth.loading,
|
||||
hasError: !!auth.error,
|
||||
userId: auth.user?.id,
|
||||
email: auth.user?.email,
|
||||
});
|
||||
}, [auth.session, auth.user, auth.loading, auth.error]);
|
||||
|
||||
return auth;
|
||||
return useSharedAuth();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Wires the editor's transport + platform seams into the shared Spring auth
|
||||
* engine. Import this module for its side effect (it configures the engine on
|
||||
* load) before any auth call runs - AppProviders does so via UseSession.
|
||||
*
|
||||
* The `@app/*` imports resolve per build flavor: proprietary/web gets the no-op
|
||||
* web defaults, the desktop build gets the Tauri-backed implementations. So the
|
||||
* desktop and web auth behaviour is unchanged by the move to the shared engine.
|
||||
*/
|
||||
import type { AxiosInstance } from "axios";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { BASE_PATH } from "@app/constants/app";
|
||||
import { configureSpringAuth } from "@shared/auth/config";
|
||||
import {
|
||||
clearPlatformAuthAfterSignOut,
|
||||
clearPlatformAuthOnLoginInit,
|
||||
} from "@app/extensions/authSessionCleanup";
|
||||
import {
|
||||
getPlatformSessionUser,
|
||||
isDesktopSaaSAuthMode,
|
||||
refreshPlatformSession,
|
||||
savePlatformToken,
|
||||
shouldCallBackendLogout,
|
||||
} from "@app/extensions/platformSessionBridge";
|
||||
import { startOAuthNavigation } from "@app/extensions/oauthNavigation";
|
||||
|
||||
configureSpringAuth({
|
||||
// The desktop build resolves @app/services/apiClient to a TauriHttpClient,
|
||||
// which is API-compatible with axios but not nominally an AxiosInstance -
|
||||
// matches the existing `as unknown as AxiosInstance` bridge in
|
||||
// desktop/services/apiClient.ts. Harmless no-op for the web (axios) build.
|
||||
http: apiClient as unknown as AxiosInstance,
|
||||
basePath: BASE_PATH,
|
||||
platform: {
|
||||
clearPlatformAuthAfterSignOut,
|
||||
clearPlatformAuthOnLoginInit,
|
||||
isDesktopSaaSAuthMode,
|
||||
shouldCallBackendLogout,
|
||||
getPlatformSessionUser,
|
||||
refreshPlatformSession,
|
||||
savePlatformToken,
|
||||
startOAuthNavigation,
|
||||
},
|
||||
});
|
||||
@@ -5,9 +5,12 @@ import {
|
||||
POST_LOGIN_REDIRECT_STORAGE_KEY,
|
||||
setPostLoginRedirectPath,
|
||||
springAuth,
|
||||
} from "@app/auth/springAuthClient";
|
||||
} from "@shared/auth/spring/springAuthClient";
|
||||
import { startOAuthNavigation } from "@app/extensions/oauthNavigation";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
// Side-effect: configures the shared Spring engine with the (mocked) apiClient
|
||||
// + oauthNavigation seam, so springAuth routes through the mocks below.
|
||||
import "@app/auth/configureSpringAuth";
|
||||
import { allowConsole, expectConsole } from "@app/tests/failOnConsole";
|
||||
import {
|
||||
AxiosError,
|
||||
|
||||
@@ -2,68 +2,27 @@ import { BASE_PATH } from "@app/constants/app";
|
||||
import { getLogoFolder } from "@app/constants/logo";
|
||||
import type { LogoVariant } from "@app/services/preferencesService";
|
||||
import type { TFunction } from "i18next";
|
||||
import { loginSlideText } from "@shared/auth/ui/loginSlideText";
|
||||
import type { ImageSlide } from "@shared/auth/ui/LoginRightCarousel";
|
||||
import addToPdf from "@shared/assets/login/AddToPDF.png";
|
||||
import securePdf from "@shared/assets/login/SecurePDF.png";
|
||||
|
||||
export type LoginCarouselSlide = {
|
||||
src: string;
|
||||
alt?: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
cornerModelUrl?: string;
|
||||
followMouseTilt?: boolean;
|
||||
tiltMaxDeg?: number;
|
||||
};
|
||||
const SLIDE_TILT = { followMouseTilt: true, tiltMaxDeg: 5 } as const;
|
||||
|
||||
/**
|
||||
* Editor login carousel slides. Copy comes from the shared set (so the editor
|
||||
* and portal carousels stay in sync) and the edit/secure images are the shared
|
||||
* bundled assets; only the hero is the logo-variant image the editor serves
|
||||
* from /public.
|
||||
*/
|
||||
export const buildLoginSlides = (
|
||||
variant: LogoVariant | null | undefined,
|
||||
t: TFunction,
|
||||
): LoginCarouselSlide[] => {
|
||||
): ImageSlide[] => {
|
||||
const folder = getLogoFolder(variant);
|
||||
const heroImage = `${BASE_PATH}/${folder}/Firstpage.png`;
|
||||
|
||||
return [
|
||||
{
|
||||
src: heroImage,
|
||||
alt: t("login.slides.overview.alt", "Stirling PDF overview"),
|
||||
title: t(
|
||||
"login.slides.overview.title",
|
||||
"Your one-stop-shop for all your PDF needs.",
|
||||
),
|
||||
subtitle: t(
|
||||
"login.slides.overview.subtitle",
|
||||
"A privacy-first cloud suite for PDFs that lets you convert, sign, redact, and manage documents, along with 50+ other powerful tools.",
|
||||
),
|
||||
followMouseTilt: true,
|
||||
tiltMaxDeg: 5,
|
||||
},
|
||||
{
|
||||
src: `${BASE_PATH}/Login/AddToPDF.png`,
|
||||
alt: t("login.slides.edit.alt", "Edit PDFs"),
|
||||
title: t(
|
||||
"login.slides.edit.title",
|
||||
"Edit PDFs to display/secure the information you want",
|
||||
),
|
||||
subtitle: t(
|
||||
"login.slides.edit.subtitle",
|
||||
"With over a dozen tools to help you redact, sign, read and manipulate PDFs, you will be sure to find what you are looking for.",
|
||||
),
|
||||
followMouseTilt: true,
|
||||
tiltMaxDeg: 5,
|
||||
},
|
||||
{
|
||||
src: `${BASE_PATH}/Login/SecurePDF.png`,
|
||||
alt: t("login.slides.secure.alt", "Secure PDFs"),
|
||||
title: t(
|
||||
"login.slides.secure.title",
|
||||
"Protect sensitive information in your PDFs",
|
||||
),
|
||||
subtitle: t(
|
||||
"login.slides.secure.subtitle",
|
||||
"Add passwords, redact content, and manage certificates with ease.",
|
||||
),
|
||||
followMouseTilt: true,
|
||||
tiltMaxDeg: 5,
|
||||
},
|
||||
];
|
||||
const text = loginSlideText((key, fallback) => t(key, fallback));
|
||||
const srcs = [`${BASE_PATH}/${folder}/Firstpage.png`, addToPdf, securePdf];
|
||||
return srcs.map((src, i) => ({ src, ...text[i], ...SLIDE_TILT }));
|
||||
};
|
||||
|
||||
export default buildLoginSlides;
|
||||
|
||||
@@ -5,14 +5,14 @@ import AuthCallback from "@app/routes/AuthCallback";
|
||||
import {
|
||||
POST_LOGIN_REDIRECT_STORAGE_KEY,
|
||||
springAuth,
|
||||
} from "@app/auth/springAuthClient";
|
||||
} from "@shared/auth/spring/springAuthClient";
|
||||
import { expectConsole } from "@app/tests/failOnConsole";
|
||||
|
||||
// Mock springAuth; keep the real redirect-path helpers.
|
||||
vi.mock("@app/auth/springAuthClient", async () => {
|
||||
vi.mock("@shared/auth/spring/springAuthClient", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@app/auth/springAuthClient")
|
||||
>("@app/auth/springAuthClient");
|
||||
typeof import("@shared/auth/spring/springAuthClient")
|
||||
>("@shared/auth/spring/springAuthClient");
|
||||
return {
|
||||
...actual,
|
||||
springAuth: {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
consumePostLoginRedirectPath,
|
||||
springAuth,
|
||||
} from "@app/auth/springAuthClient";
|
||||
} from "@shared/auth/spring/springAuthClient";
|
||||
import { handleAuthCallbackSuccess } from "@app/extensions/authCallback";
|
||||
import styles from "@app/routes/AuthCallback.module.css";
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
|
||||
import AuthLayout from "@app/routes/authShared/AuthLayout";
|
||||
import LoginHeader from "@app/routes/login/LoginHeader";
|
||||
import ErrorMessage from "@app/routes/login/ErrorMessage";
|
||||
import ErrorMessage from "@shared/auth/ui/ErrorMessage";
|
||||
import { BASE_PATH } from "@app/constants/app";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
|
||||
|
||||
@@ -5,9 +5,11 @@ import { BrowserRouter, MemoryRouter } from "react-router-dom";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import Login from "@app/routes/Login";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { springAuth } from "@app/auth/springAuthClient";
|
||||
import { springAuth } from "@shared/auth/spring/springAuthClient";
|
||||
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { configureSpringAuth } from "@shared/auth/config";
|
||||
import type { AxiosInstance } from "axios";
|
||||
|
||||
// Mock i18n to return fallback text
|
||||
vi.mock("react-i18next", () => ({
|
||||
@@ -41,10 +43,10 @@ vi.mock("@app/auth/UseSession", () => ({
|
||||
}));
|
||||
|
||||
// Mock springAuth; keep the real redirect-path helpers.
|
||||
vi.mock("@app/auth/springAuthClient", async () => {
|
||||
vi.mock("@shared/auth/spring/springAuthClient", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@app/auth/springAuthClient")
|
||||
>("@app/auth/springAuthClient");
|
||||
typeof import("@shared/auth/spring/springAuthClient")
|
||||
>("@shared/auth/spring/springAuthClient");
|
||||
return {
|
||||
...actual,
|
||||
springAuth: {
|
||||
@@ -111,6 +113,8 @@ describe("Login", () => {
|
||||
user: null,
|
||||
displayName: null,
|
||||
isAnonymous: false,
|
||||
isAdmin: false,
|
||||
role: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
signOut: vi.fn(),
|
||||
@@ -124,6 +128,11 @@ describe("Login", () => {
|
||||
providerList: {},
|
||||
},
|
||||
});
|
||||
|
||||
// The shared login hook reads getSpringAuthConfig().http; in the real app,
|
||||
// startup points that at apiClient. Mirror that here so the mocked apiClient
|
||||
// serves the login-ui-data fetch.
|
||||
configureSpringAuth({ http: apiClient as unknown as AxiosInstance });
|
||||
});
|
||||
|
||||
it("should render login form", async () => {
|
||||
@@ -159,6 +168,8 @@ describe("Login", () => {
|
||||
user: mockSession.user,
|
||||
displayName: mockSession.user.username,
|
||||
isAnonymous: false,
|
||||
isAdmin: false,
|
||||
role: mockSession.user.role,
|
||||
loading: false,
|
||||
error: null,
|
||||
signOut: vi.fn(),
|
||||
@@ -184,6 +195,8 @@ describe("Login", () => {
|
||||
user: null,
|
||||
displayName: null,
|
||||
isAnonymous: false,
|
||||
isAdmin: false,
|
||||
role: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
signOut: vi.fn(),
|
||||
|
||||
@@ -6,30 +6,23 @@ import {
|
||||
useSearchParams,
|
||||
} from "react-router-dom";
|
||||
import { Text, Stack, Alert } from "@mantine/core";
|
||||
import {
|
||||
setPostLoginRedirectPath,
|
||||
springAuth,
|
||||
} from "@app/auth/springAuthClient";
|
||||
import { setPostLoginRedirectPath } from "@shared/auth/spring/springAuthClient";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
|
||||
import AuthLayout from "@app/routes/authShared/AuthLayout";
|
||||
import { useBackendProbe } from "@app/hooks/useBackendProbe";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { BASE_PATH, withBasePath } from "@app/constants/app";
|
||||
import { type OAuthProvider } from "@app/auth/oauthTypes";
|
||||
import { updateSupportedLanguages } from "@app/i18n";
|
||||
|
||||
// Import login components
|
||||
import ErrorMessage from "@app/routes/login/ErrorMessage";
|
||||
import EmailPasswordForm from "@app/routes/login/EmailPasswordForm";
|
||||
import OAuthButtons, {
|
||||
import {
|
||||
DEBUG_SHOW_ALL_PROVIDERS,
|
||||
oauthProviderConfig,
|
||||
} from "@app/routes/login/OAuthButtons";
|
||||
import DividerWithText from "@app/components/shared/DividerWithText";
|
||||
} from "@shared/auth/ui/OAuthButtons";
|
||||
import SpringLoginForm from "@shared/auth/ui/SpringLoginForm";
|
||||
import { useSpringLogin } from "@shared/auth/ui/useSpringLogin";
|
||||
import LoggedInState from "@app/routes/login/LoggedInState";
|
||||
import loginHeader from "@shared/assets/login/LoginLightModeHeader.svg";
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
@@ -51,19 +44,11 @@ export default function Login() {
|
||||
};
|
||||
const { refetch } = useAppConfig();
|
||||
const { t } = useTranslation();
|
||||
const [isSigningIn, setIsSigningIn] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [showEmailForm, setShowEmailForm] = useState(false);
|
||||
const [email, setEmail] = useState(() => searchParams.get("email") ?? "");
|
||||
const [password, setPassword] = useState("");
|
||||
const [mfaCode, setMfaCode] = useState("");
|
||||
const [requiresMfa, setRequiresMfa] = useState(false);
|
||||
const [enabledProviders, setEnabledProviders] = useState<OAuthProvider[]>([]);
|
||||
const [hasSSOProviders, setHasSSOProviders] = useState(false);
|
||||
const [_enableLogin, setEnableLogin] = useState<boolean | null>(null);
|
||||
const [loginMethod, setLoginMethod] = useState<string>("all");
|
||||
const [ssoAutoLogin, setSsoAutoLogin] = useState(false);
|
||||
const [hasSSOProviders, setHasSSOProviders] = useState(false);
|
||||
const backendProbe = useBackendProbe();
|
||||
const [isFirstTimeSetup, setIsFirstTimeSetup] = useState(false);
|
||||
const [showDefaultCredentials, setShowDefaultCredentials] = useState(false);
|
||||
@@ -71,8 +56,6 @@ export default function Login() {
|
||||
backendProbe.loginDisabled === true || _enableLogin === false;
|
||||
const autoLoginAttempted = useRef(false);
|
||||
const autoLoginErrorRecorded = useRef(false);
|
||||
const isUserPassAllowed = loginMethod === "all" || loginMethod === "normal";
|
||||
const isSsoOnlyMode = loginMethod !== "all" && loginMethod !== "normal";
|
||||
|
||||
const AUTO_LOGIN_ATTEMPTS_KEY = "stirling_sso_auto_login_attempts";
|
||||
const AUTO_LOGIN_ERRORS_KEY = "stirling_sso_auto_login_errors";
|
||||
@@ -150,6 +133,40 @@ export default function Login() {
|
||||
|
||||
const hasSsoLoginError = Boolean(errorFromState || errorFromQuery);
|
||||
|
||||
// Shared login state + sign-in handlers + provider fetch. Editor-specific
|
||||
// behaviour (auto-login, redirects, first-time setup) is layered on here.
|
||||
const login = useSpringLogin({
|
||||
ready: backendProbe.status === "up" || backendProbe.loginDisabled,
|
||||
redirectTo: `${BASE_PATH}/auth/callback`,
|
||||
onSignInStart: clearLogoutBlock,
|
||||
onBeforeOAuth: () => {
|
||||
// Don't overwrite a path already stashed by httpErrorHandler on a 401.
|
||||
const returnPath = resolveReturnPath();
|
||||
if (returnPath) {
|
||||
setPostLoginRedirectPath(returnPath);
|
||||
}
|
||||
},
|
||||
onConfigLoaded: (data) => {
|
||||
// If login is disabled, redirect to home (anonymous mode)
|
||||
if (data.enableLogin === false) {
|
||||
console.debug("[Login] Login disabled, redirecting to home");
|
||||
navigate("/");
|
||||
return;
|
||||
}
|
||||
setEnableLogin(data.enableLogin ?? true);
|
||||
setSsoAutoLogin(Boolean(data.ssoAutoLogin));
|
||||
setIsFirstTimeSetup(data.firstTimeSetup ?? false);
|
||||
setShowDefaultCredentials(data.showDefaultCredentials ?? false);
|
||||
// Apply language configuration from server
|
||||
if (data.languages || data.defaultLocale) {
|
||||
updateSupportedLanguages(data.languages, data.defaultLocale);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const isUserPassAllowed = login.isUserPassAllowed;
|
||||
const isSsoOnlyMode = !login.isUserPassAllowed;
|
||||
|
||||
// Periodically probe while backend isn't up so the screen can auto-advance when it comes online
|
||||
useEffect(() => {
|
||||
if (backendProbe.status === "up" || backendProbe.loginDisabled) {
|
||||
@@ -203,113 +220,26 @@ export default function Login() {
|
||||
}
|
||||
}, [backendProbe.status, refetch]);
|
||||
|
||||
// Fetch enabled SSO providers and login config from backend
|
||||
useEffect(() => {
|
||||
const fetchProviders = async () => {
|
||||
try {
|
||||
const response = await apiClient.get(
|
||||
"/api/v1/proprietary/ui-data/login",
|
||||
);
|
||||
const data = response.data;
|
||||
|
||||
// Check if login is disabled - if so, redirect to home
|
||||
if (data.enableLogin === false) {
|
||||
console.debug("[Login] Login disabled, redirecting to home");
|
||||
navigate("/");
|
||||
return;
|
||||
}
|
||||
|
||||
setEnableLogin(data.enableLogin ?? true);
|
||||
setSsoAutoLogin(Boolean(data.ssoAutoLogin));
|
||||
|
||||
// Set first-time setup flags
|
||||
setIsFirstTimeSetup(data.firstTimeSetup ?? false);
|
||||
setShowDefaultCredentials(data.showDefaultCredentials ?? false);
|
||||
|
||||
// Apply language configuration from server
|
||||
if (data.languages || data.defaultLocale) {
|
||||
updateSupportedLanguages(data.languages, data.defaultLocale);
|
||||
}
|
||||
|
||||
// Use the full paths from providerList as provider identifiers
|
||||
// The backend provides paths like "/oauth2/authorization/google" or "/saml2/authenticate/stirling"
|
||||
// We'll use these full paths so the auth client knows where to redirect
|
||||
const providerPaths = Object.keys(data.providerList || {});
|
||||
|
||||
setEnabledProviders(providerPaths);
|
||||
setLoginMethod(data.loginMethod || "all");
|
||||
} catch (err) {
|
||||
console.error("[Login] Failed to fetch enabled providers:", err);
|
||||
// Set default values on error to ensure UI remains functional
|
||||
// Login method defaults to 'all' to show both SSO and email/password options
|
||||
setEnableLogin(true);
|
||||
setLoginMethod("all");
|
||||
setEnabledProviders([]);
|
||||
}
|
||||
};
|
||||
|
||||
if (backendProbe.status === "up" || backendProbe.loginDisabled) {
|
||||
fetchProviders();
|
||||
}
|
||||
}, [navigate, backendProbe.status, backendProbe.loginDisabled]);
|
||||
|
||||
// Update hasSSOProviders and showEmailForm when enabledProviders or loginMethod changes
|
||||
// Update hasSSOProviders and showEmailForm when providers or loginMethod change
|
||||
useEffect(() => {
|
||||
// In debug mode, check if any providers exist in the config
|
||||
const hasProviders = DEBUG_SHOW_ALL_PROVIDERS
|
||||
? Object.keys(oauthProviderConfig).length > 0
|
||||
: enabledProviders.length > 0;
|
||||
: login.providers.length > 0;
|
||||
setHasSSOProviders(hasProviders);
|
||||
|
||||
// Check if username/password authentication is allowed
|
||||
const isUserPassAllowed = loginMethod === "all" || loginMethod === "normal";
|
||||
const userPassAllowed =
|
||||
login.loginMethod === "all" || login.loginMethod === "normal";
|
||||
|
||||
// Show email form if no SSO providers exist AND username/password is allowed
|
||||
if (!hasProviders && isUserPassAllowed) {
|
||||
if (!hasProviders && userPassAllowed) {
|
||||
setShowEmailForm(true);
|
||||
} else if (!isUserPassAllowed) {
|
||||
} else if (!userPassAllowed) {
|
||||
// Hide email form if username/password auth is not allowed
|
||||
setShowEmailForm(false);
|
||||
}
|
||||
}, [enabledProviders, loginMethod]);
|
||||
|
||||
const signInWithProvider = async (provider: OAuthProvider) => {
|
||||
try {
|
||||
setIsSigningIn(true);
|
||||
setError(null);
|
||||
clearLogoutBlock();
|
||||
|
||||
// Don't overwrite a path already stashed by httpErrorHandler on a prior 401.
|
||||
const returnPath = resolveReturnPath();
|
||||
if (returnPath) {
|
||||
setPostLoginRedirectPath(returnPath);
|
||||
}
|
||||
|
||||
// Redirect to Spring OAuth2 endpoint using the actual provider ID from backend
|
||||
// The backend returns the correct registration ID (e.g., 'authentik', 'oidc', 'keycloak')
|
||||
const { error } = await springAuth.signInWithOAuth({
|
||||
provider: provider,
|
||||
options: { redirectTo: `${BASE_PATH}/auth/callback` },
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error(`[Login] ${provider} error:`, error);
|
||||
setError(
|
||||
t("login.failedToSignIn", { provider, message: error.message }) ||
|
||||
`Failed to sign in with ${provider}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Login] Unexpected error:`, err);
|
||||
setError(
|
||||
t("login.unexpectedError", {
|
||||
message: err instanceof Error ? err.message : "Unknown error",
|
||||
}) || "An unexpected error occurred",
|
||||
);
|
||||
} finally {
|
||||
setIsSigningIn(false);
|
||||
}
|
||||
};
|
||||
}, [login.providers, login.loginMethod]);
|
||||
|
||||
// Auto-login to SSO when enabled and only one SSO option exists
|
||||
useEffect(() => {
|
||||
@@ -342,26 +272,27 @@ export default function Login() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isUserPassAllowed) {
|
||||
if (login.isUserPassAllowed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (enabledProviders.length !== 1) {
|
||||
if (login.providers.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
autoLoginAttempted.current = true;
|
||||
recordAutoLoginAttempt();
|
||||
void signInWithProvider(enabledProviders[0]);
|
||||
void login.signInWithProvider(login.providers[0]);
|
||||
}, [
|
||||
ssoAutoLogin,
|
||||
loginDisabled,
|
||||
loading,
|
||||
session,
|
||||
backendProbe.status,
|
||||
loginMethod,
|
||||
enabledProviders,
|
||||
signInWithProvider,
|
||||
login.loginMethod,
|
||||
login.providers,
|
||||
login.signInWithProvider,
|
||||
login.isUserPassAllowed,
|
||||
hasSsoLoginError,
|
||||
]);
|
||||
|
||||
@@ -370,13 +301,13 @@ export default function Login() {
|
||||
try {
|
||||
const emailFromQuery = searchParams.get("email");
|
||||
if (emailFromQuery) {
|
||||
setEmail(emailFromQuery);
|
||||
login.setEmail(emailFromQuery);
|
||||
}
|
||||
|
||||
// Check if session expired (401 redirect)
|
||||
const expired = searchParams.get("expired");
|
||||
if (expired === "true") {
|
||||
setError(
|
||||
login.setError(
|
||||
t(
|
||||
"login.sessionExpired",
|
||||
"Your session has expired. Please sign in again.",
|
||||
@@ -415,9 +346,9 @@ export default function Login() {
|
||||
}
|
||||
|
||||
if (errorFromState) {
|
||||
setError(errorFromState);
|
||||
login.setError(errorFromState);
|
||||
} else if (errorFromQuery) {
|
||||
setError(errorFromQuery);
|
||||
login.setError(errorFromQuery);
|
||||
}
|
||||
|
||||
if (hasSsoLoginError && !autoLoginErrorRecorded.current) {
|
||||
@@ -427,7 +358,15 @@ export default function Login() {
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
}, [searchParams, t, errorFromState, errorFromQuery, hasSsoLoginError]);
|
||||
}, [
|
||||
searchParams,
|
||||
t,
|
||||
errorFromState,
|
||||
errorFromQuery,
|
||||
hasSsoLoginError,
|
||||
login.setEmail,
|
||||
login.setError,
|
||||
]);
|
||||
|
||||
const baseUrl = window.location.origin + BASE_PATH;
|
||||
|
||||
@@ -470,7 +409,7 @@ export default function Login() {
|
||||
<AuthLayout>
|
||||
<div className="auth-logo-block">
|
||||
<img
|
||||
src={withBasePath("/modern-logo/LoginLightModeHeader.svg")}
|
||||
src={loginHeader}
|
||||
alt="Stirling PDF"
|
||||
className="auth-logo-header auth-logo-header--light"
|
||||
/>
|
||||
@@ -509,207 +448,111 @@ export default function Login() {
|
||||
);
|
||||
}
|
||||
|
||||
const signInWithEmail = async () => {
|
||||
if (!email || !password) {
|
||||
setError(
|
||||
t("login.pleaseEnterBoth") || "Please enter both email and password",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (requiresMfa && !mfaCode.trim()) {
|
||||
setError(t("login.mfaRequired", "Two-factor code required"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSigningIn(true);
|
||||
setError(null);
|
||||
clearLogoutBlock();
|
||||
|
||||
const { user, session, error } = await springAuth.signInWithPassword({
|
||||
email: email.trim(),
|
||||
password: password,
|
||||
mfaCode: requiresMfa ? mfaCode.trim() : undefined,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setError(error.message);
|
||||
if (error.mfaRequired || error.code === "invalid_mfa_code") {
|
||||
setRequiresMfa(true);
|
||||
}
|
||||
} else if (user && session) {
|
||||
clearLogoutBlock();
|
||||
setRequiresMfa(false);
|
||||
setMfaCode("");
|
||||
// Auth state will update automatically and Landing will redirect to home
|
||||
// No need to navigate manually here
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Login] Unexpected error:", err);
|
||||
setError(
|
||||
t("login.unexpectedError", {
|
||||
message: err instanceof Error ? err.message : "Unknown error",
|
||||
}) || "An unexpected error occurred",
|
||||
);
|
||||
} finally {
|
||||
setIsSigningIn(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Forgot password handler (currently unused, reserved for future implementation)
|
||||
// const handleForgotPassword = () => {
|
||||
// navigate('/auth/reset');
|
||||
// };
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<div className="auth-logo-block">
|
||||
<img
|
||||
src={withBasePath("/modern-logo/LoginLightModeHeader.svg")}
|
||||
alt="Stirling PDF"
|
||||
className="auth-logo-header auth-logo-header--light"
|
||||
/>
|
||||
<img
|
||||
src={withBasePath("/modern-logo/LoginDarkModeHeader.svg")}
|
||||
alt="Stirling PDF"
|
||||
className="auth-logo-header auth-logo-header--dark"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Success message */}
|
||||
{successMessage && (
|
||||
<div
|
||||
style={{
|
||||
padding: "1rem",
|
||||
marginBottom: "1rem",
|
||||
backgroundColor: "rgba(34, 197, 94, 0.1)",
|
||||
border: "1px solid rgba(34, 197, 94, 0.3)",
|
||||
borderRadius: "0.5rem",
|
||||
color: "#16a34a",
|
||||
}}
|
||||
>
|
||||
<p style={{ margin: 0, fontSize: "0.875rem", textAlign: "center" }}>
|
||||
{successMessage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ErrorMessage error={error} />
|
||||
|
||||
{/* OAuth first */}
|
||||
<OAuthButtons
|
||||
onProviderClick={signInWithProvider}
|
||||
isSubmitting={isSigningIn}
|
||||
layout="vertical"
|
||||
enabledProviders={enabledProviders}
|
||||
ctaPrefix={
|
||||
<SpringLoginForm
|
||||
state={login}
|
||||
logoSrc={loginHeader}
|
||||
logoDarkSrc={withBasePath("/modern-logo/LoginDarkModeHeader.svg")}
|
||||
showEmailForm={showEmailForm}
|
||||
oauthCtaPrefix={
|
||||
isSsoOnlyMode ? t("login.signInWith", "Sign in with") : undefined
|
||||
}
|
||||
styleVariant="light"
|
||||
useNewStyle={isSsoOnlyMode}
|
||||
oauthUseNewStyle={isSsoOnlyMode}
|
||||
aboveError={
|
||||
successMessage ? (
|
||||
<div
|
||||
style={{
|
||||
padding: "1rem",
|
||||
marginBottom: "1rem",
|
||||
backgroundColor: "rgba(34, 197, 94, 0.1)",
|
||||
border: "1px solid rgba(34, 197, 94, 0.3)",
|
||||
borderRadius: "0.5rem",
|
||||
color: "#16a34a",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: "0.875rem",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{successMessage}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
beforeEmailForm={
|
||||
hasSSOProviders && !showEmailForm && isUserPassAllowed ? (
|
||||
<div className="auth-section">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowEmailForm(true)}
|
||||
disabled={login.isSubmitting}
|
||||
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold mb-2 cursor-pointer border-0 disabled:opacity-50 disabled:cursor-not-allowed auth-cta-button"
|
||||
>
|
||||
{t("login.useEmailInstead", "Login with email")}
|
||||
</button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
footer={
|
||||
isFirstTimeSetup && showDefaultCredentials && isUserPassAllowed ? (
|
||||
<Alert color="blue" variant="light" radius="md" mt="xl">
|
||||
<Stack gap="xs" align="center">
|
||||
<Text
|
||||
size="sm"
|
||||
fw={600}
|
||||
ta="center"
|
||||
style={{ color: "var(--text-always-dark)" }}
|
||||
>
|
||||
{t("login.defaultCredentials", "Default Login Credentials")}
|
||||
</Text>
|
||||
<Text
|
||||
size="sm"
|
||||
ta="center"
|
||||
style={{ color: "var(--text-always-dark)" }}
|
||||
>
|
||||
<Text
|
||||
component="span"
|
||||
fw={600}
|
||||
style={{ color: "var(--text-always-dark)" }}
|
||||
>
|
||||
{t("login.username", "Username")}:
|
||||
</Text>{" "}
|
||||
admin
|
||||
</Text>
|
||||
<Text
|
||||
size="sm"
|
||||
ta="center"
|
||||
style={{ color: "var(--text-always-dark)" }}
|
||||
>
|
||||
<Text
|
||||
component="span"
|
||||
fw={600}
|
||||
style={{ color: "var(--text-always-dark)" }}
|
||||
>
|
||||
{t("login.password", "Password")}:
|
||||
</Text>{" "}
|
||||
stirling
|
||||
</Text>
|
||||
<Text
|
||||
size="xs"
|
||||
ta="center"
|
||||
mt="xs"
|
||||
style={{ color: "var(--text-always-dark-muted)" }}
|
||||
>
|
||||
{t(
|
||||
"login.changePasswordWarning",
|
||||
"Please change your password after logging in for the first time",
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Divider between OAuth and Email - only show if SSO is available and username/password is allowed */}
|
||||
{hasSSOProviders && isUserPassAllowed && (
|
||||
<DividerWithText
|
||||
text={t("signup.or", "or")}
|
||||
respondsToDarkMode={false}
|
||||
opacity={0.4}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sign in with email button - only show if SSO providers exist and username/password is allowed */}
|
||||
{hasSSOProviders && !showEmailForm && isUserPassAllowed && (
|
||||
<div className="auth-section">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowEmailForm(true)}
|
||||
disabled={isSigningIn}
|
||||
className="w-full px-4 py-[0.75rem] rounded-[0.625rem] text-base font-semibold mb-2 cursor-pointer border-0 disabled:opacity-50 disabled:cursor-not-allowed auth-cta-button"
|
||||
>
|
||||
{t("login.useEmailInstead", "Login with email")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email form - show by default if no SSO, or when button clicked, but ONLY if username/password is allowed */}
|
||||
{showEmailForm && isUserPassAllowed && (
|
||||
<div style={{ marginTop: hasSSOProviders ? "1rem" : "0" }}>
|
||||
<EmailPasswordForm
|
||||
email={email}
|
||||
password={password}
|
||||
setEmail={setEmail}
|
||||
setPassword={setPassword}
|
||||
mfaCode={mfaCode}
|
||||
setMfaCode={setMfaCode}
|
||||
showMfaField={requiresMfa || Boolean(mfaCode)}
|
||||
requiresMfa={requiresMfa}
|
||||
onSubmit={signInWithEmail}
|
||||
isSubmitting={isSigningIn}
|
||||
submitButtonText={
|
||||
isSigningIn
|
||||
? t("login.loggingIn") || "Signing in..."
|
||||
: t("login.login") || "Sign in"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Help section - only show on first-time setup with default credentials and username/password auth allowed */}
|
||||
{isFirstTimeSetup && showDefaultCredentials && isUserPassAllowed && (
|
||||
<Alert color="blue" variant="light" radius="md" mt="xl">
|
||||
<Stack gap="xs" align="center">
|
||||
<Text
|
||||
size="sm"
|
||||
fw={600}
|
||||
ta="center"
|
||||
style={{ color: "var(--text-always-dark)" }}
|
||||
>
|
||||
{t("login.defaultCredentials", "Default Login Credentials")}
|
||||
</Text>
|
||||
<Text
|
||||
size="sm"
|
||||
ta="center"
|
||||
style={{ color: "var(--text-always-dark)" }}
|
||||
>
|
||||
<Text
|
||||
component="span"
|
||||
fw={600}
|
||||
style={{ color: "var(--text-always-dark)" }}
|
||||
>
|
||||
{t("login.username", "Username")}:
|
||||
</Text>{" "}
|
||||
admin
|
||||
</Text>
|
||||
<Text
|
||||
size="sm"
|
||||
ta="center"
|
||||
style={{ color: "var(--text-always-dark)" }}
|
||||
>
|
||||
<Text
|
||||
component="span"
|
||||
fw={600}
|
||||
style={{ color: "var(--text-always-dark)" }}
|
||||
>
|
||||
{t("login.password", "Password")}:
|
||||
</Text>{" "}
|
||||
stirling
|
||||
</Text>
|
||||
<Text
|
||||
size="xs"
|
||||
ta="center"
|
||||
mt="xs"
|
||||
style={{ color: "var(--text-always-dark-muted)" }}
|
||||
>
|
||||
{t(
|
||||
"login.changePasswordWarning",
|
||||
"Please change your password after logging in for the first time",
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@ import { useTranslation } from "react-i18next";
|
||||
import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
|
||||
import { useAuth } from "@app/auth/UseSession";
|
||||
import AuthLayout from "@app/routes/authShared/AuthLayout";
|
||||
import "@app/routes/authShared/auth.css";
|
||||
import "@shared/auth/ui/auth.css";
|
||||
import { BASE_PATH, withBasePath } from "@app/constants/app";
|
||||
|
||||
// Import signup components
|
||||
import ErrorMessage from "@app/routes/login/ErrorMessage";
|
||||
import ErrorMessage from "@shared/auth/ui/ErrorMessage";
|
||||
import DividerWithText from "@app/components/shared/DividerWithText";
|
||||
import SignupForm from "@app/routes/signup/SignupForm";
|
||||
import {
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
SignupFieldErrors,
|
||||
} from "@app/routes/signup/SignupFormValidation";
|
||||
import { useAuthService } from "@app/routes/signup/AuthService";
|
||||
import loginHeader from "@shared/assets/login/LoginLightModeHeader.svg";
|
||||
|
||||
export default function Signup() {
|
||||
const navigate = useNavigate();
|
||||
@@ -93,7 +94,7 @@ export default function Signup() {
|
||||
<AuthLayout>
|
||||
<div className="auth-logo-block">
|
||||
<img
|
||||
src={withBasePath("/modern-logo/LoginLightModeHeader.svg")}
|
||||
src={loginHeader}
|
||||
alt="Stirling PDF"
|
||||
className="auth-logo-header auth-logo-header--light"
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LoginRightCarousel from "@app/components/shared/LoginRightCarousel";
|
||||
import { AuthShell } from "@shared/auth/ui/AuthShell";
|
||||
import LoginRightCarousel from "@shared/auth/ui/LoginRightCarousel";
|
||||
import buildLoginSlides from "@app/components/shared/loginSlides";
|
||||
import styles from "@app/routes/authShared/AuthLayout.module.css";
|
||||
import { useLogoVariant } from "@app/hooks/useLogoVariant";
|
||||
import Footer from "@app/components/shared/Footer";
|
||||
|
||||
@@ -10,65 +10,31 @@ interface AuthLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor login layout. The card shell + carousel now live in shared so the
|
||||
* portal renders the identical screen; this wires the editor's logo-variant
|
||||
* slides and legal/cookie footer into that shared shell.
|
||||
*/
|
||||
export default function AuthLayout({ children }: AuthLayoutProps) {
|
||||
const { t } = useTranslation();
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
const [hideRightPanel, setHideRightPanel] = useState(false);
|
||||
const logoVariant = useLogoVariant();
|
||||
const imageSlides = useMemo(
|
||||
() => buildLoginSlides(logoVariant, t),
|
||||
[logoVariant, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
// Use viewport to avoid hysteresis when the card is already in single-column mode
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const cardWidthIfTwoCols = Math.min(1180, viewportWidth * 0.96); // matches min(73.75rem, 96vw)
|
||||
const columnWidth = cardWidthIfTwoCols / 2;
|
||||
const tooNarrow = columnWidth < 470;
|
||||
const tooShort = viewportHeight < 740;
|
||||
setHideRightPanel(tooNarrow || tooShort);
|
||||
};
|
||||
update();
|
||||
window.addEventListener("resize", update);
|
||||
window.addEventListener("orientationchange", update);
|
||||
return () => {
|
||||
window.removeEventListener("resize", update);
|
||||
window.removeEventListener("orientationchange", update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.authContainer}>
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={`${styles.authCard} ${!hideRightPanel ? styles.authCardTwoColumns : ""}`}
|
||||
>
|
||||
<div className={styles.authLeftPanel}>
|
||||
<div className={styles.authContent}>{children}</div>
|
||||
</div>
|
||||
{!hideRightPanel && (
|
||||
<LoginRightCarousel
|
||||
imageSlides={imageSlides}
|
||||
initialSeconds={5}
|
||||
slideSeconds={8}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
width: "100%",
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
<AuthShell
|
||||
rightPanel={
|
||||
<LoginRightCarousel
|
||||
imageSlides={imageSlides}
|
||||
initialSeconds={5}
|
||||
slideSeconds={8}
|
||||
/>
|
||||
}
|
||||
footer={<Footer />}
|
||||
>
|
||||
{children}
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import OAuthButtons from "@app/routes/login/OAuthButtons";
|
||||
import OAuthButtons from "@shared/auth/ui/OAuthButtons";
|
||||
|
||||
// Mock i18n
|
||||
vi.mock("react-i18next", () => ({
|
||||
@@ -42,7 +42,10 @@ describe("OAuthButtons", () => {
|
||||
});
|
||||
|
||||
it("should render unknown provider with capitalized label and generic icon", () => {
|
||||
const enabledProviders = ["mycompany"];
|
||||
// Render the unknown provider alongside oidc so we can assert the unknown
|
||||
// one falls back to the same (generic OIDC) icon. Icons are bundled assets,
|
||||
// so we compare resolved srcs rather than filenames.
|
||||
const enabledProviders = ["mycompany", "oidc"];
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
@@ -57,11 +60,17 @@ describe("OAuthButtons", () => {
|
||||
// Unknown provider should be capitalized
|
||||
expect(screen.getByText("Mycompany")).toBeTruthy();
|
||||
|
||||
// Check that button has generic OIDC icon
|
||||
const button = screen.getByText("Mycompany").closest("button");
|
||||
expect(button).toBeTruthy();
|
||||
const img = button?.querySelector("img");
|
||||
expect(img?.src).toContain("oidc.svg");
|
||||
// Unknown provider falls back to the generic OIDC icon
|
||||
const mycompanyImg = screen
|
||||
.getByText("Mycompany")
|
||||
.closest("button")
|
||||
?.querySelector("img");
|
||||
const oidcImg = screen
|
||||
.getByText("OIDC")
|
||||
.closest("button")
|
||||
?.querySelector("img");
|
||||
expect(mycompanyImg?.src).toBeTruthy();
|
||||
expect(mycompanyImg?.src).toBe(oidcImg?.src);
|
||||
});
|
||||
|
||||
it('should call onProviderClick with actual provider ID (not "oidc")', async () => {
|
||||
@@ -210,20 +219,19 @@ describe("OAuthButtons", () => {
|
||||
</TestWrapper>,
|
||||
);
|
||||
|
||||
// Check that each known provider has its specific icon
|
||||
const googleButton = screen.getByText("Google").closest("button");
|
||||
expect(googleButton?.querySelector("img")?.src).toContain("google.svg");
|
||||
|
||||
const githubButton = screen.getByText("GitHub").closest("button");
|
||||
expect(githubButton?.querySelector("img")?.src).toContain("github.svg");
|
||||
|
||||
const authentikButton = screen.getByText("Authentik").closest("button");
|
||||
expect(authentikButton?.querySelector("img")?.src).toContain(
|
||||
"authentik.svg",
|
||||
);
|
||||
|
||||
const keycloakButton = screen.getByText("Keycloak").closest("button");
|
||||
expect(keycloakButton?.querySelector("img")?.src).toContain("keycloak.svg");
|
||||
// Each known provider renders an icon, and the icons are distinct. Icons
|
||||
// are bundled assets (data URI / hashed URL), so assert distinctness rather
|
||||
// than matching filenames.
|
||||
const srcOf = (label: string) =>
|
||||
screen.getByText(label).closest("button")?.querySelector("img")?.src;
|
||||
const srcs = [
|
||||
srcOf("Google"),
|
||||
srcOf("GitHub"),
|
||||
srcOf("Authentik"),
|
||||
srcOf("Keycloak"),
|
||||
];
|
||||
srcs.forEach((src) => expect(src).toBeTruthy());
|
||||
expect(new Set(srcs).size).toBe(4);
|
||||
});
|
||||
|
||||
it("should handle mixed known and unknown providers", async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { springAuth } from "@app/auth/springAuthClient";
|
||||
import { springAuth } from "@shared/auth/spring/springAuthClient";
|
||||
import { BASE_PATH } from "@app/constants/app";
|
||||
|
||||
export const useAuthService = () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from "react";
|
||||
import "@app/routes/authShared/auth.css";
|
||||
import "@shared/auth/ui/auth.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Checkbox, TextInput, PasswordInput, Button } from "@mantine/core";
|
||||
import { SignupFieldErrors } from "@app/routes/signup/SignupFormValidation";
|
||||
|
||||
@@ -18,7 +18,7 @@ import Onboarding from "@app/components/onboarding/Onboarding";
|
||||
import "@app/styles/tailwind.css";
|
||||
import "@app/styles/cookieconsent.css";
|
||||
import "@app/styles/index.css";
|
||||
import "@app/styles/auth-theme.css";
|
||||
import "@shared/auth/ui/auth-theme.css";
|
||||
|
||||
// Import file ID debugging helpers (development only)
|
||||
import "@app/utils/fileIdSafety";
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
linkOAuthIdentity,
|
||||
supabase,
|
||||
} from "@app/auth/supabase";
|
||||
import { BASE_PATH } from "@app/constants/app";
|
||||
import { oauthIconUrl } from "@shared/auth/ui/oauthIcons";
|
||||
import { oauthProviders } from "@app/constants/authProviders";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
import { absoluteWithBasePath } from "@app/constants/app";
|
||||
@@ -564,7 +564,7 @@ const Overview: React.FC<OverviewProps> = ({ onLogoutClick }) => {
|
||||
size="sm"
|
||||
leftSection={
|
||||
<Image
|
||||
src={`${BASE_PATH}/Login/${provider.file}`}
|
||||
src={oauthIconUrl(provider.file)}
|
||||
alt={provider.label}
|
||||
style={{ width: 16, height: 16 }}
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useAuth } from "@app/auth/UseSession";
|
||||
import { useTranslation } from "@app/hooks/useTranslation";
|
||||
import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
|
||||
import AuthLayout from "@app/routes/authShared/AuthLayout";
|
||||
import "@app/routes/authShared/auth.css";
|
||||
import "@shared/auth/ui/auth.css";
|
||||
import "@app/routes/authShared/saas-auth.css";
|
||||
import {
|
||||
absoluteWithBasePath,
|
||||
@@ -15,10 +15,11 @@ import {
|
||||
import LinkRoundedIcon from "@mui/icons-material/LinkRounded";
|
||||
|
||||
// Import login components
|
||||
import ErrorMessage from "@app/routes/login/ErrorMessage";
|
||||
import ErrorMessage from "@shared/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 loginHeader from "@shared/assets/login/LoginLightModeHeader.svg";
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
@@ -267,7 +268,7 @@ export default function Login() {
|
||||
{/* Centered logo */}
|
||||
<div className="auth-logo-block">
|
||||
<img
|
||||
src={withBasePath("/modern-logo/LoginLightModeHeader.svg")}
|
||||
src={loginHeader}
|
||||
alt="Stirling PDF"
|
||||
className="auth-logo-header auth-logo-header--light"
|
||||
/>
|
||||
|
||||
@@ -4,10 +4,11 @@ import { useAuth } from "@app/auth/UseSession";
|
||||
import { useTranslation } from "@app/hooks/useTranslation";
|
||||
import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
|
||||
import AuthLayout from "@app/routes/authShared/AuthLayout";
|
||||
import "@app/routes/authShared/auth.css";
|
||||
import "@shared/auth/ui/auth.css";
|
||||
import "@app/routes/authShared/saas-auth.css";
|
||||
import { withBasePath } from "@app/constants/app";
|
||||
import ErrorMessage from "@app/routes/login/ErrorMessage";
|
||||
import ErrorMessage from "@shared/auth/ui/ErrorMessage";
|
||||
import loginHeader from "@shared/assets/login/LoginLightModeHeader.svg";
|
||||
|
||||
/**
|
||||
* OAuth 2.1 consent screen for the Supabase OAuth server (used by MCP clients
|
||||
@@ -190,7 +191,7 @@ export default function OAuthConsent() {
|
||||
const logoBlock = (
|
||||
<div className="auth-logo-block">
|
||||
<img
|
||||
src={withBasePath("/modern-logo/LoginLightModeHeader.svg")}
|
||||
src={loginHeader}
|
||||
alt="Stirling PDF"
|
||||
className="auth-logo-header auth-logo-header--light"
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import AuthLayout from "@app/routes/authShared/AuthLayout";
|
||||
import LoginHeader from "@app/routes/login/LoginHeader";
|
||||
import ErrorMessage from "@app/routes/login/ErrorMessage";
|
||||
import ErrorMessage from "@shared/auth/ui/ErrorMessage";
|
||||
import SuccessMessage from "@app/routes/login/SuccessMessage";
|
||||
import EmailPasswordForm from "@app/routes/login/EmailPasswordForm";
|
||||
import NavigationLink from "@app/routes/login/NavigationLink";
|
||||
|
||||
@@ -6,12 +6,12 @@ import { useTranslation } from "@app/hooks/useTranslation";
|
||||
import { useDocumentMeta } from "@app/hooks/useDocumentMeta";
|
||||
import { getBaseUrl, withBasePath } from "@app/constants/app";
|
||||
import AuthLayout from "@app/routes/authShared/AuthLayout";
|
||||
import "@app/routes/authShared/auth.css";
|
||||
import "@shared/auth/ui/auth.css";
|
||||
import "@app/routes/authShared/saas-auth.css";
|
||||
import { alert } from "@app/components/toast";
|
||||
|
||||
// Import signup components
|
||||
import ErrorMessage from "@app/routes/login/ErrorMessage";
|
||||
import ErrorMessage from "@shared/auth/ui/ErrorMessage";
|
||||
import OAuthButtons from "@app/routes/login/OAuthButtons";
|
||||
import SignupForm from "@app/routes/signup/SignupForm";
|
||||
import {
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
SignupFieldErrors,
|
||||
} from "@app/routes/signup/SignupFormValidation";
|
||||
import { useAuthService } from "@app/routes/signup/AuthService";
|
||||
import loginHeader from "@shared/assets/login/LoginLightModeHeader.svg";
|
||||
|
||||
export default function Signup() {
|
||||
const navigate = useNavigate();
|
||||
@@ -184,7 +185,7 @@ export default function Signup() {
|
||||
{/* Centered logo */}
|
||||
<div className="auth-logo-block">
|
||||
<img
|
||||
src={withBasePath("/modern-logo/LoginLightModeHeader.svg")}
|
||||
src={loginHeader}
|
||||
alt="Stirling PDF"
|
||||
className="auth-logo-header auth-logo-header--light"
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import LoginRightCarousel from "@app/components/shared/LoginRightCarousel";
|
||||
import LoginRightCarousel from "@shared/auth/ui/LoginRightCarousel";
|
||||
import buildLoginSlides from "@app/components/shared/loginSlides";
|
||||
import styles from "@app/routes/authShared/AuthLayout.module.css";
|
||||
import { useLogoVariant } from "@app/hooks/useLogoVariant";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import "@app/routes/authShared/auth.css";
|
||||
import "@shared/auth/ui/auth.css";
|
||||
import "@app/routes/authShared/saas-auth.css";
|
||||
|
||||
interface GuestSignInButtonProps {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import "@app/routes/authShared/auth.css";
|
||||
import "@shared/auth/ui/auth.css";
|
||||
import "@app/routes/authShared/saas-auth.css";
|
||||
|
||||
interface EmailPasswordFormProps {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from "@app/hooks/useTranslation";
|
||||
import "@app/routes/authShared/auth.css";
|
||||
import "@shared/auth/ui/auth.css";
|
||||
import "@app/routes/authShared/saas-auth.css";
|
||||
|
||||
interface MagicLinkFormProps {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { oauthProviders } from "@app/constants/authProviders";
|
||||
import { useTranslation } from "@app/hooks/useTranslation";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
import { withBasePath } from "@app/constants/app";
|
||||
import { oauthIconUrl } from "@shared/auth/ui/oauthIcons";
|
||||
|
||||
// Exports for compatibility with proprietary code
|
||||
export const DEBUG_SHOW_ALL_PROVIDERS = false;
|
||||
@@ -45,7 +45,7 @@ export default function OAuthButtons({
|
||||
aria-label={`${t("login.signInWith", "Sign in with")} ${p.label}`}
|
||||
>
|
||||
<img
|
||||
src={withBasePath(`/Login/${p.file}`)}
|
||||
src={oauthIconUrl(p.file)}
|
||||
alt={p.label}
|
||||
className={`oauth-icon-small ${p.isDisabled ? "opacity-20" : ""}`}
|
||||
/>
|
||||
@@ -72,7 +72,7 @@ export default function OAuthButtons({
|
||||
aria-label={`${t("login.signInWith", "Sign in with")} ${p.label}`}
|
||||
>
|
||||
<img
|
||||
src={withBasePath(`/Login/${p.file}`)}
|
||||
src={oauthIconUrl(p.file)}
|
||||
alt={p.label}
|
||||
className={`oauth-icon-medium ${p.isDisabled ? "opacity-20" : ""}`}
|
||||
/>
|
||||
@@ -96,7 +96,7 @@ export default function OAuthButtons({
|
||||
>
|
||||
<span className="oauth-btn-group">
|
||||
<img
|
||||
src={withBasePath(`/Login/${p.file}`)}
|
||||
src={oauthIconUrl(p.file)}
|
||||
alt={p.label}
|
||||
className={`oauth-icon-medium ${p.isDisabled ? "opacity-20" : ""}`}
|
||||
style={{ marginRight: "0.5rem", flexShrink: 0 }}
|
||||
@@ -123,7 +123,7 @@ export default function OAuthButtons({
|
||||
title={p.label}
|
||||
>
|
||||
<img
|
||||
src={withBasePath(`/Login/${p.file}`)}
|
||||
src={oauthIconUrl(p.file)}
|
||||
alt={p.label}
|
||||
className={`oauth-icon-tiny ${p.isDisabled ? "opacity-20" : ""}`}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Portal environment (committed). Machine-specific overrides go in .env.local.
|
||||
|
||||
# Where the "Editor" app switcher / non-admin redirect points. "/" is correct
|
||||
# for production (backend serves the editor at the root, same origin as the
|
||||
# portal). In dev, override in .env.local with your running editor's URL
|
||||
# (e.g. VITE_EDITOR_URL=http://localhost:5173/).
|
||||
VITE_EDITOR_URL=/
|
||||
|
||||
# Force MSW mocks on ("true") or off ("false"). Empty = default (on in dev, off
|
||||
# in production builds). The single-origin proxy task sets this to "false" so the
|
||||
# portal uses the real backend.
|
||||
VITE_PORTAL_MOCKS=
|
||||
@@ -37,6 +37,9 @@ darkMode = "Dark mode"
|
||||
lightMode = "Light mode"
|
||||
search = "Search"
|
||||
searchPlaceholder = "Search…"
|
||||
accountMenu = "Account menu"
|
||||
accountFallback = "Account"
|
||||
signOut = "Sign out"
|
||||
|
||||
[shell.sidebar]
|
||||
brandSuffix = "Stirling Processor"
|
||||
@@ -1737,3 +1740,43 @@ defaultLabel = "Docs processed · last 30 days"
|
||||
delta = "{{pct}}% vs prior 30d"
|
||||
docsValue = "{{value}} docs"
|
||||
srAnnounce = "{{date}}: {{value}} docs"
|
||||
|
||||
# Login screen - keys used by the shared auth UI (@shared/auth/ui/*), which the
|
||||
# portal renders identically to the editor. Values mirror the editor's en-US locale.
|
||||
[login]
|
||||
enterMfaCode = "Enter 6-digit code"
|
||||
enterPassword = "Enter your password"
|
||||
enterUsername = "Enter username"
|
||||
failedToSignIn = "Failed to sign in with {{provider}}: {{message}}"
|
||||
loggingIn = "Logging In..."
|
||||
login = "Login"
|
||||
mfaCode = "Authentication Code"
|
||||
mfaRequired = "Two-factor code required"
|
||||
password = "Password"
|
||||
pleaseEnterBoth = "Please enter both email and password"
|
||||
signInWith = "Sign in with"
|
||||
unexpectedError = "Unexpected error: {{message}}"
|
||||
username = "Username"
|
||||
|
||||
[login.slides.edit]
|
||||
alt = "Edit PDFs"
|
||||
subtitle = "With over a dozen tools to help you redact, sign, read and manipulate PDFs, you will be sure to find what you are looking for."
|
||||
title = "Edit PDFs to display/secure the information you want"
|
||||
|
||||
[login.slides.overview]
|
||||
alt = "Stirling PDF overview"
|
||||
subtitle = "A privacy-first cloud suite for PDFs that lets you convert, sign, redact, and manage documents, along with 50+ other powerful tools."
|
||||
title = "Your one-stop-shop for all your PDF needs."
|
||||
|
||||
[login.slides.secure]
|
||||
alt = "Secure PDFs"
|
||||
subtitle = "Add passwords, redact content, and manage certificates with ease."
|
||||
title = "Protect sensitive information in your PDFs"
|
||||
|
||||
[signup]
|
||||
or = "or"
|
||||
|
||||
# Auth gate (portal is admin-only): transient states shown by AuthGate.
|
||||
[auth]
|
||||
loading = "Loading"
|
||||
redirectingToEditor = "Redirecting to the editor..."
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { AuthProvider } from "@shared/auth";
|
||||
import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext";
|
||||
import { TierProvider } from "@portal/contexts/TierContext";
|
||||
import { UIProvider, useUI } from "@portal/contexts/UIContext";
|
||||
import { mantineTheme } from "@portal/theme/mantineTheme";
|
||||
import { AppShell } from "@portal/components/AppShell";
|
||||
import { AuthGate } from "@portal/components/AuthGate";
|
||||
import { AssistantButton } from "@portal/components/AssistantButton";
|
||||
import { AssistantPanel } from "@portal/components/AssistantPanel";
|
||||
import { SearchModal } from "@portal/components/SearchModal";
|
||||
@@ -60,23 +62,33 @@ function SettingsHost() {
|
||||
}
|
||||
|
||||
export function App() {
|
||||
// Honour the Vite base path so the portal routes correctly when served under a
|
||||
// subpath (e.g. "/portal" behind the single-origin proxy). BASE_URL is "./"
|
||||
// for a standalone build, which isn't a valid router basename, so only pass it
|
||||
// when it's an absolute subpath.
|
||||
const baseUrl = import.meta.env.BASE_URL;
|
||||
const basename = baseUrl.startsWith("/") ? baseUrl : undefined;
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<PortalMantineProvider>
|
||||
<TierProvider initialTier="pro">
|
||||
<BrowserRouter>
|
||||
<UIProvider>
|
||||
<GlobalShortcuts />
|
||||
<AppShell>
|
||||
<ViewRouter />
|
||||
</AppShell>
|
||||
<AssistantButton />
|
||||
<AssistantPanel />
|
||||
<SearchModal />
|
||||
<SettingsHost />
|
||||
</UIProvider>
|
||||
</BrowserRouter>
|
||||
</TierProvider>
|
||||
<AuthProvider mode="spring">
|
||||
<TierProvider initialTier="pro">
|
||||
<BrowserRouter basename={basename}>
|
||||
<UIProvider>
|
||||
<GlobalShortcuts />
|
||||
<AuthGate>
|
||||
<AppShell>
|
||||
<ViewRouter />
|
||||
</AppShell>
|
||||
<AssistantButton />
|
||||
<AssistantPanel />
|
||||
<SearchModal />
|
||||
<SettingsHost />
|
||||
</AuthGate>
|
||||
</UIProvider>
|
||||
</BrowserRouter>
|
||||
</TierProvider>
|
||||
</AuthProvider>
|
||||
</PortalMantineProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,12 @@
|
||||
* In dev and Storybook those requests are intercepted by the MSW handlers in
|
||||
* `mocks/` and answered with fixture data; pointing at a real backend is just
|
||||
* a matter of not registering MSW. Consumers don't change either way.
|
||||
*
|
||||
* The shared `stirling_jwt` bearer token (set by the auth gate, and shared
|
||||
* same-origin with the editor) is attached automatically so portal data calls
|
||||
* are authenticated once real backend endpoints exist.
|
||||
*/
|
||||
import { getStoredToken } from "@shared/auth";
|
||||
|
||||
export interface HttpRequestOptions {
|
||||
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
@@ -26,6 +31,11 @@ export class HttpError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function authHeader(): Record<string, string> {
|
||||
const token = getStoredToken();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Thin JSON fetch wrapper used by every api module. In dev/Storybook the
|
||||
* request is served by MSW; against a real backend it hits the network.
|
||||
@@ -41,6 +51,7 @@ export async function httpJson<T>(
|
||||
...(options.body !== undefined
|
||||
? { "Content-Type": "application/json" }
|
||||
: {}),
|
||||
...authHeader(),
|
||||
...options.headers,
|
||||
},
|
||||
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Where to send users to reach the editor app (app switcher, and the auth gate
|
||||
* bouncing non-admins out).
|
||||
*
|
||||
* Sourced from VITE_EDITOR_URL so it's configurable per deploy rather than
|
||||
* hardcoded. The committed default is "/" (production serves the editor at the
|
||||
* root on the same origin as the portal). For dev cross-app navigation to a
|
||||
* separately-running editor, set VITE_EDITOR_URL in portal/.env.local.
|
||||
*/
|
||||
export const EDITOR_URL = import.meta.env.VITE_EDITOR_URL;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RequireAdmin } from "@shared/auth";
|
||||
import { Spinner } from "@shared/components";
|
||||
import { LoginScreen } from "@portal/components/LoginScreen";
|
||||
import { EDITOR_URL } from "@portal/auth/editorUrl";
|
||||
|
||||
/**
|
||||
* Module-level so the reference is stable across renders (RequireAdmin runs it
|
||||
* from an effect). The portal is admin-only today; authenticated non-admins are
|
||||
* bounced to the editor rather than shown an access-denied page.
|
||||
*/
|
||||
function redirectToEditor(): void {
|
||||
window.location.href = EDITOR_URL;
|
||||
}
|
||||
|
||||
function FullScreenMessage({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100dvh",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "0.75rem",
|
||||
color: "var(--color-text-3)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gates the whole portal behind an authenticated admin session:
|
||||
* - loading -> spinner
|
||||
* - signed out -> login screen
|
||||
* - signed in, not admin -> redirect to the editor
|
||||
* - signed in admin -> the portal
|
||||
*/
|
||||
export function AuthGate({ children }: { children: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<RequireAdmin
|
||||
fallback={<LoginScreen />}
|
||||
onForbidden={redirectToEditor}
|
||||
loading={
|
||||
<FullScreenMessage>
|
||||
<Spinner size="lg" label={t("auth.loading", "Loading")} />
|
||||
</FullScreenMessage>
|
||||
}
|
||||
forbidden={
|
||||
<FullScreenMessage>
|
||||
{t("auth.redirectingToEditor", "Redirecting to the editor...")}
|
||||
</FullScreenMessage>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</RequireAdmin>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Avatar, Dropdown } from "@shared/components";
|
||||
import { useAuth } from "@shared/auth";
|
||||
import { useTheme } from "@portal/contexts/ThemeContext";
|
||||
import { useTier, TIER_INFO, type Tier } from "@portal/contexts/TierContext";
|
||||
import { useView } from "@portal/contexts/ViewContext";
|
||||
@@ -76,6 +77,32 @@ function TierSwitcher() {
|
||||
);
|
||||
}
|
||||
|
||||
function UserMenu() {
|
||||
const { t } = useTranslation();
|
||||
const { displayName, signOut } = useAuth();
|
||||
const name = displayName ?? t("shell.header.accountFallback", "Account");
|
||||
return (
|
||||
<Dropdown.Root align="end">
|
||||
<Dropdown.Trigger>
|
||||
<button
|
||||
type="button"
|
||||
className="portal-header__user"
|
||||
aria-label={t("shell.header.accountMenu", "Account menu")}
|
||||
title={name}
|
||||
>
|
||||
<Avatar name={name} size="md" tone="blue" />
|
||||
</button>
|
||||
</Dropdown.Trigger>
|
||||
<Dropdown.Menu width="12rem">
|
||||
<Dropdown.Item disabled>{name}</Dropdown.Item>
|
||||
<Dropdown.Item onSelect={() => void signOut()}>
|
||||
{t("shell.header.signOut", "Sign out")}
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
const { activeView } = useView();
|
||||
const { openSearch } = useUI();
|
||||
@@ -108,7 +135,7 @@ export function Header() {
|
||||
<ThemeToggle />
|
||||
<NotificationsDropdown />
|
||||
<TierSwitcher />
|
||||
<Avatar name="Reece" size="md" tone="blue" />
|
||||
<UserMenu />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AuthShell } from "@shared/auth/ui/AuthShell";
|
||||
import LoginRightCarousel from "@shared/auth/ui/LoginRightCarousel";
|
||||
import { buildDefaultLoginSlides } from "@shared/auth/ui/loginSlides";
|
||||
import SpringLoginForm from "@shared/auth/ui/SpringLoginForm";
|
||||
import { useSpringLogin } from "@shared/auth/ui/useSpringLogin";
|
||||
import "@shared/auth/ui/auth-theme.css";
|
||||
import "@shared/auth/ui/auth.css";
|
||||
import loginHeader from "@shared/assets/login/LoginLightModeHeader.svg";
|
||||
|
||||
/**
|
||||
* Full-screen login shown by the portal's auth gate. Renders the same screen as
|
||||
* the editor: the shared AuthShell + carousel, with the form body and Spring
|
||||
* auth wiring from @shared/auth/ui (SpringLoginForm + useSpringLogin). The gate
|
||||
* handles "already logged in", so this only needs to collect credentials.
|
||||
*/
|
||||
export function LoginScreen() {
|
||||
const { t } = useTranslation();
|
||||
const login = useSpringLogin();
|
||||
const slides = useMemo(
|
||||
() => buildDefaultLoginSlides((key, fallback) => t(key, fallback)),
|
||||
[t],
|
||||
);
|
||||
|
||||
// Auth pages render in light mode (the shared screen uses light-only tokens).
|
||||
useEffect(() => {
|
||||
const html = document.documentElement;
|
||||
const previous = html.getAttribute("data-mantine-color-scheme");
|
||||
html.setAttribute("data-mantine-color-scheme", "light");
|
||||
return () => {
|
||||
if (previous) html.setAttribute("data-mantine-color-scheme", previous);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
rightPanel={
|
||||
<LoginRightCarousel
|
||||
imageSlides={slides}
|
||||
initialSeconds={5}
|
||||
slideSeconds={8}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SpringLoginForm state={login} logoSrc={loginHeader} />
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useTheme } from "@portal/contexts/ThemeContext";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
import { useAsync } from "@portal/hooks/useAsync";
|
||||
import { fetchHomeKpis, type KpiEntry } from "@portal/api/home";
|
||||
import { EDITOR_URL } from "@portal/auth/editorUrl";
|
||||
import markLight from "@shared/assets/stirling-mark-light.svg";
|
||||
import markDark from "@shared/assets/stirling-mark-dark.svg";
|
||||
import {
|
||||
@@ -24,11 +25,6 @@ import {
|
||||
} from "@portal/components/icons";
|
||||
import "@portal/components/Sidebar.css";
|
||||
|
||||
// The editor is a separate Vite app with no shared shell, so switching apps is
|
||||
// a hard navigation — the editor's dev server in dev, the site root in prod.
|
||||
// A standalone portal deploy can gate this behind a configured editor URL.
|
||||
const EDITOR_URL = import.meta.env.DEV ? "http://localhost:5180/" : "/";
|
||||
|
||||
interface NavEntry {
|
||||
id: ViewId;
|
||||
icon: React.ReactNode;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Mock auth fixtures for the portal's design-prototype mode.
|
||||
*
|
||||
* When mocks are enabled (the default in dev), the portal seeds the mock token
|
||||
* below and the MSW auth handlers answer /api/v1/auth/* with this admin, so the
|
||||
* real auth gate + provider resolve to a signed-in admin without a backend.
|
||||
* When mocks are off (production), real auth against the Spring backend applies.
|
||||
*/
|
||||
|
||||
export interface MockUser {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
role: string;
|
||||
enabled: boolean;
|
||||
authenticationType: string;
|
||||
}
|
||||
|
||||
export const MOCK_ADMIN: MockUser = {
|
||||
id: "mock-admin",
|
||||
email: "admin@stirling.local",
|
||||
username: "admin",
|
||||
role: "ROLE_ADMIN",
|
||||
enabled: true,
|
||||
authenticationType: "WEB",
|
||||
};
|
||||
|
||||
const base64Url = (value: object): string =>
|
||||
btoa(JSON.stringify(value))
|
||||
.replace(/=/g, "")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_");
|
||||
|
||||
/** A decodable (but unsigned) JWT so the client's exp/iat handling stays quiet. */
|
||||
function buildMockToken(): string {
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const header = base64Url({ alg: "none", typ: "JWT" });
|
||||
const payload = base64Url({
|
||||
sub: MOCK_ADMIN.id,
|
||||
role: MOCK_ADMIN.role,
|
||||
iat: nowSeconds,
|
||||
exp: nowSeconds + 86400,
|
||||
});
|
||||
return `${header}.${payload}.mock-signature`;
|
||||
}
|
||||
|
||||
export const MOCK_TOKEN = buildMockToken();
|
||||
|
||||
export const MOCK_SESSION = {
|
||||
access_token: MOCK_TOKEN,
|
||||
expires_in: 86400,
|
||||
};
|
||||
@@ -1,10 +1,27 @@
|
||||
import { setupWorker } from "msw/browser";
|
||||
import { JWT_STORAGE_KEY } from "@shared/auth";
|
||||
import { handlers } from "@portal/mocks/handlers";
|
||||
import { MOCK_TOKEN } from "@portal/mocks/auth";
|
||||
|
||||
export const worker = setupWorker(...handlers);
|
||||
|
||||
let workerStarted = false;
|
||||
|
||||
/**
|
||||
* Seed the shared auth token so the auth gate resolves to the mock admin in
|
||||
* design-prototype mode (no backend needed). Only ever runs when mocks are on;
|
||||
* real deployments leave the token untouched and authenticate for real.
|
||||
*/
|
||||
function seedMockAuthToken(): void {
|
||||
try {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.setItem(JWT_STORAGE_KEY, MOCK_TOKEN);
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable - the gate will simply show the login screen.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the MSW worker. Idempotent — calling repeatedly is safe.
|
||||
*
|
||||
@@ -14,6 +31,7 @@ let workerStarted = false;
|
||||
*/
|
||||
export async function startMockWorker(): Promise<void> {
|
||||
if (workerStarted) return;
|
||||
seedMockAuthToken();
|
||||
await worker.start({
|
||||
onUnhandledRequest: "bypass",
|
||||
serviceWorker: { url: "/mockServiceWorker.js" },
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { http, HttpResponse, delay } from "msw";
|
||||
import { MOCK_ADMIN, MOCK_SESSION } from "@portal/mocks/auth";
|
||||
|
||||
/**
|
||||
* Mock auth endpoints mirroring the Spring backend's contract. In mock mode the
|
||||
* portal seeds a token, so GET /api/v1/auth/me resolves to an admin and the
|
||||
* gate lets the dashboards through without a real backend.
|
||||
*/
|
||||
export const authHandlers = [
|
||||
http.get("/api/v1/auth/me", async () => {
|
||||
await delay(60);
|
||||
return HttpResponse.json({ user: MOCK_ADMIN });
|
||||
}),
|
||||
|
||||
http.post("/api/v1/auth/login", async () => {
|
||||
await delay(120);
|
||||
return HttpResponse.json({ user: MOCK_ADMIN, session: MOCK_SESSION });
|
||||
}),
|
||||
|
||||
http.post("/api/v1/auth/refresh", async () => {
|
||||
await delay(60);
|
||||
return HttpResponse.json({ user: MOCK_ADMIN, session: MOCK_SESSION });
|
||||
}),
|
||||
|
||||
http.post("/api/v1/auth/logout", async () => {
|
||||
await delay(40);
|
||||
return HttpResponse.json({ message: "Logged out successfully" });
|
||||
}),
|
||||
|
||||
http.get("/api/v1/proprietary/ui-data/login", async () => {
|
||||
await delay(40);
|
||||
return HttpResponse.json({
|
||||
enableLogin: true,
|
||||
loginMethod: "all",
|
||||
providerList: {},
|
||||
});
|
||||
}),
|
||||
];
|
||||
@@ -1,4 +1,5 @@
|
||||
import { assistantHandlers } from "@portal/mocks/handlers/assistant";
|
||||
import { authHandlers } from "@portal/mocks/handlers/auth";
|
||||
import { homeHandlers } from "@portal/mocks/handlers/home";
|
||||
import { notificationsHandlers } from "@portal/mocks/handlers/notifications";
|
||||
import { opsHandlers } from "@portal/mocks/handlers/ops";
|
||||
@@ -17,6 +18,7 @@ import { sdkComponentsHandlers } from "@portal/mocks/handlers/sdkComponents";
|
||||
import { editorDeployHandlers } from "@portal/mocks/handlers/editorDeploy";
|
||||
|
||||
export const handlers = [
|
||||
...authHandlers,
|
||||
...homeHandlers,
|
||||
...opsHandlers,
|
||||
...notificationsHandlers,
|
||||
|
||||
@@ -11,9 +11,17 @@ const STORAGE_KEY = "stirling.portal.mocks-enabled";
|
||||
|
||||
export function readMocksPreference(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
// An explicit user toggle (persisted) always wins.
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === "true") return true;
|
||||
if (stored === "false") return false;
|
||||
// Build-time default: VITE_PORTAL_MOCKS forces mocks on/off. The single-origin
|
||||
// proxy sets it false so the portal hits the real backend (otherwise the dev
|
||||
// mock worker would seed a fake token over the shared real one). Falls back to
|
||||
// on-in-dev, off-in-production.
|
||||
const envDefault = import.meta.env.VITE_PORTAL_MOCKS;
|
||||
if (envDefault === "true") return true;
|
||||
if (envDefault === "false") return false;
|
||||
return import.meta.env.DEV;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
/** URL of the editor app (app switcher + non-admin redirect). See portal/.env. */
|
||||
readonly VITE_EDITOR_URL: string;
|
||||
/** Force MSW mocks on/off ("true"/"false"); empty falls back to dev default. */
|
||||
readonly VITE_PORTAL_MOCKS: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
@@ -22,6 +22,23 @@ export default defineConfig(async ({ mode }) => {
|
||||
// Load .env files relative to this config, regardless of where invoked from.
|
||||
const env = loadEnv(mode, import.meta.dirname, "");
|
||||
|
||||
// Backend proxy so the portal shares the editor's origin for auth: with mocks
|
||||
// off, /api/v1/auth/* (and OAuth/SAML redirects) reach the Spring backend and
|
||||
// the same stirling_jwt token works across both apps. Mirrors the editor's
|
||||
// proxy; override the target via BACKEND_URL.
|
||||
const backendUrl = process.env.BACKEND_URL || "http://localhost:8080";
|
||||
const backendProxy = {
|
||||
target: backendUrl,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
xfwd: true,
|
||||
};
|
||||
const backendProxyConfig = {
|
||||
"/api": backendProxy,
|
||||
"/oauth2": backendProxy,
|
||||
"/saml2": backendProxy,
|
||||
};
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
react(),
|
||||
@@ -50,11 +67,13 @@ export default defineConfig(async ({ mode }) => {
|
||||
fs: {
|
||||
allow: [resolve(import.meta.dirname, "..")],
|
||||
},
|
||||
proxy: backendProxyConfig,
|
||||
},
|
||||
preview: {
|
||||
host: true,
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
proxy: backendProxyConfig,
|
||||
},
|
||||
build: {
|
||||
outDir: "../dist-portal",
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Single-origin dev server for testing unified auth locally.
|
||||
*
|
||||
* The editor and portal store their session as a `stirling_jwt` token in
|
||||
* localStorage, which the browser scopes per origin. On separate dev ports they
|
||||
* can't share it; this server fronts both apps plus the backend on ONE origin
|
||||
* so a token from one app is automatically seen by the other - mirroring the
|
||||
* real same-origin production topology.
|
||||
*
|
||||
* Routing (single port):
|
||||
* /api, /oauth2, /saml2, /v1/api-docs -> reverse-proxy to the backend
|
||||
* /portal, /portal/* -> the portal app
|
||||
* everything else -> the editor app (SPA)
|
||||
*
|
||||
* Each app is served either from a production build (static files) or from a
|
||||
* running Vite dev server, chosen per app:
|
||||
* - EDITOR_DEV_URL / PORTAL_DEV_URL set -> reverse-proxy to that dev server.
|
||||
* - otherwise serve EDITOR_DIST / PORTAL_DIST as static files (SPA fallback).
|
||||
*
|
||||
* Vite HMR websockets connect straight to each dev server's own port, so live
|
||||
* mode keeps hot reload without this proxy needing to multiplex HMR sockets.
|
||||
*
|
||||
* Config via env: PORT, BACKEND_URL, EDITOR_DEV_URL, PORTAL_DEV_URL,
|
||||
* EDITOR_DIST, PORTAL_DIST.
|
||||
*/
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { Duplex } from "node:stream";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const FRONTEND = path.resolve(here, "..");
|
||||
|
||||
const PORT = Number(process.env.PORT || 3000);
|
||||
const BACKEND_URL = process.env.BACKEND_URL || "http://localhost:8080";
|
||||
const EDITOR_DEV_URL = process.env.EDITOR_DEV_URL || "";
|
||||
const PORTAL_DEV_URL = process.env.PORTAL_DEV_URL || "";
|
||||
const EDITOR_DIST = path.resolve(
|
||||
process.env.EDITOR_DIST || path.join(FRONTEND, "editor", "dist"),
|
||||
);
|
||||
const PORTAL_DIST = path.resolve(
|
||||
process.env.PORTAL_DIST || path.join(FRONTEND, "dist-portal"),
|
||||
);
|
||||
|
||||
const backend = new URL(BACKEND_URL);
|
||||
const editorDev = EDITOR_DEV_URL ? new URL(EDITOR_DEV_URL) : null;
|
||||
const portalDev = PORTAL_DEV_URL ? new URL(PORTAL_DEV_URL) : null;
|
||||
|
||||
// Paths owned by the backend (mirrors the editor/portal Vite dev proxies).
|
||||
const API_PREFIXES = ["/api", "/oauth2", "/saml2", "/v1/api-docs"];
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript",
|
||||
".mjs": "text/javascript",
|
||||
".css": "text/css",
|
||||
".json": "application/json",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".ico": "image/x-icon",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".ttf": "font/ttf",
|
||||
".wasm": "application/wasm",
|
||||
".map": "application/json",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
".toml": "text/plain; charset=utf-8",
|
||||
};
|
||||
|
||||
function isApiPath(pathname: string): boolean {
|
||||
return API_PREFIXES.some(
|
||||
(p) => pathname === p || pathname.startsWith(`${p}/`),
|
||||
);
|
||||
}
|
||||
|
||||
function clientFor(target: URL): typeof http | typeof https {
|
||||
return target.protocol === "https:" ? https : http;
|
||||
}
|
||||
|
||||
function proxyHttp(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
target: URL,
|
||||
label: string,
|
||||
): void {
|
||||
const upstream = clientFor(target).request(
|
||||
{
|
||||
protocol: target.protocol,
|
||||
hostname: target.hostname,
|
||||
port: target.port,
|
||||
path: req.url,
|
||||
method: req.method,
|
||||
headers: { ...req.headers, host: target.host },
|
||||
},
|
||||
(upRes) => {
|
||||
res.writeHead(upRes.statusCode || 502, upRes.headers);
|
||||
upRes.pipe(res);
|
||||
},
|
||||
);
|
||||
upstream.on("error", (err: Error) => {
|
||||
res.writeHead(502, { "Content-Type": "text/plain" });
|
||||
res.end(`${label} not reachable at ${target.origin}: ${err.message}`);
|
||||
});
|
||||
req.pipe(upstream);
|
||||
}
|
||||
|
||||
async function serveStatic(
|
||||
distDir: string,
|
||||
urlPath: string,
|
||||
res: http.ServerResponse,
|
||||
): Promise<void> {
|
||||
const pathname = decodeURIComponent((urlPath.split("?")[0] || "/").trim());
|
||||
const hasExtension = path.extname(pathname) !== "";
|
||||
// Routes (no file extension) and "/" fall back to index.html so the SPA
|
||||
// router can take over; real assets resolve to their file.
|
||||
const candidate = hasExtension
|
||||
? path.join(distDir, pathname)
|
||||
: path.join(distDir, "index.html");
|
||||
const resolved = path.resolve(candidate);
|
||||
|
||||
// Path-traversal guard.
|
||||
if (resolved !== distDir && !resolved.startsWith(distDir + path.sep)) {
|
||||
res.writeHead(403, { "Content-Type": "text/plain" });
|
||||
res.end("Forbidden");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await stat(resolved);
|
||||
if (!info.isFile()) throw new Error("not a file");
|
||||
} catch {
|
||||
res.writeHead(404, { "Content-Type": "text/plain" });
|
||||
res.end(
|
||||
hasExtension
|
||||
? "Not found"
|
||||
: `index.html not found in ${distDir}. Did the build run? (task dev:portal:proxy builds first)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const ext = path.extname(resolved).toLowerCase();
|
||||
res.writeHead(200, {
|
||||
"Content-Type": MIME[ext] || "application/octet-stream",
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
createReadStream(resolved).pipe(res);
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const pathname = (req.url || "/").split("?")[0];
|
||||
|
||||
if (isApiPath(pathname)) {
|
||||
proxyHttp(req, res, backend, "Backend");
|
||||
return;
|
||||
}
|
||||
if (pathname === "/portal" || pathname.startsWith("/portal/")) {
|
||||
if (portalDev) {
|
||||
// Dev server is served under the /portal base, so keep the full path.
|
||||
proxyHttp(req, res, portalDev, "Portal dev server");
|
||||
} else {
|
||||
const rest = (req.url || "").slice("/portal".length) || "/";
|
||||
void serveStatic(PORTAL_DIST, rest, res);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (editorDev) {
|
||||
proxyHttp(req, res, editorDev, "Editor dev server");
|
||||
} else {
|
||||
void serveStatic(EDITOR_DIST, req.url || "/", res);
|
||||
}
|
||||
});
|
||||
|
||||
// Forward websocket upgrades to the matching upstream. Backend streaming
|
||||
// endpoints need /api; the dev servers' HMR sockets connect directly to their
|
||||
// own ports, but routing them here too keeps things working if a browser sends
|
||||
// them through the proxy.
|
||||
function proxyUpgrade(
|
||||
req: http.IncomingMessage,
|
||||
socket: Duplex,
|
||||
head: Buffer,
|
||||
target: URL,
|
||||
): void {
|
||||
const upstream = clientFor(target).request({
|
||||
protocol: target.protocol,
|
||||
hostname: target.hostname,
|
||||
port: target.port,
|
||||
path: req.url,
|
||||
method: req.method,
|
||||
headers: { ...req.headers, host: target.host },
|
||||
});
|
||||
upstream.on("upgrade", (upRes, upSocket, upHead) => {
|
||||
const headerLines = Object.entries(upRes.headers).map(
|
||||
([k, v]) => `${k}: ${v as string}`,
|
||||
);
|
||||
socket.write(
|
||||
`HTTP/1.1 101 Switching Protocols\r\n${headerLines.join("\r\n")}\r\n\r\n`,
|
||||
);
|
||||
if (upHead && upHead.length) upSocket.unshift(upHead);
|
||||
if (head && head.length) upSocket.write(head);
|
||||
upSocket.pipe(socket);
|
||||
socket.pipe(upSocket);
|
||||
upSocket.on("error", () => socket.destroy());
|
||||
socket.on("error", () => upSocket.destroy());
|
||||
});
|
||||
upstream.on("error", () => socket.destroy());
|
||||
upstream.end();
|
||||
}
|
||||
|
||||
server.on("upgrade", (req, socket, head) => {
|
||||
const pathname = (req.url || "/").split("?")[0];
|
||||
if (isApiPath(pathname)) {
|
||||
proxyUpgrade(req, socket, head, backend);
|
||||
} else if (
|
||||
portalDev &&
|
||||
(pathname === "/portal" || pathname.startsWith("/portal/"))
|
||||
) {
|
||||
proxyUpgrade(req, socket, head, portalDev);
|
||||
} else if (editorDev) {
|
||||
proxyUpgrade(req, socket, head, editorDev);
|
||||
} else {
|
||||
socket.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
const editorSrc = editorDev ? `${editorDev.origin} (dev)` : EDITOR_DIST;
|
||||
const portalSrc = portalDev ? `${portalDev.origin} (dev)` : PORTAL_DIST;
|
||||
console.log("");
|
||||
console.log(" Unified-auth single-origin server");
|
||||
console.log(` ▶ open http://localhost:${PORT}/ (editor)`);
|
||||
console.log(` ▶ open http://localhost:${PORT}/portal (portal)`);
|
||||
console.log(` ▶ backend ${BACKEND_URL} (proxying /api, /oauth2, /saml2)`);
|
||||
console.log(` ▶ editor ${editorSrc}`);
|
||||
console.log(` ▶ portal ${portalSrc}`);
|
||||
console.log(
|
||||
" Log into one, open the other - the stirling_jwt token is shared (same origin).",
|
||||
);
|
||||
console.log("");
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2022",
|
||||
"lib": ["es2022"],
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["./**/*.ts", "./**/*.mts"]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 717 KiB After Width: | Height: | Size: 717 KiB |
|
Before Width: | Height: | Size: 211 KiB After Width: | Height: | Size: 211 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 229 KiB After Width: | Height: | Size: 229 KiB |
|
Before Width: | Height: | Size: 426 B After Width: | Height: | Size: 426 B |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 292 B After Width: | Height: | Size: 292 B |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Unified auth provider. Selects the Spring (self-hosted JWT) or Supabase
|
||||
* (cloud) backend by `mode` and feeds the single shared AuthContext, so
|
||||
* consumers read `useAuth()` identically either way.
|
||||
*/
|
||||
import { lazy, Suspense, type ReactNode } from "react";
|
||||
import { SpringAuthProvider } from "@shared/auth/spring/UseSession";
|
||||
import { type AuthMode, type AuthTranslate } from "@shared/auth/types";
|
||||
|
||||
// Lazy so Spring-mode hosts (e.g. the portal) don't bundle @supabase/supabase-js
|
||||
// they never use; only loaded when mode="supabase".
|
||||
const SupabaseAuthProvider = lazy(() =>
|
||||
import("@shared/auth/supabase/UseSession").then((m) => ({
|
||||
default: m.SupabaseAuthProvider,
|
||||
})),
|
||||
);
|
||||
|
||||
export interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
/** Which backend to authenticate against. Defaults to "spring". */
|
||||
mode?: AuthMode;
|
||||
/** Optional i18n translate for user-facing copy (defaults to English). */
|
||||
translate?: AuthTranslate;
|
||||
}
|
||||
|
||||
export function AuthProvider({
|
||||
children,
|
||||
mode = "spring",
|
||||
translate,
|
||||
}: AuthProviderProps) {
|
||||
if (mode === "supabase") {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<SupabaseAuthProvider translate={translate}>
|
||||
{children}
|
||||
</SupabaseAuthProvider>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SpringAuthProvider translate={translate}>{children}</SpringAuthProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Dependency injection seam for the shared Spring auth engine.
|
||||
*
|
||||
* The engine is created at import time (it starts a session-monitoring timer),
|
||||
* so configuration is read lazily through {@link getSpringAuthConfig}. Hosts
|
||||
* call {@link configureSpringAuth} once at startup:
|
||||
*
|
||||
* - Editor: injects its `@app/services/apiClient` plus a platform bridge built
|
||||
* from its per-flavor `@app/extensions/*` seams, so desktop/saas behaviour is
|
||||
* unchanged.
|
||||
* - Portal: relies on the web defaults below (same-origin transport + no-op
|
||||
* platform bridge).
|
||||
*/
|
||||
import type { AxiosInstance } from "axios";
|
||||
import { createDefaultHttpClient } from "@shared/auth/httpClient";
|
||||
import {
|
||||
defaultPlatformBridge,
|
||||
type PlatformBridge,
|
||||
} from "@shared/auth/spring/platformBridge";
|
||||
|
||||
export interface SpringAuthConfig {
|
||||
/** Axios instance used for all /api/v1/auth + /api/v1/user calls. */
|
||||
http: AxiosInstance;
|
||||
/** App base path (subpath deploys); used to build OAuth redirect targets. */
|
||||
basePath: string;
|
||||
/** Platform seam (web no-op by default; desktop injects Tauri behaviour). */
|
||||
platform: PlatformBridge;
|
||||
}
|
||||
|
||||
let config: SpringAuthConfig | null = null;
|
||||
|
||||
export function getSpringAuthConfig(): SpringAuthConfig {
|
||||
if (!config) {
|
||||
config = {
|
||||
http: createDefaultHttpClient(),
|
||||
basePath: "",
|
||||
platform: defaultPlatformBridge,
|
||||
};
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the shared Spring auth engine. Any field left undefined keeps its
|
||||
* current (or default) value, so hosts can configure incrementally.
|
||||
*/
|
||||
export function configureSpringAuth(partial: Partial<SpringAuthConfig>): void {
|
||||
const current = getSpringAuthConfig();
|
||||
config = {
|
||||
http: partial.http ?? current.http,
|
||||
basePath: partial.basePath ?? current.basePath,
|
||||
platform: partial.platform ?? current.platform,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* The single React context backing `useAuth()`. Both the Spring and Supabase
|
||||
* providers write to this same context so consumers read a unified value
|
||||
* regardless of which backend authenticated the user.
|
||||
*/
|
||||
import { createContext, useContext } from "react";
|
||||
import type { AuthContextValue } from "@shared/auth/types";
|
||||
|
||||
export const AuthContext = createContext<AuthContextValue>({
|
||||
session: null,
|
||||
user: null,
|
||||
displayName: null,
|
||||
isAnonymous: false,
|
||||
isAdmin: false,
|
||||
role: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
signOut: async () => {},
|
||||
refreshSession: async () => {},
|
||||
});
|
||||
|
||||
/** Access the current auth state. Must be used within an AuthProvider. */
|
||||
export function useAuth(): AuthContextValue {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Gate that renders its children only for an authenticated admin.
|
||||
*
|
||||
* - Still loading -> `loading`
|
||||
* - Signed out -> `fallback` (login screen)
|
||||
* - Signed in but not admin -> calls `onForbidden` (e.g. redirect to the
|
||||
* editor) and renders `forbidden` in the meantime
|
||||
*/
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { useAuth } from "@shared/auth/context";
|
||||
|
||||
export interface RequireAdminProps {
|
||||
children: ReactNode;
|
||||
/** Rendered when there is no session (e.g. the login panel). */
|
||||
fallback: ReactNode;
|
||||
/** Invoked once when an authenticated non-admin is detected. */
|
||||
onForbidden: () => void;
|
||||
/** Rendered while the session is still resolving. */
|
||||
loading?: ReactNode;
|
||||
/** Rendered for an authenticated non-admin (while `onForbidden` runs). */
|
||||
forbidden?: ReactNode;
|
||||
}
|
||||
|
||||
export function RequireAdmin({
|
||||
children,
|
||||
fallback,
|
||||
onForbidden,
|
||||
loading = null,
|
||||
forbidden = null,
|
||||
}: RequireAdminProps) {
|
||||
const { session, loading: isLoading, isAdmin } = useAuth();
|
||||
|
||||
const shouldRedirect = !isLoading && !!session && !isAdmin;
|
||||
useEffect(() => {
|
||||
if (shouldRedirect) onForbidden();
|
||||
}, [shouldRedirect, onForbidden]);
|
||||
|
||||
if (isLoading) return <>{loading}</>;
|
||||
if (!session) return <>{fallback}</>;
|
||||
if (!isAdmin) return <>{forbidden}</>;
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Gate that renders its children only for an authenticated session. While auth
|
||||
* is initialising it renders `loading`; when signed out it renders `fallback`
|
||||
* (typically a login screen).
|
||||
*/
|
||||
import { type ReactNode } from "react";
|
||||
import { useAuth } from "@shared/auth/context";
|
||||
|
||||
export interface RequireAuthProps {
|
||||
children: ReactNode;
|
||||
/** Rendered when there is no session (e.g. the login panel). */
|
||||
fallback: ReactNode;
|
||||
/** Rendered while the session is still resolving. */
|
||||
loading?: ReactNode;
|
||||
}
|
||||
|
||||
export function RequireAuth({
|
||||
children,
|
||||
fallback,
|
||||
loading = null,
|
||||
}: RequireAuthProps) {
|
||||
const { session, loading: isLoading } = useAuth();
|
||||
if (isLoading) return <>{loading}</>;
|
||||
if (!session) return <>{fallback}</>;
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Default HTTP transport for the shared auth engine.
|
||||
*
|
||||
* The editor injects its own richer axios instance (with platform routing,
|
||||
* error toasts, credit headers, ...) via {@link configureSpringAuth}. Apps that
|
||||
* don't have one - notably the portal - fall back to this minimal client, which
|
||||
* attaches the `stirling_jwt` bearer token so the portal and editor share a
|
||||
* single same-origin session.
|
||||
*/
|
||||
import axios, { type AxiosInstance } from "axios";
|
||||
|
||||
/** localStorage key holding the Spring JWT. Shared so portal + editor agree. */
|
||||
export const JWT_STORAGE_KEY = "stirling_jwt";
|
||||
|
||||
export function getStoredToken(): string | null {
|
||||
try {
|
||||
if (typeof localStorage === "undefined") return null;
|
||||
return localStorage.getItem(JWT_STORAGE_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setStoredToken(token: string): void {
|
||||
try {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.setItem(JWT_STORAGE_KEY, token);
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable (private mode) - fail open
|
||||
}
|
||||
}
|
||||
|
||||
export function clearStoredToken(): void {
|
||||
try {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.removeItem(JWT_STORAGE_KEY);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the fallback transport. `baseURL` defaults to "/" so it targets the
|
||||
* same origin that served the SPA - the backend serves both portal and editor,
|
||||
* so the cookie/token domain is shared.
|
||||
*/
|
||||
export function createDefaultHttpClient(baseURL = "/"): AxiosInstance {
|
||||
const client = axios.create({
|
||||
baseURL,
|
||||
responseType: "json",
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
client.interceptors.request.use((config) => {
|
||||
const token = getStoredToken();
|
||||
if (token) {
|
||||
config.headers = config.headers ?? {};
|
||||
// Respect an explicit Authorization header (e.g. /auth/me passes the
|
||||
// candidate token directly); only fill it in when absent.
|
||||
if (!config.headers.Authorization) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
return client;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Shared, provider-agnostic auth used by both the editor and the portal.
|
||||
*
|
||||
* Hosts configure a backend once at startup:
|
||||
* - Spring: configureSpringAuth({ http, basePath, platform }) then render
|
||||
* <AuthProvider mode="spring">.
|
||||
* - Supabase: configureSupabase({ url, key }) then render
|
||||
* <AuthProvider mode="supabase">.
|
||||
*
|
||||
* Consumers read state via useAuth(); guards (RequireAuth/RequireAdmin) gate UI.
|
||||
*/
|
||||
|
||||
// Contract + helpers
|
||||
export * from "@shared/auth/types";
|
||||
export { isAdminRole } from "@shared/auth/roles";
|
||||
export { AuthContext, useAuth } from "@shared/auth/context";
|
||||
|
||||
// Unified provider + guards
|
||||
export {
|
||||
AuthProvider,
|
||||
type AuthProviderProps,
|
||||
} from "@shared/auth/AuthProvider";
|
||||
export {
|
||||
RequireAuth,
|
||||
type RequireAuthProps,
|
||||
} from "@shared/auth/guards/RequireAuth";
|
||||
export {
|
||||
RequireAdmin,
|
||||
type RequireAdminProps,
|
||||
} from "@shared/auth/guards/RequireAdmin";
|
||||
|
||||
// Spring backend
|
||||
export {
|
||||
configureSpringAuth,
|
||||
getSpringAuthConfig,
|
||||
type SpringAuthConfig,
|
||||
} from "@shared/auth/config";
|
||||
export {
|
||||
type PlatformBridge,
|
||||
type PlatformSessionUser,
|
||||
defaultPlatformBridge,
|
||||
} from "@shared/auth/spring/platformBridge";
|
||||
export {
|
||||
createDefaultHttpClient,
|
||||
getStoredToken,
|
||||
setStoredToken,
|
||||
clearStoredToken,
|
||||
JWT_STORAGE_KEY,
|
||||
} from "@shared/auth/httpClient";
|
||||
export {
|
||||
SpringAuthProvider,
|
||||
deriveDisplayName,
|
||||
} from "@shared/auth/spring/UseSession";
|
||||
export {
|
||||
springAuth,
|
||||
setPostLoginRedirectPath,
|
||||
consumePostLoginRedirectPath,
|
||||
isSafePostLoginRedirect,
|
||||
POST_LOGIN_REDIRECT_STORAGE_KEY,
|
||||
getCurrentUser,
|
||||
isUserAnonymous,
|
||||
createAnonymousUser,
|
||||
createAnonymousSession,
|
||||
} from "@shared/auth/spring/springAuthClient";
|
||||
export type { OAuthProvider } from "@shared/auth/spring/oauthTypes";
|
||||
|
||||
// Supabase backend is intentionally NOT re-exported here: doing so would pull
|
||||
// @supabase/supabase-js into every barrel consumer (e.g. the portal in Spring
|
||||
// mode). Supabase-mode hosts import directly from the subpath:
|
||||
// import { configureSupabase } from "@shared/auth/supabase/supabaseClient";
|
||||
// The unified <AuthProvider mode="supabase"> lazy-loads the provider on demand.
|
||||
|
||||
// Login UI components live under @shared/auth/ui/* and are imported directly
|
||||
// (default exports), e.g. `import OAuthButtons from "@shared/auth/ui/OAuthButtons"`.
|
||||
// They use react-i18next, so hosts must initialise i18next.
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Role helpers shared across apps.
|
||||
*
|
||||
* The Spring backend serialises authorities into a single string (e.g.
|
||||
* "ROLE_ADMIN" or a space/comma separated list). Supabase carries roles in
|
||||
* app_metadata. Both normalise through {@link isAdminRole}.
|
||||
*/
|
||||
|
||||
const ADMIN_TOKENS = new Set(["ADMIN", "ROLE_ADMIN"]);
|
||||
|
||||
/**
|
||||
* True when the supplied role string grants admin access. Tolerates a single
|
||||
* role ("ROLE_ADMIN"), a space/comma separated list, and casing differences.
|
||||
*/
|
||||
export function isAdminRole(role: string | null | undefined): boolean {
|
||||
if (!role) return false;
|
||||
return role
|
||||
.toUpperCase()
|
||||
.split(/[\s,]+/)
|
||||
.filter(Boolean)
|
||||
.some((token) => ADMIN_TOKENS.has(token));
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useEffect, useState, useCallback, type ReactNode } from "react";
|
||||
import { springAuth } from "@shared/auth/spring/springAuthClient";
|
||||
import { getSpringAuthConfig } from "@shared/auth/config";
|
||||
import { isAdminRole } from "@shared/auth/roles";
|
||||
import { AuthContext } from "@shared/auth/context";
|
||||
import {
|
||||
defaultTranslate,
|
||||
type AuthContextValue,
|
||||
type AuthChangeEvent,
|
||||
type AuthError,
|
||||
type AuthSession,
|
||||
type AuthUser,
|
||||
type AuthTranslate,
|
||||
} from "@shared/auth/types";
|
||||
|
||||
/**
|
||||
* Strip the configured base path so route comparisons work under subpath
|
||||
* deploys. Mirrors the editor's `stripBasePath` but reads the injected base.
|
||||
*/
|
||||
function stripBasePath(pathname: string): string {
|
||||
const base = getSpringAuthConfig().basePath;
|
||||
if (!base) return pathname;
|
||||
if (pathname === base) return "/";
|
||||
if (pathname.startsWith(`${base}/`)) return pathname.slice(base.length);
|
||||
return pathname;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a display name from the Spring user. Anonymous users get the
|
||||
* (optionally localised) "User" placeholder; returns null only when there is
|
||||
* no user object at all so consumers can pick their own fallback.
|
||||
*/
|
||||
export function deriveDisplayName(
|
||||
user: AuthUser | null | undefined,
|
||||
translate: AuthTranslate = defaultTranslate,
|
||||
): string | null {
|
||||
if (!user) return null;
|
||||
if (user.is_anonymous) return translate("auth.displayName.user", "User");
|
||||
return user.username || user.email || null;
|
||||
}
|
||||
|
||||
export interface SpringAuthProviderProps {
|
||||
children: ReactNode;
|
||||
/**
|
||||
* Optional translate function for user-facing copy. The editor passes one
|
||||
* backed by i18next; the portal omits it and gets English fallbacks.
|
||||
*/
|
||||
translate?: AuthTranslate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth Provider Component
|
||||
*
|
||||
* Manages authentication state and provides it to the app. Integrates with the
|
||||
* Spring Security + JWT backend via the shared engine.
|
||||
*/
|
||||
export function SpringAuthProvider({
|
||||
children,
|
||||
translate = defaultTranslate,
|
||||
}: SpringAuthProviderProps) {
|
||||
const [session, setSession] = useState<AuthSession | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<AuthError | null>(null);
|
||||
|
||||
// Debug: Track state transitions
|
||||
useEffect(() => {
|
||||
console.log("[Auth] State changed:", {
|
||||
loading,
|
||||
hasSession: !!session,
|
||||
hasError: !!error,
|
||||
userId: session?.user?.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}, [loading, session, error]);
|
||||
|
||||
const refreshSession = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const { data, error } = await springAuth.refreshSession();
|
||||
|
||||
if (error) {
|
||||
console.error("[Auth] Session refresh error:", error);
|
||||
setError(error);
|
||||
setSession(null);
|
||||
} else {
|
||||
setSession(data.session);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Auth] Unexpected error during session refresh:", err);
|
||||
setError(err as AuthError);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const { error } = await springAuth.signOut();
|
||||
|
||||
// Always clear the in-memory session: springAuth.signOut() removes the
|
||||
// local token and platform user_info even when the backend POST fails,
|
||||
// so the user is effectively signed out either way.
|
||||
setSession(null);
|
||||
|
||||
if (error) {
|
||||
console.error("[Auth] Sign out error:", error);
|
||||
setError(error);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Auth] Unexpected error during sign out:", err);
|
||||
setSession(null);
|
||||
setError(err as AuthError);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const initializeAuth = async () => {
|
||||
try {
|
||||
// Clear any platform-specific cached auth on login page init.
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
stripBasePath(window.location.pathname).startsWith("/login")
|
||||
) {
|
||||
await getSpringAuthConfig().platform.clearPlatformAuthOnLoginInit();
|
||||
}
|
||||
|
||||
const { data, error } = await springAuth.getSession();
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (error) {
|
||||
console.error("[Auth] Initial session error:", error);
|
||||
setError(error);
|
||||
} else {
|
||||
setSession(data.session);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[Auth] Unexpected error during auth initialization:",
|
||||
err,
|
||||
);
|
||||
if (mounted) {
|
||||
setError(err as AuthError);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
initializeAuth();
|
||||
|
||||
// Listen for jwt-available event (triggered by desktop auth or AuthCallback)
|
||||
const handleJwtAvailable = () => {
|
||||
setLoading(true); // Prevent unstable renders during auth state transition
|
||||
setError(null);
|
||||
void initializeAuth();
|
||||
};
|
||||
|
||||
window.addEventListener("jwt-available", handleJwtAvailable);
|
||||
|
||||
// Subscribe to auth state changes
|
||||
const {
|
||||
data: { subscription },
|
||||
} = springAuth.onAuthStateChange(
|
||||
async (_event: AuthChangeEvent, newSession: AuthSession | null) => {
|
||||
if (!mounted) return;
|
||||
// Schedule state update on the next tick to match the previous behaviour.
|
||||
setTimeout(() => {
|
||||
if (mounted) {
|
||||
setSession(newSession);
|
||||
setError(null);
|
||||
}
|
||||
}, 0);
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
window.removeEventListener("jwt-available", handleJwtAvailable);
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
// Run once on mount: the provider owns its own subscription lifecycle.
|
||||
}, []);
|
||||
|
||||
const user = session?.user ?? null;
|
||||
const value: AuthContextValue = {
|
||||
session,
|
||||
user,
|
||||
displayName: deriveDisplayName(user, translate),
|
||||
isAnonymous: user?.is_anonymous === true,
|
||||
isAdmin: isAdminRole(user?.role),
|
||||
role: user?.role ?? null,
|
||||
loading,
|
||||
error,
|
||||
signOut,
|
||||
refreshSession,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Platform seam for the Spring auth client.
|
||||
*
|
||||
* The client itself is platform-agnostic: it never inspects the JWT directly
|
||||
* or touches Tauri/desktop storage. Each host wires its own behaviour through
|
||||
* this bridge. The web default (used by the portal and the editor's web builds)
|
||||
* is a no-op set that mirrors the editor's previous `@app/extensions/*`
|
||||
* defaults exactly; the editor's desktop build injects a Tauri-backed bridge.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Resolved identity for the current session, as understood by the platform
|
||||
* layer that owns the underlying token format. Treated as opaque by the client.
|
||||
*/
|
||||
export interface PlatformSessionUser {
|
||||
username: string;
|
||||
email?: string;
|
||||
/** True for anonymous/guest sessions (e.g. Supabase anonymous sign-in). */
|
||||
is_anonymous?: boolean;
|
||||
}
|
||||
|
||||
export interface PlatformBridge {
|
||||
/** Clear platform-specific cached auth after sign-out (e.g. Tauri store). */
|
||||
clearPlatformAuthAfterSignOut(): Promise<void>;
|
||||
/** Clear platform-specific cached auth when the login page initialises. */
|
||||
clearPlatformAuthOnLoginInit(): Promise<void>;
|
||||
/** Whether the active backend is a desktop SaaS gateway (Supabase-managed). */
|
||||
isDesktopSaaSAuthMode(): Promise<boolean>;
|
||||
/** Whether the active backend exposes /api/v1/auth/logout. */
|
||||
shouldCallBackendLogout(): Promise<boolean>;
|
||||
/** Resolve the current user from platform storage (desktop only). */
|
||||
getPlatformSessionUser(): Promise<PlatformSessionUser | null>;
|
||||
/** Refresh the session through the platform layer (desktop only). */
|
||||
refreshPlatformSession(): Promise<boolean>;
|
||||
/** Persist the token to platform-specific storage (Tauri store). */
|
||||
savePlatformToken(token: string): Promise<void>;
|
||||
/** Begin an OAuth navigation; return true if the platform handled it. */
|
||||
startOAuthNavigation(redirectUrl: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Web defaults - byte-for-byte equivalent to the editor's previous
|
||||
* proprietary/extensions defaults so web behaviour is unchanged.
|
||||
*/
|
||||
export const defaultPlatformBridge: PlatformBridge = {
|
||||
async clearPlatformAuthAfterSignOut() {
|
||||
// no-op for web builds
|
||||
},
|
||||
async clearPlatformAuthOnLoginInit() {
|
||||
// no-op for web builds
|
||||
},
|
||||
async isDesktopSaaSAuthMode() {
|
||||
return false;
|
||||
},
|
||||
async shouldCallBackendLogout() {
|
||||
return true;
|
||||
},
|
||||
async getPlatformSessionUser() {
|
||||
return null;
|
||||
},
|
||||
async refreshPlatformSession() {
|
||||
return false;
|
||||
},
|
||||
async savePlatformToken() {
|
||||
// Web mode: token already saved to localStorage in the auth client.
|
||||
},
|
||||
async startOAuthNavigation() {
|
||||
return false;
|
||||
},
|
||||
};
|
||||
@@ -1,26 +1,40 @@
|
||||
/**
|
||||
* Spring Auth Client
|
||||
* Spring Auth Client (shared engine)
|
||||
*
|
||||
* This client integrates with the Spring Security + JWT backend.
|
||||
* Integrates with the Spring Security + JWT backend.
|
||||
* - Uses localStorage for JWT storage (sent via Authorization header)
|
||||
* - JWT validation handled server-side
|
||||
* - No email confirmation flow (auto-confirmed on registration)
|
||||
*
|
||||
* This is the platform-agnostic engine. The HTTP transport, base path and
|
||||
* platform-specific behaviour are injected via `@shared/auth/config` so the
|
||||
* same code backs the editor (which injects its apiClient + desktop bridge)
|
||||
* and the portal (web defaults).
|
||||
*/
|
||||
|
||||
import apiClient from "@app/services/apiClient";
|
||||
import { AxiosError } from "axios";
|
||||
import { BASE_PATH } from "@app/constants/app";
|
||||
import { type OAuthProvider } from "@app/auth/oauthTypes";
|
||||
import { resetOAuthState } from "@app/auth/oauthStorage";
|
||||
import { clearPlatformAuthAfterSignOut } from "@app/extensions/authSessionCleanup";
|
||||
import {
|
||||
getPlatformSessionUser,
|
||||
isDesktopSaaSAuthMode,
|
||||
refreshPlatformSession,
|
||||
savePlatformToken,
|
||||
shouldCallBackendLogout,
|
||||
} from "@app/extensions/platformSessionBridge";
|
||||
import { startOAuthNavigation } from "@app/extensions/oauthNavigation";
|
||||
import { AxiosError, type AxiosRequestConfig } from "axios";
|
||||
import { getSpringAuthConfig } from "@shared/auth/config";
|
||||
import { type OAuthProvider } from "@shared/auth/spring/oauthTypes";
|
||||
import { resetOAuthState } from "@shared/auth/spring/oauthStorage";
|
||||
import type {
|
||||
AuthUser as User,
|
||||
AuthSession as Session,
|
||||
AuthError,
|
||||
AuthResponse,
|
||||
AuthChangeEvent,
|
||||
} from "@shared/auth/types";
|
||||
|
||||
export type { User, Session, AuthError, AuthResponse, AuthChangeEvent };
|
||||
|
||||
/** Axios config plus the editor's custom request flags (ignored by the portal). */
|
||||
type AuthRequestConfig = AxiosRequestConfig & {
|
||||
suppressErrorToast?: boolean;
|
||||
skipAuthRedirect?: boolean;
|
||||
};
|
||||
|
||||
const http = () => getSpringAuthConfig().http;
|
||||
const platform = () => getSpringAuthConfig().platform;
|
||||
const basePath = () => getSpringAuthConfig().basePath;
|
||||
|
||||
function getHttpStatus(error: unknown): number | undefined {
|
||||
if (error instanceof AxiosError) {
|
||||
@@ -52,13 +66,16 @@ function getErrorMessage(error: unknown, fallback: string): string {
|
||||
|
||||
const OAUTH_REDIRECT_COOKIE = "stirling_redirect_path";
|
||||
const OAUTH_REDIRECT_COOKIE_MAX_AGE = 60 * 5; // 5 minutes
|
||||
const DEFAULT_REDIRECT_PATH = `${BASE_PATH || ""}/auth/callback`;
|
||||
|
||||
function defaultRedirectPath(): string {
|
||||
return `${basePath() || ""}/auth/callback`;
|
||||
}
|
||||
|
||||
export const POST_LOGIN_REDIRECT_STORAGE_KEY = "stirling_post_login_path";
|
||||
|
||||
function normalizeRedirectPath(target?: string): string {
|
||||
if (!target || typeof target !== "string") {
|
||||
return DEFAULT_REDIRECT_PATH;
|
||||
return defaultRedirectPath();
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -69,7 +86,7 @@ function normalizeRedirectPath(target?: string): string {
|
||||
} catch {
|
||||
const trimmed = target.trim();
|
||||
if (!trimmed) {
|
||||
return DEFAULT_REDIRECT_PATH;
|
||||
return defaultRedirectPath();
|
||||
}
|
||||
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
}
|
||||
@@ -112,7 +129,7 @@ export function setPostLoginRedirectPath(
|
||||
window.sessionStorage.removeItem(POST_LOGIN_REDIRECT_STORAGE_KEY);
|
||||
}
|
||||
} catch (_error) {
|
||||
// sessionStorage unavailable (private mode) — fail open
|
||||
// sessionStorage unavailable (private mode): fail open
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,45 +146,6 @@ export function consumePostLoginRedirectPath(): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
// Auth types
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
role: string;
|
||||
enabled?: boolean;
|
||||
is_anonymous?: boolean;
|
||||
isFirstLogin?: boolean;
|
||||
authenticationType?: string;
|
||||
app_metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
user: User;
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
expires_at?: number;
|
||||
}
|
||||
|
||||
export interface AuthError {
|
||||
message: string;
|
||||
status?: number;
|
||||
code?: string;
|
||||
mfaRequired?: boolean;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
user: User | null;
|
||||
session: Session | null;
|
||||
error: AuthError | null;
|
||||
}
|
||||
|
||||
export type AuthChangeEvent =
|
||||
| "SIGNED_IN"
|
||||
| "SIGNED_OUT"
|
||||
| "TOKEN_REFRESHED"
|
||||
| "USER_UPDATED";
|
||||
|
||||
type AuthChangeCallback = (
|
||||
event: AuthChangeEvent,
|
||||
session: Session | null,
|
||||
@@ -175,7 +153,7 @@ type AuthChangeCallback = (
|
||||
|
||||
class SpringAuthClient {
|
||||
private listeners: AuthChangeCallback[] = [];
|
||||
private sessionCheckInterval: NodeJS.Timeout | null = null;
|
||||
private sessionCheckInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Adaptive intervals - calculated based on actual JWT token lifetime
|
||||
// Defaults for initial startup (will be recalculated on first token)
|
||||
@@ -321,10 +299,10 @@ class SpringAuthClient {
|
||||
return { data: { session: null }, error: null };
|
||||
}
|
||||
|
||||
if (await isDesktopSaaSAuthMode()) {
|
||||
if (await platform().isDesktopSaaSAuthMode()) {
|
||||
let tokenExpiry = this.getTokenExpiry(token);
|
||||
if (tokenExpiry.expiresIn <= this.DESKTOP_SAAS_REFRESH_EARLY_SECONDS) {
|
||||
const refreshed = await refreshPlatformSession();
|
||||
const refreshed = await platform().refreshPlatformSession();
|
||||
if (!refreshed) {
|
||||
localStorage.removeItem("stirling_jwt");
|
||||
return { data: { session: null }, error: null };
|
||||
@@ -345,7 +323,7 @@ class SpringAuthClient {
|
||||
return { data: { session: null }, error: null };
|
||||
}
|
||||
|
||||
const platformUser = await getPlatformSessionUser();
|
||||
const platformUser = await platform().getPlatformSessionUser();
|
||||
|
||||
const session: Session = {
|
||||
user: {
|
||||
@@ -372,14 +350,15 @@ class SpringAuthClient {
|
||||
// Verify with backend
|
||||
// Note: We pass the token explicitly here, overriding the interceptor's default
|
||||
// console.debug('[SpringAuth] getSession: Verifying JWT with /api/v1/auth/me');
|
||||
const response = await apiClient.get("/api/v1/auth/me", {
|
||||
const meConfig: AuthRequestConfig = {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
suppressErrorToast: true, // Suppress global error handler (we handle errors locally)
|
||||
// Session bootstrap should not trigger global 401 refresh/redirect loops.
|
||||
skipAuthRedirect: true,
|
||||
});
|
||||
};
|
||||
const response = await http().get("/api/v1/auth/me", meConfig);
|
||||
|
||||
// console.debug('[SpringAuth] /me response status:', response.status);
|
||||
const data = response.data;
|
||||
@@ -428,7 +407,7 @@ class SpringAuthClient {
|
||||
mfaCode?: string;
|
||||
}): Promise<AuthResponse> {
|
||||
try {
|
||||
const response = await apiClient.post(
|
||||
const response = await http().post(
|
||||
"/api/v1/auth/login",
|
||||
{
|
||||
username: credentials.email,
|
||||
@@ -448,7 +427,7 @@ class SpringAuthClient {
|
||||
// console.log('[SpringAuth] JWT stored in localStorage');
|
||||
|
||||
// Sync token to platform-specific storage (Tauri store for desktop)
|
||||
await savePlatformToken(token);
|
||||
await platform().savePlatformToken(token);
|
||||
|
||||
// Calculate adaptive monitoring intervals based on token lifetime
|
||||
this.calculateAdaptiveIntervals(token);
|
||||
@@ -504,7 +483,7 @@ class SpringAuthClient {
|
||||
options?: { data?: { full_name?: string }; emailRedirectTo?: string };
|
||||
}): Promise<AuthResponse> {
|
||||
try {
|
||||
const response = await apiClient.post(
|
||||
const response = await http().post(
|
||||
"/api/v1/user/register",
|
||||
{
|
||||
username: credentials.email,
|
||||
@@ -548,7 +527,7 @@ class SpringAuthClient {
|
||||
// Use the full path provided by the backend
|
||||
// This supports both OAuth2 (/oauth2/authorization/...) and SAML2 (/saml2/authenticate/...)
|
||||
const redirectUrl = params.provider;
|
||||
const handled = await startOAuthNavigation(redirectUrl);
|
||||
const handled = await platform().startOAuthNavigation(redirectUrl);
|
||||
if (handled) {
|
||||
return { error: null };
|
||||
}
|
||||
@@ -584,8 +563,8 @@ class SpringAuthClient {
|
||||
// `/api/v1/auth/logout` (Supabase manages session lifecycle); POSTing
|
||||
// there returns 500 and pollutes error toasts even though the local
|
||||
// cleanup below succeeds.
|
||||
if (await shouldCallBackendLogout()) {
|
||||
const response = await apiClient.post("/api/v1/auth/logout", null, {
|
||||
if (await platform().shouldCallBackendLogout()) {
|
||||
const response = await http().post("/api/v1/auth/logout", null, {
|
||||
headers: {
|
||||
"X-XSRF-TOKEN": this.getCsrfToken() || "",
|
||||
},
|
||||
@@ -628,7 +607,7 @@ class SpringAuthClient {
|
||||
}
|
||||
|
||||
try {
|
||||
await clearPlatformAuthAfterSignOut();
|
||||
await platform().clearPlatformAuthAfterSignOut();
|
||||
} catch (cleanupError) {
|
||||
console.warn(
|
||||
"[SpringAuth] Failed to run platform auth cleanup",
|
||||
@@ -645,7 +624,7 @@ class SpringAuthClient {
|
||||
// Still remove token even if backend call fails
|
||||
localStorage.removeItem("stirling_jwt");
|
||||
try {
|
||||
await clearPlatformAuthAfterSignOut();
|
||||
await platform().clearPlatformAuthAfterSignOut();
|
||||
} catch (cleanupError) {
|
||||
console.warn(
|
||||
"[SpringAuth] Failed to run platform auth cleanup after error",
|
||||
@@ -672,8 +651,8 @@ class SpringAuthClient {
|
||||
error: AuthError | null;
|
||||
}> {
|
||||
try {
|
||||
if (await isDesktopSaaSAuthMode()) {
|
||||
const refreshed = await refreshPlatformSession();
|
||||
if (await platform().isDesktopSaaSAuthMode()) {
|
||||
const refreshed = await platform().refreshPlatformSession();
|
||||
if (!refreshed) {
|
||||
localStorage.removeItem("stirling_jwt");
|
||||
return {
|
||||
@@ -702,13 +681,18 @@ class SpringAuthClient {
|
||||
return { data, error: null };
|
||||
}
|
||||
|
||||
const response = await apiClient.post("/api/v1/auth/refresh", null, {
|
||||
const refreshConfig: AuthRequestConfig = {
|
||||
headers: {
|
||||
"X-XSRF-TOKEN": this.getCsrfToken() || "",
|
||||
},
|
||||
withCredentials: true,
|
||||
suppressErrorToast: true, // Suppress global error handler (we handle errors locally)
|
||||
});
|
||||
};
|
||||
const response = await http().post(
|
||||
"/api/v1/auth/refresh",
|
||||
null,
|
||||
refreshConfig,
|
||||
);
|
||||
|
||||
const data = response.data;
|
||||
const token = data.session.access_token;
|
||||
@@ -717,7 +701,7 @@ class SpringAuthClient {
|
||||
localStorage.setItem("stirling_jwt", token);
|
||||
|
||||
// Sync token to platform-specific storage (Tauri store for desktop)
|
||||
await savePlatformToken(token);
|
||||
await platform().savePlatformToken(token);
|
||||
|
||||
// Calculate adaptive monitoring intervals based on token lifetime
|
||||
this.calculateAdaptiveIntervals(token);
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Basic Supabase-backed auth provider feeding the unified AuthContext.
|
||||
*
|
||||
* This is the portable provider used by the shared unified auth (e.g. the
|
||||
* portal in Supabase mode). It deliberately does NOT carry the editor saas
|
||||
* build's extras (pro status, profile pictures, teams) - those remain in the
|
||||
* editor's saas layer. It maps a Supabase session onto the provider-agnostic
|
||||
* AuthUser/AuthSession shapes and exposes the same useAuth() contract as the
|
||||
* Spring provider.
|
||||
*/
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import type {
|
||||
Session as SbSession,
|
||||
User as SbUser,
|
||||
} from "@supabase/supabase-js";
|
||||
import { getSupabaseClient } from "@shared/auth/supabase/supabaseClient";
|
||||
import { AuthContext } from "@shared/auth/context";
|
||||
import { isAdminRole } from "@shared/auth/roles";
|
||||
import {
|
||||
defaultTranslate,
|
||||
type AuthContextValue,
|
||||
type AuthError,
|
||||
type AuthSession,
|
||||
type AuthUser,
|
||||
type AuthTranslate,
|
||||
} from "@shared/auth/types";
|
||||
|
||||
function readRole(user: SbUser): string {
|
||||
const appRole = (user.app_metadata as { role?: unknown } | undefined)?.role;
|
||||
if (typeof appRole === "string") return appRole;
|
||||
return "USER";
|
||||
}
|
||||
|
||||
function mapUser(user: SbUser): AuthUser {
|
||||
const metadata = user.user_metadata as
|
||||
| { full_name?: string; name?: string; username?: string }
|
||||
| undefined;
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email ?? "",
|
||||
username:
|
||||
metadata?.username ||
|
||||
metadata?.full_name ||
|
||||
metadata?.name ||
|
||||
user.email ||
|
||||
"",
|
||||
role: readRole(user),
|
||||
is_anonymous: user.is_anonymous,
|
||||
app_metadata: user.app_metadata as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
function mapSession(session: SbSession | null): AuthSession | null {
|
||||
if (!session) return null;
|
||||
return {
|
||||
user: mapUser(session.user),
|
||||
access_token: session.access_token,
|
||||
expires_in: session.expires_in,
|
||||
expires_at: session.expires_at ? session.expires_at * 1000 : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function deriveDisplayName(
|
||||
user: AuthUser | null,
|
||||
translate: AuthTranslate,
|
||||
): string | null {
|
||||
if (!user) return null;
|
||||
if (user.is_anonymous) return translate("auth.displayName.guest", "Guest");
|
||||
return user.username || user.email || null;
|
||||
}
|
||||
|
||||
export interface SupabaseAuthProviderProps {
|
||||
children: ReactNode;
|
||||
translate?: AuthTranslate;
|
||||
}
|
||||
|
||||
export function SupabaseAuthProvider({
|
||||
children,
|
||||
translate = defaultTranslate,
|
||||
}: SupabaseAuthProviderProps) {
|
||||
const [session, setSession] = useState<AuthSession | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<AuthError | null>(null);
|
||||
|
||||
const refreshSession = useCallback(async () => {
|
||||
const supabase = getSupabaseClient();
|
||||
if (!supabase) return;
|
||||
setLoading(true);
|
||||
const { data, error } = await supabase.auth.refreshSession();
|
||||
if (error) {
|
||||
setError({ message: error.message });
|
||||
setSession(null);
|
||||
} else {
|
||||
setSession(mapSession(data.session));
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
const supabase = getSupabaseClient();
|
||||
setSession(null);
|
||||
if (!supabase) return;
|
||||
const { error } = await supabase.auth.signOut();
|
||||
if (error) setError({ message: error.message });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const supabase = getSupabaseClient();
|
||||
if (!supabase) {
|
||||
// Supabase mode requested but not configured - settle into a signed-out
|
||||
// state instead of hanging on "loading".
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let mounted = true;
|
||||
supabase.auth
|
||||
.getSession()
|
||||
.then(({ data }) => {
|
||||
if (!mounted) return;
|
||||
setSession(mapSession(data.session));
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (mounted) setError({ message: String(e) });
|
||||
})
|
||||
.finally(() => {
|
||||
if (mounted) setLoading(false);
|
||||
});
|
||||
|
||||
const {
|
||||
data: { subscription },
|
||||
} = supabase.auth.onAuthStateChange((_event, newSession) => {
|
||||
if (!mounted) return;
|
||||
setSession(mapSession(newSession));
|
||||
setError(null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const user = session?.user ?? null;
|
||||
const value: AuthContextValue = {
|
||||
session,
|
||||
user,
|
||||
displayName: deriveDisplayName(user, translate),
|
||||
isAnonymous: user?.is_anonymous === true,
|
||||
isAdmin: isAdminRole(user?.role),
|
||||
role: user?.role ?? null,
|
||||
loading,
|
||||
error,
|
||||
signOut,
|
||||
refreshSession,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Shared Supabase client.
|
||||
*
|
||||
* The editor's saas build keeps its own richer client (profile pictures, pro
|
||||
* status, teams). This module is the portable Supabase path used by the shared
|
||||
* unified auth provider - notably so the portal can run in Supabase mode
|
||||
* against a hosted backend. The client is created lazily via
|
||||
* {@link configureSupabase}; until then {@link getSupabaseClient} returns null,
|
||||
* so hosts that never configure it (e.g. the portal in Spring mode) don't pull
|
||||
* Supabase into their session at all.
|
||||
*/
|
||||
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
|
||||
|
||||
export interface SupabaseAuthOptions {
|
||||
persistSession?: boolean;
|
||||
autoRefreshToken?: boolean;
|
||||
detectSessionInUrl?: boolean;
|
||||
}
|
||||
|
||||
export interface SupabaseConfig {
|
||||
url: string;
|
||||
key: string;
|
||||
authOptions?: SupabaseAuthOptions;
|
||||
}
|
||||
|
||||
let client: SupabaseClient | null = null;
|
||||
|
||||
/** Create (or replace) the shared Supabase client. Returns the instance. */
|
||||
export function configureSupabase(config: SupabaseConfig): SupabaseClient {
|
||||
client = createClient(config.url, config.key, {
|
||||
auth: {
|
||||
persistSession: config.authOptions?.persistSession ?? true,
|
||||
autoRefreshToken: config.authOptions?.autoRefreshToken ?? true,
|
||||
detectSessionInUrl: config.authOptions?.detectSessionInUrl ?? true,
|
||||
},
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
/** The configured Supabase client, or null if not configured. */
|
||||
export function getSupabaseClient(): SupabaseClient | null {
|
||||
return client;
|
||||
}
|
||||
|
||||
/** Anonymous (guest) sign-in. Throws if Supabase is not configured. */
|
||||
export async function signInAnonymously() {
|
||||
const supabase = getSupabaseClient();
|
||||
if (!supabase) {
|
||||
throw new Error("Supabase is not configured");
|
||||
}
|
||||
return supabase.auth.signInAnonymously();
|
||||
}
|
||||
|
||||
export const isUserAnonymous = (user: { is_anonymous?: boolean } | null) => {
|
||||
return user?.is_anonymous === true;
|
||||
};
|
||||
|
||||
/** Fetch the current Supabase user, or null when unauthenticated/unconfigured. */
|
||||
export async function getCurrentUser() {
|
||||
const supabase = getSupabaseClient();
|
||||
if (!supabase) return null;
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
return user;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Provider-agnostic auth model shared by the editor and the portal.
|
||||
*
|
||||
* Both the Spring (self-hosted JWT) and Supabase (cloud) backends are mapped
|
||||
* onto these shapes so consumers can read `useAuth()` without knowing which
|
||||
* backend authenticated the user.
|
||||
*/
|
||||
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
/** Backend role string, e.g. "ROLE_ADMIN" / "USER". */
|
||||
role: string;
|
||||
enabled?: boolean;
|
||||
is_anonymous?: boolean;
|
||||
isFirstLogin?: boolean;
|
||||
authenticationType?: string;
|
||||
app_metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AuthSession {
|
||||
user: AuthUser;
|
||||
access_token: string;
|
||||
expires_in: number;
|
||||
expires_at?: number;
|
||||
}
|
||||
|
||||
export interface AuthError {
|
||||
message: string;
|
||||
status?: number;
|
||||
code?: string;
|
||||
mfaRequired?: boolean;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
user: AuthUser | null;
|
||||
session: AuthSession | null;
|
||||
error: AuthError | null;
|
||||
}
|
||||
|
||||
export type AuthChangeEvent =
|
||||
| "SIGNED_IN"
|
||||
| "SIGNED_OUT"
|
||||
| "TOKEN_REFRESHED"
|
||||
| "USER_UPDATED";
|
||||
|
||||
/**
|
||||
* The unified value exposed by `useAuth()` regardless of backend. The editor's
|
||||
* existing consumers destructure session/user/displayName/isAnonymous/loading/
|
||||
* error/signOut/refreshSession; `role` and `isAdmin` are additive and drive the
|
||||
* portal's admin gate.
|
||||
*/
|
||||
export interface AuthContextValue {
|
||||
session: AuthSession | null;
|
||||
user: AuthUser | null;
|
||||
displayName: string | null;
|
||||
isAnonymous: boolean;
|
||||
/** True when the current user holds an admin role. */
|
||||
isAdmin: boolean;
|
||||
/** Raw backend role string, or null when signed out. */
|
||||
role: string | null;
|
||||
loading: boolean;
|
||||
error: AuthError | null;
|
||||
signOut: () => Promise<void>;
|
||||
refreshSession: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Which backend the shared auth provider talks to. */
|
||||
export type AuthMode = "spring" | "supabase";
|
||||
|
||||
/**
|
||||
* Translate hook for user-facing auth copy. Apps with i18n (the editor) pass a
|
||||
* function backed by their `t`; apps without it (the portal) omit it and get
|
||||
* the English fallback.
|
||||
*/
|
||||
export type AuthTranslate = (key: string, fallback: string) => string;
|
||||
|
||||
export const defaultTranslate: AuthTranslate = (_key, fallback) => fallback;
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import styles from "@shared/auth/ui/AuthShell.module.css";
|
||||
|
||||
export interface AuthShellProps {
|
||||
children: ReactNode;
|
||||
/** Optional panel shown beside the form on wide/tall viewports (the carousel). */
|
||||
rightPanel?: ReactNode;
|
||||
/** Optional fixed footer slot (the editor passes its legal/cookie footer). */
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The login card shell shared by the editor and the portal: a centered card
|
||||
* that expands to two columns (form + right panel) on wide/tall viewports and
|
||||
* collapses to a single column otherwise. Purely presentational - callers
|
||||
* provide the form (children), the right panel, and an optional footer.
|
||||
*/
|
||||
export function AuthShell({ children, rightPanel, footer }: AuthShellProps) {
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
const [hideRightPanel, setHideRightPanel] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
// Use viewport to avoid hysteresis when the card is already single-column.
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const cardWidthIfTwoCols = Math.min(1180, viewportWidth * 0.96); // min(73.75rem, 96vw)
|
||||
const columnWidth = cardWidthIfTwoCols / 2;
|
||||
const tooNarrow = columnWidth < 470;
|
||||
const tooShort = viewportHeight < 740;
|
||||
setHideRightPanel(tooNarrow || tooShort);
|
||||
};
|
||||
update();
|
||||
window.addEventListener("resize", update);
|
||||
window.addEventListener("orientationchange", update);
|
||||
return () => {
|
||||
window.removeEventListener("resize", update);
|
||||
window.removeEventListener("orientationchange", update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const showRightPanel = Boolean(rightPanel) && !hideRightPanel;
|
||||
|
||||
return (
|
||||
<div className={styles.authContainer}>
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={`${styles.authCard} ${showRightPanel ? styles.authCardTwoColumns : ""}`}
|
||||
>
|
||||
<div className={styles.authLeftPanel}>
|
||||
<div className={styles.authContent}>{children}</div>
|
||||
</div>
|
||||
{showRightPanel && rightPanel}
|
||||
</div>
|
||||
{footer && (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
width: "100%",
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AuthShell;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import "@app/routes/authShared/auth.css";
|
||||
import "@shared/auth/ui/auth.css";
|
||||
import { TextInput, PasswordInput, Button } from "@mantine/core";
|
||||
|
||||
// Force light mode styles for auth inputs
|
||||
@@ -83,10 +83,10 @@ export default function EmailPasswordForm({
|
||||
<div className="auth-field">
|
||||
<PasswordInput
|
||||
id="password"
|
||||
label={t("login.password")}
|
||||
label={t("login.password", "Password")}
|
||||
name="current-password"
|
||||
autoComplete="current-password"
|
||||
placeholder={t("login.enterPassword")}
|
||||
placeholder={t("login.enterPassword", "Enter your password")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
error={fieldErrors.password}
|
||||
@@ -99,7 +99,7 @@ export default function EmailPasswordForm({
|
||||
<div className="auth-field">
|
||||
<TextInput
|
||||
id="mfaCode"
|
||||
label={t("login.mfaCode", "Authentication code")}
|
||||
label={t("login.mfaCode", "Authentication Code")}
|
||||
type="text"
|
||||
name="mfaCode"
|
||||
autoComplete="one-time-code"
|
||||
@@ -131,6 +131,15 @@ export default function EmailPasswordForm({
|
||||
className="auth-button"
|
||||
fullWidth
|
||||
loading={isSubmitting}
|
||||
styles={{
|
||||
// Own the brand colour inline so the host app's Mantine primaryColor
|
||||
// can't win over .auth-button (editor vs portal Mantine themes
|
||||
// differ). The fallback keeps it red even if auth-theme.css is absent.
|
||||
root: {
|
||||
backgroundColor: "var(--auth-button-bg-light-only, #af3434)",
|
||||
color: "var(--auth-button-text-light-only, #ffffff)",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{submitButtonText}
|
||||
</Button>
|
||||
@@ -1,7 +1,7 @@
|
||||
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { BASE_PATH } from "@app/constants/app";
|
||||
import bgDefault from "@shared/assets/login/LoginBackgroundPanel.png";
|
||||
|
||||
type ImageSlide = {
|
||||
export type ImageSlide = {
|
||||
src: string;
|
||||
alt?: string;
|
||||
cornerModelUrl?: string;
|
||||
@@ -14,11 +14,14 @@ type ImageSlide = {
|
||||
function LoginRightCarousel({
|
||||
imageSlides = [],
|
||||
showBackground = true,
|
||||
backgroundSrc = bgDefault,
|
||||
initialSeconds = 5,
|
||||
slideSeconds = 8,
|
||||
}: {
|
||||
imageSlides?: ImageSlide[];
|
||||
showBackground?: boolean;
|
||||
/** Background panel image; defaults to the bundled shared asset. */
|
||||
backgroundSrc?: string;
|
||||
initialSeconds?: number;
|
||||
slideSeconds?: number;
|
||||
}) {
|
||||
@@ -117,7 +120,7 @@ function LoginRightCarousel({
|
||||
>
|
||||
{showBackground && (
|
||||
<img
|
||||
src={`${BASE_PATH}/Login/LoginBackgroundPanel.png`}
|
||||
src={backgroundSrc}
|
||||
alt="Background panel"
|
||||
style={{
|
||||
position: "absolute",
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BASE_PATH } from "@app/constants/app";
|
||||
import { type OAuthProvider } from "@app/auth/oauthTypes";
|
||||
import { type OAuthProvider } from "@shared/auth/spring/oauthTypes";
|
||||
import { Button } from "@mantine/core";
|
||||
import {
|
||||
oauthIconUrl,
|
||||
GENERIC_PROVIDER_ICON,
|
||||
} from "@shared/auth/ui/oauthIcons";
|
||||
|
||||
// Debug flag to show all providers for UI testing
|
||||
// Set to true to see all SSO options regardless of backend configuration
|
||||
@@ -23,8 +26,7 @@ export const oauthProviderConfig: Record<
|
||||
oidc: { label: "OIDC", file: "oidc.svg" },
|
||||
};
|
||||
|
||||
// Generic fallback for unknown providers
|
||||
const GENERIC_PROVIDER_ICON = "oidc.svg";
|
||||
// Icon URLs + GENERIC_PROVIDER_ICON come from the shared oauthIcons resolver.
|
||||
|
||||
interface OAuthButtonsProps {
|
||||
onProviderClick: (provider: OAuthProvider) => void;
|
||||
@@ -112,7 +114,7 @@ export default function OAuthButtons({
|
||||
variant="default"
|
||||
>
|
||||
<img
|
||||
src={`${BASE_PATH}/Login/${p.file}`}
|
||||
src={oauthIconUrl(p.file)}
|
||||
alt={p.label}
|
||||
className="oauth-icon-small"
|
||||
/>
|
||||
@@ -139,7 +141,7 @@ export default function OAuthButtons({
|
||||
variant="default"
|
||||
>
|
||||
<img
|
||||
src={`${BASE_PATH}/Login/${p.file}`}
|
||||
src={oauthIconUrl(p.file)}
|
||||
alt={p.label}
|
||||
className="oauth-icon-medium"
|
||||
/>
|
||||
@@ -176,7 +178,7 @@ export default function OAuthButtons({
|
||||
<span className="oauth-button-left">
|
||||
<span className="oauth-icon-wrapper">
|
||||
<img
|
||||
src={`${BASE_PATH}/Login/${p.file}`}
|
||||
src={oauthIconUrl(p.file)}
|
||||
alt={p.label}
|
||||
className="oauth-icon-tiny"
|
||||
/>
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ErrorMessage from "@shared/auth/ui/ErrorMessage";
|
||||
import EmailPasswordForm from "@shared/auth/ui/EmailPasswordForm";
|
||||
import OAuthButtons from "@shared/auth/ui/OAuthButtons";
|
||||
import type { SpringLoginState } from "@shared/auth/ui/useSpringLogin";
|
||||
|
||||
interface SpringLoginFormProps {
|
||||
/** Login state + handlers, from useSpringLogin. */
|
||||
state: SpringLoginState;
|
||||
/** Light-mode logo source. */
|
||||
logoSrc: string;
|
||||
/** Optional dark-mode logo source (the editor swaps logos by colour scheme). */
|
||||
logoDarkSrc?: string;
|
||||
logoAlt?: string;
|
||||
/** OAuth CTA prefix, e.g. "Sign in with" (editor SSO-only mode). */
|
||||
oauthCtaPrefix?: string;
|
||||
/** Editor SSO-only button styling. */
|
||||
oauthUseNewStyle?: boolean;
|
||||
/**
|
||||
* Whether to render the email/password form. Defaults to
|
||||
* state.isUserPassAllowed (the portal always shows it); the editor toggles it
|
||||
* when SSO providers are present.
|
||||
*/
|
||||
showEmailForm?: boolean;
|
||||
/** Optional override for the submit button label. */
|
||||
submitButtonText?: string;
|
||||
/** Slot rendered above the error message (editor: success banner). */
|
||||
aboveError?: ReactNode;
|
||||
/** Slot rendered between the divider and the email form (editor: toggle). */
|
||||
beforeEmailForm?: ReactNode;
|
||||
/** Slot rendered after the form (editor: first-time-setup credentials hint). */
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared Spring login form body: logo, error, OAuth buttons, divider, and
|
||||
* the email/password form. Rendered by both the editor and the portal inside
|
||||
* their own auth shells; state and handlers come from useSpringLogin.
|
||||
*/
|
||||
export default function SpringLoginForm({
|
||||
state,
|
||||
logoSrc,
|
||||
logoDarkSrc,
|
||||
logoAlt = "Stirling PDF",
|
||||
oauthCtaPrefix,
|
||||
oauthUseNewStyle,
|
||||
showEmailForm,
|
||||
submitButtonText,
|
||||
aboveError,
|
||||
beforeEmailForm,
|
||||
footer,
|
||||
}: SpringLoginFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
error,
|
||||
providers,
|
||||
hasProviders,
|
||||
isUserPassAllowed,
|
||||
isSubmitting,
|
||||
email,
|
||||
password,
|
||||
setEmail,
|
||||
setPassword,
|
||||
mfaCode,
|
||||
setMfaCode,
|
||||
requiresMfa,
|
||||
signInWithEmail,
|
||||
signInWithProvider,
|
||||
} = state;
|
||||
|
||||
const renderEmailForm =
|
||||
(showEmailForm ?? isUserPassAllowed) && isUserPassAllowed;
|
||||
const submitLabel =
|
||||
submitButtonText ??
|
||||
(isSubmitting
|
||||
? t("login.loggingIn", "Logging In...")
|
||||
: t("login.login", "Login"));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="auth-logo-block">
|
||||
<img
|
||||
src={logoSrc}
|
||||
alt={logoAlt}
|
||||
className="auth-logo-header auth-logo-header--light"
|
||||
/>
|
||||
{logoDarkSrc && (
|
||||
<img
|
||||
src={logoDarkSrc}
|
||||
alt={logoAlt}
|
||||
className="auth-logo-header auth-logo-header--dark"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{aboveError}
|
||||
|
||||
<ErrorMessage error={error} />
|
||||
|
||||
{hasProviders && (
|
||||
<OAuthButtons
|
||||
onProviderClick={signInWithProvider}
|
||||
isSubmitting={isSubmitting}
|
||||
layout="vertical"
|
||||
enabledProviders={providers}
|
||||
ctaPrefix={oauthCtaPrefix}
|
||||
useNewStyle={oauthUseNewStyle}
|
||||
styleVariant="light"
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasProviders && isUserPassAllowed && (
|
||||
<div className="auth-or-divider">
|
||||
<span className="auth-or-divider__rule" aria-hidden />
|
||||
<span className="auth-or-divider__label">{t("signup.or", "or")}</span>
|
||||
<span className="auth-or-divider__rule" aria-hidden />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{beforeEmailForm}
|
||||
|
||||
{renderEmailForm && (
|
||||
<div style={{ marginTop: hasProviders ? "1rem" : 0 }}>
|
||||
<EmailPasswordForm
|
||||
email={email}
|
||||
password={password}
|
||||
setEmail={setEmail}
|
||||
setPassword={setPassword}
|
||||
mfaCode={mfaCode}
|
||||
setMfaCode={setMfaCode}
|
||||
showMfaField={requiresMfa || Boolean(mfaCode)}
|
||||
requiresMfa={requiresMfa}
|
||||
onSubmit={signInWithEmail}
|
||||
isSubmitting={isSubmitting}
|
||||
submitButtonText={submitLabel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{footer}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,26 @@
|
||||
margin-bottom: 0.75rem; /* 12px */
|
||||
}
|
||||
|
||||
/* "or" divider between OAuth buttons and the email form (light-mode auth pages) */
|
||||
.auth-or-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem; /* 12px */
|
||||
margin: 0.375rem 0 0.5rem; /* 6px 0 8px */
|
||||
}
|
||||
|
||||
.auth-or-divider__rule {
|
||||
height: 0.0625rem; /* 1px */
|
||||
flex: 1 1 0%;
|
||||
background-color: rgb(var(--text-divider-rule-rgb-light) / 0.4);
|
||||
}
|
||||
|
||||
.auth-or-divider__label {
|
||||
color: rgb(var(--text-divider-label-rgb-light) / 0.4);
|
||||
font-size: 0.75rem; /* 12px */
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.auth-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { defaultTranslate, type AuthTranslate } from "@shared/auth/types";
|
||||
|
||||
export type LoginSlideText = {
|
||||
alt: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared carousel copy (i18n-optional, English default). The editor and portal
|
||||
* both build their slides from this so the text stays in sync; only the images
|
||||
* differ (the editor swaps the hero for its logo-variant image). Order matches
|
||||
* buildDefaultLoginSlides: [overview, edit, secure].
|
||||
*
|
||||
* Kept image-free on purpose: the editor imports this without dragging the
|
||||
* bundled slide images into its build.
|
||||
*/
|
||||
export function loginSlideText(
|
||||
translate: AuthTranslate = defaultTranslate,
|
||||
): LoginSlideText[] {
|
||||
return [
|
||||
{
|
||||
alt: translate("login.slides.overview.alt", "Stirling PDF overview"),
|
||||
title: translate(
|
||||
"login.slides.overview.title",
|
||||
"Your one-stop-shop for all your PDF needs.",
|
||||
),
|
||||
subtitle: translate(
|
||||
"login.slides.overview.subtitle",
|
||||
"A privacy-first cloud suite for PDFs that lets you convert, sign, redact, and manage documents, along with 50+ other powerful tools.",
|
||||
),
|
||||
},
|
||||
{
|
||||
alt: translate("login.slides.edit.alt", "Edit PDFs"),
|
||||
title: translate(
|
||||
"login.slides.edit.title",
|
||||
"Edit PDFs to display/secure the information you want",
|
||||
),
|
||||
subtitle: translate(
|
||||
"login.slides.edit.subtitle",
|
||||
"With over a dozen tools to help you redact, sign, read and manipulate PDFs, you will be sure to find what you are looking for.",
|
||||
),
|
||||
},
|
||||
{
|
||||
alt: translate("login.slides.secure.alt", "Secure PDFs"),
|
||||
title: translate(
|
||||
"login.slides.secure.title",
|
||||
"Protect sensitive information in your PDFs",
|
||||
),
|
||||
subtitle: translate(
|
||||
"login.slides.secure.subtitle",
|
||||
"Add passwords, redact content, and manage certificates with ease.",
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defaultTranslate, type AuthTranslate } from "@shared/auth/types";
|
||||
import type { ImageSlide } from "@shared/auth/ui/LoginRightCarousel";
|
||||
import { loginSlideText } from "@shared/auth/ui/loginSlideText";
|
||||
import firstPage from "@shared/assets/login/Firstpage.png";
|
||||
import addToPdf from "@shared/assets/login/AddToPDF.png";
|
||||
import securePdf from "@shared/assets/login/SecurePDF.png";
|
||||
|
||||
const SLIDE_TILT = { followMouseTilt: true, tiltMaxDeg: 5 } as const;
|
||||
|
||||
/**
|
||||
* Default login carousel slides using bundled images. The portal uses this set;
|
||||
* the editor builds its own (logo-variant hero) from loginSlideText.
|
||||
*/
|
||||
export function buildDefaultLoginSlides(
|
||||
translate: AuthTranslate = defaultTranslate,
|
||||
): ImageSlide[] {
|
||||
const text = loginSlideText(translate);
|
||||
return [firstPage, addToPdf, securePdf].map((src, i) => ({
|
||||
src,
|
||||
...text[i],
|
||||
...SLIDE_TILT,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Bundled OAuth provider icons: the single source for provider brand SVGs.
|
||||
*
|
||||
* Importing them as modules (rather than referencing /Login/*.svg under a
|
||||
* build-time BASE_PATH) lets every consumer (the shared OAuthButtons, the
|
||||
* editor's saas/desktop login buttons, and the config provider list) share one
|
||||
* copy that works in both the editor and the portal bundles.
|
||||
*/
|
||||
import googleIcon from "@shared/assets/login/google.svg";
|
||||
import githubIcon from "@shared/assets/login/github.svg";
|
||||
import appleIcon from "@shared/assets/login/apple.svg";
|
||||
import microsoftIcon from "@shared/assets/login/microsoft.svg";
|
||||
import keycloakIcon from "@shared/assets/login/keycloak.svg";
|
||||
import cloudronIcon from "@shared/assets/login/cloudron.svg";
|
||||
import authentikIcon from "@shared/assets/login/authentik.svg";
|
||||
import oidcIcon from "@shared/assets/login/oidc.svg";
|
||||
|
||||
/** Generic fallback icon (filename) for unknown providers. */
|
||||
export const GENERIC_PROVIDER_ICON = "oidc.svg";
|
||||
|
||||
const ICON_BY_FILE: Record<string, string> = {
|
||||
"google.svg": googleIcon,
|
||||
"github.svg": githubIcon,
|
||||
"apple.svg": appleIcon,
|
||||
"microsoft.svg": microsoftIcon,
|
||||
"keycloak.svg": keycloakIcon,
|
||||
"cloudron.svg": cloudronIcon,
|
||||
"authentik.svg": authentikIcon,
|
||||
"oidc.svg": oidcIcon,
|
||||
};
|
||||
|
||||
/** Resolve a provider icon filename (e.g. "google.svg") to its bundled URL. */
|
||||
export function oauthIconUrl(file: string): string {
|
||||
return ICON_BY_FILE[file] ?? ICON_BY_FILE[GENERIC_PROVIDER_ICON];
|
||||
}
|
||||