diff --git a/frontend/src/App.css b/frontend/src/App.css index 6e2ba564..59e0ed0c 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -74,3 +74,24 @@ label.row { cursor: pointer; } + +.input-group-select { + display: flex; + align-items: center; + padding: 0; + font-size: .875rem; + font-weight: 400; + line-height: 1.25rem; + color: var(--tblr-gray-500); + text-align: center; + white-space: nowrap; + background-color: var(--tblr-bg-surface-secondary); + border: var(--tblr-border-width) solid var(--tblr-border-color); + border-radius: var(--tblr-border-radius); + + .form-select { + border: none; + background-color: var(--tblr-bg-surface-secondary); + border-radius: var(--tblr-border-radius) 0 0 var(--tblr-border-radius); + } +} diff --git a/frontend/src/api/backend/models.ts b/frontend/src/api/backend/models.ts index 7283a535..34894688 100644 --- a/frontend/src/api/backend/models.ts +++ b/frontend/src/api/backend/models.ts @@ -67,8 +67,8 @@ export interface AccessListItem { accessListId?: number; username: string; password: string; - meta: Record; - hint: string; + meta?: Record; + hint?: string; } export type AccessListClient = { @@ -78,7 +78,7 @@ export type AccessListClient = { accessListId?: number; address: string; directive: "allow" | "deny"; - meta: Record; + meta?: Record; }; export interface Certificate { diff --git a/frontend/src/components/Form/AccessClientFields.tsx b/frontend/src/components/Form/AccessClientFields.tsx new file mode 100644 index 00000000..f27d78d6 --- /dev/null +++ b/frontend/src/components/Form/AccessClientFields.tsx @@ -0,0 +1,131 @@ +import { IconX } from "@tabler/icons-react"; +import cn from "classnames"; +import { useFormikContext } from "formik"; +import { useState } from "react"; +import type { AccessListClient } from "src/api/backend"; +import { T } from "src/locale"; + +interface Props { + initialValues: AccessListClient[]; + name?: string; +} +export function AccessClientFields({ initialValues, name = "clients" }: Props) { + const [values, setValues] = useState(initialValues || []); + const { setFieldValue } = useFormikContext(); + + const blankClient: AccessListClient = { directive: "allow", address: "" }; + + if (values?.length === 0) { + setValues([blankClient]); + } + + const handleAdd = () => { + setValues([...values, blankClient]); + }; + + const handleRemove = (idx: number) => { + const newValues = values.filter((_: AccessListClient, i: number) => i !== idx); + if (newValues.length === 0) { + newValues.push(blankClient); + } + setValues(newValues); + setFormField(newValues); + }; + + const handleChange = (idx: number, field: string, fieldValue: string) => { + const newValues = values.map((v: AccessListClient, i: number) => + i === idx ? { ...v, [field]: fieldValue } : v, + ); + setValues(newValues); + setFormField(newValues); + }; + + const setFormField = (newValues: AccessListClient[]) => { + const filtered = newValues.filter((v: AccessListClient) => v?.address?.trim() !== ""); + setFieldValue(name, filtered); + }; + + return ( + <> +

+ +

+ {values.map((client: AccessListClient, idx: number) => ( +
+
+
+ + + + handleChange(idx, "address", e.target.value)} + placeholder="192.168.1.100 or 192.168.1.0/24 or 2001:0db8::/32" + /> +
+
+ +
+ ))} +
+ +
+
+

+ +

+
+
+ + + + +
+
+
+ + ); +} diff --git a/frontend/src/components/Form/AccessField.tsx b/frontend/src/components/Form/AccessField.tsx index 13412a77..4b115b8c 100644 --- a/frontend/src/components/Form/AccessField.tsx +++ b/frontend/src/components/Form/AccessField.tsx @@ -32,7 +32,7 @@ interface Props { label?: string; } export function AccessField({ name = "accessListId", label = "access.title", id = "accessListId" }: Props) { - const { isLoading, isError, error, data } = useAccessLists(); + const { isLoading, isError, error, data } = useAccessLists(["owner", "items", "clients"]); const { setFieldValue } = useFormikContext(); const handleChange = (newValue: any, _actionMeta: ActionMeta) => { diff --git a/frontend/src/components/Form/BasicAuthField.tsx b/frontend/src/components/Form/BasicAuthField.tsx deleted file mode 100644 index 65edcdb3..00000000 --- a/frontend/src/components/Form/BasicAuthField.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { useFormikContext } from "formik"; -import { T } from "src/locale"; - -interface Props { - id?: string; - name?: string; -} -export function BasicAuthField({ name = "items", id = "items" }: Props) { - const { setFieldValue } = useFormikContext(); - - return ( - <> -
-
- -
-
- -
-
-
-
- -
-
- -
-
- - - ); -} diff --git a/frontend/src/components/Form/BasicAuthFields.tsx b/frontend/src/components/Form/BasicAuthFields.tsx new file mode 100644 index 00000000..c47d96da --- /dev/null +++ b/frontend/src/components/Form/BasicAuthFields.tsx @@ -0,0 +1,105 @@ +import { IconX } from "@tabler/icons-react"; +import { useFormikContext } from "formik"; +import { useState } from "react"; +import type { AccessListItem } from "src/api/backend"; +import { T } from "src/locale"; + +interface Props { + initialValues: AccessListItem[]; + name?: string; +} +export function BasicAuthFields({ initialValues, name = "items" }: Props) { + const [values, setValues] = useState(initialValues || []); + const { setFieldValue } = useFormikContext(); + + const blankItem: AccessListItem = { username: "", password: "" }; + + if (values?.length === 0) { + setValues([blankItem]); + } + + const handleAdd = () => { + setValues([...values, blankItem]); + }; + + const handleRemove = (idx: number) => { + const newValues = values.filter((_: AccessListItem, i: number) => i !== idx); + if (newValues.length === 0) { + newValues.push(blankItem); + } + setValues(newValues); + setFormField(newValues); + }; + + const handleChange = (idx: number, field: string, fieldValue: string) => { + const newValues = values.map((v: AccessListItem, i: number) => (i === idx ? { ...v, [field]: fieldValue } : v)); + setValues(newValues); + setFormField(newValues); + }; + + const setFormField = (newValues: AccessListItem[]) => { + const filtered = newValues.filter((v: AccessListItem) => v?.username?.trim() !== ""); + setFieldValue(name, filtered); + }; + + return ( + <> +
+
+ +
+
+ +
+
+ {values.map((item: AccessListItem, idx: number) => ( +
+
+ handleChange(idx, "username", e.target.value)} + /> +
+
+ iv.username === item.username).length > 0 + ? "••••••••" + : "" + } + onChange={(e) => handleChange(idx, "password", e.target.value)} + /> +
+ +
+ ))} +
+ +
+ + ); +} diff --git a/frontend/src/components/Form/index.ts b/frontend/src/components/Form/index.ts index 16eaa7f9..fc2eb00b 100644 --- a/frontend/src/components/Form/index.ts +++ b/frontend/src/components/Form/index.ts @@ -1,5 +1,6 @@ +export * from "./AccessClientFields"; export * from "./AccessField"; -export * from "./BasicAuthField"; +export * from "./BasicAuthFields"; export * from "./DNSProviderFields"; export * from "./DomainNamesField"; export * from "./NginxConfigField"; diff --git a/frontend/src/hooks/useAccessList.ts b/frontend/src/hooks/useAccessList.ts index 90a16144..b497428e 100644 --- a/frontend/src/hooks/useAccessList.ts +++ b/frontend/src/hooks/useAccessList.ts @@ -1,7 +1,13 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { type AccessList, createAccessList, getAccessList, updateAccessList } from "src/api/backend"; +import { + type AccessList, + type AccessListExpansion, + createAccessList, + getAccessList, + updateAccessList, +} from "src/api/backend"; -const fetchAccessList = (id: number | "new") => { +const fetchAccessList = (id: number | "new", expand: AccessListExpansion[] = ["owner"]) => { if (id === "new") { return Promise.resolve({ id: 0, @@ -14,13 +20,13 @@ const fetchAccessList = (id: number | "new") => { meta: {}, } as AccessList); } - return getAccessList(id, ["owner"]); + return getAccessList(id, expand); }; -const useAccessList = (id: number | "new", options = {}) => { +const useAccessList = (id: number | "new", expand?: AccessListExpansion[], options = {}) => { return useQuery({ - queryKey: ["access-list", id], - queryFn: () => fetchAccessList(id), + queryKey: ["access-list", id, expand], + queryFn: () => fetchAccessList(id, expand), staleTime: 60 * 1000, // 1 minute ...options, }); @@ -44,7 +50,7 @@ const useSetAccessList = () => { onError: (_, __, rollback: any) => rollback(), onSuccess: async ({ id }: AccessList) => { queryClient.invalidateQueries({ queryKey: ["access-list", id] }); - queryClient.invalidateQueries({ queryKey: ["access-list"] }); + queryClient.invalidateQueries({ queryKey: ["access-lists"] }); queryClient.invalidateQueries({ queryKey: ["audit-logs"] }); }, }); diff --git a/frontend/src/hooks/useDeadHost.ts b/frontend/src/hooks/useDeadHost.ts index 44cbd20f..9f0a1f90 100644 --- a/frontend/src/hooks/useDeadHost.ts +++ b/frontend/src/hooks/useDeadHost.ts @@ -51,6 +51,7 @@ const useSetDeadHost = () => { queryClient.invalidateQueries({ queryKey: ["dead-host", id] }); queryClient.invalidateQueries({ queryKey: ["dead-hosts"] }); queryClient.invalidateQueries({ queryKey: ["audit-logs"] }); + queryClient.invalidateQueries({ queryKey: ["host-report"] }); }, }); }; diff --git a/frontend/src/hooks/useProxyHost.ts b/frontend/src/hooks/useProxyHost.ts index d9733ee6..b36c52f3 100644 --- a/frontend/src/hooks/useProxyHost.ts +++ b/frontend/src/hooks/useProxyHost.ts @@ -58,6 +58,7 @@ const useSetProxyHost = () => { queryClient.invalidateQueries({ queryKey: ["proxy-host", id] }); queryClient.invalidateQueries({ queryKey: ["proxy-hosts"] }); queryClient.invalidateQueries({ queryKey: ["audit-logs"] }); + queryClient.invalidateQueries({ queryKey: ["host-report"] }); }, }); }; diff --git a/frontend/src/hooks/useRedirectionHost.ts b/frontend/src/hooks/useRedirectionHost.ts index 62aabc98..342fe562 100644 --- a/frontend/src/hooks/useRedirectionHost.ts +++ b/frontend/src/hooks/useRedirectionHost.ts @@ -62,6 +62,7 @@ const useSetRedirectionHost = () => { queryClient.invalidateQueries({ queryKey: ["redirection-host", id] }); queryClient.invalidateQueries({ queryKey: ["redirection-hosts"] }); queryClient.invalidateQueries({ queryKey: ["audit-logs"] }); + queryClient.invalidateQueries({ queryKey: ["host-report"] }); }, }); }; diff --git a/frontend/src/hooks/useStream.ts b/frontend/src/hooks/useStream.ts index 4bd71826..a844e5a2 100644 --- a/frontend/src/hooks/useStream.ts +++ b/frontend/src/hooks/useStream.ts @@ -47,6 +47,7 @@ const useSetStream = () => { queryClient.invalidateQueries({ queryKey: ["stream", id] }); queryClient.invalidateQueries({ queryKey: ["streams"] }); queryClient.invalidateQueries({ queryKey: ["audit-logs"] }); + queryClient.invalidateQueries({ queryKey: ["host-report"] }); }, }); }; diff --git a/frontend/src/locale/lang/en.json b/frontend/src/locale/lang/en.json index 729e8924..c7a206b6 100644 --- a/frontend/src/locale/lang/en.json +++ b/frontend/src/locale/lang/en.json @@ -1,16 +1,19 @@ { - "access.access-count": "{count} Rules", + "access.access-count": "{count} {count, plural, one {Rule} other {Rules}}", "access.actions-title": "Access List #{id}", "access.add": "Add Access List", - "access.auth-count": "{count} Users", + "access.auth-count": "{count} {count, plural, one {User} other {Users}}", "access.edit": "Edit Access", "access.empty": "There are no Access Lists", + "access.help-rules-last": "When at least 1 rule exists, this deny all rule will be added last", + "access.help.rules-order": "Note that the allow and deny directives will be applied in the order they are defined.", "access.new": "New Access", "access.pass-auth": "Pass Auth to Upstream", "access.public": "Publicly Accessible", "access.satisfy-any": "Satisfy Any", - "access.subtitle": "{users} User, {rules} Rules - Created: {date}", + "access.subtitle": "{users} {users, plural, one {User} other {Users}}, {rules} {rules, plural, one {Rule} other {Rules}} - Created: {date}", "access.title": "Access", + "action.add": "Add", "action.delete": "Delete", "action.disable": "Disable", "action.edit": "Edit", @@ -56,7 +59,7 @@ "dead-host.new": "New 404 Host", "dead-hosts.actions-title": "404 Host #{id}", "dead-hosts.add": "Add 404 Host", - "dead-hosts.count": "{count} 404 Hosts", + "dead-hosts.count": "{count} {count, plural, one {404 Host} other {404 Hosts}}", "dead-hosts.empty": "There are no 404 Hosts", "dead-hosts.title": "404 Hosts", "disabled": "Disabled", @@ -74,6 +77,8 @@ "empty-search": "No results found", "empty-subtitle": "Why don't you create one?", "enabled": "Enabled", + "error.access.at-least-one": "Either one Authorization or one Access Rule is required", + "error.access.duplicate-usernames": "Authorization Usernames must be unique", "error.invalid-auth": "Invalid email or password", "error.invalid-domain": "Invalid domain: {domain}", "error.invalid-email": "Invalid email address", @@ -115,6 +120,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-deleted": "Access has been deleted", "notification.access-saved": "Access has been saved", "notification.dead-host-saved": "404 Host has been saved", "notification.error": "Error", @@ -146,7 +152,7 @@ "proxy-host.new": "New Proxy Host", "proxy-hosts.actions-title": "Proxy Host #{id}", "proxy-hosts.add": "Add Proxy Host", - "proxy-hosts.count": "{count} Proxy Hosts", + "proxy-hosts.count": "{count} {count, plural, one {Proxy Host} other {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?", @@ -155,7 +161,7 @@ "redirection-host.new": "New Redirection Host", "redirection-hosts.actions-title": "Redirection Host #{id}", "redirection-hosts.add": "Add Redirection Host", - "redirection-hosts.count": "{count} Redirection Hosts", + "redirection-hosts.count": "{count} {count, plural, one {Redirection Host} other {Redirection Hosts}}", "redirection-hosts.empty": "There are no Redirection Hosts", "redirection-hosts.title": "Redirection Hosts", "role.admin": "Administrator", @@ -174,7 +180,7 @@ "stream.new": "New Stream", "streams.actions-title": "Stream #{id}", "streams.add": "Add Stream", - "streams.count": "{count} Streams", + "streams.count": "{count} {count, plural, one {Stream} other {Streams}}", "streams.empty": "There are no Streams", "streams.tcp": "TCP", "streams.title": "Streams", diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json index 021b854e..b2b5cdf1 100644 --- a/frontend/src/locale/src/en.json +++ b/frontend/src/locale/src/en.json @@ -1,6 +1,6 @@ { "access.access-count": { - "defaultMessage": "{count} Rules" + "defaultMessage": "{count} {count, plural, one {Rule} other {Rules}}" }, "access.actions-title": { "defaultMessage": "Access List #{id}" @@ -9,7 +9,7 @@ "defaultMessage": "Add Access List" }, "access.auth-count": { - "defaultMessage": "{count} Users" + "defaultMessage": "{count} {count, plural, one {User} other {Users}}" }, "access.edit": { "defaultMessage": "Edit Access" @@ -17,6 +17,12 @@ "access.empty": { "defaultMessage": "There are no Access Lists" }, + "access.help-rules-last": { + "defaultMessage": "When at least 1 rule exists, this deny all rule will be added last" + }, + "access.help.rules-order": { + "defaultMessage": "Note that the allow and deny directives will be applied in the order they are defined." + }, "access.new": { "defaultMessage": "New Access" }, @@ -30,11 +36,14 @@ "defaultMessage": "Satisfy Any" }, "access.subtitle": { - "defaultMessage": "{users} User, {rules} Rules - Created: {date}" + "defaultMessage": "{users} {users, plural, one {User} other {Users}}, {rules} {rules, plural, one {Rule} other {Rules}} - Created: {date}" }, "access.title": { "defaultMessage": "Access" }, + "action.add": { + "defaultMessage": "Add" + }, "action.delete": { "defaultMessage": "Delete" }, @@ -171,7 +180,7 @@ "defaultMessage": "Add 404 Host" }, "dead-hosts.count": { - "defaultMessage": "{count} 404 Hosts" + "defaultMessage": "{count} {count, plural, one {404 Host} other {404 Hosts}}" }, "dead-hosts.empty": { "defaultMessage": "There are no 404 Hosts" @@ -224,6 +233,12 @@ "enabled": { "defaultMessage": "Enabled" }, + "error.access.at-least-one": { + "defaultMessage": "Either one Authorization or one Access Rule is required" + }, + "error.access.duplicate-usernames": { + "defaultMessage": "Authorization Usernames must be unique" + }, "error.invalid-auth": { "defaultMessage": "Invalid email or password" }, @@ -347,6 +362,9 @@ "notfound.title": { "defaultMessage": "Oops… You just found an error page" }, + "notification.access-deleted": { + "defaultMessage": "Access has been deleted" + }, "notification.access-saved": { "defaultMessage": "Access has been saved" }, @@ -441,7 +459,7 @@ "defaultMessage": "Add Proxy Host" }, "proxy-hosts.count": { - "defaultMessage": "{count} Proxy Hosts" + "defaultMessage": "{count} {count, plural, one {Proxy Host} other {Proxy Hosts}}" }, "proxy-hosts.empty": { "defaultMessage": "There are no Proxy Hosts" @@ -468,7 +486,7 @@ "defaultMessage": "Add Redirection Host" }, "redirection-hosts.count": { - "defaultMessage": "{count} Redirection Hosts" + "defaultMessage": "{count} {count, plural, one {Redirection Host} other {Redirection Hosts}}" }, "redirection-hosts.empty": { "defaultMessage": "There are no Redirection Hosts" @@ -525,7 +543,7 @@ "defaultMessage": "Add Stream" }, "streams.count": { - "defaultMessage": "{count} Streams" + "defaultMessage": "{count} {count, plural, one {Stream} other {Streams}}" }, "streams.empty": { "defaultMessage": "There are no Streams" diff --git a/frontend/src/modals/AccessListModal.tsx b/frontend/src/modals/AccessListModal.tsx index 0ae37c9b..cadee428 100644 --- a/frontend/src/modals/AccessListModal.tsx +++ b/frontend/src/modals/AccessListModal.tsx @@ -3,7 +3,8 @@ 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 type { AccessList, AccessListClient, AccessListItem } from "src/api/backend"; +import { AccessClientFields, BasicAuthFields, Button, Loading } from "src/components"; import { useAccessList, useSetAccessList } from "src/hooks"; import { intl, T } from "src/locale"; import { validateString } from "src/modules/Validations"; @@ -14,13 +15,36 @@ interface Props { onClose: () => void; } export function AccessListModal({ id, onClose }: Props) { - const { data, isLoading, error } = useAccessList(id); + const { data, isLoading, error } = useAccessList(id, ["items", "clients"]); const { mutate: setAccessList } = useSetAccessList(); const [errorMsg, setErrorMsg] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); + const validate = (values: any): string | null => { + // either Auths or Clients must be defined + if (values.items?.length === 0 && values.clients?.length === 0) { + return intl.formatMessage({ id: "error.access.at-least-one" }); + } + + // ensure the items don't contain the same username twice + const usernames = values.items.map((i: any) => i.username); + const uniqueUsernames = Array.from(new Set(usernames)); + if (usernames.length !== uniqueUsernames.length) { + return intl.formatMessage({ id: "error.access.duplicate-usernames" }); + } + + return null; + }; + const onSubmit = async (values: any, { setSubmitting }: any) => { if (isSubmitting) return; + + const vErr = validate(values); + if (vErr) { + setErrorMsg(vErr); + return; + } + setIsSubmitting(true); setErrorMsg(null); @@ -29,6 +53,18 @@ export function AccessListModal({ id, onClose }: Props) { ...values, }; + // Filter out "items" to only use the "username" and "password" fields + payload.items = (values.items || []).map((i: AccessListItem) => ({ + username: i.username, + password: i.password, + })); + + // Filter out "clients" to only use the "directive" and "address" fields + payload.clients = (values.clients || []).map((i: AccessListClient) => ({ + directive: i.directive, + address: i.address, + })); + setAccessList(payload, { onError: (err: any) => setErrorMsg(), onSuccess: () => { @@ -60,9 +96,9 @@ export function AccessListModal({ id, onClose }: Props) { name: data?.name, satisfyAny: data?.satisfyAny, passAuth: data?.passAuth, - // todo: more? there's stuff missing here? - meta: data?.meta || {}, - } as any + items: data?.items || [], + clients: data?.clients || [], + } as AccessList } onSubmit={onSubmit} > @@ -105,7 +141,7 @@ export function AccessListModal({ id, onClose }: Props) {
  • - - {({ field }: any) => ( + + {({ field, form }: any) => (
    )}
    @@ -210,10 +253,10 @@ export function AccessListModal({ id, onClose }: Props) {
    - +
    - todo +
    diff --git a/frontend/src/modals/ChangePasswordModal.tsx b/frontend/src/modals/ChangePasswordModal.tsx index a39abf38..22eb26c5 100644 --- a/frontend/src/modals/ChangePasswordModal.tsx +++ b/frontend/src/modals/ChangePasswordModal.tsx @@ -66,6 +66,7 @@ export function ChangePasswordModal({ userId, onClose }: Props) {
    {form.errors.password}
    diff --git a/frontend/src/pages/Setup/index.tsx b/frontend/src/pages/Setup/index.tsx index c0fea08a..4becf5fa 100644 --- a/frontend/src/pages/Setup/index.tsx +++ b/frontend/src/pages/Setup/index.tsx @@ -154,6 +154,7 @@ export default function Setup() {