404 hosts add update complete, fix certbot renewals

and remove the need for email and agreement on cert requests
This commit is contained in:
Jamie Curnow
2025-09-23 18:02:00 +10:00
parent d85e515ab9
commit 18537b9288
32 changed files with 449 additions and 446 deletions

View File

@@ -65,3 +65,8 @@
}
}
}
.textareaMono {
font-family: 'Courier New', Courier, monospace !important;
resize: vertical;
}

View File

@@ -1,16 +1,8 @@
.dnsChallengeWarning {
border: 1px solid #fecaca; /* Tailwind's red-300 */
border: 1px solid var(--tblr-orange-lt);
padding: 1rem;
border-radius: 0.375rem; /* Tailwind's rounded-md */
border-radius: 0.375rem;
margin-top: 1rem;
background-color: var(--tblr-cyan-lt);
}
.textareaMono {
font-family: 'Courier New', Courier, monospace !important;
/* background-color: #f9fafb;
border: 1px solid #d1d5db;
padding: 0.5rem;
border-radius: 0.375rem;
width: 100%; */
resize: vertical;
}

View File

@@ -1,4 +1,3 @@
import cn from "classnames";
import { Field, useFormikContext } from "formik";
import { useState } from "react";
import Select, { type ActionMeta } from "react-select";
@@ -20,8 +19,8 @@ export function DNSProviderFields() {
const v: any = values || {};
const handleChange = (newValue: any, _actionMeta: ActionMeta<DNSProviderOption>) => {
setFieldValue("dnsProvider", newValue?.value);
setFieldValue("dnsProviderCredentials", newValue?.credentials);
setFieldValue("meta.dnsProvider", newValue?.value);
setFieldValue("meta.dnsProviderCredentials", newValue?.credentials);
setDnsProviderId(newValue?.value);
};
@@ -34,12 +33,12 @@ export function DNSProviderFields() {
return (
<div className={styles.dnsChallengeWarning}>
<p className="text-danger">
This section requires some knowledge about Certbot and its DNS plugins. Please consult the respective
<p className="text-info">
This section requires some knowledge about Certbot and DNS plugins. Please consult the respective
plugins documentation.
</p>
<Field name="dnsProvider">
<Field name="meta.dnsProvider">
{({ field }: any) => (
<div className="row">
<label htmlFor="dnsProvider" className="form-label">
@@ -64,33 +63,37 @@ export function DNSProviderFields() {
{dnsProviderId ? (
<>
<Field name="dnsProviderCredentials">
<Field name="meta.dnsProviderCredentials">
{({ field }: any) => (
<div className="row mt-3">
<div className="mt-3">
<label htmlFor="dnsProviderCredentials" className="form-label">
Credentials File Content
</label>
<textarea
id="dnsProviderCredentials"
className={cn("form-control", styles.textareaMono)}
className="form-control textareaMono"
rows={3}
spellCheck={false}
value={v.dnsProviderCredentials || ""}
value={v.meta.dnsProviderCredentials || ""}
{...field}
/>
<small className="text-muted">
This plugin requires a configuration file containing an API token or other
credentials to your provider
</small>
<small className="text-danger">
This data will be stored as plaintext in the database and in a file!
</small>
<div>
<small className="text-muted">
This plugin requires a configuration file containing an API token or other
credentials to your provider
</small>
</div>
<div>
<small className="text-danger">
This data will be stored as plaintext in the database and in a file!
</small>
</div>
</div>
)}
</Field>
<Field name="propagationSeconds">
<Field name="meta.propagationSeconds">
{({ field }: any) => (
<div className="row mt-3">
<div className="mt-3">
<label htmlFor="propagationSeconds" className="form-label">
Propagation Seconds
</label>

View File

@@ -2,6 +2,7 @@ import { Field, useFormikContext } from "formik";
import type { ActionMeta, MultiValue } from "react-select";
import CreatableSelect from "react-select/creatable";
import { intl } from "src/locale";
import { validateDomain, validateDomains } from "src/modules/Validations";
export type SelectOption = {
label: string;
@@ -22,17 +23,10 @@ export function DomainNamesField({
label = "domain-names",
id = "domainNames",
maxDomains,
isWildcardPermitted,
dnsProviderWildcardSupported,
isWildcardPermitted = true,
dnsProviderWildcardSupported = true,
}: Props) {
const { values, setFieldValue } = useFormikContext();
const getDomainCount = (v: string[] | undefined): number => {
if (v?.length) {
return v.length;
}
return 0;
};
const { setFieldValue } = useFormikContext();
const handleChange = (v: MultiValue<SelectOption>, _actionMeta: ActionMeta<SelectOption>) => {
const doms = v?.map((i: SelectOption) => {
@@ -41,50 +35,18 @@ export function DomainNamesField({
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 }));
helperTexts.push(intl.formatMessage({ id: "domain-names.max" }, { count: maxDomains }));
}
if (!isWildcardPermitted) {
helperTexts.push(intl.formatMessage({ id: "wildcards-not-permitted" }));
helperTexts.push(intl.formatMessage({ id: "domain-names.wildcards-not-permitted" }));
} else if (!dnsProviderWildcardSupported) {
helperTexts.push(intl.formatMessage({ id: "wildcards-not-supported" }));
helperTexts.push(intl.formatMessage({ id: "domain-names.wildcards-not-supported" }));
}
return (
<Field name={name}>
<Field name={name} validate={validateDomains(isWildcardPermitted && dnsProviderWildcardSupported, maxDomains)}>
{({ field, form }: any) => (
<div className="mb-3">
<label className="form-label" htmlFor={id}>
@@ -97,21 +59,19 @@ export function DomainNamesField({
id={id}
closeMenuOnSelect={true}
isClearable={false}
isValidNewOption={isDomainValid}
isValidNewOption={validateDomain(isWildcardPermitted && dnsProviderWildcardSupported)}
isMulti
placeholder="Start typing to add domain..."
placeholder={intl.formatMessage({ id: "domain-names.placeholder" })}
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>
{form.errors[field.name] && form.touched[field.name] ? (
<small className="text-danger">{form.errors[field.name]}</small>
) : helperTexts.length ? (
helperTexts.map((i) => (
<div key={i} className="invalid-feedback text-info">
<small key={i} className="text-info">
{i}
</div>
</small>
))
) : null}
</div>

View File

@@ -0,0 +1,40 @@
import CodeEditor from "@uiw/react-textarea-code-editor";
import { Field } from "formik";
import { intl } from "src/locale";
interface Props {
id?: string;
name?: string;
label?: string;
}
export function NginxConfigField({
name = "advancedConfig",
label = "nginx-config.label",
id = "advancedConfig",
}: Props) {
return (
<Field name={name}>
{({ field }: any) => (
<div className="mt-3">
<label htmlFor={id} className="form-label">
{intl.formatMessage({ id: label })}
</label>
<CodeEditor
language="nginx"
placeholder={intl.formatMessage({ id: "nginx-config.placeholder" })}
padding={15}
data-color-mode="dark"
minHeight={200}
indentWidth={2}
style={{
fontFamily: "ui-monospace,SFMono-Regular,SF Mono,Consolas,Liberation Mono,Menlo,monospace",
borderRadius: "0.3rem",
minHeight: "200px",
}}
{...field}
/>
</div>
)}
</Field>
);
}

View File

@@ -2,7 +2,7 @@ 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, useUser } from "src/hooks";
import { useCertificates } from "src/hooks";
import { DateTimeFormat, intl } from "src/locale";
interface CertOption {
@@ -39,26 +39,33 @@ export function SSLCertificateField({
required,
allowNew,
}: Props) {
const { data: currentUser } = useUser("me");
const { isLoading, isError, error, data } = useCertificates();
const { values, setFieldValue } = useFormikContext();
const v: any = values || {};
const handleChange = (newValue: any, _actionMeta: ActionMeta<CertOption>) => {
setFieldValue(name, newValue?.value);
const { sslForced, http2Support, hstsEnabled, hstsSubdomains, dnsChallenge, letsencryptEmail } = v;
const {
sslForced,
http2Support,
hstsEnabled,
hstsSubdomains,
dnsChallenge,
dnsProvider,
dnsProviderCredentials,
propagationSeconds,
} = v;
if (!newValue?.value) {
sslForced && setFieldValue("sslForced", false);
http2Support && setFieldValue("http2Support", false);
hstsEnabled && setFieldValue("hstsEnabled", false);
hstsSubdomains && setFieldValue("hstsSubdomains", false);
}
if (newValue?.value === "new") {
if (!letsencryptEmail) {
setFieldValue("letsencryptEmail", currentUser?.email);
}
} else {
dnsChallenge && setFieldValue("dnsChallenge", false);
if (newValue?.value !== "new") {
dnsChallenge && setFieldValue("dnsChallenge", undefined);
dnsProvider && setFieldValue("dnsProvider", undefined);
dnsProviderCredentials && setFieldValue("dnsProviderCredentials", undefined);
propagationSeconds && setFieldValue("propagationSeconds", undefined);
}
};
@@ -105,7 +112,7 @@ export function SSLCertificateField({
<Select
className="react-select-container"
classNamePrefix="react-select"
defaultValue={options[0]}
defaultValue={options.find((o) => o.value === field.value) || options[0]}
options={options}
components={{ Option }}
styles={{

View File

@@ -1,6 +1,7 @@
import cn from "classnames";
import { Field, useFormikContext } from "formik";
import { DNSProviderFields } from "src/components";
import { intl } from "src/locale";
export function SSLOptionsFields() {
const { values, setFieldValue } = useFormikContext();
@@ -8,10 +9,16 @@ export function SSLOptionsFields() {
const newCertificate = v?.certificateId === "new";
const hasCertificate = newCertificate || (v?.certificateId && v?.certificateId > 0);
const { sslForced, http2Support, hstsEnabled, hstsSubdomains, dnsChallenge } = v;
const { sslForced, http2Support, hstsEnabled, hstsSubdomains, meta } = v;
const { dnsChallenge } = meta || {};
const handleToggleChange = (e: any, fieldName: string) => {
setFieldValue(fieldName, e.target.checked);
if (fieldName === "meta.dnsChallenge" && !e.target.checked) {
setFieldValue("meta.dnsProvider", undefined);
setFieldValue("meta.dnsProviderCredentials", undefined);
setFieldValue("meta.propagationSeconds", undefined);
}
};
const toggleClasses = "form-check-input";
@@ -31,7 +38,9 @@ export function SSLOptionsFields() {
onChange={(e) => handleToggleChange(e, field.name)}
disabled={!hasCertificate}
/>
<span className="form-check-label">Force SSL</span>
<span className="form-check-label">
{intl.formatMessage({ id: "domains.force-ssl" })}
</span>
</label>
)}
</Field>
@@ -47,7 +56,9 @@ export function SSLOptionsFields() {
onChange={(e) => handleToggleChange(e, field.name)}
disabled={!hasCertificate}
/>
<span className="form-check-label">HTTP/2 Support</span>
<span className="form-check-label">
{intl.formatMessage({ id: "domains.http2-support" })}
</span>
</label>
)}
</Field>
@@ -65,7 +76,9 @@ export function SSLOptionsFields() {
onChange={(e) => handleToggleChange(e, field.name)}
disabled={!hasCertificate || !sslForced}
/>
<span className="form-check-label">HSTS Enabled</span>
<span className="form-check-label">
{intl.formatMessage({ id: "domains.hsts-enabled" })}
</span>
</label>
)}
</Field>
@@ -81,7 +94,9 @@ export function SSLOptionsFields() {
onChange={(e) => handleToggleChange(e, field.name)}
disabled={!hasCertificate || !hstsEnabled}
/>
<span className="form-check-label">HSTS Enabled</span>
<span className="form-check-label">
{intl.formatMessage({ id: "domains.hsts-subdomains" })}
</span>
</label>
)}
</Field>
@@ -89,7 +104,7 @@ export function SSLOptionsFields() {
</div>
{newCertificate ? (
<>
<Field name="dnsChallenge">
<Field name="meta.dnsChallenge">
{({ field }: any) => (
<label className="form-check form-switch mt-1">
<input
@@ -98,29 +113,14 @@ export function SSLOptionsFields() {
checked={!!dnsChallenge}
onChange={(e) => handleToggleChange(e, field.name)}
/>
<span className="form-check-label">Use a DNS Challenge</span>
<span className="form-check-label">
{intl.formatMessage({ id: "domains.use-dns" })}
</span>
</label>
)}
</Field>
{dnsChallenge ? <DNSProviderFields /> : null}
<Field name="letsencryptEmail">
{({ field }: any) => (
<div className="mt-5">
<label htmlFor="letsencryptEmail" className="form-label">
Email Address for Let's Encrypt
</label>
<input
id="letsencryptEmail"
type="email"
className="form-control"
required
{...field}
/>
</div>
)}
</Field>
</>
) : null}
</>

View File

@@ -1,4 +1,5 @@
export * from "./DNSProviderFields";
export * from "./DomainNamesField";
export * from "./NginxConfigField";
export * from "./SSLCertificateField";
export * from "./SSLOptionsFields";

View File

@@ -50,10 +50,23 @@
"dead-hosts.title": "404 Hosts",
"disabled": "Disabled",
"domain-names": "Domain Names",
"domain-names.max": "{count} domain names maximum",
"domain-names.placeholder": "Start typing to add domain...",
"domain-names.wildcards-not-permitted": "Wildcards not permitted for this type",
"domain-names.wildcards-not-supported": "Wildcards not supported for this CA",
"domains.force-ssl": "Force SSL",
"domains.hsts-enabled": "HSTS Enabled",
"domains.hsts-subdomains": "HSTS Sub-domains",
"domains.http2-support": "HTTP/2 Support",
"domains.use-dns": "Use DNS Challenge",
"email-address": "Email address",
"empty-subtitle": "Why don't you create one?",
"error.invalid-auth": "Invalid email or password",
"error.invalid-domain": "Invalid domain: {domain}",
"error.invalid-email": "Invalid email address",
"error.max-domains": "Too many domains, max is {max}",
"error.passwords-must-match": "Passwords must match",
"error.required": "This is required",
"event.created-user": "Created User",
"event.deleted-user": "Deleted User",
"event.updated-user": "Updated User",
@@ -63,10 +76,13 @@
"lets-encrypt": "Let's Encrypt",
"loading": "Loading…",
"login.title": "Login to your account",
"nginx-config.label": "Custom Nginx Configuration",
"nginx-config.placeholder": "# Enter your custom Nginx configuration here at your own risk!",
"no-permission-error": "You do not have access to view this.",
"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.dead-host-saved": "404 Host has been saved",
"notification.error": "Error",
"notification.success": "Success",
"notification.user-deleted": "User has been deleted",
@@ -127,7 +143,5 @@
"user.switch-light": "Switch to Light mode",
"users.actions-title": "User #{id}",
"users.add": "Add User",
"users.title": "Users",
"wildcards-not-permitted": "Wildcards not permitted for this type",
"wildcards-not-supported": "Wildcards not supported for this CA"
"users.title": "Users"
}

View File

@@ -152,6 +152,33 @@
"domain-names": {
"defaultMessage": "Domain Names"
},
"domain-names.max": {
"defaultMessage": "{count} domain names maximum"
},
"domain-names.placeholder": {
"defaultMessage": "Start typing to add domain..."
},
"domain-names.wildcards-not-permitted": {
"defaultMessage": "Wildcards not permitted for this type"
},
"domain-names.wildcards-not-supported": {
"defaultMessage": "Wildcards not supported for this CA"
},
"domains.force-ssl": {
"defaultMessage": "Force SSL"
},
"domains.hsts-enabled": {
"defaultMessage": "HSTS Enabled"
},
"domains.hsts-subdomains": {
"defaultMessage": "HSTS Sub-domains"
},
"domains.http2-support": {
"defaultMessage": "HTTP/2 Support"
},
"domains.use-dns": {
"defaultMessage": "Use DNS Challenge"
},
"email-address": {
"defaultMessage": "Email address"
},
@@ -161,6 +188,18 @@
"error.invalid-auth": {
"defaultMessage": "Invalid email or password"
},
"error.invalid-domain": {
"defaultMessage": "Invalid domain: {domain}"
},
"error.invalid-email": {
"defaultMessage": "Invalid email address"
},
"error.max-domains": {
"defaultMessage": "Too many domains, max is {max}"
},
"error.required": {
"defaultMessage": "This is required"
},
"event.created-user": {
"defaultMessage": "Created User"
},
@@ -191,6 +230,12 @@
"login.title": {
"defaultMessage": "Login to your account"
},
"nginx-config.label": {
"defaultMessage": "Custom Nginx Configuration"
},
"nginx-config.placeholder": {
"defaultMessage": "# Enter your custom Nginx configuration here at your own risk!"
},
"no-permission-error": {
"defaultMessage": "You do not have access to view this."
},
@@ -203,6 +248,9 @@
"notfound.title": {
"defaultMessage": "Oops… You just found an error page"
},
"notification.dead-host-saved": {
"defaultMessage": "404 Host has been saved"
},
"notification.error": {
"defaultMessage": "Error"
},
@@ -385,11 +433,5 @@
},
"users.title": {
"defaultMessage": "Users"
},
"wildcards-not-permitted": {
"defaultMessage": "Wildcards not permitted for this type"
},
"wildcards-not-supported": {
"defaultMessage": "Wildcards not supported for this CA"
}
}

View File

@@ -13,6 +13,7 @@ interface Props {
}
export function ChangePasswordModal({ userId, onClose }: Props) {
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const onSubmit = async (values: any, { setSubmitting }: any) => {
if (values.new !== values.confirm) {
@@ -20,13 +21,18 @@ export function ChangePasswordModal({ userId, onClose }: Props) {
setSubmitting(false);
return;
}
if (isSubmitting) return;
setIsSubmitting(true);
setError(null);
try {
await updateAuth(userId, values.new, values.current);
onClose();
} catch (err: any) {
setError(intl.formatMessage({ id: err.message }));
}
setIsSubmitting(false);
setSubmitting(false);
};
@@ -42,7 +48,7 @@ export function ChangePasswordModal({ userId, onClose }: Props) {
}
onSubmit={onSubmit}
>
{({ isSubmitting }) => (
{() => (
<Form>
<Modal.Header closeButton>
<Modal.Title>{intl.formatMessage({ id: "user.change-password" })}</Modal.Title>

View File

@@ -3,9 +3,17 @@ import { Form, Formik } from "formik";
import { useState } from "react";
import { Alert } from "react-bootstrap";
import Modal from "react-bootstrap/Modal";
import { Button, DomainNamesField, Loading, SSLCertificateField, SSLOptionsFields } from "src/components";
import { useDeadHost } from "src/hooks";
import {
Button,
DomainNamesField,
Loading,
NginxConfigField,
SSLCertificateField,
SSLOptionsFields,
} from "src/components";
import { useDeadHost, useSetDeadHost } from "src/hooks";
import { intl } from "src/locale";
import { showSuccess } from "src/notifications";
interface Props {
id: number | "new";
@@ -13,28 +21,31 @@ interface Props {
}
export function DeadHostModal({ id, onClose }: Props) {
const { data, isLoading, error } = useDeadHost(id);
// const { mutate: setDeadHost } = useSetDeadHost();
const { mutate: setDeadHost } = useSetDeadHost();
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const onSubmit = async (values: any, { setSubmitting }: any) => {
setSubmitting(true);
if (isSubmitting) return;
setIsSubmitting(true);
setErrorMsg(null);
console.log("SUBMIT:", values);
setSubmitting(false);
// const { ...payload } = {
// id: id === "new" ? undefined : id,
// roles: [],
// ...values,
// };
// setDeadHost(payload, {
// onError: (err: any) => setErrorMsg(err.message),
// onSuccess: () => {
// showSuccess(intl.formatMessage({ id: "notification.dead-host-saved" }));
// onClose();
// },
// onSettled: () => setSubmitting(false),
// });
const { ...payload } = {
id: id === "new" ? undefined : id,
...values,
};
setDeadHost(payload, {
onError: (err: any) => setErrorMsg(err.message),
onSuccess: () => {
showSuccess(intl.formatMessage({ id: "notification.dead-host-saved" }));
onClose();
},
onSettled: () => {
setIsSubmitting(false);
setSubmitting(false);
},
});
};
return (
@@ -56,11 +67,12 @@ export function DeadHostModal({ id, onClose }: Props) {
http2Support: data?.http2Support,
hstsEnabled: data?.hstsEnabled,
hstsSubdomains: data?.hstsSubdomains,
meta: data?.meta || {},
} as any
}
onSubmit={onSubmit}
>
{({ isSubmitting }) => (
{() => (
<Form>
<Modal.Header closeButton>
<Modal.Title>
@@ -127,140 +139,11 @@ export function DeadHostModal({ id, onClose }: Props) {
<SSLOptionsFields />
</div>
<div className="tab-pane" id="tab-advanced" role="tabpanel">
<h4>Advanced</h4>
<NginxConfigField />
</div>
</div>
</div>
</div>
{/* <div className="row">
<div className="col-lg-6">
<div className="mb-3">
<Field name="name" validate={validateString(1, 50)}>
{({ field, form }: any) => (
<div className="form-floating mb-3">
<input
id="name"
className={`form-control ${form.errors.name && form.touched.name ? "is-invalid" : ""}`}
placeholder={intl.formatMessage({ id: "user.full-name" })}
{...field}
/>
<label htmlFor="name">
{intl.formatMessage({ id: "user.full-name" })}
</label>
{form.errors.name ? (
<div className="invalid-feedback">
{form.errors.name && form.touched.name
? form.errors.name
: null}
</div>
) : null}
</div>
)}
</Field>
</div>
</div>
<div className="col-lg-6">
<div className="mb-3">
<Field name="nickname" validate={validateString(1, 30)}>
{({ field, form }: any) => (
<div className="form-floating mb-3">
<input
id="nickname"
className={`form-control ${form.errors.nickname && form.touched.nickname ? "is-invalid" : ""}`}
placeholder={intl.formatMessage({ id: "user.nickname" })}
{...field}
/>
<label htmlFor="nickname">
{intl.formatMessage({ id: "user.nickname" })}
</label>
{form.errors.nickname ? (
<div className="invalid-feedback">
{form.errors.nickname && form.touched.nickname
? form.errors.nickname
: null}
</div>
) : null}
</div>
)}
</Field>
</div>
</div>
</div>
<div className="mb-3">
<Field name="email" validate={validateEmail()}>
{({ field, form }: any) => (
<div className="form-floating mb-3">
<input
id="email"
type="email"
className={`form-control ${form.errors.email && form.touched.email ? "is-invalid" : ""}`}
placeholder={intl.formatMessage({ id: "email-address" })}
{...field}
/>
<label htmlFor="email">
{intl.formatMessage({ id: "email-address" })}
</label>
{form.errors.email ? (
<div className="invalid-feedback">
{form.errors.email && form.touched.email
? form.errors.email
: null}
</div>
) : null}
</div>
)}
</Field>
</div>
{currentUser && data && currentUser?.id !== data?.id ? (
<div className="my-3">
<h3 className="py-2">{intl.formatMessage({ id: "user.flags.title" })}</h3>
<div className="divide-y">
<div>
<label className="row" htmlFor="isAdmin">
<span className="col">
{intl.formatMessage({ id: "role.admin" })}
</span>
<span className="col-auto">
<Field name="isAdmin" type="checkbox">
{({ field }: any) => (
<label className="form-check form-check-single form-switch">
<input
{...field}
id="isAdmin"
className="form-check-input"
type="checkbox"
/>
</label>
)}
</Field>
</span>
</label>
</div>
<div>
<label className="row" htmlFor="isDisabled">
<span className="col">
{intl.formatMessage({ id: "disabled" })}
</span>
<span className="col-auto">
<Field name="isDisabled" type="checkbox">
{({ field }: any) => (
<label className="form-check form-check-single form-switch">
<input
{...field}
id="isDisabled"
className="form-check-input"
type="checkbox"
/>
</label>
)}
</Field>
</span>
</label>
</div>
</div>
</div>
) : null} */}
</Modal.Body>
<Modal.Footer>
<Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}>

View File

@@ -15,10 +15,11 @@ interface Props {
export function DeleteConfirmModal({ title, children, onConfirm, onClose, invalidations }: Props) {
const queryClient = useQueryClient();
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const onSubmit = async () => {
setSubmitting(true);
if (isSubmitting) return;
setIsSubmitting(true);
setError(null);
try {
await onConfirm();
@@ -30,7 +31,7 @@ export function DeleteConfirmModal({ title, children, onConfirm, onClose, invali
} catch (err: any) {
setError(intl.formatMessage({ id: err.message }));
}
setSubmitting(false);
setIsSubmitting(false);
};
return (
@@ -45,7 +46,7 @@ export function DeleteConfirmModal({ title, children, onConfirm, onClose, invali
{children}
</Modal.Body>
<Modal.Footer>
<Button data-bs-dismiss="modal" onClick={onClose} disabled={submitting}>
<Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}>
{intl.formatMessage({ id: "cancel" })}
</Button>
<Button
@@ -53,8 +54,8 @@ export function DeleteConfirmModal({ title, children, onConfirm, onClose, invali
actionType="primary"
className="ms-auto btn-red"
data-bs-dismiss="modal"
isLoading={submitting}
disabled={submitting}
isLoading={isSubmitting}
disabled={isSubmitting}
onClick={onSubmit}
>
{intl.formatMessage({ id: "action.delete" })}

View File

@@ -17,8 +17,11 @@ export function PermissionsModal({ userId, onClose }: Props) {
const queryClient = useQueryClient();
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const { data, isLoading, error } = useUser(userId);
const [isSubmitting, setIsSubmitting] = useState(false);
const onSubmit = async (values: any, { setSubmitting }: any) => {
if (isSubmitting) return;
setIsSubmitting(true);
setErrorMsg(null);
try {
await setPermissions(userId, values);
@@ -29,6 +32,7 @@ export function PermissionsModal({ userId, onClose }: Props) {
setErrorMsg(intl.formatMessage({ id: err.message }));
}
setSubmitting(false);
setIsSubmitting(false);
};
const getPermissionButtons = (field: any, form: any) => {
@@ -104,7 +108,7 @@ export function PermissionsModal({ userId, onClose }: Props) {
}
onSubmit={onSubmit}
>
{({ isSubmitting }) => (
{() => (
<Form>
<Modal.Header closeButton>
<Modal.Title>

View File

@@ -15,8 +15,10 @@ interface Props {
export function SetPasswordModal({ userId, onClose }: Props) {
const [error, setError] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const onSubmit = async (values: any, { setSubmitting }: any) => {
if (isSubmitting) return;
setError(null);
try {
await updateAuth(userId, values.new);
@@ -24,6 +26,7 @@ export function SetPasswordModal({ userId, onClose }: Props) {
} catch (err: any) {
setError(intl.formatMessage({ id: err.message }));
}
setIsSubmitting(false);
setSubmitting(false);
};
@@ -37,7 +40,7 @@ export function SetPasswordModal({ userId, onClose }: Props) {
}
onSubmit={onSubmit}
>
{({ isSubmitting }) => (
{() => (
<Form>
<Modal.Header closeButton>
<Modal.Title>{intl.formatMessage({ id: "user.set-password" })}</Modal.Title>

View File

@@ -17,9 +17,13 @@ export function UserModal({ userId, onClose }: Props) {
const { data: currentUser, isLoading: currentIsLoading } = useUser("me");
const { mutate: setUser } = useSetUser();
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const onSubmit = async (values: any, { setSubmitting }: any) => {
if (isSubmitting) return;
setIsSubmitting(true);
setErrorMsg(null);
const { ...payload } = {
id: userId === "new" ? undefined : userId,
roles: [],
@@ -43,7 +47,10 @@ export function UserModal({ userId, onClose }: Props) {
showSuccess(intl.formatMessage({ id: "notification.user-saved" }));
onClose();
},
onSettled: () => setSubmitting(false),
onSettled: () => {
setIsSubmitting(false);
setSubmitting(false);
},
});
};
@@ -68,7 +75,7 @@ export function UserModal({ userId, onClose }: Props) {
}
onSubmit={onSubmit}
>
{({ isSubmitting }) => (
{() => (
<Form>
<Modal.Header closeButton>
<Modal.Title>

View File

@@ -1,3 +1,5 @@
import { intl } from "src/locale";
const validateString = (minLength = 0, maxLength = 0) => {
if (minLength <= 0 && maxLength <= 0) {
// this doesn't require translation
@@ -6,12 +8,14 @@ const validateString = (minLength = 0, maxLength = 0) => {
return (value: string): string | undefined => {
if (minLength && (typeof value === "undefined" || !value.length)) {
return "This is required";
return intl.formatMessage({ id: "error.required" });
}
if (minLength && value.length < minLength) {
// TODO: i18n
return `Minimum length is ${minLength} character${minLength === 1 ? "" : "s"}`;
}
if (maxLength && (typeof value === "undefined" || value.length > maxLength)) {
// TODO: i18n
return `Maximum length is ${maxLength} character${maxLength === 1 ? "" : "s"}`;
}
};
@@ -26,12 +30,14 @@ const validateNumber = (min = -1, max = -1) => {
return (value: string): string | undefined => {
const int: number = +value;
if (min > -1 && !int) {
return "This is required";
return intl.formatMessage({ id: "error.required" });
}
if (min > -1 && int < min) {
// TODO: i18n
return `Minimum is ${min}`;
}
if (max > -1 && int > max) {
// TODO: i18n
return `Maximum is ${max}`;
}
};
@@ -40,12 +46,62 @@ const validateNumber = (min = -1, max = -1) => {
const validateEmail = () => {
return (value: string): string | undefined => {
if (!value.length) {
return "This is required";
return intl.formatMessage({ id: "error.required" });
}
if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(value)) {
return "Invalid email address";
return intl.formatMessage({ id: "error.invalid-email" });
}
};
};
export { validateEmail, validateNumber, validateString };
const validateDomain = (allowWildcards = false) => {
return (d: string): boolean => {
const dom = d.trim().toLowerCase();
if (dom.length < 3) {
return false;
}
// Prevent wildcards
if (!allowWildcards && 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 validateDomains = (allowWildcards = false, maxDomains?: number) => {
const vDom = validateDomain(allowWildcards);
return (value: string[]): string | undefined => {
if (!value.length) {
return intl.formatMessage({ id: "error.required" });
}
// Deny if the list of domains is hit
if (maxDomains && value.length >= maxDomains) {
return intl.formatMessage({ id: "error.max-domains" }, { max: maxDomains });
}
// validate each domain
for (let i = 0; i < value.length; i++) {
if (!vDom(value[i])) {
return intl.formatMessage({ id: "error.invalid-domain" }, { domain: value[i] });
}
}
};
};
export { validateEmail, validateNumber, validateString, validateDomains, validateDomain };

View File

@@ -129,6 +129,7 @@ const Dashboard = () => {
- fix bad jwt not refreshing entire page
- add help docs for host types
- REDO SCREENSHOTS in docs folder
- Remove letsEncryptEmail field from new certificate requests, use current user email server side
More for api, then implement here:
- Properly implement refresh tokens

View File

@@ -10,10 +10,11 @@ import Empty from "./Empty";
interface Props {
data: DeadHost[];
isFetching?: boolean;
onEdit?: (id: number) => void;
onDelete?: (id: number) => void;
onNew?: () => void;
}
export default function Table({ data, isFetching, onDelete, onNew }: Props) {
export default function Table({ data, isFetching, onEdit, onDelete, onNew }: Props) {
const columnHelper = createColumnHelper<DeadHost>();
const columns = useMemo(
() => [
@@ -71,7 +72,14 @@ export default function Table({ data, isFetching, onDelete, onNew }: Props) {
{ 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" })}
</a>
@@ -100,7 +108,7 @@ export default function Table({ data, isFetching, onDelete, onNew }: Props) {
},
}),
],
[columnHelper, onDelete],
[columnHelper, onDelete, onEdit],
);
const tableInstance = useReactTable<DeadHost>({

View File

@@ -58,6 +58,7 @@ export default function TableWrapper() {
<Table
data={data ?? []}
isFetching={isFetching}
onEdit={(id: number) => setEditId(id)}
onDelete={(id: number) => setDeleteId(id)}
onNew={() => setEditId("new")}
/>