blob: 81c40c35387dc3a9dd9e93b8fb348f00e158681a [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 { Checkbox } from "./ui/checkbox";
21import { Label } from "./ui/label";
22import { Button } from "./ui/button";
23import { XIcon } from "lucide-react";
gio5f2f1002025-03-20 18:38:48 +040024
25const schema = z.object({
giod0026612025-05-08 13:00:36 +000026 network: z.string().min(1, "reqired"),
27 subdomain: z.string().min(1, "required"),
gio5f2f1002025-03-20 18:38:48 +040028});
29
30const connectedToSchema = z.object({
giod0026612025-05-08 13:00:36 +000031 id: z.string(),
32 portId: z.string(),
gio5f2f1002025-03-20 18:38:48 +040033});
34
gio9b2d4962025-05-07 04:59:39 +000035const authEnabledSchema = z.object({
giod0026612025-05-08 13:00:36 +000036 enabled: z.boolean(),
gio9b2d4962025-05-07 04:59:39 +000037});
38
39const authGroupSchema = z.object({
giod0026612025-05-08 13:00:36 +000040 group: z.string(),
gio9b2d4962025-05-07 04:59:39 +000041});
42
43const authNoAuthPatternSchema = z.object({
giod0026612025-05-08 13:00:36 +000044 noAuthPathPattern: z.string(),
gio9b2d4962025-05-07 04:59:39 +000045});
46
gio5f2f1002025-03-20 18:38:48 +040047export function NodeGatewayHttps(node: GatewayHttpsNode) {
giod0026612025-05-08 13:00:36 +000048 const { id, selected } = node;
49 const isConnectableNetwork = useMemo(() => nodeIsConnectable(node, "subdomain"), [node]);
50 const isConnectable = useMemo(() => nodeIsConnectable(node, "https"), [node]);
51 return (
52 <NodeRect id={id} selected={selected} type={node.type} state={node.data.state}>
53 {nodeLabel(node)}
54 <Handle
55 type={"source"}
56 id="subdomain"
57 position={Position.Top}
58 isConnectable={isConnectableNetwork}
59 isConnectableStart={isConnectableNetwork}
60 isConnectableEnd={isConnectableNetwork}
61 />
62 <Handle
63 type={"target"}
64 id="https"
65 position={Position.Bottom}
66 isConnectable={isConnectable}
67 isConnectableStart={isConnectable}
68 isConnectableEnd={isConnectable}
69 />
70 </NodeRect>
71 );
gio5f2f1002025-03-20 18:38:48 +040072}
73
gio3ec94242025-05-16 12:46:57 +000074export function NodeGatewayHttpsDetails({ id, data, disabled }: GatewayHttpsNode & { disabled?: boolean }) {
giod0026612025-05-08 13:00:36 +000075 const store = useStateStore();
76 const env = useEnv();
77 const form = useForm<z.infer<typeof schema>>({
78 resolver: zodResolver(schema),
79 mode: "onChange",
80 defaultValues: {
81 network: data.network,
82 subdomain: data.subdomain,
83 },
84 });
85 useEffect(() => {
86 const sub = form.watch(
87 (
88 value: DeepPartial<z.infer<typeof schema>>,
89 { name }: { name?: keyof z.infer<typeof schema> | undefined; type?: EventType | undefined },
90 ) => {
91 if (name === "network") {
92 let edges = store.edges;
93 if (data.network !== undefined) {
94 edges = edges.filter((e) => {
95 if (
96 e.source === id &&
97 e.sourceHandle === "subdomain" &&
98 e.target === data.network &&
99 e.targetHandle === "subdomain"
100 ) {
101 return false;
102 } else {
103 return true;
104 }
105 });
106 }
107 if (value.network !== undefined) {
108 edges = edges.concat({
109 id: uuidv4(),
110 source: id,
111 sourceHandle: "subdomain",
112 target: value.network,
113 targetHandle: "subdomain",
114 });
115 }
116 store.setEdges(edges);
117 store.updateNodeData<"gateway-https">(id, { network: value.network });
118 } else if (name === "subdomain") {
119 store.updateNodeData<"gateway-https">(id, { subdomain: value.subdomain });
120 }
121 },
122 );
123 return () => sub.unsubscribe();
124 }, [id, data, form, store]);
gio6d8b71c2025-05-19 12:57:35 +0000125 const network = useMemo(() => {
126 if (data.network === undefined) {
127 return null;
128 }
129 return env.networks.find((n) => n.domain === data.network)!;
130 }, [data, env]);
giod0026612025-05-08 13:00:36 +0000131 const connectedToForm = useForm<z.infer<typeof connectedToSchema>>({
132 resolver: zodResolver(connectedToSchema),
133 mode: "onChange",
134 defaultValues: {
135 id: data.https?.serviceId,
136 portId: data.https?.portId,
137 },
138 });
139 useEffect(() => {
140 connectedToForm.reset({
141 id: data.https?.serviceId,
142 portId: data.https?.portId,
143 });
144 }, [connectedToForm, data]);
145 const nodes = useNodes<AppNode>();
146 const selected = useMemo(() => {
147 if (data !== undefined && data.https !== undefined) {
148 const https = data.https;
149 return nodes.find((n) => n.id === https.serviceId)! as ServiceNode;
150 }
151 return null;
152 }, [data, nodes]);
153 const selectable = useMemo(() => {
154 return nodes.filter((n) => {
155 if (n.id === id) {
156 return false;
157 }
158 if (selected !== null && selected.id === id) {
159 return true;
160 }
161 if (n.type !== "app") {
162 return false;
163 }
164 return n.data && n.data.ports && n.data.ports.length > 0;
165 });
166 }, [id, nodes, selected]);
167 useEffect(() => {
168 const sub = connectedToForm.watch(
169 (
170 value: DeepPartial<z.infer<typeof connectedToSchema>>,
171 {
172 name,
173 type,
174 }: { name?: keyof z.infer<typeof connectedToSchema> | undefined; type?: EventType | undefined },
175 ) => {
176 if (type !== "change") {
177 return;
178 }
179 switch (name) {
180 case "id": {
181 if (!value.id) {
182 break;
183 }
184 const current = store.edges.filter((e) => e.target === id);
185 const cid = current[0] ? current[0].id : undefined;
186 store.replaceEdge(
187 {
188 source: value.id,
189 sourceHandle: "ports",
190 target: id,
191 targetHandle: "https",
192 },
193 cid,
194 );
195 break;
196 }
197 case "portId":
198 store.updateNodeData<"gateway-https">(id, {
199 https: {
200 serviceId: value.id,
201 portId: value.portId,
202 },
203 });
204 break;
205 }
206 },
207 );
208 return () => sub.unsubscribe();
209 }, [id, connectedToForm, store, selectable]);
210 const authEnabledForm = useForm<z.infer<typeof authEnabledSchema>>({
211 resolver: zodResolver(authEnabledSchema),
212 mode: "onChange",
213 defaultValues: {
214 enabled: data.auth ? data.auth.enabled : false,
215 },
216 });
217 const authGroupForm = useForm<z.infer<typeof authGroupSchema>>({
218 resolver: zodResolver(authGroupSchema),
219 mode: "onSubmit",
220 defaultValues: {
221 group: "",
222 },
223 });
224 const authNoAuthPatternFrom = useForm<z.infer<typeof authNoAuthPatternSchema>>({
225 resolver: zodResolver(authNoAuthPatternSchema),
226 mode: "onChange",
227 defaultValues: {
228 noAuthPathPattern: "",
229 },
230 });
231 useEffect(() => {
232 const sub = authEnabledForm.watch((value, { name }) => {
233 if (name === "enabled") {
234 store.updateNodeData<"gateway-https">(id, {
235 auth: {
236 ...data.auth,
237 enabled: value.enabled,
238 },
239 });
240 }
241 });
242 return () => sub.unsubscribe();
243 }, [id, data, authEnabledForm, store]);
244 const removeGroup = useCallback(
245 (group: string) => {
246 const groups = data?.auth?.groups || [];
247 store.updateNodeData<"gateway-https">(id, {
248 auth: {
249 ...data.auth,
250 groups: groups.filter((g) => g !== group),
251 },
252 });
253 return true;
254 },
255 [id, data, store],
256 );
257 const onGroupSubmit = useCallback(
258 (values: z.infer<typeof authGroupSchema>) => {
259 const groups = data.auth?.groups || [];
260 groups.push(values.group);
261 store.updateNodeData<"gateway-https">(id, {
262 auth: {
263 ...data.auth,
264 groups,
265 },
266 });
267 authGroupForm.reset();
268 },
269 [id, data, store, authGroupForm],
270 );
271 const removeNoAuthPathPattern = useCallback(
272 (path: string) => {
273 const noAuthPathPatterns = data?.auth?.noAuthPathPatterns || [];
274 store.updateNodeData<"gateway-https">(id, {
275 auth: {
276 ...data.auth,
277 noAuthPathPatterns: noAuthPathPatterns.filter((p) => p !== path),
278 },
279 });
280 return true;
281 },
282 [id, data, store],
283 );
284 const onNoAuthPathPatternSubmit = useCallback(
285 (values: z.infer<typeof authNoAuthPatternSchema>) => {
286 const noAuthPathPatterns = data.auth?.noAuthPathPatterns || [];
287 noAuthPathPatterns.push(values.noAuthPathPattern);
288 store.updateNodeData<"gateway-https">(id, {
289 auth: {
290 ...data.auth,
291 noAuthPathPatterns,
292 },
293 });
294 authNoAuthPatternFrom.reset();
295 },
296 [id, data, store, authNoAuthPatternFrom],
297 );
298 return (
299 <>
300 <Form {...form}>
301 <form className="space-y-2">
302 <FormField
303 control={form.control}
304 name="network"
305 render={({ field }) => (
306 <FormItem>
gio48fde052025-05-14 09:48:08 +0000307 <Select
308 onValueChange={field.onChange}
309 defaultValue={field.value}
gio3ec94242025-05-16 12:46:57 +0000310 disabled={data.readonly || disabled}
gio48fde052025-05-14 09:48:08 +0000311 >
giod0026612025-05-08 13:00:36 +0000312 <FormControl>
313 <SelectTrigger>
314 <SelectValue placeholder="Network" />
315 </SelectTrigger>
316 </FormControl>
317 <SelectContent>
318 {env.networks.map((n) => (
319 <SelectItem
320 key={n.name}
321 value={n.domain}
322 >{`${n.name} - ${n.domain}`}</SelectItem>
323 ))}
324 </SelectContent>
325 </Select>
326 <FormMessage />
327 </FormItem>
328 )}
329 />
330 <FormField
331 control={form.control}
332 name="subdomain"
333 render={({ field }) => (
334 <FormItem>
335 <FormControl>
gio3ec94242025-05-16 12:46:57 +0000336 <Input placeholder="subdomain" {...field} disabled={data.readonly || disabled} />
giod0026612025-05-08 13:00:36 +0000337 </FormControl>
338 <FormMessage />
339 </FormItem>
340 )}
341 />
342 </form>
343 </Form>
344 <Form {...connectedToForm}>
345 <form className="space-y-2">
346 <FormField
347 control={connectedToForm.control}
348 name="id"
349 render={({ field }) => (
350 <FormItem>
gio48fde052025-05-14 09:48:08 +0000351 <Select
352 onValueChange={field.onChange}
353 defaultValue={field.value}
gio3ec94242025-05-16 12:46:57 +0000354 disabled={data.readonly || disabled}
gio48fde052025-05-14 09:48:08 +0000355 >
giod0026612025-05-08 13:00:36 +0000356 <FormControl>
357 <SelectTrigger>
358 <SelectValue placeholder="Service" />
359 </SelectTrigger>
360 </FormControl>
361 <SelectContent>
362 {selectable.map((n) => (
363 <SelectItem key={n.id} value={n.id}>
364 {nodeLabel(n)}
365 </SelectItem>
366 ))}
367 </SelectContent>
368 </Select>
369 <FormMessage />
370 </FormItem>
371 )}
372 />
373 <FormField
374 control={connectedToForm.control}
375 name="portId"
376 render={({ field }) => (
377 <FormItem>
gio48fde052025-05-14 09:48:08 +0000378 <Select
379 onValueChange={field.onChange}
380 defaultValue={field.value}
gio3ec94242025-05-16 12:46:57 +0000381 disabled={data.readonly || disabled}
gio48fde052025-05-14 09:48:08 +0000382 >
giod0026612025-05-08 13:00:36 +0000383 <FormControl>
384 <SelectTrigger>
385 <SelectValue placeholder="Port" />
386 </SelectTrigger>
387 </FormControl>
388 <SelectContent>
389 {selected &&
390 selected.data.ports.map((p) => (
391 <SelectItem key={p.id} value={p.id}>
392 {p.name} - {p.value}
393 </SelectItem>
394 ))}
395 </SelectContent>
396 </Select>
397 <FormMessage />
398 </FormItem>
399 )}
400 />
401 </form>
402 </Form>
gio6d8b71c2025-05-19 12:57:35 +0000403 {network?.hasAuth && (
giod0026612025-05-08 13:00:36 +0000404 <>
gio6d8b71c2025-05-19 12:57:35 +0000405 Auth
406 <Form {...authEnabledForm}>
407 <form className="space-y-2">
giod0026612025-05-08 13:00:36 +0000408 <FormField
gio6d8b71c2025-05-19 12:57:35 +0000409 control={authEnabledForm.control}
410 name="enabled"
giod0026612025-05-08 13:00:36 +0000411 render={({ field }) => (
412 <FormItem>
gio6d8b71c2025-05-19 12:57:35 +0000413 <div className="flex flex-row gap-1 items-center">
414 <Checkbox
415 id="authEnabled"
416 onCheckedChange={field.onChange}
417 checked={field.value}
418 disabled={disabled}
419 />
420 <Label htmlFor="authEnabled">Enabled</Label>
421 </div>
giod0026612025-05-08 13:00:36 +0000422 <FormMessage />
423 </FormItem>
424 )}
425 />
giod0026612025-05-08 13:00:36 +0000426 </form>
427 </Form>
gio6d8b71c2025-05-19 12:57:35 +0000428 {data && data.auth && data.auth.enabled ? (
429 <>
430 Authorized Groups
431 <ul>
432 {(data.auth.groups || []).map((p) => (
433 <li key={p} className="flex flex-row gap-1 items-center">
434 <Button
435 size={"icon"}
436 variant={"ghost"}
437 onClick={() => removeGroup(p)}
438 disabled={disabled}
439 >
440 <XIcon />
441 </Button>
442 <div>{p}</div>
443 </li>
444 ))}
445 </ul>
446 <Form {...authGroupForm}>
447 <form
448 className="flex flex-row space-x-1"
449 onSubmit={authGroupForm.handleSubmit(onGroupSubmit)}
gio3ec94242025-05-16 12:46:57 +0000450 >
gio6d8b71c2025-05-19 12:57:35 +0000451 <FormField
452 control={authGroupForm.control}
453 name="group"
454 render={({ field }) => (
455 <FormItem>
456 <FormControl>
457 <Input placeholder="group" {...field} disabled={disabled} />
458 </FormControl>
459 <FormMessage />
460 </FormItem>
461 )}
462 />
463 <Button type="submit" disabled={disabled}>
464 Add Group
465 </Button>
466 </form>
467 </Form>
468 Auth optional path patterns
469 <ul>
470 {(data.auth.noAuthPathPatterns || []).map((p) => (
471 <li key={p} className="flex flex-row gap-1 items-center">
472 <Button
473 size={"icon"}
474 variant={"ghost"}
475 onClick={() => removeNoAuthPathPattern(p)}
476 disabled={disabled}
477 >
478 <XIcon />
479 </Button>
480 <div>{p}</div>
481 </li>
482 ))}
483 </ul>
484 <Form {...authNoAuthPatternFrom}>
485 <form
486 className="flex flex-row space-x-1"
487 onSubmit={authNoAuthPatternFrom.handleSubmit(onNoAuthPathPatternSubmit)}
488 >
489 <FormField
490 control={authNoAuthPatternFrom.control}
491 name="noAuthPathPattern"
492 render={({ field }) => (
493 <FormItem>
494 <FormControl>
495 <Input placeholder="group" {...field} disabled={disabled} />
496 </FormControl>
497 <FormMessage />
498 </FormItem>
499 )}
500 />
501 <Button type="submit" disabled={disabled}>
502 Add
503 </Button>
504 </form>
505 </Form>
506 </>
507 ) : (
508 <></>
509 )}
giod0026612025-05-08 13:00:36 +0000510 </>
giod0026612025-05-08 13:00:36 +0000511 )}
512 </>
513 );
514}