From f7f703383734ea05abc7e8f8714ee9b61c7581b5 Mon Sep 17 00:00:00 2001 From: Carlos Alberto Date: Wed, 30 Sep 2026 11:44:31 +0100 Subject: [PATCH] feat(settings): complete workspace configuration --- src/api/settings.py | 346 ++++++++++++++++++++++++++ src/app.py | 2 + src/front/components/Sidebar.jsx | 11 +- src/front/context/AppContext.jsx | 11 +- src/front/i18n/translations.js | 3 + src/front/pages/Settings.css | 66 +++++ src/front/pages/Settings.jsx | 274 ++++++++++++++++++++ src/front/routes.jsx | 9 + src/front/services/settingsService.js | 68 +++++ tests/test_settings.py | 247 ++++++++++++++++++ 10 files changed, 1035 insertions(+), 2 deletions(-) create mode 100644 src/api/settings.py create mode 100644 src/front/pages/Settings.css create mode 100644 src/front/pages/Settings.jsx create mode 100644 src/front/services/settingsService.js create mode 100644 tests/test_settings.py diff --git a/src/api/settings.py b/src/api/settings.py new file mode 100644 index 0000000..052bbf2 --- /dev/null +++ b/src/api/settings.py @@ -0,0 +1,346 @@ +"""Company-scoped workspace settings for ticket #29.""" + +import os +import re +from functools import wraps +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +from flask import Blueprint, g, jsonify, request +from flask_jwt_extended import get_jwt +from sqlalchemy import func, select, update +from sqlalchemy.exc import SQLAlchemyError + +from api.auth import tenant_required +from api.channel_email import validate_email_settings +from api.models import ( + AIAgent, + AuthSession, + Company, + CompanyMembership, + Integration, + KnowledgeDocument, + MembershipRole, + db, + utc_now, +) + +settings = Blueprint("settings", __name__) + +MANAGER_ROLES = { + MembershipRole.OWNER, + MembershipRole.ADMIN, + MembershipRole.MANAGER, +} +SUPPORTED_LANGUAGES = {"en", "es", "pt"} +SUPPORTED_THEMES = {"system", "light", "dark"} +SUPPORTED_INTEGRATIONS = {"email", "web_chat", "whatsapp"} + + +def settings_admin_required(function): + """Limit workspace-wide changes to trusted company roles.""" + @wraps(function) + @tenant_required + def wrapped(*args, **kwargs): + if g.membership.role not in MANAGER_ROLES: + return jsonify(message="You cannot change company settings."), 403 + return function(*args, **kwargs) + + return wrapped + + +def body_object(): + body = request.get_json(silent=True) + return body if isinstance(body, dict) else None + + +def clean_optional_text(value, field, maximum): + if value is None: + return None + if not isinstance(value, str): + raise ValueError(f"{field} must be text.") + value = value.strip() + if len(value) > maximum: + raise ValueError(f"{field} is too long.") + return value or None + + +def validate_company_changes(body): + allowed = {"name", "email", "phone", "timezone", "primary_colour"} + if body is None or not body or not set(body).issubset(allowed): + raise ValueError("Provide only supported company fields.") + + changes = {} + if "name" in body: + name = clean_optional_text(body["name"], "name", 160) + if name is None: + raise ValueError("Company name is required.") + changes["name"] = name + if "email" in body: + email = clean_optional_text(body["email"], "email", 255) + if email and not re.fullmatch(r"[^@\s<>]+@[^@\s<>]+\.[^@\s<>]+", email): + raise ValueError("Enter a valid company email address.") + changes["email"] = email + if "phone" in body: + phone = clean_optional_text(body["phone"], "phone", 40) + if phone and not re.fullmatch(r"[+0-9().\s-]{5,40}", phone): + raise ValueError("Enter a valid company phone number.") + changes["phone"] = phone + if "timezone" in body: + timezone = clean_optional_text(body["timezone"], "timezone", 60) + if timezone is None: + raise ValueError("Timezone is required.") + try: + ZoneInfo(timezone) + except ZoneInfoNotFoundError as error: + raise ValueError("Enter a valid IANA timezone.") from error + changes["timezone"] = timezone + if "primary_colour" in body: + colour = clean_optional_text(body["primary_colour"], "primary_colour", 20) + if colour and not re.fullmatch(r"#[0-9A-Fa-f]{6}", colour): + raise ValueError("Primary colour must use #RRGGBB format.") + changes["primary_colour"] = colour + return changes + + +def validate_preferences(body): + if body is None or not body or not set(body).issubset({"theme", "language"}): + raise ValueError("Provide only supported preference fields.") + changes = {} + if "theme" in body: + if body["theme"] not in SUPPORTED_THEMES: + raise ValueError("Choose system, light or dark theme.") + changes["theme"] = body["theme"] + if "language" in body: + if body["language"] not in SUPPORTED_LANGUAGES: + raise ValueError("Choose a supported language.") + changes["default_language"] = body["language"] + return changes + + +def validate_integration(provider, body): + if provider not in SUPPORTED_INTEGRATIONS: + raise ValueError("Unsupported integration provider.") + if body is None or set(body) != {"enabled", "settings"}: + raise ValueError("Provide enabled and settings fields.") + if type(body["enabled"]) is not bool or not isinstance(body["settings"], dict): + raise ValueError("Invalid integration configuration.") + + values = body["settings"] + if not body["enabled"]: + return False, {} + if provider == "email": + values = validate_email_settings(values) + elif provider == "web_chat": + expected = {"display_name", "welcome_message", "accent_colour"} + if set(values) != expected: + raise ValueError("Unexpected or missing web chat fields.") + display_name = clean_optional_text(values["display_name"], "display_name", 80) + welcome = clean_optional_text(values["welcome_message"], "welcome_message", 300) + accent = clean_optional_text(values["accent_colour"], "accent_colour", 20) + if not display_name or not welcome: + raise ValueError("Web chat name and welcome message are required.") + if accent and not re.fullmatch(r"#[0-9A-Fa-f]{6}", accent): + raise ValueError("Accent colour must use #RRGGBB format.") + values = { + "display_name": display_name, + "welcome_message": welcome, + "accent_colour": accent or "#635BFF", + } + else: + expected = {"display_name", "phone_number"} + if set(values) != expected: + raise ValueError("Unexpected or missing WhatsApp fields.") + display_name = clean_optional_text(values["display_name"], "display_name", 80) + phone = clean_optional_text(values["phone_number"], "phone_number", 40) + if not display_name or not phone or not re.fullmatch(r"\+[1-9][0-9]{6,14}", phone): + raise ValueError("Enter a name and an international WhatsApp number.") + values = {"display_name": display_name, "phone_number": phone} + return body["enabled"], values + + +def integration_json(integration): + safe_fields = { + "email": {"smtp_host", "smtp_port", "security", "from_address"}, + "web_chat": {"display_name", "welcome_message", "accent_colour"}, + "whatsapp": {"display_name", "phone_number"}, + } + stored_settings = integration.settings if isinstance(integration.settings, dict) else {} + return { + "provider": integration.provider, + "status": integration.status, + "settings": { + key: value + for key, value in stored_settings.items() + if key in safe_fields.get(integration.provider, set()) + }, + "external_account_id": integration.external_account_id, + } + + +@settings.get("/settings") +@tenant_required +def get_settings(): + company = db.session.get(Company, g.company_id) + integrations = db.session.scalars( + select(Integration).where( + Integration.company_id == g.company_id, + Integration.provider.in_(SUPPORTED_INTEGRATIONS), + ) + ).all() + active_sessions = db.session.scalar( + select(func.count()).select_from(AuthSession).where( + AuthSession.user_id == g.user.id, + AuthSession.revoked_at.is_(None), + AuthSession.expires_at > utc_now(), + ) + ) + counts = { + "members": db.session.scalar( + select(func.count()).select_from(CompanyMembership).where( + CompanyMembership.company_id == g.company_id, + CompanyMembership.is_active.is_(True), + ) + ), + "agents": db.session.scalar( + select(func.count()).select_from(AIAgent).where( + AIAgent.company_id == g.company_id, + AIAgent.is_active.is_(True), + ) + ), + "knowledge_documents": db.session.scalar( + select(func.count()).select_from(KnowledgeDocument).where( + KnowledgeDocument.company_id == g.company_id, + ) + ), + "ready_documents": db.session.scalar( + select(func.count()).select_from(KnowledgeDocument).where( + KnowledgeDocument.company_id == g.company_id, + KnowledgeDocument.ingestion_status == "ready", + ) + ), + } + configured = {row.provider: integration_json(row) for row in integrations} + for provider in SUPPORTED_INTEGRATIONS: + configured.setdefault(provider, { + "provider": provider, + "status": "disconnected", + "settings": {}, + "external_account_id": None, + }) + return jsonify( + company={ + "id": company.id, + "name": company.name, + "email": company.email, + "phone": company.phone, + "timezone": company.timezone, + "default_language": company.default_language, + "theme": company.theme, + "primary_colour": company.primary_colour, + }, + user=g.user.serialize(), + membership={ + "id": g.membership.id, + "role": g.membership.role.value, + "can_manage": g.membership.role in MANAGER_ROLES, + }, + integrations=configured, + overview=counts, + ai_service={ + "configured": bool(os.getenv("AI_SERVICE_URL") and os.getenv("AI_SERVICE_API_KEY")), + "model": os.getenv("AI_SERVICE_MODEL") or None, + }, + security={ + "active_sessions": active_sessions or 0, + "last_login_at": g.user.last_login_at.isoformat() if g.user.last_login_at else None, + }, + ) + + +@settings.patch("/settings/company") +@settings_admin_required +def update_company(): + try: + changes = validate_company_changes(body_object()) + company = db.session.get(Company, g.company_id) + for field, value in changes.items(): + setattr(company, field, value) + db.session.commit() + return jsonify(message="Company settings saved.", company={ + "id": company.id, + "name": company.name, + "email": company.email, + "phone": company.phone, + "timezone": company.timezone, + "primary_colour": company.primary_colour, + }) + except ValueError as error: + return jsonify(message=str(error)), 400 + except SQLAlchemyError: + db.session.rollback() + return jsonify(message="Company settings could not be saved."), 503 + + +@settings.patch("/settings/preferences") +@settings_admin_required +def update_preferences(): + try: + changes = validate_preferences(body_object()) + company = db.session.get(Company, g.company_id) + if "theme" in changes: + company.theme = changes["theme"] + if "default_language" in changes: + company.default_language = changes["default_language"] + g.user.preferred_language = changes["default_language"] + db.session.commit() + return jsonify( + message="Workspace preferences saved.", + theme=company.theme, + language=company.default_language, + ) + except ValueError as error: + return jsonify(message=str(error)), 400 + except SQLAlchemyError: + db.session.rollback() + return jsonify(message="Workspace preferences could not be saved."), 503 + + +@settings.put("/settings/integrations/") +@settings_admin_required +def update_integration(provider): + try: + enabled, values = validate_integration(provider, body_object()) + integration = db.session.scalar(select(Integration).where( + Integration.company_id == g.company_id, + Integration.provider == provider, + )) + if integration is None: + integration = Integration(company_id=g.company_id, provider=provider) + db.session.add(integration) + integration.status = "configured" if enabled else "disconnected" + integration.settings = values + db.session.commit() + return jsonify(message="Integration settings saved.", integration=integration_json(integration)) + except ValueError as error: + return jsonify(message=str(error)), 400 + except SQLAlchemyError: + db.session.rollback() + return jsonify(message="Integration settings could not be saved."), 503 + + +@settings.post("/settings/security/revoke-other-sessions") +@tenant_required +def revoke_other_sessions(): + current_session = get_jwt().get("sid") + if not current_session: + return jsonify(message="Current session is invalid."), 401 + now = utc_now() + result = db.session.execute( + update(AuthSession).where( + AuthSession.user_id == g.user.id, + AuthSession.id != current_session, + AuthSession.revoked_at.is_(None), + ).values(revoked_at=now) + ) + db.session.commit() + return jsonify(message="Other sessions closed.", revoked=result.rowcount) diff --git a/src/app.py b/src/app.py index a4c298c..3db77ad 100644 --- a/src/app.py +++ b/src/app.py @@ -23,6 +23,7 @@ from api.members import members from api.models import db from api.routes import api +from api.settings import settings from api.utils import APIException, generate_sitemap # from models import Person @@ -60,6 +61,7 @@ app.register_blueprint(ai, url_prefix="/api") app.register_blueprint(members, url_prefix="/api") app.register_blueprint(knowledge, url_prefix="/api") +app.register_blueprint(settings, url_prefix="/api") init_auth(app) CORS( app, diff --git a/src/front/components/Sidebar.jsx b/src/front/components/Sidebar.jsx index 9b9a2a2..e444414 100644 --- a/src/front/components/Sidebar.jsx +++ b/src/front/components/Sidebar.jsx @@ -36,7 +36,11 @@ export const Sidebar = ({ isOpen, onClose }) => { }; loadAccount(); - return () => controller.abort(); + window.addEventListener("clientflow:company-updated", loadAccount); + return () => { + controller.abort(); + window.removeEventListener("clientflow:company-updated", loadAccount); + }; }, []); const isActive = (path) => location.pathname === path; @@ -192,6 +196,11 @@ export const Sidebar = ({ isOpen, onClose }) => { {t("nav.team")} +
  • + + {t("nav.settings")} + +
  • {/*
  • Zonas de servicio diff --git a/src/front/context/AppContext.jsx b/src/front/context/AppContext.jsx index 238b1d6..b3cdffc 100644 --- a/src/front/context/AppContext.jsx +++ b/src/front/context/AppContext.jsx @@ -9,6 +9,10 @@ export const AppProvider = ({ children }) => { const [systemTheme, setSystemTheme] = useState(() => window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light" ); + const [density, setDensity] = useState(() => { + const stored = localStorage.getItem("interface_density"); + return stored === "compact" ? "compact" : "comfortable"; + }); const resolvedTheme = theme === "system" ? systemTheme : theme; @@ -26,6 +30,11 @@ export const AppProvider = ({ children }) => { localStorage.setItem("theme", theme); }, [resolvedTheme, theme]); + useEffect(() => { + document.documentElement.setAttribute("data-density", density); + localStorage.setItem("interface_density", density); + }, [density]); + const toggleTheme = () => { setTheme(resolvedTheme === "dark" ? "light" : "dark"); }; @@ -36,7 +45,7 @@ export const AppProvider = ({ children }) => { }; return ( - + {children} ); diff --git a/src/front/i18n/translations.js b/src/front/i18n/translations.js index 81b5247..882851e 100644 --- a/src/front/i18n/translations.js +++ b/src/front/i18n/translations.js @@ -7,6 +7,7 @@ export const translations = { clients: "Clients", agenda: "Agenda", team: "Team", + settings: "Settings", conversations: "Inbox", knowledge: "Knowledge", agents: "AI Agents", @@ -82,6 +83,7 @@ export const translations = { clients: "Clientes", agenda: "Agenda", team: "Equipo", + settings: "Configuración", conversations: "Bandeja", knowledge: "Base de conocimiento", agents: "Agentes IA", @@ -157,6 +159,7 @@ export const translations = { clients: "Clientes", agenda: "Agenda", team: "Equipe", + settings: "Configurações", conversations: "Caixa de entrada", knowledge: "Base de conhecimento", agents: "Agentes de IA", diff --git a/src/front/pages/Settings.css b/src/front/pages/Settings.css new file mode 100644 index 0000000..04d3904 --- /dev/null +++ b/src/front/pages/Settings.css @@ -0,0 +1,66 @@ +.settings-page { max-width: 1180px; margin: 0 auto; padding: 0 0 3rem; color: var(--cf-text); } +.settings-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 2rem; margin-bottom: 2rem; } +.settings-header p { margin: 0 0 .45rem; color: #635bff; font-size: .72rem; font-weight: 800; letter-spacing: .14em; } +.settings-header h1 { margin: 0; font-size: clamp(2rem, 4vw, 3rem); font-weight: 800; } +.settings-header span { color: var(--cf-text-muted); } +.settings-company-mark { width: 54px; height: 54px; border-radius: 16px; display: grid; place-items: center; color: white; font-size: 1.4rem; font-weight: 800; box-shadow: 0 12px 30px rgba(99,91,255,.22); } +.settings-layout { display: grid; grid-template-columns: 235px minmax(0, 1fr); gap: 1.5rem; align-items: start; } +.settings-tabs, .settings-panel { background: var(--cf-surface); border: 1px solid var(--cf-border); border-radius: 18px; box-shadow: var(--cf-control-shadow); } +.settings-tabs { padding: .65rem; display: grid; gap: .3rem; position: sticky; top: 1rem; } +.settings-tabs button { border: 0; background: transparent; color: var(--cf-text-muted); border-radius: 12px; text-align: left; padding: .8rem .9rem; display: flex; align-items: center; gap: .75rem; font-weight: 700; } +.settings-tabs button i { width: 18px; text-align: center; } +.settings-tabs button:hover { background: var(--cf-surface-muted); color: var(--cf-text); } +.settings-tabs button.active { background: rgba(99,91,255,.12); color: #635bff; } +.settings-panel { padding: clamp(1.25rem, 3vw, 2rem); min-height: 520px; } +.settings-panel-title { border-bottom: 1px solid var(--cf-border); padding-bottom: 1.1rem; margin-bottom: 1.4rem; } +.settings-panel-title h2 { margin: 0 0 .35rem; font-size: 1.45rem; font-weight: 800; } +.settings-panel-title p { margin: 0; color: var(--cf-text-muted); } +.settings-grid { display: grid; gap: 1rem; } +.settings-grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); } +.settings-grid.three { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.settings-field { display: grid; gap: .45rem; color: var(--cf-text); font-weight: 700; font-size: .88rem; } +.settings-field input, .settings-field select { width: 100%; min-height: 44px; border-radius: 10px; border: 1px solid var(--cf-border); background: var(--cf-surface-muted); color: var(--cf-text); padding: .7rem .8rem; outline: 0; } +.settings-field input:focus, .settings-field select:focus { border-color: #635bff; box-shadow: 0 0 0 3px rgba(99,91,255,.15); } +.colour-field { display: grid; grid-template-columns: 52px 1fr; gap: .5rem; } +.colour-field input[type="color"] { padding: .25rem; } +.settings-primary-button, .settings-link-button, .settings-secondary-button, .settings-danger-button { min-height: 43px; border-radius: 10px; padding: .7rem 1rem; font-weight: 800; display: inline-flex; align-items: center; justify-content: center; gap: .55rem; text-decoration: none; margin-top: 1.35rem; } +.settings-primary-button, .settings-link-button { border: 1px solid #635bff; background: #635bff; color: white; } +.settings-primary-button:disabled { opacity: .65; } +.settings-secondary-button { border: 1px solid var(--cf-border); background: var(--cf-surface); color: var(--cf-text); } +.settings-danger-button { border: 1px solid #dc3545; background: transparent; color: #dc3545; } +.settings-notice { padding: .85rem 1rem; border-radius: 12px; margin: 0 0 1rem; border: 1px solid; font-weight: 700; } +.settings-notice.success { color: #146c43; background: rgba(25,135,84,.1); border-color: rgba(25,135,84,.35); } +.settings-notice.error, .settings-error { color: #b02a37; background: rgba(220,53,69,.1); border-color: rgba(220,53,69,.35); } +.settings-notice.info { color: #3b3273; background: rgba(99,91,255,.1); border-color: rgba(99,91,255,.25); } +.settings-state { min-height: 50vh; display: grid; place-items: center; color: var(--cf-text-muted); } +.settings-error { align-content: center; border: 1px solid; border-radius: 14px; padding: 1rem; } +.settings-error button { border: 0; border-radius: 8px; padding: .6rem 1rem; } +.settings-metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; } +.settings-metric { display: grid; grid-template-columns: auto auto 1fr; align-items: center; gap: .75rem; border: 1px solid var(--cf-border); border-radius: 14px; padding: 1.15rem; background: var(--cf-surface-muted); } +.settings-metric i { width: 42px; height: 42px; border-radius: 12px; display: grid; place-items: center; color: #635bff; background: rgba(99,91,255,.12); } +.settings-metric strong { font-size: 1.6rem; } +.settings-metric span { color: var(--cf-text-muted); } +.integration-list { display: grid; gap: 1rem; } +.integration-card { border: 1px solid var(--cf-border); border-radius: 15px; padding: 1.1rem; background: var(--cf-surface-muted); } +.integration-heading { display: flex; justify-content: space-between; align-items: center; gap: 1rem; } +.integration-heading > div { display: flex; align-items: center; gap: .8rem; } +.integration-heading > div > i { width: 40px; height: 40px; border-radius: 11px; display: grid; place-items: center; background: var(--cf-surface); color: #635bff; } +.integration-heading h3 { margin: 0; font-size: 1rem; font-weight: 800; } +.integration-heading div span { color: var(--cf-text-muted); font-size: .82rem; } +.settings-switch { display: flex; align-items: center; gap: .45rem; font-weight: 700; } +.settings-switch input { width: 18px; height: 18px; accent-color: #635bff; } +.integration-fields { margin-top: 1rem; } +.security-list { display: grid; gap: .2rem; border: 1px solid var(--cf-border); border-radius: 14px; overflow: hidden; } +.security-list div { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem; background: var(--cf-surface-muted); border-bottom: 1px solid var(--cf-border); } +.security-list div:last-child { border-bottom: 0; } +.security-list span { color: var(--cf-text-muted); } +.settings-action-row { display: flex; flex-wrap: wrap; gap: .75rem; } +.ai-service-state { display: flex; align-items: center; gap: .6rem; padding: .9rem 1rem; border-radius: 12px; margin-top: 1rem; font-weight: 700; } +.ai-service-state span { margin-left: auto; color: var(--cf-text-muted); } +.ai-service-state.ready { color: #146c43; background: rgba(25,135,84,.1); } +.ai-service-state.missing { color: #997404; background: rgba(255,193,7,.12); } +:root[data-density="compact"] .settings-panel { padding: 1.25rem; } +:root[data-density="compact"] .settings-field input, :root[data-density="compact"] .settings-field select { min-height: 38px; padding-block: .45rem; } +:root[data-density="compact"] .integration-card, :root[data-density="compact"] .settings-metric { padding: .8rem; } +@media (max-width: 900px) { .settings-layout { grid-template-columns: 1fr; } .settings-tabs { position: static; grid-template-columns: repeat(3, 1fr); } .settings-tabs button { justify-content: center; text-align: center; } } +@media (max-width: 650px) { .settings-header { align-items: center; } .settings-grid.two, .settings-grid.three, .settings-metrics { grid-template-columns: 1fr; } .settings-tabs { grid-template-columns: 1fr 1fr; } .settings-tabs button { justify-content: flex-start; } .settings-panel { min-height: 0; } } diff --git a/src/front/pages/Settings.jsx b/src/front/pages/Settings.jsx new file mode 100644 index 0000000..20d231d --- /dev/null +++ b/src/front/pages/Settings.jsx @@ -0,0 +1,274 @@ +import { useEffect, useMemo, useState } from "react"; +import { Link } from "react-router-dom"; +import { useApp } from "../context/AppContext"; +import { useLanguage } from "../context/LanguageContext"; +import { settingsService } from "../services/settingsService"; +import "./Settings.css"; + +const copy = { + en: { + title: "Settings", subtitle: "Manage your company, workspace and connected services.", + company: "Company", appearance: "Appearance", users: "Users and permissions", + integrations: "Integrations", ai: "AI and knowledge", security: "Security", + companyIntro: "The information shown to your team and customers.", name: "Company name", + email: "Company email", phone: "Phone", timezone: "Timezone", colour: "Brand colour", + saveCompany: "Save company", appearanceIntro: "Choose the default experience for this workspace.", + theme: "Theme", system: "Use system", light: "Light", dark: "Dark", language: "Language", + density: "Interface density", comfortable: "Comfortable", compact: "Compact", + savePreferences: "Save preferences", usersIntro: "Manage who can access this company and what they can do.", + activeMembers: "active members", openTeam: "Manage team", integrationsIntro: "Configure channels without exposing private credentials in the browser.", + enabled: "Enabled", saveIntegration: "Save integration", emailChannel: "Email", + webChat: "Web chat", whatsapp: "WhatsApp", connected: "Configured", disconnected: "Disconnected", + smtpHost: "SMTP host", smtpPort: "SMTP port", encryption: "Encryption", sender: "Sender address", + displayName: "Display name", welcome: "Welcome message", accent: "Accent colour", whatsappNumber: "International number", + aiIntro: "Keep agents connected only to approved company knowledge.", activeAgents: "Active agents", + documents: "Documents", ready: "ready", manageAgents: "Manage agents", manageKnowledge: "Manage knowledge", + serviceReady: "AI service configured", serviceMissing: "AI service needs server configuration", + securityIntro: "Review account activity and close sessions you no longer use.", activeSessions: "Active sessions", + lastLogin: "Last login", never: "Not available", resetPassword: "Change password", closeSessions: "Close other sessions", + readOnly: "Your role can view these settings. An administrator must save workspace changes.", + loading: "Loading settings...", retry: "Try again", saved: "Changes saved.", + }, + es: { + title: "Configuración", subtitle: "Gestiona tu empresa, el espacio de trabajo y los servicios conectados.", + company: "Empresa", appearance: "Apariencia", users: "Usuarios y permisos", + integrations: "Integraciones", ai: "IA y conocimiento", security: "Seguridad", + companyIntro: "La información que verá tu equipo y tus clientes.", name: "Nombre de la empresa", + email: "Email de la empresa", phone: "Teléfono", timezone: "Zona horaria", colour: "Color de marca", + saveCompany: "Guardar empresa", appearanceIntro: "Elige la experiencia predeterminada del espacio de trabajo.", + theme: "Tema", system: "Usar el sistema", light: "Claro", dark: "Oscuro", language: "Idioma", + density: "Densidad de la interfaz", comfortable: "Cómoda", compact: "Compacta", + savePreferences: "Guardar preferencias", usersIntro: "Gestiona quién accede a la empresa y qué puede hacer.", + activeMembers: "miembros activos", openTeam: "Gestionar equipo", integrationsIntro: "Configura canales sin exponer credenciales privadas en el navegador.", + enabled: "Activada", saveIntegration: "Guardar integración", emailChannel: "Email", + webChat: "Chat web", whatsapp: "WhatsApp", connected: "Configurada", disconnected: "Desconectada", + smtpHost: "Servidor SMTP", smtpPort: "Puerto SMTP", encryption: "Cifrado", sender: "Dirección remitente", + displayName: "Nombre visible", welcome: "Mensaje de bienvenida", accent: "Color de acento", whatsappNumber: "Número internacional", + aiIntro: "Mantén los agentes conectados solamente al conocimiento autorizado de la empresa.", activeAgents: "Agentes activos", + documents: "Documentos", ready: "listos", manageAgents: "Gestionar agentes", manageKnowledge: "Gestionar conocimiento", + serviceReady: "Servicio de IA configurado", serviceMissing: "El servicio de IA necesita configuración en el servidor", + securityIntro: "Revisa la actividad de la cuenta y cierra sesiones que ya no utilizas.", activeSessions: "Sesiones activas", + lastLogin: "Último acceso", never: "No disponible", resetPassword: "Cambiar contraseña", closeSessions: "Cerrar otras sesiones", + readOnly: "Tu rol puede consultar estos ajustes. Un administrador debe guardar los cambios del espacio.", + loading: "Cargando configuración...", retry: "Reintentar", saved: "Cambios guardados.", + }, + pt: { + title: "Configurações", subtitle: "Gerencie sua empresa, o espaço de trabalho e os serviços conectados.", + company: "Empresa", appearance: "Aparência", users: "Usuários e permissões", + integrations: "Integrações", ai: "IA e conhecimento", security: "Segurança", + companyIntro: "As informações exibidas para sua equipe e seus clientes.", name: "Nome da empresa", + email: "Email da empresa", phone: "Telefone", timezone: "Fuso horário", colour: "Cor da marca", + saveCompany: "Salvar empresa", appearanceIntro: "Escolha a experiência padrão deste espaço de trabalho.", + theme: "Tema", system: "Usar o sistema", light: "Claro", dark: "Escuro", language: "Idioma", + density: "Densidade da interface", comfortable: "Confortável", compact: "Compacta", + savePreferences: "Salvar preferências", usersIntro: "Gerencie quem acessa esta empresa e o que pode fazer.", + activeMembers: "membros ativos", openTeam: "Gerenciar equipe", integrationsIntro: "Configure canais sem expor credenciais privadas no navegador.", + enabled: "Ativada", saveIntegration: "Salvar integração", emailChannel: "Email", + webChat: "Chat web", whatsapp: "WhatsApp", connected: "Configurada", disconnected: "Desconectada", + smtpHost: "Servidor SMTP", smtpPort: "Porta SMTP", encryption: "Criptografia", sender: "Endereço remetente", + displayName: "Nome visível", welcome: "Mensagem de boas-vindas", accent: "Cor de destaque", whatsappNumber: "Número internacional", + aiIntro: "Mantenha os agentes conectados apenas ao conhecimento autorizado da empresa.", activeAgents: "Agentes ativos", + documents: "Documentos", ready: "prontos", manageAgents: "Gerenciar agentes", manageKnowledge: "Gerenciar conhecimento", + serviceReady: "Serviço de IA configurado", serviceMissing: "O serviço de IA precisa de configuração no servidor", + securityIntro: "Revise a atividade da conta e encerre sessões que não usa mais.", activeSessions: "Sessões ativas", + lastLogin: "Último acesso", never: "Não disponível", resetPassword: "Alterar senha", closeSessions: "Encerrar outras sessões", + readOnly: "Sua função pode consultar estas configurações. Um administrador deve salvar alterações do espaço.", + loading: "Carregando configurações...", retry: "Tentar novamente", saved: "Alterações salvas.", + }, +}; + +const integrationDefaults = { + email: { smtp_host: "", smtp_port: 587, security: "starttls", from_address: "" }, + web_chat: { display_name: "ClientFlow", welcome_message: "Hola, ¿en qué podemos ayudarte?", accent_colour: "#635BFF" }, + whatsapp: { display_name: "", phone_number: "" }, +}; + +export const Settings = () => { + const token = localStorage.getItem("access_token"); + const { theme, setTheme, density, setDensity } = useApp(); + const { locale, setLocale } = useLanguage(); + const text = copy[locale] || copy.en; + const [tab, setTab] = useState("company"); + const [data, setData] = useState(null); + const [company, setCompany] = useState({}); + const [preferences, setPreferences] = useState({ theme, language: locale, density }); + const [integrationForms, setIntegrationForms] = useState({}); + const [notice, setNotice] = useState(null); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(""); + + const tabs = useMemo(() => [ + ["company", "fa-building", text.company], + ["appearance", "fa-palette", text.appearance], + ["users", "fa-user-shield", text.users], + ["integrations", "fa-plug", text.integrations], + ["ai", "fa-robot", text.ai], + ["security", "fa-shield-halved", text.security], + ], [text]); + + const load = async (signal) => { + setError(""); + try { + const result = await settingsService.load(token, signal); + setData(result); + setCompany(result.company); + setPreferences((current) => ({ + ...current, + theme: result.company.theme || current.theme, + language: result.company.default_language || current.language, + })); + setTheme(result.company.theme || "system"); + setLocale(result.company.default_language || "en"); + const forms = {}; + Object.entries(integrationDefaults).forEach(([provider, defaults]) => { + const saved = result.integrations[provider]; + forms[provider] = { + enabled: saved.status === "configured", + settings: { ...defaults, ...(saved.settings || {}) }, + }; + }); + setIntegrationForms(forms); + } catch (requestError) { + if (requestError.name !== "AbortError") setError(requestError.message); + } + }; + + useEffect(() => { + const controller = new AbortController(); + load(controller.signal); + return () => controller.abort(); + }, []); + + const options = { token, companyId: data?.companyId }; + const run = async (key, action, successMessage = text.saved) => { + setBusy(key); + setNotice(null); + try { + const result = await action(); + setNotice({ type: "success", message: result.message || successMessage }); + return result; + } catch (requestError) { + setNotice({ type: "error", message: requestError.message }); + return null; + } finally { + setBusy(""); + } + }; + + const saveCompany = async (event) => { + event.preventDefault(); + const result = await run("company", () => settingsService.saveCompany(options, { + name: company.name, + email: company.email || null, + phone: company.phone || null, + timezone: company.timezone, + primary_colour: company.primary_colour || null, + })); + if (result) window.dispatchEvent(new CustomEvent("clientflow:company-updated")); + }; + + const savePreferences = async (event) => { + event.preventDefault(); + const result = await run("preferences", () => settingsService.savePreferences(options, { + theme: preferences.theme, + language: preferences.language, + })); + if (result) { + setTheme(preferences.theme); + setLocale(preferences.language); + setDensity(preferences.density); + } + }; + + const changeIntegration = (provider, field, value) => { + setIntegrationForms((current) => ({ + ...current, + [provider]: field === "enabled" + ? { ...current[provider], enabled: value } + : { ...current[provider], settings: { ...current[provider].settings, [field]: value } }, + })); + }; + + const saveIntegration = async (event, provider) => { + event.preventDefault(); + const result = await run(provider, () => settingsService.saveIntegration(options, provider, integrationForms[provider])); + if (result) { + setData((current) => ({ + ...current, + integrations: { ...current.integrations, [provider]: result.integration }, + })); + } + }; + + if (!data && !error) return
    {text.loading}
    ; + if (!data) return

    {error}

    ; + + const canManage = data.membership.can_manage; + const disabled = !canManage; + const statusLabel = (provider) => data.integrations[provider].status === "configured" ? text.connected : text.disconnected; + + return ( +
    +
    +

    CLIENTFLOW · {text.company.toUpperCase()}

    {text.title}

    {text.subtitle}
    +
    {company.name?.[0]?.toUpperCase() || "C"}
    +
    + + {notice &&
    {notice.message}
    } + {!canManage &&
    {text.readOnly}
    } + +
    + + +
    + {tab === "company" &&
    + +
    + setCompany({ ...company, name: event.target.value })} /> + setCompany({ ...company, email: event.target.value })} /> + setCompany({ ...company, phone: event.target.value })} /> + setCompany({ ...company, timezone: event.target.value })} /> +
    setCompany({ ...company, primary_colour: event.target.value })} /> setCompany({ ...company, primary_colour: event.target.value })} />
    +
    + {canManage && {text.saveCompany}} + } + + {tab === "appearance" &&
    + +
    + + + +
    + {canManage && {text.savePreferences}} + } + + {tab === "users" &&
    {data.overview.members}{text.activeMembers}
    {text.openTeam}
    } + + {tab === "integrations" &&
    + changeIntegration("email", field, value)} onSubmit={(event) => saveIntegration(event, "email")} text={text} provider="email" /> + changeIntegration("web_chat", field, value)} onSubmit={(event) => saveIntegration(event, "web_chat")} text={text} provider="web_chat" /> + changeIntegration("whatsapp", field, value)} onSubmit={(event) => saveIntegration(event, "whatsapp")} text={text} provider="whatsapp" /> +
    } + + {tab === "ai" &&
    {data.overview.agents}{text.activeAgents}
    {data.overview.knowledge_documents}{text.documents} · {data.overview.ready_documents} {text.ready}
    {data.ai_service.configured ? text.serviceReady : text.serviceMissing}{data.ai_service.model && {data.ai_service.model}}
    {text.manageAgents}{text.manageKnowledge}
    } + + {tab === "security" &&
    {text.activeSessions}{data.security.active_sessions}
    {text.lastLogin}{data.security.last_login_at ? new Date(data.security.last_login_at).toLocaleString(locale) : text.never}
    {text.resetPassword}
    } +
    +
    +
    + ); +}; + +const PanelTitle = ({ title, description }) =>

    {title}

    {description}

    ; +const Field = ({ label, children }) => ; +const SaveButton = ({ busy, children }) => ; + +const IntegrationCard = ({ title, icon, status, form, disabled, busy, onChange, onSubmit, text, provider }) => { + if (!form) return null; + return

    {title}

    {status}
    {form.enabled &&
    {provider === "email" && <> onChange("smtp_host", event.target.value)} /> onChange("smtp_port", Number(event.target.value))} /> onChange("from_address", event.target.value)} />}{provider === "web_chat" && <> onChange("display_name", event.target.value)} /> onChange("accent_colour", event.target.value)} /> onChange("welcome_message", event.target.value)} />}{provider === "whatsapp" && <> onChange("display_name", event.target.value)} /> onChange("phone_number", event.target.value)} />}
    }{!disabled && {text.saveIntegration}}; +}; + +export default Settings; diff --git a/src/front/routes.jsx b/src/front/routes.jsx index 0011b63..719bb62 100644 --- a/src/front/routes.jsx +++ b/src/front/routes.jsx @@ -18,6 +18,7 @@ import { Members } from "./pages/Members"; import { PlanSelection } from "./pages/PlanSelection"; import { Register } from "./pages/Register"; import { ResetPassword } from "./pages/ResetPassword"; +import { Settings } from "./pages/Settings"; import { WebChatPage } from "./pages/WebChatPage"; const ProtectedRoute = ({ children }) => { @@ -213,6 +214,14 @@ export const router = createBrowserRouter( } /> + + + + } + />