Jamie Curnow пре 2 месеци
родитељ
комит
43599b4028

+ 21 - 0
frontend/src/App.css

@@ -74,3 +74,24 @@
 label.row {
 label.row {
 	cursor: pointer;
 	cursor: pointer;
 }
 }
+
+.input-group-select {
+	display: flex;
+	align-items: center;
+	padding: 0;
+	font-size: .875rem;
+	font-weight: 400;
+	line-height: 1.25rem;
+	color: var(--tblr-gray-500);
+	text-align: center;
+	white-space: nowrap;
+	background-color: var(--tblr-bg-surface-secondary);
+	border: var(--tblr-border-width) solid var(--tblr-border-color);
+	border-radius: var(--tblr-border-radius);
+
+	.form-select {
+		border: none;
+		background-color: var(--tblr-bg-surface-secondary);
+		border-radius: var(--tblr-border-radius) 0 0 var(--tblr-border-radius);
+	}
+}

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

@@ -67,8 +67,8 @@ export interface AccessListItem {
 	accessListId?: number;
 	accessListId?: number;
 	username: string;
 	username: string;
 	password: string;
 	password: string;
-	meta: Record<string, any>;
-	hint: string;
+	meta?: Record<string, any>;
+	hint?: string;
 }
 }
 
 
 export type AccessListClient = {
 export type AccessListClient = {
@@ -78,7 +78,7 @@ export type AccessListClient = {
 	accessListId?: number;
 	accessListId?: number;
 	address: string;
 	address: string;
 	directive: "allow" | "deny";
 	directive: "allow" | "deny";
-	meta: Record<string, any>;
+	meta?: Record<string, any>;
 };
 };
 
 
 export interface Certificate {
 export interface Certificate {

+ 131 - 0
frontend/src/components/Form/AccessClientFields.tsx

@@ -0,0 +1,131 @@
+import { IconX } from "@tabler/icons-react";
+import cn from "classnames";
+import { useFormikContext } from "formik";
+import { useState } from "react";
+import type { AccessListClient } from "src/api/backend";
+import { T } from "src/locale";
+
+interface Props {
+	initialValues: AccessListClient[];
+	name?: string;
+}
+export function AccessClientFields({ initialValues, name = "clients" }: Props) {
+	const [values, setValues] = useState<AccessListClient[]>(initialValues || []);
+	const { setFieldValue } = useFormikContext();
+
+	const blankClient: AccessListClient = { directive: "allow", address: "" };
+
+	if (values?.length === 0) {
+		setValues([blankClient]);
+	}
+
+	const handleAdd = () => {
+		setValues([...values, blankClient]);
+	};
+
+	const handleRemove = (idx: number) => {
+		const newValues = values.filter((_: AccessListClient, i: number) => i !== idx);
+		if (newValues.length === 0) {
+			newValues.push(blankClient);
+		}
+		setValues(newValues);
+		setFormField(newValues);
+	};
+
+	const handleChange = (idx: number, field: string, fieldValue: string) => {
+		const newValues = values.map((v: AccessListClient, i: number) =>
+			i === idx ? { ...v, [field]: fieldValue } : v,
+		);
+		setValues(newValues);
+		setFormField(newValues);
+	};
+
+	const setFormField = (newValues: AccessListClient[]) => {
+		const filtered = newValues.filter((v: AccessListClient) => v?.address?.trim() !== "");
+		setFieldValue(name, filtered);
+	};
+
+	return (
+		<>
+			<p className="text-muted">
+				<T id="access.help.rules-order" />
+			</p>
+			{values.map((client: AccessListClient, idx: number) => (
+				<div className="row mb-1" key={idx}>
+					<div className="col-11">
+						<div className="input-group mb-2">
+							<span className="input-group-select">
+								<select
+									className={cn(
+										"form-select",
+										"m-0",
+										client.directive === "allow" ? "bg-lime-lt" : "bg-orange-lt",
+									)}
+									name={`clients[${idx}].directive`}
+									value={client.directive}
+									onChange={(e) => handleChange(idx, "directive", e.target.value)}
+								>
+									<option value="allow">Allow</option>
+									<option value="deny">Deny</option>
+								</select>
+							</span>
+							<input
+								name={`clients[${idx}].address`}
+								type="text"
+								className="form-control"
+								autoComplete="off"
+								value={client.address}
+								onChange={(e) => handleChange(idx, "address", e.target.value)}
+								placeholder="192.168.1.100 or 192.168.1.0/24 or 2001:0db8::/32"
+							/>
+						</div>
+					</div>
+					<div className="col-1">
+						<a
+							role="button"
+							className="btn btn-ghost btn-danger p-0"
+							onClick={(e) => {
+								e.preventDefault();
+								handleRemove(idx);
+							}}
+						>
+							<IconX size={16} />
+						</a>
+					</div>
+				</div>
+			))}
+			<div className="mb-3">
+				<button type="button" className="btn btn-sm" onClick={handleAdd}>
+					<T id="action.add" />
+				</button>
+			</div>
+			<div className="row mb-3">
+				<p className="text-muted">
+					<T id="access.help-rules-last" />
+				</p>
+				<div className="col-11">
+					<div className="input-group mb-2">
+						<span className="input-group-select">
+							<select
+								className="form-select m-0 bg-orange-lt"
+								name="clients[last].directive"
+								value="deny"
+								disabled
+							>
+								<option value="deny">Deny</option>
+							</select>
+						</span>
+						<input
+							name="clients[last].address"
+							type="text"
+							className="form-control"
+							autoComplete="off"
+							value="all"
+							disabled
+						/>
+					</div>
+				</div>
+			</div>
+		</>
+	);
+}

+ 1 - 1
frontend/src/components/Form/AccessField.tsx

@@ -32,7 +32,7 @@ interface Props {
 	label?: string;
 	label?: string;
 }
 }
 export function AccessField({ name = "accessListId", label = "access.title", id = "accessListId" }: Props) {
 export function AccessField({ name = "accessListId", label = "access.title", id = "accessListId" }: Props) {
-	const { isLoading, isError, error, data } = useAccessLists();
+	const { isLoading, isError, error, data } = useAccessLists(["owner", "items", "clients"]);
 	const { setFieldValue } = useFormikContext();
 	const { setFieldValue } = useFormikContext();
 
 
 	const handleChange = (newValue: any, _actionMeta: ActionMeta<AccessOption>) => {
 	const handleChange = (newValue: any, _actionMeta: ActionMeta<AccessOption>) => {

+ 0 - 36
frontend/src/components/Form/BasicAuthField.tsx

@@ -1,36 +0,0 @@
-import { useFormikContext } from "formik";
-import { T } from "src/locale";
-
-interface Props {
-	id?: string;
-	name?: string;
-}
-export function BasicAuthField({ name = "items", id = "items" }: Props) {
-	const { setFieldValue } = useFormikContext();
-
-	return (
-		<>
-			<div className="row">
-				<div className="col-6">
-					<label className="form-label" htmlFor="...">
-						<T id="username" />
-					</label>
-				</div>
-				<div className="col-6">
-					<label className="form-label" htmlFor="...">
-						<T id="password" />
-					</label>
-				</div>
-			</div>
-			<div className="row mb-3">
-				<div className="col-6">
-					<input id="name" type="text" required autoComplete="off" className="form-control" />
-				</div>
-				<div className="col-6">
-					<input id="pw" type="password" required autoComplete="off" className="form-control" />
-				</div>
-			</div>
-			<button className="btn">+</button>
-		</>
-	);
-}

+ 105 - 0
frontend/src/components/Form/BasicAuthFields.tsx

@@ -0,0 +1,105 @@
+import { IconX } from "@tabler/icons-react";
+import { useFormikContext } from "formik";
+import { useState } from "react";
+import type { AccessListItem } from "src/api/backend";
+import { T } from "src/locale";
+
+interface Props {
+	initialValues: AccessListItem[];
+	name?: string;
+}
+export function BasicAuthFields({ initialValues, name = "items" }: Props) {
+	const [values, setValues] = useState<AccessListItem[]>(initialValues || []);
+	const { setFieldValue } = useFormikContext();
+
+	const blankItem: AccessListItem = { username: "", password: "" };
+
+	if (values?.length === 0) {
+		setValues([blankItem]);
+	}
+
+	const handleAdd = () => {
+		setValues([...values, blankItem]);
+	};
+
+	const handleRemove = (idx: number) => {
+		const newValues = values.filter((_: AccessListItem, i: number) => i !== idx);
+		if (newValues.length === 0) {
+			newValues.push(blankItem);
+		}
+		setValues(newValues);
+		setFormField(newValues);
+	};
+
+	const handleChange = (idx: number, field: string, fieldValue: string) => {
+		const newValues = values.map((v: AccessListItem, i: number) => (i === idx ? { ...v, [field]: fieldValue } : v));
+		setValues(newValues);
+		setFormField(newValues);
+	};
+
+	const setFormField = (newValues: AccessListItem[]) => {
+		const filtered = newValues.filter((v: AccessListItem) => v?.username?.trim() !== "");
+		setFieldValue(name, filtered);
+	};
+
+	return (
+		<>
+			<div className="row">
+				<div className="col-6">
+					<label className="form-label" htmlFor="...">
+						<T id="username" />
+					</label>
+				</div>
+				<div className="col-6">
+					<label className="form-label" htmlFor="...">
+						<T id="password" />
+					</label>
+				</div>
+			</div>
+			{values.map((item: AccessListItem, idx: number) => (
+				<div className="row mb-3" key={idx}>
+					<div className="col-6">
+						<input
+							type="text"
+							autoComplete="off"
+							className="form-control input-sm"
+							value={item.username}
+							onChange={(e) => handleChange(idx, "username", e.target.value)}
+						/>
+					</div>
+					<div className="col-5">
+						<input
+							type="password"
+							autoComplete="off"
+							className="form-control"
+							value={item.password}
+							placeholder={
+								initialValues.filter((iv: AccessListItem) => iv.username === item.username).length > 0
+									? "••••••••"
+									: ""
+							}
+							onChange={(e) => handleChange(idx, "password", e.target.value)}
+						/>
+					</div>
+					<div className="col-1">
+						<a
+							role="button"
+							className="btn btn-ghost btn-danger p-0"
+							onClick={(e) => {
+								e.preventDefault();
+								handleRemove(idx);
+							}}
+						>
+							<IconX size={16} />
+						</a>
+					</div>
+				</div>
+			))}
+			<div>
+				<button type="button" className="btn btn-sm" onClick={handleAdd}>
+					<T id="action.add" />
+				</button>
+			</div>
+		</>
+	);
+}

+ 2 - 1
frontend/src/components/Form/index.ts

@@ -1,5 +1,6 @@
+export * from "./AccessClientFields";
 export * from "./AccessField";
 export * from "./AccessField";
-export * from "./BasicAuthField";
+export * from "./BasicAuthFields";
 export * from "./DNSProviderFields";
 export * from "./DNSProviderFields";
 export * from "./DomainNamesField";
 export * from "./DomainNamesField";
 export * from "./NginxConfigField";
 export * from "./NginxConfigField";

+ 13 - 7
frontend/src/hooks/useAccessList.ts

@@ -1,7 +1,13 @@
 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { type AccessList, createAccessList, getAccessList, updateAccessList } from "src/api/backend";
+import {
+	type AccessList,
+	type AccessListExpansion,
+	createAccessList,
+	getAccessList,
+	updateAccessList,
+} from "src/api/backend";
 
 
-const fetchAccessList = (id: number | "new") => {
+const fetchAccessList = (id: number | "new", expand: AccessListExpansion[] = ["owner"]) => {
 	if (id === "new") {
 	if (id === "new") {
 		return Promise.resolve({
 		return Promise.resolve({
 			id: 0,
 			id: 0,
@@ -14,13 +20,13 @@ const fetchAccessList = (id: number | "new") => {
 			meta: {},
 			meta: {},
 		} as AccessList);
 		} as AccessList);
 	}
 	}
-	return getAccessList(id, ["owner"]);
+	return getAccessList(id, expand);
 };
 };
 
 
-const useAccessList = (id: number | "new", options = {}) => {
+const useAccessList = (id: number | "new", expand?: AccessListExpansion[], options = {}) => {
 	return useQuery<AccessList, Error>({
 	return useQuery<AccessList, Error>({
-		queryKey: ["access-list", id],
-		queryFn: () => fetchAccessList(id),
+		queryKey: ["access-list", id, expand],
+		queryFn: () => fetchAccessList(id, expand),
 		staleTime: 60 * 1000, // 1 minute
 		staleTime: 60 * 1000, // 1 minute
 		...options,
 		...options,
 	});
 	});
@@ -44,7 +50,7 @@ const useSetAccessList = () => {
 		onError: (_, __, rollback: any) => rollback(),
 		onError: (_, __, rollback: any) => rollback(),
 		onSuccess: async ({ id }: AccessList) => {
 		onSuccess: async ({ id }: AccessList) => {
 			queryClient.invalidateQueries({ queryKey: ["access-list", id] });
 			queryClient.invalidateQueries({ queryKey: ["access-list", id] });
-			queryClient.invalidateQueries({ queryKey: ["access-list"] });
+			queryClient.invalidateQueries({ queryKey: ["access-lists"] });
 			queryClient.invalidateQueries({ queryKey: ["audit-logs"] });
 			queryClient.invalidateQueries({ queryKey: ["audit-logs"] });
 		},
 		},
 	});
 	});

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

@@ -51,6 +51,7 @@ const useSetDeadHost = () => {
 			queryClient.invalidateQueries({ queryKey: ["dead-host", id] });
 			queryClient.invalidateQueries({ queryKey: ["dead-host", id] });
 			queryClient.invalidateQueries({ queryKey: ["dead-hosts"] });
 			queryClient.invalidateQueries({ queryKey: ["dead-hosts"] });
 			queryClient.invalidateQueries({ queryKey: ["audit-logs"] });
 			queryClient.invalidateQueries({ queryKey: ["audit-logs"] });
+			queryClient.invalidateQueries({ queryKey: ["host-report"] });
 		},
 		},
 	});
 	});
 };
 };

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

@@ -58,6 +58,7 @@ const useSetProxyHost = () => {
 			queryClient.invalidateQueries({ queryKey: ["proxy-host", id] });
 			queryClient.invalidateQueries({ queryKey: ["proxy-host", id] });
 			queryClient.invalidateQueries({ queryKey: ["proxy-hosts"] });
 			queryClient.invalidateQueries({ queryKey: ["proxy-hosts"] });
 			queryClient.invalidateQueries({ queryKey: ["audit-logs"] });
 			queryClient.invalidateQueries({ queryKey: ["audit-logs"] });
+			queryClient.invalidateQueries({ queryKey: ["host-report"] });
 		},
 		},
 	});
 	});
 };
 };

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

@@ -62,6 +62,7 @@ const useSetRedirectionHost = () => {
 			queryClient.invalidateQueries({ queryKey: ["redirection-host", id] });
 			queryClient.invalidateQueries({ queryKey: ["redirection-host", id] });
 			queryClient.invalidateQueries({ queryKey: ["redirection-hosts"] });
 			queryClient.invalidateQueries({ queryKey: ["redirection-hosts"] });
 			queryClient.invalidateQueries({ queryKey: ["audit-logs"] });
 			queryClient.invalidateQueries({ queryKey: ["audit-logs"] });
+			queryClient.invalidateQueries({ queryKey: ["host-report"] });
 		},
 		},
 	});
 	});
 };
 };

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

@@ -47,6 +47,7 @@ const useSetStream = () => {
 			queryClient.invalidateQueries({ queryKey: ["stream", id] });
 			queryClient.invalidateQueries({ queryKey: ["stream", id] });
 			queryClient.invalidateQueries({ queryKey: ["streams"] });
 			queryClient.invalidateQueries({ queryKey: ["streams"] });
 			queryClient.invalidateQueries({ queryKey: ["audit-logs"] });
 			queryClient.invalidateQueries({ queryKey: ["audit-logs"] });
+			queryClient.invalidateQueries({ queryKey: ["host-report"] });
 		},
 		},
 	});
 	});
 };
 };

+ 13 - 7
frontend/src/locale/lang/en.json

@@ -1,16 +1,19 @@
 {
 {
-  "access.access-count": "{count} Rules",
+  "access.access-count": "{count} {count, plural, one {Rule} other {Rules}}",
   "access.actions-title": "Access List #{id}",
   "access.actions-title": "Access List #{id}",
   "access.add": "Add Access List",
   "access.add": "Add Access List",
-  "access.auth-count": "{count} Users",
+  "access.auth-count": "{count} {count, plural, one {User} other {Users}}",
   "access.edit": "Edit Access",
   "access.edit": "Edit Access",
   "access.empty": "There are no Access Lists",
   "access.empty": "There are no Access Lists",
+  "access.help-rules-last": "When at least 1 rule exists, this deny all rule will be added last",
+  "access.help.rules-order": "Note that the allow and deny directives will be applied in the order they are defined.",
   "access.new": "New Access",
   "access.new": "New Access",
   "access.pass-auth": "Pass Auth to Upstream",
   "access.pass-auth": "Pass Auth to Upstream",
   "access.public": "Publicly Accessible",
   "access.public": "Publicly Accessible",
   "access.satisfy-any": "Satisfy Any",
   "access.satisfy-any": "Satisfy Any",
-  "access.subtitle": "{users} User, {rules} Rules - Created: {date}",
+  "access.subtitle": "{users} {users, plural, one {User} other {Users}}, {rules} {rules, plural, one {Rule} other {Rules}} - Created: {date}",
   "access.title": "Access",
   "access.title": "Access",
+  "action.add": "Add",
   "action.delete": "Delete",
   "action.delete": "Delete",
   "action.disable": "Disable",
   "action.disable": "Disable",
   "action.edit": "Edit",
   "action.edit": "Edit",
@@ -56,7 +59,7 @@
   "dead-host.new": "New 404 Host",
   "dead-host.new": "New 404 Host",
   "dead-hosts.actions-title": "404 Host #{id}",
   "dead-hosts.actions-title": "404 Host #{id}",
   "dead-hosts.add": "Add 404 Host",
   "dead-hosts.add": "Add 404 Host",
-  "dead-hosts.count": "{count} 404 Hosts",
+  "dead-hosts.count": "{count} {count, plural, one {404 Host} other {404 Hosts}}",
   "dead-hosts.empty": "There are no 404 Hosts",
   "dead-hosts.empty": "There are no 404 Hosts",
   "dead-hosts.title": "404 Hosts",
   "dead-hosts.title": "404 Hosts",
   "disabled": "Disabled",
   "disabled": "Disabled",
@@ -74,6 +77,8 @@
   "empty-search": "No results found",
   "empty-search": "No results found",
   "empty-subtitle": "Why don't you create one?",
   "empty-subtitle": "Why don't you create one?",
   "enabled": "Enabled",
   "enabled": "Enabled",
+  "error.access.at-least-one": "Either one Authorization or one Access Rule is required",
+  "error.access.duplicate-usernames": "Authorization Usernames must be unique",
   "error.invalid-auth": "Invalid email or password",
   "error.invalid-auth": "Invalid email or password",
   "error.invalid-domain": "Invalid domain: {domain}",
   "error.invalid-domain": "Invalid domain: {domain}",
   "error.invalid-email": "Invalid email address",
   "error.invalid-email": "Invalid email address",
@@ -115,6 +120,7 @@
   "notfound.action": "Take me home",
   "notfound.action": "Take me home",
   "notfound.text": "We are sorry but the page you are looking for was not found",
   "notfound.text": "We are sorry but the page you are looking for was not found",
   "notfound.title": "Oops… You just found an error page",
   "notfound.title": "Oops… You just found an error page",
+  "notification.access-deleted": "Access has been deleted",
   "notification.access-saved": "Access has been saved",
   "notification.access-saved": "Access has been saved",
   "notification.dead-host-saved": "404 Host has been saved",
   "notification.dead-host-saved": "404 Host has been saved",
   "notification.error": "Error",
   "notification.error": "Error",
@@ -146,7 +152,7 @@
   "proxy-host.new": "New Proxy Host",
   "proxy-host.new": "New Proxy Host",
   "proxy-hosts.actions-title": "Proxy Host #{id}",
   "proxy-hosts.actions-title": "Proxy Host #{id}",
   "proxy-hosts.add": "Add Proxy Host",
   "proxy-hosts.add": "Add Proxy Host",
-  "proxy-hosts.count": "{count} Proxy Hosts",
+  "proxy-hosts.count": "{count} {count, plural, one {Proxy Host} other {Proxy Hosts}}",
   "proxy-hosts.empty": "There are no Proxy Hosts",
   "proxy-hosts.empty": "There are no Proxy Hosts",
   "proxy-hosts.title": "Proxy Hosts",
   "proxy-hosts.title": "Proxy Hosts",
   "redirection-host.delete.content": "Are you sure you want to delete this Redirection host?",
   "redirection-host.delete.content": "Are you sure you want to delete this Redirection host?",
@@ -155,7 +161,7 @@
   "redirection-host.new": "New Redirection Host",
   "redirection-host.new": "New Redirection Host",
   "redirection-hosts.actions-title": "Redirection Host #{id}",
   "redirection-hosts.actions-title": "Redirection Host #{id}",
   "redirection-hosts.add": "Add Redirection Host",
   "redirection-hosts.add": "Add Redirection Host",
-  "redirection-hosts.count": "{count} Redirection Hosts",
+  "redirection-hosts.count": "{count} {count, plural, one {Redirection Host} other {Redirection Hosts}}",
   "redirection-hosts.empty": "There are no Redirection Hosts",
   "redirection-hosts.empty": "There are no Redirection Hosts",
   "redirection-hosts.title": "Redirection Hosts",
   "redirection-hosts.title": "Redirection Hosts",
   "role.admin": "Administrator",
   "role.admin": "Administrator",
@@ -174,7 +180,7 @@
   "stream.new": "New Stream",
   "stream.new": "New Stream",
   "streams.actions-title": "Stream #{id}",
   "streams.actions-title": "Stream #{id}",
   "streams.add": "Add Stream",
   "streams.add": "Add Stream",
-  "streams.count": "{count} Streams",
+  "streams.count": "{count} {count, plural, one {Stream} other {Streams}}",
   "streams.empty": "There are no Streams",
   "streams.empty": "There are no Streams",
   "streams.tcp": "TCP",
   "streams.tcp": "TCP",
   "streams.title": "Streams",
   "streams.title": "Streams",

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

@@ -1,6 +1,6 @@
 {
 {
 	"access.access-count": {
 	"access.access-count": {
-		"defaultMessage": "{count} Rules"
+		"defaultMessage": "{count} {count, plural, one {Rule} other {Rules}}"
 	},
 	},
 	"access.actions-title": {
 	"access.actions-title": {
 		"defaultMessage": "Access List #{id}"
 		"defaultMessage": "Access List #{id}"
@@ -9,7 +9,7 @@
 		"defaultMessage": "Add Access List"
 		"defaultMessage": "Add Access List"
 	},
 	},
 	"access.auth-count": {
 	"access.auth-count": {
-		"defaultMessage": "{count} Users"
+		"defaultMessage": "{count} {count, plural, one {User} other {Users}}"
 	},
 	},
 	"access.edit": {
 	"access.edit": {
 		"defaultMessage": "Edit Access"
 		"defaultMessage": "Edit Access"
@@ -17,6 +17,12 @@
 	"access.empty": {
 	"access.empty": {
 		"defaultMessage": "There are no Access Lists"
 		"defaultMessage": "There are no Access Lists"
 	},
 	},
+	"access.help-rules-last": {
+		"defaultMessage": "When at least 1 rule exists, this deny all rule will be added last"
+	},
+	"access.help.rules-order": {
+		"defaultMessage": "Note that the allow and deny directives will be applied in the order they are defined."
+	},
 	"access.new": {
 	"access.new": {
 		"defaultMessage": "New Access"
 		"defaultMessage": "New Access"
 	},
 	},
@@ -30,11 +36,14 @@
 		"defaultMessage": "Satisfy Any"
 		"defaultMessage": "Satisfy Any"
 	},
 	},
 	"access.subtitle": {
 	"access.subtitle": {
-		"defaultMessage": "{users} User, {rules} Rules - Created: {date}"
+		"defaultMessage": "{users} {users, plural, one {User} other {Users}}, {rules} {rules, plural, one {Rule} other {Rules}} - Created: {date}"
 	},
 	},
 	"access.title": {
 	"access.title": {
 		"defaultMessage": "Access"
 		"defaultMessage": "Access"
 	},
 	},
+	"action.add": {
+		"defaultMessage": "Add"
+	},
 	"action.delete": {
 	"action.delete": {
 		"defaultMessage": "Delete"
 		"defaultMessage": "Delete"
 	},
 	},
@@ -171,7 +180,7 @@
 		"defaultMessage": "Add 404 Host"
 		"defaultMessage": "Add 404 Host"
 	},
 	},
 	"dead-hosts.count": {
 	"dead-hosts.count": {
-		"defaultMessage": "{count} 404 Hosts"
+		"defaultMessage": "{count} {count, plural, one {404 Host} other {404 Hosts}}"
 	},
 	},
 	"dead-hosts.empty": {
 	"dead-hosts.empty": {
 		"defaultMessage": "There are no 404 Hosts"
 		"defaultMessage": "There are no 404 Hosts"
@@ -224,6 +233,12 @@
 	"enabled": {
 	"enabled": {
 		"defaultMessage": "Enabled"
 		"defaultMessage": "Enabled"
 	},
 	},
+	"error.access.at-least-one": {
+		"defaultMessage": "Either one Authorization or one Access Rule is required"
+	},
+	"error.access.duplicate-usernames": {
+		"defaultMessage": "Authorization Usernames must be unique"
+	},
 	"error.invalid-auth": {
 	"error.invalid-auth": {
 		"defaultMessage": "Invalid email or password"
 		"defaultMessage": "Invalid email or password"
 	},
 	},
@@ -347,6 +362,9 @@
 	"notfound.title": {
 	"notfound.title": {
 		"defaultMessage": "Oops… You just found an error page"
 		"defaultMessage": "Oops… You just found an error page"
 	},
 	},
+	"notification.access-deleted": {
+		"defaultMessage": "Access has been deleted"
+	},
 	"notification.access-saved": {
 	"notification.access-saved": {
 		"defaultMessage": "Access has been saved"
 		"defaultMessage": "Access has been saved"
 	},
 	},
@@ -441,7 +459,7 @@
 		"defaultMessage": "Add Proxy Host"
 		"defaultMessage": "Add Proxy Host"
 	},
 	},
 	"proxy-hosts.count": {
 	"proxy-hosts.count": {
-		"defaultMessage": "{count} Proxy Hosts"
+		"defaultMessage": "{count} {count, plural, one {Proxy Host} other {Proxy Hosts}}"
 	},
 	},
 	"proxy-hosts.empty": {
 	"proxy-hosts.empty": {
 		"defaultMessage": "There are no Proxy Hosts"
 		"defaultMessage": "There are no Proxy Hosts"
@@ -468,7 +486,7 @@
 		"defaultMessage": "Add Redirection Host"
 		"defaultMessage": "Add Redirection Host"
 	},
 	},
 	"redirection-hosts.count": {
 	"redirection-hosts.count": {
-		"defaultMessage": "{count} Redirection Hosts"
+		"defaultMessage": "{count} {count, plural, one {Redirection Host} other {Redirection Hosts}}"
 	},
 	},
 	"redirection-hosts.empty": {
 	"redirection-hosts.empty": {
 		"defaultMessage": "There are no Redirection Hosts"
 		"defaultMessage": "There are no Redirection Hosts"
@@ -525,7 +543,7 @@
 		"defaultMessage": "Add Stream"
 		"defaultMessage": "Add Stream"
 	},
 	},
 	"streams.count": {
 	"streams.count": {
-		"defaultMessage": "{count} Streams"
+		"defaultMessage": "{count} {count, plural, one {Stream} other {Streams}}"
 	},
 	},
 	"streams.empty": {
 	"streams.empty": {
 		"defaultMessage": "There are no Streams"
 		"defaultMessage": "There are no Streams"

+ 53 - 10
frontend/src/modals/AccessListModal.tsx

@@ -3,7 +3,8 @@ import { Field, Form, Formik } from "formik";
 import { type ReactNode, useState } from "react";
 import { type ReactNode, useState } from "react";
 import { Alert } from "react-bootstrap";
 import { Alert } from "react-bootstrap";
 import Modal from "react-bootstrap/Modal";
 import Modal from "react-bootstrap/Modal";
-import { BasicAuthField, Button, Loading } from "src/components";
+import type { AccessList, AccessListClient, AccessListItem } from "src/api/backend";
+import { AccessClientFields, BasicAuthFields, Button, Loading } from "src/components";
 import { useAccessList, useSetAccessList } from "src/hooks";
 import { useAccessList, useSetAccessList } from "src/hooks";
 import { intl, T } from "src/locale";
 import { intl, T } from "src/locale";
 import { validateString } from "src/modules/Validations";
 import { validateString } from "src/modules/Validations";
@@ -14,13 +15,36 @@ interface Props {
 	onClose: () => void;
 	onClose: () => void;
 }
 }
 export function AccessListModal({ id, onClose }: Props) {
 export function AccessListModal({ id, onClose }: Props) {
-	const { data, isLoading, error } = useAccessList(id);
+	const { data, isLoading, error } = useAccessList(id, ["items", "clients"]);
 	const { mutate: setAccessList } = useSetAccessList();
 	const { mutate: setAccessList } = useSetAccessList();
 	const [errorMsg, setErrorMsg] = useState<ReactNode | null>(null);
 	const [errorMsg, setErrorMsg] = useState<ReactNode | null>(null);
 	const [isSubmitting, setIsSubmitting] = useState(false);
 	const [isSubmitting, setIsSubmitting] = useState(false);
 
 
+	const validate = (values: any): string | null => {
+		// either Auths or Clients must be defined
+		if (values.items?.length === 0 && values.clients?.length === 0) {
+			return intl.formatMessage({ id: "error.access.at-least-one" });
+		}
+
+		// ensure the items don't contain the same username twice
+		const usernames = values.items.map((i: any) => i.username);
+		const uniqueUsernames = Array.from(new Set(usernames));
+		if (usernames.length !== uniqueUsernames.length) {
+			return intl.formatMessage({ id: "error.access.duplicate-usernames" });
+		}
+
+		return null;
+	};
+
 	const onSubmit = async (values: any, { setSubmitting }: any) => {
 	const onSubmit = async (values: any, { setSubmitting }: any) => {
 		if (isSubmitting) return;
 		if (isSubmitting) return;
+
+		const vErr = validate(values);
+		if (vErr) {
+			setErrorMsg(vErr);
+			return;
+		}
+
 		setIsSubmitting(true);
 		setIsSubmitting(true);
 		setErrorMsg(null);
 		setErrorMsg(null);
 
 
@@ -29,6 +53,18 @@ export function AccessListModal({ id, onClose }: Props) {
 			...values,
 			...values,
 		};
 		};
 
 
+		// Filter out "items" to only use the "username" and "password" fields
+		payload.items = (values.items || []).map((i: AccessListItem) => ({
+			username: i.username,
+			password: i.password,
+		}));
+
+		// Filter out "clients" to only use the "directive" and "address" fields
+		payload.clients = (values.clients || []).map((i: AccessListClient) => ({
+			directive: i.directive,
+			address: i.address,
+		}));
+
 		setAccessList(payload, {
 		setAccessList(payload, {
 			onError: (err: any) => setErrorMsg(<T id={err.message} />),
 			onError: (err: any) => setErrorMsg(<T id={err.message} />),
 			onSuccess: () => {
 			onSuccess: () => {
@@ -60,9 +96,9 @@ export function AccessListModal({ id, onClose }: Props) {
 							name: data?.name,
 							name: data?.name,
 							satisfyAny: data?.satisfyAny,
 							satisfyAny: data?.satisfyAny,
 							passAuth: data?.passAuth,
 							passAuth: data?.passAuth,
-							// todo: more? there's stuff missing here?
-							meta: data?.meta || {},
-						} as any
+							items: data?.items || [],
+							clients: data?.clients || [],
+						} as AccessList
 					}
 					}
 					onSubmit={onSubmit}
 					onSubmit={onSubmit}
 				>
 				>
@@ -105,7 +141,7 @@ export function AccessListModal({ id, onClose }: Props) {
 											</li>
 											</li>
 											<li className="nav-item" role="presentation">
 											<li className="nav-item" role="presentation">
 												<a
 												<a
-													href="#tab-access"
+													href="#tab-rules"
 													className="nav-link"
 													className="nav-link"
 													data-bs-toggle="tab"
 													data-bs-toggle="tab"
 													aria-selected="false"
 													aria-selected="false"
@@ -120,8 +156,8 @@ export function AccessListModal({ id, onClose }: Props) {
 									<div className="card-body">
 									<div className="card-body">
 										<div className="tab-content">
 										<div className="tab-content">
 											<div className="tab-pane active show" id="tab-details" role="tabpanel">
 											<div className="tab-pane active show" id="tab-details" role="tabpanel">
-												<Field name="name" validate={validateString(8, 255)}>
-													{({ field }: any) => (
+												<Field name="name" validate={validateString(1, 255)}>
+													{({ field, form }: any) => (
 														<div>
 														<div>
 															<label htmlFor="name" className="form-label">
 															<label htmlFor="name" className="form-label">
 																<T id="column.name" />
 																<T id="column.name" />
@@ -134,6 +170,13 @@ export function AccessListModal({ id, onClose }: Props) {
 																className="form-control"
 																className="form-control"
 																{...field}
 																{...field}
 															/>
 															/>
+															{form.errors.name ? (
+																<div className="invalid-feedback">
+																	{form.errors.name && form.touched.name
+																		? form.errors.name
+																		: null}
+																</div>
+															) : null}
 														</div>
 														</div>
 													)}
 													)}
 												</Field>
 												</Field>
@@ -210,10 +253,10 @@ export function AccessListModal({ id, onClose }: Props) {
 												</div>
 												</div>
 											</div>
 											</div>
 											<div className="tab-pane" id="tab-auth" role="tabpanel">
 											<div className="tab-pane" id="tab-auth" role="tabpanel">
-												<BasicAuthField />
+												<BasicAuthFields initialValues={data?.items || []} />
 											</div>
 											</div>
 											<div className="tab-pane" id="tab-rules" role="tabpanel">
 											<div className="tab-pane" id="tab-rules" role="tabpanel">
-												todo
+												<AccessClientFields initialValues={data?.clients || []} />
 											</div>
 											</div>
 										</div>
 										</div>
 									</div>
 									</div>

+ 3 - 0
frontend/src/modals/ChangePasswordModal.tsx

@@ -66,6 +66,7 @@ export function ChangePasswordModal({ userId, onClose }: Props) {
 											<input
 											<input
 												id="current"
 												id="current"
 												type="password"
 												type="password"
+												autoComplete="current-password"
 												required
 												required
 												className={`form-control ${form.errors.current && form.touched.current ? "is-invalid" : ""}`}
 												className={`form-control ${form.errors.current && form.touched.current ? "is-invalid" : ""}`}
 												placeholder={intl.formatMessage({
 												placeholder={intl.formatMessage({
@@ -94,6 +95,7 @@ export function ChangePasswordModal({ userId, onClose }: Props) {
 											<input
 											<input
 												id="new"
 												id="new"
 												type="password"
 												type="password"
+												autoComplete="new-password"
 												required
 												required
 												className={`form-control ${form.errors.new && form.touched.new ? "is-invalid" : ""}`}
 												className={`form-control ${form.errors.new && form.touched.new ? "is-invalid" : ""}`}
 												placeholder={intl.formatMessage({ id: "user.new-password" })}
 												placeholder={intl.formatMessage({ id: "user.new-password" })}
@@ -118,6 +120,7 @@ export function ChangePasswordModal({ userId, onClose }: Props) {
 											<input
 											<input
 												id="confirm"
 												id="confirm"
 												type="password"
 												type="password"
+												autoComplete="new-password"
 												required
 												required
 												className={`form-control ${form.errors.confirm && form.touched.confirm ? "is-invalid" : ""}`}
 												className={`form-control ${form.errors.confirm && form.touched.confirm ? "is-invalid" : ""}`}
 												placeholder={intl.formatMessage({ id: "user.confirm-password" })}
 												placeholder={intl.formatMessage({ id: "user.confirm-password" })}

+ 1 - 1
frontend/src/pages/Login/index.tsx

@@ -99,11 +99,11 @@ export default function Login() {
 														<input
 														<input
 															{...field}
 															{...field}
 															type="password"
 															type="password"
+															autoComplete="current-password"
 															required
 															required
 															maxLength={255}
 															maxLength={255}
 															className={`form-control ${form.errors.password && form.touched.password ? " is-invalid" : ""}`}
 															className={`form-control ${form.errors.password && form.touched.password ? " is-invalid" : ""}`}
 															placeholder="Password"
 															placeholder="Password"
-															autoComplete="off"
 														/>
 														/>
 														<div className="invalid-feedback">{form.errors.password}</div>
 														<div className="invalid-feedback">{form.errors.password}</div>
 													</label>
 													</label>

+ 1 - 0
frontend/src/pages/Setup/index.tsx

@@ -154,6 +154,7 @@ export default function Setup() {
 													<input
 													<input
 														id="password"
 														id="password"
 														type="password"
 														type="password"
+														autoComplete="new-password"
 														className={`form-control ${form.errors.password && form.touched.password ? "is-invalid" : ""}`}
 														className={`form-control ${form.errors.password && form.touched.password ? "is-invalid" : ""}`}
 														placeholder={intl.formatMessage({ id: "user.new-password" })}
 														placeholder={intl.formatMessage({ id: "user.new-password" })}
 														{...field}
 														{...field}