blob: 6efc3563b038eb6a40b32529d3d3c721be3915a8 [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";
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
gio08acd3a2025-06-12 12:15:30 +000074export function NodeGatewayHttpsDetails({ node, disabled }: { node: GatewayHttpsNode; disabled?: boolean }) {
75 const { id, data } = node;
giod0026612025-05-08 13:00:36 +000076 const store = useStateStore();
77 const env = useEnv();
78 const form = useForm<z.infer<typeof schema>>({
79 resolver: zodResolver(schema),
80 mode: "onChange",
81 defaultValues: {
82 network: data.network,
83 subdomain: data.subdomain,
84 },
85 });
86 useEffect(() => {
87 const sub = form.watch(
88 (
89 value: DeepPartial<z.infer<typeof schema>>,
90 { name }: { name?: keyof z.infer<typeof schema> | undefined; type?: EventType | undefined },
91 ) => {
92 if (name === "network") {
93 let edges = store.edges;
94 if (data.network !== undefined) {
95 edges = edges.filter((e) => {
96 if (
97 e.source === id &&
98 e.sourceHandle === "subdomain" &&
99 e.target === data.network &&
100 e.targetHandle === "subdomain"
101 ) {
102 return false;
103 } else {
104 return true;
105 }
106 });
107 }
108 if (value.network !== undefined) {
109 edges = edges.concat({
110 id: uuidv4(),
111 source: id,
112 sourceHandle: "subdomain",
113 target: value.network,
114 targetHandle: "subdomain",
115 });
116 }
117 store.setEdges(edges);
118 store.updateNodeData<"gateway-https">(id, { network: value.network });
119 } else if (name === "subdomain") {
120 store.updateNodeData<"gateway-https">(id, { subdomain: value.subdomain });
121 }
122 },
123 );
124 return () => sub.unsubscribe();
125 }, [id, data, form, store]);
gio6d8b71c2025-05-19 12:57:35 +0000126 const network = useMemo(() => {
127 if (data.network === undefined) {
128 return null;
129 }
130 return env.networks.find((n) => n.domain === data.network)!;
131 }, [data, env]);
giod0026612025-05-08 13:00:36 +0000132 const connectedToForm = useForm<z.infer<typeof connectedToSchema>>({
133 resolver: zodResolver(connectedToSchema),
134 mode: "onChange",
135 defaultValues: {
136 id: data.https?.serviceId,
137 portId: data.https?.portId,
138 },
139 });
140 useEffect(() => {
141 connectedToForm.reset({
142 id: data.https?.serviceId,
143 portId: data.https?.portId,
144 });
145 }, [connectedToForm, data]);
146 const nodes = useNodes<AppNode>();
147 const selected = useMemo(() => {
148 if (data !== undefined && data.https !== undefined) {
149 const https = data.https;
150 return nodes.find((n) => n.id === https.serviceId)! as ServiceNode;
151 }
152 return null;
153 }, [data, nodes]);
154 const selectable = useMemo(() => {
155 return nodes.filter((n) => {
156 if (n.id === id) {
157 return false;
158 }
159 if (selected !== null && selected.id === id) {
160 return true;
161 }
162 if (n.type !== "app") {
163 return false;
164 }
165 return n.data && n.data.ports && n.data.ports.length > 0;
166 });
167 }, [id, nodes, selected]);
168 useEffect(() => {
169 const sub = connectedToForm.watch(
170 (
171 value: DeepPartial<z.infer<typeof connectedToSchema>>,
172 {
173 name,
174 type,
175 }: { name?: keyof z.infer<typeof connectedToSchema> | undefined; type?: EventType | undefined },
176 ) => {
177 if (type !== "change") {
178 return;
179 }
180 switch (name) {
181 case "id": {
182 if (!value.id) {
183 break;
184 }
185 const current = store.edges.filter((e) => e.target === id);
186 const cid = current[0] ? current[0].id : undefined;
187 store.replaceEdge(
188 {
189 source: value.id,
190 sourceHandle: "ports",
191 target: id,
192 targetHandle: "https",
193 },
194 cid,
195 );
196 break;
197 }
198 case "portId":
199 store.updateNodeData<"gateway-https">(id, {
200 https: {
201 serviceId: value.id,
202 portId: value.portId,
203 },
204 });
205 break;
206 }
207 },
208 );
209 return () => sub.unsubscribe();
210 }, [id, connectedToForm, store, selectable]);
211 const authEnabledForm = useForm<z.infer<typeof authEnabledSchema>>({
212 resolver: zodResolver(authEnabledSchema),
213 mode: "onChange",
214 defaultValues: {
215 enabled: data.auth ? data.auth.enabled : false,
216 },
217 });
218 const authGroupForm = useForm<z.infer<typeof authGroupSchema>>({
219 resolver: zodResolver(authGroupSchema),
220 mode: "onSubmit",
221 defaultValues: {
222 group: "",
223 },
224 });
225 const authNoAuthPatternFrom = useForm<z.infer<typeof authNoAuthPatternSchema>>({
226 resolver: zodResolver(authNoAuthPatternSchema),
227 mode: "onChange",
228 defaultValues: {
229 noAuthPathPattern: "",
230 },
231 });
232 useEffect(() => {
233 const sub = authEnabledForm.watch((value, { name }) => {
234 if (name === "enabled") {
235 store.updateNodeData<"gateway-https">(id, {
236 auth: {
237 ...data.auth,
238 enabled: value.enabled,
239 },
240 });
241 }
242 });
243 return () => sub.unsubscribe();
244 }, [id, data, authEnabledForm, store]);
245 const removeGroup = useCallback(
246 (group: string) => {
247 const groups = data?.auth?.groups || [];
248 store.updateNodeData<"gateway-https">(id, {
249 auth: {
250 ...data.auth,
251 groups: groups.filter((g) => g !== group),
252 },
253 });
254 return true;
255 },
256 [id, data, store],
257 );
258 const onGroupSubmit = useCallback(
259 (values: z.infer<typeof authGroupSchema>) => {
260 const groups = data.auth?.groups || [];
261 groups.push(values.group);
262 store.updateNodeData<"gateway-https">(id, {
263 auth: {
264 ...data.auth,
265 groups,
266 },
267 });
268 authGroupForm.reset();
269 },
270 [id, data, store, authGroupForm],
271 );
272 const removeNoAuthPathPattern = useCallback(
273 (path: string) => {
274 const noAuthPathPatterns = data?.auth?.noAuthPathPatterns || [];
275 store.updateNodeData<"gateway-https">(id, {
276 auth: {
277 ...data.auth,
278 noAuthPathPatterns: noAuthPathPatterns.filter((p) => p !== path),
279 },
280 });
281 return true;
282 },
283 [id, data, store],
284 );
285 const onNoAuthPathPatternSubmit = useCallback(
286 (values: z.infer<typeof authNoAuthPatternSchema>) => {
287 const noAuthPathPatterns = data.auth?.noAuthPathPatterns || [];
288 noAuthPathPatterns.push(values.noAuthPathPattern);
289 store.updateNodeData<"gateway-https">(id, {
290 auth: {
291 ...data.auth,
292 noAuthPathPatterns,
293 },
294 });
295 authNoAuthPatternFrom.reset();
296 },
297 [id, data, store, authNoAuthPatternFrom],
298 );
299 return (
300 <>
301 <Form {...form}>
302 <form className="space-y-2">
303 <FormField
304 control={form.control}
305 name="network"
306 render={({ field }) => (
307 <FormItem>
gio48fde052025-05-14 09:48:08 +0000308 <Select
309 onValueChange={field.onChange}
310 defaultValue={field.value}
gio3ec94242025-05-16 12:46:57 +0000311 disabled={data.readonly || disabled}
gio48fde052025-05-14 09:48:08 +0000312 >
giod0026612025-05-08 13:00:36 +0000313 <FormControl>
314 <SelectTrigger>
315 <SelectValue placeholder="Network" />
316 </SelectTrigger>
317 </FormControl>
318 <SelectContent>
319 {env.networks.map((n) => (
320 <SelectItem
321 key={n.name}
322 value={n.domain}
323 >{`${n.name} - ${n.domain}`}</SelectItem>
324 ))}
325 </SelectContent>
326 </Select>
327 <FormMessage />
328 </FormItem>
329 )}
330 />
331 <FormField
332 control={form.control}
333 name="subdomain"
334 render={({ field }) => (
335 <FormItem>
336 <FormControl>
gio3ec94242025-05-16 12:46:57 +0000337 <Input placeholder="subdomain" {...field} disabled={data.readonly || disabled} />
giod0026612025-05-08 13:00:36 +0000338 </FormControl>
339 <FormMessage />
340 </FormItem>
341 )}
342 />
343 </form>
344 </Form>
345 <Form {...connectedToForm}>
346 <form className="space-y-2">
347 <FormField
348 control={connectedToForm.control}
349 name="id"
350 render={({ field }) => (
351 <FormItem>
gio48fde052025-05-14 09:48:08 +0000352 <Select
353 onValueChange={field.onChange}
354 defaultValue={field.value}
gio3ec94242025-05-16 12:46:57 +0000355 disabled={data.readonly || disabled}
gio48fde052025-05-14 09:48:08 +0000356 >
giod0026612025-05-08 13:00:36 +0000357 <FormControl>
358 <SelectTrigger>
359 <SelectValue placeholder="Service" />
360 </SelectTrigger>
361 </FormControl>
362 <SelectContent>
363 {selectable.map((n) => (
364 <SelectItem key={n.id} value={n.id}>
365 {nodeLabel(n)}
366 </SelectItem>
367 ))}
368 </SelectContent>
369 </Select>
370 <FormMessage />
371 </FormItem>
372 )}
373 />
374 <FormField
375 control={connectedToForm.control}
376 name="portId"
377 render={({ field }) => (
378 <FormItem>
gio48fde052025-05-14 09:48:08 +0000379 <Select
380 onValueChange={field.onChange}
381 defaultValue={field.value}
gio3ec94242025-05-16 12:46:57 +0000382 disabled={data.readonly || disabled}
gio48fde052025-05-14 09:48:08 +0000383 >
giod0026612025-05-08 13:00:36 +0000384 <FormControl>
385 <SelectTrigger>
386 <SelectValue placeholder="Port" />
387 </SelectTrigger>
388 </FormControl>
389 <SelectContent>
390 {selected &&
391 selected.data.ports.map((p) => (
392 <SelectItem key={p.id} value={p.id}>
393 {p.name} - {p.value}
394 </SelectItem>
395 ))}
396 </SelectContent>
397 </Select>
398 <FormMessage />
399 </FormItem>
400 )}
401 />
402 </form>
403 </Form>
gio6d8b71c2025-05-19 12:57:35 +0000404 {network?.hasAuth && (
giod0026612025-05-08 13:00:36 +0000405 <>
gio6d8b71c2025-05-19 12:57:35 +0000406 Auth
407 <Form {...authEnabledForm}>
408 <form className="space-y-2">
giod0026612025-05-08 13:00:36 +0000409 <FormField
gio6d8b71c2025-05-19 12:57:35 +0000410 control={authEnabledForm.control}
411 name="enabled"
giod0026612025-05-08 13:00:36 +0000412 render={({ field }) => (
413 <FormItem>
gio6d8b71c2025-05-19 12:57:35 +0000414 <div className="flex flex-row gap-1 items-center">
gio3d0bf032025-06-05 06:57:26 +0000415 <Switch
gio6d8b71c2025-05-19 12:57:35 +0000416 id="authEnabled"
417 onCheckedChange={field.onChange}
418 checked={field.value}
419 disabled={disabled}
420 />
421 <Label htmlFor="authEnabled">Enabled</Label>
422 </div>
giod0026612025-05-08 13:00:36 +0000423 <FormMessage />
424 </FormItem>
425 )}
426 />
giod0026612025-05-08 13:00:36 +0000427 </form>
428 </Form>
gio6d8b71c2025-05-19 12:57:35 +0000429 {data && data.auth && data.auth.enabled ? (
430 <>
431 Authorized Groups
432 <ul>
433 {(data.auth.groups || []).map((p) => (
434 <li key={p} className="flex flex-row gap-1 items-center">
435 <Button
436 size={"icon"}
437 variant={"ghost"}
438 onClick={() => removeGroup(p)}
439 disabled={disabled}
440 >
441 <XIcon />
442 </Button>
443 <div>{p}</div>
444 </li>
445 ))}
446 </ul>
447 <Form {...authGroupForm}>
448 <form
449 className="flex flex-row space-x-1"
450 onSubmit={authGroupForm.handleSubmit(onGroupSubmit)}
gio3ec94242025-05-16 12:46:57 +0000451 >
gio6d8b71c2025-05-19 12:57:35 +0000452 <FormField
453 control={authGroupForm.control}
454 name="group"
455 render={({ field }) => (
456 <FormItem>
457 <FormControl>
458 <Input placeholder="group" {...field} disabled={disabled} />
459 </FormControl>
460 <FormMessage />
461 </FormItem>
462 )}
463 />
464 <Button type="submit" disabled={disabled}>
465 Add Group
466 </Button>
467 </form>
468 </Form>
469 Auth optional path patterns
470 <ul>
471 {(data.auth.noAuthPathPatterns || []).map((p) => (
472 <li key={p} className="flex flex-row gap-1 items-center">
473 <Button
474 size={"icon"}
475 variant={"ghost"}
476 onClick={() => removeNoAuthPathPattern(p)}
477 disabled={disabled}
478 >
479 <XIcon />
480 </Button>
481 <div>{p}</div>
482 </li>
483 ))}
484 </ul>
485 <Form {...authNoAuthPatternFrom}>
486 <form
487 className="flex flex-row space-x-1"
488 onSubmit={authNoAuthPatternFrom.handleSubmit(onNoAuthPathPatternSubmit)}
489 >
490 <FormField
491 control={authNoAuthPatternFrom.control}
492 name="noAuthPathPattern"
493 render={({ field }) => (
494 <FormItem>
495 <FormControl>
496 <Input placeholder="group" {...field} disabled={disabled} />
497 </FormControl>
498 <FormMessage />
499 </FormItem>
500 )}
501 />
502 <Button type="submit" disabled={disabled}>
503 Add
504 </Button>
505 </form>
506 </Form>
507 </>
508 ) : (
509 <></>
510 )}
giod0026612025-05-08 13:00:36 +0000511 </>
giod0026612025-05-08 13:00:36 +0000512 )}
513 </>
514 );
515}