Jelajahi Sumber

Wrap intl in span identifying translation

Jamie Curnow 2 bulan lalu
induk
melakukan
227e818040
68 mengubah file dengan 1076 tambahan dan 510 penghapusan
  1. 2 1
      frontend/biome.json
  2. 1 1
      frontend/src/api/backend/models.ts
  3. 8 4
      frontend/src/components/ErrorNotFound.tsx
  4. 99 0
      frontend/src/components/Form/AccessField.tsx
  5. 36 0
      frontend/src/components/Form/BasicAuthField.tsx
  6. 1 1
      frontend/src/components/Form/DNSProviderFields.tsx
  7. 10 9
      frontend/src/components/Form/DomainNamesField.tsx
  8. 2 2
      frontend/src/components/Form/NginxConfigField.tsx
  9. 2 2
      frontend/src/components/Form/SSLCertificateField.tsx
  10. 6 6
      frontend/src/components/Form/SSLOptionsFields.tsx
  11. 2 0
      frontend/src/components/Form/index.ts
  12. 6 2
      frontend/src/components/HasPermission.tsx
  13. 4 3
      frontend/src/components/Loading.tsx
  14. 2 23
      frontend/src/components/LocalePicker.tsx
  15. 2 2
      frontend/src/components/SiteFooter.tsx
  16. 5 7
      frontend/src/components/SiteHeader.tsx
  17. 8 4
      frontend/src/components/SiteMenu.tsx
  18. 2 6
      frontend/src/components/Table/Formatter/CertificateFormatter.tsx
  19. 2 2
      frontend/src/components/Table/Formatter/DomainsFormatter.tsx
  20. 7 5
      frontend/src/components/Table/Formatter/EnabledFormatter.tsx
  21. 4 6
      frontend/src/components/Table/Formatter/EventFormatter.tsx
  22. 2 2
      frontend/src/components/Table/Formatter/RolesFormatter.tsx
  23. 7 5
      frontend/src/components/Table/Formatter/StatusFormatter.tsx
  24. 2 4
      frontend/src/components/Table/Formatter/ValueWithDateFormatter.tsx
  25. 1 0
      frontend/src/hooks/index.ts
  26. 53 0
      frontend/src/hooks/useAccessList.ts
  27. 7 1
      frontend/src/locale/IntlProvider.tsx
  28. 16 3
      frontend/src/locale/lang/en.json
  29. 45 6
      frontend/src/locale/src/en.json
  30. 243 0
      frontend/src/modals/AccessListModal.tsx
  31. 13 11
      frontend/src/modals/ChangePasswordModal.tsx
  32. 9 10
      frontend/src/modals/DeadHostModal.tsx
  33. 8 6
      frontend/src/modals/DeleteConfirmModal.tsx
  34. 5 3
      frontend/src/modals/EventDetailsModal.tsx
  35. 19 19
      frontend/src/modals/PermissionsModal.tsx
  36. 20 33
      frontend/src/modals/ProxyHostModal.tsx
  37. 14 25
      frontend/src/modals/RedirectionHostModal.tsx
  38. 14 17
      frontend/src/modals/SetPasswordModal.tsx
  39. 15 23
      frontend/src/modals/StreamModal.tsx
  40. 12 10
      frontend/src/modals/UserModal.tsx
  41. 1 0
      frontend/src/modals/index.ts
  42. 21 5
      frontend/src/pages/Access/Empty.tsx
  43. 40 38
      frontend/src/pages/Access/Table.tsx
  44. 70 19
      frontend/src/pages/Access/TableWrapper.tsx
  45. 2 2
      frontend/src/pages/AuditLog/Table.tsx
  46. 4 2
      frontend/src/pages/AuditLog/TableWrapper.tsx
  47. 50 22
      frontend/src/pages/Certificates/Empty.tsx
  48. 5 10
      frontend/src/pages/Certificates/Table.tsx
  49. 7 5
      frontend/src/pages/Certificates/TableWrapper.tsx
  50. 18 15
      frontend/src/pages/Dashboard/index.tsx
  51. 7 5
      frontend/src/pages/Login/index.tsx
  52. 11 5
      frontend/src/pages/Nginx/DeadHosts/Empty.tsx
  53. 5 12
      frontend/src/pages/Nginx/DeadHosts/Table.tsx
  54. 7 6
      frontend/src/pages/Nginx/DeadHosts/TableWrapper.tsx
  55. 11 5
      frontend/src/pages/Nginx/ProxyHosts/Empty.tsx
  56. 5 12
      frontend/src/pages/Nginx/ProxyHosts/Table.tsx
  57. 8 5
      frontend/src/pages/Nginx/ProxyHosts/TableWrapper.tsx
  58. 11 5
      frontend/src/pages/Nginx/RedirectionHosts/Empty.tsx
  59. 5 12
      frontend/src/pages/Nginx/RedirectionHosts/Table.tsx
  60. 7 6
      frontend/src/pages/Nginx/RedirectionHosts/TableWrapper.tsx
  61. 11 5
      frontend/src/pages/Nginx/Streams/Empty.tsx
  62. 7 12
      frontend/src/pages/Nginx/Streams/Table.tsx
  63. 7 5
      frontend/src/pages/Nginx/Streams/TableWrapper.tsx
  64. 4 2
      frontend/src/pages/Settings/SettingTable.tsx
  65. 11 7
      frontend/src/pages/Setup/index.tsx
  66. 11 5
      frontend/src/pages/Users/Empty.tsx
  67. 7 14
      frontend/src/pages/Users/Table.tsx
  68. 7 5
      frontend/src/pages/Users/TableWrapper.tsx

+ 2 - 1
frontend/biome.json

@@ -64,7 +64,8 @@
                 "useUniqueElementIds": "off"
             },
             "suspicious": {
-                "noExplicitAny": "off"
+                "noExplicitAny": "off",
+                "noArrayIndexKey": "off"
             },
             "performance": {
                 "noDelete": "off"

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

@@ -53,7 +53,7 @@ export interface AccessList {
 	meta: Record<string, any>;
 	satisfyAny: boolean;
 	passAuth: boolean;
-	proxyHostCount: number;
+	proxyHostCount?: number;
 	// Expansions:
 	owner?: User;
 	items?: AccessListItem[];

+ 8 - 4
frontend/src/components/ErrorNotFound.tsx

@@ -1,6 +1,6 @@
 import { useNavigate } from "react-router-dom";
 import { Button } from "src/components";
-import { intl } from "src/locale";
+import { T } from "src/locale";
 
 export function ErrorNotFound() {
 	const navigate = useNavigate();
@@ -8,11 +8,15 @@ export function ErrorNotFound() {
 	return (
 		<div className="container-tight py-4">
 			<div className="empty">
-				<p className="empty-title">{intl.formatMessage({ id: "notfound.title" })}</p>
-				<p className="empty-subtitle text-secondary">{intl.formatMessage({ id: "notfound.text" })}</p>
+				<p className="empty-title">
+					<T id="notfound.title" />
+				</p>
+				<p className="empty-subtitle text-secondary">
+					<T id="notfound.text" />
+				</p>
 				<div className="empty-action">
 					<Button type="button" size="md" onClick={() => navigate("/")}>
-						{intl.formatMessage({ id: "notfound.action" })}
+						<T id="notfound.action" />
 					</Button>
 				</div>
 			</div>

+ 99 - 0
frontend/src/components/Form/AccessField.tsx

@@ -0,0 +1,99 @@
+import { IconLock, IconLockOpen2 } from "@tabler/icons-react";
+import { Field, useFormikContext } from "formik";
+import type { ReactNode } from "react";
+import Select, { type ActionMeta, components, type OptionProps } from "react-select";
+import type { AccessList } from "src/api/backend";
+import { useAccessLists } from "src/hooks";
+import { DateTimeFormat, intl, T } from "src/locale";
+
+interface AccessOption {
+	readonly value: number;
+	readonly label: string;
+	readonly subLabel: string;
+	readonly icon: ReactNode;
+}
+
+const Option = (props: OptionProps<AccessOption>) => {
+	return (
+		<components.Option {...props}>
+			<div className="flex-fill">
+				<div className="font-weight-medium">
+					{props.data.icon} <strong>{props.data.label}</strong>
+				</div>
+				<div className="text-secondary mt-1 ps-3">{props.data.subLabel}</div>
+			</div>
+		</components.Option>
+	);
+};
+
+interface Props {
+	id?: string;
+	name?: string;
+	label?: string;
+}
+export function AccessField({ name = "accessListId", label = "access.title", id = "accessListId" }: Props) {
+	const { isLoading, isError, error, data } = useAccessLists();
+	const { setFieldValue } = useFormikContext();
+
+	const handleChange = (newValue: any, _actionMeta: ActionMeta<AccessOption>) => {
+		setFieldValue(name, newValue?.value);
+	};
+
+	const options: AccessOption[] =
+		data?.map((item: AccessList) => ({
+			value: item.id || 0,
+			label: item.name,
+			subLabel: intl.formatMessage(
+				{ id: "access.subtitle" },
+				{
+					users: item?.items?.length,
+					rules: item?.clients?.length,
+					date: item?.createdOn ? DateTimeFormat(item?.createdOn) : "N/A",
+				},
+			),
+			icon: <IconLock size={14} className="text-lime" />,
+		})) || [];
+
+	// Public option
+	options?.unshift({
+		value: 0,
+		label: intl.formatMessage({ id: "access.public" }),
+		subLabel: "No basic auth required",
+		icon: <IconLockOpen2 size={14} className="text-red" />,
+	});
+
+	return (
+		<Field name={name}>
+			{({ field, form }: any) => (
+				<div className="mb-3">
+					<label className="form-label" htmlFor={id}>
+						<T id={label} />
+					</label>
+					{isLoading ? <div className="placeholder placeholder-lg col-12 my-3 placeholder-glow" /> : null}
+					{isError ? <div className="invalid-feedback">{`${error}`}</div> : null}
+					{!isLoading && !isError ? (
+						<Select
+							className="react-select-container"
+							classNamePrefix="react-select"
+							defaultValue={options.find((o) => o.value === field.value) || options[0]}
+							options={options}
+							components={{ Option }}
+							styles={{
+								option: (base) => ({
+									...base,
+									height: "100%",
+								}),
+							}}
+							onChange={handleChange}
+						/>
+					) : null}
+					{form.errors[field.name] ? (
+						<div className="invalid-feedback">
+							{form.errors[field.name] && form.touched[field.name] ? form.errors[field.name] : null}
+						</div>
+					) : null}
+				</div>
+			)}
+		</Field>
+	);
+}

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

@@ -0,0 +1,36 @@
+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>
+		</>
+	);
+}

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

@@ -10,7 +10,6 @@ interface DNSProviderOption {
 	readonly label: string;
 	readonly credentials: string;
 }
-
 export function DNSProviderFields() {
 	const { values, setFieldValue } = useFormikContext();
 	const { data: dnsProviders, isLoading } = useDnsProviders();
@@ -100,6 +99,7 @@ export function DNSProviderFields() {
 								<input
 									id="propagationSeconds"
 									type="number"
+									x
 									className="form-control"
 									min={0}
 									max={600}

+ 10 - 9
frontend/src/components/Form/DomainNamesField.tsx

@@ -1,10 +1,11 @@
 import { Field, useFormikContext } from "formik";
+import type { ReactNode } from "react";
 import type { ActionMeta, MultiValue } from "react-select";
 import CreatableSelect from "react-select/creatable";
-import { intl } from "src/locale";
+import { intl, T } from "src/locale";
 import { validateDomain, validateDomains } from "src/modules/Validations";
 
-export type SelectOption = {
+type SelectOption = {
 	label: string;
 	value: string;
 	color?: string;
@@ -35,14 +36,14 @@ export function DomainNamesField({
 		setFieldValue(name, doms);
 	};
 
-	const helperTexts: string[] = [];
+	const helperTexts: ReactNode[] = [];
 	if (maxDomains) {
-		helperTexts.push(intl.formatMessage({ id: "domain-names.max" }, { count: maxDomains }));
+		helperTexts.push(<T id="domain-names.max" data={{ count: maxDomains }} />);
 	}
 	if (!isWildcardPermitted) {
-		helperTexts.push(intl.formatMessage({ id: "domain-names.wildcards-not-permitted" }));
+		helperTexts.push(<T id="domain-names.wildcards-not-permitted" />);
 	} else if (!dnsProviderWildcardSupported) {
-		helperTexts.push(intl.formatMessage({ id: "domain-names.wildcards-not-supported" }));
+		helperTexts.push(<T id="domain-names.wildcards-not-supported" />);
 	}
 
 	return (
@@ -50,7 +51,7 @@ export function DomainNamesField({
 			{({ field, form }: any) => (
 				<div className="mb-3">
 					<label className="form-label" htmlFor={id}>
-						{intl.formatMessage({ id: label })}
+						<T id={label} />
 					</label>
 					<CreatableSelect
 						className="react-select-container"
@@ -68,8 +69,8 @@ export function DomainNamesField({
 					{form.errors[field.name] && form.touched[field.name] ? (
 						<small className="text-danger">{form.errors[field.name]}</small>
 					) : helperTexts.length ? (
-						helperTexts.map((i) => (
-							<small key={i} className="text-info">
+						helperTexts.map((i, idx) => (
+							<small key={idx} className="text-info">
 								{i}
 							</small>
 						))

+ 2 - 2
frontend/src/components/Form/NginxConfigField.tsx

@@ -1,6 +1,6 @@
 import CodeEditor from "@uiw/react-textarea-code-editor";
 import { Field } from "formik";
-import { intl } from "src/locale";
+import { intl, T } from "src/locale";
 
 interface Props {
 	id?: string;
@@ -17,7 +17,7 @@ export function NginxConfigField({
 			{({ field }: any) => (
 				<div className="mt-3">
 					<label htmlFor={id} className="form-label">
-						{intl.formatMessage({ id: label })}
+						<T id={label} />
 					</label>
 					<CodeEditor
 						language="nginx"

+ 2 - 2
frontend/src/components/Form/SSLCertificateField.tsx

@@ -3,7 +3,7 @@ import { Field, useFormikContext } from "formik";
 import Select, { type ActionMeta, components, type OptionProps } from "react-select";
 import type { Certificate } from "src/api/backend";
 import { useCertificates } from "src/hooks";
-import { DateTimeFormat, intl } from "src/locale";
+import { DateTimeFormat, T } from "src/locale";
 
 interface CertOption {
 	readonly value: number | "new";
@@ -106,7 +106,7 @@ export function SSLCertificateField({
 			{({ field, form }: any) => (
 				<div className="mb-3">
 					<label className="form-label" htmlFor={id}>
-						{intl.formatMessage({ id: label })}
+						<T id={label} />
 					</label>
 					{isLoading ? <div className="placeholder placeholder-lg col-12 my-3 placeholder-glow" /> : null}
 					{isError ? <div className="invalid-feedback">{`${error}`}</div> : null}

+ 6 - 6
frontend/src/components/Form/SSLOptionsFields.tsx

@@ -1,7 +1,7 @@
 import cn from "classnames";
 import { Field, useFormikContext } from "formik";
 import { DNSProviderFields, DomainNamesField } from "src/components";
-import { intl } from "src/locale";
+import { T } from "src/locale";
 
 interface Props {
 	forHttp?: boolean; // the sslForced, http2Support, hstsEnabled, hstsSubdomains fields
@@ -49,7 +49,7 @@ export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomain
 									disabled={!hasCertificate}
 								/>
 								<span className="form-check-label">
-									{intl.formatMessage({ id: "domains.force-ssl" })}
+									<T id="domains.force-ssl" />
 								</span>
 							</label>
 						)}
@@ -67,7 +67,7 @@ export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomain
 									disabled={!hasCertificate}
 								/>
 								<span className="form-check-label">
-									{intl.formatMessage({ id: "domains.http2-support" })}
+									<T id="domains.http2-support" />
 								</span>
 							</label>
 						)}
@@ -87,7 +87,7 @@ export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomain
 									disabled={!hasCertificate || !sslForced}
 								/>
 								<span className="form-check-label">
-									{intl.formatMessage({ id: "domains.hsts-enabled" })}
+									<T id="domains.hsts-enabled" />
 								</span>
 							</label>
 						)}
@@ -105,7 +105,7 @@ export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomain
 									disabled={!hasCertificate || !hstsEnabled}
 								/>
 								<span className="form-check-label">
-									{intl.formatMessage({ id: "domains.hsts-subdomains" })}
+									<T id="domains.hsts-subdomains" />
 								</span>
 							</label>
 						)}
@@ -131,7 +131,7 @@ export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomain
 									onChange={(e) => handleToggleChange(e, field.name)}
 								/>
 								<span className="form-check-label">
-									{intl.formatMessage({ id: "domains.use-dns" })}
+									<T id="domains.use-dns" />
 								</span>
 							</label>
 						)}

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

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

+ 6 - 2
frontend/src/components/HasPermission.tsx

@@ -2,7 +2,7 @@ import type { ReactNode } from "react";
 import Alert from "react-bootstrap/Alert";
 import { Loading, LoadingPage } from "src/components";
 import { useUser } from "src/hooks";
-import { intl } from "src/locale";
+import { T } from "src/locale";
 
 interface Props {
 	permission: string;
@@ -64,7 +64,11 @@ function HasPermission({
 		return <>{children}</>;
 	}
 
-	return !hideError ? <Alert variant="danger">{intl.formatMessage({ id: "no-permission-error" })}</Alert> : null;
+	return !hideError ? (
+		<Alert variant="danger">
+			<T id="no-permission-error" />
+		</Alert>
+	) : null;
 }
 
 export { HasPermission };

+ 4 - 3
frontend/src/components/Loading.tsx

@@ -1,8 +1,9 @@
-import { intl } from "src/locale";
+import type { ReactNode } from "react";
+import { T } from "src/locale";
 import styles from "./Loading.module.css";
 
 interface Props {
-	label?: string;
+	label?: string | ReactNode;
 	noLogo?: boolean;
 }
 export function Loading({ label, noLogo }: Props) {
@@ -13,7 +14,7 @@ export function Loading({ label, noLogo }: Props) {
 					<img className={styles.logo} src="/images/logo-no-text.svg" alt="" />
 				</div>
 			)}
-			<div className="text-secondary mb-3">{label || intl.formatMessage({ id: "loading" })}</div>
+			<div className="text-secondary mb-3">{label || <T id="loading" />}</div>
 			<div className="progress progress-sm">
 				<div className="progress-bar progress-bar-indeterminate" />
 			</div>

+ 2 - 23
frontend/src/components/LocalePicker.tsx

@@ -2,7 +2,7 @@ import cn from "classnames";
 import { Flag } from "src/components";
 import { useLocaleState } from "src/context";
 import { useTheme } from "src/hooks";
-import { changeLocale, getFlagCodeForLocale, intl, localeOptions } from "src/locale";
+import { changeLocale, getFlagCodeForLocale, localeOptions, T } from "src/locale";
 import styles from "./LocalePicker.module.css";
 
 function LocalePicker() {
@@ -35,34 +35,13 @@ function LocalePicker() {
 								changeTo(item[0]);
 							}}
 						>
-							<Flag countryCode={getFlagCodeForLocale(item[0])} />{" "}
-							{intl.formatMessage({ id: `locale-${item[1]}` })}
+							<Flag countryCode={getFlagCodeForLocale(item[0])} /> <T id={`locale-${item[1]}`} />
 						</a>
 					);
 				})}
 			</div>
 		</div>
 	);
-
-	// <div className={className}>
-	// 		<Menu>
-	// 			<MenuButton as={Button} {...additionalProps}>
-	// 				<Flag countryCode={getFlagCodeForLocale(locale)} />
-	// 			</MenuButton>
-	// 			<MenuList>
-	// 				{localeOptions.map((item) => {
-	// 					return (
-	// 						<MenuItem
-	// 							icon={<Flag countryCode={getFlagCodeForLocale(item[0])} />}
-	// 							onClick={() => changeTo(item[0])}
-	// 							key={`locale-${item[0]}`}>
-	// 							<span>{intl.formatMessage({ id: `locale-${item[1]}` })}</span>
-	// 						</MenuItem>
-	// 					);
-	// 				})}
-	// 			</MenuList>
-	// 		</Menu>
-	// 	</Box>
 }
 
 export { LocalePicker };

+ 2 - 2
frontend/src/components/SiteFooter.tsx

@@ -1,5 +1,5 @@
 import { useHealth } from "src/hooks";
-import { intl } from "src/locale";
+import { T } from "src/locale";
 
 export function SiteFooter() {
 	const health = useHealth();
@@ -25,7 +25,7 @@ export function SiteFooter() {
 									className="link-secondary"
 									rel="noopener"
 								>
-									{intl.formatMessage({ id: "footer.github-fork" })}
+									<T id="footer.github-fork" />
 								</a>
 							</li>
 						</ul>

+ 5 - 7
frontend/src/components/SiteHeader.tsx

@@ -3,7 +3,7 @@ import { useState } from "react";
 import { LocalePicker, ThemeSwitcher } from "src/components";
 import { useAuthState } from "src/context";
 import { useUser } from "src/hooks";
-import { intl } from "src/locale";
+import { T } from "src/locale";
 import { ChangePasswordModal, UserModal } from "src/modals";
 import styles from "./SiteHeader.module.css";
 
@@ -66,9 +66,7 @@ export function SiteHeader() {
 								<div className="d-none d-xl-block ps-2">
 									<div>{currentUser?.nickname}</div>
 									<div className="mt-1 small text-secondary">
-										{intl.formatMessage({
-											id: isAdmin ? "role.admin" : "role.standard-user",
-										})}
+										<T id={isAdmin ? "role.admin" : "role.standard-user"} />
 									</div>
 								</div>
 							</a>
@@ -82,7 +80,7 @@ export function SiteHeader() {
 									}}
 								>
 									<IconUser width={18} />
-									{intl.formatMessage({ id: "user.edit-profile" })}
+									<T id="user.edit-profile" />
 								</a>
 								<a
 									href="?"
@@ -93,7 +91,7 @@ export function SiteHeader() {
 									}}
 								>
 									<IconLock width={18} />
-									{intl.formatMessage({ id: "user.change-password" })}
+									<T id="user.change-password" />
 								</a>
 								<div className="dropdown-divider" />
 								<a
@@ -105,7 +103,7 @@ export function SiteHeader() {
 									}}
 								>
 									<IconLogout width={18} />
-									{intl.formatMessage({ id: "user.logout" })}
+									<T id="user.logout" />
 								</a>
 							</div>
 						</div>

+ 8 - 4
frontend/src/components/SiteMenu.tsx

@@ -10,7 +10,7 @@ import {
 import cn from "classnames";
 import React from "react";
 import { HasPermission, NavLink } from "src/components";
-import { intl } from "src/locale";
+import { T } from "src/locale";
 
 interface MenuItem {
 	label: string;
@@ -108,7 +108,9 @@ const getMenuItem = (item: MenuItem, onClick?: () => void) => {
 					<span className="nav-link-icon d-md-none d-lg-inline-block">
 						{item.icon && React.createElement(item.icon, { height: 24, width: 24 })}
 					</span>
-					<span className="nav-link-title">{intl.formatMessage({ id: item.label })}</span>
+					<span className="nav-link-title">
+						<T id={item.label} />
+					</span>
 				</NavLink>
 			</li>
 		</HasPermission>
@@ -136,7 +138,9 @@ const getMenuDropown = (item: MenuItem, onClick?: () => void) => {
 					<span className="nav-link-icon d-md-none d-lg-inline-block">
 						<IconDeviceDesktop height={24} width={24} />
 					</span>
-					<span className="nav-link-title">{intl.formatMessage({ id: item.label })}</span>
+					<span className="nav-link-title">
+						<T id={item.label} />
+					</span>
 				</a>
 				<div className="dropdown-menu">
 					{item.items?.map((subitem, idx) => {
@@ -148,7 +152,7 @@ const getMenuDropown = (item: MenuItem, onClick?: () => void) => {
 								hideError
 							>
 								<NavLink to={subitem.to} isDropdownItem onClick={onClick}>
-									{intl.formatMessage({ id: subitem.label })}
+									<T id={subitem.label} />
 								</NavLink>
 							</HasPermission>
 						);

+ 2 - 6
frontend/src/components/Table/Formatter/CertificateFormatter.tsx

@@ -1,13 +1,9 @@
 import type { Certificate } from "src/api/backend";
-import { intl } from "src/locale";
+import { T } from "src/locale";
 
 interface Props {
 	certificate?: Certificate;
 }
 export function CertificateFormatter({ certificate }: Props) {
-	if (certificate) {
-		return intl.formatMessage({ id: "lets-encrypt" });
-	}
-
-	return intl.formatMessage({ id: "http-only" });
+	return <T id={certificate ? "lets-encrypt" : "http-only"} />;
 }

+ 2 - 2
frontend/src/components/Table/Formatter/DomainsFormatter.tsx

@@ -1,4 +1,4 @@
-import { DateTimeFormat, intl } from "src/locale";
+import { DateTimeFormat, T } from "src/locale";
 
 interface Props {
 	domains: string[];
@@ -34,7 +34,7 @@ export function DomainsFormatter({ domains, createdOn }: Props) {
 			</div>
 			{createdOn ? (
 				<div className="text-secondary mt-1">
-					{intl.formatMessage({ id: "created-on" }, { date: DateTimeFormat(createdOn) })}
+					<T id="created-on" data={{ date: DateTimeFormat(createdOn) }} />
 				</div>
 			) : null}
 		</div>

+ 7 - 5
frontend/src/components/Table/Formatter/EnabledFormatter.tsx

@@ -1,11 +1,13 @@
-import { intl } from "src/locale";
+import cn from "classnames";
+import { T } from "src/locale";
 
 interface Props {
 	enabled: boolean;
 }
 export function EnabledFormatter({ enabled }: Props) {
-	if (enabled) {
-		return <span className="badge bg-lime-lt">{intl.formatMessage({ id: "enabled" })}</span>;
-	}
-	return <span className="badge bg-red-lt">{intl.formatMessage({ id: "disabled" })}</span>;
+	return (
+		<span className={cn("badge", enabled ? "bg-lime-lt" : "bg-red-lt")}>
+			<T id={enabled ? "enabled" : "disabled"} />
+		</span>
+	);
 }

+ 4 - 6
frontend/src/components/Table/Formatter/EventFormatter.tsx

@@ -1,10 +1,6 @@
 import { IconArrowsCross, IconBolt, IconBoltOff, IconDisc, IconUser } from "@tabler/icons-react";
 import type { AuditLog } from "src/api/backend";
-import { DateTimeFormat, intl } from "src/locale";
-
-const getEventTitle = (event: AuditLog) => (
-	<span>{intl.formatMessage({ id: `event.${event.action}-${event.objectType}` })}</span>
-);
+import { DateTimeFormat, T } from "src/locale";
 
 const getEventValue = (event: AuditLog) => {
 	switch (event.objectType) {
@@ -63,7 +59,9 @@ export function EventFormatter({ row }: Props) {
 	return (
 		<div className="flex-fill">
 			<div className="font-weight-medium">
-				{getIcon(row)} {getEventTitle(row)} &mdash; <span className="badge">{getEventValue(row)}</span>
+				{getIcon(row)}
+				<T id={`event.${row.action}-${row.objectType}`} />
+				&mdash; <span className="badge">{getEventValue(row)}</span>
 			</div>
 			<div className="text-secondary mt-1">{DateTimeFormat(row.createdOn)}</div>
 		</div>

+ 2 - 2
frontend/src/components/Table/Formatter/RolesFormatter.tsx

@@ -1,4 +1,4 @@
-import { intl } from "src/locale";
+import { T } from "src/locale";
 
 interface Props {
 	roles: string[];
@@ -12,7 +12,7 @@ export function RolesFormatter({ roles }: Props) {
 		<>
 			{r.map((role: string) => (
 				<span key={role} className="badge bg-yellow-lt me-1">
-					{intl.formatMessage({ id: `role.${role}` })}
+					<T id={`role.${role}`} />
 				</span>
 			))}
 		</>

+ 7 - 5
frontend/src/components/Table/Formatter/StatusFormatter.tsx

@@ -1,11 +1,13 @@
-import { intl } from "src/locale";
+import cn from "classnames";
+import { T } from "src/locale";
 
 interface Props {
 	enabled: boolean;
 }
 export function StatusFormatter({ enabled }: Props) {
-	if (enabled) {
-		return <span className="badge bg-lime-lt">{intl.formatMessage({ id: "online" })}</span>;
-	}
-	return <span className="badge bg-red-lt">{intl.formatMessage({ id: "offline" })}</span>;
+	return (
+		<span className={cn("badge", enabled ? "bg-lime-lt" : "bg-red-lt")}>
+			<T id={enabled ? "online" : "offline"} />
+		</span>
+	);
 }

+ 2 - 4
frontend/src/components/Table/Formatter/ValueWithDateFormatter.tsx

@@ -1,4 +1,4 @@
-import { DateTimeFormat, intl } from "src/locale";
+import { DateTimeFormat, T } from "src/locale";
 
 interface Props {
 	value: string;
@@ -13,9 +13,7 @@ export function ValueWithDateFormatter({ value, createdOn, disabled }: Props) {
 			</div>
 			{createdOn ? (
 				<div className={`text-secondary mt-1 ${disabled ? "text-red" : ""}`}>
-					{disabled
-						? intl.formatMessage({ id: "disabled" })
-						: intl.formatMessage({ id: "created-on" }, { date: DateTimeFormat(createdOn) })}
+					<T id={disabled ? "disabled" : "created-on"} data={{ date: DateTimeFormat(createdOn) }} />
 				</div>
 			) : null}
 		</div>

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

@@ -1,3 +1,4 @@
+export * from "./useAccessList";
 export * from "./useAccessLists";
 export * from "./useAuditLog";
 export * from "./useAuditLogs";

+ 53 - 0
frontend/src/hooks/useAccessList.ts

@@ -0,0 +1,53 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { type AccessList, createAccessList, getAccessList, updateAccessList } from "src/api/backend";
+
+const fetchAccessList = (id: number | "new") => {
+	if (id === "new") {
+		return Promise.resolve({
+			id: 0,
+			createdOn: "",
+			modifiedOn: "",
+			ownerUserId: 0,
+			name: "",
+			satisfyAny: false,
+			passAuth: false,
+			meta: {},
+		} as AccessList);
+	}
+	return getAccessList(id, ["owner"]);
+};
+
+const useAccessList = (id: number | "new", options = {}) => {
+	return useQuery<AccessList, Error>({
+		queryKey: ["access-list", id],
+		queryFn: () => fetchAccessList(id),
+		staleTime: 60 * 1000, // 1 minute
+		...options,
+	});
+};
+
+const useSetAccessList = () => {
+	const queryClient = useQueryClient();
+	return useMutation({
+		mutationFn: (values: AccessList) => (values.id ? updateAccessList(values) : createAccessList(values)),
+		onMutate: (values: AccessList) => {
+			if (!values.id) {
+				return;
+			}
+			const previousObject = queryClient.getQueryData(["access-list", values.id]);
+			queryClient.setQueryData(["access-list", values.id], (old: AccessList) => ({
+				...old,
+				...values,
+			}));
+			return () => queryClient.setQueryData(["access-list", values.id], previousObject);
+		},
+		onError: (_, __, rollback: any) => rollback(),
+		onSuccess: async ({ id }: AccessList) => {
+			queryClient.invalidateQueries({ queryKey: ["access-list", id] });
+			queryClient.invalidateQueries({ queryKey: ["access-list"] });
+			queryClient.invalidateQueries({ queryKey: ["audit-logs"] });
+		},
+	});
+};
+
+export { useAccessList, useSetAccessList };

+ 7 - 1
frontend/src/locale/IntlProvider.tsx

@@ -61,4 +61,10 @@ const changeLocale = (locale: string): void => {
 	document.documentElement.lang = locale;
 };
 
-export { localeOptions, getFlagCodeForLocale, getLocale, createIntl, changeLocale, intl };
+// This is a translation component that wraps the translation in a span with a data
+// attribute so devs can inspect the element to see the translation ID
+const T = ({ id, data }: { id: string; data?: any }) => {
+	return <span data-translation-id={id}>{intl.formatMessage({ id }, data)}</span>;
+};
+
+export { localeOptions, getFlagCodeForLocale, getLocale, createIntl, changeLocale, intl, T };

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

@@ -3,9 +3,13 @@
   "access.actions-title": "Access List #{id}",
   "access.add": "Add Access List",
   "access.auth-count": "{count} Users",
+  "access.edit": "Edit Access",
   "access.empty": "There are no Access Lists",
-  "access.satisfy-all": "All",
-  "access.satisfy-any": "Any",
+  "access.new": "New Access",
+  "access.pass-auth": "Pass Auth to Upstream",
+  "access.public": "Publicly Accessible",
+  "access.satisfy-any": "Satisfy Any",
+  "access.subtitle": "{users} User, {rules} Rules - Created: {date}",
   "access.title": "Access",
   "action.delete": "Delete",
   "action.disable": "Disable",
@@ -23,6 +27,7 @@
   "close": "Close",
   "column.access": "Access",
   "column.authorization": "Authorization",
+  "column.authorizations": "Authorizations",
   "column.custom-locations": "Custom Locations",
   "column.destination": "Destination",
   "column.details": "Details",
@@ -35,7 +40,10 @@
   "column.protocol": "Protocol",
   "column.provider": "Provider",
   "column.roles": "Roles",
+  "column.rules": "Rules",
   "column.satisfy": "Satisfy",
+  "column.satisfy-all": "All",
+  "column.satisfy-any": "Any",
   "column.scheme": "Scheme",
   "column.source": "Source",
   "column.ssl": "SSL",
@@ -88,11 +96,11 @@
   "event.updated-redirection-host": "Updated Redirection Host",
   "event.updated-user": "Updated User",
   "footer.github-fork": "Fork me on Github",
+  "generic.flags.title": "Options",
   "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",
@@ -107,6 +115,7 @@
   "notfound.action": "Take me home",
   "notfound.text": "We are sorry but the page you are looking for was not found",
   "notfound.title": "Oops… You just found an error page",
+  "notification.access-saved": "Access has been saved",
   "notification.dead-host-saved": "404 Host has been saved",
   "notification.error": "Error",
   "notification.host-deleted": "Host has been deleted",
@@ -140,6 +149,8 @@
   "proxy-hosts.count": "{count} Proxy Hosts",
   "proxy-hosts.empty": "There are no Proxy Hosts",
   "proxy-hosts.title": "Proxy Hosts",
+  "redirection-host.delete.content": "Are you sure you want to delete this Redirection host?",
+  "redirection-host.delete.title": "Delete Redirection Host",
   "redirection-host.forward-domain": "Forward Domain",
   "redirection-host.new": "New Redirection Host",
   "redirection-hosts.actions-title": "Redirection Host #{id}",
@@ -157,6 +168,7 @@
   "ssl-certificate": "SSL Certificate",
   "stream.delete.content": "Are you sure you want to delete this Stream?",
   "stream.delete.title": "Delete Stream",
+  "stream.edit": "Edit Stream",
   "stream.forward-host": "Forward Host",
   "stream.incoming-port": "Incoming Port",
   "stream.new": "New Stream",
@@ -184,6 +196,7 @@
   "user.set-permissions": "Set Permissions for {name}",
   "user.switch-dark": "Switch to Dark mode",
   "user.switch-light": "Switch to Light mode",
+  "username": "Username",
   "users.actions-title": "User #{id}",
   "users.add": "Add User",
   "users.empty": "There are no Users",

+ 45 - 6
frontend/src/locale/src/en.json

@@ -11,14 +11,26 @@
 	"access.auth-count": {
 		"defaultMessage": "{count} Users"
 	},
+	"access.edit": {
+		"defaultMessage": "Edit Access"
+	},
 	"access.empty": {
 		"defaultMessage": "There are no Access Lists"
 	},
-	"access.satisfy-all": {
-		"defaultMessage": "All"
+	"access.new": {
+		"defaultMessage": "New Access"
+	},
+	"access.pass-auth": {
+		"defaultMessage": "Pass Auth to Upstream"
+	},
+	"access.public": {
+		"defaultMessage": "Publicly Accessible"
 	},
 	"access.satisfy-any": {
-		"defaultMessage": "Any"
+		"defaultMessage": "Satisfy Any"
+	},
+	"access.subtitle": {
+		"defaultMessage": "{users} User, {rules} Rules - Created: {date}"
 	},
 	"access.title": {
 		"defaultMessage": "Access"
@@ -71,6 +83,9 @@
 	"column.authorization": {
 		"defaultMessage": "Authorization"
 	},
+	"column.authorizations": {
+		"defaultMessage": "Authorizations"
+	},
 	"column.custom-locations": {
 		"defaultMessage": "Custom Locations"
 	},
@@ -107,9 +122,18 @@
 	"column.roles": {
 		"defaultMessage": "Roles"
 	},
+	"column.rules": {
+		"defaultMessage": "Rules"
+	},
 	"column.satisfy": {
 		"defaultMessage": "Satisfy"
 	},
+	"column.satisfy-all": {
+		"defaultMessage": "All"
+	},
+	"column.satisfy-any": {
+		"defaultMessage": "Any"
+	},
 	"column.scheme": {
 		"defaultMessage": "Scheme"
 	},
@@ -266,6 +290,9 @@
 	"footer.github-fork": {
 		"defaultMessage": "Fork me on Github"
 	},
+	"generic.flags.title": {
+		"defaultMessage": "Options"
+	},
 	"host.flags.block-exploits": {
 		"defaultMessage": "Block Common Exploits"
 	},
@@ -278,9 +305,6 @@
 	"host.flags.protocols": {
 		"defaultMessage": "Protocols"
 	},
-	"host.flags.title": {
-		"defaultMessage": "Options"
-	},
 	"host.flags.websockets-upgrade": {
 		"defaultMessage": "Websockets Support"
 	},
@@ -323,6 +347,9 @@
 	"notfound.title": {
 		"defaultMessage": "Oops… You just found an error page"
 	},
+	"notification.access-saved": {
+		"defaultMessage": "Access has been saved"
+	},
 	"notification.dead-host-saved": {
 		"defaultMessage": "404 Host has been saved"
 	},
@@ -422,6 +449,12 @@
 	"proxy-hosts.title": {
 		"defaultMessage": "Proxy Hosts"
 	},
+	"redirection-host.delete.content": {
+		"defaultMessage": "Are you sure you want to delete this Redirection host?"
+	},
+	"redirection-host.delete.title": {
+		"defaultMessage": "Delete Redirection Host"
+	},
 	"redirection-host.forward-domain": {
 		"defaultMessage": "Forward Domain"
 	},
@@ -473,6 +506,9 @@
 	"stream.delete.title": {
 		"defaultMessage": "Delete Stream"
 	},
+	"stream.edit": {
+		"defaultMessage": "Edit Stream"
+	},
 	"stream.forward-host": {
 		"defaultMessage": "Forward Host"
 	},
@@ -554,6 +590,9 @@
 	"user.switch-light": {
 		"defaultMessage": "Switch to Light mode"
 	},
+	"username": {
+		"defaultMessage": "Username"
+	},
 	"users.actions-title": {
 		"defaultMessage": "User #{id}"
 	},

+ 243 - 0
frontend/src/modals/AccessListModal.tsx

@@ -0,0 +1,243 @@
+import cn from "classnames";
+import { Field, Form, Formik } from "formik";
+import { type ReactNode, useState } from "react";
+import { Alert } from "react-bootstrap";
+import Modal from "react-bootstrap/Modal";
+import { BasicAuthField, Button, Loading } from "src/components";
+import { useAccessList, useSetAccessList } from "src/hooks";
+import { intl, T } from "src/locale";
+import { validateString } from "src/modules/Validations";
+import { showSuccess } from "src/notifications";
+
+interface Props {
+	id: number | "new";
+	onClose: () => void;
+}
+export function AccessListModal({ id, onClose }: Props) {
+	const { data, isLoading, error } = useAccessList(id);
+	const { mutate: setAccessList } = useSetAccessList();
+	const [errorMsg, setErrorMsg] = useState<ReactNode | 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,
+		};
+
+		setAccessList(payload, {
+			onError: (err: any) => setErrorMsg(<T id={err.message} />),
+			onSuccess: () => {
+				showSuccess(intl.formatMessage({ id: "notification.access-saved" }));
+				onClose();
+			},
+			onSettled: () => {
+				setIsSubmitting(false);
+				setSubmitting(false);
+			},
+		});
+	};
+
+	const toggleClasses = "form-check-input";
+	const toggleEnabled = cn(toggleClasses, "bg-cyan");
+
+	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={
+						{
+							name: data?.name,
+							satisfyAny: data?.satisfyAny,
+							passAuth: data?.passAuth,
+							// todo: more? there's stuff missing here?
+							meta: data?.meta || {},
+						} as any
+					}
+					onSubmit={onSubmit}
+				>
+					{({ setFieldValue }: any) => (
+						<Form>
+							<Modal.Header closeButton>
+								<Modal.Title>
+									<T id={data?.id ? "access.edit" : "access.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"
+												>
+													<T id="column.details" />
+												</a>
+											</li>
+											<li className="nav-item" role="presentation">
+												<a
+													href="#tab-auth"
+													className="nav-link"
+													data-bs-toggle="tab"
+													aria-selected="false"
+													tabIndex={-1}
+													role="tab"
+												>
+													<T id="column.authorizations" />
+												</a>
+											</li>
+											<li className="nav-item" role="presentation">
+												<a
+													href="#tab-access"
+													className="nav-link"
+													data-bs-toggle="tab"
+													aria-selected="false"
+													tabIndex={-1}
+													role="tab"
+												>
+													<T id="column.rules" />
+												</a>
+											</li>
+										</ul>
+									</div>
+									<div className="card-body">
+										<div className="tab-content">
+											<div className="tab-pane active show" id="tab-details" role="tabpanel">
+												<Field name="name" validate={validateString(8, 255)}>
+													{({ field }: any) => (
+														<div>
+															<label htmlFor="name" className="form-label">
+																<T id="column.name" />
+															</label>
+															<input
+																id="name"
+																type="text"
+																required
+																autoComplete="off"
+																className="form-control"
+																{...field}
+															/>
+														</div>
+													)}
+												</Field>
+												<div className="my-3">
+													<h3 className="py-2">
+														<T id="generic.flags.title" />
+													</h3>
+													<div className="divide-y">
+														<div>
+															<label className="row" htmlFor="satisfyAny">
+																<span className="col">
+																	<T id="access.satisfy-any" />
+																</span>
+																<span className="col-auto">
+																	<Field name="satisfyAny" type="checkbox">
+																		{({ field }: any) => (
+																			<label className="form-check form-check-single form-switch">
+																				<input
+																					id="satisfyAny"
+																					className={
+																						field.value
+																							? toggleEnabled
+																							: toggleClasses
+																					}
+																					type="checkbox"
+																					name={field.name}
+																					checked={field.value}
+																					onChange={(e: any) => {
+																						setFieldValue(
+																							field.name,
+																							e.target.checked,
+																						);
+																					}}
+																				/>
+																			</label>
+																		)}
+																	</Field>
+																</span>
+															</label>
+														</div>
+														<div>
+															<label className="row" htmlFor="passAuth">
+																<span className="col">
+																	<T id="access.pass-auth" />
+																</span>
+																<span className="col-auto">
+																	<Field name="passAuth" type="checkbox">
+																		{({ field }: any) => (
+																			<label className="form-check form-check-single form-switch">
+																				<input
+																					id="passAuth"
+																					className={
+																						field.value
+																							? toggleEnabled
+																							: toggleClasses
+																					}
+																					type="checkbox"
+																					name={field.name}
+																					checked={field.value}
+																					onChange={(e: any) => {
+																						setFieldValue(
+																							field.name,
+																							e.target.checked,
+																						);
+																					}}
+																				/>
+																			</label>
+																		)}
+																	</Field>
+																</span>
+															</label>
+														</div>
+													</div>
+												</div>
+											</div>
+											<div className="tab-pane" id="tab-auth" role="tabpanel">
+												<BasicAuthField />
+											</div>
+											<div className="tab-pane" id="tab-rules" role="tabpanel">
+												todo
+											</div>
+										</div>
+									</div>
+								</div>
+							</Modal.Body>
+							<Modal.Footer>
+								<Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}>
+									<T id="cancel" />
+								</Button>
+								<Button
+									type="submit"
+									actionType="primary"
+									className="ms-auto bg-cyan"
+									data-bs-dismiss="modal"
+									isLoading={isSubmitting}
+									disabled={isSubmitting}
+								>
+									<T id="save" />
+								</Button>
+							</Modal.Footer>
+						</Form>
+					)}
+				</Formik>
+			)}
+		</Modal>
+	);
+}

+ 13 - 11
frontend/src/modals/ChangePasswordModal.tsx

@@ -1,10 +1,10 @@
 import { Field, Form, Formik } from "formik";
-import { useState } from "react";
+import { type ReactNode, useState } from "react";
 import { Alert } from "react-bootstrap";
 import Modal from "react-bootstrap/Modal";
 import { updateAuth } from "src/api/backend";
 import { Button } from "src/components";
-import { intl } from "src/locale";
+import { intl, T } from "src/locale";
 import { validateString } from "src/modules/Validations";
 
 interface Props {
@@ -12,12 +12,12 @@ interface Props {
 	onClose: () => void;
 }
 export function ChangePasswordModal({ userId, onClose }: Props) {
-	const [error, setError] = useState<string | null>(null);
+	const [error, setError] = useState<ReactNode | null>(null);
 	const [isSubmitting, setIsSubmitting] = useState(false);
 
 	const onSubmit = async (values: any, { setSubmitting }: any) => {
 		if (values.new !== values.confirm) {
-			setError(intl.formatMessage({ id: "error.passwords-must-match" }));
+			setError(<T id="error.passwords-must-match" />);
 			setSubmitting(false);
 			return;
 		}
@@ -30,7 +30,7 @@ export function ChangePasswordModal({ userId, onClose }: Props) {
 			await updateAuth(userId, values.new, values.current);
 			onClose();
 		} catch (err: any) {
-			setError(intl.formatMessage({ id: err.message }));
+			setError(<T id={err.message} />);
 		}
 		setIsSubmitting(false);
 		setSubmitting(false);
@@ -51,7 +51,9 @@ export function ChangePasswordModal({ userId, onClose }: Props) {
 				{() => (
 					<Form>
 						<Modal.Header closeButton>
-							<Modal.Title>{intl.formatMessage({ id: "user.change-password" })}</Modal.Title>
+							<Modal.Title>
+								<T id="user.change-password" />
+							</Modal.Title>
 						</Modal.Header>
 						<Modal.Body>
 							<Alert variant="danger" show={!!error} onClose={() => setError(null)} dismissible>
@@ -72,7 +74,7 @@ export function ChangePasswordModal({ userId, onClose }: Props) {
 												{...field}
 											/>
 											<label htmlFor="current">
-												{intl.formatMessage({ id: "user.current-password" })}
+												<T id="user.current-password" />
 											</label>
 											{form.errors.name ? (
 												<div className="invalid-feedback">
@@ -98,7 +100,7 @@ export function ChangePasswordModal({ userId, onClose }: Props) {
 												{...field}
 											/>
 											<label htmlFor="new">
-												{intl.formatMessage({ id: "user.new-password" })}
+												<T id="user.new-password" />
 											</label>
 											{form.errors.new ? (
 												<div className="invalid-feedback">
@@ -129,7 +131,7 @@ export function ChangePasswordModal({ userId, onClose }: Props) {
 												</div>
 											) : null}
 											<label htmlFor="confirm">
-												{intl.formatMessage({ id: "user.confirm-password" })}
+												<T id="user.confirm-password" />
 											</label>
 										</div>
 									)}
@@ -138,7 +140,7 @@ export function ChangePasswordModal({ userId, onClose }: Props) {
 						</Modal.Body>
 						<Modal.Footer>
 							<Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}>
-								{intl.formatMessage({ id: "cancel" })}
+								<T id="cancel" />
 							</Button>
 							<Button
 								type="submit"
@@ -148,7 +150,7 @@ export function ChangePasswordModal({ userId, onClose }: Props) {
 								isLoading={isSubmitting}
 								disabled={isSubmitting}
 							>
-								{intl.formatMessage({ id: "save" })}
+								<T id="save" />
 							</Button>
 						</Modal.Footer>
 					</Form>

+ 9 - 10
frontend/src/modals/DeadHostModal.tsx

@@ -1,6 +1,6 @@
 import { IconSettings } from "@tabler/icons-react";
 import { Form, Formik } from "formik";
-import { useState } from "react";
+import { type ReactNode, useState } from "react";
 import { Alert } from "react-bootstrap";
 import Modal from "react-bootstrap/Modal";
 import {
@@ -12,7 +12,7 @@ import {
 	SSLOptionsFields,
 } from "src/components";
 import { useDeadHost, useSetDeadHost } from "src/hooks";
-import { intl } from "src/locale";
+import { intl, T } from "src/locale";
 import { showSuccess } from "src/notifications";
 
 interface Props {
@@ -22,7 +22,7 @@ interface Props {
 export function DeadHostModal({ id, onClose }: Props) {
 	const { data, isLoading, error } = useDeadHost(id);
 	const { mutate: setDeadHost } = useSetDeadHost();
-	const [errorMsg, setErrorMsg] = useState<string | null>(null);
+	const [errorMsg, setErrorMsg] = useState<ReactNode | null>(null);
 	const [isSubmitting, setIsSubmitting] = useState(false);
 
 	const onSubmit = async (values: any, { setSubmitting }: any) => {
@@ -36,7 +36,7 @@ export function DeadHostModal({ id, onClose }: Props) {
 		};
 
 		setDeadHost(payload, {
-			onError: (err: any) => setErrorMsg(err.message),
+			onError: (err: any) => setErrorMsg(<T id={err.message} />),
 			onSuccess: () => {
 				showSuccess(intl.formatMessage({ id: "notification.dead-host-saved" }));
 				onClose();
@@ -76,14 +76,13 @@ export function DeadHostModal({ id, onClose }: Props) {
 						<Form>
 							<Modal.Header closeButton>
 								<Modal.Title>
-									{intl.formatMessage({ id: data?.id ? "dead-host.edit" : "dead-host.new" })}
+									<T id={data?.id ? "dead-host.edit" : "dead-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">
@@ -95,7 +94,7 @@ export function DeadHostModal({ id, onClose }: Props) {
 													aria-selected="true"
 													role="tab"
 												>
-													{intl.formatMessage({ id: "column.details" })}
+													<T id="column.details" />
 												</a>
 											</li>
 											<li className="nav-item" role="presentation">
@@ -107,7 +106,7 @@ export function DeadHostModal({ id, onClose }: Props) {
 													tabIndex={-1}
 													role="tab"
 												>
-													{intl.formatMessage({ id: "column.ssl" })}
+													<T id="column.ssl" />
 												</a>
 											</li>
 											<li className="nav-item ms-auto" role="presentation">
@@ -147,7 +146,7 @@ export function DeadHostModal({ id, onClose }: Props) {
 							</Modal.Body>
 							<Modal.Footer>
 								<Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}>
-									{intl.formatMessage({ id: "cancel" })}
+									<T id="cancel" />
 								</Button>
 								<Button
 									type="submit"
@@ -157,7 +156,7 @@ export function DeadHostModal({ id, onClose }: Props) {
 									isLoading={isSubmitting}
 									disabled={isSubmitting}
 								>
-									{intl.formatMessage({ id: "save" })}
+									<T id="save" />
 								</Button>
 							</Modal.Footer>
 						</Form>

+ 8 - 6
frontend/src/modals/DeleteConfirmModal.tsx

@@ -3,7 +3,7 @@ import { type ReactNode, useState } from "react";
 import { Alert } from "react-bootstrap";
 import Modal from "react-bootstrap/Modal";
 import { Button } from "src/components";
-import { intl } from "src/locale";
+import { T } from "src/locale";
 
 interface Props {
 	title: string;
@@ -14,7 +14,7 @@ interface Props {
 }
 export function DeleteConfirmModal({ title, children, onConfirm, onClose, invalidations }: Props) {
 	const queryClient = useQueryClient();
-	const [error, setError] = useState<string | null>(null);
+	const [error, setError] = useState<ReactNode | null>(null);
 	const [isSubmitting, setIsSubmitting] = useState(false);
 
 	const onSubmit = async () => {
@@ -29,7 +29,7 @@ export function DeleteConfirmModal({ title, children, onConfirm, onClose, invali
 				queryClient.invalidateQueries({ queryKey: inv });
 			});
 		} catch (err: any) {
-			setError(intl.formatMessage({ id: err.message }));
+			setError(<T id={err.message} />);
 		}
 		setIsSubmitting(false);
 	};
@@ -37,7 +37,9 @@ export function DeleteConfirmModal({ title, children, onConfirm, onClose, invali
 	return (
 		<Modal show onHide={onClose} animation={false}>
 			<Modal.Header closeButton>
-				<Modal.Title>{title}</Modal.Title>
+				<Modal.Title>
+					<T id={title} />
+				</Modal.Title>
 			</Modal.Header>
 			<Modal.Body>
 				<Alert variant="danger" show={!!error} onClose={() => setError(null)} dismissible>
@@ -47,7 +49,7 @@ export function DeleteConfirmModal({ title, children, onConfirm, onClose, invali
 			</Modal.Body>
 			<Modal.Footer>
 				<Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}>
-					{intl.formatMessage({ id: "cancel" })}
+					<T id="cancel" />
 				</Button>
 				<Button
 					type="submit"
@@ -58,7 +60,7 @@ export function DeleteConfirmModal({ title, children, onConfirm, onClose, invali
 					disabled={isSubmitting}
 					onClick={onSubmit}
 				>
-					{intl.formatMessage({ id: "action.delete" })}
+					<T id="action.delete" />
 				</Button>
 			</Modal.Footer>
 		</Modal>

+ 5 - 3
frontend/src/modals/EventDetailsModal.tsx

@@ -2,7 +2,7 @@ import { Alert } from "react-bootstrap";
 import Modal from "react-bootstrap/Modal";
 import { Button, EventFormatter, GravatarFormatter, Loading } from "src/components";
 import { useAuditLog } from "src/hooks";
-import { intl } from "src/locale";
+import { T } from "src/locale";
 
 interface Props {
 	id: number;
@@ -22,7 +22,9 @@ export function EventDetailsModal({ id, onClose }: Props) {
 			{!isLoading && data && (
 				<>
 					<Modal.Header closeButton>
-						<Modal.Title>{intl.formatMessage({ id: "action.view-details" })}</Modal.Title>
+						<Modal.Title>
+							<T id="action.view-details" />
+						</Modal.Title>
 					</Modal.Header>
 					<Modal.Body>
 						<div className="row">
@@ -40,7 +42,7 @@ export function EventDetailsModal({ id, onClose }: Props) {
 					</Modal.Body>
 					<Modal.Footer>
 						<Button data-bs-dismiss="modal" onClick={onClose}>
-							{intl.formatMessage({ id: "close" })}
+							<T id="close" />
 						</Button>
 					</Modal.Footer>
 				</>

+ 19 - 19
frontend/src/modals/PermissionsModal.tsx

@@ -1,13 +1,13 @@
 import { useQueryClient } from "@tanstack/react-query";
 import cn from "classnames";
 import { Field, Form, Formik } from "formik";
-import { useState } from "react";
+import { type ReactNode, useState } from "react";
 import { Alert } from "react-bootstrap";
 import Modal from "react-bootstrap/Modal";
 import { setPermissions } from "src/api/backend";
 import { Button, Loading } from "src/components";
 import { useUser } from "src/hooks";
-import { intl } from "src/locale";
+import { T } from "src/locale";
 
 interface Props {
 	userId: number;
@@ -15,7 +15,7 @@ interface Props {
 }
 export function PermissionsModal({ userId, onClose }: Props) {
 	const queryClient = useQueryClient();
-	const [errorMsg, setErrorMsg] = useState<string | null>(null);
+	const [errorMsg, setErrorMsg] = useState<ReactNode | null>(null);
 	const { data, isLoading, error } = useUser(userId);
 	const [isSubmitting, setIsSubmitting] = useState(false);
 
@@ -29,7 +29,7 @@ export function PermissionsModal({ userId, onClose }: Props) {
 			queryClient.invalidateQueries({ queryKey: ["users"] });
 			queryClient.invalidateQueries({ queryKey: ["user"] });
 		} catch (err: any) {
-			setErrorMsg(intl.formatMessage({ id: err.message }));
+			setErrorMsg(<T id={err.message} />);
 		}
 		setSubmitting(false);
 		setIsSubmitting(false);
@@ -50,7 +50,7 @@ export function PermissionsModal({ userId, onClose }: Props) {
 						onChange={() => form.setFieldValue(field.name, "manage")}
 					/>
 					<label htmlFor={`${field.name}-manage`} className={cn("btn", { active: field.value === "manage" })}>
-						{intl.formatMessage({ id: "permissions.manage" })}
+						<T id="permissions.manage" />
 					</label>
 					<input
 						type="radio"
@@ -63,7 +63,7 @@ export function PermissionsModal({ userId, onClose }: Props) {
 						onChange={() => form.setFieldValue(field.name, "view")}
 					/>
 					<label htmlFor={`${field.name}-view`} className={cn("btn", { active: field.value === "view" })}>
-						{intl.formatMessage({ id: "permissions.view" })}
+						<T id="permissions.view" />
 					</label>
 					<input
 						type="radio"
@@ -76,7 +76,7 @@ export function PermissionsModal({ userId, onClose }: Props) {
 						onChange={() => form.setFieldValue(field.name, "hidden")}
 					/>
 					<label htmlFor={`${field.name}-hidden`} className={cn("btn", { active: field.value === "hidden" })}>
-						{intl.formatMessage({ id: "permissions.hidden" })}
+						<T id="permissions.hidden" />
 					</label>
 				</div>
 			</div>
@@ -112,7 +112,7 @@ export function PermissionsModal({ userId, onClose }: Props) {
 						<Form>
 							<Modal.Header closeButton>
 								<Modal.Title>
-									{intl.formatMessage({ id: "user.set-permissions" }, { name: data?.name })}
+									<T id="user.set-permissions" data={{ name: data?.name }} />
 								</Modal.Title>
 							</Modal.Header>
 							<Modal.Body>
@@ -121,7 +121,7 @@ export function PermissionsModal({ userId, onClose }: Props) {
 								</Alert>
 								<div className="mb-3">
 									<label htmlFor="asd" className="form-label">
-										{intl.formatMessage({ id: "permissions.visibility.title" })}
+										<T id="permissions.visibility.title" />
 									</label>
 									<Field name="visibility">
 										{({ field, form }: any) => (
@@ -140,7 +140,7 @@ export function PermissionsModal({ userId, onClose }: Props) {
 													htmlFor={`${field.name}-user`}
 													className={cn("btn", { active: field.value === "user" })}
 												>
-													{intl.formatMessage({ id: "permissions.visibility.user" })}
+													<T id="permissions.visibility.user" />
 												</label>
 												<input
 													type="radio"
@@ -156,7 +156,7 @@ export function PermissionsModal({ userId, onClose }: Props) {
 													htmlFor={`${field.name}-all`}
 													className={cn("btn", { active: field.value === "all" })}
 												>
-													{intl.formatMessage({ id: "permissions.visibility.all" })}
+													<T id="permissions.visibility.all" />
 												</label>
 											</div>
 										)}
@@ -166,7 +166,7 @@ export function PermissionsModal({ userId, onClose }: Props) {
 									<>
 										<div className="mb-3">
 											<label htmlFor="ignored" className="form-label">
-												{intl.formatMessage({ id: "proxy-hosts.title" })}
+												<T id="proxy-hosts.title" />
 											</label>
 											<Field name="proxyHosts">
 												{({ field, form }: any) => getPermissionButtons(field, form)}
@@ -174,7 +174,7 @@ export function PermissionsModal({ userId, onClose }: Props) {
 										</div>
 										<div className="mb-3">
 											<label htmlFor="ignored" className="form-label">
-												{intl.formatMessage({ id: "redirection-hosts.title" })}
+												<T id="redirection-hosts.title" />
 											</label>
 											<Field name="redirectionHosts">
 												{({ field, form }: any) => getPermissionButtons(field, form)}
@@ -182,7 +182,7 @@ export function PermissionsModal({ userId, onClose }: Props) {
 										</div>
 										<div className="mb-3">
 											<label htmlFor="ignored" className="form-label">
-												{intl.formatMessage({ id: "dead-hosts.title" })}
+												<T id="dead-hosts.title" />
 											</label>
 											<Field name="deadHosts">
 												{({ field, form }: any) => getPermissionButtons(field, form)}
@@ -190,7 +190,7 @@ export function PermissionsModal({ userId, onClose }: Props) {
 										</div>
 										<div className="mb-3">
 											<label htmlFor="ignored" className="form-label">
-												{intl.formatMessage({ id: "streams.title" })}
+												<T id="streams.title" />
 											</label>
 											<Field name="streams">
 												{({ field, form }: any) => getPermissionButtons(field, form)}
@@ -198,7 +198,7 @@ export function PermissionsModal({ userId, onClose }: Props) {
 										</div>
 										<div className="mb-3">
 											<label htmlFor="ignored" className="form-label">
-												{intl.formatMessage({ id: "access.title" })}
+												<T id="access.title" />
 											</label>
 											<Field name="accessLists">
 												{({ field, form }: any) => getPermissionButtons(field, form)}
@@ -206,7 +206,7 @@ export function PermissionsModal({ userId, onClose }: Props) {
 										</div>
 										<div className="mb-3">
 											<label htmlFor="ignored" className="form-label">
-												{intl.formatMessage({ id: "certificates.title" })}
+												<T id="certificates.title" />
 											</label>
 											<Field name="certificates">
 												{({ field, form }: any) => getPermissionButtons(field, form)}
@@ -217,7 +217,7 @@ export function PermissionsModal({ userId, onClose }: Props) {
 							</Modal.Body>
 							<Modal.Footer>
 								<Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}>
-									{intl.formatMessage({ id: "cancel" })}
+									<T id="cancel" />
 								</Button>
 								<Button
 									type="submit"
@@ -227,7 +227,7 @@ export function PermissionsModal({ userId, onClose }: Props) {
 									isLoading={isSubmitting}
 									disabled={isSubmitting}
 								>
-									{intl.formatMessage({ id: "save" })}
+									<T id="save" />
 								</Button>
 							</Modal.Footer>
 						</Form>

+ 20 - 33
frontend/src/modals/ProxyHostModal.tsx

@@ -1,10 +1,11 @@
 import { IconSettings } from "@tabler/icons-react";
 import cn from "classnames";
 import { Field, Form, Formik } from "formik";
-import { useState } from "react";
+import { type ReactNode, useState } from "react";
 import { Alert } from "react-bootstrap";
 import Modal from "react-bootstrap/Modal";
 import {
+	AccessField,
 	Button,
 	DomainNamesField,
 	Loading,
@@ -13,7 +14,7 @@ import {
 	SSLOptionsFields,
 } from "src/components";
 import { useProxyHost, useSetProxyHost } from "src/hooks";
-import { intl } from "src/locale";
+import { intl, T } from "src/locale";
 import { validateNumber, validateString } from "src/modules/Validations";
 import { showSuccess } from "src/notifications";
 
@@ -24,7 +25,7 @@ interface Props {
 export function ProxyHostModal({ id, onClose }: Props) {
 	const { data, isLoading, error } = useProxyHost(id);
 	const { mutate: setProxyHost } = useSetProxyHost();
-	const [errorMsg, setErrorMsg] = useState<string | null>(null);
+	const [errorMsg, setErrorMsg] = useState<ReactNode | null>(null);
 	const [isSubmitting, setIsSubmitting] = useState(false);
 
 	const onSubmit = async (values: any, { setSubmitting }: any) => {
@@ -38,7 +39,7 @@ export function ProxyHostModal({ id, onClose }: Props) {
 		};
 
 		setProxyHost(payload, {
-			onError: (err: any) => setErrorMsg(err.message),
+			onError: (err: any) => setErrorMsg(<T id={err.message} />),
 			onSuccess: () => {
 				showSuccess(intl.formatMessage({ id: "notification.proxy-host-saved" }));
 				onClose();
@@ -90,16 +91,13 @@ export function ProxyHostModal({ id, onClose }: Props) {
 						<Form>
 							<Modal.Header closeButton>
 								<Modal.Title>
-									{intl.formatMessage({
-										id: data?.id ? "proxy-host.edit" : "proxy-host.new",
-									})}
+									<T 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">
@@ -111,7 +109,7 @@ export function ProxyHostModal({ id, onClose }: Props) {
 													aria-selected="true"
 													role="tab"
 												>
-													{intl.formatMessage({ id: "column.details" })}
+													<T id="column.details" />
 												</a>
 											</li>
 											<li className="nav-item" role="presentation">
@@ -123,7 +121,7 @@ export function ProxyHostModal({ id, onClose }: Props) {
 													tabIndex={-1}
 													role="tab"
 												>
-													{intl.formatMessage({ id: "column.custom-locations" })}
+													<T id="column.custom-locations" />
 												</a>
 											</li>
 											<li className="nav-item" role="presentation">
@@ -135,7 +133,7 @@ export function ProxyHostModal({ id, onClose }: Props) {
 													tabIndex={-1}
 													role="tab"
 												>
-													{intl.formatMessage({ id: "column.ssl" })}
+													<T id="column.ssl" />
 												</a>
 											</li>
 											<li className="nav-item ms-auto" role="presentation">
@@ -166,9 +164,7 @@ export function ProxyHostModal({ id, onClose }: Props) {
 																		className="form-label"
 																		htmlFor="forwardScheme"
 																	>
-																		{intl.formatMessage({
-																			id: "host.forward-scheme",
-																		})}
+																		<T id="host.forward-scheme" />
 																	</label>
 																	<select
 																		id="forwardScheme"
@@ -196,9 +192,7 @@ export function ProxyHostModal({ id, onClose }: Props) {
 															{({ field, form }: any) => (
 																<div className="mb-3">
 																	<label className="form-label" htmlFor="forwardHost">
-																		{intl.formatMessage({
-																			id: "proxy-host.forward-host",
-																		})}
+																		<T id="proxy-host.forward-host" />
 																	</label>
 																	<input
 																		id="forwardHost"
@@ -225,9 +219,7 @@ export function ProxyHostModal({ id, onClose }: Props) {
 															{({ field, form }: any) => (
 																<div className="mb-3">
 																	<label className="form-label" htmlFor="forwardPort">
-																		{intl.formatMessage({
-																			id: "host.forward-port",
-																		})}
+																		<T id="host.forward-port" />
 																	</label>
 																	<input
 																		id="forwardPort"
@@ -252,17 +244,16 @@ export function ProxyHostModal({ id, onClose }: Props) {
 														</Field>
 													</div>
 												</div>
+												<AccessField />
 												<div className="my-3">
 													<h4 className="py-2">
-														{intl.formatMessage({ id: "host.flags.title" })}
+														<T id="generic.flags.title" />
 													</h4>
 													<div className="divide-y">
 														<div>
 															<label className="row" htmlFor="cachingEnabled">
 																<span className="col">
-																	{intl.formatMessage({
-																		id: "host.flags.cache-assets",
-																	})}
+																	<T id="host.flags.cache-assets" />
 																</span>
 																<span className="col-auto">
 																	<Field name="cachingEnabled" type="checkbox">
@@ -285,9 +276,7 @@ export function ProxyHostModal({ id, onClose }: Props) {
 														<div>
 															<label className="row" htmlFor="blockExploits">
 																<span className="col">
-																	{intl.formatMessage({
-																		id: "host.flags.block-exploits",
-																	})}
+																	<T id="host.flags.block-exploits" />
 																</span>
 																<span className="col-auto">
 																	<Field name="blockExploits" type="checkbox">
@@ -310,9 +299,7 @@ export function ProxyHostModal({ id, onClose }: Props) {
 														<div>
 															<label className="row" htmlFor="allowWebsocketUpgrade">
 																<span className="col">
-																	{intl.formatMessage({
-																		id: "host.flags.websockets-upgrade",
-																	})}
+																	<T id="host.flags.websockets-upgrade" />
 																</span>
 																<span className="col-auto">
 																	<Field name="allowWebsocketUpgrade" type="checkbox">
@@ -336,7 +323,7 @@ export function ProxyHostModal({ id, onClose }: Props) {
 												</div>
 											</div>
 											<div className="tab-pane" id="tab-locations" role="tabpanel">
-												locations
+												locations TODO
 											</div>
 											<div className="tab-pane" id="tab-ssl" role="tabpanel">
 												<SSLCertificateField
@@ -355,7 +342,7 @@ export function ProxyHostModal({ id, onClose }: Props) {
 							</Modal.Body>
 							<Modal.Footer>
 								<Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}>
-									{intl.formatMessage({ id: "cancel" })}
+									<T id="cancel" />
 								</Button>
 								<Button
 									type="submit"
@@ -365,7 +352,7 @@ export function ProxyHostModal({ id, onClose }: Props) {
 									isLoading={isSubmitting}
 									disabled={isSubmitting}
 								>
-									{intl.formatMessage({ id: "save" })}
+									<T id="save" />
 								</Button>
 							</Modal.Footer>
 						</Form>

+ 14 - 25
frontend/src/modals/RedirectionHostModal.tsx

@@ -1,7 +1,7 @@
 import { IconSettings } from "@tabler/icons-react";
 import cn from "classnames";
 import { Field, Form, Formik } from "formik";
-import { useState } from "react";
+import { type ReactNode, useState } from "react";
 import { Alert } from "react-bootstrap";
 import Modal from "react-bootstrap/Modal";
 import {
@@ -13,7 +13,7 @@ import {
 	SSLOptionsFields,
 } from "src/components";
 import { useRedirectionHost, useSetRedirectionHost } from "src/hooks";
-import { intl } from "src/locale";
+import { intl, T } from "src/locale";
 import { validateString } from "src/modules/Validations";
 import { showSuccess } from "src/notifications";
 
@@ -24,7 +24,7 @@ interface Props {
 export function RedirectionHostModal({ id, onClose }: Props) {
 	const { data, isLoading, error } = useRedirectionHost(id);
 	const { mutate: setRedirectionHost } = useSetRedirectionHost();
-	const [errorMsg, setErrorMsg] = useState<string | null>(null);
+	const [errorMsg, setErrorMsg] = useState<ReactNode | null>(null);
 	const [isSubmitting, setIsSubmitting] = useState(false);
 
 	const onSubmit = async (values: any, { setSubmitting }: any) => {
@@ -38,7 +38,7 @@ export function RedirectionHostModal({ id, onClose }: Props) {
 		};
 
 		setRedirectionHost(payload, {
-			onError: (err: any) => setErrorMsg(err.message),
+			onError: (err: any) => setErrorMsg(<T id={err.message} />),
 			onSuccess: () => {
 				showSuccess(intl.formatMessage({ id: "notification.redirection-host-saved" }));
 				onClose();
@@ -86,16 +86,13 @@ export function RedirectionHostModal({ id, onClose }: Props) {
 						<Form>
 							<Modal.Header closeButton>
 								<Modal.Title>
-									{intl.formatMessage({
-										id: data?.id ? "redirection-host.edit" : "redirection-host.new",
-									})}
+									<T id={data?.id ? "redirection-host.edit" : "redirection-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">
@@ -107,7 +104,7 @@ export function RedirectionHostModal({ id, onClose }: Props) {
 													aria-selected="true"
 													role="tab"
 												>
-													{intl.formatMessage({ id: "column.details" })}
+													<T id="column.details" />
 												</a>
 											</li>
 											<li className="nav-item" role="presentation">
@@ -119,7 +116,7 @@ export function RedirectionHostModal({ id, onClose }: Props) {
 													tabIndex={-1}
 													role="tab"
 												>
-													{intl.formatMessage({ id: "column.ssl" })}
+													<T id="column.ssl" />
 												</a>
 											</li>
 											<li className="nav-item ms-auto" role="presentation">
@@ -150,9 +147,7 @@ export function RedirectionHostModal({ id, onClose }: Props) {
 																		className="form-label"
 																		htmlFor="forwardScheme"
 																	>
-																		{intl.formatMessage({
-																			id: "host.forward-scheme",
-																		})}
+																		<T id="host.forward-scheme" />
 																	</label>
 																	<select
 																		id="forwardScheme"
@@ -187,9 +182,7 @@ export function RedirectionHostModal({ id, onClose }: Props) {
 																		className="form-label"
 																		htmlFor="forwardDomainName"
 																	>
-																		{intl.formatMessage({
-																			id: "redirection-host.forward-domain",
-																		})}
+																		<T id="redirection-host.forward-domain" />
 																	</label>
 																	<input
 																		id="forwardDomainName"
@@ -214,15 +207,13 @@ export function RedirectionHostModal({ id, onClose }: Props) {
 												</div>
 												<div className="my-3">
 													<h4 className="py-2">
-														{intl.formatMessage({ id: "host.flags.title" })}
+														<T id="generic.flags.title" />
 													</h4>
 													<div className="divide-y">
 														<div>
 															<label className="row" htmlFor="preservePath">
 																<span className="col">
-																	{intl.formatMessage({
-																		id: "host.flags.preserve-path",
-																	})}
+																	<T id="host.flags.preserve-path" />
 																</span>
 																<span className="col-auto">
 																	<Field name="preservePath" type="checkbox">
@@ -245,9 +236,7 @@ export function RedirectionHostModal({ id, onClose }: Props) {
 														<div>
 															<label className="row" htmlFor="blockExploits">
 																<span className="col">
-																	{intl.formatMessage({
-																		id: "host.flags.block-exploits",
-																	})}
+																	<T id="host.flags.block-exploits" />
 																</span>
 																<span className="col-auto">
 																	<Field name="blockExploits" type="checkbox">
@@ -287,7 +276,7 @@ export function RedirectionHostModal({ id, onClose }: Props) {
 							</Modal.Body>
 							<Modal.Footer>
 								<Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}>
-									{intl.formatMessage({ id: "cancel" })}
+									<T id="cancel" />
 								</Button>
 								<Button
 									type="submit"
@@ -297,7 +286,7 @@ export function RedirectionHostModal({ id, onClose }: Props) {
 									isLoading={isSubmitting}
 									disabled={isSubmitting}
 								>
-									{intl.formatMessage({ id: "save" })}
+									<T id="save" />
 								</Button>
 							</Modal.Footer>
 						</Form>

+ 14 - 17
frontend/src/modals/SetPasswordModal.tsx

@@ -1,11 +1,11 @@
 import { Field, Form, Formik } from "formik";
 import { generate } from "generate-password-browser";
-import { useState } from "react";
+import { type ReactNode, useState } from "react";
 import { Alert } from "react-bootstrap";
 import Modal from "react-bootstrap/Modal";
 import { updateAuth } from "src/api/backend";
 import { Button } from "src/components";
-import { intl } from "src/locale";
+import { intl, T } from "src/locale";
 import { validateString } from "src/modules/Validations";
 
 interface Props {
@@ -13,18 +13,18 @@ interface Props {
 	onClose: () => void;
 }
 export function SetPasswordModal({ userId, onClose }: Props) {
-	const [error, setError] = useState<string | null>(null);
+	const [error, setError] = useState<ReactNode | null>(null);
 	const [showPassword, setShowPassword] = useState(false);
 	const [isSubmitting, setIsSubmitting] = useState(false);
 
-	const onSubmit = async (values: any, { setSubmitting }: any) => {
+	const _onSubmit = async (values: any, { setSubmitting }: any) => {
 		if (isSubmitting) return;
 		setError(null);
 		try {
 			await updateAuth(userId, values.new);
 			onClose();
 		} catch (err: any) {
-			setError(intl.formatMessage({ id: err.message }));
+			setError(<T id={err.message} />);
 		}
 		setIsSubmitting(false);
 		setSubmitting(false);
@@ -38,12 +38,14 @@ export function SetPasswordModal({ userId, onClose }: Props) {
 						new: "",
 					} as any
 				}
-				onSubmit={onSubmit}
+				onSubmit={_onSubmit}
 			>
 				{() => (
 					<Form>
 						<Modal.Header closeButton>
-							<Modal.Title>{intl.formatMessage({ id: "user.set-password" })}</Modal.Title>
+							<Modal.Title>
+								<T id="user.set-password" />
+							</Modal.Title>
 						</Modal.Header>
 						<Modal.Body>
 							<Alert variant="danger" show={!!error} onClose={() => setError(null)} dismissible>
@@ -69,9 +71,7 @@ export function SetPasswordModal({ userId, onClose }: Props) {
 															setShowPassword(true);
 														}}
 													>
-														{intl.formatMessage({
-															id: "password.generate",
-														})}
+														<T id="password.generate" />
 													</a>{" "}
 													&mdash;{" "}
 													<a
@@ -82,9 +82,7 @@ export function SetPasswordModal({ userId, onClose }: Props) {
 															setShowPassword(!showPassword);
 														}}
 													>
-														{intl.formatMessage({
-															id: showPassword ? "password.hide" : "password.show",
-														})}
+														<T id={showPassword ? "password.hide" : "password.show"} />
 													</a>
 												</small>
 											</p>
@@ -98,9 +96,8 @@ export function SetPasswordModal({ userId, onClose }: Props) {
 													{...field}
 												/>
 												<label htmlFor="new">
-													{intl.formatMessage({ id: "user.new-password" })}
+													<T id="user.new-password" />
 												</label>
-
 												{form.errors.new ? (
 													<div className="invalid-feedback">
 														{form.errors.new && form.touched.new ? form.errors.new : null}
@@ -114,7 +111,7 @@ export function SetPasswordModal({ userId, onClose }: Props) {
 						</Modal.Body>
 						<Modal.Footer>
 							<Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}>
-								{intl.formatMessage({ id: "cancel" })}
+								<T id="cancel" />
 							</Button>
 							<Button
 								type="submit"
@@ -124,7 +121,7 @@ export function SetPasswordModal({ userId, onClose }: Props) {
 								isLoading={isSubmitting}
 								disabled={isSubmitting}
 							>
-								{intl.formatMessage({ id: "save" })}
+								<T id="save" />
 							</Button>
 						</Modal.Footer>
 					</Form>

+ 15 - 23
frontend/src/modals/StreamModal.tsx

@@ -1,10 +1,10 @@
 import { Field, Form, Formik } from "formik";
-import { useState } from "react";
+import { type ReactNode, useState } from "react";
 import { Alert } from "react-bootstrap";
 import Modal from "react-bootstrap/Modal";
 import { Button, Loading, SSLCertificateField, SSLOptionsFields } from "src/components";
 import { useSetStream, useStream } from "src/hooks";
-import { intl } from "src/locale";
+import { intl, T } from "src/locale";
 import { validateNumber, validateString } from "src/modules/Validations";
 import { showSuccess } from "src/notifications";
 
@@ -15,7 +15,7 @@ interface Props {
 export function StreamModal({ id, onClose }: Props) {
 	const { data, isLoading, error } = useStream(id);
 	const { mutate: setStream } = useSetStream();
-	const [errorMsg, setErrorMsg] = useState<string | null>(null);
+	const [errorMsg, setErrorMsg] = useState<ReactNode | null>(null);
 	const [isSubmitting, setIsSubmitting] = useState(false);
 
 	const onSubmit = async (values: any, { setSubmitting }: any) => {
@@ -29,7 +29,7 @@ export function StreamModal({ id, onClose }: Props) {
 		};
 
 		setStream(payload, {
-			onError: (err: any) => setErrorMsg(err.message),
+			onError: (err: any) => setErrorMsg(<T id={err.message} />),
 			onSuccess: () => {
 				showSuccess(intl.formatMessage({ id: "notification.stream-saved" }));
 				onClose();
@@ -68,7 +68,7 @@ export function StreamModal({ id, onClose }: Props) {
 						<Form>
 							<Modal.Header closeButton>
 								<Modal.Title>
-									{intl.formatMessage({ id: data?.id ? "stream.edit" : "stream.new" })}
+									<T id={data?.id ? "stream.edit" : "stream.new"} />
 								</Modal.Title>
 							</Modal.Header>
 							<Modal.Body className="p-0">
@@ -87,7 +87,7 @@ export function StreamModal({ id, onClose }: Props) {
 													aria-selected="true"
 													role="tab"
 												>
-													{intl.formatMessage({ id: "column.details" })}
+													<T id="column.details" />
 												</a>
 											</li>
 											<li className="nav-item" role="presentation">
@@ -99,7 +99,7 @@ export function StreamModal({ id, onClose }: Props) {
 													tabIndex={-1}
 													role="tab"
 												>
-													{intl.formatMessage({ id: "column.ssl" })}
+													<T id="column.ssl" />
 												</a>
 											</li>
 										</ul>
@@ -111,7 +111,7 @@ export function StreamModal({ id, onClose }: Props) {
 													{({ field, form }: any) => (
 														<div className="mb-3">
 															<label className="form-label" htmlFor="incomingPort">
-																{intl.formatMessage({ id: "stream.incoming-port" })}
+																<T id="stream.incoming-port" />
 															</label>
 															<input
 																id="incomingPort"
@@ -143,9 +143,7 @@ export function StreamModal({ id, onClose }: Props) {
 																		className="form-label"
 																		htmlFor="forwardingHost"
 																	>
-																		{intl.formatMessage({
-																			id: "stream.forward-host",
-																		})}
+																		<T id="stream.forward-host" />
 																	</label>
 																	<input
 																		id="forwardingHost"
@@ -178,9 +176,7 @@ export function StreamModal({ id, onClose }: Props) {
 																		className="form-label"
 																		htmlFor="forwardingPort"
 																	>
-																		{intl.formatMessage({
-																			id: "host.forward-port",
-																		})}
+																		<T id="stream.forward-port" />
 																	</label>
 																	<input
 																		id="forwardingPort"
@@ -207,15 +203,13 @@ export function StreamModal({ id, onClose }: Props) {
 												</div>
 												<div className="my-3">
 													<h3 className="py-2">
-														{intl.formatMessage({ id: "host.flags.protocols" })}
+														<T id="host.flags.protocols" />
 													</h3>
 													<div className="divide-y">
 														<div>
 															<label className="row" htmlFor="tcpForwarding">
 																<span className="col">
-																	{intl.formatMessage({
-																		id: "streams.tcp",
-																	})}
+																	<T id="streams.tcp" />
 																</span>
 																<span className="col-auto">
 																	<Field name="tcpForwarding" type="checkbox">
@@ -249,9 +243,7 @@ export function StreamModal({ id, onClose }: Props) {
 														<div>
 															<label className="row" htmlFor="udpForwarding">
 																<span className="col">
-																	{intl.formatMessage({
-																		id: "streams.udp",
-																	})}
+																	<T id="streams.udp" />
 																</span>
 																<span className="col-auto">
 																	<Field name="udpForwarding" type="checkbox">
@@ -305,7 +297,7 @@ export function StreamModal({ id, onClose }: Props) {
 							</Modal.Body>
 							<Modal.Footer>
 								<Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}>
-									{intl.formatMessage({ id: "cancel" })}
+									<T id="cancel" />
 								</Button>
 								<Button
 									type="submit"
@@ -315,7 +307,7 @@ export function StreamModal({ id, onClose }: Props) {
 									isLoading={isSubmitting}
 									disabled={isSubmitting}
 								>
-									{intl.formatMessage({ id: "save" })}
+									<T id="save" />
 								</Button>
 							</Modal.Footer>
 						</Form>

+ 12 - 10
frontend/src/modals/UserModal.tsx

@@ -4,7 +4,7 @@ import { Alert } from "react-bootstrap";
 import Modal from "react-bootstrap/Modal";
 import { Button, Loading } from "src/components";
 import { useSetUser, useUser } from "src/hooks";
-import { intl } from "src/locale";
+import { intl, T } from "src/locale";
 import { validateEmail, validateString } from "src/modules/Validations";
 import { showSuccess } from "src/notifications";
 
@@ -79,7 +79,7 @@ export function UserModal({ userId, onClose }: Props) {
 						<Form>
 							<Modal.Header closeButton>
 								<Modal.Title>
-									{intl.formatMessage({ id: data?.id ? "user.edit" : "user.new" })}
+									<T id={data?.id ? "user.edit" : "user.new"} />
 								</Modal.Title>
 							</Modal.Header>
 							<Modal.Body>
@@ -99,7 +99,7 @@ export function UserModal({ userId, onClose }: Props) {
 															{...field}
 														/>
 														<label htmlFor="name">
-															{intl.formatMessage({ id: "user.full-name" })}
+															<T id="user.full-name" />
 														</label>
 														{form.errors.name ? (
 															<div className="invalid-feedback">
@@ -125,7 +125,7 @@ export function UserModal({ userId, onClose }: Props) {
 															{...field}
 														/>
 														<label htmlFor="nickname">
-															{intl.formatMessage({ id: "user.nickname" })}
+															<T id="user.nickname" />
 														</label>
 														{form.errors.nickname ? (
 															<div className="invalid-feedback">
@@ -152,7 +152,7 @@ export function UserModal({ userId, onClose }: Props) {
 													{...field}
 												/>
 												<label htmlFor="email">
-													{intl.formatMessage({ id: "email-address" })}
+													<T id="email-address" />
 												</label>
 												{form.errors.email ? (
 													<div className="invalid-feedback">
@@ -167,12 +167,14 @@ export function UserModal({ userId, onClose }: Props) {
 								</div>
 								{currentUser && data && currentUser?.id !== data?.id ? (
 									<div className="my-3">
-										<h4 className="py-2">{intl.formatMessage({ id: "user.flags.title" })}</h4>
+										<h4 className="py-2">
+											<T id="user.flags.title" />
+										</h4>
 										<div className="divide-y">
 											<div>
 												<label className="row" htmlFor="isAdmin">
 													<span className="col">
-														{intl.formatMessage({ id: "role.admin" })}
+														<T id="role.admin" />
 													</span>
 													<span className="col-auto">
 														<Field name="isAdmin" type="checkbox">
@@ -193,7 +195,7 @@ export function UserModal({ userId, onClose }: Props) {
 											<div>
 												<label className="row" htmlFor="isDisabled">
 													<span className="col">
-														{intl.formatMessage({ id: "disabled" })}
+														<T id="disabled" />
 													</span>
 													<span className="col-auto">
 														<Field name="isDisabled" type="checkbox">
@@ -217,7 +219,7 @@ export function UserModal({ userId, onClose }: Props) {
 							</Modal.Body>
 							<Modal.Footer>
 								<Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}>
-									{intl.formatMessage({ id: "cancel" })}
+									<T id="cancel" />
 								</Button>
 								<Button
 									type="submit"
@@ -227,7 +229,7 @@ export function UserModal({ userId, onClose }: Props) {
 									isLoading={isSubmitting}
 									disabled={isSubmitting}
 								>
-									{intl.formatMessage({ id: "save" })}
+									<T id="save" />
 								</Button>
 							</Modal.Footer>
 						</Form>

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

@@ -1,3 +1,4 @@
+export * from "./AccessListModal";
 export * from "./ChangePasswordModal";
 export * from "./DeadHostModal";
 export * from "./DeleteConfirmModal";

+ 21 - 5
frontend/src/pages/Access/Empty.tsx

@@ -1,18 +1,34 @@
 import type { Table as ReactTable } from "@tanstack/react-table";
 import { Button } from "src/components";
-import { intl } from "src/locale";
+import { T } from "src/locale";
 
 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: "access.empty" })}</h2>
-					<p className="text-muted">{intl.formatMessage({ id: "empty-subtitle" })}</p>
-					<Button className="btn-cyan my-3">{intl.formatMessage({ id: "access.add" })}</Button>
+					{isFiltered ? (
+						<h2>
+							<T id="empty.search" />
+						</h2>
+					) : (
+						<>
+							<h2>
+								<T id="access.empty" />
+							</h2>
+							<p className="text-muted">
+								<T id="empty-subtitle" />
+							</p>
+							<Button className="btn-cyan my-3" onClick={onNew}>
+								<T id="access.add" />
+							</Button>
+						</>
+					)}
 				</div>
 			</td>
 		</tr>

+ 40 - 38
frontend/src/pages/Access/Table.tsx

@@ -4,23 +4,24 @@ import { useMemo } from "react";
 import type { AccessList } from "src/api/backend";
 import { GravatarFormatter, ValueWithDateFormatter } from "src/components";
 import { TableLayout } from "src/components/Table/TableLayout";
-import { intl } from "src/locale";
+import { intl, T } from "src/locale";
 import Empty from "./Empty";
 
 interface Props {
 	data: AccessList[];
+	isFiltered?: boolean;
 	isFetching?: boolean;
+	onEdit?: (id: number) => void;
+	onDelete?: (id: number) => void;
+	onNew?: () => void;
 }
-export default function Table({ data, isFetching }: Props) {
+export default function Table({ data, isFetching, isFiltered, onEdit, onDelete, onNew }: Props) {
 	const columnHelper = createColumnHelper<AccessList>();
 	const columns = useMemo(
 		() => [
 			columnHelper.accessor((row: any) => row.owner, {
 				id: "owner",
-				cell: (info: any) => {
-					const value = info.getValue();
-					return <GravatarFormatter url={value.avatar} name={value.name} />;
-				},
+				cell: (info: any) => <GravatarFormatter url={info.getValue().avatar} name={info.getValue().name} />,
 				meta: {
 					className: "w-1",
 				},
@@ -28,42 +29,29 @@ export default function Table({ data, isFetching }: Props) {
 			columnHelper.accessor((row: any) => row, {
 				id: "name",
 				header: intl.formatMessage({ id: "column.name" }),
-				cell: (info: any) => {
-					const value = info.getValue();
-					// Bit of a hack to reuse the DomainsFormatter component
-					return <ValueWithDateFormatter value={value.name} createdOn={value.createdOn} />;
-				},
+				cell: (info: any) => (
+					<ValueWithDateFormatter value={info.getValue().name} createdOn={info.getValue().createdOn} />
+				),
 			}),
 			columnHelper.accessor((row: any) => row.items, {
 				id: "items",
 				header: intl.formatMessage({ id: "column.authorization" }),
-				cell: (info: any) => {
-					const value = info.getValue();
-					return intl.formatMessage({ id: "access.auth-count" }, { count: value.length });
-				},
+				cell: (info: any) => <T id="access.auth-count" data={{ count: info.getValue().length }} />,
 			}),
 			columnHelper.accessor((row: any) => row.clients, {
 				id: "clients",
 				header: intl.formatMessage({ id: "column.access" }),
-				cell: (info: any) => {
-					const value = info.getValue();
-					return intl.formatMessage({ id: "access.access-count" }, { count: value.length });
-				},
+				cell: (info: any) => <T id="access.access-count" data={{ count: info.getValue().length }} />,
 			}),
 			columnHelper.accessor((row: any) => row.satisfyAny, {
 				id: "satisfyAny",
 				header: intl.formatMessage({ id: "column.satisfy" }),
-				cell: (info: any) => {
-					const t = info.getValue() ? "access.satisfy-any" : "access.satisfy-all";
-					return intl.formatMessage({ id: t });
-				},
+				cell: (info: any) => <T id={info.getValue() ? "column.satisfy-any" : "column.satisfy-all"} />,
 			}),
 			columnHelper.accessor((row: any) => row.proxyHostCount, {
 				id: "proxyHostCount",
 				header: intl.formatMessage({ id: "proxy-hosts.title" }),
-				cell: (info: any) => {
-					return intl.formatMessage({ id: "proxy-hosts.count" }, { count: info.getValue() });
-				},
+				cell: (info: any) => <T id="proxy-hosts.count" data={{ count: info.getValue() }} />,
 			}),
 			columnHelper.display({
 				id: "id", // todo: not needed for a display?
@@ -80,21 +68,30 @@ export default function Table({ data, isFetching }: Props) {
 							</button>
 							<div className="dropdown-menu dropdown-menu-end">
 								<span className="dropdown-header">
-									{intl.formatMessage(
-										{
-											id: "access.actions-title",
-										},
-										{ id: info.row.original.id },
-									)}
+									<T id="access.actions-title" data={{ 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" })}
+									<T id="action.edit" />
 								</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" })}
+									<T id="action.delete" />
 								</a>
 							</div>
 						</span>
@@ -105,7 +102,7 @@ export default function Table({ data, isFetching }: Props) {
 				},
 			}),
 		],
-		[columnHelper],
+		[columnHelper, onEdit, onDelete],
 	);
 
 	const tableInstance = useReactTable<AccessList>({
@@ -119,5 +116,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} />}
+		/>
+	);
 }

+ 70 - 19
frontend/src/pages/Access/TableWrapper.tsx

@@ -1,11 +1,18 @@
 import { IconSearch } from "@tabler/icons-react";
+import { useState } from "react";
 import Alert from "react-bootstrap/Alert";
+import { deleteAccessList } from "src/api/backend";
 import { Button, LoadingPage } from "src/components";
 import { useAccessLists } from "src/hooks";
-import { intl } from "src/locale";
+import { intl, T } from "src/locale";
+import { AccessListModal, DeleteConfirmModal } from "src/modals";
+import { showSuccess } from "src/notifications";
 import Table from "./Table";
 
 export default function TableWrapper() {
+	const [search, setSearch] = useState("");
+	const [editId, setEditId] = useState(0 as number | "new");
+	const [deleteId, setDeleteId] = useState(0);
 	const { isFetching, isLoading, isError, error, data } = useAccessLists(["owner", "items", "clients"]);
 
 	if (isLoading) {
@@ -16,6 +23,27 @@ export default function TableWrapper() {
 		return <Alert variant="danger">{error?.message || "Unknown error"}</Alert>;
 	}
 
+	const handleDelete = async () => {
+		await deleteAccessList(deleteId);
+		showSuccess(intl.formatMessage({ id: "notification.access-deleted" }));
+	};
+
+	let filtered = null;
+	if (search && data) {
+		filtered = data?.filter((_item) => {
+			return true;
+			// TODO
+			// return (
+			// 	`${item.incomingPort}`.includes(search) ||
+			// 	`${item.forwardingPort}`.includes(search) ||
+			// 	item.forwardingHost.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-cyan" />
@@ -23,29 +51,52 @@ export default function TableWrapper() {
 				<div className="card-header">
 					<div className="row w-full">
 						<div className="col">
-							<h2 className="mt-1 mb-0">{intl.formatMessage({ id: "access.title" })}</h2>
+							<h2 className="mt-1 mb-0">
+								<T id="access.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"
+											onChange={(e: any) => setSearch(e.target.value.toLowerCase().trim())}
+										/>
+									</div>
+									<Button size="sm" className="btn-cyan" onClick={() => setEditId("new")}>
+										<T id="access.add" />
+									</Button>
 								</div>
-								<Button size="sm" className="btn-cyan">
-									{intl.formatMessage({ id: "access.add" })}
-								</Button>
 							</div>
-						</div>
+						) : null}
 					</div>
 				</div>
-				<Table data={data ?? []} isFetching={isFetching} />
+				<Table
+					data={filtered ?? data ?? []}
+					isFetching={isFetching}
+					isFiltered={!!filtered}
+					onEdit={(id: number) => setEditId(id)}
+					onDelete={(id: number) => setDeleteId(id)}
+					onNew={() => setEditId("new")}
+				/>
+				{editId ? <AccessListModal id={editId} onClose={() => setEditId(0)} /> : null}
+				{deleteId ? (
+					<DeleteConfirmModal
+						title="access.delete.title"
+						onConfirm={handleDelete}
+						onClose={() => setDeleteId(0)}
+						invalidations={[["access-lists"], ["access-list", deleteId]]}
+					>
+						<T id="access.delete.content" />
+					</DeleteConfirmModal>
+				) : null}
 			</div>
 		</div>
 	);

+ 2 - 2
frontend/src/pages/AuditLog/Table.tsx

@@ -3,7 +3,7 @@ import { useMemo } from "react";
 import type { AuditLog } from "src/api/backend";
 import { EventFormatter, GravatarFormatter } from "src/components";
 import { TableLayout } from "src/components/Table/TableLayout";
-import { intl } from "src/locale";
+import { intl, T } from "src/locale";
 
 interface Props {
 	data: AuditLog[];
@@ -47,7 +47,7 @@ export default function Table({ data, isFetching, onSelectItem }: Props) {
 								onSelectItem?.(info.row.original.id);
 							}}
 						>
-							{intl.formatMessage({ id: "action.view-details" })}
+							<T id="action.view-details" />
 						</button>
 					);
 				},

+ 4 - 2
frontend/src/pages/AuditLog/TableWrapper.tsx

@@ -2,7 +2,7 @@ import { useState } from "react";
 import Alert from "react-bootstrap/Alert";
 import { LoadingPage } from "src/components";
 import { useAuditLogs } from "src/hooks";
-import { intl } from "src/locale";
+import { T } from "src/locale";
 import { EventDetailsModal } from "src/modals";
 import Table from "./Table";
 
@@ -25,7 +25,9 @@ export default function TableWrapper() {
 				<div className="card-header">
 					<div className="row w-full">
 						<div className="col">
-							<h2 className="mt-1 mb-0">{intl.formatMessage({ id: "auditlog.title" })}</h2>
+							<h2 className="mt-1 mb-0">
+								<T id="auditlog.title" />
+							</h2>
 						</div>
 					</div>
 				</div>

+ 50 - 22
frontend/src/pages/Certificates/Empty.tsx

@@ -1,34 +1,62 @@
 import type { Table as ReactTable } from "@tanstack/react-table";
-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.
- */
+import { T } from "src/locale";
 
 interface Props {
 	tableInstance: ReactTable<any>;
+	onNew?: () => void;
+	onNewCustom?: () => void;
+	isFiltered?: boolean;
 }
-export default function Empty({ tableInstance }: Props) {
+export default function Empty({ tableInstance, onNew, onNewCustom, isFiltered }: Props) {
 	return (
 		<tr>
 			<td colSpan={tableInstance.getVisibleFlatColumns().length}>
 				<div className="text-center my-4">
-					<h2>{intl.formatMessage({ id: "certificates.empty" })}</h2>
-					<p className="text-muted">{intl.formatMessage({ id: "empty-subtitle" })}</p>
-					<div className="dropdown">
-						<button type="button" className="btn dropdown-toggle btn-pink my-3" data-bs-toggle="dropdown">
-							{intl.formatMessage({ id: "certificates.add" })}
-						</button>
-						<div className="dropdown-menu">
-							<a className="dropdown-item" href="#">
-								{intl.formatMessage({ id: "lets-encrypt" })}
-							</a>
-							<a className="dropdown-item" href="#">
-								{intl.formatMessage({ id: "certificates.custom" })}
-							</a>
-						</div>
-					</div>
+					{isFiltered ? (
+						<h2>
+							<T id="empty.search" />
+						</h2>
+					) : (
+						<>
+							<h2>
+								<T id="certificates.empty" />
+							</h2>
+							<p className="text-muted">
+								<T id="empty-subtitle" />
+							</p>
+							<div className="dropdown">
+								<button
+									type="button"
+									className="btn dropdown-toggle btn-pink my-3"
+									data-bs-toggle="dropdown"
+								>
+									<T id="certificates.add" />
+								</button>
+								<div className="dropdown-menu">
+									<a
+										className="dropdown-item"
+										href="#"
+										onClick={(e) => {
+											e.preventDefault();
+											onNew?.();
+										}}
+									>
+										<T id="lets-encrypt" />
+									</a>
+									<a
+										className="dropdown-item"
+										href="#"
+										onClick={(e) => {
+											e.preventDefault();
+											onNewCustom?.();
+										}}
+									>
+										<T id="certificates.custom" />
+									</a>
+								</div>
+							</div>
+						</>
+					)}
 				</div>
 			</td>
 		</tr>

+ 5 - 10
frontend/src/pages/Certificates/Table.tsx

@@ -4,7 +4,7 @@ import { useMemo } from "react";
 import type { Certificate } from "src/api/backend";
 import { DomainsFormatter, GravatarFormatter } from "src/components";
 import { TableLayout } from "src/components/Table/TableLayout";
-import { intl } from "src/locale";
+import { intl, T } from "src/locale";
 import Empty from "./Empty";
 
 interface Props {
@@ -69,25 +69,20 @@ export default function Table({ data, isFetching }: Props) {
 							</button>
 							<div className="dropdown-menu dropdown-menu-end">
 								<span className="dropdown-header">
-									{intl.formatMessage(
-										{
-											id: "certificates.actions-title",
-										},
-										{ id: info.row.original.id },
-									)}
+									<T id="certificates.actions-title" data={{ id: info.row.original.id }} />
 								</span>
 								<a className="dropdown-item" href="#">
 									<IconEdit size={16} />
-									{intl.formatMessage({ id: "action.edit" })}
+									<T id="action.edit" />
 								</a>
 								<a className="dropdown-item" href="#">
 									<IconPower size={16} />
-									{intl.formatMessage({ id: "action.disable" })}
+									<T id="action.disable" />
 								</a>
 								<div className="dropdown-divider" />
 								<a className="dropdown-item" href="#">
 									<IconTrash size={16} />
-									{intl.formatMessage({ id: "action.delete" })}
+									<T id="action.delete" />
 								</a>
 							</div>
 						</span>

+ 7 - 5
frontend/src/pages/Certificates/TableWrapper.tsx

@@ -2,7 +2,7 @@ import { IconSearch } from "@tabler/icons-react";
 import Alert from "react-bootstrap/Alert";
 import { LoadingPage } from "src/components";
 import { useCertificates } from "src/hooks";
-import { intl } from "src/locale";
+import { T } from "src/locale";
 import Table from "./Table";
 
 export default function TableWrapper() {
@@ -28,7 +28,9 @@ export default function TableWrapper() {
 				<div className="card-header">
 					<div className="row w-full">
 						<div className="col">
-							<h2 className="mt-1 mb-0">{intl.formatMessage({ id: "certificates.title" })}</h2>
+							<h2 className="mt-1 mb-0">
+								<T id="certificates.title" />
+							</h2>
 						</div>
 						<div className="col-md-auto col-sm-12">
 							<div className="ms-auto d-flex flex-wrap btn-list">
@@ -49,14 +51,14 @@ export default function TableWrapper() {
 										className="btn btn-sm dropdown-toggle btn-pink mt-1"
 										data-bs-toggle="dropdown"
 									>
-										{intl.formatMessage({ id: "certificates.add" })}
+										<T id="certificates.add" />
 									</button>
 									<div className="dropdown-menu">
 										<a className="dropdown-item" href="#">
-											{intl.formatMessage({ id: "lets-encrypt" })}
+											<T id="lets-encrypt" />
 										</a>
 										<a className="dropdown-item" href="#">
-											{intl.formatMessage({ id: "certificates.custom" })}
+											<T id="certificates.custom" />
 										</a>
 									</div>
 								</div>

+ 18 - 15
frontend/src/pages/Dashboard/index.tsx

@@ -1,7 +1,7 @@
 import { IconArrowsCross, IconBolt, IconBoltOff, IconDisc } from "@tabler/icons-react";
 import { useNavigate } from "react-router-dom";
 import { useHostReport } from "src/hooks";
-import { intl } from "src/locale";
+import { T } from "src/locale";
 
 const Dashboard = () => {
 	const { data: hostReport } = useHostReport();
@@ -9,7 +9,9 @@ const Dashboard = () => {
 
 	return (
 		<div>
-			<h2>{intl.formatMessage({ id: "dashboard.title" })}</h2>
+			<h2>
+				<T id="dashboard.title" />
+			</h2>
 			<div className="row row-deck row-cards">
 				<div className="col-12 my-4">
 					<div className="row row-cards">
@@ -31,10 +33,7 @@ const Dashboard = () => {
 										</div>
 										<div className="col">
 											<div className="font-weight-medium">
-												{intl.formatMessage(
-													{ id: "proxy-hosts.count" },
-													{ count: hostReport?.proxy },
-												)}
+												<T id="proxy-hosts.count" data={{ count: hostReport?.proxy }} />
 											</div>
 										</div>
 									</div>
@@ -58,10 +57,7 @@ const Dashboard = () => {
 											</span>
 										</div>
 										<div className="col">
-											{intl.formatMessage(
-												{ id: "redirection-hosts.count" },
-												{ count: hostReport?.redirection },
-											)}
+											<T id="redirection-hosts.count" data={{ count: hostReport?.redirection }} />
 										</div>
 									</div>
 								</div>
@@ -84,7 +80,7 @@ const Dashboard = () => {
 											</span>
 										</div>
 										<div className="col">
-											{intl.formatMessage({ id: "streams.count" }, { count: hostReport?.stream })}
+											<T id="streams.count" data={{ count: hostReport?.stream }} />
 										</div>
 									</div>
 								</div>
@@ -107,10 +103,7 @@ const Dashboard = () => {
 											</span>
 										</div>
 										<div className="col">
-											{intl.formatMessage(
-												{ id: "dead-hosts.count" },
-												{ count: hostReport?.dead },
-											)}
+											<T id="dead-hosts.count" data={{ count: hostReport?.dead }} />
 										</div>
 									</div>
 								</div>
@@ -125,12 +118,22 @@ const Dashboard = () => {
 - check mobile
 - add help docs for host types
 - REDO SCREENSHOTS in docs folder
+- translations for:
+  - src/components/Form/AccessField.tsx
+  - src/components/Form/SSLCertificateField.tsx
+  - src/components/Form/DNSProviderFields.tsx
+- search codebase for "TODO"
+- update documentation to add development notes for translations
+- use syntax highligting for audit logs json output
+- double check output of access field selection on proxy host dialog, after access lists are completed
+- proxy host custom locations dialog
 
 More for api, then implement here:
 - Properly implement refresh tokens
 - Add error message_18n for all backend errors
 - minor: certificates expand with hosts needs to omit 'is_deleted'
 - properly wrap all logger.debug called in isDebug check
+- add new api endpoint changes to swagger docs
 
 `}</code>
 			</pre>

+ 7 - 5
frontend/src/pages/Login/index.tsx

@@ -5,7 +5,7 @@ import Alert from "react-bootstrap/Alert";
 import { Button, LocalePicker, Page, ThemeSwitcher } from "src/components";
 import { useAuthState } from "src/context";
 import { useHealth } from "src/hooks";
-import { intl } from "src/locale";
+import { intl, T } from "src/locale";
 import { validateEmail, validateString } from "src/modules/Validations";
 import styles from "./index.module.css";
 
@@ -57,7 +57,9 @@ export default function Login() {
 				</div>
 				<div className="card card-md">
 					<div className="card-body">
-						<h2 className="h2 text-center mb-4">{intl.formatMessage({ id: "login.title" })}</h2>
+						<h2 className="h2 text-center mb-4">
+							<T id="login.title" />
+						</h2>
 						{formErr !== "" && <Alert variant="danger">{formErr}</Alert>}
 						<Formik
 							initialValues={
@@ -74,7 +76,7 @@ export default function Login() {
 										<Field name="email" validate={validateEmail()}>
 											{({ field, form }: any) => (
 												<label className="form-label">
-													{intl.formatMessage({ id: "email-address" })}
+													<T id="email-address" />
 													<input
 														{...field}
 														ref={emailRef}
@@ -93,7 +95,7 @@ export default function Login() {
 											{({ field, form }: any) => (
 												<>
 													<label className="form-label">
-														{intl.formatMessage({ id: "password" })}
+														<T id="password" />
 														<input
 															{...field}
 															type="password"
@@ -111,7 +113,7 @@ export default function Login() {
 									</div>
 									<div className="form-footer">
 										<Button type="submit" fullWidth color="azure" isLoading={isSubmitting}>
-											{intl.formatMessage({ id: "sign-in" })}
+											<T id="sign-in" />
 										</Button>
 									</div>
 								</Form>

+ 11 - 5
frontend/src/pages/Nginx/DeadHosts/Empty.tsx

@@ -1,6 +1,6 @@
 import type { Table as ReactTable } from "@tanstack/react-table";
 import { Button } from "src/components";
-import { intl } from "src/locale";
+import { T } from "src/locale";
 
 interface Props {
 	tableInstance: ReactTable<any>;
@@ -13,13 +13,19 @@ export default function Empty({ tableInstance, onNew, isFiltered }: Props) {
 			<td colSpan={tableInstance.getVisibleFlatColumns().length}>
 				<div className="text-center my-4">
 					{isFiltered ? (
-						<h2>{intl.formatMessage({ id: "empty-search" })}</h2>
+						<h2>
+							<T id="empty.search" />
+						</h2>
 					) : (
 						<>
-							<h2>{intl.formatMessage({ id: "dead-hosts.empty" })}</h2>
-							<p className="text-muted">{intl.formatMessage({ id: "empty-subtitle" })}</p>
+							<h2>
+								<T id="dead-hosts.empty" />
+							</h2>
+							<p className="text-muted">
+								<T id="empty-subtitle" />
+							</p>
 							<Button className="btn-red my-3" onClick={onNew}>
-								{intl.formatMessage({ id: "dead-hosts.add" })}
+								<T id="dead-hosts.add" />
 							</Button>
 						</>
 					)}

+ 5 - 12
frontend/src/pages/Nginx/DeadHosts/Table.tsx

@@ -4,7 +4,7 @@ import { useMemo } from "react";
 import type { DeadHost } from "src/api/backend";
 import { CertificateFormatter, DomainsFormatter, GravatarFormatter, StatusFormatter } from "src/components";
 import { TableLayout } from "src/components/Table/TableLayout";
-import { intl } from "src/locale";
+import { intl, T } from "src/locale";
 import Empty from "./Empty";
 
 interface Props {
@@ -67,12 +67,7 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
 							</button>
 							<div className="dropdown-menu dropdown-menu-end">
 								<span className="dropdown-header">
-									{intl.formatMessage(
-										{
-											id: "dead-hosts.actions-title",
-										},
-										{ id: info.row.original.id },
-									)}
+									<T id="dead-hosts.actions-title" data={{ id: info.row.original.id }} />
 								</span>
 								<a
 									className="dropdown-item"
@@ -83,7 +78,7 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
 									}}
 								>
 									<IconEdit size={16} />
-									{intl.formatMessage({ id: "action.edit" })}
+									<T id="action.edit" />
 								</a>
 								<a
 									className="dropdown-item"
@@ -94,9 +89,7 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
 									}}
 								>
 									<IconPower size={16} />
-									{intl.formatMessage({
-										id: info.row.original.enabled ? "action.disable" : "action.enable",
-									})}
+									<T id={info.row.original.enabled ? "action.disable" : "action.enable"} />
 								</a>
 								<div className="dropdown-divider" />
 								<a
@@ -108,7 +101,7 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
 									}}
 								>
 									<IconTrash size={16} />
-									{intl.formatMessage({ id: "action.delete" })}
+									<T id="action.delete" />
 								</a>
 							</div>
 						</span>

+ 7 - 6
frontend/src/pages/Nginx/DeadHosts/TableWrapper.tsx

@@ -5,7 +5,7 @@ import Alert from "react-bootstrap/Alert";
 import { deleteDeadHost, toggleDeadHost } from "src/api/backend";
 import { Button, LoadingPage } from "src/components";
 import { useDeadHosts } from "src/hooks";
-import { intl } from "src/locale";
+import { intl, T } from "src/locale";
 import { DeadHostModal, DeleteConfirmModal } from "src/modals";
 import { showSuccess } from "src/notifications";
 import Table from "./Table";
@@ -54,7 +54,9 @@ export default function TableWrapper() {
 				<div className="card-header">
 					<div className="row w-full">
 						<div className="col">
-							<h2 className="mt-1 mb-0">{intl.formatMessage({ id: "dead-hosts.title" })}</h2>
+							<h2 className="mt-1 mb-0">
+								<T id="dead-hosts.title" />
+							</h2>
 						</div>
 						{data?.length ? (
 							<div className="col-md-auto col-sm-12">
@@ -71,9 +73,8 @@ export default function TableWrapper() {
 											onChange={(e: any) => setSearch(e.target.value.toLowerCase().trim())}
 										/>
 									</div>
-
 									<Button size="sm" className="btn-red" onClick={() => setEditId("new")}>
-										{intl.formatMessage({ id: "dead-hosts.add" })}
+										<T id="dead-hosts.add" />
 									</Button>
 								</div>
 							</div>
@@ -92,12 +93,12 @@ export default function TableWrapper() {
 				{editId ? <DeadHostModal id={editId} onClose={() => setEditId(0)} /> : null}
 				{deleteId ? (
 					<DeleteConfirmModal
-						title={intl.formatMessage({ id: "dead-host.delete.title" })}
+						title="dead-host.delete.title"
 						onConfirm={handleDelete}
 						onClose={() => setDeleteId(0)}
 						invalidations={[["dead-hosts"], ["dead-host", deleteId]]}
 					>
-						{intl.formatMessage({ id: "dead-host.delete.content" })}
+						<T id="dead-host.delete.content" />
 					</DeleteConfirmModal>
 				) : null}
 			</div>

+ 11 - 5
frontend/src/pages/Nginx/ProxyHosts/Empty.tsx

@@ -1,6 +1,6 @@
 import type { Table as ReactTable } from "@tanstack/react-table";
 import { Button } from "src/components";
-import { intl } from "src/locale";
+import { T } from "src/locale";
 
 interface Props {
 	tableInstance: ReactTable<any>;
@@ -13,13 +13,19 @@ export default function Empty({ tableInstance, onNew, isFiltered }: Props) {
 			<td colSpan={tableInstance.getVisibleFlatColumns().length}>
 				<div className="text-center my-4">
 					{isFiltered ? (
-						<h2>{intl.formatMessage({ id: "empty-search" })}</h2>
+						<h2>
+							<T id="empty.search" />
+						</h2>
 					) : (
 						<>
-							<h2>{intl.formatMessage({ id: "proxy-hosts.empty" })}</h2>
-							<p className="text-muted">{intl.formatMessage({ id: "empty-subtitle" })}</p>
+							<h2>
+								<T id="proxy-hosts.empty" />
+							</h2>
+							<p className="text-muted">
+								<T id="empty-subtitle" />
+							</p>
 							<Button className="btn-lime my-3" onClick={onNew}>
-								{intl.formatMessage({ id: "proxy-hosts.add" })}
+								<T id="proxy-hosts.add" />
 							</Button>
 						</>
 					)}

+ 5 - 12
frontend/src/pages/Nginx/ProxyHosts/Table.tsx

@@ -4,7 +4,7 @@ import { useMemo } from "react";
 import type { ProxyHost } from "src/api/backend";
 import { CertificateFormatter, DomainsFormatter, GravatarFormatter, StatusFormatter } from "src/components";
 import { TableLayout } from "src/components/Table/TableLayout";
-import { intl } from "src/locale";
+import { intl, T } from "src/locale";
 import Empty from "./Empty";
 
 interface Props {
@@ -83,12 +83,7 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
 							</button>
 							<div className="dropdown-menu dropdown-menu-end">
 								<span className="dropdown-header">
-									{intl.formatMessage(
-										{
-											id: "proxy-hosts.actions-title",
-										},
-										{ id: info.row.original.id },
-									)}
+									<T id="proxy-hosts.actions-title" data={{ id: info.row.original.id }} />
 								</span>
 								<a
 									className="dropdown-item"
@@ -99,7 +94,7 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
 									}}
 								>
 									<IconEdit size={16} />
-									{intl.formatMessage({ id: "action.edit" })}
+									<T id="action.edit" />
 								</a>
 								<a
 									className="dropdown-item"
@@ -110,9 +105,7 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
 									}}
 								>
 									<IconPower size={16} />
-									{intl.formatMessage({
-										id: info.row.original.enabled ? "action.disable" : "action.enable",
-									})}
+									<T id={info.row.original.enabled ? "action.disable" : "action.enable"} />
 								</a>
 								<div className="dropdown-divider" />
 								<a
@@ -124,7 +117,7 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
 									}}
 								>
 									<IconTrash size={16} />
-									{intl.formatMessage({ id: "action.delete" })}
+									<T id="action.delete" />
 								</a>
 							</div>
 						</span>

+ 8 - 5
frontend/src/pages/Nginx/ProxyHosts/TableWrapper.tsx

@@ -5,7 +5,7 @@ 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 { intl, T } from "src/locale";
 import { DeleteConfirmModal, ProxyHostModal } from "src/modals";
 import { showSuccess } from "src/notifications";
 import Table from "./Table";
@@ -41,6 +41,7 @@ export default function TableWrapper() {
 	if (search && data) {
 		filtered = data?.filter((_item) => {
 			return true;
+			// TODO
 			// item.domainNames.some((domain: string) => domain.toLowerCase().includes(search)) ||
 			// item.forwardDomainName.toLowerCase().includes(search)
 			// );
@@ -57,7 +58,9 @@ export default function TableWrapper() {
 				<div className="card-header">
 					<div className="row w-full">
 						<div className="col">
-							<h2 className="mt-1 mb-0">{intl.formatMessage({ id: "proxy-hosts.title" })}</h2>
+							<h2 className="mt-1 mb-0">
+								<T id="proxy-hosts.title" />
+							</h2>
 						</div>
 						{data?.length ? (
 							<div className="col-md-auto col-sm-12">
@@ -74,7 +77,7 @@ export default function TableWrapper() {
 										/>
 									</div>
 									<Button size="sm" className="btn-lime">
-										{intl.formatMessage({ id: "proxy-hosts.add" })}
+										<T id="proxy-hosts.add" />
 									</Button>
 								</div>
 							</div>
@@ -93,12 +96,12 @@ export default function TableWrapper() {
 				{editId ? <ProxyHostModal id={editId} onClose={() => setEditId(0)} /> : null}
 				{deleteId ? (
 					<DeleteConfirmModal
-						title={intl.formatMessage({ id: "proxy-host.delete.title" })}
+						title="proxy-host.delete.title"
 						onConfirm={handleDelete}
 						onClose={() => setDeleteId(0)}
 						invalidations={[["proxy-hosts"], ["proxy-host", deleteId]]}
 					>
-						{intl.formatMessage({ id: "proxy-host.delete.content" })}
+						<T id="proxy-host.delete.content" />
 					</DeleteConfirmModal>
 				) : null}
 			</div>

+ 11 - 5
frontend/src/pages/Nginx/RedirectionHosts/Empty.tsx

@@ -1,6 +1,6 @@
 import type { Table as ReactTable } from "@tanstack/react-table";
 import { Button } from "src/components";
-import { intl } from "src/locale";
+import { T } from "src/locale";
 
 interface Props {
 	tableInstance: ReactTable<any>;
@@ -13,13 +13,19 @@ export default function Empty({ tableInstance, onNew, isFiltered }: Props) {
 			<td colSpan={tableInstance.getVisibleFlatColumns().length}>
 				<div className="text-center my-4">
 					{isFiltered ? (
-						<h2>{intl.formatMessage({ id: "empty-search" })}</h2>
+						<h2>
+							<T id="empty.search" />
+						</h2>
 					) : (
 						<>
-							<h2>{intl.formatMessage({ id: "redirection-hosts.empty" })}</h2>
-							<p className="text-muted">{intl.formatMessage({ id: "empty-subtitle" })}</p>
+							<h2>
+								<T id="redirection-hosts.empty" />
+							</h2>
+							<p className="text-muted">
+								<T id="empty-subtitle" />
+							</p>
 							<Button className="btn-yellow my-3" onClick={onNew}>
-								{intl.formatMessage({ id: "redirection-hosts.add" })}
+								<T id="redirection-hosts.add" />
 							</Button>
 						</>
 					)}

+ 5 - 12
frontend/src/pages/Nginx/RedirectionHosts/Table.tsx

@@ -4,7 +4,7 @@ import { useMemo } from "react";
 import type { RedirectionHost } from "src/api/backend";
 import { CertificateFormatter, DomainsFormatter, GravatarFormatter, StatusFormatter } from "src/components";
 import { TableLayout } from "src/components/Table/TableLayout";
-import { intl } from "src/locale";
+import { intl, T } from "src/locale";
 import Empty from "./Empty";
 
 interface Props {
@@ -88,12 +88,7 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
 							</button>
 							<div className="dropdown-menu dropdown-menu-end">
 								<span className="dropdown-header">
-									{intl.formatMessage(
-										{
-											id: "redirection-hosts.actions-title",
-										},
-										{ id: info.row.original.id },
-									)}
+									<T id="redirection-hosts.actions-title" data={{ id: info.row.original.id }} />
 								</span>
 								<a
 									className="dropdown-item"
@@ -104,7 +99,7 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
 									}}
 								>
 									<IconEdit size={16} />
-									{intl.formatMessage({ id: "action.edit" })}
+									<T id="action.edit" />
 								</a>
 								<a
 									className="dropdown-item"
@@ -115,9 +110,7 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
 									}}
 								>
 									<IconPower size={16} />
-									{intl.formatMessage({
-										id: info.row.original.enabled ? "action.disable" : "action.enable",
-									})}
+									<T id={info.row.original.enabled ? "action.disable" : "action.enable"} />
 								</a>
 								<div className="dropdown-divider" />
 								<a
@@ -129,7 +122,7 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
 									}}
 								>
 									<IconTrash size={16} />
-									{intl.formatMessage({ id: "action.delete" })}
+									<T id="action.delete" />
 								</a>
 							</div>
 						</span>

+ 7 - 6
frontend/src/pages/Nginx/RedirectionHosts/TableWrapper.tsx

@@ -5,7 +5,7 @@ import Alert from "react-bootstrap/Alert";
 import { deleteRedirectionHost, toggleRedirectionHost } from "src/api/backend";
 import { Button, LoadingPage } from "src/components";
 import { useRedirectionHosts } from "src/hooks";
-import { intl } from "src/locale";
+import { intl, T } from "src/locale";
 import { DeleteConfirmModal, RedirectionHostModal } from "src/modals";
 import { showSuccess } from "src/notifications";
 import Table from "./Table";
@@ -57,7 +57,9 @@ export default function TableWrapper() {
 				<div className="card-header">
 					<div className="row w-full">
 						<div className="col">
-							<h2 className="mt-1 mb-0">{intl.formatMessage({ id: "redirection-hosts.title" })}</h2>
+							<h2 className="mt-1 mb-0">
+								<T id="redirection-hosts.title" />
+							</h2>
 						</div>
 						{data?.length ? (
 							<div className="col-md-auto col-sm-12">
@@ -74,9 +76,8 @@ export default function TableWrapper() {
 											onChange={(e: any) => setSearch(e.target.value.toLowerCase().trim())}
 										/>
 									</div>
-
 									<Button size="sm" className="btn-yellow" onClick={() => setEditId("new")}>
-										{intl.formatMessage({ id: "redirection-hosts.add" })}
+										<T id="redirection-hosts.add" />
 									</Button>
 								</div>
 							</div>
@@ -95,12 +96,12 @@ export default function TableWrapper() {
 				{editId ? <RedirectionHostModal id={editId} onClose={() => setEditId(0)} /> : null}
 				{deleteId ? (
 					<DeleteConfirmModal
-						title={intl.formatMessage({ id: "redirection-host.delete.title" })}
+						title="redirection-host.delete.title"
 						onConfirm={handleDelete}
 						onClose={() => setDeleteId(0)}
 						invalidations={[["redirection-hosts"], ["redirection-host", deleteId]]}
 					>
-						{intl.formatMessage({ id: "redirection-host.delete.content" })}
+						<T id="redirection-host.delete.content" />
 					</DeleteConfirmModal>
 				) : null}
 			</div>

+ 11 - 5
frontend/src/pages/Nginx/Streams/Empty.tsx

@@ -1,6 +1,6 @@
 import type { Table as ReactTable } from "@tanstack/react-table";
 import { Button } from "src/components";
-import { intl } from "src/locale";
+import { T } from "src/locale";
 
 interface Props {
 	tableInstance: ReactTable<any>;
@@ -13,13 +13,19 @@ export default function Empty({ tableInstance, onNew, isFiltered }: Props) {
 			<td colSpan={tableInstance.getVisibleFlatColumns().length}>
 				<div className="text-center my-4">
 					{isFiltered ? (
-						<h2>{intl.formatMessage({ id: "empty-search" })}</h2>
+						<h2>
+							<T id="empty.search" />
+						</h2>
 					) : (
 						<>
-							<h2>{intl.formatMessage({ id: "streams.empty" })}</h2>
-							<p className="text-muted">{intl.formatMessage({ id: "empty-subtitle" })}</p>
+							<h2>
+								<T id="streams.empty" />
+							</h2>
+							<p className="text-muted">
+								<T id="empty-subtitle" />
+							</p>
 							<Button className="btn-blue my-3" onClick={onNew}>
-								{intl.formatMessage({ id: "streams.add" })}
+								<T id="streams.add" />
 							</Button>
 						</>
 					)}

+ 7 - 12
frontend/src/pages/Nginx/Streams/Table.tsx

@@ -4,7 +4,7 @@ import { useMemo } from "react";
 import type { Stream } from "src/api/backend";
 import { CertificateFormatter, GravatarFormatter, StatusFormatter, ValueWithDateFormatter } from "src/components";
 import { TableLayout } from "src/components/Table/TableLayout";
-import { intl } from "src/locale";
+import { intl, T } from "src/locale";
 import Empty from "./Empty";
 
 interface Props {
@@ -55,12 +55,12 @@ export default function Table({ data, isFetching, isFiltered, onEdit, onDelete,
 						<>
 							{value.tcpForwarding ? (
 								<span className="badge badge-lg domain-name">
-									{intl.formatMessage({ id: "streams.tcp" })}
+									<T id="streams.tcp" />
 								</span>
 							) : null}
 							{value.udpForwarding ? (
 								<span className="badge badge-lg domain-name">
-									{intl.formatMessage({ id: "streams.udp" })}
+									<T id="streams.udp" />
 								</span>
 							) : null}
 						</>
@@ -96,12 +96,7 @@ export default function Table({ data, isFetching, isFiltered, onEdit, onDelete,
 							</button>
 							<div className="dropdown-menu dropdown-menu-end">
 								<span className="dropdown-header">
-									{intl.formatMessage(
-										{
-											id: "streams.actions-title",
-										},
-										{ id: info.row.original.id },
-									)}
+									<T id="streams.actions-title" data={{ id: info.row.original.id }} />
 								</span>
 								<a
 									className="dropdown-item"
@@ -112,7 +107,7 @@ export default function Table({ data, isFetching, isFiltered, onEdit, onDelete,
 									}}
 								>
 									<IconEdit size={16} />
-									{intl.formatMessage({ id: "action.edit" })}
+									<T id="action.edit" />
 								</a>
 								<a
 									className="dropdown-item"
@@ -123,7 +118,7 @@ export default function Table({ data, isFetching, isFiltered, onEdit, onDelete,
 									}}
 								>
 									<IconPower size={16} />
-									{intl.formatMessage({ id: "action.disable" })}
+									<T id="action.disable" />
 								</a>
 								<div className="dropdown-divider" />
 								<a
@@ -135,7 +130,7 @@ export default function Table({ data, isFetching, isFiltered, onEdit, onDelete,
 									}}
 								>
 									<IconTrash size={16} />
-									{intl.formatMessage({ id: "action.delete" })}
+									<T id="action.delete" />
 								</a>
 							</div>
 						</span>

+ 7 - 5
frontend/src/pages/Nginx/Streams/TableWrapper.tsx

@@ -5,7 +5,7 @@ import Alert from "react-bootstrap/Alert";
 import { deleteStream, toggleStream } from "src/api/backend";
 import { Button, LoadingPage } from "src/components";
 import { useStreams } from "src/hooks";
-import { intl } from "src/locale";
+import { intl, T } from "src/locale";
 import { DeleteConfirmModal, StreamModal } from "src/modals";
 import { showSuccess } from "src/notifications";
 import Table from "./Table";
@@ -60,7 +60,9 @@ export default function TableWrapper() {
 				<div className="card-header">
 					<div className="row w-full">
 						<div className="col">
-							<h2 className="mt-1 mb-0">{intl.formatMessage({ id: "streams.title" })}</h2>
+							<h2 className="mt-1 mb-0">
+								<T id="streams.title" />
+							</h2>
 						</div>
 						{data?.length ? (
 							<div className="col-md-auto col-sm-12">
@@ -78,7 +80,7 @@ export default function TableWrapper() {
 										/>
 									</div>
 									<Button size="sm" className="btn-blue" onClick={() => setEditId("new")}>
-										{intl.formatMessage({ id: "streams.add" })}
+										<T id="streams.add" />
 									</Button>
 								</div>
 							</div>
@@ -97,12 +99,12 @@ export default function TableWrapper() {
 				{editId ? <StreamModal id={editId} onClose={() => setEditId(0)} /> : null}
 				{deleteId ? (
 					<DeleteConfirmModal
-						title={intl.formatMessage({ id: "stream.delete.title" })}
+						title="stream.delete.title"
 						onConfirm={handleDelete}
 						onClose={() => setDeleteId(0)}
 						invalidations={[["streams"], ["stream", deleteId]]}
 					>
-						{intl.formatMessage({ id: "stream.delete.content" })}
+						<T id="stream.delete.content" />
 					</DeleteConfirmModal>
 				) : null}
 			</div>

+ 4 - 2
frontend/src/pages/Settings/SettingTable.tsx

@@ -1,5 +1,5 @@
 import { IconDotsVertical, IconEdit, IconPower, IconTrash } from "@tabler/icons-react";
-import { intl } from "src/locale";
+import { T } from "src/locale";
 
 export default function SettingTable() {
 	return (
@@ -8,7 +8,9 @@ export default function SettingTable() {
 			<div className="card-table">
 				<div className="card-header">
 					<div className="row w-full">
-						<h2 className="mt-1 mb-0">{intl.formatMessage({ id: "settings.title" })}</h2>
+						<h2 className="mt-1 mb-0">
+							<T id="settings.title" />
+						</h2>
 					</div>
 				</div>
 				<div id="advanced-table">

+ 11 - 7
frontend/src/pages/Setup/index.tsx

@@ -6,7 +6,7 @@ import { Alert } from "react-bootstrap";
 import { createUser } from "src/api/backend";
 import { Button, LocalePicker, Page, ThemeSwitcher } from "src/components";
 import { useAuthState } from "src/context";
-import { intl } from "src/locale";
+import { intl, T } from "src/locale";
 import { validateEmail, validateString } from "src/modules/Validations";
 import styles from "./index.module.css";
 
@@ -89,8 +89,12 @@ export default function Setup() {
 						{({ isSubmitting }) => (
 							<Form>
 								<div className="card-body text-center py-4 p-sm-5">
-									<h1 className="mt-5">{intl.formatMessage({ id: "setup.title" })}</h1>
-									<p className="text-secondary">{intl.formatMessage({ id: "setup.preamble" })}</p>
+									<h1 className="mt-5">
+										<T id="setup.title" />
+									</h1>
+									<p className="text-secondary">
+										<T id="setup.preamble" />
+									</p>
 								</div>
 								<hr />
 								<div className="card-body">
@@ -105,7 +109,7 @@ export default function Setup() {
 														{...field}
 													/>
 													<label htmlFor="name">
-														{intl.formatMessage({ id: "user.full-name" })}
+														<T id="user.full-name" />
 													</label>
 													{form.errors.name ? (
 														<div className="invalid-feedback">
@@ -130,7 +134,7 @@ export default function Setup() {
 														{...field}
 													/>
 													<label htmlFor="email">
-														{intl.formatMessage({ id: "email-address" })}
+														<T id="email-address" />
 													</label>
 													{form.errors.email ? (
 														<div className="invalid-feedback">
@@ -155,7 +159,7 @@ export default function Setup() {
 														{...field}
 													/>
 													<label htmlFor="password">
-														{intl.formatMessage({ id: "user.new-password" })}
+														<T id="user.new-password" />
 													</label>
 													{form.errors.password ? (
 														<div className="invalid-feedback">
@@ -178,7 +182,7 @@ export default function Setup() {
 										disabled={isSubmitting}
 										className="w-100"
 									>
-										{intl.formatMessage({ id: "save" })}
+										<T id="save" />
 									</Button>
 								</div>
 							</Form>

+ 11 - 5
frontend/src/pages/Users/Empty.tsx

@@ -1,6 +1,6 @@
 import type { Table as ReactTable } from "@tanstack/react-table";
 import { Button } from "src/components";
-import { intl } from "src/locale";
+import { T } from "src/locale";
 
 interface Props {
 	tableInstance: ReactTable<any>;
@@ -13,13 +13,19 @@ export default function Empty({ tableInstance, onNewUser, isFiltered }: Props) {
 			<td colSpan={tableInstance.getVisibleFlatColumns().length}>
 				<div className="text-center my-4">
 					{isFiltered ? (
-						<h2>{intl.formatMessage({ id: "empty-search" })}</h2>
+						<h2>
+							<T id="empty-search" />
+						</h2>
 					) : (
 						<>
-							<h2>{intl.formatMessage({ id: "users.empty" })}</h2>
-							<p className="text-muted">{intl.formatMessage({ id: "empty-subtitle" })}</p>
+							<h2>
+								<T id="users.empty" />
+							</h2>
+							<p className="text-muted">
+								<T id="empty-subtitle" />
+							</p>
 							<Button className="btn-orange my-3" onClick={onNewUser}>
-								{intl.formatMessage({ id: "users.add" })}
+								<T id="users.add" />
 							</Button>
 						</>
 					)}

+ 7 - 14
frontend/src/pages/Users/Table.tsx

@@ -10,7 +10,7 @@ import {
 	ValueWithDateFormatter,
 } from "src/components";
 import { TableLayout } from "src/components/Table/TableLayout";
-import { intl } from "src/locale";
+import { intl, T } from "src/locale";
 import Empty from "./Empty";
 
 interface Props {
@@ -101,12 +101,7 @@ export default function Table({
 							</button>
 							<div className="dropdown-menu dropdown-menu-end">
 								<span className="dropdown-header">
-									{intl.formatMessage(
-										{
-											id: "users.actions-title",
-										},
-										{ id: info.row.original.id },
-									)}
+									<T id="users.actions-title" data={{ id: info.row.original.id }} />
 								</span>
 								<a
 									className="dropdown-item"
@@ -117,7 +112,7 @@ export default function Table({
 									}}
 								>
 									<IconEdit size={16} />
-									{intl.formatMessage({ id: "user.edit" })}
+									<T id="users.edit" />
 								</a>
 								{currentUserId !== info.row.original.id ? (
 									<>
@@ -130,7 +125,7 @@ export default function Table({
 											}}
 										>
 											<IconShield size={16} />
-											{intl.formatMessage({ id: "action.permissions" })}
+											<T id="action.permissions" />
 										</a>
 										<a
 											className="dropdown-item"
@@ -141,7 +136,7 @@ export default function Table({
 											}}
 										>
 											<IconLock size={16} />
-											{intl.formatMessage({ id: "user.set-password" })}
+											<T id="user.set-password" />
 										</a>
 										<a
 											className="dropdown-item"
@@ -152,9 +147,7 @@ export default function Table({
 											}}
 										>
 											<IconPower size={16} />
-											{intl.formatMessage({
-												id: info.row.original.isDisabled ? "action.enable" : "action.disable",
-											})}
+											<T id={info.row.original.isDisabled ? "action.enable" : "action.disable"} />
 										</a>
 										<div className="dropdown-divider" />
 										<a
@@ -166,7 +159,7 @@ export default function Table({
 											}}
 										>
 											<IconTrash size={16} />
-											{intl.formatMessage({ id: "action.delete" })}
+											<T id="action.delete" />
 										</a>
 									</>
 								) : null}

+ 7 - 5
frontend/src/pages/Users/TableWrapper.tsx

@@ -5,7 +5,7 @@ import Alert from "react-bootstrap/Alert";
 import { deleteUser, toggleUser } from "src/api/backend";
 import { Button, LoadingPage } from "src/components";
 import { useUser, useUsers } from "src/hooks";
-import { intl } from "src/locale";
+import { intl, T } from "src/locale";
 import { DeleteConfirmModal, PermissionsModal, SetPasswordModal, UserModal } from "src/modals";
 import { showSuccess } from "src/notifications";
 import Table from "./Table";
@@ -61,7 +61,9 @@ export default function TableWrapper() {
 				<div className="card-header">
 					<div className="row w-full">
 						<div className="col">
-							<h2 className="mt-1 mb-0">{intl.formatMessage({ id: "users.title" })}</h2>
+							<h2 className="mt-1 mb-0">
+								<T id="users.title" />
+							</h2>
 						</div>
 						{data?.length ? (
 							<div className="col-md-auto col-sm-12">
@@ -80,7 +82,7 @@ export default function TableWrapper() {
 									</div>
 
 									<Button size="sm" className="btn-orange" onClick={() => setEditUserId("new")}>
-										{intl.formatMessage({ id: "users.add" })}
+										<T id="users.add" />
 									</Button>
 								</div>
 							</div>
@@ -105,12 +107,12 @@ export default function TableWrapper() {
 				) : null}
 				{deleteUserId ? (
 					<DeleteConfirmModal
-						title={intl.formatMessage({ id: "user.delete.title" })}
+						title="user.delete.title"
 						onConfirm={handleDelete}
 						onClose={() => setDeleteUserId(0)}
 						invalidations={[["users"], ["user", deleteUserId]]}
 					>
-						{intl.formatMessage({ id: "user.delete.content" })}
+						<T id="user.delete.content" />
 					</DeleteConfirmModal>
 				) : null}
 				{editUserPasswordId ? (