From 23606c78d6cc2c31ed727f55fe614bde454a7fc5 Mon Sep 17 00:00:00 2001 From: Fides Date: Mon, 2 Feb 2026 22:33:50 +0200 Subject: [PATCH] feat: add centralized branding configuration --- config/default.json | 31 + config/inject-config.py | 248 +++ frontend-new/.storybook/preview.tsx | 5 + frontend-new/package.json | 6 +- frontend-new/public/data/env.example.js | 18 + .../CVTypingChatMessage.test.tsx.snap | 10 +- .../UploadedCVsMenu.test.tsx.snap | 15 +- .../src/_test_utilities/envServiceMock.ts | 4 + .../auth/components/AuthHeader/AuthHeader.tsx | 6 +- .../__snapshots__/AuthHeader.test.tsx.snap | 18 +- .../RegistrationCodeFormModal.test.tsx.snap | 43 +- ...questInvitationCodeFormModal.test.tsx.snap | 81 +- .../__snapshots__/Landing.test.tsx.snap | 305 ++-- .../Login/__snapshots__/Login.test.tsx.snap | 290 ++- .../LoginWithEmailForm.test.tsx.snap | 45 +- .../LoginWithInviteCodeForm.test.tsx.snap | 10 +- .../__snapshots__/Register.test.tsx.snap | 83 +- .../RegisterWithEmailForm.test.tsx.snap | 22 +- .../__snapshots__/VerifyEmail.test.tsx.snap | 7 +- frontend-new/src/branding/branding.ts | 50 +- .../src/chat/ChatHeader/ChatHeader.tsx | 6 +- .../__snapshots__/ChatHeader.test.tsx.snap | 92 +- .../ChatMessageField.test.tsx.snap | 35 +- .../__snapshots__/ChatList.test.tsx.snap | 14 +- ...CancellableTypingChatMessage.test.tsx.snap | 12 +- .../components/chatBubble/ChatBubble.tsx | 6 +- .../__snapshots__/ChatBubble.test.tsx.snap | 8 +- .../__snapshots__/Timestamp.test.tsx.snap | 2 +- ...ConversationConclusionFooter.test.tsx.snap | 8 +- .../TypingChatMessage.test.tsx.snap | 16 +- .../ChatProgressBar.test.tsx.snap | 4 +- .../DislikeReasonPopover.test.tsx.snap | 72 +- .../ReactionButtons.test.tsx.snap | 62 +- .../__snapshots__/Consent.test.tsx.snap | 74 +- frontend-new/src/envService.ts | 38 + .../__snapshots__/errorPage.test.tsx.snap | 2 +- .../restAPIError/RestAPIError.stories.tsx | 33 +- .../ExperiencesDrawer.test.tsx.snap | 209 +-- .../DownloadReportButton.test.tsx.snap | 25 +- .../ExperienceEditForm.test.tsx.snap | 98 +- .../SummaryEditField.test.tsx.snap | 15 +- .../AddSkillsDrawer.test.tsx.snap | 44 +- .../ExperiencesDrawerContent.tsx | 13 +- .../ExperiencesDrawerContent.test.tsx.snap | 62 +- .../ExperiencesDrawerHeader.test.tsx.snap | 9 +- .../RestoreExperiencesDrawer.test.tsx.snap | 26 +- .../__snapshots__/SkillPopover.test.tsx.snap | 18 +- .../RotateToSolvePuzzle.test.tsx.snap | 67 +- ...lsRankingJobSeekerDisclosure.test.tsx.snap | 30 +- ...lsRankingJobMarketDisclosure.test.tsx.snap | 6 +- .../SkillsRankingPrompt.test.tsx.snap | 12 +- .../SkillsRankingProofOfValue.test.tsx.snap | 6 +- .../SkillsRankingRetypedRank.test.tsx.snap | 8 +- .../BugReportButton.test.tsx.snap | 11 +- .../__snapshots__/FeedbackForm.test.tsx.snap | 27 +- .../CheckboxQuestion.test.tsx.snap | 75 +- .../CommentTextField.test.tsx.snap | 4 +- .../__snapshots__/CustomRating.test.tsx.snap | 75 +- .../FeedbackFormContent.test.tsx.snap | 150 +- .../__snapshots__/QuestionText.test.tsx.snap | 2 +- .../StepsComponent.test.tsx.snap | 166 +- .../__snapshots__/YesNoQuestion.test.tsx.snap | 57 +- .../LanguageContextMenu.test.tsx.snap | 7 +- frontend-new/src/index.tsx | 2 + .../src/info/__snapshots__/Info.test.tsx.snap | 174 +- .../SensitiveDataFormSkeleton.test.tsx.snap | 35 +- .../sensitiveDataForm.test.tsx.snap | 150 +- frontend-new/src/styles/variables.css | 26 + .../__snapshots__/AnimatedBadge.test.tsx.snap | 16 +- .../AnimatedDotBadge.test.tsx.snap | 8 +- frontend-new/src/theme/Backdrop/Backdrop.tsx | 11 +- .../__snapshots__/Backdrop.test.tsx.snap | 38 +- .../InactiveBackdrop.test.tsx.snap | 7 +- .../__snapshots__/ContextMenu.test.tsx.snap | 67 +- .../CustomAccordion.test.tsx.snap | 85 +- .../CustomTextField.test.tsx.snap | 15 +- .../InlineEditField.test.tsx.snap | 21 +- .../__snapshots__/PrimaryButton.test.tsx.snap | 5 +- .../PrimaryIconButton.test.tsx.snap | 7 +- .../Sloth/__snapshots__/Solth.test.tsx.snap | 4 +- .../SnackbarProvider.test.tsx.snap | 7 +- .../applicationTheme.test.ts.snap | 1620 ++++++++++++++++- .../applicationColor.stories.tsx | 70 +- .../applicationTheme/applicationTheme.ts | 23 +- .../ConfirmModalDialog.test.tsx.snap | 35 +- .../TextConfirmModalDialog.test.tsx.snap | 32 +- frontend-new/yarn.lock | 256 +-- iac/backend/__main__.py | 3 + iac/backend/deploy_backend.py | 4 + iac/frontend/prepare_frontend.py | 18 + 90 files changed, 3723 insertions(+), 2019 deletions(-) create mode 100644 config/default.json create mode 100644 config/inject-config.py create mode 100644 frontend-new/src/styles/variables.css diff --git a/config/default.json b/config/default.json new file mode 100644 index 000000000..61e41e63f --- /dev/null +++ b/config/default.json @@ -0,0 +1,31 @@ +{ + "branding": { + "appName": "Compass", + "browserTabTitle": "Compass", + "metaDescription": "Welcome to Compass! An AI-powered career assistant that helps jobseekers identify and showcase their skills. Join now to create a digital profile and connect with new opportunities.", + "seo": { + "name": "Compass", + "url": "https://www.example.org/compass", + "image": "https://www.example.org/assets/logo.svg", + "description": "Compass is an AI-powered conversational tool that helps jobseekers discover and articulate their skills. Through natural dialogue, Compass guides users to identify abilities gained through all types of work-formal, informal, and unpaid. It creates standardized digital profiles that can connect to opportunities." + }, + "assets": { + "logo": "/logo.svg", + "favicon": "/favicon.svg", + "appIcon": "/compass.svg" + }, + "theme": { + "brand-primary": "0 255 145", + "brand-primary-light": "51 255 167", + "brand-primary-dark": "0 178 101", + "brand-primary-contrast-text": "0 0 0", + "brand-secondary": "30 113 102", + "brand-secondary-light": "77 154 143", + "brand-secondary-dark": "21 79 71", + "brand-secondary-contrast-text": "0 0 0", + "text-primary": "0 33 71", + "text-secondary": "65 64 61", + "text-accent": "38 94 167" + } + } +} diff --git a/config/inject-config.py b/config/inject-config.py new file mode 100644 index 000000000..b79199640 --- /dev/null +++ b/config/inject-config.py @@ -0,0 +1,248 @@ +import json +import argparse +import sys +import re +from pathlib import Path +from typing import Dict, Any, Optional, Iterable, Tuple + + +DEFAULT_BACKEND_ENV_PATH = '../backend/.env' +DEFAULT_FRONTEND_ENV_PATH = '../frontend-new/public/data/env.js' + +BACKEND_ENV_MAP: Dict[str, str] = { + 'GLOBAL_PRODUCT_NAME': 'branding.appName', + # Add more backend env vars here +} + +FRONTEND_ENV_MAP: Dict[str, str] = { + 'GLOBAL_PRODUCT_NAME': 'branding.appName', + 'FRONTEND_BROWSER_TAB_TITLE': 'branding.browserTabTitle', + 'FRONTEND_META_DESCRIPTION': 'branding.metaDescription', + 'FRONTEND_LOGO_URL': 'branding.assets.logo', + 'FRONTEND_FAVICON_URL': 'branding.assets.favicon', + 'FRONTEND_APP_ICON_URL': 'branding.assets.appIcon', + 'FRONTEND_THEME_CSS_VARIABLES': 'branding.theme', + 'FRONTEND_SEO': 'branding.seo', + # Add more frontend env.js keys here +} + +FRONTEND_JSON_FIELDS = { + 'FRONTEND_THEME_CSS_VARIABLES', + 'FRONTEND_SEO', + # Add more JSON.stringify fields here +} + + +def read_json(path: Path) -> Dict[str, Any]: + try: + return json.loads(path.read_text()) + except FileNotFoundError: + print(f"Error: Config file not found at '{path}'") + sys.exit(1) + except json.JSONDecodeError as e: + print(f"Error: Invalid JSON in config file: {e}") + sys.exit(1) + + +def get_by_dotted_path(obj: Any, dotted_path: str) -> Any: + """Resolve a dotted path like 'branding.assets.logo' in a dict-like object.""" + current = obj + for part in dotted_path.split('.'): + if current is None: + return None + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +def coerce_env_value(value: Any) -> Optional[str]: + """Coerce values for .env writing.""" + if value is None: + return None + if isinstance(value, bool): + # Backend env conventions vary; keep simple true/false + return 'true' if value else 'false' + if isinstance(value, (int, float)): + return str(value) + if isinstance(value, str): + v = value.strip() + return v if v != '' else None + + # For dict/list: store JSON + if isinstance(value, (dict, list)): + return json.dumps(value) + + return str(value) + + +def build_env_updates(config: Dict[str, Any], mapping: Dict[str, str], namespaces: Optional[Iterable[str]] = None) -> Dict[str, Any]: + """Build env var updates based on selected namespaces. + + If namespaces is provided, only mapping entries whose dotted-path starts with one of them are applied. + """ + selected = set(namespaces) if namespaces else None + + updates: Dict[str, Any] = {} + for env_key, dotted_path in mapping.items(): + top = dotted_path.split('.', 1)[0] + if selected is not None and top not in selected: + continue + value = get_by_dotted_path(config, dotted_path) + if value is None: + continue + updates[env_key] = value + return updates + + +def upsert_env_lines(lines: list[str], key: str, value: str) -> list[str]: + """Upsert a KEY=VALUE line into .env content preserving other lines.""" + new_line = f"{key}={value}\n" + out: list[str] = [] + found = False + + for line in lines: + if not found and line.lstrip().startswith(f"{key}="): + out.append(new_line) + found = True + else: + out.append(line) + + if not found: + if out and not out[-1].endswith('\n'): + out[-1] += '\n' + out.append(new_line) + + return out + + +def update_backend_env(config: Dict[str, Any], env_path: Path, namespaces: Optional[Iterable[str]] = None): + if not env_path.exists(): + print(f"Error: Backend .env file not found at '{env_path}'") + sys.exit(1) + + updates_raw = build_env_updates(config, BACKEND_ENV_MAP, namespaces) + + if not updates_raw: + return + + lines = env_path.read_text().splitlines(keepends=True) + + for key, raw_value in updates_raw.items(): + value = coerce_env_value(raw_value) + if value is None: + continue + lines = upsert_env_lines(lines, key, value) + + env_path.write_text(''.join(lines)) + + +def extract_existing_frontend_json_value(content: str, key: str) -> Dict[str, Any]: + """Extract the existing JSON.stringify payload for a key in env.js""" + pattern = rf'(?:"{re.escape(key)}"|{re.escape(key)}):\s*btoa\(\s*JSON\.stringify\((.*?)\)\s*\),' + match = re.search(pattern, content, re.DOTALL) + if not match: + return {} + try: + return json.loads(match.group(1).strip()) + except Exception: + return {} + + +def insert_before_closing_brace(content: str, new_line: str) -> str: + content = content.rstrip() + if content.endswith('};'): + return content[:-2] + new_line + '};\n' + return content + new_line + + +def format_frontend_replacement(key: str, raw_value: Any, existing_content: str) -> Tuple[str, int]: + """Return (replacement_string, regex_flags).""" + if key in FRONTEND_JSON_FIELDS: + # Merge dicts if possible so config can override but keep existing keys + existing = extract_existing_frontend_json_value(existing_content, key) + if isinstance(raw_value, dict) and isinstance(existing, dict): + merged = {**existing, **raw_value} + else: + merged = raw_value + + formatted_json = json.dumps(merged, indent=2).replace('\n', '\n ') + return f'{key}: btoa(\n JSON.stringify({formatted_json})\n ),', re.DOTALL + + if isinstance(raw_value, bool): + rendered = 'true' if raw_value else 'false' + elif raw_value is None: + rendered = '' + else: + rendered = str(raw_value) + + rendered = rendered.replace('\\', '\\\\').replace('"', '\\"') + return f'{key}: btoa("{rendered}"),', 0 + + +def update_frontend_env(config: Dict[str, Any], env_js_path: Path, namespaces: Optional[Iterable[str]] = None): + if not env_js_path.exists(): + print(f"Error: Frontend env.js file not found at '{env_js_path}'") + sys.exit(1) + + content = env_js_path.read_text() + + updates = build_env_updates(config, FRONTEND_ENV_MAP, namespaces) + if not updates: + return + + # Clean up multiple consecutive empty lines + content = re.sub(r'\n\n+', '\n', content) + + for key, raw_value in updates.items(): + replacement, flags = format_frontend_replacement(key, raw_value, content) + + if key in FRONTEND_JSON_FIELDS: + pattern = rf'(?:"{re.escape(key)}"|{re.escape(key)}):\s*btoa\(\s*JSON\.stringify\(.*?\)\s*\),' + else: + pattern = rf'(?:"{re.escape(key)}"|{re.escape(key)}):\s*btoa\([^)]+\),' + + if re.search(pattern, content, flags): + content = re.sub(pattern, replacement, content, count=1, flags=flags) + else: + content = insert_before_closing_brace(content, f' {replacement}\n') + + env_js_path.write_text(content) + + +def parse_args(argv: Optional[list[str]] = None): + parser = argparse.ArgumentParser( + description='Inject namespaced config into backend .env and frontend env.js', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python3 inject-config.py --config default.json + python3 inject-config.py --config default.json --namespaces branding auth + python3 inject-config.py --config default.json --backend-env backend/.env --frontend-env frontend-new/public/data/env.js + """ + ) + + parser.add_argument('--config', required=True, metavar='CONFIG_FILE', help='Path to config JSON (e.g., default.json)') + parser.add_argument('--backend-env', default=DEFAULT_BACKEND_ENV_PATH, help='Path to backend .env') + parser.add_argument('--frontend-env', default=DEFAULT_FRONTEND_ENV_PATH, help='Path to frontend env.js') + parser.add_argument('--namespaces', nargs='*', default=None, + help='Only apply these top-level namespaces (e.g., branding, cvUpload). Omit to apply all mapped keys.') + + return parser.parse_args(argv) + + +def main(): + args = parse_args() + + config_path = Path(args.config) + config = read_json(config_path) + + update_backend_env(config, Path(args.backend_env), args.namespaces) + update_frontend_env(config, Path(args.frontend_env), args.namespaces) + + print('Configuration injected successfully') + + +if __name__ == '__main__': + main() diff --git a/frontend-new/.storybook/preview.tsx b/frontend-new/.storybook/preview.tsx index c7516e245..d5f748040 100644 --- a/frontend-new/.storybook/preview.tsx +++ b/frontend-new/.storybook/preview.tsx @@ -18,6 +18,8 @@ import "./preview.css"; // If the font references by the application are not loaded via the above mechanism and they are not found locally on the system, // the browser will use a default font. +import "../src/styles/variables.css" + import type { Preview, StoryFn } from "@storybook/react"; import { IsOnlineContext } from "../src/app/isOnlineProvider/IsOnlineProvider"; import { IChatMessage } from "../src/chat/Chat.types"; @@ -26,6 +28,9 @@ import i18n from "../src/i18n/i18n"; import { initSentry } from "../src/sentryInit"; import SnackbarProvider from "../src/theme/SnackbarProvider/SnackbarProvider"; import { LocalesLabels, Locale } from "../src/i18n/constants"; +import { applyBrandingFromEnv } from "../src/branding/branding"; + +applyBrandingFromEnv(); type StorybookSelectOption = { value: string; diff --git a/frontend-new/package.json b/frontend-new/package.json index 58d40a811..a24e72c75 100644 --- a/frontend-new/package.json +++ b/frontend-new/package.json @@ -58,9 +58,9 @@ "dependencies": { "@emotion/react": "^11.11.0", "@emotion/styled": "^11.11.0", - "@mui/icons-material": "^5.11.16", - "@mui/material": "^5.15.12", - "@mui/system": "^5.15.15", + "@mui/icons-material": "^7.3.7", + "@mui/material": "^7.3.7", + "@mui/system": "^7.3.7", "@react-pdf/renderer": "^3.4.5", "@sentry/cli": "^2.39.1", "@sentry/react": "^9.41.0", diff --git a/frontend-new/public/data/env.example.js b/frontend-new/public/data/env.example.js index f37e8a42e..c787885d9 100644 --- a/frontend-new/public/data/env.example.js +++ b/frontend-new/public/data/env.example.js @@ -77,4 +77,22 @@ window.tabiyaConfig = { description: "Compass is an AI-powered career assistant that helps jobseekers discover and describe their skills.", }) ), + FRONTEND_LOGO_URL: btoa("/logo.svg"), + FRONTEND_FAVICON_URL: btoa("/favicon.svg"), + FRONTEND_APP_ICON_URL: btoa("/compass.svg"), + FRONTEND_THEME_CSS_VARIABLES: btoa( + JSON.stringify({ + "brand-primary": "0 255 145", + "brand-primary-light": "51 255 167", + "brand-primary-dark": "0 178 101", + "brand-primary-contrast-text": "0 0 0", + "brand-secondary": "30 113 102", + "brand-secondary-light": "77 154 143", + "brand-secondary-dark": "21 79 71", + "brand-secondary-contrast-text": "0 0 0", + "text-primary": "0 33 71", + "text-secondary": "65 64 61", + "text-accent": "38 94 167", + }) + ), }; diff --git a/frontend-new/src/CV/CVTypingChatMessage/__snapshots__/CVTypingChatMessage.test.tsx.snap b/frontend-new/src/CV/CVTypingChatMessage/__snapshots__/CVTypingChatMessage.test.tsx.snap index 8aa7a9e96..b76df07a0 100644 --- a/frontend-new/src/CV/CVTypingChatMessage/__snapshots__/CVTypingChatMessage.test.tsx.snap +++ b/frontend-new/src/CV/CVTypingChatMessage/__snapshots__/CVTypingChatMessage.test.tsx.snap @@ -13,7 +13,7 @@ exports[`CVTypingChatMessage should render the CV Typing Chat message correctly class="MuiBox-root css-x1sij0" >

Your CV content is in the text field. Review it and send when ready.

@@ -35,7 +35,7 @@ exports[`CVTypingChatMessage should render the CV Typing Chat message correctly class="MuiBox-root css-x1sij0" >

Please wait while I upload and parse your CV

@@ -43,17 +43,17 @@ exports[`CVTypingChatMessage should render the CV Typing Chat message correctly class="MuiBox-root css-sz63p1" > . . . diff --git a/frontend-new/src/CV/uploadedCVsMenu/__snapshots__/UploadedCVsMenu.test.tsx.snap b/frontend-new/src/CV/uploadedCVsMenu/__snapshots__/UploadedCVsMenu.test.tsx.snap index a2bbbbc55..f29901c3d 100644 --- a/frontend-new/src/CV/uploadedCVsMenu/__snapshots__/UploadedCVsMenu.test.tsx.snap +++ b/frontend-new/src/CV/uploadedCVsMenu/__snapshots__/UploadedCVsMenu.test.tsx.snap @@ -9,7 +9,7 @@ exports[`UploadedCVsMenuContent render tests should render the Uploaded CVs Menu class="MuiBox-root css-t5n7v9" >
Previously uploaded CVs (1) @@ -48,7 +45,7 @@ exports[`UploadedCVsMenuContent render tests should render the Uploaded CVs Menu > foo_bar.pdf just now diff --git a/frontend-new/src/_test_utilities/envServiceMock.ts b/frontend-new/src/_test_utilities/envServiceMock.ts index 920e54e74..d4bdc22ad 100644 --- a/frontend-new/src/_test_utilities/envServiceMock.ts +++ b/frontend-new/src/_test_utilities/envServiceMock.ts @@ -17,4 +17,8 @@ jest.mock("src/envService", () => ({ getMetaDescription: jest.fn(() => "Mocked Meta Description"), getSeoEnvVar: jest.fn(() => "{}"), ensureRequiredEnvVars: jest.fn(() => {}), + getLogoUrl: jest.fn(() => "mock-logo-url"), + getFaviconUrl: jest.fn(() => "mock-favicon-url"), + getAppIconUrl: jest.fn(() => "mock-app-icon-url"), + getThemeCssVariables: jest.fn(() => "{}"), })); diff --git a/frontend-new/src/auth/components/AuthHeader/AuthHeader.tsx b/frontend-new/src/auth/components/AuthHeader/AuthHeader.tsx index 33523987f..80f608cfb 100644 --- a/frontend-new/src/auth/components/AuthHeader/AuthHeader.tsx +++ b/frontend-new/src/auth/components/AuthHeader/AuthHeader.tsx @@ -1,6 +1,7 @@ import LanguageContextMenu from "src/i18n/languageContextMenu/LanguageContextMenu"; import { Box, Typography } from "@mui/material"; import React from "react"; +import { getLogoUrl } from "src/envService"; const uniqueId = "40374529-6e2c-49d8-81d7-93f01603a648"; @@ -17,6 +18,9 @@ export interface AuthHeaderProps { } const AuthHeader: React.FC> = ({ title, subtitle }) => { + const logoUrlFromEnv = getLogoUrl(); + const logoSrc = logoUrlFromEnv || `${process.env.PUBLIC_URL}/logo.svg`; + return ( > = ({ title, subtitle }) => > Logo

Only title @@ -61,7 +58,7 @@ exports[`AuthHeader tests should render the AuthHeader 1`] = ` style="max-width: 60%; margin: 5% 10%;" />

Test title diff --git a/frontend-new/src/auth/components/registrationCodeFormModal/__snapshots__/RegistrationCodeFormModal.test.tsx.snap b/frontend-new/src/auth/components/registrationCodeFormModal/__snapshots__/RegistrationCodeFormModal.test.tsx.snap index c4cc26241..85b505ea1 100644 --- a/frontend-new/src/auth/components/registrationCodeFormModal/__snapshots__/RegistrationCodeFormModal.test.tsx.snap +++ b/frontend-new/src/auth/components/registrationCodeFormModal/__snapshots__/RegistrationCodeFormModal.test.tsx.snap @@ -4,13 +4,13 @@ exports[`RegistrationCodeFormModal renders correctly when modal is shown and cal