blob: fe17527110e6e1a3993c79a0b401971fa331e119 [file] [log] [blame]
gioaba9a962025-04-25 14:19:40 +00001import { v4 as uuidv4 } from "uuid";
giod0026612025-05-08 13:00:36 +00002import {
3 useStateStore,
4 AppNode,
5 GatewayHttpsNode,
6 ServiceNode,
7 nodeLabel,
8 useEnv,
9 nodeIsConnectable,
10} from "@/lib/state";
11import { Handle, Position, useNodes } from "@xyflow/react";
12import { NodeRect } from "./node-rect";
13import { useCallback, useEffect, useMemo } from "react";
gio5f2f1002025-03-20 18:38:48 +040014import { z } from "zod";
15import { zodResolver } from "@hookform/resolvers/zod";
giod0026612025-05-08 13:00:36 +000016import { useForm, EventType, DeepPartial } from "react-hook-form";
17import { Form, FormControl, FormField, FormItem, FormMessage } from "./ui/form";
18import { Input } from "./ui/input";
19import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
gio9b2d4962025-05-07 04:59:39 +000020import { Label } from "./ui/label";
21import { Button } from "./ui/button";
22import { XIcon } from "lucide-react";
gio3d0bf032025-06-05 06:57:26 +000023import { Switch } from "./ui/switch";
gio3fb133d2025-06-13 07:20:24 +000024import { NodeDetailsProps } from "@/lib/types";
gio5f2f1002025-03-20 18:38:48 +040025
26const schema = z.object({
giod0026612025-05-08 13:00:36 +000027 network: z.string().min(1, "reqired"),
28 subdomain: z.string().min(1, "required"),
gio5f2f1002025-03-20 18:38:48 +040029});
30
31const connectedToSchema = z.object({
giod0026612025-05-08 13:00:36 +000032 id: z.string(),
33 portId: z.string(),
gio5f2f1002025-03-20 18:38:48 +040034});
35
gio9b2d4962025-05-07 04:59:39 +000036const authEnabledSchema = z.object({
giod0026612025-05-08 13:00:36 +000037 enabled: z.boolean(),
gio9b2d4962025-05-07 04:59:39 +000038});
39
40const authGroupSchema = z.object({
giod0026612025-05-08 13:00:36 +000041 group: z.string(),
gio9b2d4962025-05-07 04:59:39 +000042});
43
44const authNoAuthPatternSchema = z.object({
giod0026612025-05-08 13:00:36 +000045 noAuthPathPattern: z.string(),
gio9b2d4962025-05-07 04:59:39 +000046});
47
gio5f2f1002025-03-20 18:38:48 +040048export function NodeGatewayHttps(node: GatewayHttpsNode) {
giod0026612025-05-08 13:00:36 +000049 const { id, selected } = node;
50 const isConnectableNetwork = useMemo(() => nodeIsConnectable(node, "subdomain"), [node]);
51 const isConnectable = useMemo(() => nodeIsConnectable(node, "https"), [node]);
52 return (
53 <NodeRect id={id} selected={selected} type={node.type} state={node.data.state}>
54 {nodeLabel(node)}
55 <Handle
56 type={"source"}
57 id="subdomain"
58 position={Position.Top}
59 isConnectable={isConnectableNetwork}
60 isConnectableStart={isConnectableNetwork}
61 isConnectableEnd={isConnectableNetwork}
62 />
63 <Handle
64 type={"target"}
65 id="https"
66 position={Position.Bottom}
67 isConnectable={isConnectable}
68 isConnectableStart={isConnectable}
69 isConnectableEnd={isConnectable}
70 />
71 </NodeRect>
72 );
gio5f2f1002025-03-20 18:38:48 +040073}
74
gio3fb133d2025-06-13 07:20:24 +000075export function NodeGatewayHttpsDetails({ node, disabled }: NodeDetailsProps<GatewayHttpsNode>) {
gio08acd3a2025-06-12 12:15:30 +000076 const { id, data } = node;
giod0026612025-05-08 13:00:36 +000077 const store = useStateStore();
78 const env = useEnv();
79 const form = useForm<z.infer<typeof schema>>({
80 resolver: zodResolver(schema),
81 mode: "onChange",
82 defaultValues: {
83 network: data.network,
84 subdomain: data.subdomain,
85 },
86 });
87 useEffect(() => {
88 const sub = form.watch(
89 (
90 value: DeepPartial<z.infer<typeof schema>>,
91 { name }: { name?: keyof z.infer<typeof schema> | undefined; type?: EventType | undefined },
92 ) => {
93 if (name === "network") {
94 let edges = store.edges;
95 if (data.network !== undefined) {
96 edges = edges.filter((e) => {
97 if (
98 e.source === id &&
99 e.sourceHandle === "subdomain" &&
100 e.target === data.network &&
101 e.targetHandle === "subdomain"
102 ) {
103 return false;
104 } else {
105 return true;
106 }
107 });
108 }
109 if (value.network !== undefined) {
110 edges = edges.concat({
111 id: uuidv4(),
112 source: id,
113 sourceHandle: "subdomain",
114 target: value.network,
115 targetHandle: "subdomain",
116 });
117 }
118 store.setEdges(edges);
119 store.updateNodeData<"gateway-https">(id, { network: value.network });
120 } else if (name === "subdomain") {
121 store.updateNodeData<"gateway-https">(id, { subdomain: value.subdomain });
122 }
123 },
124 );
125 return () => sub.unsubscribe();
126 }, [id, data, form, store]);
gio6d8b71c2025-05-19 12:57:35 +0000127 const network = useMemo(() => {
128 if (data.network === undefined) {
129 return null;
130 }
131 return env.networks.find((n) => n.domain === data.network)!;
132 }, [data, env]);
giod0026612025-05-08 13:00:36 +0000133 const connectedToForm = useForm<z.infer<typeof connectedToSchema>>({
134 resolver: zodResolver(connectedToSchema),
135 mode: "onChange",
136 defaultValues: {
137 id: data.https?.serviceId,
138 portId: data.https?.portId,
139 },
140 });
141 useEffect(() => {
142 connectedToForm.reset({
143 id: data.https?.serviceId,
144 portId: data.https?.portId,
145 });
146 }, [connectedToForm, data]);
147 const nodes = useNodes<AppNode>();
148 const selected = useMemo(() => {
149 if (data !== undefined && data.https !== undefined) {
150 const https = data.https;
151 return nodes.find((n) => n.id === https.serviceId)! as ServiceNode;
152 }
153 return null;
154 }, [data, nodes]);
155 const selectable = useMemo(() => {
156 return nodes.filter((n) => {
157 if (n.id === id) {
158 return false;
159 }
160 if (selected !== null && selected.id === id) {
161 return true;
162 }
163 if (n.type !== "app") {
164 return false;
165 }
166 return n.data && n.data.ports && n.data.ports.length > 0;
167 });
168 }, [id, nodes, selected]);
169 useEffect(() => {
170 const sub = connectedToForm.watch(
171 (
172 value: DeepPartial<z.infer<typeof connectedToSchema>>,
173 {
174 name,
175 type,
176 }: { name?: keyof z.infer<typeof connectedToSchema> | undefined; type?: EventType | undefined },
177 ) => {
178 if (type !== "change") {
179 return;
180 }
181 switch (name) {
182 case "id": {
183 if (!value.id) {
184 break;
185 }
186 const current = store.edges.filter((e) => e.target === id);
187 const cid = current[0] ? current[0].id : undefined;
188 store.replaceEdge(
189 {
190 source: value.id,
191 sourceHandle: "ports",
192 target: id,
193 targetHandle: "https",
194 },
195 cid,
196 );
197 break;
198 }
199 case "portId":
200 store.updateNodeData<"gateway-https">(id, {
201 https: {
202 serviceId: value.id,
203 portId: value.portId,
204 },
205 });
206 break;
207 }
208 },
209 );
210 return () => sub.unsubscribe();
211 }, [id, connectedToForm, store, selectable]);
212 const authEnabledForm = useForm<z.infer<typeof authEnabledSchema>>({
213 resolver: zodResolver(authEnabledSchema),
214 mode: "onChange",
215 defaultValues: {
216 enabled: data.auth ? data.auth.enabled : false,
217 },
218 });
219 const authGroupForm = useForm<z.infer<typeof authGroupSchema>>({
220 resolver: zodResolver(authGroupSchema),
221 mode: "onSubmit",
222 defaultValues: {
223 group: "",
224 },
225 });
226 const authNoAuthPatternFrom = useForm<z.infer<typeof authNoAuthPatternSchema>>({
227 resolver: zodResolver(authNoAuthPatternSchema),
228 mode: "onChange",
229 defaultValues: {
230 noAuthPathPattern: "",
231 },
232 });
233 useEffect(() => {
234 const sub = authEnabledForm.watch((value, { name }) => {
235 if (name === "enabled") {
236 store.updateNodeData<"gateway-https">(id, {
237 auth: {
238 ...data.auth,
239 enabled: value.enabled,
240 },
241 });
242 }
243 });
244 return () => sub.unsubscribe();
245 }, [id, data, authEnabledForm, store]);
246 const removeGroup = useCallback(
247 (group: string) => {
248 const groups = data?.auth?.groups || [];
249 store.updateNodeData<"gateway-https">(id, {
250 auth: {
251 ...data.auth,
252 groups: groups.filter((g) => g !== group),
253 },
254 });
255 return true;
256 },
257 [id, data, store],
258 );
259 const onGroupSubmit = useCallback(
260 (values: z.infer<typeof authGroupSchema>) => {
261 const groups = data.auth?.groups || [];
262 groups.push(values.group);
263 store.updateNodeData<"gateway-https">(id, {
264 auth: {
265 ...data.auth,
266 groups,
267 },
268 });
269 authGroupForm.reset();
270 },
271 [id, data, store, authGroupForm],
272 );
273 const removeNoAuthPathPattern = useCallback(
274 (path: string) => {
275 const noAuthPathPatterns = data?.auth?.noAuthPathPatterns || [];
276 store.updateNodeData<"gateway-https">(id, {
277 auth: {
278 ...data.auth,
279 noAuthPathPatterns: noAuthPathPatterns.filter((p) => p !== path),
280 },
281 });
282 return true;
283 },
284 [id, data, store],
285 );
286 const onNoAuthPathPatternSubmit = useCallback(
287 (values: z.infer<typeof authNoAuthPatternSchema>) => {
288 const noAuthPathPatterns = data.auth?.noAuthPathPatterns || [];
289 noAuthPathPatterns.push(values.noAuthPathPattern);
290 store.updateNodeData<"gateway-https">(id, {
291 auth: {
292 ...data.auth,
293 noAuthPathPatterns,
294 },
295 });
296 authNoAuthPatternFrom.reset();
297 },
298 [id, data, store, authNoAuthPatternFrom],
299 );
300 return (
301 <>
302 <Form {...form}>
303 <form className="space-y-2">
304 <FormField
305 control={form.control}
306 name="network"
307 render={({ field }) => (
308 <FormItem>
gio48fde052025-05-14 09:48:08 +0000309 <Select
310 onValueChange={field.onChange}
311 defaultValue={field.value}
gio3ec94242025-05-16 12:46:57 +0000312 disabled={data.readonly || disabled}
gio48fde052025-05-14 09:48:08 +0000313 >
giod0026612025-05-08 13:00:36 +0000314 <FormControl>
315 <SelectTrigger>
316 <SelectValue placeholder="Network" />
317 </SelectTrigger>
318 </FormControl>
319 <SelectContent>
320 {env.networks.map((n) => (
321 <SelectItem
322 key={n.name}
323 value={n.domain}
324 >{`${n.name} - ${n.domain}`}</SelectItem>
325 ))}
326 </SelectContent>
327 </Select>
328 <FormMessage />
329 </FormItem>
330 )}
331 />
332 <FormField
333 control={form.control}
334 name="subdomain"
335 render={({ field }) => (
336 <FormItem>
337 <FormControl>
gio3ec94242025-05-16 12:46:57 +0000338 <Input placeholder="subdomain" {...field} disabled={data.readonly || disabled} />
giod0026612025-05-08 13:00:36 +0000339 </FormControl>
340 <FormMessage />
341 </FormItem>
342 )}
343 />
344 </form>
345 </Form>
346 <Form {...connectedToForm}>
347 <form className="space-y-2">
348 <FormField
349 control={connectedToForm.control}
350 name="id"
351 render={({ field }) => (
352 <FormItem>
gio48fde052025-05-14 09:48:08 +0000353 <Select
354 onValueChange={field.onChange}
355 defaultValue={field.value}
gio3ec94242025-05-16 12:46:57 +0000356 disabled={data.readonly || disabled}
gio48fde052025-05-14 09:48:08 +0000357 >
giod0026612025-05-08 13:00:36 +0000358 <FormControl>
359 <SelectTrigger>
360 <SelectValue placeholder="Service" />
361 </SelectTrigger>
362 </FormControl>
363 <SelectContent>
364 {selectable.map((n) => (
365 <SelectItem key={n.id} value={n.id}>
366 {nodeLabel(n)}
367 </SelectItem>
368 ))}
369 </SelectContent>
370 </Select>
371 <FormMessage />
372 </FormItem>
373 )}
374 />
375 <FormField
376 control={connectedToForm.control}
377 name="portId"
378 render={({ field }) => (
379 <FormItem>
gio48fde052025-05-14 09:48:08 +0000380 <Select
381 onValueChange={field.onChange}
382 defaultValue={field.value}
gio3ec94242025-05-16 12:46:57 +0000383 disabled={data.readonly || disabled}
gio48fde052025-05-14 09:48:08 +0000384 >
giod0026612025-05-08 13:00:36 +0000385 <FormControl>
386 <SelectTrigger>
387 <SelectValue placeholder="Port" />
388 </SelectTrigger>
389 </FormControl>
390 <SelectContent>
391 {selected &&
392 selected.data.ports.map((p) => (
393 <SelectItem key={p.id} value={p.id}>
394 {p.name} - {p.value}
395 </SelectItem>
396 ))}
397 </SelectContent>
398 </Select>
399 <FormMessage />
400 </FormItem>
401 )}
402 />
403 </form>
404 </Form>
gio6d8b71c2025-05-19 12:57:35 +0000405 {network?.hasAuth && (
giod0026612025-05-08 13:00:36 +0000406 <>
gio6d8b71c2025-05-19 12:57:35 +0000407 Auth
408 <Form {...authEnabledForm}>
409 <form className="space-y-2">
giod0026612025-05-08 13:00:36 +0000410 <FormField
gio6d8b71c2025-05-19 12:57:35 +0000411 control={authEnabledForm.control}
412 name="enabled"
giod0026612025-05-08 13:00:36 +0000413 render={({ field }) => (
414 <FormItem>
gio6d8b71c2025-05-19 12:57:35 +0000415 <div className="flex flex-row gap-1 items-center">
gio3d0bf032025-06-05 06:57:26 +0000416 <Switch
gio6d8b71c2025-05-19 12:57:35 +0000417 id="authEnabled"
418 onCheckedChange={field.onChange}
419 checked={field.value}
420 disabled={disabled}
421 />
422 <Label htmlFor="authEnabled">Enabled</Label>
423 </div>
giod0026612025-05-08 13:00:36 +0000424 <FormMessage />
425 </FormItem>
426 )}
427 />
giod0026612025-05-08 13:00:36 +0000428 </form>
429 </Form>
gio6d8b71c2025-05-19 12:57:35 +0000430 {data && data.auth && data.auth.enabled ? (
431 <>
432 Authorized Groups
433 <ul>
434 {(data.auth.groups || []).map((p) => (
435 <li key={p} className="flex flex-row gap-1 items-center">
436 <Button
437 size={"icon"}
438 variant={"ghost"}
439 onClick={() => removeGroup(p)}
440 disabled={disabled}
441 >
442 <XIcon />
443 </Button>
444 <div>{p}</div>
445 </li>
446 ))}
447 </ul>
448 <Form {...authGroupForm}>
449 <form
450 className="flex flex-row space-x-1"
451 onSubmit={authGroupForm.handleSubmit(onGroupSubmit)}
gio3ec94242025-05-16 12:46:57 +0000452 >
gio6d8b71c2025-05-19 12:57:35 +0000453 <FormField
454 control={authGroupForm.control}
455 name="group"
456 render={({ field }) => (
457 <FormItem>
458 <FormControl>
459 <Input placeholder="group" {...field} disabled={disabled} />
460 </FormControl>
461 <FormMessage />
462 </FormItem>
463 )}
464 />
465 <Button type="submit" disabled={disabled}>
466 Add Group
467 </Button>
468 </form>
469 </Form>
470 Auth optional path patterns
471 <ul>
472 {(data.auth.noAuthPathPatterns || []).map((p) => (
473 <li key={p} className="flex flex-row gap-1 items-center">
474 <Button
475 size={"icon"}
476 variant={"ghost"}
477 onClick={() => removeNoAuthPathPattern(p)}
478 disabled={disabled}
479 >
480 <XIcon />
481 </Button>
482 <div>{p}</div>
483 </li>
484 ))}
485 </ul>
486 <Form {...authNoAuthPatternFrom}>
487 <form
488 className="flex flex-row space-x-1"
489 onSubmit={authNoAuthPatternFrom.handleSubmit(onNoAuthPathPatternSubmit)}
490 >
491 <FormField
492 control={authNoAuthPatternFrom.control}
493 name="noAuthPathPattern"
494 render={({ field }) => (
495 <FormItem>
496 <FormControl>
497 <Input placeholder="group" {...field} disabled={disabled} />
498 </FormControl>
499 <FormMessage />
500 </FormItem>
501 )}
502 />
503 <Button type="submit" disabled={disabled}>
504 Add
505 </Button>
506 </form>
507 </Form>
508 </>
509 ) : (
510 <></>
511 )}
giod0026612025-05-08 13:00:36 +0000512 </>
giod0026612025-05-08 13:00:36 +0000513 )}
514 </>
515 );
516}