port editor settings into portal (#6945)

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.
This commit is contained in:
Reece Browne
2026-07-09 16:35:30 +00:00
committed by GitHub
parent a5ee329c36
commit ccfd22b2a9
19 changed files with 279 additions and 1585 deletions
@@ -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"
+7 -1
View File
@@ -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"
}
}
@@ -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/<key> 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<AppConfigModalProps> = ({
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<NavKey>(
() => 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<AppConfigModalProps> = ({
// 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<AppConfigModalProps> = ({
// 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<AppConfigModalProps> = ({
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<AppConfigModalProps> = ({
navigate(`/settings/${key}`);
}
},
[navigate],
[navigate, urlSync],
);
// Backwards-compat: external `appConfig:navigate` events route through the
@@ -156,7 +187,7 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
// 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<AppConfigModalProps> = ({
}
}
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<AppConfigModalProps> = ({
}, [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) {
@@ -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 (
<Suspense fallback={null}>
{shouldMount && <AppConfigModal opened={opened} onClose={onClose} />}
{shouldMount && (
<AppConfigModal
opened={opened}
onClose={onClose}
urlSync={urlSync}
initialSection={initialSection}
extraSections={extraSections}
/>
)}
</Suspense>
);
}
@@ -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;
@@ -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[];
}
@@ -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[];
}
@@ -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;
@@ -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<SettingsSnapshot> {
return apiClient.local.json<SettingsSnapshot>(
`/v1/settings?tier=${encodeURIComponent(tier)}`,
);
}
@@ -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 (
<SettingsModal
open={settingsOpen}
onClose={closeSettings}
initialSection={settingsInitialSection}
/>
);
}
/**
* 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() {
</ToolRegistryProvider>
<AssistantMount />
<SearchModal />
<SettingsHost />
<PortalSettingsHost />
</>
);
}
@@ -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<ConfigNavSection[]>(() => {
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: <Body />,
},
],
},
];
}, [t]);
const initialSection: NavKey | null =
settingsInitialSection &&
(VALID_NAV_KEYS as readonly string[]).includes(settingsInitialSection)
? (settingsInitialSection as NavKey)
: null;
if (!everOpened) return null;
return (
<AppConfigProvider bootstrapMode="non-blocking">
<AuthProvider>
<PreferencesProvider>
<ThemeProvider>
<AppConfigModalLazy
opened={settingsOpen}
onClose={closeSettings}
urlSync={false}
initialSection={initialSection}
extraSections={extraSections}
/>
</ThemeProvider>
</PreferencesProvider>
</AuthProvider>
</AppConfigProvider>
);
}
@@ -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;
}
}
@@ -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<SettingsSection>("profile");
const navSections = useMemo<SettingsNavSection[]>(
() => [
{
title: t("portal.settings.groups.account"),
items: [
{
key: "profile",
label: t("portal.settings.sections.profile"),
icon: <UsersIcon size={16} />,
},
{
key: "appearance",
label: t("portal.settings.sections.appearance"),
icon: <SunIcon size={16} />,
},
{
key: "notifications",
label: t("portal.settings.sections.notifications"),
icon: <BellIcon size={16} />,
},
],
},
{
title: t("portal.settings.groups.workspace"),
items: [
{
key: "general",
label: t("portal.settings.sections.general"),
icon: <SettingsIcon size={16} />,
},
],
},
{
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: <PoliciesIcon size={16} />,
},
{
key: "sessions",
label: t("portal.settings.sections.sessions"),
icon: <InfrastructureIcon size={16} />,
},
{
key: "early-access",
label: t("portal.settings.sections.early-access"),
icon: <SparklesIcon size={16} />,
},
],
},
],
[t],
);
const { data: snapshot, loading } = useAsync<SettingsSnapshot>(
() => 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<Record<string, boolean>>(
{},
);
const [security, setSecurity] = useState<SecurityForm>({
mfaEnforced: false,
ssoEnabled: false,
scimEnabled: false,
sessionTimeoutMins: 480,
});
const [betaToggles, setBetaToggles] = useState<Record<string, boolean>>({});
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<SelectOption[]>(() => {
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 (
<Modal
open={open}
onClose={onClose}
width="xl"
ariaLabel={t("portal.settings.ariaLabel")}
className="portal-settings"
>
<SettingsShell
sections={navSections}
activeKey={section}
onSelect={(k) => setSection(k as SettingsSection)}
title={t(`portal.settings.sections.${section}`)}
onClose={onClose}
footer={
<>
<span className="portal-settings__footer-note">
{t("portal.settings.footerNote")}
</span>
<Button variant="tertiary" onClick={onClose}>
{t("portal.settings.cancel")}
</Button>
<Button variant="primary" accent="premium" onClick={onClose}>
{t("portal.settings.saveChanges")}
</Button>
</>
}
>
{section === "profile" && (
<ProfilePanel
loading={isLoading}
name={name}
email={email}
role={snapshot?.profile.role}
avatarUrl={snapshot?.profile.avatarUrl ?? undefined}
onName={setName}
onEmail={setEmail}
/>
)}
{section === "appearance" && (
<AppearancePanel theme={theme} onTheme={setTheme} />
)}
{section === "notifications" && (
<NotificationsPanel
loading={isLoading}
notifications={notifications}
order={snapshot?.notifications.map((n) => n.id) ?? []}
onToggle={(id, value) =>
setNotifications((prev) => ({ ...prev, [id]: value }))
}
/>
)}
{section === "general" && (
<WorkspacePanel
loading={isLoading}
workspaceName={workspaceName}
onWorkspaceName={setWorkspaceName}
region={region}
onRegion={setRegion}
regionOptions={regionOptions}
planLabel={snapshot?.workspace.planLabel}
seats={snapshot?.workspace.seats}
/>
)}
{section === "authentication" && (
<AuthenticationPanel
loading={isLoading}
tier={tier}
security={security}
onSecurity={(patch) => setSecurity((s) => ({ ...s, ...patch }))}
/>
)}
{section === "sessions" && (
<SessionsPanel
loading={isLoading}
sessions={snapshot?.security.activeSessions ?? []}
/>
)}
{section === "early-access" && (
<EarlyAccessPanel
loading={isLoading}
tier={tier}
betaFeatures={snapshot?.betaFeatures ?? []}
betaToggles={betaToggles}
onBeta={(id, value) =>
setBetaToggles((prev) => ({ ...prev, [id]: value }))
}
/>
)}
{section === "account-link" && accountLinkSettings && (
<accountLinkSettings.Body />
)}
</SettingsShell>
</Modal>
);
}
/* ──────────────────────────────────────────────────────────────────────── */
/* 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 (
<div className="portal-settings__section">
<div className="portal-settings__identity">
<Skeleton width="3.5rem" height="3.5rem" shape="circle" />
<div className="portal-settings__identity-meta">
<Skeleton width="9rem" />
<Skeleton width="12rem" height="0.625rem" />
</div>
</div>
<Skeleton height="3rem" />
<Skeleton height="3rem" />
</div>
);
}
return (
<div className="portal-settings__section">
<div className="portal-settings__identity">
<Avatar
src={avatarUrl}
name={name || t("portal.settings.profile.accountFallback")}
size="lg"
tone="blue"
/>
<div className="portal-settings__identity-meta">
<div className="portal-settings__identity-name">
{name || t("portal.settings.profile.accountFallback")}
{role && (
<StatusBadge tone="info" size="sm" showDot={false}>
{role}
</StatusBadge>
)}
</div>
<span className="portal-settings__identity-email">{email}</span>
</div>
<Button variant="secondary" size="sm" disabled>
{t("portal.settings.profile.changePhoto")}
</Button>
</div>
<FormField label={t("portal.settings.profile.fullName")}>
<Input
value={name}
onChange={(e) => onName(e.target.value)}
placeholder={t("portal.settings.profile.namePlaceholder")}
/>
</FormField>
<FormField
label={t("portal.settings.profile.email")}
helperText={t("portal.settings.profile.emailHelper")}
>
<Input
type="email"
value={email}
onChange={(e) => onEmail(e.target.value)}
placeholder={t("portal.settings.profile.emailPlaceholder")}
/>
</FormField>
</div>
);
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Appearance */
/* ──────────────────────────────────────────────────────────────────────── */
function AppearancePanel({
theme,
onTheme,
}: {
theme: Theme;
onTheme: (theme: Theme) => void;
}) {
const { t } = useTranslation();
return (
<div className="portal-settings__section">
<div className="portal-settings__group">
<div className="portal-settings__group-head">
<h3 className="portal-settings__group-title">
{t("portal.settings.appearance.themeTitle")}
</h3>
<p className="portal-settings__group-sub">
{t("portal.settings.appearance.themeSub")}
</p>
</div>
<div
className="portal-settings__theme"
role="radiogroup"
aria-label={t("portal.settings.appearance.themeTitle")}
>
{THEME_OPTIONS.map((opt) => (
<Button
key={opt.value}
type="button"
variant="quiet"
role="radio"
aria-checked={theme === opt.value}
className={
"portal-settings__theme-card" +
(theme === opt.value ? " is-active" : "")
}
onClick={() => onTheme(opt.value)}
>
<span
className={`portal-settings__theme-swatch portal-settings__theme-swatch--${opt.value}`}
aria-hidden
>
<span />
<span />
</span>
<span className="portal-settings__theme-text">
<strong>
{t(`portal.settings.appearance.${opt.value}.label`)}
</strong>
<span>{t(`portal.settings.appearance.${opt.value}.hint`)}</span>
</span>
</Button>
))}
</div>
</div>
</div>
);
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Notifications */
/* ──────────────────────────────────────────────────────────────────────── */
function NotificationsPanel({
loading,
notifications,
order,
onToggle,
}: {
loading: boolean;
notifications: Record<string, boolean>;
order: string[];
onToggle: (id: string, value: boolean) => void;
}) {
const { t } = useTranslation();
return (
<div className="portal-settings__section">
<div className="portal-settings__group">
<div className="portal-settings__group-head">
<h3 className="portal-settings__group-title">
{t("portal.settings.notifications.title")}
</h3>
<p className="portal-settings__group-sub">
{t("portal.settings.notifications.sub")}
</p>
</div>
{loading && (
<div className="portal-settings__notifs">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="portal-settings__notif-row">
<div className="portal-settings__notif-text">
<Skeleton width="9rem" />
<Skeleton width="14rem" height="0.625rem" />
</div>
<Skeleton width="2.25rem" height="1.25rem" shape="rect" />
</div>
))}
</div>
)}
{!loading && (
<div className="portal-settings__notifs">
{order.map((id) => {
if (!(NOTIFICATION_IDS as readonly string[]).includes(id)) {
return null;
}
return (
<div key={id} className="portal-settings__notif-row">
<div className="portal-settings__notif-text">
<strong>
{t(`portal.settings.notifications.${id}.label`)}
</strong>
<span>
{t(`portal.settings.notifications.${id}.description`)}
</span>
</div>
<ToggleSwitch
checked={notifications[id] ?? false}
onChange={(v) => onToggle(id, v)}
/>
</div>
);
})}
</div>
)}
</div>
</div>
);
}
/* ──────────────────────────────────────────────────────────────────────── */
/* 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 (
<div className="portal-settings__section">
<Skeleton height="3rem" />
<Skeleton height="3rem" />
<Skeleton height="4rem" />
</div>
);
}
return (
<div className="portal-settings__section">
<FormField label={t("portal.settings.workspace.nameLabel")}>
<Input
value={workspaceName}
onChange={(e) => onWorkspaceName(e.target.value)}
placeholder={t("portal.settings.workspace.namePlaceholder")}
/>
</FormField>
<FormField
label={t("portal.settings.workspace.regionLabel")}
helperText={t("portal.settings.workspace.regionHelper")}
>
<Select
value={region}
onChange={(value) => onRegion(value ?? "")}
options={regionOptions}
/>
</FormField>
<div className="portal-settings__plan">
<div className="portal-settings__plan-row">
<span className="portal-settings__plan-label">
{t("portal.settings.workspace.plan")}
</span>
<StatusBadge tone="purple" size="sm">
{planLabel ?? "—"}
</StatusBadge>
</div>
{seats && (
<div className="portal-settings__plan-row">
<span className="portal-settings__plan-label">
{t("portal.settings.workspace.seats")}
</span>
<span className="portal-settings__plan-value">
{t("portal.settings.workspace.seatsUsed", {
used: seats.used,
total: seats.total,
})}
</span>
</div>
)}
<Button variant="secondary" size="sm" disabled>
{t("portal.settings.workspace.manageBilling")}
</Button>
</div>
</div>
);
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Admin · Authentication */
/* ──────────────────────────────────────────────────────────────────────── */
function AuthenticationPanel({
loading,
tier,
security,
onSecurity,
}: {
loading: boolean;
tier: Tier;
security: SecurityForm;
onSecurity: (patch: Partial<SecurityForm>) => void;
}) {
const { t } = useTranslation();
if (loading) {
return (
<div className="portal-settings__section">
<Skeleton height="3rem" />
<Skeleton height="3rem" />
<Skeleton height="3rem" />
</div>
);
}
// SSO/SCIM are enterprise capabilities; below it they render locked with a
// badge rather than disappearing, so the upgrade path stays visible.
const isEnterprise = tier === "enterprise";
return (
<div className="portal-settings__section">
<div className="portal-settings__group">
<div className="portal-settings__group-head">
<h3 className="portal-settings__group-title">
{t("portal.settings.authentication.title")}
</h3>
<p className="portal-settings__group-sub">
{t("portal.settings.authentication.sub")}
</p>
</div>
<div className="portal-settings__notifs">
<div className="portal-settings__notif-row">
<div className="portal-settings__notif-text">
<strong>{t("portal.settings.authentication.mfa.label")}</strong>
<span>{t("portal.settings.authentication.mfa.description")}</span>
</div>
<ToggleSwitch
checked={security.mfaEnforced}
onChange={(v) => onSecurity({ mfaEnforced: v })}
/>
</div>
<div className="portal-settings__notif-row">
<div className="portal-settings__notif-text">
<span className="portal-settings__row-label">
<strong>{t("portal.settings.authentication.sso.label")}</strong>
{!isEnterprise && (
<StatusBadge tone="info" size="sm" showDot={false}>
{t("portal.settings.enterpriseBadge")}
</StatusBadge>
)}
</span>
<span>{t("portal.settings.authentication.sso.description")}</span>
</div>
<ToggleSwitch
checked={isEnterprise && security.ssoEnabled}
disabled={!isEnterprise}
onChange={(v) => onSecurity({ ssoEnabled: v })}
/>
</div>
<div className="portal-settings__notif-row">
<div className="portal-settings__notif-text">
<span className="portal-settings__row-label">
<strong>
{t("portal.settings.authentication.scim.label")}
</strong>
{!isEnterprise && (
<StatusBadge tone="info" size="sm" showDot={false}>
{t("portal.settings.enterpriseBadge")}
</StatusBadge>
)}
</span>
<span>
{t("portal.settings.authentication.scim.description")}
</span>
</div>
<ToggleSwitch
checked={isEnterprise && security.scimEnabled}
disabled={!isEnterprise}
onChange={(v) => onSecurity({ scimEnabled: v })}
/>
</div>
</div>
<FormField
label={t("portal.settings.authentication.sessionTimeout")}
helperText={t("portal.settings.authentication.sessionTimeoutHelper")}
>
<Select
value={String(security.sessionTimeoutMins)}
onChange={(value) =>
onSecurity({ sessionTimeoutMins: Number(value ?? "0") })
}
options={SESSION_TIMEOUT_VALUES.map((value) => ({
value,
label: t(`portal.settings.authentication.timeout.${value}`),
}))}
/>
</FormField>
</div>
</div>
);
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Admin · Active sessions */
/* ──────────────────────────────────────────────────────────────────────── */
function SessionsPanel({
loading,
sessions,
}: {
loading: boolean;
sessions: ActiveSession[];
}) {
const { t } = useTranslation();
if (loading) {
return (
<div className="portal-settings__section">
<Skeleton height="3rem" />
<Skeleton height="3rem" />
</div>
);
}
return (
<div className="portal-settings__section">
<div className="portal-settings__group">
<div className="portal-settings__group-head">
<h3 className="portal-settings__group-title">
{t("portal.settings.sessions.title")}
</h3>
<p className="portal-settings__group-sub">
{t("portal.settings.sessions.sub")}
</p>
</div>
<div className="portal-settings__notifs">
{sessions.map((s) => (
<div key={s.id} className="portal-settings__notif-row">
<div className="portal-settings__notif-text">
<strong>{s.device}</strong>
<span>
{s.location} · {s.lastActive}
</span>
</div>
{s.current ? (
<StatusBadge tone="success" size="sm">
{t("portal.settings.sessions.thisDevice")}
</StatusBadge>
) : (
// TODO(backend): DELETE /v1/settings/sessions/{id}
<Button variant="tertiary" size="sm">
{t("portal.settings.sessions.revoke")}
</Button>
)}
</div>
))}
</div>
</div>
</div>
);
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Admin · Early access */
/* ──────────────────────────────────────────────────────────────────────── */
function EarlyAccessPanel({
loading,
tier,
betaFeatures,
betaToggles,
onBeta,
}: {
loading: boolean;
tier: Tier;
betaFeatures: BetaFeature[];
betaToggles: Record<string, boolean>;
onBeta: (id: string, value: boolean) => void;
}) {
const { t } = useTranslation();
if (loading) {
return (
<div className="portal-settings__section">
<Skeleton height="3rem" />
<Skeleton height="3rem" />
</div>
);
}
const isEnterprise = tier === "enterprise";
return (
<div className="portal-settings__section">
<div className="portal-settings__group">
<div className="portal-settings__group-head">
<h3 className="portal-settings__group-title">
{t("portal.settings.earlyAccess.title")}
</h3>
<p className="portal-settings__group-sub">
{t("portal.settings.earlyAccess.sub")}
</p>
</div>
<div className="portal-settings__notifs">
{betaFeatures.map((f) => {
const locked = Boolean(f.enterpriseOnly) && !isEnterprise;
return (
<div key={f.id} className="portal-settings__notif-row">
<div className="portal-settings__notif-text">
<span className="portal-settings__row-label">
<strong>{f.label}</strong>
{locked && (
<StatusBadge tone="info" size="sm" showDot={false}>
{t("portal.settings.enterpriseBadge")}
</StatusBadge>
)}
</span>
<span>{f.description}</span>
</div>
<ToggleSwitch
checked={!locked && (betaToggles[f.id] ?? false)}
disabled={locked}
onChange={(v) => onBeta(f.id, v)}
/>
</div>
);
})}
</div>
</div>
</div>
);
}
@@ -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: <LinkIcon size={16} />,
icon: "link-rounded",
Body: AccountLinkPanel,
};
@@ -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,
@@ -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));
}),
];
@@ -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<Tier, string> = {
free: "Editor plan",
pro: "Processor plan",
enterprise: "Enterprise plan",
};
const SEATS: Record<Tier, { used: number; total: number }> = {
free: { used: 1, total: 1 },
pro: { used: 4, total: 5 },
enterprise: { used: 38, total: 50 },
};
const WORKSPACE_NAME: Record<Tier, string> = {
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<Tier, number> = {
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),
};
}
@@ -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<AppConfigModalProps> = ({ opened, onClose }) => {
const AppConfigModal: React.FC<AppConfigModalProps> = ({
opened,
onClose,
initialSection,
extraSections,
}) => {
const isMobile = useMediaQuery("(max-width: 1024px)");
const { signOut, user } = useAuth();
@@ -53,16 +68,21 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ 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<AppConfigModalProps> = ({ 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) {
@@ -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[];
}