Access list modal polish

This commit is contained in:
Jamie Curnow
2025-10-09 22:14:54 +10:00
parent 227e818040
commit 43599b4028
18 changed files with 376 additions and 73 deletions

View File

@@ -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);
}
}

View File

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

View File

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

View File

@@ -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<AccessOption>) => {

View File

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

View File

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

View File

@@ -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";

View File

@@ -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<AccessList, Error>({
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"] });
},
});

View File

@@ -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"] });
},
});
};

View File

@@ -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"] });
},
});
};

View File

@@ -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"] });
},
});
};

View File

@@ -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"] });
},
});
};

View File

@@ -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",

View File

@@ -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"

View File

@@ -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<ReactNode | null>(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(<T id={err.message} />),
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) {
</li>
<li className="nav-item" role="presentation">
<a
href="#tab-access"
href="#tab-rules"
className="nav-link"
data-bs-toggle="tab"
aria-selected="false"
@@ -120,8 +156,8 @@ export function AccessListModal({ id, onClose }: Props) {
<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) => (
<Field name="name" validate={validateString(1, 255)}>
{({ field, form }: any) => (
<div>
<label htmlFor="name" className="form-label">
<T id="column.name" />
@@ -134,6 +170,13 @@ export function AccessListModal({ id, onClose }: Props) {
className="form-control"
{...field}
/>
{form.errors.name ? (
<div className="invalid-feedback">
{form.errors.name && form.touched.name
? form.errors.name
: null}
</div>
) : null}
</div>
)}
</Field>
@@ -210,10 +253,10 @@ export function AccessListModal({ id, onClose }: Props) {
</div>
</div>
<div className="tab-pane" id="tab-auth" role="tabpanel">
<BasicAuthField />
<BasicAuthFields initialValues={data?.items || []} />
</div>
<div className="tab-pane" id="tab-rules" role="tabpanel">
todo
<AccessClientFields initialValues={data?.clients || []} />
</div>
</div>
</div>

View File

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

View File

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

View File

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