API lib cleanup, 404 hosts WIP

This commit is contained in:
Jamie Curnow
2025-09-21 17:16:46 +10:00
parent 058f49ceea
commit 54e036276a
76 changed files with 921 additions and 544 deletions

View File

@@ -1,6 +1,6 @@
import { intl } from "src/locale";
import { useNavigate } from "react-router-dom";
import { Button } from "src/components";
import { intl } from "src/locale";
export function ErrorNotFound() {
const navigate = useNavigate();
@@ -9,9 +9,7 @@ export function ErrorNotFound() {
<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-subtitle text-secondary">{intl.formatMessage({ id: "notfound.text" })}</p>
<div className="empty-action">
<Button type="button" size="md" onClick={() => navigate("/")}>
{intl.formatMessage({ id: "notfound.action" })}

View File

@@ -0,0 +1,119 @@
import { Field, useFormikContext } from "formik";
import type { ActionMeta, MultiValue } from "react-select";
import CreatableSelect from "react-select/creatable";
import { intl } from "src/locale";
export type SelectOption = {
label: string;
value: string;
color?: string;
};
interface Props {
id?: string;
maxDomains?: number;
isWildcardPermitted?: boolean;
dnsProviderWildcardSupported?: boolean;
name?: string;
label?: string;
}
export function DomainNamesField({
name = "domainNames",
label = "domain-names",
id = "domainNames",
maxDomains,
isWildcardPermitted,
dnsProviderWildcardSupported,
}: Props) {
const { values, setFieldValue } = useFormikContext();
const getDomainCount = (v: string[] | undefined): number => {
if (v?.length) {
return v.length;
}
return 0;
};
const handleChange = (v: MultiValue<SelectOption>, _actionMeta: ActionMeta<SelectOption>) => {
const doms = v?.map((i: SelectOption) => {
return i.value;
});
setFieldValue(name, doms);
};
const isDomainValid = (d: string): boolean => {
const dom = d.trim().toLowerCase();
const v: any = values;
// Deny if the list of domains is hit
if (maxDomains && getDomainCount(v?.[name]) >= maxDomains) {
return false;
}
if (dom.length < 3) {
return false;
}
// Prevent wildcards
if ((!isWildcardPermitted || !dnsProviderWildcardSupported) && dom.indexOf("*") !== -1) {
return false;
}
// Prevent duplicate * in domain
if ((dom.match(/\*/g) || []).length > 1) {
return false;
}
// Prevent some invalid characters
if ((dom.match(/(@|,|!|&|\$|#|%|\^|\(|\))/g) || []).length > 0) {
return false;
}
// This will match *.com type domains,
return dom.match(/\*\.[^.]+$/m) === null;
};
const helperTexts: string[] = [];
if (maxDomains) {
helperTexts.push(intl.formatMessage({ id: "domain_names.max" }, { count: maxDomains }));
}
if (!isWildcardPermitted) {
helperTexts.push(intl.formatMessage({ id: "wildcards-not-permitted" }));
} else if (!dnsProviderWildcardSupported) {
helperTexts.push(intl.formatMessage({ id: "wildcards-not-supported" }));
}
return (
<Field name={name}>
{({ field, form }: any) => (
<div className="mb-3">
<label className="form-label" htmlFor={id}>
{intl.formatMessage({ id: label })}
</label>
<CreatableSelect
name={field.name}
id={id}
closeMenuOnSelect={true}
isClearable={false}
isValidNewOption={isDomainValid}
isMulti
placeholder="Start typing to add domain..."
onChange={handleChange}
value={field.value?.map((d: string) => ({ label: d, value: d }))}
/>
{form.errors[field.name] ? (
<div className="invalid-feedback">
{form.errors[field.name] && form.touched[field.name] ? form.errors[field.name] : null}
</div>
) : helperTexts.length ? (
helperTexts.map((i) => (
<div key={i} className="invalid-feedback text-info">
{i}
</div>
))
) : null}
</div>
)}
</Field>
);
}

View File

@@ -0,0 +1,112 @@
import { IconShield } from "@tabler/icons-react";
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";
interface CertOption {
readonly value: number | "new";
readonly label: string;
readonly subLabel: string;
readonly icon: React.ReactNode;
}
const Option = (props: OptionProps<CertOption>) => {
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;
required?: boolean;
allowNew?: boolean;
}
export function SSLCertificateField({
name = "certificateId",
label = "ssl-certificate",
id = "certificateId",
required,
allowNew,
}: Props) {
const { isLoading, isError, error, data } = useCertificates();
const { setFieldValue } = useFormikContext();
const handleChange = (v: any, _actionMeta: ActionMeta<CertOption>) => {
setFieldValue(name, v?.value);
};
const options: CertOption[] =
data?.map((cert: Certificate) => ({
value: cert.id,
label: cert.niceName,
subLabel: `${cert.provider === "letsencrypt" ? "Let's Encrypt" : cert.provider} &mdash; Expires: ${
cert.expiresOn ? DateTimeFormat(cert.expiresOn) : "N/A"
}`,
icon: <IconShield size={14} className="text-pink" />,
})) || [];
// Prepend the Add New option
if (allowNew) {
options?.unshift({
value: "new",
label: "Request a new HTTP certificate",
subLabel: "with Let's Encrypt",
icon: <IconShield size={14} className="text-lime" />,
});
}
// Prepend the None option
if (!required) {
options?.unshift({
value: 0,
label: "None",
subLabel: "This host will not use HTTPS",
icon: <IconShield size={14} className="text-red" />,
});
}
return (
<Field name={name}>
{({ field, form }: any) => (
<div className="mb-3">
<label className="form-label" htmlFor={id}>
{intl.formatMessage({ 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
defaultValue={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>
);
}

View File

@@ -0,0 +1,2 @@
export * from "./DomainNamesField";
export * from "./SSLCertificateField";

View File

@@ -7,11 +7,9 @@ function TableBody<T>(props: TableLayoutProps<T>) {
const rows = tableInstance.getRowModel().rows;
if (rows.length === 0) {
return emptyState ? (
emptyState
) : (
return (
<tbody className="table-tbody">
<EmptyRow tableInstance={tableInstance} />
{emptyState ? emptyState : <EmptyRow tableInstance={tableInstance} />}
</tbody>
);
}

View File

@@ -1,6 +1,7 @@
export * from "./Button";
export * from "./ErrorNotFound";
export * from "./Flag";
export * from "./Form";
export * from "./HasPermission";
export * from "./Loading";
export * from "./LoadingPage";