@eventcatalog/core 2.43.5 → 2.44.1
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/dist/analytics/analytics.cjs +1 -1
- package/dist/analytics/analytics.js +2 -2
- package/dist/analytics/log-build.cjs +1 -1
- package/dist/analytics/log-build.js +3 -3
- package/dist/{chunk-FLVILMI6.js → chunk-AUGREOCT.js} +1 -1
- package/dist/{chunk-ROUO6U5X.js → chunk-ECUXDBJ7.js} +1 -1
- package/dist/{chunk-JVRNO62X.js → chunk-HGLZ22GT.js} +1 -1
- package/dist/constants.cjs +1 -1
- package/dist/constants.js +1 -1
- package/dist/eventcatalog.cjs +1 -1
- package/dist/eventcatalog.js +3 -3
- package/eventcatalog/auth.config.ts +76 -24
- package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.astro +1 -0
- package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.tsx +116 -43
- package/eventcatalog/src/components/MDX/NodeGraph/VisualiserSearch.tsx +207 -0
- package/eventcatalog/src/middleware-auth.ts +283 -0
- package/eventcatalog/src/middleware.ts +38 -53
- package/eventcatalog/src/pages/auth/login.astro +11 -3
- package/eventcatalog/src/pages/unauthorized/index.astro +84 -0
- package/package.json +3 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
log_build_default
|
|
3
|
-
} from "../chunk-
|
|
4
|
-
import "../chunk-
|
|
5
|
-
import "../chunk-
|
|
3
|
+
} from "../chunk-ECUXDBJ7.js";
|
|
4
|
+
import "../chunk-AUGREOCT.js";
|
|
5
|
+
import "../chunk-HGLZ22GT.js";
|
|
6
6
|
import "../chunk-E7TXTI7G.js";
|
|
7
7
|
export {
|
|
8
8
|
log_build_default as default
|
package/dist/constants.cjs
CHANGED
package/dist/constants.js
CHANGED
package/dist/eventcatalog.cjs
CHANGED
package/dist/eventcatalog.js
CHANGED
|
@@ -6,8 +6,8 @@ import {
|
|
|
6
6
|
} from "./chunk-DCLTVJDP.js";
|
|
7
7
|
import {
|
|
8
8
|
log_build_default
|
|
9
|
-
} from "./chunk-
|
|
10
|
-
import "./chunk-
|
|
9
|
+
} from "./chunk-ECUXDBJ7.js";
|
|
10
|
+
import "./chunk-AUGREOCT.js";
|
|
11
11
|
import {
|
|
12
12
|
catalogToAstro,
|
|
13
13
|
checkAndConvertMdToMdx
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
import "./chunk-EXAALOQA.js";
|
|
16
16
|
import {
|
|
17
17
|
VERSION
|
|
18
|
-
} from "./chunk-
|
|
18
|
+
} from "./chunk-HGLZ22GT.js";
|
|
19
19
|
import {
|
|
20
20
|
isAuthEnabled,
|
|
21
21
|
isBackstagePluginEnabled,
|
|
@@ -7,6 +7,7 @@ import { isAuthEnabled, isSSR } from '@utils/feature';
|
|
|
7
7
|
import Google from '@auth/core/providers/google';
|
|
8
8
|
import Auth0 from '@auth/core/providers/auth0';
|
|
9
9
|
import Entra from '@auth/core/providers/microsoft-entra-id';
|
|
10
|
+
import jwt from 'jsonwebtoken';
|
|
10
11
|
|
|
11
12
|
// Need to try and read the eventcatalog.auth.js file and get the auth providers from there
|
|
12
13
|
const catalogDirectory = process.env.PROJECT_DIR || process.cwd();
|
|
@@ -23,8 +24,7 @@ const getAuthProviders = async () => {
|
|
|
23
24
|
const githubConfig = authConfig.providers.github;
|
|
24
25
|
providers.push(
|
|
25
26
|
GitHub({
|
|
26
|
-
|
|
27
|
-
clientSecret: githubConfig.clientSecret,
|
|
27
|
+
...githubConfig,
|
|
28
28
|
})
|
|
29
29
|
);
|
|
30
30
|
console.log('✅ GitHub provider configured');
|
|
@@ -35,8 +35,7 @@ const getAuthProviders = async () => {
|
|
|
35
35
|
const googleConfig = authConfig.providers.google;
|
|
36
36
|
providers.push(
|
|
37
37
|
Google({
|
|
38
|
-
|
|
39
|
-
clientSecret: googleConfig.clientSecret,
|
|
38
|
+
...googleConfig,
|
|
40
39
|
})
|
|
41
40
|
);
|
|
42
41
|
console.log('✅ Google provider configured');
|
|
@@ -47,9 +46,12 @@ const getAuthProviders = async () => {
|
|
|
47
46
|
const oktaConfig = authConfig.providers.okta;
|
|
48
47
|
providers.push(
|
|
49
48
|
Okta({
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
49
|
+
authorization: {
|
|
50
|
+
params: {
|
|
51
|
+
scope: 'openid email profile groups',
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
...oktaConfig,
|
|
53
55
|
})
|
|
54
56
|
);
|
|
55
57
|
console.log('✅ Okta provider configured');
|
|
@@ -60,22 +62,27 @@ const getAuthProviders = async () => {
|
|
|
60
62
|
const auth0Config = authConfig.providers.auth0;
|
|
61
63
|
providers.push(
|
|
62
64
|
Auth0({
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
65
|
+
authorization: {
|
|
66
|
+
params: {
|
|
67
|
+
scope: 'openid email profile groups',
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
...auth0Config,
|
|
66
71
|
})
|
|
67
72
|
);
|
|
68
73
|
console.log('✅ Auth0 provider configured');
|
|
69
74
|
}
|
|
70
75
|
|
|
71
|
-
// Microsoft Entra ID provider
|
|
72
76
|
if (authConfig.providers?.entra) {
|
|
73
77
|
const entraConfig = authConfig.providers.entra;
|
|
74
78
|
providers.push(
|
|
75
79
|
Entra({
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
80
|
+
authorization: {
|
|
81
|
+
params: {
|
|
82
|
+
scope: 'openid profile email',
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
...entraConfig,
|
|
79
86
|
})
|
|
80
87
|
);
|
|
81
88
|
console.log('✅ Microsoft Entra ID provider configured');
|
|
@@ -87,7 +94,6 @@ const getAuthProviders = async () => {
|
|
|
87
94
|
|
|
88
95
|
return providers;
|
|
89
96
|
} catch (error) {
|
|
90
|
-
console.log('No eventcatalog.auth.js found or error loading config:', (error as Error).message);
|
|
91
97
|
return [];
|
|
92
98
|
}
|
|
93
99
|
};
|
|
@@ -105,7 +111,6 @@ const getAuthConfig = async () => {
|
|
|
105
111
|
|
|
106
112
|
// If custom auth config is specified (Enterprise feature)
|
|
107
113
|
if (authConfig?.customAuthConfig) {
|
|
108
|
-
console.log('🚀 Loading custom auth configuration:', authConfig.customAuthConfig);
|
|
109
114
|
try {
|
|
110
115
|
const customConfig = await import(/* @vite-ignore */ join(catalogDirectory, authConfig.customAuthConfig));
|
|
111
116
|
return customConfig.default;
|
|
@@ -125,6 +130,45 @@ const getAuthConfig = async () => {
|
|
|
125
130
|
// Just allow everyone who can authenticate with the provider
|
|
126
131
|
return true;
|
|
127
132
|
},
|
|
133
|
+
async jwt({ token, account, profile }: { token: any; account: Account | null; profile?: Profile }) {
|
|
134
|
+
// Persist provider info in JWT
|
|
135
|
+
if (account && profile) {
|
|
136
|
+
token.provider = account.provider;
|
|
137
|
+
token.login = (profile as any).login || (profile as any).preferred_username;
|
|
138
|
+
|
|
139
|
+
// Handle groups from different providers
|
|
140
|
+
if (account.provider === 'microsoft-entra-id') {
|
|
141
|
+
token.groups = (profile as any).roles || (profile as any).groups || [];
|
|
142
|
+
} else if (account.provider === 'okta') {
|
|
143
|
+
// For Okta, try profile first, then decode access token
|
|
144
|
+
token.groups = (profile as any).groups || [];
|
|
145
|
+
token.roles = (profile as any).roles || [];
|
|
146
|
+
|
|
147
|
+
// If no groups in profile, decode the access token
|
|
148
|
+
if ((!token.groups || token.groups.length === 0) && account.access_token) {
|
|
149
|
+
try {
|
|
150
|
+
// Import jwt at the top of your file if not already imported
|
|
151
|
+
const decodedAccessToken = jwt.decode(account.access_token);
|
|
152
|
+
|
|
153
|
+
if (decodedAccessToken && typeof decodedAccessToken === 'object') {
|
|
154
|
+
token.groups = (decodedAccessToken as any).groups || [];
|
|
155
|
+
token.roles = (decodedAccessToken as any).roles || [];
|
|
156
|
+
}
|
|
157
|
+
} catch (error) {
|
|
158
|
+
console.error('🔍 Error decoding Okta access token:', error);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
} else if (account.provider === 'auth0') {
|
|
162
|
+
token.groups = (profile as any)['https://eventcatalog.dev/groups'] || [];
|
|
163
|
+
token.roles = (profile as any)['https://eventcatalog.dev/roles'] || [];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Store access token for potential API calls
|
|
167
|
+
token.accessToken = account.access_token;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return token;
|
|
171
|
+
},
|
|
128
172
|
async session({ session, token }: { session: Session; token: any }) {
|
|
129
173
|
// Add provider info to session
|
|
130
174
|
if (token?.provider) {
|
|
@@ -133,15 +177,23 @@ const getAuthConfig = async () => {
|
|
|
133
177
|
if (token?.login) {
|
|
134
178
|
(session.user as any).username = token.login;
|
|
135
179
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
if (account && profile) {
|
|
141
|
-
token.provider = account.provider;
|
|
142
|
-
token.login = (profile as any).login || (profile as any).preferred_username;
|
|
180
|
+
|
|
181
|
+
// Add groups to session
|
|
182
|
+
if (token?.groups) {
|
|
183
|
+
(session.user as any).groups = token.groups;
|
|
143
184
|
}
|
|
144
|
-
|
|
185
|
+
|
|
186
|
+
// Add roles if available
|
|
187
|
+
if (token?.roles) {
|
|
188
|
+
(session.user as any).roles = token.roles;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Add access token to session
|
|
192
|
+
if (token?.accessToken) {
|
|
193
|
+
(session as any).accessToken = token.accessToken;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return session;
|
|
145
197
|
},
|
|
146
198
|
},
|
|
147
199
|
pages: {
|
|
@@ -12,9 +12,13 @@ import {
|
|
|
12
12
|
type Edge,
|
|
13
13
|
type Node,
|
|
14
14
|
useReactFlow,
|
|
15
|
+
getNodesBounds,
|
|
16
|
+
getViewportForBounds,
|
|
15
17
|
} from '@xyflow/react';
|
|
16
18
|
import '@xyflow/react/dist/style.css';
|
|
17
19
|
import { HistoryIcon } from 'lucide-react';
|
|
20
|
+
import { toPng } from 'html-to-image';
|
|
21
|
+
import { DocumentArrowDownIcon } from '@heroicons/react/24/outline';
|
|
18
22
|
// Nodes and edges
|
|
19
23
|
import ServiceNode from './Nodes/Service';
|
|
20
24
|
import FlowNode from './Nodes/Flow';
|
|
@@ -31,11 +35,11 @@ import CustomNode from './Nodes/Custom';
|
|
|
31
35
|
import type { CollectionEntry } from 'astro:content';
|
|
32
36
|
import { navigate } from 'astro:transitions/client';
|
|
33
37
|
import type { CollectionTypes } from '@types';
|
|
34
|
-
import DownloadButton from './DownloadButton';
|
|
35
38
|
import { buildUrl } from '@utils/url-builder';
|
|
36
39
|
import ChannelNode from './Nodes/Channel';
|
|
37
40
|
import { CogIcon } from '@heroicons/react/20/solid';
|
|
38
41
|
import { useEventCatalogVisualiser } from 'src/hooks/eventcatalog-visualizer';
|
|
42
|
+
import VisualiserSearch, { type VisualiserSearchRef } from './VisualiserSearch';
|
|
39
43
|
interface Props {
|
|
40
44
|
nodes: any;
|
|
41
45
|
edges: any;
|
|
@@ -47,6 +51,7 @@ interface Props {
|
|
|
47
51
|
includeKey?: boolean;
|
|
48
52
|
linksToVisualiser?: boolean;
|
|
49
53
|
links?: { label: string; url: string }[];
|
|
54
|
+
mode?: 'full' | 'simple';
|
|
50
55
|
}
|
|
51
56
|
|
|
52
57
|
const getVisualiserUrlForCollection = (collectionItem: CollectionEntry<CollectionTypes>) => {
|
|
@@ -62,6 +67,7 @@ const NodeGraphBuilder = ({
|
|
|
62
67
|
includeKey = true,
|
|
63
68
|
linksToVisualiser = false,
|
|
64
69
|
links = [],
|
|
70
|
+
mode = 'full',
|
|
65
71
|
}: Props) => {
|
|
66
72
|
const nodeTypes = useMemo(
|
|
67
73
|
() => ({
|
|
@@ -92,7 +98,8 @@ const NodeGraphBuilder = ({
|
|
|
92
98
|
const [isAnimated, setIsAnimated] = useState(false);
|
|
93
99
|
const [animateMessages, setAnimateMessages] = useState(false);
|
|
94
100
|
const { hideChannels, toggleChannelsVisibility } = useEventCatalogVisualiser({ nodes, edges, setNodes, setEdges });
|
|
95
|
-
const { fitView } = useReactFlow();
|
|
101
|
+
const { fitView, getNodes } = useReactFlow();
|
|
102
|
+
const searchRef = useRef<VisualiserSearchRef>(null);
|
|
96
103
|
|
|
97
104
|
const resetNodesAndEdges = useCallback(() => {
|
|
98
105
|
setNodes((nds) =>
|
|
@@ -200,10 +207,59 @@ const NodeGraphBuilder = ({
|
|
|
200
207
|
|
|
201
208
|
const handlePaneClick = useCallback(() => {
|
|
202
209
|
setIsSettingsOpen(false);
|
|
210
|
+
searchRef.current?.hideSuggestions();
|
|
203
211
|
resetNodesAndEdges();
|
|
204
212
|
fitView({ duration: 800 });
|
|
205
213
|
}, [resetNodesAndEdges, fitView]);
|
|
206
214
|
|
|
215
|
+
const handleNodeSelect = useCallback(
|
|
216
|
+
(node: Node) => {
|
|
217
|
+
handleNodeClick(null, node);
|
|
218
|
+
},
|
|
219
|
+
[handleNodeClick]
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
const handleSearchClear = useCallback(() => {
|
|
223
|
+
resetNodesAndEdges();
|
|
224
|
+
fitView({ duration: 800 });
|
|
225
|
+
}, [resetNodesAndEdges, fitView]);
|
|
226
|
+
|
|
227
|
+
const downloadImage = useCallback((dataUrl: string, filename?: string) => {
|
|
228
|
+
const a = document.createElement('a');
|
|
229
|
+
a.setAttribute('download', `${filename || 'eventcatalog'}.png`);
|
|
230
|
+
a.setAttribute('href', dataUrl);
|
|
231
|
+
a.click();
|
|
232
|
+
}, []);
|
|
233
|
+
|
|
234
|
+
const handleExportVisual = useCallback(() => {
|
|
235
|
+
const imageWidth = 1024;
|
|
236
|
+
const imageHeight = 768;
|
|
237
|
+
const nodesBounds = getNodesBounds(getNodes());
|
|
238
|
+
const width = imageWidth > nodesBounds.width ? imageWidth : nodesBounds.width;
|
|
239
|
+
const height = imageHeight > nodesBounds.height ? imageHeight : nodesBounds.height;
|
|
240
|
+
const viewport = getViewportForBounds(nodesBounds, width, height, 0.5, 2, 0);
|
|
241
|
+
|
|
242
|
+
// Hide settings panel and controls during export
|
|
243
|
+
setIsSettingsOpen(false);
|
|
244
|
+
const controls = document.querySelector('.react-flow__controls') as HTMLElement;
|
|
245
|
+
if (controls) controls.style.display = 'none';
|
|
246
|
+
|
|
247
|
+
toPng(document.querySelector('.react-flow__viewport') as HTMLElement, {
|
|
248
|
+
backgroundColor: '#f1f1f1',
|
|
249
|
+
width,
|
|
250
|
+
height,
|
|
251
|
+
style: {
|
|
252
|
+
width: width.toString(),
|
|
253
|
+
height: height.toString(),
|
|
254
|
+
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.zoom})`,
|
|
255
|
+
},
|
|
256
|
+
}).then((dataUrl: string) => {
|
|
257
|
+
downloadImage(dataUrl, title);
|
|
258
|
+
// Restore controls
|
|
259
|
+
if (controls) controls.style.display = 'block';
|
|
260
|
+
});
|
|
261
|
+
}, [getNodes, downloadImage, title]);
|
|
262
|
+
|
|
207
263
|
const handleLegendClick = useCallback(
|
|
208
264
|
(collectionType: string, groupId?: string) => {
|
|
209
265
|
const updatedNodes = nodes.map((node: Node<any>) => {
|
|
@@ -304,50 +360,55 @@ const NodeGraphBuilder = ({
|
|
|
304
360
|
className="relative"
|
|
305
361
|
>
|
|
306
362
|
<Panel position="top-center" className="w-full pr-6 ">
|
|
307
|
-
<div className="flex space-x-2 justify-between
|
|
308
|
-
<div>
|
|
309
|
-
<
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
<div className="flex justify-end space-x-2">
|
|
323
|
-
<DownloadButton filename={title} addPadding={false} />
|
|
324
|
-
{/* // Dropdown for links */}
|
|
325
|
-
{links.length > 0 && (
|
|
326
|
-
<div className="relative flex items-center -mt-1">
|
|
327
|
-
<span className="absolute left-2 pointer-events-none flex items-center h-full">
|
|
328
|
-
<HistoryIcon className="h-4 w-4 text-gray-600" />
|
|
329
|
-
</span>
|
|
330
|
-
<select
|
|
331
|
-
value={links.find((link) => window.location.href.includes(link.url))?.url || links[0].url}
|
|
332
|
-
onChange={(e) => navigate(e.target.value)}
|
|
333
|
-
className="appearance-none pl-7 pr-6 py-0 text-[14px] bg-white rounded-md border border-gray-200 hover:bg-gray-100/50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500"
|
|
334
|
-
style={{ minWidth: 120, height: '26px' }}
|
|
335
|
-
>
|
|
336
|
-
{links.map((link) => (
|
|
337
|
-
<option key={link.url} value={link.url}>
|
|
338
|
-
{link.label}
|
|
339
|
-
</option>
|
|
340
|
-
))}
|
|
341
|
-
</select>
|
|
342
|
-
<span className="absolute right-2 pointer-events-none">
|
|
343
|
-
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
344
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
|
345
|
-
</svg>
|
|
346
|
-
</span>
|
|
347
|
-
</div>
|
|
363
|
+
<div className="flex space-x-2 justify-between items-center">
|
|
364
|
+
<div className="flex space-x-2">
|
|
365
|
+
<div>
|
|
366
|
+
<button
|
|
367
|
+
onClick={() => setIsSettingsOpen(!isSettingsOpen)}
|
|
368
|
+
className="py-2.5 px-3 bg-white rounded-md shadow-md hover:bg-purple-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500"
|
|
369
|
+
aria-label="Open settings"
|
|
370
|
+
>
|
|
371
|
+
<CogIcon className="h-5 w-5 text-gray-600" />
|
|
372
|
+
</button>
|
|
373
|
+
</div>
|
|
374
|
+
{title && (
|
|
375
|
+
<span className="block shadow-sm bg-white text-xl z-10 text-black px-4 py-1.5 border-gray-200 rounded-md border opacity-80">
|
|
376
|
+
{title}
|
|
377
|
+
</span>
|
|
348
378
|
)}
|
|
349
379
|
</div>
|
|
380
|
+
{mode === 'full' && (
|
|
381
|
+
<div className="flex justify-end space-x-2 w-96">
|
|
382
|
+
<VisualiserSearch ref={searchRef} nodes={nodes} onNodeSelect={handleNodeSelect} onClear={handleSearchClear} />
|
|
383
|
+
</div>
|
|
384
|
+
)}
|
|
350
385
|
</div>
|
|
386
|
+
{links.length > 0 && (
|
|
387
|
+
<div className="flex justify-end mt-3">
|
|
388
|
+
<div className="relative flex items-center -mt-1">
|
|
389
|
+
<span className="absolute left-2 pointer-events-none flex items-center h-full">
|
|
390
|
+
<HistoryIcon className="h-4 w-4 text-gray-600" />
|
|
391
|
+
</span>
|
|
392
|
+
<select
|
|
393
|
+
value={links.find((link) => window.location.href.includes(link.url))?.url || links[0].url}
|
|
394
|
+
onChange={(e) => navigate(e.target.value)}
|
|
395
|
+
className="appearance-none pl-7 pr-6 py-0 text-[14px] bg-white rounded-md border border-gray-200 hover:bg-gray-100/50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500"
|
|
396
|
+
style={{ minWidth: 120, height: '26px' }}
|
|
397
|
+
>
|
|
398
|
+
{links.map((link) => (
|
|
399
|
+
<option key={link.url} value={link.url}>
|
|
400
|
+
{link.label}
|
|
401
|
+
</option>
|
|
402
|
+
))}
|
|
403
|
+
</select>
|
|
404
|
+
<span className="absolute right-2 pointer-events-none">
|
|
405
|
+
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
|
406
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
|
407
|
+
</svg>
|
|
408
|
+
</span>
|
|
409
|
+
</div>
|
|
410
|
+
</div>
|
|
411
|
+
)}
|
|
351
412
|
</Panel>
|
|
352
413
|
|
|
353
414
|
{isSettingsOpen && (
|
|
@@ -396,6 +457,15 @@ const NodeGraphBuilder = ({
|
|
|
396
457
|
</div>
|
|
397
458
|
<p className="text-[10px] text-gray-500">Show or hide channels in the visualizer.</p>
|
|
398
459
|
</div>
|
|
460
|
+
<div className="pt-4 border-t border-gray-200">
|
|
461
|
+
<button
|
|
462
|
+
onClick={handleExportVisual}
|
|
463
|
+
className="w-full flex items-center justify-center space-x-2 px-4 py-2 bg-purple-600 text-white text-sm font-medium rounded-md hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2"
|
|
464
|
+
>
|
|
465
|
+
<DocumentArrowDownIcon className="w-4 h-4" />
|
|
466
|
+
<span>Export Visual</span>
|
|
467
|
+
</button>
|
|
468
|
+
</div>
|
|
399
469
|
</div>
|
|
400
470
|
</div>
|
|
401
471
|
)}
|
|
@@ -437,6 +507,7 @@ interface NodeGraphProps {
|
|
|
437
507
|
footerLabel?: string;
|
|
438
508
|
linksToVisualiser?: boolean;
|
|
439
509
|
links?: { label: string; url: string }[];
|
|
510
|
+
mode?: 'full' | 'simple';
|
|
440
511
|
}
|
|
441
512
|
|
|
442
513
|
const NodeGraph = ({
|
|
@@ -451,6 +522,7 @@ const NodeGraph = ({
|
|
|
451
522
|
footerLabel,
|
|
452
523
|
linksToVisualiser = false,
|
|
453
524
|
links = [],
|
|
525
|
+
mode = 'full',
|
|
454
526
|
}: NodeGraphProps) => {
|
|
455
527
|
const [elem, setElem] = useState(null);
|
|
456
528
|
const [showFooter, setShowFooter] = useState(true);
|
|
@@ -482,6 +554,7 @@ const NodeGraph = ({
|
|
|
482
554
|
includeKey={includeKey}
|
|
483
555
|
linksToVisualiser={linksToVisualiser}
|
|
484
556
|
links={links}
|
|
557
|
+
mode={mode}
|
|
485
558
|
/>
|
|
486
559
|
|
|
487
560
|
{showFooter && (
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { useState, useCallback, useRef, useEffect, forwardRef, useImperativeHandle } from 'react';
|
|
2
|
+
import type { Node } from '@xyflow/react';
|
|
3
|
+
|
|
4
|
+
interface VisualiserSearchProps {
|
|
5
|
+
nodes: Node[];
|
|
6
|
+
onNodeSelect: (node: Node) => void;
|
|
7
|
+
onClear: () => void;
|
|
8
|
+
onPaneClick?: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface VisualiserSearchRef {
|
|
12
|
+
hideSuggestions: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const VisualiserSearch = forwardRef<VisualiserSearchRef, VisualiserSearchProps>(
|
|
16
|
+
({ nodes, onNodeSelect, onClear, onPaneClick }, ref) => {
|
|
17
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
18
|
+
const [filteredSuggestions, setFilteredSuggestions] = useState<Node[]>([]);
|
|
19
|
+
const [showSuggestions, setShowSuggestions] = useState(false);
|
|
20
|
+
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1);
|
|
21
|
+
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
22
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
23
|
+
|
|
24
|
+
const hideSuggestions = useCallback(() => {
|
|
25
|
+
setShowSuggestions(false);
|
|
26
|
+
setSelectedSuggestionIndex(-1);
|
|
27
|
+
}, []);
|
|
28
|
+
|
|
29
|
+
useImperativeHandle(
|
|
30
|
+
ref,
|
|
31
|
+
() => ({
|
|
32
|
+
hideSuggestions,
|
|
33
|
+
}),
|
|
34
|
+
[hideSuggestions]
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const getNodeDisplayName = useCallback((node: Node) => {
|
|
38
|
+
// @ts-ignore
|
|
39
|
+
const name = node.data?.message?.data?.name || node.data?.service?.data?.name || node.data?.name || node.id;
|
|
40
|
+
// @ts-ignore
|
|
41
|
+
const version = node.data?.message?.data?.version || node.data?.service?.data?.version || node.data?.version;
|
|
42
|
+
return version ? `${name} (v${version})` : name;
|
|
43
|
+
}, []);
|
|
44
|
+
|
|
45
|
+
const getNodeTypeColorClass = useCallback((nodeType: string) => {
|
|
46
|
+
const colorClasses: { [key: string]: string } = {
|
|
47
|
+
events: 'bg-orange-600 text-white',
|
|
48
|
+
services: 'bg-pink-600 text-white',
|
|
49
|
+
flows: 'bg-teal-600 text-white',
|
|
50
|
+
commands: 'bg-blue-600 text-white',
|
|
51
|
+
queries: 'bg-green-600 text-white',
|
|
52
|
+
channels: 'bg-gray-600 text-white',
|
|
53
|
+
externalSystem: 'bg-pink-600 text-white',
|
|
54
|
+
actor: 'bg-yellow-500 text-white',
|
|
55
|
+
step: 'bg-gray-700 text-white',
|
|
56
|
+
user: 'bg-yellow-500 text-white',
|
|
57
|
+
custom: 'bg-gray-500 text-white',
|
|
58
|
+
};
|
|
59
|
+
return colorClasses[nodeType] || 'bg-gray-100 text-gray-700';
|
|
60
|
+
}, []);
|
|
61
|
+
|
|
62
|
+
const handleSearchChange = useCallback(
|
|
63
|
+
(event: React.ChangeEvent<HTMLInputElement>) => {
|
|
64
|
+
const query = event.target.value;
|
|
65
|
+
setSearchQuery(query);
|
|
66
|
+
|
|
67
|
+
if (query.length > 0) {
|
|
68
|
+
const filtered = nodes.filter((node) => {
|
|
69
|
+
const nodeName = getNodeDisplayName(node);
|
|
70
|
+
return nodeName.toLowerCase().includes(query.toLowerCase());
|
|
71
|
+
});
|
|
72
|
+
setFilteredSuggestions(filtered);
|
|
73
|
+
setShowSuggestions(true);
|
|
74
|
+
setSelectedSuggestionIndex(-1);
|
|
75
|
+
} else {
|
|
76
|
+
setFilteredSuggestions(nodes);
|
|
77
|
+
setShowSuggestions(true);
|
|
78
|
+
setSelectedSuggestionIndex(-1);
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
[nodes, getNodeDisplayName]
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const handleSearchFocus = useCallback(() => {
|
|
85
|
+
if (searchQuery.length === 0) {
|
|
86
|
+
setFilteredSuggestions(nodes);
|
|
87
|
+
}
|
|
88
|
+
setShowSuggestions(true);
|
|
89
|
+
setSelectedSuggestionIndex(-1);
|
|
90
|
+
}, [nodes, searchQuery]);
|
|
91
|
+
|
|
92
|
+
const handleSuggestionClick = useCallback(
|
|
93
|
+
(node: Node) => {
|
|
94
|
+
setSearchQuery(getNodeDisplayName(node));
|
|
95
|
+
setShowSuggestions(false);
|
|
96
|
+
onNodeSelect(node);
|
|
97
|
+
},
|
|
98
|
+
[onNodeSelect, getNodeDisplayName]
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const handleSearchKeyDown = useCallback(
|
|
102
|
+
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
103
|
+
if (!showSuggestions || filteredSuggestions.length === 0) return;
|
|
104
|
+
|
|
105
|
+
switch (event.key) {
|
|
106
|
+
case 'ArrowDown':
|
|
107
|
+
event.preventDefault();
|
|
108
|
+
setSelectedSuggestionIndex((prev) => (prev < filteredSuggestions.length - 1 ? prev + 1 : 0));
|
|
109
|
+
break;
|
|
110
|
+
case 'ArrowUp':
|
|
111
|
+
event.preventDefault();
|
|
112
|
+
setSelectedSuggestionIndex((prev) => (prev > 0 ? prev - 1 : filteredSuggestions.length - 1));
|
|
113
|
+
break;
|
|
114
|
+
case 'Enter':
|
|
115
|
+
event.preventDefault();
|
|
116
|
+
if (selectedSuggestionIndex >= 0) {
|
|
117
|
+
handleSuggestionClick(filteredSuggestions[selectedSuggestionIndex]);
|
|
118
|
+
}
|
|
119
|
+
break;
|
|
120
|
+
case 'Escape':
|
|
121
|
+
setShowSuggestions(false);
|
|
122
|
+
setSelectedSuggestionIndex(-1);
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
[showSuggestions, filteredSuggestions, selectedSuggestionIndex, handleSuggestionClick]
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const clearSearch = useCallback(() => {
|
|
130
|
+
setSearchQuery('');
|
|
131
|
+
setShowSuggestions(false);
|
|
132
|
+
setFilteredSuggestions([]);
|
|
133
|
+
setSelectedSuggestionIndex(-1);
|
|
134
|
+
onClear();
|
|
135
|
+
if (searchInputRef.current) {
|
|
136
|
+
searchInputRef.current.focus();
|
|
137
|
+
}
|
|
138
|
+
}, [onClear]);
|
|
139
|
+
|
|
140
|
+
// Close suggestions when clicking outside
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
const handleClickOutside = (event: MouseEvent) => {
|
|
143
|
+
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
144
|
+
setShowSuggestions(false);
|
|
145
|
+
setSelectedSuggestionIndex(-1);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
document.addEventListener('mousedown', handleClickOutside);
|
|
150
|
+
return () => {
|
|
151
|
+
document.removeEventListener('mousedown', handleClickOutside);
|
|
152
|
+
};
|
|
153
|
+
}, []);
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<div ref={containerRef} className="w-full max-w-md mx-auto relative">
|
|
157
|
+
<div className="relative">
|
|
158
|
+
<input
|
|
159
|
+
ref={searchInputRef}
|
|
160
|
+
type="text"
|
|
161
|
+
placeholder="Search nodes..."
|
|
162
|
+
value={searchQuery}
|
|
163
|
+
onChange={handleSearchChange}
|
|
164
|
+
onKeyDown={handleSearchKeyDown}
|
|
165
|
+
onFocus={handleSearchFocus}
|
|
166
|
+
className="w-full px-4 py-2 pr-10 bg-white border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
|
167
|
+
/>
|
|
168
|
+
{searchQuery && (
|
|
169
|
+
<button
|
|
170
|
+
onClick={clearSearch}
|
|
171
|
+
className="absolute right-2 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
|
172
|
+
aria-label="Clear search"
|
|
173
|
+
>
|
|
174
|
+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
175
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
176
|
+
</svg>
|
|
177
|
+
</button>
|
|
178
|
+
)}
|
|
179
|
+
</div>
|
|
180
|
+
{showSuggestions && filteredSuggestions.length > 0 && (
|
|
181
|
+
<div className="absolute top-full left-0 right-0 mt-1 bg-white border border-gray-300 rounded-md shadow-lg z-50 max-h-60 overflow-y-auto">
|
|
182
|
+
{filteredSuggestions.map((node, index) => {
|
|
183
|
+
const nodeName = getNodeDisplayName(node);
|
|
184
|
+
const nodeType = node.type || 'unknown';
|
|
185
|
+
return (
|
|
186
|
+
<div
|
|
187
|
+
key={node.id}
|
|
188
|
+
onClick={() => handleSuggestionClick(node)}
|
|
189
|
+
className={`px-4 py-2 cursor-pointer flex items-center justify-between hover:bg-gray-100 ${
|
|
190
|
+
index === selectedSuggestionIndex ? 'bg-purple-50' : ''
|
|
191
|
+
}`}
|
|
192
|
+
>
|
|
193
|
+
<span className="text-sm font-medium text-gray-900">{nodeName}</span>
|
|
194
|
+
<span className={`text-xs capitalize px-2 py-1 rounded ${getNodeTypeColorClass(nodeType)}`}>{nodeType}</span>
|
|
195
|
+
</div>
|
|
196
|
+
);
|
|
197
|
+
})}
|
|
198
|
+
</div>
|
|
199
|
+
)}
|
|
200
|
+
</div>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
VisualiserSearch.displayName = 'VisualiserSearch';
|
|
206
|
+
|
|
207
|
+
export default VisualiserSearch;
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// src/middleware/auth.ts
|
|
2
|
+
import type { MiddlewareHandler } from 'astro';
|
|
3
|
+
import { getSession } from 'auth-astro/server';
|
|
4
|
+
import { isAuthEnabled } from '@utils/feature';
|
|
5
|
+
import jwt from 'jsonwebtoken';
|
|
6
|
+
|
|
7
|
+
// Define the types in this file
|
|
8
|
+
export interface NormalizedUser {
|
|
9
|
+
id: string;
|
|
10
|
+
email: string;
|
|
11
|
+
name?: string;
|
|
12
|
+
picture?: string;
|
|
13
|
+
roles: string[];
|
|
14
|
+
permissions: string[];
|
|
15
|
+
groups: string[];
|
|
16
|
+
metadata: Record<string, any>;
|
|
17
|
+
provider: 'auth0' | 'okta' | 'microsoft' | 'google' | 'unknown';
|
|
18
|
+
raw: {
|
|
19
|
+
user: any;
|
|
20
|
+
token: any;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Type the locals object with matching utilities
|
|
25
|
+
interface TypedLocals {
|
|
26
|
+
session: any;
|
|
27
|
+
user: NormalizedUser;
|
|
28
|
+
hasRole: (role: string) => boolean;
|
|
29
|
+
hasPermission: (permission: string) => boolean;
|
|
30
|
+
hasGroup: (group: string) => boolean;
|
|
31
|
+
findMatchingRule: (rules: Record<string, () => boolean>, pathname: string) => (() => boolean) | null;
|
|
32
|
+
matchesPattern: (pattern: string, pathname: string) => boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Wildcard matching utilities
|
|
36
|
+
export function matchesPattern(pattern: string, pathname: string): boolean {
|
|
37
|
+
const regexPattern = pattern
|
|
38
|
+
.replace(/\*/g, '[^/]+') // * matches any characters except /
|
|
39
|
+
.replace(/\*\*/g, '.*'); // ** matches any characters including /
|
|
40
|
+
|
|
41
|
+
const regex = new RegExp(`^${regexPattern}$`);
|
|
42
|
+
return regex.test(pathname);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function calculateSpecificity(pattern: string): number {
|
|
46
|
+
let score = 0;
|
|
47
|
+
score += (pattern.length - (pattern.match(/\*/g) || []).length) * 10;
|
|
48
|
+
score -= (pattern.match(/\*/g) || []).length * 5;
|
|
49
|
+
return score;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function findMatchingRule(rules: Record<string, () => boolean>, pathname: string) {
|
|
53
|
+
const matches = [];
|
|
54
|
+
|
|
55
|
+
for (const [pattern, rule] of Object.entries(rules)) {
|
|
56
|
+
if (matchesPattern(pattern, pathname)) {
|
|
57
|
+
matches.push({ pattern, rule, specificity: calculateSpecificity(pattern) });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Sort by specificity (most specific first)
|
|
62
|
+
matches.sort((a, b) => b.specificity - a.specificity);
|
|
63
|
+
|
|
64
|
+
return matches.length > 0 ? matches[0].rule : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const authMiddleware: MiddlewareHandler = async (context, next) => {
|
|
68
|
+
const { request, redirect, locals } = context;
|
|
69
|
+
const url = new URL(request.url);
|
|
70
|
+
const pathname = url.pathname;
|
|
71
|
+
|
|
72
|
+
// If auth is disabled and we are on an auth route, redirect to home
|
|
73
|
+
if (!isAuthEnabled() && pathname.includes('/auth')) {
|
|
74
|
+
return redirect('/');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Auth is disabled, skip auth check
|
|
78
|
+
if (!isAuthEnabled()) {
|
|
79
|
+
return next();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Skip system/browser requests
|
|
83
|
+
const systemRoutes = ['/.well-known/', '/favicon.ico', '/robots.txt', '/sitemap.xml', '/_astro/', '/__astro'];
|
|
84
|
+
const publicRoutes = ['/auth/login', '/auth/signout', '/auth/error', '/api/auth'];
|
|
85
|
+
|
|
86
|
+
if (
|
|
87
|
+
pathname.startsWith('/_') ||
|
|
88
|
+
systemRoutes.some((route) => pathname.startsWith(route)) ||
|
|
89
|
+
pathname.startsWith('/.well-known/') ||
|
|
90
|
+
publicRoutes.some((route) => pathname.startsWith(route))
|
|
91
|
+
) {
|
|
92
|
+
return next();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const session = await getSession(request);
|
|
97
|
+
|
|
98
|
+
if (!session) {
|
|
99
|
+
const callbackUrl = encodeURIComponent(pathname + url.search);
|
|
100
|
+
return redirect(`/auth/login?callbackUrl=${callbackUrl}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Normalize user data across providers
|
|
104
|
+
const normalizedUser = normalizeUserData(session);
|
|
105
|
+
|
|
106
|
+
// Type assertion for locals
|
|
107
|
+
const typedLocals = locals as TypedLocals;
|
|
108
|
+
|
|
109
|
+
// Add session and normalized user to locals
|
|
110
|
+
typedLocals.session = session;
|
|
111
|
+
typedLocals.user = normalizedUser;
|
|
112
|
+
|
|
113
|
+
// Add helper functions for customer middleware
|
|
114
|
+
typedLocals.hasRole = (role: string) => normalizedUser.roles.includes(role);
|
|
115
|
+
typedLocals.hasPermission = (permission: string) => normalizedUser.permissions.includes(permission);
|
|
116
|
+
typedLocals.hasGroup = (group: string) => {
|
|
117
|
+
return normalizedUser.groups.includes(group);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// Add wildcard matching utilities to locals
|
|
121
|
+
typedLocals.findMatchingRule = findMatchingRule;
|
|
122
|
+
typedLocals.matchesPattern = matchesPattern;
|
|
123
|
+
} catch (error) {
|
|
124
|
+
console.error('Session error:', error);
|
|
125
|
+
const callbackUrl = encodeURIComponent(pathname + url.search);
|
|
126
|
+
return redirect(`/auth/login?callbackUrl=${callbackUrl}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return next();
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// Normalize user data from different providers
|
|
133
|
+
function normalizeUserData(session: any): NormalizedUser {
|
|
134
|
+
const user = session.user;
|
|
135
|
+
const accessToken = session.accessToken;
|
|
136
|
+
let decodedToken = null;
|
|
137
|
+
|
|
138
|
+
if (accessToken) {
|
|
139
|
+
try {
|
|
140
|
+
decodedToken = jwt.decode(accessToken);
|
|
141
|
+
} catch (e) {
|
|
142
|
+
console.warn('Could not decode access token');
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const provider = detectProvider(session);
|
|
147
|
+
|
|
148
|
+
switch (provider) {
|
|
149
|
+
case 'auth0':
|
|
150
|
+
return normalizeAuth0User(user, decodedToken);
|
|
151
|
+
case 'okta':
|
|
152
|
+
return normalizeOktaUser(user, decodedToken);
|
|
153
|
+
case 'microsoft':
|
|
154
|
+
return normalizeMicrosoftUser(user, decodedToken);
|
|
155
|
+
case 'google':
|
|
156
|
+
return normalizeGoogleUser(user, decodedToken);
|
|
157
|
+
default:
|
|
158
|
+
return normalizeGenericUser(user, decodedToken);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function detectProvider(session: any): string {
|
|
163
|
+
const account = session.account;
|
|
164
|
+
if (account?.provider) return account.provider;
|
|
165
|
+
|
|
166
|
+
if (session.user?.sub?.startsWith('auth0|')) return 'auth0';
|
|
167
|
+
if (session.user?.iss?.includes('okta.com') || session.user?.provider?.includes('okta')) return 'okta';
|
|
168
|
+
if (session.user?.iss?.includes('microsoft')) return 'microsoft';
|
|
169
|
+
if (session.user?.iss?.includes('google')) return 'google';
|
|
170
|
+
|
|
171
|
+
return 'unknown';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function normalizeAuth0User(user: any, token: any): NormalizedUser {
|
|
175
|
+
return {
|
|
176
|
+
id: user.sub || user.id,
|
|
177
|
+
email: user.email,
|
|
178
|
+
name: user.name,
|
|
179
|
+
picture: user.picture,
|
|
180
|
+
roles: token?.['https://eventcatalog.dev/roles'] || [],
|
|
181
|
+
permissions: token?.permissions || [],
|
|
182
|
+
groups: token?.['https://eventcatalog.dev/groups'] || [],
|
|
183
|
+
metadata: token?.['https://eventcatalog.dev/app_metadata'] || {},
|
|
184
|
+
provider: 'auth0',
|
|
185
|
+
raw: { user, token },
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function normalizeOktaUser(user: any, token: any): NormalizedUser {
|
|
190
|
+
// Try to get groups from multiple sources
|
|
191
|
+
let groups: string[] = [];
|
|
192
|
+
let roles: string[] = [];
|
|
193
|
+
|
|
194
|
+
// Source 1: User object (from session)
|
|
195
|
+
if (user.groups && Array.isArray(user.groups)) {
|
|
196
|
+
groups = user.groups;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Source 2: Decoded token (from access token)
|
|
200
|
+
if ((!groups || groups.length === 0) && token?.groups && Array.isArray(token.groups)) {
|
|
201
|
+
groups = token.groups;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Source 3: Roles
|
|
205
|
+
if (user.roles && Array.isArray(user.roles)) {
|
|
206
|
+
roles = user.roles;
|
|
207
|
+
} else if (token?.roles && Array.isArray(token.roles)) {
|
|
208
|
+
roles = token.roles;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
id: user.sub || user.id,
|
|
213
|
+
email: user.email,
|
|
214
|
+
name: user.name,
|
|
215
|
+
picture: user.picture,
|
|
216
|
+
roles: roles,
|
|
217
|
+
permissions: [],
|
|
218
|
+
groups: groups,
|
|
219
|
+
metadata: {
|
|
220
|
+
department: token?.department || user.department,
|
|
221
|
+
title: token?.title || user.title,
|
|
222
|
+
locale: token?.locale || user.locale,
|
|
223
|
+
},
|
|
224
|
+
provider: 'okta',
|
|
225
|
+
raw: { user, token },
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function normalizeMicrosoftUser(user: any, token: any): NormalizedUser {
|
|
230
|
+
// Groups should now be available directly on the user object from the session callback
|
|
231
|
+
const groups = user.groups || token?.groups || [];
|
|
232
|
+
const roles = user.roles || token?.roles || [];
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
id: user.sub || user.oid,
|
|
236
|
+
email: user.email || user.preferred_username,
|
|
237
|
+
name: user.name,
|
|
238
|
+
picture: user.picture,
|
|
239
|
+
roles: roles,
|
|
240
|
+
permissions: [],
|
|
241
|
+
groups: groups,
|
|
242
|
+
metadata: {
|
|
243
|
+
department: token?.extension_Department,
|
|
244
|
+
jobTitle: token?.jobTitle,
|
|
245
|
+
companyName: token?.companyName,
|
|
246
|
+
},
|
|
247
|
+
provider: 'microsoft',
|
|
248
|
+
raw: { user, token },
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function normalizeGoogleUser(user: any, token: any): NormalizedUser {
|
|
253
|
+
return {
|
|
254
|
+
id: user.sub || user.id,
|
|
255
|
+
email: user.email,
|
|
256
|
+
name: user.name,
|
|
257
|
+
picture: user.picture,
|
|
258
|
+
roles: [],
|
|
259
|
+
permissions: [],
|
|
260
|
+
groups: [],
|
|
261
|
+
metadata: {
|
|
262
|
+
domain: token?.hd,
|
|
263
|
+
locale: token?.locale,
|
|
264
|
+
},
|
|
265
|
+
provider: 'google',
|
|
266
|
+
raw: { user, token },
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function normalizeGenericUser(user: any, token: any): NormalizedUser {
|
|
271
|
+
return {
|
|
272
|
+
id: user.sub || user.id || user.email,
|
|
273
|
+
email: user.email,
|
|
274
|
+
name: user.name,
|
|
275
|
+
picture: user.picture,
|
|
276
|
+
roles: user.roles || token?.roles || [],
|
|
277
|
+
permissions: user.permissions || token?.permissions || [],
|
|
278
|
+
groups: user.groups || token?.groups || [],
|
|
279
|
+
metadata: {},
|
|
280
|
+
provider: 'unknown',
|
|
281
|
+
raw: { user, token },
|
|
282
|
+
};
|
|
283
|
+
}
|
|
@@ -1,62 +1,47 @@
|
|
|
1
|
-
// src/middleware.ts
|
|
2
1
|
import type { MiddlewareHandler } from 'astro';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
2
|
+
import { authMiddleware } from './middleware-auth.ts';
|
|
3
|
+
import { sequence } from 'astro:middleware';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { isSSR } from '@utils/feature';
|
|
6
|
+
|
|
7
|
+
// Try to load customer's custom RBAC middleware
|
|
8
|
+
let customerRbacMiddleware: MiddlewareHandler | null = null;
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
const catalogDirectory = process.env.PROJECT_DIR || process.cwd();
|
|
12
|
+
const customerMiddleware = await import(/* @vite-ignore */ join(catalogDirectory, 'middleware.ts'));
|
|
13
|
+
customerRbacMiddleware = customerMiddleware.rbacMiddleware;
|
|
14
|
+
|
|
15
|
+
if (!isSSR()) {
|
|
16
|
+
// Tell user they need to build in SSR mode
|
|
17
|
+
console.log(
|
|
18
|
+
'🔴 Found custom middleware.ts file. To use RBAC, you need to build in SSR mode, by setting output: "server" in your eventcatalog.config.js file.'
|
|
19
|
+
);
|
|
20
|
+
} else {
|
|
21
|
+
console.log('✅ Loaded custom RBAC middleware');
|
|
19
22
|
}
|
|
23
|
+
} catch (error) {
|
|
24
|
+
console.log('Error loading custom RBAC middleware:', error);
|
|
25
|
+
// Just silently fail, we don't want to block the app
|
|
26
|
+
}
|
|
20
27
|
|
|
21
|
-
|
|
22
|
-
const
|
|
28
|
+
const errorHandlingMiddleware: MiddlewareHandler = async (context, next) => {
|
|
29
|
+
const response = await next();
|
|
23
30
|
|
|
24
|
-
|
|
25
|
-
|
|
31
|
+
if (response.status === 403) {
|
|
32
|
+
const params = new URLSearchParams({
|
|
33
|
+
path: context.url.pathname,
|
|
34
|
+
returnTo: context.url.pathname + context.url.search,
|
|
35
|
+
});
|
|
26
36
|
|
|
27
|
-
|
|
28
|
-
if (
|
|
29
|
-
pathname.startsWith('/_') ||
|
|
30
|
-
systemRoutes.some((route) => pathname.startsWith(route)) ||
|
|
31
|
-
pathname.startsWith('/.well-known/')
|
|
32
|
-
) {
|
|
33
|
-
return next();
|
|
37
|
+
return context.redirect(`/unauthorized?${params.toString()}`);
|
|
34
38
|
}
|
|
35
39
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
return next();
|
|
39
|
-
}
|
|
40
|
+
return response;
|
|
41
|
+
};
|
|
40
42
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (!session) {
|
|
46
|
-
const callbackUrl = encodeURIComponent(pathname + url.search);
|
|
47
|
-
return redirect(`/auth/login?callbackUrl=${callbackUrl}`);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// Add session to locals for pages to use
|
|
51
|
-
// @ts-ignore
|
|
52
|
-
locals.session = session;
|
|
53
|
-
// @ts-ignore
|
|
54
|
-
locals.user = session.user;
|
|
55
|
-
} catch (error) {
|
|
56
|
-
console.error('Session error:', error);
|
|
57
|
-
const callbackUrl = encodeURIComponent(pathname + url.search);
|
|
58
|
-
return redirect(`/auth/login?callbackUrl=${callbackUrl}`);
|
|
59
|
-
}
|
|
43
|
+
const middlewareChain = customerRbacMiddleware
|
|
44
|
+
? [errorHandlingMiddleware, authMiddleware, customerRbacMiddleware]
|
|
45
|
+
: [errorHandlingMiddleware, authMiddleware];
|
|
60
46
|
|
|
61
|
-
|
|
62
|
-
};
|
|
47
|
+
export const onRequest = isSSR() ? sequence(...middlewareChain) : undefined;
|
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
---
|
|
2
2
|
import config from '@config';
|
|
3
3
|
const { title, logo } = config;
|
|
4
|
-
import { getSession } from 'auth-astro/server';
|
|
5
4
|
import { join } from 'node:path';
|
|
6
5
|
import { isAuthEnabled, isSSR } from '@utils/feature';
|
|
7
6
|
|
|
8
|
-
const session = await getSession(Astro.request);
|
|
9
7
|
const catalogDirectory = process.env.PROJECT_DIR || process.cwd();
|
|
10
8
|
|
|
11
9
|
let hasAuthConfigurationFile = false;
|
|
@@ -25,10 +23,20 @@ const shouldShowLogin = hasAuthConfigurationFile && isSSR() && isAuthEnabled() &
|
|
|
25
23
|
// Check if configuration exists but no providers are set up
|
|
26
24
|
const hasConfigButNoProviders = hasAuthConfigurationFile && isSSR() && isAuthEnabled() && providers.length === 0;
|
|
27
25
|
|
|
28
|
-
|
|
26
|
+
// If we are not in SSR mode, redirect to home
|
|
27
|
+
if (!isSSR() || !isAuthEnabled()) {
|
|
29
28
|
return Astro.redirect('/');
|
|
30
29
|
}
|
|
31
30
|
|
|
31
|
+
// If we are in SSR mode, check if the user is already logged in
|
|
32
|
+
if (isSSR() && isAuthEnabled()) {
|
|
33
|
+
const { getSession } = await import(/* @vite-ignore */ 'auth-astro/server');
|
|
34
|
+
const session = await getSession(Astro.request);
|
|
35
|
+
if (session) {
|
|
36
|
+
return Astro.redirect('/');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
32
40
|
// Provider configurations
|
|
33
41
|
const providerConfig = {
|
|
34
42
|
github: {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
---
|
|
2
|
+
import VerticalSideBarLayout from '@layouts/VerticalSideBarLayout.astro';
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
<VerticalSideBarLayout title="Unauthorized - EventCatalog">
|
|
6
|
+
<div class="flex h-[calc(100vh-120px)] bg-white">
|
|
7
|
+
<main class="flex w-full items-center justify-center bg-white text-center sm:p-12">
|
|
8
|
+
<div class="w-full max-w-xl text-center">
|
|
9
|
+
<div class="mb-6 inline-flex h-16 w-16 items-center justify-center rounded-2xl bg-red-100">
|
|
10
|
+
<svg
|
|
11
|
+
class="h-9 w-9 text-red-600"
|
|
12
|
+
viewBox="0 0 24 24"
|
|
13
|
+
fill="none"
|
|
14
|
+
stroke="currentColor"
|
|
15
|
+
stroke-width="2"
|
|
16
|
+
stroke-linecap="round"
|
|
17
|
+
stroke-linejoin="round"
|
|
18
|
+
>
|
|
19
|
+
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
|
|
20
|
+
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
|
|
21
|
+
</svg>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<h1 class="text-3xl font-bold text-gray-800 mb-4">Access Denied</h1>
|
|
25
|
+
|
|
26
|
+
<p class="max-w-md mx-auto text-base text-gray-500 mb-8">You don't have permission to access this resource.</p>
|
|
27
|
+
|
|
28
|
+
<!-- Client-side path display -->
|
|
29
|
+
<div
|
|
30
|
+
id="path-container"
|
|
31
|
+
class="hidden mb-8 w-full max-w-lg mx-auto rounded-lg border border-gray-200 bg-gray-100 p-4 text-left"
|
|
32
|
+
>
|
|
33
|
+
<p class="mb-2 text-sm font-medium text-gray-600">Requested path:</p>
|
|
34
|
+
<code id="requested-path" class="break-all font-mono text-sm text-gray-800"></code>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
|
38
|
+
<button
|
|
39
|
+
onclick="history.back()"
|
|
40
|
+
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
|
41
|
+
>
|
|
42
|
+
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
43
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
|
|
44
|
+
</svg>
|
|
45
|
+
Go Back
|
|
46
|
+
</button>
|
|
47
|
+
|
|
48
|
+
<a
|
|
49
|
+
href="/dashboard"
|
|
50
|
+
class="inline-flex items-center justify-center px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
|
51
|
+
>
|
|
52
|
+
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
53
|
+
<path
|
|
54
|
+
stroke-linecap="round"
|
|
55
|
+
stroke-linejoin="round"
|
|
56
|
+
stroke-width="2"
|
|
57
|
+
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z"></path>
|
|
58
|
+
</svg>
|
|
59
|
+
Go to Dashboard
|
|
60
|
+
</a>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
</main>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<!-- Client-side script -->
|
|
67
|
+
<script>
|
|
68
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
69
|
+
// Get query parameters on the client side
|
|
70
|
+
const url = new URL(window.location.href);
|
|
71
|
+
const path = url.searchParams.get('path');
|
|
72
|
+
|
|
73
|
+
if (path) {
|
|
74
|
+
const pathContainer = document.getElementById('path-container');
|
|
75
|
+
const requestedPath = document.getElementById('requested-path');
|
|
76
|
+
|
|
77
|
+
if (pathContainer && requestedPath) {
|
|
78
|
+
requestedPath.textContent = path;
|
|
79
|
+
pathContainer.classList.remove('hidden');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
</script>
|
|
84
|
+
</VerticalSideBarLayout>
|
package/package.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
"url": "https://github.com/event-catalog/eventcatalog.git"
|
|
7
7
|
},
|
|
8
8
|
"type": "module",
|
|
9
|
-
"version": "2.
|
|
9
|
+
"version": "2.44.1",
|
|
10
10
|
"publishConfig": {
|
|
11
11
|
"access": "public"
|
|
12
12
|
},
|
|
@@ -74,6 +74,7 @@
|
|
|
74
74
|
"gray-matter": "^4.0.3",
|
|
75
75
|
"html-to-image": "^1.11.11",
|
|
76
76
|
"js-yaml": "^4.1.0",
|
|
77
|
+
"jsonwebtoken": "^9.0.2",
|
|
77
78
|
"langchain": "^0.3.19",
|
|
78
79
|
"lodash.debounce": "^4.0.8",
|
|
79
80
|
"lodash.merge": "4.6.2",
|
|
@@ -104,6 +105,7 @@
|
|
|
104
105
|
"@types/dagre": "^0.7.52",
|
|
105
106
|
"@types/diff": "^5.2.2",
|
|
106
107
|
"@types/js-yaml": "^4.0.9",
|
|
108
|
+
"@types/jsonwebtoken": "^9.0.10",
|
|
107
109
|
"@types/lodash.debounce": "^4.0.9",
|
|
108
110
|
"@types/lodash.merge": "4.6.9",
|
|
109
111
|
"@types/node": "^20.14.2",
|