mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 07:48:14 +00:00
feat: switch to a new form hook
This commit is contained in:
@@ -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<string, FormValue>;
|
||||
errors: Record<string, string>;
|
||||
modified: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface FormFieldProps {
|
||||
name: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onBlur: () => void;
|
||||
invalid: boolean;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
interface UseFormOptions<T extends Record<string, unknown>> {
|
||||
schema: Type<T>;
|
||||
defaultValues?: Partial<Record<keyof T & string, FormValue>>;
|
||||
actionData?: unknown;
|
||||
validate?: (values: Record<string, FormValue>) => Record<string, string> | undefined;
|
||||
}
|
||||
|
||||
// arktype's Type<T> 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<T extends Record<string, unknown>>(options: UseFormOptions<T>) {
|
||||
const { schema, actionData, validate } = options;
|
||||
const keys = schemaKeys(schema);
|
||||
const validateRef = useRef(validate);
|
||||
validateRef.current = validate;
|
||||
|
||||
const initialValues = useMemo(() => {
|
||||
const values: Record<string, FormValue> = {};
|
||||
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<FormState>({
|
||||
values: initialValues,
|
||||
errors: {},
|
||||
modified: {},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (actionData && typeof actionData === "object" && "errors" in actionData) {
|
||||
const errors = actionData.errors as Record<string, string>;
|
||||
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<string, string> = {};
|
||||
|
||||
// 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<T> =
|
||||
| { success: true; data: T }
|
||||
| { success: false; errors: Record<string, string> };
|
||||
|
||||
export async function validateFormData<T extends Record<string, unknown>>(
|
||||
request: Request,
|
||||
schema: Type<T>,
|
||||
): Promise<ServerValidationResult<T>> {
|
||||
const formData = await request.formData();
|
||||
const parsed: Record<string, unknown> = {};
|
||||
|
||||
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<string, string> = {};
|
||||
for (const [path, problems] of Object.entries(result.flatProblemsByPath)) {
|
||||
errors[path] = problems[0];
|
||||
}
|
||||
return { success: false, errors };
|
||||
}
|
||||
|
||||
return { success: true, data: result as T };
|
||||
}
|
||||
@@ -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<string, string[]>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<Dialog>
|
||||
@@ -41,12 +51,10 @@ export default function AddNameserver({ nameservers }: Props) {
|
||||
<Title className="mb-4">Add nameserver</Title>
|
||||
<input name="action_id" type="hidden" value="add_ns" />
|
||||
<Input
|
||||
{...form.field("ns")}
|
||||
description="Use this IPv4 or IPv6 address to resolve names."
|
||||
invalid={isInvalid}
|
||||
required
|
||||
label="Nameserver"
|
||||
name="ns"
|
||||
onChange={setNs}
|
||||
placeholder="1.2.3.4"
|
||||
/>
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
@@ -67,16 +75,20 @@ export default function AddNameserver({ nameservers }: Props) {
|
||||
</div>
|
||||
<Text className="text-sm">This nameserver will only be used for some domains.</Text>
|
||||
</div>
|
||||
<Switch label="Split DNS" onCheckedChange={setSplit} />
|
||||
<Switch
|
||||
label="Split DNS"
|
||||
onCheckedChange={(checked) => {
|
||||
form.setValue("split_name", checked ? "" : "global");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{split ? (
|
||||
<>
|
||||
<Text className="mt-8 font-semibold">Domain</Text>
|
||||
<Input
|
||||
required={split === true}
|
||||
{...form.field("split_name")}
|
||||
required
|
||||
label="Domain"
|
||||
name="split_name"
|
||||
onChange={setDomain}
|
||||
placeholder="example.com"
|
||||
/>
|
||||
<Text className="text-sm">
|
||||
|
||||
@@ -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 (
|
||||
<Dialog>
|
||||
<Button>Add DNS record</Button>
|
||||
<DialogPanel
|
||||
onSubmit={() => {
|
||||
setName("");
|
||||
setIp("");
|
||||
}}
|
||||
>
|
||||
<DialogPanel onSubmit={() => form.reset()}>
|
||||
<Title>Add DNS record</Title>
|
||||
<Text>Enter the domain and IP address for the new DNS record.</Text>
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
@@ -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) {
|
||||
]}
|
||||
/>
|
||||
<Input
|
||||
{...form.field("record_name")}
|
||||
required
|
||||
label="Domain"
|
||||
placeholder="test.example.com"
|
||||
name="record_name"
|
||||
onChange={setName}
|
||||
invalid={isDuplicate}
|
||||
/>
|
||||
<Input
|
||||
{...form.field("record_value")}
|
||||
required
|
||||
label="IP Address"
|
||||
placeholder={type === "AAAA" ? "2001:db8::ff00:42:8329" : "101.101.101.101"}
|
||||
name="record_value"
|
||||
onChange={setIp}
|
||||
invalid={isDuplicate}
|
||||
placeholder={recordType === "AAAA" ? "2001:db8::ff00:42:8329" : "101.101.101.101"}
|
||||
/>
|
||||
{isDuplicate ? (
|
||||
<p className="text-sm opacity-50">
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<Dialog isOpen={pushDialog} onOpenChange={setPushDialog}>
|
||||
<DialogPanel isDisabled={mkey.length !== 24}>
|
||||
<DialogPanel isDisabled={!form.canSubmit}>
|
||||
<Title>Register Machine Key</Title>
|
||||
<Text className="mb-4">
|
||||
The machine key is given when you run{" "}
|
||||
@@ -37,18 +42,16 @@ export default function NewMachine(data: NewMachineProps) {
|
||||
</Text>
|
||||
<input name="action_id" type="hidden" value="register" />
|
||||
<Input
|
||||
errorMessage="Machine key must be exactly 24 characters"
|
||||
invalid={isMkeyInvalid}
|
||||
{...form.field("register_key")}
|
||||
required
|
||||
label="Machine Key"
|
||||
name="register_key"
|
||||
onChange={setMkey}
|
||||
placeholder="AbCd..."
|
||||
/>
|
||||
<Select
|
||||
required
|
||||
label="Owner"
|
||||
name="user"
|
||||
onValueChange={(v) => form.setValue("user", v)}
|
||||
placeholder="Select a user"
|
||||
items={data.users.map((user) => ({
|
||||
value: user.id,
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { useState } from "react";
|
||||
import { type } from "arktype";
|
||||
|
||||
import Code from "~/components/code";
|
||||
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";
|
||||
import type { Machine } from "~/types";
|
||||
|
||||
const renameSchema = type({
|
||||
name: "string > 0",
|
||||
});
|
||||
|
||||
interface RenameProps {
|
||||
machine: Machine;
|
||||
isOpen: boolean;
|
||||
@@ -15,7 +20,11 @@ interface RenameProps {
|
||||
}
|
||||
|
||||
export default function Rename({ machine, magic, isOpen, setIsOpen }: RenameProps) {
|
||||
const [name, setName] = useState(machine.givenName);
|
||||
const form = useForm({
|
||||
schema: renameSchema,
|
||||
defaultValues: { name: machine.givenName },
|
||||
});
|
||||
const name = form.values.name as string;
|
||||
|
||||
return (
|
||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
@@ -27,14 +36,7 @@ export default function Rename({ machine, magic, isOpen, setIsOpen }: RenameProp
|
||||
</Text>
|
||||
<input name="action_id" type="hidden" value="rename" />
|
||||
<input name="node_id" type="hidden" value={machine.id} />
|
||||
<Input
|
||||
defaultValue={machine.givenName}
|
||||
required
|
||||
label="Machine name"
|
||||
name="name"
|
||||
onChange={setName}
|
||||
placeholder="Machine name"
|
||||
/>
|
||||
<Input {...form.field("name")} required label="Machine name" placeholder="Machine name" />
|
||||
{magic ? (
|
||||
name.length > 0 && name !== machine.givenName ? (
|
||||
<p className="mt-2 text-sm leading-tight text-mist-600 dark:text-mist-300">
|
||||
|
||||
@@ -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 (
|
||||
<Dialog>
|
||||
@@ -45,23 +52,16 @@ export default function AddDomain({ domains, isDisabled }: AddDomainProps) {
|
||||
</Text>
|
||||
<input name="action_id" type="hidden" value="add_domain" />
|
||||
<Input
|
||||
{...form.field("domain")}
|
||||
description={
|
||||
domain.trim().length > 0
|
||||
? `Matches users with <user>@${domain.trim()}`
|
||||
domain.length > 0
|
||||
? `Matches users with <user>@${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 && (
|
||||
<p className="mt-2 text-sm text-red-500">
|
||||
The domain you entered is invalid or already exists in the list.
|
||||
</p>
|
||||
)}
|
||||
</DialogPanel>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<Dialog>
|
||||
@@ -35,19 +41,12 @@ export default function AddGroup({ groups, isDisabled }: AddGroupProps) {
|
||||
</Text>
|
||||
<input name="action_id" type="hidden" value="add_group" />
|
||||
<Input
|
||||
{...form.field("group")}
|
||||
description="The group to allow for OIDC authentication."
|
||||
invalid={group.trim().length === 0 || isInvalid}
|
||||
required
|
||||
label="Group"
|
||||
name="group"
|
||||
onChange={setGroup}
|
||||
placeholder="admin"
|
||||
/>
|
||||
{isInvalid && (
|
||||
<p className="mt-2 text-sm text-red-500">
|
||||
The group you entered already exists in the list of allowed groups.
|
||||
</p>
|
||||
)}
|
||||
</DialogPanel>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<Dialog>
|
||||
@@ -35,19 +41,12 @@ export default function AddUser({ users, isDisabled }: AddUserProps) {
|
||||
</Text>
|
||||
<input name="action_id" type="hidden" value="add_user" />
|
||||
<Input
|
||||
{...form.field("user")}
|
||||
description="The user to allow for OIDC authentication."
|
||||
invalid={user.trim().length === 0 || isInvalid}
|
||||
required
|
||||
label="User"
|
||||
name="user"
|
||||
onChange={setUser}
|
||||
placeholder="john_doe"
|
||||
/>
|
||||
{isInvalid && (
|
||||
<p className="mt-2 text-sm text-red-500">
|
||||
The user you entered already exists in the list of allowed users.
|
||||
</p>
|
||||
)}
|
||||
</DialogPanel>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user