Restructure Portal code to be inside Editor (#6857)

# Description of Changes
We don't have any strong reasons to keep the Portal as a separate Vite
app, and it needs access to so many things from the Editor that it no
longer makes sense to keep them separate. This PR moves the Portal code
to have direct access to the Editor code and gets rid of the shared
folder.
This commit is contained in:
James Brunton
2026-07-03 13:20:02 +00:00
committed by GitHub
parent 675afe9b71
commit 11ba3814e5
689 changed files with 4509 additions and 4783 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>
);
}
@@ -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 {
@@ -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");
@@ -12,8 +12,8 @@ const { getSession, getStoredTokenMock } = vi.hoisted(() => ({
getStoredTokenMock: vi.fn(),
}));
vi.mock("@shared/auth", () => ({ getStoredToken: getStoredTokenMock }));
vi.mock("@shared/auth/supabase/supabaseClient", () => ({
vi.mock("@app/auth", () => ({ getStoredToken: getStoredTokenMock }));
vi.mock("@app/auth/supabase/supabaseClient", () => ({
getSupabaseClient: () => ({ auth: { getSession } }),
configureSupabase: vi.fn(),
}));
@@ -5,7 +5,7 @@
*
* apiClient.local Same-origin (vite proxy this instance's local
* Stirling backend on :8080). Spring admin bearer
* (`stirling_jwt` from @shared/auth) auto-attached.
* (`stirling_jwt` from @app/auth) auto-attached.
* USE FOR: actions on this instance
* /api/v1/account-link/{status,link,unlink}, etc.
*
@@ -39,8 +39,8 @@
* entitlement calls. It never enters the portal the browser is the human
* admin and uses the Supabase JWT for SaaS reads. Don't add it here.
*/
import { clearStoredToken, getStoredToken } from "@shared/auth";
import { getSupabaseClient } from "@shared/auth/supabase/supabaseClient";
import { clearStoredToken, getStoredToken } from "@app/auth";
import { getSupabaseClient } from "@app/auth/supabase/supabaseClient";
import { ensureSaasSupabase } from "@portal/auth/saasSupabase";
/** Read the SaaS base URL at call time so tests can stub it via vi.stubEnv. */
@@ -20,7 +20,7 @@ const { getSession } = vi.hoisted(() => ({
data: { session: { access_token: "supabase_jwt_test" } },
}),
}));
vi.mock("@shared/auth/supabase/supabaseClient", () => ({
vi.mock("@app/auth/supabase/supabaseClient", () => ({
getSupabaseClient: () => ({ auth: { getSession } }),
configureSupabase: vi.fn(),
}));

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