@growthub/cli 0.9.10 → 0.9.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/README.md +1 -1
  2. package/assets/worker-kits/creative-strategist-v1/kit.json +5 -2
  3. package/assets/worker-kits/growthub-agency-portal-starter-v1/kit.json +4 -1
  4. package/assets/worker-kits/growthub-ai-website-cloner-v1/kit.json +6 -3
  5. package/assets/worker-kits/growthub-creative-video-pipeline-v1/kit.json +4 -1
  6. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/README.md +4 -4
  7. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/api/workspace/integration-entities/route.js +50 -0
  8. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/globals.css +284 -15
  9. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/page.jsx +5 -2
  10. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/settings/integrations/page.jsx +4 -5
  11. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/app/workspace-builder.jsx +507 -34
  12. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/growthub-connection-normalizer.js +12 -16
  13. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/adapters/integrations/index.js +61 -11
  14. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/domain/integrations.js +31 -1
  15. package/assets/worker-kits/growthub-custom-workspace-starter-v1/apps/workspace/lib/workspace-schema.js +54 -7
  16. package/assets/worker-kits/growthub-custom-workspace-starter-v1/kit.json +5 -2
  17. package/assets/worker-kits/growthub-email-marketing-v1/kit.json +5 -2
  18. package/assets/worker-kits/growthub-geo-seo-v1/kit.json +5 -2
  19. package/assets/worker-kits/growthub-hyperframes-studio-v1/kit.json +5 -2
  20. package/assets/worker-kits/growthub-marketing-skills-v1/kit.json +6 -3
  21. package/assets/worker-kits/growthub-open-higgsfield-studio-v1/kit.json +5 -2
  22. package/assets/worker-kits/growthub-open-montage-studio-v1/kit.json +6 -3
  23. package/assets/worker-kits/growthub-postiz-social-v1/kit.json +5 -2
  24. package/assets/worker-kits/growthub-twenty-crm-v1/kit.json +6 -3
  25. package/assets/worker-kits/growthub-video-use-studio-v1/kit.json +5 -2
  26. package/assets/worker-kits/growthub-zernio-social-v1/kit.json +5 -2
  27. package/dist/index.js +1750 -433
  28. package/package.json +1 -1
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
5
  import {
6
6
  BarChart3,
7
7
  Bolt,
8
+ Check,
8
9
  ChevronDown,
9
10
  Code2,
10
11
  Columns3,
@@ -33,7 +34,6 @@ import {
33
34
  Settings,
34
35
  Sigma,
35
36
  SlidersHorizontal,
36
- Sparkles,
37
37
  Table2,
38
38
  Trash2,
39
39
  Type,
@@ -63,6 +63,25 @@ const DEFAULT_FILTER_OP = "and";
63
63
  const DEFAULT_FILTER_OPERATOR = "contains";
64
64
  const DEFAULT_SORT_DIRECTION = "asc";
65
65
  const SUB_PANEL_ROOT = "root";
66
+ const MANAGED_INTEGRATION_SOURCE_TYPE = "managed-integrations";
67
+ const CUSTOM_API_SOURCE_TYPE = "custom-api-webhooks";
68
+
69
+ const SOURCE_TYPE_OBJECTS = [
70
+ {
71
+ id: MANAGED_INTEGRATION_SOURCE_TYPE,
72
+ label: "Managed Integrations",
73
+ authority: "Growthub Bridge",
74
+ description: "Bridge or BYO adapters resolve metadata server-side."
75
+ },
76
+ {
77
+ id: CUSTOM_API_SOURCE_TYPE,
78
+ label: "Custom APIs/Webhooks",
79
+ authority: "Custom endpoint",
80
+ description: "Reference a governed endpoint object without storing credentials in widget config."
81
+ }
82
+ ];
83
+
84
+ const ENTITY_REFERENCE_FIELD_IDS = ["id", "entityId"];
66
85
 
67
86
  const CHART_TYPE_LABELS = {
68
87
  "bar-vertical": "Vertical Bar",
@@ -622,11 +641,28 @@ function getChartStyle(widget) {
622
641
 
623
642
  function summarizeSource(widget) {
624
643
  const binding = widget?.config?.binding;
625
- if (binding?.mode === "integration" && binding.source) return binding.source;
644
+ if (binding?.mode === "integration") {
645
+ const source = binding.source || "Integration";
646
+ if (binding.entityLabel) return `${source} · ${binding.entityLabel}`;
647
+ if (binding.entityId) return `${source} · ${binding.entityId}`;
648
+ return source;
649
+ }
626
650
  if (widget?.config?.source) return widget.config.source;
627
651
  return "Static";
628
652
  }
629
653
 
654
+ function summarizeSourceType(binding) {
655
+ if (binding?.sourceType === CUSTOM_API_SOURCE_TYPE) return "Custom APIs/Webhooks";
656
+ if (binding?.mode === "integration" || binding?.sourceType === MANAGED_INTEGRATION_SOURCE_TYPE) return "Managed Integrations";
657
+ return "Static data";
658
+ }
659
+
660
+ function resolveBindingSourceType(binding) {
661
+ if (binding?.sourceType) return binding.sourceType;
662
+ if (binding?.mode === "integration") return MANAGED_INTEGRATION_SOURCE_TYPE;
663
+ return "static";
664
+ }
665
+
630
666
  function summarizeFields(widget) {
631
667
  const total = getColumnList(widget).length;
632
668
  const hidden = getHiddenColumnSet(widget).size;
@@ -655,6 +691,142 @@ function describeIntegrationLane(integration) {
655
691
  return integration?.lane === "data-source" ? "Data Sources" : "Workspace Tools";
656
692
  }
657
693
 
694
+ function flattenIntegrationSettings(integrationSettings) {
695
+ const grouped = integrationSettings?.integrations || integrationSettings || {};
696
+ const runtime = [
697
+ ...(Array.isArray(grouped.dataSources) ? grouped.dataSources : []),
698
+ ...(Array.isArray(grouped.workspaceIntegrations) ? grouped.workspaceIntegrations : [])
699
+ ];
700
+ const byId = new Map();
701
+ for (const item of [...governedWorkspaceIntegrationCatalog, ...runtime]) {
702
+ if (item?.id) byId.set(item.id, { ...byId.get(item.id), ...item });
703
+ }
704
+ return Array.from(byId.values());
705
+ }
706
+
707
+ function getFilterFieldOptions(widget, entities = []) {
708
+ const fields = new Set(getColumnList(widget));
709
+ const binding = widget?.config?.binding || {};
710
+ if (binding.mode === "integration") {
711
+ ["id", "label", "secondaryLabel", "entityType", "provider", "lane", "status"].forEach((field) => fields.add(field));
712
+ fields.add("provider");
713
+ fields.add("lane");
714
+ for (const entity of entities) {
715
+ if (entity?.metadata && typeof entity.metadata === "object") {
716
+ Object.keys(entity.metadata).forEach((field) => fields.add(field));
717
+ }
718
+ }
719
+ }
720
+ if (binding.sourceType === CUSTOM_API_SOURCE_TYPE) {
721
+ const customFields = Array.isArray(binding.fields) ? binding.fields : ["entityId", "status", "createdAt"];
722
+ customFields.forEach((field) => fields.add(field));
723
+ }
724
+ return Array.from(fields).filter(Boolean);
725
+ }
726
+
727
+ function getEntityFieldValue(entity, fieldId) {
728
+ if (!entity || !fieldId) return "";
729
+ if (fieldId === "id" || fieldId === "entityId") return entity.id || "";
730
+ if (fieldId === "label" || fieldId === "name") return entity.label || "";
731
+ if (fieldId === "secondaryLabel") return entity.secondaryLabel || "";
732
+ if (fieldId === "entityType") return entity.entityType || "";
733
+ if (fieldId === "provider") return entity.provider || "";
734
+ if (fieldId === "lane") return entity.lane || "";
735
+ if (fieldId === "status") return entity.status || "";
736
+ if (entity.metadata && typeof entity.metadata === "object" && entity.metadata[fieldId] !== undefined) {
737
+ return String(entity.metadata[fieldId]);
738
+ }
739
+ return "";
740
+ }
741
+
742
+ function getEntityFieldChoices(entities) {
743
+ const fields = new Map();
744
+ const add = (id, label) => {
745
+ if (id && !fields.has(id)) fields.set(id, { id, label });
746
+ };
747
+ add("id", "Stable ID");
748
+ add("label", "Primary label");
749
+ add("secondaryLabel", "Secondary label");
750
+ add("entityType", "Entity type");
751
+ add("provider", "Provider");
752
+ add("lane", "Lane");
753
+ add("status", "Status");
754
+ for (const entity of entities) {
755
+ if (entity?.metadata && typeof entity.metadata === "object") {
756
+ Object.keys(entity.metadata).forEach((field) => add(field, field));
757
+ }
758
+ }
759
+ return Array.from(fields.values());
760
+ }
761
+
762
+ function getFilterFieldChoices(widget, entities = []) {
763
+ const binding = widget?.config?.binding || {};
764
+ if (binding.mode === "integration" && entities.length) return getEntityFieldChoices(entities);
765
+ return getFilterFieldOptions(widget, entities).map((id) => ({ id, label: id }));
766
+ }
767
+
768
+ function getEntityValueChoices(entities, fieldId) {
769
+ const seen = new Map();
770
+ for (const entity of entities) {
771
+ const value = getEntityFieldValue(entity, fieldId);
772
+ if (!value || seen.has(value)) continue;
773
+ const label = fieldId === "id" || fieldId === "entityId"
774
+ ? `${entity.label || value} · ${value}`
775
+ : value;
776
+ seen.set(value, { value, label, entity });
777
+ }
778
+ return Array.from(seen.values());
779
+ }
780
+
781
+ function findEntityByFieldValue(entities, fieldId, value) {
782
+ if (!value) return null;
783
+ return entities.find((entity) => getEntityFieldValue(entity, fieldId) === value) || null;
784
+ }
785
+
786
+ function updateWidgetEntityBinding(widget, entity) {
787
+ const binding = widget.config?.binding || {};
788
+ const existingFilter = widget.config?.filter;
789
+ const existingClauses = Array.isArray(existingFilter?.clauses) ? existingFilter.clauses : [];
790
+
791
+ if (!entity) {
792
+ const { entityId, entityType, entityLabel, ...restBinding } = binding;
793
+ const cleanedClauses = existingClauses.filter(
794
+ (clause) => !(ENTITY_REFERENCE_FIELD_IDS.includes(clause.fieldId) && clause.operator === "eq")
795
+ );
796
+ return {
797
+ ...widget.config,
798
+ binding: restBinding,
799
+ filter: { op: existingFilter?.op || DEFAULT_FILTER_OP, clauses: cleanedClauses }
800
+ };
801
+ }
802
+
803
+ const entityClause = { fieldId: "id", operator: "eq", value: entity.id };
804
+ const otherClauses = existingClauses.filter(
805
+ (clause) => !(ENTITY_REFERENCE_FIELD_IDS.includes(clause.fieldId) && clause.operator === "eq")
806
+ );
807
+ const nextBinding = {
808
+ ...binding,
809
+ entityId: entity.id,
810
+ entityLabel: entity.label
811
+ };
812
+ if (entity.entityType) nextBinding.entityType = entity.entityType;
813
+ else delete nextBinding.entityType;
814
+ return {
815
+ ...widget.config,
816
+ source: binding.source || entity.label,
817
+ binding: nextBinding,
818
+ filter: { op: existingFilter?.op || DEFAULT_FILTER_OP, clauses: [entityClause, ...otherClauses] }
819
+ };
820
+ }
821
+
822
+ function resolveChartColor(style, branding) {
823
+ if (style?.colors === "manual" && style.manualColor) return style.manualColor;
824
+ if (style?.colors === "brand-local") return branding?.accent || "#3f68ff";
825
+ if (style?.colors === "brand-bridge") return branding?.bridgeAccent || branding?.accent || "#3f68ff";
826
+ if (style?.colors === "accent") return "#38bdf8";
827
+ return null;
828
+ }
829
+
658
830
  const NORMALIZED_TEMPLATES = DASHBOARD_TEMPLATES.map((template) => ({
659
831
  ...normalizeWorkspaceTemplate(template),
660
832
  widgets: template.widgets
@@ -809,11 +981,110 @@ function SubPanelHeader({ title, breadcrumb, onBack }) {
809
981
  </div>;
810
982
  }
811
983
 
984
+ /**
985
+ * EntityBadge — chip showing the selected entity on the source panel and root inspector.
986
+ * Displays primary label + muted secondary label (stable ID). onClear is optional.
987
+ */
988
+ function EntityBadge({ entity, onClear }) {
989
+ const initials = entity.entityType
990
+ ? entity.entityType[0].toUpperCase()
991
+ : (entity.label?.[0] || "•").toUpperCase();
992
+ return <div className="workspace-entity-badge">
993
+ <span className="workspace-entity-badge-icon" aria-hidden="true">{initials}</span>
994
+ <span className="workspace-entity-badge-meta">
995
+ <strong title={entity.label}>{entity.label}</strong>
996
+ {entity.secondaryLabel ? <em title={entity.secondaryLabel}>{entity.secondaryLabel}</em> : null}
997
+ </span>
998
+ {onClear ? <button
999
+ type="button"
1000
+ className="workspace-entity-badge-clear"
1001
+ aria-label={`Clear selected entity ${entity.label}`}
1002
+ onClick={onClear}
1003
+ >
1004
+ <X size={11} />
1005
+ </button> : null}
1006
+ </div>;
1007
+ }
1008
+
1009
+ function UniversalSourceInfoCard() {
1010
+ return <p className="workspace-source-info-card">
1011
+ Universal source objects support managed integrations and custom APIs/webhooks through normalized metadata and stable saved references.
1012
+ </p>;
1013
+ }
1014
+
1015
+ /**
1016
+ * EntitySelector — compact dropdown for picking a normalized source object after
1017
+ * an integration is selected from the SourceSubPanel.
1018
+ *
1019
+ * Governed invariant: only the object `id` is persisted. The `label` is
1020
+ * display-only and may be refreshed from adapter metadata at any time.
1021
+ * The browser never holds source credentials or executes source queries.
1022
+ */
1023
+ function EntitySelector({ integration, entities, selectedEntityId, selectedEntityLabel, selectedEntityType, onSelect, loading }) {
1024
+ const selected = entities.find((e) => e.id === selectedEntityId)
1025
+ || (selectedEntityId ? {
1026
+ id: selectedEntityId,
1027
+ label: selectedEntityLabel || selectedEntityId,
1028
+ secondaryLabel: selectedEntityId,
1029
+ entityType: selectedEntityType
1030
+ } : null);
1031
+
1032
+ const clearSelected = () => {
1033
+ if (!selectedEntityId || window.confirm("Remove the selected source object from this widget?")) {
1034
+ onSelect(null);
1035
+ }
1036
+ };
1037
+
1038
+ return <div className="workspace-entity-selector">
1039
+ <p className="workspace-panel-label">Source object</p>
1040
+ {selected ? <EntityBadge entity={selected} onClear={clearSelected} /> : null}
1041
+ {loading ? <p className="workspace-entity-empty">Loading source objects…</p> : null}
1042
+ {!loading && !entities.length ? <p className="workspace-entity-empty">
1043
+ No source objects returned. Configure a server-side API/webhook object resolver for this integration.
1044
+ </p> : null}
1045
+ {!loading && entities.length ? <label className="workspace-entity-dropdown">
1046
+ <span>Select source object</span>
1047
+ <select
1048
+ aria-label="Select source object"
1049
+ value={selectedEntityId || ""}
1050
+ onChange={(event) => {
1051
+ const entity = entities.find((item) => item.id === event.target.value);
1052
+ onSelect(entity || null);
1053
+ }}
1054
+ >
1055
+ <option value="">Choose an object</option>
1056
+ {entities.map((entity) => <option key={entity.id} value={entity.id}>
1057
+ {entity.label}{entity.secondaryLabel ? ` · ${entity.secondaryLabel}` : ""}
1058
+ </option>)}
1059
+ </select>
1060
+ </label> : null}
1061
+ </div>;
1062
+ }
1063
+
812
1064
  function SourceSubPanel({ widget, integrations, onChange, onBack }) {
813
1065
  const binding = widget.config?.binding || {};
814
1066
  const currentMode = binding.mode || (widget.kind === "view" ? "manual" : "json");
815
- const [query, setQuery] = useState("");
816
- const [laneFilter, setLaneFilter] = useState("all");
1067
+ const activeSourceType = resolveBindingSourceType(binding);
1068
+ const [query, setQuery] = useState("");
1069
+ const [laneFilter, setLaneFilter] = useState("all");
1070
+ const hasConnectedSource = Boolean(
1071
+ binding.integrationId ||
1072
+ binding.endpointRef ||
1073
+ binding.sourceType === MANAGED_INTEGRATION_SOURCE_TYPE ||
1074
+ binding.sourceType === CUSTOM_API_SOURCE_TYPE
1075
+ );
1076
+ const confirmSourceChange = useCallback((nextLabel) => {
1077
+ if (!hasConnectedSource) return true;
1078
+ const currentLabel = summarizeSource(widget);
1079
+ return window.confirm(`Change source from ${currentLabel} to ${nextLabel}? This updates the widget binding and can clear source-object filters.`);
1080
+ }, [hasConnectedSource, widget]);
1081
+
1082
+ const activeIntegration = useMemo(() => {
1083
+ if (currentMode !== "integration" || !binding.integrationId) return null;
1084
+ const list = Array.isArray(integrations) ? integrations : [];
1085
+ return list.find((item) => item.id === binding.integrationId) || null;
1086
+ }, [currentMode, binding.integrationId, integrations]);
1087
+
817
1088
  const groups = useMemo(() => {
818
1089
  const list = Array.isArray(integrations) ? integrations : [];
819
1090
  const filtered = list.filter((item) => {
@@ -826,8 +1097,10 @@ function SourceSubPanel({ widget, integrations, onChange, onBack }) {
826
1097
  "workspace-integration": filtered.filter((item) => item.lane === "workspace-integration")
827
1098
  };
828
1099
  }, [integrations, laneFilter, query]);
829
- const selectStatic = useCallback(() => {
830
- if (widget.kind === "chart") {
1100
+
1101
+ const selectStatic = useCallback(() => {
1102
+ if (!confirmSourceChange("Static rows")) return;
1103
+ if (widget.kind === "chart") {
831
1104
  onChange({ ...widget.config, binding: SAMPLE_DATA_BINDINGS.reportingJson });
832
1105
  } else {
833
1106
  onChange({
@@ -836,27 +1109,102 @@ function SourceSubPanel({ widget, integrations, onChange, onBack }) {
836
1109
  binding: SAMPLE_DATA_BINDINGS.companiesManual
837
1110
  });
838
1111
  }
839
- }, [onChange, widget.config, widget.kind]);
840
- const selectIntegration = useCallback((integration) => {
1112
+ }, [confirmSourceChange, onChange, widget.config, widget.kind]);
1113
+
1114
+ const selectCustomApi = useCallback(() => {
1115
+ if (!confirmSourceChange("Custom APIs/Webhooks")) return;
1116
+ onChange({
1117
+ ...widget.config,
1118
+ source: "Custom APIs/Webhooks",
1119
+ binding: {
1120
+ ...binding,
1121
+ mode: "json",
1122
+ source: "Custom APIs/Webhooks",
1123
+ sourceType: CUSTOM_API_SOURCE_TYPE,
1124
+ sourceAuthority: "custom-api",
1125
+ endpointRef: binding.endpointRef || "",
1126
+ fields: Array.isArray(binding.fields) ? binding.fields : ["entityId", "status", "createdAt"]
1127
+ }
1128
+ });
1129
+ }, [binding, confirmSourceChange, onChange, widget.config]);
1130
+
1131
+ const updateCustomFields = useCallback((value) => {
1132
+ const fields = value.split(",").map((item) => item.trim()).filter(Boolean);
1133
+ onChange({
1134
+ ...widget.config,
1135
+ binding: {
1136
+ ...binding,
1137
+ mode: "json",
1138
+ source: "Custom APIs/Webhooks",
1139
+ sourceType: CUSTOM_API_SOURCE_TYPE,
1140
+ sourceAuthority: "custom-api",
1141
+ fields
1142
+ }
1143
+ });
1144
+ }, [binding, onChange, widget.config]);
1145
+
1146
+ const updateEndpointRef = useCallback((value) => {
841
1147
  onChange({
842
1148
  ...widget.config,
1149
+ binding: {
1150
+ ...binding,
1151
+ mode: "json",
1152
+ source: "Custom APIs/Webhooks",
1153
+ sourceType: CUSTOM_API_SOURCE_TYPE,
1154
+ sourceAuthority: "custom-api",
1155
+ endpointRef: value
1156
+ }
1157
+ });
1158
+ }, [binding, onChange, widget.config]);
1159
+
1160
+ const selectIntegration = useCallback((integration) => {
1161
+ if (binding.integrationId && binding.integrationId !== integration.id && !confirmSourceChange(integration.label)) return;
1162
+ onChange({
1163
+ ...widget.config,
843
1164
  source: integration.label,
844
1165
  binding: {
845
1166
  mode: "integration",
846
1167
  source: integration.label,
1168
+ sourceType: MANAGED_INTEGRATION_SOURCE_TYPE,
1169
+ sourceAuthority: "growthub-bridge",
847
1170
  integrationId: integration.id,
848
- lane: integration.lane
1171
+ lane: integration.lane,
1172
+ provider: integration.provider
849
1173
  }
850
1174
  });
851
- }, [onChange, widget.config]);
852
- return <section className="workspace-widget-subpanel">
853
- <SubPanelHeader title="Source" breadcrumb={widget.title} onBack={onBack} />
1175
+ }, [binding.integrationId, confirmSourceChange, onChange, widget.config]);
1176
+
1177
+ return <section className="workspace-widget-subpanel">
1178
+ <SubPanelHeader title="Source" breadcrumb={widget.title} onBack={onBack} />
1179
+ <UniversalSourceInfoCard />
1180
+ <p className="workspace-panel-label">Source type</p>
1181
+ <div className="workspace-source-object-list">
1182
+ {SOURCE_TYPE_OBJECTS.map((sourceType) => {
1183
+ const isActive = activeSourceType === sourceType.id;
1184
+ return <button
1185
+ key={sourceType.id}
1186
+ type="button"
1187
+ className={`workspace-source-object-row${isActive ? " active" : ""}`}
1188
+ onClick={sourceType.id === CUSTOM_API_SOURCE_TYPE ? selectCustomApi : undefined}
1189
+ disabled={sourceType.id === MANAGED_INTEGRATION_SOURCE_TYPE}
1190
+ >
1191
+ <span className="workspace-source-object-icon" aria-hidden="true">
1192
+ {sourceType.id === MANAGED_INTEGRATION_SOURCE_TYPE ? <Database size={15} /> : <LinkIcon size={15} />}
1193
+ </span>
1194
+ <span className="workspace-source-meta">
1195
+ <strong>{sourceType.label}</strong>
1196
+ <em>{sourceType.authority} · {sourceType.description}</em>
1197
+ </span>
1198
+ {isActive ? <span className="workspace-source-tick" aria-hidden="true"><Check size={16} strokeWidth={2.4} /></span> : null}
1199
+ </button>;
1200
+ })}
1201
+ </div>
854
1202
  <div className="workspace-source-controls">
855
1203
  <label>
856
1204
  <Search size={14} aria-hidden="true" />
857
1205
  <input
858
1206
  aria-label="Search sources"
859
- placeholder="Search sources"
1207
+ placeholder="Search connectors"
860
1208
  value={query}
861
1209
  onChange={(event) => setQuery(event.target.value)}
862
1210
  />
@@ -868,7 +1216,7 @@ function SourceSubPanel({ widget, integrations, onChange, onBack }) {
868
1216
  value={laneFilter}
869
1217
  onChange={(event) => setLaneFilter(event.target.value)}
870
1218
  >
871
- <option value="all">All source types</option>
1219
+ <option value="all">All connector lanes</option>
872
1220
  <option value="data-source">Data sources</option>
873
1221
  <option value="workspace-integration">Workspace tools</option>
874
1222
  </select>
@@ -879,15 +1227,15 @@ function SourceSubPanel({ widget, integrations, onChange, onBack }) {
879
1227
  <div className="workspace-source-list">
880
1228
  <button
881
1229
  type="button"
882
- className={`workspace-source-row${currentMode !== "integration" ? " active" : ""}`}
1230
+ className={`workspace-source-row${activeSourceType === "static" ? " active" : ""}`}
883
1231
  onClick={selectStatic}
884
1232
  >
885
1233
  <span className="workspace-source-icon" aria-hidden="true"><Grid2X2 size={15} /></span>
886
1234
  <span className="workspace-source-meta">
887
1235
  <strong>Static rows</strong>
888
- <em>Inline data no external authority required.</em>
1236
+ <em>Inline JSON, CSV, or manual rows remain supported.</em>
889
1237
  </span>
890
- {currentMode !== "integration" ? <span className="workspace-source-tick" aria-hidden="true"><Sparkles size={15} /></span> : null}
1238
+ {activeSourceType === "static" ? <span className="workspace-source-tick" aria-hidden="true"><Check size={16} strokeWidth={2.4} /></span> : null}
891
1239
  </button>
892
1240
  </div>
893
1241
  {Object.entries(groups).map(([lane, items]) => items.length ? <div key={lane}>
@@ -907,13 +1255,36 @@ function SourceSubPanel({ widget, integrations, onChange, onBack }) {
907
1255
  <strong>{integration.label}</strong>
908
1256
  <em>{describeIntegrationLane(integration)} · {connected ? "connected" : "needs connection"}</em>
909
1257
  </span>
910
- {isActive ? <span className="workspace-source-tick" aria-hidden="true"><Sparkles size={15} /></span> : null}
1258
+ {isActive ? <span className="workspace-source-tick" aria-hidden="true"><Check size={16} strokeWidth={2.4} /></span> : null}
911
1259
  </button>;
912
1260
  })}
913
1261
  </div>
914
1262
  </div> : null)}
1263
+ {activeSourceType === CUSTOM_API_SOURCE_TYPE ? <div className="workspace-custom-source-config">
1264
+ <label>
1265
+ <span>Endpoint reference</span>
1266
+ <input
1267
+ value={binding.endpointRef || ""}
1268
+ placeholder="api.clients.primary"
1269
+ onChange={(event) => updateEndpointRef(event.target.value)}
1270
+ />
1271
+ </label>
1272
+ <label>
1273
+ <span>Available fields</span>
1274
+ <input
1275
+ value={(Array.isArray(binding.fields) ? binding.fields : []).join(", ")}
1276
+ placeholder="entityId, status, createdAt"
1277
+ onChange={(event) => updateCustomFields(event.target.value)}
1278
+ />
1279
+ </label>
1280
+ </div> : null}
1281
+ {currentMode === "integration" && binding.integrationId ? <div className="workspace-active-source-state">
1282
+ <span>Active source</span>
1283
+ <strong>{activeIntegration?.label || binding.source || binding.integrationId}</strong>
1284
+ <code>{binding.integrationId}</code>
1285
+ </div> : null}
915
1286
  <p className="workspace-panel-hint">
916
- Selecting a source writes a binding reference only. The browser does not query integrations or store tokens.
1287
+ Selecting a source writes a binding reference only. The browser only calls local workspace routes and never stores source credentials.
917
1288
  </p>
918
1289
  </section>;
919
1290
  }
@@ -1052,24 +1423,85 @@ function SortSubPanel({ widget, onChange, onBack }) {
1052
1423
  </section>;
1053
1424
  }
1054
1425
 
1055
- function FilterSubPanel({ widget, onChange, onBack }) {
1426
+ function FilterSubPanel({ widget, integrations, onChange, onBack }) {
1427
+ const binding = widget.config?.binding || {};
1056
1428
  const filter = getFilterConfig(widget);
1057
- const columns = getColumnList(widget);
1429
+ const [entities, setEntities] = useState([]);
1430
+ const [entitiesLoading, setEntitiesLoading] = useState(false);
1431
+ const fieldChoices = getFilterFieldChoices(widget, entities);
1432
+ const columns = fieldChoices.map((field) => field.id);
1058
1433
  const setFilter = (next) => onChange({ ...widget.config, filter: next });
1059
1434
  const setOp = (op) => setFilter({ ...filter, op });
1435
+ const activeIntegration = useMemo(() => {
1436
+ if (binding.mode !== "integration" || !binding.integrationId) return null;
1437
+ const list = Array.isArray(integrations) ? integrations : [];
1438
+ return list.find((item) => item.id === binding.integrationId) || null;
1439
+ }, [binding.integrationId, binding.mode, integrations]);
1440
+
1441
+ useEffect(() => {
1442
+ if (!binding.integrationId || binding.mode !== "integration") {
1443
+ setEntities([]);
1444
+ return;
1445
+ }
1446
+ let cancelled = false;
1447
+ setEntitiesLoading(true);
1448
+ fetch(`/api/workspace/integration-entities?integrationId=${encodeURIComponent(binding.integrationId)}`, { cache: "no-store" })
1449
+ .then((res) => res.ok ? res.json() : { entities: [] })
1450
+ .then((data) => {
1451
+ if (!cancelled) {
1452
+ setEntities(Array.isArray(data.entities) ? data.entities : []);
1453
+ setEntitiesLoading(false);
1454
+ }
1455
+ })
1456
+ .catch(() => {
1457
+ if (!cancelled) {
1458
+ setEntities([]);
1459
+ setEntitiesLoading(false);
1460
+ }
1461
+ });
1462
+ return () => { cancelled = true; };
1463
+ }, [binding.integrationId, binding.mode]);
1464
+
1060
1465
  const addClause = () => {
1061
- const fieldId = columns[0] || "";
1466
+ const fieldId = binding.mode === "integration" && entities.length ? "id" : (columns[0] || "");
1062
1467
  if (!fieldId) return;
1063
- setFilter({ ...filter, clauses: [...filter.clauses, { fieldId, operator: DEFAULT_FILTER_OPERATOR, value: "" }] });
1468
+ setFilter({ ...filter, clauses: [...filter.clauses, { fieldId, operator: "eq", value: "" }] });
1064
1469
  };
1470
+ const updateField = (index, fieldId) => {
1471
+ setFilter({
1472
+ ...filter,
1473
+ clauses: filter.clauses.map((clause, idx) => idx === index ? { ...clause, fieldId, value: "" } : clause)
1474
+ });
1475
+ };
1476
+ const selectEntity = useCallback((entity) => {
1477
+ onChange(updateWidgetEntityBinding(widget, entity));
1478
+ }, [onChange, widget]);
1065
1479
  const updateClause = (index, patch) => {
1066
1480
  setFilter({ ...filter, clauses: filter.clauses.map((clause, idx) => idx === index ? { ...clause, ...patch } : clause) });
1067
1481
  };
1068
1482
  const removeClause = (index) => {
1069
1483
  setFilter({ ...filter, clauses: filter.clauses.filter((_, idx) => idx !== index) });
1070
1484
  };
1071
- return <section className="workspace-widget-subpanel">
1072
- <SubPanelHeader title="Filter" breadcrumb={widget.title} onBack={onBack} />
1485
+ return <section className="workspace-widget-subpanel">
1486
+ <SubPanelHeader title="Filter" breadcrumb={widget.title} onBack={onBack} />
1487
+ <UniversalSourceInfoCard />
1488
+ <div className="workspace-filter-source-state">
1489
+ <span>{summarizeSourceType(binding)}</span>
1490
+ <strong>{summarizeSource(widget)}</strong>
1491
+ </div>
1492
+ {binding.mode === "integration" && binding.integrationId ? <EntitySelector
1493
+ integration={activeIntegration}
1494
+ entities={entities}
1495
+ selectedEntityId={binding.entityId || null}
1496
+ selectedEntityLabel={binding.entityLabel || null}
1497
+ selectedEntityType={binding.entityType || null}
1498
+ onSelect={selectEntity}
1499
+ loading={entitiesLoading}
1500
+ /> : null}
1501
+ {binding.sourceType === CUSTOM_API_SOURCE_TYPE ? <div className="workspace-filter-source-state">
1502
+ <span>Custom endpoint</span>
1503
+ <code>{binding.endpointRef || "No endpoint reference set"}</code>
1504
+ </div> : null}
1073
1505
  <div className="workspace-filter-op-toggle" role="radiogroup" aria-label="Filter conjunction">
1074
1506
  {KNOWN_FILTER_CONJUNCTIONS.map((op) => <button
1075
1507
  key={op}
@@ -1084,14 +1516,15 @@ function FilterSubPanel({ widget, onChange, onBack }) {
1084
1516
  {filter.clauses.length === 0 ? <p className="workspace-panel-hint">No filter clauses.</p> : null}
1085
1517
  {filter.clauses.map((clause, index) => {
1086
1518
  const valueless = clause.operator === "isEmpty" || clause.operator === "isNotEmpty";
1519
+ const valueChoices = binding.mode === "integration" ? getEntityValueChoices(entities, clause.fieldId) : [];
1087
1520
  return <div key={index} className="workspace-filter-clause">
1088
1521
  <select
1089
1522
  aria-label={`Filter ${index + 1} field`}
1090
1523
  value={clause.fieldId}
1091
- onChange={(event) => updateClause(index, { fieldId: event.target.value })}
1524
+ onChange={(event) => updateField(index, event.target.value)}
1092
1525
  >
1093
1526
  {!columns.includes(clause.fieldId) && clause.fieldId ? <option value={clause.fieldId}>{clause.fieldId}</option> : null}
1094
- {columns.map((name) => <option key={name} value={name}>{name}</option>)}
1527
+ {fieldChoices.map((field) => <option key={field.id} value={field.id}>{field.label}</option>)}
1095
1528
  </select>
1096
1529
  <select
1097
1530
  aria-label={`Filter ${index + 1} operator`}
@@ -1100,7 +1533,20 @@ function FilterSubPanel({ widget, onChange, onBack }) {
1100
1533
  >
1101
1534
  {KNOWN_FILTER_OPERATORS.map((op) => <option key={op} value={op}>{FILTER_OPERATOR_LABELS[op] || op}</option>)}
1102
1535
  </select>
1103
- {!valueless ? <input
1536
+ {!valueless && binding.mode === "integration" && valueChoices.length ? <select
1537
+ aria-label={`Filter ${index + 1} value`}
1538
+ value={clause.value ?? ""}
1539
+ onChange={(event) => {
1540
+ const entity = findEntityByFieldValue(entities, clause.fieldId, event.target.value);
1541
+ updateClause(index, { value: event.target.value });
1542
+ if (entity && (clause.fieldId === "id" || clause.fieldId === "entityId")) {
1543
+ onChange(updateWidgetEntityBinding(widget, entity));
1544
+ }
1545
+ }}
1546
+ >
1547
+ <option value="">Select value</option>
1548
+ {valueChoices.map((choice) => <option key={choice.value} value={choice.value}>{choice.label}</option>)}
1549
+ </select> : !valueless ? <input
1104
1550
  aria-label={`Filter ${index + 1} value`}
1105
1551
  value={clause.value ?? ""}
1106
1552
  placeholder="value"
@@ -1119,11 +1565,12 @@ function FilterSubPanel({ widget, onChange, onBack }) {
1119
1565
  </section>;
1120
1566
  }
1121
1567
 
1122
- function ChartConfigPanel({ widget, onChange, onSubPage }) {
1568
+ function ChartConfigPanel({ widget, branding, onChange, onSubPage }) {
1123
1569
  const chartType = getChartType(widget) === "line" ? DEFAULT_CHART_TYPE : getChartType(widget);
1124
1570
  const xAxis = getChartAxis(widget, "xAxis");
1125
1571
  const yAxis = getChartAxis(widget, "yAxis");
1126
1572
  const style = getChartStyle(widget);
1573
+ const activeColor = resolveChartColor(style, branding) || "#d9e4ff";
1127
1574
  const setChartType = (type) => onChange({ ...widget.config, chartType: type });
1128
1575
  const setXAxis = (patch) => onChange({ ...widget.config, xAxis: { ...xAxis, ...patch } });
1129
1576
  const setYAxis = (patch) => onChange({ ...widget.config, yAxis: { ...yAxis, ...patch } });
@@ -1148,11 +1595,17 @@ function ChartConfigPanel({ widget, onChange, onSubPage }) {
1148
1595
  })}
1149
1596
  </div>
1150
1597
  <button type="button" className="workspace-settings-row" onClick={() => onSubPage("source")}>
1151
- <span>Source</span><code>{summarizeSource(widget)}</code>
1598
+ <span>Source</span><code>{summarizeSourceType(widget.config?.binding)} · {summarizeSource(widget)}</code>
1152
1599
  </button>
1153
1600
  <button type="button" className="workspace-settings-row" onClick={() => onSubPage("filter")}>
1154
1601
  <span>Filter</span><code>{summarizeFilter(widget)}</code>
1155
1602
  </button>
1603
+ {widget.config?.binding?.entityId ? <EntityBadge entity={{
1604
+ id: widget.config.binding.entityId,
1605
+ label: widget.config.binding.entityLabel || widget.config.binding.entityId,
1606
+ secondaryLabel: widget.config.binding.entityId,
1607
+ entityType: widget.config.binding.entityType
1608
+ }} /> : null}
1156
1609
  <p className="workspace-panel-label">X axis</p>
1157
1610
  <label>
1158
1611
  <span>Data on display</span>
@@ -1225,9 +1678,16 @@ function ChartConfigPanel({ widget, onChange, onSubPage }) {
1225
1678
  <select value={style.colors || "auto"} onChange={(event) => setStyle({ colors: event.target.value })}>
1226
1679
  <option value="auto">Auto</option>
1227
1680
  <option value="accent">Accent</option>
1681
+ <option value="brand-local">Local brand kit</option>
1682
+ <option value="brand-bridge">Bridge brand kit</option>
1228
1683
  <option value="manual">Manual</option>
1229
1684
  </select>
1230
1685
  </label>
1686
+ <div className="workspace-color-preview-row">
1687
+ <span>Active color</span>
1688
+ <em style={{ background: activeColor }} />
1689
+ <code>{activeColor}</code>
1690
+ </div>
1231
1691
  {style.colors === "manual" ? <div className="workspace-color-picker-row">
1232
1692
  <label>
1233
1693
  <span>Manual color</span>
@@ -1414,7 +1874,7 @@ function IframePreviewModal({ widget, onClose }) {
1414
1874
  </div>;
1415
1875
  }
1416
1876
 
1417
- function WidgetPreview({ widget, selected, onSelect, onMoveStart, onRemove, onResizeStart, onExpandIframe }) {
1877
+ function WidgetPreview({ widget, branding, selected, onSelect, onMoveStart, onRemove, onResizeStart, onExpandIframe }) {
1418
1878
  const fallbackColumns = widget.config?.columns?.length ? widget.config.columns : ["Name", "Domain Name"];
1419
1879
  const visibleColumns = widget.kind === "view" ? getVisibleColumns(widget) : fallbackColumns;
1420
1880
  const viewColumns = visibleColumns.length ? visibleColumns : fallbackColumns;
@@ -1423,7 +1883,11 @@ function WidgetPreview({ widget, selected, onSelect, onMoveStart, onRemove, onRe
1423
1883
  const chartType = widget.kind === "chart" ? (getChartType(widget) === "line" ? DEFAULT_CHART_TYPE : getChartType(widget)) : null;
1424
1884
  const dataLabels = widget.kind === "chart" ? Boolean(widget.config?.style?.dataLabels) : false;
1425
1885
  const chartStyle = widget.kind === "chart" ? getChartStyle(widget) : {};
1426
- const chartColor = chartStyle.colors === "manual" && chartStyle.manualColor ? chartStyle.manualColor : undefined;
1886
+ const chartColor = resolveChartColor(chartStyle, branding);
1887
+ const selectedSourceObject = widget.config?.binding?.entityId ? {
1888
+ id: widget.config.binding.entityId,
1889
+ label: widget.config.binding.entityLabel || widget.config.binding.entityId
1890
+ } : null;
1427
1891
  return <article
1428
1892
  className={`workspace-widget-preview${selected ? " selected" : ""}`}
1429
1893
  onClick={onSelect}
@@ -1439,6 +1903,10 @@ function WidgetPreview({ widget, selected, onSelect, onMoveStart, onRemove, onRe
1439
1903
  onPointerDown={(event) => onMoveStart(event)}
1440
1904
  >::</span>
1441
1905
  <strong>{widget.title}</strong>
1906
+ {selectedSourceObject ? <span
1907
+ className="workspace-widget-source-chip"
1908
+ title={`${selectedSourceObject.label} · ${selectedSourceObject.id}`}
1909
+ >{selectedSourceObject.label}</span> : null}
1442
1910
  <button
1443
1911
  aria-label={`Remove ${widget.title}`}
1444
1912
  onClick={(event) => {
@@ -1651,7 +2119,7 @@ function WorkspaceManagementPanel({ config, persistence, adapterConfig, onClose
1651
2119
  </div>;
1652
2120
  }
1653
2121
 
1654
- function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, persistence }) {
2122
+ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, integrationSettings, persistence }) {
1655
2123
  const [config, setConfig] = useState(() => {
1656
2124
  const dashboards = Array.isArray(initialConfig.dashboards) && initialConfig.dashboards.length
1657
2125
  ? initialConfig.dashboards.map((dashboard, index) =>
@@ -1706,6 +2174,8 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
1706
2174
  const importInputRef = useRef(null);
1707
2175
  const addSlot = dragPreview || selectedPosition;
1708
2176
  const selectedWidget = activeWidgets.find((widget) => widget.id === selectedWidgetId) || null;
2177
+ const availableIntegrations = useMemo(() => flattenIntegrationSettings(integrationSettings), [integrationSettings]);
2178
+ const branding = config.branding || {};
1709
2179
  const occupiedCells = useMemo(() => {
1710
2180
  const cells = new Set();
1711
2181
  for (const widget of activeWidgets) {
@@ -2769,6 +3239,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
2769
3239
  </button>
2770
3240
  {activeWidgets.map((widget) => <WidgetPreview
2771
3241
  key={widget.id}
3242
+ branding={branding}
2772
3243
  onMoveStart={(event) => beginMoveDrag(widget, event)}
2773
3244
  onRemove={() => removeSelectedWidget(widget.id)}
2774
3245
  onResizeStart={(corner, event) => beginResizeDrag(widget, corner, event)}
@@ -2820,7 +3291,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
2820
3291
  </div> : null}
2821
3292
  {selectedWidget && inspectorPath === "source" ? <SourceSubPanel
2822
3293
  widget={selectedWidget}
2823
- integrations={governedWorkspaceIntegrationCatalog}
3294
+ integrations={availableIntegrations}
2824
3295
  onChange={replaceSelectedWidgetConfig}
2825
3296
  onBack={() => setInspectorPath(SUB_PANEL_ROOT)}
2826
3297
  /> : null}
@@ -2836,6 +3307,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
2836
3307
  /> : null}
2837
3308
  {selectedWidget && inspectorPath === "filter" ? <FilterSubPanel
2838
3309
  widget={selectedWidget}
3310
+ integrations={availableIntegrations}
2839
3311
  onChange={replaceSelectedWidgetConfig}
2840
3312
  onBack={() => setInspectorPath(SUB_PANEL_ROOT)}
2841
3313
  /> : null}
@@ -2846,6 +3318,7 @@ function WorkspaceBuilder({ initialConfig, adapterConfig, integrationAdapter, pe
2846
3318
  </label>
2847
3319
  {selectedWidget.kind === "chart" ? <ChartConfigPanel
2848
3320
  widget={selectedWidget}
3321
+ branding={branding}
2849
3322
  onChange={replaceSelectedWidgetConfig}
2850
3323
  onSubPage={(name) => setInspectorPath(name)}
2851
3324
  /> : null}