diff --git a/app/hooks/use-form.ts b/app/hooks/use-form.ts new file mode 100644 index 0000000..d8e57db --- /dev/null +++ b/app/hooks/use-form.ts @@ -0,0 +1,181 @@ +import { type, type Type } from "arktype"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +type FormValue = string | number | boolean | null; + +interface FormState { + values: Record; + errors: Record; + modified: Record; +} + +export interface FormFieldProps { + name: string; + value: string; + onChange: (value: string) => void; + onBlur: () => void; + invalid: boolean; + errorMessage?: string; +} + +interface UseFormOptions> { + schema: Type; + defaultValues?: Partial>; + actionData?: unknown; + validate?: (values: Record) => Record | undefined; +} + +// arktype's Type doesn't expose `.props` in the type system, +// but it exists at runtime. This extracts schema keys safely. +function schemaKeys(schema: Type): string[] { + const props = (schema as unknown as { props?: { key: string }[] }).props; + return props?.map((p) => p.key) ?? []; +} + +export function useForm>(options: UseFormOptions) { + const { schema, actionData, validate } = options; + const keys = schemaKeys(schema); + const validateRef = useRef(validate); + validateRef.current = validate; + + const initialValues = useMemo(() => { + const values: Record = {}; + for (const key of keys) { + values[key] = + options.defaultValues && key in options.defaultValues + ? (options.defaultValues[key as keyof T & string] as FormValue) + : ""; + } + return values; + }, []); + + const [state, setState] = useState({ + values: initialValues, + errors: {}, + modified: {}, + }); + + useEffect(() => { + if (actionData && typeof actionData === "object" && "errors" in actionData) { + const errors = actionData.errors as Record; + setState((prev) => ({ + ...prev, + errors: { ...prev.errors, ...errors }, + })); + } + }, [actionData]); + + const setValue = useCallback((name: keyof T & string, raw: FormValue) => { + setState((prev) => ({ + ...prev, + values: { ...prev.values, [name]: raw }, + modified: { ...prev.modified, [name]: true }, + })); + }, []); + + function validateField(name: string) { + if (!state.modified[name]) { + return; + } + + const errors: Record = {}; + + // Schema validation + const result = schema({ ...state.values } as never); + if (result instanceof type.errors) { + const fieldProblems = result.flatProblemsByPath[name]; + if (fieldProblems) { + errors[name] = fieldProblems[0]; + } + } + + // Cross-field validation + if (!errors[name]) { + const customErrors = validateRef.current?.(state.values); + if (customErrors?.[name]) { + errors[name] = customErrors[name]; + } + } + + setState((prev) => ({ + ...prev, + errors: { + ...prev.errors, + [name]: errors[name] ?? "", + }, + })); + } + + function field(name: keyof T & string): FormFieldProps { + const error = state.errors[name]; + return { + name, + value: String(state.values[name] ?? ""), + onChange: (value: string) => setValue(name, value), + onBlur: () => validateField(name), + invalid: !!error, + errorMessage: error || undefined, + }; + } + + function computeCanSubmit(): boolean { + const result = schema({ ...state.values } as never); + if (result instanceof type.errors) { + return false; + } + + const customErrors = validateRef.current?.(state.values); + if (customErrors && Object.values(customErrors).some(Boolean)) { + return false; + } + + return true; + } + + const reset = useCallback(() => { + setState({ + values: { ...initialValues }, + errors: {}, + modified: {}, + }); + }, [initialValues]); + + return { + values: state.values, + errors: state.errors, + field, + canSubmit: computeCanSubmit(), + setValue, + reset, + }; +} + +type ServerValidationResult = + | { success: true; data: T } + | { success: false; errors: Record }; + +export async function validateFormData>( + request: Request, + schema: Type, +): Promise> { + const formData = await request.formData(); + const parsed: Record = {}; + + for (const key of schemaKeys(schema)) { + const raw = formData.get(key); + if (raw !== null) { + parsed[key] = raw.toString(); + } + } + + const result = schema(parsed as never); + if (result instanceof type.errors) { + const errors: Record = {}; + for (const [path, problems] of Object.entries(result.flatProblemsByPath)) { + errors[path] = problems[0]; + } + return { success: false, errors }; + } + + return { success: true, data: result as T }; +} diff --git a/app/routes/dns/dialogs/add-ns.tsx b/app/routes/dns/dialogs/add-ns.tsx index 985d0ed..23c7212 100644 --- a/app/routes/dns/dialogs/add-ns.tsx +++ b/app/routes/dns/dialogs/add-ns.tsx @@ -1,5 +1,5 @@ +import { type } from "arktype"; import { Split } from "lucide-react"; -import { useMemo, useState } from "react"; import Button from "~/components/button"; import Chip from "~/components/chip"; @@ -9,30 +9,40 @@ import Switch from "~/components/switch"; import Text from "~/components/text"; import Title from "~/components/title"; import Tooltip from "~/components/tooltip"; +import { useForm } from "~/hooks/use-form"; import cn from "~/utils/cn"; +const nsSchema = type({ + ns: "string.ip", + split_name: "string > 0", +}); + interface Props { nameservers: Record; } export default function AddNameserver({ nameservers }: Props) { - const [split, setSplit] = useState(false); - const [ns, setNs] = useState(""); - const [domain, setDomain] = useState(""); + const form = useForm({ + schema: nsSchema, + defaultValues: { split_name: "global" }, + validate: (values) => { + const ns = values.ns as string; + const domain = values.split_name as string; + if (!ns) return undefined; - const isInvalid = useMemo(() => { - if (ns === "") return false; - // Test if it's a valid IPv4 or IPv6 address - const ipv4 = /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/; - const ipv6 = /^([0-9a-fA-F:]+:+)+[0-9a-fA-F]+$/; - if (!ipv4.test(ns) && !ipv6.test(ns)) return true; + const isSplit = domain !== "global"; + const isDuplicate = isSplit + ? nameservers[domain]?.includes(ns) + : Object.values(nameservers).some((nsList) => nsList.includes(ns)); - if (split) { - return nameservers[domain]?.includes(ns); - } + if (isDuplicate) { + return { ns: "This nameserver already exists." }; + } - return Object.values(nameservers).some((nsList) => nsList.includes(ns)); - }, [nameservers, ns]); + return undefined; + }, + }); + const split = (form.values.split_name as string) !== "global"; return ( @@ -41,12 +51,10 @@ export default function AddNameserver({ nameservers }: Props) { Add nameserver
@@ -67,16 +75,20 @@ export default function AddNameserver({ nameservers }: Props) {
This nameserver will only be used for some domains. - + { + form.setValue("split_name", checked ? "" : "global"); + }} + /> {split ? ( <> Domain diff --git a/app/routes/dns/dialogs/add-record.tsx b/app/routes/dns/dialogs/add-record.tsx index dec86fd..a8b0c1f 100644 --- a/app/routes/dns/dialogs/add-record.tsx +++ b/app/routes/dns/dialogs/add-record.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { type } from "arktype"; import Button from "~/components/button"; import Code from "~/components/code"; @@ -7,33 +7,49 @@ import Input from "~/components/input"; import Select from "~/components/select"; import Text from "~/components/text"; import Title from "~/components/title"; +import { useForm } from "~/hooks/use-form"; + +const recordSchema = type({ + record_type: "'A' | 'AAAA'", + record_name: "string > 0", + record_value: "string > 0", +}); interface Props { records: { name: string; type: "A" | "AAAA" | string; value: string }[]; } export default function AddRecord({ records }: Props) { - const [type, setType] = useState<"A" | "AAAA" | string>("A"); - const [name, setName] = useState(""); - const [ip, setIp] = useState(""); + const form = useForm({ + schema: recordSchema, + defaultValues: { record_type: "A" }, + validate: (values) => { + const name = values.record_name as string; + const ip = values.record_value as string; + if (name.length === 0 || ip.length === 0) return undefined; - const isDuplicate = useMemo(() => { - if (name.length === 0 || ip.length === 0) return false; - const lookup = records.find((record) => record.name === name); - if (!lookup) return false; + const lookup = records.find((r) => r.name === name); + if (lookup?.value === ip) { + return { + record_name: "This record already exists.", + record_value: "This record already exists.", + }; + } - return lookup.value === ip; - }, [records, name, ip]); + return undefined; + }, + }); + const name = form.values.record_name as string; + const ip = form.values.record_value as string; + const recordType = form.values.record_type as string; + const isDuplicate = + !!form.errors.record_name?.includes("already exists") && + !!form.errors.record_value?.includes("already exists"); return ( - { - setName(""); - setIp(""); - }} - > + form.reset()}> Add DNS record Enter the domain and IP address for the new DNS record.
@@ -42,9 +58,9 @@ export default function AddRecord({ records }: Props) { required label="Record Type" name="record_type" - defaultValue={type} + defaultValue={recordType} onValueChange={(v) => { - if (v) setType(v as "A" | "AAAA"); + if (v) form.setValue("record_type", v); }} items={[ { value: "A", label: "A" }, @@ -52,20 +68,16 @@ export default function AddRecord({ records }: Props) { ]} /> {isDuplicate ? (

diff --git a/app/routes/machines/dialogs/new.tsx b/app/routes/machines/dialogs/new.tsx index 2555dd8..3f5ebd8 100644 --- a/app/routes/machines/dialogs/new.tsx +++ b/app/routes/machines/dialogs/new.tsx @@ -1,3 +1,4 @@ +import { type } from "arktype"; import { Computer, FileKey2 } from "lucide-react"; import { useState } from "react"; import { useNavigate } from "react-router"; @@ -9,9 +10,15 @@ import { Menu, MenuContent, MenuItem, MenuTrigger } from "~/components/menu"; import Select from "~/components/select"; import Text from "~/components/text"; import Title from "~/components/title"; +import { useForm } from "~/hooks/use-form"; import type { User } from "~/types"; import { getUserDisplayName } from "~/utils/user"; +const registerSchema = type({ + register_key: "string == 24", + user: "string > 0", +}); + export interface NewMachineProps { server: string; users: User[]; @@ -21,15 +28,13 @@ export interface NewMachineProps { export default function NewMachine(data: NewMachineProps) { const [pushDialog, setPushDialog] = useState(false); - const [mkey, setMkey] = useState(""); + const form = useForm({ schema: registerSchema }); const navigate = useNavigate(); - const isMkeyInvalid = mkey.length > 0 && mkey.length !== 24; - return ( <>

- + Register Machine Key The machine key is given when you run{" "} @@ -37,18 +42,16 @@ export default function NewMachine(data: NewMachineProps) { - + {magic ? ( name.length > 0 && name !== machine.givenName ? (

diff --git a/app/routes/settings/restrictions/dialogs/add-domain.tsx b/app/routes/settings/restrictions/dialogs/add-domain.tsx index 64efb46..9a54b36 100644 --- a/app/routes/settings/restrictions/dialogs/add-domain.tsx +++ b/app/routes/settings/restrictions/dialogs/add-domain.tsx @@ -1,10 +1,15 @@ -import { useMemo, useState } from "react"; +import { type } from "arktype"; import Button from "~/components/button"; import Dialog, { DialogPanel } from "~/components/dialog"; import Input from "~/components/input"; import Text from "~/components/text"; import Title from "~/components/title"; +import { useForm } from "~/hooks/use-form"; + +const domainSchema = type({ + domain: "string > 0", +}); interface AddDomainProps { domains: string[]; @@ -12,27 +17,29 @@ interface AddDomainProps { } export default function AddDomain({ domains, isDisabled }: AddDomainProps) { - const [domain, setDomain] = useState(""); + const form = useForm({ + schema: domainSchema, + validate: (values) => { + const domain = (values.domain as string).trim(); + if (domain.length === 0) return undefined; - const isInvalid = useMemo(() => { - if (!domain || domain.trim().length === 0) { - // Empty domain is invalid, but no error shown - return false; - } + if (domains.includes(domain)) { + return { domain: "This domain already exists in the list." }; + } - if (domains.includes(domain.trim())) { - return true; - } + try { + const url = new URL(`http://${domain}`); + if (url.hostname !== domain) { + return { domain: "This is not a valid domain." }; + } + } catch { + return { domain: "This is not a valid domain." }; + } - try { - // Check if domain is a valid FQDN - const url = new URL(`http://${domain.trim()}`); - return url.hostname !== domain.trim(); - } catch (e) { - // If URL constructor fails, it's not a valid domain - return true; - } - }, [domain, domains]); + return undefined; + }, + }); + const domain = (form.values.domain as string).trim(); return (

@@ -45,23 +52,16 @@ export default function AddDomain({ domains, isDisabled }: AddDomainProps) { 0 - ? `Matches users with @${domain.trim()}` + domain.length > 0 + ? `Matches users with @${domain}` : "Enter a domain to match users with their email addresses." } - invalid={domain.trim().length === 0 || isInvalid} required label="Domain" - name="domain" - onChange={setDomain} placeholder="example.com" /> - {isInvalid && ( -

- The domain you entered is invalid or already exists in the list. -

- )}
); diff --git a/app/routes/settings/restrictions/dialogs/add-group.tsx b/app/routes/settings/restrictions/dialogs/add-group.tsx index 415cb55..e831b33 100644 --- a/app/routes/settings/restrictions/dialogs/add-group.tsx +++ b/app/routes/settings/restrictions/dialogs/add-group.tsx @@ -1,10 +1,15 @@ -import { useMemo, useState } from "react"; +import { type } from "arktype"; import Button from "~/components/button"; import Dialog, { DialogPanel } from "~/components/dialog"; import Input from "~/components/input"; import Text from "~/components/text"; import Title from "~/components/title"; +import { useForm } from "~/hooks/use-form"; + +const groupSchema = type({ + group: "string > 0", +}); interface AddGroupProps { groups: string[]; @@ -12,18 +17,19 @@ interface AddGroupProps { } export default function AddGroup({ groups, isDisabled }: AddGroupProps) { - const [group, setGroup] = useState(""); + const form = useForm({ + schema: groupSchema, + validate: (values) => { + const group = (values.group as string).trim(); + if (group.length === 0) return undefined; - const isInvalid = useMemo(() => { - if (!group || group.trim().length === 0) { - // Empty group is invalid, but no error shown - return false; - } + if (groups.includes(group)) { + return { group: "This group already exists in the list." }; + } - if (groups.includes(group.trim())) { - return true; - } - }, [group, groups]); + return undefined; + }, + }); return ( @@ -35,19 +41,12 @@ export default function AddGroup({ groups, isDisabled }: AddGroupProps) { - {isInvalid && ( -

- The group you entered already exists in the list of allowed groups. -

- )}
); diff --git a/app/routes/settings/restrictions/dialogs/add-user.tsx b/app/routes/settings/restrictions/dialogs/add-user.tsx index d31c9b0..1df1fd6 100644 --- a/app/routes/settings/restrictions/dialogs/add-user.tsx +++ b/app/routes/settings/restrictions/dialogs/add-user.tsx @@ -1,10 +1,15 @@ -import { useMemo, useState } from "react"; +import { type } from "arktype"; import Button from "~/components/button"; import Dialog, { DialogPanel } from "~/components/dialog"; import Input from "~/components/input"; import Text from "~/components/text"; import Title from "~/components/title"; +import { useForm } from "~/hooks/use-form"; + +const userSchema = type({ + user: "string > 0", +}); interface AddUserProps { users: string[]; @@ -12,18 +17,19 @@ interface AddUserProps { } export default function AddUser({ users, isDisabled }: AddUserProps) { - const [user, setUser] = useState(""); + const form = useForm({ + schema: userSchema, + validate: (values) => { + const user = (values.user as string).trim(); + if (user.length === 0) return undefined; - const isInvalid = useMemo(() => { - if (!user || user.trim().length === 0) { - // Empty user is invalid, but no error shown - return false; - } + if (users.includes(user)) { + return { user: "This user already exists in the list." }; + } - if (users.includes(user.trim())) { - return true; - } - }, [user, users]); + return undefined; + }, + }); return ( @@ -35,19 +41,12 @@ export default function AddUser({ users, isDisabled }: AddUserProps) { - {isInvalid && ( -

- The user you entered already exists in the list of allowed users. -

- )}
);