Merge branch 'main' into docker_pinned_20260624

This commit is contained in:
Ludy
2026-07-04 14:25:49 +02:00
committed by GitHub
693 changed files with 4549 additions and 4787 deletions
-1
View File
@@ -27,7 +27,6 @@ node_modules/
**/node_modules/
frontend/node_modules/
frontend/editor/dist/
frontend/dist-portal/
frontend/editor/playwright-report/
.npm/
.yarn/
+2 -2
View File
@@ -15,10 +15,10 @@ testing/compose/validate-mcp-test.sh:curl-auth-header:92
testing/compose/validate-mcp-test.sh:curl-auth-header:116
# Storybook example showing curl with a fake Bearer token placeholder (sk_live_a3f8...).
frontend/shared/components/CodeBlock.stories.tsx:curl-auth-header:4
frontend/editor/src/proprietary/ui/CodeBlock.stories.tsx:curl-auth-header:5
# Truncated placeholder API key in portal docs example (sk_live_8f2c...e10) - not a real secret.
frontend/portal/src/components/docs/GettingStartedSection.tsx:generic-api-key:31
frontend/editor/src/portal/components/docs/GettingStartedSection.tsx:generic-api-key:30
# False positive: generic-api-key matches the Java type name "X509Certificate"
# in a method signature (CreateSignatureBase.resolveSignatureAlgorithm) - not a secret.
+5 -77
View File
@@ -128,37 +128,6 @@ tasks:
- task: dev:_run
vars: { MODE: prototypes, PORT: '{{.PORT}}', BACKEND_URL: '{{.BACKEND_URL}}', OPEN: '{{.OPEN}}' }
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:
- '{{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
# ============================================================
@@ -205,29 +174,6 @@ tasks:
cmds:
- npx vite build editor --mode prototypes
build:portal:
desc: "Build developer portal"
deps: [install]
vars:
SUBPATH: '{{.SUBPATH | default ""}}'
cmds:
- '{{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"
@@ -263,8 +209,8 @@ tasks:
deps: [install]
cmds:
# Globs so dpdm walks the whole tree. dpdm expands the braces itself, so this is
# shell-agnostic. Covers editor, portal, and the shared design system.
- npx dpdm "editor/src/**/*.{ts,tsx}" "portal/src/**/*.{ts,tsx}" "shared/**/*.{ts,tsx}" --circular --no-warning --no-tree --exit-code circular:1
# shell-agnostic. Covers the whole editor tree, including the portal layer.
- npx dpdm "editor/src/**/*.{ts,tsx}" --circular --no-warning --no-tree --exit-code circular:1
lint:fix:
desc: "Auto-fix lint issues"
@@ -345,8 +291,6 @@ tasks:
desc: "Typecheck scripts"
deps: [prepare]
cmds:
- task: typecheck:_run
vars: { PROJECT: scripts/tsconfig.json }
- task: typecheck:_run
vars: { PROJECT: editor/scripts/tsconfig.json }
@@ -362,14 +306,7 @@ tasks:
deps: [install]
cmds:
- task: typecheck:_run
vars: { PROJECT: portal/tsconfig.json }
typecheck:shared:
desc: "Typecheck the shared design system"
deps: [install]
cmds:
- task: typecheck:_run
vars: { PROJECT: shared/tsconfig.json }
vars: { PROJECT: editor/src/portal/tsconfig.json }
typecheck:all:
desc: "Typecheck all build variants"
@@ -382,7 +319,6 @@ tasks:
- task: typecheck:scripts
- task: typecheck:prototypes
- task: typecheck:portal
- task: typecheck:shared
# ============================================================
# Quality Gate
@@ -411,7 +347,6 @@ tasks:
- task: lint
- task: format:check
- task: build
- task: build:portal
- task: test
- task: storybook:build
@@ -423,7 +358,6 @@ tasks:
desc: "Run tests"
cmds:
- task: test:editor
- task: test:portal
test:editor:
desc: "Run editor tests"
@@ -431,12 +365,6 @@ tasks:
cmds:
- npx vitest run --root editor
test:portal:
desc: "Run portal tests"
deps: [prepare]
cmds:
- npx vitest run --root portal
test:watch:
desc: "Run tests in watch mode"
deps: [prepare]
@@ -481,7 +409,7 @@ tasks:
clean:
desc: "Clean build artifacts and caches"
cmds:
- cmd: powershell rm -Recurse -Force -ErrorAction SilentlyContinue node_modules/.vite, editor/dist, dist, dist-portal
- cmd: powershell rm -Recurse -Force -ErrorAction SilentlyContinue node_modules/.vite, editor/dist, dist
platforms: [windows]
- cmd: rm -rf node_modules/.vite editor/dist dist dist-portal
- cmd: rm -rf node_modules/.vite editor/dist dist
platforms: [linux, darwin]
+2 -1
View File
@@ -139,7 +139,8 @@ The project structure is defined in `engine/pyproject.toml`. Any new dependencie
#### Environment Variables
- All `VITE_*` variables must be declared in the appropriate committed env file:
- `frontend/editor/.env` — core, proprietary, and shared vars
- `frontend/editor/.env` — core and shared vars (base, loaded in every mode)
- `frontend/editor/.env.proprietary` — proprietary-only vars, e.g. the admin portal's SaaS/account-link keys (layered on top of `.env` in proprietary mode)
- `frontend/editor/.env.saas` — SaaS-only vars (layered on top of `.env` in SaaS mode)
- `frontend/editor/.env.desktop` — desktop (Tauri)-only vars (layered on top of `.env` in desktop mode)
- These files are committed to Git and must not contain private keys
+2 -2
View File
@@ -20,8 +20,8 @@ if that directory exists, is licensed under the license defined in "frontend/edi
if that directory exists, is licensed under the license defined in "frontend/editor/src/cloud/LICENSE".
* All content that resides under the "frontend/editor/src/prototypes/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/editor/src/prototypes/LICENSE".
* All content that resides under the "frontend/portal/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/portal/LICENSE".
* All content that resides under the "frontend/editor/src/portal/" directory of this repository,
if that directory exists, is licensed under the license defined in "frontend/editor/src/portal/LICENSE".
* Content outside of the above mentioned directories or restrictions above is
available under the MIT License as defined below.
+3 -106
View File
@@ -79,86 +79,12 @@ tasks:
OPEN: "true"
dev:portal:
desc: "Start backend + developer portal concurrently on free ports"
desc: "Start backend + editor; the portal is an admin route at /portal"
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"
POLICIES_ENABLED: "true"
- task: frontend:dev:portal
vars:
PORT: '{{.PORTAL_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
MOCKS: 'false'
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"
POLICIES_ENABLED: "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}}/'
MOCKS: 'false'
OPEN: "true"
- task: frontend:dev
vars:
PORT: '{{.EDITOR_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
dev:portal:all:saas:
desc: "Start SaaS 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:saas
vars:
PORT: '{{.BACKEND_PORT}}'
POLICIES_ENABLED: "true"
- task: frontend:dev:portal
vars:
PORT: '{{.PORTAL_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
EDITOR_URL: 'http://localhost:{{.EDITOR_PORT}}/'
MOCKS: 'false'
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}}'
EDITOR_PORT: '{{index (splitList "\n" .PORTS) 1}}'
deps:
- task: backend:dev
vars:
@@ -169,18 +95,7 @@ tasks:
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}}'
OPEN: "true"
dev:saas:
desc: "Start SaaS backend + frontend concurrently on free ports"
@@ -228,24 +143,6 @@ 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"
POLICIES_ENABLED: "true"
- task: frontend:preview:portal:proxy
vars:
PORT: '{{.PROXY_PORT}}'
BACKEND_URL: 'http://localhost:{{.BACKEND_PORT}}'
# ============================================================
# Test
# ============================================================
@@ -2,7 +2,8 @@ package stirling.software.proprietary.access.model;
/** Types of resources whose access can be gated by {@link ResourceGrant}. */
public enum ResourceType {
// The admin portal / processor (frontend/portal). Singleton resource (empty resourceId).
// The admin portal / processor (frontend/editor/src/portal). Singleton resource (empty
// resourceId).
PORTAL,
// A stored S3/MCP/API integration configuration.
INTEGRATION_CONFIG
+1 -2
View File
@@ -11,7 +11,6 @@
# production
/build
/dist
/dist-portal
/storybook-static
/editor/build
@@ -27,8 +26,8 @@
# Root .gitignore ignores all .env* - whitelist only our committed ones, anchored
# to their app so a stray top-level frontend/.env stays ignored (Storybook's SaaS
# mock env is injected via .storybook/main.ts, not a file).
!/portal/.env
!/editor/.env
!/editor/.env.proprietary
!/editor/.env.desktop
!/editor/.env.saas
+1 -2
View File
@@ -1,5 +1,4 @@
dist/
dist-portal/
editor/dist/
# Tauri/Cargo build output (binary assets named *.js etc. confuse Prettier).
# Match nested target/ dirs too - provisioner/ and thumbnail-handler/ each
@@ -9,7 +8,7 @@ editor/src-tauri/gen/
node_modules/
editor/public/vendor/
# Auto-generated by MSW (`msw init`); regenerated verbatim, not hand-formatted.
portal/public/mockServiceWorker.js
editor/public/mockServiceWorker.js
# Auto-generated OG/social-preview metadata (scripts/generate-og-metadata.mjs); regenerated verbatim.
editor/public/og-metadata.json
editor/src/core/data/ogImageMap.json
+15 -14
View File
@@ -6,16 +6,13 @@ import tsconfigPaths from "vite-tsconfig-paths";
* Storybook 9 ships essentials, interactions, and docs as built-ins, so the
* addon list is just the extras we want: theme switching + a11y auditing.
*
* Story files live next to their components in shared/, portal/src/, and
* editor/src/ — the design system is shared by BOTH apps, so both surface
* their stories here. MDX docs pages live in portal/src/docs/.
* Story files live next to their components under editor/src/ (which includes
* the portal layer at editor/src/portal/). MDX docs pages live in
* editor/src/portal/docs/.
*/
const config: StorybookConfig = {
stories: [
"../portal/src/**/*.mdx",
"../portal/src/**/*.stories.@(ts|tsx)",
"../shared/**/*.mdx",
"../shared/**/*.stories.@(ts|tsx)",
"../editor/src/portal/**/*.mdx",
"../editor/src/**/*.stories.@(ts|tsx)",
],
addons: ["@storybook/addon-themes", "@storybook/addon-a11y"],
@@ -26,17 +23,21 @@ const config: StorybookConfig = {
typescript: {
reactDocgen: "react-docgen-typescript",
},
// Serve the MSW worker file from portal/public so Storybook can intercept
// network calls the same way the dev portal does.
staticDirs: ["../portal/public"],
// Serve the MSW worker file from the portal's public dir so Storybook can
// intercept network calls the same way the dev portal does.
staticDirs: ["../editor/public"],
viteFinal: async (config) => {
// Wire @portal/* and @shared/* aliases directly on the Storybook bundler so
// portal story imports resolve without needing the portal's vite config.
// Wire the @portal/* alias directly on the Storybook bundler so portal
// story imports resolve without needing the portal's vite config.
config.resolve = config.resolve ?? {};
config.resolve.alias = {
...(config.resolve.alias ?? {}),
"@portal": resolve(__dirname, "../portal/src"),
"@shared": resolve(__dirname, "../shared"),
"@portal": resolve(__dirname, "../editor/src/portal"),
// Direct layer aliases so .storybook config files (preview.tsx), which sit
// outside src/ and so aren't covered by tsconfigPaths, can import layer
// modules (e.g. the auth supabase client that moved into proprietary).
"@proprietary": resolve(__dirname, "../editor/src/proprietary"),
"@core": resolve(__dirname, "../editor/src/core"),
};
// Editor stories import via @app/* (proprietary→core fallback), @core/* and
// @proprietary/*. Resolve them exactly the way the editor's own build does —
+3 -3
View File
@@ -19,11 +19,11 @@ import { ThemeProvider } from "@portal/contexts/ThemeContext";
import { UIProvider } from "@portal/contexts/UIContext";
import { mantineTheme } from "@portal/theme/mantineTheme";
import { handlers } from "@portal/mocks/handlers";
import { configureSupabase } from "@shared/auth/supabase/supabaseClient";
import { configureSupabase } from "@proprietary/auth/supabase/supabaseClient";
import "@mantine/core/styles.css";
import "@shared/tokens/tokens.css";
import "@shared/tokens/base.css";
import "@core/tokens/tokens.css";
import "@core/tokens/base.css";
// Start MSW once. Storybook runs in a browser so this uses the service worker.
initialize({ onUnhandledRequest: "bypass" }, handlers);
+32
View File
@@ -0,0 +1,32 @@
###############################################################################
# Proprietary-build environment variables, layered on top of `.env` when Vite
# runs in `--mode proprietary` (so the core/OSS build never sees them). These
# back the admin portal mounted at /portal. Committed to Git, so no private keys;
# machine-specific overrides go in the uncommitted .env.proprietary.local /
# .env.local. (VITE_STRIPE_PUBLISHABLE_KEY lives in the base `.env` as it is
# shared across builds.)
###############################################################################
# Where the portal's "Editor" switcher / non-admin redirect points. "/" is
# correct: the editor serves the portal at /portal on the same origin. In dev,
# override in .env.local with your running editor's URL (e.g.
# VITE_EDITOR_URL=http://localhost:5173/).
VITE_EDITOR_URL=/
# Force the portal's MSW mocks on ("true") or off ("false"). Empty = default
# (on in dev, off in production builds). Set "false" to run against the real
# backend.
VITE_PORTAL_MOCKS=
# Hosted SaaS Supabase project for the self-hosted portal's IN-APP account
# linking (both values are public). Set per deploy; absent -> the account-link
# UI shows a "configure" state. For local e2e, point these at the SaaS Supabase
# project the local backend links against.
VITE_SAAS_SUPABASE_URL=
VITE_SAAS_SUPABASE_ANON_KEY=
# Hosted SaaS Java backend base URL (e.g. https://api.stirlingpdf.com). Used for
# ATTENDED portal -> SaaS reads (wallet, billing, plans, checkout) with the
# admin's Supabase JWT. Distinct from the local backend (reached same-origin via
# the editor's vite proxy). Absent -> wallet/billing surfaces stay on the mock.
VITE_SAAS_API_URL=
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
/**
* Editor cloud adapter over the shared {@code @shared/billing} spend-cap control:
* Editor cloud adapter over the shared {@code @app/billing} spend-cap control:
* supplies the i18n copy (the shared control is copy-agnostic) and the editor's
* {@code scc-*} styling. The public API (controlled {@code capUsd}/{@code
* onChange}, optional {@code onSave}/{@code saveLabel}, {@code note}) is
@@ -11,7 +11,7 @@ import { useTranslation } from "react-i18next";
import {
DEFAULT_CAP_PRESETS,
SpendCapControl as SharedSpendCapControl,
} from "@shared/billing";
} from "@app/billing";
// eslint-disable-next-line no-restricted-imports
import "./SpendCapControl.css";
@@ -7,7 +7,7 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useWallet, type Wallet } from "@app/hooks/useWallet";
import { currencySymbol, MeterBar, meterState } from "@shared/billing";
import { currencySymbol, MeterBar, meterState } from "@app/billing";
import "@app/components/shared/config/configSections/Payg.css";
import "@app/components/shared/config/configSections/PaygFree.css";
+2 -2
View File
@@ -57,10 +57,10 @@ import type {
WalletMember,
WalletCategoryBreakdown,
WalletActivityRow,
} from "@shared/billing";
} from "@app/billing";
// ─── Public types ───────────────────────────────────────────────────────
// The wallet contract lives in @shared/billing (shared with the admin portal).
// The wallet contract lives in @app/billing (shared with the admin portal).
// Re-exported so existing `@app/hooks/useWallet` importers keep their imports.
export type {
Wallet,
+2 -2
View File
@@ -7,10 +7,10 @@
"../../src/proprietary/*",
"../../src/core/*"
],
"@portal/*": ["../../src/portal/*"],
"@cloud/*": ["../../src/cloud/*"],
"@proprietary/*": ["../../src/proprietary/*"],
"@core/*": ["../../src/core/*"],
"@shared/*": ["../../../shared/*"]
"@core/*": ["../../src/core/*"]
}
},
"include": [
@@ -2,16 +2,16 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
// Visual catalogue of the shared brand assets (shared/assets/brand) — the single
// source of truth for the Stirling logos used across the apps.
import modernMarkDark from "@shared/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg";
import modernMarkLight from "@shared/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg";
import modernBlack from "@shared/assets/brand/modern-logo/StirlingPDFLogoBlackText.svg";
import modernWhite from "@shared/assets/brand/modern-logo/StirlingPDFLogoWhiteText.svg";
import modernGrey from "@shared/assets/brand/modern-logo/StirlingPDFLogoGreyText.svg";
import classicMarkDark from "@shared/assets/brand/classic-logo/StirlingPDFLogoNoTextDark.svg";
import classicMarkLight from "@shared/assets/brand/classic-logo/StirlingPDFLogoNoTextLight.svg";
import classicBlack from "@shared/assets/brand/classic-logo/StirlingPDFLogoBlackText.svg";
import classicWhite from "@shared/assets/brand/classic-logo/StirlingPDFLogoWhiteText.svg";
import classicGrey from "@shared/assets/brand/classic-logo/StirlingPDFLogoGreyText.svg";
import modernMarkDark from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextDark.svg";
import modernMarkLight from "@app/assets/brand/modern-logo/StirlingPDFLogoNoTextLight.svg";
import modernBlack from "@app/assets/brand/modern-logo/StirlingPDFLogoBlackText.svg";
import modernWhite from "@app/assets/brand/modern-logo/StirlingPDFLogoWhiteText.svg";
import modernGrey from "@app/assets/brand/modern-logo/StirlingPDFLogoGreyText.svg";
import classicMarkDark from "@app/assets/brand/classic-logo/StirlingPDFLogoNoTextDark.svg";
import classicMarkLight from "@app/assets/brand/classic-logo/StirlingPDFLogoNoTextLight.svg";
import classicBlack from "@app/assets/brand/classic-logo/StirlingPDFLogoBlackText.svg";
import classicWhite from "@app/assets/brand/classic-logo/StirlingPDFLogoWhiteText.svg";
import classicGrey from "@app/assets/brand/classic-logo/StirlingPDFLogoGreyText.svg";
type Asset = { label: string; src: string; onDark?: boolean };
type VariantSet = { variant: string; mark: Asset[]; wordmark: Asset[] };

Before

Width:  |  Height:  |  Size: 406 KiB

After

Width:  |  Height:  |  Size: 406 KiB

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Before

Width:  |  Height:  |  Size: 211 KiB

After

Width:  |  Height:  |  Size: 211 KiB

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before

Width:  |  Height:  |  Size: 339 B

After

Width:  |  Height:  |  Size: 339 B

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

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: 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

@@ -6,14 +6,14 @@
* 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";
import googleIcon from "@app/assets/login/google.svg";
import githubIcon from "@app/assets/login/github.svg";
import appleIcon from "@app/assets/login/apple.svg";
import microsoftIcon from "@app/assets/login/microsoft.svg";
import keycloakIcon from "@app/assets/login/keycloak.svg";
import cloudronIcon from "@app/assets/login/cloudron.svg";
import authentikIcon from "@app/assets/login/authentik.svg";
import oidcIcon from "@app/assets/login/oidc.svg";
/** Generic fallback icon (filename) for unknown providers. */
export const GENERIC_PROVIDER_ICON = "oidc.svg";
@@ -18,8 +18,8 @@ import {
getSystemTheme,
resolveColorScheme,
} from "@app/constants/theme";
// SUI shared design-system tokens (used by @shared/components); key on `data-theme`.
import "@shared/tokens/tokens.css";
// SUI shared design-system tokens (used by @app/ui); key on `data-theme`.
import "@app/tokens/tokens.css";
interface ThemeContextType {
themeMode: ThemeMode;
@@ -0,0 +1,93 @@
/* ===================== PanelHeader (core) ===================== */
/* Header for the active-tool rail. Core-owned so the OSS build has no */
/* design-system dependency; mirrors the shared rail header treatment so it */
/* reads well in both light and dark mode (thin border, no heavy fill). */
.sui-panelhdr {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem 1rem 0.75rem;
flex-shrink: 0;
}
.sui-panelhdr__bar {
display: inline-flex;
align-items: center;
gap: 0.5rem;
flex: 1;
min-width: 0;
padding: 0.4rem 0.75rem 0.4rem 0.4rem;
border: 1px solid var(--border-subtle, var(--mantine-color-default-border));
border-radius: 9999px;
background: var(--mantine-color-body);
text-align: left;
color: inherit;
}
.sui-panelhdr__icon {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 9999px;
background: var(--mantine-color-blue-light);
color: var(--mantine-color-blue-filled);
flex-shrink: 0;
}
.sui-panelhdr__icon svg {
font-size: 1rem;
width: 1rem;
height: 1rem;
}
/* ToolIcon wraps its glyph in .tool-button-icon with its own margin/transform;
reset them so the glyph sits dead-centre in the circular badge. */
.sui-panelhdr__icon .tool-button-icon {
margin: 0 !important;
transform: none !important;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.sui-panelhdr__label {
flex: 1;
min-width: 0;
font-size: 0.9rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--mantine-color-text);
}
/* Dark mode: let the header blend into the rail — just a thin border, no fill —
so it doesn't read as a clashing lighter card on the dark toolbar. */
[data-mantine-color-scheme="dark"] .sui-panelhdr__bar {
background: transparent;
border-color: var(--border-subtle, var(--mantine-color-default-border));
}
[data-mantine-color-scheme="dark"] .sui-panelhdr__icon {
background: color-mix(
in srgb,
var(--mantine-color-blue-filled) 18%,
transparent
);
color: var(--mantine-color-blue-3, var(--mantine-color-blue-filled));
}
/* Dark mode: the subtle gray close button is too dim against the dark rail —
brighten it to a clearly-visible light grey (near-white on hover). */
[data-mantine-color-scheme="dark"] .sui-panelhdr__close {
color: var(--mantine-color-gray-4);
}
[data-mantine-color-scheme="dark"] .sui-panelhdr__close:hover {
color: var(--mantine-color-gray-2);
}
@@ -0,0 +1,53 @@
import type { ReactNode } from "react";
import { ActionIcon } from "@mantine/core";
import CloseIcon from "@mui/icons-material/Close";
import "@app/components/shared/ToolPanelHeader.css";
export interface ToolPanelHeaderProps {
/** Glyph rendered in the tinted circular badge at the header's leading edge. */
icon: ReactNode;
/** Header title. */
title: ReactNode;
/** Close (X) handler. The trailing close button renders only when supplied. */
onClose?: () => void;
/** aria-label for the close button. */
closeLabel?: string;
}
/**
* Header for the active-tool rail surface: a tinted icon badge + title in a
* rounded bar with a trailing close button.
*
* Core-owned so the OSS build carries no design-system dependency. It mirrors
* the shared rail header's styling for the simple (non-menu) case the tool panel
* needs; the richer variant (dropdown menu, accents, loading dot) lives in the
* design system for the AI chat and Policies surfaces.
*/
export function ToolPanelHeader({
icon,
title,
onClose,
closeLabel,
}: ToolPanelHeaderProps) {
return (
<div className="sui-panelhdr">
<div className="sui-panelhdr__bar">
<span className="sui-panelhdr__icon">{icon}</span>
<span className="sui-panelhdr__label">{title}</span>
</div>
{onClose && (
<ActionIcon
className="sui-panelhdr__close"
variant="subtle"
color="gray"
radius="xl"
size="md"
onClick={onClose}
aria-label={closeLabel}
>
<CloseIcon sx={{ fontSize: 18 }} />
</ActionIcon>
)}
</div>
);
}
@@ -112,12 +112,14 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({
// falling back to the backend version
const currentVersion = appVersion ?? config?.appVersion ?? null;
// Check for updates on mount
// Check for updates on mount — skipped when the update UI is hidden (SaaS
// build, managed-disabled desktop) so no external update call ever fires.
useEffect(() => {
if (hideUpdateSection) return;
if (currentVersion) {
checkForUpdate();
}
}, [currentVersion, config?.machineType]);
}, [currentVersion, config?.machineType, hideUpdateSection]);
const checkForUpdate = async () => {
if (!currentVersion) return;
@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next";
import { oauthIconUrl } from "@shared/auth/ui/oauthIcons";
import { oauthIconUrl } from "@app/auth/ui/oauthIcons";
export type ProviderType = "oauth2" | "saml2" | "telegram" | "googledrive";
@@ -19,7 +19,7 @@ import { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems";
import { useToolSections } from "@app/hooks/useToolSections";
import type { SubcategoryGroup } from "@app/hooks/useToolSections";
import { ToolIcon } from "@app/components/shared/ToolIcon";
import { PanelHeader } from "@shared/components/PanelHeader";
import { ToolPanelHeader } from "@app/components/shared/ToolPanelHeader";
import { Tooltip as AppTooltip } from "@app/components/shared/Tooltip";
import { withViewTransition } from "@app/utils/viewTransition";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
@@ -291,7 +291,7 @@ export default function RightSidebar() {
<>
{!showPolicies &&
(activeTool ? (
<PanelHeader
<ToolPanelHeader
icon={
<ToolIcon
icon={activeTool.icon}
+5
View File
@@ -50,6 +50,10 @@ function findViteEnvVars(srcDir: string): Set<string> {
describe("env vars", () => {
it("every VITE_ var used in source is present in an example env file", () => {
const baseEnv = readFileSync(join(frontendRoot, ".env"), "utf-8");
const proprietaryEnv = readFileSync(
join(frontendRoot, ".env.proprietary"),
"utf-8",
);
const desktopEnv = readFileSync(
join(frontendRoot, ".env.desktop"),
"utf-8",
@@ -58,6 +62,7 @@ describe("env vars", () => {
const declaredKeys = new Set([
...parseEnvKeys(baseEnv),
...parseEnvKeys(proprietaryEnv),
...parseEnvKeys(desktopEnv),
...parseEnvKeys(saasEnv),
]);
@@ -10,11 +10,11 @@ import type { LogoVariant } from "@app/services/preferencesService";
*/
describe("useLogoAssets - Logo Asset Files", () => {
const publicDir = path.resolve(__dirname, "../../../public");
// Brand logo assets live in the shared design system; the editor's vite
// config copies shared/assets/brand/<folder>/* into the served root at build
// time (see viteStaticCopy in editor/vite.config.ts), so useLogoAssets can
// keep referencing them by their public-URL path. Validate them at source.
const brandDir = path.resolve(__dirname, "../../../../shared/assets/brand");
// Brand logo assets live in core; the editor's vite config copies
// core/assets/brand/<folder>/* into the served root at build time (see
// viteStaticCopy in editor/vite.config.ts), so useLogoAssets can keep
// referencing them by their public-URL path. Validate them at source.
const brandDir = path.resolve(__dirname, "../assets/brand");
// All asset files that useLogoAssets references
const requiredAssets = [
+3 -3
View File
@@ -1,7 +1,7 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import TomlBackend from "@shared/i18n/tomlBackend";
import TomlBackend from "@app/i18n/tomlBackend";
import {
supportedLanguages,
rtlLanguages,
@@ -10,10 +10,10 @@ import {
normalizeLanguageCode,
toUnderscoreFormat,
toUnderscoreLanguages,
} from "@shared/i18n/languages";
} from "@app/i18n/languages";
// Language metadata and code helpers are shared with the portal via
// @shared/i18n. Re-export them so existing `@app/i18n` consumers are unchanged.
// @app/i18n. Re-export them so existing `@app/i18n` consumers are unchanged.
export {
supportedLanguages,
rtlLanguages,
+1 -1
View File
@@ -1,6 +1,6 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import TomlBackend from "@shared/i18n/tomlBackend";
import TomlBackend from "@app/i18n/tomlBackend";
i18n
.use(TomlBackend)
@@ -19,8 +19,8 @@ import { fileURLToPath } from "url";
import ts from "typescript";
import { parse } from "smol-toml";
const HERE = path.dirname(fileURLToPath(import.meta.url)); // frontend/shared/i18n
const FRONTEND_ROOT = path.resolve(HERE, "../.."); // frontend
const HERE = path.dirname(fileURLToPath(import.meta.url)); // frontend/editor/src/core/i18n
const FRONTEND_ROOT = path.resolve(HERE, "../../../.."); // frontend
/** Repo root, for rendering source paths relative in CI annotations. */
export const REPO_ROOT = path.resolve(FRONTEND_ROOT, "..");
@@ -29,12 +29,6 @@ export interface TranslationProject {
name: string;
/** Absolute path to the source tree to scan. */
srcRoot: string;
/**
* Extra source trees that also contribute used keys (e.g. the shared layer
* (frontend/shared), whose components (login UI, etc.) are rendered by both
* apps and reference each app's locale keys).
*/
extraRoots?: string[];
/** Absolute path to the en-US source locale. */
localeFile: string;
/** Static keys flagged as missing that are genuinely fine (false positives). */
@@ -68,11 +62,6 @@ const PLURAL_SUFFIX_RE = /_(zero|one|two|few|many|other)$/;
const front = (rel: string): string => path.join(FRONTEND_ROOT, rel);
// The shared layer is rendered by both apps, so its translation usage counts
// toward each project (or keys used only from shared look unused, and a shared
// component's keys go unvalidated against an app's locale).
const SHARED_SRC = front("shared");
/**
* The projects the i18n suites guard. Each carries its own ignore lists: the
* editor exempts a few runtime-assembled key families; the portal starts clean.
@@ -80,8 +69,11 @@ const SHARED_SRC = front("shared");
export const I18N_PROJECTS: TranslationProject[] = [
{
name: "editor",
// One project over the whole editor tree, including the portal layer. Portal
// strings live under portal.* in the shared locale and are referenced with
// that prefix in source (t("portal.users.title")), so they validate here
// like any other key.
srcRoot: front("editor/src"),
extraRoots: [SHARED_SRC],
localeFile: front("editor/public/locales/en-US/translation.toml"),
ignoredKeyPatterns: [
// SignSettings / SavedSignaturesSection resolve every key as
@@ -93,24 +85,14 @@ export const I18N_PROJECTS: TranslationProject[] = [
/^account\./,
// [language] direction is read by the i18n layer, never as a UI string.
/^language\.direction$/,
// Portal source-type copy is referenced via metadata keys in
// components/sources/sourceTypes.ts (t(field.labelKey)), invisible to the
// static scan.
/^portal\.sources\.types\./,
],
minUsedKeys: 100,
minLocaleKeys: 100,
},
{
name: "portal",
srcRoot: front("portal/src"),
extraRoots: [SHARED_SRC],
localeFile: front("portal/public/locales/en-US/translation.toml"),
ignoredKeyPatterns: [
// Source-type copy is referenced via metadata keys in
// components/sources/sourceTypes.ts (t(field.labelKey)), so the static
// scan can't see these as used.
/^sources\.types\./,
],
minUsedKeys: 20,
minLocaleKeys: 20,
},
];
// Locale tables are shallow by design; the cap only trips on a pathological
@@ -189,11 +171,6 @@ const listSourceFiles = (roots: string[]): string[] =>
)
.filter((file) => !IGNORED_FILE_PATTERNS.some((re) => re.test(file)));
const projectRoots = (project: TranslationProject): string[] => [
project.srcRoot,
...(project.extraRoots ?? []),
];
const hasPluralCoverage = (key: string, available: Set<string>): boolean =>
[...available].some(
(k) => k.startsWith(`${key}_`) && PLURAL_SUFFIX_RE.test(k),
@@ -347,7 +324,7 @@ export function findMissingKeys(project: TranslationProject): {
return false;
};
const used = listSourceFiles(projectRoots(project))
const used = listSourceFiles([project.srcRoot])
.flatMap(extractStaticKeys)
.filter(({ key }) => !ignored.has(key));
const missing = used.filter(({ key }) => !resolves(key));
@@ -360,7 +337,7 @@ export function findUnusedKeys(project: TranslationProject): {
localeCount: number;
} {
const localeKeys = Array.from(collectLocaleKeys(project.localeFile));
const files = listSourceFiles(projectRoots(project));
const files = listSourceFiles([project.srcRoot]);
const source = files.map((file) => fs.readFileSync(file, "utf8")).join("\n");
const shapes = new Set<string>();
@@ -0,0 +1,10 @@
import type { ReactElement } from "react";
/**
* Admin-only route-set contributed by higher layers (the portal). The OSS core
* build ships none, so this stub returns an empty list and the portal chunk is
* never referenced in the core bundle.
*/
export function getAdminRouteExtensions(): ReactElement[] {
return [];
}
@@ -5,10 +5,10 @@ import {
I18N_PROJECTS,
REPO_ROOT,
findMissingKeys,
} from "@shared/i18n/translationAudit";
} from "@app/i18n/translationAudit";
// One suite per frontend app (editor + portal). The scan logic lives in
// @shared/i18n/translationAudit so both apps share one implementation.
// @app/i18n/translationAudit so both apps share one implementation.
describe.each(I18N_PROJECTS)(
"Missing translation coverage — $name",
(project) => {
@@ -5,10 +5,10 @@ import {
I18N_PROJECTS,
REPO_ROOT,
findUnusedKeys,
} from "@shared/i18n/translationAudit";
} from "@app/i18n/translationAudit";
// One suite per frontend app (editor + portal). The scan logic lives in
// @shared/i18n/translationAudit so both apps share one implementation; each
// @app/i18n/translationAudit so both apps share one implementation; each
// project carries its own ignoredKeyPatterns for runtime-assembled keys.
describe.each(I18N_PROJECTS)(
"Unused translation coverage — $name",
@@ -38,7 +38,7 @@ describe.each(I18N_PROJECTS)(
`Found ${unused.length} unused en-US translation key(s). ` +
`Remove them from ${localeRelative}, or (if the usage is too ` +
`dynamic for the heuristic to spot) add a pattern to this ` +
`project's ignoredKeyPatterns in @shared/i18n/translationAudit.`,
`project's ignoredKeyPatterns in @app/i18n/translationAudit.`,
).toEqual([]);
},
);
+1 -2
View File
@@ -2,8 +2,7 @@
"extends": "../../tsconfig.json",
"compilerOptions": {
"paths": {
"@app/*": ["../../src/core/*"],
"@shared/*": ["../../../shared/*"]
"@app/*": ["../../src/core/*"]
}
},
"include": ["../global.d.ts", "../*.js", "../*.ts", "../*.tsx", "."]
@@ -1,8 +1,8 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import LoginRightCarousel from "@shared/auth/ui/LoginRightCarousel";
import LoginRightCarousel from "@app/auth/ui/LoginRightCarousel";
import buildLoginSlides from "@app/components/shared/loginSlides";
import styles from "@shared/auth/ui/AuthShell.module.css";
import styles from "@app/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 { oauthIconUrl } from "@shared/auth/ui/oauthIcons";
import { oauthIconUrl } from "@app/auth/ui/oauthIcons";
import { STIRLING_SAAS_URL } from "@app/constants/connection";
import "@app/components/SetupWizard/desktopOAuth.css";
@@ -1,13 +1,13 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import LoginHeader from "@app/routes/login/LoginHeader";
import ErrorMessage from "@shared/auth/ui/ErrorMessage";
import EmailPasswordForm from "@shared/auth/ui/EmailPasswordForm";
import ErrorMessage from "@app/auth/ui/ErrorMessage";
import EmailPasswordForm from "@app/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 "@shared/auth/ui/auth.css";
import "@app/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 "@shared/auth/ui/ErrorMessage";
import ErrorMessage from "@app/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 "@shared/auth/ui/auth.css";
import "@app/auth/ui/auth.css";
interface SaaSSignupScreenProps {
loading: boolean;
@@ -1,6 +1,6 @@
import React from "react";
import { useTranslation } from "react-i18next";
import "@shared/auth/ui/auth.css";
import "@app/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 "@shared/auth/ui/ErrorMessage";
import EmailPasswordForm from "@shared/auth/ui/EmailPasswordForm";
import ErrorMessage from "@app/auth/ui/ErrorMessage";
import EmailPasswordForm from "@app/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 "@shared/auth/ui/auth.css";
import "@app/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 "@shared/auth/ui/ErrorMessage";
import ErrorMessage from "@app/auth/ui/ErrorMessage";
import { ServerSelection } from "@app/components/SetupWizard/ServerSelection";
import { ServerConfig } from "@app/services/connectionModeService";
import "@shared/auth/ui/auth.css";
import "@app/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 "@shared/auth/ui/auth.css";
import "@app/auth/ui/auth.css";
import { DisabledButtonWithTooltip } from "@app/components/shared/DisabledButtonWithTooltip";
enum SetupStep {
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import CoreGeneralSection from "@core/components/shared/config/configSections/GeneralSection";
import { DefaultAppSettings } from "@app/components/shared/config/configSections/DefaultAppSettings";
import { useDesktopInstall } from "@app/hooks/useDesktopInstall";
import { useSaaSMode } from "@app/hooks/useSaaSMode";
import {
desktopUpdateService,
type UpdateMode,
@@ -22,6 +23,9 @@ import {
const GeneralSection: React.FC = () => {
const { t } = useTranslation();
const install = useDesktopInstall();
// In SaaS connection mode the cloud owns app versioning — hide the update
// section (which also stops the core auto-check from firing).
const isSaaSMode = useSaaSMode();
const [updateModeInfo, setUpdateModeInfo] = useState<UpdateModeInfo>({
mode: "prompt",
locked: false,
@@ -93,7 +97,8 @@ const GeneralSection: React.FC = () => {
)}
<CoreGeneralSection
hideUpdateSection={
updateModeInfo.mode === "disabled" && updateModeInfo.locked
isSaaSMode ||
(updateModeInfo.mode === "disabled" && updateModeInfo.locked)
}
desktopInstall={{
state: install.state,
@@ -9,6 +9,7 @@ const getVersionMock = vi.fn();
const getUpdateModeMock = vi.fn();
const canInstallUpdatesMock = vi.fn();
const getUpdateSummaryMock = vi.fn();
const getCurrentModeMock = vi.fn();
vi.mock("@tauri-apps/api/core", () => ({
invoke: (cmd: string, args?: unknown) => invokeMock(cmd, args),
@@ -36,6 +37,12 @@ vi.mock("@app/services/desktopUpdateService", () => ({
},
}));
vi.mock("@app/services/connectionModeService", () => ({
connectionModeService: {
getCurrentMode: () => getCurrentModeMock(),
},
}));
import { useDesktopUpdatePopup } from "@app/hooks/useDesktopUpdatePopup";
/** Flush pending microtasks so awaited promises settle. */
@@ -74,8 +81,11 @@ describe("useDesktopUpdatePopup — auto mode", () => {
getUpdateModeMock.mockReset();
canInstallUpdatesMock.mockReset();
getUpdateSummaryMock.mockReset();
getCurrentModeMock.mockReset();
// Defaults: auto mode, update available, install permitted.
// Defaults: local (non-SaaS) connection, auto mode, update available,
// install permitted.
getCurrentModeMock.mockResolvedValue("local");
getUpdateModeMock.mockResolvedValue("auto");
getVersionMock.mockResolvedValue("1.0.0");
getUpdateSummaryMock.mockResolvedValue({ latest_version: "2.0.0" });
@@ -189,4 +199,18 @@ describe("useDesktopUpdatePopup — auto mode", () => {
expect(invocations).toContain("download_and_install_update");
expect(invocations).toContain("restart_app");
});
it("skips the update check entirely in SaaS connection mode", async () => {
// In SaaS mode the cloud owns versioning — the self-hosted update check
// must never run: no mode lookup, no external summary fetch, no install.
getCurrentModeMock.mockResolvedValue("saas");
await runStartup();
expect(getUpdateModeMock).not.toHaveBeenCalled();
expect(getUpdateSummaryMock).not.toHaveBeenCalled();
const invocations = invokeMock.mock.calls.map((c) => c[0]);
expect(invocations).not.toContain("download_and_install_update");
expect(invocations).not.toContain("restart_app");
});
});
@@ -6,6 +6,7 @@ import {
desktopUpdateService,
type CanInstallResult,
} from "@app/services/desktopUpdateService";
import { connectionModeService } from "@app/services/connectionModeService";
const SNOOZE_KEY = "stirling-pdf-updater:snoozedUntil";
const STARTUP_DELAY_MS = 15_000;
@@ -72,6 +73,10 @@ export function useDesktopUpdatePopup() {
hasChecked.current = true;
const timer = setTimeout(async () => {
// In SaaS connection mode the cloud owns app versioning — the self-hosted
// update check + popup must never run (no external call, no modal).
if ((await connectionModeService.getCurrentMode()) === "saas") return;
let mode: Awaited<ReturnType<typeof desktopUpdateService.getUpdateMode>> =
"prompt";
try {
@@ -0,0 +1,10 @@
import type { ReactElement } from "react";
/**
* Desktop inherits proprietary's App but must NOT ship the portal. Shadowing
* the seam back to empty means the desktop bundle never references PortalApp,
* so the portal chunk is not emitted (and @portal never has to resolve there).
*/
export function getAdminRouteExtensions(): ReactElement[] {
return [];
}
+1 -2
View File
@@ -10,8 +10,7 @@
],
"@cloud/*": ["../../src/cloud/*"],
"@proprietary/*": ["../../src/proprietary/*"],
"@core/*": ["../../src/core/*"],
"@shared/*": ["../../../shared/*"]
"@core/*": ["../../src/core/*"]
}
},
"include": [
@@ -1,12 +1,12 @@
import { useEffect, type ReactNode } from "react";
import { BrowserRouter, useLocation } from "react-router-dom";
import { useLocation } from "react-router-dom";
import { MantineProvider } from "@mantine/core";
import { AuthProvider } from "@shared/auth";
import { AuthProvider } from "@app/auth";
import { ErrorBoundary } from "@portal/components/ErrorBoundary";
import { ThemeProvider, useTheme } from "@portal/contexts/ThemeContext";
import { TierProvider } from "@portal/contexts/TierContext";
import { LinkProvider, useLink } from "@portal/contexts/LinkContext";
import type { SupabaseLoginSession } from "@shared/auth/ui/useSupabaseLogin";
import type { SupabaseLoginSession } from "@app/auth/ui/useSupabaseLogin";
import { UIProvider, useUI } from "@portal/contexts/UIContext";
import { mantineTheme } from "@portal/theme/mantineTheme";
import { AppShell } from "@portal/components/AppShell";
@@ -21,6 +21,8 @@ import {
useAccountLinkContext,
} from "@portal/contexts/AccountLinkContext";
import { ViewRouter } from "@portal/ViewRouter";
// Reset + typography, scoped to .portal-scope below.
import "@portal/theme/base.css";
/**
* Binds Mantine's colour scheme to the portal's own ThemeProvider so Mantine
@@ -117,22 +119,24 @@ function RoutedContent() {
);
}
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;
/**
* The portal, mounted as a route-set under /portal/* inside the editor app (via
* the admin-route seam). It supplies its own providers and its own i18next
* instance (the `portal` namespace), but NOT a router — the editor's
* <BrowserRouter> is the one and only router; the portal's routes are relative
* to the /portal mount (see ViewRouter).
*/
export function PortalApp() {
return (
<ThemeProvider>
<PortalMantineProvider>
<AuthProvider mode="spring">
<LinkProvider initialState="unlinked">
{/* TierProvider sits INSIDE LinkProvider so it can derive the tier
{/* Scopes base.css to the portal so it doesn't restyle the host editor. */}
<div className="portal-scope">
<AuthProvider mode="spring">
<LinkProvider initialState="unlinked">
{/* TierProvider sits INSIDE LinkProvider so it can derive the tier
from the real link/subscription state when MSW mocks are off. */}
<TierProvider initialTier="pro">
<BrowserRouter basename={basename}>
<TierProvider initialTier="pro">
<UIProvider>
<GlobalShortcuts />
<AuthGate>
@@ -148,10 +152,10 @@ export function App() {
</AccountLinkProvider>
</AuthGate>
</UIProvider>
</BrowserRouter>
</TierProvider>
</LinkProvider>
</AuthProvider>
</TierProvider>
</LinkProvider>
</AuthProvider>
</div>
</PortalMantineProvider>
</ThemeProvider>
);
@@ -3,7 +3,7 @@
The portal has two component sources. The rule:
> **Simple, presentational, brand-defining UI → our SUI design system
> (`@shared/components`). Complex, stateful, or accessibility-hard widgets →
> (`@app/ui`). Complex, stateful, or accessibility-hard widgets →
> Mantine.** Don't reinvent what Mantine already does well; do own the look of
> the simple, high-frequency pieces.
@@ -13,7 +13,7 @@ follow the same light/dark switch and brand colours as SUI. **The provider is
intentional** — it exists precisely so we can drop Mantine widgets in where they
earn their keep.
## Use SUI (`@shared/components`) — our own style
## Use SUI (`@app/ui`) — our own style
Layout and presentational primitives we want full brand control over and that
are cheap to own:
+58
View File
@@ -0,0 +1,58 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { Home } from "@portal/views/Home";
import { Users } from "@portal/views/Users";
import { Documents } from "@portal/views/Documents";
import { Pipelines } from "@portal/views/Pipelines";
import { Sources } from "@portal/views/Sources";
import { AgentBuilder } from "@portal/views/AgentBuilder";
import { Policies } from "@portal/views/Policies";
import { Components } from "@portal/views/Components";
import { EditorAdmin } from "@portal/views/EditorAdmin";
import { Infrastructure } from "@portal/views/Infrastructure";
import { Usage } from "@portal/views/Usage";
import { DeveloperDocs } from "@portal/views/DeveloperDocs";
import { Procurement } from "@portal/views/Procurement";
import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
// The portal mounts as a route-set under /portal/* in the editor app, so these
// child routes are relative to that base: strip the leading slash from the
// logical VIEW_PATHS, and home is the index route. Redirects use toPortalPath
// so they resolve to the portal, not the editor root.
const rel = (viewPath: string) => viewPath.replace(/^\//, "");
export function ViewRouter() {
return (
<Routes>
<Route index element={<Home />} />
<Route path={rel(VIEW_PATHS.users)} element={<Users />} />
<Route path={rel(VIEW_PATHS.pipelines)} element={<Pipelines />} />
<Route path={rel(VIEW_PATHS.sources)} element={<Sources />} />
<Route
path={rel(VIEW_PATHS["agent-builder"])}
element={<AgentBuilder />}
/>
<Route path={rel(VIEW_PATHS.policies)} element={<Policies />} />
<Route path={rel(VIEW_PATHS.documents)} element={<Documents />} />
<Route path={rel(VIEW_PATHS.components)} element={<Components />} />
<Route path={rel(VIEW_PATHS.editor)} element={<EditorAdmin />} />
<Route
path={rel(VIEW_PATHS.infrastructure)}
element={<Infrastructure />}
/>
<Route path={rel(VIEW_PATHS.usage)} element={<Usage />} />
<Route path={rel(VIEW_PATHS.procurement)} element={<Procurement />} />
<Route path={rel(VIEW_PATHS.docs)} element={<DeveloperDocs />} />
{/* Account-link is now a Settings panel; redirect legacy bookmarks home. */}
<Route
path="account-link"
element={<Navigate to={toPortalPath(VIEW_PATHS.home)} replace />}
/>
{/* Settings is a modal overlay, not a route (see AppShell + UIContext). */}
{/* Unknown paths land on Home. */}
<Route
path="*"
element={<Navigate to={toPortalPath(VIEW_PATHS.home)} replace />}
/>
</Routes>
);
}
@@ -1,10 +1,10 @@
import { apiClient } from "@portal/api/http";
import type { Wallet } from "@shared/billing";
import type { Wallet } from "@app/billing";
/**
* Real wallet + billing surface. All calls go to apiClient.saas — the hosted
* SaaS Java backend, authed by the admin's Supabase JWT. The wallet contract
* itself lives in {@code @shared/billing} (shared with the editor cloud surface).
* itself lives in {@code @app/billing} (shared with the editor cloud surface).
*/
// Re-export the shared contract so existing `@portal/api/billing` importers keep working.
@@ -15,7 +15,7 @@ export type {
WalletMember,
WalletCategoryBreakdown,
WalletActivityRow,
} from "@shared/billing";
} from "@app/billing";
export async function fetchWallet(): Promise<Wallet> {
return apiClient.saas.json<Wallet>("/api/v1/payg/wallet");

Some files were not shown because too many files have changed in this diff Show More