mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
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:
@@ -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"
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user