@complai/news-integration 1.0.0

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.
package/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # news-integration
2
+
3
+ Plugin for integration with ComplAi API
@@ -0,0 +1,445 @@
1
+ "use strict";
2
+ const react = require("react");
3
+ const jsxRuntime = require("react/jsx-runtime");
4
+ const designSystem = require("@strapi/design-system");
5
+ const admin = require("@strapi/strapi/admin");
6
+ const PLUGIN_ID = "news-integration";
7
+ const Initializer = ({ setPlugin }) => {
8
+ const ref = react.useRef(setPlugin);
9
+ react.useEffect(() => {
10
+ ref.current(PLUGIN_ID);
11
+ }, []);
12
+ return null;
13
+ };
14
+ const Checkbox = ({ checked, indeterminate, disabled: isDisabled }) => /* @__PURE__ */ jsxRuntime.jsxs(
15
+ designSystem.Box,
16
+ {
17
+ style: {
18
+ width: "18px",
19
+ height: "18px",
20
+ borderRadius: "4px",
21
+ border: checked || indeterminate ? "2px solid #4945ff" : "2px solid #dcdce4",
22
+ background: checked ? "#4945ff" : indeterminate ? "#4945ff" : "white",
23
+ display: "flex",
24
+ alignItems: "center",
25
+ justifyContent: "center",
26
+ flexShrink: 0,
27
+ cursor: isDisabled ? "not-allowed" : "pointer",
28
+ opacity: isDisabled ? 0.5 : 1
29
+ },
30
+ children: [
31
+ checked && /* @__PURE__ */ jsxRuntime.jsx("svg", { width: "10", height: "8", viewBox: "0 0 10 8", fill: "none", children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M1 4L3.5 6.5L9 1", stroke: "white", strokeWidth: "2", strokeLinecap: "round" }) }),
32
+ indeterminate && !checked && /* @__PURE__ */ jsxRuntime.jsx("svg", { width: "10", height: "2", viewBox: "0 0 10 2", fill: "none", children: /* @__PURE__ */ jsxRuntime.jsx("path", { d: "M1 1H9", stroke: "white", strokeWidth: "2", strokeLinecap: "round" }) })
33
+ ]
34
+ }
35
+ );
36
+ const OrganizationPickerInput = ({
37
+ name,
38
+ value,
39
+ onChange,
40
+ error,
41
+ required,
42
+ intlLabel,
43
+ labelAction,
44
+ disabled
45
+ }) => {
46
+ const [organizations, setOrganizations] = react.useState([]);
47
+ const [loading, setLoading] = react.useState(false);
48
+ const [fetchError, setFetchError] = react.useState(null);
49
+ const [isExpanded, setIsExpanded] = react.useState(true);
50
+ const [expandedGroups, setExpandedGroups] = react.useState({});
51
+ const { get } = admin.useFetchClient();
52
+ const selectedOrgs = react.useMemo(() => {
53
+ if (!value) return [];
54
+ if (typeof value === "string") {
55
+ try {
56
+ const parsed = JSON.parse(value);
57
+ return Array.isArray(parsed) ? parsed : [];
58
+ } catch {
59
+ return [];
60
+ }
61
+ }
62
+ return Array.isArray(value) ? value : [];
63
+ }, [value]);
64
+ const selectedOrgIds = react.useMemo(() => {
65
+ return new Set(selectedOrgs.map((org) => org.id));
66
+ }, [selectedOrgs]);
67
+ const computedGroups = react.useMemo(() => {
68
+ if (organizations.length === 0) return [];
69
+ const tagMap = /* @__PURE__ */ new Map();
70
+ const ungroupedOrgs = [];
71
+ organizations.forEach((org) => {
72
+ const tags = org.contentTags || [];
73
+ if (tags.length === 0) {
74
+ ungroupedOrgs.push({ id: org.id, name: org.displayName || org.name });
75
+ } else {
76
+ tags.forEach((tag) => {
77
+ if (!tagMap.has(tag)) {
78
+ tagMap.set(tag, []);
79
+ }
80
+ tagMap.get(tag).push({ id: org.id, name: org.displayName || org.name });
81
+ });
82
+ }
83
+ });
84
+ const groups = Array.from(tagMap.entries()).sort((a, b) => a[0].localeCompare(b[0])).map(([tag, orgs]) => ({
85
+ id: `tag-${tag}`,
86
+ name: tag,
87
+ description: "",
88
+ color: "#4945ff",
89
+ organizations: orgs,
90
+ organizationCount: orgs.length
91
+ }));
92
+ if (ungroupedOrgs.length > 0) {
93
+ groups.push({
94
+ id: "ungrouped",
95
+ name: "Ungrouped",
96
+ description: "Organizations without content tags",
97
+ color: "#8e8ea9",
98
+ organizations: ungroupedOrgs,
99
+ organizationCount: ungroupedOrgs.length
100
+ });
101
+ }
102
+ return groups;
103
+ }, [organizations]);
104
+ const fetchData = react.useCallback(async () => {
105
+ setLoading(true);
106
+ setFetchError(null);
107
+ try {
108
+ const orgsResponse = await get("/news-integration/organizations");
109
+ const orgsData = orgsResponse?.data || {};
110
+ const orgs = orgsData.organizations || [];
111
+ setOrganizations(orgs);
112
+ if (orgsData.error) {
113
+ setFetchError(orgsData.message || orgsData.error);
114
+ } else if (orgs.length === 0) {
115
+ setFetchError("No organizations found. Ensure you are logged in via Auth0 SSO.");
116
+ }
117
+ } catch (err) {
118
+ setFetchError(err?.message || "Failed to load data");
119
+ } finally {
120
+ setLoading(false);
121
+ }
122
+ }, [get]);
123
+ react.useEffect(() => {
124
+ if (isExpanded && organizations.length === 0 && !loading) {
125
+ fetchData();
126
+ }
127
+ }, [isExpanded, fetchData]);
128
+ const updateValue = (newSelection) => {
129
+ onChange({
130
+ target: {
131
+ name,
132
+ value: JSON.stringify(newSelection)
133
+ }
134
+ });
135
+ };
136
+ const handleToggle = (org) => {
137
+ const isSelected = selectedOrgIds.has(org.id);
138
+ let newSelection;
139
+ if (isSelected) {
140
+ newSelection = selectedOrgs.filter((s) => s.id !== org.id);
141
+ } else {
142
+ newSelection = [...selectedOrgs, { id: org.id, name: org.displayName || org.name }];
143
+ }
144
+ updateValue(newSelection);
145
+ };
146
+ const getGroupSelectionState = (group) => {
147
+ const groupOrgIds = (group.organizations || []).map((o) => o.id);
148
+ if (groupOrgIds.length === 0) return "none";
149
+ const selectedCount = groupOrgIds.filter((id) => selectedOrgIds.has(id)).length;
150
+ if (selectedCount === 0) return "none";
151
+ if (selectedCount === groupOrgIds.length) return "all";
152
+ return "partial";
153
+ };
154
+ const handleGroupToggle = (group) => {
155
+ const state = getGroupSelectionState(group);
156
+ const groupOrgIds = new Set((group.organizations || []).map((o) => o.id));
157
+ let newSelection;
158
+ if (state === "all") {
159
+ newSelection = selectedOrgs.filter((s) => !groupOrgIds.has(s.id));
160
+ } else {
161
+ const existingOrgIds = new Set(selectedOrgs.map((s) => s.id));
162
+ const orgsToAdd = (group.organizations || []).filter((o) => !existingOrgIds.has(o.id)).map((o) => ({ id: o.id, name: o.name }));
163
+ newSelection = [...selectedOrgs, ...orgsToAdd];
164
+ }
165
+ updateValue(newSelection);
166
+ };
167
+ const toggleGroupExpansion = (groupId) => {
168
+ setExpandedGroups((prev) => ({
169
+ ...prev,
170
+ [groupId]: !prev[groupId]
171
+ }));
172
+ };
173
+ const handleSelectAll = () => {
174
+ const allOrgs = organizations.map((org) => ({
175
+ id: org.id,
176
+ name: org.displayName || org.name
177
+ }));
178
+ updateValue(allOrgs);
179
+ };
180
+ const handleClearAll = () => {
181
+ updateValue([]);
182
+ };
183
+ const isOrgSelected = (orgId) => selectedOrgIds.has(orgId);
184
+ const displayError = error && !error.includes("JSON") ? error : false;
185
+ return /* @__PURE__ */ jsxRuntime.jsx(designSystem.Field.Root, { name, error: displayError, required, children: /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { direction: "column", alignItems: "stretch", gap: 1, children: [
186
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Field.Label, { action: labelAction, children: intlLabel?.defaultMessage || "Organizations" }),
187
+ /* @__PURE__ */ jsxRuntime.jsxs(
188
+ designSystem.Box,
189
+ {
190
+ padding: 4,
191
+ background: "neutral0",
192
+ borderColor: error ? "danger600" : "neutral200",
193
+ hasRadius: true,
194
+ style: { border: "1px solid" },
195
+ children: [
196
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { justifyContent: "space-between", alignItems: "center", marginBottom: 3, children: [
197
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { alignItems: "center", gap: 2, children: [
198
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { fontSize: "18px" }, children: "🏢" }),
199
+ /* @__PURE__ */ jsxRuntime.jsxs(
200
+ designSystem.Typography,
201
+ {
202
+ variant: "omega",
203
+ fontWeight: "bold",
204
+ textColor: selectedOrgs.length > 0 ? "success600" : "neutral600",
205
+ children: [
206
+ selectedOrgs.length,
207
+ " organization",
208
+ selectedOrgs.length !== 1 ? "s" : "",
209
+ " selected"
210
+ ]
211
+ }
212
+ )
213
+ ] }),
214
+ /* @__PURE__ */ jsxRuntime.jsx(
215
+ designSystem.Button,
216
+ {
217
+ variant: "ghost",
218
+ size: "S",
219
+ onClick: () => setIsExpanded(!isExpanded),
220
+ children: isExpanded ? "▲ Collapse" : "▼ Expand"
221
+ }
222
+ )
223
+ ] }),
224
+ selectedOrgs.length > 0 && /* @__PURE__ */ jsxRuntime.jsx(designSystem.Box, { marginBottom: 3, children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Flex, { wrap: "wrap", gap: 1, children: selectedOrgs.map((org) => /* @__PURE__ */ jsxRuntime.jsx(
225
+ designSystem.Box,
226
+ {
227
+ padding: 1,
228
+ paddingLeft: 2,
229
+ paddingRight: 2,
230
+ background: "primary100",
231
+ hasRadius: true,
232
+ children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "pi", textColor: "primary700", children: org.name })
233
+ },
234
+ org.id
235
+ )) }) }),
236
+ isExpanded && /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
237
+ loading && /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { justifyContent: "center", padding: 4, children: [
238
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Loader, { small: true }),
239
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Box, { paddingLeft: 2, children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "pi", children: "Loading organizations..." }) })
240
+ ] }),
241
+ fetchError && /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Box, { marginBottom: 3, children: [
242
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "pi", textColor: "danger600", children: fetchError }),
243
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Box, { paddingTop: 2, children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Button, { variant: "secondary", size: "S", onClick: fetchData, children: "Retry" }) })
244
+ ] }),
245
+ !loading && !fetchError && organizations.length > 0 && /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
246
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { gap: 2, marginBottom: 3, children: [
247
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Button, { variant: "tertiary", size: "S", onClick: handleSelectAll, disabled, children: "Select All" }),
248
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Button, { variant: "tertiary", size: "S", onClick: handleClearAll, disabled, children: "Clear All" })
249
+ ] }),
250
+ computedGroups.length > 0 && /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Box, { marginBottom: 4, children: [
251
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { alignItems: "center", gap: 2, marginBottom: 2, children: [
252
+ /* @__PURE__ */ jsxRuntime.jsx("span", { style: { fontSize: "16px" }, children: "🏷️" }),
253
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "sigma", textColor: "neutral600", children: "CONTENT TAG GROUPS" })
254
+ ] }),
255
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Box, { style: { maxHeight: "300px", overflowY: "auto" }, children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Flex, { direction: "column", gap: 2, children: computedGroups.map((group) => {
256
+ const selectionState = getGroupSelectionState(group);
257
+ const isGroupExpanded = expandedGroups[group.id];
258
+ const orgCount = (group.organizations || []).length;
259
+ const selectedInGroup = (group.organizations || []).filter((o) => selectedOrgIds.has(o.id)).length;
260
+ return /* @__PURE__ */ jsxRuntime.jsxs(
261
+ designSystem.Box,
262
+ {
263
+ padding: 2,
264
+ paddingLeft: 3,
265
+ paddingRight: 3,
266
+ background: selectionState !== "none" ? "primary100" : "neutral100",
267
+ hasRadius: true,
268
+ width: "100%",
269
+ style: {
270
+ border: selectionState === "all" ? "2px solid #4945ff" : selectionState === "partial" ? "2px dashed #4945ff" : "2px solid transparent",
271
+ cursor: disabled ? "not-allowed" : "pointer"
272
+ },
273
+ onClick: () => !disabled && handleGroupToggle(group),
274
+ children: [
275
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { justifyContent: "space-between", alignItems: "center", children: [
276
+ /* @__PURE__ */ jsxRuntime.jsxs(
277
+ designSystem.Flex,
278
+ {
279
+ alignItems: "center",
280
+ gap: 2,
281
+ children: [
282
+ /* @__PURE__ */ jsxRuntime.jsx(
283
+ Checkbox,
284
+ {
285
+ checked: selectionState === "all",
286
+ indeterminate: selectionState === "partial",
287
+ disabled
288
+ }
289
+ ),
290
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Box, { children: [
291
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { alignItems: "center", gap: 2, children: [
292
+ /* @__PURE__ */ jsxRuntime.jsx(
293
+ designSystem.Box,
294
+ {
295
+ style: {
296
+ width: "12px",
297
+ height: "12px",
298
+ borderRadius: "3px",
299
+ background: group.color || "#4945ff"
300
+ }
301
+ }
302
+ ),
303
+ /* @__PURE__ */ jsxRuntime.jsx(
304
+ designSystem.Typography,
305
+ {
306
+ variant: "omega",
307
+ fontWeight: "semiBold",
308
+ textColor: selectionState !== "none" ? "primary700" : "neutral800",
309
+ children: group.name
310
+ }
311
+ )
312
+ ] }),
313
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Typography, { variant: "pi", textColor: "neutral500", children: [
314
+ selectedInGroup,
315
+ "/",
316
+ orgCount,
317
+ " organizations",
318
+ group.description && ` • ${group.description}`
319
+ ] })
320
+ ] })
321
+ ]
322
+ }
323
+ ),
324
+ /* @__PURE__ */ jsxRuntime.jsx(
325
+ designSystem.Button,
326
+ {
327
+ variant: "ghost",
328
+ size: "S",
329
+ onClick: (e) => {
330
+ e.stopPropagation();
331
+ toggleGroupExpansion(group.id);
332
+ },
333
+ children: isGroupExpanded ? "▲" : "▼"
334
+ }
335
+ )
336
+ ] }),
337
+ isGroupExpanded && /* @__PURE__ */ jsxRuntime.jsx(designSystem.Box, { paddingLeft: 4, paddingTop: 2, children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Flex, { direction: "column", alignItems: "flex-start", gap: 1, children: (group.organizations || []).map((org) => {
338
+ const isSelected = isOrgSelected(org.id);
339
+ return /* @__PURE__ */ jsxRuntime.jsx(
340
+ designSystem.Box,
341
+ {
342
+ padding: 1,
343
+ paddingLeft: 2,
344
+ paddingRight: 2,
345
+ background: isSelected ? "primary100" : "neutral100",
346
+ hasRadius: true,
347
+ style: {
348
+ cursor: disabled ? "not-allowed" : "pointer",
349
+ border: isSelected ? "1px solid #4945ff" : "1px solid transparent",
350
+ opacity: disabled ? 0.5 : 1
351
+ },
352
+ onClick: () => !disabled && handleToggle(org),
353
+ children: /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { alignItems: "center", gap: 1, children: [
354
+ /* @__PURE__ */ jsxRuntime.jsx(
355
+ Checkbox,
356
+ {
357
+ checked: isSelected,
358
+ disabled
359
+ }
360
+ ),
361
+ /* @__PURE__ */ jsxRuntime.jsx(
362
+ designSystem.Typography,
363
+ {
364
+ variant: "pi",
365
+ textColor: isSelected ? "primary700" : "neutral600",
366
+ children: org.name
367
+ }
368
+ )
369
+ ] })
370
+ },
371
+ org.id
372
+ );
373
+ }) }) })
374
+ ]
375
+ },
376
+ group.id
377
+ );
378
+ }) }) })
379
+ ] })
380
+ ] }),
381
+ !loading && !fetchError && organizations.length === 0 && /* @__PURE__ */ jsxRuntime.jsx(designSystem.Button, { variant: "secondary", onClick: fetchData, children: "Load Organizations" })
382
+ ] })
383
+ ]
384
+ }
385
+ ),
386
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Field.Hint, {}),
387
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Field.Error, {})
388
+ ] }) });
389
+ };
390
+ const OrganizationIcon = () => {
391
+ return react.createElement("span", { role: "img", "aria-label": "organizations" }, "🏢");
392
+ };
393
+ const index = {
394
+ register(app) {
395
+ app.customFields.register({
396
+ name: "organization-picker",
397
+ pluginId: PLUGIN_ID,
398
+ type: "text",
399
+ intlLabel: {
400
+ id: `${PLUGIN_ID}.organization-picker.label`,
401
+ defaultMessage: "Organization Picker"
402
+ },
403
+ intlDescription: {
404
+ id: `${PLUGIN_ID}.organization-picker.description`,
405
+ defaultMessage: "Select organizations for multi-tenant content"
406
+ },
407
+ icon: OrganizationIcon,
408
+ components: {
409
+ Input: async () => ({ default: OrganizationPickerInput })
410
+ },
411
+ options: {
412
+ base: [],
413
+ advanced: [
414
+ {
415
+ sectionTitle: {
416
+ id: "global.settings",
417
+ defaultMessage: "Settings"
418
+ },
419
+ items: [
420
+ {
421
+ name: "required",
422
+ type: "checkbox",
423
+ intlLabel: {
424
+ id: `${PLUGIN_ID}.organization-picker.required`,
425
+ defaultMessage: "Required field"
426
+ },
427
+ description: {
428
+ id: `${PLUGIN_ID}.organization-picker.required.description`,
429
+ defaultMessage: "You won't be able to create an entry if this field is empty"
430
+ }
431
+ }
432
+ ]
433
+ }
434
+ ]
435
+ }
436
+ });
437
+ app.registerPlugin({
438
+ id: PLUGIN_ID,
439
+ initializer: Initializer,
440
+ isReady: false,
441
+ name: PLUGIN_ID
442
+ });
443
+ }
444
+ };
445
+ module.exports = index;
@@ -0,0 +1,446 @@
1
+ import { useRef, useEffect, useState, useMemo, useCallback, createElement } from "react";
2
+ import { jsx, jsxs, Fragment } from "react/jsx-runtime";
3
+ import { Field, Flex, Box, Typography, Button, Loader } from "@strapi/design-system";
4
+ import { useFetchClient } from "@strapi/strapi/admin";
5
+ const PLUGIN_ID = "news-integration";
6
+ const Initializer = ({ setPlugin }) => {
7
+ const ref = useRef(setPlugin);
8
+ useEffect(() => {
9
+ ref.current(PLUGIN_ID);
10
+ }, []);
11
+ return null;
12
+ };
13
+ const Checkbox = ({ checked, indeterminate, disabled: isDisabled }) => /* @__PURE__ */ jsxs(
14
+ Box,
15
+ {
16
+ style: {
17
+ width: "18px",
18
+ height: "18px",
19
+ borderRadius: "4px",
20
+ border: checked || indeterminate ? "2px solid #4945ff" : "2px solid #dcdce4",
21
+ background: checked ? "#4945ff" : indeterminate ? "#4945ff" : "white",
22
+ display: "flex",
23
+ alignItems: "center",
24
+ justifyContent: "center",
25
+ flexShrink: 0,
26
+ cursor: isDisabled ? "not-allowed" : "pointer",
27
+ opacity: isDisabled ? 0.5 : 1
28
+ },
29
+ children: [
30
+ checked && /* @__PURE__ */ jsx("svg", { width: "10", height: "8", viewBox: "0 0 10 8", fill: "none", children: /* @__PURE__ */ jsx("path", { d: "M1 4L3.5 6.5L9 1", stroke: "white", strokeWidth: "2", strokeLinecap: "round" }) }),
31
+ indeterminate && !checked && /* @__PURE__ */ jsx("svg", { width: "10", height: "2", viewBox: "0 0 10 2", fill: "none", children: /* @__PURE__ */ jsx("path", { d: "M1 1H9", stroke: "white", strokeWidth: "2", strokeLinecap: "round" }) })
32
+ ]
33
+ }
34
+ );
35
+ const OrganizationPickerInput = ({
36
+ name,
37
+ value,
38
+ onChange,
39
+ error,
40
+ required,
41
+ intlLabel,
42
+ labelAction,
43
+ disabled
44
+ }) => {
45
+ const [organizations, setOrganizations] = useState([]);
46
+ const [loading, setLoading] = useState(false);
47
+ const [fetchError, setFetchError] = useState(null);
48
+ const [isExpanded, setIsExpanded] = useState(true);
49
+ const [expandedGroups, setExpandedGroups] = useState({});
50
+ const { get } = useFetchClient();
51
+ const selectedOrgs = useMemo(() => {
52
+ if (!value) return [];
53
+ if (typeof value === "string") {
54
+ try {
55
+ const parsed = JSON.parse(value);
56
+ return Array.isArray(parsed) ? parsed : [];
57
+ } catch {
58
+ return [];
59
+ }
60
+ }
61
+ return Array.isArray(value) ? value : [];
62
+ }, [value]);
63
+ const selectedOrgIds = useMemo(() => {
64
+ return new Set(selectedOrgs.map((org) => org.id));
65
+ }, [selectedOrgs]);
66
+ const computedGroups = useMemo(() => {
67
+ if (organizations.length === 0) return [];
68
+ const tagMap = /* @__PURE__ */ new Map();
69
+ const ungroupedOrgs = [];
70
+ organizations.forEach((org) => {
71
+ const tags = org.contentTags || [];
72
+ if (tags.length === 0) {
73
+ ungroupedOrgs.push({ id: org.id, name: org.displayName || org.name });
74
+ } else {
75
+ tags.forEach((tag) => {
76
+ if (!tagMap.has(tag)) {
77
+ tagMap.set(tag, []);
78
+ }
79
+ tagMap.get(tag).push({ id: org.id, name: org.displayName || org.name });
80
+ });
81
+ }
82
+ });
83
+ const groups = Array.from(tagMap.entries()).sort((a, b) => a[0].localeCompare(b[0])).map(([tag, orgs]) => ({
84
+ id: `tag-${tag}`,
85
+ name: tag,
86
+ description: "",
87
+ color: "#4945ff",
88
+ organizations: orgs,
89
+ organizationCount: orgs.length
90
+ }));
91
+ if (ungroupedOrgs.length > 0) {
92
+ groups.push({
93
+ id: "ungrouped",
94
+ name: "Ungrouped",
95
+ description: "Organizations without content tags",
96
+ color: "#8e8ea9",
97
+ organizations: ungroupedOrgs,
98
+ organizationCount: ungroupedOrgs.length
99
+ });
100
+ }
101
+ return groups;
102
+ }, [organizations]);
103
+ const fetchData = useCallback(async () => {
104
+ setLoading(true);
105
+ setFetchError(null);
106
+ try {
107
+ const orgsResponse = await get("/news-integration/organizations");
108
+ const orgsData = orgsResponse?.data || {};
109
+ const orgs = orgsData.organizations || [];
110
+ setOrganizations(orgs);
111
+ if (orgsData.error) {
112
+ setFetchError(orgsData.message || orgsData.error);
113
+ } else if (orgs.length === 0) {
114
+ setFetchError("No organizations found. Ensure you are logged in via Auth0 SSO.");
115
+ }
116
+ } catch (err) {
117
+ setFetchError(err?.message || "Failed to load data");
118
+ } finally {
119
+ setLoading(false);
120
+ }
121
+ }, [get]);
122
+ useEffect(() => {
123
+ if (isExpanded && organizations.length === 0 && !loading) {
124
+ fetchData();
125
+ }
126
+ }, [isExpanded, fetchData]);
127
+ const updateValue = (newSelection) => {
128
+ onChange({
129
+ target: {
130
+ name,
131
+ value: JSON.stringify(newSelection)
132
+ }
133
+ });
134
+ };
135
+ const handleToggle = (org) => {
136
+ const isSelected = selectedOrgIds.has(org.id);
137
+ let newSelection;
138
+ if (isSelected) {
139
+ newSelection = selectedOrgs.filter((s) => s.id !== org.id);
140
+ } else {
141
+ newSelection = [...selectedOrgs, { id: org.id, name: org.displayName || org.name }];
142
+ }
143
+ updateValue(newSelection);
144
+ };
145
+ const getGroupSelectionState = (group) => {
146
+ const groupOrgIds = (group.organizations || []).map((o) => o.id);
147
+ if (groupOrgIds.length === 0) return "none";
148
+ const selectedCount = groupOrgIds.filter((id) => selectedOrgIds.has(id)).length;
149
+ if (selectedCount === 0) return "none";
150
+ if (selectedCount === groupOrgIds.length) return "all";
151
+ return "partial";
152
+ };
153
+ const handleGroupToggle = (group) => {
154
+ const state = getGroupSelectionState(group);
155
+ const groupOrgIds = new Set((group.organizations || []).map((o) => o.id));
156
+ let newSelection;
157
+ if (state === "all") {
158
+ newSelection = selectedOrgs.filter((s) => !groupOrgIds.has(s.id));
159
+ } else {
160
+ const existingOrgIds = new Set(selectedOrgs.map((s) => s.id));
161
+ const orgsToAdd = (group.organizations || []).filter((o) => !existingOrgIds.has(o.id)).map((o) => ({ id: o.id, name: o.name }));
162
+ newSelection = [...selectedOrgs, ...orgsToAdd];
163
+ }
164
+ updateValue(newSelection);
165
+ };
166
+ const toggleGroupExpansion = (groupId) => {
167
+ setExpandedGroups((prev) => ({
168
+ ...prev,
169
+ [groupId]: !prev[groupId]
170
+ }));
171
+ };
172
+ const handleSelectAll = () => {
173
+ const allOrgs = organizations.map((org) => ({
174
+ id: org.id,
175
+ name: org.displayName || org.name
176
+ }));
177
+ updateValue(allOrgs);
178
+ };
179
+ const handleClearAll = () => {
180
+ updateValue([]);
181
+ };
182
+ const isOrgSelected = (orgId) => selectedOrgIds.has(orgId);
183
+ const displayError = error && !error.includes("JSON") ? error : false;
184
+ return /* @__PURE__ */ jsx(Field.Root, { name, error: displayError, required, children: /* @__PURE__ */ jsxs(Flex, { direction: "column", alignItems: "stretch", gap: 1, children: [
185
+ /* @__PURE__ */ jsx(Field.Label, { action: labelAction, children: intlLabel?.defaultMessage || "Organizations" }),
186
+ /* @__PURE__ */ jsxs(
187
+ Box,
188
+ {
189
+ padding: 4,
190
+ background: "neutral0",
191
+ borderColor: error ? "danger600" : "neutral200",
192
+ hasRadius: true,
193
+ style: { border: "1px solid" },
194
+ children: [
195
+ /* @__PURE__ */ jsxs(Flex, { justifyContent: "space-between", alignItems: "center", marginBottom: 3, children: [
196
+ /* @__PURE__ */ jsxs(Flex, { alignItems: "center", gap: 2, children: [
197
+ /* @__PURE__ */ jsx("span", { style: { fontSize: "18px" }, children: "🏢" }),
198
+ /* @__PURE__ */ jsxs(
199
+ Typography,
200
+ {
201
+ variant: "omega",
202
+ fontWeight: "bold",
203
+ textColor: selectedOrgs.length > 0 ? "success600" : "neutral600",
204
+ children: [
205
+ selectedOrgs.length,
206
+ " organization",
207
+ selectedOrgs.length !== 1 ? "s" : "",
208
+ " selected"
209
+ ]
210
+ }
211
+ )
212
+ ] }),
213
+ /* @__PURE__ */ jsx(
214
+ Button,
215
+ {
216
+ variant: "ghost",
217
+ size: "S",
218
+ onClick: () => setIsExpanded(!isExpanded),
219
+ children: isExpanded ? "▲ Collapse" : "▼ Expand"
220
+ }
221
+ )
222
+ ] }),
223
+ selectedOrgs.length > 0 && /* @__PURE__ */ jsx(Box, { marginBottom: 3, children: /* @__PURE__ */ jsx(Flex, { wrap: "wrap", gap: 1, children: selectedOrgs.map((org) => /* @__PURE__ */ jsx(
224
+ Box,
225
+ {
226
+ padding: 1,
227
+ paddingLeft: 2,
228
+ paddingRight: 2,
229
+ background: "primary100",
230
+ hasRadius: true,
231
+ children: /* @__PURE__ */ jsx(Typography, { variant: "pi", textColor: "primary700", children: org.name })
232
+ },
233
+ org.id
234
+ )) }) }),
235
+ isExpanded && /* @__PURE__ */ jsxs(Fragment, { children: [
236
+ loading && /* @__PURE__ */ jsxs(Flex, { justifyContent: "center", padding: 4, children: [
237
+ /* @__PURE__ */ jsx(Loader, { small: true }),
238
+ /* @__PURE__ */ jsx(Box, { paddingLeft: 2, children: /* @__PURE__ */ jsx(Typography, { variant: "pi", children: "Loading organizations..." }) })
239
+ ] }),
240
+ fetchError && /* @__PURE__ */ jsxs(Box, { marginBottom: 3, children: [
241
+ /* @__PURE__ */ jsx(Typography, { variant: "pi", textColor: "danger600", children: fetchError }),
242
+ /* @__PURE__ */ jsx(Box, { paddingTop: 2, children: /* @__PURE__ */ jsx(Button, { variant: "secondary", size: "S", onClick: fetchData, children: "Retry" }) })
243
+ ] }),
244
+ !loading && !fetchError && organizations.length > 0 && /* @__PURE__ */ jsxs(Fragment, { children: [
245
+ /* @__PURE__ */ jsxs(Flex, { gap: 2, marginBottom: 3, children: [
246
+ /* @__PURE__ */ jsx(Button, { variant: "tertiary", size: "S", onClick: handleSelectAll, disabled, children: "Select All" }),
247
+ /* @__PURE__ */ jsx(Button, { variant: "tertiary", size: "S", onClick: handleClearAll, disabled, children: "Clear All" })
248
+ ] }),
249
+ computedGroups.length > 0 && /* @__PURE__ */ jsxs(Box, { marginBottom: 4, children: [
250
+ /* @__PURE__ */ jsxs(Flex, { alignItems: "center", gap: 2, marginBottom: 2, children: [
251
+ /* @__PURE__ */ jsx("span", { style: { fontSize: "16px" }, children: "🏷️" }),
252
+ /* @__PURE__ */ jsx(Typography, { variant: "sigma", textColor: "neutral600", children: "CONTENT TAG GROUPS" })
253
+ ] }),
254
+ /* @__PURE__ */ jsx(Box, { style: { maxHeight: "300px", overflowY: "auto" }, children: /* @__PURE__ */ jsx(Flex, { direction: "column", gap: 2, children: computedGroups.map((group) => {
255
+ const selectionState = getGroupSelectionState(group);
256
+ const isGroupExpanded = expandedGroups[group.id];
257
+ const orgCount = (group.organizations || []).length;
258
+ const selectedInGroup = (group.organizations || []).filter((o) => selectedOrgIds.has(o.id)).length;
259
+ return /* @__PURE__ */ jsxs(
260
+ Box,
261
+ {
262
+ padding: 2,
263
+ paddingLeft: 3,
264
+ paddingRight: 3,
265
+ background: selectionState !== "none" ? "primary100" : "neutral100",
266
+ hasRadius: true,
267
+ width: "100%",
268
+ style: {
269
+ border: selectionState === "all" ? "2px solid #4945ff" : selectionState === "partial" ? "2px dashed #4945ff" : "2px solid transparent",
270
+ cursor: disabled ? "not-allowed" : "pointer"
271
+ },
272
+ onClick: () => !disabled && handleGroupToggle(group),
273
+ children: [
274
+ /* @__PURE__ */ jsxs(Flex, { justifyContent: "space-between", alignItems: "center", children: [
275
+ /* @__PURE__ */ jsxs(
276
+ Flex,
277
+ {
278
+ alignItems: "center",
279
+ gap: 2,
280
+ children: [
281
+ /* @__PURE__ */ jsx(
282
+ Checkbox,
283
+ {
284
+ checked: selectionState === "all",
285
+ indeterminate: selectionState === "partial",
286
+ disabled
287
+ }
288
+ ),
289
+ /* @__PURE__ */ jsxs(Box, { children: [
290
+ /* @__PURE__ */ jsxs(Flex, { alignItems: "center", gap: 2, children: [
291
+ /* @__PURE__ */ jsx(
292
+ Box,
293
+ {
294
+ style: {
295
+ width: "12px",
296
+ height: "12px",
297
+ borderRadius: "3px",
298
+ background: group.color || "#4945ff"
299
+ }
300
+ }
301
+ ),
302
+ /* @__PURE__ */ jsx(
303
+ Typography,
304
+ {
305
+ variant: "omega",
306
+ fontWeight: "semiBold",
307
+ textColor: selectionState !== "none" ? "primary700" : "neutral800",
308
+ children: group.name
309
+ }
310
+ )
311
+ ] }),
312
+ /* @__PURE__ */ jsxs(Typography, { variant: "pi", textColor: "neutral500", children: [
313
+ selectedInGroup,
314
+ "/",
315
+ orgCount,
316
+ " organizations",
317
+ group.description && ` • ${group.description}`
318
+ ] })
319
+ ] })
320
+ ]
321
+ }
322
+ ),
323
+ /* @__PURE__ */ jsx(
324
+ Button,
325
+ {
326
+ variant: "ghost",
327
+ size: "S",
328
+ onClick: (e) => {
329
+ e.stopPropagation();
330
+ toggleGroupExpansion(group.id);
331
+ },
332
+ children: isGroupExpanded ? "▲" : "▼"
333
+ }
334
+ )
335
+ ] }),
336
+ isGroupExpanded && /* @__PURE__ */ jsx(Box, { paddingLeft: 4, paddingTop: 2, children: /* @__PURE__ */ jsx(Flex, { direction: "column", alignItems: "flex-start", gap: 1, children: (group.organizations || []).map((org) => {
337
+ const isSelected = isOrgSelected(org.id);
338
+ return /* @__PURE__ */ jsx(
339
+ Box,
340
+ {
341
+ padding: 1,
342
+ paddingLeft: 2,
343
+ paddingRight: 2,
344
+ background: isSelected ? "primary100" : "neutral100",
345
+ hasRadius: true,
346
+ style: {
347
+ cursor: disabled ? "not-allowed" : "pointer",
348
+ border: isSelected ? "1px solid #4945ff" : "1px solid transparent",
349
+ opacity: disabled ? 0.5 : 1
350
+ },
351
+ onClick: () => !disabled && handleToggle(org),
352
+ children: /* @__PURE__ */ jsxs(Flex, { alignItems: "center", gap: 1, children: [
353
+ /* @__PURE__ */ jsx(
354
+ Checkbox,
355
+ {
356
+ checked: isSelected,
357
+ disabled
358
+ }
359
+ ),
360
+ /* @__PURE__ */ jsx(
361
+ Typography,
362
+ {
363
+ variant: "pi",
364
+ textColor: isSelected ? "primary700" : "neutral600",
365
+ children: org.name
366
+ }
367
+ )
368
+ ] })
369
+ },
370
+ org.id
371
+ );
372
+ }) }) })
373
+ ]
374
+ },
375
+ group.id
376
+ );
377
+ }) }) })
378
+ ] })
379
+ ] }),
380
+ !loading && !fetchError && organizations.length === 0 && /* @__PURE__ */ jsx(Button, { variant: "secondary", onClick: fetchData, children: "Load Organizations" })
381
+ ] })
382
+ ]
383
+ }
384
+ ),
385
+ /* @__PURE__ */ jsx(Field.Hint, {}),
386
+ /* @__PURE__ */ jsx(Field.Error, {})
387
+ ] }) });
388
+ };
389
+ const OrganizationIcon = () => {
390
+ return createElement("span", { role: "img", "aria-label": "organizations" }, "🏢");
391
+ };
392
+ const index = {
393
+ register(app) {
394
+ app.customFields.register({
395
+ name: "organization-picker",
396
+ pluginId: PLUGIN_ID,
397
+ type: "text",
398
+ intlLabel: {
399
+ id: `${PLUGIN_ID}.organization-picker.label`,
400
+ defaultMessage: "Organization Picker"
401
+ },
402
+ intlDescription: {
403
+ id: `${PLUGIN_ID}.organization-picker.description`,
404
+ defaultMessage: "Select organizations for multi-tenant content"
405
+ },
406
+ icon: OrganizationIcon,
407
+ components: {
408
+ Input: async () => ({ default: OrganizationPickerInput })
409
+ },
410
+ options: {
411
+ base: [],
412
+ advanced: [
413
+ {
414
+ sectionTitle: {
415
+ id: "global.settings",
416
+ defaultMessage: "Settings"
417
+ },
418
+ items: [
419
+ {
420
+ name: "required",
421
+ type: "checkbox",
422
+ intlLabel: {
423
+ id: `${PLUGIN_ID}.organization-picker.required`,
424
+ defaultMessage: "Required field"
425
+ },
426
+ description: {
427
+ id: `${PLUGIN_ID}.organization-picker.required.description`,
428
+ defaultMessage: "You won't be able to create an entry if this field is empty"
429
+ }
430
+ }
431
+ ]
432
+ }
433
+ ]
434
+ }
435
+ });
436
+ app.registerPlugin({
437
+ id: PLUGIN_ID,
438
+ initializer: Initializer,
439
+ isReady: false,
440
+ name: PLUGIN_ID
441
+ });
442
+ }
443
+ };
444
+ export {
445
+ index as default
446
+ };
@@ -0,0 +1,191 @@
1
+ "use strict";
2
+ const https = require("https");
3
+ const http = require("http");
4
+ const _interopDefault = (e) => e && e.__esModule ? e : { default: e };
5
+ const https__default = /* @__PURE__ */ _interopDefault(https);
6
+ const http__default = /* @__PURE__ */ _interopDefault(http);
7
+ const register = ({ strapi: strapi2 }) => {
8
+ strapi2.customFields.register({
9
+ name: "organization-picker",
10
+ plugin: "news-integration",
11
+ type: "text",
12
+ inputSize: {
13
+ default: 12,
14
+ isResizable: false
15
+ }
16
+ });
17
+ };
18
+ const config = {
19
+ default: {
20
+ apiBase: process.env.STRAPI_ADMIN_API_BASE || "https://localhost:4200"
21
+ },
22
+ validator() {
23
+ }
24
+ };
25
+ function makeRequest(url, options = {}) {
26
+ return new Promise((resolve, reject) => {
27
+ const parsedUrl = new URL(url);
28
+ const isHttps = parsedUrl.protocol === "https:";
29
+ const client = isHttps ? https__default.default : http__default.default;
30
+ const requestOptions = {
31
+ hostname: parsedUrl.hostname,
32
+ port: parsedUrl.port || (isHttps ? 443 : 80),
33
+ path: parsedUrl.pathname + parsedUrl.search,
34
+ method: options.method || "GET",
35
+ headers: options.headers || {},
36
+ // Ignore SSL certificate errors for localhost in development
37
+ rejectUnauthorized: !(parsedUrl.hostname === "localhost" || parsedUrl.hostname === "127.0.0.1")
38
+ };
39
+ const req = client.request(requestOptions, (res) => {
40
+ let data = "";
41
+ res.on("data", (chunk) => {
42
+ data += chunk;
43
+ });
44
+ res.on("end", () => {
45
+ resolve({
46
+ ok: Boolean(res.statusCode && res.statusCode >= 200 && res.statusCode < 300),
47
+ status: res.statusCode || 500,
48
+ statusText: res.statusMessage || "Unknown error",
49
+ text: () => Promise.resolve(data),
50
+ json: () => Promise.resolve(JSON.parse(data))
51
+ });
52
+ });
53
+ });
54
+ req.on("error", (error) => {
55
+ reject(error);
56
+ });
57
+ if (options.body) {
58
+ req.write(options.body);
59
+ }
60
+ req.end();
61
+ });
62
+ }
63
+ const TOKEN_TTL_MS = 8 * 60 * 60 * 1e3;
64
+ function getAuth0Context(ctx) {
65
+ const adminUser = ctx.state?.user;
66
+ const email = adminUser?.email;
67
+ if (!email) {
68
+ return { idToken: null, accessToken: null, email: null };
69
+ }
70
+ let idToken = null;
71
+ let accessToken = null;
72
+ const store = strapi.auth0TokenStore;
73
+ if (store) {
74
+ const entry = store.get(email.toLowerCase());
75
+ if (entry && Date.now() - entry.timestamp < TOKEN_TTL_MS) {
76
+ idToken = entry.idToken;
77
+ accessToken = entry.accessToken;
78
+ }
79
+ }
80
+ return { idToken, accessToken, email };
81
+ }
82
+ const newsIntegration = {
83
+ /**
84
+ * Get plugin configuration (for admin UI status display)
85
+ */
86
+ getConfig(ctx) {
87
+ const config2 = strapi.config.get("plugin::news-integration");
88
+ ctx.body = {
89
+ apiBase: config2?.apiBase || ""
90
+ };
91
+ },
92
+ /**
93
+ * Fetch user's organizations from the ComplAi API.
94
+ *
95
+ * Uses the Auth0 access token (preferred) or ID token from the SSO session.
96
+ * The access token is a JWT when the Grant config includes an audience,
97
+ * which is required for api-auth0 to accept the request.
98
+ */
99
+ async getOrganizations(ctx) {
100
+ const config2 = strapi.config.get("plugin::news-integration") || {};
101
+ const auth0Context = getAuth0Context(ctx);
102
+ const { accessToken, idToken, email: userEmail } = auth0Context;
103
+ const bearerToken = accessToken || idToken;
104
+ if (!bearerToken) {
105
+ strapi.log.warn("news-integration: No Auth0 token — ensure you are logged in via Auth0 SSO");
106
+ ctx.body = {
107
+ organizations: [],
108
+ error: "No Auth0 token available",
109
+ message: "Please ensure you are logged in via Auth0 SSO. Try logging out and back in."
110
+ };
111
+ return;
112
+ }
113
+ try {
114
+ const apiBase = config2.apiBase || "https://localhost:4200";
115
+ const profileEndpoint = `${apiBase}/api/v2/me`;
116
+ strapi.log.debug(`news-integration: Fetching organizations for ${userEmail} (using ${accessToken ? "access_token" : "id_token"})`);
117
+ const response = await makeRequest(profileEndpoint, {
118
+ method: "GET",
119
+ headers: {
120
+ "Content-Type": "application/json",
121
+ "Authorization": `Bearer ${bearerToken}`
122
+ }
123
+ });
124
+ if (!response.ok) {
125
+ const errorText = await response.text();
126
+ strapi.log.error(`news-integration: API error ${response.status}: ${errorText.substring(0, 200)}`);
127
+ ctx.body = {
128
+ organizations: [],
129
+ error: "Failed to fetch user profile",
130
+ message: `API returned ${response.status}: ${response.statusText}`
131
+ };
132
+ return;
133
+ }
134
+ const data = await response.json();
135
+ const organizations = (data.organizations || []).map((org) => ({
136
+ id: org.id,
137
+ name: org.displayName || org.name || org.id,
138
+ displayName: org.displayName || org.name || org.id,
139
+ plan: org.plan || null,
140
+ role: org.role || null,
141
+ contentTags: Array.isArray(org.contentTags) ? org.contentTags : []
142
+ }));
143
+ strapi.log.debug(`news-integration: Found ${organizations.length} organizations for ${userEmail}`);
144
+ ctx.body = {
145
+ organizations,
146
+ userEmail: data.email || userEmail,
147
+ userId: data.userId || data.sub
148
+ };
149
+ } catch (error) {
150
+ strapi.log.error(`news-integration: Error fetching organizations: ${error.message}`);
151
+ ctx.body = {
152
+ organizations: [],
153
+ error: "Internal error",
154
+ message: error.message
155
+ };
156
+ }
157
+ }
158
+ };
159
+ const controllers = {
160
+ newsIntegration
161
+ };
162
+ const routes = [
163
+ {
164
+ method: "GET",
165
+ path: "/config",
166
+ handler: "newsIntegration.getConfig",
167
+ config: {
168
+ policies: [],
169
+ auth: false
170
+ }
171
+ },
172
+ {
173
+ method: "GET",
174
+ path: "/organizations",
175
+ handler: "newsIntegration.getOrganizations",
176
+ config: {
177
+ policies: [],
178
+ // Require Strapi admin authentication
179
+ auth: {
180
+ strategy: "admin"
181
+ }
182
+ }
183
+ }
184
+ ];
185
+ const index = {
186
+ register,
187
+ config,
188
+ controllers,
189
+ routes
190
+ };
191
+ module.exports = index;
@@ -0,0 +1,189 @@
1
+ import https from "https";
2
+ import http from "http";
3
+ const register = ({ strapi: strapi2 }) => {
4
+ strapi2.customFields.register({
5
+ name: "organization-picker",
6
+ plugin: "news-integration",
7
+ type: "text",
8
+ inputSize: {
9
+ default: 12,
10
+ isResizable: false
11
+ }
12
+ });
13
+ };
14
+ const config = {
15
+ default: {
16
+ apiBase: process.env.STRAPI_ADMIN_API_BASE || "https://localhost:4200"
17
+ },
18
+ validator() {
19
+ }
20
+ };
21
+ function makeRequest(url, options = {}) {
22
+ return new Promise((resolve, reject) => {
23
+ const parsedUrl = new URL(url);
24
+ const isHttps = parsedUrl.protocol === "https:";
25
+ const client = isHttps ? https : http;
26
+ const requestOptions = {
27
+ hostname: parsedUrl.hostname,
28
+ port: parsedUrl.port || (isHttps ? 443 : 80),
29
+ path: parsedUrl.pathname + parsedUrl.search,
30
+ method: options.method || "GET",
31
+ headers: options.headers || {},
32
+ // Ignore SSL certificate errors for localhost in development
33
+ rejectUnauthorized: !(parsedUrl.hostname === "localhost" || parsedUrl.hostname === "127.0.0.1")
34
+ };
35
+ const req = client.request(requestOptions, (res) => {
36
+ let data = "";
37
+ res.on("data", (chunk) => {
38
+ data += chunk;
39
+ });
40
+ res.on("end", () => {
41
+ resolve({
42
+ ok: Boolean(res.statusCode && res.statusCode >= 200 && res.statusCode < 300),
43
+ status: res.statusCode || 500,
44
+ statusText: res.statusMessage || "Unknown error",
45
+ text: () => Promise.resolve(data),
46
+ json: () => Promise.resolve(JSON.parse(data))
47
+ });
48
+ });
49
+ });
50
+ req.on("error", (error) => {
51
+ reject(error);
52
+ });
53
+ if (options.body) {
54
+ req.write(options.body);
55
+ }
56
+ req.end();
57
+ });
58
+ }
59
+ const TOKEN_TTL_MS = 8 * 60 * 60 * 1e3;
60
+ function getAuth0Context(ctx) {
61
+ const adminUser = ctx.state?.user;
62
+ const email = adminUser?.email;
63
+ if (!email) {
64
+ return { idToken: null, accessToken: null, email: null };
65
+ }
66
+ let idToken = null;
67
+ let accessToken = null;
68
+ const store = strapi.auth0TokenStore;
69
+ if (store) {
70
+ const entry = store.get(email.toLowerCase());
71
+ if (entry && Date.now() - entry.timestamp < TOKEN_TTL_MS) {
72
+ idToken = entry.idToken;
73
+ accessToken = entry.accessToken;
74
+ }
75
+ }
76
+ return { idToken, accessToken, email };
77
+ }
78
+ const newsIntegration = {
79
+ /**
80
+ * Get plugin configuration (for admin UI status display)
81
+ */
82
+ getConfig(ctx) {
83
+ const config2 = strapi.config.get("plugin::news-integration");
84
+ ctx.body = {
85
+ apiBase: config2?.apiBase || ""
86
+ };
87
+ },
88
+ /**
89
+ * Fetch user's organizations from the ComplAi API.
90
+ *
91
+ * Uses the Auth0 access token (preferred) or ID token from the SSO session.
92
+ * The access token is a JWT when the Grant config includes an audience,
93
+ * which is required for api-auth0 to accept the request.
94
+ */
95
+ async getOrganizations(ctx) {
96
+ const config2 = strapi.config.get("plugin::news-integration") || {};
97
+ const auth0Context = getAuth0Context(ctx);
98
+ const { accessToken, idToken, email: userEmail } = auth0Context;
99
+ const bearerToken = accessToken || idToken;
100
+ if (!bearerToken) {
101
+ strapi.log.warn("news-integration: No Auth0 token — ensure you are logged in via Auth0 SSO");
102
+ ctx.body = {
103
+ organizations: [],
104
+ error: "No Auth0 token available",
105
+ message: "Please ensure you are logged in via Auth0 SSO. Try logging out and back in."
106
+ };
107
+ return;
108
+ }
109
+ try {
110
+ const apiBase = config2.apiBase || "https://localhost:4200";
111
+ const profileEndpoint = `${apiBase}/api/v2/me`;
112
+ strapi.log.debug(`news-integration: Fetching organizations for ${userEmail} (using ${accessToken ? "access_token" : "id_token"})`);
113
+ const response = await makeRequest(profileEndpoint, {
114
+ method: "GET",
115
+ headers: {
116
+ "Content-Type": "application/json",
117
+ "Authorization": `Bearer ${bearerToken}`
118
+ }
119
+ });
120
+ if (!response.ok) {
121
+ const errorText = await response.text();
122
+ strapi.log.error(`news-integration: API error ${response.status}: ${errorText.substring(0, 200)}`);
123
+ ctx.body = {
124
+ organizations: [],
125
+ error: "Failed to fetch user profile",
126
+ message: `API returned ${response.status}: ${response.statusText}`
127
+ };
128
+ return;
129
+ }
130
+ const data = await response.json();
131
+ const organizations = (data.organizations || []).map((org) => ({
132
+ id: org.id,
133
+ name: org.displayName || org.name || org.id,
134
+ displayName: org.displayName || org.name || org.id,
135
+ plan: org.plan || null,
136
+ role: org.role || null,
137
+ contentTags: Array.isArray(org.contentTags) ? org.contentTags : []
138
+ }));
139
+ strapi.log.debug(`news-integration: Found ${organizations.length} organizations for ${userEmail}`);
140
+ ctx.body = {
141
+ organizations,
142
+ userEmail: data.email || userEmail,
143
+ userId: data.userId || data.sub
144
+ };
145
+ } catch (error) {
146
+ strapi.log.error(`news-integration: Error fetching organizations: ${error.message}`);
147
+ ctx.body = {
148
+ organizations: [],
149
+ error: "Internal error",
150
+ message: error.message
151
+ };
152
+ }
153
+ }
154
+ };
155
+ const controllers = {
156
+ newsIntegration
157
+ };
158
+ const routes = [
159
+ {
160
+ method: "GET",
161
+ path: "/config",
162
+ handler: "newsIntegration.getConfig",
163
+ config: {
164
+ policies: [],
165
+ auth: false
166
+ }
167
+ },
168
+ {
169
+ method: "GET",
170
+ path: "/organizations",
171
+ handler: "newsIntegration.getOrganizations",
172
+ config: {
173
+ policies: [],
174
+ // Require Strapi admin authentication
175
+ auth: {
176
+ strategy: "admin"
177
+ }
178
+ }
179
+ }
180
+ ];
181
+ const index = {
182
+ register,
183
+ config,
184
+ controllers,
185
+ routes
186
+ };
187
+ export {
188
+ index as default
189
+ };
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "version": "1.0.0",
3
+ "keywords": [],
4
+ "type": "commonjs",
5
+ "exports": {
6
+ "./package.json": "./package.json",
7
+ "./strapi-admin": {
8
+ "types": "./dist/admin/src/index.d.ts",
9
+ "source": "./admin/src/index.ts",
10
+ "import": "./dist/admin/index.mjs",
11
+ "require": "./dist/admin/index.js",
12
+ "default": "./dist/admin/index.js"
13
+ },
14
+ "./strapi-server": {
15
+ "types": "./dist/server/src/index.d.ts",
16
+ "source": "./server/src/index.ts",
17
+ "import": "./dist/server/index.mjs",
18
+ "require": "./dist/server/index.js",
19
+ "default": "./dist/server/index.js"
20
+ }
21
+ },
22
+ "files": [
23
+ "dist"
24
+ ],
25
+ "scripts": {
26
+ "build": "strapi-plugin build",
27
+ "watch": "strapi-plugin watch",
28
+ "watch:link": "strapi-plugin watch:link",
29
+ "verify": "strapi-plugin verify",
30
+ "test:ts:front": "run -T tsc -p admin/tsconfig.json",
31
+ "test:ts:back": "run -T tsc -p server/tsconfig.json"
32
+ },
33
+ "dependencies": {},
34
+ "devDependencies": {
35
+ "@strapi/design-system": "^2.0.0-rc.30",
36
+ "@strapi/icons": "^2.0.0-rc.30",
37
+ "@strapi/sdk-plugin": "^5.4.0",
38
+ "@strapi/strapi": "^5.36.1",
39
+ "@strapi/typescript-utils": "^5.36.1",
40
+ "@types/node": "^22.10.2",
41
+ "@types/react": "^19.2.14",
42
+ "@types/react-dom": "^19.2.3",
43
+ "prettier": "^3.8.1",
44
+ "react": "^18.3.1",
45
+ "react-dom": "^18.3.1",
46
+ "react-router-dom": "^6.30.3",
47
+ "styled-components": "^6.3.11",
48
+ "typescript": "^5.9.3"
49
+ },
50
+ "peerDependencies": {
51
+ "@strapi/design-system": "^2.0.0-rc.30",
52
+ "@strapi/icons": "^2.0.0-rc.30",
53
+ "@strapi/sdk-plugin": "^5.4.0",
54
+ "@strapi/strapi": "^5.36.1",
55
+ "react": "^18.3.1",
56
+ "react-dom": "^18.3.1",
57
+ "react-router-dom": "^6.30.3",
58
+ "styled-components": "^6.3.11"
59
+ },
60
+ "strapi": {
61
+ "kind": "plugin",
62
+ "name": "news-integration",
63
+ "displayName": "Complai API integration",
64
+ "description": "Integration plugin for Auth0 token acquisition and company API calls"
65
+ },
66
+ "name": "@complai/news-integration",
67
+ "description": "Integration plugin for Auth0 token acquisition and company API calls"
68
+ }