blob: e88902d53b706ffff0ac090593c36523a4265f31 [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]);
125 const connectedToForm = useForm<z.infer<typeof connectedToSchema>>({
126 resolver: zodResolver(connectedToSchema),
127 mode: "onChange",
128 defaultValues: {
129 id: data.https?.serviceId,
130 portId: data.https?.portId,
131 },
132 });
133 useEffect(() => {
134 connectedToForm.reset({
135 id: data.https?.serviceId,
136 portId: data.https?.portId,
137 });
138 }, [connectedToForm, data]);
139 const nodes = useNodes<AppNode>();
140 const selected = useMemo(() => {
141 if (data !== undefined && data.https !== undefined) {
142 const https = data.https;
143 return nodes.find((n) => n.id === https.serviceId)! as ServiceNode;
144 }
145 return null;
146 }, [data, nodes]);
147 const selectable = useMemo(() => {
148 return nodes.filter((n) => {
149 if (n.id === id) {
150 return false;
151 }
152 if (selected !== null && selected.id === id) {
153 return true;
154 }
155 if (n.type !== "app") {
156 return false;
157 }
158 return n.data && n.data.ports && n.data.ports.length > 0;
159 });
160 }, [id, nodes, selected]);
161 useEffect(() => {
162 const sub = connectedToForm.watch(
163 (
164 value: DeepPartial<z.infer<typeof connectedToSchema>>,
165 {
166 name,
167 type,
168 }: { name?: keyof z.infer<typeof connectedToSchema> | undefined; type?: EventType | undefined },
169 ) => {
170 if (type !== "change") {
171 return;
172 }
173 switch (name) {
174 case "id": {
175 if (!value.id) {
176 break;
177 }
178 const current = store.edges.filter((e) => e.target === id);
179 const cid = current[0] ? current[0].id : undefined;
180 store.replaceEdge(
181 {
182 source: value.id,
183 sourceHandle: "ports",
184 target: id,
185 targetHandle: "https",
186 },
187 cid,
188 );
189 break;
190 }
191 case "portId":
192 store.updateNodeData<"gateway-https">(id, {
193 https: {
194 serviceId: value.id,
195 portId: value.portId,
196 },
197 });
198 break;
199 }
200 },
201 );
202 return () => sub.unsubscribe();
203 }, [id, connectedToForm, store, selectable]);
204 const authEnabledForm = useForm<z.infer<typeof authEnabledSchema>>({
205 resolver: zodResolver(authEnabledSchema),
206 mode: "onChange",
207 defaultValues: {
208 enabled: data.auth ? data.auth.enabled : false,
209 },
210 });
211 const authGroupForm = useForm<z.infer<typeof authGroupSchema>>({
212 resolver: zodResolver(authGroupSchema),
213 mode: "onSubmit",
214 defaultValues: {
215 group: "",
216 },
217 });
218 const authNoAuthPatternFrom = useForm<z.infer<typeof authNoAuthPatternSchema>>({
219 resolver: zodResolver(authNoAuthPatternSchema),
220 mode: "onChange",
221 defaultValues: {
222 noAuthPathPattern: "",
223 },
224 });
225 useEffect(() => {
226 const sub = authEnabledForm.watch((value, { name }) => {
227 if (name === "enabled") {
228 store.updateNodeData<"gateway-https">(id, {
229 auth: {
230 ...data.auth,
231 enabled: value.enabled,
232 },
233 });
234 }
235 });
236 return () => sub.unsubscribe();
237 }, [id, data, authEnabledForm, store]);
238 const removeGroup = useCallback(
239 (group: string) => {
240 const groups = data?.auth?.groups || [];
241 store.updateNodeData<"gateway-https">(id, {
242 auth: {
243 ...data.auth,
244 groups: groups.filter((g) => g !== group),
245 },
246 });
247 return true;
248 },
249 [id, data, store],
250 );
251 const onGroupSubmit = useCallback(
252 (values: z.infer<typeof authGroupSchema>) => {
253 const groups = data.auth?.groups || [];
254 groups.push(values.group);
255 store.updateNodeData<"gateway-https">(id, {
256 auth: {
257 ...data.auth,
258 groups,
259 },
260 });
261 authGroupForm.reset();
262 },
263 [id, data, store, authGroupForm],
264 );
265 const removeNoAuthPathPattern = useCallback(
266 (path: string) => {
267 const noAuthPathPatterns = data?.auth?.noAuthPathPatterns || [];
268 store.updateNodeData<"gateway-https">(id, {
269 auth: {
270 ...data.auth,
271 noAuthPathPatterns: noAuthPathPatterns.filter((p) => p !== path),
272 },
273 });
274 return true;
275 },
276 [id, data, store],
277 );
278 const onNoAuthPathPatternSubmit = useCallback(
279 (values: z.infer<typeof authNoAuthPatternSchema>) => {
280 const noAuthPathPatterns = data.auth?.noAuthPathPatterns || [];
281 noAuthPathPatterns.push(values.noAuthPathPattern);
282 store.updateNodeData<"gateway-https">(id, {
283 auth: {
284 ...data.auth,
285 noAuthPathPatterns,
286 },
287 });
288 authNoAuthPatternFrom.reset();
289 },
290 [id, data, store, authNoAuthPatternFrom],
291 );
292 return (
293 <>
294 <Form {...form}>
295 <form className="space-y-2">
296 <FormField
297 control={form.control}
298 name="network"
299 render={({ field }) => (
300 <FormItem>
gio48fde052025-05-14 09:48:08 +0000301 <Select
302 onValueChange={field.onChange}
303 defaultValue={field.value}
gio3ec94242025-05-16 12:46:57 +0000304 disabled={data.readonly || disabled}
gio48fde052025-05-14 09:48:08 +0000305 >
giod0026612025-05-08 13:00:36 +0000306 <FormControl>
307 <SelectTrigger>
308 <SelectValue placeholder="Network" />
309 </SelectTrigger>
310 </FormControl>
311 <SelectContent>
312 {env.networks.map((n) => (
313 <SelectItem
314 key={n.name}
315 value={n.domain}
316 >{`${n.name} - ${n.domain}`}</SelectItem>
317 ))}
318 </SelectContent>
319 </Select>
320 <FormMessage />
321 </FormItem>
322 )}
323 />
324 <FormField
325 control={form.control}
326 name="subdomain"
327 render={({ field }) => (
328 <FormItem>
329 <FormControl>
gio3ec94242025-05-16 12:46:57 +0000330 <Input placeholder="subdomain" {...field} disabled={data.readonly || disabled} />
giod0026612025-05-08 13:00:36 +0000331 </FormControl>
332 <FormMessage />
333 </FormItem>
334 )}
335 />
336 </form>
337 </Form>
338 <Form {...connectedToForm}>
339 <form className="space-y-2">
340 <FormField
341 control={connectedToForm.control}
342 name="id"
343 render={({ field }) => (
344 <FormItem>
gio48fde052025-05-14 09:48:08 +0000345 <Select
346 onValueChange={field.onChange}
347 defaultValue={field.value}
gio3ec94242025-05-16 12:46:57 +0000348 disabled={data.readonly || disabled}
gio48fde052025-05-14 09:48:08 +0000349 >
giod0026612025-05-08 13:00:36 +0000350 <FormControl>
351 <SelectTrigger>
352 <SelectValue placeholder="Service" />
353 </SelectTrigger>
354 </FormControl>
355 <SelectContent>
356 {selectable.map((n) => (
357 <SelectItem key={n.id} value={n.id}>
358 {nodeLabel(n)}
359 </SelectItem>
360 ))}
361 </SelectContent>
362 </Select>
363 <FormMessage />
364 </FormItem>
365 )}
366 />
367 <FormField
368 control={connectedToForm.control}
369 name="portId"
370 render={({ field }) => (
371 <FormItem>
gio48fde052025-05-14 09:48:08 +0000372 <Select
373 onValueChange={field.onChange}
374 defaultValue={field.value}
gio3ec94242025-05-16 12:46:57 +0000375 disabled={data.readonly || disabled}
gio48fde052025-05-14 09:48:08 +0000376 >
giod0026612025-05-08 13:00:36 +0000377 <FormControl>
378 <SelectTrigger>
379 <SelectValue placeholder="Port" />
380 </SelectTrigger>
381 </FormControl>
382 <SelectContent>
383 {selected &&
384 selected.data.ports.map((p) => (
385 <SelectItem key={p.id} value={p.id}>
386 {p.name} - {p.value}
387 </SelectItem>
388 ))}
389 </SelectContent>
390 </Select>
391 <FormMessage />
392 </FormItem>
393 )}
394 />
395 </form>
396 </Form>
397 Auth
398 <Form {...authEnabledForm}>
399 <form className="space-y-2">
400 <FormField
401 control={authEnabledForm.control}
402 name="enabled"
403 render={({ field }) => (
404 <FormItem>
gio818da4e2025-05-12 14:45:35 +0000405 <div className="flex flex-row gap-1 items-center">
gio3ec94242025-05-16 12:46:57 +0000406 <Checkbox
407 id="authEnabled"
408 onCheckedChange={field.onChange}
409 checked={field.value}
410 disabled={disabled}
411 />
gio818da4e2025-05-12 14:45:35 +0000412 <Label htmlFor="authEnabled">Enabled</Label>
413 </div>
giod0026612025-05-08 13:00:36 +0000414 <FormMessage />
415 </FormItem>
416 )}
417 />
418 </form>
419 </Form>
420 {data && data.auth && data.auth.enabled ? (
421 <>
422 Authorized Groups
423 <ul>
424 {(data.auth.groups || []).map((p) => (
gio818da4e2025-05-12 14:45:35 +0000425 <li key={p} className="flex flex-row gap-1 items-center">
gio3ec94242025-05-16 12:46:57 +0000426 <Button
427 size={"icon"}
428 variant={"ghost"}
429 onClick={() => removeGroup(p)}
430 disabled={disabled}
431 >
giod0026612025-05-08 13:00:36 +0000432 <XIcon />
gio818da4e2025-05-12 14:45:35 +0000433 </Button>
434 <div>{p}</div>
giod0026612025-05-08 13:00:36 +0000435 </li>
436 ))}
437 </ul>
438 <Form {...authGroupForm}>
439 <form className="flex flex-row space-x-1" onSubmit={authGroupForm.handleSubmit(onGroupSubmit)}>
440 <FormField
441 control={authGroupForm.control}
442 name="group"
443 render={({ field }) => (
444 <FormItem>
445 <FormControl>
gio3ec94242025-05-16 12:46:57 +0000446 <Input placeholder="group" {...field} disabled={disabled} />
giod0026612025-05-08 13:00:36 +0000447 </FormControl>
448 <FormMessage />
449 </FormItem>
450 )}
451 />
gio3ec94242025-05-16 12:46:57 +0000452 <Button type="submit" disabled={disabled}>
453 Add Group
454 </Button>
giod0026612025-05-08 13:00:36 +0000455 </form>
456 </Form>
457 Auth optional path patterns
458 <ul>
459 {(data.auth.noAuthPathPatterns || []).map((p) => (
gio818da4e2025-05-12 14:45:35 +0000460 <li key={p} className="flex flex-row gap-1 items-center">
gio3ec94242025-05-16 12:46:57 +0000461 <Button
462 size={"icon"}
463 variant={"ghost"}
464 onClick={() => removeNoAuthPathPattern(p)}
465 disabled={disabled}
466 >
giod0026612025-05-08 13:00:36 +0000467 <XIcon />
gio818da4e2025-05-12 14:45:35 +0000468 </Button>
469 <div>{p}</div>
giod0026612025-05-08 13:00:36 +0000470 </li>
471 ))}
472 </ul>
473 <Form {...authNoAuthPatternFrom}>
474 <form
475 className="flex flex-row space-x-1"
476 onSubmit={authNoAuthPatternFrom.handleSubmit(onNoAuthPathPatternSubmit)}
477 >
478 <FormField
479 control={authNoAuthPatternFrom.control}
480 name="noAuthPathPattern"
481 render={({ field }) => (
482 <FormItem>
483 <FormControl>
gio3ec94242025-05-16 12:46:57 +0000484 <Input placeholder="group" {...field} disabled={disabled} />
giod0026612025-05-08 13:00:36 +0000485 </FormControl>
486 <FormMessage />
487 </FormItem>
488 )}
489 />
gio3ec94242025-05-16 12:46:57 +0000490 <Button type="submit" disabled={disabled}>
491 Add
492 </Button>
giod0026612025-05-08 13:00:36 +0000493 </form>
494 </Form>
495 </>
496 ) : (
497 <></>
498 )}
499 </>
500 );
501}