소스 검색

Proxy host modal basis, other improvements

Jamie Curnow 2 달 전
부모
커밋
d0767baafa

+ 0 - 3
backend/internal/proxy-host.js

@@ -422,7 +422,6 @@ const internalProxyHost = {
 	 */
 	getAll: async (access, expand, searchQuery) => {
 		const accessData = await access.can("proxy_hosts:list");
-
 		const query = proxyHostModel
 			.query()
 			.where("is_deleted", 0)
@@ -446,11 +445,9 @@ const internalProxyHost = {
 		}
 
 		const rows = await query.then(utils.omitRows(omissions()));
-
 		if (typeof expand !== "undefined" && expand !== null && expand.indexOf("certificate") !== -1) {
 			return internalHost.cleanAllRowsCertificateMeta(rows);
 		}
-
 		return rows;
 	},
 

+ 1 - 1
backend/lib/access.js

@@ -131,7 +131,7 @@ export default function (tokenString) {
 						const rows = await query;
 						objects = [];
 						_.forEach(rows, (ruleRow) => {
-							result.push(ruleRow.id);
+							objects.push(ruleRow.id);
 						});
 
 						// enum should not have less than 1 item

+ 4 - 0
frontend/src/App.css

@@ -70,3 +70,7 @@
 	font-family: 'Courier New', Courier, monospace !important;
 	resize: vertical;
 }
+
+label.row {
+	cursor: pointer;
+}

+ 2 - 2
frontend/src/api/backend/models.ts

@@ -103,6 +103,7 @@ export interface ProxyHost {
 	modifiedOn: string;
 	ownerUserId: number;
 	domainNames: string[];
+	forwardScheme: string;
 	forwardHost: string;
 	forwardPort: number;
 	accessListId: number;
@@ -114,9 +115,8 @@ export interface ProxyHost {
 	meta: Record<string, any>;
 	allowWebsocketUpgrade: boolean;
 	http2Support: boolean;
-	forwardScheme: string;
 	enabled: boolean;
-	locations: string[]; // todo: string or object?
+	locations?: string[]; // todo: string or object?
 	hstsEnabled: boolean;
 	hstsSubdomains: boolean;
 	// Expansions:

+ 3 - 2
frontend/src/components/Form/SSLOptionsFields.tsx

@@ -7,8 +7,9 @@ interface Props {
 	forHttp?: boolean; // the sslForced, http2Support, hstsEnabled, hstsSubdomains fields
 	forceDNSForNew?: boolean;
 	requireDomainNames?: boolean; // used for streams
+	color?: string;
 }
-export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomainNames }: Props) {
+export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomainNames, color = "bg-cyan" }: Props) {
 	const { values, setFieldValue } = useFormikContext();
 	const v: any = values || {};
 
@@ -31,7 +32,7 @@ export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomain
 	};
 
 	const toggleClasses = "form-check-input";
-	const toggleEnabled = cn(toggleClasses, "bg-cyan");
+	const toggleEnabled = cn(toggleClasses, color);
 
 	const getHttpOptions = () => (
 		<div>

+ 1 - 0
frontend/src/hooks/index.ts

@@ -7,6 +7,7 @@ export * from "./useDeadHosts";
 export * from "./useDnsProviders";
 export * from "./useHealth";
 export * from "./useHostReport";
+export * from "./useProxyHost";
 export * from "./useProxyHosts";
 export * from "./useRedirectionHost";
 export * from "./useRedirectionHosts";

+ 65 - 0
frontend/src/hooks/useProxyHost.ts

@@ -0,0 +1,65 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { createProxyHost, getProxyHost, type ProxyHost, updateProxyHost } from "src/api/backend";
+
+const fetchProxyHost = (id: number | "new") => {
+	if (id === "new") {
+		return Promise.resolve({
+			id: 0,
+			createdOn: "",
+			modifiedOn: "",
+			ownerUserId: 0,
+			domainNames: [],
+			forwardHost: "",
+			forwardPort: 0,
+			accessListId: 0,
+			certificateId: 0,
+			sslForced: false,
+			cachingEnabled: false,
+			blockExploits: false,
+			advancedConfig: "",
+			meta: {},
+			allowWebsocketUpgrade: false,
+			http2Support: false,
+			forwardScheme: "",
+			enabled: true,
+			hstsEnabled: false,
+			hstsSubdomains: false,
+		} as ProxyHost);
+	}
+	return getProxyHost(id, ["owner"]);
+};
+
+const useProxyHost = (id: number | "new", options = {}) => {
+	return useQuery<ProxyHost, Error>({
+		queryKey: ["proxy-host", id],
+		queryFn: () => fetchProxyHost(id),
+		staleTime: 60 * 1000, // 1 minute
+		...options,
+	});
+};
+
+const useSetProxyHost = () => {
+	const queryClient = useQueryClient();
+	return useMutation({
+		mutationFn: (values: ProxyHost) => (values.id ? updateProxyHost(values) : createProxyHost(values)),
+		onMutate: (values: ProxyHost) => {
+			if (!values.id) {
+				return;
+			}
+			const previousObject = queryClient.getQueryData(["proxy-host", values.id]);
+			queryClient.setQueryData(["proxy-host", values.id], (old: ProxyHost) => ({
+				...old,
+				...values,
+			}));
+			return () => queryClient.setQueryData(["proxy-host", values.id], previousObject);
+		},
+		onError: (_, __, rollback: any) => rollback(),
+		onSuccess: async ({ id }: ProxyHost) => {
+			queryClient.invalidateQueries({ queryKey: ["proxy-host", id] });
+			queryClient.invalidateQueries({ queryKey: ["proxy-hosts"] });
+			queryClient.invalidateQueries({ queryKey: ["audit-logs"] });
+		},
+	});
+};
+
+export { useProxyHost, useSetProxyHost };

+ 8 - 3
frontend/src/locale/lang/en.json

@@ -23,6 +23,7 @@
   "close": "Close",
   "column.access": "Access",
   "column.authorization": "Authorization",
+  "column.custom-locations": "Custom Locations",
   "column.destination": "Destination",
   "column.details": "Details",
   "column.email": "Email",
@@ -88,9 +89,13 @@
   "event.updated-user": "Updated User",
   "footer.github-fork": "Fork me on Github",
   "host.flags.block-exploits": "Block Common Exploits",
+  "host.flags.cache-assets": "Cache Assets",
   "host.flags.preserve-path": "Preserve Path",
   "host.flags.protocols": "Protocols",
   "host.flags.title": "Options",
+  "host.flags.websockets-upgrade": "Websockets Support",
+  "host.forward-port": "Forward Port",
+  "host.forward-scheme": "Scheme",
   "hosts.title": "Hosts",
   "http-only": "HTTP Only",
   "lets-encrypt": "Let's Encrypt",
@@ -128,13 +133,14 @@
   "permissions.visibility.all": "All Items",
   "permissions.visibility.title": "Item Visibility",
   "permissions.visibility.user": "Created Items Only",
+  "proxy-host.forward-host": "Forward Hostname / IP",
+  "proxy-host.new": "New Proxy Host",
   "proxy-hosts.actions-title": "Proxy Host #{id}",
   "proxy-hosts.add": "Add Proxy Host",
   "proxy-hosts.count": "{count} Proxy Hosts",
   "proxy-hosts.empty": "There are no Proxy Hosts",
   "proxy-hosts.title": "Proxy Hosts",
-  "redirect-host.forward-domain": "Forward Domain",
-  "redirect-host.forward-scheme": "Scheme",
+  "redirection-host.forward-domain": "Forward Domain",
   "redirection-host.new": "New Redirection Host",
   "redirection-hosts.actions-title": "Redirection Host #{id}",
   "redirection-hosts.add": "Add Redirection Host",
@@ -152,7 +158,6 @@
   "stream.delete.content": "Are you sure you want to delete this Stream?",
   "stream.delete.title": "Delete Stream",
   "stream.forward-host": "Forward Host",
-  "stream.forward-port": "Forward Port",
   "stream.incoming-port": "Incoming Port",
   "stream.new": "New Stream",
   "streams.actions-title": "Stream #{id}",

+ 22 - 7
frontend/src/locale/src/en.json

@@ -71,6 +71,9 @@
 	"column.authorization": {
 		"defaultMessage": "Authorization"
 	},
+	"column.custom-locations": {
+		"defaultMessage": "Custom Locations"
+	},
 	"column.destination": {
 		"defaultMessage": "Destination"
 	},
@@ -266,6 +269,9 @@
 	"host.flags.block-exploits": {
 		"defaultMessage": "Block Common Exploits"
 	},
+	"host.flags.cache-assets": {
+		"defaultMessage": "Cache Assets"
+	},
 	"host.flags.preserve-path": {
 		"defaultMessage": "Preserve Path"
 	},
@@ -275,6 +281,15 @@
 	"host.flags.title": {
 		"defaultMessage": "Options"
 	},
+	"host.flags.websockets-upgrade": {
+		"defaultMessage": "Websockets Support"
+	},
+	"host.forward-port": {
+		"defaultMessage": "Forward Port"
+	},
+	"host.forward-scheme": {
+		"defaultMessage": "Scheme"
+	},
 	"hosts.title": {
 		"defaultMessage": "Hosts"
 	},
@@ -386,6 +401,12 @@
 	"permissions.visibility.user": {
 		"defaultMessage": "Created Items Only"
 	},
+	"proxy-host.forward-host": {
+		"defaultMessage": "Forward Hostname / IP"
+	},
+	"proxy-host.new": {
+		"defaultMessage": "New Proxy Host"
+	},
 	"proxy-hosts.actions-title": {
 		"defaultMessage": "Proxy Host #{id}"
 	},
@@ -401,12 +422,9 @@
 	"proxy-hosts.title": {
 		"defaultMessage": "Proxy Hosts"
 	},
-	"redirect-host.forward-domain": {
+	"redirection-host.forward-domain": {
 		"defaultMessage": "Forward Domain"
 	},
-	"redirect-host.forward-scheme": {
-		"defaultMessage": "Scheme"
-	},
 	"redirection-host.new": {
 		"defaultMessage": "New Redirection Host"
 	},
@@ -458,9 +476,6 @@
 	"stream.forward-host": {
 		"defaultMessage": "Forward Host"
 	},
-	"stream.forward-port": {
-		"defaultMessage": "Forward Port"
-	},
 	"stream.incoming-port": {
 		"defaultMessage": "Incoming Port"
 	},

+ 2 - 2
frontend/src/modals/DeadHostModal.tsx

@@ -136,7 +136,7 @@ export function DeadHostModal({ id, onClose }: Props) {
 													label="ssl-certificate"
 													allowNew
 												/>
-												<SSLOptionsFields />
+												<SSLOptionsFields color="bg-red" />
 											</div>
 											<div className="tab-pane" id="tab-advanced" role="tabpanel">
 												<NginxConfigField />
@@ -152,7 +152,7 @@ export function DeadHostModal({ id, onClose }: Props) {
 								<Button
 									type="submit"
 									actionType="primary"
-									className="ms-auto"
+									className="ms-auto bg-red"
 									data-bs-dismiss="modal"
 									isLoading={isSubmitting}
 									disabled={isSubmitting}

+ 377 - 0
frontend/src/modals/ProxyHostModal.tsx

@@ -0,0 +1,377 @@
+import { IconSettings } from "@tabler/icons-react";
+import cn from "classnames";
+import { Field, Form, Formik } from "formik";
+import { useState } from "react";
+import { Alert } from "react-bootstrap";
+import Modal from "react-bootstrap/Modal";
+import {
+	Button,
+	DomainNamesField,
+	Loading,
+	NginxConfigField,
+	SSLCertificateField,
+	SSLOptionsFields,
+} from "src/components";
+import { useProxyHost, useSetProxyHost } from "src/hooks";
+import { intl } from "src/locale";
+import { validateNumber, validateString } from "src/modules/Validations";
+import { showSuccess } from "src/notifications";
+
+interface Props {
+	id: number | "new";
+	onClose: () => void;
+}
+export function ProxyHostModal({ id, onClose }: Props) {
+	const { data, isLoading, error } = useProxyHost(id);
+	const { mutate: setProxyHost } = useSetProxyHost();
+	const [errorMsg, setErrorMsg] = useState<string | null>(null);
+	const [isSubmitting, setIsSubmitting] = useState(false);
+
+	const onSubmit = async (values: any, { setSubmitting }: any) => {
+		if (isSubmitting) return;
+		setIsSubmitting(true);
+		setErrorMsg(null);
+
+		const { ...payload } = {
+			id: id === "new" ? undefined : id,
+			...values,
+		};
+
+		setProxyHost(payload, {
+			onError: (err: any) => setErrorMsg(err.message),
+			onSuccess: () => {
+				showSuccess(intl.formatMessage({ id: "notification.proxy-host-saved" }));
+				onClose();
+			},
+			onSettled: () => {
+				setIsSubmitting(false);
+				setSubmitting(false);
+			},
+		});
+	};
+
+	return (
+		<Modal show onHide={onClose} animation={false}>
+			{!isLoading && error && (
+				<Alert variant="danger" className="m-3">
+					{error?.message || "Unknown error"}
+				</Alert>
+			)}
+			{isLoading && <Loading noLogo />}
+			{!isLoading && data && (
+				<Formik
+					initialValues={
+						{
+							// Details tab
+							domainNames: data?.domainNames || [],
+							forwardScheme: data?.forwardScheme || "http",
+							forwardHost: data?.forwardHost || "",
+							forwardPort: data?.forwardPort || undefined,
+							accessListId: data?.accessListId || 0,
+							cachingEnabled: data?.cachingEnabled || false,
+							blockExploits: data?.blockExploits || false,
+							allowWebsocketUpgrade: data?.allowWebsocketUpgrade || false,
+							// Locations tab
+							locations: data?.locations || [],
+							// SSL tab
+							certificateId: data?.certificateId || 0,
+							sslForced: data?.sslForced || false,
+							http2Support: data?.http2Support || false,
+							hstsEnabled: data?.hstsEnabled || false,
+							hstsSubdomains: data?.hstsSubdomains || false,
+							// Advanced tab
+							advancedConfig: data?.advancedConfig || "",
+							meta: data?.meta || {},
+						} as any
+					}
+					onSubmit={onSubmit}
+				>
+					{() => (
+						<Form>
+							<Modal.Header closeButton>
+								<Modal.Title>
+									{intl.formatMessage({
+										id: data?.id ? "proxy-host.edit" : "proxy-host.new",
+									})}
+								</Modal.Title>
+							</Modal.Header>
+							<Modal.Body className="p-0">
+								<Alert variant="danger" show={!!errorMsg} onClose={() => setErrorMsg(null)} dismissible>
+									{errorMsg}
+								</Alert>
+
+								<div className="card m-0 border-0">
+									<div className="card-header">
+										<ul className="nav nav-tabs card-header-tabs" data-bs-toggle="tabs">
+											<li className="nav-item" role="presentation">
+												<a
+													href="#tab-details"
+													className="nav-link active"
+													data-bs-toggle="tab"
+													aria-selected="true"
+													role="tab"
+												>
+													{intl.formatMessage({ id: "column.details" })}
+												</a>
+											</li>
+											<li className="nav-item" role="presentation">
+												<a
+													href="#tab-locations"
+													className="nav-link"
+													data-bs-toggle="tab"
+													aria-selected="false"
+													tabIndex={-1}
+													role="tab"
+												>
+													{intl.formatMessage({ id: "column.custom-locations" })}
+												</a>
+											</li>
+											<li className="nav-item" role="presentation">
+												<a
+													href="#tab-ssl"
+													className="nav-link"
+													data-bs-toggle="tab"
+													aria-selected="false"
+													tabIndex={-1}
+													role="tab"
+												>
+													{intl.formatMessage({ id: "column.ssl" })}
+												</a>
+											</li>
+											<li className="nav-item ms-auto" role="presentation">
+												<a
+													href="#tab-advanced"
+													className="nav-link"
+													title="Settings"
+													data-bs-toggle="tab"
+													aria-selected="false"
+													tabIndex={-1}
+													role="tab"
+												>
+													<IconSettings size={20} />
+												</a>
+											</li>
+										</ul>
+									</div>
+									<div className="card-body">
+										<div className="tab-content">
+											<div className="tab-pane active show" id="tab-details" role="tabpanel">
+												<DomainNamesField isWildcardPermitted />
+												<div className="row">
+													<div className="col-md-3">
+														<Field name="forwardScheme">
+															{({ field, form }: any) => (
+																<div className="mb-3">
+																	<label
+																		className="form-label"
+																		htmlFor="forwardScheme"
+																	>
+																		{intl.formatMessage({
+																			id: "host.forward-scheme",
+																		})}
+																	</label>
+																	<select
+																		id="forwardScheme"
+																		className={`form-control ${form.errors.forwardScheme && form.touched.forwardScheme ? "is-invalid" : ""}`}
+																		required
+																		{...field}
+																	>
+																		<option value="http">http</option>
+																		<option value="https">https</option>
+																	</select>
+																	{form.errors.forwardScheme ? (
+																		<div className="invalid-feedback">
+																			{form.errors.forwardScheme &&
+																			form.touched.forwardScheme
+																				? form.errors.forwardScheme
+																				: null}
+																		</div>
+																	) : null}
+																</div>
+															)}
+														</Field>
+													</div>
+													<div className="col-md-6">
+														<Field name="forwardHost" validate={validateString(1, 255)}>
+															{({ field, form }: any) => (
+																<div className="mb-3">
+																	<label className="form-label" htmlFor="forwardHost">
+																		{intl.formatMessage({
+																			id: "proxy-host.forward-host",
+																		})}
+																	</label>
+																	<input
+																		id="forwardHost"
+																		type="text"
+																		className={`form-control ${form.errors.forwardHost && form.touched.forwardHost ? "is-invalid" : ""}`}
+																		required
+																		placeholder="example.com"
+																		{...field}
+																	/>
+																	{form.errors.forwardHost ? (
+																		<div className="invalid-feedback">
+																			{form.errors.forwardHost &&
+																			form.touched.forwardHost
+																				? form.errors.forwardHost
+																				: null}
+																		</div>
+																	) : null}
+																</div>
+															)}
+														</Field>
+													</div>
+													<div className="col-md-3">
+														<Field name="forwardPort" validate={validateNumber(1, 65535)}>
+															{({ field, form }: any) => (
+																<div className="mb-3">
+																	<label className="form-label" htmlFor="forwardPort">
+																		{intl.formatMessage({
+																			id: "host.forward-port",
+																		})}
+																	</label>
+																	<input
+																		id="forwardPort"
+																		type="number"
+																		min={1}
+																		max={65535}
+																		className={`form-control ${form.errors.forwardPort && form.touched.forwardPort ? "is-invalid" : ""}`}
+																		required
+																		placeholder="eg: 8081"
+																		{...field}
+																	/>
+																	{form.errors.forwardPort ? (
+																		<div className="invalid-feedback">
+																			{form.errors.forwardPort &&
+																			form.touched.forwardPort
+																				? form.errors.forwardPort
+																				: null}
+																		</div>
+																	) : null}
+																</div>
+															)}
+														</Field>
+													</div>
+												</div>
+												<div className="my-3">
+													<h4 className="py-2">
+														{intl.formatMessage({ id: "host.flags.title" })}
+													</h4>
+													<div className="divide-y">
+														<div>
+															<label className="row" htmlFor="cachingEnabled">
+																<span className="col">
+																	{intl.formatMessage({
+																		id: "host.flags.cache-assets",
+																	})}
+																</span>
+																<span className="col-auto">
+																	<Field name="cachingEnabled" type="checkbox">
+																		{({ field }: any) => (
+																			<label className="form-check form-check-single form-switch">
+																				<input
+																					{...field}
+																					id="cachingEnabled"
+																					className={cn("form-check-input", {
+																						"bg-lime": field.checked,
+																					})}
+																					type="checkbox"
+																				/>
+																			</label>
+																		)}
+																	</Field>
+																</span>
+															</label>
+														</div>
+														<div>
+															<label className="row" htmlFor="blockExploits">
+																<span className="col">
+																	{intl.formatMessage({
+																		id: "host.flags.block-exploits",
+																	})}
+																</span>
+																<span className="col-auto">
+																	<Field name="blockExploits" type="checkbox">
+																		{({ field }: any) => (
+																			<label className="form-check form-check-single form-switch">
+																				<input
+																					{...field}
+																					id="blockExploits"
+																					className={cn("form-check-input", {
+																						"bg-lime": field.checked,
+																					})}
+																					type="checkbox"
+																				/>
+																			</label>
+																		)}
+																	</Field>
+																</span>
+															</label>
+														</div>
+														<div>
+															<label className="row" htmlFor="allowWebsocketUpgrade">
+																<span className="col">
+																	{intl.formatMessage({
+																		id: "host.flags.websockets-upgrade",
+																	})}
+																</span>
+																<span className="col-auto">
+																	<Field name="allowWebsocketUpgrade" type="checkbox">
+																		{({ field }: any) => (
+																			<label className="form-check form-check-single form-switch">
+																				<input
+																					{...field}
+																					id="allowWebsocketUpgrade"
+																					className={cn("form-check-input", {
+																						"bg-lime": field.checked,
+																					})}
+																					type="checkbox"
+																				/>
+																			</label>
+																		)}
+																	</Field>
+																</span>
+															</label>
+														</div>
+													</div>
+												</div>
+											</div>
+											<div className="tab-pane" id="tab-locations" role="tabpanel">
+												locations
+											</div>
+											<div className="tab-pane" id="tab-ssl" role="tabpanel">
+												<SSLCertificateField
+													name="certificateId"
+													label="ssl-certificate"
+													allowNew
+												/>
+												<SSLOptionsFields color="bg-lime" />
+											</div>
+											<div className="tab-pane" id="tab-advanced" role="tabpanel">
+												<NginxConfigField />
+											</div>
+										</div>
+									</div>
+								</div>
+							</Modal.Body>
+							<Modal.Footer>
+								<Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}>
+									{intl.formatMessage({ id: "cancel" })}
+								</Button>
+								<Button
+									type="submit"
+									actionType="primary"
+									className="ms-auto bg-lime"
+									data-bs-dismiss="modal"
+									isLoading={isSubmitting}
+									disabled={isSubmitting}
+								>
+									{intl.formatMessage({ id: "save" })}
+								</Button>
+							</Modal.Footer>
+						</Form>
+					)}
+				</Formik>
+			)}
+		</Modal>
+	);
+}

+ 11 - 6
frontend/src/modals/RedirectionHostModal.tsx

@@ -1,4 +1,5 @@
 import { IconSettings } from "@tabler/icons-react";
+import cn from "classnames";
 import { Field, Form, Formik } from "formik";
 import { useState } from "react";
 import { Alert } from "react-bootstrap";
@@ -150,7 +151,7 @@ export function RedirectionHostModal({ id, onClose }: Props) {
 																		htmlFor="forwardScheme"
 																	>
 																		{intl.formatMessage({
-																			id: "redirect-host.forward-scheme",
+																			id: "host.forward-scheme",
 																		})}
 																	</label>
 																	<select
@@ -187,7 +188,7 @@ export function RedirectionHostModal({ id, onClose }: Props) {
 																		htmlFor="forwardDomainName"
 																	>
 																		{intl.formatMessage({
-																			id: "redirect-host.forward-domain",
+																			id: "redirection-host.forward-domain",
 																		})}
 																	</label>
 																	<input
@@ -230,7 +231,9 @@ export function RedirectionHostModal({ id, onClose }: Props) {
 																				<input
 																					{...field}
 																					id="preservePath"
-																					className="form-check-input"
+																					className={cn("form-check-input", {
+																						"bg-yellow": field.checked,
+																					})}
 																					type="checkbox"
 																				/>
 																			</label>
@@ -253,7 +256,9 @@ export function RedirectionHostModal({ id, onClose }: Props) {
 																				<input
 																					{...field}
 																					id="blockExploits"
-																					className="form-check-input"
+																					className={cn("form-check-input", {
+																						"bg-yellow": field.checked,
+																					})}
 																					type="checkbox"
 																				/>
 																			</label>
@@ -271,7 +276,7 @@ export function RedirectionHostModal({ id, onClose }: Props) {
 													label="ssl-certificate"
 													allowNew
 												/>
-												<SSLOptionsFields />
+												<SSLOptionsFields color="bg-yellow" />
 											</div>
 											<div className="tab-pane" id="tab-advanced" role="tabpanel">
 												<NginxConfigField />
@@ -287,7 +292,7 @@ export function RedirectionHostModal({ id, onClose }: Props) {
 								<Button
 									type="submit"
 									actionType="primary"
-									className="ms-auto"
+									className="ms-auto bg-yellow"
 									data-bs-dismiss="modal"
 									isLoading={isSubmitting}
 									disabled={isSubmitting}

+ 7 - 2
frontend/src/modals/StreamModal.tsx

@@ -179,7 +179,7 @@ export function StreamModal({ id, onClose }: Props) {
 																		htmlFor="forwardingPort"
 																	>
 																		{intl.formatMessage({
-																			id: "stream.forward-port",
+																			id: "host.forward-port",
 																		})}
 																	</label>
 																	<input
@@ -292,7 +292,12 @@ export function StreamModal({ id, onClose }: Props) {
 													allowNew
 													forHttp={false}
 												/>
-												<SSLOptionsFields forHttp={false} forceDNSForNew requireDomainNames />
+												<SSLOptionsFields
+													color="bg-blue"
+													forHttp={false}
+													forceDNSForNew
+													requireDomainNames
+												/>
 											</div>
 										</div>
 									</div>

+ 1 - 0
frontend/src/modals/index.ts

@@ -3,6 +3,7 @@ export * from "./DeadHostModal";
 export * from "./DeleteConfirmModal";
 export * from "./EventDetailsModal";
 export * from "./PermissionsModal";
+export * from "./ProxyHostModal";
 export * from "./RedirectionHostModal";
 export * from "./SetPasswordModal";
 export * from "./StreamModal";

+ 9 - 8
frontend/src/pages/Nginx/DeadHosts/TableWrapper.tsx

@@ -56,9 +56,9 @@ export default function TableWrapper() {
 						<div className="col">
 							<h2 className="mt-1 mb-0">{intl.formatMessage({ id: "dead-hosts.title" })}</h2>
 						</div>
-						<div className="col-md-auto col-sm-12">
-							<div className="ms-auto d-flex flex-wrap btn-list">
-								{data?.length ? (
+						{data?.length ? (
+							<div className="col-md-auto col-sm-12">
+								<div className="ms-auto d-flex flex-wrap btn-list">
 									<div className="input-group input-group-flat w-auto">
 										<span className="input-group-text input-group-text-sm">
 											<IconSearch size={16} />
@@ -71,12 +71,13 @@ export default function TableWrapper() {
 											onChange={(e: any) => setSearch(e.target.value.toLowerCase().trim())}
 										/>
 									</div>
-								) : null}
-								<Button size="sm" className="btn-red" onClick={() => setEditId("new")}>
-									{intl.formatMessage({ id: "dead-hosts.add" })}
-								</Button>
+
+									<Button size="sm" className="btn-red" onClick={() => setEditId("new")}>
+										{intl.formatMessage({ id: "dead-hosts.add" })}
+									</Button>
+								</div>
 							</div>
-						</div>
+						) : null}
 					</div>
 				</div>
 				<Table

+ 14 - 9
frontend/src/pages/Nginx/ProxyHosts/Empty.tsx

@@ -2,22 +2,27 @@ import type { Table as ReactTable } from "@tanstack/react-table";
 import { Button } from "src/components";
 import { intl } from "src/locale";
 
-/**
- * This component should never render as there should always be 1 user minimum,
- * but I'm keeping it for consistency.
- */
-
 interface Props {
 	tableInstance: ReactTable<any>;
+	onNew?: () => void;
+	isFiltered?: boolean;
 }
-export default function Empty({ tableInstance }: Props) {
+export default function Empty({ tableInstance, onNew, isFiltered }: Props) {
 	return (
 		<tr>
 			<td colSpan={tableInstance.getVisibleFlatColumns().length}>
 				<div className="text-center my-4">
-					<h2>{intl.formatMessage({ id: "proxy-hosts.empty" })}</h2>
-					<p className="text-muted">{intl.formatMessage({ id: "empty-subtitle" })}</p>
-					<Button className="btn-lime my-3">{intl.formatMessage({ id: "proxy-hosts.add" })}</Button>
+					{isFiltered ? (
+						<h2>{intl.formatMessage({ id: "empty-search" })}</h2>
+					) : (
+						<>
+							<h2>{intl.formatMessage({ id: "proxy-hosts.empty" })}</h2>
+							<p className="text-muted">{intl.formatMessage({ id: "empty-subtitle" })}</p>
+							<Button className="btn-lime my-3" onClick={onNew}>
+								{intl.formatMessage({ id: "proxy-hosts.add" })}
+							</Button>
+						</>
+					)}
 				</div>
 			</td>
 		</tr>

+ 41 - 8
frontend/src/pages/Nginx/ProxyHosts/Table.tsx

@@ -9,9 +9,14 @@ import Empty from "./Empty";
 
 interface Props {
 	data: ProxyHost[];
+	isFiltered?: boolean;
 	isFetching?: boolean;
+	onEdit?: (id: number) => void;
+	onDelete?: (id: number) => void;
+	onDisableToggle?: (id: number, enabled: boolean) => void;
+	onNew?: () => void;
 }
-export default function Table({ data, isFetching }: Props) {
+export default function Table({ data, isFetching, onEdit, onDelete, onDisableToggle, onNew, isFiltered }: Props) {
 	const columnHelper = createColumnHelper<ProxyHost>();
 	const columns = useMemo(
 		() => [
@@ -64,7 +69,7 @@ export default function Table({ data, isFetching }: Props) {
 				},
 			}),
 			columnHelper.display({
-				id: "id", // todo: not needed for a display?
+				id: "id",
 				cell: (info: any) => {
 					return (
 						<span className="dropdown">
@@ -85,16 +90,39 @@ export default function Table({ data, isFetching }: Props) {
 										{ id: info.row.original.id },
 									)}
 								</span>
-								<a className="dropdown-item" href="#">
+								<a
+									className="dropdown-item"
+									href="#"
+									onClick={(e) => {
+										e.preventDefault();
+										onEdit?.(info.row.original.id);
+									}}
+								>
 									<IconEdit size={16} />
 									{intl.formatMessage({ id: "action.edit" })}
 								</a>
-								<a className="dropdown-item" href="#">
+								<a
+									className="dropdown-item"
+									href="#"
+									onClick={(e) => {
+										e.preventDefault();
+										onDisableToggle?.(info.row.original.id, !info.row.original.enabled);
+									}}
+								>
 									<IconPower size={16} />
-									{intl.formatMessage({ id: "action.disable" })}
+									{intl.formatMessage({
+										id: info.row.original.enabled ? "action.disable" : "action.enable",
+									})}
 								</a>
 								<div className="dropdown-divider" />
-								<a className="dropdown-item" href="#">
+								<a
+									className="dropdown-item"
+									href="#"
+									onClick={(e) => {
+										e.preventDefault();
+										onDelete?.(info.row.original.id);
+									}}
+								>
 									<IconTrash size={16} />
 									{intl.formatMessage({ id: "action.delete" })}
 								</a>
@@ -107,7 +135,7 @@ export default function Table({ data, isFetching }: Props) {
 				},
 			}),
 		],
-		[columnHelper],
+		[columnHelper, onEdit, onDisableToggle, onDelete],
 	);
 
 	const tableInstance = useReactTable<ProxyHost>({
@@ -121,5 +149,10 @@ export default function Table({ data, isFetching }: Props) {
 		enableSortingRemoval: false,
 	});
 
-	return <TableLayout tableInstance={tableInstance} emptyState={<Empty tableInstance={tableInstance} />} />;
+	return (
+		<TableLayout
+			tableInstance={tableInstance}
+			emptyState={<Empty tableInstance={tableInstance} onNew={onNew} isFiltered={isFiltered} />}
+		/>
+	);
 }

+ 72 - 17
frontend/src/pages/Nginx/ProxyHosts/TableWrapper.tsx

@@ -1,11 +1,20 @@
 import { IconSearch } from "@tabler/icons-react";
+import { useQueryClient } from "@tanstack/react-query";
+import { useState } from "react";
 import Alert from "react-bootstrap/Alert";
+import { deleteProxyHost, toggleProxyHost } from "src/api/backend";
 import { Button, LoadingPage } from "src/components";
 import { useProxyHosts } from "src/hooks";
 import { intl } from "src/locale";
+import { DeleteConfirmModal, ProxyHostModal } from "src/modals";
+import { showSuccess } from "src/notifications";
 import Table from "./Table";
 
 export default function TableWrapper() {
+	const queryClient = useQueryClient();
+	const [search, setSearch] = useState("");
+	const [deleteId, setDeleteId] = useState(0);
+	const [editId, setEditId] = useState(0 as number | "new");
 	const { isFetching, isLoading, isError, error, data } = useProxyHosts(["owner", "access_list", "certificate"]);
 
 	if (isLoading) {
@@ -16,6 +25,31 @@ export default function TableWrapper() {
 		return <Alert variant="danger">{error?.message || "Unknown error"}</Alert>;
 	}
 
+	const handleDelete = async () => {
+		await deleteProxyHost(deleteId);
+		showSuccess(intl.formatMessage({ id: "notification.host-deleted" }));
+	};
+
+	const handleDisableToggle = async (id: number, enabled: boolean) => {
+		await toggleProxyHost(id, enabled);
+		queryClient.invalidateQueries({ queryKey: ["proxy-hosts"] });
+		queryClient.invalidateQueries({ queryKey: ["proxy-host", id] });
+		showSuccess(intl.formatMessage({ id: enabled ? "notification.host-enabled" : "notification.host-disabled" }));
+	};
+
+	let filtered = null;
+	if (search && data) {
+		filtered = data?.filter((_item) => {
+			return true;
+			// item.domainNames.some((domain: string) => domain.toLowerCase().includes(search)) ||
+			// item.forwardDomainName.toLowerCase().includes(search)
+			// );
+		});
+	} else if (search !== "") {
+		// this can happen if someone deletes the last item while searching
+		setSearch("");
+	}
+
 	return (
 		<div className="card mt-4">
 			<div className="card-status-top bg-lime" />
@@ -25,27 +59,48 @@ export default function TableWrapper() {
 						<div className="col">
 							<h2 className="mt-1 mb-0">{intl.formatMessage({ id: "proxy-hosts.title" })}</h2>
 						</div>
-						<div className="col-md-auto col-sm-12">
-							<div className="ms-auto d-flex flex-wrap btn-list">
-								<div className="input-group input-group-flat w-auto">
-									<span className="input-group-text input-group-text-sm">
-										<IconSearch size={16} />
-									</span>
-									<input
-										id="advanced-table-search"
-										type="text"
-										className="form-control form-control-sm"
-										autoComplete="off"
-									/>
+						{data?.length ? (
+							<div className="col-md-auto col-sm-12">
+								<div className="ms-auto d-flex flex-wrap btn-list">
+									<div className="input-group input-group-flat w-auto">
+										<span className="input-group-text input-group-text-sm">
+											<IconSearch size={16} />
+										</span>
+										<input
+											id="advanced-table-search"
+											type="text"
+											className="form-control form-control-sm"
+											autoComplete="off"
+										/>
+									</div>
+									<Button size="sm" className="btn-lime">
+										{intl.formatMessage({ id: "proxy-hosts.add" })}
+									</Button>
 								</div>
-								<Button size="sm" className="btn-lime">
-									{intl.formatMessage({ id: "proxy-hosts.add" })}
-								</Button>
 							</div>
-						</div>
+						) : null}
 					</div>
 				</div>
-				<Table data={data ?? []} isFetching={isFetching} />
+				<Table
+					data={filtered ?? data ?? []}
+					isFiltered={!!search}
+					isFetching={isFetching}
+					onEdit={(id: number) => setEditId(id)}
+					onDelete={(id: number) => setDeleteId(id)}
+					onDisableToggle={handleDisableToggle}
+					onNew={() => setEditId("new")}
+				/>
+				{editId ? <ProxyHostModal id={editId} onClose={() => setEditId(0)} /> : null}
+				{deleteId ? (
+					<DeleteConfirmModal
+						title={intl.formatMessage({ id: "proxy-host.delete.title" })}
+						onConfirm={handleDelete}
+						onClose={() => setDeleteId(0)}
+						invalidations={[["proxy-hosts"], ["proxy-host", deleteId]]}
+					>
+						{intl.formatMessage({ id: "proxy-host.delete.content" })}
+					</DeleteConfirmModal>
+				) : null}
 			</div>
 		</div>
 	);

+ 1 - 1
frontend/src/pages/Nginx/RedirectionHosts/Table.tsx

@@ -74,7 +74,7 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
 				},
 			}),
 			columnHelper.display({
-				id: "id", // todo: not needed for a display?
+				id: "id",
 				cell: (info: any) => {
 					return (
 						<span className="dropdown">

+ 9 - 8
frontend/src/pages/Nginx/RedirectionHosts/TableWrapper.tsx

@@ -59,9 +59,9 @@ export default function TableWrapper() {
 						<div className="col">
 							<h2 className="mt-1 mb-0">{intl.formatMessage({ id: "redirection-hosts.title" })}</h2>
 						</div>
-						<div className="col-md-auto col-sm-12">
-							<div className="ms-auto d-flex flex-wrap btn-list">
-								{data?.length ? (
+						{data?.length ? (
+							<div className="col-md-auto col-sm-12">
+								<div className="ms-auto d-flex flex-wrap btn-list">
 									<div className="input-group input-group-flat w-auto">
 										<span className="input-group-text input-group-text-sm">
 											<IconSearch size={16} />
@@ -74,12 +74,13 @@ export default function TableWrapper() {
 											onChange={(e: any) => setSearch(e.target.value.toLowerCase().trim())}
 										/>
 									</div>
-								) : null}
-								<Button size="sm" className="btn-yellow" onClick={() => setEditId("new")}>
-									{intl.formatMessage({ id: "redirection-hosts.add" })}
-								</Button>
+
+									<Button size="sm" className="btn-yellow" onClick={() => setEditId("new")}>
+										{intl.formatMessage({ id: "redirection-hosts.add" })}
+									</Button>
+								</div>
 							</div>
-						</div>
+						) : null}
 					</div>
 				</div>
 				<Table

+ 8 - 8
frontend/src/pages/Nginx/Streams/TableWrapper.tsx

@@ -62,9 +62,9 @@ export default function TableWrapper() {
 						<div className="col">
 							<h2 className="mt-1 mb-0">{intl.formatMessage({ id: "streams.title" })}</h2>
 						</div>
-						<div className="col-md-auto col-sm-12">
-							<div className="ms-auto d-flex flex-wrap btn-list">
-								{data?.length ? (
+						{data?.length ? (
+							<div className="col-md-auto col-sm-12">
+								<div className="ms-auto d-flex flex-wrap btn-list">
 									<div className="input-group input-group-flat w-auto">
 										<span className="input-group-text input-group-text-sm">
 											<IconSearch size={16} />
@@ -77,12 +77,12 @@ export default function TableWrapper() {
 											onChange={(e: any) => setSearch(e.target.value.toLowerCase().trim())}
 										/>
 									</div>
-								) : null}
-								<Button size="sm" className="btn-blue" onClick={() => setEditId("new")}>
-									{intl.formatMessage({ id: "streams.add" })}
-								</Button>
+									<Button size="sm" className="btn-blue" onClick={() => setEditId("new")}>
+										{intl.formatMessage({ id: "streams.add" })}
+									</Button>
+								</div>
 							</div>
-						</div>
+						) : null}
 					</div>
 				</div>
 				<Table

+ 9 - 8
frontend/src/pages/Users/TableWrapper.tsx

@@ -63,9 +63,9 @@ export default function TableWrapper() {
 						<div className="col">
 							<h2 className="mt-1 mb-0">{intl.formatMessage({ id: "users.title" })}</h2>
 						</div>
-						<div className="col-md-auto col-sm-12">
-							<div className="ms-auto d-flex flex-wrap btn-list">
-								{data?.length ? (
+						{data?.length ? (
+							<div className="col-md-auto col-sm-12">
+								<div className="ms-auto d-flex flex-wrap btn-list">
 									<div className="input-group input-group-flat w-auto">
 										<span className="input-group-text input-group-text-sm">
 											<IconSearch size={16} />
@@ -78,12 +78,13 @@ export default function TableWrapper() {
 											onChange={(e: any) => setSearch(e.target.value.toLowerCase().trim())}
 										/>
 									</div>
-								) : null}
-								<Button size="sm" className="btn-orange" onClick={() => setEditUserId("new")}>
-									{intl.formatMessage({ id: "users.add" })}
-								</Button>
+
+									<Button size="sm" className="btn-orange" onClick={() => setEditUserId("new")}>
+										{intl.formatMessage({ id: "users.add" })}
+									</Button>
+								</div>
 							</div>
-						</div>
+						) : null}
 					</div>
 				</div>
 				<Table