feat: switch to normal form actions for dns

This commit is contained in:
Aarnav Tale 2025-02-13 12:29:16 -05:00
parent 2a1c795d46
commit 5be3cb345e
No known key found for this signature in database
10 changed files with 448 additions and 391 deletions

View File

@ -1,44 +0,0 @@
import { useFetcher } from 'react-router';
import Dialog from '~/components/Dialog';
import Spinner from '~/components/Spinner';
type Properties = {
readonly isEnabled: boolean;
readonly disabled?: boolean;
};
// TODO: Use form action instead of JSON patching
// AND FIX JSON END OF UNEXPECTED INPUT
export default function Modal({ isEnabled, disabled }: Properties) {
const fetcher = useFetcher();
return (
<Dialog>
<Dialog.Button isDisabled={disabled}>
{fetcher.state === 'idle' ? undefined : <Spinner className="w-3 h-3" />}
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Button>
<Dialog.Panel
onSubmit={() => {
fetcher.submit(
{
'dns.magic_dns': !isEnabled,
},
{
method: 'PATCH',
encType: 'application/json',
},
);
}}
>
<Dialog.Title>
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Title>
<Dialog.Text>
Devices will no longer be accessible via your tailnet domain. The
search domain will also be disabled.
</Dialog.Text>
</Dialog.Panel>
</Dialog>
);
}

View File

@ -1,4 +1,3 @@
/* eslint-disable unicorn/no-keyword-prefix */
import { DndContext, DragOverlay, closestCorners } from '@dnd-kit/core'; import { DndContext, DragOverlay, closestCorners } from '@dnd-kit/core';
import { import {
restrictToParentElement, restrictToParentElement,
@ -13,29 +12,25 @@ import {
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { GripVertical, Lock } from 'lucide-react'; import { GripVertical, Lock } from 'lucide-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { type FetcherWithComponents, useFetcher } from 'react-router'; import { type FetcherWithComponents, Form, useFetcher } from 'react-router';
import Button from '~/components/Button'; import Button from '~/components/Button';
import Input from '~/components/Input'; import Input from '~/components/Input';
import Spinner from '~/components/Spinner';
import TableList from '~/components/TableList'; import TableList from '~/components/TableList';
import cn from '~/utils/cn'; import cn from '~/utils/cn';
type Properties = { interface Props {
readonly baseDomain?: string; searchDomains: string[];
readonly searchDomains: string[]; isDisabled: boolean;
readonly disabled?: boolean; // TODO: isDisabled magic?: string;
}; }
export default function Domains({ export default function ManageDomains({
baseDomain,
searchDomains, searchDomains,
disabled, isDisabled,
}: Properties) { magic,
}: Props) {
const [activeId, setActiveId] = useState<number | string | null>(null); const [activeId, setActiveId] = useState<number | string | null>(null);
const [localDomains, setLocalDomains] = useState(searchDomains); const [localDomains, setLocalDomains] = useState(searchDomains);
const [newDomain, setNewDomain] = useState('');
const fetcher = useFetcher();
useEffect(() => { useEffect(() => {
setLocalDomains(searchDomains); setLocalDomains(searchDomains);
@ -77,16 +72,16 @@ export default function Domains({
}} }}
> >
<TableList> <TableList>
{baseDomain ? ( {magic ? (
<TableList.Item key="magic-dns-sd"> <TableList.Item key="magic-dns-sd">
<div <div
className={cn( className={cn(
'flex items-center gap-4', 'flex items-center gap-4',
disabled ? 'flex-row-reverse justify-between w-full' : '', isDisabled ? 'flex-row-reverse justify-between w-full' : '',
)} )}
> >
<Lock className="p-0.5" /> <Lock className="p-0.5" />
<p className="font-mono text-sm py-0.5">{baseDomain}</p> <p className="font-mono text-sm py-0.5">{magic}</p>
</div> </div>
</TableList.Item> </TableList.Item>
) : undefined} ) : undefined}
@ -99,63 +94,49 @@ export default function Domains({
key={sd} key={sd}
domain={sd} domain={sd}
id={index + 1} id={index + 1}
localDomains={localDomains} isDisabled={isDisabled}
disabled={disabled}
fetcher={fetcher}
/> />
))} ))}
<DragOverlay adjustScale> <DragOverlay adjustScale>
{activeId ? ( {activeId ? (
<Domain <Domain
isDrag isDragging
domain={localDomains[(activeId as number) - 1]} domain={localDomains[(activeId as number) - 1]}
localDomains={localDomains}
id={(activeId as number) - 1} id={(activeId as number) - 1}
disabled={disabled} isDisabled={isDisabled}
fetcher={fetcher}
/> />
) : undefined} ) : undefined}
</DragOverlay> </DragOverlay>
</SortableContext> </SortableContext>
{disabled ? undefined : ( {isDisabled ? undefined : (
<TableList.Item key="add-sd"> <TableList.Item key="add-sd">
<Input <Form
type="text" method="POST"
className={cn( className="flex items-center justify-between w-full"
'border-none font-mono p-0', >
'rounded-none focus:ring-0 w-full', <input type="hidden" name="action_id" value="add_domain" />
)} <Input
placeholder="Search Domain" type="text"
onChange={setNewDomain} className={cn(
label="Search Domain" 'border-none font-mono p-0 text-sm',
labelHidden 'rounded-none focus:ring-0 w-full ml-1',
/> )}
{fetcher.state === 'idle' ? ( placeholder="Search Domain"
label="Search Domain"
name="domain"
labelHidden
isRequired
/>
<Button <Button
type="submit"
className={cn( className={cn(
'px-2 py-1 rounded-md', 'px-2 py-1 rounded-md',
'text-blue-500 dark:text-blue-400', 'text-blue-500 dark:text-blue-400',
)} )}
isDisabled={newDomain.length === 0}
onPress={() => {
fetcher.submit(
{
'dns.search_domains': [...localDomains, newDomain],
},
{
method: 'PATCH',
encType: 'application/json',
},
);
setNewDomain('');
}}
> >
Add Add
</Button> </Button>
) : ( </Form>
<Spinner className="w-3 h-3 mr-0" />
)}
</TableList.Item> </TableList.Item>
)} )}
</TableList> </TableList>
@ -164,38 +145,29 @@ export default function Domains({
); );
} }
type DomainProperties = { interface DomainProps {
readonly domain: string; domain: string;
readonly id: number; id: number;
readonly isDrag?: boolean; isDragging?: boolean;
readonly localDomains: string[]; isDisabled: boolean;
readonly disabled?: boolean; // TODO: isDisabled }
readonly fetcher: FetcherWithComponents<unknown>;
};
function Domain({ function Domain({ domain, id, isDragging, isDisabled }: DomainProps) {
domain,
id,
localDomains,
isDrag,
disabled,
fetcher,
}: DomainProperties) {
const { const {
attributes, attributes,
listeners, listeners,
setNodeRef, setNodeRef,
transform, transform,
transition, transition,
isDragging, isDragging: isSortableDragging,
} = useSortable({ id }); } = useSortable({ id });
return ( return (
<TableList.Item <TableList.Item
ref={setNodeRef} ref={setNodeRef}
className={cn( className={cn(
isDragging ? 'opacity-50' : '', isSortableDragging ? 'opacity-50' : '',
isDrag ? 'ring bg-white dark:bg-headplane-900' : '', isDragging ? 'ring bg-white dark:bg-headplane-900' : '',
)} )}
style={{ style={{
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
@ -203,34 +175,30 @@ function Domain({
}} }}
> >
<p className="font-mono text-sm flex items-center gap-4"> <p className="font-mono text-sm flex items-center gap-4">
{disabled ? undefined : ( {isDisabled ? undefined : (
<GripVertical {...attributes} {...listeners} className="p-0.5" /> <GripVertical
{...attributes}
{...listeners}
className="p-0.5 focus:ring outline-none rounded-md"
/>
)} )}
{domain} {domain}
</p> </p>
{isDrag ? undefined : ( {isDragging ? undefined : (
<Button <Form method="POST">
className={cn( <input type="hidden" name="action_id" value="remove_domain" />
'px-2 py-1 rounded-md', <input type="hidden" name="domain" value={domain} />
'text-red-500 dark:text-red-400', <Button
)} type="submit"
isDisabled={disabled} isDisabled={isDisabled}
onPress={() => { className={cn(
fetcher.submit( 'px-2 py-1 rounded-md',
{ 'text-red-500 dark:text-red-400',
'dns.search_domains': localDomains.filter( )}
(_, index) => index !== id - 1, >
), Remove
}, </Button>
{ </Form>
method: 'PATCH',
encType: 'application/json',
},
);
}}
>
Remove
</Button>
)} )}
</TableList.Item> </TableList.Item>
); );

View File

@ -1,16 +1,16 @@
import { useSubmit } from 'react-router'; import { Form } from 'react-router';
import Button from '~/components/Button'; import Button from '~/components/Button';
import Link from '~/components/Link'; import Link from '~/components/Link';
import TableList from '~/components/TableList'; import TableList from '~/components/TableList';
import cn from '~/utils/cn'; import cn from '~/utils/cn';
import AddNameserver from '../dialogs/nameserver'; import AddNS from '../dialogs/add-ns';
interface Props { interface Props {
nameservers: Record<string, string[]>; nameservers: Record<string, string[]>;
isDisabled: boolean; isDisabled: boolean;
} }
export default function Nameservers({ nameservers, isDisabled }: Props) { export default function ManageNS({ nameservers, isDisabled }: Props) {
return ( return (
<div className="flex flex-col w-2/3"> <div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Nameservers</h1> <h1 className="text-2xl font-medium mb-4">Nameservers</h1>
@ -35,7 +35,7 @@ export default function Nameservers({ nameservers, isDisabled }: Props) {
/> />
))} ))}
{isDisabled ? undefined : <AddNameserver nameservers={nameservers} />} {isDisabled ? undefined : <AddNS nameservers={nameservers} />}
</div> </div>
</div> </div>
); );
@ -54,7 +54,6 @@ function NameserverList({
nameservers, nameservers,
name, name,
}: ListProps) { }: ListProps) {
const submit = useSubmit();
const list = isGlobal ? nameservers.global : nameservers[name]; const list = isGlobal ? nameservers.global : nameservers[name];
if (list.length === 0) { if (list.length === 0) {
return null; return null;
@ -69,46 +68,28 @@ function NameserverList({
</div> </div>
<TableList> <TableList>
{list.length > 0 {list.length > 0
? list.map((ns, index) => ( ? list.map((ns) => (
<TableList.Item key={ns}> <TableList.Item key={ns}>
<p className="font-mono text-sm">{ns}</p> <p className="font-mono text-sm">{ns}</p>
<Button <Form method="POST">
className={cn( <input type="hidden" name="action_id" value="remove_ns" />
'px-2 py-1 rounded-md', <input type="hidden" name="ns" value={ns} />
'text-red-500 dark:text-red-400', <input
)} type="hidden"
isDisabled={isDisabled} name="split_name"
onPress={() => { value={isGlobal ? 'global' : name}
if (isGlobal) { />
submit( <Button
{ isDisabled={isDisabled}
'dns.nameservers.global': list.filter( type="submit"
(_, i) => i !== index, className={cn(
), 'px-2 py-1 rounded-md',
}, 'text-red-500 dark:text-red-400',
{ )}
method: 'PATCH', >
encType: 'application/json', Remove
}, </Button>
); </Form>
} else {
submit(
{
'dns.nameservers.split': {
...nameservers,
[name]: list.filter((_, i) => i !== index),
},
},
{
method: 'PATCH',
encType: 'application/json',
},
);
}
}}
>
Remove
</Button>
</TableList.Item> </TableList.Item>
)) ))
: undefined} : undefined}

View File

@ -1,19 +1,17 @@
import { useSubmit } from 'react-router'; import { Form } from 'react-router';
import Button from '~/components/Button'; import Button from '~/components/Button';
import Code from '~/components/Code'; import Code from '~/components/Code';
import Link from '~/components/Link'; import Link from '~/components/Link';
import TableList from '~/components/TableList'; import TableList from '~/components/TableList';
import cn from '~/utils/cn'; import cn from '~/utils/cn';
import AddDNS from '../dialogs/dns'; import AddRecord from '../dialogs/add-record';
interface Props { interface Props {
records: { name: string; type: 'A'; value: string }[]; records: { name: string; type: 'A' | string; value: string }[];
isDisabled: boolean; isDisabled: boolean;
} }
export default function DNS({ records, isDisabled }: Props) { export default function ManageRecords({ records, isDisabled }: Props) {
const submit = useSubmit();
return ( return (
<div className="flex flex-col w-2/3"> <div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">DNS Records</h1> <h1 className="text-2xl font-medium mb-4">DNS Records</h1>
@ -50,34 +48,27 @@ export default function DNS({ records, isDisabled }: Props) {
</div> </div>
<p className="font-mono text-sm">{record.value}</p> <p className="font-mono text-sm">{record.value}</p>
</div> </div>
<Button <Form method="POST">
className={cn( <input type="hidden" name="action_id" value="remove_record" />
'px-2 py-1 rounded-md', <input type="hidden" name="record_name" value={record.name} />
'text-red-500 dark:text-red-400', <input type="hidden" name="record_type" value={record.type} />
)} <Button
isDisabled={isDisabled} type="submit"
onPress={() => { isDisabled={isDisabled}
submit( className={cn(
{ 'px-2 py-1 rounded-md',
'dns.extra_records': records.filter( 'text-red-500 dark:text-red-400',
(_, i) => i !== index, )}
), >
}, Remove
{ </Button>
method: 'PATCH', </Form>
encType: 'application/json',
},
);
}}
>
Remove
</Button>
</TableList.Item> </TableList.Item>
)) ))
)} )}
</TableList> </TableList>
{isDisabled ? undefined : <AddDNS records={records} />} {isDisabled ? undefined : <AddRecord records={records} />}
</div> </div>
</div> </div>
); );

View File

@ -1,22 +1,13 @@
import { useState } from 'react';
import { useFetcher } from 'react-router';
import Code from '~/components/Code'; import Code from '~/components/Code';
import Dialog from '~/components/Dialog'; import Dialog from '~/components/Dialog';
import Input from '~/components/Input'; import Input from '~/components/Input';
import Spinner from '~/components/Spinner';
import cn from '~/utils/cn';
type Properties = { interface Props {
readonly name: string; name: string;
readonly disabled?: boolean; isDisabled: boolean;
}; }
// TODO: Switch to form submit instead of JSON patch
export default function Modal({ name, disabled }: Properties) {
const [newName, setNewName] = useState(name);
const fetcher = useFetcher();
export default function RenameTailnet({ name, isDisabled }: Props) {
return ( return (
<div className="flex flex-col w-2/3 gap-y-4"> <div className="flex flex-col w-2/3 gap-y-4">
<h1 className="text-2xl font-medium mb-2">Tailnet Name</h1> <h1 className="text-2xl font-medium mb-2">Tailnet Name</h1>
@ -35,34 +26,20 @@ export default function Modal({ name, disabled }: Properties) {
}} }}
/> />
<Dialog> <Dialog>
<Dialog.Button isDisabled={disabled}> <Dialog.Button isDisabled={isDisabled}>Rename Tailnet</Dialog.Button>
{fetcher.state === 'idle' ? undefined : ( <Dialog.Panel isDisabled={isDisabled}>
<Spinner className="w-3 h-3" />
)}
Rename Tailnet
</Dialog.Button>
<Dialog.Panel
onSubmit={() => {
fetcher.submit(
{
'dns.base_domain': newName,
},
{
method: 'PATCH',
encType: 'application/json',
},
);
}}
>
<Dialog.Title>Rename Tailnet</Dialog.Title> <Dialog.Title>Rename Tailnet</Dialog.Title>
<Dialog.Text> <Dialog.Text className="mb-8">
Keep in mind that changing this can lead to all sorts of unexpected Keep in mind that changing this can lead to all sorts of unexpected
behavior and may break existing devices in your tailnet. behavior and may break existing devices in your tailnet.
</Dialog.Text> </Dialog.Text>
<input type="hidden" name="action_id" value="rename_tailnet" />
<Input <Input
isRequired
label="Tailnet name" label="Tailnet name"
placeholder="ts.net" placeholder="ts.net"
onChange={setNewName} defaultValue={name}
name="new_name"
/> />
</Dialog.Panel> </Dialog.Panel>
</Dialog> </Dialog>

View File

@ -0,0 +1,31 @@
import Dialog from '~/components/Dialog';
interface Props {
isEnabled: boolean;
isDisabled: boolean;
}
export default function Modal({ isEnabled, isDisabled }: Props) {
return (
<Dialog>
<Dialog.Button isDisabled={isDisabled}>
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Button>
<Dialog.Panel isDisabled={isDisabled}>
<Dialog.Title>
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Title>
<Dialog.Text>
Devices will no longer be accessible via your tailnet domain. The
search domain will also be disabled.
</Dialog.Text>
<input type="hidden" name="action_id" value="toggle_magic" />
<input
type="hidden"
name="new_state"
value={isEnabled ? 'disabled' : 'enabled'}
/>
</Dialog.Panel>
</Dialog>
);
}

View File

@ -1,8 +1,6 @@
import { RepoForkedIcon } from '@primer/octicons-react'; import { RepoForkedIcon } from '@primer/octicons-react';
import { useState } from 'react'; import { useMemo, useState } from 'react';
import { useSubmit } from 'react-router';
import Chip from '~/components/Chip'; import Chip from '~/components/Chip';
import Dialog from '~/components/Dialog'; import Dialog from '~/components/Dialog';
import Input from '~/components/Input'; import Input from '~/components/Input';
import Switch from '~/components/Switch'; import Switch from '~/components/Switch';
@ -14,69 +12,40 @@ interface Props {
} }
export default function AddNameserver({ nameservers }: Props) { export default function AddNameserver({ nameservers }: Props) {
const submit = useSubmit();
const [split, setSplit] = useState(false); const [split, setSplit] = useState(false);
const [ns, setNs] = useState(''); const [ns, setNs] = useState('');
const [domain, setDomain] = useState(''); const [domain, setDomain] = useState('');
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;
if (split) {
return nameservers[domain]?.includes(ns);
}
return Object.values(nameservers).some((nsList) => nsList.includes(ns));
}, [nameservers, ns]);
return ( return (
<Dialog> <Dialog>
<Dialog.Button>Add nameserver</Dialog.Button> <Dialog.Button>Add nameserver</Dialog.Button>
<Dialog.Panel <Dialog.Panel>
onSubmit={(event) => { <Dialog.Title className="mb-4">Add nameserver</Dialog.Title>
event.preventDefault(); <input type="hidden" name="action_id" value="add_ns" />
if (!ns) return;
if (split) {
const splitNs: Record<string, string[]> = {};
for (const [key, value] of Object.entries(nameservers)) {
if (key === 'global') continue;
splitNs[key] = value;
}
if (Object.keys(splitNs).includes(domain)) {
splitNs[domain].push(ns);
} else {
splitNs[domain] = [ns];
}
submit(
{
'dns.nameservers.split': splitNs,
},
{
method: 'PATCH',
encType: 'application/json',
},
);
} else {
const globalNs = nameservers.global;
globalNs.push(ns);
submit(
{
'dns.nameservers.global': globalNs,
},
{
method: 'PATCH',
encType: 'application/json',
},
);
}
setNs('');
setDomain('');
setSplit(false);
}}
>
<Dialog.Title>Add nameserver</Dialog.Title>
<Input <Input
isRequired
label="Nameserver" label="Nameserver"
description="Use this IPv4 or IPv6 address to resolve names." description="Use this IPv4 or IPv6 address to resolve names."
placeholder="1.2.3.4" placeholder="1.2.3.4"
name="ns" name="ns"
onChange={setNs} onChange={setNs}
isInvalid={isInvalid}
/> />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between mt-8">
<div className="block"> <div className="block">
<div className="inline-flex items-center gap-2"> <div className="inline-flex items-center gap-2">
<Dialog.Text className="font-semibold"> <Dialog.Text className="font-semibold">
@ -99,21 +68,16 @@ export default function AddNameserver({ nameservers }: Props) {
This nameserver will only be used for some domains. This nameserver will only be used for some domains.
</Dialog.Text> </Dialog.Text>
</div> </div>
<Switch <Switch label="Split DNS" onChange={setSplit} />
label="Split DNS"
defaultSelected={split}
onChange={() => {
setSplit(!split);
}}
/>
</div> </div>
{split ? ( {split ? (
<> <>
<Dialog.Text className="font-semibold mt-8">Domain</Dialog.Text> <Dialog.Text className="font-semibold mt-8">Domain</Dialog.Text>
<Input <Input
isRequired={split === true}
label="Domain" label="Domain"
placeholder="example.com" placeholder="example.com"
name="domain" name="split_name"
onChange={setDomain} onChange={setDomain}
/> />
<Dialog.Text className="text-sm"> <Dialog.Text className="text-sm">
@ -121,7 +85,9 @@ export default function AddNameserver({ nameservers }: Props) {
should use the nameserver. should use the nameserver.
</Dialog.Text> </Dialog.Text>
</> </>
) : undefined} ) : (
<input type="hidden" name="split_name" value="global" />
)}
</Dialog.Panel> </Dialog.Panel>
</Dialog> </Dialog>
); );

View File

@ -1,15 +1,13 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useSubmit } from 'react-router';
import Code from '~/components/Code'; import Code from '~/components/Code';
import Dialog from '~/components/Dialog'; import Dialog from '~/components/Dialog';
import Input from '~/components/Input'; import Input from '~/components/Input';
interface Props { interface Props {
records: { name: string; type: 'A'; value: string }[]; records: { name: string; type: 'A' | string; value: string }[];
} }
export default function AddDNS({ records }: Props) { export default function AddRecord({ records }: Props) {
const submit = useSubmit();
const [name, setName] = useState(''); const [name, setName] = useState('');
const [ip, setIp] = useState(''); const [ip, setIp] = useState('');
@ -21,44 +19,22 @@ export default function AddDNS({ records }: Props) {
return lookup.value === ip; return lookup.value === ip;
}, [records, name, ip]); }, [records, name, ip]);
// TODO: Ditch useSubmit here (non JSON form)
return ( return (
<Dialog> <Dialog>
<Dialog.Button>Add DNS record</Dialog.Button> <Dialog.Button>Add DNS record</Dialog.Button>
<Dialog.Panel <Dialog.Panel>
onSubmit={(event) => {
event.preventDefault();
if (!name || !ip) return;
setName('');
setIp('');
submit(
{
'dns.extra_records': [
...records,
{
name,
type: 'A',
value: ip,
},
],
},
{
method: 'PATCH',
encType: 'application/json',
},
);
}}
>
<Dialog.Title>Add DNS record</Dialog.Title> <Dialog.Title>Add DNS record</Dialog.Title>
<Dialog.Text> <Dialog.Text>
Enter the domain and IP address for the new DNS record. Enter the domain and IP address for the new DNS record.
</Dialog.Text> </Dialog.Text>
<div className="flex flex-col gap-2 mt-4"> <div className="flex flex-col gap-2 mt-4">
<input type="hidden" name="action_id" value="add_record" />
<input type="hidden" name="record_type" value="A" />
<Input <Input
isRequired isRequired
label="Domain" label="Domain"
placeholder="test.example.com" placeholder="test.example.com"
name="record_name"
onChange={setName} onChange={setName}
isInvalid={isDuplicate} isInvalid={isDuplicate}
/> />
@ -66,7 +42,7 @@ export default function AddDNS({ records }: Props) {
isRequired isRequired
label="IP Address" label="IP Address"
placeholder="101.101.101.101" placeholder="101.101.101.101"
name="ip" name="record_value"
onChange={setIp} onChange={setIp}
isInvalid={isDuplicate} isInvalid={isDuplicate}
/> />

View File

@ -0,0 +1,238 @@
import { ActionFunctionArgs, data } from 'react-router';
import { hs_patchConfig } from '~/utils/config/loader';
import { auth } from '~/utils/sessions.server';
import { hs_getConfig } from '~/utils/state';
export async function dnsAction({ request }: ActionFunctionArgs) {
const session = await auth(request);
if (!session) {
return data({ success: false }, 401);
}
const { mode } = hs_getConfig();
if (mode !== 'rw') {
return data({ success: false }, 403);
}
const formData = await request.formData();
const action = formData.get('action_id')?.toString();
if (!action) {
return data({ success: false }, 400);
}
switch (action) {
case 'rename_tailnet':
return renameTailnet(formData);
case 'toggle_magic':
return toggleMagic(formData);
case 'remove_ns':
return removeNs(formData);
case 'add_ns':
return addNs(formData);
case 'remove_domain':
return removeDomain(formData);
case 'add_domain':
return addDomain(formData);
case 'remove_record':
return removeRecord(formData);
case 'add_record':
return addRecord(formData);
default:
return data({ success: false }, 400);
}
// TODO: Integration update
}
async function renameTailnet(formData: FormData) {
const newName = formData.get('new_name')?.toString();
if (!newName) {
return data({ success: false }, 400);
}
await hs_patchConfig([
{
path: 'dns.base_domain',
value: newName,
},
]);
}
async function toggleMagic(formData: FormData) {
const newState = formData.get('new_state')?.toString();
if (!newState) {
return data({ success: false }, 400);
}
await hs_patchConfig([
{
path: 'dns.magic_dns',
value: newState === 'enabled',
},
]);
}
async function removeNs(formData: FormData) {
const ns = formData.get('ns')?.toString();
const splitName = formData.get('split_name')?.toString();
if (!ns || !splitName) {
return data({ success: false }, 400);
}
const { config, mode } = hs_getConfig();
if (mode !== 'rw') {
return data({ success: false }, 403);
}
if (splitName === 'global') {
const servers = config.dns.nameservers.global.filter((i) => i !== ns);
await hs_patchConfig([
{
path: 'dns.nameservers.global',
value: servers,
},
]);
} else {
const splits = config.dns.nameservers.split;
const servers = splits[splitName].filter((i) => i !== ns);
await hs_patchConfig([
{
path: `dns.nameservers.split."${splitName}"`,
value: servers,
},
]);
}
}
async function addNs(formData: FormData) {
const ns = formData.get('ns')?.toString();
const splitName = formData.get('split_name')?.toString();
if (!ns || !splitName) {
return data({ success: false }, 400);
}
const { config, mode } = hs_getConfig();
if (mode !== 'rw') {
return data({ success: false }, 403);
}
if (splitName === 'global') {
const servers = config.dns.nameservers.global;
servers.push(ns);
await hs_patchConfig([
{
path: 'dns.nameservers.global',
value: servers,
},
]);
} else {
const splits = config.dns.nameservers.split;
const servers = splits[splitName] ?? [];
servers.push(ns);
await hs_patchConfig([
{
path: `dns.nameservers.split."${splitName}"`,
value: servers,
},
]);
}
}
async function removeDomain(formData: FormData) {
const domain = formData.get('domain')?.toString();
if (!domain) {
return data({ success: false }, 400);
}
const { config, mode } = hs_getConfig();
if (mode !== 'rw') {
return data({ success: false }, 403);
}
const domains = config.dns.search_domains.filter((i) => i !== domain);
await hs_patchConfig([
{
path: 'dns.search_domains',
value: domains,
},
]);
}
async function addDomain(formData: FormData) {
const domain = formData.get('domain')?.toString();
if (!domain) {
return data({ success: false }, 400);
}
const { config, mode } = hs_getConfig();
if (mode !== 'rw') {
return data({ success: false }, 403);
}
const domains = config.dns.search_domains;
domains.push(domain);
await hs_patchConfig([
{
path: 'dns.search_domains',
value: domains,
},
]);
}
async function removeRecord(formData: FormData) {
const recordName = formData.get('record_name')?.toString();
const recordType = formData.get('record_type')?.toString();
if (!recordName || !recordType) {
return data({ success: false }, 400);
}
const { config, mode } = hs_getConfig();
if (mode !== 'rw') {
return data({ success: false }, 403);
}
const records = config.dns.extra_records.filter(
(i) => i.name !== recordName || i.type !== recordType,
);
await hs_patchConfig([
{
path: 'dns.extra_records',
value: records,
},
]);
}
async function addRecord(formData: FormData) {
const recordName = formData.get('record_name')?.toString();
const recordType = formData.get('record_type')?.toString();
const recordValue = formData.get('record_value')?.toString();
if (!recordName || !recordType || !recordValue) {
return data({ success: false }, 400);
}
const { config, mode } = hs_getConfig();
if (mode !== 'rw') {
return data({ success: false }, 403);
}
const records = config.dns.extra_records;
records.push({ name: recordName, type: recordType, value: recordValue });
await hs_patchConfig([
{
path: 'dns.extra_records',
value: records,
},
]);
}

View File

@ -1,26 +1,22 @@
import type { ActionFunctionArgs } from 'react-router'; import type { ActionFunctionArgs } from 'react-router';
import { data, useLoaderData } from 'react-router'; import { useLoaderData } from 'react-router';
import Code from '~/components/Code'; import Code from '~/components/Code';
import Notice from '~/components/Notice'; import Notice from '~/components/Notice';
import { loadContext } from '~/utils/config/headplane'; import { hs_getConfig } from '~/utils/state';
import { loadConfig, patchConfig } from '~/utils/config/headscale'; import ManageDomains from './components/manage-domains';
import { getSession } from '~/utils/sessions.server'; import ManageNS from './components/manage-ns';
import ManageRecords from './components/manage-records';
import DNS from './components/dns'; import RenameTailnet from './components/rename-tailnet';
import Domains from './components/domains'; import ToggleMagic from './components/toggle-magic';
import MagicModal from './components/magic'; import { dnsAction } from './dns-actions';
import Nameservers from './components/nameservers';
import RenameModal from './components/rename';
// We do not want to expose every config value // We do not want to expose every config value
export async function loader() { export async function loader() {
const context = await loadContext(); const { config, mode } = hs_getConfig();
if (!context.config.read) { if (mode === 'no') {
throw new Error('No configuration is available'); throw new Error('No configuration is available');
} }
const config = await loadConfig();
const dns = { const dns = {
prefixes: config.prefixes, prefixes: config.prefixes,
magicDns: config.dns.magic_dns, magicDns: config.dns.magic_dns,
@ -33,34 +29,12 @@ export async function loader() {
return { return {
...dns, ...dns,
...context, mode,
}; };
} }
export async function action({ request }: ActionFunctionArgs) { export async function action(data: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie')); return dnsAction(data);
if (!session.has('hsApiKey')) {
return data({ success: false }, { status: 401 });
}
const context = await loadContext();
if (!context.config.write) {
return data({ success: false }, { status: 403 });
}
const textData = await request.text();
if (!textData) {
return data({ success: true });
}
const patch = JSON.parse(textData) as Record<string, unknown>;
await patchConfig(patch);
if (context.integration?.onConfigChange) {
await context.integration.onConfigChange(context.integration.context);
}
return data({ success: true });
} }
export default function Page() { export default function Page() {
@ -72,24 +46,23 @@ export default function Page() {
} }
allNs.global = data.nameservers; allNs.global = data.nameservers;
const isDisabled = data.mode !== 'rw';
return ( return (
<div className="flex flex-col gap-16 max-w-screen-lg"> <div className="flex flex-col gap-16 max-w-screen-lg">
{data.config.write ? undefined : ( {data.mode === 'rw' ? undefined : (
<Notice> <Notice>
The Headscale configuration is read-only. You cannot make changes to The Headscale configuration is read-only. You cannot make changes to
the configuration the configuration
</Notice> </Notice>
)} )}
<RenameModal name={data.baseDomain} disabled={!data.config.write} /> <RenameTailnet name={data.baseDomain} isDisabled={isDisabled} />
<Nameservers nameservers={allNs} isDisabled={!data.config.write} /> <ManageNS nameservers={allNs} isDisabled={isDisabled} />
<ManageRecords records={data.extraRecords} isDisabled={isDisabled} />
<DNS records={data.extraRecords} isDisabled={!data.config.write} /> <ManageDomains
<Domains
baseDomain={data.magicDns ? data.baseDomain : undefined}
searchDomains={data.searchDomains} searchDomains={data.searchDomains}
disabled={!data.config.write} isDisabled={isDisabled}
magic={data.magicDns ? data.baseDomain : undefined}
/> />
<div className="flex flex-col w-2/3"> <div className="flex flex-col w-2/3">
@ -103,7 +76,7 @@ export default function Page() {
</Code>{' '} </Code>{' '}
when Magic DNS is enabled. when Magic DNS is enabled.
</p> </p>
<MagicModal isEnabled={data.magicDns} disabled={!data.config.write} /> <ToggleMagic isEnabled={data.magicDns} isDisabled={isDisabled} />
</div> </div>
</div> </div>
); );