From ccfd22b2a926ddb7c62b700b8f9252c46c2aeb71 Mon Sep 17 00:00:00 2001 From: Reece Browne <74901996+reecebrowne@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:35:30 +0100 Subject: [PATCH] port editor settings into portal (#6945) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The portal's `SettingsModal` was a parallel, mock-backed settings implementation. It's replaced by the editor's `AppConfigModal`, mounted via a new `PortalSettingsHost` that supplies the contexts the portal doesn't have (app config, flavor-resolved session, preferences, editor theme). Flavor resolution does the rest: the self-hosted portal gets the admin sections, the SaaS portal gets the saas shell. The self-hosted account-link panel rides in through the existing seam as an extra section. The shell gains three host props (`urlSync`, `initialSection`, `extraSections`); editor behaviour is unchanged. Net −1,300 lines. Manually verified on both flavors against live backends. --- .../public/locales/en-US/translation.toml | 111 --- frontend/editor/public/og-metadata.json | 8 +- .../core/components/shared/AppConfigModal.tsx | 59 +- .../components/shared/AppConfigModalLazy.tsx | 23 +- .../shared/config/configNavSections.tsx | 23 +- .../core/components/shared/config/types.ts | 22 + .../desktop/components/shared/config/types.ts | 19 + .../settings/accountLinkSettings.tsx | 6 +- frontend/editor/src/portal/api/settings.ts | 87 -- .../src/portal/components/PortalChrome.tsx | 16 +- .../portal/components/PortalSettingsHost.tsx | 84 ++ .../src/portal/components/SettingsModal.css | 255 ----- .../src/portal/components/SettingsModal.tsx | 902 ------------------ .../settings/accountLinkSettings.tsx | 20 +- .../editor/src/portal/mocks/handlers/index.ts | 2 - .../src/portal/mocks/handlers/settings.ts | 12 - frontend/editor/src/portal/mocks/settings.ts | 151 --- .../saas/components/shared/AppConfigModal.tsx | 45 +- .../saas/components/shared/config/types.ts | 19 + 19 files changed, 279 insertions(+), 1585 deletions(-) delete mode 100644 frontend/editor/src/portal/api/settings.ts create mode 100644 frontend/editor/src/portal/components/PortalSettingsHost.tsx delete mode 100644 frontend/editor/src/portal/components/SettingsModal.css delete mode 100644 frontend/editor/src/portal/components/SettingsModal.tsx delete mode 100644 frontend/editor/src/portal/mocks/handlers/settings.ts delete mode 100644 frontend/editor/src/portal/mocks/settings.ts diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 1cc2ad8ff1..11193c3ed3 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -7913,122 +7913,11 @@ noActionsTitle = "No quick actions" noMatches = "No matches for \"{{query}}\"" noMatchesDescription = "Try a different keyword or browse the catalogue." -[portal.settings] -ariaLabel = "Settings" -cancel = "Cancel" -enterpriseBadge = "Enterprise" -footerNote = "Changes apply to this workspace." -saveChanges = "Save changes" - -[portal.settings.appearance] -themeSub = "Choose how the portal looks on this device." -themeTitle = "Theme" - -[portal.settings.appearance.dark] -hint = "Dim surfaces" -label = "Dark" - -[portal.settings.appearance.light] -hint = "Bright surfaces" -label = "Light" - -[portal.settings.authentication] -sessionTimeout = "Session timeout" -sessionTimeoutHelper = "Members re-authenticate after this idle period." -sub = "Organisation-wide authentication controls." -title = "Sign-in policy" - -[portal.settings.authentication.mfa] -description = "Require every member to complete MFA at sign-in." -label = "Enforce two-factor (MFA)" - -[portal.settings.authentication.scim] -description = "Sync members and roles from your directory." -label = "SCIM provisioning" - -[portal.settings.authentication.sso] -description = "Federate sign-in through your identity provider." -label = "Single sign-on (SAML)" - -[portal.settings.authentication.timeout] -1440 = "24 hours" -240 = "4 hours" -480 = "8 hours" -60 = "1 hour" -720 = "12 hours" - -[portal.settings.earlyAccess] -sub = "Opt into features still in preview." -title = "Preview features" - [portal.settings.groups] -account = "Account" admin = "Admin" -workspace = "Workspace" - -[portal.settings.notifications] -sub = "Pick which events reach your inbox." -title = "Email notifications" - -[portal.settings.notifications.pipeline-failures] -description = "A run errors out or a step times out." -label = "Pipeline failures" - -[portal.settings.notifications.pipeline-success] -description = "Every successful pipeline run finishes." -label = "Pipeline completions" - -[portal.settings.notifications.product-updates] -description = "New operations, sources, and release notes." -label = "Product updates" - -[portal.settings.notifications.security-alerts] -description = "New API keys, sign-ins, or permission changes." -label = "Security alerts" - -[portal.settings.notifications.usage-alerts] -description = "You approach a plan limit or rate cap." -label = "Usage & quota alerts" - -[portal.settings.notifications.weekly-digest] -description = "A Monday summary of volume and health." -label = "Weekly digest" - -[portal.settings.profile] -accountFallback = "Account" -changePhoto = "Change photo" -email = "Email" -emailHelper = "Used for sign-in and notification delivery." -emailPlaceholder = "you@company.com" -fullName = "Full name" -namePlaceholder = "Your name" [portal.settings.sections] account-link = "Account link" -appearance = "Appearance" -authentication = "Authentication" -early-access = "Early access" -general = "General" -notifications = "Notifications" -profile = "Profile" -sessions = "Active sessions" - -[portal.settings.sessions] -revoke = "Revoke" -sub = "Devices currently signed in to this account." -thisDevice = "This device" -title = "Active sessions" - -[portal.settings.workspace] -manageBilling = "Manage billing" -nameLabel = "Workspace name" -namePlaceholder = "Workspace name" -plan = "Plan" -regionEnterpriseSuffix = "{{region}} · Enterprise" -regionHelper = "Where documents are processed and stored at rest." -regionLabel = "Data residency region" -seats = "Seats" -seatsUsed = "{{used}} of {{total}} used" [portal.shell.header] accountFallback = "Account" diff --git a/frontend/editor/public/og-metadata.json b/frontend/editor/public/og-metadata.json index 65f1da789c..1fc552cd80 100644 --- a/frontend/editor/public/og-metadata.json +++ b/frontend/editor/public/og-metadata.json @@ -499,6 +499,11 @@ "image": "/og_images/home.png", "title": "Payg Settings - Stirling PDF", "description": "The Free Adobe Acrobat alternative (10M+ Downloads)" + }, + "/settings/account-link": { + "image": "/og_images/home.png", + "title": "Account Link Settings - Stirling PDF", + "description": "The Free Adobe Acrobat alternative (10M+ Downloads)" } }, "byPath": { @@ -653,6 +658,7 @@ "/settings/legal": "/settings/legal", "/settings/backendThirdPartyLicenses": "/settings/backendThirdPartyLicenses", "/settings/frontendThirdPartyLicenses": "/settings/frontendThirdPartyLicenses", - "/settings/payg": "/settings/payg" + "/settings/payg": "/settings/payg", + "/settings/account-link": "/settings/account-link" } } diff --git a/frontend/editor/src/core/components/shared/AppConfigModal.tsx b/frontend/editor/src/core/components/shared/AppConfigModal.tsx index 264db178ac..485403c512 100644 --- a/frontend/editor/src/core/components/shared/AppConfigModal.tsx +++ b/frontend/editor/src/core/components/shared/AppConfigModal.tsx @@ -11,7 +11,11 @@ import { useNavigate, useLocation } from "react-router-dom"; import { useTranslation } from "react-i18next"; import LocalIcon from "@app/components/shared/LocalIcon"; import { useConfigNavSections } from "@app/components/shared/config/configNavSections"; -import { NavKey, VALID_NAV_KEYS } from "@app/components/shared/config/types"; +import { + NavKey, + VALID_NAV_KEYS, + type ConfigNavSection, +} from "@app/components/shared/config/types"; import { useAppConfig } from "@app/contexts/AppConfigContext"; import { COOKIE_CONSENT_SCROLL_SHARD } from "@app/hooks/useCookieConsent"; import "@app/components/shared/AppConfigModal.css"; @@ -31,6 +35,18 @@ import { stripBasePath, withBasePath } from "@app/constants/app"; interface AppConfigModalProps { opened: boolean; onClose: () => void; + /** + * Mirror the active section to /settings/ URLs (deep links, history + * unwind on close). Hosts mounted away from the editor's /settings route — + * the admin portal — turn this off and the modal keeps its section purely in + * state. + */ + urlSync?: boolean; + /** Section to land on when opening. Only honoured when urlSync is off (URL + * deep links win otherwise). */ + initialSection?: NavKey | null; + /** Host-specific sections appended after the build's registry sections. */ + extraSections?: ConfigNavSection[]; } // Extract section from URL path (e.g., /settings/people -> people) @@ -46,12 +62,18 @@ const getSectionFromPath = (pathname: string): NavKey | null => { const AppConfigModalInner: React.FC = ({ opened, onClose, + urlSync = true, + initialSection, + extraSections, }) => { const { t } = useTranslation(); // Initialize from the URL so a deep link (`/settings/people`) lands on the // right tab without a one-frame "general" flicker. const [active, setActive] = useState( - () => getSectionFromPath(window.location.pathname) ?? "general", + () => + (urlSync ? getSectionFromPath(window.location.pathname) : null) ?? + initialSection ?? + "general", ); const isMobile = useIsMobile(); const navigate = useNavigate(); @@ -66,6 +88,7 @@ const AppConfigModalInner: React.FC = ({ // those update the URL via `history.replaceState` directly and never push // a new React Router location. useEffect(() => { + if (!urlSync) return; const section = getSectionFromPath(location.pathname); if (opened && section) { setActive(section); @@ -77,7 +100,14 @@ const AppConfigModalInner: React.FC = ({ // If at /settings without a section, redirect to general navigate("/settings/general", { replace: true }); } - }, [location.pathname, opened, navigate]); + }, [location.pathname, opened, navigate, urlSync]); + + // Non-URL hosts land the modal on the section they asked for. + useEffect(() => { + if (opened && !urlSync && initialSection) { + setActive(initialSection); + } + }, [opened, urlSync, initialSection]); useEffect(() => { if (opened) { @@ -99,6 +129,7 @@ const AppConfigModalInner: React.FC = ({ const switchSection = useCallback( (key: NavKey) => { setActive(key); + if (!urlSync) return; const alreadyInSettings = stripBasePath( window.location.pathname, ).startsWith("/settings"); @@ -112,7 +143,7 @@ const AppConfigModalInner: React.FC = ({ navigate(`/settings/${key}`); } }, - [navigate], + [navigate, urlSync], ); // Backwards-compat: external `appConfig:navigate` events route through the @@ -156,7 +187,7 @@ const AppConfigModalInner: React.FC = ({ // Only unwind history if settings was opened via the URL; opened via state // there's no /settings entry to pop and navigate(-1) would jump to /files. - if (location.pathname.startsWith("/settings")) { + if (urlSync && location.pathname.startsWith("/settings")) { // "default" key = first entry (deep link/refresh); nothing to pop to. if (location.key === "default") { navigate("/", { replace: true }); @@ -165,7 +196,14 @@ const AppConfigModalInner: React.FC = ({ } } onClose(); - }, [confirmIfDirty, location.key, location.pathname, navigate, onClose]); + }, [ + confirmIfDirty, + location.key, + location.pathname, + navigate, + onClose, + urlSync, + ]); // Synchronous wrapper for contexts (e.g. tour buttons) that need () => void const handleCloseSync = useCallback(() => { @@ -173,12 +211,19 @@ const AppConfigModalInner: React.FC = ({ }, [handleClose]); // Left navigation structure and icons - const configNavSections = useConfigNavSections( + const registrySections = useConfigNavSections( isAdmin, runningEE, loginEnabled, handleCloseSync, ); + const configNavSections = useMemo( + () => + extraSections?.length + ? [...registrySections, ...extraSections] + : registrySections, + [registrySections, extraSections], + ); const activeLabel = useMemo(() => { for (const section of configNavSections) { diff --git a/frontend/editor/src/core/components/shared/AppConfigModalLazy.tsx b/frontend/editor/src/core/components/shared/AppConfigModalLazy.tsx index d242db9a66..1ba57618f0 100644 --- a/frontend/editor/src/core/components/shared/AppConfigModalLazy.tsx +++ b/frontend/editor/src/core/components/shared/AppConfigModalLazy.tsx @@ -1,4 +1,8 @@ import { Suspense, lazy, useEffect, useState } from "react"; +import type { + ConfigNavSection, + NavKey, +} from "@app/components/shared/config/types"; // AppConfigModal pulls in the entire settings UI tree (admin sections, // account, supabase auth flows, etc.). We defer loading until the user first @@ -10,11 +14,20 @@ const AppConfigModal = lazy( interface AppConfigModalLazyProps { opened: boolean; onClose: () => void; + /** See AppConfigModal — off for hosts outside the /settings route. */ + urlSync?: boolean; + /** Section to land on when opening (non-URL hosts). */ + initialSection?: NavKey | null; + /** Host-specific sections appended after the build's registry sections. */ + extraSections?: ConfigNavSection[]; } export default function AppConfigModalLazy({ opened, onClose, + urlSync, + initialSection, + extraSections, }: AppConfigModalLazyProps) { const [shouldMount, setShouldMount] = useState(false); @@ -24,7 +37,15 @@ export default function AppConfigModalLazy({ return ( - {shouldMount && } + {shouldMount && ( + + )} ); } diff --git a/frontend/editor/src/core/components/shared/config/configNavSections.tsx b/frontend/editor/src/core/components/shared/config/configNavSections.tsx index db02ad8458..0c9515b68c 100644 --- a/frontend/editor/src/core/components/shared/config/configNavSections.tsx +++ b/frontend/editor/src/core/components/shared/config/configNavSections.tsx @@ -1,6 +1,5 @@ import React from "react"; import { useTranslation } from "react-i18next"; -import { NavKey } from "@app/components/shared/config/types"; import HotkeysSection from "@app/components/shared/config/configSections/HotkeysSection"; import GeneralSection from "@app/components/shared/config/configSections/GeneralSection"; import HelpSection from "@app/components/shared/config/configSections/HelpSection"; @@ -9,22 +8,14 @@ import { BackendThirdPartyLicensesSection, FrontendThirdPartyLicensesSection, } from "@app/components/shared/config/configSections/ThirdPartyLicensesSection"; +import type { + ConfigNavItem, + ConfigNavSection, +} from "@app/components/shared/config/types"; -export interface ConfigNavItem { - key: NavKey; - label: string; - icon: string; - component: React.ReactNode; - disabled?: boolean; - disabledTooltip?: string; - badge?: string; - badgeColor?: string; -} - -export interface ConfigNavSection { - title: string; - items: ConfigNavItem[]; -} +// Re-exported for the many existing importers; the definitions live in +// config/types so type-only consumers don't pull the section tree in. +export type { ConfigNavItem, ConfigNavSection }; export interface ConfigColors { navBg: string; diff --git a/frontend/editor/src/core/components/shared/config/types.ts b/frontend/editor/src/core/components/shared/config/types.ts index 9fa87fe915..5cbdd1b169 100644 --- a/frontend/editor/src/core/components/shared/config/types.ts +++ b/frontend/editor/src/core/components/shared/config/types.ts @@ -1,3 +1,5 @@ +import type React from "react"; + // Single source of truth for all valid nav keys export const VALID_NAV_KEYS = [ "preferences", @@ -35,9 +37,29 @@ export const VALID_NAV_KEYS = [ "backendThirdPartyLicenses", "frontendThirdPartyLicenses", "payg", + "account-link", ] as const; // Derive the type from the array export type NavKey = (typeof VALID_NAV_KEYS)[number]; // some of these are not used yet, but appear in figma designs + +// Nav structure of the settings modal. Lives here (not configNavSections) so +// consumers that only need the shape don't pull the whole section-component +// tree into their build's typecheck graph. +export interface ConfigNavItem { + key: NavKey; + label: string; + icon: string; + component: React.ReactNode; + disabled?: boolean; + disabledTooltip?: string; + badge?: string; + badgeColor?: string; +} + +export interface ConfigNavSection { + title: string; + items: ConfigNavItem[]; +} diff --git a/frontend/editor/src/desktop/components/shared/config/types.ts b/frontend/editor/src/desktop/components/shared/config/types.ts index a1ab108a1f..daae2b16bc 100644 --- a/frontend/editor/src/desktop/components/shared/config/types.ts +++ b/frontend/editor/src/desktop/components/shared/config/types.ts @@ -1,3 +1,4 @@ +import type React from "react"; import { VALID_NAV_KEYS as CORE_NAV_KEYS } from "@core/components/shared/config/types"; export const VALID_NAV_KEYS = [ @@ -7,3 +8,21 @@ export const VALID_NAV_KEYS = [ ] as const; export type NavKey = (typeof VALID_NAV_KEYS)[number]; + +// Mirrors the core shape over the widened desktop NavKey union — see the core +// module for why these live in types rather than configNavSections. +export interface ConfigNavItem { + key: NavKey; + label: string; + icon: string; + component: React.ReactNode; + disabled?: boolean; + disabledTooltip?: string; + badge?: string; + badgeColor?: string; +} + +export interface ConfigNavSection { + title: string; + items: ConfigNavItem[]; +} diff --git a/frontend/editor/src/portal-saas/components/settings/accountLinkSettings.tsx b/frontend/editor/src/portal-saas/components/settings/accountLinkSettings.tsx index 6fc0af735b..556cfe1863 100644 --- a/frontend/editor/src/portal-saas/components/settings/accountLinkSettings.tsx +++ b/frontend/editor/src/portal-saas/components/settings/accountLinkSettings.tsx @@ -2,8 +2,8 @@ import type { AccountLinkSettingsSeam } from "@portal-proprietary/components/set /** * SaaS has no account-link concept — the signed-in account IS the SaaS account. - * Null drops the "Account link" nav item and its panel from Settings (the shared - * SettingsModal treats the seam as optional), so the link-only AccountLinkPanel - * is never imported into the SaaS bundle. + * Null drops the "Account link" nav item and its panel from the shared settings + * modal (the portal host treats the seam as optional), so the link-only + * AccountLinkPanel is never imported into the SaaS bundle. */ export const accountLinkSettings: AccountLinkSettingsSeam | null = null; diff --git a/frontend/editor/src/portal/api/settings.ts b/frontend/editor/src/portal/api/settings.ts deleted file mode 100644 index 430ea6e14d..0000000000 --- a/frontend/editor/src/portal/api/settings.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { apiClient } from "@portal/api/http"; -import type { Tier } from "@portal/contexts/TierContext"; - -/* - * The account + workspace settings surface. The shape is tier-aware: the - * workspace plan label, available regions, and data-residency posture differ - * by tier, so the modal reflects what each plan can actually configure. - */ - -export interface RegionOption { - value: string; - label: string; - /** Enterprise-only residency regions are gated below higher tiers. */ - enterpriseOnly?: boolean; -} - -export interface NotificationDefault { - id: string; - enabled: boolean; -} - -/** A device/browser with an active session, shown under Admin → Security. */ -export interface ActiveSession { - id: string; - device: string; - location: string; - lastActive: string; - /** The session viewing this modal — can't be revoked from here. */ - current: boolean; -} - -/** - * Org-wide authentication posture. SSO/SCIM are enterprise capabilities; lower - * tiers see them as locked rows with an upgrade nudge. - */ -export interface SecuritySettings { - mfaEnforced: boolean; - ssoEnabled: boolean; - scimEnabled: boolean; - /** Idle timeout before re-auth, in minutes. */ - sessionTimeoutMins: number; - activeSessions: ActiveSession[]; -} - -/** An opt-in early-access feature flag. */ -export interface BetaFeature { - id: string; - label: string; - description: string; - enabled: boolean; - /** Gated to enterprise — rendered locked below it. */ - enterpriseOnly?: boolean; -} - -/** - * Server snapshot of the account + workspace the modal opens onto. Editable - * fields seed local form state; `planLabel` / `seats` are read-only context. - */ -export interface SettingsSnapshot { - profile: { - name: string; - email: string; - role: string; - /** Avatar image URL, or null to fall back to initials. */ - avatarUrl: string | null; - }; - workspace: { - name: string; - region: string; - planLabel: string; - seats: { used: number; total: number }; - }; - /** Per-category notification toggles, server-default on/off. */ - notifications: NotificationDefault[]; - regions: RegionOption[]; - /** Org-wide authentication + session posture (Admin scope). */ - security: SecuritySettings; - /** Opt-in early-access features (Admin scope). */ - betaFeatures: BetaFeature[]; -} - -/** GET /v1/settings?tier=… — the account + workspace snapshot the modal edits. */ -export async function fetchSettings(tier: Tier): Promise { - return apiClient.local.json( - `/v1/settings?tier=${encodeURIComponent(tier)}`, - ); -} diff --git a/frontend/editor/src/portal/components/PortalChrome.tsx b/frontend/editor/src/portal/components/PortalChrome.tsx index f5b524145d..49172784fc 100644 --- a/frontend/editor/src/portal/components/PortalChrome.tsx +++ b/frontend/editor/src/portal/components/PortalChrome.tsx @@ -6,7 +6,7 @@ import { useUI } from "@portal/contexts/UIContext"; import { AppShell } from "@portal/components/AppShell"; import { AssistantMount } from "@portal/components/AssistantMount"; import { SearchModal } from "@portal/components/SearchModal"; -import { SettingsModal } from "@portal/components/SettingsModal"; +import { PortalSettingsHost } from "@portal/components/PortalSettingsHost"; import { ViewRouter } from "@portal/ViewRouter"; /** @@ -35,18 +35,6 @@ function GlobalShortcuts() { return null; } -/** Bridges the Settings modal's open/close props to UIContext state. */ -function SettingsHost() { - const { settingsOpen, settingsInitialSection, closeSettings } = useUI(); - return ( - - ); -} - /** * The routed view, wrapped in an error boundary so a single view crashing can't * white-screen the portal (the shell + nav stay alive). Keyed by route so @@ -80,7 +68,7 @@ export function PortalChrome() { - + ); } diff --git a/frontend/editor/src/portal/components/PortalSettingsHost.tsx b/frontend/editor/src/portal/components/PortalSettingsHost.tsx new file mode 100644 index 0000000000..d68ad92ba5 --- /dev/null +++ b/frontend/editor/src/portal/components/PortalSettingsHost.tsx @@ -0,0 +1,84 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import AppConfigModalLazy from "@app/components/shared/AppConfigModalLazy"; +import { AppConfigProvider } from "@app/contexts/AppConfigContext"; +import { PreferencesProvider } from "@app/contexts/PreferencesContext"; +import { ThemeProvider } from "@app/components/shared/ThemeProvider"; +import { AuthProvider } from "@app/auth/UseSession"; +import { + VALID_NAV_KEYS, + type ConfigNavSection, + type NavKey, +} from "@app/components/shared/config/types"; +import { accountLinkSettings } from "@portal/components/settings/accountLinkSettings"; +import { useUI } from "@portal/contexts/UIContext"; + +/** + * Mounts the editor's settings modal (the app-wide settings surface) inside the + * portal. The portal deliberately lives outside the editor's AppProviders, so + * this host supplies the contexts the settings tree needs: app config, user + * preferences, the session provider the account sections read (flavor-resolved: + * Spring on self-hosted, Supabase on SaaS — same underlying session the portal + * is already signed in with), and the editor ThemeProvider (which also carries + * the Mantine theme + toasts the sections expect). URL sync is off — the portal + * owns its own route subtree, so the modal keeps its section purely in state. + * + * Everything (providers included) mounts on first open and stays mounted, so + * the editor theme wiring never runs for portal sessions that never open + * settings. + */ +export function PortalSettingsHost() { + const { settingsOpen, settingsInitialSection, closeSettings } = useUI(); + const { t } = useTranslation(); + const [everOpened, setEverOpened] = useState(false); + + useEffect(() => { + if (settingsOpen) setEverOpened(true); + }, [settingsOpen]); + + // Portal-only sections, appended after the build's registry sections. The + // account-link seam is self-hosted-only (the saas overlay shadows it to null). + const extraSections = useMemo(() => { + if (!accountLinkSettings) return []; + const { navKey, labelKey, icon, Body } = accountLinkSettings; + return [ + { + title: t("portal.settings.groups.admin", "Admin"), + items: [ + { + key: navKey, + label: t(labelKey, "Account link"), + icon, + component: , + }, + ], + }, + ]; + }, [t]); + + const initialSection: NavKey | null = + settingsInitialSection && + (VALID_NAV_KEYS as readonly string[]).includes(settingsInitialSection) + ? (settingsInitialSection as NavKey) + : null; + + if (!everOpened) return null; + + return ( + + + + + + + + + + ); +} diff --git a/frontend/editor/src/portal/components/SettingsModal.css b/frontend/editor/src/portal/components/SettingsModal.css deleted file mode 100644 index 59fa1e0bc4..0000000000 --- a/frontend/editor/src/portal/components/SettingsModal.css +++ /dev/null @@ -1,255 +0,0 @@ -/* The settings overlay hosts a full-bleed two-pane SettingsShell, so the - modal frame contributes no padding of its own and lets the shell scroll. */ -.portal-settings .sui-modal__body { - padding: 0; - overflow: hidden; -} - -.portal-settings__section { - display: flex; - flex-direction: column; - gap: 1rem; -} - -/* Footer note pushes the action buttons to the right. */ -.portal-settings__footer-note { - margin-right: auto; - align-self: center; - font-size: 0.75rem; - color: var(--color-text-4); -} - -/* ── Profile identity row ─────────────────────────────────────────────── */ -.portal-settings__identity { - display: flex; - align-items: center; - gap: 0.875rem; - padding: 0.875rem; - background: var(--color-bg-subtle); - border: 1px solid var(--color-border-light); - border-radius: var(--radius-lg); -} - -.portal-settings__identity-meta { - display: flex; - flex-direction: column; - gap: 0.25rem; - min-width: 0; - flex: 1 1 auto; -} - -.portal-settings__identity-name { - display: flex; - align-items: center; - gap: 0.5rem; - font-size: 0.9375rem; - font-weight: 600; - color: var(--color-text-1); -} - -.portal-settings__identity-email { - font-size: 0.8125rem; - color: var(--color-text-4); -} - -/* ── Preference groups ────────────────────────────────────────────────── */ -.portal-settings__group { - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.portal-settings__group + .portal-settings__group { - padding-top: 1rem; - border-top: 1px solid var(--color-border-light); -} - -.portal-settings__group-head { - display: flex; - flex-direction: column; - gap: 0.125rem; -} - -.portal-settings__group-title { - margin: 0; - font-size: 0.875rem; - font-weight: 600; - color: var(--color-text-1); -} - -.portal-settings__group-sub { - margin: 0; - font-size: 0.75rem; - color: var(--color-text-4); -} - -/* ── Theme picker ─────────────────────────────────────────────────────── */ -.portal-settings__theme { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 0.625rem; -} - -.portal-settings__theme-card { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.75rem; - text-align: left; - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - transition: - border-color var(--motion-fast), - background var(--motion-fast), - box-shadow var(--motion-fast); -} - -.portal-settings__theme-card:hover { - border-color: var(--color-border-strong, var(--color-border)); - background: var(--color-bg-hover); -} - -.portal-settings__theme-card.is-active { - border-color: var(--color-blue); - box-shadow: 0 0 0 1px var(--color-blue); -} - -.portal-settings__theme-swatch { - display: inline-flex; - flex-direction: column; - gap: 2px; - width: 2.25rem; - height: 2.25rem; - padding: 4px; - border-radius: var(--radius-sm); - border: 1px solid var(--color-border); - flex: 0 0 auto; -} - -.portal-settings__theme-swatch span { - border-radius: 2px; -} - -.portal-settings__theme-swatch span:first-child { - flex: 0 0 35%; -} - -.portal-settings__theme-swatch span:last-child { - flex: 1 1 auto; -} - -.portal-settings__theme-swatch--light { - background: #ffffff; -} -.portal-settings__theme-swatch--light span:first-child { - background: #cbd5e1; -} -.portal-settings__theme-swatch--light span:last-child { - background: #eef2f7; -} - -.portal-settings__theme-swatch--dark { - background: #0f172a; -} -.portal-settings__theme-swatch--dark span:first-child { - background: #475569; -} -.portal-settings__theme-swatch--dark span:last-child { - background: #1e293b; -} - -.portal-settings__theme-text { - display: flex; - flex-direction: column; - min-width: 0; -} - -.portal-settings__theme-text strong { - font-size: 0.8125rem; - font-weight: 600; - color: var(--color-text-1); -} - -.portal-settings__theme-text span { - font-size: 0.75rem; - color: var(--color-text-4); -} - -/* ── Notification rows ────────────────────────────────────────────────── */ -.portal-settings__notifs { - display: flex; - flex-direction: column; -} - -.portal-settings__notif-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - padding: 0.75rem 0; -} - -.portal-settings__notif-row + .portal-settings__notif-row { - border-top: 1px solid var(--color-border-light); -} - -.portal-settings__notif-text { - display: flex; - flex-direction: column; - gap: 0.125rem; - min-width: 0; -} - -.portal-settings__notif-text strong { - font-size: 0.8125rem; - font-weight: 500; - color: var(--color-text-1); -} - -.portal-settings__notif-text span { - font-size: 0.75rem; - color: var(--color-text-4); -} - -/* Label paired with a gating badge (e.g. "SCIM provisioning" + Enterprise). */ -.portal-settings__row-label { - display: inline-flex; - align-items: center; - gap: 0.4rem; -} - -/* ── Workspace plan card ──────────────────────────────────────────────── */ -.portal-settings__plan { - display: flex; - flex-direction: column; - gap: 0.625rem; - padding: 0.875rem; - background: var(--color-bg-subtle); - border: 1px solid var(--color-border-light); - border-radius: var(--radius-lg); -} - -.portal-settings__plan-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.75rem; -} - -.portal-settings__plan-label { - font-size: 0.8125rem; - color: var(--color-text-3); -} - -.portal-settings__plan-value { - font-size: 0.8125rem; - font-weight: 500; - color: var(--color-text-1); -} - -@media (max-width: 40rem) { - .portal-settings__theme { - grid-template-columns: 1fr; - } -} diff --git a/frontend/editor/src/portal/components/SettingsModal.tsx b/frontend/editor/src/portal/components/SettingsModal.tsx deleted file mode 100644 index eb2380a9a9..0000000000 --- a/frontend/editor/src/portal/components/SettingsModal.tsx +++ /dev/null @@ -1,902 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { - Avatar, - Button, - FormField, - Input, - Modal, - Select, - SettingsShell, - Skeleton, - StatusBadge, - ToggleSwitch, - type SelectOption, - type SettingsNavSection, -} from "@app/ui"; -import { useTier, type Tier } from "@portal/contexts/TierContext"; -import { useTheme, type Theme } from "@portal/contexts/ThemeContext"; -import { useAsync } from "@portal/hooks/useAsync"; -import { - fetchSettings, - type ActiveSession, - type BetaFeature, - type SettingsSnapshot, -} from "@portal/api/settings"; -import { - UsersIcon, - SunIcon, - BellIcon, - SettingsIcon, - PoliciesIcon, - InfrastructureIcon, - SparklesIcon, -} from "@portal/components/icons"; -import { accountLinkSettings } from "@portal/components/settings/accountLinkSettings"; -import "@portal/components/SettingsModal.css"; - -type SettingsSection = - | "profile" - | "appearance" - | "notifications" - | "general" - | "authentication" - | "sessions" - | "early-access" - | "account-link"; - -function isSettingsSection(value: string | null): value is SettingsSection { - return ( - value === "profile" || - value === "appearance" || - value === "notifications" || - value === "general" || - value === "authentication" || - value === "sessions" || - value === "early-access" || - value === "account-link" - ); -} - -/** Org-wide auth posture the Admin sections edit, mirrored into local state. */ -interface SecurityForm { - mfaEnforced: boolean; - ssoEnabled: boolean; - scimEnabled: boolean; - sessionTimeoutMins: number; -} - -interface SettingsModalProps { - open: boolean; - onClose: () => void; - /** - * Optional section to land on when opening. When `null`/unsupported the modal - * picks the default ("profile"). Set by callers like the sidebar's "Link - * account" affordance → "account-link". - */ - initialSection?: string | null; -} - -/** - * Notification categories with known display copy, in the order the snapshot - * exposes them. Labels and descriptions are resolved via i18n at render time, - * keyed by id; ids absent from this list are skipped. - */ -const NOTIFICATION_IDS = [ - "pipeline-failures", - "pipeline-success", - "usage-alerts", - "weekly-digest", - "security-alerts", - "product-updates", -] as const; - -const THEME_OPTIONS: { value: Theme }[] = [ - { value: "light" }, - { value: "dark" }, -]; - -const SESSION_TIMEOUT_VALUES = ["60", "240", "480", "720", "1440"] as const; - -/** - * Account settings as a portal-wide overlay. A grouped left-nav (Account / - * Workspace / Admin) over a tier-aware snapshot that seeds editable local form - * state. Save is a no-op for the demo — it closes — but the theme control - * writes straight through to ThemeProvider so the change is real and visible. - */ -export function SettingsModal({ - open, - onClose, - initialSection, -}: SettingsModalProps) { - const { t } = useTranslation(); - const { tier } = useTier(); - const { theme, setTheme } = useTheme(); - const [section, setSection] = useState("profile"); - - const navSections = useMemo( - () => [ - { - title: t("portal.settings.groups.account"), - items: [ - { - key: "profile", - label: t("portal.settings.sections.profile"), - icon: , - }, - { - key: "appearance", - label: t("portal.settings.sections.appearance"), - icon: , - }, - { - key: "notifications", - label: t("portal.settings.sections.notifications"), - icon: , - }, - ], - }, - { - title: t("portal.settings.groups.workspace"), - items: [ - { - key: "general", - label: t("portal.settings.sections.general"), - icon: , - }, - ], - }, - { - title: t("portal.settings.groups.admin"), - items: [ - // Account-link is a self-hosted-only section; the SaaS build shadows - // the seam to null, dropping the item entirely. - ...(accountLinkSettings - ? [ - { - key: accountLinkSettings.navKey, - label: t(accountLinkSettings.labelKey), - icon: accountLinkSettings.icon, - }, - ] - : []), - { - key: "authentication", - label: t("portal.settings.sections.authentication"), - icon: , - }, - { - key: "sessions", - label: t("portal.settings.sections.sessions"), - icon: , - }, - { - key: "early-access", - label: t("portal.settings.sections.early-access"), - icon: , - }, - ], - }, - ], - [t], - ); - - const { data: snapshot, loading } = useAsync( - () => fetchSettings(tier), - [tier], - ); - - // Editable copies seeded from the snapshot. Re-seed whenever a fresh snapshot - // arrives (tier switch) or the modal is re-opened, so edits never leak across - // sessions or stack on stale values. - const [name, setName] = useState(""); - const [email, setEmail] = useState(""); - const [workspaceName, setWorkspaceName] = useState(""); - const [region, setRegion] = useState(""); - const [notifications, setNotifications] = useState>( - {}, - ); - const [security, setSecurity] = useState({ - mfaEnforced: false, - ssoEnabled: false, - scimEnabled: false, - sessionTimeoutMins: 480, - }); - const [betaToggles, setBetaToggles] = useState>({}); - - useEffect(() => { - if (!snapshot) return; - setName(snapshot.profile.name); - setEmail(snapshot.profile.email); - setWorkspaceName(snapshot.workspace.name); - setRegion(snapshot.workspace.region); - setNotifications( - Object.fromEntries(snapshot.notifications.map((n) => [n.id, n.enabled])), - ); - setSecurity({ - mfaEnforced: snapshot.security.mfaEnforced, - ssoEnabled: snapshot.security.ssoEnabled, - scimEnabled: snapshot.security.scimEnabled, - sessionTimeoutMins: snapshot.security.sessionTimeoutMins, - }); - setBetaToggles( - Object.fromEntries(snapshot.betaFeatures.map((f) => [f.id, f.enabled])), - ); - }, [snapshot]); - - useEffect(() => { - if (!open) return; - const requested = initialSection ?? null; - setSection(isSettingsSection(requested) ? requested : "profile"); - }, [open, initialSection]); - - const regionOptions = useMemo(() => { - if (!snapshot) return []; - return snapshot.regions.map((r) => ({ - value: r.value, - label: - r.enterpriseOnly && tier !== "enterprise" - ? t("portal.settings.workspace.regionEnterpriseSuffix", { - region: r.label, - }) - : r.label, - disabled: r.enterpriseOnly && tier !== "enterprise", - })); - }, [snapshot, tier, t]); - - const isLoading = loading && !snapshot; - - return ( - - setSection(k as SettingsSection)} - title={t(`portal.settings.sections.${section}`)} - onClose={onClose} - footer={ - <> - - {t("portal.settings.footerNote")} - - - - - } - > - {section === "profile" && ( - - )} - - {section === "appearance" && ( - - )} - - {section === "notifications" && ( - n.id) ?? []} - onToggle={(id, value) => - setNotifications((prev) => ({ ...prev, [id]: value })) - } - /> - )} - - {section === "general" && ( - - )} - - {section === "authentication" && ( - setSecurity((s) => ({ ...s, ...patch }))} - /> - )} - - {section === "sessions" && ( - - )} - - {section === "early-access" && ( - - setBetaToggles((prev) => ({ ...prev, [id]: value })) - } - /> - )} - - {section === "account-link" && accountLinkSettings && ( - - )} - - - ); -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Profile */ -/* ──────────────────────────────────────────────────────────────────────── */ - -function ProfilePanel({ - loading, - name, - email, - role, - avatarUrl, - onName, - onEmail, -}: { - loading: boolean; - name: string; - email: string; - role?: string; - avatarUrl?: string; - onName: (v: string) => void; - onEmail: (v: string) => void; -}) { - const { t } = useTranslation(); - if (loading) { - return ( -
-
- -
- - -
-
- - -
- ); - } - - return ( -
-
- -
-
- {name || t("portal.settings.profile.accountFallback")} - {role && ( - - {role} - - )} -
- {email} -
- -
- - - onName(e.target.value)} - placeholder={t("portal.settings.profile.namePlaceholder")} - /> - - - - onEmail(e.target.value)} - placeholder={t("portal.settings.profile.emailPlaceholder")} - /> - -
- ); -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Appearance */ -/* ──────────────────────────────────────────────────────────────────────── */ - -function AppearancePanel({ - theme, - onTheme, -}: { - theme: Theme; - onTheme: (theme: Theme) => void; -}) { - const { t } = useTranslation(); - return ( -
-
-
-

- {t("portal.settings.appearance.themeTitle")} -

-

- {t("portal.settings.appearance.themeSub")} -

-
-
- {THEME_OPTIONS.map((opt) => ( - - ))} -
-
-
- ); -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Notifications */ -/* ──────────────────────────────────────────────────────────────────────── */ - -function NotificationsPanel({ - loading, - notifications, - order, - onToggle, -}: { - loading: boolean; - notifications: Record; - order: string[]; - onToggle: (id: string, value: boolean) => void; -}) { - const { t } = useTranslation(); - return ( -
-
-
-

- {t("portal.settings.notifications.title")} -

-

- {t("portal.settings.notifications.sub")} -

-
- - {loading && ( -
- {Array.from({ length: 4 }).map((_, i) => ( -
-
- - -
- -
- ))} -
- )} - - {!loading && ( -
- {order.map((id) => { - if (!(NOTIFICATION_IDS as readonly string[]).includes(id)) { - return null; - } - return ( -
-
- - {t(`portal.settings.notifications.${id}.label`)} - - - {t(`portal.settings.notifications.${id}.description`)} - -
- onToggle(id, v)} - /> -
- ); - })} -
- )} -
-
- ); -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Workspace */ -/* ──────────────────────────────────────────────────────────────────────── */ - -function WorkspacePanel({ - loading, - workspaceName, - onWorkspaceName, - region, - onRegion, - regionOptions, - planLabel, - seats, -}: { - loading: boolean; - workspaceName: string; - onWorkspaceName: (v: string) => void; - region: string; - onRegion: (v: string) => void; - regionOptions: SelectOption[]; - planLabel?: string; - seats?: { used: number; total: number }; -}) { - const { t } = useTranslation(); - if (loading) { - return ( -
- - - -
- ); - } - - return ( -
- - onWorkspaceName(e.target.value)} - placeholder={t("portal.settings.workspace.namePlaceholder")} - /> - - - - - onSecurity({ sessionTimeoutMins: Number(value ?? "0") }) - } - options={SESSION_TIMEOUT_VALUES.map((value) => ({ - value, - label: t(`portal.settings.authentication.timeout.${value}`), - }))} - /> - -
- - ); -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Admin · Active sessions */ -/* ──────────────────────────────────────────────────────────────────────── */ - -function SessionsPanel({ - loading, - sessions, -}: { - loading: boolean; - sessions: ActiveSession[]; -}) { - const { t } = useTranslation(); - if (loading) { - return ( -
- - -
- ); - } - - return ( -
-
-
-

- {t("portal.settings.sessions.title")} -

-

- {t("portal.settings.sessions.sub")} -

-
-
- {sessions.map((s) => ( -
-
- {s.device} - - {s.location} · {s.lastActive} - -
- {s.current ? ( - - {t("portal.settings.sessions.thisDevice")} - - ) : ( - // TODO(backend): DELETE /v1/settings/sessions/{id} - - )} -
- ))} -
-
-
- ); -} - -/* ──────────────────────────────────────────────────────────────────────── */ -/* Admin · Early access */ -/* ──────────────────────────────────────────────────────────────────────── */ - -function EarlyAccessPanel({ - loading, - tier, - betaFeatures, - betaToggles, - onBeta, -}: { - loading: boolean; - tier: Tier; - betaFeatures: BetaFeature[]; - betaToggles: Record; - onBeta: (id: string, value: boolean) => void; -}) { - const { t } = useTranslation(); - if (loading) { - return ( -
- - -
- ); - } - - const isEnterprise = tier === "enterprise"; - - return ( -
-
-
-

- {t("portal.settings.earlyAccess.title")} -

-

- {t("portal.settings.earlyAccess.sub")} -

-
-
- {betaFeatures.map((f) => { - const locked = Boolean(f.enterpriseOnly) && !isEnterprise; - return ( -
-
- - {f.label} - {locked && ( - - {t("portal.settings.enterpriseBadge")} - - )} - - {f.description} -
- onBeta(f.id, v)} - /> -
- ); - })} -
-
-
- ); -} diff --git a/frontend/editor/src/portal/components/settings/accountLinkSettings.tsx b/frontend/editor/src/portal/components/settings/accountLinkSettings.tsx index 8e769672d7..22c03ec682 100644 --- a/frontend/editor/src/portal/components/settings/accountLinkSettings.tsx +++ b/frontend/editor/src/portal/components/settings/accountLinkSettings.tsx @@ -1,26 +1,26 @@ -import type { ComponentType, ReactNode } from "react"; -import { LinkIcon } from "@portal/components/icons"; +import type { ComponentType } from "react"; import { AccountLinkPanel } from "@portal/components/account-link/AccountLinkPanel"; export interface AccountLinkSettingsSeam { - /** Section key in the Settings nav + body switch. */ - navKey: string; + /** Nav key in the shared settings modal (registered in config/types.ts). */ + navKey: "account-link"; /** i18n key for the nav label; resolved with `t()` at the call site. */ labelKey: string; - icon: ReactNode; + /** LocalIcon name for the nav item. */ + icon: string; /** The section body — the account-link panel. */ Body: ComponentType; } /** - * The admin "Account link" section of Settings (self-hosted only). The SaaS - * build shadows this file with `null`: the signed-in account IS the SaaS - * account, so there is no instance to link — the nav item and its panel both - * drop out, and nothing imports the link-only AccountLinkPanel. + * The admin "Account link" section of the shared settings modal (self-hosted + * only). The SaaS build shadows this file with `null`: the signed-in account IS + * the SaaS account, so there is no instance to link — the nav item and its + * panel both drop out, and nothing imports the link-only AccountLinkPanel. */ export const accountLinkSettings: AccountLinkSettingsSeam | null = { navKey: "account-link", labelKey: "portal.settings.sections.account-link", - icon: , + icon: "link-rounded", Body: AccountLinkPanel, }; diff --git a/frontend/editor/src/portal/mocks/handlers/index.ts b/frontend/editor/src/portal/mocks/handlers/index.ts index 8d2139a0b8..64e885d15f 100644 --- a/frontend/editor/src/portal/mocks/handlers/index.ts +++ b/frontend/editor/src/portal/mocks/handlers/index.ts @@ -8,7 +8,6 @@ import { infrastructureHandlers } from "@portal/mocks/handlers/infrastructure"; import { procurementHandlers } from "@portal/mocks/handlers/procurement"; import { procurementSaasHandlers } from "@portal/mocks/handlers/procurementSaas"; import { docsHandlers } from "@portal/mocks/handlers/docs"; -import { settingsHandlers } from "@portal/mocks/handlers/settings"; import { usersHandlers } from "@portal/mocks/handlers/users"; import { agentsHandlers } from "@portal/mocks/handlers/agents"; import { policiesHandlers } from "@portal/mocks/handlers/policies"; @@ -28,7 +27,6 @@ export const handlers = [ ...docsHandlers, ...procurementHandlers, ...procurementSaasHandlers, - ...settingsHandlers, ...usersHandlers, ...agentsHandlers, ...policiesHandlers, diff --git a/frontend/editor/src/portal/mocks/handlers/settings.ts b/frontend/editor/src/portal/mocks/handlers/settings.ts deleted file mode 100644 index 8fdd4c02fa..0000000000 --- a/frontend/editor/src/portal/mocks/handlers/settings.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { http, HttpResponse, delay } from "msw"; -import type { Tier } from "@portal/contexts/TierContext"; -import { buildSettingsSnapshot } from "@portal/mocks/settings"; - -export const settingsHandlers = [ - http.get("/v1/settings", async ({ request }) => { - await delay(120); - const url = new URL(request.url); - const tier = (url.searchParams.get("tier") ?? "pro") as Tier; - return HttpResponse.json(buildSettingsSnapshot(tier)); - }), -]; diff --git a/frontend/editor/src/portal/mocks/settings.ts b/frontend/editor/src/portal/mocks/settings.ts deleted file mode 100644 index 59cceb17d8..0000000000 --- a/frontend/editor/src/portal/mocks/settings.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * Account-settings fixtures. Types live in api/settings.ts (the backend - * contract); this module only builds fake data for Storybook and tests. - * - * The shape is tier-aware: the workspace plan label, available regions, and - * data-residency posture differ by tier, so the modal reflects what each plan - * can actually configure. - */ - -import type { - ActiveSession, - BetaFeature, - NotificationDefault, - RegionOption, - SecuritySettings, - SettingsSnapshot, -} from "@portal/api/settings"; -import type { Tier } from "@portal/contexts/TierContext"; - -const REGIONS: RegionOption[] = [ - { value: "us-east-1", label: "US East (N. Virginia)" }, - { value: "us-west-2", label: "US West (Oregon)" }, - { value: "eu-west-1", label: "EU West (Ireland)" }, - { value: "eu-central-1", label: "EU Central (Frankfurt)" }, - { - value: "ap-southeast-2", - label: "Asia Pacific (Sydney)", - enterpriseOnly: true, - }, - { value: "ca-central-1", label: "Canada (Central)", enterpriseOnly: true }, -]; - -const PLAN_LABEL: Record = { - free: "Editor plan", - pro: "Processor plan", - enterprise: "Enterprise plan", -}; - -const SEATS: Record = { - free: { used: 1, total: 1 }, - pro: { used: 4, total: 5 }, - enterprise: { used: 38, total: 50 }, -}; - -const WORKSPACE_NAME: Record = { - free: "My Workspace", - pro: "Acme Document Ops", - enterprise: "Acme Corp — Global", -}; - -/** Notification categories shown in Preferences, with sensible per-tier defaults. */ -function notificationsFor(tier: Tier): NotificationDefault[] { - return [ - { id: "pipeline-failures", enabled: true }, - { id: "pipeline-success", enabled: tier !== "free" }, - { id: "usage-alerts", enabled: true }, - { id: "weekly-digest", enabled: tier === "free" }, - { id: "security-alerts", enabled: true }, - { id: "product-updates", enabled: false }, - ]; -} - -/** Session timeout shortens as the plan's security posture tightens. */ -const SESSION_TIMEOUT_MINS: Record = { - free: 1440, - pro: 720, - enterprise: 480, -}; - -function securityFor(tier: Tier): SecuritySettings { - const base: ActiveSession[] = [ - { - id: "sess-current", - device: "Chrome · macOS", - location: "London, UK", - lastActive: "Active now", - current: true, - }, - ]; - if (tier !== "free") { - base.push({ - id: "sess-cli", - device: "Stirling CLI · CI runner", - location: "eu-west-1", - lastActive: "12 min ago", - current: false, - }); - } - if (tier === "enterprise") { - base.push({ - id: "sess-mobile", - device: "Safari · iPhone", - location: "London, UK", - lastActive: "3 h ago", - current: false, - }); - } - return { - // Enterprise tenants enforce MFA + SSO/SCIM org-wide by default. - mfaEnforced: tier === "enterprise", - ssoEnabled: tier === "enterprise", - scimEnabled: tier === "enterprise", - sessionTimeoutMins: SESSION_TIMEOUT_MINS[tier], - activeSessions: base, - }; -} - -function betaFeaturesFor(tier: Tier): BetaFeature[] { - return [ - { - id: "pipeline-canary", - label: "Pipeline canary rollouts", - description: "Shadow-run a new pipeline version before promoting it.", - enabled: false, - }, - { - id: "component-sandboxes", - label: "Live component sandboxes", - description: "Interactive previews for embeddable components.", - enabled: tier !== "free", - }, - { - id: "agent-evals-v2", - label: "Agent evals v2", - description: "Richer golden-set scoring with regression diffs.", - enabled: tier === "enterprise", - enterpriseOnly: true, - }, - ]; -} - -export function buildSettingsSnapshot(tier: Tier): SettingsSnapshot { - return { - profile: { - name: "Reece Browne", - email: "reece@stirlingpdf.com", - role: tier === "enterprise" ? "Org Admin" : "Owner", - avatarUrl: null, - }, - workspace: { - name: WORKSPACE_NAME[tier], - region: tier === "free" ? "us-east-1" : "eu-west-1", - planLabel: PLAN_LABEL[tier], - seats: SEATS[tier], - }, - notifications: notificationsFor(tier), - regions: REGIONS, - security: securityFor(tier), - betaFeatures: betaFeaturesFor(tier), - }; -} diff --git a/frontend/editor/src/saas/components/shared/AppConfigModal.tsx b/frontend/editor/src/saas/components/shared/AppConfigModal.tsx index b691244e68..7d36b40ee4 100644 --- a/frontend/editor/src/saas/components/shared/AppConfigModal.tsx +++ b/frontend/editor/src/saas/components/shared/AppConfigModal.tsx @@ -9,7 +9,10 @@ import { useTranslation } from "react-i18next"; import LocalIcon from "@app/components/shared/LocalIcon"; import Overview from "@app/components/shared/config/configSections/Overview"; import { createSaasConfigNavSections } from "@app/components/shared/config/saasConfigNavSections"; -import { NavKey } from "@app/components/shared/config/types"; +import { + NavKey, + type ConfigNavSection, +} from "@app/components/shared/config/types"; import { stripBasePath, withBasePath } from "@app/constants/app"; import { COOKIE_CONSENT_SCROLL_SHARD } from "@app/hooks/useCookieConsent"; import "@app/components/shared/AppConfigModal.css"; @@ -21,9 +24,21 @@ import { interface AppConfigModalProps { opened: boolean; onClose: () => void; + /** Accepted for interface parity with the core shell; this shell never + * URL-syncs, so it has no effect. */ + urlSync?: boolean; + /** Section to land on when opening (used by non-URL hosts like the portal). */ + initialSection?: NavKey | null; + /** Host-specific sections appended after the saas registry sections. */ + extraSections?: ConfigNavSection[]; } -const AppConfigModal: React.FC = ({ opened, onClose }) => { +const AppConfigModal: React.FC = ({ + opened, + onClose, + initialSection, + extraSections, +}) => { const isMobile = useMediaQuery("(max-width: 1024px)"); const { signOut, user } = useAuth(); @@ -53,16 +68,21 @@ const AppConfigModal: React.FC = ({ opened, onClose }) => { // usage-limit modal CTAs, which need to land on the Plan section), select that section. The // opener (QuickAccessBar) opens the modal whenever the path is /settings/*, but doesn't carry // the section, and `active` defaults to "overview" — so without this a deep link would open on - // Overview rather than the linked section. + // Overview rather than the linked section. Non-URL hosts (the portal) pass the + // section directly instead. useEffect(() => { if (!opened) return; + if (initialSection) { + setActive(initialSection); + return; + } const match = stripBasePath(window.location.pathname).match( /^\/settings\/([^/?#]+)/, ); if (match) { setActive(match[1] as NavKey); } - }, [opened]); + }, [opened, initialSection]); // Listen for notice updates (e.g., "Not enough credits..." next to Plan title) useEffect(() => { @@ -116,15 +136,14 @@ const AppConfigModal: React.FC = ({ opened, onClose }) => { // Left navigation structure and icons. The Plan tab now internally branches // free vs subscribed × leader vs member via useWallet(), so the modal no // longer plumbs paygEnabled / isLeader through to the nav builder. - const configNavSections = useMemo( - () => - createSaasConfigNavSections(Overview, openLogoutConfirm, { - isDev, - isAnonymous, - t, - }), - [openLogoutConfirm, isDev, isAnonymous, t], - ); + const configNavSections = useMemo(() => { + const sections = createSaasConfigNavSections(Overview, openLogoutConfirm, { + isDev, + isAnonymous, + t, + }); + return extraSections?.length ? [...sections, ...extraSections] : sections; + }, [openLogoutConfirm, isDev, isAnonymous, t, extraSections]); const activeLabel = useMemo(() => { for (const section of configNavSections) { diff --git a/frontend/editor/src/saas/components/shared/config/types.ts b/frontend/editor/src/saas/components/shared/config/types.ts index f80bd1ac19..ebaff637d5 100644 --- a/frontend/editor/src/saas/components/shared/config/types.ts +++ b/frontend/editor/src/saas/components/shared/config/types.ts @@ -1,3 +1,4 @@ +import type React from "react"; import { VALID_NAV_KEYS as CORE_NAV_KEYS } from "@core/components/shared/config/types"; // SaaS adds an "overview" account section and an "mcp" integrations tab. All @@ -7,3 +8,21 @@ import { VALID_NAV_KEYS as CORE_NAV_KEYS } from "@core/components/shared/config/ export const VALID_NAV_KEYS = [...CORE_NAV_KEYS, "overview", "mcp"] as const; export type NavKey = (typeof VALID_NAV_KEYS)[number]; + +// Mirrors the core shape over the widened saas NavKey union — see the core +// module for why these live in types rather than configNavSections. +export interface ConfigNavItem { + key: NavKey; + label: string; + icon: string; + component: React.ReactNode; + disabled?: boolean; + disabledTooltip?: string; + badge?: string; + badgeColor?: string; +} + +export interface ConfigNavSection { + title: string; + items: ConfigNavItem[]; +}