Spaces:
Running
Running
Ashraf Al-Kassem Claude Opus 4.6 commited on
Commit ·
c626151
1
Parent(s): 6da3982
feat: Mission 21 — Settings Center + Data-Driven Dropdowns
Browse files- Add PATCH /auth/me for user profile updates with audit logging
- Add 8 new catalog endpoints (timezones, languages, ai-models, dedupe-strategies, event-sources, event-outcomes, audit-actions, contact-fields)
- Create LookupsProvider context + useLookups() hook for bulk catalog preload
- Restructure Settings page with Profile section (name edit, email verification badge)
- Replace 10 hardcoded dropdowns across 5 frontend pages with catalog data
- Sidebar shows real user name + computed initials from /auth/me
- 141 tests passing (+23 new), zero TS build errors
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- backend/app/api/v1/auth.py +49 -1
- backend/app/api/v1/catalog.py +48 -0
- backend/app/core/catalog_registry.py +102 -0
- backend/app/schemas/user.py +4 -0
- backend/tests/test_catalog_lookups.py +68 -0
- backend/tests/test_profile.py +116 -0
- docs/missions/mission_21.md +104 -0
- docs/missions/mission_21_baseline.md +51 -0
- frontend/src/app/(admin)/admin/runtime-events/page.tsx +9 -9
- frontend/src/app/(admin)/admin/system-settings/page.tsx +4 -2
- frontend/src/app/(dashboard)/integrations/zoho/mapping/page.tsx +6 -13
- frontend/src/app/(dashboard)/logs/page.tsx +8 -14
- frontend/src/app/(dashboard)/settings/page.tsx +192 -81
- frontend/src/components/AppShell.tsx +3 -0
- frontend/src/components/Sidebar.tsx +20 -5
- frontend/src/lib/catalog.ts +10 -2
- frontend/src/lib/lookups.tsx +86 -0
backend/app/api/v1/auth.py
CHANGED
|
@@ -14,7 +14,7 @@ from app.core import security
|
|
| 14 |
from app.core.config import settings
|
| 15 |
from app.core.db import get_db
|
| 16 |
from app.models.models import User, Workspace, WorkspaceMember, WorkspaceRole, PasswordResetToken, EmailLog, EmailStatus, EmailOutbox, EmailOutboxStatus
|
| 17 |
-
from app.schemas.user import Token, UserRead, UserCreate, ForgotPassword, ResetPassword, GoogleCallback, MeRead
|
| 18 |
from app.schemas.envelope import ResponseEnvelope, wrap_data, wrap_error
|
| 19 |
from app.api.deps import get_current_user
|
| 20 |
from app.services.audit_service import audit_event
|
|
@@ -56,6 +56,54 @@ async def get_me(
|
|
| 56 |
))
|
| 57 |
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
@router.post("/login", response_model=ResponseEnvelope[Token])
|
| 60 |
async def login(
|
| 61 |
request: Request, db: AsyncSession = Depends(get_db), form_data: OAuth2PasswordRequestForm = Depends()
|
|
|
|
| 14 |
from app.core.config import settings
|
| 15 |
from app.core.db import get_db
|
| 16 |
from app.models.models import User, Workspace, WorkspaceMember, WorkspaceRole, PasswordResetToken, EmailLog, EmailStatus, EmailOutbox, EmailOutboxStatus
|
| 17 |
+
from app.schemas.user import Token, UserRead, UserCreate, ForgotPassword, ResetPassword, GoogleCallback, MeRead, ProfileUpdate
|
| 18 |
from app.schemas.envelope import ResponseEnvelope, wrap_data, wrap_error
|
| 19 |
from app.api.deps import get_current_user
|
| 20 |
from app.services.audit_service import audit_event
|
|
|
|
| 56 |
))
|
| 57 |
|
| 58 |
|
| 59 |
+
@router.patch("/me", response_model=ResponseEnvelope[MeRead])
|
| 60 |
+
async def update_profile(
|
| 61 |
+
update_in: ProfileUpdate,
|
| 62 |
+
db: AsyncSession = Depends(get_db),
|
| 63 |
+
current_user: User = Depends(get_current_user),
|
| 64 |
+
request: Request = None,
|
| 65 |
+
) -> Any:
|
| 66 |
+
"""Update the authenticated user's profile."""
|
| 67 |
+
changed = False
|
| 68 |
+
|
| 69 |
+
if update_in.full_name is not None:
|
| 70 |
+
stripped = update_in.full_name.strip()
|
| 71 |
+
if not stripped:
|
| 72 |
+
return wrap_error("Full name cannot be empty")
|
| 73 |
+
current_user.full_name = stripped
|
| 74 |
+
changed = True
|
| 75 |
+
|
| 76 |
+
if changed:
|
| 77 |
+
db.add(current_user)
|
| 78 |
+
await audit_event(
|
| 79 |
+
db, action="update_profile", entity_type="user",
|
| 80 |
+
entity_id=str(current_user.id), actor_user_id=current_user.id,
|
| 81 |
+
outcome="success", request=request,
|
| 82 |
+
)
|
| 83 |
+
await db.commit()
|
| 84 |
+
await db.refresh(current_user)
|
| 85 |
+
|
| 86 |
+
now = datetime.utcnow()
|
| 87 |
+
verified = current_user.email_verified_at is not None
|
| 88 |
+
grace_expires = current_user.email_verification_expires_at
|
| 89 |
+
grace_remaining_days: Optional[int] = None
|
| 90 |
+
if not verified and grace_expires is not None:
|
| 91 |
+
delta = grace_expires - now
|
| 92 |
+
grace_remaining_days = max(0, delta.days)
|
| 93 |
+
|
| 94 |
+
return wrap_data(MeRead(
|
| 95 |
+
id=current_user.id,
|
| 96 |
+
email=current_user.email,
|
| 97 |
+
full_name=current_user.full_name,
|
| 98 |
+
is_active=current_user.is_active,
|
| 99 |
+
is_superuser=current_user.is_superuser,
|
| 100 |
+
email_verified_at=current_user.email_verified_at,
|
| 101 |
+
email_verification_expires_at=grace_expires,
|
| 102 |
+
requires_email_verification=not verified,
|
| 103 |
+
verification_grace_remaining_days=grace_remaining_days,
|
| 104 |
+
))
|
| 105 |
+
|
| 106 |
+
|
| 107 |
@router.post("/login", response_model=ResponseEnvelope[Token])
|
| 108 |
async def login(
|
| 109 |
request: Request, db: AsyncSession = Depends(get_db), form_data: OAuth2PasswordRequestForm = Depends()
|
backend/app/api/v1/catalog.py
CHANGED
|
@@ -21,6 +21,14 @@ from app.core.catalog_registry import (
|
|
| 21 |
WORKSPACE_ROLES,
|
| 22 |
AGENCY_ROLES,
|
| 23 |
MODULE_LABELS,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
)
|
| 25 |
|
| 26 |
router = APIRouter()
|
|
@@ -145,3 +153,43 @@ router.add_api_route(
|
|
| 145 |
_static_endpoint(MESSAGE_DELIVERY_STATUSES),
|
| 146 |
methods=["GET"],
|
| 147 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
WORKSPACE_ROLES,
|
| 22 |
AGENCY_ROLES,
|
| 23 |
MODULE_LABELS,
|
| 24 |
+
TIMEZONES,
|
| 25 |
+
LANGUAGES,
|
| 26 |
+
AI_MODELS,
|
| 27 |
+
DEDUPE_STRATEGIES,
|
| 28 |
+
EVENT_SOURCES,
|
| 29 |
+
EVENT_OUTCOMES,
|
| 30 |
+
AUDIT_ACTIONS,
|
| 31 |
+
CONTACT_FIELDS,
|
| 32 |
)
|
| 33 |
|
| 34 |
router = APIRouter()
|
|
|
|
| 153 |
_static_endpoint(MESSAGE_DELIVERY_STATUSES),
|
| 154 |
methods=["GET"],
|
| 155 |
)
|
| 156 |
+
router.add_api_route(
|
| 157 |
+
"/timezones",
|
| 158 |
+
_static_endpoint(TIMEZONES),
|
| 159 |
+
methods=["GET"],
|
| 160 |
+
)
|
| 161 |
+
router.add_api_route(
|
| 162 |
+
"/languages",
|
| 163 |
+
_static_endpoint(LANGUAGES),
|
| 164 |
+
methods=["GET"],
|
| 165 |
+
)
|
| 166 |
+
router.add_api_route(
|
| 167 |
+
"/ai-models",
|
| 168 |
+
_static_endpoint(AI_MODELS),
|
| 169 |
+
methods=["GET"],
|
| 170 |
+
)
|
| 171 |
+
router.add_api_route(
|
| 172 |
+
"/dedupe-strategies",
|
| 173 |
+
_static_endpoint(DEDUPE_STRATEGIES),
|
| 174 |
+
methods=["GET"],
|
| 175 |
+
)
|
| 176 |
+
router.add_api_route(
|
| 177 |
+
"/event-sources",
|
| 178 |
+
_static_endpoint(EVENT_SOURCES),
|
| 179 |
+
methods=["GET"],
|
| 180 |
+
)
|
| 181 |
+
router.add_api_route(
|
| 182 |
+
"/event-outcomes",
|
| 183 |
+
_static_endpoint(EVENT_OUTCOMES),
|
| 184 |
+
methods=["GET"],
|
| 185 |
+
)
|
| 186 |
+
router.add_api_route(
|
| 187 |
+
"/audit-actions",
|
| 188 |
+
_static_endpoint(AUDIT_ACTIONS),
|
| 189 |
+
methods=["GET"],
|
| 190 |
+
)
|
| 191 |
+
router.add_api_route(
|
| 192 |
+
"/contact-fields",
|
| 193 |
+
_static_endpoint(CONTACT_FIELDS),
|
| 194 |
+
methods=["GET"],
|
| 195 |
+
)
|
backend/app/core/catalog_registry.py
CHANGED
|
@@ -184,3 +184,105 @@ MODULE_LABELS: Dict[str, str] = {
|
|
| 184 |
"diagnostics": "Diagnostics",
|
| 185 |
"admin_portal": "Admin Portal",
|
| 186 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
"diagnostics": "Diagnostics",
|
| 185 |
"admin_portal": "Admin Portal",
|
| 186 |
}
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
# ── Timezones ───────────────────────────────────────────────────────
|
| 190 |
+
|
| 191 |
+
TIMEZONES: List[Dict[str, str]] = [
|
| 192 |
+
{"key": "UTC", "label": "UTC (Coordinated Universal Time)"},
|
| 193 |
+
{"key": "US/Eastern", "label": "US/Eastern (EST/EDT)"},
|
| 194 |
+
{"key": "US/Central", "label": "US/Central (CST/CDT)"},
|
| 195 |
+
{"key": "US/Pacific", "label": "US/Pacific (PST/PDT)"},
|
| 196 |
+
{"key": "Europe/London", "label": "Europe/London (GMT/BST)"},
|
| 197 |
+
{"key": "Europe/Paris", "label": "Europe/Paris (CET/CEST)"},
|
| 198 |
+
{"key": "Asia/Dubai", "label": "Asia/Dubai (GST)"},
|
| 199 |
+
{"key": "Asia/Tokyo", "label": "Asia/Tokyo (JST)"},
|
| 200 |
+
{"key": "Asia/Shanghai", "label": "Asia/Shanghai (CST)"},
|
| 201 |
+
{"key": "Australia/Sydney", "label": "Australia/Sydney (AEST)"},
|
| 202 |
+
]
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
# ── Languages ───────────────────────────────────────────────────────
|
| 206 |
+
|
| 207 |
+
LANGUAGES: List[Dict[str, str]] = [
|
| 208 |
+
{"key": "en", "label": "English"},
|
| 209 |
+
{"key": "ar", "label": "Arabic"},
|
| 210 |
+
{"key": "es", "label": "Spanish"},
|
| 211 |
+
{"key": "fr", "label": "French"},
|
| 212 |
+
{"key": "de", "label": "German"},
|
| 213 |
+
{"key": "zh", "label": "Chinese"},
|
| 214 |
+
{"key": "pt", "label": "Portuguese"},
|
| 215 |
+
{"key": "ja", "label": "Japanese"},
|
| 216 |
+
]
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
# ── AI Models ───────────────────────────────────────────────────────
|
| 220 |
+
|
| 221 |
+
AI_MODELS: List[Dict[str, str]] = [
|
| 222 |
+
{"key": "gemini-1.5-pro", "label": "Gemini 1.5 Pro"},
|
| 223 |
+
{"key": "gemini-1.5-flash", "label": "Gemini 1.5 Flash"},
|
| 224 |
+
{"key": "gemini-2.0-flash", "label": "Gemini 2.0 Flash"},
|
| 225 |
+
]
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
# ── Dedupe Strategies ──────────────────────────────────────────────
|
| 229 |
+
|
| 230 |
+
DEDUPE_STRATEGIES: List[Dict[str, str]] = [
|
| 231 |
+
{"key": "EMAIL", "label": "Email Address Only"},
|
| 232 |
+
{"key": "PHONE", "label": "Phone Number Only"},
|
| 233 |
+
{"key": "EMAIL_OR_PHONE", "label": "Email OR Phone (Recommended)"},
|
| 234 |
+
]
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
# ── Event Sources ──────────────────────────────────────────────────
|
| 238 |
+
|
| 239 |
+
EVENT_SOURCES: List[Dict[str, str]] = [
|
| 240 |
+
{"key": "webhook", "label": "Webhook"},
|
| 241 |
+
{"key": "runtime", "label": "Runtime"},
|
| 242 |
+
{"key": "dispatch", "label": "Dispatch"},
|
| 243 |
+
{"key": "email", "label": "Email"},
|
| 244 |
+
{"key": "zoho", "label": "Zoho"},
|
| 245 |
+
{"key": "inbox", "label": "Inbox"},
|
| 246 |
+
]
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
# ── Event Outcomes ─────────────────────────────────────────────────
|
| 250 |
+
|
| 251 |
+
EVENT_OUTCOMES: List[Dict[str, str]] = [
|
| 252 |
+
{"key": "success", "label": "Success"},
|
| 253 |
+
{"key": "failure", "label": "Failure"},
|
| 254 |
+
{"key": "skipped", "label": "Skipped"},
|
| 255 |
+
]
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
# ── Audit Actions ──────────────────────────────────────────────────
|
| 259 |
+
|
| 260 |
+
AUDIT_ACTIONS: List[Dict[str, str]] = [
|
| 261 |
+
{"key": "user_login", "label": "User Login"},
|
| 262 |
+
{"key": "user_signup", "label": "User Signup"},
|
| 263 |
+
{"key": "update_profile", "label": "Profile Update"},
|
| 264 |
+
{"key": "workspace_create", "label": "Workspace Create"},
|
| 265 |
+
{"key": "workspace_invite", "label": "Workspace Invite"},
|
| 266 |
+
{"key": "workspace_member_remove", "label": "Member Remove"},
|
| 267 |
+
{"key": "workspace_role_change", "label": "Role Change"},
|
| 268 |
+
{"key": "automation_create", "label": "Automation Create"},
|
| 269 |
+
{"key": "automation_publish", "label": "Automation Publish"},
|
| 270 |
+
{"key": "integration_connect", "label": "Integration Connect"},
|
| 271 |
+
{"key": "integration_disconnect", "label": "Integration Disconnect"},
|
| 272 |
+
{"key": "dispatch_trigger", "label": "Dispatch Trigger"},
|
| 273 |
+
{"key": "inbox_reply", "label": "Inbox Reply"},
|
| 274 |
+
{"key": "inbox_status_change", "label": "Inbox Status Change"},
|
| 275 |
+
{"key": "update_workspace_settings", "label": "Settings Update"},
|
| 276 |
+
]
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
# ── Contact Fields ─────────────────────────────────────────────────
|
| 280 |
+
|
| 281 |
+
CONTACT_FIELDS: List[Dict[str, Any]] = [
|
| 282 |
+
{"key": "first_name", "label": "First Name", "required": True},
|
| 283 |
+
{"key": "last_name", "label": "Last Name", "required": True},
|
| 284 |
+
{"key": "email", "label": "Email", "required": False},
|
| 285 |
+
{"key": "phone", "label": "Phone", "required": False},
|
| 286 |
+
{"key": "company", "label": "Company", "required": False},
|
| 287 |
+
{"key": "description", "label": "Description / Notes", "required": False},
|
| 288 |
+
]
|
backend/app/schemas/user.py
CHANGED
|
@@ -29,6 +29,10 @@ class MeRead(UserRead):
|
|
| 29 |
verification_grace_remaining_days: Optional[int] = None
|
| 30 |
|
| 31 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
class Token(BaseModel):
|
| 33 |
access_token: str
|
| 34 |
token_type: str
|
|
|
|
| 29 |
verification_grace_remaining_days: Optional[int] = None
|
| 30 |
|
| 31 |
|
| 32 |
+
class ProfileUpdate(BaseModel):
|
| 33 |
+
full_name: Optional[str] = None
|
| 34 |
+
|
| 35 |
+
|
| 36 |
class Token(BaseModel):
|
| 37 |
access_token: str
|
| 38 |
token_type: str
|
backend/tests/test_catalog_lookups.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Mission 21: Catalog Lookup Tests
|
| 3 |
+
Tests all 8 new static catalog endpoints added for data-driven dropdowns.
|
| 4 |
+
"""
|
| 5 |
+
import pytest
|
| 6 |
+
from httpx import AsyncClient
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
# All new Mission 21 catalog endpoints with a sample of expected keys
|
| 10 |
+
NEW_LOOKUP_ENDPOINTS = [
|
| 11 |
+
("timezones", ["UTC", "US/Eastern", "Asia/Dubai"]),
|
| 12 |
+
("languages", ["en", "ar", "es"]),
|
| 13 |
+
("ai-models", ["gemini-1.5-pro", "gemini-2.0-flash"]),
|
| 14 |
+
("dedupe-strategies", ["EMAIL", "PHONE", "EMAIL_OR_PHONE"]),
|
| 15 |
+
("event-sources", ["webhook", "runtime", "dispatch", "inbox"]),
|
| 16 |
+
("event-outcomes", ["success", "failure", "skipped"]),
|
| 17 |
+
("audit-actions", ["user_login", "update_profile", "automation_create"]),
|
| 18 |
+
("contact-fields", ["first_name", "email", "phone"]),
|
| 19 |
+
]
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@pytest.mark.asyncio
|
| 23 |
+
@pytest.mark.parametrize("endpoint,expected_keys", NEW_LOOKUP_ENDPOINTS)
|
| 24 |
+
async def test_lookup_endpoint_returns_data(
|
| 25 |
+
async_client: AsyncClient, endpoint: str, expected_keys: list
|
| 26 |
+
):
|
| 27 |
+
response = await async_client.get(f"/api/v1/catalog/{endpoint}")
|
| 28 |
+
assert response.status_code == 200
|
| 29 |
+
data = response.json()
|
| 30 |
+
assert data["success"] is True
|
| 31 |
+
items = data["data"]
|
| 32 |
+
assert isinstance(items, list)
|
| 33 |
+
assert len(items) >= 1
|
| 34 |
+
|
| 35 |
+
# Every item must have key + label
|
| 36 |
+
for item in items:
|
| 37 |
+
assert "key" in item, f"Item missing 'key' in {endpoint}"
|
| 38 |
+
assert "label" in item, f"Item missing 'label' in {endpoint}"
|
| 39 |
+
|
| 40 |
+
# Spot-check expected keys are present
|
| 41 |
+
actual_keys = [item["key"] for item in items]
|
| 42 |
+
for key in expected_keys:
|
| 43 |
+
assert key in actual_keys, f"Expected key '{key}' in /catalog/{endpoint}"
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@pytest.mark.asyncio
|
| 47 |
+
@pytest.mark.parametrize("endpoint,expected_keys", NEW_LOOKUP_ENDPOINTS)
|
| 48 |
+
async def test_lookup_endpoint_cache_header(
|
| 49 |
+
async_client: AsyncClient, endpoint: str, expected_keys: list
|
| 50 |
+
):
|
| 51 |
+
response = await async_client.get(f"/api/v1/catalog/{endpoint}")
|
| 52 |
+
assert response.status_code == 200
|
| 53 |
+
assert "Cache-Control" in response.headers
|
| 54 |
+
assert "max-age=60" in response.headers["Cache-Control"]
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@pytest.mark.asyncio
|
| 58 |
+
async def test_contact_fields_have_required_flag(async_client: AsyncClient):
|
| 59 |
+
response = await async_client.get("/api/v1/catalog/contact-fields")
|
| 60 |
+
data = response.json()
|
| 61 |
+
for item in data["data"]:
|
| 62 |
+
assert "required" in item, f"Contact field '{item['key']}' missing 'required' flag"
|
| 63 |
+
assert isinstance(item["required"], bool)
|
| 64 |
+
|
| 65 |
+
# first_name should be required, phone should not
|
| 66 |
+
fields_map = {f["key"]: f for f in data["data"]}
|
| 67 |
+
assert fields_map["first_name"]["required"] is True
|
| 68 |
+
assert fields_map["phone"]["required"] is False
|
backend/tests/test_profile.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Mission 21: Profile Update Tests
|
| 3 |
+
Tests PATCH /auth/me endpoint for user profile management.
|
| 4 |
+
"""
|
| 5 |
+
import pytest
|
| 6 |
+
from httpx import AsyncClient
|
| 7 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
# ── Helpers ─────────────────────────────────────────────────────────
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
async def _signup_and_login(client: AsyncClient, email: str = "profile_user@example.com") -> str:
|
| 14 |
+
"""Create a user and return a JWT access token."""
|
| 15 |
+
await client.post("/api/v1/auth/signup", json={
|
| 16 |
+
"email": email,
|
| 17 |
+
"password": "securepassword123",
|
| 18 |
+
"full_name": "Original Name",
|
| 19 |
+
})
|
| 20 |
+
login = await client.post(
|
| 21 |
+
"/api/v1/auth/login",
|
| 22 |
+
data={"username": email, "password": "securepassword123"},
|
| 23 |
+
headers={"content-type": "application/x-www-form-urlencoded"},
|
| 24 |
+
)
|
| 25 |
+
return login.json()["data"]["access_token"]
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _auth(token: str) -> dict:
|
| 29 |
+
return {"Authorization": f"Bearer {token}"}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# ── Tests ───────────────────────────────────────────────────────────
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@pytest.mark.asyncio
|
| 36 |
+
async def test_patch_me_updates_full_name(async_client: AsyncClient):
|
| 37 |
+
token = await _signup_and_login(async_client, "profile_patch@example.com")
|
| 38 |
+
|
| 39 |
+
response = await async_client.patch(
|
| 40 |
+
"/api/v1/auth/me",
|
| 41 |
+
json={"full_name": "Updated Name"},
|
| 42 |
+
headers=_auth(token),
|
| 43 |
+
)
|
| 44 |
+
assert response.status_code == 200
|
| 45 |
+
data = response.json()
|
| 46 |
+
assert data["success"] is True
|
| 47 |
+
assert data["data"]["full_name"] == "Updated Name"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@pytest.mark.asyncio
|
| 51 |
+
async def test_patch_me_strips_whitespace(async_client: AsyncClient):
|
| 52 |
+
token = await _signup_and_login(async_client, "profile_strip@example.com")
|
| 53 |
+
|
| 54 |
+
response = await async_client.patch(
|
| 55 |
+
"/api/v1/auth/me",
|
| 56 |
+
json={"full_name": " Padded Name "},
|
| 57 |
+
headers=_auth(token),
|
| 58 |
+
)
|
| 59 |
+
assert response.status_code == 200
|
| 60 |
+
assert response.json()["data"]["full_name"] == "Padded Name"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@pytest.mark.asyncio
|
| 64 |
+
async def test_patch_me_empty_name_rejected(async_client: AsyncClient):
|
| 65 |
+
token = await _signup_and_login(async_client, "profile_empty@example.com")
|
| 66 |
+
|
| 67 |
+
response = await async_client.patch(
|
| 68 |
+
"/api/v1/auth/me",
|
| 69 |
+
json={"full_name": " "},
|
| 70 |
+
headers=_auth(token),
|
| 71 |
+
)
|
| 72 |
+
assert response.status_code == 200
|
| 73 |
+
data = response.json()
|
| 74 |
+
assert data["success"] is False
|
| 75 |
+
assert "empty" in data["error"].lower()
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@pytest.mark.asyncio
|
| 79 |
+
async def test_patch_me_no_change_when_empty_body(async_client: AsyncClient):
|
| 80 |
+
token = await _signup_and_login(async_client, "profile_noop@example.com")
|
| 81 |
+
|
| 82 |
+
response = await async_client.patch(
|
| 83 |
+
"/api/v1/auth/me",
|
| 84 |
+
json={},
|
| 85 |
+
headers=_auth(token),
|
| 86 |
+
)
|
| 87 |
+
assert response.status_code == 200
|
| 88 |
+
data = response.json()
|
| 89 |
+
assert data["success"] is True
|
| 90 |
+
assert data["data"]["full_name"] == "Original Name"
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
@pytest.mark.asyncio
|
| 94 |
+
async def test_get_me_reflects_patch(async_client: AsyncClient):
|
| 95 |
+
token = await _signup_and_login(async_client, "profile_reflect@example.com")
|
| 96 |
+
|
| 97 |
+
# Update
|
| 98 |
+
await async_client.patch(
|
| 99 |
+
"/api/v1/auth/me",
|
| 100 |
+
json={"full_name": "Reflected Name"},
|
| 101 |
+
headers=_auth(token),
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
# Read back
|
| 105 |
+
response = await async_client.get("/api/v1/auth/me", headers=_auth(token))
|
| 106 |
+
assert response.status_code == 200
|
| 107 |
+
assert response.json()["data"]["full_name"] == "Reflected Name"
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
@pytest.mark.asyncio
|
| 111 |
+
async def test_patch_me_unauthenticated(async_client: AsyncClient):
|
| 112 |
+
response = await async_client.patch(
|
| 113 |
+
"/api/v1/auth/me",
|
| 114 |
+
json={"full_name": "No Auth"},
|
| 115 |
+
)
|
| 116 |
+
assert response.status_code in (401, 403)
|
docs/missions/mission_21.md
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Mission 21 — Settings Center + Data-Driven Dropdowns
|
| 2 |
+
|
| 3 |
+
## Summary
|
| 4 |
+
|
| 5 |
+
Mission 21 eliminates all hardcoded frontend dropdowns by extending the catalog registry, adds user profile management, and restructures the settings page with a Profile tab.
|
| 6 |
+
|
| 7 |
+
1. **8 new catalog endpoints** — timezones, languages, AI models, dedupe strategies, event sources, event outcomes, audit actions, contact fields — all served from the catalog registry.
|
| 8 |
+
2. **LookupsProvider** — single React context preloading all lookups on mount via `Promise.all`, consumed by `useLookups()` hook.
|
| 9 |
+
3. **Profile management** — `PATCH /auth/me` lets users update their name. Settings page gains a Profile section.
|
| 10 |
+
4. **Sidebar shows real user data** — fetches `/auth/me` on mount, displays actual name + computed initials.
|
| 11 |
+
5. **Every hardcoded dropdown replaced** — 13 occurrences across 7 files now use catalog data.
|
| 12 |
+
|
| 13 |
+
## What Changed
|
| 14 |
+
|
| 15 |
+
### Backend
|
| 16 |
+
|
| 17 |
+
| File | Change |
|
| 18 |
+
|---|---|
|
| 19 |
+
| `app/schemas/user.py` | Added `ProfileUpdate` schema |
|
| 20 |
+
| `app/api/v1/auth.py` | Added `PATCH /auth/me` endpoint with audit logging |
|
| 21 |
+
| `app/core/catalog_registry.py` | Added 8 new catalog constants (TIMEZONES, LANGUAGES, AI_MODELS, DEDUPE_STRATEGIES, EVENT_SOURCES, EVENT_OUTCOMES, AUDIT_ACTIONS, CONTACT_FIELDS) |
|
| 22 |
+
| `app/api/v1/catalog.py` | Registered 8 new static endpoints |
|
| 23 |
+
|
| 24 |
+
### Frontend
|
| 25 |
+
|
| 26 |
+
| File | Change |
|
| 27 |
+
|---|---|
|
| 28 |
+
| `src/lib/catalog.ts` | Extended `CatalogKey` type with 8 new keys; exported `fetchCatalog` |
|
| 29 |
+
| `src/lib/lookups.tsx` | **NEW** — LookupsProvider context + useLookups hook |
|
| 30 |
+
| `src/components/AppShell.tsx` | Wrapped with `<LookupsProvider>` |
|
| 31 |
+
| `src/app/(dashboard)/settings/page.tsx` | Added Profile section; replaced 3 hardcoded dropdowns with `CatalogSelectField` |
|
| 32 |
+
| `src/components/Sidebar.tsx` | Real user name from `/auth/me`, dynamic initials |
|
| 33 |
+
| `src/app/(dashboard)/logs/page.tsx` | Action + outcome filters from catalog |
|
| 34 |
+
| `src/app/(dashboard)/integrations/zoho/mapping/page.tsx` | Contact fields + dedupe strategies from catalog |
|
| 35 |
+
| `src/app/(admin)/admin/runtime-events/page.tsx` | Source + outcome filters from catalog |
|
| 36 |
+
| `src/app/(admin)/admin/system-settings/page.tsx` | Plan tier select from `/catalog/plans` |
|
| 37 |
+
|
| 38 |
+
### Tests
|
| 39 |
+
|
| 40 |
+
| File | Tests |
|
| 41 |
+
|---|---|
|
| 42 |
+
| `tests/test_profile.py` | 6 tests — update name, whitespace strip, empty rejected, no-op body, GET reflects PATCH, unauthenticated |
|
| 43 |
+
| `tests/test_catalog_lookups.py` | 19 tests — 8 endpoints return data with key/label, 8 have Cache-Control header, contact fields have required flag |
|
| 44 |
+
|
| 45 |
+
## New API Endpoints
|
| 46 |
+
|
| 47 |
+
| Method | Path | Description |
|
| 48 |
+
|---|---|---|
|
| 49 |
+
| PATCH | `/api/v1/auth/me` | Update authenticated user's profile (full_name) |
|
| 50 |
+
| GET | `/api/v1/catalog/timezones` | List of common timezones |
|
| 51 |
+
| GET | `/api/v1/catalog/languages` | Supported languages |
|
| 52 |
+
| GET | `/api/v1/catalog/ai-models` | Available AI models |
|
| 53 |
+
| GET | `/api/v1/catalog/dedupe-strategies` | CRM dedupe strategies |
|
| 54 |
+
| GET | `/api/v1/catalog/event-sources` | Runtime event sources |
|
| 55 |
+
| GET | `/api/v1/catalog/event-outcomes` | Event outcome types |
|
| 56 |
+
| GET | `/api/v1/catalog/audit-actions` | Audit log action types |
|
| 57 |
+
| GET | `/api/v1/catalog/contact-fields` | Standard contact fields |
|
| 58 |
+
|
| 59 |
+
## Architecture: LookupsProvider
|
| 60 |
+
|
| 61 |
+
```
|
| 62 |
+
<LookupsProvider> ← wraps AppShell (dashboard layout)
|
| 63 |
+
│
|
| 64 |
+
├── Promise.all on mount
|
| 65 |
+
│ ├── fetchCatalog("timezones")
|
| 66 |
+
│ ├── fetchCatalog("languages")
|
| 67 |
+
│ ├── fetchCatalog("ai-models")
|
| 68 |
+
│ ├── fetchCatalog("dedupe-strategies")
|
| 69 |
+
│ ├── fetchCatalog("event-sources")
|
| 70 |
+
│ ├── fetchCatalog("event-outcomes")
|
| 71 |
+
│ ├── fetchCatalog("audit-actions")
|
| 72 |
+
│ ├── fetchCatalog("contact-fields")
|
| 73 |
+
│ ├── fetchCatalog("automation-trigger-types")
|
| 74 |
+
│ └── fetchCatalog("automation-node-types")
|
| 75 |
+
│
|
| 76 |
+
└── useLookups() → { timezones, languages, aiModels, ... loading }
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
Admin pages (separate layout) use `useCatalog()` hook directly.
|
| 80 |
+
|
| 81 |
+
## Hardcoded Dropdowns Replaced
|
| 82 |
+
|
| 83 |
+
| File | Before | After |
|
| 84 |
+
|---|---|---|
|
| 85 |
+
| `settings/page.tsx` | 6 inline timezone options | `lookups.timezones` |
|
| 86 |
+
| `settings/page.tsx` | 6 inline language options | `lookups.languages` |
|
| 87 |
+
| `settings/page.tsx` | 3 inline AI model options | `lookups.aiModels` |
|
| 88 |
+
| `logs/page.tsx` | 11 inline action options | `lookups.auditActions` |
|
| 89 |
+
| `logs/page.tsx` | 2 inline outcome options | `lookups.eventOutcomes` |
|
| 90 |
+
| `zoho/mapping/page.tsx` | 6 hardcoded LEADPILOT_FIELDS | `lookups.contactFields` |
|
| 91 |
+
| `zoho/mapping/page.tsx` | 3 inline dedupe strategies | `lookups.dedupeStrategies` |
|
| 92 |
+
| `admin/runtime-events/page.tsx` | 6 inline source options | `useCatalog("event-sources")` |
|
| 93 |
+
| `admin/runtime-events/page.tsx` | 3 inline outcome options | `useCatalog("event-outcomes")` |
|
| 94 |
+
| `admin/system-settings/page.tsx` | 4 hardcoded plan tiers | `useCatalog("plans")` |
|
| 95 |
+
|
| 96 |
+
## How to Test
|
| 97 |
+
|
| 98 |
+
1. **Backend tests**: `cd backend && rm -f test.db && python3 -m pytest tests/ -v`
|
| 99 |
+
2. **Frontend build**: `cd frontend && npm run build`
|
| 100 |
+
3. **Manual — Profile**: Settings → Profile tab → edit name → save → sidebar updates
|
| 101 |
+
4. **Manual — Dropdowns**: Settings → General → timezone dropdown shows catalog values
|
| 102 |
+
5. **Manual — Logs**: Activity Logs → action filter populated from catalog
|
| 103 |
+
6. **Manual — API**: `curl /api/v1/catalog/timezones` returns list with key/label
|
| 104 |
+
7. **Manual — Profile API**: `curl -X PATCH /api/v1/auth/me -d '{"full_name":"Test"}' -H 'Authorization: Bearer ...'`
|
docs/missions/mission_21_baseline.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Mission 21 — Pre-Mission Baseline
|
| 2 |
+
|
| 3 |
+
Captured before any Mission 21 changes.
|
| 4 |
+
|
| 5 |
+
## Hardcoded Dropdowns
|
| 6 |
+
|
| 7 |
+
| File | Values |
|
| 8 |
+
|---|---|
|
| 9 |
+
| `settings/page.tsx` | Timezones (6 hardcoded), Languages (6), AI Models (3) |
|
| 10 |
+
| `automations/new/page.tsx` | TRIGGER_CARDS array (3 hardcoded triggers) |
|
| 11 |
+
| `zoho/mapping/page.tsx` | LEADPILOT_FIELDS (6 contact fields), Dedupe strategies (3), Fallback Zoho fields |
|
| 12 |
+
| `admin/system-settings/page.tsx` | Plan tiers: free, starter, growth, enterprise |
|
| 13 |
+
| `admin/runtime-events/page.tsx` | Event sources (6), Event outcomes (3) |
|
| 14 |
+
| `logs/page.tsx` | Action filter (11 types), Outcome filter (2) |
|
| 15 |
+
|
| 16 |
+
## User Profile
|
| 17 |
+
|
| 18 |
+
- `GET /auth/me` existed — returns user data
|
| 19 |
+
- `PATCH /auth/me` did NOT exist — no way to update profile
|
| 20 |
+
- No `ProfileUpdate` schema
|
| 21 |
+
|
| 22 |
+
## Sidebar
|
| 23 |
+
|
| 24 |
+
- Fetched `/auth/me` but showed `userName || "Loading..."` with no initials fallback
|
| 25 |
+
- No role display
|
| 26 |
+
|
| 27 |
+
## Settings Page
|
| 28 |
+
|
| 29 |
+
- Workspace settings only (5 tabs: General, Messaging, AI, Automation, Notifications)
|
| 30 |
+
- No Profile section
|
| 31 |
+
- All dropdowns hardcoded with inline arrays
|
| 32 |
+
|
| 33 |
+
## Catalog Registry (Mission 19)
|
| 34 |
+
|
| 35 |
+
8 existing catalogs:
|
| 36 |
+
- integration-providers, automation-node-types, automation-trigger-types
|
| 37 |
+
- conversation-statuses, message-delivery-statuses
|
| 38 |
+
- workspace-roles, admin-roles
|
| 39 |
+
- plans, modules (DB-backed)
|
| 40 |
+
|
| 41 |
+
No lookup catalogs for: timezones, languages, AI models, dedupe strategies, event sources, event outcomes, audit actions, contact fields.
|
| 42 |
+
|
| 43 |
+
## Frontend Data Layer
|
| 44 |
+
|
| 45 |
+
- `useCatalog()` hook existed in `catalog.ts` for individual catalog fetching
|
| 46 |
+
- No `LookupsProvider` — no bulk preload of lookups
|
| 47 |
+
- `fetchCatalog` was a private function (not exported)
|
| 48 |
+
|
| 49 |
+
## Test Count
|
| 50 |
+
|
| 51 |
+
118 tests across 20 files, all passing.
|
frontend/src/app/(admin)/admin/runtime-events/page.tsx
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
|
| 3 |
import { useEffect, useState, useCallback } from "react";
|
| 4 |
import { adminApi, RuntimeEventEntry } from "@/lib/admin-api";
|
|
|
|
| 5 |
import { Loader2, ChevronLeft, ChevronRight, ChevronDown, ChevronUp, Copy, Check, AlertCircle, CheckCircle, MinusCircle } from "lucide-react";
|
| 6 |
|
| 7 |
function formatDate(iso: string) {
|
|
@@ -70,6 +71,8 @@ function CopyButton({ text }: { text: string }) {
|
|
| 70 |
}
|
| 71 |
|
| 72 |
export default function AdminRuntimeEventsPage() {
|
|
|
|
|
|
|
| 73 |
const [events, setEvents] = useState<RuntimeEventEntry[]>([]);
|
| 74 |
const [total, setTotal] = useState(0);
|
| 75 |
const [loading, setLoading] = useState(true);
|
|
@@ -126,12 +129,9 @@ export default function AdminRuntimeEventsPage() {
|
|
| 126 |
className="bg-slate-800 border border-slate-700 rounded-lg text-sm text-white px-3 py-2 focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500"
|
| 127 |
>
|
| 128 |
<option value="">All Sources</option>
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
<option value="email">Email</option>
|
| 133 |
-
<option value="zoho">Zoho</option>
|
| 134 |
-
<option value="inbox">Inbox</option>
|
| 135 |
</select>
|
| 136 |
<input
|
| 137 |
type="text"
|
|
@@ -146,9 +146,9 @@ export default function AdminRuntimeEventsPage() {
|
|
| 146 |
className="bg-slate-800 border border-slate-700 rounded-lg text-sm text-white px-3 py-2 focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500"
|
| 147 |
>
|
| 148 |
<option value="">All Outcomes</option>
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
</select>
|
| 153 |
<input
|
| 154 |
type="text"
|
|
|
|
| 2 |
|
| 3 |
import { useEffect, useState, useCallback } from "react";
|
| 4 |
import { adminApi, RuntimeEventEntry } from "@/lib/admin-api";
|
| 5 |
+
import { useCatalog, type CatalogEntry } from "@/lib/catalog";
|
| 6 |
import { Loader2, ChevronLeft, ChevronRight, ChevronDown, ChevronUp, Copy, Check, AlertCircle, CheckCircle, MinusCircle } from "lucide-react";
|
| 7 |
|
| 8 |
function formatDate(iso: string) {
|
|
|
|
| 71 |
}
|
| 72 |
|
| 73 |
export default function AdminRuntimeEventsPage() {
|
| 74 |
+
const { data: eventSources } = useCatalog("event-sources");
|
| 75 |
+
const { data: eventOutcomes } = useCatalog("event-outcomes");
|
| 76 |
const [events, setEvents] = useState<RuntimeEventEntry[]>([]);
|
| 77 |
const [total, setTotal] = useState(0);
|
| 78 |
const [loading, setLoading] = useState(true);
|
|
|
|
| 129 |
className="bg-slate-800 border border-slate-700 rounded-lg text-sm text-white px-3 py-2 focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500"
|
| 130 |
>
|
| 131 |
<option value="">All Sources</option>
|
| 132 |
+
{(eventSources || []).map((s) => (
|
| 133 |
+
<option key={s.key} value={s.key}>{s.label}</option>
|
| 134 |
+
))}
|
|
|
|
|
|
|
|
|
|
| 135 |
</select>
|
| 136 |
<input
|
| 137 |
type="text"
|
|
|
|
| 146 |
className="bg-slate-800 border border-slate-700 rounded-lg text-sm text-white px-3 py-2 focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500"
|
| 147 |
>
|
| 148 |
<option value="">All Outcomes</option>
|
| 149 |
+
{(eventOutcomes || []).map((o) => (
|
| 150 |
+
<option key={o.key} value={o.key}>{o.label}</option>
|
| 151 |
+
))}
|
| 152 |
</select>
|
| 153 |
<input
|
| 154 |
type="text"
|
frontend/src/app/(admin)/admin/system-settings/page.tsx
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
|
| 3 |
import { useEffect, useState } from "react";
|
| 4 |
import { getSystemSettings, patchSystemSettings } from "@/lib/admin-api";
|
|
|
|
| 5 |
import {
|
| 6 |
Settings,
|
| 7 |
Loader2,
|
|
@@ -14,6 +15,7 @@ import {
|
|
| 14 |
type SettingsData = Record<string, any>;
|
| 15 |
|
| 16 |
export default function SystemSettingsPage() {
|
|
|
|
| 17 |
const [settings, setSettings] = useState<SettingsData | null>(null);
|
| 18 |
const [version, setVersion] = useState(0);
|
| 19 |
const [loading, setLoading] = useState(true);
|
|
@@ -154,8 +156,8 @@ export default function SystemSettingsPage() {
|
|
| 154 |
onChange={(e) => updateNested("defaults", "default_workspace_plan", e.target.value)}
|
| 155 |
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-violet-500/50"
|
| 156 |
>
|
| 157 |
-
{
|
| 158 |
-
<option key={p} value={p}>{p.
|
| 159 |
))}
|
| 160 |
</select>
|
| 161 |
</div>
|
|
|
|
| 2 |
|
| 3 |
import { useEffect, useState } from "react";
|
| 4 |
import { getSystemSettings, patchSystemSettings } from "@/lib/admin-api";
|
| 5 |
+
import { useCatalog, type CatalogEntry, type PlanCatalogEntry } from "@/lib/catalog";
|
| 6 |
import {
|
| 7 |
Settings,
|
| 8 |
Loader2,
|
|
|
|
| 15 |
type SettingsData = Record<string, any>;
|
| 16 |
|
| 17 |
export default function SystemSettingsPage() {
|
| 18 |
+
const { data: plans } = useCatalog<PlanCatalogEntry[]>("plans");
|
| 19 |
const [settings, setSettings] = useState<SettingsData | null>(null);
|
| 20 |
const [version, setVersion] = useState(0);
|
| 21 |
const [loading, setLoading] = useState(true);
|
|
|
|
| 156 |
onChange={(e) => updateNested("defaults", "default_workspace_plan", e.target.value)}
|
| 157 |
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-violet-500/50"
|
| 158 |
>
|
| 159 |
+
{(plans || []).map((p) => (
|
| 160 |
+
<option key={p.name} value={p.name}>{p.display_name}</option>
|
| 161 |
))}
|
| 162 |
</select>
|
| 163 |
</div>
|
frontend/src/app/(dashboard)/integrations/zoho/mapping/page.tsx
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
| 15 |
import Link from "next/link";
|
| 16 |
import { cn } from "@/lib/utils";
|
| 17 |
import { apiClient } from "@/lib/api";
|
|
|
|
| 18 |
import { toast } from "sonner";
|
| 19 |
|
| 20 |
interface ZohoField {
|
|
@@ -29,17 +30,9 @@ interface MappingConfig {
|
|
| 29 |
field_mappings: Record<string, string>;
|
| 30 |
}
|
| 31 |
|
| 32 |
-
const LEADPILOT_FIELDS = [
|
| 33 |
-
{ key: "first_name", label: "First Name", required: true },
|
| 34 |
-
{ key: "last_name", label: "Last Name", required: true },
|
| 35 |
-
{ key: "email", label: "Email", required: false },
|
| 36 |
-
{ key: "phone", label: "Phone", required: false },
|
| 37 |
-
{ key: "company", label: "Company", required: false },
|
| 38 |
-
{ key: "description", label: "Description / Notes", required: false },
|
| 39 |
-
];
|
| 40 |
-
|
| 41 |
export default function ZohoMappingPage() {
|
| 42 |
const router = useRouter();
|
|
|
|
| 43 |
const [isLoading, setIsLoading] = useState(true);
|
| 44 |
const [isSaving, setIsSaving] = useState(false);
|
| 45 |
const [zohoFields, setZohoFields] = useState<ZohoField[]>([]);
|
|
@@ -183,9 +176,9 @@ export default function ZohoMappingPage() {
|
|
| 183 |
value={config.dedupe_strategy}
|
| 184 |
onChange={(e) => setConfig({ ...config, dedupe_strategy: e.target.value })}
|
| 185 |
>
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
</select>
|
| 190 |
<p className="text-[10px] text-slate-400">
|
| 191 |
{config.dedupe_strategy === "EMAIL_OR_PHONE"
|
|
@@ -217,7 +210,7 @@ export default function ZohoMappingPage() {
|
|
| 217 |
</div>
|
| 218 |
|
| 219 |
<div className="divide-y divide-border">
|
| 220 |
-
{
|
| 221 |
<div key={field.key} className="p-4 flex items-center gap-4 hover:bg-slate-50/50 transition-colors">
|
| 222 |
<div className="w-1/3">
|
| 223 |
<label className="text-sm font-medium text-slate-700 flex items-center gap-1.5">
|
|
|
|
| 15 |
import Link from "next/link";
|
| 16 |
import { cn } from "@/lib/utils";
|
| 17 |
import { apiClient } from "@/lib/api";
|
| 18 |
+
import { useLookups } from "@/lib/lookups";
|
| 19 |
import { toast } from "sonner";
|
| 20 |
|
| 21 |
interface ZohoField {
|
|
|
|
| 30 |
field_mappings: Record<string, string>;
|
| 31 |
}
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
export default function ZohoMappingPage() {
|
| 34 |
const router = useRouter();
|
| 35 |
+
const lookups = useLookups();
|
| 36 |
const [isLoading, setIsLoading] = useState(true);
|
| 37 |
const [isSaving, setIsSaving] = useState(false);
|
| 38 |
const [zohoFields, setZohoFields] = useState<ZohoField[]>([]);
|
|
|
|
| 176 |
value={config.dedupe_strategy}
|
| 177 |
onChange={(e) => setConfig({ ...config, dedupe_strategy: e.target.value })}
|
| 178 |
>
|
| 179 |
+
{lookups.dedupeStrategies.map((s) => (
|
| 180 |
+
<option key={s.key} value={s.key}>{s.label}</option>
|
| 181 |
+
))}
|
| 182 |
</select>
|
| 183 |
<p className="text-[10px] text-slate-400">
|
| 184 |
{config.dedupe_strategy === "EMAIL_OR_PHONE"
|
|
|
|
| 210 |
</div>
|
| 211 |
|
| 212 |
<div className="divide-y divide-border">
|
| 213 |
+
{lookups.contactFields.map((field) => (
|
| 214 |
<div key={field.key} className="p-4 flex items-center gap-4 hover:bg-slate-50/50 transition-colors">
|
| 215 |
<div className="w-1/3">
|
| 216 |
<label className="text-sm font-medium text-slate-700 flex items-center gap-1.5">
|
frontend/src/app/(dashboard)/logs/page.tsx
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
|
| 3 |
import { useEffect, useState, useCallback } from "react";
|
| 4 |
import { apiClient } from "@/lib/api";
|
|
|
|
| 5 |
import { Loader2, AlertCircle, ChevronLeft, ChevronRight, Search, Filter } from "lucide-react";
|
| 6 |
|
| 7 |
interface AuditEntry {
|
|
@@ -67,6 +68,7 @@ function ActionBadge({ action }: { action: string }) {
|
|
| 67 |
const PAGE_SIZE = 25;
|
| 68 |
|
| 69 |
export default function LogsPage() {
|
|
|
|
| 70 |
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
| 71 |
const [total, setTotal] = useState(0);
|
| 72 |
const [page, setPage] = useState(0);
|
|
@@ -116,18 +118,9 @@ export default function LogsPage() {
|
|
| 116 |
className="px-3 py-2 rounded-lg border border-slate-200 bg-white text-sm text-slate-700 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
| 117 |
>
|
| 118 |
<option value="">All Actions</option>
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
<option value="workspace_role_change">Role Change</option>
|
| 123 |
-
<option value="automation_create">Automation Create</option>
|
| 124 |
-
<option value="automation_publish">Automation Publish</option>
|
| 125 |
-
<option value="integration_connect">Integration Connect</option>
|
| 126 |
-
<option value="integration_disconnect">Integration Disconnect</option>
|
| 127 |
-
<option value="dispatch_trigger">Dispatch Trigger</option>
|
| 128 |
-
<option value="inbox_reply">Inbox Reply</option>
|
| 129 |
-
<option value="inbox_status_change">Inbox Status Change</option>
|
| 130 |
-
<option value="update_workspace_settings">Settings Update</option>
|
| 131 |
</select>
|
| 132 |
<select
|
| 133 |
value={outcomeFilter}
|
|
@@ -135,8 +128,9 @@ export default function LogsPage() {
|
|
| 135 |
className="px-3 py-2 rounded-lg border border-slate-200 bg-white text-sm text-slate-700 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
| 136 |
>
|
| 137 |
<option value="">All Outcomes</option>
|
| 138 |
-
|
| 139 |
-
|
|
|
|
| 140 |
</select>
|
| 141 |
</div>
|
| 142 |
|
|
|
|
| 2 |
|
| 3 |
import { useEffect, useState, useCallback } from "react";
|
| 4 |
import { apiClient } from "@/lib/api";
|
| 5 |
+
import { useLookups } from "@/lib/lookups";
|
| 6 |
import { Loader2, AlertCircle, ChevronLeft, ChevronRight, Search, Filter } from "lucide-react";
|
| 7 |
|
| 8 |
interface AuditEntry {
|
|
|
|
| 68 |
const PAGE_SIZE = 25;
|
| 69 |
|
| 70 |
export default function LogsPage() {
|
| 71 |
+
const lookups = useLookups();
|
| 72 |
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
| 73 |
const [total, setTotal] = useState(0);
|
| 74 |
const [page, setPage] = useState(0);
|
|
|
|
| 118 |
className="px-3 py-2 rounded-lg border border-slate-200 bg-white text-sm text-slate-700 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
| 119 |
>
|
| 120 |
<option value="">All Actions</option>
|
| 121 |
+
{lookups.auditActions.map((a) => (
|
| 122 |
+
<option key={a.key} value={a.key}>{a.label}</option>
|
| 123 |
+
))}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
</select>
|
| 125 |
<select
|
| 126 |
value={outcomeFilter}
|
|
|
|
| 128 |
className="px-3 py-2 rounded-lg border border-slate-200 bg-white text-sm text-slate-700 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
| 129 |
>
|
| 130 |
<option value="">All Outcomes</option>
|
| 131 |
+
{lookups.eventOutcomes.map((o) => (
|
| 132 |
+
<option key={o.key} value={o.key}>{o.label}</option>
|
| 133 |
+
))}
|
| 134 |
</select>
|
| 135 |
</div>
|
| 136 |
|
frontend/src/app/(dashboard)/settings/page.tsx
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
|
| 3 |
import { useEffect, useState } from "react";
|
| 4 |
import { apiClient } from "@/lib/api";
|
|
|
|
| 5 |
import {
|
| 6 |
Settings,
|
| 7 |
Globe,
|
|
@@ -11,10 +12,24 @@ import {
|
|
| 11 |
Bell,
|
| 12 |
Loader2,
|
| 13 |
Save,
|
|
|
|
|
|
|
| 14 |
} from "lucide-react";
|
| 15 |
|
| 16 |
type SettingsData = Record<string, any>;
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
const TABS = [
|
| 19 |
{ key: "general", label: "General", icon: Globe },
|
| 20 |
{ key: "messaging", label: "Messaging", icon: MessageSquare },
|
|
@@ -23,20 +38,38 @@ const TABS = [
|
|
| 23 |
{ key: "notifications", label: "Notifications", icon: Bell },
|
| 24 |
] as const;
|
| 25 |
|
| 26 |
-
export default function
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
const [settings, setSettings] = useState<SettingsData | null>(null);
|
| 28 |
const [version, setVersion] = useState(0);
|
| 29 |
const [activeTab, setActiveTab] = useState<string>("general");
|
| 30 |
-
const [loading, setLoading] = useState(true);
|
| 31 |
const [saving, setSaving] = useState(false);
|
| 32 |
const [dirty, setDirty] = useState<SettingsData>({});
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
const [toast, setToast] = useState<{ type: "success" | "error"; msg: string } | null>(null);
|
| 34 |
|
| 35 |
useEffect(() => {
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
}
|
| 41 |
setLoading(false);
|
| 42 |
});
|
|
@@ -47,14 +80,29 @@ export default function WorkspaceSettingsPage() {
|
|
| 47 |
setTimeout(() => setToast(null), 3000);
|
| 48 |
};
|
| 49 |
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
setSettings((prev) => {
|
| 52 |
if (!prev) return prev;
|
| 53 |
-
return { ...prev, [
|
| 54 |
});
|
| 55 |
setDirty((prev) => ({
|
| 56 |
...prev,
|
| 57 |
-
[
|
| 58 |
}));
|
| 59 |
};
|
| 60 |
|
|
@@ -81,11 +129,8 @@ export default function WorkspaceSettingsPage() {
|
|
| 81 |
);
|
| 82 |
}
|
| 83 |
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
}
|
| 87 |
-
|
| 88 |
-
const s = settings[activeTab] || {};
|
| 89 |
|
| 90 |
return (
|
| 91 |
<div className="space-y-8 pb-10">
|
|
@@ -93,18 +138,30 @@ export default function WorkspaceSettingsPage() {
|
|
| 93 |
<div className="flex items-center justify-between">
|
| 94 |
<div>
|
| 95 |
<h1 className="text-2xl font-bold tracking-tight text-slate-900 flex items-center gap-2">
|
| 96 |
-
<Settings className="w-6 h-6 text-teal-700" />
|
| 97 |
</h1>
|
| 98 |
-
<p className="text-slate-500 text-sm mt-1">Manage your
|
| 99 |
</div>
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
</div>
|
| 109 |
|
| 110 |
{/* Toast */}
|
|
@@ -118,62 +175,116 @@ export default function WorkspaceSettingsPage() {
|
|
| 118 |
</div>
|
| 119 |
)}
|
| 120 |
|
| 121 |
-
{/*
|
| 122 |
-
<div className="flex
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
</div>
|
| 138 |
|
| 139 |
-
{/*
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
<>
|
| 143 |
-
<
|
| 144 |
-
<
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
<>
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
<
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
</div>
|
| 178 |
);
|
| 179 |
}
|
|
@@ -195,7 +306,7 @@ function Field({ label, type, value, onChange, ...props }: { label: string; type
|
|
| 195 |
);
|
| 196 |
}
|
| 197 |
|
| 198 |
-
function
|
| 199 |
return (
|
| 200 |
<div>
|
| 201 |
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1.5">{label}</label>
|
|
@@ -204,8 +315,8 @@ function SelectField({ label, value, options, onChange }: { label: string; value
|
|
| 204 |
onChange={(e) => onChange(e.target.value)}
|
| 205 |
className="w-full p-2.5 bg-slate-50 border border-slate-200 rounded-lg text-sm text-slate-900 focus:ring-2 focus:ring-teal-500/20 focus:border-teal-500 outline-none transition-all"
|
| 206 |
>
|
| 207 |
-
{
|
| 208 |
-
<option key={
|
| 209 |
))}
|
| 210 |
</select>
|
| 211 |
</div>
|
|
|
|
| 2 |
|
| 3 |
import { useEffect, useState } from "react";
|
| 4 |
import { apiClient } from "@/lib/api";
|
| 5 |
+
import { useLookups } from "@/lib/lookups";
|
| 6 |
import {
|
| 7 |
Settings,
|
| 8 |
Globe,
|
|
|
|
| 12 |
Bell,
|
| 13 |
Loader2,
|
| 14 |
Save,
|
| 15 |
+
User,
|
| 16 |
+
ShieldCheck,
|
| 17 |
} from "lucide-react";
|
| 18 |
|
| 19 |
type SettingsData = Record<string, any>;
|
| 20 |
|
| 21 |
+
interface UserProfile {
|
| 22 |
+
id: string;
|
| 23 |
+
email: string;
|
| 24 |
+
full_name: string | null;
|
| 25 |
+
is_active: boolean;
|
| 26 |
+
is_superuser: boolean;
|
| 27 |
+
email_verified_at: string | null;
|
| 28 |
+
requires_email_verification: boolean;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
type Section = "profile" | "workspace";
|
| 32 |
+
|
| 33 |
const TABS = [
|
| 34 |
{ key: "general", label: "General", icon: Globe },
|
| 35 |
{ key: "messaging", label: "Messaging", icon: MessageSquare },
|
|
|
|
| 38 |
{ key: "notifications", label: "Notifications", icon: Bell },
|
| 39 |
] as const;
|
| 40 |
|
| 41 |
+
export default function SettingsPage() {
|
| 42 |
+
const lookups = useLookups();
|
| 43 |
+
|
| 44 |
+
// Profile state
|
| 45 |
+
const [profile, setProfile] = useState<UserProfile | null>(null);
|
| 46 |
+
const [profileName, setProfileName] = useState("");
|
| 47 |
+
const [savingProfile, setSavingProfile] = useState(false);
|
| 48 |
+
|
| 49 |
+
// Workspace state
|
| 50 |
const [settings, setSettings] = useState<SettingsData | null>(null);
|
| 51 |
const [version, setVersion] = useState(0);
|
| 52 |
const [activeTab, setActiveTab] = useState<string>("general");
|
|
|
|
| 53 |
const [saving, setSaving] = useState(false);
|
| 54 |
const [dirty, setDirty] = useState<SettingsData>({});
|
| 55 |
+
|
| 56 |
+
// Shared
|
| 57 |
+
const [section, setSection] = useState<Section>("profile");
|
| 58 |
+
const [loading, setLoading] = useState(true);
|
| 59 |
const [toast, setToast] = useState<{ type: "success" | "error"; msg: string } | null>(null);
|
| 60 |
|
| 61 |
useEffect(() => {
|
| 62 |
+
Promise.all([
|
| 63 |
+
apiClient.get("/auth/me"),
|
| 64 |
+
apiClient.get("/settings/workspace"),
|
| 65 |
+
]).then(([meRes, wsRes]) => {
|
| 66 |
+
if (meRes.success && meRes.data) {
|
| 67 |
+
setProfile(meRes.data);
|
| 68 |
+
setProfileName(meRes.data.full_name || "");
|
| 69 |
+
}
|
| 70 |
+
if (wsRes.success && wsRes.data) {
|
| 71 |
+
setSettings(wsRes.data.settings);
|
| 72 |
+
setVersion(wsRes.data.version);
|
| 73 |
}
|
| 74 |
setLoading(false);
|
| 75 |
});
|
|
|
|
| 80 |
setTimeout(() => setToast(null), 3000);
|
| 81 |
};
|
| 82 |
|
| 83 |
+
// Profile save
|
| 84 |
+
const handleSaveProfile = async () => {
|
| 85 |
+
setSavingProfile(true);
|
| 86 |
+
const res = await apiClient.patch("/auth/me", { full_name: profileName.trim() || null });
|
| 87 |
+
setSavingProfile(false);
|
| 88 |
+
if (res.success && res.data) {
|
| 89 |
+
setProfile(res.data);
|
| 90 |
+
setProfileName(res.data.full_name || "");
|
| 91 |
+
showToast("success", "Profile updated successfully");
|
| 92 |
+
} else {
|
| 93 |
+
showToast("error", res.error || "Failed to update profile");
|
| 94 |
+
}
|
| 95 |
+
};
|
| 96 |
+
|
| 97 |
+
// Workspace settings
|
| 98 |
+
const updateField = (sec: string, key: string, value: any) => {
|
| 99 |
setSettings((prev) => {
|
| 100 |
if (!prev) return prev;
|
| 101 |
+
return { ...prev, [sec]: { ...prev[sec], [key]: value } };
|
| 102 |
});
|
| 103 |
setDirty((prev) => ({
|
| 104 |
...prev,
|
| 105 |
+
[sec]: { ...(prev[sec] || {}), [key]: value },
|
| 106 |
}));
|
| 107 |
};
|
| 108 |
|
|
|
|
| 129 |
);
|
| 130 |
}
|
| 131 |
|
| 132 |
+
const s = settings ? (settings[activeTab] || {}) : {};
|
| 133 |
+
const profileDirty = profileName.trim() !== (profile?.full_name || "");
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
return (
|
| 136 |
<div className="space-y-8 pb-10">
|
|
|
|
| 138 |
<div className="flex items-center justify-between">
|
| 139 |
<div>
|
| 140 |
<h1 className="text-2xl font-bold tracking-tight text-slate-900 flex items-center gap-2">
|
| 141 |
+
<Settings className="w-6 h-6 text-teal-700" /> Settings
|
| 142 |
</h1>
|
| 143 |
+
<p className="text-slate-500 text-sm mt-1">Manage your profile and workspace configuration</p>
|
| 144 |
</div>
|
| 145 |
+
{section === "workspace" && (
|
| 146 |
+
<button
|
| 147 |
+
onClick={handleSave}
|
| 148 |
+
disabled={saving || Object.keys(dirty).length === 0}
|
| 149 |
+
className="flex items-center gap-2 px-5 py-2.5 bg-teal-700 text-white rounded-lg hover:bg-teal-800 disabled:opacity-50 disabled:cursor-not-allowed text-sm font-bold transition-all"
|
| 150 |
+
>
|
| 151 |
+
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
| 152 |
+
Save Changes
|
| 153 |
+
</button>
|
| 154 |
+
)}
|
| 155 |
+
{section === "profile" && (
|
| 156 |
+
<button
|
| 157 |
+
onClick={handleSaveProfile}
|
| 158 |
+
disabled={savingProfile || !profileDirty}
|
| 159 |
+
className="flex items-center gap-2 px-5 py-2.5 bg-teal-700 text-white rounded-lg hover:bg-teal-800 disabled:opacity-50 disabled:cursor-not-allowed text-sm font-bold transition-all"
|
| 160 |
+
>
|
| 161 |
+
{savingProfile ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
| 162 |
+
Save Profile
|
| 163 |
+
</button>
|
| 164 |
+
)}
|
| 165 |
</div>
|
| 166 |
|
| 167 |
{/* Toast */}
|
|
|
|
| 175 |
</div>
|
| 176 |
)}
|
| 177 |
|
| 178 |
+
{/* Section Toggle */}
|
| 179 |
+
<div className="flex gap-2">
|
| 180 |
+
<button
|
| 181 |
+
onClick={() => setSection("profile")}
|
| 182 |
+
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-bold transition-all ${
|
| 183 |
+
section === "profile"
|
| 184 |
+
? "bg-teal-700 text-white shadow-sm"
|
| 185 |
+
: "bg-slate-100 text-slate-600 hover:bg-slate-200"
|
| 186 |
+
}`}
|
| 187 |
+
>
|
| 188 |
+
<User className="w-4 h-4" /> Profile
|
| 189 |
+
</button>
|
| 190 |
+
<button
|
| 191 |
+
onClick={() => setSection("workspace")}
|
| 192 |
+
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-bold transition-all ${
|
| 193 |
+
section === "workspace"
|
| 194 |
+
? "bg-teal-700 text-white shadow-sm"
|
| 195 |
+
: "bg-slate-100 text-slate-600 hover:bg-slate-200"
|
| 196 |
+
}`}
|
| 197 |
+
>
|
| 198 |
+
<ShieldCheck className="w-4 h-4" /> Workspace
|
| 199 |
+
{version > 0 && <span className="text-xs opacity-70">v{version}</span>}
|
| 200 |
+
</button>
|
| 201 |
</div>
|
| 202 |
|
| 203 |
+
{/* Profile Section */}
|
| 204 |
+
{section === "profile" && profile && (
|
| 205 |
+
<div className="bg-white p-6 md:p-8 rounded-xl border border-slate-100 shadow-sm space-y-6">
|
| 206 |
+
<div>
|
| 207 |
+
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1.5">Email</label>
|
| 208 |
+
<input
|
| 209 |
+
type="text"
|
| 210 |
+
value={profile.email}
|
| 211 |
+
disabled
|
| 212 |
+
className="w-full p-2.5 bg-slate-100 border border-slate-200 rounded-lg text-sm text-slate-500 cursor-not-allowed"
|
| 213 |
+
/>
|
| 214 |
+
</div>
|
| 215 |
+
<Field label="Full Name" type="text" value={profileName} onChange={setProfileName} placeholder="Enter your full name" />
|
| 216 |
+
<div className="flex items-center gap-3">
|
| 217 |
+
<span className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Email Verification</span>
|
| 218 |
+
{profile.email_verified_at ? (
|
| 219 |
+
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-50 text-emerald-700 border border-emerald-200">Verified</span>
|
| 220 |
+
) : (
|
| 221 |
+
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-bold bg-amber-50 text-amber-700 border border-amber-200">Unverified</span>
|
| 222 |
+
)}
|
| 223 |
+
</div>
|
| 224 |
+
</div>
|
| 225 |
+
)}
|
| 226 |
+
|
| 227 |
+
{/* Workspace Section */}
|
| 228 |
+
{section === "workspace" && settings && (
|
| 229 |
+
<>
|
| 230 |
+
{/* Tabs */}
|
| 231 |
+
<div className="flex border-b border-slate-100 bg-slate-50/50 rounded-t-xl">
|
| 232 |
+
{TABS.map((tab) => (
|
| 233 |
+
<button
|
| 234 |
+
key={tab.key}
|
| 235 |
+
onClick={() => setActiveTab(tab.key)}
|
| 236 |
+
className={`flex items-center gap-2 px-6 py-3.5 text-sm font-bold border-b-2 transition-all ${
|
| 237 |
+
activeTab === tab.key
|
| 238 |
+
? "border-teal-600 text-teal-700 bg-white"
|
| 239 |
+
: "border-transparent text-slate-400 hover:text-slate-600"
|
| 240 |
+
}`}
|
| 241 |
+
>
|
| 242 |
+
<tab.icon className="w-4 h-4" />
|
| 243 |
+
{tab.label}
|
| 244 |
+
</button>
|
| 245 |
+
))}
|
| 246 |
+
</div>
|
| 247 |
+
|
| 248 |
+
{/* Fields */}
|
| 249 |
+
<div className="bg-white p-6 md:p-8 rounded-xl border border-slate-100 shadow-sm space-y-6">
|
| 250 |
+
{activeTab === "general" && (
|
| 251 |
+
<>
|
| 252 |
+
<Field label="Name Override" type="text" value={s.name_override ?? ""} onChange={(v) => updateField("general", "name_override", v || null)} placeholder="Leave empty to use workspace name" />
|
| 253 |
+
<Field label="Logo URL" type="text" value={s.logo_url ?? ""} onChange={(v) => updateField("general", "logo_url", v || null)} placeholder="https://example.com/logo.png" />
|
| 254 |
+
<CatalogSelectField label="Timezone" value={s.timezone} items={lookups.timezones} onChange={(v) => updateField("general", "timezone", v)} />
|
| 255 |
+
<CatalogSelectField label="Default Language" value={s.default_language} items={lookups.languages} onChange={(v) => updateField("general", "default_language", v)} />
|
| 256 |
+
</>
|
| 257 |
+
)}
|
| 258 |
+
{activeTab === "messaging" && (
|
| 259 |
+
<>
|
| 260 |
+
<Field label="Default Reply Delay (seconds)" type="number" value={s.default_reply_delay_seconds} onChange={(v) => updateField("messaging", "default_reply_delay_seconds", Number(v))} />
|
| 261 |
+
<Toggle label="Fallback Message Enabled" description="Send a fallback message when AI cannot generate a response" value={s.fallback_message_enabled} onChange={(v) => updateField("messaging", "fallback_message_enabled", v)} />
|
| 262 |
+
<Toggle label="Auto-Retry Failed Dispatch" description="Automatically retry failed message deliveries with exponential backoff" value={s.auto_retry_failed_dispatch} onChange={(v) => updateField("messaging", "auto_retry_failed_dispatch", v)} />
|
| 263 |
+
</>
|
| 264 |
+
)}
|
| 265 |
+
{activeTab === "ai" && (
|
| 266 |
+
<>
|
| 267 |
+
<CatalogSelectField label="Default Model" value={s.default_model} items={lookups.aiModels} onChange={(v) => updateField("ai", "default_model", v)} />
|
| 268 |
+
<Field label="Temperature" type="number" value={s.temperature} step="0.1" min="0" max="2" onChange={(v) => updateField("ai", "temperature", Number(v))} />
|
| 269 |
+
<Field label="Max Tokens" type="number" value={s.max_tokens} onChange={(v) => updateField("ai", "max_tokens", Number(v))} />
|
| 270 |
+
<Toggle label="Guardrails Enabled" description="Enforce safety guardrails on AI-generated responses" value={s.guardrails_enabled} onChange={(v) => updateField("ai", "guardrails_enabled", v)} />
|
| 271 |
+
</>
|
| 272 |
+
)}
|
| 273 |
+
{activeTab === "automation" && (
|
| 274 |
+
<>
|
| 275 |
+
<Toggle label="Auto-Publish" description="Automatically publish new automation flows" value={s.auto_publish} onChange={(v) => updateField("automation", "auto_publish", v)} />
|
| 276 |
+
<Field label="Draft Expiry (days)" type="number" value={s.draft_expiry_days} onChange={(v) => updateField("automation", "draft_expiry_days", Number(v))} />
|
| 277 |
+
</>
|
| 278 |
+
)}
|
| 279 |
+
{activeTab === "notifications" && (
|
| 280 |
+
<>
|
| 281 |
+
<Toggle label="Email Notifications Enabled" description="Receive email notifications for important workspace events" value={s.email_notifications_enabled} onChange={(v) => updateField("notifications", "email_notifications_enabled", v)} />
|
| 282 |
+
<Field label="Webhook URL" type="text" value={s.webhook_url ?? ""} onChange={(v) => updateField("notifications", "webhook_url", v || null)} placeholder="https://example.com/webhook" />
|
| 283 |
+
</>
|
| 284 |
+
)}
|
| 285 |
+
</div>
|
| 286 |
+
</>
|
| 287 |
+
)}
|
| 288 |
</div>
|
| 289 |
);
|
| 290 |
}
|
|
|
|
| 306 |
);
|
| 307 |
}
|
| 308 |
|
| 309 |
+
function CatalogSelectField({ label, value, items, onChange }: { label: string; value: string; items: { key: string; label: string }[]; onChange: (v: string) => void }) {
|
| 310 |
return (
|
| 311 |
<div>
|
| 312 |
<label className="block text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1.5">{label}</label>
|
|
|
|
| 315 |
onChange={(e) => onChange(e.target.value)}
|
| 316 |
className="w-full p-2.5 bg-slate-50 border border-slate-200 rounded-lg text-sm text-slate-900 focus:ring-2 focus:ring-teal-500/20 focus:border-teal-500 outline-none transition-all"
|
| 317 |
>
|
| 318 |
+
{items.map((item) => (
|
| 319 |
+
<option key={item.key} value={item.key}>{item.label}</option>
|
| 320 |
))}
|
| 321 |
</select>
|
| 322 |
</div>
|
frontend/src/components/AppShell.tsx
CHANGED
|
@@ -3,9 +3,11 @@
|
|
| 3 |
import { Sidebar } from "./Sidebar";
|
| 4 |
import { Header } from "./Header";
|
| 5 |
import { ImpersonationBanner } from "./ImpersonationBanner";
|
|
|
|
| 6 |
|
| 7 |
export function AppShell({ children }: { children: React.ReactNode }) {
|
| 8 |
return (
|
|
|
|
| 9 |
<div className="min-h-screen bg-background">
|
| 10 |
<ImpersonationBanner />
|
| 11 |
<Sidebar />
|
|
@@ -18,5 +20,6 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
|
| 18 |
</main>
|
| 19 |
</div>
|
| 20 |
</div>
|
|
|
|
| 21 |
);
|
| 22 |
}
|
|
|
|
| 3 |
import { Sidebar } from "./Sidebar";
|
| 4 |
import { Header } from "./Header";
|
| 5 |
import { ImpersonationBanner } from "./ImpersonationBanner";
|
| 6 |
+
import { LookupsProvider } from "@/lib/lookups";
|
| 7 |
|
| 8 |
export function AppShell({ children }: { children: React.ReactNode }) {
|
| 9 |
return (
|
| 10 |
+
<LookupsProvider>
|
| 11 |
<div className="min-h-screen bg-background">
|
| 12 |
<ImpersonationBanner />
|
| 13 |
<Sidebar />
|
|
|
|
| 20 |
</main>
|
| 21 |
</div>
|
| 22 |
</div>
|
| 23 |
+
</LookupsProvider>
|
| 24 |
);
|
| 25 |
}
|
frontend/src/components/Sidebar.tsx
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
|
| 3 |
import Link from "next/link";
|
| 4 |
import { usePathname } from "next/navigation";
|
| 5 |
-
import {
|
| 6 |
import {
|
| 7 |
LayoutDashboard,
|
| 8 |
Users,
|
|
@@ -19,6 +19,7 @@ import {
|
|
| 19 |
Landmark,
|
| 20 |
} from "lucide-react";
|
| 21 |
import { cn } from "@/lib/utils";
|
|
|
|
| 22 |
|
| 23 |
const navItems = [
|
| 24 |
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
|
|
@@ -35,9 +36,24 @@ const navItems = [
|
|
| 35 |
{ name: "Agency", href: "/agency", icon: Landmark },
|
| 36 |
];
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
export function Sidebar() {
|
| 39 |
const pathname = usePathname();
|
| 40 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
const handleLogout = () => {
|
| 43 |
const { auth } = require("@/lib/auth");
|
|
@@ -74,11 +90,10 @@ export function Sidebar() {
|
|
| 74 |
<div className="p-4 border-t border-border">
|
| 75 |
<div className="flex items-center gap-3 px-3 py-2">
|
| 76 |
<div className="w-8 h-8 rounded-full bg-slate-200 flex items-center justify-center text-slate-600 text-xs font-bold">
|
| 77 |
-
|
| 78 |
</div>
|
| 79 |
<div className="flex-1 min-w-0">
|
| 80 |
-
<p className="text-xs font-semibold truncate">
|
| 81 |
-
<p className="text-[10px] text-slate-500 truncate">Workspace Owner</p>
|
| 82 |
</div>
|
| 83 |
<button
|
| 84 |
onClick={handleLogout}
|
|
|
|
| 2 |
|
| 3 |
import Link from "next/link";
|
| 4 |
import { usePathname } from "next/navigation";
|
| 5 |
+
import { useEffect, useState } from "react";
|
| 6 |
import {
|
| 7 |
LayoutDashboard,
|
| 8 |
Users,
|
|
|
|
| 19 |
Landmark,
|
| 20 |
} from "lucide-react";
|
| 21 |
import { cn } from "@/lib/utils";
|
| 22 |
+
import { apiClient } from "@/lib/api";
|
| 23 |
|
| 24 |
const navItems = [
|
| 25 |
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
|
|
|
|
| 36 |
{ name: "Agency", href: "/agency", icon: Landmark },
|
| 37 |
];
|
| 38 |
|
| 39 |
+
function getInitials(name: string | null): string {
|
| 40 |
+
if (!name) return "?";
|
| 41 |
+
const parts = name.trim().split(/\s+/);
|
| 42 |
+
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
|
| 43 |
+
return parts[0].substring(0, 2).toUpperCase();
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
export function Sidebar() {
|
| 47 |
const pathname = usePathname();
|
| 48 |
+
const [userName, setUserName] = useState<string | null>(null);
|
| 49 |
+
|
| 50 |
+
useEffect(() => {
|
| 51 |
+
apiClient.get("/auth/me").then((res) => {
|
| 52 |
+
if (res.success && res.data) {
|
| 53 |
+
setUserName(res.data.full_name || res.data.email);
|
| 54 |
+
}
|
| 55 |
+
});
|
| 56 |
+
}, []);
|
| 57 |
|
| 58 |
const handleLogout = () => {
|
| 59 |
const { auth } = require("@/lib/auth");
|
|
|
|
| 90 |
<div className="p-4 border-t border-border">
|
| 91 |
<div className="flex items-center gap-3 px-3 py-2">
|
| 92 |
<div className="w-8 h-8 rounded-full bg-slate-200 flex items-center justify-center text-slate-600 text-xs font-bold">
|
| 93 |
+
{getInitials(userName)}
|
| 94 |
</div>
|
| 95 |
<div className="flex-1 min-w-0">
|
| 96 |
+
<p className="text-xs font-semibold truncate">{userName || "Loading..."}</p>
|
|
|
|
| 97 |
</div>
|
| 98 |
<button
|
| 99 |
onClick={handleLogout}
|
frontend/src/lib/catalog.ts
CHANGED
|
@@ -34,7 +34,15 @@ export type CatalogKey =
|
|
| 34 |
| "automation-node-types"
|
| 35 |
| "automation-trigger-types"
|
| 36 |
| "conversation-statuses"
|
| 37 |
-
| "message-delivery-statuses"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
// ── Cache ────────────────────────────────────────────────────────────
|
| 40 |
|
|
@@ -62,7 +70,7 @@ function setCache<T>(key: string, data: T): void {
|
|
| 62 |
|
| 63 |
// ── Fetch ────────────────────────────────────────────────────────────
|
| 64 |
|
| 65 |
-
async function fetchCatalog<T = CatalogEntry[]>(key: CatalogKey): Promise<T> {
|
| 66 |
const cached = getCached<T>(key);
|
| 67 |
if (cached !== null) return cached;
|
| 68 |
|
|
|
|
| 34 |
| "automation-node-types"
|
| 35 |
| "automation-trigger-types"
|
| 36 |
| "conversation-statuses"
|
| 37 |
+
| "message-delivery-statuses"
|
| 38 |
+
| "timezones"
|
| 39 |
+
| "languages"
|
| 40 |
+
| "ai-models"
|
| 41 |
+
| "dedupe-strategies"
|
| 42 |
+
| "event-sources"
|
| 43 |
+
| "event-outcomes"
|
| 44 |
+
| "audit-actions"
|
| 45 |
+
| "contact-fields";
|
| 46 |
|
| 47 |
// ── Cache ────────────────────────────────────────────────────────────
|
| 48 |
|
|
|
|
| 70 |
|
| 71 |
// ── Fetch ────────────────────────────────────────────────────────────
|
| 72 |
|
| 73 |
+
export async function fetchCatalog<T = CatalogEntry[]>(key: CatalogKey): Promise<T> {
|
| 74 |
const cached = getCached<T>(key);
|
| 75 |
if (cached !== null) return cached;
|
| 76 |
|
frontend/src/lib/lookups.tsx
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* LookupsProvider — Mission 21
|
| 5 |
+
* Fetches all catalog lookups on mount and provides them via React context.
|
| 6 |
+
* Product dashboard pages consume via useLookups() hook.
|
| 7 |
+
*/
|
| 8 |
+
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
| 9 |
+
import { fetchCatalog, type CatalogEntry } from "./catalog";
|
| 10 |
+
|
| 11 |
+
export interface Lookups {
|
| 12 |
+
timezones: CatalogEntry[];
|
| 13 |
+
languages: CatalogEntry[];
|
| 14 |
+
aiModels: CatalogEntry[];
|
| 15 |
+
dedupeStrategies: CatalogEntry[];
|
| 16 |
+
eventSources: CatalogEntry[];
|
| 17 |
+
eventOutcomes: CatalogEntry[];
|
| 18 |
+
auditActions: CatalogEntry[];
|
| 19 |
+
contactFields: CatalogEntry[];
|
| 20 |
+
triggerTypes: CatalogEntry[];
|
| 21 |
+
nodeTypes: CatalogEntry[];
|
| 22 |
+
loading: boolean;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
const defaultLookups: Lookups = {
|
| 26 |
+
timezones: [],
|
| 27 |
+
languages: [],
|
| 28 |
+
aiModels: [],
|
| 29 |
+
dedupeStrategies: [],
|
| 30 |
+
eventSources: [],
|
| 31 |
+
eventOutcomes: [],
|
| 32 |
+
auditActions: [],
|
| 33 |
+
contactFields: [],
|
| 34 |
+
triggerTypes: [],
|
| 35 |
+
nodeTypes: [],
|
| 36 |
+
loading: true,
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
const LookupsContext = createContext<Lookups>(defaultLookups);
|
| 40 |
+
|
| 41 |
+
export function LookupsProvider({ children }: { children: ReactNode }) {
|
| 42 |
+
const [lookups, setLookups] = useState<Lookups>(defaultLookups);
|
| 43 |
+
|
| 44 |
+
useEffect(() => {
|
| 45 |
+
Promise.all([
|
| 46 |
+
fetchCatalog("timezones").catch(() => []),
|
| 47 |
+
fetchCatalog("languages").catch(() => []),
|
| 48 |
+
fetchCatalog("ai-models").catch(() => []),
|
| 49 |
+
fetchCatalog("dedupe-strategies").catch(() => []),
|
| 50 |
+
fetchCatalog("event-sources").catch(() => []),
|
| 51 |
+
fetchCatalog("event-outcomes").catch(() => []),
|
| 52 |
+
fetchCatalog("audit-actions").catch(() => []),
|
| 53 |
+
fetchCatalog("contact-fields").catch(() => []),
|
| 54 |
+
fetchCatalog("automation-trigger-types").catch(() => []),
|
| 55 |
+
fetchCatalog("automation-node-types").catch(() => []),
|
| 56 |
+
]).then(([
|
| 57 |
+
timezones, languages, aiModels, dedupeStrategies,
|
| 58 |
+
eventSources, eventOutcomes, auditActions, contactFields,
|
| 59 |
+
triggerTypes, nodeTypes,
|
| 60 |
+
]) => {
|
| 61 |
+
setLookups({
|
| 62 |
+
timezones,
|
| 63 |
+
languages,
|
| 64 |
+
aiModels,
|
| 65 |
+
dedupeStrategies,
|
| 66 |
+
eventSources,
|
| 67 |
+
eventOutcomes,
|
| 68 |
+
auditActions,
|
| 69 |
+
contactFields,
|
| 70 |
+
triggerTypes,
|
| 71 |
+
nodeTypes,
|
| 72 |
+
loading: false,
|
| 73 |
+
});
|
| 74 |
+
});
|
| 75 |
+
}, []);
|
| 76 |
+
|
| 77 |
+
return (
|
| 78 |
+
<LookupsContext.Provider value={lookups}>
|
| 79 |
+
{children}
|
| 80 |
+
</LookupsContext.Provider>
|
| 81 |
+
);
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
export function useLookups(): Lookups {
|
| 85 |
+
return useContext(LookupsContext);
|
| 86 |
+
}
|